Move Region/environment, mandatory modern rendering, terrain, WB, texture, and sampler construction behind the typed Phase-4 composition boundary. Give every fallible GL constructor prefix retryable ownership so partial startup failure cannot leak or replay resource deletion while preserving the accepted render path and DAT inputs. Co-authored-by: Codex <codex@openai.com>
676 lines
30 KiB
C#
676 lines
30 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.Rendering;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Single seam between acdream and WB's render pipeline. Owns the
|
|
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
|
|
/// so the rest of the renderer doesn't need to know about WB's types directly.
|
|
///
|
|
/// <para>
|
|
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
|
|
/// <see cref="IDatReaderWriter"/> facade — the separate
|
|
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|
{
|
|
internal const int MaximumUploadsPerFrame = 8;
|
|
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const int MaximumNewArraysPerFrame = 1;
|
|
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
|
|
// A GL buffer store is indivisible. The active vertex arena's explicit
|
|
// supported ceiling is therefore the truthful one-allocation bound; only
|
|
// its prefix copy is staged across frames.
|
|
internal const long MaximumBufferAllocationBytesPerFrame =
|
|
GlobalMeshBuffer.MaximumVertexBufferBytes;
|
|
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
|
|
internal const int MaximumNewBuffersPerFrame = 1;
|
|
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
|
|
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
|
|
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
|
|
internal const int MaximumSingleNewArrays = 16;
|
|
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
|
|
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
|
|
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
|
|
internal const int MaximumStaleDiscardsPerFrame = 64;
|
|
private readonly OpenGLGraphicsDevice? _graphicsDevice;
|
|
private readonly ObjectMeshManager? _meshManager;
|
|
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
|
|
private readonly IDatReaderWriter? _dats;
|
|
private readonly AcSurfaceMetadataTable _metadataTable = new();
|
|
private readonly HashSet<ulong> _metadataPopulated = new();
|
|
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
|
|
MaximumUploadsPerFrame,
|
|
MaximumUploadBytesPerFrame,
|
|
MaximumArrayAllocationBytesPerFrame,
|
|
MaximumMipmapBytesPerFrame,
|
|
MaximumNewArraysPerFrame,
|
|
MaximumBufferUploadBytesPerFrame,
|
|
MaximumBufferAllocationBytesPerFrame,
|
|
MaximumBufferCopyBytesPerFrame,
|
|
MaximumNewBuffersPerFrame,
|
|
MaximumSingleUploadBytes,
|
|
MaximumSingleArrayAllocationBytes,
|
|
MaximumSingleMipmapBytes,
|
|
MaximumSingleNewArrays,
|
|
MaximumSingleBufferUploadBytes));
|
|
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
|
|
|
|
/// <summary>
|
|
/// True when this instance was created via <see cref="CreateUninitialized"/>;
|
|
/// all public methods no-op when uninitialized.
|
|
/// </summary>
|
|
private readonly bool _isUninitialized;
|
|
|
|
private bool _disposed;
|
|
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
|
|
internal int LastUploadCount { get; private set; }
|
|
internal long LastUploadBytes { get; private set; }
|
|
internal int LastStaleDiscardCount { get; private set; }
|
|
internal long LastArrayAllocationBytes { get; private set; }
|
|
internal long LastPlannedMipmapBytes { get; private set; }
|
|
internal int LastNewArrayCount { get; private set; }
|
|
internal long LastBufferUploadBytes { get; private set; }
|
|
internal long LastBufferAllocationBytes { get; private set; }
|
|
internal long LastBufferCopyBytes { get; private set; }
|
|
internal int LastNewBufferCount { get; private set; }
|
|
internal int LastMipmapArrayCount { get; private set; }
|
|
internal long LastMipmapBytes { get; private set; }
|
|
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
|
|
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
|
|
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
|
|
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
|
|
_meshManager?.CpuCacheDiagnostics ?? default;
|
|
|
|
/// <summary>
|
|
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
|
|
/// DAT facade → ObjectMeshManager.
|
|
/// </summary>
|
|
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
|
|
/// thread (construction runs GL queries; call from OnLoad).</param>
|
|
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
|
|
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
|
|
/// the rest of the client; read-only access from the render thread.</param>
|
|
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
|
|
/// NullLogger internally.</param>
|
|
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
|
|
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
|
|
{
|
|
}
|
|
|
|
internal WbMeshAdapter(
|
|
GL gl,
|
|
IDatReaderWriter dats,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(gl);
|
|
ArgumentNullException.ThrowIfNull(dats);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
|
|
_dats = dats;
|
|
_resourceRetirement = resourceRetirement;
|
|
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
|
|
OpenGLGraphicsDevice? graphicsDevice = null;
|
|
ObjectMeshManager? meshManager = null;
|
|
try
|
|
{
|
|
graphicsDevice = new OpenGLGraphicsDevice(
|
|
gl,
|
|
logger,
|
|
new DebugRenderSettings(),
|
|
resourceRetirement);
|
|
OpenGLGraphicsDevice ownedGraphicsDevice = graphicsDevice;
|
|
var graphicsDeviceRelease = new RetryableGpuResourceRelease(
|
|
ownedGraphicsDevice.Dispose,
|
|
() =>
|
|
{
|
|
ownedGraphicsDevice.ProcessGLQueue();
|
|
if (ownedGraphicsDevice.HasPendingGLWork)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"WB graphics-device construction cleanup still has queued GL work.");
|
|
}
|
|
});
|
|
resources.Add("WB graphics device", graphicsDeviceRelease.Run);
|
|
graphicsDevice.ParticleBatcher = new ParticleBatcher(graphicsDevice);
|
|
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
|
|
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
|
|
meshManager = new ObjectMeshManager(
|
|
graphicsDevice,
|
|
dats,
|
|
new ConsoleErrorLogger<ObjectMeshManager>());
|
|
resources.Add("WB object mesh manager", meshManager.Dispose);
|
|
resources.TransferAll();
|
|
}
|
|
catch (Exception constructionFailure)
|
|
{
|
|
resources.RollbackConstructionAndThrow(
|
|
"WbMeshAdapter construction failed and its WB/GL prefix did not cleanly roll back.",
|
|
constructionFailure);
|
|
}
|
|
|
|
_graphicsDevice = graphicsDevice;
|
|
_meshManager = meshManager;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal Console-backed logger that fires only on
|
|
/// <see cref="LogLevel.Error"/> and above. Format:
|
|
/// <code>[wb-error] <message>
|
|
/// [wb-error] <ExceptionType>: <ExceptionMessage>
|
|
/// [wb-error] at <frame> (up to 5 frames)</code>
|
|
/// Used to surface WB's silently-caught exceptions in
|
|
/// <c>ObjectMeshManager.PrepareMeshData</c>.
|
|
/// </summary>
|
|
private sealed class ConsoleErrorLogger<T> : ILogger<T>
|
|
{
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (!IsEnabled(logLevel)) return;
|
|
var message = formatter(state, exception);
|
|
Console.WriteLine($"[wb-error] {message}");
|
|
if (exception is not null)
|
|
{
|
|
Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
|
|
var stack = (exception.StackTrace ?? "")
|
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Take(5);
|
|
foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
|
|
}
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
private WbMeshAdapter()
|
|
{
|
|
// Uninitialized constructor — only for tests / flag-off cases where
|
|
// the caller wants a Dispose-safe no-op instance.
|
|
_isUninitialized = true;
|
|
}
|
|
|
|
/// <summary>Test/init helper — produces a Dispose-safe instance with no
|
|
/// underlying mesh manager. Public methods are all no-ops.</summary>
|
|
public static WbMeshAdapter CreateUninitialized() => new();
|
|
|
|
/// <summary>
|
|
/// The surface metadata side-table populated on each first
|
|
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
|
|
/// to determine translucency, luminosity, and fog behavior per batch.
|
|
/// </summary>
|
|
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
|
|
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-28): exposes the underlying <see cref="ObjectMeshManager"/>
|
|
/// so <c>EnvCellRenderer</c> can share the same global mesh buffer (VAO/VBO/IBO).
|
|
/// Returns null when the adapter is uninitialized.
|
|
/// </summary>
|
|
public ObjectMeshManager? MeshManager => _meshManager;
|
|
|
|
/// <summary>
|
|
/// Returns the WB render data for <paramref name="id"/>, or null if not
|
|
/// yet uploaded or if this adapter is uninitialized. Increments WB's
|
|
/// internal usage counter — use <see cref="TryGetRenderData"/> for
|
|
/// render-loop lookups that should not affect lifecycle.
|
|
/// </summary>
|
|
public ObjectRenderData? GetRenderData(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return null;
|
|
return _meshManager.GetRenderData(id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the WB render data for <paramref name="id"/> without
|
|
/// modifying reference counts. Returns null if the mesh is not yet
|
|
/// uploaded. Safe for render-loop lookups.
|
|
/// </summary>
|
|
public ObjectRenderData? TryGetRenderData(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return null;
|
|
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?.EnsureRenderDataReady(id) ?? false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void IncrementRefCount(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_meshManager.IncrementRefCount(id);
|
|
|
|
bool metadataClaimed = false;
|
|
try
|
|
{
|
|
metadataClaimed = _metadataPopulated.Add(id);
|
|
if (metadataClaimed)
|
|
PopulateMetadata(id);
|
|
|
|
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
|
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
|
|
// so the background workers actually decode the GfxObj. The result
|
|
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
|
|
// which Tick() drains onto the GPU. Until that completes,
|
|
// TryGetRenderData(id) returns null and the dispatcher silently
|
|
// skips the entity — standard streaming flicker.
|
|
//
|
|
// #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render
|
|
// data — NOT only on the first-ever registration. The old
|
|
// first-ever-only gate (`if (_metadataPopulated.Add(id))`) permanently
|
|
// lost any id whose initial decode was cancelled before completing
|
|
// (landblock unload → CancelStagedUploads during login/teleport
|
|
// churn) or whose upload was later LRU-evicted: every subsequent
|
|
// registration skipped Prepare, so the mesh stayed invisible for the
|
|
// session with zero log output — the dispatcher's slow path just
|
|
// counted meshMissing forever (issue #55's 1.45M/5s mountain was this
|
|
// bug's heartbeat). User-visible: the AAB3 tower staircase rendering
|
|
// partially or not at all depending on the session's landblock
|
|
// load/unload interleaving (#119/#128 "broken stairs"). Safe to call
|
|
// unconditionally when data is absent: PrepareMeshDataAsync early-outs
|
|
// on existing render data, returns the in-flight task when already
|
|
// pending, and dedups via _preparationTasks.
|
|
//
|
|
// isSetup: false — acdream's MeshRefs already carry expanded
|
|
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
|
// unused.
|
|
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
|
|
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
|
}
|
|
catch (Exception acquireFailure)
|
|
{
|
|
if (metadataClaimed)
|
|
{
|
|
_metadataPopulated.Remove(id);
|
|
_metadataTable.Remove(id);
|
|
}
|
|
|
|
// IncrementRefCount is a public ownership boundary. Metadata/DAT
|
|
// preparation happens after ObjectMeshManager commits its count,
|
|
// so compensate before reporting failure. ObjectMeshManager's
|
|
// decrement has a strong exception guarantee; if compensation
|
|
// itself fails, expose that the increment remains committed so a
|
|
// higher-level transactional owner can retain its held marker.
|
|
try
|
|
{
|
|
_meshManager.DecrementRefCount(id);
|
|
}
|
|
catch (Exception rollbackFailure)
|
|
{
|
|
throw new MeshReferenceMutationException(
|
|
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
|
|
mutationCommitted: true,
|
|
new AggregateException(acquireFailure, rollbackFailure));
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void DecrementRefCount(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_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
|
|
/// cancelled by landblock churn AFTER the last registration event
|
|
/// (running across blocks loads/unloads them repeatedly) left the mesh
|
|
/// permanently unloadable with no later event to re-fire it. The draw
|
|
/// dispatcher touches every missing-but-referenced mesh every frame (the
|
|
/// meshMissing slow path) — that is the one place a retry can never be
|
|
/// missed. Cheap and idempotent: PrepareMeshDataAsync early-outs on
|
|
/// existing render data and returns the in-flight task when pending.
|
|
/// Retail-equivalence: retail loads content synchronously — geometry is
|
|
/// never permanently absent; this converges our async pipeline to the
|
|
/// same guarantee.
|
|
/// </summary>
|
|
public void EnsureLoaded(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
|
|
/// called once per frame from the render thread. Without this, the staged
|
|
/// mesh data queue grows unbounded (memory leak) and queued GL actions
|
|
/// never execute.
|
|
///
|
|
/// <para>
|
|
/// Order matters: <c>ProcessGLQueue</c> runs first to apply any pending GL
|
|
/// state changes (e.g., texture uploads queued by background workers
|
|
/// during mesh prep). Then we drain staged mesh data, calling
|
|
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
|
|
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
|
|
/// previously passed to <c>IncrementRefCount</c> may return non-null.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// No-op when the adapter is uninitialized (e.g., flag is off and the
|
|
/// adapter was constructed via <c>CreateUninitialized</c>).
|
|
/// </para>
|
|
/// </summary>
|
|
public void Tick()
|
|
{
|
|
if (_isUninitialized) return;
|
|
if (_disposed) return;
|
|
|
|
ObjectMeshManager meshManager = _meshManager!;
|
|
_graphicsDevice!.ProcessGLQueue();
|
|
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
|
|
// null from its catch) is re-staged for a LATER frame, not dropped. The
|
|
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
|
|
// inside the while would let a deterministic failure spin the queue in a
|
|
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
|
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
|
List<MeshUploadQueueItem>? requeue = null;
|
|
_uploadBudget.Reset();
|
|
_mipmapsBudgeted.Clear();
|
|
int staleDiscardCount = 0;
|
|
bool arenaBackpressured = false;
|
|
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
|
|
|
|
// A backing-store migration is real GL work, not a cost estimate. One
|
|
// bounded copy chunk runs per frame while the old VAO remains active.
|
|
// Uploads stay queued until the atomic final swap has completed.
|
|
if (globalBuffer?.IsMigrationInProgress == true)
|
|
{
|
|
GlobalMeshMaintenanceStep maintenance =
|
|
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
|
|
_uploadBudget.RecordBufferMaintenance(
|
|
maintenance.AllocationBytes,
|
|
maintenance.CopyBytes,
|
|
maintenance.NewBufferCount);
|
|
arenaBackpressured = !maintenance.Completed;
|
|
}
|
|
|
|
while (globalBuffer?.IsMigrationInProgress != true
|
|
&& _uploadBudget.BufferCopyBytes == 0
|
|
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
|
|
{
|
|
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
|
|
MaximumStaleDiscardsPerFrame - staleDiscardCount);
|
|
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
|
|
break;
|
|
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
|
|
break;
|
|
|
|
GlobalMeshCapacityResult capacity;
|
|
GlobalMeshMaintenanceStep maintenance;
|
|
try
|
|
{
|
|
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
|
|
}
|
|
catch (NotSupportedException error)
|
|
{
|
|
RejectUnsupportedHead(meshManager, next, error);
|
|
throw;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
|
|
{
|
|
_uploadBudget.RecordBufferMaintenance(
|
|
maintenance.AllocationBytes,
|
|
maintenance.CopyBytes,
|
|
maintenance.NewBufferCount);
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
|
|
{
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
|
|
{
|
|
// Do not evict another batch while the frame-fence queue is
|
|
// already returning ranges or retiring an old backing store.
|
|
// Waiting for real allocator state prevents three frames of
|
|
// duplicate eviction before the first release becomes reusable.
|
|
if (globalBuffer?.HasPendingReclamation != true)
|
|
{
|
|
var reclaimed = meshManager.ReclaimUnusedResources(
|
|
MaximumReclaimedMeshesPerFrame,
|
|
MaximumReclaimedMeshBytesPerFrame,
|
|
forceArenaReclamation: true);
|
|
if (reclaimed.Count == 0)
|
|
{
|
|
throw new NotSupportedException(
|
|
$"The live global-mesh working set cannot fit the supported "
|
|
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
|
|
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
|
|
}
|
|
}
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
|
|
MeshUploadCost cost;
|
|
try
|
|
{
|
|
cost = meshManager.PlanUploadCost(
|
|
next.Data,
|
|
_mipmapsBudgeted,
|
|
next.Generation);
|
|
if (!_uploadBudget.TryAdmit(cost))
|
|
break;
|
|
}
|
|
catch (NotSupportedException error)
|
|
{
|
|
RejectUnsupportedHead(meshManager, next, error);
|
|
throw;
|
|
}
|
|
|
|
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
|
|
break;
|
|
if (meshData.Generation != next.Generation)
|
|
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
|
|
if (meshManager.UploadOrRequeue(meshData))
|
|
(requeue ??= new()).Add(meshData);
|
|
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
|
|
}
|
|
if (requeue is not null)
|
|
foreach (var m in requeue)
|
|
meshManager.RequeueStagedMeshData(m);
|
|
meshManager.SetArenaBackpressure(arenaBackpressured);
|
|
|
|
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
|
var pendingBefore = texProbe
|
|
? meshManager.GetPendingTextureUpdateStats()
|
|
: default;
|
|
|
|
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
|
|
// immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
|
|
// actual TexSubImage3D copies + mipmap regeneration happen in
|
|
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
|
|
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
|
|
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
|
|
// extraction replaced with GameWindow, so the driver was silently dropped:
|
|
// staged updates never reached TexSubImage3D, leaving undefined
|
|
// TexStorage3D content behind valid resident bindless handles — the
|
|
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
|
|
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
|
|
// runs before all draw passes (GameWindow OnRender), so this is the exact
|
|
// WB-equivalent position.
|
|
(LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
|
|
if (globalBuffer?.HasPendingReclamation != true)
|
|
{
|
|
meshManager.ReclaimUnusedResources(
|
|
MaximumReclaimedMeshesPerFrame,
|
|
MaximumReclaimedMeshBytesPerFrame);
|
|
}
|
|
meshManager.EvictOneEmptyAtlas();
|
|
|
|
// Arena maintenance is capacity-driven and uses genuinely idle upload
|
|
// frames. It never competes with destination materialization, and the
|
|
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
|
|
// revisits do not alternate shrink/grow every frame.
|
|
if (_uploadBudget.ObjectCount == 0
|
|
&& LastMipmapArrayCount == 0
|
|
&& meshManager.StagedMeshCount == 0
|
|
&& globalBuffer?.IsMigrationInProgress == false
|
|
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
|
|
{
|
|
_uploadBudget.RecordBufferMaintenance(
|
|
trim.AllocationBytes,
|
|
trim.CopyBytes,
|
|
trim.NewBufferCount);
|
|
meshManager.SetArenaBackpressure(true);
|
|
}
|
|
|
|
LastUploadCount = _uploadBudget.ObjectCount;
|
|
LastUploadBytes = _uploadBudget.SourceBytes;
|
|
LastStaleDiscardCount = staleDiscardCount;
|
|
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
|
|
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
|
|
LastNewArrayCount = _uploadBudget.NewArrayCount;
|
|
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
|
|
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
|
|
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
|
|
LastNewBufferCount = _uploadBudget.NewBufferCount;
|
|
|
|
if (texProbe)
|
|
EmitTexFlushProbe(pendingBefore);
|
|
}
|
|
|
|
private static void RejectUnsupportedHead(
|
|
ObjectMeshManager meshManager,
|
|
MeshUploadQueueItem expected,
|
|
NotSupportedException error)
|
|
{
|
|
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|
|
|| rejected.Generation != expected.Generation)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
|
|
error);
|
|
}
|
|
meshManager.RejectUnsupportedStagedUpload(rejected, error);
|
|
}
|
|
|
|
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
|
|
private int _lastTexFlushBefore = -1;
|
|
private int _texFlushHeartbeat;
|
|
|
|
/// <summary>
|
|
/// #105 apparatus: one <c>[tex-flush]</c> line on change of the staged-texture
|
|
/// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy
|
|
/// frame ends with <c>after=0</c>; <c>before==after>0</c> persisting at
|
|
/// standstill is the white-walls mechanism live (staged uploads never applied).
|
|
/// </summary>
|
|
private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before)
|
|
{
|
|
var after = _meshManager!.GetPendingTextureUpdateStats();
|
|
bool changed = before.PendingUpdates != _lastTexFlushBefore;
|
|
bool flushed = after.PendingUpdates != before.PendingUpdates;
|
|
bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600;
|
|
if (!changed && !flushed && !heartbeat) return;
|
|
|
|
_texFlushHeartbeat = 0;
|
|
_lastTexFlushBefore = before.PendingUpdates;
|
|
Console.WriteLine(
|
|
$"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" +
|
|
$" arrays={after.ArraysWithPending}/{after.TotalArrays}" +
|
|
$" (arraysBefore={before.ArraysWithPending})");
|
|
}
|
|
|
|
private void PopulateMetadata(ulong id)
|
|
{
|
|
if (_dats is null) return;
|
|
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
|
|
|
|
var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
|
|
for (int i = 0; i < subMeshes.Count; i++)
|
|
{
|
|
var sm = subMeshes[i];
|
|
_metadataTable.Add(id, i, new AcSurfaceMetadata(
|
|
sm.Translucency, sm.Luminosity, sm.Diffuse,
|
|
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
// These are dependency edges, not merely a best-effort cleanup list.
|
|
// In particular, the sampler objects owned by the device must remain
|
|
// alive until every resident texture-array handle has been retired.
|
|
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
|
|
() =>
|
|
{
|
|
// The current global arena is still directly owned by the mesh
|
|
// manager. Fence every submitted draw before manager teardown
|
|
// can delete that arena's VAO/VBO/IBO.
|
|
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
|
frameFlights.WaitForSubmittedWork();
|
|
},
|
|
() => _meshManager?.Dispose(),
|
|
() => DrainGraphicsQueue("publishing mesh resource retirements"),
|
|
() =>
|
|
{
|
|
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
|
frameFlights.WaitForSubmittedWork();
|
|
},
|
|
() => DrainGraphicsQueue("releasing retired mesh resources"),
|
|
() => _graphicsDevice?.Dispose(),
|
|
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
|
|
|
|
_teardown.Advance();
|
|
_disposed = _teardown.IsComplete;
|
|
}
|
|
|
|
private void DrainGraphicsQueue(string operation)
|
|
{
|
|
if (_graphicsDevice is null)
|
|
return;
|
|
|
|
_graphicsDevice.ProcessGLQueue();
|
|
if (_graphicsDevice.HasPendingGLWork)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
|
|
}
|
|
}
|
|
}
|