junkers devlog 06 · @martinthebad
c64.jpg ─ □ ✕
thank you
bannerz.sys ─ □ ✕
libre culture open source it all make art encrypt everything junkerz
nav.exe ─ □ ✕
junkers devlog 06: baked atmospheric lighting

Static baked lighting with ordered dither

This is where the city starts to have a mood. Every light and every wall is static, so the lightmap is worked out once when the level loads and then composited each frame with one multiply pass. There is no lighting maths per frame. The bake is heavy, millions of grid steps, which is fine because it only runs once.

The occluder grid

A flat uint8 array sized (level_w/16) by (level_h/16). Each solid collider writes 1s into the tiles it covers. One-way platforms are left out so light passes through them. Shadow tests then run as a walk over this grid instead of a scan through a list.

A grid DDA for shadows

A shadow test asks whether any solid tile blocks the path, which is a visibility question over a grid. An Amanatides-Woo DDA answers it in about as many steps as the path is long in tiles, roughly 90, using only array lookups, instead of checking every collider for every light and every texel. The combat code uses ray_vs_aabb instead, because that question is nearest-hit against a few boxes. Two different jobs, two tools.

fBm noise and ordered dither

Each light falls off as a quadratic, (1 - dist/radius) squared, and that gets broken up with four octaves of fractal noise (lacunarity 2, gain 0.5) so it does not look like a clean gradient. Then every texel is snapped to six brightness levels through a 4x4 Bayer matrix, which gives the chunky banding that suits a pixel game.

static const uint8_t bayer4[16] = {
     0,  8,  2, 10,
    12,  4, 14,  6,
     3, 11,  1,  9,
    15,  7, 13,  5
};

static uint8_t dither_quantize(float v, int px, int py) {
    float t = ((float)bayer4[((py & 3) * 4) + (px & 3)] + 0.5f) / 16.0f;
    int   q = (int)(v * (float)(LIGHT_LEVELS - 1) + (t - 0.5f) + 0.5f);
    if (q < 0)             q = 0;
    if (q >= LIGHT_LEVELS) q = LIGHT_LEVELS - 1;
    return (uint8_t)(q * 255 / (LIGHT_LEVELS - 1));
}

The Bayer matrix is indexed by world-space pixel coordinates (px & 3 and py & 3), not screen coordinates. If I used screen coordinates the pattern would crawl across the world as the camera moved and the lighting would look like it was animating. Tying it to the world grid holds it still.

Deterministic soft shadows

A single ray from the centre of a light is all or nothing at every tile corner, which throws out hard radial spokes. The fix treats the light as a small disc and casts several rays from points spread across it, then takes the fraction that get through. The sample positions come from a sunflower pattern and use no random numbers, so the bake comes out the same every time.

angle  = k * 2.399963              /* k * golden angle, radians */
r      = sqrt((k + 0.5) / n) * R   /* sqrt gives uniform area density */
sample = (lx + r*cos(angle), ly + r*sin(angle))

The golden angle, about 137.5 degrees, does not line up with a full turn, so the points never stack up along a radius and the disc fills evenly. A later pass also supersamples the receiving texel to clear up triangle-shaped artifacts along edges, but it is the same idea: average a lot of fixed samples.

light_artifacting.png ─ □ ✕
lighting artifact

This one was interesting. Mostly sorted, not completely. I am finishing the tilemap side before I come back to it. Some kind of fractal dithering is going to look great eventually, bleeding out of street lights, signs, screens and drones. It should be very atmospheric.

← the drone pool and hitscan all posts next: where it stands →

Ignorance is death
gb.log
Guestbook
rollin.ini ─ □ ✕
solarpunk.bat ─ □ ✕
garden wizard