Skip to content

feat: vertical orbit in Loading and Backpack character preview - #10112

Open
MadRex wants to merge 2 commits into
devfrom
feat/character-preview-vertical-orbit
Open

MadRex wants to merge 2 commits into
devfrom
feat/character-preview-vertical-orbit

Conversation

@MadRex

@MadRex MadRex commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Left-drag now orbits the preview camera vertically as well as spinning the
avatar. The rig turns rigidly about a new CameraPivot on the avatar's axis, so
the camera and the framing point rotate together and each screen's tuned shot
survives. Pitching the Cinemachine target instead would orbit around the
framing point, which sits off the avatar's axis wherever the shot is angled.

The descent limit is derived rather than fixed: the camera's height traces
radius * cos(pitch + phase) about the pivot, solved for cameraFloorHeight so it
sets down at the avatar's feet instead of shooting from under the platform.
Upward travel is capped by maxVerticalAngle at 40 degrees, and the inertia is
dropped at either limit so the camera stops dead rather than drifting into it.

Position and aim damping would trail the drag by seconds, so the rig takes the
orbit undamped and keeps its damping for panning. The backpack levels the
elevation on equip, unequip and outfit changes; yaw and zoom are left alone.

Enabled on the backpack and the login screen. The passport and credit purchase
previews carry the settings with the flag off.

@MadRex
MadRex requested review from a team as code owners September 15, 2026 19:39
@github-actions
github-actions Bot requested a review from DafGreco September 15, 2026 19:39
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Sep 15, 2026
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build 7591979 · Logs · built 2026-09-15T21:45:32Z
Windows GitHub job · Unity Cloud #2 · Unity log · ⏱ 56m 52s build + 5m 2s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #2 · Unity log · ⏱ 1h 28m build + 5m 2s queue · Download .zip · .zip via S3

Lint

Warnings not reduced: 11910 => 11916 — remove at least 7 warnings to merge.

Warnings/errors in files changed by this PR (56)
Assets/DCL/Character/CharacterPreview/CharacterPreviewController.cs:83  CSharpWarnings::CS8600  Converting null literal or possible null value into non-nullable type
Assets/DCL/Character/CharacterPreview/CharacterPreviewController.cs:133  CSharpWarnings::CS8600  Converting null literal or possible null value into non-nullable type
Assets/DCL/Character/CharacterPreview/CharacterPreviewController.cs:185  CSharpWarnings::CS8600  Converting null literal or possible null value into non-nullable type
Assets/DCL/Backpack/CharacterPreview/BackpackCharacterPreviewController.cs:169  CSharpWarnings::CS8602  Dereference of a possibly null reference
Assets/DCL/Character/CharacterPreview/CharacterPreviewControllerBase.cs:44  CSharpWarnings::CS8603  Possible null reference return
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:29  CSharpWarnings::CS8618  Non-nullable property 'avatarParent' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:30  CSharpWarnings::CS8618  Non-nullable property 'camera' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:32  CSharpWarnings::CS8618  Non-nullable property 'cameraPivot' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:31  CSharpWarnings::CS8618  Non-nullable property 'cameraTarget' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:14  CSharpWarnings::CS8618  Non-nullable property 'cursorSettings' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:39  CSharpWarnings::CS8618  Non-nullable property 'freeLookCamera' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:41  CSharpWarnings::CS8618  Non-nullable property 'headIKSettings' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:40  CSharpWarnings::CS8618  Non-nullable property 'previewPlatform' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:38  CSharpWarnings::CS8618  Non-nullable property 'rotationTarget' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:48  InconsistentNaming  Name 'AngularVelocity' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'angularVelocity'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:267  InconsistentNaming  Name 'AvatarPreviewHeadIKSettings' does not match rule 'members_should_be_pascal_case'. Suggested name is 'AvatarPreviewHeadIkSettings'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewCameraController.cs:89  InconsistentNaming  Name 'CalculateFOV' does not match rule 'members_should_be_pascal_case'. Suggested name is 'CalculateFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewController.cs:64  InconsistentNaming  Name 'EnableHeadIK' does not match rule 'members_should_be_pascal_case'. Suggested name is 'EnableHeadIk'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:46  InconsistentNaming  Name 'IsDragging' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'isDragging'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:47  InconsistentNaming  Name 'LastDragTime' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'lastDragTime'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:50  InconsistentNaming  Name 'MaxVerticalAngle' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'maxVerticalAngle'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:45  InconsistentNaming  Name 'RotationInertia' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'rotationInertia'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:44  InconsistentNaming  Name 'RotationModifier' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'rotationModifier'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:95  InconsistentNaming  Name 'StartFOVTransition' does not match rule 'members_should_be_pascal_case'. Suggested name is 'StartFovTransition'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:43  InconsistentNaming  Name 'TargetFOV' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'targetFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:186  InconsistentNaming  Name 'UpdateFOV' does not match rule 'members_should_be_pascal_case'. Suggested name is 'UpdateFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:51  InconsistentNaming  Name 'VerticalAngularVelocity' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'verticalAngularVelocity'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:49  InconsistentNaming  Name 'VerticalRotationModifier' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'verticalRotationModifier'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:11  InconsistentNaming  Name 'cameraSettings' does not match rule 'members_should_be_pascal_case'. Suggested name is 'CameraSettings'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:188  InconsistentNaming  Name 'currentFOV' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'currentFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:14  InconsistentNaming  Name 'cursorSettings' does not match rule 'members_should_be_pascal_case'. Suggested name is 'CursorSettings'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:32  InconsistentNaming  Name 'dragEnabled' does not match rule 'members_should_be_pascal_case'. Suggested name is 'DragEnabled'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewController.cs:66  InconsistentNaming  Name 'headIK' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'headIk'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:41  InconsistentNaming  Name 'headIKSettings' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'headIkSettings'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:25  InconsistentNaming  Name 'isFOVTransitioning' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'isFovTransitioning'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:197  InconsistentNaming  Name 'newFOV' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'newFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:47  InconsistentNaming  Name 'rotationEnabled' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RotationEnabled'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:49  InconsistentNaming  Name 'rotationInertia' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RotationInertia'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:48  InconsistentNaming  Name 'rotationModifier' does not match rule 'members_should_be_pascal_case'. Suggested name is 'RotationModifier'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewSettingsSO.cs:38  InconsistentNaming  Name 'scrollEnabled' does not match rule 'members_should_be_pascal_case'. Suggested name is 'ScrollEnabled'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:95  InconsistentNaming  Name 'targetFOV' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'targetFov'.
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:2  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:4  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:29  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'avatarParent.set' is never used
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:30  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'camera.set' is never used
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:37  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'cameraFloorHeight.set' is never used
Assets/DCL/Character/CharacterPreview/CharacterPreviewAvatarContainer.cs:32  UnusedAutoPropertyAccessor.Local  Auto-property acce

…truncated; see the linked run for the full report.

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 25962 0 13 4m 30s 15m 24s
PlayMode ✅ Passed 248 0 37 45s 12m 22s

Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import.

Slowest tests
  • [editmode] 16.5s DCL.AuthenticationScreenFlow.Tests.ProfileFetchingAuthStateShould.CancelStalledFetchOnTimeout
  • [editmode] 15.5s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 11.9s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 5.2s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(180,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(90,4000)
  • [playmode] 4.5s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 3.0s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.4s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 2.2s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 1.7s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousAroundXAxisRotatesAroundXNotZ
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousPositiveAndNegativeYDirectionsAreOpposite

Full report: run summary · results + editor logs: editmode · playmode

Performance

🏁 Bare-metal benchmark finished — run #35027455890.

Full report

PR #10112, run #35027455890

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.

Apple M1

Metric Baseline Change Δ Result
Samples 3982 (×3) 3945 (×3)
CPU average 22.5 ms (21.8–23.5) 22.6 ms (22.4–23.8) 0.1 ms ⚪ within noise
CPU 1% worst 233.5 ms (230.7–233.6) 232.0 ms (231.2–233.6) -1.4 ms ⚪ within noise
CPU 0.1% worst 239.1 ms (235.8–244.9) 236.6 ms (236.4–236.7) -2.5 ms — informational
GPU average 34.8 ms (34.1–35.2) 35.1 ms (33.8–35.8) 0.4 ms ⚪ within noise
GPU 1% worst 44.6 ms (43.0–46.9) 46.0 ms (43.8–47.2) 1.5 ms ⚪ within noise
GPU 0.1% worst 45.2 ms (44.1–47.5) 47.1 ms (44.8–48.3) 1.9 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 4271 (×3) 4476 (×3)
CPU average 20.9 ms (17.9–21.9) 19.9 ms (16.5–21.3) -1.0 ms ⚪ within noise
CPU 1% worst 375.0 ms (291.7–403.8) 347.0 ms (233.6–388.8) -28.0 ms ⚪ within noise
CPU 0.1% worst 438.1 ms (383.8–446.3) 395.6 ms (290.7–420.2) -42.5 ms — informational
GPU average 13.9 ms (11.0–13.9) 12.7 ms (9.7–13.7) -1.2 ms ⚪ within noise
GPU 1% worst 213.6 ms (165.1–280.0) 215.3 ms (105.6–265.4) 1.8 ms ⚪ within noise
GPU 0.1% worst 429.8 ms (384.6–447.8) 390.3 ms (277.6–421.3) -39.5 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

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

Review — PR #10112: feat: vertical orbit in Loading and Backpack character preview

STEP 2 — Root-cause check: PASS

This PR adds a new feature (vertical orbit in character preview), not a fix. Root-cause analysis does not apply.

STEP 3 — Design & integration: PASS

The vertical orbit is added to CharacterPreviewAvatarContainer, which already owns horizontal rotation and FOV in its Update(). The new CameraPivot GameObject is inserted in the prefab hierarchy between the container root and the CameraTarget — the correct placement for a rigid orbit rig under Cinemachine FreeLook. The design reuses the same velocity/inertia/deceleration model as horizontal rotation, with Decelerate() extracted as a shared static helper.

Owner search: The character preview's camera lifecycle is fully owned by CharacterPreviewAvatarContainer (created via CharacterPreviewPlugin → pooled instantiation, disposed in CharacterPreviewController.Dispose()). The rotation and FOV logic already live in Update() on this MonoBehaviour. The new vertical orbit logic belongs here — no existing owner is bypassed, no new lifecycle is introduced.

Teardown trace: No new subscriptions, events, IDisposable fields, or CancellationTokenSource instances are added. cameraPivot is a serialized Transform owned by the prefab hierarchy, not by this class. ResetVerticalRotation() is called on Initialize() and ResetAvatarMovement(), matching the existing reset pattern. Clean.

STEP 4 — Member audit

Member Consumers Verdict
ResetVerticalRotation() BackpackCharacterPreviewController (3 sites), Initialize(), ResetAvatarMovement() Multiple consumers — justified as public
VerticalRotationModifier, MaxVerticalAngle, VerticalAngularVelocity Written by CharacterPreviewCameraController, consumed by AvatarContainer Mirrors existing RotationModifier/RotationInertia/AngularVelocity pattern
Accelerate() Horizontal + vertical velocity Refactored from inline code, 2 consumers — good extraction
Decelerate() Horizontal + vertical deceleration New static, 2 consumers — good extraction
cameraPivot, cameraFloorHeight Serialized config consumed by FloorPitchLimit() Serialized fields, not derived predicates

STEP 5 — Line-level findings

See inline comments below.

Categories checked clean:

  • R1 perf-alloc: new Vector2 is a struct (stack-allocated). Quaternion.Euler, Mathf.Acos/Atan2/Clamp are all value-type. Decelerate() is static with value args. The hot path (Update → UpdateRotation → UpdateCameraPitch → FloorPitchLimit → ApplyCameraPitch) is allocation-free. ✅
  • R2 LINQ: No LINQ introduced. ✅
  • R3 class-in-struct: No class references stored in structs. ✅
  • R4 ECS discipline: Not applicable — this is a MonoBehaviour, not an ECS system. ✅
  • R5 Entity by-ref: Not applicable. ✅
  • R6 Teardown pairs: No new subscriptions, events, pools, or CTS. ✅
  • R7 Nullability: All new fields are non-nullable value types or serialized Transform references (Unity Object lifetime, not C# nullable). ✅
  • R8 Root cause: Feature addition, not a fix. ✅
  • R9 Logging: No new logging added. ✅
  • R10 Catch scope: No new try/catch. ✅
  • R11 Async/CT: No new async code. ✅
  • R12 Abstraction: No new interfaces or wrappers. Accelerate/Decelerate are well-scoped utilities with 2 consumers each. ✅
  • R13 Reuse: Horizontal rotation primitives reused (same velocity/inertia model, shared Decelerate/Accelerate). ✅
  • R14 Contract honesty: No correlated booleans or partially-initialized objects. ✅
  • R15 Dead weight: No commented-out code or unused fields. ✅
  • R16/R17 Naming: cameraPitch, CameraPivot, FloorPitchLimit, ApplyCameraPitch — clear and precise. The VerticalRotation* prefix family matches the existing Rotation* pattern. ✅
  • R19 File hygiene: One class per file maintained. No non-Unity files under Assets/. ✅
  • R20 Idiom cluster: readonly not applicable to the new auto-properties (they have setters by design — written by the camera controller, read by the container). No string comparisons or path operations. ✅
  • R21 MVC: Not a view/controller change. ✅
  • R24 Scope: All 11 files are directly related to the character preview vertical orbit feature. No unrelated changes. ✅

STEP 6 — Complexity

COMPLEX — Touches camera/input handling, Cinemachine FreeLook rig integration, prefab hierarchy restructuring, and 11 files with significant logic changes.

STEP 7 — QA assessment

QA_REQUIRED: YES — Runtime camera behavior change affecting Backpack and Loading Screen character previews on both horizontal and vertical axes.

STEP 8 — Non-blocking warnings

No warnings. Main scene not modified.

Merge gates

  • R25 — Outstanding comments: No prior review threads. This review's findings are the first round.
  • R26 — QA sign-off: Awaiting QA. The PR does not carry no QA needed. QA should verify: (1) vertical orbit drag on Backpack and Loading Screen, (2) orbit resets on equip/unequip/outfit change, (3) floor limit prevents camera from going under the platform, (4) Passport and Credit Purchase previews do NOT have vertical orbit, (5) inertia stops at pitch limits, (6) test on both Windows and Mac.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches Cinemachine camera rig, prefab hierarchy, input handling, and cross-screen preview settings
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown via Slack

float floorAbovePivot = cameraFloorHeight - cameraPivot.localPosition.y;
float phase = Mathf.Atan2(restingOffset.z, restingOffset.y) * Mathf.Rad2Deg;

return (Mathf.Acos(Mathf.Clamp(floorAbovePivot / radius, -1f, 1f)) * Mathf.Rad2Deg) - phase;

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.

[P1] R7 Nullability / Correctness — NaN propagation from division by zero in FloorPitchLimit().

When restingOffset.y and restingOffset.z are both zero (camera sits exactly at the pivot), radius is 0f. The expression floorAbovePivot / radius produces NaN (0/0) or ±Infinity. Mathf.Clamp(NaN, -1, 1) returns NaN on Mono/IL2CPP, Mathf.Acos(NaN) returns NaN, and Mathf.Min(MaxVerticalAngle, NaN) returns NaN. Once cameraPitch absorbs NaN through the Mathf.Clamp chain in UpdateCameraPitch(), every subsequent frame stays NaN and the camera rotation is permanently broken.

While the Cinemachine orbits (radius 2.2–6) make radius ≈ 0 unlikely during normal operation, the guard is cheap and prevents a permanent NaN corruption if the camera ever passes through the pivot (e.g. during a zoom snap or a prefab misconfiguration).

Suggested change
return (Mathf.Acos(Mathf.Clamp(floorAbovePivot / radius, -1f, 1f)) * Mathf.Rad2Deg) - phase;
Vector3 restingOffset = cameraPivot.InverseTransformPoint(camera.transform.position);
float radius = new Vector2(restingOffset.y, restingOffset.z).magnitude;
if (radius < 1e-4f)
return MaxVerticalAngle;
float floorAbovePivot = cameraFloorHeight - cameraPivot.localPosition.y;
float phase = Mathf.Atan2(restingOffset.z, restingOffset.y) * Mathf.Rad2Deg;
return (Mathf.Acos(Mathf.Clamp(floorAbovePivot / radius, -1f, 1f)) * Mathf.Rad2Deg) - phase;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

checked by Claude and deliberately not applied. I traced it rather than taking it: the divisor can't reach zero (the camera hangs ~6 units off the pivot by rig construction, and zoom here drives FOV, not distance); at zero it would be -1f / 0f = -Infinity, which the existing Mathf.Clamp(…, -1f, 1f) already folds into a 40° fallback — the identical result to the proposed guard; and the claimed "permanent NaN corruption" can't occur because Mathf.Clamp returns value untouched when max is NaN. So it would be an unreachable branch, which CLAUDE.md names as a smell in its own right.

Comment on lines +250 to +264
/// <summary>
/// Swings the camera over and under the avatar, which stays upright on its platform.
/// The rig reads its target's rotation (LockToTargetNoRoll binding) and the target hangs off this
/// pivot, so camera and framing point rotate together: the elevation changes and the shot the
/// screen was tuned for survives. Pitching the target instead would orbit around the framing
/// point, which sits off the avatar's axis wherever the shot is angled.
/// </summary>
private void ApplyCameraPitch()
{
cameraPivot.localRotation = Quaternion.Euler(cameraPitch, 0f, 0f);

// Position and aim damping would leave the camera seconds behind the drag. The orbit is
// already smoothed by its own inertia, so the rig takes this rotation undamped and lands on
// the pose exactly. Damping stays in effect on every frame the orbit does not move.
freeLookCamera.PreviousStateIsValid = false;

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] R23 — Comment narrates Cinemachine's external behavior.

The <summary> block describes what the Cinemachine rig does with the target's rotation ("The rig reads its target's rotation (LockToTargetNoRoll binding)…") and what would happen if a different approach were taken ("Pitching the target instead would orbit around the framing point…"). Per R23, a comment must state only what the annotated code itself does or guarantees — external behavior can change without this code changing, silently turning the comment into a lie.

The inline comment on PreviousStateIsValid is fine — it explains why this code sets that flag.

Suggested trim — keep the summary focused on what ApplyCameraPitch does:

Suggested change
/// <summary>
/// Swings the camera over and under the avatar, which stays upright on its platform.
/// The rig reads its target's rotation (LockToTargetNoRoll binding) and the target hangs off this
/// pivot, so camera and framing point rotate together: the elevation changes and the shot the
/// screen was tuned for survives. Pitching the target instead would orbit around the framing
/// point, which sits off the avatar's axis wherever the shot is angled.
/// </summary>
private void ApplyCameraPitch()
{
cameraPivot.localRotation = Quaternion.Euler(cameraPitch, 0f, 0f);
// Position and aim damping would leave the camera seconds behind the drag. The orbit is
// already smoothed by its own inertia, so the rig takes this rotation undamped and lands on
// the pose exactly. Damping stays in effect on every frame the orbit does not move.
freeLookCamera.PreviousStateIsValid = false;
/// <summary>
/// Applies the current pitch to the camera pivot, orbiting the camera vertically
/// while preserving each screen's tuned shot.
/// </summary>
private void ApplyCameraPitch()
{
cameraPivot.localRotation = Quaternion.Euler(cameraPitch, 0f, 0f);
// Position and aim damping would leave the camera seconds behind the drag. The orbit is
// already smoothed by its own inertia, so the rig takes this rotation undamped and lands on
// the pose exactly. Damping stays in effect on every frame the orbit does not move.
freeLookCamera.PreviousStateIsValid = false;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

This branch has not been deployed

No deployments
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants