# Ice Logo - Skill Reference > Any SVG logo extruded into a block of ice with Three.js: a physical glass material, a Voronoi crack plan read by a fragment shader, and pooled shards, chips and powder that fly out under the cursor and spring back. ## What this teaches - `SVGLoader` + `polygon-clipping` union to turn any SVG into one clean polygon, normalised so its larger side is always `LOGO_UNITS` - Stretched Voronoi (`d3-delaunay` in a skewed space) to cut the logo into splinters that follow the strokes, then clipped to the outline - `ExtrudeGeometry` with a bevel for one smooth intact mesh, instead of thousands of separate pieces - `MeshPhysicalMaterial` (transmission, ior, clearcoat) patched with `onBeforeCompile` to discard fragments inside broken cells - A `DataTexture` (float, one texel per cell) as the "which cells are broken" mask, read with `texelFetch` in the shader - Hidden twin shards: one jagged lump per cell part, shown only while its cell is broken - Fixed-size pools (`THREE.Points` for dust, `InstancedMesh` for chips) so a burst never allocates - Spring-damper return: `stiffness` and `damping` instead of tweens - Canvas-drawn frost textures generated at startup, no image assets ## Core concept The logo is never a pile of pieces. It is one intact mesh whose fragment shader asks, per pixel, "which crack cell am I in, and is that cell open?". Breaking a cell flips one texel to 1, so the hole appears at no geometry cost, while a pre-built shard for that cell becomes visible and flies. When the shard lands, the texel flips back and the intact mesh closes the hole, so the logo is always exactly whole at rest. ## Use it ### 1. Embed (no build step) ```html
``` Every key of `defaults` is a kebab-case `data-*` attribute: `maxBroken` -> `data-max-broken`, `autoBreak` -> `data-auto-break="true"` (`"false"` or `"0"` turns a boolean off), `restoreDelay` -> `data-restore-delay`. Numbers are parsed with `Number()`. The logo cannot be passed as an attribute, only as the inline ``; use filled shapes (strokes are ignored, holes stay holes). The script also exposes `window.IceLogo` with `mount`, `init(root)`, `get(el)` and `defaults`. ### 2. Bundled apps ```ts import { mount } from "@vanilla-motion/ice-logo"; // workspace package; or window.IceLogo.mount from the embed // adds a to the container, keeps it sized with a ResizeObserver const fx = mount(container, { svg: logoSvgMarkup, maxBroken: 20, reach: 4 }); fx.setParams({ reach: 5 }); // Partial; shape keys (below) rebuild the geometry fx.setLogo(otherSvgMarkup); // swap the logo and rebuild fx.shatterAll(); // break everything now fx.restoreNow(); // start returning loose pieces fx.dispose(); // stops the loop, frees GPU resources, removes the canvas ``` Handle type (`IceLogoHandle`): `setParams(next)`, `setLogo(svg)`, `resize(width, height)`, `shatterAll()`, `restoreNow()`, `dispose()`. `resize` is only needed with `createIceLogo(canvas, options)`, which takes your own canvas and does not observe it; `mount` calls it for you. `options` is `Partial & { svg?: string }`. ### 3. Most useful params Lengths are in logo units (the logo's larger side is scaled to 28.5), so values behave the same for any SVG. | Key | Default | Range | Meaning | | --- | --- | --- | --- | | `reach` | 3 | 0.5 - 10 | How far from the cursor the ice crumbles, also into its depth | | `maxBroken` | 30 | 2 - 100 | Toughness: max % of the logo loose at once | | `cooldown` | 0.12 | 0 - 0.6 | Seconds between two breaks while the cursor moves | | `force` | 12 | 1 - 60 | How hard pieces are pushed away | | `dust` | 700 | 0 - 900 | Fine powder per break (lower on weak devices) | | `chips` | 10 | 0 - 40 | Small lumps per break | | `restoreDelay` | 1.2 | 0 - 5 | Seconds before loose pieces return | | `restoreStagger` | 1 | 0 - 4 | Extra random wait so pieces return one by one | | `stiffness` | 45 | 5 - 250 | Pull strength back to place | | `damping` | 0.85 | 0.2 - 2 | Below 1 pieces overshoot and click in; above 1 they glide | | `transmission` | 0.96 | 0 - 1 | How see-through the ice is | | `milk` | 0.75 | 0 - 2.5 | Strength of frost patches, grain, bubbles, cracks | | `light` | 2.6 | 0 - 5 | Brightness of reflections and highlights | | `sway` | 0 | 0 - 1.5 | Automatic turning; 0 keeps the logo still | | `follow` | 0.45 | 0 - 1.2 | How far the logo leans toward the cursor | | `autoBreak` | false | boolean | Breaks the logo on its own after 3 s without pointer input | | `cells` | 520 | 40 - 800 | Number of crack cells (workload; rebuilds geometry) | | `layers` | 3 | 1 - 4 | Pieces per cell through the thickness (workload; rebuilds) | | `depth` | 9 | 1 - 16 | Logo thickness (rebuilds) | | `bevel` | 1.2 | 0 - 1.4 | Edge rounding (rebuilds) | | `splinter` | 1 | 1 - 5 | Stretches cells along strokes; 1 = round (rebuilds) | Other keys, same style: `spin`, `gravity`, `drag`, `roughness`, `thickness`, `ior`, `frost`, `rim`, `shardFrost`, `frostShift`, `swaySpeed`. ## Key code ### Geometry ```ts // 1. SVG -> one polygon. Union merges overlapping shapes; holes stay holes. const logo = polygonClipping.union(raw[0], ...raw.slice(1)); // Normalise: centre it and scale the larger side to LOGO_UNITS, so depth/bevel/reach // mean the same for a wide wordmark and a square icon. const k = LOGO_UNITS / Math.max(maxX - minX, maxY - minY); // 2. Crack plan: stretched Voronoi. Rotate 45deg and stretch one axis by `splinter`, // run Delaunay/Voronoi there, map cells back: cells become splinters, not round pebbles. const toQ = ([x, y]) => [ct * x + st * y, (-st * x + ct * y) * kS]; const vor = Delaunay.from(pts.map(toQ)).voronoi(bounds); const pieces = polygonClipping.intersection([cell], logo); // clip each cell to the outline // 3. Cut each cell through its thickness at jittered heights, so pieces are lumps, not slabs. cuts.push((l + (rng() - 0.5) * 0.5) / layers); // Seeded RNG (mulberry32(7)): the same logo always cracks along the same lines. // 4. The intact mesh: one bevelled extrusion. Bevel goes inward (bevelOffset = -bevel) // so the outline of the logo is not grown. new THREE.ExtrudeGeometry(shapes, { depth, bevelEnabled: bevel > 0, bevelThickness: bevel, bevelSize: bevel, bevelOffset: -bevel, bevelSegments: 8, curveSegments: 24, }); ``` ### Shader / material ```ts // Which cells are broken: one float texel per cell. xy = cell centre in the stretched space, z = open (0/1). cellTex = new THREE.DataTexture(data, count, 1, THREE.RGBAFormat, THREE.FloatType); cellTex.minFilter = cellTex.magFilter = THREE.NearestFilter; // exact lookups, never interpolated // Patch the stock physical material instead of writing a new one: keep transmission, // clearcoat and tone mapping, add only the "is my cell open?" test. intactMat.onBeforeCompile = (sh) => { sh.fragmentShader = sh.fragmentShader.replace("void main() {", `void main() { // jitter the position with sines so the hole edge is ragged, not a straight Voronoi line vec2 pw = vLocal.xy + vec2(sin(...), sin(...)) * .28; vec2 q = (uMetric * vec3(pw, 1.)).xy; // same stretch as the JS side // nearest cell centre = the Voronoi cell this pixel belongs to for (int i = 0; i < 1024; i++) { if (i >= uCount) break; /* track min distance, read c.z */ } if (open > .5) discard; // broken cell: punch a hole through the ice `); // frost on the rounded edges: white where the surface turns away from the eye, clear face-on // totalEmissiveRadiance += vec3(pow(1. - abs(dot(N, V)), 2.2) * uRim); }; // The loose shards use a separate material (clear, flat-shaded, speckle texture): // fresh breaks look glossy inside while only the outer skin is frosted. ``` The loop caps at 1024 cells (`cells` is clamped to 1000 in `build()`; the slider stops at 800). Frost textures (`makeFrostTexture`, `makeCloudTexture`, `makeSpeckleTexture`) are drawn on a 2D canvas at startup, so there are no image files to host. ### Break and restore loop ```ts // Break: pick shards within `reach` of the pointer, measured in 3D with depth weighted 0.22, // so a touch chips the surface and still leaves a real hole through the thickness. const d = Math.hypot(dx, dy, (s.mesh.position.z - p.z) * 0.22); // Toughness: only maxBroken % of all shards may be loose at once; nearest first. budget = Math.floor((P.maxBroken / 100) * shards.length) - loose; // Falloff k = (1 - d/R)^0.6; a quarter of the shards are flung 2-4x further for variety. s.v.set(nx * sp, ny * sp, /* random z kick */); s.state = 1; // 0 rest, 1 flying, 2 returning s.t = P.restoreDelay + Math.random() * P.restoreStagger; // staggered return // Fly: gravity + exponential drag (frame-rate independent), tumbling by angular velocity. s.v.y -= P.gravity * dt; s.v.multiplyScalar(Math.exp(-P.drag * dt)); // Return: critically-tunable spring. c = 2*sqrt(k)*damping -> damping < 1 overshoots. const k = P.stiffness, c = 2 * Math.sqrt(k) * P.damping; force = (rest - position) * k - v * c; m.quaternion.slerp(s.restQ, 1 - Math.exp(-rotRate * dt)); // rotation settles with the position // Snap exactly to rest only when position, velocity and rotation are all tiny; then state = 0. // Each frame: a cell is open while any of its shards is not at rest. The hole closes the // frame the last shard lands, so the intact mesh is the truth at rest. cell.broken = cell.shards.some((s) => s.state !== 0); data[i * 4 + 2] = open ? 1 : 0; cellTex.needsUpdate = true; // only when something changed // Physics runs in sub-steps of at most 1/120 s and dt is capped at 1/30 s, so a slow frame never explodes the springs. // Dust (6000 points) and chips (1200 instances) are ring-buffer pools: spawn = overwrite the next slot. ``` Pointer handling: `pointermove` raycasts against the intact mesh only, so nothing breaks while the cursor is off the logo. A new break needs the cooldown to have passed and the cursor to have moved more than `reach * 0.5` from the last break point. ## Adapt to your layout - **Logo**: pass any SVG with filled shapes (inline `` in the embed, `svg` option or `setLogo()` in code). Strokes are not read; convert them to outlines first. Throws if the SVG has no filled shapes. - **Size**: the canvas is 100% of its container, and the camera pulls back to fit narrow containers (`resize`). Give the container an explicit height. - **Background**: the scene draws its own dark backdrop (`#0c0c0c` with a soft glow, so the ice has something to bend); there is no transparent mode. Match the page to it. - **Performance**: lower `cells`, `layers`, `dust` and `chips` first. Shape keys (`cells`, `layers`, `depth`, `bevel`, `splinter`) rebuild all geometry on change, so don't animate them each frame. - **Toughness**: harder to break = lower `maxBroken`, higher `cooldown`, smaller `reach`. - **Always-on motion**: `sway` > 0 turns the logo on its own; `autoBreak: true` plays a self-running demo while the pointer is idle (every fifth run is a full shatter). - **Behaviour at rest**: `follow: 0` stops the lean toward the cursor. ## Browser support WebGL2 is required (`texelFetch`, float data textures, Three.js r185 has no WebGL1 path). Chrome, Edge, Firefox and Safari 15+ on current hardware. Transmission renders an extra pass, so expect a heavier GPU cost than any CSS effect; on weak phones reduce `cells` and `dust`. Pointer events are used, `touch-action: none` is set on the canvas, so it captures touch drags and the page can't scroll by dragging on it. Without WebGL the constructor throws; wrap `mount` in `try/catch` and show a static logo. ## Accessibility The effect has no `prefers-reduced-motion` handling of its own, so guard it at the call site. ```ts const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!reduce) { mount(container, { svg }); // skip the effect entirely: no breaking, no flying shards } else { container.innerHTML = svg; // show the plain logo instead } ``` Don't just shorten the timings: skip the effect. The canvas has no text alternative, so label the container (`role="img"` and `aria-label=""`) and keep real text for the brand name elsewhere on the page.