Created
August 3, 2026 16:27
-
-
Save byteab/55af58985d0338d63292ef550e9d85b4 to your computer and use it in GitHub Desktop.
Realistic burning paper effect. built with react-native
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * <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