acdream/src/AcDream.Core/Textures/SurfaceDecoder.cs
Erik 16ed6e7c5c
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(render): keep authored surface translucency on composite textures
The user reported wielded items subtly hiding particle effects, as if a
translucent texture were missing. Root cause verified in source: the
DAT authors a per-surface Translucency float, and the shared-atlas
extraction honors it by baking (1 - Translucency) into the texture
alpha (MeshExtractor). But a surface with an appearance override -
ObjDesc subpalettes or texture changes, which wielded loot typically
carries - routes through the per-instance composite paths instead
(WbDrawDispatcher.ResolveTexture -> TextureCache
GetOrUploadWithPaletteOverrideBindless /
GetOrUploadWithOrigTextureOverrideBindless -> DecodeFromDats), and the
textured decode there never saw the authored value: only the
Base1Solid branch passed it (SurfaceDecoder.DecodeSolidColor);
DecodeRenderSurface has no translucency input at all.

Consequence: the part still classified translucent, still sorted in
the RetailAlphaQueue, still drew with depth writes off - but with
texture alpha = 1 it overwrote everything already composited behind
it. Particles behind the part vanished; particles in front survived.
The same GfxObj without overrides (atlas path) rendered correctly,
which is why the loss was so selective and subtle.

Fix: SurfaceDecoder.ApplyAuthoredTranslucency mirrors the atlas bake
(in-place alpha scale, caller-owned buffers, Magenta sentinel
guarded), and DecodeFromDats applies it behind an opt-in flag set by
exactly the two world composite paths. The sky path stays unbaked (its
shader applies the authored opacity separately - baking would
double-apply, the AP-89 compounding class) and particle sheets stay
unbaked (emitter-driven alpha, no authored-translucency consumer).
Composite cache keys already include the surface id, so the baked
alpha is cache-coherent.

This closes an unregistered divergence (no register row existed; the
fix restores parity with the shipped atlas mechanism, so none is
added). Investigation evidence: equipped children and world objects
share the same classification chain (ClassifyPackedBatches/GroupKey),
so the gap was override-driven, not attachment-driven - a dropped item
with the same ObjDesc was equally affected.

Core SurfaceDecoder tests 22/22 (3 new); App Release suite 3,968 / 3
skips. Visual gate: a wielded item with authored-translucent parts
must let its particle effects show through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:40:26 +02:00

244 lines
11 KiB
C#

using AcDream.Core.Rendering.Wb;
using BCnEncoder.Decoder;
using BCnEncoder.Shared;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
namespace AcDream.Core.Textures;
public static class SurfaceDecoder
{
private static readonly BcDecoder BcDecoder = new();
/// <summary>
/// Decode a RenderSurface's pixel bytes into RGBA8. Returns <see cref="DecodedTexture.Magenta"/>
/// for unsupported formats, null data, or corrupt sizing. This overload does NOT
/// support PFID_INDEX16 — use <see cref="DecodeRenderSurface(RenderSurface, Palette?)"/>
/// when a palette is available.
/// </summary>
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
/// <summary>
/// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support.
/// When <paramref name="palette"/> is non-null and the format is PFID_INDEX16, each
/// 16-bit value in SourceData is treated as an index into <see cref="Palette.Colors"/>.
/// When <paramref name="isClipMap"/> is true on an indexed surface, palette indices
/// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention).
/// When <paramref name="isAdditive"/> is true, A8/CUSTOM_LSCAPE_ALPHA surfaces
/// replicate the byte into all four channels (R=G=B=A=val, for terrain alpha masks
/// and additive surfaces). When false, R=G=B=255, A=val (WB FillA8 semantics).
/// </summary>
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)
{
if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0)
return DecodedTexture.Magenta;
try
{
return rs.Format switch
{
PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs),
PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs),
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap),
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap),
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap),
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),
PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap),
PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
_ => DecodedTexture.Magenta,
};
}
catch
{
return DecodedTexture.Magenta;
}
}
private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
/// <summary>
/// Build a 1x1 RGBA8 texture from a single <see cref="ColorARGB"/> modulated
/// by a surface translucency value. Used for <c>Surface.Type.HasFlag(Base1Solid)</c>
/// surfaces that carry a color value instead of a texture chain.
///
/// AC's convention: <paramref name="translucency"/> 0.0 is fully opaque, 1.0 is
/// fully transparent. A surface with Translucency=1.0 should render invisibly,
/// which the mesh shader's alpha discard (alpha &lt; 0.5) will honor.
/// </summary>
public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency)
{
// Malformed Base1Solid (or OrigTextureId==0) surface with no color value:
// signal undecodable (Magenta) instead of NRE. This method is called
// directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's
// try/catch, so it must be null-safe itself.
if (color is null) return DecodedTexture.Magenta;
float opacity = Math.Clamp(1f - translucency, 0f, 1f);
byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f);
return new DecodedTexture(
Rgba8: [color.Red, color.Green, color.Blue, alpha],
Width: 1,
Height: 1);
}
/// <summary>
/// Scale a decoded texture's alpha channel by a surface's authored translucency
/// (AC's convention: 0.0 fully opaque, 1.0 fully transparent). This is the same
/// bake the shared-atlas extraction applies (<c>MeshExtractor</c>,
/// <c>alphaScale = 1 - Surface.Translucency</c>); runtime composite decodes must
/// apply it too or an override-carrying surface silently loses its authored
/// translucency. Scales IN PLACE and returns the same instance — the caller must
/// own the buffer (never pass a shared/cached texture such as
/// <see cref="DecodedTexture.Magenta"/>).
/// </summary>
public static DecodedTexture ApplyAuthoredTranslucency(DecodedTexture texture, float translucency)
{
if (translucency <= 0f) return texture;
float alphaScale = Math.Clamp(1f - translucency, 0f, 1f);
byte[] rgba = texture.Rgba8;
for (int i = 3; i < rgba.Length; i += 4)
rgba[i] = (byte)(rgba[i] * alphaScale);
return texture;
}
/// <summary>
/// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8.
/// When <paramref name="isAdditive"/> is true: R=G=B=A=val (terrain alpha masks and
/// additive entity textures — the shader reads .r for the blend weight). When false:
/// R=G=B=255, A=val (WB FillA8 semantics for non-additive entity textures).
/// </summary>
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
{
int expected = rs.Width * rs.Height;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected * 4];
if (isAdditive)
TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
else
TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
{
int expected = rs.Width * rs.Height * 4;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected];
TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
/// <summary>
/// Decode PFID_P8 (8-bit palette index, one byte per pixel) into RGBA8.
/// This is the 8-bit sibling of PFID_INDEX16: each byte is a palette index.
/// The <paramref name="isClipMap"/> convention (indices 0..7 → fully transparent)
/// is identical to the INDEX16 path.
/// </summary>
private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap)
{
int expectedBytes = rs.Width * rs.Height;
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
/// <summary>
/// Decode PFID_R8G8B8 (24-bit, 3 bytes per pixel) into RGBA8 with alpha=255.
/// AC stores R8G8B8 on disk in B,G,R byte order (confirmed by ACE's
/// GetImageColorArray: <c>byte b = reader.ReadByte(); g = ...; r = ...;</c>).
/// Output is R,G,B,255 in RGBA8 order for OpenGL PixelFormat.Rgba upload.
/// </summary>
private static DecodedTexture DecodeR8G8B8(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 3;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
/// <summary>
/// Decode PFID_X8R8G8B8 (32-bit, 4 bytes per pixel) into RGBA8 with alpha=255.
/// AC stores X8R8G8B8 on disk in B,G,R,X byte order (DirectX little-endian
/// convention: low byte = B). The X (high) byte is unused padding and is
/// discarded — it is NOT treated as alpha. Output is R,G,B,255 for OpenGL.
/// </summary>
private static DecodedTexture DecodeX8R8G8B8(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 4;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[expectedBytes];
for (int i = 0; i < rs.Width * rs.Height; i++)
{
int s = i * 4;
// On-disk byte order: B, G, R, X (little-endian 32-bit; high byte X is padding)
rgba[s + 0] = rs.SourceData[s + 2]; // R
rgba[s + 1] = rs.SourceData[s + 1]; // G
rgba[s + 2] = rs.SourceData[s + 0]; // B
rgba[s + 3] = 0xFF; // A = opaque (X byte discarded)
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap)
{
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
var rgba = new byte[rs.Width * rs.Height * 4];
for (int i = 0; i < pixels.Length; i++)
{
int s = i * 4;
rgba[s + 0] = pixels[i].r;
rgba[s + 1] = pixels[i].g;
rgba[s + 2] = pixels[i].b;
rgba[s + 3] = pixels[i].a;
if (isClipMap && rgba[s + 0] == 0 && rgba[s + 1] == 0 && rgba[s + 2] == 0)
rgba[s + 3] = 0;
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
}