The Prompt I Used to Build a Punch-Through Ice Wall in Three.js (Voronoi
Shatter + Refracted Hero Reveal)
Published August 2026 ·
avivashishta.com
Overview
You land on the page and there is a sheet of frosted mint-green ice
between you and it. Behind the frost, blurred and bent, you can
almost read a name. So you click. A hole punches through with
straight cracks snapping outward, the camera kicks, and glass cracks in
your speakers. Four more hits and the whole wall lets go — roughly ninety
real polygonal shards tumbling toward the lens, growing as they come,
flying past your head and out of frame. Behind them, the hero finally
resolves sharp.
The idea is the fourth wall. The glass isn't decoration sitting on top of
the page, it's in front of the page, and the visitor breaks it
themselves. Everything in the prompt below serves that one sentence.
The whole thing is three.js
— real meshes, shaders and render targets. No physics engine, no CSG
library, no textures loaded from disk.
What it produces: a full-viewport frosted pane that
genuinely refracts the scene behind it, five escalating punches that
paint holes and cracks into a damage texture, a hand-rolled Voronoi
fracture into ~90 shards launched at the camera, WebAudio glass sounds
synthesised from noise buffers, and a DOM hero that cross-fades in once
the wall is gone.
Stack: three.js, troika-three-text,
EffectComposer with bloom and a custom grain/barrel pass.
Five files. Every tunable in one CONFIG object.
The one idea that makes it work
The instinct is to float a transparent WebGL canvas over your normal HTML
and let the glass sit on top. That cannot work, because WebGL cannot
refract DOM. You end up reaching for backdrop-filter, and
backdrop-filter gives you a blur — not refraction, not
chromatic aberration, and definitely not per-shard distortion.
So the page renders twice. The hero text and background
live in their own scene, rendered into a render target. Then the ice is
rendered to the screen, and its fragment shader samples that render target,
offsetting the lookup by the frost's surface normal. Now the glass is
bending an actual image of the actual page. Aberration and blur-behind-glass
come free once that path exists.
The corollary is the part worth protecting: keep sampling the live
render target after the wall breaks. The shards are still running
the ice shader, so as they tumble away they refract the newly-revealed hero
in real time. It's tempting to freeze a snapshot for performance. Don't —
that one detail is the best frame in the whole sequence.
The prompt
Paste everything below into Replit Agent, Lovable, v0, Bolt or Cursor as a
single message. It is written to be executed rather than interpreted — the
shader code, the fracture algorithm and the specific numbers are what stop
an agent handing you a grey box with a console.log in it. If
your tool has a planning step, let it plan, then tell it to build the whole
thing in one pass.
One thing to change before you paste: the
Hero content block near the end has my name and copy in it. Swap
it for yours.
Build a WebGL landing page with **three.js**. Real 3D — meshes, shaders, render targets,
post-processing. Do **not** implement this with a 2D canvas, CSS filters, or an SVG fallback.
## Stack
- `three` (latest), ES modules, `import * as THREE from 'three'`
- From `three/addons/`: `EffectComposer`, `RenderPass`, `UnrealBloomPass`, `ShaderPass`, `OutputPass`
- `troika-three-text` for the hero type (so the glass can actually refract it)
- Vite if the environment supports it; otherwise a single `index.html` with a
`<script type="importmap">` pointing at `https://unpkg.com/three@latest/build/three.module.js`
and `https://unpkg.com/three@latest/examples/jsm/`
- **No physics library.** No `cannon-es`, no `rapier`, no `ammo`. The motion is 40 lines of
hand-rolled Euler integration and a physics engine will only fight the art direction.
- **No CSG library.** The fracture is 2D Voronoi + triangulation, described below.
- No images, no textures loaded from disk. Everything procedural.
Files: `index.html`, `main.js`, `ice.glsl.js` (exported vertex/fragment strings),
`fracture.js`, `style.css`. Every tunable number lives in one `CONFIG` object at the top of
`main.js`.
## The concept
The visitor lands facing a full-screen sheet of frosted mint-green ice. Behind it, blurred and
refracted, they can *almost* read a name. They click to punch the ice. Each punch opens a hole
with straight angular cracks radiating out. After five punches the entire wall shatters into
real Voronoi shards that tumble **toward the camera and past it**, out of frame — and the hero
resolves into focus behind them.
The whole point is the fourth wall. The glass is between the viewer and the page, and they
break it with their own hand. Every choice below serves that.
## Architecture — two-pass render (this is the important part)
Naive approach: transparent WebGL canvas over DOM content. **Don't.** WebGL can't refract DOM,
so you'd end up faking it with `backdrop-filter` and the whole thing falls flat.
Instead:
sceneWorld → renderTarget (half resolution is fine)
├─ background plane, procedural aurora shader
├─ hero text (troika-three-text)
└─ ambient + one soft point light
sceneIce → screen (via EffectComposer)
├─ fullscreen quad sampling renderTarget (the world, undistorted)
└─ the ice: one PlaneGeometry, then N shard meshes after the break
→ their fragment shader samples renderTarget
So: render `sceneWorld` into `rtWorld` each frame. Then render `sceneIce`, whose ice material
reads `rtWorld` as `uScene` and offsets its lookup by the ice surface normal. That gives you
genuine refraction, chromatic aberration, and blur-behind-glass for free.
**Keep sampling the live render target after the shatter.** As the shards tumble away they'll
refract the revealed hero text in real time. That is the money shot of the whole page — do not
freeze a snapshot to "optimise" it.
Camera: `PerspectiveCamera(50, aspect, 0.1, 100)` at `z = 5`. Size the ice plane to exactly
fill the frustum at `z = 0` (compute it: `h = 2 * tan(fov/2) * 5`, `w = h * aspect`) and
recompute on resize. Perspective, not orthographic — the shards need real depth to fly past
the lens.
## The ice material
`ShaderMaterial`, `transparent: true`, `depthWrite: false`.
Uniforms: `uScene` (the world RT), `uDamage` (damage texture, below), `uTime`,
`uResolution`, `uMouse`, `uOpacity`, `uTintA` `#dff3ec`, `uTintB` `#7fd6ac`,
`uTintC` `#5fc0a2`, `uRefract` 0.045, `uAberration` 0.006, `uFrostScale` 3.2.
The **damage texture** is the one place a 2D canvas is legitimate: keep an offscreen
`canvas` at viewport resolution wrapped in a `THREE.CanvasTexture`, and paint damage into it
on each punch. Channel layout:
- **R** = hole mask (white where the ice is gone → shader alpha goes to 0)
- **G** = crack intensity (drives a specular ridge and a normal kink)
- **B** = bruise / micro-fracture haze around impacts (whitens the frost, no hole yet)
Set `texture.needsUpdate = true` only on the frames a punch happens.
Fragment shader — implement essentially this:
varying vec2 vUv;
uniform sampler2D uScene, uDamage;
uniform float uTime, uRefract, uAberration, uFrostScale, uOpacity;
uniform vec3 uTintA, uTintB, uTintC;
// hash / value noise / fbm — standard 3-octave fbm, write it inline
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p){ /* smoothstep-interpolated value noise */ }
float fbm(vec2 p){ float a=.5, s=0.; for(int i=0;i<4;i++){ s+=a*noise(p); p*=2.03; a*=.5; } return s; }
void main() {
vec3 dmg = texture2D(uDamage, vUv).rgb;
// ---- frost surface normal ----
// two fbm samples at slightly offset uvs give a cheap gradient
float e = 1.5 / 1000.0;
float f = fbm(vUv * uFrostScale * 40.0);
float fx = fbm((vUv + vec2(e,0.)) * uFrostScale * 40.0);
float fy = fbm((vUv + vec2(0.,e)) * uFrostScale * 40.0);
vec2 grad = vec2(fx - f, fy - f) * 40.0;
// cracks kink the normal hard — that's what makes them catch light like glass
grad += (dmg.g * 2.2) * normalize(grad + 0.001);
// ---- refraction with chromatic aberration ----
vec2 off = grad * uRefract;
float r = texture2D(uScene, vUv + off * (1.0 + uAberration)).r;
float g = texture2D(uScene, vUv + off).g;
float b = texture2D(uScene, vUv + off * (1.0 - uAberration)).b;
vec3 behind = vec3(r, g, b);
// blur-behind-glass: mix in 4 wide taps, weighted by how frosted this pixel is
vec3 diffuse = vec3(0.0);
for (int i = 0; i < 4; i++) {
float a = float(i) * 1.5707 + uTime * 0.02;
diffuse += texture2D(uScene, vUv + off + vec2(cos(a), sin(a)) * 0.012).rgb;
}
behind = mix(behind, diffuse * 0.25, 0.75);
// ---- the ice itself ----
float marble = fbm(vUv * uFrostScale * 1.6 + uTime * 0.01);
vec3 ice = mix(uTintC, uTintB, marble);
ice = mix(ice, uTintA, pow(f, 1.6));
ice = mix(ice, vec3(1.0), dmg.b * 0.5); // bruised = whiter
vec3 col = mix(behind, ice, 0.78); // 0.78 = how opaque the frost reads
// long thin skate scratches
float scr = smoothstep(0.975, 1.0, fbm(vUv * vec2(2.0, 90.0) + 5.0));
col += scr * 0.16;
// crack specular: bright core, mint bloom shoulder
col += dmg.g * vec3(0.85, 1.0, 0.94) * 1.6;
// rim light around holes so broken edges catch the light
float rim = smoothstep(0.35, 0.5, dmg.r) - smoothstep(0.5, 0.72, dmg.r);
col += rim * vec3(0.8, 1.0, 0.92) * 1.4;
// pane vignette
col += pow(length(vUv - 0.5) * 1.4, 3.0) * 0.22;
float alpha = (1.0 - smoothstep(0.42, 0.58, dmg.r)) * uOpacity;
gl_FragColor = vec4(col, alpha);
}
Vertex shader: pass `vUv`, and additionally displace `position.z` by
`-dmg.b * 0.06` so bruised regions dish inward slightly. Sample the damage texture in the
vertex shader for that (needs enough plane subdivision — use `PlaneGeometry(w, h, 120, 120)`).
## Punching
Raycast from the pointer onto the ice plane. On `pointerup`:
1. **Paint the damage texture** at the hit UV:
- Hole: an irregular 16-vertex blob, radius `CONFIG.holeRadius * (0.75 + hits*0.16)`,
vertex radius jitter only `0.80–1.14` of nominal. Fill into the **R** channel with a
3px blur. Deep notches make it look like a starfish, not broken glass.
- Bruise: a soft radial gradient ~2.2× the hole radius into **B**.
- Cracks into **G** — read the next section, it's the detail everyone gets wrong.
2. **Displace the mesh**: push the plane vertices near the impact backward by
`-0.12 * falloff` for one frame, then spring them back over ~180ms. The wall should
visibly *take* the hit.
3. **Spawn debris**: ~18 small shard meshes (random 3–6 gon `ShapeGeometry`, ~0.02–0.09 world
units) flying outward from the impact, plus ~30 additive point sprites as sparks.
4. **Shake the camera**: offset `camera.position` by random ±`0.02 * strength` and roll
`camera.rotation.z` by ±`0.004`, decaying over 400ms. Shake the *camera*, never a CSS
transform on the canvas.
5. **Flash + sound**: bump a `uFlash` uniform in the final pass to 0.4 and decay it. Synthesise
the sound with WebAudio, no files: a 0.4s white-noise buffer with `(1-t)^5` decay through a
highpass sweeping 900→3500 Hz as damage accumulates, layered with a sine thud
150 Hz → 42 Hz. Rising pitch each hit. Mute toggle in the corner.
6. Escalate the centred hint text: `PUNCH THE ICE` → `AGAIN` → `HARDER` → `ALMOST THROUGH`.
Tick an `INTEGRITY 100%` readout down toward zero.
## Cracks — get this right or the illusion dies
Glass cracks are **straight and angular**. Organic wandering lines read as lightning, roots, or
veins, and instantly kill it.
- ~13 radial arms from the impact point.
- Each arm is built from long rigid segments of **26–54 px**, with only **±0.14 rad** of
direction drift per segment. Long and stiff.
- With ~22% probability per segment, a splinter forks off at a **sharp 0.45–1.0 rad** angle and
dies within half the parent arm's remaining length. Thinner stroke.
- Then **3 concentric rings** joining the arms — this is the actual signature of impacted glass.
20–28 segments each, radius jitter ±7%. **They must stay inside the radial reach**
(`ring_radius ≈ arm_length * (0.26 + i * 0.21)`). Rings drawn larger than the arms look like
giant floating hexagons and destroy the effect.
- Stroke into the **G** channel with a small blur so the shader gets a soft ridge, not aliased
hairlines.
## The collapse — Voronoi fracture
Trigger after `CONFIG.hitsToBreak` (5) punches.
In `fracture.js`, implement Voronoi by **half-plane clipping**, not a library:
// for each seed i: start with the full plane rect as a polygon,
// then clip it by the perpendicular bisector against every other seed j
function clipHalfPlane(poly, normal, midpoint) { /* Sutherland–Hodgman */ }
~90 seeds, biased toward the final impact point (sample `r = pow(random(), 0.6)` around it) so
the break originates where they hit. 90 seeds × 89 clips is instant — do it once, on the click.
Turn each cell polygon into a mesh:
- Fan-triangulate the convex cell from its centroid.
- **UVs must be the original plane UVs** (`uv = (localXY / planeSize) + 0.5`), so every shard
carries the exact patch of wall it came from and keeps refracting correctly. Getting this
wrong is the #1 way this looks cheap.
- Recentre each shard's geometry on its centroid and put the centroid in the mesh's `position`,
so it rotates about itself.
- Same ice `ShaderMaterial`, cloned, with a per-shard `uOpacity`.
- Optional and worth it: `ExtrudeGeometry` with `depth: 0.012` and a tiny bevel so shard edges
catch the light as they tumble.
Then hide the intact plane and animate every shard:
direction = normalize(shardCentroid - impactPoint)
boost = clamp(0.9 / distance, 0.5, 3.2) // near the fist = launched hardest
velocity.xy = direction * rand(0.6, 2.2) * boost
velocity.z = rand(0.9, 3.4) * boost // POSITIVE — toward the camera
angularVel = random on all three axes, ±3 rad/s
gravity = -3.2 units/s² on y
Shards get **bigger as they approach the lens** — that's the perspective camera doing the work,
and it's what makes it feel like the wall broke *at you* rather than away from you. Let them
fly past `camera.z` and out of frame; cull each shard once `z > 6` or its opacity hits 0. Fade
`uOpacity` over ~1.1s.
Simultaneously: hard camera shake, bloom intensity spikes to 2.2 and eases back to 0.6, and a
low WebAudio rumble (1.6s brown-noise buffer through a 320 Hz lowpass).
## Hero reveal
While the wall is intact, the hero already exists in `sceneWorld` as `troika-three-text` — that
is *why* the glass has something to refract, and why the visitor can half-read a name and wants
to break through. Keep it slightly out of focus by rendering `rtWorld` at 0.5× and letting the
shader's blur taps do the rest.
On collapse:
1. Ease `rtWorld` scale to 1.0 and the shader's diffuse mix to 0 — the world sharpens.
2. Animate the text material opacity and a per-glyph `translateY` in, staggered ~35 ms apart.
3. Ease the background plane's scale 1.06 → 1.0, a slow settling exhale.
4. **Then cross-fade the troika text out and a real DOM hero in**, absolutely positioned over
the canvas. WebGL text is not selectable, not readable by a screen reader, and not
crawlable. The DOM version is the one that ships — the WebGL one exists only to be
refracted. Do this; don't skip it because it's two implementations of the same words.
5. Show a `REFREEZE` button that resets damage texture, shard list, hit count and material
uniforms so it can be replayed.
### Hero content
eyebrow PORTFOLIO // 2026
h1 Avi Vashishta light weight, clamp(38px, 10.5vw, 150px), per-letter stagger
rule thin mint gradient line, scales in from centre
role SOFTWARE DEVELOPMENT ENGINEER uppercase, letter-spacing .5em, mint
tagline one muted line
buttons "View work" / "Get in touch" as pill outlines
Type: `Space Grotesk` for display, `JetBrains Mono` for the HUD. Palette: void `#04110d`,
deep `#0a2a22`, mint accent `#7cf0b4`, ice `#dff3ec`.
## Post-processing
`EffectComposer`: `RenderPass` → `UnrealBloomPass(strength 0.6, radius 0.8, threshold 0.72)`
→ custom `ShaderPass` (barrel distortion 0.015, RGB shift 0.0012, film grain 0.035, vignette,
plus the `uFlash` white additive) → `OutputPass`.
Grain and a hair of barrel distortion are what make it read as *looking through something*
rather than at a flat render. Don't drop them.
## Details that matter
- **No native cursor over the canvas.** Draw a reticle: a thin ring mesh (or an additive
sprite) tracking the pointer at `z = 0.1`, radius easing down ~35% on `pointerdown` so it
reads as a fist closing. Add four small tick marks that slowly rotate.
- `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`. Half-res render target.
- Resize: rebuild the damage canvas, re-stamp stored impacts (keep `{uv, strength}` for each),
recompute plane size from the frustum, resize the RT and the composer.
- Touch: `touchend` punches. On mobile drop to 45 shards, bloom off, no barrel pass.
- `prefers-reduced-motion`: no camera shake, no pulsing hint, one click breaks it.
- Target 60fps on an M1 MacBook Air and ≥30 on a 3-year-old Android.
- The hint, integrity meter and mute toggle live in a DOM `#hud` overlay, not in WebGL.
## Do not
- Do not use `MeshPhysicalMaterial` with `transmission` for the wall. It's the obvious
reach and it can't do the crack normals, the hole mask, or the frost marbling. (It *is* fine
for the small debris shards if you want them cheap.)
- Do not add a physics engine, a CSG library, or GSAP-driven DOM animation of the shards.
- Do not let the shards fly *away* from the camera. The direction is the entire idea.
- Do not make the cracks curve.
- Do not ship WebGL-only text.
## Acceptance checklist
Before you tell me it's done, verify each of these yourself:
1. On load: a frosted mint wall fills the viewport, and a name is faintly legible behind it.
2. Moving the pointer shows a custom reticle; the OS cursor is hidden.
3. First click: a hole appears with **straight** radial cracks and visible concentric rings
inside the crack reach. Camera shakes. A crack sound plays.
4. Through the hole, the hero text is visibly **sharper** than through the ice — proving the
refraction path is wired to the render target and not faked.
5. Clicks 2–4 escalate: bigger holes, longer cracks, higher-pitched crack sound, integrity
dropping.
6. Click 5: the full wall becomes ~90 distinct polygonal shards. They tumble, **grow as they
approach**, and leave the frame. Each shard visibly refracts what's behind it while flying.
7. Hero animates in sharp, letters staggered, and the final text is selectable with the mouse.
8. `REFREEZE` fully restores the intact wall and it's replayable.
9. Resizing the window doesn't stretch, blur, or misalign the ice, and existing damage survives.
10. No console errors. No WebGL warnings about render target feedback loops.
Why each part is in there
The damage texture, and why a 2D canvas is allowed here
Everything else in this build is explicitly not-a-2D-canvas, but damage is
the exception. An offscreen canvas wrapped in a
CanvasTexture is the right tool: you get
arc, lineTo and blur for free, and you only pay
for it on the handful of frames where a punch actually lands.
Packing three separate maps into one texture's channels is what keeps it
cheap — R is the hole mask that drives alpha to zero,
G is crack intensity that kinks the surface normal and
lights a specular ridge, B is the bruise haze that whitens
the frost and dishes the geometry inward without cutting a hole yet. One
texture fetch in the shader, three different jobs.
Cracks must be straight, and this is the whole illusion
This is the clause I would fight hardest for. An agent left to its own
devices draws cracks as wandering organic curves, and the instant they
curve they stop reading as glass — they read as lightning, or roots, or
veins. Real impacted glass is long, rigid and angular.
Hence the very specific numbers: segments of 26–54px with only ±0.14 rad of
drift each, splinters forking at a sharp 0.45–1.0 rad. And then the detail
that sells it more than anything — three concentric rings
joining the radial arms. That ladder pattern is the actual signature of a
point impact. Miss it and you have a starburst; include it and people
recognise it without knowing why.
The prompt also constrains the rings to stay inside the radial
reach, because the obvious implementation draws them at whatever radius and
you end up with giant floating hexagons sitting outside the cracks.
Voronoi by half-plane clipping, in about 20 lines
You do not need a CSG library to break a flat plane. For each seed, start
with the full rectangle as a polygon and clip it by the perpendicular
bisector against every other seed — Sutherland–Hodgman, once per pair. 90
seeds against 89 others is 8,000 clips of tiny polygons, which is
instantaneous, so it runs on the click with no pre-bake.
Biasing the seeds toward the final impact point with
pow(random(), 0.6) is a one-line touch that matters a lot: the
break visibly originates where the last punch landed, with fine shards near
the fist and bigger slabs out at the edges.
The shard UVs are the #1 way this looks cheap
Each shard must carry the original plane UVs, not fresh 0–1 UVs of
its own outline. Get it wrong and every shard maps the whole wall onto
itself, so the frost pattern and the refraction visibly pop the moment the
wall breaks. Get it right and each piece keeps refracting exactly the patch
of world it was covering a frame earlier, and the transition is invisible.
Toward the camera, never away
velocity.z is positive. The shards fly at the lens and past
it, and because the camera is perspective they grow on approach. That's
what makes it feel like the wall broke at you. Send them away
instead and you get a tidy, entirely forgettable dissolve — same code, all
the impact gone. It's in the "Do not" list for a reason.
Two implementations of the same words, on purpose
The hero exists twice: as troika-three-text inside the render
target, which is the thing the glass has to refract, and as real DOM that
cross-fades in after the collapse. That looks redundant and agents will try
to save you the trouble by shipping only the WebGL one.
Don't let it. WebGL text can't be selected, isn't read by a screen reader
and isn't crawlable — which for a portfolio landing page is the entire
point of having a name on it. The WebGL copy exists to be broken; the DOM
copy is what ships.
Grain and barrel distortion are not garnish
A hair of barrel distortion (0.015) plus film grain (0.035) is the
difference between "looking through something" and "looking at a flat
render". They're the cheapest credibility in the whole pipeline and the
first things an agent drops when trimming.
Things to watch for
-
Render target feedback loops. The ice samples
rtWorld, so the ice must never be in
sceneWorld. If WebGL warns about reading and writing the
same target in one pass, something drifted between the two scenes.
-
The vertex displacement needs subdivision.
PlaneGeometry(w, h) defaults to a single quad, so sampling
damage in the vertex shader does nothing at all. The
120 × 120 in the prompt is load-bearing.
-
Resize has to re-stamp the damage. The damage canvas is
viewport-sized, so a resize wipes it unless you keep every
{uv, strength} and repaint. Cheapest correct approach is to
store the impacts, not the pixels.
-
Shake the camera, not the canvas. A CSS transform on the
canvas shakes the post-processing and the DOM HUD along with it, which
instantly reads as a webpage wobbling rather than an impact.
-
Hole blobs want low jitter. 0.80–1.14 of nominal radius.
Any more and the deep notches make it a starfish instead of broken glass.
Recap
-
Render the world into a target and let the ice shader sample it. That is
the difference between refraction and a blur filter.
-
Keep sampling it live after the break, so the shards refract the hero as
they fly.
-
Straight cracks with concentric rings. Curved cracks are lightning.
-
Voronoi is half-plane clipping and about 20 lines. Skip the library.
-
Shard UVs come from the original plane, or the illusion pops on the first
frame of the collapse.
-
Positive Z. The wall breaks at the viewer, not away from them.
-
Ship the DOM hero even though the WebGL one already says the same thing.
If you want more prompts in this shape, there is
a kinetic wind wall of 2,000 blades moved entirely in the vertex
shader,
a frosted-glass refraction cube in raw WebGL,
a fluid ink-reveal homepage with a Higgsfield-generated wordmark
video, and
a hover-reveal spaceship hull scanner.