Part 7 — Creative Coding and Shaders | Prerequisites: Part 0 + Part 1 + Ch 36 | Difficulty: Intermediate | Language: JS/GLSL
- Live-coding as performance: Algorave, Hydra, and the live-coding scene
- Three.js fundamentals: scene, camera, renderer, mesh, material
- Instanced meshes for rendering thousands of objects
- Custom shader materials in Three.js
- Audio-reactive visuals: connecting Web Audio API to shader uniforms
- canvas-sketch: a framework for creative coding with export
- Exporting GIFs, videos, and still frames for sharing
- The VJ workflow: real-time visuals for live events
- Chapter 15: Your First OpenGL Program -- the 3D rendering concepts (scene, camera, shaders) that Three.js wraps
- Chapter 36: The Book of Shaders -- the GLSL shader techniques used in custom Three.js materials
- Chapter 38: Generative Art and Nature -- the generative systems animated in real-time here
- Chapter 44: WebGPU and the Future -- WebGPU as the next-generation backend for Three.js
This chapter is about making visuals that MOVE -- in real-time, on stage, in the browser, as GIFs that stop people scrolling. We'll build 3D scenes with Three.js, animate them with trigonometry and easing, make shaders that react to music, and export everything as shareable media. This is creative coding at its most performative: visuals as instrument, code as performance.
Live-coding means writing code in front of an audience while the output is projected. You type, the visuals change. Mistakes are part of the show. The audience sees your process, your thinking, your creative decisions in real-time.
This isn't theoretical -- live-coding has a thriving scene:
- Algorave: dance events where all music and visuals are live-coded
- TOPLAP: the live-coding organization (toplap.org)
- Live-coding tools: Hydra (video synth), Sonic Pi (music), TidalCycles (patterns), Strudel (browser-based), KodeLife (shaders)
The philosophy: show your screen. Make your process visible. Embrace errors as creative opportunities. The constraint of real-time creation forces a different kind of creativity -- intuitive, improvisational, performative.
Hydra (hydra.ojack.xyz) by Olivia Jack lets you live-code video synthesis in one line:
// In Hydra's browser editor:
osc(10, 0.1, 1.2).rotate(0.5).modulate(noise(3, 0.1)).out()
// Feedback loop:
src(o0).rotate(0.01).modulate(osc(3), 0.01).blend(osc(10, 0.1, 1), 0.3).out()
// Audio reactive:
a.show() // show audio waveform
osc(10, 0, () => a.fft[0] * 4).out() // oscillator frequency driven by bassHydra chains transformations like Unix pipes. Each function takes an input and produces an output. The result is projected live. It's the fastest path from idea to visual.
Three.js is a JavaScript library that wraps WebGL, making 3D accessible without writing raw GPU code. For creative coding, Three.js provides the geometry, materials, and rendering pipeline so you can focus on composition, animation, and aesthetics.
import * as THREE from 'three';
// Scene: the container for everything
const scene = new THREE.Scene();
// Camera: what you see through
const camera = new THREE.PerspectiveCamera(
50, // field of view (degrees)
window.innerWidth / window.innerHeight, // aspect ratio
0.1, // near clipping plane
100 // far clipping plane
);
camera.position.set(0, 0, 5); // pull camera back
// Renderer: draws the scene
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);Three.js provides parametric geometries out of the box:
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32);
const torusGeo = new THREE.TorusGeometry(0.5, 0.2, 16, 100);
const torusKnotGeo = new THREE.TorusKnotGeometry(0.5, 0.15, 100, 16);
const icosaGeo = new THREE.IcosahedronGeometry(0.5, 0);
const planeGeo = new THREE.PlaneGeometry(2, 2, 32, 32);// Flat color, no lighting needed
const basicMat = new THREE.MeshBasicMaterial({ color: 0x4488ff });
// Reacts to light (diffuse + specular)
const standardMat = new THREE.MeshStandardMaterial({
color: 0xff6644,
roughness: 0.4,
metalness: 0.1,
});
// Physical material (more accurate)
const physicalMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
roughness: 0.1,
metalness: 0.9,
clearcoat: 1.0,
clearcoatRoughness: 0.1,
});
// Wireframe
const wireMat = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x00ffaa });
// Custom shader
const shaderMat = new THREE.ShaderMaterial({
vertexShader: `...`,
fragmentShader: `...`,
uniforms: { u_time: { value: 0 } },
});const mesh = new THREE.Mesh(sphereGeo, standardMat);
scene.add(mesh);That's it. Geometry defines shape, material defines appearance, mesh combines them and places them in the scene. Everything in Three.js follows this pattern.
Wrapping images around 3D objects is one of the most satisfying creative coding techniques:
const textureLoader = new THREE.TextureLoader();
// Load an image as a texture
const texture = textureLoader.load('earth.jpg');
const earthMat = new THREE.MeshStandardMaterial({
map: texture, // color texture
roughness: 0.8,
});
const earth = new THREE.Mesh(
new THREE.SphereGeometry(1, 64, 64),
earthMat
);
scene.add(earth);With a good Earth texture and rotation, you've got a globe in 10 lines. Add a starfield background (scene.background = new THREE.CubeTextureLoader().load(...)) and you're in space.
You can also use a 2D canvas as a texture -- draw anything with Canvas2D and map it onto 3D geometry:
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
// Draw something on the canvas
ctx.fillStyle = '#222';
ctx.fillRect(0, 0, 512, 512);
ctx.fillStyle = '#fff';
ctx.font = 'bold 80px monospace';
ctx.fillText('HELLO', 100, 280);
const canvasTexture = new THREE.CanvasTexture(canvas);
const mat = new THREE.MeshBasicMaterial({ map: canvasTexture });Update the canvas each frame and call canvasTexture.needsUpdate = true to animate.
Three.js uses a scene graph. A Group is a container whose transform applies to all children:
const group = new THREE.Group();
for (let i = 0; i < 12; i++) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 0.2, 0.2),
new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(i / 12, 0.7, 0.6) })
);
// Position in a circle
const angle = (i / 12) * Math.PI * 2;
mesh.position.set(Math.cos(angle) * 1.5, Math.sin(angle) * 1.5, 0);
group.add(mesh);
}
scene.add(group);
// Now rotating the group rotates ALL children
function animate(time) {
group.rotation.z = time * 0.001;
group.rotation.x = Math.sin(time * 0.0005) * 0.5;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate(0);Groups are how you build complex hierarchical structures. A solar system: sun group contains planet groups, planet groups contain moon meshes. Rotate a planet group and its moons orbit with it.
Lighting makes the difference between flat shapes and dimensionality:
// Ambient: uniform fill light (prevents pure-black shadows)
const ambient = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambient);
// Directional: sunlight (parallel rays from a direction)
const directional = new THREE.DirectionalLight(0xffffff, 1.0);
directional.position.set(5, 5, 5);
scene.add(directional);
// Point: light bulb (radiates in all directions from a point)
const point = new THREE.PointLight(0xff4400, 1.0, 10);
point.position.set(-2, 2, 1);
scene.add(point);
// Hemisphere: sky/ground gradient
const hemi = new THREE.HemisphereLight(0x88ccff, 0x444422, 0.6);
scene.add(hemi);For creative coding, hemisphere light + one directional light is a reliable setup. It gives you dimension without harsh shadows.
An orthographic camera creates the isometric look popular in creative coding -- no perspective distortion, clean geometric aesthetics:
const frustumSize = 5;
const aspect = window.innerWidth / window.innerHeight;
const camera = new THREE.OrthographicCamera(
-frustumSize * aspect / 2,
frustumSize * aspect / 2,
frustumSize / 2,
-frustumSize / 2,
0.1, 100
);
// Classic isometric angle
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);Isometric scenes have a toy-like, architectural quality. Perfect for grid-based compositions, voxel art, and abstract geometric pieces.
What happens if... you combine isometric camera with a grid of objects that have noise-driven heights? You get a rolling landscape of geometric blocks that looks like a miniature world.
Drawing 10,000 individual meshes is slow. Instancing draws them all in a single GPU call:
const count = 10000;
const geometry = new THREE.IcosahedronGeometry(0.05, 0);
const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
const instanced = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
for (let i = 0; i < count; i++) {
// Random position in a sphere
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
const r = Math.cbrt(Math.random()) * 3;
dummy.position.set(
r * Math.sin(phi) * Math.cos(theta),
r * Math.sin(phi) * Math.sin(theta),
r * Math.cos(phi)
);
dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
dummy.scale.setScalar(0.5 + Math.random() * 1.5);
dummy.updateMatrix();
instanced.setMatrixAt(i, dummy.matrix);
// Vary color per instance
color.setHSL(Math.random() * 0.1 + 0.55, 0.6, 0.4 + Math.random() * 0.3);
instanced.setColorAt(i, color);
}
instanced.instanceMatrix.needsUpdate = true;
instanced.instanceColor.needsUpdate = true;
scene.add(instanced);10,000 icosahedra in a spherical cloud, each with unique color, position, and scale. Runs at 60fps. This is how you build dense, rich creative scenes.
function animate(time) {
const t = time * 0.001;
for (let i = 0; i < count; i++) {
instanced.getMatrixAt(i, dummy.matrix);
dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
// Oscillate each instance based on its index
dummy.position.y += Math.sin(t + i * 0.1) * 0.001;
dummy.rotation.x = t + i * 0.01;
dummy.updateMatrix();
instanced.setMatrixAt(i, dummy.matrix);
}
instanced.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}Matt DesLauriers emphasizes that color and composition separate amateur work from exhibition-quality pieces.
// 1. Monochromatic with value variation
const hue = 0.6; // blue
for (let i = 0; i < 100; i++) {
const lightness = 0.3 + Math.random() * 0.4;
const saturation = 0.4 + Math.random() * 0.3;
color.setHSL(hue, saturation, lightness);
}
// 2. Analogous (neighboring hues)
for (let i = 0; i < 100; i++) {
const h = 0.6 + (Math.random() - 0.5) * 0.1; // blue +/- 10%
color.setHSL(h, 0.5 + Math.random() * 0.3, 0.3 + Math.random() * 0.4);
}
// 3. From a curated palette (best approach)
const palette = ['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'];
const c = new THREE.Color(palette[Math.floor(Math.random() * palette.length)]);- Margins matter. Never fill edge-to-edge. Leave breathing room.
- Vary density. Dense clusters + empty space is more interesting than uniform distribution.
- Use Gaussian distribution for size/position -- cluster near center, thin at edges.
- Limit your palette to 3-5 colors. Fewer colors = more cohesion.
- Add one accent color used sparingly -- it draws the eye.
Smooth animation is the difference between "programmer art" and "art."
// Linear (boring)
const linear = t => t;
// Ease in (slow start)
const easeIn = t => t * t;
// Ease out (slow end)
const easeOut = t => 1 - (1 - t) * (1 - t);
// Ease in-out (smooth both ends)
const easeInOut = t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
// Elastic (bouncy overshoot)
const elastic = t => {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 :
Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
};function animate(time) {
const t = time * 0.001;
// Smooth oscillation
mesh.position.y = Math.sin(t) * 0.5;
// Circular orbit
mesh.position.x = Math.cos(t) * 2;
mesh.position.z = Math.sin(t) * 2;
// Figure-8 (Lissajous)
mesh.position.x = Math.sin(t) * 2;
mesh.position.z = Math.sin(t * 2) * 1;
// Breathing scale
const breath = 1.0 + Math.sin(t * 2) * 0.1;
mesh.scale.setScalar(breath);
// Staggered animation (each object offset in time)
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
const offset = i * 0.2;
child.position.y = Math.sin(t * 2 + offset) * 0.3;
child.rotation.y = t + offset;
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}The staggered offset is everything. Without it, all objects move in lockstep -- robotic. With it, a wave ripples through the group -- organic, alive.
What happens if... you use
Math.sin(t + i * 0.2)vsMath.sin(t * 2 + i * 0.1)? The first creates a spatial wave (each object offset in phase). The second creates a faster wave with tighter spacing. TryMath.sin(t + i * i * 0.01)for an accelerating wave.
Matt DesLauriers' canvas-sketch provides the infrastructure for looping animations and export:
const canvasSketch = require('canvas-sketch');
const THREE = require('three');
const settings = {
animate: true,
dimensions: [1024, 1024],
fps: 30,
duration: 4, // loop duration in seconds
context: 'webgl',
};
const sketch = ({ context }) => {
const renderer = new THREE.WebGLRenderer({ canvas: context.canvas });
renderer.setClearColor('#1a1a2e', 1);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-2, 2, 2, -2, 0.1, 100);
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
// Lighting
scene.add(new THREE.AmbientLight('#555'));
const light = new THREE.DirectionalLight('#fff', 1);
light.position.set(3, 5, 2);
scene.add(light);
// Grid of cubes
const meshes = [];
const gridSize = 5;
for (let x = 0; x < gridSize; x++) {
for (let z = 0; z < gridSize; z++) {
const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(
(x + z) / (gridSize * 2), 0.6, 0.5
),
roughness: 0.5,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(
x - gridSize / 2 + 0.5,
0,
z - gridSize / 2 + 0.5
);
scene.add(mesh);
meshes.push({ mesh, x, z });
}
}
return {
render({ playhead }) {
// playhead goes 0 -> 1 over the duration, then loops
const t = playhead * Math.PI * 2;
for (let { mesh, x, z } of meshes) {
// Wave animation: cubes rise and fall in a wave
const delay = (x + z) * 0.3;
const height = Math.sin(t + delay) * 0.5 + 0.5;
mesh.position.y = height * 1.5;
mesh.scale.y = 0.5 + height;
// Color shift
mesh.material.color.setHSL(
(playhead + (x + z) / (gridSize * 2)) % 1, 0.6, 0.3 + height * 0.3
);
}
renderer.render(scene, camera);
},
};
};
canvasSketch(sketch, settings);canvas-sketch makes export simple:
# Export frames as PNG sequence
canvas-sketch my-sketch.js --output=frames
# Then convert to GIF:
gifski --fps 30 --width 512 frames/*.png -o output.gif
# Or to MP4 with ffmpeg:
ffmpeg -framerate 30 -i frames/%05d.png -c:v libx264 -pix_fmt yuv420p output.mp4Or use the built-in Cmd+Shift+S during canvas-sketch playback to capture a single frame.
For looping animations, set duration in settings and use playhead (0 to 1) instead of raw time. When playhead reaches 1, it loops back to 0 seamlessly.
Connecting sound to visuals is the ultimate creative coding experience. The core idea: analyze audio frequencies with FFT, pass them as uniforms to your shader or animation.
let analyser, dataArray;
async function initAudio() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount; // 128
dataArray = new Uint8Array(bufferLength);
source.connect(analyser);
}
function getFrequencyData() {
analyser.getByteFrequencyData(dataArray);
// Frequency bands (roughly)
let bass = 0, mid = 0, treble = 0;
for (let i = 0; i < 10; i++) bass += dataArray[i]; // 0-400Hz
for (let i = 10; i < 60; i++) mid += dataArray[i]; // 400Hz-5kHz
for (let i = 60; i < 128; i++) treble += dataArray[i]; // 5kHz+
return {
bass: bass / (10 * 255), // normalized 0-1
mid: mid / (50 * 255),
treble: treble / (68 * 255),
raw: dataArray,
};
}// Fragment shader
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_bass;
uniform float u_mid;
uniform float u_treble;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
uv.x *= u_resolution.x / u_resolution.y;
// Bass drives overall scale
float d = length(uv) / (0.5 + u_bass * 1.5);
// Treble drives detail frequency
float detail = sin(d * (10.0 + u_treble * 40.0) - u_time * 3.0);
detail = smoothstep(-0.1, 0.1, detail);
// Mid drives color cycling speed
float hue = u_time * 0.2 * (1.0 + u_mid * 3.0);
// Color
vec3 col;
col.r = sin(hue + d * 3.0) * 0.5 + 0.5;
col.g = sin(hue + d * 3.0 + 2.094) * 0.5 + 0.5;
col.b = sin(hue + d * 3.0 + 4.189) * 0.5 + 0.5;
col *= detail;
// Bass pulse: bright flash on beat
col += vec3(1.0) * pow(u_bass, 4.0) * 0.3;
fragColor = vec4(col, 1.0);
}function animate(time) {
if (analyser) {
const { bass, mid, treble } = getFrequencyData();
// Smooth the values (avoid jitter)
smoothBass += (bass - smoothBass) * 0.3;
smoothMid += (mid - smoothMid) * 0.2;
smoothTreble += (treble - smoothTreble) * 0.2;
shader.uniforms.u_bass = smoothBass;
shader.uniforms.u_mid = smoothMid;
shader.uniforms.u_treble = smoothTreble;
}
shader.uniforms.u_time = time * 0.001;
shader.render();
requestAnimationFrame(animate);
}The smoothing is critical. Raw FFT data is noisy. Without smoothing, visuals jitter and flicker. The 0.3 lerp factor is a good starting point -- smaller values for smoother, more delayed response; larger values for more reactive, jittery response.
What happens if... you drive the Three.js instanced mesh positions with audio? Each instance's Y position modulated by its frequency bin creates a 3D equalizer. Or drive the scale of objects with bass for a "breathing" effect.
A complete example combining Three.js, instancing, and audio:
import * as THREE from 'three';
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0a0a15');
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight('#333'));
const light = new THREE.PointLight('#4488ff', 2, 20);
light.position.set(0, 5, 0);
scene.add(light);
// Ring of torus knots
const count = 32;
const group = new THREE.Group();
const meshes = [];
for (let i = 0; i < count; i++) {
const geo = new THREE.TorusKnotGeometry(0.15, 0.05, 64, 8);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(i / count, 0.7, 0.5),
roughness: 0.3,
metalness: 0.6,
});
const mesh = new THREE.Mesh(geo, mat);
const angle = (i / count) * Math.PI * 2;
mesh.position.set(Math.cos(angle) * 3, 0, Math.sin(angle) * 3);
mesh.lookAt(0, 0, 0);
group.add(mesh);
meshes.push(mesh);
}
scene.add(group);
// Audio setup (click to start)
let analyser, dataArray;
let smoothBass = 0;
document.addEventListener('click', async () => {
const audioCtx = new AudioContext();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
dataArray = new Uint8Array(analyser.frequencyBinCount);
source.connect(analyser);
});
function animate(time) {
const t = time * 0.001;
// Get audio data
let bass = 0;
if (analyser) {
analyser.getByteFrequencyData(dataArray);
for (let i = 0; i < 8; i++) bass += dataArray[i];
bass /= (8 * 255);
smoothBass += (bass - smoothBass) * 0.3;
}
// Animate: rotation + audio-driven scale
group.rotation.y = t * 0.3;
for (let i = 0; i < meshes.length; i++) {
const mesh = meshes[i];
const binIndex = Math.floor(i / count * 128);
const freqVal = analyser ? dataArray[binIndex] / 255 : 0;
// Each mesh reacts to its frequency band
const scale = 0.5 + freqVal * 2;
mesh.scale.setScalar(scale);
// Vertical bounce
mesh.position.y = Math.sin(t * 2 + i * 0.3) * 0.3 + freqVal * 1.5;
// Spin
mesh.rotation.x = t + i * 0.1;
mesh.rotation.z = t * 0.5 + i * 0.05;
// Color intensity with bass
mesh.material.emissive = new THREE.Color().setHSL(
(i / count + t * 0.1) % 1, 0.8, smoothBass * 0.3
);
}
// Light pulses with bass
light.intensity = 1 + smoothBass * 5;
light.color.setHSL((t * 0.05) % 1, 0.8, 0.5);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate(0);Click the page, play music, and watch the torus knots dance. Each one reacts to a different frequency band. The bass drives overall light intensity. The ring rotates smoothly while individual elements bounce and scale.
| Tool | What It Does | Language | Platform |
|---|---|---|---|
| Shadertoy | Fragment shader playground | GLSL | Browser |
| p5.js editor | Creative coding IDE | JavaScript | Browser |
| Hydra | Live-coded video synth | JavaScript | Browser |
| KodeLife | Real-time shader editor | GLSL | Desktop |
| canvas-sketch | Generative art framework | JavaScript | Node.js |
| Three.js editor | 3D scene builder | JavaScript | Browser |
| Strudel | Live-coded music patterns | JavaScript | Browser |
| TidalCycles | Algorithmic music | Haskell | Desktop |
| Sonic Pi | Music live-coding | Ruby-like | Desktop |
| TouchDesigner | Node-based visual programming | Python | Desktop |
VJs (visual jockeys) create real-time visuals for concerts, clubs, and festivals. The workflow:
- Build a library of scenes/shaders/loops
- Map MIDI controllers to parameters (brightness, speed, color, effect intensity)
- Trigger and mix visuals live, synchronized to music
- Layer multiple sources with blend modes
Many VJs use Resolume, VDMX, or TouchDesigner. But a growing number use code: Hydra, custom GLSL, or Three.js with MIDI input.
// WebMIDI for controller input
navigator.requestMIDIAccess().then((access) => {
for (let input of access.inputs.values()) {
input.onmidimessage = (msg) => {
const [status, control, value] = msg.data;
if (status === 176) { // CC message
// Map control number to parameter
params[control] = value / 127; // normalize to 0-1
}
};
}
});Map a knob to noise scale. Map a fader to color hue. Map a button to trigger a flash. Physical controls make live-coding performative and expressive.
Putting it all together -- a canvas-sketch + Three.js scene ready for GIF export:
const canvasSketch = require('canvas-sketch');
const THREE = require('three');
const settings = {
dimensions: [800, 800],
fps: 30,
duration: 3,
animate: true,
context: 'webgl',
};
const sketch = ({ context }) => {
const renderer = new THREE.WebGLRenderer({ canvas: context.canvas });
renderer.setClearColor('#120818', 1);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-3, 3, 3, -3, 0.1, 100);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);
scene.add(new THREE.AmbientLight('#2a1a3a', 0.6));
const dir = new THREE.DirectionalLight('#eeddff', 0.8);
dir.position.set(3, 5, 2);
scene.add(dir);
const palette = ['#ff6b6b', '#ffa07a', '#98d8c8', '#6c5ce7', '#fdcb6e'];
const meshes = [];
const gridSize = 7;
for (let x = 0; x < gridSize; x++) {
for (let z = 0; z < gridSize; z++) {
const types = [
new THREE.BoxGeometry(0.35, 0.35, 0.35),
new THREE.SphereGeometry(0.2, 16, 16),
new THREE.ConeGeometry(0.2, 0.4, 8),
new THREE.TorusGeometry(0.15, 0.06, 8, 20),
];
const geo = types[Math.floor(Math.random() * types.length)];
const col = palette[Math.floor(Math.random() * palette.length)];
const mat = new THREE.MeshStandardMaterial({
color: col,
roughness: 0.5,
metalness: 0.2,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x - gridSize / 2 + 0.5, 0, z - gridSize / 2 + 0.5);
scene.add(mesh);
meshes.push({ mesh, x, z, baseY: 0 });
}
}
return {
render({ playhead }) {
const t = playhead * Math.PI * 2;
for (let { mesh, x, z } of meshes) {
// Radial wave from center
const dx = x - gridSize / 2 + 0.5;
const dz = z - gridSize / 2 + 0.5;
const dist = Math.sqrt(dx * dx + dz * dz);
const wave = Math.sin(t - dist * 0.8) * 0.5 + 0.5;
mesh.position.y = wave * 2;
mesh.rotation.y = t + dist * 0.3;
mesh.rotation.x = Math.sin(t + dist) * 0.5;
// Scale pulse
const s = 0.8 + wave * 0.4;
mesh.scale.setScalar(s);
}
renderer.render(scene, camera);
},
};
};
canvasSketch(sketch, settings);Run with canvas-sketch my-scene.js --open. Press Cmd+Shift+S to export the loop as a PNG sequence, then convert to GIF. The playhead-based animation guarantees a perfect loop.
Key Takeaways:
- Live-coding is performance art -- code as instrument, visuals as music
- Three.js provides Scene + Camera + Renderer; Mesh = Geometry + Material
- Instancing (
InstancedMesh) renders thousands of objects efficiently - Isometric cameras (orthographic) create clean, architectural aesthetics
- Easing functions and staggered offsets make animation feel organic, not robotic
- canvas-sketch + Three.js +
playheadcreates perfect looping animations exportable as GIF/MP4 - Audio-reactive visuals: FFT frequency data drives shader uniforms and animation parameters
- Smooth audio input with lerp to avoid jitter; separate bass/mid/treble for different visual effects
- Hydra, Shadertoy, KodeLife, and p5.js editor are the essential live-coding playgrounds
- VJ culture: MIDI controllers mapped to visual parameters for live performance