From 16ed6e7c5c2ecc0997c0b5e3240920e55e0e6bd3 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 21:40:26 +0200 Subject: [PATCH] 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 --- src/AcDream.App/Rendering/TextureCache.cs | 37 +++++++++++++++-- src/AcDream.Core/Textures/SurfaceDecoder.cs | 20 +++++++++ .../Textures/SurfaceDecoderTests.cs | 41 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index df9ddada..f3e18c42 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -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) + /// + /// Apply the surface's authored Translucency 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). + /// + private DecodedTexture DecodeFromDats( + uint surfaceId, + uint? origTextureOverride, + PaletteOverride? paletteOverride, + bool bakeAuthoredTranslucency = false) { var surface = _dats.Get(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; } /// diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index f727a59c..6cbd108f 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -93,6 +93,26 @@ public static class SurfaceDecoder Height: 1); } + /// + /// 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 (MeshExtractor, + /// alphaScale = 1 - Surface.Translucency); 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 + /// ). + /// + 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; + } + /// /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8. /// When is true: R=G=B=A=val (terrain alpha masks and diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index 7a440e0a..e50ff372 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -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() {