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.
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) andenvMapIntensity(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. -
uCursorRadiusagainstuCursorAmt. 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_vertexpatch is missing or is running afterbegin_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
instanceMatrixand 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.
frustumCulledis 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
#defineinstead 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_vertexruns 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
customProgramCacheKeywhen 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.