ZERO: The Engineering Behind a Defiant Interactive Narrative
Codrops

ZERO: The Engineering Behind a Defiant Interactive Narrative

ZERO: The Engineering Behind a Defiant Interactive Narrative

A technical breakdown of the pipeline, rendering techniques, and performance optimizations behind ZERO, an immersive scroll-driven WebGL experience built for desktop and mobile.

The Zero Gesture

You arrive on the site and it's locked. There's no enter button, only a prompt to draw a zero. As soon as the circle closes, frost spreads from the stroke and gradually reveals the experience. We hadn't seen this kind of interaction used before, and that was exactly the appeal. It gives you a few seconds to capture the visitor's attention with something unexpected, while immediately rewarding the interaction.

The gesture check is surprisingly simple. It measures just three things: total signed angle, roundness, and closure. If the stroke passes those checks, its centroid becomes the seed for the frost shader:

// accept the stroke as a "zero" only if it truly closes a round loop
const wound = totalSignedAngle(points, center); // ~2ฯ€ for a full turn
const radiusCV = std(radii) / mean(radii); // low = round, not a scribble
const closed = dist(points[0], points.at(-1)) < 20; // must nearly meet
if (wound > 5.76 && radiusCV < 0.5 && closed) {
  startFrost(centroid);
}

Asset Pipeline

The source assets were massive: over 1 GB of video files, 3D models, and textures. The final experience ships in under 10 MB. Every asset was compressed, often multiple times, using custom tooling.

Video to Lottie. All video assets were converted to Lottie JSON using a custom pipeline built on Lottie Web Worker. This gave us crisp vector animation at a fraction of the file size. The conversion also let us tint, scale, and control playback frame by frame from JavaScript.

Texture atlasing. Every texture in the experience is packed into a single atlas. The atlas is built by a custom Node script that reads each texture, packs it into a bin, and outputs a JSON manifest mapping original filenames to UV coordinates. The manifest is loaded at runtime so the renderer can look up any texture by name without hardcoding coordinates.

DRACO compression. All 3D geometry uses DRACO compression with the highest encoder settings. The loader runs in a separate thread via a Web Worker so the main thread stays responsive during decompression.

Self-hosted decoders. Instead of relying on CDN-hosted DRACO or Lottie decoders, we self-host both. This avoids third-party dependencies and ensures the decoders are always available, even offline.

GPU Upload Strategy

Uploading textures to the GPU is surprisingly expensive, especially on mobile. A naive approach that uploads every texture at load time would cause a multi-second freeze. Instead, we use an upload queue that spreads texture uploads across multiple frames.

// drain the upload queue during idle time, one texture per frame
function drainUploads(deadline) {
  while (deadline.timeRemaining() > 5 && uploadQueue.length) {
    renderer.initTexture(uploadQueue.shift()); // forces the GPU upload now
  }
  if (uploadQueue.length) requestIdleCallback(drainUploads, { timeout: 2000 });
}
  • Split large atlases into smaller chunks. The largest textures are divided into 256ยฒ tiles and uploaded one per frame, preventing a single upload from blowing the frame budget.
  • Whenever we know a new stage is about to render, such as after the loader or during gate transitions, we flush the upload queue synchronously. This ensures every required texture is already on the GPU before it appears on screen, avoiding first-time uploads during scrolling.

The Adaptive Quality Manager

Since you can't predict the user's device, the renderer continuously monitors its own performance. It tracks frame times using a rolling buffer and adjusts quality dynamically. If rendering becomes too slow, it steps down to a lower quality tier. If performance improves and stays stable, it scales back up again. A cooldown prevents it from constantly switching between quality levels:

if (avgMs > 22 && tier > LOW && cooldownElapsed) downgrade(); // ~45 fps
if (avgMs < 12 && tier < HIGH && cooldownElapsed) upgrade(); // ~83 fps

The quality tiers only affect visual polish, not the experience itself. They adjust things like pixel ratio, blur samples, coin geometry, and text resolution, while keeping the story identical on both flagship devices and budget phones.

We also use a few targeted optimizations. During the glass shatter interaction, for example, the renderer temporarily lowers the pixel ratio and disables blur and frost effects, hiding the performance cost within the gesture itself.

Much of the final month was spent profiling and optimizing on a budget Android device. We tracked down frame spikes one by one until the experience ran smoothly. The worst offender turned out to be a single 157ms frame.

The Shaders

The post-processing chain. Each frame is built from a series of post-processing passes that are applied in sequence:

  • Render: The main 3D scene.
  • Background: Procedural GLSL backgrounds instead of image assets.
  • Glass refraction: Renders the unbroken glass so it refracts the background behind it.
  • Frost and trail: Draws the user's gesture, the frost spread, and the melting effect.
  • Depth of field: Blur quality scales with the active quality tier and is disabled on low settings.
  • Foreground: Applies film grain and final tone mapping.
  • Deferred text: Composites text after tone mapping to keep it crisp. This pass is skipped whenever no text sprites are visible.
  • Shatter: Draws the breaking glass as the final pass.

The glass, text, and shatter passes are all initialized lazily and warmed up during idle time, keeping them off the loader's critical path.

// boot: the always-on spine, added in order
composer.addPass(renderPass);     // 1. the 3D scene
composer.addPass(bgPass);         // 2. procedural background
composer.addPass(frostingPass);   // 3. draw-zero frost + melt
composer.addPass(lensBlurPass);   // 4. depth-of-field, tier-gated
composer.addPass(fgPass);         // 5. grain + the one tone mapping

// later, off the loader's critical path
composer.addPass(textPass);       // type, composited after tone mapping
composer.insertPass(glassPass, 2); // slots in right after the background
composer.addPass(shatterPass);    // the break, last over everything

// warmed during an earlier stage's idle time,
// so their first real frame is a cache hit, not a shader-compile stall
glassPass.prewarm(renderer, camera);
shatterPass.prewarm(renderer, camera);

// per frame: don't pay for the text composite when nothing's on screen
textPass.enabled = textPass.hasVisibleSprites();

Each key moment in the experience uses a custom shader built for that specific effect. AI helped generate the initial versions of many of them, but every shader was refined and reworked before it became part of the final experience.

The frost unlock. The frost effect is built using a ping-pong buffer with four passes: horizontal, vertical, and the two diagonals. Each pass expands the drawn stroke by propagating the brightest neighbouring pixels, creating an octagonal growth pattern. The expansion is modulated by the brightness of a frost texture, giving the edge a more natural, crystalline appearance. Once the stroke is complete, its centroid becomes the starting point for the radial melt effect.

// one of four axis passes โ†’ octagonal spread; uSpreadAxis is the pass direction
float m = texture2D(uPrevTrail, vUv).r;
float step = uSpreadStep * (0.4 + iceLuma * 1.2); // stepped by frost luma
for (int k = 1; k <= 2; k++) {
  m = max(m, texture2D(uPrevTrail, vUv + uSpreadAxis * step * float(k)).r * 0.92);
  m = max(m, texture2D(uPrevTrail, vUv - uSpreadAxis * step * float(k)).r * 0.92);
}
gl_FragColor.r = m; // frost only ever advance

Lighting the hands without lights. Real-time lighting on a skinned mesh would have been too expensive, and the lighting needed to match the original artwork exactly. Instead, we baked the lighting into textures and blended between them. Two texture slots are used, with the incoming slot updated for each keyframe and smoothly crossfaded to create the lighting transition.

// two slots crossfaded; the incoming one holds the next keyframe's texture
vec4 a = texture2D(uTextureA, vUv * uTexScaleA + uTexOffsetA);
vec4 b = texture2D(uTextureB, vUv * uTexScaleB + uTexOffsetB);
a.rgb *= a.a;
b.rgb *= b.a; // premultiply before the blend
vec4 col = mix(a, b, uProgress); // uProgress ramps 0โ†’1 across the beat

We premultiply the alpha before blending to avoid dark halos around the transparent edges of the hands. Both lighting textures are stored as regions within a single atlas, so switching between them only requires updating two UV offsets and a blend factor.

Burning money. The burning money effect uses a noise-driven distance field to dissolve each bill over time. Just before the burn reaches each area, a thin glowing HDR ember rim appears, followed by the charred surface.

float threshold = uBurnProgress * 1.5; // sweeps the burn front
float burn = distField * 0.5 + fbm(uv) * 0.5;
float edge = burn - threshold;
if (edge < 0.0) discard; // already ash
float ch = 1.0 - smoothstep(0.0, uCharWidth, edge);
float em = 1.0 - smoothstep(0.0, uEmberWidth, edge);
vec3 col = mix(baseColor, uCharColor, ch) + uEmberColor * em; // HDR ember rim

FBM is the most expensive part of the shader, so we discard fragments as early as possible whenever we know they cannot have reached the burn threshold yet.

Shredding certificates. Each vertex stores a strip index that determines which shred it belongs to. As the shred front moves from left to right, each strip separates from the sheet and falls independently with its own rotation, making the certificate tear apart into individual ribbons.

float past = clamp((uShredProgress - uv.x) / 0.4, 0.0, 1.0);
float e = past * past; // ease-in
pos.y -= e * (0.25 + hash(aStripIndex) * 0.25); // gravity, per strip
pos = rotateStrip(pos, aStripIndex, e * 3.0); // independent tumble, ~3 rad

Lighting normals are generated analytically from the same wave function that drives the animation, so no normal map needs to be stored or shipped.

The tunnel. The tunnel is generated by extruding the cross section of the ZERO logo. A brightness pulse travels through the tunnel using the geometry's world-space Z coordinate, allowing the effect to remain seamless even as sections of the tunnel repeat.

float q = fract(vTunnelZ * uPulseFreq + uPulseTime); // vTunnelZ = world-space Z
float band = 1.0 - smoothstep(0.0, uPulseWidth, min(q, 1.0 - q));
totalEmissiveRadiance *= 1.0 + uPulseGain * band;

Text that pulls into focus. The narrative text uses a seven-tap hexagonal blur consisting of a center sample and six surrounding samples. The blur radius is driven by the reveal progress, allowing the text to gradually sharpen as it comes into view instead of appearing instantly.

// 7-tap hexagonal blur; r comes from the reveal progress (uProgress)
float r = blurFactor * uMaxBlur * 0.003;
vec4 sum = texture2D(uText, vUv) * 2.0; // centre, weighted ร—2
for (int i = 0; i < 6; i++) // six taps at 60ยฐ
  sum += texture2D(uText, vUv + hexDir[i] * r);
gl_FragColor = sum / 8.0;

Because it runs as part of the deferred text pass, the blur is skipped entirely whenever no text is visible, avoiding any unnecessary rendering cost.

One issue that took longer than expected to debug was shader precision. Several shaders had to be explicitly set to highp float because many mobile GPUs, including Adreno and Mali, treat mediump as a true 16-bit float. This caused bugs that never appeared on desktop, such as all certificate strips moving identically or visible banding in the burn effect. It is a good reminder to test on real mobile devices from the start, not just at the end.

Interaction Design

The source material consisted of images and videos rather than interaction specifications, so the behaviour of each gate had to be designed from scratch. Most of them use the same configurable press-and-hold system. A shared configuration defines when the prompt appears and how long the hold lasts, while each gate implements its own visuals through a set of hooks:

holdTrigger: {
  showAt: 0.6,
  holdDuration: 1.4,
  onHoldProgress(ctx, p) { /* drive this gate's visuals with p (0..1) */ },
  onHoldComplete(ctx) { /* fire the shatter / launch, then advance */ },
}

During the hold, the entire frame gradually shifts towards dark red. When the glass shatters, the colour snaps back in roughly 200ms, making it feel like the shards are breaking the darkness away. A longer 400ms transition felt noticeably less impactful.

The shatter sound is also synchronized with the first rendered frame rather than a timer. On slower devices, relying on a timer can cause the audio to play before the animation is actually visible, making the effect feel out of sync.

The Payoff: An Interactive World

The final launch sequence takes you into an open sky. As stage four progresses, the clouds part to reveal the city below, with ZERO's tower at its centre. A halo appears above the tower as the final gate, before the camera settles into the interactive map.

Stage five hands control over to the user. You can pan, zoom, and explore the city, with each marker opening a card containing the role, scenario, and tools, along with a Join Beta button. The persistent join bar also becomes the waitlist signup. After guiding the user through the narrative, the experience ends by letting them explore it for themselves.

Final Thoughts

One of the biggest lessons from this project was that asset preparation and GPU uploads deserve as much attention as rendering itself. Tools like the compression previewer, self-hosted decoders, upload queue, and adaptive quality manager are not the most exciting parts of the pipeline, but they made the difference between shipping more than a gigabyte of source assets and delivering a sub-10MB experience that runs smoothly on a budget phone.

As for AI, it did exactly what it was good at. It helped generate the first version of much of the code, including the prototype that won the project. The real work came afterwards: refining the interactions, improving the visuals, profiling performance, and making hundreds of small decisions that only become obvious when testing on real devices. AI can generate code quickly, but building a polished interactive experience still depends on careful iteration and engineering judgment.

Credits

  • Concept & design direction: Atul Khola, Sanjay, Saba, Hamsa Rasheem, Akshai & team, for Zero University
  • Development, 3D web pipeline, shaders & optimization: Sindhur Dutta, Shuprobho, Yashas & team, for BUNQ LABS
  • Built with: Three.js, GLSL shaders, GSAP, Howler, DRACO, Vite

Comments

No comments yet. Start the discussion.