// core.js: constants, helpers, paper, paint wrapper, compositing and render hooks. const W = 1920, H = 1080; const BPM = 88, BEAT = 60 / BPM, OFF = 0.21, BOIL = 12, DUR = 156.6; const TAU = Math.PI * 2; const PAL = { paper: '#F3EBDC', ink: '#2B2233', clay: '#D97757', clayDk: '#A84D33', clayLt: '#F2A283', night: '#1F2550', indigo: '#2F3C7A', rose: '#E27A92', ochre: '#E8AA38', sap: '#6E9F58', teal: '#3A9C98', violet: '#7B5CA8', cream: '#FFF5E2', sky: '#8EC3E6' }; const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x)); const lerp = (a, b, x) => a + (b - a) * x; const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); }; const easeOut = x => 1 - Math.pow(1 - clamp(x), 3); const backOut = x => { x = clamp(x); const s = 1.9; return 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2); }; const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }; const bpOf = t => (t - OFF) / BEAT; // Seeded by the boil frame, so linework "boils" at BOIL fps like hand-drawn animation. const jit = a => (random() * 2 - 1) * a; // ---------- timing helpers (everything is a pure function of t; no state survives between frames) ---------- const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b] const frac = x => x - Math.floor(x); const beatN = t => Math.floor(bpOf(t)); // integer beat index const pulse = (t, k = 6) => Math.exp(-frac(bpOf(t)) * k); // 1 exactly on each beat, decays after const pulse2 = (t, k = 6) => Math.exp(-frac(bpOf(t) * 2) * k); // same on eighth notes const wob = (t, f = 1, ph = 0) => Math.sin((t * f + ph) * TAU); const easeIn = x => Math.pow(clamp(x), 3); const elasticOut = x => { x = clamp(x); return x === 0 || x === 1 ? x : Math.pow(2, -10 * x) * Math.sin((x * 10 - .75) * (TAU / 3)) + 1; }; // keyframes: kf(t, [[t0, v0], [t1, v1], ...], easeFn). Values may be numbers or arrays of numbers. function kf(t, keys, e = ease) { if (t <= keys[0][0]) return keys[0][1]; for (let i = 1; i < keys.length; i++) { if (t < keys[i][0]) { const [a, va] = keys[i - 1], [b, vb] = keys[i], k = e((t - a) / (b - a)); return Array.isArray(va) ? va.map((v, j) => lerp(v, vb[j], k)) : lerp(va, vb, k); } } return keys[keys.length - 1][1]; } // hex color mix function mixCol(a, b, k) { const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16), c = i => Math.round(lerp((pa >> i) & 255, (pb >> i) & 255, clamp(k))); return '#' + ((1 << 24) + (c(16) << 16) + (c(8) << 8) + c(0)).toString(16).slice(1); } // small deterministic camera shake, changes at 24 fps const shakeXY = (t, amt) => { const f = Math.floor(t * 24); return [(hash(f * 1.7) - .5) * 2 * amt, (hash(f * 2.3 + 9) - .5) * 2 * amt]; }; // ---------- camera ---------- // camBegin(cx, cy, zoom, rot): world point (cx, cy) lands at screen centre. Letters queued while a camera is // active are placed through it automatically (pass {screen:true} to opt out). One level only: always pair with camEnd(). let CAM = null; function camBegin(cx = W / 2, cy = H / 2, zoom = 1, rot = 0) { push(); translate(W / 2, H / 2); rotate(rot); scale(zoom); translate(-cx, -cy); CAM = { cx, cy, zoom, rot }; } function camEnd() { pop(); CAM = null; } function toScreen(x, y) { if (!CAM) return [x, y]; const c = Math.cos(CAM.rot), s = Math.sin(CAM.rot), dx = (x - CAM.cx) * CAM.zoom, dy = (y - CAM.cy) * CAM.zoom; return [W / 2 + dx * c - dy * s, H / 2 + dx * s + dy * c]; } // ---------- full-frame effects (call outside a camera, in screen space) ---------- function flash(k, col = '#FFFDF6') { if (k > .01) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, washOp: 255 * clamp(k), ink: null }); if (k > .6) METER_SHOWN = true; } // Paint everything OUTSIDE a star-shaped hole (irises, mouth-shaped reveals, keyholes). function irisShape(pts, col = PAL.ink, far = 4000) { const n = pts.length; let cx = 0, cy = 0; for (const p of pts) { cx += p[0]; cy += p[1]; } cx /= n; cy /= n; const out = p => { const dx = p[0] - cx, dy = p[1] - cy, d = Math.hypot(dx, dy) || 1; return [cx + dx / d * far, cy + dy / d * far]; }; for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % n], ex = (b[0] - a[0]) * .06, ey = (b[1] - a[1]) * .06; const a2 = [a[0] - ex, a[1] - ey], b2 = [b[0] + ex, b[1] + ey]; paint([a2, b2, out(b2), out(a2)], { wash: col, washOp: 255, ink: null }); } } function iris(cx, cy, r, col = PAL.ink) { if (r < 60) METER_SHOWN = true; if (r < 4) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, ink: null }); else irisShape(ellPts(cx, cy, r, r, 40), col); } let T = 0, paperG = null, grainC = null, letG = null, outC = null, outX = null; let LETTERS = [], KARAOKE = null; // ---------- geometry ---------- function rectPts(x, y, w, h, j = 0) { return [[x + jit(j), y + jit(j)], [x + w / 2 + jit(j), y + jit(j) * .5], [x + w + jit(j), y + jit(j)], [x + w + jit(j) * .5, y + h / 2], [x + w + jit(j), y + h + jit(j)], [x + w / 2 + jit(j), y + h + jit(j) * .5], [x + jit(j), y + h + jit(j)], [x + jit(j) * .5, y + h / 2]]; } function ellPts(cx, cy, rx, ry, n = 28, j = 0, rot = 0) { const p = []; for (let i = 0; i < n; i++) { const a = rot + i / n * TAU; p.push([cx + Math.cos(a) * rx + jit(j), cy + Math.sin(a) * ry + jit(j)]); } return p; } function rrPts(x, y, w, h, r, j = 0) { const p = [], seg = 5, corner = (cx, cy, a0) => { for (let i = 0; i <= seg; i++) { const a = a0 + i / seg * Math.PI / 2; p.push([cx + Math.cos(a) * r + jit(j), cy + Math.sin(a) * r + jit(j)]); } }; corner(x + w - r, y + r, -Math.PI / 2); corner(x + w - r, y + h - r, 0); corner(x + r, y + h - r, Math.PI / 2); corner(x + r, y + r, Math.PI); return p; } function starPts(cx, cy, r, inner = .38, n = 4, rot = -Math.PI / 2) { const p = []; for (let i = 0; i < n * 2; i++) { const a = rot + i * Math.PI / n, q = i % 2 ? r * inner : r; p.push([cx + Math.cos(a) * q, cy + Math.sin(a) * q]); } return p; } // ---------- paint wrapper ---------- // One call = one painted shape: optional flat wash, optional watercolor fill, optional hatch, optional ink outline. function paint(pts, o = {}) { if (o.wash || o.fill || o.hatch) { if (o.wash) brush.wash(o.wash, o.washOp ?? 255); else brush.noWash(); if (o.fill) { brush.fill(o.fill, o.fillOp ?? 170); brush.fillBleed(o.bleed ?? .1); brush.fillTexture(o.tex ?? .4, o.border ?? .35); } else brush.noFill(); if (o.hatch) { brush.hatch(o.hatch.d, o.hatch.a, o.hatch.o || { rand: .15 }); brush.hatchStyle(o.hatch.b || 'HB', o.hatch.c || PAL.ink, o.hatch.w || 1); } else brush.noHatch(); brush.noStroke(); if (o.curv) { brush.beginShape(o.curv); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true); } else brush.polygon(pts); } // the outline is one continuous tapered stroke, not a stroke per side if (o.ink !== null) { brush.noWash(); brush.noFill(); brush.noHatch(); brush.set(o.br || 'ink', o.ink || PAL.ink, o.sw ?? 1); brush.beginShape(o.curv || 0); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true); } } function inkLine(pts, sw = 1, col = PAL.ink, br = 'ink', curv = .5) { brush.noFill(); brush.noWash(); brush.noHatch(); brush.set(br, col, sw); brush.spline(pts, curv); } // ---------- lettering (drawn on the 2D compositor, under the paper grain) ---------- function letter(txt, x, y, size, color, o = {}) { if (CAM && !o.screen) { [x, y] = toScreen(x, y); size *= CAM.zoom; o = { ...o, rot: (o.rot || 0) + CAM.rot }; if (o.font) o.font = o.font.replace(/(\d+(\.\d+)?)px/, (m, v) => (v * CAM.zoom) + 'px'); } LETTERS.push({ txt, x, y, size, color, ...o }); } // Comic sound effect: pops in at age 0, wobbles, fades by `life` seconds. function sfx(txt, x, y, size, color, age, o = {}) { const life = o.life ?? 1.2; if (age < 0 || age > life) return; letter(txt, x, y, size, color, { pop: age * 5, rot: (o.rot ?? -.08) + Math.sin(age * 20) * .03 * (1 - age / life), alpha: 1 - seg(age, life - .25, life), ...o }); } function drawLetters(c) { for (const L of LETTERS) { const k = L.pop != null ? backOut(L.pop) : 1; if (k <= .01) continue; c.save(); c.translate(L.x, L.y); c.rotate(L.rot || 0); c.scale(k, k); c.globalAlpha = L.alpha ?? 1; c.font = L.font || `${L.size}px "Permanent Marker", "Comic Sans MS", cursive`; c.textAlign = L.align || 'center'; c.textBaseline = 'middle'; if (L.stroke) { c.lineJoin = 'round'; c.lineWidth = L.size * .12; c.strokeStyle = L.stroke; c.strokeText(L.txt, 0, 0); } if (L.ink !== false) { c.fillStyle = PAL.ink; c.fillText(L.txt, L.size * .045, L.size * .055); } c.fillStyle = L.color; c.fillText(L.txt, 0, 0); c.restore(); } } // Paint the queued lettering into the scene itself, so later layers (wipes) cover it. function flushLetters() { if (!LETTERS.length) return; letG.clear(); drawLetters(letG.drawingContext); LETTERS = []; // p5.brush defers washes and strokes into a mask layer; a (tiny, off-screen) watercolor fill forces it to composite // now, so everything painted before this call really lands under the letters. // Letters are already in screen space, so composite them with the base transform even inside camBegin(). push(); resetMatrix(); translate(-W / 2, -H / 2); brush.noStroke(); brush.noHatch(); brush.noWash(); brush.fill('#000000', 1); brush.fillBleed(0); brush.fillTexture(0, 0); brush.polygon([[-50, -50], [-40, -50], [-40, -40]]); brush.noFill(); image(letG, 0, 0); pop(); } // ---------- paper ---------- function lcg(seed) { let s = seed; return () => (s = (s * 16807) % 2147483647) / 2147483647; } function makePaper() { const g = createGraphics(W, H); g.pixelDensity(1); const c = g.drawingContext, rnd = lcg(11); c.fillStyle = PAL.paper; c.fillRect(0, 0, W, H); for (let i = 0; i < 70; i++) { const x = rnd() * W, y = rnd() * H, r = 120 + rnd() * 380, gr = c.createRadialGradient(x, y, 0, x, y, r), a = .045 * rnd(); gr.addColorStop(0, `rgba(160,125,80,${a})`); gr.addColorStop(1, 'rgba(160,125,80,0)'); c.fillStyle = gr; c.fillRect(x - r, y - r, 2 * r, 2 * r); } c.lineWidth = 1; for (let i = 0; i < 1400; i++) { const x = rnd() * W, y = rnd() * H, l = 6 + rnd() * 26, a = rnd() * TAU; c.strokeStyle = `rgba(110,88,60,${.035 + rnd() * .06})`; c.beginPath(); c.moveTo(x, y); c.quadraticCurveTo(x + Math.cos(a + .6) * l * .5, y + Math.sin(a + .6) * l * .5, x + Math.cos(a) * l, y + Math.sin(a) * l); c.stroke(); } return g; } // Static grain + vignette, multiplied over the painted frame so pigment sits "in" the paper. function makeGrain() { const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const c = cv.getContext('2d'), rnd = lcg(5); const id = c.createImageData(W, H), d = id.data; for (let i = 0; i < d.length; i += 4) { const v = 255 - (rnd() < .55 ? rnd() * rnd() * 34 : 0); d[i] = v; d[i + 1] = v - 1; d[i + 2] = v - 3; d[i + 3] = 255; } c.putImageData(id, 0, 0); const g = c.createRadialGradient(W / 2, H / 2, H * .45, W / 2, H / 2, H * 1.05); g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(120,95,70,.35)'); c.fillStyle = g; c.fillRect(0, 0, W, H); return cv; } // ---------- custom brushes ---------- function defineBrushes() { brush.add('ink', { type: 'default', weight: 5, scatter: .25, sharpness: .8, grain: 40, opacity: 235, spacing: .2, pressure: [1.15, .75], rotate: 'natural', noise: .15 }); brush.add('inkfine', { type: 'default', weight: 2.6, scatter: .15, sharpness: .85, grain: 40, opacity: 230, spacing: .2, pressure: [1.1, .8], rotate: 'natural', noise: .1 }); brush.add('dry', { type: 'default', weight: 14, scatter: 3, sharpness: .3, grain: 6, opacity: 90, spacing: .6, pressure: [1, .6], rotate: 'natural', noise: .4 }); } // ---------- frame ---------- async function setup() { createCanvas(W, H, WEBGL); pixelDensity(1); noLoop(); brush.scaleBrushes(5); defineBrushes(); paperG = makePaper(); grainC = makeGrain(); letG = createGraphics(W, H); letG.pixelDensity(1); outC = document.getElementById('out'); outX = outC.getContext('2d'); await Promise.all([document.fonts.load('100px "Permanent Marker"'), document.fonts.load('800 50px "Shantell Sans"')]); window.ready = true; if (!location.search.includes('render')) devUI(); } function draw() { if (!window.ready) return; LETTERS = []; KARAOKE = null; METER_SHOWN = false; CAM = null; push(); translate(-W / 2, -H / 2); randomSeed(1000 + Math.floor(T * BOIL)); noiseSeed(77); image(paperG, 0, 0); drawWorld(T); pop(); } function composite(t) { const c = outX; c.globalCompositeOperation = 'source-over'; c.globalAlpha = 1; c.drawImage(drawingContext.canvas, 0, 0, W, H); drawLetters(c); c.globalCompositeOperation = 'multiply'; c.drawImage(grainC, 0, 0); c.globalCompositeOperation = 'source-over'; drawKaraokeText(c); } window.renderAt = async (t, type = 'image/png', q = .92) => { T = t; await redraw(); composite(t); return outC.toDataURL(type, q); }; // Contact sheet of several times, for quick visual checks: returns { url, ms[] }. window.renderSheet = async (times, cols = 3, w = 640) => { const h = Math.round(w * 9 / 16), rows = Math.ceil(times.length / cols), sc = document.createElement('canvas'); sc.width = cols * w; sc.height = rows * h; const c = sc.getContext('2d'), ms = []; for (let i = 0; i < times.length; i++) { const t0 = performance.now(); T = times[i]; await redraw(); composite(times[i]); ms.push(Math.round(performance.now() - t0)); const x = (i % cols) * w, y = Math.floor(i / cols) * h; c.drawImage(outC, x, y, w, h); c.fillStyle = 'rgba(0,0,0,.65)'; c.fillRect(x, y, 96, 26); c.fillStyle = '#fff'; c.font = '16px sans-serif'; c.fillText(times[i].toFixed(2) + 's', x + 6, y + 18); } return { url: sc.toDataURL('image/jpeg', .88), ms }; }; window.gpuInfo = () => { const gl = drawingContext, e = gl.getExtension('WEBGL_debug_renderer_info'); return e ? gl.getParameter(e.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); }; function devUI() { const s = document.getElementById('scrub'), lab = document.getElementById('tt'); let busy = false, want = null; const go = async () => { if (busy) return; busy = true; while (want != null) { const t = want; want = null; const t0 = performance.now(); await window.renderAt(t); lab.textContent = `${t.toFixed(2)}s ยท ${Math.round(performance.now() - t0)} ms/frame`; } busy = false; }; s.addEventListener('input', () => { want = +s.value; go(); }); want = +(new URLSearchParams(location.search).get('t') || 0); s.value = want; go(); }