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:
parent
6c485c2f06
commit
6e82807863
3 changed files with 129 additions and 0 deletions
|
|
@ -542,6 +542,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
||||||
return composed;
|
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)
|
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
|
||||||
{
|
{
|
||||||
uint tex = _gl.GenTexture();
|
uint tex = _gl.GenTexture();
|
||||||
|
|
|
||||||
88
src/AcDream.App/UI/IconComposer.cs
Normal file
88
src/AcDream.App/UI/IconComposer.cs
Normal 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->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
tests/AcDream.App.Tests/UI/IconComposerTests.cs
Normal file
36
tests/AcDream.App.Tests/UI/IconComposerTests.cs
Normal file
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue