Creating a 3D Dynamic Dragon with Three.js
Result
A dragon that smoothly flies along random curved paths, with dynamic lighting that creates an ethereal atmosphere.
The Static Model
Before applying any transformations, we have a static 3D dragon model:
The model is initially positioned along the X-axis. Our goal is to bend and animate it along arbitrary curves in 3D space.
Implementation
1. Creating the Flight Path
Generate a smooth, closed curve using random control points:
function makeDragon() {
if (!model) return;
// Generate 20 random points in 3D space
const points = Array.from({ length: 20 }, () => ({
x: Math.random() * 80 - 40, // X: -40 to 40
y: Math.random() * 80 - 40, // Y: -40 to 40
z: Math.random() * 160 - 80, // Z: -80 to 80
}));
// Create smooth curve through points
const curve = new CatmullRomCurve3(
points.map(p => new Vector3(p.x, p.y, p.z))
);
curve.curveType = "centripetal";
curve.closed = true;
// Apply curve animation to dragon
const dragon = new Flow(model);
dragon.updateCurve(0, curve);
scene.add(dragon.object3D);
}
2. Transformation Pipeline
The Flow class from Three.js's CurveModifier performs the transformation:
graph TD
A[Curve Points] --> B[Pack into Data Texture]
B --> C[Send to Vertex Shader]
C --> D[Restore Curve Information]
D --> E[Transform Vertices Along Curve]Here is a transformed dragon using a circle
Notice how the dragon now follows the curve shape. This is the result of the transformation happening on shader.
Dragon on a Figure-8 Path
Now let's try a more complex curve - a figure-8 (lemniscate). Use the slider below to manually position the dragon along the path:
The figure-8 curve is created using parametric equations:
As you move the slider, you can see how the dragon maintains its orientation along the curve, demonstrating how the Frenet frames keep the model properly aligned at every point.
3. Animation Loop
Finally, the render function updates the dragon position:
function render() {
time += clock.getDelta();
// Move dragon along curve
dragon.moveAlongCurve(0.002);
renderer.render(scene, camera);
}
How CurveModifier Works
1. Data Texture
Instead of sending curve data to the GPU every frame, CurveModifier packs the whole curve into a texture:
// Create a texture to store curve data
const dataTexture = new DataTexture(
dataArray,
1024, // Width: number of curve points
4, // Height: 4 rows of data
RGBAFormat, // Each pixel has RGBA channels
HalfFloatType
);
2. Frenet Frames - Orientation Along the Curve
For each point on the curve, we calculate three vectors that define orientation:
graph TD
B[Tangent<br/>Forward direction]
C[Normal<br/>Up direction]
D[Binormal<br/>Right direction]
B & C & D --> E[3x3 Transform Matrix]// Calculate orientation vectors for each curve point
const frenetFrames = curve.computeFrenetFrames(1024, true);
// Store in texture rows:
// Row 0: Position
// Row 1: Tangent (forward)
// Row 2: Normal (up)
// Row 3: Binormal (right)
3. Vertex Shader
The GPU transforms each vertex of the model based on its position along the curve:
// Find where we are on the curve (0.0 to 1.0)
float curvePosition = (vertex.x + offset) / modelLength;
// Read curve data from texture
vec3 curvePoint = texture2D(dataTexture, vec2(curvePosition, 0.0));
vec3 forward = texture2D(dataTexture, vec2(curvePosition, 0.25));
vec3 up = texture2D(dataTexture, vec2(curvePosition, 0.5));
vec3 right = texture2D(dataTexture, vec2(curvePosition, 0.75));
// Transform the vertex
mat3 orientation = mat3(forward, up, right);
vec3 finalPosition = orientation * vertex + curvePoint;
See it live!
Faster rust version
I've also created a Rust + WebGPU version that follows the same GPU animation principles. This version also features a hand made transformation shader 😎.