fire-paper-loader
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFire-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 , 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.
<head>Nearest skills: 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 but the page draws it. For a spinner or AI-status orb, use .
web-design-performancekbFlamethinking-orbsStack, 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.
页面构建期间,一圈燃烧的纸在黑色背景上缓缓展开。其机制是:火焰由 Worker 在从 启动的 OffscreenCanvas 上绘制,因此即使主线程被阻塞数百毫秒,加载环仍会持续燃烧。 火焰外观属于表现层;无论外观如何,停滞 450 ms 的加载器看起来都会像卡死。
<head>最接近的 skill: 涵盖常规页面性能;这个 skill 专门用于即使线程被阻塞也能存活的加载器。如果需要用于滚动转场(而非加载器)的燃烧纸张效果,火焰 GLSL 使用相同的 ,但改由页面绘制。如果需要 spinner 或 AI 状态球,请使用 。
web-design-performancekbFlamethinking-orbs源码中已验证的技术栈:Vanilla JavaScript、原生 WebGL 1(GLSL ES 1.00,加载器中不使用 Three.js)、Worker 与 OffscreenCanvas,以及用于标签和淡出的 CSS。
Start from the head, not from the body
从 head 启动,而不是从 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 and not again until the page is parsed.
<head>- In a in
<script>: create a canvas,<head>, start a Worker from a Blob URL of a tiny boot script, posttransferControlToOffscreen()and call{kbPre, canvas}in the worker there and then. Keep the canvas and worker ongetContext('webgl', {antialias:false, alpha:false, depth:false, stencil:false}).window.__kbPre - The boot script is only a shell. The loader's own (later in the body) sends its own
<script>to the worker withtextContent, and the worker{kbInit, src, canvas:null, t0, origin: performance.timeOrigin, reduced, view, target}s it. One source text runs on both threads:eval. Two copies drift apart.const IN_W = typeof document === 'undefined' - Guard every step: no , no
Worker, noOffscreenCanvas, workertransferControlToOffscreen, or aonerrormessage means draw on the main thread with the same code after the same warm-up.fail - Convert clocks. The worker's differs from the page's: send
performance.now(), and addperformance.timeOriginin the worker so every timestamp the page reads is its own.performance.timeOrigin - origin
症状:加载器晚 1–2 秒才出现,或 Worker 始终无法取得 GL context,或 three.js 下载期间火焰静止不动。只有主线程空闲时,Worker 才能启动并创建其 WebGL context;这样的时机出现在 中加载样式表的阶段,页面解析完成后就不会再有。
<head>- 在 中的
<head>内:创建 canvas,调用<script>,通过一个微型 boot script 的 Blob URL 启动 Worker,发送transferControlToOffscreen(),并立即在 Worker 内调用{kbPre, canvas}。将 canvas 与 Worker 挂到getContext('webgl', {antialias:false, alpha:false, depth:false, stencil:false})上。window.__kbPre - boot script 只是一个外壳。加载器自身的 (位于 body 中稍后的位置)会把自己的
<script>连同textContent一并发给 Worker,再由 Worker 对其执行{kbInit, src, canvas:null, t0, origin: performance.timeOrigin, reduced, view, target}。同一份源码会同时运行在两个线程上:eval。维护两份副本会发生漂移。const IN_W = typeof document === 'undefined' - 为每一步做好降级保护:没有 Worker、没有 OffscreenCanvas、没有 、触发 Worker
transferControlToOffscreen,或收到onerror消息时,就改由主线程使用相同代码,并在完成相同预热后绘制。fail - 转换时钟。Worker 的 与页面不同:发送
performance.now(),并在 Worker 中加上performance.timeOrigin,这样页面读取的每个时间戳都基于页面自身的时钟。performance.timeOrigin - origin
Compile in parallel, warm the pipeline, then show
并行编译、预热 pipeline,然后再显示
Symptom: the first visible frames are the slowest of the load (30-130 ms, once 408 ms on Metal).
- Create both programs (ring, cinders), attach shaders, both, and only then ask for status. With
linkProgramthe driver compiles on its own threads; readingKHR_parallel_shader_compileper shader defeats it. Skip the per-shader check when the extension exists.COMPILE_STATUS - : run a real frame's draws of both programs into a 1x1 viewport, then
warmUp()1 pixel, waited. A first draw builds the GPU pipeline; make that happen before anything is seen.readPixels - Only then start . The page's
requestAnimationFrameresolves when the worker draws (typically 0.15-0.3 s, timed out at 1.2 s); the build awaits it so it does not hold the thread off for a second while the fire stands still.LOAD.ready() - Show one canvas, not two. Drawing the fire on both threads first cost compile time in the first visible frames.
症状:最初几个可见帧恰好是加载过程中最慢的帧(30–130 ms,在 Metal 上有一次达到 408 ms)。
- 创建 ring 和 cinders 两个 program,为其 attach shaders 并分别执行 ,之后才查询状态。使用
linkProgram时,驱动会在自己的线程上完成编译;逐个 shader 读取KHR_parallel_shader_compile会抵消这种并行优势。扩展存在时,应跳过逐 shader 检查。COMPILE_STATUS - :将两个 program 的真实帧 draw 调用运行到 1x1 viewport 中,然后执行
warmUp()读取 1 个像素,并等待完成。首次 draw 会构建 GPU pipeline;应确保它发生在任何内容被看到之前。readPixels - 完成上述步骤后,才启动 。页面中的
requestAnimationFrame会在 Worker 完成绘制时 resolve(通常为 0.15–0.3 s,1.2 s 超时);页面构建会等待它,从而不会在火焰静止时让主线程额外停顿一秒。LOAD.ready() - 只显示一个 canvas,而不是两个。如果最初在两个线程上都绘制火焰,其编译时间会落入最初几个可见帧之中。
The ring's pace is planned in what is seen
加载环的节奏按可见效果规划
Symptom: the coal sits at one size for a second, then leaps 2.5x; or a ring that stalls is overtaken by a faster one and reads as two fires.
- Plan the growth in , not in the raw progress
S = gain + ln(radius / first radius). Equal steps in S look like equal growth at every size. The fade-in weighs 0.3 in S (at 1 it held the size back for 0.9 s).u - Follow with a critically damped spring (omega 5/s, speed clamped to 1.6/s, integrated in 1/120 s sub-steps) toward a goal that is the real progress but never behind a creep:
S, and the goal cannot passcreep = 0.38*(1 - exp(-t/800 ms))0.290 while waiting, easing towardWAIT0.335 with a 2.5 s time constant. A hard hold and progress arriving in lumps read as a ring parked, then shoved.CEIL - Never still: minimum 0.016/s (about 5 px a frame at 8 fps), maximum
VMIN0.05/s. The seen radius: a 4 px fleck at progress 0, 5.8 px by 0.15, then the burn's own radius.VMAX - Before the page has said anything, progress creeps on its own clock, so the ring lives while three.js is still on the wire.
症状:煤块在一秒内保持同一尺寸,随后突然跃升 2.5 倍;或者一个停滞的加载环被更快的加载环追上,看起来像两团火。
- 在 中规划增长,而不是在原始进度
S = gain + ln(radius / first radius)中规划。S 中相同的步长,在任何尺寸下都呈现为均匀增长。淡入在 S 中的权重为 0.3(权重为 1 时,它会将尺寸压住 0.9 s)。u - 让 S 通过临界阻尼 spring 跟随目标(omega 5/s,速度限制为 1.6/s,以 1/120 s 子步长积分),目标值采用真实进度,但始终不低于 creep:。等待期间,目标值不能超过
creep = 0.38*(1 - exp(-t/800 ms))0.290,而是以 2.5 s 时间常数向WAIT0.335 缓动。硬性暂停和成块抵达的进度,会让加载环看起来先停住、再被猛推。CEIL - 绝不能静止:最低 为 0.016/s(在 8 fps 下约为每帧 5 px),最高
VMIN为 0.05/s。可见半径在进度为 0 时是一块 4 px 的微粒,到 0.15 时增至 5.8 px,随后过渡到燃烧本身对应的半径。VMAX - 在页面尚未给出任何状态前,进度会按照自己的时钟缓慢爬升,因此即使 three.js 仍在传输,加载环也始终保持活性。
Hand off without a stall
无停顿地完成交接
Symptom: the ring lurches into the page's intro, or the page stutters for 55-240 ms when the loader goes away.
- runs a Hermite in S from the creep's speed to the intro's speed
done()0.24 (u/s), overV_HANDseconds, and reports when the rim reaches the hand-off progressds = clamp(2.8*du/vH, 0.30, 1.2)0.36. The page resumes its own intro from exactly that progress and clock. A safety timer (HAND, and 2.4 s on the page side) resolves anyway if rAF stalled.doneDur + 400 ms - The label lands on 100 and fades on the compositor (): a busy first frame cannot cut it short.
transition: opacity .40s linear .12s - : set
close()and tell the worker to stop drawing. Two contexts drawing during the fade shared the GPU and ran the fade's frames at 35-67 ms. The last frame stays up and is faded with CSS opacity (120 ms, 250 ms under reduced motion), scaled by Web Animations keyframes along the intro's own curve so it stays on the page's rim while it goes.alive = false - Do not while the page is on screen. It held the main thread 55-240 ms at any time. Post
worker.terminate()(lose the GL context in the worker, on the worker's thread) and terminate only onfreehidden orvisibilitychange. Neverpagehideon the main thread either: it waits on all queued GPU work, including the intro's first frames (100 ms).loseContext() - Yield between build stations: sets progress and, at most every 45 ms (90 ms when the worker draws, since only the count needs it), waits one
await LOAD.step(p)plusrequestAnimationFrame. In a hidden tab it does not wait, because rAF does not run there.setTimeout 0
症状:加载环猛然闯入页面的 intro,或者加载器消失时令页面产生 55–240 ms 的卡顿。
- 会在 S 中执行 Hermite 插值,将速度从 creep 的速度过渡到页面 intro 的速度
done()0.24(u/s),持续V_HAND秒,并在环缘抵达交接进度ds = clamp(2.8*du/vH, 0.30, 1.2)0.36 时报告状态。页面从完全相同的进度和时钟继续自己的 intro。安全计时器(HAND,页面侧另设为 2.4 s)无论doneDur + 400 ms是否卡住都会 resolve。requestAnimationFrame - 标签数值落到 100,并通过 compositor 淡出():繁忙的首帧无法提前截断这一过程。
transition: opacity .40s linear .12s - :设置
close(),并通知 Worker 停止绘制。淡出期间两个 context 同时绘制会共享 GPU 资源,并使淡出帧耗时达到 35–67 ms。最后一帧继续保留,并通过 CSS opacity 淡出(120 ms;减少动态效果时为 250 ms),同时使用 Web Animations keyframes 沿 intro 自身的曲线缩放,使其在离开时仍贴合页面环缘。alive = false - 页面显示期间,绝不要调用 。 这种操作曾在主线程上造成 55–240 ms 的阻塞。发送
worker.terminate()消息,让 Worker 在自身线程中释放 GL context,并且只在free变为 hidden 或触发visibilitychange时才 terminate。也绝不要在主线程调用pagehide:它会等待所有已排队的 GPU 工作,包括 intro 的最初几帧(约 100 ms)。loseContext() - 在各个构建步骤之间让出执行权:会设置进度,等待频率最多为每 45 ms 一次(Worker 绘制时为 90 ms,因为此时只需更新计数),等待内容为一个
await LOAD.step(p)加一次requestAnimationFrame。隐藏标签页中不等待,因为setTimeout 0不会运行。requestAnimationFrame
The burn look: the footer is the reference
燃烧质感:页脚效果是基准
Symptoms the owner named, in order: "too static", "the black part should be alpha masked", then after a four-step posterised ramp, "too non-realistic, like before, the footer burn was better".
- A luminous gradient, not posterised steps. The ember zone is a smooth ramp yellow to orange to deep red, hot stretches carrying yellow further back, ending at a crisp torn lip. Hard steps read as a poster.
- A bright beading thread at the front, 0.7-5 px, pinched and swollen along its length (an even stroke reads as neon), with a tight additive glow of 2-5 px. Nothing wider: a fat bloom turns the rim into a neon hoop.
- It must move. Heat pulses run along the edge both ways (), patches flicker at 7 Hz (
run), beads drift, a roll travels back through the zone. A still frame of it looks fine; the loader is judged in motion.flk - Gold, not cream. The hottest core colour is , ember
(1.85, 1.02, 0.30)at most, only where hot. Cream and khaki came from letting the hot end go white or dimming by darkening; a dim stretch goes down the ramp toward deep red instead.(1.10, 0.64, 0.17) - Porous char, no rims. The charred band behind the ember is near black with fibre grain, and pores open in it with age down to the frame beneath. Never ring the pores with glowing edges: they joined into one drawn contour along the edge.
(0.0085, 0.0062, 0.0048) - A ring never starts as a disc or an "o". It begins as a torn ember arc, 3.5-7 px thick, a little over half round, whose ends crawl round and meet between 6.5 and 12 px. Ring width is never under 3.5-7 px or it reads as a drawn outline.
- Cinders: four instanced sets (1800, 720, 360, 160 quads, seeded mulberry32 ), only off the upper arc (20-160 degrees), short lives, heat orange to deep red and never white. Tone-mapped ACES, then sRGB in the shader (
0x2f6b9d1thenaces), so keepsrgbalpha 1 and the canvasgl_FragColor.alpha:false
按顺序列出需求方指出的症状:“太静态了”、“黑色部分应该用 alpha 遮罩”;在采用四级 posterised ramp 后,又出现:“太不真实了,和之前一样,页脚的燃烧效果更好”。
- 采用明亮渐变,而不是 posterised 色阶。 余烬区是一条平滑的渐变带,从黄色过渡到橙色,再到深红色;高温区会把黄色向后方延伸,最终收束成清晰、撕裂状的边缘。硬色阶看起来会像海报。
- 最前缘是一串明亮的珠状亮线,宽度为 0.7–5 px,沿长度方向不断收细和鼓胀(均匀的 stroke 看起来像 neon),外围带有紧致的 additive glow,范围为 2–5 px。宽度不能更大;过宽的 bloom 会把环缘变成霓虹光圈。
- 它必须持续运动。 热量脉冲沿边缘双向奔涌(),斑块以 7 Hz 闪烁(
run),珠状亮线不断漂移,一道滚流还会穿过余烬区向后传播。单看静止帧似乎没有问题,但加载器必须在运动中接受评判。flk - 使用金色,而不是奶油色。 最热核心色为 ,余烬色最高为
(1.85, 1.02, 0.30),且只应出现在高温区域。奶油色和卡其色通常来自两种错误:让最热端变白,或通过整体压暗来降低亮度。较暗区域应沿渐变降向深红色,而不是整体变暗。(1.10, 0.64, 0.17) - 使用多孔炭层,不添加发光轮廓。 余烬后方的炭化带接近黑色 ,并带有纤维纹理;孔隙会随时间扩大,并向下贯穿到下层画面。绝不要用发光边缘勾勒孔隙,否则所有孔隙都会沿边缘连成一条绘制出来的轮廓。
(0.0085, 0.0062, 0.0048) - 加载环绝不能从圆盘或 开始。 它始于一段撕裂的余烬弧,厚度为 3.5–7 px,略超过半圆;弧线两端沿圆周缓慢爬行,并在 6.5–12 px 之间汇合。环宽必须保持在 3.5–7 px,不能更窄,否则会像一条绘制出来的轮廓。
o - Cinders:四组 instanced quads(1800、720、360、160 个,使用带种子的 mulberry32 ),只从上方弧段(20–160°)散出,生命周期短,颜色从炽热橙色过渡到深红色,绝不出现白色。先进行 ACES tone mapping,再在 shader 中转为 sRGB(
0x2f6b9d1后执行aces),因此要保持srgb的 alpha 为 1,并设置 canvas 的gl_FragColor。alpha:false
Cost, measured
实测成本
- Drawn on the main thread against a build that blocks it 250-450 ms at a time, the longest gap between fire frames was 450 ms. In the worker, on the same page, 9 ms.
- Render at DPR up to 1.5 (the source's cap; the general web cap is 2). The ring is one full-screen triangle plus 3040 cinder quads; the fragment shader is the cost, not the cinders.
- Time step: the worker draws on its own frames; the display clock is the page's, so a dt of the animation is clamped by design (max 1.5 s, 1/120 s spring sub-steps).
dtp
- 在主线程上绘制时,面对每次会阻塞 250–450 ms 的构建,火焰帧之间的最长间隔为 450 ms。改为在 Worker 中绘制后,同一页面的间隔为 9 ms。
- DPR 最高为 1.5(这是源码中的上限;Web 的通用上限为 2)。加载环由一个全屏 triangle 和 3040 个 cinder quads 组成;成本主要来自 fragment shader,而不是 cinders。
- 时间步进:Worker 按自己的帧绘制,显示时钟则属于页面,因此动画 dt 会按设计进行 clamp(最大 1.5 s,spring 使用 1/120 s 子步长)。
dtp
Lifecycle and accessibility
生命周期与无障碍
- on the label with
role="progressbar"kept in step; the canvases arearia-valuenow. The count is text, so it is readable without the fire.aria-hidden - : no time (
prefers-reduced-motion: reduce), no cinders, no compositor scale; the ring still opens with progress as a still, grained rim and the fade is 250 ms. The frame is designed as a still, not hidden.tSec = 0 - Hidden tab: rAF pauses in both threads; stops waiting;
step()resolves at once, and the worker is terminated only then.done() - After 12 s at under 40 percent, the label says "Still kindling": slow connections should be told rather than burnt at quietly forever.
- Resize: post the new (w, h, DPR) to the worker; the canvas resizes on the next frame.
view
- 在标签上使用 ,并让
role="progressbar"始终与进度同步;canvas 使用aria-valuenow。计数采用文本,因此即使看不到火焰也能读取。aria-hidden - :时间保持为 0(
prefers-reduced-motion: reduce),不生成 cinders,也不使用 compositor scale;加载环仍会随进度展开,但呈现为静止、带颗粒的边缘,淡出时长为 250 ms。画面被设计为静态版本,而不是直接隐藏。tSec = 0 - 隐藏标签页:两个线程中的 都会暂停;
requestAnimationFrame停止等待;step()立即 resolve,并且只在此时 terminate Worker。done() - 12 s 后若进度仍低于 40%,标签会显示“仍在引燃”:这样慢速连接能够及时得到提示,而不是无声无息地一直燃烧下去。
- 调整尺寸时,将新的 (w、h、DPR)发送给 Worker;canvas 会在下一帧完成 resize。
view
Boundary of what this skill gives you
此 skill 提供的边界
Mechanism (keep): head-started worker, one source on two threads, parallel compile plus waited 1-px warm-up, S-space pacing, Hermite hand-off, stop-then-CSS-fade, no early terminate. Staging (replace freely): the fire's palette and shape, the label copy, the page it hands to. The demo stands in for the page's intro: after the loader's ring runs on along the intro curve for 1.8 s until it leaves the frame, where the real page draws its own rim from the hand-off progress.
done()Source: extracted from the KINDLING loader of KIBORI 木彫, a scroll-linked Three.js woodcraft page whose 1.1 MB build held the main thread for up to 1.2 s.
机制(保留):从 head 启动 Worker、同一份源码运行于两个线程、并行编译加带等待的 1 px warm-up、S 空间节奏控制、Hermite 交接、先停止再通过 CSS 淡出,以及不提前 terminate。表现层(可自由替换):火焰的配色与形状、标签文案,以及最终交接到的页面。demo 代替页面的 intro:执行 后,加载环会沿 intro 曲线继续运行 1.8 s,直到离开画面;真实页面随后从交接进度开始绘制自己的环缘。
done()来源:提取自 KIBORI 木彫 的 KINDLING loader。这是一个滚动联动的 Three.js 木艺页面,其 1.1 MB 构建曾让主线程最多阻塞 1.2 s。