perf: bake the radial gradients into sprites, short-circuit the flat field grid

This commit is contained in:
m3ultra 2026-07-25 23:59:31 +10:00
parent 802879fc3e
commit a5bf60cc43
5 changed files with 147 additions and 67 deletions

View File

@ -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) ----

View File

@ -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})`;

View File

@ -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})`;

View File

@ -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();

97
src/sprites.js Normal file
View File

@ -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;
}