InstancedMesh and Sprite

Great question! Using InstancedMesh for 3D objects is indeed an efficient way to render multiple instances of the same geometry. For sprites, we can achieve a similar optimization using a technique called “sprite batching” or by creating a custom InstancedSprite class. Let’s explore both approaches.

Approach 1: Sprite Batching

This method involves creating a single geometry with multiple quads, each representing a sprite. We’ll use a custom shader to render these quads as sprites.

import * as THREE from 'three';

class InstancedSprites {
    constructor(texture, count) {
        this.texture = texture;
        this.count = count;

        this.geometry = new THREE.InstancedBufferGeometry();
        
        // Base quad
        const baseGeometry = new THREE.BufferGeometry();
        const vertices = new Float32Array([
            -0.5, -0.5, 0,
             0.5, -0.5, 0,
             0.5,  0.5, 0,
            -0.5,  0.5, 0
        ]);
        const uvs = new Float32Array([
            0, 0,
            1, 0,
            1, 1,
            0, 1
        ]);
        const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
        
        baseGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
        baseGeometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
        baseGeometry.setIndex(new THREE.BufferAttribute(indices, 1));

        this.geometry.copy(baseGeometry);

        // Instanced attributes
        const offsets = new Float32Array(count * 3);
        const scales = new Float32Array(count);
        const rotations = new Float32Array(count);

        for (let i = 0; i < count; i++) {
            offsets[i * 3] = (Math.random() - 0.5) * 10;
            offsets[i * 3 + 1] = (Math.random() - 0.5) * 10;
            offsets[i * 3 + 2] = (Math.random() - 0.5) * 10;
            scales[i] = Math.random() * 0.5 + 0.5;
            rotations[i] = Math.random() * Math.PI * 2;
        }

        this.geometry.setAttribute('offset', new THREE.InstancedBufferAttribute(offsets, 3));
        this.geometry.setAttribute('scale', new THREE.InstancedBufferAttribute(scales, 1));
        this.geometry.setAttribute('rotation', new THREE.InstancedBufferAttribute(rotations, 1));

        this.material = new THREE.ShaderMaterial({
            uniforms: {
                map: { value: texture }
            },
            vertexShader: `
                attribute vec3 offset;
                attribute float scale;
                attribute float rotation;
                varying vec2 vUv;
                void main() {
                    vUv = uv;
                    vec2 pos = position.xy;
                    float c = cos(rotation);
                    float s = sin(rotation);
                    pos = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c);
                    pos *= scale;
                    vec4 mvPosition = modelViewMatrix * vec4(offset + vec3(pos, 0.0), 1.0);
                    gl_Position = projectionMatrix * mvPosition;
                }
            `,
            fragmentShader: `
                uniform sampler2D map;
                varying vec2 vUv;
                void main() {
                    gl_FragColor = texture2D(map, vUv);
                    if (gl_FragColor.a < 0.5) discard;
                }
            `,
            transparent: true
        });

        this.mesh = new THREE.Mesh(this.geometry, this.material);
    }

    update() {
        // You can update positions, scales, rotations here if needed
    }
}

// Usage
const loader = new THREE.TextureLoader();
loader.load('path/to/your/sprite/texture.png', (texture) => {
    const instancedSprites = new InstancedSprites(texture, 1000);
    scene.add(instancedSprites.mesh);

    function animate() {
        requestAnimationFrame(animate);
        instancedSprites.update();
        renderer.render(scene, camera);
    }
    animate();
});

This implementation creates a custom InstancedSprites class that uses instanced buffer geometry to render multiple sprites efficiently. Here’s a breakdown of how it works:

  1. We create a base quad geometry for a single sprite.
  2. We use instanced attributes for position (offset), scale, and rotation of each sprite.
  3. A custom shader is used to position and rotate each instance of the sprite.
  4. The fragment shader samples from the provided texture and discards transparent pixels.

To use this in your Three.js scene:

  1. Load your sprite texture.
  2. Create an instance of InstancedSprites with the texture and desired count.
  3. Add the resulting mesh to your scene.
  4. Call the update method in your animation loop if you need to change sprite properties over time.

Approach 2: Custom InstancedSprite class

Alternatively, you could create a custom InstancedSprite class that extends Three.js’s InstancedMesh:

import * as THREE from 'three';

class InstancedSprite extends THREE.InstancedMesh {
    constructor(texture, count) {
        const geometry = new THREE.PlaneGeometry(1, 1);
        const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
        super(geometry, material, count);

        this.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    }

    setPositionAt(index, position) {
        const matrix = new THREE.Matrix4();
        matrix.setPosition(position);
        this.setMatrixAt(index, matrix);
    }

    setScaleAt(index, scale) {
        const matrix = new THREE.Matrix4();
        this.getMatrixAt(index, matrix);
        matrix.scale(new THREE.Vector3(scale, scale, 1));
        this.setMatrixAt(index, matrix);
    }

    setRotationAt(index, rotation) {
        const matrix = new THREE.Matrix4();
        this.getMatrixAt(index, matrix);
        const position = new THREE.Vector3();
        const quaternion = new THREE.Quaternion();
        const scale = new THREE.Vector3();
        matrix.decompose(position, quaternion, scale);
        quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), rotation);
        matrix.compose(position, quaternion, scale);
        this.setMatrixAt(index, matrix);
    }

    update() {
        this.instanceMatrix.needsUpdate = true;
    }
}

// Usage
const loader = new THREE.TextureLoader();
loader.load('path/to/your/sprite/texture.png', (texture) => {
    const instancedSprite = new InstancedSprite(texture, 1000);
    scene.add(instancedSprite);

    // Set initial positions, scales, rotations
    for (let i = 0; i < 1000; i++) {
        instancedSprite.setPositionAt(i, new THREE.Vector3(
            (Math.random() - 0.5) * 10,
            (Math.random() - 0.5) * 10,
            (Math.random() - 0.5) * 10
        ));
        instancedSprite.setScaleAt(i, Math.random() * 0.5 + 0.5);
        instancedSprite.setRotationAt(i, Math.random() * Math.PI * 2);
    }

    function animate() {
        requestAnimationFrame(animate);
        instancedSprite.update();
        renderer.render(scene, camera);
    }
    animate();
});

This approach extends Three.js’s InstancedMesh class to work specifically with sprites. It provides methods to easily set position, scale, and rotation for each instance.

Both approaches will give you efficient rendering of multiple sprites. The first approach gives you more control over the rendering process and may be more performant for very large numbers of sprites, while the second approach is easier to integrate with existing Three.js code and provides a more familiar API.

I hope this can be helpful to your question.
Thanks.

2 Likes