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