fix(overhaul): integrate reviewed room-light selection repair
Exact26 code/test/architecture/register blobs from621b41fa3; campaign ledger and lead verification included. Independent retail and production/lifetime/ABI reviews PASS. Lead69Core/176App/2actualshader pixels, viewer/clear/NaN negative controls fail as intended, exact restoration69PASS. AP68retired; AP16/35/85 residuals honest. Fresh campaign Release and graphical lighting proof still owed; temporary observer cleanup contract conditional. FPS deferred; no G4 or main merge.
This commit is contained in:
parent
e8efe1131f
commit
a5debaca2b
30 changed files with 928 additions and 399 deletions
|
|
@ -1195,6 +1195,17 @@ no reconstructed fallback. Login and portal-space retain
|
|||
last-frame point-light feedback seam are deleted. Directional shadows remain
|
||||
the next typed landscape consumer (S5-c2).
|
||||
|
||||
Point-light retention follows retail's two independent products: seven dynamic
|
||||
and forty static candidates, with POINT lights ranked from the light owner's
|
||||
explicit root origin using stable forward insertion; SPOT/non-point lights rank
|
||||
at zero. Authored offsets affect the final light position but not the rank. The viewer light is offered to the dynamic product
|
||||
first even when its registration is late. EnvCell shells consume the complete
|
||||
47-entry product through their mode-specific binding-5 stride, while ordinary
|
||||
Gfx/building objects retain the separate eight-entry hardware selection and
|
||||
stride. Both ordinary and atmospheric mesh shaders select that stride from the
|
||||
existing lighting mode; the attenuation, ambient, packing, and per-object
|
||||
selection formulas are otherwise shared and unchanged.
|
||||
|
||||
WorldBuilder contributes DAT decoding, mesh preparation, residency, and Vulkan
|
||||
batch mechanics only; it makes no visibility or ordering decision. Forced merge
|
||||
breaks at walk event boundaries preserve retail order while compatible draws
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -797,8 +797,14 @@ separation design follows exact retail ranking-frame trace; no FPS work.
|
|||
**#478 repair contract:** `478-room-light-selection-repair-contract.md`
|
||||
authorizes independent default7/40 root-ranked light products and complete
|
||||
47-slot EnvCell consumption, ordinary objects8 unchanged. Same-pose evidence
|
||||
precedes temporary observer removal. Ambient/formulas/FPS untouched; not yet
|
||||
implemented or accepted. G4FAIL/no merge.
|
||||
precedes temporary observer removal. Reviewed scratch621b41fa3 now integrates
|
||||
all26 blobs unchanged: independent retail and production/lifetime/ABI PASS,
|
||||
lead69Core/176App/2native pixels, three additional discriminating mutations
|
||||
and exact restoration69PASS. AP68retired; AP16/35/85 and architecture current.
|
||||
See `478-room-lights-lead-verification.md`. Fresh campaign Release and exact
|
||||
Town/control graphical evidence remain owed; observer removal contract is
|
||||
prepared but inactive until those frames pass. Ambient/formulas/FPS untouched.
|
||||
G4FAIL/no merge.
|
||||
|
||||
## 14. Risk register (condensed)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
# #478 temporary lighting observer removal
|
||||
|
||||
Prepared only; activate after the repaired candidate's unchanged Town
|
||||
High/off/High and ordinary-dungeon control PNGs are inspected. Name that
|
||||
candidate and its evidence before dispatch. A failed visual check returns to
|
||||
the lighting chunk's bounded fix-round rule; this cleanup cannot hide failure.
|
||||
|
||||
Remove only the temporary checkpoint observer introduced at59b0b6e72 and its
|
||||
#478 shape adaptation: EnvCellRenderer.LightingObservation.cs, the optional
|
||||
resource snapshot field, the snapshot-source renderer/LightManager arguments,
|
||||
capture helper and composition wiring. Remove only corresponding observer
|
||||
assertions/tests from FrameRootCompositionTests, WorldLifecycleAutomation-
|
||||
ControllerTests and EnvCellRendererTests. Preserve unrelated lifecycle and
|
||||
render-scene checkpoint evidence.
|
||||
|
||||
Keep all permanent #478 correctness coverage, especially
|
||||
SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration and its
|
||||
required seeding helpers, ordinary eight-index upload, both committed-shader
|
||||
pixel witnesses, root/order/7+40 fixtures and actual Town DAT pin. No light
|
||||
selection, formula, shader, generated binary, pack, geometry or FPS changes.
|
||||
|
||||
One bounded OpenAI implementer; no client launches or commits. Return exact
|
||||
diff plus affected Release tests and full Release build, checking both client
|
||||
names before each command. Lead checks removal against59b0b6e72 and reviews
|
||||
the retained regression paths; no new retail algorithm is introduced. No new
|
||||
deviation is expected; disclose any actual deviation before proceeding.
|
||||
|
||||
Lead commits, builds Release green and repeats the unchanged lighting/control
|
||||
routes on the observer-free binary. Record PNG PASS/FAIL provisionally. The
|
||||
earlier candidate's consumed-light membership remains evidence for THAT
|
||||
candidate, never relabeled as a measurement of the observer-free binary.
|
||||
No main merge before G4; FPS remains deferred.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# #478 room-light repair — lead verification
|
||||
|
||||
REVIEW-CLOSED: scratch621b41fa3 independently checked; all26 reviewed blobs
|
||||
integrate unchanged with this record. Not graphically accepted yet.
|
||||
Contract4bc208b57, scratch `s5-478-room-lights`;
|
||||
campaign route-only follow-up e8efe1131. FPS deferred; G4FAIL/no merge.
|
||||
|
||||
## Independently established inputs and boundaries
|
||||
|
||||
The contract and `478-480-readonly-diagnosis.md` seal the named-retail / paired
|
||||
binary proof for separate40static/7dynamic caps, every-insertion ordering,
|
||||
root-frame ranking before authored offset composition, viewer-first insertion,
|
||||
and EnvCell all-dynamic plus independent all-static consumption. The activated
|
||||
Town Network defect and exact bound inputs are in
|
||||
`478-479-graphical-observations.md` R2. These are not matched-retail pixel proof.
|
||||
|
||||
Lead separately traced the modern production inputs: static publication calls
|
||||
LightInfoLoader with entity.Position/Rotation; LiveEntityLightController does
|
||||
the same and LightingHookSink refreshes attached lights from the canonical
|
||||
root pose. The only production LightSource constructors are that loader,
|
||||
the viewer and the sun. WorldRenderFrameBuilder calls UpdateViewerLight,
|
||||
then Tick(camera), then BuildPointLightSnapshot(player) before lending the
|
||||
same snapshot to the dispatcher and EnvCell renderer. Thus Build may safely
|
||||
replace DistSq with the retained root rank before selection/observation.
|
||||
No shader reads DistSq; final light position still feeds contribution math.
|
||||
|
||||
EnvCell RHI uses LightingMode1 and TextureIndexB0; ordinary Wb paths remain
|
||||
MaxLightsPerObject8. Shader internal light-array stride is a real changed
|
||||
contract and is pinned together with its CPU upload. Set numbers, buffer
|
||||
element format and push/uniform layouts do not change. The built-in pack's
|
||||
declared mesh receiver is WorldMesh and ordinary mode remains8; no public
|
||||
RenderPipelineBaseSemantic.EnvCell production use was found in src/tests.
|
||||
Both built-in vertex families must nevertheless pass the explicit mode1/mode0
|
||||
compiled-module witness. No general pack API expansion is part of this repair.
|
||||
|
||||
## Existing authored-type projection limitation (not repaired here)
|
||||
|
||||
Lead inspected the installed Chorizite.DatReaderWriter2.1.7 assembly metadata
|
||||
and actual Setup.Unpack IL. Setup.Lights is Dictionary<int,LightInfo>:
|
||||
IL0236 ReadInt32 → IL0243 ReadItem<LightInfo> → IL0254 dictionary Add.
|
||||
LightInfo exposes only ViewSpaceLocation, Color, Intensity, Falloff, ConeAngle.
|
||||
Named CSetup::UnPack00521307 stores that leading word in LIGHTINFO.type,
|
||||
then00521316 unpacks Frame at+4. Native span00521301/raw00121301/77bytes has
|
||||
SHA2564A45A458A56C043ECA0B8CC824843D632E2A4E67698BA6194B1499549CA97EC7;
|
||||
its leading bytes72488B0E8B11895500 implement the guarded word read/store.
|
||||
Parser DLL SHA2565DF9E0220EB5674FE8AC7C9477C9DF85DB10875FAD3726FAA9BEDB9D26A58F7E.
|
||||
|
||||
The existing loader derives modern Kind from ConeAngle instead of that key.
|
||||
The #478 contract deliberately leaves that projection unchanged; AP-85 must
|
||||
disclose it alongside numerical/cell-offset and residency/degrade residuals.
|
||||
POINT-versus-SPOT rank tests prove behavior for supplied modern kinds, NOT
|
||||
faithful projection of every authored type. The actual Town fixture must
|
||||
report key and cone; no speculative extra kind/type port is authorized.
|
||||
|
||||
## Review and execution pending
|
||||
|
||||
Lead read the initial source delta and inspected both compiled modules with
|
||||
the installed spirv-dis: mode1 selects constant47, otherwise8; that selected
|
||||
value supplies instance multiplication. At that inspection the module hashes
|
||||
were9909CA4729DBE4FC7FBFFB11C73481977D8F593D37F130FCDD44A7803B47944F
|
||||
(modern) andAE5DD5F4B9B2891A669340FD9712169BD1EC58BCF8C596E1ABDAF71997420231
|
||||
(atmospheric). Final hashes and complete frozen diff still need rechecking.
|
||||
This static inspection is not the pending actual-pixel result.
|
||||
|
||||
Before native shader mutations, lead required padding the synthetic ordinary
|
||||
light-index witness to94 entries with -1 sentinels. Correct indices remain7/15;
|
||||
the wrong47 mutation must fail by color/index behavior, not execute an
|
||||
out-of-bounds GPU read. Production ordinary upload extent is checked separately.
|
||||
Permanent RHI regression tests must survive removal of the temporary observer.
|
||||
|
||||
## Frozen return and initial lead evidence corrections
|
||||
|
||||
The implementer froze26 files at14:47+02. Lead verified all26 against
|
||||
`artifacts/478-room-lights/final-source-hashes.json`, checked the complete
|
||||
source/generated/test/doc diff, and independently counted159 active AP rows.
|
||||
Only AP-68 retires; AP-16/AP-35/AP-85 carry the disclosed limitations.
|
||||
Both final SPIR-V hashes match the values above. No contribution-formula,
|
||||
ambient, scene-specific, package, or FPS edits are present.
|
||||
|
||||
Initial lead review corrected evidence, not the lighting algorithm: a purported
|
||||
viewer-root tie actually used different roots; Clear's assertion was delayed
|
||||
until after a rebuild; a NaN-first fixture did not discriminate forward from
|
||||
backward insertion. The final tests now use a genuine root tie, assert empty
|
||||
immediately after Clear, and pin finite-far/NaN/finite-near to near/far/NaN.
|
||||
The table-walker citation was corrected to CEnvCell::add_dynamic_lights0052D410
|
||||
(which calls the per-cell feeder0052B390); architecture now states POINT-only
|
||||
distance rank. No frozen production fix round has been required.
|
||||
|
||||
Actual final implementer artifacts: full Release0W0E; Core69/69 (all Lighting
|
||||
plus installed Town fixture), App176/176, committed-module native pixels2/2.
|
||||
The first solution build lacked project.assets.json in the fresh scratch;
|
||||
normal restore resolved39NETSDK1004 errors. No compilation repair was hidden.
|
||||
Seven intended mutations each failed their named assertion. The first
|
||||
mutation4/5 invocations discovered zero tests due to typoed filters; only
|
||||
their r2 failures count. Mutation6 used94 in-bounds sentinel entries and failed
|
||||
by wrong color, not unsafe out-of-bounds GPU access.
|
||||
|
||||
Lead independently reran the frozen positives, both clients absent before
|
||||
each invocation: `root-positive-core/root-core.trx`69/69,
|
||||
`root-positive-app/root-app.trx`176/176, and
|
||||
`root-positive-pixels/root-shader-pixels.trx`2/2, all under the scratch's
|
||||
`artifacts/478-room-lights/`. These are narrow correctness suites, not full
|
||||
solution or FPS gates. Native pixels exercise actual committed shaders on
|
||||
synthetic quads; they are not an installed-world visual acceptance.
|
||||
|
||||
Wegener's independent production/lifetime/ABI review PASS: no findings,
|
||||
all26seals checked, no reviewer executions or edits. Independent retail
|
||||
review PASS: all26seals, paired executable and ten native spans independently
|
||||
matched, no findings. The stale prior retail-agent continuation was unavailable;
|
||||
one fresh bounded OpenAI retail reviewer took that same lens, not an extra
|
||||
implementation or review round.
|
||||
|
||||
Lead additionally executed three independent negative controls on the final
|
||||
source: ordinary-registry viewer insertion failed the actual equal-root test
|
||||
(`Assert.NotSame`); omitting Clear's snapshot clearing failed the immediate
|
||||
`Assert.Empty`; backward insertion failed the finite-far/NaN/finite-near exact
|
||||
order. Each ran1test/1intended failure, with exact raw source restoration
|
||||
SHA256C141B2B2234BB94D25663B588A0142A3D520859276CE70A41F9F3659D989C0D5
|
||||
after EACH control. Artifacts: `root-mutation-viewer`, `root-mutation-clear`,
|
||||
`root-mutation-nan` under the same scratch artifact directory. Final rebuilt
|
||||
`root-restored-core/root-restored-core.trx`69/69; all26file seals rechecked.
|
||||
Lead did not re-execute the implementer's seven other mutations: their actual
|
||||
TRX failure text and source restoration evidence were inspected independently.
|
||||
|
||||
Pending: fresh integration Release,
|
||||
unchanged Town High/off/High graphical proof, ordinary dungeon controls,
|
||||
observer cleanup and final observer-free re-gate. No PASS inferred in advance.
|
||||
|
|
@ -4957,3 +4957,18 @@ same Town route remains unchanged. Prepared `route-478-dungeon-controls.txt`
|
|||
uses the already selected0125/8C04 poses, High/off still frames and Holtburg
|
||||
return after the repair. This is a lighting/reveal smoke, not full dungeon
|
||||
stairs/camera-seam coverage and not an FPS benchmark. No run/result yet.
|
||||
|
||||
Reviewed return621b41fa3 now integrates with all26 blobs unchanged. Separate
|
||||
independent OpenAI retail and production/lifetime/ABI lenses PASS; lead
|
||||
checked every source/generated/doc change and native identity, independently
|
||||
repeated69Core/176App/2committed-shader pixels. Seven implementer controls
|
||||
have actual intended failures (two zero-test typo-filter precursors excluded).
|
||||
Lead additionally mutated viewer-first tie, immediate Clear and forward NaN
|
||||
ordering: each1/1intended failure, exact raw restoration, rebuilt69PASS.
|
||||
Full scratch Release0W0E after normal fresh-worktree restore. Evidence:
|
||||
`478-room-lights-lead-verification.md`. AP68retired,159active AP rows;
|
||||
AP16/35/85 preserve ordinary-selection/bake/residency/degrade/numerical/type
|
||||
projection limits. No ambient/formula/FPS edits or graphical PASS yet.
|
||||
Fresh integration Release and unchanged Town/control runs next. Prepared
|
||||
`478-observer-removal-contract.md` activates only after inspected candidate
|
||||
frames; then final observer-free re-gate. G4FAIL/no merge.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ internal static class GpuBindingModel
|
|||
/// <summary>A7 Fix B global point/spot light array.</summary>
|
||||
public const uint StorageGlobalLights = 4;
|
||||
|
||||
/// <summary>A7 Fix B per-instance light set: 8 indices into the global light array, -1 = unused.</summary>
|
||||
/// <summary>
|
||||
/// Per-instance indices into the global light array: object submissions use
|
||||
/// stride 8; EnvCell submissions use stride 47. Unused entries are -1.
|
||||
/// </summary>
|
||||
public const uint StorageInstanceLightSets = 5;
|
||||
|
||||
/// <summary>#142 per-instance indoor flag (1 = parented to an EnvCell, skip the sun).</summary>
|
||||
|
|
@ -195,4 +198,7 @@ internal static class GpuBindingModel
|
|||
|
||||
/// <summary>Lights selected per object by retail's <c>minimize_object_lighting</c>.</summary>
|
||||
public const int MaxLightsPerObject = 8;
|
||||
|
||||
/// <summary>Complete retained dynamic + static products consumed by EnvCells.</summary>
|
||||
public const int MaxLightsPerEnvCell = 47;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
|||
GlobalLight gLights[];
|
||||
};
|
||||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||||
int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused
|
||||
};
|
||||
|
||||
// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
|
||||
|
|
@ -274,8 +274,9 @@ vec3 accumulateAmbientLocalLights(
|
|||
// 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 lightStride = (uLightingMode == 1) ? 47 : 8;
|
||||
int base = instanceIndex * lightStride;
|
||||
for (int k = 0; k < lightStride; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
|||
GlobalLight gLights[];
|
||||
};
|
||||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||||
int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused
|
||||
};
|
||||
|
||||
// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
|
||||
|
|
@ -257,8 +257,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
|||
// 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 lightStride = (uLightingMode == 1) ? 47 : 8;
|
||||
int base = instanceIndex * lightStride;
|
||||
for (int k = 0; k < lightStride; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -215,7 +215,7 @@
|
|||
"stages": [
|
||||
{
|
||||
"stage": "vert",
|
||||
"sourceSha256": "b1e8e52b0b3dedec6c7dfd29ce3e70deb44d6b18197d6bd312e1a34e543f8337",
|
||||
"sourceSha256": "16c29168aedead79da28151e2cec863634b7818d28c72619f9905b81dabd8571",
|
||||
"compiled": true
|
||||
},
|
||||
{
|
||||
|
|
@ -231,7 +231,7 @@
|
|||
"stages": [
|
||||
{
|
||||
"stage": "vert",
|
||||
"sourceSha256": "d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2",
|
||||
"sourceSha256": "33d5563db8bcd7dc74e4836df2c6064cbd5112d074da5380b0bab333f1d7ece7",
|
||||
"compiled": true
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ internal sealed record EnvCellLightingSourceSnapshot(
|
|||
bool IsDynamic,
|
||||
bool IsLit,
|
||||
bool TracksOwnerPose,
|
||||
EnvCellLightingVectorBits RankingOrigin,
|
||||
EnvCellLightingVectorBits Position,
|
||||
EnvCellLightingVectorBits Forward,
|
||||
EnvCellLightingVectorBits Color,
|
||||
|
|
@ -37,7 +38,7 @@ internal sealed record EnvCellLightingCellSetSnapshot(
|
|||
/// Checkpoint-only copy of the point-light inputs the EnvCell RHI submission
|
||||
/// already consumed. PackedGlobalLightBits is the authoritative binding-4
|
||||
/// payload; Sources records its identity/provenance and CellSets records the
|
||||
/// exact cached eight-index values copied to binding 5.
|
||||
/// exact cached 47-index values copied to binding 5.
|
||||
/// </summary>
|
||||
internal sealed record EnvCellLightingConsumptionSnapshot(
|
||||
bool HasCurrentEnvCellInput,
|
||||
|
|
@ -141,6 +142,7 @@ public sealed partial class EnvCellRenderer
|
|||
IsDynamic: source.IsDynamic,
|
||||
IsLit: source.IsLit,
|
||||
TracksOwnerPose: source.TracksOwnerPose,
|
||||
RankingOrigin: CaptureVector(source.RankingOrigin),
|
||||
Position: CaptureVector(source.WorldPosition),
|
||||
Forward: CaptureVector(source.WorldForward),
|
||||
Color: CaptureVector(source.ColorLinear),
|
||||
|
|
|
|||
|
|
@ -205,9 +205,8 @@ public sealed unsafe partial class EnvCellRenderer
|
|||
}
|
||||
Array.Fill(_instanceAlphaData, 1f, 0, uniqueInstanceCount);
|
||||
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each
|
||||
// shell instance belongs to.
|
||||
int lightStride = LightManager.MaxLightsPerObject;
|
||||
// Complete retained 7-dynamic + 40-static product per EnvCell instance.
|
||||
int lightStride = LightManager.MaxLightsPerEnvCell;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
{
|
||||
_lightSetData = new int[Math.Max(
|
||||
|
|
|
|||
|
|
@ -108,14 +108,15 @@ public sealed partial class EnvCellRenderer :
|
|||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained state,
|
||||
// like uViewProjection) instead of reading whatever WbDrawDispatcher last
|
||||
// bound. Global point-light snapshot (same data/indices as the dispatcher,
|
||||
// via GlobalLightPacker) plus 8 int indices per instance.
|
||||
// via GlobalLightPacker) plus the complete 47-index retained product per
|
||||
// EnvCell instance. Ordinary objects retain their separate 8-index stride.
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private sealed class CachedCellLightSet
|
||||
{
|
||||
public int FrameGeneration;
|
||||
public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
|
||||
}
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<uint, CachedCellLightSet> _cellLightSetCache = new();
|
||||
|
|
@ -1101,13 +1102,11 @@ public sealed partial class EnvCellRenderer :
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Complete per-cell retained point-light product, cached per frame.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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).
|
||||
// A7 Fix D (D-2): all retained 7 dynamic + 40 static candidates. The shader
|
||||
// applies the unchanged per-vertex range cutoff. Unused slots are -1.
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached))
|
||||
|
|
@ -1122,24 +1121,8 @@ public sealed partial class EnvCellRenderer :
|
|||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
// Landblocks are keyed by the streaming landblock id 0xXXYYFFFF
|
||||
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock
|
||||
// key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key
|
||||
// (0xXXYY0000) NEVER matched a registered landblock, so this lookup always
|
||||
// missed: SelectForObject never ran and every EnvCell wall received ZERO
|
||||
// point lights (the entire "indoor torches/lanterns don't light the room"
|
||||
// bug — confirmed by the [cell-light] probe: inBounds=False for every cell).
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, 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;
|
||||
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
|
||||
// dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
|
||||
// let the portal set flip as the flood shifted → floor-lighting flap.
|
||||
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
|
||||
}
|
||||
if (snap is { Count: > 0 })
|
||||
AcDream.Core.Lighting.LightManager.SelectForCell(snap, set);
|
||||
cached.FrameGeneration = _lightFrameGeneration;
|
||||
return set;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ public static class LightInfoLoader
|
|||
{
|
||||
Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point,
|
||||
WorldPosition = worldPos,
|
||||
RankingOrigin = entityPosition,
|
||||
WorldForward = forward,
|
||||
ColorLinear = new Vector3(
|
||||
(info.Color?.Red ?? 255) / 255f,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ using System.Numerics;
|
|||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the registered dynamic lights in the world and picks the 8
|
||||
/// most relevant ones each frame for the shader to consume. Matches
|
||||
/// retail's fixed-function-era "8 hardware lights" constraint (r13
|
||||
/// §12.2).
|
||||
/// Owns the registered world-light set. <see cref="Tick"/> retains the legacy
|
||||
/// directional-plus-eight UBO view, while <see cref="BuildPointLightSnapshot"/>
|
||||
/// builds retail's independent seven-dynamic and forty-static point-light
|
||||
/// products for binding 4/5 consumers.
|
||||
///
|
||||
/// <para>
|
||||
/// Active-light selection algorithm (r13 §12.2), as implemented by
|
||||
|
|
@ -65,6 +65,10 @@ public sealed class LightManager
|
|||
public void Register(LightSource light)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(light);
|
||||
if (light.Kind != LightKind.Directional && !light.HasRankingOrigin)
|
||||
throw new ArgumentException(
|
||||
"A nondirectional light requires an explicit authored ranking origin.",
|
||||
nameof(light));
|
||||
foreach (var existing in _all)
|
||||
if (ReferenceEquals(existing, light)) return;
|
||||
_all.Add(light);
|
||||
|
|
@ -88,6 +92,9 @@ public sealed class LightManager
|
|||
Array.Clear(_active);
|
||||
_activeCount = 0;
|
||||
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
|
||||
_pointSnapshot.Clear();
|
||||
_dynamicPointSnapshot.Clear();
|
||||
_staticPointSnapshot.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -175,30 +182,20 @@ public sealed class LightManager
|
|||
/// 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. #176 root-cause history (2026-07-06, corrected): retail's pool is
|
||||
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
|
||||
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
|
||||
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
|
||||
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
|
||||
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
|
||||
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
|
||||
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
|
||||
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
|
||||
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||||
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||||
/// purples entered/left the pool as the camera turned — the seam-floor
|
||||
/// blink). Current model: all registered
|
||||
/// (=resident) lit lights, then dynamics-first nearest-player, capped here.
|
||||
/// A later last-frame drawable-cell filter was removed after the Facility Hub
|
||||
/// zoom trace proved it recreated the same camera-root coupling: the pool
|
||||
/// collapsed from five lights to one without the player moving. 128 is wider
|
||||
/// than retail's 40+7 — a documented backstop
|
||||
/// that in a properly cell-scoped room only ever evicts far-out-of-range
|
||||
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
|
||||
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
|
||||
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
|
||||
public const int MaxGlobalLights = 128;
|
||||
/// <summary>Retail's independent retained dynamic-light cap.</summary>
|
||||
public const int MaxDynamicPointLights = 7;
|
||||
|
||||
/// <summary>Retail's independent retained static-light cap.</summary>
|
||||
public const int MaxStaticPointLights = 40;
|
||||
|
||||
/// <summary>Combined GPU-index transport capacity: dynamics, then statics.</summary>
|
||||
public const int MaxGlobalLights = MaxDynamicPointLights + MaxStaticPointLights;
|
||||
|
||||
/// <summary>
|
||||
/// EnvCell shells consume the complete retained dynamic and static products.
|
||||
/// Ordinary objects retain <see cref="MaxLightsPerObject"/>.
|
||||
/// </summary>
|
||||
public const int MaxLightsPerEnvCell = MaxGlobalLights;
|
||||
|
||||
private readonly List<LightSource> _pointSnapshot = new();
|
||||
|
||||
|
|
@ -209,27 +206,23 @@ public sealed class LightManager
|
|||
/// <see cref="BuildPointLightSnapshot"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
|
||||
internal bool LastPointSnapshotUsedBoundedSelection { get; private set; }
|
||||
internal bool LastPointSnapshotUsedTieFallback { get; private set; }
|
||||
|
||||
// Slice H-b: keep only the best MaxGlobalLights entries in a retained
|
||||
// max-heap. Rank includes qualifying registration order because retail
|
||||
// insert_light (0x0054D1B0) advances past equal-distance residents.
|
||||
private readonly List<RankedLight> _pointSelectionHeap =
|
||||
new(MaxGlobalLights);
|
||||
private Comparison<RankedLight>? _rankComparison;
|
||||
private Vector3 _legacyPoolAnchor;
|
||||
private Comparison<LightSource>? _legacyPoolComparison;
|
||||
private readonly List<LightSource> _dynamicPointSnapshot =
|
||||
new(MaxDynamicPointLights);
|
||||
private readonly List<LightSource> _staticPointSnapshot =
|
||||
new(MaxStaticPointLights);
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
|
||||
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
|
||||
/// The sun and unlit lights are excluded (the sun is global ambient-path;
|
||||
/// unlit torches contribute nothing).
|
||||
/// Rebuild <see cref="PointSnapshot"/> from all registered lit point/spot
|
||||
/// residents as independent seven-dynamic and forty-static products.
|
||||
/// Each product is stable insertion-ranked from the player using the
|
||||
/// authored root origin for points; spots have rank zero. The viewer is
|
||||
/// inserted before ordinary dynamics. The combined snapshot is dynamics
|
||||
/// followed by statics so binding-5 indices are deterministic.
|
||||
/// <para>
|
||||
/// Retail anchors (#176 corrected reading, 2026-07-06):
|
||||
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
|
||||
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
|
||||
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052D410) walks the WHOLE static
|
||||
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry — and calls
|
||||
/// <c>CObjCell::add_dynamic_to_global_lights</c> (0x0052B390) for each entry. The table is the one
|
||||
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
|
||||
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
|
||||
/// entries leave only via the flush machinery). It is NOT the per-frame portal
|
||||
|
|
@ -240,18 +233,10 @@ public sealed class LightManager
|
|||
/// them to every Hub cell) — the faceted purple wedge is faithful.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
|
||||
/// first (retail's dynamic lights live in their own 7-slot pool —
|
||||
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
|
||||
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
|
||||
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
|
||||
/// set from <c>player->m_position</c>, SmartBox 0x00453d3a, with the
|
||||
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
|
||||
/// of PLAYER position and light registration ONLY — camera rotation/position
|
||||
/// cannot change it. Both camera-anchored pools and last-frame drawable-cell
|
||||
/// scoping produced gaze/zoom-dependent membership changes, so neither is an
|
||||
/// input to this retail resident-cell collection. Call once per frame before
|
||||
/// per-object selection.
|
||||
/// Retail's <c>insert_light</c> (0x0054d1b0) uses a strict forward
|
||||
/// <c>newDistance < existingDistance</c> insertion. Equal and unordered
|
||||
/// comparisons therefore advance, which preserves registration order without
|
||||
/// <c>float.CompareTo</c> or an unstable sort.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="playerWorldPos">The player's world position (render position;
|
||||
|
|
@ -260,200 +245,58 @@ public sealed class LightManager
|
|||
public void BuildPointLightSnapshot(Vector3 playerWorldPos)
|
||||
{
|
||||
_pointSnapshot.Clear();
|
||||
_pointSelectionHeap.Clear();
|
||||
int qualifyingOrdinal = 0;
|
||||
bool overflow = false;
|
||||
LastPointSnapshotUsedBoundedSelection = false;
|
||||
LastPointSnapshotUsedTieFallback = false;
|
||||
_dynamicPointSnapshot.Clear();
|
||||
_staticPointSnapshot.Clear();
|
||||
|
||||
// SmartBox inserts the viewer light before collecting ordinary dynamic
|
||||
// residents. Processing it first also preserves its equal-root tie even
|
||||
// though the retained registry may contain earlier registrations.
|
||||
if (_viewerLight is { IsLit: true } viewer)
|
||||
InsertRetained(viewer, playerWorldPos, _dynamicPointSnapshot, MaxDynamicPointLights);
|
||||
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||
var ranked = new RankedLight(
|
||||
if (ReferenceEquals(light, _viewerLight)
|
||||
|| !light.IsLit
|
||||
|| light.Kind == LightKind.Directional)
|
||||
continue;
|
||||
|
||||
InsertRetained(
|
||||
light,
|
||||
qualifyingOrdinal++,
|
||||
Vector3.DistanceSquared(
|
||||
light.WorldPosition,
|
||||
playerWorldPos));
|
||||
_pointSnapshot.Add(light);
|
||||
if (_pointSnapshot.Count <= MaxGlobalLights)
|
||||
continue;
|
||||
|
||||
if (!overflow)
|
||||
{
|
||||
for (int index = 0;
|
||||
index < MaxGlobalLights;
|
||||
index++)
|
||||
{
|
||||
LightSource existing = _pointSnapshot[index];
|
||||
_pointSelectionHeap.Add(new RankedLight(
|
||||
existing,
|
||||
index,
|
||||
Vector3.DistanceSquared(
|
||||
existing.WorldPosition,
|
||||
playerWorldPos)));
|
||||
}
|
||||
HeapifyWorstFirst(_pointSelectionHeap);
|
||||
overflow = true;
|
||||
}
|
||||
|
||||
// Root is the currently-worst selected rank. A later light at the
|
||||
// same distance ranks after an earlier resident, matching retail.
|
||||
if (CompareRankedLights(ranked, _pointSelectionHeap[0]) < 0)
|
||||
{
|
||||
_pointSelectionHeap[0] = ranked;
|
||||
SiftWorstDown(_pointSelectionHeap, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (overflow)
|
||||
{
|
||||
_rankComparison ??= CompareRankedLights;
|
||||
_pointSelectionHeap.Sort(_rankComparison);
|
||||
bool comparatorTie =
|
||||
SelectedRanksContainObservableTie(playerWorldPos);
|
||||
if (comparatorTie)
|
||||
{
|
||||
LastPointSnapshotUsedTieFallback = true;
|
||||
// The previous List.Sort comparator intentionally returned zero
|
||||
// for equal pool/distance ranks. List.Sort is unstable, so its
|
||||
// exact tie permutation is observable in shader indices. Keep
|
||||
// that legacy oracle for tied frames; H-b is performance-only.
|
||||
// AP-85's eventual dual-pool port can adopt retail's stable tie
|
||||
// insertion as a separately visual-gated behavior change.
|
||||
_legacyPoolAnchor = playerWorldPos;
|
||||
_legacyPoolComparison ??= CompareLegacyPoolLights;
|
||||
_pointSnapshot.Sort(_legacyPoolComparison);
|
||||
_pointSnapshot.RemoveRange(
|
||||
MaxGlobalLights,
|
||||
_pointSnapshot.Count - MaxGlobalLights);
|
||||
}
|
||||
else
|
||||
{
|
||||
LastPointSnapshotUsedBoundedSelection = true;
|
||||
_pointSnapshot.Clear();
|
||||
for (int index = 0;
|
||||
index < _pointSelectionHeap.Count;
|
||||
index++)
|
||||
{
|
||||
_pointSnapshot.Add(
|
||||
_pointSelectionHeap[index].Light);
|
||||
}
|
||||
}
|
||||
playerWorldPos,
|
||||
light.IsDynamic ? _dynamicPointSnapshot : _staticPointSnapshot,
|
||||
light.IsDynamic ? MaxDynamicPointLights : MaxStaticPointLights);
|
||||
}
|
||||
|
||||
_pointSnapshot.AddRange(_dynamicPointSnapshot);
|
||||
_pointSnapshot.AddRange(_staticPointSnapshot);
|
||||
}
|
||||
|
||||
private static int CompareRankedLights(
|
||||
RankedLight left,
|
||||
RankedLight right)
|
||||
private static void InsertRetained(
|
||||
LightSource light,
|
||||
Vector3 playerWorldPos,
|
||||
List<LightSource> product,
|
||||
int capacity)
|
||||
{
|
||||
if (left.Light.IsDynamic != right.Light.IsDynamic)
|
||||
return left.Light.IsDynamic ? -1 : 1;
|
||||
// Retail ranks only POINT. Non-point lights have rank zero. The strict
|
||||
// forward comparison is intentional: equal and NaN comparisons advance,
|
||||
// preserving insertion order and retail's unordered behavior.
|
||||
float rank = light.Kind == LightKind.Point
|
||||
? Vector3.DistanceSquared(light.RankingOrigin, playerWorldPos)
|
||||
: 0f;
|
||||
light.DistSq = rank;
|
||||
|
||||
int distance = left.DistanceSq.CompareTo(right.DistanceSq);
|
||||
return distance != 0
|
||||
? distance
|
||||
: left.QualifyingOrdinal.CompareTo(right.QualifyingOrdinal);
|
||||
int index = 0;
|
||||
while (index < product.Count && !(rank < product[index].DistSq))
|
||||
index++;
|
||||
if (index >= capacity)
|
||||
return;
|
||||
|
||||
product.Insert(index, light);
|
||||
if (product.Count > capacity)
|
||||
product.RemoveAt(capacity);
|
||||
}
|
||||
|
||||
private bool SelectedRanksContainObservableTie(Vector3 playerWorldPos)
|
||||
{
|
||||
for (int index = 1;
|
||||
index < _pointSelectionHeap.Count;
|
||||
index++)
|
||||
{
|
||||
if (HaveSameLegacyRank(
|
||||
_pointSelectionHeap[index - 1],
|
||||
_pointSelectionHeap[index]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// A tie may straddle the cap with only one copy in the selected heap.
|
||||
// Such a tie can change which light the old unstable List.Sort kept.
|
||||
RankedLight cutoff = _pointSelectionHeap[^1];
|
||||
int cutoffMatches = 0;
|
||||
for (int index = 0; index < _pointSnapshot.Count; index++)
|
||||
{
|
||||
LightSource light = _pointSnapshot[index];
|
||||
if (light.IsDynamic != cutoff.Light.IsDynamic)
|
||||
continue;
|
||||
|
||||
float distance = Vector3.DistanceSquared(
|
||||
light.WorldPosition,
|
||||
playerWorldPos);
|
||||
if (distance.CompareTo(cutoff.DistanceSq) != 0)
|
||||
continue;
|
||||
|
||||
if (++cutoffMatches > 1)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HaveSameLegacyRank(
|
||||
RankedLight left,
|
||||
RankedLight right) =>
|
||||
left.Light.IsDynamic == right.Light.IsDynamic
|
||||
&& left.DistanceSq.CompareTo(right.DistanceSq) == 0;
|
||||
|
||||
private int CompareLegacyPoolLights(
|
||||
LightSource left,
|
||||
LightSource right)
|
||||
{
|
||||
if (left.IsDynamic != right.IsDynamic)
|
||||
return left.IsDynamic ? -1 : 1;
|
||||
|
||||
float leftDistance = Vector3.DistanceSquared(
|
||||
left.WorldPosition,
|
||||
_legacyPoolAnchor);
|
||||
float rightDistance = Vector3.DistanceSquared(
|
||||
right.WorldPosition,
|
||||
_legacyPoolAnchor);
|
||||
return leftDistance.CompareTo(rightDistance);
|
||||
}
|
||||
|
||||
private static void HeapifyWorstFirst(List<RankedLight> heap)
|
||||
{
|
||||
for (int index = heap.Count / 2 - 1;
|
||||
index >= 0;
|
||||
index--)
|
||||
{
|
||||
SiftWorstDown(heap, index);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SiftWorstDown(
|
||||
List<RankedLight> heap,
|
||||
int index)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int left = checked(index * 2 + 1);
|
||||
if (left >= heap.Count)
|
||||
return;
|
||||
|
||||
int right = left + 1;
|
||||
int worse = right < heap.Count
|
||||
&& CompareRankedLights(heap[right], heap[left]) > 0
|
||||
? right
|
||||
: left;
|
||||
if (CompareRankedLights(heap[worse], heap[index]) <= 0)
|
||||
return;
|
||||
|
||||
(heap[index], heap[worse]) =
|
||||
(heap[worse], heap[index]);
|
||||
index = worse;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RankedLight(
|
||||
LightSource Light,
|
||||
int QualifyingOrdinal,
|
||||
float DistanceSq);
|
||||
|
||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||
// Retail adds a white fill light pinned to the player EVERY frame via
|
||||
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor
|
||||
|
|
@ -495,6 +338,7 @@ public sealed class LightManager
|
|||
_viewerLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
LocalPose = Matrix4x4.CreateTranslation(0f, 0f, 2f),
|
||||
ColorLinear = Vector3.One, // white (1,1,1)
|
||||
Intensity = ViewerLightIntensity,
|
||||
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
|
||||
|
|
@ -504,6 +348,7 @@ public sealed class LightManager
|
|||
};
|
||||
_all.Add(_viewerLight);
|
||||
}
|
||||
_viewerLight.RankingOrigin = playerWorldPos;
|
||||
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
|
||||
}
|
||||
|
||||
|
|
@ -575,73 +420,22 @@ public sealed class LightManager
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
|
||||
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
|
||||
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
|
||||
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
|
||||
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
|
||||
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
|
||||
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
|
||||
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
|
||||
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
|
||||
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
|
||||
/// (retail minimize_object_lighting).
|
||||
/// Per-CELL light selection: every retained dynamic and static candidate.
|
||||
/// Retail enables the complete dynamic product and separately bakes every
|
||||
/// retained static into EnvCell vertices. Our documented GPU-evaluation path
|
||||
/// supplies both products and leaves the unchanged shader range cutoff to
|
||||
/// decide each vertex contribution. Ordinary objects keep
|
||||
/// <see cref="SelectForObject"/> and its eight-light hardware cap.
|
||||
/// </summary>
|
||||
public static int SelectForCell(
|
||||
IReadOnlyList<LightSource> snapshot,
|
||||
Vector3 center,
|
||||
float radius,
|
||||
Span<int> outIndices)
|
||||
{
|
||||
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
|
||||
if (cap <= 0) return 0;
|
||||
|
||||
int count = 0;
|
||||
|
||||
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
|
||||
// every cell — stable regardless of the cell's relation to each light).
|
||||
for (int li = 0; li < snapshot.Count && count < cap; li++)
|
||||
if (snapshot[li].IsDynamic)
|
||||
outIndices[count++] = li;
|
||||
|
||||
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
|
||||
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
|
||||
int staticStart = count;
|
||||
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
|
||||
for (int li = 0; li < snapshot.Count; li++)
|
||||
{
|
||||
var light = snapshot[li];
|
||||
if (light.IsDynamic) continue; // dynamics already added
|
||||
float reach = light.Range + radius;
|
||||
float dsq = (light.WorldPosition - center).LengthSquared();
|
||||
if (dsq >= reach * reach) continue;
|
||||
|
||||
if (count < cap)
|
||||
{
|
||||
int j = count;
|
||||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
count++;
|
||||
}
|
||||
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
|
||||
{
|
||||
int j = cap - 1;
|
||||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
}
|
||||
}
|
||||
int count = Math.Min(
|
||||
Math.Min(snapshot.Count, outIndices.Length),
|
||||
MaxLightsPerEnvCell);
|
||||
for (int index = 0; index < count; index++)
|
||||
outIndices[index] = index;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ public enum LightKind
|
|||
|
||||
/// <summary>
|
||||
/// Per-frame light record. Used by <see cref="LightManager"/> and fed to
|
||||
/// the shader UBO on every draw call.
|
||||
/// the directional UBO or retained point-light storage buffer as appropriate.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail semantics (r13 §10.2):
|
||||
|
|
@ -40,6 +40,25 @@ public sealed class LightSource
|
|||
{
|
||||
public LightKind Kind;
|
||||
public Vector3 WorldPosition;
|
||||
private Vector3 _rankingOrigin;
|
||||
|
||||
/// <summary>
|
||||
/// Authored root-frame origin used by retail's retained-light insertion
|
||||
/// rank. This is deliberately independent of <see cref="WorldPosition"/>,
|
||||
/// which includes the authored LIGHTINFO local offset.
|
||||
/// </summary>
|
||||
public Vector3 RankingOrigin
|
||||
{
|
||||
get => _rankingOrigin;
|
||||
set
|
||||
{
|
||||
_rankingOrigin = value;
|
||||
HasRankingOrigin = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True only after the nondirectional ranking root was supplied.</summary>
|
||||
public bool HasRankingOrigin { get; private set; }
|
||||
public Vector3 WorldForward; // for Spot/Directional
|
||||
// R,G,B in [0,1], pre-brightness. NOTE (Campaign VM VM3, 2026-08-22
|
||||
// review item A4): despite the name, this is retail's authored
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ public sealed class LightingHookSink : IAnimationHookSink
|
|||
{
|
||||
LightSource light = lights[i];
|
||||
Matrix4x4 lightWorld = light.LocalPose * rootWorld;
|
||||
light.RankingOrigin = rootWorld.Translation;
|
||||
light.WorldPosition = lightWorld.Translation;
|
||||
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
|
||||
if (forward.LengthSquared() > 1e-8f)
|
||||
|
|
|
|||
|
|
@ -437,6 +437,8 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
IsDynamic: true,
|
||||
IsLit: true,
|
||||
TracksOwnerPose: true,
|
||||
RankingOrigin: new EnvCellLightingVectorBits(
|
||||
0x3F000000u, 0x3F800000u, 0x40000000u),
|
||||
Position: new EnvCellLightingVectorBits(
|
||||
0x3F800000u, 0x40000000u, 0x40400000u),
|
||||
Forward: new EnvCellLightingVectorBits(
|
||||
|
|
@ -459,7 +461,7 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
[
|
||||
new EnvCellLightingCellSetSnapshot(
|
||||
0x00070143u,
|
||||
[0, -1, -1, -1, -1, -1, -1, -1]),
|
||||
[0, .. Enumerable.Repeat(-1, 46)]),
|
||||
],
|
||||
Ambient: new EnvCellLightingAmbientSnapshot(
|
||||
new EnvCellLightingVectorBits(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Core.Lighting;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
|
||||
{
|
||||
[Trait("Lane", "Vulkan")]
|
||||
[Theory]
|
||||
[InlineData("mesh_modern", false)]
|
||||
[InlineData("mesh_atmospheric", true)]
|
||||
public void CommittedProductionWorldShaders_UseEnvCell47AndOrdinary8LightStrides(
|
||||
string shaderName,
|
||||
bool atmospheric)
|
||||
{
|
||||
lock (VulkanLock)
|
||||
{
|
||||
string shaderDirectory = Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
|
||||
using var host = HeadlessVulkanHost.Create(shaderDirectory);
|
||||
|
||||
(Pixel envFirst, Pixel envSecond) = RenderStridePair(
|
||||
host,
|
||||
shaderName,
|
||||
atmospheric,
|
||||
lightingMode: 1,
|
||||
firstSlot: 8,
|
||||
secondSlot: 46,
|
||||
stride: 47);
|
||||
(Pixel objectFirst, Pixel objectSecond) = RenderStridePair(
|
||||
host,
|
||||
shaderName,
|
||||
atmospheric,
|
||||
lightingMode: 0,
|
||||
firstSlot: 7,
|
||||
secondSlot: 7,
|
||||
stride: 8);
|
||||
|
||||
AssertRed(envFirst, $"{shaderName} EnvCell slot 8");
|
||||
AssertGreen(envSecond, $"{shaderName} EnvCell slot 46 / instance stride 47");
|
||||
AssertRed(objectFirst, $"{shaderName} ordinary slot 7");
|
||||
AssertGreen(objectSecond, $"{shaderName} ordinary instance stride 8");
|
||||
}
|
||||
}
|
||||
|
||||
private static (Pixel First, Pixel Second) RenderStridePair(
|
||||
HeadlessVulkanHost host,
|
||||
string shaderName,
|
||||
bool atmospheric,
|
||||
int lightingMode,
|
||||
int firstSlot,
|
||||
int secondSlot,
|
||||
int stride)
|
||||
{
|
||||
VulkanGpuDevice device = host.Device;
|
||||
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
|
||||
$"478-{shaderName}-vertices",
|
||||
4 * Marshal.SizeOf<Vertex>(),
|
||||
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription(
|
||||
$"478-{shaderName}-indices",
|
||||
6 * sizeof(ushort),
|
||||
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
Vertex[] vertexData =
|
||||
[
|
||||
new(new Vector3(-0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.Zero),
|
||||
new(new Vector3( 0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.UnitX),
|
||||
new(new Vector3( 0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.One),
|
||||
new(new Vector3(-0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.UnitY),
|
||||
];
|
||||
vertices.Upload(0, MemoryMarshal.AsBytes<Vertex>(vertexData));
|
||||
indices.Upload(0, MemoryMarshal.AsBytes<ushort>([0, 1, 2, 2, 3, 0]));
|
||||
|
||||
using IGpuRenderTarget target = CreateTarget(device, $"478-{shaderName}-{lightingMode}");
|
||||
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
|
||||
{
|
||||
Name = $"478-{shaderName}-{lightingMode}",
|
||||
Shaders = new GpuShaderSet(shaderName),
|
||||
VertexLayout = GpuVertexLayout.WorldMesh,
|
||||
Topology = GpuPrimitiveTopology.TriangleList,
|
||||
Blend = GpuBlendMode.None,
|
||||
Depth = GpuDepthState.Disabled,
|
||||
Cull = GpuCullMode.None,
|
||||
UsesRenderPackShaderAbi = atmospheric,
|
||||
SampleCount = 1,
|
||||
});
|
||||
|
||||
// Pad both modes to 94 entries. The ordinary path still reads only
|
||||
// offsets 7/15, while its stride-47 sabotage deterministically reads
|
||||
// in-bounds wrong data rather than invoking driver-dependent OOB access.
|
||||
var lightIndices = Enumerable.Repeat(
|
||||
-1,
|
||||
LightManager.MaxLightsPerEnvCell * 2).ToArray();
|
||||
lightIndices[firstSlot] = 0;
|
||||
lightIndices[stride + secondSlot] = 1;
|
||||
GlobalLight[] globalLights =
|
||||
[
|
||||
DynamicLight(new Vector3(-0.48f, 0f, 1f), Vector3.UnitX),
|
||||
DynamicLight(new Vector3( 0.48f, 0f, 1f), Vector3.UnitY),
|
||||
];
|
||||
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
using IGpuPassEncoder encoder = BeginPass(frame, target, $"478-{shaderName}-stride");
|
||||
encoder.BindPipeline(pipeline);
|
||||
GpuPushConstants constants = GpuPushConstants.Default;
|
||||
constants.LightingMode = lightingMode;
|
||||
constants.LightDebug = 3;
|
||||
encoder.SetPushConstants(constants);
|
||||
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstances,
|
||||
[
|
||||
Matrix4x4.CreateTranslation(-0.48f, 0f, 0f),
|
||||
Matrix4x4.CreateTranslation( 0.48f, 0f, 0f),
|
||||
]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageBatches,
|
||||
[new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 0u)]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [0u, 0u]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, globalLights);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets, lightIndices);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [1u, 1u]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [1f, 1f]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting,
|
||||
[new Vector2(0f, 1f), new Vector2(0f, 1f)]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [0u, 0u]);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, default(SceneLightingUbo));
|
||||
if (atmospheric)
|
||||
{
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformAtmosphericFrame,
|
||||
default(AtmosphericFrameUniforms));
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
|
||||
ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
|
||||
}
|
||||
|
||||
encoder.BindVertexBuffer(0, vertices, 0);
|
||||
encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16);
|
||||
encoder.DrawIndexed(6, 2, 0, 0, 0);
|
||||
}
|
||||
|
||||
device.WaitIdle();
|
||||
VulkanGpuRenderTarget vkTarget = Assert.IsType<VulkanGpuRenderTarget>(target);
|
||||
byte[] pixels = ReadBack(
|
||||
host.Vk,
|
||||
host.PhysicalDevice,
|
||||
host.LogicalDevice,
|
||||
host.Queue,
|
||||
host.QueueFamily,
|
||||
vkTarget.ColorResult.Image);
|
||||
return (PixelAt(pixels, 16, 32), PixelAt(pixels, 48, 32));
|
||||
}
|
||||
|
||||
private static GlobalLight DynamicLight(Vector3 position, Vector3 color) => new(
|
||||
new Vector4(position, (float)LightKind.Point),
|
||||
new Vector4(0f, 0f, 1f, 10f),
|
||||
new Vector4(color, 1f),
|
||||
new Vector4(0f, 1f, 0f, 0f));
|
||||
|
||||
private static void AssertRed(Pixel pixel, string label)
|
||||
{
|
||||
// Vulkan's selected offscreen format is copied in BGRA byte order.
|
||||
Assert.True(pixel.R < 8 && pixel.G < 8 && pixel.B > 180,
|
||||
$"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}");
|
||||
}
|
||||
|
||||
private static void AssertGreen(Pixel pixel, string label)
|
||||
{
|
||||
Assert.True(pixel.R < 8 && pixel.G > 180 && pixel.B < 8,
|
||||
$"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}");
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,9 @@ public sealed class VulkanShaderManifestTests
|
|||
// S5-c4 replaces the padding word at batch offset 4 with authored
|
||||
// surface opacity and forwards it plus exact detail category/flags
|
||||
// flat to the fragment shader. The 16-byte ABI is unchanged.
|
||||
["mesh_modern.vert.spv"] = "6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4",
|
||||
// Re-pinned 2026-09-05 (#478): binding 5 keeps object stride 8
|
||||
// while EnvCell mode consumes the complete retained 47-light set.
|
||||
["mesh_modern.vert.spv"] = "9909ca4729dbe4fc7fbffb11c73481977d8f593d37f130fcdd44a7803b47944f",
|
||||
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
|
||||
// Re-pinned 2026-08-27: portal-view clip slots now travel with
|
||||
// deferred billboard particles, matching retail PortalList draws.
|
||||
|
|
|
|||
|
|
@ -934,6 +934,9 @@ public sealed class OrderPreservingSubmitterTests
|
|||
AssertLocalSection(
|
||||
GpuBindingModel.StorageInstanceLightSets,
|
||||
[0, 2, 4, 6, -1, -1, -1, -1, 1, 3, 5, 7, -1, -1, -1, -1]);
|
||||
Assert.Equal(
|
||||
(uint)(2 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
LastBind(GpuBindingModel.StorageInstanceLightSets).SizeBytes);
|
||||
AssertLocalSection(GpuBindingModel.StorageInstanceIndoor, [0u, 1u]);
|
||||
AssertLocalSection(GpuBindingModel.StorageInstanceAlpha, [0.25f, 0.75f]);
|
||||
AssertLocalSection(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ public class EnvCellRendererTests
|
|||
{
|
||||
Kind = LightKind.Spot,
|
||||
WorldPosition = new Vector3(50f, 60f, 70f),
|
||||
RankingOrigin = new Vector3(5f, 6f, 7f),
|
||||
WorldForward = new Vector3(0.25f, -0.5f, 0.75f),
|
||||
ColorLinear = new Vector3(0.11f, 0.22f, 0.33f),
|
||||
Intensity = 4.5f,
|
||||
|
|
@ -99,6 +100,7 @@ public class EnvCellRendererTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = Vector3.Zero,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
WorldForward = Vector3.UnitX,
|
||||
ColorLinear = new Vector3(0.9f, 0.1f, 0.2f),
|
||||
Intensity = 2f,
|
||||
|
|
@ -111,6 +113,7 @@ public class EnvCellRendererTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(100f, 0f, 0f),
|
||||
RankingOrigin = new Vector3(100f, 0f, 0f),
|
||||
WorldForward = Vector3.UnitY,
|
||||
ColorLinear = new Vector3(0.2f, 0.8f, 0.4f),
|
||||
Intensity = 3f,
|
||||
|
|
@ -173,16 +176,17 @@ public class EnvCellRendererTests
|
|||
(int)setBind.OffsetBytes,
|
||||
(int)setBind.SizeBytes)).ToArray();
|
||||
Assert.Equal(
|
||||
actualSets.AsSpan(0, LightManager.MaxLightsPerObject).ToArray(),
|
||||
actualSets.AsSpan(0, LightManager.MaxLightsPerEnvCell).ToArray(),
|
||||
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
|
||||
Assert.Equal(
|
||||
actualSets.AsSpan(
|
||||
LightManager.MaxLightsPerObject,
|
||||
LightManager.MaxLightsPerObject).ToArray(),
|
||||
LightManager.MaxLightsPerEnvCell,
|
||||
LightManager.MaxLightsPerEnvCell).ToArray(),
|
||||
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
|
||||
Assert.Equal([0, 1, -1, -1, -1, -1, -1, -1],
|
||||
int[] expectedSet = [0, 1, 2, .. Enumerable.Repeat(-1, 44)];
|
||||
Assert.Equal(expectedSet,
|
||||
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
|
||||
Assert.Equal([0, 2, -1, -1, -1, -1, -1, -1],
|
||||
Assert.Equal(expectedSet,
|
||||
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
|
||||
|
||||
EnvCellLightingSourceSnapshot source = snapshot.Sources[0];
|
||||
|
|
@ -193,6 +197,7 @@ public class EnvCellRendererTests
|
|||
Assert.True(source.IsDynamic);
|
||||
Assert.True(source.IsLit);
|
||||
Assert.True(source.TracksOwnerPose);
|
||||
Assert.Equal(BitConverter.SingleToUInt32Bits(5f), source.RankingOrigin.X);
|
||||
Assert.Equal(BitConverter.SingleToUInt32Bits(50f), source.Position.X);
|
||||
Assert.Equal(BitConverter.SingleToUInt32Bits(-0.5f), source.Forward.Y);
|
||||
Assert.Equal(BitConverter.SingleToUInt32Bits(0.33f), source.Color.Z);
|
||||
|
|
@ -235,6 +240,98 @@ public class EnvCellRendererTests
|
|||
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration()
|
||||
{
|
||||
const uint cellA = 0x8C040101u;
|
||||
const uint cellB = 0x8C040102u;
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var meshManager = CreateMeshManager(device);
|
||||
var frameLifetime = new GpuDeviceFrameLifetime(device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
using var renderer = new EnvCellRenderer(
|
||||
device,
|
||||
frameLifetime,
|
||||
scope,
|
||||
meshManager,
|
||||
new WbFrustum());
|
||||
LightSource[] lights = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell)
|
||||
.Select(index => new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(index, 0f, 0f),
|
||||
RankingOrigin = new Vector3(index, 0f, 0f),
|
||||
Range = 10f,
|
||||
IsDynamic = index < LightManager.MaxDynamicPointLights,
|
||||
})
|
||||
.ToArray();
|
||||
renderer.SetPointSnapshot(lights);
|
||||
|
||||
frameLifetime.BeginFrame();
|
||||
IGpuFrame frame = frameLifetime.CurrentFrame!;
|
||||
IGpuPassEncoder pass = frame.BeginPass(
|
||||
GpuPassDescription.BackbufferClear(
|
||||
"envcell-47-index-binding",
|
||||
Vector4.Zero,
|
||||
sampleCount: 1));
|
||||
IDisposable publication = scope.Publish(pass);
|
||||
renderer.BeginFrame(frame.SlotIndex);
|
||||
SeedLightingSubmission(renderer, instanceCount: 2);
|
||||
device.Clear();
|
||||
InvokeSubmitRhi(renderer,
|
||||
[
|
||||
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA },
|
||||
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB },
|
||||
], instanceCount: 2);
|
||||
|
||||
GpuRecordedStorageBind bind = Assert.Single(
|
||||
device.Calls.OfType<GpuRecordedStorageBind>(),
|
||||
call => call.Binding == GpuBindingModel.StorageInstanceLightSets);
|
||||
Assert.Equal((uint)(2 * LightManager.MaxLightsPerEnvCell * sizeof(int)), bind.SizeBytes);
|
||||
int[] actual = MemoryMarshal.Cast<byte, int>(device.RingBytes.Slice(
|
||||
(int)bind.OffsetBytes,
|
||||
(int)bind.SizeBytes)).ToArray();
|
||||
int[] expected = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell).ToArray();
|
||||
Assert.Equal(expected, actual.AsSpan(0, 47).ToArray());
|
||||
Assert.Equal(expected, actual.AsSpan(47, 47).ToArray());
|
||||
Assert.Equal(8, GpuBindingModel.MaxLightsPerObject);
|
||||
Assert.Equal(47, GpuBindingModel.MaxLightsPerEnvCell);
|
||||
|
||||
publication.Dispose();
|
||||
pass.Dispose();
|
||||
frameLifetime.EndFrame();
|
||||
|
||||
// A shorter next-generation snapshot must overwrite every binding-5
|
||||
// slot. No index from the prior 47-entry generation may survive.
|
||||
renderer.SetPointSnapshot(lights.Take(2).ToArray());
|
||||
frameLifetime.BeginFrame();
|
||||
frame = frameLifetime.CurrentFrame!;
|
||||
using IGpuPassEncoder nextPass = frame.BeginPass(
|
||||
GpuPassDescription.BackbufferClear(
|
||||
"envcell-47-index-binding-next-generation",
|
||||
Vector4.Zero,
|
||||
sampleCount: 1));
|
||||
using IDisposable nextPublication = scope.Publish(nextPass);
|
||||
renderer.BeginFrame(frame.SlotIndex);
|
||||
SeedLightingSubmission(renderer, instanceCount: 2);
|
||||
device.Clear();
|
||||
InvokeSubmitRhi(renderer,
|
||||
[
|
||||
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA },
|
||||
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB },
|
||||
], instanceCount: 2);
|
||||
|
||||
bind = Assert.Single(
|
||||
device.Calls.OfType<GpuRecordedStorageBind>(),
|
||||
call => call.Binding == GpuBindingModel.StorageInstanceLightSets);
|
||||
actual = MemoryMarshal.Cast<byte, int>(device.RingBytes.Slice(
|
||||
(int)bind.OffsetBytes,
|
||||
(int)bind.SizeBytes)).ToArray();
|
||||
int[] expectedShort = [0, 1, .. Enumerable.Repeat(-1, 45)];
|
||||
Assert.Equal(expectedShort, actual.AsSpan(0, 47).ToArray());
|
||||
Assert.Equal(expectedShort, actual.AsSpan(47, 47).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CullMode.Landblock)]
|
||||
[InlineData(CullMode.None)]
|
||||
|
|
|
|||
|
|
@ -263,11 +263,13 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
var visibleLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
CellId = visibleCell,
|
||||
};
|
||||
var hiddenLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
CellId = hiddenCell,
|
||||
};
|
||||
lighting.Register(visibleLight);
|
||||
|
|
|
|||
|
|
@ -395,11 +395,13 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
{
|
||||
OwnerId = staticEntity.Id,
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
});
|
||||
fixture.Lighting.RegisterOwnedLight(new LightSource
|
||||
{
|
||||
OwnerId = liveEntity.Id,
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
});
|
||||
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
|
||||
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);
|
||||
|
|
@ -448,11 +450,13 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
{
|
||||
OwnerId = staticEntity.Id,
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
});
|
||||
fixture.Lighting.RegisterOwnedLight(new LightSource
|
||||
{
|
||||
OwnerId = liveEntity.Id,
|
||||
Kind = LightKind.Point,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
});
|
||||
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
|
||||
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public sealed class LightManagerTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
RankingOrigin = pos,
|
||||
Range = range,
|
||||
IsLit = lit,
|
||||
OwnerId = ownerId,
|
||||
|
|
@ -23,6 +24,7 @@ public sealed class LightManagerTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
RankingOrigin = pos,
|
||||
Range = range,
|
||||
IsLit = true,
|
||||
IsDynamic = true,
|
||||
|
|
@ -53,6 +55,21 @@ public sealed class LightManagerTests
|
|||
Assert.Equal(1, mgr.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_NondirectionalWithoutExplicitRankingOrigin_Throws()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(3f, 4f, 5f),
|
||||
};
|
||||
|
||||
ArgumentException error = Assert.Throws<ArgumentException>(() => mgr.Register(light));
|
||||
Assert.Contains("ranking origin", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(0, mgr.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_SelectsByDistance_Top8()
|
||||
{
|
||||
|
|
@ -175,18 +192,148 @@ public sealed class LightManagerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_IndexStable_InBudget()
|
||||
public void BuildPointLightSnapshot_UnderCap_SortsByRootDistance()
|
||||
{
|
||||
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);
|
||||
Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
|
||||
Assert.Equal(100f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_RanksRootBeforeAuthoredOffsetFinalPosition()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
LightSource nearRootFarFinal = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 1);
|
||||
nearRootFarFinal.RankingOrigin = new Vector3(1f, 0f, 0f);
|
||||
LightSource farRootNearFinal = MakePoint(new Vector3(2f, 0f, 0f), 20f, ownerId: 2);
|
||||
farRootNearFinal.RankingOrigin = new Vector3(50f, 0f, 0f);
|
||||
manager.Register(farRootNearFinal);
|
||||
manager.Register(nearRootFarFinal);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(new[] { nearRootFarFinal, farRootNearFinal }, manager.PointSnapshot);
|
||||
Assert.Equal(1f, manager.PointSnapshot[0].DistSq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_StrictForwardInsertion_PreservesEqualAndNaNOrder()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 1);
|
||||
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
|
||||
LightSource equalA = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 2);
|
||||
equalA.RankingOrigin = new Vector3(2f, 0f, 0f);
|
||||
LightSource equalB = MakePoint(new Vector3(20f, 0f, 0f), 20f, ownerId: 3);
|
||||
equalB.RankingOrigin = new Vector3(-2f, 0f, 0f);
|
||||
manager.Register(nan);
|
||||
manager.Register(equalA);
|
||||
manager.Register(equalB);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(new[] { nan, equalA, equalB }, manager.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_StrictForwardInsertion_AdvancesPastNaN()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
LightSource far = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 1);
|
||||
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 2);
|
||||
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
|
||||
LightSource near = MakePoint(Vector3.One, 20f, ownerId: 3);
|
||||
manager.Register(far);
|
||||
manager.Register(nan);
|
||||
manager.Register(near);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(new[] { near, far, nan }, manager.PointSnapshot);
|
||||
Assert.Equal(new[] { 3f, 100f }, manager.PointSnapshot.Take(2).Select(light => light.DistSq));
|
||||
Assert.True(float.IsNaN(manager.PointSnapshot[2].DistSq));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_SpotUsesZeroRank_PointUsesRootDistance()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
LightSource point = MakePoint(new Vector3(1f, 0f, 0f), 20f, ownerId: 1);
|
||||
LightSource spot = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 2);
|
||||
spot.Kind = LightKind.Spot;
|
||||
manager.Register(point);
|
||||
manager.Register(spot);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(new[] { spot, point }, manager.PointSnapshot);
|
||||
Assert.Equal(0f, spot.DistSq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_IndependentSevenAndFortyProducts_DoNotCrossEvict()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
var dynamics = new List<LightSource>();
|
||||
var statics = new List<LightSource>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
LightSource light = MakeDynamic(new Vector3(100f + i, 0f, 0f), 10f);
|
||||
light.OwnerId = checked((uint)(100 + i));
|
||||
dynamics.Add(light);
|
||||
manager.Register(light);
|
||||
}
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
LightSource light = MakePoint(new Vector3(i, 0f, 0f), 10f, checked((uint)(200 + i)));
|
||||
statics.Add(light);
|
||||
manager.Register(light);
|
||||
}
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
|
||||
Assert.Equal(dynamics.Take(7), manager.PointSnapshot.Take(7));
|
||||
Assert.Equal(statics.Take(40), manager.PointSnapshot.Skip(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_ViewerWinsEqualRootTieThoughRegisteredLast()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
LightSource ordinary = MakeDynamic(new Vector3(0f, 0f, 2f), 15f);
|
||||
ordinary.RankingOrigin = Vector3.Zero;
|
||||
manager.Register(ordinary);
|
||||
manager.UpdateViewerLight(Vector3.Zero);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(2, manager.PointSnapshot.Count);
|
||||
Assert.NotSame(ordinary, manager.PointSnapshot[0]);
|
||||
Assert.Equal(new Vector3(0f, 0f, 2f), manager.PointSnapshot[0].WorldPosition);
|
||||
Assert.Equal(Vector3.Zero, manager.PointSnapshot[0].RankingOrigin);
|
||||
Assert.Same(ordinary, manager.PointSnapshot[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_ClearRemovesRetainedProducts()
|
||||
{
|
||||
var manager = new LightManager();
|
||||
manager.Register(MakePoint(Vector3.One, 5f));
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
Assert.NotEmpty(manager.PointSnapshot);
|
||||
|
||||
manager.Clear();
|
||||
Assert.Empty(manager.PointSnapshot);
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Empty(manager.PointSnapshot);
|
||||
}
|
||||
|
||||
// ── Resident collection (#176 corrected reading, 2026-07-06) ───────────────
|
||||
|
|
@ -280,8 +427,6 @@ public sealed class LightManagerTests
|
|||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(expected, manager.PointSnapshot);
|
||||
Assert.True(manager.LastPointSnapshotUsedTieFallback);
|
||||
Assert.False(manager.LastPointSnapshotUsedBoundedSelection);
|
||||
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
|
@ -369,8 +514,6 @@ public sealed class LightManagerTests
|
|||
|
||||
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
|
||||
Assert.Equal(expected, manager.PointSnapshot);
|
||||
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
|
||||
Assert.False(manager.LastPointSnapshotUsedTieFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -391,8 +534,6 @@ public sealed class LightManagerTests
|
|||
}
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
manager.BuildPointLightSnapshot(Vector3.Zero);
|
||||
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
|
||||
Assert.False(manager.LastPointSnapshotUsedTieFallback);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 100; iteration++)
|
||||
|
|
@ -498,14 +639,14 @@ public sealed class LightManagerTests
|
|||
Assert.Equal(a[0], b[0]);
|
||||
}
|
||||
|
||||
// ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ──
|
||||
// ── SelectForCell — complete retained 7-dynamic + 40-static products ──
|
||||
|
||||
[Fact]
|
||||
public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach()
|
||||
{
|
||||
// Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same
|
||||
// portal lights on every Facility Hub cell) — including ones that don't reach it,
|
||||
// since the shader's range cutoff zeroes those. Static lights still cull by reach.
|
||||
// Retail enables the whole dynamic subset and bakes the whole retained static
|
||||
// product. The GPU path supplies both unchanged; per-vertex range cutoff decides
|
||||
// which entries contribute.
|
||||
var snapshot = new[]
|
||||
{
|
||||
MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches
|
||||
|
|
@ -513,8 +654,8 @@ public sealed class LightManagerTests
|
|||
MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near
|
||||
MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach)
|
||||
};
|
||||
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
||||
int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel);
|
||||
Span<int> sel = stackalloc int[LightManager.MaxLightsPerEnvCell];
|
||||
int n = LightManager.SelectForCell(snapshot, sel);
|
||||
|
||||
bool d1 = false, d2 = false, s0 = false, s3 = false;
|
||||
for (int i = 0; i < n; i++)
|
||||
|
|
@ -527,7 +668,7 @@ public sealed class LightManagerTests
|
|||
Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics");
|
||||
Assert.True(d2, "the near dynamic light is applied");
|
||||
Assert.True(s0, "the near static light reaches the cell → selected");
|
||||
Assert.False(s3, "the far static light doesn't reach → not selected");
|
||||
Assert.True(s3, "the complete retained static product is supplied; the shader applies range");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -541,15 +682,35 @@ public sealed class LightManagerTests
|
|||
MakeDynamic(new Vector3(0, 0, 0), range: 5f),
|
||||
MakeDynamic(new Vector3(100, 0, 0), range: 5f),
|
||||
};
|
||||
Span<int> a = stackalloc int[8];
|
||||
Span<int> b = stackalloc int[8];
|
||||
int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a);
|
||||
int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b);
|
||||
Span<int> a = stackalloc int[LightManager.MaxLightsPerEnvCell];
|
||||
Span<int> b = stackalloc int[LightManager.MaxLightsPerEnvCell];
|
||||
int na = LightManager.SelectForCell(snapshot, a);
|
||||
int nb = LightManager.SelectForCell(snapshot, b);
|
||||
|
||||
Assert.Equal(2, na); // both dynamics on the near cell
|
||||
Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForCell_CarriesAllSevenDynamicsAndFortyStatics_WhileObjectStaysEight()
|
||||
{
|
||||
var snapshot = new List<LightSource>();
|
||||
for (int i = 0; i < LightManager.MaxDynamicPointLights; i++)
|
||||
snapshot.Add(MakeDynamic(new Vector3(i, 0f, 0f), 100f));
|
||||
for (int i = 0; i < LightManager.MaxStaticPointLights; i++)
|
||||
snapshot.Add(MakePoint(new Vector3(i + 10f, 0f, 0f), 100f));
|
||||
|
||||
Span<int> cell = stackalloc int[LightManager.MaxLightsPerEnvCell];
|
||||
int cellCount = LightManager.SelectForCell(snapshot, cell);
|
||||
Span<int> obj = stackalloc int[LightManager.MaxLightsPerEnvCell];
|
||||
int objectCount = LightManager.SelectForObject(snapshot, Vector3.Zero, 100f, obj);
|
||||
|
||||
Assert.Equal(47, cellCount);
|
||||
for (int index = 0; index < cellCount; index++)
|
||||
Assert.Equal(index, cell[index]);
|
||||
Assert.Equal(8, objectCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is
|
||||
/// retail's RESIDENT collection anchored at the PLAYER: a light in range of an
|
||||
|
|
@ -599,7 +760,7 @@ public sealed class LightManagerTests
|
|||
"an in-range light beside the player was evicted from the pool — " +
|
||||
"per-cell lighting would pop (the #176/#177 mechanism)");
|
||||
Assert.True(torchSelected2, "consecutive same-player builds must select identically");
|
||||
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster
|
||||
Assert.Equal(LightManager.MaxStaticPointLights, mgr.PointSnapshot.Count);
|
||||
|
||||
static bool SelectedContains(
|
||||
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||
|
|
@ -623,22 +784,34 @@ public sealed class LightManagerTests
|
|||
continue;
|
||||
ranked.Add(new OracleRank(
|
||||
light,
|
||||
Vector3.DistanceSquared(light.WorldPosition, player)));
|
||||
light.Kind == LightKind.Point
|
||||
? Vector3.DistanceSquared(light.RankingOrigin, player)
|
||||
: 0f));
|
||||
}
|
||||
|
||||
if (ranked.Count <= LightManager.MaxGlobalLights)
|
||||
return ranked.Select(static item => item.Light).ToArray();
|
||||
var dynamics = ranked.Where(static item => item.Light.IsDynamic).ToList();
|
||||
var statics = ranked.Where(static item => !item.Light.IsDynamic).ToList();
|
||||
StableRetailInsertion(dynamics, LightManager.MaxDynamicPointLights);
|
||||
StableRetailInsertion(statics, LightManager.MaxStaticPointLights);
|
||||
return dynamics.Concat(statics).Select(static item => item.Light).ToArray();
|
||||
|
||||
ranked.Sort(static (left, right) =>
|
||||
static void StableRetailInsertion(List<OracleRank> values, int cap)
|
||||
{
|
||||
if (left.Light.IsDynamic != right.Light.IsDynamic)
|
||||
return left.Light.IsDynamic ? -1 : 1;
|
||||
return left.DistanceSq.CompareTo(right.DistanceSq);
|
||||
});
|
||||
return ranked
|
||||
.Take(LightManager.MaxGlobalLights)
|
||||
.Select(static item => item.Light)
|
||||
.ToArray();
|
||||
var selected = new List<OracleRank>(cap);
|
||||
foreach (OracleRank value in values)
|
||||
{
|
||||
int index = 0;
|
||||
while (index < selected.Count && !(value.DistanceSq < selected[index].DistanceSq))
|
||||
index++;
|
||||
if (index >= cap)
|
||||
continue;
|
||||
selected.Insert(index, value);
|
||||
if (selected.Count > cap)
|
||||
selected.RemoveAt(cap);
|
||||
}
|
||||
values.Clear();
|
||||
values.AddRange(selected);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct OracleRank(
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ public sealed class LightingHookSinkTests
|
|||
var mgr = new LightManager();
|
||||
var sink = new LightingHookSink(mgr, new MutablePoseSource());
|
||||
|
||||
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
|
||||
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
|
||||
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true };
|
||||
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
|
||||
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
|
||||
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true, RankingOrigin = Vector3.Zero };
|
||||
sink.RegisterOwnedLight(light1);
|
||||
sink.RegisterOwnedLight(light2);
|
||||
sink.RegisterOwnedLight(other);
|
||||
|
|
@ -35,8 +35,8 @@ public sealed class LightingHookSinkTests
|
|||
var mgr = new LightManager();
|
||||
var sink = new LightingHookSink(mgr, new MutablePoseSource());
|
||||
|
||||
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
|
||||
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
|
||||
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
|
||||
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
|
||||
Assert.Equal(2, mgr.RegisteredCount);
|
||||
|
||||
sink.UnregisterOwner(7);
|
||||
|
|
@ -48,7 +48,7 @@ public sealed class LightingHookSinkTests
|
|||
{
|
||||
var mgr = new LightManager();
|
||||
var sink = new LightingHookSink(mgr, new MutablePoseSource());
|
||||
var light = new LightSource { OwnerId = 1, IsLit = true };
|
||||
var light = new LightSource { OwnerId = 1, IsLit = true, RankingOrigin = Vector3.Zero };
|
||||
sink.RegisterOwnedLight(light);
|
||||
|
||||
// Should not crash or change state for non-SetLight hooks.
|
||||
|
|
@ -71,6 +71,7 @@ public sealed class LightingHookSinkTests
|
|||
var light = new LightSource
|
||||
{
|
||||
OwnerId = 42u,
|
||||
RankingOrigin = Vector3.Zero,
|
||||
LocalPose = Matrix4x4.CreateTranslation(1, 0, 2),
|
||||
TracksOwnerPose = true,
|
||||
};
|
||||
|
|
@ -81,6 +82,7 @@ public sealed class LightingHookSinkTests
|
|||
Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f);
|
||||
Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f);
|
||||
Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f);
|
||||
Assert.Equal(new Vector3(10f, 20f, 30f), light.RankingOrigin);
|
||||
Assert.Equal(0x01010002u, light.CellId);
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +97,7 @@ public sealed class LightingHookSinkTests
|
|||
{
|
||||
OwnerId = 42u,
|
||||
WorldPosition = new Vector3(7, 8, 9),
|
||||
RankingOrigin = new Vector3(7, 8, 9),
|
||||
LocalPose = Matrix4x4.CreateTranslation(1, 0, 0),
|
||||
TracksOwnerPose = false,
|
||||
};
|
||||
|
|
@ -114,7 +117,7 @@ public sealed class LightingHookSinkTests
|
|||
sink.InitializeOwnerLighting(7u, enabled: true);
|
||||
sink.SetOwnerLighting(7u, enabled: false);
|
||||
sink.UnregisterOwner(7u, forgetState: false);
|
||||
var replacement = new LightSource { OwnerId = 7u };
|
||||
var replacement = new LightSource { OwnerId = 7u, RankingOrigin = Vector3.Zero };
|
||||
|
||||
sink.RegisterOwnedLight(replacement);
|
||||
|
||||
|
|
@ -160,6 +163,7 @@ public sealed class LightInfoLoaderTests
|
|||
Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
|
||||
Assert.Equal(0.8f, light.Intensity);
|
||||
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
|
||||
Assert.Equal(new Vector3(100, 200, 300), light.RankingOrigin);
|
||||
Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation);
|
||||
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public sealed class SceneLightingUboTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1, 2, 3),
|
||||
RankingOrigin = new Vector3(1, 2, 3),
|
||||
ColorLinear = new Vector3(1f, 0.5f, 0.25f),
|
||||
Intensity = 0.8f,
|
||||
Range = 6f,
|
||||
|
|
@ -84,6 +85,7 @@ public sealed class SceneLightingUboTests
|
|||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(i, 0, 0),
|
||||
RankingOrigin = new Vector3(i, 0, 0),
|
||||
Range = 200f, // all in range
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Lighting;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
|
|
@ -111,7 +114,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
|
|||
|
||||
_out.WriteLine($"=== Setup 0x{setupId:X8}: Parts={setup!.Parts.Count} PlacementFrames={setup.PlacementFrames.Count} Lights={setup.Lights.Count} ===");
|
||||
foreach (var kvp in setup.Lights)
|
||||
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle}");
|
||||
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle} LocalOrigin=({kvp.Value.ViewSpaceLocation?.Origin.X:R},{kvp.Value.ViewSpaceLocation?.Origin.Y:R},{kvp.Value.ViewSpaceLocation?.Origin.Z:R})");
|
||||
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
_out.WriteLine($" SetupMesh.Flatten -> {flat.Count} MeshRefs");
|
||||
|
|
@ -139,6 +142,68 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
|
|||
$"=> GameWindow.cs:7324 would {(survivors == 0 ? "DROP" : "KEEP")} this entity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TownCeilingFixture_AuthoredTypeKeyAndRootRankOrigin_ArePinnedFromInstalledDat()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
const uint cellId = 0x00070144u;
|
||||
const uint setupId = 0x02000365u;
|
||||
EnvCell cell = Assert.IsType<EnvCell>(dats.Get<EnvCell>(cellId));
|
||||
var fixture = Assert.Single(cell.StaticObjects, entry => entry.Id == setupId);
|
||||
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(setupId));
|
||||
var authored = Assert.Single(setup.Lights);
|
||||
|
||||
// The dictionary key is native LIGHTINFO.type. The current loader's
|
||||
// cone-derived Point/Spot projection deliberately remains unchanged.
|
||||
Assert.Equal(0, authored.Key);
|
||||
Assert.Equal(0xCDCDCDCDu, BitConverter.SingleToUInt32Bits(authored.Value.ConeAngle));
|
||||
Assert.Equal(0.000759337f, authored.Value.ViewSpaceLocation.Origin.X);
|
||||
Assert.Equal(0.00675148f, authored.Value.ViewSpaceLocation.Origin.Y);
|
||||
Assert.Equal(0.0277f, authored.Value.ViewSpaceLocation.Origin.Z);
|
||||
|
||||
Vector3 root = new(
|
||||
fixture.Frame.Origin.X,
|
||||
fixture.Frame.Origin.Y,
|
||||
fixture.Frame.Origin.Z);
|
||||
Quaternion rotation = new(
|
||||
fixture.Frame.Orientation.X,
|
||||
fixture.Frame.Orientation.Y,
|
||||
fixture.Frame.Orientation.Z,
|
||||
fixture.Frame.Orientation.W);
|
||||
Assert.Equal(new Vector3(69.875f, -69.916f, 5.005f), root);
|
||||
Assert.Equal(new Quaternion(0f, 0f, -0.94372f, 0.330745f), rotation);
|
||||
LightSource light = Assert.Single(LightInfoLoader.Load(
|
||||
setup,
|
||||
ownerId: 0x4000712Fu,
|
||||
entityPosition: root,
|
||||
entityRotation: rotation,
|
||||
isDynamic: false,
|
||||
cellId: cellId));
|
||||
|
||||
Assert.True(light.HasRankingOrigin);
|
||||
Assert.Equal(root, light.RankingOrigin);
|
||||
Assert.Equal(LightKind.Point, light.Kind);
|
||||
Assert.NotEqual(root, light.WorldPosition);
|
||||
var localFrame = authored.Value.ViewSpaceLocation;
|
||||
var localOffset = new Vector3(
|
||||
localFrame.Origin.X,
|
||||
localFrame.Origin.Y,
|
||||
localFrame.Origin.Z);
|
||||
var localRotation = new Quaternion(
|
||||
localFrame.Orientation.X,
|
||||
localFrame.Orientation.Y,
|
||||
localFrame.Orientation.Z,
|
||||
localFrame.Orientation.W);
|
||||
Matrix4x4 expectedWorld = (Matrix4x4.CreateFromQuaternion(localRotation)
|
||||
* Matrix4x4.CreateTranslation(localOffset))
|
||||
* (Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(root));
|
||||
Assert.Equal(expectedWorld.Translation, light.WorldPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up (same session, 2026-07-09): user confirmed lighting improved but
|
||||
/// reported missing candle flames + fountain water particles. Hypothesis tested:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue