Compare commits
No commits in common. "main" and "latest" have entirely different histories.
22 changed files with 317 additions and 1323 deletions
133
docs/ISSUES.md
133
docs/ISSUES.md
|
|
@ -39,97 +39,6 @@ confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to
|
||||||
remain closed. See
|
remain closed. See
|
||||||
[`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md).
|
[`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md).
|
||||||
|
|
||||||
## #456 — Occluded distant buildings/creatures show through structures at raised cameras
|
|
||||||
|
|
||||||
**Status:** OPEN — mechanism established 2026-08-29; needs its own slice.
|
|
||||||
**Severity:** MEDIUM (visual correctness at pulled-out cameras; the long-standing
|
|
||||||
"camera outside shows buildings behind terrain" residual, now with a mechanism)
|
|
||||||
**Component:** pview outdoor shell drawing / dynamics distance admission
|
|
||||||
|
|
||||||
**Symptom (owner, 2026-08-29, Sanctuary cathedral terrace `0xF4180104`
|
|
||||||
[31.1, 55.7, 169.8]):** zoom the chase camera out and a building in the next
|
|
||||||
landblock (the `0xF518` shrine, model `0x01001777` at world (47173, 4741, 130),
|
|
||||||
317 m away) plus purple creatures appear across the lake where structure/ridge
|
|
||||||
should hide them; zoom in and they vanish. Verified with the punched-cell probe:
|
|
||||||
zoomed in (root = EnvCell `0xF4180104`, `visible=1`) the shrine never enters the
|
|
||||||
frame; zoomed out (root = landcell `0xF4180003`) the per-building flood admits
|
|
||||||
`0xF5180100`/`0xF5180106` at 316.8 m into the punch/shell pipeline. DAT terrain
|
|
||||||
profile from the true eye: the sight-line is blocked by a z=160 knoll by only
|
|
||||||
−2.8 m (roof) / −10.3 m (base), and a ~9 m camera raise makes the roofline
|
|
||||||
geometrically clear — but the screenshots show the WHOLE building, so an
|
|
||||||
occluder that should cover its lower half is not being drawn.
|
|
||||||
|
|
||||||
**Established mechanism candidates (both retail-cited):**
|
|
||||||
1. Retail `RenderDeviceD3D::DrawBuilding @0x0059F2A0` draws a building's whole
|
|
||||||
exterior unconditionally in the block walk (`CPhysicsPart::Draw(parts, 0)`);
|
|
||||||
only the interior flood is portal-gated. Our shell pass draws only
|
|
||||||
flood-admitted cells' shells, so intervening cathedral pieces whose cells the
|
|
||||||
flood did not reach leave a hole the shrine shows through (the owner's
|
|
||||||
original "wall appears transparent though its geometry is present" read).
|
|
||||||
2. Retail degrades/skips distant objects (`DrawBuilding`'s
|
|
||||||
`gfxobj[deg_level] != 0` gate; `GfxObjDegradeInfo::get_degrade` off CYpt in
|
|
||||||
`CPhysicsPart::UpdateViewerDistance @0x0050E030`) and never draws creatures
|
|
||||||
at 300 m; we draw dynamics and admit look-in structures at unlimited range.
|
|
||||||
|
|
||||||
**Prescribed next step:** RenderDoc pixel history on an artifact pixel (which
|
|
||||||
draw owns it, what should have covered it), then port the missing admission
|
|
||||||
gates. Full evidence chain in
|
|
||||||
`C:/Users/erikn/.codex/worktrees/16ee/acdream/docs/research/2026-08-29-cathedral-seam-progress.md`.
|
|
||||||
|
|
||||||
## #455 — Clicking an equipped item on the paperdoll does not dequip it
|
|
||||||
|
|
||||||
**Status:** OPEN — needs retail investigation BEFORE any implementation.
|
|
||||||
**Severity:** MEDIUM (core inventory interaction missing)
|
|
||||||
**Component:** paperdoll viewport interaction / equipped-item picking
|
|
||||||
|
|
||||||
**Symptom (owner, 2026-08-29):** clicking an equipped item on the inventory
|
|
||||||
paperdoll does nothing. The owner expects the click to dequip / pick up the
|
|
||||||
item; the exact retail gesture (single click pickup-to-cursor, click-drag,
|
|
||||||
or double-click) is NOT yet established and must come from the named decomp,
|
|
||||||
not a guess.
|
|
||||||
|
|
||||||
**Retail investigation owed:** how `gmPaperDollUI` maps a doll-viewport
|
|
||||||
click to the equipped object — the hit test against the private
|
|
||||||
CreatureMode render and the handler that starts the pickup (class anchor
|
|
||||||
already known: `gmPaperDollUI::RedressCreature @ 0x004A3BC0`; the
|
|
||||||
click/drag handler functions are unmapped). Compare with the shipped
|
|
||||||
world-side equipped-child picking (M4 slice 4, user-accepted 2026-07-29),
|
|
||||||
which may share the pick primitive.
|
|
||||||
|
|
||||||
**acdream side today:** `UiViewport` supports `Clicked`/`ClickedAt`
|
|
||||||
(viewport-local pixel coords), but the paperdoll wires no equipped-item hit
|
|
||||||
path at all — the click falls through, so "does nothing" is currently
|
|
||||||
by construction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## #454 — Timered quest-item pickup leaves the icon barred in the backpack
|
|
||||||
|
|
||||||
**Status:** OPEN
|
|
||||||
**Severity:** LOW-MEDIUM (icon state presentation)
|
|
||||||
**Component:** inventory icon overlay state / pickup + quest-timer response
|
|
||||||
handling
|
|
||||||
|
|
||||||
**Symptom (owner, 2026-08-29, with screenshot):** pick up a boss quest item
|
|
||||||
that carries a completion timer — ACE replies
|
|
||||||
`You may complete this quest again in 19h 59m 59s.` The item lands in the
|
|
||||||
backpack with the BARRED (unusable) icon overlay and stays barred; second
|
|
||||||
occurrence observed by the owner. Expected: the icon is clear immediately
|
|
||||||
once the item sits in the pack — the timer belongs in chat, not on the
|
|
||||||
icon.
|
|
||||||
|
|
||||||
**Mechanism (unestablished — candidates only, verify before fixing):**
|
|
||||||
(a) the quest-timer/use-refusal response stamps an unusable icon state that
|
|
||||||
is never cleared when the pickup transaction completes; (b) the icon
|
|
||||||
overlay state machine misses a refresh on container placement. Retail
|
|
||||||
oracle to establish: how retail presents a freshly picked-up timered quest
|
|
||||||
item's icon (expected no bar).
|
|
||||||
|
|
||||||
**Repro:** any boss quest item with a reuse timer on the local ACE server;
|
|
||||||
pick it up, watch the backpack icon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## #453 — Rain and thunder audio disappears while the Rainy sky remains active
|
## #453 — Rain and thunder audio disappears while the Rainy sky remains active
|
||||||
|
|
||||||
**Status:** DONE — USER-ACCEPTED 2026-08-28
|
**Status:** DONE — USER-ACCEPTED 2026-08-28
|
||||||
|
|
@ -522,29 +431,7 @@ authoritative 8→8 reconciliation. Owner check is in
|
||||||
|
|
||||||
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
|
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
|
||||||
|
|
||||||
**Status:** FIXED 2026-08-29 — root cause found; owner visual gate pending.
|
**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup.
|
||||||
|
|
||||||
**2026-08-29 root cause (the "only visible in portal space" recurrence):**
|
|
||||||
the classic `WbDrawDispatcher.Draw` path appended its transforms into the
|
|
||||||
SHARED world transform frame with a non-zero base instance, but the default
|
|
||||||
mesh shaders index every parallel per-instance array (clip slots, light
|
|
||||||
sets, indoor, OPACITY, selection lighting, detail category) zero-based —
|
|
||||||
only the packed world submission's shader convention subtracts the
|
|
||||||
shared-arena prefix. The doll therefore drew all of its instances with
|
|
||||||
per-instance opacity 0 into a cleared target whenever a world transform
|
|
||||||
frame was active: counted draws, blank pixels, deterministic. Portal space
|
|
||||||
worked because the arena is inactive there (ring path, base 0). The
|
|
||||||
private viewports are the only production consumers of the classic path,
|
|
||||||
which is why nothing else ever showed the defect. Fix:
|
|
||||||
`WbDrawDispatcher.NextClassicDrawIsPrivatePass` — private passes always
|
|
||||||
take the ring transform path (self-contained render state). The same round
|
|
||||||
also restored per-flight-slot private targets (the f6fe0f2a single-slot
|
|
||||||
revert relied on cross-command-buffer ordering Vulkan does not guarantee),
|
|
||||||
moved paperdoll resource preparation into the frame's resource phase ahead
|
|
||||||
of world composite-budget consumption, made the presenter redress on every
|
|
||||||
dirty edge, and stopped transient zero handles from erasing a completed
|
|
||||||
image (session reset is the one explicit clear). Verified live: doll
|
|
||||||
visible in the normal world, through portal space, and after arrival.
|
|
||||||
|
|
||||||
**Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner
|
**Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner
|
||||||
acceptance. Reopened after the owner again observed a missing paperdoll that
|
acceptance. Reopened after the owner again observed a missing paperdoll that
|
||||||
|
|
@ -19649,24 +19536,6 @@ the post-world PView replay is deleted.
|
||||||
**Gate:** both sides — indoors with the opening behind the candle, and
|
**Gate:** both sides — indoors with the opening behind the candle, and
|
||||||
outdoors at the angle that previously erased it.
|
outdoors at the angle that previously erased it.
|
||||||
|
|
||||||
**2026-08-29 recurrence fixed (register AP-236 retired in the same commit):**
|
|
||||||
the #451 correction moved outdoor-static flames into the LScape-stage drain,
|
|
||||||
which reopened this class — every opaque pass after that drain (doors,
|
|
||||||
creatures, look-in interiors, DynamicsLast) overwrote the already-composited
|
|
||||||
flames, and depth/barrier A/Bs were no-ops because the eraser is opaque color
|
|
||||||
painted later. Fix, both halves retail-cited and user-gated live at Holtburg
|
|
||||||
(candle whole in front of sign + door) and the cathedral (waterfalls
|
|
||||||
contained at every zoom): (1) an OUTDOOR root skips the stage-boundary drain
|
|
||||||
and runs `FlushLandscapeAlpha()` after `DrawDynamicsLast` — retail's walk
|
|
||||||
draws every cell's objects before `PView::DrawCells`' boundary flush
|
|
||||||
(`DrawSortCell @0x005A17C0`, flush `@0x005A4872`); (2) before
|
|
||||||
`DrawExitPortalMasks`, a partial drain flushes everything at or beyond the
|
|
||||||
nearest punched cell (`ExitPortalMaskBarrierDistance`) — retail `DrawBuilding
|
|
||||||
@0x0059F2A0` runs `FlushAlphaList(0f)` BEFORE its portal-only far-Z pass, so
|
|
||||||
in the far→near walk nothing already drained can meet a punched aperture's
|
|
||||||
falsified depth (the waterfall-through-aperture regression the first half
|
|
||||||
exposed). Interior roots keep the pre-clear stage-boundary drain unchanged.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Recently closed
|
# Recently closed
|
||||||
|
|
|
||||||
|
|
@ -762,8 +762,7 @@ internal sealed class FrameRootCompositionPhase
|
||||||
var framePreparation = new RenderFramePreparationController(
|
var framePreparation = new RenderFramePreparationController(
|
||||||
renderFrameResources,
|
renderFrameResources,
|
||||||
devTools: null,
|
devTools: null,
|
||||||
renderWeatherFrame,
|
renderWeatherFrame);
|
||||||
live.PaperdollPresenter);
|
|
||||||
IRenderFramePostDiagnosticsPhase postDiagnostics =
|
IRenderFramePostDiagnosticsPhase postDiagnostics =
|
||||||
renderSceneShadowComparison is not null
|
renderSceneShadowComparison is not null
|
||||||
&& lifecycleAutomation is not null
|
&& lifecycleAutomation is not null
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ internal interface IPaperdollFrameView
|
||||||
bool TryGetVisibleSize(out int width, out int height);
|
bool TryGetVisibleSize(out int width, out int height);
|
||||||
|
|
||||||
void SetTextureHandle(uint textureHandle);
|
void SetTextureHandle(uint textureHandle);
|
||||||
|
|
||||||
void ClearTextureHandle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface IPaperdollInventoryVisibility
|
internal interface IPaperdollInventoryVisibility
|
||||||
|
|
@ -53,9 +51,7 @@ internal interface IPaperdollPoseApplicator
|
||||||
/// presentation edge. The renderer remains a borrowed resource disposed by
|
/// presentation edge. The renderer remains a borrowed resource disposed by
|
||||||
/// the existing window shutdown transaction.
|
/// the existing window shutdown transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class PaperdollFramePresenter :
|
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
||||||
IPrivateEntityViewportFrame,
|
|
||||||
IPrivateEntityViewportResourcePreparation
|
|
||||||
{
|
{
|
||||||
private readonly IPaperdollDollRenderer _renderer;
|
private readonly IPaperdollDollRenderer _renderer;
|
||||||
private readonly IPaperdollFrameView _view;
|
private readonly IPaperdollFrameView _view;
|
||||||
|
|
@ -77,30 +73,22 @@ internal sealed class PaperdollFramePresenter :
|
||||||
|
|
||||||
public void MarkDirty() => _dirty = true;
|
public void MarkDirty() => _dirty = true;
|
||||||
|
|
||||||
/// <summary>
|
public void Render()
|
||||||
/// Pre-world resource phase: rebuild/redress the private clone and advance
|
|
||||||
/// its mesh/composite readiness BEFORE world draws can consume the
|
|
||||||
/// bounded per-frame composite-upload budget. In a dense scene the late
|
|
||||||
/// presentation phase never wins that budget, which is why the doll was
|
|
||||||
/// visible only while portal space quiesced the world (#443).
|
|
||||||
/// </summary>
|
|
||||||
public void PrepareResources()
|
|
||||||
{
|
{
|
||||||
if (_dirty)
|
if (_dirty)
|
||||||
{
|
{
|
||||||
if (_factory.TryBuild(out WorldEntity? doll))
|
if (_factory.TryBuild(out WorldEntity? doll))
|
||||||
{
|
{
|
||||||
// Redress every accepted live-player refresh, including an
|
// Same-generation CreateObject refreshes can repeat the exact
|
||||||
// appearance-equivalent ObjDesc after portal/relogin. The
|
// player ObjDesc at a portal boundary. Retail redresses its
|
||||||
// private clone belongs to the current presentation
|
// private inventory object in place; releasing and reacquiring
|
||||||
// generation; retaining the old object merely because its
|
// an identical synthetic owner briefly blanks the viewport and
|
||||||
// pixels compare equal can leave it attached to retired
|
// churns its texture composites.
|
||||||
// mesh/composite readiness that never completes again.
|
if (!HasEquivalentAppearance(_doll, doll))
|
||||||
// PrivateEntityViewportRenderer promotes replacements in two
|
{
|
||||||
// phases, so the last completed target remains visible until
|
|
||||||
// this fresh clone is completely drawable.
|
|
||||||
_renderer.SetDoll(doll);
|
_renderer.SetDoll(doll);
|
||||||
_doll = doll;
|
_doll = doll;
|
||||||
|
}
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -108,25 +96,16 @@ internal sealed class PaperdollFramePresenter :
|
||||||
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
|
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
|
||||||
// private m_pInventoryObject intact when the SmartBox player
|
// private m_pInventoryObject intact when the SmartBox player
|
||||||
// is temporarily unavailable. Keep the successful doll and
|
// is temporarily unavailable. Keep the successful doll and
|
||||||
// retry this dirty redress on the next frame.
|
// retry this dirty redress on the next visible frame.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderer.Prepare();
|
_renderer.Prepare();
|
||||||
}
|
|
||||||
|
|
||||||
public void Render()
|
|
||||||
{
|
|
||||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Zero is a transient not-ready result, not a request to erase a
|
_view.SetTextureHandle(_renderer.Render(width, height));
|
||||||
// previously completed paperdoll. Session reset clears explicitly in
|
|
||||||
// ResetSession; ordinary mesh/composite upload latency keeps the last
|
|
||||||
// good image instead of intermittently blanking the viewport.
|
|
||||||
uint textureHandle = _renderer.Render(width, height);
|
|
||||||
if (textureHandle != 0u)
|
|
||||||
_view.SetTextureHandle(textureHandle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -136,10 +115,80 @@ internal sealed class PaperdollFramePresenter :
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
_renderer.SetDoll(null);
|
_renderer.SetDoll(null);
|
||||||
_view.ClearTextureHandle();
|
|
||||||
_doll = null;
|
_doll = null;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasEquivalentAppearance(
|
||||||
|
WorldEntity? current,
|
||||||
|
WorldEntity? candidate)
|
||||||
|
{
|
||||||
|
if (current is null || candidate is null)
|
||||||
|
return ReferenceEquals(current, candidate);
|
||||||
|
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|
||||||
|
|| current.Scale != candidate.Scale
|
||||||
|
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|
||||||
|
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|
||||||
|
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < current.MeshRefs.Count; i++)
|
||||||
|
{
|
||||||
|
MeshRef left = current.MeshRefs[i];
|
||||||
|
MeshRef right = candidate.MeshRefs[i];
|
||||||
|
if (left.GfxObjId != right.GfxObjId
|
||||||
|
|| left.PartTransform != right.PartTransform
|
||||||
|
|| !DictionaryEquals(
|
||||||
|
left.SurfaceOverrides,
|
||||||
|
right.SurfaceOverrides))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < current.PartOverrides.Count; i++)
|
||||||
|
{
|
||||||
|
if (current.PartOverrides[i] != candidate.PartOverrides[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PaletteOverride? leftPalette = current.PaletteOverride;
|
||||||
|
PaletteOverride? rightPalette = candidate.PaletteOverride;
|
||||||
|
if (leftPalette is null || rightPalette is null)
|
||||||
|
return leftPalette is null && rightPalette is null;
|
||||||
|
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|
||||||
|
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
|
||||||
|
{
|
||||||
|
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DictionaryEquals(
|
||||||
|
IReadOnlyDictionary<uint, uint>? left,
|
||||||
|
IReadOnlyDictionary<uint, uint>? right)
|
||||||
|
{
|
||||||
|
if (left is null || right is null)
|
||||||
|
return left is null && right is null;
|
||||||
|
if (left.Count != right.Count)
|
||||||
|
return false;
|
||||||
|
foreach ((uint key, uint value) in left)
|
||||||
|
{
|
||||||
|
if (!right.TryGetValue(key, out uint rightValue)
|
||||||
|
|| rightValue != value)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
||||||
|
|
@ -177,9 +226,6 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetTextureHandle(uint textureHandle) =>
|
public void SetTextureHandle(uint textureHandle) =>
|
||||||
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
||||||
|
|
||||||
public void ClearTextureHandle() =>
|
|
||||||
_viewport.TextureSlot = GpuTextureSlot.Unassigned;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
||||||
|
|
|
||||||
|
|
@ -234,8 +234,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
IReadOnlySet<uint> attachedOwnerIds,
|
IReadOnlySet<uint> attachedOwnerIds,
|
||||||
bool includeUnattached = false,
|
bool includeUnattached = false,
|
||||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
||||||
uint clipSlot = 0,
|
uint clipSlot = 0)
|
||||||
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
|
|
||||||
{
|
{
|
||||||
if (camera is null)
|
if (camera is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -245,8 +244,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
attachedOwnerIds,
|
attachedOwnerIds,
|
||||||
includeUnattached,
|
includeUnattached,
|
||||||
_scopedEmitterScratch,
|
_scopedEmitterScratch,
|
||||||
excludedAttachedOwnerIds,
|
excludedAttachedOwnerIds);
|
||||||
unattachedCellScope);
|
|
||||||
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));
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared render-to-texture implementation for the private 3-D creature
|
/// Shared render-to-texture implementation for the private 3-D creature
|
||||||
/// viewports used by paperdoll and examination UI. Each instance owns one
|
/// viewports used by paperdoll and examination UI. Each instance owns one
|
||||||
/// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
|
/// <see cref="IGpuRenderTarget"/>, one synthetic render identity, and one
|
||||||
/// synthetic render identity, and one balanced texture-owner lease.
|
/// balanced texture-owner lease.
|
||||||
///
|
///
|
||||||
/// <para><b>Campaign V slice V6k (V4g's first half).</b> The target used to be a
|
/// <para><b>Campaign V slice V6k (V4g's first half).</b> The target used to be a
|
||||||
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
|
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
|
||||||
|
|
@ -65,6 +65,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
{
|
{
|
||||||
private const uint PrivateLandblockId = 0u;
|
private const uint PrivateLandblockId = 0u;
|
||||||
|
|
||||||
|
private readonly IGpuDevice _device;
|
||||||
private readonly ICurrentGpuFrameSource _frames;
|
private readonly ICurrentGpuFrameSource _frames;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -93,12 +94,17 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
/// feature does not exist for them, not just "unused".</summary>
|
/// feature does not exist for them, not just "unused".</summary>
|
||||||
private readonly EntitySlot? _backdropSlot;
|
private readonly EntitySlot? _backdropSlot;
|
||||||
|
|
||||||
// A target written by frame N cannot also be sampled by an unretired frame
|
// One stable sampled texture-table slot is part of the retained viewport's
|
||||||
// N-1. Vulkan permits those command buffers to overlap, so one shared image
|
// presentation contract. Rotating the slot with the Vulkan flight index
|
||||||
// is a cross-frame write/read race. The current frame slot selects one
|
// made the UI sample a freshly-created/cleared sibling after world reveal.
|
||||||
// bounded target + texture-table handle; the retained UI samples that exact
|
// The frame submission order already protects this target's write -> sample
|
||||||
// handle later in the same command buffer.
|
// transition; keep its identity stable until resize or disposal.
|
||||||
private readonly PrivateViewportFlightTargets _flightTargets;
|
private IGpuRenderTarget? _target;
|
||||||
|
private IGpuSampler? _sampler;
|
||||||
|
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
||||||
|
private int _fbW;
|
||||||
|
private int _fbH;
|
||||||
|
private bool _hasRenderedScene;
|
||||||
|
|
||||||
public PrivateEntityViewportRenderer(
|
public PrivateEntityViewportRenderer(
|
||||||
IWorldPassScope scope,
|
IWorldPassScope scope,
|
||||||
|
|
@ -121,7 +127,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
_scope = scope ?? throw new ArgumentNullException(
|
_scope = scope ?? throw new ArgumentNullException(
|
||||||
nameof(scope),
|
nameof(scope),
|
||||||
"The viewport must publish a world pass scope to draw into.");
|
"The viewport must publish a world pass scope to draw into.");
|
||||||
ArgumentNullException.ThrowIfNull(device);
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
||||||
|
|
@ -131,9 +137,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
||||||
? "creature viewport"
|
? "creature viewport"
|
||||||
: diagnosticName;
|
: diagnosticName;
|
||||||
_flightTargets = new PrivateViewportFlightTargets(
|
|
||||||
device,
|
|
||||||
_diagnosticName);
|
|
||||||
|
|
||||||
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
|
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
|
||||||
?? throw new ArgumentNullException(nameof(textureLifetime));
|
?? throw new ArgumentNullException(nameof(textureLifetime));
|
||||||
|
|
@ -170,17 +173,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TextureIsBottomUp => false;
|
public bool TextureIsBottomUp => false;
|
||||||
|
|
||||||
public void SetEntity(WorldEntity? entity)
|
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
|
||||||
{
|
|
||||||
_mainSlot.Set(entity);
|
|
||||||
if (entity is null)
|
|
||||||
{
|
|
||||||
// A character-session reset explicitly invalidates the sampled
|
|
||||||
// scenes. Do not let a replacement that is still uploading expose
|
|
||||||
// the previous character through any flight target.
|
|
||||||
_flightTargets.InvalidateCompletedScenes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advances the private entity's mesh and texture-composite readiness
|
/// Advances the private entity's mesh and texture-composite readiness
|
||||||
|
|
@ -233,50 +226,51 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint Render(int width, int height)
|
public uint Render(int width, int height)
|
||||||
{
|
{
|
||||||
if (width <= 0 || height <= 0)
|
|
||||||
return 0u;
|
|
||||||
|
|
||||||
IGpuFrame frame = _frames.CurrentFrame
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
|
||||||
int frameSlot = frame.SlotIndex;
|
|
||||||
|
|
||||||
// #443: acquiring a synthetic mesh reference only schedules CPU
|
// #443: acquiring a synthetic mesh reference only schedules CPU
|
||||||
// preparation/GPU upload; it does not make the mesh drawable. Keep the
|
// preparation/GPU upload; it does not make the mesh drawable. Keep the
|
||||||
// current flight slot's last completed private scene intact until every
|
// last completed private scene intact until every drawable mesh in the
|
||||||
// drawable mesh in the replacement has crossed that upload barrier.
|
// replacement has crossed that upload barrier. On first open there is
|
||||||
// On first open there is no completed scene, so return zero and let the
|
// no completed scene, so return zero and let the authored panel art
|
||||||
// authored panel art show through instead of publishing a cleared target.
|
// show through instead of publishing a freshly-cleared black target.
|
||||||
bool mainReady = _mainSlot.PrepareForDraw();
|
bool mainReady = _mainSlot.PrepareForDraw();
|
||||||
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
|
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
|
||||||
if (!mainReady || !backdropReady)
|
if (!mainReady || !backdropReady)
|
||||||
{
|
{
|
||||||
return _mainSlot.Entity is not null
|
return _mainSlot.Entity is not null
|
||||||
? _flightTargets.CompletedHandle(frameSlot)
|
&& _hasRenderedScene
|
||||||
|
&& _slot.IsAssigned
|
||||||
|
? UiTextureTableHandle.FromSlot(_slot)
|
||||||
: 0u;
|
: 0u;
|
||||||
}
|
}
|
||||||
|
|
||||||
WorldEntity? entity = _mainSlot.Entity;
|
WorldEntity? entity = _mainSlot.Entity;
|
||||||
if (entity is null || entity.MeshRefs.Count == 0)
|
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||||
return 0u;
|
return 0u;
|
||||||
|
|
||||||
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
|
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
|
||||||
_backdropSlot?.Entity,
|
_backdropSlot?.Entity,
|
||||||
entity);
|
entity);
|
||||||
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
|
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
|
||||||
return _flightTargets.CompletedHandle(frameSlot);
|
{
|
||||||
|
return _hasRenderedScene && _slot.IsAssigned
|
||||||
|
? UiTextureTableHandle.FromSlot(_slot)
|
||||||
|
: 0u;
|
||||||
|
}
|
||||||
|
|
||||||
PrivateViewportFlightTargets.TargetSlot? targetSlot =
|
EnsureRenderTarget(width, height);
|
||||||
_flightTargets.Ensure(frameSlot, width, height);
|
if (_target is null)
|
||||||
if (targetSlot is null)
|
|
||||||
return 0u;
|
return 0u;
|
||||||
_camera.Aspect = width / (float)height;
|
_camera.Aspect = width / (float)height;
|
||||||
|
|
||||||
|
IGpuFrame frame = _frames.CurrentFrame
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
||||||
|
|
||||||
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
|
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
|
||||||
{
|
{
|
||||||
Name = _diagnosticName,
|
Name = _diagnosticName,
|
||||||
Color = new GpuColorAttachment(
|
Color = new GpuColorAttachment(
|
||||||
Target: targetSlot.Target,
|
Target: _target,
|
||||||
Load: GpuLoadOp.Clear,
|
Load: GpuLoadOp.Clear,
|
||||||
Store: GpuStoreOp.Store,
|
Store: GpuStoreOp.Store,
|
||||||
ClearColor: Vector4.Zero),
|
ClearColor: Vector4.Zero),
|
||||||
|
|
@ -310,12 +304,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
null),
|
null),
|
||||||
};
|
};
|
||||||
|
|
||||||
// #443: a private pass must not append its transforms into the shared
|
|
||||||
// world transform frame — the default mesh shaders index parallel
|
|
||||||
// per-instance arrays zero-based, so a non-zero arena base zeroes the
|
|
||||||
// doll's per-instance opacity and the target stays blank whenever a
|
|
||||||
// world frame is active. See NextClassicDrawIsPrivatePass.
|
|
||||||
_dispatcher.NextClassicDrawIsPrivatePass = true;
|
|
||||||
_dispatcher.Draw(
|
_dispatcher.Draw(
|
||||||
_camera,
|
_camera,
|
||||||
entries,
|
entries,
|
||||||
|
|
@ -323,8 +311,8 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
neverCullLandblockId: PrivateLandblockId,
|
neverCullLandblockId: PrivateLandblockId,
|
||||||
visibleCellIds: null,
|
visibleCellIds: null,
|
||||||
animatedEntityIds: _animatedIds);
|
animatedEntityIds: _animatedIds);
|
||||||
targetSlot.HasRenderedScene = true;
|
_hasRenderedScene = true;
|
||||||
return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
|
return UiTextureTableHandle.FromSlot(_slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -375,6 +363,69 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureRenderTarget(int width, int height)
|
||||||
|
{
|
||||||
|
if (_target is not null && width == _fbW && height == _fbH)
|
||||||
|
return;
|
||||||
|
ReleaseRenderTarget();
|
||||||
|
|
||||||
|
IGpuRenderTarget target;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
target = _device.CreateRenderTarget(new GpuRenderTargetDescription(
|
||||||
|
_diagnosticName,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
GpuTextureFormat.Rgba8UnormRenderTarget,
|
||||||
|
// Depth24Stencil8, as the hand-rolled renderbuffer was: nothing
|
||||||
|
// samples it, and the stencil aspect keeps the attachment shape
|
||||||
|
// the depth/stencil renderers already expect.
|
||||||
|
GpuTextureFormat.Depth24Stencil8,
|
||||||
|
SampleCount: 1));
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[{_diagnosticName}] render target unavailable ({width}x{height}): {failure.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The retained UI blits this attachment as an ordinary table entry.
|
||||||
|
// Linear/clamped is the filtering the hand-rolled colour texture set
|
||||||
|
// on itself before the §7.1 seam registered it.
|
||||||
|
_sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
|
||||||
|
_slot = _device.RegisterTexture(target.ColorTexture, _sampler);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
target.Dispose();
|
||||||
|
_sampler = null;
|
||||||
|
_slot = GpuTextureSlot.Unassigned;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
_target = target;
|
||||||
|
_fbW = width;
|
||||||
|
_fbH = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseRenderTarget()
|
||||||
|
{
|
||||||
|
if (_slot.IsAssigned)
|
||||||
|
{
|
||||||
|
_device.ReleaseTextureSlot(_slot);
|
||||||
|
_slot = GpuTextureSlot.Unassigned;
|
||||||
|
}
|
||||||
|
_sampler = null;
|
||||||
|
_target?.Dispose();
|
||||||
|
_target = null;
|
||||||
|
_fbW = 0;
|
||||||
|
_fbH = 0;
|
||||||
|
_hasRenderedScene = false;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
|
|
@ -396,7 +447,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_flightTargets.Dispose();
|
ReleaseRenderTarget();
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -411,163 +462,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bounded render-target ownership keyed by <see cref="IGpuFrame.SlotIndex"/>.
|
|
||||||
/// A frame slot is reopened only after its previous submission retires, so
|
|
||||||
/// the target selected here can be written and sampled within that frame
|
|
||||||
/// without racing a different in-flight command buffer.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class PrivateViewportFlightTargets : IDisposable
|
|
||||||
{
|
|
||||||
internal sealed class TargetSlot(
|
|
||||||
IGpuRenderTarget target,
|
|
||||||
GpuTextureSlot textureSlot)
|
|
||||||
{
|
|
||||||
internal IGpuRenderTarget Target { get; } = target;
|
|
||||||
internal GpuTextureSlot TextureSlot { get; } = textureSlot;
|
|
||||||
internal bool HasRenderedScene { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly IGpuDevice _device;
|
|
||||||
private readonly string _diagnosticName;
|
|
||||||
private readonly List<TargetSlot?> _slots = [];
|
|
||||||
private int _width;
|
|
||||||
private int _height;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
internal PrivateViewportFlightTargets(
|
|
||||||
IGpuDevice device,
|
|
||||||
string diagnosticName)
|
|
||||||
{
|
|
||||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
|
||||||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
|
||||||
? "creature viewport"
|
|
||||||
: diagnosticName;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal int AllocatedSlotCount =>
|
|
||||||
_slots.Count(static slot => slot is not null);
|
|
||||||
|
|
||||||
internal TargetSlot? Ensure(int frameSlot, int width, int height)
|
|
||||||
{
|
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
|
|
||||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
|
|
||||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
|
|
||||||
|
|
||||||
if (_width != 0 && (_width != width || _height != height))
|
|
||||||
ReleaseAll();
|
|
||||||
|
|
||||||
while (_slots.Count <= frameSlot)
|
|
||||||
_slots.Add(null);
|
|
||||||
if (_slots[frameSlot] is { } existing)
|
|
||||||
return existing;
|
|
||||||
|
|
||||||
IGpuRenderTarget target;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
target = _device.CreateRenderTarget(
|
|
||||||
new GpuRenderTargetDescription(
|
|
||||||
$"{_diagnosticName}-flight-{frameSlot}",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
GpuTextureFormat.Rgba8UnormRenderTarget,
|
|
||||||
// Depth24Stencil8, as the original private viewport
|
|
||||||
// renderbuffer was. Nothing samples this attachment.
|
|
||||||
GpuTextureFormat.Depth24Stencil8,
|
|
||||||
SampleCount: 1));
|
|
||||||
}
|
|
||||||
catch (Exception failure)
|
|
||||||
{
|
|
||||||
Console.WriteLine(
|
|
||||||
$"[{_diagnosticName}] render target unavailable "
|
|
||||||
+ $"({width}x{height}, flight {frameSlot}): {failure.Message}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// The device de-duplicates immutable samplers. Retained UI
|
|
||||||
// blits this target through its ordinary texture-table entry.
|
|
||||||
IGpuSampler sampler = _device.CreateSampler(
|
|
||||||
GpuSamplerDescription.WorldClamp);
|
|
||||||
GpuTextureSlot textureSlot = _device.RegisterTexture(
|
|
||||||
target.ColorTexture,
|
|
||||||
sampler);
|
|
||||||
var created = new TargetSlot(target, textureSlot);
|
|
||||||
_slots[frameSlot] = created;
|
|
||||||
_width = width;
|
|
||||||
_height = height;
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
target.Dispose();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal uint CompletedHandle(int frameSlot)
|
|
||||||
{
|
|
||||||
if ((uint)frameSlot >= (uint)_slots.Count
|
|
||||||
|| _slots[frameSlot] is not { HasRenderedScene: true } slot)
|
|
||||||
{
|
|
||||||
return 0u;
|
|
||||||
}
|
|
||||||
|
|
||||||
return UiTextureTableHandle.FromSlot(slot.TextureSlot);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void InvalidateCompletedScenes()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < _slots.Count; i++)
|
|
||||||
{
|
|
||||||
if (_slots[i] is { } slot)
|
|
||||||
slot.HasRenderedScene = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReleaseAll()
|
|
||||||
{
|
|
||||||
List<Exception>? failures = null;
|
|
||||||
for (int i = 0; i < _slots.Count; i++)
|
|
||||||
{
|
|
||||||
TargetSlot? slot = _slots[i];
|
|
||||||
if (slot is null)
|
|
||||||
continue;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_device.ReleaseTextureSlot(slot.TextureSlot);
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
(failures ??= []).Add(error);
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
slot.Target.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
(failures ??= []).Add(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_slots.Clear();
|
|
||||||
_width = 0;
|
|
||||||
_height = 0;
|
|
||||||
if (failures is { Count: > 0 })
|
|
||||||
throw new AggregateException(failures);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
return;
|
|
||||||
_disposed = true;
|
|
||||||
ReleaseAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One private entity's mesh-reference/texture-owner lifetime, independent
|
/// One private entity's mesh-reference/texture-owner lifetime, independent
|
||||||
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
|
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,6 @@ internal interface IPrivateEntityViewportFrame
|
||||||
void Render();
|
void Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional pre-world resource preparation for a private viewport whose
|
|
||||||
/// visibility must not compete with world composite uploads. The composite
|
|
||||||
/// upload budget opens with the frame's resource phase; a dense world can
|
|
||||||
/// consume all of it every frame, so a viewport that only prepares during
|
|
||||||
/// late presentation can starve indefinitely (#443's paperdoll: visible in
|
|
||||||
/// portal space — where the world is quiesced — and nowhere busy).
|
|
||||||
/// </summary>
|
|
||||||
internal interface IPrivateEntityViewportResourcePreparation
|
|
||||||
{
|
|
||||||
void PrepareResources();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface IRetainedGameplayUiFrame
|
internal interface IRetainedGameplayUiFrame
|
||||||
{
|
{
|
||||||
void Render(double deltaSeconds, int width, int height);
|
void Render(double deltaSeconds, int width, int height);
|
||||||
|
|
|
||||||
|
|
@ -29,29 +29,20 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
|
||||||
private readonly IRenderFrameResourcePhase _resources;
|
private readonly IRenderFrameResourcePhase _resources;
|
||||||
private readonly IDevToolsFrameLifecycle? _devTools;
|
private readonly IDevToolsFrameLifecycle? _devTools;
|
||||||
private readonly IRenderWeatherFramePhase _weather;
|
private readonly IRenderWeatherFramePhase _weather;
|
||||||
private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
|
|
||||||
|
|
||||||
public RenderFramePreparationController(
|
public RenderFramePreparationController(
|
||||||
IRenderFrameResourcePhase resources,
|
IRenderFrameResourcePhase resources,
|
||||||
IDevToolsFrameLifecycle? devTools,
|
IDevToolsFrameLifecycle? devTools,
|
||||||
IRenderWeatherFramePhase weather,
|
IRenderWeatherFramePhase weather)
|
||||||
IPrivateEntityViewportResourcePreparation? privateViewports = null)
|
|
||||||
{
|
{
|
||||||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||||
_devTools = devTools;
|
_devTools = devTools;
|
||||||
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
||||||
_privateViewports = privateViewports;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Prepare(RenderFrameInput input)
|
public void Prepare(RenderFrameInput input)
|
||||||
{
|
{
|
||||||
_resources.Prepare(input);
|
_resources.Prepare(input);
|
||||||
// The composite upload budget opens with the resource phase. Give the
|
|
||||||
// paperdoll's private object its prewarm slot before the world can
|
|
||||||
// consume the complete per-frame budget (#443 — the doll rendered
|
|
||||||
// only while portal space quiesced the world); presentation samples
|
|
||||||
// the result later, after the world pass has closed.
|
|
||||||
_privateViewports?.PrepareResources();
|
|
||||||
_devTools?.BeginFrame((float)input.DeltaSeconds);
|
_devTools?.BeginFrame((float)input.DeltaSeconds);
|
||||||
_weather.Tick(input.DeltaSeconds);
|
_weather.Tick(input.DeltaSeconds);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,106 +239,6 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drains only the entries at or beyond <paramref name="minViewerDistance"/>
|
|
||||||
/// and keeps every nearer entry queued with the frame open. This is the
|
|
||||||
/// pre/inter-building barrier semantics: retail's far→near land walk means
|
|
||||||
/// <c>DrawBuilding</c>'s <c>FlushAlphaList(0f)</c> @0x0059F2A0 can only
|
|
||||||
/// flush content from cells FARTHER than that building — a nearer emitter
|
|
||||||
/// has not been inserted yet and composites after the building at a later
|
|
||||||
/// flush (the float there is a COUNT threshold, not a depth). The batched
|
|
||||||
/// landscape has no per-cell walk, so the same outcome is restored by
|
|
||||||
/// draining the far prefix of the established far→near order (AP-236).
|
|
||||||
/// Sources are deliberately NOT reset: retained tokens must stay valid
|
|
||||||
/// for the remaining entries' later <see cref="Flush"/>.
|
|
||||||
/// </summary>
|
|
||||||
public void FlushFartherThan(float minViewerDistance)
|
|
||||||
{
|
|
||||||
if (!IsCollecting)
|
|
||||||
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
|
|
||||||
if (_submissions.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
float threshold = NormalizeDistance(minViewerDistance);
|
|
||||||
SortRetailOrder();
|
|
||||||
int prefix = 0;
|
|
||||||
while (prefix < _submissions.Count
|
|
||||||
&& _submissions[prefix].ViewerDistance >= threshold)
|
|
||||||
{
|
|
||||||
prefix++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefix == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsureTokenCapacity(prefix);
|
|
||||||
EnsureSourceCapacity(_sources.Count);
|
|
||||||
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
|
|
||||||
|
|
||||||
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
|
|
||||||
{
|
|
||||||
IRetailAlphaDrawSource source = _sources[sourceIndex];
|
|
||||||
int sourceCount = 0;
|
|
||||||
for (int i = 0; i < prefix; i++)
|
|
||||||
{
|
|
||||||
RetailAlphaSubmission submission = _submissions[i];
|
|
||||||
if (ReferenceEquals(submission.Source, source))
|
|
||||||
_tokenScratch[sourceCount++] = submission.Token;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceCount > 0)
|
|
||||||
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
|
|
||||||
}
|
|
||||||
|
|
||||||
int start = 0;
|
|
||||||
while (start < prefix)
|
|
||||||
{
|
|
||||||
IRetailAlphaDrawSource source = _submissions[start].Source;
|
|
||||||
int end = start + 1;
|
|
||||||
while (end < prefix
|
|
||||||
&& ReferenceEquals(_submissions[end].Source, source))
|
|
||||||
end++;
|
|
||||||
|
|
||||||
int count = end - start;
|
|
||||||
int sourceIndex = FindSourceIndex(source);
|
|
||||||
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
|
|
||||||
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
|
|
||||||
_sourceDrawOffsets[sourceIndex] += count;
|
|
||||||
start = end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Converge to the full-drain failure shape: the retained suffix
|
|
||||||
// cannot be trusted once a source threw mid-prepare/draw.
|
|
||||||
_submissions.Clear();
|
|
||||||
List<Exception>? resetFailures = null;
|
|
||||||
for (int i = 0; i < _sources.Count; i++)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sources[i].ResetAlphaSubmissions();
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
(resetFailures ??= []).Add(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_sources.Clear();
|
|
||||||
if (resetFailures is { Count: > 0 })
|
|
||||||
{
|
|
||||||
throw new AggregateException(
|
|
||||||
"Retail alpha partial drain failed and its submissions could not be fully reset.",
|
|
||||||
resetFailures);
|
|
||||||
}
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
_submissions.RemoveRange(0, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EndFrame()
|
public void EndFrame()
|
||||||
{
|
{
|
||||||
if (!IsCollecting)
|
if (!IsCollecting)
|
||||||
|
|
|
||||||
|
|
@ -444,9 +444,20 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
animatedEntityIds: frame.AnimatedEntityIds);
|
animatedEntityIds: frame.AnimatedEntityIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Late-stage particle owners submit ONCE per frame through
|
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||||
// DrawLandscapeStaticParticles after the slice loop (retail: one
|
|
||||||
// unclipped alpha-list insertion per emitter), not per slice here.
|
if (_particleClassifications.Outdoor.Count > 0
|
||||||
|
&& _particles is not null
|
||||||
|
&& _particleRenderer is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.DrawForOwners(
|
||||||
|
frame.Camera,
|
||||||
|
frame.CameraWorldPosition,
|
||||||
|
ParticleRenderPass.Scene,
|
||||||
|
_particleClassifications.Outdoor,
|
||||||
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
|
}
|
||||||
|
|
||||||
EnableClipDistances();
|
EnableClipDistances();
|
||||||
if (frame.RenderSky && frame.RenderWeather)
|
if (frame.RenderSky && frame.RenderWeather)
|
||||||
{
|
{
|
||||||
|
|
@ -481,12 +492,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
RetailPViewLandscapeStaticParticleContext context)
|
RetailPViewLandscapeStaticParticleContext context)
|
||||||
{
|
{
|
||||||
// One unclipped submission per owner per frame. Retail never clips a
|
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
|
||||||
// particle to a portal view — its polys join the one alpha list during
|
_surface.BindTerrainClip();
|
||||||
// the owner cell's walk turn and the depth test at the flush decides
|
|
||||||
// occlusion (FlushAlphaList @0x0059D2E0). The former per-slice call
|
|
||||||
// with the slice's clip slot both hardware-cut effects at aperture
|
|
||||||
// boundaries and double-submitted owners visible in two slices.
|
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
|
|
||||||
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||||
|
|
@ -499,9 +506,11 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
frame.CameraWorldPosition,
|
frame.CameraWorldPosition,
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
_particleClassifications.Outdoor,
|
_particleClassifications.Outdoor,
|
||||||
clipSlot: 0);
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scissor)
|
||||||
|
_surface.EndScissor();
|
||||||
_entities.ClearClipRouting();
|
_entities.ClearClipRouting();
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
@ -564,19 +573,11 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
|
|
||||||
public void DrawUnattachedSceneParticles(
|
public void DrawUnattachedSceneParticles(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
bool outdoorCells)
|
ClipViewSlice slice)
|
||||||
{
|
{
|
||||||
if (_particles is null || _particleRenderer is null)
|
if (_particles is null || _particleRenderer is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Retail draws an unattached emitter once, during its owner CELL's
|
|
||||||
// walk turn, with NO portal-view clip (CPhysicsObj::ShouldDrawParticles
|
|
||||||
// @0x0050FE60 gates by cell in-view + distance; occlusion is the depth
|
|
||||||
// test at FlushAlphaList @0x0059D2E0). Outdoor-cell emitters submit in
|
|
||||||
// the landscape stage, interior-cell emitters in the final world stage.
|
|
||||||
// The former once-per-OutsideView-slice submission with that slice's
|
|
||||||
// hardware clip slot made effects vanish by view direction (zero
|
|
||||||
// outside slices in view = zero submissions) — invented behavior.
|
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
_particleRenderer.DrawForOwners(
|
_particleRenderer.DrawForOwners(
|
||||||
frame.Camera,
|
frame.Camera,
|
||||||
|
|
@ -584,17 +585,11 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
_noSceneParticleEntityIds,
|
_noSceneParticleEntityIds,
|
||||||
includeUnattached: true,
|
includeUnattached: true,
|
||||||
clipSlot: 0,
|
clipSlot: (uint)slice.Slot);
|
||||||
unattachedCellScope: outdoorCells
|
|
||||||
? UnattachedEmitterCellScope.OutdoorCells
|
|
||||||
: UnattachedEmitterCellScope.InteriorCells);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void FlushLandscapeAlpha() => _alpha.Flush();
|
public void FlushLandscapeAlpha() => _alpha.Flush();
|
||||||
|
|
||||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
|
||||||
_alpha.FlushFartherThan(minViewerDistance);
|
|
||||||
|
|
||||||
public void DrawCellParticles(
|
public void DrawCellParticles(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
RetailPViewCellSliceContext context)
|
RetailPViewCellSliceContext context)
|
||||||
|
|
@ -613,14 +608,12 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
return;
|
return;
|
||||||
|
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
// Retail never clips cell particles to a portal view: the owner
|
|
||||||
// cell's walls own occlusion via the depth test at the alpha flush.
|
|
||||||
_particleRenderer.DrawForOwners(
|
_particleRenderer.DrawForOwners(
|
||||||
frame.Camera,
|
frame.Camera,
|
||||||
frame.CameraWorldPosition,
|
frame.CameraWorldPosition,
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
visible,
|
visible,
|
||||||
clipSlot: 0);
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,19 +28,6 @@ public sealed class RetailPViewRenderer
|
||||||
private static readonly IReadOnlySet<uint> NoParticleOwners =
|
private static readonly IReadOnlySet<uint> NoParticleOwners =
|
||||||
new HashSet<uint>();
|
new HashSet<uint>();
|
||||||
|
|
||||||
// Frame unions for the once-per-frame particle submissions (retail: one
|
|
||||||
// unclipped alpha-list insertion per emitter; occlusion by depth at the
|
|
||||||
// flush). Per-slice owner culls still run — these accumulate their union.
|
|
||||||
private readonly HashSet<uint> _staticParticleUnionScratch = new();
|
|
||||||
private readonly HashSet<uint> _cellParticleUnionScratch = new();
|
|
||||||
|
|
||||||
// Every cell drawn as a building look-in this frame. Retail marks each
|
|
||||||
// drawn non-player part for the frame (DrawMeshInternal @0x0059F360,
|
|
||||||
// GetDrawnThisFrame), so an object whose cell drew with a look-in cannot
|
|
||||||
// draw again in a later pass; dynamics-last consults this set to honor
|
|
||||||
// the same drawn-once contract.
|
|
||||||
private readonly HashSet<uint> _lookInCellIds = new();
|
|
||||||
|
|
||||||
private readonly HashSet<uint> _oneCell = new(1);
|
private readonly HashSet<uint> _oneCell = new(1);
|
||||||
// Shell-batch scratch: all of a pass's cells collected for ONE batched
|
// Shell-batch scratch: all of a pass's cells collected for ONE batched
|
||||||
// opaque Render call (instead of one heavy Render per cell). Reused across
|
// opaque Render call (instead of one heavy Render per cell). Reused across
|
||||||
|
|
@ -179,19 +166,13 @@ public sealed class RetailPViewRenderer
|
||||||
// contains them). drawableCells itself stays the MAIN flood: it feeds the
|
// contains them). drawableCells itself stays the MAIN flood: it feeds the
|
||||||
// seals, the outside-stage predicate, and the frame result.
|
// seals, the outside-stage predicate, and the frame result.
|
||||||
var prepareCells = drawableCells;
|
var prepareCells = drawableCells;
|
||||||
_lookInCellIds.Clear();
|
|
||||||
if (_lookInFrames.Count > 0)
|
if (_lookInFrames.Count > 0)
|
||||||
{
|
{
|
||||||
_lookInPrepareScratch.Clear();
|
_lookInPrepareScratch.Clear();
|
||||||
_lookInPrepareScratch.UnionWith(drawableCells);
|
_lookInPrepareScratch.UnionWith(drawableCells);
|
||||||
foreach (var f in _lookInFrames)
|
foreach (var f in _lookInFrames)
|
||||||
{
|
|
||||||
foreach (uint c in f.OrderedVisibleCells)
|
foreach (uint c in f.OrderedVisibleCells)
|
||||||
{
|
|
||||||
_lookInPrepareScratch.Add(c);
|
_lookInPrepareScratch.Add(c);
|
||||||
_lookInCellIds.Add(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prepareCells = _lookInPrepareScratch;
|
prepareCells = _lookInPrepareScratch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,13 +251,10 @@ public sealed class RetailPViewRenderer
|
||||||
RenderProjectionCounts sourceCounts = frameViewBorrowed
|
RenderProjectionCounts sourceCounts = frameViewBorrowed
|
||||||
? frameView.SourceDigest.Counts
|
? frameView.SourceDigest.Counts
|
||||||
: LegacySourceCounts(partition!);
|
: LegacySourceCounts(partition!);
|
||||||
// prepareCells is exactly "main flood ∪ look-in cells" — the cells
|
|
||||||
// this traversal actually reached, i.e. retail's in-view set.
|
|
||||||
RetailPViewFrameResult result = _frameResultScratch.Reset(
|
RetailPViewFrameResult result = _frameResultScratch.Reset(
|
||||||
pvFrame,
|
pvFrame,
|
||||||
clipAssembly,
|
clipAssembly,
|
||||||
drawableCells,
|
drawableCells,
|
||||||
prepareCells,
|
|
||||||
counts,
|
counts,
|
||||||
sourceCounts,
|
sourceCounts,
|
||||||
partition);
|
partition);
|
||||||
|
|
@ -318,31 +296,6 @@ public sealed class RetailPViewRenderer
|
||||||
frameEntityPasses,
|
frameEntityPasses,
|
||||||
in frameView);
|
in frameView);
|
||||||
passes.UseIndoorMembershipOnlyRouting();
|
passes.UseIndoorMembershipOnlyRouting();
|
||||||
|
|
||||||
// Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE
|
|
||||||
// its portal-only far-Z pass. In retail's strict far→near walk
|
|
||||||
// everything queued at that instant is FARTHER than the structure
|
|
||||||
// being punched, so no already-drained poly can meet a punched
|
|
||||||
// aperture's falsified depth, and everything drained later is
|
|
||||||
// NEARER than the punched structure and legitimately composites in
|
|
||||||
// front of it. The batched outdoor frame reproduces that invariant
|
|
||||||
// here: drain the far prefix — every entry at or beyond the
|
|
||||||
// nearest cell whose exit-portal mask is about to punch far-Z —
|
|
||||||
// against still-true landscape depth. Without this, an exterior
|
|
||||||
// waterfall beyond the cathedral drains after the punches and
|
|
||||||
// z-passes across every aperture pixel whose true depth the punch
|
|
||||||
// erased (#132 regression found at the 2026-08-29 cathedral gate).
|
|
||||||
// Interior roots keep their pre-clear stage-boundary drain.
|
|
||||||
if (ctx.RootCell.IsOutdoorNode)
|
|
||||||
{
|
|
||||||
passes.FlushLandscapeAlphaFartherThan(
|
|
||||||
ExitPortalMaskBarrierDistance(
|
|
||||||
pvFrame,
|
|
||||||
drawableCells,
|
|
||||||
ctx.Cells,
|
|
||||||
ctx.CameraWorldPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
|
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
|
||||||
DrawEnvCellShells(passes, pvFrame);
|
DrawEnvCellShells(passes, pvFrame);
|
||||||
DrawCellObjectLists(
|
DrawCellObjectLists(
|
||||||
|
|
@ -364,24 +317,6 @@ public sealed class RetailPViewRenderer
|
||||||
frameEntityPasses,
|
frameEntityPasses,
|
||||||
in frameView);
|
in frameView);
|
||||||
|
|
||||||
// OUTDOOR root: the LScape-boundary alpha drain deferred from the
|
|
||||||
// landscape stage runs HERE, after punches, interior shells, cell
|
|
||||||
// objects, and the dynamics pass — the frame's complete opaque
|
|
||||||
// world. Retail's walk draws all of those before its boundary
|
|
||||||
// flush (LScape::draw includes every cell's objects,
|
|
||||||
// DrawSortCell 0x005A17C0), so this is the same one-list far→near
|
|
||||||
// composite over finished depth; draining at the stage end instead
|
|
||||||
// let every later opaque mesh overwrite the flames (#132).
|
|
||||||
if (ctx.RootCell.IsOutdoorNode)
|
|
||||||
passes.FlushLandscapeAlpha();
|
|
||||||
|
|
||||||
// Interior-cell UNATTACHED emitters (spell ground effects and
|
|
||||||
// swirls anchored in EnvCells) draw in this final world scope —
|
|
||||||
// the cells' walls and the seals already own the depth buffer, so
|
|
||||||
// one unclipped submission matches retail's cell-walk insertion.
|
|
||||||
// Outdoor-cell unattached emitters drew in the landscape stage.
|
|
||||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false);
|
|
||||||
|
|
||||||
if (entityFrameOpen)
|
if (entityFrameOpen)
|
||||||
{
|
{
|
||||||
frameEntityPasses!.CompleteEntityFrame(in frameView);
|
frameEntityPasses!.CompleteEntityFrame(in frameView);
|
||||||
|
|
@ -493,71 +428,6 @@ public sealed class RetailPViewRenderer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Conservative barrier drain threshold for one look-in frame: the viewer
|
|
||||||
/// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit
|
|
||||||
/// inside the building, so this over-estimates the building's
|
|
||||||
/// nearest-point distance and under-drains; anything conservatively
|
|
||||||
/// retained still composites correctly at the later depth-tested drains.
|
|
||||||
/// Retail needs no threshold — its far→near walk guarantees only farther
|
|
||||||
/// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0
|
|
||||||
/// (full drain, today's behavior) when no cell resolves.
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
|
||||||
/// The pre-punch barrier threshold for <see cref="DrawExitPortalMasks"/>:
|
|
||||||
/// the nearest drawable cell whose exit-portal mask is about to write
|
|
||||||
/// far-Z. Every queued alpha entry at or beyond it must drain first
|
|
||||||
/// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the
|
|
||||||
/// portal-only pass), because after the punch those entries would z-pass
|
|
||||||
/// across aperture pixels whose true depth no longer exists. No punched
|
|
||||||
/// cells → MaxValue → the partial drain retains everything.
|
|
||||||
/// </summary>
|
|
||||||
internal static float ExitPortalMaskBarrierDistance(
|
|
||||||
PortalVisibilityFrame frame,
|
|
||||||
HashSet<uint> drawableCells,
|
|
||||||
IRetailPViewCellSource cells,
|
|
||||||
Vector3 viewerPosition)
|
|
||||||
{
|
|
||||||
float best = float.PositiveInfinity;
|
|
||||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
|
||||||
{
|
|
||||||
uint cellId = frame.OrderedVisibleCells[i];
|
|
||||||
if (!drawableCells.Contains(cellId))
|
|
||||||
continue;
|
|
||||||
LoadedCell? cell = cells.Find(cellId);
|
|
||||||
if (cell is null)
|
|
||||||
continue;
|
|
||||||
float distance = Vector3.Distance(
|
|
||||||
cell.WorldTransform.Translation,
|
|
||||||
viewerPosition);
|
|
||||||
if (distance < best)
|
|
||||||
best = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return float.IsFinite(best) ? best : float.MaxValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static float LookInBarrierDrainDistance(
|
|
||||||
PortalVisibilityFrame frame,
|
|
||||||
IRetailPViewCellSource cells,
|
|
||||||
Vector3 viewerPosition)
|
|
||||||
{
|
|
||||||
float best = float.PositiveInfinity;
|
|
||||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
|
||||||
{
|
|
||||||
LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]);
|
|
||||||
if (cell is null)
|
|
||||||
continue;
|
|
||||||
float distance = Vector3.Distance(
|
|
||||||
cell.WorldTransform.Translation,
|
|
||||||
viewerPosition);
|
|
||||||
if (distance < best)
|
|
||||||
best = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return float.IsFinite(best) ? best : 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecycleLookInFrames()
|
private void RecycleLookInFrames()
|
||||||
{
|
{
|
||||||
for (int i = 0; i < _lookInFrames.Count; i++)
|
for (int i = 0; i < _lookInFrames.Count; i++)
|
||||||
|
|
@ -615,22 +485,12 @@ public sealed class RetailPViewRenderer
|
||||||
{
|
{
|
||||||
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
|
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
|
||||||
|
|
||||||
// Retail enters DrawBuilding once per building and drains the
|
// Retail enters DrawBuilding once per building and drains every
|
||||||
// alpha accumulated by the preceding building before punching the
|
// alpha submission accumulated by the preceding building before
|
||||||
// next building's portals — and because retail's far→near walk
|
// punching the next building's portals. The first building uses
|
||||||
// has only inserted FARTHER content by then, that drain can never
|
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
|
||||||
// composite an emitter nearer than this building
|
|
||||||
// (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236).
|
|
||||||
// The first building uses the pre-look-in barrier in
|
|
||||||
// DrawLandscapeThroughOutsideView.
|
|
||||||
if (frameIndex > 0)
|
if (frameIndex > 0)
|
||||||
{
|
passes.FlushLandscapeAlpha();
|
||||||
passes.FlushLandscapeAlphaFartherThan(
|
|
||||||
LookInBarrierDrainDistance(
|
|
||||||
frame,
|
|
||||||
ctx.Cells,
|
|
||||||
ctx.CameraWorldPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 1: far-Z punch every aperture of this building.
|
// Pass 1: far-Z punch every aperture of this building.
|
||||||
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
|
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
|
||||||
|
|
@ -690,8 +550,6 @@ public sealed class RetailPViewRenderer
|
||||||
_cellStaticScratch.Add(e);
|
_cellStaticScratch.Add(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool cellDrewObjects = false;
|
|
||||||
_cellParticleUnionScratch.Clear();
|
|
||||||
foreach (ClipViewSlice slice in cellSlices)
|
foreach (ClipViewSlice slice in cellSlices)
|
||||||
{
|
{
|
||||||
int routeIndex = lookInRouteIndex++;
|
int routeIndex = lookInRouteIndex++;
|
||||||
|
|
@ -737,20 +595,11 @@ public sealed class RetailPViewRenderer
|
||||||
_cellStaticScratch,
|
_cellStaticScratch,
|
||||||
_oneCell);
|
_oneCell);
|
||||||
|
|
||||||
cellDrewObjects = true;
|
// The nested DrawCells object pass includes emitters and
|
||||||
_cellParticleUnionScratch.UnionWith(
|
// retains the exact setup_view clip until alpha playback.
|
||||||
_cellParticleOwnerScratch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The nested DrawCells object pass includes emitters: ONE
|
|
||||||
// unclipped submission per look-in cell (retail draws a
|
|
||||||
// particle during its cell's walk turn; the cell walls own
|
|
||||||
// occlusion by depth at alpha playback — never a view clip).
|
|
||||||
if (cellDrewObjects)
|
|
||||||
{
|
|
||||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||||
cellId, NoClipSlice, _cellParticleUnionScratch));
|
cellId, slice, _cellParticleOwnerScratch));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,7 +612,6 @@ public sealed class RetailPViewRenderer
|
||||||
// anchor EnvCell; never let an unrelated building repaint a
|
// anchor EnvCell; never let an unrelated building repaint a
|
||||||
// look-in merely because both happen to be nearby.
|
// look-in merely because both happen to be nearby.
|
||||||
int sliceIndex = 0;
|
int sliceIndex = 0;
|
||||||
_staticParticleUnionScratch.Clear();
|
|
||||||
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||||
{
|
{
|
||||||
int shellRouteIndex = LookInBuildingShellRouteIndex(
|
int shellRouteIndex = LookInBuildingShellRouteIndex(
|
||||||
|
|
@ -842,20 +690,14 @@ public sealed class RetailPViewRenderer
|
||||||
_lateParticleOwnerScratch,
|
_lateParticleOwnerScratch,
|
||||||
_buildingShellScratch);
|
_buildingShellScratch);
|
||||||
}
|
}
|
||||||
_staticParticleUnionScratch.UnionWith(
|
|
||||||
_lateParticleOwnerScratch);
|
|
||||||
}
|
|
||||||
sliceIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE unclipped submission for this look-in frame's shell-route
|
|
||||||
// owners (retail: one alpha-list insertion per emitter,
|
|
||||||
// depth-occluded at the flush — never re-drawn per outside view).
|
|
||||||
passes.DrawLandscapeStaticParticles(
|
passes.DrawLandscapeStaticParticles(
|
||||||
ctx,
|
ctx,
|
||||||
new RetailPViewLandscapeStaticParticleContext(
|
new RetailPViewLandscapeStaticParticleContext(
|
||||||
_staticParticleUnionScratch));
|
slice,
|
||||||
_staticParticleUnionScratch.Clear();
|
_lateParticleOwnerScratch));
|
||||||
|
}
|
||||||
|
sliceIndex++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -953,19 +795,14 @@ public sealed class RetailPViewRenderer
|
||||||
bool hasBuildingLookIns = _lookInFrames.Count > 0;
|
bool hasBuildingLookIns = _lookInFrames.Count > 0;
|
||||||
if (hasBuildingLookIns)
|
if (hasBuildingLookIns)
|
||||||
{
|
{
|
||||||
// Ownerless OUTDOOR-cell emitters cannot ride an entity route.
|
int barrierSliceIndex = 0;
|
||||||
// Retail inserts each one into the single alpha list once, during
|
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||||
// its cell's landscape walk turn, with no portal-view clip; the
|
|
||||||
// interior-cell ownerless emitters submit in the final world
|
|
||||||
// scope instead (see DrawDynamicsLast).
|
|
||||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
|
||||||
|
|
||||||
_staticParticleUnionScratch.Clear();
|
|
||||||
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
|
|
||||||
for (int barrierSliceIndex = 0;
|
|
||||||
barrierSliceIndex < outsideSliceTotal;
|
|
||||||
barrierSliceIndex++)
|
|
||||||
{
|
{
|
||||||
|
// Ownerless outdoor emitters cannot ride an entity route. Retail
|
||||||
|
// draws their meshes once for every installed outside_view;
|
||||||
|
// retain that slot through deferred alpha playback.
|
||||||
|
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||||
|
|
||||||
_lateParticleOwnerScratch.Clear();
|
_lateParticleOwnerScratch.Clear();
|
||||||
if (partition is not null)
|
if (partition is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -998,30 +835,15 @@ public sealed class RetailPViewRenderer
|
||||||
barrierSliceIndex,
|
barrierSliceIndex,
|
||||||
0);
|
0);
|
||||||
}
|
}
|
||||||
_staticParticleUnionScratch.UnionWith(
|
|
||||||
_lateParticleOwnerScratch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE unclipped submission for the union of every slice's cone
|
|
||||||
// survivors, then retail's pre-building barrier drain. Under
|
|
||||||
// retail's far→near walk, DrawBuilding's FlushAlphaList(0f)
|
|
||||||
// @0x0059F2A0 can only ever flush content from cells FARTHER
|
|
||||||
// than the building it precedes — a nearer emitter (the Holtburg
|
|
||||||
// candle in front of a door) has not been inserted yet and
|
|
||||||
// composites at a later flush, after that building's opaques.
|
|
||||||
// Drain the far prefix only; nearer entries stay queued for the
|
|
||||||
// DrawCells-boundary flush, which runs after the late dynamics
|
|
||||||
// (AP-236 retirement).
|
|
||||||
passes.DrawLandscapeStaticParticles(
|
passes.DrawLandscapeStaticParticles(
|
||||||
ctx,
|
ctx,
|
||||||
new RetailPViewLandscapeStaticParticleContext(
|
new RetailPViewLandscapeStaticParticleContext(
|
||||||
_staticParticleUnionScratch));
|
slice,
|
||||||
_staticParticleUnionScratch.Clear();
|
_lateParticleOwnerScratch));
|
||||||
passes.FlushLandscapeAlphaFartherThan(
|
barrierSliceIndex++;
|
||||||
LookInBarrierDrainDistance(
|
}
|
||||||
_lookInFrames[0],
|
passes.FlushLandscapeAlpha();
|
||||||
ctx.Cells,
|
|
||||||
ctx.CameraWorldPosition));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// #124: far-building look-ins draw HERE — still inside the landscape
|
// #124: far-building look-ins draw HERE — still inside the landscape
|
||||||
|
|
@ -1040,10 +862,8 @@ public sealed class RetailPViewRenderer
|
||||||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||||
// pre-clear so the seal protects their aperture pixels; AFTER the
|
// pre-clear so the seal protects their aperture pixels; AFTER the
|
||||||
// look-ins so a translucent portal mesh blends over a far interior
|
// look-ins so a translucent portal mesh blends over a far interior
|
||||||
// instead of being overpainted). The scene-particle owners (statics +
|
// instead of being overpainted) + the scene-particle owners (statics +
|
||||||
// dynamics cone survivors) accumulate across the slices and submit
|
// dynamics cone survivors — flames ride here for the same reason).
|
||||||
// ONCE, unclipped, after the loop.
|
|
||||||
_staticParticleUnionScratch.Clear();
|
|
||||||
probeSliceIndex = 0;
|
probeSliceIndex = 0;
|
||||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||||
{
|
{
|
||||||
|
|
@ -1071,14 +891,6 @@ public sealed class RetailPViewRenderer
|
||||||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||||
{
|
{
|
||||||
_outdoorStaticScratch.Add(e);
|
_outdoorStaticScratch.Add(e);
|
||||||
// Particles emit in the stage matching the PARENT CELL:
|
|
||||||
// an INTERIOR dynamic whose sphere merely straddles an
|
|
||||||
// exit-portal plane keeps its mesh in both stages (#118)
|
|
||||||
// but its particles belong to the final pass — draining
|
|
||||||
// them at the pre-clear boundary lets the interior stage
|
|
||||||
// repaint over them except on seal-protected aperture
|
|
||||||
// pixels (the cathedral middle-cell spell-star cut).
|
|
||||||
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
|
||||||
_lateParticleOwnerScratch.Add(e.Id);
|
_lateParticleOwnerScratch.Add(e.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1119,74 +931,36 @@ public sealed class RetailPViewRenderer
|
||||||
0,
|
0,
|
||||||
ctx.PlayerLandblockId ?? 0);
|
ctx.PlayerLandblockId ?? 0);
|
||||||
probeSliceIndex++;
|
probeSliceIndex++;
|
||||||
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
|
|
||||||
passes.DrawLandscapeSliceLate(
|
passes.DrawLandscapeSliceLate(
|
||||||
ctx,
|
ctx,
|
||||||
new RetailPViewLandscapeLateSliceContext(
|
new RetailPViewLandscapeLateSliceContext(
|
||||||
slice,
|
slice,
|
||||||
_outdoorStaticScratch)
|
_outdoorStaticScratch,
|
||||||
|
_lateParticleOwnerScratch)
|
||||||
{
|
{
|
||||||
EntityDraw = entityDraw,
|
EntityDraw = entityDraw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONE unclipped submission for every late-stage particle owner —
|
|
||||||
// OUTDOOR-parented outside-stage dynamics' emitters plus, without
|
|
||||||
// look-ins, the outdoor statics' emitters (retail: one alpha-list
|
|
||||||
// insertion per emitter during the landscape walk; per-slice
|
|
||||||
// re-submission with clip slots was the direction-dependent
|
|
||||||
// disappearance class). Interior-parented straddlers appear in BOTH
|
|
||||||
// the LandscapeOutsideDynamic and DynamicLast routes; their particles
|
|
||||||
// emit only in the final pass, so remove them here.
|
|
||||||
if (frameEntityPasses is not null)
|
|
||||||
{
|
|
||||||
RenderFrameRouteOwnerSelector.ExceptRoute(
|
|
||||||
_staticParticleUnionScratch,
|
|
||||||
in frameView,
|
|
||||||
RenderFrameCandidateRoute.DynamicLast);
|
|
||||||
}
|
|
||||||
if (_staticParticleUnionScratch.Count > 0)
|
|
||||||
{
|
|
||||||
passes.DrawLandscapeStaticParticles(
|
|
||||||
ctx,
|
|
||||||
new RetailPViewLandscapeStaticParticleContext(
|
|
||||||
_staticParticleUnionScratch));
|
|
||||||
_staticParticleUnionScratch.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||||
// campfires, ground effects anchored at a position) have no owner id
|
// campfires, ground effects anchored at a position) have no owner id
|
||||||
// to ride any of the id-filtered particle passes. OUTDOOR-cell ones
|
// to ride any of the id-filtered particle passes. Draw once per
|
||||||
// submit ONCE in the landscape stage, unclipped — retail inserts each
|
// installed outside_view for BOTH root kinds, matching retail's
|
||||||
// particle into the single alpha list during its owner cell's walk
|
// landscape-stage placement and preserving the slot in each deferred
|
||||||
// turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
|
// draw. The former outdoor-root post-world tail ran after building
|
||||||
// FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
|
// cells and let exterior alpha repaint the cathedral transition.
|
||||||
// once-per-outside-slice submission with that slice's clip slot cut
|
// With no look-ins they drain at the end of the landscape stage; the
|
||||||
// effects at aperture boundaries and drew NOTHING when no outside
|
// look-in path submits them at its pre-building barrier so later opaque
|
||||||
// slice was in view. Interior-cell unattached emitters submit in the
|
// cell floors can cover them.
|
||||||
// final world scope (DrawDynamicsLast) — in the landscape stage the
|
|
||||||
// upcoming depth clear + interior repaint would erase them.
|
|
||||||
if (!hasBuildingLookIns)
|
if (!hasBuildingLookIns)
|
||||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
{
|
||||||
|
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||||
|
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||||
|
}
|
||||||
|
|
||||||
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
||||||
// immediately after LScape::draw and before the optional depth clear.
|
// immediately after LScape::draw and before the optional depth clear.
|
||||||
// The queue remains active for the post-clear/final-world scope.
|
// The queue remains active for the post-clear/final-world scope.
|
||||||
//
|
|
||||||
// Only an INTERIOR root drains here: its full depth clear follows, and
|
|
||||||
// a flame drained after that clear would z-pass through every interior
|
|
||||||
// wall. An OUTDOOR root has no depth clear (retail gates it on
|
|
||||||
// portalsDrawnCount, pc:432731), and retail's LScape::draw walk has
|
|
||||||
// already drawn every building interior and every cell object via
|
|
||||||
// DrawSortCell 0x005A17C0 before that boundary — while our outdoor
|
|
||||||
// frame draws punches, interior shells, cell objects, and ALL dynamics
|
|
||||||
// (doors, creatures, NPCs) after this point. Draining here painted the
|
|
||||||
// flames first and let each of those later opaque meshes overwrite
|
|
||||||
// them (#132: "the door draws over the candle"); the outdoor drain
|
|
||||||
// therefore runs after DrawDynamicsLast, where world depth is complete
|
|
||||||
// and the one far-to-near list composites over everything, exactly as
|
|
||||||
// retail's boundary flush does relative to its finished walk.
|
|
||||||
if (!ctx.RootCell.IsOutdoorNode)
|
|
||||||
passes.FlushLandscapeAlpha();
|
passes.FlushLandscapeAlpha();
|
||||||
|
|
||||||
// T1: retail clears the FULL depth buffer ONCE between the outside
|
// T1: retail clears the FULL depth buffer ONCE between the outside
|
||||||
|
|
@ -1360,13 +1134,13 @@ public sealed class RetailPViewRenderer
|
||||||
Array.Empty<WorldEntity>(),
|
Array.Empty<WorldEntity>(),
|
||||||
visibleCellIds: null);
|
visibleCellIds: null);
|
||||||
|
|
||||||
// Particles emit exactly once, in the stage matching the parent
|
// An owner routed through any pre-clear outside slice already had
|
||||||
// cell. Pure-outdoor dynamics are absent from the DynamicLast
|
// its alpha particles drawn there. Meshes may be submitted in both
|
||||||
// route (they draw only in the outside stage), and interior
|
// stages, but particles must be emitted exactly once.
|
||||||
// straddlers — present in BOTH routes — emit their particles
|
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||||
// HERE so the interior stage cannot repaint over them; the late
|
_dynamicParticleOwnerScratch,
|
||||||
// landscape submission excludes DynamicLast owners for the same
|
in frameView,
|
||||||
// reason.
|
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
|
||||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||||
{
|
{
|
||||||
passes.DrawDynamicsParticles(
|
passes.DrawDynamicsParticles(
|
||||||
|
|
@ -1403,14 +1177,6 @@ public sealed class RetailPViewRenderer
|
||||||
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
|
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Drawn-once (retail DrawMeshInternal @0x0059F360 marks every
|
|
||||||
// non-player part for the frame): a dynamic whose cell drew as a
|
|
||||||
// building LOOK-IN already rendered with that cell inside the
|
|
||||||
// landscape stage (#131). Redrawing it here would land AFTER the
|
|
||||||
// boundary alpha drain and overpaint nearer flames — the Holtburg
|
|
||||||
// door repainting the candle in front of it.
|
|
||||||
if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value))
|
|
||||||
continue;
|
|
||||||
bool visible = indoor
|
bool visible = indoor
|
||||||
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
||||||
: viewcone.SphereVisibleOutside(c, r);
|
: viewcone.SphereVisibleOutside(c, r);
|
||||||
|
|
@ -1457,28 +1223,22 @@ public sealed class RetailPViewRenderer
|
||||||
// particles must not double-draw, unlike the depth-idempotent meshes).
|
// particles must not double-draw, unlike the depth-idempotent meshes).
|
||||||
if (frameEntityPasses is not null)
|
if (frameEntityPasses is not null)
|
||||||
{
|
{
|
||||||
// Parent-cell stage split: every DynamicLast owner emits its
|
|
||||||
// particles here. Pure-outdoor dynamics are absent from this
|
|
||||||
// route (outside stage only), and interior straddlers — whose
|
|
||||||
// meshes drew in both stages — must emit HERE so the interior
|
|
||||||
// stage cannot repaint over them (matches the production
|
|
||||||
// partition-null path above).
|
|
||||||
RenderFrameRouteOwnerSelector.Replace(
|
RenderFrameRouteOwnerSelector.Replace(
|
||||||
_dynamicParticleOwnerScratch,
|
_dynamicParticleOwnerScratch,
|
||||||
in frameView,
|
in frameView,
|
||||||
RenderFrameCandidateRoute.DynamicLast,
|
RenderFrameCandidateRoute.DynamicLast,
|
||||||
0,
|
0,
|
||||||
0);
|
0);
|
||||||
|
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||||
|
_dynamicParticleOwnerScratch,
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_dynamicParticleOwnerScratch.Clear();
|
_dynamicParticleOwnerScratch.Clear();
|
||||||
// Interior-parented dynamics — INCLUDING exit-portal straddlers
|
|
||||||
// whose mesh also drew in the outside stage — emit particles in
|
|
||||||
// this final pass; outdoor-parented ones emitted in the late
|
|
||||||
// landscape submission (parent-cell stage split).
|
|
||||||
foreach (var e in _dynamicsScratch)
|
foreach (var e in _dynamicsScratch)
|
||||||
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
if (!_outsideStageDynamics.Contains(e))
|
||||||
_dynamicParticleOwnerScratch.Add(e.Id);
|
_dynamicParticleOwnerScratch.Add(e.Id);
|
||||||
}
|
}
|
||||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||||
|
|
@ -1870,29 +1630,10 @@ public interface IRetailPViewPassExecutor
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
RetailPViewCellSliceContext context,
|
RetailPViewCellSliceContext context,
|
||||||
int portalIndex);
|
int portalIndex);
|
||||||
/// <summary>
|
|
||||||
/// One unclipped submission for every renderable UNATTACHED emitter whose
|
|
||||||
/// owner cell matches the scope: outdoor landcells in the landscape stage,
|
|
||||||
/// interior EnvCells in the final world scope. Retail inserts each such
|
|
||||||
/// particle into the single alpha list during its owner cell's walk turn
|
|
||||||
/// and never clips it to a portal view.
|
|
||||||
/// </summary>
|
|
||||||
void DrawUnattachedSceneParticles(
|
void DrawUnattachedSceneParticles(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
bool outdoorCells);
|
ClipViewSlice slice);
|
||||||
void FlushLandscapeAlpha();
|
void FlushLandscapeAlpha();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pre/inter-building barrier drain: composites only the queued alpha at
|
|
||||||
/// or beyond <paramref name="minViewerDistance"/> and retains nearer
|
|
||||||
/// entries for the later boundary flush — retail's far→near walk outcome
|
|
||||||
/// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush
|
|
||||||
/// content from cells farther than that building; AP-236). The default
|
|
||||||
/// falls back to a full flush so non-production executors keep today's
|
|
||||||
/// behavior until they opt in.
|
|
||||||
/// </summary>
|
|
||||||
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
|
||||||
FlushLandscapeAlpha();
|
|
||||||
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
||||||
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
||||||
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
|
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
|
||||||
|
|
@ -2169,23 +1910,6 @@ public sealed class RetailPViewFrameResult
|
||||||
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
|
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
|
||||||
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
|
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
|
||||||
public HashSet<uint> DrawableCells { get; private set; } = null!;
|
public HashSet<uint> DrawableCells { get; private set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Every cell this completed view actually reached: the main flood
|
|
||||||
/// (<see cref="DrawableCells"/>) plus the building look-in cells. This is
|
|
||||||
/// retail's per-cell <c>in_view</c> answer for effect consumers —
|
|
||||||
/// <c>CPhysicsObj::ShouldDrawParticles</c> @0x0050FE60 gates on
|
|
||||||
/// <c>cell->IsInView()</c>, and a cell entered through a building portal
|
|
||||||
/// (<c>PView::ConstructView</c> @0x005A57B0, installed by
|
|
||||||
/// <c>RenderDeviceD3D::DrawBuilding</c> @0x0059F2A0) is drawn by the same
|
|
||||||
/// <c>PView::DrawCells</c> traversal as a flooded cell, so retail marks it
|
|
||||||
/// in view identically. acdream's look-in adaptation keeps those cells out
|
|
||||||
/// of <see cref="DrawableCells"/> (seals / outside-stage predicate stay
|
|
||||||
/// main-flood scoped, #124); particle and light visibility must consume
|
|
||||||
/// THIS set or look-in rooms render with frozen emitters and dark lights.
|
|
||||||
/// </summary>
|
|
||||||
public HashSet<uint> InViewCells { get; private set; } = null!;
|
|
||||||
|
|
||||||
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
|
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
|
||||||
internal RenderProjectionCounts SourceCounts { get; private set; }
|
internal RenderProjectionCounts SourceCounts { get; private set; }
|
||||||
internal InteriorEntityPartition.Result? DiagnosticPartition
|
internal InteriorEntityPartition.Result? DiagnosticPartition
|
||||||
|
|
@ -2195,7 +1919,6 @@ public sealed class RetailPViewFrameResult
|
||||||
PortalVisibilityFrame portalFrame,
|
PortalVisibilityFrame portalFrame,
|
||||||
ClipFrameAssembly clipAssembly,
|
ClipFrameAssembly clipAssembly,
|
||||||
HashSet<uint> drawableCells,
|
HashSet<uint> drawableCells,
|
||||||
HashSet<uint> inViewCells,
|
|
||||||
RenderFrameDiagnosticCounts diagnosticCounts,
|
RenderFrameDiagnosticCounts diagnosticCounts,
|
||||||
RenderProjectionCounts sourceCounts,
|
RenderProjectionCounts sourceCounts,
|
||||||
InteriorEntityPartition.Result? diagnosticPartition)
|
InteriorEntityPartition.Result? diagnosticPartition)
|
||||||
|
|
@ -2203,7 +1926,6 @@ public sealed class RetailPViewFrameResult
|
||||||
PortalFrame = portalFrame;
|
PortalFrame = portalFrame;
|
||||||
ClipAssembly = clipAssembly;
|
ClipAssembly = clipAssembly;
|
||||||
DrawableCells = drawableCells;
|
DrawableCells = drawableCells;
|
||||||
InViewCells = inViewCells;
|
|
||||||
DiagnosticCounts = diagnosticCounts;
|
DiagnosticCounts = diagnosticCounts;
|
||||||
SourceCounts = sourceCounts;
|
SourceCounts = sourceCounts;
|
||||||
DiagnosticPartition = diagnosticPartition;
|
DiagnosticPartition = diagnosticPartition;
|
||||||
|
|
@ -2219,7 +1941,6 @@ public sealed class RetailPViewFrameResult
|
||||||
portalFrame,
|
portalFrame,
|
||||||
clipAssembly,
|
clipAssembly,
|
||||||
drawableCells,
|
drawableCells,
|
||||||
drawableCells,
|
|
||||||
RetailPViewRenderer.LegacyDiagnosticCounts(
|
RetailPViewRenderer.LegacyDiagnosticCounts(
|
||||||
diagnosticPartition),
|
diagnosticPartition),
|
||||||
RetailPViewRenderer.LegacySourceCounts(
|
RetailPViewRenderer.LegacySourceCounts(
|
||||||
|
|
@ -2235,13 +1956,12 @@ public readonly record struct RetailPViewLandscapeSliceContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
|
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
|
||||||
/// union of every outside slice's cone survivors). Mesh alpha for the same
|
/// Mesh alpha for the same owners is already queued by the early landscape
|
||||||
/// owners is already queued by the entity routes; retail inserts each
|
/// entity route.
|
||||||
/// emitter's polys into the single alpha list once, during its owner cell's
|
|
||||||
/// walk turn, with no portal-view clip.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct RetailPViewLandscapeStaticParticleContext(
|
public readonly record struct RetailPViewLandscapeStaticParticleContext(
|
||||||
|
ClipViewSlice Slice,
|
||||||
IReadOnlySet<uint> ParticleOwnerIds);
|
IReadOnlySet<uint> ParticleOwnerIds);
|
||||||
|
|
||||||
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
|
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
|
||||||
|
|
@ -2258,7 +1978,8 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
|
||||||
/// submitted at a pre-building barrier.</summary>
|
/// submitted at a pre-building barrier.</summary>
|
||||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||||
ClipViewSlice Slice,
|
ClipViewSlice Slice,
|
||||||
IReadOnlyList<WorldEntity> Dynamics)
|
IReadOnlyList<WorldEntity> Dynamics,
|
||||||
|
IReadOnlySet<uint> ParticleOwnerIds)
|
||||||
{
|
{
|
||||||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1128,12 +1128,6 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
private RenderProjectionRecord[] _cell = [];
|
private RenderProjectionRecord[] _cell = [];
|
||||||
private RenderProjectionRecord[] _dirty = [];
|
private RenderProjectionRecord[] _dirty = [];
|
||||||
private RenderProjectionRecord[] _survivors = [];
|
private RenderProjectionRecord[] _survivors = [];
|
||||||
|
|
||||||
// Cells drawn as building look-ins this frame — the DynamicLast route
|
|
||||||
// honors retail's drawn-once contract (DrawMeshInternal @0x0059F360
|
|
||||||
// marks every non-player part): an object whose cell drew with a look-in
|
|
||||||
// must not enter the final dynamics route again.
|
|
||||||
private readonly HashSet<uint> _lookInCellScratch = new();
|
|
||||||
private RenderProjectionRecord[] _cellRoute = [];
|
private RenderProjectionRecord[] _cellRoute = [];
|
||||||
private readonly Dictionary<RenderProjectionId, int>
|
private readonly Dictionary<RenderProjectionId, int>
|
||||||
_outdoorPositions = [];
|
_outdoorPositions = [];
|
||||||
|
|
@ -1534,16 +1528,6 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
RenderFrameWriter writer,
|
RenderFrameWriter writer,
|
||||||
in RenderScenePViewBuildInput input)
|
in RenderScenePViewBuildInput input)
|
||||||
{
|
{
|
||||||
_lookInCellScratch.Clear();
|
|
||||||
for (int frameIndex = 0;
|
|
||||||
frameIndex < input.LookInFrames.Count;
|
|
||||||
frameIndex++)
|
|
||||||
{
|
|
||||||
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
|
|
||||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
|
||||||
_lookInCellScratch.Add(frame.OrderedVisibleCells[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
int count = 0;
|
int count = 0;
|
||||||
EnsureCapacity(ref _survivors, _dynamicCount);
|
EnsureCapacity(ref _survivors, _dynamicCount);
|
||||||
for (int i = 0; i < _dynamicCount; i++)
|
for (int i = 0; i < _dynamicCount; i++)
|
||||||
|
|
@ -1555,14 +1539,6 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
if (!input.RootIsOutdoor && !indoor)
|
if (!input.RootIsOutdoor && !indoor)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic
|
|
||||||
// whose cell drew as a building look-in already rendered with
|
|
||||||
// that cell in the landscape stage; re-entering the final route
|
|
||||||
// would draw it after the boundary alpha drain and overpaint
|
|
||||||
// nearer flames (the Holtburg candle-behind-door class).
|
|
||||||
if (indoor && _lookInCellScratch.Contains(parentCellId!.Value))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
Sphere(in record, out Vector3 center, out float radius);
|
Sphere(in record, out Vector3 center, out float radius);
|
||||||
bool visible = indoor
|
bool visible = indoor
|
||||||
? input.Viewcone.SphereVisibleInCell(
|
? input.Viewcone.SphereVisibleInCell(
|
||||||
|
|
|
||||||
|
|
@ -924,24 +924,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
|
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
|
|
||||||
/// chargen preview) and must take the plain ring transform path with
|
|
||||||
/// <c>firstInstance = 0</c>, never an append into the shared world
|
|
||||||
/// transform frame. The default mesh shaders index every parallel
|
|
||||||
/// per-instance array (clip slots, light sets, indoor, opacity, selection,
|
|
||||||
/// detail category) zero-based — only the packed world submission's
|
|
||||||
/// shader convention subtracts a shared-arena prefix — so an
|
|
||||||
/// arena-appended classic draw with a non-zero base reads zeroed
|
|
||||||
/// per-instance data (opacity 0 ⇒ an invisible doll whenever a world
|
|
||||||
/// frame is active; portal space worked only because the arena was
|
|
||||||
/// inactive there). The private pass owns its own camera, lighting, and
|
|
||||||
/// target; per the self-contained-render-state rule it must not depend on
|
|
||||||
/// the world frame's pose address space at all. Consumed and cleared by
|
|
||||||
/// the next <see cref="WriteWorldTransformSection"/>.
|
|
||||||
/// </summary>
|
|
||||||
internal bool NextClassicDrawIsPrivatePass;
|
|
||||||
|
|
||||||
private RhiSection WriteWorldTransformSection(
|
private RhiSection WriteWorldTransformSection(
|
||||||
IGpuFrame frame,
|
IGpuFrame frame,
|
||||||
ReadOnlySpan<float> matrixFloats,
|
ReadOnlySpan<float> matrixFloats,
|
||||||
|
|
@ -957,9 +939,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
ObserveOrdinaryTransformDemand(
|
ObserveOrdinaryTransformDemand(
|
||||||
frame.Serial,
|
frame.Serial,
|
||||||
checked((uint)(matrixFloats.Length / 16)));
|
checked((uint)(matrixFloats.Length / 16)));
|
||||||
bool privatePass = NextClassicDrawIsPrivatePass;
|
if (!_worldTransformFrames.IsActive)
|
||||||
NextClassicDrawIsPrivatePass = false;
|
|
||||||
if (privatePass || !_worldTransformFrames.IsActive)
|
|
||||||
{
|
{
|
||||||
firstInstance = 0;
|
firstInstance = 0;
|
||||||
return WriteRingSection(frame, matrixFloats);
|
return WriteRingSection(frame, matrixFloats);
|
||||||
|
|
|
||||||
|
|
@ -254,14 +254,8 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
camera.Camera.View,
|
camera.Camera.View,
|
||||||
_diagnostics.CameraCellResolution));
|
_diagnostics.CameraCellResolution));
|
||||||
|
|
||||||
// Effect visibility consumes InViewCells (main flood ∪ look-in
|
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
|
||||||
// cells), not the flood-only DrawableCells: retail's
|
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
|
||||||
// ShouldDrawParticles @0x0050FE60 asks cell->IsInView(), and a
|
|
||||||
// look-in cell drawn via DrawBuilding @0x0059F2A0 is in view
|
|
||||||
// exactly like a flooded cell. Flood-only scoping froze
|
|
||||||
// emitters and darkened lights in visible adjacent rooms.
|
|
||||||
_particleVisibility.MarkVisibleCells(pviewResult.InViewCells);
|
|
||||||
_frames.ObserveDrawableCells(pviewResult.InViewCells);
|
|
||||||
_diagnostics.EmitPViewInput(
|
_diagnostics.EmitPViewInput(
|
||||||
pviewResult.PortalFrame,
|
pviewResult.PortalFrame,
|
||||||
camera.ViewProjection,
|
camera.ViewProjection,
|
||||||
|
|
|
||||||
|
|
@ -271,10 +271,8 @@ public sealed class LightManager
|
||||||
/// geometrically closer than the player's own room's torches and win the cap,
|
/// geometrically closer than the player's own room's torches and win the cap,
|
||||||
/// leaving the visible room dark. Scoping candidacy to the frame's actual
|
/// leaving the visible room dark. Scoping candidacy to the frame's actual
|
||||||
/// visible cells (the render already computes this — callers pass last frame's
|
/// visible cells (the render already computes this — callers pass last frame's
|
||||||
/// <c>RetailPViewFrameResult.InViewCells</c>, the main flood PLUS building
|
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
|
||||||
/// look-in cells, one frame of latency, to avoid re-threading a mid-render
|
/// re-threading a mid-render callback) removes those from contention before the
|
||||||
/// callback; flood-only scoping darkened look-in rooms' lanterns) removes
|
|
||||||
/// non-visible cells from contention before the
|
|
||||||
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
|
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
|
||||||
/// parameter only narrows candidacy, it does not change the sort (the #176
|
/// parameter only narrows candidacy, it does not change the sort (the #176
|
||||||
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
|
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
|
||||||
|
|
|
||||||
|
|
@ -526,8 +526,7 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
IReadOnlySet<uint> attachedOwnerIds,
|
IReadOnlySet<uint> attachedOwnerIds,
|
||||||
bool includeUnattached,
|
bool includeUnattached,
|
||||||
List<ParticleEmitter> destination,
|
List<ParticleEmitter> destination,
|
||||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
|
||||||
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
|
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
|
||||||
ArgumentNullException.ThrowIfNull(destination);
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
|
|
@ -540,13 +539,10 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
|
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
|
||||||
{
|
{
|
||||||
LastRenderScopeEmitterVisitCount++;
|
LastRenderScopeEmitterVisitCount++;
|
||||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
||||||
&& MatchesUnattachedCellScope(emitter, unattachedCellScope))
|
|
||||||
{
|
|
||||||
destination.Add(emitter);
|
destination.Add(emitter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex];
|
Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex];
|
||||||
foreach (uint ownerId in attachedOwnerIds)
|
foreach (uint ownerId in attachedOwnerIds)
|
||||||
|
|
@ -570,29 +566,6 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
|
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Splits unattached emitters by their owner cell kind so each draws once
|
|
||||||
/// in its retail stage: an outdoor landcell emitter belongs to the
|
|
||||||
/// landscape stage (before the depth clear), an interior EnvCell emitter
|
|
||||||
/// to the final world stage (after the seals). Retail gets this for free
|
|
||||||
/// because a particle draws during its owner CELL's walk turn
|
|
||||||
/// (CPhysicsObj::ShouldDrawParticles @0x0050FE60 reads the one cell).
|
|
||||||
/// AC cell convention: low word < 0x0100 is an outdoor landcell,
|
|
||||||
/// 0x0100..0xFFFD is an interior EnvCell. Cell 0 matches neither scoped
|
|
||||||
/// mode — such an emitter cannot pass the world in-view gate anyway.
|
|
||||||
/// </summary>
|
|
||||||
private static bool MatchesUnattachedCellScope(
|
|
||||||
ParticleEmitter emitter,
|
|
||||||
UnattachedEmitterCellScope scope)
|
|
||||||
{
|
|
||||||
if (scope == UnattachedEmitterCellScope.Any)
|
|
||||||
return true;
|
|
||||||
uint low = emitter.OwnerCellId & 0xFFFFu;
|
|
||||||
return scope == UnattachedEmitterCellScope.OutdoorCells
|
|
||||||
? low != 0 && low < 0x0100u
|
|
||||||
: low >= 0x0100u;
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
|
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
|
||||||
{
|
{
|
||||||
private readonly ParticleSystem _owner;
|
private readonly ParticleSystem _owner;
|
||||||
|
|
|
||||||
|
|
@ -46,20 +46,6 @@ public enum ParticleRenderPass
|
||||||
SkyPostScene = 2,
|
SkyPostScene = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which unattached emitters a scoped render copy admits, by owner cell kind.
|
|
||||||
/// Retail draws every particle during its owner CELL's walk turn, so an
|
|
||||||
/// outdoor-cell emitter renders in the landscape stage and an interior-cell
|
|
||||||
/// emitter in the final world stage; acdream draws each group once in the
|
|
||||||
/// matching stage instead of per portal slice.
|
|
||||||
/// </summary>
|
|
||||||
public enum UnattachedEmitterCellScope
|
|
||||||
{
|
|
||||||
Any = 0,
|
|
||||||
OutdoorCells = 1,
|
|
||||||
InteriorCells = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authority used by retail's particle presentation gate. World-owned
|
/// Authority used by retail's particle presentation gate. World-owned
|
||||||
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and
|
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,6 @@ namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
public sealed class PaperdollFramePresenterTests
|
public sealed class PaperdollFramePresenterTests
|
||||||
{
|
{
|
||||||
/// <summary>One frame in production order: the pre-world resource phase
|
|
||||||
/// (build/redress + prewarm) then the late presentation phase.</summary>
|
|
||||||
private static void Frame(PaperdollFramePresenter presenter)
|
|
||||||
{
|
|
||||||
presenter.PrepareResources();
|
|
||||||
presenter.Render();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
|
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +16,7 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = CreateDoll() };
|
var factory = new RecordingFactory { Doll = CreateDoll() };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.False(presenter.IsDirty);
|
Assert.False(presenter.IsDirty);
|
||||||
Assert.Equal(1, factory.BuildCount);
|
Assert.Equal(1, factory.BuildCount);
|
||||||
|
|
@ -42,7 +34,7 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = doll };
|
var factory = new RecordingFactory { Doll = doll };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.False(presenter.IsDirty);
|
Assert.False(presenter.IsDirty);
|
||||||
Assert.Equal(1, factory.BuildCount);
|
Assert.Equal(1, factory.BuildCount);
|
||||||
|
|
@ -62,25 +54,20 @@ public sealed class PaperdollFramePresenterTests
|
||||||
};
|
};
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
presenter.MarkDirty();
|
presenter.MarkDirty();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.Equal(2, factory.BuildCount);
|
Assert.Equal(2, factory.BuildCount);
|
||||||
Assert.Equal(2, renderer.Dolls.Count);
|
Assert.Single(renderer.Dolls);
|
||||||
Assert.Equal(3, renderer.RenderCount);
|
Assert.Equal(3, renderer.RenderCount);
|
||||||
Assert.False(presenter.IsDirty);
|
Assert.False(presenter.IsDirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PortalRefresh_RedressesEvenAnEquivalentAppearance()
|
public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner()
|
||||||
{
|
{
|
||||||
// #443: the private clone belongs to the current presentation
|
|
||||||
// generation. Retaining an old clone because its pixels compare equal
|
|
||||||
// can pin retired mesh/composite readiness that never completes
|
|
||||||
// again; the renderer's two-phase promote keeps the last completed
|
|
||||||
// image visible while the fresh clone becomes drawable.
|
|
||||||
WorldEntity first = CreateDoll();
|
WorldEntity first = CreateDoll();
|
||||||
WorldEntity repeated = CreateDoll();
|
WorldEntity repeated = CreateDoll();
|
||||||
var renderer = new RecordingRenderer();
|
var renderer = new RecordingRenderer();
|
||||||
|
|
@ -88,13 +75,13 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = first };
|
var factory = new RecordingFactory { Doll = first };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
factory.Doll = repeated;
|
factory.Doll = repeated;
|
||||||
presenter.MarkDirty();
|
presenter.MarkDirty();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.Equal(2, factory.BuildCount);
|
Assert.Equal(2, factory.BuildCount);
|
||||||
Assert.Equal([first, repeated], renderer.Dolls);
|
Assert.Equal([first], renderer.Dolls);
|
||||||
Assert.Equal(2, renderer.RenderCount);
|
Assert.Equal(2, renderer.RenderCount);
|
||||||
Assert.False(presenter.IsDirty);
|
Assert.False(presenter.IsDirty);
|
||||||
}
|
}
|
||||||
|
|
@ -109,34 +96,14 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = first };
|
var factory = new RecordingFactory { Doll = first };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
factory.Doll = changed;
|
factory.Doll = changed;
|
||||||
presenter.MarkDirty();
|
presenter.MarkDirty();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.Equal([first, changed], renderer.Dolls);
|
Assert.Equal([first, changed], renderer.Dolls);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TransientZeroRender_NeverErasesThePublishedTexture()
|
|
||||||
{
|
|
||||||
// #443: zero is a not-ready result (mesh/composite upload latency),
|
|
||||||
// not a request to blank a completed paperdoll.
|
|
||||||
var renderer = new RecordingRenderer { TextureHandle = 91u };
|
|
||||||
var view = new RecordingView();
|
|
||||||
var factory = new RecordingFactory { Doll = CreateDoll() };
|
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
|
||||||
|
|
||||||
Frame(presenter);
|
|
||||||
renderer.TextureHandle = 0u;
|
|
||||||
Frame(presenter);
|
|
||||||
renderer.TextureHandle = 91u;
|
|
||||||
Frame(presenter);
|
|
||||||
|
|
||||||
Assert.Equal([91u, 91u], view.TextureHandles);
|
|
||||||
Assert.Equal(0, view.ClearCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static WorldEntity CreateDoll(float scale = 1f) => new()
|
private static WorldEntity CreateDoll(float scale = 1f) => new()
|
||||||
{
|
{
|
||||||
Id = 42u,
|
Id = 42u,
|
||||||
|
|
@ -156,11 +123,11 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = firstDoll };
|
var factory = new RecordingFactory { Doll = firstDoll };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
factory.CanBuild = false;
|
factory.CanBuild = false;
|
||||||
presenter.MarkDirty();
|
presenter.MarkDirty();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.True(presenter.IsDirty);
|
Assert.True(presenter.IsDirty);
|
||||||
Assert.Equal(3, factory.BuildCount);
|
Assert.Equal(3, factory.BuildCount);
|
||||||
|
|
@ -179,17 +146,14 @@ public sealed class PaperdollFramePresenterTests
|
||||||
var factory = new RecordingFactory { Doll = firstDoll };
|
var factory = new RecordingFactory { Doll = firstDoll };
|
||||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||||
|
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
presenter.ResetSession();
|
presenter.ResetSession();
|
||||||
factory.Doll = secondDoll;
|
factory.Doll = secondDoll;
|
||||||
Frame(presenter);
|
presenter.Render();
|
||||||
|
|
||||||
Assert.False(presenter.IsDirty);
|
Assert.False(presenter.IsDirty);
|
||||||
Assert.Equal(2, factory.BuildCount);
|
Assert.Equal(2, factory.BuildCount);
|
||||||
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
|
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
|
||||||
// The session boundary is the ONE explicit viewport clear (#443);
|
|
||||||
// an old character must not linger while the next one uploads.
|
|
||||||
Assert.Equal(1, view.ClearCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -288,7 +252,7 @@ public sealed class PaperdollFramePresenterTests
|
||||||
|
|
||||||
private sealed class RecordingRenderer : IPaperdollDollRenderer
|
private sealed class RecordingRenderer : IPaperdollDollRenderer
|
||||||
{
|
{
|
||||||
public uint TextureHandle { get; set; }
|
public uint TextureHandle { get; init; }
|
||||||
public List<WorldEntity?> Dolls { get; } = [];
|
public List<WorldEntity?> Dolls { get; } = [];
|
||||||
public List<(int Width, int Height)> RenderSizes { get; } = [];
|
public List<(int Width, int Height)> RenderSizes { get; } = [];
|
||||||
public int RenderCount => RenderSizes.Count;
|
public int RenderCount => RenderSizes.Count;
|
||||||
|
|
@ -321,10 +285,6 @@ public sealed class PaperdollFramePresenterTests
|
||||||
|
|
||||||
public void SetTextureHandle(uint textureHandle) =>
|
public void SetTextureHandle(uint textureHandle) =>
|
||||||
TextureHandles.Add(textureHandle);
|
TextureHandles.Add(textureHandle);
|
||||||
|
|
||||||
public int ClearCount { get; private set; }
|
|
||||||
|
|
||||||
public void ClearTextureHandle() => ClearCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecordingFactory : IPaperdollDollFactory
|
private sealed class RecordingFactory : IPaperdollDollFactory
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
using AcDream.App.Rendering;
|
|
||||||
using AcDream.App.Rendering.Gpu;
|
|
||||||
using AcDream.App.Tests.Rendering.Gpu;
|
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
|
||||||
|
|
||||||
public sealed class PrivateViewportFlightTargetsTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void FlightSlotsOwnDistinctTargetsAndPublishOnlyCompletedScenes()
|
|
||||||
{
|
|
||||||
using var device = new RecordingGpuDevice();
|
|
||||||
using var targets =
|
|
||||||
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
|
|
||||||
device,
|
|
||||||
"paperdoll");
|
|
||||||
|
|
||||||
var first = Assert.IsType<
|
|
||||||
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
|
|
||||||
targets.Ensure(0, 120, 180));
|
|
||||||
var second = Assert.IsType<
|
|
||||||
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
|
|
||||||
targets.Ensure(1, 120, 180));
|
|
||||||
|
|
||||||
Assert.NotSame(first.Target, second.Target);
|
|
||||||
Assert.NotEqual(first.TextureSlot, second.TextureSlot);
|
|
||||||
Assert.Equal(2, targets.AllocatedSlotCount);
|
|
||||||
Assert.Equal(0u, targets.CompletedHandle(0));
|
|
||||||
Assert.Equal(0u, targets.CompletedHandle(1));
|
|
||||||
|
|
||||||
first.HasRenderedScene = true;
|
|
||||||
|
|
||||||
Assert.NotEqual(0u, targets.CompletedHandle(0));
|
|
||||||
Assert.Equal(0u, targets.CompletedHandle(1));
|
|
||||||
Assert.Same(first, targets.Ensure(0, 120, 180));
|
|
||||||
|
|
||||||
targets.InvalidateCompletedScenes();
|
|
||||||
|
|
||||||
Assert.Equal(0u, targets.CompletedHandle(0));
|
|
||||||
Assert.Equal(2, targets.AllocatedSlotCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ResizeRetiresEveryFlightTargetBeforeCreatingTheNewExtent()
|
|
||||||
{
|
|
||||||
using var device = new RecordingGpuDevice();
|
|
||||||
using var targets =
|
|
||||||
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
|
|
||||||
device,
|
|
||||||
"paperdoll");
|
|
||||||
var first = targets.Ensure(0, 120, 180)!;
|
|
||||||
var second = targets.Ensure(1, 120, 180)!;
|
|
||||||
var firstTarget = Assert.IsType<RecordingGpuRenderTarget>(first.Target);
|
|
||||||
var secondTarget = Assert.IsType<RecordingGpuRenderTarget>(second.Target);
|
|
||||||
|
|
||||||
var resized = targets.Ensure(1, 160, 220)!;
|
|
||||||
|
|
||||||
Assert.True(firstTarget.IsDisposed);
|
|
||||||
Assert.True(secondTarget.IsDisposed);
|
|
||||||
Assert.Equal(1, targets.AllocatedSlotCount);
|
|
||||||
Assert.Equal(160, resized.Target.Description.Width);
|
|
||||||
Assert.Equal(220, resized.Target.Description.Height);
|
|
||||||
Assert.Equal(3, device.CreatedRenderTargets.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -75,80 +75,6 @@ public sealed class RetailAlphaQueueTests
|
||||||
Assert.Equal(2, source.ResetCount);
|
Assert.Equal(2, source.ResetCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries()
|
|
||||||
{
|
|
||||||
// The pre-building barrier: retail's far→near walk means DrawBuilding's
|
|
||||||
// FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells
|
|
||||||
// farther than that building; a nearer candle flame is not inserted
|
|
||||||
// yet and composites after the building at a later flush (AP-236).
|
|
||||||
var log = new List<string>();
|
|
||||||
var objects = new RecordingSource("object", log);
|
|
||||||
var particles = new RecordingSource("particle", log);
|
|
||||||
var queue = new RetailAlphaQueue();
|
|
||||||
|
|
||||||
queue.BeginFrame();
|
|
||||||
queue.Submit(particles, 0, 30f); // far waterfall
|
|
||||||
queue.Submit(objects, 0, 25f); // far translucent part
|
|
||||||
queue.Submit(particles, 1, 10f); // exactly at the building threshold
|
|
||||||
queue.Submit(particles, 2, 5f); // near candle flame — must be kept
|
|
||||||
queue.FlushFartherThan(10f);
|
|
||||||
|
|
||||||
Assert.True(queue.IsCollecting);
|
|
||||||
Assert.Equal(1, queue.PendingCount);
|
|
||||||
Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log);
|
|
||||||
Assert.Equal(0, objects.ResetCount);
|
|
||||||
Assert.Equal(0, particles.ResetCount);
|
|
||||||
|
|
||||||
queue.EndFrame();
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
new[] { "particle:0", "object:0", "particle:1", "particle:2" },
|
|
||||||
log);
|
|
||||||
Assert.Equal(1, objects.ResetCount);
|
|
||||||
Assert.Equal(1, particles.ResetCount);
|
|
||||||
Assert.Equal(2, particles.PrepareCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched()
|
|
||||||
{
|
|
||||||
var log = new List<string>();
|
|
||||||
var source = new RecordingSource("alpha", log);
|
|
||||||
var queue = new RetailAlphaQueue();
|
|
||||||
|
|
||||||
queue.BeginFrame();
|
|
||||||
queue.Submit(source, 1, 4f);
|
|
||||||
queue.FlushFartherThan(10f);
|
|
||||||
|
|
||||||
Assert.Empty(log);
|
|
||||||
Assert.Equal(1, queue.PendingCount);
|
|
||||||
Assert.Equal(0, source.PrepareCount);
|
|
||||||
Assert.Equal(0, source.ResetCount);
|
|
||||||
queue.EndFrame();
|
|
||||||
Assert.Equal(new[] { "alpha:1" }, log);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources()
|
|
||||||
{
|
|
||||||
var log = new List<string>();
|
|
||||||
var source = new RecordingSource("alpha", log);
|
|
||||||
var queue = new RetailAlphaQueue();
|
|
||||||
|
|
||||||
queue.BeginFrame();
|
|
||||||
queue.Submit(source, 1, 8f);
|
|
||||||
queue.Submit(source, 2, 2f);
|
|
||||||
queue.FlushFartherThan(0f);
|
|
||||||
|
|
||||||
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
|
|
||||||
Assert.Equal(0, queue.PendingCount);
|
|
||||||
Assert.Equal(0, source.ResetCount);
|
|
||||||
Assert.True(queue.IsCollecting);
|
|
||||||
queue.EndFrame();
|
|
||||||
Assert.Equal(1, source.ResetCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
|
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,13 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
"terrain-clip",
|
"terrain-clip",
|
||||||
"clear-routing",
|
"clear-routing",
|
||||||
"landscape-late",
|
"landscape-late",
|
||||||
"unattached-particles-outdoor",
|
"unattached-particles",
|
||||||
// #132: an OUTDOOR root does NOT drain at the stage boundary.
|
"landscape-alpha",
|
||||||
// The far prefix drains at the pre-punch barrier (retail
|
|
||||||
// DrawBuilding @0x0059F2A0 flushes before its portal-only
|
|
||||||
// far-Z pass), and the full drain runs after the dynamics
|
|
||||||
// pass, where the frame's opaque world depth is complete.
|
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
"landscape-alpha-farther",
|
|
||||||
"exit-mask",
|
"exit-mask",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
"opaque-shells",
|
"opaque-shells",
|
||||||
"landscape-alpha",
|
|
||||||
"unattached-particles-interior",
|
|
||||||
],
|
],
|
||||||
executor.Operations);
|
executor.Operations);
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +65,7 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
string.Join('|', executor.Operations),
|
string.Join('|', executor.Operations),
|
||||||
"landscape-early",
|
"landscape-early",
|
||||||
"landscape-late",
|
"landscape-late",
|
||||||
"unattached-particles-outdoor",
|
"unattached-particles",
|
||||||
"landscape-alpha",
|
"landscape-alpha",
|
||||||
"interior-depth-clear",
|
"interior-depth-clear",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
|
|
@ -100,32 +93,6 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DrawInside_interior_without_an_outside_slice_still_draws_interior_unattached_particles()
|
|
||||||
{
|
|
||||||
// Repro (Sanctuary middle cell, looking north): spell ground effects
|
|
||||||
// vanished whenever no exit portal was in view, because unattached
|
|
||||||
// emitters submitted once PER outside slice under that slice's
|
|
||||||
// hardware clip slot — zero slices meant zero submissions. Retail
|
|
||||||
// draws such an emitter during its owner cell's walk turn
|
|
||||||
// (ShouldDrawParticles @0x0050FE60) and never clips it to a view.
|
|
||||||
var renderer = new RetailPViewRenderer();
|
|
||||||
using var executor = new RecordingExecutor();
|
|
||||||
var root = new LoadedCell
|
|
||||||
{
|
|
||||||
CellId = 0xA9B40100u,
|
|
||||||
WorldTransform = Matrix4x4.Identity,
|
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
|
||||||
};
|
|
||||||
|
|
||||||
renderer.DrawInside(Frame(root), executor);
|
|
||||||
|
|
||||||
Assert.Contains("unattached-particles-interior", executor.Operations);
|
|
||||||
Assert.DoesNotContain(
|
|
||||||
"unattached-particles-outdoor",
|
|
||||||
executor.Operations);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Particle_classifications_reset_before_an_empty_following_frame()
|
public void Particle_classifications_reset_before_an_empty_following_frame()
|
||||||
{
|
{
|
||||||
|
|
@ -310,9 +277,9 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
string.Join('|', executor.Operations),
|
string.Join('|', executor.Operations),
|
||||||
"landscape-early",
|
"landscape-early",
|
||||||
"unattached-particles-outdoor",
|
"unattached-particles",
|
||||||
"landscape-static-particles",
|
"landscape-static-particles",
|
||||||
"landscape-alpha-farther",
|
"landscape-alpha",
|
||||||
"look-in-punch",
|
"look-in-punch",
|
||||||
"landscape-late",
|
"landscape-late",
|
||||||
"landscape-alpha",
|
"landscape-alpha",
|
||||||
|
|
@ -386,7 +353,7 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
"look-in-punch",
|
"look-in-punch",
|
||||||
"landscape-building-shell",
|
"landscape-building-shell",
|
||||||
"landscape-static-particles",
|
"landscape-static-particles",
|
||||||
"landscape-alpha-farther",
|
"landscape-alpha",
|
||||||
"look-in-punch",
|
"look-in-punch",
|
||||||
"landscape-building-shell");
|
"landscape-building-shell");
|
||||||
}
|
}
|
||||||
|
|
@ -796,13 +763,8 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
int portalIndex) => Operations.Add("look-in-punch");
|
int portalIndex) => Operations.Add("look-in-punch");
|
||||||
public void DrawUnattachedSceneParticles(
|
public void DrawUnattachedSceneParticles(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
bool outdoorCells) => Operations.Add(
|
ClipViewSlice slice) => Operations.Add("unattached-particles");
|
||||||
outdoorCells
|
|
||||||
? "unattached-particles-outdoor"
|
|
||||||
: "unattached-particles-interior");
|
|
||||||
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
||||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
|
||||||
Operations.Add("landscape-alpha-farther");
|
|
||||||
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
||||||
public void DrawDynamicsParticles(
|
public void DrawDynamicsParticles(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
|
|
|
||||||
|
|
@ -251,29 +251,6 @@ public sealed class WorldSceneRendererTests
|
||||||
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
|
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PViewWorld_PublishesLookInCellsToParticleAndLightVisibility()
|
|
||||||
{
|
|
||||||
var root = new LoadedCell
|
|
||||||
{
|
|
||||||
CellId = 0x01010001u,
|
|
||||||
IsOutdoorNode = false,
|
|
||||||
};
|
|
||||||
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
|
|
||||||
|
|
||||||
rig.Renderer.Render(default);
|
|
||||||
|
|
||||||
// Retail gates effects on cell->IsInView() (ShouldDrawParticles
|
|
||||||
// @0x0050FE60), and a cell entered through a building portal
|
|
||||||
// (DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0) is
|
|
||||||
// drawn by the same traversal as a flooded cell. The particle gate and
|
|
||||||
// the light-candidate scope must therefore receive InViewCells (flood
|
|
||||||
// plus look-ins), not the flood-only DrawableCells.
|
|
||||||
Assert.Contains(0x01010003u, rig.Visibility.MarkedCells);
|
|
||||||
Assert.NotNull(rig.Frames.ObservedCells);
|
|
||||||
Assert.Contains(0x01010003u, rig.Frames.ObservedCells!);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
|
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
|
||||||
{
|
{
|
||||||
|
|
@ -559,8 +536,7 @@ public sealed class WorldSceneRendererTests
|
||||||
Frames = new FrameBuilder(Calls, frame);
|
Frames = new FrameBuilder(Calls, frame);
|
||||||
Selection = new SelectionFrame(Calls);
|
Selection = new SelectionFrame(Calls);
|
||||||
var alpha = new AlphaFrame(Calls);
|
var alpha = new AlphaFrame(Calls);
|
||||||
Visibility = new ParticleVisibility(Calls);
|
var visibility = new ParticleVisibility(Calls);
|
||||||
var visibility = Visibility;
|
|
||||||
PView = new PViewRenderer(Calls);
|
PView = new PViewRenderer(Calls);
|
||||||
Passes = new PassExecutor(Calls);
|
Passes = new PassExecutor(Calls);
|
||||||
var diagnostics = new Diagnostics(Calls);
|
var diagnostics = new Diagnostics(Calls);
|
||||||
|
|
@ -597,8 +573,6 @@ public sealed class WorldSceneRendererTests
|
||||||
|
|
||||||
public SelectionFrame Selection { get; }
|
public SelectionFrame Selection { get; }
|
||||||
|
|
||||||
public ParticleVisibility Visibility { get; }
|
|
||||||
|
|
||||||
public PViewRenderer PView { get; }
|
public PViewRenderer PView { get; }
|
||||||
|
|
||||||
public PassExecutor Passes { get; }
|
public PassExecutor Passes { get; }
|
||||||
|
|
@ -647,13 +621,8 @@ public sealed class WorldSceneRendererTests
|
||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlySet<uint>? ObservedCells { get; private set; }
|
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
||||||
|
|
||||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
|
|
||||||
{
|
|
||||||
calls.Add("frame:observe-cells");
|
calls.Add("frame:observe-cells");
|
||||||
ObservedCells = new HashSet<uint>(drawableCells);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
|
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
|
||||||
}
|
}
|
||||||
|
|
@ -715,13 +684,8 @@ public sealed class WorldSceneRendererTests
|
||||||
private sealed class ParticleVisibility(List<string> calls) :
|
private sealed class ParticleVisibility(List<string> calls) :
|
||||||
IWorldSceneParticleVisibility
|
IWorldSceneParticleVisibility
|
||||||
{
|
{
|
||||||
public HashSet<uint> MarkedCells { get; } = [];
|
public void MarkVisibleCells(HashSet<uint> cellIds) =>
|
||||||
|
|
||||||
public void MarkVisibleCells(HashSet<uint> cellIds)
|
|
||||||
{
|
|
||||||
calls.Add("visibility:mark");
|
calls.Add("visibility:mark");
|
||||||
MarkedCells.UnionWith(cellIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CompleteFrame() => calls.Add("visibility:complete");
|
public void CompleteFrame() => calls.Add("visibility:complete");
|
||||||
|
|
||||||
|
|
@ -737,17 +701,11 @@ public sealed class WorldSceneRendererTests
|
||||||
public PViewRenderer(List<string> calls)
|
public PViewRenderer(List<string> calls)
|
||||||
{
|
{
|
||||||
_calls = calls;
|
_calls = calls;
|
||||||
// Distinct flood-only vs in-view sets: 0x01010003 is a look-in
|
|
||||||
// cell that is drawn but never part of the main flood.
|
|
||||||
var interiorPartition = new InteriorEntityPartition.Result();
|
|
||||||
_interiorResult = new RetailPViewFrameResult().Reset(
|
_interiorResult = new RetailPViewFrameResult().Reset(
|
||||||
new PortalVisibilityFrame(),
|
new PortalVisibilityFrame(),
|
||||||
new ClipFrameAssembly(),
|
new ClipFrameAssembly(),
|
||||||
[0x01010001u],
|
[],
|
||||||
[0x01010001u, 0x01010003u],
|
new InteriorEntityPartition.Result());
|
||||||
RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
|
|
||||||
RetailPViewRenderer.LegacySourceCounts(interiorPartition),
|
|
||||||
interiorPartition);
|
|
||||||
var outdoorPortalFrame = new PortalVisibilityFrame();
|
var outdoorPortalFrame = new PortalVisibilityFrame();
|
||||||
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
|
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -742,58 +742,6 @@ public sealed class ParticleSystemTests
|
||||||
Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle));
|
Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void UnattachedCellScope_SplitsEmittersByOwnerCellKind()
|
|
||||||
{
|
|
||||||
// One submission per retail stage: outdoor-landcell unattached
|
|
||||||
// emitters ride the landscape stage and interior-EnvCell ones the
|
|
||||||
// final world stage, because retail draws each particle during its
|
|
||||||
// owner CELL's walk turn (ShouldDrawParticles @0x0050FE60). AC cell
|
|
||||||
// convention: low word < 0x0100 is a landcell, >= 0x0100 an EnvCell;
|
|
||||||
// cell 0 matches neither scoped mode.
|
|
||||||
var sys = MakeSystem();
|
|
||||||
var desc = new EmitterDesc
|
|
||||||
{
|
|
||||||
DatId = 0x32000083u,
|
|
||||||
Type = ParticleType.Still,
|
|
||||||
MaxParticles = 1,
|
|
||||||
};
|
|
||||||
int outdoor = sys.SpawnEmitter(desc, Vector3.Zero);
|
|
||||||
sys.UpdateEmitterOwnerCell(outdoor, 0xA9B40021u);
|
|
||||||
int interior = sys.SpawnEmitter(desc, Vector3.Zero);
|
|
||||||
sys.UpdateEmitterOwnerCell(interior, 0xA9B40100u);
|
|
||||||
int cellLess = sys.SpawnEmitter(desc, Vector3.Zero);
|
|
||||||
|
|
||||||
var destination = new List<ParticleEmitter>();
|
|
||||||
var none = new HashSet<uint>();
|
|
||||||
|
|
||||||
sys.CopyRenderableEmittersForOwners(
|
|
||||||
ParticleRenderPass.Scene,
|
|
||||||
none,
|
|
||||||
includeUnattached: true,
|
|
||||||
destination,
|
|
||||||
unattachedCellScope: UnattachedEmitterCellScope.OutdoorCells);
|
|
||||||
Assert.Equal(new[] { outdoor }, destination.Select(e => e.Handle));
|
|
||||||
|
|
||||||
sys.CopyRenderableEmittersForOwners(
|
|
||||||
ParticleRenderPass.Scene,
|
|
||||||
none,
|
|
||||||
includeUnattached: true,
|
|
||||||
destination,
|
|
||||||
unattachedCellScope: UnattachedEmitterCellScope.InteriorCells);
|
|
||||||
Assert.Equal(new[] { interior }, destination.Select(e => e.Handle));
|
|
||||||
|
|
||||||
sys.CopyRenderableEmittersForOwners(
|
|
||||||
ParticleRenderPass.Scene,
|
|
||||||
none,
|
|
||||||
includeUnattached: true,
|
|
||||||
destination,
|
|
||||||
unattachedCellScope: UnattachedEmitterCellScope.Any);
|
|
||||||
Assert.Equal(
|
|
||||||
new[] { outdoor, interior, cellLess },
|
|
||||||
destination.Select(e => e.Handle));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable()
|
public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue