acdream/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs
Erik a5debaca2b 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.
2026-09-05 14:57:52 +02:00

1010 lines
42 KiB
C#

// Tests for EnvCellRenderer (Phase A8, 2026-05-28).
// These cover the pure data-handling portions of EnvCellRenderer.
// The draw-recording Render() and RenderModernMDIInternal() paths require a
// live GPU device and are visual-verified at the render frame (Task 10).
//
// Campaign V slice V11: the raw-GL constructor (which stored a possibly-null
// GL reference and did no other work) was deleted along with the GL arm. The
// sole remaining constructor builds three real pipelines against IGpuDevice,
// so these "no GL calls" tests now construct through the RHI arm with the
// same lightweight, no-hardware-required fakes the composition tests use:
// RecordingGpuDevice (records but does no driver work), GpuDeviceFrameLifetime
// (never began, so CurrentFrame stays null — fine, since these tests never
// draw), and VulkanWorldPassScope (needs only a sample count, no live surface).
// meshManager can no longer be null either — the RHI constructor throws on a
// null ObjectMeshManager, so tests that don't care about mesh-manager
// behavior get a real one built the same way MeshPipelineDeviceSeamTests
// does: VulkanMeshPipelineDevice (the production Vulkan seam implementation)
// 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;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using CullMode = DatReaderWriter.Enums.CullMode;
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()
{
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)]
[InlineData(CullMode.Clockwise)]
[InlineData(CullMode.CounterClockwise)]
public void CellShellCullPolicy_UsesRetailConstructedMeshClockwiseCull(
CullMode sourceSidesType)
{
Assert.Equal(
CullMode.Clockwise,
EnvCellRenderer.ResolveRetailCellShellCullMode(sourceSidesType));
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private static ObjectMeshManager CreateMeshManager(RecordingGpuDevice device) =>
new(
new VulkanMeshPipelineDevice(device.Retirement),
device,
new NullPreparedAssetSource(),
NullLogger<ObjectMeshManager>.Instance);
private static EnvCellRenderer CreateRenderer(ObjectMeshManager? meshManager = null)
{
var device = new RecordingGpuDevice();
return new EnvCellRenderer(
device,
new GpuDeviceFrameLifetime(device),
new VulkanWorldPassScope(sampleCount: 1),
meshManager ?? CreateMeshManager(device),
new WbFrustum());
}
[Fact]
public void EnvironmentDetailCategory_BindsValidStorageDescriptorNine()
{
using var device = new RecordingGpuDevice();
device.Clear();
using IGpuFrame frame = device.BeginFrame();
using IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-detail-binding",
Vector4.Zero,
sampleCount: 1));
using EnvCellRenderer renderer = CreateRenderer();
renderer.BindEnvironmentDetailCategory(pass, frame, instanceCount: 3);
GpuRecordedStorageBind storageBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>());
Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding);
Assert.Equal((uint)(3 * sizeof(uint)), storageBind.SizeBytes);
ReadOnlySpan<uint> categories = MemoryMarshal.Cast<byte, uint>(
device.RingBytes.Slice((int)storageBind.OffsetBytes, (int)storageBind.SizeBytes));
Assert.Equal([1u, 1u, 1u], categories.ToArray());
}
/// <summary>
/// Campaign VM VM1 follow-up. EnvCellRenderer.Rhi's SubmitRhi bound
/// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/
/// StorageInstanceLightSets every frame but never
/// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both
/// both world vertex families read as instanceAlpha[instanceIndex].
/// Predates VM1 (6c79d35c has the same omission); an unfixed gap here
/// would have made the interior detail material read whatever section a DIFFERENT renderer (or a
/// stale ring slot) last left in binding 7, indexed by these cells'
/// instance ids. This pins that SubmitRhi binds its own constant-1.0f
/// section, sized to the live instance count, before it records any
/// draw in the pass — one bind serves every subsequent draw in that
/// pass and every shell pipeline.
/// </summary>
[Theory]
[InlineData(WbRenderPass.Opaque)]
[InlineData(WbRenderPass.Transparent)]
public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass(
WbRenderPass renderPass)
{
const int instanceCount = 5;
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());
frameLifetime.BeginFrame();
IGpuFrame frame = frameLifetime.CurrentFrame!;
using IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-submit-alpha-binding",
Vector4.Zero,
sampleCount: 1));
using IDisposable publication = scope.Publish(pass);
// Seed one real draw command, exactly what
// RenderModernMDIInternal would have built from a live landblock,
// so the test can assert an actual ordering against a real draw
// rather than a vacuous "no draw happened" pass. Same command for
// both passes: with _buildingDetailEnabled left at its default
// (false), the Transparent branch's own detail-interleave logic
// (SubmitRhi's "detailEnabled" block) never fires, so it draws
// through the identical MultiDrawIndexedIndirect call the Opaque
// pass does — only the pipeline rebound inside the per-range loop
// differs, which is not what this test is pinning.
Type rendererType = typeof(EnvCellRenderer);
FieldInfo commandsField = rendererType.GetField(
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!;
commandsField.SetValue(renderer, new[]
{
new DrawElementsIndirectCommand
{
Count = 3,
InstanceCount = (uint)instanceCount,
FirstIndex = 0,
BaseVertex = 0,
BaseInstance = 0,
},
});
FieldInfo batchesField = rendererType.GetField(
"_modernBatches", BindingFlags.NonPublic | BindingFlags.Instance)!;
batchesField.SetValue(renderer, new ModernBatchData[] { default });
FieldInfo rangesField = rendererType.GetField(
"_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!;
var ranges = (List<EnvCellRenderer.MdiDrawRange>)rangesField.GetValue(renderer)!;
ranges.Clear();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 0, firstCommand: 0, commandCount: 1,
RetailSetSurfaceMaterialState.Opaque);
var allInstances = new List<InstanceData>();
for (int i = 0; i < instanceCount; i++)
{
allInstances.Add(new InstanceData
{
Transform = Matrix4x4.Identity,
CellId = 0x8C040100u + (uint)i,
});
}
device.Clear();
MethodInfo submitRhi = rendererType.GetMethod(
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!;
submitRhi.Invoke(
renderer,
new object[] { allInstances, renderPass, 1, instanceCount });
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
int alphaBindIndex = -1;
int firstDrawIndex = -1;
for (int i = 0; i < calls.Count; i++)
{
if (alphaBindIndex < 0
&& calls[i] is GpuRecordedStorageBind bind
&& bind.Binding == GpuBindingModel.StorageInstanceAlpha)
{
alphaBindIndex = i;
}
if (firstDrawIndex < 0 && calls[i] is GpuRecordedMultiDrawIndirect)
firstDrawIndex = i;
}
Assert.True(alphaBindIndex >= 0, "StorageInstanceAlpha was never bound.");
Assert.True(firstDrawIndex >= 0, "The seeded draw command was never recorded.");
Assert.True(
alphaBindIndex < firstDrawIndex,
"StorageInstanceAlpha must be bound before the pass's draw call, "
+ "not left to whatever a prior renderer's bind left in slot 7.");
var alphaBind = (GpuRecordedStorageBind)calls[alphaBindIndex];
Assert.Equal((uint)(instanceCount * sizeof(float)), alphaBind.SizeBytes);
ReadOnlySpan<float> alphaValues = MemoryMarshal.Cast<byte, float>(
device.RingBytes.Slice((int)alphaBind.OffsetBytes, (int)alphaBind.SizeBytes));
Assert.Equal(instanceCount, alphaValues.Length);
foreach (float value in alphaValues)
Assert.Equal(1.0f, value);
}
[Fact]
public void DetailUsesOnlySetSurfaceShellPipelinesAndNoReplayPipeline()
{
using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device);
using var renderer = new EnvCellRenderer(
device,
new GpuDeviceFrameLifetime(device),
new VulkanWorldPassScope(sampleCount: 1),
meshManager,
new WbFrustum());
Assert.Equal(12, device.CreatedPipelines.Count);
Assert.DoesNotContain(device.CreatedPipelines,
pipeline => pipeline.Description.Name.Contains("detail", StringComparison.Ordinal));
}
[Fact]
public void SetSurfacePipelineConstructionFailureDisposesEveryCompletedVariant()
{
using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device);
device.PipelineFailure = description =>
description.Name == "envcell-inverse-depth-write"
? new InvalidOperationException("injected pipeline failure")
: null;
Assert.Throws<InvalidOperationException>(() => new EnvCellRenderer(
device,
new GpuDeviceFrameLifetime(device),
new VulkanWorldPassScope(sampleCount: 1),
meshManager,
new WbFrustum()));
Assert.Equal(9, device.CreatedPipelines.Count);
Assert.All(device.CreatedPipelines, static pipeline => Assert.True(pipeline.IsDisposed));
}
[Fact]
public void DisposeReleasesEverySetSurfacePipelineVariant()
{
using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device);
var renderer = new EnvCellRenderer(
device,
new GpuDeviceFrameLifetime(device),
new VulkanWorldPassScope(sampleCount: 1),
meshManager,
new WbFrustum());
RecordingGpuPipeline[] pipelines = device.CreatedPipelines.ToArray();
renderer.Dispose();
Assert.Equal(12, pipelines.Length);
Assert.All(pipelines, static pipeline => Assert.True(pipeline.IsDisposed));
}
[Fact]
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3,
RetailSetSurfaceMaterialState.Opaque);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4,
RetailSetSurfaceMaterialState.Opaque);
Assert.Equal(
[new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7,
RetailSetSurfaceMaterialState.Opaque)],
ranges);
}
[Fact]
public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3,
RetailSetSurfaceMaterialState.Opaque);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2,
RetailSetSurfaceMaterialState.Opaque);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1,
RetailSetSurfaceMaterialState.Opaque);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2,
RetailSetSurfaceMaterialState.Opaque);
Assert.Equal(
[
new EnvCellRenderer.MdiDrawRange(2, 0, 3, RetailSetSurfaceMaterialState.Opaque),
new EnvCellRenderer.MdiDrawRange(6, 3, 2, RetailSetSurfaceMaterialState.Opaque),
new EnvCellRenderer.MdiDrawRange(2, 5, 1, RetailSetSurfaceMaterialState.Opaque),
new EnvCellRenderer.MdiDrawRange(2, 9, 2, RetailSetSurfaceMaterialState.Opaque),
],
ranges);
}
[Fact]
public void OrderedMdiRanges_IgnoreEmptyCellRanges()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0,
RetailSetSurfaceMaterialState.Opaque);
Assert.Empty(ranges);
}
// -----------------------------------------------------------------------
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
// -----------------------------------------------------------------------
[Fact]
public void GetEnvCellGeomId_DedupBitSet()
{
var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 3 });
// Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids.
Assert.NotEqual(0UL, id & 0x2_0000_0000UL);
}
[Fact]
public void GetEnvCellGeomId_Deterministic()
{
var s = new List<ushort> { 1, 2, 3 };
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s);
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s);
Assert.Equal(a, b);
}
[Fact]
public void GetEnvCellGeomId_DiffersByEnvironmentId()
{
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List<ushort> { 1 });
Assert.NotEqual(a, b);
}
[Fact]
public void GetEnvCellGeomId_DiffersByCellStructure()
{
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 8, new List<ushort> { 1 });
Assert.NotEqual(a, b);
}
[Fact]
public void GetEnvCellGeomId_DiffersBySurfaces()
{
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 2 });
Assert.NotEqual(a, b);
}
// -----------------------------------------------------------------------
// Constructor — pure data, no GL
// -----------------------------------------------------------------------
[Fact]
public void NewRenderer_NeedsPrepareIsTrue()
{
// GL and meshManager are null — only valid for pure-data tests (no
// Initialize() is called, so no GL calls are made).
var r = CreateRenderer();
Assert.True(r.NeedsPrepare);
}
[Fact]
public void NewRenderer_NotDisposed()
{
var r = CreateRenderer();
Assert.False(r.IsDisposed);
}
// -----------------------------------------------------------------------
// RemoveLandblock — pure data path
// -----------------------------------------------------------------------
[Fact]
public void RemoveLandblock_NonExistent_DoesNotThrow()
{
var r = CreateRenderer();
// Should silently no-op.
r.RemoveLandblock(0xA9B40000u);
Assert.True(r.NeedsPrepare);
}
// -----------------------------------------------------------------------
// GetEnvCellGeomId — additional edge cases
// -----------------------------------------------------------------------
[Fact]
public void GetEnvCellGeomId_EmptySurfaces_Deterministic()
{
var a = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List<ushort>());
var b = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List<ushort>());
Assert.Equal(a, b);
Assert.NotEqual(0UL, a & 0x2_0000_0000UL);
}
[Fact]
public void GetEnvCellGeomId_SurfaceOrderMatters()
{
var a = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List<ushort> { 10, 20 });
var b = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List<ushort> { 20, 10 });
// The hash is order-sensitive (matches WB's foreach loop), so
// swapped order should produce a different id.
Assert.NotEqual(a, b);
}
// (Render() requires a GL context — visual-verified in Task 10.)
[Fact]
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
{
// mesh_modern.vert declares SSBO InstanceData as exactly one mat4,
// so the GPU array stride is 64 bytes. EnvCellRenderer's CPU
// InstanceData also carries CellId/Flags for culling/filtering and
// is 80 bytes; uploading that struct corrupts every instance after 0.
Assert.Equal(64, Marshal.SizeOf<Matrix4x4>());
Assert.Equal(80, Marshal.SizeOf<InstanceData>());
var field = typeof(EnvCellRenderer).GetField("_gpuInstanceTransforms",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(field);
Assert.Equal(typeof(Matrix4x4[]), field!.FieldType);
}
// -----------------------------------------------------------------------
// Pool-aliasing regression tests (2026-05-28 audit findings).
//
// Two interconnected bugs caused the post-Wave-5 visual chaos:
// 1. GetPooledList didn't clear reused lists, causing AddRange to grow
// pool entries unbounded across frames.
// 2. Render's pool cursor reset used `BatchedByCell.Count` (cell count,
// a small int with no relation to the pool) instead of WB's
// `PostPreparePoolIndex` (the pool high-water mark after Prepare),
// pointing Render's GetPooledList back into snapshot-owned lists.
//
// These tests use reflection to verify the fixes without widening
// EnvCellRenderer's public API. If either fix regresses, the
// corresponding test fails fast.
// -----------------------------------------------------------------------
[Fact]
public void Snapshot_PostPreparePoolIndex_IsInitSettable()
{
// Compile-time guarantee: the field exists and is init-only.
// If a future refactor renames or removes it, this test won't compile.
var s = new EnvCellVisibilitySnapshot { PostPreparePoolIndex = 42 };
Assert.Equal(42, s.PostPreparePoolIndex);
}
[Fact]
public void Snapshot_PostPreparePoolIndex_DefaultsToZero()
{
var s = new EnvCellVisibilitySnapshot();
Assert.Equal(0, s.PostPreparePoolIndex);
}
[Fact]
public void GetPooledList_ReusedList_IsClearedBeforeReturn()
{
// The bug: WB's GetPooledList clears the list before returning so
// the merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
// populates fresh data. The original port omitted Clear() — each
// frame's lists grew unbounded with stale data layered on top.
//
// Reflection-based test that drives the private GetPooledList +
// _poolIndex/_listPool fields. If a future refactor removes the
// Clear() call, this test fails.
var r = CreateRenderer();
var type = typeof(EnvCellRenderer);
var getPooledListMethod = type.GetMethod("GetPooledList",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(getPooledListMethod);
var poolIndexField = type.GetField("_poolIndex",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(poolIndexField);
// First call — creates _listPool[0], _poolIndex 0 → 1.
var first = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
first.Add(new InstanceData());
first.Add(new InstanceData());
Assert.Equal(2, first.Count);
// Reset cursor to 0 — simulates the start of the next prepare cycle.
poolIndexField!.SetValue(r, 0);
// Second call — returns _listPool[0] (same as first). With the fix
// it should be cleared. Without the fix the list still has 2 items.
var second = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
Assert.Same(first, second); // reuses the same instance
Assert.Empty(second); // and the data is gone
}
[Fact]
public void GetPooledList_FreshList_IsAlwaysEmpty()
{
// Sanity check for the fresh-list branch. _poolIndex past _listPool.Count
// should produce a brand-new empty list and grow the pool.
var r = CreateRenderer();
var type = typeof(EnvCellRenderer);
var getPooledListMethod = type.GetMethod("GetPooledList",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var a = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
var b = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
Assert.NotSame(a, b);
Assert.Empty(a);
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)
{
Type rendererType = typeof(EnvCellRenderer);
rendererType.GetField(
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(renderer, new[]
{
new DrawElementsIndirectCommand
{
Count = 3,
InstanceCount = (uint)instanceCount,
FirstIndex = 0,
BaseVertex = 0,
BaseInstance = 0,
},
});
rendererType.GetField(
"_modernBatches", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(renderer, new ModernBatchData[] { default });
var ranges = (List<EnvCellRenderer.MdiDrawRange>)rendererType.GetField(
"_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(renderer)!;
ranges.Clear();
EnvCellRenderer.AppendMdiDrawRange(
ranges,
groupIndex: 0,
firstCommand: 0,
commandCount: 1,
RetailSetSurfaceMaterialState.Opaque);
}
private static void InvokeSubmitRhi(
EnvCellRenderer renderer,
List<InstanceData> instances,
int instanceCount)
{
typeof(EnvCellRenderer).GetMethod(
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(
renderer,
new object[]
{
instances,
WbRenderPass.Opaque,
1,
instanceCount,
});
}
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
// R-A2 note) but never survive real camera motion.
// -----------------------------------------------------------------------
private static Matrix4x4 ViewProjectionFor(Vector3 eye, Vector3 forward)
{
var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
fieldOfView: 1.2f, aspectRatio: 16f / 9f, nearPlaneDistance: 0.1f, farPlaneDistance: 2000f);
return view * proj;
}
[Fact]
public void CameraApproximatelyEqual_IdenticalCamera_True()
{
var eye = new Vector3(120f, 80f, 10f);
var vp = ViewProjectionFor(eye, Vector3.UnitX);
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(vp, eye, vp, eye));
}
[Fact]
public void CameraApproximatelyEqual_RestJitter_True()
{
// The ~36 µm eye rest jitter must NOT dirty the gate.
var eye = new Vector3(120.34f, 87.91f, 10.2f);
var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f);
var a = ViewProjectionFor(eye, Vector3.UnitX);
var b = ViewProjectionFor(jittered, Vector3.UnitX);
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, jittered));
}
[Fact]
public void CameraApproximatelyEqual_SmallRealRotation_False()
{
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
var eye = new Vector3(120.34f, 87.91f, 10.2f);
float yaw = 0.05f * MathF.PI / 180f;
var a = ViewProjectionFor(eye, Vector3.UnitX);
var b = ViewProjectionFor(eye, new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f));
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, eye));
}
[Fact]
public void CameraApproximatelyEqual_SmallRealTranslation_False()
{
// 5 cm of movement must dirty the gate.
var eye = new Vector3(120.34f, 87.91f, 10.2f);
var moved = eye + new Vector3(0.05f, 0f, 0f);
var a = ViewProjectionFor(eye, Vector3.UnitX);
var b = ViewProjectionFor(moved, Vector3.UnitX);
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
}
[Fact]
public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties()
{
// AC world coordinates reach ~5e4. A relative tolerance applied to the
// matrix translation row would mask sub-meter motion at that scale —
// the eye epsilon is absolute precisely so this case stays sharp.
var eye = new Vector3(40120.34f, 45087.91f, 110.2f);
var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame
var a = ViewProjectionFor(eye, Vector3.UnitX);
var b = ViewProjectionFor(moved, Vector3.UnitX);
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
var jittered = eye + new Vector3(36e-6f, 0f, 0f);
var c = ViewProjectionFor(jittered, Vector3.UnitX);
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, c, jittered));
}
[Fact]
public void NewRenderer_SnapshotGenerationStartsAtZero()
{
var r = CreateRenderer();
Assert.Equal(0, r.SnapshotGeneration);
}
}