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 );

     
     // 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.

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

Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here