Creating Dynamic Terrain Deformation with React Three Fiber

In this tutorial, we will explore how to dynamically deform terrain, a feature widely used in modern games. Some time ago, we learned about how to create the PS1 jitter shader, taking a nostalgic journey into retro graphics. Transitioning from that retro vibe to cutting-edge techniques has been exciting to me, and I’m happy to see so much interest in these topics.

This tutorial will be divided into two parts. In the first part, we’ll focus on Dynamic Terrain Deformation, exploring how to create and manipulate terrain interactively. In the second part, we’ll take it a step further by creating an unlimited walking zone using the generated pieces, all while maintaining optimal performance.

Building Interactive Terrain Deformation Step by Step

After setting up the scene, we’ll create a planeGeometry and apply the snow texture obtained from AmbientCG. To enhance realism, we’ll increase the displacementScale value, creating a more dynamic and lifelike snowy environment. We’ll dive into CHUNKs later in the tutorial.

const [colorMap, normalMap, roughnessMap, aoMap, displacementMap] = useTexture([ "/textures/snow/snow-color.jpg", "/textures/snow/snow-normal-gl.jpg", "/textures/snow/snow-roughness.jpg", "/textures/snow/snow-ambientocclusion.jpg", "/textures/snow/snow-displacement.jpg", ]); return <mesh rotation={[-Math.PI / 2, 0, 0]} // Rotate to make it horizontal position={[chunk.x * CHUNK_SIZE, 0, chunk.z * CHUNK_SIZE]} > <planeGeometry args={[ CHUNK_SIZE + CHUNK_OVERLAP * 2, CHUNK_SIZE + CHUNK_OVERLAP * 2, GRID_RESOLUTION, GRID_RESOLUTION, ]} /> <meshStandardMaterial map={colorMap} normalMap={normalMap} roughnessMap={roughnessMap} aoMap={aoMap} displacementMap={displacementMap} displacementScale={2} /> </mesh> ))} 

After creating the planeGeometry, we’ll explore the deformMesh function—the core of this demo.

const deformMesh = useCallback( (mesh, point) => { if (!mesh) return; // Retrieve neighboring chunks around the point of deformation. const neighboringChunks = getNeighboringChunks(point, chunksRef); // Temporary vector to hold vertex positions during calculations const tempVertex = new THREE.Vector3(); // Array to keep track of geometries that require normal recomputation const geometriesToUpdate = []; // Iterate through each neighboring chunk to apply deformations neighboringChunks.forEach((chunk) => { const geometry = chunk.geometry; // Validate that the chunk has valid geometry and position attributes if (!geometry || !geometry.attributes || !geometry.attributes.position) return; const positionAttribute = geometry.attributes.position; const vertices = positionAttribute.array; // Flag to determine if the current chunk has been deformed let hasDeformation = false; // Loop through each vertex in the chunk's geometry for (let i = 0; i < positionAttribute.count; i++) { // Extract the current vertex's position from the array tempVertex.fromArray(vertices, i * 3); // Convert the vertex position from local to world coordinates chunk.localToWorld(tempVertex); // Calculate the distance between the vertex and the point of influence const distance = tempVertex.distanceTo(point); // Check if the vertex is within the deformation radius if (distance < DEFORM_RADIUS) { // Calculate the influence of the deformation based on distance. // The closer the vertex is to the point, the greater the influence. // Using a cubic falloff for a smooth transition. const influence = Math.pow( (DEFORM_RADIUS - distance) / DEFORM_RADIUS, 3 ); // Calculate the vertical offset (y-axis) to apply to the vertex. // This creates a depression effect that simulates impact or footprint. const yOffset = influence * 10; tempVertex.y -= yOffset * Math.sin((distance / DEFORM_RADIUS) * Math.PI); // Add a wave effect to the vertex's y-position. // This simulates ripples or disturbances caused by the deformation. tempVertex.y += WAVE_AMPLITUDE * Math.sin(WAVE_FREQUENCY * distance); // Convert the modified vertex position back to local coordinates chunk.worldToLocal(tempVertex); // Update the vertex position in the geometry's position array tempVertex.toArray(vertices, i * 3); // Mark that this chunk has undergone deformation hasDeformation = true; } } // If any vertex in the chunk was deformed, update the geometry accordingly if (hasDeformation) { // Indicate that the position attribute needs to be updated positionAttribute.needsUpdate = true; // Add the geometry to the list for batch normal recomputation geometriesToUpdate.push(geometry); // Save the deformation state for potential future use or persistence saveChunkDeformation(chunk); } }); // After processing all neighboring chunks, recompute the vertex normals // for each affected geometry. This ensures that lighting and shading // accurately reflect the new geometry after deformation. if (geometriesToUpdate.length > 0) { geometriesToUpdate.forEach((geometry) => geometry.computeVertexNormals()); } }, [ getNeighboringChunks, chunksRef, saveChunkDeformation, ]
);

I added the “Add a subtle wave effect for visual variation” part to this function to address an issue that was limiting the natural appearance of the snow as the track formed. The edges of the snow needed to bulge slightly. Here’s what it looked like before I added it:

After creating the deformMesh function, we’ll determine where to use it to complete the Dynamic Terrain Deformation. Specifically, we’ll integrate it into useFrame, selecting the right and left foot bones in the character animation and extracting their positions from matrixWorld.

useFrame((state, delta) => { // Other codes... // Get the bones representing the character's left and right feet const leftFootBone = characterRef.current.getObjectByName("mixamorigLeftFoot"); const rightFootBone = characterRef.current.getObjectByName("mixamorigRightFoot"); if (leftFootBone) { // Get the world position of the left foot bone tempVector.setFromMatrixPosition(leftFootBone.matrixWorld); // Apply terrain deformation at the position of the left foot deformMesh(activeChunk, tempVector); } if (rightFootBone) { // Get the world position of the right foot bone tempVector.setFromMatrixPosition(rightFootBone.matrixWorld); // Apply terrain deformation at the position of the right foot deformMesh(activeChunk, tempVector); } // Other codes...
});

And there you have it: a smooth, dynamic deformation in action!

Cool moon-walking
Uncool walking

Unlimited Walking with CHUNKs

In the code we’ve explored so far, you might have noticed the CHUNK parts. In simple terms, we create snow blocks arranged in a 3×3 grid. To ensure the character always stays in the center, we remove the previous CHUNKs based on the direction the character is moving and generate new CHUNKs ahead in the same direction. You can see this process in action in the GIF below. However, this method introduced several challenges.

Problems:

  • Gaps appear at the joints between CHUNKs
  • Vertex calculations are disrupted at the joints
  • Tracks from the previous CHUNK vanish instantly when transitioning to a new CHUNK

Solutions:

1. getChunkKey

// Generates a unique key for a chunk based on its current position.
// Uses globally accessible CHUNK_SIZE for calculations.
// Purpose: Ensures each chunk can be uniquely identified and managed in a Map. const deformedChunksMapRef = useRef(new Map()); const getChunkKey = () => `${Math.round(currentChunk.position.x / CHUNK_SIZE)},${Math.round(currentChunk.position.z / CHUNK_SIZE)}`;

2. saveChunkDeformation

// Saves the deformation state of the current chunk by storing its vertex positions.
// Purpose: Preserves the deformation of a chunk for later retrieval.
const saveChunkDeformation = () => { if (!currentChunk) return; // Generate the unique key for this chunk const chunkKey = getChunkKey(); // Save the current vertex positions into the deformation map const position = currentChunk.geometry.attributes.position; deformedChunksMapRef.current.set( chunkKey, new Float32Array(position.array) );
};

3. loadChunkDeformation

// Restores the deformation state of the current chunk, if previously saved. // Purpose: Ensures that deformed chunks retain their state when repositioned. const loadChunkDeformation = () => { if (!currentChunk) return false; // Retrieve the unique key for this chunk const chunkKey = getChunkKey(); // Get the saved deformation data for this chunk const savedDeformation = deformedChunksMapRef.current.get(chunkKey); if (savedDeformation) { const position = currentChunk.geometry.attributes.position; // Restore the saved vertex positions position.array.set(savedDeformation); position.needsUpdate = true; currentChunk.geometry.computeVertexNormals(); return true; } return false;
};

4. getNeighboringChunks

// Finds chunks that are close to the current position.
// Purpose: Limits deformation operations to only relevant chunks, improving performance. const getNeighboringChunks = () => { return chunksRef.current.filter((chunk) => { // Calculate the distance between the chunk and the current position const distance = new THREE.Vector2( chunk.position.x - currentPosition.x, chunk.position.z - currentPosition.z ).length(); // Include chunks within the deformation radius return distance < CHUNK_SIZE + DEFORM_RADIUS; });
};

5. recycleDistantChunks

// Recycles chunks that are too far from the character by resetting their deformation state.
// Purpose: Prepares distant chunks for reuse, maintaining efficient resource usage. const recycleDistantChunks = () => { chunksRef.current.forEach((chunk) => { // Calculate the distance between the chunk and the character const distance = new THREE.Vector2( chunk.position.x - characterPosition.x, chunk.position.z - characterPosition.z ).length(); // If the chunk is beyond the unload distance, reset its deformation if (distance > CHUNK_UNLOAD_DISTANCE) { const geometry = chunk.geometry; const originalPosition = geometry.userData.originalPosition; if (originalPosition) { // Reset vertex positions to their original state geometry.attributes.position.array.set(originalPosition); geometry.attributes.position.needsUpdate = true; // Recompute normals for correct lighting geometry.computeVertexNormals(); } // Remove the deformation data for this chunk const chunkKey = getChunkKey(chunk.position.x, chunk.position.z); deformedChunksMapRef.current.delete(chunkKey); } });
};

With these functions, we resolved the issues with CHUNKs, achieving the look we aimed for.

A video of the final demo

Conclusion

In this tutorial, we covered the basics of creating Dynamic Terrain Deformation using React Three Fiber. From implementing realistic snow deformation to managing CHUNKs for unlimited walking zones, we explored some core techniques and tackled common challenges along the way.

While this project focused on the essentials, it provides a solid starting point for building more complex features, such as advanced character controls or dynamic environments. The concepts of vertex manipulation and chunk management are versatile and can be applied to many other creative projects.

Thank you for following along, and I hope this tutorial inspires you to create your own interactive 3D experiences! If you have any questions or feedback, feel free to reach out me. Happy coding! 🎉

Credits

Source

Shopping Cart
×

Hi There!

× How can I help you?