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 |
|
| # | 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-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-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` |
|
| 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."),
|
"The retail frame walk requires the landscape registry."),
|
||||||
d.CellVisibility,
|
d.CellVisibility,
|
||||||
d.PhysicsEngine.ShadowObjects),
|
d.PhysicsEngine.ShadowObjects),
|
||||||
retailPViewPassExecutor,
|
|
||||||
retailPViewPassExecutor),
|
retailPViewPassExecutor),
|
||||||
retailPViewCells,
|
retailPViewCells,
|
||||||
worldScenePasses,
|
worldScenePasses,
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
|
_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);
|
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
|
||||||
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
||||||
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
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
|
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
|
||||||
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
/// placement follows <c>LScape::draw @ 0x00506330</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal interface IOutdoorSceneParticleOwnerSource
|
|
||||||
{
|
|
||||||
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign V slice V6j: backend-neutral. The order it implements is retail's and
|
/// 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
|
/// is written once; the four places it touches graphics state directly are owned
|
||||||
/// by <see cref="IWorldPassSurface"/>.
|
/// by <see cref="IWorldPassSurface"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class RetailPViewPassExecutor :
|
internal sealed partial class RetailPViewPassExecutor
|
||||||
IOutdoorSceneParticleOwnerSource
|
|
||||||
{
|
{
|
||||||
private readonly IWorldPassSurface _surface;
|
private readonly IWorldPassSurface _surface;
|
||||||
private readonly IRenderFrameGlState _frameGlState;
|
private readonly IRenderFrameGlState _frameGlState;
|
||||||
|
|
@ -77,18 +71,7 @@ internal sealed partial class RetailPViewPassExecutor :
|
||||||
private readonly WorldRenderDiagnostics _diagnostics;
|
private readonly WorldRenderDiagnostics _diagnostics;
|
||||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||||
// Campaign OVERHAUL S2 chunk 6: the walk now draws every owner's scene
|
public RetailPViewPassExecutor(
|
||||||
// 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(
|
|
||||||
IWorldPassSurface surface,
|
IWorldPassSurface surface,
|
||||||
IRenderFrameGlState frameGlState,
|
IRenderFrameGlState frameGlState,
|
||||||
ClipFrame clipFrame,
|
ClipFrame clipFrame,
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
private WalkPlane _lookInCyPlane;
|
private WalkPlane _lookInCyPlane;
|
||||||
|
|
||||||
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
|
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;
|
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
|
||||||
|
|
||||||
|
|
@ -653,6 +655,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
_floodViewRouteScratch.Clear();
|
_floodViewRouteScratch.Clear();
|
||||||
_dispatcher.EndWalkPartFrame();
|
_dispatcher.EndWalkPartFrame();
|
||||||
_cellShellsDrawnThisFrame.Clear();
|
_cellShellsDrawnThisFrame.Clear();
|
||||||
|
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||||
_lookInCyPlane = default;
|
_lookInCyPlane = default;
|
||||||
LookInCells.Clear();
|
LookInCells.Clear();
|
||||||
VisitedBuildings.Clear();
|
VisitedBuildings.Clear();
|
||||||
|
|
@ -787,6 +790,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
_visibleClipSlotScratch.Clear();
|
_visibleClipSlotScratch.Clear();
|
||||||
_lookInCyPlane = ctx.CyPlane;
|
_lookInCyPlane = ctx.CyPlane;
|
||||||
_cellShellsDrawnThisFrame.Clear();
|
_cellShellsDrawnThisFrame.Clear();
|
||||||
|
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||||
LookInCells.Clear();
|
LookInCells.Clear();
|
||||||
VisitedBuildings.Clear();
|
VisitedBuildings.Clear();
|
||||||
VisitedLandscapeCellIds.Clear();
|
VisitedLandscapeCellIds.Clear();
|
||||||
|
|
@ -1249,6 +1253,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
{
|
{
|
||||||
_dispatcher.AdvanceWalkPartPassStamp();
|
_dispatcher.AdvanceWalkPartPassStamp();
|
||||||
_cellShellsDrawnThisFrame.Clear();
|
_cellShellsDrawnThisFrame.Clear();
|
||||||
|
_cellParticleTurnsDrawnThisFrame.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// PView::DrawCells @0x005a4840: the gated full depth clear
|
// PView::DrawCells @0x005a4840: the gated full depth clear
|
||||||
|
|
@ -1428,11 +1433,17 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
alphaSubmissions: _alphaSubmissions);
|
alphaSubmissions: _alphaSubmissions);
|
||||||
MarkIfGrown();
|
MarkIfGrown();
|
||||||
MarkAlphaIfGrown();
|
MarkAlphaIfGrown();
|
||||||
// Fires unconditionally: retail's add_particle_shadow_to_cell draws
|
// Fires for every cell turn regardless of whether that cell has any
|
||||||
// an emitter at its own cell's turn independent of whether that cell
|
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
|
||||||
// has any visible static/dynamic owner record (Campaign OVERHAUL S2
|
// a suspended/hidden owner's emitter must still show), but ONCE per
|
||||||
// chunk 6 — a suspended/hidden owner's emitter must still show).
|
// render stamp: retail's particle parts sit in the same shadow_part_list
|
||||||
_events.Add(WalkFrameEvent.CellParticles(cellId));
|
// 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)
|
private void CaptureCellViews(uint cellId)
|
||||||
|
|
|
||||||
|
|
@ -1,242 +1,240 @@
|
||||||
using AcDream.App.Rendering.Sky;
|
using AcDream.App.Rendering.Sky;
|
||||||
using AcDream.App.Rendering.Wb;
|
using AcDream.App.Rendering.Wb;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
using AcDream.Core.Vfx;
|
using AcDream.Core.Vfx;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
internal interface IWorldScenePassExecutor
|
internal interface IWorldScenePassExecutor
|
||||||
{
|
{
|
||||||
HashSet<uint>? TerrainVisibleCellIds { get; }
|
HashSet<uint>? TerrainVisibleCellIds { get; }
|
||||||
|
|
||||||
void BeginFrame();
|
void BeginFrame();
|
||||||
|
|
||||||
void PrepareFlatWorldClip();
|
void PrepareFlatWorldClip();
|
||||||
|
|
||||||
void DrawFlatSky(
|
void DrawFlatSky(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
in RenderFrameFoundation foundation,
|
in RenderFrameFoundation foundation,
|
||||||
DayGroupData? activeDayGroup,
|
DayGroupData? activeDayGroup,
|
||||||
float dayFraction);
|
float dayFraction);
|
||||||
|
|
||||||
void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId);
|
void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId);
|
||||||
|
|
||||||
void DrawFlatEntities(
|
void DrawFlatEntities(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
||||||
System.Numerics.Vector3 AabbMax,
|
System.Numerics.Vector3 AabbMax,
|
||||||
IReadOnlyList<WorldEntity> Entities,
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
||||||
uint? playerLandblockId,
|
uint? playerLandblockId,
|
||||||
HashSet<uint> animatedEntityIds);
|
HashSet<uint> animatedEntityIds);
|
||||||
|
|
||||||
string DrawPostWorldParticles(
|
string DrawPostWorldParticles(
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlySet<uint> outdoorOwnerIds,
|
string currentSignature);
|
||||||
string currentSignature);
|
|
||||||
|
void DrawFlatWeather(
|
||||||
void DrawFlatWeather(
|
in WorldCameraFrame camera,
|
||||||
in WorldCameraFrame camera,
|
in RenderFrameFoundation foundation,
|
||||||
in RenderFrameFoundation foundation,
|
DayGroupData? activeDayGroup,
|
||||||
DayGroupData? activeDayGroup,
|
float dayFraction);
|
||||||
float dayFraction);
|
|
||||||
|
void DisableClipDistances();
|
||||||
void DisableClipDistances();
|
|
||||||
|
void AbortFrame();
|
||||||
void AbortFrame();
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
/// <summary>
|
/// Concrete leaf for the flat-world safety path and the post-world particle
|
||||||
/// Concrete leaf for the flat-world safety path and the post-world particle
|
/// and weather passes. Retail PView frames remain owned by
|
||||||
/// and weather passes. Retail PView frames remain owned by
|
/// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>.
|
||||||
/// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>.
|
///
|
||||||
///
|
/// <para>Campaign V slice V6j: backend-neutral. Everything it does is either
|
||||||
/// <para>Campaign V slice V6j: backend-neutral. Everything it does is either
|
/// delegation to a renderer or one of the four concerns
|
||||||
/// delegation to a renderer or one of the four concerns
|
/// <see cref="IWorldPassSurface"/> owns, so one implementation serves both
|
||||||
/// <see cref="IWorldPassSurface"/> owns, so one implementation serves both
|
/// backends and the retail ordering is written once.</para>
|
||||||
/// backends and the retail ordering is written once.</para>
|
/// </summary>
|
||||||
/// </summary>
|
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
{
|
||||||
{
|
private readonly IWorldPassSurface _surface;
|
||||||
private readonly IWorldPassSurface _surface;
|
private readonly IRenderFrameGlState _frameGlState;
|
||||||
private readonly IRenderFrameGlState _frameGlState;
|
private readonly ClipFrame _clipFrame;
|
||||||
private readonly ClipFrame _clipFrame;
|
private readonly WbDrawDispatcher _entities;
|
||||||
private readonly WbDrawDispatcher _entities;
|
private readonly EnvCellRenderer _environmentCells;
|
||||||
private readonly EnvCellRenderer _environmentCells;
|
private readonly TerrainModernRenderer? _terrain;
|
||||||
private readonly TerrainModernRenderer? _terrain;
|
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
private readonly SkyRenderer? _sky;
|
||||||
private readonly SkyRenderer? _sky;
|
|
||||||
private readonly ParticleSystem? _particles;
|
private readonly ParticleSystem? _particles;
|
||||||
private readonly ParticleRenderer? _particleRenderer;
|
private readonly ParticleRenderer? _particleRenderer;
|
||||||
private readonly HashSet<uint> _visibleParticleOwners = [];
|
private readonly HashSet<uint> _visibleParticleOwners = [];
|
||||||
private readonly HashSet<uint> _noExcludedParticleOwners = [];
|
private readonly HashSet<uint> _noExcludedParticleOwners = [];
|
||||||
|
|
||||||
public WorldScenePassExecutor(
|
public WorldScenePassExecutor(
|
||||||
IWorldPassSurface surface,
|
IWorldPassSurface surface,
|
||||||
IRenderFrameGlState frameGlState,
|
IRenderFrameGlState frameGlState,
|
||||||
ClipFrame clipFrame,
|
ClipFrame clipFrame,
|
||||||
WbDrawDispatcher entities,
|
WbDrawDispatcher entities,
|
||||||
EnvCellRenderer environmentCells,
|
EnvCellRenderer environmentCells,
|
||||||
TerrainModernRenderer? terrain,
|
TerrainModernRenderer? terrain,
|
||||||
TerrainDrawDiagnosticsController terrainDiagnostics,
|
TerrainDrawDiagnosticsController terrainDiagnostics,
|
||||||
SkyRenderer? sky,
|
SkyRenderer? sky,
|
||||||
ParticleSystem? particles,
|
ParticleSystem? particles,
|
||||||
ParticleRenderer? particleRenderer)
|
ParticleRenderer? particleRenderer)
|
||||||
{
|
{
|
||||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||||
_frameGlState = frameGlState
|
_frameGlState = frameGlState
|
||||||
?? throw new ArgumentNullException(nameof(frameGlState));
|
?? throw new ArgumentNullException(nameof(frameGlState));
|
||||||
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
|
||||||
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||||
_environmentCells = environmentCells
|
_environmentCells = environmentCells
|
||||||
?? throw new ArgumentNullException(nameof(environmentCells));
|
?? throw new ArgumentNullException(nameof(environmentCells));
|
||||||
_terrain = terrain;
|
_terrain = terrain;
|
||||||
_terrainDiagnostics = terrainDiagnostics
|
_terrainDiagnostics = terrainDiagnostics
|
||||||
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
||||||
_sky = sky;
|
_sky = sky;
|
||||||
_particles = particles;
|
_particles = particles;
|
||||||
_particleRenderer = particleRenderer;
|
_particleRenderer = particleRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
|
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
|
||||||
|
|
||||||
public void BeginFrame()
|
public void BeginFrame()
|
||||||
{
|
{
|
||||||
_visibleParticleOwners.Clear();
|
_visibleParticleOwners.Clear();
|
||||||
_clipFrame.Reset();
|
_clipFrame.Reset();
|
||||||
_entities.ClearClipRouting();
|
_entities.ClearClipRouting();
|
||||||
_environmentCells.SetClipRouting(null);
|
_environmentCells.SetClipRouting(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1);
|
public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1);
|
||||||
|
|
||||||
public void DrawFlatSky(
|
public void DrawFlatSky(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
in RenderFrameFoundation foundation,
|
in RenderFrameFoundation foundation,
|
||||||
DayGroupData? activeDayGroup,
|
DayGroupData? activeDayGroup,
|
||||||
float dayFraction)
|
float dayFraction)
|
||||||
{
|
{
|
||||||
_surface.BindTerrainClip();
|
_surface.BindTerrainClip();
|
||||||
_surface.EnableClipDistances();
|
_surface.EnableClipDistances();
|
||||||
Exception? drawFailure = null;
|
Exception? drawFailure = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sky?.RenderSky(
|
_sky?.RenderSky(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
camera.Position,
|
camera.Position,
|
||||||
dayFraction,
|
dayFraction,
|
||||||
activeDayGroup,
|
activeDayGroup,
|
||||||
foundation.Sky,
|
foundation.Sky,
|
||||||
foundation.EnvironOverrideActive);
|
foundation.EnvironOverrideActive);
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
drawFailure = error;
|
drawFailure = error;
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
catch (Exception closeFailure) when (drawFailure is not null)
|
catch (Exception closeFailure) when (drawFailure is not null)
|
||||||
{
|
{
|
||||||
throw new AggregateException(
|
throw new AggregateException(
|
||||||
"Sky drawing failed and its clip-distance bracket could not be closed.",
|
"Sky drawing failed and its clip-distance bracket could not be closed.",
|
||||||
drawFailure,
|
drawFailure,
|
||||||
closeFailure);
|
closeFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_particles is not null && _particleRenderer is not null)
|
if (_particles is not null && _particleRenderer is not null)
|
||||||
{
|
{
|
||||||
_particleRenderer.Draw(
|
_particleRenderer.Draw(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
camera.Position,
|
camera.Position,
|
||||||
ParticleRenderPass.SkyPreScene);
|
ParticleRenderPass.SkyPreScene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DrawFlatTerrain(
|
public void DrawFlatTerrain(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
uint? playerLandblockId)
|
uint? playerLandblockId)
|
||||||
{
|
{
|
||||||
_surface.EnableClipDistances();
|
_surface.EnableClipDistances();
|
||||||
_terrainDiagnostics.Begin();
|
_terrainDiagnostics.Begin();
|
||||||
_terrain?.Draw(
|
_terrain?.Draw(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
camera.Frustum,
|
camera.Frustum,
|
||||||
neverCullLandblockId: playerLandblockId);
|
neverCullLandblockId: playerLandblockId);
|
||||||
_terrainDiagnostics.Complete();
|
_terrainDiagnostics.Complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DrawFlatEntities(
|
public void DrawFlatEntities(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
|
||||||
System.Numerics.Vector3 AabbMax,
|
System.Numerics.Vector3 AabbMax,
|
||||||
IReadOnlyList<WorldEntity> Entities,
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
|
||||||
uint? playerLandblockId,
|
uint? playerLandblockId,
|
||||||
HashSet<uint> animatedEntityIds) =>
|
HashSet<uint> animatedEntityIds) =>
|
||||||
_entities.Draw(
|
_entities.Draw(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
entries,
|
entries,
|
||||||
camera.Frustum,
|
camera.Frustum,
|
||||||
neverCullLandblockId: playerLandblockId,
|
neverCullLandblockId: playerLandblockId,
|
||||||
visibleCellIds: null,
|
visibleCellIds: null,
|
||||||
animatedEntityIds: animatedEntityIds);
|
animatedEntityIds: animatedEntityIds);
|
||||||
|
|
||||||
public string DrawPostWorldParticles(
|
public string DrawPostWorldParticles(
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlySet<uint> outdoorOwnerIds,
|
string currentSignature)
|
||||||
string currentSignature)
|
{
|
||||||
{
|
if (_particles is null || _particleRenderer is null)
|
||||||
if (_particles is null || _particleRenderer is null)
|
return currentSignature;
|
||||||
return currentSignature;
|
|
||||||
|
if (clipRoot is null)
|
||||||
if (clipRoot is null)
|
{
|
||||||
{
|
if (clipAssembly is not null)
|
||||||
if (clipAssembly is not null)
|
{
|
||||||
{
|
_particleRenderer.DrawForOwners(
|
||||||
_particleRenderer.DrawForOwners(
|
camera.Camera,
|
||||||
camera.Camera,
|
camera.Position,
|
||||||
camera.Position,
|
ParticleRenderPass.Scene,
|
||||||
ParticleRenderPass.Scene,
|
_visibleParticleOwners,
|
||||||
_visibleParticleOwners,
|
includeUnattached: true,
|
||||||
includeUnattached: true,
|
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
||||||
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
return AppendSignature(currentSignature, "filtered");
|
||||||
return AppendSignature(currentSignature, "filtered");
|
}
|
||||||
}
|
|
||||||
|
_particleRenderer.Draw(
|
||||||
_particleRenderer.Draw(
|
camera.Camera,
|
||||||
camera.Camera,
|
camera.Position,
|
||||||
camera.Position,
|
ParticleRenderPass.Scene);
|
||||||
ParticleRenderPass.Scene);
|
return AppendSignature(currentSignature, "global");
|
||||||
return AppendSignature(currentSignature, "global");
|
}
|
||||||
}
|
|
||||||
|
// Every PView root, including the outdoor sentinel, now submits scene
|
||||||
// Every PView root, including the outdoor sentinel, now submits scene
|
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||||
// particles inside LScape::draw. Replaying them here is both a duplicate
|
// and too late: it occurs after nested building cells, allowing exterior
|
||||||
// and too late: it occurs after nested building cells, allowing exterior
|
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||||
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
return currentSignature;
|
||||||
return currentSignature;
|
}
|
||||||
}
|
|
||||||
|
public void DrawFlatWeather(
|
||||||
public void DrawFlatWeather(
|
in WorldCameraFrame camera,
|
||||||
in WorldCameraFrame camera,
|
in RenderFrameFoundation foundation,
|
||||||
in RenderFrameFoundation foundation,
|
DayGroupData? activeDayGroup,
|
||||||
DayGroupData? activeDayGroup,
|
float dayFraction)
|
||||||
float dayFraction)
|
{
|
||||||
{
|
_surface.BindTerrainClip();
|
||||||
_surface.BindTerrainClip();
|
_surface.EnableClipDistances();
|
||||||
_surface.EnableClipDistances();
|
Exception? drawFailure = null;
|
||||||
Exception? drawFailure = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sky?.RenderWeather(
|
_sky?.RenderWeather(
|
||||||
|
|
@ -246,63 +244,63 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
activeDayGroup,
|
activeDayGroup,
|
||||||
foundation.Sky,
|
foundation.Sky,
|
||||||
foundation.EnvironOverrideActive);
|
foundation.EnvironOverrideActive);
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
drawFailure = error;
|
drawFailure = error;
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
catch (Exception closeFailure) when (drawFailure is not null)
|
catch (Exception closeFailure) when (drawFailure is not null)
|
||||||
{
|
{
|
||||||
throw new AggregateException(
|
throw new AggregateException(
|
||||||
"Weather drawing failed and its clip-distance bracket could not be closed.",
|
"Weather drawing failed and its clip-distance bracket could not be closed.",
|
||||||
drawFailure,
|
drawFailure,
|
||||||
closeFailure);
|
closeFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_particles is not null && _particleRenderer is not null)
|
if (_particles is not null && _particleRenderer is not null)
|
||||||
{
|
{
|
||||||
_particleRenderer.Draw(
|
_particleRenderer.Draw(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
camera.Position,
|
camera.Position,
|
||||||
ParticleRenderPass.SkyPostScene);
|
ParticleRenderPass.SkyPostScene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DisableClipDistances() => _surface.DisableClipDistances();
|
public void DisableClipDistances() => _surface.DisableClipDistances();
|
||||||
|
|
||||||
public void AbortFrame()
|
public void AbortFrame()
|
||||||
{
|
{
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
TryAbort(_frameGlState.RestoreFrameDefaults);
|
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||||
TryAbort(_clipFrame.Reset);
|
TryAbort(_clipFrame.Reset);
|
||||||
TryAbort(_entities.ClearClipRouting);
|
TryAbort(_entities.ClearClipRouting);
|
||||||
TryAbort(_entities.AbortCurrentRenderSceneObserverFrame);
|
TryAbort(_entities.AbortCurrentRenderSceneObserverFrame);
|
||||||
TryAbort(() => _environmentCells.SetClipRouting(null));
|
TryAbort(() => _environmentCells.SetClipRouting(null));
|
||||||
_visibleParticleOwners.Clear();
|
_visibleParticleOwners.Clear();
|
||||||
if (failures is { Count: > 0 })
|
if (failures is { Count: > 0 })
|
||||||
throw new AggregateException("World scene pass abort failed.", failures);
|
throw new AggregateException("World scene pass abort failed.", failures);
|
||||||
|
|
||||||
void TryAbort(Action operation)
|
void TryAbort(Action operation)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
operation();
|
operation();
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
(failures ??= []).Add(error);
|
(failures ??= []).Add(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string AppendSignature(string current, string value) =>
|
private static string AppendSignature(string current, string value) =>
|
||||||
current == "none" ? value : current + "+" + value;
|
current == "none" ? value : current + "+" + value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,477 +1,470 @@
|
||||||
using AcDream.App.Rendering.Scene;
|
using AcDream.App.Rendering.Scene;
|
||||||
using AcDream.App.Rendering.Selection;
|
using AcDream.App.Rendering.Selection;
|
||||||
using AcDream.App.Rendering.Packs;
|
using AcDream.App.Rendering.Packs;
|
||||||
using AcDream.App.Rendering.Vfx;
|
using AcDream.App.Rendering.Vfx;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
internal interface IWorldScenePViewRenderer
|
internal interface IWorldScenePViewRenderer
|
||||||
{
|
{
|
||||||
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
|
||||||
|
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
|
||||||
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
|
|
||||||
|
void AbortFrame();
|
||||||
void AbortFrame();
|
}
|
||||||
}
|
|
||||||
|
internal readonly record struct PreparedWorldSceneFrame(
|
||||||
internal readonly record struct PreparedWorldSceneFrame(
|
bool ShouldRender,
|
||||||
bool ShouldRender,
|
RenderFrameFoundation Foundation,
|
||||||
RenderFrameFoundation Foundation,
|
WorldRenderFrame World,
|
||||||
WorldRenderFrame World,
|
int ActiveDayGroup);
|
||||||
int ActiveDayGroup);
|
|
||||||
|
/// <summary>
|
||||||
/// <summary>
|
/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and
|
||||||
/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and
|
/// borrowed world roots once; the shadow prepass may consume those exact facts
|
||||||
/// borrowed world roots once; the shadow prepass may consume those exact facts
|
/// before the normal world pass executes them without a second PView/build.
|
||||||
/// before the normal world pass executes them without a second PView/build.
|
/// </summary>
|
||||||
/// </summary>
|
internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
|
||||||
internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
|
{
|
||||||
{
|
PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input);
|
||||||
PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input);
|
|
||||||
|
WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||||
WorldRenderFrameOutcome RenderPreparedEnhanced(
|
RenderFrameInput input,
|
||||||
RenderFrameInput input,
|
in PreparedWorldSceneFrame prepared);
|
||||||
in PreparedWorldSceneFrame prepared);
|
|
||||||
|
void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared);
|
||||||
void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared);
|
}
|
||||||
}
|
|
||||||
|
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
||||||
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
{
|
||||||
{
|
private readonly RetailPViewRenderer _renderer;
|
||||||
private readonly RetailPViewRenderer _renderer;
|
private readonly RetailPViewPassExecutor _passes;
|
||||||
private readonly RetailPViewPassExecutor _passes;
|
|
||||||
private readonly IOutdoorSceneParticleOwnerSource _particles;
|
public WorldScenePViewRenderer(
|
||||||
|
RetailPViewRenderer renderer,
|
||||||
public WorldScenePViewRenderer(
|
RetailPViewPassExecutor passes)
|
||||||
RetailPViewRenderer renderer,
|
{
|
||||||
RetailPViewPassExecutor passes,
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||||
IOutdoorSceneParticleOwnerSource particles)
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
{
|
}
|
||||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
|
||||||
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
|
||||||
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) =>
|
||||||
}
|
_renderer.DrawInside(input, _passes);
|
||||||
|
|
||||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
|
public void AbortFrame() => _passes.AbortFrame();
|
||||||
_particles.OutdoorSceneParticleEntityIds;
|
}
|
||||||
|
|
||||||
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) =>
|
/// <summary>
|
||||||
_renderer.DrawInside(input, _passes);
|
/// Owns the normal-world render transaction between shared frame-resource
|
||||||
|
/// preparation and private presentation. It preserves retail's one PView
|
||||||
public void AbortFrame() => _passes.AbortFrame();
|
/// decision: a resolved viewer root uses DrawInside; only an unresolved root
|
||||||
}
|
/// uses the flat safety path.
|
||||||
|
/// </summary>
|
||||||
/// <summary>
|
internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
/// Owns the normal-world render transaction between shared frame-resource
|
{
|
||||||
/// preparation and private presentation. It preserves retail's one PView
|
private readonly IRenderFrameFoundationSource _foundation;
|
||||||
/// decision: a resolved viewer root uses DrawInside; only an unresolved root
|
private readonly IRenderLoginStateSource _login;
|
||||||
/// uses the flat safety path.
|
private readonly IWorldSceneSkyStateSource _sky;
|
||||||
/// </summary>
|
private readonly IWorldRenderFrameBuilder _frames;
|
||||||
internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
private readonly IWorldSceneEntitySource _entities;
|
||||||
{
|
private readonly IWorldSceneSelectionFrame? _selection;
|
||||||
private readonly IRenderFrameFoundationSource _foundation;
|
private readonly IWorldSceneAlphaFrame _alpha;
|
||||||
private readonly IRenderLoginStateSource _login;
|
private readonly IWorldSceneParticleVisibility _particleVisibility;
|
||||||
private readonly IWorldSceneSkyStateSource _sky;
|
private readonly IWorldScenePViewRenderer _pview;
|
||||||
private readonly IWorldRenderFrameBuilder _frames;
|
private readonly IRetailPViewCellSource _pviewCells;
|
||||||
private readonly IWorldSceneEntitySource _entities;
|
private readonly IWorldScenePassExecutor _passes;
|
||||||
private readonly IWorldSceneSelectionFrame? _selection;
|
private readonly IWorldRenderRangeSource _renderRange;
|
||||||
private readonly IWorldSceneAlphaFrame _alpha;
|
private readonly IWorldSceneDiagnostics _diagnostics;
|
||||||
private readonly IWorldSceneParticleVisibility _particleVisibility;
|
private readonly IWorldGenerationAvailability _availability;
|
||||||
private readonly IWorldScenePViewRenderer _pview;
|
private readonly IAtmosphericWorldFrameSink? _atmosphere;
|
||||||
private readonly IRetailPViewCellSource _pviewCells;
|
private readonly RetailPViewFrameInput _pviewFrameInput = new();
|
||||||
private readonly IWorldScenePassExecutor _passes;
|
private WorldRenderFrame _preparedEnhancedWorld;
|
||||||
private readonly IWorldRenderRangeSource _renderRange;
|
private bool _hasPreparedEnhancedWorld;
|
||||||
private readonly IWorldSceneDiagnostics _diagnostics;
|
|
||||||
private readonly IWorldGenerationAvailability _availability;
|
public WorldSceneRenderer(
|
||||||
private readonly IAtmosphericWorldFrameSink? _atmosphere;
|
IRenderFrameFoundationSource foundation,
|
||||||
private readonly RetailPViewFrameInput _pviewFrameInput = new();
|
IRenderLoginStateSource login,
|
||||||
private WorldRenderFrame _preparedEnhancedWorld;
|
IWorldSceneSkyStateSource sky,
|
||||||
private bool _hasPreparedEnhancedWorld;
|
IWorldRenderFrameBuilder frames,
|
||||||
|
IWorldSceneEntitySource entities,
|
||||||
public WorldSceneRenderer(
|
IWorldSceneSelectionFrame? selection,
|
||||||
IRenderFrameFoundationSource foundation,
|
IWorldSceneAlphaFrame alpha,
|
||||||
IRenderLoginStateSource login,
|
IWorldSceneParticleVisibility particleVisibility,
|
||||||
IWorldSceneSkyStateSource sky,
|
IWorldScenePViewRenderer pview,
|
||||||
IWorldRenderFrameBuilder frames,
|
IRetailPViewCellSource pviewCells,
|
||||||
IWorldSceneEntitySource entities,
|
IWorldScenePassExecutor passes,
|
||||||
IWorldSceneSelectionFrame? selection,
|
IWorldRenderRangeSource renderRange,
|
||||||
IWorldSceneAlphaFrame alpha,
|
IWorldSceneDiagnostics diagnostics,
|
||||||
IWorldSceneParticleVisibility particleVisibility,
|
IWorldGenerationAvailability? availability = null,
|
||||||
IWorldScenePViewRenderer pview,
|
IAtmosphericWorldFrameSink? atmosphere = null)
|
||||||
IRetailPViewCellSource pviewCells,
|
{
|
||||||
IWorldScenePassExecutor passes,
|
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
|
||||||
IWorldRenderRangeSource renderRange,
|
_login = login ?? throw new ArgumentNullException(nameof(login));
|
||||||
IWorldSceneDiagnostics diagnostics,
|
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||||
IWorldGenerationAvailability? availability = null,
|
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||||||
IAtmosphericWorldFrameSink? atmosphere = null)
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||||
{
|
_selection = selection;
|
||||||
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
|
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
||||||
_login = login ?? throw new ArgumentNullException(nameof(login));
|
_particleVisibility = particleVisibility
|
||||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
?? throw new ArgumentNullException(nameof(particleVisibility));
|
||||||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
||||||
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
_pviewCells = pviewCells ?? throw new ArgumentNullException(nameof(pviewCells));
|
||||||
_selection = selection;
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
||||||
_particleVisibility = particleVisibility
|
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
?? throw new ArgumentNullException(nameof(particleVisibility));
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||||
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
_atmosphere = atmosphere;
|
||||||
_pviewCells = pviewCells ?? throw new ArgumentNullException(nameof(pviewCells));
|
}
|
||||||
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
|
||||||
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
{
|
||||||
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
_ = input;
|
||||||
_atmosphere = atmosphere;
|
bool selectionFrameStarted = _selection is not null;
|
||||||
}
|
bool worldFrameStarted = false;
|
||||||
|
bool pviewFrameStarted = false;
|
||||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
try
|
||||||
{
|
{
|
||||||
_ = input;
|
// Enhanced rendering builds the canonical camera before this world
|
||||||
bool selectionFrameStarted = _selection is not null;
|
// transaction so the shadow prepass can consume it. Carry that
|
||||||
bool worldFrameStarted = false;
|
// exact frustum across BeginFrame: clearing it here would make every
|
||||||
bool pviewFrameStarted = false;
|
// subsequently drawn part fail RetailSelectionScene's visibility
|
||||||
try
|
// gate, leaving radar selection alive but disabling world picking.
|
||||||
{
|
FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld
|
||||||
// Enhanced rendering builds the canonical camera before this world
|
? _preparedEnhancedWorld.Camera.Frustum
|
||||||
// transaction so the shadow prepass can consume it. Carry that
|
: null;
|
||||||
// exact frustum across BeginFrame: clearing it here would make every
|
_selection?.BeginFrame(preparedSelectionFrustum);
|
||||||
// subsequently drawn part fail RetailSelectionScene's visibility
|
if (!_availability.IsWorldAvailable)
|
||||||
// gate, leaving radar selection alive but disabling world picking.
|
{
|
||||||
FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld
|
_selection?.CompleteFrame();
|
||||||
? _preparedEnhancedWorld.Camera.Frustum
|
selectionFrameStarted = false;
|
||||||
: null;
|
return default;
|
||||||
_selection?.BeginFrame(preparedSelectionFrustum);
|
}
|
||||||
if (!_availability.IsWorldAvailable)
|
|
||||||
{
|
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||||
_selection?.CompleteFrame();
|
if (foundation.PortalViewportVisible)
|
||||||
selectionFrameStarted = false;
|
{
|
||||||
return default;
|
_selection?.CompleteFrame();
|
||||||
}
|
selectionFrameStarted = false;
|
||||||
|
return default;
|
||||||
RenderFrameFoundation foundation = _foundation.Foundation;
|
}
|
||||||
if (foundation.PortalViewportVisible)
|
|
||||||
{
|
// Set before BeginFrame so an already-poisoned queue is recovered by
|
||||||
_selection?.CompleteFrame();
|
// the same abort path instead of making every later frame fail.
|
||||||
selectionFrameStarted = false;
|
worldFrameStarted = true;
|
||||||
return default;
|
_alpha.BeginFrame();
|
||||||
}
|
WorldRenderFrame world;
|
||||||
|
if (_hasPreparedEnhancedWorld)
|
||||||
// Set before BeginFrame so an already-poisoned queue is recovered by
|
{
|
||||||
// the same abort path instead of making every later frame fail.
|
world = _preparedEnhancedWorld;
|
||||||
worldFrameStarted = true;
|
_hasPreparedEnhancedWorld = false;
|
||||||
_alpha.BeginFrame();
|
}
|
||||||
WorldRenderFrame world;
|
else
|
||||||
if (_hasPreparedEnhancedWorld)
|
{
|
||||||
{
|
world = _frames.Build(
|
||||||
world = _preparedEnhancedWorld;
|
in foundation,
|
||||||
_hasPreparedEnhancedWorld = false;
|
_login.IsWaitingForLogin,
|
||||||
}
|
_sky.ActiveDayGroup);
|
||||||
else
|
}
|
||||||
{
|
_atmosphere?.Publish(
|
||||||
world = _frames.Build(
|
in foundation,
|
||||||
in foundation,
|
in world,
|
||||||
_login.IsWaitingForLogin,
|
_sky.ActiveDayGroupIndex);
|
||||||
_sky.ActiveDayGroup);
|
_passes.BeginFrame();
|
||||||
}
|
|
||||||
_atmosphere?.Publish(
|
WorldCameraFrame camera = world.Camera;
|
||||||
in foundation,
|
WorldRootFrame roots = world.Roots;
|
||||||
in world,
|
LoadedCell? clipRoot = world.ClipRoot;
|
||||||
_sky.ActiveDayGroupIndex);
|
bool renderSky = roots.RenderSky;
|
||||||
_passes.BeginFrame();
|
bool drawSkyThisFrame = false;
|
||||||
|
bool terrainDrawn = false;
|
||||||
WorldCameraFrame camera = world.Camera;
|
bool skyDrawn = false;
|
||||||
WorldRootFrame roots = world.Roots;
|
bool depthClear = false;
|
||||||
LoadedCell? clipRoot = world.ClipRoot;
|
bool outdoorSceneryDrawn = false;
|
||||||
bool renderSky = roots.RenderSky;
|
int liveDynamicDrawnCount = 0;
|
||||||
bool drawSkyThisFrame = false;
|
string sceneParticles = "none";
|
||||||
bool terrainDrawn = false;
|
TerrainClipMode terrainClipMode = TerrainClipMode.Planes;
|
||||||
bool skyDrawn = false;
|
RetailPViewFrameResult? pviewResult = null;
|
||||||
bool depthClear = false;
|
|
||||||
bool outdoorSceneryDrawn = false;
|
if (clipRoot is null)
|
||||||
int liveDynamicDrawnCount = 0;
|
{
|
||||||
string sceneParticles = "none";
|
_passes.PrepareFlatWorldClip();
|
||||||
TerrainClipMode terrainClipMode = TerrainClipMode.Planes;
|
drawSkyThisFrame = renderSky;
|
||||||
RetailPViewFrameResult? pviewResult = null;
|
skyDrawn = drawSkyThisFrame;
|
||||||
|
if (drawSkyThisFrame)
|
||||||
if (clipRoot is null)
|
{
|
||||||
{
|
_passes.DrawFlatSky(
|
||||||
_passes.PrepareFlatWorldClip();
|
in camera,
|
||||||
drawSkyThisFrame = renderSky;
|
in foundation,
|
||||||
skyDrawn = drawSkyThisFrame;
|
_sky.ActiveDayGroup,
|
||||||
if (drawSkyThisFrame)
|
_sky.DayFraction);
|
||||||
{
|
}
|
||||||
_passes.DrawFlatSky(
|
|
||||||
in camera,
|
// The former sky-only "waiting for login" skip lived here.
|
||||||
in foundation,
|
// It is unreachable now: LocalPlayerTeleportRenderStateSource
|
||||||
_sky.ActiveDayGroup,
|
// folds IsWaitingForLogin into PortalViewportVisible (retail's
|
||||||
_sky.DayFraction);
|
// 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;
|
||||||
// The former sky-only "waiting for login" skip lived here.
|
// this phase only enforces it.
|
||||||
// It is unreachable now: LocalPlayerTeleportRenderStateSource
|
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||||
// folds IsWaitingForLogin into PortalViewportVisible (retail's
|
terrainDrawn = true;
|
||||||
// pre-player gameplay screen draws NO world — black, not sky),
|
if (_passes.TerrainVisibleCellIds is { } flatTerrainCells)
|
||||||
// so the portal-visible return above already covers every
|
_particleVisibility.MarkVisibleCells(flatTerrainCells);
|
||||||
// waiting frame. One gate computes the frame's visibility;
|
}
|
||||||
// this phase only enforces it.
|
else
|
||||||
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
{
|
||||||
terrainDrawn = true;
|
terrainClipMode = TerrainClipMode.Skip;
|
||||||
if (_passes.TerrainVisibleCellIds is { } flatTerrainCells)
|
}
|
||||||
_particleVisibility.MarkVisibleCells(flatTerrainCells);
|
|
||||||
}
|
if (clipRoot is not null)
|
||||||
else
|
{
|
||||||
{
|
pviewFrameStarted = true;
|
||||||
terrainClipMode = TerrainClipMode.Skip;
|
pviewResult = _pview.DrawInside(
|
||||||
}
|
_pviewFrameInput.Reset(
|
||||||
|
clipRoot,
|
||||||
if (clipRoot is not null)
|
world.Buildings.NearbyBuildingCells,
|
||||||
{
|
roots.ViewerEyePosition,
|
||||||
pviewFrameStarted = true;
|
camera.ViewProjection,
|
||||||
pviewResult = _pview.DrawInside(
|
_pviewCells,
|
||||||
_pviewFrameInput.Reset(
|
camera.Camera,
|
||||||
clipRoot,
|
camera.Position,
|
||||||
world.Buildings.NearbyBuildingCells,
|
camera.Frustum,
|
||||||
roots.ViewerEyePosition,
|
roots.PlayerLandblockId,
|
||||||
camera.ViewProjection,
|
world.AnimatedEntityIds,
|
||||||
_pviewCells,
|
roots.RenderCenterLandblockX,
|
||||||
camera.Camera,
|
roots.RenderCenterLandblockY,
|
||||||
camera.Position,
|
_renderRange.NearRadius,
|
||||||
camera.Frustum,
|
_entities.LandblockEntries,
|
||||||
roots.PlayerLandblockId,
|
renderSky,
|
||||||
world.AnimatedEntityIds,
|
roots.PlayerSeenOutside,
|
||||||
roots.RenderCenterLandblockX,
|
_sky.DayFraction,
|
||||||
roots.RenderCenterLandblockY,
|
_sky.ActiveDayGroup,
|
||||||
_renderRange.NearRadius,
|
foundation.Sky,
|
||||||
_entities.LandblockEntries,
|
foundation.EnvironOverrideActive,
|
||||||
renderSky,
|
roots.ViewerCellId,
|
||||||
roots.PlayerSeenOutside,
|
roots.PlayerCellId,
|
||||||
_sky.DayFraction,
|
roots.PlayerViewPosition,
|
||||||
_sky.ActiveDayGroup,
|
camera.Camera.View,
|
||||||
foundation.Sky,
|
_diagnostics.CameraCellResolution));
|
||||||
foundation.EnvironOverrideActive,
|
|
||||||
roots.ViewerCellId,
|
// One visibility answer: the walk's exact visited-cell set
|
||||||
roots.PlayerCellId,
|
// feeds effects and the frame environment (point lights and
|
||||||
roots.PlayerViewPosition,
|
// directional-shadow filtering).
|
||||||
camera.Camera.View,
|
_particleVisibility.MarkVisibleCells(pviewResult.VisibleCells);
|
||||||
_diagnostics.CameraCellResolution));
|
_frames.ObserveDrawableCells(pviewResult.VisibleCells);
|
||||||
|
_diagnostics.EmitPViewInput(
|
||||||
// One visibility answer: the walk's exact visited-cell set
|
pviewResult.VisibleCells,
|
||||||
// feeds effects and the frame environment (point lights and
|
pviewResult.ClipAssembly.OutsideViewSlices.Length,
|
||||||
// directional-shadow filtering).
|
camera.ViewProjection,
|
||||||
_particleVisibility.MarkVisibleCells(pviewResult.VisibleCells);
|
clipRoot,
|
||||||
_frames.ObserveDrawableCells(pviewResult.VisibleCells);
|
camera.Position,
|
||||||
_diagnostics.EmitPViewInput(
|
roots.PlayerViewPosition);
|
||||||
pviewResult.VisibleCells,
|
|
||||||
pviewResult.ClipAssembly.OutsideViewSlices.Length,
|
bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
||||||
camera.ViewProjection,
|
terrainDrawn = hasOutsideSlice;
|
||||||
clipRoot,
|
skyDrawn = renderSky && hasOutsideSlice;
|
||||||
camera.Position,
|
// This mirrors the established diagnostic meaning, including
|
||||||
roots.PlayerViewPosition);
|
// outdoor roots whose PView executor does not issue an interior
|
||||||
|
// depth clear.
|
||||||
bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
depthClear = hasOutsideSlice;
|
||||||
terrainDrawn = hasOutsideSlice;
|
RenderProjectionCounts sourceCounts =
|
||||||
skyDrawn = renderSky && hasOutsideSlice;
|
pviewResult.SourceCounts;
|
||||||
// This mirrors the established diagnostic meaning, including
|
if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice)
|
||||||
// outdoor roots whose PView executor does not issue an interior
|
sceneParticles = "pviewScoped";
|
||||||
// depth clear.
|
outdoorSceneryDrawn =
|
||||||
depthClear = hasOutsideSlice;
|
sourceCounts.OutdoorStatic > 0
|
||||||
RenderProjectionCounts sourceCounts =
|
&& hasOutsideSlice;
|
||||||
pviewResult.SourceCounts;
|
liveDynamicDrawnCount =
|
||||||
if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice)
|
sourceCounts.LiveDynamicRoot;
|
||||||
sceneParticles = "pviewScoped";
|
}
|
||||||
outdoorSceneryDrawn =
|
else
|
||||||
sourceCounts.OutdoorStatic > 0
|
{
|
||||||
&& hasOutsideSlice;
|
_passes.DrawFlatEntities(
|
||||||
liveDynamicDrawnCount =
|
in camera,
|
||||||
sourceCounts.LiveDynamicRoot;
|
_entities.LandblockEntries,
|
||||||
}
|
roots.PlayerLandblockId,
|
||||||
else
|
world.AnimatedEntityIds);
|
||||||
{
|
_frames.ClearDrawableCells();
|
||||||
_passes.DrawFlatEntities(
|
}
|
||||||
in camera,
|
|
||||||
_entities.LandblockEntries,
|
_passes.DisableClipDistances();
|
||||||
roots.PlayerLandblockId,
|
sceneParticles = _passes.DrawPostWorldParticles(
|
||||||
world.AnimatedEntityIds);
|
clipRoot,
|
||||||
_frames.ClearDrawableCells();
|
pviewResult?.ClipAssembly,
|
||||||
}
|
in camera,
|
||||||
|
sceneParticles);
|
||||||
_passes.DisableClipDistances();
|
|
||||||
sceneParticles = _passes.DrawPostWorldParticles(
|
if (clipRoot is null && drawSkyThisFrame)
|
||||||
clipRoot,
|
{
|
||||||
pviewResult?.ClipAssembly,
|
skyDrawn = true;
|
||||||
in camera,
|
_passes.DrawFlatWeather(
|
||||||
_pview.OutdoorSceneParticleEntityIds,
|
in camera,
|
||||||
sceneParticles);
|
in foundation,
|
||||||
|
_sky.ActiveDayGroup,
|
||||||
if (clipRoot is null && drawSkyThisFrame)
|
_sky.DayFraction);
|
||||||
{
|
}
|
||||||
skyDrawn = true;
|
|
||||||
_passes.DrawFlatWeather(
|
_diagnostics.EmitRenderSignature(
|
||||||
in camera,
|
clipRoot is null ? "OutdoorRoot" : "RetailPViewInside",
|
||||||
in foundation,
|
clipRoot,
|
||||||
_sky.ActiveDayGroup,
|
roots.ViewerRoot,
|
||||||
_sky.DayFraction);
|
roots.PlayerRoot,
|
||||||
}
|
roots.ViewerCellId,
|
||||||
|
roots.PlayerCellId,
|
||||||
_diagnostics.EmitRenderSignature(
|
roots.PlayerIndoorGate,
|
||||||
clipRoot is null ? "OutdoorRoot" : "RetailPViewInside",
|
roots.CameraInsideCell,
|
||||||
clipRoot,
|
renderSky,
|
||||||
roots.ViewerRoot,
|
drawSkyThisFrame,
|
||||||
roots.PlayerRoot,
|
terrainDrawn,
|
||||||
roots.ViewerCellId,
|
terrainClipMode,
|
||||||
roots.PlayerCellId,
|
skyDrawn,
|
||||||
roots.PlayerIndoorGate,
|
depthClear,
|
||||||
roots.CameraInsideCell,
|
outdoorSceneryDrawn,
|
||||||
renderSky,
|
liveDynamicDrawnCount,
|
||||||
drawSkyThisFrame,
|
sceneParticles,
|
||||||
terrainDrawn,
|
pviewResult,
|
||||||
terrainClipMode,
|
camera.Position,
|
||||||
skyDrawn,
|
roots.PlayerViewPosition);
|
||||||
depthClear,
|
|
||||||
outdoorSceneryDrawn,
|
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
|
||||||
liveDynamicDrawnCount,
|
in camera,
|
||||||
sceneParticles,
|
_entities.LandblockBounds);
|
||||||
pviewResult,
|
CompleteWorldFrame();
|
||||||
camera.Position,
|
worldFrameStarted = false;
|
||||||
roots.PlayerViewPosition);
|
pviewFrameStarted = false;
|
||||||
|
_selection?.CompleteFrame();
|
||||||
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
|
selectionFrameStarted = false;
|
||||||
in camera,
|
return new WorldRenderFrameOutcome(
|
||||||
_entities.LandblockBounds);
|
diagnostic.VisibleLandblocks,
|
||||||
CompleteWorldFrame();
|
diagnostic.TotalLandblocks,
|
||||||
worldFrameStarted = false;
|
NormalWorldDrawn: true);
|
||||||
pviewFrameStarted = false;
|
}
|
||||||
_selection?.CompleteFrame();
|
catch (Exception renderFailure)
|
||||||
selectionFrameStarted = false;
|
{
|
||||||
return new WorldRenderFrameOutcome(
|
List<Exception>? abortFailures = AbortFrame(
|
||||||
diagnostic.VisibleLandblocks,
|
worldFrameStarted,
|
||||||
diagnostic.TotalLandblocks,
|
pviewFrameStarted,
|
||||||
NormalWorldDrawn: true);
|
selectionFrameStarted);
|
||||||
}
|
if (abortFailures is { Count: > 0 })
|
||||||
catch (Exception renderFailure)
|
{
|
||||||
{
|
abortFailures.Insert(0, renderFailure);
|
||||||
List<Exception>? abortFailures = AbortFrame(
|
throw new AggregateException(
|
||||||
worldFrameStarted,
|
"World rendering failed and the incomplete frame could not be fully aborted.",
|
||||||
pviewFrameStarted,
|
abortFailures);
|
||||||
selectionFrameStarted);
|
}
|
||||||
if (abortFailures is { Count: > 0 })
|
throw;
|
||||||
{
|
}
|
||||||
abortFailures.Insert(0, renderFailure);
|
}
|
||||||
throw new AggregateException(
|
|
||||||
"World rendering failed and the incomplete frame could not be fully aborted.",
|
public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input)
|
||||||
abortFailures);
|
{
|
||||||
}
|
_ = input;
|
||||||
throw;
|
if (_hasPreparedEnhancedWorld)
|
||||||
}
|
{
|
||||||
}
|
throw new InvalidOperationException(
|
||||||
|
"The prior enhanced world preparation was not consumed.");
|
||||||
public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input)
|
}
|
||||||
{
|
|
||||||
_ = input;
|
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||||
if (_hasPreparedEnhancedWorld)
|
if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible)
|
||||||
{
|
return new PreparedWorldSceneFrame(false, foundation, default, -1);
|
||||||
throw new InvalidOperationException(
|
|
||||||
"The prior enhanced world preparation was not consumed.");
|
WorldRenderFrame world = _frames.Build(
|
||||||
}
|
in foundation,
|
||||||
|
_login.IsWaitingForLogin,
|
||||||
RenderFrameFoundation foundation = _foundation.Foundation;
|
_sky.ActiveDayGroup);
|
||||||
if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible)
|
WorldRootFrame roots = world.Roots;
|
||||||
return new PreparedWorldSceneFrame(false, foundation, default, -1);
|
world = world with
|
||||||
|
{
|
||||||
WorldRenderFrame world = _frames.Build(
|
ResidentStreamingWindow =
|
||||||
in foundation,
|
_entities.CaptureResidentStreamingWindow(
|
||||||
_login.IsWaitingForLogin,
|
roots.RenderCenterLandblockX,
|
||||||
_sky.ActiveDayGroup);
|
roots.RenderCenterLandblockY),
|
||||||
WorldRootFrame roots = world.Roots;
|
CelestialShadowSource =
|
||||||
world = world with
|
AuthoredCelestialShadowSourceResolver.Resolve(
|
||||||
{
|
_sky.ActiveDayGroup,
|
||||||
ResidentStreamingWindow =
|
_sky.DayFraction,
|
||||||
_entities.CaptureResidentStreamingWindow(
|
foundation.Sky),
|
||||||
roots.RenderCenterLandblockX,
|
};
|
||||||
roots.RenderCenterLandblockY),
|
_preparedEnhancedWorld = world;
|
||||||
CelestialShadowSource =
|
_hasPreparedEnhancedWorld = true;
|
||||||
AuthoredCelestialShadowSourceResolver.Resolve(
|
return new PreparedWorldSceneFrame(
|
||||||
_sky.ActiveDayGroup,
|
true,
|
||||||
_sky.DayFraction,
|
foundation,
|
||||||
foundation.Sky),
|
world,
|
||||||
};
|
_sky.ActiveDayGroupIndex);
|
||||||
_preparedEnhancedWorld = world;
|
}
|
||||||
_hasPreparedEnhancedWorld = true;
|
|
||||||
return new PreparedWorldSceneFrame(
|
public WorldRenderFrameOutcome RenderPreparedEnhanced(
|
||||||
true,
|
RenderFrameInput input,
|
||||||
foundation,
|
in PreparedWorldSceneFrame prepared)
|
||||||
world,
|
{
|
||||||
_sky.ActiveDayGroupIndex);
|
if (prepared.ShouldRender != _hasPreparedEnhancedWorld)
|
||||||
}
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
public WorldRenderFrameOutcome RenderPreparedEnhanced(
|
"The prepared enhanced-world token does not match the pending frame.");
|
||||||
RenderFrameInput input,
|
}
|
||||||
in PreparedWorldSceneFrame prepared)
|
|
||||||
{
|
try
|
||||||
if (prepared.ShouldRender != _hasPreparedEnhancedWorld)
|
{
|
||||||
{
|
return Render(input);
|
||||||
throw new InvalidOperationException(
|
}
|
||||||
"The prepared enhanced-world token does not match the pending frame.");
|
finally
|
||||||
}
|
{
|
||||||
|
// A gate may change between prepare and execute. Never let that
|
||||||
try
|
// exceptional edge leak borrowed builder scratch into another frame.
|
||||||
{
|
_hasPreparedEnhancedWorld = false;
|
||||||
return Render(input);
|
_preparedEnhancedWorld = default;
|
||||||
}
|
}
|
||||||
finally
|
}
|
||||||
{
|
|
||||||
// A gate may change between prepare and execute. Never let that
|
public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared)
|
||||||
// exceptional edge leak borrowed builder scratch into another frame.
|
{
|
||||||
_hasPreparedEnhancedWorld = false;
|
if (!prepared.ShouldRender)
|
||||||
_preparedEnhancedWorld = default;
|
return;
|
||||||
}
|
if (!_hasPreparedEnhancedWorld)
|
||||||
}
|
return;
|
||||||
|
_hasPreparedEnhancedWorld = false;
|
||||||
public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared)
|
_preparedEnhancedWorld = default;
|
||||||
{
|
}
|
||||||
if (!prepared.ShouldRender)
|
|
||||||
return;
|
private void CompleteWorldFrame()
|
||||||
if (!_hasPreparedEnhancedWorld)
|
{
|
||||||
return;
|
_alpha.EndFrame();
|
||||||
_hasPreparedEnhancedWorld = false;
|
_particleVisibility.CompleteFrame();
|
||||||
_preparedEnhancedWorld = default;
|
}
|
||||||
}
|
|
||||||
|
private List<Exception>? AbortFrame(
|
||||||
private void CompleteWorldFrame()
|
bool worldFrameStarted,
|
||||||
{
|
bool pviewFrameStarted,
|
||||||
_alpha.EndFrame();
|
bool selectionFrameStarted)
|
||||||
_particleVisibility.CompleteFrame();
|
{
|
||||||
}
|
List<Exception>? failures = null;
|
||||||
|
if (worldFrameStarted)
|
||||||
private List<Exception>? AbortFrame(
|
{
|
||||||
bool worldFrameStarted,
|
if (pviewFrameStarted)
|
||||||
bool pviewFrameStarted,
|
TryAbort(_pview.AbortFrame);
|
||||||
bool selectionFrameStarted)
|
TryAbort(_passes.AbortFrame);
|
||||||
{
|
TryAbort(_particleVisibility.AbortFrame);
|
||||||
List<Exception>? failures = null;
|
TryAbort(_alpha.AbortFrame);
|
||||||
if (worldFrameStarted)
|
}
|
||||||
{
|
if (selectionFrameStarted && _selection is not null)
|
||||||
if (pviewFrameStarted)
|
TryAbort(_selection.AbortFrame);
|
||||||
TryAbort(_pview.AbortFrame);
|
return failures;
|
||||||
TryAbort(_passes.AbortFrame);
|
|
||||||
TryAbort(_particleVisibility.AbortFrame);
|
void TryAbort(Action abort)
|
||||||
TryAbort(_alpha.AbortFrame);
|
{
|
||||||
}
|
try
|
||||||
if (selectionFrameStarted && _selection is not null)
|
{
|
||||||
TryAbort(_selection.AbortFrame);
|
abort();
|
||||||
return failures;
|
}
|
||||||
|
catch (Exception error)
|
||||||
void TryAbort(Action abort)
|
{
|
||||||
{
|
(failures ??= []).Add(error);
|
||||||
try
|
}
|
||||||
{
|
}
|
||||||
abort();
|
}
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
(failures ??= []).Add(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -624,12 +624,14 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(destination);
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
destination.Clear();
|
destination.Clear();
|
||||||
|
LastRenderScopeEmitterVisitCount = 0;
|
||||||
int passIndex = RenderPassIndex(renderPass);
|
int passIndex = RenderPassIndex(renderPass);
|
||||||
if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket))
|
if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_scopeHandleScratch.Clear();
|
_scopeHandleScratch.Clear();
|
||||||
bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
|
bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
|
||||||
|
LastRenderScopeEmitterVisitCount = _scopeHandleScratch.Count;
|
||||||
foreach (int handle in _scopeHandleScratch)
|
foreach (int handle in _scopeHandleScratch)
|
||||||
{
|
{
|
||||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
||||||
|
|
|
||||||
|
|
@ -313,8 +313,6 @@ public sealed class WorldSceneRendererTests
|
||||||
|
|
||||||
Assert.True(result.NormalWorldDrawn);
|
Assert.True(result.NormalWorldDrawn);
|
||||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
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);
|
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -756,9 +754,6 @@ public sealed class WorldSceneRendererTests
|
||||||
diagnosticPartition: null);
|
diagnosticPartition: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
|
|
||||||
new HashSet<uint> { 0xCAFEu };
|
|
||||||
|
|
||||||
public RetailPViewFrameInput? LastInput { get; private set; }
|
public RetailPViewFrameInput? LastInput { get; private set; }
|
||||||
|
|
||||||
public bool ThrowOnDraw { get; set; }
|
public bool ThrowOnDraw { get; set; }
|
||||||
|
|
@ -800,7 +795,6 @@ public sealed class WorldSceneRendererTests
|
||||||
|
|
||||||
public float WeatherDayFraction { 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 BeginFrame() => calls.Add("passes:begin");
|
||||||
|
|
||||||
|
|
@ -837,10 +831,8 @@ public sealed class WorldSceneRendererTests
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlySet<uint> outdoorOwnerIds,
|
|
||||||
string currentSignature)
|
string currentSignature)
|
||||||
{
|
{
|
||||||
ParticleOwners = outdoorOwnerIds;
|
|
||||||
string kind = clipRoot switch
|
string kind = clipRoot switch
|
||||||
{
|
{
|
||||||
null => "global",
|
null => "global",
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,113 @@ public sealed class ParticleHookSinkTests
|
||||||
.AnchorPos);
|
.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 :
|
private sealed class MutablePoseSource :
|
||||||
IEntityEffectPoseSource,
|
IEntityEffectPoseSource,
|
||||||
IEntityEffectPoseChangeSource
|
IEntityEffectPoseChangeSource
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue