Code quality review caught four issues: - Critical: Dispose interleaved MakeNonResident + DeleteTexture per entry, violating ARB_bindless_texture's "all handles non-resident before any texture deletion" requirement. Reordered to two phases: Phase 1 makes ALL bindless handles non-resident; Phase 2 deletes ALL bindless textures; Phase 3 deletes legacy Texture2D textures. - Important: per-call _bindless?.MakeNonResident replaced with a single if (_bindless is not null) guard around the whole Phase 1 block — cleaner reasoning, one null check. - Minor: test contract comment referenced wrong task number for visual gate; corrected to match current plan. - Minor: two abbreviated XML docs (GetOrUploadWithOrigTextureOverrideBindless, GetOrUploadWithPaletteOverrideBindless) expanded to mention the throw-on-null-bindless contract for IDE readers. This fixup also completes Task 4's Dispose work — Task 4 will be marked complete since this commit does its full job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
449 lines
20 KiB
C#
449 lines
20 KiB
C#
// src/AcDream.App/Rendering/TextureCache.cs
|
|
using AcDream.Core.Textures;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using Silk.NET.OpenGL;
|
|
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable
|
|
{
|
|
private readonly GL _gl;
|
|
private readonly DatCollection _dats;
|
|
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
|
|
/// <summary>
|
|
/// Composite cache for surface-with-override-origtex entries (Phase 5
|
|
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
|
|
/// value = GL texture handle.
|
|
/// </summary>
|
|
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
|
|
/// <summary>
|
|
/// Composite cache for palette-overridden entries (Phase 5 SubPalettes).
|
|
/// Key = (baseSurfaceId, origTexOverride, paletteHash), value = handle.
|
|
/// paletteHash is a cheap combined hash of the PaletteOverride's ids +
|
|
/// offsets + lengths so two entities with equivalent palette setups
|
|
/// share the same decoded texture.
|
|
/// </summary>
|
|
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
|
|
private uint _magentaHandle;
|
|
|
|
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>
|
|
/// Get or upload the GL texture handle for a Surface id. Returns a
|
|
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is
|
|
/// missing or uses an unsupported format.
|
|
/// </summary>
|
|
public uint GetOrUpload(uint surfaceId)
|
|
{
|
|
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
|
|
return h;
|
|
|
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
|
|
DumpAlphaHistogram(surfaceId, decoded);
|
|
h = UploadRgba8(decoded);
|
|
_handlesBySurfaceId[surfaceId] = h;
|
|
return h;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Alpha-channel histogram for one decoded texture. Used to diagnose
|
|
/// "why are clouds not transparent" — if cloud textures come out with
|
|
/// alpha = 1.0 everywhere we know the decode path strips the alpha
|
|
/// channel somewhere. Printed once per unique surfaceId under
|
|
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
|
|
/// </summary>
|
|
private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded)
|
|
{
|
|
if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0)
|
|
{
|
|
System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty");
|
|
return;
|
|
}
|
|
int total = decoded.Rgba8.Length / 4;
|
|
// Bucket alpha in 10 bins.
|
|
var buckets = new int[10];
|
|
int aMin = 255, aMax = 0;
|
|
long aSum = 0;
|
|
for (int i = 0; i < decoded.Rgba8.Length; i += 4)
|
|
{
|
|
int a = decoded.Rgba8[i + 3];
|
|
if (a < aMin) aMin = a;
|
|
if (a > aMax) aMax = a;
|
|
aSum += a;
|
|
int b = a * 10 / 256;
|
|
if (b > 9) b = 9;
|
|
buckets[b]++;
|
|
}
|
|
float aMean = aSum / (float)total / 255f;
|
|
var pct = new string[10];
|
|
for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%";
|
|
System.Console.WriteLine(
|
|
$"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " +
|
|
$"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " +
|
|
$"bins[0-9]={string.Join(",", pct)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get or upload a texture for a Surface id but with its
|
|
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.
|
|
/// The Surface's other properties (type flags, color, translucency,
|
|
/// clipmap handling, default palette) are preserved — only the
|
|
/// SurfaceTexture lookup is swapped. This is how the server's
|
|
/// CreateObject.TextureChanges are applied at render time.
|
|
/// Caches under a composite key so multiple entities can share.
|
|
/// </summary>
|
|
public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId)
|
|
{
|
|
var key = (surfaceId, overrideOrigTextureId);
|
|
if (_handlesByOverridden.TryGetValue(key, out var h))
|
|
return h;
|
|
|
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
|
|
h = UploadRgba8(decoded);
|
|
_handlesByOverridden[key] = h;
|
|
return h;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full Phase 5 override: for palette-indexed textures (PFID_P8 /
|
|
/// PFID_INDEX16), applies <paramref name="paletteOverride"/>'s
|
|
/// subpalette overlays on top of the texture's default palette
|
|
/// before decoding. Non-palette formats ignore the palette override.
|
|
/// Also honors <paramref name="overrideOrigTextureId"/> if non-null.
|
|
/// </summary>
|
|
public uint GetOrUploadWithPaletteOverride(
|
|
uint surfaceId,
|
|
uint? overrideOrigTextureId,
|
|
PaletteOverride paletteOverride)
|
|
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
|
|
HashPaletteOverride(paletteOverride));
|
|
|
|
/// <summary>
|
|
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
|
|
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
|
|
/// it across every (part, batch) lookup, avoiding the per-batch
|
|
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
|
|
/// </summary>
|
|
public uint GetOrUploadWithPaletteOverride(
|
|
uint surfaceId,
|
|
uint? overrideOrigTextureId,
|
|
PaletteOverride paletteOverride,
|
|
ulong precomputedPaletteHash)
|
|
{
|
|
uint origTexKey = overrideOrigTextureId ?? 0;
|
|
var key = (surfaceId, origTexKey, precomputedPaletteHash);
|
|
if (_handlesByPalette.TryGetValue(key, out var h))
|
|
return h;
|
|
|
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
|
|
h = UploadRgba8(decoded);
|
|
_handlesByPalette[key] = h;
|
|
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 handle variant of <see cref="GetOrUploadWithOrigTextureOverride"/>
|
|
/// for the WB modern rendering path. Uploads the texture as a 1-layer
|
|
/// Texture2DArray with the override SurfaceTexture id and returns a resident
|
|
/// bindless handle. Caches under a separate composite key from the legacy
|
|
/// path. Throws if BindlessSupport wasn't provided to the constructor.
|
|
/// </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 handle variant of <see cref="GetOrUploadWithPaletteOverride"/>
|
|
/// for the WB modern rendering path. Applies the palette override on top of
|
|
/// the texture's default palette before decoding, uploads as a 1-layer
|
|
/// Texture2DArray, and returns a resident bindless handle. Takes a
|
|
/// precomputed palette hash so the WB dispatcher can compute it once per
|
|
/// entity. Throws if BindlessSupport wasn't provided to the constructor.
|
|
/// </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
|
|
/// the WB dispatcher can compute it once per entity.
|
|
/// </summary>
|
|
internal static ulong HashPaletteOverride(PaletteOverride p)
|
|
{
|
|
// Not cryptographic — just needs to distinguish override setups
|
|
// for caching. Start with base palette id, fold in each entry.
|
|
ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis
|
|
const ulong prime = 0x100000001B3UL;
|
|
h = (h ^ p.BasePaletteId) * prime;
|
|
foreach (var sp in p.SubPalettes)
|
|
{
|
|
h = (h ^ sp.SubPaletteId) * prime;
|
|
h = (h ^ sp.Offset) * prime;
|
|
h = (h ^ sp.Length) * prime;
|
|
}
|
|
return h;
|
|
}
|
|
|
|
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride)
|
|
{
|
|
var surface = _dats.Get<Surface>(surfaceId);
|
|
if (surface is null)
|
|
return DecodedTexture.Magenta;
|
|
|
|
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
|
|
// instead of a texture chain. Overrides are irrelevant here — there's
|
|
// no texture chain to swap — so the override is ignored for solid-color
|
|
// surfaces. Translucency is honored so Base1Solid|Translucent surfaces
|
|
// with Translucency=1.0 become alpha=0, which the mesh shader's discard
|
|
// cutout makes invisible.
|
|
if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0)
|
|
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
|
|
|
|
// Use the override SurfaceTexture id when present, otherwise the
|
|
// Surface's native OrigTextureId.
|
|
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
|
|
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
|
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
|
return DecodedTexture.Magenta;
|
|
|
|
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
|
|
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
|
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
|
return DecodedTexture.Magenta;
|
|
|
|
// Start with the texture's default palette, then apply overlays.
|
|
// ACViewer's Render/TextureCache.IndexToColor does the same and never
|
|
// consults ObjDesc.BasePaletteId for palette-indexed textures — the
|
|
// RenderSurface's own default palette is the starting point.
|
|
Palette? basePalette = rs.DefaultPaletteId != 0
|
|
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
|
: null;
|
|
|
|
Palette? effectivePalette = basePalette;
|
|
if (paletteOverride is not null && basePalette is not null && paletteOverride.SubPalettes.Count > 0)
|
|
{
|
|
effectivePalette = ComposePalette(basePalette, paletteOverride);
|
|
}
|
|
|
|
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
|
|
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
|
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
|
|
|
|
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a composite palette by copying subpalette ranges into a
|
|
/// mutable copy of the base. Ported from ACViewer's
|
|
/// Render/TextureCache.IndexToColor, with network-side Offset/Length
|
|
/// multiplied by 8 to recover the raw palette-index units (ACE's
|
|
/// writer divides by 8 before writing).
|
|
/// </summary>
|
|
private Palette ComposePalette(Palette basePalette, PaletteOverride paletteOverride)
|
|
{
|
|
var composed = new Palette();
|
|
composed.Colors.AddRange(basePalette.Colors);
|
|
|
|
foreach (var sp in paletteOverride.SubPalettes)
|
|
{
|
|
var subPal = _dats.Get<Palette>(sp.SubPaletteId);
|
|
if (subPal is null) continue;
|
|
|
|
int startIdx = sp.Offset * 8;
|
|
// Length == 0 is the sentinel for "entire palette" per
|
|
// Chorizite.ACProtocol.Types.Subpalette docs. Use a value
|
|
// large enough to cover any real palette; we clamp below.
|
|
int count = sp.Length == 0 ? 2048 : sp.Length * 8;
|
|
|
|
for (int j = 0; j < count; j++)
|
|
{
|
|
int idx = startIdx + j;
|
|
if (idx >= composed.Colors.Count || idx >= subPal.Colors.Count)
|
|
break;
|
|
composed.Colors[idx] = subPal.Colors[idx];
|
|
}
|
|
}
|
|
|
|
return composed;
|
|
}
|
|
|
|
private uint UploadRgba8(DecodedTexture decoded)
|
|
{
|
|
uint tex = _gl.GenTexture();
|
|
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
|
|
|
fixed (byte* p = decoded.Rgba8)
|
|
_gl.TexImage2D(
|
|
TextureTarget.Texture2D,
|
|
0,
|
|
InternalFormat.Rgba8,
|
|
(uint)decoded.Width,
|
|
(uint)decoded.Height,
|
|
0,
|
|
PixelFormat.Rgba,
|
|
PixelType.UnsignedByte,
|
|
p);
|
|
|
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
|
|
|
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
|
return tex;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
|
|
/// Texture2DArray. Required by the WB modern rendering path which samples via
|
|
/// sampler2DArray in its bindless shader. Pixel data is identical.
|
|
/// </summary>
|
|
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
|
|
{
|
|
uint tex = _gl.GenTexture();
|
|
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
|
|
|
fixed (byte* p = decoded.Rgba8)
|
|
_gl.TexImage3D(
|
|
TextureTarget.Texture2DArray,
|
|
0,
|
|
InternalFormat.Rgba8,
|
|
(uint)decoded.Width,
|
|
(uint)decoded.Height,
|
|
depth: 1,
|
|
border: 0,
|
|
PixelFormat.Rgba,
|
|
PixelType.UnsignedByte,
|
|
p);
|
|
|
|
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
|
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
|
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
|
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
|
|
|
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
|
return tex;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Phase 1: make all bindless handles non-resident BEFORE any
|
|
// DeleteTexture call. ARB_bindless_texture requires that resident
|
|
// handles be released before their backing texture is deleted —
|
|
// interleaving per-entry is UB. Single null-guard around the whole
|
|
// block (cleaner than per-call null-conditionals).
|
|
if (_bindless is not null)
|
|
{
|
|
foreach (var (_, handle) in _bindlessBySurfaceId.Values)
|
|
_bindless.MakeNonResident(handle);
|
|
foreach (var (_, handle) in _bindlessByOverridden.Values)
|
|
_bindless.MakeNonResident(handle);
|
|
foreach (var (_, handle) in _bindlessByPalette.Values)
|
|
_bindless.MakeNonResident(handle);
|
|
}
|
|
|
|
// Phase 2: delete the Texture2DArray 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();
|
|
|
|
// Phase 3: legacy Texture2D textures.
|
|
foreach (var h in _handlesBySurfaceId.Values)
|
|
_gl.DeleteTexture(h);
|
|
_handlesBySurfaceId.Clear();
|
|
|
|
foreach (var h in _handlesByOverridden.Values)
|
|
_gl.DeleteTexture(h);
|
|
_handlesByOverridden.Clear();
|
|
|
|
foreach (var h in _handlesByPalette.Values)
|
|
_gl.DeleteTexture(h);
|
|
_handlesByPalette.Clear();
|
|
|
|
if (_magentaHandle != 0)
|
|
{
|
|
_gl.DeleteTexture(_magentaHandle);
|
|
_magentaHandle = 0;
|
|
}
|
|
}
|
|
}
|