Skip to content

feat: avatars now blink and talks#7452

Draft
olavra wants to merge 29 commits intodevfrom
feat/avatar-blink
Draft

feat: avatars now blink and talks#7452
olavra wants to merge 29 commits intodevfrom
feat/avatar-blink

Conversation

@olavra
Copy link
Copy Markdown
Member

@olavra olavra commented Mar 10, 2026

Added test feature to make avatars blink and talk. This is an experimental feature

  • Can be configured in Global Plugin Settings.

  • Uses another texture for the Blink and Mouth Atlas for poses

  • StartBlink now sets a MaterialPropertyBlock on the renderer

  • EndBlink clears it with SetPropertyBlock(null). A single static s_Mpb is reused to avoid per-blink allocations.

  • Mouth atlas is 1024, divided in chunks of 256s to fix the texture array

  • Each character is associated with mouth pose

  • You can se it in action while speaking

- Can be configured in Global Plugin Settings.
- Uses another texture for the
- StartBlink now sets a MaterialPropertyBlock on the renderer
- EndBlink clears it with SetPropertyBlock(null). A single static s_Mpb is reused to avoid per-blink allocations.
@github-actions
Copy link
Copy Markdown
Contributor

badge

New build in progress, come back later!

@olavra
Copy link
Copy Markdown
Member Author

olavra commented Mar 10, 2026

Grabacion.de.pantalla.2026-03-10.062607.mp4

- Uses 1024 atlas of 16 textures of 256.
- Each letter has a mouth pose associated
- The character talks when chating
@olavra olavra changed the title feat: Avatars now blink ;) feat: Avatars now blink and talks Mar 11, 2026
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Mar 11, 2026

Mouth720p.mp4

@olavra olavra changed the title feat: Avatars now blink and talks feat: Avatars now blink and talks 😉 Mar 11, 2026
@olavra olavra self-assigned this Mar 11, 2026
olavra added 5 commits March 12, 2026 20:31
- Added Avatar Face Debug panel
- Added Expressions system
- Added Eyebrows Atlas
- Added Expressions config file (scriptable)
- Push Y + Number to execute an expression
- Moves the mouth with hardcoded text while recognizing voice chat.
 - Alert! this uses the nametag "is talking" flag, to determine if you should be talking, which I dont hinkg is the best way to do it.
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Mar 13, 2026

The two "Talking" components exist because of assembly boundaries. NametagPlacementSystem and VoiceChatNametagsHandler live in separate assemblies that don't know about each other, they can't share a single signal component. The bridge component pattern lets each system write into the AvatarShape assembly without creating a dependency chain between them.

AvatarMouthAnimationComponent is different in nature it's not a signal, it's runtime state that only AvatarFacialAnimationSystem ever touches. Merging the signals into it would mean the chat system and voice system would need to write directly into animation state, which breaks encapsulation (they'd need to know about CharacterIndex, CharacterTimer, etc.).

If both chat and voice were in the same assembly, you could collapse the two bridge components into one. As it stands, the split is the cost of keeping the assembly graph clean.

olavra added 5 commits March 13, 2026 03:56
Created custom VoiceChat handler for the AvatarMouth instead of reusing the one on the nametag
- Using the same input component for the mouth movement from chat and voice chat
- Decoupled avatar mouth animation system from the chat bubble / nametag, added a service directly in the chat services to control input
Previously any unhandled character was defaulted to pose 0. This is now defaulted to the face expression override, which feels more natural.
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Mar 13, 2026

Screen.Recording.2026-03-13.015532.mp4

@olavra olavra changed the title feat: Avatars now blink and talks 😉 feat: avatars now blink and talks Apr 5, 2026
olavra added 2 commits April 5, 2026 21:43
Signed-off-by: Alvaro Luque <41490935+olavra@users.noreply.github.com>
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Apr 5, 2026

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 5, 2026

Claude finished @olavra's task in 3m 35s —— View job


PR Review — feat: avatars now blink and talk

  • Gather context
  • Read key changed files
  • Analyze code quality, bugs, performance
  • Post inline comments for issues
  • Summarize findings

6 issues found. 2 are bugs that must be fixed before merge; 2 are ECS rule violations per CLAUDE.md; 2 are lower-priority.

Bugs

1. IndexOutOfRangeException in UpdateFaceExpressionInputSystem (line 83)
ResolveExpressionIndex returns 0 even when expressions is empty, so expressions[0] throws if any Y+slot key is pressed with no expression config loaded. → inline comment

2. Eyebrows never applied when eye or mouth renderer is missing (AvatarFacialExpressionSystem.cs line 247)
UpdateFaceExpression has [All(typeof(AvatarBlinkComponent), typeof(AvatarMouthAnimationComponent))]. If either renderer is absent (→ component not added), the query skips that avatar entirely — including the eyebrows update, even if the eyebrows renderer exists. → inline comment

ECS Rule Violations

3. VoiceChatMouthAnimationHandler.SetMouthSpeaking calls world.Add/world.Get from a LiveKit event callback — entity manipulation outside systems is strictly forbidden. → inline comment

4. ChatAvatarMouthService.SetPendingMessage has the same violation — world mutations from a MessageAdded event callback. → inline comment

5. UpdateFaceExpressionInputSystem stores per-frame state in instance fields (triggeredSlot, faceExpressionsActions, etc.) — systems must not hold state. → inline comment

Performance / Architecture

6. VoiceChatMouthAnimationHandler.OnActiveSpeakersUpdated allocates new HashSet<string>() on every speaker-change event — reuse the existing instance with Clear + in-place diffing. → inline comment

7. Facial expression indices packed into GlideState proto field bits 2-13 — fragile coupling between the facial feature and movement protocol layers. Any future addition to GlideStateValue silently corrupts expressions. A defensive assertion + TODO comment would reduce the risk. → inline comment

REVIEW_RESULT: FAIL ❌

[None(typeof(DeleteEntityIntention))]
private void ApplyExpressionToPlayer([Data] int expressionIndex, ref AvatarFaceExpressionComponent expression, ref LocalPlayerFacialExpressionComponent networkExpression)
{
AvatarFaceExpressionDefinition def = expressions[expressionIndex];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: IndexOutOfRangeException when expressions is empty

ResolveExpressionIndex returns 0 when expressions.Length == 0, then expressions[0] throws. The guard in ResolveExpressionIndex handles the "nothing to pick" case by returning 0 but then that value is blindly used as an array index.

Fix: skip the query entirely if there are no expressions to apply:

if (triggeredSlot < 0)
    return;

int expressionIndex = ResolveExpressionIndex(triggeredSlot);
triggeredSlot = -1;

if (expressions.Length == 0)
    return;

ApplyExpressionToPlayerQuery(World, expressionIndex);

Why: a server build with no expression config, or a missing asset, will crash on any Y+1..9 keypress.

/// Also handles re-initialisation of the eyebrows renderer after avatar re-instantiation.
/// </summary>
[Query]
[All(typeof(AvatarBlinkComponent), typeof(AvatarMouthAnimationComponent))]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: eyebrows are never applied when eye or mouth renderer is missing

[All(typeof(AvatarBlinkComponent), typeof(AvatarMouthAnimationComponent))] means UpdateFaceExpression is silently skipped for any avatar that doesn't have both components. SetupBlinkComponent and SetupMouthComponent bail out early when eyeRenderer == null / mouthRenderer == null, so an avatar without a Mask_Eyes or Mask_Mouth mesh will never have its eyebrows updated, even if the eyebrows renderer exists.

The eyebrows and expression-base-index propagation logic should not depend on the blink and mouth components being present. Either:

  • Split UpdateFaceExpression into two queries (one for eyebrows that doesn't require Blink/Mouth, one for propagating base indices), or
  • Add a fallback query for entities that have AvatarFaceExpressionComponent but not Blink/Mouth components, applying at least the eyebrows and clamping blink/mouth index syncing.


private void SetMouthSpeaking(Entity entity, bool isSpeaking)
{
if (world.Has<AvatarMouthInputComponent>(entity))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECS violation: entity manipulation from outside a system

Per project rules, entity manipulation (including world.Has, world.Get, world.Add) is strictly forbidden outside of ECS systems. This method is called from a LiveKit event callback on a potentially non-main thread (depends on LiveKit's threading model). Even on the main thread, bypassing the ECS update cycle can cause structural changes mid-frame that invalidate outstanding refs in other systems.

The recommended pattern is to write the desired state into a thread-safe queue or a pending-writes list that a dedicated system flushes each frame, keeping all world mutations inside BaseUnityLoopSystem.Update. Fix this →


private void SetPendingMessage(Entity entity, string message)
{
if (world.Has<AvatarMouthInputComponent>(entity))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECS violation: entity manipulation from outside a system

Same issue as VoiceChatMouthAnimationHandler: world.Has, world.Get, and world.Add are called from a chatHistory.MessageAdded event callback, not from inside an ECS system's Update. This can cause mid-frame structural changes that corrupt outstanding component references in other systems running the same frame.

The fix is the same pattern: cache the pending message + sender wallet in a plain field/queue inside this service, then have a dedicated ECS system read and apply it each frame. Fix this →

private readonly Dictionary<string, int> slotByActionName = new (SLOT_COUNT);
private readonly DCLInput.FaceExpressionsActions faceExpressionsActions;

private int triggeredSlot = -1;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECS violation: system holds persistent state

triggeredSlot, faceExpressionsActions, expressions, and slotByActionName are all instance fields that persist across frames. Per project rules: "Systems must not contain state — all state goes into ECS."

triggeredSlot should be a singleton ECS component (e.g. PendingFaceExpressionSlot) that the input callback sets and the query reads+clears. faceExpressionsActions and the action subscriptions belong in a service/plugin layer, not the system itself. This also makes the system testable via the normal UnitySystemTestBase<T> pattern.


private void OnActiveSpeakersUpdated()
{
var newActiveSpeakers = new HashSet<string>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: new HashSet<string>() allocated on every active-speakers update

OnActiveSpeakersUpdated fires whenever the set of speakers changes (can be frequent during a voice call). Replacing activeSpeakers with a newly allocated HashSet each time means the old one is handed to the GC every call.

Fix: reuse the existing instance by swapping two pooled sets or by using Clear + Add directly on activeSpeakers:

private void OnActiveSpeakersUpdated()
{
    // Collect speakers that stopped talking
    foreach (string oldSpeakerId in activeSpeakers)
        if (!voiceChatRoom.ActiveSpeakers.Contains(oldSpeakerId))
            SetIsSpeaking(oldSpeakerId, false);

    activeSpeakers.Clear();

    foreach (string speakerId in voiceChatRoom.ActiveSpeakers)
    {
        if (activeSpeakers.Add(speakerId))  // Add returns true if it was newly inserted
            SetIsSpeaking(speakerId, true);
    }
}

(Adjust if voiceChatRoom.ActiveSpeakers doesn't support Contains / random access.)

movement.GlideState = (Decentraland.Kernel.Comms.Rfc4.Movement.Types.GlideState)message.animState.GlideState;
// GlideStateValue uses values 0-3 (2 bits). Facial expression indices (4 bits each) are
// packed into bits 2-13 of the GlideState int field to avoid adding new proto fields.
int packedFaceExpression = (message.eyebrowsExpressionIndex & 0xF)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile proto field overloading — silent breakage risk

Facial expression indices are packed into bits 2-13 of GlideState, which is defined in a shared protobuf schema. If GlideStateValue ever gains new enum values that use bits ≥ 2, or if a partner client decodes GlideState directly (e.g. the old uncompressed path or a mobile client), facial expression data will either be misread as a GlideState or the GlideState will be corrupted.

The same packing is mirrored in UncompressedMovementMessage (decode side, line ~156) and needs to stay in sync manually. This is a maintenance trap.

If adding a new proto field is off the table for this PR, at least add a defensive assertion:

// Ensure GlideState values only occupy the low 2 bits we reserved.
Debug.Assert((int)message.animState.GlideState <= 3, "GlideState exceeded 2-bit budget; facial expression packing is broken.");

And document the bit layout as a constant with a // TODO: migrate to dedicated proto field comment so it doesn't get forgotten.

1. IndexOutOfRangeException in UpdateFaceExpressionInputSystem (line 83)
ResolveExpressionIndex returns 0 even when expressions is empty, so expressions[0] throws if any Y+slot key is pressed with no expression config loaded. → inline comment

2. Eyebrows never applied when eye or mouth renderer is missing (AvatarFacialExpressionSystem.cs line 247)
UpdateFaceExpression has [All(typeof(AvatarBlinkComponent), typeof(AvatarMouthAnimationComponent))]. If either renderer is absent (→ component not added), the query skips that avatar entirely — including the eyebrows update, even if the eyebrows renderer exists. → inline comment

ECS Rule Violations
3. VoiceChatMouthAnimationHandler.SetMouthSpeaking calls world.Add/world.Get from a LiveKit event callback — entity manipulation outside systems is strictly forbidden. → inline comment

4. ChatAvatarMouthService.SetPendingMessage has the same violation — world mutations from a MessageAdded event callback. → inline comment

5. UpdateFaceExpressionInputSystem stores per-frame state in instance fields (triggeredSlot, faceExpressionsActions, etc.) — systems must not hold state. → inline comment

Performance / Architecture
6. VoiceChatMouthAnimationHandler.OnActiveSpeakersUpdated allocates new HashSet<string>() on every speaker-change event — reuse the existing instance with Clear + in-place diffing. → inline comment

7. Facial expression indices packed into GlideState proto field bits 2-13 — fragile coupling between the facial feature and movement protocol layers. Any future addition to GlideStateValue silently corrupts expressions. A defensive assertion + TODO comment would reduce the risk. → inline comment

REVIEW_RESULT: FAIL ❌
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Apr 5, 2026

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 5, 2026

Claude finished @olavra's task in 4m 26s —— View job


PR Review — feat: avatars now blink and talk

  • Gather context
  • Read key changed files
  • Analyze code quality, bugs, performance
  • Post inline comments for issues
  • Summarize findings

The previous review's major issues (ECS violations in VoiceChatMouthAnimationHandler and ChatAvatarMouthService, IndexOutOfRangeException, eyebrows query filter, HashSet allocation) are all resolved. Good work on those.

4 remaining issues found. 2 are bugs/wrong code that must be fixed; 2 carry over from the previous review.

Bugs (must fix)

1. Unused Entity entity parameter + orphaned using Utility.Arch in NametagPlacementSystem (NametagPlacementSystem.cs line 130)
The entity was added in this PR but never used inside ProcessChatBubbleComponents. The import was added alongside it and is also unused. This is leftover from a refactor — mouth animation was originally going to be triggered from here, then moved to ChatAvatarMouthService. → inline comment

2. Wrong writer in AvatarMouthInputComponent docstring (AvatarMouthInputComponent.cs line 12)
PendingMessage/MessageIsDirty are documented as "set by NametagPlacementSystem" but the actual writer is ChatAvatarMouthService. → inline comment

Carried from previous review

3. pendingSlot instance state on system (UpdateFaceExpressionInputSystem.cs line 51)
Still violates CLAUDE.md §5: "Systems must not contain state — all state goes into ECS." The comment documents it as intentional but the rule is clear. Should become a singleton ECS component or at minimum have a // TODO: move to ECS singleton marker. → inline comment

4. AvatarMouthInputQueue thread safety assumption not verified (AvatarMouthInputQueue.cs line 13)
Plain List<T> with no locking. Safe only if LiveKit callbacks fire on the main thread. If they do, the comment should state this explicitly (e.g., // LiveKit SDK guarantees main-thread dispatch). If not, a lock is needed. → inline comment

REVIEW_RESULT: FAIL ❌

Comment thread Explorer/Assets/DCL/NameTags/Systems/NametagPlacementSystem.cs Outdated
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Apr 6, 2026

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 6, 2026

Claude finished @olavra's task in 3m 32s —— View job


PR Review — feat: avatars now blink and talks

  • Gather context from previous reviews
  • Read changed files to check current state
  • Verify previously flagged issues are resolved
  • Check for any new issues
  • Post inline comments for any remaining issues
  • Summarize findings

Good progress — almost all previous issues are resolved. One new bug found.

Previously flagged issues — resolved ✅

All bugs and ECS violations from the two prior reviews are addressed:

  • IndexOutOfRangeException in UpdateFaceExpressionInputSystem — fixed (empty-array guard + early return)
  • Eyebrows query filter ([All(AvatarBlink, AvatarMouth)]) — fixed (query now uses [None(DeleteEntityIntention)] only, with inline World.Has checks)
  • ECS violations in VoiceChatMouthAnimationHandler and ChatAvatarMouthService — fixed (both now enqueue into AvatarMouthInputQueue)
  • HashSet allocation per speaker-change — fixed (two reused sets, in-place diffing)
  • Unused Entity entity param + dead using Utility.Arch in NametagPlacementSystem — removed
  • Wrong writer in AvatarMouthInputComponent docstring — corrected
  • AvatarMouthInputQueue thread safety — fixed (proper lock on all public methods)
  • pendingSlot instance state — carries a // TODO: move to ECS singleton comment as requested

New bug (must fix)

Thread-safety race in VoiceChatMouthAnimationHandleractiveSpeakers and nextActiveSpeakers are modified from two separate LiveKit event callbacks (OnActiveSpeakersUpdated via ActiveSpeakers.Updated, and OnParticipantUpdated via UpdatesFromParticipant) without any lock. The AvatarMouthInputQueue docstring on the same class explicitly states that these callbacks are not guaranteed to fire on the Unity main thread. Concurrent access causes InvalidOperationException: Collection was modified when iterating activeSpeakers in one callback while the other removes from it. OnCallStatusChanged also touches activeSpeakers and calls OnActiveSpeakersUpdated() directly, adding a second race vector. → inline comment

Carried from previous review (non-blocking)

GlideState bit-packing still has no defensive Debug.Assert((int)message.animState.GlideState <= 3, ...) guard. The comments were improved, which reduces the maintenance risk, but the assertion would catch future enum additions at the source. Leaving this as a follow-up TODO is acceptable if it's documented.

REVIEW_RESULT: FAIL ❌

Comment thread Explorer/Assets/DCL/VoiceChat/VoiceChatMouthAnimationHandler.cs Outdated
@olavra
Copy link
Copy Markdown
Member Author

olavra commented Apr 6, 2026

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 6, 2026

Claude finished @olavra's task in 1m 58s —— View job


PR Review — feat: avatars now blink and talks

  • Gather context from previous reviews
  • Read changed files to check current state
  • Verify thread-safety fix in VoiceChatMouthAnimationHandler
  • Check for any new issues
  • Summarize findings

All issues from the three previous reviews are resolved. No new issues found.

Previously flagged issues — all resolved ✅

  • Thread-safety race in VoiceChatMouthAnimationHandler — fixed. speakerLock is now acquired in OnActiveSpeakersUpdated, OnParticipantUpdated, and the disconnect branch of OnCallStatusChanged. The VOICE_CHAT_IN_CALL branch correctly delegates to OnActiveSpeakersUpdated() which acquires the lock internally.
  • AvatarMouthInputQueue thread safety — fixed with syncLock on all public methods.
  • ECS violations in VoiceChatMouthAnimationHandler and ChatAvatarMouthService — both services only enqueue into AvatarMouthInputQueue; AvatarFacialExpressionSystem drains and applies to ECS on the main thread.
  • IndexOutOfRangeException guard — fixed.
  • Eyebrows query filter — fixed (query uses only [None(DeleteEntityIntention)]; World.Has inline checks handle optional blink/mouth components).
  • HashSet allocation per speaker-change — fixed (two reused sets, in-place diffing with UnionWith).
  • Unused Entity entity param + dead using Utility.Arch — removed.
  • AvatarMouthInputComponent docstring — now correctly credits ChatAvatarMouthService.
  • pendingSlot instance state — carries // TODO: move to ECS singleton as requested.

Non-blocking carry-over

GlideState bit-packing (MultiplayerMovementMessageBus.cs line ~212) still has no Debug.Assert((int)message.animState.GlideState <= 3, ...) guard. The bit-layout comment is present and well-documented. Acceptable as a follow-up.

REVIEW_RESULT: PASS ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant