refactor(sky): replace per-frame wrap-mode mutation with persistent samplers

Ports WorldBuilder's GL sampler-object pattern
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132,
SkyboxRenderManager.cs:312). Two persistent samplers (Repeat +
ClampToEdge) are created once at GL init; the sky pass binds the
appropriate one to texture unit 0 per submesh instead of mutating
per-texture GL_TEXTURE_WRAP_S/T state.

Why this is better than the prior M1 track-and-restore hack:

  1. Sampler state is decoupled from texture state. Two renderers can
     share the same texture handle but sample it with different wrap
     modes simultaneously and safely — sampler state at the bind point
     overrides the texture's own wrap parameters.

  2. No bookkeeping. Drops the HashSet<uint> clamped-textures tracking
     and the end-of-pass restore loop. The only restore needed is
     BindSampler(0, 0) to release unit 0 back to per-texture state.

  3. Constant cost. Sampler objects are created once per GL context,
     not per draw. Filter modes match TextureCache's upload defaults
     (Linear/Linear, no mipmaps) so the binding is purely a wrap-mode
     selection.

Field count: SkyRenderer.cs -28 lines, +14 lines. GameWindow.cs gets
the SamplerCache field + ctor + Dispose. SkyRenderer disposed before
SamplerCache so the sky teardown path doesn't reference a freed
sampler handle.

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 08:08:26 +02:00
parent ec1bbb4f43
commit 3d21c1352a
3 changed files with 90 additions and 40 deletions

View file

@ -48,6 +48,7 @@ public sealed unsafe class SkyRenderer : IDisposable
private readonly DatCollection _dats;
private readonly Shader _shader;
private readonly TextureCache _textures;
private readonly SamplerCache _samplers;
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
@ -61,12 +62,13 @@ public sealed unsafe class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_shader = shader ?? throw new ArgumentNullException(nameof(shader));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_samplers = samplers ?? throw new ArgumentNullException(nameof(samplers));
}
/// <summary>
@ -214,12 +216,6 @@ public sealed unsafe class SkyRenderer : IDisposable
float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
// M1: track texture handles whose wrap mode we set to ClampToEdge
// so we can restore them to Repeat (TextureCache's default upload
// state) at end-of-pass. Without this, any subsequent renderer
// sharing the texture handle would silently inherit ClampToEdge.
var clampedTextures = new HashSet<uint>();
for (int i = 0; i < group.SkyObjects.Count; i++)
{
var obj = group.SkyObjects[i];
@ -413,28 +409,17 @@ public sealed unsafe class SkyRenderer : IDisposable
// 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).
//
// Implementation: bind a persistent sampler object to
// texture unit 0. Sampler state overrides the texture's
// own wrap state, so two renderers can share the same
// texture handle but sample it with different wrap modes
// safely. Ported from WorldBuilder
// (Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:312).
bool needsRepeat = sub.NeedsUvRepeat
|| obj.TexVelocityX != 0f
|| obj.TexVelocityY != 0f;
if (!needsRepeat)
{
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
clampedTextures.Add(tex);
}
// No else branch: TextureCache uploads with Repeat, so a
// texture whose wrap was clamped earlier this pass and is
// re-bound now still needs to be told to Repeat.
else if (clampedTextures.Contains(tex))
{
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.Repeat);
clampedTextures.Remove(tex);
}
_gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles,
@ -444,19 +429,13 @@ public sealed unsafe class SkyRenderer : IDisposable
}
}
// M1: restore wrap mode on every texture this pass clamped, so
// the rest of the pipeline sees TextureCache's default Repeat
// state regardless of which sky-mesh order we drew.
foreach (var tex in clampedTextures)
{
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.Repeat);
}
// Restore GL state expected by the rest of the pipeline.
// Critical: unbind the sampler from unit 0. While bound, sampler
// state overrides the texture's own wrap parameters, so leaving
// (e.g.) Clamp bound would silently force ClampToEdge on every
// subsequent draw on unit 0 regardless of how that texture was
// configured at upload time.
_gl.BindSampler(0, 0);
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);