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
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue