Finish bounded lighting-observer cleanup and record verification

This commit is contained in:
Erik 2026-09-05 15:15:59 +02:00
parent 2f192570a2
commit 0ae2666ef0
11 changed files with 70 additions and 582 deletions

View file

@ -814,6 +814,13 @@ room fixtures now reach every drawn cell (7+40product, unchanged ambient.2).
of the temporary observer is now active, followed by final observer-free
re-gate. FPS deferred; owner G4 still FAIL/unpassed.
**#478 observer cleanup:** reviewed scratch `ed2c8b12c` integrates seven
paths with unchanged retained blobs; temporary observer and only its tests
removed. Full scratch Release0W0E, App180/Core69/native2 PASS, lead independent
106/106. Permanent RHI47/ordinary8/native witnesses remain. Evidence:
`478-observer-removal-verification.md`. Fresh integrated Release and unchanged
Town/control re-gate next; FPS deferred, no owner acceptance or main merge.
## 14. Risk register (condensed)
- **R1 second scene graph.** Graph records own only topology/membership facts;

View file

@ -1,6 +1,8 @@
# #478 temporary lighting observer removal
ACTIVE after candidatea5debaca2: unchanged Town High/off/High and0125/8C04
IMPLEMENTED and lead-reviewed at scratch ed2c8b12c; integration's fresh Release
and final graphical repeat are pending. See `478-observer-removal-verification.md`.
Activated after candidatea5debaca2: unchanged Town High/off/High and0125/8C04
control PNGs inspected, eight provisional lighting/reveal PASS frames,
`478-repaired-lighting-graphical.md`. Both own clients exited0/gracefully.
This is not owner acceptance. A failed final visual check returns to

View file

@ -0,0 +1,51 @@
# #478 observer removal — lead verification
The eight candidate PNGs at `a5debaca2` passed the narrow lighting/reveal
smoke provisionally (`478-repaired-lighting-graphical.md`). Under
`478-observer-removal-contract.md`, OpenAI James removed only the temporary
observer and its corresponding tests. Lead committed scratch
`ed2c8b12c1ecd31dbc0069419db8104ed0e2bd49`, base `1772c1a5a`, and verified
the six retained integration blobs against that commit; the seventh path is
the deleted `EnvCellRenderer.LightingObservation.cs`.
## Scope review
Exactly seven paths, 1 insertion / 581 deletions. No lighting selection,
ambient, shader, generated module, package, geometry or FPS changes. Four
composition/DTO/test files restore the exact pre-observer (`59b0b6e72^`) Git
blobs; snapshot-source restoration differs only by one blank line. Remaining
EnvCell test changes remove only observer tests and unused helpers/imports.
The permanent two-cell 47-index RHI upload and next-generation overwrite test,
its required helpers, ordinary 8-index upload and both compiled shader pixel
witnesses remain. Observer symbol search over `src` and `tests` returns none.
Unrelated lifecycle and current-render-scene checkpoint evidence remains.
Lead checked the complete diff and all six raw source hashes against the
frozen implementer return. No new retail algorithm or deviation is introduced;
the preceding independent retail and production/ABI reviews still cover the
unchanged repair. No additional retail re-port or review round was needed.
The removed temporary source remains recoverable from Git history.
## Verification before integration
Artifacts under scratch `s5-478-observer-cleanup/artifacts/478-observer-cleanup/`:
- `release-build-r2/console.log`: full solution Release, 0 warnings/errors.
First build caught a removed Wb import also needed by `GpuMemoryTracker`;
that import was restored before the final build and freeze.
- `app-focused/app-focused.trx`: 180/180 affected/permanent tests.
- `core-lighting/core-lighting.trx`: 69/69, including installed Town DAT.
- `shader-pixels/shader-pixels.trx`: both committed-module witnesses pass.
- Lead independent `root-removal/root-removal.trx`: 106/106 affected
lifecycle/composition/EnvCell/ordinary-upload tests plus both native pixels.
Lead inspected actual build output and all TRX counters. Both client names
were absent before the independent execution. These are focused correctness
tests, not the full solution test lane or an FPS benchmark.
## Final graphical gate
Pending fresh Release on the integrated observer-free commit and the unchanged
Town High/off/High plus 0125/8C04/Holtburg control routes. Candidate consumed-
light measurements remain evidence only for `a5debaca2`, not for this binary.
Owner acceptance remains owed; G4 FAIL/unpassed, no main merge. FPS deferred.

View file

@ -4982,3 +4982,11 @@ requested but its live module check missed the already-exited process; no
stronger claim. `478-repaired-lighting-graphical.md` seals paths/hashes/limits.
Observer removal contract activated; final observer-free re-gate still owed.
No FPS work, no owner acceptance, G4FAIL/no main merge.
Observer cleanup reviewed and integrated from scratch `ed2c8b12c`: exactly
seven paths, temporary capture/DTO/wiring and observer-only tests removed.
Permanent RHI47/ordinary8/native-pixel coverage retained; six retained blobs
match. Full scratch Release0W0E, App180/Core69/native2 PASS; lead independent
affected/native106/106. `478-observer-removal-verification.md` records scope
and actual reports. No new deviation, retail algorithm or FPS edit. Fresh
integrated Release and unchanged final graphical repeats remain next.

View file

@ -596,8 +596,6 @@ internal sealed class FrameRootCompositionPhase
foundation.MeshAdapter,
foundation.TextureCache,
live.DrawDispatcher,
live.EnvCellRenderer,
d.Lighting,
d.FrameProfiler,
content.Dats,
foundation.Residency,

View file

@ -2,7 +2,6 @@ using System.Text.Json;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using AcDream.Core.Physics;
@ -97,8 +96,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
ResidencySnapshot Residency,
double Fps,
double FrameMilliseconds,
string? LastFrameProfile,
EnvCellLightingConsumptionSnapshot? EnvCellLighting = null);
string? LastFrameProfile);
internal sealed record WorldLifecycleCheckpoint(
int Sequence,

View file

@ -7,7 +7,6 @@ using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Lighting;
using AcDream.Core.Vfx;
using DatReaderWriter.Lib.IO;
@ -40,8 +39,6 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly WbMeshAdapter? _meshes;
private readonly TextureCache _textures;
private readonly WbDrawDispatcher? _dispatcher;
private readonly EnvCellRenderer? _envCellRenderer;
private readonly LightManager _lighting;
private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency;
@ -65,8 +62,6 @@ internal sealed class WorldLifecycleResourceSnapshotSource
WbMeshAdapter? meshes,
TextureCache textures,
WbDrawDispatcher? dispatcher,
EnvCellRenderer? envCellRenderer,
LightManager lighting,
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency,
@ -93,8 +88,6 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_meshes = meshes;
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_dispatcher = dispatcher;
_envCellRenderer = envCellRenderer;
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
_frameProfiler = frameProfiler
?? throw new ArgumentNullException(nameof(frameProfiler));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
@ -163,10 +156,6 @@ internal sealed class WorldLifecycleResourceSnapshotSource
ParticleOwners: _particleSink.TrackedOwnerCount,
EffectOwners: _effects.ReadyOwnerCount,
LightOwners: _lights.TrackedOwnerCount,
EnvCellLighting: CaptureEnvCellLighting(
outcome,
_envCellRenderer,
_lighting),
ScriptOwners: _scripts.ActiveOwnerCount,
ActiveScripts: _scripts.ActiveScriptCount,
MeshRenderData: mesh.RenderData,
@ -215,16 +204,4 @@ internal sealed class WorldLifecycleResourceSnapshotSource
LastFrameProfile: _frameProfiler.LastReport);
}
internal static EnvCellLightingConsumptionSnapshot? CaptureEnvCellLighting(
RenderFrameOutcome outcome,
EnvCellRenderer? renderer,
LightManager lighting)
{
ArgumentNullException.ThrowIfNull(lighting);
return renderer?.CaptureLightingConsumption(
lighting,
currentWorldFrame:
!outcome.SkippedZeroArea
&& outcome.World.NormalWorldDrawn);
}
}

View file

@ -1,178 +0,0 @@
using AcDream.Core.Lighting;
namespace AcDream.App.Rendering.Wb;
internal sealed record EnvCellLightingVectorBits(
uint X,
uint Y,
uint Z);
internal sealed record EnvCellLightingAmbientSnapshot(
EnvCellLightingVectorBits AmbientColor,
EnvCellLightingVectorBits SunColor,
EnvCellLightingVectorBits SunDirection);
internal sealed record EnvCellLightingSourceSnapshot(
int Index,
uint OwnerId,
uint CellId,
int Kind,
bool IsDynamic,
bool IsLit,
bool TracksOwnerPose,
EnvCellLightingVectorBits RankingOrigin,
EnvCellLightingVectorBits Position,
EnvCellLightingVectorBits Forward,
EnvCellLightingVectorBits Color,
uint Intensity,
uint Range,
uint ConeAngle,
uint DistSq,
uint[] LocalPose);
internal sealed record EnvCellLightingCellSetSnapshot(
uint CellId,
int[] Indices);
/// <summary>
/// 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 47-index values copied to binding 5.
/// </summary>
internal sealed record EnvCellLightingConsumptionSnapshot(
bool HasCurrentEnvCellInput,
int FrameGeneration,
int FrameSlot,
int RegisteredLightCount,
int LogicalGlobalLightCount,
int UploadedGlobalLightCount,
uint[] PackedGlobalLightBits,
EnvCellLightingSourceSnapshot[] Sources,
EnvCellLightingCellSetSnapshot[] CellSets,
EnvCellLightingAmbientSnapshot Ambient);
public sealed partial class EnvCellRenderer
{
/// <summary>
/// Copies the retained inputs from this frame's completed EnvCell submission
/// for an explicitly requested lifecycle checkpoint. This method never
/// rebuilds a light snapshot or invokes either cell selector.
/// </summary>
internal EnvCellLightingConsumptionSnapshot CaptureLightingConsumption(
LightManager lighting,
bool currentWorldFrame)
{
ArgumentNullException.ThrowIfNull(lighting);
EnvCellLightingAmbientSnapshot ambient = CaptureAmbient(
lighting.CurrentAmbient);
if (!currentWorldFrame)
return EmptyLightingConsumption(lighting.RegisteredCount, ambient);
List<KeyValuePair<uint, CachedCellLightSet>> currentCells = [];
foreach (KeyValuePair<uint, CachedCellLightSet> entry in _cellLightSetCache)
{
if (entry.Value.FrameGeneration == _lightFrameGeneration)
currentCells.Add(entry);
}
currentCells.Sort(static (left, right) => left.Key.CompareTo(right.Key));
if (currentCells.Count == 0)
return EmptyLightingConsumption(lighting.RegisteredCount, ambient);
IReadOnlyList<LightSource>? pointSnapshot = _pointSnapshot;
int logicalCount = pointSnapshot?.Count ?? 0;
int uploadedCount = Math.Max(logicalCount, 1);
int packedFloatCount = checked(
uploadedCount * GlobalLightPacker.FloatsPerLight);
var packedBits = new uint[packedFloatCount];
for (int index = 0; index < packedFloatCount; index++)
packedBits[index] = Bits(_globalLightData[index]);
var sources = new EnvCellLightingSourceSnapshot[logicalCount];
for (int index = 0; index < logicalCount; index++)
sources[index] = CaptureSource(index, pointSnapshot![index]);
var cellSets = new EnvCellLightingCellSetSnapshot[currentCells.Count];
for (int index = 0; index < currentCells.Count; index++)
{
KeyValuePair<uint, CachedCellLightSet> entry = currentCells[index];
cellSets[index] = new EnvCellLightingCellSetSnapshot(
entry.Key,
(int[])entry.Value.Indices.Clone());
}
return new EnvCellLightingConsumptionSnapshot(
HasCurrentEnvCellInput: true,
FrameGeneration: _lightFrameGeneration,
FrameSlot: _dynamicFrameSlot,
RegisteredLightCount: lighting.RegisteredCount,
LogicalGlobalLightCount: logicalCount,
UploadedGlobalLightCount: uploadedCount,
PackedGlobalLightBits: packedBits,
Sources: sources,
CellSets: cellSets,
Ambient: ambient);
}
private EnvCellLightingConsumptionSnapshot EmptyLightingConsumption(
int registeredLightCount,
EnvCellLightingAmbientSnapshot ambient) =>
new(
HasCurrentEnvCellInput: false,
FrameGeneration: _lightFrameGeneration,
FrameSlot: _dynamicFrameSlot,
RegisteredLightCount: registeredLightCount,
LogicalGlobalLightCount: 0,
UploadedGlobalLightCount: 0,
PackedGlobalLightBits: [],
Sources: [],
CellSets: [],
Ambient: ambient);
private static EnvCellLightingSourceSnapshot CaptureSource(
int index,
LightSource source) =>
new(
Index: index,
OwnerId: source.OwnerId,
CellId: source.CellId,
Kind: (int)source.Kind,
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),
Intensity: Bits(source.Intensity),
Range: Bits(source.Range),
ConeAngle: Bits(source.ConeAngle),
DistSq: Bits(source.DistSq),
LocalPose:
[
Bits(source.LocalPose.M11), Bits(source.LocalPose.M12),
Bits(source.LocalPose.M13), Bits(source.LocalPose.M14),
Bits(source.LocalPose.M21), Bits(source.LocalPose.M22),
Bits(source.LocalPose.M23), Bits(source.LocalPose.M24),
Bits(source.LocalPose.M31), Bits(source.LocalPose.M32),
Bits(source.LocalPose.M33), Bits(source.LocalPose.M34),
Bits(source.LocalPose.M41), Bits(source.LocalPose.M42),
Bits(source.LocalPose.M43), Bits(source.LocalPose.M44),
]);
private static EnvCellLightingAmbientSnapshot CaptureAmbient(
CellAmbientState ambient) =>
new(
CaptureVector(ambient.AmbientColor),
CaptureVector(ambient.SunColor),
CaptureVector(ambient.SunDirection));
private static EnvCellLightingVectorBits CaptureVector(
System.Numerics.Vector3 value) =>
new(Bits(value.X), Bits(value.Y), Bits(value.Z));
private static uint Bits(float value) =>
BitConverter.SingleToUInt32Bits(value);
}

View file

@ -7,7 +7,6 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Architecture;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Lighting;
namespace AcDream.App.Tests.Composition;
@ -78,52 +77,6 @@ public sealed class FrameRootCompositionTests
Assert.True(secondTransfer > firstTransfer);
}
[Fact]
public void ProductionLightingCheckpointReceivesCanonicalRendererAndManager()
{
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
"ComposeCore",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMethodException(
typeof(FrameRootCompositionPhase).FullName,
"ComposeCore");
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(compose);
int constructor = CallIndex(
calls,
typeof(WorldLifecycleResourceSnapshotSource),
".ctor");
int renderer = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(LivePresentationResult)
&& pair.call.Target.Name == "get_EnvCellRenderer")
.index;
int lighting = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(FrameRootDependencies)
&& pair.call.Target.Name == "get_Lighting")
.index;
int dispatcher = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(LivePresentationResult)
&& pair.call.Target.Name == "get_DrawDispatcher")
.index;
int profiler = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(FrameRootDependencies)
&& pair.call.Target.Name == "get_FrameProfiler")
.index;
Assert.Equal(dispatcher + 1, renderer);
Assert.Equal(renderer + 1, lighting);
Assert.Equal(lighting + 1, profiler);
Assert.True(profiler < constructor);
}
[Fact]
public void ProductionWiresTheOptionalScreenshotAdapterIntoTheOuterRenderOwner()
{
@ -196,19 +149,6 @@ public sealed class FrameRootCompositionTests
BindingFlags.Instance | BindingFlags.NonPublic),
field => field.FieldType == typeof(GameWindow));
FieldInfo[] snapshotFields = typeof(WorldLifecycleResourceSnapshotSource)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Contains(snapshotFields, field => field.FieldType == typeof(EnvCellRenderer));
Assert.Contains(snapshotFields, field => field.FieldType == typeof(LightManager));
ParameterInfo[] snapshotParameters = typeof(WorldLifecycleResourceSnapshotSource)
.GetConstructors().Single().GetParameters();
Assert.Contains(snapshotParameters,
parameter => parameter.ParameterType == typeof(EnvCellRenderer)
&& !parameter.HasDefaultValue);
Assert.Contains(snapshotParameters,
parameter => parameter.ParameterType == typeof(LightManager)
&& !parameter.HasDefaultValue);
MethodInfo capture = typeof(WorldLifecycleResourceSnapshotSource)
.GetMethod(nameof(WorldLifecycleResourceSnapshotSource.Capture))!;
Assert.Equal(
@ -227,12 +167,6 @@ public sealed class FrameRootCompositionTests
calls,
call => call.Target.DeclaringType == typeof(FrameProfiler)
&& call.Target.Name == "get_LastReport");
Assert.Contains(
calls,
call => call.Target.DeclaringType
== typeof(WorldLifecycleResourceSnapshotSource)
&& call.Target.Name == nameof(
WorldLifecycleResourceSnapshotSource.CaptureEnvCellLighting));
Assert.Contains(
calls,
call => call.Target.DeclaringType == typeof(WorldRenderFrameOutcome)

View file

@ -4,7 +4,6 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using AcDream.Runtime;
@ -413,62 +412,6 @@ public sealed class WorldLifecycleAutomationControllerTests
{
LoadedLandblocks = 1,
WorldEntities = 42,
EnvCellLighting = new EnvCellLightingConsumptionSnapshot(
HasCurrentEnvCellInput: true,
FrameGeneration: 31,
FrameSlot: 2,
RegisteredLightCount: 214,
LogicalGlobalLightCount: 1,
UploadedGlobalLightCount: 1,
PackedGlobalLightBits:
[
0x3F800000u, 0x40000000u, 0x40400000u, 0x3F800000u,
0u, 0u, 0x3F800000u, 0x41200000u,
0x3DCCCCCDu, 0x3E4CCCCDu, 0x3E99999Au, 0x40000000u,
0u, 0x3F800000u, 0u, 0u,
],
Sources:
[
new EnvCellLightingSourceSnapshot(
Index: 0,
OwnerId: 0x5000000Au,
CellId: 0x00070143u,
Kind: 1,
IsDynamic: true,
IsLit: true,
TracksOwnerPose: true,
RankingOrigin: new EnvCellLightingVectorBits(
0x3F000000u, 0x3F800000u, 0x40000000u),
Position: new EnvCellLightingVectorBits(
0x3F800000u, 0x40000000u, 0x40400000u),
Forward: new EnvCellLightingVectorBits(
0u, 0u, 0x3F800000u),
Color: new EnvCellLightingVectorBits(
0x3DCCCCCDu, 0x3E4CCCCDu, 0x3E99999Au),
Intensity: 0x40000000u,
Range: 0x41200000u,
ConeAngle: 0u,
DistSq: 0x42F60000u,
LocalPose:
[
0x3F800000u, 0u, 0u, 0u,
0u, 0x3F800000u, 0u, 0u,
0u, 0u, 0x3F800000u, 0u,
0u, 0u, 0u, 0x3F800000u,
]),
],
CellSets:
[
new EnvCellLightingCellSetSnapshot(
0x00070143u,
[0, .. Enumerable.Repeat(-1, 46)]),
],
Ambient: new EnvCellLightingAmbientSnapshot(
new EnvCellLightingVectorBits(
0x3E4CCCCDu, 0x3E4CCCCDu, 0x3E4CCCCDu),
new EnvCellLightingVectorBits(0u, 0u, 0u),
new EnvCellLightingVectorBits(
0xBF800000u, 0x3F000000u, 0x3E800000u))),
RenderSceneOracle = new CurrentRenderSceneOracleSnapshot(
Enabled: true,
CompletedFrameSequence: 19,
@ -587,28 +530,6 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
.GetProperty("screenshotCaptured").GetBoolean());
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
JsonElement envCellLighting = json.RootElement
.GetProperty("resources")
.GetProperty("envCellLighting");
Assert.True(envCellLighting
.GetProperty("hasCurrentEnvCellInput").GetBoolean());
Assert.Equal(31, envCellLighting.GetProperty("frameGeneration").GetInt32());
Assert.Equal(214, envCellLighting
.GetProperty("registeredLightCount").GetInt32());
Assert.Equal(0x3F800000u, envCellLighting
.GetProperty("sources")[0]
.GetProperty("position")
.GetProperty("x").GetUInt32());
Assert.Equal(0x00070143u, envCellLighting
.GetProperty("cellSets")[0]
.GetProperty("cellId").GetUInt32());
Assert.Equal(-1, envCellLighting
.GetProperty("cellSets")[0]
.GetProperty("indices")[7].GetInt32());
Assert.Equal(0xBF800000u, envCellLighting
.GetProperty("ambient")
.GetProperty("sunDirection")
.GetProperty("x").GetUInt32());
JsonElement renderSceneOracle = json.RootElement
.GetProperty("resources")
.GetProperty("renderSceneOracle");

View file

@ -18,13 +18,11 @@
// over the same RecordingGpuDevice, plus a no-op IPreparedAssetSource.
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using AcDream.App.Rendering;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Wb;
@ -40,206 +38,6 @@ namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests
{
[Fact]
public void LightingCheckpoint_ReportsActualBoundInputsAndRejectsStaleFrames()
{
const uint landblockId = 0x8C04FFFFu;
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());
renderer.CommitLandblock(new EnvCellLandblockBuild(
landblockId,
[],
[
LightingShell(cellA, 1, new Vector3(-1f), new Vector3(1f)),
LightingShell(cellB, 2, new Vector3(99f, -1f, -1f),
new Vector3(101f, 1f, 1f)),
]));
var lighting = new LightManager
{
CurrentAmbient = new CellAmbientState(
new Vector3(0.2f, 0.3f, 0.4f),
new Vector3(0.5f, 0.6f, 0.7f),
new Vector3(-0.1f, 0.8f, -0.9f)),
};
var dynamicLight = new LightSource
{
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,
Range = 1.25f,
ConeAngle = 0.625f,
OwnerId = 0x50000001u,
CellId = cellA,
IsDynamic = true,
IsLit = true,
TracksOwnerPose = true,
DistSq = 123.5f,
LocalPose = new Matrix4x4(
1f, 2f, 3f, 4f,
5f, 6f, 7f, 8f,
9f, 10f, 11f, 12f,
13f, 14f, 15f, 16f),
};
var staticA = new LightSource
{
Kind = LightKind.Point,
WorldPosition = Vector3.Zero,
RankingOrigin = Vector3.Zero,
WorldForward = Vector3.UnitX,
ColorLinear = new Vector3(0.9f, 0.1f, 0.2f),
Intensity = 2f,
Range = 4f,
OwnerId = 0x50000002u,
CellId = cellA,
IsLit = true,
};
var staticB = new LightSource
{
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,
Range = 4f,
OwnerId = 0x50000003u,
CellId = cellB,
IsLit = true,
};
lighting.Register(dynamicLight);
lighting.Register(staticA);
lighting.Register(staticB);
lighting.BuildPointLightSnapshot(Vector3.Zero);
renderer.SetPointSnapshot(lighting.PointSnapshot);
frameLifetime.BeginFrame();
IGpuFrame frame = frameLifetime.CurrentFrame!;
using IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-lighting-observation",
Vector4.Zero,
sampleCount: 1));
using IDisposable publication = scope.Publish(pass);
renderer.BeginFrame(frame.SlotIndex);
SeedLightingSubmission(renderer, instanceCount: 2);
// Deliberately submit in the opposite order from deterministic JSON's
// outer cell-id ordering. Each exported set must still match the exact
// binding-5 block associated with its submitted cell.
var instances = new List<InstanceData>
{
new() { Transform = Matrix4x4.Identity, CellId = cellB },
new() { Transform = Matrix4x4.Identity, CellId = cellA },
};
device.Clear();
InvokeSubmitRhi(renderer, instances, instanceCount: 2);
EnvCellLightingConsumptionSnapshot snapshot =
renderer.CaptureLightingConsumption(lighting, currentWorldFrame: true);
Assert.True(snapshot.HasCurrentEnvCellInput);
Assert.Equal(frame.SlotIndex, snapshot.FrameSlot);
Assert.Equal(3, snapshot.RegisteredLightCount);
Assert.Equal(3, snapshot.LogicalGlobalLightCount);
Assert.Equal(3, snapshot.UploadedGlobalLightCount);
Assert.Equal([cellA, cellB], snapshot.CellSets.Select(set => set.CellId));
GpuRecordedStorageBind globalBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageGlobalLights);
uint[] actualGlobalBits = MemoryMarshal.Cast<byte, uint>(
device.RingBytes.Slice(
(int)globalBind.OffsetBytes,
(int)globalBind.SizeBytes)).ToArray();
Assert.Equal(actualGlobalBits, snapshot.PackedGlobalLightBits);
GpuRecordedStorageBind setBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageInstanceLightSets);
int[] actualSets = MemoryMarshal.Cast<byte, int>(
device.RingBytes.Slice(
(int)setBind.OffsetBytes,
(int)setBind.SizeBytes)).ToArray();
Assert.Equal(
actualSets.AsSpan(0, LightManager.MaxLightsPerEnvCell).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
Assert.Equal(
actualSets.AsSpan(
LightManager.MaxLightsPerEnvCell,
LightManager.MaxLightsPerEnvCell).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
int[] expectedSet = [0, 1, 2, .. Enumerable.Repeat(-1, 44)];
Assert.Equal(expectedSet,
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
Assert.Equal(expectedSet,
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
EnvCellLightingSourceSnapshot source = snapshot.Sources[0];
Assert.Equal(0, source.Index);
Assert.Equal(dynamicLight.OwnerId, source.OwnerId);
Assert.Equal(dynamicLight.CellId, source.CellId);
Assert.Equal((int)LightKind.Spot, source.Kind);
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);
Assert.Equal(BitConverter.SingleToUInt32Bits(16f), source.LocalPose[15]);
RenderFrameOutcome nonWorld = new(
new WorldRenderFrameOutcome(0, 0, NormalWorldDrawn: false),
default);
AssertNoCurrentLighting(WorldLifecycleResourceSnapshotSource
.CaptureEnvCellLighting(nonWorld, renderer, lighting)!);
AssertNoCurrentLighting(WorldLifecycleResourceSnapshotSource
.CaptureEnvCellLighting(RenderFrameOutcome.ZeroArea, renderer, lighting)!);
// A real zero-light submission still binds one all-zero dummy light.
var emptyLighting = new LightManager();
renderer.BeginFrame(frame.SlotIndex);
renderer.SetPointSnapshot(emptyLighting.PointSnapshot);
device.Clear();
InvokeSubmitRhi(renderer, [instances[0]], instanceCount: 1);
EnvCellLightingConsumptionSnapshot empty =
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true);
Assert.True(empty.HasCurrentEnvCellInput);
Assert.Equal(0, empty.LogicalGlobalLightCount);
Assert.Equal(1, empty.UploadedGlobalLightCount);
Assert.Equal(GlobalLightPacker.FloatsPerLight, empty.PackedGlobalLightBits.Length);
Assert.All(empty.PackedGlobalLightBits, bits => Assert.Equal(0u, bits));
GpuRecordedStorageBind emptyGlobalBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageGlobalLights);
Assert.Equal(
MemoryMarshal.Cast<byte, uint>(device.RingBytes.Slice(
(int)emptyGlobalBind.OffsetBytes,
(int)emptyGlobalBind.SizeBytes)).ToArray(),
empty.PackedGlobalLightBits);
// BeginFrame advances the existing cache generation. Without a draw,
// the following checkpoint cannot reuse the preceding frame's cell set.
renderer.BeginFrame(frame.SlotIndex);
AssertNoCurrentLighting(
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true));
}
[Fact]
public void SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration()
{
@ -850,23 +648,6 @@ public class EnvCellRendererTests
Assert.Empty(b);
}
private static EnvCellShellPlacement LightingShell(
uint cellId,
ulong geometryId,
Vector3 minimum,
Vector3 maximum) =>
new(
CellId: cellId,
GeometryId: geometryId,
EnvironmentId: 0x0D000001u,
CellStructure: 1,
Surfaces: ImmutableArray<ushort>.Empty,
WorldPosition: Vector3.Zero,
Rotation: Quaternion.Identity,
Transform: Matrix4x4.Identity,
LocalBounds: new WbBoundingBox(minimum, maximum),
WorldBounds: new WbBoundingBox(minimum, maximum));
private static void SeedLightingSubmission(
EnvCellRenderer renderer,
int instanceCount)
@ -918,17 +699,6 @@ public class EnvCellRendererTests
});
}
private static void AssertNoCurrentLighting(
EnvCellLightingConsumptionSnapshot snapshot)
{
Assert.False(snapshot.HasCurrentEnvCellInput);
Assert.Equal(0, snapshot.LogicalGlobalLightCount);
Assert.Equal(0, snapshot.UploadedGlobalLightCount);
Assert.Empty(snapshot.PackedGlobalLightBits);
Assert.Empty(snapshot.Sources);
Assert.Empty(snapshot.CellSets);
}
// -----------------------------------------------------------------------
// Prepare gate (2026-07-24) — pure camera-tolerance half.
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer