// 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 _handlesBySurfaceId = new(); /// /// Composite cache for surface-with-override-origtex entries (Phase 5 /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), /// value = GL texture handle. /// private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new(); /// /// 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. /// 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; } /// /// 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. /// 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; } /// /// 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 /// ACDREAM_DUMP_SKY=1. Adds ~2ms per texture upload, negligible. /// 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)}"); } /// /// Get or upload a texture for a Surface id but with its /// OrigTextureId replaced by . /// 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. /// 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; } /// /// Full Phase 5 override: for palette-indexed textures (PFID_P8 / /// PFID_INDEX16), applies 's /// subpalette overlays on top of the texture's default palette /// before decoding. Non-palette formats ignore the palette override. /// Also honors if non-null. /// public uint GetOrUploadWithPaletteOverride( uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride) => GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride, HashPaletteOverride(paletteOverride)); /// /// 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 . /// 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; } /// /// 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. /// 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(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(surfaceTextureId); if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) return DecodedTexture.Magenta; uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && !_dats.HighRes.TryGet(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(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); } /// /// 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). /// 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(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; } } }