Creating a 3D Dynamic Dragon with Three.js


cover

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

Source Code

View on GitHub