three-pinata

repository·main·Indexed 19 days ago

https://github.com/dgreenheck/three-pinata

A real-time mesh fracturing and slicing library for Three.js. It enables the creation of destruction physics, interactive art, and scientific visualizations in the browser using Voronoi and plane-based fracturing methods. The library supports dual materials for outer and inner surfaces, progressive refracturing, and integration with physics engines like Rapier. It requires manifold (watertight) meshes for its algorithms to function correctly.

Tokens
14.1K
Snippets
44
Records
51
Agent score
63%

What's inside three-pinata

  1. Configure Dual Materials for Fragments

    main

    Fragments in three-pinata support two materials: one for the original outer surface and one for the newly created internal fracture faces.

    Recommended Approach: Pass both materials to the DestructibleMesh constructor. The library will automatically assign them to the fragments.

    • Group 0 (materialIndex 0): Original outer surface faces.
    • Group 1 (materialIndex 1): Internal fracture faces.
    const outerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6644 });
    const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xdddddd });
    
    const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
    
    const fragments = mesh.fracture(options, (fragment) => {
      scene.add(fragment);
    });

    Manual Approach: If you only pass one material to the constructor, you can manually assign the material array in the fracture callback.

    const mesh = new DestructibleMesh(geometry, outerMaterial);
    
    const fragments = mesh.fracture(options, (fragment) => {
      fragment.material = [outerMaterial, innerMaterial];
      scene.add(fragment);
    });
    // Recommended approach
    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);
    });
  2. Requirement: Manifold (Watertight) Meshes

    main

    The fracturing algorithms require manifold (watertight) meshes. A valid mesh must form a completely closed, solid volume with no holes or self-intersecting geometry.

    Valid Meshes:

    • Spheres, cubes, cylinders, tori
    • 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

    Why this matters: Non-manifold meshes create ambiguity for the algorithms, leading to missing faces, holes, visual artifacts, and unpredictable physics behavior.

  3. Install @dgreenheck/three-pinata

    main

    Install the library via npm. Note that three.js version 0.158.0 or higher is required as a peer dependency.

    npm install @dgreenheck/three-pinata
  4. Performance Optimization Tips

    main

    To maintain high performance during destruction:

    • Fragment Count: Aim for 10-50 fragments. Using 100+ fragments may cause significant lag on slower devices.
    • Use 2.5D: Use mode: "2.5D" in voronoiOptions whenever possible; it is significantly faster than 3D.
    • Pre-fracture: For instant destruction, fracture the mesh ahead of time and keep the fragments hidden until needed.
    • Approximation: For high fragment counts (>50), enable useApproximation in your options (note: this may cause slight overlaps).
    • Physics Management: More fragments mean more physics bodies. Despawn or remove fragments once they have settled to save resources.
  5. Implement Progressive Refracturing

    main

    To achieve progressive destruction (where fragments can be broken down further), you must track the 'generation' of each mesh externally, typically using the userData property. This allows you to control how many times a piece can be fractured and how many fragments it produces at each stage.

    Workflow:

    1. Initialize the base mesh with mesh.userData.generation = 0.
    2. In the .fracture() callback, assign the next generation number to each new fragment.
    3. When a fragment is interacted with, check its generation against a maxGeneration limit before calling .fracture() on it again.
    // 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;
    
    const options1 = new FractureOptions({
      fractureMethod: "voronoi",
      fragmentCount: fragmentCounts[1],
      voronoiOptions: {
        mode: "3D",
      },
    });
    
    const fragments = mesh.fracture(options1, (fragment) => {
      fragment.userData.generation = 1;
      scene.add(fragment);
    });
    
    // Later, refracture a fragment when clicked
    function onFragmentClick(fragment: DestructibleMesh) {
      const currentGeneration = fragment.userData.generation || 0;
    
      if (currentGeneration >= maxGeneration) return;
    
      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) => {
        newFragment.userData.generation = nextGeneration;
        scene.add(newFragment);
      });
    
      scene.remove(fragment);
      fragment.geometry.dispose();
    }
  6. Integrate with Physics (e.g., Rapier)

    main

    The library handles geometry only. To achieve realistic destruction, you must manually integrate the generated fragments with a physics engine like Rapier.

    Basic Integration Pattern:

    1. Initialize your physics world.
    2. In the .fracture() callback, create a rigid body for each fragment.
    3. Create a collider for the fragment using its geometry (e.g., a convexHull).
    4. Update the physics world and sync Three.js objects in your animation loop.
    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...
    }
  7. Quick Start: Fracture a mesh with Voronoi

    main

    To use three-pinata, create a DestructibleMesh by providing a geometry and two materials: one for the outer surface and one for the internal fracture faces. Use FractureOptions to configure the method (e.g., voronoi) and then call .fracture() on the mesh instance.

    import * as THREE from "three";
    import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";
    
    // ... setup scene, camera, renderer ...
    
    // 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;
  8. Configure fracturing with FractureOptions

    main

    Use FractureOptions to define how a mesh should be broken apart. You can choose between voronoi (natural patterns) and simple (fast, plane-based) methods.

    new FractureOptions({
      fractureMethod?: "voronoi" | "simple", // default: "voronoi"
      fragmentCount?: number,                // default: 50
      voronoiOptions?: VoronoiOptions,      // required if method is "voronoi"
      fracturePlanes?: { x: boolean; y: boolean; z: boolean }, // default: all true
      textureScale?: THREE.Vector2,          // default: 1,1
      textureOffset?: THREE.Vector2,         // default: 0,0
      seed?: number,                         // for reproducibility
    });
  9. Configure slicing with SliceOptions

    main

    Use SliceOptions to control the UV mapping of the newly created internal faces during a slice operation.

    new SliceOptions();
    // Properties:
    // textureScale: THREE.Vector2 (default: 1,1)
    // textureOffset: THREE.Vector2 (default: 0,0)
  10. Configure Voronoi fracturing with VoronoiOptions

    main

    When using the voronoi fracture method, provide VoronoiOptions to control the pattern. You can use impactPoint and impactRadius to concentrate fragments around a specific location, simulating a hit.

    // VoronoiOptions 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;
    }
  11. How the Triangulator class works

    main

    The Triangulator uses a Delaunay triangulation approach. The process follows these steps:

    1. Initialization: Projects 3D points onto a 2D plane using a basis derived from the provided normal.
    2. Super Triangle: Adds a large 'super triangle' that encompasses all input points to provide a starting boundary.
    3. Normalization: Scales 2D coordinates uniformly between [0, 1] to improve numerical stability.
    4. Bin Sorting: Sorts points into an ordered grid (bins) to optimize the search for containing triangles.
    5. Point Insertion: Iteratively inserts points into the triangulation, splitting existing triangles and performing edge swaps (via restoreDelauneyTriangulation) to maintain the Delaunay property.
    6. Cleanup: Discards any triangles that share vertices with the initial super triangle, leaving only the triangulation of the original point set.
  12. Slice a Mesh with a Plane

    main

    The .slice() method allows you to cut a DestructibleMesh into pieces using a normal and an origin point.

    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);
    
    pieces.forEach((piece) => scene.add(piece));
    mesh.visible = false;
    // 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);
    
    pieces.forEach((piece) => scene.add(piece));
    mesh.visible = false;