fix(render) #443: private viewports take the ring transform path

The paperdoll was visible only in portal space. Root cause: the classic
WbDrawDispatcher.Draw path appended its transforms into the SHARED world
transform frame (WorldTransformFrameArena.Append) 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. With a world frame active
the doll drew all instances at per-instance opacity 0 into a cleared
target: counted draws, blank pixels, deterministic. Portal space worked
because no world transform frame is active there, so the same code took
the ring path with base 0. The private viewports are the only production
consumers of the classic path, hiding the defect everywhere else.

Fix: WbDrawDispatcher.NextClassicDrawIsPrivatePass - the private
viewport renderer marks its draw and WriteWorldTransformSection routes
private passes onto the plain ring path unconditionally (self-contained
render state: the private pass owns its own camera, lighting, and
target, and must not depend on the world frame's pose address space).

Also landed, each independently justified:
- Per-GPU-flight-slot private targets (PrivateViewportFlightTargets),
  restoring the pre-f6fe0f2a design: that revert's claim that frame
  submission order protects the single target's write->sample transition
  is not guaranteed across Vulkan command buffers. Per-slot completed
  scenes fix the cleared-sibling-after-reveal wart the old attempt had.
- Paperdoll resource preparation moved to the frame resource phase
  (IPrivateEntityViewportResourcePreparation) before world draws consume
  the bounded composite-upload budget.
- The presenter redresses on every dirty edge (an appearance-equal clone
  can pin retired readiness across generations; the renderer's two-phase
  promote keeps the last completed image visible during replacement),
  publishes only non-zero handles, and clears the viewport exactly once
  at the explicit character-session boundary.

Verified live on the clean build: doll visible in the NORMAL world,
visible through portal space, and still visible after arrival - the
exact reported repro cycle. 26 paperdoll/private-viewport/preparation
tests plus 60 renderer-suite tests pass; owner visual gate pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-29 16:51:32 +02:00
parent fc30285fd7
commit cd1cdee0e5
9 changed files with 439 additions and 209 deletions

View file

@ -8,6 +8,14 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
/// <summary>One frame in production order: the pre-world resource phase
/// (build/redress + prewarm) then the late presentation phase.</summary>
private static void Frame(PaperdollFramePresenter presenter)
{
presenter.PrepareResources();
presenter.Render();
}
[Fact]
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<WorldEntity?> 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

View file

@ -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<RecordingGpuRenderTarget>(first.Target);
var secondTarget = Assert.IsType<RecordingGpuRenderTarget>(second.Target);
var resized = targets.Ensure(1, 160, 220)!;
Assert.True(firstTarget.IsDisposed);
Assert.True(secondTarget.IsDisposed);
Assert.Equal(1, targets.AllocatedSlotCount);
Assert.Equal(160, resized.Target.Description.Width);
Assert.Equal(220, resized.Target.Description.Height);
Assert.Equal(3, device.CreatedRenderTargets.Count);
}
}