Skip to content

fix: reset stale hand point-at on teleport and scene reload (#9578) - #9693

Closed
lorenzo-ranciaffi wants to merge 3 commits into
devfrom
fix/9578
Closed

fix: reset stale hand point-at on teleport and scene reload (#9578)#9693
lorenzo-ranciaffi wants to merge 3 commits into
devfrom
fix/9578

Conversation

@lorenzo-ranciaffi

@lorenzo-ranciaffi lorenzo-ranciaffi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Pull Request Description

What does this PR change?

A cached absolute point-at world hit-point survived a teleport or same-realm scene reload, leaving an avatar's arm pinned at a stale position. Reset the point-at target from both the PlayerTeleportIntent path and the scene-reload path by using the CurrentScene update in the scene cache..

Test Steps

  1. Point at something and immediately teleport to a different place
  2. Verify the avatar is no longer pointing at anything
  3. Point at something and immediately reload the scene
  4. Verify the avatar is no longer pointing at anything

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

Code Review Reference

Please review our Branch & PR Standards before submitting. It explains the automated review flow, QA/DEV approval requirements, and what each label does — especially useful for first-time contributors.

A cached absolute point-at world hit-point survived a teleport or same-realm
scene reload, leaving an avatar's arm pinned at a stale position. Reset the
point-at target from both the PlayerTeleportIntent path and the scene-reload
path (ECSReloadScene, both overloads).

Verified on real Unity 6000.4.0f1 (compile + EditMode/PlayMode regression tests).
@lorenzo-ranciaffi lorenzo-ranciaffi self-assigned this Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 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 24bb9de
Logs https://github.com/decentraland/unity-explorer/actions/runs/31599705747
Download Windows https://github.com/decentraland/unity-explorer/suites/85717507999/artifacts/9143917789
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/9578/pr-24908-24bb9de/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/85717507999/artifacts/9144203952
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/9578/pr-24908-24bb9de/Decentraland_macos.zip
Built on 2026-08-12T13:54:39Z

Lint

Warnings not reduced: 13292 => 13296 — remove at least 5 warnings to merge.

Warnings/errors in files changed by this PR (13)
Assets/DCL/PluginSystem/Global/CharacterMotionPlugin.cs:54  CSharpWarnings::CS8618  Non-nullable fields 'settings', 'gliderPropPrefab', 'pointAtMarkerPool', 'destinationMarkerPrefab' must contain non-null values when exiting constructor. Consider adding the 'required' modifiers or declaring the fields as nullable.
Assets/DCL/Character/CharacterMotion/Systems/HandPointAtSystem.cs:296  InconsistentNaming  Name 'ApplyPointAtIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'ApplyPointAtIk'.
Assets/DCL/Character/CharacterMotion/Systems/HandPointAtSystem.cs:33  InconsistentNaming  Name 'isFreshPress' does not match rule 'members_should_be_pascal_case'. Suggested name is 'IsFreshPress'.
Assets/DCL/Character/CharacterMotion/Systems/HandPointAtSystem.cs:32  InconsistentNaming  Name 'isPressed' does not match rule 'members_should_be_pascal_case'. Suggested name is 'IsPressed'.
Assets/DCL/Character/CharacterMotion/Systems/HandPointAtSystem.cs:34  InconsistentNaming  Name 'pointerPos' does not match rule 'members_should_be_pascal_case'. Suggested name is 'PointerPos'.
Assets/DCL/PluginSystem/Global/CharacterMotionPlugin.cs:37  NotAccessedField.Local  Field 'realmData' is assigned but its value is never used
Assets/DCL/PluginSystem/Global/CharacterMotionPlugin.cs:84  ParameterHidesMember  Parameter 'settings' hides field 'DCL.PluginSystem.Global.CharacterMotionPlugin.settings'
Assets/DCL/Character/CharacterMotion/Tests/HandPointAtStaleResetShould.cs:44  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Character/CharacterMotion/Tests/HandPointAtStaleResetShould.cs:55  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Character/CharacterMotion/Tests/HandPointAtStaleResetShould.cs:66  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Character/CharacterMotion/Tests/HandPointAtStaleResetShould.cs:76  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Infrastructure/SceneLifeCycle/ECSReloadScene.cs:10  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/CharacterMotionPlugin.cs:18  RedundantUsingDirective  Using directive is not required by the code and can be safely removed

Tests

All Unity tests passed ✅

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

@lorenzo-ranciaffi
lorenzo-ranciaffi marked this pull request as ready for review August 12, 2026 13:06
@lorenzo-ranciaffi
lorenzo-ranciaffi requested review from a team as code owners August 12, 2026 13:06
@github-actions
github-actions Bot requested a review from DafGreco August 12, 2026 13:06
@decentraland-bot
decentraland-bot self-requested a review August 12, 2026 13:06

@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.

Review — PR #9693: fix: reset stale hand point-at on teleport and scene reload (#9578)

STEP 1 — Context

Subsystem: Character Motion (hand point-at IK system)
Files: HandPointAtComponent.cs, HandPointAtSystem.cs, HandPointAtStaleResetShould.cs (new), ECSReloadScene.cs, CharacterMotionPlugin.cs
Diff size: +157 −3 across 6 files (including test + meta)

Reviewed against CLAUDE.md, docs/README.md, and surrounding files: TeleportCharacterSystem.cs, UpdateCurrentSceneSystem.cs, IScenesCache.cs, ReactiveProperty.cs, SmartWearableSystem.cs, BaseUnityLoopSystem.cs, CharacterMotionPlugin.cs.


STEP 2 — Root-cause check: PASS ✅

Problem: A cached absolute WorldHitPoint survived a teleport or same-realm scene reload, leaving the avatar's arm pinned at a stale world-space position.

Fix: Resets IsPointing and duration via two distinct ECS queries:

  1. TeleportResetPointAtOnTeleport matches entities with PlayerTeleportIntent, clearing point-at while the teleport is in flight.
  2. Scene teardownResetPointAtOnCurrentSceneLost fires when CurrentScene transitions to null (scene reload, ban, realm change), bridged via a one-frame latch set in the OnUpdate callback.

This addresses the root cause — the hit-point was never invalidated when the player's spatial context changed. The fix is not a symptom workaround.


STEP 3 — Design & integration: PASS ✅

Owner search:

  • HandPointAtComponent is created by CharacterMotionPlugin.InjectToWorld (line ~147) on the player entity.
  • HandPointAtComponent state is mutated exclusively by HandPointAtSystem — confirmed via rg "ref HandPointAtComponent" (only HandPointAtSystem queries, plus RemoteHandPointAtSystem for remote avatars).
  • The teleport lifecycle is owned by TeleportCharacterSystem (in ChangeCharacterPositionGroup). It manages position, collisions, and readiness reports — it has no knowledge of hand IK and should not.
  • The scene lifecycle is owned by UpdateCurrentSceneSystem (in RealmGroup), which calls scenesCache.SetCurrentScene(). The CurrentScene.OnUpdate callback pattern is the established bridge for downstream consumers.

Verdict: HandPointAtSystem is the correct owner for this reset logic. Moving it to TeleportCharacterSystem or scene lifecycle code would create cross-cutting concerns in systems that don't own HandPointAtComponent.

Callback+latch pattern: The currentSceneLost bool latch (set in OnCurrentSceneChanged, consumed in Update) is the identical pattern used by SmartWearableSystem.currentSceneDirty (line 313-314) and DonationsService. It correctly bridges a synchronous event callback into ECS frame processing without mutating components outside Update(). Acceptable precedented deviation from the "no system state" rule — this is a transient one-frame flag, not persistent membership.

Teardown trace:

  • scenesCache.CurrentScene.OnUpdate += OnCurrentSceneChanged — constructor (line 67)
  • scenesCache.CurrentScene.OnUpdate -= OnCurrentSceneChangedOnDispose() (line 77)
  • ✅ Properly paired. BaseUnityLoopSystem.Dispose() guarantees OnDispose() runs exactly once.

STEP 4 — Member audit

StopPointing() (new public method on HandPointAtComponent):

  • Consumers: 2 — ResetPointAtOnCurrentSceneLost and ResetPointAtOnTeleport.
  • Justification: Combines IsPointing = false + RefreshDuration(0f) — a meaningful grouping. Differs from ForceStopAction() (which also sets ForceStop = true to prevent re-trigger while button held). Not single-use-to-merge — it has two independent callers.
  • ✅ Well-scoped, intent-named.

WorldHitPoint not cleared by StopPointing():

  • Every reader gates on IsPointing first: ApplyPointAtIK (line 305), TorsoIKSystem (line 49/59), PointAtMarkerSystem (lines 90/132/149), HeadIKSystem (line 192), HandsIKSystem (line 91/99).
  • Network path (PlayerMovementNetSendSystem) also gates on isPointingAt.
  • ✅ Stale WorldHitPoint is never observable when IsPointing is false.

STEP 5 — Line-level review

No blocking issues found. All code follows project conventions:

  • Queries correctly filter [None(typeof(DeleteEntityIntention))]
  • Constructor is internal (CLAUDE.md §1/§10)
  • No LINQ, no allocations in Update path
  • ref access for component mutation
  • Event subscribe/unsubscribe properly paired
  • _ = Resources.UnloadUnusedAssets() is a correct discard of AsyncOperation return value (suppresses CS4014)

Coverage note (non-blocking): For instant teleports (loadReport == null), TeleportCharacterSystem (in ChangeCharacterPositionGroup) resolves and removes PlayerTeleportIntent before HandPointAtSystem (in PresentationSystemGroup, UpdateAfter(ChangeCharacterPositionGroup)) runs. The teleport reset query would not observe the intent in that frame. This is not a regression — existing movement guards (IsGrounded set to false on teleport, velocity checks) naturally suppress pointing within 1-2 frames. The PR targets the async teleport path (realm/portal) where the intent persists across multiple pending frames.


STEP 6 — Complexity

COMPLEX — Adds a new dependency (IScenesCache) to an ECS system, introduces an event subscription with lifecycle management, adds two new ECS queries with archetype filters, and modifies system execution order in Update().


STEP 7 — QA assessment

QA_REQUIRED: YES — Changes affect user-visible pointing behavior during teleport and scene reload. Manual verification needed per the PR's test steps.


STEP 8 — Non-blocking warnings

No warnings. Main.unity is not modified.


STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Adds IScenesCache dependency and CurrentScene event subscription to HandPointAtSystem, introduces two new ECS queries modifying point-at component state on teleport and scene teardown.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9693, run #31610131253

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.

Apple M1

Metric Baseline Change Δ Result
Samples 4066 (×3) 4068 (×3)
CPU average 22.1 ms (21.9–22.1) 22.1 ms (21.9–23.1) -0.0 ms ⚪ within noise
CPU 1% worst 231.3 ms (196.1–233.3) 231.6 ms (203.6–233.9) 0.3 ms ⚪ within noise
CPU 0.1% worst 233.6 ms (231.9–238.3) 237.3 ms (233.3–240.3) 3.6 ms ⚪ within noise
GPU average 2.5 ms (1.9–2.7) 3.5 ms (2.5–9.1) 1.0 ms ⚪ within noise
GPU 1% worst 34.6 ms (34.2–35.5) 34.5 ms (34.3–37.4) -0.1 ms ⚪ within noise
GPU 0.1% worst 35.7 ms (35.2–37.2) 35.4 ms (35.0–38.0) -0.3 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

Intel Core i5

Metric Baseline Change Δ Result
Samples 2331 (×3) 2361 (×3)
CPU average 38.3 ms (36.6–38.8) 38.0 ms (37.2–38.4) -0.3 ms ⚪ within noise
CPU 1% worst 380.0 ms (330.7–396.1) 303.4 ms (298.9–320.8) -76.6 ms 🟢 20% faster
CPU 0.1% worst 411.3 ms (351.0–424.5) 325.3 ms (321.3–338.3) -86.0 ms 🟢 21% faster
GPU average 8.0 ms (8.0–8.0) 9.4 ms (9.3–9.6) 1.4 ms 🔴 18% slower
GPU 1% worst 18.8 ms (18.6–19.3) 33.7 ms (32.2–35.2) 14.9 ms 🔴 79% slower
GPU 0.1% worst 19.2 ms (19.0–20.2) 39.6 ms (39.3–40.6) 20.5 ms 🔴 107% slower
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

NickKhalow added a commit that referenced this pull request Aug 13, 2026
…9705 #9706) (#9707)

* opti(avatar-rendering): bound bone-matrix job by each avatar's actual bone count

BoneMatrixCalculationJob recomputed all MAX_BONE_COUNT matrices per avatar every frame regardless of how many ComputeSkinning actually uploads; refresh and pass through the authoritative per-avatar count so the job only computes the uploaded range.

* opti(transforms): assign world cache directly in SetWorldTransform

Transform.SetPositionAndRotation is world-authoritative, so the resulting world pose equals the arguments passed in; skip the native position/rotation readback and assign the cache from them directly.

* opti(landscape): reuse persistent native containers in RenderGroundSystem

RenderGroundInternal allocated and disposed a NativeArray<int> and a NativeList<Matrix4x4> with Allocator.TempJob every frame the ground was visible; hold them as Allocator.Persistent fields instead, clearing them each frame and disposing once in OnDispose.

* opti(web-requests): pre-size PartialDownloadHandler and grow geometrically

ReceiveData rented exactly PartialData.Length + dataLength and copied the full old buffer on every regrow, causing O(N^2) rent+memcpy churn over a chunked download. Pre-size the buffer from the Content-Length header when available, and otherwise double the buffer on regrow, copying only the bytes written so far.

Exercising the growth path requires driving DownloadHandlerScript's native callbacks, which has no stable unit-test seam in headless batch mode.

* opti(comms-profiles): skip the remove-intentions lock on empty frames

RemoteEntitiesExtensions.Remove ran every frame and unconditionally built an OwnedBunch<RemoveIntention>, whose ctor acquires MutexSync even when there is nothing to remove. Add a racy, lock-free NewBunchAvailable() pre-check (mirrors RemoteProfiles.NewBunchAvailable()) so the lock is skipped entirely on empty frames.

* fix: reset stale hand point-at on teleport and scene reload (#9578)

A cached absolute point-at world hit-point survived a teleport or same-realm
scene reload, leaving an avatar's arm pinned at a stale position. Reset the
point-at target from both the PlayerTeleportIntent path and the scene-reload
path (ECSReloadScene, both overloads).

Verified on real Unity 6000.4.0f1 (compile + EditMode/PlayMode regression tests).

* opti(avatar-rendering): hoist frustum-plane extraction out of the outline query

GetAvatarsVisibleWithOutline recalculated the camera's frustum planes and re-fetched the camera component for every avatar; both are identical across a frame, so compute them once in Update and pass the camera in via [Data].

* opti(avatar-animation): skip redundant point-at/rotation layer weight writes

SetPointAtLayerWeight and SetRotationLayerWeight now shadow the last value written and skip the native Animator.SetLayerWeight call when it hasn't changed; ResetState clears the shadow so a rebind isn't skipped.

* refactor(landscape): replace init flag with nullable tuple in RenderGroundSystem

* docs(landscape): explain why persistent ground containers never need reallocation

* Fix formatting (no code changes)

* changed how reload/teleport reset is managed

* opti(avatar-animation): finish indexed animator-layer API and drop string-based lookups

Completes the layer-index scaffolding introduced with the weight guard:

- AnimatorEmoteLayers gains the BASE_LAYER_INDEX constant the scaffolding
  referenced (fixes compilation) and loses GetFromEmoteMask plus the
  unused ALL_LAYERS / NON_BASE_LAYERS arrays.
- GetAnimatorCurrentStateTag(string) and SetLayerWeight(string) resolved
  the layer via a native Animator.GetLayerIndex call on every invocation,
  per frame in HeadIKSystem and HandsIKSystem. All callers now use the
  cached index API (UpperBodyLayerIndex, GetEmoteLayerIndex, int
  overloads) and the string overloads are removed.
- Removes the unused DclAvatarMask using alias.

* Update Explorer/Assets/DCL/Multiplayer/Profiles/RemoveIntentions/IRemoveIntentions.cs

Co-authored-by: Muna <44584806+decentraland-bot@users.noreply.github.com>
Signed-off-by: Nick Khalow <71646502+NickKhalow@users.noreply.github.com>

* docs(transforms): explain direct world cache write in SetWorldTransform

* expected length "no double calculation" and comments

* refactor(avatar-rendering): drop camera param from IsVisibleInCamera, pass CameraComponent as query data

---------

Signed-off-by: Nick Khalow <71646502+NickKhalow@users.noreply.github.com>
Co-authored-by: Esteban Ordano <esteban@decentraland.org>
Co-authored-by: lorenzo-ranciaffi <lorenzo.ranciaffi@decentraland.org>
Co-authored-by: lorenzo-ranciaffi <41125365+lorenzo-ranciaffi@users.noreply.github.com>
Co-authored-by: Muna <44584806+decentraland-bot@users.noreply.github.com>
@lorenzo-ranciaffi

Copy link
Copy Markdown
Contributor Author

introduced in #9707

@lorenzo-ranciaffi
lorenzo-ranciaffi deleted the fix/9578 branch August 14, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-warning-ratchet Prevent CI linting checks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants