diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 0922068d..7744316e 100644
--- a/docs/ISSUES.md
+++ b/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
[`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
**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")
-**Status:** FIXED 2026-08-29 — root cause found; owner visual gate pending.
-
-**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.
+**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup.
**Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner
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
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
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index dc94659c..cf0bf2d1 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -762,8 +762,7 @@ internal sealed class FrameRootCompositionPhase
var framePreparation = new RenderFramePreparationController(
renderFrameResources,
devTools: null,
- renderWeatherFrame,
- live.PaperdollPresenter);
+ renderWeatherFrame);
IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null
&& lifecycleAutomation is not null
diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
index b19eba1d..815a794c 100644
--- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
+++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
@@ -24,8 +24,6 @@ internal interface IPaperdollFrameView
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
-
- void ClearTextureHandle();
}
internal interface IPaperdollInventoryVisibility
@@ -53,9 +51,7 @@ internal interface IPaperdollPoseApplicator
/// presentation edge. The renderer remains a borrowed resource disposed by
/// the existing window shutdown transaction.
///
-internal sealed class PaperdollFramePresenter :
- IPrivateEntityViewportFrame,
- IPrivateEntityViewportResourcePreparation
+internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;
@@ -77,30 +73,22 @@ internal sealed class PaperdollFramePresenter :
public void MarkDirty() => _dirty = true;
- ///
- /// 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).
- ///
- public void PrepareResources()
+ public void Render()
{
if (_dirty)
{
if (_factory.TryBuild(out WorldEntity? doll))
{
- // Redress every accepted live-player refresh, including an
- // appearance-equivalent ObjDesc after portal/relogin. The
- // private clone belongs to the current presentation
- // generation; retaining the old object merely because its
- // pixels compare equal can leave it attached to retired
- // mesh/composite readiness that never completes again.
- // PrivateEntityViewportRenderer promotes replacements in two
- // phases, so the last completed target remains visible until
- // this fresh clone is completely drawable.
- _renderer.SetDoll(doll);
- _doll = doll;
+ // Same-generation CreateObject refreshes can repeat the exact
+ // player ObjDesc at a portal boundary. Retail redresses its
+ // private inventory object in place; releasing and reacquiring
+ // an identical synthetic owner briefly blanks the viewport and
+ // churns its texture composites.
+ if (!HasEquivalentAppearance(_doll, doll))
+ {
+ _renderer.SetDoll(doll);
+ _doll = doll;
+ }
_dirty = false;
}
else
@@ -108,25 +96,16 @@ internal sealed class PaperdollFramePresenter :
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// private m_pInventoryObject intact when the SmartBox player
// 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();
- }
- public void Render()
- {
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
- // Zero is a transient not-ready result, not a request to erase a
- // 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);
+ _view.SetTextureHandle(_renderer.Render(width, height));
}
///
@@ -136,10 +115,80 @@ internal sealed class PaperdollFramePresenter :
public void ResetSession()
{
_renderer.SetDoll(null);
- _view.ClearTextureHandle();
_doll = null;
_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? left,
+ IReadOnlyDictionary? 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;
+ }
}
/// Retained-UI visibility and texture publication for the doll view.
@@ -177,9 +226,6 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
///
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
-
- public void ClearTextureHandle() =>
- _viewport.TextureSlot = GpuTextureSlot.Unassigned;
}
/// Narrow visibility adapter for the paperdoll's inventory host.
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs
index 9766089b..6a963121 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.cs
@@ -234,8 +234,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
IReadOnlySet attachedOwnerIds,
bool includeUnattached = false,
IReadOnlySet? excludedAttachedOwnerIds = null,
- uint clipSlot = 0,
- UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
+ uint clipSlot = 0)
{
if (camera is null)
return;
@@ -245,8 +244,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
attachedOwnerIds,
includeUnattached,
_scopedEmitterScratch,
- excludedAttachedOwnerIds,
- unattachedCellScope);
+ excludedAttachedOwnerIds);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
index c395af3f..84961aab 100644
--- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
@@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
///
/// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one
-/// per encountered GPU flight slot, one
-/// synthetic render identity, and one balanced texture-owner lease.
+/// , one synthetic render identity, and one
+/// balanced texture-owner lease.
///
/// Campaign V slice V6k (V4g's first half). The target used to be a
/// 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 readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames;
///
@@ -93,12 +94,17 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".
private readonly EntitySlot? _backdropSlot;
- // A target written by frame N cannot also be sampled by an unretired frame
- // N-1. Vulkan permits those command buffers to overlap, so one shared image
- // is a cross-frame write/read race. The current frame slot selects one
- // bounded target + texture-table handle; the retained UI samples that exact
- // handle later in the same command buffer.
- private readonly PrivateViewportFlightTargets _flightTargets;
+ // One stable sampled texture-table slot is part of the retained viewport's
+ // presentation contract. Rotating the slot with the Vulkan flight index
+ // made the UI sample a freshly-created/cleared sibling after world reveal.
+ // The frame submission order already protects this target's write -> sample
+ // transition; keep its identity stable until resize or disposal.
+ private IGpuRenderTarget? _target;
+ private IGpuSampler? _sampler;
+ private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
+ private int _fbW;
+ private int _fbH;
+ private bool _hasRenderedScene;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@@ -121,7 +127,7 @@ internal sealed class PrivateEntityViewportRenderer :
_scope = scope ?? throw new ArgumentNullException(
nameof(scope),
"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));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
@@ -131,9 +137,6 @@ internal sealed class PrivateEntityViewportRenderer :
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
- _flightTargets = new PrivateViewportFlightTargets(
- device,
- _diagnosticName);
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
?? throw new ArgumentNullException(nameof(textureLifetime));
@@ -170,17 +173,7 @@ internal sealed class PrivateEntityViewportRenderer :
///
public bool TextureIsBottomUp => false;
- public void SetEntity(WorldEntity? 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();
- }
- }
+ public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
///
/// Advances the private entity's mesh and texture-composite readiness
@@ -233,50 +226,51 @@ internal sealed class PrivateEntityViewportRenderer :
///
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
// preparation/GPU upload; it does not make the mesh drawable. Keep the
- // current flight slot's last completed private scene intact until every
- // drawable mesh in the replacement has crossed that upload barrier.
- // On first open there is no completed scene, so return zero and let the
- // authored panel art show through instead of publishing a cleared target.
+ // last completed private scene intact until every drawable mesh in the
+ // replacement has crossed that upload barrier. On first open there is
+ // no completed scene, so return zero and let the authored panel art
+ // show through instead of publishing a freshly-cleared black target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
- ? _flightTargets.CompletedHandle(frameSlot)
+ && _hasRenderedScene
+ && _slot.IsAssigned
+ ? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
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;
IReadOnlyList drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
- return _flightTargets.CompletedHandle(frameSlot);
+ {
+ return _hasRenderedScene && _slot.IsAssigned
+ ? UiTextureTableHandle.FromSlot(_slot)
+ : 0u;
+ }
- PrivateViewportFlightTargets.TargetSlot? targetSlot =
- _flightTargets.Ensure(frameSlot, width, height);
- if (targetSlot is null)
+ EnsureRenderTarget(width, height);
+ if (_target is null)
return 0u;
_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
{
Name = _diagnosticName,
Color = new GpuColorAttachment(
- Target: targetSlot.Target,
+ Target: _target,
Load: GpuLoadOp.Clear,
Store: GpuStoreOp.Store,
ClearColor: Vector4.Zero),
@@ -310,12 +304,6 @@ internal sealed class PrivateEntityViewportRenderer :
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(
_camera,
entries,
@@ -323,8 +311,8 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
- targetSlot.HasRenderedScene = true;
- return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
+ _hasRenderedScene = true;
+ return UiTextureTableHandle.FromSlot(_slot);
}
///
@@ -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()
{
List? failures = null;
@@ -396,7 +447,7 @@ internal sealed class PrivateEntityViewportRenderer :
}
try
{
- _flightTargets.Dispose();
+ ReleaseRenderTarget();
}
catch (Exception error)
{
@@ -411,163 +462,6 @@ internal sealed class PrivateEntityViewportRenderer :
}
}
- ///
- /// Bounded render-target ownership keyed by .
- /// 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.
- ///
- 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 _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? 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();
- }
- }
-
///
/// 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
diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
index fa59bf91..1a3d5db8 100644
--- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
@@ -16,19 +16,6 @@ internal interface IPrivateEntityViewportFrame
void Render();
}
-///
-/// 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).
-///
-internal interface IPrivateEntityViewportResourcePreparation
-{
- void PrepareResources();
-}
-
internal interface IRetainedGameplayUiFrame
{
void Render(double deltaSeconds, int width, int height);
diff --git a/src/AcDream.App/Rendering/RenderFramePreparationController.cs b/src/AcDream.App/Rendering/RenderFramePreparationController.cs
index 820ddaa1..82306d67 100644
--- a/src/AcDream.App/Rendering/RenderFramePreparationController.cs
+++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs
@@ -29,29 +29,20 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
private readonly IRenderFrameResourcePhase _resources;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IRenderWeatherFramePhase _weather;
- private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
public RenderFramePreparationController(
IRenderFrameResourcePhase resources,
IDevToolsFrameLifecycle? devTools,
- IRenderWeatherFramePhase weather,
- IPrivateEntityViewportResourcePreparation? privateViewports = null)
+ IRenderWeatherFramePhase weather)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_devTools = devTools;
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
- _privateViewports = privateViewports;
}
public void Prepare(RenderFrameInput 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);
_weather.Tick(input.DeltaSeconds);
}
diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
index 3f828f0a..9fea6157 100644
--- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs
+++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
@@ -239,106 +239,6 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
}
- ///
- /// Drains only the entries at or beyond
- /// 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
- /// DrawBuilding's FlushAlphaList(0f) @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 .
- ///
- 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? 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()
{
if (!IsCollecting)
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index 037c9f17..5b90654a 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -444,9 +444,20 @@ internal sealed class RetailPViewPassExecutor :
animatedEntityIds: frame.AnimatedEntityIds);
}
- // Late-stage particle owners submit ONCE per frame through
- // DrawLandscapeStaticParticles after the slice loop (retail: one
- // unclipped alpha-list insertion per emitter), not per slice here.
+ _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
+
+ 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();
if (frame.RenderSky && frame.RenderWeather)
{
@@ -481,12 +492,8 @@ internal sealed class RetailPViewPassExecutor :
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context)
{
- // One unclipped submission per owner per frame. Retail never clips a
- // particle to a portal view — its polys join the one alpha list during
- // 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.
+ bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
+ _surface.BindTerrainClip();
DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
@@ -499,9 +506,11 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
- clipSlot: 0);
+ clipSlot: (uint)context.Slice.Slot);
}
+ if (scissor)
+ _surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@@ -564,19 +573,11 @@ internal sealed class RetailPViewPassExecutor :
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
- bool outdoorCells)
+ ClipViewSlice slice)
{
if (_particles is null || _particleRenderer is null)
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();
_particleRenderer.DrawForOwners(
frame.Camera,
@@ -584,17 +585,11 @@ internal sealed class RetailPViewPassExecutor :
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true,
- clipSlot: 0,
- unattachedCellScope: outdoorCells
- ? UnattachedEmitterCellScope.OutdoorCells
- : UnattachedEmitterCellScope.InteriorCells);
+ clipSlot: (uint)slice.Slot);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
- public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
- _alpha.FlushFartherThan(minViewerDistance);
-
public void DrawCellParticles(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context)
@@ -613,14 +608,12 @@ internal sealed class RetailPViewPassExecutor :
return;
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(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible,
- clipSlot: 0);
+ clipSlot: (uint)context.Slice.Slot);
DisableClipDistances();
}
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index f1c71440..bc582a3d 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -28,19 +28,6 @@ public sealed class RetailPViewRenderer
private static readonly IReadOnlySet NoParticleOwners =
new HashSet();
- // 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 _staticParticleUnionScratch = new();
- private readonly HashSet _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 _lookInCellIds = new();
-
private readonly HashSet _oneCell = new(1);
// 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
@@ -179,19 +166,13 @@ public sealed class RetailPViewRenderer
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
- _lookInCellIds.Clear();
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
- {
foreach (uint c in f.OrderedVisibleCells)
- {
_lookInPrepareScratch.Add(c);
- _lookInCellIds.Add(c);
- }
- }
prepareCells = _lookInPrepareScratch;
}
@@ -270,13 +251,10 @@ public sealed class RetailPViewRenderer
RenderProjectionCounts sourceCounts = frameViewBorrowed
? frameView.SourceDigest.Counts
: 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(
pvFrame,
clipAssembly,
drawableCells,
- prepareCells,
counts,
sourceCounts,
partition);
@@ -318,31 +296,6 @@ public sealed class RetailPViewRenderer
frameEntityPasses,
in frameView);
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);
DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists(
@@ -364,24 +317,6 @@ public sealed class RetailPViewRenderer
frameEntityPasses,
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)
{
frameEntityPasses!.CompleteEntityFrame(in frameView);
@@ -493,71 +428,6 @@ public sealed class RetailPViewRenderer
}
}
- ///
- /// 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.
- ///
- ///
- /// The pre-punch barrier threshold for :
- /// 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.
- ///
- internal static float ExitPortalMaskBarrierDistance(
- PortalVisibilityFrame frame,
- HashSet 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()
{
for (int i = 0; i < _lookInFrames.Count; i++)
@@ -615,22 +485,12 @@ public sealed class RetailPViewRenderer
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
- // Retail enters DrawBuilding once per building and drains the
- // alpha accumulated by the preceding building before punching the
- // next building's portals — and because retail's far→near walk
- // has only inserted FARTHER content by then, that drain can never
- // 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.
+ // Retail enters DrawBuilding once per building and drains every
+ // alpha submission accumulated by the preceding building before
+ // punching the next building's portals. The first building uses
+ // the pre-look-in barrier in DrawLandscapeThroughOutsideView.
if (frameIndex > 0)
- {
- passes.FlushLandscapeAlphaFartherThan(
- LookInBarrierDrainDistance(
- frame,
- ctx.Cells,
- ctx.CameraWorldPosition));
- }
+ passes.FlushLandscapeAlpha();
// Pass 1: far-Z punch every aperture of this building.
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
@@ -690,8 +550,6 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
- bool cellDrewObjects = false;
- _cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices)
{
int routeIndex = lookInRouteIndex++;
@@ -737,21 +595,12 @@ public sealed class RetailPViewRenderer
_cellStaticScratch,
_oneCell);
- cellDrewObjects = true;
- _cellParticleUnionScratch.UnionWith(
- _cellParticleOwnerScratch);
+ // The nested DrawCells object pass includes emitters and
+ // retains the exact setup_view clip until alpha playback.
+ passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
+ cellId, slice, _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(
- cellId, NoClipSlice, _cellParticleUnionScratch));
- }
}
// The ordinary exterior building shell is clipped by the outer
@@ -763,7 +612,6 @@ public sealed class RetailPViewRenderer
// anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby.
int sliceIndex = 0;
- _staticParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{
int shellRouteIndex = LookInBuildingShellRouteIndex(
@@ -842,20 +690,14 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch,
_buildingShellScratch);
}
- _staticParticleUnionScratch.UnionWith(
- _lateParticleOwnerScratch);
+ passes.DrawLandscapeStaticParticles(
+ ctx,
+ new RetailPViewLandscapeStaticParticleContext(
+ slice,
+ _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(
- ctx,
- new RetailPViewLandscapeStaticParticleContext(
- _staticParticleUnionScratch));
- _staticParticleUnionScratch.Clear();
}
}
@@ -953,19 +795,14 @@ public sealed class RetailPViewRenderer
bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns)
{
- // Ownerless OUTDOOR-cell emitters cannot ride an entity route.
- // Retail inserts each one into the single alpha list once, during
- // 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++)
+ int barrierSliceIndex = 0;
+ foreach (var slice in clipAssembly.OutsideViewSlices)
{
+ // 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();
if (partition is not null)
{
@@ -998,30 +835,15 @@ public sealed class RetailPViewRenderer
barrierSliceIndex,
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(
- ctx,
- new RetailPViewLandscapeStaticParticleContext(
- _staticParticleUnionScratch));
- _staticParticleUnionScratch.Clear();
- passes.FlushLandscapeAlphaFartherThan(
- LookInBarrierDrainDistance(
- _lookInFrames[0],
- ctx.Cells,
- ctx.CameraWorldPosition));
+ passes.DrawLandscapeStaticParticles(
+ ctx,
+ new RetailPViewLandscapeStaticParticleContext(
+ slice,
+ _lateParticleOwnerScratch));
+ barrierSliceIndex++;
+ }
+ passes.FlushLandscapeAlpha();
}
// #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
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
- // instead of being overpainted). The scene-particle owners (statics +
- // dynamics cone survivors) accumulate across the slices and submit
- // ONCE, unclipped, after the loop.
- _staticParticleUnionScratch.Clear();
+ // instead of being overpainted) + the scene-particle owners (statics +
+ // dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@@ -1071,15 +891,7 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_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);
}
}
if (frameEntityPasses is not null)
@@ -1119,75 +931,37 @@ public sealed class RetailPViewRenderer
0,
ctx.PlayerLandblockId ?? 0);
probeSliceIndex++;
- _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate(
ctx,
new RetailPViewLandscapeLateSliceContext(
slice,
- _outdoorStaticScratch)
+ _outdoorStaticScratch,
+ _lateParticleOwnerScratch)
{
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,
// campfires, ground effects anchored at a position) have no owner id
- // to ride any of the id-filtered particle passes. OUTDOOR-cell ones
- // submit ONCE in the landscape stage, unclipped — retail inserts each
- // particle into the single alpha list during its owner cell's walk
- // turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
- // FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
- // once-per-outside-slice submission with that slice's clip slot cut
- // effects at aperture boundaries and drew NOTHING when no outside
- // slice was in view. Interior-cell unattached emitters submit in the
- // final world scope (DrawDynamicsLast) — in the landscape stage the
- // upcoming depth clear + interior repaint would erase them.
+ // to ride any of the id-filtered particle passes. Draw once per
+ // installed outside_view for BOTH root kinds, matching retail's
+ // landscape-stage placement and preserving the slot in each deferred
+ // draw. The former outdoor-root post-world tail ran after building
+ // cells and let exterior alpha repaint the cathedral transition.
+ // With no look-ins they drain at the end of the landscape stage; the
+ // look-in path submits them at its pre-building barrier so later opaque
+ // cell floors can cover them.
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
// immediately after LScape::draw and before the optional depth clear.
// 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
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
@@ -1360,13 +1134,13 @@ public sealed class RetailPViewRenderer
Array.Empty(),
visibleCellIds: null);
- // Particles emit exactly once, in the stage matching the parent
- // cell. Pure-outdoor dynamics are absent from the DynamicLast
- // route (they draw only in the outside stage), and interior
- // straddlers — present in BOTH routes — emit their particles
- // HERE so the interior stage cannot repaint over them; the late
- // landscape submission excludes DynamicLast owners for the same
- // reason.
+ // An owner routed through any pre-clear outside slice already had
+ // its alpha particles drawn there. Meshes may be submitted in both
+ // stages, but particles must be emitted exactly once.
+ RenderFrameRouteOwnerSelector.ExceptRoute(
+ _dynamicParticleOwnerScratch,
+ in frameView,
+ RenderFrameCandidateRoute.LandscapeOutsideDynamic);
if (_dynamicParticleOwnerScratch.Count > 0)
{
passes.DrawDynamicsParticles(
@@ -1403,14 +1177,6 @@ public sealed class RetailPViewRenderer
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
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
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
@@ -1457,28 +1223,22 @@ public sealed class RetailPViewRenderer
// particles must not double-draw, unlike the depth-idempotent meshes).
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(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0);
+ RenderFrameRouteOwnerSelector.ExceptRoute(
+ _dynamicParticleOwnerScratch,
+ in frameView,
+ RenderFrameCandidateRoute.LandscapeOutsideDynamic);
}
else
{
_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)
- if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
+ if (!_outsideStageDynamics.Contains(e))
_dynamicParticleOwnerScratch.Add(e.Id);
}
if (_dynamicParticleOwnerScratch.Count > 0)
@@ -1870,29 +1630,10 @@ public interface IRetailPViewPassExecutor
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context,
int portalIndex);
- ///
- /// 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.
- ///
void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
- bool outdoorCells);
+ ClipViewSlice slice);
void FlushLandscapeAlpha();
-
- ///
- /// Pre/inter-building barrier drain: composites only the queued alpha at
- /// or beyond 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.
- ///
- void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
- FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
@@ -2169,23 +1910,6 @@ public sealed class RetailPViewFrameResult
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public HashSet DrawableCells { get; private set; } = null!;
-
- ///
- /// Every cell this completed view actually reached: the main flood
- /// () plus the building look-in cells. This is
- /// retail's per-cell in_view answer for effect consumers —
- /// CPhysicsObj::ShouldDrawParticles @0x0050FE60 gates on
- /// cell->IsInView(), and a cell entered through a building portal
- /// (PView::ConstructView @0x005A57B0, installed by
- /// RenderDeviceD3D::DrawBuilding @0x0059F2A0) is drawn by the same
- /// PView::DrawCells traversal as a flooded cell, so retail marks it
- /// in view identically. acdream's look-in adaptation keeps those cells out
- /// of (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.
- ///
- public HashSet InViewCells { get; private set; } = null!;
-
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
internal RenderProjectionCounts SourceCounts { get; private set; }
internal InteriorEntityPartition.Result? DiagnosticPartition
@@ -2195,7 +1919,6 @@ public sealed class RetailPViewFrameResult
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet drawableCells,
- HashSet inViewCells,
RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition)
@@ -2203,7 +1926,6 @@ public sealed class RetailPViewFrameResult
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
- InViewCells = inViewCells;
DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition;
@@ -2219,7 +1941,6 @@ public sealed class RetailPViewFrameResult
portalFrame,
clipAssembly,
drawableCells,
- drawableCells,
RetailPViewRenderer.LegacyDiagnosticCounts(
diagnosticPartition),
RetailPViewRenderer.LegacySourceCounts(
@@ -2235,13 +1956,12 @@ public readonly record struct RetailPViewLandscapeSliceContext(
}
///
-/// Scene-particle owners for ONE unclipped landscape-stage submission (the
-/// union of every outside slice's cone survivors). Mesh alpha for the same
-/// owners is already queued by the entity routes; retail inserts each
-/// emitter's polys into the single alpha list once, during its owner cell's
-/// walk turn, with no portal-view clip.
+/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
+/// Mesh alpha for the same owners is already queued by the early landscape
+/// entity route.
///
public readonly record struct RetailPViewLandscapeStaticParticleContext(
+ ClipViewSlice Slice,
IReadOnlySet ParticleOwnerIds);
/// Retail DrawBuilding's ordinary exterior-shell pass, issued after
@@ -2258,7 +1978,8 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
/// submitted at a pre-building barrier.
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
- IReadOnlyList Dynamics)
+ IReadOnlyList Dynamics,
+ IReadOnlySet ParticleOwnerIds)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
index 10bb06e3..61f2d3e7 100644
--- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
@@ -1128,12 +1128,6 @@ internal sealed class RenderScenePViewFrameBuilder
private RenderProjectionRecord[] _cell = [];
private RenderProjectionRecord[] _dirty = [];
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 _lookInCellScratch = new();
private RenderProjectionRecord[] _cellRoute = [];
private readonly Dictionary
_outdoorPositions = [];
@@ -1534,16 +1528,6 @@ internal sealed class RenderScenePViewFrameBuilder
RenderFrameWriter writer,
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;
EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++)
@@ -1555,14 +1539,6 @@ internal sealed class RenderScenePViewFrameBuilder
if (!input.RootIsOutdoor && !indoor)
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);
bool visible = indoor
? input.Viewcone.SphereVisibleInCell(
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index 2d9fac4b..30b22946 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -924,24 +924,6 @@ public sealed unsafe partial class WbDrawDispatcher
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
- ///
- /// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
- /// chargen preview) and must take the plain ring transform path with
- /// firstInstance = 0, 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 .
- ///
- internal bool NextClassicDrawIsPrivatePass;
-
private RhiSection WriteWorldTransformSection(
IGpuFrame frame,
ReadOnlySpan matrixFloats,
@@ -957,9 +939,7 @@ public sealed unsafe partial class WbDrawDispatcher
ObserveOrdinaryTransformDemand(
frame.Serial,
checked((uint)(matrixFloats.Length / 16)));
- bool privatePass = NextClassicDrawIsPrivatePass;
- NextClassicDrawIsPrivatePass = false;
- if (privatePass || !_worldTransformFrames.IsActive)
+ if (!_worldTransformFrames.IsActive)
{
firstInstance = 0;
return WriteRingSection(frame, matrixFloats);
diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
index 39ea601d..9d7ec0d1 100644
--- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
@@ -254,14 +254,8 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
camera.Camera.View,
_diagnostics.CameraCellResolution));
- // Effect visibility consumes InViewCells (main flood ∪ look-in
- // cells), not the flood-only DrawableCells: retail's
- // 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);
+ _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
+ _frames.ObserveDrawableCells(pviewResult.DrawableCells);
_diagnostics.EmitPViewInput(
pviewResult.PortalFrame,
camera.ViewProjection,
diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs
index 7a63e96c..c25a476d 100644
--- a/src/AcDream.Core/Lighting/LightManager.cs
+++ b/src/AcDream.Core/Lighting/LightManager.cs
@@ -271,10 +271,8 @@ public sealed class LightManager
/// 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
/// visible cells (the render already computes this — callers pass last frame's
- /// RetailPViewFrameResult.InViewCells, the main flood PLUS building
- /// look-in cells, one frame of latency, to avoid re-threading a mid-render
- /// callback; flood-only scoping darkened look-in rooms' lanterns) removes
- /// non-visible cells from contention before the
+ /// RetailPViewFrameResult.DrawableCells, one frame of latency, to avoid
+ /// re-threading a mid-render callback) removes those from contention before the
/// 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
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs
index 818587e6..4345c6f3 100644
--- a/src/AcDream.Core/Vfx/ParticleSystem.cs
+++ b/src/AcDream.Core/Vfx/ParticleSystem.cs
@@ -526,8 +526,7 @@ public sealed class ParticleSystem : IParticleSystem
IReadOnlySet attachedOwnerIds,
bool includeUnattached,
List destination,
- IReadOnlySet? excludedAttachedOwnerIds = null,
- UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
+ IReadOnlySet? excludedAttachedOwnerIds = null)
{
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
ArgumentNullException.ThrowIfNull(destination);
@@ -540,11 +539,8 @@ public sealed class ParticleSystem : IParticleSystem
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
{
LastRenderScopeEmitterVisitCount++;
- if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
- && MatchesUnattachedCellScope(emitter, unattachedCellScope))
- {
+ if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
destination.Add(emitter);
- }
}
}
@@ -570,29 +566,6 @@ public sealed class ParticleSystem : IParticleSystem
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
}
- ///
- /// 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.
- ///
- 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
{
private readonly ParticleSystem _owner;
diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs
index a019a304..bdeb661c 100644
--- a/src/AcDream.Core/Vfx/VfxModel.cs
+++ b/src/AcDream.Core/Vfx/VfxModel.cs
@@ -46,20 +46,6 @@ public enum ParticleRenderPass
SkyPostScene = 2,
}
-///
-/// 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.
-///
-public enum UnattachedEmitterCellScope
-{
- Any = 0,
- OutdoorCells = 1,
- InteriorCells = 2,
-}
-
///
/// Authority used by retail's particle presentation gate. World-owned
/// emitters follow CPhysicsObj::ShouldDrawParticles; examination and
diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
index 5b4ee653..41f47c47 100644
--- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
@@ -8,14 +8,6 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
- /// One frame in production order: the pre-world resource phase
- /// (build/redress + prewarm) then the late presentation phase.
- private static void Frame(PaperdollFramePresenter presenter)
- {
- presenter.PrepareResources();
- presenter.Render();
- }
-
[Fact]
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
{
@@ -24,7 +16,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@@ -42,7 +34,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@@ -62,25 +54,20 @@ public sealed class PaperdollFramePresenterTests
};
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
- Frame(presenter);
+ presenter.Render();
+ presenter.Render();
presenter.MarkDirty();
- Frame(presenter);
+ presenter.Render();
Assert.Equal(2, factory.BuildCount);
- Assert.Equal(2, renderer.Dolls.Count);
+ Assert.Single(renderer.Dolls);
Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
[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 repeated = CreateDoll();
var renderer = new RecordingRenderer();
@@ -88,13 +75,13 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
factory.Doll = repeated;
presenter.MarkDirty();
- Frame(presenter);
+ presenter.Render();
Assert.Equal(2, factory.BuildCount);
- Assert.Equal([first, repeated], renderer.Dolls);
+ Assert.Equal([first], renderer.Dolls);
Assert.Equal(2, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
@@ -109,34 +96,14 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
factory.Doll = changed;
presenter.MarkDirty();
- Frame(presenter);
+ presenter.Render();
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()
{
Id = 42u,
@@ -156,11 +123,11 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
factory.CanBuild = false;
presenter.MarkDirty();
- Frame(presenter);
- Frame(presenter);
+ presenter.Render();
+ presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(3, factory.BuildCount);
@@ -179,17 +146,14 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- Frame(presenter);
+ presenter.Render();
presenter.ResetSession();
factory.Doll = secondDoll;
- Frame(presenter);
+ presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount);
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]
@@ -288,7 +252,7 @@ public sealed class PaperdollFramePresenterTests
private sealed class RecordingRenderer : IPaperdollDollRenderer
{
- public uint TextureHandle { get; set; }
+ public uint TextureHandle { get; init; }
public List Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
@@ -321,10 +285,6 @@ public sealed class PaperdollFramePresenterTests
public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle);
-
- public int ClearCount { get; private set; }
-
- public void ClearTextureHandle() => ClearCount++;
}
private sealed class RecordingFactory : IPaperdollDollFactory
diff --git a/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs
deleted file mode 100644
index ac2d7bf5..00000000
--- a/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs
+++ /dev/null
@@ -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(first.Target);
- var secondTarget = Assert.IsType(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);
- }
-}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
index d60cc655..2141e611 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
@@ -75,80 +75,6 @@ public sealed class RetailAlphaQueueTests
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();
- 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();
- 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();
- 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]
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
{
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index 36628dbf..71068650 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -34,20 +34,13 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip",
"clear-routing",
"landscape-late",
- "unattached-particles-outdoor",
- // #132: an OUTDOOR root does NOT drain at the stage boundary.
- // 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.
+ "unattached-particles",
+ "landscape-alpha",
"indoor-routing",
"indoor-routing",
- "landscape-alpha-farther",
"exit-mask",
"indoor-routing",
"opaque-shells",
- "landscape-alpha",
- "unattached-particles-interior",
],
executor.Operations);
}
@@ -72,7 +65,7 @@ public sealed class RetailPViewPassExecutorTests
string.Join('|', executor.Operations),
"landscape-early",
"landscape-late",
- "unattached-particles-outdoor",
+ "unattached-particles",
"landscape-alpha",
"interior-depth-clear",
"indoor-routing",
@@ -100,32 +93,6 @@ public sealed class RetailPViewPassExecutorTests
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]
public void Particle_classifications_reset_before_an_empty_following_frame()
{
@@ -310,9 +277,9 @@ public sealed class RetailPViewPassExecutorTests
AssertAppearsInOrder(
string.Join('|', executor.Operations),
"landscape-early",
- "unattached-particles-outdoor",
+ "unattached-particles",
"landscape-static-particles",
- "landscape-alpha-farther",
+ "landscape-alpha",
"look-in-punch",
"landscape-late",
"landscape-alpha",
@@ -386,7 +353,7 @@ public sealed class RetailPViewPassExecutorTests
"look-in-punch",
"landscape-building-shell",
"landscape-static-particles",
- "landscape-alpha-farther",
+ "landscape-alpha",
"look-in-punch",
"landscape-building-shell");
}
@@ -796,13 +763,8 @@ public sealed class RetailPViewPassExecutorTests
int portalIndex) => Operations.Add("look-in-punch");
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
- bool outdoorCells) => Operations.Add(
- outdoorCells
- ? "unattached-particles-outdoor"
- : "unattached-particles-interior");
+ ClipViewSlice slice) => Operations.Add("unattached-particles");
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 DrawDynamicsParticles(
RetailPViewFrameInput frame,
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index c67028eb..a8baa7b9 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -251,29 +251,6 @@ public sealed class WorldSceneRendererTests
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]
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
{
@@ -559,8 +536,7 @@ public sealed class WorldSceneRendererTests
Frames = new FrameBuilder(Calls, frame);
Selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls);
- Visibility = new ParticleVisibility(Calls);
- var visibility = Visibility;
+ var visibility = new ParticleVisibility(Calls);
PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls);
@@ -597,8 +573,6 @@ public sealed class WorldSceneRendererTests
public SelectionFrame Selection { get; }
- public ParticleVisibility Visibility { get; }
-
public PViewRenderer PView { get; }
public PassExecutor Passes { get; }
@@ -647,13 +621,8 @@ public sealed class WorldSceneRendererTests
return frame;
}
- public IReadOnlySet? ObservedCells { get; private set; }
-
- public void ObserveDrawableCells(IReadOnlySet drawableCells)
- {
+ public void ObserveDrawableCells(IReadOnlySet drawableCells) =>
calls.Add("frame:observe-cells");
- ObservedCells = new HashSet(drawableCells);
- }
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
}
@@ -715,13 +684,8 @@ public sealed class WorldSceneRendererTests
private sealed class ParticleVisibility(List calls) :
IWorldSceneParticleVisibility
{
- public HashSet MarkedCells { get; } = [];
-
- public void MarkVisibleCells(HashSet cellIds)
- {
+ public void MarkVisibleCells(HashSet cellIds) =>
calls.Add("visibility:mark");
- MarkedCells.UnionWith(cellIds);
- }
public void CompleteFrame() => calls.Add("visibility:complete");
@@ -737,17 +701,11 @@ public sealed class WorldSceneRendererTests
public PViewRenderer(List 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(
new PortalVisibilityFrame(),
new ClipFrameAssembly(),
- [0x01010001u],
- [0x01010001u, 0x01010003u],
- RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
- RetailPViewRenderer.LegacySourceCounts(interiorPartition),
- interiorPartition);
+ [],
+ new InteriorEntityPartition.Result());
var outdoorPortalFrame = new PortalVisibilityFrame();
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
[
diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
index 466958b4..0d6f4a52 100644
--- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
+++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
@@ -742,58 +742,6 @@ public sealed class ParticleSystemTests
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();
- var none = new HashSet();
-
- 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]
public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable()
{