Routes server-spawned (CreateObject) entities through the per-instance rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural, ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead. For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the palette-composed GL texture before the first draw. Surfaces not in the SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj dat) are decoded lazily by the draw dispatcher on first use, consistent with StaticMeshRenderer. Builds AnimatedEntityState per server-guid via injected sequencer factory (Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter from DatCollection so tests pass a stub lambda without a GL context. OnRemove releases per-entity state. Unknown guids no-op. Introduces ITextureCachePerInstance: thin seam interface over the palette decode path so EntitySpawnAdapter tests can use a CapturingTextureCache mock without constructing a GL context. TextureCache implements it. Adjustment 4 documented in source comments: WorldEntity does not currently expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the network layer before the WorldEntity is built). HideParts / SetPartOverride calls are placeholder TODO'd for when those fields are promoted. Wired into GpuWorldState.AppendLiveEntity (OnCreate) and RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer factory captures _dats + _animLoader at construction time; falls back to an empty Setup + MotionTable via NullAnimLoader when dats are unavailable. 10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm (with and without surface overrides), OnRemove lifecycle, unknown-guid noop, multi-entity isolation. All pass; 8 pre-existing failures unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
12 KiB
C#
285 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)
|
|
{
|
|
ulong hash = HashPaletteOverride(paletteOverride);
|
|
uint origTexKey = overrideOrigTextureId ?? 0;
|
|
var key = (surfaceId, origTexKey, hash);
|
|
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.
|
|
/// </summary>
|
|
private 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;
|
|
}
|
|
}
|
|
}
|