feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache

Adds IconComposer (AcDream.App.UI) which mirrors retail IconData::RenderIcons
(decomp 407524): decodes each RenderSurface layer directly via SurfaceDecoder,
composites them bottom-to-top with Porter-Duff alpha-over, and uploads the
result to a GL texture via TextureCache. Composited handles are keyed by the
(iconId, underlayId, overlayId) tuple so each unique combo is uploaded once.

Adds a public TextureCache.UploadRgba8(byte[], int, int, bool) wrapper — a
thin shell around the existing private overload — so IconComposer can upload
its CPU-side composite without duplicating any GL state logic.

Pure Compose() path is covered by 2 unit tests (opaque top wins; transparent
top preserves bottom). Dat-decode + GL-upload exercised by the visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 22:09:41 +02:00
parent 6c485c2f06
commit 6e82807863
3 changed files with 129 additions and 0 deletions

View file

@ -542,6 +542,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return composed;
}
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.</summary>
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();

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Pure alpha-over composite, bottom-&gt;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).</summary>
public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
{
if (layers.Count == 0) return (Array.Empty<byte>(), 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);
}
/// <summary>Resolve (and cache) the composited GL texture for an item's icon
/// layers. Returns 0 if no base icon is available.</summary>
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<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return;
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
}