Skip to content

feat: PointerEvents max_distance is player distance; add max_camera_distance - #9902

Draft
robtfm wants to merge 2 commits into
devfrom
feat/max-camera-distance
Draft

feat: PointerEvents max_distance is player distance; add max_camera_distance#9902
robtfm wants to merge 2 commits into
devfrom
feat/max-camera-distance

Conversation

@robtfm

@robtfm robtfm commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Pull Request Description

What does this PR change?

Implements decentraland/protocol#470 for PBPointerEvents.Info:

  • max_distance is the player-distance threshold (this is what the explorer has effectively measured since 2024 — avatar head in every mode except first-person — and what deployed scenes rely on; the protocol comment now says so instead of "camera distance").
  • max_player_distance is a deprecated alias for max_distance; when both are set the larger wins.
  • New max_camera_distance: the camera-origin check (ray length), for scenes driving a virtual camera away from the avatar.
  • Combination rules keep their shape: only player → player check; only camera → camera check; both → OR; neither → player distance ≤ 10.

Fixes #9320: PrepareDefaultValues wrote MaxDistance = 10 / MaxPlayerDistance = 0 onto the live message, and the generated setters flip the Has* presence bits, so every entry looked like it had both fields and only the OR branch of IsQualifiedByDistance ever ran (the "only max_player_distance" branch was dead code). The fallback now lives in IsQualifiedByDistance.

Changes:

  • InteractionInputUtils.IsQualifiedByDistance (cursor overload): resolves the player and camera thresholds from field presence, then the 4-way rule. Proximity overload untouched.
  • InteractionInputUtils.PrepareDefaultValues: no longer touches the distance fields.
  • PlayerOriginatedRaycastSystem: scene-entity SetupHit gets hitInfo.distance as the camera distance in every camera mode (previously mode-dependent). Global-entity path unchanged.
  • PointerEvents.gen.cs regenerated from the Feat: worlds compatibility chat commands #470 protocol (scripts/npm run build-protocol); scripts/package.json still pins the older npm release — bump it once Feat: worlds compatibility chat commands #470 is published.
  • InteractionInputUtilsShould.QualifyByDistance covers the four rules and the alias.

Behaviour deltas to be aware of: first-person maxDistance now measures from the avatar root to the hit point rather than along the ray from the near plane; scenes setting only maxPlayerDistance no longer get an implicit || rayDistance <= 10.

Test scene: pointer-camera-distance-scene (cubes with maxDistance: 2, maxPlayerDistance: 2, maxCameraDistance: 5, keyed virtual cameras) — same scene used to verify the bevy side in decentraland/bevy-explorer#1158. SDK side: decentraland/js-sdk-toolchain#1560.

🤖 Generated with Claude Code

…stance

Follow decentraland/protocol#470: max_distance (and its deprecated alias
max_player_distance, larger wins) is the player-distance threshold, and
the new max_camera_distance is the camera-distance one. Combination:
only player, only camera, both (OR), neither (player <= 10).

PrepareDefaultValues no longer writes MaxDistance/MaxPlayerDistance onto
the live message: the generated setters flip the Has* presence bits, so
every entry looked like it had both fields and only the OR branch ever
ran (#9320). The fallback lives in IsQualifiedByDistance instead.

Scene-entity raycast results now carry the ray length as the camera
distance in every camera mode; the global-entity path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robtfm
robtfm requested review from a team as code owners August 28, 2026 16:37
@github-actions
github-actions Bot requested a review from anicalbano August 28, 2026 16:37
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Aug 28, 2026
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

No C# files changed — lint ratchet skipped.

Tests

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

⚠️ PlayMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (playmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ⚠️ No results

@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: fix: PointerEvents max_distance is player distance; add max_camera_distance


STEP 1 — Context & Scope

Files in diff:

  • InteractionInputUtils.cs — core distance-qualification rewrite + PrepareDefaultValues cleanup
  • InteractionInputUtilsShould.cs — new unit tests for the 4-way distance rules
  • PlayerOriginatedRaycastSystem.cs — scene-entity SetupHit now passes hitInfo.distance (ray length)
  • PointerEvents.gen.cs — regenerated protobuf with MaxCameraDistance (field 8)

Surrounding context read (not in diff):

  • PlayerOriginatedProximitySystem.cs — proximity overlap + GetMaxDistanceAndHighestPriority (line 217)
  • ProcessPointerEventsSystem.cs — main consumer calling PrepareDefaultValues + both IsQualifiedByDistance overloads (lines 306-310)
  • HoverFeedbackUtils.cs — calls IsQualifiedByDistance for hover-leave events (line 30)
  • PlayerOriginRaycastResultForSceneEntities.cs — struct with float? DistanceToPlayer and float GetDistance()
  • ProximityResultForSceneEntities.cs — class with non-nullable float DistanceToPlayer
  • PlayerInteractionEntity.csPlayerPosition nullable when CharacterController absent

Docs: CLAUDE.md, docs/README.md, review-instructions prompt.


STEP 2 — Root-Cause Check

Problem: The protocol (decentraland/protocol#470) redefines max_distance as player distance (matching what the explorer has effectively measured since 2024), deprecates max_player_distance as an alias, and adds a new max_camera_distance for the camera-origin check. The old code also had a bug (#9320): PrepareDefaultValues wrote MaxDistance = 10 / MaxPlayerDistance = 0, flipping Has* presence bits, so every entry appeared to have both fields and only the OR branch of IsQualifiedByDistance ever ran.

Does the diff fix the cause? Yes for the cursor/raycast path — the rewrite correctly resolves player and camera thresholds from field presence and moves the default out of PrepareDefaultValues into the (null, null) fallback. However, the proximity interaction path (PlayerOriginatedProximitySystem + proximity overload of IsQualifiedByDistance) still reads only MaxPlayerDistance, which is now the deprecated field. This is an incomplete application of the semantic change — see P1 below.

STEP 2 verdict: PARTIAL — cursor path fixed, proximity path not updated.


STEP 3 — Design & Integration

No new long-lived units are introduced. The change is a semantic rewrite within existing static helpers and an existing system. Design is sound for the cursor path.

Teardown/consumption trace: No new subscriptions, events, or resources are opened. The protobuf field MaxCameraDistance is purely a data read. ✅


STEP 4 — Member Audit

  • DEFAULT_MAX_DISTANCE (new public const, line 13): Used by the (null, null) fallback in IsQualifiedByDistance (1 consumer). Appropriate — documents the protocol default and avoids a magic number.

STEP 5 — Line-Level Findings

P1 — Proximity system not updated for new max_distance semantics

Locations (not in diff):

  • PlayerOriginatedProximitySystem.cs line 233 — GetMaxDistanceAndHighestPriority()
  • InteractionInputUtils.cs lines 62-67 — proximity overload of IsQualifiedByDistance

Problem: This PR redefines max_distance as the canonical player-distance field and deprecates max_player_distance as an alias. The cursor overload correctly resolves both fields (taking the max when both present). The proximity code path has two gaps:

  1. GetMaxDistanceAndHighestPriority (line 233) reads info.MaxPlayerDistance directly (deprecated field only, no Has check). When a scene uses only max_distance: 5 for proximity events, MaxPlayerDistance returns 0 (proto default), sqrMaxPlayerDistance = 0, and every entity is filtered out → proximity silently broken.

  2. Proximity IsQualifiedByDistance overload (line 66) only checks HasMaxPlayerDistance. Three broken cases:

    • Scene sets only max_distance: HasMaxPlayerDistance is false → returns true unconditionally (no distance limit applied)
    • Scene sets both: only deprecated value used, "larger wins" reconciliation skipped
    • Scene sets neither: returns true unconditionally — contradicts the cursor overload's DEFAULT_MAX_DISTANCE = 10f fallback (unlimited range for proximity while cursor has 10-unit cap)

Once the SDK migrates scenes from deprecated max_player_distance to canonical max_distance, proximity interactions will silently break.

Fix for GetMaxDistanceAndHighestPriority (line 233):

// Replace:
float maxDistance = info.MaxPlayerDistance;
// With:
float maxDistance = (info.HasMaxDistance, info.HasMaxPlayerDistance) switch
{
    (true, true)   => Mathf.Max(info.MaxDistance, info.MaxPlayerDistance),
    (true, false)  => info.MaxDistance,
    (false, true)  => info.MaxPlayerDistance,
    (false, false) => PROXIMITY_DEFAULT_MAX_DISTANCE,
};

Fix for proximity IsQualifiedByDistance (lines 62-67):

public static bool IsQualifiedByDistance(
    in ProximityResultForSceneEntities proximityResultForSceneEntities,
    PBPointerEvents.Types.Info info
)
{
    float? maxPlayerDistance = (info.HasMaxDistance, info.HasMaxPlayerDistance) switch
    {
        (true, true)   => Mathf.Max(info.MaxDistance, info.MaxPlayerDistance),
        (true, false)  => info.MaxDistance,
        (false, true)  => info.MaxPlayerDistance,
        _              => null,
    };
    float effectiveMax = maxPlayerDistance ?? DEFAULT_MAX_DISTANCE;
    return proximityResultForSceneEntities.DistanceToPlayer <= effectiveMax;
}

Consider extracting the alias-resolution switch into a shared static float? ResolveMaxPlayerDistance(PBPointerEvents.Types.Info info) helper to avoid duplicating it across three call sites.

P2 — Missing reverse-alias test case

(See inline suggestion on test file)

P2 — Branch/title convention mismatch (ADR-6)

Branch: feat/max-camera-distance, PR title: fix:. The branch prefix should match the commit type. Minor — not blocking.


Security Review

No security issues found.

  • NaN distance values: <= comparisons return false per IEEE 754 (rejects interaction — safe)
  • Infinity: bounded by MAX_RAYCAST_DISTANCE = 100f raycast cap
  • Negatives: distances are non-negative, so <= negative is false
  • No hardcoded secrets, no sensitive data in logs

STEP 6 — Complexity

COMPLEX — modifies pointer event distance semantics, interaction qualification logic, and raycast system input handling.

STEP 7 — QA Assessment

QA required — changes affect runtime interaction behavior (distance checks for pointer events governing hover, click, and proximity).

STEP 8 — Non-blocking warnings

None. Main.unity not modified.


REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies pointer-event distance semantics and interaction qualification across raycast and proximity systems
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown (<@unknown>) via Slack

Assert.IsTrue(InteractionInputUtils.IsQualifiedByDistance(result, new PBPointerEvents.Types.Info { MaxPlayerDistance = 6 }));
Assert.IsFalse(InteractionInputUtils.IsQualifiedByDistance(result, new PBPointerEvents.Types.Info { MaxPlayerDistance = 4 }));
Assert.IsTrue(InteractionInputUtils.IsQualifiedByDistance(result, new PBPointerEvents.Types.Info { MaxDistance = 4, MaxPlayerDistance = 6 }));

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] Missing reverse-alias test case. The test verifies MaxDistance=4, MaxPlayerDistance=6 (alias wins), but not the reverse direction where the canonical field wins. Add a case to confirm Mathf.Max works both ways.

Suggested change
Assert.IsTrue(InteractionInputUtils.IsQualifiedByDistance(result, new PBPointerEvents.Types.Info { MaxDistance = 4, MaxPlayerDistance = 6 }));
Assert.IsTrue(InteractionInputUtils.IsQualifiedByDistance(result, new PBPointerEvents.Types.Info { MaxDistance = 6, MaxPlayerDistance = 4 }));

Review follow-up: the proximity broad-phase and its IsQualifiedByDistance
overload still read only the deprecated max_player_distance. Resolve the
threshold through a shared ResolveMaxPlayerDistance (max_distance, alias,
larger wins; null when neither) so cursor and proximity agree; proximity
falls back to its 3 m default in the broad-phase and to the 10 m default
in the qualifier. Adds the reverse-alias and proximity test cases.

Also clears four RedundantArgumentDefaultValue warnings elsewhere to
satisfy the lint ratchet (no warnings in the files this PR touches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robtfm robtfm changed the title fix: PointerEvents max_distance is player distance; add max_camera_distance feat: PointerEvents max_distance is player distance; add max_camera_distance Aug 28, 2026
@robtfm

robtfm commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review:

  • P1 proximity pathGetMaxDistanceAndHighestPriority and the proximity IsQualifiedByDistance overload now resolve the threshold via a shared InteractionInputUtils.ResolveMaxPlayerDistance (max_distance, deprecated alias, larger wins; null when neither). Broad-phase falls back to PROXIMITY_DEFAULT_MAX_DISTANCE (3 m), the qualifier to DEFAULT_MAX_DISTANCE (10 m; moot in practice since the overlap sphere already caps candidates at 3 m). Camera distance plays no part in proximity.
  • P2 reverse-alias test — added (MaxDistance = 6, MaxPlayerDistance = 4 → true; 3/4 → false), plus a QualifyProximityByDistance test.
  • P2 branch/title — retitled to feat: to match the branch.
  • Lint ratchet — cleared four RedundantArgumentDefaultValue warnings (ThirdWebLoginService.cs:29, DefaultTexturesContainer.cs:75-76) since the files in this PR introduce none.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9902, run #33197110408

Overall: ✅ no significant changes

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

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of 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.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2321 (×3) 2327 (×3)
CPU average 38.6 ms (33.4–38.6) 38.4 ms (38.1–38.5) -0.2 ms ⚪ within noise
CPU 1% worst 384.3 ms (55.3–401.3) 384.8 ms (371.9–415.3) 0.6 ms ⚪ within noise
CPU 0.1% worst 399.6 ms (135.2–420.8) 397.2 ms (396.6–425.6) -2.3 ms — informational
GPU average 24.5 ms (20.3–24.7) 24.2 ms (24.0–24.7) -0.2 ms ⚪ within noise
GPU 1% worst 388.5 ms (33.6–404.8) 390.4 ms (377.1–420.1) 1.9 ms ⚪ within noise
GPU 0.1% worst 404.1 ms (70.2–417.1) 406.1 ms (402.2–432.7) 2.0 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Apple M1

Metric Baseline Change Δ Result
Samples 3215 (×3) 3193 (×3)
CPU average 27.8 ms (27.7–28.4) 28.0 ms (27.8–28.1) 0.1 ms ⚪ within noise
CPU 1% worst 234.3 ms (229.2–236.7) 233.4 ms (211.7–235.1) -0.9 ms ⚪ within noise
CPU 0.1% worst 246.3 ms (243.8–246.9) 246.8 ms (245.9–247.7) 0.5 ms — informational
GPU average 19.7 ms (19.6–22.6) 21.3 ms (19.9–21.6) 1.7 ms ⚪ within noise
GPU 1% worst 57.2 ms (57.1–57.7) 55.4 ms (52.3–56.1) -1.8 ms 🟢 3% faster
GPU 0.1% worst 61.6 ms (60.4–62.3) 59.8 ms (58.2–60.7) -1.8 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

@pravusjif pravusjif self-assigned this Aug 31, 2026
@pravusjif
pravusjif marked this pull request as draft August 31, 2026 17:08
@pravusjif pravusjif added the no-warning-ratchet Prevent CI linting checks label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do not merge ext-contribution Identifies a contribution which was not initiated by a Unity Developer no-warning-ratchet Prevent CI linting checks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PointerEvents maxDistance and maxPlayerDistance not behaving as expected

3 participants