From 28d2c6018ef4bc0be0bad39b697c06ad6d23a17c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:43:06 +0200 Subject: [PATCH] feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2) GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality) + WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores result in _resolvedQuality field. All six quality dimensions applied: - NearRadius / FarRadius: replace old T16 env-var-only block; preset drives the radii, legacy ACDREAM_STREAM_RADIUS override still honoured. - MsaaSamples: WindowOptions.Samples reads from startup quality resolution in Run() (pre-window-create read from SettingsStore). MSAA cannot change at runtime; ReapplyQualityPreset logs a restart-required warning if the new preset would change it. - AnisotropicLevel: TerrainAtlas.SetAnisotropic() called after Build() and again in ReapplyQualityPreset. Temporarily removes bindless residency before the GL TexParameter call, re-makes resident after. - AlphaToCoverage: WbDrawDispatcher.AlphaToCoverage property gates the glEnable/glDisable(SampleAlphaToCoverage) pair around the opaque pass. - MaxCompletionsPerFrame: set on StreamingController after construction and after each mid-session restart. ReapplyQualityPreset(QualityPreset) method handles mid-session changes (Settings panel Quality dropdown Save): rebuilds streamer + controller for radius changes, toggles A2C and aniso immediately, logs MSAA restart caveat. onSaveDisplay callback updated to call ReapplyQualityPreset when Quality field changes. TerrainModernRenderer.Atlas property added to expose the atlas for mid-session aniso updates. 991 tests passing, 8 pre-existing failures unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 175 ++++++++++++++++-- src/AcDream.App/Rendering/TerrainAtlas.cs | 37 ++++ .../Rendering/TerrainModernRenderer.cs | 4 + .../Rendering/Wb/WbDrawDispatcher.cs | 15 +- .../Panels/Settings/SettingsPanel.cs | 21 ++- 5 files changed, 236 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4927cf0..5226921 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -86,6 +86,13 @@ public sealed class GameWindow : IDisposable private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) + // A.5 T22.5: resolved quality settings (preset + env-var overrides). + // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on + // ReapplyQualityPreset(). Default matches QualityPreset.High so the field + // is valid before OnLoad fires (no GL calls are made before OnLoad anyway). + private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality = + AcDream.UI.Abstractions.Settings.QualitySettings.From( + AcDream.UI.Abstractions.Settings.QualityPreset.High); private uint? _lastLivePlayerLandblockId; // Phase B.3: physics engine — populated from the streaming pipeline. @@ -820,6 +827,16 @@ public sealed class GameWindow : IDisposable public void Run() { + // A.5 T22.5: resolve quality preset BEFORE creating the window so + // Samples (MSAA) is baked into WindowOptions correctly. GL context + // sample count cannot change at runtime; all other quality fields are + // applied again in OnLoad after the full settings load. + var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var startupDisplay = startupStore.LoadDisplay(); + var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); + var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -830,7 +847,11 @@ public sealed class GameWindow : IDisposable ContextFlags.ForwardCompatible, new APIVersion(4, 3)), VSync = false, // off during development so the perf overlay shows true framerate - Samples = 4, // A.5 T20: MSAA 4x for A2C foliage smoothing + // A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample). + // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). + // Cannot be changed at runtime; Quality changes mid-session that would + // alter MsaaSamples are logged as a restart-required warning. + Samples = startupQuality.MsaaSamples, }; _window = Window.Create(options); @@ -1094,6 +1115,18 @@ public sealed class GameWindow : IDisposable // without re-loading. LoadAndApplyPersistedSettings(); + // A.5 T22.5: resolve quality preset immediately after settings load so + // _resolvedQuality is available for TerrainAtlas.SetAnisotropic, + // WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below. + { + var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality); + _resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase); + if (!_resolvedQuality.Equals(qBase)) + Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}"); + else + Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}"); + } + // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. @@ -1227,6 +1260,12 @@ public sealed class GameWindow : IDisposable // already track DisplayDraft via the // per-frame push. ApplyDisplayWindowState(display); + // A.5 T22.5: apply quality preset if it changed. + // MSAA changes log a restart-required warning + // inside ReapplyQualityPreset; all other fields + // apply immediately. + _persistedDisplay = display; + ReapplyQualityPreset(display.Quality); } catch (Exception ex) { @@ -1453,6 +1492,10 @@ public sealed class GameWindow : IDisposable // atlas exposes bindless handles for the modern terrain path, so // BindlessSupport is threaded through. var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport); + // A.5 T22.5: apply anisotropic level from quality preset. Build() + // hard-codes 16x; override here to match the resolved quality so Low + // (4x) and Medium (8x) actually take effect. + terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); _terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas); @@ -1562,6 +1605,8 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!); + // A.5 T22.5: apply A2C gate from quality preset. + _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -1579,20 +1624,17 @@ public sealed class GameWindow : IDisposable // the player. _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); - // Phase A.5 T16: two-tier radius env-var parsing. - // ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS set the two rings independently. - // Legacy ACDREAM_STREAM_RADIUS is honoured for backward-compat: it sets - // nearRadius and bumps farRadius to max(streamRadius, default farRadius). + // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. + // _resolvedQuality was set by the quality block immediately after + // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. + // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. + _nearRadius = _resolvedQuality.NearRadius; + _farRadius = _resolvedQuality.FarRadius; + + // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and + // ensures farRadius >= streamRadius. { - var nearEnv = Environment.GetEnvironmentVariable("ACDREAM_NEAR_RADIUS"); - var farEnv = Environment.GetEnvironmentVariable("ACDREAM_FAR_RADIUS"); var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); - - if (int.TryParse(nearEnv, out var nr) && nr >= 0) _nearRadius = nr; - if (int.TryParse(farEnv, out var fr) && fr >= 0) _farRadius = fr; - - // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and - // ensures farRadius >= streamRadius. if (int.TryParse(legacyEnv, out var sr) && sr >= 0) { _nearRadius = sr; @@ -1649,6 +1691,8 @@ public sealed class GameWindow : IDisposable _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); }); + // A.5 T22.5: apply max-completions from resolved quality. + _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream @@ -8048,6 +8092,111 @@ public sealed class GameWindow : IDisposable } } + /// + /// A.5 T22.5: apply a new quality preset mid-session (called from the + /// Settings panel Save path when + /// changes). + /// + /// + /// What changes immediately: + /// + /// Streaming radii: disposes the old + /// + + /// and constructs new ones with the new radii. + /// Anisotropic filtering: calls + /// TerrainAtlas.SetAnisotropic. + /// Alpha-to-coverage gate: sets + /// WbDrawDispatcher.AlphaToCoverage. + /// Max completions per frame: updates + /// StreamingController.MaxCompletionsPerFrame. + /// + /// + /// + /// + /// What requires a restart: + /// MSAA samples are baked into the GL context via WindowOptions.Samples + /// at window creation time and cannot change at runtime. If the new preset + /// would change MsaaSamples, a warning is logged and MSAA is left + /// at its current level until the next launch. + /// + /// + public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) + { + var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); + var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); + + Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); + + // MSAA samples cannot change at runtime — warn if preset would differ. + if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples) + { + Console.WriteLine( + $"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " + + $"{newResolved.MsaaSamples}) requires a restart — skipped for this session."); + } + + _resolvedQuality = newResolved; + + // A2C gate — immediate toggle, no GL context restart needed. + if (_wbDrawDispatcher is not null) + _wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage; + + // Anisotropic — immediate GL TexParameter call on the terrain atlas. + _terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel); + + // Streaming radii — requires tearing down + rebuilding the controller + // (radii are constructor-time on StreamingController, not live-mutable). + // The ~1-2s hitch while the worker drains is acceptable for a settings change. + if (_streamer is not null && _streamingController is not null) + { + _nearRadius = newResolved.NearRadius; + _farRadius = newResolved.FarRadius; + + // StreamingController is stateless (no Dispose needed); dispose + // only the LandblockStreamer worker thread. + _streamer.Dispose(); + + _streamer = new AcDream.App.Streaming.LandblockStreamer( + loadLandblock: id => BuildLandblockForStreaming(id), + buildMeshOrNull: (id, lb) => + { + if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) + return null; + uint lbX = (id >> 24) & 0xFFu; + uint lbY = (id >> 16) & 0xFFu; + return AcDream.Core.Terrain.LandblockMesh.Build( + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + }); + _streamer.Start(); + + _streamingController = new AcDream.App.Streaming.StreamingController( + enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), + enqueueUnload: _streamer.EnqueueUnload, + drainCompletions: _streamer.DrainCompletions, + applyTerrain: ApplyLoadedTerrain, + state: _worldState, + nearRadius: _nearRadius, + farRadius: _farRadius, + removeTerrain: id => + { + if (_lightingSink is not null && + _worldState.TryGetLandblock(id, out var lb)) + { + foreach (var ent in lb!.Entities) + _lightingSink.UnregisterOwner(ent.Id); + } + _terrain?.RemoveLandblock(id); + _physicsEngine.RemoveLandblock(id); + _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); + }); + _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; + + Console.WriteLine( + $"[QUALITY] Streaming restarted: nearRadius={_nearRadius}, " + + $"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}"); + } + } + /// /// L.0 Display tab: framebuffer-resize handler — update GL viewport /// + camera aspect when the window is resized (by the user dragging diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index c0d488e..03f66f6 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -415,6 +415,43 @@ public sealed unsafe class TerrainAtlas : IDisposable Array.Empty(), Array.Empty(), Array.Empty()); } + /// + /// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at + /// runtime (called by when + /// the user changes Quality preset mid-session). Idempotent — calling with + /// the same level as the current setting is safe and produces no visual + /// change. The texture must not be resident-bindless when its parameters + /// are mutated; we temporarily make it non-resident if needed. + /// + public void SetAnisotropic(int level) + { + // If bindless handles are live we must make them non-resident before + // mutating texture state, then re-resident after. + bool wasResident = _handlesGenerated && _bindless is not null; + if (wasResident) + { + _bindless!.MakeNonResident(_terrainHandle); + // Alpha texture is not affected by anisotropic but we must keep + // residency symmetric — re-generate both handles after. + _bindless.MakeNonResident(_alphaHandle); + _handlesGenerated = false; + } + + _gl.BindTexture(TextureTarget.Texture2DArray, GlTexture); + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE + _gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level); + _gl.BindTexture(TextureTarget.Texture2DArray, 0); + + // Re-generate bindless handles if they were live before. + if (wasResident) + { + // GetBindlessHandles regenerates and makes resident. + _ = GetBindlessHandles(); + } + + Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); + } + public void Dispose() { // Phase 1: release bindless residency BEFORE deleting textures. diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 3f62493..0145ce9 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -35,6 +35,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private readonly Shader _shader; private readonly TerrainAtlas _atlas; + /// A.5 T22.5: exposes the terrain atlas so callers can update + /// anisotropic level mid-session via . + public TerrainAtlas Atlas => _atlas; + private readonly TerrainSlotAllocator _alloc; // Per-slot live data (index by slot integer; null entries are unused slots). diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 3a4db8c..b72490e 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -68,6 +68,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly BindlessSupport _bindless; + /// + /// A.5 T22.5: gate for GL_SAMPLE_ALPHA_TO_COVERAGE around the opaque pass. + /// Default true matches T20 behavior. Set false for Low/Medium presets that + /// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off + /// avoids the unnecessary GL state thrash and is cleaner diagnostics). + /// Can be toggled mid-session via . + /// + public bool AlphaToCoverage { get; set; } = true; + // SSBO buffer ids private uint _instanceSsbo; private uint _batchSsbo; @@ -491,7 +500,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask // from the alpha written by mesh_modern.frag so foliage edges are // smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches. - _gl.Enable(EnableCap.SampleAlphaToCoverage); + // A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets + // (no MSAA) skip the unnecessary GL state change. + if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage); _shader.SetInt("uRenderPass", 0); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque); @@ -502,7 +513,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable (uint)_opaqueDrawCount, (uint)DrawCommandStride); if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed); - _gl.Disable(EnableCap.SampleAlphaToCoverage); + if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage); } // ── Phase 8: transparent pass ──────────────────────────────────────── diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index a8a8034..698eee1 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -219,10 +220,23 @@ public sealed class SettingsPanel : IPanel if (renderer.Checkbox("Show FPS", ref showFps)) _vm.SetDisplay(d with { ShowFps = showFps }); + // A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA, + // anisotropic level, A2C, and max completions-per-frame as a unit. + // Resolution + anisotropic + A2C + completions apply immediately via + // ReapplyQualityPreset; MSAA samples require a restart (GL context + // cannot change sample count at runtime). + var presets = s_qualityPresetNames; + int qIdx = (int)d.Quality; + if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High; + if (renderer.Combo("Quality", ref qIdx, presets)) + _vm.SetDisplay(d with { Quality = (QualityPreset)qIdx }); + renderer.Spacing(); renderer.TextWrapped( "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " - + "preview live as you drag; Cancel reverts to the saved value."); + + "preview live as you drag; Cancel reverts to the saved value. " + + "Quality preset applies streaming radius, anisotropic, and A2C " + + "immediately on Save; MSAA sample count requires a restart."); } /// @@ -446,6 +460,11 @@ public sealed class SettingsPanel : IPanel + "round-trip lands."); } + // A.5 T22.5: preset label array parallel to QualityPreset enum values. + // Order must match the enum (Low=0, Medium=1, High=2, Ultra=3). + private static readonly string[] s_qualityPresetNames = + { "Low", "Medium", "High", "Ultra" }; + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX.