Update Render Mesh Vertices from Ammo.js Soft Body

This article provides a step-by-step guide on how to systematically synchronize a 3D render mesh with the physics calculations of an Ammo.js soft body. You will learn how to map the vertices of a rendering engine’s mesh (such as Three.js) to the nodes of an Ammo.js soft body and update them in real time during the physics simulation loop.

The Challenge of Soft Body Synchronization

Unlike rigid bodies, which only require updating a single transformation matrix (position and rotation) for the entire mesh, soft bodies deform. This means every individual vertex of the render mesh must be updated to match the corresponding node position calculated by the Ammo.js physics engine on every frame.

Because 3D rendering engines often duplicate vertices to handle UV mapping and face normals (resulting in more render vertices than physics nodes), you must establish a systematic mapping system.


Step 1: Establish a Vertex-to-Node Map (Initialization)

During the initialization of your soft body, you must create an association map. This map links the indices of your render mesh’s vertex buffer to the corresponding nodes of the Ammo.js soft body.

If you created your btSoftBody directly from your render geometry helper, you can build a mapping array.

// Arrays to map render vertices to physics nodes
const vertexMapping = [];

// Helper to check if two points are virtually identical
function isClose(v1, v2, tolerance = 0.0001) {
    return v1.distanceTo(v2) < tolerance;
}

const positionAttribute = geometry.attributes.position;
const tempV3 = new THREE.Vector3();

// Iterate through render vertices and match them to unique soft body nodes
for (let i = 0; i < positionAttribute.count; i++) {
    tempV3.fromBufferAttribute(positionAttribute, i);
    
    let matchedNodeIndex = -1;
    for (let j = 0; j < physicsNodes.length; j++) {
        if (isClose(tempV3, physicsNodes[j])) {
            matchedNodeIndex = j;
            break;
        }
    }
    
    // Store the index of the matching node for this render vertex
    vertexMapping.push(matchedNodeIndex);
}

Step 2: Retrieve the Soft Body Nodes

Inside your animation or physics update loop, after stepping the physics world, you must access the soft body’s node array (tNodeArray).

// Step the physics world (usually 60fps)
physicsWorld.stepSimulation(deltaTime, 10);

// Get the soft body's nodes
const nodes = softBody.get_m_nodes();
const numNodes = nodes.size();

Step 3: Update the Render Mesh Vertices

Using the mapping array created during initialization, loop through your render mesh’s vertex buffer and update its coordinates using the corresponding soft body node positions.

const positionAttribute = geometry.attributes.position;
const positions = positionAttribute.array;

for (let i = 0; i < positionAttribute.count; i++) {
    const nodeIndex = vertexMapping[i];
    
    if (nodeIndex !== -1 && nodeIndex < numNodes) {
        const node = nodes.at(nodeIndex);
        const nodePos = node.get_m_x(); // Returns btVector3
        
        const i3 = i * 3;
        positions[i3]     = nodePos.x();
        positions[i3 + 1] = nodePos.y();
        positions[i3 + 2] = nodePos.z();
    }
}

Step 4: Notify the GPU and Recompute Normals

Modifying the vertex buffer array directly in JavaScript does not automatically update the GPU. You must explicitly flag the geometry attribute as dirty. Additionally, because the shape of the mesh has changed, you must recalculate the vertex normals so lighting renders correctly.

// Flag the position attribute for GPU upload
geometry.attributes.position.needsUpdate = true;

// Recompute normals for accurate dynamic lighting
geometry.computeVertexNormals();

Optimization Tip

If your render mesh and your physics soft body mesh have a strict 1:1 index topology (i.e., you are using an indexed geometry with no duplicated vertices), you can skip the search mapping phase. In this scenario, you can write directly to the buffer sequentially:

for (let i = 0; i < numNodes; i++) {
    const node = nodes.at(i);
    const nodePos = node.get_m_x();
    
    const i3 = i * 3;
    positions[i3]     = nodePos.x();
    positions[i3 + 1] = nodePos.y();
    positions[i3 + 2] = nodePos.z();
}