A 3D shoe showroom in raw WebGL2: line lights, clear acrylic, soft shadows and a worker

How Floodlit lights photographs as part of a 3D room in raw WebGL2: the line-light maths, clear acrylic with no depth buffer, soft shadows, a scanned wall, and the room in a Web Worker.

Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork24 min readUpdated 2 views

Written with AI: Claude Opus 5.5, through Claude

15 lessons learned building it
0likes
No ratings yet
0commentsSave
On this page

What it is

Floodlit is a 3D night showroom for a small range of sports shoes, written as a React component in raw WebGL2 with no 3D library. Pairs stand on clear acrylic shelves against a plaster wall. Point at a shelf and a strip of light inside the acrylic comes up in that shoe's own colour, and the light lands on the sole, on the edge of the slab and on the wall behind it. Click, and one camera walks up to the shoe.

Preview of Floodlit — 3D Sneaker & Shoe Showroom for React with a WebGL Product Gallery

This article is about the rendering: one camera for the whole room, the wall, the four kinds of light and the maths behind each, the acrylic, the soft shadows, how a flat photograph is lit as if it stood in the room, how a still is made clean, and how all of it runs in a Web Worker with the page's own thread as a fallback. The motion and the store around it are in a second article. The demo's ten shoes are five real photographs and five AI-generated images, listed with their sources in the product's PHOTOS.md.

Why raw WebGL2, and not three.js or a baked picture

A baked picture of a lit wall cannot answer a pointer: the strip comes up on hover, a torch follows the pointer across the plaster, and the camera walks. That needs a real renderer.

three.js would have given a scene graph and materials, but nothing this room needs is a standard material. The strip is a line light with a closed-form falloff. The acrylic is drawn without a depth buffer, in a fixed order around a photograph that stands inside it. The photographs are premultiplied in sRGB and must be unpremultiplied and decoded in the shader before they are lit, or every edge gets a fringe. Each of those fights a general-purpose engine. Written by hand, the whole component is 26.5 KB of gzipped JavaScript without React: 11.6 KB on the page and 14.8 KB in the worker.

One room, one camera

Everything is placed in world units that are CSS pixels at the rest camera: x to the right, y down, z towards the viewer. The wall is the plane z = 0. A shoe's picture is a card standing at z = 24. A slab is a box 46 deep and 8 thick, from z = 2 to its front at z = 48.

components/floodlit/layout.tslines 4–6Open in the code browser
export const SLAB = { depth: 46, thick: 8, zBack: 2 } as const;
export const SHOE_Z = 24;
export const MAX_SHOE_WIDTH = 340;

There is one camera, and every shelf is seen from it. The focal length follows the component's width, f = clamp(0.9 × width, 900, 1700) pixels. A point at depth z appears at

screen x = W/2 + (x − camera.x) × k + shiftX × W/2, with k = f / (camera.z − z)

and the same for y. shiftX and shiftY are a lens shift: they move the picture across the screen without turning the camera. The projection matrix carries them as an extra term in clip space:

components/floodlit/camera.tslines 126–162Open in the code browser
export function viewProjection(pose: Pose, width: number, height: number, jitterX = 0, jitterY = 0): Mat4 {
  const f = focalLength(width);
  const cy = Math.cos(pose.yaw);
  const sy = Math.sin(pose.yaw);
  const cp = Math.cos(pose.pitch);
  const sp = Math.sin(pose.pitch);
  // Rotation: yaw about y, then pitch about x. Columns of the view rotation.
  const r00 = cy, r01 = 0, r02 = sy;
  const r10 = sp * sy, r11 = cp, r12 = -sp * cy;
  const r20 = -cp * sy, r21 = sp, r22 = cp * cy;
  const tx = -(r00 * pose.x + r01 * pose.y + r02 * pose.z);
  const ty = -(r10 * pose.x + r11 * pose.y + r12 * pose.z);
  const tz = -(r20 * pose.x + r21 * pose.y + r22 * pose.z);
  const fx = (2 * f) / width;
  const fy = (2 * f) / height;
  const sx = pose.shiftX + jitterX;
  const syy = pose.shiftY + jitterY;
  // clip.x = fx * vx + sx * (-vz); clip.y = -fy * vy + sy * (-vz); clip.w = -vz. World y points down.
  const m = new Float32Array(16);
  m[0] = fx * r00 - sx * r20;
  m[4] = fx * r01 - sx * r21;
  m[8] = fx * r02 - sx * r22;
  m[12] = fx * tx - sx * tz;
  m[1] = -fy * r10 - syy * r20;
  m[5] = -fy * r11 - syy * r21;
  m[9] = -fy * r12 - syy * r22;
  m[13] = -fy * ty - syy * tz;
  m[2] = 0;
  m[6] = 0;
  m[10] = 0;
  m[14] = 0;
  m[3] = -r20;
  m[7] = -r21;
  m[11] = -r22;
  m[15] = -tz;
  return m;
}

At rest the camera stands at z = f × stepBack + 24, so for the shoes' plane k = 1 / stepBack. On a window tall enough, that is exactly 1: the shoes' plane is 1:1 with CSS pixels, which is what lets the page lay its posters out with CSS alone (the second article explains why that matters). The wall at z = 0 comes out a fraction smaller and the slab's front a fraction larger, as they would in a real room.

components/floodlit/camera.tslines 33–50Open in the code browser
export function restPose(layout: WallLayout, width: number, height: number, scrollY = 0, top = 0): Pose {
  const f = focalLength(width);
  if (width < TALL_WIDTH) {
    return { x: width / 2, y: scrollY + height / 2, z: f + SHOE_Z, pitch: 0, yaw: 0, shiftX: 0, shiftY: 0 };
  }
  // A short window steps back: the layout never changes by height. The host's header keeps its own strip on top.
  const room = Math.max(1, height - top);
  const stepBack = Math.max(1, layout.contentHeight / room);
  // The camera's eye sits level with the middle of the shelves, so the rows above it are seen from below
  // and the rows below it from above. A lens shift keeps the page itself where it belongs.
  const first = layout.slots[0]?.slab.y ?? 0;
  const last = layout.slots[layout.slots.length - 1]?.slab.y ?? 0;
  const eye = (first + last) / 2;
  const pageCentre = layout.contentHeight / 2;
  const screenEye = top + room / 2 + (eye - pageCentre) / stepBack;
  const shiftY = -((screenEye / height) * 2 - 1);
  return { x: width / 2, y: eye, z: f * stepBack + SHOE_Z, pitch: 0, yaw: 0, shiftX: 0, shiftY };
}

The same trick keeps a shoe head-on in the focus view. To leave room for the panel, the shoe must sit left of centre. Moving the camera sideways would view the flat card about 9° off its axis, and a photograph cannot show a side it does not have. So the camera stays directly in front of the shoe, and the lens shift slides the picture over. The wall and the slab still move behind the shoe during a walk, because they are at other depths.

The wall: a scan, not a photograph

The plaster is a photogrammetry scan from Poly Haven, under CC0. It arrives as a colour map and a measured height map, and becomes three textures:

  • a colour ratio map, 1024², the colour divided by its own heavy blur, so it holds only the plaster's variation and the shader chooses the base colour per theme;
  • a normal map, 1024², from the height map;
  • a detail tile, 512², the height's finest frequencies with the seams cross-faded, for when the camera is close.

The normal comes from the height's slope. With Sobel derivatives gx, gy, the tangent-space normal is normalize(−s·gx, s·gy, 1), where s is a strength. There is no true unit for s here: a height map's values are relative. So s is set from the data:

In the shader the ratio map scales a base colour, and the normal map and the detail tile tilt the surface:

vec3 ratio = texture(uAlbedo, uv, uMapBias).rgb * 2.0;
ratio = mix(vec3(dot(ratio, LUMA)), ratio, uChroma);        // how much of the plaster's colour to keep
ratio = max(vec3(0.0), 1.0 + (ratio - 1.0) * uContrast);
vec2 nm = texture(uNormal, uv, uMapBias).rg * 2.0 - 1.0;    // stored with +y up the image
vec2 dt = texture(uDetail, P * uDetailScale).rg * 2.0 - 1.0; // uDetailScale = 1/170: one tile per 170 world units
vec2 tilt = nm * uRelief + dt * uDetailRelief;
vec3 N = normalize(vec3(tilt.x, -tilt.y, 1.0));             // world y points down

Light, in linear light

All lighting is done in linear light in RGBA16F render targets, and converted to sRGB only once, at the end. The wall receives four terms: an ambient, a key light, a torch, and the strips.

Ambient. A constant, falling by up to 20% towards the room's far ends: room = 1 − 0.2 × smoothstep(0.9, 2.2, |x − centre| / halfWidth).

Key light and torch. Point lights with real inverse-square falloff: E = power × max(N·L, 0) / d², where L points from the wall pixel to the light and d is the distance in world units. The key light is fixed above and in front of the wall. The torch follows the pointer at a small distance from the wall, so it rakes across the plaster and moves the shadows a little.

Strips. Each strip is a straight line of light of constant intensity along x, from x0 to x1, inside the slab's front edge at height y_s and depth z_s. For a wall pixel at (x, y, 0), put the line's ends at a = x0 − x and b = x1 − x, and its perpendicular distance at r² = (y − y_s)² + z_s². The irradiance on the wall, which faces +z, is the integral of cos θ / ρ² along the line:

E = ∫ₐᵇ z_s / (t² + r²)^(3/2) dt = (z_s / r²) × ( b/√(b² + r²) − a/√(a² + r²) )

That closed form is why the glow has no box to have an edge: its ends fall away smoothly by construction, and its cross-section is the real fall of a line light, not a Gaussian picked by eye.

// Light from a straight line of length b - a, on a plane parallel to it.
float lineIrradiance(float a, float b, float d2, float z) {
  return (b / sqrt(b * b + d2) - a / sqrt(a * a + d2)) * z / d2;
}

for (int i = 0; i < uCount; i++) {
  vec4 st = uStrip[i];                       // x0, x1, y, z
  float dy = P.y - st.z;
  float d2 = dy * dy + st.w * st.w;
  float e = lineIrradiance(st.x - P.x, st.y - P.x, d2, st.w);
  // Relief: how much the normal-mapped surface faces the nearest point of the line, against a flat wall.
  vec3 toLine = normalize(vec3(clamp(P.x, st.x, st.y) - P.x, -dy, st.w));
  float relief = max(dot(N, toLine), 0.0) / max(toLine.z, 0.08);
  // The strip's own shoe stands between it and the wall above it.
  vec2 q = vec2(P.x, P.y + (st.z - P.y) * (uShoeZ / st.w));
  float shade = 1.0 - 0.72 * shadowAt(i, q, 3.2);
  E += uLed[i].rgb * uLed[i].a * uStripPower * e * relief * shade;
}

The relief term divides by toLine.z because the closed form already includes the flat wall's cosine. The ratio leaves the strip's overall falloff untouched and adds only what the plaster's tilt changes: grazing light catches the bumps, which is what makes the glow look like light on a material and not paint.

Measured on the P1 stills, the largest step between neighbouring pixels along and across any glow was 4 and 3 levels, against a limit of 20.

Soft shadows from a blurred alpha chain

A shoe is a card at z = 24, 24 units in front of the wall. To know whether a wall pixel P sees a light at Lp, follow the ray from P towards the light up to the shoe's plane:

q = P + (Lp − P).xy × (24 / Lp.z)

and read the shoe's alpha at q. A hard read would give a hard shadow. Instead each shoe ships a small atlas of its alpha, blurred at five strengths (256² down to 16²), stored as one layer of a TEXTURE_2D_ARRAY with its own mip chain. The shader reads it with an explicit level that grows with how far up the shoe q is, so the shadow is sharp at the sole and soft at the collar, as a real contact shadow is:

float h = clamp((uShoeBase[i].x - q.y) / uShoeBase[i].y, 0.0, 1.0);  // 0 at the sole, 1 at the top
keyVis *= 1.0 - here * uKeyShadow * shadowAt(i, q, mix(0.5, 2.6, h));

The slab casts a faint soft bar from the key light and a closer ambient occlusion where it meets the wall. Both are a softBar, a smoothstep along the slab and a Gaussian across it, so neither has a box edge either.

Clear acrylic with no depth buffer

The context has no depth buffer. Everything is transparent or cut out, and a depth buffer would not order transparent faces anyway. So each shelf is drawn in a fixed order: the brackets under it, then the slab's far faces, then the part of its top face behind the shoe, then the shoe, then the part of the top in front of the shoe, then the near faces and the front. Which side faces and which of top or bottom are far depends on where the camera is, so the order is chosen per shelf, per frame.

components/floodlit/engine.tslines 607–658Open in the code browser
      const size: [number, number, number] = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
      const face = (name: string, zFrom = min[2], zTo = max[2]) => {
        g.uniform3f(slabP.u.uOrigin, min[0], min[1], zFrom);
        g.uniform3f(slabP.u.uScale, size[0], size[1], zTo - zFrom);
        const [first, count] = box.ranges[name];
        g.drawArrays(g.TRIANGLES, first, count);
      };
      const camAbove = cam.y < slot.slab.y;
      const camBelowFloor = cam.y > max[1];
      // Far faces of the slab, before the shoe.
      if (!camBelowFloor) face("bottom");
      face("top", min[2], SHOE_Z);
      if (cam.x > min[0]) face("left");
      if (cam.x < max[0]) face("right");

      // The shoe.
      {
        const p = programs.shoe;
        g.useProgram(p.program);
        g.bindVertexArray(quadVao);
        g.uniformMatrix4fv(p.u.uViewProj, false, vp);
        g.uniform3f(p.u.uOrigin, slot.card.x, slot.card.y, SHOE_Z);
        g.uniform3f(p.u.uScale, slot.card.w, slot.card.h, 1);
        bind2D(0, shoeTextures[i]);
        g.uniform1i(p.u.uShoe, 0);
        const large = larges.get(i);
        const largeMix = large?.ready ? (st.largeMix ?? 0) : 0;
        if (largeMix > 0) bind2D(1, large!.texture!);
        g.uniform1i(p.u.uShoeLarge, 1);
        g.uniform1f(p.u.uLargeMix, largeMix);
        g.uniform1f(p.u.uMipBias, st.mipBias);
        g.uniform1f(p.u.uExposure, st.exposure);
        g.uniform3fv(p.u.uLedColor, led as unknown as number[]);
        g.uniform1f(p.u.uLedLevel, st.level);
        g.uniform1f(p.u.uSoleCatch, L.soleCatch);
        g.uniform1f(p.u.uBaseline, shoes[i].baseline);
        g.uniform2f(p.u.uPanelFade, state.panelFade[0], state.panelFade[1]);
        g.uniform1f(p.u.uPanelAmount, st.panelAmount);
        g.uniform1f(p.u.uFadeAll, st.fade);
        g.uniform1f(p.u.uFlip, flip);
        g.uniform2f(p.u.uViewport, targetW, targetH);
        g.drawArrays(g.TRIANGLES, 0, 6);
      }

      // Near faces of the slab, over the shoe.
      g.useProgram(slabP.program);
      g.bindVertexArray(boxVao);
      face("top", SHOE_Z, max[2]);
      if (camBelowFloor) face("bottom");
      if (cam.x < min[0]) face("left");
      if (cam.x > max[0]) face("right");
      face("front");

The acrylic's look comes from three terms. A rim: light caught along the edges of each face, exp(−edge / w), where edge is the pixel's distance to the nearest edge of its face in world units and w is at least 1.1 pixels, from fwidth(edge), so the rim never aliases. A Fresnel term, (1 − |N·V|)⁴, so faces seen at a grazing angle are more reflective. And the strip itself on the front face: a sharp core exp(−(d/0.95)²) and a wider halo exp(−d/3.2), with a faint glow leaving through the top and bottom faces, strongest near the front. On the top face the shader also draws the shoe's contact shadow, a Gaussian across z centred on the shoe's plane, and a faint reflection: it mirrors the view ray off the top face, intersects it with the shoe's plane, and reads the shoe's picture there at a soft mip level, fading with height above the sole.

A photograph lit as part of the room

Each picture is a cut-out, uploaded with its alpha premultiplied by createImageBitmap in sRGB. Blending premultiplied colours in a texture is what keeps mip levels free of dark or pale halos. But light must be applied to the real colour in linear light. So the shoe's shader undoes the premultiply, decodes sRGB to linear, lights the colour, and premultiplies again in linear light:

vec4 s = texture(uShoe, uv, uMipBias);
if (uLargeMix > 0.0) s = mix(s, texture(uShoeLarge, uv, uMipBias), uLargeMix);  // a larger picture fading in
if (s.a < 0.002) discard;
vec3 albedo = srgbToLinear(clamp(s.rgb / s.a, 0.0, 1.0));
float h = max(uBaseline - vFace.y, 0.0);       // height above the sole, as a share of the picture
float low = exp(-h / 0.13);                     // the strip catches only the lower part of the shoe
vec3 lit = albedo * (uExposure + uLedColor * uLedLevel * uSoleCatch * low);
outColor = vec4(lit * s.a, s.a);

The picture is never lit by the key light or the torch. It is a photograph, already lit when it was taken, and a flat card lit again by a point light only looks flat. What it does get is the strip's coloured light on its sole, falling off with height, and its exposure lowered when the room dims around a shoe in focus.

In focus, the panel needs a calm zone. Shoes on the panel's side fade out before the panel, by a screen-space ramp, panelKeep(x), a smoothstep between two screen positions just past the focused shoe's own slab.

Clean pixels: float targets, MSAA, and a still averaged in linear light

While the camera moves, each frame is drawn once into a multisampled RGBA16F renderbuffer and resolved: 4× MSAA up to 2.6 megapixels, 2× up to 5, and none above that, where the pixels are already small. The total is capped at 6 megapixels.

components/floodlit/engine.tslines 700–745Open in the code browser
  function samplesFor(w: number, h: number): number {
    const limit = gl!.getParameter(gl!.MAX_SAMPLES) as number;
    const pixels = w * h;
    // Past 5 MP the pixels themselves do the work while moving, and a still is averaged anyway.
    const want = small ? 2 : pixels <= 2_600_000 ? 4 : pixels <= 5_000_000 ? 2 : 0;
    return Math.min(limit, want);
  }

  /**
   * One frame of motion, or one step of a still. A still is built one jittered frame at a time across animation
   * frames, so it never makes a long frame. Its first frame has no jitter and is the same picture as a moving
   * frame, so nothing shifts at the moment the camera stops. Pass stillIndex -1 while moving.
   */
  function frame(layout: WallLayout, cssW: number, cssH: number, dpr: number, state: RoomState, stillIndex: number) {
    const g = gl!;
    const w = Math.round(cssW * dpr);
    const h = Math.round(cssH * dpr);
    resize(w, h, samplesFor(w, h));
    drawCalls = 0;
    worldDim = state.worldDim;
    if (stillIndex < 0) {
      renderFrame(layout, cssW, cssH, state, 0, 0);
      present(sceneTex!);
      return drawCalls;
    }
    const [jx, jy] = stillIndex === 0 ? [0, 0] : HALTON_16[(stillIndex - 1) % 16];
    renderFrame(layout, cssW, cssH, state, jx, jy);
    g.bindFramebuffer(g.FRAMEBUFFER, accumFbo);
    g.viewport(0, 0, targetW, targetH);
    const p = programs.accumulate;
    g.useProgram(p.program);
    g.bindVertexArray(emptyVao);
    bind2D(0, sceneTex!);
    g.uniform1i(p.u.uFrame, 0);
    g.uniform1f(p.u.uWeight, 1 / (stillIndex + 1));
    if (stillIndex === 0) {
      g.disable(g.BLEND);
    } else {
      g.enable(g.BLEND);
      g.blendFunc(g.SRC_ALPHA, g.ONE_MINUS_SRC_ALPHA);
    }
    g.drawArrays(g.TRIANGLES, 0, 3);
    g.disable(g.BLEND);
    present(accumTex!);
    return drawCalls;
  }

When the camera stops, the room builds a still over the next 16 animation frames: each frame is drawn with a sub-pixel jitter from a Halton (2, 3) sequence, and blended into an accumulation target with weight 1/(i+1), which keeps a running mean. Then it draws nothing at all until something changes. Drawing one jittered frame per animation frame, rather than 16 in one go, means a still never makes a long frame.

The last step is present: highlights roll off above a knee rather than clip, c' = k + (1 − k)(1 − e^(−(c − k)/(1 − k))) above k = 0.72, so a bright strip keeps its hue instead of going white. Then sRGB encoding, and a static 64² blue-noise dither of one level peak to peak, which stops banding in the dim tails of the glows. It is never animated, so it cannot shimmer.

The still has one rule of its own, caught on reading the first loop rather than seen on screen: it starts on every stop, not only when a redraw is forced, and its first frame has no jitter, so the picture does not shift by a fraction of a pixel at the moment the camera stops. The Halton offsets begin with the second frame.

The stills and the frame-by-frame checks ran in installed Chrome on this laptop's real GPU, headless:

Off the page's thread

Creating a context, compiling shaders, decoding pictures and drawing frames all cost the thread that does them. So the room runs in a Web Worker on an OffscreenCanvas where the browser allows it, and the page only sends it commands. A canvas handed to a worker can never be taken back, so the page asks first: the worker's opening message says whether it can draw at all, and only then is the canvas transferred. A worker that never answers, because its file is missing or blocked, counts as no worker after 4 s, and an error before the hand-over falls back at once.

components/floodlit/client.tslines 88–128Open in the code browser
  const canHandOver = init.worker && typeof Worker === "function" && "transferControlToOffscreen" in HTMLCanvasElement.prototype && typeof import.meta.url === "string";
  if (!canHandOver) {
    void onThisThread();
  } else {
    let worker: Worker | null = null;
    let handed = false;
    const giveUp = () => {
      clearTimeout(timer);
      worker?.terminate();
      worker = null;
      void onThisThread();
    };
    // A worker that never answers, its file missing or blocked, counts as no worker.
    const timer = setTimeout(giveUp, 4000);
    try {
      worker = new Worker(new URL("./room.worker.ts", import.meta.url), { type: "module", name: "floodlit" });
      worker.onerror = (e) => {
        e.preventDefault();
        if (handed) events.onFail();
        else giveUp();
      };
      worker.onmessage = ({ data }) => {
        if (data.type === "hello") {
          if (!data.ok) {
            giveUp();
            return;
          }
          clearTimeout(timer);
          handed = true;
          const w = worker as Worker;
          const offscreen = canvas.transferControlToOffscreen();
          w.postMessage({ type: "init", canvas: offscreen, init: hostInit }, [offscreen]);
          connect((method, args) => w.postMessage({ type: "call", method, args }));
        } else {
          onEvent(data as HostEvent);
        }
      };
    } catch {
      giveUp();
    }
  }
components/floodlit/room.worker.tslines 1–25Open in the code browser
// The room's own thread: context, shaders, pictures and every frame, so the page's main thread never waits for them.
// Typed without the "webworker" lib, so a project's own TypeScript setup never has to include it.
import { createHost, type HostInit, type HostMethod } from "./host";

type Message = { type: "init"; canvas: OffscreenCanvas; init: HostInit } | { type: "call"; method: HostMethod; args: unknown[] };
type Scope = { postMessage(message: unknown): void; onmessage: ((e: { data: Message }) => void) | null };

const scope = self as unknown as Scope;
let host: ReturnType<typeof createHost> | null = null;

scope.onmessage = ({ data }) => {
  if (data.type === "init") host = createHost(data.canvas, data.init, (event) => scope.postMessage(event), "worker");
  else host?.call(data.method, data.args);
};

// Asked before the page hands its canvas over, because a canvas handed over cannot be taken back.
let ok = typeof requestAnimationFrame === "function" && typeof OffscreenCanvas === "function";
if (ok) {
  try {
    ok = !!new OffscreenCanvas(1, 1).getContext("webgl2");
  } catch {
    ok = false;
  }
}
scope.postMessage({ type: "hello", ok });

On the page's own thread the same code runs: host.ts holds the engine and the room and takes commands by name in both places, and its module is loaded with a dynamic import() only when the fallback is needed. That fallback is not hypothetical: UpFork's preview is a single HTML file whose rules allow workers only from blob: addresses, so there the room always runs on the page's thread. A host with rules of its own can pass worker={false}.

Proving that needed a tab that was really hidden, and that was harder than it sounds:

A lost context, in either place

If the GPU resets, the context is lost. The host listens on the canvas, which works for an OffscreenCanvas in a worker as for a canvas on the page. On loss it drops the room and tells the page, which shows the plain posters and a plain slide-in, so buying never stops. On restore it builds a new engine on the same canvas, and puts back the layout, the shoe in focus and the bag.

components/floodlit/host.tslines 148–161Open in the code browser
  const contextOf = () => (canvas as OffscreenCanvas).getContext("webgl2");
  const onLost = (e: Event) => {
    e.preventDefault();
    ready = false;
    room?.stop();
    room = null;
    engine = null;
    emit({ type: "lost" });
  };
  const onRestored = () => boot(true);
  canvas.addEventListener("webglcontextlost", onLost);
  canvas.addEventListener("webglcontextrestored", onRestored);

  boot(false);

The context comes back as the same object. The engine counts draw calls by wrapping drawArrays, and it binds the real function from WebGL2RenderingContext.prototype, never from the context, so a restored context is never wrapped twice.

components/floodlit/engine.tslines 146–152Open in the code browser
  // From the prototype: after a restored context this is the same object, already wrapped once.
  const realDraw = WebGL2RenderingContext.prototype.drawArrays.bind(gl);
  gl.drawArrays = (mode: number, first: number, count: number) => {
    drawCalls++;
    totalDraws++;
    realDraw(mode, first, count);
  };

Larger pictures on demand

Every shoe loads at 960 px, shared with the page's own poster so the browser's cache serves it. A shoe in focus gets a 2000 px picture, and so do its two neighbours, because walking along is one key away. The larger picture is fetched and decoded as soon as a shelf is hovered or focused, which costs the GPU nothing. It goes to the GPU only when no walk is running, one step per animation frame: the picture in one frame, its smaller levels in the next. Then it crossfades over the smaller one in the shoe's shader. Far ones are let go: at most five larger pictures are held on a desktop and three on a phone.

components/floodlit/engine.tslines 327–390Open in the code browser
  async function requestLarge(index: number): Promise<boolean> {
    const url = shoes[index]?.large;
    if (!url || url === shoes[index].src || failedShoes.has(index) || larges.has(index)) return false;
    const entry: Large = { texture: null, bitmap: null, ready: false, bytes: 0 };
    larges.set(index, entry);
    try {
      const bitmap = await loadBitmap(url, { premultiplyAlpha: "premultiply", colorSpaceConversion: "none" });
      // Let go while it was on its way.
      if (larges.get(index) !== entry) {
        bitmap.close();
        return false;
      }
      entry.bitmap = bitmap;
      return true;
    } catch {
      larges.delete(index); // the smaller picture stays, and the walk never waited for this one
      return false;
    }
  }

  /** One step of one upload: the picture in one frame, its smaller levels in the next. True if there was work. */
  function pumpUpload(): boolean {
    for (const entry of larges.values()) {
      if (entry.ready || !entry.bitmap) continue;
      const g = gl!;
      if (!entry.texture) {
        const texture = g.createTexture()!;
        g.bindTexture(g.TEXTURE_2D, texture);
        const { width, height } = entry.bitmap;
        const levels = Math.floor(Math.log2(Math.max(width, height))) + 1;
        g.texStorage2D(g.TEXTURE_2D, levels, g.RGBA8, width, height);
        g.pixelStorei(g.UNPACK_COLORSPACE_CONVERSION_WEBGL, g.NONE);
        g.pixelStorei(g.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
        g.texSubImage2D(g.TEXTURE_2D, 0, 0, 0, g.RGBA, g.UNSIGNED_BYTE, entry.bitmap);
        g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
        g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
        g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
        g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR_MIPMAP_LINEAR);
        if (aniso) g.texParameterf(g.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, maxAniso);
        entry.texture = texture;
        entry.bytes = Math.round(width * height * 4 * 1.34);
        return true;
      }
      g.bindTexture(g.TEXTURE_2D, entry.texture);
      g.generateMipmap(g.TEXTURE_2D);
      entry.bitmap.close();
      entry.bitmap = null;
      entry.ready = true;
      return true;
    }
    return false;
  }

  const largeReady = (index: number) => larges.get(index)?.ready ?? false;

  /** Keep only these. The rest go back to the smaller picture. */
  function keepLarge(keep: (index: number) => boolean) {
    for (const [index, entry] of larges) {
      if (keep(index)) continue;
      entry.bitmap?.close();
      if (entry.texture) gl!.deleteTexture(entry.texture);
      larges.delete(index);
    }
  }
components/floodlit/room.tslines 604–621Open in the code browser
  function preload(index: number) {
    if (!layout) return;
    const n = layout.slots.length;
    const near = (i: number, centre: number) => {
      const d = Math.abs(i - centre);
      return Math.min(d, n - d) <= 1;
    };
    const centre = focused >= 0 ? focused : index;
    const limit = options.small ? 3 : 5;
    let held = 0;
    for (let i = 0; i < n; i++) if (engine.largeReady(i)) held++;
    if (held >= limit) engine.keepLarge((i) => near(i, centre) || near(i, index));
    for (const k of [index, (index + 1) % n, (index - 1 + n) % n]) {
      void engine.requestLarge(k).then((arrived) => {
        if (arrived) wake();
      });
    }
  }

With four larger pictures held at 1440×900, the textures came to 79 MB, and 143 MB with the render targets. At a phone's size at 2×, with three held, 29 MB and 74 MB.

What was measured, and what was not

Everything below was measured on one laptop with an Intel Iris Plus, in installed Chrome, on a production build, while other work was running on the same machine.

WhatResult
Component JavaScript, gzip, without React26.5 KB (11.6 on the page, 14.8 in the worker)
Pictures for the first view at 1440×900863 KB
GPU textures, desktop / phone size79 MB / 29 MB (143 / 74 with render targets)
Draw calls at rest, and in a hidden tab0
Largest step along or across a glow4 and 3 levels
Pixels shimmering in a walk's settle0 of 31,360

Frame times, long tasks, LCP and GPU time per frame were never measured on a quiet machine. The laptop was busy with other work for the whole build, so this article gives no frame rate. The room was not tested on a real phone, a real tablet, Safari or Firefox.

Make it yours

npx shadcn@latest add https://upfork.dev/r/3d-shoe-showroom-webgl-react.json

Free: no account or token needed.

The GLSL sources are in the product's gl/ folder, built into gl/shaders.ts by a small script. The wall, its maps and the photo pipeline are described in the product's README. The pictures are not covered by the MIT licence: five are real photographs under their own licences and five are AI-generated, and we have not verified those tools' terms, so check them yourself or replace the pictures.

Written by

Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork

Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL

Did it help? Like it, or rate it.

0likes
No ratings yet
0comments

Comments

0 comments

No comments yet. Questions, what you built with it, what could be better: its developer reads them.

Sign in to comment.

Sign in to comment

This version is signed in UpFork’s ledger as entry #751, so anyone can check it hasn’t changed since it was published. Check it.

Something wrong in it, or against the rules? Report it.

Read next

All articles
Behind the build

Behind the build · Pacewall — Sneaker & Shoe Store Product Grid for React and Tailwind CSS

Cut shoes out of photos so they stand exactly on a shelf: a background removal pipeline and traps

Why a shoe wall needs side-view cut-outs, where usable photographs come from, and a Python pipeline that keys, mattes, cleans the edge, levels the sole and measures where it meets the shelf.

Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork18 min readAI

14 lessons

Behind the build

Behind the build · Lightfall — Crypto & Web3 Hero Section for React with WebGL Background

A WebGL hero in a Web Worker: Lightfall's lane-space shader, OffscreenCanvas and eight traps

How Lightfall draws up to 160 light lanes in one shader pass, why its WebGL runs in a Web Worker (Lighthouse 70 to 98), and eight traps in GLSL, layout shift, timing and contrast.

Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork10 min readAI3

8 lessons