← Learn · Prompt · 5 August 2026 · 20 min

The Prompt I Used to Build a Kinetic Wind Wall in Three.js (2,000 Metal Blades, All Moved in the Vertex Shader)

A copy-pasteable prompt for a wall of ~2,000 metal louvres that ripples with a procedural wind field and parts around the cursor — all motion in an onBeforeCompile patch on MeshPhysicalMaterial, reflections from a procedural PMREM studio, zero texture or HDR files. Plus the three silent bugs that cost me an evening.

Overview

A dark room with a wall in it. The wall is two thousand small metal louvres on a gentle cylindrical curve, and it is never still — a procedural wind field runs across it in travelling waves, and when you move the cursor the elements twist away from it and back, so a wake opens and closes behind your hand. Six reflective objects float in front, each with a different metal treatment. Every parameter is on a panel you can close.

The whole thing is Vite, vanilla JS, and one npm dependency. No textures, no HDR environment map, no addon imports, no React, no lil-gui. Three files.

The prompt is long, and almost all of that length is buying one thing: that the motion happens on the GPU while the material stays a real MeshPhysicalMaterial. Ask for a moving wall without saying that and you get an animation loop that rewrites two thousand instance matrices from JavaScript every frame, which is a slideshow. Ask for a custom shader instead and you get a wall that moves beautifully and reflects nothing, because you have just thrown away three's entire physical shading model to get one rotation.

What it produces: a full-screen scene with a curved wall of ~1,500 blades or ~2,200 discs (toggleable), rippling with directional wind and parting around the cursor; six floating metal shapes on slow orbits; a glassy tuning panel with every knob exposed; film grain and a vignette over the top.

Stack: Vite + vanilla JS, three@0.160.1 installed from npm. index.html, src/main.js, src/style.css. Zero asset files — the environment, the roughness map and the noise are all generated at runtime.

The prompt

Paste the whole thing at once.

Build an interactive kinetic wind wall WebGL scene with three.js. Use a Vite + vanilla JS template and install three as a dependency (npm i three@0.160.1) — do not load it from a CDN. Everything lives in index.html, src/main.js, and src/style.css. No React, no UI libraries, no HDR/texture files, no addon imports beyond what I list. When you're done, run the dev server and confirm the canvas renders with zero console errors. WHAT IT IS A full-screen dark scene containing: (1) a dense, gently curved wall of small metallic elements that ripples with a procedural wind field and parts around the cursor like a wake; (2) six reflective shapes floating in front of the wall, each with a different metal treatment; (3) a live tuning panel for every parameter, which can be closed. 1. RENDERER AND CAMERA WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }), setPixelRatio(Math.min(devicePixelRatio, 2)). toneMapping = ACESFilmicToneMapping, toneMappingExposure = 1.02, outputColorSpace = SRGBColorSpace. PerspectiveCamera(36, aspect, 0.1, 200) at (0,0,16); the wall plane sits at z = -3. Use alpha: true so a CSS radial-gradient behind the canvas shows through as the background. 2. REFLECTIONS — PROCEDURAL STUDIO ENVIRONMENT, NO FILES Write buildStudioEnv() that constructs a throwaway Scene and runs it through PMREMGenerator.fromScene(envScene, 0.035), then assign the result to scene.environment. The env scene contains: an inverted BoxGeometry shell (side: BackSide, scale 30) with a tiny ShaderMaterial fading dark at the bottom to lighter blue-grey at the top; and emissive "softboxes" — PlaneGeometry meshes with MeshBasicMaterial whose color is multiplied past 1.0 (m.color.multiplyScalar(intensity)) so PMREM captures real HDR range. Place a warm key at upper left (~9), a cool fill at right (~4), an overhead (~5), a dim floor bounce (~1), plus 5-6 narrow tall bright strips (12-20) at front and back. The narrow strips are what read as crisp streak highlights on chrome — don't skip them. Dispose the PMREM generator and temp geometries afterwards. Also add a DirectionalLight key, a cool DirectionalLight rim, and a PointLight that rides the cursor (intensity ~110, distance 18, decay 2). 3. THE WALL — ONE InstancedMesh PER MODE, ALL MOTION IN THE VERTEX SHADER Two modes, toggled, one visible at a time: "blades" = thin BoxGeometry louvres (~54% of cell width, 88% of cell height, thin in z), ~1500 instances; "discs" = coin-shaped CylinderGeometry(r, r, r*0.16, 18) with geometry.rotateX(Math.PI/2) so flat faces point at the camera, alternate rows offset by half a cell, ~2200 instances. Grid spans 27 x 15.5 world units. Provide low/medium/high density presets that rebuild the meshes. Per instance: bake position + a small random UNIFORM scale into instanceMatrix via Matrix4.compose (uniform only — non-uniform shears the element once the shader rotates it). Add an InstancedBufferAttribute named aRand (one float per instance). Also call setColorAt with a subtle per-instance brightness/temperature jitter (v = 0.82 + rand*0.26, plus a ±0.035 warm/cool offset on R and B) — this is what stops 2000 identical elements looking like a decal. Set mesh.frustumCulled = false since vertices move in the shader. SHADER INJECTION: use MeshPhysicalMaterial (metalness 1, roughness ~0.11, envMapIntensity ~1.85, light clearcoat, side: DoubleSide) patched in onBeforeCompile: - Prepend a GLSL block declaring attribute float aRand, the uniforms, kRotX/kRotY/kRotZ mat3 helpers, a hash + value-noise function using QUINTIC smoothing (f*f*f*(f*(f*6-15)+10) — cubic smoothing leaves visible grid creases), a 3-octave fbm, two globals `mat3 kRot; vec3 kOff;`, and a void kinetic() that fills them. - Replace #include <beginnormal_vertex> with: kinetic(); vec3 objectNormal = kRot * vec3(normal); (plus the USE_TANGENT branch). That chunk runs BEFORE begin_vertex, so compute kRot/kOff there and reuse them. - Replace #include <begin_vertex> with: vec3 transformed = kRot * position + kOff; - Set material.customProgramCacheKey = () => 'kineticWall', and keep a per-material uniforms object on material.userData so JS can drive it. Read each instance's world position as instanceMatrix[3].xyz. Because rotation happens in object space BEFORE instanceMatrix is applied, it composes correctly with three's own instancing. Inside kinetic(): rotate the sampling frame by a uWindDir uniform (degrees) so wind has a direction. Wind value w = two travelling sines + fbm turbulence blended by uWindTurb, times a slow global swell from uGust, scaled by uWindStrength and varied per element with aRand. Cursor field: infl = pow(1 - smoothstep(0, uCursorRadius, distance(ip.xy, uCursor.xy)), 1.55) * uCursorAmt, times a burst factor from uCursorSpeed so fast movement hits harder; add rotation that flips sign either side of the cursor (a parting wake), a counter-tilt on the other axis, and a push along +z via uCursorPush. Cylindrical wrap: nx = ip.x / uHalfW, offset z by -uCurve * nx * nx and add a small toe-in rotation of -uCurve * 0.16 * nx. Discs get a small idle spin term, blades don't — differentiate the two modes purely with per-material uRotGain / uTiltGain / uPushGain / uSpin uniforms so both share one compiled program. BACKDROP PANEL: add a curved PlaneGeometry (48x12 segments, vertices displaced by the same parabola) 1.6 units behind the wall, MeshStandardMaterial in near-black with metalness 0.5, roughness 0.68. Without it the gaps between elements fall to pure black and the wall looks like a flat graphic instead of a facade. 4. THE SHAPES Six meshes floating between camera and wall, each on its own slow sin/cos orbit with independent per-axis spin, plus gentle repulsion away from the cursor: 1. Chrome sphere — metalness 1, roughness 0.03. 2. Brushed torus — roughness ~0.4 driven by a CANVAS-GENERATED streaked roughness map (~5000 faint horizontal lines on a 512² canvas, RepeatWrapping, repeat 6x2), light clearcoat. 3. Iridescent torus knot — iridescence: 1, iridescenceIOR: 1.9, iridescenceThicknessRange: [120, 520], clearcoat 1. 4. Gold icosahedron — flatShading: true. 5. Clearcoat rounded cube — build the geometry as a superellipsoid: take SphereGeometry(1, 96, 58), normalize each vertex, scale by pow(pow(|x|,n)+pow(|y|,n)+pow(|z|,n), -1/n) with n ≈ 5.2, then computeVertexNormals(). Dark base colour, clearcoat: 1, clearcoatRoughness: 0.03, a touch of sheen. 6. Copper capsule — CapsuleGeometry, metalness 1, roughness 0.09. Keep them modest (radius ~0.3-0.7) and inside roughly ±4 x, ±2.6 y so nothing clips the frame edges. OPTIONAL REAL-TIME REFLECTIONS (off by default, exposed as a toggle): a WebGLCubeRenderTarget(192, { type: HalfFloatType }) + CubeCamera at (0,0,2.4), updated every 3rd frame with shapeGroup.visible = false during the capture, assigned to each shape's material.envMap. When on, set scene.background to the env texture at low backgroundIntensity (~0.3) with high backgroundBlurriness so the capture has something to reflect. Comment that the studio env alone actually gives crisper metal and this mode is for mirroring the wall itself. 5. CONTROL PANEL Hand-rolled, no lil-gui or dat.gui. A glassy blurred panel pinned top right, dark, monospace, ~300px, scrollable, with small helper functions slider / toggle / color / select / segmented / group. Groups: Wall (mode segmented control, density); Wind (strength, speed, scale, turbulence, direction°, gust, wall curve); Cursor (radius, twist, push, easing, light, idle-drift toggle); Material (tint colour, metal, rough, reflect, exposure); Scene (shapes toggle, spread, spin, real-time reflections, background mode, two bg colours, parallax, auto-orbit); Presets (4 named looks — chrome studio / sequin gold / cyan storm / graphite calm — plus reset). Footer shows live fps and element count. Panel dismissal, all three paths: an × button in the panel header that hides it and reveals a small ☰ button in its place; H toggles, Space switches wall mode, R resets; and a `const SHOW_UI = true` flag at the top of the file which, when false, hides panel + ☰ + caption entirely. Hide with style.display = 'none' rather than .remove(), so any code that writes into those elements stays valid. 6. INTERACTION AND LOOP Track the pointer in NDC, project it onto the wall plane, and lerp a smoothed world-space cursor toward it; feed that into the shader uniform and the point light. Derive a cursor-speed value from frame-to-frame movement and smooth it — this drives the burst term. Idle drift: if the pointer hasn't moved for ~2.5s, drive it along a slow Lissajous path so the wall keeps breathing. Camera parallax toward the pointer with an optional slow auto-orbit; camera.position.lerp(target, 0.045). Honour prefers-reduced-motion by damping wind speed and gust. Adaptive quality: sample fps every 0.5s, step setPixelRatio down below ~42fps and back up above ~58. Debounced resize handler. Expose window.KWW = { THREE, scene, camera, renderer, walls, shapes, P, applyAll, rebuildWalls } for console tinkering. CSS overlay extras: an animated SVG-turbulence film grain at low opacity with mix-blend-mode: overlay, and a radial vignette. THREE BUGS TO AVOID — I HIT ALL OF THESE 1. Don't call both clock.getDelta() and clock.getElapsedTime() in the same frame. getElapsedTime() internally consumes the delta, so doing both silently halves your dt toward zero — shape spin dies, fps never updates, adaptive quality never fires. Take t = clock.getElapsedTime() and compute dt = t - prevT yourself. 2. Don't reuse one Vector3 as both direction and result when unprojecting the pointer onto the wall plane. out.copy(camera.position).addScaledVector(out, t) overwrites the direction before it's used and the cursor lands hundreds of units off-screen. Use a separate scratch vector for the ray direction. 3. Allocate no Vector3/Color/Matrix4 inside the frame loop — hoist scratch objects to module scope. ACCEPTANCE CHECK Both wall modes render with visible wind ripple; moving the cursor produces an obvious parting wake that follows it; every slider and toggle changes something without throwing; density switching rebuilds cleanly; the × / ☰ / H cycle works; SHOW_UI = false gives a bare full-screen piece; console is clean and gl.getError() returns 0.

Why the prompt is written this way

There are eight clauses here that are doing structural work. Remove any one of them and you still get a scene — just a worse one, usually in a way that looks like an art problem and is actually an engineering one.

All motion in the vertex shader, and it stays a physical material

This is the sentence the entire prompt is built around, and it rules out both of the obvious approaches.

The first is animating from JavaScript: loop over the instances every frame, compose a fresh Matrix4 for each, write it into the instance buffer, flag it for upload. Two thousand matrix composes and a full re-upload of a 128 KB buffer, sixty times a second, on the main thread. It runs. It runs at about the speed of a carousel.

The second is reaching for a RawShaderMaterial, which makes the motion free and the reflections impossible. Every element in this wall is chrome, and chrome is defined entirely by what it reflects — so the moment you write your own fragment shader you owe yourself an implementation of the GGX specular lobe, the PMREM lookup, the clearcoat layer, and tone mapping. That is a week, and three already ships it.

onBeforeCompile is the seam between the two. You keep the stock MeshPhysicalMaterial — its lighting, its envMapIntensity, its clearcoat — and you replace two include chunks with your own. Which two, and in what order, is the rest of the trick.

Patch the normal chunk too, or the light never moves

Replace #include <beginnormal_vertex> with: kinetic(); vec3 objectNormal = kRot * vec3(normal);

This is the clause people skip, because rotating the position is obviously necessary and rotating the normal is not obviously anything. Leave it out and the geometry moves perfectly — the wall ripples, the blades twist, the wake opens — and every specular highlight stays nailed exactly where it was. The wall reads as a video texture playing on a flat surface. That single frozen-highlight tell is the difference between a facade and a screensaver, and it is one line.

The ordering note matters as much as the patch. beginnormal_vertex runs before begin_vertex in three's chunk sequence, so it is the right place to call kinetic() and fill the two globals. By the time begin_vertex runs, kRot and kOff already exist and the position patch is a single multiply-add. Do it the other way round — compute in begin_vertex — and the normal chunk has already run with a kRot that is either garbage or identity, which is exactly the frozen-highlight bug with extra steps.

Quintic smoothing, because cubic leaves grid creases

a hash + value-noise function using QUINTIC smoothing (f*f*f*(f*(f*6-15)+10))

Most value-noise implementations you will find use smoothstep, i.e. 3f² − 2f³. Its first derivative is continuous, so the noise looks smooth in isolation, but its second derivative jumps at every cell boundary. In a lighting context that shows up as faint creases along the noise lattice.

Normally you would never notice. Here you have two thousand elements sitting on a regular grid, and if the noise lattice lands anywhere near the element lattice the creases stop being faint and start being rows — a visible seam marching across the wall in straight lines. It looks like a bug in how you laid out the grid. It is a bug in the interpolant. Ken Perlin's quintic curve is C², the second derivative is continuous, and the rows vanish.

This is the single hardest thing in the whole build to debug from the symptom, which is why it is in the prompt as a parenthetical rather than left to chance.

Uniform scale only

bake position + a small random UNIFORM scale into instanceMatrix — non-uniform shears the element once the shader rotates it

A little size variation across the wall is good, and the instinct is to vary width and height independently for more variety. On a static grid that is fine. Here it is not, and the reason is the order of operations you just committed to.

The rotation happens in object space, before instanceMatrix is applied — that is what makes it compose with three's instancing at all. So the final transform is S · R: scale after rotation. When S is uniform that is a rotation and a resize, which is what you want. When S is non-uniform it is a shear, and it gets worse the further each element rotates. Your louvres turn into parallelograms at exactly the moments the wind is strongest.

Same root cause as the frozen highlight, seen from the other side: once motion moves into object space, everything baked into the instance matrix has to survive being multiplied by a rotation.

The narrow bright strips are the reflection

The environment is built at runtime from a box shell and a handful of emissive planes, and the crucial detail is the m.color.multiplyScalar(intensity) — pushing colour values past 1.0 before PMREM samples them. A MeshBasicMaterial clamped at white gives PMREM an LDR scene, and an LDR environment on a metal surface produces a flat grey sheen. It looks like painted plastic. The multiply is what gives the generator real range to work with.

Then: broad softboxes versus narrow strips. The four big sources — warm key, cool fill, overhead, floor bounce — establish the value structure, the sense of a room. They will not, on their own, make anything look like chrome. Chrome is recognisable because it reflects edges: long, hard-edged streaks that slide across the surface as it turns. That is what the 5-6 tall strips at intensity 12-20 are for, and it is why the prompt says don't skip them. They cost almost nothing and they are the entire material read.

The backdrop panel is not decoration

A curved near-black plane 1.6 units behind the wall, displaced by the same parabola. It is barely visible in the final render, and without it the piece falls apart.

Two thousand elements on a grid means two thousand gaps between them. With nothing behind, every gap is pure background — a hard-edged hole straight through to the CSS gradient. The eye reads a field of holes as a pattern, not as depth, and the wall flattens into a graphic. Put a dark surface behind it and those gaps become shadowed recesses that catch a little bounce light, and suddenly the wall has a behind. It is the cheapest depth cue in the file.

One compiled program for both modes

differentiate the two modes purely with per-material uRotGain / uTiltGain / uPushGain / uSpin uniforms, plus material.customProgramCacheKey = () => 'kineticWall'.

Blades and discs want different motion — discs get an idle spin, blades would look broken with one — and the natural way to express that is a #define or two branches of injected GLSL. Do that and you have two programs, and every mode switch and every density rebuild risks a shader compile. A compile is a multi-hundred-millisecond stall on the main thread, which on a Space keypress reads as the app hanging. Express the difference as four floats instead and the switch is instant.

The cache key is the other half. Three derives its program cache key from the material's own parameters, and it has no idea you rewrote the source in onBeforeCompile — so two materials that differ only in your injected code can collide, and you get one wall rendering with the other's shader. Overriding customProgramCacheKey is how you tell it these are yours.

Ship the panel, then ship the off switch

Thirty-odd sliders is not over-engineering for a piece like this — a wind wall lives or dies on about six numbers, and you cannot find them by editing a file and waiting on a hot reload. You find them by dragging a slider and watching the wall.

But the panel is scaffolding, and the finished thing is the wall. Hence three separate dismissal paths, deliberately: the ×/☰ pair for a visitor who wants to just look at it, H for you while you work, and SHOW_UI = false for when you embed it somewhere and it needs to be a full-bleed piece with no chrome. That last one is a one-character edit rather than a deletion job, which is the difference between shipping it as art and rewriting it.

And hide, don't remove. Every frame the loop writes the current fps into the panel footer. Call .remove() and that node is gone from the document, so the next write throws on a null reference — one frame after you dismiss the UI, in a code path nobody tests. style.display = 'none' keeps every element addressable and every writer valid.

The three bugs, and why they are all silent

The prompt ends with three named bugs because I hit all three, and none of them threw an error. That is the common thread: every one produces a scene that renders cleanly at sixty frames a second while being subtly or completely wrong. Telling the model about a trap you have already fallen into is the highest-leverage line you can add to a prompt.

1. Two clock reads in one frame

Clock.getElapsedTime() calls getDelta() internally and adds the result to its accumulator. So if you call getDelta() and then getElapsedTime() in the same frame, the first call consumes the interval and the second one measures the microseconds since — a dt of roughly zero.

Nothing errors. The wall keeps rippling, because the wind is driven by elapsed time. Everything driven by dt quietly stops: the shapes' orbits freeze mid-flight, the fps counter reads a number from the first frame forever, and the adaptive-quality sampler never accumulates half a second so it never fires. You spend twenty minutes looking for a bug in your spin code. Read the elapsed time once and subtract the previous value yourself.

2. The aliased scratch vector

Unprojecting the pointer onto the wall plane means building a ray direction, scaling it to the plane distance, and adding it to the camera position. Write that as a single chain on one vector — out.copy(camera.position).addScaledVector(out, t) — and the copy has already overwritten out before addScaledVector reads it as a direction. You are adding a scaled copy of the camera position to the camera position.

The uniform still updates, so nothing looks broken from the outside. The cursor influence point just lands a few hundred units off in the dark, and the wake never appears anywhere on screen. Every instinct says the shader is wrong, and you go and rewrite the smoothstep falloff that was fine all along. Use a separate scratch vector for the direction.

3. Allocating inside the loop

Standard advice, and it bites harder here than usual. A few new Vector3() per frame is fine at 60fps in a light scene. In this one the GC already has two thousand instances and a PMREM texture to walk, so a collection pause is long enough to see — and it lands exactly when you are moving the cursor fast, because that is when the allocations spike. The stutter correlates perfectly with interaction, which makes it look like the cursor maths is expensive. Hoist every scratch object to module scope.

Knobs to turn first

  • roughness (0.11) and envMapIntensity (1.85). The pair that decides metal versus plastic, and they fight each other. Roughness past ~0.2 blurs out the strip highlights and no amount of env intensity brings them back; below ~0.05 the wall becomes a mirror and every element reflects the same thing, which reads as flat.
  • uCursorRadius against uCursorAmt. The whole feel of the interaction. Wide and gentle is a breath moving over the wall; tight and strong is a hand pushing through it. Turning both up gives you neither — the wake swallows the wind field entirely.
  • Wall curve. More sensitive than it looks. A little gives the wall presence and catches different reflections at the edges; too much and the outer columns turn edge-on to the camera and the wall visibly ends.
  • The ±0.035 colour jitter. Tiny and load-bearing. Take it to zero and two thousand identical elements collapse into a texture. Push it to 0.1 and the wall looks dirty rather than hand-assembled.
  • toneMappingExposure (1.02). ACES rolls off the highlights, so this mostly moves the mid-tones. Nudge it in hundredths, not tenths.
  • Density. Not just a performance dial. Fewer, larger elements read as a sculpture; more, smaller ones read as fabric. Pick the one you meant, then tune the wind against it — the same wind strength looks calm on a coarse wall and frantic on a fine one.

If it comes out wrong

  • The wall ripples but the highlights are frozen. Positions are being rotated and normals are not. The beginnormal_vertex patch is missing or is running after begin_vertex.
  • Faint straight seams march across the wall. Cubic smoothing in the value noise. Swap in the quintic curve.
  • Elements skew into parallelograms when the wind peaks. A non-uniform scale is baked into instanceMatrix and the object-space rotation is shearing it.
  • The metal looks like grey plastic. Either the env colours were never multiplied past 1.0, or there are only broad softboxes and no narrow strips. Chrome needs edges to reflect.
  • It reads as a flat printed pattern. No backdrop panel — the gaps are punching through to the background.
  • No wake anywhere, but the shader looks right. The aliased scratch vector in the unproject. Log the smoothed cursor position; if it is in the hundreds, that is the one.
  • Shapes stop spinning after the first frame and fps never changes. Both clock methods are being called in the same frame.
  • Elements pop out of existence near the screen edges. frustumCulled is still true. The bounding volume is computed from the base geometry and the instance matrices, and knows nothing about vertices your shader moved.
  • Memory climbs every time you switch density. The rebuild is creating new meshes without calling dispose() on the old geometry, material and instance buffers. Same applies to the PMREM generator after the env is baked.
  • Half-second freeze on the first mode switch. A second shader program is compiling. The mode difference leaked into a #define instead of staying in uniforms.
  • Console error one frame after you close the panel. The panel was removed rather than hidden, and the loop is still writing fps into it.

Recap

  • Say all motion in the vertex shader and keep MeshPhysicalMaterial in the same breath, or you get either a slideshow or a beautifully animated wall that reflects nothing.
  • Patch the normal chunk, not just the position chunk. Frozen highlights on moving geometry is the tell that gives it away.
  • Name the chunk order. beginnormal_vertex runs first, so that is where the work goes.
  • Quintic smoothing in the noise. Cubic leaves creases that align with the element grid and look like a layout bug.
  • Uniform scale only. Object-space rotation plus non-uniform instance scale is a shear.
  • Push environment colours past 1.0 and include narrow bright strips. Broad soft light makes chrome look like plastic.
  • Put something behind the wall. Gaps that fall to pure black flatten the whole thing.
  • Keep the two modes on one compiled program, and override customProgramCacheKey when you rewrite a shader.
  • Hide UI with display: none, never .remove(), when a render loop writes into it.
  • Write the bugs you already hit into the prompt. All three of mine rendered a clean, error-free, wrong scene.

If you want more prompts in this shape, there is a frosted-glass refraction cube in raw WebGL, a fluid ink-reveal homepage, and a drivable 3D car game in React Three Fiber.