From 6c11e3c4ee3cb7ec428c448a5feda6d8fcd7e0b6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 1 Sep 2026 15:18:08 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.7=20=E2=80=94=20idle=20drift=20cru?= =?UTF-8?q?ise=20(ambient=20field=20baseline),=20rAF=20clamp=20matches=20e?= =?UTF-8?q?ngine=20catch-up=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire each blob's driftAngle/driftSpeed — initialized since the pre-Phase-A baseline but never read by the loop — into updateScreensaverPhysics as a per-substep steering toward a gentle cruise velocity. With no pointer, scroll, or device-motion input the only idle motion was a zero-mean jitter random walk erased by the *=0.992 damping in ~1.4s; blobs jiggled near their territory instead of drifting. The settled cruise delivers the 'idle blobs drift' baseline of docs/physics-feel-contract.md on every environment with no permission grant or sensor, while pointer/scroll/ gravity fields still bias motion on top exactly as before and prefers-reduced-motion still freezes the frame entirely. Also raise TinyVectors' rAF frame-delta clamp from 0.033s to 8/60s to match BlobPhysics.tick()'s own catch-up ceiling: the old clamp pre-empted the 0.3.6 fixed-timestep accumulator and dilated simulated time ~3x at 11fps (software raster / heavy blur scenes). Seeded sims: idle mean net displacement 4.3-5.0 -> 16.4-25.5 units/12s, speeds bounded near 0.15 units/substep over 300s. Headed-Chrome A/B probe: idle net/path coherence 0.5-0.64 -> 0.72-0.81 at 60fps; reducedMotion 'reduce' context still renders a fully frozen frame. --- BUILD.bazel | 2 +- CHANGELOG.md | 5 + MODULE.bazel | 2 +- package.json | 2 +- src/core/BlobPhysics.ts | 23 +++++ src/svelte/TinyVectors.svelte | 6 +- tests/unit/blob-physics-idle-drift.test.ts | 113 +++++++++++++++++++++ 7 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 tests/unit/blob-physics-idle-drift.test.ts diff --git a/BUILD.bazel b/BUILD.bazel index f2314a7..84549a8 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -149,7 +149,7 @@ npm_package( ":tinyvectors", ], package = "@tummycrypt/tinyvectors", - version = "0.3.6", + version = "0.3.7", visibility = ["//visibility:public"], ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b57cc8..d43ed8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > [tinyland-inc/bazel-registry](https://github.com/tinyland-inc/bazel-registry) > only. See the README's Install section for the sanctioned consumption paths. +## 0.3.7 - 2026-09-01 + +- Idle drift cruise: `updateScreensaverPhysics` now steers each blob's velocity toward its own `driftAngle`/`driftSpeed` heading every substep (`DRIFT_CRUISE_STEERING = 0.05`, target speed `driftSpeed * 3`). Both fields were initialized per blob since the pre-Phase-A baseline but never read by the loop, so with no pointer, scroll, or device-motion input the only idle motion was a zero-mean jitter random walk that the `*= 0.992` damping erased in ~1.4s — blobs jiggled near their territory instead of drifting. The settled cruise (~1.5-3.9 units/s in the -40..140 physics space) delivers the "idle blobs drift" baseline of `docs/physics-feel-contract.md` on every environment, with no permission grant or sensor required; the existing re-heading roll (~every 8s) and the wall-bounce re-randomization keep paths wandering rather than ballistic, and pointer/scroll/gravity fields still bias motion on top exactly as before. Deterministic seeded runs measure idle mean net displacement rising from ~4.3-5.0 to ~16.4-25.5 units over 12s, peak speeds bounded near 0.15 units/substep across 300 simulated seconds; a headed-Chrome A/B probe of the built package measures the net/path coherence of idle motion rising from ~0.5-0.64 (decorrelating jitter) to ~0.72-0.81 (directed travel) at 60fps, and a `reducedMotion: 'reduce'` context still renders a fully frozen frame (0 of 15 paths move) (`tests/unit/blob-physics-idle-drift.test.ts`). +- `TinyVectors`' rAF frame-delta clamp is raised from `0.033`s to `8/60`s to match `BlobPhysics.tick()`'s own catch-up ceiling (8 substeps of 1/60s, added in 0.3.6). The old clamp pre-empted the engine's fixed-timestep accumulator: any sustained render rate below 30fps (software raster, heavy blur scenes on hiDPI displays) dilated simulated time proportionally (~3x slower motion at 11fps). Now sustained rendering down to ~7.5fps keeps real-time motion speed; a backgrounded-tab resume is still bounded by the same engine substep cap. + ## 0.3.6 - 2026-07-25 - Adds a `respectReducedMotion?: boolean` prop (default `true`) to `TinyVectors`: when `(prefers-reduced-motion: reduce)` matches, the component renders the existing static single frame (the same path `animated={false}` already uses) instead of running the rAF loop, and switches live if the media query changes. Pass `respectReducedMotion={false}` to animate regardless (TIN-3170). diff --git a/MODULE.bazel b/MODULE.bazel index a977d45..972b4dc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -7,7 +7,7 @@ Usage from external repo: module( name = "tummycrypt_tinyvectors", - version = "0.3.6", + version = "0.3.7", compatibility_level = 1, ) diff --git a/package.json b/package.json index 6cff52f..490d44c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyvectors", - "version": "0.3.6", + "version": "0.3.7", "description": "Animated vector blob backgrounds with physics simulation for Svelte 5", "type": "module", "packageManager": "pnpm@9.15.9", diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 0194d7c..febed1b 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -60,6 +60,21 @@ const FIXED_TIMESTEP_SECONDS = 1 / 60; // after a paused/backgrounded tab resumes), so a long real-time gap can't // spiral into an unbounded number of substeps in one frame. const MAX_PHYSICS_SUBSTEPS = 8; +// Per-substep steering gain for the ambient drift cruise. Each blob carries a +// driftAngle/driftSpeed pair (initialized below, re-randomized on wall bounce +// and by the slow re-heading roll in updateMovementWithAccelerometer); the +// cruise steers velocity toward that heading every substep. Against the +// *= 0.992 damping this settles near 0.86x of the scaled target, a sustained +// gentle coherent travel in the -40..140 physics space — the +// "idle blobs drift" baseline of docs/physics-feel-contract.md — instead of +// the zero-mean jitter random walk that damping otherwise erases in ~1.4s. +const DRIFT_CRUISE_STEERING = 0.05; +// The cruise target speed is driftSpeed scaled by this factor. The raw +// driftSpeed field (0.01-0.025/substep) sits below the speed the jitter +// kicks already reach, so unscaled it disappears into the random walk; +// scaled, the settled cruise (~1.5-3.9 units/s) reads as travel while staying +// well under pointer/scroll/gravity speeds so inputs still dominate. +const DRIFT_CRUISE_SPEED_SCALE = 3; export class BlobPhysics { private blobs: ConvexBlob[] = []; @@ -565,6 +580,14 @@ export class BlobPhysics { } private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { + // Ambient cruise: the always-on baseline field. driftAngle/driftSpeed + // were initialized per-blob since the pre-Phase-A baseline but never + // read by the loop, so idle motion was only the bounded jitter below. + const driftAngle = blob.driftAngle || 0; + const cruiseSpeed = (blob.driftSpeed || 0) * DRIFT_CRUISE_SPEED_SCALE; + blob.velocityX += (Math.cos(driftAngle) * cruiseSpeed - blob.velocityX) * DRIFT_CRUISE_STEERING; + blob.velocityY += (Math.sin(driftAngle) * cruiseSpeed - blob.velocityY) * DRIFT_CRUISE_STEERING; + const neutralDriftX = (Math.random() - 0.5) * 0.001; const neutralDriftY = (Math.random() - 0.5) * 0.001; diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index 93a3322..2f6a7e2 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -180,7 +180,11 @@ }; function tick(currentTime: number) { - const dt = Math.min((currentTime - lastTime) / 1000, 0.033); + // Clamp matches the engine's own catch-up ceiling (8 substeps of 1/60s + // in BlobPhysics.tick), so sustained low-fps rendering down to ~7.5fps + // keeps real-time motion speed instead of dilating simulated time. A + // tab-resume jump is still bounded by the same engine substep cap. + const dt = Math.min((currentTime - lastTime) / 1000, 8 / 60); lastTime = currentTime; if (physics) { diff --git a/tests/unit/blob-physics-idle-drift.test.ts b/tests/unit/blob-physics-idle-drift.test.ts new file mode 100644 index 0000000..2ff784a --- /dev/null +++ b/tests/unit/blob-physics-idle-drift.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BlobPhysics } from '../../src/core/BlobPhysics.js'; +import type { ConvexBlob } from '../../src/core/types.js'; + +// Deterministic xorshift32 PRNG (same shape as blob-physics-timestep.test.ts) +// so every run of a scenario draws the exact same "random" sequence. +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state ^= state << 13; + state >>>= 0; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 4294967296; + }; +} + +let randomSpy: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + randomSpy = vi.spyOn(Math, 'random'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +interface IdleRunResult { + meanNetDisplacement: number; + maxSpeed: number; + minCoordinate: number; + maxCoordinate: number; +} + +// Simulates `seconds` of untouched idle time (no pointer, scroll, or gravity +// input) at a 60Hz tick cadence and reports perceptual aggregates. When +// `zeroDriftSpeed` is set, each blob's cruise field is zeroed after init so +// the run measures the jitter-only baseline the cruise must rise above. +async function runIdle(seed: number, seconds: number, zeroDriftSpeed: boolean): Promise { + randomSpy.mockImplementation(seededRandom(seed)); + + const physics = new BlobPhysics(8); + await physics.init(); + + const blobs = physics.getBlobs() as ConvexBlob[]; + if (zeroDriftSpeed) { + for (const blob of blobs) blob.driftSpeed = 0; + } + const start = blobs.map((blob) => ({ x: blob.currentX, y: blob.currentY })); + + const frames = Math.round(seconds * 60); + let maxSpeed = 0; + let minCoordinate = Number.POSITIVE_INFINITY; + let maxCoordinate = Number.NEGATIVE_INFINITY; + for (let i = 0; i < frames; i++) { + physics.tick(1 / 60, i / 60); + for (const blob of physics.getBlobs() as ConvexBlob[]) { + maxSpeed = Math.max(maxSpeed, Math.hypot(blob.velocityX, blob.velocityY)); + minCoordinate = Math.min(minCoordinate, blob.currentX, blob.currentY); + maxCoordinate = Math.max(maxCoordinate, blob.currentX, blob.currentY); + } + } + + const end = physics.getBlobs() as ConvexBlob[]; + const displacements = end.map((blob, i) => + Math.hypot(blob.currentX - start[i].x, blob.currentY - start[i].y) + ); + + return { + meanNetDisplacement: displacements.reduce((sum, d) => sum + d, 0) / displacements.length, + maxSpeed, + minCoordinate, + maxCoordinate, + }; +} + +// Perceptual contract (docs/physics-feel-contract.md): "idle drift is present +// and bounded". Thresholds are deliberately loose — they assert travel vs. +// jiggle, not exact coefficients or frame-by-frame positions. +describe('BlobPhysics idle drift cruise (ambient field)', () => { + const SEEDS = [42, 7, 1234]; + + it('idle blobs travel with no input at all: net displacement well above the jitter-only baseline', async () => { + for (const seed of SEEDS) { + const cruise = await runIdle(seed, 12, false); + const jitterOnly = await runIdle(seed, 12, true); + + // Measured cruise means sit at 16.4-25.5 units over 12s in the + // -40..140 physics space; jitter-only at 4.3-5.0. Floors leave + // generous room for feel tuning while still failing if the cruise + // field ever regresses to dead code again. + expect(cruise.meanNetDisplacement).toBeGreaterThan(8); + expect(cruise.meanNetDisplacement).toBeGreaterThan(jitterOnly.meanNetDisplacement * 1.5); + } + }); + + it('idle drift stays bounded: gentle speeds, positions inside the physics walls', async () => { + for (const seed of SEEDS) { + const cruise = await runIdle(seed, 12, false); + + // ~0.08 units/substep measured peak over 12s; 0.15 (9 units/s) + // is the "no longer gentle" ceiling. + expect(cruise.maxSpeed).toBeLessThan(0.15); + expect(cruise.minCoordinate).toBeGreaterThanOrEqual(-40); + expect(cruise.maxCoordinate).toBeLessThanOrEqual(140); + } + }); +});