docs(N.5): plan amendment — Task 3+4 use parallel bindless caches

Original Task 3 had Bindless* methods calling the legacy Texture2D
GetOrUpload* and converting the GL name to a bindless handle —
producing a sampler2D texture sampled via sampler2DArray (GLSL type
mismatch).

Revised: Task 3 introduces three parallel cache dictionaries
(_bindlessBySurfaceId / _bindlessByOverridden / _bindlessByPalette)
storing both the GL texture name and the resident handle. Bindless*
methods call DecodeFromDats + UploadRgba8AsLayer1Array directly with
their own caching; legacy three-cache structure mirrored exactly.

Task 4 (Dispose) updated to: (1) MakeNonResident on every bindless
handle FIRST, (2) DeleteTexture on every Texture2DArray name, (3)
DeleteTexture on every legacy Texture2D handle. Order matters per
ARB_bindless_texture spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 19:50:36 +02:00
parent 0b73875d39
commit 4b9a9bb721

View file

@ -259,7 +259,9 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
## Task 3: Add bindless handle cache + Bindless GetOrUpload methods
## Task 3: Add bindless GetOrUpload methods with parallel Texture2DArray cache
**AMENDED 2026-05-08:** the original Task 3 had Bindless* methods calling the legacy Texture2D `GetOrUpload*` then converting the GL name to a bindless handle. That produces a `sampler2D` texture sampled via `sampler2DArray` in the shader — a GLSL type mismatch. Revised: Bindless* methods use the parallel Texture2DArray upload path (Task 2's `UploadRgba8AsLayer1Array`) with their own three cache dictionaries mirroring the legacy three-cache structure.
**Files:**
- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
@ -267,26 +269,28 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- [ ] **Step 3.1: Read TextureCache constructor + cache fields**
Read `src/AcDream.App/Rendering/TextureCache.cs:1-50`. Note the existing dictionaries: `_handlesBySurfaceId`, `_handlesByOverridden`, `_handlesByPalette`.
Read `src/AcDream.App/Rendering/TextureCache.cs:1-50`. Note the existing dictionaries: `_handlesBySurfaceId`, `_handlesByOverridden`, `_handlesByPalette` — these stay untouched, serving the legacy Texture2D path.
- [ ] **Step 3.2: Add BindlessSupport dependency to TextureCache constructor**
- [ ] **Step 3.2: Add BindlessSupport dependency + three parallel cache dicts**
In `src/AcDream.App/Rendering/TextureCache.cs`, change the constructor from:
```csharp
public TextureCache(GL gl, DatCollection dats)
{
_gl = gl;
_dats = dats;
}
```
to:
Add these fields to `TextureCache`, near the existing legacy cache dicts:
```csharp
private readonly Wb.BindlessSupport? _bindless;
private readonly Dictionary<uint, ulong> _bindlessHandlesByGlName = new();
// 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();
```
Change the constructor signature:
```csharp
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
_gl = gl;
@ -295,7 +299,7 @@ public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = nu
}
```
The optional parameter keeps backward compatibility with consumers that don't need bindless (sky, terrain, etc.).
The optional `bindless` parameter keeps backward compatibility — legacy `GetOrUpload*` keeps working without it. The Bindless* methods throw if `bindless` is null.
- [ ] **Step 3.3: Update TextureCache constructor sites**
@ -305,55 +309,79 @@ Identified call site: `src/AcDream.App/Rendering/GameWindow.cs` (typically aroun
Modify `GameWindow.cs` to pass the `BindlessSupport` instance — but only after Task 6 wires it up. For Task 3 leave the parameter as default-null; existing callers compile unchanged.
- [ ] **Step 3.4: Add MakeResidentHandle helper + three Bindless GetOrUpload methods**
- [ ] **Step 3.4: Add three Bindless GetOrUpload methods**
Add to `src/AcDream.App/Rendering/TextureCache.cs` immediately after the existing `GetOrUploadWithPaletteOverride` overloads:
```csharp
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUpload"/>.
/// 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)
{
uint name = GetOrUpload(surfaceId);
return MakeResidentHandle(name);
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"/>.</summary>
/// <summary>64-bit bindless variant of <see cref="GetOrUploadWithOrigTextureOverride"/>.
/// Uses the parallel Texture2DArray upload path.</summary>
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
{
uint name = GetOrUploadWithOrigTextureOverride(surfaceId, overrideOrigTextureId);
return MakeResidentHandle(name);
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.</summary>
/// taking a precomputed palette hash. Uses the parallel Texture2DArray upload path.</summary>
public ulong GetOrUploadWithPaletteOverrideBindless(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{
uint name = GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride, precomputedPaletteHash);
return MakeResidentHandle(name);
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 ulong MakeResidentHandle(uint glTextureName)
private void EnsureBindlessAvailable()
{
if (glTextureName == 0) return 0;
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless ctor overload.");
if (_bindlessHandlesByGlName.TryGetValue(glTextureName, out var h))
return h;
h = _bindless.GetResidentHandle(glTextureName);
_bindlessHandlesByGlName[glTextureName] = h;
return h;
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
```
Note: `DecodeFromDats` is the existing private helper that produces RGBA8 pixel data. It's target-agnostic — same decoded pixels go to either Texture2D (legacy) or Texture2DArray (bindless) upload. No duplication of the decode pipeline.
- [ ] **Step 3.5: Write the failing tests**
Create `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs`:
@ -436,14 +464,30 @@ Replace the existing `Dispose` in `src/AcDream.App/Rendering/TextureCache.cs` (c
public void Dispose()
{
// Release bindless handles BEFORE deleting underlying textures.
// glDeleteTextures of a texture with resident handles is undefined behavior.
// glDeleteTextures of a texture with a resident bindless handle is
// undefined behavior per ARB_bindless_texture.
if (_bindless is not null)
{
foreach (var h in _bindlessHandlesByGlName.Values)
_bindless.MakeNonResident(h);
foreach (var (name, handle) in _bindlessBySurfaceId.Values)
_bindless.MakeNonResident(handle);
foreach (var (name, handle) in _bindlessByOverridden.Values)
_bindless.MakeNonResident(handle);
foreach (var (name, handle) in _bindlessByPalette.Values)
_bindless.MakeNonResident(handle);
}
_bindlessHandlesByGlName.Clear();
// Then delete the array textures backing those handles.
foreach (var (name, _) in _bindlessBySurfaceId.Values)
_gl.DeleteTexture(name);
_bindlessBySurfaceId.Clear();
foreach (var (name, _) in _bindlessByOverridden.Values)
_gl.DeleteTexture(name);
_bindlessByOverridden.Clear();
foreach (var (name, _) in _bindlessByPalette.Values)
_gl.DeleteTexture(name);
_bindlessByPalette.Clear();
// Legacy Texture2D textures.
foreach (var h in _handlesBySurfaceId.Values)
_gl.DeleteTexture(h);
_handlesBySurfaceId.Clear();