diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7744316e..0922068d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -39,6 +39,97 @@ 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 @@ -431,7 +522,29 @@ 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:** CLOSED 2026-08-28 — owner-directed ledger cleanup. +**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. **Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner acceptance. Reopened after the owner again observed a missing paperdoll that @@ -19536,6 +19649,24 @@ 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 cf0bf2d1..dc94659c 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -762,7 +762,8 @@ internal sealed class FrameRootCompositionPhase var framePreparation = new RenderFramePreparationController( renderFrameResources, devTools: null, - renderWeatherFrame); + renderWeatherFrame, + live.PaperdollPresenter); 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 815a794c..b19eba1d 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -24,6 +24,8 @@ internal interface IPaperdollFrameView bool TryGetVisibleSize(out int width, out int height); void SetTextureHandle(uint textureHandle); + + void ClearTextureHandle(); } internal interface IPaperdollInventoryVisibility @@ -51,7 +53,9 @@ internal interface IPaperdollPoseApplicator /// presentation edge. The renderer remains a borrowed resource disposed by /// the existing window shutdown transaction. /// -internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame +internal sealed class PaperdollFramePresenter : + IPrivateEntityViewportFrame, + IPrivateEntityViewportResourcePreparation { private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; @@ -73,22 +77,30 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame public void MarkDirty() => _dirty = true; - public void Render() + /// + /// Pre-world resource phase: rebuild/redress the private clone and advance + /// its mesh/composite readiness BEFORE world draws can consume the + /// bounded per-frame composite-upload budget. In a dense scene the late + /// presentation phase never wins that budget, which is why the doll was + /// visible only while portal space quiesced the world (#443). + /// + public void PrepareResources() { if (_dirty) { if (_factory.TryBuild(out WorldEntity? 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; - } + // 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; _dirty = false; } else @@ -96,16 +108,25 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame // 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 visible frame. + // retry this dirty redress on the next frame. } } _renderer.Prepare(); + } + public void Render() + { if (!_view.TryGetVisibleSize(out int width, out int height)) return; - _view.SetTextureHandle(_renderer.Render(width, height)); + // 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); } /// @@ -115,80 +136,10 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame 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. @@ -226,6 +177,9 @@ 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 6a963121..9766089b 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -234,7 +234,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable IReadOnlySet attachedOwnerIds, bool includeUnattached = false, IReadOnlySet? excludedAttachedOwnerIds = null, - uint clipSlot = 0) + uint clipSlot = 0, + UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any) { if (camera is null) return; @@ -244,7 +245,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable attachedOwnerIds, includeUnattached, _scopedEmitterScratch, - excludedAttachedOwnerIds); + excludedAttachedOwnerIds, + unattachedCellScope); 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 84961aab..c395af3f 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 -/// , one synthetic render identity, and one -/// balanced texture-owner lease. +/// per encountered GPU flight slot, 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,7 +65,6 @@ internal sealed class PrivateEntityViewportRenderer : { private const uint PrivateLandblockId = 0u; - private readonly IGpuDevice _device; private readonly ICurrentGpuFrameSource _frames; /// @@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer : /// feature does not exist for them, not just "unused". private readonly EntitySlot? _backdropSlot; - // 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; + // 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; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -127,7 +121,7 @@ internal sealed class PrivateEntityViewportRenderer : _scope = scope ?? throw new ArgumentNullException( nameof(scope), "The viewport must publish a world pass scope to draw into."); - _device = device ?? throw new ArgumentNullException(nameof(device)); + ArgumentNullException.ThrowIfNull(device); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); @@ -137,6 +131,9 @@ internal sealed class PrivateEntityViewportRenderer : _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; + _flightTargets = new PrivateViewportFlightTargets( + device, + _diagnosticName); IEntityTextureLifetime textureLifetimeChecked = textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)); @@ -173,7 +170,17 @@ internal sealed class PrivateEntityViewportRenderer : /// public bool TextureIsBottomUp => false; - public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); + 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(); + } + } /// /// Advances the private entity's mesh and texture-composite readiness @@ -226,51 +233,50 @@ 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 - // 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. + // 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. bool mainReady = _mainSlot.PrepareForDraw(); bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; if (!mainReady || !backdropReady) { return _mainSlot.Entity is not null - && _hasRenderedScene - && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) + ? _flightTargets.CompletedHandle(frameSlot) : 0u; } WorldEntity? entity = _mainSlot.Entity; - if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) + if (entity is null || entity.MeshRefs.Count == 0) return 0u; IReadOnlyList drawEntities = BuildDrawEntities( _backdropSlot?.Entity, entity); if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) - { - return _hasRenderedScene && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) - : 0u; - } + return _flightTargets.CompletedHandle(frameSlot); - EnsureRenderTarget(width, height); - if (_target is null) + PrivateViewportFlightTargets.TargetSlot? targetSlot = + _flightTargets.Ensure(frameSlot, width, height); + if (targetSlot 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: _target, + Target: targetSlot.Target, Load: GpuLoadOp.Clear, Store: GpuStoreOp.Store, ClearColor: Vector4.Zero), @@ -304,6 +310,12 @@ 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, @@ -311,8 +323,8 @@ internal sealed class PrivateEntityViewportRenderer : neverCullLandblockId: PrivateLandblockId, visibleCellIds: null, animatedEntityIds: _animatedIds); - _hasRenderedScene = true; - return UiTextureTableHandle.FromSlot(_slot); + targetSlot.HasRenderedScene = true; + return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot); } /// @@ -363,69 +375,6 @@ 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; @@ -447,7 +396,7 @@ internal sealed class PrivateEntityViewportRenderer : } try { - ReleaseRenderTarget(); + _flightTargets.Dispose(); } catch (Exception error) { @@ -462,6 +411,163 @@ 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 1a3d5db8..fa59bf91 100644 --- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -16,6 +16,19 @@ 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 82306d67..820ddaa1 100644 --- a/src/AcDream.App/Rendering/RenderFramePreparationController.cs +++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs @@ -29,20 +29,29 @@ 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) + IRenderWeatherFramePhase weather, + IPrivateEntityViewportResourcePreparation? privateViewports = null) { _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 9fea6157..3f828f0a 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -239,6 +239,106 @@ 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 5b90654a..037c9f17 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -444,20 +444,9 @@ internal sealed class RetailPViewPassExecutor : animatedEntityIds: frame.AnimatedEntityIds); } - _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); - } - + // 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. EnableClipDistances(); if (frame.RenderSky && frame.RenderWeather) { @@ -492,8 +481,12 @@ internal sealed class RetailPViewPassExecutor : RetailPViewFrameInput frame, RetailPViewLandscapeStaticParticleContext context) { - bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); - _surface.BindTerrainClip(); + // 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. DisableClipDistances(); _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); @@ -506,11 +499,9 @@ internal sealed class RetailPViewPassExecutor : frame.CameraWorldPosition, ParticleRenderPass.Scene, _particleClassifications.Outdoor, - clipSlot: (uint)context.Slice.Slot); + clipSlot: 0); } - if (scissor) - _surface.EndScissor(); _entities.ClearClipRouting(); DisableClipDistances(); } @@ -573,11 +564,19 @@ internal sealed class RetailPViewPassExecutor : public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, - ClipViewSlice slice) + bool outdoorCells) { 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, @@ -585,11 +584,17 @@ internal sealed class RetailPViewPassExecutor : ParticleRenderPass.Scene, _noSceneParticleEntityIds, includeUnattached: true, - clipSlot: (uint)slice.Slot); + clipSlot: 0, + unattachedCellScope: outdoorCells + ? UnattachedEmitterCellScope.OutdoorCells + : UnattachedEmitterCellScope.InteriorCells); } public void FlushLandscapeAlpha() => _alpha.Flush(); + public void FlushLandscapeAlphaFartherThan(float minViewerDistance) => + _alpha.FlushFartherThan(minViewerDistance); + public void DrawCellParticles( RetailPViewFrameInput frame, RetailPViewCellSliceContext context) @@ -608,12 +613,14 @@ 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: (uint)context.Slice.Slot); + clipSlot: 0); DisableClipDistances(); } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index bc582a3d..f1c71440 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -28,6 +28,19 @@ 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 @@ -166,13 +179,19 @@ 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; } @@ -251,10 +270,13 @@ 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); @@ -296,6 +318,31 @@ 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( @@ -317,6 +364,24 @@ 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); @@ -428,6 +493,71 @@ 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++) @@ -485,12 +615,22 @@ public sealed class RetailPViewRenderer { PortalVisibilityFrame frame = _lookInFrames[frameIndex]; - // 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. + // 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. if (frameIndex > 0) - passes.FlushLandscapeAlpha(); + { + passes.FlushLandscapeAlphaFartherThan( + LookInBarrierDrainDistance( + frame, + ctx.Cells, + ctx.CameraWorldPosition)); + } // Pass 1: far-Z punch every aperture of this building. foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) @@ -550,6 +690,8 @@ public sealed class RetailPViewRenderer _cellStaticScratch.Add(e); } + bool cellDrewObjects = false; + _cellParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in cellSlices) { int routeIndex = lookInRouteIndex++; @@ -595,12 +737,21 @@ public sealed class RetailPViewRenderer _cellStaticScratch, _oneCell); - // 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)); + cellDrewObjects = true; + _cellParticleUnionScratch.UnionWith( + _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 @@ -612,6 +763,7 @@ 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( @@ -690,14 +842,20 @@ public sealed class RetailPViewRenderer _lateParticleOwnerScratch, _buildingShellScratch); } - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); + _staticParticleUnionScratch.UnionWith( + _lateParticleOwnerScratch); } sliceIndex++; } + + // ONE unclipped submission for this look-in frame's shell-route + // owners (retail: one alpha-list insertion per emitter, + // depth-occluded at the flush — never re-drawn per outside view). + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + _staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); } } @@ -795,14 +953,19 @@ public sealed class RetailPViewRenderer bool hasBuildingLookIns = _lookInFrames.Count > 0; if (hasBuildingLookIns) { - 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); + // 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++) + { _lateParticleOwnerScratch.Clear(); if (partition is not null) { @@ -835,15 +998,30 @@ public sealed class RetailPViewRenderer barrierSliceIndex, 0); } - - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); - barrierSliceIndex++; + _staticParticleUnionScratch.UnionWith( + _lateParticleOwnerScratch); } - passes.FlushLandscapeAlpha(); + + // 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)); } // #124: far-building look-ins draw HERE — still inside the landscape @@ -862,8 +1040,10 @@ 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 — flames ride here for the same reason). + // 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(); probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { @@ -891,7 +1071,15 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); - _lateParticleOwnerScratch.Add(e.Id); + // 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); } } if (frameEntityPasses is not null) @@ -931,37 +1119,75 @@ public sealed class RetailPViewRenderer 0, ctx.PlayerLandblockId ?? 0); probeSliceIndex++; + _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch); passes.DrawLandscapeSliceLate( ctx, new RetailPViewLandscapeLateSliceContext( slice, - _outdoorStaticScratch, - _lateParticleOwnerScratch) + _outdoorStaticScratch) { 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. 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. + // 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. if (!hasBuildingLookIns) - { - foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) - passes.DrawUnattachedSceneParticles(ctx, slice); - } + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); // 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. - passes.FlushLandscapeAlpha(); + // + // 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(); // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — @@ -1134,13 +1360,13 @@ public sealed class RetailPViewRenderer Array.Empty(), visibleCellIds: null); - // 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); + // 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. if (_dynamicParticleOwnerScratch.Count > 0) { passes.DrawDynamicsParticles( @@ -1177,6 +1403,14 @@ 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); @@ -1223,22 +1457,28 @@ 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 (!_outsideStageDynamics.Contains(e)) + if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _dynamicParticleOwnerScratch.Add(e.Id); } if (_dynamicParticleOwnerScratch.Count > 0) @@ -1630,10 +1870,29 @@ 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, - ClipViewSlice slice); + bool outdoorCells); 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); @@ -1910,6 +2169,23 @@ 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 @@ -1919,6 +2195,7 @@ public sealed class RetailPViewFrameResult PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, + HashSet inViewCells, RenderFrameDiagnosticCounts diagnosticCounts, RenderProjectionCounts sourceCounts, InteriorEntityPartition.Result? diagnosticPartition) @@ -1926,6 +2203,7 @@ public sealed class RetailPViewFrameResult PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; + InViewCells = inViewCells; DiagnosticCounts = diagnosticCounts; SourceCounts = sourceCounts; DiagnosticPartition = diagnosticPartition; @@ -1941,6 +2219,7 @@ public sealed class RetailPViewFrameResult portalFrame, clipAssembly, drawableCells, + drawableCells, RetailPViewRenderer.LegacyDiagnosticCounts( diagnosticPartition), RetailPViewRenderer.LegacySourceCounts( @@ -1956,12 +2235,13 @@ public readonly record struct RetailPViewLandscapeSliceContext( } /// -/// 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. +/// 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. /// public readonly record struct RetailPViewLandscapeStaticParticleContext( - ClipViewSlice Slice, IReadOnlySet ParticleOwnerIds); /// Retail DrawBuilding's ordinary exterior-shell pass, issued after @@ -1978,8 +2258,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext( /// submitted at a pre-building barrier. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, - IReadOnlyList Dynamics, - IReadOnlySet ParticleOwnerIds) + IReadOnlyList Dynamics) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 61f2d3e7..10bb06e3 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1128,6 +1128,12 @@ 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 = []; @@ -1528,6 +1534,16 @@ 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++) @@ -1539,6 +1555,14 @@ 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 30b22946..2d9fac4b 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -924,6 +924,24 @@ 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, @@ -939,7 +957,9 @@ public sealed unsafe partial class WbDrawDispatcher ObserveOrdinaryTransformDemand( frame.Serial, checked((uint)(matrixFloats.Length / 16))); - if (!_worldTransformFrames.IsActive) + bool privatePass = NextClassicDrawIsPrivatePass; + NextClassicDrawIsPrivatePass = false; + if (privatePass || !_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 9d7ec0d1..39ea601d 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -254,8 +254,14 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase camera.Camera.View, _diagnostics.CameraCellResolution)); - _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); - _frames.ObserveDrawableCells(pviewResult.DrawableCells); + // 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); _diagnostics.EmitPViewInput( pviewResult.PortalFrame, camera.ViewProjection, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index c25a476d..7a63e96c 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -271,8 +271,10 @@ 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.DrawableCells, one frame of latency, to avoid - /// re-threading a mid-render callback) removes those from contention before the + /// 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 /// 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 4345c6f3..818587e6 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -526,7 +526,8 @@ public sealed class ParticleSystem : IParticleSystem IReadOnlySet attachedOwnerIds, bool includeUnattached, List destination, - IReadOnlySet? excludedAttachedOwnerIds = null) + IReadOnlySet? excludedAttachedOwnerIds = null, + UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any) { ArgumentNullException.ThrowIfNull(attachedOwnerIds); ArgumentNullException.ThrowIfNull(destination); @@ -539,8 +540,11 @@ public sealed class ParticleSystem : IParticleSystem foreach (int handle in _renderableUnattachedHandlesByPass[passIndex]) { LastRenderScopeEmitterVisitCount++; - if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) + if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter) + && MatchesUnattachedCellScope(emitter, unattachedCellScope)) + { destination.Add(emitter); + } } } @@ -566,6 +570,29 @@ 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 bdeb661c..a019a304 100644 --- a/src/AcDream.Core/Vfx/VfxModel.cs +++ b/src/AcDream.Core/Vfx/VfxModel.cs @@ -46,6 +46,20 @@ 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 41f47c47..5b4ee653 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -8,6 +8,14 @@ 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() { @@ -16,7 +24,7 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = CreateDoll() }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); Assert.False(presenter.IsDirty); Assert.Equal(1, factory.BuildCount); @@ -34,7 +42,7 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = doll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); Assert.False(presenter.IsDirty); Assert.Equal(1, factory.BuildCount); @@ -54,20 +62,25 @@ public sealed class PaperdollFramePresenterTests }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); - presenter.Render(); + Frame(presenter); + Frame(presenter); presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); Assert.Equal(2, factory.BuildCount); - Assert.Single(renderer.Dolls); + Assert.Equal(2, renderer.Dolls.Count); Assert.Equal(3, renderer.RenderCount); Assert.False(presenter.IsDirty); } [Fact] - public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner() + public void PortalRefresh_RedressesEvenAnEquivalentAppearance() { + // #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(); @@ -75,13 +88,13 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = first }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.Doll = repeated; presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); Assert.Equal(2, factory.BuildCount); - Assert.Equal([first], renderer.Dolls); + Assert.Equal([first, repeated], renderer.Dolls); Assert.Equal(2, renderer.RenderCount); Assert.False(presenter.IsDirty); } @@ -96,14 +109,34 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = first }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.Doll = changed; presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); 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, @@ -123,11 +156,11 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = firstDoll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.CanBuild = false; presenter.MarkDirty(); - presenter.Render(); - presenter.Render(); + Frame(presenter); + Frame(presenter); Assert.True(presenter.IsDirty); Assert.Equal(3, factory.BuildCount); @@ -146,14 +179,17 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = firstDoll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); presenter.ResetSession(); factory.Doll = secondDoll; - presenter.Render(); + Frame(presenter); 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] @@ -252,7 +288,7 @@ public sealed class PaperdollFramePresenterTests private sealed class RecordingRenderer : IPaperdollDollRenderer { - public uint TextureHandle { get; init; } + public uint TextureHandle { get; set; } public List Dolls { get; } = []; public List<(int Width, int Height)> RenderSizes { get; } = []; public int RenderCount => RenderSizes.Count; @@ -285,6 +321,10 @@ 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 new file mode 100644 index 00000000..ac2d7bf5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs @@ -0,0 +1,65 @@ +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 2141e611..d60cc655 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -75,6 +75,80 @@ 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 71068650..36628dbf 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -34,13 +34,20 @@ public sealed class RetailPViewPassExecutorTests "terrain-clip", "clear-routing", "landscape-late", - "unattached-particles", - "landscape-alpha", + "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. "indoor-routing", "indoor-routing", + "landscape-alpha-farther", "exit-mask", "indoor-routing", "opaque-shells", + "landscape-alpha", + "unattached-particles-interior", ], executor.Operations); } @@ -65,7 +72,7 @@ public sealed class RetailPViewPassExecutorTests string.Join('|', executor.Operations), "landscape-early", "landscape-late", - "unattached-particles", + "unattached-particles-outdoor", "landscape-alpha", "interior-depth-clear", "indoor-routing", @@ -93,6 +100,32 @@ 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() { @@ -277,9 +310,9 @@ public sealed class RetailPViewPassExecutorTests AssertAppearsInOrder( string.Join('|', executor.Operations), "landscape-early", - "unattached-particles", + "unattached-particles-outdoor", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-late", "landscape-alpha", @@ -353,7 +386,7 @@ public sealed class RetailPViewPassExecutorTests "look-in-punch", "landscape-building-shell", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-building-shell"); } @@ -763,8 +796,13 @@ public sealed class RetailPViewPassExecutorTests int portalIndex) => Operations.Add("look-in-punch"); public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, - ClipViewSlice slice) => Operations.Add("unattached-particles"); + bool outdoorCells) => Operations.Add( + outdoorCells + ? "unattached-particles-outdoor" + : "unattached-particles-interior"); 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 a8baa7b9..c67028eb 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -251,6 +251,29 @@ 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() { @@ -536,7 +559,8 @@ public sealed class WorldSceneRendererTests Frames = new FrameBuilder(Calls, frame); Selection = new SelectionFrame(Calls); var alpha = new AlphaFrame(Calls); - var visibility = new ParticleVisibility(Calls); + Visibility = new ParticleVisibility(Calls); + var visibility = Visibility; PView = new PViewRenderer(Calls); Passes = new PassExecutor(Calls); var diagnostics = new Diagnostics(Calls); @@ -573,6 +597,8 @@ public sealed class WorldSceneRendererTests public SelectionFrame Selection { get; } + public ParticleVisibility Visibility { get; } + public PViewRenderer PView { get; } public PassExecutor Passes { get; } @@ -621,8 +647,13 @@ public sealed class WorldSceneRendererTests return frame; } - public void ObserveDrawableCells(IReadOnlySet drawableCells) => + public IReadOnlySet? ObservedCells { get; private set; } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) + { calls.Add("frame:observe-cells"); + ObservedCells = new HashSet(drawableCells); + } public void ClearDrawableCells() => calls.Add("frame:clear-cells"); } @@ -684,8 +715,13 @@ public sealed class WorldSceneRendererTests private sealed class ParticleVisibility(List calls) : IWorldSceneParticleVisibility { - public void MarkVisibleCells(HashSet cellIds) => + public HashSet MarkedCells { get; } = []; + + public void MarkVisibleCells(HashSet cellIds) + { calls.Add("visibility:mark"); + MarkedCells.UnionWith(cellIds); + } public void CompleteFrame() => calls.Add("visibility:complete"); @@ -701,11 +737,17 @@ 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(), - [], - new InteriorEntityPartition.Result()); + [0x01010001u], + [0x01010001u, 0x01010003u], + RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition), + RetailPViewRenderer.LegacySourceCounts(interiorPartition), + interiorPartition); 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 0d6f4a52..466958b4 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -742,6 +742,58 @@ 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() {