paper-cinder-transition

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Paper Cinder Transition

纸张余烬转场

A page does not fade into the next one, it is eaten by a line of fire. Reach for
fire-paper-loader
when the burn is a ring on a loading screen that must survive a blocked main thread; reach for this when the burn is the change between two scenes you own. For text that is written in by a mask rather than burned, use
masked-reveal
; for a scroll-scrubbed sequence with no burn,
scroll-scrubbed-visual-sequence
.
页面不是淡入下一页面,而是被一道火线吞噬。当燃烧效果是加载屏上的圆环,并且需要 在主线程被阻塞时仍能正常工作时,请使用
fire-paper-loader
;当燃烧效果用于切换 你掌控的两个场景时,请使用本方案。对于通过蒙版写入而非燃烧呈现的文本,使用
masked-reveal
;对于没有燃烧效果、由滚动进度驱动的序列,使用
scroll-scrubbed-visual-sequence
。

The mechanism

机制

One full-screen pass decides, per pixel, whether it belongs to the frozen old frame (ahead of the line), to the burning zone (the line), or to the live new scene (behind it), from a single signed distance to a noisy front. Everything else is staging. Remove any of the three layers and it stops reading as fire: no frozen frame and the old page keeps moving under the flame, no live scene and the reveal is a still, no pixel-true distance and the edge is a soft blotch.
xc  = signed distance to the front in CSS px (+ ahead, - burnt)
reveal = clamp(0.5 - xc/aa, 0, 1)      // new frame from the front back, cut to the pixel
col = mix(fromFrozen, toLive, reveal)
col = mix(col, charcoal,  bed * pores) // 26 px band of torn, porous char (alpha-masked)
col += flame(xc) + charGlow(xc)        // additive: thread, ember zone, tight glow
全屏 pass 根据到含噪前沿的单一有符号距离,逐像素判断该像素属于冻结的旧帧 (位于线条前方)、燃烧区(线条本身),还是实时的新场景(位于线条后方)。其余部分都只是布景。 移除三个图层中的任意一个,它就不再像火焰:没有冻结帧,旧页面会在火焰下继续运动; 没有实时场景,显露出来的就是静止画面;没有像素级精确的距离,边缘就会变成柔软的斑块。
xc  = signed distance to the front in CSS px (+ ahead, - burnt)
reveal = clamp(0.5 - xc/aa, 0, 1)      // new frame from the front back, cut to the pixel
col = mix(fromFrozen, toLive, reveal)
col = mix(col, charcoal,  bed * pores) // 26 px band of torn, porous char (alpha-masked)
col += flame(xc) + charGlow(xc)        // additive: thread, ember zone, tight glow

Staging split

场景布置拆分

goes in the skillstaging (yours to change)
the composite order, the distance field, the ember ramp, char rules, the noise bakewhat the two scenes are, palette of the scenes, typography, page copy
the two scene buffers (frozen from, live to) and the scissorwhich side the line enters from (see below)
timing, easing, DPR, reduced-motion stillcontrol chrome
放入 skill 的内容场景布置(由你调整)
合成顺序、距离场、余烬渐变、炭化规则、噪声烘焙两个场景的内容、场景配色、排版、页面文案
两个场景的 buffer(冻结的 from、实时的 to)以及 scissor线条从哪一侧进入(见下文)
时序、缓动、DPR、reduced-motion 静帧控制组件

Rules, each tied to the failure it prevents

每项规则都对应它所防止的失败

Draw the frozen old frame and the live new scene, never one fading into the other. Snapshot the outgoing scene once into a full-resolution target when the transition starts (clock frozen) and keep rendering the incoming scene live. A cross-fade, or a burn over a still-animating old scene, shows two chapters in one patch of screen and the fire stops being the thing that changes the page.
Measure the front in pixels, not in field units.
xc = (field - T)/grad * pxHeight / pxScale
. A level set alone runs a hair-thin seam wherever the noise leans with the sweep and a fat one where it leans against it, so the flame's width is uneven for no reason.
Take the slope by finite differences a few px apart, never
dFdx
of the baked texture.
The baked noise's slope is constant across each texel; per pixel it stripes the band across the line. Sample the broad field at
+4 px
in x and y (
hStep = 4*uPxS/uPxH
) and clamp the gradient to
0.35/aspect .. 3/aspect
.
The ember zone is a smooth luminous gradient, not steps. Symptom: it reads as a non-realistic poster. Grade it continuously by a heat variable: yellow at the front, then orange, then deep red at a crisp torn lip. Hot stretches carry yellow further back. Ramp constants (linear, before tone map):
stoprgbwhere
deep
0.34, 0.022, 0.003
lip, tau 0.05
orange
0.86, 0.20, 0.024
tau 0.42
gold
1.02, 0.38, 0.055
tau 0.76
hot gold
1.10, 0.64, 0.17
x (0.30 + 0.70*hot)
tau above 0.86
thread
1.40, 0.56, 0.09
to
1.85, 1.02, 0.30
by heat
the front line
glow
0.95, 0.20, 0.022
x 0.60
2 to 5 px round the thread
Keep hot colours gold, never cream. A pale yellow reads cream at full heat and khaki when dimmed. Nothing on the ramp is grey or white; a dim stretch goes down the ramp, it is not just darker.
The front is a bright thread that beads and pinches, 0.7 to 5 px, not an even stroke. An even stroke reads as neon. Width
(0.9 + 2.6*heat) * bead
with
bead
0.5 to 1.5 from a noise along the edge. Give it a tight glow only (
exp(-|xc+0.5wc| / (2 + 2.8*heat))
), no wide halo.
It must visibly move, at rest of the sweep too. Symptom: "too stale/static". Drive heat with pulses that run along the edge in both directions (
sA*0.018 - t*1.30
,
sA*0.027 + t*1.70
), flicker at
t*7
, a roll back through the zone at
-t*2.6
, drifting beads (
-t*0.8
), and licking tongues on the front (
t*1.1
,
t*2.8
, at most 0.022 and 0.010 of the screen). Stills hide all of this, so judge the burn in a real-time held clip.
Alpha-mask the black char; it is not a flat slab. The char is a band 26 px wide (plus 12 px of front slack) that crumbles off the live scene along a torn far edge, with pores opening in it as it ages, down to whatever lies beneath. A solid black strip reads as a wipe. Cover =
bed * pores
, edge torn by
8*noise(0.08) + 3*noise(0.25)
px, which is gentler than the front's own slope so it frays without cutting loose an island.
Never ring the pores with a glowing rim. Rims joined into one drawn contour along the char edge. Pores are just holes.
Take the char's edge from the broad front, not the fine one. A 2-D noise cut loose islands of char that floated on the new frame. Use a noise along the edge only (
kbEdgeNLine
), and let
edge = zoneW + fs + bedW*(0.35 + 1.10*n)
with
fs
at most 12.
Scorch the old frame ahead of the flame in one continuous darkening, 20 to 40 px reach, from tan
0.80, 0.62, 0.42
to dark brown
0.20, 0.10, 0.045
by
tk^2
, reach varied by noise. Three separate steps drew outlines that copied the flame.
Bake the noise; do not stack fbm per pixel. Cost at 2880x1800: per-pixel fbm 1.5 to 3 ms a pass; one tileable texture and a hash table, 0.3 to 0.6 ms with the same look. Bake once: a 1024^2 RGBA16F texture on a 16-cell period (r fbm 3 octaves, g ridged 3, b gradient, a ridged 2, all sampled at
x*0.0625
) and the integer hash lattice in a 2048^2 R16F table, filtered at the smoothstep of the fraction so it equals the value noise exactly.
Scissor the live scene to the revealed side, and render it small. Behind the line only, plus
0.16
of the screen for bays and haze. During a burn draw it at
0.75
scale (the composite stays full size, because the edge is what the eye reads); once the transition has rested 150 ms, cross-fade to the full-size scene over 0.3 s, both drawn meanwhile, so the sharpness step is never seen and never costs a moving frame. Put text on the live side at full size in the composite; drawn in the 0.75 buffer it goes soft.
Skip the noise where nothing can reach. By
hx
: more than
0.215 + 0.15
ahead of the front return the frozen frame, and behind
0.25 - 0.215
return the live one. Inside the band, more than 62 px ahead (plus slack) or 125 px behind does the same. That is what pays for full device resolution.
Sweep from the side the incoming words sit.
hx = dir>0 ? uv.x : 1-uv.x
; a chapter whose text is on the right is burned in from the right. Going backward uses the target's side too. A line that always runs left to right crosses its own new title last.
Seed every burn.
seed = (chapter+1)*17.3
picks 2.0 to 3.6 broad bays up the frame, their depth (0.055 to 0.115) and lean (
(sv.x-0.5)*0.07
), so two burns are never the same silhouette.
绘制冻结的旧帧和实时的新场景,绝不能让一个场景淡入另一个场景。 转场开始时,将退场场景一次性快照到全分辨率 target 中(冻结时钟),并继续实时渲染入场场景。 交叉淡入,或者在仍会运动的旧场景上燃烧,会让同一块屏幕区域同时显示两个章节, 火焰便不再是负责换页的元素。
以像素而非场单位测量前沿。
xc = (field - T)/grad * pxHeight / pxScale
。 仅靠 level set,会在噪声顺着扫掠方向倾斜的位置形成细如发丝的接缝,在逆着扫掠方向的位置形成粗接缝, 导致火焰宽度无缘无故地不均匀。
通过间隔数像素的有限差分求斜率,绝不要对烘焙纹理使用
dFdx
。
烘焙噪声的斜率在每个 texel 内恒定;逐像素计算会让接缝沿整条线产生条纹。 以间隔数像素的方式在 x 和 y 方向采样宽范围场(
hStep = 4*uPxS/uPxH
), 并将梯度限制在
0.35/aspect .. 3/aspect
。
余烬区应使用平滑的发光渐变,而不是色阶。 症状:看起来像不真实的海报。 用 heat 变量连续分级:前沿为黄色,然后是橙色,最后在清晰撕裂的唇边处转为深红色。 高温段会让黄色向后延伸更远。渐变常量(线性,位于 tone map 之前):
停止点rgb位置
深色(deep)
0.34, 0.022, 0.003
唇边,tau 0.05
橙色(orange)
0.86, 0.20, 0.024
tau 0.42
金色(gold)
1.02, 0.38, 0.055
tau 0.76
高热金色(hot gold)
1.10, 0.64, 0.17
x (0.30 + 0.70*hot)
tau 高于 0.86
细线(thread)
1.40, 0.56, 0.09
到
1.85, 1.02, 0.30
,按 heat 插值
前沿线
辉光(glow)
0.95, 0.20, 0.022
x 0.60
细线周围 2 到 5 px
高热颜色始终保持金色,绝不能变成奶油色。 浅黄色在满热时会呈现奶油色,变暗后则呈卡其色。渐变中不能出现灰色或白色; 变暗的区段应沿渐变向低处移动,而不是仅仅降低亮度。
前沿是聚珠、收束的明亮细线,宽度为 0.7 到 5 px,不能是均匀笔画。 均匀笔画会像霓虹灯。宽度为
(0.9 + 2.6*heat) * bead
,其中
bead
为 0.5 到 1.5, 由沿边缘的噪声决定。仅添加紧凑辉光(
exp(-|xc+0.5wc| / (2 + 2.8*heat))
),不要宽泛光晕。
即使在扫掠停驻时,它也必须明显运动。 症状:“too stale/static”(太陈旧或静止)。 用沿边缘双向移动的脉冲驱动 heat(
sA*0.018 - t*1.30
、
sA*0.027 + t*1.70
),在
t*7
处闪烁,以
-t*2.6
穿过燃烧区向后滚动, 加入漂移的串珠(
-t*0.8
),以及前沿上舔舐的火舌(
t*1.1
、
t*2.8
, 最多占屏幕的 0.022 和 0.010)。静帧会隐藏所有这些效果,因此应在实时录制的片段中判断燃烧效果。
对黑色炭层使用 alpha mask;它不是一块平板。 炭层宽 26 px(另加前沿的 12 px 余量),沿撕裂的远端边缘从实时场景上碎落; 随着老化,孔隙会在炭层中张开,透出下方内容。一条纯黑带会看起来像简单擦除。 覆盖率 =
bed * pores
,边缘通过
8*noise(0.08) + 3*noise(0.25)
px 撕裂, 这比前沿自身的斜率更缓和,因此只会磨损起毛,不会切下漂浮的孤立碎片。
绝不要让孔隙周围出现发光轮廓。 发光边缘连接后会沿炭层边缘形成一条画出来的轮廓线。孔隙只是洞。
炭层边缘取自宽范围前沿,而不是精细前沿。 二维噪声会切下漂浮在新帧上的孤立炭块。只使用沿边缘的噪声(
kbEdgeNLine
), 并令
edge = zoneW + fs + bedW*(0.35 + 1.10*n)
,其中
fs
最大为 12。
以一次连续的加深处理来烧灼火焰前方的旧帧, 覆盖距离为 20 到 40 px, 从棕褐色
0.80, 0.62, 0.42
按
tk^2
过渡到深棕色
0.20, 0.10, 0.045
, 并使用噪声改变覆盖范围。分成三个独立色阶会画出复制火焰形状的轮廓。
烘焙噪声;不要逐像素堆叠 fbm。 在 2880x1800 下,成本为:逐像素 fbm 每个 pass 需要 1.5 到 3 ms; 一张可平铺纹理加一张 hash 表,在保持相同效果的情况下只需 0.3 到 0.6 ms。 只烘焙一次:在 16 单元格周期上创建一张 1024^2 RGBA16F 纹理 (r 为 3 个八度的 fbm,g 为 3 个八度的 ridged,b 为 gradient,a 为 2 个八度的 ridged, 全部按
x*0.0625
采样),并在 2048^2 R16F 表中创建整数 hash lattice; 按小数部分的 smoothstep 过滤,使其与 value noise 完全一致。
用 scissor 将实时场景裁剪到显露侧,并缩小渲染。 只在线条后方,并额外包含屏幕的
0.16
以呈现 bays 和 haze。 燃烧过程中按
0.75
比例绘制(composite 保持全尺寸,因为眼睛感知的是边缘); 转场停驻 150 ms 后,在 0.3 s 内交叉淡入全尺寸场景,期间同时绘制两者, 这样既看不到清晰度突变,也不会给运动中的帧增加成本。 文字应在 composite 中以全尺寸绘制在实时侧;画在
0.75
buffer 中会变模糊。
跳过噪声影响不到的区域。 按
hx
判断:前沿前方超过
0.215 + 0.15
时返回冻结帧, 后方超过
0.25 - 0.215
时返回实时场景。在 band 内部,前方超过 62 px(另加余量) 或后方超过 125 px 时同样如此。这正是全设备分辨率性能得以维持的原因。
从入场文字所在的一侧扫掠。
hx = dir>0 ? uv.x : 1-uv.x
;文字位于右侧的章节应从右侧烧入。 向后导航时也使用目标场景所在的一侧。若线条始终从左向右移动,它最后会越过自己的新标题。
为每次燃烧设置随机种子。
seed = (chapter+1)*17.3
用于选取沿画面向上分布的 2.0 到 3.6 个宽阔 bays, 以及它们的深度(0.055 到 0.115)和倾斜(
(sv.x-0.5)*0.07
), 使两次燃烧永远不会具有相同轮廓。

Constants

常量

itemvalue
band half width
0.215
screen heights
front, linear
-0.26 + 1.62 * rc
,
rc = clamp((f-0.01)/0.97)
, in and out off frame
duration3.0 s default; 1.5 to 5 s all read
line noise scalesbroad
q*1.10
(0.055-0.115),
q*2.60
(0.050-0.030); fine 0.014, 0.006, 0.0025
ember zone width12 to 66 px,
(18 + 20*min(w,2.6)) * (0.55 + 0.45*life)
charbed 26 px, crust 3 to 6 px, sumi
0.0085, 0.0062, 0.0048
heat shimmerabout 1.8 px of
uPxH
, only within 18 px of the front
bloombright pass threshold 1.15, half-res and quarter-res blurs, weights
0.26*4
and
0.34*4
tonevignette
0.42 + 0.58v
, ACES, sRGB, grain 0.003
live scene0.75 scale in transit, 150 ms rest, 0.3 s cross-fade
budgets
dt
clamp 1/30 s, DPR cap 2
项目值
band 半宽
0.215
个屏幕高度
前沿,线性
-0.26 + 1.62 * rc
,
rc = clamp((f-0.01)/0.97)
,进入和离开时均在画面外
持续时间默认 3.0 s;1.5 到 5 s 均可读
线噪声尺度宽范围
q*1.10
(0.055-0.115)、
q*2.60
(0.050-0.030);精细 0.014、0.006、0.0025
余烬区宽度12 到 66 px,
(18 + 20*min(w,2.6)) * (0.55 + 0.45*life)
炭化层bed 26 px,crust 3 到 6 px,sumi
0.0085, 0.0062, 0.0048
热浪扰动约 1.8 px 的
uPxH
,仅在距前沿 18 px 内
bloom亮部提取阈值 1.15,半分辨率和四分之一分辨率模糊,权重
0.26*4
和
0.34*4
色调暗角
0.42 + 0.58v
,ACES,sRGB,grain 0.003
实时场景过渡期间 0.75 缩放,停驻 150 ms,0.3 s 交叉淡入
性能预算
dt
限制为 1/30 s,DPR 上限为 2

Gotchas, symptom first

常见问题:先看症状

  • The flame is a thin red line on black char, no ember zone. The baked noise and hash samplers are unbound, so
    life
    reads as the frame texture's value. Bind
    uKbN
    and
    uKbH
    .
  • Bare garbage or a hard cut behind the line. The live scene is only drawn inside the scissor (
    front + 0.215 + 0.16
    ); a narrower margin lets the composite read undrawn texels.
  • Stair-steps or 2x2 blocks along the char edge. A derivative taken past a skipped branch. Compute the pixel change from the field's own scale (
    kbAA(v, w)
    ).
  • Islands of char floating on the new frame. The far edge was cut from fine noise; take it from the broad front.
  • A milky band inside the ember edge. Lifting the blacks with a curve washed it white over paper; light the dark areas by a gain instead.
  • The scene behind the line looks soft after the burn ends. The 0.75 buffer stayed; run the full-size cross-fade after the rest interval.
  • Timings look slow. A hidden Browser pane keeps rendering and steals the GPU; measure in a headless browser on the GPU flags (
    --use-angle=metal --enable-gpu
    ) with the pane parked. Kibori's transitions held p50 8.3 ms (120 Hz cap) once the noise was baked and the scene scissored; this demo's burn measured p50 8.3 ms, p90 8.8 ms at 1440x900 the same way.
  • The grain and embers swim as the front passes. Keep them on the paper (
    g
    in paper coordinates plus a per-burn offset), never in screen space; only the front moves.
  • 火焰是在黑色炭层上的细红线,没有余烬区。 烘焙噪声和 hash 采样器未绑定,导致
    life
    读取到 frame texture 的值。绑定
    uKbN
    和
    uKbH
    。
  • 线条后方出现未绘制内容或硬切。 实时场景只在 scissor 内绘制(
    front + 0.215 + 0.16
    );余量过窄会让 composite 读取到未绘制的 texel。
  • 炭层边缘出现阶梯或 2x2 色块。 导数取自被跳过分支之后的位置。应根据场自身的尺度(
    kbAA(v, w)
    )计算像素变化。
  • 新帧上漂浮着孤立炭块。 远端边缘使用了精细噪声切割;应从宽范围前沿取得边缘。
  • 余烬边缘内部出现乳白色带。 抬黑曲线会在纸张上洗成白色;应使用 gain 提亮暗部。
  • 燃烧结束后,线条后方的场景仍然模糊。
    0.75
    buffer 一直保留;应在停驻时间结束后执行全尺寸交叉淡入。
  • 时序看起来太慢。 隐藏的 Browser 面板仍在渲染并抢占 GPU;应将面板停放,在无头浏览器中通过 GPU 标志(
    --use-angle=metal --enable-gpu
    )进行测量。采用相同方式,Kibori 的转场在烘焙噪声并裁剪场景后保持 p50 8.3 ms(120 Hz 上限);此 demo 的燃烧效果在 1440x900 下测得 p50 8.3 ms、p90 8.8 ms。
  • 前沿经过时,grain 和余烬会游动。 将它们固定在纸张坐标上(使用纸张坐标中的
    g
    ,并加上每次燃烧的偏移),绝不能使用屏幕坐标;只有前沿需要移动。

Accessibility and lifecycle

无障碍与生命周期

  • prefers-reduced-motion: reduce
    : no burn. Render the designed still of each scene at a fixed time and cross-fade the frozen and live frames over 0.35 s (
    smoothstep
    ). Controls stay live.
  • Pause on
    document.hidden
    and when the canvas leaves the viewport; reset the time base on resume so the first frame does not integrate the pause.
  • Clamp
    dt
    to 1/30 s, cap DPR at 2, size from a
    ResizeObserver
    and build nothing at a zero viewport.
  • A press during a burn queues the target; the last one wins after the line leaves.
  • Real buttons, arrow keys and 1 to 3 change chapters, and a live region names the arrival.
  • prefers-reduced-motion: reduce
    :不燃烧。在固定时间渲染每个场景的设计静帧,并在 0.35 s 内使用
    smoothstep
    交叉淡化冻结帧和实时帧。控件保持可用。
  • 在
    document.hidden
    以及 canvas 离开视口时暂停;恢复时重置时间基准,避免首帧积分暂停期间的时间。
  • 将
    dt
    限制为 1/30 s,DPR 上限为 2,根据
    ResizeObserver
    设置尺寸,视口尺寸为零时不构建任何内容。
  • 燃烧过程中按下按钮会排队目标场景;线条离开后,最后一次操作生效。
  • 真实按钮、方向键以及 1 到 3 可切换章节,并由 live region 宣告到达的章节。

Cost

成本

Measured in the source page: per-pixel noise was the cost, not the flame. Baking it, and returning the plain scene away from the line, is the lever. The 0.75 scale and the scissor come second. Do not chase MSAA on the composite: the edge is drawn to the pixel by
kbAA
.
在源页面中测得:成本来自逐像素噪声,而不是火焰。烘焙噪声,并在线条影响范围之外 直接返回普通场景,是首要优化手段。其次是
0.75
缩放和 scissor。 不要在 composite 上追逐 MSAA:边缘由
kbAA
逐像素绘制。

Provenance

来源

Extracted from KIBORI, a WebGL timber-workshop page whose chapters burn into each other and whose footer paper burn set the look. The
EDGE
GLSL (flame, char, pores, crust, scorch) and the burn pass in
demo.html
are that page's own, ported, with the scenes replaced by three shader-drawn rooms.
提取自 KIBORI,这是一个 WebGL 木工工坊页面,其各章节会相互燃烧过渡, 页脚的纸张燃烧效果定义了整体视觉风格。
demo.html
中的
EDGE
GLSL (火焰、炭层、孔隙、结壳、焦痕)和燃烧 pass 均移植自该页面, 并已将场景替换为三个由 shader 绘制的房间。