Skip to content

dgreenheck/three-pinata

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

72 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

three-pinata

NPM Version NPM Downloads GitHub Repo stars X (formerly Twitter) Follow YouTube Channel Subscribers

Real-time mesh fracturing and slicing for Three.js

Three-pinata is a library that enables you to fracture and slice 3D meshes in real-time directly in the browser. Whether you're building a destruction physics game, an interactive art piece, or a scientific visualization, three-pinata provides the tools you need to break things beautifully.

Features

  • Voronoi Fracturing - Natural-looking fracture patterns with 3D and 2.5D modes
  • Impact-Based Fracturing - Concentrate fragments around impact points for realistic destruction
  • Refracturing - Fragments can be fractured again for progressive destruction (generation tracking handled externally)
  • Plane Slicing - Slice meshes along arbitrary planes in local or world space
  • Dual Materials - Separate materials for outer surfaces and internal fracture faces
  • Custom Seed Points - Full control over fracture patterns with custom Voronoi seeds
  • UV Mapping - Automatic UV generation for internal faces with configurable scale and offset

Live Demo

Check out the interactive demo: https://three-pinata-demo.vercel.app/

Installation

npm install @dgreenheck/three-pinata

Requires Three.js >= 0.158.0 as a peer dependency.

Quick Start

import * as THREE from "three";
import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

// Setup scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000,
);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Create materials
const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

// Create destructible mesh with both materials
const geometry = new THREE.SphereGeometry(1, 32, 32);
const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

// Fracture the mesh
const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 50,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options, (fragment) => {
  // Setup each fragment (material already set automatically)
  scene.add(fragment);
});

// Hide original mesh
mesh.visible = false;

// Render
renderer.render(scene, camera);

API Reference

Classes

DestructibleMesh

Extends THREE.Mesh with built-in fracturing and slicing capabilities.

Constructor:

new DestructibleMesh(
  geometry?: THREE.BufferGeometry,
  outerMaterial?: THREE.Material,
  innerMaterial?: THREE.Material
)

Parameters:

  • geometry - The geometry for the mesh
  • outerMaterial - Material for the original outer surfaces
  • innerMaterial - Material for newly created internal fracture/slice faces (optional, defaults to outerMaterial)

Methods:

fracture(options, onFragment?, onComplete?)

Fractures the mesh into fragments.

  • Parameters:
    • options: FractureOptions - Fracture configuration
    • onFragment?: (fragment: DestructibleMesh, index: number) => void - Optional callback for each fragment
    • onComplete?: () => void - Optional callback when fracturing is complete
  • Returns: DestructibleMesh[] - Array of fragment meshes
slice(sliceNormal, sliceOrigin, options?, onSlice?, onComplete?)

Slices the mesh along a plane (local space).

  • Parameters:
    • sliceNormal: THREE.Vector3 - Slice plane normal (local space)
    • sliceOrigin: THREE.Vector3 - Point on slice plane (local space)
    • options?: SliceOptions - Slice configuration
    • onSlice?: (piece: DestructibleMesh, index: number) => void - Optional callback for each piece
    • onComplete?: () => void - Optional callback when slicing is complete
  • Returns: DestructibleMesh[] - Array of fragment meshes
sliceWorld(worldNormal, worldOrigin, options?, onSlice?, onComplete?)

Slices the mesh along a plane (world space).

  • Parameters:
    • worldNormal: THREE.Vector3 - Slice plane normal (world space)
    • worldOrigin: THREE.Vector3 - Point on slice plane (world space)
    • options?: SliceOptions - Slice configuration
    • onSlice?: (piece: DestructibleMesh, index: number) => void - Optional callback for each piece
    • onComplete?: () => void - Optional callback when slicing is complete
  • Returns: DestructibleMesh[] - Array of fragment meshes
dispose()

Disposes the mesh geometry and material to free up memory.

  • Parameters: None
  • Returns: void

Options

FractureOptions

Configuration for fracturing operations. Supports both Voronoi and simple plane-based fracturing.

Constructor:

new FractureOptions({
  fractureMethod?: "voronoi" | "simple";
  fragmentCount?: number;
  voronoiOptions?: VoronoiOptions;
  fracturePlanes?: { x: boolean; y: boolean; z: boolean };
  textureScale?: THREE.Vector2;
  textureOffset?: THREE.Vector2;
  seed?: number;
})

Properties:

  • fractureMethod: "voronoi" | "simple" - Fracture method to use (default: "voronoi")
    • "voronoi": Natural-looking fracture using Voronoi tessellation (requires voronoiOptions)
    • "simple": Simple plane-based fracturing (fast, lower quality)
  • fragmentCount: number - Number of fragments to create (default: 50). Note that actual fragment count may be higher when fracturing non-convex meshes.
  • voronoiOptions?: VoronoiOptions - Voronoi-specific options (required when fractureMethod is "voronoi")
  • fracturePlanes: { x: boolean; y: boolean; z: boolean } - Simple fracture: which axes to fracture along (default: all true)
  • textureScale: THREE.Vector2 - UV scale for internal faces (default: 1,1)
  • textureOffset: THREE.Vector2 - UV offset for internal faces (default: 0,0)
  • seed?: number - Random seed for reproducibility

VoronoiOptions

Voronoi-specific fracture configuration (used within FractureOptions).

Interface:

{
  mode: "3D" | "2.5D";
  seedPoints?: THREE.Vector3[];
  impactPoint?: THREE.Vector3;
  impactRadius?: number;
  projectionAxis?: "x" | "y" | "z" | "auto";
  projectionNormal?: THREE.Vector3;
  useApproximation?: boolean;
  approximationNeighborCount?: number;
}

Properties:

  • mode: "3D" | "2.5D" - Voronoi fracture mode (required)
    • "3D": Full 3D Voronoi tessellation - most realistic, slower
    • "2.5D": 2D Voronoi projected through mesh - faster, good for flat objects
  • seedPoints?: THREE.Vector3[] - Custom seed points for Voronoi cells in local space (auto-generated if not provided)
  • impactPoint?: THREE.Vector3 - Impact location in local space to concentrate fragments around
  • impactRadius?: number - Radius around impact point for fragment density
  • projectionAxis?: "x" | "y" | "z" | "auto" - For 2.5D mode: projection axis (default: "auto")
  • projectionNormal?: THREE.Vector3 - For 2.5D mode: optional projection plane normal
  • useApproximation?: boolean - Use K-nearest neighbor approximation for performance (default: false)
    • Warning: May cause fragment overlap when enabled
  • approximationNeighborCount?: number - Neighbors to consider when using approximation (default: 12)

SliceOptions

Configuration for slicing operations.

Constructor:

new SliceOptions();

Properties:

  • textureScale: THREE.Vector2 - UV scale for internal faces (default: 1,1)
  • textureOffset: THREE.Vector2 - UV offset for internal faces (default: 0,0)

Usage Examples

Basic Fracturing

import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options);

fragments.forEach((fragment) => scene.add(fragment));
mesh.visible = false;

Fracturing with Impact Point

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
    impactPoint: new THREE.Vector3(0, 1, 0), // Local space
    impactRadius: 0.5, // Smaller = more concentrated
  },
});

const fragments = mesh.fracture(options);

Refracturing

Fragments can be fractured again for progressive destruction. Generation tracking is handled externally using userData:

// Configuration
const maxGeneration = 3;
const fragmentCounts = {
  1: 32, // First fracture: 32 fragments
  2: 16, // Second fracture: 16 fragments
  3: 8,  // Third fracture: 8 fragments
};

// Initial fracture
const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
mesh.userData.generation = 0; // Track generation externally

const options1 = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: fragmentCounts[1],
  voronoiOptions: {
    mode: "3D",
  },
});

const fragments = mesh.fracture(options1, (fragment) => {
  // Track generation in each fragment
  fragment.userData.generation = 1;
  scene.add(fragment);
});

// Later, refracture a fragment when clicked
function onFragmentClick(fragment: DestructibleMesh) {
  const currentGeneration = fragment.userData.generation || 0;

  // Check generation limit (external refracture control)
  if (currentGeneration >= maxGeneration) {
    return; // Max generation reached
  }

  // Determine fragment count for next generation
  const nextGeneration = currentGeneration + 1;
  const fragmentCount = fragmentCounts[nextGeneration];

  const options = new FractureOptions({
    fractureMethod: "voronoi",
    fragmentCount: fragmentCount,
    voronoiOptions: {
      mode: "3D",
    },
  });

  const newFragments = fragment.fracture(options, (newFragment) => {
    // Track generation externally
    newFragment.userData.generation = nextGeneration;
    scene.add(newFragment);
  });

  // Clean up old fragment
  scene.remove(fragment);
  fragment.geometry.dispose();
}

This external approach gives you complete control over refracture behavior, allowing you to implement custom strategies like:

  • Progressive weakening (fewer fragments per generation)
  • Energy-based refracturing (only if impact force exceeds threshold)
  • Material-based limits (glass refractures more than wood)
  • Performance-based throttling (stop refracturing when FPS drops)

Simple Fracturing

const options = new FractureOptions({
  fractureMethod: "simple",
  fragmentCount: 50,
  fracturePlanes: { x: true, y: true, z: true },
});

const fragments = mesh.fracture(options);

Slicing

// Create mesh with outer and inner materials
const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);

const sliceNormal = new THREE.Vector3(0, 1, 0); // Horizontal cut
const sliceOrigin = new THREE.Vector3(0, 0, 0); // At origin

const options = new SliceOptions();
const pieces = mesh.slice(sliceNormal, sliceOrigin, options);

// Materials are already set automatically
pieces.forEach((piece) => scene.add(piece));
mesh.visible = false;

Using Callbacks

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);

const fragments = mesh.fracture(
  options,
  (fragment, index) => {
    // Called for each fragment (materials already set)
    fragment.castShadow = true;

    // Add physics, apply forces, etc.
    physics.add(fragment, { type: "dynamic" });
  },
  () => {
    // Called when complete
    console.log("Fracturing complete!");
  },
);

Dual Materials

Fragments support two materials - one for the original surface, one for internal fracture faces.

Recommended approach: Pass both materials to the constructor:

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6644 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xdddddd });

// Materials are automatically inherited by fragments
const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);

const fragments = mesh.fracture(options, (fragment) => {
  // Material array [outer, inner] is already set automatically
  scene.add(fragment);
});

Alternative: Manually set materials on each fragment:

const mesh = new DestructibleMesh(geometry, outerMaterial);

const fragments = mesh.fracture(options, (fragment) => {
  // Manually assign material array: [outer, inner]
  fragment.material = [outerMaterial, innerMaterial];
  scene.add(fragment);
});

The fragment geometries include two material groups:

  • Group 0 (materialIndex 0): Original outer surface faces
  • Group 1 (materialIndex 1): Newly created internal fracture faces

Important Requirements

Manifold/Watertight Meshes

The library requires manifold (watertight) meshes - 3D models that form a completely closed, solid volume with no holes or self-intersecting geometry.

Valid meshes:

  • Sphere, cube, cylinder, torus
  • Closed character models
  • Properly modeled objects with no gaps

Invalid meshes:

  • Planes or single-sided surfaces
  • Meshes with holes or missing faces
  • Open-ended cylinders or boxes
  • Overlapping geometry

How to check/fix in Blender:

  1. Select mesh in Edit mode
  2. Mesh > Clean Up > Make Manifold
  3. Use "3D Print Toolbox" addon to check for issues

Why this matters:

  • Fracturing algorithms need to determine "inside" vs "outside"
  • Non-manifold meshes create ambiguity leading to missing faces, holes, and visual artifacts
  • Physics colliders will behave unpredictably with non-manifold fragments

Physics Integration

The library handles geometry only. For realistic destruction, integrate with a physics engine like Rapier.

Basic Physics Pattern

import RAPIER from "@dimforge/rapier3d";

// Initialize physics world
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });

// Add physics to fragments
const fragments = mesh.fracture(options, (fragment) => {
  // Create rigid body
  const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic().setTranslation(
    fragment.position.x,
    fragment.position.y,
    fragment.position.z,
  );
  const rigidBody = world.createRigidBody(rigidBodyDesc);

  // Create convex hull collider
  const vertices = fragment.geometry.getAttribute("position").array;
  const colliderDesc = RAPIER.ColliderDesc.convexHull(vertices)
    .setRestitution(0.3)
    .setFriction(0.5);
  world.createCollider(colliderDesc, rigidBody);
});

// Update physics each frame
function animate() {
  world.step();
  // Sync Three.js objects with physics...
}

For a complete implementation, see:

  • demo/src/physics/PhysicsWorld.ts - Complete physics wrapper
  • demo/src/physics/PhysicsBody.ts - Physics body wrapper
  • demo/src/scenes/SmashingScene.ts - Real-world example

Performance Tips

  • Fragment Count: 10-50 fragments is optimal. 100+ may cause lag on slower devices
  • 2.5D vs 3D: Use 2.5D mode when possible - significantly faster
  • Pre-fracture: Fracture ahead of time and keep fragments hidden for instant destruction
  • Approximation: For high fragment counts (>50), enable useApproximation (may cause overlap)
  • Physics: More fragments = more physics bodies. Despawn fragments after they settle

Limitations

  • Manifold Requirement: Meshes must be watertight (no holes or self-intersecting geometry)
  • Memory: Each fragment is a new geometry. Plan accordingly for many destructible objects
  • Physics Required: Library only handles geometry - you must add physics integration

Building

npm run build:lib

Generates:

  • three-pinata.es.js - ES Module
  • three-pinata.umd.js - UMD Module
  • Type declarations

Running the Demo

npm install
npm run dev

Open http://localhost:5173/

License

This project is licensed under the MIT License.

Acknowledgments

Support

For bugs or feature requests, create an issue in the issue tracker.

Contributing

Contributions welcome! Please submit a Pull Request.

About

Three.js library for fracturing and slicing meshes in real time.

Resources

Stars

Watchers

Forks

Contributors 2

  •  
  •