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.