diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 94666df..dd95e63 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -3,6 +3,7 @@ using AcDream.Core.Textures; using DatReaderWriter; using DatReaderWriter.DBObjs; using Silk.NET.OpenGL; +using SurfaceType = DatReaderWriter.Enums.SurfaceType; namespace AcDream.App.Rendering; @@ -41,6 +42,14 @@ public sealed unsafe class TextureCache : IDisposable if (surface is null) return DecodedTexture.Magenta; + // Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue + // instead of a texture chain. Without this, surfaces with no texture + // would fall through to the magenta fallback. Translucency is honored + // so Base1Solid|Translucent surfaces with Translucency=1.0 become + // alpha=0, which the mesh shader's discard cutout makes invisible. + if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0) + return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency); + var surfaceTexture = _dats.Get((uint)surface.OrigTextureId); if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) return DecodedTexture.Magenta; @@ -49,14 +58,14 @@ public sealed unsafe class TextureCache : IDisposable if (rs is null) return DecodedTexture.Magenta; - // Palette lookup for indexed formats (doors, windows, alpha-keyed foliage). - // If DefaultPaletteId is 0 or unresolvable, SurfaceDecoder falls back to magenta - // for PFID_INDEX16 surfaces. Palette? palette = rs.DefaultPaletteId != 0 ? _dats.Get(rs.DefaultPaletteId) : null; - return SurfaceDecoder.DecodeRenderSurface(rs, palette); + // Clipmap surfaces use palette indices 0..7 as transparent sentinels. + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + + return SurfaceDecoder.DecodeRenderSurface(rs, palette, isClipMap); } private uint UploadRgba8(DecodedTexture decoded) diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index e294445..9c3c100 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -28,9 +28,20 @@ public static class LandblockMesh { for (int x = 0; x < VerticesPerSide; x++) { - int i = y * VerticesPerSide + x; - float height = heightTable[block.Height[i]]; - vertices[i] = new Vertex( + // Vertex buffer index (row-major, y*9+x) is internal to this mesh + // and what the index buffer below references. + int vi = y * VerticesPerSide + x; + + // Height dat index is PACKED AS x*9+y — AC stores per-vertex + // heights in x-major order (see ACViewer's + // LandblockStruct: Height[x * VertexDim + y]). Using y*9+x here + // (as Phase 1 did) transposes the terrain along its diagonal, + // which is invisible for flat landblocks but leaves buildings + // buried by ~10+ units on real terrain like Holtburg. + int hi = x * VerticesPerSide + y; + + float height = heightTable[block.Height[hi]]; + vertices[vi] = new Vertex( Position: new Vector3(x * CellSize, y * CellSize, height), Normal: Vector3.UnitZ, TexCoord: new Vector2(x / (float)CellsPerSide, y / (float)CellsPerSide)); diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 191f7b0..8c7c9b5 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -22,8 +22,10 @@ public static class SurfaceDecoder /// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support. /// When is non-null and the format is PFID_INDEX16, each /// 16-bit value in SourceData is treated as an index into . + /// When is true on an indexed surface, palette indices + /// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention). /// - public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette) + public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false) { if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) return DecodedTexture.Magenta; @@ -36,7 +38,7 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), - PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette), + PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), _ => DecodedTexture.Magenta, }; } @@ -46,7 +48,7 @@ public static class SurfaceDecoder } } - private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette) + 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) @@ -63,14 +65,45 @@ public static class SurfaceDecoder var c = palette.Colors[idx]; int dst = i * 4; - rgba[dst + 0] = c.Red; - rgba[dst + 1] = c.Green; - rgba[dst + 2] = c.Blue; - rgba[dst + 3] = c.Alpha; + // Clipmap alpha-key convention (ACViewer: if (isClipMap && color < 8) r=g=b=a=0): + // palette indices 0..7 on clipmap surfaces represent transparent pixels. + if (isClipMap && idx < 8) + { + rgba[dst + 0] = 0; + rgba[dst + 1] = 0; + rgba[dst + 2] = 0; + rgba[dst + 3] = 0; + } + else + { + rgba[dst + 0] = c.Red; + rgba[dst + 1] = c.Green; + rgba[dst + 2] = c.Blue; + rgba[dst + 3] = c.Alpha; + } } return new DecodedTexture(rgba, rs.Width, rs.Height); } + /// + /// Build a 1x1 RGBA8 texture from a single modulated + /// by a surface translucency value. Used for Surface.Type.HasFlag(Base1Solid) + /// surfaces that carry a color value instead of a texture chain. + /// + /// AC's convention: 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 < 0.5) will honor. + /// + public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) + { + 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); + } + private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) { int expected = rs.Width * rs.Height * 4; diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index bc81aac..b2853f6 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -81,4 +81,30 @@ public class LandblockMeshTests // AC's Land::LandHeightTable scales height byte index by 2.0f for the simple ramp case. Assert.Equal(10.0f, mesh.Vertices[0].Position.Z); } + + [Fact] + public void Build_HeightmapPackedAsXMajor_NotYMajor() + { + // Regression: Phase 1 used block.Height[y*9+x] which transposes the terrain + // along its diagonal relative to AC's native x-major packing. Invisible on + // flat landblocks but catastrophically wrong on Holtburg where static-object + // positions reference the un-transposed ground truth, leaving buildings + // buried by ~10 world-Z units. + // + // Set up an asymmetric heightmap: value at x-major index (x=2, y=0) = 5 + // (scaled to Z=10), everything else 0. The vertex at world position + // (x=2*24=48, y=0) should have Z=10. The vertex at (x=0, y=2*24=48) should + // have Z=0. Y-major indexing would swap these. + var block = BuildFlatLandBlock(); + block.Height[2 * 9 + 0] = 5; // x=2, y=0 in x-major packing + + var mesh = LandblockMesh.Build(block, IdentityHeightTable); + + // Find vertices by position. Vertex buffer uses y*9+x internally. + var vAt_x2_y0 = mesh.Vertices[0 * 9 + 2]; // world (48, 0) + var vAt_x0_y2 = mesh.Vertices[2 * 9 + 0]; // world (0, 48) + + Assert.Equal(new Vector3(48, 0, 10), vAt_x2_y0.Position); + Assert.Equal(new Vector3(0, 48, 0), vAt_x0_y2.Position); + } } diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index dadcb97..92f7bd7 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -1,6 +1,7 @@ using AcDream.Core.Textures; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; +using DatReaderWriter.Types; namespace AcDream.Core.Tests.Textures; @@ -86,4 +87,64 @@ public class SurfaceDecoderTests Assert.Same(DecodedTexture.Magenta, decoded); } + + [Fact] + public void DecodeSolidColor_Opaque_PreservesAlpha() + { + var color = new ColorARGB { Alpha = 0xFF, Red = 0x11, Green = 0x22, Blue = 0x33 }; + + var decoded = SurfaceDecoder.DecodeSolidColor(color, translucency: 0f); + + Assert.Equal(1, decoded.Width); + Assert.Equal(1, decoded.Height); + Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0xFF }, decoded.Rgba8); + } + + [Fact] + public void DecodeSolidColor_FullyTranslucent_AlphaGoesToZero() + { + // Surfaces marked Base1Solid + Translucent with Translucency=1.0 are + // AC's convention for "invisible placeholder surfaces" — the engine renders + // them as nothing. Alpha must go to 0 so the mesh shader's discard rule + // makes them invisible. + var color = new ColorARGB { Alpha = 0xFF, Red = 0xC8, Green = 0xC8, Blue = 0xC8 }; + + var decoded = SurfaceDecoder.DecodeSolidColor(color, translucency: 1f); + + Assert.Equal(0, decoded.Rgba8[3]); // alpha must be zero + } + + [Fact] + public void DecodeIndex16_ClipMap_ZerosAlphaForLowIndices() + { + // Build a 4x1 INDEX16 surface with indices 0, 1, 7, 8. + // On a clipmap surface, indices 0..7 should be fully transparent and + // index 8 should render with its palette color. + var rs = new RenderSurface + { + Width = 4, + Height = 1, + Format = PixelFormat.PFID_INDEX16, + SourceData = new byte[] + { + 0x00, 0x00, // index 0 + 0x01, 0x00, // index 1 + 0x07, 0x00, // index 7 + 0x08, 0x00, // index 8 + }, + }; + var palette = new Palette(); + for (int i = 0; i < 16; i++) + palette.Colors.Add(new ColorARGB { Alpha = 0xFF, Red = 0xAA, Green = 0xBB, Blue = 0xCC }); + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette, isClipMap: true); + + // Pixels 0, 1, 2 (indices 0, 1, 7) should be fully transparent. + Assert.Equal(0, decoded.Rgba8[3]); // pixel 0 alpha + Assert.Equal(0, decoded.Rgba8[7]); // pixel 1 alpha + Assert.Equal(0, decoded.Rgba8[11]); // pixel 2 alpha + // Pixel 3 (index 8) should have the palette alpha. + Assert.Equal(0xFF, decoded.Rgba8[15]); + Assert.Equal(0xAA, decoded.Rgba8[12]); + } }