feat(render): Campaign V slice V4b - move the mesh arena onto IGpuBuffer

The shared vertex/index arena is the largest single GPU allocation acdream
makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific
plan for (campaign doc section 4.3). This slice swaps the resource handle type
underneath it and changes nothing else: the reclaimable-range allocator, the
growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger
gating, the abort ticket, the LRU that drives eviction, and the 896 MiB
dual-generation physical ceiling are all untouched. That is deliberate - those
are the semantics section 4.3 says the Vulkan arena must mirror exactly, so
preserving them is the point of the slice rather than an incidental constraint.

What moved:

- GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through
  IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus
  both transfer usages (the arena is simultaneously a draw source and both ends
  of its own migration, which is exactly why GpuBufferUsage is a flags enum).
- UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload.
  The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an
  upload could not mutate whichever VAO a preceding render pass left bound;
  Upload stages through a neutral binding point of the backend's choosing, so
  that property now comes for free instead of by hand.
- AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side
  copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live
  prefix still never round-trips through system memory.
- BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in
  the migration record and the abort ticket instead of raw uint names, so the
  ticket's identity check is a resource identity rather than a number that goes
  stale the moment the buffer is deleted.

What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex
input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and
ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the
pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and
its attribute layout, and VBO/IBO became computed properties that publish the
backing GL name of the buffer the arena now owns as an IGpuBuffer. One private
RequireGlBuffer helper is the single place that reaches through the interface,
and it disappears with those consumers. ObjectMeshManager therefore needed no
upload-path change at all - it reads those same three properties.

Two decisions worth recording.

First, arena deletes do not route through IGpuBuffer.Dispose. The arena already
gates every delete behind its own GpuRetirementLedger and decrements its
physical-capacity accounting in the same retirement stage; Dispose would defer
the physical free through the device queue a second time, so the accounting
would run ahead of real GPU residency and could admit a migration that breaches
the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have
already proved flight safety, and GlobalMeshBuffer composes it into a release
whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly
- precondition, mutation-with-validation, byte accounting, resource-count
accounting - so a driver failure re-issues only the delete and never
double-counts.

Second, two corrections in the GL backend, both required to keep this port
behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData
usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the
arena has always requested; the host-writable rings and texture table keep
DynamicDraw and are unaffected. And a failed allocation now releases the GL
name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB
growth destination, and the previous code leaked the name on that path.

Plumbing: the device reaches the arena through WbMeshAdapter and
ObjectMeshManager. Their constructors became internal because IGpuDevice is an
internal type by the pinned contract, matching what V4a did for BitmapFont,
DebugLineRenderer and TextRenderer; both classes stay public and every caller
already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The
unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could
not supply a device and had no callers.

Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed /
3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5
skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200
(fraction 4.44e-05), against a same-commit control captured immediately
afterwards of 24 - the change is indistinguishable from capture noise and sits
40x under the 0.001 threshold. An earlier gate run was discarded rather than
interpreted: its client log showed real ScrollUp/ScrollDown input reaching the
offline window, which zoomed the camera, and a camera-motion difference is not
a rendering result.

No divergence-register row: this slice changes no retail-facing behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 20:02:50 +02:00
parent 79ee2361ad
commit e946b46f75
9 changed files with 302 additions and 142 deletions

View file

@ -114,6 +114,7 @@ internal interface IWorldRenderCompositionFactory
Shader CreateMeshShader(GL gl, string shadersDirectory); Shader CreateMeshShader(GL gl, string shadersDirectory);
WbMeshAdapter CreateMeshAdapter( WbMeshAdapter CreateMeshAdapter(
GL gl, GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement, IGpuResourceRetirementQueue retirement,
@ -291,12 +292,14 @@ internal sealed class RetailWorldRenderCompositionFactory
public WbMeshAdapter CreateMeshAdapter( public WbMeshAdapter CreateMeshAdapter(
GL gl, GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement, IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets) => ResidencyBudgetOptions budgets) =>
new( new(
gl, gl,
device,
dats, dats,
preparedAssets, preparedAssets,
NullLogger<WbMeshAdapter>.Instance, NullLogger<WbMeshAdapter>.Instance,
@ -540,6 +543,7 @@ internal sealed class WorldRenderCompositionPhase
"WB mesh adapter", "WB mesh adapter",
() => _factory.CreateMeshAdapter( () => _factory.CreateMeshAdapter(
gl, gl,
_dependencies.GpuDevice,
content.Dats, content.Dats,
content.PreparedAssets, content.PreparedAssets,
_dependencies.ResourceRetirement, _dependencies.ResourceRetirement,

View file

@ -18,6 +18,15 @@ namespace AcDream.App.Rendering.Gpu.Gl;
/// data; every write after that is <c>glBufferSubData</c> — no persistent /// data; every write after that is <c>glBufferSubData</c> — no persistent
/// mapping, matching the campaign's "GL backend is deliberately behaviour- /// mapping, matching the campaign's "GL backend is deliberately behaviour-
/// preserving" rule for this slice. /// preserving" rule for this slice.
///
/// The <c>glBufferData</c> usage hint follows
/// <see cref="GpuBufferDescription.Residency"/>:
/// <see cref="GpuMemoryResidency.DeviceLocal"/> is written rarely and read by
/// many draws, so it takes <c>StaticDraw</c>; the host-writable rings and
/// tables are rewritten every frame and take <c>DynamicDraw</c>. The hint is
/// advisory to the driver, but keeping it per-residency is what let the mesh
/// arena (Campaign V slice V4b) move onto this class without changing the
/// allocation it has always requested.
/// </summary> /// </summary>
internal sealed class GlGpuBuffer : IGpuBuffer internal sealed class GlGpuBuffer : IGpuBuffer
{ {
@ -35,13 +44,29 @@ internal sealed class GlGpuBuffer : IGpuBuffer
Residency = description.Residency; Residency = description.Residency;
_name = GlResourceCommand.CreateName(_gl, $"buffer '{Name}'", _gl.GenBuffer, _gl.DeleteBuffer); _name = GlResourceCommand.CreateName(_gl, $"buffer '{Name}'", _gl.GenBuffer, _gl.DeleteBuffer);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name); try
unsafe
{ {
_gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
unsafe
{
_gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, UsageHintFor(Residency));
}
GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
catch
{
// A rejected data store (GL_OUT_OF_MEMORY is a real outcome for the
// mesh arena's 384 MiB growth destination) must not strand the name
// that was already created for it. The caller sees the original
// failure and owns nothing.
GlResourceCommand.DeleteBuffer(
_gl,
_name,
$"rollback buffer '{Name}' after a failed allocation");
_name = 0;
throw;
} }
GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
} }
public string Name { get; } public string Name { get; }
@ -52,6 +77,43 @@ internal sealed class GlGpuBuffer : IGpuBuffer
/// <summary>The physical GL buffer name. For bind calls issued by <see cref="GlGpuPassEncoder"/>.</summary> /// <summary>The physical GL buffer name. For bind calls issued by <see cref="GlGpuPassEncoder"/>.</summary>
internal uint GlName => _name; internal uint GlName => _name;
private static BufferUsageARB UsageHintFor(GpuMemoryResidency residency) =>
residency == GpuMemoryResidency.DeviceLocal
? BufferUsageARB.StaticDraw
: BufferUsageARB.DynamicDraw;
/// <summary>
/// Deletes the physical buffer on the calling thread instead of deferring it
/// through the device's retirement queue the way <see cref="Dispose"/> does.
///
/// Campaign V slice V4b: the mesh arena (<c>GlobalMeshBuffer</c>) already gates
/// every arena delete behind its own <c>GpuRetirementLedger</c> and decrements
/// its <c>MaximumPhysicalArenaBytes</c> accounting in the same retirement stage.
/// Routing the physical free through the queue a second time would delay it by
/// a further flight generation, so the arena's physical-capacity accounting
/// would run ahead of real GPU residency and could admit a migration that
/// breaches the 896 MiB dual-generation ceiling. A caller of this method must
/// therefore already have proved no submitted frame can still reference the
/// buffer.
///
/// Retryable by construction: the managed name is cleared only after the driver
/// reports success, so a failed delete is re-issued by the next attempt, and any
/// later call (including <see cref="Dispose"/>) is a no-op.
/// </summary>
internal void DeleteRetired(string context)
{
uint name = _name;
if (name == 0)
return;
_gl.DeleteBuffer(name);
// Per the GL error contract a command which generates an error does not
// change object state, so validation stays in the same stage as the
// mutation and the name is only surrendered once deletion committed.
GLHelpers.ThrowOnResourceError(_gl, context);
_name = 0;
}
public void Upload(long offsetBytes, ReadOnlySpan<byte> data) public void Upload(long offsetBytes, ReadOnlySpan<byte> data)
{ {
ThrowIfDisposed(); ThrowIfDisposed();

View file

@ -200,6 +200,7 @@ public static class RenderBootstrap
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance; var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets( var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets(
gl, gl,
gpuDevice,
dats, dats,
wbLogger, wbLogger,
frameFlights); frameFlights);

View file

@ -1,7 +1,10 @@
using System.Runtime.InteropServices;
using AcDream.Content; using AcDream.Content;
using Chorizite.Core.Render.Enums; using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Wb;
@ -23,7 +26,7 @@ internal readonly record struct GlobalMeshMaintenanceStep(
bool Completed); bool Completed);
/// <summary> /// <summary>
/// Retains the staged GL name and its exact release cursor while an aborted /// Retains the staged buffer and its exact release cursor while an aborted
/// arena migration is being unwound. The owner may only forget the migration /// arena migration is being unwound. The owner may only forget the migration
/// after this ticket has converged. /// after this ticket has converged.
/// </summary> /// </summary>
@ -32,19 +35,18 @@ internal sealed class GlobalMeshMigrationAbortTicket
private readonly RetryableGpuResourceRelease _release; private readonly RetryableGpuResourceRelease _release;
public GlobalMeshMigrationAbortTicket( public GlobalMeshMigrationAbortTicket(
uint buffer, IGpuBuffer buffer,
long capacityBytes, long capacityBytes,
RetryableGpuResourceRelease release) RetryableGpuResourceRelease release)
{ {
if (buffer == 0) ArgumentNullException.ThrowIfNull(buffer);
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
Buffer = buffer; Buffer = buffer;
CapacityBytes = capacityBytes; CapacityBytes = capacityBytes;
_release = release ?? throw new ArgumentNullException(nameof(release)); _release = release ?? throw new ArgumentNullException(nameof(release));
} }
public uint Buffer { get; } public IGpuBuffer Buffer { get; }
public long CapacityBytes { get; } public long CapacityBytes { get; }
public bool IsComplete => _release.IsComplete; public bool IsComplete => _release.IsComplete;
@ -72,6 +74,19 @@ internal enum GlobalMeshCapacityResult
/// Shared modern-rendering vertex/index buffers with reclaimable ranges. /// Shared modern-rendering vertex/index buffers with reclaimable ranges.
/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges /// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
/// when its zero-reference LRU entry is evicted. /// when its zero-reference LRU entry is evicted.
///
/// Campaign V slice V4b moved the two backing stores onto
/// <see cref="IGpuBuffer"/>: allocation is <see cref="IGpuDevice.CreateBuffer"/>,
/// mesh upload is <see cref="IGpuBuffer.Upload"/>, and the grow-and-copy
/// migration is <see cref="IGpuBuffer.CopyTo"/> — a device-side copy the Vulkan
/// backend implements with <c>vkCmdCopyBuffer</c>. The reclaimable-range
/// allocator, growth quanta, budgeted incremental migration, retirement-ledger
/// gating and the dual-generation physical ceiling are unchanged; only the
/// resource handle type moved. The vertex array object stays raw GL because a
/// VAO has no RHI equivalent (Vulkan bakes vertex input into the pipeline) and
/// <c>WbDrawDispatcher</c>, <c>EnvCellRenderer</c> and <c>ParticleRenderer</c>
/// still bind <see cref="VAO"/>/<see cref="VBO"/>/<see cref="IBO"/> directly
/// until slice V4c moves them onto the pass encoder.
/// </summary> /// </summary>
public sealed class GlobalMeshBuffer : IDisposable public sealed class GlobalMeshBuffer : IDisposable
{ {
@ -90,16 +105,41 @@ public sealed class GlobalMeshBuffer : IDisposable
internal const int MaximumIndexCapacity = internal const int MaximumIndexCapacity =
(int)(MaximumIndexBufferBytes / sizeof(ushort)); (int)(MaximumIndexBufferBytes / sizeof(ushort));
// Retained only for the vertex array object and its attribute layout, which
// the RHI has no verb for. Slice V4c retires this field with the raw-GL
// dispatcher.
private readonly GL _gl; private readonly GL _gl;
private readonly IGpuDevice _device;
private readonly GpuRetirementLedger _retirementLedger; private readonly GpuRetirementLedger _retirementLedger;
private readonly GpuRetiredRangeAllocator _vertices; private readonly GpuRetiredRangeAllocator _vertices;
private readonly GpuRetiredRangeAllocator _indices; private readonly GpuRetiredRangeAllocator _indices;
private IGpuBuffer? _vertexBuffer;
private IGpuBuffer? _indexBuffer;
private BufferMigration? _migration; private BufferMigration? _migration;
private GlobalMeshMigrationAbortTicket? _migrationAbort; private GlobalMeshMigrationAbortTicket? _migrationAbort;
private long _retiredCapacityBytes; private long _retiredCapacityBytes;
private int _storeGeneration;
private bool _disposed; private bool _disposed;
private RetryableResourceReleaseLedger? _disposeResources; private RetryableResourceReleaseLedger? _disposeResources;
private static IGpuBuffer RequireStore(IGpuBuffer? store) =>
store ?? throw new InvalidOperationException(
"The global mesh arena has no live backing store.");
/// <summary>
/// Campaign V slice V4b transitional bridge. The arena owns its stores as
/// <see cref="IGpuBuffer"/>, but its consumers — the vertex array object here,
/// and <c>WbDrawDispatcher</c>/<c>EnvCellRenderer</c>/<c>ParticleRenderer</c>
/// through <see cref="VBO"/>/<see cref="IBO"/> — are still raw GL until slice
/// V4c. This is the only place that reaches through the interface, and it
/// disappears with those consumers.
/// </summary>
private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer) =>
buffer as GlGpuBuffer
?? throw new NotSupportedException(
"The global mesh arena requires a GL-backed buffer while its draw paths "
+ "still bind raw GL names (Campaign V slice V4c retires that requirement).");
private enum BufferKind private enum BufferKind
{ {
Vertices, Vertices,
@ -108,8 +148,8 @@ public sealed class GlobalMeshBuffer : IDisposable
private sealed record BufferMigration( private sealed record BufferMigration(
BufferKind Kind, BufferKind Kind,
uint OldBuffer, IGpuBuffer OldBuffer,
uint NewBuffer, IGpuBuffer NewBuffer,
int OldCapacity, int OldCapacity,
int NewCapacity, int NewCapacity,
long OldCapacityBytes, long OldCapacityBytes,
@ -120,8 +160,17 @@ public sealed class GlobalMeshBuffer : IDisposable
} }
public uint VAO { get; private set; } public uint VAO { get; private set; }
public uint VBO { get; private set; }
public uint IBO { get; private set; } /// <summary>
/// The vertex store's raw GL name. Transitional: the modern draw paths still
/// bind the arena themselves until Campaign V slice V4c hands them the pass
/// encoder, so the arena keeps publishing the backend name of the buffer it
/// now owns as an <see cref="IGpuBuffer"/>.
/// </summary>
public uint VBO => _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName;
/// <summary>The index store's raw GL name. See <see cref="VBO"/>.</summary>
public uint IBO => _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName;
internal long UploadCount { get; private set; } internal long UploadCount { get; private set; }
internal long UploadedBytes { get; private set; } internal long UploadedBytes { get; private set; }
internal long CapacityBytes => internal long CapacityBytes =>
@ -193,14 +242,10 @@ public sealed class GlobalMeshBuffer : IDisposable
newBuffers); newBuffers);
} }
public GlobalMeshBuffer(GL gl) internal GlobalMeshBuffer(GL gl, IGpuDevice device, IGpuResourceRetirementQueue retirement)
: this(gl, ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal GlobalMeshBuffer(GL gl, IGpuResourceRetirementQueue retirement)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_device = device ?? throw new ArgumentNullException(nameof(device));
ArgumentNullException.ThrowIfNull(retirement); ArgumentNullException.ThrowIfNull(retirement);
_retirementLedger = new GpuRetirementLedger(retirement); _retirementLedger = new GpuRetirementLedger(retirement);
_vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB _vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB
@ -208,11 +253,27 @@ public sealed class GlobalMeshBuffer : IDisposable
InitBuffers(); InitBuffers();
} }
/// <summary>
/// The mesh arena is simultaneously a draw source and both ends of the
/// grow-and-copy migration, which is why <see cref="GpuBufferUsage"/> is a
/// flags enum: Vulkan must name every usage at creation time.
/// </summary>
private static GpuBufferDescription DescribeStore(BufferKind kind, long sizeBytes, int generation) =>
new(
kind == BufferKind.Vertices
? $"mesh-arena-vertex-{generation}"
: $"mesh-arena-index-{generation}",
sizeBytes,
(kind == BufferKind.Vertices ? GpuBufferUsage.Vertex : GpuBufferUsage.Index)
| GpuBufferUsage.TransferSource
| GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal);
private unsafe void InitBuffers() private unsafe void InitBuffers()
{ {
uint vao = 0; uint vao = 0;
uint vbo = 0; IGpuBuffer? vbo = null;
uint ibo = 0; IGpuBuffer? ibo = null;
long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size; long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size;
long indexBytes = (long)_indices.Capacity * sizeof(ushort); long indexBytes = (long)_indices.Capacity * sizeof(ushort);
bool vaoTracked = false; bool vaoTracked = false;
@ -222,18 +283,16 @@ public sealed class GlobalMeshBuffer : IDisposable
try try
{ {
_gl.GenVertexArrays(1, out vao); _gl.GenVertexArrays(1, out vao);
_gl.GenBuffers(1, out vbo); if (vao == 0)
_gl.GenBuffers(1, out ibo);
if (vao == 0 || vbo == 0 || ibo == 0)
throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects."); throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects.");
vbo = _device.CreateBuffer(DescribeStore(BufferKind.Vertices, vertexBytes, _storeGeneration));
ibo = _device.CreateBuffer(DescribeStore(BufferKind.Indices, indexBytes, _storeGeneration));
_gl.BindVertexArray(vao); _gl.BindVertexArray(vao);
_gl.BindBuffer(GLEnum.ArrayBuffer, vbo); _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName);
_gl.BufferData(GLEnum.ArrayBuffer, ToNativeSize(vertexBytes), null, GLEnum.StaticDraw);
ConfigureVertexAttributes(); ConfigureVertexAttributes();
_gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName);
_gl.BufferData(GLEnum.ElementArrayBuffer, ToNativeSize(indexBytes), null, GLEnum.StaticDraw);
GLHelpers.ThrowOnResourceError( GLHelpers.ThrowOnResourceError(
_gl, _gl,
$"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)"); $"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)");
@ -248,13 +307,19 @@ public sealed class GlobalMeshBuffer : IDisposable
indexTracked = true; indexTracked = true;
VAO = vao; VAO = vao;
VBO = vbo; _vertexBuffer = vbo;
IBO = ibo; _indexBuffer = ibo;
} }
catch catch
{ {
if (ibo != 0) _gl.DeleteBuffer(ibo); // Construction rollback: nothing was ever submitted, so the physical
if (vbo != 0) _gl.DeleteBuffer(vbo); // stores are released on the spot rather than deferred. Pattern-matched
// rather than RequireGlBuffer'd so a non-GL store could never raise a
// cast failure that masks the original construction exception.
if (ibo is GlGpuBuffer stagedIndexStore)
stagedIndexStore.DeleteRetired("rolling back the global index arena buffer");
if (vbo is GlGpuBuffer stagedVertexStore)
stagedVertexStore.DeleteRetired("rolling back the global vertex arena buffer");
if (vao != 0) _gl.DeleteVertexArray(vao); if (vao != 0) _gl.DeleteVertexArray(vao);
if (indexTracked) if (indexTracked)
{ {
@ -287,7 +352,7 @@ public sealed class GlobalMeshBuffer : IDisposable
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
} }
internal unsafe GlobalMeshAllocation UploadMesh( internal GlobalMeshAllocation UploadMesh(
VertexPositionNormalTexture[] vertices, VertexPositionNormalTexture[] vertices,
IReadOnlyList<ushort[]> indexBatches) IReadOnlyList<ushort[]> indexBatches)
{ {
@ -326,22 +391,17 @@ public sealed class GlobalMeshBuffer : IDisposable
var firstIndices = new int[indexBatches.Count]; var firstIndices = new int[indexBatches.Count];
try try
{ {
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO); // IGpuBuffer.Upload stages through a neutral binding point of the
fixed (VertexPositionNormalTexture* ptr = vertices) // backend's choosing, so a mesh upload can no longer disturb whichever
{ // vertex array a preceding render pass happened to leave bound — the
long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size); // property the old hand-rolled ElementArrayBuffer/CopyWriteBuffer split
long vertexUploadBytes = checked((long)vertices.Length * VertexPositionNormalTexture.Size); // was protecting.
_gl.BufferSubData( long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size);
GLEnum.ArrayBuffer, RequireStore(_vertexBuffer).Upload(
ToNativeOffset(vertexOffsetBytes), vertexOffsetBytes,
ToNativeSize(vertexUploadBytes), MemoryMarshal.AsBytes(new ReadOnlySpan<VertexPositionNormalTexture>(vertices)));
ptr);
}
// ElementArrayBuffer binding is VAO state. Use the neutral copy IGpuBuffer indexStore = RequireStore(_indexBuffer);
// target for staging so uploads cannot mutate whichever VAO the
// preceding render pass happened to leave bound.
_gl.BindBuffer(GLEnum.CopyWriteBuffer, IBO);
int indexOffset = indexRange.Offset; int indexOffset = indexRange.Offset;
for (int i = 0; i < indexBatches.Count; i++) for (int i = 0; i < indexBatches.Count; i++)
{ {
@ -349,22 +409,13 @@ public sealed class GlobalMeshBuffer : IDisposable
firstIndices[i] = indexOffset; firstIndices[i] = indexOffset;
if (batch.Length > 0) if (batch.Length > 0)
{ {
fixed (ushort* ptr = batch) long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
{ indexStore.Upload(
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); indexOffsetBytes,
long indexUploadBytes = checked((long)batch.Length * sizeof(ushort)); MemoryMarshal.AsBytes(new ReadOnlySpan<ushort>(batch)));
_gl.BufferSubData(
GLEnum.CopyWriteBuffer,
ToNativeOffset(indexOffsetBytes),
ToNativeSize(indexUploadBytes),
ptr);
}
indexOffset = checked(indexOffset + batch.Length); indexOffset = checked(indexOffset + batch.Length);
} }
} }
GLHelpers.ThrowOnResourceError(
_gl,
$"uploading global mesh ({vertices.Length} vertices, {totalIndices} indices)");
} }
catch catch
{ {
@ -603,18 +654,13 @@ public sealed class GlobalMeshBuffer : IDisposable
{ {
if (chunk != 0) if (chunk != 0)
{ {
_gl.BindBuffer(GLEnum.CopyReadBuffer, migration.OldBuffer); // Device-side copy: the live prefix never round-trips through
_gl.BindBuffer(GLEnum.CopyWriteBuffer, migration.NewBuffer); // system memory. The Vulkan backend records vkCmdCopyBuffer here.
_gl.CopyBufferSubData( migration.OldBuffer.CopyTo(
GLEnum.CopyReadBuffer, migration.NewBuffer,
GLEnum.CopyWriteBuffer, migration.CopiedBytes,
ToNativeOffset(migration.CopiedBytes), migration.CopiedBytes,
ToNativeOffset(migration.CopiedBytes), chunk);
ToNativeSize(chunk));
GLHelpers.ThrowOnResourceError(
_gl,
$"migrating {migration.Kind} arena bytes "
+ $"{migration.CopiedBytes:N0}..{migration.CopiedBytes + chunk:N0}");
migration.CopiedBytes = checked(migration.CopiedBytes + chunk); migration.CopiedBytes = checked(migration.CopiedBytes + chunk);
} }
@ -704,33 +750,18 @@ public sealed class GlobalMeshBuffer : IDisposable
return Math.Min(totalBytes - copiedBytes, maximumCopyBytes); return Math.Min(totalBytes - copiedBytes, maximumCopyBytes);
} }
private unsafe void BeginMigration(BufferKind kind, int newCapacity, long copyBytes) private void BeginMigration(BufferKind kind, int newCapacity, long copyBytes)
{ {
if (_migration is not null || _migrationAbort is not null) if (_migration is not null || _migrationAbort is not null)
throw new InvalidOperationException("Only one global mesh backing buffer may migrate at a time."); throw new InvalidOperationException("Only one global mesh backing buffer may migrate at a time.");
int oldCapacity = kind == BufferKind.Vertices ? _vertices.Capacity : _indices.Capacity; int oldCapacity = kind == BufferKind.Vertices ? _vertices.Capacity : _indices.Capacity;
uint oldBuffer = kind == BufferKind.Vertices ? VBO : IBO; IGpuBuffer oldBuffer = RequireStore(
kind == BufferKind.Vertices ? _vertexBuffer : _indexBuffer);
long oldBytes = CapacityBytesFor(kind, oldCapacity); long oldBytes = CapacityBytesFor(kind, oldCapacity);
long newBytes = CapacityBytesFor(kind, newCapacity); long newBytes = CapacityBytesFor(kind, newCapacity);
uint newBuffer = 0;
try IGpuBuffer newBuffer = _device.CreateBuffer(
{ DescribeStore(kind, newBytes, checked(++_storeGeneration)));
_gl.GenBuffers(1, out newBuffer);
if (newBuffer == 0)
throw new InvalidOperationException($"OpenGL did not create a staged {kind} arena buffer.");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newBuffer);
_gl.BufferData(GLEnum.CopyWriteBuffer, ToNativeSize(newBytes), null, GLEnum.StaticDraw);
GLHelpers.ThrowOnResourceError(
_gl,
$"allocating staged {kind} arena buffer ({newBytes:N0} bytes)");
}
catch
{
if (newBuffer != 0)
_gl.DeleteBuffer(newBuffer);
throw;
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer); GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer);
@ -752,12 +783,12 @@ public sealed class GlobalMeshBuffer : IDisposable
_gl.BindVertexArray(VAO); _gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices) if (migration.Kind == BufferKind.Vertices)
{ {
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.NewBuffer); _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName);
ConfigureVertexAttributes(); ConfigureVertexAttributes();
} }
else else
{ {
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.NewBuffer); _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName);
} }
GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer"); GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer");
} }
@ -766,12 +797,12 @@ public sealed class GlobalMeshBuffer : IDisposable
_gl.BindVertexArray(VAO); _gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices) if (migration.Kind == BufferKind.Vertices)
{ {
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.OldBuffer); _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName);
ConfigureVertexAttributes(); ConfigureVertexAttributes();
} }
else else
{ {
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.OldBuffer); _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName);
} }
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
throw; throw;
@ -783,7 +814,7 @@ public sealed class GlobalMeshBuffer : IDisposable
if (migration.Kind == BufferKind.Vertices) if (migration.Kind == BufferKind.Vertices)
{ {
VBO = migration.NewBuffer; _vertexBuffer = migration.NewBuffer;
if (migration.NewCapacity > migration.OldCapacity) if (migration.NewCapacity > migration.OldCapacity)
_vertices.Grow(migration.NewCapacity); _vertices.Grow(migration.NewCapacity);
else else
@ -791,7 +822,7 @@ public sealed class GlobalMeshBuffer : IDisposable
} }
else else
{ {
IBO = migration.NewBuffer; _indexBuffer = migration.NewBuffer;
if (migration.NewCapacity > migration.OldCapacity) if (migration.NewCapacity > migration.OldCapacity)
_indices.Grow(migration.NewCapacity); _indices.Grow(migration.NewCapacity);
else else
@ -801,11 +832,10 @@ public sealed class GlobalMeshBuffer : IDisposable
_migration = null; _migration = null;
_retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes); _retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes);
RetryableGpuResourceRelease oldBufferRelease = RetryableGpuResourceRelease oldBufferRelease =
TrackedGlResource.CreateRetryableBufferDeletion( CreateRetryableStoreDeletion(
_gl,
migration.OldBuffer, migration.OldBuffer,
migration.OldCapacityBytes, migration.OldCapacityBytes,
$"retiring replaced global {migration.Kind} arena buffer {migration.OldBuffer}"); $"retiring replaced global {migration.Kind} arena buffer '{migration.OldBuffer.Name}'");
_retirementLedger.Retire(new RetryableGpuResourceRelease( _retirementLedger.Retire(new RetryableGpuResourceRelease(
oldBufferRelease.Run, oldBufferRelease.Run,
() => _retiredCapacityBytes = checked( () => _retiredCapacityBytes = checked(
@ -819,14 +849,41 @@ public sealed class GlobalMeshBuffer : IDisposable
_migrationAbort ??= new GlobalMeshMigrationAbortTicket( _migrationAbort ??= new GlobalMeshMigrationAbortTicket(
migration.NewBuffer, migration.NewBuffer,
migration.NewCapacityBytes, migration.NewCapacityBytes,
TrackedGlResource.CreateRetryableBufferDeletion( CreateRetryableStoreDeletion(
_gl,
migration.NewBuffer, migration.NewBuffer,
migration.NewCapacityBytes, migration.NewCapacityBytes,
$"aborting staged global {migration.Kind} arena buffer {migration.NewBuffer}")); $"aborting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'"));
RetryPendingMigrationAbort(); RetryPendingMigrationAbort();
} }
/// <summary>
/// The arena's own flight gate — <see cref="_retirementLedger"/> and the abort
/// ticket — already proves no submitted frame can reference the store, so the
/// physical delete runs here rather than being deferred a second time by
/// <see cref="IGpuBuffer.Dispose"/>. Stages match
/// <c>TrackedGlResource.CreateRetryableBufferDeletion</c> exactly: precondition,
/// mutation-with-validation, byte accounting, then resource-count accounting,
/// so a driver failure re-issues only the delete and never double-counts.
/// </summary>
private RetryableGpuResourceRelease CreateRetryableStoreDeletion(
IGpuBuffer buffer,
long capacityBytes,
string context)
{
ArgumentNullException.ThrowIfNull(buffer);
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GL gl = _gl;
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() => RequireGlBuffer(buffer).DeleteRetired(context),
() =>
{
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
},
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
}
private void RetryPendingMigrationAbort() private void RetryPendingMigrationAbort()
{ {
GlobalMeshMigrationAbortTicket? ticket = _migrationAbort; GlobalMeshMigrationAbortTicket? ticket = _migrationAbort;
@ -844,7 +901,7 @@ public sealed class GlobalMeshBuffer : IDisposable
BufferMigration migration = _migration BufferMigration migration = _migration
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"A staged-buffer abort ticket outlived its migration record."); "A staged-buffer abort ticket outlived its migration record.");
if (migration.NewBuffer != ticket.Buffer if (!ReferenceEquals(migration.NewBuffer, ticket.Buffer)
|| migration.NewCapacityBytes != ticket.CapacityBytes) || migration.NewCapacityBytes != ticket.CapacityBytes)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@ -874,21 +931,11 @@ public sealed class GlobalMeshBuffer : IDisposable
return checked((int)rounded); return checked((int)rounded);
} }
private static nint ToNativeOffset(long value) // The former ToNativeOffset/ToNativeSize narrowing guards went with the raw
{ // glBufferSubData/glCopyBufferSubData calls they wrapped (Campaign V slice
ArgumentOutOfRangeException.ThrowIfNegative(value); // V4b). IGpuBuffer speaks in long, and every arena offset and length is
if (IntPtr.Size == 4 && value > int.MaxValue) // bounded by an int-typed element capacity times a 32- or 2-byte stride, so
throw new NotSupportedException("The requested GPU byte offset exceeds this process's native pointer range."); // the arena can never present a value a backend cannot express.
return checked((nint)value);
}
private static nuint ToNativeSize(long value)
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (UIntPtr.Size == 4 && value > uint.MaxValue)
throw new NotSupportedException("The requested GPU byte count exceeds this process's native pointer range.");
return checked((nuint)value);
}
public void Dispose() public void Dispose()
{ {
@ -903,11 +950,10 @@ public sealed class GlobalMeshBuffer : IDisposable
if (_migration is { } migration) if (_migration is { } migration)
{ {
RetryableGpuResourceRelease release = RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion( CreateRetryableStoreDeletion(
_gl,
migration.NewBuffer, migration.NewBuffer,
migration.NewCapacityBytes, migration.NewCapacityBytes,
$"deleting staged global {migration.Kind} arena buffer {migration.NewBuffer}"); $"deleting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'");
releases.Add(("staged-migration-buffer", release.Run)); releases.Add(("staged-migration-buffer", release.Run));
} }
@ -921,24 +967,22 @@ public sealed class GlobalMeshBuffer : IDisposable
GlobalMeshVaoAccounting.TrackDeallocation); GlobalMeshVaoAccounting.TrackDeallocation);
releases.Add(("global-vao", release.Run)); releases.Add(("global-vao", release.Run));
} }
if (VBO != 0) if (_vertexBuffer is { } vertexStore)
{ {
RetryableGpuResourceRelease release = RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion( CreateRetryableStoreDeletion(
_gl, vertexStore,
VBO,
(long)_vertices.Capacity * VertexPositionNormalTexture.Size, (long)_vertices.Capacity * VertexPositionNormalTexture.Size,
$"deleting global mesh vertex buffer {VBO}"); $"deleting global mesh vertex buffer '{vertexStore.Name}'");
releases.Add(("global-vbo", release.Run)); releases.Add(("global-vbo", release.Run));
} }
if (IBO != 0) if (_indexBuffer is { } indexStore)
{ {
RetryableGpuResourceRelease release = RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion( CreateRetryableStoreDeletion(
_gl, indexStore,
IBO,
(long)_indices.Capacity * sizeof(ushort), (long)_indices.Capacity * sizeof(ushort),
$"deleting global mesh index buffer {IBO}"); $"deleting global mesh index buffer '{indexStore.Name}'");
releases.Add(("global-ibo", release.Run)); releases.Add(("global-ibo", release.Run));
} }
_disposeResources = new RetryableResourceReleaseLedger(releases); _disposeResources = new RetryableResourceReleaseLedger(releases);
@ -951,7 +995,9 @@ public sealed class GlobalMeshBuffer : IDisposable
_migration = null; _migration = null;
_migrationAbort = null; _migrationAbort = null;
VAO = VBO = IBO = 0; VAO = 0;
_vertexBuffer = null;
_indexBuffer = null;
_disposeResources = null; _disposeResources = null;
_disposed = true; _disposed = true;

View file

@ -406,8 +406,13 @@ namespace AcDream.App.Rendering.Wb
public bool IsQueued { get; set; } public bool IsQueued { get; set; }
} }
public ObjectMeshManager( // internal, not public: IGpuDevice is an internal type (the pinned RHI
// contract), and the shared mesh arena is created from it. Every caller
// already lives inside AcDream.App or its InternalsVisibleTo test
// assemblies.
internal ObjectMeshManager(
OpenGLGraphicsDevice graphicsDevice, OpenGLGraphicsDevice graphicsDevice,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
ILogger<ObjectMeshManager> logger, ILogger<ObjectMeshManager> logger,
ResidencyBudgetOptions? budgets = null) ResidencyBudgetOptions? budgets = null)
@ -415,6 +420,7 @@ namespace AcDream.App.Rendering.Wb
budgets ??= ResidencyBudgetOptions.Default; budgets ??= ResidencyBudgetOptions.Default;
_graphicsDevice = graphicsDevice _graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice)); ?? throw new ArgumentNullException(nameof(graphicsDevice));
ArgumentNullException.ThrowIfNull(gpuDevice);
_preparedAssets = preparedAssets _preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets)); ?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger _logger = logger
@ -432,6 +438,7 @@ namespace AcDream.App.Rendering.Wb
{ {
GlobalBuffer = new GlobalMeshBuffer( GlobalBuffer = new GlobalMeshBuffer(
_graphicsDevice.GL, _graphicsDevice.GL,
gpuDevice,
_graphicsDevice.ResourceRetirement); _graphicsDevice.ResourceRetirement);
} }
} }

View file

@ -109,16 +109,28 @@ public sealed class WbMeshAdapter
/// Constructs the UI-Studio/tooling WB pipeline. Production composition /// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// supplies the validated pak source through the internal overload below; /// supplies the validated pak source through the internal overload below;
/// this explicit tooling seam retains live-DAT extraction. /// this explicit tooling seam retains live-DAT extraction.
///
/// internal, not public, since Campaign V slice V4b: the shared mesh arena
/// allocates its backing stores from <c>IGpuDevice</c>, which is an internal
/// type by the pinned RHI contract. Every caller already lives inside
/// AcDream.App or its InternalsVisibleTo test assemblies.
/// </summary> /// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current /// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param> /// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="gpuDevice">The one process RHI device. Supplies the mesh
/// arena's vertex/index buffers.</param>
/// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it /// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it
/// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param> /// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses /// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param> /// NullLogger internally.</param>
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger) internal WbMeshAdapter(
GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger)
: this( : this(
gl, gl,
gpuDevice,
dats, dats,
preparedAssets: null, preparedAssets: null,
logger, logger,
@ -130,11 +142,13 @@ public sealed class WbMeshAdapter
internal static WbMeshAdapter CreateWithLiveDatPreparedAssets( internal static WbMeshAdapter CreateWithLiveDatPreparedAssets(
GL gl, GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats, IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) => AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) =>
new( new(
gl, gl,
gpuDevice,
dats, dats,
preparedAssets: null, preparedAssets: null,
logger, logger,
@ -144,6 +158,7 @@ public sealed class WbMeshAdapter
internal WbMeshAdapter( internal WbMeshAdapter(
GL gl, GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
@ -151,6 +166,7 @@ public sealed class WbMeshAdapter
ResidencyBudgetOptions? budgets = null) ResidencyBudgetOptions? budgets = null)
: this( : this(
gl, gl,
gpuDevice,
dats, dats,
preparedAssets, preparedAssets,
logger, logger,
@ -162,6 +178,7 @@ public sealed class WbMeshAdapter
private WbMeshAdapter( private WbMeshAdapter(
GL gl, GL gl,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource? preparedAssets, IPreparedAssetSource? preparedAssets,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
@ -170,6 +187,7 @@ public sealed class WbMeshAdapter
ResidencyBudgetOptions budgets) ResidencyBudgetOptions budgets)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(gpuDevice);
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(budgets); ArgumentNullException.ThrowIfNull(budgets);
@ -212,6 +230,7 @@ public sealed class WbMeshAdapter
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589). // (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
meshManager = new ObjectMeshManager( meshManager = new ObjectMeshManager(
graphicsDevice, graphicsDevice,
gpuDevice,
resolvedPreparedAssets, resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>(), new ConsoleErrorLogger<ObjectMeshManager>(),
budgets); budgets);

View file

@ -291,6 +291,7 @@ public sealed class WorldRenderCompositionTests
public WbMeshAdapter CreateMeshAdapter( public WbMeshAdapter CreateMeshAdapter(
GL gl, GL gl,
IGpuDevice device,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement, IGpuResourceRetirementQueue retirement,

View file

@ -1,5 +1,7 @@
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
@ -224,14 +226,28 @@ public sealed class GpuResourceRetirementTransactionTests
Assert.False(device.HasPendingGLWork); Assert.False(device.HasPendingGLWork);
} }
/// <summary>
/// Campaign V slice V4b: the mesh arena's staged store is an
/// <see cref="IGpuBuffer"/> rather than a raw GL name, so the abort ticket
/// retains a resource identity. The invariant under test is unchanged — the
/// owner may only forget the migration once every release stage converged.
/// </summary>
private static IGpuBuffer StagedArenaBuffer(string name, long sizeBytes) =>
new RecordingGpuBuffer(new GpuBufferDescription(
name,
sizeBytes,
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
[Fact] [Fact]
public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges() public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges()
{ {
int deleteCalls = 0; int deleteCalls = 0;
int accountingCalls = 0; int accountingCalls = 0;
bool failAccounting = true; bool failAccounting = true;
IGpuBuffer staged = StagedArenaBuffer("staged-37", 4096);
var ticket = new GlobalMeshMigrationAbortTicket( var ticket = new GlobalMeshMigrationAbortTicket(
buffer: 37, buffer: staged,
capacityBytes: 4096, capacityBytes: 4096,
new RetryableGpuResourceRelease( new RetryableGpuResourceRelease(
() => deleteCalls++, () => deleteCalls++,
@ -247,7 +263,7 @@ public sealed class GpuResourceRetirementTransactionTests
Assert.Throws<InvalidOperationException>(ticket.Advance); Assert.Throws<InvalidOperationException>(ticket.Advance);
Assert.False(ticket.IsComplete); Assert.False(ticket.IsComplete);
Assert.Equal((uint)37, ticket.Buffer); Assert.Same(staged, ticket.Buffer);
Assert.Equal(4096, ticket.CapacityBytes); Assert.Equal(4096, ticket.CapacityBytes);
Assert.Equal(1, deleteCalls); Assert.Equal(1, deleteCalls);
Assert.Equal(0, accountingCalls); Assert.Equal(0, accountingCalls);
@ -267,7 +283,7 @@ public sealed class GpuResourceRetirementTransactionTests
int accountingCalls = 0; int accountingCalls = 0;
bool failDeleteValidation = true; bool failDeleteValidation = true;
var ticket = new GlobalMeshMigrationAbortTicket( var ticket = new GlobalMeshMigrationAbortTicket(
buffer: 41, buffer: StagedArenaBuffer("staged-41", 8192),
capacityBytes: 8192, capacityBytes: 8192,
new RetryableGpuResourceRelease( new RetryableGpuResourceRelease(
() => () =>

View file

@ -14,7 +14,11 @@ public sealed class WbMeshAdapterTests
// We can't pass a real GL (no context in tests), so we verify only the // We can't pass a real GL (no context in tests), so we verify only the
// null-GL guard. The real pipeline is tested via integration. // null-GL guard. The real pipeline is tested via integration.
Assert.Throws<ArgumentNullException>(() => Assert.Throws<ArgumentNullException>(() =>
new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger<WbMeshAdapter>.Instance)); new WbMeshAdapter(
gl: null!,
gpuDevice: null!,
dats: null!,
logger: NullLogger<WbMeshAdapter>.Instance));
} }
[Fact] [Fact]