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:
commit
d3d78fa14f
37 changed files with 6001 additions and 281 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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 <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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
28
src/AcDream.App/Streaming/LandblockStreamTier.cs
Normal file
28
src/AcDream.App/Streaming/LandblockStreamTier.cs
Normal 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,
|
||||
}
|
||||
|
|
@ -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<T></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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 << 24) | (lbY << 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 }
|
||||
|
|
|
|||
15
src/AcDream.App/Streaming/TwoTierDiff.cs
Normal file
15
src/AcDream.App/Streaming/TwoTierDiff.cs
Normal 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)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
src/AcDream.UI.Abstractions/Settings/QualityPreset.cs
Normal file
67
src/AcDream.UI.Abstractions/Settings/QualityPreset.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue