From bb5acab9e60a56685592a2a8a0c063ac38912281 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 21:04:22 +0200 Subject: [PATCH] fix(rendering): restore retail terrain texture tiling Carry TerrainTex.TexTiling through the terrain atlas and upload a layer-indexed table to the modern bindless shader so base, overlay, and road textures repeat at retail scale. Keep alpha masks cell-scaled and preserve retail's verified source-level-zero high-detail selection. Add the named-retail pseudocode, WorldBuilder/ACE/ACME cross-reference, adapter conformance tests, and inventory documentation so a future renderer migration keeps the contract. Co-Authored-By: Codex --- docs/architecture/worldbuilder-inventory.md | 9 ++ ...etail-terrain-texture-tiling-pseudocode.md | 109 ++++++++++++++++++ .../Rendering/Shaders/terrain_modern.frag | 18 ++- src/AcDream.App/Rendering/TerrainAtlas.cs | 28 ++++- .../Rendering/TerrainModernRenderer.cs | 34 ++++++ .../Rendering/TerrainTextureTilingTable.cs | 40 +++++++ .../TerrainTextureTilingTableTests.cs | 66 +++++++++++ 7 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md create mode 100644 src/AcDream.App/Rendering/TerrainTextureTilingTable.cs create mode 100644 tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index b45a7b07..d4c54495 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -153,6 +153,15 @@ use from WB rather than re-implement. | `TextureAtlasManager` | Texture atlas builder | | `VertexLandscape` | Terrain vertex format | +**Modern terrain adapter:** acdream's bindless path uses `TerrainAtlas` plus +`TerrainModernRenderer` rather than WB's draw manager, but retains +`LandSurfaceManager`'s layer-indexed `TerrainTex.TexTiling` contract. The +36-entry table is uploaded to `terrain_modern.frag`; base, overlay, and road +layers each use their owning repeat count while alpha masks stay at cell scale. +Retail `TexMerge::CopyAndTile` (`0x00503580`) and `TexMerge::Merge` +(`0x005038C0`) are the behavior oracle; see +`docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md`. + ### Scenery (procedural placement: trees, bushes, rocks, fences) | Component | What it does | diff --git a/docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md b/docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md new file mode 100644 index 00000000..02cf2668 --- /dev/null +++ b/docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md @@ -0,0 +1,109 @@ +# Retail terrain texture tiling + +## Question + +Why do acdream's grass, dirt, and road textures look lower-detail than the +September 2013 retail client even though the modern terrain atlas loads 512 x +512 source images, generates mipmaps, and enables anisotropic filtering? + +## Retail oracle + +Named retail symbols in +`docs/research/named-retail/acclient_2013_pseudo_c.txt`: + +- `TexMerge::CopyAndTile` at `0x00503580` +- `TexMerge::Merge` at `0x005038C0` +- `ImgTex::TileCSI` at `0x0053E740` +- `ImgTex::GetSurfaceDID` at `0x0053F0E0` +- `Render::ShouldDropHighDetail` at `0x0054C700` + +The verbatim `TerrainTex` declaration in +`docs/research/named-retail/acclient.h` contains `unsigned int tex_tiling`. + +## Readable pseudocode + +```text +CopyAndTile(destination, destinationSize, terrainTexture): + baseTexture = terrainTexture.base_texture + if baseTexture is null: + if terrainTexture.tex_gid is valid: + terrainTexture.base_texture = load ImgTex(terrainTexture.tex_gid) + baseTexture = terrainTexture.base_texture + + if baseTexture is null: + CopyCSI(destination, destinationSize, destinationSize, null, 1) + return false + + CopyCSI( + destination, + destinationSize, + destinationSize, + baseTexture, + terrainTexture.tex_tiling) + return true + +Merge(destination, destinationSize, alphaTexture, rotation, terrainTexture): + if alphaTexture is null: + return false + + if terrainTexture.base_texture is null: + if not terrainTexture.InitEnd(): + return false + + MergeTexture( + destination, + destinationSize, + destinationSize, + terrainTexture.base_texture, + terrainTexture.tex_tiling, + alphaTexture, + rotation) + return true + +GetSurfaceDID(surfaceTexture): + if surfaceTexture has one source level: + return sourceLevels[0] + if surfaceTexture has two source levels: + if ShouldDropHighDetail(): + return sourceLevels[1] + return sourceLevels[0] + report invalid source-level count +``` + +`ImgTex::TileCSI` repeats the source image `tex_tiling` times in both axes. +`TexMerge::Merge` applies the same repeat count to every terrain layer before +the cell-scale alpha mask is merged. Therefore the modern shader must multiply +the terrain UV independently for the base layer, each overlay layer, and each +road layer. The alpha-map UV must not be multiplied. + +The source image selection is a separate policy. Retail uses source level zero +unless its quality policy explicitly drops high detail, so changing acdream's +atlas from `SurfaceTexture.Textures[0]` would invert the verified retail rule. + +## Cross-references + +- Extracted WorldBuilder lineage: `LandSurfaceManager` stores a 36-entry + layer-indexed tiling table populated from `TerrainTex.TexTiling`, and its + terrain shader multiplies each texture layer's UV by that value. The pinned + WorldBuilder change introducing the path is commit `0d1405af65`. +- ACE: `ACE.Server/Physics/Common/TexMerge.cs` forwards + `TerrainTex.TexTiling` from both `CopyAndTile` and `Merge`; `ImgTex.cs` + resolves the normal source as `Textures[0]`. +- ACME-derived `tests/AcDream.Core.Tests/Terrain/ClientReference.cs` covers + terrain split, pal/road codes, and blend selection, but not image tiling or + source-level selection. It supplies no competing behavior for this path. + +Retail remains the oracle where a reference differs: the pinned WorldBuilder +reader selects the last surface source, while retail's `GetSurfaceDID` proves +that the normal/high-detail source is index zero. + +## acdream port shape + +1. Capture `TerrainTex.TexTiling` while `TerrainAtlas` assigns each terrain + type to an array layer. +2. Convert the type-keyed values into the renderer's 36-entry layer-indexed + uniform table. Unused layers default to `1.0`; mapped values are preserved + verbatim. +3. Upload the table after binding `terrain_modern.frag`. +4. Sample base, overlay, and road textures with + `cellUv * uTexTiling[layer]`; sample alpha masks with their original UV. diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag index 27e9aa2f..6120be6c 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag @@ -29,6 +29,7 @@ out vec4 fragColor; uniform uvec2 uTerrainHandle; uniform uvec2 uAlphaHandle; +uniform float uTexTiling[36]; #define uTerrain sampler2DArray(uTerrainHandle) #define uAlpha sampler2DArray(uAlphaHandle) @@ -46,7 +47,12 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -const float TILE = 1.0; +// Retail TexMerge::CopyAndTile (0x00503580) and TexMerge::Merge +// (0x005038C0) pass TerrainTex::tex_tiling to every terrain source before +// the cell-scale alpha mask is applied. The atlas stores that value by layer. +float terrainTiling(float layer) { + return uTexTiling[int(layer)]; +} vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { float a0 = h0 == 0.0 ? 1.0 : t0.a; @@ -68,21 +74,21 @@ vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2 vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0); if (h0 > 0.0) { - t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z)); + t0 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z)); if (pOverlay0.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w)); t0.a = a.a; } } if (h1 > 0.0) { - t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z)); + t1 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z)); if (pOverlay1.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w)); t1.a = a.a; } } if (h2 > 0.0) { - t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z)); + t2 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z)); if (pOverlay2.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w)); t2.a = a.a; @@ -96,7 +102,7 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; vec4 result = vec4(0.0); if (h0 > 0.0) { - result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); + result = texture(uTerrain, vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z)); if (pRoad0.w >= 0.0) { vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); result.a = 1.0 - a0.a; @@ -123,7 +129,7 @@ vec3 applyFog(vec3 lit, vec3 worldPos) { void main() { vec4 baseColor = vec4(0.0); if (vBaseTexIdx >= 0.0) { - baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); + baseColor = texture(uTerrain, vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx)); } vec4 overlays = vec4(0.0); diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 03f66f6b..29f8ed4c 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -34,6 +34,12 @@ public sealed unsafe class TerrainAtlas : IDisposable public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer public IReadOnlyDictionary TerrainTypeToLayer { get; } public int LayerCount { get; } + /// + /// UV repeat count for each terrain-array layer. Retail forwards + /// TerrainTex::tex_tiling to both base and merged terrain textures + /// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0). + /// + public IReadOnlyList TilingByLayer { get; } // --- Alpha atlas (new in Phase 3c.2) --- public uint GlAlphaTexture { get; } @@ -86,6 +92,7 @@ public sealed unsafe class TerrainAtlas : IDisposable GL gl, Wb.BindlessSupport? bindless, uint glTexture, IReadOnlyDictionary map, int layerCount, + IReadOnlyList tilingByLayer, uint glAlphaTexture, int alphaLayerCount, IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes) @@ -95,6 +102,7 @@ public sealed unsafe class TerrainAtlas : IDisposable GlTexture = glTexture; TerrainTypeToLayer = map; LayerCount = layerCount; + TilingByLayer = tilingByLayer; GlAlphaTexture = glAlphaTexture; AlphaLayerCount = alphaLayerCount; CornerAlphaLayers = cornerLayers; @@ -125,6 +133,7 @@ public sealed unsafe class TerrainAtlas : IDisposable // ---- Terrain atlas (unchanged Phase 2b logic) ---- var decodedByType = new Dictionary(); + var tilingByType = new Dictionary(); int maxW = 1, maxH = 1; foreach (var tmtd in terrainDesc) { @@ -132,6 +141,12 @@ public sealed unsafe class TerrainAtlas : IDisposable if (decodedByType.ContainsKey(typeKey)) continue; + // Retail terrain composition repeats this surface exactly + // TerrainTex::tex_tiling times in each axis before applying the + // cell-scale alpha mask. Preserve the dat field alongside the + // decoded surface while layer assignment is still type-keyed. + tilingByType[typeKey] = tmtd.TerrainTex.TexTiling; + var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId; var st = dats.Get(surfaceTextureId); if (st is null || st.Textures.Count == 0) @@ -141,6 +156,10 @@ public sealed unsafe class TerrainAtlas : IDisposable continue; } + // Retail ImgTex::GetSurfaceDID (0x0053F0E0) returns source level + // zero unless Render::ShouldDropHighDetail explicitly selects + // level one. acdream currently has no low-detail quality mode, so + // index zero is the retail high-detail source. var rs = dats.Get((uint)st.Textures[0]); if (rs is null) { @@ -183,6 +202,12 @@ public sealed unsafe class TerrainAtlas : IDisposable layerIdx++; } + var tilingByLayer = TerrainTextureTilingTable.Build( + map.Select(entry => + (entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount) + ? repeatCount + : 1u))); + // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. gl.GenerateMipmap(TextureTarget.Texture2DArray); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); @@ -203,7 +228,7 @@ public sealed unsafe class TerrainAtlas : IDisposable return new TerrainAtlas( gl, bindless, - tex, map, layerCount, + tex, map, layerCount, tilingByLayer, alphaBuild.gl, alphaBuild.layerCount, alphaBuild.corner, alphaBuild.side, alphaBuild.road, alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes); @@ -410,6 +435,7 @@ public sealed unsafe class TerrainAtlas : IDisposable gl, bindless, tex, new Dictionary { [0] = 0u }, 1, + TerrainTextureTilingTable.Build(Array.Empty<(uint Layer, uint RepeatCount)>()), alphaTex, 1, Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index f14c98b1..75922f70 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -64,6 +64,8 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). private int _uTerrainHandleLoc; private int _uAlphaHandleLoc; + private int _uTexTilingLoc; + private bool _textureTilingUploaded; // Reusable per-frame buffers. private readonly List _visibleSlots = new(); @@ -90,6 +92,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable _uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle"); _uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle"); + _uTexTilingLoc = _gl.GetUniformLocation(_shader.Program, "uTexTiling[0]"); + if (_uTexTilingLoc < 0) + throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform."); _globalVao = _gl.GenVertexArray(); _globalVbo = _gl.GenBuffer(); @@ -267,6 +272,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // pre-positions terrain to the outdoor landcell, but acdream uses the // unified camera matrix everywhere, so no separate viewpoint divergence can occur. _shader.Use(); + UploadTextureTilingOnce(); _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); @@ -331,6 +337,34 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // Private helpers // ---------------------------------------------------------------- + /// + /// Upload the texture-array adapter for retail's per-surface repeat count. + /// Retail passes TerrainTex::tex_tiling directly to + /// ImgTex::TileCSI / ImgTex::MergeTexture + /// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0). + /// Uniform values persist for the lifetime of this linked shader program, + /// so the immutable atlas table is uploaded on its first bound draw. + /// + private void UploadTextureTilingOnce() + { + if (_textureTilingUploaded) + return; + + if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity) + { + throw new InvalidOperationException( + $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " + + $"expected {TerrainTextureTilingTable.LayerCapacity}."); + } + + Span values = stackalloc float[TerrainTextureTilingTable.LayerCapacity]; + for (int i = 0; i < values.Length; i++) + values[i] = _atlas.TilingByLayer[i]; + + _gl.Uniform1(_uTexTilingLoc, values); + _textureTilingUploaded = true; + } + /// /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared /// UBO (); otherwise lazily diff --git a/src/AcDream.App/Rendering/TerrainTextureTilingTable.cs b/src/AcDream.App/Rendering/TerrainTextureTilingTable.cs new file mode 100644 index 00000000..a5686057 --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainTextureTilingTable.cs @@ -0,0 +1,40 @@ +namespace AcDream.App.Rendering; + +/// +/// Builds the layer-indexed adapter table consumed by +/// terrain_modern.frag. Retail passes TerrainTex::tex_tiling +/// directly to ImgTex::TileCSI / ImgTex::MergeTexture +/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0). +/// The table is the modern texture-array equivalent of that per-texture +/// argument. +/// +internal static class TerrainTextureTilingTable +{ + // WorldBuilder's extracted terrain-array path uses the same 36-layer + // contract. Packed terrain layer indices are validated against this + // capacity before the shader table is built. + internal const int LayerCapacity = 36; + + internal static float[] Build(IEnumerable<(uint Layer, uint RepeatCount)> entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var table = new float[LayerCapacity]; + Array.Fill(table, 1f); + + foreach (var (layer, repeatCount) in entries) + { + if (layer >= LayerCapacity) + { + throw new InvalidOperationException( + $"Terrain atlas layer {layer} exceeds the shader capacity of {LayerCapacity} layers."); + } + + // Preserve the dat value exactly. Retail forwards the unsigned + // field without normalizing it in both CopyAndTile and Merge. + table[layer] = repeatCount; + } + + return table; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs new file mode 100644 index 00000000..e2bec44f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs @@ -0,0 +1,66 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class TerrainTextureTilingTableTests +{ + [Fact] + public void Build_MapsDatRepeatCountsToAtlasLayers() + { + var table = TerrainTextureTilingTable.Build( + [ + (Layer: 0u, RepeatCount: 4u), + (Layer: 7u, RepeatCount: 2u), + (Layer: 32u, RepeatCount: 8u), + ]); + + Assert.Equal(TerrainTextureTilingTable.LayerCapacity, table.Length); + Assert.Equal(4f, table[0]); + Assert.Equal(2f, table[7]); + Assert.Equal(8f, table[32]); + } + + [Fact] + public void Build_DefaultsOnlyUnusedLayersToOne() + { + var table = TerrainTextureTilingTable.Build( + [ + (Layer: 3u, RepeatCount: 0u), + ]); + + Assert.Equal(1f, table[2]); + Assert.Equal(0f, table[3]); + Assert.Equal(1f, table[4]); + } + + [Fact] + public void Build_RejectsLayersTheShaderCannotAddress() + { + var ex = Assert.Throws(() => + TerrainTextureTilingTable.Build( + [ + (Layer: (uint)TerrainTextureTilingTable.LayerCapacity, RepeatCount: 1u), + ])); + + Assert.Contains("exceeds the shader capacity", ex.Message); + } + + [Fact] + public void ModernShader_AppliesTheOwningLayersRepeatCountToEveryTerrainSample() + { + string shaderPath = Path.Combine( + AppContext.BaseDirectory, + "Rendering", + "Shaders", + "terrain_modern.frag"); + string shader = File.ReadAllText(shaderPath); + + Assert.Contains("uniform float uTexTiling[36];", shader); + Assert.Contains("baseUV * terrainTiling(pOverlay0.z)", shader); + Assert.Contains("baseUV * terrainTiling(pOverlay1.z)", shader); + Assert.Contains("baseUV * terrainTiling(pOverlay2.z)", shader); + Assert.Contains("baseUV * terrainTiling(pRoad0.z)", shader); + Assert.Contains("vBaseUV * terrainTiling(vBaseTexIdx)", shader); + Assert.DoesNotContain("const float TILE", shader); + } +}