diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs index cb9899a8..cb328e20 100644 --- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs +++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs @@ -1,3 +1,5 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using AcDream.Core.Textures; using AcDream.Core.World; using Silk.NET.OpenGL; @@ -5,10 +7,63 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Location of one decoded entity-material composite in a resident bindless -/// texture array. The modern mesh shader consumes this exact pair. +/// Location of one decoded entity-material composite: the device texture-table +/// slot of the array holding it, plus the layer within that array. The modern +/// mesh shader consumes this exact pair. +/// +/// Campaign V slice V4t replaced the raw 64-bit +/// ARB_bindless_texture handle with , and +/// that makes the DEFAULT value load-bearing. The old type could say "not +/// resolved" with handle 0, because no texture ever has handle 0. A slot index +/// has no such spare value — default(GpuTextureSlot) is real slot 0 — so +/// a positional record would have turned every budget-rejected or +/// still-uploading return into a silent read of whichever texture registered +/// first. The slot is therefore stored one-based, which makes default +/// exactly . Same discipline, same reason, as +/// UiTextureTableHandle on the UI path. +/// +/// Internal rather than public because is +/// part of the internal RHI contract. Nothing outside this assembly and its +/// InternalsVisibleTo test assemblies ever named this type. /// -public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer); +internal readonly struct BindlessTextureLocation : IEquatable +{ + private readonly uint _slotPlusOne; + + public BindlessTextureLocation(GpuTextureSlot slot, uint layer) + { + _slotPlusOne = slot.IsAssigned ? slot.Index + 1 : 0; + Layer = layer; + } + + /// The "no composite yet" value. Identical to default. + public static BindlessTextureLocation Unresolved => default; + + /// Layer within the array. Meaningless unless . + public uint Layer { get; } + + public bool IsResolved => _slotPlusOne != 0; + + public GpuTextureSlot Slot => + _slotPlusOne == 0 ? GpuTextureSlot.Unassigned : new GpuTextureSlot(_slotPlusOne - 1); + + public bool Equals(BindlessTextureLocation other) => + _slotPlusOne == other._slotPlusOne && Layer == other.Layer; + + public override bool Equals(object? obj) => + obj is BindlessTextureLocation other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(_slotPlusOne, Layer); + + public static bool operator ==(BindlessTextureLocation left, BindlessTextureLocation right) => + left.Equals(right); + + public static bool operator !=(BindlessTextureLocation left, BindlessTextureLocation right) => + !left.Equals(right); + + public override string ToString() => + IsResolved ? $"{Slot}/layer{Layer}" : "unresolved"; +} internal enum CompositeTextureKind : byte { @@ -74,6 +129,14 @@ internal sealed class CompositeTextureArrayResource { public required uint Name { get; init; } public required ulong Handle { get; init; } + + /// + /// Campaign V slice V4t: this array's entry in the device texture table. + /// The backend that made resident also interned it, so + /// the pair is created and retired together and the cache above never has + /// to know a backend exists. + /// + public required GpuTextureSlot Slot { get; init; } public required int Width { get; init; } public required int Height { get; init; } public required int Capacity { get; init; } @@ -98,11 +161,13 @@ internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureA { private readonly GL _gl; private readonly Wb.BindlessSupport _bindless; + private readonly GlGpuDevice _device; - public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless) + public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless, GlGpuDevice device) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); + _device = device ?? throw new ArgumentNullException(nameof(device)); _gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers); MaximumArrayLayers = Math.Max(1, maximumLayers); } @@ -149,10 +214,15 @@ internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureA Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture); tracked = true; + // Campaign V slice V4t: intern the resident handle into the device's + // one texture table. A table-exhaustion throw here is caught by the + // same rollback below, and nothing was added to the table if it did. + GpuTextureSlot slot = _device.RegisterWorldTextureHandle(handle); return new CompositeTextureArrayResource { Name = name, Handle = handle, + Slot = slot, Width = width, Height = height, Capacity = capacity, @@ -248,6 +318,10 @@ internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureA Wb.GLHelpers.ThrowOnResourceError( _gl, $"releasing composite texture handle {resource.Handle} (precondition)"); + // Campaign V slice V4t: retire the table entry before the handle it + // names stops being resident. Idempotent, so this stays correct when + // the retryable release ledger re-runs the operation. + _device.ReleaseWorldTextureHandle(resource.Handle); _bindless.MakeNonResident(resource.Handle); Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}"); } @@ -341,13 +415,14 @@ internal sealed class CompositeTextureArrayCache : IDisposable public CompositeTextureArrayCache( GL gl, Wb.BindlessSupport bindless, + GlGpuDevice device, IGpuResourceRetirementQueue retirementQueue, long unownedBudgetBytes = DefaultUnownedBudgetBytes, long physicalBudgetBytes = DefaultPhysicalBudgetBytes, int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame, long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame) : this( - new GlCompositeTextureArrayBackend(gl, bindless), + new GlCompositeTextureArrayBackend(gl, bindless, device), retirementQueue, unownedBudgetBytes, physicalBudgetBytes, @@ -508,7 +583,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable ThrowIfUnavailable(); if (!_entries.TryGetValue(key, out Entry? entry)) { - location = default; + location = BindlessTextureLocation.Unresolved; return false; } @@ -516,7 +591,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable _unowned.MarkOwned(key); entry.Atlas.LastUseSequence = ++_useSequence; location = new BindlessTextureLocation( - entry.Atlas.Resource.Handle, + entry.Atlas.Resource.Slot, checked((uint)entry.Layer)); return true; } @@ -542,7 +617,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable // does not fit, stop all later decodes this frame rather than // repeatedly allocating RGBA buffers that cannot be uploaded. _uploadBudgetBlocked = true; - location = default; + location = BindlessTextureLocation.Unresolved; return false; } @@ -552,7 +627,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable // this frame. Tick advances compatible reclamation before the next // frame retries the same logical composite. _uploadBudgetBlocked = true; - location = default; + location = BindlessTextureLocation.Unresolved; return false; } @@ -585,7 +660,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable _owners.Acquire(ownerLocalId, key); _frameUploadCount++; _frameUploadBytes = checked(_frameUploadBytes + bytes); - location = new BindlessTextureLocation(atlas.Resource.Handle, checked((uint)layer)); + location = new BindlessTextureLocation(atlas.Resource.Slot, checked((uint)layer)); return true; } diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index b00d3d91..e3e6a366 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -46,7 +46,7 @@ public sealed unsafe class ParticleRenderer : IDisposable public readonly Vector3 AxisX; public readonly Vector3 AxisY; public readonly uint ColorArgb; - public readonly ulong TextureHandle; + public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot; public readonly float DistanceSq; public ParticleInstance( @@ -54,14 +54,14 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 axisX, Vector3 axisY, uint colorArgb, - ulong textureHandle, + AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, float distanceSq) { Position = position; AxisX = axisX; AxisY = axisY; ColorArgb = colorArgb; - TextureHandle = textureHandle; + TextureSlot = textureSlot; DistanceSq = distanceSq; } } @@ -124,17 +124,20 @@ public sealed unsafe class ParticleRenderer : IDisposable private readonly int _meshTextureIndexLoc = -1; private readonly int _meshTextureLayerLoc = -1; - // Campaign V slice V2c (2026-07-27): GL-only emulation of the eventual - // Vulkan global texture descriptor array (binding=9, - // GpuBindingModel.StorageTextureTable). Owns its own table — see - // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why - // particles don't share WbDrawDispatcher's/EnvCellRenderer's/ - // TerrainModernRenderer's tables. There is no automated pixel-gate - // coverage for particles (the offline gate's fixed outdoor view has none - // in frame), so this indirection is kept strictly mechanical. - private readonly GlBindlessHandleTable _textureTable = new(); - private uint _textureTableSsbo; - private int _textureTableSsboCapacityBytes; + // Campaign V slice V4t (2026-07-28): the interim per-renderer + // GlBindlessHandleTable is retired. Both particle texture sources now hand + // out the device's own GpuTextureSlot — TextureCache.AcquireParticleTexture + // for billboards, ObjectRenderBatch.TextureSlot for mesh particles — so all + // that is left here is flushing and binding that one table before each + // raw-GL draw. There is still no automated pixel-gate coverage for + // particles (the offline gate's fixed outdoor view has none in frame), so + // this change is kept strictly mechanical, exactly as V2c's was. + private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => + (_meshAdapter + ?? throw new InvalidOperationException( + "ParticleRenderer was constructed without a mesh adapter: its texture " + + "slots come from that adapter's GL device table (Campaign V slice V4t).")) + .WorldTextureTable; private uint _quadVao; private readonly uint _quadVbo; @@ -275,21 +278,6 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uParamA"); } - // Campaign V slice V2c: binding=9 texture-table SSBO (GL-only - // emulation of the eventual Vulkan descriptor array). - _textureTableSsbo = TrackedGlResource.CreateBuffer( - _gl, - "creating particle texture-table SSBO"); - RetryableGpuResourceRelease textureTableRelease = - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, - _textureTableSsbo, - () => _textureTableSsboCapacityBytes, - "rolling back particle texture-table SSBO"); - constructionResources.Add( - "particle texture-table SSBO", - textureTableRelease.Run); - float[] quadVerts = { -0.5f, -0.5f, 0f, 0f, @@ -568,7 +556,7 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.ProgramUniform1( _meshShader.Program, _meshTextureIndexLoc, - _textureTable.GetOrAdd(batch.BindlessTextureHandle)); + batch.TextureSlot.Index); // Slice V6e: uParamA is a float, so the layer is widened here rather // than in the shader's float(uTextureLayer). Layers are small // integers; the sampled value is bit-identical. @@ -763,7 +751,7 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.ProgramUniform1( _meshShader.Program, _meshTextureIndexLoc, - _textureTable.GetOrAdd(batch.BindlessTextureHandle)); + batch.TextureSlot.Index); // Slice V6e: uParamA is a float, so the layer is widened here rather // than in the shader's float(uTextureLayer). Layers are small // integers; the sampled value is bit-identical. @@ -962,7 +950,7 @@ public sealed unsafe class ParticleRenderer : IDisposable axisX, axisY, p.ColorArgb, - gfxInfo.TextureHandle, + gfxInfo.TextureSlot, distSq))); _submissionScratch.Add(new ParticleSubmission( ParticleSubmissionKind.Billboard, @@ -1005,7 +993,7 @@ public sealed unsafe class ParticleRenderer : IDisposable for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) { ObjectRenderBatch batch = renderData.Batches[batchIndex]; - if (batch.IndexCount <= 0 || batch.BindlessTextureHandle == 0) + if (batch.IndexCount <= 0 || !batch.TextureSlot.IsAssigned) continue; int drawIndex = _meshDrawListScratch.Count; @@ -1084,9 +1072,12 @@ public sealed unsafe class ParticleRenderer : IDisposable /// private const uint NoTextureSlot = 0xFFFFFFFFu; - // Campaign V slice V2c: instance method (not static) because it converts - // the particle's raw bindless handle to a _textureTable slot. - private void WriteBillboardGpuInstance( + // Campaign V slice V4t: static again — the particle already carries the + // device's table slot, so there is no per-renderer interning left to do. + // GpuTextureSlot.Unassigned and NoTextureSlot are the same 0xFFFFFFFF by + // construction (the contract's sentinel IS ACDREAM_TEXTURE_NONE), so the + // untextured branch collapses into the assignment rather than disappearing. + private static void WriteBillboardGpuInstance( ref BillboardGpuInstance destination, ParticleInstance particle) { @@ -1100,9 +1091,9 @@ public sealed unsafe class ParticleRenderer : IDisposable ((particle.ColorArgb >> 8) & 0xFF) / 255f, (particle.ColorArgb & 0xFF) / 255f, ((particle.ColorArgb >> 24) & 0xFF) / 255f), - TextureIndex = particle.TextureHandle == 0UL - ? NoTextureSlot - : _textureTable.GetOrAdd(particle.TextureHandle), + TextureIndex = particle.TextureSlot.IsAssigned + ? particle.TextureSlot.Index + : NoTextureSlot, }; } @@ -1282,45 +1273,22 @@ public sealed unsafe class ParticleRenderer : IDisposable } /// - /// Campaign V slice V2c: uploads 's handles to - /// when a new one was registered since the - /// last flush (by or either mesh - /// draw site's _textureTable.GetOrAdd), then (re)binds it at + /// Campaign V slice V4t: drains the device texture table's dirty runs and + /// (re)binds it at /// . - /// Called immediately before every draw call rather than once per pipeline - /// switch: a run of consecutive mesh-particle sub-batches can register a - /// new handle partway through, and the table must be current for each one. + /// Still called immediately before every draw call rather than once per + /// pipeline switch, for the reason V2c gave: a run of consecutive + /// mesh-particle sub-batches can pull in a texture whose slot was registered + /// this frame, and the table must be current for each one. /// private void FlushAndBindTextureTable() { - if (_textureTable.Dirty) - { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = handles.Length * sizeof(ulong); - fixed (ulong* p = handles) - { - _gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _textureTableSsbo); - if (_textureTableSsboCapacityBytes < byteCount) - { - int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount); - TrackedGlResource.AllocateBufferStorage( - _gl, - GLEnum.ShaderStorageBuffer, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - grown, - GLEnum.DynamicDraw, - "growing particle texture-table SSBO"); - _textureTableSsboCapacityBytes = grown; - } - _gl.BufferSubData(BufferTargetARB.ShaderStorageBuffer, 0, (nuint)byteCount, p); - } - _textureTable.MarkFlushed(); - } + AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable; + device.FlushTextureTable(); _gl.BindBufferBase( BufferTargetARB.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + device.TextureTableGlName); } private void ApplyMeshCullMode(CullMode mode) @@ -1431,7 +1399,7 @@ public sealed unsafe class ParticleRenderer : IDisposable ? ParticleGfxInfo.Default : info with { - TextureHandle = _textures.AcquireParticleTexture( + TextureSlot = _textures.AcquireParticleTexture( emitter.Handle, info.SurfaceId), }; @@ -1456,7 +1424,9 @@ public sealed unsafe class ParticleRenderer : IDisposable } return AuthoredParticleGfxInfo( gfx, - texture: 0, + // Shape only: the caller re-resolves the slot per emitter, so + // this record is cached with no texture rather than slot 0. + texture: AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned, additive, hasMaterial: surfaceId != 0, surfaceId: surfaceId); @@ -1469,7 +1439,7 @@ public sealed unsafe class ParticleRenderer : IDisposable private ParticleGfxInfo AuthoredParticleGfxInfo( GfxObj gfx, - ulong texture, + AcDream.App.Rendering.Gpu.GpuTextureSlot texture, bool additive, bool hasMaterial, uint surfaceId) @@ -1663,13 +1633,6 @@ public sealed unsafe class ParticleRenderer : IDisposable 6L * sizeof(uint), "quad-ebo", "deleting particle quad EBO"); - AddTrackedBufferRelease( - releases, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - "texture-table", - "deleting particle texture-table SSBO"); - for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) { List frameSets = _dynamicBufferSetsByFrame[frame]; @@ -1766,8 +1729,6 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshInstanceVbo = 0; _instanceVboCapacityBytes = 0; _meshInstanceVboCapacityBytes = 0; - _textureTableSsbo = 0; - _textureTableSsboCapacityBytes = 0; _particleGfxInfoByEmitter.Clear(); _particleGfxInfoByGfxObj.Clear(); _geometryKindByGfxObj.Clear(); @@ -1776,7 +1737,7 @@ public sealed unsafe class ParticleRenderer : IDisposable } private readonly record struct ParticleGfxInfo( - ulong TextureHandle, + AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot, Vector2 Size, Vector3 AxisX, Vector3 AxisY, @@ -1788,7 +1749,7 @@ public sealed unsafe class ParticleRenderer : IDisposable { public static ParticleGfxInfo Default { get; } = Billboard( - 0ul, + AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned, Vector2.One, Vector3.Zero, additive: false, @@ -1796,14 +1757,14 @@ public sealed unsafe class ParticleRenderer : IDisposable surfaceId: 0); public static ParticleGfxInfo Billboard( - ulong textureHandle, + AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, Vector2 size, Vector3 centerOffset, bool additive, bool hasMaterial, uint surfaceId) => new( - textureHandle, + textureSlot, size, Vector3.UnitX, Vector3.UnitY, diff --git a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs index 5952f590..d021478e 100644 --- a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs +++ b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs @@ -9,6 +9,13 @@ internal sealed class StandaloneBindlessTextureResource public required uint SurfaceId { get; init; } public required uint Name { get; init; } public required ulong Handle { get; init; } + + /// + /// Campaign V slice V4t: this texture's entry in the device texture table. + /// Created with 's residency and retired with it, so a + /// particle batch carries a backend-neutral slot rather than a GL handle. + /// + public required Gpu.GpuTextureSlot Slot { get; init; } public required long Bytes { get; init; } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index d90085c4..844c00ac 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -164,6 +164,7 @@ public sealed unsafe class TextureCache composite = new CompositeTextureArrayCache( gl!, bindless, + WorldDevice, retirementQueue, budgets.CompositeUnownedBytes, budgets.CompositePhysicalBytes); @@ -199,6 +200,20 @@ public sealed unsafe class TextureCache "(Texture2D upload, particle arrays, composite/bindless caches) are " + "unavailable until Campaign V slice V4t ports them onto the RHI."); + /// + /// The GL backend's device, for the world texture paths' table + /// registrations (Campaign V slice V4t). Those paths already require a GL + /// context — see — so the same construction that makes + /// non-null makes this cast sound; a Vulkan-composed + /// cache serves only the UI path through and never + /// reaches here. + /// + private GlGpuDevice WorldDevice => _device as GlGpuDevice + ?? throw new InvalidOperationException( + "This TextureCache's device is not the GL backend's: the world " + + "texture paths intern their bindless handles into GlGpuDevice's " + + "texture table (Campaign V slice V4t)."); + internal void RegisterResidencySources(ResidencyManager manager) { ArgumentNullException.ThrowIfNull(manager); @@ -435,8 +450,13 @@ public sealed unsafe class TextureCache /// Acquires the exact DAT-decoded one-layer texture array for a live /// particle emitter. Equivalent surfaces are shared; the cache ownership /// ends with . + /// + /// Campaign V slice V4t: returns the device texture-table + /// rather than the raw bindless handle. The + /// handle is still created, made resident and destroyed here — only the + /// table entry belongs to the device. /// - internal ulong AcquireParticleTexture(int emitterHandle, uint surfaceId) + internal GpuTextureSlot AcquireParticleTexture(int emitterHandle, uint surfaceId) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle); ArgumentOutOfRangeException.ThrowIfZero(surfaceId); @@ -447,7 +467,7 @@ public sealed unsafe class TextureCache surfaceId, out StandaloneBindlessTextureResource? existing)) { - return existing.Handle; + return existing.Slot; } DecodedTexture decoded = DecodeFromDats( @@ -462,15 +482,17 @@ public sealed unsafe class TextureCache Wb.GLHelpers.ThrowOnResourceError( Gl, $"making particle surface 0x{surfaceId:X8} resident"); + GpuTextureSlot slot = WorldDevice.RegisterWorldTextureHandle(handle); var resource = new StandaloneBindlessTextureResource { SurfaceId = surfaceId, Name = name, Handle = handle, + Slot = slot, Bytes = checked((long)decoded.Width * decoded.Height * 4L), }; textures.AddAndAcquire(ownerId, resource); - return handle; + return slot; } catch (Exception residencyFailure) { @@ -486,6 +508,10 @@ public sealed unsafe class TextureCache { Attempt(() => { + // Slice V4t: the table entry may or may not have been made + // before the failure. Releasing an unregistered handle is a + // no-op, so this covers both without asking which. + WorldDevice.ReleaseWorldTextureHandle(handle); _bindless!.MakeNonResident(handle); Wb.GLHelpers.ThrowOnResourceError( Gl, @@ -681,6 +707,9 @@ public sealed unsafe class TextureCache { public void MakeNonResident(StandaloneBindlessTextureResource resource) { + // Slice V4t: retire the table entry before its handle stops being + // resident. Idempotent, so a retried release stays correct. + owner.WorldDevice.ReleaseWorldTextureHandle(resource.Handle); owner._bindless!.MakeNonResident(resource.Handle); Wb.GLHelpers.ThrowOnResourceError( owner.Gl, diff --git a/src/AcDream.App/Rendering/Wb/CachedBatch.cs b/src/AcDream.App/Rendering/Wb/CachedBatch.cs index 6816d380..3cadb4b1 100644 --- a/src/AcDream.App/Rendering/Wb/CachedBatch.cs +++ b/src/AcDream.App/Rendering/Wb/CachedBatch.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Gpu; namespace AcDream.App.Rendering.Wb; @@ -22,7 +23,7 @@ namespace AcDream.App.Rendering.Wb; /// internal readonly record struct CachedBatch( GroupKey Key, - ulong BindlessTextureHandle, + GpuTextureSlot TextureSlot, Matrix4x4 RestPose, Vector3 LocalSortCenter = default, WbDrawDispatcher.InstanceGroup? Group = null, diff --git a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs index a37b289f..a0dcc187 100644 --- a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs +++ b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs @@ -151,7 +151,7 @@ internal sealed class EntityClassificationCache /// field has drifted from live state. /// /// - /// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose) + /// Caller passes per-batch live state (Key, TextureSlot, RestPose) /// reconstructed from the same path the populate ran. The cache iterates /// its stored entries in parallel and asserts equality. /// @@ -181,8 +181,8 @@ internal sealed class EntityClassificationCache cached.Key.Equals(live.Key), $"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}"); System.Diagnostics.Debug.Assert( - cached.BindlessTextureHandle == live.BindlessTextureHandle, - $"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}"); + cached.TextureSlot == live.TextureSlot, + $"EntityClassificationCache: texture slot drift for entity {entityId} batch {i}"); System.Diagnostics.Debug.Assert( MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f), $"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}"); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 4c4054a1..91a341a7 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -153,18 +153,19 @@ public sealed unsafe class EnvCellRenderer : private uint _sharedClipRegionSsbo; private uint _fallbackClipRegionSsbo; - // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual - // Vulkan global texture descriptor array (binding=9, - // GpuBindingModel.StorageTextureTable). Owns its own table rather than - // sharing WbDrawDispatcher's — EnvCellRenderer never had a TextureCache - // dependency and nothing requires index agreement between renderers (each - // rebinds its own buffer to binding=9 immediately before its own draw - // call). See GlBindlessHandleTable's doc comment and the campaign doc's - // §5.2. Lazily created; grown/uploaded only when a genuinely new handle - // appears (rare — see FlushAndBindTextureTable). - private readonly GlBindlessHandleTable _textureTable = new(); - private uint _textureTableSsbo; - private int _textureTableSsboCapacityBytes; + // Campaign V slice V4t (2026-07-28): the interim per-renderer + // GlBindlessHandleTable is retired. ObjectRenderBatch already carries the + // device's own GpuTextureSlot, so this renderer shares WbDrawDispatcher's + // table — the device's — and only has to flush and bind it before its own + // raw-GL draws. That also removes the V2 caveat that two renderers could + // legitimately number the same texture differently: there is now one + // numbering, and it is the one the mesh manager assigned at upload. + private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => + (_meshManager + ?? throw new InvalidOperationException( + "EnvCellRenderer was constructed without a mesh manager: its texture " + + "slots come from that manager's GL device table (Campaign V slice V4t).")) + .WorldTextureTable; // Reusable scratch arrays — avoid per-frame allocation. // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() @@ -283,7 +284,7 @@ public sealed unsafe class EnvCellRenderer : cullModes.Add(b.CullMode); if (b.IsTransparent) translucent++; if (b.IsAdditive) additive++; - if (b.BindlessTextureHandle == 0) zeroHandle++; + if (!b.TextureSlot.IsAssigned) zeroHandle++; } var cullList = string.Join(",", cullModes); lines.Add( @@ -1201,7 +1202,7 @@ public sealed unsafe class EnvCellRenderer : var rd = _meshManager.TryGetRenderData(gfxObjId); if (rd != null) foreach (var b in rd.Batches) - { batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (b.BindlessTextureHandle == 0) zh++; } + { batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (!b.TextureSlot.IsAssigned) zh++; } } sb.Append(" [0x").Append(cellId.ToString("X8")) .Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf) @@ -1625,9 +1626,9 @@ public sealed unsafe class EnvCellRenderer : _modernBatches[cmdIndex] = new ModernBatchData { // Campaign V slice V2: table slot, not the raw handle. - // See _textureTable's doc comment for why EnvCellRenderer - // owns its own table rather than sharing WbDrawDispatcher's. - TextureTableIndex = _textureTable.GetOrAdd(item.batch.BindlessTextureHandle), + // Slice V4t: the slot is the device's, assigned by the + // mesh manager at upload rather than interned here. + TextureTableIndex = item.batch.TextureSlot.Index, TextureIndex = (uint)item.batch.TextureIndex, }; @@ -1992,46 +1993,22 @@ public sealed unsafe class EnvCellRenderer : // --------------------------------------------------------------------------- /// - /// Uploads 's handles to - /// when a new one was registered since the last flush, then (re)binds it at + /// Campaign V slice V4t: drains the device texture table's dirty runs and + /// (re)binds it at /// . - /// A genuinely new handle is rare — new dat surfaces/atlases, not every - /// frame — so this is not part of the ring-buffered per-frame SSBO set; - /// see GlBindlessHandleTable's doc comment. + /// A genuinely new slot is rare — new dat surfaces/atlases, not every frame + /// — but the bind is unconditional, because GL's storage-buffer binding + /// points are global and another raw-GL renderer's binding 9 sits there + /// between two of these draws. /// private void FlushAndBindTextureTable() { - if (_textureTableSsbo == 0) - _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO"); - - if (_textureTable.Dirty) - { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = handles.Length * sizeof(ulong); - fixed (ulong* p = handles) - { - if (_textureTableSsboCapacityBytes < byteCount) - { - int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount); - TrackedGlResource.AllocateBufferStorage( - _gl, - GLEnum.ShaderStorageBuffer, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - grown, - GLEnum.DynamicDraw, - "growing EnvCell texture-table SSBO"); - _textureTableSsboCapacityBytes = grown; - } - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _textureTableSsbo); - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)byteCount, p); - } - _textureTable.MarkFlushed(); - } + AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable; + device.FlushTextureTable(); _gl.BindBufferBase( GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + device.TextureTableGlName); } // --------------------------------------------------------------------------- @@ -2202,12 +2179,6 @@ public sealed unsafe class EnvCellRenderer : AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, "fallback-clip-region", "deleting EnvCell fallback clip SSBO"); - AddTrackedBufferRelease( - releases, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - "texture-table", - "deleting EnvCell texture-table SSBO"); _disposeResources = new RetryableResourceReleaseLedger(releases); } @@ -2229,8 +2200,6 @@ public sealed unsafe class EnvCellRenderer : _globalLightsSsbo = 0; _instLightSetSsbo = 0; _fallbackClipRegionSsbo = 0; - _textureTableSsbo = 0; - _textureTableSsboCapacityBytes = 0; _disposeResources = null; IsDisposed = true; diff --git a/src/AcDream.App/Rendering/Wb/GroupKey.cs b/src/AcDream.App/Rendering/Wb/GroupKey.cs index cda45879..3a79567f 100644 --- a/src/AcDream.App/Rendering/Wb/GroupKey.cs +++ b/src/AcDream.App/Rendering/Wb/GroupKey.cs @@ -1,3 +1,4 @@ +using AcDream.App.Rendering.Gpu; using AcDream.Core.Meshing; using DatReaderWriter.Enums; @@ -10,12 +11,23 @@ namespace AcDream.App.Rendering.Wb; /// internal at file scope (was a private nested type) so /// can store it inside /// without depending on dispatcher internals. +/// +/// Campaign V slice V4t replaced the raw 64-bit +/// ARB_bindless_texture handle with . The +/// substitution is a bijection — the device interns one slot per resident +/// handle — so exactly the same (entity, batch) pairs bucket together as +/// before, which is the property that keeps submission order identical. This +/// key's VALUE never orders anything: groups are enumerated in the dictionary's +/// insertion order and sorted by cull mode then camera distance +/// (CompareOpaqueSubmissionOrder / CompareTransparentSubmissionOrder), +/// and the delayed-alpha path sorts by viewer distance then submission ordinal. +/// The key reaches only equality, hashing, and the scene-digest fingerprints. /// internal readonly record struct GroupKey( uint FirstIndex, int BaseVertex, int IndexCount, - ulong BindlessTextureHandle, + GpuTextureSlot TextureSlot, uint TextureLayer, TranslucencyKind Translucency, CullMode CullMode = CullMode.CounterClockwise); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index ae3a42b4..e3dd2b17 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -93,7 +93,20 @@ namespace AcDream.App.Rendering.Wb // Modern rendering path fields public uint FirstIndex { get; set; } public uint BaseVertex { get; set; } - public ulong BindlessTextureHandle { get; set; } + + /// + /// Campaign V slice V4t: the shared atlas's entry in the device texture + /// table, replacing the raw 64-bit ARB_bindless_texture handle + /// this used to carry. Wrap and clamp addressing are two different + /// entries because a bindless handle bakes its sampler — see + /// , which selects between them. + /// + /// internal on an otherwise public type because + /// GpuTextureSlot belongs to the internal RHI contract. Every + /// reader is a renderer inside this assembly. + /// + internal AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot { get; set; } + = AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned; } /// @@ -107,6 +120,19 @@ namespace AcDream.App.Rendering.Wb private readonly IPreparedAssetSource _preparedAssets; private readonly ILogger _logger; + /// + /// Campaign V slice V4t: the GL backend's device, whose texture table + /// every shared atlas's bindless handle is interned into. Also the + /// device the two world renderers this class feeds + /// (WbDrawDispatcher, EnvCellRenderer) flush and bind + /// through — they take it from here rather than from a second + /// composition wire, because a batch's slot and the table that resolves + /// it must come from the same device by construction. + /// + private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice _worldTextureTable; + + internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => _worldTextureTable; + /// /// The immutable prepared-payload source is injected by composition. /// Production uses the validated pak; UI Studio explicitly supplies the @@ -164,6 +190,14 @@ namespace AcDream.App.Rendering.Wb // the owners here makes that overlap both retryable and observable. private readonly List _retiringAtlases = []; + // Campaign V slice V4t: the two bindless handles a retiring atlas had + // when it left the live set. ManagedGLTextureArray.Dispose zeroes its + // own copies as its first act, so the values must be snapshotted at the + // moment of eviction to be releasable from the device's texture table + // once physical retirement completes. + private readonly Dictionary + _retiringAtlasTextureHandles = []; + // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) private readonly CpuMeshUploadCache _cpuMeshCache; @@ -421,6 +455,10 @@ namespace AcDream.App.Rendering.Wb _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice)); ArgumentNullException.ThrowIfNull(gpuDevice); + // Slice V4t: this class only ever exists on GL — it takes an + // OpenGLGraphicsDevice — so the backend cast states that fact rather + // than narrowing anything. + _worldTextureTable = (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice; _preparedAssets = preparedAssets ?? throw new ArgumentNullException(nameof(preparedAssets)); _logger = logger @@ -544,6 +582,9 @@ namespace AcDream.App.Rendering.Wb } _dirtyAtlases.Remove(victim); _retiringAtlases.Add(victim); + _retiringAtlasTextureHandles[victim] = ( + victim.TextureArray.BindlessWrapHandle, + victim.TextureArray.BindlessClampHandle); victim.Dispose(); RemoveCompletedAtlasRetirements(); return true; @@ -560,11 +601,29 @@ namespace AcDream.App.Rendering.Wb { for (int i = _retiringAtlases.Count - 1; i >= 0; i--) { - if (_retiringAtlases[i].IsPhysicalRetirementComplete) - _retiringAtlases.RemoveAt(i); + if (!_retiringAtlases[i].IsPhysicalRetirementComplete) + continue; + // Campaign V slice V4t: the array's handles are non-resident and + // its texture deleted by the time physical retirement reports + // complete, so this is the point at which its two table entries + // stop naming anything. Without it, a session that churns atlases + // would accumulate entries against the table's fixed capacity — + // the interim per-renderer tables grew without bound instead, so + // this is stricter than what it replaces, not looser. The + // device defers the index itself behind its retirement queue. + ReleaseAtlasTextureSlots(_retiringAtlases[i]); + _retiringAtlases.RemoveAt(i); } } + private void ReleaseAtlasTextureSlots(TextureAtlasManager atlas) + { + if (!_retiringAtlasTextureHandles.Remove(atlas, out (ulong Wrap, ulong Clamp) handles)) + return; + _worldTextureTable.ReleaseWorldTextureHandle(handles.Wrap); + _worldTextureTable.ReleaseWorldTextureHandle(handles.Clamp); + } + private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas) { if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas)) @@ -2036,9 +2095,18 @@ namespace AcDream.App.Rendering.Wb legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort))); } + // Campaign V slice V4t: intern the atlas's resident + // handle into the device's one texture table and carry + // the slot. Registration is idempotent by handle, so the + // many batches sharing an atlas share its entry; + // ManagedGLTextureArray still owns the residency and the + // GL texture, and the entry is retired when the array's + // physical retirement completes. ulong bindlessHandle = batch.HasWrappingUVs ? atlasManager.TextureArray.BindlessWrapHandle : atlasManager.TextureArray.BindlessClampHandle; + AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot = + _worldTextureTable.RegisterWorldTextureHandle(bindlessHandle); renderBatches.Add(new ObjectRenderBatch { @@ -2056,7 +2124,7 @@ namespace AcDream.App.Rendering.Wb CullMode = batch.CullMode, FirstIndex = firstIndex, BaseVertex = (uint)batchBaseVertex, - BindlessTextureHandle = bindlessHandle, + TextureSlot = textureSlot, }); } } @@ -2737,6 +2805,12 @@ namespace AcDream.App.Rendering.Wb _uploadRollbackQueue.Clear(); _globalAtlases.Clear(); _retiringAtlases.Clear(); + // Slice V4t: teardown drops the snapshots without releasing their + // table entries. The device is torn down alongside this manager, so + // there is nothing left to recycle a slot into — and asking a + // possibly-already-disposed device to defer work through its + // retirement queue would turn a clean shutdown into a throw. + _retiringAtlasTextureHandles.Clear(); _dirtyAtlases.Clear(); _safeEmptyAtlases.Clear(); _currentNonArenaGpuMemory = 0; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index b04df854..985a8183 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -607,14 +607,14 @@ public sealed unsafe partial class WbDrawDispatcher out bool compositePending); if (compositePending) reusableAcrossFrames = false; - if (texture.Handle == 0) + if (!texture.Slot.IsAssigned) continue; var key = new GroupKey( batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, - texture.Handle, + texture.Slot, texture.Layer, translucency, batch.CullMode); @@ -714,7 +714,7 @@ public sealed unsafe partial class WbDrawDispatcher FirstIndex = key.FirstIndex, BaseVertex = key.BaseVertex, IndexCount = key.IndexCount, - BindlessTextureHandle = key.BindlessTextureHandle, + TextureSlot = key.TextureSlot, TextureLayer = key.TextureLayer, Translucency = key.Translucency, CullMode = key.CullMode, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index bb82c7e1..5ed6ec0a 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Scene; using AcDream.Core.Lighting; @@ -493,16 +494,17 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private uint _instSelectionLightingSsbo; private int _instSelectionLightingSsboCapacityBytes; - // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual - // Vulkan global texture descriptor array (binding=9, - // GpuBindingModel.StorageTextureTable). A genuinely new handle is rare — - // new dat surfaces/atlases, not every frame — so this single buffer is - // NOT part of the ring-buffered DynamicBufferSet below; see - // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why - // this table is owned here rather than by GlGpuDevice. Lazily created. - private readonly GlBindlessHandleTable _textureTable = new(); - private uint _textureTableSsbo; - private int _textureTableSsboCapacityBytes; + // Campaign V slice V4t (2026-07-28): the interim per-renderer + // GlBindlessHandleTable is retired. Batch data now carries the device's own + // GpuTextureSlot, produced by the texture stack at upload time, so this + // renderer only has to flush and bind that one table at + // GpuBindingModel.StorageTextureTable before each of its raw-GL draws — it + // does not submit through the encoder, so it never reaches + // GlGpuDevice.FlushBeforeDraw. Taken from the mesh adapter rather than + // wired separately: a batch's slot and the table that resolves it must come + // from the same device by construction. + private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => + _meshAdapter.WorldTextureTable; private sealed class DynamicBufferSet { @@ -2521,15 +2523,15 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable } } - // Campaign V slice V2: instance method (not static) because it converts - // the group's raw bindless handle to a _textureTable slot. - private IndirectGroupInput ToInput(InstanceGroup g) => new( + // Campaign V slice V4t: static again — the group already carries the + // device's table slot, so there is no per-renderer interning left to do. + private static IndirectGroupInput ToInput(InstanceGroup g) => new( IndexCount: g.IndexCount, FirstIndex: g.FirstIndex, BaseVertex: g.BaseVertex, InstanceCount: g.InstanceCount, FirstInstance: g.FirstInstance, - TextureIndex: _textureTable.GetOrAdd(g.BindlessTextureHandle), + TextureIndex: g.TextureSlot.Index, TextureLayer: g.TextureLayer, Translucency: g.Translucency, CullMode: g.CullMode); @@ -2599,7 +2601,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable g.FirstIndex, g.BaseVertex, g.IndexCount, - g.BindlessTextureHandle, + g.TextureSlot, g.TextureLayer, g.Translucency, g.CullMode); @@ -2777,7 +2779,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable hash.Add(key.FirstIndex); hash.Add(key.BaseVertex); hash.Add(key.IndexCount); - hash.Add(key.BindlessTextureHandle); + hash.Add(key.TextureSlot.Index); hash.Add(key.TextureLayer); hash.Add((int)key.Translucency); hash.Add((int)key.CullMode); @@ -2827,7 +2829,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable hash.Add(key.FirstIndex); hash.Add(key.BaseVertex); hash.Add(key.IndexCount); - hash.Add(key.BindlessTextureHandle); + hash.Add(key.TextureSlot.Index); hash.Add(key.TextureLayer); hash.Add((int)key.Translucency); hash.Add((int)key.CullMode); @@ -2983,7 +2985,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _batchData[i] = new BatchData { // Campaign V slice V2: table slot, not the raw handle. - TextureIndex = _textureTable.GetOrAdd(key.BindlessTextureHandle), + TextureIndex = key.TextureSlot.Index, TextureLayer = key.TextureLayer, Flags = 0, }; @@ -3032,12 +3034,12 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 6, _instIndoorSsbo); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 7, _instAlphaSsbo); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 8, _instSelectionLightingSsbo); - // Campaign V slice V2: already flushed/uploaded in UploadDeferredAlphaBuffers - // (this is the same non-ring buffer as the main draw path); just rebind. + // Campaign V slice V2: already flushed in UploadDeferredAlphaBuffers + // (this is the same device table as the main draw path); just rebind. _gl.BindBufferBase( BufferTargetARB.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + WorldTextureTable.TextureTableGlName); BindClipRegionBinding2(); _gl.BindVertexArray(global.VAO); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); @@ -3173,8 +3175,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, p, count * sizeof(float) * 2); UploadGlobalLights(); - // Campaign V slice V2 (binding=9): PrepareDeferredAlphaDraws registers - // handles into _textureTable above; flush/rebind before DrawPreparedAlphaBatch. + // Campaign V slice V2 (binding=9): flush/rebind the device texture table + // before DrawPreparedAlphaBatch, which submits without going through + // here again. FlushAndBindTextureTable(); fixed (DrawElementsIndirectCommand* p = _indirectCommands) @@ -3457,39 +3460,24 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable } /// - /// Campaign V slice V2: uploads 's handles to - /// when a new one was registered since the - /// last flush (by or ), - /// then (re)binds it at . - /// A genuinely new handle is rare — new dat surfaces/composite overrides, - /// not every frame — so unlike the SSBOs above this is not part of the - /// ring-buffered ; see - /// 's doc comment. + /// Campaign V slice V4t: drains the device texture table's dirty runs and + /// (re)binds it at + /// . + /// The drain is normally a no-op — a genuinely new slot means a new dat + /// surface or composite override, not a new frame — but the bind is + /// unconditional, because GL storage-buffer binding points are global and + /// another raw-GL renderer's binding 9 sits there between two of these + /// draws. Deleted with the raw-GL world path once these draws go through the + /// encoder, which binds the same table on every pipeline bind. /// - private unsafe void FlushAndBindTextureTable() + private void FlushAndBindTextureTable() { - if (_textureTableSsbo == 0) - _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO"); - - if (_textureTable.Dirty) - { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = handles.Length * sizeof(ulong); - fixed (ulong* p = handles) - { - UploadDynamicBuffer( - BufferTargetARB.ShaderStorageBuffer, - _textureTableSsbo, - ref _textureTableSsboCapacityBytes, - p, - byteCount); - } - _textureTable.MarkFlushed(); - } + AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable; + device.FlushTextureTable(); _gl.BindBufferBase( BufferTargetARB.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + device.TextureTableGlName); } /// @@ -3764,7 +3752,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable FirstIndex = key.FirstIndex, BaseVertex = key.BaseVertex, IndexCount = key.IndexCount, - BindlessTextureHandle = key.BindlessTextureHandle, + TextureSlot = key.TextureSlot, TextureLayer = key.TextureLayer, Translucency = key.Translucency, CullMode = key.CullMode, @@ -3944,13 +3932,17 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable out bool compositePending); if (compositePending) allTexturesReady = false; - if (texture.Handle == 0) continue; - ulong texHandle = texture.Handle; + // Campaign V slice V4t: an unassigned slot is the "no texture yet" + // case a zero handle used to signal. It is a real sentinel + // (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the + // default value, so nothing here can silently resolve to slot 0. + if (!texture.Slot.IsAssigned) continue; + GpuTextureSlot texSlot = texture.Slot; uint texLayer = texture.Layer; var key = new GroupKey( batch.FirstIndex, (int)batch.BaseVertex, - batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode); + batch.IndexCount, texSlot, texLayer, translucency, batch.CullMode); InstanceGroup grp = GetOrCreateInstanceGroup(key); grp.Matrices.Add(model); @@ -3962,7 +3954,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable grp.SelectionLighting.Add(_currentEntitySelectionLighting); collector?.Add(new CachedBatch( key, - texHandle, + texSlot, restPose, renderData.SortCenter, grp, @@ -3971,7 +3963,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable return allTexturesReady; } - private readonly record struct ResolvedTexture(ulong Handle, uint Layer); + private readonly record struct ResolvedTexture(GpuTextureSlot Slot, uint Layer); private ResolvedTexture ResolveTexture( in RenderInstanceCandidate entity, @@ -4038,8 +4030,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable origTexOverride, paletteOverride!, paletteIdentity); - compositePending = texture.Handle == 0; - return new ResolvedTexture(texture.Handle, texture.Layer); + compositePending = !texture.IsResolved; + return new ResolvedTexture(texture.Slot, texture.Layer); } case WbTextureResolutionKind.OriginalTextureOverride: @@ -4049,13 +4041,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable localEntityId, surfaceId, overrideOrigTex); - compositePending = texture.Handle == 0; - return new ResolvedTexture(texture.Handle, texture.Layer); + compositePending = !texture.IsResolved; + return new ResolvedTexture(texture.Slot, texture.Layer); } case WbTextureResolutionKind.SharedAtlas: return new ResolvedTexture( - batch.BindlessTextureHandle, + batch.TextureSlot, checked((uint)batch.TextureIndex)); default: @@ -4156,13 +4148,6 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable "deleting entity fallback clip SSBO", _gl.DeleteBuffer); - AddTrackedBufferRelease( - releases, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - "texture-table", - "deleting entity texture-table SSBO"); - if (!_gpuQueriesInitialized) return; for (int i = 0; i < GpuQueryRingDepth; i++) @@ -4265,8 +4250,6 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _instAlphaSsbo = 0; _instSelectionLightingSsbo = 0; _fallbackClipRegionSsbo = 0; - _textureTableSsbo = 0; - _textureTableSsboCapacityBytes = 0; Array.Clear(_gpuQueryOpaque); Array.Clear(_gpuQueryTransparent); _gpuQueriesInitialized = false; @@ -4440,7 +4423,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable public uint FirstIndex; public int BaseVertex; public int IndexCount; - public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4) + // Campaign V slice V4t: the device texture-table slot (was a raw 64-bit + // ARB_bindless_texture handle, and a uint TextureHandle in N.4). + public AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot = + AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned; public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas. public TranslucencyKind Translucency; public CullMode CullMode; diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index d55e2165..7cddfa07 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -251,6 +251,19 @@ public sealed class WbMeshAdapter : null; } + /// + /// Campaign V slice V4t: the GL device whose texture table every mesh + /// batch's GpuTextureSlot indexes. The world renderers this adapter + /// feeds flush and bind that table before their raw-GL draws, and taking it + /// from here rather than from a second composition wire is what makes "the + /// slot and the table came from the same device" true by construction. + /// + internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => + (_meshManager + ?? throw new InvalidOperationException( + "An initialized mesh adapter is required for the world texture table.")) + .WorldTextureTable; + internal void RegisterResidencySources(ResidencyManager manager) { ArgumentNullException.ThrowIfNull(manager); diff --git a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs index bf134599..539332e2 100644 --- a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs @@ -6,6 +6,54 @@ namespace AcDream.App.Tests.Rendering; public sealed class CompositeTextureArrayCachePolicyTests { + /// + /// Campaign V slice V4t: the retype from a 64-bit bindless handle to + /// removed the spare + /// value that used to mean "not resolved" — no texture has handle 0, but + /// slot 0 is a perfectly good slot. This pins the replacement invariant: + /// the DEFAULT location is unresolved, and it is distinguishable from a + /// location that genuinely names slot 0. Getting this wrong would not throw; + /// it would draw the first-registered texture on every unresolved + /// composite, which is the magenta-placeholder failure shape one layer down. + /// + [Fact] + public void DefaultLocationIsUnresolvedAndDistinctFromSlotZero() + { + Assert.False(default(BindlessTextureLocation).IsResolved); + Assert.Equal(BindlessTextureLocation.Unresolved, default(BindlessTextureLocation)); + Assert.False(BindlessTextureLocation.Unresolved.Slot.IsAssigned); + + var slotZero = new BindlessTextureLocation( + new AcDream.App.Rendering.Gpu.GpuTextureSlot(0), layer: 3); + Assert.True(slotZero.IsResolved); + Assert.Equal(0u, slotZero.Slot.Index); + Assert.Equal(3u, slotZero.Layer); + Assert.NotEqual(BindlessTextureLocation.Unresolved, slotZero); + } + + [Fact] + public void BudgetRejectionReturnsAnUnresolvedLocation() + { + // One layer per atlas and room for exactly one atlas, so the second + // composite is refused by the physical budget rather than uploaded. + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + physicalBudgetBytes: 64); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + + Assert.False(cache.TryAddAndAcquire( + 2, + Key(2), + Texture(4, 4), + out BindlessTextureLocation rejected)); + Assert.False(rejected.IsResolved); + Assert.False(rejected.Slot.IsAssigned); + } + [Fact] public void ResidencySnapshotSeparatesResidentRequestedAndRetiringStorage() { @@ -77,7 +125,7 @@ public sealed class CompositeTextureArrayCachePolicyTests Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out BindlessTextureLocation second)); Assert.Equal(first, shared); - Assert.Equal(first.Handle, second.Handle); + Assert.Equal(first.Slot, second.Slot); Assert.NotEqual(first.Layer, second.Layer); Assert.Single(backend.Created); Assert.Equal(2, backend.Uploads.Count); @@ -124,7 +172,7 @@ public sealed class CompositeTextureArrayCachePolicyTests Assert.Equal(1, retirements.Count); cache.BeginFrame(); Assert.True(cache.TryAddAndAcquire(2, Key(1), Texture(4, 4), out BindlessTextureLocation replacement)); - Assert.NotEqual(old.Handle, replacement.Handle); + Assert.NotEqual(old.Slot, replacement.Slot); Assert.Equal(2, backend.Created.Count); retirements.DrainAll(); @@ -369,7 +417,7 @@ public sealed class CompositeTextureArrayCachePolicyTests cache.BeginFrame(); cache.Tick(); Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out BindlessTextureLocation reused)); - Assert.Equal(first.Handle, reused.Handle); + Assert.Equal(first.Slot, reused.Slot); Assert.Single(backend.Created); Assert.Equal(64, cache.AllocatedBytes); } @@ -606,6 +654,10 @@ public sealed class CompositeTextureArrayCachePolicyTests { Name = name, Handle = 1000UL + name, + // Slice V4t: the GL backend interns each resident handle into + // GlGpuDevice's table; the fake mints a deterministic stand-in + // so slot identity is still comparable across entries. + Slot = new AcDream.App.Rendering.Gpu.GpuTextureSlot(name), Width = width, Height = height, Capacity = capacity, diff --git a/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs index 08033fd7..e144ece6 100644 --- a/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs @@ -300,6 +300,7 @@ public sealed class StandaloneBindlessTextureCacheTests SurfaceId = surfaceId, Name = surfaceId, Handle = surfaceId + 1_000UL, + Slot = new AcDream.App.Rendering.Gpu.GpuTextureSlot(surfaceId), Bytes = bytes, }; diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index 4a8852fa..ae606e47 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -24,7 +24,7 @@ public class InstanceGroupClearTests cache.Populate( entityId: 100, landblockHint: 0xA9B40000u, - batches: [new CachedBatch(key, 0xAA, Matrix4x4.Identity)], + batches: [new CachedBatch(key, Slot(0xAA), Matrix4x4.Identity)], selectionParts: parts); Assert.True(cache.TryGet(100, 0xA9B40000u, out EntityCacheEntry? entry)); @@ -91,10 +91,10 @@ public class InstanceGroupClearTests public void DispatcherFingerprint_EqualDistanceAlphaUsesOriginalSubmissionOrder() { WbDrawDispatcher.InstanceGroup first = MakeCompleteGroup( - textureHandle: 0xAA, + textureSlot: 0xAA, submissionOrder: 0); WbDrawDispatcher.InstanceGroup second = MakeCompleteGroup( - textureHandle: 0xBB, + textureSlot: 0xBB, submissionOrder: 1); var scratch = new List(); @@ -143,7 +143,7 @@ public class InstanceGroupClearTests public void DispatcherFingerprint_ZeroVisibleInstancesIgnoresStaleGroupStorage() { WbDrawDispatcher.InstanceGroup stale = MakeCompleteGroup( - textureHandle: 0xAA, + textureSlot: 0xAA, submissionOrder: 0); var scratch = new List(); @@ -177,12 +177,12 @@ public class InstanceGroupClearTests FirstIndex: 0, BaseVertex: 0, IndexCount: 6, - BindlessTextureHandle: 0xAA, + TextureSlot: Slot(0xAA), TextureLayer: 0, Translucency: TranslucencyKind.Opaque); var cached = new CachedBatch( key, - 0xAA, + Slot(0xAA), Matrix4x4.Identity, Group: group, GroupRegistration: 17); @@ -221,7 +221,7 @@ public class InstanceGroupClearTests var retiredKeys = new List(); var staleCache = new CachedBatch( retiredKey, - retiredKey.BindlessTextureHandle, + retiredKey.TextureSlot, Matrix4x4.Identity, Group: retired, GroupRegistration: retired.Registration); @@ -274,11 +274,13 @@ public class InstanceGroupClearTests Assert.Equal(22, paperdoll.Registration); } - private static GroupKey MakeKey(ulong textureHandle) => new( + private static AcDream.App.Rendering.Gpu.GpuTextureSlot Slot(uint index) => new(index); + + private static GroupKey MakeKey(uint textureSlot) => new( FirstIndex: 0, BaseVertex: 0, IndexCount: 6, - BindlessTextureHandle: textureHandle, + TextureSlot: Slot(textureSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque); @@ -294,7 +296,7 @@ public class InstanceGroupClearTests } private static WbDrawDispatcher.InstanceGroup MakeCompleteGroup( - ulong textureHandle, + uint textureSlot, int submissionOrder) { var group = new WbDrawDispatcher.InstanceGroup @@ -302,7 +304,7 @@ public class InstanceGroupClearTests FirstIndex = 0, BaseVertex = 0, IndexCount = 6, - BindlessTextureHandle = textureHandle, + TextureSlot = Slot(textureSlot), TextureLayer = 0, Translucency = TranslucencyKind.AlphaBlend, }; diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs index fe57939e..5f86aee9 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs @@ -23,8 +23,8 @@ public class EntityClassificationCacheTests var cache = new EntityClassificationCache(); var batches = new[] { - MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA), - MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB), + MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texSlot: 0xAA), + MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texSlot: 0xBB), }; cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches); @@ -46,7 +46,7 @@ public class EntityClassificationCacheTests Assert.True(cache.TryGet(100, 0u, out var entry)); Assert.NotNull(entry); Assert.Single(entry!.Batches); - Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle); + Assert.Equal(0xCCu, entry.Batches[0].TextureSlot.Index); } // ── #119 root-cause regression (2026-06-11): colliding entity ids across @@ -152,15 +152,15 @@ public class EntityClassificationCacheTests ibo: (uint)(subPart + 1), firstIndex: (uint)(b * 6), indexCount: 6, - texHandle: (ulong)(0x100 + subPart * 2 + b)); + texSlot: (uint)(0x100 + subPart * 2 + b)); } cache.Populate(99, 0u, batches); Assert.True(cache.TryGet(99, 0u, out var entry)); Assert.NotNull(entry); Assert.Equal(6, entry!.Batches.Length); - Assert.Equal(0x100u, entry.Batches[0].BindlessTextureHandle); - Assert.Equal(0x105u, entry.Batches[5].BindlessTextureHandle); + Assert.Equal(0x100u, entry.Batches[0].TextureSlot.Index); + Assert.Equal(0x105u, entry.Batches[5].TextureSlot.Index); } [Fact] @@ -248,7 +248,7 @@ public class EntityClassificationCacheTests Assert.True(cache.TryGet(100, 0xA9B40000u, out var entry)); Assert.NotNull(entry); Assert.Equal(batchesV2, entry!.Batches); - Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle); + Assert.Equal(0xCCu, entry.Batches[0].TextureSlot.Index); } #if DEBUG @@ -328,15 +328,15 @@ public class EntityClassificationCacheTests #endif private static CachedBatch MakeCachedBatch( - uint ibo, uint firstIndex, int indexCount, ulong texHandle) + uint ibo, uint firstIndex, int indexCount, uint texSlot) { var key = new GroupKey( FirstIndex: firstIndex, BaseVertex: 0, IndexCount: indexCount, - BindlessTextureHandle: texHandle, + TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque); - return new CachedBatch(key, texHandle, Matrix4x4.Identity); + return new CachedBatch(key, new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), Matrix4x4.Identity); } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs index 73707dc7..f0740973 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -416,7 +416,7 @@ public sealed class WbDrawDispatcherBucketingTests uint ibo, uint firstIndex, int indexCount, - ulong texHandle, + uint texSlot, Matrix4x4? restPose = null, Vector3? localSortCenter = null) { @@ -424,12 +424,12 @@ public sealed class WbDrawDispatcherBucketingTests FirstIndex: firstIndex, BaseVertex: 0, IndexCount: indexCount, - BindlessTextureHandle: texHandle, + TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque); return new CachedBatch( key, - texHandle, + new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), restPose ?? Matrix4x4.Identity, localSortCenter ?? Vector3.Zero); } @@ -459,8 +459,8 @@ public sealed class WbDrawDispatcherBucketingTests const uint LandblockId = 0xA9B40000u; // First MeshRef contributes 2 batches (mimics ClassifyBatches output). - scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA)); - scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB)); + scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texSlot: 0xAA)); + scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texSlot: 0xBB)); uint? populateEntityId = null; uint populateLandblockId = 0u; @@ -479,8 +479,8 @@ public sealed class WbDrawDispatcherBucketingTests Assert.True(cache.TryGet(EntityId, LandblockId, out var entry)); Assert.NotNull(entry); Assert.Equal(2, entry!.Batches.Length); - Assert.Equal(0xAAul, entry.Batches[0].BindlessTextureHandle); - Assert.Equal(0xBBul, entry.Batches[1].BindlessTextureHandle); + Assert.Equal(0xAAu, entry.Batches[0].TextureSlot.Index); + Assert.Equal(0xBBu, entry.Batches[1].TextureSlot.Index); // Frame 2: cache hit. ApplyCacheHit walks the cached batches and // appends RestPose * entityWorld to a per-frame group dict. @@ -535,7 +535,7 @@ public sealed class WbDrawDispatcherBucketingTests ibo: 1, firstIndex: 0, indexCount: 6, - texHandle: 0xAA, + texSlot: 0xAA, localSortCenter: authoredCenter), ], }; @@ -638,16 +638,16 @@ public sealed class WbDrawDispatcherBucketingTests populateEntityId, populateLandblockId, EntityId, cache, scratch); // Mimic ClassifyBatches' collector output for THIS MeshRef: - // 2 batches with distinct (ibo, firstIndex, texHandle) so the + // 2 batches with distinct (ibo, firstIndex, texSlot) so the // ordering can be verified post-hoc. for (int b = 0; b < BatchesPerMeshRef; b++) { - ulong texHandle = (ulong)(0x100 + meshRefIdx * BatchesPerMeshRef + b); + uint texSlot = (uint)(0x100 + meshRefIdx * BatchesPerMeshRef + b); scratch.Add(MakeCachedBatch( ibo: (uint)(meshRefIdx + 1), firstIndex: (uint)(b * 6), indexCount: 6, - texHandle: texHandle)); + texSlot: texSlot)); } // After ClassifyBatches, Draw sets the tracker (matching the @@ -676,7 +676,7 @@ public sealed class WbDrawDispatcherBucketingTests // Per-batch ordering check: batches arrived in MeshRef order, so // texture handles run 0x100..0x105 in the order they were appended. for (int i = 0; i < ExpectedTotalBatches; i++) - Assert.Equal((ulong)(0x100 + i), entry.Batches[i].BindlessTextureHandle); + Assert.Equal((uint)(0x100 + i), entry.Batches[i].TextureSlot.Index); // After flush, scratch is cleared so the next entity starts fresh. Assert.Empty(scratch); @@ -717,12 +717,12 @@ public sealed class WbDrawDispatcherBucketingTests currentEntityIncomplete = true; // Tuple 1 (MeshRef[1]): renderData valid -> classify, accumulate. - scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAAul)); + scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texSlot: 0xAA)); populateEntityId = EntityId; populateLandblockId = LandblockId; // Tuple 2 (MeshRef[2]): renderData valid -> classify, accumulate. - scratch.Add(MakeCachedBatch(ibo: 2, firstIndex: 0, indexCount: 6, texHandle: 0xBBul)); + scratch.Add(MakeCachedBatch(ibo: 2, firstIndex: 0, indexCount: 6, texSlot: 0xBB)); populateEntityId = EntityId; populateLandblockId = LandblockId; @@ -774,7 +774,7 @@ public sealed class WbDrawDispatcherBucketingTests for (int i = 0; i < CachedBatchCount; i++) { batches[i] = MakeCachedBatch( - ibo: 1u, firstIndex: (uint)i, indexCount: 6, texHandle: (ulong)(0x100 + i)); + ibo: 1u, firstIndex: (uint)i, indexCount: 6, texSlot: (uint)(0x100 + i)); } cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);