1225 lines
53 KiB
Markdown
1225 lines
53 KiB
Markdown
# Phase A8.F — Retail portal-frame visibility port — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Fix the A8 "cellar flap" (outdoor terrain leaking through ground-floor windows when the camera is in a cottage cellar) by porting retail's recursive portal-clip visibility and feeding its recursively-clipped output into the existing A8 stencil pipeline.
|
||
|
||
**Architecture:** A GL-free CPU builder walks the portal graph from the camera cell (BFS), projecting each portal opening to screen space and intersecting it with the inherited clip region; exit-portal regions union into an `OutsideView` (where outdoors may draw, clipped to the portal chain). Enforcement maps onto the existing A8 stencil pipeline (mark + far-depth punch), now fed the recursively-clipped polygons instead of a flat union of all exit portals. Three wire-ins land together (terrain region, per-cell geometry, cross-building) plus the Job-A/Job-B decoupling, behind one visual gate.
|
||
|
||
**Tech Stack:** C# .NET 10, System.Numerics (CPU layer, GL-free, unit-tested), Silk.NET.OpenGL (enforcement). Spec: [docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md](../specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||
|
||
**Fidelity note:** We match retail's *behavior and structure* (the OutsideView regions), not its exact float output. Conformance asserts clipping *relationships* (a deep cell's exit region ⊆ the intersection of its portal-chain openings), per the spec. A correct convex-polygon intersection is therefore faithful; we are not bit-matching retail's `polyClipFinish`.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
**New (pure CPU, GL-free — `src/AcDream.App/Rendering/`, beside `CellVisibility`):**
|
||
- `PortalView.cs` — `ViewPolygon` (2D NDC convex polygon + bbox) and `CellView` (set of view polygons + union bbox). Data model; mirrors retail `view_poly`/`view_type`.
|
||
- `ScreenPolygonClip.cs` — `Intersect(subject, clip)`: 2D convex-polygon intersection (Sutherland–Hodgman). Port of retail `ACRender::polyClipFinish` behavior.
|
||
- `PortalProjection.cs` — `ProjectToNdc(localPoly, cellToWorld, viewProj)`: project a portal polygon to NDC with in-front-of-camera (homogeneous-w) clipping to prevent inversion.
|
||
- `PortalVisibilityBuilder.cs` — `Build(cameraCell, lookup, viewProj)`: the BFS; produces `PortalVisibilityFrame { OutsideView, CellViews, CrossBuildingViews }`. Port of retail `PView::ConstructView`/`ClipPortals`/`AddViewToPortals`.
|
||
|
||
**Modified:**
|
||
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — add `MarkAndPunchNdc(region)` (NDC-polygon stencil mark + far-depth punch). The flat `PortalMeshBuilder.BuildTriangles` path is superseded.
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — rewrite `RenderInsideOutAcdream` (~11012) to drive from the builder, restore WB's unconditional-exterior structure, and apply the three wire-ins. Remove `ACDREAM_A8_DIAG_*` (Task 0).
|
||
- `src/AcDream.App/RuntimeOptions.cs` — remove `A8Diag*` properties (Task 0).
|
||
- `docs/plans/2026-04-11-roadmap.md` — A8.F shipped row (Task 9).
|
||
|
||
**New tests (`tests/AcDream.App.Tests/Rendering/`):**
|
||
- `PortalViewTests.cs`, `ScreenPolygonClipTests.cs`, `PortalProjectionTests.cs`, `PortalVisibilityBuilderTests.cs`.
|
||
|
||
---
|
||
|
||
## Task 0: Strip leftover A8 diag flags (baseline cleanup)
|
||
|
||
The A8 batch is already committed (`5dc4140`). This removes the temporary step-disable flags it left behind. **Keep** the `ACDREAM_PROBE_VIS` apparatus.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/RuntimeOptions.cs` (the `A8Diag*` properties, ~18 occurrences)
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`diagDisableStep*` locals + their use sites in `RenderInsideOutAcdream`, ~6 occurrences)
|
||
|
||
- [ ] **Step 1: Find every flag reference**
|
||
|
||
Run: `rg "A8Diag|ACDREAM_A8_DIAG" src` (expect hits in `RuntimeOptions.cs` and `GameWindow.cs` only)
|
||
|
||
- [ ] **Step 2: Remove the `A8Diag*` properties from `RuntimeOptions.cs`**
|
||
|
||
Delete each `public bool A8DiagDisable... { get; }` property and its `Environment.GetEnvironmentVariable("ACDREAM_A8_DIAG_...")` initializer. Leave all non-`A8Diag` options untouched.
|
||
|
||
- [ ] **Step 3: Remove the `diagDisable*` locals + guards in `RenderInsideOutAcdream`**
|
||
|
||
In `GameWindow.cs`, delete the `bool diagDisableStep... = _options.A8Diag...;` locals (lines ~11029-11034) and unwrap the `if (!diagDisableStepX...)` guards so the guarded draw always runs (the draw bodies stay; only the `if (!diag...)` wrapper and its `else` diagnostic branch are removed). For the Step-4 terrain `FrontFace` block, keep the `gl.FrontFace(Ccw)` / draw / `gl.FrontFace(CW)` sequence; drop only the `diagDisableStep4Terrain` branch.
|
||
|
||
- [ ] **Step 4: Build + test green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded, 0 errors.
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||
Expected: PASS (App baseline ~90).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "chore(render): Phase A8.F — strip ACDREAM_A8_DIAG_* step-disable flags (keep PROBE_VIS)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: `ViewPolygon` + `CellView` data model
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/PortalView.cs`
|
||
- Test: `tests/AcDream.App.Tests/Rendering/PortalViewTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
// tests/AcDream.App.Tests/Rendering/PortalViewTests.cs
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.Rendering;
|
||
|
||
public class PortalViewTests
|
||
{
|
||
[Fact]
|
||
public void ViewPolygon_ComputesBoundingRect()
|
||
{
|
||
var p = new ViewPolygon(new[]
|
||
{
|
||
new Vector2(-0.5f, -0.25f), new Vector2(0.5f, -0.25f), new Vector2(0.0f, 0.75f),
|
||
});
|
||
Assert.Equal(-0.5f, p.MinX, 5);
|
||
Assert.Equal(0.5f, p.MaxX, 5);
|
||
Assert.Equal(-0.25f, p.MinY, 5);
|
||
Assert.Equal(0.75f, p.MaxY, 5);
|
||
Assert.False(p.IsEmpty);
|
||
}
|
||
|
||
[Fact]
|
||
public void ViewPolygon_FewerThanThreeVerts_IsEmpty()
|
||
{
|
||
Assert.True(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(1, 0) }).IsEmpty);
|
||
Assert.True(new ViewPolygon(System.Array.Empty<Vector2>()).IsEmpty);
|
||
}
|
||
|
||
[Fact]
|
||
public void CellView_FullScreen_CoversNdc()
|
||
{
|
||
var v = CellView.FullScreen();
|
||
Assert.False(v.IsEmpty);
|
||
Assert.Equal(-1f, v.MinX, 5);
|
||
Assert.Equal(1f, v.MaxX, 5);
|
||
Assert.Equal(-1f, v.MinY, 5);
|
||
Assert.Equal(1f, v.MaxY, 5);
|
||
}
|
||
|
||
[Fact]
|
||
public void CellView_Add_GrowsUnionBoundsAndIsEmptyTracks()
|
||
{
|
||
var v = new CellView();
|
||
Assert.True(v.IsEmpty);
|
||
v.Add(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(0.2f, 0), new Vector2(0, 0.2f) }));
|
||
v.Add(new ViewPolygon(new[] { new Vector2(-0.3f, -0.3f), new Vector2(-0.1f, -0.3f), new Vector2(-0.1f, -0.1f) }));
|
||
Assert.False(v.IsEmpty);
|
||
Assert.Equal(-0.3f, v.MinX, 5);
|
||
Assert.Equal(0.2f, v.MaxX, 5);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalViewTests"`
|
||
Expected: FAIL (compile error — `ViewPolygon`/`CellView` not defined).
|
||
|
||
- [ ] **Step 3: Implement `PortalView.cs`**
|
||
|
||
```csharp
|
||
// src/AcDream.App/Rendering/PortalView.cs
|
||
//
|
||
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
|
||
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
|
||
// a cell's clip region is a SET of convex polygons in normalized device coords.
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
|
||
public readonly struct ViewPolygon
|
||
{
|
||
public readonly Vector2[] Vertices;
|
||
public readonly float MinX, MinY, MaxX, MaxY;
|
||
|
||
public ViewPolygon(Vector2[] vertices)
|
||
{
|
||
Vertices = vertices;
|
||
if (vertices is null || vertices.Length < 3)
|
||
{
|
||
MinX = MinY = MaxX = MaxY = 0f;
|
||
return;
|
||
}
|
||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||
foreach (var v in vertices)
|
||
{
|
||
if (v.X < minX) minX = v.X;
|
||
if (v.X > maxX) maxX = v.X;
|
||
if (v.Y < minY) minY = v.Y;
|
||
if (v.Y > maxY) maxY = v.Y;
|
||
}
|
||
MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY;
|
||
}
|
||
|
||
public bool IsEmpty => Vertices is null || Vertices.Length < 3;
|
||
}
|
||
|
||
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
|
||
public sealed class CellView
|
||
{
|
||
public readonly List<ViewPolygon> Polygons = new();
|
||
public float MinX { get; private set; } = float.MaxValue;
|
||
public float MinY { get; private set; } = float.MaxValue;
|
||
public float MaxX { get; private set; } = float.MinValue;
|
||
public float MaxY { get; private set; } = float.MinValue;
|
||
|
||
public bool IsEmpty => Polygons.Count == 0;
|
||
|
||
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
|
||
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
|
||
public static CellView FullScreen()
|
||
{
|
||
var v = new CellView();
|
||
v.Add(new ViewPolygon(new[]
|
||
{
|
||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||
}));
|
||
return v;
|
||
}
|
||
|
||
public void Add(ViewPolygon p)
|
||
{
|
||
if (p.IsEmpty) return;
|
||
Polygons.Add(p);
|
||
if (p.MinX < MinX) MinX = p.MinX;
|
||
if (p.MinY < MinY) MinY = p.MinY;
|
||
if (p.MaxX > MaxX) MaxX = p.MaxX;
|
||
if (p.MaxY > MaxY) MaxY = p.MaxY;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalViewTests"`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/PortalView.cs tests/AcDream.App.Tests/Rendering/PortalViewTests.cs
|
||
git commit -m "feat(render): Phase A8.F — ViewPolygon + CellView clip-region data model"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `ScreenPolygonClip` — 2D convex-polygon intersection
|
||
|
||
Sutherland–Hodgman: clip the subject convex polygon against each edge of the (convex) clip polygon, treating each edge as a half-plane (keep the left side for CCW winding). Port of retail `ACRender::polyClipFinish` behavior.
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/ScreenPolygonClip.cs`
|
||
- Test: `tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
// tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.Rendering;
|
||
|
||
public class ScreenPolygonClipTests
|
||
{
|
||
// CCW unit square [-1,1]^2.
|
||
private static Vector2[] Square(float min, float max) => new[]
|
||
{
|
||
new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max),
|
||
};
|
||
|
||
private static float Area(Vector2[] poly)
|
||
{
|
||
if (poly.Length < 3) return 0f;
|
||
float a = 0f;
|
||
for (int i = 0; i < poly.Length; i++)
|
||
{
|
||
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
|
||
a += p.X * q.Y - q.X * p.Y;
|
||
}
|
||
return System.MathF.Abs(a) * 0.5f;
|
||
}
|
||
|
||
[Fact]
|
||
public void Intersect_FullyContained_ReturnsSubject()
|
||
{
|
||
var outer = Square(-1f, 1f);
|
||
var inner = Square(-0.5f, 0.5f);
|
||
var r = ScreenPolygonClip.Intersect(inner, outer);
|
||
Assert.Equal(1.0f, Area(r), 3); // inner area = 1x1
|
||
}
|
||
|
||
[Fact]
|
||
public void Intersect_PartialOverlap_ReturnsOverlapArea()
|
||
{
|
||
var a = Square(0f, 2f);
|
||
var b = Square(1f, 3f);
|
||
var r = ScreenPolygonClip.Intersect(a, b);
|
||
Assert.Equal(1.0f, Area(r), 3); // overlap [1,2]^2 = 1
|
||
}
|
||
|
||
[Fact]
|
||
public void Intersect_Disjoint_ReturnsEmpty()
|
||
{
|
||
var a = Square(0f, 1f);
|
||
var b = Square(5f, 6f);
|
||
var r = ScreenPolygonClip.Intersect(a, b);
|
||
Assert.True(r.Length < 3); // empty
|
||
}
|
||
|
||
[Fact]
|
||
public void Intersect_Sliver_PreservesNarrowOverlap()
|
||
{
|
||
// A tall thin clip (the "stairwell") intersected with a wide subject (the "window").
|
||
var window = Square(-1f, 1f);
|
||
var stairwell = new[]
|
||
{
|
||
new Vector2(-0.1f, -1f), new Vector2(0.1f, -1f), new Vector2(0.1f, 1f), new Vector2(-0.1f, 1f),
|
||
};
|
||
var r = ScreenPolygonClip.Intersect(window, stairwell);
|
||
Assert.Equal(0.4f, Area(r), 3); // 0.2 wide x 2 tall
|
||
Assert.True(System.MathF.Abs(r[0].X) <= 0.1001f); // clipped to stairwell width
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ScreenPolygonClipTests"`
|
||
Expected: FAIL (compile error — `ScreenPolygonClip` not defined).
|
||
|
||
- [ ] **Step 3: Implement `ScreenPolygonClip.cs`**
|
||
|
||
```csharp
|
||
// src/AcDream.App/Rendering/ScreenPolygonClip.cs
|
||
//
|
||
// Phase A8.F: 2D convex-polygon intersection (Sutherland-Hodgman).
|
||
// Ports the BEHAVIOR of retail ACRender::polyClipFinish (the screen-space
|
||
// portal clipper invoked by PView::GetClip, decomp:432344). Both inputs are
|
||
// convex (a cell's accumulated view polygon and a projected portal polygon),
|
||
// so the result is convex. We match retail's clipping relationship, not its
|
||
// exact float output (see plan fidelity note).
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
public static class ScreenPolygonClip
|
||
{
|
||
private const float Eps = 1e-7f;
|
||
|
||
/// <summary>Intersect two convex polygons given CCW. Returns the clipped
|
||
/// vertices (CCW) or an array with <3 verts when the intersection is empty.</summary>
|
||
public static Vector2[] Intersect(IReadOnlyList<Vector2> subject, IReadOnlyList<Vector2> clip)
|
||
{
|
||
if (subject == null || clip == null || subject.Count < 3 || clip.Count < 3)
|
||
return System.Array.Empty<Vector2>();
|
||
|
||
var output = new List<Vector2>(subject);
|
||
|
||
for (int i = 0; i < clip.Count; i++)
|
||
{
|
||
if (output.Count < 3) return System.Array.Empty<Vector2>();
|
||
|
||
Vector2 a = clip[i];
|
||
Vector2 b = clip[(i + 1) % clip.Count];
|
||
output = ClipByEdge(output, a, b);
|
||
}
|
||
|
||
return output.Count >= 3 ? output.ToArray() : System.Array.Empty<Vector2>();
|
||
}
|
||
|
||
// Keep the part of `poly` on the left of directed edge a->b (CCW inside).
|
||
private static List<Vector2> ClipByEdge(List<Vector2> poly, Vector2 a, Vector2 b)
|
||
{
|
||
var result = new List<Vector2>(poly.Count + 1);
|
||
Vector2 edge = b - a;
|
||
|
||
for (int i = 0; i < poly.Count; i++)
|
||
{
|
||
Vector2 cur = poly[i];
|
||
Vector2 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||
|
||
float curSide = Cross(edge, cur - a); // > 0 = left (inside)
|
||
float prevSide = Cross(edge, prev - a);
|
||
|
||
bool curIn = curSide >= -Eps;
|
||
bool prevIn = prevSide >= -Eps;
|
||
|
||
if (curIn)
|
||
{
|
||
if (!prevIn)
|
||
result.Add(Intersection(prev, cur, prevSide, curSide));
|
||
result.Add(cur);
|
||
}
|
||
else if (prevIn)
|
||
{
|
||
result.Add(Intersection(prev, cur, prevSide, curSide));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private static float Cross(Vector2 u, Vector2 v) => u.X * v.Y - u.Y * v.X;
|
||
|
||
private static Vector2 Intersection(Vector2 p, Vector2 q, float dp, float dq)
|
||
{
|
||
float t = dp / (dp - dq); // dp, dq are signed distances (cross products); opposite signs here
|
||
return p + t * (q - p);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ScreenPolygonClipTests"`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/ScreenPolygonClip.cs tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs
|
||
git commit -m "feat(render): Phase A8.F — ScreenPolygonClip 2D convex-polygon intersection"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `PortalProjection` — project portal polygon to NDC with near-clip
|
||
|
||
> **Correction (2026-05-29, during execution):** the `w >= WEps` clip in the Step-3 code
|
||
> below was a bug — it leaves a clipped vertex at the eye singularity (w≈1e-4) so `x/w` blows
|
||
> up (~±7852), relocating the inversion instead of preventing it. The shipped code clips
|
||
> against the in-front-of-camera half-space `w + z >= 0` (commit `a28a176`; comments corrected
|
||
> in `9ec8330`). That predicate is convention-agnostic: acdream's cameras use
|
||
> `Matrix4x4.CreatePerspectiveFieldOfView` (NDC z ∈ **[0,1]**, not GL [-1,1]); the eye (w=0)
|
||
> is always excluded so the divide is safe — and **Task 6 needs no projection-convention fix**
|
||
> (verified: no `glClipControl`/`glDepthRange` remap anywhere in the codebase). The straddle
|
||
> test bound was relaxed `[-10,10]`→`[-50,50]` and a 4th downstream-intersection test added.
|
||
> **Task 4 requirement:** `ProjectToNdc` preserves input winding (NOT normalized CCW) — the
|
||
> builder MUST apply the portal-side test and feed camera-facing (CCW) portals to the CCW-only
|
||
> `ScreenPolygonClip`, and Task 4's conformance tests should assert a winding/back-face case.
|
||
|
||
Transform a cell-local portal polygon to clip space, clip against the in-front-of-camera plane (`w >= eps`) to prevent inversion when a portal straddles the camera, then perspective-divide to NDC. Ports the projection in retail `PView::GetClip` (decomp:432344); the `w` clip is the homogeneous form of retail's near-plane sidedness math (`PView::ConstructView(CBldPortal)` decomp:433832-433845).
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/PortalProjection.cs`
|
||
- Test: `tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
// tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.Rendering;
|
||
|
||
public class PortalProjectionTests
|
||
{
|
||
// A simple GL-style perspective looking down -Z, camera at origin.
|
||
private static Matrix4x4 ViewProj()
|
||
{
|
||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.0f, 1.0f, 0.1f, 1000f);
|
||
return view * proj;
|
||
}
|
||
|
||
[Fact]
|
||
public void Project_QuadInFront_ProducesNdcInsideViewport()
|
||
{
|
||
// A 2x2 quad at z=-5 (in front), cell-local == world (identity transform).
|
||
var poly = new[]
|
||
{
|
||
new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5),
|
||
};
|
||
var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj());
|
||
Assert.True(r.Length >= 3);
|
||
foreach (var v in r)
|
||
{
|
||
Assert.InRange(v.X, -1.001f, 1.001f);
|
||
Assert.InRange(v.Y, -1.001f, 1.001f);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Project_QuadFullyBehind_ReturnsEmpty()
|
||
{
|
||
var poly = new[]
|
||
{
|
||
new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5),
|
||
};
|
||
var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj());
|
||
Assert.True(r.Length < 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void Project_QuadStraddlingCamera_ClipsWithoutInversion()
|
||
{
|
||
// Spans from behind (z=+2) to in front (z=-5). Must clip to the in-front part,
|
||
// never produce a wildly out-of-range inverted vertex.
|
||
var poly = new[]
|
||
{
|
||
new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2),
|
||
};
|
||
var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj());
|
||
Assert.True(r.Length >= 3);
|
||
foreach (var v in r)
|
||
{
|
||
Assert.InRange(v.X, -10f, 10f); // bounded — no inversion blow-up
|
||
Assert.InRange(v.Y, -10f, 10f);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalProjectionTests"`
|
||
Expected: FAIL (compile error — `PortalProjection` not defined).
|
||
|
||
- [ ] **Step 3: Implement `PortalProjection.cs`**
|
||
|
||
```csharp
|
||
// src/AcDream.App/Rendering/PortalProjection.cs
|
||
//
|
||
// Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping
|
||
// against the in-front-of-camera plane (w >= eps) so a portal straddling the
|
||
// camera does not invert under the perspective divide. Homogeneous form of the
|
||
// near-plane sidedness in retail PView::GetClip / ConstructView(CBldPortal)
|
||
// (decomp:432344 / 433832).
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
public static class PortalProjection
|
||
{
|
||
private const float WEps = 1e-4f;
|
||
|
||
/// <summary>Project a cell-local polygon to NDC. Returns CCW NDC xy verts, or
|
||
/// fewer than 3 verts when the polygon is entirely behind the camera / degenerate.</summary>
|
||
public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
|
||
{
|
||
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
|
||
|
||
Matrix4x4 m = cellToWorld * viewProj;
|
||
|
||
// To clip space (keep w).
|
||
var clip = new List<Vector4>(localPoly.Count);
|
||
foreach (var lp in localPoly)
|
||
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
|
||
|
||
// Clip against w >= WEps (in front of camera).
|
||
clip = ClipAgainstW(clip);
|
||
if (clip.Count < 3) return System.Array.Empty<Vector2>();
|
||
|
||
// Perspective divide → NDC xy.
|
||
var ndc = new Vector2[clip.Count];
|
||
for (int i = 0; i < clip.Count; i++)
|
||
{
|
||
float w = clip[i].W;
|
||
ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w);
|
||
}
|
||
return ndc;
|
||
}
|
||
|
||
// Sutherland-Hodgman against the single plane w = WEps.
|
||
private static List<Vector4> ClipAgainstW(List<Vector4> poly)
|
||
{
|
||
var result = new List<Vector4>(poly.Count + 1);
|
||
for (int i = 0; i < poly.Count; i++)
|
||
{
|
||
Vector4 cur = poly[i];
|
||
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||
float dCur = cur.W - WEps;
|
||
float dPrev = prev.W - WEps;
|
||
bool curIn = dCur >= 0f;
|
||
bool prevIn = dPrev >= 0f;
|
||
|
||
if (curIn)
|
||
{
|
||
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
|
||
result.Add(cur);
|
||
}
|
||
else if (prevIn)
|
||
{
|
||
result.Add(Lerp(prev, cur, dPrev, dCur));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq)
|
||
{
|
||
float t = dp / (dp - dq);
|
||
return p + t * (q - p);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalProjectionTests"`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/PortalProjection.cs tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs
|
||
git commit -m "feat(render): Phase A8.F — PortalProjection (NDC projection + in-front-of-camera clip)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `PortalVisibilityBuilder` — the recursive clip-frame BFS
|
||
|
||
The heart of the port. BFS from the camera cell carrying a `CellView` per reached cell; per portal, project the opening and intersect with the current cell's region; route exit-portal regions to `OutsideView` and interior-portal regions to the neighbor's accumulator. Re-enqueue a neighbor whose region *grew* (retail's `update_count` re-processing — `PView::ClipPortals`/`AddViewToPortals`, decomp:433572/433446), bounded to guarantee termination.
|
||
|
||
**Verify-against-decomp during execution:** the per-portal `seen`/`inflag` gating and the `OtherPortalClip` neighbor-side clip (decomp:433524) — confirm interior portals also clip against the *neighbor's* matching portal polygon. This plan implements the source-cell clip + union accumulation; add the neighbor-side clip if the conformance test (`Builder_Inn_*`) shows over-inclusion.
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`
|
||
- Test: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test (synthetic cellar + multi-cell fixtures)**
|
||
|
||
```csharp
|
||
// tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.Rendering;
|
||
|
||
public class PortalVisibilityBuilderTests
|
||
{
|
||
// Camera at origin looking down -Z.
|
||
private static Matrix4x4 ViewProj()
|
||
{
|
||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||
return view * proj;
|
||
}
|
||
|
||
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
|
||
{
|
||
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
|
||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||
};
|
||
|
||
// Builds: camera cell (0x0001) with ONE interior portal (narrow, "stairwell") to
|
||
// cell 0x0002; cell 0x0002 has ONE exit portal (wide, "window"). The window must
|
||
// appear in OutsideView clipped to the stairwell width.
|
||
private static (LoadedCell cam, Dictionary<uint, LoadedCell> all) BuildCellarFixture()
|
||
{
|
||
var cam = new LoadedCell
|
||
{
|
||
CellId = 0x0001,
|
||
WorldTransform = Matrix4x4.Identity,
|
||
InverseWorldTransform = Matrix4x4.Identity,
|
||
Portals = { new CellPortalInfo(0x0002, 0, 0) },
|
||
PortalPolygons = { Quad(0f, 0f, 0.1f, 1.0f, -3f) }, // narrow stairwell at z=-3
|
||
};
|
||
var ground = new LoadedCell
|
||
{
|
||
CellId = 0x0002,
|
||
WorldTransform = Matrix4x4.Identity,
|
||
InverseWorldTransform = Matrix4x4.Identity,
|
||
Portals = { new CellPortalInfo(0xFFFF, 0, 0) },
|
||
PortalPolygons = { Quad(0f, 0f, 1.0f, 1.0f, -6f) }, // wide window at z=-6
|
||
};
|
||
return (cam, new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground });
|
||
}
|
||
|
||
[Fact]
|
||
public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow()
|
||
{
|
||
var (cam, all) = BuildCellarFixture();
|
||
var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||
|
||
// OutsideView must be non-empty (we CAN see a sliver of daylight up the stairwell)...
|
||
Assert.False(frame.OutsideView.IsEmpty);
|
||
// ...but its width must be bounded by the stairwell, NOT the full window.
|
||
// Stairwell half-width 0.1 at z=-3 projects much narrower than window half-width 1.0 at z=-6.
|
||
float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX;
|
||
float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth(
|
||
new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj());
|
||
Assert.True(outsideWidth < windowOnlyWidth * 0.5f,
|
||
$"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}");
|
||
}
|
||
|
||
[Fact]
|
||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||
{
|
||
// Camera cell with a single interior portal to a cell that has NO exit portal.
|
||
var cam = new LoadedCell
|
||
{
|
||
CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||
Portals = { new CellPortalInfo(0x0002, 0, 0) }, PortalPolygons = { Quad(0, 0, 0.1f, 1f, -3f) },
|
||
};
|
||
var inner = new LoadedCell
|
||
{
|
||
CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||
};
|
||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||
var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||
Assert.True(frame.OutsideView.IsEmpty);
|
||
}
|
||
|
||
[Fact]
|
||
public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow()
|
||
{
|
||
// Standing in a ground-floor room: camera cell has its OWN window (exit). Daylight unclipped.
|
||
var cam = new LoadedCell
|
||
{
|
||
CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||
Portals = { new CellPortalInfo(0xFFFF, 0, 0) }, PortalPolygons = { Quad(0, 0, 1f, 1f, -6f) },
|
||
};
|
||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||
var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||
Assert.False(frame.OutsideView.IsEmpty);
|
||
float w = frame.OutsideView.MaxX - frame.OutsideView.MinX;
|
||
Assert.True(w > 0.3f, $"direct window should give a wide OutsideView, got {w}");
|
||
}
|
||
}
|
||
|
||
internal static class PortalFrameTestHelper
|
||
{
|
||
public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp)
|
||
{
|
||
var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp);
|
||
var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp);
|
||
return System.MathF.Abs(a.X / a.W - b.X / b.W);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilderTests"`
|
||
Expected: FAIL (compile error — `PortalVisibilityBuilder` / `PortalVisibilityFrame` not defined).
|
||
|
||
- [ ] **Step 3: Implement `PortalVisibilityBuilder.cs`**
|
||
|
||
```csharp
|
||
// src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
|
||
//
|
||
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
|
||
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
|
||
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
|
||
// screen-space CellView; exit portals union their clipped region into OutsideView.
|
||
// GL-free; unit-tested without a GPU context.
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>Per-frame output of the portal-frame BFS.</summary>
|
||
public sealed class PortalVisibilityFrame
|
||
{
|
||
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
||
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
||
public CellView OutsideView { get; } = new();
|
||
|
||
/// <summary>Per-cell accumulated clip region, keyed by full cell id. Used for
|
||
/// per-cell geometry clipping (wire-in #2).</summary>
|
||
public Dictionary<uint, CellView> CellViews { get; } = new();
|
||
|
||
/// <summary>Entry clip regions for other buildings reached through our portals,
|
||
/// keyed by the neighbour cell id whose OtherCellId left our building's cell set
|
||
/// (wire-in #3 / Step 5). Empty in the common single-building case.</summary>
|
||
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
||
}
|
||
|
||
public static class PortalVisibilityBuilder
|
||
{
|
||
// Bound on neighbour re-processing (retail uses update_count timestamps). Portal
|
||
// graphs are small; this guards against cycles while allowing multi-portal unions.
|
||
private const int MaxReprocessPerCell = 4;
|
||
|
||
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
||
/// <param name="buildingMembership">Optional: returns true if a cell id is in the camera
|
||
/// building's cell set. When provided, a neighbour OUTSIDE the set routes to
|
||
/// CrossBuildingViews instead of continuing the in-building BFS. Pass null to treat all
|
||
/// reachable cells as in-building.</param>
|
||
public static PortalVisibilityFrame Build(
|
||
LoadedCell cameraCell,
|
||
Func<uint, LoadedCell?> lookup,
|
||
Matrix4x4 viewProj,
|
||
Func<uint, bool>? buildingMembership = null)
|
||
{
|
||
var frame = new PortalVisibilityFrame();
|
||
if (cameraCell == null) return frame;
|
||
|
||
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
|
||
|
||
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
|
||
var processCount = new Dictionary<uint, int>();
|
||
var queue = new Queue<LoadedCell>();
|
||
queue.Enqueue(cameraCell);
|
||
|
||
while (queue.Count > 0)
|
||
{
|
||
var cell = queue.Dequeue();
|
||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||
continue;
|
||
|
||
processCount.TryGetValue(cell.CellId, out int pc);
|
||
if (pc >= MaxReprocessPerCell) continue;
|
||
processCount[cell.CellId] = pc + 1;
|
||
|
||
for (int i = 0; i < cell.Portals.Count; i++)
|
||
{
|
||
if (i >= cell.PortalPolygons.Count) continue;
|
||
var poly = cell.PortalPolygons[i];
|
||
if (poly == null || poly.Length < 3) continue;
|
||
|
||
// Project this portal opening to NDC.
|
||
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
|
||
if (portalNdc.Length < 3) continue;
|
||
|
||
// Intersect the portal opening with every polygon of the current cell's view.
|
||
var clippedRegion = new List<ViewPolygon>();
|
||
foreach (var vp in currentView.Polygons)
|
||
{
|
||
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
||
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
||
}
|
||
if (clippedRegion.Count == 0) continue; // portal not visible through this chain
|
||
|
||
var portal = cell.Portals[i];
|
||
|
||
if (portal.OtherCellId == 0xFFFF)
|
||
{
|
||
// Exit portal -> outdoors visible through this (clipped) opening.
|
||
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
|
||
continue;
|
||
}
|
||
|
||
uint neighbourId = lbMask | portal.OtherCellId;
|
||
|
||
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
|
||
if (buildingMembership != null && !buildingMembership(neighbourId))
|
||
{
|
||
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
|
||
foreach (var cp in clippedRegion) xview.Add(cp);
|
||
continue;
|
||
}
|
||
|
||
var neighbour = lookup(neighbourId);
|
||
if (neighbour == null) continue;
|
||
|
||
// Union the clipped region into the neighbour's accumulated view and (re)enqueue.
|
||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||
int before = nview.Polygons.Count;
|
||
foreach (var cp in clippedRegion) nview.Add(cp);
|
||
if (nview.Polygons.Count > before)
|
||
queue.Enqueue(neighbour);
|
||
}
|
||
}
|
||
|
||
return frame;
|
||
}
|
||
|
||
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
|
||
{
|
||
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
|
||
return v;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilderTests"`
|
||
Expected: PASS (3 tests). If `Builder_Cellar_*` fails on width, inspect projection sign conventions before adjusting (do not loosen the assertion).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
|
||
git commit -m "feat(render): Phase A8.F — PortalVisibilityBuilder recursive portal-clip BFS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `IndoorCellStencilPipeline.MarkAndPunchNdc` — mark NDC region into stencil
|
||
|
||
Add an entry that marks a `CellView`/`ViewPolygon` set (already in NDC) into stencil bit 1 and far-depth-punches it, reusing the existing shader with an identity view-projection (NDC passthrough). Mirrors the existing `MarkAndPunch` GL state (IndoorCellStencilPipeline.cs:218-265) but takes pre-projected NDC triangles.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
|
||
|
||
- [ ] **Step 1: Add `MarkAndPunchNdc` (no separate unit test — GL; verified by build + Task 9 visual gate)**
|
||
|
||
Add to `IndoorCellStencilPipeline`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Phase A8.F: mark a pre-projected NDC clip region into stencil bit 1 and far-depth-punch it.
|
||
/// Replaces the flat world-space exit-portal path (PortalMeshBuilder.BuildTriangles) with the
|
||
/// recursively-clipped region from PortalVisibilityBuilder. Polygons are triangulated (fan) and
|
||
/// uploaded as NDC verts (z=0, w=1) drawn with an identity view-projection.
|
||
/// GL state on exit matches MarkAndPunch: stencil disabled, color+depth on, depth=Less.
|
||
/// </summary>
|
||
public void MarkAndPunchNdc(System.Collections.Generic.IReadOnlyList<ViewPolygon> region)
|
||
{
|
||
// Triangulate the region (fan per convex polygon) into NDC Vector3 (z=0).
|
||
int triVerts = 0;
|
||
foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3;
|
||
if (triVerts == 0) { _lastVertexCount = 0; return; }
|
||
|
||
var verts = new Vector3[triVerts];
|
||
int idx = 0;
|
||
foreach (var p in region)
|
||
{
|
||
if (p.IsEmpty) continue;
|
||
var v0 = new Vector3(p.Vertices[0], 0f);
|
||
for (int i = 1; i < p.Vertices.Length - 1; i++)
|
||
{
|
||
verts[idx++] = v0;
|
||
verts[idx++] = new Vector3(p.Vertices[i], 0f);
|
||
verts[idx++] = new Vector3(p.Vertices[i + 1], 0f);
|
||
}
|
||
}
|
||
|
||
if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024));
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||
fixed (Vector3* p = verts)
|
||
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p);
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||
_lastVertexCount = triVerts;
|
||
|
||
// Same GL state machine as MarkAndPunch, but identity VP (verts are already NDC).
|
||
var identity = Matrix4x4.Identity;
|
||
|
||
_gl.Enable(EnableCap.StencilTest);
|
||
_gl.Enable(EnableCap.DepthTest);
|
||
_gl.ClearStencil(0);
|
||
_gl.Clear(ClearBufferMask.StencilBufferBit);
|
||
|
||
// Step 1: mark bit 1.
|
||
_gl.ColorMask(false, false, false, false);
|
||
_gl.DepthMask(false);
|
||
_gl.DepthFunc(DepthFunction.Always);
|
||
_gl.Disable(EnableCap.CullFace);
|
||
_gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
|
||
_gl.StencilMask(0x01u);
|
||
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
||
_shader.Use();
|
||
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity);
|
||
_gl.Uniform1(_uWriteFarDepthLoc, 0);
|
||
_gl.BindVertexArray(_vao);
|
||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts);
|
||
|
||
// Step 2: far-depth punch where bit 1 is set.
|
||
_gl.DepthMask(true);
|
||
_gl.DepthFunc(DepthFunction.Always);
|
||
_gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
|
||
_gl.StencilMask(0x00u);
|
||
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||
_gl.Uniform1(_uWriteFarDepthLoc, 1);
|
||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts);
|
||
_gl.BindVertexArray(0);
|
||
|
||
// Clean state for the indoor-entities pass (matches MarkAndPunch exit).
|
||
_gl.Enable(EnableCap.CullFace);
|
||
_gl.ColorMask(true, true, true, true);
|
||
_gl.DepthFunc(DepthFunction.Less);
|
||
_gl.Disable(EnableCap.StencilTest);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded. (No unit test — GL path, exercised by Task 9.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
|
||
git commit -m "feat(render): Phase A8.F — IndoorCellStencilPipeline.MarkAndPunchNdc (clipped-region stencil)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Rewrite `RenderInsideOutAcdream` — Job-A/B decouple + wire-in #1 (terrain)
|
||
|
||
Drive the stencil from `PortalVisibilityBuilder.OutsideView` instead of the flat exit-portal mesh, and restore WB's structure: exterior geometry (terrain/scenery/shells) draws **unconditionally**; only the stencil *state* is gated by whether `OutsideView` is non-empty. (WB `VisibilityManager.RenderInsideOut`: terrain/scenery/static draws at lines 143-154 are OUTSIDE the `if (didInsideStencil)` block; only the state-setup at 130-139 is inside.)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `RenderInsideOutAcdream` (~11012) and its call site (~7640, to pass what the builder needs).
|
||
|
||
- [ ] **Step 1: Build the frame at the top of `RenderInsideOutAcdream`**
|
||
|
||
Replace the `visiblePortalCells` + `UploadPortalMesh` block (current lines ~11036-11093) with a builder call and the `MarkAndPunchNdc`:
|
||
|
||
```csharp
|
||
// Phase A8.F: build the recursively-clipped portal frame from the camera cell.
|
||
var portalFrame = PortalVisibilityBuilder.Build(
|
||
cameraCell,
|
||
id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||
viewProj,
|
||
buildingMembership: id => currentEnvCellIds.Contains(id)); // see Step 3 for currentEnvCellIds hoist
|
||
|
||
bool didInsideStencil = !portalFrame.OutsideView.IsEmpty;
|
||
if (didInsideStencil)
|
||
{
|
||
EmitDrawOrderProbe(step: 1, sub: ' ');
|
||
_indoorStencilPipeline!.MarkAndPunchNdc(portalFrame.OutsideView.Polygons);
|
||
EmitStencilProbe(op: "mark-clipped");
|
||
}
|
||
```
|
||
|
||
Note: `currentEnvCellIds` (the camera-building cell set) is computed later in the current code; hoist its construction above the builder call so `buildingMembership` can use it (Step 3).
|
||
|
||
- [ ] **Step 2: Keep Step 3 (render camera-building cells) as-is**
|
||
|
||
The existing Step 3 block (render `currentEnvCellIds` opaque + transparent, then IndoorPass shells) is unchanged. It already writes the depth that occludes terrain outside the clipped region.
|
||
|
||
- [ ] **Step 3: Hoist `currentEnvCellIds` above the builder call**
|
||
|
||
Move the construction of `currentEnvCellIds` (union of `camBuildings[].EnvCellIds` + `visibleCellIds`, current lines ~11105-11119) to just before the builder call in Step 1, so `buildingMembership` can reference it. Leave its use in Step 3 intact.
|
||
|
||
- [ ] **Step 4: Decouple Step 4 — terrain/scenery/shells draw unconditionally, stencil state gated**
|
||
|
||
Rewrite the Step 4 block (current lines ~11167-11208) so the DRAWS are unconditional and only the stencil STATE is gated (WB structure):
|
||
|
||
```csharp
|
||
// Step 4 (WB VisibilityManager.cs:130-154): stencil STATE gated; exterior DRAWS unconditional.
|
||
if (didInsideStencil)
|
||
{
|
||
gl.Enable(EnableCap.StencilTest);
|
||
gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
|
||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||
gl.StencilMask(0x00u);
|
||
}
|
||
else
|
||
{
|
||
gl.Disable(EnableCap.StencilTest); // sealed view (e.g. cellar with no reachable exit) — depth alone occludes
|
||
}
|
||
gl.ColorMask(true, true, true, false);
|
||
gl.DepthMask(true);
|
||
gl.Enable(EnableCap.CullFace);
|
||
gl.DepthFunc(DepthFunction.Less);
|
||
|
||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||
// Terrain (WB:143). Terrain mesh is CCW; Step 4 culls, so use terrain's own front-face.
|
||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||
gl.FrontFace(FrontFaceDirection.CW);
|
||
|
||
_meshShader!.Use();
|
||
// Scenery + static objects (WB:148-154).
|
||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||
neverCullLandblockId: playerLb, visibleCellIds: visibleCellIds, animatedEntityIds: null,
|
||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||
```
|
||
|
||
- [ ] **Step 5: Build green + smoke probe**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "feat(render): Phase A8.F — RenderInsideOut driven by clipped OutsideView + Job-A/B decouple"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Wire-in #2 — per-cell geometry clipping (depth for opaque; targeted stencil for translucent)
|
||
|
||
Per Q4: opaque cells already get retail's observable result from depth-testing (kept). Apply the per-cell `CellView` via stencil ONLY for translucent cell geometry. This keeps `EnvCellRenderer`'s MDI batching for the opaque pass.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — the Step 3 transparent-cell render inside `RenderInsideOutAcdream`.
|
||
|
||
- [ ] **Step 1: Gate the transparent EnvCell pass per cell by its `CellView`**
|
||
|
||
In Step 3, the opaque pass (`_envCellRenderer.Render(Opaque, currentEnvCellIds)`) stays unchanged (depth handles it). For the transparent pass, iterate cells that have a non-full `CellView` and render each gated by its region:
|
||
|
||
```csharp
|
||
// Phase A8.F wire-in #2: translucent cell geometry clipped to its portal-chain region.
|
||
// Opaque cells rely on depth (retail's observable result, keeps MDI batching).
|
||
gl.Enable(EnableCap.Blend);
|
||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||
gl.DepthMask(false);
|
||
foreach (var cellId in currentEnvCellIds)
|
||
{
|
||
if (portalFrame.CellViews.TryGetValue(cellId, out var cv) && !cv.IsEmpty)
|
||
{
|
||
_indoorStencilPipeline!.MarkRegionStencilOnly(cv.Polygons); // bit 1, no depth punch
|
||
gl.Enable(EnableCap.StencilTest);
|
||
gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
|
||
gl.StencilMask(0x00u);
|
||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent,
|
||
new System.Collections.Generic.HashSet<uint> { cellId });
|
||
gl.Disable(EnableCap.StencilTest);
|
||
}
|
||
else
|
||
{
|
||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent,
|
||
new System.Collections.Generic.HashSet<uint> { cellId });
|
||
}
|
||
}
|
||
gl.DepthMask(true);
|
||
gl.Disable(EnableCap.Blend);
|
||
```
|
||
|
||
- [ ] **Step 2: Add `MarkRegionStencilOnly` to `IndoorCellStencilPipeline`**
|
||
|
||
Same as `MarkAndPunchNdc` but WITHOUT the far-depth punch step (mark bit 1 only, leave depth untouched), and it does NOT clear the stencil (the caller clears once per cell). Concretely: copy `MarkAndPunchNdc`, keep the upload + Step-1 mark block, delete the Step-2 punch block, and remove the `ClearStencil`/`Clear` (caller-controlled). Leave color/depth restored on exit as in `MarkAndPunchNdc`.
|
||
|
||
- [ ] **Step 3: Build green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
|
||
git commit -m "feat(render): Phase A8.F — wire-in #2 per-cell clip for translucent geometry"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Wire-in #3 — cross-building visibility via clip regions
|
||
|
||
Drive the existing Step-5 3-bit helpers from `portalFrame.CrossBuildingViews` instead of the env-gated `ACDREAM_A8_STEP5` opt-in. Each cross-building entry region marks where another building's cells may show through our portal chain.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — the Step-5 block in `RenderInsideOutAcdream` (current lines ~11220-11284).
|
||
|
||
- [ ] **Step 1: Replace the env-gate with the builder's cross-building views**
|
||
|
||
Remove the `step5Enabled` env-var gate. Run Step 5 when `portalFrame.CrossBuildingViews` is non-empty. For each `(neighbourCellId, entryView)`, resolve the owning building (via `BuildingRegistry.GetBuildingsContainingCell(neighbourCellId)`), and use the existing `MarkBuildingBit2`/`PunchDepthAtStencil3`/`EnableOtherBuildingPass`/`ResetBit2` sequence — but mark bit 1 from `entryView` (the clipped region) rather than the whole building's exit portals.
|
||
|
||
```csharp
|
||
// Step 5 (WB:157-232): other-building cells seen through OUR clipped portal regions.
|
||
if (didInsideStencil && portalFrame.CrossBuildingViews.Count > 0)
|
||
{
|
||
foreach (var (neighbourCellId, entryView) in portalFrame.CrossBuildingViews)
|
||
{
|
||
if (entryView.IsEmpty) continue;
|
||
var buildings = _buildingRegistry?.GetBuildingsContainingCell(neighbourCellId);
|
||
if (buildings == null) continue;
|
||
foreach (var b in buildings)
|
||
{
|
||
// Mark bit 1 = the clipped entry region (NDC), then the existing 3-bit dance.
|
||
_indoorStencilPipeline!.MarkRegionStencilOnly(entryView.Polygons);
|
||
// ... existing MarkBuildingBit2 / PunchDepthAtStencil3 / EnableOtherBuildingPass /
|
||
// render b.EnvCellIds / ResetBit2 sequence (unchanged from current Step 5) ...
|
||
}
|
||
}
|
||
gl.DepthFunc(DepthFunction.Less);
|
||
}
|
||
```
|
||
|
||
**Verify-against-decomp during execution:** confirm `BuildingRegistry.GetBuildingsContainingCell` exists with that signature (grep `BuildingRegistry`); if the registry exposes a different accessor, use it. The 3-bit Mark/Punch/Reset sequence is already implemented and correct (IndoorCellStencilPipeline.cs:310-419) — only its bit-1 source changes.
|
||
|
||
- [ ] **Step 2: Build green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "feat(render): Phase A8.F — wire-in #3 cross-building visibility from clip regions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Integration — full suite, probe evidence, visual gate, roadmap + memory
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md`
|
||
- Run: full build + test + a probe launch + the visual gate.
|
||
|
||
- [ ] **Step 1: Full build + test**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeded, 0 errors.
|
||
Run: `dotnet test`
|
||
Expected: PASS — App baseline (~90) + the new PortalView/ScreenPolygonClip/PortalProjection/PortalVisibilityBuilder tests; Core baseline maintained.
|
||
|
||
- [ ] **Step 2: Probe-evidence launch (read the log BEFORE asking for the visual gate)**
|
||
|
||
Launch with probes (PowerShell), walk into a Holtburg cottage and down to the cellar:
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
||
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
|
||
$env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_PROBE_VIS="1"
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f-cellar-gate.log"
|
||
```
|
||
Read `a8f-cellar-gate.log` and confirm: `[buildings] camBldgs=[0xA]` when in the cottage; `[draworder]` shows steps 1→3→4 per indoor frame; `[stencil] op=mark-clipped verts>0` in the cellar with a SMALL vert count (sliver), not the full-window count.
|
||
|
||
- [ ] **Step 3: Visual gate (user)**
|
||
|
||
User confirms in the running client:
|
||
- Cottage **cellar**: no flap — at most a daylight sliver up the stairwell, never the full outdoor world through the floor.
|
||
- Cottage interior + inn: walls/furniture render; windows show correct daylight; multi-room daylight preserved.
|
||
- Dungeon if reachable (deep portal chain).
|
||
- No A8 regression: no see-through walls; building shells intact; LiveDynamic entities (player/NPCs/items) present indoors.
|
||
|
||
This is the acceptance test and the only mandatory stop.
|
||
|
||
- [ ] **Step 4: Update roadmap**
|
||
|
||
Add an A8.F "shipped" row to `docs/plans/2026-04-11-roadmap.md` summarizing the port (recursively-clipped OutsideView via PortalVisibilityBuilder; enforcement on the A8 stencil pipeline; cellar flap closed) with the commit range.
|
||
|
||
- [ ] **Step 5: Commit the roadmap update**
|
||
|
||
```bash
|
||
git add docs/plans/2026-04-11-roadmap.md
|
||
git commit -m "docs: Phase A8.F shipped — retail portal-frame visibility (cellar flap closed)"
|
||
```
|
||
|
||
- [ ] **Step 6: Update memory + CLAUDE.md M1.5 line**
|
||
|
||
Confirm `memory/project_indoor_portal_visibility.md` reflects "A8.F shipped"; update the CLAUDE.md "currently working toward M1.5" block to note the cellar flap closed (if A8.F completes the M1.5 indoor-visibility scope). Commit.
|
||
|
||
---
|
||
|
||
## Self-review (completed)
|
||
|
||
- **Spec coverage:** Step 0 (flag strip) ✓ Task 0; builder + clip + projection + data model ✓ Tasks 1-4; stencil NDC entry ✓ Task 5; Job-A/B decouple + wire-in #1 ✓ Task 6; wire-in #2 ✓ Task 7; wire-in #3 ✓ Task 8; tests + visual gate + roadmap/memory ✓ Task 9. Near-plane risk addressed by Task 3's straddle test. Q4 fidelity-vs-perf realized in Task 7 (opaque=depth, translucent=stencil).
|
||
- **Placeholders:** none — every code step has complete code; the two "verify-against-decomp during execution" notes (Task 4 neighbour-side clip, Task 8 registry accessor) are explicit verification steps with grep targets, not deferred implementation.
|
||
- **Type consistency:** `ViewPolygon`/`CellView` (Task 1) → `ScreenPolygonClip.Intersect(subject, clip)` returning `Vector2[]` (Task 2) → `PortalProjection.ProjectToNdc(localPoly, cellToWorld, viewProj)` returning `Vector2[]` (Task 3) → `PortalVisibilityBuilder.Build(cameraCell, lookup, viewProj, buildingMembership)` returning `PortalVisibilityFrame {OutsideView, CellViews, CrossBuildingViews}` (Task 4) → `MarkAndPunchNdc(IReadOnlyList<ViewPolygon>)` + `MarkRegionStencilOnly(...)` (Tasks 5,7) → consumed in `RenderInsideOutAcdream` (Tasks 6-8). Consistent throughout.
|
||
|
||
**Known execution-time verifications (flagged, not placeholders):** Task 4 neighbour-side `OtherPortalClip` (decomp:433524) if the inn conformance shows over-inclusion; Task 8 `BuildingRegistry.GetBuildingsContainingCell` signature.
|