Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
289 lines
11 KiB
C#
289 lines
11 KiB
C#
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// One standalone texture used by particle billboards. Unlike entity
|
|
/// composites, the shader always samples layer zero.
|
|
///
|
|
/// <para>Campaign V slice V6l: the resource identifies its texture on ONE of the
|
|
/// two arms. The GL arm names a texture object and a resident bindless handle;
|
|
/// the RHI arm names an <see cref="Gpu.IGpuTexture"/> created through the device.
|
|
/// <see cref="Slot"/> is common to both, which is the point — a particle batch
|
|
/// has carried a backend-neutral table slot rather than a handle since V4t, so
|
|
/// only the ownership record had to grow a second shape.</para>
|
|
/// </summary>
|
|
internal sealed class StandaloneBindlessTextureResource
|
|
{
|
|
public required uint SurfaceId { get; init; }
|
|
|
|
/// <summary>GL texture name on the GL arm; zero on the RHI arm.</summary>
|
|
public uint Name { get; init; }
|
|
|
|
/// <summary>Resident bindless handle on the GL arm; zero on the RHI arm.</summary>
|
|
public ulong Handle { get; init; }
|
|
|
|
/// <summary>The device texture on the RHI arm; null on the GL arm.</summary>
|
|
public Gpu.IGpuTexture? Texture { get; init; }
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V4t: this texture's entry in the device texture table.
|
|
/// Created with <see cref="Handle"/>'s residency (GL) or with
|
|
/// <see cref="Texture"/> (RHI) and retired with it, so a particle batch
|
|
/// carries a backend-neutral slot rather than a GL handle.
|
|
/// </summary>
|
|
public required Gpu.GpuTextureSlot Slot { get; init; }
|
|
public required long Bytes { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether the resource names a texture at all. Exactly one arm must have
|
|
/// filled it in; a resource that names neither would retire nothing and
|
|
/// leak silently, which is what this guards.
|
|
/// </summary>
|
|
public bool IdentifiesATexture => (Name != 0 && Handle != 0) || Texture is not null;
|
|
}
|
|
|
|
internal interface IStandaloneBindlessTextureBackend
|
|
{
|
|
void MakeNonResident(StandaloneBindlessTextureResource resource);
|
|
void Delete(StandaloneBindlessTextureResource resource);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shares exact DAT-decoded particle textures between live emitter owners.
|
|
/// The final owner moves a resource into a small LRU reuse pool; exceeding
|
|
/// either cache bound logically evicts the oldest entry immediately and
|
|
/// retires its resident handle/backing texture behind the frame-flight fence.
|
|
/// Live resources are never evicted, so this policy cannot change visuals.
|
|
/// Render-thread only.
|
|
/// </summary>
|
|
internal sealed class StandaloneBindlessTextureCache : IDisposable
|
|
{
|
|
internal const long DefaultUnownedBudgetBytes = 32L * 1024 * 1024;
|
|
internal const int DefaultMaximumUnownedCount = 256;
|
|
internal const int DefaultMaximumEvictionsPerFrame = 1;
|
|
|
|
private readonly IStandaloneBindlessTextureBackend _backend;
|
|
private readonly GpuRetirementLedger _retirementLedger;
|
|
private readonly OwnerScopedResourceRegistry<uint> _owners = new();
|
|
private readonly BoundedUnownedResourceCache<uint> _unowned;
|
|
private readonly Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
|
|
private readonly HashSet<uint> _disposeResidencyReleased = [];
|
|
private readonly HashSet<uint> _disposeDeleted = [];
|
|
private long _allocatedBytes;
|
|
private long _retiringBytes;
|
|
private bool _disposeRequested;
|
|
private bool _disposing;
|
|
private bool _disposed;
|
|
|
|
public StandaloneBindlessTextureCache(
|
|
IStandaloneBindlessTextureBackend backend,
|
|
IGpuResourceRetirementQueue retirementQueue,
|
|
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
|
|
int maximumUnownedCount = DefaultMaximumUnownedCount)
|
|
{
|
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
|
ArgumentNullException.ThrowIfNull(retirementQueue);
|
|
_retirementLedger = new GpuRetirementLedger(retirementQueue);
|
|
_unowned = new BoundedUnownedResourceCache<uint>(
|
|
unownedBudgetBytes,
|
|
maximumUnownedCount);
|
|
}
|
|
|
|
internal int EntryCount => _entries.Count;
|
|
internal int ActiveResourceCount => _owners.ResourceCount;
|
|
internal int OwnerCount => _owners.OwnerCount;
|
|
internal int UnownedEntryCount => _unowned.Count;
|
|
internal long UnownedBytes => _unowned.ResidentBytes;
|
|
internal long AllocatedBytes => _allocatedBytes;
|
|
internal long RetiringBytes => _retiringBytes;
|
|
internal long BudgetBytes => _unowned.BudgetBytes;
|
|
internal int AwaitingRetirementPublicationCount =>
|
|
_retirementLedger.AwaitingPublicationCount;
|
|
|
|
public bool TryAcquire(
|
|
uint ownerId,
|
|
uint surfaceId,
|
|
out StandaloneBindlessTextureResource resource)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
|
ValidateOwnerAndSurface(ownerId, surfaceId);
|
|
if (!_entries.TryGetValue(surfaceId, out resource!))
|
|
return false;
|
|
|
|
_owners.Acquire(ownerId, surfaceId);
|
|
_unowned.MarkOwned(surfaceId);
|
|
return true;
|
|
}
|
|
|
|
public void AddAndAcquire(
|
|
uint ownerId,
|
|
StandaloneBindlessTextureResource resource)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
|
ArgumentNullException.ThrowIfNull(resource);
|
|
ValidateOwnerAndSurface(ownerId, resource.SurfaceId);
|
|
if (!resource.IdentifiesATexture)
|
|
{
|
|
throw new ArgumentException(
|
|
"A standalone particle texture must name either a GL texture and its "
|
|
+ "resident handle or a device texture (campaign plan slice V6l).",
|
|
nameof(resource));
|
|
}
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes);
|
|
|
|
if (!_entries.TryAdd(resource.SurfaceId, resource))
|
|
throw new InvalidOperationException(
|
|
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
|
|
|
|
_owners.Acquire(ownerId, resource.SurfaceId);
|
|
_allocatedBytes = checked(_allocatedBytes + resource.Bytes);
|
|
}
|
|
|
|
public void ReleaseOwner(uint ownerId)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
|
if (ownerId == 0)
|
|
return;
|
|
|
|
IReadOnlyList<uint> newlyUnowned = _owners.ReleaseOwner(ownerId);
|
|
for (int i = 0; i < newlyUnowned.Count; i++)
|
|
{
|
|
uint surfaceId = newlyUnowned[i];
|
|
if (_entries.TryGetValue(surfaceId, out StandaloneBindlessTextureResource? resource))
|
|
_unowned.MarkUnowned(surfaceId, resource.Bytes);
|
|
}
|
|
|
|
}
|
|
|
|
internal void VisitEntries(Action<StandaloneBindlessTextureResource> visitor)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(visitor);
|
|
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
|
visitor(resource);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances logical eviction at a bounded rate. Owner release only marks
|
|
/// resources reusable; this render-frame maintenance edge prevents a
|
|
/// portal unload from submitting hundreds of bindless destruction calls
|
|
/// to the same GPU fence serial.
|
|
/// </summary>
|
|
public void Tick(int maximumEvictions = DefaultMaximumEvictionsPerFrame)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEvictions);
|
|
_retirementLedger.RetryPendingPublications();
|
|
|
|
for (int i = 0; i < maximumEvictions; i++)
|
|
{
|
|
if (!_unowned.TryTakeOldestOverBudget(out uint surfaceId))
|
|
break;
|
|
if (!_entries.Remove(surfaceId, out StandaloneBindlessTextureResource? resource))
|
|
continue;
|
|
|
|
// The publication ledger owns this release from this point even
|
|
// when queue admission or an immediate callback throws. Never
|
|
// republish a texture after residency release has started.
|
|
_retiringBytes = checked(_retiringBytes + resource.Bytes);
|
|
_retirementLedger.Retire(new RetryableGpuResourceRelease(
|
|
() => _backend.MakeNonResident(resource),
|
|
() => _backend.Delete(resource),
|
|
() => _retiringBytes = checked(
|
|
_retiringBytes - resource.Bytes),
|
|
() => _allocatedBytes = checked(
|
|
_allocatedBytes - resource.Bytes)));
|
|
}
|
|
}
|
|
|
|
private static void ValidateOwnerAndSurface(uint ownerId, uint surfaceId)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfZero(ownerId);
|
|
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed || _disposing)
|
|
return;
|
|
_disposeRequested = true;
|
|
_disposing = true;
|
|
|
|
try
|
|
{
|
|
// GameWindow drains the frame-flight queue before TextureCache
|
|
// teardown. Release every handle before deleting any texture so the
|
|
// ARB_bindless_texture lifetime ordering remains explicit.
|
|
List<Exception>? failures = null;
|
|
try
|
|
{
|
|
_retirementLedger.RetryPendingPublications();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
|
|
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
|
{
|
|
if (_disposeResidencyReleased.Contains(resource.SurfaceId))
|
|
continue;
|
|
try
|
|
{
|
|
_backend.MakeNonResident(resource);
|
|
_disposeResidencyReleased.Add(resource.SurfaceId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
(failures ??= []).Add(ex);
|
|
}
|
|
}
|
|
|
|
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
|
{
|
|
if (!_disposeResidencyReleased.Contains(resource.SurfaceId)
|
|
|| _disposeDeleted.Contains(resource.SurfaceId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
_backend.Delete(resource);
|
|
_disposeDeleted.Add(resource.SurfaceId);
|
|
_allocatedBytes = checked(
|
|
_allocatedBytes - resource.Bytes);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
(failures ??= []).Add(ex);
|
|
}
|
|
}
|
|
|
|
if (_disposeDeleted.Count != 0)
|
|
{
|
|
foreach (uint surfaceId in _disposeDeleted)
|
|
_entries.Remove(surfaceId);
|
|
}
|
|
|
|
if (_entries.Count == 0
|
|
&& _retirementLedger.AwaitingPublicationCount == 0)
|
|
{
|
|
_owners.Clear();
|
|
_unowned.Clear();
|
|
_disposeResidencyReleased.Clear();
|
|
_disposeDeleted.Clear();
|
|
_disposed = true;
|
|
}
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more standalone particle textures failed to retire.",
|
|
failures);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_disposing = false;
|
|
}
|
|
}
|
|
}
|