Creating a 3D Dynamic Dragon with Three.js


cover

The Result

A dragon that smoothly flies along random curved paths, with dynamic lighting that creates an ethereal atmosphere.

Starting Point: 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. Scene Setup

Basic Three.js setup with ambient and dynamic point lighting:

function setupLightning() {
  // Blue ambient light for atmosphere
  ambientLight = new AmbientLight(0x003973);
  scene.add(ambientLight);

  // Dynamic white point light with visual indicator
  dynamicLight = new PointLight(0xffffff, 5, 0, 0.2);
  dynamicLight.add(
    new Mesh(
      new SphereGeometry(2, 16, 8),
      new MeshBasicMaterial({ color: 0xffffff })
    )
  );
  scene.add(dynamicLight);
}

2. 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);
}

3. Pipeline

The Flow class from Three.js's CurveModifier performs the heavy lifting:

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 GPU transformation without any animation.

4. Animation Loop

The render function updates the dragon position and creates dynamic lighting:

function render() {
  time += clock.getDelta();

  // Move dragon along curve
  dragon.moveAlongCurve(0.002);

  // Animate lights for atmosphere
  if (dynamicLight) {
    dynamicLight.position.x = Math.sin(time * 0.7) * 30 + 20;
    dynamicLight.position.y = Math.cos(time * 0.5) * 40;
    dynamicLight.position.z = Math.cos(time * 0.3) * 30 + 20;

    // Cycle light colors
    dynamicLight.color.r = (Math.sin(time * 0.3) + 1.0) * 0.5;
    dynamicLight.color.g = (Math.sin(time * 0.7) + 1.0) * 0.5;
    dynamicLight.color.b = (Math.sin(time * 0.2) + 1.0) * 0.5;
  }

  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.

Source Code

View on GitHub