fire-paper-loader
Fire-Paper Loader
A ring of burning paper opens on black while the page builds. The mechanism: the fire is drawn by a Worker on an OffscreenCanvas that is started from <head>, so the ring keeps burning while the main thread is blocked for hundreds of milliseconds. The flame look is staging; a loader that stops dead for 450 ms reads as a hang whatever it looks like.
Nearest skills: web-design-performance covers general page speed; this one is specifically a loader that survives a blocked thread. For the burning-paper look on scroll transitions (not a loader), the flame GLSL is the same kbFlame but the page draws it. For a spinner or AI-status orb, use thinking-orbs.
Stack, verified in the source: Vanilla JavaScript, raw WebGL 1 (GLSL ES 1.00, no Three.js in the loader), Worker plus OffscreenCanvas, CSS for the label and fade.
Start from the head, not from the body
Symptom: the loader appears 1-2 s late, or the worker never gets a GL context, or the fire stands still while three.js downloads. A worker can start and create its WebGL context only while the main thread is free, which is during stylesheet loading in <head> and not again until the page is parsed.
- In a
<script>in<head>: create a canvas,transferControlToOffscreen(), start a Worker from a Blob URL of a tiny boot script, post{kbPre, canvas}and callgetContext('webgl', {antialias:false, alpha:false, depth:false, stencil:false})in the worker there and then. Keep the canvas and worker onwindow.__kbPre. - The boot script is only a shell. The loader's own
<script>(later in the body) sends its owntextContentto the worker with{kbInit, src, canvas:null, t0, origin: performance.timeOrigin, reduced, view, target}, and the workerevals it. One source text runs on both threads:const IN_W = typeof document === 'undefined'. Two copies drift apart. - Guard every step: no
Worker, noOffscreenCanvas, notransferControlToOffscreen, workeronerror, or afailmessage means draw on the main thread with the same code after the same warm-up. - Convert clocks. The worker's
performance.now()differs from the page's: sendperformance.timeOrigin, and addperformance.timeOrigin - originin the worker so every timestamp the page reads is its own.