Skip to content

fix: enable camera controls and local item-only previews - #10053

Open
gonpombo8 wants to merge 1 commit into
devfrom
feat/validator-capture-controls
Open

gonpombo8 wants to merge 1 commit into
devfrom
feat/validator-capture-controls

Conversation

@gonpombo8

Copy link
Copy Markdown
Contributor

Summary

Make the existing camera RPCs move the Unity preview and support type=wearable for a single local Builder upload. The isolated view hides the avatar, and an unsupported request releases the loader so another upload can recover.

What could break

Camera commands stop automatic subject rotation. Check manual dragging and Marketplace avatar/item switching after camera changes.

How to test

  • Run the camera regression tests: both pass with the patch and fail with the original renderer methods restored.
  • Build the renderer for Web. In Builder mode, load one local wearable definition with type=wearable, then type=avatar: expect an isolated item, then the worn item. An emote requested as item-only must report an error and allow the next valid upload.

Validated a local Unity Web build with the validator's capture probe: repeatable camera views, reversible zoom, absolute pan, isolated wearable, paused emote samples and engine recovery after rejection. The current iframe wrapper retains a stale error overlay after recovery; that wrapper UI is a separate follow-up.

@gonpombo8
gonpombo8 requested review from a team as code owners September 10, 2026 13:35
@github-actions
github-actions Bot requested a review from anicalbano September 10, 2026 13:35
@decentraland-bot
decentraland-bot self-requested a review September 10, 2026 13:35
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

Waiting for lint to start…

Tests

Waiting for tests to start…

Performance

Bare-metal benchmarks run automatically after each successful build; results land in this section. Add the perf_test label to run the in-repo Unity performance suite instead (skips normal CI and blocks merge while set).

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.

PR Review: fix: enable camera controls and local item-only previews

STEP 1 — Context

This PR touches avatar-preview-renderer/, a standalone MonoBehaviour-based WebGL project (ECS rules do not apply). It adds Unity-side handlers for camera RPCs that the wearable-preview JS wrapper already sends (changeCameraPosition, panCamera, changeZoom), a Builder item-only preview mode (type=wearable), and an error-recovery path that replaces the old throw in the reload loop.

12 files changed: 5 source files, 1 README, 4 test files + meta, 2 folder meta.


STEP 2 — Root-cause check: PASS

The PR solves the right problems:

  • Camera RPCs: The wearable-preview wrapper already sends SetCameraPosition, SetOffset, and SetZoom messages via SendMessage (confirmed in src/lib/unity/scene.ts). The Unity renderer lacked handlers — this PR adds them.
  • Builder item-only view: New feature for the Builder to preview a single local wearable definition in isolation.
  • Error recovery: The old throw; in the Reload() catch block left _loading = true permanently, deadlocking all future InvokeReload() calls. The new code properly resets _loading = false and allows recovery.

STEP 3 — Design & integration: PASS

No new lifecycle units are introduced. All new behavior extends existing owners:

  • Orbit systemPreviewCameraController (already owns camera manipulation: FOV zoom, pan, fit)
  • StopMotionDragRotator (already owns rotation state: velocities, auto-rotate, target)
  • Camera forwardingPreviewController (follows the existing Pan forwarding pattern through previewUIPresenter)
  • Item-only Builder viewLoadForBuilder (extends the existing load path; wearable/avatar loaders already exist)

The dual camera mode (fit-based FOV zoom vs. orbit-based spherical coordinates) is clean: BeginOrbit() lazily initializes from the current camera state, orbit mode bypasses the FOV zoom and pan-offset systems, and ResetFraming clears orbit state on mode switch or reload. Pan correctly branches to move the orbit target when orbiting.

Visibility management (avatarLoader/wearableLoader/glowCatcher GameObjects) is now toggled in both ShowWearableView and Reload. Traced all paths — Marketplace, Builder with/without wearable, other modes — the final visibility state is consistent. The duplication is a minor maintenance concern but not a bug.


STEP 4 — Member audit

Member Consumers Assessment
DragRotator.StopMotion() PreviewController.StopAutomaticRotation (1) Distinct from ResetRotation — clears velocities/auto-rotate without resetting angles. Not merge material.
PreviewController.ChangeCameraPosition(Vector3) JSBridge.SetCameraPosition, JSBridge.SetZoom (2) Forwarding + mode gate. Consistent pattern.
PreviewController.SetCameraTarget(Vector3) JSBridge.SetOffset (1) Forwarding + mode gate. Parallels ChangeCameraPosition.
PreviewCameraController.ShowWearable(bool, PreviewMode) PreviewController.ShowWearableView (1) Renamed from ShowMarketplaceWearable. Mode param needed for Builder/avatar framing selection.
PreviewCameraController.ChangeCameraPosition(Vector3) PreviewController.ChangeCameraPosition (1) Public API for orbit delta.
PreviewCameraController.SetCameraTarget(Vector3) PreviewController.SetCameraTarget (1) Public API for absolute orbit target.
PreviewCameraController.BeginOrbit() / ApplyOrbit() Internal (3 call sites each) Private, well-scoped.

No single-use merge candidates. All members have appropriate scope.


STEP 5 — Line-level findings

See inline comments below.

[P2] Nested ternary readabilityPreviewCameraController.cs:230. Correct operator precedence (C# ternary is right-associative), but the nested ternary is harder to parse at a glance. Parentheses make the intent explicit.

[P2] Defensive radius guard in BeginOrbitPreviewCameraController.cs:263. If panSubjectDistance were ever set to 0 in the inspector, offset.magnitude would be 0, producing a NaN division on line 265. Practically unreachable (default is 7f and the value is serialized), but ChangeCameraPosition already guards with Mathf.Max(MIN_FRUSTUM_DEPTH, ...)BeginOrbit should be consistent.


Additional observations (non-blocking)

  • Error recovery leaves mainCamera.cullingMask = 0: After the catch-block return, the camera renders nothing and the outline-update enabled flags stay false. The PR description explicitly acknowledges the JS error overlay covers this, and recovery via InvokeReload() restores everything. Strictly better than the base version (permanent deadlock).

  • wearableRotator.EnableAutoRotate behavioral change: Now explicitly set to config.Mode is PreviewMode.Marketplace instead of relying on the true default from Awake(). This is a correctness fix — without it, the wearable would auto-rotate in Builder mode, which conflicts with scripted camera controls.

  • Switcher disabled in Builder mode: EnableSwitcher now requires config.Mode == PreviewMode.Marketplace, which is correct — the Builder item-only view has no meaningful avatar/wearable toggle since the avatar is hidden.

  • #nullable enable annotations: Correct gradual-adoption approach. string.Equals(bodyShapeName, ...) is null-safe. EntityDefinition? itemAlone is properly null-checked before use.

  • Test design: The reflection-based approach is deliberate — the test assembly avoids depending on Assembly-CSharp and tests the exact web-bridge interface (method names as strings) that JS calls. The locale test (fr-FR) explicitly verifies CultureInfo.InvariantCulture parsing. Both tests verify round-trip accuracy.

  • Consumer impact: ShowMarketplaceWearableShowWearable rename is internal to the renderer (confirmed: no external consumers). The new camera RPCs match the existing wearable-preview wrapper's SendMessage calls exactly (format, naming, semantics).


STEP 6 — Complexity: COMPLEX

Touches camera control system (new orbit mode with spherical coordinates), async error handling in the reload loop, visibility management across preview modes, and a new Builder preview path.

STEP 7 — QA: YES

Runtime code changes affect camera behavior, wearable/avatar visibility, and error recovery in the WebGL preview renderer.

STEP 8 — Non-blocking warnings

None. No Main scene modified.


Security review

Input validation: Camera parsing uses float.TryParse with CultureInfo.InvariantCulture, rejects NaN/Infinity, requires exactly 3 comma-separated values for vectors. Downstream clamping: Beta to [ε, π−ε], Radius to ≥ 0.01, Alpha wraps naturally. No gaps found.

Error exposure: OnError(e.Message) was pre-existing; new error messages are generic (no paths, stack traces, or internal state).

Mode guard: StopAutomaticRotation restricts camera RPCs to Builder/Marketplace modes.

Supply chain: No new dependencies, binaries, CI/workflow changes, or registry modifications.

DEPENDENCY_REVIEW: PASS


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: New orbit-based camera control system, async error recovery rework, and Builder item-only preview path across the avatar-preview-renderer.
QA_REQUIRED: YES


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

}

public void ShowMarketplaceWearable(bool showWearable)
public void ShowWearable(bool showWearable, PreviewMode mode)

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] Readability: The nested ternary is correct (C# ternary is right-associative) but parentheses make the intent immediately obvious.

Suggested change
public void ShowWearable(bool showWearable, PreviewMode mode)
_active = showWearable ? _wearableFraming : (mode == PreviewMode.Builder ? _builderFraming : _avatarFraming);

var cameraTransform = _active.Camera.transform;
_active.OrbitTarget = cameraTransform.position + cameraTransform.forward * panSubjectDistance;
var offset = cameraTransform.position - _active.OrbitTarget;
_active.Radius = offset.magnitude;

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] Defensive guard: ChangeCameraPosition already floors Radius at MIN_FRUSTUM_DEPTHBeginOrbit should be consistent. If panSubjectDistance were ever 0 in the inspector, offset.magnitude would be 0 and the division on line 265 would produce NaN.

Suggested change
_active.Radius = offset.magnitude;
_active.Radius = Mathf.Max(MIN_FRUSTUM_DEPTH, offset.magnitude);

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

STEP 1 — Context & Scope

Reviewed at base 2831b062 / head 28bcf4da. Loaded CLAUDE.md and docs/README.md. The diff touches the avatar-preview-renderer/ project — a standalone MonoBehaviour WebGL renderer. ECS rules do not apply (per CLAUDE.md).

Files changed: 7 (5 source, 1 test, 1 README). ~200 additions / 15 deletions.

STEP 2 — Root-cause check

Problem: The avatar-preview-renderer lacked scripted camera control from JS and Builder-mode item-only previews.

Approach: The diff adds orbit-camera methods callable from JS (SetCameraPosition, SetOffset, SetZoom), builder item-only rendering via type=wearable, and error recovery to release the loader lock after unsupported requests.

The changes address the cause directly — extending the preview API surface. The old throw in the catch block left _loading = true permanently, preventing all future reloads; the new recovery pattern is a strict improvement.

PASS.

STEP 3 — Design & Integration

Orbit camera system: BeginOrbit/ApplyOrbit on CameraFraming is well-placed in PreviewCameraController. It correctly disables auto-refit (HasSubject = false), snapshots FOV, branches pan between orbit and fit modes, and gets fully cleared by ResetFraming on mode/view switches. No new long-lived unit introduced — the orbit state lives as fields on the existing CameraFraming inner class.

Builder item-only view: Reuses the existing WearableLoader and ShowWearableView flow. No new lifecycle owner needed — the wearable is loaded/cleaned up within the existing LoadForBuilderReload cycle. The LoadForBuilder return type change (voidbool) cleanly signals whether a standalone item was produced.

Error recovery: The old throw from a coroutine left _loading = true forever. The new pattern reports the error to JS via OnError, releases the lock (_loading = false), and allows the next Reload() to proceed. Correct.

Mode coupling (P2): ShowWearable takes a PreviewMode parameter instead of reading a stored mode. StopAutomaticRotation reads PreviewConfiguration.Instance.Mode directly rather than relying on state the camera controller owns. Both work correctly today because all callers are consistent, but the camera controller cannot self-enforce its invariants. See inline comments.

Teardown trace: No new subscriptions, event hookups, connections, or buffers. StopMotion() clears velocity state — no teardown needed. Orbit state is reset by ResetFraming on mode/view changes. ✓

PASS — no lifecycle duplication, no per-frame reconciliation, no persistent state outside the existing CameraFraming class.

STEP 4 — Member Audit

New member Consumers Assessment
DragRotator.StopMotion() PreviewController.StopAutomaticRotation (1) Distinct from ResetRotation (zeroes angles + position). StopMotion freezes motion without resetting orientation — correct separation.
PreviewCameraController.ChangeCameraPosition(Vector3) PreviewController (1) → JSBridge (2 paths) Three consumers via the delegation chain. Clean public API.
PreviewCameraController.SetCameraTarget(Vector3) PreviewController (1) → JSBridge (1) Two consumers. Clean.
PreviewCameraController.ShowWearable(bool, PreviewMode) PreviewController.ShowWearableView (1) Replaces old ShowMarketplaceWearable. Mode parameter is a P2 coupling concern (see below).
PreviewController.StopAutomaticRotation() ChangeCameraPosition, SetCameraTarget (2) Private helper. Guards camera ops with mode check.
JSBridge.ParseCameraVector/ParseCameraNumber SetCameraPosition, SetOffset, SetZoom (3+) Private helpers, well-scoped.
CameraFraming.InitialLocalRotation ResetFraming (1) Needed to restore rotation after orbit — mirrors existing InitialLocalPosition.
CameraFraming.Orbiting/OrbitTarget/Alpha/Beta/Radius BeginOrbit, ApplyOrbit, ChangeCameraPosition, SetCameraTarget, Pan, ResetFraming (6+) Orbit state on existing class — well-scoped.

No member audit issues.

STEP 5 — Line-level Review

All findings are P2. See inline comments for details and suggestions.

Additional notes:

  • The test assembly AvatarPreview.Editor.Tests.asmdef uses the legacy optionalUnityReferences field (pre-2019.2 pattern). Modern Unity (6000.x) recommends explicit references to UnityEngine.TestRunner / UnityEditor.TestRunner with defineConstraints: ["UNITY_INCLUDE_TESTS"]. The legacy format still works — low priority.
  • The test correctly validates the JS bridge's camera API contract via reflection (mirroring the SendMessage invocation path real callers use) and exercises locale-independent parsing (fr-FR). The method-name strings are the contract — a rename breaks the test the same way it breaks JS callers.
  • #nullable enable annotations (without warnings) is a valid stepping stone — marks nullable parameters without generating NRT warnings across the rest of the file.
  • The orbit math (spherical coordinates with ISO physics convention — Beta as polar angle from +Y) round-trips correctly. SetZoom negation chain is correct: positive zoom value → negative delta.z → radius decreases → camera closer → zooms in.
  • string.Equals(bodyShapeName, ...) replacing bodyShapeName.Equals(...) is a correct null-safety improvement matching the new string? annotation.
  • LoadForBuilder validation (base64Entities.Length != 1 || base64Emote != null) correctly rejects emotes, multi-entity, and zero-entity item-only requests.

STEP 6 — Complexity

COMPLEX — Adds orbit camera coordinate system with spherical math, modifies error recovery in the async reload loop, changes camera/input handling across JSBridge, PreviewCameraController, and PreviewController.

STEP 7 — QA Assessment

QA_REQUIRED: YES — Changes affect runtime camera rendering behavior, item visibility toggling, and error recovery flow.

STEP 8 — Non-blocking Warnings

No Main.unity scene modified. No warnings.


DEPENDENCY_REVIEW: PASS

Security review: No dependency, binary, or lockfile changes. No new native plugins. Test assembly correctly scoped to Editor-only (includePlatforms: ["Editor"]). No secrets or credentials. No automation or execution hook changes. Input validation rejects NaN/Infinity and uses CultureInfo.InvariantCulture.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Adds orbit camera system with spherical coordinates, modifies async error recovery, changes input handling across three files
QA_REQUIRED: YES


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

Comment on lines +111 to +118
[UsedImplicitly]
public void SetCameraPosition(string value) => previewController.ChangeCameraPosition(ParseCameraVector(value));

[UsedImplicitly]
public void SetOffset(string value) => previewController.SetCameraTarget(ParseCameraVector(value));

[UsedImplicitly]
public void SetZoom(string value) => previewController.ChangeCameraPosition(new Vector3(0f, 0f, -ParseCameraNumber(value)));

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] Camera bridge methods let ArgumentException propagate unhandled through SendMessage, unlike SetSpringBonesParams (line 98) which catches and logs. The JS caller receives no OnError callback on bad input and cannot distinguish success from failure.

Suggested change
[UsedImplicitly]
public void SetCameraPosition(string value) => previewController.ChangeCameraPosition(ParseCameraVector(value));
[UsedImplicitly]
public void SetOffset(string value) => previewController.SetCameraTarget(ParseCameraVector(value));
[UsedImplicitly]
public void SetZoom(string value) => previewController.ChangeCameraPosition(new Vector3(0f, 0f, -ParseCameraNumber(value)));
[UsedImplicitly]
public void SetCameraPosition(string value)
{
try { previewController.ChangeCameraPosition(ParseCameraVector(value)); }
catch (Exception e) { Debug.LogError($"[Camera] SetCameraPosition: {e.Message}"); }
}
[UsedImplicitly]
public void SetOffset(string value)
{
try { previewController.SetCameraTarget(ParseCameraVector(value)); }
catch (Exception e) { Debug.LogError($"[Camera] SetOffset: {e.Message}"); }
}
[UsedImplicitly]
public void SetZoom(string value)
{
try { previewController.ChangeCameraPosition(new Vector3(0f, 0f, -ParseCameraNumber(value))); }
catch (Exception e) { Debug.LogError($"[Camera] SetZoom: {e.Message}"); }
}

var offset = cameraTransform.position - _active.OrbitTarget;
_active.Radius = offset.magnitude;
_active.Alpha = Mathf.Atan2(offset.z, offset.x);
_active.Beta = Mathf.Acos(Mathf.Clamp(offset.y / _active.Radius, -1f, 1f));

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] BeginOrbit computes Beta via Acos but does not clamp the result to [POLE_EPSILON, PI - POLE_EPSILON], unlike ChangeCameraPosition (line 244) which does. If the camera is directly above/below the computed orbit target, Beta would be 0 or π, making LookRotation(-offset, Vector3.up) receive a vertical vector parallel to up. Unlikely with current scene layouts but easy to defend.

Suggested change
_active.Beta = Mathf.Acos(Mathf.Clamp(offset.y / _active.Radius, -1f, 1f));
_active.Beta = Mathf.Clamp(Mathf.Acos(Mathf.Clamp(offset.y / _active.Radius, -1f, 1f)), POLE_EPSILON, Mathf.PI - POLE_EPSILON);

Comment on lines +230 to +232
public void ShowWearable(bool showWearable, PreviewMode mode)
{
_active = showWearable ? _wearableFraming : _avatarFraming;
_active = showWearable ? _wearableFraming : mode == PreviewMode.Builder ? _builderFraming : _avatarFraming;

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] ShowWearable accepts a PreviewMode parameter that could theoretically diverge from the mode set via SetMode. Similarly, StopAutomaticRotation in PreviewController reads PreviewConfiguration.Instance.Mode rather than state the camera controller owns. Consider storing the mode in a _currentMode field (set in SetMode) and using it here — this lets the camera controller self-enforce its invariants and removes the parameter.

Suggested change
public void ShowWearable(bool showWearable, PreviewMode mode)
{
_active = showWearable ? _wearableFraming : _avatarFraming;
_active = showWearable ? _wearableFraming : mode == PreviewMode.Builder ? _builderFraming : _avatarFraming;
public void ShowWearable(bool showWearable)
{
_active = showWearable ? _wearableFraming : _currentMode == PreviewMode.Builder ? _builderFraming : _avatarFraming;

Companion change in SetMode:

private PreviewMode _currentMode;

public void SetMode(PreviewMode mode)
{
    _currentMode = mode;
    // ... existing body
}

Comment on lines +279 to +283
if (_shouldReload) continue;

_loading = false;
previewUIPresenter.ShowLoader(false);
return;

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] The error exit resets _loading and hides the loader but leaves mainCamera.cullingMask = 0 (set at line 184), so the renderer shows a black screen until the next Reload(). Restoring the mask here lets the previous content remain visible rather than going entirely blank.

Suggested change
if (_shouldReload) continue;
_loading = false;
previewUIPresenter.ShowLoader(false);
return;
if (_shouldReload) continue;
_loading = false;
mainCamera.cullingMask = -1;
previewUIPresenter.ShowLoader(false);

@github-actions

Copy link
Copy Markdown
Contributor

badge

Avatar Preview Renderer — Vercel Preview is ready!

Field Value
Preview https://unity-explorer-m3gibp24o-decentraland1.vercel.app
Commit 28bcf4dae5d75989fb74b3a156299243177c1538
Logs https://github.com/decentraland/unity-explorer/actions/runs/34483553026

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