From d1cdf5ca402994b5496bd83e21303349ee448b1e Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 08:50:33 -0500 Subject: [PATCH 1/3] feat(studio): add tested camera-parallax math (damped, fps-independent) --- ui/studio/src/lib/__tests__/parallax.test.ts | 25 ++++++++++++++++++++ ui/studio/src/lib/parallax.ts | 14 +++++++++++ 2 files changed, 39 insertions(+) create mode 100644 ui/studio/src/lib/__tests__/parallax.test.ts create mode 100644 ui/studio/src/lib/parallax.ts diff --git a/ui/studio/src/lib/__tests__/parallax.test.ts b/ui/studio/src/lib/__tests__/parallax.test.ts new file mode 100644 index 0000000..3dbe143 --- /dev/null +++ b/ui/studio/src/lib/__tests__/parallax.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { computeParallaxTarget, damp } from '../parallax'; + +describe('computeParallaxTarget', () => { + it('scales normalized pointer by intensity', () => { + expect(computeParallaxTarget({ x: 1, y: -1 }, 2)).toEqual({ x: 2, y: -2 }); + expect(computeParallaxTarget({ x: 0, y: 0 }, 5)).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('damp', () => { + it('returns the current value when dt is 0', () => { + expect(damp(3, 10, 5, 0)).toBeCloseTo(3, 6); + }); + + it('moves toward the target and never overshoots for positive lambda/dt', () => { + const next = damp(0, 10, 5, 0.016); + expect(next).toBeGreaterThan(0); + expect(next).toBeLessThan(10); + }); + + it('approaches the target as dt grows large', () => { + expect(damp(0, 10, 5, 100)).toBeCloseTo(10, 5); + }); +}); diff --git a/ui/studio/src/lib/parallax.ts b/ui/studio/src/lib/parallax.ts new file mode 100644 index 0000000..6b40e52 --- /dev/null +++ b/ui/studio/src/lib/parallax.ts @@ -0,0 +1,14 @@ +// Pure parallax helpers. Frame-rate-independent exponential damping so motion is +// identical at 30 or 144 fps. No three/react imports -> testable in plain node. +export interface Vec2 { + x: number; + y: number; +} + +export function computeParallaxTarget(pointer: Vec2, intensity: number): Vec2 { + return { x: pointer.x * intensity, y: pointer.y * intensity }; +} + +export function damp(current: number, target: number, lambda: number, dt: number): number { + return current + (target - current) * (1 - Math.exp(-lambda * dt)); +} From 46daa73c8ea308cef2befe5b9d8fa9aead4d887c Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 08:51:19 -0500 Subject: [PATCH 2/3] feat(studio): mouse-driven camera parallax via damped useFrame rig --- ui/studio/src/components/MatrixScene.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/studio/src/components/MatrixScene.tsx b/ui/studio/src/components/MatrixScene.tsx index 6d94d69..79f2c2d 100644 --- a/ui/studio/src/components/MatrixScene.tsx +++ b/ui/studio/src/components/MatrixScene.tsx @@ -6,6 +6,7 @@ import * as THREE from 'three'; import { AlphaRig } from './AlphaRig'; import { TelemetryMonitor } from './TelemetryMonitor'; import { useIntentPulse } from '../lib/useIntentPulse'; +import { computeParallaxTarget, damp } from '../lib/parallax'; import { RotaryDial } from './RotaryDial'; export function MatrixScene() { @@ -26,6 +27,11 @@ export function MatrixScene() { if (coreRef.current) { coreRef.current.scale.setScalar(1 + kick.current * 0.4); } + // Mouse-driven camera parallax (state.pointer is normalized -1..1). + const target = computeParallaxTarget({ x: state.pointer.x, y: state.pointer.y }, 1.5); + state.camera.position.x = damp(state.camera.position.x, target.x, 3, delta); + state.camera.position.y = damp(state.camera.position.y, target.y, 3, delta); + state.camera.lookAt(0, 0, -5); }); return ( From 9e9182c9c75081cb3d5bc539fe8fb42fa783f48b Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 08:56:55 -0500 Subject: [PATCH 3/3] refactor(studio): name parallax consts, tie lookAt to dial Z, test fps-independence - extract PARALLAX_INTENSITY/PARALLAX_LAMBDA/DIAL_Z (DIAL_Z shared with the dial group) - pass state.pointer directly to drop a per-frame object alloc - test no-overshoot toward a negative target and frame-rate independence --- ui/studio/src/components/MatrixScene.tsx | 14 +++++++++----- ui/studio/src/lib/__tests__/parallax.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/ui/studio/src/components/MatrixScene.tsx b/ui/studio/src/components/MatrixScene.tsx index 79f2c2d..9528d84 100644 --- a/ui/studio/src/components/MatrixScene.tsx +++ b/ui/studio/src/components/MatrixScene.tsx @@ -9,6 +9,10 @@ import { useIntentPulse } from '../lib/useIntentPulse'; import { computeParallaxTarget, damp } from '../lib/parallax'; import { RotaryDial } from './RotaryDial'; +const PARALLAX_INTENSITY = 1.5; // tune lower (e.g. 0.6) for a subtler parallax +const PARALLAX_LAMBDA = 3; +const DIAL_Z = -5; + export function MatrixScene() { const coreRef = useRef(null); const pulse = useIntentPulse(); @@ -28,16 +32,16 @@ export function MatrixScene() { coreRef.current.scale.setScalar(1 + kick.current * 0.4); } // Mouse-driven camera parallax (state.pointer is normalized -1..1). - const target = computeParallaxTarget({ x: state.pointer.x, y: state.pointer.y }, 1.5); - state.camera.position.x = damp(state.camera.position.x, target.x, 3, delta); - state.camera.position.y = damp(state.camera.position.y, target.y, 3, delta); - state.camera.lookAt(0, 0, -5); + const target = computeParallaxTarget(state.pointer, PARALLAX_INTENSITY); + state.camera.position.x = damp(state.camera.position.x, target.x, PARALLAX_LAMBDA, delta); + state.camera.position.y = damp(state.camera.position.y, target.y, PARALLAX_LAMBDA, delta); + state.camera.lookAt(0, 0, DIAL_Z); }); return ( {/* The Central Rotary Slider Dial (instanced, O(1)) */} - + diff --git a/ui/studio/src/lib/__tests__/parallax.test.ts b/ui/studio/src/lib/__tests__/parallax.test.ts index 3dbe143..351661d 100644 --- a/ui/studio/src/lib/__tests__/parallax.test.ts +++ b/ui/studio/src/lib/__tests__/parallax.test.ts @@ -22,4 +22,17 @@ describe('damp', () => { it('approaches the target as dt grows large', () => { expect(damp(0, 10, 5, 100)).toBeCloseTo(10, 5); }); + + it('does not overshoot a negative target', () => { + expect(damp(0, -10, 5, 100)).toBeGreaterThanOrEqual(-10); + const step = damp(0, -10, 5, 0.016); + expect(step).toBeLessThan(0); + expect(step).toBeGreaterThan(-10); + }); + + it('is frame-rate independent (one dt step == two dt/2 steps)', () => { + const oneBig = damp(0, 10, 5, 1 / 30); + const twoSmall = damp(damp(0, 10, 5, 1 / 60), 10, 5, 1 / 60); + expect(twoSmall).toBeCloseTo(oneBig, 6); + }); });