From a5bf60cc4358702d402b69d05f8f8befe87fa338 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sat, 25 Jul 2026 23:59:31 +1000 Subject: [PATCH] perf: bake the radial gradients into sprites, short-circuit the flat field grid --- src/bolts.js | 15 ++------ src/overload.js | 12 +++--- src/radicals.js | 12 +++--- src/render.js | 78 ++++++++++++++++++--------------------- src/sprites.js | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 67 deletions(-) create mode 100644 src/sprites.js diff --git a/src/bolts.js b/src/bolts.js index 5621db2..1ce1845 100644 --- a/src/bolts.js +++ b/src/bolts.js @@ -4,6 +4,7 @@ import { FX } from './fx.js'; import { Audio } from './audio.js'; import { Juice } from './juice.js'; import { Overload } from './overload.js'; +import { softSprite, blit } from './sprites.js'; // ============================================================ // LASERHANDS. @@ -197,12 +198,8 @@ export class Bolts { ctx.stroke(); } // head - const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, CFG.BOLT_R * 4); - g.addColorStop(0, rgba('#ffffff', 0.95)); - g.addColorStop(0.35, rgba(col, 0.8)); - g.addColorStop(1, rgba(col, 0)); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(b.x, b.y, CFG.BOLT_R * 4, 0, Math.PI * 2); ctx.fill(); + blit(ctx, softSprite(col), b.x, b.y, CFG.BOLT_R * 4, 0.8); + blit(ctx, softSprite('#ffffff'), b.x, b.y, CFG.BOLT_R * 1.6, 0.95); } // ---- muzzle flash ---- @@ -211,11 +208,7 @@ export class Bolts { const a = this.muzzle / CFG.MUZZLE_LIFE; const mx = m.x + this.muzzleDir.x * (m.r + 4); const my = m.y + this.muzzleDir.y * (m.r + 4); - const g = ctx.createRadialGradient(mx, my, 0, mx, my, 26 * a); - g.addColorStop(0, rgba('#ffffff', 0.85 * a)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(mx, my, 26 * a, 0, Math.PI * 2); ctx.fill(); + blit(ctx, softSprite('#ffffff'), mx, my, 26 * a, 0.85 * a); } // ---- the ammo ring: 8 pips, spins faster as it empties (nervous) ---- diff --git a/src/overload.js b/src/overload.js index 0f90482..fa85ae4 100644 --- a/src/overload.js +++ b/src/overload.js @@ -5,6 +5,7 @@ import { Audio } from './audio.js'; import { Juice } from './juice.js'; import { Radicals } from './radicals.js'; import { unbond } from './particle.js'; +import { softSprite, blit } from './sprites.js'; // ============================================================ // OVERLOAD. @@ -121,14 +122,11 @@ export class Overload { const k = 1 - p.fuse / p.fuseMax; // 0..1 toward detonation const pulse = 0.5 + Math.sin(world.time * (14 + k * 46)) * 0.5; - // white-hot core + // white-hot core — baked sprites, layered, so a screen full of fuses + // doesn't allocate a gradient per fuse per frame const R = p.r * (1.5 + k * 1.4 + pulse * 0.35); - const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R * 2); - g.addColorStop(0, `rgba(255,255,255,${0.55 + k * 0.4})`); - g.addColorStop(0.4, rgba(CFG.COL_POS, 0.4 + k * 0.35)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(p.x, p.y, R * 2, 0, Math.PI * 2); ctx.fill(); + blit(ctx, softSprite(CFG.COL_POS), p.x, p.y, R * 2, 0.4 + k * 0.35); + blit(ctx, softSprite('#FFFFFF'), p.x, p.y, R * 0.9, 0.55 + k * 0.4); // the blast radius you're about to be inside of ctx.strokeStyle = `rgba(255,45,155,${0.1 + k * 0.4})`; diff --git a/src/radicals.js b/src/radicals.js index fc1dcab..2b5008b 100644 --- a/src/radicals.js +++ b/src/radicals.js @@ -2,6 +2,7 @@ import { CFG } from './config.js'; import { FX } from './fx.js'; import { Audio } from './audio.js'; import { Juice } from './juice.js'; +import { softSprite, blit } from './sprites.js'; // ============================================================ // THE RADICAL. @@ -312,13 +313,10 @@ export class Radicals { ctx.lineWidth = 2; ctx.stroke(); - // the only pure-white glow in the game - const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R * 2.1); - g.addColorStop(0, `rgba(255,255,255,${0.5 * flick})`); - g.addColorStop(0.45, `rgba(255,255,255,${0.16 * flick})`); - g.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(p.x, p.y, R * 2.1, 0, Math.PI * 2); ctx.fill(); + // the only pure-white glow in the game — one baked sprite, not a fresh + // gradient per radical per frame (at 30 radicals that was 30 allocations + // and 30 gradient rasterisations every frame) + blit(ctx, softSprite('#FFFFFF'), p.x, p.y, R * 2.1, 0.5 * flick); // solid white core — unmistakable ctx.fillStyle = `rgba(255,255,255,${0.92 * flick})`; diff --git a/src/render.js b/src/render.js index 0d70b8f..9453a76 100644 --- a/src/render.js +++ b/src/render.js @@ -1,6 +1,7 @@ import { CFG } from './config.js'; import { clamp, mixHex, rgba, lerp } from './util.js'; import { FX } from './fx.js'; +import { bodySprite, glowSprite, haloSprite, softSprite, blit } from './sprites.js'; // charge -> color, continuous over displayCharge (-1..1) function chargeColor(dc) { @@ -23,12 +24,7 @@ function drawBody(ctx, x, y, r, dc, spin, col, fill) { // eats the element colours and the radicals' white. if (neg > 0.03) { const pulse = 1 + Math.sin(spin * 3) * 0.07; - const R = r * (1 + 0.95 * neg) * pulse; - const g = ctx.createRadialGradient(x, y, r * 0.65, x, y, R); - g.addColorStop(0, rgba(CFG.COL_NEG, 0.30 * neg)); - g.addColorStop(1, rgba(CFG.COL_NEG, 0)); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill(); + blit(ctx, haloSprite(CFG.COL_NEG), x, y, r * (1 + 0.95 * neg) * pulse, 0.30 * neg); } // positive: spiky outline @@ -51,18 +47,18 @@ function drawBody(ctx, x, y, r, dc, spin, col, fill) { // core — ELEMENT FILL. Matte and never blooms, but SATURATED: these twelve // hues are the game's identity and they have to survive on a dark ground. + if (fill) { + // pre-baked: same radial shading, none of the per-frame allocation + ctx.drawImage(bodySprite(fill, r), x - r, y - r, r * 2, r * 2); + } else { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = rgba('#0b1118', 0.9); + ctx.fill(); + } + // CHARGE RIM ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); - if (fill) { - // radial: brighter at the top-left shoulder so it reads as a solid body - const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.15, x, y, r); - g.addColorStop(0, mixHex(fill, '#FFFFFF', 0.28)); - g.addColorStop(0.55, fill); - g.addColorStop(1, mixHex('#0B0716', fill, 0.55)); - ctx.fillStyle = g; - } else ctx.fillStyle = rgba('#0b1118', 0.9); - ctx.fill(); - // CHARGE RIM ctx.strokeStyle = col; ctx.lineWidth = neg > 0.3 || pos > 0.3 ? 2.2 : 1.4; ctx.stroke(); @@ -81,12 +77,7 @@ function drawBody(ctx, x, y, r, dc, spin, col, fill) { if (chg > 0.05) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; - const g = ctx.createRadialGradient(x, y, r * 0.72, x, y, r * 1.5); - g.addColorStop(0, 'rgba(0,0,0,0)'); - g.addColorStop(0.5, rgba(pos > neg ? CFG.COL_POS : CFG.COL_NEG, 0.42 * chg)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(x, y, r * 1.5, 0, Math.PI * 2); ctx.fill(); + blit(ctx, glowSprite(pos > neg ? CFG.COL_POS : CFG.COL_NEG), x, y, r * 1.5, 0.42 * chg); ctx.restore(); } } @@ -118,6 +109,25 @@ function drawField(ctx, world) { for (const p of world.particles) if (p.charge) src.push(p); if (world.molly.charge) src.push(world.molly); + // FAST PATH: nothing charged means zero displacement everywhere, so the + // warped lattice is just... a lattice. Walking 627 nodes to emit ~1250 + // antialiased polyline segments to draw the same thing 62 straight lines + // draw is pure waste, and it's the whole cost of a tutorial room where a + // single atom is on screen. + if (!src.length) { + ctx.strokeStyle = CFG.COL_GRID; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = B.x; x <= B.x + B.w; x += FIELD_STEP) { + ctx.moveTo(x, B.y); ctx.lineTo(x, B.y + B.h); + } + for (let y = B.y; y <= B.y + B.h; y += FIELD_STEP) { + ctx.moveTo(B.x, y); ctx.lineTo(B.x + B.w, y); + } + ctx.stroke(); + return; + } + const R2 = FIELD_REACH * FIELD_REACH; for (let j = 0; j <= rows; j++) { for (let i = 0; i <= cols; i++) { @@ -181,6 +191,7 @@ function drawField(ctx, world) { // corona and core sit on a dark hole instead of on top of a coloured atom. const RAD_FILL = '#241F2E'; + function drawFlash(ctx, p) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; @@ -324,24 +335,11 @@ function drawMolly(ctx, world, t) { const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge); // the hole: drawn straight, NOT additive, so brightness around her can't eat it - ctx.save(); - const dg = ctx.createRadialGradient(m.x, m.y, m.r + 4, m.x, m.y, m.r + 30); - dg.addColorStop(0, 'rgba(6,4,14,0.85)'); - dg.addColorStop(1, 'rgba(6,4,14,0)'); - ctx.fillStyle = dg; - ctx.beginPath(); ctx.arc(m.x, m.y, m.r + 30, 0, Math.PI * 2); ctx.fill(); - ctx.restore(); + blit(ctx, haloSprite('#06040E'), m.x, m.y, m.r + 30, 0.85); ctx.save(); ctx.globalCompositeOperation = 'lighter'; - - const R = m.r * 5.2; - const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R); - g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30)); - g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill(); + blit(ctx, softSprite(CFG.COL_MOLLY), m.x, m.y, m.r * 5.2, 0.34); // two arcs sweeping one way, two the other — motion the eye catches in chaos ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.55); @@ -370,11 +368,7 @@ function drawMolly(ctx, world, t) { if (alive) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; - const g2 = ctx.createRadialGradient(x, y, 0, x, y, 7); - g2.addColorStop(0, rgba('#7FE3C4', 0.95)); - g2.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g2; - ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); + blit(ctx, softSprite('#7FE3C4'), x, y, 7, 0.95); ctx.restore(); } ctx.beginPath(); diff --git a/src/sprites.js b/src/sprites.js new file mode 100644 index 0000000..8a0dd36 --- /dev/null +++ b/src/sprites.js @@ -0,0 +1,97 @@ +import { mixHex, rgba } from './util.js'; + +// ============================================================ +// SPRITE CACHE. +// +// Every radial fill in this game used to be a createRadialGradient() call +// made fresh each frame, per body. At 116 atoms that is 150-250 gradient +// objects allocated AND rasterised every frame, which measured 12.9ms of +// pure raster on an M3 Ultra — a whole frame budget spent before anything +// else drew, and the reason a ONE-ATOM tutorial room ran at 30fps. +// +// The design doc called it from the start: pre-render the glows and blit +// them, never createRadialGradient per frame. There are only ~14 distinct +// fills (twelve elements + Molly + the radical hole) and two charge +// colours, so the whole game needs a handful of sprites baked once. +// +// Lives in its own module rather than render.js because radicals.js needs +// it too, and radicals.js is pulled into the headless test graph — the +// renderer must never follow it there. Nothing here touches the DOM until +// a sprite is actually requested, so importing this in Node is safe. +// ============================================================ + +const SPR = new Map(); +const SS = 3; // supersample: stays crisp when the camera zooms + +function sprite(key, size, paint) { + let c = SPR.get(key); + if (c) return c; + c = document.createElement('canvas'); + c.width = c.height = size; + paint(c.getContext('2d'), size); + SPR.set(key, c); + return c; +} + +// the element core, radially shaded — brighter at the top-left shoulder +export function bodySprite(fill, r) { + const size = Math.max(8, Math.ceil(r * 2 * SS)); + return sprite(`b|${fill}|${r}`, size, (g, s) => { + const R = s / 2; + const grad = g.createRadialGradient(R - R * 0.3, R - R * 0.35, R * 0.15, R, R, R); + grad.addColorStop(0, mixHex(fill, '#FFFFFF', 0.28)); + grad.addColorStop(0.55, fill); + grad.addColorStop(1, mixHex('#0B0716', fill, 0.55)); + g.fillStyle = grad; + g.beginPath(); g.arc(R, R, R, 0, Math.PI * 2); g.fill(); + }); +} + +// the charge rim-light: an annulus baked at full alpha, modulated with +// globalAlpha at blit time so one sprite covers every charge level +export function glowSprite(col) { + return sprite(`g|${col}`, 128, (g, s) => { + const R = s / 2; + const grad = g.createRadialGradient(R, R, 0, R, R, R); + grad.addColorStop(0, 'rgba(0,0,0,0)'); + grad.addColorStop(0.48, 'rgba(0,0,0,0)'); + grad.addColorStop(0.74, rgba(col, 1)); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = grad; + g.beginPath(); g.arc(R, R, R, 0, Math.PI * 2); g.fill(); + }); +} + +// the negative body's soft halo +export function haloSprite(col) { + return sprite(`h|${col}`, 128, (g, s) => { + const R = s / 2; + const grad = g.createRadialGradient(R, R, R * 0.4, R, R, R); + grad.addColorStop(0, rgba(col, 1)); + grad.addColorStop(1, rgba(col, 0)); + g.fillStyle = grad; + g.beginPath(); g.arc(R, R, R, 0, Math.PI * 2); g.fill(); + }); +} + +// a plain soft disc — anything that just needs a falloff blob +export function softSprite(col) { + return sprite(`s|${col}`, 128, (g, s) => { + const R = s / 2; + const grad = g.createRadialGradient(R, R, 0, R, R, R); + grad.addColorStop(0, rgba(col, 1)); + grad.addColorStop(0.45, rgba(col, 0.32)); + grad.addColorStop(1, rgba(col, 0)); + g.fillStyle = grad; + g.beginPath(); g.arc(R, R, R, 0, Math.PI * 2); g.fill(); + }); +} + +// blit a cached sprite centred on (x,y) at the given radius +export function blit(ctx, spr, x, y, r, alpha = 1) { + if (alpha <= 0.003) return; + const a = ctx.globalAlpha; + ctx.globalAlpha = a * (alpha < 1 ? alpha : 1); + ctx.drawImage(spr, x - r, y - r, r * 2, r * 2); + ctx.globalAlpha = a; +}