Explore my works  here

Research, Design & Development



I recently built a generative animation using Three.js, and it was picked for CodePen’s trending section!

In this post, I’ll break down the techniques behind the animation from procedural geometry to camera movement through a tubular 3D curve.


Overview

This project explores how to animate a camera moving through a twisting tube generated along a curve using CatmullRomCurve3. The entire scene is rendered using TubeGeometry, enhanced with a glowing wireframe aesthetic and a subtle bloom pass.


Core Techniques

  • CatmullRomCurve3 to generate a looping path.
  • TubeGeometry to form the main tunnel.
  • FrenetFrames for correct orientation of ring segments along the curve.
  • EffectComposer and UnrealBloomPass for post-processing.
  • Responsive resizing and smooth camera movement using interpolation.


Three.js continues to be a great way to blend art and code. This piece was built in under a day but became something visually captivating. I hope it inspires others.


See it in action here! 🚀 https://codepen.io/kurtgrung/pen/LEVEGby




Code

// code by Kurt Grüng

const MathUtils = {
  normalize: (value, min, max) => (value - min) / (max - min),
  interpolate: (normValue, min, max) => min + (max - min) * normValue,
  map: (value, min1, max1, min2, max2) => {
    value = Math.min(Math.max(value, min1), max1);
    return MathUtils.interpolate(
      MathUtils.normalize(value, min1, max1),
      min2,
      max2
    );
  }
};

let w = window.innerWidth;
let h = window.innerHeight;

const renderer = new THREE.WebGLRenderer({
  canvas: document.querySelector("canvas"),
  antialias: true,
  shadowMapEnabled: true,
  shadowMapType: THREE.PCFSoftShadowMap
});
renderer.setSize(w, h);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

const camera = new THREE.PerspectiveCamera(45, w / h, 0.001, 200);
let cameraRotationProxyX = Math.PI;
let cameraRotationProxyY = 0;
camera.rotation.y = cameraRotationProxyX;
camera.rotation.z = cameraRotationProxyY;

const cameraGroup = new THREE.Group();
cameraGroup.position.z = 400;
cameraGroup.add(camera);
scene.add(cameraGroup);

const generatePathPoints = (count = 10, spacing = 25) => {
  const points = [];
  for (let i = 0; i < count; i++) {
    const x = i * spacing;
    const y = Math.sin(i * 0.5 + Math.random()) * 100 + 50;
    const z = Math.cos(i * 0.3 + Math.random()) * 100 + 50;
    points.push(new THREE.Vector3(x, z, y));
  }
  return points;
};

const points = generatePathPoints(10);
const path = new THREE.CatmullRomCurve3(points);
path.closed = true;
path.tension = 1;

const ringCount = 600;
const ringRadius = 3;
const ringSegments = 32;

const geometry = new THREE.TubeGeometry(
  path,
  ringCount,
  ringRadius,
  ringSegments,
  true
);
const wireframe = new THREE.LineSegments(
  new THREE.EdgesGeometry(geometry),
  new THREE.LineBasicMaterial({ linewidth: 0.1, opacity: 0.1 })
);
scene.add(wireframe);

const ringMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });

const ringMaterial1 = new THREE.LineBasicMaterial({
  color: 0xffffff,
  transparent: true,
  opacity: 0.8,
  depthWrite: false
});

const frenetFrames = path.computeFrenetFrames(ringCount, true);

for (let i = 0; i <= ringCount; i++) {
  const t = i / ringCount;
  const pos = path.getPointAt(t);
  const normal = frenetFrames.normals[i];
  const binormal = frenetFrames.binormals[i];

  const ringPoints = [];
  for (let j = 0; j <= ringSegments; j++) {
    const theta = (j / ringSegments) * Math.PI * 2;
    const x = Math.cos(theta) * ringRadius;
    const y = Math.sin(theta) * ringRadius;

    const point = new THREE.Vector3().addVectors(
      pos,
      new THREE.Vector3()
        .addScaledVector(normal, x)
        .addScaledVector(binormal, y)
    );

    ringPoints.push(point);
  }

  const ringGeometry = new THREE.BufferGeometry().setFromPoints(ringPoints);
  const ringMesh = new THREE.LineLoop(ringGeometry, ringMaterial);
  scene.add(ringMesh);
}

const light = new THREE.PointLight(0xffffff, 0.1, 4, 0);
light.castShadow = true;
scene.add(light);

const renderScene = new THREE.RenderPass(scene, camera);
const bloomPass = new THREE.UnrealBloomPass(
  new THREE.Vector2(w, h),
  1.5,
  0.4,
  0.5
);
bloomPass.renderToScreen = true;

const composer = new THREE.EffectComposer(renderer);
composer.setSize(w, h);
composer.addPass(renderScene);
composer.addPass(bloomPass);

let cameraTargetPercentage = 0;
let currentCameraPercentage = 0;

function updateCameraPercentage(percentage) {
  const p1 = path.getPointAt(percentage % 1);
  const p2 = path.getPointAt((percentage + 0.01) % 1);

  cameraGroup.position.set(p1.x, p1.y, p1.z);
  cameraGroup.lookAt(p2);
  light.position.set(p2.x, p2.y, p2.z);
}

const tubePerc = { percent: 0 };

function render() {
  cameraTargetPercentage = (cameraTargetPercentage + 0.001) % 1;

  camera.rotation.y += (cameraRotationProxyX - camera.rotation.y) / 15;
  camera.rotation.x += (cameraRotationProxyY - camera.rotation.x) / 15;
  updateCameraPercentage(cameraTargetPercentage);
  composer.render();
  requestAnimationFrame(render);
  console.log(cameraTargetPercentage);
}

requestAnimationFrame(render);

window.addEventListener("resize", () => {
  w = window.innerWidth;
  h = window.innerHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
  composer.setSize(w, h);
});