GPU Billboard for meshes and sprites

Hi, I used entity.lookAt(camera) for quite a long time, but it is not very good when there are many things we need to billboard, or many cameras. So I made a GPU implementation, which would be much more performant even for a large number of meshes. It just modifies transformVS chunk of the material to always look at the active camera, so we don’t have to send any additional data to the shader every tick. It works for render and sprite components’ materials. We can choose wether we want to clone material only for this mesh instance. Didn’t test it a lot yet, so there are might be issues in some cases, but generally it just works.

class BillboardMesh extends pc.ScriptType {
    initialize() {
        const renders = this.entity.findComponents('render');
        const sprites = this.entity.findComponents('sprite');
        const materials = [];
        const meshInstanceByMaterial = new Map();

        for (const render of renders) {
            for (const meshInstance of render.meshInstances) {
                const material = meshInstance.material;
                if (material instanceof pc.StandardMaterial) {
                    materials.push(material);
                    meshInstanceByMaterial.set(material, meshInstance);
                }
            }
        }
        for (const sprite of sprites) {
            const material = sprite.material;
            if (material instanceof pc.StandardMaterial) {
                materials.push(material);
                meshInstanceByMaterial.set(material, sprite);
            }
        }

        materials.forEach(material => {
            if (material.isBillboard) {
                return;
            }

            if (this.cloneMaterial) {
                const clone = material.clone();
                clone.setDefine('LINEAR_DEPTH', true);
                clone.update();
                const meshInstance = meshInstanceByMaterial.get(material);
                if (meshInstance) {
                    meshInstance.material = clone;
                }
                material = clone;
            }

            material.setDefine('LINEAR_DEPTH', true);

            material.chunks.transformVS = `
                varying float vLinearDepth;

                vec4 getPosition() {
                    dModelMatrix = getModelMatrix();

                    // Extract world position from model matrix
                    vec3 worldPos = (dModelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz;

                    // Extract scale from model matrix
                    vec3 scale;
                    scale.x = length(dModelMatrix[0].xyz);
                    scale.y = length(dModelMatrix[1].xyz);
                    scale.z = length(dModelMatrix[2].xyz);

                    // Billboard axes from view matrix
                    vec3 right = vec3(matrix_view[0][0], matrix_view[1][0], matrix_view[2][0]);
                    vec3 up = vec3(matrix_view[0][1], matrix_view[1][1], matrix_view[2][1]);

                    // Apply mesh scale to vertex position
                    vec3 scaledVertex = vec3(vertex_position.x * scale.x, vertex_position.y * scale.y, vertex_position.z * scale.z);

                    // Billboard position, preserving mesh scale
                    vec3 billboardPos = worldPos 
                        + right * scaledVertex.x
                        + up * scaledVertex.y;

                    dPositionW = billboardPos;

                    return matrix_viewProjection * vec4(billboardPos, 1.0);
                }

                vec3 getWorldPosition() {
                    return dPositionW;
                }
            `;
            material.update();
            material.isBillboard = true;
        });
    }
}

pc.registerScript(BillboardMesh);

BillboardMesh.attributes.add('cloneMaterial', {
    type: 'boolean',
    default: false,
    title: 'Clone Material',
});
2 Likes