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; }