fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
|
|
@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable
|
|||
_streamer.Start();
|
||||
|
||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
||||
enqueueUnload: _streamer.EnqueueUnload,
|
||||
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
|
||||
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||
drainCompletions: _streamer.DrainCompletions,
|
||||
applyTerrain: ApplyLoadedTerrain,
|
||||
state: _worldState,
|
||||
|
|
@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable
|
|||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||||
},
|
||||
demoteNearLayer: DemoteLoadedLandblockToTerrain,
|
||||
// #138: restore retained server objects when a landblock reloads
|
||||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
||||
// objects it thinks we still know, so we re-project them ourselves.
|
||||
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
|
||||
onLandblockLoaded: RehydrateServerEntitiesForLandblock,
|
||||
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
|
||||
// A.5 T22.5: apply max-completions from resolved quality.
|
||||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
||||
|
||||
|
|
@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// worldReady for the TAS transit: is the player's teleport destination resident so we
|
||||
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
|
||||
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
|
||||
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
||||
/// single destination landblock — so portal space exits onto a loaded, collidable world
|
||||
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
|
||||
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
|
||||
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
|
||||
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
|
||||
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
|
||||
/// load. acdream's async equivalent must join the render publication barrier
|
||||
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
|
||||
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
|
||||
/// destinations require both domains for the priority near ring. An impossible claim
|
||||
/// returns true so the TAS stops holding and the forced placement surfaces the failure
|
||||
/// loudly rather than holding forever.
|
||||
/// </summary>
|
||||
|
|
@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
|
||||
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
|
||||
destCell,
|
||||
renderRadius) == true;
|
||||
return indoor
|
||||
? _physicsEngine.IsSpawnCellReady(destCell)
|
||||
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
||||
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
|
||||
: renderReady
|
||||
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
||||
}
|
||||
|
||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||
|
|
@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable
|
|||
completedEnvCells);
|
||||
}
|
||||
|
||||
private void EnsureEnvCellMeshesAfterPin(
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
|
||||
{
|
||||
if (_wbMeshAdapter?.MeshManager is { } meshManager)
|
||||
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
||||
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
||||
|
|
@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable
|
|||
(lbY - _liveCenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
|
||||
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
|
||||
// Both must go into our ID so landblocks don't collide.
|
||||
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
|
||||
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
|
||||
// Per-landblock id namespace. The top nibble identifies procedural
|
||||
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
|
||||
// DAT-generated landblocks from overflowing into a neighbour's range.
|
||||
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
|
||||
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
||||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||||
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
|
||||
uint localIndex = 0;
|
||||
uint sceneryCounter = 0;
|
||||
|
||||
foreach (var spawn in spawns)
|
||||
{
|
||||
|
|
@ -7836,13 +7846,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (localIndex > 0xFFu)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = sceneryIdBase + localIndex++,
|
||||
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
|
||||
lbXByte,
|
||||
lbYByte,
|
||||
ref sceneryCounter),
|
||||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||||
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
||||
Rotation = spawn.Rotation,
|
||||
|
|
@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retire the Near-only presentation and collision layer while preserving
|
||||
/// the terrain slot/surface used by Far streaming. StreamingController calls
|
||||
/// this before GpuWorldState drops static entities so owner-based resources
|
||||
/// can be released exactly once.
|
||||
/// </summary>
|
||||
private void DemoteLoadedLandblockToTerrain(uint landblockId)
|
||||
{
|
||||
if (_worldState.TryGetLandblock(landblockId, out var landblock))
|
||||
{
|
||||
foreach (var entity in landblock!.Entities)
|
||||
{
|
||||
if (entity.ServerGuid != 0)
|
||||
continue;
|
||||
_lightingSink?.UnregisterOwner(entity.Id);
|
||||
_translucencyFades.ClearEntity(entity.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_physicsEngine.DemoteLandblockToTerrain(landblockId);
|
||||
_cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu);
|
||||
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
|
||||
_envCellRenderer?.RemoveLandblock(landblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
|
||||
/// whenever a new landblock's terrain + entities are ready for GPU upload.
|
||||
|
|
@ -8568,7 +8602,7 @@ public sealed class GameWindow : IDisposable
|
|||
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
|
||||
// collision covers the whole structure; the mesh-AABB-fallback
|
||||
// path below is for canopy-only-BSP procedural scenery
|
||||
// (0x80XXYY00+n) and produces a redundant 1.5m-clamped
|
||||
// (0x8XXYYIII) and produces a redundant 1.5m-clamped
|
||||
// invisible disc at the stab's mesh origin — the user-reported
|
||||
// "thin air" collision inside cottages. Gate the fallback to
|
||||
// exclude stabs. Spec:
|
||||
|
|
@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable
|
|||
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
||||
continue;
|
||||
|
||||
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
|
||||
// no PartArray animation and no physics integration, but
|
||||
// PositionManager::adjust_offset plus the manager tail still run.
|
||||
// Scripts and particles tick in their shared passes below.
|
||||
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
|
||||
{
|
||||
// Hidden suppresses the PartArray/mesh, not the effect owner.
|
||||
// Remote PositionManager composition already ran in the
|
||||
// unconditional live-entity pass; publish again for the local
|
||||
// player and for retained objects without remote motion.
|
||||
// Frozen part-local poses remain unchanged until UnHide.
|
||||
_effectPoses.UpdateRoot(ae.Entity);
|
||||
continue;
|
||||
}
|
||||
// Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose
|
||||
// composition. set_hidden -> HandleEnterWorld can move CSequence's
|
||||
// cursor from a finished link/action to the first cyclic node; the
|
||||
// next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0)
|
||||
// performs UpdatePositionInternal and then reaches set_frame ->
|
||||
// CPartArray::UpdateParts, publishing that newly-current pose before
|
||||
// the zero-time Hidden PES creates its silhouette particles.
|
||||
bool hidden = serverGuid != 0
|
||||
&& _liveEntities?.IsHidden(serverGuid) == true;
|
||||
|
||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||
|
|
@ -10774,7 +10803,8 @@ public sealed class GameWindow : IDisposable
|
|||
// runaway when the sequencer's velocity and the server's reality
|
||||
// disagree (e.g. server is rubber-banding the entity). Retail
|
||||
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
||||
if (ae.Sequencer is not null
|
||||
if (!hidden
|
||||
&& ae.Sequencer is not null
|
||||
&& serverGuid != 0
|
||||
&& serverGuid != _playerServerGuid
|
||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
||||
|
|
@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable
|
|||
rmDiag.LastSeqStateLogTime = nowSec;
|
||||
}
|
||||
}
|
||||
seqFrames = ae.Sequencer.Advance(dt);
|
||||
seqFrames = hidden
|
||||
? ae.Sequencer.SampleCurrentPose()
|
||||
: ae.Sequencer.Advance(dt);
|
||||
|
||||
// Capture hooks now, but deliver them only after every final
|
||||
// part transform and equipped-child root has been published.
|
||||
|
|
@ -10847,21 +10879,25 @@ public sealed class GameWindow : IDisposable
|
|||
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
|
||||
// reads this frame's pose, never the previous frame's pose.
|
||||
// AnimationDone/UseTime remain paired with the deferred drain.
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
if (!hidden)
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Legacy path (entities without a MotionTable / sequencer).
|
||||
int span = ae.HighFrame - ae.LowFrame;
|
||||
if (span <= 0) continue;
|
||||
ae.CurrFrame += dt * ae.Framerate;
|
||||
if (ae.CurrFrame > ae.HighFrame)
|
||||
if (span <= 0 && !hidden) continue;
|
||||
if (!hidden)
|
||||
{
|
||||
float over = ae.CurrFrame - ae.LowFrame;
|
||||
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
|
||||
ae.CurrFrame += dt * ae.Framerate;
|
||||
if (ae.CurrFrame > ae.HighFrame)
|
||||
{
|
||||
float over = ae.CurrFrame - ae.LowFrame;
|
||||
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
|
||||
}
|
||||
else if (ae.CurrFrame < ae.LowFrame)
|
||||
ae.CurrFrame = ae.LowFrame;
|
||||
}
|
||||
else if (ae.CurrFrame < ae.LowFrame)
|
||||
ae.CurrFrame = ae.LowFrame;
|
||||
}
|
||||
|
||||
int partCount = ae.PartTemplate.Count;
|
||||
|
|
@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable
|
|||
_streamer.Start();
|
||||
|
||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
||||
enqueueUnload: _streamer.EnqueueUnload,
|
||||
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
|
||||
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||
drainCompletions: _streamer.DrainCompletions,
|
||||
applyTerrain: ApplyLoadedTerrain,
|
||||
state: _worldState,
|
||||
|
|
@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable
|
|||
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
||||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||||
});
|
||||
},
|
||||
demoteNearLayer: DemoteLoadedLandblockToTerrain,
|
||||
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
|
||||
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
|
||||
|
||||
Console.WriteLine(
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <para>
|
||||
/// <b>Key composition:</b> entries are keyed by the tuple
|
||||
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
|
||||
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
|
||||
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
|
||||
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
|
||||
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
|
||||
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
|
||||
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
|
||||
/// uncovered that older hydration IDs were not globally unique across all
|
||||
/// static-entity paths: their former byte-aligned counters overflowed at
|
||||
/// more than 256 items per landblock and wrapped into the <c>lbY</c> byte.
|
||||
/// The current <c>0x8XXYYIII</c> scenery and <c>0x4XXYYIII</c> interior
|
||||
/// allocators reserve 12-bit counters and fail before aliasing. Keying
|
||||
/// by the tuple is correct-by-construction ONLY when the hint identifies the
|
||||
/// entity's OWNING landblock — callers must derive it via
|
||||
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
|
||||
|
|
@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache
|
|||
/// <summary>
|
||||
/// Look up an entity's cached classification. Keyed by both
|
||||
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
|
||||
/// disambiguate entities whose Ids collide across landblocks (e.g.,
|
||||
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
|
||||
/// preserve defensive isolation if a future hydration path introduces an
|
||||
/// entity-id collision across landblocks.
|
||||
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
|
||||
/// <paramref name="entry"/> set to <c>null</c> on miss.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -9,4 +9,19 @@ public interface IWbMeshAdapter
|
|||
{
|
||||
void IncrementRefCount(ulong id);
|
||||
void DecrementRefCount(ulong id);
|
||||
|
||||
/// <summary>
|
||||
/// Pins render data whose CPU preparation is owned by a specialized
|
||||
/// pipeline (for example synthetic EnvCell geometry). Unlike ordinary
|
||||
/// registration, production implementations must not start a generic
|
||||
/// GfxObj decode for this id.
|
||||
/// </summary>
|
||||
void PinPreparedRenderData(ulong id) => IncrementRefCount(id);
|
||||
|
||||
/// <summary>
|
||||
/// True once the mesh has crossed the render-thread upload barrier and is
|
||||
/// available to draw. The default keeps lifecycle-only test doubles
|
||||
/// source-compatible; production adapters override it.
|
||||
/// </summary>
|
||||
bool IsRenderDataReady(ulong id) => true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <para>
|
||||
/// On load: walks the landblock's atlas-tier entities, collects unique
|
||||
/// GfxObj ids from their <c>MeshRefs</c>, calls
|
||||
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
|
||||
/// unload can match the load 1:1.
|
||||
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
|
||||
/// id without starting generic GfxObj decode. Snapshots both id-sets per
|
||||
/// landblock so unload can match the load 1:1.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
|
||||
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
|
||||
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
|
||||
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
|
||||
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
|
||||
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
|
||||
/// be called from a single streaming thread (the same thread that fires
|
||||
/// AddLandblock / RemoveLandblock events).
|
||||
/// Thread safety: the internal snapshots are intentionally not synchronized.
|
||||
/// <see cref="AcDream.App.Streaming.GpuWorldState"/> invokes every load, unload, and readiness query
|
||||
/// on the owning render/update thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LandblockSpawnAdapter
|
||||
|
|
@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter
|
|||
// Maps landblock id → unique GfxObj ids registered for that landblock.
|
||||
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
|
||||
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
|
||||
// than generic GfxObj loading, but still require explicit lifetime pins.
|
||||
// Keep their synthetic ids separate so registration uses the no-decode pin.
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
|
||||
|
||||
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
||||
{
|
||||
|
|
@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter
|
|||
/// unique atlas-tier GfxObj id that has not already been registered for
|
||||
/// this landblock.
|
||||
/// </summary>
|
||||
public void OnLandblockLoaded(LoadedLandblock landblock)
|
||||
public void OnLandblockLoaded(
|
||||
LoadedLandblock landblock,
|
||||
IEnumerable<ulong>? additionalReadinessIds = null)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
|
|
@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter
|
|||
{
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var id in unique)
|
||||
{
|
||||
if (registered.Add(id))
|
||||
_adapter.IncrementRefCount(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var id in unique)
|
||||
if (!_additionalReadinessIdsByLandblock.TryGetValue(
|
||||
landblock.LandblockId,
|
||||
out var additional))
|
||||
{
|
||||
if (registered.Add(id))
|
||||
_adapter.IncrementRefCount(id);
|
||||
additional = new HashSet<ulong>();
|
||||
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
|
||||
}
|
||||
if (additionalReadinessIds is not null)
|
||||
{
|
||||
foreach (var id in additionalReadinessIds)
|
||||
if (additional.Add(id))
|
||||
_adapter.PinPreparedRenderData(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True only after every render mesh required by this published landblock
|
||||
/// is drawable. This includes both ref-counted static GfxObjs and EnvCell
|
||||
/// shell geometry prepared by the independent indoor pipeline.
|
||||
/// </summary>
|
||||
public bool IsLandblockRenderReady(uint landblockId)
|
||||
{
|
||||
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|
||||
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var id in registered)
|
||||
if (!_adapter.IsRenderDataReady(id))
|
||||
return false;
|
||||
foreach (var id in additional)
|
||||
if (!_adapter.IsRenderDataReady(id))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter
|
|||
{
|
||||
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
||||
foreach (var id in unique) _adapter.DecrementRefCount(id);
|
||||
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
|
||||
foreach (var id in additional) _adapter.DecrementRefCount(id);
|
||||
_idsByLandblock.Remove(landblockId);
|
||||
_additionalReadinessIdsByLandblock.Remove(landblockId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
Normal file
117
src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Collections.Concurrent;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
|
||||
/// in-flight at a time; a retry retains ownership until upload succeeds or
|
||||
/// exhausts its retry budget.
|
||||
/// </summary>
|
||||
internal sealed class MeshUploadStagingQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
|
||||
|
||||
public bool Stage(ObjectMeshData data)
|
||||
{
|
||||
if (!_ownedIds.TryAdd(data.ObjectId, 0))
|
||||
return false;
|
||||
|
||||
data.UploadAttempts = 0;
|
||||
_queue.Enqueue(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
|
||||
|
||||
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
|
||||
|
||||
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical render-data ownership, deliberately independent from GPU upload
|
||||
/// existence. Completing or repeating an upload never manufactures an owner;
|
||||
/// only a live entity/landblock pin changes this count.
|
||||
/// </summary>
|
||||
internal sealed class MeshOwnershipCounter
|
||||
{
|
||||
private readonly ConcurrentDictionary<ulong, int> _counts = new();
|
||||
|
||||
public int Acquire(ulong id) => _counts.AddOrUpdate(id, 1, (_, count) => count + 1);
|
||||
|
||||
public int Release(ulong id) => _counts.AddOrUpdate(id, 0, (_, count) => Math.Max(0, count - 1));
|
||||
|
||||
public int Count(ulong id) => _counts.TryGetValue(id, out int count) ? count : 0;
|
||||
|
||||
public bool IsOwned(ulong id) => Count(id) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether freshly uploaded data has a live owner. This is a query,
|
||||
/// not an acquire: upload existence and logical ownership are independent.
|
||||
/// </summary>
|
||||
public bool MarkUploadComplete(ulong id) => IsOwned(id);
|
||||
|
||||
public bool Remove(ulong id) => _counts.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded prepared-mesh cache. A cache hit is not merely a CPU return value:
|
||||
/// when GPU render data is absent it re-stages that mesh for upload.
|
||||
/// </summary>
|
||||
internal sealed class CpuMeshUploadCache
|
||||
{
|
||||
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
|
||||
private readonly LinkedList<ulong> _lru = new();
|
||||
private readonly int _capacity;
|
||||
|
||||
public CpuMeshUploadCache(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_capacity = capacity;
|
||||
}
|
||||
|
||||
public bool TryGetAndStage(
|
||||
ulong id,
|
||||
MeshUploadStagingQueue staging,
|
||||
out ObjectMeshData? data)
|
||||
{
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.TryGetValue(id, out data))
|
||||
return false;
|
||||
_lru.Remove(id);
|
||||
_lru.AddLast(id);
|
||||
staging.Stage(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Store(ObjectMeshData data)
|
||||
{
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
|
||||
{
|
||||
ulong oldest = _lru.First!.Value;
|
||||
_lru.RemoveFirst();
|
||||
_data.Remove(oldest);
|
||||
}
|
||||
|
||||
_data[data.ObjectId] = data;
|
||||
_lru.Remove(data.ObjectId);
|
||||
_lru.AddLast(data.ObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_data)
|
||||
{
|
||||
_data.Clear();
|
||||
_lru.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
public bool IsDisposed { get; private set; }
|
||||
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
|
||||
private readonly ConcurrentDictionary<ulong, int> _usageCount = new();
|
||||
private readonly MeshOwnershipCounter _ownership = new();
|
||||
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
|
||||
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
|
||||
|
||||
|
|
@ -119,12 +119,10 @@ namespace AcDream.App.Rendering.Wb {
|
|||
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
|
||||
|
||||
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
|
||||
private readonly Dictionary<ulong, ObjectMeshData> _cpuMeshCache = new();
|
||||
private readonly LinkedList<ulong> _cpuLruList = new();
|
||||
private readonly int _maxCpuCacheSize = 100;
|
||||
private readonly CpuMeshUploadCache _cpuMeshCache;
|
||||
|
||||
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
|
||||
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
|
||||
private readonly MeshUploadStagingQueue _stagedMeshData = new();
|
||||
|
||||
/// <summary>#125: how many times a failed GL upload is re-staged before
|
||||
/// giving up loudly. Small — a transient GL error clears on the next
|
||||
|
|
@ -141,17 +139,28 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
|
||||
/// </summary>
|
||||
public bool UploadOrRequeue(ObjectMeshData meshData) {
|
||||
if (UploadMeshData(meshData) is not null)
|
||||
if (UploadMeshData(meshData) is not null) {
|
||||
_stagedMeshData.Complete(meshData.ObjectId);
|
||||
return false; // success (incl. legitimate 0-vertex → empty render data)
|
||||
if (HasRenderData(meshData.ObjectId))
|
||||
}
|
||||
if (HasRenderData(meshData.ObjectId)) {
|
||||
_stagedMeshData.Complete(meshData.ObjectId);
|
||||
return false; // raced to present by another path
|
||||
}
|
||||
meshData.UploadAttempts++;
|
||||
if (meshData.UploadAttempts < MaxUploadRetries)
|
||||
return true; // re-stage for next frame
|
||||
_stagedMeshData.Complete(meshData.ObjectId);
|
||||
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
|
||||
return false;
|
||||
}
|
||||
|
||||
internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) =>
|
||||
_stagedMeshData.TryDequeue(out data);
|
||||
|
||||
internal void RequeueStagedMeshData(ObjectMeshData data) =>
|
||||
_stagedMeshData.Requeue(data);
|
||||
|
||||
public GlobalMeshBuffer? GlobalBuffer { get; }
|
||||
private readonly bool _useModernRendering;
|
||||
|
||||
|
|
@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
// did — immediate enqueue, surviving a later throw in the same
|
||||
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
|
||||
// extractor's up-to-4 concurrent decode workers.
|
||||
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data));
|
||||
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
|
||||
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data));
|
||||
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
|
||||
if (_useModernRendering) {
|
||||
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
|
||||
|
|
@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// </summary>
|
||||
public ObjectRenderData? GetRenderData(ulong id) {
|
||||
if (_renderData.TryGetValue(id, out var data)) {
|
||||
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
|
||||
_ownership.Acquire(id);
|
||||
|
||||
if (data.IsSetup) {
|
||||
foreach (var (partId, _) in data.SetupParts) {
|
||||
|
|
@ -223,7 +233,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// Increment reference count for an object (e.g. when a landblock starts using it).
|
||||
/// </summary>
|
||||
public void IncrementRefCount(ulong id) {
|
||||
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
|
||||
_ownership.Acquire(id);
|
||||
lock (_lruList) {
|
||||
_lruList.Remove(id);
|
||||
}
|
||||
|
|
@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// Decrement reference count and unload GPU resources if no longer needed.
|
||||
/// </summary>
|
||||
public void DecrementRefCount(ulong id) {
|
||||
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
|
||||
var newCount = _ownership.Release(id);
|
||||
if (newCount <= 0) {
|
||||
// Instead of unloading, move to LRU
|
||||
lock (_lruList) {
|
||||
|
|
@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// Decrement reference count and unload if no longer needed.
|
||||
/// </summary>
|
||||
public void ReleaseRenderData(ulong id) {
|
||||
if (_usageCount.TryGetValue(id, out var count) && count > 0) {
|
||||
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
|
||||
if (_ownership.IsOwned(id)) {
|
||||
var newCount = _ownership.Release(id);
|
||||
if (newCount <= 0) {
|
||||
// Instead of unloading, move to LRU
|
||||
lock (_lruList) {
|
||||
|
|
@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb {
|
|||
var idToEvict = _lruList.First!.Value;
|
||||
_lruList.RemoveFirst();
|
||||
|
||||
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
|
||||
if (!_ownership.IsOwned(idToEvict)) {
|
||||
UnloadObject(idToEvict);
|
||||
_usageCount.TryRemove(idToEvict, out _);
|
||||
_ownership.Remove(idToEvict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb {
|
|||
var idToEvict = _lruList.First!.Value;
|
||||
_lruList.RemoveFirst();
|
||||
|
||||
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
|
||||
if (!_ownership.IsOwned(idToEvict)) {
|
||||
UnloadObject(idToEvict);
|
||||
_usageCount.TryRemove(idToEvict, out _);
|
||||
_ownership.Remove(idToEvict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear CPU mesh cache
|
||||
lock (_cpuMeshCache) {
|
||||
_cpuMeshCache.Clear();
|
||||
_cpuLruList.Clear();
|
||||
}
|
||||
_cpuMeshCache.Clear();
|
||||
}
|
||||
|
||||
public struct EnvCellGeomRequest {
|
||||
|
|
@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
|
||||
|
||||
// Check CPU cache first
|
||||
lock (_cpuMeshCache) {
|
||||
if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) {
|
||||
_cpuLruList.Remove(geomId);
|
||||
_cpuLruList.AddLast(geomId);
|
||||
return Task.FromResult<ObjectMeshData?>(cachedData);
|
||||
}
|
||||
}
|
||||
if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData))
|
||||
return Task.FromResult(cachedData);
|
||||
|
||||
// Return existing task if already running or queued
|
||||
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
|
||||
|
|
@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
|
||||
|
||||
// Check CPU cache first
|
||||
lock (_cpuMeshCache) {
|
||||
if (_cpuMeshCache.TryGetValue(id, out var cachedData)) {
|
||||
_cpuLruList.Remove(id);
|
||||
_cpuLruList.AddLast(id);
|
||||
return Task.FromResult<ObjectMeshData?>(cachedData);
|
||||
}
|
||||
}
|
||||
if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData))
|
||||
return Task.FromResult(cachedData);
|
||||
|
||||
// Return existing task if already running or queued
|
||||
if (_preparationTasks.TryGetValue(id, out var existing)) {
|
||||
|
|
@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
|
||||
}
|
||||
if (data != null) {
|
||||
lock (_cpuMeshCache) {
|
||||
if (_cpuMeshCache.Count >= _maxCpuCacheSize) {
|
||||
var oldest = _cpuLruList.First!.Value;
|
||||
_cpuLruList.RemoveFirst();
|
||||
_cpuMeshCache.Remove(oldest);
|
||||
}
|
||||
_cpuMeshCache[id] = data;
|
||||
_cpuLruList.AddLast(id);
|
||||
}
|
||||
_stagedMeshData.Enqueue(data);
|
||||
_cpuMeshCache.Store(data);
|
||||
_stagedMeshData.Stage(data);
|
||||
}
|
||||
tcs.TrySetResult(data);
|
||||
}
|
||||
|
|
@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
try {
|
||||
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
|
||||
_preparationTasks.TryRemove(meshData.ObjectId, out _);
|
||||
if (existing.IsSetup) {
|
||||
foreach (var (partId, _) in existing.SetupParts) {
|
||||
IncrementRefCount(partId);
|
||||
lock (_lruList) {
|
||||
_lruList.Remove(partId);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Increment ref counts for all textures in this GfxObj
|
||||
foreach (var batch in existing.Batches) {
|
||||
if (batch.Atlas != null) {
|
||||
batch.Atlas.AddTexture(batch.Key, Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
}
|
||||
IncrementRefCount(meshData.ObjectId);
|
||||
lock (_lruList) {
|
||||
_lruList.Remove(meshData.ObjectId);
|
||||
}
|
||||
UpdateLruAfterUpload(meshData.ObjectId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
|
@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
MemorySize = 1024 // Small overhead for the setup itself
|
||||
};
|
||||
_renderData.TryAdd(meshData.ObjectId, data);
|
||||
IncrementRefCount(meshData.ObjectId);
|
||||
_currentGpuMemory += data.MemorySize;
|
||||
|
||||
// Increment ref counts for all parts
|
||||
|
|
@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
IncrementRefCount(partId);
|
||||
}
|
||||
|
||||
UpdateLruAfterUpload(meshData.ObjectId);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb {
|
|||
renderData.DIDDegrade = meshData.DIDDegrade;
|
||||
renderData.SelectionSphere = meshData.SelectionSphere;
|
||||
_renderData.TryAdd(meshData.ObjectId, renderData);
|
||||
IncrementRefCount(meshData.ObjectId);
|
||||
_currentGpuMemory += renderData.MemorySize;
|
||||
UpdateLruAfterUpload(meshData.ObjectId);
|
||||
|
||||
// Clear texture data after upload to save RAM
|
||||
foreach (var batchList in meshData.TextureBatches.Values) {
|
||||
foreach (var batch in batchList) {
|
||||
batch.TextureData = Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
// Keep the bounded CPU cache's texture payload intact. GPU LRU
|
||||
// eviction may need to upload this same prepared mesh again;
|
||||
// clearing these bytes made a cache hit produce blank textures.
|
||||
return renderData;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
|
|
@ -636,6 +604,14 @@ namespace AcDream.App.Rendering.Wb {
|
|||
}
|
||||
}
|
||||
|
||||
private void UpdateLruAfterUpload(ulong id) {
|
||||
lock (_lruList) {
|
||||
_lruList.Remove(id);
|
||||
if (!_ownership.MarkUploadComplete(id))
|
||||
_lruList.AddLast(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets bounding box for an object (for frustum culling).
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
return _meshManager.TryGetRenderData(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsRenderDataReady(ulong id)
|
||||
{
|
||||
// An uninitialized adapter owns no render pipeline, so it must not
|
||||
// manufacture a readiness wait that can never complete. The modern
|
||||
// production path is always initialized at startup.
|
||||
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
|
|
@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
_meshManager.DecrementRefCount(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PinPreparedRenderData(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return;
|
||||
// EnvCell geometry has its own schema-aware preparation request.
|
||||
// Only establish lifecycle ownership here; IncrementRefCount's normal
|
||||
// generic GfxObj preparation would decode the synthetic id incorrectly.
|
||||
_meshManager.IncrementRefCount(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
|
||||
/// USE. Registration-time re-arming was insufficient — a preparation
|
||||
|
|
@ -252,14 +271,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
||||
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
||||
List<ObjectMeshData>? requeue = null;
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
|
||||
{
|
||||
if (meshData is null)
|
||||
continue;
|
||||
if (_meshManager.UploadOrRequeue(meshData))
|
||||
(requeue ??= new()).Add(meshData);
|
||||
}
|
||||
if (requeue is not null)
|
||||
foreach (var m in requeue)
|
||||
_meshManager.StagedMeshData.Enqueue(m);
|
||||
_meshManager.RequeueStagedMeshData(m);
|
||||
|
||||
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
||||
var pendingBefore = texProbe
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue