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

Recap

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.

← Portfolio · Blog