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
|
|
@ -191,19 +191,6 @@ public sealed class GameWindow : IDisposable
|
|||
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
|
||||
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
|
||||
|
||||
// Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
|
||||
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
|
||||
// this dict and uploads them via EnsureUploaded before the per-MeshRef loop.
|
||||
// ConcurrentDictionary is required because the worker and render threads
|
||||
// access this simultaneously without a broader lock.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<
|
||||
uint, System.Collections.Generic.IReadOnlyList<AcDream.Core.Meshing.GfxObjSubMesh>>
|
||||
_pendingCellMeshes = new();
|
||||
|
||||
// Step 4: pending LoadedCell objects built on the worker thread, drained
|
||||
// to _cellVisibility on the render thread in ApplyLoadedTerrain.
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<LoadedCell> _pendingCells = new();
|
||||
|
||||
// Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock
|
||||
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
|
||||
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame
|
||||
|
|
@ -991,17 +978,6 @@ public sealed class GameWindow : IDisposable
|
|||
// closes (stale-centered geometry built during the InWorld-but-not-yet-
|
||||
// located window, applied late once its build finished).
|
||||
private bool _liveCenterKnown;
|
||||
// Set by the login-spawn recenter (network thread) when the spawn landblock
|
||||
// differs from the startup streaming center; consumed on the render thread
|
||||
// in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far
|
||||
// login-spawn moves the render origin just like an outdoor teleport, so the
|
||||
// streaming region — bootstrapped around the stale startup center — must be
|
||||
// rebuilt fresh around the spawn. Without this, RecenterTo treats the
|
||||
// old/new window overlap as already-resident (MarkResidentFromBootstrap marks
|
||||
// residence before the async loads land), leaving a permanent hole of
|
||||
// resident-but-never-loaded landblocks where the player spawns — the
|
||||
// cold-spawn "invisible player / world not loading" bug (2026-07-03).
|
||||
private volatile bool _pendingForceReloadWindow;
|
||||
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
|
||||
|
||||
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
|
||||
|
|
@ -3182,13 +3158,6 @@ public sealed class GameWindow : IDisposable
|
|||
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
// The origin jumped — the streaming region is still bootstrapped
|
||||
// around the stale startup center with residence marked before its
|
||||
// async loads landed. Flag a full window rebuild (consumed on the
|
||||
// render thread in OnUpdateFrame) so the region re-bootstraps fresh
|
||||
// around the spawn, re-loading the landblocks RecenterTo would
|
||||
// otherwise skip as already-resident — the cold-spawn streaming hole.
|
||||
_pendingForceReloadWindow = true;
|
||||
}
|
||||
|
||||
// #135: the instant we know the player spawned into a SEALED dungeon,
|
||||
|
|
@ -3202,10 +3171,12 @@ public sealed class GameWindow : IDisposable
|
|||
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
|
||||
// (reentrant); the controller call is render-thread-safe (Channel writes).
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& _streamingController is not null
|
||||
&& IsSealedDungeonCell(p.LandblockId))
|
||||
&& _streamingController is not null)
|
||||
{
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
_streamingController.InitializeKnownLoginCenter(
|
||||
lbX,
|
||||
lbY,
|
||||
isSealedDungeon: IsSealedDungeonCell(p.LandblockId));
|
||||
}
|
||||
|
||||
var origin = new System.Numerics.Vector3(
|
||||
|
|
@ -6453,7 +6424,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||||
/// early-out at the source.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(
|
||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
|
||||
uint landblockId,
|
||||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
||||
{
|
||||
|
|
@ -6491,7 +6462,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(
|
||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
|
||||
uint landblockId,
|
||||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
||||
{
|
||||
|
|
@ -6506,11 +6477,12 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||||
if (heightmapOnly is null) return null;
|
||||
return new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities
|
||||
}
|
||||
|
||||
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
|
||||
|
|
@ -6587,17 +6559,27 @@ public sealed class GameWindow : IDisposable
|
|||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||||
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
|
||||
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, envCellBuild));
|
||||
|
||||
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
|
||||
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
||||
// `merged` so entity GfxObj/Setup ids are known.
|
||||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||||
return new AcDream.Core.World.LoadedLandblock(
|
||||
baseLoaded.LandblockId,
|
||||
baseLoaded.Heightmap,
|
||||
merged,
|
||||
physicsDats);
|
||||
var completedEnvCells = envCellBuild.Build();
|
||||
if (_wbMeshAdapter?.MeshManager is { } meshManager)
|
||||
{
|
||||
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
|
||||
completedEnvCells,
|
||||
meshManager);
|
||||
}
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
baseLoaded.LandblockId,
|
||||
baseLoaded.Heightmap,
|
||||
merged,
|
||||
physicsDats),
|
||||
completedEnvCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -6898,13 +6880,18 @@ public sealed class GameWindow : IDisposable
|
|||
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
|
||||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
|
||||
/// so ApplyLoadedTerrain can drain + upload them on the render thread.
|
||||
/// Portal cells and drawable shell placements are accumulated in the
|
||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||
/// cannot observe this landblock until the streaming completion carries the
|
||||
/// finished transaction to ApplyLoadedTerrain.
|
||||
///
|
||||
/// Ported from pre-streaming preload lines 407-565.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
|
||||
uint landblockId, int lbX, int lbY)
|
||||
uint landblockId,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
if (_dats is null) return result;
|
||||
|
|
@ -6976,12 +6963,12 @@ public sealed class GameWindow : IDisposable
|
|||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||||
{
|
||||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||||
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
|
||||
// _pendingCellMeshes (still populated for any consumer) and the physics
|
||||
// data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes).
|
||||
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
|
||||
// drawable-geometry predicate; the actual shell placement is now owned
|
||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
||||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
|
||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
|
|
@ -7007,44 +6994,20 @@ public sealed class GameWindow : IDisposable
|
|||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
|
||||
// before the flood runs; the cell-build transaction reads portals, NOT
|
||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
|
||||
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
|
||||
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
|
||||
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
|
||||
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
|
||||
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
|
||||
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
|
||||
// un-cached connector 0x014D — so the side test culled the on-screen connector
|
||||
// portal and the grey clear showed through). Retail keeps the whole landblock
|
||||
// cell array resident for the sweep; a portals-only connector has an empty
|
||||
// collision BSP but its portals drive the transit. CacheCellStruct reads the
|
||||
// cellStruct directly, not the render sub-meshes.
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
if (cellSubMeshes.Count > 0)
|
||||
{
|
||||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||||
|
||||
// Phase A8: register the cell with EnvCellRenderer for rendering.
|
||||
// staticObjects is empty — cell stabs continue as separate WorldEntity
|
||||
// records via the dispatcher (see lines below for the unchanged stab path).
|
||||
_envCellRenderer?.RegisterCell(
|
||||
landblockId: landblockId,
|
||||
envCellId: envCellId,
|
||||
envCell: envCell,
|
||||
cellStruct: cellStruct,
|
||||
cellTransform: cellTransform,
|
||||
cellWorldPosition: cellOrigin,
|
||||
cellRotation: envCell.Position.Orientation,
|
||||
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
|
||||
}
|
||||
envCellBuild.AddCell(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellOrigin,
|
||||
physicsCellTransform,
|
||||
cellOrigin,
|
||||
cellTransform,
|
||||
hasDrawableGeometry: cellSubMeshes.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7207,9 +7170,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// this callback no longer pays that CPU cost.
|
||||
/// Must only be called from the render thread.
|
||||
/// </summary>
|
||||
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb,
|
||||
private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
|
||||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||||
{
|
||||
var lb = build.Landblock;
|
||||
if (_terrain is null || _dats is null) return;
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
|
||||
|
||||
|
|
@ -7227,7 +7191,7 @@ public sealed class GameWindow : IDisposable
|
|||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
if (_frameDiag)
|
||||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
ApplyLoadedTerrainLocked(lb, meshData);
|
||||
ApplyLoadedTerrainLocked(build, meshData);
|
||||
if (_frameDiag)
|
||||
{
|
||||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
|
|
@ -7235,164 +7199,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
|
||||
/// for render-thread registration. Called from the worker thread during
|
||||
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
|
||||
/// </summary>
|
||||
private void BuildLoadedCell(
|
||||
uint envCellId,
|
||||
DatReaderWriter.DBObjs.EnvCell envCell,
|
||||
DatReaderWriter.Types.CellStruct cellStruct,
|
||||
System.Numerics.Vector3 cellOrigin,
|
||||
System.Numerics.Matrix4x4 cellTransform)
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
// Compute local AABB from CellStruct vertices.
|
||||
var boundsMin = new System.Numerics.Vector3(float.MaxValue);
|
||||
var boundsMax = new System.Numerics.Vector3(float.MinValue);
|
||||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
var v = kvp.Value;
|
||||
var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||||
boundsMin = System.Numerics.Vector3.Min(boundsMin, pos);
|
||||
boundsMax = System.Numerics.Vector3.Max(boundsMax, pos);
|
||||
}
|
||||
if (boundsMin.X == float.MaxValue)
|
||||
{
|
||||
boundsMin = System.Numerics.Vector3.Zero;
|
||||
boundsMax = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
|
||||
// Build portal list and clip planes from CellPortals.
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<System.Numerics.Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId,
|
||||
portal.PolygonId,
|
||||
(ushort)portal.Flags,
|
||||
portal.OtherPortalId)); // Phase U.2b: dat back-link → reciprocal portal index (retail 433557)
|
||||
|
||||
// Build clip plane from the portal polygon.
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3)
|
||||
{
|
||||
// Get first 3 vertices in local space for the plane.
|
||||
System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero,
|
||||
p1 = System.Numerics.Vector3.Zero,
|
||||
p2 = System.Numerics.Vector3.Zero;
|
||||
bool found = true;
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
|
||||
p0 = new System.Numerics.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 System.Numerics.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 System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
else found = false;
|
||||
|
||||
if (found)
|
||||
{
|
||||
var normal = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -System.Numerics.Vector3.Dot(normal, p0);
|
||||
|
||||
// InsideSide from the dat PortalSide bit — retail PView::InitCell
|
||||
// (0x005a4b70) gates traversal on `iVar9 != portals->portal_side`, and
|
||||
// acdream's own physics path uses the same bit (CellTransit.cs:190).
|
||||
// The former AABB-centroid reconstruction mis-derived the interior side
|
||||
// for THIN connector cells: the connector's bounding-box center falls on
|
||||
// the wrong side of a portal near the cell edge, so the eye read as a
|
||||
// back-portal and the forward room was culled — the #186 grey flap at
|
||||
// 0xF6820118→0116 (retail draws 0116 from that root; cdb-confirmed).
|
||||
// PortalSide (Flags&2)==0 → the portal polygon normal points INTO the
|
||||
// owning cell → interior is the negative half → InsideSide=1. Diagnostic
|
||||
// Issue186…PortalSide_CentroidVsDatBit_AtGreyEye: the dat bit matches the
|
||||
// centroid on every portal of these cells EXCEPT the one #186 breaks.
|
||||
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);
|
||||
}
|
||||
|
||||
// Phase A8: capture the full polygon vertices in cell-local space
|
||||
// for the indoor stencil pipeline. Reads the same source as the
|
||||
// ClipPlane block above (polygon.VertexIds → cellStruct vertices).
|
||||
System.Numerics.Vector3[] polyVerts = System.Array.Empty<System.Numerics.Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||||
&& portalPoly.VertexIds.Count >= 3)
|
||||
{
|
||||
polyVerts = new System.Numerics.Vector3[portalPoly.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||||
{
|
||||
polyVerts[vi] = new System.Numerics.Vector3(
|
||||
pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
allResolved = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allResolved)
|
||||
polyVerts = System.Array.Empty<System.Numerics.Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polyVerts);
|
||||
}
|
||||
|
||||
// Phase U.4c: surface the stable PVS + seen-outside flag onto the render cell.
|
||||
// Both come straight off the dat EnvCell — no new parsing (PhysicsDataCache
|
||||
// already reads VisibleCells the same way; A8CellAudit reads the flag).
|
||||
uint lbPrefix = envCellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(lbPrefix | lowId);
|
||||
bool seenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
|
||||
|
||||
var loaded = new LoadedCell
|
||||
{
|
||||
CellId = envCellId,
|
||||
WorldPosition = cellOrigin,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
LocalBoundsMax = boundsMax,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons, // Phase A8
|
||||
VisibleCells = visibleCells, // Phase U.4c
|
||||
SeenOutside = seenOutside, // Phase U.4c
|
||||
};
|
||||
_pendingCells.Add(loaded);
|
||||
}
|
||||
|
||||
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb,
|
||||
private void ApplyLoadedTerrainLocked(
|
||||
AcDream.App.Streaming.LandblockBuild build,
|
||||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||||
{
|
||||
var lb = build.Landblock;
|
||||
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
|
||||
// _heightTable still needed for physics TerrainSurface below.
|
||||
if (_terrain is null || _dats is null || _heightTable is null) return;
|
||||
|
|
@ -7425,13 +7236,20 @@ public sealed class GameWindow : IDisposable
|
|||
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
|
||||
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
|
||||
// Step 4: drain pending LoadedCells from the worker thread.
|
||||
// Also collect into a local dict for the BuildingLoader stamping pass below.
|
||||
var drainedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
|
||||
while (_pendingCells.TryTake(out var cell))
|
||||
// Commit the exact visibility-cell snapshot produced by THIS streaming
|
||||
// completion. The former global ConcurrentBag let one landblock drain
|
||||
// another landblock's cells and exposed partial builds to the flood.
|
||||
var committedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
|
||||
if (build.EnvCells is { } envCellBuild)
|
||||
{
|
||||
_cellVisibility.AddCell(cell);
|
||||
drainedCells[cell.CellId] = cell;
|
||||
if (envCellBuild.LandblockId != lb.LandblockId)
|
||||
throw new InvalidOperationException(
|
||||
$"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " +
|
||||
$"landblock 0x{lb.LandblockId:X8}.");
|
||||
|
||||
_cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells);
|
||||
foreach (var cell in envCellBuild.VisibilityCells)
|
||||
committedCells[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
// Compute the per-landblock AABB for frustum culling. XY from the
|
||||
|
|
@ -7493,6 +7311,19 @@ public sealed class GameWindow : IDisposable
|
|||
// Transform CellStruct vertices from cell-local to world space.
|
||||
var rot = envCell.Position.Orientation;
|
||||
var cellOriginWorld = envCell.Position.Origin + origin;
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(rot) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld);
|
||||
|
||||
// Commit live physics-cache state on the render thread with
|
||||
// the same completed landblock transaction. The worker only
|
||||
// builds private output; it no longer mutates cell registries.
|
||||
_physicsDataCache.CacheCellStruct(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellTransform);
|
||||
|
||||
var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>(
|
||||
cellStruct.VertexArray.Vertices.Count);
|
||||
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
|
||||
|
|
@ -7655,16 +7486,9 @@ public sealed class GameWindow : IDisposable
|
|||
// surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// Merge: previously-loaded cells + freshly-drained cells.
|
||||
// drainedCells wins on key collision (it has the same instance
|
||||
// anyway — both dicts reference the same LoadedCell objects).
|
||||
var lbStampCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>(drainedCells);
|
||||
uint lbPrefix = (lb.LandblockId >> 16) & 0xFFFFu;
|
||||
foreach (var c in _cellVisibility.GetCellsForLandblock(lbPrefix))
|
||||
{
|
||||
if (!lbStampCells.ContainsKey(c.CellId))
|
||||
lbStampCells[c.CellId] = c;
|
||||
}
|
||||
// The streaming result now carries the complete landblock cell
|
||||
// set; no cross-frame merge or global-bag recovery is required.
|
||||
var lbStampCells = committedCells;
|
||||
// FIX 2026-05-28: normalize storage key to the cell-prefix
|
||||
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
|
||||
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
|
||||
|
|
@ -7681,15 +7505,13 @@ public sealed class GameWindow : IDisposable
|
|||
lbInfo, lb.LandblockId, lbStampCells);
|
||||
}
|
||||
|
||||
// Phase A8: finalize EnvCellRenderer's per-landblock instance store.
|
||||
// Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds,
|
||||
// populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
|
||||
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
|
||||
if (build.EnvCells is { } renderBuild)
|
||||
_envCellRenderer?.CommitLandblock(renderBuild);
|
||||
}
|
||||
|
||||
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
|
||||
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
|
||||
// (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
|
||||
// and the legacy cell-mesh upload path are retired with InstancedMeshRenderer.
|
||||
// Cache GfxObj physics data (BSP trees) for the physics engine — this
|
||||
// loop is physics-only, not renderer-side.
|
||||
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
|
||||
|
|
@ -7703,10 +7525,6 @@ public sealed class GameWindow : IDisposable
|
|||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||||
}
|
||||
}
|
||||
// Drain _pendingCellMeshes to prevent unbounded accumulation.
|
||||
// The data is no longer consumed (WB handles EnvCell geometry through
|
||||
// its own pipeline), but the worker thread still populates this dict.
|
||||
_pendingCellMeshes.Clear();
|
||||
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
|
||||
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
|
||||
|
||||
|
|
@ -8202,17 +8020,6 @@ public sealed class GameWindow : IDisposable
|
|||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||||
observerCy = (int)(cellLb & 0xFFu);
|
||||
}
|
||||
// Consume the login-spawn far-recenter flag (network thread → render
|
||||
// thread): drop the stale startup window + null the region so this
|
||||
// Tick re-bootstraps the whole window fresh around the spawn origin.
|
||||
// Same mechanism the outdoor-teleport path uses (line ~5725). Fixes
|
||||
// the cold-spawn streaming hole (resident-but-never-loaded landblocks
|
||||
// the RecenterTo diff skipped as already-resident).
|
||||
if (_pendingForceReloadWindow)
|
||||
{
|
||||
_pendingForceReloadWindow = false;
|
||||
_streamingController.ForceReloadWindow();
|
||||
}
|
||||
_streamingController.Tick(observerCx, observerCy, insideDungeon);
|
||||
|
||||
// Re-inject persistent entities rescued from unloaded landblocks
|
||||
|
|
@ -13100,7 +12907,7 @@ public sealed class GameWindow : IDisposable
|
|||
// a real dungeon (collapse streaming to its single landblock) from a building
|
||||
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
|
||||
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
|
||||
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
|
||||
// the hydration path (EnvCellLandblockBuildBuilder) and as the physics
|
||||
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
|
||||
// decision matches the eventual gate decision exactly. Returns false when the dat
|
||||
// lacks the cell (out-of-range index / missing record) so we never collapse on a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue