Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system

Phase A.5 — Two-tier Streaming + Horizon LOD shipped.

Headline: 2.3 km terrain horizon (radius=4 near + 12 far) with off-thread
mesh build, fog blend at N₁, mipmaps + 16x AF, MSAA 4x + A2C foliage,
depth-write audit, BUDGET_OVER diag, Quality Preset system (Low/Medium/
High/Ultra) with env-var overrides + F11 mid-session re-apply.

~999 tests pass, 8 pre-existing physics/input failures unchanged.

Two structural-to-A.5 bug fixes shipped post-T26:
- Bug A (9217fd9): far-tier worker strips entities (T13/T16 had only
  wired the controller side; far-tier was loading full entity layers,
  ~71K entities instead of ~10K, 5x perf regression).
- Bug B (0ad8c99): WalkEntities scratch list reused across frames
  (was 480 KB / frame allocation).

Tier 1 entity-classification cache attempted as polish (3639a6f),
reverted (9b49009) — broke animation by caching mutable per-frame
state. Retry deferred to post-A.5 polish phase (ISSUE #53).

Deferred to post-A.5 polish:
- Tier 1 retry with animation-mutation audit (ISSUE #53)
- Lifestone missing visual (ISSUE #52)
- JobKind plumbing through BuildLandblockForStreaming (ISSUE #54)
- Tier 2 (static/dynamic split) + Tier 3 (GPU compute cull) —
  separate multi-week phases. Roadmap at
  docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md.

SHIP commit: 9245db5.
This commit is contained in:
Erik 2026-05-10 10:09:03 +02:00
commit d3d78fa14f
37 changed files with 6001 additions and 281 deletions

View file

@ -83,7 +83,16 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5
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.
@ -97,13 +106,24 @@ public sealed class GameWindow : IDisposable
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker
// thread and the render thread both read dats (BuildLandblockForStreaming
// on the worker; ApplyLoadedTerrain + live-spawn handlers on the render
// thread). Concurrent reads corrupt internal caches and produce
// half-populated LandBlock.Height[] arrays, which caused terrain to render
// as "a giant ball with spikes" before this lock was added. All _dats.Get
// calls that can race with the worker thread MUST acquire this lock.
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
// the render thread (BuildLandblockForStreaming on the worker;
// ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render
// thread) corrupt that state and produce half-populated LandBlock.Height[]
// arrays, rendering as "a giant ball with spikes". All _dats.Get<T> call
// sites that can race with the streaming worker MUST hold this lock.
//
// Worker-thread dat reads enter via the factory closures passed to
// LandblockStreamer at construction (loadLandblock + buildMeshOrNull).
// Those closures already acquire _datLock, so no additional wrapping is
// needed for reads inside BuildLandblockForStreamingLocked /
// BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming.
// Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already
// hold this lock via their outer wrappers; all remaining render-thread
// _dats.Get calls run only when no worker dat read can be in flight (during
// initialization or within the same lock scope).
private readonly object _datLock = new();
// Terrain build context shared across all streamed landblocks. Stored as
@ -111,7 +131,9 @@ public sealed class GameWindow : IDisposable
// LandblockMesh.Build without re-deriving these each time.
private float[]? _heightTable;
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
private Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Was: Dictionary<uint, SurfaceInfo>. ConcurrentDictionary so the off-thread
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
@ -805,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<int>(1280, 720),
@ -815,6 +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
// 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);
@ -1078,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.
@ -1136,7 +1185,8 @@ public sealed class GameWindow : IDisposable
getNearestObjLabel: () => _lastNearestObjLabel,
getColliding: () => _lastColliding,
getDebugWireframes: () => _debugCollisionVisible,
getStreamingRadius: () => _streamingRadius,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () => GetActiveSensitivity(),
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () => _rmbHeld,
@ -1210,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)
{
@ -1436,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);
@ -1465,7 +1525,7 @@ public sealed class GameWindow : IDisposable
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
_surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// (Bindless detection moved above — must precede TerrainAtlas.Build /
// TerrainModernRenderer ctor so they can consume BindlessSupport.)
@ -1545,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)
@ -1562,27 +1624,57 @@ public sealed class GameWindow : IDisposable
// the player.
_particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats);
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window).
// Values outside [0, 8] fall back to the field default of 2.
var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8)
_streamingRadius = r;
Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})");
// 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;
// The streamer's load delegate wraps LandblockLoader.Load + stab
// hydration. Scenery + interior will land in Task 8.
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius.
{
var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(legacyEnv, out var sr) && sr >= 0)
{
_nearRadius = sr;
_streamingRadius = sr; // keep debug overlay in sync
_farRadius = System.Math.Max(sr, _farRadius);
}
}
Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" +
$" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})");
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock acquires _datLock (T10) before touching DatCollection.
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// it can call LandblockMesh.Build without a dat read — _heightTable and
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: id => BuildLandblockForStreaming(id));
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;
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
// _heightTable and _blendCtx are read-only after initialization.
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
});
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: _streamer.EnqueueLoad,
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
radius: _streamingRadius,
nearRadius: _nearRadius,
farRadius: _farRadius,
removeTerrain: id =>
{
// Phase G.2: release any LightSources attached to entities
@ -1599,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
@ -3895,7 +3989,7 @@ public sealed class GameWindow : IDisposable
// position by adding the residual back (so the visual doesn't jerk
// for one frame before the residual decay kicks in on the next tick).
System.Numerics.Vector3 preSnapPos = entity.Position;
entity.Position = worldPos;
entity.SetPosition(worldPos);
entity.Rotation = rot;
// Commit B 2026-04-29 — keep the shadow registry in sync with
@ -4015,11 +4109,11 @@ public sealed class GameWindow : IDisposable
if (!update.IsGrounded)
{
// Undo the unconditional entity hard-snap at the top of the
// function (entity.Position = worldPos): the body is mid-arc
// function (entity.SetPosition(worldPos)): the body is mid-arc
// and TickAnimations will write entity = body next frame
// anyway. Setting entity = body now prevents a 1-frame
// teleport-to-server-then-yank-back rubber-band.
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
return;
}
@ -4128,12 +4222,12 @@ public sealed class GameWindow : IDisposable
}
// Sync the visible entity to the body — overrides the unconditional
// entity.Position = worldPos snap at the top of this function.
// entity.SetPosition(worldPos) snap at the top of this function.
// For the far-snap branch this is a no-op (body == worldPos); for
// the near-enqueue branch this prevents a 1-frame teleport-then-
// yank-back rubber-band as TickAnimations chases worldPos via the
// queue.
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
return;
}
@ -4265,7 +4359,7 @@ public sealed class GameWindow : IDisposable
rmState.ServerVelocity);
}
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
}
@ -4310,7 +4404,7 @@ public sealed class GameWindow : IDisposable
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
// 3. Snap player entity + controller.
entity.Position = snappedPos;
entity.SetPosition(snappedPos);
entity.Rotation = rot;
_playerController.SetPosition(snappedPos, resolved.CellId);
@ -4970,24 +5064,26 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Phase A.1: render-thread callback from StreamingController.Tick
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
/// Mirrors the terrain-build + entity-upload part of the old preload.
/// Phase A.5 T12: the worker pre-builds <paramref name="meshData"/> off the
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread.
/// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb)
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
if (_terrain is null || _dats is null || _blendCtx is null
|| _heightTable is null || _surfaceCache is null) return;
if (_terrain is null || _dats is null) return;
// Phase A.1 hotfix: render-thread path also takes the dat lock so it
// doesn't race with BuildLandblockForStreaming on the worker thread.
// Hold the lock across the entire apply because we read dats below
// (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from
// LandblockMesh.Build.
// Hold the lock across the entity hydration below (GfxObj sub-mesh
// builds). The terrain mesh is pre-built by the worker (T12) and passed
// in via meshData, so LandblockMesh.Build no longer runs under this lock.
lock (_datLock)
{
ApplyLoadedTerrainLocked(lb);
ApplyLoadedTerrainLocked(lb, meshData);
}
}
@ -5097,10 +5193,12 @@ public sealed class GameWindow : IDisposable
_pendingCells.Add(loaded);
}
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb)
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
if (_terrain is null || _dats is null || _blendCtx is null
|| _heightTable is null || _surfaceCache is null) return;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return;
uint lbXu = (lb.LandblockId >> 24) & 0xFFu;
uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
@ -5111,11 +5209,10 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
// Build terrain mesh data on the render thread (pure CPU; acceptable
// for the MVP; a future pass can move it to the worker thread).
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
// two-tier entry point. Delegates to AddLandblock internally; both
// paths share one GPU upload path.
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
// Step 4: drain pending LoadedCells from the worker thread.
while (_pendingCells.TryTake(out var cell))
@ -5887,7 +5984,7 @@ public sealed class GameWindow : IDisposable
// the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.Position = result.RenderPosition;
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
@ -6291,6 +6388,28 @@ public sealed class GameWindow : IDisposable
Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
// A.5 T22: override fog ramp with N₁/N₂-derived distances so the
// horizon fog masks the N₁ scenery boundary. Sky keyframe fog is
// retail-accurate at normal view distances but far too short for
// the extended N₂=12 (25×25 LB) streaming window.
// FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12).
// FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults.
// Multipliers exposed as env vars for fast iteration at visual gate.
{
const float LandblockSize = 192.0f;
float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f);
float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f);
float fogStart = _nearRadius * LandblockSize * startMult;
float fogEnd = _farRadius * LandblockSize * endMult;
// Preserve fog color (xyz), lightning flash (z), and mode (w).
ubo.FogParams = new System.Numerics.Vector4(
fogStart,
fogEnd,
ubo.FogParams.Z, // lightning flash — unchanged
ubo.FogParams.W); // fog mode — unchanged
}
_sceneLightingUbo?.Upload(ubo);
// Never cull the landblock the player is currently on.
@ -6896,7 +7015,7 @@ public sealed class GameWindow : IDisposable
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation;
}
else
@ -7224,7 +7343,7 @@ public sealed class GameWindow : IDisposable
}
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation;
}
}
@ -7511,7 +7630,11 @@ public sealed class GameWindow : IDisposable
// we always want it animated in player mode.
if (!_animatedEntities.TryGetValue(pe.Id, out var ae))
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId);
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently. DatBinReader's
// shared buffer position would corrupt without serialization.
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock) { setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId); }
if (setup is null) return;
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
@ -7969,6 +8092,111 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: disposes the old
/// <see cref="_streamingController"/> + <see cref="_streamer"/>
/// and constructs new ones with the new radii.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
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}");
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
@ -8532,7 +8760,10 @@ public sealed class GameWindow : IDisposable
// 0.4 m fallbacks.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently.
DatReaderWriter.DBObjs.Setup? playerSetup;
lock (_datLock) { playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId); }
if (playerSetup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
@ -8765,8 +8996,12 @@ public sealed class GameWindow : IDisposable
long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples);
double cpuMedUs = cpuMedHundredthsUs / 100.0;
double cpuP95Us = cpuP95HundredthsUs / 100.0;
// A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const double TerrainBudgetUs = 1000.0;
string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[TERRAIN-DIAG] cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
$"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
$"draws={_terrain?.VisibleSlots ?? 0}/frame " +
$"visible={_terrain?.VisibleSlots ?? 0} " +
$"loaded={_terrain?.LoadedSlots ?? 0} " +
@ -8802,6 +9037,17 @@ public sealed class GameWindow : IDisposable
return copy[copy.Length - 1 - offset];
}
/// <summary>A.5 T22: parse a float environment variable, returning
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
private static float ParseEnvFloat(string name, float defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v))
return v;
return defaultValue;
}
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL

View file

@ -80,8 +80,11 @@ void main() {
vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
// Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) {
if (color.a < 0.95) discard; // opaque pass
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
} else {
if (color.a >= 0.95) discard; // transparent pass
if (color.a < 0.05) discard; // skip totally-empty

View file

@ -183,13 +183,17 @@ public sealed unsafe class TerrainAtlas : IDisposable
layerIdx++;
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}");
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
@ -411,6 +415,43 @@ public sealed unsafe class TerrainAtlas : IDisposable
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.ReapplyQualityPreset"/> 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.
/// </summary>
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.

View file

@ -35,6 +35,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
public TerrainAtlas Atlas => _atlas;
private readonly TerrainSlotAllocator _alloc;
// Per-slot live data (index by slot integer; null entries are unused slots).
@ -89,6 +93,18 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_indirectBuffer = _gl.GenBuffer();
}
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> built on the worker
/// thread, together with the world-space origin computed by the caller
/// (render-thread GameWindow derives it from landblockId + liveCenterX/Y).
///
/// Delegates to <see cref="AddLandblock(uint,LandblockMeshData,Vector3)"/>
/// so both paths share one upload path. Per Phase A.5 spec T15.
/// </summary>
public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
=> AddLandblock(landblockId, meshData, worldOrigin);
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
ArgumentNullException.ThrowIfNull(meshData);

View file

@ -128,6 +128,12 @@ public sealed class EntitySpawnAdapter
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null —

View file

@ -68,6 +68,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly BindlessSupport _bindless;
/// <summary>
/// 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 <see cref="GameWindow.ReapplyQualityPreset"/>.
/// </summary>
public bool AlphaToCoverage { get; set; } = true;
// SSBO buffer ids
private uint _instanceSsbo;
private uint _batchSsbo;
@ -100,6 +109,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new();
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
// of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled.
@ -157,9 +171,142 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
/// <summary>
/// Entry for <see cref="WalkEntities"/> per-landblock iteration.
/// Mirrors the shape yielded by <c>GpuWorldState.LandblockEntries</c>.
/// </summary>
public readonly record struct LandblockEntry(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById);
/// <summary>
/// Result of <see cref="WalkEntities"/> — the list of (entity, meshRef index)
/// pairs that passed all visibility filters, plus a diagnostic walk count.
/// </summary>
public struct WalkResult
{
public int EntitiesWalked;
public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
}
/// <summary>
/// Pure-CPU visibility filter over <paramref name="landblockEntries"/>.
/// Separated from <see cref="Draw"/> so tests can exercise it without GL state.
///
/// <para>
/// A.5 T17 Change #1: when an LB is frustum-culled AND
/// <paramref name="animatedEntityIds"/> is non-empty, the OLD path walked
/// every entity in the LB just to find the few animated ones. This helper
/// fixes that: if the LB is invisible, we iterate
/// <paramref name="animatedEntityIds"/> directly and look each up in
/// <c>entry.AnimatedById</c> (typically &lt;50 animated, up to ~10K total).
/// </para>
///
/// <para>
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
/// <see cref="WorldEntity.AabbMin"/>/<see cref="WorldEntity.AabbMax"/>
/// (refreshed lazily if <see cref="WorldEntity.AabbDirty"/>), instead of
/// recomputing Position±5 each frame.
/// </para>
/// </summary>
/// <summary>
/// Test-friendly overload that allocates a fresh ToDraw list per call.
/// Production code (<see cref="Draw"/>) uses the no-alloc overload below
/// with a caller-provided scratch list.
/// </summary>
internal static WalkResult WalkEntities(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
var scratch = new List<(WorldEntity Entity, int MeshRefIndex)>();
var result = new WalkResult { ToDraw = scratch };
WalkEntitiesInto(
landblockEntries, frustum, neverCullLandblockId,
visibleCellIds, animatedEntityIds, scratch, ref result);
return result;
}
/// <summary>
/// No-alloc overload: clears + populates the caller-provided <paramref name="scratch"/>
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
/// </summary>
internal static void WalkEntitiesInto(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex)> scratch,
ref WalkResult result)
{
scratch.Clear();
result.EntitiesWalked = 0;
result.ToDraw = scratch;
foreach (var entry in landblockEntries)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible)
{
// A.5 T17 Change #1: walk only animated entities, not all entities.
// Avoids O(N_entities) scan when only O(N_animated) work is needed.
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
if (entry.AnimatedById is null) continue;
foreach (var animatedId in animatedEntityIds)
{
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i));
}
continue;
}
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
continue;
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i));
}
}
}
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
@ -194,97 +341,85 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
foreach (var entry in landblockEntries)
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
static IEnumerable<LandblockEntry> ToEntries(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> src)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
foreach (var e in src)
yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
}
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
var walkResult = default(WalkResult);
WalkEntitiesInto(
ToEntries(landblockEntries),
frustum,
neverCullLandblockId,
visibleCellIds,
animatedEntityIds,
_walkScratch,
ref walkResult);
foreach (var entity in entry.Entities)
foreach (var (entity, partIdx) in _walkScratch)
{
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the
// entity has no palette override (trees, scenery).
ulong palHash = 0;
if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
var meshRef = entity.MeshRefs[partIdx];
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (entity.MeshRefs.Count == 0) continue;
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
// Per-entity AABB frustum cull (perf #3). Skips work for distant
// entities even when their landblock is visible. Animated
// entities bypass — they're tracked at landblock level + need
// per-frame work for animation regardless. Conservative 5m
// radius covers typical entity bounds.
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
var p = entity.Position;
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
continue;
}
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the
// entity has no palette override (trees, scenery).
ulong palHash = 0;
if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
bool drewAny = false;
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
{
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
var meshRef = entity.MeshRefs[partIdx];
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (diag) _meshesMissing++;
continue;
}
drewAny = true;
if (anyVao == 0) anyVao = renderData.VAO;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
if (diag && drewAny) _entitiesDrawn++;
if (diag) _meshesMissing++;
continue;
}
if (anyVao == 0) anyVao = renderData.VAO;
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
drewAny = true;
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
drewAny = true;
}
if (diag && drewAny) _entitiesDrawn++;
}
// Nothing visible — skip the GL pass entirely.
@ -402,6 +537,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
// 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.
// 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);
@ -412,6 +553,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
(uint)_opaqueDrawCount,
(uint)DrawCommandStride);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
}
// ── Phase 8: transparent pass ────────────────────────────────────────
@ -492,8 +634,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
long cpuP95 = Percentile95Micros(_cpuSamples);
long gpuMed = MedianMicros(_gpuSamples);
long gpuP95 = Percentile95Micros(_gpuSamples);
// A.5 T23: flag when entity dispatcher median exceeds 2.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const long BudgetUs = 2000;
string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
_lastLogTick = now;

View file

@ -106,17 +106,33 @@ public sealed class GpuWorldState
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible.
///
/// <para>
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary built on the fly
/// from the landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
/// skip the full entity walk when the landblock is frustum-culled but animated
/// entities inside it must still be processed (Change #1).
/// Building the dict per-yield is cheap (~132 entities/LB max). A caching
/// layer is out of A.5 scope.
/// </para>
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}
}
@ -339,6 +355,73 @@ public sealed class GpuWorldState
bucket.Add(entity);
}
/// <summary>
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier
/// only (procedural scenery, dat-static stabs/buildings) — they never
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
/// The local player and other live server-spawned entities live in their
/// landblock via <c>RelocateEntity</c> per frame and are not affected
/// by Near→Far demotion of dat-static landblock layers.
/// </para>
/// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
// protects against future callers that mirror AppendLiveEntity's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
RebuildFlatView();
}
/// <summary>
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
RebuildFlatView();
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();

View file

@ -1,3 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -10,7 +12,7 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
}
@ -22,7 +24,29 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
{
public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId);
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
/// is built off the render thread on the streaming worker.
/// </summary>
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LoadedLandblock Landblock,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId);
/// <summary>
/// A previously-Far-resident landblock was promoted to Near. Terrain
/// mesh is already on the GPU; the result carries the entity layer
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
/// entry.
/// </summary>
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities
) : LandblockStreamResult(LandblockId);
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);

View file

@ -0,0 +1,28 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// </summary>
public enum LandblockStreamTier
{
Far,
Near,
}
/// <summary>
/// What work the streaming worker should perform for a given job. Distinct
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
/// reads only the entity layer (terrain mesh already loaded), while
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// </summary>
public enum LandblockStreamJobKind
{
/// <summary>Read LandBlock heightmap, build mesh, no entity layer.</summary>
LoadFar,
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
LoadNear,
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
PromoteToNear,
}

View file

@ -8,28 +8,27 @@ using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Services landblock load/unload requests by invoking a caller-supplied
/// load delegate (the production instance wraps
/// <see cref="LandblockLoader.Load"/>) and posting results to an outbox
/// the render thread drains once per OnUpdate.
/// Services landblock load/unload requests by invoking caller-supplied
/// factory delegates (the production instance wraps
/// <see cref="LandblockLoader.Load"/> for loading and
/// <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/> for the terrain
/// mesh) and posting results to an outbox the render thread drains once
/// per OnUpdate.
///
/// <para>
/// <b>Currently runs synchronously on the calling thread.</b> The original
/// Phase A.1 design ran loads on a dedicated worker thread, but DatReaderWriter's
/// <c>DatCollection</c> is not thread-safe — concurrent reads from a worker
/// and the render thread (animation tick, live spawn handlers) corrupt
/// internal buffer state and produce half-populated <c>LandBlock.Height[]</c>
/// arrays which render as wildly distorted terrain. Until Phase A.3 introduces
/// a thread-safe dat wrapper, loads are synchronous: <see cref="EnqueueLoad"/>
/// invokes the load delegate inline and writes the result to the outbox in
/// a single call. This causes a frame hitch when crossing landblock
/// boundaries, but the rendering is correct.
/// <b>Thread model (Phase A.5 T11+):</b> <see cref="Start"/> spawns a
/// dedicated background worker thread. <see cref="EnqueueLoad"/> and
/// <see cref="EnqueueUnload"/> write non-blocking to the inbox
/// <see cref="Channel{T}"/>; the worker drains it and posts
/// <see cref="LandblockStreamResult"/> records to the outbox.
/// </para>
///
/// <para>
/// The Channel-based outbox + <see cref="DrainCompletions"/> API is
/// preserved so the move back to async loading is a single-class change
/// when DatCollection thread safety lands.
/// <b>DatCollection thread safety</b> is provided by the caller:
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
/// <c>DatCollection.Get&lt;T&gt;</c> calls. Both factory closures passed at
/// construction acquire that lock before reading dats. The worker never
/// touches <c>DatCollection</c> directly — it only calls the factories.
/// </para>
///
/// <para>
@ -39,8 +38,9 @@ namespace AcDream.App.Streaming;
/// </para>
///
/// <remarks>
/// Threading: synchronous mode means all methods must be called from the
/// same thread (the render thread in production).
/// Threading: <see cref="DrainCompletions"/> must be called from a single
/// consumer thread (the render thread in production). All other public
/// methods are thread-safe.
/// </remarks>
/// </summary>
public sealed class LandblockStreamer : IDisposable
@ -53,49 +53,72 @@ public sealed class LandblockStreamer : IDisposable
public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LoadedLandblock?> _loadLandblock;
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new();
#pragma warning disable CS0649 // _worker stays declared for the future async path; unused in synchronous mode.
private Thread? _worker;
#pragma warning restore CS0649
private int _disposed;
public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock)
public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
{
_loadLandblock = loadLandblock;
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
/// <summary>
/// No-op in synchronous mode. Preserved on the API surface so callers
/// don't need to change when async loading returns in Phase A.3.
/// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent
/// calls are no-ops. Atomic via <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>.
/// </summary>
public void Start()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// No worker thread in synchronous mode.
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
// Start() callers don't both pass the null check and spawn duplicate
// threads. Construct the candidate; CAS it into _worker; if we lost
// the race, the candidate goes unstarted and is GCed.
var candidate = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
candidate.Start();
// else: another caller won the race; their thread is running.
}
public void EnqueueLoad(uint landblockId)
/// <summary>
/// Non-blocking enqueue. The worker drains the inbox and posts a
/// <see cref="LandblockStreamResult.Loaded"/> (or
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
/// </summary>
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// Synchronous mode: invoke the load delegate inline. The result lands
// in the outbox and DrainCompletions picks it up later in the same
// (or next) frame.
HandleJob(new LandblockStreamJob.Load(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
}
/// <summary>
/// Non-blocking enqueue. The worker posts a
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
/// </summary>
public void EnqueueUnload(uint landblockId)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
HandleJob(new LandblockStreamJob.Unload(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
@ -118,17 +141,14 @@ public sealed class LandblockStreamer : IDisposable
{
try
{
// Synchronous read loop via .WaitToReadAsync + ReadAllAsync
// would be idiomatic but requires async; the blocking reader
// is simpler and the thread is dedicated anyway.
// Safe to block: this is a dedicated worker thread with no
// SynchronizationContext, so .Result/.GetResult cannot deadlock
// against any captured continuation. Using the sync pattern
// here keeps the loop linear; an async-enumerable alternative
// would force WorkerLoop to be async Task and lose the
// simple thread-start shape.
while (!_cancel.Token.IsCancellationRequested)
{
// Safe to block: this is a dedicated worker thread with no
// SynchronizationContext, so .Result/.GetResult cannot deadlock
// against any captured continuation. Using the sync pattern
// here keeps the loop linear; an async-enumerable alternative
// would force WorkerLoop to be async Task and lose the
// simple thread-start shape.
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
break;
@ -157,15 +177,47 @@ public sealed class LandblockStreamer : IDisposable
switch (job)
{
case LandblockStreamJob.Load load:
// A.5 T26 follow-up (Bug A): far-tier LBs must NOT contribute
// entities to GpuWorldState — that defeats the whole purpose of
// the two-tier split. The factory still builds the full entity
// layer (LandblockLoader + scenery generation + interior cells)
// regardless of Kind because it doesn't know about JobKind today.
// We strip Entities here for far-tier results so the render-
// thread dispatcher walks only near-tier (~10K) entities, not
// all (~71K) entities at radius=12.
//
// Wasted worker-thread CPU is acceptable (it's off the render
// thread). A future optimization (TODO N.6 or A.6) plumbs Kind
// through BuildLandblockForStreaming so the dat read + scenery
// generation are skipped entirely for far-tier.
try
{
var lb = _loadLandblock(load.LandblockId);
if (lb is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
else
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, lb));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
break;
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
? LandblockStreamTier.Far : LandblockStreamTier.Near;
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
{
// Strip entities — far-tier ships terrain only.
lb = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>());
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh));
}
catch (Exception ex)
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -16,15 +17,33 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint> _enqueueLoad;
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock> _applyTerrain;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
public int Radius { get; set; }
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — the
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
/// </remarks>
public int NearRadius { get; }
/// <summary>
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
/// </remarks>
public int FarRadius { get; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
@ -45,12 +64,13 @@ public sealed class StreamingController
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action<uint> enqueueLoad,
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock> applyTerrain,
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
GpuWorldState state,
int radius,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null)
{
_enqueueLoad = enqueueLoad;
@ -59,29 +79,42 @@ public sealed class StreamingController
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_state = state;
Radius = radius;
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
///
/// <para>Two-tier model (Phase A.5 T13):</para>
/// <list type="bullet">
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy)
{
// First-tick bootstrap: no region yet, so the whole visible window
// is a load diff.
if (_region is null)
{
_region = new StreamingRegion(observerCx, observerCy, Radius);
foreach (var id in _region.Visible)
_enqueueLoad(id);
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
_region.MarkResidentFromBootstrap();
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToLoad) _enqueueLoad(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
// Drain up to N completions per frame so a big diff doesn't spike
@ -92,9 +125,12 @@ public sealed class StreamingController
switch (result)
{
case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock);
_applyTerrain(loaded.Landblock, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
break;
case LandblockStreamResult.Promoted promoted:
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.Streaming;
@ -10,9 +11,11 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
public int NearRadius { get; }
public int FarRadius { get; }
// Strictly the (2r+1)×(2r+1) window (clamped to world bounds).
private readonly HashSet<uint> _visible = new();
@ -20,6 +23,16 @@ public sealed class StreamingRegion
// Everything currently loaded: window + hysteresis-retained landblocks.
private readonly HashSet<uint> _resident = new();
// Two-tier residence tracking: maps each resident LB to its current tier.
private readonly Dictionary<uint, TierResidence> _tierResidence = new();
// Set true after MarkResidentFromBootstrap. The two-tier RecenterTo
// requires this state to be seeded; calling RecenterTo before the
// bootstrap silently emits the entire window as fresh loads (no demotes,
// no unloads, since _tierResidence is empty), which is a correctness
// hazard. The flag converts that into a loud InvalidOperationException.
private bool _bootstrapped;
/// <summary>
/// Landblock IDs in the current visible window in the AC 8.8 coordinate
/// form: <c>(lbX &lt;&lt; 24) | (lbY &lt;&lt; 16) | 0xFFFF</c>. The trailing
@ -43,12 +56,16 @@ public sealed class StreamingRegion
/// </summary>
public IReadOnlyCollection<uint> Resident => _resident;
public StreamingRegion(int cx, int cy, int radius)
public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius)
{
Radius = radius;
Recenter(cx, cy);
NearRadius = nearRadius;
FarRadius = farRadius;
Radius = farRadius; // outer ring drives Resident bookkeeping
Recenter(centerX, centerY);
}
public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { }
private void Recenter(int cx, int cy)
{
CenterX = cx;
@ -81,13 +98,197 @@ public sealed class StreamingRegion
internal static uint EncodeLandblockId(int lbX, int lbY)
=> ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFFu;
/// <summary>
/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring,
/// ToLoadFar for every LB in the outer ring (between near and far). Used
/// by <see cref="StreamingController.Tick"/> on the first call before any
/// RecenterTo.
/// </summary>
public TwoTierDiff ComputeFirstTickDiff()
{
var near = new List<uint>();
var far = new List<uint>();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = System.Math.Abs(dx);
int absDy = System.Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
if (absDx <= NearRadius && absDy <= NearRadius)
near.Add(id);
else
far.Add(id);
}
}
return new TwoTierDiff(
ToLoadFar: far,
ToLoadNear: near,
ToPromote: System.Array.Empty<uint>(),
ToDemote: System.Array.Empty<uint>(),
ToUnload: System.Array.Empty<uint>());
}
/// <summary>
/// Call once after <see cref="ComputeFirstTickDiff"/> to seed
/// <c>_tierResidence</c> with the initial window. Every LB in the inner
/// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far.
/// </summary>
public void MarkResidentFromBootstrap()
{
if (_bootstrapped)
throw new InvalidOperationException(
"MarkResidentFromBootstrap was already called; calling it again would " +
"reset accumulated tier-residence state and silently drop differential " +
"data built up by interim RecenterTo calls.");
_tierResidence.Clear();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
_tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
? TierResidence.Near
: TierResidence.Far;
}
}
_bootstrapped = true;
}
/// <summary>
/// Test-visible wrapper around <see cref="EncodeLandblockId"/> so test
/// assemblies can build expected IDs without duplicating the encoding rule.
/// </summary>
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
=> EncodeLandblockId(lbX, lbY);
/// <summary>
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
/// unload. Requires <see cref="MarkResidentFromBootstrap"/> (or a prior
/// call to this method) to have seeded <c>_tierResidence</c>.
/// </summary>
public TwoTierDiff RecenterTo(int newCx, int newCy)
{
if (!_bootstrapped)
throw new InvalidOperationException(
"Two-tier RecenterTo called before MarkResidentFromBootstrap. " +
"First call ComputeFirstTickDiff to enqueue the bootstrap loads, " +
"then MarkResidentFromBootstrap to seed _tierResidence, then RecenterTo " +
"for subsequent observer moves.");
int nearUnloadThreshold = NearRadius + 2;
int farUnloadThreshold = FarRadius + 2;
var toLoadFar = new List<uint>();
var toLoadNear = new List<uint>();
var toPromote = new List<uint>();
var toDemote = new List<uint>();
var toUnload = new List<uint>();
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet<uint>();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = newCx + dx;
int ny = newCy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
bool inNear = absDx <= NearRadius && absDy <= NearRadius;
var id = EncodeLandblockId(nx, ny);
newCenterIds.Add(id);
if (!_tierResidence.TryGetValue(id, out var current))
{
// Not resident at all — fresh load.
if (inNear) toLoadNear.Add(id);
else toLoadFar.Add(id);
_tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
}
else if (current == TierResidence.Far && inNear)
{
// Was Far, now inside Near ring — promote.
toPromote.Add(id);
_tierResidence[id] = TierResidence.Near;
}
// Near→Near and Far→Far are no-ops.
}
}
// Pass 2: check previously-resident LBs for demote / unload.
foreach (var kvp in _tierResidence.ToArray())
{
var id = kvp.Key;
var current = kvp.Value;
int lbX = (int)((id >> 24) & 0xFFu);
int lbY = (int)((id >> 16) & 0xFFu);
int absDx = Math.Abs(lbX - newCx);
int absDy = Math.Abs(lbY - newCy);
int distance = Math.Max(absDx, absDy); // Chebyshev
if (newCenterIds.Contains(id))
{
// Still in the far window — only Near→Far demote possible here.
if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
}
}
continue;
}
// Outside the new window — demote / unload by threshold.
if (current == TierResidence.Near)
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
else if (current == TierResidence.Far)
{
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
CenterX = newCx;
CenterY = newCy;
return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload);
}
/// <summary>
/// Recompute the visible window around a new center and return the
/// delta vs. the previous state. Hysteresis: landblocks aren't unloaded
/// until they're further than <c>Radius + 2</c> from the new center,
/// so boundary crossings don't thrash.
/// </summary>
public RegionDiff RecenterTo(int newCx, int newCy)
public RegionDiff RecenterToSingleTier(int newCx, int newCy)
{
// Snapshot the old resident set so we can diff against it.
var oldResident = new HashSet<uint>(_resident);
@ -126,7 +327,7 @@ public sealed class StreamingRegion
}
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/>: the landblocks to
/// Output of <see cref="StreamingRegion.RecenterToSingleTier"/>: the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is <c>Radius + 2</c>).
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
@ -135,3 +336,10 @@ public sealed class StreamingRegion
public readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);
/// <summary>
/// Tracks which tier a landblock currently occupies in the two-tier streaming
/// model. Absence from the dictionary encodes "not resident"; the enum has no
/// None member to avoid suggesting a third runtime state.
/// </summary>
internal enum TierResidence { Far, Near }

View file

@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/> for the two-tier model.
/// Five disjoint lists describe what changed since the previous Tick. Per
/// Phase A.5 spec §4.2.
/// </summary>
public readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (entities only)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)

View file

@ -46,7 +46,7 @@ public static class LandblockMesh
uint landblockY,
float[] heightTable,
TerrainBlendingContext ctx,
Dictionary<uint, SurfaceInfo> surfaceCache)
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache)
{
ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable);
@ -105,6 +105,10 @@ public static class LandblockMesh
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
// Lookup-or-build pattern. Not atomic under concurrent access
// (TryGetValue then assign), but BuildSurface is deterministic —
// two workers building the same palCode produce equal SurfaceInfo,
// last-write-wins is benign.
if (!surfaceCache.TryGetValue(palCode, out var surf))
{
surf = TerrainBlending.BuildSurface(palCode, ctx);

View file

@ -42,28 +42,32 @@ public static class LandblockLoader
{
if (!IsSupported(stab.Id))
continue;
result.Add(new WorldEntity
var stabEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
};
stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(stabEntity);
}
foreach (var building in info.Buildings)
{
if (!IsSupported(building.ModelId))
continue;
result.Add(new WorldEntity
var buildingEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
};
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(buildingEntity);
}
return result;

View file

@ -71,6 +71,30 @@ public sealed class WorldEntity
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the
// dispatcher's frustum cull is a memory read, not a per-frame recompute.
// AabbDirty starts true so the dispatcher calls RefreshAabb on first read
// (AabbMin/AabbMax are Vector3.Zero until refreshed).
public Vector3 AabbMin { get; private set; }
public Vector3 AabbMax { get; private set; }
public bool AabbDirty { get; private set; } = true;
private const float DefaultAabbRadius = 5.0f;
public void RefreshAabb()
{
var p = Position;
AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius);
AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius);
AabbDirty = false;
}
public void SetPosition(Vector3 pos)
{
Position = pos;
AabbDirty = true;
}
}
/// <summary>

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
@ -20,7 +21,8 @@ public sealed record DisplaySettings(
bool VSync,
float FieldOfView,
float Gamma,
bool ShowFps)
bool ShowFps,
QualityPreset Quality)
{
/// <summary>Values used on first launch / when settings.json is absent.
/// All defaults pinned to the pre-L.0 runtime state — Resolution
@ -35,7 +37,8 @@ public sealed record DisplaySettings(
VSync: false,
FieldOfView: 60f,
Gamma: 1.0f,
ShowFps: true);
ShowFps: true,
Quality: QualityPreset.High);
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]

View file

@ -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.");
}
/// <summary>
@ -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.

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
@ -62,12 +63,13 @@ public sealed class SettingsStore
var d = DisplaySettings.Default;
return new DisplaySettings(
Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps));
Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality));
}
catch (Exception ex)
{
@ -327,6 +329,7 @@ public sealed class SettingsStore
["fieldOfView"] = d.FieldOfView,
["fullscreen"] = d.Fullscreen,
["gamma"] = d.Gamma,
["quality"] = d.Quality.ToString(),
["resolution"] = d.Resolution,
["showFps"] = d.ShowFps,
["vsync"] = d.VSync,
@ -405,4 +408,12 @@ public sealed class SettingsStore
private static float ReadFloat(JsonElement obj, string name, float fallback)
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetSingle() : fallback;
private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
return fallback;
var s = el.GetString();
return Enum.TryParse<QualityPreset>(s, ignoreCase: true, out var v) ? v : fallback;
}
}

View file

@ -0,0 +1,67 @@
namespace AcDream.UI.Abstractions.Settings;
/// <summary>
/// A.5 T22.5: single user-facing quality knob that drives streaming radii,
/// MSAA samples, anisotropic level, alpha-to-coverage, and max completions
/// per frame in a single setting. Individual fields can still be overridden
/// by env vars (see <see cref="QualitySettings.WithEnvOverrides"/>).
/// </summary>
public enum QualityPreset { Low, Medium, High, Ultra }
/// <summary>
/// Resolved per-preset quality parameters. Constructed via
/// <see cref="From(QualityPreset)"/> then optionally overridden with
/// <see cref="WithEnvOverrides(QualitySettings)"/> before applying to the
/// renderer and streaming controller.
/// </summary>
public readonly record struct QualitySettings(
int NearRadius,
int FarRadius,
int MsaaSamples, // 0 = off, 2, 4, 8
int AnisotropicLevel, // 1 = off, 4, 8, 16
bool AlphaToCoverage,
int MaxCompletionsPerFrame)
{
/// <summary>
/// Return the default <see cref="QualitySettings"/> for <paramref name="preset"/>.
/// Unknown enum values fall back to <see cref="QualityPreset.High"/>.
/// </summary>
public static QualitySettings From(QualityPreset preset) => preset switch
{
QualityPreset.Low => new(NearRadius: 2, FarRadius: 5, MsaaSamples: 0, AnisotropicLevel: 4, AlphaToCoverage: false, MaxCompletionsPerFrame: 2),
QualityPreset.Medium => new(NearRadius: 3, FarRadius: 8, MsaaSamples: 2, AnisotropicLevel: 8, AlphaToCoverage: false, MaxCompletionsPerFrame: 3),
QualityPreset.High => new(NearRadius: 4, FarRadius: 12, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 4),
QualityPreset.Ultra => new(NearRadius: 5, FarRadius: 15, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 6),
_ => From(QualityPreset.High),
};
/// <summary>
/// Apply env-var overrides to a preset's resolved settings. Per-field
/// env vars beat the preset (so devs can spot-test a single dimension).
/// Unset or empty env vars leave the preset default unchanged.
/// </summary>
public static QualitySettings WithEnvOverrides(QualitySettings baseSettings)
{
int nearRadius = TryParseEnvInt("ACDREAM_NEAR_RADIUS", baseSettings.NearRadius);
int farRadius = TryParseEnvInt("ACDREAM_FAR_RADIUS", baseSettings.FarRadius);
int msaa = TryParseEnvInt("ACDREAM_MSAA_SAMPLES", baseSettings.MsaaSamples);
int aniso = TryParseEnvInt("ACDREAM_ANISOTROPIC", baseSettings.AnisotropicLevel);
// Bool override: any non-empty value other than "0"/"false" enables A2C.
// Empty / unset → keep preset default.
var a2cEnv = System.Environment.GetEnvironmentVariable("ACDREAM_A2C");
bool a2c = a2cEnv switch
{
null or "" => baseSettings.AlphaToCoverage,
"0" or "false" or "False" or "FALSE" => false,
_ => true,
};
int completions = TryParseEnvInt("ACDREAM_MAX_COMPLETIONS_PER_FRAME", baseSettings.MaxCompletionsPerFrame);
return new QualitySettings(nearRadius, farRadius, msaa, aniso, a2c, completions);
}
private static int TryParseEnvInt(string name, int defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
return s is not null && int.TryParse(s, out var v) ? v : defaultValue;
}
}