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:
Erik 2026-07-13 18:45:24 +02:00
parent 85980fe13f
commit b0c175afc0
26 changed files with 973 additions and 617 deletions

View 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),
};
}
}

View file

@ -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));
}
}
}

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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>