feat(render): Phase A8 Wave 1 — WB scaffolding extraction + stencil low-level method

Five tasks shipped together (interdependent at build time):

Task 1: WbRenderPass enum — verbatim port of WB RenderPass.cs:1-22
Task 2: WbFrustum + WbBoundingBox + FrustumTestResult — verbatim port
  of WB Frustum.cs (98 LOC) with namespace + BoundingBox-type adaptations.
  +7 unit tests.
Task 3: EnvCellSceneryInstance + EnvCellLandblock — verbatim port of WB
  SceneryInstance.cs:1-161, renamed scope-narrow. Dropped editor-only
  fields (DisqualificationReason, ParticleEmitters, IsQueuedForUpload,
  InstanceBufferOffset, InstanceCount, MdiCommands, IsTransformOnlyUpdate)
  + InstanceId narrowed uint (we don't use ObjectId's editor methods).
  +5 unit tests.
Task 4: EnvCellVisibilitySnapshot — direct port of WB VisibilitySnapshot
  narrowed to BatchedByCell + VisibleLandblocks only.
Task 7: IndoorCellStencilPipeline.RenderBuildingStencilMask — new
  low-level WB-faithful entry mirroring PortalRenderManager:471-484.
  No surrounding GL state setup (caller's responsibility). Probe fields
  LastStencilVertexCount / LastStencilWasFarPunch / LastStencilBuildingId
  for the [stencil] probe emitter in Task 9.

Build green, 18 tests pass (7 new Frustum + 5 new SceneryInstance + 6
existing stencil pipeline). Ready for Wave 2 (EnvCellRenderer port).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 14:46:07 +02:00
parent 95f0d5267b
commit fc68d6d01f
7 changed files with 595 additions and 0 deletions

View file

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellSceneryInstanceTests
{
[Fact]
public void Instance_DefaultConstruct_HasZeroFields()
{
var s = new EnvCellSceneryInstance();
Assert.Equal(0UL, s.ObjectId);
Assert.False(s.IsBuilding);
Assert.False(s.IsSetup);
Assert.False(s.IsEntryCell);
}
[Fact]
public void Instance_AssignFields_RoundTrip()
{
var t = Matrix4x4.CreateTranslation(1, 2, 3);
var s = new EnvCellSceneryInstance
{
ObjectId = 0x01000123,
IsBuilding = true,
IsSetup = false,
IsEntryCell = true,
WorldPosition = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
Scale = Vector3.One,
Transform = t,
};
Assert.Equal(0x01000123UL, s.ObjectId);
Assert.True(s.IsBuilding);
Assert.True(s.IsEntryCell);
Assert.Equal(new Vector3(1, 2, 3), s.WorldPosition);
Assert.Equal(t, s.Transform);
}
[Fact]
public void Landblock_Construct_StartsEmpty()
{
var lb = new EnvCellLandblock { GridX = 0xA9, GridY = 0xB4 };
Assert.Empty(lb.StaticPartGroups);
Assert.Empty(lb.BuildingPartGroups);
Assert.Empty(lb.Instances);
Assert.Empty(lb.EnvCellBounds);
Assert.False(lb.InstancesReady);
Assert.False(lb.GpuReady);
Assert.False(lb.MeshDataReady);
}
[Fact]
public void Landblock_AddInstanceToBuildingPartGroups_PreservesOrder()
{
var lb = new EnvCellLandblock();
if (!lb.BuildingPartGroups.TryGetValue(0x01000001UL, out var list))
{
list = new List<InstanceData>();
lb.BuildingPartGroups[0x01000001UL] = list;
}
list.Add(default);
list.Add(default);
Assert.Single(lb.BuildingPartGroups);
Assert.Equal(2, lb.BuildingPartGroups[0x01000001UL].Count);
}
[Fact]
public void Landblock_PendingInstancesNullByDefault()
{
var lb = new EnvCellLandblock();
Assert.Null(lb.PendingInstances);
Assert.Null(lb.PendingEnvCellBounds);
Assert.Null(lb.PendingSeenOutsideCells);
}
}

View file

@ -0,0 +1,95 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
public class WbFrustumTests
{
private static Matrix4x4 PerspectiveVp(Vector3 eye, Vector3 lookAt) =>
Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitY)
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1.0f, 0.1f, 100.0f);
[Fact]
public void TestBox_BoxInFrontOfCamera_ReturnsInsideOrIntersecting()
{
var f = new WbFrustum();
f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
var res = f.TestBox(box);
Assert.NotEqual(FrustumTestResult.Outside, res);
}
[Fact]
public void TestBox_BoxBehindCamera_ReturnsOutside()
{
var f = new WbFrustum();
f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10));
Assert.Equal(FrustumTestResult.Outside, f.TestBox(box));
}
[Fact]
public void Intersects_BoxInFrontOfCamera_ReturnsTrue()
{
var f = new WbFrustum();
f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
Assert.True(f.Intersects(box));
}
[Fact]
public void Update_IsIdempotent()
{
var f = new WbFrustum();
var vp = PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1));
f.Update(vp);
f.Update(vp);
var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
Assert.NotEqual(FrustumTestResult.Outside, f.TestBox(box));
}
[Fact]
public void WbBoundingBox_Union_ExtendsToCoverBoth()
{
var a = new WbBoundingBox(new Vector3(0, 0, 0), new Vector3(1, 1, 1));
var b = new WbBoundingBox(new Vector3(2, 2, 2), new Vector3(3, 3, 3));
var u = WbBoundingBox.Union(a, b);
Assert.Equal(new Vector3(0, 0, 0), u.Min);
Assert.Equal(new Vector3(3, 3, 3), u.Max);
}
[Fact]
public void Intersects_BoxBehindCamera_ReturnsFalse()
{
var f = new WbFrustum();
f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10));
Assert.False(f.Intersects(box));
}
[Fact]
public void TestBox_IgnoreNearPlane_DoesNotReturnOutsideForNearOverlap()
{
// Box straddles the near plane (z from -0.05 to 5) — with near plane ignored,
// the box should not be culled as Outside.
var f = new WbFrustum();
f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
var box = new WbBoundingBox(new Vector3(-0.5f, -0.5f, -5f), new Vector3(0.5f, 0.5f, -0.05f));
var withNear = f.TestBox(box, ignoreNearPlane: false);
var withoutNear = f.TestBox(box, ignoreNearPlane: true);
// Both should be non-Outside for a box clearly in front
Assert.NotEqual(FrustumTestResult.Outside, withoutNear);
_ = withNear; // result varies by near clip; just verify no exception
}
[Fact]
public void WbBoundingBox_Union_WithOverlapping_CoversAll()
{
var a = new WbBoundingBox(new Vector3(-1, -1, -1), new Vector3(2, 2, 2));
var b = new WbBoundingBox(new Vector3(0, 0, 0), new Vector3(3, 3, 3));
var u = WbBoundingBox.Union(a, b);
Assert.Equal(new Vector3(-1, -1, -1), u.Min);
Assert.Equal(new Vector3(3, 3, 3), u.Max);
}
}