fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 22:55:24 +02:00
parent 991fb9a222
commit 7b88fde52d
3 changed files with 68 additions and 11 deletions

View file

@ -222,17 +222,31 @@ public sealed unsafe class SkyRenderer : IDisposable
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex); _gl.BindTexture(TextureTarget.Texture2D, tex);
// Sky meshes need per-object wrap mode. The dome is 5 flat // Sky meshes need per-object wrap mode driven by the
// walls meeting at edges — under GL_REPEAT any UV drift // mesh's authored UV range, not by TexVelocity:
// past [0,1] wraps to the opposite edge of the texture, // * The outer dome (0x010015EE/F0/F1/F2) authors UVs
// drawing a visible line along each wall seam. Static // strictly in [0,1]. Under GL_REPEAT the bilinear
// sky GfxObjs (dome, sun, moon, stars) should use // filter at wall-seam edges would average a texel
// CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud // near the right edge with one near the left edge of
// layers (TexVelocity != 0) still need REPEAT so the // the texture, drawing a visible "bleed line" along
// animated UV offset wraps correctly. Detection heuristic: // every dome seam. CLAMP_TO_EDGE avoids that.
// non-zero TexVelocity on either axis ⇒ scrolling layer. // * The inner sky/star layer (0x010015EF) and the
bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; // cloud meshes (0x010015B6, 0x01004C36 etc) author
int wrapMode = scrolling // 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.Repeat
: (int)TextureWrapMode.ClampToEdge; : (int)TextureWrapMode.ClampToEdge;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
@ -423,6 +437,7 @@ public sealed unsafe class SkyRenderer : IDisposable
SurfaceId = sm.SurfaceId, SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive, IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity, SurfLuminosity = sm.Luminosity,
NeedsUvRepeat = sm.NeedsUvRepeat,
}; };
} }
@ -462,5 +477,15 @@ public sealed unsafe class SkyRenderer : IDisposable
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6. /// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// </summary> /// </summary>
public float SurfLuminosity; public float SurfLuminosity;
/// <summary>
/// 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 <c>GL_REPEAT</c> 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.
/// </summary>
public bool NeedsUvRepeat;
} }
} }

View file

@ -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( result.Add(new GfxObjSubMesh(
SurfaceId: surfaceId, SurfaceId: surfaceId,
Vertices: kvp.Value.Vertices.ToArray(), Vertices: kvp.Value.Vertices.ToArray(),
@ -217,6 +234,7 @@ public static class GfxObjMesh
{ {
Translucency = translucency, Translucency = translucency,
Luminosity = luminosity, Luminosity = luminosity,
NeedsUvRepeat = needsUvRepeat,
}); });
} }
return result; return result;

View file

@ -39,4 +39,18 @@ public sealed record GfxObjSubMesh(
/// normal lighting path without change. /// normal lighting path without change.
/// </summary> /// </summary>
public float Luminosity { get; init; } = 0f; public float Luminosity { get; init; } = 0f;
/// <summary>
/// True when at least one vertex's UV component lies outside the
/// <c>[0, 1]</c> range, meaning the mesh was authored to have its
/// texture tile across the geometry (i.e. it expects
/// <c>GL_REPEAT</c>/<c>D3DTADDRESS_WRAP</c>). The sky renderer reads
/// this to decide between <c>GL_REPEAT</c> (this flag set, or any
/// scrolling layer) and <c>GL_CLAMP_TO_EDGE</c> (all UVs strictly
/// in <c>[0,1]</c>), which avoids wall-seam bleed on the dome
/// (UVs in <c>[0,1]</c>) while still tiling the inner star/cloud
/// layers (UVs in <c>[~0.4, ~4.6]</c>) correctly.
/// Defaults to false so non-sky consumers get the previous behavior.
/// </summary>
public bool NeedsUvRepeat { get; init; } = false;
} }