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:
parent
fc30285fd7
commit
cd1cdee0e5
9 changed files with 439 additions and 209 deletions
|
|
@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
|
|||
/// <summary>
|
||||
/// Shared render-to-texture implementation for the private 3-D creature
|
||||
/// viewports used by paperdoll and examination UI. Each instance owns one
|
||||
/// <see cref="IGpuRenderTarget"/>, one synthetic render identity, and one
|
||||
/// balanced texture-owner lease.
|
||||
/// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
|
||||
/// synthetic render identity, and one balanced texture-owner lease.
|
||||
///
|
||||
/// <para><b>Campaign V slice V6k (V4g's first half).</b> 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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// feature does not exist for them, not just "unused".</summary>
|
||||
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 :
|
|||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the private entity's mesh and texture-composite readiness
|
||||
|
|
@ -226,51 +233,50 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// </summary>
|
||||
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<WorldEntity> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<Exception>? 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 :
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded render-target ownership keyed by <see cref="IGpuFrame.SlotIndex"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<TargetSlot?> _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<Exception>? 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue