2D lighting techniques for pixel-art games, from smooth to stylized (a catalog for Tycho’s roadmap)

Scope note: researched 2026-11-03. This catalog goes deeper than the lighting section of effects_rendering.md in this folder, which already covers Tycho’s renderer, Red Blob’s sweep, naive JFA GI, radiance cascades in outline, normal-map generators and Terraria’s light modes. Those findings are not repeated here except where an algorithm needs them.

Execution tiers. Every technique is rated on where it can run:

Two corrections to the brief’s API list:

Provenance. Two engine sources here are not official:

Both are cited for the algorithms they show, and their status is flagged where they are used.

Smooth light maps versus banded light versus dithered light

Takeaway

The three looks are three stages of one pipeline:

  1. Compute a coarse light buffer.
  2. Upscale it, which gives smooth light.
  3. Optionally quantize it to N levels, which gives bands. Quantizing can also mean picking a palette ramp step, which gives “reprint” lighting.
  4. Optionally break the band edges with an ordered (Bayer) threshold, which gives dithered light.

Smooth bilinear light over crisp sprites is what most commercial games ship (Terraria’s Color mode, Godot’s and Unity’s 2D lights), and it does break the pixel grid. The fixes are to snap the light to the art’s pixel grid, to quantize it, or both. Bayer beats noise for dithering light: it is stable, tileable, position-only and reads as a deliberate pattern. White noise clumps and leaves voids, and it shimmers if reseeded.

Cited Findings

Smooth light maps (multiply a low-resolution buffer)

Does bilinear light break the pixel look?

Banded light and palette reprint tables

Ordered (Bayer) versus noise dithering

Inferences

The banded or dithered light step, as pseudocode (it fits in a light shader [H]; on the CPU, it is the [S] variant):

L = lightmap(px)                         // 0..1, from the coarse buffer
if snapToArt: sample L at floor(px/artPx)*artPx + artPx/2    // light per art pixel, not per screen pixel
s = L * (N-1)                            // N = ramp steps (3–5 for an 8-ink look)
k = floor(s); f = s - k                  // band and position inside it
if dither: k += (f > bayer4[px.y&3][px.x&3]) ? 1 : 0      // Bayer only inside the band edge
else:      k += (f > 0.5) ? 1 : 0
out = ramp[materialOrInk(px)][k]         // reprint table: the ink index → its k-th shade

Where each look can run:

Why hash or noise dithering looks bad here:

Bilinear-on-crisp, how to handle it. In rising order of style:

  1. Accept smooth light, but keep the cells smaller than a tile (Terraria Color mode).
  2. Snap light sampling to art pixels (Godot’s floor trick).
  3. Quantize to a few levels.
  4. Quantize plus Bayer dither at edges.
  5. Palette reprint, where light only selects among inks.

For Elsewhere’s 8-ink rule, only 5 (or 4 resolved to inks) keeps the frame strictly within eight inks.

Gaps

Tile flood-fill light: Minecraft, Terraria, Starbound, and per-tile interpolation

Takeaway

All three engines compute light per tile on the CPU and interpolate on the GPU:

All of this is cheap at Elsewhere’s scale (a 40×23-tile view is about 900 cells) and fully deterministic.

Cited Findings

Minecraft

Terraria 1.4.4.1 (decompiled Terraria.Graphics.Light.LightMap)

Starbound (OpenStarbound, StarCellularLightArray)

Inferences

Cost at Elsewhere’s scale [S].

Which falloff to pick:

Directional sweeps versus a BFS queue.

Determinism. All of these are pure functions of the tile grid and light list, so they satisfy CLAUDE.md’s determinism rule with no rng.

Day and night without re-propagation. Minecraft’s two-channel trick (block and sky stored separately, combined through a 2D lookup that changes with time) is a cheap way to do day/night. Store two byte grids, and recompute only the final cell color when the hour changes.

Gaps

Hard shadows from walls: visibility polygons, grid shadowcasting, penumbra wedges, 1D polar shadow maps

Takeaway

Four families:

  1. Geometric visibility polygons: an angular sweep over segment endpoints, done on the CPU, giving exact hard shadows.
  2. Grid FOV: recursive shadowcasting per octant, fastest on tile maps, with well-known asymmetry artifacts.
  3. Geometric soft shadows: shadow quads plus penumbra “fins” or wedges per light-facing edge.
  4. 1D polar shadow maps: an image-based GPU method. It unrolls occluders around a light into a 1×N texture of nearest-hit distance per angle, then blurs by angle for softness.

The first two produce data for the CPU that the stand-in can draw. The last two need either polygon fills (impossible in the stand-in per frame) or shaders.

Cited Findings

2D visibility polygons

Grid shadowcasting (roguelike FOV)

Soft shadows by geometry (penumbra fins and wedges)

1D polar shadow map (mattdesl)

  1. Draw occluders into a square FBO centered on the light (256×256 by default), alpha only.
  2. A shader turns this into a 256×1 texture. For each angle (x), march outward and keep the minimum distance at which alpha > 0.75. Rays that hit nothing store 1.0.
  3. Draw the light quad. Each fragment compares its radius with the stored distance for its angle, which gives hard shadows.
    • For softness, take 9 samples of neighboring angles with Gaussian weights, with a blur width that grows with distance.
    • Multiply by radial falloff and light color, then add.

Limitations from the author:

— mattdesl/lwjgl-basics: 2D Pixel Perfect Shadows

Engines’ shadow filters

Inferences

Per family, how it would run in Tycho:

Gaps

2D global illumination: radiance cascades, JFA SDF raymarching, Noita, CPU cost at 160×90

Takeaway

2D GI treats every emissive pixel as a light and every opaque pixel as an occluder, so light bleeds color around corners with soft, correct penumbrae. Two practical families:

Both are GPU-shader methods ([H]) with roughly 10 or more passes. At 160×90 the pixel count is so small that a CPU version is conceivable for static light (bake [B]), and possibly for low-frequency dynamic light at a reduced rate. But quantizing GI to eight inks throws away most of what makes it worth having. No developer source documents Noita’s lighting.

Cited Findings

JFA SDF plus raymarching

Radiance cascades

Noita

Inferences

CPU cost at 160×90 (14,400 px). Estimates, not measurements:

Look versus palette. GI’s selling point is continuous color bleeding and soft penumbrae. Under 8 inks, it reduces to “which band, which ink”. The banded result of a GI bake would differ from a careful hand-placed flood fill mainly in corner wrap-around and color bleed. A bake can still be worth it for one showcase quest with a “high-bit” look (Elsewhere permits per-quest looks), done as [H] through gpu.program at 320×180.

Pseudocode for the CPU RC skeleton (base 4, my sketch from the GM Shaders description):

for i = N-1 down to 0:
  spacing = 2^i ; rays = 4^(i+1) ; t0 = I0*(4^i-1)/3 ; t1 = t0 + I0*4^i
  for each probe p on the spacing grid, for each ray k:
    hit, rad = march(p, dir(k), t0, t1)          // SDF sphere trace limited to [t0,t1]
    if !hit and i < N-1:
      rad += bilinear over 4 parent probes of avg(parent rays 4k..4k+3)
    C[i][p][k] = rad
light(px) = avg_k C[0][px][k]

Gaps

Normal-mapped sprites with point lights (Dead Cells, Children of Morta), and generating the normals

Takeaway

Per-pixel N·L lighting turns flat sprites into relightable ones. Dead Cells redrew backgrounds and decorations as normal maps and rendered characters from 3D. Eastward hand-painted bump maps over 3D rebuilds. Children of Morta is documented only as Unity dynamic lighting with specular maps. Automatic normal generators are worse than hand-painted maps.

The pixel-art problem is that per-fragment lighting cuts across art pixels. Snap the lookup to the art grid, and prefer a few flat “facet” normals over smooth gradients. In Tycho, live normal lighting is [H]. Prebaking N lighting directions to palette-quantized sprite variants is [B] and fits the 8-ink rule.

Cited Findings

Inferences

Live normal lighting [H]. The per-pixel term is:

N = normalTex(floor(uv*artSize)+0.5)*2-1                  // sample normals per art pixel
for each light: Ldir = normalize(vec3(lightPos - px, height)); diff += col*max(dot(N,Ldir),0)*atten(dist)
out = albedo*(ambient + diff)

Then quantize diff to ramp steps for a pixel look.

Prebaked directional variants [B]. Elsewhere’s Figure sprites are generated, so their normals can come from the same shape data (the bevel or chamfer approach) at paint time. Bake 4 or 8 light directions × 2 or 3 intensities, each quantized to the quest’s inks. At draw time, pick the variant whose direction is nearest the dominant light, plus ctx.tint or ctx.lightMap for intensity. That costs memory (8× sprites) and no per-frame work. It fits strict palettes, because every variant is printed from inks.

Rim-light hack [S]. Draw a 1 px light-ink outline only on the side facing the light, by blitting a pre-made per-side edge mask (4 masks per sprite). Many palette-limited games approximate normal mapping this way. This is my inference; no source covers it.

Gaps

Emissive and bloom

Takeaway

Bloom has four steps: threshold or tag the bright or emissive pixels, blur them, often at reduced resolution and in several passes, then add (lighter) the result over the frame. For pixel art, the glow should be blocky, or blurred at low resolution and drawn with nearest scaling, and it should come from a separate emissive layer rather than a luminance threshold. A threshold on an 8-ink palette makes every light ink glow. Tycho already has an emissive target (F.EM) and a post.Blur finish, so a basic bloom is [S], or [S+H] for the blur.

Cited Findings

Inferences

Gaps

Volumetric light, god rays, fog and haze

Takeaway

2D god rays are an occlusion mask (a bright light disk with occluders drawn black) blurred radially toward the light and added over the scene. Fog and haze are layers: a flat or gradient fill, alpha by depth, or scrolling noise sprites. The radial blur is [H] or a CPU loop. Pre-baked shaft sprites drawn with lighter and slight alpha animation give most of the look on the stand-in.

Cited Findings

Inferences

The radial-blur loop, per output pixel [H, or CPU at light resolution]:

uv = px; delta = (px - lightPos) * density / S
illum = 0; w = 1
for s in 1..S:  uv -= delta; illum += mask(uv) * w; w *= decay
out = scene + illum * exposure * lightColor

At 160×90 with S = 16 that is about 230k mask reads in JS per frame, feasible as drawPixels. Or apply the three.js trick (3 passes × 6) on a quarter-resolution mask.

On the stand-in:

Gaps

Palette-based lighting: ramp lookup, per-material ramps, hue-shifted shadows

Takeaway

In palette lighting, light selects a shade from a ramp owned by the pixel’s ink or material, instead of multiplying RGB. LutLight2D does exactly that on the GPU. On the CPU, Elsewhere’s Ink index buffers make it natural:

Hue-shifted ramps, with shadows toward violet or blue and lights toward gold, read as painted. Black multiply reads as dirty. A colored multiply (an ambient tint) is the cheap stand-in approximation.

Cited Findings

Inferences

Three implementations, from strict to cheap:

  1. Per-pixel index lookup [S via drawPixels, or H].
    • out = palette[ramp[ink][band(L)]]. Each ink’s ramp is a list of other inks in the frame’s eight, so the frame never leaves the palette.
    • Per-material ramps are the same with a material id per pixel (Ink could keep a second index plane).
    • Hue shift is free, because the ramp’s darker entries are whatever inks the artist or palette generator chose (violet-ish).
  2. Layer reprint per light state [B, then S]. Print each layer once per band or time of day, then select or crossfade with drawImage and globalAlpha.
    • Holding a crossfade mid-way creates off-palette blends. That is the existing note in effects_rendering.md.
    • Local light pools use the band reprint plus a mask: draw the lit print, then destination-out a baked light-shape mask, or draw lit-print tiles only within the light’s tiles.
  3. Colored multiply [S]. Multiply by a violet-blue ambient instead of gray, then add warm light (lighter). This gives the hue-shifted feel at the cost of off-palette colors. Tycho’s Smooth model already supports colored cells (RGB light map), so cool ambient cells with warm cells near lamps is a one-line change in the cell fill.

Pseudocode for building a ramp table from an 8-ink palette (at quest load; deterministic):

for each ink i: candidates = inks with lower lightness; sort by lightness
  ramp[i][0] = i
  ramp[i][k] = argmin over candidates of (ΔL to L_i - k*step) + λ·(hue distance toward the "shadow hue", e.g. violet)

Gaps

Light color mixing: warm and cool, additive versus screen versus overlay

Takeaway

The common game formula is scene × (ambient + Σ lights) + small additive, the Unity two-factor and Tycho Smooth form. Warm lights over a cool ambient give the classic look.

Cited Findings

Inferences

How the lights should combine in the light buffer:

Screen on the stand-in. It would need inverting the scene, so it is not directly possible. Approximations:

Option (c) is free and gives a non-clipping mix of many lights.

Overlay is best treated as a whole-frame grade done by palette reprint, not as a light.

Gaps

Which techniques suit a strict palette-limited look, and which a modern “high-bit” pixel look

Takeaway

The dividing line is whether light selects an ink or produces new colors.

Cited Findings

Inferences

Master table (B = bake, S = stand-in per frame, H = shader only; “palette-safe” = stays inside the frame’s inks):

Technique Look Per-frame cost at 320×180 Tier Palette-safe? Fits
Coarse light grid, smooth multiply (Smooth, ctx.lightMap) soft pools, Terraria Color JS grid fill + 1 quad S no high-bit
Same grid, nearest upscale (blocky) per-tile steps, Terraria Retro same S no (multiply) either
Tile flood fill (Minecraft BFS / Terraria sweeps / Starbound octagonal CA) light pours around corners ~10–20k cell ops S (CPU) as above, depending on the final step both
Quantized bands + palette ramp lookup per pixel painted, stepped ~60k table reads + drawPixels S (drawPixels) / H yes strict
Band reprints per layer (Ink prints N times) stepped, tile-aligned pools N× memory, ~0 per frame B→S yes strict
Bayer-dithered band edges crosshatch transitions per pixel H / S (drawPixels) / B (masks) yes strict
White-noise or hash dither grainy, crawling per pixel — yes, but ugly avoid
Grid shadowcasting into the light grid crisp or soft tile shadows microseconds S (CPU) depends on final step both
Visibility polygon (Red Blob) rasterized exact hard shadows O(n log n) + raster S (CPU+drawPixels) / B static depends both
1D polar shadow map (CPU variant) per-pixel shadows, angular blur ~100k ops/light S (drawPixels) / H depends high-bit
Penumbra fins (geometry) physically soft shadows per edge per light H no high-bit
JFA/EDT + Monte Carlo GI soft GI, noisy millions of reads H (bake B) no high-bit
Radiance cascades noise-free GI ~0.3M short marches (CPU est.) H / B bake no (bands lose it) high-bit showcase
Normal-mapped sprites, live relit sprites per pixel per light H no (unless quantized) high-bit
Baked directional sprite variants relit in 4–8 steps 0 B yes strict
Baked glow halos (lighter) blocky glow 1 drawImage each B→S near (quantized halo, but additive) both
Downsampled screen bloom soft glow 2–3 drawImage S no high-bit
Radial-blur god rays shafts ~230k reads at 160×90 H / S (drawPixels) no high-bit
Baked shaft sprites shafts 1–3 drawImage B→S near both
Colored multiply ambient + warm add warm/cool mood 1–2 fills S no high-bit-ish
Palette or time-of-day reprint and swap whole-scene mood 0 (crossfade 2 draws) B→S yes (except mid-crossfade) strict

A Tycho roadmap reading:

Gaps