From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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!
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
- “Antarctic Man” (https://skfb.ly/ovJS8) by Berk Gedik
- Snow textures from AmbientCG