PrepareRenderBatches rebuilt the full EnvCell visibility snapshot every frame any indoor/building root was resolved: a Parallel.ForEach dispatch over every GpuReady EnvCell landblock (with per-landblock locks), a fresh outer dictionary plus one fresh inner dictionary per visible cell, a new snapshot object, and a complete transparency rescan - all while standing perfectly still. The NeedsPrepare flag existed since Phase A8 as the intended rebuild gate but was never read by production code. This wires the gate on the snapshot's actual inputs: - landblock commits/removals (the existing NeedsPrepare flag), - the visible-cell filter (content-compared; the caller reuses one scratch HashSet across frames), - the trim window (center/radius), - mesh render-data availability - new ObjectMeshManager.RenderDataAvailabilityVersion, bumped at publish, pending-release hide, release completion, and teardown, because the snapshot bakes per-cell transparency from TryGetRenderData and a late-arriving transparent shell must reclassify its cell, - the camera: eye position under a 1 mm ABSOLUTE epsilon (swallows the documented ~36 um rest jitter, dirties on any real movement; the VP translation row scales with AC's ~5e4 world coordinates where a relative tolerance would mask sub-meter motion) plus rows 1-3 of view*projection (position-independent rotation x projection) under relative 1e-5. Skipping is pool-safe: RenderCore re-anchors _poolIndex to the active snapshot's PostPreparePoolIndex on every call, so consecutive Renders without an intervening Prepare reuse scratch lists past the snapshot's owned region exactly as within-frame passes already do. The empty-filter branch is now also idempotent instead of allocating a fresh empty snapshot per frame. Render still receives the current frame's view-projection every frame (the U.4 stale-matrix rule) - only the snapshot rebuild is gated. Pixels must be identical; needs the standard user visual pass (dungeon + town-near-buildings) before the change is considered accepted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
344 lines
14 KiB
C#
344 lines
14 KiB
C#
// Tests for EnvCellRenderer (Phase A8, 2026-05-28).
|
|
// These cover the pure data-handling portions of EnvCellRenderer.
|
|
// The GL-dependent Render() and RenderModernMDIInternal() paths require a
|
|
// GL context and are visual-verified at the render frame (Task 10).
|
|
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Wb;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
public class EnvCellRendererTests
|
|
{
|
|
[Fact]
|
|
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4);
|
|
|
|
Assert.Equal(
|
|
[new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)],
|
|
ranges);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2);
|
|
|
|
Assert.Equal(
|
|
[
|
|
new EnvCellRenderer.MdiDrawRange(2, 0, 3),
|
|
new EnvCellRenderer.MdiDrawRange(6, 3, 2),
|
|
new EnvCellRenderer.MdiDrawRange(2, 5, 1),
|
|
new EnvCellRenderer.MdiDrawRange(2, 9, 2),
|
|
],
|
|
ranges);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderedMdiRanges_IgnoreEmptyCellRanges()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0);
|
|
|
|
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 = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
Assert.True(r.NeedsPrepare);
|
|
}
|
|
|
|
[Fact]
|
|
public void NewRenderer_NotDisposed()
|
|
{
|
|
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
Assert.False(r.IsDisposed);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RemoveLandblock — pure data path
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RemoveLandblock_NonExistent_DoesNotThrow()
|
|
{
|
|
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
// 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 = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
|
|
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 = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
|
|
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 = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
Assert.Equal(0, r.SnapshotGeneration);
|
|
}
|
|
}
|