acdream/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs
Erik d5cfd1c916 Fix alpha-tested blend depth writes
Add paired depth-write variants for all five blended SetSurface families in ordinary and atmospheric Wb pipeline sets at both sample counts and in EnvCell. Select them only from the carried AlphaTestEnabled state, retain pure-Clip and Translucent override behavior, and cover disposal plus partial-construction rollback.

Correct the three stale depth oracles and the AP register count/temporary AP-232 overclaim.

Required restored mutations and first discriminating failures:

1. Wb StraightAlpha+Clip collapsed to depth-off: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_UsesExactResolvedSetSurfaceState failed at line 1278; expected wb-mesh-alpha-depth-write-1x, binds were wb-mesh-alpha-1x.

2. EnvCell raw Additive+Clip collapsed to depth-off: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity failed at line 132; expected envcell-raw-additive-depth-write, actual envcell-raw-additive.

3. EnvCell non-Clip raw Additive forced depth-on: the same production transcript failed at line 132; first row expected envcell-raw-additive, actual envcell-raw-additive-depth-write.

4. Late Translucent|Clip override forced alpha-test/depth-on: the same production transcript failed at line 132; expected envcell-alpha, actual envcell-alpha-depth-write.
2026-09-05 02:03:13 +02:00

636 lines
26 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.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using AcDream.App.Rendering;
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.Meshing;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests
{
[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);
}
// -----------------------------------------------------------------------
// 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);
}
}