Skip to content

feat: avatar facial expressions#8746

Draft
alejandro-jimenez-dcl wants to merge 29 commits into
devfrom
feat/avatar-facial-expressions
Draft

feat: avatar facial expressions#8746
alejandro-jimenez-dcl wants to merge 29 commits into
devfrom
feat/avatar-facial-expressions

Conversation

@alejandro-jimenez-dcl
Copy link
Copy Markdown
Contributor

@alejandro-jimenez-dcl alejandro-jimenez-dcl commented May 12, 2026

Pull Request Description

What does this PR change?

Adds facial expressions to avatars: a radial wheel HUD picks one of 9 presets or fine-tunes eyebrows/eyes/mouth independently, applies the result to the local avatar, and broadcasts changes over comms so remote players see them.

Data + animation layer (Assets/DCL/AvatarRendering/AvatarShape)

  • AvatarFaceComponent holds per-channel renderer/material refs, atlas-capability bools, resting indices (Eyebrows/Eyes/MouthExpressionIndex), live override indices, and blink/mouth-animation state.
  • AvatarFacialExpressionSystem (in AvatarGroup, after AvatarInstantiatorSystem) sets up the component, re-binds renderers/materials on wearable swap, and drives three layers each frame: eyebrows base, blink override, mouth-pose override (mouth restores to MouthExpressionIndex when chat/voice end, not to idle).
  • Atlas slicing is shader-driven via the _ExpressionIndex material property + _expressions texture; per-channel writes go through MaterialPropertyBlock in AvatarFaceMaterialUtils. Channels whose wearable lacks an _expressions atlas are skipped so we don't paint atlas slices onto a single-frame face texture.
  • Wearable-swap correctness: the skinning material pool can return the same Renderer instance with a freshly pooled Material whose _ExpressionIndex is at the shader-default sentinel. The system diffs both the renderer ref and sharedMaterial against the cached pair every frame and flips IsDirty on mismatch, so the override is re-applied after a swap.
  • MouthAnimationSystem / inputs in AvatarMouthInputComponent: chat text overrides voice-chat loop; vowels and uppercase get distinct frame durations.

Comms (Assets/DCL/Multiplayer/FacialExpression)

  • Regenerated rfc4 protobuf with a new FacialExpression message (EyebrowsIndex, EyesIndex, MouthIndex).

  • MultiplayerFacialExpressionMessageBus subscribes on Island + Scene pipes, drops payloads with index > 15 (ADR-317), and exposes a Drain pattern keyed by wallet.

  • PlayerFacialExpressionNetSendSystem is edge-triggered off LocalPlayerFacialExpressionComponent (only sends when indices change). RemoteFacialExpressionSystem applies drained RemoteFacialExpressionIntentions to remote avatars via TriggerFacialExpressionIntent, consumed by ApplyFacialExpressionIntentSystem.

    Input

  • New Shortcuts.FaceExpression (Y) and FaceExpressions (1-9) action maps in DCLInput.inputactions; map flag added to InputMapComponent.

  • UpdateFacialExpressionInputSystem resolves wheel-open vs. quick-press: Y down opens the wheel state, Y+digit while open applies a slot, Y+digit released without explicit open is a "shortcut" quick-apply.

  • FacialExpressionsWheelShortcutHandler owns Y release: ignoreNextRelease after a SHORTCUT apply and a 0.5s QUICK_APPLY_LOCK_TIME after a WHEEL_SLOT apply suppress the toggle so a quick pick doesn't immediately reopen/close the wheel. Publishes RequestToggleFacialExpressionsWheelEvent otherwise.

Wheel UI (Assets/DCL/FacialExpressionsWheel)

  • New FacialExpressionsWheelHUD prefab + FacialExpressionWheelSlotView, controller, view, and a FacialExpressionsCharacterPreviewController.

  • On open, the wheel seeds from the player's current AvatarFaceComponent, auto-matches the indices to a preset slot label or shows "Custom"; commits only when the user actually cycled a stepper or clicked a slot.

  • Three independent steppers (eyebrows / eyes / mouth) backed by a reusable DCL.UI.NumericCycler (extracted from the wheel-specific FaceChannelCyclerView).

  • Emote wheel adds a "face tab" entry that hands off to the facial expressions wheel.

    Test Instructions

    Prerequisites

    • Test account wears face items that ship an _expressions atlas (default body works).
    • Two clients on the same realm to verify remote propagation.

    Test Steps

    1. Launch the build, log in.
    2. Press Y to open the wheel. Verify the preview seeds to your current expression on the very first open after launch (covers the async-instantiation race fix).
    3. Press 1-9 (or click slots) with the wheel open: preview updates per slot, highlight and name follow.
    4. Cycle each stepper independently; verify "Custom" appears when the combination is not a preset.
    5. Close the wheel; verify the in-world avatar mirrors the preview.
    6. From a second client on the same realm, verify the remote avatar shows the same expression (edge-triggered send: only changes propagate).
    7. Hold Y + press a digit (wheel closed); confirm the wheel does not pop and the expression applies on Y release (shortcut path + ignoreNextRelease).
    8. Apply a non-default expression, then swap a face-affecting wearable; verify the expression survives the swap (renderer + material-ref diff fix).
    9. Chat and voice chat change the mouth expression.

    Quality Checklist

    • Changes have been tested locally
    • Documentation has been updated (if required)
    • Performance impact has been considered
    • For SDK features: Test scene is included (N/A)

    Code Review Reference

    Please review our Branch & PR Standards before submitting.

alejandro-jimenez-dcl and others added 20 commits April 28, 2026 14:40
Components (AvatarFaceComponent merging expression/blink/mouth-anim,
AvatarMouthInputComponent, LocalPlayerFacialExpressionComponent),
SO definitions (AvatarFaceExpressionConfig + Definition), shared
mouth input queue, default expressions asset, and Essentials addressable.
Adds the central face animation system delegating material binding to
AvatarFaceMaterialUtils and atlas/pose data to AvatarFacialExpressionConstants.
Designer-tunable timings live in AvatarFaceAnimationSettings (SO).
Drops AvatarMouthInputQueue: chat/voice handlers will write directly to
AvatarMouthInputComponent (added alongside AvatarFaceComponent in setup).
Adds rfc4 FacialExpression message (eyebrows/eyes/mouth tile indices,
ADR-317) and wires MessagePipe payload extraction for the new oneof case.
Source proto change lives on protocol branch feat/facial-expressions.
Multiplayer: IFacialExpressionMessageBus + MultiplayerFacialExpressionMessageBus
(subscribe to FacialExpression oneof, edge-triggered Send), RemoteFacialExpressionIntention,
and PlayerFacialExpressionNetSendSystem reading LocalPlayerFacialExpressionComponent.
AvatarShape: RemoteFacialExpressionSystem drains intentions and applies indices to
AvatarFaceComponent of the matching remote avatar (TODO: validate non-query-driven
participant lookup with architect, mirrors RemoteEmotesSystem). Face systems grouped
under AvatarShape/Systems/FacialExpression and Multiplayer/FacialExpression.
Slots 1-9 bound as Y-modifier composites mirroring the emote slot
shortcut style. Unblocks UpdateFaceExpressionInputSystem (Domain 2 leftover).
Y + 1-9 shortcut applies the matching expression to the local player's
AvatarFaceComponent and mirrors indices into LocalPlayerFacialExpressionComponent
so PlayerFacialExpressionNetSendSystem propagates the change.
AvatarPlugin loads AvatarFaceAnimationSettings + AvatarFaceExpressionConfig
SOs and injects AvatarFacialExpressionSystem (null texture arrays for now —
atlases come from wearables per ADR-317) and UpdateFaceExpressionInputSystem.

MultiplayerMovementPlugin owns IFacialExpressionMessageBus, injects
PlayerFacialExpressionNetSendSystem and RemoteFacialExpressionSystem.
DynamicWorldContainer instantiates MultiplayerFacialExpressionMessageBus
alongside the movement bus.

RemoteFacialExpressionSystem made partial so the Arch source generator
emits InjectToWorld. Added asmref so PlayerFacialExpressionNetSendSystem
compiles in DCL.Plugins (which has Arch.SystemGroups).

Default AvatarFaceAnimationSettings asset and Essentials addressable entry
plus Global Plugins Settings.asset references for both SOs.
Brings the facial expressions wheel UI online: ring of 10 slots with
icons, EYEBROWS / EYES / MOUTH cyclers, live avatar preview, and the
input maps that drive it (Y+[0-9] always-on quick-apply, plain [0-9]
while the wheel is open). On close, the last preview is committed via
IFacialExpressionApplier if the user touched a slot or cycler; opening
and closing without picking anything leaves the avatar untouched.

The slot view, cycler view, controller, character preview wrapper and
icon textures are introduced here. FacialExpressionWheelUtils centralises
slot label / action name / wrap math so the controller and the (future)
ECS input system share the same encoding. The cycler exposes a single
OnCycle(int delta) instead of separate Previous/Next events.
Pulls Y-shortcut release and Y+[0-9] quick-apply out of the wheel
controller so the wheel stays UI-only.

FacialExpressionsWheelShortcutHandler mirrors EmoteWheelShortcutHandler:
listens to Shortcuts.FaceExpression.canceled, publishes
RequestToggleFacialExpressionsWheelEvent on the event bus (subscriber
is the wheel plugin, follow-up). Exposes NotifyExpressionPlayed so the
ECS input system can suppress the next Y release after a quick-apply.

UpdateFacialExpressionInputSystem replaces the old
UpdateFaceExpressionInputSystem: listens to the FaceExpressions map,
writes TriggerFacialExpressionIntent on the local player, notifies the
handler so the upcoming Y release is swallowed.

The FaceExpressions map was previously always-on (it had no entry in
ApplyInputMapsSystem) which meant Y+[0-9] fired in chat input, menus,
and any state that disables player input. Adds InputMapComponent.Kind
FACE_EXPRESSIONS, wires it through ApplyInputMapsSystem, and includes
it in the default-enabled set in MainSceneLoader so blocking emotes
also blocks face quick-apply.

AvatarPlugin now owns the handler lifecycle (created when the face
expression config loads, disposed with the plugin) and injects the
new ECS input system. DynamicWorldContainer passes the shared
emotesEventBus to AvatarPlugin so the handler and the wheel use the
same event channel.
Inline FacialExpressionApplier as a static util and drop the matching
interface; the wheel takes World+Entity directly and calls Apply(). Move
the applier out of the FacialExpression subfolder so it lives in the
AvatarShape assembly, reachable from the wheel (DCL.Social), which the
prior asmref-to-Plugins indirection had silently broken.

Relocate LocalPlayerFacialExpressionComponent from DCL.Multiplayer.Movement
to DCL.Multiplayer.FacialExpression next to its only consumer
(PlayerFacialExpressionNetSendSystem), and fix the stale xmldoc that
pointed at the old PlayerMovementNetSendSystem path.

In ApplyFacialExpressionIntentSystem: rename the network-flavored query
to SetupLocalPlayerFacialExpression, rename the ref param network->local,
and drop the redundant `in Entity` per code standards (Entity is a small
value type and the query mutates).

Fold FacialExpressionTriggerSource into FacialExpressionsWheelShortcutHandler
(single-use enum), drop the unjustified virtual on NotifyExpressionPlayed,
inline StashRemoteMessage into OnChatMessageAdded, and turn EnqueueSpeaking
into a local function inside OnActiveSpeakersUpdated.
Slot prefab + HUD prefab with 10 radial slot instances (R=270).
Move expression icons to Assets/Textures/FacialExpressionsWheel.
Register FacialExpressionsWheelPlugin in DynamicWorldContainer.
Qualify UnityEngine.Time to avoid DCL.Time namespace shadow.

Cycler buttons not wired yet.
Wearable load substitutes *_expressions.png / *_expressions_mask.png when
present and flags the wearable. The facial expression system stamps
per-channel capability onto AvatarFaceComponent and skips MaterialPropertyBlock
overrides (blink, mouth pose, eyebrow frames) on channels without an atlas,
keeping the wearable's static face texture untouched.
Material-side wiring for the in-shader 4x4 atlas slicing landed in
unity-shared-dependencies. AvatarFaceMaterialUtils sets _ExpressionIndex
through a MaterialPropertyBlock (-1 disabled, 0..15 cell) so the
renderer's existing _MainTexArr_ID slot keeps the wearable's loaded
atlas while the shader picks the per-frame cell on its own.

AvatarFacialExpressionSystem drops the dead eyebrows / eye / mouth
global Texture2DArray params (AvatarPlugin had already been passing
null); the system now only needs the animation settings.

Also restores the LINQ Contains -> explicit loop fix in
WearablePolymorphicBehaviour.TryGetMainFileKey: the LINQ overload
picked up the wrong extension method once the ambient using shifted.
…g gaps

Properties inside UnityPerMaterial CBuffer (added there for SRP batcher
compatibility) can't be overridden by MaterialPropertyBlock. Each face
renderer already has its own pooled material instance, so write the
expression cell index directly onto sharedMaterial. Same pattern as
_MainTexArr_ID.

SetupFaceComponents flips IsDirty=true so the first frame's
ApplyExpressionLayer actually writes the initial cell index, and bumps
the per-channel index to 0 (idle) for atlas-bound channels. The -1
sentinel stays the default so non-atlas wearables keep their static
face texture untouched.

Also:
- Register FacialExpression in MessageWrapExtensions.WRITES_MAP so the
  rfc4 broadcast stops throwing NotSupportedException every trigger.
- Park FacialExpressionsWheelPlugin behind a commented registration in
  DynamicWorldContainer until wearables ship expression atlases; its
  Settings entry stays in the Global Plugins Settings container so
  re-enabling is a single uncomment.
@alejandro-jimenez-dcl alejandro-jimenez-dcl added the force-build Used to trigger a build on draft PR label May 12, 2026
…ressions

# Conflicts:
#	Explorer/Assets/AddressableAssetsData/AssetGroups/Essentials.asset
#	Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
#	Explorer/Packages/packages-lock.json
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

alejandro-jimenez-dcl and others added 7 commits May 12, 2026 21:21
Swapping wearables pool-recycles the body shape renderer with a fresh
skinning material whose _ExpressionIndex starts at the shader-default
sentinel. The face component still cached its prior renderer reference
and Current* indices, so the equality guard short-circuited subsequent
writes and the channel rendered the full atlas until the next input.

- Stamp per-channel atlas capability on AvatarShapeComponent during
  AvatarInstantiator so AvatarFacialExpressionSystem no longer reads
  the (possibly replaced) WearablePromise.
- Bring HasExpressionAtlas onto AvatarFaceComponent as a stable bool;
  resting index resolves to 0 or the channel sentinel from that flag.
- Run ApplyExpressionLayer every frame and drop the Current* equality
  guard in AvatarFaceMaterialUtils, since the pool can swap the
  underlying material without changing the renderer ref.
The previous fix ran ApplyExpressionLayer every frame and dropped the
equality guard in AvatarFaceMaterialUtils because the skinning material
pool can swap the face material under us on a wearable swap (same
renderer instance, fresh shader-default _ExpressionIndex). That bypassed
the Current* optimization unconditionally.

Cache the renderer.sharedMaterial alongside the renderer ref on
AvatarFaceComponent and diff it in ReInitRenderersIfNeeded. A mismatch
flips IsDirty and resets Current* the same way a renderer-ref or
capability-bool change does, so the equality short-circuit and the
IsDirty gate are reliable again.
@
feat(facial-expressions): rework wheel state model + enable plugin

Extract FaceChannelCyclerView to reusable DCL.UI.NumericCycler{View,Controller}.
Controller owns wrap/index, raises OnIndexChanged only on user input so external
SetIndex calls do not re-enter.

Rewire FacialExpressionsWheelController around three NumericCyclerController
instances: seed from current AvatarFaceComponent on open, auto-match a slot or
show "Custom", commit via FacialExpressionApplier only when the user touched
something.

Fix initial-seed race: preview wearables instantiate async after OnShow, so
TrySetFace was silently no-opping. SetFaceWhenReadyAsync waits for the preview
entity AvatarFaceComponent before pushing seed indices. Required exposing
CharacterPreviewController.PreviewEntity.

Shorten wearable URNs in FacialExpressionsCharacterPreviewController.Initialize
to avoid long-form lookup misses.

Make CharacterPreviewAvatarContainer FOV smooth duration configurable via
cameraSettings.fieldOfViewDuration; <= 0 snaps instantly.

Re-enable FacialExpressionsWheelPlugin in DynamicWorldContainer (was gated on
wearables shipping expression atlases).
@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

force-build Used to trigger a build on draft PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants