diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 7744316e..0c0dab2f 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -431,7 +431,29 @@ authoritative 8→8 reconciliation. Owner check is in
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
-**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup.
+**Status:** FIXED 2026-08-29 — root cause found; owner visual gate pending.
+
+**2026-08-29 root cause (the "only visible in portal space" recurrence):**
+the classic `WbDrawDispatcher.Draw` path appended its transforms into the
+SHARED world transform frame with a non-zero base instance, but the default
+mesh shaders index every parallel per-instance array (clip slots, light
+sets, indoor, OPACITY, selection lighting, detail category) zero-based —
+only the packed world submission's shader convention subtracts the
+shared-arena prefix. The doll therefore drew all of its instances with
+per-instance opacity 0 into a cleared target whenever a world transform
+frame was active: counted draws, blank pixels, deterministic. Portal space
+worked because the arena is inactive there (ring path, base 0). The
+private viewports are the only production consumers of the classic path,
+which is why nothing else ever showed the defect. Fix:
+`WbDrawDispatcher.NextClassicDrawIsPrivatePass` — private passes always
+take the ring transform path (self-contained render state). The same round
+also restored per-flight-slot private targets (the f6fe0f2a single-slot
+revert relied on cross-command-buffer ordering Vulkan does not guarantee),
+moved paperdoll resource preparation into the frame's resource phase ahead
+of world composite-budget consumption, made the presenter redress on every
+dirty edge, and stopped transient zero handles from erasing a completed
+image (session reset is the one explicit clear). Verified live: doll
+visible in the normal world, through portal space, and after arrival.
**Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner
acceptance. Reopened after the owner again observed a missing paperdoll that
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index cf0bf2d1..dc94659c 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -762,7 +762,8 @@ internal sealed class FrameRootCompositionPhase
var framePreparation = new RenderFramePreparationController(
renderFrameResources,
devTools: null,
- renderWeatherFrame);
+ renderWeatherFrame,
+ live.PaperdollPresenter);
IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null
&& lifecycleAutomation is not null
diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
index 815a794c..b19eba1d 100644
--- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
+++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
@@ -24,6 +24,8 @@ internal interface IPaperdollFrameView
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
+
+ void ClearTextureHandle();
}
internal interface IPaperdollInventoryVisibility
@@ -51,7 +53,9 @@ internal interface IPaperdollPoseApplicator
/// presentation edge. The renderer remains a borrowed resource disposed by
/// the existing window shutdown transaction.
///
-internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
+internal sealed class PaperdollFramePresenter :
+ IPrivateEntityViewportFrame,
+ IPrivateEntityViewportResourcePreparation
{
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;
@@ -73,22 +77,30 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void MarkDirty() => _dirty = true;
- public void Render()
+ ///
+ /// Pre-world resource phase: rebuild/redress the private clone and advance
+ /// its mesh/composite readiness BEFORE world draws can consume the
+ /// bounded per-frame composite-upload budget. In a dense scene the late
+ /// presentation phase never wins that budget, which is why the doll was
+ /// visible only while portal space quiesced the world (#443).
+ ///
+ public void PrepareResources()
{
if (_dirty)
{
if (_factory.TryBuild(out WorldEntity? doll))
{
- // Same-generation CreateObject refreshes can repeat the exact
- // player ObjDesc at a portal boundary. Retail redresses its
- // private inventory object in place; releasing and reacquiring
- // an identical synthetic owner briefly blanks the viewport and
- // churns its texture composites.
- if (!HasEquivalentAppearance(_doll, doll))
- {
- _renderer.SetDoll(doll);
- _doll = doll;
- }
+ // Redress every accepted live-player refresh, including an
+ // appearance-equivalent ObjDesc after portal/relogin. The
+ // private clone belongs to the current presentation
+ // generation; retaining the old object merely because its
+ // pixels compare equal can leave it attached to retired
+ // mesh/composite readiness that never completes again.
+ // PrivateEntityViewportRenderer promotes replacements in two
+ // phases, so the last completed target remains visible until
+ // this fresh clone is completely drawable.
+ _renderer.SetDoll(doll);
+ _doll = doll;
_dirty = false;
}
else
@@ -96,16 +108,25 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// private m_pInventoryObject intact when the SmartBox player
// is temporarily unavailable. Keep the successful doll and
- // retry this dirty redress on the next visible frame.
+ // retry this dirty redress on the next frame.
}
}
_renderer.Prepare();
+ }
+ public void Render()
+ {
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
- _view.SetTextureHandle(_renderer.Render(width, height));
+ // Zero is a transient not-ready result, not a request to erase a
+ // previously completed paperdoll. Session reset clears explicitly in
+ // ResetSession; ordinary mesh/composite upload latency keeps the last
+ // good image instead of intermittently blanking the viewport.
+ uint textureHandle = _renderer.Render(width, height);
+ if (textureHandle != 0u)
+ _view.SetTextureHandle(textureHandle);
}
///
@@ -115,80 +136,10 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void ResetSession()
{
_renderer.SetDoll(null);
+ _view.ClearTextureHandle();
_doll = null;
_dirty = true;
}
-
- private static bool HasEquivalentAppearance(
- WorldEntity? current,
- WorldEntity? candidate)
- {
- if (current is null || candidate is null)
- return ReferenceEquals(current, candidate);
- if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
- || current.Scale != candidate.Scale
- || current.HiddenPartsMask != candidate.HiddenPartsMask
- || current.MeshRefs.Count != candidate.MeshRefs.Count
- || current.PartOverrides.Count != candidate.PartOverrides.Count)
- {
- return false;
- }
-
- for (int i = 0; i < current.MeshRefs.Count; i++)
- {
- MeshRef left = current.MeshRefs[i];
- MeshRef right = candidate.MeshRefs[i];
- if (left.GfxObjId != right.GfxObjId
- || left.PartTransform != right.PartTransform
- || !DictionaryEquals(
- left.SurfaceOverrides,
- right.SurfaceOverrides))
- {
- return false;
- }
- }
-
- for (int i = 0; i < current.PartOverrides.Count; i++)
- {
- if (current.PartOverrides[i] != candidate.PartOverrides[i])
- return false;
- }
-
- PaletteOverride? leftPalette = current.PaletteOverride;
- PaletteOverride? rightPalette = candidate.PaletteOverride;
- if (leftPalette is null || rightPalette is null)
- return leftPalette is null && rightPalette is null;
- if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
- || leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
- {
- return false;
- }
- for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
- {
- if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
- return false;
- }
- return true;
- }
-
- private static bool DictionaryEquals(
- IReadOnlyDictionary? left,
- IReadOnlyDictionary? right)
- {
- if (left is null || right is null)
- return left is null && right is null;
- if (left.Count != right.Count)
- return false;
- foreach ((uint key, uint value) in left)
- {
- if (!right.TryGetValue(key, out uint rightValue)
- || rightValue != value)
- {
- return false;
- }
- }
- return true;
- }
}
/// Retained-UI visibility and texture publication for the doll view.
@@ -226,6 +177,9 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
///
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
+
+ public void ClearTextureHandle() =>
+ _viewport.TextureSlot = GpuTextureSlot.Unassigned;
}
/// Narrow visibility adapter for the paperdoll's inventory host.
diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
index 84961aab..c395af3f 100644
--- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
@@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
///
/// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one
-/// , one synthetic render identity, and one
-/// balanced texture-owner lease.
+/// per encountered GPU flight slot, one
+/// synthetic render identity, and one balanced texture-owner lease.
///
/// Campaign V slice V6k (V4g's first half). The target used to be a
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
@@ -65,7 +65,6 @@ internal sealed class PrivateEntityViewportRenderer :
{
private const uint PrivateLandblockId = 0u;
- private readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames;
///
@@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".
private readonly EntitySlot? _backdropSlot;
- // One stable sampled texture-table slot is part of the retained viewport's
- // presentation contract. Rotating the slot with the Vulkan flight index
- // made the UI sample a freshly-created/cleared sibling after world reveal.
- // The frame submission order already protects this target's write -> sample
- // transition; keep its identity stable until resize or disposal.
- private IGpuRenderTarget? _target;
- private IGpuSampler? _sampler;
- private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
- private int _fbW;
- private int _fbH;
- private bool _hasRenderedScene;
+ // A target written by frame N cannot also be sampled by an unretired frame
+ // N-1. Vulkan permits those command buffers to overlap, so one shared image
+ // is a cross-frame write/read race. The current frame slot selects one
+ // bounded target + texture-table handle; the retained UI samples that exact
+ // handle later in the same command buffer.
+ private readonly PrivateViewportFlightTargets _flightTargets;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@@ -127,7 +121,7 @@ internal sealed class PrivateEntityViewportRenderer :
_scope = scope ?? throw new ArgumentNullException(
nameof(scope),
"The viewport must publish a world pass scope to draw into.");
- _device = device ?? throw new ArgumentNullException(nameof(device));
+ ArgumentNullException.ThrowIfNull(device);
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
@@ -137,6 +131,9 @@ internal sealed class PrivateEntityViewportRenderer :
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
+ _flightTargets = new PrivateViewportFlightTargets(
+ device,
+ _diagnosticName);
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
?? throw new ArgumentNullException(nameof(textureLifetime));
@@ -173,7 +170,17 @@ internal sealed class PrivateEntityViewportRenderer :
///
public bool TextureIsBottomUp => false;
- public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
+ public void SetEntity(WorldEntity? entity)
+ {
+ _mainSlot.Set(entity);
+ if (entity is null)
+ {
+ // A character-session reset explicitly invalidates the sampled
+ // scenes. Do not let a replacement that is still uploading expose
+ // the previous character through any flight target.
+ _flightTargets.InvalidateCompletedScenes();
+ }
+ }
///
/// Advances the private entity's mesh and texture-composite readiness
@@ -226,51 +233,50 @@ internal sealed class PrivateEntityViewportRenderer :
///
public uint Render(int width, int height)
{
+ if (width <= 0 || height <= 0)
+ return 0u;
+
+ IGpuFrame frame = _frames.CurrentFrame
+ ?? throw new InvalidOperationException(
+ $"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
+ int frameSlot = frame.SlotIndex;
+
// #443: acquiring a synthetic mesh reference only schedules CPU
// preparation/GPU upload; it does not make the mesh drawable. Keep the
- // last completed private scene intact until every drawable mesh in the
- // replacement has crossed that upload barrier. On first open there is
- // no completed scene, so return zero and let the authored panel art
- // show through instead of publishing a freshly-cleared black target.
+ // current flight slot's last completed private scene intact until every
+ // drawable mesh in the replacement has crossed that upload barrier.
+ // On first open there is no completed scene, so return zero and let the
+ // authored panel art show through instead of publishing a cleared target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
- && _hasRenderedScene
- && _slot.IsAssigned
- ? UiTextureTableHandle.FromSlot(_slot)
+ ? _flightTargets.CompletedHandle(frameSlot)
: 0u;
}
WorldEntity? entity = _mainSlot.Entity;
- if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
+ if (entity is null || entity.MeshRefs.Count == 0)
return 0u;
IReadOnlyList drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
- {
- return _hasRenderedScene && _slot.IsAssigned
- ? UiTextureTableHandle.FromSlot(_slot)
- : 0u;
- }
+ return _flightTargets.CompletedHandle(frameSlot);
- EnsureRenderTarget(width, height);
- if (_target is null)
+ PrivateViewportFlightTargets.TargetSlot? targetSlot =
+ _flightTargets.Ensure(frameSlot, width, height);
+ if (targetSlot is null)
return 0u;
_camera.Aspect = width / (float)height;
- IGpuFrame frame = _frames.CurrentFrame
- ?? throw new InvalidOperationException(
- $"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
-
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = _diagnosticName,
Color = new GpuColorAttachment(
- Target: _target,
+ Target: targetSlot.Target,
Load: GpuLoadOp.Clear,
Store: GpuStoreOp.Store,
ClearColor: Vector4.Zero),
@@ -304,6 +310,12 @@ internal sealed class PrivateEntityViewportRenderer :
null),
};
+ // #443: a private pass must not append its transforms into the shared
+ // world transform frame — the default mesh shaders index parallel
+ // per-instance arrays zero-based, so a non-zero arena base zeroes the
+ // doll's per-instance opacity and the target stays blank whenever a
+ // world frame is active. See NextClassicDrawIsPrivatePass.
+ _dispatcher.NextClassicDrawIsPrivatePass = true;
_dispatcher.Draw(
_camera,
entries,
@@ -311,8 +323,8 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
- _hasRenderedScene = true;
- return UiTextureTableHandle.FromSlot(_slot);
+ targetSlot.HasRenderedScene = true;
+ return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
}
///
@@ -363,69 +375,6 @@ internal sealed class PrivateEntityViewportRenderer :
});
}
- private void EnsureRenderTarget(int width, int height)
- {
- if (_target is not null && width == _fbW && height == _fbH)
- return;
- ReleaseRenderTarget();
-
- IGpuRenderTarget target;
- try
- {
- target = _device.CreateRenderTarget(new GpuRenderTargetDescription(
- _diagnosticName,
- width,
- height,
- GpuTextureFormat.Rgba8UnormRenderTarget,
- // Depth24Stencil8, as the hand-rolled renderbuffer was: nothing
- // samples it, and the stencil aspect keeps the attachment shape
- // the depth/stencil renderers already expect.
- GpuTextureFormat.Depth24Stencil8,
- SampleCount: 1));
- }
- catch (Exception failure)
- {
- Console.WriteLine(
- $"[{_diagnosticName}] render target unavailable ({width}x{height}): {failure.Message}");
- return;
- }
-
- try
- {
- // The retained UI blits this attachment as an ordinary table entry.
- // Linear/clamped is the filtering the hand-rolled colour texture set
- // on itself before the §7.1 seam registered it.
- _sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
- _slot = _device.RegisterTexture(target.ColorTexture, _sampler);
- }
- catch
- {
- target.Dispose();
- _sampler = null;
- _slot = GpuTextureSlot.Unassigned;
- throw;
- }
-
- _target = target;
- _fbW = width;
- _fbH = height;
- }
-
- private void ReleaseRenderTarget()
- {
- if (_slot.IsAssigned)
- {
- _device.ReleaseTextureSlot(_slot);
- _slot = GpuTextureSlot.Unassigned;
- }
- _sampler = null;
- _target?.Dispose();
- _target = null;
- _fbW = 0;
- _fbH = 0;
- _hasRenderedScene = false;
- }
-
public void Dispose()
{
List? failures = null;
@@ -447,7 +396,7 @@ internal sealed class PrivateEntityViewportRenderer :
}
try
{
- ReleaseRenderTarget();
+ _flightTargets.Dispose();
}
catch (Exception error)
{
@@ -462,6 +411,163 @@ internal sealed class PrivateEntityViewportRenderer :
}
}
+ ///
+ /// Bounded render-target ownership keyed by .
+ /// A frame slot is reopened only after its previous submission retires, so
+ /// the target selected here can be written and sampled within that frame
+ /// without racing a different in-flight command buffer.
+ ///
+ internal sealed class PrivateViewportFlightTargets : IDisposable
+ {
+ internal sealed class TargetSlot(
+ IGpuRenderTarget target,
+ GpuTextureSlot textureSlot)
+ {
+ internal IGpuRenderTarget Target { get; } = target;
+ internal GpuTextureSlot TextureSlot { get; } = textureSlot;
+ internal bool HasRenderedScene { get; set; }
+ }
+
+ private readonly IGpuDevice _device;
+ private readonly string _diagnosticName;
+ private readonly List _slots = [];
+ private int _width;
+ private int _height;
+ private bool _disposed;
+
+ internal PrivateViewportFlightTargets(
+ IGpuDevice device,
+ string diagnosticName)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
+ ? "creature viewport"
+ : diagnosticName;
+ }
+
+ internal int AllocatedSlotCount =>
+ _slots.Count(static slot => slot is not null);
+
+ internal TargetSlot? Ensure(int frameSlot, int width, int height)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+
+ if (_width != 0 && (_width != width || _height != height))
+ ReleaseAll();
+
+ while (_slots.Count <= frameSlot)
+ _slots.Add(null);
+ if (_slots[frameSlot] is { } existing)
+ return existing;
+
+ IGpuRenderTarget target;
+ try
+ {
+ target = _device.CreateRenderTarget(
+ new GpuRenderTargetDescription(
+ $"{_diagnosticName}-flight-{frameSlot}",
+ width,
+ height,
+ GpuTextureFormat.Rgba8UnormRenderTarget,
+ // Depth24Stencil8, as the original private viewport
+ // renderbuffer was. Nothing samples this attachment.
+ GpuTextureFormat.Depth24Stencil8,
+ SampleCount: 1));
+ }
+ catch (Exception failure)
+ {
+ Console.WriteLine(
+ $"[{_diagnosticName}] render target unavailable "
+ + $"({width}x{height}, flight {frameSlot}): {failure.Message}");
+ return null;
+ }
+
+ try
+ {
+ // The device de-duplicates immutable samplers. Retained UI
+ // blits this target through its ordinary texture-table entry.
+ IGpuSampler sampler = _device.CreateSampler(
+ GpuSamplerDescription.WorldClamp);
+ GpuTextureSlot textureSlot = _device.RegisterTexture(
+ target.ColorTexture,
+ sampler);
+ var created = new TargetSlot(target, textureSlot);
+ _slots[frameSlot] = created;
+ _width = width;
+ _height = height;
+ return created;
+ }
+ catch
+ {
+ target.Dispose();
+ throw;
+ }
+ }
+
+ internal uint CompletedHandle(int frameSlot)
+ {
+ if ((uint)frameSlot >= (uint)_slots.Count
+ || _slots[frameSlot] is not { HasRenderedScene: true } slot)
+ {
+ return 0u;
+ }
+
+ return UiTextureTableHandle.FromSlot(slot.TextureSlot);
+ }
+
+ internal void InvalidateCompletedScenes()
+ {
+ for (int i = 0; i < _slots.Count; i++)
+ {
+ if (_slots[i] is { } slot)
+ slot.HasRenderedScene = false;
+ }
+ }
+
+ private void ReleaseAll()
+ {
+ List? failures = null;
+ for (int i = 0; i < _slots.Count; i++)
+ {
+ TargetSlot? slot = _slots[i];
+ if (slot is null)
+ continue;
+ try
+ {
+ _device.ReleaseTextureSlot(slot.TextureSlot);
+ }
+ catch (Exception error)
+ {
+ (failures ??= []).Add(error);
+ }
+ try
+ {
+ slot.Target.Dispose();
+ }
+ catch (Exception error)
+ {
+ (failures ??= []).Add(error);
+ }
+ }
+ _slots.Clear();
+ _width = 0;
+ _height = 0;
+ if (failures is { Count: > 0 })
+ throw new AggregateException(failures);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ ReleaseAll();
+ }
+ }
+
///
/// One private entity's mesh-reference/texture-owner lifetime, independent
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
index 1a3d5db8..fa59bf91 100644
--- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
@@ -16,6 +16,19 @@ internal interface IPrivateEntityViewportFrame
void Render();
}
+///
+/// Optional pre-world resource preparation for a private viewport whose
+/// visibility must not compete with world composite uploads. The composite
+/// upload budget opens with the frame's resource phase; a dense world can
+/// consume all of it every frame, so a viewport that only prepares during
+/// late presentation can starve indefinitely (#443's paperdoll: visible in
+/// portal space — where the world is quiesced — and nowhere busy).
+///
+internal interface IPrivateEntityViewportResourcePreparation
+{
+ void PrepareResources();
+}
+
internal interface IRetainedGameplayUiFrame
{
void Render(double deltaSeconds, int width, int height);
diff --git a/src/AcDream.App/Rendering/RenderFramePreparationController.cs b/src/AcDream.App/Rendering/RenderFramePreparationController.cs
index 82306d67..820ddaa1 100644
--- a/src/AcDream.App/Rendering/RenderFramePreparationController.cs
+++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs
@@ -29,20 +29,29 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
private readonly IRenderFrameResourcePhase _resources;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IRenderWeatherFramePhase _weather;
+ private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
public RenderFramePreparationController(
IRenderFrameResourcePhase resources,
IDevToolsFrameLifecycle? devTools,
- IRenderWeatherFramePhase weather)
+ IRenderWeatherFramePhase weather,
+ IPrivateEntityViewportResourcePreparation? privateViewports = null)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_devTools = devTools;
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
+ _privateViewports = privateViewports;
}
public void Prepare(RenderFrameInput input)
{
_resources.Prepare(input);
+ // The composite upload budget opens with the resource phase. Give the
+ // paperdoll's private object its prewarm slot before the world can
+ // consume the complete per-frame budget (#443 — the doll rendered
+ // only while portal space quiesced the world); presentation samples
+ // the result later, after the world pass has closed.
+ _privateViewports?.PrepareResources();
_devTools?.BeginFrame((float)input.DeltaSeconds);
_weather.Tick(input.DeltaSeconds);
}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index 30b22946..2d9fac4b 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -924,6 +924,24 @@ public sealed unsafe partial class WbDrawDispatcher
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
+ ///
+ /// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
+ /// chargen preview) and must take the plain ring transform path with
+ /// firstInstance = 0, never an append into the shared world
+ /// transform frame. The default mesh shaders index every parallel
+ /// per-instance array (clip slots, light sets, indoor, opacity, selection,
+ /// detail category) zero-based — only the packed world submission's
+ /// shader convention subtracts a shared-arena prefix — so an
+ /// arena-appended classic draw with a non-zero base reads zeroed
+ /// per-instance data (opacity 0 ⇒ an invisible doll whenever a world
+ /// frame is active; portal space worked only because the arena was
+ /// inactive there). The private pass owns its own camera, lighting, and
+ /// target; per the self-contained-render-state rule it must not depend on
+ /// the world frame's pose address space at all. Consumed and cleared by
+ /// the next .
+ ///
+ internal bool NextClassicDrawIsPrivatePass;
+
private RhiSection WriteWorldTransformSection(
IGpuFrame frame,
ReadOnlySpan matrixFloats,
@@ -939,7 +957,9 @@ public sealed unsafe partial class WbDrawDispatcher
ObserveOrdinaryTransformDemand(
frame.Serial,
checked((uint)(matrixFloats.Length / 16)));
- if (!_worldTransformFrames.IsActive)
+ bool privatePass = NextClassicDrawIsPrivatePass;
+ NextClassicDrawIsPrivatePass = false;
+ if (privatePass || !_worldTransformFrames.IsActive)
{
firstInstance = 0;
return WriteRingSection(frame, matrixFloats);
diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
index 41f47c47..5b4ee653 100644
--- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
@@ -8,6 +8,14 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
+ /// One frame in production order: the pre-world resource phase
+ /// (build/redress + prewarm) then the late presentation phase.
+ private static void Frame(PaperdollFramePresenter presenter)
+ {
+ presenter.PrepareResources();
+ presenter.Render();
+ }
+
[Fact]
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
{
@@ -16,7 +24,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@@ -34,7 +42,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@@ -54,20 +62,25 @@ public sealed class PaperdollFramePresenterTests
};
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
- presenter.Render();
+ Frame(presenter);
+ Frame(presenter);
presenter.MarkDirty();
- presenter.Render();
+ Frame(presenter);
Assert.Equal(2, factory.BuildCount);
- Assert.Single(renderer.Dolls);
+ Assert.Equal(2, renderer.Dolls.Count);
Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
[Fact]
- public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner()
+ public void PortalRefresh_RedressesEvenAnEquivalentAppearance()
{
+ // #443: the private clone belongs to the current presentation
+ // generation. Retaining an old clone because its pixels compare equal
+ // can pin retired mesh/composite readiness that never completes
+ // again; the renderer's two-phase promote keeps the last completed
+ // image visible while the fresh clone becomes drawable.
WorldEntity first = CreateDoll();
WorldEntity repeated = CreateDoll();
var renderer = new RecordingRenderer();
@@ -75,13 +88,13 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
factory.Doll = repeated;
presenter.MarkDirty();
- presenter.Render();
+ Frame(presenter);
Assert.Equal(2, factory.BuildCount);
- Assert.Equal([first], renderer.Dolls);
+ Assert.Equal([first, repeated], renderer.Dolls);
Assert.Equal(2, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
@@ -96,14 +109,34 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
factory.Doll = changed;
presenter.MarkDirty();
- presenter.Render();
+ Frame(presenter);
Assert.Equal([first, changed], renderer.Dolls);
}
+ [Fact]
+ public void TransientZeroRender_NeverErasesThePublishedTexture()
+ {
+ // #443: zero is a not-ready result (mesh/composite upload latency),
+ // not a request to blank a completed paperdoll.
+ var renderer = new RecordingRenderer { TextureHandle = 91u };
+ var view = new RecordingView();
+ var factory = new RecordingFactory { Doll = CreateDoll() };
+ var presenter = new PaperdollFramePresenter(renderer, view, factory);
+
+ Frame(presenter);
+ renderer.TextureHandle = 0u;
+ Frame(presenter);
+ renderer.TextureHandle = 91u;
+ Frame(presenter);
+
+ Assert.Equal([91u, 91u], view.TextureHandles);
+ Assert.Equal(0, view.ClearCount);
+ }
+
private static WorldEntity CreateDoll(float scale = 1f) => new()
{
Id = 42u,
@@ -123,11 +156,11 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
factory.CanBuild = false;
presenter.MarkDirty();
- presenter.Render();
- presenter.Render();
+ Frame(presenter);
+ Frame(presenter);
Assert.True(presenter.IsDirty);
Assert.Equal(3, factory.BuildCount);
@@ -146,14 +179,17 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
- presenter.Render();
+ Frame(presenter);
presenter.ResetSession();
factory.Doll = secondDoll;
- presenter.Render();
+ Frame(presenter);
Assert.False(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount);
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
+ // The session boundary is the ONE explicit viewport clear (#443);
+ // an old character must not linger while the next one uploads.
+ Assert.Equal(1, view.ClearCount);
}
[Fact]
@@ -252,7 +288,7 @@ public sealed class PaperdollFramePresenterTests
private sealed class RecordingRenderer : IPaperdollDollRenderer
{
- public uint TextureHandle { get; init; }
+ public uint TextureHandle { get; set; }
public List Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
@@ -285,6 +321,10 @@ public sealed class PaperdollFramePresenterTests
public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle);
+
+ public int ClearCount { get; private set; }
+
+ public void ClearTextureHandle() => ClearCount++;
}
private sealed class RecordingFactory : IPaperdollDollFactory
diff --git a/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs
new file mode 100644
index 00000000..ac2d7bf5
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs
@@ -0,0 +1,65 @@
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Tests.Rendering.Gpu;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class PrivateViewportFlightTargetsTests
+{
+ [Fact]
+ public void FlightSlotsOwnDistinctTargetsAndPublishOnlyCompletedScenes()
+ {
+ using var device = new RecordingGpuDevice();
+ using var targets =
+ new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
+ device,
+ "paperdoll");
+
+ var first = Assert.IsType<
+ PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
+ targets.Ensure(0, 120, 180));
+ var second = Assert.IsType<
+ PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
+ targets.Ensure(1, 120, 180));
+
+ Assert.NotSame(first.Target, second.Target);
+ Assert.NotEqual(first.TextureSlot, second.TextureSlot);
+ Assert.Equal(2, targets.AllocatedSlotCount);
+ Assert.Equal(0u, targets.CompletedHandle(0));
+ Assert.Equal(0u, targets.CompletedHandle(1));
+
+ first.HasRenderedScene = true;
+
+ Assert.NotEqual(0u, targets.CompletedHandle(0));
+ Assert.Equal(0u, targets.CompletedHandle(1));
+ Assert.Same(first, targets.Ensure(0, 120, 180));
+
+ targets.InvalidateCompletedScenes();
+
+ Assert.Equal(0u, targets.CompletedHandle(0));
+ Assert.Equal(2, targets.AllocatedSlotCount);
+ }
+
+ [Fact]
+ public void ResizeRetiresEveryFlightTargetBeforeCreatingTheNewExtent()
+ {
+ using var device = new RecordingGpuDevice();
+ using var targets =
+ new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
+ device,
+ "paperdoll");
+ var first = targets.Ensure(0, 120, 180)!;
+ var second = targets.Ensure(1, 120, 180)!;
+ var firstTarget = Assert.IsType(first.Target);
+ var secondTarget = Assert.IsType(second.Target);
+
+ var resized = targets.Ensure(1, 160, 220)!;
+
+ Assert.True(firstTarget.IsDisposed);
+ Assert.True(secondTarget.IsDisposed);
+ Assert.Equal(1, targets.AllocatedSlotCount);
+ Assert.Equal(160, resized.Target.Description.Width);
+ Assert.Equal(220, resized.Target.Description.Height);
+ Assert.Equal(3, device.CreatedRenderTargets.Count);
+ }
+}