Repository: BabylonJS/Babylon.js
Pull request #16430: Improve ArcRotateCamera interpolation logic
Author: login: ryantrem
Created: 2025-03-31T20:00:03Z | Merged: 2025-04-01T15:56:55Z

This PR makes several improvements to the existing ArcRotateCamera interpolation logic:
- Allows overlapping interpolations. For example, if you start an interpolation of alpha, and before it is finished start an interpolation of the y component of the target, it previously would always stop the previous interpolation. Now it is possible for them to overlap, but passing in `NaN` for the components you don't want to interpolate. This is perhaps a little unusual, but it's the least disruptive to the API. Still, open to alternate suggestions.
- Ensures the camera orbit/target/etc. eventually reach the actual interpolation goal values. Previously, it would stop the interpolation when it was "close," but not actually bring the values to the final goal values.
- Because of the previous point, I had to improve the logic for determining when we are "close" to the final goal values, to ensure "close" is visually indistinguishable from the final value.
  - For target, it is relative to the radius. This ensures it visually always looks the same regardless of scale.
  - For rotation, it is just an absolute value (0.0002 radians), which was chosen empirically by just seeing what looked visually indistinguishable.
  - For radius, it is when we are 99.9% of the way to the goal, which again should work well regardless of scale.
  - For target screen offset, it is just an absolute value, same as previous actually.
- Swaps the order to applying user input and applying interpolation. This way, if there is any user input, we don't add in a small and unnecessary and incorrect interpolation first (size of the change depends on frame time). Also, it means we don't need to repeat the checks for user input twice.
- Add a `stopInterpolation` function to make it easier to interrupt an in-progress interpolation.
- Unit tests for the interpolation logic.


Diff:
diff --git a/packages/dev/core/src/Cameras/arcRotateCamera.ts b/packages/dev/core/src/Cameras/arcRotateCamera.ts
index 833d07575758..792046c1749b 100644
--- a/packages/dev/core/src/Cameras/arcRotateCamera.ts
+++ b/packages/dev/core/src/Cameras/arcRotateCamera.ts
@@ -56,6 +56,11 @@ export function ComputeBeta(verticalOffset: number, radius: number): number {
     return Math.acos(verticalOffset / radius);
 }
 
+// Returns the value if not NaN, otherwise returns the fallback value.
+function checkNaN(value: number, fallback: number): number {
+    return isNaN(value) ? fallback : value;
+}
+
 /**
  * This represents an orbital type of camera.
  *
@@ -607,8 +612,15 @@ export class ArcRotateCamera extends TargetCamera {
     // Behaviors
     private _bouncingBehavior: Nullable<BouncingBehavior>;
 
-    // restoring state progressively
-    private _progressiveRestore: boolean = false;
+    // This is redundant with all _goal* properties being NaN, but we track it anyway because we check for active interpolation in the hot path.
+    private _isInterpolating = false;
+
+    /**
+     * If true, indicates the camera is currently interpolating to a new pose.
+     */
+    public get isInterpolating(): boolean {
+        return this._isInterpolating;
+    }
 
     /**
      * Gets the bouncing behavior of the camera if it has been enabled.
@@ -819,22 +831,22 @@ export class ArcRotateCamera extends TargetCamera {
     private _storedTarget: Vector3;
     private _storedTargetScreenOffset: Vector2;
 
-    private _goalAlpha: number;
-    private _goalBeta: number;
-    private _goalRadius: number;
-    private _goalTarget: Vector3;
-    private _goalTargetScreenOffset: Vector2;
+    private _goalAlpha = NaN;
+    private _goalBeta = NaN;
+    private _goalRadius = NaN;
+    private readonly _goalTarget = new Vector3(NaN, NaN, NaN);
+    private readonly _goalTargetScreenOffset = new Vector2(NaN, NaN);
 
     /**
      * Stores the current state of the camera (alpha, beta, radius and target)
      * @returns the camera itself
      */
     public override storeState(): Camera {
-        this._storedAlpha = this._goalAlpha = this.alpha;
-        this._storedBeta = this._goalBeta = this.beta;
-        this._storedRadius = this._goalRadius = this.radius;
-        this._storedTarget = this._goalTarget = this._getTargetPosition().clone();
-        this._storedTargetScreenOffset = this._goalTargetScreenOffset = this.targetScreenOffset.clone();
+        this._storedAlpha = this.alpha;
+        this._storedBeta = this.beta;
+        this._storedRadius = this.radius;
+        this._storedTarget = this._getTargetPosition().clone();
+        this._storedTargetScreenOffset = this.targetScreenOffset.clone();
 
         return super.storeState();
     }
@@ -867,6 +879,17 @@ export class ArcRotateCamera extends TargetCamera {
         return true;
     }
 
+    /**
+     * Stops any in-progress interpolation.
+     */
+    public stopInterpolation(): void {
+        this._goalAlpha = NaN;
+        this._goalBeta = NaN;
+        this._goalRadius = NaN;
+        this._goalTarget.set(NaN, NaN, NaN);
+        this._goalTargetScreenOffset.set(NaN, NaN);
+    }
+
     /**
      * Interpolates the camera to a goal state.
      * @param alpha Defines the goal alpha.
@@ -875,6 +898,8 @@ export class ArcRotateCamera extends TargetCamera {
      * @param target Defines the goal target.
      * @param targetScreenOffset Defines the goal target screen offset.
      * @param interpolationFactor A value  between 0 and 1 that determines the speed of the interpolation.
+     * @remarks Passing undefined for any of the parameters will use the current value (effectively stopping any in-progress interpolation for that parameter).
+     *          Passing NaN will not start or stop any interpolation for that parameter (effectively allowing multiple interpolations of different parameters to overlap).
      */
     public interpolateTo(
         alpha = this.alpha,
@@ -884,7 +909,6 @@ export class ArcRotateCamera extends TargetCamera {
         targetScreenOffset = this.targetScreenOffset,
         interpolationFactor?: number
     ): void {
-        this._progressiveRestore = true;
         this.inertialAlphaOffset = 0;
         this.inertialBetaOffset = 0;
         this.inertialRadiusOffset = 0;
@@ -899,16 +923,19 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        this._goalAlpha = checkNaN(alpha, this._goalAlpha);
+        this._goalBeta = checkNaN(beta, this._goalBeta);
+        this._goalRadius = checkNaN(radius, this._goalRadius);
+        this._goalTarget.set(checkNaN(target.x, this._goalTarget.x), checkNaN(target.y, this._goalTarget.y), checkNaN(target.z, this._goalTarget.z));
+        this._goalTargetScreenOffset.set(checkNaN(targetScreenOffset.x, this._goalTargetScreenOffset.x), checkNaN(targetScreenOffset.y, this._goalTargetScreenOffset.y));
 
-        this._goalAlpha = alpha;
-        this._goalBeta = beta;
-        this._goalRadius = radius;
-        this._goalTarget = target;
-        this._goalTargetScreenOffset = targetScreenOffset;
+        this._goalAlpha = Clamp(this._goalAlpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
+        this._goalBeta = Clamp(this._goalBeta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
+        this._goalRadius = Clamp(this._goalRadius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
+        this._goalTarget.y = Clamp(this._goalTarget.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+
+        this._isInterpolating = true;
     }
 
     // Synchronized
@@ -1013,44 +1040,12 @@ export class ArcRotateCamera extends TargetCamera {
 
         this.inputs.checkInputs();
 
-        // progressive restore
-        if (this._progressiveRestore) {
-            const dt = this._scene.getEngine().getDeltaTime() / 1000;
-            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
-
-            // can't use tmp vector here because of assignment
-            this.setTarget(Vector3.Lerp(this.getTarget(), this._goalTarget, t));
-
-            // Using quaternion for smoother interpolation (and no Euler angles modulo)
-            Quaternion.RotationAlphaBetaGammaToRef(this._goalAlpha, this._goalBeta, 0, TmpVectors.Quaternion[0]);
-            Quaternion.RotationAlphaBetaGammaToRef(this.alpha, this.beta, 0, TmpVectors.Quaternion[1]);
-            Quaternion.SlerpToRef(TmpVectors.Quaternion[1], TmpVectors.Quaternion[0], t, TmpVectors.Quaternion[2]);
-            TmpVectors.Quaternion[2].normalize();
-            TmpVectors.Quaternion[2].toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
-            this.alpha = TmpVectors.Vector3[0].x;
-            this.beta = TmpVectors.Vector3[0].y;
-
-            this.radius += (this._goalRadius - this.radius) * t;
-            Vector2.LerpToRef(this.targetScreenOffset, this._goalTargetScreenOffset, t, this.targetScreenOffset);
-
-            // stop restoring when within close range or when user starts interacting
-            if (
-                (Vector3.DistanceSquared(this.getTarget(), this._goalTarget) < Epsilon &&
-                    TmpVectors.Quaternion[2].isApprox(TmpVectors.Quaternion[0]) &&
-                    Math.pow(this._goalRadius - this.radius, 2) < Epsilon &&
-                    Vector2.Distance(this.targetScreenOffset, this._goalTargetScreenOffset) < Epsilon) ||
-                this.inertialAlphaOffset !== 0 ||
-                this.inertialBetaOffset !== 0 ||
-                this.inertialRadiusOffset !== 0 ||
-                this.inertialPanningX !== 0 ||
-                this.inertialPanningY !== 0
-            ) {
-                this._progressiveRestore = false;
-            }
-        }
+        let hasUserInteractions = false;
 
         // Inertia
         if (this.inertialAlphaOffset !== 0 || this.inertialBetaOffset !== 0 || this.inertialRadiusOffset !== 0) {
+            hasUserInteractions = true;
+
             const directionModifier = this.invertRotation ? -1 : 1;
             const handednessMultiplier = this._calculateHandednessMultiplier();
             let inertialAlphaOffset = this.inertialAlphaOffset * handednessMultiplier;
@@ -1079,6 +1074,8 @@ export class ArcRotateCamera extends TargetCamera {
 
         // Panning inertia
         if (this.inertialPanningX !== 0 || this.inertialPanningY !== 0) {
+            hasUserInteractions = true;
+
             const localDirection = new Vector3(this.inertialPanningX, this.inertialPanningY, this.inertialPanningY);
 
             this._viewMatrix.invertToRef(this._cameraTransformMatrix);
@@ -1124,6 +1121,98 @@ export class ArcRotateCamera extends TargetCamera {
             }
         }
 
+        if (hasUserInteractions) {
+            this.stopInterpolation();
+        } else if (this._isInterpolating) {
+            let isInterpolating = false;
+            const dt = this._scene.getEngine().getDeltaTime() / 1000;
+            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
+
+            // NOTE: If the goal is NaN, it means we are not interpolating to a new value, so we can use the current value. Hence the calls to checkNaN.
+
+            // Get the goal radius immediately as we'll need it for determining interpolation termination for the target.
+            const goalRadius = checkNaN(this._goalRadius, this.radius);
+
+            // Interpolate the target if we haven't reached the goal yet.
+            if (!isNaN(this._goalTarget.x) || !isNaN(this._goalTarget.y) || !isNaN(this._goalTarget.z)) {
+                const goalTarget = TmpVectors.Vector3[0].set(
+                    checkNaN(this._goalTarget.x, this._target.x),
+                    checkNaN(this._goalTarget.y, this._target.y),
+                    checkNaN(this._goalTarget.z, this._target.z)
+                );
+                this.setTarget(Vector3.Lerp(this.target, goalTarget, t), undefined, undefined, true);
+
+                // Terminate the target interpolation if we the target is close relative to the radius.
+                // This is when visually (regardless of scale) the target appears close to its final goal position.
+                if ((Vector3.Distance(this.target, goalTarget) * 10) / goalRadius < Epsilon) {
+                    this._goalTarget.set(NaN, NaN, NaN);
+                    this.setTarget(goalTarget.clone(), undefined, undefined, true);
+                } else {
+                    isInterpolating = true;
+                }
+            }
+
+            // Interpolate the rotation if we haven't reached the goal yet.
+            if (!isNaN(this._goalAlpha) || !isNaN(this._goalBeta)) {
+                // Using quaternion for smoother interpolation (and no Euler angles modulo)
+                const goalRotation = Quaternion.RotationAlphaBetaGammaToRef(
+                    checkNaN(this._goalAlpha, this.alpha),
+                    checkNaN(this._goalBeta, this.beta),
+                    0,
+                    TmpVectors.Quaternion[0]
+                );
+                const currentRotation = Quaternion.RotationAlphaBetaGammaToRef(this.alpha, this.beta, 0, TmpVectors.Quaternion[1]);
+                const newRotation = Quaternion.SlerpToRef(currentRotation, goalRotation, t, TmpVectors.Quaternion[2]);
+                newRotation.normalize();
+                const newAlphaBetaGamma = newRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                this.alpha = newAlphaBetaGamma.x;
+                this.beta = newAlphaBetaGamma.y;
+
+                // Terminate the rotation interpolation when the rotation appears visually close to the final goal rotation.
+                if (newRotation.isApprox(goalRotation, Epsilon / 5)) {
+                    this._goalAlpha = NaN;
+                    this._goalBeta = NaN;
+                    const goalAlphaBetaGamma = goalRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                    this.alpha = goalAlphaBetaGamma.x;
+                    this.beta = goalAlphaBetaGamma.y;
+                } else {
+                    isInterpolating = true;
+                }
+            }
+
+            // Interpolate the radius if we haven't reached the goal yet.
+            if (!isNaN(this._goalRadius)) {
+                this.radius += (goalRadius - this.radius) * t;
+
+                // Terminate the radius interpolation when we are 99.9% of the way to the goal radius, at which point it is visually indistinguishable from the goal.
+                if (Math.abs(goalRadius / this.radius - 1) < Epsilon) {
+                    this._goalRadius = NaN;
+                    this.radius = goalRadius;
+                } else {
+                    isInterpolating = true;
+                }
+            }
+
+            // Interpolate the target screen offset if we haven't reached the goal yet.
+            if (!isNaN(this._goalTargetScreenOffset.x) || !isNaN(this._goalTargetScreenOffset.y)) {
+                const goalTargetScreenOffset = TmpVectors.Vector2[0].set(
+                    checkNaN(this._goalTargetScreenOffset.x, this.targetScreenOffset.x),
+                    checkNaN(this._goalTargetScreenOffset.y, this.targetScreenOffset.y)
+                );
+                Vector2.LerpToRef(this.targetScreenOffset, goalTargetScreenOffset, t, this.targetScreenOffset);
+
+                // Terminate the target screen offset interpolation when the target screen offset appears visually close to the final goal target screen offset.
+                if (Vector2.Distance(this.targetScreenOffset, goalTargetScreenOffset) < Epsilon) {
+                    this._goalTargetScreenOffset.set(NaN, NaN);
+                    this.targetScreenOffset.copyFrom(goalTargetScreenOffset);
+                } else {
+                    isInterpolating = true;
+                }
+            }
+
+            this._isInterpolating = isInterpolating;
+        }
+
         // Limits
         this._checkLimits();
 
diff --git a/packages/dev/core/test/unit/Cameras/babylon.arcRotateCameraInputs.test.ts b/packages/dev/core/test/unit/Cameras/babylon.arcRotateCameraInputs.test.ts
index 9752434725da..eedac2077892 100644
--- a/packages/dev/core/test/unit/Cameras/babylon.arcRotateCameraInputs.test.ts
+++ b/packages/dev/core/test/unit/Cameras/babylon.arcRotateCameraInputs.test.ts
@@ -5,7 +5,8 @@ import { DeviceType, PointerInput } from "core/DeviceInput/InputDevices/deviceEn
 import { NullEngine } from "core/Engines/nullEngine";
 import { PointerEventTypes, PointerInfo } from "core/Events";
 import type { IMouseEvent } from "core/Events/deviceInputEvents";
-import { Vector3 } from "core/Maths/math.vector";
+import { Vector2, Vector3 } from "core/Maths/math.vector";
+import { WithinEpsilon } from "core/Maths/math.scalar.functions";
 import { Scene } from "core/scene";
 import type { Nullable } from "core/types";
 import { TestDeviceInputSystem } from "../DeviceInput/testDeviceInputSystem";
@@ -13,7 +14,25 @@ import { MeshBuilder } from "core/Meshes/meshBuilder";
 import { Frustum } from "core/Maths";
 import { StandardMaterial } from "core/Materials";
 
-describe("ArcRotateCameraMouseInput", () => {
+function waitForCondition(condition: () => boolean, timeout: number = 5000, interval: number = 50): Promise<void> {
+    return new Promise((resolve, reject) => {
+        const startTime = Date.now();
+
+        const checkCondition = () => {
+            if (condition()) {
+                resolve();
+            } else if (Date.now() - startTime >= timeout) {
+                reject(new Error(`Timeout waiting for condition to become true: ${condition}`));
+            } else {
+                setTimeout(checkCondition, interval);
+            }
+        };
+
+        checkCondition();
+    });
+}
+
+describe("ArcRotateCamera", () => {
     let engine: Nullable<NullEngine> = null;
     let scene: Nullable<Scene> = null;
     let camera: Nullable<ArcRotateCamera> = null;
@@ -31,6 +50,7 @@ describe("ArcRotateCameraMouseInput", () => {
         camera = new ArcRotateCamera("camera", 0, 0, 10, Vector3.Zero(), scene);
         camera.setTarget(Vector3.Zero());
         camera.attachControl();
+        camera.restoreStateInterpolationFactor = 0.01;
     });
 
     afterEach(() => {
@@ -39,173 +59,225 @@ describe("ArcRotateCameraMouseInput", () => {
         engine?.dispose();
     });
 
-    it("ignores any touch inputs after the second", () => {
-        let radius = camera!.radius;
-        const alpha = camera!.alpha;
-        const beta = camera!.beta;
-        const testDeviceInputSystem = new TestDeviceInputSystem(
-            engine!,
-            () => {},
-            () => {},
-            () => {}
-        );
-        testDeviceInputSystem.connectDevice(DeviceType.Touch, 0, TestDeviceInputSystem.MAX_POINTER_INPUTS);
-        testDeviceInputSystem.connectDevice(DeviceType.Touch, 1, TestDeviceInputSystem.MAX_POINTER_INPUTS);
-
-        // First touch events
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.LeftClick, 1, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Horizontal, 0, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Vertical, 0, false);
-
-        const downEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.LeftClick, 1, testDeviceInputSystem);
-        const downPI1 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt1 as IMouseEvent, new PickingInfo());
-
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Horizontal, 15, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Vertical, 15, false);
-
-        const moveEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.Move, 1, testDeviceInputSystem);
-        const movePI1 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt1 as IMouseEvent, new PickingInfo());
-
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.LeftClick, 0, false);
-
-        const upEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.LeftClick, 0, testDeviceInputSystem);
-        const upPI1 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt1 as IMouseEvent, new PickingInfo());
-
-        // Second touch events
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.LeftClick, 1, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Horizontal, 127, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Vertical, 127, false);
-
-        const downEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.LeftClick, 1, testDeviceInputSystem);
-        const downPI2 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt2 as IMouseEvent, new PickingInfo());
-
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Horizontal, 112, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Vertical, 112, false);
-
-        const moveEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.Move, 1, testDeviceInputSystem);
-        const movePI2 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt2 as IMouseEvent, new PickingInfo());
-
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.LeftClick, 0, false);
-
-        const upEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.LeftClick, 0, testDeviceInputSystem);
-        const upPI2 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt2 as IMouseEvent, new PickingInfo());
-
-        // Third touch events
-        testDeviceInputSystem.connectDevice(DeviceType.Touch, 2, TestDeviceInputSystem.MAX_POINTER_INPUTS);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.LeftClick, 1, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 64, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 64, false);
+    describe("MouseInput", () => {
+        it("ignores any touch inputs after the second", () => {
+            let radius = camera!.radius;
+            const alpha = camera!.alpha;
+            const beta = camera!.beta;
+            const testDeviceInputSystem = new TestDeviceInputSystem(
+                engine!,
+                () => {},
+                () => {},
+                () => {}
+            );
+            testDeviceInputSystem.connectDevice(DeviceType.Touch, 0, TestDeviceInputSystem.MAX_POINTER_INPUTS);
+            testDeviceInputSystem.connectDevice(DeviceType.Touch, 1, TestDeviceInputSystem.MAX_POINTER_INPUTS);
 
-        const downEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.LeftClick, 1, testDeviceInputSystem);
-        const downPI3 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt3 as IMouseEvent, new PickingInfo());
+            // First touch events
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.LeftClick, 1, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Horizontal, 0, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Vertical, 0, false);
+
+            const downEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.LeftClick, 1, testDeviceInputSystem);
+            const downPI1 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt1 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Horizontal, 15, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.Vertical, 15, false);
+
+            const moveEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.Move, 1, testDeviceInputSystem);
+            const movePI1 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt1 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 0, PointerInput.LeftClick, 0, false);
+
+            const upEvt1 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 0, PointerInput.LeftClick, 0, testDeviceInputSystem);
+            const upPI1 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt1 as IMouseEvent, new PickingInfo());
+
+            // Second touch events
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.LeftClick, 1, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Horizontal, 127, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Vertical, 127, false);
+
+            const downEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.LeftClick, 1, testDeviceInputSystem);
+            const downPI2 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt2 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Horizontal, 112, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.Vertical, 112, false);
+
+            const moveEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.Move, 1, testDeviceInputSystem);
+            const movePI2 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt2 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 1, PointerInput.LeftClick, 0, false);
+
+            const upEvt2 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 1, PointerInput.LeftClick, 0, testDeviceInputSystem);
+            const upPI2 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt2 as IMouseEvent, new PickingInfo());
+
+            // Third touch events
+            testDeviceInputSystem.connectDevice(DeviceType.Touch, 2, TestDeviceInputSystem.MAX_POINTER_INPUTS);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.LeftClick, 1, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 64, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 64, false);
+
+            const downEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.LeftClick, 1, testDeviceInputSystem);
+            const downPI3 = new PointerInfo(PointerEventTypes.POINTERDOWN, downEvt3 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 50, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 50, false);
+
+            const moveEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.Move, 1, testDeviceInputSystem);
+            const movePI3 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt3 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.LeftClick, 0, false);
+
+            const upEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.LeftClick, 0, testDeviceInputSystem);
+            const upPI3 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt3 as IMouseEvent, new PickingInfo());
+
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 64, false);
+            testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 64, false);
+
+            const moveEvt4 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.Move, 1, testDeviceInputSystem);
+            const movePI4 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt4 as IMouseEvent, new PickingInfo());
+
+            // Start pinch gesture
+            scene?.onPointerObservable.notifyObservers(downPI1);
+            scene?.onPointerObservable.notifyObservers(downPI2);
+            scene?.onPointerObservable.notifyObservers(movePI1);
+            scene?.onPointerObservable.notifyObservers(movePI2);
+            scene?.render();
+            expect(camera!.radius).toBeGreaterThan(radius);
+
+            radius = camera!.radius;
+
+            // Remove any pending movement
+            camera!.inertialRadiusOffset = 0;
+
+            // Move third while first and second are still down
+            // No changes should occur
+            scene?.onPointerObservable.notifyObservers(downPI3);
+            scene?.onPointerObservable.notifyObservers(movePI3);
+            scene?.render();
+            expect(camera!.radius).toEqual(radius);
+
+            // Release first only, no radius changes should occur
+            scene?.onPointerObservable.notifyObservers(upPI1);
+            scene?.onPointerObservable.notifyObservers(movePI4);
+            scene?.render();
+            expect(camera!.radius).toEqual(radius);
+
+            // Release second, no radius changes should occur
+            scene?.onPointerObservable.notifyObservers(upPI2);
+            scene?.onPointerObservable.notifyObservers(movePI3);
+            scene?.onPointerObservable.notifyObservers(upPI3);
+            scene?.render();
+            expect(camera!.radius).toEqual(radius);
 
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 50, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 50, false);
+            // Once all are released, third should be able to move the camera
+            scene?.onPointerObservable.notifyObservers(downPI3);
+            scene?.onPointerObservable.notifyObservers(movePI3);
+            scene?.onPointerObservable.notifyObservers(upPI3);
+            scene?.render();
 
-        const moveEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.Move, 1, testDeviceInputSystem);
-        const movePI3 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt3 as IMouseEvent, new PickingInfo());
-
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.LeftClick, 0, false);
-
-        const upEvt3 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.LeftClick, 0, testDeviceInputSystem);
-        const upPI3 = new PointerInfo(PointerEventTypes.POINTERUP, upEvt3 as IMouseEvent, new PickingInfo());
+            expect(camera!.alpha).toBeGreaterThan(alpha);
+            expect(camera!.beta).toBeGreaterThan(beta);
+        });
 
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Horizontal, 64, false);
-        testDeviceInputSystem.changeInput(DeviceType.Touch, 2, PointerInput.Vertical, 64, false);
+        it("correctly zooms when zoomOn is called", () => {
+            let outOfBoundsPoints = 0;
+            let inBoundsPoints = 0;
+
+            if (camera && scene && StandardMaterial) {
+                // Create box to check zoomOn against
+                const box = MeshBuilder.CreateBox("box", { height: 1, width: 2, depth: 1 }, scene);
+                // Set angles such that the box's mix/max points are not technically
+                // the farthest points in screen/camera space
+                camera.alpha = Math.PI / 3;
+                camera.beta = Math.PI / 2.5;
+                camera.radius = 0.01;
+                box.position = new Vector3(0, 0.5, 0);
+                scene.render();
+
+                // Get frustum planes from camera transformation matrix
+                let transformMatrix = camera.getTransformationMatrix();
+                let frustumPlanes = Frustum.GetPlanes(transformMatrix);
+
+                // Get all bounding box points and check if they are in the frustum
+                // both before and after zoomOn
+                const pointsToCheck = box.getBoundingInfo().boundingBox.vectorsWorld;
+
+                // Before zoomOn
+                for (const point of pointsToCheck) {
+                    if (!Frustum.IsPointInFrustum(point, frustumPlanes)) {
+                        outOfBoundsPoints++;
+                    }
+                }
 
-        const moveEvt4 = DeviceEventFactory.CreateDeviceEvent(DeviceType.Touch, 2, PointerInput.Move, 1, testDeviceInputSystem);
-        const movePI4 = new PointerInfo(PointerEventTypes.POINTERMOVE, moveEvt4 as IMouseEvent, new PickingInfo());
+                scene.render();
+                camera.zoomOn([box]);
+                scene.render();
 
-        // Start pinch gesture
-        scene?.onPointerObservable.notifyObservers(downPI1);
-        scene?.onPointerObservable.notifyObservers(downPI2);
-        scene?.onPointerObservable.notifyObservers(movePI1);
-        scene?.onPointerObservable.notifyObservers(movePI2);
-        scene?.render();
-        expect(camera!.radius).toBeGreaterThan(radius);
+                // Update frustum planes and transformation matrix
+                transformMatrix = camera.getTransformationMatrix();
+                frustumPlanes = Frustum.GetPlanes(transformMatrix);
 
-        radius = camera!.radius;
+                // After zoomOn
+                for (const point of pointsToCheck) {
+                    if (Frustum.IsPointInFrustum(point, frustumPlanes)) {
+                        inBoundsPoints++;
+                    }
+                }
+            }
 
-        // Remove any pending movement
-        camera!.inertialRadiusOffset = 0;
+            expect(outOfBoundsPoints).toEqual(8);
+            expect(inBoundsPoints).toEqual(8);
+        });
+    });
 
-        // Move third while first and second are still down
-        // No changes should occur
-        scene?.onPointerObservable.notifyObservers(downPI3);
-        scene?.onPointerObservable.notifyObservers(movePI3);
-        scene?.render();
-        expect(camera!.radius).toEqual(radius);
+    describe("Interpolation", () => {
+        it("arrives at goal", async () => {
+            engine!.runRenderLoop(() => {
+                scene!.render();
+            });
+
+            camera!.interpolateTo(1, 2, 3, new Vector3(4, 5, 6), new Vector2(7, 8), 0.01);
+            await waitForCondition(() => !camera!.isInterpolating);
+
+            expect(WithinEpsilon(camera!.alpha, 1)).toBe(true);
+            expect(WithinEpsilon(camera!.beta, 2)).toBe(true);
+            expect(WithinEpsilon(camera!.radius, 3)).toBe(true);
+            expect(WithinEpsilon(camera!.target.x, 4)).toBe(true);
+            expect(WithinEpsilon(camera!.target.y, 5)).toBe(true);
+            expect(WithinEpsilon(camera!.target.z, 6)).toBe(true);
+            expect(WithinEpsilon(camera!.targetScreenOffset.x, 7)).toBe(true);
+            expect(WithinEpsilon(camera!.targetScreenOffset.y, 8)).toBe(true);
+        });
 
-        // Release first only, no radius changes should occur
-        scene?.onPointerObservable.notifyObservers(upPI1);
-        scene?.onPointerObservable.notifyObservers(movePI4);
-        scene?.render();
-        expect(camera!.radius).toEqual(radius);
-
-        // Release second, no radius changes should occur
-        scene?.onPointerObservable.notifyObservers(upPI2);
-        scene?.onPointerObservable.notifyObservers(movePI3);
-        scene?.onPointerObservable.notifyObservers(upPI3);
-        scene?.render();
-        expect(camera!.radius).toEqual(radius);
+        it("undefined uses current value", async () => {
+            engine!.runRenderLoop(() => {
+                scene!.render();
+            });
 
-        // Once all are released, third should be able to move the camera
-        scene?.onPointerObservable.notifyObservers(downPI3);
-        scene?.onPointerObservable.notifyObservers(movePI3);
-        scene?.onPointerObservable.notifyObservers(upPI3);
-        scene?.render();
+            camera!.alpha = 1;
+            camera!.interpolateTo(2);
+            camera!.interpolateTo(undefined, 2);
 
-        expect(camera!.alpha).toBeGreaterThan(alpha);
-        expect(camera!.beta).toBeGreaterThan(beta);
-    });
+            await waitForCondition(() => !camera!.isInterpolating);
 
-    it("correctly zooms when zoomOn is called", () => {
-        let outOfBoundsPoints = 0;
-        let inBoundsPoints = 0;
-
-        if (camera && scene && StandardMaterial) {
-            // Create box to check zoomOn against
-            const box = MeshBuilder.CreateBox("box", { height: 1, width: 2, depth: 1 }, scene);
-            // Set angles such that the box's mix/max points are not technically
-            // the farthest points in screen/camera space
-            camera.alpha = Math.PI / 3;
-            camera.beta = Math.PI / 2.5;
-            camera.radius = 0.01;
-            box.position = new Vector3(0, 0.5, 0);
-            scene.render();
-
-            // Get frustum planes from camera transformation matrix
-            let transformMatrix = camera.getTransformationMatrix();
-            let frustumPlanes = Frustum.GetPlanes(transformMatrix);
-
-            // Get all bounding box points and check if they are in the frustum
-            // both before and after zoomOn
-            const pointsToCheck = box.getBoundingInfo().boundingBox.vectorsWorld;
-
-            // Before zoomOn
-            for (const point of pointsToCheck) {
-                if (!Frustum.IsPointInFrustum(point, frustumPlanes)) {
-                    outOfBoundsPoints++;
-                }
-            }
+            expect(WithinEpsilon(camera!.alpha, 1)).toBe(true);
+            expect(WithinEpsilon(camera!.beta, 2)).toBe(true);
+        });
 
-            scene.render();
-            camera.zoomOn([box]);
-            scene.render();
+        it("NaN uses current goal", async () => {
+            engine!.runRenderLoop(() => {
+                scene!.render();
+            });
 
-            // Update frustum planes and transformation matrix
-            transformMatrix = camera.getTransformationMatrix();
-            frustumPlanes = Frustum.GetPlanes(transformMatrix);
+            camera!.alpha = 1;
+            camera!.interpolateTo(2);
+            camera!.interpolateTo(NaN, 2);
 
-            // After zoomOn
-            for (const point of pointsToCheck) {
-                if (Frustum.IsPointInFrustum(point, frustumPlanes)) {
-                    inBoundsPoints++;
-                }
-            }
-        }
+            await waitForCondition(() => !camera!.isInterpolating);
 
-        expect(outOfBoundsPoints).toEqual(8);
-        expect(inBoundsPoints).toEqual(8);
+            expect(WithinEpsilon(camera!.alpha, 2)).toBe(true);
+            expect(WithinEpsilon(camera!.beta, 2)).toBe(true);
+        });
     });
 });


Reviews:
user: login: deltakosh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:14:42Z
user: login: deltakosh
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:15:09Z
user: login: ryantrem
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T20:40:30Z
user: login: deltakosh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:42:16Z
user: login: ryantrem
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T21:19:58Z
user: login: deltakosh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T21:21:21Z
user: login: AmoebaChant
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T00:01:59Z
user: login: ryantrem
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T01:25:42Z
user: login: ryantrem
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T01:25:52Z
user: login: ryantrem
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T01:26:37Z
user: login: CedricGuillemet
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T07:39:41Z
user: login: AmoebaChant
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T15:56:52Z
user: login: sebavan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T20:04:13Z

Review comments:
diff_hunk: @@ -899,16 +911,24 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        const selectGoalValue = (newGoal: number, currentGoal: number): number => (isNaN(newGoal) ? currentGoal : newGoal);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: deltakosh
body: maybe move it outside the loop as we do not need to redefined it everytime
created_at: 2025-03-31T20:14:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -899,16 +911,24 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        const selectGoalValue = (newGoal: number, currentGoal: number): number => (isNaN(newGoal) ? currentGoal : newGoal);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: ryantrem
body: Ok I changed it to use a helper function at the module level. That said, from what I've observed, modern JS engines are smart enough to not re-allocate lambdas that don't capture local state. It's true though that making it a module level function eliminates a dependency on this JS engine implementation detail.
created_at: 2025-03-31T20:40:30Z
author_association: MEMBER
diff_hunk: @@ -899,16 +911,24 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        const selectGoalValue = (newGoal: number, currentGoal: number): number => (isNaN(newGoal) ? currentGoal : newGoal);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: deltakosh
body: oh interesting! how did you see that?
created_at: 2025-03-31T20:42:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -899,16 +911,24 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        const selectGoalValue = (newGoal: number, currentGoal: number): number => (isNaN(newGoal) ? currentGoal : newGoal);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: ryantrem
body: It came up previously with Viewer related stuff and I investigated a bit. You can easily see it with something like this:
```ts
function outer() {
    const someLocal = performance.now();

    const lambdaWithCapture = () => {
        console.log(`With capture: ${someLocal}`);
    };
    lambdaWithCapture();

    const lambdaWithoutCapture = () => {
        console.log("Without capture");
    };
    lambdaWithoutCapture();
}

for (let i = 0; i < 100; i++) {
    outer();
}
```

If you do a memory profile, and look at allocations, you will see:
![image](https://github.com/user-attachments/assets/819d5c06-7121-4cb3-a752-140cf00e78e5)

Admittedly I don't know why the count is 201 instead of 100. But in comparison the `lambdaWithoutCapture` is just 1.
created_at: 2025-03-31T21:19:57Z
author_association: MEMBER
diff_hunk: @@ -899,16 +911,24 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        const selectGoalValue = (newGoal: number, currentGoal: number): number => (isNaN(newGoal) ? currentGoal : newGoal);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: deltakosh
body: excellent! thanks for the good learning!
created_at: 2025-03-31T21:21:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -899,16 +923,19 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        this._goalAlpha = checkNaN(alpha, this._goalAlpha);
+        this._goalBeta = checkNaN(beta, this._goalBeta);
+        this._goalRadius = checkNaN(radius, this._goalRadius);
+        this._goalTarget.set(checkNaN(target?.x, this._goalTarget.x), checkNaN(target?.y, this._goalTarget.y), checkNaN(target?.z, this._goalTarget.z));
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: AmoebaChant
body: target can't be null can it?  Not sure why the optional chaining is needed here.
created_at: 2025-03-31T23:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -899,16 +923,19 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        this._goalAlpha = checkNaN(alpha, this._goalAlpha);
+        this._goalBeta = checkNaN(beta, this._goalBeta);
+        this._goalRadius = checkNaN(radius, this._goalRadius);
+        this._goalTarget.set(checkNaN(target?.x, this._goalTarget.x), checkNaN(target?.y, this._goalTarget.y), checkNaN(target?.z, this._goalTarget.z));
+        this._goalTargetScreenOffset.set(checkNaN(targetScreenOffset?.x, this._goalTargetScreenOffset.x), checkNaN(targetScreenOffset?.y, this._goalTargetScreenOffset.y));
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: AmoebaChant
body: Same with targetScreenOffset - can it be null?
created_at: 2025-03-31T23:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1124,6 +1121,98 @@ export class ArcRotateCamera extends TargetCamera {
             }
         }
 
+        if (hasUserInteractions) {
+            this.stopInterpolation();
+        } else if (this._isInterpolating) {
+            let isInterpolating = false;
+            const dt = this._scene.getEngine().getDeltaTime() / 1000;
+            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
+
+            // NOTE: If the goal is NaN, it means we are not interpolating to a new value, so we can use the current value. Hence the calls to checkNaN.
+
+            // Get the goal radius immediately as we'll need it for determining interpolation termination for the target.
+            const goalRadius = checkNaN(this._goalRadius, this.radius);
+
+            // Interpolate the target if we haven't reached the goal yet.
+            if (!isNaN(this._goalTarget.x) || !isNaN(this._goalTarget.y) || !isNaN(this._goalTarget.z)) {
+                isInterpolating = true;
+
+                const goalTarget = TmpVectors.Vector3[0].set(
+                    checkNaN(this._goalTarget.x, this._target.x),
+                    checkNaN(this._goalTarget.y, this._target.y),
+                    checkNaN(this._goalTarget.z, this._target.z)
+                );
+                this.setTarget(Vector3.Lerp(this.target, goalTarget, t), undefined, undefined, true);
+
+                // Terminate the target interpolation if we the target is close relative to the radius.
+                // This is when visually (regardless of scale) the target appears close to its final goal position.
+                if ((Vector3.Distance(this.target, goalTarget) * 10) / goalRadius < Epsilon) {
+                    this._goalTarget.set(NaN, NaN, NaN);
+                    this.setTarget(goalTarget.clone(), undefined, undefined, true);
+                }
+            }
+
+            // Interpolate the rotation if we haven't reached the goal yet.
+            if (!isNaN(this._goalAlpha) || !isNaN(this._goalBeta)) {
+                isInterpolating = true;
+
+                // Using quaternion for smoother interpolation (and no Euler angles modulo)
+                const goalRotation = Quaternion.RotationAlphaBetaGammaToRef(
+                    checkNaN(this._goalAlpha, this.alpha),
+                    checkNaN(this._goalBeta, this.beta),
+                    0,
+                    TmpVectors.Quaternion[0]
+                );
+                const currentRotation = Quaternion.RotationAlphaBetaGammaToRef(this.alpha, this.beta, 0, TmpVectors.Quaternion[1]);
+                const newRotation = Quaternion.SlerpToRef(currentRotation, goalRotation, t, TmpVectors.Quaternion[2]);
+                newRotation.normalize();
+                const newAlphaBetaGamma = newRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                this.alpha = newAlphaBetaGamma.x;
+                this.beta = newAlphaBetaGamma.y;
+
+                // Terminate the rotation interpolation when the rotation appears visually close to the final goal rotation.
+                if (newRotation.isApprox(goalRotation, Epsilon / 5)) {
+                    this._goalAlpha = NaN;
+                    this._goalBeta = NaN;
+                    const goalAlphaBetaGamma = goalRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                    this.alpha = goalAlphaBetaGamma.x;
+                    this.beta = goalAlphaBetaGamma.y;
+                }
+            }
+
+            // Interpolate the radius if we haven't reached the goal yet.
+            if (!isNaN(this._goalRadius)) {
+                isInterpolating = true;
+
+                this.radius += (goalRadius - this.radius) * t;
+
+                // Terminate the radius interpolation when we are 99.9% of the way to the goal radius, at which point it is visually indistinguishable from the goal.
+                if (Math.abs(goalRadius / this.radius - 1) < Epsilon) {
+                    this._goalRadius = NaN;
+                    this.radius = goalRadius;
+                }
+            }
+
+            // Interpolate the target screen offset if we haven't reached the goal yet.
+            if (!isNaN(this._goalTargetScreenOffset.x) || !isNaN(this._goalTargetScreenOffset.y)) {
+                isInterpolating = true;
+
+                const goalTargetScreenOffset = TmpVectors.Vector2[0].set(
+                    checkNaN(this._goalTargetScreenOffset.x, this.targetScreenOffset.x),
+                    checkNaN(this._goalTargetScreenOffset.y, this.targetScreenOffset.y)
+                );
+                Vector2.LerpToRef(this.targetScreenOffset, goalTargetScreenOffset, t, this.targetScreenOffset);
+
+                // Terminate the target screen offset interpolation when the target screen offset appears visually close to the final goal target screen offset.
+                if (Vector2.Distance(this.targetScreenOffset, goalTargetScreenOffset) < Epsilon) {
+                    this._goalTargetScreenOffset.set(NaN, NaN);
+                    this.targetScreenOffset.copyFrom(goalTargetScreenOffset);
+                }
+            }
+
+            this._isInterpolating = isInterpolating;
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: AmoebaChant
body: All of the blocks above can set isInterpolating to true if they see a goal that isn't NaN, but they can also determine that we've reached the goal and set the goal to NaN, yet they'll leave isInterpolating true.  Will waiting until later to set isInterpolating to false have any side effects?
created_at: 2025-04-01T00:00:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -899,16 +923,19 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        this._goalAlpha = checkNaN(alpha, this._goalAlpha);
+        this._goalBeta = checkNaN(beta, this._goalBeta);
+        this._goalRadius = checkNaN(radius, this._goalRadius);
+        this._goalTarget.set(checkNaN(target?.x, this._goalTarget.x), checkNaN(target?.y, this._goalTarget.y), checkNaN(target?.z, this._goalTarget.z));
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: ryantrem
body: Oh yea good catch, this was a holdover from an earlier iteration of the change where the params could be undefined.
created_at: 2025-04-01T01:25:42Z
author_association: MEMBER
diff_hunk: @@ -899,16 +923,19 @@ export class ArcRotateCamera extends TargetCamera {
             this._currentInterpolationFactor = 0.1;
         }
 
-        alpha = Clamp(alpha, this.lowerAlphaLimit ?? -Infinity, this.upperAlphaLimit ?? Infinity);
-        beta = Clamp(beta, this.lowerBetaLimit ?? -Infinity, this.upperBetaLimit ?? Infinity);
-        radius = Clamp(radius, this.lowerRadiusLimit ?? -Infinity, this.upperRadiusLimit ?? Infinity);
-        target.y = Clamp(target.y, this.lowerTargetYLimit ?? -Infinity, Infinity);
+        // If NaN is passed in for a goal value, keep the current goal value.
+        this._goalAlpha = checkNaN(alpha, this._goalAlpha);
+        this._goalBeta = checkNaN(beta, this._goalBeta);
+        this._goalRadius = checkNaN(radius, this._goalRadius);
+        this._goalTarget.set(checkNaN(target?.x, this._goalTarget.x), checkNaN(target?.y, this._goalTarget.y), checkNaN(target?.z, this._goalTarget.z));
+        this._goalTargetScreenOffset.set(checkNaN(targetScreenOffset?.x, this._goalTargetScreenOffset.x), checkNaN(targetScreenOffset?.y, this._goalTargetScreenOffset.y));
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: ryantrem
body: Same as above, thanks!
created_at: 2025-04-01T01:25:52Z
author_association: MEMBER
diff_hunk: @@ -1124,6 +1121,98 @@ export class ArcRotateCamera extends TargetCamera {
             }
         }
 
+        if (hasUserInteractions) {
+            this.stopInterpolation();
+        } else if (this._isInterpolating) {
+            let isInterpolating = false;
+            const dt = this._scene.getEngine().getDeltaTime() / 1000;
+            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
+
+            // NOTE: If the goal is NaN, it means we are not interpolating to a new value, so we can use the current value. Hence the calls to checkNaN.
+
+            // Get the goal radius immediately as we'll need it for determining interpolation termination for the target.
+            const goalRadius = checkNaN(this._goalRadius, this.radius);
+
+            // Interpolate the target if we haven't reached the goal yet.
+            if (!isNaN(this._goalTarget.x) || !isNaN(this._goalTarget.y) || !isNaN(this._goalTarget.z)) {
+                isInterpolating = true;
+
+                const goalTarget = TmpVectors.Vector3[0].set(
+                    checkNaN(this._goalTarget.x, this._target.x),
+                    checkNaN(this._goalTarget.y, this._target.y),
+                    checkNaN(this._goalTarget.z, this._target.z)
+                );
+                this.setTarget(Vector3.Lerp(this.target, goalTarget, t), undefined, undefined, true);
+
+                // Terminate the target interpolation if we the target is close relative to the radius.
+                // This is when visually (regardless of scale) the target appears close to its final goal position.
+                if ((Vector3.Distance(this.target, goalTarget) * 10) / goalRadius < Epsilon) {
+                    this._goalTarget.set(NaN, NaN, NaN);
+                    this.setTarget(goalTarget.clone(), undefined, undefined, true);
+                }
+            }
+
+            // Interpolate the rotation if we haven't reached the goal yet.
+            if (!isNaN(this._goalAlpha) || !isNaN(this._goalBeta)) {
+                isInterpolating = true;
+
+                // Using quaternion for smoother interpolation (and no Euler angles modulo)
+                const goalRotation = Quaternion.RotationAlphaBetaGammaToRef(
+                    checkNaN(this._goalAlpha, this.alpha),
+                    checkNaN(this._goalBeta, this.beta),
+                    0,
+                    TmpVectors.Quaternion[0]
+                );
+                const currentRotation = Quaternion.RotationAlphaBetaGammaToRef(this.alpha, this.beta, 0, TmpVectors.Quaternion[1]);
+                const newRotation = Quaternion.SlerpToRef(currentRotation, goalRotation, t, TmpVectors.Quaternion[2]);
+                newRotation.normalize();
+                const newAlphaBetaGamma = newRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                this.alpha = newAlphaBetaGamma.x;
+                this.beta = newAlphaBetaGamma.y;
+
+                // Terminate the rotation interpolation when the rotation appears visually close to the final goal rotation.
+                if (newRotation.isApprox(goalRotation, Epsilon / 5)) {
+                    this._goalAlpha = NaN;
+                    this._goalBeta = NaN;
+                    const goalAlphaBetaGamma = goalRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                    this.alpha = goalAlphaBetaGamma.x;
+                    this.beta = goalAlphaBetaGamma.y;
+                }
+            }
+
+            // Interpolate the radius if we haven't reached the goal yet.
+            if (!isNaN(this._goalRadius)) {
+                isInterpolating = true;
+
+                this.radius += (goalRadius - this.radius) * t;
+
+                // Terminate the radius interpolation when we are 99.9% of the way to the goal radius, at which point it is visually indistinguishable from the goal.
+                if (Math.abs(goalRadius / this.radius - 1) < Epsilon) {
+                    this._goalRadius = NaN;
+                    this.radius = goalRadius;
+                }
+            }
+
+            // Interpolate the target screen offset if we haven't reached the goal yet.
+            if (!isNaN(this._goalTargetScreenOffset.x) || !isNaN(this._goalTargetScreenOffset.y)) {
+                isInterpolating = true;
+
+                const goalTargetScreenOffset = TmpVectors.Vector2[0].set(
+                    checkNaN(this._goalTargetScreenOffset.x, this.targetScreenOffset.x),
+                    checkNaN(this._goalTargetScreenOffset.y, this.targetScreenOffset.y)
+                );
+                Vector2.LerpToRef(this.targetScreenOffset, goalTargetScreenOffset, t, this.targetScreenOffset);
+
+                // Terminate the target screen offset interpolation when the target screen offset appears visually close to the final goal target screen offset.
+                if (Vector2.Distance(this.targetScreenOffset, goalTargetScreenOffset) < Epsilon) {
+                    this._goalTargetScreenOffset.set(NaN, NaN);
+                    this.targetScreenOffset.copyFrom(goalTargetScreenOffset);
+                }
+            }
+
+            this._isInterpolating = isInterpolating;
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: ryantrem
body: Hmm, in practice it *probably* doesn't hurt, but it does mean this redundant state is out of sync for 1 frame, which I don't like. I made a change that I think is more correct, let me know if you agree.
created_at: 2025-04-01T01:26:37Z
author_association: MEMBER
diff_hunk: @@ -1124,6 +1121,98 @@ export class ArcRotateCamera extends TargetCamera {
             }
         }
 
+        if (hasUserInteractions) {
+            this.stopInterpolation();
+        } else if (this._isInterpolating) {
+            let isInterpolating = false;
+            const dt = this._scene.getEngine().getDeltaTime() / 1000;
+            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
+
+            // NOTE: If the goal is NaN, it means we are not interpolating to a new value, so we can use the current value. Hence the calls to checkNaN.
+
+            // Get the goal radius immediately as we'll need it for determining interpolation termination for the target.
+            const goalRadius = checkNaN(this._goalRadius, this.radius);
+
+            // Interpolate the target if we haven't reached the goal yet.
+            if (!isNaN(this._goalTarget.x) || !isNaN(this._goalTarget.y) || !isNaN(this._goalTarget.z)) {
+                isInterpolating = true;
+
+                const goalTarget = TmpVectors.Vector3[0].set(
+                    checkNaN(this._goalTarget.x, this._target.x),
+                    checkNaN(this._goalTarget.y, this._target.y),
+                    checkNaN(this._goalTarget.z, this._target.z)
+                );
+                this.setTarget(Vector3.Lerp(this.target, goalTarget, t), undefined, undefined, true);
+
+                // Terminate the target interpolation if we the target is close relative to the radius.
+                // This is when visually (regardless of scale) the target appears close to its final goal position.
+                if ((Vector3.Distance(this.target, goalTarget) * 10) / goalRadius < Epsilon) {
+                    this._goalTarget.set(NaN, NaN, NaN);
+                    this.setTarget(goalTarget.clone(), undefined, undefined, true);
+                }
+            }
+
+            // Interpolate the rotation if we haven't reached the goal yet.
+            if (!isNaN(this._goalAlpha) || !isNaN(this._goalBeta)) {
+                isInterpolating = true;
+
+                // Using quaternion for smoother interpolation (and no Euler angles modulo)
+                const goalRotation = Quaternion.RotationAlphaBetaGammaToRef(
+                    checkNaN(this._goalAlpha, this.alpha),
+                    checkNaN(this._goalBeta, this.beta),
+                    0,
+                    TmpVectors.Quaternion[0]
+                );
+                const currentRotation = Quaternion.RotationAlphaBetaGammaToRef(this.alpha, this.beta, 0, TmpVectors.Quaternion[1]);
+                const newRotation = Quaternion.SlerpToRef(currentRotation, goalRotation, t, TmpVectors.Quaternion[2]);
+                newRotation.normalize();
+                const newAlphaBetaGamma = newRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                this.alpha = newAlphaBetaGamma.x;
+                this.beta = newAlphaBetaGamma.y;
+
+                // Terminate the rotation interpolation when the rotation appears visually close to the final goal rotation.
+                if (newRotation.isApprox(goalRotation, Epsilon / 5)) {
+                    this._goalAlpha = NaN;
+                    this._goalBeta = NaN;
+                    const goalAlphaBetaGamma = goalRotation.toAlphaBetaGammaToRef(TmpVectors.Vector3[0]);
+                    this.alpha = goalAlphaBetaGamma.x;
+                    this.beta = goalAlphaBetaGamma.y;
+                }
+            }
+
+            // Interpolate the radius if we haven't reached the goal yet.
+            if (!isNaN(this._goalRadius)) {
+                isInterpolating = true;
+
+                this.radius += (goalRadius - this.radius) * t;
+
+                // Terminate the radius interpolation when we are 99.9% of the way to the goal radius, at which point it is visually indistinguishable from the goal.
+                if (Math.abs(goalRadius / this.radius - 1) < Epsilon) {
+                    this._goalRadius = NaN;
+                    this.radius = goalRadius;
+                }
+            }
+
+            // Interpolate the target screen offset if we haven't reached the goal yet.
+            if (!isNaN(this._goalTargetScreenOffset.x) || !isNaN(this._goalTargetScreenOffset.y)) {
+                isInterpolating = true;
+
+                const goalTargetScreenOffset = TmpVectors.Vector2[0].set(
+                    checkNaN(this._goalTargetScreenOffset.x, this.targetScreenOffset.x),
+                    checkNaN(this._goalTargetScreenOffset.y, this.targetScreenOffset.y)
+                );
+                Vector2.LerpToRef(this.targetScreenOffset, goalTargetScreenOffset, t, this.targetScreenOffset);
+
+                // Terminate the target screen offset interpolation when the target screen offset appears visually close to the final goal target screen offset.
+                if (Vector2.Distance(this.targetScreenOffset, goalTargetScreenOffset) < Epsilon) {
+                    this._goalTargetScreenOffset.set(NaN, NaN);
+                    this.targetScreenOffset.copyFrom(goalTargetScreenOffset);
+                }
+            }
+
+            this._isInterpolating = isInterpolating;
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: AmoebaChant
body: I like that approach - looks good!
created_at: 2025-04-01T15:56:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1124,6 +1121,98 @@ export class ArcRotateCamera extends TargetCamera {
             }
         }
 
+        if (hasUserInteractions) {
+            this.stopInterpolation();
+        } else if (this._isInterpolating) {
+            let isInterpolating = false;
+            const dt = this._scene.getEngine().getDeltaTime() / 1000;
+            const t = 1 - Math.pow(2, -dt / this._currentInterpolationFactor);
+
+            // NOTE: If the goal is NaN, it means we are not interpolating to a new value, so we can use the current value. Hence the calls to checkNaN.
+
+            // Get the goal radius immediately as we'll need it for determining interpolation termination for the target.
+            const goalRadius = checkNaN(this._goalRadius, this.radius);
+
+            // Interpolate the target if we haven't reached the goal yet.
+            if (!isNaN(this._goalTarget.x) || !isNaN(this._goalTarget.y) || !isNaN(this._goalTarget.z)) {
+                const goalTarget = TmpVectors.Vector3[0].set(
+                    checkNaN(this._goalTarget.x, this._target.x),
+                    checkNaN(this._goalTarget.y, this._target.y),
+                    checkNaN(this._goalTarget.z, this._target.z)
+                );
+                this.setTarget(Vector3.Lerp(this.target, goalTarget, t), undefined, undefined, true);
path: packages/dev/core/src/Cameras/arcRotateCamera.ts
user: login: sebavan
body: Wondering if we could use the allocation free version ? sorry for being so late
created_at: 2025-04-01T20:04:13Z
author_association: MEMBER

PR comments:
user: login: bjsplat
created_at: 2025-03-31T20:02:23Z
body: Please make sure to label your PR with "bug", "new feature" or "breaking change" label(s).
To prevent this PR from going to the changelog marked it with the "skip changelog" label.
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-03-31T20:14:35Z
body: Snapshot stored with reference name:
refs/pull/16430/merge

Test environment: 
https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/index.html

To test a playground add it to the URL, for example:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/index.html#WGZLGJ#4600

Links to test babylon tools with this snapshot:

https://playground.babylonjs.com/?snapshot=refs/pull/16430/merge
https://sandbox.babylonjs.com/?snapshot=refs/pull/16430/merge
https://gui.babylonjs.com/?snapshot=refs/pull/16430/merge
https://nme.babylonjs.com/?snapshot=refs/pull/16430/merge

To test the snapshot in the playground with a playground ID add it after the snapshot query string:

https://playground.babylonjs.com/?snapshot=refs/pull/16430/merge#BCU1XR#0
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-03-31T20:25:55Z
body: Visualization tests for WebGPU

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgpuplaywright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-03-31T20:28:26Z
body: WebGL2 visualization test reporter:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgl2playwright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-03-31T21:07:25Z
body: Visualization tests for WebGPU

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgpuplaywright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-03-31T21:15:09Z
body: WebGL2 visualization test reporter:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgl2playwright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-04-01T00:24:16Z
body: Visualization tests for WebGPU

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgpuplaywright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-04-01T00:27:03Z
body: WebGL2 visualization test reporter:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgl2playwright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-04-01T01:54:08Z
body: WebGL2 visualization test reporter:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgl2playwright/index.html
author_association: COLLABORATOR
user: login: bjsplat
created_at: 2025-04-01T01:59:35Z
body: Visualization tests for WebGPU

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/16430/merge/testResults/webgpuplaywright/index.html
author_association: COLLABORATOR