diff --git a/example/assets/cigarette.png b/example/assets/cigarette.png new file mode 100644 index 0000000..9c41e50 Binary files /dev/null and b/example/assets/cigarette.png differ diff --git a/example/assets/components/cigarette.png b/example/assets/components/cigarette.png new file mode 100644 index 0000000..69c8a50 Binary files /dev/null and b/example/assets/components/cigarette.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index e36caeb..6c126b2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -40,6 +40,7 @@ import FoamLabScreen from './screens/FoamLabScreen'; import VoiceDeleteScreen from './screens/VoiceDeleteScreen'; import SnowGlobeScreen from './screens/SnowGlobeScreen'; import Magic8BallScreen from './screens/Magic8BallScreen'; +import CigaretteScreen from './screens/CigaretteScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -138,6 +139,7 @@ export default function App() { /> + diff --git a/example/src/components/cigarette/Cigarette.tsx b/example/src/components/cigarette/Cigarette.tsx new file mode 100644 index 0000000..934bc15 --- /dev/null +++ b/example/src/components/cigarette/Cigarette.tsx @@ -0,0 +1,258 @@ +import type { ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +/** The photo's proportions: length over width. */ +export const CIG_ASPECT = 3074 / 512; +/** Where the filter ends and the tobacco begins, as a fraction of the length. */ +export const FILTER_FRAC = 0.356; +/** + * The view is taller than the cigarette so the ash has somewhere to grow + * above the tip: the photo occupies the bottom `1 / HEADROOM` of it. + */ +export const HEADROOM = 1.25; +/** + * ...and wider, so a piece of ash can tumble off to the side while still + * being drawn IN FRONT of the paper: the cigarette sits in the middle third. + */ +export const PANE_SCALE = 3; + +const CIGARETTE_PHOTO = require('../../../assets/cigarette.png'); + +type Props = ViewProps & { + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The cigarette itself: a photo of one, burnt down by the shader. + * + * Every pixel is decided in cigarette space (v = 0 at the filter's end, v = 1 + * at the tobacco tip). Below the burn line the photo shows through, charred + * for the last few millimetres; on the line sits the coal — a crust of ash + * cells with fire in the cracks between them; above it stands the ash column, + * the same crust gone cold and grey; above the ash there is nothing. The + * burn line moves down the tobacco as the sim's `burn` advances, so the whole + * thing shortens for free. A knocked-off piece of ash is drawn on top as it + * falls, along whatever direction the phone says is down. + * + * Transparent, so the smoke pass behind shows around it — which means the + * output has to be premultiplied. + */ +export default function Cigarette({ + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const CIGARETTE_SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const FILTER_FRAC = ${FILTER_FRAC}; +const HEADROOM = ${HEADROOM}; +const PANE_SCALE = ${PANE_SCALE}; +const CIG_ASPECT = ${CIG_ASPECT}; +const TOBACCO = 1.0 - FILTER_FRAC; +// Half the cigarette's width, in lengths. +const HW = 0.5 / CIG_ASPECT; +// How long a dropped piece of ash is in the air (s) — keep in step with the hook. +const CHUNK_DURATION = 1.6; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +// The ash crust, shared by the column on the cigarette and the piece that +// falls off it. cp is the crust-space coordinate (~14 units across the +// paper), h the height above the coal (lengths), heat how hard the coal is +// burning, shade the cylinder's shading across it. +// +// Not tiles: a real coal is open glowing tobacco with soft-edged flakes of +// grey ash lying over it, and the flakes take over as it cools further up. +fn crust(cp: vec2, h: f32, heat: f32, shade: f32, emberCol: vec3) -> vec3 { + let n1 = vnoise(cp * 0.55); + let n2 = vnoise(cp * 1.3 + 7.0); + let n3 = vnoise(cp * 2.6 + 3.0); + let g = clamp(exp(-max(h, 0.0) * 24.0) * heat * 1.5, 0.0, 1.0); + + // Pale ash, lighter than you think — it is nearly white in daylight. + let lit = 0.62 + 0.38 * shade; + var col = vec3(0.87, 0.86, 0.83) * (0.86 + 0.14 * n2) * (0.94 + 0.06 * n3) * lit; + // Charcoal cracks between the flakes: dark grey, never brown. + let crack = smoothstep(0.42, 0.30, n2 * 0.7 + n3 * 0.3); + col = mix(col, vec3(0.26, 0.25, 0.24) * lit, crack * 0.75); + + // The fire: it shows first in the cracks, then the whole face goes orange + // near the paper, with a few flakes left dark on top of it. + let coal = emberCol * (0.85 + 0.30 * n3); + // Either dark or orange — a half-lit crack is brown, and brown is wrong. + let crackFire = smoothstep(0.25, 0.60, crack * g * 1.8); + let faceFire = smoothstep(0.30, 0.80, g); + let darkFlake = smoothstep(0.62, 0.78, n1) * 0.7; + let fire = clamp(crackFire + faceFire * (1.0 - darkFlake), 0.0, 1.0); + col = mix(col, coal, fire); + return col; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + + let ember = clamp(u.live.x, 0.0, 1.0); + let draw = clamp(u.live.y, 0.0, 1.0); + let tilt = u.live.w; + let phase = u.liveData[0].x; + let burn = clamp(u.liveData[0].y, 0.0, 1.0); + let ash = clamp(u.liveData[0].z, 0.0, 1.0); + let chunkAge = clamp(u.liveData[1].y, 0.0, 1.0); + let flare = clamp(u.liveData[1].z, 0.0, 1.0); + let vis = clamp(u.liveData[1].w, 0.0, 1.0); + let chunkLen = clamp(u.liveData[2].x, 0.0, 1.0); + let chunkKick = clamp(u.liveData[2].y, -1.0, 1.0); + + // Cigarette space: v runs 0 at the filter's end to 1 at the tobacco tip, + // x runs -1..1 across the paper. P is the same thing isotropic, in lengths. + let uu = (uv.x - 0.5) * PANE_SCALE; + let x = uu * 2.0; + let v = uv.y * HEADROOM; + let P = vec2(uu / CIG_ASPECT, v); + + // The photo, where there is one. + var tc = vec4(0.0); + if (v <= 1.0 && abs(uu) < 0.5) { + tc = textureSampleLevel(tex, samp, vec2(uu + 0.5, 1.0 - v), 0.0); + } + + // Cylinder shading across the paper; the photo already has its own, the + // ash needs it painted on. + let shade = 0.50 + 0.50 * sqrt(max(1.0 - x * x * 0.92, 0.0)); + + // The burn line: a ragged edge, keyed to the burn so it changes shape as + // the coal creeps rather than every frame, and the coal is a shallow dome. + let burnLine = 1.0 - burn * TOBACCO; + let rag = (vnoise(vec2(x * 2.6 + 4.0, burn * 60.0)) - 0.5) * 0.012; + let vB = burnLine + rag + 0.008 * (1.0 - x * x); + // A crust of coal stays even right after the ash is knocked off. + let ashLen = max(ash * TOBACCO, 0.014); + let topRag = (vnoise(vec2(x * 3.1 + 9.0, burn * 45.0 + 3.0)) - 0.5) * 0.02; + let vTop = vB + ashLen + topRag; + + // Coal brightness: a slow breath, a fast shimmer under a drag, and the + // flare of lighting up or of a freshly bared coal. + let breath = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); + let shimmer = vnoise(vec2(phase * 11.0 + x * 3.0, 7.0)); + let heat = ember * (breath * (0.62 + 0.38 * draw) + draw * 0.40 * shimmer) + + flare * 0.9; + // Saturated orange even at rest — a coal seen through grey flakes must + // not average out to brown. + let emberCol = mix(vec3(0.96, 0.30, 0.03), vec3(1.0, 0.74, 0.26), + clamp(heat - 0.35, 0.0, 1.0)); + + // Cell space for the crust: ~14 cells across the paper, square. + let cp = vec2(x * 7.0, v * 84.0); + + var col = vec3(0.0); + var alpha = 0.0; + + if (abs(uu) < 0.5) { + if (v < vB) { + // ---- Paper (and filter): the photo, charred toward the coal. ---- + col = tc.rgb; + alpha = tc.a; + let d = vB - v; + let charN = vnoise(vec2(x * 4.0 + 1.0, v * 90.0)); + // A thin black ring where the paper is burning, a faint tan just + // below it — not a wide brown smear. + let char = smoothstep(0.016, 0.0, d + (charN - 0.5) * 0.010); + let scorch = smoothstep(0.045, 0.0, d + (charN - 0.5) * 0.03); + col = mix(col, vec3(0.62, 0.48, 0.30), scorch * 0.35); + col = mix(col, vec3(0.06, 0.05, 0.04), char * 0.95); + // The paper's edge is itself on fire: a thin bright rim, then the + // coal's light falling on the char below it. + col = col + emberCol * heat * (0.9 * exp(-d * 260.0) + 0.22 * exp(-d * 50.0)); + // Sparks along the rim under a drag. + let sp = hash21(floor(vec2(x * 30.0, d * 900.0)) + floor(phase * 24.0)); + col = col + vec3(1.0, 0.85, 0.5) * step(0.985, sp) * draw * exp(-d * 200.0); + } else if (v < vTop) { + // ---- The coal and the ash standing on it. ---- + let h = v - vB; + let edgeN = vnoise(vec2(3.0, v * 140.0)) - 0.5; + let edgeR = 0.93 + edgeN * 0.09; + let inside = smoothstep(edgeR, edgeR - 0.05, abs(x)); + col = crust(cp, h, heat, shade, emberCol); + // Sparks in the seams under a drag. + let sp = hash21(floor(cp * 4.0) + floor(phase * 24.0)); + col = col + vec3(1.0, 0.85, 0.5) * step(0.988, sp) * draw * exp(-h * 40.0); + alpha = inside * smoothstep(vTop, vTop - 0.010, v); + } + } + + // Premultiply, and the whole butt fades during a swap. + var outC = col * alpha * vis; + var outA = alpha * vis; + + // ---- A piece of ash in the air, falling toward the real floor. ---- + if (chunkAge < 1.0 && chunkLen > 0.0) { + let t = chunkAge * CHUNK_DURATION; + let up = vec2(-sin(tilt), cos(tilt)); + let right = vec2(cos(tilt), sin(tilt)); + let len = chunkLen * TOBACCO; + let start = vec2(0.0, burnLine + 0.008 + len * 0.5); + let pos = start - up * (0.5 * 5.0 * t * t) + right * (chunkKick * 0.6 * t) + + up * (0.35 * abs(chunkKick) * t); + let rot = chunkKick * 4.0 * t + 0.8 * t; + let d = P - pos; + let cs = cos(rot); + let sn = sin(rot); + let c = vec2(d.x * cs + d.y * sn, -d.x * sn + d.y * cs); + let cx = c.x / HW; + let edgeN = vnoise(vec2(5.0, c.y * 140.0 + 40.0)) - 0.5; + let edgeR = 0.93 + edgeN * 0.09; + let insideX = smoothstep(edgeR, edgeR - 0.06, abs(cx)); + let endRag = (vnoise(vec2(cx * 3.0 + 17.0, 1.0)) - 0.5) * 0.012; + let insideY = smoothstep(len * 0.5 + 0.004, len * 0.5 - 0.004, abs(c.y) + endRag); + let cshade = 0.50 + 0.50 * sqrt(max(1.0 - cx * cx * 0.92, 0.0)); + // The end that sat on the coal keeps a fading glow. + let hc = c.y + len * 0.5; + let chunkHeat = ember * 0.8 * exp(-t * 3.5); + var ccol = crust(vec2(cx * 7.0, c.y * 84.0 + 300.0), hc, chunkHeat, cshade, emberCol); + let ca = insideX * insideY * (1.0 - smoothstep(0.70, 1.0, chunkAge)); + outC = ccol * ca + outC * (1.0 - ca); + outA = ca + outA * (1.0 - ca); + } + + return vec4(outC, outA); +} +`; diff --git a/example/src/components/cigarette/Smoke.tsx b/example/src/components/cigarette/Smoke.tsx new file mode 100644 index 0000000..1494b9b --- /dev/null +++ b/example/src/components/cigarette/Smoke.tsx @@ -0,0 +1,301 @@ +import type { ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +type Props = ViewProps & { + /** + * `[tipX, tipY, cigWidth, tobaccoLength]`. The unburnt tobacco tip is in + * screen uv (y-up); the two sizes are fractions of the screen HEIGHT. + */ + params: number[]; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The room and the smoke in it. + * + * One opaque full-screen pass: the black room, the faint warmth the coal + * throws, the sidestream plume climbing off the ash, and the crumbs that + * come away with a knocked-off piece of ash. The cigarette is composited on + * top by its own view. + * + * The plume is built the way a real one is shaped. It leaves the tip as a + * LAMINAR ribbon: a thin, sharp-edged sheet that rises straight and only + * waves a little. Some way up it goes turbulent: the sheet folds over on + * itself into curls, spreads and thins out. In the shader that is one thin + * Gaussian ribbon evaluated in DOMAIN-WARPED coordinates — the warp is near + * zero at the tip and grows with height, so the same ribbon is a clean + * thread low down and a tangle of folded veils higher up, and the veils are + * bright where the fold compresses the sheet, exactly like edge-on smoke. + * The whole frame is rotated by the phone's tilt, so "up" is the real up. + * + * Budget: two 3-octave fbm samples for the warp field, reused by every + * layer, and everything is skipped outside the plume's bounding box. + */ +export default function Smoke({ + params, + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const SMOKE_SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; + +// Keep in step with the hook. +const CHUNK_DURATION = 1.6; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var v = 0.0; + var a = 0.5; + let m = mat2x2(1.6, 1.2, -1.2, 1.6); + for (var i = 0; i < 3; i = i + 1) { + v = v + a * vnoise(p); + p = m * p; + a = a * 0.5; + } + return v; +} + +// The ash crust, shared by the column on the cigarette and the piece that +// falls off it. cp is the crust-space coordinate (~14 units across the +// paper), h the height above the coal (lengths), heat how hard the coal is +// burning, shade the cylinder's shading across it. +// +// Not tiles: a real coal is open glowing tobacco with soft-edged flakes of +// grey ash lying over it, and the flakes take over as it cools further up. +fn crust(cp: vec2, h: f32, heat: f32, shade: f32, emberCol: vec3) -> vec3 { + // Three scales of flake, none finer than a few pixels — the finest layer + // is what read as pixel grit on a phone. + let n1 = vnoise(cp * 0.55); + let n2 = vnoise(cp * 1.3 + 7.0); + let n3 = vnoise(cp * 2.6 + 3.0); + let flakeN = n1 * 0.50 + n2 * 0.35 + n3 * 0.15; + let glow = exp(-max(h, 0.0) * 30.0) * clamp(heat * 1.6, 0.0, 1.2); + let g = clamp(glow, 0.0, 1.0); + + // What shows between the flakes: dark ash seams when cold, coal when hot, + // with brighter grains where the tobacco is burning hardest. + let seam = vec3(0.36, 0.34, 0.32) * shade * (0.8 + 0.4 * n2); + var coal = emberCol * (0.70 + 0.55 * n2) * (0.85 + 0.15 * shade); + coal = coal + vec3(0.35, 0.18, 0.02) * smoothstep(0.62, 0.90, n3) * g; + let open = mix(seam, coal, g); + + // The flakes: pale grey, lit from below where they lie on the coal, and a + // few of them charred black right at the fire. + var flake = vec3(0.74, 0.72, 0.69) * (0.82 + 0.18 * n2) * (0.92 + 0.08 * n3) * shade; + flake = mix(flake, emberCol * 0.95, g * 0.40 * (1.0 - n1)); + let charred = smoothstep(0.40, 0.22, n2) * g; + flake = mix(flake, vec3(0.14, 0.11, 0.10), charred * 0.85); + + // More of the surface is bare coal the hotter it is; soft edges, always. + let th = 0.62 - 0.32 * g; + let cover = smoothstep(th - 0.13, th + 0.13, flakeN); + return mix(open, flake, cover); +} + +// One ribbon of smoke: a thin Gaussian around a centreline that waves more +// the higher it goes. q is the (warped) plume-space point, w the +// ribbon's half-width there. +fn ribbon(q: vec2, w: f32, phase: f32, seed: f32) -> f32 { + let h = max(q.y, 0.0); + // Sinuous instability: a small wave at the tip that grows with height, + // travelling upward with the smoke. + let amp = 0.002 + 0.035 * smoothstep(0.04, 0.45, h); + let wave = sin(h * 17.0 - phase * 3.4 + seed) * amp; + // A slow meander on top, so the column drifts as a whole. + let meander = (vnoise(vec2(h * 1.6 - phase * 0.35, 5.0 + seed)) - 0.5) * 0.14 * h; + let dx = (q.x - wave - meander) / w; + // A sheet seen side-on is brighter at its rims than through its middle. + return exp(-dx * dx) * (0.45 + 1.0 * dx * dx); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let aspect = u.resolution.z; + let uv = ndc * 0.5 + 0.5; + + let tip = u.params0.xy; + let cigW = max(u.params0.z, 0.001); + let tobacco = max(u.params0.w, 0.001); + + let ember = clamp(u.live.x, 0.0, 1.0); + let draw = clamp(u.live.y, 0.0, 1.0); + let puff = clamp(u.live.z, 0.0, 1.0); + let tilt = u.live.w; + let phase = u.liveData[0].x; + let burn = clamp(u.liveData[0].y, 0.0, 1.0); + let ash = clamp(u.liveData[0].z, 0.0, 1.0); + let puffAge = clamp(u.liveData[0].w, 0.0, 1.0); + let chunkAge = clamp(u.liveData[1].y, 0.0, 1.0); + let flare = clamp(u.liveData[1].z, 0.0, 1.0); + let vis = clamp(u.liveData[1].w, 0.0, 1.0); + let chunkLen = clamp(u.liveData[2].x, 0.0, 1.0); + let chunkKick = clamp(u.liveData[2].y, -1.0, 1.0); + + // Where the coal is now, and where the ash ends above it: that is where + // the smoke comes off. + let burnY = tip.y - burn * tobacco; + let ashTopY = burnY + max(ash * tobacco, 0.014 * tobacco); + + // Plume space: origin on top of the ash, ONE unit = the screen's height, + // rotated so y runs along the real up. + let p0 = vec2((uv.x - tip.x) * aspect, uv.y - ashTopY); + let up = vec2(-sin(tilt), cos(tilt)); + let right = vec2(cos(tilt), sin(tilt)); + let p = vec2(dot(p0, right), dot(p0, up)); + + let breath = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); + let heat = (ember * breath * (0.42 + 0.58 * draw) + flare * 0.9) * vis; + + // ---- The room: black. The coal warms the wall a little, nothing more. ---- + var col = vec3(0.006, 0.006, 0.008); + let lp = p0 - vec2(0.0, burnY - ashTopY); + let ld2 = dot(lp, lp); + col = col + vec3(1.00, 0.40, 0.10) * heat * 0.10 / (1.0 + ld2 * 420.0); + + // ---- The plume. ---- + let source = (ember * (0.65 + 0.35 * breath) * (1.0 - 0.75 * draw)) * vis; + let puffPos = 0.02 + puffAge * 0.9; + let puffBand = exp(-pow((p.y - puffPos) / (0.06 + 0.20 * puffAge), 2.0)) * puff; + if ((source > 0.003 || puffBand > 0.003) && p.y > -0.03 && p.y < 1.05 && abs(p.x) < 0.55) { + let h = max(p.y, 0.0); + + // Laminar at the tip, turbulent above: the warp's amplitude is what + // makes the difference, and an exhale surge kicks it up early. + let turb = smoothstep(0.05, 0.35, h) + puffBand * 0.6; + let wAmp = 0.005 + 0.075 * turb; + + // The warp field, scrolled upward with the smoke. Two fbm samples make + // a vector field; a second, finer warp of that field adds the small + // curls inside the big folds. + let rise = phase * 0.5; + let q0 = vec2(p.x, h - rise) * 7.0; + let d1 = vec2(fbm(q0 + vec2(3.1, 7.3)), fbm(q0 + vec2(9.7, 1.3))) - 0.5; + let q1 = q0 * 2.5 + d1 * 1.5; + let d2 = vec2(vnoise(q1 + vec2(11.0, 4.0)), vnoise(q1 + vec2(2.0, 17.0))) - 0.5; + let warp = d1 * wAmp * 2.0 + d2 * wAmp * 0.9 * smoothstep(0.10, 0.5, h); + let q = vec2(p.x + warp.x, h + warp.y * 0.8); + + // Two ribbons out of phase — a real plume is a few sheets, not one — + // their width opening slowly with height and fattening in a surge. + let w = cigW * 0.15 * (1.0 + 2.2 * q.y) + puffBand * 0.015; + let r1 = ribbon(q, w, phase, 0.0); + let r2 = ribbon(q + vec2(cigW * 0.08, 0.0), w * 0.7, phase, 2.1) * 0.6; + // A soft body of diffuse smoke around the sheets once it has mixed. + let body = exp(-pow(q.x / (w * 4.0), 2.0)) * 0.08 * smoothstep(0.12, 0.5, h); + + // Thins as it climbs and mixes into the room. + let fade = smoothstep(-0.015, 0.008, p.y) * (1.0 - smoothstep(0.5, 0.95, h)) / (1.0 + 1.2 * h); + var dens = (r1 + r2 + body) * fade; + dens = dens * (source + puffBand * 1.4); + + // Sidestream smoke is BLUE: fine particles scatter the short wavelengths. + // It goes white where sheets pile up, and warm right above the coal. + let sa = clamp(dens * 0.75, 0.0, 0.88); + var smokeCol = mix(vec3(0.45, 0.60, 0.90), vec3(0.93, 0.96, 1.0), smoothstep(0.15, 0.8, sa)); + smokeCol = mix(smokeCol, vec3(0.98, 0.78, 0.55), heat * exp(-h * 40.0) * 0.5); + // Screen blend: lighter where sheets overlap, never past white. + col = col + smokeCol * sa * (1.0 - col); + } + + // ---- The knocked-off piece of ash, and the crumbs that come with it. ---- + // The cigarette pane draws the same piece in front of the paper; this copy + // behind it is what keeps it in the picture once it has tumbled out of + // that narrow pane. + if (chunkAge < 1.0 && chunkLen > 0.0) { + let t = chunkAge * CHUNK_DURATION; + let origin = vec2(0.0, burnY - ashTopY); + // Everything here is in screen-height units; the cigarette's length is + // the yardstick the pane uses, so convert. + let cigL = tobacco / 0.644; + let hw = cigW * 0.5; + let len = chunkLen * tobacco; + let breathC = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); + let heatC = ember * breathC * 0.62 + flare * 0.9; + let emberCol = mix(vec3(0.80, 0.12, 0.01), vec3(1.0, 0.66, 0.20), + clamp(heatC - 0.30, 0.0, 1.0)); + let pos = origin + up * (0.008 * cigL + len * 0.5) + - up * (0.5 * 5.0 * t * t * cigL) + right * (chunkKick * 0.6 * t * cigL) + + up * (0.35 * abs(chunkKick) * t * cigL); + let rot = chunkKick * 4.0 * t + 0.8 * t; + let d = p0 - pos; + // Into the piece's own frame: first undo the tilt, then its tumble. + let dl = vec2(dot(d, right), dot(d, up)); + let cs = cos(rot); + let sn = sin(rot); + let c = vec2(dl.x * cs + dl.y * sn, -dl.x * sn + dl.y * cs); + let cx = c.x / hw; + let cyL = c.y / cigL; + let edgeN = vnoise(vec2(5.0, cyL * 140.0 + 40.0)) - 0.5; + let edgeR = 0.93 + edgeN * 0.09; + let insideX = smoothstep(edgeR, edgeR - 0.06, abs(cx)); + let endRag = (vnoise(vec2(cx * 3.0 + 17.0, 1.0)) - 0.5) * 0.012 * cigL; + let insideY = smoothstep(len * 0.5 + 0.004 * cigL, len * 0.5 - 0.004 * cigL, abs(c.y) + endRag); + let cshade = 0.50 + 0.50 * sqrt(max(1.0 - cx * cx * 0.92, 0.0)); + let hc = (c.y + len * 0.5) / cigL; + let chunkHeat = ember * 0.8 * exp(-t * 3.5); + let ccol = crust(vec2(cx * 7.0, cyL * 84.0 + 300.0), hc, chunkHeat, cshade, emberCol); + let ca = insideX * insideY * (1.0 - smoothstep(0.70, 1.0, chunkAge)); + col = mix(col, ccol, ca); + for (var i = 0; i < 6; i = i + 1) { + let fi = f32(i); + let h1 = hash21(vec2(fi, 1.0)); + let h2 = hash21(vec2(fi, 2.0)); + let h3 = hash21(vec2(fi, 3.0)); + let side = select(-1.0, 1.0, h1 > 0.5); + let vx = side * (0.03 + 0.08 * h2) + chunkKick * 0.10; + let fpos = origin + right * (cigW * 0.35 * side + vx * t) + + up * ((ashTopY - burnY) * h3 + 0.02 * h2 * t - (0.14 + 0.16 * h1) * t * t); + let r = length(p0 - fpos); + let sz = 0.0018 + 0.0022 * h3; + let flake = smoothstep(sz, sz * 0.3, r) * (1.0 - smoothstep(0.6, 1.0, chunkAge)); + let glowF = smoothstep(0.35, 0.0, chunkAge) * ember; + let fcol = mix(vec3(0.50, 0.48, 0.46), vec3(1.0, 0.45, 0.10), glowF * h2); + col = mix(col, fcol, flake); + } + } + + // ---- Frame: the faintest vignette, and dither so the black stays clean. ---- + let vd = (uv - vec2(0.5, 0.45)) * vec2(1.10, 1.00); + col = col * (1.0 - clamp(dot(vd, vd), 0.0, 1.0) * 0.5); + col = col + (hash21(uv * u.resolution.xy) - 0.5) * (2.0 / 255.0); + + return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); +} +`; diff --git a/example/src/hooks/useCigarettePhysics.ts b/example/src/hooks/useCigarettePhysics.ts new file mode 100644 index 0000000..d5fb185 --- /dev/null +++ b/example/src/hooks/useCigarettePhysics.ts @@ -0,0 +1,312 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Accelerometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** A cigarette left alone burns to the filter in this long (s). */ +const SMOLDER_TIME = 360; +/** Pulling on it burns the tobacco this much faster on top. */ +const DRAW_TIME = 20; +/** How much of the tobacco's length the ash can grow to before it drops. */ +const ASH_MAX = 0.26; +/** Press shorter than this is a tap (flick the ash / light up), not a drag. */ +const TAP_MAX = 0.22; +/** A drag only starts to pull once the finger has been down this long (s). */ +const DRAW_DELAY = 0.1; +const DRAW_IN_RATE = 4.5; +const DRAW_OUT_RATE = 3.0; +/** How long the after-drag surge of smoke takes to climb out of the picture (s). */ +const PUFF_DURATION = 3.4; +/** How long a dropped piece of ash stays in the picture (s). */ +const CHUNK_DURATION = 1.6; +/** Fade out the spent butt, fade in a fresh one (s). */ +const SWAP_DURATION = 0.9; +const SWAP_CROSS = 0.4; +/** Match-flare on lighting up (s). */ +const FLARE_TAU = 0.45; +/** Ember envelope rates: catching is slow, dying at the filter slower. */ +const EMBER_IN_RATE = 1.6; +const EMBER_OUT_RATE = 0.7; +/** Spring that carries the phone's in-plane tilt (rad). */ +const TILT_STIFFNESS = 120; +const TILT_DAMPING = 16; + +type CigaretteSim = { + lit: boolean; + ember: number; + burn: number; + ash: number; + pressing: boolean; + pressStart: number; + draw: number; + puff: number; + puffAge: number; + /** The piece of ash in the air: its length, sideways kick and age. */ + chunkLen: number; + chunkKick: number; + chunkAge: number; + swapAge: number; + flare: number; + /** In-plane gravity angle from the sensor (rad), confidence-blended. */ + tiltTarget: number; + tilt: number; + tiltVel: number; + /** Integrated turbulence phase — never time x varying speed. */ + phase: number; +}; + +/** + * The physics of a lit cigarette. + * + * It smoulders on its own: the coal creeps down the tobacco, the ash grows + * above it and breaks off by itself once it gets long. Hold a finger down to + * take a drag — the coal flares, the tobacco burns faster and the sidestream + * plume is pulled thin — and let go for the surge of smoke that follows. A + * tap knocks the ash off: the column detaches, tumbles and falls. Once the + * coal reaches the filter it goes out, and a tap swaps in a fresh one. + * + * The smoke rises toward the real ceiling: `atan2(x, -y)` is the same + * in-plane gravity angle the lighter uses, blended by confidence² so a phone + * lying flat holds its last angle, and carried on a stiff spring so the plume + * bends with a twist instead of snapping. + * + * Writes the live channel every frame: + * `u.live = (ember 0..1, draw 0..1, puff 0..1, tilt rad)`, + * `u.liveData[0] = (turbulencePhase, burn 0..1, ash 0..1, puffAge 0..1)`, + * `u.liveData[1] = (flick 0..1, chunkAge 0..1, flare 0..1, visibility 0..1)`, + * `u.liveData[2] = (chunkLen 0..1, chunkKick -1..1, 0, 0)`. + * `burn`, `ash` and `chunkLen` are fractions of the tobacco's length. + */ +export function useCigarettePhysics(): { + paramsSynchronizable: ReturnType< + typeof useParamsSynchronizable + >['paramsSynchronizable']; + pressIn: () => void; + pressOut: () => void; +} { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0]); + + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = { + lit: true, + ember: 0, + burn: 0.04, + ash: 0.05, + pressing: false, + pressStart: 0, + draw: 0, + puff: 0, + puffAge: 1, + chunkLen: 0, + chunkKick: 0, + chunkAge: 1, + swapAge: 1, + flare: 1, + tiltTarget: 0, + tilt: 0, + tiltVel: 0, + phase: 0, + }; + } + + // Accelerometer -> in-plane gravity angle. Upright portrait reads y ~ -1, + // and a positive angle means the phone's right-hand side has dropped. + useEffect(() => { + let sub: { remove: () => void } | null = null; + let cancelled = false; + + (async () => { + try { + const ok = await Accelerometer.isAvailableAsync(); + if (!ok || cancelled) { + return; + } + Accelerometer.setUpdateInterval(16); + sub = Accelerometer.addListener(({ x, y }) => { + const s = simRef.current as CigaretteSim; + const reading = Math.atan2(x, -y); + const conf = Math.min(1, Math.hypot(x, y) / 0.35); + + // Weak in-plane signal (phone near-flat) HOLDS the last angle. + let delta = reading - s.tiltTarget; + if (delta > Math.PI) { + delta -= 2 * Math.PI; + } else if (delta < -Math.PI) { + delta += 2 * Math.PI; + } + s.tiltTarget += delta * conf * conf; + }); + } catch { + // No accelerometer (simulator) -> the smoke just rises straight up. + } + })(); + + // The simulator has no accelerometer and no patience: let a debugger + // feed the tilt, and skip ahead along the burn. + if (__DEV__) { + const g = globalThis as { + __cigTilt?: (rad: number) => void; + __cigBurn?: (burn: number) => void; + }; + g.__cigTilt = (rad: number) => { + (simRef.current as CigaretteSim).tiltTarget = rad; + }; + g.__cigBurn = (burn: number) => { + (simRef.current as CigaretteSim).burn = burn; + }; + } + + return () => { + cancelled = true; + if (sub) { + sub.remove(); + } + if (__DEV__) { + const g = globalThis as { __cigTilt?: unknown; __cigBurn?: unknown }; + delete g.__cigTilt; + delete g.__cigBurn; + } + }; + }, []); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + + const step = (now: number) => { + const s = simRef.current as CigaretteSim; + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + + s.tiltVel += (s.tiltTarget - s.tilt) * TILT_STIFFNESS * dt; + s.tiltVel -= s.tiltVel * TILT_DAMPING * dt; + s.tilt += s.tiltVel * dt; + + // Drag envelope: only once the press has outlived a tap. + const held = s.pressing ? (now - s.pressStart) / 1000 : 0; + const drawing = s.lit && s.pressing && held > DRAW_DELAY; + const drawRate = drawing ? DRAW_IN_RATE : DRAW_OUT_RATE; + s.draw += ((drawing ? 1 : 0) - s.draw) * Math.min(1, drawRate * dt); + + // The coal eats the tobacco; the ash is whatever it leaves behind. + if (s.lit) { + const dBurn = dt * (1 / SMOLDER_TIME + s.draw / DRAW_TIME); + s.burn = Math.min(1, s.burn + dBurn); + s.ash = Math.min(ASH_MAX, s.ash + dBurn); + if (s.ash >= ASH_MAX) { + // Too long to hold itself up: it breaks off, barely kicked. + dropAsh(s, Math.random() < 0.5 ? -0.12 : 0.12); + } + if (s.burn >= 1) { + s.lit = false; + } + } + const emberRate = s.lit ? EMBER_IN_RATE : EMBER_OUT_RATE; + s.ember += ((s.lit ? 1 : 0) - s.ember) * Math.min(1, emberRate * dt); + if (!s.lit && s.ember < 0.002) { + s.ember = 0; + } + + // Swapping in a fresh one: fade the butt out, reset, fade in. + if (s.swapAge < 1) { + const before = s.swapAge; + s.swapAge = Math.min(1, s.swapAge + dt / SWAP_DURATION); + if (before < SWAP_CROSS && s.swapAge >= SWAP_CROSS) { + s.lit = true; + s.burn = 0; + s.ash = 0; + s.chunkAge = 1; + s.flare = 1; + s.puffAge = 1; + } + } + const vis = + s.swapAge >= 1 + ? 1 + : s.swapAge < SWAP_CROSS + ? 1 - s.swapAge / SWAP_CROSS + : (s.swapAge - SWAP_CROSS) / (1 - SWAP_CROSS); + + s.puffAge = Math.min(1, s.puffAge + dt / PUFF_DURATION); + s.chunkAge = Math.min(1, s.chunkAge + dt / CHUNK_DURATION); + s.flare *= Math.exp(-dt / FLARE_TAU); + + const puff = s.puffAge >= 1 ? 0 : s.puff * Math.pow(1 - s.puffAge, 0.7); + const flick = s.chunkAge >= 1 ? 0 : 1; + + // Turbulence runs faster while air is being pulled through the tip. + s.phase += dt * (0.75 + 0.45 * s.draw + 0.35 * puff); + + setParamsSynchronizable( + s.ember, + s.draw, + puff, + s.tilt, + s.phase, + s.burn, + s.ash, + s.puffAge, + flick, + s.chunkAge, + s.flare, + vis, + s.chunkLen, + s.chunkKick, + 0, + 0 + ); + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [setParamsSynchronizable]); + + const pressIn = useCallback(() => { + const s = simRef.current as CigaretteSim; + s.pressing = true; + s.pressStart = performance.now(); + }, []); + + const pressOut = useCallback(() => { + const s = simRef.current as CigaretteSim; + if (!s.pressing) { + return; + } + s.pressing = false; + const held = (performance.now() - s.pressStart) / 1000; + + if (held < TAP_MAX) { + if (!s.lit && s.swapAge >= 1) { + // Spent, or dying: swap in a fresh one. + s.swapAge = 0; + } else if (s.lit) { + dropAsh(s, Math.random() < 0.5 ? -1 : 1); + } + return; + } + + // Letting go after a real drag: the coal is at its hottest and the + // sidestream surges. + if (s.draw > 0.3) { + s.puff = s.draw; + s.puffAge = 0; + } + }, []); + + return { paramsSynchronizable, pressIn, pressOut }; +} + +/** Knock the ash column off. `kick` is the sideways impulse, -1..1. */ +function dropAsh(s: CigaretteSim, kick: number) { + if (s.ash < 0.015) { + return; + } + s.chunkLen = s.ash; + s.chunkKick = kick; + s.chunkAge = 0; + s.ash = 0; + // Freshly bared coal glows brighter for a moment. + s.flare = Math.max(s.flare, 0.45); +} diff --git a/example/src/screens/CigaretteScreen.tsx b/example/src/screens/CigaretteScreen.tsx new file mode 100644 index 0000000..db8d35d --- /dev/null +++ b/example/src/screens/CigaretteScreen.tsx @@ -0,0 +1,100 @@ +import { useMemo } from 'react'; +import { + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, +} from 'react-native'; +import Cigarette, { + CIG_ASPECT, + FILTER_FRAC, + HEADROOM, + PANE_SCALE, +} from '../components/cigarette/Cigarette'; +import Smoke from '../components/cigarette/Smoke'; +import { useCigarettePhysics } from '../hooks/useCigarettePhysics'; + +/** The cigarette's width on screen, in points. */ +const CIG_W = 68; +/** Where the unburnt tip sits, as a fraction of the screen's height from the top. */ +const TIP_FRAC = 0.55; + +/** + * A cigarette smouldering in a dark room. + * + * Hold a finger anywhere to take a drag and let go to exhale; tap to flick + * the ash. It burns down on its own, drops its ash when it gets long and + * goes out at the filter — tap to light another. Tilt the phone and the + * smoke keeps rising toward the real ceiling. No chrome: back is the native + * edge swipe. + */ +export default function CigaretteScreen() { + const { width, height } = useWindowDimensions(); + const { paramsSynchronizable, pressIn, pressOut } = useCigarettePhysics(); + + const cigL = CIG_W * CIG_ASPECT; + const viewH = cigL * HEADROOM; + const tipTop = Math.round(height * TIP_FRAC); + const layout = useMemo( + () => ({ + left: Math.round(width / 2 - (CIG_W * PANE_SCALE) / 2), + top: tipTop - (viewH - cigL), + width: CIG_W * PANE_SCALE, + height: viewH, + }), + [width, tipTop, viewH, cigL] + ); + + const smokeParams = useMemo( + () => [ + 0.5, + 1 - tipTop / height, + CIG_W / height, + (cigL * (1 - FILTER_FRAC)) / height, + ], + [tipTop, height, cigL] + ); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + cigarette: { + position: 'absolute', + }, + hint: { + position: 'absolute', + top: 64, + left: 0, + right: 0, + textAlign: 'center', + color: 'rgba(255,255,255,0.28)', + fontSize: 13, + letterSpacing: 0.4, + }, +}); diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 932c17a..1d2b33d 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -11,6 +11,14 @@ import { useNavigation } from '@react-navigation/native'; import type { ExampleCategory, HomeScreenNavigationProp } from '../types'; const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'cigarette', + title: 'Cigarette', + description: 'Hold to drag, tap to flick the ash, tilt the smoke', + screen: 'CigaretteExample', + color: '#ff6a1a', + image: require('../../assets/components/cigarette.png'), + }, { id: 'magic-8-ball', title: 'Magic 8-ball', diff --git a/example/src/types.ts b/example/src/types.ts index 45e2380..72daa18 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -40,6 +40,7 @@ export type RootStackParamList = { VoiceDeleteExample: undefined; SnowGlobeExample: undefined; Magic8BallExample: undefined; + CigaretteExample: undefined; }; export type HomeScreenNavigationProp =