V3 ("Lilith") is not a game you win; it is a place you inhabit — a multi-user
metaverse where a verified yoga instructor demonstrates an asana to a room of
embodied students, a Saraswati artist performs to a stadium audience, and
strangers free-roam a commons. Embodiment is therefore the product, and three
subsystems carry it: the avatar pipeline that turns two very different art
sources (MetaHumans and VRM) into one runtime body, the animation pipeline
that drives that body from canonical clips, hand-tracking, and voice, and the
spatial audio layer that places every voice and music stream in 3D so the
room feels like a room. Unlike V2's combat core — one provably-deterministic C++
engine — V3's embodiment stack is deliberately split across Unreal C++ (the
Tier-1 native runtime under V3/ue/Source) and a tier of TypeScript domain
libraries under libs/v3/ that own the import math, the retarget tables, the
codec/HRTF plans, and the browser fallback. That split exists because V3 renders
the same world two ways: a high-fidelity UE5 client (MetaHuman-class avatars,
Lumen, Sequencer concerts) and a Tier-2 web fallback (VRM proxies, Web Audio),
and the two paths must agree on skeleton, blendshapes, mix buses, and provenance
even though they share no renderer.
The grounding for everything below is real and dual-located: the analytic
hand-IK geometry in V3/ue/Source/V3Avatar/Public/V3HandIkSolver.h, the 60-bone
Oshun→MetaHuman bind table in V3MetaHumanRetarget.cpp, the VRM 1.0 importer
and blendshape semantics in libs/v3/avatar-pipeline/src/index.ts, the HRTF /
ambisonic / music-sync math in libs/v3/spatial-audio/src/index.ts, and the
Opus-spatializing voice gateway in
V3/ue/Source/V3Voice/Private/V3VoiceRealtimeGateway.cpp. This page is the
architecture-side companion for the "Avatars, Audio, and Tenant Experiences"
set; the section hub is ../V3_ARCHITECTURE.md.
What ships, honestly#
The geometry, retarget tables, codec/spatialization math, and validation
harnesses are real C++ and TypeScript, each backed by an automation test. On
the UE side, V3Avatar ships an analytic two-bone hand-IK solver, a 60-binding
MetaHuman retarget table, a four-posture state machine with IK foot/pelvis
alignment, and a consent-gated Tara eyes-open policy — covered by the real
automation tests V3.Avatar.OpenXRHandTracking.HandIKP95,
V3.Avatar.MetaHumanRetarget.TableDrivesAsanaAndGestures,
V3.Avatar.Posture.StateMachineIkAlignment, and
V3.Avatar.TaraEyesOpenPolicy.SequenceDefault (all under
V3/ue/Source/V3Tests/Private). V3Audio ships a Steam Audio VR profile +
validator (V3.Audio.SteamAudio.VRSpatialProfile); V3Voice ships an Opus-24
kbps positional-voice gateway with equal-power panning and a mouth-to-ear
latency budget (V3.Voice.RealtimeGateway.*). On the TS side,
avatar-pipeline, spatial-audio, psyche-3d, and aja-pose are substantive
libraries with domain-specific algorithms (VRM import + round-trip, viseme
lip-sync, HRTF convolution, ambisonic decode, NTP-style music-sync clock
recovery, MediaPipe/MoveNet pose estimation, asana classification), not CRUD
shells.
Four honest qualifications. First, the V3Animation module is a thin
registration contract — FV3AnimationModuleContract exposes only
GetModuleName(), GetOwnedSurfaceTag(), and SupportsRuntimeLoad()
(V3Animation/Public/V3Animation.h). The rich animation graph the monolith
describes (AnimBP-per-archetype, Mover 2.0 motion matching, ChooserTable asana
selection, Cascadeur/Live Link mocap) is spec-level: the retarget table
and asana/gesture catalog are implemented (in V3Avatar and
avatar-pipeline), but the AnimBP assets and motion-matching database are
content, not code in this repo. Second, libs/v3/isis-motion is a
descriptor-only package — its src/ holds a single index.ts capability
descriptor with no implementation; treat it as a placeholder for the
motion-authoring service, not a shipped engine. Third, the MetaHuman master
assets and their hand-authored VRM proxies are content/art: the
dual-authoring orchestration and likeness-drift gate are real TS
(avatar-pipeline/src/premium/), but the avatars themselves are produced
outside the repo. Fourth, the voice gateway's latency probes are
deterministic budget-model harnesses, not live network measurements
(detailed below), and Steam Audio / Resonance DSP is engine-plugin-gated —
the code validates and configures the runtime via honest fail-loud seams
(IsResonanceAudioRuntimeAvailable() is literally a
FModuleManager::ModuleExists check) but the convolution itself runs in the
plugin. The sections below say where each claim is backed.
The avatar pipeline: two sources, one body#
V3 accepts avatars from two worlds and normalizes both to a single runtime
representation: the Oshun 60-bone skeleton plus a shared blendshape
vocabulary. The canonical bone list is authored in two places that must agree —
OSHUN_60_BONE_NAMES in libs/v3/avatar-pipeline/src/index.ts (60 entries,
root → pelvis → spine01/02/chest → arms/hands/legs + finger chains + IK
targets) and the matching BuildOshun60Table() in
V3/ue/Source/V3Avatar/Private/V3MetaHumanRetarget.cpp, which binds each Oshun
bone to its MetaHuman counterpart (spine01 → spine_01, leftHand → hand_l,
leftIndexProximal → index_01_l, …). The UE table is not a flat alias map: each
FV3MetaHumanRetargetBinding carries a kind — Direct, Corrective (e.g.
clavicles get a ±0.5° rotation offset; facial and finger bones are corrective),
or IK (knee/elbow targets) — and ValidateReferenceMetaHuman() asserts
exactly 60 bindings with a max angular offset ≤ 1.0° before declaring
bNoVisibleArtifacts. That same table also ships the canonical content catalogs
the animation layer indexes against: CanonicalAsanaLibrary() (Tadasana,
Virabhadrasana II, Adho Mukha Svanasana, Balasana, Padmasana, Savasana) and
CanonicalGestureCatalog() (Namaste, Jnana mudra, Applause, Snap, Stage Wave,
Consent Raise-Hand).
VRM 1.0 — the importer and its round-trip guarantee#
VRM is canonical for community avatars, Ready-Player-Me / VRoid imports, and the
Oshun gallery. The importer is real TypeScript: importVrm1Document() and
parseVrm1Json() parse the glTF + VRMC_vrm extension into an
ImportedVrm1Avatar (avatar-pipeline/src/index.ts:843),
createOshun60RetargetTable() (:920) produces an Oshun60RetargetTable that
records missingRequiredBones, and verifyVrm1IdentityRoundTrip() /
roundTripImportedVrm1Avatar() re-export the avatar and prove the bind survives
serialization — identity is verified, not assumed. Reference locomotion is
generated and retargeted by createOshun60ReferenceAnimations() and
retargetVrmReferenceAnimation(), then scored by
evaluateOshun60RetargetRegression() against the canonical pose set, mirroring
the C++ table's role on the native side. The deeper retarget machinery lives in
avatar-pipeline/src/retarget/vrm-to-oshun.ts (~21 KB).
Blendshapes — one facial vocabulary across renderers#
Both source types bind to the same blendshape semantics so a facial expression
authored once plays everywhere: OSHUN_VISEME_NAMES,
OSHUN_EMOTION_BLENDSHAPES, OSHUN_GAZE_BLENDSHAPES, and
OSHUN_BROW_BLENDSHAPES (avatar-pipeline/src/index.ts:290+).
createOshunBlendshapeMap() projects an imported avatar's expression bindings
onto that vocabulary, and the lip-sync path — createReferenceVisemePhrase() →
renderVisemePhrase() → evaluateVisemePhraseLipSync() (:1119–:1166) —
renders viseme weights per frame and scores them. This vocabulary is
intentionally the same one psyche-3d drives at runtime (next section), which
is why a chat-tone-derived smile or a TTS-derived viseme means the same thing on
a MetaHuman face and a VRM proxy face.
MetaHumans and the dual-authoring requirement#
MetaHumans are canonical for high-fidelity verified personas — every Saraswati
artist's primary avatar, and any Tara instructor who opts in. The problem the
architecture confronts head-on: a MetaHuman cannot render on the Tier-2 web
client (UE-specific skeletal mesh, MetaHuman facial rig, strand-based Groom
hair, Substrate materials — none port to glTF cleanly), and automated
MetaHuman→VRM conversion produces unacceptable likeness drift. So every premium
persona ships two parallel assets: a MetaHuman master (Tier-1 + Pixel
Streaming) and a hand-authored VRM proxy (Tier-2, ≤ 80 K triangles, hair as
cards, reduced viseme set, same costume material-slot identifiers). The
orchestration of that workflow is real code in avatar-pipeline/src/premium/:
dual-authoring.ts models the two-asset binding, likeness-drift.ts implements
the release gate (the editorial owner reviews the proxy against the master from
front / three-quarter / profile and a drift score must clear the §34 threshold),
saraswati-ga-personas.ts enumerates the six GA personas, and
tara-instructor-opt-in.ts models the post-GA opt-in queue. What is not in
the repo is the art itself — the MetaHuman .uassets and the authored .vrm
proxies are content. This is the honest line: the pipeline is real; the avatars
are a content pipeline it gates.
Provenance and costume — identity travels with the body#
Every avatar carries an Isis provenance bundle:
createImportedAvatarProvenanceBundle() /
createGalleryAvatarProvenanceBundle() build it,
attachProvenanceToImportedAvatar() binds it, and inspectAvatarProvenance()
reads it back (:1788–:1850); both variants of a premium persona share one
bundle id. Costume is realm-aware: AVATAR_COSTUME_SLOT_NAMES and
REALM_COSTUME_SLOT_POLICIES (:382/:400) encode which slots each realm
(tara / saraswati / commons) permits, lilithSafetyCostumeRuleCheck()
enforces the per-realm safety rules, and swapCostumeVariantsWithoutReload()
hot-swaps wardrobe without re-importing the avatar — the same material-slot
identifiers on the MetaHuman and the VRM proxy are what make a per-realm
wardrobe rule apply uniformly across both renderers. Identity binding (V1
user-id ↔ avatar-id) is owned by Lilith-Identity-Bridge, not this library; see
../../platform/oshun-domain-libraries.html
for where these @oshun/* packages sit in the domain map.
The animation pipeline: clips, hands, posture, and a consent gate#
The monolith's animation taxonomy (AnimBP-per-archetype, Mover 2.0 motion
matching, ChooserTable asana selection, mocap via Vicon/OptiTrack/Move.AI/
Cascadeur, MetaHuman Animator facial capture) is the design; the parts that
exist as verified code are the geometric and policy cores that any AnimBP would
call into. There are three, all in V3Avatar, plus a runtime expression engine
in psyche-3d.
Hand IK — measured, never hand-authored#
VR clients drive finger pose from OpenXR hand-tracking, and V3 solves it
analytically rather than blending canned poses. V3HandIkSolver.h is
deliberately UE-independent (plain double/Vec3, no FVector) so the
geometry unit-tests outside the engine: FlexionFromPositions() derives a
joint's flexion angle from captured parent/joint/child positions via the dot
product; SolveTwoBoneFlexionForReach() is a law-of-cosines two-bone IK; and
RetargetFlexionDegrees() maps a tracked flexion onto the avatar bone through a
per-joint RigCalibration (rest offset, transfer gain, anatomical
JointLimitDegrees default 95°) and clamps it. The UE wrapper
V3OpenXRHandIk.cpp builds 52 Quest 3 joint bindings
(BuildQuest3Bindings(), both hands, palm→tip) and a 20-sample validation set
spanning an open palm (~2°) through a tight snap (~62°);
ValidateQuest3HandIk() runs each captured pose through the solver, measures
the angular retarget residual, and only sets bQuest3Ready when the binding
count is 52, the sample count ≥ 20, and the P95 angular error ≤ 5°. The
error is a computed geometric quantity — exactly the anti-pattern the
V3HandIkSolver.h header comment calls out ("never a hand-authored literal").
This is what the V3.Avatar.OpenXRHandTracking.HandIKP95 test guards, and it is
how mudra and prop interaction stay believable per finger.
Posture state machine — sit/kneel/lie need a prop anchor#
Contemplative practice means avatars sit on cushions, kneel in seiza, and lie on
mats — postures most metaverse rigs fake. V3PostureStateMachine.cpp models
four EV3AvatarPosture states (Stand, Sit, Kneel, Lie) with per-posture
IK targets (pelvis offset, foot-lock tolerance ≤ 2 cm) and six bidirectional
transitions with bounded blend durations and alignment-error caps.
FV3AvatarPostureTarget::Validate() enforces a real invariant: a Sit or Lie
posture must require a prop anchor (bRequiresPropAnchor), and a sitting
transition must reference a sitting prop anchor — so an avatar can't float where
a zafu cushion should be. ValidateSittingPropDrill() checks the full set (4
targets, ≥ 6 transitions) and a P95 prop-alignment of 1.6 cm against a 2.0 cm
ceiling, which the V3.Avatar.Posture.StateMachineIkAlignment test asserts.
The Tara eyes-open consent policy#
The most domain-specific piece of the avatar layer is also the smallest:
V3TaraEyesOpenPolicy.cpp decides whether a Tara (instructor) avatar may close
its eyes during a guided sequence. The default is eyes open — auto eye-close
cues are suppressed unless there is an explicit, consent-shaped invitation,
and HasExplicitInvitation() actually inspects the invitation text for
permissive phrasing ("if it feels", "comfortable", "you may", "choose to close",
…). Only then does Evaluate() apply EyesClosed with a full 1.0 close-blend
weight; otherwise it returns EyesOpen with bSuppressAutoEyeClose and a
recorded reason. Every decision carries a Reason string and is validated
(FV3TaraEyesOpenPolicyModel::Validate()), so the policy fails loud rather than
silently closing an instructor's eyes. This is consent encoded as a state
machine, not a comment — guarded by
V3.Avatar.TaraEyesOpenPolicy.SequenceDefault.
Runtime expression and lip-sync — psyche-3d#
The face is driven at runtime by libs/v3/psyche-3d. It targets 60 Hz
lip-sync (PSYCHE_3D_LIPSYNC_TARGET_HZ) across web/mobile/VR surfaces, with a
15-viseme set; classifyPsyche3dVoiceFrame() maps a voice-analysis frame to a
viseme, buildPsyche3dLipsyncTracks() produces per-surface blendshape tracks,
and evaluatePsyche3dLipsyncQuality() / isPsyche3dLipsync60HzReady() gate it.
It also resolves emotion from text: classifyPsyche3dChatTone() →
PSYCHE_3D_TONE_TO_EMOTION → resolvePsyche3dExpressionStateFromChatTone(),
with evaluatePsyche3dExpressionCoherence() checking tone/expression agreement,
and parsePsyche3dGestureCommand() turning a chat gesture (/wave, /namaste)
into one of the canonical gesture presets consistently across clients. This is
the runtime consumer of the same blendshape vocabulary avatar-pipeline
defines.
The practitioner-facing counterpart — grading the human's real pose from a
webcam, not animating the avatar — is libs/v3/aja-pose. It runs on-device pose
estimation (runtime.ts: MediaPipe Tasks-Vision pose-landmarker, MoveNet
lightning/thunder fallback, ≥ 15 Hz on web + mobile), classifies against
AJA_CANONICAL_30_ASANAS to a ≥ 92% per-asana accuracy bar (classifier.ts),
generates alignment cues (cue-generator.ts), routes known-risk poses through a
modification ladder (risk-flagger.ts), keeps raw pose on-device with Iris
aggregate-only egress (privacy.ts), and emits Psyche-TTS instructor voice cues
(voice-cue.ts). Aja grades the student; psyche-3d animates the avatar — two
distinct embodiment loops that meet in a Tara class.
Spatial audio: voice, music, and a two-tier renderer#
Audio is what makes the room a room. The design splits cleanly by tier: Tier 1
(UE5) uses MetaSounds with Resonance Audio by default and Steam Audio as a VR
opt-in; Tier 2 (web) uses Web Audio + Resonance Audio JS, with an
ambisonic-to-stereo fallback for low-end CPUs. Both tiers share identical
mix-bus semantics (voice / music / effects / ambience /
accessibility), which is enforced in code: MixBusKey and
createDefaultMixBusRuntime() in libs/v3/spatial-audio/src/index.ts define
exactly those five buses for both paths.
Positional voice — the UE gateway#
Voice is Opus 24 kbps mono, 20 ms frames per speaker (OPUS_24K_MONO_CODEC
in spatial-audio, and FV3VoiceRealtimeGatewayConfig in
V3Voice/Public/V3Voice.h). UE clients receive directly via a WebRTC SFU
(livekit-compatible-sfu, DTLS-SRTP with AEAD-AES-128-GCM); Pixel Streaming
clients receive the already- mixed audio inside the H.264/AV1 video stream
(spatialization done in the UE worker). The spatialization itself is real math
in V3VoiceRealtimeGateway.cpp: from speaker/listener positions it derives
DistanceM, AzimuthDegrees (atan2), and ElevationDegrees, then
ApplyResonanceAudioSpatialization() computes an equal-power pan —
LeftGain = atten · √(0.5·(1−pan)), RightGain = atten · √(0.5·(1+pan)) with
atten = 1/(1 + 0.15·distance). The Resonance binding is an honest seam:
IsResonanceAudioRuntimeAvailable() is
FModuleManager::ModuleExists("ResonanceAudio"), and ConnectToRoom() fails
loud ("ResonanceAudio runtime module is not available") rather than pretending.
Honesty note: the 16-participant and regional probes
(RunSixteenParticipantLatencyProbe, RunRegionalLatencyValidationProbe) are
deterministic budget-model harnesses — mouth-to-ear latency is composed
analytically as
OpusFrameDurationMs + GatewayToSfuMs + SfuFanoutMs + RegionalRttMs/2 from
synthesized regional-RTT fixtures, then percentiled against the 80 ms budget.
They validate the latency model and the spatialization math, not a live
network, which is the correct scope for an automation test
(V3.Voice.RealtimeGateway.SfuOpusSpatializationLatency /
...RegionalLatencyP95).
Steam Audio VR profile#
Higher-fidelity VR occlusion is the V3Audio Steam Audio profile.
FV3SteamAudioVrRuntimeProfile::BuildDefaultVrProfile() configures 48 kHz, 8
reflection bounces, a 30 Hz occlusion trace rate, HRTF + occlusion + reflections
- voice + music spatialization, and an explicit voice-latency budget that sums
to 70 ms (22 capture-to-SFU + 18 jitter + 12 DSP + 10 mixer + 8 output) under
an 80 ms ceiling, across the launch XR backend matrix (Quest 3, Vision Pro,
PSVR2, Valve Index, Vive Focus 3).
Validate()rejects any profile that doesn't bind theSteamAudioplugin and HRTF dataset, isn't the VR-opt-in default, drops a spatialization feature, or blows the latency budget — andV3.Audio.SteamAudio.VRSpatialProfileasserts the validator passes. The DSP runs in the Steam Audio plugin; this code owns the contract and the budget, not the convolution.
Music sync and the Tier-2 fallback#
Stadium-tier concerts need every attendee to hear the music in lockstep. The
sync is NTP-style: createMusicStreamServerTimestamp() stamps the stream,
estimateMusicSyncClock() recovers the server-clock offset from a
four-timestamp exchange (with round-trip-delay smoothing),
mapMusicStreamTimestampToPlaybackTarget() turns that into a client playback
target with a playout buffer, and simulateMusicSyncConcert() validates a
256-attendee, hour-long concert against a P99 drift budget of 25 ms
(MUSIC_SYNC_P99_DRIFT_BUDGET_MS). The Tier-2 renderer is real Web Audio:
createHrtfConvolutionPlan() builds an
AudioWorkletNode → ConvolverNode → GainNode chain,
decodeFirstOrderAmbisonicToStereo() is the low-CPU fallback,
selectSpatialRendererForDevice() chooses between them per device profile, and
createTier2FallbackSpatialAudioScenePlan() wires Resonance Audio JS
(ResonanceAudio.Source → ResonanceAudio.Scene node chain) — the
resonance-audio module surface is declared in
spatial-audio/src/resonance-audio.d.ts. Scene occlusion is sampled at 5 Hz
(evaluateSceneOcclusion, SCENE_OCCLUSION_SAMPLE_RATE_HZ), and accessibility
is first-class: an audio-description bed (createAudioDescriptionBedPlan) and a
flat-stereo spatial-audio-off mode (createSpatialAudioOffModePlan) that
bypasses ambisonics for pixel-streaming / fallback / native-mobile / VR clients.
Edge cases and failure modes#
- Hand IK degrades to zero, never to garbage.
FlexionFromPositions()returns 0 for a degenerate (zero-length) bone segment, andRetargetFlexionDegrees()clamps to the anatomical joint limit — a bad capture can't hyperextend a finger. - Posture won't float.
Validate()blocks a sitting or lying posture that lacks a prop anchor, so an avatar physically requires the cushion/mat to exist. - Eyes-closed needs words. The Tara policy suppresses an auto eye-close cue
unless the invitation text is explicitly permissive; absent that, it records
eyes-closed-auto-cue-suppressed-explicit-invitation-requiredand keeps eyes open. - Voice fails loud. No Resonance module →
ConnectToRoom()returns false with a reason; wrong participant count → an explicit "expected N, got M"; a non-Resonance spatializer is rejected ("V3Voice must render positional voice with Resonance Audio"). - Music sync is bounded, not best-effort. The concert simulation asserts a P99 drift ≤ 25 ms; the clock estimator smooths round-trip delay so a single jittery exchange can't yank the offset.
- Spec vs. content seam. The
V3Animationmodule andisis-motionpackage are intentionally thin; the AnimBPs, motion-matching database, MetaHuman masters, and VRM proxies they presuppose are authored content. Reading those modules expecting a motion engine will mislead — the engine-side logic lives inV3Avatar(IK, retarget, posture) andpsyche-3d/aja-pose(expression, grading).
How it connects#
The avatar/animation/audio stack is the embodiment substrate the rest of V3
reads. The Oshun-60 skeleton, blendshape map, and MetaHuman/VRM bodies are
rendered by the Tier-1 UE5 client and its Pixel-
Streaming workers, and proxied to the browser through the
tier-routing path. The voice gateway
and music-sync clock ride the wire protocol described in
netcode, protocol, and physics, whose
per-mode tick targets (60 Hz expression, ≤ 25 ms music sync for Saraswati
performers) are exactly what psyche-3d and the music-sync simulator are sized
against. The two tenant experiences consume this stack directly:
Saraswati stage pipeline drives the premium
MetaHuman performers, concert costume packs, and stadium music sync, while
Tara classes, Aja, and the Commons drive
the instructor avatars, the Aja webcam pose-grading loop, asana retargeting, and
the eyes-open consent policy. Provenance and identity binding reach into the V1
domain libraries catalogued in
../../platform/oshun-domain-libraries.html,
and the module topology (V3Avatar / V3Animation / V3Audio / V3Voice and
the libs/v3/* packages) is mapped in
the subsystem glossary and layout.
Related#
- Tier-1 UE5 Client — the native runtime that renders
MetaHumans and runs
V3Avatar/V3Audio/V3Voice - Saraswati Stage Pipeline, Tara Classes, Aja, and the Commons — the tenant experiences that consume avatars + audio
- Netcode, Protocol, and Physics, Tier Routing and Pixel Streaming — how voice, expression, and music sync traverse the wire and the two tiers
- Subsystem Glossary and Layout, Tier-2 Fallback Web Client — module topology and the Web Audio / VRM-proxy fallback
- Oshun Domain Libraries — where
the
@oshun/*avatar/audio packages and Isis provenance sit - The section hub: ../V3_ARCHITECTURE.md