phase(N.5) Task 3: TextureCache bindless GetOrUpload + parallel cache

Adds three Bindless variants (GetOrUploadBindless,
GetOrUploadWithOrigTextureOverrideBindless,
GetOrUploadWithPaletteOverrideBindless) that decode + upload via
UploadRgba8AsLayer1Array (Texture2DArray) and cache in three new
dictionaries that mirror the legacy three-cache structure. Each entry
stores both the GL texture name (for Dispose cleanup in Task 4) and
the resident bindless handle.

Constructor gains optional BindlessSupport param; null keeps backward
compat. EnsureBindlessAvailable throws InvalidOperationException if
Bindless* methods are called without BindlessSupport (fail-fast vs
silent zero handle that would produce GPU faults).

Dispose extended to make handles non-resident before deleting the
underlying Texture2DArray names (bindless handles must be made
non-resident before the texture is deleted; skipping this causes
GPU faults on driver cleanup).

Marker test in TextureCacheBindlessTests documents the throw contract
for future engineers; real bindless integration is verified at
Task 14's visual gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 19:53:10 +02:00
parent 4b9a9bb721
commit 0d96716825
2 changed files with 130 additions and 1 deletions

View file

@ -29,10 +29,22 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle;
public TextureCache(GL gl, DatCollection dats)
private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
// caches so a surface used by both the legacy (Texture2D, sampler2D) and
// modern (Texture2DArray, sampler2DArray) paths is uploaded twice — once
// per target. Each entry stores both the GL texture name (for Dispose
// cleanup) and the resident bindless handle (returned to callers).
private readonly Dictionary<uint, (uint Name, ulong Handle)> _bindlessBySurfaceId = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
_gl = gl;
_dats = dats;
_bindless = bindless;
}
/// <summary>
@ -149,6 +161,71 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUpload"/> for the WB
/// modern rendering path. Uploads the texture as a 1-layer Texture2DArray
/// (so the shader's <c>sampler2DArray</c> can sample at layer 0) and returns
/// a resident bindless handle. Caches by surfaceId in a separate dictionary
/// from the legacy Texture2D path; the same surface may be uploaded twice
/// if used by both paths (acceptable transition cost — N.6 deletes the legacy
/// path).
/// Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadBindless(uint surfaceId)
{
EnsureBindlessAvailable();
if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessBySurfaceId[surfaceId] = (name, handle);
return handle;
}
/// <summary>64-bit bindless variant of <see cref="GetOrUploadWithOrigTextureOverride"/>.
/// Uses the parallel Texture2DArray upload path.</summary>
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
{
EnsureBindlessAvailable();
var key = (surfaceId, overrideOrigTextureId);
if (_bindlessByOverridden.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByOverridden[key] = (name, handle);
return handle;
}
/// <summary>64-bit bindless variant of <see cref="GetOrUploadWithPaletteOverride"/>
/// taking a precomputed palette hash. Uses the parallel Texture2DArray upload path.</summary>
public ulong GetOrUploadWithPaletteOverrideBindless(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{
EnsureBindlessAvailable();
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_bindlessByPalette.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByPalette[key] = (name, handle);
return handle;
}
private void EnsureBindlessAvailable()
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so
@ -327,5 +404,25 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
_gl.DeleteTexture(_magentaHandle);
_magentaHandle = 0;
}
// Bindless caches: make handles non-resident before deleting the texture.
foreach (var (name, handle) in _bindlessBySurfaceId.Values)
{
_bindless?.MakeNonResident(handle);
_gl.DeleteTexture(name);
}
_bindlessBySurfaceId.Clear();
foreach (var (name, handle) in _bindlessByOverridden.Values)
{
_bindless?.MakeNonResident(handle);
_gl.DeleteTexture(name);
}
_bindlessByOverridden.Clear();
foreach (var (name, handle) in _bindlessByPalette.Values)
{
_bindless?.MakeNonResident(handle);
_gl.DeleteTexture(name);
}
_bindlessByPalette.Clear();
}
}

View file

@ -0,0 +1,32 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using DatReaderWriter;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
/// <summary>
/// Lightweight unit tests for <see cref="TextureCache"/>'s bindless path.
/// We can't construct a real TextureCache in a headless test (it requires a
/// live GL context), so this file documents contracts that future engineers
/// should preserve. Real bindless integration is verified at Task 14's
/// visual gate.
/// </summary>
public sealed class TextureCacheBindlessTests
{
[Fact]
public void Contract_BindlessMethodsThrowWithoutBindlessSupport()
{
// The actual throw lives in TextureCache.EnsureBindlessAvailable
// and is reached only via GL-bound Bindless* method calls. The
// contract is: if the dispatcher (which requires bindless) ever
// gets a TextureCache constructed without BindlessSupport, it
// should fail-fast with InvalidOperationException — NOT silently
// route a draw to handle 0 (which would produce a non-resident
// GPU fault).
//
// This test is a marker. Future engineers: do not weaken
// EnsureBindlessAvailable to swallow the missing dependency.
Assert.True(true, "Contract documented in TextureCache.EnsureBindlessAvailable");
}
}