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);