Skip to content

Instantly share code, notes, and snippets.

@byteab
Created August 3, 2026 16:27
Show Gist options
  • Select an option

  • Save byteab/55af58985d0338d63292ef550e9d85b4 to your computer and use it in GitHub Desktop.

Select an option

Save byteab/55af58985d0338d63292ef550e9d85b4 to your computer and use it in GitHub Desktop.
Realistic burning paper effect. built with react-native
/**
* <BurningPhotoCard /> — a photo card printed on a sheet of paper that catches
* fire and burns away when you press Delete.
*
* Everything is in this one file: the WGSL shaders, the WebGPU burn simulation,
* the procedural paper grain, the Skia rasterizer that prints the card, and the
* React component. Drop it in your project and import it.
*
* ---------------------------------------------------------------------------
* INSTALL
*
* npx expo install @shopify/react-native-skia react-native-webgpu
*
* react-native-webgpu is native code, so this needs a development build — it
* will not run in Expo Go, and it does not run on web (the component renders a
* short notice there instead).
*
* On iOS the WebGPU package needs the New Architecture and a deployment target
* of 15.1+; on Android, minSdkVersion 26+.
*
* ---------------------------------------------------------------------------
* USE
*
* import { BurningPhotoCard } from './BurningPhotoCard';
*
* export default function Screen() {
* return <BurningPhotoCard />;
* }
*
* It takes no props. This is a worked example rather than a component to
* configure, so the photograph and the copy are constants at the top of "THE
* INK" — PHOTO, TITLE, META and DELETE_LABEL — and changing the card means
* editing them. The component fills its parent, so give it a flex:1 container
* or use it as a whole screen.
*
* The card — the picture, the type, the button — is not React. It is drawn once
* with Skia into a single texture and handed to the engine as "ink", which the
* paper shader multiplies into the sheet's albedo, so the button scorches and
* chars and falls with the fibre it is printed on instead of floating over it.
*
* Tunables worth knowing about: DEFAULT_PAPER_PARAMS (the whole burn model),
* CARD_PARAMS (this screen's overrides), the layout constants under "THE INK",
* and PARTICLE_COUNT / MASK_SIZE if you need to trade quality for framerate.
*
* @license MIT
*/
import {
AlphaType,
ClipOp,
ColorType,
matchFont,
PaintStyle,
Skia,
StrokeCap,
StrokeJoin,
useImage,
type SkCanvas,
type SkFont,
type SkImage,
type SkPaint,
type SkSurface,
} from "@shopify/react-native-skia";
import { useCallback, useEffect, useRef, useState } from "react";
import {
PixelRatio,
Platform,
Pressable,
StyleSheet,
Text,
useWindowDimensions,
View,
} from "react-native";
import {
Canvas,
useCanvasRef,
type RNCanvasContext,
} from "react-native-webgpu";
/* ==========================================================================
* WGSL SHADER SOURCES
*
* Pipelines:
* SIM_WGSL compute — reaction/diffusion burn-mask solver (ping-pong storage textures)
* PAPER_WGSL render — paper surface, char/ember zones, curling ash, discard
* PARTICLE_WGSL compute + render — fire/smoke emitter fed by the burn mask
* COMPOSITE_WGSL render — ACES tonemap, vignette, dither
*
* There is deliberately no bloom pass (a bright-pass and a separated gaussian
* between the scene and the composite) — see `DEFAULT_PAPER_PARAMS`.
* ========================================================================== */
/** Simplex 3D noise, curl noise and hash helpers shared by every stage. */
const NOISE = /* wgsl */ `
const PI: f32 = 3.14159265;
fn mod289v3(x: vec3f) -> vec3f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
fn mod289v4(x: vec4f) -> vec4f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
fn permute4(x: vec4f) -> vec4f { return mod289v4(((x * 34.0) + 1.0) * x); }
fn taylorInvSqrt4(r: vec4f) -> vec4f { return 1.79284291400159 - 0.85373472095314 * r; }
// Ashima-style simplex noise, ported to WGSL. Returns roughly [-1, 1].
fn snoise(v: vec3f) -> f32 {
let C = vec2f(1.0 / 6.0, 1.0 / 3.0);
let D = vec4f(0.0, 0.5, 1.0, 2.0);
var i = floor(v + dot(v, C.yyy));
let x0 = v - i + dot(i, C.xxx);
let g = step(x0.yzx, x0.xyz);
let l = 1.0 - g;
let i1 = min(g.xyz, l.zxy);
let i2 = max(g.xyz, l.zxy);
let x1 = x0 - i1 + C.xxx;
let x2 = x0 - i2 + C.yyy;
let x3 = x0 - D.yyy;
i = mod289v3(i);
let p = permute4(permute4(permute4(
i.z + vec4f(0.0, i1.z, i2.z, 1.0)) +
i.y + vec4f(0.0, i1.y, i2.y, 1.0)) +
i.x + vec4f(0.0, i1.x, i2.x, 1.0));
let n_ = 0.142857142857;
let ns = n_ * D.wyz - D.xzx;
let j = p - 49.0 * floor(p * ns.z * ns.z);
let x_ = floor(j * ns.z);
let y_ = floor(j - 7.0 * x_);
let x = x_ * ns.x + ns.yyyy;
let y = y_ * ns.x + ns.yyyy;
let h = 1.0 - abs(x) - abs(y);
let b0 = vec4f(x.xy, y.xy);
let b1 = vec4f(x.zw, y.zw);
let s0 = floor(b0) * 2.0 + 1.0;
let s1 = floor(b1) * 2.0 + 1.0;
let sh = -step(h, vec4f(0.0));
let a0 = b0.xzyw + s0.xzyw * sh.xxyy;
let a1 = b1.xzyw + s1.xzyw * sh.zzww;
var p0 = vec3f(a0.xy, h.x);
var p1 = vec3f(a0.zw, h.y);
var p2 = vec3f(a1.xy, h.z);
var p3 = vec3f(a1.zw, h.w);
let norm = taylorInvSqrt4(vec4f(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
var m = max(0.6 - vec4f(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), vec4f(0.0));
m *= m;
return 42.0 * dot(m * m, vec4f(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
fn fbm3(p: vec3f) -> f32 {
var f = 0.0;
var amp = 0.5;
var q = p;
for (var i = 0; i < 4; i++) {
f += amp * snoise(q);
q *= 2.02;
amp *= 0.5;
}
return f;
}
/**
* The x and y of the divergence-free curl of a 3D noise potential field:
*
* curl.x = d(pot.z)/dy - d(pot.y)/dz
* curl.y = d(pot.x)/dz - d(pot.z)/dx
*
* with pot(p) = (snoise(p), snoise(p + A), snoise(p + B)).
*
* Only .xy is ever consumed — this is a 2D effect, and both callers flatten the
* result to the picture plane. Naming just the eight surviving terms is the same
* arithmetic in the same order as a full 3D curl (eighteen noise evaluations,
* ten of them feeding only .z), so the two components are bit-identical.
*/
fn curlNoiseXY(p: vec3f) -> vec2f {
let e = 0.34;
let ex = vec3f(e, 0.0, 0.0);
let ey = vec3f(0.0, e, 0.0);
let ez = vec3f(0.0, 0.0, e);
let A = vec3f(31.416, 17.13, 7.77);
let B = vec3f(-11.3, 5.91, 23.24);
let x = (snoise(p + ey + B) - snoise(p - ey + B))
- (snoise(p + ez + A) - snoise(p - ez + A));
let y = (snoise(p + ez) - snoise(p - ez))
- (snoise(p + ex + B) - snoise(p - ex + B));
return vec2f(x, y) / (2.0 * e);
}
fn pcg(v: u32) -> u32 {
let s = v * 747796405u + 2891336453u;
let w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
return (w >> 22u) ^ w;
}
fn rnd(state: ptr<function, u32>) -> f32 {
*state = pcg(*state);
return f32(*state) / 4294967296.0;
}
fn hash21(p: vec2f) -> f32 {
let h = pcg(u32(p.x) * 1973u + u32(p.y) * 9277u + 26699u);
return f32(h) / 4294967296.0;
}
`;
/** Hot-band colour ramp: deep red -> orange -> yellow -> white core. */
const BLACKBODY = /* wgsl */ `
fn blackbody(t: f32) -> vec3f {
let x = clamp(t, 0.0, 1.0);
var c = mix(vec3f(0.42, 0.015, 0.0), vec3f(1.0, 0.20, 0.015), smoothstep(0.0, 0.35, x));
c = mix(c, vec3f(1.0, 0.58, 0.10), smoothstep(0.30, 0.62, x));
c = mix(c, vec3f(1.0, 0.90, 0.55), smoothstep(0.58, 0.86, x));
c = mix(c, vec3f(1.0, 1.0, 0.94), smoothstep(0.84, 1.0, x));
return c;
}
`;
/* ------------------------------------------------------------------ *
* 1. Compute: burn-mask spread solver
* ------------------------------------------------------------------ */
/** Output texels per side of a solver workgroup. */
const SIM_TILE = 8;
/** The same tile plus the one-texel halo its 3x3 neighbourhoods reach into. */
const SIM_HALO = SIM_TILE + 2;
/** Seconds a texel's age saturates at; it is only ever read as a ramp. */
const SIM_AGE_MAX = 64;
const SIM_WGSL = /* wgsl */ `
${NOISE}
struct SimU {
// A seed is a CAPSULE: everything within seedRadius of the segment seed..
// seedB catches at once. A click is the degenerate case with both ends equal,
// and a pattern that wants an edge alight passes the edge as one segment
// rather than a row of discs — a row of discs starts as a row of holes, and
// no amount of spacing hides that until they have eaten into each other.
seed: vec2f,
seedRadius: f32,
seedActive: f32,
seedB: vec2f,
dt: f32,
time: f32,
speed: f32,
noiseScale: f32,
noiseContrast: f32,
aspect: f32,
reset: f32,
edgeBias: f32,
};
@group(0) @binding(0) var<uniform> u: SimU;
@group(0) @binding(1) var srcTex: texture_2d<f32>;
@group(0) @binding(2) var dstTex: texture_storage_2d<rgba16float, write>;
/**
* The workgroup's output tile and halo, staged in shared memory.
*
* Every invocation needs its eight neighbours, so reading straight from the
* texture costs nine fetches per thread. Staging the halo cooperatively costs
* SIM_HALO² fetches per SIM_TILE² invocations instead — ~3.5x less memory
* traffic, and the halo is clamped exactly as the direct reads were.
*/
var<workgroup> tile: array<f32, ${SIM_HALO * SIM_HALO}>;
@compute @workgroup_size(${SIM_TILE}, ${SIM_TILE})
fn main(
@builtin(global_invocation_id) gid: vec3u,
@builtin(local_invocation_index) li: u32,
@builtin(workgroup_id) wid: vec3u,
) {
let dim = textureDimensions(srcTex);
let coord = vec2i(gid.xy);
let cell = hash21(vec2f(coord));
// Uniform across the dispatch, so taking it before the barrier below is
// legal — and a reset needs none of the neighbourhood anyway.
if (u.reset > 0.5) {
if (gid.x < dim.x && gid.y < dim.y) {
textureStore(dstTex, coord, vec4f(0.0, 0.0, cell, 0.0));
}
return;
}
let hi = vec2i(dim) - vec2i(1, 1);
let origin = vec2i(wid.xy) * ${SIM_TILE} - vec2i(1, 1);
for (var t = li; t < ${SIM_HALO * SIM_HALO}u; t += ${SIM_TILE * SIM_TILE}u) {
let c = clamp(
origin + vec2i(i32(t % ${SIM_HALO}u), i32(t / ${SIM_HALO}u)),
vec2i(0, 0), hi);
tile[t] = textureLoad(srcTex, c, 0).r;
}
workgroupBarrier();
if (gid.x >= dim.x || gid.y >= dim.y) { return; }
let uv = (vec2f(coord) + 0.5) / vec2f(dim);
let cur = textureLoad(srcTex, coord, 0);
// Fully consumed cells are pinned: nothing below can move a burn value that
// has already saturated, so the whole solve collapses to ageing the texel.
// This is the interior of the burn, which is most of a late sheet.
if (cur.r >= 1.0) {
textureStore(dstTex, coord,
vec4f(1.0, min(cur.g + u.dt, ${SIM_AGE_MAX}.0), cell, cur.a));
return;
}
// 3x3 neighbourhood: diffusion average + activity test.
let t0 = (li / ${SIM_TILE}u) * ${SIM_HALO}u + (li % ${SIM_TILE}u);
var sum = 0.0;
var maxN = 0.0;
for (var dy = 0u; dy <= 2u; dy++) {
for (var dx = 0u; dx <= 2u; dx++) {
let v = tile[t0 + dy * ${SIM_HALO}u + dx];
sum += v;
maxN = max(maxN, v);
}
}
let avg = sum / 9.0;
let act = step(0.004, maxN);
// The noise field shapes the front, and every use of it below is multiplied
// by act — a cell whose whole neighbourhood is cold cannot advance whatever
// the field says, so both noise evaluations are skipped over untouched paper.
// nv rides in the alpha channel so it is carried rather than recomputed.
var nv = cur.a;
var rate = 0.0;
if (act > 0.0) {
// Anisotropic, drifting noise field -> jagged organic front.
let base = snoise(vec3f(uv * u.noiseScale, u.time * 0.12)) * 0.5 + 0.5;
let fine = snoise(vec3f(uv * u.noiseScale * 3.1, u.time * 0.35 + 19.0)) * 0.5 + 0.5;
nv = clamp(mix(base, base * (0.45 + fine), 0.6), 0.0, 1.0);
// No floor: cells the noise leaves "damp" genuinely stall until the field
// drifts off them, which is what makes the contour ragged instead of round.
rate = clamp(pow(max(nv, 0.0), u.noiseContrast), 0.0, 2.0);
}
// Paper burns faster near its edges (more oxygen, less mass).
let edge = 1.0 - smoothstep(0.0, 0.18, min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y)));
let fuel = 1.0 + edge * u.edgeBias;
var burn = cur.r;
// Two terms on purpose. The linear part keeps the leading edge advancing even
// where the neighbourhood is barely alight, spreading the front over many
// texels — without it every downstream threshold lands inside a single texel
// and stair-steps along the simulation grid. The squared part then accelerates
// cells that are already well alight through to fully consumed, so the
// interior burns away instead of sitting mid-burn as one broad glowing patch.
let drive = avg * avg * 4.2 + avg * 0.7 + 0.015;
burn += u.dt * u.speed * rate * fuel * drive * act;
// Blur toward the neighbourhood average — also noise-weighted, otherwise the
// diffusion term alone would smooth every front back into a circle.
burn = max(burn, mix(burn, avg, 0.22 * act * min(rate, 1.0)));
burn = max(burn, cur.r);
if (u.seedActive > 0.5) {
// Measured with x scaled by the aspect, so the radius is a real distance on
// the sheet and not an ellipse — same reason as everywhere else here.
let p = uv * vec2f(u.aspect, 1.0);
let a = u.seed * vec2f(u.aspect, 1.0);
let ab = (u.seedB - u.seed) * vec2f(u.aspect, 1.0);
// Nearest point on the segment; the clamp is what makes the ends round
// instead of running the line out to infinity, and it collapses safely to
// the point case when the segment has no length.
let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-8), 0.0, 1.0);
if (distance(p, a + ab * t) < u.seedRadius) {
burn = max(burn, 1.0);
}
}
burn = clamp(burn, 0.0, 1.0);
var age = cur.g;
if (burn > 0.02) { age = min(age + u.dt, ${SIM_AGE_MAX}.0); }
textureStore(dstTex, coord, vec4f(burn, age, cell, nv));
}
`;
/* ------------------------------------------------------------------ *
* 1b. Compute: the blur pyramid the discolouration is read from
* ------------------------------------------------------------------ */
/**
* Blur-pyramid ("smear") dimensions, in texels. The paper is 1 x 1.38, and
* 352 / 1.38 = 255 — so a texel is square in WORLD space, not in UV, and one
* mip level is an isotropic disc average on the sheet rather than an ellipse.
* Eight levels reach a footprint of 128/256 = 0.5 world units, past the widest
* the spread slider goes.
*/
const SMEAR_W = 256;
const SMEAR_H = 352;
const SMEAR_LEVELS = 8;
/**
* Level 0 of the smear pyramid: the burn mask boxed down to SMEAR_W x SMEAR_H.
*
* Separate from the halving pass below only because the source is a different
* resolution and aspect — 1024 square standing in for a 1 x 1.38 sheet.
* Sampling rather than loading lets the hardware handle the fractional ratio in
* y (1024 / 352 is not an integer).
*/
const SMEAR_SEED_WGSL = /* wgsl */ `
@group(0) @binding(0) var srcTex: texture_2d<f32>;
@group(0) @binding(1) var srcSamp: sampler;
@group(0) @binding(2) var dstTex: texture_storage_2d<rgba16float, write>;
const W: u32 = ${SMEAR_W}u;
const H: u32 = ${SMEAR_H}u;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3u) {
if (gid.x >= W || gid.y >= H) { return; }
let uv0 = vec2f(f32(gid.x), f32(gid.y)) / vec2f(f32(W), f32(H));
let step = 1.0 / vec2f(f32(W), f32(H));
// 2x2 bilinear taps inside the output texel: with a linear sampler that is a
// 4x4 box of the source, which is the whole footprint at this ratio.
var sum = 0.0;
for (var y = 0; y < 2; y++) {
for (var x = 0; x < 2; x++) {
let o = (vec2f(f32(x), f32(y)) + 0.5) * 0.5;
sum += textureSampleLevel(srcTex, srcSamp, uv0 + o * step, 0.0).r;
}
}
textureStore(dstTex, vec2i(gid.xy), vec4f(sum * 0.25, 0.0, 0.0, 1.0));
}
`;
/**
* One halving step of the pyramid: level k from level k-1, 2x2 box.
*
* A box is a crude filter alone, but composed down the chain it converges on a
* Gaussian. All that matters is that each level is the honest average of its
* footprint, since that is what a single trilinear tap reads back.
*/
const SMEAR_DOWN_WGSL = /* wgsl */ `
@group(0) @binding(0) var srcTex: texture_2d<f32>;
@group(0) @binding(1) var srcSamp: sampler;
@group(0) @binding(2) var dstTex: texture_storage_2d<rgba16float, write>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3u) {
let dim = textureDimensions(dstTex);
if (gid.x >= dim.x || gid.y >= dim.y) { return; }
// Sampled at the centre of the output texel, which with a linear filter on
// the level above averages exactly the 2x2 underneath it — and stays correct
// where a level's odd size makes the ratio slightly off two.
let uv = (vec2f(gid.xy) + 0.5) / vec2f(dim);
let v = textureSampleLevel(srcTex, srcSamp, uv, 0.0).r;
textureStore(dstTex, vec2i(gid.xy), vec4f(v, 0.0, 0.0, 1.0));
}
`;
/* ------------------------------------------------------------------ *
* 2. Render: paper surface (curl displacement + zone shading + discard)
* ------------------------------------------------------------------ */
/** Burn-mask resolution, in texels — the solver's grid. */
const MASK_SIZE = 1024;
/**
* Occupancy / fragment-ownership grid resolution. `MASK_SIZE` must be an exact
* multiple of it: the occupancy pass reduces a square block of mask texels per
* cell.
*/
const OCC_SIZE = 256;
/**
* Scene uniform and the surface bindings, shared verbatim by the sheet and the
* falling-fragment modules so both can use `shadeSurface` below.
*/
const SCENE_BINDINGS = /* wgsl */ `
struct Scene {
viewProj: mat4x4f,
model: mat4x4f,
camRight: vec3f,
time: f32,
camUp: vec3f,
emissive: f32,
camPos: vec3f,
curlStrength: f32,
paperSize: vec2f,
riseAmount: f32,
curlScale: f32,
charDarkness: f32,
flicker: f32,
/** How far the discolouration reaches back from the burn, in world units. */
charSpread: f32,
/** How deep the torn outer edge bites into the sheet, in world units. */
deckleDepth: f32,
/** Spatial frequency of the tear profile, in cycles per world unit. */
deckleScale: f32,
/** How much of the ink layer is on the sheet: 0 bare paper, 1 fully printed. */
inkAmount: f32,
/** Squircle corner radius, in world units. 0 is the square-cornered sheet. */
cornerRadius: f32,
};
@group(0) @binding(0) var<uniform> s: Scene;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var burnTex: texture_2d<f32>;
@group(0) @binding(3) var paperTex: texture_2d<f32>;
// Per-cell owner: 0 = still part of the sheet, otherwise the id of the
// fragment that has claimed it (or DEAD = consumed, drawn by nobody).
@group(0) @binding(4) var ownerTex: texture_2d<u32>;
// Binding 5 is the scrap storage buffer in the fragment module, so the smear
// pyramid starts at 6 in both.
@group(0) @binding(6) var smearTex: texture_2d<f32>;
// Its own sampler: clamped, and mip-filtered. The shared one repeats, which at
// a coarse mip would fold the far side of the sheet into the near border.
@group(0) @binding(7) var smearSamp: sampler;
// What is printed on the sheet, as a MULTIPLIER over the paper's own albedo:
// white is bare paper. A page with nothing on it binds a 1x1 white texel here
// and shades exactly as it did before the layer existed. Read through the
// smear sampler because that one clamps — the grain sampler repeats, and a
// linear tap at the sheet's border would wrap ink in from the opposite edge.
@group(0) @binding(8) var inkTex: texture_2d<f32>;
/**
* Average burn over a disc of radius r (WORLD units) centred on uv, read from
* the pre-blurred pyramid instead of measured with taps.
*
* The pyramid is what makes a wide, smooth discolouration affordable. Sampling
* the raw mask over a disc needs the tap count to grow with the AREA to keep
* the estimate quiet, and at these widths twenty-odd taps leave visible error
* — detached blotches or film grain depending on how the pattern is rotated,
* and neither is scorching. A mip level IS the exact average over its
* footprint, so one trilinear tap is both smooth and cheap. The texture's
* texels are square in WORLD space, which is what lets an isotropic mip stand
* in for a disc on a 1 x 1.38 sheet.
*/
fn smearAt(uv: vec2f, r: f32) -> f32 {
// Level L has texels of 2^L / SMEAR_W world units and the bilinear tap spans
// about two of them, so the level whose FOOTPRINT is r is the one whose texel
// is r/2 — hence the -1. Getting this wrong by one level doubles or halves
// every band at once, which reads as the slider having the wrong scale.
let lod = clamp(log2(max(r, 1e-4) * ${SMEAR_W}.0) - 1.0, 0.0, ${SMEAR_LEVELS - 1}.0);
return textureSampleLevel(smearTex, smearSamp, uv, lod).r;
}
fn ownerAt(uv: vec2f) -> u32 {
let c = clamp(vec2i(uv * ${OCC_SIZE}.0), vec2i(0), vec2i(${OCC_SIZE - 1}));
return textureLoad(ownerTex, c, 0).r;
}
// The shared sampler is 'repeat' because the paper grain tiles, but the burn
// mask must never wrap: a linear tap at uv.y = 1 blends the sheet's bottom texel
// row with its top, leaving an opaque strip surviving along every border while
// the far edge burns. Every mask read — including the ring taps, which reach
// 0.105 past an edge — goes through here.
const MASK_INSET = vec2f(0.5 / ${MASK_SIZE}.0);
fn burnAt(uv: vec2f) -> vec4f {
let c = clamp(uv, MASK_INSET, vec2f(1.0) - MASK_INSET);
return textureSampleLevel(burnTex, samp, c, 0.0);
}
`;
/**
* The sheet's own outer edge: torn, not guillotined.
*
* A cut edge is the one thing in this scene that never looks photographed — it
* is the only perfectly straight line in a picture made of noise, and the eye
* finds it immediately. Handmade and torn paper ends on a deckle: a slow
* wander the length of the sheet, a coarse chew on top of it, and a lip of
* loose fibre too fine to resolve.
*
* Only the outer boundary is touched. Everything inside it is the sheet as it
* was.
*/
const DECKLE_EDGE = /* wgsl */ `
/**
* Two octaves, deliberately NOT fbm3: fbm3's finest octave lands below a pixel
* at every frequency this edge wants, and sub-pixel detail on a silhouette only
* flips neighbouring pixels on and off — dust along the edge, not paper.
*/
fn fibreNoise(p: vec2f) -> f32 {
return snoise(vec3f(p, 0.0)) * 0.66 + snoise(vec3f(p * 2.1 + 17.0, 0.0)) * 0.34;
}
/**
* How far the tear has eaten in from one border, at the position \`t\` (world
* units) ALONG that border.
*
* The profile is a function of the along-edge coordinate alone — each border is
* a graph, so the bitten-away region is always a single connected strip. The
* obvious alternative, thresholding a 2D noise field against distance-to-border,
* is wrong here: wherever that field wobbles perpendicular to the edge it
* strands islands of paper outside the sheet and punches holes just inside it,
* which reads as a damaged sheet rather than a torn one.
*/
fn deckleBite(t: f32, seed: f32) -> f32 {
let f = s.deckleScale;
let slow = snoise(vec3f(t * f, seed, 0.0));
let mid = snoise(vec3f(t * f * 2.3, seed + 5.0, 0.0));
let fine = snoise(vec3f(t * f * 5.5, seed + 11.0, 0.0));
// Centred below half depth, so the mean edge sits inside the rectangle and the
// tear reads as material removed rather than as a wavy sheet. Weighted hard
// toward the slowest scale — with the mid octave anywhere near the slow one
// the edge comes out evenly lumpy, like torn foam.
var v = 0.42 + slow * 0.30 + mid * 0.10 + fine * 0.045;
// A tear does not meander: it runs nearly straight along the fibre, then
// steps. The clamp flattens the extremes into those straight runs and the gain
// turns what is between them into abrupt steps. Un-steepened, every edge is a
// smooth sine wobble — the one thing torn paper never looks like.
v = clamp((v - 0.45) * 2.3 + 0.45, 0.0, 1.0);
// Occasional deep notches, sparse because the threshold is high and steep
// because the ramp above it is narrow. These small V-shaped bites, left where
// a tear jumped across the grain, are what the eye actually reads as "torn".
let notch = smoothstep(0.68, 0.90, snoise(vec3f(t * f * 2.1 + 40.0, seed + 23.0, 0.0)) * 0.5 + 0.5);
return min(v + notch * 0.4, 1.0) * s.deckleDepth;
}
/**
* Distance to the sheet's SHAPE — a squircle — ignoring the tear. Positive
* inside, negative outside.
*
* The corner is an L4 norm rather than the L2 of an ordinary rounded rect, and
* that exponent is the entire difference between the two: L2 leaves a circular
* arc that meets the straight run at a visible change of curvature, while L4
* holds the curve flatter where it joins and tightens it at 45°. That is the
* continuous corner an iOS card has.
*
* Not a true Euclidean distance under that norm — the gradient runs up to ~19%
* off through the corner, so the anti-aliased edge and the torn lip come out a
* fraction of a pixel wider there. A real superellipse distance costs an
* iteration and would buy nothing visible.
*/
fn squircleDist(p: vec2f, halfSize: vec2f, radius: f32) -> f32 {
let r = min(radius, min(halfSize.x, halfSize.y));
if (r <= 0.0) {
return min(min(p.x + halfSize.x, halfSize.x - p.x),
min(p.y + halfSize.y, halfSize.y - p.y));
}
let q = abs(p) - halfSize + vec2f(r);
let m = max(q, vec2f(0.0));
let corner = pow(pow(m.x, 4.0) + pow(m.y, 4.0), 0.25);
return r - (corner + min(max(q.x, q.y), 0.0));
}
/**
* Distance from \`uv\` to the torn boundary in world units: positive on paper,
* negative in the part that has been torn away. The four borders are bitten
* independently and combined with a min, which is also what gives the corners
* their ragged notch for free — then the squircle cuts across all of it.
*
* The tear and the shape are separate on purpose. A bite is a function of the
* along-edge coordinate of one of four axis-aligned borders, which has no
* meaning once the border curves; running the bites against the rectangle and
* intersecting with the squircle keeps the straight runs torn and leaves the
* corners as clean arcs, which is what a trimmed card looks like.
*/
fn deckleDist(uv: vec2f) -> f32 {
let halfSize = s.paperSize * 0.5;
let p = (uv - vec2f(0.5)) * s.paperSize;
// Past deckleCare nothing downstream still cares: deckleAlpha has saturated
// at 1 and the pale torn lip has faded out. A bite is bounded by deckleDepth,
// so the torn distance is never less than the un-torn distance minus that
// depth — and where even that lower bound is past the point of caring, the
// four noise profiles below (sixteen simplex evaluations) cannot change the
// pixel. That is the ~92% of the sheet away from its own border.
let dShape = squircleDist(p, halfSize, s.cornerRadius);
let deckleCare = s.deckleDepth * 0.5 + 0.005;
let bound = dShape - s.deckleDepth;
if (bound > deckleCare) { return bound; }
let dL = (p.x + halfSize.x) - deckleBite(p.y, 0.0);
let dR = (halfSize.x - p.x) - deckleBite(p.y, 3.1);
let dB = (p.y + halfSize.y) - deckleBite(p.x, 7.3);
let dT = (halfSize.y - p.y) - deckleBite(p.x, 11.9);
return min(min(min(dL, dR), min(dB, dT)), dShape);
}
/**
* Alpha for the torn edge — a HARD silhouette, feathered by just enough to
* anti-alias it.
*
* Paper is opaque right up to where it stops, so the tear's character has to
* live entirely in the shape of the boundary and never in a soft ramp across
* it: widen this and the sheet stops ending and starts fading out. The width is
* a world-space constant because the view is orthographic and fixed — about a
* pixel and a half at the size the sheet is drawn.
*/
fn deckleAlpha(d: f32) -> f32 {
return clamp(d / 0.0016, 0.0, 1.0);
}
`;
/**
* The sheet's geometry at a UV, plus the curl and lift on the dying rim. Shared
* with the fragment pass so a scrap's mesh coincides with the sheet's *exactly*
* at the instant it detaches — any difference here shows up as the scrap
* popping as it lets go.
*
* This is a 2D effect: the sheet is a flat rectangle in the z = 0 plane, the
* normal is constant, and every displacement below stays in the picture plane.
* Nothing may push a vertex along z — the view is orthographic and flat-on, so
* out-of-plane motion would be invisible except as depth-sorting artefacts.
*/
const SURFACE_GEOMETRY = /* wgsl */ `
struct SurfacePoint {
world: vec3f,
normal: vec3f,
curl: f32,
};
fn surfacePoint(uv: vec2f) -> SurfacePoint {
let local = vec3f((uv.x - 0.5) * s.paperSize.x, (0.5 - uv.y) * s.paperSize.y, 0.0);
var out: SurfacePoint;
out.world = (s.model * vec4f(local, 1.0)).xyz;
out.normal = normalize((s.model * vec4f(0.0, 0.0, 1.0, 0.0)).xyz);
out.curl = 0.0;
// Zone 4 rim: lift and curl the dying edge into the thermal column.
// Drive the curl from a blurred mask, not the raw one. Neighbouring vertices
// are 1/192 apart; a driver that jumps 0->1 between two of them tears their
// triangle into a blade no matter how small the displacement is.
var mb = burnAt(uv).r * 0.34;
for (var k = 0; k < 4; k++) {
let ang = f32(k) * (PI * 0.5);
mb += burnAt(uv + vec2f(cos(ang), sin(ang)) * 0.012).r * 0.165;
}
if (mb > 0.62) {
// A bump, not a ramp: displacement peaks in the rim band and returns to
// zero for fully consumed vertices. A ramp leaves the deepest-burnt
// vertices pulled far out of plane, stretching their triangles into combs.
// The band tracks the fragment shader's coverage fade rather than a hard
// 0.8 — above that the sheet is already transparent, so a curl placed
// there would never be visible.
out.curl = smoothstep(0.62, 0.72, mb) * (1.0 - smoothstep(0.78, 0.90, mb));
// Direction only — the raw curl magnitude is unbounded and would tear the
// rim into long spikes instead of curling it. Flattened to the picture
// plane and renormalised there, so the rim writhes sideways along the front
// rather than lifting out of it.
let c2 = curlNoiseXY(out.world * s.curlScale + vec3f(0.0, -s.time * 0.35, s.time * 0.1));
let c = normalize(vec3f(c2, 0.0) + vec3f(0.0, 0.0001, 0.0));
let lift = vec3f(0.0, 1.0, 0.0) * s.riseAmount;
out.world += (c * s.curlStrength + lift) * out.curl;
}
return out;
}
`;
/**
* The whole paper surface look, factored out so the sheet and the scraps that
* break off it shade identically — they are the same material, and any drift
* between the two reads instantly as the fragments being fake.
*/
const SURFACE_SHADING = /* wgsl */ `
struct Surface {
color: vec3f,
coverage: f32,
};
/** Coverage below which a pixel is discarded rather than blended. */
const COVERAGE_EPS: f32 = 0.004;
// Every texture read here is textureSampleLevel (burnAt included), never plain
// textureSample. This function returns early once the surface is fully
// consumed, and textureSample is illegal after a conditional return — it needs
// uniform control flow, and only 'discard' is exempt from that rule. Neither
// texture has mips, so an explicit level 0 samples identically.
fn shadeSurface(uv: vec2f, nIn: vec3f, world: vec3f, curl: f32, front: bool) -> Surface {
let m = burnAt(uv);
let b = m.r;
var out: Surface;
out.color = vec3f(0.0);
out.coverage = 0.0;
// Two rejections, both ahead of every noise evaluation and every ring tap in
// this function. A pixel that fails either is transparent and is discarded by
// both callers, so the several dozen simplex evaluations below would be spent
// on nothing. The dissolve threshold is jittered by fine noise but only over
// [0.79, 0.91], so anything burnt past 0.92 is gone whatever that noise says
// — which lets the test run before the noise that would otherwise define it.
if (b >= 0.92) { return out; }
// The sheet's own torn boundary, which cuts the same alpha. Taken here rather
// than after the shading so everything outside the tear is never shaded.
let dDeckle = deckleDist(uv);
let dAlpha = deckleAlpha(dDeckle);
if (dAlpha <= COVERAGE_EPS) { return out; }
// Fine static noise, shared by the dissolve edge and the ash powder.
let fine = fbm3(vec3f(uv * 130.0, 0.0)) * 0.5 + 0.5;
// Zone 4 — consumed. Feathered over a wide, noise-jittered band so the sheet
// thins into translucent glowing tatters and dissolves, rather than ending on
// a hard stencil silhouette. The band must stay centred on the raw mask —
// blending in the blurred value dilates the cut, which eats the whole char
// zone and leaves nothing but a bright keyline.
let cut = 0.85 - (fine - 0.5) * 0.12;
out.coverage = (1.0 - smoothstep(cut - 0.22, cut, b)) * dAlpha;
if (out.coverage <= COVERAGE_EPS) { return out; }
// Two tight rings, at FIXED radii. They carry the black crust and (via the
// shading mask below) the burning band itself, both of which have to stay
// pinned to the front however wide the discolouration is set — widen these
// and the ember line smears into a broad glow. Averaged, never maxed: a max
// over six sparse taps traces a contour of the sampling pattern.
// Sampled below the coverage test, not above it: nothing before that test
// consumes them, and a fully consumed pixel would pay fifteen texture reads
// on its way to being discarded.
var near = 0.0;
for (var i = 0; i < 6; i++) {
let a = f32(i) * (PI / 3.0) + 0.4;
let o = vec2f(cos(a), sin(a));
near += burnAt(uv + o * 0.006).r;
near += burnAt(uv + o * 0.018).r;
}
near /= 12.0;
// The discolouration field at three widths: x is the tight one that sits
// against the crust, z the wide one that reaches into clean paper. A gradient
// needs several widths to fall off over, and three overlapping ones read as
// continuous. The multipliers overshoot charSpread on purpose — a box average
// of a burnt edge reads 0.5 at the edge itself and reaches zero only half a
// footprint out, so the widest band has to be sampled at twice the spread for
// the tint to actually reach that far.
let band = vec3f(
smearAt(uv, s.charSpread * 0.55),
smearAt(uv, s.charSpread * 1.15),
smearAt(uv, s.charSpread * 2.0),
);
// Only the char reads this now — the unburnt sheet is a flat colour. Fibre
// grain printed across clean paper reads as noise over the artwork rather
// than as texture, and at this scale on a screen there is nothing for it to
// resolve into. Charcoal is the opposite case: it is genuinely lumpy, and
// flat black crust looks like a hole cut in the sheet.
let grain = textureSampleLevel(paperTex, samp, uv * vec2f(3.0, 4.0), 0.0).rgb;
var rough = 0.72;
// How far into the burn this point is: 0 on clean paper, 1 against the crust.
//
// Three ramps summed, one per band. Each saturates at a different distance, so
// the total climbs smoothly all the way in instead of stepping. The raw mask
// joins with a max so paper that is itself alight is always fully toasted
// whatever charSpread is; at its minimum that term is the only one left and
// the band collapses to a thin scorch line.
// Low thresholds, because these are AREA fractions: a burnt edge gives 0.5 at
// most and a small hole a few percent, so ramps keyed near 0.5 would tint
// nothing but the inside of a large burn. Reading them low is also what makes
// the size of a burn tell — a pinhole scorches faintly, a spreading front
// saturates the whole ramp.
var toast = smoothstep(0.006, 0.22, band.z) * 0.34
+ smoothstep(0.012, 0.28, band.y) * 0.33
+ smoothstep(0.025, 0.34, band.x) * 0.33;
// Blurred, and thresholded well above zero. The raw mask is not usable here:
// the solver's noise sends hairline tendrils of half-burnt paper ahead of the
// front, and keying the ramp to them paints the sheet with ochre filigree —
// thin bright veins that read as scribble, not as scorching.
toast = max(toast, smoothstep(0.05, 0.42, (b + near) * 0.5));
// Real scorching is uneven, but the unevenness has to move the whole ramp in
// and out rather than punch holes in one band of it — modulating the layers
// separately gives contour-map blotches, because a hole in the mid brown shows
// the pale layer underneath as a closed ring. Perturbing the distance instead
// keeps the ordering: every point still runs paper -> ochre -> brown -> crust,
// just at a different depth.
//
// Added rather than multiplied, and faded out at both ends of the ramp.
// Multiplicative noise scales the faint outer tail as hard as the middle,
// carrying it across the threshold in wiggly closed curves that litter clean
// paper with ochre squiggles. The smoothstep(toast) * (1 - toast) weight
// confines the perturbation to the body of the band.
let mottle = fbm3(vec3f(uv * 7.0, 0.0)) * 0.5 + 0.5;
let fibre = fbm3(vec3f(uv * vec2f(38.0, 15.0), 0.0)) * 0.5 + 0.5;
let jitter = (mottle - 0.5) * 0.22 + (fibre - 0.5) * 0.09;
toast = clamp(toast + jitter * smoothstep(0.04, 0.45, toast) * (1.0 - toast), 0.0, 1.0);
// One monotonic ramp, paper through ochre and brown to near-black. Each stop
// overlaps the next, so the gradient reads continuous at any width. It starts
// on a flat off-white: clean paper here is one colour, and everything that
// varies across the sheet is either printed on it or is the fire working on
// it. Warm rather than neutral, and a shade off white on purpose — this is
// what the ink multiplies into, and pure white would leave the sheet with no
// colour of its own, printing the photograph on nothing.
var albedo = mix(vec3f(0.95, 0.925, 0.875), vec3f(0.66, 0.52, 0.29),
smoothstep(0.03, 0.42, toast));
albedo = mix(albedo, vec3f(0.42, 0.245, 0.105), smoothstep(0.32, 0.74, toast));
albedo = mix(albedo, vec3f(0.17, 0.082, 0.036), smoothstep(0.70, 1.0, toast));
// Ink, printed on the sheet. It multiplies the albedo rather than replacing
// it, which is what ink physically does — the whole scorch ramp goes on
// showing through, so type yellows as the paper toasts and is swallowed by the
// char without a single line of its own. Sampled here, between the
// discolouration and the char, so nothing printed survives on top of charcoal
// or inside the burning band.
let ink = textureSampleLevel(inkTex, smearSamp, uv, 0.0).rgb;
albedo *= mix(vec3f(1.0), ink, s.inkAmount);
// Shading mask: the raw front can still be only a few texels wide wherever
// the noise lets it run fast, which squeezes every zone into a thin line.
// Blending in the ring average guarantees the bands are spatially wide no
// matter how sharp the solver's front happens to be locally. Only shading
// uses this — the cut above must stay on the raw mask.
let bs = mix(b, near, 0.68);
// Zone 2 — charred edge: drive toward charcoal, roughen. Char is never flat
// black; it carries pale ash powder and fibre structure, and fades in over a
// wider band than the raw mask alone would give.
// The ring-average term carries char ahead of the front, so its offset sets
// how deep the black band gets — photographs of burning paper show a
// substantial crusty zone between the white and the flame, and a tight band
// leaves the flame sitting almost directly on clean paper.
// The third term is the only one that follows charSpread, so what widens is
// the whole ramp — black, dark brown, brown, tan — and not just its pale outer
// edge. Its threshold is high so black stays in the inner part of the ramp
// however far the spread is pushed.
let charT = clamp(max(max(bs / 0.25, (near - 0.05) / 0.40),
(band.x - 0.34) / 0.40), 0.0, 1.0);
let char = smoothstep(0.0, 1.0, charT);
let ashPowder = clamp(fine * 0.55 + (fbm3(vec3f(uv * 46.0, 0.0)) * 0.5 + 0.5) * 0.65, 0.0, 1.0);
var charCol = vec3f(0.055, 0.046, 0.042) * s.charDarkness * (0.45 + grain.b * 1.1);
charCol = mix(charCol, vec3f(0.185, 0.175, 0.170), smoothstep(0.6, 1.0, ashPowder) * 0.6);
albedo = mix(albedo, charCol, char);
rough = mix(rough, 0.97, char);
// The torn lip itself. Tearing pulls fibre out of the sheet's core, which is
// paler and furrier than the sized face — every deckle edge in a photograph is
// a bright hairline. Faded out under char on purpose: a rim that has already
// burnt is charcoal, and lightening it there would draw a pale keyline around
// the entire fire. Squared, so the brightening stays a hairline highlight
// rather than washing a wide pale border onto the sheet's face.
let lipD = 1.0 - smoothstep(0.0, s.deckleDepth * 0.5 + 0.005, dDeckle);
let lip = lipD * lipD;
// Only a slight unevenness along the lip. This band is a couple of pixels
// wide, so anything stronger here is read as speckle on the edge rather than
// as varying thickness of exposed core.
let fray = fibreNoise(uv * vec2f(30.0, 41.0) + 5.0) * 0.5 + 0.5;
albedo = mix(albedo, albedo * 1.04 + vec3f(0.07, 0.066, 0.061) * (0.7 + fray * 0.5),
lip * 0.8 * (1.0 - char));
rough = mix(rough, 0.94, lip * (1.0 - char));
// Cracked-ember speckle scattered through the char. Deliberately NOT gated by
// a falling ramp on the mask — char rising against that falling would peak in
// a thin ring and draw yet another outline.
let speck = smoothstep(0.62, 0.98, m.b) * char * 0.4;
var emissive = blackbody(0.35) * speck * 1.6;
// Zone 3 — active burning band, on the smoothed mask. Colour and intensity
// use separate exponents so the band can stay hot-cored without collapsing
// into a drawn line.
if (bs >= 0.25) {
// A narrow window puts the whole glow inside a couple of texels, which is a
// hairline tracing the entire front. Photographs of burning paper show a
// soft orange band a few millimetres deep, not a keyline.
let edgeFactor = clamp(1.0 - abs((bs - 0.55) / 0.38), 0.0, 1.0);
// Cap the colour temperature short of white. At full white the band clips
// to a continuous bright filament and reads as a drawn outline; real paper
// edges glow yellow-orange with only pinpoint white.
let heatCol = pow(edgeFactor, 1.4) * 0.62;
// Gentle exponent keeps the band broad rather than a spike at bs = 0.55.
var heatAmp = pow(edgeFactor, 1.25);
// Break the band into a chain of embers — a smooth band is what makes it
// look drawn rather than burnt. The modulation has to reach near zero in
// places or the chain closes back up into the continuous hairline it is
// there to prevent; two frequencies so the gaps are irregular in scale.
let emberNoise = clamp(
(fbm3(vec3f(uv * 55.0, s.time * 1.1)) * 0.5 + 0.5) * 0.72
+ (fbm3(vec3f(uv * 17.0, s.time * 0.6)) * 0.5 + 0.5) * 0.45 - 0.16,
0.0, 1.0);
heatAmp *= 0.10 + 1.5 * emberNoise;
let flick = 1.0 + s.flicker * (fbm3(vec3f(uv * 26.0, s.time * 2.6)) * 0.5);
emissive += blackbody(heatCol) * heatAmp * s.emissive * flick;
albedo = mix(albedo, vec3f(0.05, 0.03, 0.02), edgeFactor * 0.7);
}
// Cheap one-bounce firelight from the front nearby. This has to enter as a
// LIGHT the surface reflects, not as an additive term gated by (1 - char):
// a rising glow times a falling gate peaks in a one-pixel ring and paints a
// hard keyline right around the burn. As light, dark char simply reflects
// little and the falloff stays smooth.
let rimLight = smoothstep(0.02, 0.55, near);
let fireLight = vec3f(0.95, 0.42, 0.13) * rimLight * 1.15;
var n = normalize(nIn);
if (!front) { n = -n; }
let lightDir = normalize(vec3f(0.35, 0.85, 0.45));
let viewDir = normalize(s.camPos - world);
let diff = max(dot(n, lightDir), 0.0) * 0.62 + 0.30;
let backlit = pow(max(dot(-n, lightDir), 0.0), 2.0) * 0.25;
let spec = pow(max(dot(reflect(-lightDir, n), viewDir), 0.0), mix(48.0, 4.0, rough)) * (1.0 - rough) * 0.35;
var color = albedo * (vec3f(diff + backlit) + fireLight) + vec3f(spec);
// Curling ash flakes glow from the fire underneath them.
color += blackbody(0.5) * curl * 0.2;
color += emissive;
out.color = color;
return out;
}
`;
const PAPER_WGSL = /* wgsl */ `
${NOISE}
${BLACKBODY}
${SCENE_BINDINGS}
${DECKLE_EDGE}
${SURFACE_GEOMETRY}
${SURFACE_SHADING}
struct VOut {
@builtin(position) clip: vec4f,
@location(0) uv: vec2f,
@location(1) world: vec3f,
@location(2) normal: vec3f,
@location(3) curl: f32,
};
@vertex
fn vs(@location(0) uv: vec2f) -> VOut {
let sp = surfacePoint(uv);
var out: VOut;
out.clip = s.viewProj * vec4f(sp.world, 1.0);
out.uv = uv;
out.world = sp.world;
out.normal = sp.normal;
out.curl = sp.curl;
return out;
}
@fragment
fn fs(in: VOut, @builtin(front_facing) front: bool) -> @location(0) vec4f {
// Anything a fragment has claimed has physically left the sheet — it is drawn
// by the fragment pass now, at wherever it has fallen to.
if (ownerAt(in.uv) != 0u) { discard; }
let surf = shadeSurface(in.uv, in.normal, in.world, in.curl, front);
if (surf.coverage <= COVERAGE_EPS) { discard; }
return vec4f(surf.color, surf.coverage);
}
`;
/* ------------------------------------------------------------------ *
* 2b. Compute + render: orphaned scraps that break off and fall
* ------------------------------------------------------------------ */
/**
* Downsamples the burn mask into a coarse "is there still paper here" grid that
* the CPU reads back to find islands. A cell counts as paper if ANY texel in it
* is still substantially unburnt — erring toward keeping cells connected, so a
* scrap detaches a beat late rather than tearing off while it is still joined.
*/
const OCCUPANCY_WGSL = /* wgsl */ `
@group(0) @binding(0) var burnTex: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> occ: array<u32>;
const OCC: u32 = ${OCC_SIZE}u;
const BLOCK: i32 = ${MASK_SIZE / OCC_SIZE};
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3u) {
if (gid.x >= OCC || gid.y >= OCC) { return; }
let base = vec2i(gid.xy) * BLOCK;
var sum = 0.0;
for (var y = 0; y < BLOCK; y++) {
for (var x = 0; x < BLOCK; x++) {
sum += textureLoad(burnTex, base + vec2i(x, y), 0).r;
}
}
// Averaged and thresholded near where coverage has faded to a translucent
// thread. Testing the block's *minimum* instead keeps a neck "connected"
// until every last texel in it is gone, by which point the island it was
// holding has already burnt down to a crumb.
occ[gid.y * OCC + gid.x] = select(0u, 1u, sum / f32(BLOCK * BLOCK) < 0.72);
}
`;
/** Quads per side of the patch mesh drawn for each detached scrap. */
const FRAG_PATCH = 16;
/** Vertices in that patch: six per quad, drawn non-indexed. */
const FRAG_VERTS = FRAG_PATCH * FRAG_PATCH * 6;
/** Mirrored on the CPU by the `FRAG` offset table. */
const FRAG_STRUCT = /* wgsl */ `
struct Frag {
uvMin: vec2f,
uvMax: vec2f,
pos: vec3f,
id: f32,
pivot: vec3f,
alpha: f32,
rot: vec3f,
pad0: f32,
};
`;
const FRAGMENT_WGSL = /* wgsl */ `
${NOISE}
${BLACKBODY}
${SCENE_BINDINGS}
${DECKLE_EDGE}
${SURFACE_GEOMETRY}
${SURFACE_SHADING}
${FRAG_STRUCT}
@group(0) @binding(5) var<storage, read> frags: array<Frag>;
const PATCH: u32 = ${FRAG_PATCH}u;
struct FOut {
@builtin(position) clip: vec4f,
@location(0) uv: vec2f,
@location(1) world: vec3f,
@location(2) normal: vec3f,
@location(3) @interpolate(flat) fid: u32,
@location(4) @interpolate(flat) alpha: f32,
@location(5) curl: f32,
};
// A scrap only ever rotates in the picture plane — the effect is 2D, so there
// is no tumbling out of it. rot.x is that angle; the other two components are
// unused and the CPU side writes them as zero.
fn rotateZ(v: vec3f, e: vec3f) -> vec3f {
let c = cos(e.x);
let sn = sin(e.x);
return vec3f(v.x * c - v.y * sn, v.x * sn + v.y * c, v.z);
}
@vertex
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> FOut {
let f = frags[ii];
var out: FOut;
let quad = vi / 6u;
let corner = vi % 6u;
var offs = array<vec2u, 6>(
vec2u(0u, 0u), vec2u(1u, 0u), vec2u(0u, 1u),
vec2u(0u, 1u), vec2u(1u, 0u), vec2u(1u, 1u),
);
let o = offs[corner];
let t = vec2f(f32(quad % PATCH + o.x), f32(quad / PATCH + o.y)) / f32(PATCH);
let uv = mix(f.uvMin, f.uvMax, t);
// Rotate about the scrap's own centroid, starting from the exact pose it had
// while it was still part of the sheet. The position is seeded with this same
// pivot in world space and the rotation starts at zero, so at the moment of
// detachment this reproduces the sheet's geometry identically — including its
// slack and rim curl, which is why surfacePoint is shared rather than copied.
let sp = surfacePoint(uv);
let pivotW = (s.model * vec4f(f.pivot, 1.0)).xyz;
let world = rotateZ(sp.world - pivotW, f.rot) + f.pos;
out.clip = s.viewProj * vec4f(world, 1.0);
out.uv = uv;
out.world = world;
out.normal = rotateZ(sp.normal, f.rot);
out.curl = sp.curl;
out.fid = u32(f.id);
out.alpha = f.alpha;
return out;
}
@fragment
fn fs(in: FOut, @builtin(front_facing) front: bool) -> @location(0) vec4f {
// The patch is a rectangle over the island's bounding box; the island itself
// is whatever cells this fragment actually owns. Everything else in the box
// belongs to the sheet or to another scrap.
if (ownerAt(in.uv) != in.fid) { discard; }
let surf = shadeSurface(in.uv, in.normal, in.world, in.curl, front);
if (surf.coverage * in.alpha <= COVERAGE_EPS) { discard; }
return vec4f(surf.color, surf.coverage * in.alpha);
}
`;
/* ------------------------------------------------------------------ *
* 3. Compute + render: fire and smoke particles
* ------------------------------------------------------------------ */
/**
* Life below which a particle is smoke rather than flame. Shared by the sim
* (which rushes life down to it once the fuel is gone) and the render stage
* (whose `fireAmt` ramp starts here) — they have to name the same number or
* the flame either lingers past the rush or is cut off before it fades.
*/
const FIRE_END = "0.44";
/** Life units per second the starve rush adds — the flame is out in ~0.17 s. */
const FIRE_OUT_RATE = "3.2";
/**
* Seed cutoff above which a particle is a spark rather than a flame puff, so
* `1 - this` is the spark fraction — 1.5%, already at the top of what reads as
* fire. Sparks are the most eye-catching thing in the plume and photographs of
* burning paper have almost none; at 4% they read as drifting confetti and at
* 10% as thrown seeds.
*/
const EMBER_CUT = "0.985";
/**
* Burn value at which a texel stops counting as paper for the purpose of
* feeding a flame. `shadeSurface` fades coverage out over roughly 0.63..0.85,
* so by 0.78 the sheet there is down to a quarter-opaque tatter — thin enough
* that a flame standing on it reads as a flame standing on nothing.
*/
const PAPER_CUT = "0.78";
/**
* Fraction of a handful-sized disc that has to still be paper for the flame to
* burn freely (`FUEL_HI`) and below which it is starved outright (`FUEL_LO`).
*
* The scale to read these against: a healthy front mid-sheet has consumed paper
* on one side and intact paper on the other, so it measures around 0.5 — every
* legitimate fire sits far above these numbers. What lands under them is the
* debris the burn leaves behind: hairline necks, isolated crumbs, and the tail
* end of a sheet that is mostly holes. Those emit flame per burning texel
* exactly like a real front does, and since there is no visible paper left to
* anchor it the flame hangs in mid-air, which is what these cut off.
*/
const FUEL_LO = "0.22";
const FUEL_HI = "0.44";
/**
* Stricter still for lighting a NEW flame: a spawn has to sit on a real body of
* paper. Starving is a fade, so it can afford a softer threshold; emission is
* binary and is what actually decides where fire appears.
*
* At 0.40 a straight front on a big sheet (0.5) emits freely, while a
* peninsula, a finger, or anything scrap-sized never does — which is the rule
* asked for: fire belongs to big paper, not to the crumbs coming off it.
*/
const EMIT_MASS = "0.40";
/**
* How far, in world units, a spawn is nudged from the burning band toward the
* intact paper behind it — a little under half a flame sprite.
*
* The band is the BOUNDARY of the paper, so a flame seeded exactly on it has
* half its body hanging over the hole it just ate, and that half is the part
* that reads as flame floating free. Pushing the seed inward puts the sprite
* over paper and lets it lick past the edge as it rises, which is the way round
* a real flame sits on its fuel.
*/
const EMIT_INSET = "0.026";
/** Shared by both particle modules; its size on the CPU is `PARTICLE_STRIDE`. */
const PARTICLE_STRUCT = /* wgsl */ `
struct Particle {
pos: vec3f,
life: f32,
vel: vec3f,
seed: f32,
};
/**
* 1 if this particle is a spark, 0 if it is a flame puff. The sim exempts
* sparks from starving and the render stage draws them differently, so both
* have to classify the same particles — hence one function, shared.
*/
fn isEmber(seed: f32) -> f32 {
return step(${EMBER_CUT}, fract(seed * 13.77));
}
`;
const PARTICLE_SIM_WGSL = /* wgsl */ `
${NOISE}
${PARTICLE_STRUCT}
struct SimU {
model: mat4x4f,
paperSize: vec2f,
dt: f32,
time: f32,
buoyancy: f32,
turbulence: f32,
turbScale: f32,
drag: f32,
emitChance: f32,
reset: f32,
spawnSpeed: f32,
frame: u32,
};
@group(0) @binding(0) var<uniform> u: SimU;
@group(0) @binding(1) var<storage, read_write> parts: array<Particle>;
@group(0) @binding(2) var burnTex: texture_2d<f32>;
// Same per-cell owner grid the two surface passes consult: 0 = still part of
// the sheet, anything else = claimed by a scrap that has fallen away, or gone.
@group(0) @binding(3) var ownerTex: texture_2d<u32>;
/** True while the paper at uv is still where the sheet says it is. A cell some
* scrap took with it is drawn somewhere else on screen now, so a flame left
* behind at its old UV is burning empty air. */
fn attachedAt(uv: vec2f) -> bool {
let c = clamp(vec2i(uv * ${OCC_SIZE}.0), vec2i(0), vec2i(${OCC_SIZE - 1}));
return textureLoad(ownerTex, c, 0).r == 0u;
}
/**
* The sheet UV a world point sits over, projected along the sheet normal.
* The model is a pure rotation, so its inverse is its transpose — add a
* translation or a scale to it and this silently goes wrong (same caveat as
* the CPU-side pickUV).
*/
fn sheetUV(world: vec3f) -> vec2f {
let local = vec3f(
dot(world, u.model[0].xyz),
dot(world, u.model[1].xyz),
dot(world, u.model[2].xyz),
);
return vec2f(local.x / u.paperSize.x + 0.5, 0.5 - local.y / u.paperSize.y);
}
/** The burn mask at a UV, clamped to the sheet. Unfiltered — this pass wants
* the texel, not a blend of four. */
fn burnAtTexel(uv: vec2f, dim: vec2f) -> f32 {
return textureLoad(burnTex, vec2i(clamp(uv, vec2f(0.0), vec2f(1.0)) * dim), 0).r;
}
/**
* Which way the intact paper lies from uv, as a unit direction in world-scaled
* UV. The burn mask rises toward consumed, so the way IN to the sheet is minus
* its gradient. Returns zero where the neighbourhood is flat and there is no
* meaningful inward — deep inside a hole, or out on untouched paper.
*/
fn towardPaper(uv: vec2f, dim: vec2f) -> vec2f {
// Stepped an equal WORLD distance on both axes, so the result is a direction
// on the paper rather than one skewed by the sheet's aspect.
let e = 0.020 / u.paperSize;
let g = vec2f(
burnAtTexel(uv + vec2f(e.x, 0.0), dim) - burnAtTexel(uv - vec2f(e.x, 0.0), dim),
burnAtTexel(uv + vec2f(0.0, e.y), dim) - burnAtTexel(uv - vec2f(0.0, e.y), dim),
);
let len = length(g);
if (len < 0.02) { return vec2f(0.0); }
return -g / len;
}
/**
* How much of a handful-sized disc around uv is still paper: intact enough to
* see, and still attached to the sheet.
*
* This is a MASS, not a nearest-fuel distance, and the difference is the whole
* point. Asking only "is the least-burnt texel nearby still unburnt" keeps a
* flame fed off a single surviving hair of paper, so the burn's debris — necks
* one texel wide, crumbs, the shredded tail end of the sheet — goes on emitting
* flame at the same rate a real front does, with nothing visible underneath it.
* A fraction asks the question that actually matters: is there a body of paper
* here to burn?
*
* Only taps that land on the sheet vote. Area outside it was never paper, so it
* must not count against a flame at the sheet's own edge — that fire is real,
* and half its neighbourhood is simply off the page.
*/
fn paperMass(uv: vec2f, dim: vec2f) -> f32 {
// Well clear of the sheet there is nothing to weigh, and without this the
// clamped reads below would let the border row feed a flame drifting away
// into open air.
if (uv.x < -0.06 || uv.x > 1.06 || uv.y < -0.06 || uv.y > 1.06) { return 0.0; }
// Radii in world units, then converted to UV: the sheet is 1.38x taller than
// it is wide, so a radius applied straight in UV would be an ellipse on the
// paper. Two rings rather than one so a flame is judged on its immediate
// surroundings and on the wider body behind them at once.
let r1 = 0.035 / u.paperSize;
let r2 = 0.075 / u.paperSize;
var on = 0.0;
var mass = 0.0;
for (var k = 0u; k < 17u; k++) {
var t = uv;
if (k > 0u) {
// Eight directions per ring: the inner one for k in 1..8, the outer for
// 9..16, both landing back on the same eight angles.
let a = f32(k) * (PI * 0.25);
t = uv + vec2f(cos(a), sin(a)) * select(r1, r2, k > 8u);
}
if (t.x < 0.0 || t.x > 1.0 || t.y < 0.0 || t.y > 1.0) { continue; }
on += 1.0;
if (burnAtTexel(t, dim) < ${PAPER_CUT} && attachedAt(t)) { mass += 1.0; }
}
if (on < 1.0) { return 0.0; }
return mass / on;
}
@compute @workgroup_size(64)
fn updateParticles(@builtin(global_invocation_id) gid: vec3u) {
let i = gid.x;
if (i >= arrayLength(&parts)) { return; }
var p = parts[i];
if (u.reset > 0.5) {
p.life = 0.0;
p.pos = vec3f(0.0);
p.vel = vec3f(0.0);
parts[i] = p;
return;
}
var rs = pcg(i * 2654435761u + u.frame * 40503u);
let dim = vec2f(textureDimensions(burnTex));
if (p.life > 0.0) {
let heat = p.life * p.life;
let age = 1.0 - p.life;
let buoy = vec3f(0.0, u.buoyancy * (0.25 + heat * 1.75), 0.0);
// Flattened to the picture plane: the view is orthographic and flat-on, so
// z drift buys nothing visually but does let a flame wander behind the sheet
// and get depth-culled by it. Every particle keeps the z it was born with.
let wind = vec3f(curlNoiseXY(p.pos * u.turbScale + vec3f(0.0, -u.time * 0.4, u.time * 0.12)), 0.0);
// Real flames are laminar where they leave the fuel and only break up
// further along: ramping turbulence with age keeps a coherent column at the
// base and lets the tips tear apart, which is most of what sells the shape.
// Squared rather than linear, so the base is markedly cleaner — that clean
// base is what lets the sheet of flame along the front read as continuous.
p.vel += (buoy + wind * u.turbulence * (0.10 + 1.55 * age * age)) * u.dt;
p.vel *= exp(-u.drag * u.dt);
p.pos += p.vel * u.dt;
// Fire follows the fuel, and the test is WHERE THE FLAME IS NOW, not where
// it was emitted: a particle rises the better part of a sheet width during
// its fire window while the front creeps a fraction of that, so a test
// anchored at the birth UV leaves a wall of fire standing over paper that
// burnt away seconds ago.
// Starved once the paper under the flame thins past a tatter — over a hole,
// over a scrap that has fallen away, or over debris too slight to feed it.
// Sparks are exempt: a carried ember over a hole is right. So is smoke
// (life < ${FIRE_END}) drifting off a burnt-out hole. Both are pure gates,
// and paperMass is thirty-odd texture reads, so it is only evaluated where
// its answer can survive them.
let notEmber = 1.0 - isEmber(p.seed);
let inFire = step(${FIRE_END}, p.life);
var starveRate = 0.0;
if (notEmber * inFire > 0.0) {
let mass = paperMass(sheetUV(p.pos), dim);
starveRate = ((1.0 - smoothstep(${FUEL_LO}, ${FUEL_HI}, mass)) * notEmber)
* inFire * ${FIRE_OUT_RATE};
}
// Lifetime 0.67s .. 1.8s depending on the particle's seed. Kept short: a
// long-lived parcel of flame drifts far from the front and the plume builds
// into a standing wall of light instead of licking off the paper.
p.life -= u.dt * (0.55 + 0.95 * fract(p.seed * 7.31) + starveRate);
if (p.life <= 0.0) { p.life = 0.0; }
} else if (rnd(&rs) < u.emitChance) {
// Rejection-sample the mask for a point on the active burning band, step it
// onto the paper, and keep it only if it landed on a real body of paper.
// Ten tries: the mass test rejects a good share of the band once the sheet
// starts breaking up, and too few tries would thin the flame along the
// stretches of front that are still perfectly healthy.
for (var k = 0u; k < 10u; k++) {
let hit = vec2f(rnd(&rs), rnd(&rs));
let b = burnAtTexel(hit, dim);
if (b <= 0.2 || b >= 0.8) { continue; }
// Step off the band into the paper behind it, so the sprite sits ON the
// sheet instead of straddling its edge. Tested AFTER the step: the point
// that has to be on a body of paper is the one the flame is drawn at.
let uv = clamp(hit + towardPaper(hit, dim) * (${EMIT_INSET} / u.paperSize),
vec2f(0.0), vec2f(1.0));
if (attachedAt(uv) && paperMass(uv, dim) > ${EMIT_MASS}) {
let local = vec3f((uv.x - 0.5) * u.paperSize.x, (0.5 - uv.y) * u.paperSize.y, 0.0);
let world = (u.model * vec4f(local, 1.0)).xyz;
let nrm = normalize((u.model * vec4f(0.0, 0.0, 1.0, 0.0)).xyz);
// The offset along the normal is pure depth ordering — flat-on and
// orthographic, it moves nothing on screen. It sits in front of the
// scrap layer (see FRAG_Z_LIFT) so flame always draws over falling
// paper rather than being cut in half by it.
p.pos = world + nrm * (0.030 + rnd(&rs) * 0.010);
// Almost no lateral spread at birth — the plume should leave the sheet as
// a column and only fan out once the age-scaled turbulence takes over.
// No z component: the plume lives in the picture plane.
p.vel = vec3f(
(rnd(&rs) - 0.5) * 0.09,
0.40 + rnd(&rs) * 0.55,
0.0,
) * u.spawnSpeed;
p.life = 1.0;
p.seed = rnd(&rs);
break;
}
}
}
parts[i] = p;
}
`;
const PARTICLE_RENDER_WGSL = /* wgsl */ `
${NOISE}
${BLACKBODY}
${PARTICLE_STRUCT}
struct RenderU {
viewProj: mat4x4f,
camRight: vec3f,
fireSize: f32,
camUp: vec3f,
smokeSize: f32,
time: f32,
fireIntensity: f32,
smokeOpacity: f32,
flameDetail: f32,
flameWisp: f32,
flameStretch: f32,
flameTongue: f32,
flameSharp: f32,
};
@group(0) @binding(0) var<uniform> r: RenderU;
@group(0) @binding(1) var<storage, read> rparts: array<Particle>;
/**
* Flame density at a world point. Three octaves rather than fbm3's four — this
* runs on every fragment of every sprite, the most fill-bound thing on screen.
*
* Two things separate it from plain fbm, and both are load-bearing:
*
* - The vertical axis is COMPRESSED in noise space (by flameTongue), so
* features come out several times taller than they are wide. An isotropic
* field carves the plume into round blobs however it is thresholded, and a
* heap of blobs is what a particle system must not look like.
* - The finest octave is RIDGED (1 - abs(noise)), running a thin bright spine
* down each filament rather than a smooth hump — the yellow core visible up
* a real flame tongue.
*
* Sampled in WORLD space: overlapping sprites have to carve out of the same
* filaments or each one shows its own footprint again.
*/
fn flameField(world: vec3f, t: f32) -> f32 {
// Lean the filaments over as a function of height so they curl instead of
// standing as parallel bars. Two frequencies, because one reads as a single
// coherent wave running through the whole plume.
var q = world;
q.x += sin(world.y * 2.3 + t * 0.9) * 0.05 + sin(world.y * 5.1 - t * 1.4) * 0.022;
q.z += sin(world.y * 3.7 + t * 1.1) * 0.035;
let p = vec3f(q.x, q.y / max(r.flameTongue, 0.1), q.z) * r.flameDetail
+ vec3f(0.0, -t * 2.4, t * 0.16);
var f = snoise(p) * 0.52;
f += snoise(p * vec3f(2.1, 1.7, 2.1) + vec3f(19.7, 3.1, 11.3)) * 0.26;
// Ridged: abs() folds the octave so its zero crossings become creases, and
// the 1 - keeps them as bright spines rather than dark seams. Range is
// [0, 0.22], recentred by the -0.11 so it does not bias the sum.
f += (1.0 - abs(snoise(p * vec3f(4.3, 3.1, 4.3) + vec3f(-7.2, 23.9, 5.4)))) * 0.22 - 0.11;
return clamp(f * 0.5 + 0.5, 0.0, 1.0);
}
struct POut {
@builtin(position) clip: vec4f,
@location(0) quad: vec2f,
@location(1) life: f32,
@location(2) seed: f32,
@location(3) world: vec3f,
};
@vertex
fn particleVs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> POut {
var out: POut;
let p = rparts[ii];
if (p.life <= 0.0) {
// Push dead particles outside the clip volume.
out.clip = vec4f(0.0, 0.0, 2.0, 1.0);
out.quad = vec2f(0.0);
out.life = 0.0;
out.seed = 0.0;
out.world = vec3f(0.0);
return out;
}
var corners = array<vec2f, 6>(
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
);
let q = corners[vi];
let age = 1.0 - p.life;
let ember = isEmber(p.seed);
// Flame puffs swell into smoke as they cool. Sparks must NOT — they were
// sharing this ramp, so an old ember grew to smokeSize and hung in the air as
// a fat orange lozenge. A spark is a speck of burning fibre; it stays a speck.
let grow = mix(r.fireSize, r.smokeSize, smoothstep(0.15, 1.0, age));
let size = mix(grow, r.fireSize * 0.18, ember) * (0.55 + fract(p.seed * 3.77));
// NO per-sprite spin. A rotating quad drags its own shape mask around with it,
// and a field of those reads unmistakably as a heap of spinning discs — the
// single biggest tell that this is a particle system. Shape variety now comes
// from the shared world-space field sampled in the fragment stage instead.
//
// The billboard's vertical axis follows the particle's motion projected into
// the view plane, so flames streak along the direction they are actually
// travelling and lick sideways where the turbulence pushes them.
let fwd = normalize(cross(r.camRight, r.camUp));
var vAxis = p.vel - fwd * dot(p.vel, fwd);
let vLen = length(vAxis);
// Fall back to screen-up as the velocity turns edge-on to the camera: what is
// left of the projection there is mostly numerical noise, and a hard cutoff
// would make those sprites snap between orientations frame to frame.
vAxis = normalize(mix(r.camUp, vAxis / max(vLen, 1e-5), smoothstep(0.02, 0.20, vLen)));
let hAxis = normalize(cross(fwd, vAxis));
// Tall and narrow while hot, rounding out into a smoke puff as it cools.
let stretchY = mix(r.flameStretch, 1.0, smoothstep(0.0, 0.7, age));
// Sparks streak along their own motion rather than sitting as round dots —
// an ember travelling this fast is a short line, not a bead.
let sq = select(vec2f(q.x * mix(0.7, 1.0, age), q.y * stretchY), vec2f(q.x, q.y * 1.8), ember > 0.5);
let world = p.pos + (hAxis * sq.x + vAxis * sq.y) * size;
out.clip = r.viewProj * vec4f(world, 1.0);
out.quad = q;
out.life = p.life;
out.seed = p.seed;
out.world = world;
return out;
}
@fragment
fn particleFs(in: POut) -> @location(0) vec4f {
let ember = isEmber(in.seed);
let age = 1.0 - in.life;
// Sprite footprint: a teardrop for flames — pinched hard toward the tip, full
// at the base — and a streak for sparks. The radius reaches 1 strictly inside
// the quad, so both profiles below are exactly zero before the quad edge and
// the clipped square never shows. (An exponent under 1 would lift that tail
// back up and reintroduce the visible boundary, so neither profile uses one.)
//
// This is the first thing the stage does. The teardrop covers about three
// fifths of its quad, so two fragments in five are outside it — and this is
// the most fill-bound shader on screen, with three simplex evaluations in the
// field below. Rejecting on the cheap analytic shape before paying for any of
// that is worth more here than anywhere else in the frame.
let q = in.quad;
let taper = mix(1.0 - 0.72 * smoothstep(-0.5, 1.0, q.y), 1.0, ember);
let rr = length(vec2f(q.x / max(taper, 0.18), q.y));
if (rr > 1.0) { discard; }
// The density field is sampled in WORLD space, not sprite space. That is what
// makes the plume read as fire rather than as sprites: every overlapping quad
// carves out of the *same* filaments, so neighbours fuse into one continuous
// body instead of each showing its own little disc. Sampling it per-sprite
// gives every sprite a private pattern that travels with it — the classic
// cloud-of-blobs look.
let field = flameField(in.world, r.time);
// The carve has to reach ZERO. flameWisp moves the THRESHOLD rather than
// scaling the carve — scaling leaves a floor over every sprite's whole
// footprint, and thousands of overlapping floors sum into a flat translucent
// veil: fog with orange smudges in it, unrecoverable by any downstream tuning.
//
// The threshold also rises steeply with age, which does two jobs at once: near
// birth it is low enough that the flame is almost solid, so the base reads as
// one continuous sheet running along the burn front rather than a row of
// separate licks; by the tips it has risen far enough to erode them to
// filaments, which is the taper.
let cut = mix(0.20, 0.54, r.flameWisp) + 0.34 * age;
// A narrow ramp is a defined silhouette; a wide one is a smudge. The interior
// ramp is deliberately much wider than the edge ramp so the flame has a crisp
// outline *and* a soft bright core, rather than being uniformly filled to its
// own edge (which reads as cut paper) or soft all the way out (fog again).
let edge = mix(0.26, 0.035, r.flameSharp);
let body = smoothstep(cut, cut + edge, field);
let dens = body * (0.30 + 0.70 * smoothstep(cut, cut + 0.42, field));
// The sprite envelope stays soft and the FIELD supplies every hard edge. That
// division of labour is deliberate: a hard envelope would put each quad's own
// silhouette back on screen, while a soft field leaves nothing but haze.
let env = smoothstep(1.0, 0.04, rr);
let mask = env * mix(dens, 1.0, ember);
// Smoke stays much softer and wider so it blends rather than stippling, and
// takes only a little of the carve so it does not shred into lace.
let softMask = smoothstep(1.0, 0.28, rr) * mix(mix(1.0, body, 0.35), 1.0, ember);
// Fire burns out well before the particle does — paper flames are short, and
// a long fire tail turns the whole plume into a standing wall of light.
let fireAmt = mix(smoothstep(${FIRE_END}, 0.98, in.life), smoothstep(0.02, 0.4, in.life), ember);
// Smoke starts only once the flame is essentially over. Overlap the two and
// every flame sprite is also a grey sprite alpha-blending over its own colour,
// which comes out brown and desaturated instead of orange.
let smokeMix = smoothstep(0.52, 0.12, in.life) * (1.0 - ember);
// Temperature is driven by the shared field as well as by age, so hot yellow
// filaments run continuously across many sprites instead of each puff owning
// its own bright centre.
//
// Sprites are red-shifted well below the temperature the flame should read as,
// and accumulation is what carries dense regions up the curve. Brightness and
// hue are not independent here: additive overlap raises every channel and ACES
// desaturates whatever it compresses from above 1, so the plume's hue lands
// well up-curve from any single sprite's. Aiming a sprite at the hue the flame
// should be gets a cream-gold flame; aiming it at blackbody's orange-RED gets
// an orange one, with overlap taking the dense base to yellow-white on its own
// — which is where a real sheet of burning paper is brightest. The life² term
// leaves the tips orange.
let temp = clamp(mix(0.24, 0.52, in.life * in.life) * (0.58 + 0.42 * dens), 0.0, 1.0);
// A squared core (not cubed) keeps any single sprite from resolving as a
// bright dot — density comes from overlap, not from per-sprite peaks.
let core = mask * mask;
let fireCol = blackbody(temp) * core * r.fireIntensity * fireAmt * mix(1.0, 1.6, ember);
// Smoke: alpha-blended, expanding, fading.
let alpha = softMask * smokeMix * r.smokeOpacity * smoothstep(0.0, 0.25, in.life);
// Smoke sits in the fire's own light, so it is warm near birth and cools off.
let smokeCol = mix(vec3f(0.22, 0.17, 0.145), vec3f(0.05, 0.048, 0.052), smokeMix);
// Premultiplied output: fire contributes with alpha 0 (pure add), smoke blends.
return vec4f(fireCol + smokeCol * alpha, alpha);
}
`;
/* ------------------------------------------------------------------ *
* 4. Post: ACES tonemap
* ------------------------------------------------------------------ */
const FULLSCREEN_VS = /* wgsl */ `
struct FOut {
@builtin(position) clip: vec4f,
@location(0) uv: vec2f,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> FOut {
var pts = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
let p = pts[vi];
var out: FOut;
out.clip = vec4f(p, 0.0, 1.0);
out.uv = vec2f((p.x + 1.0) * 0.5, 1.0 - (p.y + 1.0) * 0.5);
return out;
}
`;
const COMPOSITE_WGSL = /* wgsl */ `
${FULLSCREEN_VS}
struct PostU { exposure: f32, pad0: f32, vignette: f32, time: f32 };
@group(0) @binding(0) var<uniform> u: PostU;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var scene: texture_2d<f32>;
fn aces(x: vec3f) -> vec3f {
let a = 2.51; let b = 0.03; let c = 2.43; let d = 0.59; let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3f(0.0), vec3f(1.0));
}
@fragment
fn fs(in: FOut) -> @location(0) vec4f {
var c = textureSample(scene, samp, in.uv).rgb;
c = aces(c * u.exposure);
let d = distance(in.uv, vec2f(0.5));
c *= mix(1.0, smoothstep(0.95, 0.25, d), u.vignette);
// Dither to break up gradient banding in the dark falloff.
let dither = (fract(sin(dot(in.uv * 1024.0, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
return vec4f(pow(c, vec3f(1.0 / 2.2)) + dither, 1.0);
}
`;
/* ==========================================================================
* MAT4 / VEC3 HELPERS — minimal and column-major, as WGSL's `mat4x4f` is.
* ========================================================================== */
type Mat4 = Float32Array;
type Vec3 = [number, number, number];
function mat4(): Mat4 {
const m = new Float32Array(16);
m[0] = m[5] = m[10] = m[15] = 1;
return m;
}
/** out = a * b */
function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
for (let c = 0; c < 4; c++) {
const b0 = b[c * 4],
b1 = b[c * 4 + 1],
b2 = b[c * 4 + 2],
b3 = b[c * 4 + 3];
out[c * 4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
out[c * 4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
out[c * 4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
out[c * 4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
}
return out;
}
/**
* Right-handed orthographic with a [0, 1] depth range (WebGPU convention).
* Half-extents rather than a frustum: the view is flat-on, so all the caller
* ever has is "how much world fits on screen".
*/
function orthographic(
out: Mat4,
halfW: number,
halfH: number,
near: number,
far: number,
): Mat4 {
out.fill(0);
out[0] = 1 / halfW;
out[5] = 1 / halfH;
out[10] = 1 / (near - far);
out[14] = near / (near - far);
out[15] = 1;
return out;
}
function lookAt(out: Mat4, eye: Vec3, target: Vec3, up: Vec3): Mat4 {
const z = normalize([
eye[0] - target[0],
eye[1] - target[1],
eye[2] - target[2],
]);
const x = normalize(cross(up, z));
const y = cross(z, x);
out[0] = x[0];
out[1] = y[0];
out[2] = z[0];
out[3] = 0;
out[4] = x[1];
out[5] = y[1];
out[6] = z[1];
out[7] = 0;
out[8] = x[2];
out[9] = y[2];
out[10] = z[2];
out[11] = 0;
out[12] = -dot(x, eye);
out[13] = -dot(y, eye);
out[14] = -dot(z, eye);
out[15] = 1;
return out;
}
/** Transform a position (w = 1) by a matrix. */
function transformPoint(m: Mat4, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],
];
}
function cross(a: Vec3, b: Vec3): Vec3 {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
function dot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function normalize(v: Vec3): Vec3 {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
}
/* ==========================================================================
* PROCEDURAL PAPER TEXTURE — fibre grain and blotches, generated on the CPU so
* the effect ships with no external image assets.
* ========================================================================== */
const SIZE = 512;
function fract(x: number) {
return x - Math.floor(x);
}
function hash2(x: number, y: number): number {
return fract(Math.sin(x * 127.1 + y * 311.7) * 43758.5453);
}
function valueNoise(x: number, y: number): number {
const ix = Math.floor(x),
iy = Math.floor(y);
const fx = x - ix,
fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy);
const b = hash2(ix + 1, iy);
const c = hash2(ix, iy + 1);
const d = hash2(ix + 1, iy + 1);
return (
a * (1 - ux) * (1 - uy) +
b * ux * (1 - uy) +
c * (1 - ux) * uy +
d * ux * uy
);
}
function fbm(x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 0.5;
let fx = x;
let fy = y;
for (let i = 0; i < octaves; i++) {
sum += amp * valueNoise(fx, fy);
fx *= 2.03;
fy *= 2.03;
amp *= 0.5;
}
return sum;
}
/** Returns an RGBA8 buffer of paper grain: R = fine fibre, G = blotch, B = speckle. */
function generatePaperTexture(): { data: Uint8Array; size: number } {
const data = new Uint8Array(SIZE * SIZE * 4);
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const u = x / SIZE;
const v = y / SIZE;
// Stretched noise reads as directional pulp fibres.
const fibre = fbm(u * 220, v * 34, 4);
const crossFibre = fbm(u * 30 + 11, v * 190 + 7, 3);
const grain = 0.5 + (fibre - 0.5) * 0.75 + (crossFibre - 0.5) * 0.45;
const blotch = fbm(u * 5.5 + 31, v * 5.5 + 17, 4);
const speckle = hash2(x * 0.61, y * 0.37) > 0.995 ? 0.35 : 0;
const i = (y * SIZE + x) * 4;
data[i] = Math.max(0, Math.min(255, (grain - speckle) * 255));
data[i + 1] = Math.max(0, Math.min(255, blotch * 255));
data[i + 2] = Math.max(
0,
Math.min(255, (0.5 + (fibre - blotch) * 0.6) * 255),
);
data[i + 3] = 255;
}
}
return { data, size: SIZE };
}
/* ==========================================================================
* THE INK — the card, drawn with Skia
*
* A photo detail card, rasterized as ink for `BurningPaperEngine.setInk`.
*
* The one rule: this is painted onto an OPAQUE surface that the surface shader
* multiplies into the paper's albedo, so nothing here can lighten the sheet —
* white is bare paper and is the only source of anything pale. The photograph
* is subject to the same multiply, which is the point: it is a print, and it
* scorches, chars and falls with the fibre it was printed on instead of
* floating over it.
*
* The layout is in ink pixels and is the single source of truth for both the
* artwork and the invisible touchable laid over it — see `RASTER_W` for the
* size those coordinates are actually rasterized at.
* ========================================================================== */
/*
* What is on the card. The component takes no props, so this is where the
* content lives — change these four and you have changed the card.
*/
/**
* Decoded by `useImage`. A remote URL here means the card prints a flat wash
* where the picture goes until the download lands, then re-prints itself; a
* `require('./photo.jpg')` is on the sheet from the first frame.
*/
const PHOTO =
"https://media.istockphoto.com/id/517188688/photo/mountain-landscape.jpg" +
"?s=612x612&w=0&k=20&c=A63koPKaCyIwQWOTFBRWXj_PwCrR4cEoOw2S9Q7yVl8=";
const TITLE = "Beautiful landscape";
const META = "May 12, 2025 • 4:32 PM";
/** Keep it short: the pill is a fixed width and does not reflow. */
const DELETE_LABEL = "Delete";
/**
* Ink layout size. Stretched over the whole sheet, so it carries the paper's
* own 1 : 1.38 aspect — otherwise the photograph would print squashed.
*/
const INK_W = 1400;
const INK_H = 1932;
/**
* What the layout above is actually rasterized at.
*
* Rasterizing 1 : 1 would be 10 MB of pixels to read back out of Skia and hand
* to the texture on every reprint. A phone draws the sheet about 900 device px
* wide at most, so 1024 still has texels to spare, at a third of the cost.
*/
const RASTER_W = 1024;
const RASTER_SCALE = RASTER_W / INK_W;
const RASTER_H = Math.round(INK_H * RASTER_SCALE);
// Skia has no `system-ui` alias, so this names the platform's own UI face.
const FONT_FAMILY = Platform.select({
ios: "Helvetica Neue",
default: "sans-serif",
});
/** Bare paper — the identity of the multiply. */
const PAPER = "#ffffff";
const INK_STRONG = "#221d1a";
const INK_BODY = "#8b827a";
const INK_HAIRLINE = "#d2c9c0";
/** What stands in for the picture until it has decoded. */
const PHOTO_PLACEHOLDER = "#ded6cd";
const DANGER = "#b3261e";
const DANGER_ACTIVE = "#851912";
interface InkRect {
x: number;
y: number;
w: number;
h: number;
}
interface CardVisual {
/** A finger is down on the delete button. */
active: boolean;
/**
* The button's scale about its own centre — the press bounce. 1 at rest, and
* never far from it: `BUTTON_PATCH` is only sized to hold a little overshoot
* past 1, and a button drawn larger than that would be trimmed by its patch.
*/
scale?: number;
}
/**
* The card.
*
* The photograph is full bleed — off the top and off both sides, so it is
* trimmed by the sheet's own torn edge rather than by a frame of blank paper.
* There is no margin to speak of anywhere else either: everything below runs
* the same width as the button.
*
* The foot is 92 against the 130 down the sides — deliberately LESS than the
* flanks. A block of type sitting on a page wants slightly more at its base or
* it looks pinched, but the button is a filled shape with its own optical
* weight, and once it is a full pill the paper under it reads as a band the
* layout forgot rather than as a margin.
*
* There is no Cancel. With one control on the page the destructive button is
* not one of a pair to be weighed against the other, so it takes the full
* measure rather than a right-hand slot.
*/
const PHOTO_RECT: InkRect = { x: 0, y: 0, w: INK_W, h: 1164 };
const DELETE_RECT: InkRect = { x: 130, y: 1654, w: 1140, h: 186 };
const TITLE_BASELINE = 1356;
const META_BASELINE = 1452;
const RULE_Y = 1566;
/** The rect in sheet UV, which is what both the shader and picking speak. */
function rectToUV(r: InkRect) {
return {
u0: r.x / INK_W,
v0: r.y / INK_H,
u1: (r.x + r.w) / INK_W,
v1: (r.y + r.h) / INK_H,
};
}
/**
* Where the fire starts: one small spot in the middle of the delete button.
*
* A seed does not warm the paper, it sets the burn mask straight to 1, so
* whatever it covers is gone on the first frame. Paired with a `seedRadius` a
* fifth of the engine's default (see the screen), that leaves a pinhole in the
* button that has to eat its way out — the button is bitten, then consumed,
* then the photograph above it goes. At the default radius the button would
* simply be missing before the first frame is drawn.
*/
const DELETE_IGNITION: [number, number] = [
(DELETE_RECT.x + DELETE_RECT.w / 2) / INK_W,
(DELETE_RECT.y + DELETE_RECT.h / 2) / INK_H,
];
/**
* Fonts and paints are cached by their arguments.
*
* `renderButtonPatch` redraws the whole button on every frame of the press, and
* `matchFont` resolves a typeface each time it is called. Both kinds of object
* are immutable once built here and independent of any surface, so one instance
* per distinct argument list is enough for the life of the process.
*/
const fontCache = new Map<string, SkFont>();
const paintCache = new Map<string, SkPaint>();
function makeFont(size: number, weight: "normal" | "600" | "bold"): SkFont {
const key = `${size}:${weight}`;
let font = fontCache.get(key);
if (!font) {
font = matchFont({
fontFamily: FONT_FAMILY,
fontSize: size,
fontStyle: "normal",
fontWeight: weight,
});
fontCache.set(key, font);
}
return font;
}
function fillPaint(color: string): SkPaint {
const key = `f:${color}`;
let paint = paintCache.get(key);
if (!paint) {
paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setColor(Skia.Color(color));
paintCache.set(key, paint);
}
return paint;
}
function strokePaint(color: string, width: number): SkPaint {
const key = `s:${color}:${width}`;
let paint = paintCache.get(key);
if (!paint) {
paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setColor(Skia.Color(color));
paint.setStyle(PaintStyle.Stroke);
paint.setStrokeWidth(width);
paintCache.set(key, paint);
}
return paint;
}
/**
* Draw `text` centred on `cx`, with its ALPHABETIC baseline on `y`.
*
* `measureText` is baseline-relative and returns the INKED bounds, so the pen
* has to be pulled back by the left side bearing (`b.x`) as well as by half the
* width. That centres on the ink rather than on the advance, which is the better
* answer for a title; the two differ by a fraction of a side bearing.
*/
function drawCenteredText(
canvas: SkCanvas,
text: string,
font: SkFont,
color: string,
cx: number,
baseline: number,
): void {
const b = font.measureText(text);
canvas.drawText(
text,
cx - b.width / 2 - b.x,
baseline,
fillPaint(color),
font,
);
}
function rrect(r: InkRect, radius: number) {
return Skia.RRectXY(Skia.XYWHRect(r.x, r.y, r.w, r.h), radius, radius);
}
/**
* The print itself.
*
* No frame and no corner radius: it is bled off three sides, and a rounded
* corner or a hairline anywhere along an edge that is meant to run past the
* trim would state exactly the boundary the bleed exists to hide.
*
* Filled rather than fitted — the frame's proportions and the file's are close
* but not equal, and a picture with two bands of blank paper down its sides
* would look like a mistake, where a crop off each end is invisible. Until the
* decode lands there is a flat wash in its place, which is what the sheet fades
* up over on a cold load.
*/
function printedPhoto(canvas: SkCanvas, photo: SkImage | null): void {
canvas.save();
canvas.clipRect(
Skia.XYWHRect(PHOTO_RECT.x, PHOTO_RECT.y, PHOTO_RECT.w, PHOTO_RECT.h),
ClipOp.Intersect,
true,
);
if (photo) {
const iw = photo.width();
const ih = photo.height();
const scale = Math.max(PHOTO_RECT.w / iw, PHOTO_RECT.h / ih);
const w = iw * scale;
const h = ih * scale;
const paint = Skia.Paint();
paint.setAntiAlias(true);
canvas.drawImageRect(
photo,
Skia.XYWHRect(0, 0, iw, ih),
Skia.XYWHRect(
PHOTO_RECT.x + (PHOTO_RECT.w - w) / 2,
PHOTO_RECT.y + (PHOTO_RECT.h - h) / 2,
w,
h,
),
paint,
);
} else {
canvas.drawRect(
Skia.XYWHRect(PHOTO_RECT.x, PHOTO_RECT.y, PHOTO_RECT.w, PHOTO_RECT.h),
fillPaint(PHOTO_PLACEHOLDER),
);
}
canvas.restore();
}
/**
* The trash can's drawn width, as a fraction of the `size` given to
* `trashIcon`. The caller needs it too, to lay the icon out against the label.
*/
const ICON_WIDTH = 0.74;
/**
* The trash can, knocked out of the button in bare paper.
*
* Drawn about its own centre so the caller can place the icon and the label as
* one group, which is the only way to keep the pair optically centred when the
* label's width is whatever the platform's system font makes it.
*/
function trashIcon(
canvas: SkCanvas,
cx: number,
cy: number,
size: number,
): void {
const w = size * ICON_WIDTH;
const top = cy - size / 2;
const bodyTop = top + size * 0.24;
const bottom = cy + size / 2;
const stroke = strokePaint(PAPER, size * 0.088);
stroke.setStrokeCap(StrokeCap.Round);
stroke.setStrokeJoin(StrokeJoin.Round);
// Lid, and the handle above it.
canvas.drawLine(
cx - w / 2 - size * 0.06,
bodyTop,
cx + w / 2 + size * 0.06,
bodyTop,
stroke,
);
canvas.drawPath(
Skia.PathBuilder.Make()
.moveTo(cx - w * 0.22, bodyTop)
.lineTo(cx - w * 0.22, top + size * 0.06)
.lineTo(cx + w * 0.22, top + size * 0.06)
.lineTo(cx + w * 0.22, bodyTop)
.build(),
stroke,
);
// Tapered can.
canvas.drawPath(
Skia.PathBuilder.Make()
.moveTo(cx - w * 0.42, bodyTop + size * 0.08)
.lineTo(cx - w * 0.33, bottom)
.lineTo(cx + w * 0.33, bottom)
.lineTo(cx + w * 0.42, bodyTop + size * 0.08)
.build(),
stroke,
);
// Slots.
const slot = strokePaint(PAPER, size * 0.07);
slot.setStrokeCap(StrokeCap.Round);
for (const dx of [-w * 0.17, 0, w * 0.17]) {
canvas.drawLine(
cx + dx,
bodyTop + size * 0.24,
cx + dx * 0.82,
bottom - size * 0.16,
slot,
);
}
}
function deleteButton(canvas: SkCanvas, v: CardVisual): void {
// The press bounce, taken about the button's own centre so the pill closes in
// on its label rather than sliding off toward a corner. Everything below is
// drawn at rest size and carried by this transform, label and icon included —
// a pill that shrank while its contents held still would read as two objects.
const scale = v.scale ?? 1;
const pivotX = DELETE_RECT.x + DELETE_RECT.w / 2;
const pivotY = DELETE_RECT.y + DELETE_RECT.h / 2;
canvas.save();
canvas.translate(pivotX, pivotY);
canvas.scale(scale, scale);
canvas.translate(-pivotX, -pivotY);
// A full pill: the radius is half the height, so the ends are semicircles and
// there is no straight run left in the corner to state a radius at all.
canvas.drawRRect(
rrect(DELETE_RECT, DELETE_RECT.h / 2),
fillPaint(v.active ? DANGER_ACTIVE : DANGER),
);
const iconSize = 62;
const iconW = iconSize * ICON_WIDTH;
const gap = 32;
const fontSize = 60;
const font = makeFont(fontSize, "600");
const bounds = font.measureText(DELETE_LABEL);
const cx = DELETE_RECT.x + DELETE_RECT.w / 2;
const cy = DELETE_RECT.y + DELETE_RECT.h / 2;
// Icon and label centred together as one group, not each in its own half.
const start = cx - (iconW + gap + bounds.width) / 2;
trashIcon(canvas, start + iconW / 2, cy, iconSize);
// Knocked out of the ink, so the label is the sheet showing through. Centred
// on the cap height rather than the em box, whose descender space prints a
// label sitting visibly low in its button.
canvas.drawText(
DELETE_LABEL,
start + iconW + gap - bounds.x,
cy + fontSize * 0.355,
fillPaint(PAPER),
font,
);
canvas.restore();
}
/** Prints the whole card into `canvas`, in ink coordinates. */
function drawCard(
canvas: SkCanvas,
v: CardVisual,
photo: SkImage | null,
): void {
canvas.drawRect(Skia.XYWHRect(0, 0, INK_W, INK_H), fillPaint(PAPER));
printedPhoto(canvas, photo);
drawCenteredText(
canvas,
TITLE,
makeFont(96, "bold"),
INK_STRONG,
INK_W / 2,
TITLE_BASELINE,
);
drawCenteredText(
canvas,
META,
makeFont(50, "normal"),
INK_BODY,
INK_W / 2,
META_BASELINE,
);
// The rule takes the button's measure, not the photograph's: it belongs to
// the block of type it divides, and a line running out to the trim would read
// as a second edge of the print above it.
canvas.drawLine(
DELETE_RECT.x,
RULE_Y,
DELETE_RECT.x + DELETE_RECT.w,
RULE_Y,
strokePaint(INK_HAIRLINE, 2.5),
);
deleteButton(canvas, v);
}
interface InkImage {
/** Straight (un-premultiplied) sRGB bytes, row-major, `width * height * 4`. */
rgba: Uint8Array;
width: number;
height: number;
}
/** An `InkImage` that covers part of the page, at `x`, `y` in ink texture px. */
interface InkPatch extends InkImage {
x: number;
y: number;
}
/**
* Slack around the button in the patch, in raster px.
*
* It buys two things: room for the bounce to overshoot 1 without the pill being
* trimmed by its own patch, and a rim of bare paper so the joint between
* re-printed and untouched page falls somewhere nothing is drawn.
*/
const PATCH_MARGIN = 20;
/**
* The rectangle re-printed while the button animates.
*
* Widened to a multiple of 64 px — 256 bytes a row — so the upload is on the
* alignment every GPU copy path likes, and pinned inside the page so a patch
* can never be rejected for hanging off an edge.
*/
const BUTTON_PATCH = (() => {
const x0 = Math.max(
0,
Math.floor(DELETE_RECT.x * RASTER_SCALE) - PATCH_MARGIN,
);
const y0 = Math.max(
0,
Math.floor(DELETE_RECT.y * RASTER_SCALE) - PATCH_MARGIN,
);
const x1 = Math.min(
RASTER_W,
Math.ceil((DELETE_RECT.x + DELETE_RECT.w) * RASTER_SCALE) + PATCH_MARGIN,
);
const y1 = Math.min(
RASTER_H,
Math.ceil((DELETE_RECT.y + DELETE_RECT.h) * RASTER_SCALE) + PATCH_MARGIN,
);
const width = Math.min(RASTER_W - x0, Math.ceil((x1 - x0) / 64) * 64);
return { x: x0, y: y0, width, height: y1 - y0 };
})();
/**
* Rasterizes the card.
*
* Off the render loop and only on a state change — never per frame. One Skia
* surface, one read-back, one texture upload.
*/
function renderCard(v: CardVisual, photo: SkImage | null): InkImage | null {
const surface = Skia.Surface.Make(RASTER_W, RASTER_H);
if (!surface) return null;
const canvas = surface.getCanvas();
canvas.scale(RASTER_SCALE, RASTER_SCALE);
drawCard(canvas, v, photo);
surface.flush();
const image = surface.makeImageSnapshot();
// Pin the layout: the platform-native N32 order is BGRA on some Apple builds,
// and the texture below is read as RGBA.
const rgba = image.readPixels(0, 0, {
width: RASTER_W,
height: RASTER_H,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
}) as Uint8Array | null;
image.dispose();
surface.dispose();
if (!rgba) return null;
return { rgba, width: RASTER_W, height: RASTER_H };
}
/**
* Rasterizes just the button and the paper around it, for
* `BurningPaperEngine.setInkRegion`.
*
* This one IS called per frame, for as long as the press animation runs, so it
* exists to keep the photograph out of the loop: same drawing code as the card
* above, over a hundredth of the pixels.
*/
let patchSurface: SkSurface | null = null;
function renderButtonPatch(v: CardVisual): InkPatch | null {
const { x, y, width, height } = BUTTON_PATCH;
// One surface, kept for the life of the process. The patch is always the same
// size and this runs every frame of the press, so allocating a fresh surface
// per call would be most of what the animation costs.
if (!patchSurface) patchSurface = Skia.Surface.Make(width, height);
if (!patchSurface) return null;
const canvas = patchSurface.getCanvas();
// The transform has to be unwound: this canvas outlives the call.
canvas.save();
// Bare paper first: the patch is re-printed whole, so ground the button has
// given up has to come back as page rather than as a ghost of the last frame.
// Opaque, so it also clears the surface for the next press.
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), fillPaint(PAPER));
canvas.translate(-x, -y);
canvas.scale(RASTER_SCALE, RASTER_SCALE);
deleteButton(canvas, v);
canvas.restore();
patchSurface.flush();
const image = patchSurface.makeImageSnapshot();
const rgba = image.readPixels(0, 0, {
width,
height,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
}) as Uint8Array | null;
image.dispose();
if (!rgba) return null;
return { rgba, width, height, x, y };
}
/* ==========================================================================
* THE ENGINE — WebGPU burning-paper renderer
*
* The sheet is a displaced grid whose burn is a reaction/diffusion mask solved
* on the GPU: the front eats outward from wherever it was lit, jagged by curl
* noise, and the surface shader reads that mask to scorch, char, curl and then
* discard the paper behind it. Scraps that fully detach are found by an
* occupancy read-back and handed to a fragment pass that lets them fall, and the
* rim feeds a particle emitter for the fire and smoke.
*
* Where this differs from the same effect in a browser:
*
* - the canvas is an `RNCanvasContext`, so every frame ends in `present()` and
* the drawing-buffer size is fixed at construction rather than tracked by a
* `ResizeObserver` — the component remounts the Canvas on rotation, which
* rebuilds the engine at the new resolution;
* - `setInk` takes raw RGBA bytes rasterized by Skia rather than an
* `HTMLCanvasElement` handed to `copyExternalImageToTexture`, which native
* Dawn has no DOM source for;
* - there is no bloom, and no pointer painting. This scene prints its own
* controls on the sheet, so a touch on the paper must not light it.
* ========================================================================== */
interface PaperParams {
/** Burn front propagation speed. */
burnSpeed: number;
/** Spatial frequency of the noise that jags the front. */
noiseScale: number;
/** Contrast of that noise — higher means more ragged, stalling contours. */
noiseContrast: number;
/** How much faster the sheet burns near its outer edges. */
edgeBias: number;
/** Solver substeps per frame. */
substeps: number;
/** Radius (in UV) of the ignition spot placed by a seed. */
seedRadius: number;
/** Curl-noise displacement magnitude on the curling rim. */
curlStrength: number;
/** Curl-noise spatial frequency. */
curlScale: number;
/** Straight +Y lift applied to the curling rim. */
riseAmount: number;
/** HDR emissive multiplier for the burning band. */
emissive: number;
/** Flicker amount on the burning band. */
flicker: number;
/** How black the charred zone goes. */
charDarkness: number;
/**
* How far back from the burn the discolouration reaches, in world units
* (the sheet is 1 x 1.38). Scales the whole ramp — crust, dark brown, brown
* and the outer stain all widen together, keeping their proportions.
*/
charSpread: number;
/**
* How deep the sheet's torn outer edge bites in, in world units. 0 gives the
* guillotined rectangle this started as.
*/
deckleDepth: number;
/** Spatial frequency of the tear profile, in cycles per world unit. */
deckleScale: number;
/**
* Squircle corner radius, in world units (the sheet is 1 x 1.38). 0 is the
* square-cornered sheet this started as; the tear still runs along the
* straight edges, the corners just stop being right angles.
*/
cornerRadius: number;
/** Fraction of the idle particle pool that may respawn per second. */
emitRate: number;
/** Turbulence (curl wind) strength on particles. */
turbulence: number;
/** Turbulence spatial frequency. */
turbScale: number;
/** Upward thermal acceleration. */
buoyancy: number;
/** Velocity damping. */
drag: number;
/** Fire billboard size. */
fireSize: number;
/** Smoke billboard size (particles expand toward this). */
smokeSize: number;
/** Additive fire brightness. */
fireIntensity: number;
/** Smoke alpha. */
smokeOpacity: number;
/** Terminal fall speed multiplier for detached scraps. */
fragFall: number;
/** Sway/tumble amount for detached scraps. */
fragFlutter: number;
/** Initial lift from the thermal column when a scrap breaks off. */
fragUpdraft: number;
/** Spatial frequency of the shared world-space flame field. */
flameDetail: number;
/** Carve threshold: how much of that field is cut away to leave filaments. */
flameWisp: number;
/** Vertical elongation of a hot flame billboard. */
flameStretch: number;
/** Vertical stretch of the flame field itself — filament aspect ratio. */
flameTongue: number;
/** Crispness of the carved silhouette: 0 smudge, 1 hard edge. */
flameSharp: number;
/**
* Post exposure, against a page whose linear radiance is 1 — so this is
* literally how far under (or over) the page everything else is stopped.
*/
exposure: number;
/** Vignette amount. */
vignette: number;
}
/**
* The look this ships with.
*
* Its character: the sheet goes hard and fast, and the flame is made of many
* small, dim, overlapping sprites, so the bright parts of the plume are the
* places they pile up rather than anything a single sprite is lit at.
*
* That was tuned against a bloom pass, which is where the plume's brightness
* used to come back from, and an exposure pulled down under it so the paper did
* not come up with the fire. With no bloom here the fire's whole reach is what
* the sprites themselves emit — `fireIntensity` is the dial to turn if the plume
* reads too faint.
*/
const DEFAULT_PAPER_PARAMS: PaperParams = {
burnSpeed: 40.0,
noiseScale: 18.3,
noiseContrast: 2.05,
edgeBias: 2.0,
substeps: 6,
seedRadius: 0.02,
curlStrength: 0.011,
curlScale: 2.4,
riseAmount: 0.01,
emissive: 1.5,
flicker: 0.55,
charDarkness: 1.0,
charSpread: 0.22,
deckleDepth: 0.006,
deckleScale: 4.2,
// Square corners: a plain sheet. A scene that wants a card sets its own —
// see `CARD_PARAMS`.
cornerRadius: 0,
emitRate: 2.2,
turbulence: 1.03,
turbScale: 3.65,
buoyancy: 0.2,
drag: 0.6,
fireSize: 0.026,
smokeSize: 0.05,
fireIntensity: 0.2,
smokeOpacity: 0.045,
fragFall: 1.0,
fragFlutter: 1.0,
fragUpdraft: 1.0,
flameDetail: 8.0,
flameWisp: 0.61,
flameStretch: 1.4,
flameTongue: 1.7,
flameSharp: 1.0,
exposure: 0.72,
vignette: 0,
};
/** Vertices per side of the sheet mesh. */
const GRID = 192;
const PARTICLE_COUNT = 32768;
/** Bytes per `Particle` — must match `PARTICLE_STRUCT`. */
const PARTICLE_STRIDE = 32;
/** Cells in the occupancy grid. */
const OCC_CELLS = OCC_SIZE * OCC_SIZE;
/** OCC_SIZE is a power of two, so the flood fill splits an index with shifts. */
const OCC_SHIFT = Math.log2(OCC_SIZE);
const OCC_MASK = OCC_SIZE - 1;
/** Owner-grid sentinel for "burnt away entirely; nobody draws this". */
const OWNER_DEAD = 0xffffffff;
/*
* Labels the island search puts on each occupancy cell.
*
* BLOCKED covers burnt cells and cells some scrap already owns; the two floods
* only ever advance into FREE, so nothing else needs testing in their inner
* loop.
*/
const CELL_FREE = 0;
const CELL_ATTACHED = 1;
const CELL_ISLAND = 2;
const CELL_BLOCKED = 3;
/** Frames between occupancy read-backs. Detaching a scrap a few frames late is
* invisible, and this keeps the 256 KB transfer well off the critical path. */
const OCC_INTERVAL = 6;
/** Islands smaller than this are simply ash, not a scrap worth simulating. */
const FRAG_MIN_CELLS = 4;
/** Islands larger than this stay attached. ~9% of the sheet: big enough that a
* torn-off corner falls, small enough that the sheet itself never drops as one
* flat slab when the held top strip finally burns through. */
const FRAG_MAX_CELLS = 6000;
const MAX_FRAGS = 48;
/**
* How far in front of the sheet a scrap sits, in world z. The camera is
* orthographic and flat-on, so z only decides what covers what.
*/
const FRAG_Z_LIFT = 0.012;
/*
* Float offsets of every field the CPU writes into a uniform or storage buffer,
* one table per WGSL struct. WGSL pads each vec3 out to four floats and aligns
* the struct as a whole to 16 bytes, which is where the gaps below — and the
* round-up in each *_FLOATS size — come from. Adding a field to one of these
* structs means adding it here, and nowhere else on the CPU side.
*/
/** `Scene`, in SCENE_BINDINGS. */
const SCENE = {
viewProj: 0,
model: 16,
camRight: 32,
time: 35,
camUp: 36,
emissive: 39,
camPos: 40,
curlStrength: 43,
paperSize: 44,
riseAmount: 46,
curlScale: 47,
charDarkness: 48,
flicker: 49,
charSpread: 50,
deckleDepth: 51,
deckleScale: 52,
inkAmount: 53,
cornerRadius: 54,
} as const;
const SCENE_FLOATS = 56;
/** `SimU`, in SIM_WGSL — one of these per solver substep. */
const BURN_U = {
seed: 0,
seedRadius: 2,
seedActive: 3,
seedB: 4,
dt: 6,
time: 7,
speed: 8,
noiseScale: 9,
noiseContrast: 10,
aspect: 11,
reset: 12,
edgeBias: 13,
} as const;
const BURN_U_FLOATS = 16;
/** `SimU`, in PARTICLE_SIM_WGSL. */
const PART_SIM_U = {
model: 0,
paperSize: 16,
dt: 18,
time: 19,
buoyancy: 20,
turbulence: 21,
turbScale: 22,
drag: 23,
emitChance: 24,
reset: 25,
spawnSpeed: 26,
/** Written through a Uint32Array view over the same buffer. */
frame: 27,
} as const;
const PART_SIM_U_FLOATS = 28;
/** `RenderU`, in PARTICLE_RENDER_WGSL. */
const PART_U = {
viewProj: 0,
camRight: 16,
fireSize: 19,
camUp: 20,
smokeSize: 23,
time: 24,
fireIntensity: 25,
smokeOpacity: 26,
flameDetail: 27,
flameWisp: 28,
flameStretch: 29,
flameTongue: 30,
flameSharp: 31,
} as const;
const PART_U_FLOATS = 32;
/** `Frag`, in FRAG_STRUCT — one per scrap, `FRAG_FLOATS` apart. */
const FRAG = {
uvMin: 0,
uvMax: 2,
pos: 4,
id: 7,
pivot: 8,
alpha: 11,
rot: 12,
} as const;
const FRAG_FLOATS = 16;
interface Fragment {
id: number;
/** Owner-grid cell indices this scrap took with it. */
cells: Int32Array;
uvMin: [number, number];
uvMax: [number, number];
/** Centroid in sheet-local space; the scrap rotates about this. */
pivot: Vec3;
/** World position of the pivot at the moment it detached. */
spawn: Vec3;
pos: Vec3;
age: number;
fallY: number;
vy: number;
fallSpeed: number;
updraft: number;
swayAmp: number;
swayFreq: number;
swayPhase: number;
/** sin() of the sway phase at birth; subtracted so the sway starts at zero. */
swayBase: number;
spin: number;
rollAmp: number;
alpha: number;
/** Set once the scrap is on its way out; alpha ramps down and it is culled. */
retiring: boolean;
}
const HDR_FORMAT: GPUTextureFormat = "rgba16float";
/**
* Straight alpha, for the sheet and the scraps that break off it. Both surface
* shaders return coverage, which feathers the burnt-away edge instead of
* leaving a hard stencil cut.
*/
const SURFACE_BLEND: GPUBlendState = {
color: {
srcFactor: "src-alpha",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
};
/** Premultiplied, for the particles: fire writes alpha 0 (additive), smoke blends. */
const PARTICLE_BLEND: GPUBlendState = {
color: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
};
const PAPER_W = 1.0;
const PAPER_H = 1.38;
/**
* How much world height fills the canvas. The sheet is a rectangle in the
* picture plane and never foreshortens, so this is the only thing that sets
* how big it is drawn.
*/
const VIEW_H = 1.8;
/** Half the world width that must stay on screen: the sheet plus a margin. */
const VIEW_HALF_W_MIN = PAPER_W * 0.5 + 0.12;
/** Camera distance along +Z. Only sets the depth range; nothing scales with it. */
const CAM_Z = 2.35;
/** Most ignition points a single `igniteRandom()` will light at once. */
const MAX_IGNITIONS = 5;
/**
* Ceiling on the extra solver substeps run to drain a queued ignition pattern.
* At 24 even the perimeter ring is alight in three frames.
*/
const SEED_STEPS_MAX = 24;
/**
* `n` random ignition points, spread out over the sheet.
*
* Two seeds landing within a third of the sheet of each other merge into one
* front within a second or so, so each point is rejection-sampled against the
* ones already chosen; if no candidate clears the separation the furthest one
* seen is taken.
*/
function randomIgnitionUVs(n: number): [number, number][] {
const minSep = 0.34;
const out: [number, number][] = [];
/** World distance from `p` to the nearest point already chosen. */
const separation = ([u, v]: [number, number]) => {
let sep = Infinity;
for (const [pu, pv] of out) {
sep = Math.min(sep, Math.hypot((u - pu) * PAPER_W, (v - pv) * PAPER_H));
}
return sep;
};
for (let i = 0; i < n; i++) {
let best = randomEdgeUV();
let bestSep = separation(best);
for (let tries = 1; tries < 24 && bestSep < minSep; tries++) {
const cand = randomEdgeUV();
const sep = separation(cand);
if (sep > bestSep) {
best = cand;
bestSep = sep;
}
}
out.push(best);
}
return out;
}
/**
* One ignition: everything within the ignition radius of the segment a..b
* catches. A spot is the degenerate case with `b` omitted.
*/
interface Seed {
a: [number, number];
b: [number, number];
}
const spot = (u: number, v: number): Seed => ({ a: [u, v], b: [u, v] });
const seg = (a: [number, number], b: [number, number]): Seed => ({ a, b });
/** The set ignition shapes. */
type IgnitionPattern =
| "centre"
| "corners"
| "perimeter"
| "topEdge"
| "bottomEdge"
| "crossfire"
| "scatter"
| "random";
/**
* The seeds for a pattern.
*
* The edge patterns are SEGMENTS, one per edge, not rows of spots — a row of
* spots starts life as a row of holes and only becomes an edge once they have
* eaten into one another. Edge seeds sit ON the border, at UV 0 and 1 exactly,
* so the front never has to crawl outwards to reach it.
*/
function patternSeeds(pattern: IgnitionPattern): Seed[] {
const tl: [number, number] = [0, 0];
const tr: [number, number] = [1, 0];
const bl: [number, number] = [0, 1];
const br: [number, number] = [1, 1];
switch (pattern) {
case "centre":
return [spot(0.5, 0.5)];
case "corners":
// An L of two short segments per corner rather than a disc: paper caught
// at a corner burns back along both edges.
return [tl, tr, bl, br].flatMap(([u, v]) => {
const du = u < 0.5 ? 0.13 : -0.13;
const dv = ((v < 0.5 ? 0.13 : -0.13) / PAPER_H) * PAPER_W;
return [seg([u, v], [u + du, v]), seg([u, v], [u, v + dv])];
});
case "perimeter":
return [seg(tl, tr), seg(tr, br), seg(br, bl), seg(bl, tl)];
case "topEdge":
return [seg(tl, tr)];
case "bottomEdge":
return [seg(bl, br)];
case "crossfire":
return [seg(tl, bl), seg(tr, br)];
case "scatter": {
// Jittered grid rather than uniform random: independent points clump, and
// a clump of seeds is one hole.
const out: Seed[] = [];
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 3; x++) {
out.push(
spot(
(x + 0.25 + Math.random() * 0.5) / 3,
(y + 0.25 + Math.random() * 0.5) / 4,
),
);
}
}
return out;
}
default:
return randomIgnitionUVs(3).map(([u, v]) => spot(u, v));
}
}
/**
* A UV somewhere on the perimeter of a rectangle inset from the sheet's edge.
* The inset is cubed, so most points land right against the border and a few
* well inside it.
*/
function randomEdgeUV(): [number, number] {
// Floored at the tear depth: the outermost band of the rectangle is torn away
// and isn't drawn, so a seed placed there would appear to light nothing.
const inset =
DEFAULT_PAPER_PARAMS.deckleDepth +
Math.min(PAPER_W, PAPER_H) * 0.5 * Math.random() ** 3;
const w = PAPER_W - 2 * inset;
const h = PAPER_H - 2 * inset;
const uv = (x: number, y: number): [number, number] => [
x / PAPER_W,
y / PAPER_H,
];
// Walk that perimeter from the top-left corner, taking off each edge's length
// as it is ruled out: top, right, bottom, left.
let t = Math.random() * 2 * (w + h);
if (t < w) return uv(inset + t, inset);
t -= w;
if (t < h) return uv(PAPER_W - inset, inset + t);
t -= h;
if (t < w) return uv(PAPER_W - inset - t, PAPER_H - inset);
t -= w;
return uv(inset, PAPER_H - inset - t);
}
interface PaperEngineOptions {
/**
* How much world height fills the canvas, which is the only thing that sets
* how big the sheet is drawn. Defaults to `VIEW_H`; a larger value frames
* MORE world and so draws the sheet smaller, with more room around it.
*
* On a phone the canvas is far taller than it is wide, so the narrow-window
* guard below wins and this ends up setting the sheet's WIDTH on screen —
* the sheet spans `1 / (2 * VIEW_HALF_W_MIN * viewHeight / VIEW_H)` of it.
*/
viewHeight?: number;
}
class BurningPaperEngine {
readonly params: PaperParams = { ...DEFAULT_PAPER_PARAMS };
onStats: ((fps: number) => void) | null = null;
/**
* How much of the ink layer (see `setInk`) is on the sheet: 1 fully printed,
* 0 bare paper. Animating it fades type on and off the page without touching
* the burn.
*/
inkAmount = 1;
/**
* Fraction of the sheet still holding unburnt paper that is still attached,
* from the last occupancy read-back — so it lags by a few frames and only
* moves while a burn is running. 1 on a fresh sheet, ~0 once it is spent.
*/
paperLeft = 1;
private context: RNCanvasContext;
private device: GPUDevice;
private format: GPUTextureFormat;
// Simulation
private simTex: GPUTexture[] = [];
private simView: GPUTextureView[] = [];
private simPipeline!: GPUComputePipeline;
/** `simBind[parity][substep]` — the substep picks the uniform slot. */
private simBind: GPUBindGroup[][] = [];
private simUniform!: GPUBuffer;
/**
* One frame's substep uniforms, packed at `simSlotFloats` apart, so every
* substep of a frame can be written in one go and then bound individually.
*/
private simData!: Float32Array;
private simSlotFloats = BURN_U_FLOATS;
private simIndex = 0;
// Blur pyramid of the burn mask, rebuilt every frame. The surface shading
// reads its discolouration from this instead of tapping the raw mask.
private smearTex!: GPUTexture;
private smearAll!: GPUTextureView;
private smearSampler!: GPUSampler;
private smearSeedPipeline!: GPUComputePipeline;
private smearDownPipeline!: GPUComputePipeline;
/** Seed bind group per sim ping-pong slot. */
private smearSeedBind: GPUBindGroup[] = [];
/** One per halving step: level k-1 in, level k out. */
private smearDownBind: GPUBindGroup[] = [];
// Paper surface
private paperPipeline!: GPURenderPipeline;
private paperBind: GPUBindGroup[] = [];
private sceneUniform!: GPUBuffer;
private sceneData = new Float32Array(SCENE_FLOATS);
private gridVerts!: GPUBuffer;
private gridIndices!: GPUBuffer;
private indexCount = 0;
private paperTex!: GPUTexture;
private sampler!: GPUSampler;
/** What is printed on the sheet. 1x1 white — bare paper — until `setInk`. */
private inkTex!: GPUTexture;
// Particles
private particleBuffer!: GPUBuffer;
private particleSimPipeline!: GPUComputePipeline;
private particleSimBind: GPUBindGroup[] = [];
private particleSimUniform!: GPUBuffer;
private particleSimData = new Float32Array(PART_SIM_U_FLOATS);
private particleSimU32 = new Uint32Array(this.particleSimData.buffer);
private particlePipeline!: GPURenderPipeline;
private particleBind!: GPUBindGroup;
private particleUniform!: GPUBuffer;
private particleData = new Float32Array(PART_U_FLOATS);
// Orphaned scraps: occupancy read-back, island detection, falling fragments
private occPipeline!: GPUComputePipeline;
private occBind: GPUBindGroup[] = [];
private occBuffer!: GPUBuffer;
private occRead!: GPUBuffer;
private occReadPending = false;
/** Bumped on every reset; an occupancy read-back from an older era is stale. */
private sheetEra = 0;
private ownerTex!: GPUTexture;
private ownerView!: GPUTextureView;
/** 0 = still part of the sheet, else a fragment id or OWNER_DEAD. */
private owner = new Uint32Array(OCC_CELLS);
private ownerDirty = false;
private labelScratch = new Int32Array(OCC_CELLS);
private stackScratch = new Int32Array(OCC_CELLS);
private componentScratch = new Int32Array(OCC_CELLS);
private frags: Fragment[] = [];
private nextFragId = 1;
private fragPipeline!: GPURenderPipeline;
private fragBind: GPUBindGroup[] = [];
private fragBuffer!: GPUBuffer;
private fragData = new Float32Array(MAX_FRAGS * FRAG_FLOATS);
private fragCount = 0;
// Post
private compositePipeline!: GPURenderPipeline;
/** Reused staging for the 4-float post uniform — see `render`. */
private postScratch = new Float32Array(4);
private postUniform!: GPUBuffer;
private hdrTex: GPUTexture | null = null;
private depthTex: GPUTexture | null = null;
// Views of the two above, taken once. `createView` allocates and validates,
// and on this runtime it also hands a native handle across JSI — calling it
// per frame for a texture that never changes leaks handles until the process
// runs out. The swapchain's own view is the exception: that texture is a
// different one each frame.
private hdrView: GPUTextureView | null = null;
private depthView: GPUTextureView | null = null;
private compositeBind: GPUBindGroup | null = null;
// Camera / view state. The sheet has no transform of its own and the camera
// looks straight down -Z, so `model` stays the identity and camRight/camUp
// are simply screen X and Y — they are still uploaded because the shaders
// billboard and project through them.
private model = mat4();
private view = mat4();
private proj = mat4();
private viewProj = mat4();
private eye: Vec3 = [0, 0, CAM_Z];
private readonly camRight: Vec3 = [1, 0, 0];
private readonly camUp: Vec3 = [0, 1, 0];
/** World half-extents of the visible area, in world units. */
private halfW = 1;
private halfH = VIEW_H / 2;
/** This scene's framing — see `PaperEngineOptions.viewHeight`. */
private viewH = VIEW_H;
private viewHalfWMin = VIEW_HALF_W_MIN;
// Frame state
private width = 1;
private height = 1;
private cssW = 1;
private cssH = 1;
private raf = 0;
private frame = 0;
private lastT = 0;
private time = 0;
private disposed = false;
private resetPending = false;
// The solver carries one seed per substep, so a multi-seed ignition queues
// them here and drains one per substep until empty.
private seedQueue: Seed[] = [];
private fpsAcc = 0;
private fpsN = 0;
private fpsLast = 0;
/**
* `performance.now()` counts from somewhere near device boot, so it is
* already a large number at launch. The flame shaders feed `time` straight
* into a noise coordinate, and on Metal a coordinate that large blows past
* fp32 precision — `v - floor(v)` collapses into steps and the fire
* quantizes into blocks. Rebasing to engine start keeps it small; it is a
* pure phase shift, so nothing about the look changes.
*/
private timeOrigin = performance.now();
static async create(
context: RNCanvasContext,
opts: PaperEngineOptions = {},
): Promise<BurningPaperEngine> {
// Android-emulator guard: launches sometimes land on the SwiftShader (CPU)
// Vulkan adapter, and Dawn ABORTS the whole process inside requestDevice()
// on it. The adapter roll is per-request, so retry, and fail with a
// readable message rather than a native crash.
let adapter: GPUAdapter | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
const candidate = await navigator.gpu.requestAdapter({
powerPreference: "high-performance",
});
if (!candidate)
throw new Error("WebGPU is not supported on this device (no adapter)");
const info = (
candidate as {
info?: {
vendor?: string;
description?: string;
architecture?: string;
};
}
).info;
const desc = info
? `${info.vendor ?? ""} ${info.architecture ?? ""} ${info.description ?? ""}`.toLowerCase()
: "";
if (!desc.includes("swiftshader")) {
adapter = candidate;
break;
}
await new Promise((r) => setTimeout(r, 600));
}
if (!adapter) {
throw new Error(
"Only the SwiftShader (software) Vulkan adapter is available — creating a " +
"device on it crashes Dawn on the Android emulator. Tap Retry or reopen " +
"the app; if it keeps happening, reboot the emulator (adb reboot). " +
"Real devices are unaffected.",
);
}
const device = await adapter.requestDevice();
return new BurningPaperEngine(context, device, opts);
}
private constructor(
context: RNCanvasContext,
device: GPUDevice,
opts: PaperEngineOptions,
) {
this.context = context;
this.device = device;
this.format = navigator.gpu.getPreferredCanvasFormat();
// Validation errors do not throw — a bad pipeline or bind group silently
// turns its draw into a no-op, and the symptom is a black frame with
// nothing in the log. Worth the two lines.
device.onuncapturederror = (e) =>
console.error("[BurningPaper] WebGPU:", e.error.message);
device.lost.then((info) => {
if (!this.disposed)
console.error("[BurningPaper] device lost:", info.message);
});
this.viewH = opts.viewHeight ?? VIEW_H;
// The narrow-window guard scales with the framing. Left at its constant, a
// sheet framed small would spring back to full size the moment the canvas
// got narrow enough for that term to win.
this.viewHalfWMin = VIEW_HALF_W_MIN * (this.viewH / VIEW_H);
const canvas = context.canvas as HTMLCanvasElement;
const dpr = Math.min(PixelRatio.get(), 2);
this.cssW = Math.max(1, Math.round(canvas.clientWidth));
this.cssH = Math.max(1, Math.round(canvas.clientHeight));
this.width = Math.max(1, Math.round(this.cssW * dpr));
this.height = Math.max(1, Math.round(this.cssH * dpr));
canvas.width = this.width;
canvas.height = this.height;
context.configure({ device, format: this.format, alphaMode: "opaque" });
this.createSim();
// Owns `ownerTex`, which the paper bind group needs — must come first.
this.createFragments();
// Owns `smearTex`, which the paper bind group also needs.
this.createSmear();
this.createPaper();
this.createParticles();
this.createPost();
this.createTargets();
this.lastT = performance.now() - this.timeOrigin;
this.fpsLast = this.lastT;
this.raf = requestAnimationFrame(this.loop);
}
/* ---------------------------------------------------------------- */
/* Setup */
/* ---------------------------------------------------------------- */
private createSim() {
const d = this.device;
for (let i = 0; i < 2; i++) {
const tex = d.createTexture({
size: [MASK_SIZE, MASK_SIZE],
format: HDR_FORMAT,
usage:
GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING,
});
this.simTex.push(tex);
this.simView.push(tex.createView());
}
// A slot per substep, spaced to the alignment a bound uniform range needs.
// The whole frame's worth is written with one `writeBuffer`, and each
// substep binds its own slice — which is what lets every substep share a
// single command buffer instead of needing one submit each.
this.simSlotFloats = Math.max(
BURN_U_FLOATS,
d.limits.minUniformBufferOffsetAlignment / 4,
);
this.simData = new Float32Array(SEED_STEPS_MAX * this.simSlotFloats);
this.simUniform = d.createBuffer({
size: this.simData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const module = d.createShaderModule({ code: SIM_WGSL, label: "burn-sim" });
this.simPipeline = d.createComputePipeline({
layout: "auto",
compute: { module, entryPoint: "main" },
});
const layout = this.simPipeline.getBindGroupLayout(0);
for (let i = 0; i < 2; i++) {
const perStep: GPUBindGroup[] = [];
for (let s = 0; s < SEED_STEPS_MAX; s++) {
perStep.push(
d.createBindGroup({
layout,
entries: [
{
binding: 0,
resource: {
buffer: this.simUniform,
offset: s * this.simSlotFloats * 4,
size: BURN_U_FLOATS * 4,
},
},
{ binding: 1, resource: this.simView[i] },
{ binding: 2, resource: this.simView[1 - i] },
],
}),
);
}
this.simBind.push(perStep);
}
}
/**
* The sheet's mesh: a GRID x GRID grid of UVs, indexed into triangles. The
* vertex shader turns each UV into a world position, so nothing here knows
* how big the paper is or where it sits.
*/
private createSheetMesh() {
const d = this.device;
const side = GRID + 1;
const uvs = new Float32Array(side * side * 2);
for (let y = 0; y < side; y++) {
for (let x = 0; x < side; x++) {
const i = (y * side + x) * 2;
uvs[i] = x / GRID;
uvs[i + 1] = y / GRID;
}
}
this.gridVerts = d.createBuffer({
size: uvs.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
d.queue.writeBuffer(this.gridVerts, 0, uvs);
const indices = new Uint32Array(GRID * GRID * 6);
let k = 0;
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
const topLeft = y * side + x;
const topRight = topLeft + 1;
const botLeft = topLeft + side;
const botRight = botLeft + 1;
indices[k++] = topLeft;
indices[k++] = botLeft;
indices[k++] = topRight;
indices[k++] = topRight;
indices[k++] = botLeft;
indices[k++] = botRight;
}
}
this.indexCount = indices.length;
this.gridIndices = d.createBuffer({
size: indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
d.queue.writeBuffer(this.gridIndices, 0, indices);
}
private createPaper() {
const d = this.device;
this.createSheetMesh();
const { data, size } = generatePaperTexture();
this.paperTex = d.createTexture({
size: [size, size],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
d.queue.writeTexture(
{ texture: this.paperTex },
data,
{ bytesPerRow: size * 4, rowsPerImage: size },
[size, size],
);
this.sampler = d.createSampler({
magFilter: "linear",
minFilter: "linear",
addressModeU: "repeat",
addressModeV: "repeat",
});
// One white texel: the identity for the multiply the surface shader does,
// so a sheet nobody has printed on shades exactly as it did before the ink
// layer existed.
this.inkTex = this.createInkTexture(1, 1);
d.queue.writeTexture(
{ texture: this.inkTex },
new Uint8Array([255, 255, 255, 255]),
{ bytesPerRow: 4 },
[1, 1],
);
this.sceneUniform = d.createBuffer({
size: this.sceneData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const module = d.createShaderModule({ code: PAPER_WGSL, label: "paper" });
this.paperPipeline = d.createRenderPipeline({
label: "paper",
layout: "auto",
vertex: {
module,
entryPoint: "vs",
buffers: [
{
arrayStride: 8,
attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }],
},
],
},
fragment: {
module,
entryPoint: "fs",
targets: [{ format: HDR_FORMAT, blend: SURFACE_BLEND }],
},
primitive: { topology: "triangle-list", cullMode: "none" },
depthStencil: {
format: "depth24plus",
depthWriteEnabled: true,
depthCompare: "less",
},
});
// The fragment pipeline shares the sheet's bindings 0-4 and adds the
// per-scrap storage buffer, so it has to be built after `createPaper`.
const fragModule = d.createShaderModule({
code: FRAGMENT_WGSL,
label: "paper-fragments",
});
this.fragPipeline = d.createRenderPipeline({
label: "paper-fragments",
layout: "auto",
vertex: { module: fragModule, entryPoint: "vs" },
fragment: {
module: fragModule,
entryPoint: "fs",
targets: [{ format: HDR_FORMAT, blend: SURFACE_BLEND }],
},
primitive: { topology: "triangle-list", cullMode: "none" },
depthStencil: {
format: "depth24plus",
depthWriteEnabled: true,
depthCompare: "less",
},
});
this.buildSurfaceBinds();
}
/**
* The sheet's and the scraps' bind groups, one per sim ping-pong slot.
*
* Rebuilt rather than patched whenever the ink texture is replaced — a bind
* group holds its views for good, so a new texture needs new groups. Only
* `setInk` does that, and only when the printed page changes size.
*/
private buildSurfaceBinds() {
const d = this.device;
const paperView = this.paperTex.createView();
const inkView = this.inkTex.createView();
this.paperBind = [];
this.fragBind = [];
for (let i = 0; i < 2; i++) {
const shared: GPUBindGroupEntry[] = [
{ binding: 0, resource: { buffer: this.sceneUniform } },
{ binding: 1, resource: this.sampler },
{ binding: 2, resource: this.simView[i] },
{ binding: 3, resource: paperView },
{ binding: 4, resource: this.ownerView },
{ binding: 6, resource: this.smearAll },
{ binding: 7, resource: this.smearSampler },
{ binding: 8, resource: inkView },
];
this.paperBind.push(
d.createBindGroup({
layout: this.paperPipeline.getBindGroupLayout(0),
entries: shared,
}),
);
this.fragBind.push(
d.createBindGroup({
layout: this.fragPipeline.getBindGroupLayout(0),
entries: [
...shared,
{ binding: 5, resource: { buffer: this.fragBuffer } },
],
}),
);
}
}
private createInkTexture(width: number, height: number): GPUTexture {
return this.device.createTexture({
label: "ink",
size: [width, height],
// sRGB, so a colour picked the way a stylesheet picks one lands where it
// is expected once the composite pass has re-encoded the frame.
format: "rgba8unorm-srgb",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
}
/**
* Builds the blur pyramid the discolouration is read from: one texture with a
* full mip chain, a seed pass that boxes the burn mask down into level 0, and
* a halving pass run once per level after that.
*/
private createSmear() {
const d = this.device;
this.smearTex = d.createTexture({
size: [SMEAR_W, SMEAR_H],
mipLevelCount: SMEAR_LEVELS,
format: HDR_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING,
});
this.smearAll = this.smearTex.createView();
// Clamped, not repeating: at a coarse level a wrapped tap would fold the
// far side of the sheet into the near border.
this.smearSampler = d.createSampler({
magFilter: "linear",
minFilter: "linear",
mipmapFilter: "linear",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
const seed = d.createShaderModule({
code: SMEAR_SEED_WGSL,
label: "smear-seed",
});
const down = d.createShaderModule({
code: SMEAR_DOWN_WGSL,
label: "smear-down",
});
this.smearSeedPipeline = d.createComputePipeline({
layout: "auto",
compute: { module: seed, entryPoint: "main" },
});
this.smearDownPipeline = d.createComputePipeline({
layout: "auto",
compute: { module: down, entryPoint: "main" },
});
const level = (i: number) =>
this.smearTex.createView({
baseMipLevel: i,
mipLevelCount: 1,
});
for (let i = 0; i < 2; i++) {
this.smearSeedBind.push(
d.createBindGroup({
layout: this.smearSeedPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this.simView[i] },
{ binding: 1, resource: this.smearSampler },
{ binding: 2, resource: level(0) },
],
}),
);
}
for (let i = 1; i < SMEAR_LEVELS; i++) {
this.smearDownBind.push(
d.createBindGroup({
layout: this.smearDownPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: level(i - 1) },
{ binding: 1, resource: this.smearSampler },
{ binding: 2, resource: level(i) },
],
}),
);
}
}
/** Level `i` of the pyramid, in texels — mip sizes floor-halve. */
private smearSize(i: number): [number, number] {
return [Math.max(1, SMEAR_W >> i), Math.max(1, SMEAR_H >> i)];
}
/**
* Sets up island detection: a compute pass that downsamples the burn mask into
* a coarse occupancy grid, a buffer pair to read that back to the CPU, and the
* owner texture both render passes consult to decide who draws which cell.
*/
private createFragments() {
const d = this.device;
this.occBuffer = d.createBuffer({
size: OCC_CELLS * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
this.occRead = d.createBuffer({
size: OCC_CELLS * 4,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
this.ownerTex = d.createTexture({
size: [OCC_SIZE, OCC_SIZE],
format: "r32uint",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
this.ownerView = this.ownerTex.createView();
this.uploadOwner();
this.fragBuffer = d.createBuffer({
size: this.fragData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const module = d.createShaderModule({
code: OCCUPANCY_WGSL,
label: "occupancy",
});
this.occPipeline = d.createComputePipeline({
layout: "auto",
compute: { module, entryPoint: "main" },
});
for (let i = 0; i < 2; i++) {
this.occBind.push(
d.createBindGroup({
layout: this.occPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this.simView[i] },
{ binding: 1, resource: { buffer: this.occBuffer } },
],
}),
);
}
}
private uploadOwner() {
this.device.queue.writeTexture(
{ texture: this.ownerTex },
this.owner,
{ bytesPerRow: OCC_SIZE * 4, rowsPerImage: OCC_SIZE },
[OCC_SIZE, OCC_SIZE],
);
this.ownerDirty = false;
}
private createParticles() {
const d = this.device;
this.particleBuffer = d.createBuffer({
size: PARTICLE_COUNT * PARTICLE_STRIDE,
usage: GPUBufferUsage.STORAGE,
});
this.particleSimUniform = d.createBuffer({
size: this.particleSimData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.particleUniform = d.createBuffer({
size: this.particleData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const simModule = d.createShaderModule({
code: PARTICLE_SIM_WGSL,
label: "particle-sim",
});
const drawModule = d.createShaderModule({
code: PARTICLE_RENDER_WGSL,
label: "particle-draw",
});
this.particleSimPipeline = d.createComputePipeline({
layout: "auto",
compute: { module: simModule, entryPoint: "updateParticles" },
});
for (let i = 0; i < 2; i++) {
this.particleSimBind.push(
d.createBindGroup({
layout: this.particleSimPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.particleSimUniform } },
{ binding: 1, resource: { buffer: this.particleBuffer } },
{ binding: 2, resource: this.simView[i] },
// The sim needs to know which cells a scrap has taken with it, so a
// flame is never left burning at the UV a fallen fragment came from.
{ binding: 3, resource: this.ownerView },
],
}),
);
}
this.particlePipeline = d.createRenderPipeline({
layout: "auto",
vertex: { module: drawModule, entryPoint: "particleVs" },
fragment: {
module: drawModule,
entryPoint: "particleFs",
targets: [{ format: HDR_FORMAT, blend: PARTICLE_BLEND }],
},
primitive: { topology: "triangle-list", cullMode: "none" },
depthStencil: {
format: "depth24plus",
depthWriteEnabled: false,
depthCompare: "less",
},
});
this.particleBind = d.createBindGroup({
layout: this.particlePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.particleUniform } },
{ binding: 1, resource: { buffer: this.particleBuffer } },
],
});
}
private createPost() {
const d = this.device;
this.postUniform = d.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const comp = d.createShaderModule({
code: COMPOSITE_WGSL,
label: "composite",
});
this.compositePipeline = d.createRenderPipeline({
layout: "auto",
vertex: { module: comp, entryPoint: "vs" },
fragment: {
module: comp,
entryPoint: "fs",
targets: [{ format: this.format }],
},
primitive: { topology: "triangle-list" },
});
}
/* ---------------------------------------------------------------- */
/* Sizing */
/* ---------------------------------------------------------------- */
/**
* Allocates the offscreen targets. Called once: the drawing buffer is fixed
* for the engine's lifetime, and a rotation remounts the Canvas and builds a
* fresh engine rather than resizing this one in place.
*/
private createTargets() {
const d = this.device;
const w = this.width;
const h = this.height;
this.updateCamera();
this.hdrTex = d.createTexture({
size: [w, h],
format: HDR_FORMAT,
usage:
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
this.depthTex = d.createTexture({
size: [w, h],
format: "depth24plus",
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
this.hdrView = this.hdrTex.createView();
this.depthView = this.depthTex.createView();
this.compositeBind = d.createBindGroup({
layout: this.compositePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.postUniform } },
{ binding: 1, resource: this.sampler },
{ binding: 2, resource: this.hdrView },
],
});
}
/* ---------------------------------------------------------------- */
/* Camera + picking */
/* ---------------------------------------------------------------- */
private updateCamera() {
const aspect = this.width / this.height;
// Fit height first, and only widen the framing when the canvas is too
// narrow to hold the sheet at that height — otherwise a portrait screen,
// which is every phone, crops it.
this.halfH = Math.max(
this.viewH / 2,
this.viewHalfWMin / Math.max(aspect, 1e-4),
);
this.halfW = this.halfH * aspect;
lookAt(this.view, this.eye, [0, 0, 0], [0, 1, 0]);
orthographic(this.proj, this.halfW, this.halfH, 0.05, 50);
multiply(this.viewProj, this.proj, this.view);
}
/**
* The sheet UV under a canvas-space point (css px, origin at the canvas's
* top-left), or null if it is off the sheet.
*
* Flat-on and orthographic, so this is a straight linear map — no ray cast.
*/
pickUV(x: number, y: number): [number, number] | null {
const ndcX = (x / this.cssW) * 2 - 1;
const ndcY = 1 - (y / this.cssH) * 2;
const u = (ndcX * this.halfW) / PAPER_W + 0.5;
const v = 0.5 - (ndcY * this.halfH) / PAPER_H;
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
return [u, v];
}
/* ---------------------------------------------------------------- */
/* Orphaned scraps */
/* ---------------------------------------------------------------- */
/**
* Finds the pieces of sheet that the burn has cut loose and hands them to the
* fragment system.
*
* The sheet is held along its top edge, so "still attached" means "reachable
* from the top row through unburnt cells".
*/
private detectIslands(occ: Uint32Array) {
const label = this.labelScratch;
const comp = this.componentScratch;
const owner = this.owner;
// A scrap that has burnt away entirely stops showing up as occupied, which
// is how we know to retire it. Asked of each scrap's own cell list rather
// than by sweeping the whole owner grid.
for (const f of this.frags) {
let alive = false;
for (const c of f.cells) {
if (owner[c] === f.id && occ[c] !== 0) {
alive = true;
break;
}
}
if (!alive) f.retiring = true;
}
// Anything not FREE starts out BLOCKED, so the floods below need no test
// beyond the label. The FREE cells are also exactly the sheet's remaining
// paper, so the count rides along here.
let free = 0;
for (let i = 0; i < OCC_CELLS; i++) {
const isFree = occ[i] !== 0 && owner[i] === 0;
label[i] = isFree ? CELL_FREE : CELL_BLOCKED;
if (isFree) free++;
}
this.paperLeft = free / OCC_CELLS;
for (let x = 0; x < OCC_SIZE; x++) this.flood(x, CELL_ATTACHED);
// Whatever is still FREE could not be reached from the top row, so it is an
// island: it has come loose from the sheet.
for (let start = 0; start < OCC_CELLS; start++) {
if (label[start] !== CELL_FREE) continue;
const n = this.flood(start, CELL_ISLAND);
if (n < FRAG_MIN_CELLS) {
// Crumbs this small are ash, not paper.
for (let k = 0; k < n; k++) owner[comp[k]] = OWNER_DEAD;
this.ownerDirty = true;
continue;
}
// Too big to be a scrap, or no room left: leave it attached.
if (n > FRAG_MAX_CELLS || this.frags.length >= MAX_FRAGS) continue;
let minX = OCC_SIZE,
minY = OCC_SIZE,
maxX = -1,
maxY = -1;
let sumX = 0,
sumY = 0;
for (let k = 0; k < n; k++) {
const x = comp[k] & OCC_MASK;
const y = comp[k] >> OCC_SHIFT;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
sumX += x;
sumY += y;
}
this.spawnFragment(comp, n, minX, minY, maxX, maxY, sumX / n, sumY / n);
}
}
/**
* Floods out from `start` over cells still labelled `CELL_FREE`, relabelling
* each one `mark`. Returns how many it took; those cells land in
* `componentScratch`.
*
* 8-connected on purpose: a diagonal touch counts as joined, which errs
* toward detaching a scrap late rather than tearing off peninsulas that this
* grid's coarseness merely separated.
*/
private flood(start: number, mark: number): number {
const label = this.labelScratch;
const stack = this.stackScratch;
const comp = this.componentScratch;
if (label[start] !== CELL_FREE) return 0;
let n = 0;
let sp = 0;
label[start] = mark;
stack[sp++] = start;
while (sp > 0) {
const i = stack[--sp];
comp[n++] = i;
const x = i & OCC_MASK;
const y = i >> OCC_SHIFT;
const y0 = y > 0 ? -1 : 0;
const y1 = y < OCC_SIZE - 1 ? 1 : 0;
const x0 = x > 0 ? -1 : 0;
const x1 = x < OCC_SIZE - 1 ? 1 : 0;
for (let dy = y0; dy <= y1; dy++) {
const row = (y + dy) << OCC_SHIFT;
for (let dx = x0; dx <= x1; dx++) {
const j = row + x + dx;
if (label[j] !== CELL_FREE) continue;
label[j] = mark;
stack[sp++] = j;
}
}
}
return n;
}
private spawnFragment(
comp: Int32Array,
n: number,
minX: number,
minY: number,
maxX: number,
maxY: number,
cx: number,
cy: number,
) {
const N = OCC_SIZE;
const id = this.nextFragId++;
const cells = comp.slice(0, n);
for (let k = 0; k < n; k++) this.owner[cells[k]] = id;
this.ownerDirty = true;
const cu = (cx + 0.5) / N;
const cv = (cy + 0.5) / N;
const pivot: Vec3 = [(cu - 0.5) * PAPER_W, (0.5 - cv) * PAPER_H, 0];
const spawn = transformPoint(this.model, pivot);
// Lift it clear of the sheet's own plane, and stagger consecutive scraps so
// two overlapping ones layer consistently instead of z-fighting.
spawn[2] += FRAG_Z_LIFT + (id % 8) * 0.0004;
const r = Math.random();
const swayPhase = Math.random() * Math.PI * 2;
this.frags.push({
id,
cells,
uvMin: [minX / N, minY / N],
uvMax: [(maxX + 1) / N, (maxY + 1) / N],
pivot,
spawn,
pos: [spawn[0], spawn[1], spawn[2]],
age: 0,
fallY: 0,
vy: 0,
// Small scraps have a little more drag per unit mass, so they sink slower.
fallSpeed: 0.26 + r * 0.3 + Math.min(n / FRAG_MAX_CELLS, 1) * 0.16,
updraft: 0.7 + Math.random() * 1.1,
swayAmp: 0.025 + Math.random() * 0.075,
swayFreq: 1.5 + Math.random() * 2.4,
swayPhase,
// The scrap must be at rest in the sheet's own pose on its first frame:
// phase variety comes from these bases being subtracted out, not from the
// pose starting somewhere arbitrary.
swayBase: Math.sin(swayPhase),
spin: (Math.random() - 0.5) * 2.6,
rollAmp: 0.45 + Math.random() * 0.95,
alpha: 1,
retiring: false,
});
}
/**
* Integrates the falling scraps and packs them for the GPU.
*
* Paper has a huge drag-to-mass ratio: it reaches terminal velocity almost at
* once and then sinks slowly while rocking side to side and tumbling. Sway is
* a position offset rather than an integrated force so the rocking stays a
* clean pendulum instead of drifting off with accumulated error.
*/
private updateFragments(dt: number) {
const p = this.params;
const data = this.fragData;
let n = 0;
for (let i = this.frags.length - 1; i >= 0; i--) {
const f = this.frags[i];
f.age += dt;
const lift = f.updraft * p.fragUpdraft * Math.exp(-f.age * 2.0);
f.vy += (lift - 1.7) * dt;
const terminal = -f.fallSpeed * p.fragFall;
if (f.vy < terminal) f.vy = terminal;
f.fallY += f.vy * dt;
// Ease everything in from a standstill: a scrap that lets go should drift
// out of the sheet's plane, not snap sideways on its first frame.
const ease = 1 - Math.exp(-f.age * 2.2);
const phase = f.swayFreq * p.fragFlutter * f.age + f.swayPhase;
const amp = f.swayAmp * p.fragFlutter * ease;
const swayX = Math.sin(phase) - f.swayBase;
f.pos[0] = f.spawn[0] + swayX * amp;
f.pos[1] = f.spawn[1] + f.fallY;
// z is fixed: a scrap only moves within the picture plane, and its depth
// is purely the layer it was spawned into.
f.pos[2] = f.spawn[2];
if (f.pos[1] < -2.2) f.retiring = true;
if (f.retiring) f.alpha -= dt * 2.5;
if (f.alpha <= 0) {
for (const c of f.cells) {
if (this.owner[c] === f.id) this.owner[c] = OWNER_DEAD;
}
this.ownerDirty = true;
this.frags.splice(i, 1);
continue;
}
const o = n * FRAG_FLOATS;
data.set(f.uvMin, o + FRAG.uvMin);
data.set(f.uvMax, o + FRAG.uvMax);
data.set(f.pos, o + FRAG.pos);
data.set(f.pivot, o + FRAG.pivot);
data[o + FRAG.id] = f.id;
data[o + FRAG.alpha] = Math.min(f.alpha, 1);
// One angle, in the picture plane: a slow turn plus a rock in step with
// the sway. The turn uses the integral of the same ease, so both the
// angle and the angular velocity start at zero. The other two components
// of rot are unused — see rotateZ.
const spun = f.age - (1 - Math.exp(-f.age * 2.2)) / 2.2;
data[o + FRAG.rot] = f.spin * spun * 0.4 + swayX * ease * f.rollAmp * 0.5;
data[o + FRAG.rot + 1] = 0;
data[o + FRAG.rot + 2] = 0;
n++;
}
this.fragCount = n;
if (n > 0) {
this.device.queue.writeBuffer(
this.fragBuffer,
0,
data,
0,
n * FRAG_FLOATS,
);
}
if (this.ownerDirty) this.uploadOwner();
}
private clearFragments() {
this.frags.length = 0;
this.fragCount = 0;
this.owner.fill(0);
this.uploadOwner();
}
/* ---------------------------------------------------------------- */
/* Public API */
/* ---------------------------------------------------------------- */
/**
* Prints an image onto the sheet, in sheet UV — the source is stretched over
* the whole page, so it should carry the paper's own 1 : 1.38 aspect.
*
* The image is a MULTIPLIER over the paper's albedo, exactly like ink: white
* leaves bare paper, and everything darker takes the sheet's grain, its
* scorching and its char with it as the page burns. Since it never lightens
* anything, the source should be fully opaque.
*
* `rgba` is straight (un-premultiplied) sRGB bytes — what Skia's `readPixels`
* hands back with an `Unpremul` alpha type. Calling it repeatedly at one size
* reuses the texture and re-uploads only the pixels.
*/
setInk(rgba: Uint8Array, width: number, height: number) {
if (this.disposed) return;
if (this.inkTex.width !== width || this.inkTex.height !== height) {
this.inkTex.destroy();
this.inkTex = this.createInkTexture(width, height);
this.buildSurfaceBinds();
}
this.device.queue.writeTexture(
{ texture: this.inkTex },
rgba,
{ bytesPerRow: width * 4, rowsPerImage: height },
[width, height],
);
}
/**
* Re-prints one rectangle of the ink, leaving the rest of the page alone.
*
* `setInk` is cheap for a state change and far too expensive for an
* animation: the whole page has to be rasterized, read back out of Skia and
* copied to the GPU, and the photograph is most of that cost. A control that
* moves under the finger disturbs only its own patch of paper, and re-printing
* that patch is a few hundred KB rather than several MB.
*
* `x`, `y`, `w` and `h` are in ink TEXTURE pixels, matching the last `setInk`.
* A patch that would fall outside the page is ignored rather than clamped: a
* silently shifted upload would print the control in the wrong place.
*/
setInkRegion(rgba: Uint8Array, x: number, y: number, w: number, h: number) {
if (this.disposed || w <= 0 || h <= 0) return;
if (
x < 0 ||
y < 0 ||
x + w > this.inkTex.width ||
y + h > this.inkTex.height
)
return;
this.device.queue.writeTexture(
{ texture: this.inkTex, origin: { x, y } },
rgba,
{ bytesPerRow: w * 4, rowsPerImage: h },
[w, h],
);
}
/** Seed the burn mask at a UV coordinate on the sheet. */
ignite(u: number, v: number) {
this.seedQueue.push(spot(u, v));
}
/**
* Light the sheet at several random spots at once, usually near an edge.
* Defaults to a random 2–`MAX_IGNITIONS` points so no two presses look alike.
*/
igniteRandom(count?: number) {
const n = count ?? 2 + Math.floor(Math.random() * (MAX_IGNITIONS - 1));
const pts = randomIgnitionUVs(
Math.max(1, Math.min(MAX_IGNITIONS, Math.round(n))),
);
this.seedQueue.push(...pts.map(([u, v]) => spot(u, v)));
}
/**
* Clear the sheet and light it in one of the set shapes.
*
* Resetting first is the point of these: half of them (a ring around the
* border, a line along an edge) describe a whole sheet rather than a spot, so
* applying one to a sheet already full of holes would light only whatever
* happened to be left.
*/
ignitePattern(pattern: IgnitionPattern) {
this.reset();
if (pattern === "random") {
this.igniteRandom();
return;
}
this.seedQueue.push(...patternSeeds(pattern));
}
/**
* Where the sheet's rectangle sits inside the canvas, in css px — what an
* overlay needs to line a real touchable up with something printed on the
* page.
*
* The inverse of `pickUV`, and linear for the same reason: the camera is
* orthographic and flat-on, so the sheet is an axis-aligned rectangle on
* screen and a UV maps to `left + u * width`.
*/
sheetBox(): { left: number; top: number; width: number; height: number } {
const width = (this.cssW * PAPER_W) / (2 * this.halfW);
const height = (this.cssH * PAPER_H) / (2 * this.halfH);
return {
left: (this.cssW - width) / 2,
top: (this.cssH - height) / 2,
width,
height,
};
}
/** Clear the burn mask, all particles and any scraps still in the air. */
reset() {
this.resetPending = true;
this.seedQueue.length = 0;
this.sheetEra++;
this.paperLeft = 1;
this.clearFragments();
}
dispose() {
if (this.disposed) return;
this.disposed = true;
cancelAnimationFrame(this.raf);
this.hdrTex?.destroy();
this.depthTex?.destroy();
this.inkTex?.destroy();
this.paperTex?.destroy();
this.ownerTex?.destroy();
this.smearTex?.destroy();
for (const t of this.simTex) t.destroy();
this.simUniform?.destroy();
this.sceneUniform?.destroy();
this.gridVerts?.destroy();
this.gridIndices?.destroy();
this.particleBuffer?.destroy();
this.particleSimUniform?.destroy();
this.particleUniform?.destroy();
this.fragBuffer?.destroy();
this.occBuffer?.destroy();
// Not while a mapAsync is in flight: unmapping is what the resolve does,
// and destroying underneath it rejects the promise on some backends.
if (!this.occReadPending) this.occRead?.destroy();
this.postUniform?.destroy();
}
/* ---------------------------------------------------------------- */
/* Frame */
/* ---------------------------------------------------------------- */
private loop = () => {
if (this.disposed) return;
this.raf = requestAnimationFrame(this.loop);
const now = performance.now() - this.timeOrigin;
const dt = Math.min((now - this.lastT) / 1000, 1 / 30);
this.lastT = now;
this.time += dt;
this.frame++;
this.fpsAcc += dt;
this.fpsN++;
if (now - this.fpsLast > 500) {
this.onStats?.(Math.round(this.fpsN / Math.max(this.fpsAcc, 1e-4)));
this.fpsAcc = 0;
this.fpsN = 0;
this.fpsLast = now;
}
this.render(dt);
};
private render(dt: number) {
const d = this.device;
if (!this.hdrTex || !this.depthTex) return;
const p = this.params;
/* --- 1. Burn spread solver ---------------------------------- */
// A substep can apply exactly one seed, so a queued pattern would otherwise
// light at two points per frame. Extra substeps split the same dt between
// them, so the front advances by the same amount however many are run; only
// the seeding rate changes.
const steps = Math.min(
SEED_STEPS_MAX,
this.resetPending
? 1
: Math.max(
1,
Math.round(p.substeps),
Math.min(this.seedQueue.length, SEED_STEPS_MAX),
),
);
const groups = Math.ceil(MASK_SIZE / SIM_TILE);
const startIndex = this.simIndex;
const slot = this.simSlotFloats;
// Every substep's uniforms are staged first, each into its OWN slot, and
// written in one go. `writeBuffer` runs on the queue timeline, so all of a
// frame's writes land before any command buffer recorded that frame
// executes — which is why substeps cannot share a slot.
for (let s = 0; s < steps; s++) {
// Nothing is queued through a reset: the reset branch in the solver
// returns before the seed is applied, so a seed spent on that frame would
// simply be lost.
const seed = this.resetPending ? null : (this.seedQueue.shift() ?? null);
const u = this.simData;
const o = s * slot;
u[o + BURN_U.seed] = seed ? seed.a[0] : 0;
u[o + BURN_U.seed + 1] = seed ? seed.a[1] : 0;
u[o + BURN_U.seedRadius] = p.seedRadius;
u[o + BURN_U.seedActive] = seed ? 1 : 0;
u[o + BURN_U.seedB] = seed ? seed.b[0] : 0;
u[o + BURN_U.seedB + 1] = seed ? seed.b[1] : 0;
u[o + BURN_U.dt] = dt / steps;
u[o + BURN_U.time] = this.time;
u[o + BURN_U.speed] = p.burnSpeed;
u[o + BURN_U.noiseScale] = p.noiseScale;
u[o + BURN_U.noiseContrast] = p.noiseContrast;
u[o + BURN_U.aspect] = PAPER_W / PAPER_H;
u[o + BURN_U.reset] = this.resetPending ? 1 : 0;
u[o + BURN_U.edgeBias] = p.edgeBias;
}
d.queue.writeBuffer(this.simUniform, 0, this.simData, 0, steps * slot);
this.simIndex = (startIndex + steps) % 2;
// Everything this frame is one command buffer.
const encoder = d.createCommandEncoder();
/* --- 2. Particle update ------------------------------------- */
// Staged before the pass is opened so the whole frame's compute work can go
// into one encoder.
const ps = this.particleSimData;
ps.set(this.model, PART_SIM_U.model);
ps[PART_SIM_U.paperSize] = PAPER_W;
ps[PART_SIM_U.paperSize + 1] = PAPER_H;
ps[PART_SIM_U.dt] = dt;
ps[PART_SIM_U.time] = this.time;
ps[PART_SIM_U.buoyancy] = p.buoyancy;
ps[PART_SIM_U.turbulence] = p.turbulence;
ps[PART_SIM_U.turbScale] = p.turbScale;
ps[PART_SIM_U.drag] = p.drag;
// Per-frame respawn chance, derived from the per-second rate so that
// emission does not scale with refresh rate.
ps[PART_SIM_U.emitChance] = this.resetPending
? 0
: Math.min(p.emitRate * dt, 1);
ps[PART_SIM_U.reset] = this.resetPending ? 1 : 0;
ps[PART_SIM_U.spawnSpeed] = 1;
this.particleSimU32[PART_SIM_U.frame] = this.frame;
d.queue.writeBuffer(this.particleSimUniform, 0, this.particleSimData);
/* --- 2b. Orphaned scraps ------------------------------------ */
const occRequested =
this.frame % OCC_INTERVAL === 0 &&
!this.occReadPending &&
!this.resetPending;
// One compute pass for the whole frame. Dispatches within a pass run in
// order and WebGPU inserts the barriers that make each see the one before.
const cPass = encoder.beginComputePass();
cPass.setPipeline(this.simPipeline);
for (let s = 0; s < steps; s++) {
cPass.setBindGroup(0, this.simBind[(startIndex + s) % 2][s]);
cPass.dispatchWorkgroups(groups, groups);
}
// Blur pyramid of the burn mask. Rebuilt from scratch every frame: the mask
// changes every frame, and each level is only a few thousand texels.
cPass.setPipeline(this.smearSeedPipeline);
cPass.setBindGroup(0, this.smearSeedBind[this.simIndex]);
cPass.dispatchWorkgroups(Math.ceil(SMEAR_W / 8), Math.ceil(SMEAR_H / 8));
cPass.setPipeline(this.smearDownPipeline);
for (let i = 1; i < SMEAR_LEVELS; i++) {
const [w, h] = this.smearSize(i);
cPass.setBindGroup(0, this.smearDownBind[i - 1]);
cPass.dispatchWorkgroups(Math.ceil(w / 8), Math.ceil(h / 8));
}
cPass.setPipeline(this.particleSimPipeline);
cPass.setBindGroup(0, this.particleSimBind[this.simIndex]);
cPass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64));
if (occRequested) {
cPass.setPipeline(this.occPipeline);
cPass.setBindGroup(0, this.occBind[this.simIndex]);
const g = Math.ceil(OCC_SIZE / 8);
cPass.dispatchWorkgroups(g, g);
}
cPass.end();
// Outside the pass, but still in this encoder, so it runs after it.
if (occRequested) {
encoder.copyBufferToBuffer(
this.occBuffer,
0,
this.occRead,
0,
OCC_CELLS * 4,
);
}
this.updateFragments(dt);
this.resetPending = false;
/* --- 3. Scene render into the HDR target -------------------- */
const scene = this.sceneData;
scene.set(this.viewProj, SCENE.viewProj);
scene.set(this.model, SCENE.model);
scene.set(this.camRight, SCENE.camRight);
scene.set(this.camUp, SCENE.camUp);
scene.set(this.eye, SCENE.camPos);
scene[SCENE.time] = this.time;
scene[SCENE.emissive] = p.emissive;
scene[SCENE.curlStrength] = p.curlStrength;
scene[SCENE.paperSize] = PAPER_W;
scene[SCENE.paperSize + 1] = PAPER_H;
scene[SCENE.riseAmount] = p.riseAmount;
scene[SCENE.curlScale] = p.curlScale;
scene[SCENE.charDarkness] = p.charDarkness;
scene[SCENE.flicker] = p.flicker;
scene[SCENE.charSpread] = p.charSpread;
scene[SCENE.deckleDepth] = p.deckleDepth;
scene[SCENE.deckleScale] = p.deckleScale;
scene[SCENE.inkAmount] = this.inkAmount;
scene[SCENE.cornerRadius] = p.cornerRadius;
d.queue.writeBuffer(this.sceneUniform, 0, scene);
const pu = this.particleData;
pu.set(this.viewProj, PART_U.viewProj);
pu.set(this.camRight, PART_U.camRight);
pu.set(this.camUp, PART_U.camUp);
pu[PART_U.fireSize] = p.fireSize;
pu[PART_U.smokeSize] = p.smokeSize;
pu[PART_U.time] = this.time;
pu[PART_U.fireIntensity] = p.fireIntensity;
pu[PART_U.smokeOpacity] = p.smokeOpacity;
pu[PART_U.flameDetail] = p.flameDetail;
pu[PART_U.flameWisp] = p.flameWisp;
pu[PART_U.flameStretch] = p.flameStretch;
pu[PART_U.flameTongue] = p.flameTongue;
pu[PART_U.flameSharp] = p.flameSharp;
d.queue.writeBuffer(this.particleUniform, 0, pu);
const scenePass = encoder.beginRenderPass({
colorAttachments: [
{
view: this.hdrView!,
clearValue: { r: 0.012, g: 0.011, b: 0.014, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
depthStencilAttachment: {
view: this.depthView!,
depthClearValue: 1,
depthLoadOp: "clear",
depthStoreOp: "store",
},
});
scenePass.setPipeline(this.paperPipeline);
scenePass.setBindGroup(0, this.paperBind[this.simIndex]);
scenePass.setVertexBuffer(0, this.gridVerts);
scenePass.setIndexBuffer(this.gridIndices, "uint32");
scenePass.drawIndexed(this.indexCount);
if (this.fragCount > 0) {
scenePass.setPipeline(this.fragPipeline);
scenePass.setBindGroup(0, this.fragBind[this.simIndex]);
scenePass.draw(FRAG_VERTS, this.fragCount);
}
scenePass.setPipeline(this.particlePipeline);
scenePass.setBindGroup(0, this.particleBind);
scenePass.draw(6, PARTICLE_COUNT);
scenePass.end();
/* --- 4. Tonemap --------------------------------------------- */
this.postScratch[0] = p.exposure;
this.postScratch[1] = 0;
this.postScratch[2] = p.vignette;
this.postScratch[3] = this.time;
d.queue.writeBuffer(this.postUniform, 0, this.postScratch);
const post = encoder.beginRenderPass({
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
});
post.setPipeline(this.compositePipeline);
post.setBindGroup(0, this.compositeBind!);
post.draw(3);
post.end();
d.queue.submit([encoder.finish()]);
this.context.present();
// Pull the occupancy grid back and look for newly orphaned islands. This
// resolves a frame or two later, which is invisible — a scrap detaching
// slightly late reads as the last fibres giving way.
if (occRequested) {
this.occReadPending = true;
// Which sheet this grid describes: a reset between the request and the
// map would otherwise tear scraps out of the fresh sheet at the shapes
// the previous burn had left.
const era = this.sheetEra;
this.occRead
.mapAsync(GPUMapMode.READ)
.then(() => {
this.occReadPending = false;
if (this.disposed) {
this.occRead.unmap();
this.occRead.destroy();
return;
}
if (era === this.sheetEra) {
this.detectIslands(new Uint32Array(this.occRead.getMappedRange()));
}
this.occRead.unmap();
})
.catch(() => {
this.occReadPending = false;
});
}
}
}
/* ==========================================================================
* THE COMPONENT — a photo detail card printed on a burning sheet, with one
* destructive control and no way back.
*
* The whole card — the photograph, the type, the button — is ink handed to the
* engine as a single texture, with a transparent `Pressable` laid exactly over
* the printed button to carry the touch and the accessibility tree.
* `engine.sheetBox()` is the inverse of the engine's own picking maths, so the
* two can never disagree about where the button is.
*
* Pressing it burns the photograph itself, and there is nothing to put back —
* only a fresh copy.
* ========================================================================== */
type Phase = "open" | "burning" | "burnt";
/** Fraction of the sheet below which the burn counts as finished. */
const SPENT = 0.012;
/**
* Backstop for the burn watcher, in SIMULATED seconds — the same clamped dt the
* engine advances the solver by, not wall clock. A screen that loses the
* compositor runs its frames far apart while the burn advances by a thirtieth
* of a second each, so a wall-clock deadline would announce a deleted photo
* over one that is barely alight.
*/
const BURN_MAX_SIM = 60;
/** The engine's own per-frame dt clamp; the backstop has to match it. */
const DT_CLAMP = 1 / 30;
const NOTICE = "Photo deleted.";
/**
* How far the footer sits off the bottom of the screen.
*
* A plain constant rather than `useSafeAreaInsets()`, so this file needs no
* safe-area provider mounted above it. With `react-native-safe-area-context`
* already in the tree, `insets.bottom + 30` is the better value.
*/
const FOOTER_BOTTOM = Platform.OS === "ios" ? 64 : 42;
/**
* How much world height fills the canvas. A phone is far taller than it is
* wide, so the engine's narrow-canvas guard wins and this ends up setting the
* sheet's width: at 1.7 the card spans about 85% of the screen.
*/
const VIEW_HEIGHT = 1.7;
/**
* The press.
*
* `PRESS_SCALE` is small on purpose: the button is 1140 ink px wide, so even
* this takes 50 of them off the measure, and anything deeper reads as the card
* itself moving rather than as the control being pushed.
*
* The spring is tuned snappy — `stiffness` well past what a settling animation
* needs, `damping` a little under critical (2 * sqrt(stiffness) = 63) so the
* release comes back with one small overshoot and stops. Both ends land inside
* about 200 ms, which is the point: this has to be under the finger, not
* arriving after it.
*
* `MAX_SCALE` caps that overshoot at what the re-printed patch has room to
* hold — see `BUTTON_PATCH`.
*/
const PRESS_SCALE = 0.955;
const PRESS_SPRING = { stiffness: 1100, damping: 48 };
const MAX_SCALE = 1.02;
/** Substeps per frame: a spring this stiff is not stable at a whole frame. */
const SPRING_SUBSTEPS = 4;
/** Distance from the target, and speed, at which the spring is called done. */
const SPRING_REST = 0.0004;
const SPRING_REST_V = 0.02;
/** The press animation's state. Lives in a ref, never in React state. */
interface PressSpring {
/** The rAF handle while it is running, 0 when it is at rest. */
raf: number;
scale: number;
velocity: number;
target: number;
/** Whether a finger is currently down, which is the button's ink colour. */
down: boolean;
}
function pressAtRest(): PressSpring {
return { raf: 0, scale: 1, velocity: 0, target: 1, down: false };
}
function advancePress(s: PressSpring, dt: number): void {
const h = dt / SPRING_SUBSTEPS;
for (let i = 0; i < SPRING_SUBSTEPS; i++) {
const accel =
PRESS_SPRING.stiffness * (s.target - s.scale) -
PRESS_SPRING.damping * s.velocity;
s.velocity += accel * h;
s.scale += s.velocity * h;
// The cap is the patch's, not the spring's, so anything that runs into it
// has its motion taken away rather than being held against the ceiling for
// a few frames.
if (s.scale > MAX_SCALE) {
s.scale = MAX_SCALE;
s.velocity = 0;
}
}
}
function pressSettled(s: PressSpring): boolean {
return (
Math.abs(s.target - s.scale) < SPRING_REST &&
Math.abs(s.velocity) < SPRING_REST_V
);
}
/**
* Where this screen starts, over the engine's defaults.
*
* `seedRadius` is about the press rather than about the look: a seed does not
* warm the paper, it sets the burn mask straight to 1, so at the default radius
* the spot covers the whole button and the button is gone before the first
* frame is drawn. A fifth of that leaves a pinhole that has to eat its way out.
*
* `deckleDepth` is the only thing that deforms the sheet before it is lit. At
* the engine's default the tear reads as a warp in the card — this is a printed
* photograph, not a hand-torn sheet, and the edge only has to stop the card
* looking die-cut. Halved, with the profile left as fine as it was.
*
* `cornerRadius` makes it a card rather than a sheet. The engine's shape is a
* squircle, so the corner is a continuous curve and not an arc pasted onto two
* straight runs.
*/
const CARD_PARAMS: PaperParams = {
...DEFAULT_PAPER_PARAMS,
seedRadius: 0.004,
deckleDepth: 0.003,
cornerRadius: 0.11,
};
interface SheetBox {
left: number;
top: number;
width: number;
height: number;
}
/**
* Rotation and Retry both mean "throw this away and build it again at the new
* size": the engine's drawing buffer is fixed at construction, and every piece
* of state under it — the phase, the printed card, the sheet's rectangle — is
* about the sheet that engine owns. So the stage below is KEYED rather than
* reset, and React unmounts the lot.
*/
export function BurningPhotoCard() {
const { width, height } = useWindowDimensions();
const [retryTick, setRetryTick] = useState(0);
return (
<PhotoStage
key={`${width}x${height}:${retryTick}`}
onRetry={() => setRetryTick((n) => n + 1)}
/>
);
}
function PhotoStage({ onRetry }: { onRetry: () => void }) {
const canvasRef = useCanvasRef();
const engineRef = useRef<BurningPaperEngine | null>(null);
const fadeRef = useRef(0);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>("open");
const [sheet, setSheet] = useState<SheetBox>({
left: 0,
top: 0,
width: 0,
height: 0,
});
// Out of React entirely: the only consumer of this animation is a Skia
// rasterization, so a state update per frame would buy nothing but re-renders
// of a tree that does not draw it.
const press = useRef<PressSpring>(pressAtRest());
// Decoded alongside the engine rather than after it, since both take a moment
// and neither waits on the other; whichever lands last triggers the print.
// Until it lands the card prints a flat wash in the photograph's place.
const photo = useImage(PHOTO);
/* ---------------------------------------------------------------- */
/* Engine */
/* ---------------------------------------------------------------- */
useEffect(() => {
let cancelled = false;
let engine: BurningPaperEngine | null = null;
(async () => {
try {
// The native surface is created a frame or two after mount.
let context = canvasRef.current?.getContext("webgpu");
for (let i = 0; i < 60 && !context && !cancelled; i++) {
await new Promise(requestAnimationFrame);
context = canvasRef.current?.getContext("webgpu");
}
if (cancelled) return;
if (!context) throw new Error("Could not acquire a WebGPU context");
engine = await BurningPaperEngine.create(context, {
viewHeight: VIEW_HEIGHT,
});
if (cancelled) {
engine.dispose();
return;
}
Object.assign(engine.params, CARD_PARAMS);
engineRef.current = engine;
setSheet(engine.sheetBox());
setReady(true);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => {
cancelled = true;
engineRef.current = null;
engine?.dispose();
};
}, [canvasRef]);
/* ---------------------------------------------------------------- */
/* Printing */
/* ---------------------------------------------------------------- */
// The whole page, printed once per card: on the first frame it can be, and
// again when a restore puts a fresh copy up. One Skia draw and one texture
// upload, off the render loop — never per frame, which is why the press goes
// through `setInkRegion` below instead of coming back through here. Always at
// rest: what a finger is doing to the button belongs to the patch. And only
// while the card is up — once it has burnt there is no sheet left to print on,
// so the outcome is announced in the footer instead.
useEffect(() => {
const engine = engineRef.current;
if (!engine || !ready || phase !== "open") return;
const ink = renderCard({ active: false, scale: 1 }, photo);
if (ink) engine.setInk(ink.rgba, ink.width, ink.height);
}, [ready, phase, photo]);
/** Re-prints the button's own patch of paper, and nothing else. */
const printButton = useCallback((active: boolean, scale: number) => {
const engine = engineRef.current;
if (!engine) return;
const patch = renderButtonPatch({ active, scale });
if (patch) {
engine.setInkRegion(
patch.rgba,
patch.x,
patch.y,
patch.width,
patch.height,
);
}
}, []);
/**
* Takes the button down under a finger and lets it back up.
*
* The colour is not animated — it is the pressed state and it belongs to the
* frame the touch lands on — so it switches with the target while the scale
* springs toward it. Re-entrant: a second press while the release is still
* running redirects the spring rather than starting a second one, so a fast
* double tap keeps the velocity it already had instead of jumping.
*/
const setPressed = useCallback(
(down: boolean) => {
const p = press.current;
p.down = down;
p.target = down ? PRESS_SCALE : 1;
printButton(down, p.scale);
if (p.raf) return;
let last = performance.now();
const step = () => {
const now = performance.now();
advancePress(p, Math.min((now - last) / 1000, DT_CLAMP));
last = now;
if (pressSettled(p)) {
p.raf = 0;
p.scale = p.target;
p.velocity = 0;
} else {
p.raf = requestAnimationFrame(step);
}
printButton(p.down, p.scale);
};
p.raf = requestAnimationFrame(step);
},
[printButton],
);
useEffect(() => () => cancelAnimationFrame(press.current.raf), []);
/** Ramps the ink on or off the sheet. Smoothstepped so it never snaps. */
const fadeInk = useCallback((to: number, ms: number) => {
const engine = engineRef.current;
if (!engine) return;
cancelAnimationFrame(fadeRef.current);
const from = engine.inkAmount;
const start = performance.now();
const step = () => {
const k = Math.min(1, (performance.now() - start) / ms);
engine.inkAmount = from + (to - from) * (k * k * (3 - 2 * k));
if (k < 1) fadeRef.current = requestAnimationFrame(step);
};
step();
}, []);
useEffect(() => () => cancelAnimationFrame(fadeRef.current), []);
// Fades the card in behind whatever the print effect above just drew.
// Declared after it, so within a commit the ink texture is already the new
// one by the time this runs. Not for 'burnt': the sheet is ash by then.
useEffect(() => {
if (ready && phase === "open") fadeInk(1, 340);
}, [ready, phase, fadeInk]);
/* ---------------------------------------------------------------- */
/* Actions */
/* ---------------------------------------------------------------- */
const onDelete = () => {
const engine = engineRef.current;
if (!engine || phase !== "open") return;
// Lets the button up here as well as on `onPressOut`. The press ends this
// control's life — the touchable unmounts on the phase change below — and a
// release that arrives after the unmount is one nobody hears, which would
// leave the button burning in its pressed state.
setPressed(false);
setPhase("burning");
// The button is what catches. The photograph burns because it was printed
// above it.
engine.ignite(DELETE_IGNITION[0], DELETE_IGNITION[1]);
};
const restore = () => {
const engine = engineRef.current;
if (!engine) return;
engine.inkAmount = 0;
engine.reset();
// The new copy is printed at rest, so the spring has to agree with it.
cancelAnimationFrame(press.current.raf);
press.current = pressAtRest();
setPhase("open");
};
// Watches the burn out. `paperLeft` comes from the occupancy read-back the
// engine already does for its falling scraps, so this costs nothing but the
// poll — and it tracks the burn actually finishing rather than guessing at a
// duration that would be wrong on the first slow device.
useEffect(() => {
if (phase !== "burning") return;
let raf = 0;
let simulated = 0;
let last = performance.now();
const tick = (now: number) => {
simulated += Math.min((now - last) / 1000, DT_CLAMP);
last = now;
const engine = engineRef.current;
if (!engine) return;
if (engine.paperLeft < SPENT || simulated > BURN_MAX_SIM) {
setPhase("burnt");
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [phase]);
/* ---------------------------------------------------------------- */
/* Overlay */
/* ---------------------------------------------------------------- */
// The printed button's rectangle, in css px on the canvas — the sheet box
// scaled by the button's own UV within the ink layout.
const uv = rectToUV(DELETE_RECT);
const hit = {
left: sheet.left + uv.u0 * sheet.width,
top: sheet.top + uv.v0 * sheet.height,
width: (uv.u1 - uv.u0) * sheet.width,
height: (uv.v1 - uv.v0) * sheet.height,
};
return (
<View style={styles.root}>
<Canvas ref={canvasRef} style={StyleSheet.absoluteFill} />
{ready && phase === "open" && (
<Pressable
style={[styles.hit, hit]}
accessibilityRole="button"
accessibilityLabel={`${DELETE_LABEL}: ${TITLE}`}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPress={onDelete}
/>
)}
{error && (
<View style={[styles.center, StyleSheet.absoluteFill]}>
<Text style={styles.errorTitle}>WebGPU unavailable</Text>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)}
{/* Nothing at all while it burns — the pill is a visible object, and an
empty one would sit at the bottom of the shot for the whole burn. */}
{phase !== "burning" && !error && (
<View style={[styles.foot, { bottom: FOOTER_BOTTOM }]}>
{phase === "open" && (
<Text style={styles.hint}>One photo. No undo.</Text>
)}
{phase === "burnt" && (
<>
<Text
style={styles.note}
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{NOTICE}
</Text>
<Pressable
style={({ pressed }) => [
styles.again,
pressed && styles.againPressed,
]}
onPress={restore}
>
<Text style={styles.againText}>Restore from backup</Text>
</Pressable>
</>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
root: {
// Matches the page the engine draws, so the two never disagree in the
// frame or two before the first present.
flex: 1,
backgroundColor: "#08060a",
},
center: {
alignItems: "center",
justifyContent: "center",
padding: 24,
gap: 10,
},
/**
* Transparent: this control's pressed state is printed on the paper
* underneath it, in ink, by `deleteButton`. Anything drawn here would sit on
* the glass in front of the sheet and double what is already there.
*/
hit: {
position: "absolute",
},
/**
* The only thing on the screen that is not printed on the sheet.
*
* It carries its own dark pill because it has no reliable backdrop: the sheet
* reaches down toward it, and for the whole back half of a burn there are
* embers and lit scraps falling past.
*/
foot: {
position: "absolute",
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 7,
paddingHorizontal: 16,
borderRadius: 999,
backgroundColor: "rgba(10, 7, 6, 0.72)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255, 140, 60, 0.24)",
},
hint: {
color: "rgba(255, 170, 90, 0.34)",
fontSize: 12,
letterSpacing: 0.4,
},
note: {
color: "rgba(255, 170, 90, 0.62)",
fontSize: 12,
letterSpacing: 0.4,
},
again: {
marginRight: -8,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 999,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255, 140, 60, 0.26)",
backgroundColor: "rgba(255, 122, 30, 0.1)",
},
againPressed: {
backgroundColor: "rgba(255, 122, 30, 0.24)",
},
againText: {
color: "#ffcc9a",
fontSize: 12,
letterSpacing: 0.4,
},
errorTitle: {
color: "#ff8c3a",
fontSize: 15,
fontWeight: "700",
},
errorText: {
color: "#a89684",
fontSize: 13,
textAlign: "center",
maxWidth: 460,
},
retryButton: {
marginTop: 10,
paddingVertical: 10,
paddingHorizontal: 28,
borderRadius: 10,
backgroundColor: "#ff6a1a",
},
retryText: {
color: "#1a0a03",
fontSize: 16,
fontWeight: "700",
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment