15
Plasma Bloom
move mouse to shift, keys 1-5 swap palette, space rerolls
idle
231 lines · vanilla
view source
// Plasma Bloom — classic demoscene 4-sine plasma.
//
// Conceptually: each pixel's intensity is the sum of a few sine waves of the
// (x,y,t) coordinate, mapped through a 256-entry color LUT that rotates one
// step per frame so the colors "flow" without recomputing the field.
//
// Why this is fast despite the visuals:
// - Per-pixel work in the inner loop is 3 table reads + 3 adds + one
// palette lookup. Math.sin is hoisted out into row / col / radial
// tables that are rebuilt once per frame.
// - We render into a half-resolution Uint32 buffer and let drawImage()
// upscale it. The GPU does the bilinear stretch for free.
// ---------- runtime state ----------
let W = 0, H = 0; // CSS pixels of the output canvas
let lw = 0, lh = 0; // low-res buffer dimensions
let off = null; // OffscreenCanvas at (lw, lh)
let octx = null;
let img = null; // ImageData backing `buf`
let buf = null; // Uint32 view over img.data — one pixel per entry
// Lookup tables. SIN_TABLE is 1024-entry; the field tables hold a precomputed
// scaled sin contribution per row / col / radial-index. At pixel (x,y) the
// raw field value is `colTab[x] + rowTab[y] + radTab[radIdx[y*lw+x]] + tBase`.
// All values are integers in the 0..N range; we'll mod 1024 to index SIN_TABLE.
const SIN_N = 1024;
let SIN_TABLE = null;
let colTab = null; // Int32, length lw — per-column sin contribution
let rowTab = null; // Int32, length lh — per-row sin contribution
let radTab = null; // Int32, length SIN_N — radial sin LUT (indexed by radIdx)
let radIdx = null; // Uint16, length lw*lh — precomputed sqrt(...) index
let tBase = 0; // time term (integer SIN units)
// Palette: 256 RGBA entries packed as Uint32 in native (little-endian) order.
// `paletteOffset` rotates every frame for the color-cycling effect.
let palette = null;
let paletteOffset = 0;
let paletteId = 0;
const PALETTE_NAMES = ['lava', 'ice', 'neon', 'cga-faux', 'default'];
// Slow drift of the time sine. Mouse drags this and the two field offsets.
let phase = 0;
let driftX = 0, driftY = 0;
// HUD fade — show palette name for a moment after switching.
let hudTimer = 0;
let hudLine = '';
// ---------- one-time setup ----------
function buildSinTable() {
SIN_TABLE = new Int16Array(SIN_N);
// We pack sin into a fixed-point integer to keep the inner loop in i32 land.
// Range chosen so 4 summed entries fit in a single byte (max ~255).
// Each sin contributes [0, 64], so 4 of them sit in [0, 256).
for (let i = 0; i < SIN_N; i++) {
SIN_TABLE[i] = ((Math.sin((i / SIN_N) * Math.PI * 2) + 1) * 0.5 * 64) | 0;
}
}
// ---------- palettes ----------
function packRGBA(r, g, b) {
// Little-endian Uint32 -> RGBA on the wire.
return (0xff << 24) | ((b & 0xff) << 16) | ((g & 0xff) << 8) | (r & 0xff);
}
// Smooth gradient between an array of color stops, sampled at 256 positions.
function gradientPalette(stops) {
const out = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const u = i / 255 * (stops.length - 1);
const a = Math.floor(u);
const b = Math.min(stops.length - 1, a + 1);
const f = u - a;
const c0 = stops[a], c1 = stops[b];
const r = (c0[0] + (c1[0] - c0[0]) * f) | 0;
const g = (c0[1] + (c1[1] - c0[1]) * f) | 0;
const bl = (c0[2] + (c1[2] - c0[2]) * f) | 0;
out[i] = packRGBA(r, g, bl);
}
return out;
}
function lavaPalette() {
// Black -> deep red -> orange -> yellow-white. Hot plasma.
return gradientPalette([
[10, 0, 0], [80, 0, 0], [200, 30, 0],
[255, 110, 0], [255, 200, 60], [255, 255, 220],
]);
}
function icePalette() {
// Indigo -> teal -> ice white.
return gradientPalette([
[5, 0, 30], [10, 30, 90], [20, 90, 180],
[80, 200, 230], [200, 240, 255], [255, 255, 255],
]);
}
function neonPalette() {
// Saturated magenta/cyan/yellow — the "demo" look.
const out = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const u = i / 256 * Math.PI * 2;
const r = ((Math.sin(u) * 0.5 + 0.5) * 255) | 0;
const g = ((Math.sin(u + 2.094) * 0.5 + 0.5) * 255) | 0;
const b = ((Math.sin(u + 4.188) * 0.5 + 0.5) * 255) | 0;
out[i] = packRGBA(r, g, b);
}
return out;
}
function cgaPalette() {
// Faux CGA / 16-color: 256 entries that quantize to 8 hard buckets, so the
// plasma takes on a chunky retro feel.
const stops = [
[0, 0, 0], [0, 0, 170], [170, 0, 170], [255, 85, 255],
[85, 255, 255], [255, 255, 85], [255, 255, 255], [255, 85, 85],
];
const out = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const k = (i >> 5) & 7;
out[i] = packRGBA(stops[k][0], stops[k][1], stops[k][2]);
}
return out;
}
function defaultPalette() {
// Three-phase RGB sinusoid, the textbook plasma palette.
const out = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const u = i / 256;
const r = ((Math.sin(u * Math.PI * 2 + 0) * 0.5 + 0.5) * 255) | 0;
const g = ((Math.sin(u * Math.PI * 2 + 2.0) * 0.5 + 0.5) * 255) | 0;
const b = ((Math.sin(u * Math.PI * 2 + 4.0) * 0.5 + 0.5) * 255) | 0;
out[i] = packRGBA(r, g, b);
}
return out;
}
// A small LCG so the random palette is reproducible per-roll and we don't have
// to lean on Math.random's frequency characteristics.
function rollPalette(seed) {
let s = (seed | 0) || 0x9e3779b9;
function rnd() { s = (s * 1664525 + 1013904223) | 0; return ((s >>> 0) / 0xffffffff); }
// Three sin phases with random frequencies and offsets — same shape as the
// default palette but with surprises.
const fr = 1 + Math.floor(rnd() * 3);
const fg = 1 + Math.floor(rnd() * 3);
const fb = 1 + Math.floor(rnd() * 3);
const pr = rnd() * Math.PI * 2;
const pg = rnd() * Math.PI * 2;
const pb = rnd() * Math.PI * 2;
const out = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const u = i / 256 * Math.PI * 2;
const r = ((Math.sin(u * fr + pr) * 0.5 + 0.5) * 255) | 0;
const g = ((Math.sin(u * fg + pg) * 0.5 + 0.5) * 255) | 0;
const b = ((Math.sin(u * fb + pb) * 0.5 + 0.5) * 255) | 0;
out[i] = packRGBA(r, g, b);
}
return out;
}
function setPalette(id) {
paletteId = ((id % PALETTE_NAMES.length) + PALETTE_NAMES.length) % PALETTE_NAMES.length;
if (paletteId === 0) palette = lavaPalette();
else if (paletteId === 1) palette = icePalette();
else if (paletteId === 2) palette = neonPalette();
else if (paletteId === 3) palette = cgaPalette();
else palette = defaultPalette();
hudLine = 'palette: ' + PALETTE_NAMES[paletteId];
hudTimer = 1.6;
}
// ---------- buffer (re)allocation ----------
function ensureBuffers(width, height) {
// 1/2 resolution — sweet spot between detail and a comfortable margin even
// on phones. Drop to 1/3 if either dimension exceeds 900 (e.g. desktop)
// since the upscale hides the difference and 3x area savings matter.
const scale = (width >= 900 || height >= 900) ? 3 : 2;
const sw = Math.max(8, Math.floor(width / scale));
const sh = Math.max(8, Math.floor(height / scale));
if (sw === lw && sh === lh && off) return;
lw = sw; lh = sh;
off = new OffscreenCanvas(lw, lh);
octx = off.getContext('2d');
img = octx.createImageData(lw, lh);
buf = new Uint32Array(img.data.buffer);
colTab = new Int32Array(lw);
rowTab = new Int32Array(lh);
radTab = new Int32Array(SIN_N);
// Precompute the per-pixel radial index `floor(sqrt(dx^2+dy^2) * RAD_FREQ)`
// ONCE per resize. Center is (lw/2, lh/2). The radial sin term in the
// inner loop becomes `radTab[radIdx[i]]` — a 1D fetch into the rotated
// sin LUT, with the sqrt amortized over many frames.
radIdx = new Uint16Array(lw * lh);
const ccx = lw * 0.5;
const ccy = lh * 0.5;
const RAD_FREQ = 0.18; // controls how tightly the radial waves spiral
for (let y = 0; y < lh; y++) {
const dy = y - ccy;
for (let x = 0; x < lw; x++) {
const dx = x - ccx;
const r = Math.sqrt(dx * dx + dy * dy);
// Wrap into [0, SIN_N) — high-freq pixels would otherwise overflow
// the radTab lookup.
radIdx[y * lw + x] = ((r * RAD_FREQ * SIN_N / (Math.PI * 2)) | 0) % SIN_N;
}
}
}
// ---------- per-frame field tables ----------
// These four tables together form the plasma:
// field(x,y) = colTab[x] + rowTab[y] + radTab[radIdx[y*lw+x]] + tBase
// Each contributes 0..64, summed they're 0..256, which we clamp to the
// 256-entry palette LUT.
function rebuildTables(tx, ty, tr) {
// Frequencies in radians per pixel. Mixed so the pattern doesn't tile
// visibly. Multiplied by SIN_N/(2π) so we can do integer mod indexing
// into SIN_TABLE.
const FX = 0.16, FY = 0.13;
const KX = (FX * SIN_N / (Math.PI * 2));
const KY = (FY * SIN_N / (Math.PI * 2));
const itx = (tx * SIN_N / (Math.PI * 2)) | 0;
const ity = (ty * SIN_N / (Math.PI * 2)) | 0;
for (let x = 0; x < lw; x++) {
let idx = ((x * KX) | 0) + itx;
idx = ((idx % SIN_N) + SIN_N) % SIN_N;
colTab[x] = SIN_TABLE[idx];
}
for (let y = 0; y < lh; y++) {
let idx = ((y * KY) | 0) + ity;
idx = ((idx % SIN_N) + SIN_N) % SIN_N;
rowTab[y] = SIN_TABLE[idx];
}
// Radial table — shift the whole SIN_TABLE by a time-varying offset so the
// concentric rings appear to breathe outward.
const itr = (tr * SIN_N / (Math.PI * 2)) | 0;
for (let i = 0; i < SIN_N; i++) {
let j = (i + itr) % SIN_N;
if (j < 0) j += SIN_N;
radTab[i] = SIN_TABLE[j];
}
}
// ---------- init / tick ----------
function init({ canvas, ctx, width, height }) {
W = width; H = height;
buildSinTable();
ensureBuffers(W, H);
setPalette(0);
paletteOffset = 0;
phase = 0;
driftX = 0; driftY = 0;
// Paint once so the first frame isn't blank if init->tick has a hitch.
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
}
function tick({ ctx, dt, width, height, input, time }) {
if (width !== W || height !== H) {
W = width; H = height;
ensureBuffers(W, H);
}
// ----- input -----
// Keys 1-5 select a palette. Numeric keys come through `input.keys` as the
// character itself.
for (let k = 1; k <= 5; k++) {
if (input.justPressed(String(k))) setPalette(k - 1);
}
if (input.justPressed(' ') || input.justPressed('Space')) {
palette = rollPalette(((time * 1000) | 0) ^ 0xa53f);
hudLine = 'palette: rerolled';
hudTimer = 1.6;
}
// Mouse drags the sine offsets. We map mouse pos relative to canvas
// center, then ease toward it so motion is buttery, not jittery.
const mx = (input.mouseX || W * 0.5) / W - 0.5; // [-0.5, 0.5]
const my = (input.mouseY || H * 0.5) / H - 0.5;
driftX += (mx * Math.PI * 2 * 3 - driftX) * Math.min(1, dt * 4);
driftY += (my * Math.PI * 2 * 3 - driftY) * Math.min(1, dt * 4);
// Slow time drift on top of the mouse contribution.
phase += dt * 0.6;
// Rebuild the precomputed sin tables for this frame.
rebuildTables(driftX + phase * 0.7, driftY - phase * 0.5, phase * 1.3);
// The "time" sine contributes a constant offset added to every pixel.
tBase = SIN_TABLE[(((phase * 0.5 * SIN_N / (Math.PI * 2)) | 0) % SIN_N + SIN_N) % SIN_N];
// ----- render the plasma field -----
// Inner loop: 1 add (col+row), 1 table lookup (rad), 1 add (tBase),
// 1 byte clamp (mask), 1 palette LUT, 1 store. Everything else is
// hoisted. This costs maybe 6-8 ns per pixel on V8 — at 1/2 res of a
// 1080x1080 viewport that's ~290k pixels, ~2 ms per frame.
const pal = palette;
const off2 = paletteOffset & 0xff;
const tb = tBase | 0;
for (let y = 0; y < lh; y++) {
const rowVal = rowTab[y] + tb;
const rowOff = y * lw;
for (let x = 0; x < lw; x++) {
let v = colTab[x] + rowVal + radTab[radIdx[rowOff + x] | 0];
// v is in [0, ~256). The rotation makes the palette flow.
v = (v + off2) & 0xff;
buf[rowOff + x] = pal[v];
}
}
octx.putImageData(img, 0, 0);
// Upscale to fill. Bilinear smoothing gives a soft demoscene glow; turn it
// off for the CGA palette to keep the chunky retro look honest.
ctx.imageSmoothingEnabled = paletteId !== 3;
ctx.drawImage(off, 0, 0, W, H);
// Cycle one palette step per frame — the canonical color-cycling trick.
paletteOffset = (paletteOffset + 1) & 0xff;
// ----- HUD -----
if (hudTimer > 0) {
hudTimer -= dt;
const a = Math.max(0, Math.min(1, hudTimer / 1.6));
ctx.fillStyle = `rgba(0,0,0,${(0.55 * a).toFixed(3)})`;
ctx.fillRect(10, 10, 200, 30);
ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
ctx.font = '13px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(hudLine, 20, 30);
}
// Persistent legend in the corner, low-contrast so it doesn't fight the art.
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = '11px ui-monospace, monospace';
ctx.textAlign = 'right';
ctx.fillText('mouse: warp · 1-5: palette · space: reroll', W - 10, H - 10);
}
Comments (0)
Log in to comment.