refactor(render): extract world scene frame owner
Move fallback and PView world drawing, shared alpha, particles, visibility, selection, and diagnostics behind focused frame owners. Make exceptional frames failure-atomic by restoring the shared GL baseline and preserving primary alpha failures through cleanup. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
85239fb373
commit
28e1cf8029
25 changed files with 2679 additions and 844 deletions
|
|
@ -82,7 +82,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
|
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
|
||||||
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
|
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
|
||||||
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
|
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
|
||||||
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/WorldSceneRenderer.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
||||||
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
||||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ port, or resource-lifetime redesign.
|
||||||
run the dependent audio, sky, lighting, and listener preparation.
|
run the dependent audio, sky, lighting, and listener preparation.
|
||||||
- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed
|
- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed
|
||||||
`RetailPViewPassExecutor` and data-only inputs.
|
`RetailPViewPassExecutor` and data-only inputs.
|
||||||
- [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
|
- [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
|
||||||
PView/fallback world branches, particles, debug world draw, and completion.
|
PView/fallback world branches, particles, debug world draw, and completion.
|
||||||
- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
||||||
handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
|
handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
|
||||||
|
|
@ -451,6 +451,22 @@ corrected-diff reviews are clean.
|
||||||
- return a small `WorldRenderFrameOutcome` for diagnostics/presentation;
|
- return a small `WorldRenderFrameOutcome` for diagnostics/presentation;
|
||||||
- delete the world draw body and helper closure from `GameWindow`.
|
- delete the world draw body and helper closure from `GameWindow`.
|
||||||
|
|
||||||
|
Completed 2026-07-22. `WorldSceneRenderer` now owns the normal-world
|
||||||
|
transaction, one shared-alpha frame, retail selection accumulation, PView versus
|
||||||
|
null-root fallback branching, post-world particles/weather, visibility
|
||||||
|
publication, and world diagnostics. `WorldScenePassExecutor`, focused runtime
|
||||||
|
sources, and `WorldSceneDiagnosticsController` carry the concrete GL and data
|
||||||
|
seams without retaining `GameWindow`, delegates, or borrowed PView products.
|
||||||
|
Failed frames abort PView, pass routing, particle visibility, alpha, and
|
||||||
|
selection in reverse ownership order; one shared `RenderFrameGlStateController`
|
||||||
|
restores scissor/stencil/blend/clip/mask/depth/cull/program/buffer state before
|
||||||
|
the next clear. Normal portal-depth exits now restore the same cull-off
|
||||||
|
convention, and alpha cleanup preserves the original draw failure while
|
||||||
|
attempting every source reset. `GameWindow.cs` is 4,765 raw lines, 69.7% below
|
||||||
|
the 15,723-line campaign baseline. Release build and the complete suite pass
|
||||||
|
(7,313 passed / 5 skipped). Retail, architecture, and adversarial
|
||||||
|
corrected-diff reviews are clean.
|
||||||
|
|
||||||
### G — final orchestration cutover
|
### G — final orchestration cutover
|
||||||
|
|
||||||
- compose concrete resource, world, private-presentation, and diagnostic owners;
|
- compose concrete resource, world, private-presentation, and diagnostic owners;
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// wireframes are noisy outdoors and confuse first-time users into
|
// wireframes are noisy outdoors and confuse first-time users into
|
||||||
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
|
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
|
||||||
// → Diagnostics → "Toggle collision wires" button toggles too.
|
// → Diagnostics → "Toggle collision wires" button toggles too.
|
||||||
private bool _debugCollisionVisible = false;
|
private readonly AcDream.App.Rendering.WorldSceneDebugState
|
||||||
private int _debugDrawLogOnce = 0;
|
_worldSceneDebugState = new();
|
||||||
|
|
||||||
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
|
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
|
||||||
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
|
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
|
||||||
|
|
@ -107,6 +107,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_renderWeatherFrame;
|
_renderWeatherFrame;
|
||||||
private AcDream.App.Rendering.WorldRenderFrameBuilder?
|
private AcDream.App.Rendering.WorldRenderFrameBuilder?
|
||||||
_worldRenderFrameBuilder;
|
_worldRenderFrameBuilder;
|
||||||
|
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
|
||||||
|
private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer;
|
||||||
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
|
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private readonly DisplayFramePacingController _displayFramePacing;
|
private readonly DisplayFramePacingController _displayFramePacing;
|
||||||
|
|
@ -219,9 +221,8 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
|
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
|
||||||
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
|
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
|
||||||
|
|
||||||
// R1 (render redesign): the per-cell DrawInside flood + its per-frame entity partition.
|
// R1 (render redesign): the per-cell DrawInside flood and concrete pass
|
||||||
// _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt
|
// executor are borrowed by WorldSceneRenderer for each normal-world frame.
|
||||||
// each frame on an indoor root (null on the outdoor root).
|
|
||||||
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
||||||
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
|
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
|
||||||
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
|
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
|
||||||
|
|
@ -229,7 +230,6 @@ public sealed class GameWindow : IDisposable
|
||||||
_terrainDrawDiagnostics;
|
_terrainDrawDiagnostics;
|
||||||
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
||||||
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
|
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
|
||||||
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
|
|
||||||
|
|
||||||
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
|
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
|
||||||
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
|
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
|
||||||
|
|
@ -240,11 +240,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
|
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
|
||||||
private ClipFrame? _clipFrame;
|
private ClipFrame? _clipFrame;
|
||||||
|
|
||||||
// Flat-world fallback particle scratch. PView classifications belong to
|
|
||||||
// RetailPViewPassExecutor.
|
|
||||||
private readonly HashSet<uint> _visibleSceneParticleEntityIds = [];
|
|
||||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.4: per-entity animation playback state for entities whose
|
/// Phase 6.4: per-entity animation playback state for entities whose
|
||||||
/// MotionTable resolved to a real cycle. The render loop ticks each
|
/// MotionTable resolved to a real cycle. The render loop ticks each
|
||||||
|
|
@ -470,7 +465,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// unconditionally installs a provider. See r12 §11 for the roller
|
// unconditionally installs a provider. See r12 §11 for the roller
|
||||||
// semantics.
|
// semantics.
|
||||||
private long _loadedSkyDayIndex = long.MinValue;
|
private long _loadedSkyDayIndex = long.MinValue;
|
||||||
private AcDream.Core.World.DayGroupData? _activeDayGroup;
|
|
||||||
|
|
||||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
||||||
// range of the real values so the first press hits index 0 of the
|
// range of the real values so the first press hits index 0 of the
|
||||||
|
|
@ -589,31 +583,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// single-flag reads instead of env-var lookups. True iff
|
// single-flag reads instead of env-var lookups. True iff
|
||||||
// ACDREAM_LIVE=1 was set when the window came up.
|
// ACDREAM_LIVE=1 was set when the window came up.
|
||||||
// Backed by RuntimeOptions.LiveMode via the _options field.
|
// Backed by RuntimeOptions.LiveMode via the _options field.
|
||||||
private bool LiveModeEnabled => _options.LiveMode;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// K-fix1 (2026-04-26): true iff live mode is configured AND we have
|
|
||||||
/// NOT yet handed control to the chase camera. Gates initial
|
|
||||||
/// streaming + scene rendering so the user never sees Holtburg flash
|
|
||||||
/// before login completes — the screen stays at the sky/fog clear
|
|
||||||
/// color until the player entity has spawned + the auto-entry guard
|
|
||||||
/// has triggered the player-mode controller's auto-entry edge.
|
|
||||||
/// Offline (LiveModeEnabled false) returns false → unchanged
|
|
||||||
/// orbit-camera Holtburg view stays the foreground. Once chase mode
|
|
||||||
/// is active the property latches false for the rest of the
|
|
||||||
/// session, even if the user later toggles into fly mode (we don't
|
|
||||||
/// want to re-blank the world after they've seen it).
|
|
||||||
/// </summary>
|
|
||||||
private bool IsLiveModeWaitingForLogin =>
|
|
||||||
LiveModeEnabled
|
|
||||||
&& !_chaseModeEverEntered;
|
|
||||||
|
|
||||||
// Latches true the first time chase mode becomes active. Used by
|
|
||||||
// IsLiveModeWaitingForLogin to suppress the pre-login render gate
|
|
||||||
// for subsequent fly-mode toggles.
|
|
||||||
private bool _chaseModeEverEntered
|
|
||||||
=> _localPlayerMode.ChaseModeEverEntered;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
|
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
|
||||||
/// UpdateMotion and UpdatePosition handlers can find the entity the
|
/// UpdateMotion and UpdatePosition handlers can find the entity the
|
||||||
|
|
@ -769,6 +738,8 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void OnLoad()
|
private void OnLoad()
|
||||||
{
|
{
|
||||||
|
_worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState(
|
||||||
|
WorldTime);
|
||||||
// Task 7: wire the physics data cache into the engine so Transition can
|
// Task 7: wire the physics data cache into the engine so Transition can
|
||||||
// run narrow-phase BSP tests during FindObjCollisions.
|
// run narrow-phase BSP tests during FindObjCollisions.
|
||||||
_physicsEngine.DataCache = _physicsDataCache;
|
_physicsEngine.DataCache = _physicsDataCache;
|
||||||
|
|
@ -1115,7 +1086,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
|
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
|
||||||
getColliding: () =>
|
getColliding: () =>
|
||||||
_debugVmRenderFacts.DebugVmFacts.Colliding,
|
_debugVmRenderFacts.DebugVmFacts.Colliding,
|
||||||
getDebugWireframes: () => _debugCollisionVisible,
|
getDebugWireframes: () =>
|
||||||
|
_worldSceneDebugState.CollisionWireframesVisible,
|
||||||
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
|
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
|
||||||
|
|
||||||
getMouseSensitivity: () => GetActiveSensitivity(),
|
getMouseSensitivity: () => GetActiveSensitivity(),
|
||||||
|
|
@ -2805,15 +2777,18 @@ public sealed class GameWindow : IDisposable
|
||||||
var teleportRenderState =
|
var teleportRenderState =
|
||||||
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
||||||
_localPlayerTeleport);
|
_localPlayerTeleport);
|
||||||
|
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||||
|
_options.LiveMode,
|
||||||
|
_localPlayerMode);
|
||||||
|
var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController(
|
||||||
|
new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!));
|
||||||
_renderFrameLivePreparation =
|
_renderFrameLivePreparation =
|
||||||
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
|
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
|
||||||
_textureCache,
|
_textureCache,
|
||||||
_wbMeshAdapter,
|
_wbMeshAdapter,
|
||||||
_worldReveal,
|
_worldReveal,
|
||||||
teleportRenderState,
|
teleportRenderState,
|
||||||
new AcDream.App.Rendering.RenderLoginStateSource(
|
renderLoginState,
|
||||||
_options.LiveMode,
|
|
||||||
_localPlayerMode),
|
|
||||||
new AcDream.App.Rendering.LiveLoginRevealCellSource(
|
new AcDream.App.Rendering.LiveLoginRevealCellSource(
|
||||||
_liveEntities!,
|
_liveEntities!,
|
||||||
_localPlayerIdentity),
|
_localPlayerIdentity),
|
||||||
|
|
@ -2841,7 +2816,8 @@ public sealed class GameWindow : IDisposable
|
||||||
Weather,
|
Weather,
|
||||||
teleportRenderState,
|
teleportRenderState,
|
||||||
_particleVisibility,
|
_particleVisibility,
|
||||||
_worldRenderDiagnostics!),
|
_worldRenderDiagnostics!,
|
||||||
|
renderFrameGlState),
|
||||||
_renderFrameLivePreparation);
|
_renderFrameLivePreparation);
|
||||||
_renderWeatherFrame =
|
_renderWeatherFrame =
|
||||||
new AcDream.App.Rendering.RenderWeatherFrameController(
|
new AcDream.App.Rendering.RenderWeatherFrameController(
|
||||||
|
|
@ -2911,6 +2887,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_retailPViewPassExecutor =
|
_retailPViewPassExecutor =
|
||||||
new AcDream.App.Rendering.RetailPViewPassExecutor(
|
new AcDream.App.Rendering.RetailPViewPassExecutor(
|
||||||
_gl!,
|
_gl!,
|
||||||
|
renderFrameGlState,
|
||||||
new AcDream.App.Rendering.SilkRetailPViewFramebufferSource(
|
new AcDream.App.Rendering.SilkRetailPViewFramebufferSource(
|
||||||
_window!),
|
_window!),
|
||||||
_clipFrame!,
|
_clipFrame!,
|
||||||
|
|
@ -2924,6 +2901,48 @@ public sealed class GameWindow : IDisposable
|
||||||
_retailAlphaQueue,
|
_retailAlphaQueue,
|
||||||
_worldRenderDiagnostics!,
|
_worldRenderDiagnostics!,
|
||||||
_terrainDrawDiagnostics);
|
_terrainDrawDiagnostics);
|
||||||
|
var worldSceneDiagnostics =
|
||||||
|
new AcDream.App.Rendering.WorldSceneDiagnosticsController(
|
||||||
|
_worldRenderDiagnostics!,
|
||||||
|
new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource(
|
||||||
|
_playerControllerSlot,
|
||||||
|
_physicsEngine,
|
||||||
|
_cellVisibility),
|
||||||
|
_worldSceneDebugState,
|
||||||
|
_debugLines,
|
||||||
|
_physicsEngine,
|
||||||
|
_localPlayerMode,
|
||||||
|
_playerControllerSlot,
|
||||||
|
_debugVmRenderFacts,
|
||||||
|
_debugVm is not null);
|
||||||
|
var worldScenePasses = new AcDream.App.Rendering.WorldScenePassExecutor(
|
||||||
|
_gl!,
|
||||||
|
renderFrameGlState,
|
||||||
|
_clipFrame!,
|
||||||
|
_wbDrawDispatcher!,
|
||||||
|
_envCellRenderer!,
|
||||||
|
_terrain,
|
||||||
|
_terrainDrawDiagnostics,
|
||||||
|
_skyRenderer,
|
||||||
|
_particleSystem,
|
||||||
|
_particleRenderer);
|
||||||
|
_worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
|
||||||
|
_renderFrameResources,
|
||||||
|
renderLoginState,
|
||||||
|
_worldSceneSkyState!,
|
||||||
|
_worldRenderFrameBuilder,
|
||||||
|
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
|
||||||
|
_retailSelectionScene,
|
||||||
|
_retailAlphaQueue,
|
||||||
|
_particleVisibility,
|
||||||
|
new AcDream.App.Rendering.WorldScenePViewRenderer(
|
||||||
|
_retailPViewRenderer!,
|
||||||
|
_retailPViewPassExecutor,
|
||||||
|
_retailPViewPassExecutor),
|
||||||
|
_retailPViewCells,
|
||||||
|
worldScenePasses,
|
||||||
|
_renderRange,
|
||||||
|
worldSceneDiagnostics);
|
||||||
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
||||||
liveObjectFrame,
|
liveObjectFrame,
|
||||||
_worldState,
|
_worldState,
|
||||||
|
|
@ -3400,7 +3419,6 @@ public sealed class GameWindow : IDisposable
|
||||||
Exception? renderFailure = null;
|
Exception? renderFailure = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GL gl = _gl!;
|
|
||||||
var frameInput = new AcDream.App.Rendering.RenderFrameInput(
|
var frameInput = new AcDream.App.Rendering.RenderFrameInput(
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
_window!.Size.X,
|
_window!.Size.X,
|
||||||
|
|
@ -3414,10 +3432,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// CreatureMode; it does not redraw the world behind portal space.
|
// CreatureMode; it does not redraw the world behind portal space.
|
||||||
bool portalViewportVisible = foundation.PortalViewportVisible;
|
bool portalViewportVisible = foundation.PortalViewportVisible;
|
||||||
|
|
||||||
AcDream.Core.World.SkyKeyframe kf = foundation.Sky;
|
|
||||||
AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere;
|
|
||||||
bool environOverrideActive = foundation.EnvironOverrideActive;
|
|
||||||
|
|
||||||
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
||||||
// after the scene draws (below). ImGuiController.Update()
|
// after the scene draws (below). ImGuiController.Update()
|
||||||
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
|
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
|
||||||
|
|
@ -3441,558 +3455,11 @@ public sealed class GameWindow : IDisposable
|
||||||
// and the SkyRenderer.RenderWeather pass both pick up snow
|
// and the SkyRenderer.RenderWeather pass both pick up snow
|
||||||
// weather meshes for free.)
|
// weather meshes for free.)
|
||||||
|
|
||||||
int visibleLandblocks = 0;
|
AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome =
|
||||||
int totalLandblocks = 0;
|
_worldSceneRenderer!.Render(frameInput);
|
||||||
bool normalWorldDrawn = false;
|
int visibleLandblocks = worldOutcome.VisibleLandblocks;
|
||||||
|
int totalLandblocks = worldOutcome.TotalLandblocks;
|
||||||
_retailSelectionScene?.BeginFrame();
|
bool normalWorldDrawn = worldOutcome.NormalWorldDrawn;
|
||||||
if (_cameraController is not null && !portalViewportVisible)
|
|
||||||
{
|
|
||||||
_retailAlphaQueue.BeginFrame();
|
|
||||||
AcDream.App.Rendering.WorldRenderFrame worldFrame =
|
|
||||||
_worldRenderFrameBuilder!.Build(
|
|
||||||
in foundation,
|
|
||||||
IsLiveModeWaitingForLogin,
|
|
||||||
_activeDayGroup);
|
|
||||||
AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera;
|
|
||||||
AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots;
|
|
||||||
var camera = cameraFrame.Camera;
|
|
||||||
var worldProjection = cameraFrame.Projection;
|
|
||||||
var frustum = cameraFrame.Frustum;
|
|
||||||
var camPos = cameraFrame.Position;
|
|
||||||
// The frame builder resolves retail's player-lighting root and
|
|
||||||
// collided-viewer render root before any draw decision is made.
|
|
||||||
LoadedCell? playerRoot = rootFrame.PlayerRoot;
|
|
||||||
bool playerSeenOutside = rootFrame.PlayerSeenOutside;
|
|
||||||
uint viewerCellId = rootFrame.ViewerCellId;
|
|
||||||
var viewerEyePos = rootFrame.ViewerEyePosition;
|
|
||||||
var playerViewPos = rootFrame.PlayerViewPosition;
|
|
||||||
LoadedCell? viewerRoot = rootFrame.ViewerRoot;
|
|
||||||
bool cameraInsideCell = rootFrame.CameraInsideCell;
|
|
||||||
|
|
||||||
// Never cull the landblock the player is currently on.
|
|
||||||
uint? playerLb = rootFrame.PlayerLandblockId;
|
|
||||||
int renderCenterLbX = rootFrame.RenderCenterLandblockX;
|
|
||||||
int renderCenterLbY = rootFrame.RenderCenterLandblockY;
|
|
||||||
|
|
||||||
var envCellViewProj = cameraFrame.ViewProjection;
|
|
||||||
HashSet<uint> animatedIds = worldFrame.AnimatedEntityIds;
|
|
||||||
|
|
||||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
|
||||||
// celestial meshes FIRST so the rest of the scene z-tests
|
|
||||||
// on top of them (depth mask off, no depth writes).
|
|
||||||
//
|
|
||||||
// Suppressed inside cells (the camera is in a sealed interior;
|
|
||||||
// no sky is visible). Mirrors retail's LScape::draw at 0x00506330
|
|
||||||
// which calls GameSky::Draw(0) (sky pass) BEFORE the landblock
|
|
||||||
// DrawBlock loop and GameSky::Draw(1) (weather pass) AFTER. The
|
|
||||||
// split matters because weather meshes (the 815m-tall rain
|
|
||||||
// cylinder 0x01004C42/0x01004C44) need to overlay terrain and
|
|
||||||
// entities to look volumetric — see the post-scene RenderWeather
|
|
||||||
// call further below.
|
|
||||||
// Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649.
|
|
||||||
// Outdoor root (cameraInsideCell=false): always render sky.
|
|
||||||
// Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped
|
|
||||||
// to the doorway via the OutsideView (Stage 4, below).
|
|
||||||
// Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky.
|
|
||||||
bool renderSky = rootFrame.RenderSky;
|
|
||||||
// Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape
|
|
||||||
// position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to
|
|
||||||
// the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky
|
|
||||||
// pre-scene" block after UploadShared. renderSky is the seen_outside policy gate; the draw
|
|
||||||
// additionally requires an exit portal in view when indoors (drawSkyThisFrame, below).
|
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): the pre-login world-suppression gate (goto SkipWorldGeometry)
|
|
||||||
// moved DOWN — below the sky pre-scene draw (Phase W Stage 4) — so the live sky still
|
|
||||||
// draws during the connection + EnterWorld handshake while the world geometry is skipped.
|
|
||||||
// See the gate just before the world-geometry clip bracket.
|
|
||||||
|
|
||||||
// Phase U.4: build the SHARED per-frame clip data from the portal-
|
|
||||||
// visibility result, ahead of both terrain and entity draws.
|
|
||||||
//
|
|
||||||
// Root: a non-null CameraCell means the camera is INSIDE a cell (indoor
|
|
||||||
// root) — run the portal-frame BFS (PortalVisibilityBuilder) and assemble
|
|
||||||
// a real ClipFrame (slot 0 no-clip, slot 1.. per visible cell + the
|
|
||||||
// OutsideView) + a cellId→slot map. A null CameraCell is the OUTDOOR root:
|
|
||||||
// no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain
|
|
||||||
// draws normally — bit-identical to U.3 (outdoor→building peering is U.5).
|
|
||||||
//
|
|
||||||
// The single _clipFrame instance is RESET + repacked in place each frame.
|
|
||||||
// One SSBO and one range-addressed terrain UBO arena are reused per
|
|
||||||
// GPU-fenced frame slot; each renderer re-binds binding=2 defensively.
|
|
||||||
_clipFrame ??= ClipFrame.NoClip();
|
|
||||||
|
|
||||||
IReadOnlyList<LoadedCell> nearbyBuildingCells =
|
|
||||||
worldFrame.Buildings.NearbyBuildingCells;
|
|
||||||
bool playerIndoorGate = rootFrame.PlayerIndoorGate;
|
|
||||||
uint playerCellId = rootFrame.PlayerCellId;
|
|
||||||
// Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the
|
|
||||||
// VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the
|
|
||||||
// frame builder's synthetic outdoor node, built from nearby building entrances. The
|
|
||||||
// result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) ->
|
|
||||||
// the outdoor LScape block below still runs as the safety path (and login still shows the
|
|
||||||
// live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the
|
|
||||||
// doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays
|
|
||||||
// computed for the [render-sig] probe but no longer selects the path (handoff
|
|
||||||
// docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B).
|
|
||||||
var clipRoot = worldFrame.ClipRoot;
|
|
||||||
string renderBranch = clipRoot is null
|
|
||||||
? "OutdoorRoot"
|
|
||||||
: "RetailPViewInside";
|
|
||||||
ClipFrameAssembly? clipAssembly = null;
|
|
||||||
PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells
|
|
||||||
var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root
|
|
||||||
HashSet<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
|
|
||||||
PortalVisibilityFrame? sigPvFrame = null;
|
|
||||||
ClipFrameAssembly? sigClipAssembly = null;
|
|
||||||
IReadOnlySet<uint>? sigDrawableCells = null;
|
|
||||||
AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null;
|
|
||||||
PortalVisibilityFrame? sigExteriorPvFrame = null;
|
|
||||||
ClipFrameAssembly? sigExteriorClipAssembly = null;
|
|
||||||
IReadOnlySet<uint>? sigExteriorDrawableCells = null;
|
|
||||||
AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null;
|
|
||||||
bool sigTerrainDrawn = false;
|
|
||||||
bool sigSkyDrawn = false;
|
|
||||||
bool sigDepthClear = false;
|
|
||||||
bool sigOutdoorPortalDrawn = false;
|
|
||||||
bool sigOutdoorSceneryDrawn = false;
|
|
||||||
int sigOutdoorRootObjectCount = 0;
|
|
||||||
int sigLiveDynamicDrawnCount = 0;
|
|
||||||
string sigSceneParticles = "none";
|
|
||||||
_visibleSceneParticleEntityIds.Clear();
|
|
||||||
// Retail entry ownership: GameWindow never builds a second indoor PView product.
|
|
||||||
// Outdoor frames begin no-clip; indoor frames skip the global landscape block and let
|
|
||||||
// RetailPViewRenderer.DrawInside own ConstructView -> DrawCells.
|
|
||||||
_clipFrame.Reset();
|
|
||||||
_wbDrawDispatcher?.ClearClipRouting();
|
|
||||||
_envCellRenderer?.SetClipRouting(null);
|
|
||||||
_interiorPartition = null;
|
|
||||||
if (clipRoot is not null)
|
|
||||||
{
|
|
||||||
clipAssembly = null;
|
|
||||||
pvFrame = null;
|
|
||||||
terrainClipMode = TerrainClipMode.Skip;
|
|
||||||
envCellShellFilter = null;
|
|
||||||
_interiorPartition = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clipRoot is null)
|
|
||||||
{
|
|
||||||
// Outdoor frames have one no-clip terrain state. Indoor frames
|
|
||||||
// defer both allocations to RetailPViewRenderer, which knows the
|
|
||||||
// exact slice count and packs every terrain state into one arena.
|
|
||||||
_clipFrame.ReserveTerrainUploads(gl, 1);
|
|
||||||
_clipFrame.UploadRegions(gl);
|
|
||||||
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl);
|
|
||||||
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
|
||||||
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
|
||||||
_terrain?.SetClipUbo(terrainBinding);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool drawSkyThisFrame = false;
|
|
||||||
|
|
||||||
if (clipRoot is null)
|
|
||||||
{
|
|
||||||
// ── Outdoor LScape entry ─────────────────────────────────────────────────
|
|
||||||
// Retail indoor frames do not pass through this block. If the player is indoors,
|
|
||||||
// PView::DrawInside owns landscape drawing through outside_view and the depth-only
|
|
||||||
// clear. This outdoor-only block is the LScape half of RenderNormalMode.
|
|
||||||
drawSkyThisFrame = renderSky;
|
|
||||||
sigSkyDrawn = drawSkyThisFrame;
|
|
||||||
if (drawSkyThisFrame)
|
|
||||||
{
|
|
||||||
_clipFrame.BindTerrainClip(gl);
|
|
||||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
|
||||||
gl.Enable(EnableCap.ClipDistance0 + _cp);
|
|
||||||
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
|
|
||||||
_activeDayGroup, kf, environOverrideActive);
|
|
||||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
|
||||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
|
||||||
|
|
||||||
if (_particleSystem is not null && _particleRenderer is not null)
|
|
||||||
_particleRenderer.Draw(camera, camPos,
|
|
||||||
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
|
|
||||||
}
|
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured
|
|
||||||
// but the chase camera hasn't engaged yet. The sky (above) still draws during login so the
|
|
||||||
// user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake.
|
|
||||||
if (IsLiveModeWaitingForLogin)
|
|
||||||
goto SkipWorldGeometry;
|
|
||||||
|
|
||||||
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup.
|
|
||||||
EnableClipDistances();
|
|
||||||
_terrainDrawDiagnostics!.Begin();
|
|
||||||
sigTerrainDrawn = true;
|
|
||||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
|
||||||
_terrainDrawDiagnostics.Complete();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaching the retail normal-world decision below proves this is
|
|
||||||
// neither a portal replacement nor the sky-only login-wait path.
|
|
||||||
normalWorldDrawn = true;
|
|
||||||
|
|
||||||
// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
|
|
||||||
// INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global
|
|
||||||
// entity pass + global shell pass are NOT issued — visibility IS the cull, so the
|
|
||||||
// outdoor world cannot bleed (it is never iterated; outdoor scenery entered above,
|
|
||||||
// clipped to the doorway). DrawInside follows retail DrawCells order: reverse
|
|
||||||
// cell_draw_list shell stage, then reverse object-list stage, per portal_view slice.
|
|
||||||
// OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal
|
|
||||||
// portal frame from exterior-facing cell portals so peering through an open door
|
|
||||||
// draws the indoor SHELL + its statics together. The old global pass drew indoor
|
|
||||||
// statics without the EnvCell shells, which made walls look transparent from outside.
|
|
||||||
if (clipRoot is not null)
|
|
||||||
{
|
|
||||||
if (_retailPViewRenderer is null)
|
|
||||||
throw new InvalidOperationException("Retail PView renderer is required for indoor frames.");
|
|
||||||
|
|
||||||
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewFrameInput
|
|
||||||
{
|
|
||||||
RootCell = clipRoot,
|
|
||||||
// R-A2: outdoor root floods each nearby building per-building (not via the root).
|
|
||||||
// #124: interior roots get the gather too — the renderer routes them to the
|
|
||||||
// landscape-stage look-in sub-pass instead of the merge.
|
|
||||||
NearbyBuildingCells = nearbyBuildingCells,
|
|
||||||
ViewerEyePos = viewerEyePos,
|
|
||||||
ViewProjection = envCellViewProj,
|
|
||||||
Cells = _retailPViewCells!,
|
|
||||||
Camera = camera,
|
|
||||||
CameraWorldPosition = camPos,
|
|
||||||
// (#176 correction: the former RebuildScopedLights callback —
|
|
||||||
// rebuilding the light pool from the frame's FLOOD — was the
|
|
||||||
// flicker mechanism and is deleted. The pool is built once per
|
|
||||||
// frame above, player-anchored, from all resident lights.)
|
|
||||||
Frustum = frustum,
|
|
||||||
PlayerLandblockId = playerLb,
|
|
||||||
AnimatedEntityIds = animatedIds,
|
|
||||||
RenderCenterLbX = renderCenterLbX,
|
|
||||||
RenderCenterLbY = renderCenterLbY,
|
|
||||||
RenderRadius = _nearRadius,
|
|
||||||
LandblockEntries = _worldState.LandblockEntries,
|
|
||||||
RenderSky = renderSky,
|
|
||||||
RenderWeather = playerSeenOutside,
|
|
||||||
DayFraction = (float)WorldTime.DayFraction,
|
|
||||||
ActiveDayGroup = _activeDayGroup,
|
|
||||||
SkyKeyframe = kf,
|
|
||||||
EnvironOverrideActive = environOverrideActive,
|
|
||||||
ViewerCellId = viewerCellId,
|
|
||||||
PlayerCellId = playerCellId,
|
|
||||||
PlayerViewPosition = playerViewPos,
|
|
||||||
CameraView = camera.View,
|
|
||||||
CameraCellResolution = _cellVisibility.LastCameraCellResolution,
|
|
||||||
}, _retailPViewPassExecutor
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"Retail PView pass executor is required for indoor frames."));
|
|
||||||
pvFrame = pviewResult.PortalFrame;
|
|
||||||
clipAssembly = pviewResult.ClipAssembly;
|
|
||||||
envCellShellFilter = pviewResult.DrawableCells;
|
|
||||||
_interiorPartition = pviewResult.Partition;
|
|
||||||
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
|
|
||||||
|
|
||||||
// A7.L1: snapshot this frame's drawn cell set for NEXT frame's light-
|
|
||||||
// pool scoping. DrawableCells is the renderer's own reused scratch set
|
|
||||||
// (cleared/rebuilt every DrawInside call) — copy it, don't hold a
|
|
||||||
// reference to it.
|
|
||||||
_worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells);
|
|
||||||
|
|
||||||
// Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp +
|
|
||||||
// the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player
|
|
||||||
// identical to cm; this answers whether the inputs differ sub-cm (jitter) or are
|
|
||||||
// byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled.
|
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled
|
|
||||||
&& pvFrame is not null)
|
|
||||||
{
|
|
||||||
// 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood);
|
|
||||||
// player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics
|
|
||||||
// body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood
|
|
||||||
// flickers while idle, exactly one of {eye, player, rawPlayer, yaw} is the varying input.
|
|
||||||
var pvRawPlayer = _playerController?.Position ?? playerViewPos;
|
|
||||||
float pvYaw = _playerController?.Yaw ?? 0f;
|
|
||||||
// §4 outdoor flap (2026-06-09): terrain height under the EYE — if the
|
|
||||||
// camera boom is buried in a hillside (eyeAbove < 0), every nearby
|
|
||||||
// terrain triangle backface-culls and the view punches through the
|
|
||||||
// world to the clear color. Pin or refute eye-under-terrain.
|
|
||||||
float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y);
|
|
||||||
_worldRenderDiagnostics?.EmitPViewInput(
|
|
||||||
enabled: true,
|
|
||||||
pvFrame,
|
|
||||||
envCellViewProj,
|
|
||||||
clipRoot.IsOutdoorNode,
|
|
||||||
camPos,
|
|
||||||
playerViewPos,
|
|
||||||
pvRawPlayer,
|
|
||||||
pvYaw,
|
|
||||||
pvTerrZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
sigPvFrame = pviewResult.PortalFrame;
|
|
||||||
sigClipAssembly = pviewResult.ClipAssembly;
|
|
||||||
sigDrawableCells = pviewResult.DrawableCells;
|
|
||||||
sigPartition = pviewResult.Partition;
|
|
||||||
sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
|
||||||
sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
|
||||||
sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
|
||||||
sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0
|
|
||||||
|| pviewResult.ClipAssembly.OutsideViewSlices.Length > 0)
|
|
||||||
? "pviewScoped"
|
|
||||||
: sigSceneParticles;
|
|
||||||
sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0
|
|
||||||
&& pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
|
||||||
|
|
||||||
// T1: DrawInside now draws ALL dynamics itself in its single
|
|
||||||
// last entity pass (DrawDynamicsLast) — the old LiveDynamic
|
|
||||||
// top-up draw is gone.
|
|
||||||
sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// T4 (BR-6): the old clipRoot==null mini-pipeline (outdoor
|
|
||||||
// partition + Chebyshev look-in gather + DrawPortal + dynamics
|
|
||||||
// fallback) is DELETED — it was the SECOND render path the
|
|
||||||
// one-gate rule forbids (legacy-outdoor-branch-remnant,
|
|
||||||
// adjusted-confirmed). clipRoot is null only when NO viewer
|
|
||||||
// cell exists at all (pre-login, fly/debug cameras, transient
|
|
||||||
// streaming gaps — the outdoor node covers every normal outdoor
|
|
||||||
// frame): draw the world flat through the dispatcher; floods
|
|
||||||
// resume the moment a viewer cell resolves.
|
|
||||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
|
||||||
neverCullLandblockId: playerLb,
|
|
||||||
visibleCellIds: null,
|
|
||||||
animatedEntityIds: animatedIds);
|
|
||||||
|
|
||||||
// A7.L1: no indoor draw this frame — fail open (unscoped) rather than
|
|
||||||
// scoping next frame's light pool by a stale dungeon's cell ids.
|
|
||||||
_worldRenderFrameBuilder!.ClearDrawableCells();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase U.3: close the world-geometry clip bracket opened above. From here down the
|
|
||||||
// scene particles, debug lines, and UI use shaders that do NOT write gl_ClipDistance, so
|
|
||||||
// the planes must be OFF to avoid the undefined-behavior clip. (The weather/rain pass
|
|
||||||
// below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky
|
|
||||||
// pre-scene pass above already did the same. Both are scissored to the doorway too.)
|
|
||||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
|
||||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
|
||||||
|
|
||||||
// Phase G.1 / E.3: draw all live particles after opaque
|
|
||||||
// scene geometry so alpha blending composites correctly.
|
|
||||||
// Runs with depth test on (particles occluded by walls)
|
|
||||||
// but depth write off (no self-occlusion sorting needed).
|
|
||||||
if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null)
|
|
||||||
{
|
|
||||||
if (clipAssembly is not null)
|
|
||||||
{
|
|
||||||
sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered";
|
|
||||||
_particleRenderer.DrawForOwners(
|
|
||||||
camera,
|
|
||||||
camPos,
|
|
||||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
|
||||||
_visibleSceneParticleEntityIds,
|
|
||||||
includeUnattached: true,
|
|
||||||
excludedAttachedOwnerIds: _noSceneParticleEntityIds);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global";
|
|
||||||
_particleRenderer.Draw(
|
|
||||||
camera,
|
|
||||||
camPos,
|
|
||||||
AcDream.Core.Vfx.ParticleRenderPass.Scene);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (clipRoot is { IsOutdoorNode: true }
|
|
||||||
&& _particleSystem is not null && _particleRenderer is not null)
|
|
||||||
{
|
|
||||||
// T3 (BR-5): unattached emitters (campfires, ground effects —
|
|
||||||
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
|
|
||||||
// root's outside view is full-screen (cone pass-all); depth
|
|
||||||
// test composites them against the world.
|
|
||||||
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
|
|
||||||
// (lantern/candle flames) moved here too — drawn in the
|
|
||||||
// landscape slice they were overpainted by merged building
|
|
||||||
// interiors (drawn later) whenever a punched aperture sat
|
|
||||||
// behind them. Post-frame, depth is complete and the flames
|
|
||||||
// composite correctly. The owner-id set is the late slice's
|
|
||||||
// (full-screen cone outdoors). Cell-pass and dynamics-pass
|
|
||||||
// emitters keep their own passes (no double-draw: their owners
|
|
||||||
// are never in the outdoor-static id set).
|
|
||||||
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
|
|
||||||
_particleRenderer.DrawForOwners(
|
|
||||||
camera,
|
|
||||||
camPos,
|
|
||||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
|
||||||
_retailPViewPassExecutor!.OutdoorSceneParticleEntityIds,
|
|
||||||
includeUnattached: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
|
|
||||||
// Outdoor LScape post-scene weather. Indoor weather through an exit portal is
|
|
||||||
// drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor.
|
|
||||||
if (clipRoot is null && drawSkyThisFrame)
|
|
||||||
{
|
|
||||||
sigSkyDrawn = true;
|
|
||||||
_clipFrame.BindTerrainClip(gl);
|
|
||||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
|
||||||
gl.Enable(EnableCap.ClipDistance0 + _cp);
|
|
||||||
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
|
|
||||||
_activeDayGroup, kf, environOverrideActive);
|
|
||||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
|
||||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
|
||||||
|
|
||||||
if (_particleSystem is not null && _particleRenderer is not null)
|
|
||||||
_particleRenderer.Draw(camera, camPos,
|
|
||||||
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
|
|
||||||
}
|
|
||||||
|
|
||||||
_worldRenderDiagnostics?.EmitRenderSignatureIfChanged(
|
|
||||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled,
|
|
||||||
renderBranch,
|
|
||||||
clipRoot,
|
|
||||||
viewerRoot,
|
|
||||||
playerRoot,
|
|
||||||
viewerCellId,
|
|
||||||
playerCellId,
|
|
||||||
playerIndoorGate,
|
|
||||||
cameraInsideCell,
|
|
||||||
renderSky,
|
|
||||||
drawSkyThisFrame,
|
|
||||||
sigTerrainDrawn,
|
|
||||||
terrainClipMode,
|
|
||||||
sigSkyDrawn,
|
|
||||||
sigDepthClear,
|
|
||||||
sigOutdoorSceneryDrawn,
|
|
||||||
sigOutdoorPortalDrawn,
|
|
||||||
sigOutdoorRootObjectCount,
|
|
||||||
sigLiveDynamicDrawnCount,
|
|
||||||
sigSceneParticles,
|
|
||||||
sigPvFrame,
|
|
||||||
sigClipAssembly,
|
|
||||||
sigDrawableCells,
|
|
||||||
sigPartition,
|
|
||||||
sigExteriorPvFrame,
|
|
||||||
sigExteriorClipAssembly,
|
|
||||||
sigExteriorDrawableCells,
|
|
||||||
sigExteriorPartition,
|
|
||||||
camPos,
|
|
||||||
playerViewPos);
|
|
||||||
|
|
||||||
// Debug: draw collision shapes as wireframe cylinders around the
|
|
||||||
// player so we can visually verify alignment with scenery meshes.
|
|
||||||
if (_debugCollisionVisible && _debugLines is not null)
|
|
||||||
{
|
|
||||||
_debugLines.Begin();
|
|
||||||
|
|
||||||
// Pick the center for the debug radius. Prefer player
|
|
||||||
// position in player mode, otherwise use camPos.
|
|
||||||
System.Numerics.Vector3 center;
|
|
||||||
if (_playerMode && _playerController is not null)
|
|
||||||
center = _playerController.Position;
|
|
||||||
else
|
|
||||||
center = camPos;
|
|
||||||
|
|
||||||
// Draw ALL registered shadow objects regardless of distance —
|
|
||||||
// if it has collision, it gets a wireframe. This lets the user
|
|
||||||
// see exactly what's in the collision registry at any moment.
|
|
||||||
int drawn = 0;
|
|
||||||
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
|
||||||
{
|
|
||||||
var dx = obj.Position.X - center.X;
|
|
||||||
var dy = obj.Position.Y - center.Y;
|
|
||||||
|
|
||||||
if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder)
|
|
||||||
{
|
|
||||||
float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f;
|
|
||||||
_debugLines.AddCylinder(
|
|
||||||
obj.Position, obj.Radius, h,
|
|
||||||
new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// BSP: show a bounding sphere as a cylinder for visibility
|
|
||||||
_debugLines.AddCylinder(
|
|
||||||
obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius),
|
|
||||||
obj.Radius, obj.Radius * 2f,
|
|
||||||
new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP
|
|
||||||
}
|
|
||||||
drawn++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall)
|
|
||||||
if (_playerMode && _playerController is not null)
|
|
||||||
{
|
|
||||||
var pp = _playerController.Position;
|
|
||||||
_debugLines.AddCylinder(
|
|
||||||
new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f),
|
|
||||||
0.48f, 1.8f,
|
|
||||||
new System.Numerics.Vector3(1f, 0f, 0f)); // red player
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null)
|
|
||||||
{
|
|
||||||
var pp = _playerController.Position;
|
|
||||||
Console.WriteLine(
|
|
||||||
$"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " +
|
|
||||||
$"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}");
|
|
||||||
// Sample 3 nearest shadow objects
|
|
||||||
int logged = 0;
|
|
||||||
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
|
||||||
{
|
|
||||||
var dx = o.Position.X - pp.X;
|
|
||||||
var dy = o.Position.Y - pp.Y;
|
|
||||||
float dh = MathF.Sqrt(dx * dx + dy * dy);
|
|
||||||
if (dh < 10f)
|
|
||||||
{
|
|
||||||
Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}");
|
|
||||||
if (++logged >= 5) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_debugDrawLogOnce++;
|
|
||||||
}
|
|
||||||
_debugLines.Flush(camera.View, worldProjection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count visible vs total for the perf overlay.
|
|
||||||
foreach (var entry in _worldState.LandblockBounds)
|
|
||||||
{
|
|
||||||
totalLandblocks++;
|
|
||||||
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
|
|
||||||
visibleLandblocks++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase I.2: refresh per-frame fields that DebugVM closures
|
|
||||||
// can't compute lazily (frustum-derived counters + nearest-
|
|
||||||
// object scan). Every other DebugVM field reads through to
|
|
||||||
// the live source via its closure. Skipped entirely when
|
|
||||||
// devtools are off — avoids the nearest-object O(N) scan in
|
|
||||||
// the hot path of an offline render.
|
|
||||||
if (_debugVm is not null)
|
|
||||||
{
|
|
||||||
System.Numerics.Vector3 debugNearestOrigin =
|
|
||||||
_playerMode && _playerController is not null
|
|
||||||
? _playerController.Position
|
|
||||||
: camPos;
|
|
||||||
_debugVmRenderFacts.PublishDebugVmFacts(
|
|
||||||
consumerActive: true,
|
|
||||||
visibleLandblocks,
|
|
||||||
totalLandblocks,
|
|
||||||
debugNearestOrigin,
|
|
||||||
_physicsEngine.ShadowObjects.AllEntriesForDebug());
|
|
||||||
}
|
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin —
|
|
||||||
// skips the world geometry pass before login. ImGui (chat,
|
|
||||||
// debug, settings panels) and the menu bar still render
|
|
||||||
// below. Sky has already drawn before this label so the
|
|
||||||
// pre-login screen shows a live, correctly-tinted sky and
|
|
||||||
// nothing else.
|
|
||||||
SkipWorldGeometry:
|
|
||||||
_retailAlphaQueue.EndFrame();
|
|
||||||
if (_terrain is not null)
|
|
||||||
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
|
|
||||||
_particleVisibility.CompleteFrame();
|
|
||||||
}
|
|
||||||
_retailSelectionScene?.CompleteFrame();
|
|
||||||
|
|
||||||
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
||||||
// CreatureMode portal-space viewport. The world block above is skipped
|
// CreatureMode portal-space viewport. The world block above is skipped
|
||||||
|
|
@ -4139,7 +3606,8 @@ public sealed class GameWindow : IDisposable
|
||||||
/// within the same server-day. Swaps both the
|
/// within the same server-day. Swaps both the
|
||||||
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
|
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
|
||||||
/// <see cref="WorldTime"/> (for lighting interp) and the cached
|
/// <see cref="WorldTime"/> (for lighting interp) and the cached
|
||||||
/// <see cref="_activeDayGroup"/> (for the sky-object render loop).
|
/// <see cref="WorldSceneSkyState.ActiveDayGroup"/> (for the sky-object
|
||||||
|
/// render loop).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
|
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
|
||||||
|
|
@ -4186,12 +3654,16 @@ public sealed class GameWindow : IDisposable
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
bool dayChanged = dayIndex != _loadedSkyDayIndex;
|
bool dayChanged = dayIndex != _loadedSkyDayIndex;
|
||||||
bool groupChanged = !ReferenceEquals(grp, _activeDayGroup);
|
bool groupChanged = !ReferenceEquals(
|
||||||
|
grp,
|
||||||
|
_worldSceneSkyState?.ActiveDayGroup);
|
||||||
|
|
||||||
if (!dayChanged && !groupChanged) return;
|
if (!dayChanged && !groupChanged) return;
|
||||||
|
|
||||||
_loadedSkyDayIndex = dayIndex;
|
_loadedSkyDayIndex = dayIndex;
|
||||||
_activeDayGroup = grp;
|
(_worldSceneSkyState
|
||||||
|
??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime))
|
||||||
|
.ActiveDayGroup = grp;
|
||||||
|
|
||||||
if (grp is not null && grp.SkyTimes.Count > 0)
|
if (grp is not null && grp.SkyTimes.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -4214,18 +3686,6 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnableClipDistances()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
|
|
||||||
_gl!.Enable(EnableCap.ClipDistance0 + i);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisableClipDistances()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
|
|
||||||
_gl!.Disable(EnableCap.ClipDistance0 + i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
||||||
//
|
//
|
||||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
// The ImGui DebugPanel reads through DebugVM closures that ask
|
||||||
|
|
@ -4343,8 +3803,8 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ToggleCollisionWires()
|
private void ToggleCollisionWires()
|
||||||
{
|
{
|
||||||
_debugCollisionVisible = !_debugCollisionVisible;
|
bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
|
||||||
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
|
_debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase K.3 settings state remains runtime-owned; the presenter owns the
|
// Phase K.3 settings state remains runtime-owned; the presenter owns the
|
||||||
|
|
@ -5103,7 +4563,9 @@ public sealed class GameWindow : IDisposable
|
||||||
[
|
[
|
||||||
new("world frame composition", () =>
|
new("world frame composition", () =>
|
||||||
{
|
{
|
||||||
|
_worldSceneRenderer = null;
|
||||||
_worldRenderFrameBuilder = null;
|
_worldRenderFrameBuilder = null;
|
||||||
|
_worldSceneSkyState = null;
|
||||||
_skyPesFrame = null;
|
_skyPesFrame = null;
|
||||||
_retailPViewPassExecutor = null;
|
_retailPViewPassExecutor = null;
|
||||||
_retailPViewCells = null;
|
_retailPViewCells = null;
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,9 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
||||||
_gl.ColorMask(true, true, true, true);
|
_gl.ColorMask(true, true, true, true);
|
||||||
_gl.DepthMask(true);
|
_gl.DepthMask(true);
|
||||||
_gl.DepthFunc(DepthFunction.Less);
|
_gl.DepthFunc(DepthFunction.Less);
|
||||||
_gl.Enable(EnableCap.CullFace);
|
// Renderers opt into culling locally; the shared frame convention is
|
||||||
|
// cull-off. Keep this success exit identical to frame-abort recovery.
|
||||||
|
_gl.Disable(EnableCap.CullFace);
|
||||||
_gl.CullFace(TriangleFace.Back);
|
_gl.CullFace(TriangleFace.Back);
|
||||||
_gl.FrontFace(FrontFaceDirection.CW);
|
_gl.FrontFace(FrontFaceDirection.CW);
|
||||||
_gl.UseProgram(0);
|
_gl.UseProgram(0);
|
||||||
|
|
|
||||||
113
src/AcDream.App/Rendering/RenderFrameGlStateController.cs
Normal file
113
src/AcDream.App/Rendering/RenderFrameGlStateController.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal interface IRenderFrameGlState
|
||||||
|
{
|
||||||
|
void RestoreFrameDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRenderFrameGlStateApi
|
||||||
|
{
|
||||||
|
void SetCapability(EnableCap capability, bool enabled);
|
||||||
|
|
||||||
|
void ColorMask(bool red, bool green, bool blue, bool alpha);
|
||||||
|
|
||||||
|
void StencilMask(uint mask);
|
||||||
|
|
||||||
|
void DepthMask(bool enabled);
|
||||||
|
|
||||||
|
void DepthFunc(DepthFunction function);
|
||||||
|
|
||||||
|
void CullFace(TriangleFace face);
|
||||||
|
|
||||||
|
void FrontFace(FrontFaceDirection direction);
|
||||||
|
|
||||||
|
void UseProgram(uint program);
|
||||||
|
|
||||||
|
void BindVertexArray(uint vertexArray);
|
||||||
|
|
||||||
|
void BindBuffer(BufferTargetARB target, uint buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SilkRenderFrameGlStateApi : IRenderFrameGlStateApi
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
|
||||||
|
public SilkRenderFrameGlStateApi(GL gl)
|
||||||
|
{
|
||||||
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCapability(EnableCap capability, bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled)
|
||||||
|
_gl.Enable(capability);
|
||||||
|
else
|
||||||
|
_gl.Disable(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
|
||||||
|
_gl.ColorMask(red, green, blue, alpha);
|
||||||
|
|
||||||
|
public void StencilMask(uint mask) => _gl.StencilMask(mask);
|
||||||
|
|
||||||
|
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
|
||||||
|
|
||||||
|
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
|
||||||
|
|
||||||
|
public void CullFace(TriangleFace face) => _gl.CullFace(face);
|
||||||
|
|
||||||
|
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
|
||||||
|
|
||||||
|
public void UseProgram(uint program) => _gl.UseProgram(program);
|
||||||
|
|
||||||
|
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
|
||||||
|
|
||||||
|
public void BindBuffer(BufferTargetARB target, uint buffer) =>
|
||||||
|
_gl.BindBuffer(target, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores the frame-global OpenGL convention shared by frame clear and
|
||||||
|
/// exceptional world-pass rollback. This prevents a failed doorway scissor or
|
||||||
|
/// portal depth mask from clipping/color-masking the next frame's clear.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RenderFrameGlStateController : IRenderFrameGlState
|
||||||
|
{
|
||||||
|
private readonly IRenderFrameGlStateApi _gl;
|
||||||
|
|
||||||
|
public RenderFrameGlStateController(IRenderFrameGlStateApi gl)
|
||||||
|
{
|
||||||
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RestoreFrameDefaults()
|
||||||
|
{
|
||||||
|
_gl.SetCapability(EnableCap.ScissorTest, enabled: false);
|
||||||
|
_gl.SetCapability(EnableCap.StencilTest, enabled: false);
|
||||||
|
_gl.SetCapability(EnableCap.Blend, enabled: false);
|
||||||
|
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
|
||||||
|
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
|
||||||
|
{
|
||||||
|
_gl.SetCapability(
|
||||||
|
EnableCap.ClipDistance0 + index,
|
||||||
|
enabled: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_gl.ColorMask(true, true, true, true);
|
||||||
|
_gl.StencilMask(0xFF);
|
||||||
|
_gl.DepthMask(true);
|
||||||
|
_gl.DepthFunc(DepthFunction.Less);
|
||||||
|
_gl.SetCapability(EnableCap.DepthTest, enabled: true);
|
||||||
|
// Renderers that need face culling establish it locally. The shared
|
||||||
|
// frame convention is culling off; SkyRenderer restores the state it
|
||||||
|
// observed, so enabling it here would leak culling into later passes.
|
||||||
|
_gl.SetCapability(EnableCap.CullFace, enabled: false);
|
||||||
|
_gl.CullFace(TriangleFace.Back);
|
||||||
|
_gl.FrontFace(FrontFaceDirection.CW);
|
||||||
|
_gl.UseProgram(0);
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,11 @@ internal interface IRenderFrameClearPhase
|
||||||
RenderFrameFoundation Clear();
|
RenderFrameFoundation Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal interface IRenderFrameFoundationSource
|
||||||
|
{
|
||||||
|
RenderFrameFoundation Foundation { get; }
|
||||||
|
}
|
||||||
|
|
||||||
internal interface IRenderFrameLivePreparation
|
internal interface IRenderFrameLivePreparation
|
||||||
{
|
{
|
||||||
void Prepare(int gpuSlot);
|
void Prepare(int gpuSlot);
|
||||||
|
|
@ -43,7 +48,9 @@ internal interface IRenderFrameLivePreparation
|
||||||
/// typed phases preserve resource begin, clear/state, then live publication
|
/// typed phases preserve resource begin, clear/state, then live publication
|
||||||
/// order without exposing a renderer service bag.
|
/// order without exposing a renderer service bag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase
|
internal sealed class RenderFrameResourceController :
|
||||||
|
IRenderFrameResourcePhase,
|
||||||
|
IRenderFrameFoundationSource
|
||||||
{
|
{
|
||||||
private readonly IRenderFrameSlotSource _frameSlots;
|
private readonly IRenderFrameSlotSource _frameSlots;
|
||||||
private readonly IRenderFrameBeginResources _resources;
|
private readonly IRenderFrameBeginResources _resources;
|
||||||
|
|
@ -162,6 +169,7 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
||||||
private readonly IRenderFramePortalStateSource _portal;
|
private readonly IRenderFramePortalStateSource _portal;
|
||||||
private readonly ParticleVisibilityController _particleVisibility;
|
private readonly ParticleVisibilityController _particleVisibility;
|
||||||
private readonly WorldRenderDiagnostics _diagnostics;
|
private readonly WorldRenderDiagnostics _diagnostics;
|
||||||
|
private readonly IRenderFrameGlState _frameGlState;
|
||||||
|
|
||||||
public RuntimeRenderFrameClearPhase(
|
public RuntimeRenderFrameClearPhase(
|
||||||
GL gl,
|
GL gl,
|
||||||
|
|
@ -169,7 +177,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
||||||
WeatherSystem weather,
|
WeatherSystem weather,
|
||||||
IRenderFramePortalStateSource portal,
|
IRenderFramePortalStateSource portal,
|
||||||
ParticleVisibilityController particleVisibility,
|
ParticleVisibilityController particleVisibility,
|
||||||
WorldRenderDiagnostics diagnostics)
|
WorldRenderDiagnostics diagnostics,
|
||||||
|
IRenderFrameGlState frameGlState)
|
||||||
{
|
{
|
||||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||||
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||||
|
|
@ -178,6 +187,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
||||||
_particleVisibility = particleVisibility
|
_particleVisibility = particleVisibility
|
||||||
?? throw new ArgumentNullException(nameof(particleVisibility));
|
?? throw new ArgumentNullException(nameof(particleVisibility));
|
||||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
_frameGlState = frameGlState
|
||||||
|
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RenderFrameFoundation Clear()
|
public RenderFrameFoundation Clear()
|
||||||
|
|
@ -203,13 +214,11 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
||||||
1f);
|
1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
_gl.DepthMask(true);
|
_frameGlState.RestoreFrameDefaults();
|
||||||
_gl.Clear(
|
_gl.Clear(
|
||||||
ClearBufferMask.ColorBufferBit
|
ClearBufferMask.ColorBufferBit
|
||||||
| ClearBufferMask.DepthBufferBit
|
| ClearBufferMask.DepthBufferBit
|
||||||
| ClearBufferMask.StencilBufferBit);
|
| ClearBufferMask.StencilBufferBit);
|
||||||
_gl.CullFace(TriangleFace.Back);
|
|
||||||
_gl.FrontFace(FrontFaceDirection.CW);
|
|
||||||
_diagnostics.EmitGlStateTripwireIfChanged(
|
_diagnostics.EmitGlStateTripwireIfChanged(
|
||||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
@ -56,7 +57,16 @@ internal readonly record struct RetailAlphaSubmission(
|
||||||
/// material/renderer sort.</item>
|
/// material/renderer sort.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailAlphaQueue
|
internal interface IWorldSceneAlphaFrame
|
||||||
|
{
|
||||||
|
void BeginFrame();
|
||||||
|
|
||||||
|
void EndFrame();
|
||||||
|
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
|
||||||
{
|
{
|
||||||
private readonly List<RetailAlphaSubmission> _submissions = new(256);
|
private readonly List<RetailAlphaSubmission> _submissions = new(256);
|
||||||
private readonly List<IRetailAlphaDrawSource> _sources = new(4);
|
private readonly List<IRetailAlphaDrawSource> _sources = new(4);
|
||||||
|
|
@ -108,11 +118,12 @@ internal sealed class RetailAlphaQueue
|
||||||
if (!IsCollecting)
|
if (!IsCollecting)
|
||||||
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
|
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
|
||||||
|
|
||||||
|
Exception? drawFailure = null;
|
||||||
|
List<Exception>? resetFailures = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_submissions.Count == 0)
|
if (_submissions.Count > 0)
|
||||||
return;
|
{
|
||||||
|
|
||||||
SortRetailOrder();
|
SortRetailOrder();
|
||||||
EnsureTokenCapacity(_submissions.Count);
|
EnsureTokenCapacity(_submissions.Count);
|
||||||
EnsureSourceCapacity(_sources.Count);
|
EnsureSourceCapacity(_sources.Count);
|
||||||
|
|
@ -153,13 +164,47 @@ internal sealed class RetailAlphaQueue
|
||||||
start = end;
|
start = end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
drawFailure = error;
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
for (int i = 0; i < _sources.Count; i++)
|
for (int i = 0; i < _sources.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
_sources[i].ResetAlphaSubmissions();
|
_sources[i].ResetAlphaSubmissions();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(resetFailures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
_sources.Clear();
|
_sources.Clear();
|
||||||
_submissions.Clear();
|
_submissions.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (drawFailure is not null)
|
||||||
|
{
|
||||||
|
if (resetFailures is { Count: > 0 })
|
||||||
|
{
|
||||||
|
resetFailures.Insert(0, drawFailure);
|
||||||
|
throw new AggregateException(
|
||||||
|
"Retail alpha drawing failed and its submissions could not be fully reset.",
|
||||||
|
resetFailures);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExceptionDispatchInfo.Capture(drawFailure).Throw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetFailures is { Count: > 0 })
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Retail alpha submissions could not be fully reset.",
|
||||||
|
resetFailures);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EndFrame()
|
public void EndFrame()
|
||||||
|
|
@ -177,6 +222,39 @@ internal sealed class RetailAlphaQueue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discards an incomplete frame without drawing it. Every source is still
|
||||||
|
/// told to release its retained submission payload so the next frame begins
|
||||||
|
/// from the same empty invariant as a successful flush.
|
||||||
|
/// </summary>
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _sources.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sources[i].ResetAlphaSubmissions();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sources.Clear();
|
||||||
|
_submissions.Clear();
|
||||||
|
IsCollecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures is { Count: > 0 })
|
||||||
|
throw new AggregateException("Retail alpha frame abort failed.", failures);
|
||||||
|
}
|
||||||
|
|
||||||
private void SortRetailOrder()
|
private void SortRetailOrder()
|
||||||
{
|
{
|
||||||
int count = _submissions.Count;
|
int count = _submissions.Count;
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,17 @@ internal sealed class RetailPViewParticleClassifications
|
||||||
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
|
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
|
||||||
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
internal interface IOutdoorSceneParticleOwnerSource
|
||||||
|
{
|
||||||
|
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RetailPViewPassExecutor :
|
||||||
|
IRetailPViewPassExecutor,
|
||||||
|
IOutdoorSceneParticleOwnerSource
|
||||||
{
|
{
|
||||||
private readonly GL _gl;
|
private readonly GL _gl;
|
||||||
|
private readonly IRenderFrameGlState _frameGlState;
|
||||||
private readonly IRetailPViewFramebufferSource _framebuffer;
|
private readonly IRetailPViewFramebufferSource _framebuffer;
|
||||||
private readonly ClipFrame _clipFrame;
|
private readonly ClipFrame _clipFrame;
|
||||||
private readonly TerrainModernRenderer? _terrain;
|
private readonly TerrainModernRenderer? _terrain;
|
||||||
|
|
@ -101,6 +109,7 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
||||||
|
|
||||||
public RetailPViewPassExecutor(
|
public RetailPViewPassExecutor(
|
||||||
GL gl,
|
GL gl,
|
||||||
|
IRenderFrameGlState frameGlState,
|
||||||
IRetailPViewFramebufferSource framebuffer,
|
IRetailPViewFramebufferSource framebuffer,
|
||||||
ClipFrame clipFrame,
|
ClipFrame clipFrame,
|
||||||
TerrainModernRenderer? terrain,
|
TerrainModernRenderer? terrain,
|
||||||
|
|
@ -115,6 +124,8 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
||||||
TerrainDrawDiagnosticsController terrainDiagnostics)
|
TerrainDrawDiagnosticsController terrainDiagnostics)
|
||||||
{
|
{
|
||||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||||
|
_frameGlState = frameGlState
|
||||||
|
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||||
_framebuffer = framebuffer
|
_framebuffer = framebuffer
|
||||||
?? throw new ArgumentNullException(nameof(framebuffer));
|
?? throw new ArgumentNullException(nameof(framebuffer));
|
||||||
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
||||||
|
|
@ -136,6 +147,28 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
||||||
_particleClassifications.BeginFrame();
|
_particleClassifications.BeginFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||||
|
TryAbort(_particleClassifications.BeginFrame);
|
||||||
|
TryAbort(_noSceneParticleEntityIds.Clear);
|
||||||
|
if (failures is { Count: > 0 })
|
||||||
|
throw new AggregateException("Retail PView pass abort failed.", failures);
|
||||||
|
|
||||||
|
void TryAbort(Action operation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operation();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ClipFrameAssembly AssembleClipFrame(
|
public ClipFrameAssembly AssembleClipFrame(
|
||||||
PortalVisibilityFrame portalFrame,
|
PortalVisibilityFrame portalFrame,
|
||||||
ClipFrameAssembly reuseAssembly) =>
|
ClipFrameAssembly reuseAssembly) =>
|
||||||
|
|
|
||||||
|
|
@ -889,6 +889,7 @@ public interface IRetailPViewCellSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IRetailPViewPassExecutor
|
public interface IRetailPViewPassExecutor
|
||||||
{
|
{
|
||||||
|
void AbortFrame();
|
||||||
void BeginFrame();
|
void BeginFrame();
|
||||||
ClipFrameAssembly AssembleClipFrame(
|
ClipFrameAssembly AssembleClipFrame(
|
||||||
PortalVisibilityFrame portalFrame,
|
PortalVisibilityFrame portalFrame,
|
||||||
|
|
@ -1118,7 +1119,8 @@ public sealed class RetailPViewFrameInput
|
||||||
public required int RenderRadius { get; init; }
|
public required int RenderRadius { get; init; }
|
||||||
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
IReadOnlyList<WorldEntity> Entities,
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||||||
|
{ get; init; }
|
||||||
|
|
||||||
// Pass-presentation and diagnostic values consumed synchronously by the
|
// Pass-presentation and diagnostic values consumed synchronously by the
|
||||||
// typed executor. This input is data-only and is never retained.
|
// typed executor. This input is data-only and is never retained.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,19 @@ namespace AcDream.App.Rendering.Selection;
|
||||||
/// The renderer builds one frame while input queries the previously completed
|
/// The renderer builds one frame while input queries the previously completed
|
||||||
/// frame, avoiding partial visibility state during multi-slice portal drawing.
|
/// frame, avoiding partial visibility state during multi-slice portal drawing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
|
internal interface IWorldSceneSelectionFrame
|
||||||
|
{
|
||||||
|
void BeginFrame();
|
||||||
|
|
||||||
|
void CompleteFrame();
|
||||||
|
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RetailSelectionScene :
|
||||||
|
IRetailSelectionRenderSink,
|
||||||
|
IRetailSelectionLightingSource,
|
||||||
|
IWorldSceneSelectionFrame
|
||||||
{
|
{
|
||||||
private readonly IRetailSelectionGeometrySource _geometry;
|
private readonly IRetailSelectionGeometrySource _geometry;
|
||||||
private readonly RetailSelectionLightingPulse _lightingPulse;
|
private readonly RetailSelectionLightingPulse _lightingPulse;
|
||||||
|
|
@ -100,6 +112,15 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
(_published, _building) = (_building, _published);
|
(_published, _building) = (_building, _published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Discards the incomplete building frame and preserves the last
|
||||||
|
/// completely published selection product.</summary>
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
_building.Clear();
|
||||||
|
_buildingKeys.Clear();
|
||||||
|
_viewFrustum = null;
|
||||||
|
}
|
||||||
|
|
||||||
public RetailSelectionHit? Pick(
|
public RetailSelectionHit? Pick(
|
||||||
float mouseX,
|
float mouseX,
|
||||||
float mouseY,
|
float mouseY,
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,22 @@ using AcDream.Core.Vfx;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering.Vfx;
|
namespace AcDream.App.Rendering.Vfx;
|
||||||
|
|
||||||
|
internal interface IWorldSceneParticleVisibility
|
||||||
|
{
|
||||||
|
void MarkVisibleCells(HashSet<uint> cellIds);
|
||||||
|
|
||||||
|
void CompleteFrame();
|
||||||
|
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bridges the retained retail PView result into the next physics update's
|
/// Bridges the retained retail PView result into the next physics update's
|
||||||
/// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable
|
/// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable
|
||||||
/// frame meaning: one completed viewer position plus the AC cells admitted by
|
/// frame meaning: one completed viewer position plus the AC cells admitted by
|
||||||
/// that completed view. It neither creates emitters nor performs rendering.
|
/// that completed view. It neither creates emitters nor performs rendering.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ParticleVisibilityController
|
public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
|
||||||
{
|
{
|
||||||
public const float ExtendedRangeMultiplier = 2f;
|
public const float ExtendedRangeMultiplier = 2f;
|
||||||
|
|
||||||
|
|
@ -70,6 +79,15 @@ public sealed class ParticleVisibilityController
|
||||||
_hasCompletedWorldView = true;
|
_hasCompletedWorldView = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Discards the in-progress visibility product while preserving
|
||||||
|
/// the last completed view consumed by the update thread.</summary>
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
_buildingCellIds.Clear();
|
||||||
|
_frameUsesWorldView = false;
|
||||||
|
_frameOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
public void Apply(ParticleSystem particles, float rangeMultiplier)
|
public void Apply(ParticleSystem particles, float rangeMultiplier)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(particles);
|
ArgumentNullException.ThrowIfNull(particles);
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,18 @@ internal readonly record struct WorldRenderFrame(
|
||||||
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
|
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal interface IWorldRenderFrameBuilder
|
||||||
|
{
|
||||||
|
WorldRenderFrame Build(
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
bool waitingForLogin,
|
||||||
|
DayGroupData? activeDayGroup);
|
||||||
|
|
||||||
|
void ObserveDrawableCells(IReadOnlySet<uint> drawableCells);
|
||||||
|
|
||||||
|
void ClearDrawableCells();
|
||||||
|
}
|
||||||
|
|
||||||
internal interface IWorldFrameCameraSource
|
internal interface IWorldFrameCameraSource
|
||||||
{
|
{
|
||||||
WorldCameraFrame Resolve();
|
WorldCameraFrame Resolve();
|
||||||
|
|
@ -113,7 +125,7 @@ internal interface IWorldFrameBuildingSource
|
||||||
/// Orders typed world-frame fact sources without owning their borrowed render
|
/// Orders typed world-frame fact sources without owning their borrowed render
|
||||||
/// resources. It makes no draw decision and retains no result from a prior call.
|
/// resources. It makes no draw decision and retains no result from a prior call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class WorldRenderFrameBuilder
|
internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
|
||||||
{
|
{
|
||||||
private readonly IWorldFrameCameraSource _camera;
|
private readonly IWorldFrameCameraSource _camera;
|
||||||
private readonly IWorldFrameVisibilityPreparation _visibility;
|
private readonly IWorldFrameVisibilityPreparation _visibility;
|
||||||
|
|
|
||||||
274
src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
Normal file
274
src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal readonly record struct WorldSceneDiagnosticOutcome(
|
||||||
|
int VisibleLandblocks,
|
||||||
|
int TotalLandblocks);
|
||||||
|
|
||||||
|
internal interface IWorldSceneDiagnostics
|
||||||
|
{
|
||||||
|
CameraCellResolution CameraCellResolution { get; }
|
||||||
|
|
||||||
|
void EmitPViewInput(
|
||||||
|
PortalVisibilityFrame portalFrame,
|
||||||
|
Matrix4x4 viewProjection,
|
||||||
|
LoadedCell clipRoot,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition);
|
||||||
|
|
||||||
|
void EmitRenderSignature(
|
||||||
|
string branch,
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
LoadedCell? viewerRoot,
|
||||||
|
LoadedCell? playerRoot,
|
||||||
|
uint viewerCellId,
|
||||||
|
uint playerCellId,
|
||||||
|
bool playerIndoorGate,
|
||||||
|
bool cameraInsideCell,
|
||||||
|
bool renderSky,
|
||||||
|
bool drawSkyThisFrame,
|
||||||
|
bool terrainDrawn,
|
||||||
|
TerrainClipMode terrainClipMode,
|
||||||
|
bool skyDrawn,
|
||||||
|
bool depthClear,
|
||||||
|
bool outdoorSceneryDrawn,
|
||||||
|
int liveDynamicDrawnCount,
|
||||||
|
string sceneParticles,
|
||||||
|
RetailPViewFrameResult? pviewResult,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition);
|
||||||
|
|
||||||
|
WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns world-pass probes, collision wireframes, and the DebugVM facts derived
|
||||||
|
/// from one completed normal-world draw. None of these diagnostics influence
|
||||||
|
/// visibility or pass ordering.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||||
|
{
|
||||||
|
private readonly WorldRenderDiagnostics _diagnostics;
|
||||||
|
private readonly IWorldScenePViewDiagnosticSource _pview;
|
||||||
|
private readonly IWorldSceneDebugStateSource _state;
|
||||||
|
private readonly DebugLineRenderer? _lines;
|
||||||
|
private readonly PhysicsEngine _physics;
|
||||||
|
private readonly ILocalPlayerModeSource _mode;
|
||||||
|
private readonly ILocalPlayerControllerSource _player;
|
||||||
|
private readonly DebugVmRenderFactsPublisher _debugVm;
|
||||||
|
private readonly bool _debugVmConsumerActive;
|
||||||
|
private int _debugDrawLogCount;
|
||||||
|
|
||||||
|
public WorldSceneDiagnosticsController(
|
||||||
|
WorldRenderDiagnostics diagnostics,
|
||||||
|
IWorldScenePViewDiagnosticSource pview,
|
||||||
|
IWorldSceneDebugStateSource state,
|
||||||
|
DebugLineRenderer? lines,
|
||||||
|
PhysicsEngine physics,
|
||||||
|
ILocalPlayerModeSource mode,
|
||||||
|
ILocalPlayerControllerSource player,
|
||||||
|
DebugVmRenderFactsPublisher debugVm,
|
||||||
|
bool debugVmConsumerActive)
|
||||||
|
{
|
||||||
|
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
||||||
|
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||||
|
_lines = lines;
|
||||||
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||||
|
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||||
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||||
|
_debugVm = debugVm ?? throw new ArgumentNullException(nameof(debugVm));
|
||||||
|
_debugVmConsumerActive = debugVmConsumerActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
|
||||||
|
|
||||||
|
public void EmitPViewInput(
|
||||||
|
PortalVisibilityFrame portalFrame,
|
||||||
|
Matrix4x4 viewProjection,
|
||||||
|
LoadedCell clipRoot,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition)
|
||||||
|
{
|
||||||
|
if (!RenderingDiagnostics.ProbePvInputEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_diagnostics.EmitPViewInput(
|
||||||
|
enabled: true,
|
||||||
|
portalFrame,
|
||||||
|
viewProjection,
|
||||||
|
clipRoot.IsOutdoorNode,
|
||||||
|
cameraPosition,
|
||||||
|
playerPosition,
|
||||||
|
_pview.RawPlayerPositionOr(playerPosition),
|
||||||
|
_pview.PlayerYaw,
|
||||||
|
_pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EmitRenderSignature(
|
||||||
|
string branch,
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
LoadedCell? viewerRoot,
|
||||||
|
LoadedCell? playerRoot,
|
||||||
|
uint viewerCellId,
|
||||||
|
uint playerCellId,
|
||||||
|
bool playerIndoorGate,
|
||||||
|
bool cameraInsideCell,
|
||||||
|
bool renderSky,
|
||||||
|
bool drawSkyThisFrame,
|
||||||
|
bool terrainDrawn,
|
||||||
|
TerrainClipMode terrainClipMode,
|
||||||
|
bool skyDrawn,
|
||||||
|
bool depthClear,
|
||||||
|
bool outdoorSceneryDrawn,
|
||||||
|
int liveDynamicDrawnCount,
|
||||||
|
string sceneParticles,
|
||||||
|
RetailPViewFrameResult? pviewResult,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition)
|
||||||
|
{
|
||||||
|
_diagnostics.EmitRenderSignatureIfChanged(
|
||||||
|
RenderingDiagnostics.ProbeFlapEnabled,
|
||||||
|
branch,
|
||||||
|
clipRoot,
|
||||||
|
viewerRoot,
|
||||||
|
playerRoot,
|
||||||
|
viewerCellId,
|
||||||
|
playerCellId,
|
||||||
|
playerIndoorGate,
|
||||||
|
cameraInsideCell,
|
||||||
|
renderSky,
|
||||||
|
drawSkyThisFrame,
|
||||||
|
terrainDrawn,
|
||||||
|
terrainClipMode,
|
||||||
|
skyDrawn,
|
||||||
|
depthClear,
|
||||||
|
outdoorSceneryDrawn,
|
||||||
|
outdoorPortalDrawn: false,
|
||||||
|
outdoorRootObjectCount: 0,
|
||||||
|
liveDynamicDrawnCount,
|
||||||
|
sceneParticles,
|
||||||
|
pviewResult?.PortalFrame,
|
||||||
|
pviewResult?.ClipAssembly,
|
||||||
|
pviewResult?.DrawableCells,
|
||||||
|
pviewResult?.Partition,
|
||||||
|
exteriorPortalFrame: null,
|
||||||
|
exteriorClipAssembly: null,
|
||||||
|
exteriorDrawableCells: null,
|
||||||
|
exteriorPartition: null,
|
||||||
|
cameraPosition,
|
||||||
|
playerPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bounds);
|
||||||
|
DrawCollisionWireframes(in camera);
|
||||||
|
|
||||||
|
int visible = 0;
|
||||||
|
int total = 0;
|
||||||
|
foreach (var entry in bounds)
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (FrustumCuller.IsAabbVisible(
|
||||||
|
camera.Frustum,
|
||||||
|
entry.AabbMin,
|
||||||
|
entry.AabbMax))
|
||||||
|
{
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 nearestOrigin =
|
||||||
|
_mode.IsPlayerMode && _player.Controller is { } controller
|
||||||
|
? controller.Position
|
||||||
|
: camera.Position;
|
||||||
|
_debugVm.PublishDebugVmFacts(
|
||||||
|
_debugVmConsumerActive,
|
||||||
|
visible,
|
||||||
|
total,
|
||||||
|
nearestOrigin,
|
||||||
|
_physics.ShadowObjects.AllEntriesForDebug());
|
||||||
|
return new WorldSceneDiagnosticOutcome(visible, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCollisionWireframes(in WorldCameraFrame camera)
|
||||||
|
{
|
||||||
|
if (!_state.CollisionWireframesVisible || _lines is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lines.Begin();
|
||||||
|
int drawn = 0;
|
||||||
|
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
||||||
|
{
|
||||||
|
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
|
||||||
|
{
|
||||||
|
float height = shadow.CylHeight > 0f
|
||||||
|
? shadow.CylHeight
|
||||||
|
: shadow.Radius * 2f;
|
||||||
|
_lines.AddCylinder(
|
||||||
|
shadow.Position,
|
||||||
|
shadow.Radius,
|
||||||
|
height,
|
||||||
|
new Vector3(0f, 1f, 0f));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lines.AddCylinder(
|
||||||
|
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
|
||||||
|
shadow.Radius,
|
||||||
|
shadow.Radius * 2f,
|
||||||
|
new Vector3(1f, 0.5f, 0f));
|
||||||
|
}
|
||||||
|
drawn++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mode.IsPlayerMode && _player.Controller is { } localPlayer)
|
||||||
|
{
|
||||||
|
Vector3 position = localPlayer.Position;
|
||||||
|
_lines.AddCylinder(
|
||||||
|
new Vector3(position.X, position.Y, position.Z),
|
||||||
|
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
|
||||||
|
1.8f,
|
||||||
|
new Vector3(1f, 0f, 0f));
|
||||||
|
LogNearbyCollisionObjects(position, drawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lines.Flush(camera.Camera.View, camera.Projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
|
||||||
|
{
|
||||||
|
if (_debugDrawLogCount >= 5)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"debug frame {_debugDrawLogCount}: player=({playerPosition.X:F1},{playerPosition.Y:F1},{playerPosition.Z:F1}) drew={drawn} "
|
||||||
|
+ $"totalReg={_physics.ShadowObjects.TotalRegistered}");
|
||||||
|
int logged = 0;
|
||||||
|
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
||||||
|
{
|
||||||
|
float dx = shadow.Position.X - playerPosition.X;
|
||||||
|
float dy = shadow.Position.Y - playerPosition.Y;
|
||||||
|
float horizontalDistance = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (horizontalDistance >= 10f)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$" near id=0x{shadow.EntityId:X8} type={shadow.CollisionType} "
|
||||||
|
+ $"pos=({shadow.Position.X:F1},{shadow.Position.Y:F1},{shadow.Position.Z:F1}) "
|
||||||
|
+ $"r={shadow.Radius:F2} h={shadow.CylHeight:F2} dh={horizontalDistance:F2}");
|
||||||
|
if (++logged >= 5)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_debugDrawLogCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/AcDream.App/Rendering/WorldScenePassExecutor.cs
Normal file
328
src/AcDream.App/Rendering/WorldScenePassExecutor.cs
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
using AcDream.App.Rendering.Sky;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using AcDream.Core.Vfx;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal interface IWorldScenePassExecutor
|
||||||
|
{
|
||||||
|
HashSet<uint>? TerrainVisibleCellIds { get; }
|
||||||
|
|
||||||
|
void BeginFrame();
|
||||||
|
|
||||||
|
void PrepareFlatWorldClip();
|
||||||
|
|
||||||
|
void DrawFlatSky(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction);
|
||||||
|
|
||||||
|
void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId);
|
||||||
|
|
||||||
|
void DrawFlatEntities(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
||||||
|
System.Numerics.Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
||||||
|
uint? playerLandblockId,
|
||||||
|
HashSet<uint> animatedEntityIds);
|
||||||
|
|
||||||
|
string DrawPostWorldParticles(
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
ClipFrameAssembly? clipAssembly,
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IReadOnlySet<uint> outdoorOwnerIds,
|
||||||
|
string currentSignature);
|
||||||
|
|
||||||
|
void DrawFlatWeather(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction);
|
||||||
|
|
||||||
|
void DisableClipDistances();
|
||||||
|
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete GL leaf for the flat-world safety path and the post-world particle
|
||||||
|
/// and weather passes. Retail PView frames remain owned by
|
||||||
|
/// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly IRenderFrameGlState _frameGlState;
|
||||||
|
private readonly ClipFrame _clipFrame;
|
||||||
|
private readonly WbDrawDispatcher _entities;
|
||||||
|
private readonly EnvCellRenderer _environmentCells;
|
||||||
|
private readonly TerrainModernRenderer? _terrain;
|
||||||
|
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||||
|
private readonly SkyRenderer? _sky;
|
||||||
|
private readonly ParticleSystem? _particles;
|
||||||
|
private readonly ParticleRenderer? _particleRenderer;
|
||||||
|
private readonly HashSet<uint> _visibleParticleOwners = [];
|
||||||
|
private readonly HashSet<uint> _noExcludedParticleOwners = [];
|
||||||
|
|
||||||
|
public WorldScenePassExecutor(
|
||||||
|
GL gl,
|
||||||
|
IRenderFrameGlState frameGlState,
|
||||||
|
ClipFrame clipFrame,
|
||||||
|
WbDrawDispatcher entities,
|
||||||
|
EnvCellRenderer environmentCells,
|
||||||
|
TerrainModernRenderer? terrain,
|
||||||
|
TerrainDrawDiagnosticsController terrainDiagnostics,
|
||||||
|
SkyRenderer? sky,
|
||||||
|
ParticleSystem? particles,
|
||||||
|
ParticleRenderer? particleRenderer)
|
||||||
|
{
|
||||||
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||||
|
_frameGlState = frameGlState
|
||||||
|
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||||
|
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
||||||
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||||
|
_environmentCells = environmentCells
|
||||||
|
?? throw new ArgumentNullException(nameof(environmentCells));
|
||||||
|
_terrain = terrain;
|
||||||
|
_terrainDiagnostics = terrainDiagnostics
|
||||||
|
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
||||||
|
_sky = sky;
|
||||||
|
_particles = particles;
|
||||||
|
_particleRenderer = particleRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
|
||||||
|
|
||||||
|
public void BeginFrame()
|
||||||
|
{
|
||||||
|
_visibleParticleOwners.Clear();
|
||||||
|
_clipFrame.Reset();
|
||||||
|
_entities.ClearClipRouting();
|
||||||
|
_environmentCells.SetClipRouting(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PrepareFlatWorldClip()
|
||||||
|
{
|
||||||
|
_clipFrame.ReserveTerrainUploads(_gl, 1);
|
||||||
|
_clipFrame.UploadRegions(_gl);
|
||||||
|
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl);
|
||||||
|
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||||
|
_environmentCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||||
|
_terrain?.SetClipUbo(terrainBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatSky(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction)
|
||||||
|
{
|
||||||
|
_clipFrame.BindTerrainClip(_gl);
|
||||||
|
EnableClipDistances();
|
||||||
|
Exception? drawFailure = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sky?.RenderSky(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
dayFraction,
|
||||||
|
activeDayGroup,
|
||||||
|
foundation.Sky,
|
||||||
|
foundation.EnvironOverrideActive);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
drawFailure = error;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DisableClipDistances();
|
||||||
|
}
|
||||||
|
catch (Exception closeFailure) when (drawFailure is not null)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Sky drawing failed and its clip-distance bracket could not be closed.",
|
||||||
|
drawFailure,
|
||||||
|
closeFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_particles is not null && _particleRenderer is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.Draw(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
ParticleRenderPass.SkyPreScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatTerrain(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
uint? playerLandblockId)
|
||||||
|
{
|
||||||
|
EnableClipDistances();
|
||||||
|
_terrainDiagnostics.Begin();
|
||||||
|
_terrain?.Draw(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Frustum,
|
||||||
|
neverCullLandblockId: playerLandblockId);
|
||||||
|
_terrainDiagnostics.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatEntities(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
||||||
|
System.Numerics.Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
||||||
|
uint? playerLandblockId,
|
||||||
|
HashSet<uint> animatedEntityIds) =>
|
||||||
|
_entities.Draw(
|
||||||
|
camera.Camera,
|
||||||
|
entries,
|
||||||
|
camera.Frustum,
|
||||||
|
neverCullLandblockId: playerLandblockId,
|
||||||
|
visibleCellIds: null,
|
||||||
|
animatedEntityIds: animatedEntityIds);
|
||||||
|
|
||||||
|
public string DrawPostWorldParticles(
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
ClipFrameAssembly? clipAssembly,
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IReadOnlySet<uint> outdoorOwnerIds,
|
||||||
|
string currentSignature)
|
||||||
|
{
|
||||||
|
if (_particles is null || _particleRenderer is null)
|
||||||
|
return currentSignature;
|
||||||
|
|
||||||
|
if (clipRoot is null)
|
||||||
|
{
|
||||||
|
if (clipAssembly is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.DrawForOwners(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
ParticleRenderPass.Scene,
|
||||||
|
_visibleParticleOwners,
|
||||||
|
includeUnattached: true,
|
||||||
|
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
||||||
|
return AppendSignature(currentSignature, "filtered");
|
||||||
|
}
|
||||||
|
|
||||||
|
_particleRenderer.Draw(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
ParticleRenderPass.Scene);
|
||||||
|
return AppendSignature(currentSignature, "global");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipRoot.IsOutdoorNode)
|
||||||
|
{
|
||||||
|
_particleRenderer.DrawForOwners(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
ParticleRenderPass.Scene,
|
||||||
|
outdoorOwnerIds,
|
||||||
|
includeUnattached: true);
|
||||||
|
return AppendSignature(currentSignature, "unattached");
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatWeather(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction)
|
||||||
|
{
|
||||||
|
_clipFrame.BindTerrainClip(_gl);
|
||||||
|
EnableClipDistances();
|
||||||
|
Exception? drawFailure = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sky?.RenderWeather(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
dayFraction,
|
||||||
|
activeDayGroup,
|
||||||
|
foundation.Sky,
|
||||||
|
foundation.EnvironOverrideActive);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
drawFailure = error;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DisableClipDistances();
|
||||||
|
}
|
||||||
|
catch (Exception closeFailure) when (drawFailure is not null)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Weather drawing failed and its clip-distance bracket could not be closed.",
|
||||||
|
drawFailure,
|
||||||
|
closeFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_particles is not null && _particleRenderer is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.Draw(
|
||||||
|
camera.Camera,
|
||||||
|
camera.Position,
|
||||||
|
ParticleRenderPass.SkyPostScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DisableClipDistances()
|
||||||
|
{
|
||||||
|
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
|
||||||
|
_gl.Disable(EnableCap.ClipDistance0 + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||||
|
TryAbort(_clipFrame.Reset);
|
||||||
|
TryAbort(_entities.ClearClipRouting);
|
||||||
|
TryAbort(() => _environmentCells.SetClipRouting(null));
|
||||||
|
_visibleParticleOwners.Clear();
|
||||||
|
if (failures is { Count: > 0 })
|
||||||
|
throw new AggregateException("World scene pass abort failed.", failures);
|
||||||
|
|
||||||
|
void TryAbort(Action operation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operation();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnableClipDistances()
|
||||||
|
{
|
||||||
|
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
|
||||||
|
_gl.Enable(EnableCap.ClipDistance0 + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AppendSignature(string current, string value) =>
|
||||||
|
current == "none" ? value : current + "+" + value;
|
||||||
|
}
|
||||||
345
src/AcDream.App/Rendering/WorldSceneRenderer.cs
Normal file
345
src/AcDream.App/Rendering/WorldSceneRenderer.cs
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
using AcDream.App.Rendering.Selection;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal interface IWorldScenePViewRenderer
|
||||||
|
{
|
||||||
|
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
||||||
|
|
||||||
|
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
|
||||||
|
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
||||||
|
{
|
||||||
|
private readonly RetailPViewRenderer _renderer;
|
||||||
|
private readonly IRetailPViewPassExecutor _passes;
|
||||||
|
private readonly IOutdoorSceneParticleOwnerSource _particles;
|
||||||
|
|
||||||
|
public WorldScenePViewRenderer(
|
||||||
|
RetailPViewRenderer renderer,
|
||||||
|
IRetailPViewPassExecutor passes,
|
||||||
|
IOutdoorSceneParticleOwnerSource particles)
|
||||||
|
{
|
||||||
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||||
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
|
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
|
||||||
|
_particles.OutdoorSceneParticleEntityIds;
|
||||||
|
|
||||||
|
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) =>
|
||||||
|
_renderer.DrawInside(input, _passes);
|
||||||
|
|
||||||
|
public void AbortFrame() => _passes.AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the normal-world render transaction between shared frame-resource
|
||||||
|
/// preparation and private presentation. It preserves retail's one PView
|
||||||
|
/// decision: a resolved viewer root uses DrawInside; only an unresolved root
|
||||||
|
/// uses the flat safety path.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
|
||||||
|
{
|
||||||
|
private readonly IRenderFrameFoundationSource _foundation;
|
||||||
|
private readonly IRenderLoginStateSource _login;
|
||||||
|
private readonly IWorldSceneSkyStateSource _sky;
|
||||||
|
private readonly IWorldRenderFrameBuilder _frames;
|
||||||
|
private readonly IWorldSceneEntitySource _entities;
|
||||||
|
private readonly IWorldSceneSelectionFrame? _selection;
|
||||||
|
private readonly IWorldSceneAlphaFrame _alpha;
|
||||||
|
private readonly IWorldSceneParticleVisibility _particleVisibility;
|
||||||
|
private readonly IWorldScenePViewRenderer _pview;
|
||||||
|
private readonly IRetailPViewCellSource _pviewCells;
|
||||||
|
private readonly IWorldScenePassExecutor _passes;
|
||||||
|
private readonly IWorldRenderRangeSource _renderRange;
|
||||||
|
private readonly IWorldSceneDiagnostics _diagnostics;
|
||||||
|
|
||||||
|
public WorldSceneRenderer(
|
||||||
|
IRenderFrameFoundationSource foundation,
|
||||||
|
IRenderLoginStateSource login,
|
||||||
|
IWorldSceneSkyStateSource sky,
|
||||||
|
IWorldRenderFrameBuilder frames,
|
||||||
|
IWorldSceneEntitySource entities,
|
||||||
|
IWorldSceneSelectionFrame? selection,
|
||||||
|
IWorldSceneAlphaFrame alpha,
|
||||||
|
IWorldSceneParticleVisibility particleVisibility,
|
||||||
|
IWorldScenePViewRenderer pview,
|
||||||
|
IRetailPViewCellSource pviewCells,
|
||||||
|
IWorldScenePassExecutor passes,
|
||||||
|
IWorldRenderRangeSource renderRange,
|
||||||
|
IWorldSceneDiagnostics diagnostics)
|
||||||
|
{
|
||||||
|
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
|
||||||
|
_login = login ?? throw new ArgumentNullException(nameof(login));
|
||||||
|
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||||
|
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||||||
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||||
|
_selection = selection;
|
||||||
|
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
||||||
|
_particleVisibility = particleVisibility
|
||||||
|
?? throw new ArgumentNullException(nameof(particleVisibility));
|
||||||
|
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
||||||
|
_pviewCells = pviewCells ?? throw new ArgumentNullException(nameof(pviewCells));
|
||||||
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
|
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
||||||
|
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||||
|
{
|
||||||
|
_ = input;
|
||||||
|
bool selectionFrameStarted = _selection is not null;
|
||||||
|
bool worldFrameStarted = false;
|
||||||
|
bool pviewFrameStarted = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_selection?.BeginFrame();
|
||||||
|
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||||
|
if (foundation.PortalViewportVisible)
|
||||||
|
{
|
||||||
|
_selection?.CompleteFrame();
|
||||||
|
selectionFrameStarted = false;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set before BeginFrame so an already-poisoned queue is recovered by
|
||||||
|
// the same abort path instead of making every later frame fail.
|
||||||
|
worldFrameStarted = true;
|
||||||
|
_alpha.BeginFrame();
|
||||||
|
WorldRenderFrame world = _frames.Build(
|
||||||
|
in foundation,
|
||||||
|
_login.IsWaitingForLogin,
|
||||||
|
_sky.ActiveDayGroup);
|
||||||
|
_passes.BeginFrame();
|
||||||
|
|
||||||
|
WorldCameraFrame camera = world.Camera;
|
||||||
|
WorldRootFrame roots = world.Roots;
|
||||||
|
LoadedCell? clipRoot = world.ClipRoot;
|
||||||
|
bool renderSky = roots.RenderSky;
|
||||||
|
bool drawSkyThisFrame = false;
|
||||||
|
bool terrainDrawn = false;
|
||||||
|
bool skyDrawn = false;
|
||||||
|
bool depthClear = false;
|
||||||
|
bool outdoorSceneryDrawn = false;
|
||||||
|
int liveDynamicDrawnCount = 0;
|
||||||
|
string sceneParticles = "none";
|
||||||
|
TerrainClipMode terrainClipMode = TerrainClipMode.Planes;
|
||||||
|
RetailPViewFrameResult? pviewResult = null;
|
||||||
|
|
||||||
|
if (clipRoot is null)
|
||||||
|
{
|
||||||
|
_passes.PrepareFlatWorldClip();
|
||||||
|
drawSkyThisFrame = renderSky;
|
||||||
|
skyDrawn = drawSkyThisFrame;
|
||||||
|
if (drawSkyThisFrame)
|
||||||
|
{
|
||||||
|
_passes.DrawFlatSky(
|
||||||
|
in camera,
|
||||||
|
in foundation,
|
||||||
|
_sky.ActiveDayGroup,
|
||||||
|
_sky.DayFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retail keeps the live sky during EnterWorld while suppressing
|
||||||
|
// terrain and object geometry until chase mode has engaged.
|
||||||
|
if (_login.IsWaitingForLogin)
|
||||||
|
return CompleteSkippedWorld();
|
||||||
|
|
||||||
|
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||||
|
terrainDrawn = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
terrainClipMode = TerrainClipMode.Skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipRoot is not null)
|
||||||
|
{
|
||||||
|
pviewFrameStarted = true;
|
||||||
|
pviewResult = _pview.DrawInside(
|
||||||
|
new RetailPViewFrameInput
|
||||||
|
{
|
||||||
|
RootCell = clipRoot,
|
||||||
|
NearbyBuildingCells = world.Buildings.NearbyBuildingCells,
|
||||||
|
ViewerEyePos = roots.ViewerEyePosition,
|
||||||
|
ViewProjection = camera.ViewProjection,
|
||||||
|
Cells = _pviewCells,
|
||||||
|
Camera = camera.Camera,
|
||||||
|
CameraWorldPosition = camera.Position,
|
||||||
|
Frustum = camera.Frustum,
|
||||||
|
PlayerLandblockId = roots.PlayerLandblockId,
|
||||||
|
AnimatedEntityIds = world.AnimatedEntityIds,
|
||||||
|
RenderCenterLbX = roots.RenderCenterLandblockX,
|
||||||
|
RenderCenterLbY = roots.RenderCenterLandblockY,
|
||||||
|
RenderRadius = _renderRange.NearRadius,
|
||||||
|
LandblockEntries = _entities.LandblockEntries,
|
||||||
|
RenderSky = renderSky,
|
||||||
|
RenderWeather = roots.PlayerSeenOutside,
|
||||||
|
DayFraction = _sky.DayFraction,
|
||||||
|
ActiveDayGroup = _sky.ActiveDayGroup,
|
||||||
|
SkyKeyframe = foundation.Sky,
|
||||||
|
EnvironOverrideActive = foundation.EnvironOverrideActive,
|
||||||
|
ViewerCellId = roots.ViewerCellId,
|
||||||
|
PlayerCellId = roots.PlayerCellId,
|
||||||
|
PlayerViewPosition = roots.PlayerViewPosition,
|
||||||
|
CameraView = camera.Camera.View,
|
||||||
|
CameraCellResolution = _diagnostics.CameraCellResolution,
|
||||||
|
});
|
||||||
|
|
||||||
|
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
|
||||||
|
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
|
||||||
|
_diagnostics.EmitPViewInput(
|
||||||
|
pviewResult.PortalFrame,
|
||||||
|
camera.ViewProjection,
|
||||||
|
clipRoot,
|
||||||
|
camera.Position,
|
||||||
|
roots.PlayerViewPosition);
|
||||||
|
|
||||||
|
bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
||||||
|
terrainDrawn = hasOutsideSlice;
|
||||||
|
skyDrawn = renderSky && hasOutsideSlice;
|
||||||
|
// This mirrors the established diagnostic meaning, including
|
||||||
|
// outdoor roots whose PView executor does not issue an interior
|
||||||
|
// depth clear.
|
||||||
|
depthClear = hasOutsideSlice;
|
||||||
|
if (pviewResult.Partition.ByCell.Count > 0 || hasOutsideSlice)
|
||||||
|
sceneParticles = "pviewScoped";
|
||||||
|
outdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0
|
||||||
|
&& hasOutsideSlice;
|
||||||
|
liveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_passes.DrawFlatEntities(
|
||||||
|
in camera,
|
||||||
|
_entities.LandblockEntries,
|
||||||
|
roots.PlayerLandblockId,
|
||||||
|
world.AnimatedEntityIds);
|
||||||
|
_frames.ClearDrawableCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
_passes.DisableClipDistances();
|
||||||
|
sceneParticles = _passes.DrawPostWorldParticles(
|
||||||
|
clipRoot,
|
||||||
|
pviewResult?.ClipAssembly,
|
||||||
|
in camera,
|
||||||
|
_pview.OutdoorSceneParticleEntityIds,
|
||||||
|
sceneParticles);
|
||||||
|
|
||||||
|
if (clipRoot is null && drawSkyThisFrame)
|
||||||
|
{
|
||||||
|
skyDrawn = true;
|
||||||
|
_passes.DrawFlatWeather(
|
||||||
|
in camera,
|
||||||
|
in foundation,
|
||||||
|
_sky.ActiveDayGroup,
|
||||||
|
_sky.DayFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnostics.EmitRenderSignature(
|
||||||
|
clipRoot is null ? "OutdoorRoot" : "RetailPViewInside",
|
||||||
|
clipRoot,
|
||||||
|
roots.ViewerRoot,
|
||||||
|
roots.PlayerRoot,
|
||||||
|
roots.ViewerCellId,
|
||||||
|
roots.PlayerCellId,
|
||||||
|
roots.PlayerIndoorGate,
|
||||||
|
roots.CameraInsideCell,
|
||||||
|
renderSky,
|
||||||
|
drawSkyThisFrame,
|
||||||
|
terrainDrawn,
|
||||||
|
terrainClipMode,
|
||||||
|
skyDrawn,
|
||||||
|
depthClear,
|
||||||
|
outdoorSceneryDrawn,
|
||||||
|
liveDynamicDrawnCount,
|
||||||
|
sceneParticles,
|
||||||
|
pviewResult,
|
||||||
|
camera.Position,
|
||||||
|
roots.PlayerViewPosition);
|
||||||
|
|
||||||
|
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
|
||||||
|
in camera,
|
||||||
|
_entities.LandblockBounds);
|
||||||
|
CompleteWorldFrame();
|
||||||
|
worldFrameStarted = false;
|
||||||
|
pviewFrameStarted = false;
|
||||||
|
_selection?.CompleteFrame();
|
||||||
|
selectionFrameStarted = false;
|
||||||
|
return new WorldRenderFrameOutcome(
|
||||||
|
diagnostic.VisibleLandblocks,
|
||||||
|
diagnostic.TotalLandblocks,
|
||||||
|
NormalWorldDrawn: true);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome CompleteSkippedWorld()
|
||||||
|
{
|
||||||
|
CompleteWorldFrame();
|
||||||
|
worldFrameStarted = false;
|
||||||
|
pviewFrameStarted = false;
|
||||||
|
_selection?.CompleteFrame();
|
||||||
|
selectionFrameStarted = false;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception renderFailure)
|
||||||
|
{
|
||||||
|
List<Exception>? abortFailures = AbortFrame(
|
||||||
|
worldFrameStarted,
|
||||||
|
pviewFrameStarted,
|
||||||
|
selectionFrameStarted);
|
||||||
|
if (abortFailures is { Count: > 0 })
|
||||||
|
{
|
||||||
|
abortFailures.Insert(0, renderFailure);
|
||||||
|
throw new AggregateException(
|
||||||
|
"World rendering failed and the incomplete frame could not be fully aborted.",
|
||||||
|
abortFailures);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteWorldFrame()
|
||||||
|
{
|
||||||
|
_alpha.EndFrame();
|
||||||
|
if (_passes.TerrainVisibleCellIds is { } visibleTerrainCells)
|
||||||
|
_particleVisibility.MarkVisibleCells(visibleTerrainCells);
|
||||||
|
_particleVisibility.CompleteFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Exception>? AbortFrame(
|
||||||
|
bool worldFrameStarted,
|
||||||
|
bool pviewFrameStarted,
|
||||||
|
bool selectionFrameStarted)
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
if (worldFrameStarted)
|
||||||
|
{
|
||||||
|
if (pviewFrameStarted)
|
||||||
|
TryAbort(_pview.AbortFrame);
|
||||||
|
TryAbort(_passes.AbortFrame);
|
||||||
|
TryAbort(_particleVisibility.AbortFrame);
|
||||||
|
TryAbort(_alpha.AbortFrame);
|
||||||
|
}
|
||||||
|
if (selectionFrameStarted && _selection is not null)
|
||||||
|
TryAbort(_selection.AbortFrame);
|
||||||
|
return failures;
|
||||||
|
|
||||||
|
void TryAbort(Action abort)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
Normal file
117
src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal interface IWorldSceneSkyStateSource
|
||||||
|
{
|
||||||
|
DayGroupData? ActiveDayGroup { get; }
|
||||||
|
|
||||||
|
float DayFraction { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the selected retail day group beside the world clock that provides
|
||||||
|
/// its current interpolation point. Day-group selection remains an update-time
|
||||||
|
/// concern; render owners receive this read-only seam.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldSceneSkyState : IWorldSceneSkyStateSource
|
||||||
|
{
|
||||||
|
private readonly WorldTimeService _worldTime;
|
||||||
|
|
||||||
|
public WorldSceneSkyState(WorldTimeService worldTime)
|
||||||
|
{
|
||||||
|
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
public DayGroupData? ActiveDayGroup { get; set; }
|
||||||
|
|
||||||
|
public float DayFraction => (float)_worldTime.DayFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IWorldSceneEntitySource
|
||||||
|
{
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||||||
|
{ get; }
|
||||||
|
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
|
||||||
|
{
|
||||||
|
private readonly GpuWorldState _world;
|
||||||
|
|
||||||
|
public RuntimeWorldSceneEntitySource(GpuWorldState world)
|
||||||
|
{
|
||||||
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
|
||||||
|
_world.LandblockEntries;
|
||||||
|
|
||||||
|
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
|
||||||
|
_world.LandblockBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IWorldScenePViewDiagnosticSource
|
||||||
|
{
|
||||||
|
Vector3 RawPlayerPositionOr(Vector3 fallback);
|
||||||
|
|
||||||
|
float PlayerYaw { get; }
|
||||||
|
|
||||||
|
float? SampleTerrainZ(float worldX, float worldY);
|
||||||
|
|
||||||
|
CameraCellResolution CameraCellResolution { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RuntimeWorldScenePViewDiagnosticSource :
|
||||||
|
IWorldScenePViewDiagnosticSource
|
||||||
|
{
|
||||||
|
private readonly ILocalPlayerControllerSource _player;
|
||||||
|
private readonly PhysicsEngine _physics;
|
||||||
|
private readonly CellVisibility _visibility;
|
||||||
|
|
||||||
|
public RuntimeWorldScenePViewDiagnosticSource(
|
||||||
|
ILocalPlayerControllerSource player,
|
||||||
|
PhysicsEngine physics,
|
||||||
|
CellVisibility visibility)
|
||||||
|
{
|
||||||
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||||
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||||
|
_visibility = visibility ?? throw new ArgumentNullException(nameof(visibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 RawPlayerPositionOr(Vector3 fallback) =>
|
||||||
|
_player.Controller?.Position ?? fallback;
|
||||||
|
|
||||||
|
public float PlayerYaw => _player.Controller?.Yaw ?? 0f;
|
||||||
|
|
||||||
|
public float? SampleTerrainZ(float worldX, float worldY) =>
|
||||||
|
_physics.SampleTerrainZ(worldX, worldY);
|
||||||
|
|
||||||
|
public CameraCellResolution CameraCellResolution =>
|
||||||
|
_visibility.LastCameraCellResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IWorldSceneDebugStateSource
|
||||||
|
{
|
||||||
|
bool CollisionWireframesVisible { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WorldSceneDebugState : IWorldSceneDebugStateSource
|
||||||
|
{
|
||||||
|
public bool CollisionWireframesVisible { get; private set; }
|
||||||
|
|
||||||
|
public bool ToggleCollisionWireframes()
|
||||||
|
{
|
||||||
|
CollisionWireframesVisible = !CollisionWireframesVisible;
|
||||||
|
return CollisionWireframesVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
"_renderFrameResources!.Prepare(frameInput);",
|
"_renderFrameResources!.Prepare(frameInput);",
|
||||||
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
||||||
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
||||||
"_retailSelectionScene?.BeginFrame();");
|
"_worldSceneRenderer!.Render(frameInput);");
|
||||||
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
||||||
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
||||||
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
||||||
|
|
@ -126,12 +126,10 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
{
|
{
|
||||||
string source = GameWindowSource();
|
string source = GameWindowSource();
|
||||||
|
|
||||||
Assert.Contains("bool normalWorldDrawn = false;", source);
|
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"if (IsLiveModeWaitingForLogin)",
|
"_worldSceneRenderer!.Render(frameInput);",
|
||||||
"goto SkipWorldGeometry;",
|
"worldOutcome.NormalWorldDrawn;",
|
||||||
"normalWorldDrawn = true;",
|
|
||||||
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
|
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
|
||||||
"normalWorldDrawn),",
|
"normalWorldDrawn),",
|
||||||
"screenshotCaptured)));");
|
"screenshotCaptured)));");
|
||||||
|
|
@ -158,8 +156,9 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
|
"new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
|
||||||
source);
|
source);
|
||||||
Assert.Contains("_terrainDrawDiagnostics!.Begin();", source);
|
Assert.Contains("new AcDream.App.Rendering.WorldScenePassExecutor(", source);
|
||||||
Assert.Contains("_terrainDrawDiagnostics.Complete();", source);
|
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
|
||||||
|
Assert.DoesNotContain("_terrainDrawDiagnostics.Complete();", source);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class RenderFrameGlStateControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RestoreFrameDefaults_ReestablishesTheCompleteFrameGlobalContract()
|
||||||
|
{
|
||||||
|
var api = new RecordingApi();
|
||||||
|
var state = new RenderFrameGlStateController(api);
|
||||||
|
|
||||||
|
state.RestoreFrameDefaults();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"cap:ScissorTest:False",
|
||||||
|
"cap:StencilTest:False",
|
||||||
|
"cap:Blend:False",
|
||||||
|
"cap:SampleAlphaToMaskSgis:False",
|
||||||
|
"cap:ClipDistance0:False",
|
||||||
|
"cap:ClipDistance1:False",
|
||||||
|
"cap:ClipDistance2:False",
|
||||||
|
"cap:ClipDistance3:False",
|
||||||
|
"cap:ClipDistance4:False",
|
||||||
|
"cap:ClipDistance5:False",
|
||||||
|
"cap:ClipDistance6:False",
|
||||||
|
"cap:ClipDistance7:False",
|
||||||
|
"color-mask:True:True:True:True",
|
||||||
|
"stencil-mask:255",
|
||||||
|
"depth-mask:True",
|
||||||
|
"depth-func:Less",
|
||||||
|
"cap:DepthTest:True",
|
||||||
|
"cap:CullFace:False",
|
||||||
|
"cull:Back",
|
||||||
|
"front:CW",
|
||||||
|
"program:0",
|
||||||
|
"vao:0",
|
||||||
|
"buffer:ArrayBuffer:0",
|
||||||
|
],
|
||||||
|
api.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PortalDepthMask_SuccessExitRestoresTheSameCullOffConvention()
|
||||||
|
{
|
||||||
|
string source = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"PortalDepthMaskRenderer.cs"));
|
||||||
|
int restore = source.IndexOf(
|
||||||
|
"// ---- restore the frame-global convention ----",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.True(restore >= 0, "Missing portal-depth state restore block.");
|
||||||
|
string restoreBlock = source[restore..];
|
||||||
|
Assert.Contains("_gl.Disable(EnableCap.CullFace);", restoreBlock);
|
||||||
|
Assert.DoesNotContain("_gl.Enable(EnableCap.CullFace);", restoreBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
return directory.FullName;
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingApi : IRenderFrameGlStateApi
|
||||||
|
{
|
||||||
|
public List<string> Calls { get; } = [];
|
||||||
|
|
||||||
|
public void SetCapability(EnableCap capability, bool enabled) =>
|
||||||
|
Calls.Add($"cap:{capability}:{enabled}");
|
||||||
|
|
||||||
|
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
|
||||||
|
Calls.Add($"color-mask:{red}:{green}:{blue}:{alpha}");
|
||||||
|
|
||||||
|
public void StencilMask(uint mask) => Calls.Add($"stencil-mask:{mask}");
|
||||||
|
|
||||||
|
public void DepthMask(bool enabled) => Calls.Add($"depth-mask:{enabled}");
|
||||||
|
|
||||||
|
public void DepthFunc(DepthFunction function) =>
|
||||||
|
Calls.Add($"depth-func:{function}");
|
||||||
|
|
||||||
|
public void CullFace(TriangleFace face) => Calls.Add($"cull:{face}");
|
||||||
|
|
||||||
|
public void FrontFace(FrontFaceDirection direction) =>
|
||||||
|
Calls.Add($"front:{direction}");
|
||||||
|
|
||||||
|
public void UseProgram(uint program) => Calls.Add($"program:{program}");
|
||||||
|
|
||||||
|
public void BindVertexArray(uint vertexArray) => Calls.Add($"vao:{vertexArray}");
|
||||||
|
|
||||||
|
public void BindBuffer(BufferTargetARB target, uint buffer) =>
|
||||||
|
Calls.Add($"buffer:{target}:{buffer}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,10 +87,8 @@ public sealed class RenderFrameResourceControllerTests
|
||||||
"_particleVisibility.Reset();",
|
"_particleVisibility.Reset();",
|
||||||
"SkyKeyframe sky = _worldTime.CurrentSky;",
|
"SkyKeyframe sky = _worldTime.CurrentSky;",
|
||||||
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
|
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
|
||||||
"_gl.DepthMask(true);",
|
"_frameGlState.RestoreFrameDefaults();",
|
||||||
"_gl.Clear(",
|
"_gl.Clear(",
|
||||||
"_gl.CullFace(TriangleFace.Back);",
|
|
||||||
"_gl.FrontFace(FrontFaceDirection.CW);",
|
|
||||||
"_diagnostics.EmitGlStateTripwireIfChanged(");
|
"_diagnostics.EmitGlStateTripwireIfChanged(");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,76 @@ public sealed class RetailAlphaQueueTests
|
||||||
log);
|
log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender()
|
||||||
|
{
|
||||||
|
var log = new List<string>();
|
||||||
|
var source = new RecordingSource("alpha", log);
|
||||||
|
var queue = new RetailAlphaQueue();
|
||||||
|
|
||||||
|
queue.BeginFrame();
|
||||||
|
queue.Submit(source, 1, 12f);
|
||||||
|
queue.AbortFrame();
|
||||||
|
|
||||||
|
Assert.False(queue.IsCollecting);
|
||||||
|
Assert.Equal(0, queue.PendingCount);
|
||||||
|
Assert.Empty(log);
|
||||||
|
Assert.Equal(1, source.ResetCount);
|
||||||
|
|
||||||
|
queue.BeginFrame();
|
||||||
|
queue.Submit(source, 2, 12f);
|
||||||
|
queue.EndFrame();
|
||||||
|
|
||||||
|
Assert.Equal(["alpha:2"], log);
|
||||||
|
Assert.Equal(2, source.ResetCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndFrame_DrawAndResetFailuresPreserveThePrimaryFailureAndClearTheFrame()
|
||||||
|
{
|
||||||
|
var drawSource = new FailureSource("draw failed", "first reset failed");
|
||||||
|
var secondSource = new FailureSource(null, null);
|
||||||
|
var queue = new RetailAlphaQueue();
|
||||||
|
|
||||||
|
queue.BeginFrame();
|
||||||
|
queue.Submit(drawSource, 1, 20f);
|
||||||
|
queue.Submit(secondSource, 2, 10f);
|
||||||
|
|
||||||
|
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
||||||
|
|
||||||
|
Assert.Collection(
|
||||||
|
failure.InnerExceptions,
|
||||||
|
error => Assert.Equal("draw failed", error.Message),
|
||||||
|
error => Assert.Equal("first reset failed", error.Message));
|
||||||
|
Assert.Equal(1, drawSource.ResetCount);
|
||||||
|
Assert.Equal(1, secondSource.ResetCount);
|
||||||
|
Assert.Equal(0, queue.PendingCount);
|
||||||
|
Assert.False(queue.IsCollecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndFrame_MultipleResetFailuresAttemptEverySourceAndClearTheFrame()
|
||||||
|
{
|
||||||
|
var first = new FailureSource(null, "first reset failed");
|
||||||
|
var second = new FailureSource(null, "second reset failed");
|
||||||
|
var queue = new RetailAlphaQueue();
|
||||||
|
|
||||||
|
queue.BeginFrame();
|
||||||
|
queue.Submit(first, 1, 20f);
|
||||||
|
queue.Submit(second, 2, 10f);
|
||||||
|
|
||||||
|
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
||||||
|
|
||||||
|
Assert.Collection(
|
||||||
|
failure.InnerExceptions,
|
||||||
|
error => Assert.Equal("first reset failed", error.Message),
|
||||||
|
error => Assert.Equal("second reset failed", error.Message));
|
||||||
|
Assert.Equal(1, first.ResetCount);
|
||||||
|
Assert.Equal(1, second.ResetCount);
|
||||||
|
Assert.Equal(0, queue.PendingCount);
|
||||||
|
Assert.False(queue.IsCollecting);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
|
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
|
||||||
{
|
{
|
||||||
|
|
@ -224,4 +294,28 @@ public sealed class RetailAlphaQueueTests
|
||||||
|
|
||||||
public void ResetAlphaSubmissions() => ResetCount++;
|
public void ResetAlphaSubmissions() => ResetCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FailureSource(
|
||||||
|
string? drawFailure,
|
||||||
|
string? resetFailure) : IRetailAlphaDrawSource
|
||||||
|
{
|
||||||
|
public int ResetCount { get; private set; }
|
||||||
|
|
||||||
|
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
||||||
|
{
|
||||||
|
if (drawFailure is not null)
|
||||||
|
throw new InvalidOperationException(drawFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAlphaSubmissions()
|
||||||
|
{
|
||||||
|
ResetCount++;
|
||||||
|
if (resetFailure is not null)
|
||||||
|
throw new InvalidOperationException(resetFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,8 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
"GameWindow.cs"));
|
"GameWindow.cs"));
|
||||||
|
|
||||||
Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source);
|
Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source);
|
||||||
Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source);
|
Assert.Contains("new AcDream.App.Rendering.WorldScenePViewRenderer(", source);
|
||||||
|
Assert.DoesNotContain("new AcDream.App.Rendering.RetailPViewFrameInput", source);
|
||||||
Assert.DoesNotContain("RetailPViewDrawContext", source);
|
Assert.DoesNotContain("RetailPViewDrawContext", source);
|
||||||
Assert.DoesNotContain("DrawLandscapeSlice =", source);
|
Assert.DoesNotContain("DrawLandscapeSlice =", source);
|
||||||
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
||||||
|
|
@ -475,6 +476,8 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
|
|
||||||
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
|
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
|
||||||
{
|
{
|
||||||
|
public void AbortFrame() => Operations.Add("abort");
|
||||||
|
|
||||||
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
||||||
|
|
||||||
public List<string> Operations { get; } = [];
|
public List<string> Operations { get; } = [];
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,29 @@ public sealed class RetailSelectionSceneTests
|
||||||
Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _));
|
Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbortFrame_DiscardsBuildingSelectionAndPreservesPublishedFrame()
|
||||||
|
{
|
||||||
|
var scene = CreateScene();
|
||||||
|
var published = Entity(localId: 46u, serverGuid: 0x5000_0046u);
|
||||||
|
Publish(scene, published);
|
||||||
|
var rejected = Entity(localId: 47u, serverGuid: 0x5000_0047u);
|
||||||
|
|
||||||
|
scene.BeginFrame();
|
||||||
|
(Matrix4x4 view, Matrix4x4 projection) = Camera();
|
||||||
|
scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection));
|
||||||
|
scene.AddVisiblePart(
|
||||||
|
rejected,
|
||||||
|
partIndex: 0,
|
||||||
|
gfxObjId: 0x0100_0001u,
|
||||||
|
Matrix4x4.CreateTranslation(0f, 0f, -5f));
|
||||||
|
scene.AbortFrame();
|
||||||
|
|
||||||
|
RetailSelectionHit? hit = PickCenter(scene);
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(published.Id, hit.Value.LocalEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailSelectionScene CreateScene(
|
private static RetailSelectionScene CreateScene(
|
||||||
RetailSelectionLightingPulse? pulse = null)
|
RetailSelectionLightingPulse? pulse = null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,35 @@ public sealed class ParticleVisibilityControllerTests
|
||||||
controller.Apply(particles, 1f);
|
controller.Apply(particles, 1f);
|
||||||
Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
|
Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbortedFrame_PreservesTheLastCompletedWorldView()
|
||||||
|
{
|
||||||
|
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(1));
|
||||||
|
int handle = particles.SpawnEmitter(
|
||||||
|
new EmitterDesc
|
||||||
|
{
|
||||||
|
DatId = 0x32000003u,
|
||||||
|
Type = ParticleType.Still,
|
||||||
|
MaxDegradeDistance = 100f,
|
||||||
|
MaxParticles = 1,
|
||||||
|
},
|
||||||
|
Vector3.Zero,
|
||||||
|
attachedObjectId: 42u);
|
||||||
|
particles.UpdateEmitterOwnerCell(handle, 0x01010001u);
|
||||||
|
var controller = new ParticleVisibilityController();
|
||||||
|
|
||||||
|
controller.BeginFrame(Vector3.Zero);
|
||||||
|
controller.UseWorldView();
|
||||||
|
controller.MarkVisibleCells([0x01010001u]);
|
||||||
|
controller.CompleteFrame();
|
||||||
|
|
||||||
|
controller.BeginFrame(new Vector3(1000f, 1000f, 0f));
|
||||||
|
controller.UseWorldView();
|
||||||
|
controller.MarkVisibleCells([0x02020001u]);
|
||||||
|
controller.AbortFrame();
|
||||||
|
controller.Apply(particles, 1f);
|
||||||
|
|
||||||
|
Assert.True(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,25 +325,32 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners()
|
public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners()
|
||||||
{
|
{
|
||||||
string source = GameWindowSource();
|
string gameWindow = GameWindowSource();
|
||||||
|
string worldScene = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"WorldSceneRenderer.cs"));
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
1,
|
1,
|
||||||
CountOccurrences(source, "_worldRenderFrameBuilder!.Build("));
|
CountOccurrences(worldScene, "_frames.Build("));
|
||||||
Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
worldScene,
|
||||||
"if (_cameraController is not null && !portalViewportVisible)",
|
"_alpha.BeginFrame();",
|
||||||
"_retailAlphaQueue.BeginFrame();",
|
"_frames.Build(",
|
||||||
"_worldRenderFrameBuilder!.Build(",
|
"if (_login.IsWaitingForLogin)",
|
||||||
"if (IsLiveModeWaitingForLogin)",
|
"_passes.DrawFlatTerrain(",
|
||||||
"normalWorldDrawn = true;");
|
"NormalWorldDrawn: true");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
gameWindow,
|
||||||
|
"_worldSceneRenderer = null;",
|
||||||
"_worldRenderFrameBuilder = null;",
|
"_worldRenderFrameBuilder = null;",
|
||||||
"_skyPesFrame = null;",
|
"_skyPesFrame = null;",
|
||||||
"new(\"equipped children\"",
|
"new(\"equipped children\"",
|
||||||
|
|
|
||||||
744
tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
Normal file
744
tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
Normal file
|
|
@ -0,0 +1,744 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Selection;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class WorldSceneRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners()
|
||||||
|
{
|
||||||
|
var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.Equal(default, result);
|
||||||
|
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
|
||||||
|
{
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: true, clipRoot: null);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.False(result.NormalWorldDrawn);
|
||||||
|
Assert.Equal(0, result.VisibleLandblocks);
|
||||||
|
Assert.Equal(0, result.TotalLandblocks);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"selection:begin",
|
||||||
|
"alpha:begin",
|
||||||
|
"frame:build",
|
||||||
|
"passes:begin",
|
||||||
|
"flat:clip",
|
||||||
|
"flat:sky",
|
||||||
|
"alpha:end",
|
||||||
|
"visibility:mark",
|
||||||
|
"visibility:complete",
|
||||||
|
"selection:complete",
|
||||||
|
],
|
||||||
|
rig.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlatWorld_PreservesTerrainEntityParticleWeatherAndCompletionOrder()
|
||||||
|
{
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.Equal(new WorldRenderFrameOutcome(3, 8, true), result);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"selection:begin",
|
||||||
|
"alpha:begin",
|
||||||
|
"frame:build",
|
||||||
|
"passes:begin",
|
||||||
|
"flat:clip",
|
||||||
|
"flat:sky",
|
||||||
|
"flat:terrain",
|
||||||
|
"flat:entities",
|
||||||
|
"frame:clear-cells",
|
||||||
|
"passes:disable-clip",
|
||||||
|
"particles:global",
|
||||||
|
"flat:weather",
|
||||||
|
"diagnostics:signature:OutdoorRoot",
|
||||||
|
"diagnostics:draw",
|
||||||
|
"alpha:end",
|
||||||
|
"visibility:mark",
|
||||||
|
"visibility:complete",
|
||||||
|
"selection:complete",
|
||||||
|
],
|
||||||
|
rig.Calls);
|
||||||
|
|
||||||
|
Assert.Equal(rig.Foundation, rig.Frames.Foundation);
|
||||||
|
Assert.False(rig.Frames.WaitingForLogin);
|
||||||
|
Assert.Same(rig.DayGroup, rig.Frames.ActiveDayGroup);
|
||||||
|
Assert.Equal(rig.Foundation, rig.Passes.SkyFoundation);
|
||||||
|
Assert.Equal(rig.Foundation, rig.Passes.WeatherFoundation);
|
||||||
|
Assert.Same(rig.DayGroup, rig.Passes.SkyDayGroup);
|
||||||
|
Assert.Same(rig.DayGroup, rig.Passes.WeatherDayGroup);
|
||||||
|
Assert.Equal(rig.DayFraction, rig.Passes.SkyDayFraction);
|
||||||
|
Assert.Equal(rig.DayFraction, rig.Passes.WeatherDayFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PViewWorld_UsesOnePViewProductAndPublishesItsDrawableCells()
|
||||||
|
{
|
||||||
|
var root = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = 0x01010001u,
|
||||||
|
IsOutdoorNode = false,
|
||||||
|
};
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.True(result.NormalWorldDrawn);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"selection:begin",
|
||||||
|
"alpha:begin",
|
||||||
|
"frame:build",
|
||||||
|
"passes:begin",
|
||||||
|
"pview:draw",
|
||||||
|
"visibility:mark",
|
||||||
|
"frame:observe-cells",
|
||||||
|
"diagnostics:pview",
|
||||||
|
"passes:disable-clip",
|
||||||
|
"particles:none",
|
||||||
|
"diagnostics:signature:RetailPViewInside",
|
||||||
|
"diagnostics:draw",
|
||||||
|
"alpha:end",
|
||||||
|
"visibility:mark",
|
||||||
|
"visibility:complete",
|
||||||
|
"selection:complete",
|
||||||
|
],
|
||||||
|
rig.Calls);
|
||||||
|
Assert.DoesNotContain("flat:clip", rig.Calls);
|
||||||
|
Assert.DoesNotContain("flat:terrain", rig.Calls);
|
||||||
|
Assert.DoesNotContain("flat:entities", rig.Calls);
|
||||||
|
Assert.NotNull(rig.PView.LastInput);
|
||||||
|
Assert.Same(rig.DayGroup, rig.PView.LastInput!.ActiveDayGroup);
|
||||||
|
Assert.Equal(rig.DayFraction, rig.PView.LastInput.DayFraction);
|
||||||
|
Assert.Equal(rig.Foundation.Sky, rig.PView.LastInput.SkyKeyframe);
|
||||||
|
Assert.True(rig.PView.LastInput.RenderWeather);
|
||||||
|
Assert.Equal(root.CellId, rig.PView.LastInput.ViewerCellId);
|
||||||
|
Assert.Equal(0x01010002u, rig.PView.LastInput.PlayerCellId);
|
||||||
|
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
|
||||||
|
{
|
||||||
|
var root = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = 0x01010001u,
|
||||||
|
IsOutdoorNode = true,
|
||||||
|
};
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
|
||||||
|
|
||||||
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.True(result.NormalWorldDrawn);
|
||||||
|
Assert.Contains("particles:pviewScoped+unattached", rig.Calls);
|
||||||
|
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
|
||||||
|
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
|
||||||
|
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SealedInteriorPView_DisablesWeatherAndPropagatesEnvironmentOverride()
|
||||||
|
{
|
||||||
|
var root = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = 0x01010001u,
|
||||||
|
IsOutdoorNode = false,
|
||||||
|
};
|
||||||
|
var rig = new Rig(
|
||||||
|
portalVisible: false,
|
||||||
|
waitingForLogin: false,
|
||||||
|
clipRoot: root,
|
||||||
|
playerSeenOutside: false,
|
||||||
|
environOverride: EnvironOverride.BlueFog);
|
||||||
|
|
||||||
|
rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.NotNull(rig.PView.LastInput);
|
||||||
|
Assert.False(rig.PView.LastInput!.RenderWeather);
|
||||||
|
Assert.True(rig.PView.LastInput.EnvironOverrideActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PViewFailure_AbortsPortalStateAndRecoversOnTheNextFrame()
|
||||||
|
{
|
||||||
|
var root = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = 0x01010001u,
|
||||||
|
IsOutdoorNode = false,
|
||||||
|
};
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
|
||||||
|
rig.PView.ThrowOnDraw = true;
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"selection:begin",
|
||||||
|
"alpha:begin",
|
||||||
|
"frame:build",
|
||||||
|
"passes:begin",
|
||||||
|
"pview:draw",
|
||||||
|
"pview:abort",
|
||||||
|
"passes:abort",
|
||||||
|
"visibility:abort",
|
||||||
|
"alpha:abort",
|
||||||
|
"selection:abort",
|
||||||
|
],
|
||||||
|
rig.Calls);
|
||||||
|
|
||||||
|
rig.Calls.Clear();
|
||||||
|
rig.PView.ThrowOnDraw = false;
|
||||||
|
|
||||||
|
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
|
||||||
|
Assert.Contains("selection:complete", rig.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawFailure_DoesNotPublishPartialAlphaSelectionOrParticleFrames()
|
||||||
|
{
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||||
|
rig.Passes.ThrowOnTerrain = true;
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"selection:begin",
|
||||||
|
"alpha:begin",
|
||||||
|
"frame:build",
|
||||||
|
"passes:begin",
|
||||||
|
"flat:clip",
|
||||||
|
"flat:sky",
|
||||||
|
"flat:terrain",
|
||||||
|
"passes:abort",
|
||||||
|
"visibility:abort",
|
||||||
|
"alpha:abort",
|
||||||
|
"selection:abort",
|
||||||
|
],
|
||||||
|
rig.Calls);
|
||||||
|
|
||||||
|
rig.Calls.Clear();
|
||||||
|
rig.Passes.ThrowOnTerrain = false;
|
||||||
|
WorldRenderFrameOutcome recovered = rig.Renderer.Render(default);
|
||||||
|
|
||||||
|
Assert.True(recovered.NormalWorldDrawn);
|
||||||
|
Assert.Contains("alpha:end", rig.Calls);
|
||||||
|
Assert.Contains("selection:complete", rig.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbortFailure_IsAggregatedAfterEveryOtherFrameOwnerIsAborted()
|
||||||
|
{
|
||||||
|
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||||
|
rig.Passes.ThrowOnTerrain = true;
|
||||||
|
rig.Passes.ThrowOnAbort = true;
|
||||||
|
|
||||||
|
AggregateException failure = Assert.Throws<AggregateException>(
|
||||||
|
() => rig.Renderer.Render(default));
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
failure.InnerExceptions,
|
||||||
|
error => error is InvalidOperationException
|
||||||
|
&& error.Message == "terrain failed");
|
||||||
|
Assert.Contains(
|
||||||
|
failure.InnerExceptions,
|
||||||
|
error => error is InvalidOperationException
|
||||||
|
&& error.Message == "pass abort failed");
|
||||||
|
Assert.Contains("visibility:abort", rig.Calls);
|
||||||
|
Assert.Contains("alpha:abort", rig.Calls);
|
||||||
|
Assert.Contains("selection:abort", rig.Calls);
|
||||||
|
|
||||||
|
rig.Calls.Clear();
|
||||||
|
rig.Passes.ThrowOnTerrain = false;
|
||||||
|
rig.Passes.ThrowOnAbort = false;
|
||||||
|
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameWindow_DelegatesWorldRenderingWithoutOwningDrawBranches()
|
||||||
|
{
|
||||||
|
string source = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindow.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source);
|
||||||
|
Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);"));
|
||||||
|
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
|
||||||
|
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
|
||||||
|
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
|
||||||
|
Assert.DoesNotContain("SkipWorldGeometry:", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractedWorldOwners_RetainNoWindowDelegateOrBorrowedPViewProduct()
|
||||||
|
{
|
||||||
|
Type[] ownerTypes =
|
||||||
|
[
|
||||||
|
typeof(WorldSceneRenderer),
|
||||||
|
typeof(WorldScenePassExecutor),
|
||||||
|
typeof(WorldSceneDiagnosticsController),
|
||||||
|
];
|
||||||
|
Type[] borrowedTypes =
|
||||||
|
[
|
||||||
|
typeof(RetailPViewFrameResult),
|
||||||
|
typeof(PortalVisibilityFrame),
|
||||||
|
typeof(ClipFrameAssembly),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (Type owner in ownerTypes)
|
||||||
|
{
|
||||||
|
foreach (FieldInfo field in owner.GetFields(
|
||||||
|
BindingFlags.Instance
|
||||||
|
| BindingFlags.Public
|
||||||
|
| BindingFlags.NonPublic))
|
||||||
|
{
|
||||||
|
Assert.False(
|
||||||
|
typeof(GameWindow).IsAssignableFrom(field.FieldType),
|
||||||
|
$"{owner.Name}.{field.Name} retains GameWindow.");
|
||||||
|
Assert.False(
|
||||||
|
typeof(Delegate).IsAssignableFrom(field.FieldType),
|
||||||
|
$"{owner.Name}.{field.Name} retains a delegate callback.");
|
||||||
|
Assert.DoesNotContain(field.FieldType, borrowedTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Rig
|
||||||
|
{
|
||||||
|
public Rig(
|
||||||
|
bool portalVisible,
|
||||||
|
bool waitingForLogin,
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
bool? playerSeenOutside = null,
|
||||||
|
EnvironOverride environOverride = EnvironOverride.None)
|
||||||
|
{
|
||||||
|
Calls = [];
|
||||||
|
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
|
||||||
|
DayFraction = 0.375f;
|
||||||
|
Foundation = new RenderFrameFoundation(
|
||||||
|
portalVisible,
|
||||||
|
new SkyKeyframe(
|
||||||
|
0.25f,
|
||||||
|
35f,
|
||||||
|
45f,
|
||||||
|
new Vector3(0.1f, 0.2f, 0.3f),
|
||||||
|
0.8f,
|
||||||
|
new Vector3(0.4f, 0.5f, 0.6f),
|
||||||
|
0.7f,
|
||||||
|
new Vector3(0.7f, 0.8f, 0.9f),
|
||||||
|
0.2f),
|
||||||
|
new AtmosphereSnapshot(
|
||||||
|
WeatherKind.Clear,
|
||||||
|
1f,
|
||||||
|
new Vector3(0.1f, 0.2f, 0.3f),
|
||||||
|
1f,
|
||||||
|
100f,
|
||||||
|
FogMode.Linear,
|
||||||
|
0f,
|
||||||
|
environOverride));
|
||||||
|
var foundation = new FoundationSource(Foundation);
|
||||||
|
var login = new LoginSource(waitingForLogin);
|
||||||
|
var sky = new SkySource(DayGroup, DayFraction);
|
||||||
|
var frame = CreateFrame(
|
||||||
|
clipRoot,
|
||||||
|
playerSeenOutside ?? clipRoot is not null);
|
||||||
|
Frames = new FrameBuilder(Calls, frame);
|
||||||
|
var selection = new SelectionFrame(Calls);
|
||||||
|
var alpha = new AlphaFrame(Calls);
|
||||||
|
var visibility = new ParticleVisibility(Calls);
|
||||||
|
PView = new PViewRenderer(Calls);
|
||||||
|
Passes = new PassExecutor(Calls);
|
||||||
|
var diagnostics = new Diagnostics(Calls);
|
||||||
|
|
||||||
|
Renderer = new WorldSceneRenderer(
|
||||||
|
foundation,
|
||||||
|
login,
|
||||||
|
sky,
|
||||||
|
Frames,
|
||||||
|
new EntitySource(),
|
||||||
|
selection,
|
||||||
|
alpha,
|
||||||
|
visibility,
|
||||||
|
PView,
|
||||||
|
new PViewCells(),
|
||||||
|
Passes,
|
||||||
|
new WorldRenderRangeState(4, 12),
|
||||||
|
diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> Calls { get; }
|
||||||
|
|
||||||
|
public RenderFrameFoundation Foundation { get; }
|
||||||
|
|
||||||
|
public DayGroupData DayGroup { get; }
|
||||||
|
|
||||||
|
public float DayFraction { get; }
|
||||||
|
|
||||||
|
public FrameBuilder Frames { get; }
|
||||||
|
|
||||||
|
public PViewRenderer PView { get; }
|
||||||
|
|
||||||
|
public PassExecutor Passes { get; }
|
||||||
|
|
||||||
|
public WorldSceneRenderer Renderer { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FoundationSource(RenderFrameFoundation foundation) :
|
||||||
|
IRenderFrameFoundationSource
|
||||||
|
{
|
||||||
|
public RenderFrameFoundation Foundation { get; } = foundation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LoginSource(bool waiting) : IRenderLoginStateSource
|
||||||
|
{
|
||||||
|
public bool IsWaitingForLogin { get; } = waiting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SkySource(
|
||||||
|
DayGroupData activeDayGroup,
|
||||||
|
float dayFraction) : IWorldSceneSkyStateSource
|
||||||
|
{
|
||||||
|
public DayGroupData? ActiveDayGroup { get; } = activeDayGroup;
|
||||||
|
|
||||||
|
public float DayFraction { get; } = dayFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FrameBuilder(List<string> calls, WorldRenderFrame frame) :
|
||||||
|
IWorldRenderFrameBuilder
|
||||||
|
{
|
||||||
|
public RenderFrameFoundation Foundation { get; private set; }
|
||||||
|
|
||||||
|
public bool WaitingForLogin { get; private set; }
|
||||||
|
|
||||||
|
public DayGroupData? ActiveDayGroup { get; private set; }
|
||||||
|
|
||||||
|
public WorldRenderFrame Build(
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
bool waitingForLogin,
|
||||||
|
DayGroupData? activeDayGroup)
|
||||||
|
{
|
||||||
|
calls.Add("frame:build");
|
||||||
|
Foundation = foundation;
|
||||||
|
WaitingForLogin = waitingForLogin;
|
||||||
|
ActiveDayGroup = activeDayGroup;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
||||||
|
calls.Add("frame:observe-cells");
|
||||||
|
|
||||||
|
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EntitySource : IWorldSceneEntitySource
|
||||||
|
{
|
||||||
|
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
|
||||||
|
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>?)>();
|
||||||
|
|
||||||
|
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
|
||||||
|
Array.Empty<(uint, Vector3, Vector3)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SelectionFrame(List<string> calls) : IWorldSceneSelectionFrame
|
||||||
|
{
|
||||||
|
public void BeginFrame() => calls.Add("selection:begin");
|
||||||
|
|
||||||
|
public void CompleteFrame() => calls.Add("selection:complete");
|
||||||
|
|
||||||
|
public void AbortFrame() => calls.Add("selection:abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlphaFrame(List<string> calls) : IWorldSceneAlphaFrame
|
||||||
|
{
|
||||||
|
public void BeginFrame() => calls.Add("alpha:begin");
|
||||||
|
|
||||||
|
public void EndFrame() => calls.Add("alpha:end");
|
||||||
|
|
||||||
|
public void AbortFrame() => calls.Add("alpha:abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ParticleVisibility(List<string> calls) :
|
||||||
|
IWorldSceneParticleVisibility
|
||||||
|
{
|
||||||
|
public void MarkVisibleCells(HashSet<uint> cellIds) =>
|
||||||
|
calls.Add("visibility:mark");
|
||||||
|
|
||||||
|
public void CompleteFrame() => calls.Add("visibility:complete");
|
||||||
|
|
||||||
|
public void AbortFrame() => calls.Add("visibility:abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PViewRenderer : IWorldScenePViewRenderer
|
||||||
|
{
|
||||||
|
private readonly List<string> _calls;
|
||||||
|
private readonly RetailPViewFrameResult _interiorResult;
|
||||||
|
private readonly RetailPViewFrameResult _outdoorResult;
|
||||||
|
|
||||||
|
public PViewRenderer(List<string> calls)
|
||||||
|
{
|
||||||
|
_calls = calls;
|
||||||
|
_interiorResult = new RetailPViewFrameResult().Reset(
|
||||||
|
new PortalVisibilityFrame(),
|
||||||
|
new ClipFrameAssembly(),
|
||||||
|
[],
|
||||||
|
new InteriorEntityPartition.Result());
|
||||||
|
var outdoorPortalFrame = new PortalVisibilityFrame();
|
||||||
|
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
|
||||||
|
[
|
||||||
|
new Vector2(-1f, -1f),
|
||||||
|
new Vector2(1f, -1f),
|
||||||
|
new Vector2(1f, 1f),
|
||||||
|
new Vector2(-1f, 1f),
|
||||||
|
]));
|
||||||
|
_outdoorResult = new RetailPViewFrameResult().Reset(
|
||||||
|
outdoorPortalFrame,
|
||||||
|
ClipFrameAssembler.Assemble(ClipFrame.NoClip(), outdoorPortalFrame),
|
||||||
|
[],
|
||||||
|
new InteriorEntityPartition.Result());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
|
||||||
|
new HashSet<uint> { 0xCAFEu };
|
||||||
|
|
||||||
|
public RetailPViewFrameInput? LastInput { get; private set; }
|
||||||
|
|
||||||
|
public bool ThrowOnDraw { get; set; }
|
||||||
|
|
||||||
|
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input)
|
||||||
|
{
|
||||||
|
_calls.Add("pview:draw");
|
||||||
|
LastInput = input;
|
||||||
|
if (ThrowOnDraw)
|
||||||
|
throw new InvalidOperationException("pview failed");
|
||||||
|
return input.RootCell.IsOutdoorNode ? _outdoorResult : _interiorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AbortFrame() => _calls.Add("pview:abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PViewCells : IRetailPViewCellSource
|
||||||
|
{
|
||||||
|
public LoadedCell? Find(uint cellId) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PassExecutor(List<string> calls) : IWorldScenePassExecutor
|
||||||
|
{
|
||||||
|
public bool ThrowOnTerrain { get; set; }
|
||||||
|
|
||||||
|
public bool ThrowOnAbort { get; set; }
|
||||||
|
|
||||||
|
public HashSet<uint>? TerrainVisibleCellIds { get; } = [0x01010001u];
|
||||||
|
|
||||||
|
public RenderFrameFoundation? SkyFoundation { get; private set; }
|
||||||
|
|
||||||
|
public RenderFrameFoundation? WeatherFoundation { get; private set; }
|
||||||
|
|
||||||
|
public DayGroupData? SkyDayGroup { get; private set; }
|
||||||
|
|
||||||
|
public DayGroupData? WeatherDayGroup { get; private set; }
|
||||||
|
|
||||||
|
public float SkyDayFraction { get; private set; }
|
||||||
|
|
||||||
|
public float WeatherDayFraction { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
|
||||||
|
|
||||||
|
public void BeginFrame() => calls.Add("passes:begin");
|
||||||
|
|
||||||
|
public void PrepareFlatWorldClip() => calls.Add("flat:clip");
|
||||||
|
|
||||||
|
public void DrawFlatSky(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction)
|
||||||
|
{
|
||||||
|
calls.Add("flat:sky");
|
||||||
|
SkyFoundation = foundation;
|
||||||
|
SkyDayGroup = activeDayGroup;
|
||||||
|
SkyDayFraction = dayFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId)
|
||||||
|
{
|
||||||
|
calls.Add("flat:terrain");
|
||||||
|
if (ThrowOnTerrain)
|
||||||
|
throw new InvalidOperationException("terrain failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatEntities(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
||||||
|
uint? playerLandblockId,
|
||||||
|
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
|
||||||
|
|
||||||
|
public string DrawPostWorldParticles(
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
ClipFrameAssembly? clipAssembly,
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IReadOnlySet<uint> outdoorOwnerIds,
|
||||||
|
string currentSignature)
|
||||||
|
{
|
||||||
|
ParticleOwners = outdoorOwnerIds;
|
||||||
|
string kind = clipRoot switch
|
||||||
|
{
|
||||||
|
null => "global",
|
||||||
|
{ IsOutdoorNode: true } => currentSignature == "none"
|
||||||
|
? "unattached"
|
||||||
|
: currentSignature + "+unattached",
|
||||||
|
_ => currentSignature,
|
||||||
|
};
|
||||||
|
calls.Add($"particles:{kind}");
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawFlatWeather(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
in RenderFrameFoundation foundation,
|
||||||
|
DayGroupData? activeDayGroup,
|
||||||
|
float dayFraction)
|
||||||
|
{
|
||||||
|
calls.Add("flat:weather");
|
||||||
|
WeatherFoundation = foundation;
|
||||||
|
WeatherDayGroup = activeDayGroup;
|
||||||
|
WeatherDayFraction = dayFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DisableClipDistances() => calls.Add("passes:disable-clip");
|
||||||
|
|
||||||
|
public void AbortFrame()
|
||||||
|
{
|
||||||
|
calls.Add("passes:abort");
|
||||||
|
if (ThrowOnAbort)
|
||||||
|
throw new InvalidOperationException("pass abort failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Diagnostics(List<string> calls) : IWorldSceneDiagnostics
|
||||||
|
{
|
||||||
|
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
|
||||||
|
|
||||||
|
public void EmitPViewInput(
|
||||||
|
PortalVisibilityFrame portalFrame,
|
||||||
|
Matrix4x4 viewProjection,
|
||||||
|
LoadedCell clipRoot,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition) => calls.Add("diagnostics:pview");
|
||||||
|
|
||||||
|
public void EmitRenderSignature(
|
||||||
|
string branch,
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
LoadedCell? viewerRoot,
|
||||||
|
LoadedCell? playerRoot,
|
||||||
|
uint viewerCellId,
|
||||||
|
uint playerCellId,
|
||||||
|
bool playerIndoorGate,
|
||||||
|
bool cameraInsideCell,
|
||||||
|
bool renderSky,
|
||||||
|
bool drawSkyThisFrame,
|
||||||
|
bool terrainDrawn,
|
||||||
|
TerrainClipMode terrainClipMode,
|
||||||
|
bool skyDrawn,
|
||||||
|
bool depthClear,
|
||||||
|
bool outdoorSceneryDrawn,
|
||||||
|
int liveDynamicDrawnCount,
|
||||||
|
string sceneParticles,
|
||||||
|
RetailPViewFrameResult? pviewResult,
|
||||||
|
Vector3 cameraPosition,
|
||||||
|
Vector3 playerPosition) =>
|
||||||
|
calls.Add($"diagnostics:signature:{branch}");
|
||||||
|
|
||||||
|
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
|
in WorldCameraFrame camera,
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||||
|
{
|
||||||
|
calls.Add("diagnostics:draw");
|
||||||
|
return new WorldSceneDiagnosticOutcome(3, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldRenderFrame CreateFrame(
|
||||||
|
LoadedCell? clipRoot,
|
||||||
|
bool playerSeenOutside)
|
||||||
|
{
|
||||||
|
var camera = new FlyCamera { Position = new Vector3(1f, 2f, 3f) };
|
||||||
|
var cameraFrame = new WorldCameraFrame(
|
||||||
|
camera,
|
||||||
|
camera.Projection,
|
||||||
|
camera.View * camera.Projection,
|
||||||
|
default,
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
camera.Position);
|
||||||
|
var roots = new WorldRootFrame(
|
||||||
|
PlayerRoot: null,
|
||||||
|
PlayerSeenOutside: playerSeenOutside,
|
||||||
|
ViewerCellId: clipRoot?.CellId ?? 0u,
|
||||||
|
ViewerEyePosition: camera.Position,
|
||||||
|
PlayerViewPosition: camera.Position,
|
||||||
|
ViewerRoot: clipRoot,
|
||||||
|
CameraInsideCell: clipRoot is not null,
|
||||||
|
RootSeenOutside: true,
|
||||||
|
PlayerInsideCell: false,
|
||||||
|
PlayerLandblockId: null,
|
||||||
|
RenderCenterLandblockX: 0,
|
||||||
|
RenderCenterLandblockY: 0,
|
||||||
|
PlayerCellId: 0x01010002u,
|
||||||
|
PlayerIndoorGate: false);
|
||||||
|
return new WorldRenderFrame(
|
||||||
|
cameraFrame,
|
||||||
|
roots,
|
||||||
|
new WorldBuildingFrame(null, Array.Empty<LoadedCell>()),
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string source, string value)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
int cursor = 0;
|
||||||
|
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
cursor += value.Length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
return directory.FullName;
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue