merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D, GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack (D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md. Conflict resolved: both lineages used #140 for different issues. Kept main's #140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to #141 (note added; this branch's commits/spec still reference #140 — immutable). The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D). Build + full suite green on the merged tree (2,713 passed / 4 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
31d7ffd253
27 changed files with 2327 additions and 103 deletions
|
|
@ -46,9 +46,9 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #140 — Toolbar interactivity — selected-object display
|
||||
## #141 — Toolbar interactivity — selected-object display
|
||||
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred)
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-06-17
|
||||
**Component:** ui — D.5 toolbar / selection
|
||||
|
|
@ -57,7 +57,7 @@ Copy this block when adding a new issue:
|
|||
|
||||
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
||||
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46.
|
||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #140:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
|
||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
|
||||
|
||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||
|
||||
|
|
@ -67,6 +67,27 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #140 — A7 "Fix D": outdoor objects too bright near torches
|
||||
|
||||
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
|
||||
**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
|
||||
**Filed:** 2026-06-18
|
||||
**Component:** render — point lighting on outdoor objects
|
||||
|
||||
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
|
||||
|
||||
**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*.
|
||||
|
||||
**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.**
|
||||
|
||||
**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired).
|
||||
|
||||
**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
|
||||
|
||||
---
|
||||
|
||||
## #139 — D.2b retail UI polish: chat text colors + buttons
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
|
||||
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
|
||||
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
||||
| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint |
|
||||
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
|
||||
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
||||
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
||||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||
|
|
@ -136,7 +136,8 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
|
||||
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
|
||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
||||
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
||||
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
|
|
@ -200,6 +201,7 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
|
||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
|
||||
|
||||
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
|
||||
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
|
||||
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
|
||||
|
||||
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
|
||||
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
|
||||
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
|
||||
**deliberately NOT implemented** because the math contradicts the observed result —
|
||||
one more capture is needed first.
|
||||
|
||||
## SHIPPED this session (all on `main`)
|
||||
|
||||
| Fix | Commit | What | Result |
|
||||
|---|---|---|---|
|
||||
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
|
||||
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
|
||||
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
|
||||
|
||||
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
|
||||
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
|
||||
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
|
||||
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
|
||||
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
|
||||
matrices.
|
||||
|
||||
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
|
||||
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
|
||||
`LScape::set_sky_position`'s world transform. cdb read retail's actual
|
||||
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
|
||||
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
|
||||
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
|
||||
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
|
||||
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
|
||||
|
||||
## KEY LESSON: the "too purple" was NEVER a bug
|
||||
|
||||
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
|
||||
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
|
||||
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
|
||||
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
|
||||
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
|
||||
was wrong (Fix C). Don't re-investigate the purple.
|
||||
|
||||
---
|
||||
|
||||
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
|
||||
|
||||
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
|
||||
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
|
||||
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
|
||||
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
|
||||
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
|
||||
not because it'd be too bright, but because it's the wrong path entirely.
|
||||
|
||||
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
|
||||
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
|
||||
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
|
||||
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
|
||||
colours. AC town buildings are EnvCell structures, so their walls take this path.
|
||||
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0)
|
||||
→ `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
|
||||
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
|
||||
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
|
||||
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
|
||||
carries DYNAMIC lights for cells, not the wall torches.
|
||||
|
||||
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
|
||||
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
|
||||
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1−d/range)·intensity·(wrap/norm)`;
|
||||
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
|
||||
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
|
||||
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
|
||||
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
|
||||
lights stacking past 1.0; a hall has a handful, each warm-capped.
|
||||
|
||||
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
|
||||
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
|
||||
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
|
||||
@ +0x3588. Captured standing in Holtburg:
|
||||
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
|
||||
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 1–2): viewer light `intensity=2.25 falloff=10
|
||||
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
|
||||
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
|
||||
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
|
||||
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 3–5
|
||||
(→ bake range ~3.9–6.5 m). Torches DO carry intensity=100, but the per-channel cap
|
||||
pins each to its warm colour ⇒ retail walls go warm, not white.
|
||||
|
||||
### acdream's actual bug — TWO real causes (both verified in source)
|
||||
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
|
||||
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
|
||||
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
|
||||
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
|
||||
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
|
||||
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
|
||||
torch sum on its OWN (from black); ambient/sun are a separate term.
|
||||
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
|
||||
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
|
||||
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
|
||||
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
|
||||
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
|
||||
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
|
||||
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
|
||||
the in-shader version missing the clamp shape.
|
||||
|
||||
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
|
||||
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
|
||||
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
|
||||
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
|
||||
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
|
||||
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
|
||||
bakes warm-but-≤[0,1], NOT white.
|
||||
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
|
||||
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
|
||||
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
|
||||
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
|
||||
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
|
||||
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
|
||||
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
|
||||
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
|
||||
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
|
||||
baked walls.
|
||||
|
||||
---
|
||||
|
||||
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
|
||||
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
|
||||
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
|
||||
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
|
||||
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
|
||||
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
|
||||
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
|
||||
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
|
||||
|
||||
## Build / run
|
||||
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
|
||||
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
|
||||
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
|
||||
|
||||
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
|
||||
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
|
||||
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
|
||||
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
|
||||
|
||||
---
|
||||
|
||||
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
|
||||
|
||||
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
|
||||
"shorten torch reach" — it is "outdoor objects receive NO torches."**
|
||||
|
||||
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
|
||||
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
|
||||
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
|
||||
this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
|
||||
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
|
||||
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
|
||||
inflated. So "acdream 6 vs retail 4" was a red herring.
|
||||
|
||||
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
|
||||
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
|
||||
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
|
||||
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
|
||||
before `LScape::draw`), so when the building EXTERIOR shell is drawn
|
||||
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
|
||||
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
|
||||
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
|
||||
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
|
||||
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
|
||||
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
|
||||
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
|
||||
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
|
||||
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
|
||||
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
|
||||
enables it outdoors.)
|
||||
|
||||
**The three retail lighting regimes (now all mapped):**
|
||||
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
|
||||
→ acdream mode 1 (EnvCell). ✓ already correct.
|
||||
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
|
||||
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
|
||||
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
|
||||
|
||||
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
|
||||
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
|
||||
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
|
||||
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
|
||||
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
|
||||
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
|
||||
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
|
||||
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
|
||||
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
|
||||
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
|
||||
|
||||
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
|
||||
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
|
||||
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
|
||||
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
|
||||
reach is the bug.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — what the visible bug actually is (and is NOT)
|
||||
|
||||
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
|
||||
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
|
||||
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
|
||||
|
||||
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
|
||||
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
|
||||
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
|
||||
retail ("looks much better", user 2026-06-19).
|
||||
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
|
||||
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
|
||||
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
|
||||
|
||||
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
|
||||
|
||||
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
|
||||
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
|
||||
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
|
||||
**D-4 (EnvCell walls get no sun) never touched it**.
|
||||
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
|
||||
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
|
||||
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
|
||||
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
|
||||
from the dat EnvCell list — never `info.Buildings`, never characters.
|
||||
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
|
||||
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
|
||||
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
|
||||
shared uniform; each renderer re-sets it before its draws.
|
||||
|
||||
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
|
||||
|
||||
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
|
||||
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
|
||||
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
|
||||
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
|
||||
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
|
||||
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
|
||||
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
|
||||
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
|
||||
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
|
||||
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
|
||||
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
|
||||
|
||||
## The OPEN question to resolve FIRST on resume (don't guess)
|
||||
|
||||
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
|
||||
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
|
||||
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
|
||||
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
|
||||
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
|
||||
for the same one. Then:
|
||||
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
|
||||
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
|
||||
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
|
||||
the issue; tighten how building shells receive torches (the per-vertex range gate already
|
||||
localises, so this is unlikely — favour the Falloff hypothesis).
|
||||
|
||||
## Proposed fix (after the falloff is confirmed)
|
||||
|
||||
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
|
||||
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
|
||||
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
|
||||
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
|
||||
object point lights OFF == retail-match).
|
||||
|
||||
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
|
||||
|
||||
| Commit | What | Verdict |
|
||||
|---|---|---|
|
||||
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
|
||||
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
|
||||
| `cf62793` | D-1 shader clamp | keep |
|
||||
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
|
||||
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
|
||||
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
|
||||
|
||||
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
|
||||
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
|
||||
|
||||
## DO-NOT-RETRY (cost a lot this session)
|
||||
|
||||
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
|
||||
building is a mode-0 OBJECT, none of that path lights it.
|
||||
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
|
||||
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
|
||||
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.
|
||||
603
docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md
Normal file
603
docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white.
|
||||
|
||||
**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md)
|
||||
|
||||
**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 3–5 → Range 3.9–6.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white.
|
||||
|
||||
**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher`
|
||||
|
||||
Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
public class GlobalLightPackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
|
||||
{
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(10f, 20f, 30f),
|
||||
WorldForward = new Vector3(0f, 0f, 1f),
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
ConeAngle = 0f,
|
||||
};
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
|
||||
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.True(buffer.Length >= 16);
|
||||
// posAndKind
|
||||
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
|
||||
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
|
||||
// dirAndRange
|
||||
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
|
||||
Assert.Equal(5.2f, buffer[7]);
|
||||
// colorAndIntensity
|
||||
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
|
||||
Assert.Equal(100f, buffer[11]);
|
||||
// coneAngleEtc
|
||||
Assert.Equal(0f, buffer[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
|
||||
{
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
int count = GlobalLightPacker.Pack(null, ref buffer);
|
||||
Assert.Equal(0, count);
|
||||
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: FAIL — `GlobalLightPacker` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement `GlobalLightPacker`**
|
||||
|
||||
Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Packs a point-light snapshot into the flat float layout the bindless mesh
|
||||
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
|
||||
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
|
||||
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
|
||||
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
|
||||
/// </summary>
|
||||
public static class GlobalLightPacker
|
||||
{
|
||||
public const int FloatsPerLight = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
|
||||
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
|
||||
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
|
||||
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
|
||||
/// </summary>
|
||||
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
|
||||
{
|
||||
int n = snapshot?.Count ?? 0;
|
||||
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
|
||||
if (buffer.Length < floatsNeeded)
|
||||
buffer = new float[floatsNeeded + FloatsPerLight * 16];
|
||||
Array.Clear(buffer, 0, floatsNeeded);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var L = snapshot![i];
|
||||
int o = i * FloatsPerLight;
|
||||
buffer[o + 0] = L.WorldPosition.X;
|
||||
buffer[o + 1] = L.WorldPosition.Y;
|
||||
buffer[o + 2] = L.WorldPosition.Z;
|
||||
buffer[o + 3] = (int)L.Kind;
|
||||
buffer[o + 4] = L.WorldForward.X;
|
||||
buffer[o + 5] = L.WorldForward.Y;
|
||||
buffer[o + 6] = L.WorldForward.Z;
|
||||
buffer[o + 7] = L.Range;
|
||||
buffer[o + 8] = L.ColorLinear.X;
|
||||
buffer[o + 9] = L.ColorLinear.Y;
|
||||
buffer[o + 10] = L.ColorLinear.Z;
|
||||
buffer[o + 11] = L.Intensity;
|
||||
buffer[o + 12] = L.ConeAngle;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer**
|
||||
|
||||
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with:
|
||||
|
||||
```csharp
|
||||
private unsafe void UploadGlobalLights()
|
||||
{
|
||||
int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int count = n > 0 ? n : 1; // never zero-size
|
||||
fixed (float* gp = _globalLightData)
|
||||
UploadSsbo(_globalLightsSsbo, 4, gp,
|
||||
count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||||
}
|
||||
```
|
||||
|
||||
Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it.
|
||||
|
||||
- [ ] **Step 6: Build and run the full Core test suite**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
Expected: build green; all tests pass (no regression — the packing is byte-identical).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||||
git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches
|
||||
|
||||
`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check).
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the conformance test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
|
||||
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
|
||||
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
|
||||
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
|
||||
/// </summary>
|
||||
public class LightBakeConformanceTests
|
||||
{
|
||||
private static LightSource OrangeTorch(Vector3 pos) => new()
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
|
||||
Intensity = 100f,
|
||||
Range = 4f * 1.3f, // falloff 4 × static_light_factor
|
||||
IsLit = true,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(2f)]
|
||||
[InlineData(3f)]
|
||||
[InlineData(4f)]
|
||||
[InlineData(5f)]
|
||||
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
|
||||
{
|
||||
// Wall vertex at the origin, normal facing the torch (+X). Torch out along +X.
|
||||
var vtx = Vector3.Zero;
|
||||
var normal = Vector3.UnitX;
|
||||
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
|
||||
|
||||
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
|
||||
|
||||
// Every channel bounded to [0,1] — intensity=100 must NOT blow to white.
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
// Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering.
|
||||
if (c.X > 0f)
|
||||
{
|
||||
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
|
||||
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeyondRange_ContributesNothing()
|
||||
{
|
||||
var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
|
||||
Assert.Equal(Vector3.Zero, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyOverlappingIntenseTorches_StillClampToOne()
|
||||
{
|
||||
// Eight near-white intensity-100 torches all 1.5 m from the vertex: the
|
||||
// [0,1] saturate must hold (no overflow past 1.0 per channel).
|
||||
var lights = new List<LightSource>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
lights.Add(new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
|
||||
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
IsLit = true,
|
||||
});
|
||||
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests`
|
||||
Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs
|
||||
git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert`
|
||||
|
||||
Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`)
|
||||
|
||||
- [ ] **Step 1: Apply the clamp split**
|
||||
|
||||
Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`):
|
||||
|
||||
```glsl
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
|
||||
// SUN / directional — material-lit term (added with ambient, NOT into the
|
||||
// torch sum), unchanged from before.
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
|
||||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||||
// clamps the SUM to [0,1] before anything else (it is a baked emissive term),
|
||||
// so a few warm intensity-100 torches can't push the whole pixel to white the
|
||||
// way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor
|
||||
// (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap
|
||||
// inside pointContribution is unchanged.
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||||
|
||||
return lit; // frag still does the final min(lit, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.)
|
||||
|
||||
- [ ] **Step 3: Review the math against the oracle**
|
||||
|
||||
Confirm by reading both side-by-side that the shader's point path now matches `LightBake`:
|
||||
- `mesh_modern.vert` `pointContribution` ↔ `LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal.
|
||||
- new `min(pointAcc, vec3(1.0))` ↔ `LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum.
|
||||
No code change expected here — this is the verification step the commit message cites.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert
|
||||
git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
|
||||
|
||||
accumulateLights folded ambient+sun+torches into one accumulator clamped only
|
||||
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
|
||||
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
|
||||
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
|
||||
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5)
|
||||
|
||||
Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot)
|
||||
|
||||
- [ ] **Step 1: Add fields + the per-frame snapshot setter**
|
||||
|
||||
In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||||
private uint _globalLightsSsbo; // binding=4
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private uint _instLightSetSsbo; // binding=5
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||||
```
|
||||
|
||||
Near `SetClipRouting` (~262) add the per-frame setter:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
|
||||
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
|
||||
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
|
||||
/// reference this snapshot, which is also uploaded to binding=4 here, so the
|
||||
/// pass is self-contained. Null/empty ⇒ shells receive no point lights.
|
||||
/// </summary>
|
||||
public void SetPointSnapshot(
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
|
||||
=> _pointSnapshot = snapshot;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`**
|
||||
|
||||
In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the per-cell light-set helper**
|
||||
|
||||
Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
|
||||
|
||||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
}
|
||||
```
|
||||
|
||||
(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.)
|
||||
|
||||
- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`**
|
||||
|
||||
(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache:
|
||||
|
||||
```csharp
|
||||
_cellLightSetCache.Clear();
|
||||
```
|
||||
|
||||
(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
{
|
||||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
```
|
||||
|
||||
(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
fixed (float* gp = _globalLightData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||||
fixed (int* lp = _lightSetData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||||
```
|
||||
|
||||
(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add:
|
||||
|
||||
```csharp
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo);
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the snapshot from GameWindow**
|
||||
|
||||
In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add:
|
||||
|
||||
```csharp
|
||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Dispose the new buffers**
|
||||
|
||||
In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add:
|
||||
|
||||
```csharp
|
||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo);
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
|
||||
|
||||
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
|
||||
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
|
||||
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
|
||||
per-instance set, computed per cell by LightManager.SelectForObject over the
|
||||
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Divergence register — correct AP-35, reconcile the Fix B row
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row)
|
||||
|
||||
- [ ] **Step 1: Correct AP-35**
|
||||
|
||||
Find the `AP-35` row. It currently describes the point-light path as per-pixel
|
||||
`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to
|
||||
reflect reality after Fix A + Fix D D-1:
|
||||
- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`.
|
||||
- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`).
|
||||
- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`.
|
||||
- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle.
|
||||
|
||||
- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row**
|
||||
|
||||
Find the row describing Fix B (per-object 8-light selection by sphere overlap vs
|
||||
retail's per-vertex sum over the full static list — `minimize_object_lighting`
|
||||
0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per
|
||||
cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the
|
||||
"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a
|
||||
new contradicting row.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
|
||||
- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions).
|
||||
- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires.
|
||||
- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning).
|
||||
- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach).
|
||||
- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug.
|
||||
- **Don't touch the purple portal** — confirmed correct.
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# A7 Fix D — warm torch over-brightness on indoor walls (#140)
|
||||
|
||||
**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting
|
||||
**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan.
|
||||
**Investigation source of truth:**
|
||||
[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md)
|
||||
(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
## Problem
|
||||
|
||||
The Holtburg meeting-hall walls (and outdoor objects near torches) blow out
|
||||
**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this.
|
||||
|
||||
The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE,
|
||||
yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle**
|
||||
for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous):
|
||||
|
||||
- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) →
|
||||
`SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its
|
||||
SOLE caller). Wall torches are STATIC objects → baked into vertex colours.
|
||||
- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light` → `config_hardware_light`
|
||||
(0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic
|
||||
subset for a cell. The previously-captured `intensity=100` light is on THIS path.
|
||||
|
||||
`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the
|
||||
decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own
|
||||
sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to
|
||||
`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights;
|
||||
a hall has a handful, each warm-capped.
|
||||
|
||||
### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0)
|
||||
|
||||
Holtburg: **38 static + 2 dynamic** lights.
|
||||
|
||||
| Light | path | type | intensity | falloff | colour (r,g,b) |
|
||||
|---|---|---|---|---|---|
|
||||
| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white |
|
||||
| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch |
|
||||
| 38× wall torch | static / **bake** | point | 100 | 3–5 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream |
|
||||
|
||||
Torches carry `intensity=100` too, but the per-channel cap pins each to its warm
|
||||
colour ⇒ retail walls go warm, never white.
|
||||
|
||||
## Root cause in acdream (both verified in source)
|
||||
|
||||
Two independent bugs, both touching the meeting-hall walls; this spec fixes both.
|
||||
|
||||
**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.**
|
||||
[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert)
|
||||
`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each
|
||||
capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in
|
||||
`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful.
|
||||
But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and
|
||||
trimming only at the end overflows to warm-white. Retail clamps the torch sum on its
|
||||
OWN (from black); ambient/sun are a separate material-lit term.
|
||||
|
||||
**D-2 (state, compounding): EnvCell shell SSBO binding leak.**
|
||||
[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs)
|
||||
binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which
|
||||
the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher`
|
||||
binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever
|
||||
LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's
|
||||
torches, wrong per-instance indices) ⇒ wrong/over-bright walls.
|
||||
|
||||
`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers).
|
||||
|
||||
## Design
|
||||
|
||||
Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake);
|
||||
**D-1 + D-2 land together**, single visual verification.
|
||||
|
||||
### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`)
|
||||
|
||||
In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator,
|
||||
saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and
|
||||
`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`.
|
||||
|
||||
```glsl
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
// ambient + sun = retail's material-lit term
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
// point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive)
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // <-- THE FIX
|
||||
return lit; // frag still does final min(lit, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 —
|
||||
normally-lit surfaces are byte-identical (no regression). Shared by every mesh using
|
||||
this shader (outdoor objects AND cell walls), matching the issue's scope.
|
||||
`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the
|
||||
retail FF pixel clamp). The lightning bump (frag:89) is unaffected.
|
||||
|
||||
### D-2 — the EnvCell shell binds its OWN light set
|
||||
|
||||
`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading
|
||||
leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern
|
||||
(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`):
|
||||
|
||||
1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained
|
||||
pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already
|
||||
re-uploads its own `uViewProjection`; it now also uploads/binds its own lights.
|
||||
2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed
|
||||
identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout:
|
||||
`posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot →
|
||||
same indices both renderers reference. `BuildPointLightSnapshot` is already called
|
||||
once per frame before rendering. **Extract the packing into a shared helper** so the
|
||||
two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a
|
||||
static on the snapshot type) — do not copy-paste the struct layout.
|
||||
3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`),
|
||||
compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter,
|
||||
cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's
|
||||
part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int
|
||||
set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape
|
||||
as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no
|
||||
point light) and still bind a ≥1-element buffer so the SSBO is never unbound.
|
||||
4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the
|
||||
cell's existing visibility bound (the BSP/AABB sphere already computed for culling).
|
||||
The exact field is pinned during planning by reading the cell-storage structs in
|
||||
`EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform
|
||||
translation, radius from the cell vertex AABB. **This is the one detail to confirm
|
||||
against code in the plan.**
|
||||
|
||||
Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can
|
||||
be implemented in either order, but ship together.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped
|
||||
(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum
|
||||
reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp
|
||||
mirrors `ComputeVertexColor`'s final clamp exactly.
|
||||
|
||||
New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`):
|
||||
|
||||
- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100`
|
||||
`falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at
|
||||
d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel
|
||||
≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate).
|
||||
- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed
|
||||
via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds).
|
||||
- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not
|
||||
desaturated toward white.
|
||||
|
||||
These pin the contract the shader must match. GLSL is not unit-testable in-process
|
||||
(standard for this project per the render digest); the shader `pointContribution` +
|
||||
`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C#
|
||||
oracle as the pinned reference (call it out in the implementation commit).
|
||||
|
||||
## Bookkeeping — divergence register
|
||||
|
||||
- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it
|
||||
describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert
|
||||
wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in
|
||||
`mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp
|
||||
makes the accumulator MORE faithful (no new deviation introduced).
|
||||
- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing
|
||||
per-object approximation (retail bakes per-VERTEX over the full static list; acdream
|
||||
selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's
|
||||
register row covers EnvCell shells; extend that row if needed — do NOT add a
|
||||
contradicting row.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split.
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct
|
||||
(expected no change).
|
||||
- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell
|
||||
light sets, bind SSBO 4 + 5, per-instance light-set buffer.
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) —
|
||||
extract the binding-4 global-lights packing so both renderers share it.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into
|
||||
`EnvCellRenderer.Initialize` (minimal).
|
||||
- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new.
|
||||
- `docs/architecture/retail-divergence-register.md` — AP-35 update.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green including the new conformance test.
|
||||
- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved).
|
||||
- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against
|
||||
`LightBake` (cited in the commit).
|
||||
- AP-35 corrected; any D-2 register note reconciled with Fix B's row.
|
||||
- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the
|
||||
Holtburg meeting-hall walls render warm-but-dim like retail.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s
|
||||
`color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics,
|
||||
not the baked walls. Wrong oracle (handoff warning stands).
|
||||
- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen
|
||||
approach is the in-shader clamp split. `LightBake.cs` stays the test oracle.
|
||||
- Sun handling on indoor walls is unchanged (kept in the material-lit term as today);
|
||||
any "should indoor walls receive sun at all" refinement is a separate question.
|
||||
- The purple portal is correct — do not touch it.
|
||||
|
|
@ -8121,6 +8121,17 @@ public sealed class GameWindow : IDisposable
|
|||
// frame — terrain, static mesh, instanced mesh, sky.
|
||||
UpdateSunFromSky(kf, playerInsideCell);
|
||||
Lighting.Tick(camPos);
|
||||
|
||||
// Fix B (A7 #3): build this frame's point-light snapshot and hand it to
|
||||
// the entity dispatcher for per-OBJECT light selection
|
||||
// (minimize_object_lighting). Replaces the single global nearest-8-to-
|
||||
// camera UBO set for point/spot lights so a wall's torches stay tied to
|
||||
// the wall as the camera moves. The SUN + ambient still flow through the
|
||||
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
|
||||
Lighting.BuildPointLightSnapshot(camPos);
|
||||
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
|
||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||
|
||||
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
|
||||
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
|||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
// Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
|
||||
// point/spot light can't push a channel past its own colour, regardless of
|
||||
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
|
||||
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
in vec3 vNormal;
|
||||
in vec2 vTexCoord;
|
||||
in vec3 vWorldPos;
|
||||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||
in flat uvec2 vTextureHandle;
|
||||
in flat uint vTextureLayer;
|
||||
|
||||
|
|
@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting {
|
|||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
if (kind == 0) {
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
|
||||
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
|
||||
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
|
||||
// smooth glow that blends into the ambient instead of a flat disc with
|
||||
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
|
||||
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
|
||||
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
|
||||
// denominator is just Range and fades to 0 exactly at the cutoff.
|
||||
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
||||
if (kind == 2) {
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
|
||||
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
|
||||
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
|
||||
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
|
||||
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
|
|
@ -114,8 +82,8 @@ void main() {
|
|||
if (color.a < 0.05) discard;
|
||||
}
|
||||
|
||||
vec3 N = normalize(vNormal);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
|
||||
vec3 lit = vLit;
|
||||
|
||||
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
|
|||
uint instanceClipSlot[];
|
||||
};
|
||||
|
||||
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
|
||||
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
|
||||
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
|
||||
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
|
||||
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
|
||||
// (its torches swapping into the global top-8). Two SSBOs replace that for
|
||||
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
|
||||
//
|
||||
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
|
||||
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
|
||||
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
|
||||
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
|
||||
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
|
||||
// it once per entity (the set is constant across the entity's parts/tuples).
|
||||
struct GlobalLight {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
||||
GlobalLight gLights[];
|
||||
};
|
||||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||||
};
|
||||
|
||||
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
|
||||
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
|
||||
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
|
||||
|
|
@ -95,10 +122,107 @@ uniform mat4 uViewProjection;
|
|||
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
|
||||
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
||||
uniform int uDrawIDOffset;
|
||||
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
|
||||
|
||||
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
|
||||
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
|
||||
// above). IDENTICAL std140 layout to mesh_modern.frag.
|
||||
//
|
||||
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
|
||||
// torch/point lights Gouraud-interpolate across each triangle the way retail's
|
||||
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
|
||||
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
|
||||
// per-vertex spreads it into a soft, broad gradient with no hard edge.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
|
||||
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
|
||||
// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal.
|
||||
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
|
||||
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
||||
int kind = int(L.posAndKind.w);
|
||||
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
|
||||
float distsq = dot(toL, toL);
|
||||
float d = sqrt(distsq);
|
||||
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3
|
||||
if (d >= range || range <= 1e-4) return vec3(0.0);
|
||||
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
|
||||
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
|
||||
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
|
||||
// nothing (retail's hardware path). toL is un-normalised (length d).
|
||||
float angular = (uLightingMode == 1)
|
||||
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
|
||||
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
|
||||
if (angular <= 0.0) return vec3(0.0);
|
||||
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
|
||||
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
|
||||
float norm = (distsq > 1.0) ? (distsq * d) : d;
|
||||
float intensity = L.colorAndIntensity.w;
|
||||
float scale = (1.0 - d / range) * intensity * (angular / norm);
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
|
||||
if (cos_l <= cos_edge) scale = 0.0;
|
||||
}
|
||||
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
|
||||
// a single light can't push a channel past its colour. Summed lit clamped in frag.
|
||||
vec3 baseCol = L.colorAndIntensity.xyz;
|
||||
return min(scale * baseCol, baseCol);
|
||||
}
|
||||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
|
||||
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
|
||||
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
|
||||
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
|
||||
if (uLightingMode == 0) {
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
}
|
||||
|
||||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||||
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
|
||||
// few warm intensity-100 torches can't push the whole pixel to white the way
|
||||
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
|
||||
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||||
|
||||
return lit; // frag still does the final min(lit, 1.0)
|
||||
}
|
||||
|
||||
out vec3 vNormal;
|
||||
out vec2 vTexCoord;
|
||||
out vec3 vWorldPos;
|
||||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||
out flat uvec2 vTextureHandle;
|
||||
out flat uint vTextureLayer;
|
||||
|
||||
|
|
@ -123,6 +247,7 @@ void main() {
|
|||
|
||||
vWorldPos = worldPos.xyz;
|
||||
vNormal = normalize(mat3(model) * aNormal);
|
||||
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
|
||||
vTexCoord = aTexCoord;
|
||||
|
||||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private uint _clipSlotBuffer;
|
||||
private uint[] _clipSlotData = Array.Empty<uint>();
|
||||
|
||||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||||
private uint _globalLightsSsbo; // binding=4
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private uint _instLightSetSsbo; // binding=5
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||||
|
||||
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
|
||||
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
|
||||
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
|
||||
|
|
@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
|
||||
|
||||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||
}
|
||||
|
|
@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
|
||||
=> _cellIdToSlot = cellIdToSlot;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
|
||||
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
|
||||
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
|
||||
/// reference this snapshot, which is also uploaded to binding=4 here, so the
|
||||
/// pass is self-contained. Null/empty -> shells receive no point lights.
|
||||
/// </summary>
|
||||
public void SetPointSnapshot(
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
|
||||
=> _pointSnapshot = snapshot;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetEnvCellGeomId
|
||||
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
|
||||
|
|
@ -843,6 +877,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
|
||||
_shader.SetInt("uRenderPass", (int)renderPass);
|
||||
_shader.SetInt("uFilterByCell", 0);
|
||||
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
|
||||
|
||||
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
|
||||
// moving"): upload uViewProjection HERE rather than inheriting it from
|
||||
|
|
@ -997,6 +1032,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetCellLightSet (A7 Fix D D-2 helper)
|
||||
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
|
||||
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
|
||||
|
||||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderModernMDIInternal
|
||||
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
|
||||
|
|
@ -1016,6 +1080,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
int passIdx = (int)renderPass;
|
||||
if (passIdx < 0 || passIdx > 2) return;
|
||||
|
||||
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
|
||||
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
|
||||
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
|
||||
// transparent); the cache entries are stable within a frame since PointSnapshot
|
||||
// doesn't change between Render calls, so clearing once (at the opaque pass)
|
||||
// and leaving stale entries for the transparent pass would also be correct, but
|
||||
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
|
||||
_cellLightSetCache.Clear();
|
||||
|
||||
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
|
||||
// Without the global VAO nothing can draw, and returning AFTER the pass state
|
||||
// was established leaked it (same early-out shape as the totalDraws==0 leak —
|
||||
|
|
@ -1213,6 +1286,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
|
||||
}
|
||||
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
{
|
||||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
fixed (float* gp = _globalLightData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||||
fixed (int* lp = _lightSetData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||||
|
||||
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
|
||||
// (globalVao validated at the top of the method — a return here would leak the
|
||||
// pass state established above.)
|
||||
|
|
@ -1228,6 +1330,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
|
||||
BindClipRegionBinding2();
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||||
|
||||
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
|
||||
|
|
@ -1443,5 +1547,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
|
||||
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
|
||||
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
|
||||
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
|
||||
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Terrain;
|
||||
|
|
@ -132,6 +133,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
private uint _clipSlotSsbo;
|
||||
private uint[] _clipSlotData = new uint[256];
|
||||
|
||||
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
|
||||
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
|
||||
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
|
||||
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
|
||||
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
|
||||
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
|
||||
private uint _globalLightsSsbo;
|
||||
private uint _instLightSetSsbo;
|
||||
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
|
||||
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
|
||||
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
||||
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
||||
private IReadOnlyList<LightSource>? _pointSnapshot;
|
||||
// This entity's selected point/spot light set — computed ONCE per entity at
|
||||
// the isNewEntity site (constant across the entity's parts/tuples), exactly
|
||||
// like _currentEntitySlot. -1 = unused slot.
|
||||
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
|
||||
|
||||
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
|
||||
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
|
||||
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the
|
||||
|
|
@ -329,8 +348,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_batchSsbo = _gl.GenBuffer();
|
||||
_indirectBuffer = _gl.GenBuffer();
|
||||
_clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
|
||||
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
||||
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
|
||||
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
|
||||
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
|
||||
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
|
||||
/// by the object's bounding sphere — camera-independent. Pass null/empty to
|
||||
/// disable per-object point lights (only ambient + sun render).
|
||||
/// </summary>
|
||||
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
|
||||
=> _pointSnapshot = pointSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
|
||||
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
|
||||
|
|
@ -861,6 +893,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_indoorProbeFrameCounter++;
|
||||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
|
||||
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
|
||||
_shader.SetInt("uLightingMode", 0);
|
||||
|
||||
// #128 self-heal: fresh re-request dedup per Draw pass.
|
||||
_missRequested.Clear();
|
||||
|
|
@ -888,7 +923,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
camPos = invView.Translation;
|
||||
|
||||
// ── Phase 1: clear groups, walk entities, build groups ──────────────
|
||||
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); }
|
||||
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); }
|
||||
|
||||
var metaTable = _meshAdapter.MetadataTable;
|
||||
uint anyVao = 0;
|
||||
|
|
@ -1053,6 +1088,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_currentEntityCulled)
|
||||
probeCulledEntities++;
|
||||
|
||||
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
|
||||
// is constant across the entity's parts/tuples), by the entity's
|
||||
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
|
||||
ComputeEntityLightSet(entity);
|
||||
|
||||
// #119 decisive probe: one-shot dump (+ change re-emission) for
|
||||
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
|
||||
// so a routed-out entity still reports its state.
|
||||
|
|
@ -1350,6 +1390,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_clipSlotData.Length < totalInstances)
|
||||
_clipSlotData = new uint[totalInstances + 256];
|
||||
|
||||
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
|
||||
// instance, laid out in the SAME group order / cursor as _instanceData
|
||||
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
|
||||
// Instances[instanceIndex] (binding=0).
|
||||
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
|
||||
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
|
||||
|
|
@ -1375,6 +1422,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Slots[] is parallel to Matrices[] within the group; write the
|
||||
// slot at the same cursor so binding=3 stays aligned with binding=0.
|
||||
_clipSlotData[cursor] = grp.Slots[i];
|
||||
// Fix B: LightSets[] holds 8 ints per instance, parallel to
|
||||
// Matrices[]; copy this instance's block to the same cursor so
|
||||
// binding=5 stays aligned with binding=0.
|
||||
int lsDst = cursor * LightManager.MaxLightsPerObject;
|
||||
int lsSrc = i * LightManager.MaxLightsPerObject;
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
|
@ -1460,6 +1514,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
fixed (uint* sp = _clipSlotData)
|
||||
UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
|
||||
|
||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||||
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||||
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
|
||||
// shader never reads an unbound SSBO on a no-lights frame.
|
||||
UploadGlobalLights();
|
||||
fixed (int* lp = _lightSetData)
|
||||
UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
|
||||
|
||||
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||||
|
|
@ -1743,6 +1806,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
|
||||
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
|
||||
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
|
||||
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
|
||||
/// a zeroed dummy that no instance set references (all sets are -1).
|
||||
/// </summary>
|
||||
private unsafe void UploadGlobalLights()
|
||||
{
|
||||
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int count = n > 0 ? n : 1; // never zero-size
|
||||
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
|
||||
fixed (float* gp = _globalLightData)
|
||||
UploadSsbo(_globalLightsSsbo, 4, gp,
|
||||
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
|
||||
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
|
||||
|
|
@ -1936,6 +2016,75 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
|
||||
/// reused by every part/instance of it), by the entity's world bounding
|
||||
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
|
||||
/// a static building's torches stay constant as the viewer moves. Fills
|
||||
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
|
||||
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
|
||||
///
|
||||
/// <para>
|
||||
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
|
||||
/// ambient ONLY — never the static wall torches. The per-object torch step
|
||||
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
|
||||
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
|
||||
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
|
||||
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
|
||||
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
|
||||
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
|
||||
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
|
||||
/// creatures get the sun, not torches. We mirror that: only objects parented to
|
||||
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
|
||||
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
|
||||
/// facade wash out warm — the dat's intensity-100 wall torches (range
|
||||
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
|
||||
/// The indoor "no sun" half is already handled by the global sun kill when the
|
||||
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
|
||||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ComputeEntityLightSet(WorldEntity entity)
|
||||
{
|
||||
Array.Fill(_currentEntityLightSet, -1);
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is null || snap.Count == 0) return;
|
||||
|
||||
// Retail useSunlight gate: outdoor objects receive no per-object torches.
|
||||
if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return;
|
||||
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
|
||||
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
|
||||
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
|
||||
/// predicate. An object receives the static wall torches (the indoor
|
||||
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
|
||||
/// — an interior cell, by the AC convention <c>(cellId & 0xFFFF) >= 0x0100</c>.
|
||||
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
|
||||
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
|
||||
/// are sun-lit only and return false. Mirrors
|
||||
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
|
||||
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
|
||||
/// </summary>
|
||||
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
|
||||
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: append the current entity's 8-slot light set to a group's
|
||||
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
|
||||
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
|
||||
/// </summary>
|
||||
private void AppendCurrentLightSet(InstanceGroup grp)
|
||||
{
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
grp.LightSets.Add(_currentEntityLightSet[k]);
|
||||
}
|
||||
|
||||
private void ClassifyBatches(
|
||||
|
|
@ -1993,6 +2142,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||||
}
|
||||
}
|
||||
|
|
@ -2072,6 +2222,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_gl.DeleteBuffer(_indirectBuffer);
|
||||
if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
|
||||
if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
|
||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
||||
if (_gpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||||
|
|
@ -2257,5 +2409,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
|
||||
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
|
||||
public readonly List<uint> Slots = new();
|
||||
|
||||
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
|
||||
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
|
||||
// light index block for the instance whose matrix is Matrices[i]). At
|
||||
// layout time the dispatcher copies each block into _lightSetData at the
|
||||
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
|
||||
// instance. -1 = unused slot.
|
||||
public readonly List<int> LightSets = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/AcDream.Core/Lighting/GlobalLightPacker.cs
Normal file
55
src/AcDream.Core/Lighting/GlobalLightPacker.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Packs a point-light snapshot into the flat float layout the bindless mesh
|
||||
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
|
||||
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
|
||||
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
|
||||
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
|
||||
/// </summary>
|
||||
public static class GlobalLightPacker
|
||||
{
|
||||
public const int FloatsPerLight = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
|
||||
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
|
||||
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
|
||||
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
|
||||
/// </summary>
|
||||
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
|
||||
{
|
||||
int n = snapshot?.Count ?? 0;
|
||||
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
|
||||
if (buffer.Length < floatsNeeded)
|
||||
buffer = new float[floatsNeeded + FloatsPerLight * 16];
|
||||
Array.Clear(buffer, 0, floatsNeeded);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var L = snapshot![i];
|
||||
int o = i * FloatsPerLight;
|
||||
// posAndKind (xyz world pos, w kind)
|
||||
buffer[o + 0] = L.WorldPosition.X;
|
||||
buffer[o + 1] = L.WorldPosition.Y;
|
||||
buffer[o + 2] = L.WorldPosition.Z;
|
||||
buffer[o + 3] = (int)L.Kind;
|
||||
// dirAndRange (xyz forward, w range)
|
||||
buffer[o + 4] = L.WorldForward.X;
|
||||
buffer[o + 5] = L.WorldForward.Y;
|
||||
buffer[o + 6] = L.WorldForward.Z;
|
||||
buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff
|
||||
// colorAndIntensity (xyz linear colour, w intensity)
|
||||
buffer[o + 8] = L.ColorLinear.X;
|
||||
buffer[o + 9] = L.ColorLinear.Y;
|
||||
buffer[o + 10] = L.ColorLinear.Z;
|
||||
buffer[o + 11] = L.Intensity;
|
||||
// coneAngleEtc (x cone radians; yzw reserved)
|
||||
buffer[o + 12] = L.ConeAngle;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
|
@ -157,4 +157,125 @@ public sealed class LightManager
|
|||
|
||||
_activeCount = baseSlot + filled;
|
||||
}
|
||||
|
||||
// ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ──
|
||||
//
|
||||
// The single global nearest-8-to-VIEWER set above (Tick) is camera-relative:
|
||||
// a wall's brightness changes as the camera moves because the wall's torches
|
||||
// swap in/out of that global top-8. Retail instead picks up-to-8 lights PER
|
||||
// OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480),
|
||||
// so a torch always lights the wall it sits on, camera-independent. The two
|
||||
// members below feed the per-instance light path in WbDrawDispatcher; Tick
|
||||
// remains the source of the legacy single-UBO path + the sun slot.
|
||||
|
||||
/// <summary>Max point/spot lights any one object can be lit by — retail's
|
||||
/// D3D fixed-function 8-light cap (<c>minimize_object_lighting</c>). The sun
|
||||
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
|
||||
public const int MaxLightsPerObject = 8;
|
||||
|
||||
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
|
||||
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
|
||||
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
|
||||
public const int MaxGlobalLights = 128;
|
||||
|
||||
private readonly List<LightSource> _pointSnapshot = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame snapshot of lit point/spot lights, stable-indexed for the global
|
||||
/// shader light buffer and for per-object selection: the index of a light here
|
||||
/// IS the index the per-instance light-set SSBO references. Built by
|
||||
/// <see cref="BuildPointLightSnapshot"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
|
||||
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
|
||||
/// path; unlit torches contribute nothing). When more than
|
||||
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
|
||||
/// most relevant lights survive the cap. Call once per frame before
|
||||
/// per-object selection.
|
||||
/// </summary>
|
||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
||||
{
|
||||
_pointSnapshot.Clear();
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
||||
_pointSnapshot.Add(light);
|
||||
}
|
||||
if (_pointSnapshot.Count > MaxGlobalLights)
|
||||
{
|
||||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
|
||||
/// <paramref name="snapshot"/> that reach the object sphere
|
||||
/// (<paramref name="center"/>, <paramref name="radius"/>), nearest-first.
|
||||
/// Faithful to retail's <c>minimize_object_lighting</c> (0x0054d480): a light
|
||||
/// is a candidate iff its falloff sphere overlaps the object sphere —
|
||||
/// <c>(light.pos − center)² < (light.Range + radius)²</c> — and when more
|
||||
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
|
||||
/// farthest fall off). <paramref name="light.Range"/> already folds
|
||||
/// <c>static_light_factor</c> (1.3), matching the per-vertex cutoff so a
|
||||
/// selected light always actually contributes in the shader.
|
||||
/// <para>
|
||||
/// Writes indices INTO <paramref name="snapshot"/> to
|
||||
/// <paramref name="outIndices"/> (ascending by distance) and returns the count.
|
||||
/// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a
|
||||
/// static object's set is stable and may be computed once. Unit-testable
|
||||
/// without GL.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static int SelectForObject(
|
||||
IReadOnlyList<LightSource> snapshot,
|
||||
Vector3 center,
|
||||
float radius,
|
||||
Span<int> outIndices)
|
||||
{
|
||||
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
|
||||
if (cap <= 0) return 0;
|
||||
|
||||
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
|
||||
int count = 0;
|
||||
|
||||
for (int li = 0; li < snapshot.Count; li++)
|
||||
{
|
||||
var light = snapshot[li];
|
||||
float reach = light.Range + radius;
|
||||
float dsq = (light.WorldPosition - center).LengthSquared();
|
||||
if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object
|
||||
|
||||
if (count < cap)
|
||||
{
|
||||
int j = count;
|
||||
while (j > 0 && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
count++;
|
||||
}
|
||||
else if (dsq < keptDistSq[cap - 1])
|
||||
{
|
||||
int j = cap - 1;
|
||||
while (j > 0 && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe(
|
|||
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// Why <c>|sunVec|</c> instead of <c>DirBright</c> directly: retail's
|
||||
/// <c>PrimD3DRender::UpdateLightsInternal</c> at <c>0x0059b57c</c>
|
||||
/// (decomp line 424118-424119) computes
|
||||
/// <code>D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)</code>
|
||||
/// from the sun vector <c>SkyDesc::GetLighting</c> built at
|
||||
/// <c>0x00500ac9</c> (decomp lines 261343-261353):
|
||||
/// <code>
|
||||
/// sunVec.x = sin(H) × DirBright × cos(P)
|
||||
/// sunVec.y = cos(P) // NOT scaled by DirBright
|
||||
/// sunVec.z = DirBright × sin(P)
|
||||
/// </code>
|
||||
/// Because Y is unscaled by <c>DirBright</c>, <c>|sunVec|</c> ≠
|
||||
/// <c>DirBright</c> in general — it varies with sun pitch and heading.
|
||||
/// Using <c>DirBright</c> alone underweighted the warm directional
|
||||
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
|
||||
/// blue-white at keyframes where retail looked warm-gray.
|
||||
/// <c>|sunVec|</c> is retail's <c>D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)</c>
|
||||
/// scaling (<c>PrimD3DRender::UpdateLightsInternal</c> 0x0059b57c, decomp
|
||||
/// 424118-424119) of the WORLD-space sun vector (<c>LScape::sunlight</c>).
|
||||
/// Because <see cref="SkyStateProvider.RetailSunVector"/> is now the
|
||||
/// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
|
||||
/// see that method), <c>|sunVec| == DirBright</c>, so this is effectively
|
||||
/// <c>SunColor = DirColor × DirBright</c>. (A prior bug used the un-transformed
|
||||
/// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~4–5× too bright at dawn/dusk;
|
||||
/// [[reference-retail-ambient-values]].)
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
|
||||
|
|
@ -301,21 +294,35 @@ public sealed class SkyStateProvider
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's raw sun vector (NOT normalized) — the same vector
|
||||
/// <c>SkyDesc::GetLighting</c> writes at <c>0x00500ac9</c>
|
||||
/// (decomp lines 261343, 261352, 261353):
|
||||
/// Retail's world-space sun vector (NOT normalized): the standard
|
||||
/// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
|
||||
/// <c>DirBright</c>:
|
||||
/// <code>
|
||||
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
|
||||
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright
|
||||
/// sunVec.z = DirBright × sin(P_rad)
|
||||
/// sunVec.x = DirBright × cos(P) × sin(H)
|
||||
/// sunVec.y = DirBright × cos(P) × cos(H)
|
||||
/// sunVec.z = DirBright × sin(P)
|
||||
/// </code>
|
||||
/// Y is unscaled by brightness on purpose — that's what makes
|
||||
/// <c>|sunVec|</c> ≠ <c>DirBright</c> in general (the magnitude varies
|
||||
/// with pitch/heading, which is the basis for retail's "sun is brighter
|
||||
/// in some configurations than others" lighting behavior). The shader's
|
||||
/// <c>uSunDir</c> uniform uses the NORMALIZED vector for N·L; the
|
||||
/// magnitude feeds <see cref="SkyKeyframe.SunColor"/> intensity and
|
||||
/// the ambient brightness boost in <see cref="SkyKeyframe.AmbientColor"/>.
|
||||
/// so <c>|sunVec| == DirBright</c> exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
|
||||
///
|
||||
/// <para>
|
||||
/// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
|
||||
/// retail's <c>LScape::sunlight</c> read at a dawn keyframe (H=90°, P=0.9°,
|
||||
/// DirBright≈0.224) = <c>(0.2238, ~0, 0.00352)</c> — y≈0, magnitude 0.224 =
|
||||
/// DirBright. That fed <c>level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
|
||||
/// 0.40 = 0.445</c>, matching the captured <c>SetWorldAmbientLight</c> level.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// PRIOR BUG: an earlier version returned <c>y = cos(P)</c> (≈1) — the raw
|
||||
/// PRE-transform value the decomp's <c>SkyDesc::GetLighting</c> writes to its
|
||||
/// <c>arg5</c> (0x00500ac9, before <c>LScape::set_sky_position</c>'s world
|
||||
/// transform). Porting that un-transformed vector inflated <c>|sunVec|</c> to
|
||||
/// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
|
||||
/// (<see cref="SkyKeyframe.AmbientColor"/>) AND the sun colour
|
||||
/// (<see cref="SkyKeyframe.SunColor"/>) by ~30% vs retail. The world-space
|
||||
/// form above is what <c>LScape::sunlight</c> actually holds at runtime.
|
||||
/// </para>
|
||||
/// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
|
||||
/// feeds the sun-colour intensity and the ambient brightness boost.
|
||||
/// </summary>
|
||||
public static Vector3 RetailSunVector(SkyKeyframe kf)
|
||||
{
|
||||
|
|
@ -325,9 +332,9 @@ public sealed class SkyStateProvider
|
|||
float sinP = MathF.Sin(p);
|
||||
float B = kf.DirBright;
|
||||
return new Vector3(
|
||||
MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P)
|
||||
cosP, // y = cos(P) ← unscaled by B
|
||||
B * sinP); // z = B × sin(P)
|
||||
B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
|
||||
B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
|
||||
B * sinP); // z = DirBright × sin(P)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D round 2 — pins retail's <c>useSunlight</c> gate for per-object torch
|
||||
/// lighting (<c>WbDrawDispatcher.IndoorObjectReceivesTorches</c>). Retail enables
|
||||
/// the static wall-torches on an object ONLY in the indoor stage
|
||||
/// (<c>DrawMeshInternal</c> 0x0059f398: <c>if (useSunlight == 0) minimize_object_lighting()</c>),
|
||||
/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor
|
||||
/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only
|
||||
/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches.
|
||||
/// </summary>
|
||||
public sealed class WbDrawDispatcherTorchGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildingShell_NullParent_IsOutdoor_NoTorches()
|
||||
{
|
||||
// Building exterior shells are top-level landblock stabs with no
|
||||
// ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent).
|
||||
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xA9B4_0001u)] // outdoor land sub-cell
|
||||
[InlineData(0xA9B4_0020u)] // outdoor land sub-cell
|
||||
[InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40)
|
||||
public void OutdoorLandCell_NoTorches(uint parentCellId)
|
||||
{
|
||||
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xA9B4_0100u)] // first EnvCell
|
||||
[InlineData(0xA9B4_0164u)] // interior EnvCell
|
||||
[InlineData(0x0007_0143u)] // dungeon EnvCell
|
||||
public void IndoorEnvCell_GetsTorches(uint parentCellId)
|
||||
{
|
||||
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without
|
||||
/// guessing or a live launch: dump the RAW dat <c>LightInfo.Falloff</c> for every
|
||||
/// static light in the Holtburg landblocks, via the EXACT production load path
|
||||
/// (<see cref="LightInfoLoader.Load"/>). The dat is the SAME file retail reads, so
|
||||
/// these falloffs ARE what retail reads (modulo any load-time transform, settled
|
||||
/// separately in the decomp). Output-only — no assertions; read the log.
|
||||
/// </summary>
|
||||
public sealed class HoltburgTorchFalloffProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
[Fact]
|
||||
public void Dump_Holtburg_StaticLight_Falloffs()
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// The meeting hall sits in the Holtburg town landblocks. Sweep a small
|
||||
// neighbourhood so we catch every entrance torch the streaming window
|
||||
// would load around the player at the hall.
|
||||
uint[] landblocks =
|
||||
{
|
||||
0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u,
|
||||
};
|
||||
|
||||
// Tally every distinct raw Falloff seen (the headline number).
|
||||
var falloffTally = new SortedDictionary<float, int>();
|
||||
int totalLights = 0;
|
||||
|
||||
foreach (uint lb in landblocks)
|
||||
{
|
||||
uint infoId = (lb << 16) | 0xFFFEu;
|
||||
var info = dats.Get<DatLandBlockInfo>(infoId);
|
||||
if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; }
|
||||
|
||||
int buildings = info.Buildings?.Count ?? 0;
|
||||
int objects = info.Objects?.Count ?? 0;
|
||||
_out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ===");
|
||||
|
||||
// Record building-shell origins so we can rank torches by proximity.
|
||||
var shells = new List<(uint model, Vector3 pos)>();
|
||||
if (info.Buildings is not null)
|
||||
{
|
||||
foreach (var b in info.Buildings)
|
||||
{
|
||||
var o = b.Frame.Origin;
|
||||
shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z)));
|
||||
_out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}");
|
||||
}
|
||||
}
|
||||
|
||||
if (info.Objects is null) continue;
|
||||
foreach (var stab in info.Objects)
|
||||
{
|
||||
// Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary —
|
||||
// identical gate to GameWindow.cs:6399.
|
||||
if ((stab.Id & 0xFF000000u) != 0x02000000u) continue;
|
||||
var setup = dats.Get<DatSetup>(stab.Id);
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) continue;
|
||||
|
||||
var loaded = LightInfoLoader.Load(
|
||||
setup,
|
||||
ownerId: 0,
|
||||
entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z),
|
||||
entityRotation: new Quaternion(
|
||||
stab.Frame.Orientation.X, stab.Frame.Orientation.Y,
|
||||
stab.Frame.Orientation.Z, stab.Frame.Orientation.W));
|
||||
|
||||
foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l)))
|
||||
{
|
||||
float rawFalloff = kvp.Value.Falloff;
|
||||
totalLights++;
|
||||
falloffTally.TryGetValue(rawFalloff, out int c);
|
||||
falloffTally[rawFalloff] = c + 1;
|
||||
|
||||
// Nearest building shell, for "is this an entrance torch on the hall?".
|
||||
float nearest = float.MaxValue;
|
||||
uint nearestModel = 0;
|
||||
foreach (var (model, spos) in shells)
|
||||
{
|
||||
float dd = Vector3.Distance(ls.WorldPosition, spos);
|
||||
if (dd < nearest) { nearest = dd; nearestModel = model; }
|
||||
}
|
||||
|
||||
_out.WriteLine(
|
||||
$" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " +
|
||||
$"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " +
|
||||
$"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " +
|
||||
$"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " +
|
||||
$"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ===");
|
||||
foreach (var kv in falloffTally)
|
||||
_out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}");
|
||||
}
|
||||
}
|
||||
45
tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs
Normal file
45
tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
public class GlobalLightPackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
|
||||
{
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(10f, 20f, 30f),
|
||||
WorldForward = new Vector3(0f, 0f, 1f),
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
ConeAngle = 0f,
|
||||
};
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
|
||||
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.True(buffer.Length >= 16);
|
||||
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
|
||||
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
|
||||
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
|
||||
Assert.Equal(5.2f, buffer[7]);
|
||||
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
|
||||
Assert.Equal(100f, buffer[11]);
|
||||
Assert.Equal(0f, buffer[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
|
||||
{
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
int count = GlobalLightPacker.Pack(null, ref buffer);
|
||||
Assert.Equal(0, count);
|
||||
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
|
||||
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
|
||||
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
|
||||
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
|
||||
/// </summary>
|
||||
public class LightBakeConformanceTests
|
||||
{
|
||||
private static LightSource OrangeTorch(Vector3 pos) => new()
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
|
||||
Intensity = 100f,
|
||||
Range = 4f * 1.3f, // falloff 4 × static_light_factor
|
||||
IsLit = true,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(2f)]
|
||||
[InlineData(3f)]
|
||||
[InlineData(4f)]
|
||||
[InlineData(5f)]
|
||||
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
|
||||
{
|
||||
var vtx = Vector3.Zero;
|
||||
var normal = Vector3.UnitX;
|
||||
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
|
||||
|
||||
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
|
||||
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
if (c.X > 0f)
|
||||
{
|
||||
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
|
||||
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeyondRange_ContributesNothing()
|
||||
{
|
||||
var torch = OrangeTorch(new Vector3(100f, 0f, 0f));
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
|
||||
Assert.Equal(Vector3.Zero, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyOverlappingIntenseTorches_StillClampToOne()
|
||||
{
|
||||
var lights = new List<LightSource>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
lights.Add(new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
|
||||
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
IsLit = true,
|
||||
});
|
||||
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
}
|
||||
}
|
||||
|
|
@ -144,4 +144,116 @@ public sealed class LightManagerTests
|
|||
mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4
|
||||
Assert.Equal(16f, light.DistSq, 2);
|
||||
}
|
||||
|
||||
// ── Fix B: per-object selection (minimize_object_lighting) ────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out
|
||||
mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out
|
||||
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Single(mgr.PointSnapshot);
|
||||
Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_IndexStable_InBudget()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
// Registration order preserved when under MaxGlobalLights (no sort).
|
||||
mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near
|
||||
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||
Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered
|
||||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
||||
{
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), Vector3.Zero, 1f, idx);
|
||||
Assert.Equal(0, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_InRange_Selected()
|
||||
{
|
||||
var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
Assert.Equal(1, n);
|
||||
Assert.Equal(0, idx[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_OutOfRange_Excluded()
|
||||
{
|
||||
// dist 10, range 5, radius 0 → 10 >= 5 → excluded.
|
||||
var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) };
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
Assert.Equal(0, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_ObjectRadiusExtendsReach()
|
||||
{
|
||||
// dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere
|
||||
// overlaps (7 < 5+3). The whole object catches the light — retail uses the
|
||||
// object's bounding sphere, not its centre point.
|
||||
var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) };
|
||||
Span<int> idx = stackalloc int[8];
|
||||
|
||||
Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx));
|
||||
Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_MoreThan8_KeepsNearest8()
|
||||
{
|
||||
// 10 candidate lights all in range; expect the 8 nearest the object centre,
|
||||
// ascending by distance, with the two farthest dropped.
|
||||
var snapshot = new LightSource[10];
|
||||
for (int i = 0; i < 10; i++)
|
||||
snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range
|
||||
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
|
||||
Assert.Equal(8, n);
|
||||
// Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest
|
||||
// (indices 8,9 / dist 9,10) are evicted.
|
||||
for (int k = 0; k < 8; k++)
|
||||
Assert.Equal(k, idx[k]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre()
|
||||
{
|
||||
// Same snapshot, same object centre → identical selection regardless of
|
||||
// where any "camera" is (the method takes no camera). This is the property
|
||||
// that kills the "lights up as I approach" popping.
|
||||
var snapshot = new[]
|
||||
{
|
||||
MakePoint(new Vector3(2, 0, 0), range: 10f),
|
||||
MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0
|
||||
};
|
||||
Span<int> a = stackalloc int[8];
|
||||
Span<int> b = stackalloc int[8];
|
||||
int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a);
|
||||
int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b);
|
||||
|
||||
Assert.Equal(1, na);
|
||||
Assert.Equal(na, nb);
|
||||
Assert.Equal(a[0], b[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests
|
|||
{
|
||||
// The loader stores DirColor and DirBright RAW. The SunColor property
|
||||
// composes them via |sunVec| per retail's UpdateLightsInternal at
|
||||
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
|
||||
// where the sun vector is built from heading/pitch/brightness with
|
||||
// Y unscaled by brightness (decomp 261352).
|
||||
// 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|.
|
||||
// cdb-verified (reference-retail-ambient-values): |LScape::sunlight| ==
|
||||
// DirBright for every keyframe (world-space spherical vector, magnitude
|
||||
// DirBright·sqrt(cos²P+sin²P) = DirBright).
|
||||
//
|
||||
// For this region: H=180°, P=70°, B=1.5
|
||||
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
|
||||
// = (0, 0.342, 1.410)
|
||||
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
|
||||
// sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70))
|
||||
// = (0, -0.513, 1.410)
|
||||
// |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright)
|
||||
// DirColor.X = 200/255 = 0.7843
|
||||
// SunColor.X = 0.7843 × 1.4509 = 1.138
|
||||
// SunColor.X = 0.7843 × 1.500 = 1.1765
|
||||
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
||||
var loaded = SkyDescLoader.LoadFromRegion(region);
|
||||
Assert.NotNull(loaded);
|
||||
|
||||
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
|
||||
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
|
||||
Assert.InRange(kf.SunColor.X, 1.17f, 1.18f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -66,24 +66,33 @@ public sealed class SkyStateTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
|
||||
public void RetailSunVector_MagnitudeAlwaysEqualsDirBright()
|
||||
{
|
||||
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
|
||||
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
|
||||
// |sunVec| = 1 regardless of B (because Y is unscaled by B)
|
||||
var kf = new SkyKeyframe(
|
||||
Begin: 0f,
|
||||
SunHeadingDeg: 0f,
|
||||
SunPitchDeg: 0f,
|
||||
DirColor: Vector3.One,
|
||||
DirBright: 2.0f, // anything
|
||||
AmbColor: Vector3.One,
|
||||
AmbBright: 1f,
|
||||
FogColor: Vector3.One,
|
||||
FogDensity: 0f);
|
||||
// cdb-verified (2026-06-18, reference-retail-ambient-values): retail's
|
||||
// world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP),
|
||||
// whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright
|
||||
// for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the
|
||||
// horizon — that was the ~30% over-bright bug.)
|
||||
// Horizon north (H=0°, P=0°): (0, B, 0), |.| = B.
|
||||
var horizon = new SkyKeyframe(
|
||||
Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f,
|
||||
DirColor: Vector3.One, DirBright: 2.0f,
|
||||
AmbColor: Vector3.One, AmbBright: 1f,
|
||||
FogColor: Vector3.One, FogDensity: 0f);
|
||||
Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f);
|
||||
|
||||
var v = SkyStateProvider.RetailSunVector(kf);
|
||||
Assert.InRange(v.Length(), 0.99f, 1.01f);
|
||||
// Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224
|
||||
// → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright.
|
||||
var dawn = new SkyKeyframe(
|
||||
Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f,
|
||||
DirColor: Vector3.One, DirBright: 0.224f,
|
||||
AmbColor: Vector3.One, AmbBright: 0.40f,
|
||||
FogColor: Vector3.One, FogDensity: 0f);
|
||||
var v = SkyStateProvider.RetailSunVector(dawn);
|
||||
Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224
|
||||
Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1)
|
||||
Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035
|
||||
Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
15
tools/cdb/a7-fixd-golden-probe.cdb
Normal file
15
tools/cdb/a7-fixd-golden-probe.cdb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
$$ A7 Fix D — GOLDEN: dump the nearest static lights (the meeting-hall wall torches)
|
||||
$$ + the ambient/sun that acdream folds into its accumulator. Breakpoint-free, instant.
|
||||
$$ Render::world_lights @ 0x008672a0; sorted_static_lights[] (RenderLight*) @ +0x3498
|
||||
$$ (verified: num_static_lights@+0x104=38, num_dynamic_lights@+0x3588=2).
|
||||
$$ Stand near the meeting-hall torches so the nearest sorted lights ARE them.
|
||||
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden-probe.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
.echo === ambient_color / sunlight_color / sunlight (what acdream folds into the accumulator) ===
|
||||
dt -r1 acclient!Render::world_lights ambient_color sunlight_color sunlight num_static_lights num_dynamic_lights
|
||||
.echo === nearest 10 sorted static lights (RenderLight.d3dLightIndex + info: type/intensity/falloff/color) ===
|
||||
.for (r $t0=0; @$t0 < 10; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RenderLight=%p ---\n", @$t0, @$t1; dt -r2 acclient!RenderLight @$t1 d3dLightIndex distancesq info }
|
||||
.echo === END ===
|
||||
qd
|
||||
17
tools/cdb/a7-fixd-golden2-probe.cdb
Normal file
17
tools/cdb/a7-fixd-golden2-probe.cdb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
$$ A7 Fix D — GOLDEN v2: explicit LIGHTINFO/RGBColor dump of the nearest static
|
||||
$$ lights. info @ RenderLight+0x70 (LIGHTINFO); within info: color@+0x50, intensity@+0x5C,
|
||||
$$ falloff@+0x60 -> absolute color@RL+0xC0, intensity@RL+0xCC, falloff@RL+0xD0.
|
||||
$$ Characterizes the 38-light static set (warm town torches?) + golden for the fix.
|
||||
$$ Breakpoint-free, instant, uses current scene.
|
||||
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden2-probe.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
.echo === ambient_color (r,g,b) ===
|
||||
dt acclient!RGBColor acclient!Render::world_lights+0x0
|
||||
.echo === sunlight_color (r,g,b) ===
|
||||
dt acclient!RGBColor acclient!Render::world_lights+0xc
|
||||
.echo === nearest 8 sorted static lights: type/intensity/falloff + color(r,g,b) + distsq ===
|
||||
.for (r $t0=0; @$t0 < 8; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RL=%p d3dIdx=%d ---\n", @$t0, @$t1, dwo(@$t1+0x68); dt acclient!LIGHTINFO @$t1+0x70 type intensity falloff; .echo color(r,g,b):; dt acclient!RGBColor @$t1+0xc0; .echo distancesq:; dd @$t1+0xd8 L1 }
|
||||
.echo === END ===
|
||||
qd
|
||||
36
tools/cdb/a7-fixd-lights-v2.cdb
Normal file
36
tools/cdb/a7-fixd-lights-v2.cdb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
$$
|
||||
$$ A7 Fix D (#140) v2 — fills the two gaps v1 left:
|
||||
$$ (1) light COLORS (v1's dt did not expand RGBColor); expanded here as a
|
||||
$$ typed RGBColor dump + raw dd hex backup (reinterpret IEEE-754 if dt fails).
|
||||
$$ (2) the STATIC wall torches (the lights that actually BAKE the walls) — these
|
||||
$$ only re-register on a visible-cell-set change, so the player must MOVE
|
||||
$$ (walk IN and OUT of the meeting hall, circle past the torches) to trigger
|
||||
$$ Render::add_static_light.
|
||||
$$
|
||||
$$ v1 already proved: intensity=100/falloff=6 light is DYNAMIC (add_dynamic_light,
|
||||
$$ d3dIdx=2) = the portal/effect on the hardware path, NOT a baked wall torch.
|
||||
$$ viewer light = intensity 2.25 / falloff 10 (dynamic, d3dIdx=1).
|
||||
$$
|
||||
$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset):
|
||||
$$ LIGHTINFO* = poi(@esp+4). color@+0x50 (r/g/b floats), origin@+0x38, intensity@+0x5C, falloff@+0x60.
|
||||
$$
|
||||
$$ Dynamic logging is limited to the first 8 hits (we already characterised them);
|
||||
$$ ALL static hits log. qd when 12 static torches captured OR 1500 total hits (safety).
|
||||
|
||||
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-v2.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
|
||||
r $t0 = 0
|
||||
r $t2 = 0
|
||||
r $t3 = 0
|
||||
|
||||
$$ STATIC wall torches (baked path) — MOVE to trigger. Color (typed + hex) + origin.
|
||||
bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff cone_angle; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3; .echo origin_hex(x,y,z):; dd poi(@esp+4)+0x38 L3; .if (@$t2 >= 12) { qd } .elsif (@$t0 >= 1500) { qd } .else { gc }"
|
||||
|
||||
$$ DYNAMIC lights (portal/viewer) — log first 8 with color, then silent gc.
|
||||
bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .if (@$t3 <= 8) { .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3 }; .if (@$t0 >= 1500) { qd } .else { gc }"
|
||||
|
||||
.printf "v2 armed: STATIC=wall torches (MOVE in/out of hall to trigger), DYNAMIC=portal/viewer; colors expanded. qd at 12 statics or 1500 total.\\n"
|
||||
g
|
||||
50
tools/cdb/a7-fixd-lights.cdb
Normal file
50
tools/cdb/a7-fixd-lights.cdb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
$$
|
||||
$$ A7 Fix D (#140) — wall-torch vs portal light OWNERSHIP + the actual LIGHTINFO
|
||||
$$ values that feed the EnvCell wall bake. 2026-06-18.
|
||||
$$
|
||||
$$ Decomp already settled the render path (workflow wf_f660eb88):
|
||||
$$ STATIC lights -> CPU per-vertex bake (SetStaticLightingVertexColors ->
|
||||
$$ calc_point_light), DOUBLE-clamped (per-light min(scale*color,color) +
|
||||
$$ per-vertex [0,1]) -> walls stay DIM even at intensity=100.
|
||||
$$ DYNAMIC lights -> D3D hardware FF (minimize_envcell_lighting).
|
||||
$$ Render::insert_light copies intensity VERBATIM to BOTH paths, so the only
|
||||
$$ open empirical question is: which light carries intensity=100, and what do
|
||||
$$ the actual wall-torch LIGHTINFOs look like (intensity/falloff/color)?
|
||||
$$
|
||||
$$ CLASSIFICATION via config_hardware_light's d3dLightIndex (arg1 @ [esp+4]):
|
||||
$$ add_dynamic_light base index = 1 -> dynamic idx in [1..10] (viewer light / teleport PORTAL)
|
||||
$$ add_static_light base index = 11 -> static idx in [11..70] (WALL TORCHES, baked)
|
||||
$$
|
||||
$$ config_hardware_light(d3dIndex, _D3DLIGHT9* out, ulong cellID, LIGHTINFO* info):
|
||||
$$ d3dIndex = dwo(@esp+4) ; LIGHTINFO* = poi(@esp+0x10) (PROVEN last session)
|
||||
$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset):
|
||||
$$ LIGHTINFO* = poi(@esp+4)
|
||||
$$ `dt acclient!LIGHTINFO <ptr> type intensity falloff color` resolves the
|
||||
$$ float fields symbolically (PDB types) -> readable values, no hex reinterp.
|
||||
$$
|
||||
$$ USAGE: with retail in-world standing in/near the Holtburg meeting hall by a
|
||||
$$ wall torch, WALK around the hall (and past the teleport portal if present)
|
||||
$$ for ~15 s so static torch sets re-register. Auto-detaches (qd) after 600
|
||||
$$ total hits, leaving retail running.
|
||||
|
||||
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-capture.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
|
||||
r $t0 = 0
|
||||
r $t1 = 0
|
||||
r $t2 = 0
|
||||
r $t3 = 0
|
||||
|
||||
$$ BP1: config_hardware_light — EVERY light (static+dynamic); d3dIdx classifies.
|
||||
bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d (1-10=DYNAMIC portal/viewer, 11+=STATIC torch)\\n\", @$t1, dwo(@esp+4); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
|
||||
|
||||
$$ BP2: add_static_light — every hit is a WALL TORCH (baked path).
|
||||
bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
|
||||
|
||||
$$ BP3: add_dynamic_light — viewer light + teleport PORTAL (hardware path).
|
||||
bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
|
||||
|
||||
.printf "a7-fixd-lights armed: BP1 CHL (classify via d3dIdx), BP2 STATIC=torch, BP3 DYNAMIC=portal/viewer. qd after 600 total hits.\\n"
|
||||
g
|
||||
18
tools/cdb/a7-fixd-numstatic-probe.cdb
Normal file
18
tools/cdb/a7-fixd-numstatic-probe.cdb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
$$ A7 Fix D — instant (breakpoint-free) read of how many STATIC lights the
|
||||
$$ current scene bakes with. Confirms whether the meeting hall has static torches
|
||||
$$ (-> D-1 summed-torches matters) or near-zero (-> D-2 leaked-SSBO is the cause).
|
||||
$$ Stand where the meeting-hall walls are visible. No movement / no breakpoints.
|
||||
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-numstatic-probe.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
.echo === x acclient!*world_lights* ===
|
||||
x acclient!*world_lights*
|
||||
.echo === x acclient!Render::world_lights ===
|
||||
x acclient!Render::world_lights
|
||||
.echo === dt typed (num_static_lights / num_dynamic_lights / ambient_color) ===
|
||||
dt acclient!Render::world_lights num_static_lights num_dynamic_lights ambient_color sunlight_color
|
||||
.echo === dt LightParms at symbol (fallback by explicit type) ===
|
||||
dt acclient!LightParms acclient!Render::world_lights num_static_lights num_dynamic_lights
|
||||
.echo === END ===
|
||||
qd
|
||||
Loading…
Add table
Add a link
Reference in a new issue