perf: bake the radial gradients into sprites, short-circuit the flat field grid
This commit is contained in:
parent
802879fc3e
commit
a5bf60cc43
15
src/bolts.js
15
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) ----
|
||||
|
||||
@ -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})`;
|
||||
|
||||
@ -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})`;
|
||||
|
||||
@ -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);
|
||||
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.fillStyle = rgba('#0b1118', 0.9);
|
||||
ctx.fill();
|
||||
}
|
||||
// CHARGE RIM
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
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
97
src/sprites.js
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user