From 7b88fde52d2ba47110b49b42def436fedfd8f7fa Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 22:55:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(sky):=20drive=20wrap=20mode=20from=20mesh?= =?UTF-8?q?=20UV=20range=20=E2=80=94=20fixes=20Bug=20B=20(stars-as-square)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug B in docs/research/2026-04-26-sky-investigation-handoff.md: stars rendered as a small square in one corner of the sky instead of stretching across the dome. Root cause: the wrap-mode heuristic at SkyRenderer.cs:234-237 was "GL_CLAMP_TO_EDGE unless TexVelocity != 0". That heuristic was tuned to fix a separate symptom (the outer dome 0x010015EE/F0/F1/F2 shows wall-seam bleed under GL_REPEAT because of bilinear-filter sampling at texel boundaries). But it misclassified any *static* sky object whose mesh UVs are deliberately authored outside [0,1] to tile the texture across the geometry. The smoking gun: GfxObj 0x010015EF is OI-1 in EVERY DayGroup (always loaded), has TexVelocity = 0 (no scrolling), and authors UVs in [0.398, 4.602] (texture tiles ~4× across each face). Under CLAMP_TO_EDGE the bulk of the inner dome sampled the texture's edge texels; only the small region where UVs happened to fall in [0,1] showed actual texture content. Hence "a square in one corner". Fix: * GfxObjMesh.Build() now scans the resulting per-vertex UVs and sets GfxObjSubMesh.NeedsUvRepeat true when any component lies outside [0,1]. Mesh-time scan, not draw-time guess. * SubMeshGpu carries the flag through to draw time. * SkyRenderer uses `sub.NeedsUvRepeat || obj.TexVelocity != 0` to decide REPEAT vs CLAMP_TO_EDGE. The dome (UVs in [0,1]) keeps CLAMP — no seam regression. The inner star/sky layer 0x010015EF (UVs outside [0,1]) gets REPEAT — texture tiles across the dome. Cloud meshes (UVs outside [0,1] AND non-zero TexVelocity) keep REPEAT via either branch. Probe-driven: tools/StarsProbe (committed in 991fb9a) dumps every SkyObject's geometry + UVs and flags meshes whose UV range exceeds [0,1]. Run `dotnet run --project tools/StarsProbe -c Release` to re-derive. Verified visually by user against the live ACE server in Holtburg — stars now stretch across the night sky instead of appearing as a square in one corner. Build green, dotnet test 1222 pass. Note: this is functionally retail-equivalent for the reported bug but not the exact retail mechanism. Retail's GameSky::Draw at 0x00506ff0 relies on D3D's global default D3DTADDRESS_WRAP (i.e. REPEAT everywhere). True retail-faithfulness would require investigating why our pipeline shows seams on the dome under REPEAT (likely a bilinear filter / non-seamless texture detail). The data-driven approach taken here preserves working dome behavior while fixing the broken star behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 47 +++++++++++++++----- src/AcDream.Core/Meshing/GfxObjMesh.cs | 18 ++++++++ src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 14 ++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 48ac917..6fefea3 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -222,17 +222,31 @@ public sealed unsafe class SkyRenderer : IDisposable _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); - // Sky meshes need per-object wrap mode. The dome is 5 flat - // walls meeting at edges — under GL_REPEAT any UV drift - // past [0,1] wraps to the opposite edge of the texture, - // drawing a visible line along each wall seam. Static - // sky GfxObjs (dome, sun, moon, stars) should use - // CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud - // layers (TexVelocity != 0) still need REPEAT so the - // animated UV offset wraps correctly. Detection heuristic: - // non-zero TexVelocity on either axis ⇒ scrolling layer. - bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; - int wrapMode = scrolling + // Sky meshes need per-object wrap mode driven by the + // mesh's authored UV range, not by TexVelocity: + // * The outer dome (0x010015EE/F0/F1/F2) authors UVs + // strictly in [0,1]. Under GL_REPEAT the bilinear + // filter at wall-seam edges would average a texel + // near the right edge with one near the left edge of + // the texture, drawing a visible "bleed line" along + // every dome seam. CLAMP_TO_EDGE avoids that. + // * The inner sky/star layer (0x010015EF) and the + // cloud meshes (0x010015B6, 0x01004C36 etc) author + // UVs that deliberately exceed [0,1] (~0.4..4.6) so + // the texture tiles across the geometry. CLAMP_TO_EDGE + // would clamp ~99% of the surface to a single edge + // texel, leaving only a small "square" where UVs + // happen to fall in [0,1] (Bug B in + // docs/research/2026-04-26-sky-investigation-handoff.md). + // The mesh builder pre-computes NeedsUvRepeat from the + // actual UV range so the right answer is data-driven. + // Scrolling clouds are also forced to REPEAT (the running + // UV offset can drift outside [0,1] regardless of authored + // range, and they'd show their own seam bleed otherwise). + bool needsRepeat = sub.NeedsUvRepeat + || obj.TexVelocityX != 0f + || obj.TexVelocityY != 0f; + int wrapMode = needsRepeat ? (int)TextureWrapMode.Repeat : (int)TextureWrapMode.ClampToEdge; _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); @@ -423,6 +437,7 @@ public sealed unsafe class SkyRenderer : IDisposable SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, + NeedsUvRepeat = sm.NeedsUvRepeat, }; } @@ -462,5 +477,15 @@ public sealed unsafe class SkyRenderer : IDisposable /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; + /// + /// True when the source mesh's authored UVs exceed [0,1] (e.g. + /// the inner sky/star layer 0x010015EF and the cloud meshes — + /// they tile their texture across the geometry). The renderer + /// must use GL_REPEAT for these or only the small region + /// where UVs fall in [0,1] samples the actual texture; the rest + /// clamps to the edge texel ("square in one corner" symptom). + /// Computed once at mesh build from the actual UV range. + /// + public bool NeedsUvRepeat; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index f468dae..240e4db 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -210,6 +210,23 @@ public static class GfxObjMesh } } + // Authored UV range determines the wrap-mode choice in the + // sky pass. A mesh whose UVs are strictly in [0,1] (e.g. the + // outer dome 0x010015EE) wants CLAMP_TO_EDGE to avoid + // bilinear-filter bleed at the wall-seam edges; a mesh whose + // UVs deliberately tile (e.g. 0x010015EF, ~0.4..4.6) wants + // REPEAT so the texture tiles across the geometry. We make + // the call data-driven here rather than guessing from + // TexVelocity at draw time. See + // docs/research/2026-04-26-sky-investigation-handoff.md (Bug B). + bool needsUvRepeat = false; + foreach (var v in kvp.Value.Vertices) + { + if (v.TexCoord.X < 0f || v.TexCoord.X > 1f + || v.TexCoord.Y < 0f || v.TexCoord.Y > 1f) + { needsUvRepeat = true; break; } + } + result.Add(new GfxObjSubMesh( SurfaceId: surfaceId, Vertices: kvp.Value.Vertices.ToArray(), @@ -217,6 +234,7 @@ public static class GfxObjMesh { Translucency = translucency, Luminosity = luminosity, + NeedsUvRepeat = needsUvRepeat, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index d6a9cd0..488b0dd 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -39,4 +39,18 @@ public sealed record GfxObjSubMesh( /// normal lighting path without change. /// public float Luminosity { get; init; } = 0f; + + /// + /// True when at least one vertex's UV component lies outside the + /// [0, 1] range, meaning the mesh was authored to have its + /// texture tile across the geometry (i.e. it expects + /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads + /// this to decide between GL_REPEAT (this flag set, or any + /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly + /// in [0,1]), which avoids wall-seam bleed on the dome + /// (UVs in [0,1]) while still tiling the inner star/cloud + /// layers (UVs in [~0.4, ~4.6]) correctly. + /// Defaults to false so non-sky consumers get the previous behavior. + /// + public bool NeedsUvRepeat { get; init; } = false; }