Procedural visual effects and rendering for limited-palette pixel-art games (plain JS, phone browser)

Scope note: researched 2026-11-02. Target: internal resolution about 160x90 to 640x360, a fixed palette of 8 to 64 colors, deterministic, drawn each frame with only fillRect, drawImage, alpha, four blend modes (source-over, lighter, destination-out, multiply), transforms and gradients. In Elsewhere this is “Tycho’s GPU stand-in”. Offscreen canvases painted once may use anything. Web sources are cited inline. Where a claim rests on general graphics knowledge rather than a fetched source, it sits under Inferences or Gaps, not Cited Findings. Tycho’s own docs in the repo are cited as local files because they decide what’s feasible here.

Lighting: light maps, blend-mode lighting, normal maps, 2D shadows, 2D GI, quantized light, day/night, AO

Takeaway

For a 320x180, 8-ink look the cheapest effect that still reads well is a coarse light grid. Compute it per cell on the CPU, scale it up, and multiply it over the scene. Tycho already ships this as Tycho.light.Smooth, plus ctx.lightMap/ctx.tint on the stand-in. Hard shadows come from visibility polygons or grid shadowcasting, and a stepped or dithered light shader can snap light back to palette bands. Raymarched SDF GI and radiance cascades produce beautiful soft GI, but they need real fragment shaders and many passes. They’re out of reach for the stand-in and marginal on phones at anything above very low resolution.

Cited Findings

Inferences

Gaps

Palette tricks: cycling, swaps, palette fades, LUTs

Takeaway

Palette cycling is the most on-model effect for an indexed-color engine. Elsewhere’s Ink already stores pictures as palette indexes, so cycling, hit flashes, status swaps and palette-to-palette fades become a matter of reprinting from a different palette. Canvas Cycle shows how to make this fast in a browser: precompute which pixels use cycling indexes and redraw only those.

Cited Findings

Inferences

Gaps

Water and reflections: ripple displacement, caustics, waves, foam, refraction, waterfalls

Takeaway

At pixel resolution, water reads through four cheap tricks: a vertically flipped copy of what’s above the shoreline, a per-row horizontal sine offset for ripples, cycling highlight inks for glints and foam, and a scrolling strip for waterfalls. All but the per-pixel displacement are doable with drawImage strips. Tycho’s post.Blur already reflects “wet strips” with a ripple.

Cited Findings

Inferences

Gaps

Weather and atmosphere: rain, snow, fog, god rays, clouds and cloud shadows, wind, heat haze, motes, fireflies, depth haze

Takeaway

Nearly all weather is either particles (rain, snow, motes, fireflies, leaves) or scrolling, pre-painted layers (fog, clouds, cloud shadows, god rays) drawn with alpha or multiply/lighter. Heat haze is per-row strip displacement. All fit the stand-in if the layers are painted once in palette inks.

Cited Findings

Inferences

Gaps

Particles: pixel-snapped systems, sprite versus per-pixel, palette ramps, determinism, cheap physics

Takeaway

Keep particles as plain {x, y, vx, vy, t} objects drawn as 1 to 2 px fillRects at rounded positions, colored by stepping down a short palette ramp over their life, with randomness drawn from a hash of (frame, index, draw#) so a frame redraws identically. Tycho.fx already implements this pattern.

Cited Findings

Inferences

Gaps

Simulation effects: falling sand/CA, stable fluids, Doom fire, grass, destructible terrain

Takeaway

The Doom fire automaton is the gold standard for this target. It’s an integer grid of palette indexes, one subtract and one random offset per cell, fully deterministic with a seeded RNG, and its output is already palette indexes. Falling sand and Stam fluids also run fine at 160x90 to 320x180 in JS. Noita shows the chunking and dirty rects needed at scale, and Animal Well shows thresholding a fluid sim back to pixel-art colors.

Cited Findings

Inferences

Gaps

Post-processing: CRT, palette-limited bloom, chromatic aberration, vignette, grain, dithered transparency, outlines, upscaling, juice, transitions

Takeaway

Most “post” for an 8-ink look should be done at the native pixel grid with drawImage and fillRect: dithered transparency, baked outlines, vignettes from a pre-painted dither mask, integer upscaling, screen shake by whole pixels, hit stop by skipping simulation frames, and transitions by mosaic (draw small, then scale up) or by masks. Bloom, CRT curvature, chromatic aberration and sharp-bilinear scaling are shader territory, and Tycho’s frameEnd and post.Blur already provide bloom and integer scaling.

Cited Findings

Inferences

Gaps

Feasibility: what fits the stand-in, what needs offscreen pre-baking, what needs real shaders; phone performance

Takeaway

With Tycho’s design the rule is: anything expressible as quads of pre-painted pictures, combined with source-over, lighter, multiply or destination-out plus alpha, runs on the stand-in. Anything per-pixel either becomes a CPU buffer uploaded with drawPixels (fine at 320x180 if you never read back from the GPU) or a custom shader through gpu.program/lightPass. The biggest phone hazards are GPU readback (getImageData), full-screen overdraw, and per-frame canvas-to-texture copies.

Cited Findings

Inferences

Stand-in OK each frame (quads, alpha, four blends, transforms, gradients):

Effect How
Light grid Smooth, or a scaled canvas with multiply
Additive glows lighter, or the F.EM layer for bloom
Day/night Crossfade of two reprints
Cloud shadows, fog, god rays Scrolling pre-painted layers
Rain, snow, motes, fireflies, sparks Particles
Screen shake, hit stop Camera offset, simulation freeze
Mosaic transition Two drawImage calls
Dither wipes, iris Pre-baked masks
Reflections and ripples, heat haze Per-row strips
Waterfalls Scroll
Palette cycling N pre-rendered phases
Hit flash Baked silhouette
Destructible terrain destination-out on an offscreen canvas plus dirty
Vignette Gradient or mask

Offscreen pre-bake (paint once with anything, including getImageData on a CPU canvas with willReadFrequently): outlines, AO, palette reprints (night, status, fades, parallax haze), dither masks, normal-map prelit variants, caustic frames, fog and cloud textures, cycle phases, and sprite silhouettes.

CPU buffer plus drawPixels per frame: Doom fire, falling sand, Stam fluid, and Game-of-Life-style automata. These are fine at about 320x180 or smaller regions. Never read back from a GPU canvas. Keep the truth in a typed array.

Real shaders (WebGL2 via gpu.program/lightPass, or WebGPU): normal-mapped per-pixel lighting, stepped and dithered light quantization in a light pass, SDF/JFA soft shadows, radiance-cascade or raymarched GI, true displacement-map refraction, CRT curvature and masks, chromatic aberration done properly, sharp-bilinear and cleanEdge scaling, and color LUTs on non-indexed content.

Phone cost ranking (cheapest first): palette reprints and baked work (zero per frame); quads and particles; per-row strip effects; small CPU automata plus uploads; full-screen extra passes (each roughly one more full-screen fill); multi-pass GI. At 320x180 even several full-screen passes are small (57,600 px each). The risk grows when post runs at the device resolution after upscaling, for example 2532x1170, which is about 50x more pixels. So do all effects at native resolution and upscale last.

Gaps

Notable games and talks

Takeaway

The pixel-art lighting landscape splits into two camps. One is “modern lighting over pixel art”: Animal Well’s SDF raymarching plus hand-drawn normals plus a fluid sim, Sea of Stars’ custom Unity pipeline, and Kiyo’s 3D-style lights. The other is “lighting that obeys the palette”: Bloodless’s 4-color dithered light, Terraria’s per-tile Retro mode, and Ferrari’s palette cycling. Elsewhere’s 8-ink rule points squarely at the second camp, using the first only for glows and bloom.

Cited Findings

Inferences

Gaps