acdream/src/AcDream.App/Rendering/TextureCache.cs
Erik 573526dae5 phase(N.4): WbDrawDispatcher perf pass — sort, cull, hash memoization
Four small wins on top of the grouped-instanced refactor.

1. Drop unused animState lookup. Was a side-effect-free
   _entitySpawnAdapter.GetState call per per-instance entity, made
   redundant by the Issue #47 fix that trusts MeshRefs.

2. Front-to-back sort opaque groups. Squared distance from camera to
   each group's first-instance translation; ascending sort. Lets the
   GPU's depth test reject fragments behind closer geometry — real
   win on dense scenes (Holtburg courtyard, Foundry interior).

3. Per-entity AABB frustum cull. 5m-radius AABB check per entity
   before walking parts. Skips work for distant entities even when
   their landblock is partially visible. Animated entities (other
   characters, NPCs, monsters) bypass — they always need per-frame
   work for animation regardless. Conservative radius covers typical
   entity bounds; large outliers stay landblock-culled.

4. Memoize palette hash per entity. TextureCache.HashPaletteOverride
   is now internal; new GetOrUploadWithPaletteOverride overload takes
   a precomputed hash. The dispatcher computes it ONCE per entity and
   reuses across every (part, batch) lookup, avoiding the per-batch
   FNV-1a fold over SubPalettes. Trees / scenery without palette
   overrides skip entirely (palHash stays 0).

Visual output unchanged; FPS up further, especially in dense scenes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 17:51:03 +02:00

299 lines
12 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;
public TextureCache(GL gl, DatCollection dats)
{
_gl = gl;
_dats = dats;
}
/// <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>
/// 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;
}
public void Dispose()
{
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;
}
}
}