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>
345 lines
12 KiB
C#
345 lines
12 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.World;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
internal interface IPaperdollDollRenderer
|
|
{
|
|
void SetDoll(WorldEntity? doll);
|
|
|
|
void Prepare();
|
|
|
|
uint Render(int width, int height);
|
|
}
|
|
|
|
internal interface IPaperdollFrameView
|
|
{
|
|
bool TryGetVisibleSize(out int width, out int height);
|
|
|
|
void SetTextureHandle(uint textureHandle);
|
|
|
|
void ClearTextureHandle();
|
|
}
|
|
|
|
internal interface IPaperdollInventoryVisibility
|
|
{
|
|
bool IsVisible { get; }
|
|
}
|
|
|
|
internal interface IPaperdollDollFactory
|
|
{
|
|
bool TryBuild(out WorldEntity? doll);
|
|
}
|
|
|
|
internal interface IPaperdollEntityLookup
|
|
{
|
|
bool TryGet(uint serverGuid, out WorldEntity player);
|
|
}
|
|
|
|
internal interface IPaperdollPoseApplicator
|
|
{
|
|
void Apply(WorldEntity doll, uint setupId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns paperdoll dirty/rebuild state and the private render-target
|
|
/// presentation edge. The renderer remains a borrowed resource disposed by
|
|
/// the existing window shutdown transaction.
|
|
/// </summary>
|
|
internal sealed class PaperdollFramePresenter :
|
|
IPrivateEntityViewportFrame,
|
|
IPrivateEntityViewportResourcePreparation
|
|
{
|
|
private readonly IPaperdollDollRenderer _renderer;
|
|
private readonly IPaperdollFrameView _view;
|
|
private readonly IPaperdollDollFactory _factory;
|
|
private WorldEntity? _doll;
|
|
private bool _dirty = true;
|
|
|
|
public PaperdollFramePresenter(
|
|
IPaperdollDollRenderer renderer,
|
|
IPaperdollFrameView view,
|
|
IPaperdollDollFactory factory)
|
|
{
|
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
|
_view = view ?? throw new ArgumentNullException(nameof(view));
|
|
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
|
}
|
|
|
|
internal bool IsDirty => _dirty;
|
|
|
|
public void MarkDirty() => _dirty = true;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public void PrepareResources()
|
|
{
|
|
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;
|
|
_dirty = false;
|
|
}
|
|
else
|
|
{
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
_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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the private object only at the owning character-session
|
|
/// boundary, matching gmPaperDollUI's private-object lifetime.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
_renderer.SetDoll(null);
|
|
_view.ClearTextureHandle();
|
|
_doll = null;
|
|
_dirty = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
|
internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
|
|
{
|
|
private readonly UiViewport _viewport;
|
|
private readonly IPaperdollInventoryVisibility _inventory;
|
|
|
|
public RetailPaperdollFrameView(
|
|
UiViewport viewport,
|
|
IPaperdollInventoryVisibility inventory)
|
|
{
|
|
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
|
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
|
|
}
|
|
|
|
public bool TryGetVisibleSize(out int width, out int height)
|
|
{
|
|
width = 0;
|
|
height = 0;
|
|
if (!_viewport.Visible || !_inventory.IsVisible)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
width = (int)_viewport.Width;
|
|
height = (int)_viewport.Height;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6k: the renderer already hands out a
|
|
/// <see cref="UiTextureTableHandle"/>, so this decodes rather than registers.
|
|
/// The §7.1 external-texture seam it used to call is deleted with V4g.
|
|
/// </summary>
|
|
public void SetTextureHandle(uint textureHandle) =>
|
|
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
|
|
|
public void ClearTextureHandle() =>
|
|
_viewport.TextureSlot = GpuTextureSlot.Unassigned;
|
|
}
|
|
|
|
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
|
internal sealed class PaperdollInventoryVisibility : IPaperdollInventoryVisibility
|
|
{
|
|
private readonly UiElement _inventoryFrame;
|
|
|
|
public PaperdollInventoryVisibility(UiElement inventoryFrame)
|
|
{
|
|
_inventoryFrame = inventoryFrame
|
|
?? throw new ArgumentNullException(nameof(inventoryFrame));
|
|
}
|
|
|
|
public bool IsVisible => _inventoryFrame.Visible;
|
|
}
|
|
|
|
/// <summary>Canonical live-entity lookup used by the paperdoll factory.</summary>
|
|
internal sealed class LivePaperdollEntityLookup : IPaperdollEntityLookup
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
|
|
public LivePaperdollEntityLookup(LiveEntityRuntime liveEntities)
|
|
{
|
|
_liveEntities = liveEntities
|
|
?? throw new ArgumentNullException(nameof(liveEntities));
|
|
}
|
|
|
|
public bool TryGet(uint serverGuid, out WorldEntity player) =>
|
|
_liveEntities.TryGetWorldEntity(serverGuid, out player);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the static retail paperdoll clone from the canonical live player
|
|
/// projection and applies the DAT-defined held pose.
|
|
/// </summary>
|
|
internal sealed class RetailPaperdollDollFactory : IPaperdollDollFactory
|
|
{
|
|
private readonly IPaperdollEntityLookup _entities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
private readonly IPaperdollPoseApplicator _pose;
|
|
|
|
public RetailPaperdollDollFactory(
|
|
IPaperdollEntityLookup entities,
|
|
ILocalPlayerIdentitySource identity,
|
|
IPaperdollPoseApplicator pose)
|
|
{
|
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
_pose = pose ?? throw new ArgumentNullException(nameof(pose));
|
|
}
|
|
|
|
public bool TryBuild(out WorldEntity? doll)
|
|
{
|
|
doll = null;
|
|
if (!_entities.TryGet(_identity.ServerGuid, out WorldEntity player)
|
|
|| player.MeshRefs.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
uint? basePalette = null;
|
|
List<(uint, byte, byte)>? subPalettes = null;
|
|
if (player.PaletteOverride is { } palette)
|
|
{
|
|
basePalette = palette.BasePaletteId;
|
|
subPalettes = new List<(uint, byte, byte)>();
|
|
foreach (var range in palette.SubPalettes)
|
|
{
|
|
subPalettes.Add((
|
|
range.SubPaletteId,
|
|
range.Offset,
|
|
range.Length));
|
|
}
|
|
}
|
|
|
|
List<(byte, uint)>? partOverrides = null;
|
|
if (player.PartOverrides.Count > 0)
|
|
{
|
|
partOverrides = new List<(byte, uint)>(player.PartOverrides.Count);
|
|
foreach (var part in player.PartOverrides)
|
|
partOverrides.Add((part.PartIndex, part.GfxObjId));
|
|
}
|
|
|
|
doll = DollEntityBuilder.Build(
|
|
player.SourceGfxObjOrSetupId,
|
|
new List<MeshRef>(player.MeshRefs),
|
|
basePalette,
|
|
subPalettes,
|
|
partOverrides);
|
|
_pose.Apply(doll, player.SourceGfxObjOrSetupId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Applies retail's DAT-defined settled paperdoll stance.</summary>
|
|
internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
|
{
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly IAnimationLoader _animations;
|
|
private readonly object _datLock;
|
|
|
|
public RetailPaperdollPoseApplicator(
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animations,
|
|
object datLock)
|
|
{
|
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
|
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c> —
|
|
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
|
|
/// paperdoll's own fixed enum key.
|
|
/// </summary>
|
|
private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u);
|
|
|
|
public void Apply(WorldEntity doll, uint setupId)
|
|
{
|
|
DatReaderWriter.DBObjs.Animation? animation;
|
|
DatReaderWriter.DBObjs.Setup? setup;
|
|
lock (_datLock)
|
|
{
|
|
uint poseDid = ResolvePoseDid();
|
|
if ((poseDid >> 24) != 0x03u)
|
|
return;
|
|
|
|
animation = _animations.LoadAnimation(poseDid);
|
|
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
|
}
|
|
if (animation is null || setup is null || animation.PartFrames.Count == 0)
|
|
return;
|
|
|
|
// RedressCreature @ 0x004A3C22 installs the pose with zero frame rate
|
|
// and holds the settled final frame.
|
|
var frame = animation.PartFrames[^1];
|
|
var reposed = new List<MeshRef>(doll.MeshRefs.Count);
|
|
for (int index = 0; index < doll.MeshRefs.Count; index++)
|
|
{
|
|
Vector3 scale = index < setup.DefaultScale.Count
|
|
? setup.DefaultScale[index]
|
|
: Vector3.One;
|
|
Vector3 origin = Vector3.Zero;
|
|
Quaternion orientation = Quaternion.Identity;
|
|
if (index < frame.Frames.Count)
|
|
{
|
|
origin = frame.Frames[index].Origin;
|
|
orientation = frame.Frames[index].Orientation;
|
|
}
|
|
|
|
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation);
|
|
MeshRef source = doll.MeshRefs[index];
|
|
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
|
{
|
|
SurfaceOverrides = source.SurfaceOverrides,
|
|
});
|
|
}
|
|
|
|
doll.MeshRefs = reposed;
|
|
}
|
|
}
|