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-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-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-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) |
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ port, or resource-lifetime redesign.
|
|||
run the dependent audio, sky, lighting, and listener preparation.
|
||||
- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed
|
||||
`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.
|
||||
- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
||||
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;
|
||||
- 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
|
||||
|
||||
- compose concrete resource, world, private-presentation, and diagnostic owners;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -377,7 +377,9 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
_gl.ColorMask(true, true, true, true);
|
||||
_gl.DepthMask(true);
|
||||
_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.FrontFace(FrontFaceDirection.CW);
|
||||
_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();
|
||||
}
|
||||
|
||||
internal interface IRenderFrameFoundationSource
|
||||
{
|
||||
RenderFrameFoundation Foundation { get; }
|
||||
}
|
||||
|
||||
internal interface IRenderFrameLivePreparation
|
||||
{
|
||||
void Prepare(int gpuSlot);
|
||||
|
|
@ -43,7 +48,9 @@ internal interface IRenderFrameLivePreparation
|
|||
/// typed phases preserve resource begin, clear/state, then live publication
|
||||
/// order without exposing a renderer service bag.
|
||||
/// </summary>
|
||||
internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase
|
||||
internal sealed class RenderFrameResourceController :
|
||||
IRenderFrameResourcePhase,
|
||||
IRenderFrameFoundationSource
|
||||
{
|
||||
private readonly IRenderFrameSlotSource _frameSlots;
|
||||
private readonly IRenderFrameBeginResources _resources;
|
||||
|
|
@ -162,6 +169,7 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
|||
private readonly IRenderFramePortalStateSource _portal;
|
||||
private readonly ParticleVisibilityController _particleVisibility;
|
||||
private readonly WorldRenderDiagnostics _diagnostics;
|
||||
private readonly IRenderFrameGlState _frameGlState;
|
||||
|
||||
public RuntimeRenderFrameClearPhase(
|
||||
GL gl,
|
||||
|
|
@ -169,7 +177,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
|||
WeatherSystem weather,
|
||||
IRenderFramePortalStateSource portal,
|
||||
ParticleVisibilityController particleVisibility,
|
||||
WorldRenderDiagnostics diagnostics)
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
IRenderFrameGlState frameGlState)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||
|
|
@ -178,6 +187,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
|||
_particleVisibility = particleVisibility
|
||||
?? throw new ArgumentNullException(nameof(particleVisibility));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_frameGlState = frameGlState
|
||||
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||
}
|
||||
|
||||
public RenderFrameFoundation Clear()
|
||||
|
|
@ -203,13 +214,11 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
|||
1f);
|
||||
}
|
||||
|
||||
_gl.DepthMask(true);
|
||||
_frameGlState.RestoreFrameDefaults();
|
||||
_gl.Clear(
|
||||
ClearBufferMask.ColorBufferBit
|
||||
| ClearBufferMask.DepthBufferBit
|
||||
| ClearBufferMask.StencilBufferBit);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
_diagnostics.EmitGlStateTripwireIfChanged(
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
|
@ -56,7 +57,16 @@ internal readonly record struct RetailAlphaSubmission(
|
|||
/// material/renderer sort.</item>
|
||||
/// </list>
|
||||
/// </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<IRetailAlphaDrawSource> _sources = new(4);
|
||||
|
|
@ -108,58 +118,93 @@ internal sealed class RetailAlphaQueue
|
|||
if (!IsCollecting)
|
||||
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
|
||||
|
||||
Exception? drawFailure = null;
|
||||
List<Exception>? resetFailures = null;
|
||||
try
|
||||
{
|
||||
if (_submissions.Count == 0)
|
||||
return;
|
||||
|
||||
SortRetailOrder();
|
||||
EnsureTokenCapacity(_submissions.Count);
|
||||
EnsureSourceCapacity(_sources.Count);
|
||||
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
|
||||
|
||||
// Prepare each renderer once for this alpha scope. The filtered
|
||||
// token sequence preserves final retail order for that source, so
|
||||
// every later adjacent source-run maps to one contiguous prepared
|
||||
// range without another GPU upload.
|
||||
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
|
||||
if (_submissions.Count > 0)
|
||||
{
|
||||
IRetailAlphaDrawSource source = _sources[sourceIndex];
|
||||
int sourceCount = 0;
|
||||
for (int i = 0; i < _submissions.Count; i++)
|
||||
SortRetailOrder();
|
||||
EnsureTokenCapacity(_submissions.Count);
|
||||
EnsureSourceCapacity(_sources.Count);
|
||||
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
|
||||
|
||||
// Prepare each renderer once for this alpha scope. The filtered
|
||||
// token sequence preserves final retail order for that source, so
|
||||
// every later adjacent source-run maps to one contiguous prepared
|
||||
// range without another GPU upload.
|
||||
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
|
||||
{
|
||||
RetailAlphaSubmission submission = _submissions[i];
|
||||
if (ReferenceEquals(submission.Source, source))
|
||||
_tokenScratch[sourceCount++] = submission.Token;
|
||||
IRetailAlphaDrawSource source = _sources[sourceIndex];
|
||||
int sourceCount = 0;
|
||||
for (int i = 0; i < _submissions.Count; i++)
|
||||
{
|
||||
RetailAlphaSubmission submission = _submissions[i];
|
||||
if (ReferenceEquals(submission.Source, source))
|
||||
_tokenScratch[sourceCount++] = submission.Token;
|
||||
}
|
||||
|
||||
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
|
||||
}
|
||||
|
||||
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
|
||||
}
|
||||
int start = 0;
|
||||
while (start < _submissions.Count)
|
||||
{
|
||||
IRetailAlphaDrawSource source = _submissions[start].Source;
|
||||
int end = start + 1;
|
||||
while (end < _submissions.Count
|
||||
&& ReferenceEquals(_submissions[end].Source, source))
|
||||
end++;
|
||||
|
||||
int start = 0;
|
||||
while (start < _submissions.Count)
|
||||
{
|
||||
IRetailAlphaDrawSource source = _submissions[start].Source;
|
||||
int end = start + 1;
|
||||
while (end < _submissions.Count
|
||||
&& ReferenceEquals(_submissions[end].Source, source))
|
||||
end++;
|
||||
|
||||
int count = end - start;
|
||||
int sourceIndex = FindSourceIndex(source);
|
||||
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
|
||||
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
|
||||
_sourceDrawOffsets[sourceIndex] += count;
|
||||
start = end;
|
||||
int count = end - start;
|
||||
int sourceIndex = FindSourceIndex(source);
|
||||
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
|
||||
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
|
||||
_sourceDrawOffsets[sourceIndex] += count;
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
drawFailure = error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
_sources[i].ResetAlphaSubmissions();
|
||||
{
|
||||
try
|
||||
{
|
||||
_sources[i].ResetAlphaSubmissions();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(resetFailures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
_sources.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()
|
||||
|
|
@ -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()
|
||||
{
|
||||
int count = _submissions.Count;
|
||||
|
|
|
|||
|
|
@ -74,9 +74,17 @@ internal sealed class RetailPViewParticleClassifications
|
|||
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
|
||||
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
||||
/// </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 IRenderFrameGlState _frameGlState;
|
||||
private readonly IRetailPViewFramebufferSource _framebuffer;
|
||||
private readonly ClipFrame _clipFrame;
|
||||
private readonly TerrainModernRenderer? _terrain;
|
||||
|
|
@ -101,6 +109,7 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
|||
|
||||
public RetailPViewPassExecutor(
|
||||
GL gl,
|
||||
IRenderFrameGlState frameGlState,
|
||||
IRetailPViewFramebufferSource framebuffer,
|
||||
ClipFrame clipFrame,
|
||||
TerrainModernRenderer? terrain,
|
||||
|
|
@ -115,6 +124,8 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
|||
TerrainDrawDiagnosticsController terrainDiagnostics)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_frameGlState = frameGlState
|
||||
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||
_framebuffer = framebuffer
|
||||
?? throw new ArgumentNullException(nameof(framebuffer));
|
||||
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
||||
|
|
@ -136,6 +147,28 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
|
|||
_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(
|
||||
PortalVisibilityFrame portalFrame,
|
||||
ClipFrameAssembly reuseAssembly) =>
|
||||
|
|
|
|||
|
|
@ -889,6 +889,7 @@ public interface IRetailPViewCellSource
|
|||
/// </summary>
|
||||
public interface IRetailPViewPassExecutor
|
||||
{
|
||||
void AbortFrame();
|
||||
void BeginFrame();
|
||||
ClipFrameAssembly AssembleClipFrame(
|
||||
PortalVisibilityFrame portalFrame,
|
||||
|
|
@ -1118,7 +1119,8 @@ public sealed class RetailPViewFrameInput
|
|||
public required int RenderRadius { get; init; }
|
||||
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
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
|
||||
// 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
|
||||
/// frame, avoiding partial visibility state during multi-slice portal drawing.
|
||||
/// </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 RetailSelectionLightingPulse _lightingPulse;
|
||||
|
|
@ -100,6 +112,15 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
|||
(_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(
|
||||
float mouseX,
|
||||
float mouseY,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,22 @@ using AcDream.Core.Vfx;
|
|||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
internal interface IWorldSceneParticleVisibility
|
||||
{
|
||||
void MarkVisibleCells(HashSet<uint> cellIds);
|
||||
|
||||
void CompleteFrame();
|
||||
|
||||
void AbortFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the retained retail PView result into the next physics update's
|
||||
/// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable
|
||||
/// frame meaning: one completed viewer position plus the AC cells admitted by
|
||||
/// that completed view. It neither creates emitters nor performs rendering.
|
||||
/// </summary>
|
||||
public sealed class ParticleVisibilityController
|
||||
public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
|
||||
{
|
||||
public const float ExtendedRangeMultiplier = 2f;
|
||||
|
||||
|
|
@ -70,6 +79,15 @@ public sealed class ParticleVisibilityController
|
|||
_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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(particles);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ internal readonly record struct WorldRenderFrame(
|
|||
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
|
||||
{
|
||||
WorldCameraFrame Resolve();
|
||||
|
|
@ -113,7 +125,7 @@ internal interface IWorldFrameBuildingSource
|
|||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class WorldRenderFrameBuilder
|
||||
internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
|
||||
{
|
||||
private readonly IWorldFrameCameraSource _camera;
|
||||
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);",
|
||||
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
||||
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
||||
"_retailSelectionScene?.BeginFrame();");
|
||||
"_worldSceneRenderer!.Render(frameInput);");
|
||||
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
||||
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
||||
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
||||
|
|
@ -126,12 +126,10 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
Assert.Contains("bool normalWorldDrawn = false;", source);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if (IsLiveModeWaitingForLogin)",
|
||||
"goto SkipWorldGeometry;",
|
||||
"normalWorldDrawn = true;",
|
||||
"_worldSceneRenderer!.Render(frameInput);",
|
||||
"worldOutcome.NormalWorldDrawn;",
|
||||
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
|
||||
"normalWorldDrawn),",
|
||||
"screenshotCaptured)));");
|
||||
|
|
@ -158,8 +156,9 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.Contains(
|
||||
"new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
|
||||
source);
|
||||
Assert.Contains("_terrainDrawDiagnostics!.Begin();", source);
|
||||
Assert.Contains("_terrainDrawDiagnostics.Complete();", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.WorldScenePassExecutor(", source);
|
||||
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
|
||||
Assert.DoesNotContain("_terrainDrawDiagnostics.Complete();", source);
|
||||
}
|
||||
|
||||
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();",
|
||||
"SkyKeyframe sky = _worldTime.CurrentSky;",
|
||||
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
|
||||
"_gl.DepthMask(true);",
|
||||
"_frameGlState.RestoreFrameDefaults();",
|
||||
"_gl.Clear(",
|
||||
"_gl.CullFace(TriangleFace.Back);",
|
||||
"_gl.FrontFace(FrontFaceDirection.CW);",
|
||||
"_diagnostics.EmitGlStateTripwireIfChanged(");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,76 @@ public sealed class RetailAlphaQueueTests
|
|||
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]
|
||||
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
|
||||
{
|
||||
|
|
@ -224,4 +294,28 @@ public sealed class RetailAlphaQueueTests
|
|||
|
||||
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"));
|
||||
|
||||
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("DrawLandscapeSlice =", source);
|
||||
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
||||
|
|
@ -399,15 +400,15 @@ public sealed class RetailPViewPassExecutorTests
|
|||
uint id,
|
||||
uint serverGuid = 0,
|
||||
uint? parentCellId = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
||||
ParentCellId = parentCellId,
|
||||
};
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
||||
ParentCellId = parentCellId,
|
||||
};
|
||||
|
||||
private static string MethodBody(string source, string signature)
|
||||
{
|
||||
|
|
@ -475,6 +476,8 @@ public sealed class RetailPViewPassExecutorTests
|
|||
|
||||
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
|
||||
{
|
||||
public void AbortFrame() => Operations.Add("abort");
|
||||
|
||||
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
||||
|
||||
public List<string> Operations { get; } = [];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,29 @@ public sealed class RetailSelectionSceneTests
|
|||
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(
|
||||
RetailSelectionLightingPulse? pulse = null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,4 +64,35 @@ public sealed class ParticleVisibilityControllerTests
|
|||
controller.Apply(particles, 1f);
|
||||
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]
|
||||
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(
|
||||
1,
|
||||
CountOccurrences(source, "_worldRenderFrameBuilder!.Build("));
|
||||
Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal);
|
||||
CountOccurrences(worldScene, "_frames.Build("));
|
||||
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if (_cameraController is not null && !portalViewportVisible)",
|
||||
"_retailAlphaQueue.BeginFrame();",
|
||||
"_worldRenderFrameBuilder!.Build(",
|
||||
"if (IsLiveModeWaitingForLogin)",
|
||||
"normalWorldDrawn = true;");
|
||||
worldScene,
|
||||
"_alpha.BeginFrame();",
|
||||
"_frames.Build(",
|
||||
"if (_login.IsWaitingForLogin)",
|
||||
"_passes.DrawFlatTerrain(",
|
||||
"NormalWorldDrawn: true");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
gameWindow,
|
||||
"_worldSceneRenderer = null;",
|
||||
"_worldRenderFrameBuilder = null;",
|
||||
"_skyPesFrame = null;",
|
||||
"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