fix(streaming): commit EnvCell landblocks atomically (#214)
Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
85980fe13f
commit
b0c175afc0
26 changed files with 973 additions and 617 deletions
299
src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
Normal file
299
src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// One drawable EnvCell shell prepared by the streaming worker. This is CPU-only
|
||||
/// placement data; the render thread schedules mesh preparation when it commits
|
||||
/// the containing <see cref="EnvCellLandblockBuild"/>.
|
||||
/// </summary>
|
||||
public sealed record EnvCellShellPlacement(
|
||||
uint CellId,
|
||||
ulong GeometryId,
|
||||
uint EnvironmentId,
|
||||
ushort CellStructure,
|
||||
ImmutableArray<ushort> Surfaces,
|
||||
Vector3 WorldPosition,
|
||||
Quaternion Rotation,
|
||||
Matrix4x4 Transform,
|
||||
WbBoundingBox LocalBounds,
|
||||
WbBoundingBox WorldBounds);
|
||||
|
||||
/// <summary>
|
||||
/// Complete, immutable worker output for one landblock's indoor cells. It owns
|
||||
/// both portal-visibility cells and drawable shell placements so neither can be
|
||||
/// drained by, or mixed with, another streaming completion.
|
||||
/// </summary>
|
||||
public sealed class EnvCellLandblockBuild
|
||||
{
|
||||
public EnvCellLandblockBuild(
|
||||
uint landblockId,
|
||||
IEnumerable<LoadedCell> visibilityCells,
|
||||
IEnumerable<EnvCellShellPlacement> shells)
|
||||
{
|
||||
LandblockId = landblockId;
|
||||
VisibilityCells = visibilityCells.ToImmutableArray();
|
||||
Shells = shells.ToImmutableArray();
|
||||
|
||||
uint expectedPrefix = landblockId & 0xFFFF0000u;
|
||||
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
|
||||
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||
throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
|
||||
}
|
||||
|
||||
public uint LandblockId { get; }
|
||||
public ImmutableArray<LoadedCell> VisibilityCells { get; }
|
||||
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transaction-local builder used only by one streaming job. Unlike the former
|
||||
/// global pending bags, instances of this class are never shared between jobs or
|
||||
/// observed by the render thread before <see cref="Build"/> returns.
|
||||
/// </summary>
|
||||
public sealed class EnvCellLandblockBuildBuilder
|
||||
{
|
||||
private readonly uint _landblockId;
|
||||
private readonly List<LoadedCell> _visibilityCells = new();
|
||||
private readonly List<EnvCellShellPlacement> _shells = new();
|
||||
private bool _built;
|
||||
|
||||
public EnvCellLandblockBuildBuilder(uint landblockId)
|
||||
{
|
||||
_landblockId = landblockId;
|
||||
}
|
||||
|
||||
public void AddCell(
|
||||
uint envCellId,
|
||||
EnvCell envCell,
|
||||
CellStruct cellStruct,
|
||||
Vector3 physicsCellOrigin,
|
||||
Matrix4x4 physicsCellTransform,
|
||||
Vector3 shellWorldPosition,
|
||||
Matrix4x4 shellTransform,
|
||||
bool hasDrawableGeometry)
|
||||
{
|
||||
if (_built)
|
||||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||
|
||||
var localBounds = ComputeLocalBounds(cellStruct);
|
||||
_visibilityCells.Add(BuildVisibilityCell(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellOrigin,
|
||||
physicsCellTransform,
|
||||
localBounds));
|
||||
|
||||
if (!hasDrawableGeometry)
|
||||
return;
|
||||
|
||||
ulong geometryId = ComputeGeometryId(
|
||||
envCell.EnvironmentId,
|
||||
envCell.CellStructure,
|
||||
envCell.Surfaces);
|
||||
_shells.Add(new EnvCellShellPlacement(
|
||||
envCellId,
|
||||
geometryId,
|
||||
envCell.EnvironmentId,
|
||||
envCell.CellStructure,
|
||||
envCell.Surfaces.ToImmutableArray(),
|
||||
shellWorldPosition,
|
||||
envCell.Position.Orientation,
|
||||
shellTransform,
|
||||
localBounds,
|
||||
TransformBoundingBox(localBounds, shellTransform)));
|
||||
}
|
||||
|
||||
public EnvCellLandblockBuild Build()
|
||||
{
|
||||
if (_built)
|
||||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||
_built = true;
|
||||
return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim arithmetic).
|
||||
/// </summary>
|
||||
public static ulong ComputeGeometryId(
|
||||
uint environmentId,
|
||||
ushort cellStructure,
|
||||
IReadOnlyList<ushort> surfaces)
|
||||
{
|
||||
var hash = 17L;
|
||||
hash = hash * 31 + (int)environmentId;
|
||||
hash = hash * 31 + cellStructure;
|
||||
foreach (var surface in surfaces)
|
||||
hash = hash * 31 + surface;
|
||||
return (ulong)hash | 0x2_0000_0000UL;
|
||||
}
|
||||
|
||||
private static WbBoundingBox ComputeLocalBounds(CellStruct cellStruct)
|
||||
{
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var vertex in cellStruct.VertexArray.Vertices.Values)
|
||||
{
|
||||
var position = new Vector3(vertex.Origin.X, vertex.Origin.Y, vertex.Origin.Z);
|
||||
min = Vector3.Min(min, position);
|
||||
max = Vector3.Max(max, position);
|
||||
}
|
||||
|
||||
return min.X == float.MaxValue
|
||||
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
|
||||
: new WbBoundingBox(min, max);
|
||||
}
|
||||
|
||||
private static WbBoundingBox TransformBoundingBox(WbBoundingBox box, Matrix4x4 transform)
|
||||
{
|
||||
Span<Vector3> corners = stackalloc Vector3[8]
|
||||
{
|
||||
new(box.Min.X, box.Min.Y, box.Min.Z),
|
||||
new(box.Max.X, box.Min.Y, box.Min.Z),
|
||||
new(box.Min.X, box.Max.Y, box.Min.Z),
|
||||
new(box.Max.X, box.Max.Y, box.Min.Z),
|
||||
new(box.Min.X, box.Min.Y, box.Max.Z),
|
||||
new(box.Max.X, box.Min.Y, box.Max.Z),
|
||||
new(box.Min.X, box.Max.Y, box.Max.Z),
|
||||
new(box.Max.X, box.Max.Y, box.Max.Z),
|
||||
};
|
||||
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var corner in corners)
|
||||
{
|
||||
var transformed = Vector3.Transform(corner, transform);
|
||||
min = Vector3.Min(min, transformed);
|
||||
max = Vector3.Max(max, transformed);
|
||||
}
|
||||
|
||||
return new WbBoundingBox(min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mechanical extraction of the former GameWindow.BuildLoadedCell. Portal-side behavior
|
||||
/// is unchanged: retail PView::InitCell (0x005a4b70) uses the dat PortalSide
|
||||
/// bit, and the reciprocal portal index follows the named retail path at
|
||||
/// pseudo-C line 433557.
|
||||
/// </summary>
|
||||
private static LoadedCell BuildVisibilityCell(
|
||||
uint envCellId,
|
||||
EnvCell envCell,
|
||||
CellStruct cellStruct,
|
||||
Vector3 cellOrigin,
|
||||
Matrix4x4 cellTransform,
|
||||
WbBoundingBox localBounds)
|
||||
{
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId,
|
||||
portal.PolygonId,
|
||||
(ushort)portal.Flags,
|
||||
portal.OtherPortalId));
|
||||
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3)
|
||||
{
|
||||
Vector3 p0 = Vector3.Zero, p1 = Vector3.Zero, p2 = Vector3.Zero;
|
||||
bool found = true;
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
|
||||
p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||||
else
|
||||
found = false;
|
||||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
|
||||
p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||||
else
|
||||
found = false;
|
||||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||||
p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
else
|
||||
found = false;
|
||||
|
||||
if (found)
|
||||
{
|
||||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -Vector3.Dot(normal, p0);
|
||||
int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0;
|
||||
clipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = normal,
|
||||
D = d,
|
||||
InsideSide = insideSide,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
|
||||
Vector3[] polygonVertices = Array.Empty<Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPolygon)
|
||||
&& portalPolygon.VertexIds.Count >= 3)
|
||||
{
|
||||
polygonVertices = new Vector3[portalPolygon.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vertexIndex = 0; vertexIndex < portalPolygon.VertexIds.Count; vertexIndex++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPolygon.VertexIds[vertexIndex], out var vertex))
|
||||
{
|
||||
polygonVertices[vertexIndex] = new Vector3(
|
||||
vertex.Origin.X,
|
||||
vertex.Origin.Y,
|
||||
vertex.Origin.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
allResolved = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allResolved)
|
||||
polygonVertices = Array.Empty<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polygonVertices);
|
||||
}
|
||||
|
||||
uint landblockPrefix = envCellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
{
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(landblockPrefix | lowId);
|
||||
}
|
||||
|
||||
return new LoadedCell
|
||||
{
|
||||
CellId = envCellId,
|
||||
WorldPosition = cellOrigin,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = localBounds.Min,
|
||||
LocalBoundsMax = localBounds.Max,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Starts CPU mesh extraction for a completed EnvCell build without publishing
|
||||
/// that build to the live renderer. ObjectMeshManager owns its thread-safe work
|
||||
/// queue; the render-thread commit remains a small atomic state replacement.
|
||||
/// </summary>
|
||||
public static class EnvCellMeshPreparationScheduler
|
||||
{
|
||||
public static void Schedule(
|
||||
EnvCellLandblockBuild build,
|
||||
ObjectMeshManager meshManager)
|
||||
{
|
||||
foreach (var shell in build.Shells)
|
||||
{
|
||||
_ = meshManager.PrepareEnvCellGeomMeshDataAsync(
|
||||
shell.GeometryId,
|
||||
shell.EnvironmentId,
|
||||
shell.CellStructure,
|
||||
new List<ushort>(shell.Surfaces));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,15 +8,14 @@
|
|||
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
|
||||
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
|
||||
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
|
||||
// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
|
||||
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
|
||||
//
|
||||
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
|
||||
// class owns the landblock-streaming loop (Update, _pendingGeneration,
|
||||
// _uploadQueue). acdream's StreamingController already does that work —
|
||||
// running a parallel loop would compete for dat I/O. Instead, we expose
|
||||
// RegisterCell(...) as the seam: callers populate our instance store at
|
||||
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
|
||||
// running a parallel loop would compete for dat I/O. Instead, streaming builds
|
||||
// a private EnvCellLandblockBuild and CommitLandblock publishes the completed
|
||||
// snapshot on the render thread.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
|
|
@ -36,7 +35,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private readonly ObjectMeshManager _meshManager;
|
||||
private readonly WbFrustum _frustum;
|
||||
|
||||
// Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
|
||||
// Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF).
|
||||
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
|
||||
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
|
||||
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
|
||||
|
|
@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
|
||||
/// </summary>
|
||||
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
|
||||
{
|
||||
// WB EnvCellRenderManager.cs:95-102 verbatim:
|
||||
var hash = 17L;
|
||||
hash = hash * 31 + (int)environmentId;
|
||||
hash = hash * 31 + cellStructure;
|
||||
foreach (var surface in surfaces) {
|
||||
hash = hash * 31 + surface;
|
||||
}
|
||||
// Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry)
|
||||
return (ulong)hash | 0x2_0000_0000UL;
|
||||
}
|
||||
=> EnvCellLandblockBuildBuilder.ComputeGeometryId(
|
||||
environmentId,
|
||||
cellStructure,
|
||||
surfaces);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegisterCell — streaming seam
|
||||
// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
|
||||
// time, ONCE per EnvCell that has non-null EnvironmentId.
|
||||
// CommitLandblock — render-thread transaction boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Registers a single EnvCell and its static objects into the per-landblock
|
||||
/// pending list. Call <see cref="FinalizeLandblock"/> after all cells for a
|
||||
/// landblock have been registered.
|
||||
/// Commits one complete worker-built landblock snapshot. A replacement is
|
||||
/// constructed off to the side, then published with one dictionary write;
|
||||
/// render preparation can observe either the previous complete snapshot or
|
||||
/// this complete snapshot, never an in-progress cell list.
|
||||
/// </summary>
|
||||
public void RegisterCell(
|
||||
uint landblockId,
|
||||
uint envCellId,
|
||||
DatReaderWriter.DBObjs.EnvCell envCell,
|
||||
DatReaderWriter.Types.CellStruct cellStruct,
|
||||
Matrix4x4 cellTransform,
|
||||
Vector3 cellWorldPosition,
|
||||
Quaternion cellRotation,
|
||||
IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
|
||||
public void CommitLandblock(EnvCellLandblockBuild build)
|
||||
{
|
||||
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
|
||||
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
|
||||
_landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
|
||||
NeedsPrepare = true;
|
||||
}
|
||||
|
||||
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
|
||||
// This populates ObjectMeshManager._renderData[cellGeomId] when complete.
|
||||
// Verified Phase W Stage 4 (T4.4): ceilings are present by construction.
|
||||
// PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons
|
||||
// (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO
|
||||
// surface filter — every polygon that passes the ≥3-vertex check is added to
|
||||
// the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which
|
||||
// is the same closed-box structure. No ceiling polygons are dropped.
|
||||
_ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
|
||||
envCell.CellStructure, envCell.Surfaces);
|
||||
|
||||
// 3. Compute local bounds from cellStruct vertex array.
|
||||
var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
|
||||
var worldBounds = TransformBoundingBox(localBounds, cellTransform);
|
||||
|
||||
// 4. Create the per-cell SceneryInstance.
|
||||
var cellInstance = new EnvCellSceneryInstance
|
||||
/// <summary>
|
||||
/// Pure half of <see cref="CommitLandblock"/>. Kept separate so transaction
|
||||
/// replacement semantics are regression-tested without an OpenGL context.
|
||||
/// </summary>
|
||||
internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build)
|
||||
{
|
||||
var replacement = new EnvCellLandblock
|
||||
{
|
||||
ObjectId = cellGeomId,
|
||||
InstanceId = envCellId, // uint InstanceId maps to the cell id
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false, // could be derived from entry-portal walk; default false
|
||||
WorldPosition = cellWorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = cellRotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = cellTransform,
|
||||
LocalBoundingBox = localBounds,
|
||||
BoundingBox = worldBounds,
|
||||
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
|
||||
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
|
||||
};
|
||||
|
||||
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
|
||||
foreach (var shell in build.Shells)
|
||||
{
|
||||
GridX = (int)((id >> 24) & 0xFFu),
|
||||
GridY = (int)((id >> 16) & 0xFFu),
|
||||
});
|
||||
|
||||
lock (lb.Lock)
|
||||
{
|
||||
// TEMP diagnostic #105 (strip with fix): a registration landing AFTER
|
||||
// this landblock was already finalized starts a fresh pending list that
|
||||
// only commits if ANOTHER finalize arrives — and that one will REPLACE
|
||||
// (not merge) the committed set. One-shot per landblock per pending list.
|
||||
if (lb.InstancesReady && lb.PendingInstances is null)
|
||||
Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)");
|
||||
lb.PendingInstances ??= new List<EnvCellSceneryInstance>(capacity: 32);
|
||||
lb.PendingInstances.Add(cellInstance);
|
||||
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
|
||||
lb.PendingEnvCellBounds[envCellId] = worldBounds;
|
||||
|
||||
// Add static-object instances inside the cell.
|
||||
foreach (var stab in staticObjects)
|
||||
replacement.Instances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
|
||||
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
|
||||
ObjectId = shell.GeometryId,
|
||||
InstanceId = shell.CellId,
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false,
|
||||
WorldPosition = shell.WorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = shell.Rotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = shell.Transform,
|
||||
LocalBoundingBox = shell.LocalBounds,
|
||||
BoundingBox = shell.WorldBounds,
|
||||
});
|
||||
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
|
||||
|
||||
WbBoundingBox stabBoundsLocal;
|
||||
var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup);
|
||||
if (rawBounds.HasValue)
|
||||
stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max);
|
||||
else
|
||||
stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
|
||||
|
||||
lb.PendingInstances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
ObjectId = stab.StaticObjectId,
|
||||
InstanceId = 0,
|
||||
IsSetup = stab.IsSetup,
|
||||
IsBuilding = false,
|
||||
Transform = stab.Transform,
|
||||
BoundingBox = stabBoundsWorld,
|
||||
LocalBoundingBox = stabBoundsLocal,
|
||||
Scale = Vector3.One,
|
||||
Rotation = stab.Rot,
|
||||
// CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups
|
||||
CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch
|
||||
});
|
||||
|
||||
// Union the cell bounds with the stab bounds.
|
||||
var current = lb.PendingEnvCellBounds[envCellId];
|
||||
lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
|
||||
if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
|
||||
{
|
||||
instances = new List<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically promotes the pending instance list to the committed list,
|
||||
/// computes total EnvCell bounds, populates PartGroups, and marks the
|
||||
/// landblock ready for rendering.
|
||||
/// </summary>
|
||||
public void FinalizeLandblock(uint landblockId)
|
||||
{
|
||||
if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
|
||||
lock (lb.Lock)
|
||||
{
|
||||
if (lb.PendingInstances is not null)
|
||||
{
|
||||
// TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a
|
||||
// previous finalize already committed instances for this landblock,
|
||||
// this swap DISCARDS them in favor of the new pending set. A partial
|
||||
// pending set (finalize racing a still-registering promote job)
|
||||
// silently loses buildings.
|
||||
if (lb.Instances is { Count: > 0 })
|
||||
Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending");
|
||||
lb.Instances = lb.PendingInstances;
|
||||
lb.PendingInstances = null;
|
||||
}
|
||||
if (lb.PendingEnvCellBounds is not null)
|
||||
{
|
||||
lb.EnvCellBounds = lb.PendingEnvCellBounds;
|
||||
lb.PendingEnvCellBounds = null;
|
||||
}
|
||||
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
||||
foreach (var bounds in replacement.EnvCellBounds.Values)
|
||||
total = WbBoundingBox.Union(total, bounds);
|
||||
replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0
|
||||
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
|
||||
: total;
|
||||
|
||||
// Compute total bounds union across all cells.
|
||||
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
||||
foreach (var b in lb.EnvCellBounds.Values)
|
||||
total = WbBoundingBox.Union(total, b);
|
||||
lb.TotalEnvCellBounds = total;
|
||||
|
||||
// Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
|
||||
PopulatePartGroups(lb);
|
||||
|
||||
lb.InstancesReady = true;
|
||||
lb.MeshDataReady = true;
|
||||
lb.GpuReady = true;
|
||||
}
|
||||
NeedsPrepare = true;
|
||||
replacement.InstancesReady = true;
|
||||
replacement.MeshDataReady = true;
|
||||
replacement.GpuReady = true;
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
NeedsPrepare = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PopulatePartGroups
|
||||
// Verbatim port of WB EnvCellRenderManager.cs:572-580.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
|
||||
/// Must be called under lb.Lock.
|
||||
/// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
|
||||
/// </summary>
|
||||
private void PopulatePartGroups(EnvCellLandblock lb)
|
||||
{
|
||||
// WB EnvCellRenderManager.cs:573-574:
|
||||
lb.StaticPartGroups.Clear();
|
||||
lb.BuildingPartGroups.Clear();
|
||||
|
||||
// WB EnvCellRenderManager.cs:575-579:
|
||||
foreach (var instance in lb.Instances)
|
||||
{
|
||||
var targetGroup = instance.IsBuilding ? lb.BuildingPartGroups : lb.StaticPartGroups;
|
||||
// WB: var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId.DataId;
|
||||
// We store the parent cellId in CurrentPreviewCellId (for stabs) or InstanceId (for cell geometry).
|
||||
var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId;
|
||||
PopulateRecursive(targetGroup, instance.ObjectId, instance.IsSetup, instance.Transform, cellId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
|
||||
/// adding one <see cref="InstanceData"/> per leaf GfxObj to the target group.
|
||||
/// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
|
||||
/// </summary>
|
||||
private void PopulateRecursive(
|
||||
Dictionary<ulong, List<InstanceData>> group,
|
||||
ulong objectId,
|
||||
bool isSetup,
|
||||
Matrix4x4 transform,
|
||||
uint cellId)
|
||||
{
|
||||
if (isSetup)
|
||||
{
|
||||
// For a Setup, TryGetRenderData returns a record whose SetupParts lists
|
||||
// (partId, partTransform) pairs. We recursively handle each part.
|
||||
var rd = _meshManager.TryGetRenderData(objectId);
|
||||
if (rd is null || !rd.IsSetup) return;
|
||||
|
||||
foreach (var (partId, partTransform) in rd.SetupParts)
|
||||
{
|
||||
// Compose: world = partTransform (relative to setup) * setup world transform.
|
||||
//
|
||||
// FIX 2026-05-28: detect nested Setups via the high-byte
|
||||
// 0x02 convention (Setup IDs start with 0x02; plain GfxObj
|
||||
// IDs start with 0x01). Mirrors WB
|
||||
// ObjectRenderManagerBase.cs:813. Original port hardcoded
|
||||
// isSetup:false which silently dropped any nested Setup —
|
||||
// its TryGetRenderData returns IsSetup=true, and Render's
|
||||
// `if (!renderData.IsSetup)` guard then skips the draw.
|
||||
var combined = partTransform * transform;
|
||||
bool partIsSetup = (partId >> 24) == 0x02UL;
|
||||
PopulateRecursive(group, partId, partIsSetup, combined, cellId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain GfxObj — just add one InstanceData.
|
||||
if (!group.TryGetValue(objectId, out var list))
|
||||
{
|
||||
list = new List<InstanceData>();
|
||||
group[objectId] = list;
|
||||
}
|
||||
list.Add(new InstanceData
|
||||
{
|
||||
Transform = transform,
|
||||
CellId = cellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PrepareRenderBatches
|
||||
// Verbatim port of WB EnvCellRenderManager.cs:247-373.
|
||||
|
|
@ -1629,59 +1459,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// Helpers: bounds computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Computes a local-space AABB from a CellStruct's vertex array.
|
||||
/// </summary>
|
||||
private static WbBoundingBox ComputeLocalBoundsFromCellStruct(DatReaderWriter.Types.CellStruct cellStruct)
|
||||
{
|
||||
if (cellStruct.VertexArray is null || cellStruct.VertexArray.Vertices is null || cellStruct.VertexArray.Vertices.Count == 0)
|
||||
return new WbBoundingBox(Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var v in cellStruct.VertexArray.Vertices.Values)
|
||||
{
|
||||
var p = v.Origin;
|
||||
min = Vector3.Min(min, p);
|
||||
max = Vector3.Max(max, p);
|
||||
}
|
||||
return new WbBoundingBox(min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a local AABB to a world AABB by transforming all 8 corners.
|
||||
/// </summary>
|
||||
private static WbBoundingBox TransformBoundingBox(WbBoundingBox local, Matrix4x4 transform)
|
||||
{
|
||||
if (local.Min == local.Max && local.Min == Vector3.Zero)
|
||||
return local;
|
||||
|
||||
var min = local.Min;
|
||||
var max = local.Max;
|
||||
|
||||
Span<Vector3> corners = stackalloc Vector3[8]
|
||||
{
|
||||
new Vector3(min.X, min.Y, min.Z),
|
||||
new Vector3(max.X, min.Y, min.Z),
|
||||
new Vector3(min.X, max.Y, min.Z),
|
||||
new Vector3(max.X, max.Y, min.Z),
|
||||
new Vector3(min.X, min.Y, max.Z),
|
||||
new Vector3(max.X, min.Y, max.Z),
|
||||
new Vector3(min.X, max.Y, max.Z),
|
||||
new Vector3(max.X, max.Y, max.Z),
|
||||
};
|
||||
|
||||
var wMin = new Vector3(float.MaxValue);
|
||||
var wMax = new Vector3(float.MinValue);
|
||||
foreach (var c in corners)
|
||||
{
|
||||
var w = Vector3.Transform(c, transform);
|
||||
wMin = Vector3.Min(wMin, w);
|
||||
wMax = Vector3.Max(wMax, w);
|
||||
}
|
||||
return new WbBoundingBox(wMin, wMax);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,18 +90,6 @@ public class EnvCellLandblock
|
|||
/// </summary>
|
||||
public HashSet<uint> SeenOutsideCells { get; set; } = new();
|
||||
|
||||
public List<EnvCellSceneryInstance>? PendingInstances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Grouped bounding boxes for each EnvCell in this landblock (pending upload).
|
||||
/// </summary>
|
||||
public Dictionary<uint, WbBoundingBox>? PendingEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload).
|
||||
/// </summary>
|
||||
public HashSet<uint>? PendingSeenOutsideCells { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering.
|
||||
/// Key: GfxObjId, Value: List of transforms
|
||||
|
|
@ -124,11 +112,6 @@ public class EnvCellLandblock
|
|||
/// </summary>
|
||||
public WbBoundingBox TotalEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bounding box covering all EnvCells in this landblock (pending upload).
|
||||
/// </summary>
|
||||
public WbBoundingBox PendingTotalEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether instances (positions/bounding boxes) have been generated.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue