diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5f85641..6d6f558 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); _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(); diff --git a/src/AcDream.App/Rendering/SamplerCache.cs b/src/AcDream.App/Rendering/SamplerCache.cs new file mode 100644 index 0000000..d65e7e8 --- /dev/null +++ b/src/AcDream.App/Rendering/SamplerCache.cs @@ -0,0 +1,63 @@ +using System; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once +/// per GL context. Renderers the appropriate +/// one to a texture unit instead of mutating per-texture +/// GL_TEXTURE_WRAP_S/T 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. +/// +/// +/// Ported from +/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. +/// Filter modes match '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]. +/// +/// +/// +/// Lifetime: created once at GL init, disposed with the GL context. +/// Anything that binds a sampler MUST unbind it (BindSampler(unit, 0)) +/// 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. +/// +/// +public sealed class SamplerCache : IDisposable +{ + private readonly GL _gl; + + /// Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by . + public uint Wrap { get; } + + /// 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. + 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); + } +} diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 84969e8..29cba3f 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -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> _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)); } /// @@ -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(); - 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);