feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation

Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:21:34 +02:00
parent 3a6b7e3115
commit 55bfd9ca82
16 changed files with 2131 additions and 2 deletions

View file

@ -0,0 +1,176 @@
using System;
using System.Numerics;
using AcDream.Core.CharGen;
namespace AcDream.App.Rendering;
/// <summary>
/// Heritage-parameterized camera for the chargen 3D preview
/// (<c>gmCG3DView</c>, Appearance page viewport <c>0x100003bb</c> / Summary
/// <c>0x10000406</c>). Retail-exact eye positions, ported from
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c> (pseudo-C ~139037-139114,
/// which sets <c>m_vectTargPosition</c>/<c>m_vectCurPosition</c> per
/// heritage and snaps them together with no tween — CC6a's static preview
/// renders that snapped default, the "zoomed-in" framing) and cross-checked
/// against the IDENTICAL literals in <c>gmCGAppearancePage::ZoomIn @
/// 0x0047CF00</c> (pseudo-C ~137618-137638). Direction is always
/// <c>(0,0,0)</c> ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view
/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention
/// <see cref="DollCamera"/> already established for the paperdoll (look
/// straight down +Y, +Z up); every camera position below is used AS the
/// world-space eye directly, matching that camera's approach.
///
/// <para>
/// <b>Rotation is NOT a camera property.</b> Retail's continuous-rotation
/// button (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>) advances a
/// HEADING applied to the preview CHARACTER (<c>CPhysicsObj::set_heading</c>
/// inside <c>gmCG3DView::Update</c>, pseudo-C ~242088) — the camera's own
/// position/direction never change during a rotation. CC6b's heading
/// parameter therefore belongs on the entity builder
/// (<see cref="ChargenPreviewEntityBuilder"/>), not here; this class stays a
/// fixed-per-heritage eye, exactly like retail's own camera.
/// </para>
/// </summary>
public sealed class ChargenPreviewCamera : ICamera
{
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera.
private Vector3 _eye;
public ChargenPreviewCamera(uint heritageId = 0u)
{
_eye = ResolveDefaultEye(heritageId);
}
/// <summary>
/// The camera's current world-space eye. Settable so CC6b can react to a
/// heritage change without reconstructing the camera.
/// </summary>
public Vector3 Eye
{
get => _eye;
set => _eye = value;
}
/// <summary>Re-derives <see cref="Eye"/> for the given heritage id (retail's <c>mHeritageGroup</c>).</summary>
public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId);
/// <summary>
/// Retail default (zoomed-in) camera eye per heritage. All four profiles
/// share <c>X=0</c>; only <c>(Y, Z)</c> — the AC world-space forward
/// offset and height — vary. FOUR distinct profiles across the 13
/// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho,
/// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen,
/// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME
/// numeric offset as Gearknight's own dedicated branch in the decomp.
/// </summary>
public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f),
(uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f),
_ => new Vector3(0f, -0.550000012f, 1.64999998f),
};
/// <summary>
/// Retail zoomed-OUT camera eye per heritage
/// (<c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>, pseudo-C
/// ~137671-137687). CC6a does not implement the zoom button (CC6b) —
/// recorded here as the verified target CC6b's tween will animate
/// toward. Olthoi/OlthoiAcid each keep their own dedicated profile;
/// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is
/// special-cased but whose zoomed-OUT is not — shares one value.
/// </summary>
public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f),
_ => new Vector3(0f, -2.5f, 0.95f),
};
/// <summary>
/// Seconds per 360° revolution for the continuous-rotation button
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, ctor pseudo-C
/// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000,
/// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly,
/// no reconstruction needed). Consumed by CC6b's rotation controller as
/// <c>360f / RotationDegreesPerSecond</c> — NOT applied here; see this
/// class's own doc comment on why rotation is not a camera concern.
/// </summary>
public const float RotationSecondsPerRevolution = 3.0f;
/// <summary>
/// Zoom tween duration in seconds
/// (<c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>'s
/// reset-if-invalid default, cross-confirmed by <c>ZoomIn</c>/<c>ZoomOut</c>'s
/// own <c>-0.1</c> sentinel write, which deliberately invalidates
/// <c>m_dAnimDuration</c> so the very next <c>DoZoomAnimation</c> tick
/// resets it to this same value). The campaign plan flagged this
/// constant as decompiler-garbled (both sites split the raw double
/// across two 32-bit stores, and the decompiler mis-renders the LOW
/// dword's store as a bogus float literal instead of raw bits) — it is
/// NOT unrecoverable: reinterpreting each garbled float literal as its
/// own raw 32-bit pattern and pairing it with the store's (clean) high
/// dword reconstructs an exact IEEE-754 double both times.
/// <c>DoZoomAnimation</c>'s own reset path: low32 from
/// <c>4.17232506e-08f</c> reinterpreted = <c>0x33333333</c>, high32 =
/// <c>0x3fe33333</c> (clean) → exactly <b>0.6</b>. Cross-check via
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s sentinel: low32 from
/// <c>-1.58818684e-23f</c> reinterpreted = <c>0x9999999A</c>, high32 =
/// <c>0xbfb99999</c> (clean) → exactly <b>-0.1</b>, the well-known
/// IEEE-754 bit pattern for -0.1 (<c>0xBFB999999999999A</c>) — confirming
/// the reconstruction technique itself, not just this one value.
/// </summary>
public const float ZoomTweenDurationSeconds = 0.6f;
public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera.
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 50f;
public float Aspect { get; set; } = 1f;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
}
/// <summary>
/// Internal private-viewport adapter, mirroring <c>DollViewportCamera</c>'s
/// role for <see cref="ChargenPreviewCamera"/>.
/// </summary>
internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera
{
private readonly ChargenPreviewCamera _camera;
public ChargenPreviewViewportCamera(uint heritageId = 0u)
{
_camera = new ChargenPreviewCamera(heritageId);
}
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public Vector3 Eye => _camera.Eye;
public float FovRadians
{
get => _camera.FovRadians;
set => _camera.FovRadians = value;
}
public float Near
{
get => _camera.Near;
set => _camera.Near = value;
}
public float Far
{
get => _camera.Far;
set => _camera.Far = value;
}
public float Aspect
{
get => _camera.Aspect;
set => _camera.Aspect = value;
}
public Matrix4x4 View => _camera.View;
public Matrix4x4 Projection => _camera.Projection;
}

View file

@ -0,0 +1,258 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the static-pose chargen preview <see cref="WorldEntity"/> from a
/// <see cref="ChargenAppearanceResult"/> — the App-layer counterpart to
/// <see cref="DollEntityBuilder"/>, except this one resolves its OWN
/// MeshRefs from a Setup + the composed ObjDesc rather than receiving
/// already-resolved refs from a live entity (there is no live entity yet;
/// character creation hasn't happened). DAT-touching, unlike
/// <see cref="DollEntityBuilder"/>'s pure index-agnostic builder — the
/// closest existing precedent for the actual mesh-flatten/apply-changes/
/// resolve-surface-overrides steps is
/// <c>DatLiveEntityProjectionMaterializer.TryMaterialize</c>, trimmed to
/// what a private, non-animated, non-collision preview scene needs.
/// </summary>
internal static class ChargenPreviewEntityBuilder
{
/// <summary>Reserved synthetic guid for the chargen preview clone —
/// same reserved family as <see cref="DollEntityBuilder.DollServerGuid"/>
/// (0xDA11D0xx) and <c>CreatureAppraisalEntityBuilder</c> (0xDA11D02x).</summary>
public const uint PreviewServerGuid = 0xDA11_D031u;
/// <summary>Reserved render-local entity id — passed in
/// <c>animatedEntityIds</c> by the renderer so a re-dress (a new
/// selection) bypasses <c>WbDrawDispatcher</c>'s Tier-1 classification
/// cache, mirroring <see cref="DollEntityBuilder.DollRenderId"/>'s own
/// doc comment.</summary>
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>
/// Retail's held-pose animation DID enum key, resolved through master
/// map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
/// — 0x10000005 for every standard heritage (the SAME enum id the
/// paperdoll's own held pose reads), matching
/// <c>gmCG3DView</c>'s ctor / <c>::Update</c> per-heritage
/// <c>m_didAnimationRest</c> assignment (pseudo-C ~0x004EE948,
/// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest
/// DID — the one divergence from the paperdoll, which never needs an
/// Olthoi branch because a live player can't be one.
/// </summary>
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000005u,
};
/// <summary>
/// Builds the preview entity, or null when the resolved body Setup
/// isn't in the dat source (a corrupted/incomplete install — the same
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
/// as "drop this spawn").
/// </summary>
public static WorldEntity? TryBuild(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animations);
ArgumentNullException.ThrowIfNull(appearance);
Setup? setup = dats.Get<Setup>(appearance.SetupId);
if (setup is null)
return null;
var flattened = new List<MeshRef>(SetupMesh.Flatten(setup));
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
{
if (change.PartIndex < flattened.Count)
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
}
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
var meshRefs = new List<MeshRef>(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
if (dats.Get<GfxObj>(part.GfxObjId) is null)
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
IReadOnlyDictionary<uint, uint>? overrides = null;
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
overrides = perPart;
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
}
if (meshRefs.Count == 0)
return null;
PaletteOverride? paletteOverride = null;
if (appearance.ObjDesc.SubPalettes.Count > 0)
{
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
{
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
}
paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges);
}
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
{
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
}
return new WorldEntity
{
Id = PreviewRenderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = appearance.SetupId,
Position = Vector3.Zero,
Rotation = heading,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = partOverrides,
ParentCellId = null,
};
}
/// <summary>
/// Overwrites every part's transform from the resolved rest pose's
/// FINAL frame — same "hold the settled last frame at zero frame rate"
/// approach as <c>RetailPaperdollPoseApplicator.Apply</c>
/// (<c>RedressCreature @ 0x004A3C22</c>), applied to the FULL
/// setup-part-indexed array (before drawable filtering) so the index
/// alignment holds even if a later part turns out to have a missing
/// GfxObj. No-ops (keeps the default placement frame) when the pose
/// DID or its animation can't be resolved.
/// </summary>
private static void ApplyHeldPose(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint heritageId,
List<MeshRef> flattened)
{
uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId));
if ((poseDid >> 24) != 0x03u)
return;
Animation? animation = animations.LoadAnimation(poseDid);
if (animation is null || animation.PartFrames.Count == 0)
return;
var frame = animation.PartFrames[^1];
for (int index = 0; index < flattened.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 = Matrix4x4.CreateScale(scale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
flattened[index] = new MeshRef(flattened[index].GfxObjId, transform);
}
}
/// <summary>
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent — verbatim
/// port of <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>,
/// parameterized by the target enum key.
/// </summary>
private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
{
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
/// algorithm against <see cref="ChargenTextureChange"/> instead of the
/// wire's <c>CreateObject.TextureChange</c>.
/// </summary>
private static Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
IDatReaderWriter dats,
IReadOnlyList<MeshRef> parts,
IReadOnlyList<ChargenTextureChange> textureChanges)
{
if (textureChanges.Count == 0)
return null;
var oldToNewByPart = new Dictionary<int, Dictionary<uint, uint>>();
foreach (ChargenTextureChange change in textureChanges)
{
if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew))
{
oldToNew = [];
oldToNewByPart.Add(change.PartIndex, oldToNew);
}
oldToNew[change.OldTextureId] = change.NewTextureId;
}
var result = new Dictionary<int, Dictionary<uint, uint>>();
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
{
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
continue;
GfxObj? gfx = dats.Get<GfxObj>(parts[partIndex].GfxObjId);
if (gfx is null)
continue;
Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = dats.Get<Surface>(surfaceId);
if (surface is null)
continue;
uint originalTexture = (uint)surface.OrigTextureId;
if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture))
continue;
(resolved ??= [])[surfaceId] = newTexture;
}
if (resolved is not null)
result[partIndex] = resolved;
}
return result.Count == 0 ? null : result;
}
}

View file

@ -0,0 +1,80 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Chargen-specific facade over the shared private creature viewport
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
/// the campaign plan's "chargen preview renderer" deliverable. Mirrors
/// <see cref="PaperdollViewportRenderer"/>'s shape exactly, with a
/// heading-capable <see cref="ChargenPreviewViewportCamera"/> in place of the
/// paperdoll's fixed one.
///
/// <para>
/// <b>NOT wired here (CC6b, after CC4 merges per the campaign's parallelism
/// contract):</b> mounting into the authored Appearance/Summary viewport ids
/// (<c>0x100003bb</c> / <c>0x10000406</c>), spin/color-wheel controls, and
/// the rotate/zoom buttons. This class is a standalone, composition-root-
/// agnostic renderer — nothing in <c>AcDream.App/UI/Layout/</c> or
/// <c>RetailUiRuntime.cs</c> references it yet.
/// </para>
///
/// <para>
/// <b>Register row (staged deviation, retired by CC6b):</b> retail plays a
/// live 30fps idle loop in the preview
/// (<c>gmCG3DView</c>'s <c>m_didAnimation</c>/<c>m_didAnimArray</c>,
/// <c>set_sequence_animation</c>, distinct from the STATIC
/// <c>m_didAnimationRest</c> this class's entity builder uses). CC6a holds
/// the static rest-pose final frame only — see
/// <c>docs/architecture/retail-divergence-register.md</c>.
/// </para>
/// </summary>
internal sealed class ChargenPreviewRenderer :
IUiViewportRenderer,
IDisposable
{
private readonly PrivateEntityViewportRenderer _renderer;
private readonly ChargenPreviewViewportCamera _camera;
internal ChargenPreviewRenderer(
IWorldPassScope scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint heritageId = 0u)
{
_camera = new ChargenPreviewViewportCamera(heritageId);
_renderer = new PrivateEntityViewportRenderer(
scope,
device,
frames,
dispatcher,
lightUbo,
textureLifetime,
meshAdapter,
ChargenPreviewEntityBuilder.PreviewRenderId,
_camera,
"chargen preview");
}
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
/// <summary>
/// Re-derives the fixed per-heritage camera eye
/// (<see cref="ChargenPreviewCamera.ResolveDefaultEye"/>) — call whenever
/// the selected heritage changes, BEFORE the next <see cref="Render"/>.
/// </summary>
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity);
public uint Render(int width, int height) => _renderer.Render(width, height);
public void Dispose() => _renderer.Dispose();
}