fix(render): keep authored surface translucency on composite textures
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

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>
This commit is contained in:
Erik 2026-07-29 21:40:26 +02:00
parent bfba0ecf7f
commit 16ed6e7c5c
3 changed files with 94 additions and 4 deletions

View file

@ -495,7 +495,8 @@ public sealed class TextureCache
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: null);
paletteOverride: null,
bakeAuthoredTranslucency: true);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
@ -534,7 +535,8 @@ public sealed class TextureCache
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: paletteOverride);
paletteOverride: paletteOverride,
bakeAuthoredTranslucency: true);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
@ -806,7 +808,21 @@ public sealed class TextureCache
Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)");
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride)
/// <param name="bakeAuthoredTranslucency">
/// Apply the surface's authored <c>Translucency</c> to the decoded alpha, the same
/// bake the shared-atlas extraction performs. TRUE for the world composite paths
/// (palette / original-texture overrides) — without it an override-carrying item's
/// translucent part paints alpha=1: it still sorts as see-through in the alpha
/// queue but erases the particles composited behind it. FALSE for the sky (its
/// shader applies the authored opacity separately — baking would double-apply)
/// and for particle sheets (emitter-driven alpha, no authored-translucency
/// consumer today).
/// </param>
private DecodedTexture DecodeFromDats(
uint surfaceId,
uint? origTextureOverride,
PaletteOverride? paletteOverride,
bool bakeAuthoredTranslucency = false)
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
@ -863,7 +879,20 @@ public sealed class TextureCache
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
DecodedTexture decoded =
SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
// The decoders return the shared Magenta sentinel on failure; it must never
// be scaled in place. Fresh decodes are caller-owned, so the in-place bake
// is safe.
if (bakeAuthoredTranslucency
&& surface.Translucency > 0.0f
&& !ReferenceEquals(decoded, DecodedTexture.Magenta))
{
decoded = SurfaceDecoder.ApplyAuthoredTranslucency(decoded, surface.Translucency);
}
return decoded;
}
/// <summary>

View file

@ -93,6 +93,26 @@ public static class SurfaceDecoder
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

View file

@ -7,6 +7,47 @@ namespace AcDream.Core.Tests.Textures;
public class SurfaceDecoderTests
{
[Fact]
public void ApplyAuthoredTranslucency_ScalesAlphaOnly_InPlace()
{
// Runtime analogue of the shared-atlas bake (MeshExtractor's
// alphaScale = 1 - Surface.Translucency): the composite decode paths
// apply this so an override-carrying item keeps its authored
// translucency instead of painting alpha=1 and erasing the particles
// composited behind it.
var texture = new DecodedTexture(
Rgba8: [10, 20, 30, 200, 40, 50, 60, 100],
Width: 2,
Height: 1);
var result = SurfaceDecoder.ApplyAuthoredTranslucency(texture, 0.5f);
Assert.Same(texture, result);
Assert.Equal(new byte[] { 10, 20, 30, 100, 40, 50, 60, 50 }, result.Rgba8);
}
[Fact]
public void ApplyAuthoredTranslucency_FullTranslucency_ZeroesAlpha()
{
var texture = new DecodedTexture(Rgba8: [255, 255, 255, 255], Width: 1, Height: 1);
var result = SurfaceDecoder.ApplyAuthoredTranslucency(texture, 1f);
Assert.Equal(0, result.Rgba8[3]);
Assert.Equal(255, result.Rgba8[0]);
}
[Fact]
public void ApplyAuthoredTranslucency_ZeroOrNegative_IsANoOp()
{
var texture = new DecodedTexture(Rgba8: [1, 2, 3, 4], Width: 1, Height: 1);
Assert.Same(texture, SurfaceDecoder.ApplyAuthoredTranslucency(texture, 0f));
Assert.Equal(4, texture.Rgba8[3]);
Assert.Same(texture, SurfaceDecoder.ApplyAuthoredTranslucency(texture, -0.25f));
Assert.Equal(4, texture.Rgba8[3]);
}
[Fact]
public void Decode_A8R8G8B8_ConvertsToRgba8()
{