diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs index 98ff91e..71d65d9 100644 --- a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs +++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs @@ -464,6 +464,73 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable return idx; } + /// + /// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB + /// PortalRenderManager.RenderBuildingStencilMask at + /// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484. + /// + /// Uploads the building's exit-portal mesh to our shared VBO and draws + /// it with the portal_stencil shader. Does NOT set or restore any + /// surrounding GL state — caller is responsible (stencil func, + /// depth mask, color mask, cull face, etc.) per WB + /// VisibilityManager.RenderInsideOut Steps 1/2/5a/5b/5d + /// expectations. + /// + /// Note: enables/disables GL_DEPTH_CLAMP around the draw + /// because portal polygons can extend beyond the camera's near/far range. + /// This is symmetric — no state leakage. + /// + /// The building whose exit portal polygons to draw. + /// Camera view-projection matrix. + /// When true, the fragment shader writes + /// gl_FragDepth = 1.0 (WB Step 2 / Step 5b "punch" semantic). + /// When false, default depth is written (WB Step 1 / Step 5a "mark" + /// semantic). + public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth) + { + int vertexCount = UploadBuildingPortalMesh(building); + if (vertexCount == 0) + { + LastStencilVertexCount = 0; + LastStencilWasFarPunch = writeFarDepth; + LastStencilBuildingId = building.BuildingId; + return; + } + + _gl.Enable(EnableCap.DepthClamp); + + _shader.Use(); + var vp = viewProjection; + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); + _gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0); + + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount); + _gl.BindVertexArray(0); + + _gl.Disable(EnableCap.DepthClamp); + + LastStencilVertexCount = vertexCount; + LastStencilWasFarPunch = writeFarDepth; + LastStencilBuildingId = building.BuildingId; + } + + // ------------------------------------------------------------------------- + // Probe data (Phase A8 — read by the [stencil] probe emitter in GameWindow). + // ------------------------------------------------------------------------- + + /// Phase A8 RR9: vertex count of the most recent + /// draw. 0 if the building had no portals. + public int LastStencilVertexCount { get; private set; } + + /// Phase A8 RR9: true iff the most recent + /// draw was a far-depth punch (Step 2). + public bool LastStencilWasFarPunch { get; private set; } + + /// Phase A8 RR9: building id of the most recent + /// draw. + public uint LastStencilBuildingId { get; private set; } + public void Dispose() { _shader.Dispose(); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs new file mode 100644 index 0000000..4374963 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs @@ -0,0 +1,146 @@ +// Ported from references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs +// Phase A8 extraction (2026-05-28). Verbatim port; adaptations: +// - SceneryInstance -> EnvCellSceneryInstance (scope-narrow to env-cell rendering) +// - ObjectLandblock -> EnvCellLandblock +// - Namespace: AcDream.App.Rendering.Wb +// - BoundingBox -> WbBoundingBox (defined in WbFrustum.cs) +// - DisqualificationReason + SceneryDisqualificationReason dropped (editor-only) +// - IsQueuedForUpload, IsTransformOnlyUpdate, ParticleEmitters, InstanceBufferOffset, +// InstanceCount, MdiCommands dropped (editor-only / shared-MDI) + +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Lightweight data for a single placed env-cell scenery object. +/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs (lines 11-56) +/// +public struct EnvCellSceneryInstance +{ + /// GfxObj or Setup ID from DAT. + public ulong ObjectId; + + /// Unique instance index within the landblock (WB used a 128-bit editor ID; we use uint). + public uint InstanceId; + + /// True for multi-part Setup objects, false for simple GfxObj. + public bool IsSetup; + + /// True if this instance is a building. + public bool IsBuilding; + + /// True if this is an interior cell connected directly to the landblock. + public bool IsEntryCell; + + /// World-space position. + public Vector3 WorldPosition; + + /// Local-space position (relative to landblock origin). + public Vector3 LocalPosition; + + /// Rotation quaternion. + public Quaternion Rotation; + + /// The current cell ID this instance is in (used for previewing moves between cells). + public uint CurrentPreviewCellId; + + /// Scale (typically uniform). + public Vector3 Scale; + + /// Pre-computed world transform matrix. + public Matrix4x4 Transform; + + /// Local-space bounding box. + public WbBoundingBox LocalBoundingBox; + + /// World-space bounding box. + public WbBoundingBox BoundingBox; + + /// Rendering flags for this instance. + public uint Flags; +} + +/// +/// Holds all EnvCell instances for a single landblock, ready for rendering. +/// Shared by both scenery and static object render managers. +/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs (lines 62-160) +/// +public class EnvCellLandblock +{ + /// Grid X coordinate of this landblock. + public int GridX { get; set; } + + /// Grid Y coordinate of this landblock. + public int GridY { get; set; } + + public object Lock { get; } = new(); + + public List Instances { get; set; } = new(); + + /// + /// Grouped bounding boxes for each EnvCell in this landblock. + /// Key: CellID, Value: Composite bounding box of the cell and all its static objects. + /// + public Dictionary EnvCellBounds { get; set; } = new(); + + /// + /// Set of EnvCell IDs in this landblock that have the SeenOutside flag. + /// + public HashSet SeenOutsideCells { get; set; } = new(); + + public List? PendingInstances { get; set; } + + /// + /// Grouped bounding boxes for each EnvCell in this landblock (pending upload). + /// + public Dictionary? PendingEnvCellBounds { get; set; } + + /// + /// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload). + /// + public HashSet? PendingSeenOutsideCells { get; set; } + + /// + /// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering. + /// Key: GfxObjId, Value: List of transforms + /// + public Dictionary> StaticPartGroups { get; set; } = new(); + + /// + /// Grouped transforms for each GfxObj part for buildings, for efficient instanced rendering. + /// Key: GfxObjId, Value: List of transforms + /// + public Dictionary> BuildingPartGroups { get; set; } = new(); + + /// + /// World-space bounding box of this landblock. + /// + public WbBoundingBox BoundingBox { get; set; } + + /// + /// Total bounding box covering all EnvCells in this landblock. + /// + public WbBoundingBox TotalEnvCellBounds { get; set; } + + /// + /// Total bounding box covering all EnvCells in this landblock (pending upload). + /// + public WbBoundingBox PendingTotalEnvCellBounds { get; set; } + + /// + /// Whether instances (positions/bounding boxes) have been generated. + /// + public bool InstancesReady { get; set; } + + /// + /// Whether mesh data for all instances has been prepared (CPU-side). + /// + public bool MeshDataReady { get; set; } + + /// + /// Whether GPU resources have been uploaded. + /// + public bool GpuReady { get; set; } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs new file mode 100644 index 0000000..f9dfc69 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-28): EnvCell-scoped visibility snapshot. Direct port of +/// WB's VisibilitySnapshot at +/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36, +/// narrowed to the fields actually consumes +/// (BatchedByCell + VisibleLandblocks). The scenery-side +/// VisibleGroups / VisibleGfxObjIds / IntersectingLandblocks +/// / PostPreparePoolIndex are dropped — we render scenery through +/// , not through this snapshot. +/// +/// Used as an immutable snapshot atomically swapped under the +/// renderer's render lock so PrepareRenderBatches (worker-driven) and +/// Render (render-thread-driven) can't race on a half-populated dict. +/// +public sealed class EnvCellVisibilitySnapshot +{ + /// Landblocks fully or partially inside the frustum at prepare time. + public List VisibleLandblocks { get; init; } = new(); + + /// + /// Grouped instance data by full 32-bit cell id. + /// Outer key: CellId. Inner key: GfxObjId (ulong; bit 33 set for + /// deduplicated cell geometry per ). + /// Value: list of per-instance transforms (one per cell or per static object + /// inside that cell). + /// + public Dictionary>> BatchedByCell { get; init; } = new(); + + /// True when no visible cells were produced this prepare cycle. + public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0; +} diff --git a/src/AcDream.App/Rendering/Wb/WbFrustum.cs b/src/AcDream.App/Rendering/Wb/WbFrustum.cs new file mode 100644 index 0000000..96a254e --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbFrustum.cs @@ -0,0 +1,143 @@ +// Ported from references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs +// Phase A8 extraction (2026-05-28). Verbatim algorithm; adaptations: +// - Namespace: AcDream.App.Rendering.Wb +// - Class renamed Frustum -> WbFrustum +// - BoundingBox -> WbBoundingBox (inlined below; no new project dep) +// - FrustumTestResult kept as-is (inlined below) +// - WbPlane inlined (was an inner type of Frustum.cs in WB) + +using System.Numerics; + +namespace AcDream.App.Rendering.Wb; + +public struct WbBoundingBox +{ + public Vector3 Min; + public Vector3 Max; + + public WbBoundingBox(Vector3 min, Vector3 max) + { + Min = min; + Max = max; + } + + public static WbBoundingBox Union(WbBoundingBox a, WbBoundingBox b) => + new WbBoundingBox( + Vector3.Min(a.Min, b.Min), + Vector3.Max(a.Max, b.Max)); +} + +public enum FrustumTestResult +{ + Outside, + Inside, + Intersecting +} + +/// +/// View-frustum helper extracted from WorldBuilder's Frustum class. +/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs +/// Phase A8 extraction (2026-05-28). +/// +public sealed class WbFrustum +{ + private struct Plane + { + public Vector3 Normal; + public float D; + + public Plane(float a, float b, float c, float d) + { + Normal = new Vector3(a, b, c); + float length = Normal.Length(); + Normal /= length; + D = d / length; + } + + public float Dot(Vector3 point) => Vector3.Dot(Normal, point) + D; + } + + private readonly Plane[] _planes = new Plane[6]; + private readonly object _lock = new(); + + public void Update(Matrix4x4 matrix) + { + lock (_lock) + { + // Left plane + _planes[0] = new Plane(matrix.M14 + matrix.M11, matrix.M24 + matrix.M21, matrix.M34 + matrix.M31, matrix.M44 + matrix.M41); + // Right plane + _planes[1] = new Plane(matrix.M14 - matrix.M11, matrix.M24 - matrix.M21, matrix.M34 - matrix.M31, matrix.M44 - matrix.M41); + // Bottom plane + _planes[2] = new Plane(matrix.M14 + matrix.M12, matrix.M24 + matrix.M22, matrix.M34 + matrix.M32, matrix.M44 + matrix.M42); + // Top plane + _planes[3] = new Plane(matrix.M14 - matrix.M12, matrix.M24 - matrix.M22, matrix.M34 - matrix.M32, matrix.M44 - matrix.M42); + // Near plane + _planes[4] = new Plane(matrix.M14 + matrix.M13, matrix.M24 + matrix.M23, matrix.M34 + matrix.M33, matrix.M44 + matrix.M43); + // Far plane + _planes[5] = new Plane(matrix.M14 - matrix.M13, matrix.M24 - matrix.M23, matrix.M34 - matrix.M33, matrix.M44 - matrix.M43); + } + } + + public bool Intersects(WbBoundingBox box, bool ignoreNearPlane = false) + { + lock (_lock) + { + for (int i = 0; i < 6; i++) + { + if (ignoreNearPlane && i == 4) continue; + + Vector3 positive = box.Min; + if (_planes[i].Normal.X >= 0) positive.X = box.Max.X; + if (_planes[i].Normal.Y >= 0) positive.Y = box.Max.Y; + if (_planes[i].Normal.Z >= 0) positive.Z = box.Max.Z; + + if (_planes[i].Dot(positive) < 0) + { + return false; + } + } + } + return true; + } + + public FrustumTestResult TestBox(WbBoundingBox box, bool ignoreNearPlane = false) + { + var result = FrustumTestResult.Inside; + lock (_lock) + { + for (int i = 0; i < 6; i++) + { + if (ignoreNearPlane && i == 4) continue; + + Vector3 positive = box.Min; + Vector3 negative = box.Max; + if (_planes[i].Normal.X >= 0) + { + positive.X = box.Max.X; + negative.X = box.Min.X; + } + if (_planes[i].Normal.Y >= 0) + { + positive.Y = box.Max.Y; + negative.Y = box.Min.Y; + } + if (_planes[i].Normal.Z >= 0) + { + positive.Z = box.Max.Z; + negative.Z = box.Min.Z; + } + + if (_planes[i].Dot(positive) < 0) + { + return FrustumTestResult.Outside; + } + if (_planes[i].Dot(negative) < 0) + { + result = FrustumTestResult.Intersecting; + } + } + } + return result; + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbRenderPass.cs b/src/AcDream.App/Rendering/Wb/WbRenderPass.cs new file mode 100644 index 0000000..746050f --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbRenderPass.cs @@ -0,0 +1,31 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-28): WB's RenderPass enum, extracted verbatim from +/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs:1-22. +/// +/// Renamed to WbRenderPass to keep the WB-faithful name distinct +/// from any future acdream-side RenderPass with different semantics. +/// Consumed by and matches the +/// uRenderPass uniform in the modern mesh shaders. +/// +public enum WbRenderPass +{ + /// + /// The opaque pass. Only non-transparent objects are rendered. + /// + Opaque = 0, + + /// + /// The transparent pass. Only transparent objects are rendered, + /// usually after the opaque pass. + /// + Transparent = 1, + + /// + /// A single-pass render that includes both opaque and (sometimes) + /// transparent objects, or for special cases like skyboxes and + /// certain UI elements. + /// + SinglePass = 2, +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs new file mode 100644 index 0000000..c29f299 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs @@ -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(); + 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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs new file mode 100644 index 0000000..b775c5d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs @@ -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); + } +}