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:
parent
ec1bbb4f43
commit
3d21c1352a
3 changed files with 90 additions and 40 deletions
|
|
@ -28,6 +28,7 @@ public sealed class GameWindow : IDisposable
|
|||
private InstancedMeshRenderer? _staticMesh;
|
||||
private Shader? _meshShader;
|
||||
private TextureCache? _textureCache;
|
||||
private SamplerCache? _samplerCache;
|
||||
private DebugLineRenderer? _debugLines;
|
||||
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
|
||||
// wireframes are noisy outdoors and confuse first-time users into
|
||||
|
|
@ -1210,6 +1211,12 @@ public sealed class GameWindow : IDisposable
|
|||
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
|
||||
|
||||
_textureCache = new TextureCache(_gl, _dats);
|
||||
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
|
||||
// the sky pass can pick wrap mode per submesh without mutating
|
||||
// shared per-texture wrap state. See SamplerCache + the
|
||||
// WorldBuilder reference at
|
||||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
|
||||
_samplerCache = new SamplerCache(_gl);
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||
|
|
@ -1219,7 +1226,7 @@ public sealed class GameWindow : IDisposable
|
|||
Path.Combine(shadersDir, "sky.vert"),
|
||||
Path.Combine(shadersDir, "sky.frag"));
|
||||
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||
_gl, _dats, skyShader, _textureCache);
|
||||
_gl, _dats, skyShader, _textureCache, _samplerCache);
|
||||
|
||||
// Phase G.1 particle renderer — renders rain / snow / spell auras
|
||||
// spawned into the shared ParticleSystem as billboard quads.
|
||||
|
|
@ -6426,12 +6433,13 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession?.Dispose();
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_staticMesh?.Dispose();
|
||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||
_samplerCache?.Dispose();
|
||||
_textureCache?.Dispose();
|
||||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
_shader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_skyRenderer?.Dispose();
|
||||
_particleRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_textRenderer?.Dispose();
|
||||
|
|
|
|||
63
src/AcDream.App/Rendering/SamplerCache.cs
Normal file
63
src/AcDream.App/Rendering/SamplerCache.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once
|
||||
/// per GL context. Renderers <see cref="GL.BindSampler"/> the appropriate
|
||||
/// one to a texture unit instead of mutating per-texture
|
||||
/// <c>GL_TEXTURE_WRAP_S/T</c> state — sampler state overrides the
|
||||
/// texture's own wrap parameters, so two renderers can share the same
|
||||
/// texture handle but sample it with different wrap modes safely.
|
||||
///
|
||||
/// <para>
|
||||
/// Ported from
|
||||
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132</c>.
|
||||
/// Filter modes match <see cref="TextureCache"/>'s upload defaults
|
||||
/// (Linear / Linear, no mipmaps) so binding either sampler doesn't
|
||||
/// change the visual filtering behavior — only the wrap behavior at
|
||||
/// UVs outside [0, 1].
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Lifetime: created once at GL init, disposed with the GL context.
|
||||
/// Anything that binds a sampler MUST unbind it (<c>BindSampler(unit, 0)</c>)
|
||||
/// before yielding to a renderer that doesn't use samplers, otherwise
|
||||
/// the bound sampler's wrap mode will silently override that renderer's
|
||||
/// per-texture wrap state.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SamplerCache : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
|
||||
/// <summary>Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by <see cref="TextureCache"/>.</summary>
|
||||
public uint Wrap { get; }
|
||||
|
||||
/// <summary>Sampler with WrapS = WrapT = ClampToEdge. Used by sky meshes whose authored UVs are strictly in [0, 1] to avoid bilinear-filter bleed at seam edges.</summary>
|
||||
public uint Clamp { get; }
|
||||
|
||||
public SamplerCache(GL gl)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
||||
Wrap = _gl.GenSampler();
|
||||
_gl.SamplerParameter(Wrap, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
|
||||
_gl.SamplerParameter(Wrap, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
|
||||
_gl.SamplerParameter(Wrap, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.SamplerParameter(Wrap, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
|
||||
|
||||
Clamp = _gl.GenSampler();
|
||||
_gl.SamplerParameter(Clamp, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
|
||||
_gl.SamplerParameter(Clamp, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
|
||||
_gl.SamplerParameter(Clamp, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.SamplerParameter(Clamp, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Wrap != 0) _gl.DeleteSampler(Wrap);
|
||||
if (Clamp != 0) _gl.DeleteSampler(Clamp);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue