PIXELPUNK JOURNAL / CHAPTER 03 Editorial evidence captured 23 September 2026. These are historical project notes. Status and next-step instructions in these excerpts belong to their original development session. Performance and test results quoted here are from those sessions, not tests performed by the journal build. ======================================================================== docs/lighting-and-texture-plan.md | original lines 1–43 ======================================================================== # PixelPunk lighting and texture plan Date: 15 September 2026; updated 16 September 2026. Status: first lighting milestone and second material/water pass delivered and verified. The broader plan remains incremental. See `docs/visual-handoff.md` for current evidence and ordered remaining work. ## Direction Make PixelPunk feel like a small, lovingly built place: tactile stone and roof tiles, soft outdoor light, warm inhabited rooms, and water that picks up the town's colour. Preserve the orthographic camera, procedural building tools, and adjustable pixel presentation. **User requirement: support both day and night.** Start with authored Day, Golden Hour, and Night presets. A continuous time slider can follow once those three views work; automatic time progression should be optional so players can hold the light while building or photographing. The existing `artreference.png` anchors night-time colour, weathering, and atmosphere. It is an art-direction reference, not a promise to reproduce an illustration's detail at every camera angle. Daylight should reveal the same materials and architecture through warm sunlight, blue sky fill, soft shadows, and restrained window emission. ## What the current game needs Reviewed the Harbour capture and hipped-roof close-up from `artifacts/phase06-bricks`, the art reference, and the lighting/material/presentation code. - The geometry already carries roof tiles, brick surrounds, quoins, openings, supports, and planting. The next investment should help those forms read. - Broad lavender ambient light and a pink rim light tint many materials similarly. Some roofs and trims become very bright while walls lose depth. - Many windows read as flat yellow panels. Existing canal interiors offer a useful starting point, but brightness, colour, and room depth need more variation across styles. - The outline shader detects brightness changes, including texture, rain, and reflections. It can turn fine detail into black speckles rather than clarify silhouettes. - Water has conspicuous repeated streaks; paving and grass have relatively uniform surfaces. Their scale and contrast need stronger art direction. - Local lights go to the first 24 eligible objects in scene order. This does not prioritise what the camera sees, and several decorative emitter types are absent from that selection. ## Target appearance | Element | Day | Golden Hour | Night | | --- | --- | --- | --- | | Main light | Warm, fairly neutral sun | Low amber sun | Restrained cool moonlight | | Shadows | Blue-grey, readable | Long, cooler than sunlit surfaces | Deep blue with visible forms | | Stone and plaster | Natural material colour | Warm on exposed faces | Cool outside warm pools of light | | Windows | Mostly reflective/dim; visible interiors | Some rooms begin to glow | Varied amber rooms, occasional dark windows | | Water | Blue-green, broad sky highlights | Warm glints and cool body colour | Dark blue, broken local reflections | | Background | Pale atmospheric distance | Warm horizon and cooler upper sky | Muted city layers and sparse lights | Weather stays independent of time: a dry night must be possible, and rain must also work in daylight. ## Phase 1 — Establish the lighting foundation 1. Capture fixed Harbour wide, courtyard mid-range, and roof/window close-up views before changing anything. Compare at the same pixel size and exposure. 2. Centralise main-light angle/colour/energy, ambient fill, sky colours, window emission, light-pool strength, reflection strength, and glow in one environment profile. Currently these are scattered across scripts and shaders. 3. Build the three presets. Reduce the all-over magenta cast; reserve pink and cyan for actual neon sources. Keep shadow detail readable without brightening every surface. 4. Audit colour conversion across custom shaders and standard materials using neutral test surfaces. The masonry/plaster shaders manually convert vertex colours while standard materials use their own sRGB flag; verify consistency before changing conversion code. 5. Trial Godot 4.6 Compatibility SSAO: subtle darkening where objects meet, especially eaves, steps, window recesses, pillars, and building bases. Test its Radius and Intensity controls with the actual orthographic, low-resolution viewport. Keep it only if it helps without dark halos or crawling pixels. ======================================================================== docs/visual-handoff.md | original lines 3047–3107 ======================================================================== ## Fifth-pass review — orbit and drag light transitions, 16 September 2026 **Measured, not changed.** No lighting behaviour was altered. Two diagnostics were added so the transitions could be measured rather than argued about: `--orbit=` turns the camera continuously through an automated `--smoke` run, and `--churn=` forces an emitter rebuild at a drag-like cadence. Both add captures at frames 140/220/300/380/460/540. New `performance.json` fields: `orbit_rad_per_s`, `churn_frames`, `light_admits`, `light_evicts`, `light_starved`, `light_zombies_max`, `light_zombies_mean`, `pool_resets`, `pool_lit_lost`, `pool_energy_lost`, `pool_energy_delivered`, `pool_energy_promised`, `pool_energy_ratio`. One edit touches control flow: the admission loop now `continue`s where it used to `break`. Once the pool is full nothing further down the ranking can be admitted either, so the chosen set is identical and only the counter differs. Sources after this pass: `world_view.gd` `3427fb11`, `main.gd` `4cc8411c`. Canal quarter at Night, 221 sources, budget 16, `artifacts/lighting-pass5/e-{rest,orbit,drag}/`: | | delivered | promised | ratio | pool resets | starved | zombie slots max | |---|---|---|---|---|---|---| | at rest | 15.37 | 15.66 | 0.98 | 0 | 0 | 0 | | orbit, 1.2 rad/s | 13.61 | 13.45 | 1.01 | 0 | **27** | **7** | | drag cadence | 8.37 | 15.66 | **0.53** | **19** | 0 | 0 | **Finding 1, the larger one: a rebuild throws the whole pool away.** `_sync_lights()` frees every lamp and clears `active_lights`, so the next selection recreates all sixteen from `light_energy = 0`. A fade-in takes about 0.33 s and a drag rebuilds about twice a second, so the lamps never reach their targets: the quarter delivers **53% of the light it has promised** for as long as the drag lasts, with 19 pool resets and 272 lit lamps destroyed mid-life across an eight-second run. Every edit that marks emitters dirty pays this, not only drags. **Finding 2: a slot cannot be reused while its light fades.** The eviction loop sets `target = 0` but frees the node only once `light_energy < 0.01`, and the admission loop counts those still-fading nodes against the budget. During a brisk orbit up to **seven of the sixteen slots** are held by lights on their way out, the pool's promised energy sits 14% below the budget, and **27 top-ranked on-screen sources were refused a slot**. A newly revealed fixture waits two selection cycles for a slot and then a third of a second to fade up: roughly 0.8 s during which it shows a lit bulb that casts no light. At rest the pool is perfectly stable, so this is purely a motion fault. **Why there are no before/after pictures.** Capturing pauses the automated run, so the fades catch up while the screenshot is written. A churn capture measures only **-0.31%** mean luminance against the same camera at rest, which is an artifact of the capture and not the size of the effect. Transitions must be measured per frame in-engine; `pool_energy_ratio` is that measurement. `artifacts/lighting-pass5/tools/lumadiff.gd` records how that was established. **Fixes not attempted.** The 16-light ceiling is a hard constraint, so an outgoing and an incoming light genuinely compete for one slot; this is a policy decision about how motion should look, not a typo. Two candidates, in the order they are worth doing: 1. *Keep the pool across rebuilds.* Retain each `active_lights` entry whose source id still exists after the rebuild instead of freeing every lamp, so an edit re-points the pool rather than restarting it. This fixes Finding 1 and needs source ids that survive a rebuild; today they are `":"`, stable only while batch keys and emitter order are. 2. *Reclaim the dimmest fading slot on demand.* When the pool is full and a chosen source has no slot, free the outgoing light with the lowest `light_energy` if it is below a small threshold, instead of waiting for it to reach 0.01. This fixes Finding 2 without ever exceeding sixteen lamps; the threshold is the visual trade-off, since reclaiming a slot early cuts that light's fade short. **Verification of this pass.** Tests **1,168 / zero failures** (scene 451, building tools 400, clusters 198, lighting 119). Windowed integration `PIXELPUNK_INTEGRATION []`. Exports were rebuilt in the fourth pass from the previous sources and do **not** contain these diagnostics; rebuild them before shipping. **Next assistant's first job:** implement fix 1, then fix 2, re-running `--churn=4` and `--orbit=1.2` on the Canal quarter and comparing `pool_energy_ratio`, `light_starved` and `light_zombies_max` against the table above. Preserve source/visible-fixture agreement, the 16-light ceiling, RNG order, decorative template transforms, deletion/undo cleanup, and the shared terrain mask. Then proceed to material-coordinate work. ## Sixth-pass checkpoint — the two light-transition fixes, 16 September 2026 Both faults found in the fifth-pass review are fixed. Sources after this pass: `world_view.gd` `e3dc3041`, `main.gd` `8cca9caa`, `tests/test_lighting.gd` `bf6796aa`. `light_sources.gd` is untouched at `0622f031`. Artifacts under `artifacts/lighting-pass6/`, pre-pass sources under `artifacts/lighting-pass6/before/`. Canal quarter at Night, 221 sources, budget 16. Before columns are the fifth-pass `e-*` runs, after columns `artifacts/lighting-pass6/g-*`: | | delivered | promised | ratio | pool resets | lamps kept | lit lamps lost | starved | reclaims | zombie slots max | |---|---|---|---|---|---|---|---|---|---| | at rest, before | 15.37 | 15.66 | 0.98 | 0 | — | 0 | 0 | — | 0 | | at rest, after | 15.42 | 15.66 | 0.99 | 0 | 0 | 0 | 0 | 0 | 0 | | orbit 1.2 rad/s, before | 13.61 | 13.45 | 1.01 | 0 | — | 0 | **27** | — | **7** | | orbit 1.2 rad/s, after | 13.82 | 14.35 | 0.96 | 0 | 0 | 0 | **0** | 24 | **0** | | drag cadence, before | 8.37 | 15.66 | **0.53** | 19 | — | **304** | 0 | — | 0 | | drag cadence, after | 15.58 | 15.66 | **0.995** | 18 | 288 | **0** | 0 | 0 | 0 | | orbit and drag together, after | 13.80 | 14.32 | 0.96 | 18 | 288 | 0 | 0 | 24 | 0 | (The fifth-pass prose said 272 lit lamps were destroyed during a drag; `e-drag/performance.json` records 304. The table above uses the recorded figure.) **Fix 1 — the pool survives a rebuild.** `_sync_lights()` no longer frees every lamp. It rebuilds the source caches first and then calls the new `_retain_lights()`, which keeps a lamp when the id it was built from still names a fixture at the same position, colour and range. The id alone is not trusted: ids are `":"`, and an index comes to mean a different fixture when a batch shrinks, so each `active_lights` entry now records the fixture it was built from and the comparison is against that. A lamp that fails the comparison — moved, deleted, or an index that now means something else — is freed as before and fades in fresh. Batch keys are per cluster, per connection and per decoration group, so dragging one building discards only that building's lamps and leaves the rest of the quarter lit. Result: over an eight-second drag, 288 of 288 lamps are kept, nothing lit is thrown away, and the quarter delivers **99.5%** of its promised light instead of 53%. Admissions across that run fall from 320 to 16. **Fix 2 — a full pool reclaims its dimmest outgoing slot.** When every slot is taken and a chosen source has none, the admission loop now takes the slot held by the fading light with the lowest `light_energy` and frees it immediately, rather than counting it as starved. Sources are refused only when no slot is fading, so the sixteen-light ceiling is never exceeded. Across a 1.2 rad/s orbit this removes all 27 refusals and all 7 zombie slots, and the pool's promised energy rises from 13.45 to 14.35 — it now holds the lights the ranking actually wants. **The threshold in candidate 2 was dropped, on evidence.** The fifth pass proposed reclaiming a slot only below a small energy threshold, to bound the cut-short fade. Implemented at 0.4 it fired zero times in an orbit that starved 28 sources. That run is kept as `artifacts/lighting-pass6/f-orbit/` (the `f-*` runs are fix 1 only, the `g-*` runs are both fixes). Instrumenting the refusals showed why: a light is evicted at its full energy and the fade clears it well inside one 0.25 s selection interval, so there is only ever one age of blocker present, and every blocker seen stood between 0.475 and 1.5. A threshold can therefore only be all-or-nothing here. The reclaim is now unconditional on energy and still takes the dimmest candidate, so the fade cut short is always the one furthest along. That cut is the real cost of the ceiling: an outgoing light at the edge of the frame the camera is leaving stops instantly instead of fading. It is paid where a light is going out of view rather than in the middle of the frame, where the unlit fixture used to be. **What did not change.** Ranking, hysteresis, RNG order, the shortlist, the reflection builder, the shared terrain mask, and the budget itself. `local_lights` stays at 16 and `emitter_sources` at 221 in every run; `reflection_streaks` stays at 126. Frame time is unchanged within run-to-run spread: at rest 8.33 → 6.81 ms, orbit 9.73 → 9.76 ms, drag 9.97 → 9.81 ms. All three lighting presets were re-run under orbit and all show zero starvation and zero zombie slots (`preset-day` ratio 0.999, `preset-golden` 0.983, Night 0.963). **Why `pool_energy_ratio` falls slightly under orbit.** It is delivered over promised, and fix 2 raises the promise: the pool now admits the lights the ranking wants instead of refusing them, so more fade-ins are in flight at any moment. Delivered energy rose at the same time, 13.61 to 13.82. Read the two columns together, never the ratio alone. **Still no useful before/after picture, for the fifth pass's reason.** `artifacts/lighting-pass6/g-orbit/orbit_300.png` against `artifacts/lighting-pass5/e-orbit/orbit_300.png` differs by +0.35% mean luminance, and the two frames are not even at the same yaw, because the orbit advances by delta time and frame 300 lands at a slightly different angle in each run. The quay and bridge lamps do cast visible pools in the newer frame where the older one has lit bulbs over dark pavement, but that is an illustration, not the measurement. The measurement is the table. **New checks.** `pool_retention_checks()` in `tests/test_lighting.gd` drives `_retain_lights()` directly and pins both halves of the retention rule: an unchanged rebuild keeps every lamp at the energy it had, while a moved fixture, a deleted fixture, and an index that now names a different fixture each lose theirs. Lighting suite 119 → 130 checks. **Verification of this pass.** Tests **1,179 / zero failures** (scene 451, building tools 400, clusters 198, lighting 130). Windowed integration `PIXELPUNK_INTEGRATION []`, including fixture removal/undo/redo, which is the deletion path through `_retain_lights()`. No script or shader errors in any log.