feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)
Purely additive: creates the synthetic outdoor cell node that will serve as the flood-graph root for the unified render pipeline. Each nearby building's exit portal (OtherCellId==0xFFFF) is reversed into a portal pointing back into the building, with its polygon transformed to world space and InsideSide flipped so the outdoor half-space is "inside" the node. WorldTransform=Identity (portals in world space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635). Nothing consumes this yet — consumer wiring is Task 2. 2 new tests, 212 total passing, 0 regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06666b75a1
commit
2a2cc97d28
2 changed files with 115 additions and 0 deletions
63
src/AcDream.App/Rendering/OutdoorCellNode.cs
Normal file
63
src/AcDream.App/Rendering/OutdoorCellNode.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell
|
||||
/// (spec docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md).
|
||||
/// Its "shell" is the landscape (drawn by the terrain renderer); its portals are the
|
||||
/// reverse of each nearby building's exit portal (OtherCellId==0xFFFF). One node per
|
||||
/// frame, keyed by the viewer's outdoor landcell id. WorldTransform is identity
|
||||
/// (portals stored in world space). Mirrors retail's outdoor landcell that
|
||||
/// DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635).
|
||||
/// </summary>
|
||||
public static class OutdoorCellNode
|
||||
{
|
||||
public static LoadedCell Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells)
|
||||
{
|
||||
var node = new LoadedCell
|
||||
{
|
||||
CellId = outdoorCellId,
|
||||
SeenOutside = true,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
foreach (var bcell in nearbyBuildingCells)
|
||||
{
|
||||
for (int i = 0; i < bcell.Portals.Count; i++)
|
||||
{
|
||||
if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors
|
||||
if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue;
|
||||
|
||||
// Reverse portal: outdoor node -> this building cell.
|
||||
node.Portals.Add(new CellPortalInfo(
|
||||
OtherCellId: (ushort)(bcell.CellId & 0xFFFFu),
|
||||
PolygonId: bcell.Portals[i].PolygonId,
|
||||
Flags: bcell.Portals[i].Flags,
|
||||
OtherPortalId: (ushort)i));
|
||||
|
||||
// Entrance polygon -> world space (node transform is identity).
|
||||
var srcPoly = bcell.PortalPolygons[i];
|
||||
var worldPoly = new Vector3[srcPoly.Length];
|
||||
for (int v = 0; v < srcPoly.Length; v++)
|
||||
worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform);
|
||||
node.PortalPolygons.Add(worldPoly);
|
||||
|
||||
// Clip plane -> world space, inside-side flipped (outdoor half-space is "inside").
|
||||
var src = bcell.ClipPlanes[i];
|
||||
var worldNormal = Vector3.Normalize(Vector3.TransformNormal(src.Normal, bcell.WorldTransform));
|
||||
var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform);
|
||||
node.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = worldNormal,
|
||||
D = -Vector3.Dot(worldNormal, pointOnPlane),
|
||||
InsideSide = src.InsideSide == 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue