The remaining code-bearing findings from the round review, F4-F16 minus the doc-only items (batched separately): - F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly the 4 Town buttons, confined to chargen; conflicting custom-selection- pair + standard Normal/Highlight media — zero found, no gate tightening needed; per-state label-color map — 209 matches beyond chargen, confirming AP-222's mechanism has always been broadly active since it shipped generically in DatWidgetFactory). - F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now honors a child's own AuthoredInvisible flag (a narrow honor scoped to exactly that carve-out, not the general #408 client-wide one) — the chat transcript's new-text indicator (0x1000048C) was building as a visible phantom element retail never shows; verified both directions against the gold-frame pieces, which do not author Invisible. - F7: BoundedProcessOutputCapture.AppendLine combines the line text and its trailing newline into one buffer and one file open/write/close instead of two. - F9: corrected a stale comment in RuntimeSettingsTargets — #407 split DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the fullscreen validator's own narrower list is now DELIBERATELY different from the Config dropdown's fuller offering, not the "must match" bug the comment described. - F10: documented (not changed) why the LabelBox path's default 3px inset and the face-relative +4px gap in DatWidgetFactory.BuildButton are deliberately different numbers — neither carries a retail citation, and moving either to match the other would be an unfounded guess on a button that currently works correctly. - F11: Heritage/Profession/Summary/Town description pages now compose DatRichText.Compose's result ONCE inside their already revision-gated Refresh, caching the built line list instead of re-wrapping on every draw call. - F14: documented (not changed) why PrivateEntityViewportRenderer's _animatedIds set carrying a reserved-but-never-drawn backdrop id is harmless — BuildDrawEntities already excludes a null/empty backdrop from the actual draw list, so the id is never looked up. - F16: the Summary preview now uses its own render-id pair (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/ 0xDA11D036) instead of sharing the Appearance page's (0xDA11D032/0xDA11D034) — confirmed by tracing FixedEntityTextureOwnerLease through TextureCache to CompositeTextureArrayCache's shared owner tracker that both pages' previews share ONE process-wide TextureCache, so sharing render ids was a real cross-page texture-release collision (either page's own re-dress or disposal could release the OTHER page's still-active textures), not a theoretical one. F3's own register bookkeeping (AP-229 addendum) and F12's register/AD header-count corrections land in the docs-only commit alongside F15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
416 lines
19 KiB
C#
416 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.Content;
|
|
using AcDream.Core.CharGen;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over
|
|
/// the CC6a/CC6b-PRE preview foundation. <see cref="CharacterCreationAppearancePage"/>
|
|
/// is constructed BEFORE the graphical presentation pipeline exists (early
|
|
/// retained-UI composition — see <see cref="AcDream.App.UI.Layout.CharacterCreationRuntimeBindings"/>'s
|
|
/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against
|
|
/// this interface's default no-op-until-assigned shape rather than a
|
|
/// concrete renderer reference. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
|
|
/// constructs the real <see cref="ChargenPreviewController"/> once the
|
|
/// graphics backend exists and assigns it onto the page — mirroring exactly
|
|
/// how the paperdoll's <c>viewport.Renderer = paperdollLease.Resource</c>
|
|
/// late-assignment already works for a DIFFERENT screen's viewport.
|
|
/// </summary>
|
|
internal interface IChargenPreviewControl
|
|
{
|
|
/// <summary>
|
|
/// Recomposes and rebuilds the preview entity when the heritage/gender/
|
|
/// appearance selection actually changed since the last call (a cheap
|
|
/// no-op otherwise). Returns false when the selection cannot be
|
|
/// resolved/built (heritage or gender not yet chosen, or a missing dat
|
|
/// resource) — the caller (the page) simply leaves the previous frame on
|
|
/// screen, matching <c>PaperdollFramePresenter</c>'s own
|
|
/// "keep the successful doll, retry next visible frame" precedent.
|
|
/// </summary>
|
|
bool Rebuild(
|
|
ChargenOptions options,
|
|
uint heritageId,
|
|
int genderKey,
|
|
ChargenAppearanceSelection selection);
|
|
|
|
void ZoomIn();
|
|
void ZoomOut();
|
|
void RotateClockwise();
|
|
void RotateCounterClockwise();
|
|
}
|
|
|
|
/// <summary>Gates the preview's per-frame work on whether the Appearance
|
|
/// PAGE (not just the leaf viewport widget) is the currently visible page —
|
|
/// mirrors <c>IPaperdollInventoryVisibility</c>'s outer-frame gate.</summary>
|
|
internal interface IChargenPreviewPageVisibility
|
|
{
|
|
bool IsVisible { get; }
|
|
}
|
|
|
|
/// <summary>CC6b-MOUNT: narrow seam mirroring <c>IPaperdollFrameView</c> so
|
|
/// <see cref="ChargenPreviewController"/> can be exercised with a fake view
|
|
/// in tests.</summary>
|
|
internal interface IChargenPreviewFrameView
|
|
{
|
|
bool TryGetVisibleSize(out int width, out int height);
|
|
|
|
void SetTextureHandle(uint textureHandle);
|
|
}
|
|
|
|
/// <summary>Thin adapter over <c>RetailUiRuntime.IsChargenPreviewPageVisible</c>
|
|
/// — narrowed to <see cref="IChargenPreviewPageVisibility"/> so this
|
|
/// Rendering-namespace class doesn't need a direct dependency on the
|
|
/// UI/Layout-namespace <c>RetailUiRuntime</c> type beyond the one property
|
|
/// read.</summary>
|
|
internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility
|
|
{
|
|
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
|
|
|
|
public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
|
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
|
|
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
|
|
}
|
|
|
|
/// <summary>Campaign CC slice CC5: the Summary page's own visibility gate —
|
|
/// same shape as <see cref="RetailChargenPreviewPageVisibility"/>, reading
|
|
/// <c>RetailUiRuntime.IsSummaryPreviewPageVisible</c> instead.</summary>
|
|
internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility
|
|
{
|
|
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
|
|
|
|
public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
|
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
|
|
public bool IsVisible => _runtime.IsSummaryPreviewPageVisible;
|
|
}
|
|
|
|
/// <summary>Retained-UI visibility + texture publication, mirroring
|
|
/// <c>RetailPaperdollFrameView</c>.</summary>
|
|
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
|
|
{
|
|
private readonly UiViewport _viewport;
|
|
private readonly IChargenPreviewPageVisibility _page;
|
|
|
|
public RetailChargenPreviewFrameView(
|
|
UiViewport viewport,
|
|
IChargenPreviewPageVisibility page)
|
|
{
|
|
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
|
_page = page ?? throw new ArgumentNullException(nameof(page));
|
|
}
|
|
|
|
public bool TryGetVisibleSize(out int width, out int height)
|
|
{
|
|
width = 0;
|
|
height = 0;
|
|
if (!_viewport.Visible || !_page.IsVisible)
|
|
return false;
|
|
|
|
width = (int)_viewport.Width;
|
|
height = (int)_viewport.Height;
|
|
return true;
|
|
}
|
|
|
|
public void SetTextureHandle(uint textureHandle) =>
|
|
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The real, dat-touching implementation of <see cref="IChargenPreviewControl"/>
|
|
/// plus the per-frame <see cref="IPrivateEntityViewportFrame"/> owner —
|
|
/// constructed once in <see cref="AcDream.App.Composition.LivePresentationComposition"/>
|
|
/// (same composition scope <c>RetailPaperdollPoseApplicator</c> is built in,
|
|
/// which has the real <c>content.Dats</c>/<c>content.AnimationLoader</c>/
|
|
/// <c>d.DatLock</c>) and assigned onto the already-mounted Appearance page.
|
|
///
|
|
/// <para>
|
|
/// <b>Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap):</b>
|
|
/// <see cref="ChargenPreviewRenderer"/> only ever built its OWN private
|
|
/// <see cref="ChargenPreviewCamera"/> with no injection seam, but
|
|
/// <see cref="ChargenPreviewZoomController"/> needs a SETTABLE camera to
|
|
/// tween. This class owns the ONE <see cref="ChargenPreviewCamera"/>
|
|
/// instance and hands it to the renderer via the new
|
|
/// <see cref="ChargenPreviewViewportCamera(ChargenPreviewCamera)"/> overload,
|
|
/// so both the renderer's draw and the zoom controller's tween read/write
|
|
/// the exact same eye position.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Rebuild vs per-frame ownership split, decomp-cited (retail
|
|
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>):</b> the camera SNAPS to
|
|
/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER
|
|
/// change (the two confirmed direct call sites of the outer <c>Update</c> —
|
|
/// <c>InitializePage</c> and the two gender-button handlers,
|
|
/// <c>ListenToElementMessage</c> cases <c>0x9d</c>/<c>0x9e</c>) — spin/color/
|
|
/// shade changes call the narrower <c>SetSelection</c>/<c>SetColor</c>/
|
|
/// <c>SetShade</c> instead, none of which touch <c>m_vectCurPosition</c>.
|
|
/// <see cref="Rebuild"/> reproduces that split: it always recomposes the
|
|
/// ObjDesc/mesh (every appearance field feeds <c>gmCG3DView::Update</c>'s
|
|
/// rebuild eventually), but only resets the camera when heritage or gender
|
|
/// actually changed. <c>m_fCurHeading</c> (this class's
|
|
/// <see cref="ChargenPreviewRotationController"/>) and <c>m_bZoomedIn</c>
|
|
/// (read through <see cref="ChargenPreviewAnimator.IsZoomedIn"/>) both live
|
|
/// on the PAGE in retail and are NEVER reset by <c>Update</c> — so a fresh
|
|
/// <see cref="ChargenPreviewAnimator"/> (unavoidable: it owns the resolved
|
|
/// drawable-part list, which changes with the mesh) is immediately restored
|
|
/// to the PREVIOUS zoom state, and the current accumulated heading is passed
|
|
/// into the rebuild rather than resetting to the retail default.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class ChargenPreviewController :
|
|
IChargenPreviewControl,
|
|
IPrivateEntityViewportFrame,
|
|
IDisposable
|
|
{
|
|
private readonly IChargenPreviewRenderer _renderer;
|
|
private readonly IChargenPreviewFrameView _view;
|
|
private readonly ChargenPreviewCamera _camera;
|
|
private readonly ChargenPreviewRotationController _rotation;
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly IAnimationLoader _animations;
|
|
private readonly IChargenPalSetSource _palSets;
|
|
private readonly IChargenClothingTableSource _clothingTables;
|
|
private readonly object _datLock;
|
|
private readonly bool _useZoomedOutEye;
|
|
private readonly uint _renderId;
|
|
private readonly uint _backdropRenderId;
|
|
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
|
|
|
private ChargenPreviewAnimator? _animator;
|
|
private ChargenPreviewZoomController? _zoom;
|
|
private double _lastElapsedSeconds;
|
|
private bool _hasComposed;
|
|
private uint _lastHeritageId;
|
|
private int _lastGenderKey = -1;
|
|
private ChargenAppearanceSelection _lastSelection;
|
|
private bool _disposed;
|
|
|
|
/// <param name="camera">The SAME instance passed to the
|
|
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
|
|
/// parameter — see this class's own doc comment on why the renderer and
|
|
/// the zoom controller must share one mutable camera.</param>
|
|
/// <param name="useZoomedOutEye">Review fix round F5 (2026-08-16):
|
|
/// <see langword="false"/> (the default) reproduces the Appearance
|
|
/// page's own zoomed-IN default eye
|
|
/// (<c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>,
|
|
/// <see cref="ChargenPreviewCamera.ResolveDefaultEye"/>).
|
|
/// <see langword="true"/> reproduces the Summary page's own eye
|
|
/// (<c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>, byte-decoded
|
|
/// eye literal <c>(0, -2.5, 0.95)</c> at <c>~0x0047bd14-0x0047bd44</c> —
|
|
/// exactly <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/>'s
|
|
/// default-heritage value, NOT the zoomed-in one this controller used
|
|
/// before the fix). CC5 re-review residual round, nit 1 (2026-08-16):
|
|
/// <c>InitializePage</c> alone only justifies the ONE-TIME seed below —
|
|
/// the STRONGER citation for why <see cref="Rebuild"/> re-derives this
|
|
/// same eye PER HERITAGE on every heritage/gender change (not just
|
|
/// once) is <c>gmCGSummaryPage::Update @ 0x0047baa0</c>, which re-sets
|
|
/// the camera on every update using the identical per-heritage mapping
|
|
/// <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> already
|
|
/// implements (<c>0xc</c> Olthoi → <c>(0, -3.8, 1.15)</c>, <c>0xd</c>
|
|
/// OlthoiAcid → <c>(0, -5.7, 1.65)</c>, else → <c>(0, -2.5, 0.95)</c>) —
|
|
/// confirming the per-heritage re-derive below is retail-correct, not
|
|
/// an acdream-only elaboration on a one-shot init value. Summary has no
|
|
/// zoom buttons at all (retail's own viewport there is fixed-framing),
|
|
/// so this is a permanent camera profile for the controller's whole
|
|
/// lifetime, not a toggle.</param>
|
|
public ChargenPreviewController(
|
|
IChargenPreviewRenderer renderer,
|
|
ChargenPreviewCamera camera,
|
|
IChargenPreviewFrameView view,
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animations,
|
|
IChargenPalSetSource palSets,
|
|
IChargenClothingTableSource clothingTables,
|
|
object datLock,
|
|
bool useZoomedOutEye = false,
|
|
// F16 (Campaign CC gate round 1 closeout): the render-id pair this
|
|
// controller stamps on the entities it builds — MUST match the
|
|
// pair the sibling ChargenPreviewRenderer was constructed with (see
|
|
// that class's own renderId/backdropRenderId parameters), since
|
|
// both feed the SAME shared TextureCache owner-tracking key.
|
|
// Defaults to the Appearance page's pair; the composition root
|
|
// passes the Summary pair explicitly for its own instance — see
|
|
// ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for
|
|
// why sharing the default here would be a real collision, not
|
|
// merely untidy.
|
|
uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId,
|
|
uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId)
|
|
{
|
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
|
_view = view ?? throw new ArgumentNullException(nameof(view));
|
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
|
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
|
|
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
|
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
|
_useZoomedOutEye = useZoomedOutEye;
|
|
_renderId = renderId;
|
|
_backdropRenderId = backdropRenderId;
|
|
_rotation = new ChargenPreviewRotationController();
|
|
// Seed the eye NOW, matching whatever the first Rebuild's own
|
|
// heritageOrGenderChanged branch below would otherwise defer until
|
|
// the first successful compose — avoids one frame of the wrong
|
|
// (Appearance-profile) eye if this controller ever renders before
|
|
// Rebuild's first call succeeds.
|
|
if (_useZoomedOutEye)
|
|
_camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u);
|
|
}
|
|
|
|
/// <summary>Test-observability seam only — production callers use
|
|
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/>.</summary>
|
|
internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false;
|
|
|
|
/// <summary>Test-observability seam only.</summary>
|
|
internal Vector3 CameraEye => _camera.Eye;
|
|
|
|
public bool Rebuild(
|
|
ChargenOptions options,
|
|
uint heritageId,
|
|
int genderKey,
|
|
ChargenAppearanceSelection selection)
|
|
{
|
|
if (_disposed)
|
|
return false;
|
|
|
|
if (_hasComposed
|
|
&& heritageId == _lastHeritageId
|
|
&& genderKey == _lastGenderKey
|
|
&& selection.Equals(_lastSelection))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site):
|
|
// TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/
|
|
// TryGetClothingTable (_palSets/_clothingTables), which do lazy raw
|
|
// DatCollection.Get<T>() reads on first use — DatCollection is NOT
|
|
// thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread
|
|
// Rebuild call is the catalog's first production call site. Every
|
|
// sibling DAT read in this same method already guards with
|
|
// _datLock (see the TryBuildAnimated call just below) — this one
|
|
// must too.
|
|
bool composed;
|
|
ChargenAppearanceResult result;
|
|
lock (_datLock)
|
|
{
|
|
composed = ChargenAppearanceFactory.TryCompose(
|
|
options, heritageId, genderKey, selection,
|
|
_palSets, _clothingTables, out result);
|
|
}
|
|
if (!composed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Quaternion heading = MoveToMath.SetHeading(
|
|
Quaternion.Identity, _rotation.HeadingDegrees);
|
|
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
|
_dats, _animations, result, heritageId, heading, _datLock, _renderId);
|
|
if (build is null)
|
|
return false;
|
|
|
|
bool wasZoomedIn = _animator?.IsZoomedIn ?? false;
|
|
_animator = new ChargenPreviewAnimator(build);
|
|
if (wasZoomedIn)
|
|
_animator.SetZoomedIn(true);
|
|
|
|
bool heritageOrGenderChanged =
|
|
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
|
|
if (heritageOrGenderChanged)
|
|
{
|
|
// F5: the Summary controller (_useZoomedOutEye) re-derives the
|
|
// FIXED zoomed-out eye per heritage instead of SetHeritage's
|
|
// zoomed-in default — see the ctor param's own doc comment.
|
|
_camera.Eye = _useZoomedOutEye
|
|
? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId)
|
|
: ChargenPreviewCamera.ResolveDefaultEye(heritageId);
|
|
}
|
|
|
|
// Batch D (GF-7/GF-14): retail's own backdrop-rebuild gate
|
|
// (gmCG3DView::Update's `m_bgSetupID.id != eax_32` check) fires
|
|
// whenever the HERITAGE's own environmentSetupID differs from the
|
|
// one currently shown — and that value is a pure function of
|
|
// heritage (ACCharGenData::GetHG(mHeritageGroup).environmentSetupID),
|
|
// never gender. Narrower than heritageOrGenderChanged on purpose: a
|
|
// gender-only change (or an appearance-only change, which never
|
|
// reaches this branch at all) would otherwise pay a redundant Setup
|
|
// dat fetch + mesh-reference acquire/release for a backdrop that
|
|
// cannot have changed.
|
|
bool heritageChanged = !_hasComposed || heritageId != _lastHeritageId;
|
|
if (heritageChanged)
|
|
{
|
|
WorldEntity? backdrop =
|
|
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|
|
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
|
|
_dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId)
|
|
: null;
|
|
_renderer.SetBackdrop(backdrop);
|
|
}
|
|
|
|
// ChargenPreviewZoomController's animator dependency is required at
|
|
// construction (fix round F2) — a fresh animator means a fresh
|
|
// controller, but it reads IsZoomedIn straight through the animator
|
|
// we just restored above, so zoom state itself survives the swap.
|
|
_zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator);
|
|
|
|
_renderer.SetPreview(_animator.Entity);
|
|
_hasComposed = true;
|
|
_lastHeritageId = heritageId;
|
|
_lastGenderKey = genderKey;
|
|
_lastSelection = selection;
|
|
return true;
|
|
}
|
|
|
|
public void ZoomIn() => _zoom?.ZoomIn();
|
|
public void ZoomOut() => _zoom?.ZoomOut();
|
|
public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise);
|
|
public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise);
|
|
|
|
public void Render()
|
|
{
|
|
if (_disposed || !_view.TryGetVisibleSize(out int width, out int height))
|
|
return;
|
|
|
|
double now = _clock.Elapsed.TotalSeconds;
|
|
float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds);
|
|
_lastElapsedSeconds = now;
|
|
|
|
_animator?.Tick(deltaSeconds);
|
|
_rotation.Tick(now);
|
|
_zoom?.Tick(now);
|
|
if (_animator is not null)
|
|
_animator.Entity.Rotation = _rotation.ToOrientation();
|
|
|
|
_view.SetTextureHandle(_renderer.Render(width, height));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
// Fix round F9: release the preview entity NOW rather than leaving
|
|
// the leased renderer holding it until the renderer's OWN disposal
|
|
// (a separate manifest entry, one step later) — this class built
|
|
// the entity via Rebuild, so it releases it on its own teardown
|
|
// instead of relying on a downstream owner to notice. Batch D: the
|
|
// backdrop entity is the SAME kind of controller-built resource, so
|
|
// it releases on the same teardown for the same reason.
|
|
_renderer.SetPreview(null);
|
|
_renderer.SetBackdrop(null);
|
|
_animator = null;
|
|
_zoom = null;
|
|
// The renderer itself is a leased composition resource disposed by
|
|
// the composition root (mirrors PaperdollViewportRenderer — this
|
|
// class does not own its lifetime, only its per-frame drive).
|
|
}
|
|
}
|