docs(lighting): #142 spec — per-instance sun gate for windowed interiors
Decomp + in-game probe (agent-arcanum-probe.log) show the ambient regime is already retail-faithful (CellManager::ChangePosition 0x004559B0); only the sun is wrong — acdream gates it per-FRAME, retail per-STAGE (useSunlight 0x0054d450). Fix = a per-instance indoor flag (reusing IndoorObjectReceivesTorches, the AP-43 predicate) gating the sun off for indoor mode-0 objects. No second ambient, UpdateSunFromSky + EnvCellRenderer unchanged. User pre-approved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7f3e0887b
commit
cd024377a0
1 changed files with 245 additions and 0 deletions
245
docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md
Normal file
245
docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# #142 — windowed-building interiors read "like outdoors": per-instance sun gate
|
||||||
|
|
||||||
|
**Date:** 2026-06-20 **Status:** user pre-approved (2026-06-20), ready to implement
|
||||||
|
**Milestone:** M1.5 "Indoor world feels right"
|
||||||
|
**Predecessor brainstorm:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md`
|
||||||
|
**Companion memory:** `claude-memory/reference_retail_ambient_values.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Standing inside (or looking into) a **windowed** building — e.g. the Holtburg
|
||||||
|
Agent of Arcanum — the interior reads "like outdoors": flat, sun-shaded, not the
|
||||||
|
warm torch-lit room retail shows. Dungeons (sealed cells) already look right.
|
||||||
|
|
||||||
|
## 2. Root cause (decomp-verified + in-game-probe-confirmed)
|
||||||
|
|
||||||
|
acdream's lighting **REGIME** has two halves. One is already faithful; one is not.
|
||||||
|
|
||||||
|
**Ambient — already faithful, DO NOT CHANGE.** Retail sets a single global ambient
|
||||||
|
once per frame in `CellManager::ChangePosition` (0x004559B0), keyed on the player's
|
||||||
|
current cell:
|
||||||
|
|
||||||
|
- `curr_cell->seen_outside != 0` (windowed or outdoor) → ambient = **sky ambient**
|
||||||
|
(`SetWorldAmbientLight(smartbox_2, calc_object_light(), LScape::ambient_color)`)
|
||||||
|
- `seen_outside == 0` (sealed) → ambient = **flat (0.2,0.2,0.2)**
|
||||||
|
(`SetWorldAmbientLight(smartbox_1, 0.2f, 0xffffffff)`)
|
||||||
|
|
||||||
|
acdream's `UpdateSunFromSky(playerInsideCell)` already does exactly this (flat 0.2
|
||||||
|
for sealed, sky ambient otherwise). Adding a *second* indoor ambient would diverge
|
||||||
|
**away** from retail. The in-game probe (2026-06-20) confirmed the interior ambient
|
||||||
|
is the sky value `(0.43,0.43,0.51)` — correct.
|
||||||
|
|
||||||
|
**Sun — the bug. Retail gates it per-DRAW-STAGE; acdream gates it per-FRAME.**
|
||||||
|
`PView::DrawCells` (0x005a4840) draws each frame in two stages:
|
||||||
|
|
||||||
|
1. outdoor stage: `useSunlightSet(1)` (0x005a485a) → sun ON
|
||||||
|
2. interior-cell stage: `useSunlightSet(0)` (0x005a49f3) → sun OFF
|
||||||
|
|
||||||
|
`useSunlightSet` (0x0054d450) **only toggles the sun** (`Render::useSunlight` + the
|
||||||
|
sun's active-light slot) — it never touches ambient. Per-object torch lighting is
|
||||||
|
gated on the same flag: `minimize_object_lighting` (0x0054d480) runs only when
|
||||||
|
`Render::useSunlight == 0` (`DrawMeshInternal` 0x0059f398). So **every interior
|
||||||
|
draw gets no sun + torches**, even while the player stands in a windowed building
|
||||||
|
with sky visible.
|
||||||
|
|
||||||
|
acdream gates the sun **per frame**: `UpdateSunFromSky` zeroes the sun only when the
|
||||||
|
player is in a **sealed** cell. A windowed building is `seen_outside = true`, so
|
||||||
|
acdream leaves the sun on for the whole frame and the interior objects get sunlit.
|
||||||
|
|
||||||
|
### In-game probe evidence (`agent-arcanum-probe.log`, 2026-06-20)
|
||||||
|
|
||||||
|
The Agent of Arcanum is a **windowed EnvCell building**:
|
||||||
|
```
|
||||||
|
[cell-transit] 0xA9B40031 -> 0xA9B40170 -> 0xA9B40171 -> 0xA9B40172 (land cell → EnvCells)
|
||||||
|
[light] insideCell=False ambient=(0.426,0.426,0.507) sun=1 ... playerCell=0xA9B40172
|
||||||
|
```
|
||||||
|
`playerCell` low-16-bits `0x0172 >= 0x0100` ⟹ EnvCell. `insideCell=False` inside it
|
||||||
|
⟹ `SeenOutside=true` (windowed). **`sun=1` while indoors** ⟹ the bug. Warm torches
|
||||||
|
ARE present + selected (`[light-detail]` intensity-100 cream/orange points). So the
|
||||||
|
only wrong term is the directional sun washing over the interior objects.
|
||||||
|
|
||||||
|
The mode-1 EnvCell **walls** are already correct: the shader's sun loop is gated on
|
||||||
|
`uLightingMode == 0`, so walls never get the sun, and `EnvCellRenderer` already
|
||||||
|
binds their torch set (the old "D-2" gap is fixed). **Only mode-0 objects (furniture,
|
||||||
|
NPCs, the player) inside an EnvCell get the erroneous sun.**
|
||||||
|
|
||||||
|
## 3. Design — per-instance sun gate
|
||||||
|
|
||||||
|
Mirror AP-43's per-object torch decision. Add a 1-bit-per-instance "indoor" flag,
|
||||||
|
derived from the **same** `IndoorObjectReceivesTorches(ParentCellId)` predicate
|
||||||
|
AP-43 already computes, and gate the sun off for indoor mode-0 objects. This makes
|
||||||
|
the sun per-draw, exactly matching retail's per-stage `useSunlight`.
|
||||||
|
|
||||||
|
**Rejected alternatives (record why, do not revisit):**
|
||||||
|
- *Dual indoor/outdoor ambient* (the handoff's Fork-1): retail uses one global
|
||||||
|
ambient keyed on the player cell, which acdream already replicates. Diverges from
|
||||||
|
retail. The probe confirmed the ambient is already correct.
|
||||||
|
- *Split indoor/outdoor into separate draw passes*: DO-NOT-RETRY — fights the
|
||||||
|
batched bindless-MDI (same reasoning that put AP-43 per-object).
|
||||||
|
|
||||||
|
### 3.1 Shader — `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`
|
||||||
|
|
||||||
|
Add the binding=6 SSBO declaration alongside the existing per-instance buffers
|
||||||
|
(binding=3 clip slot, binding=5 light set):
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
|
||||||
|
// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
|
||||||
|
// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
|
||||||
|
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
|
||||||
|
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
|
||||||
|
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
||||||
|
uint instanceIndoor[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
In `accumulateLights`, nest the indoor check **inside** the existing
|
||||||
|
`if (uLightingMode == 0)` sun block:
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
if (uLightingMode == 0) {
|
||||||
|
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`uLightingMode` is a uniform, so the `instanceIndoor[]` read is uniform-control-flow
|
||||||
|
gated — a mode-1 draw never executes it. The torch/point-light accumulator below is
|
||||||
|
untouched (indoor objects keep their torches).
|
||||||
|
|
||||||
|
### 3.2 Dispatcher — `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||||||
|
|
||||||
|
The per-instance flag is a `uint` per instance — mechanically identical to the
|
||||||
|
**binding=3 clip-slot** buffer (`_clipSlotData` / `_clipSlotSsbo`). Mirror it. The
|
||||||
|
per-entity *value* is the **same decision** as the torch set, computed in
|
||||||
|
`ComputeEntityLightSet`. Sites (line numbers @ HEAD, will drift — match by symbol):
|
||||||
|
|
||||||
|
1. **Per-entity decision.** Add a field beside `_currentEntityLightSet` (~:152):
|
||||||
|
```csharp
|
||||||
|
private bool _currentEntityIndoor;
|
||||||
|
```
|
||||||
|
In `ComputeEntityLightSet` (~:2050), set it at the top from the existing
|
||||||
|
predicate, and reuse it for the early-return so the two can't disagree:
|
||||||
|
```csharp
|
||||||
|
_currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId);
|
||||||
|
Array.Fill(_currentEntityLightSet, -1);
|
||||||
|
var snap = _pointSnapshot;
|
||||||
|
if (snap is null || snap.Count == 0) return;
|
||||||
|
if (!_currentEntityIndoor) return; // was: if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return;
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Per-instance storage.** Add to `InstanceGroup` (~:2419), after `LightSets`:
|
||||||
|
```csharp
|
||||||
|
// #142: per-instance "indoor" flag, parallel to Matrices. 1 = EnvCell-parented
|
||||||
|
// (skip the sun); 0 = outdoor. Written into _indoorData at the same cursor as
|
||||||
|
// Matrices, so binding=6 instanceIndoor[] tracks the binding=0 instance.
|
||||||
|
public readonly List<uint> IndoorFlags = new();
|
||||||
|
```
|
||||||
|
Clear it where the other per-group lists are cleared (~:926):
|
||||||
|
```csharp
|
||||||
|
grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear();
|
||||||
|
```
|
||||||
|
Append it per instance in `AppendCurrentLightSet` (~:2084) — it already runs once
|
||||||
|
per instance from both emit sites (~:2019, ~:2145), so folding it in covers both:
|
||||||
|
```csharp
|
||||||
|
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||||
|
grp.LightSets.Add(_currentEntityLightSet[k]);
|
||||||
|
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Backing array + SSBO.** Beside `_clipSlotData` / `_clipSlotSsbo`:
|
||||||
|
```csharp
|
||||||
|
private uint[] _indoorData = new uint[256]; // grown to totalInstances like _clipSlotData
|
||||||
|
private uint _instIndoorSsbo; // binding=6
|
||||||
|
```
|
||||||
|
Generate it where `_clipSlotSsbo` / `_instLightSetSsbo` are generated (~:352);
|
||||||
|
delete it where they are deleted (~:2226).
|
||||||
|
|
||||||
|
4. **Pack + upload.** In the cursor loop (~:1419), beside the clip-slot write:
|
||||||
|
```csharp
|
||||||
|
_clipSlotData[cursor] = grp.Slots[i];
|
||||||
|
_indoorData[cursor] = grp.IndoorFlags[i]; // #142, parallel to binding=0
|
||||||
|
```
|
||||||
|
Add the grow-check beside the others (~:1390) and upload+bind beside the clip-slot
|
||||||
|
upload (~:1515), using the existing `UploadSsbo` helper (it binds via
|
||||||
|
`BindBufferBase`):
|
||||||
|
```csharp
|
||||||
|
fixed (uint* dp = _indoorData)
|
||||||
|
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 `UpdateSunFromSky` — UNCHANGED
|
||||||
|
|
||||||
|
Its per-frame ambient regime (flat 0.2 sealed / sky otherwise) IS retail's
|
||||||
|
`ChangePosition` and the probe confirmed it correct. Its per-frame sun-zero for
|
||||||
|
sealed frames stays: it correctly models "a sealed dungeon runs only the interior
|
||||||
|
stage" and harmlessly guards transient null-cell frames; the new per-instance flag
|
||||||
|
adds the windowed/look-in coverage it was missing. The two together reproduce
|
||||||
|
retail's per-stage `useSunlight` for every case. **Do not touch the ambient values
|
||||||
|
or the sun colors here.**
|
||||||
|
|
||||||
|
### 3.4 `EnvCellRenderer` — UNCHANGED
|
||||||
|
|
||||||
|
Mode-1 draws set `uLightingMode = 1`, so the `instanceIndoor[]` read (inside the
|
||||||
|
uniform `uLightingMode == 0` branch) is never executed for EnvCell shells. The walls
|
||||||
|
already skip the sun and already get their torch set. No binding=6 bind needed.
|
||||||
|
|
||||||
|
## 4. Divergence register
|
||||||
|
|
||||||
|
Update **AP-43** in `docs/architecture/retail-divergence-register.md` in the same
|
||||||
|
commit: the regime is now per-draw for **both** torches (AP-43 original) and the sun
|
||||||
|
(this change), matching retail's per-stage `useSunlight`. Narrow the row to whatever
|
||||||
|
residual remains (e.g. the `ebp_2` term in `ChangePosition`'s seen_outside test,
|
||||||
|
unaudited), or close it if nothing remains. Do not leave it claiming the sun half is
|
||||||
|
unfixed.
|
||||||
|
|
||||||
|
## 5. Tests
|
||||||
|
|
||||||
|
`tests/AcDream.App.Tests/` (or the nearest existing dispatcher test project):
|
||||||
|
|
||||||
|
1. **Predicate-to-flag pin.** Assert the per-instance indoor flag equals
|
||||||
|
`WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)` for representative
|
||||||
|
ids: EnvCell `0xA9B40172` → 1; land sub-cell `0xA9B40031` → 0; landblock
|
||||||
|
`0xA9B4FFFF` → 0; `null` → 0. (The static predicate is the single source of truth
|
||||||
|
for both torches and the sun gate.)
|
||||||
|
2. If a lightweight path exists to drive `WbDrawDispatcher` instance emission in a
|
||||||
|
test (mirror any existing light-set/clip-slot dispatcher test), assert the
|
||||||
|
uploaded `_indoorData[cursor]` matches each entity's `ParentCellId` decision.
|
||||||
|
Optional if no such harness exists — do not build GL plumbing for it.
|
||||||
|
|
||||||
|
`dotnet build` + `dotnet test` green (App / Core / UI / Net suites) before done.
|
||||||
|
|
||||||
|
## 6. Faithfulness guardrails (DO NOT DRIFT)
|
||||||
|
|
||||||
|
- Do **not** add a second ambient or change `UpdateSunFromSky`'s ambient/sun values.
|
||||||
|
- Do **not** touch the torch/point-light path or shorten `Falloff × 1.3`.
|
||||||
|
- Keep the predicate exactly `IndoorObjectReceivesTorches` — same one AP-43 uses.
|
||||||
|
- Do **not** split the MDI into indoor/outdoor passes.
|
||||||
|
- The flag is per-INSTANCE (parallel to Matrices), never per-group.
|
||||||
|
|
||||||
|
## 7. Acceptance gate (visual)
|
||||||
|
|
||||||
|
Launch with `testaccount2` / `testpassword2`, go to the **Agent of Arcanum**: looking
|
||||||
|
in from outside AND standing inside, the interior should read warm/torch-lit (no sun
|
||||||
|
wash) instead of "like outdoors"; outdoor world through the windows still sunlit;
|
||||||
|
dungeons + the sealed meeting hall unchanged.
|
||||||
|
|
||||||
|
## 8. Decomp anchors
|
||||||
|
|
||||||
|
`CellManager::ChangePosition` 0x004559B0 (per-frame ambient + sun keyed on player
|
||||||
|
`curr_cell->seen_outside`; the 0.2/sky split @ pc:94682-94712) · `PView::DrawCells`
|
||||||
|
0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3) ·
|
||||||
|
`useSunlightSet` 0x0054d450 (sun only, never ambient) · `DrawMeshInternal` 0x0059f398
|
||||||
|
(`useSunlight==0` gate on per-object lighting) · `minimize_object_lighting` 0x0054d480
|
||||||
|
· `IndoorObjectReceivesTorches` (`WbDrawDispatcher.cs`, AP-43).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue