Skip to content

perf: emote optimizations — scene-emote URN prefix match + animator layer helpers - #9740

Open
eordano wants to merge 2 commits into
devfrom
perf/emote-optimizations
Open

perf: emote optimizations — scene-emote URN prefix match + animator layer helpers#9740
eordano wants to merge 2 commits into
devfrom
perf/emote-optimizations

Conversation

@eordano

@eordano eordano commented Aug 13, 2026

Copy link
Copy Markdown
Member

Recovers the emote optimizations that were deferred out of the consolidated #9685 during its resync onto dev. Dev's perf roll-up (#9707) re-implemented much of #9685's perf work, but not these — they're genuinely unique, so they get their own focused PR here.

What

  • Scene-emote URN prefix matchCharacterEmoteSystem.TryMatchSceneEmote, avoids re-scanning on scene-emote lookups.
  • Animator layer helpersAnimatorEmoteLayers: BASE_LAYER, ALL_LAYERS, GetFromEmoteMask, alongside dev's existing BASE_LAYER_INDEX.
  • Supporting perf tests: CharacterEmoteSystemPerformanceTest, AvatarAnimatorLayerIndexCachePerformanceTest, SceneEmoteUrnPrefixMatchPerformanceTest.

11 files, +412/−8, based on current dev.

Note

The bot's P2 on EmotePlayer (emotesInUse.Add before early-return → tracking asymmetry) from #9685 lives in this area — worth folding the fix in here.

Part of the dataroom 1+2 decomposition (see #9685).

@eordano
eordano requested review from a team as code owners August 13, 2026 19:08
@github-actions
github-actions Bot requested a review from DafGreco August 13, 2026 19:08
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Aug 13, 2026
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit 17e4f37
Logs https://github.com/decentraland/unity-explorer/actions/runs/31735634320
Download Windows https://github.com/decentraland/unity-explorer/suites/86097777281/artifacts/9196256927
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/perf/emote-optimizations/pr-25068-17e4f37/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/86097777281/artifacts/9196169977
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/perf/emote-optimizations/pr-25068-17e4f37/Decentraland_macos.zip
Built on 2026-08-13T20:05:26Z

Lint

Warnings not reduced: 13169 => 13173 — remove at least 5 warnings to merge.

Warnings/errors in files changed by this PR (25)
Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs:545  CSharpWarnings::CS8604  Possible null reference argument for parameter 'walletId' in 'DCL.Multiplayer.Emotes.IEmotesMessageBus.OnPlayerRemoved'
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:215  CompareOfFloatsByEqualityOperator  Equality comparison of floating point numbers. Possible loss of precision while rounding values
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:222  CompareOfFloatsByEqualityOperator  Equality comparison of floating point numbers. Possible loss of precision while rounding values
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:47  InconsistentNaming  Name 'FeetIKRig' does not match rule 'members_should_be_pascal_case'. Suggested name is 'FeetIkRig'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:65  InconsistentNaming  Name 'HandsIKRig' does not match rule 'members_should_be_pascal_case'. Suggested name is 'HandsIkRig'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:79  InconsistentNaming  Name 'HeadIKRig' does not match rule 'members_should_be_pascal_case'. Suggested name is 'HeadIkRig'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:66  InconsistentNaming  Name 'LeftHandIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'LeftHandIk'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:59  InconsistentNaming  Name 'LeftLegIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'LeftLegIk'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:55  InconsistentNaming  Name 'LeftLegIKTarget' does not match rule 'members_should_be_pascal_case'. Suggested name is 'LeftLegIkTarget'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:26  InconsistentNaming  Name 'RandomID' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RandomId'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:73  InconsistentNaming  Name 'RightHandIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RightHandIk'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:58  InconsistentNaming  Name 'RightLegIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RightLegIk'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:53  InconsistentNaming  Name 'RightLegIKTarget' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RightLegIkTarget'.
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:90  InconsistentNaming  Name 'TorsoIKRig' does not match rule 'members_should_be_pascal_case'. Suggested name is 'TorsoIkRig'.
Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs:467  InconsistentNaming  Name 'headIK' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'headIk'.
Assets/DCL/Tests/PlayMode/PerformanceTests/SceneEmoteUrnPrefixMatchPerformanceTest.cs:60  InconsistentNaming  Name 'probePayload' does not match rule 'constants_should_be_capital_snake_case'. Suggested name is 'PROBE_PAYLOAD'.
Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs:51  NotAccessedField.Local  Field 'debugContainerBuilder' is assigned but its value is never used
Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs:112  RedundantNameQualifier  Qualifier is redundant
Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs:232  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/AvatarRendering/Emotes/Tests/PerformanceTests/CharacterEmoteSystemPerformanceTest.cs:105  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/AvatarRendering/Emotes/Tests/PerformanceTests/CharacterEmoteSystemPerformanceTest.cs:113  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs:11  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs:13  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/AvatarRendering/Emotes/Tests/PerformanceTests/CharacterEmoteSystemPerformanceTest.cs:1  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs:155  UnusedMember.Local  Method 'Awake' is never used

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24954 0 13
PlayMode ✅ Passed 236 0 36

@github-actions

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@decentraland-bot decentraland-bot left a comment

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.

PR Review: perf: emote optimizations — scene-emote URN prefix match + animator layer helpers

STEP 2 — Root-cause check: ✅ PASS

This PR solves three genuine performance/correctness problems:

  1. UpdateEmoteTags over-polling — The query polled native Mecanim GetAnimatorCurrentStateTag on every entity carrying a CharacterEmoteComponent, including idle avatars. The new guard (CurrentEmoteReference == null && CurrentAnimationTag == 0) correctly skips entities that have no emote state to track, avoiding native interop overhead in crowded scenes.
  2. Scene-emote prefix-match allocationTryResolveSceneByNamePrefix allocated a new string (candidateName + "-") on every candidate iteration. The extracted TryMatchSceneEmotePayload uses span-based comparison that is allocation-free on the non-matching path.
  3. emotesInUse tracking asymmetryemotesInUse.Add was placed after early-return paths in Play/PlayMasked, so Stop() (which calls emotesInUse.Remove) could not find the reference to release it back to the pool. This was a real resource leak.

All three fixes address root causes, not symptoms.

STEP 3 — Design & integration: ✅ PASS

No new long-lived units are introduced. The changes are:

  • A guard added to an existing ECS query method (UpdateEmoteTags)
  • A static helper extracted from an existing static method (TryResolveSceneByNamePrefixTryMatchSceneEmotePayload)
  • A line reorder within an existing class (EmotePlayer)
  • Constants/helpers added to an existing static utility class (AnimatorEmoteLayers)
  • A method overload added to an existing concrete class (AvatarBase)

The TryMatchSceneEmotePayload extraction is appropriate: it enables direct unit testing of the prefix-match logic without constructing ISceneFacade instances, and its internal static visibility correctly limits its scope.

Teardown/consumption trace: No new subscriptions, event hookups, connections, or buffers are introduced. The emotesInUse.Add reorder is purely a fix to an existing lifecycle.

STEP 4 — Member audit

Member Consumers Verdict
AnimatorEmoteLayers.BASE_LAYER 2 (perf test only) P2 — no production consumer
AnimatorEmoteLayers.ALL_LAYERS 0 P2 — dead code
AnimatorEmoteLayers.NON_BASE_LAYERS 0 P2 — dead code
AnimatorEmoteLayers.GetFromEmoteMask 0 P2 — dead code
AvatarBase.GetAnimatorCurrentStateTag(string) 2 (perf test only) P2 — not on IAvatarView, no production consumer
CharacterEmoteSystem.TryMatchSceneEmotePayload 1 production + 1 test ✅ justified

STEP 5 — Line-level findings

See inline comments below. All findings are P2.

STEP 6 — Complexity: COMPLEX

Touches emote ECS system queries, animation pipeline helpers, and emote pooling lifecycle — all performance-sensitive runtime paths.

STEP 7 — QA: YES

Modifies runtime emote playback code (animator polling, emote pool tracking) that directly affects avatar animations visible to users.

STEP 8 — Non-blocking warnings

None.

Security review

No security issues found. All changes are internal Unity C# animation/emote system optimizations with no network I/O, user input parsing, auth, or file operations.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches emote ECS system queries (CharacterEmoteSystem.UpdateEmoteTags), animation utility infrastructure (AnimatorEmoteLayers), and emote pool lifecycle (EmotePlayer.emotesInUse)
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown via Slack

Comment on lines +1 to +21
using DCL.ECSComponents;

namespace Utility.Animations
{
public static class AnimatorEmoteLayers
{
// Unity's Animator always places the base layer at index 0.
public const string BASE_LAYER = "Base Layer";
public const string UPPER_BODY_LAYER = "Upper Body Layer";

public const int BASE_LAYER_INDEX = 0;

public const string UPPER_BODY_LAYER = "Upper Body Layer";
public static readonly string[] ALL_LAYERS =
{
BASE_LAYER,
UPPER_BODY_LAYER,
};

public static readonly string[] NON_BASE_LAYERS =
{
UPPER_BODY_LAYER,
};

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.

[P2] Dead infrastructure — ALL_LAYERS, NON_BASE_LAYERS, and using DCL.ECSComponents have zero production consumers. ALL_LAYERS and NON_BASE_LAYERS are not referenced by any code outside this file, and the using DCL.ECSComponents import is only needed by the equally unused GetFromEmoteMask below. Adding unused infrastructure pulls the Protocol assembly dependency into Utility for no runtime benefit. Per CLAUDE.md §11 (anti-patterns), unused code should not be added speculatively — add these when the consumer that needs them arrives.

Suggested change
using DCL.ECSComponents;
namespace Utility.Animations
{
public static class AnimatorEmoteLayers
{
// Unity's Animator always places the base layer at index 0.
public const string BASE_LAYER = "Base Layer";
public const string UPPER_BODY_LAYER = "Upper Body Layer";
public const int BASE_LAYER_INDEX = 0;
public const string UPPER_BODY_LAYER = "Upper Body Layer";
public static readonly string[] ALL_LAYERS =
{
BASE_LAYER,
UPPER_BODY_LAYER,
};
public static readonly string[] NON_BASE_LAYERS =
{
UPPER_BODY_LAYER,
};
namespace Utility.Animations
{
public static class AnimatorEmoteLayers
{
public const string BASE_LAYER = "Base Layer";
public const string UPPER_BODY_LAYER = "Upper Body Layer";
public const int BASE_LAYER_INDEX = 0;

Comment on lines 23 to 30
public static string GetFromEmoteMask(AvatarEmoteMask mask) =>
mask switch
{
AvatarEmoteMask.AemFullBody => BASE_LAYER,
AvatarEmoteMask.AemUpperBody => UPPER_BODY_LAYER,
_ => BASE_LAYER,
};
}

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.

[P2] Dead infrastructure — GetFromEmoteMask has zero consumers in the codebase. Additionally, the switch body has inconsistent indentation (extra leading spaces on the switch expression). Remove until a consumer exists; if kept, fix the indentation to match project style.

Suggested change
public static string GetFromEmoteMask(AvatarEmoteMask mask) =>
mask switch
{
AvatarEmoteMask.AemFullBody => BASE_LAYER,
AvatarEmoteMask.AemUpperBody => UPPER_BODY_LAYER,
_ => BASE_LAYER,
};
}
}
}

Comment on lines +259 to +263
public int GetAnimatorCurrentStateTag(string layerName)
{
int layerIndex = AvatarAnimator.GetLayerIndex(layerName);
return AvatarAnimator.GetCurrentAnimatorStateInfo(layerIndex).tagHash;
}

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.

[P2] Dead production code — GetAnimatorCurrentStateTag(string) is not declared on the IAvatarView interface and has no production consumer — only the perf test (AvatarAnimatorLayerIndexCachePerformanceTest) calls it on the concrete AvatarBase. Since all production code goes through IAvatarView, this overload is unreachable outside tests. Consider removing it and having the perf test call AvatarAnimator.GetLayerIndex + the int overload directly, or defer adding it until a production consumer exists.

Suggested change
public int GetAnimatorCurrentStateTag(string layerName)
{
int layerIndex = AvatarAnimator.GetLayerIndex(layerName);
return AvatarAnimator.GetCurrentAnimatorStateInfo(layerIndex).tagHash;
}

@eordano eordano added the force-build Used to trigger a build on draft PR label Aug 13, 2026
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9740, run #31739159760

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2287 (×3) 2413 (×3)
CPU average 39.2 ms (36.8–39.3) 37.1 ms (36.5–37.8) -2.1 ms ⚪ within noise
CPU 1% worst 378.9 ms (366.5–380.5) 287.5 ms (284.9–293.0) -91.3 ms 🟢 24% faster
CPU 0.1% worst 395.9 ms (389.0–396.8) 310.3 ms (303.8–312.1) -85.6 ms 🟢 22% faster
GPU average 9.5 ms (9.4–9.6) 9.4 ms (9.3–9.4) -0.1 ms ⚪ within noise
GPU 1% worst 40.2 ms (39.7–40.3) 30.2 ms (29.5–31.7) -10.0 ms 🟢 25% faster
GPU 0.1% worst 48.7 ms (44.9–49.0) 37.5 ms (36.2–39.0) -11.2 ms 🟢 23% faster
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4021 (×3) 4025 (×3)
CPU average 22.3 ms (21.7–23.2) 22.2 ms (21.9–23.1) -0.0 ms ⚪ within noise
CPU 1% worst 230.5 ms (224.7–232.1) 228.4 ms (217.1–231.0) -2.1 ms ⚪ within noise
CPU 0.1% worst 238.5 ms (234.0–239.6) 237.2 ms (236.2–239.3) -1.3 ms ⚪ within noise
GPU average 6.9 ms (2.7–7.2) 2.3 ms (2.1–5.0) -4.7 ms 🟢 68% faster
GPU 1% worst 35.3 ms (33.7–37.2) 35.3 ms (34.6–36.5) -0.0 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (35.1–38.0) 36.6 ms (36.2–37.7) 0.3 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

… default gate

AvatarBase.GetAnimatorCurrentStateTag(string) and AnimatorEmoteLayers'
ALL_LAYERS / NON_BASE_LAYERS / GetFromEmoteMask existed only to give a
performance test something to benchmark against: zero production call sites, so
two shipped classes carried API nobody owns. Both files are byte-identical to
their pre-PR state again, and the benchmark resolves the string layer path
through the Animator it already holds.

All three behaviour changes shipped only under [Category("Performance")].
test.yml runs the standard EditMode/PlayMode matrix with -testCategory
"!Performance", and the only workflow that runs Performance tests is gated
behind workflow_dispatch / the perf_test label, so the default PR gate covered
none of them. CharacterEmoteSystemShould now covers the UpdateEmoteTags
early-out (no poll for idle avatars, one poll while a reference is held, and
polling continues until a stale tag clears) and TryMatchSceneEmotePayload's
match/reject contract.

The emotesInUse.Add move in EmotePlayer.Play/PlayMasked is a leak fix, not a
reshuffle: on the !legacyAnimationsEnabled path and the masked-legacy-failure
path, Stop(emoteReferences) ran before the entry existed, so emotesInUse.Remove
returned false and pool.Release was skipped, stranding the pooled
EmoteReferences GameObject on the avatar. EmotePlayerShould drives both paths
and asserts the instance ends up back in the pool hierarchy.

The guard's safety rests on untagged Mecanim states reporting tagHash 0 while
CharacterEmoteComponent.Reset() leaves currentAnimationTag untouched; that
invariant is now stated at the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018c638dR1vPysCMbYt2qQg5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer 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