fix(render): S2 chunk 6 review round — one particle turn per cell per stamp, cheap empty cells, dead owner-set stub deleted
Retail-lens review of chunk 6 (no blocking finding). Fixed: - a cell that gets two object-list turns in one frame (a chamber reached through two portals) submitted its emitters twice; retail's particle parts sit in the same shadow_part_list as every other part and CPhysicsPart::Draw's frame stamp suppresses the second draw, so the walk now dedupes the particle turn with the same frame-scoped set that dedupes the cell shell; - every visited land cell paid the full per-cell draw setup even with no emitter; DrawForCell now returns after the cell lookup, retail's own cost (DrawPartCell 0x005a07a0 `num_shadow_parts > 0`); - CopyRenderableEmittersInCell maintains LastRenderScopeEmitterVisitCount; - the OutdoorSceneParticleEntityIds / outdoorOwnerIds stub chain (permanently empty, never read) is deleted through IWorldSceneRenderer, WorldScenePViewRenderer, IWorldScenePasses and the composition root; - AD-117 item 4 names the two behavioral residuals (owner-cell substitution; no per-emission AddPartToShadowCells); - ParticleHookSinkTests pins that an emitter's draw cell is its owner's pose cell and survives the projection-visibility switch across the per-frame view pass. Gates: Core 4,988/4,988 (Vfx 108/108), App hermetic 6,760/6,760, Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7969c2e6ad
commit
6d5afcccde
11 changed files with 891 additions and 800 deletions
|
|
@ -111,7 +111,7 @@ readiness/requeue adaptation. See
|
|||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) PORTED at Campaign OVERHAUL S2 chunk 6: an emitter now owns exactly one draw membership in its own current cell via `ParticleSystem`'s per-pass cell index (`CopyRenderableEmittersInCell`), matching `add_particle_shadow_to_cell` 0x00514a70's own-cell-only, no-clip-planes rule and drawn at that cell's own walk turn independent of its attached owner's registry membership. The residual is architectural, not behavioral: the membership index lives in `ParticleSystem` rather than as a `ShadowObjectRegistry` row, because an emitter is not a `CPhysicsObj` in acdream and never registers with the shadow registry at all. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm), `src/AcDream.Core/Vfx/ParticleSystem.cs` (per-pass cell index, item 4) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleSystem`'s cell index is maintained at every point renderable state or `OwnerCellId` changes (`RefreshRenderableIndex`, `UpdateEmitterOwnerCell`), so an emitter's draw membership always matches its live cell regardless of its attached owner's suspended/hidden registry state. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) None behavioral — a code-location note only: if particle emitters are ever modeled as registry-backed physics objects, this cell index should be retired in favor of a genuine `ShadowObjectRegistry` row rather than kept as a parallel mechanism. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. |
|
||||
| AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) PORTED at Campaign OVERHAUL S2 chunk 6: an emitter now owns exactly one draw membership in its own current cell via `ParticleSystem`'s per-pass cell index (`CopyRenderableEmittersInCell`), matching `add_particle_shadow_to_cell` 0x00514a70's own-cell-only, no-clip-planes rule and drawn at that cell's own walk turn independent of its attached owner's registry membership. The residual is architectural, not behavioral: the membership index lives in `ParticleSystem` rather than as a `ShadowObjectRegistry` row, because an emitter is not a `CPhysicsObj` in acdream and never registers with the shadow registry at all. Two behavioral residuals remain: acdream's emitter cell is its OWNER's pose cell (`ParticleEmitter.OwnerCellId` ← `EntityEffectPoseRegistry.CellId`), where retail's emitter is a standalone `CPhysicsObj` (`makeParticleObject` 0x00512640) with its own position and cell; and retail re-evaluates membership per emission (`ParticleEmitter::EmitParticle` → `CPhysicsObj::AddPartToShadowCells` at 0x0051d126), which acdream does not — a caster in a doorway draws the cloud at the caster's cell turn only. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm), `src/AcDream.Core/Vfx/ParticleSystem.cs` (per-pass cell index, item 4) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleSystem`'s cell index is maintained at every point renderable state or `OwnerCellId` changes (`RefreshRenderableIndex`, `UpdateEmitterOwnerCell`), so an emitter's draw membership always matches its live cell regardless of its attached owner's suspended/hidden registry state. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) None behavioral — a code-location note only: if particle emitters are ever modeled as registry-backed physics objects, this cell index should be retired in favor of a genuine `ShadowObjectRegistry` row rather than kept as a parallel mechanism. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. |
|
||||
| AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover).** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount` and, when nonzero, reported by one print-only `[walk-membership]` line at the start of the next `BeginFrame`, gated on `RenderingDiagnostics.ProbeFacilityStairsEnabled`. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`, the `BeginFrame` diagnostic line) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) |
|
||||
| AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` |
|
||||
| AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` |
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -508,7 +508,6 @@ internal sealed class FrameRootCompositionPhase
|
|||
"The retail frame walk requires the landscape registry."),
|
||||
d.CellVisibility,
|
||||
d.PhysicsEngine.ShadowObjects),
|
||||
retailPViewPassExecutor,
|
||||
retailPViewPassExecutor),
|
||||
retailPViewCells,
|
||||
worldScenePasses,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
return;
|
||||
|
||||
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
|
||||
// Retail's per-cell particle cost for an emitter-less cell is one
|
||||
// count check (RenderDeviceD3D::DrawPartCell 0x005a07a0,
|
||||
// `num_shadow_parts > 0`); every visited land cell reaches here, so
|
||||
// pay nothing more than that here either (chunk 6 review F2).
|
||||
if (_scopedEmitterScratch.Count == 0)
|
||||
return;
|
||||
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
|
||||
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
||||
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
||||
|
|
|
|||
|
|
@ -50,18 +50,12 @@ internal sealed class RetailPViewCellSource : IRetailPViewCellSource
|
|||
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
|
||||
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
||||
/// </summary>
|
||||
internal interface IOutdoorSceneParticleOwnerSource
|
||||
{
|
||||
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6j: backend-neutral. The order it implements is retail's and
|
||||
/// is written once; the four places it touches graphics state directly are owned
|
||||
/// by <see cref="IWorldPassSurface"/>.
|
||||
/// </summary>
|
||||
internal sealed partial class RetailPViewPassExecutor :
|
||||
IOutdoorSceneParticleOwnerSource
|
||||
internal sealed partial class RetailPViewPassExecutor
|
||||
{
|
||||
private readonly IWorldPassSurface _surface;
|
||||
private readonly IRenderFrameGlState _frameGlState;
|
||||
|
|
@ -77,18 +71,7 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
private readonly WorldRenderDiagnostics _diagnostics;
|
||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||
// Campaign OVERHAUL S2 chunk 6: the walk now draws every owner's scene
|
||||
// particles at its own cell's turn (DrawLandscapeStaticParticles/
|
||||
// DrawCellParticles below), so there is no longer a per-frame outdoor
|
||||
// owner set to hand the flat-world safety path's post-world particle
|
||||
// pass — that pass's outdoorOwnerIds parameter is unused on every live
|
||||
// code path (WorldScenePassExecutor.DrawPostWorldParticles) and this
|
||||
// property now always reports empty.
|
||||
private static readonly IReadOnlySet<uint> NoOutdoorSceneParticleEntityIds = new HashSet<uint>();
|
||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
|
||||
NoOutdoorSceneParticleEntityIds;
|
||||
|
||||
public RetailPViewPassExecutor(
|
||||
public RetailPViewPassExecutor(
|
||||
IWorldPassSurface surface,
|
||||
IRenderFrameGlState frameGlState,
|
||||
ClipFrame clipFrame,
|
||||
|
|
|
|||
|
|
@ -519,6 +519,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
|||
private WalkPlane _lookInCyPlane;
|
||||
|
||||
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
|
||||
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
|
||||
private readonly HashSet<uint> _cellParticleTurnsDrawnThisFrame = new();
|
||||
|
||||
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
|
||||
|
||||
|
|
@ -653,6 +655,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
|||
_floodViewRouteScratch.Clear();
|
||||
_dispatcher.EndWalkPartFrame();
|
||||
_cellShellsDrawnThisFrame.Clear();
|
||||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||
_lookInCyPlane = default;
|
||||
LookInCells.Clear();
|
||||
VisitedBuildings.Clear();
|
||||
|
|
@ -787,6 +790,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
|||
_visibleClipSlotScratch.Clear();
|
||||
_lookInCyPlane = ctx.CyPlane;
|
||||
_cellShellsDrawnThisFrame.Clear();
|
||||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||
LookInCells.Clear();
|
||||
VisitedBuildings.Clear();
|
||||
VisitedLandscapeCellIds.Clear();
|
||||
|
|
@ -1249,6 +1253,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
|||
{
|
||||
_dispatcher.AdvanceWalkPartPassStamp();
|
||||
_cellShellsDrawnThisFrame.Clear();
|
||||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||
}
|
||||
|
||||
// PView::DrawCells @0x005a4840: the gated full depth clear
|
||||
|
|
@ -1428,11 +1433,17 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
|||
alphaSubmissions: _alphaSubmissions);
|
||||
MarkIfGrown();
|
||||
MarkAlphaIfGrown();
|
||||
// Fires unconditionally: retail's add_particle_shadow_to_cell draws
|
||||
// an emitter at its own cell's turn independent of whether that cell
|
||||
// has any visible static/dynamic owner record (Campaign OVERHAUL S2
|
||||
// chunk 6 — a suspended/hidden owner's emitter must still show).
|
||||
_events.Add(WalkFrameEvent.CellParticles(cellId));
|
||||
// Fires for every cell turn regardless of whether that cell has any
|
||||
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
|
||||
// a suspended/hidden owner's emitter must still show), but ONCE per
|
||||
// render stamp: retail's particle parts sit in the same shadow_part_list
|
||||
// as every other part, so CPhysicsPart::Draw's frame stamp suppresses a
|
||||
// second submission when one cell gets two object-list turns in one
|
||||
// frame (a chamber reached through two portals). The same frame-scoped
|
||||
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6
|
||||
// review F1).
|
||||
if (_cellParticleTurnsDrawnThisFrame.Add(cellId))
|
||||
_events.Add(WalkFrameEvent.CellParticles(cellId));
|
||||
}
|
||||
|
||||
private void CaptureCellViews(uint cellId)
|
||||
|
|
|
|||
|
|
@ -1,242 +1,240 @@
|
|||
using AcDream.App.Rendering.Sky;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
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 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"/>.
|
||||
///
|
||||
/// <para>Campaign V slice V6j: backend-neutral. Everything it does is either
|
||||
/// delegation to a renderer or one of the four concerns
|
||||
/// <see cref="IWorldPassSurface"/> owns, so one implementation serves both
|
||||
/// backends and the retail ordering is written once.</para>
|
||||
/// </summary>
|
||||
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||
{
|
||||
private readonly IWorldPassSurface _surface;
|
||||
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;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
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,
|
||||
string currentSignature);
|
||||
|
||||
void DrawFlatWeather(
|
||||
in WorldCameraFrame camera,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup,
|
||||
float dayFraction);
|
||||
|
||||
void DisableClipDistances();
|
||||
|
||||
void AbortFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete 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"/>.
|
||||
///
|
||||
/// <para>Campaign V slice V6j: backend-neutral. Everything it does is either
|
||||
/// delegation to a renderer or one of the four concerns
|
||||
/// <see cref="IWorldPassSurface"/> owns, so one implementation serves both
|
||||
/// backends and the retail ordering is written once.</para>
|
||||
/// </summary>
|
||||
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||
{
|
||||
private readonly IWorldPassSurface _surface;
|
||||
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(
|
||||
IWorldPassSurface surface,
|
||||
IRenderFrameGlState frameGlState,
|
||||
ClipFrame clipFrame,
|
||||
WbDrawDispatcher entities,
|
||||
EnvCellRenderer environmentCells,
|
||||
TerrainModernRenderer? terrain,
|
||||
private readonly HashSet<uint> _visibleParticleOwners = [];
|
||||
private readonly HashSet<uint> _noExcludedParticleOwners = [];
|
||||
|
||||
public WorldScenePassExecutor(
|
||||
IWorldPassSurface surface,
|
||||
IRenderFrameGlState frameGlState,
|
||||
ClipFrame clipFrame,
|
||||
WbDrawDispatcher entities,
|
||||
EnvCellRenderer environmentCells,
|
||||
TerrainModernRenderer? terrain,
|
||||
TerrainDrawDiagnosticsController terrainDiagnostics,
|
||||
SkyRenderer? sky,
|
||||
ParticleSystem? particles,
|
||||
ParticleRenderer? particleRenderer)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_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;
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_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() => _surface.PrepareClipFrame(1);
|
||||
|
||||
public void DrawFlatSky(
|
||||
in WorldCameraFrame camera,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup,
|
||||
float dayFraction)
|
||||
{
|
||||
_surface.BindTerrainClip();
|
||||
_surface.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)
|
||||
{
|
||||
_surface.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");
|
||||
}
|
||||
|
||||
// Every PView root, including the outdoor sentinel, now submits scene
|
||||
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||
// and too late: it occurs after nested building cells, allowing exterior
|
||||
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||
return currentSignature;
|
||||
}
|
||||
|
||||
public void DrawFlatWeather(
|
||||
in WorldCameraFrame camera,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup,
|
||||
float dayFraction)
|
||||
{
|
||||
_surface.BindTerrainClip();
|
||||
_surface.EnableClipDistances();
|
||||
Exception? drawFailure = null;
|
||||
}
|
||||
|
||||
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
|
||||
|
||||
public void BeginFrame()
|
||||
{
|
||||
_visibleParticleOwners.Clear();
|
||||
_clipFrame.Reset();
|
||||
_entities.ClearClipRouting();
|
||||
_environmentCells.SetClipRouting(null);
|
||||
}
|
||||
|
||||
public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1);
|
||||
|
||||
public void DrawFlatSky(
|
||||
in WorldCameraFrame camera,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup,
|
||||
float dayFraction)
|
||||
{
|
||||
_surface.BindTerrainClip();
|
||||
_surface.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)
|
||||
{
|
||||
_surface.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,
|
||||
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");
|
||||
}
|
||||
|
||||
// Every PView root, including the outdoor sentinel, now submits scene
|
||||
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||
// and too late: it occurs after nested building cells, allowing exterior
|
||||
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||
return currentSignature;
|
||||
}
|
||||
|
||||
public void DrawFlatWeather(
|
||||
in WorldCameraFrame camera,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup,
|
||||
float dayFraction)
|
||||
{
|
||||
_surface.BindTerrainClip();
|
||||
_surface.EnableClipDistances();
|
||||
Exception? drawFailure = null;
|
||||
try
|
||||
{
|
||||
_sky?.RenderWeather(
|
||||
|
|
@ -246,63 +244,63 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
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() => _surface.DisableClipDistances();
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||
TryAbort(_clipFrame.Reset);
|
||||
TryAbort(_entities.ClearClipRouting);
|
||||
TryAbort(_entities.AbortCurrentRenderSceneObserverFrame);
|
||||
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 static string AppendSignature(string current, string value) =>
|
||||
current == "none" ? value : current + "+" + value;
|
||||
}
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||
TryAbort(_clipFrame.Reset);
|
||||
TryAbort(_entities.ClearClipRouting);
|
||||
TryAbort(_entities.AbortCurrentRenderSceneObserverFrame);
|
||||
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 static string AppendSignature(string current, string value) =>
|
||||
current == "none" ? value : current + "+" + value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,477 +1,470 @@
|
|||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal interface IWorldScenePViewRenderer
|
||||
{
|
||||
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
||||
|
||||
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
|
||||
|
||||
void AbortFrame();
|
||||
}
|
||||
|
||||
internal readonly record struct PreparedWorldSceneFrame(
|
||||
bool ShouldRender,
|
||||
RenderFrameFoundation Foundation,
|
||||
WorldRenderFrame World,
|
||||
int ActiveDayGroup);
|
||||
|
||||
/// <summary>
|
||||
/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and
|
||||
/// borrowed world roots once; the shadow prepass may consume those exact facts
|
||||
/// before the normal world pass executes them without a second PView/build.
|
||||
/// </summary>
|
||||
internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
|
||||
{
|
||||
PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input);
|
||||
|
||||
WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||
RenderFrameInput input,
|
||||
in PreparedWorldSceneFrame prepared);
|
||||
|
||||
void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared);
|
||||
}
|
||||
|
||||
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
||||
{
|
||||
private readonly RetailPViewRenderer _renderer;
|
||||
private readonly RetailPViewPassExecutor _passes;
|
||||
private readonly IOutdoorSceneParticleOwnerSource _particles;
|
||||
|
||||
public WorldScenePViewRenderer(
|
||||
RetailPViewRenderer renderer,
|
||||
RetailPViewPassExecutor 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 : IPreparedWorldSceneFramePhase
|
||||
{
|
||||
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;
|
||||
private readonly IWorldGenerationAvailability _availability;
|
||||
private readonly IAtmosphericWorldFrameSink? _atmosphere;
|
||||
private readonly RetailPViewFrameInput _pviewFrameInput = new();
|
||||
private WorldRenderFrame _preparedEnhancedWorld;
|
||||
private bool _hasPreparedEnhancedWorld;
|
||||
|
||||
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,
|
||||
IWorldGenerationAvailability? availability = null,
|
||||
IAtmosphericWorldFrameSink? atmosphere = null)
|
||||
{
|
||||
_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));
|
||||
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||
_atmosphere = atmosphere;
|
||||
}
|
||||
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
_ = input;
|
||||
bool selectionFrameStarted = _selection is not null;
|
||||
bool worldFrameStarted = false;
|
||||
bool pviewFrameStarted = false;
|
||||
try
|
||||
{
|
||||
// Enhanced rendering builds the canonical camera before this world
|
||||
// transaction so the shadow prepass can consume it. Carry that
|
||||
// exact frustum across BeginFrame: clearing it here would make every
|
||||
// subsequently drawn part fail RetailSelectionScene's visibility
|
||||
// gate, leaving radar selection alive but disabling world picking.
|
||||
FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld
|
||||
? _preparedEnhancedWorld.Camera.Frustum
|
||||
: null;
|
||||
_selection?.BeginFrame(preparedSelectionFrustum);
|
||||
if (!_availability.IsWorldAvailable)
|
||||
{
|
||||
_selection?.CompleteFrame();
|
||||
selectionFrameStarted = false;
|
||||
return default;
|
||||
}
|
||||
|
||||
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;
|
||||
if (_hasPreparedEnhancedWorld)
|
||||
{
|
||||
world = _preparedEnhancedWorld;
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
world = _frames.Build(
|
||||
in foundation,
|
||||
_login.IsWaitingForLogin,
|
||||
_sky.ActiveDayGroup);
|
||||
}
|
||||
_atmosphere?.Publish(
|
||||
in foundation,
|
||||
in world,
|
||||
_sky.ActiveDayGroupIndex);
|
||||
_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);
|
||||
}
|
||||
|
||||
// The former sky-only "waiting for login" skip lived here.
|
||||
// It is unreachable now: LocalPlayerTeleportRenderStateSource
|
||||
// folds IsWaitingForLogin into PortalViewportVisible (retail's
|
||||
// pre-player gameplay screen draws NO world — black, not sky),
|
||||
// so the portal-visible return above already covers every
|
||||
// waiting frame. One gate computes the frame's visibility;
|
||||
// this phase only enforces it.
|
||||
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||
terrainDrawn = true;
|
||||
if (_passes.TerrainVisibleCellIds is { } flatTerrainCells)
|
||||
_particleVisibility.MarkVisibleCells(flatTerrainCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
terrainClipMode = TerrainClipMode.Skip;
|
||||
}
|
||||
|
||||
if (clipRoot is not null)
|
||||
{
|
||||
pviewFrameStarted = true;
|
||||
pviewResult = _pview.DrawInside(
|
||||
_pviewFrameInput.Reset(
|
||||
clipRoot,
|
||||
world.Buildings.NearbyBuildingCells,
|
||||
roots.ViewerEyePosition,
|
||||
camera.ViewProjection,
|
||||
_pviewCells,
|
||||
camera.Camera,
|
||||
camera.Position,
|
||||
camera.Frustum,
|
||||
roots.PlayerLandblockId,
|
||||
world.AnimatedEntityIds,
|
||||
roots.RenderCenterLandblockX,
|
||||
roots.RenderCenterLandblockY,
|
||||
_renderRange.NearRadius,
|
||||
_entities.LandblockEntries,
|
||||
renderSky,
|
||||
roots.PlayerSeenOutside,
|
||||
_sky.DayFraction,
|
||||
_sky.ActiveDayGroup,
|
||||
foundation.Sky,
|
||||
foundation.EnvironOverrideActive,
|
||||
roots.ViewerCellId,
|
||||
roots.PlayerCellId,
|
||||
roots.PlayerViewPosition,
|
||||
camera.Camera.View,
|
||||
_diagnostics.CameraCellResolution));
|
||||
|
||||
// One visibility answer: the walk's exact visited-cell set
|
||||
// feeds effects and the frame environment (point lights and
|
||||
// directional-shadow filtering).
|
||||
_particleVisibility.MarkVisibleCells(pviewResult.VisibleCells);
|
||||
_frames.ObserveDrawableCells(pviewResult.VisibleCells);
|
||||
_diagnostics.EmitPViewInput(
|
||||
pviewResult.VisibleCells,
|
||||
pviewResult.ClipAssembly.OutsideViewSlices.Length,
|
||||
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;
|
||||
RenderProjectionCounts sourceCounts =
|
||||
pviewResult.SourceCounts;
|
||||
if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice)
|
||||
sceneParticles = "pviewScoped";
|
||||
outdoorSceneryDrawn =
|
||||
sourceCounts.OutdoorStatic > 0
|
||||
&& hasOutsideSlice;
|
||||
liveDynamicDrawnCount =
|
||||
sourceCounts.LiveDynamicRoot;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input)
|
||||
{
|
||||
_ = input;
|
||||
if (_hasPreparedEnhancedWorld)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The prior enhanced world preparation was not consumed.");
|
||||
}
|
||||
|
||||
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||
if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible)
|
||||
return new PreparedWorldSceneFrame(false, foundation, default, -1);
|
||||
|
||||
WorldRenderFrame world = _frames.Build(
|
||||
in foundation,
|
||||
_login.IsWaitingForLogin,
|
||||
_sky.ActiveDayGroup);
|
||||
WorldRootFrame roots = world.Roots;
|
||||
world = world with
|
||||
{
|
||||
ResidentStreamingWindow =
|
||||
_entities.CaptureResidentStreamingWindow(
|
||||
roots.RenderCenterLandblockX,
|
||||
roots.RenderCenterLandblockY),
|
||||
CelestialShadowSource =
|
||||
AuthoredCelestialShadowSourceResolver.Resolve(
|
||||
_sky.ActiveDayGroup,
|
||||
_sky.DayFraction,
|
||||
foundation.Sky),
|
||||
};
|
||||
_preparedEnhancedWorld = world;
|
||||
_hasPreparedEnhancedWorld = true;
|
||||
return new PreparedWorldSceneFrame(
|
||||
true,
|
||||
foundation,
|
||||
world,
|
||||
_sky.ActiveDayGroupIndex);
|
||||
}
|
||||
|
||||
public WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||
RenderFrameInput input,
|
||||
in PreparedWorldSceneFrame prepared)
|
||||
{
|
||||
if (prepared.ShouldRender != _hasPreparedEnhancedWorld)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The prepared enhanced-world token does not match the pending frame.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Render(input);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A gate may change between prepare and execute. Never let that
|
||||
// exceptional edge leak borrowed builder scratch into another frame.
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
_preparedEnhancedWorld = default;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared)
|
||||
{
|
||||
if (!prepared.ShouldRender)
|
||||
return;
|
||||
if (!_hasPreparedEnhancedWorld)
|
||||
return;
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
_preparedEnhancedWorld = default;
|
||||
}
|
||||
|
||||
private void CompleteWorldFrame()
|
||||
{
|
||||
_alpha.EndFrame();
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal interface IWorldScenePViewRenderer
|
||||
{
|
||||
|
||||
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
|
||||
|
||||
void AbortFrame();
|
||||
}
|
||||
|
||||
internal readonly record struct PreparedWorldSceneFrame(
|
||||
bool ShouldRender,
|
||||
RenderFrameFoundation Foundation,
|
||||
WorldRenderFrame World,
|
||||
int ActiveDayGroup);
|
||||
|
||||
/// <summary>
|
||||
/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and
|
||||
/// borrowed world roots once; the shadow prepass may consume those exact facts
|
||||
/// before the normal world pass executes them without a second PView/build.
|
||||
/// </summary>
|
||||
internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
|
||||
{
|
||||
PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input);
|
||||
|
||||
WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||
RenderFrameInput input,
|
||||
in PreparedWorldSceneFrame prepared);
|
||||
|
||||
void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared);
|
||||
}
|
||||
|
||||
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
||||
{
|
||||
private readonly RetailPViewRenderer _renderer;
|
||||
private readonly RetailPViewPassExecutor _passes;
|
||||
|
||||
public WorldScenePViewRenderer(
|
||||
RetailPViewRenderer renderer,
|
||||
RetailPViewPassExecutor passes)
|
||||
{
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||
}
|
||||
|
||||
|
||||
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 : IPreparedWorldSceneFramePhase
|
||||
{
|
||||
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;
|
||||
private readonly IWorldGenerationAvailability _availability;
|
||||
private readonly IAtmosphericWorldFrameSink? _atmosphere;
|
||||
private readonly RetailPViewFrameInput _pviewFrameInput = new();
|
||||
private WorldRenderFrame _preparedEnhancedWorld;
|
||||
private bool _hasPreparedEnhancedWorld;
|
||||
|
||||
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,
|
||||
IWorldGenerationAvailability? availability = null,
|
||||
IAtmosphericWorldFrameSink? atmosphere = null)
|
||||
{
|
||||
_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));
|
||||
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||
_atmosphere = atmosphere;
|
||||
}
|
||||
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
_ = input;
|
||||
bool selectionFrameStarted = _selection is not null;
|
||||
bool worldFrameStarted = false;
|
||||
bool pviewFrameStarted = false;
|
||||
try
|
||||
{
|
||||
// Enhanced rendering builds the canonical camera before this world
|
||||
// transaction so the shadow prepass can consume it. Carry that
|
||||
// exact frustum across BeginFrame: clearing it here would make every
|
||||
// subsequently drawn part fail RetailSelectionScene's visibility
|
||||
// gate, leaving radar selection alive but disabling world picking.
|
||||
FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld
|
||||
? _preparedEnhancedWorld.Camera.Frustum
|
||||
: null;
|
||||
_selection?.BeginFrame(preparedSelectionFrustum);
|
||||
if (!_availability.IsWorldAvailable)
|
||||
{
|
||||
_selection?.CompleteFrame();
|
||||
selectionFrameStarted = false;
|
||||
return default;
|
||||
}
|
||||
|
||||
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;
|
||||
if (_hasPreparedEnhancedWorld)
|
||||
{
|
||||
world = _preparedEnhancedWorld;
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
world = _frames.Build(
|
||||
in foundation,
|
||||
_login.IsWaitingForLogin,
|
||||
_sky.ActiveDayGroup);
|
||||
}
|
||||
_atmosphere?.Publish(
|
||||
in foundation,
|
||||
in world,
|
||||
_sky.ActiveDayGroupIndex);
|
||||
_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);
|
||||
}
|
||||
|
||||
// The former sky-only "waiting for login" skip lived here.
|
||||
// It is unreachable now: LocalPlayerTeleportRenderStateSource
|
||||
// folds IsWaitingForLogin into PortalViewportVisible (retail's
|
||||
// pre-player gameplay screen draws NO world — black, not sky),
|
||||
// so the portal-visible return above already covers every
|
||||
// waiting frame. One gate computes the frame's visibility;
|
||||
// this phase only enforces it.
|
||||
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||
terrainDrawn = true;
|
||||
if (_passes.TerrainVisibleCellIds is { } flatTerrainCells)
|
||||
_particleVisibility.MarkVisibleCells(flatTerrainCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
terrainClipMode = TerrainClipMode.Skip;
|
||||
}
|
||||
|
||||
if (clipRoot is not null)
|
||||
{
|
||||
pviewFrameStarted = true;
|
||||
pviewResult = _pview.DrawInside(
|
||||
_pviewFrameInput.Reset(
|
||||
clipRoot,
|
||||
world.Buildings.NearbyBuildingCells,
|
||||
roots.ViewerEyePosition,
|
||||
camera.ViewProjection,
|
||||
_pviewCells,
|
||||
camera.Camera,
|
||||
camera.Position,
|
||||
camera.Frustum,
|
||||
roots.PlayerLandblockId,
|
||||
world.AnimatedEntityIds,
|
||||
roots.RenderCenterLandblockX,
|
||||
roots.RenderCenterLandblockY,
|
||||
_renderRange.NearRadius,
|
||||
_entities.LandblockEntries,
|
||||
renderSky,
|
||||
roots.PlayerSeenOutside,
|
||||
_sky.DayFraction,
|
||||
_sky.ActiveDayGroup,
|
||||
foundation.Sky,
|
||||
foundation.EnvironOverrideActive,
|
||||
roots.ViewerCellId,
|
||||
roots.PlayerCellId,
|
||||
roots.PlayerViewPosition,
|
||||
camera.Camera.View,
|
||||
_diagnostics.CameraCellResolution));
|
||||
|
||||
// One visibility answer: the walk's exact visited-cell set
|
||||
// feeds effects and the frame environment (point lights and
|
||||
// directional-shadow filtering).
|
||||
_particleVisibility.MarkVisibleCells(pviewResult.VisibleCells);
|
||||
_frames.ObserveDrawableCells(pviewResult.VisibleCells);
|
||||
_diagnostics.EmitPViewInput(
|
||||
pviewResult.VisibleCells,
|
||||
pviewResult.ClipAssembly.OutsideViewSlices.Length,
|
||||
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;
|
||||
RenderProjectionCounts sourceCounts =
|
||||
pviewResult.SourceCounts;
|
||||
if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice)
|
||||
sceneParticles = "pviewScoped";
|
||||
outdoorSceneryDrawn =
|
||||
sourceCounts.OutdoorStatic > 0
|
||||
&& hasOutsideSlice;
|
||||
liveDynamicDrawnCount =
|
||||
sourceCounts.LiveDynamicRoot;
|
||||
}
|
||||
else
|
||||
{
|
||||
_passes.DrawFlatEntities(
|
||||
in camera,
|
||||
_entities.LandblockEntries,
|
||||
roots.PlayerLandblockId,
|
||||
world.AnimatedEntityIds);
|
||||
_frames.ClearDrawableCells();
|
||||
}
|
||||
|
||||
_passes.DisableClipDistances();
|
||||
sceneParticles = _passes.DrawPostWorldParticles(
|
||||
clipRoot,
|
||||
pviewResult?.ClipAssembly,
|
||||
in camera,
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input)
|
||||
{
|
||||
_ = input;
|
||||
if (_hasPreparedEnhancedWorld)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The prior enhanced world preparation was not consumed.");
|
||||
}
|
||||
|
||||
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||
if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible)
|
||||
return new PreparedWorldSceneFrame(false, foundation, default, -1);
|
||||
|
||||
WorldRenderFrame world = _frames.Build(
|
||||
in foundation,
|
||||
_login.IsWaitingForLogin,
|
||||
_sky.ActiveDayGroup);
|
||||
WorldRootFrame roots = world.Roots;
|
||||
world = world with
|
||||
{
|
||||
ResidentStreamingWindow =
|
||||
_entities.CaptureResidentStreamingWindow(
|
||||
roots.RenderCenterLandblockX,
|
||||
roots.RenderCenterLandblockY),
|
||||
CelestialShadowSource =
|
||||
AuthoredCelestialShadowSourceResolver.Resolve(
|
||||
_sky.ActiveDayGroup,
|
||||
_sky.DayFraction,
|
||||
foundation.Sky),
|
||||
};
|
||||
_preparedEnhancedWorld = world;
|
||||
_hasPreparedEnhancedWorld = true;
|
||||
return new PreparedWorldSceneFrame(
|
||||
true,
|
||||
foundation,
|
||||
world,
|
||||
_sky.ActiveDayGroupIndex);
|
||||
}
|
||||
|
||||
public WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||
RenderFrameInput input,
|
||||
in PreparedWorldSceneFrame prepared)
|
||||
{
|
||||
if (prepared.ShouldRender != _hasPreparedEnhancedWorld)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The prepared enhanced-world token does not match the pending frame.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Render(input);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A gate may change between prepare and execute. Never let that
|
||||
// exceptional edge leak borrowed builder scratch into another frame.
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
_preparedEnhancedWorld = default;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared)
|
||||
{
|
||||
if (!prepared.ShouldRender)
|
||||
return;
|
||||
if (!_hasPreparedEnhancedWorld)
|
||||
return;
|
||||
_hasPreparedEnhancedWorld = false;
|
||||
_preparedEnhancedWorld = default;
|
||||
}
|
||||
|
||||
private void CompleteWorldFrame()
|
||||
{
|
||||
_alpha.EndFrame();
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -624,12 +624,14 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
destination.Clear();
|
||||
LastRenderScopeEmitterVisitCount = 0;
|
||||
int passIndex = RenderPassIndex(renderPass);
|
||||
if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket))
|
||||
return;
|
||||
|
||||
_scopeHandleScratch.Clear();
|
||||
bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
|
||||
LastRenderScopeEmitterVisitCount = _scopeHandleScratch.Count;
|
||||
foreach (int handle in _scopeHandleScratch)
|
||||
{
|
||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
||||
|
|
|
|||
|
|
@ -313,8 +313,6 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
||||
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
|
||||
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
|
||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -756,9 +754,6 @@ public sealed class WorldSceneRendererTests
|
|||
diagnosticPartition: null);
|
||||
}
|
||||
|
||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
|
||||
new HashSet<uint> { 0xCAFEu };
|
||||
|
||||
public RetailPViewFrameInput? LastInput { get; private set; }
|
||||
|
||||
public bool ThrowOnDraw { get; set; }
|
||||
|
|
@ -800,7 +795,6 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
public float WeatherDayFraction { get; private set; }
|
||||
|
||||
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
|
||||
|
||||
public void BeginFrame() => calls.Add("passes:begin");
|
||||
|
||||
|
|
@ -837,10 +831,8 @@ public sealed class WorldSceneRendererTests
|
|||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlySet<uint> outdoorOwnerIds,
|
||||
string currentSignature)
|
||||
{
|
||||
ParticleOwners = outdoorOwnerIds;
|
||||
string kind = clipRoot switch
|
||||
{
|
||||
null => "global",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,113 @@ public sealed class ParticleHookSinkTests
|
|||
.AnchorPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 6 (review F6): an emitter's draw membership
|
||||
/// is its OWNER's pose cell, published through the sink, and nothing else
|
||||
/// — no registry row, no owner "spatial" state. Retail
|
||||
/// <c>add_particle_shadow_to_cell</c> (0x00514a70) gives the emitter one
|
||||
/// shadow in its own cell, drawn at that cell's turn regardless of the
|
||||
/// parent's hidden state; the only presentation gate the sink owns is the
|
||||
/// explicit projection-visibility switch, which is NOT the retail Hidden
|
||||
/// state. Route retail-Hidden into that switch and the portal cloud
|
||||
/// vanishes again — this pin is what fails first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmitterKeepsItsOwnerCellAndStaysRenderableWithNoOwnerPresenceBesidesThePose()
|
||||
{
|
||||
const uint emitterId = 0x3200_0777u;
|
||||
const uint cell = 0x8A02015Eu;
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(MakeDesc(emitterId, attachLocal: false, totalParticles: 0, totalDuration: 0f));
|
||||
var system = new ParticleSystem(registry, new Random(42));
|
||||
var poses = new CellPoseSource();
|
||||
poses.Publish(Owner, Matrix4x4.CreateTranslation(3, 4, 5), cell);
|
||||
var sink = new ParticleHookSink(system, poses);
|
||||
|
||||
sink.OnHook(Owner, new Vector3(3, 4, 5), Create(emitterId, logicalId: 7u));
|
||||
ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
|
||||
Assert.Equal(cell, emitter.OwnerCellId);
|
||||
|
||||
// The retail ShouldDrawParticles gate: the owner cell is in view.
|
||||
sink.RefreshAttachedEmitters();
|
||||
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
|
||||
Assert.True(emitter.PresentationVisible, "presentation visible");
|
||||
Assert.True(emitter.ViewEligible, "view eligible (owner cell in view, in range)");
|
||||
Assert.Equal(ParticleRenderPass.Scene, emitter.RenderPass);
|
||||
var byOwner = new List<ParticleEmitter>();
|
||||
system.CopyRenderableEmittersForOwners(
|
||||
ParticleRenderPass.Scene, new HashSet<uint> { Owner }, includeUnattached: false, byOwner);
|
||||
Assert.Single(byOwner);
|
||||
var inCell = new List<ParticleEmitter>();
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Single(inCell);
|
||||
Assert.Same(emitter, inCell[0]);
|
||||
|
||||
// Only the sink's explicit projection-visibility switch removes it
|
||||
// from the cell's renderable set — and only until it flips back.
|
||||
sink.SetEntityPresentationVisible(Owner, false);
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Empty(inCell);
|
||||
sink.SetEntityPresentationVisible(Owner, true);
|
||||
sink.RefreshAttachedEmitters();
|
||||
// The next frame's view pass re-evaluates eligibility (production
|
||||
// cadence: bindings refresh, then ApplyRetailView, then the walk).
|
||||
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Single(inCell);
|
||||
Assert.Equal(cell, emitter.OwnerCellId);
|
||||
}
|
||||
|
||||
private sealed class CellPoseSource :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectCellSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
{
|
||||
private readonly Dictionary<uint, (Matrix4x4 Root, uint Cell)> _poses = new();
|
||||
public event Action<uint>? EffectPoseChanged;
|
||||
|
||||
public void Publish(uint id, Matrix4x4 root, uint cellId)
|
||||
{
|
||||
_poses[id] = (root, cellId);
|
||||
EffectPoseChanged?.Invoke(id);
|
||||
}
|
||||
|
||||
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out var pose))
|
||||
{
|
||||
rootWorld = pose.Root;
|
||||
return true;
|
||||
}
|
||||
rootWorld = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
|
||||
{
|
||||
// One identity part at index 0, the shape the sink resolves an
|
||||
// emitter anchor through before it spawns.
|
||||
if (partIndex == 0 && _poses.ContainsKey(localEntityId))
|
||||
{
|
||||
partLocal = Matrix4x4.Identity;
|
||||
return true;
|
||||
}
|
||||
partLocal = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetCellId(uint localEntityId, out uint cellId)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out var pose))
|
||||
{
|
||||
cellId = pose.Cell;
|
||||
return true;
|
||||
}
|
||||
cellId = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MutablePoseSource :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue