diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 7d1c0b25..0e7ebcea 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -542,6 +542,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return composed; } + /// Uploads a raw RGBA8 byte array as a Texture2D. Used by + /// to upload CPU-composited icon layers. + public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) + => UploadRgba8(new DecodedTexture(rgba, width, height), nearest); + private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) { uint tex = _gl.GenTexture(); diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs new file mode 100644 index 00000000..09b97def --- /dev/null +++ b/src/AcDream.App/UI/IconComposer.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using AcDream.App.Rendering; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI; + +/// +/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32 +/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a +/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule). +/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base + +/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor +/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows). +/// Composited textures are cached by their layer-id tuple. +/// +public sealed class IconComposer +{ + private readonly DatCollection _dats; + private readonly TextureCache _cache; + private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new(); + + public IconComposer(DatCollection dats, TextureCache cache) + { + _dats = dats; + _cache = cache; + } + + /// Pure alpha-over composite, bottom->top. Layers may differ in size; + /// the result is sized to the FIRST (bottom) layer and upper layers are sampled + /// top-left aligned (all icon layers are 32x32 in practice). + public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers) + { + if (layers.Count == 0) return (Array.Empty(), 0, 0); + var (baseRgba, w, h) = layers[0]; + var outp = (byte[])baseRgba.Clone(); + for (int li = 1; li < layers.Count; li++) + { + var (src, sw, sh) = layers[li]; + int cw = Math.Min(w, sw), ch = Math.Min(h, sh); + for (int y = 0; y < ch; y++) + for (int x = 0; x < cw; x++) + { + int di = (y * w + x) * 4, si = (y * sw + x) * 4; + float sa = src[si + 3] / 255f; + if (sa <= 0f) continue; + float da = 1f - sa; + outp[di] = (byte)(src[si] * sa + outp[di] * da); + outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da); + outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da); + outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da); + } + } + return (outp, w, h); + } + + /// Resolve (and cache) the composited GL texture for an item's icon + /// layers. Returns 0 if no base icon is available. + public uint GetIcon(uint iconId, uint underlayId, uint overlayId) + { + if (iconId == 0) return 0; + var key = (iconId, underlayId, overlayId); + if (_byTuple.TryGetValue(key, out var tex)) return tex; + + var layers = new List<(byte[] rgba, int w, int h)>(); + AddLayer(layers, underlayId); + AddLayer(layers, iconId); + AddLayer(layers, overlayId); + if (layers.Count == 0) return 0; + + var (rgba, w, h) = Compose(layers); + uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true); + _byTuple[key] = handle; + return handle; + } + + private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId) + { + if (renderSurfaceId == 0) return; + if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return; + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + layers.Add((decoded.Rgba8, decoded.Width, decoded.Height)); + } +} diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs new file mode 100644 index 00000000..09ec721f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -0,0 +1,36 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class IconComposerTests +{ + private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a) + { + var px = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; } + return px; + } + + [Fact] + public void Compose_alphaOver_topOpaqueLayerWins() + { + var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque + var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque + var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(2, w); Assert.Equal(2, h); + Assert.Equal(0, rgba[0]); // R + Assert.Equal(0, rgba[1]); // G + Assert.Equal(255, rgba[2]); // B — top layer won + Assert.Equal(255, rgba[3]); // A + } + + [Fact] + public void Compose_alphaOver_transparentTopKeepsBottom() + { + var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1); + var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue + var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(255, rgba[0]); // bottom red preserved + Assert.Equal(0, rgba[2]); + } +}