Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window
UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5: the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the Character window Attributes tab (reads as retail). 3062 tests green. Handoff: docs/research/2026-06-26-mockup-stage-handoff.md. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
ca94b479bf
61 changed files with 125881 additions and 44 deletions
53
src/AcDream.App/Rendering/DollCamera.cs
Normal file
53
src/AcDream.App/Rendering/DollCamera.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed camera for the paperdoll mini-scene — retail-exact, ported from the gmPaperDollUI viewport
|
||||
/// setup (decomp 0x004a5a39–0x004a5a69). The viewport (element <c>0x100001d5</c>) is configured by
|
||||
/// <c>UIElement_Viewport::SetCamera(position, direction)</c> with:
|
||||
/// <list type="bullet">
|
||||
/// <item>position = (0.12, −2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae]</item>
|
||||
/// <item>direction = (0, 0, 0) ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view frame to
|
||||
/// IDENTITY (<c>euler_set_rotate(0,0,0)</c> then <c>rotate(0,0,0)</c>), so the camera looks
|
||||
/// straight down +Y with +Z up — NO yaw, NO pitch.</item>
|
||||
/// </list>
|
||||
/// The doll is framed purely by camera POSITION + FOV, NOT by aiming at the body: at distance 2.4 with
|
||||
/// a π/4 (45°) vertical FOV the visible vertical band is z≈[−0.11, 1.87], which covers the whole ~1.6 m
|
||||
/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is <c>CreatureMode</c>'s default
|
||||
/// <c>m_fFOVRadians</c> (ctor 0x004543cf, hex 0x3f490fdb); default ambient is (0.3,0.3,0.3); the
|
||||
/// paperdoll uses <c>UseSharpMode</c> (not SmartboxFOV) so <c>Render::SetFOVRad(m_fFOVRadians)</c> applies.
|
||||
///
|
||||
/// <para>An earlier hand-tune aimed the camera at mid-body to "fit the figure", which introduced a
|
||||
/// spurious yaw (Target.x ≠ Eye.x) that rotated the view and turned the doll's face away from the
|
||||
/// viewer. This restores retail's zero-yaw frame, where full-body framing comes from the eye height +
|
||||
/// FOV, not from aiming.</para>
|
||||
///
|
||||
/// Uses the same <see cref="Matrix4x4.CreateLookAt"/> / <see cref="Matrix4x4.CreatePerspectiveFieldOfView"/>
|
||||
/// convention as <see cref="ChaseCamera"/> so the doll's triangle winding + back-face culling match the
|
||||
/// world render pass. AC up-axis = +Z.
|
||||
/// </summary>
|
||||
public sealed class DollCamera : ICamera
|
||||
{
|
||||
// Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61).
|
||||
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
|
||||
// Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
|
||||
private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f);
|
||||
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
|
||||
|
||||
/// <summary>Vertical field of view — retail <c>CreatureMode</c> default <c>m_fFOVRadians</c> = π/4 (45°).</summary>
|
||||
public float FovRadians { get; set; } = MathF.PI / 4f;
|
||||
|
||||
public float Near { get; set; } = 0.1f; // same near plane as ChaseCamera / retail znear
|
||||
public float Far { get; set; } = 50f; // doll scene is small; 50 m is ample
|
||||
public float Aspect { get; set; } = 1f;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 View =>
|
||||
Matrix4x4.CreateLookAt(Eye, Target, Up);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 Projection =>
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
|
||||
}
|
||||
133
src/AcDream.App/Rendering/DollEntityBuilder.cs
Normal file
133
src/AcDream.App/Rendering/DollEntityBuilder.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the dedicated paperdoll WorldEntity — retail's <c>makeObject(player)</c>
|
||||
/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
|
||||
/// + part overrides), posed at the scene origin facing the viewer.
|
||||
///
|
||||
/// <para>
|
||||
/// The palette / part-override mapping mirrors the inline construction in
|
||||
/// <c>GameWindow.cs</c> around lines 3390–3431. Extracted here so it is
|
||||
/// unit-testable without dats and so the paperdoll renderer owns a clean
|
||||
/// seam: it calls <see cref="Build"/> with fresh player state each time the
|
||||
/// ObjDesc changes, and the renderer only has to swap the entity into its
|
||||
/// dedicated mini-scene.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="DollServerGuid"/> is a reserved synthetic guid that is:
|
||||
/// (a) non-zero (so <c>EntitySpawnAdapter.OnCreate</c>'s <c>ServerGuid != 0</c>
|
||||
/// guard accepts it), and (b) high enough that it never collides with a real
|
||||
/// server-assigned guid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DollEntityBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserved synthetic guid for the paperdoll clone. High, deliberately
|
||||
/// outside the server's assignable range, and non-zero.
|
||||
/// </summary>
|
||||
public const uint DollServerGuid = 0xDA11_D011u;
|
||||
|
||||
/// <summary>
|
||||
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
|
||||
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The
|
||||
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
|
||||
/// doll as animated — which BYPASSES the Tier-1 classification cache
|
||||
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW
|
||||
/// WorldEntity with this SAME id; without the cache bypass the dispatcher would
|
||||
/// serve the previous doll's cached batches and the new gear wouldn't appear.
|
||||
/// </summary>
|
||||
public const uint DollRenderId = 0xDA11_D012u;
|
||||
|
||||
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
|
||||
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
|
||||
// North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about
|
||||
// +Z; System.Numerics rotates +Y → (−sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
|
||||
// NEGATED (θ = −h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
|
||||
private const float _headingDegrees = 191.367905f;
|
||||
private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f);
|
||||
private static readonly Quaternion _dollRotation =
|
||||
Quaternion.CreateFromAxisAngle(new Vector3(0f, 0f, 1f), _headingRad);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a synthetic <see cref="WorldEntity"/> for the paperdoll mini-scene.
|
||||
/// </summary>
|
||||
/// <param name="setupId">The player's Setup dat id (0x02xxxxxx).</param>
|
||||
/// <param name="meshRefs">Pre-resolved mesh refs (may be empty; caller fills them in).</param>
|
||||
/// <param name="basePaletteId">
|
||||
/// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
|
||||
/// also present — mirrors GameWindow which only builds a PaletteOverride
|
||||
/// when <c>SubPalettes.Count > 0</c>.
|
||||
/// </param>
|
||||
/// <param name="subPalettes">
|
||||
/// Subpalette overlays from the server ObjDesc. Each tuple carries the
|
||||
/// subpalette dat id, byte offset into the base palette, and byte length.
|
||||
/// Null or empty → <c>PaletteOverride</c> on the returned entity is null.
|
||||
/// </param>
|
||||
/// <param name="partOverrides">
|
||||
/// AnimPartChange swaps from the server ObjDesc. Each tuple is a
|
||||
/// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
|
||||
/// </param>
|
||||
public static WorldEntity Build(
|
||||
uint setupId,
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
uint? basePaletteId = null,
|
||||
IReadOnlyList<(uint SubPaletteId, byte Offset, byte Length)>? subPalettes = null,
|
||||
IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null)
|
||||
{
|
||||
// --- palette override (mirrors GameWindow:3395-3405) ---
|
||||
// Only build when there are sub-palette overlays — same gate as GameWindow.
|
||||
PaletteOverride? paletteOverride = null;
|
||||
if (subPalettes is { Count: > 0 } spList)
|
||||
{
|
||||
var ranges = new PaletteOverride.SubPaletteRange[spList.Count];
|
||||
for (int i = 0; i < spList.Count; i++)
|
||||
ranges[i] = new PaletteOverride.SubPaletteRange(
|
||||
spList[i].SubPaletteId,
|
||||
spList[i].Offset,
|
||||
spList[i].Length);
|
||||
paletteOverride = new PaletteOverride(
|
||||
BasePaletteId: basePaletteId ?? 0u,
|
||||
SubPalettes: ranges);
|
||||
}
|
||||
|
||||
// --- part overrides (mirrors GameWindow:3407-3418) ---
|
||||
PartOverride[] entityPartOverrides;
|
||||
if (partOverrides is null or { Count: 0 })
|
||||
{
|
||||
entityPartOverrides = Array.Empty<PartOverride>();
|
||||
}
|
||||
else
|
||||
{
|
||||
entityPartOverrides = new PartOverride[partOverrides.Count];
|
||||
for (int i = 0; i < partOverrides.Count; i++)
|
||||
entityPartOverrides[i] = new PartOverride(
|
||||
partOverrides[i].PartIndex,
|
||||
partOverrides[i].GfxObjId);
|
||||
}
|
||||
|
||||
// --- assemble entity (mirrors GameWindow:3420-3431) ---
|
||||
// Id=0: the paperdoll renderer assigns its own render-local id when it
|
||||
// registers this entity with the mini-scene GpuWorldState. We set zero
|
||||
// here so the builder stays pure (no static counter). The caller may
|
||||
// replace it before registration.
|
||||
return new WorldEntity
|
||||
{
|
||||
Id = DollRenderId,
|
||||
ServerGuid = DollServerGuid,
|
||||
SourceGfxObjOrSetupId = setupId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = _dollRotation,
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = paletteOverride,
|
||||
PartOverrides = entityPartOverrides,
|
||||
ParentCellId = null, // paperdoll mini-scene has no parent cell
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -667,6 +667,12 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||||
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
|
||||
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||||
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
|
||||
private AcDream.App.UI.UiViewport? _paperdollViewportWidget;
|
||||
private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame;
|
||||
private bool _paperdollDollDirty = true;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
||||
|
|
@ -2296,7 +2302,15 @@ public sealed class GameWindow : IDisposable
|
|||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
||||
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
||||
emptySlotSprite: contentsEmpty);
|
||||
emptySlotSprite: contentsEmpty,
|
||||
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
|
||||
|
||||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||||
// when the window is open and in doll-view. The renderer itself is built after the
|
||||
// WbDrawDispatcher exists (further down this method).
|
||||
_paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport;
|
||||
_inventoryFrame = inventoryFrame;
|
||||
|
||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
|
|
@ -2407,6 +2421,16 @@ public sealed class GameWindow : IDisposable
|
|||
// A.5 T22.5: apply A2C gate from quality preset.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
|
||||
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
|
||||
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
|
||||
if (_paperdollViewportWidget is not null && _sceneLightingUbo is not null)
|
||||
{
|
||||
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
|
||||
_gl, _wbDrawDispatcher, _sceneLightingUbo);
|
||||
_paperdollViewportWidget.Renderer = _paperdollViewportRenderer;
|
||||
}
|
||||
|
||||
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
|
||||
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
|
||||
// from ObjectMeshManager.GlobalBuffer.
|
||||
|
|
@ -3898,6 +3922,121 @@ public sealed class GameWindow : IDisposable
|
|||
BasePaletteId = md.BasePaletteId,
|
||||
};
|
||||
OnLiveEntitySpawned(newSpawn);
|
||||
|
||||
// Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player]
|
||||
// above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of
|
||||
// RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible.
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_paperdollDollDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
|
||||
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
|
||||
/// part overrides into a dedicated <see cref="DollEntityBuilder"/> entity posed at the scene origin
|
||||
/// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the
|
||||
/// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the
|
||||
/// player entity isn't available yet.
|
||||
/// </summary>
|
||||
private bool RefreshPaperdollDoll()
|
||||
{
|
||||
if (_paperdollViewportRenderer is null) return false;
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0)
|
||||
{
|
||||
_paperdollViewportRenderer.SetDoll(null);
|
||||
return false; // player not ready — retry next frame
|
||||
}
|
||||
|
||||
uint? basePal = null;
|
||||
List<(uint, byte, byte)>? subs = null;
|
||||
if (pe.PaletteOverride is { } po)
|
||||
{
|
||||
basePal = po.BasePaletteId;
|
||||
subs = new List<(uint, byte, byte)>();
|
||||
foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length));
|
||||
}
|
||||
|
||||
List<(byte, uint)>? parts = null;
|
||||
if (pe.PartOverrides.Count > 0)
|
||||
{
|
||||
parts = new List<(byte, uint)>(pe.PartOverrides.Count);
|
||||
foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
|
||||
}
|
||||
|
||||
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // dressed parts (player's live frame)
|
||||
var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
|
||||
pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
|
||||
ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
|
||||
_paperdollViewportRenderer.SetDoll(doll);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
|
||||
/// <c>gmPaperDollUI</c> ctor (decomp 174243) sets <c>m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7)</c>,
|
||||
/// which is <c>DBCache::GetDIDFromEnumStatic</c> (decomp 20380) = <c>master[MasterMapId][0x10000005]</c>
|
||||
/// → submap <c>0x25000009</c> → key <c>7</c>. (Icon effects use keys 1-6 of the same submap; key 7 is the
|
||||
/// paperdoll pose.) Per-race <c>UpdateForRace</c> override deferred — the ctor default applies to all
|
||||
/// body-types for now. Returns 0 if the chain can't resolve.
|
||||
/// </summary>
|
||||
private uint ResolvePaperdollPoseDid()
|
||||
{
|
||||
var dats = _dats;
|
||||
if (dats is null) return 0u;
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0) return 0u;
|
||||
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)) return 0u;
|
||||
// DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
|
||||
// GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
|
||||
if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
|
||||
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)) return 0u;
|
||||
return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
|
||||
/// retail <c>set_sequence_animation(doll, m_didAnimation, …)</c>. Keeps each cloned part's GfxObjId +
|
||||
/// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
|
||||
/// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
|
||||
/// animation tick (<see cref="TickAnimations"/>, GameWindow.cs ~9999). Static (the pose is fixed). If the
|
||||
/// DID does not resolve to an <c>Animation</c>, the doll keeps its cloned pose (no regression) — the log
|
||||
/// line surfaces what resolved so the pose is verified, not guessed.
|
||||
/// </summary>
|
||||
private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
|
||||
{
|
||||
var dats = _dats;
|
||||
if (dats is null) return;
|
||||
// Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
|
||||
_animatedEntities.Remove(doll.Id);
|
||||
// poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
|
||||
// did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
|
||||
// garbage frame count → OOM).
|
||||
uint poseDid = ResolvePaperdollPoseDid();
|
||||
if ((poseDid >> 24) != 0x03u) return;
|
||||
var anim = dats.Get<DatReaderWriter.DBObjs.Animation>(poseDid);
|
||||
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
||||
if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
|
||||
|
||||
// Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
|
||||
// RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
|
||||
// frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
|
||||
// arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
|
||||
int frameIdx = anim.PartFrames.Count - 1;
|
||||
var frame = anim.PartFrames[frameIdx];
|
||||
var src = doll.MeshRefs;
|
||||
var reposed = new List<AcDream.Core.World.MeshRef>(src.Count);
|
||||
for (int i = 0; i < src.Count; i++)
|
||||
{
|
||||
var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
|
||||
System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
|
||||
if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
|
||||
var transform = System.Numerics.Matrix4x4.CreateScale(scale)
|
||||
* System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(origin);
|
||||
reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
|
||||
}
|
||||
doll.MeshRefs = reposed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -9329,6 +9468,20 @@ public sealed class GameWindow : IDisposable
|
|||
SkipWorldGeometry: ;
|
||||
}
|
||||
|
||||
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
|
||||
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
|
||||
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
|
||||
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
|
||||
if (_options.RetailUi && _paperdollViewportRenderer is not null
|
||||
&& _paperdollViewportWidget is { Visible: true } dollWidget
|
||||
&& _inventoryFrame is { Visible: true })
|
||||
{
|
||||
if (_paperdollDollDirty && RefreshPaperdollDoll())
|
||||
_paperdollDollDirty = false;
|
||||
dollWidget.TextureHandle = _paperdollViewportRenderer.Render(
|
||||
(int)dollWidget.Width, (int)dollWidget.Height);
|
||||
}
|
||||
|
||||
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
|
||||
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
|
||||
// blend/depth state and restores. Drawn before ImGui so the devtools
|
||||
|
|
@ -13469,6 +13622,7 @@ public sealed class GameWindow : IDisposable
|
|||
_terrain?.Dispose();
|
||||
_terrainModernShader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
|
||||
_particleRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_uiHost?.Dispose();
|
||||
|
|
|
|||
193
src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
Normal file
193
src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
|
||||
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
|
||||
/// built by <see cref="DollEntityBuilder"/> + wired by GameWindow) into a private off-screen
|
||||
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
|
||||
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
|
||||
///
|
||||
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> so it can't disturb the
|
||||
/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the
|
||||
/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's
|
||||
/// 2-D OnDraw.</para>
|
||||
///
|
||||
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
|
||||
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
|
||||
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
|
||||
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a STATIC pose
|
||||
/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.</para>
|
||||
/// </summary>
|
||||
public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly WbDrawDispatcher _dispatcher;
|
||||
private readonly SceneLightingUboBinding _lightUbo;
|
||||
private readonly DollCamera _camera = new();
|
||||
|
||||
// Off-screen target (lazily (re)created on size change).
|
||||
private uint _fbo;
|
||||
private uint _colorTex;
|
||||
private uint _depthRbo;
|
||||
private int _fbW;
|
||||
private int _fbH;
|
||||
|
||||
// The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its
|
||||
// MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw.
|
||||
private WorldEntity? _doll;
|
||||
|
||||
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
|
||||
// neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path.
|
||||
private const uint DollLandblockId = 0u;
|
||||
|
||||
// The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification
|
||||
// cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is
|
||||
// what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect,
|
||||
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
|
||||
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
|
||||
|
||||
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
||||
}
|
||||
|
||||
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
|
||||
public void SetDoll(WorldEntity? doll) => _doll = doll;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
var doll = _doll;
|
||||
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||
return 0u;
|
||||
|
||||
EnsureFramebuffer(width, height);
|
||||
if (_fbo == 0) return 0u;
|
||||
_camera.Aspect = width / (float)height;
|
||||
|
||||
// Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/
|
||||
// FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched.
|
||||
using var scope = new GLStateScope(_gl);
|
||||
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
_gl.Disable(EnableCap.ScissorTest);
|
||||
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll
|
||||
_gl.ClearDepth(1.0);
|
||||
_gl.DepthMask(true); // depth clears honor glDepthMask
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.DepthFunc(DepthFunction.Less);
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
|
||||
UploadDollLight();
|
||||
|
||||
// One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒
|
||||
// walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190).
|
||||
var entities = new WorldEntity[] { doll };
|
||||
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
|
||||
{
|
||||
(DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null),
|
||||
};
|
||||
|
||||
_dispatcher.Draw(
|
||||
_camera,
|
||||
entries,
|
||||
frustum: null,
|
||||
neverCullLandblockId: DollLandblockId,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: _dollAnimatedIds); // doll treated as animated ⇒ cache-bypass (re-dress correctness)
|
||||
|
||||
return _colorTex;
|
||||
}
|
||||
|
||||
/// <summary>Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant
|
||||
/// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the
|
||||
/// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65)
|
||||
/// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533).</summary>
|
||||
private void UploadDollLight()
|
||||
{
|
||||
var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f));
|
||||
var ubo = new SceneLightingUbo
|
||||
{
|
||||
Light0 = new UboLight
|
||||
{
|
||||
PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional
|
||||
DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff
|
||||
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
|
||||
ConeAngleEtc = Vector4.Zero,
|
||||
},
|
||||
CellAmbient = new Vector4(0.30f, 0.30f, 0.30f, 1f), // retail CreatureMode default ambient (ctor 0x004543cf = 0.3,0.3,0.3)
|
||||
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll
|
||||
FogColor = Vector4.Zero,
|
||||
CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye)
|
||||
};
|
||||
_lightUbo.Upload(ubo);
|
||||
}
|
||||
|
||||
/// <summary>(Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
|
||||
/// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.</summary>
|
||||
private void EnsureFramebuffer(int width, int height)
|
||||
{
|
||||
if (_fbo != 0 && width == _fbW && height == _fbH) return;
|
||||
DeleteFramebuffer();
|
||||
|
||||
_fbW = width;
|
||||
_fbH = height;
|
||||
|
||||
_colorTex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
|
||||
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)width, (uint)height, 0,
|
||||
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||
|
||||
_depthRbo = _gl.GenRenderbuffer();
|
||||
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
|
||||
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
|
||||
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
|
||||
|
||||
_fbo = _gl.GenFramebuffer();
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||||
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0,
|
||||
TextureTarget.Texture2D, _colorTex, 0);
|
||||
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthStencilAttachment,
|
||||
RenderbufferTarget.Renderbuffer, _depthRbo);
|
||||
|
||||
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
|
||||
if (status != GLEnum.FramebufferComplete)
|
||||
{
|
||||
Console.WriteLine($"[paperdoll] framebuffer incomplete: {status} ({width}x{height})");
|
||||
DeleteFramebuffer();
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFramebuffer()
|
||||
{
|
||||
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
|
||||
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
|
||||
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
|
||||
_fbW = _fbH = 0;
|
||||
}
|
||||
|
||||
public void Dispose() => DeleteFramebuffer();
|
||||
}
|
||||
231
src/AcDream.App/Rendering/RenderBootstrap.cs
Normal file
231
src/AcDream.App/Rendering/RenderBootstrap.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System.Collections.Concurrent;
|
||||
using DatReaderWriter;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// The subset of the production render stack that the UI Studio needs:
|
||||
/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same
|
||||
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
|
||||
/// terrain / sky / physics / streaming.
|
||||
/// </summary>
|
||||
public sealed record RenderStack(
|
||||
GL Gl,
|
||||
DatReaderWriter.DatCollection Dats,
|
||||
string ShaderDir,
|
||||
Wb.BindlessSupport Bindless,
|
||||
TextureCache TextureCache,
|
||||
Shader MeshShader,
|
||||
Wb.WbMeshAdapter MeshAdapter,
|
||||
Wb.EntitySpawnAdapter EntitySpawnAdapter,
|
||||
Wb.WbDrawDispatcher DrawDispatcher,
|
||||
SceneLightingUboBinding LightingUbo,
|
||||
AcDream.App.UI.UiHost UiHost,
|
||||
AcDream.App.UI.UiDatFont? VitalsDatFont,
|
||||
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
|
||||
{
|
||||
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
|
||||
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
|
||||
/// and NOT disposed here. Called once at studio teardown.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
DrawDispatcher.Dispose();
|
||||
MeshAdapter.Dispose();
|
||||
TextureCache.Dispose();
|
||||
MeshShader.Dispose();
|
||||
LightingUbo.Dispose();
|
||||
UiHost.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
|
||||
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
|
||||
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
|
||||
/// </summary>
|
||||
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
|
||||
{
|
||||
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
|
||||
return (t, w, h);
|
||||
}
|
||||
|
||||
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
|
||||
/// Populated lazily by <see cref="ResolveDatFont"/>. Thread-safe for
|
||||
/// concurrent reads from the studio render loop; writes happen only
|
||||
/// during the first load of each distinct FontDid.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?> _fontCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Lazily load and cache a dat font by its FontDid. Returns null (and
|
||||
/// caches null) when the Font DBObj is absent or has no foreground surface —
|
||||
/// callers fall back to the global font in that case.
|
||||
///
|
||||
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
|
||||
/// <see cref="LargeDatFont"/> (0x40000001) from the already-loaded instances
|
||||
/// to avoid a redundant upload on those two ids.</para>
|
||||
/// </summary>
|
||||
public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
|
||||
{
|
||||
return _fontCache.GetOrAdd(fontDid, id =>
|
||||
AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-seeds the font cache from the two already-loaded font instances
|
||||
/// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that
|
||||
/// <see cref="ResolveDatFont"/> returns them without a redundant GL upload.
|
||||
/// Called once by <see cref="RenderBootstrap.Create"/> after the stack is
|
||||
/// fully constructed.
|
||||
/// </summary>
|
||||
internal void SeedFontCache()
|
||||
{
|
||||
if (VitalsDatFont is not null)
|
||||
_fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont);
|
||||
if (LargeDatFont is not null)
|
||||
_fontCache.TryAdd(0x40000001u, LargeDatFont);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
|
||||
public sealed record RenderBootstrapOptions(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings Quality);
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the UI Studio's render stack from the production classes,
|
||||
/// in the same order as <see cref="GameWindow.OnLoad"/>.
|
||||
/// </summary>
|
||||
public static class RenderBootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
|
||||
/// (same message as GameWindow) if GL_ARB_bindless_texture or
|
||||
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
|
||||
/// </summary>
|
||||
public static RenderStack Create(
|
||||
GL gl,
|
||||
DatReaderWriter.DatCollection dats,
|
||||
RenderBootstrapOptions opts)
|
||||
{
|
||||
// --- Bindless detection (GameWindow ~1701-1723) ---
|
||||
if (!Wb.BindlessSupport.TryCreate(gl, out var bindless)
|
||||
|| bindless is null
|
||||
|| !bindless.HasShaderDrawParameters(gl))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
|
||||
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
|
||||
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
|
||||
}
|
||||
|
||||
// --- Shared infra (GameWindow ~1198, ~1211) ---
|
||||
string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
|
||||
var lightingUbo = new SceneLightingUboBinding(gl);
|
||||
|
||||
// --- Mesh shader (GameWindow ~1769-1771) ---
|
||||
var meshShader = new Shader(gl,
|
||||
Path.Combine(shaderDir, "mesh_modern.vert"),
|
||||
Path.Combine(shaderDir, "mesh_modern.frag"));
|
||||
|
||||
// --- TextureCache (GameWindow ~1774) ---
|
||||
var textureCache = new TextureCache(gl, dats, bindless);
|
||||
|
||||
// --- AnimLoader (GameWindow ~1240) ---
|
||||
var animLoader = new AcDream.Core.Physics.DatCollectionLoader(dats);
|
||||
|
||||
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
|
||||
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
|
||||
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger);
|
||||
|
||||
// --- SequencerFactory (GameWindow ~2306-2334) ---
|
||||
var capturedDats = dats;
|
||||
var capturedAnimLoader = animLoader;
|
||||
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
if (capturedDats is not null && capturedAnimLoader is not null)
|
||||
{
|
||||
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
uint mtableId = (uint)setup.DefaultMotionTable;
|
||||
if (mtableId != 0)
|
||||
{
|
||||
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||
if (mtable is not null)
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup, mtable, capturedAnimLoader);
|
||||
}
|
||||
// Setup exists but no motion table — no-op sequencer.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
capturedAnimLoader);
|
||||
}
|
||||
}
|
||||
// Complete fallback: empty setup + empty motion table + null loader.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
new DatReaderWriter.DBObjs.Setup(),
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimLoader());
|
||||
}
|
||||
|
||||
// --- EntitySpawnAdapter (GameWindow ~2335-2336) ---
|
||||
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
|
||||
textureCache, SequencerFactory, meshAdapter);
|
||||
|
||||
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
||||
var classificationCache = new Wb.EntityClassificationCache();
|
||||
|
||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
bindless, classificationCache);
|
||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||
|
||||
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
||||
var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache);
|
||||
|
||||
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
|
||||
// The default font (0x40000000, 16px) renders the row names too small; the 18px
|
||||
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
|
||||
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
|
||||
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
|
||||
|
||||
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
|
||||
// a fallback BitmapFont for the world-space HUD — not needed for the
|
||||
// UI Studio, and BitmapFont requires a system font byte array) ---
|
||||
var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null);
|
||||
|
||||
var stack = new RenderStack(
|
||||
Gl: gl,
|
||||
Dats: dats,
|
||||
ShaderDir: shaderDir,
|
||||
Bindless: bindless,
|
||||
TextureCache: textureCache,
|
||||
MeshShader: meshShader,
|
||||
MeshAdapter: meshAdapter,
|
||||
EntitySpawnAdapter: entitySpawnAdapter,
|
||||
DrawDispatcher: drawDispatcher,
|
||||
LightingUbo: lightingUbo,
|
||||
UiHost: uiHost,
|
||||
VitalsDatFont: vitalsDatFont,
|
||||
LargeDatFont: largeDatFont);
|
||||
|
||||
// Pre-seed the font cache with the two already-uploaded atlas instances
|
||||
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache
|
||||
// rather than re-uploading the same GL texture a second time.
|
||||
stack.SeedFontCache();
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
// NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330).
|
||||
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
|
||||
{
|
||||
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue