diff --git a/ui/studio/src/components/MatrixScene.tsx b/ui/studio/src/components/MatrixScene.tsx index 6d94d69..9528d84 100644 --- a/ui/studio/src/components/MatrixScene.tsx +++ b/ui/studio/src/components/MatrixScene.tsx @@ -6,8 +6,13 @@ 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'; +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(); @@ -26,12 +31,17 @@ 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(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 new file mode 100644 index 0000000..351661d --- /dev/null +++ b/ui/studio/src/lib/__tests__/parallax.test.ts @@ -0,0 +1,38 @@ +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); + }); + + 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); + }); +}); 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)); +}