import { Scene, Entity, ComputeParticleEntity } from '@vectojs/core';
// Measures the Scene's REAL render cadence, not the display's vsync rate.
// An independent requestAnimationFrame loop fires every vsync tick
// regardless of whether Scene actually rendered that tick — on a 240Hz
// display it samples ~240 ticks/sec even while Scene.loop()'s own maxFPS
// cap renders only every 4th one, so a naive HUD would read "240fps" while
// the visible motion was genuinely capped at 60. Entity.update(dt) is only
// ever called from inside Scene's renderNode() walk, which is skipped
// entirely on a throttled/skipped tick — so a probe entity's own update()
// calls are a direct measurement of frames Scene actually rendered.
class FrameProbe extends Entity {
frameTimes: number[] = [];
isPointInside() {
return false;
}
render() {}
update(dt: number) {
const prevAvg = this.avgFrameTime();
this.frameTimes.push(dt);
// When a frame arrives dramatically faster than the running average
// (<60% of the previous avg), the Scene just un-throttled after idle.
// Discard stale idle samples so the average immediately reflects the
// new active speed — otherwise a short burst (3-5 frames at 200
// particles) gets drowned by the 17 idle 500ms records and the HUD
// reads ~2fps even during visible animation.
if (prevAvg > 0 && dt < prevAvg * 0.6) {
this.frameTimes = [dt];
return;
}
if (this.frameTimes.length > 20) this.frameTimes.shift();
}
avgFrameTime() {
if (this.frameTimes.length === 0) return 0;
return this.frameTimes.reduce((s, v) => s + v, 0) / this.frameTimes.length;
}
}
// Particle text: two words the visitor chooses ("From"/"To"), each rasterized
// once into an offscreen canvas and sampled into a point cloud. Clicking
// Transform scatters the FROM shape outward and reforms it into the TO
// shape — the spring-to-origin physics built into ComputeParticleEntity is
// what holds each shape together and animates the reform.
//
// Gradient color: ComputeParticleEntity.baseColor is a single flat CSS
// color — there is no per-particle color hook in the engine. A visible
// gradient across the word is built by splitting the particle budget across
// several ComputeParticleEntity instances ("buckets"), each holding roughly
// an equal horizontal slice of the shape and painted a color interpolated
// between the two color pickers. More buckets read as a smoother gradient
// at the cost of one more Entity (and one more simulation pass) per bucket.
//
// Content projection: a separate, invisible Word entity always reports the
// TARGET word's real text via getContentProjection() — Ctrl+F, translation
// tools, and screen readers see real text throughout the scatter/reform
// cycle, never the literal particle positions. selectable is left at its
// default (false) rather than true: a projected-but-unselectable text layer
// still participates in Ctrl+F and accessibility, but doesn't intercept
// pointer events — with selectable: true, moving the mouse across the
// particle cloud kept triggering the browser's native text-selection drag
// instead of reaching the canvas underneath.
const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const hud = document.getElementById('hud')!;
const panel = {
origin: document.getElementById('input-origin') as HTMLInputElement,
target: document.getElementById('input-target') as HTMLInputElement,
transform: document.getElementById('btn-transform') as HTMLButtonElement,
count: document.getElementById('input-count') as HTMLInputElement,
countValue: document.getElementById('value-count')!,
duration: document.getElementById('input-duration') as HTMLInputElement,
durationValue: document.getElementById('value-duration')!,
colorA: document.getElementById('input-color-a') as HTMLInputElement,
colorB: document.getElementById('input-color-b') as HTMLInputElement,
};
const BUCKET_COUNT: number = 8;
const SAMPLE_CANVAS_H = 220;
// duration slider is in 0.1s steps (3..30 -> 0.3s..3.0s). Spring stiffness
// is tuned inversely against the selected duration below — the actual
// settle time depends on many factors (explosion force, word shape, canvas
// size), so this is a "feel" control (faster/stiffer vs slower/softer) that
// correlates loosely with the label, not a guaranteed-convergence timer.
//
// Tuning note (2026-07-19, revised): ComputeParticleEntity.updateCPU's
// spring formula is `vx = (vx + (ox-px)*springK*dt) * damping; px +=
// vx*dt` — an UNDER-damped pair converges the *position* by the end of the
// window while velocity is still large, so particles visibly fly PAST the
// target and snap back on the next frame ("explodes" instead of settling).
// A first attempt at retuning (springK=8.75/damping=0.93) optimized only for
// "small remaining distance at 1.2s" and reproduced exactly that overshoot
// (confirmed by simulation: distance ~1% but velocity still ~-24px/s at the
// window boundary). This pair is chosen to be critically/over-damped
// instead — monotonically approaching the target with no sign change in
// position at any frame rate from 24-144fps (verified by simulation) — so
// motion always reads as a continuous decelerating glide, never a snap.
// The tradeoff is a larger residual distance right at the nominal window
// (~38% at 60fps) that keeps closing smoothly for a bit after; springK is
// still clamped to 10 by the engine, so very short duration-slider values
// converge even less — an inherent tradeoff of a physically-modeled spring,
// not a bug.
const REFERENCE_DURATION_S = 1.2;
const REFERENCE_SPRING_K = 10;
const DAMPING = 0.828;
// Rasterize `text` and return up to `maxPoints` (x, y) samples of its
// opaque pixels, in the rasterizing canvas's own pixel space, along with the
// tight bounding box of those samples (so the caller can re-center them).
function sampleTextPoints(
text: string,
fontPx: number,
maxPoints: number,
): { points: Float32Array; width: number; height: number } {
const safeText = text.trim() || ' ';
const probe = document.createElement('canvas');
const pctx = probe.getContext('2d')!;
const font = `900 ${fontPx}px "Inter", system-ui, sans-serif`;
pctx.font = font;
const width = Math.ceil(pctx.measureText(safeText).width) + 40;
probe.width = width;
probe.height = SAMPLE_CANVAS_H;
pctx.font = font;
pctx.fillStyle = '#000';
pctx.textBaseline = 'middle';
pctx.fillText(safeText, 20, SAMPLE_CANVAS_H / 2);
const { data } = pctx.getImageData(0, 0, width, SAMPLE_CANVAS_H);
const candidates: number[] = [];
// Grid step tuned so a typical word's opaque-pixel count lands near
// maxPoints; oversample slightly then subsample evenly below, rather than
// under-filling the particle budget on short words.
const step = 2;
for (let y = 0; y < SAMPLE_CANVAS_H; y += step) {
for (let x = 0; x < width; x += step) {
const alpha = data[(y * width + x) * 4 + 3];
if (alpha > 128) candidates.push(x, y);
}
}
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (let i = 0; i < candidates.length; i += 2) {
const x = candidates[i];
const y = candidates[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (candidates.length === 0) {
minX = minY = 0;
maxX = maxY = 1;
}
const nCandidates = candidates.length / 2;
const keep = Math.min(maxPoints, nCandidates);
const points = new Float32Array(Math.max(0, keep) * 2);
const stride = keep > 0 ? nCandidates / keep : 1;
for (let i = 0; i < keep; i++) {
const srcIdx = Math.floor(i * stride) * 2;
points[i * 2] = candidates[srcIdx] - minX;
points[i * 2 + 1] = candidates[srcIdx + 1] - minY;
}
return { points, width: maxX - minX, height: maxY - minY };
}
// Invisible content-projection host: no drawImage/fillText of its own (the
// particles are the entire visual), but a real Entity in the tree so
// getContentProjection() gives Ctrl+F, translation tools, and screen
// readers the actual word — independent of however scattered the particle
// cloud currently is.
class Word extends Entity {
text = '';
constructor() {
super('Word');
}
isPointInside() {
return false;
}
render() {}
override getContentProjection() {
return {
text: this.text,
font: '900 64px "Inter", system-ui, sans-serif',
// Deliberately NOT selectable: true. A projected text layer with
// selectable left at its default (false, meaning pointer-events:
// none on the shadow element) still participates in Ctrl+F/
// translation/screen readers, but doesn't intercept the mouse —
// with selectable: true, moving the pointer across the word kept
// starting the browser's native text-selection drag instead of
// reaching the canvas underneath it.
};
}
}
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lerpColor(a: string, b: string, t: number): string {
const [ar, ag, ab] = hexToRgb(a);
const [br, bg, bb] = hexToRgb(b);
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return `rgb(${r}, ${g}, ${bl})`;
}
const scene = new Scene(canvas, {
// 'always' is Scene's default renderMode; particles are perpetually integrating.
maxFPS: 60,
// Without this, ComputeParticleEntity.hasPendingAnimations() correctly
// reports "at rest" once the settled shape's velocity/distance-to-origin
// drop below its epsilon, and Scene's idle auto-throttle then drops the
// WHOLE scene to ~2fps — technically correct (nothing is visibly moving),
// but this demo's own HUD prints that "2 fps" number, which reads as
// broken/janky on first load rather than intentionally idle. This is a
// showcase piece meant to always look alive, not a battery-conscious
// production UI, so opt out of the throttle entirely via the engine's
// own escape hatch rather than fighting it with a fake perpetual-motion
// entity.
autoThrottle: false,
disableWindowResize: true,
maxDPR: 2,
});
// One ComputeParticleEntity per gradient bucket. Sizes/colors are fixed at
// construction (maxParticles per bucket is recomputed and entities rebuilt
// whenever the particle-count slider changes — see rebuildBuckets()).
let buckets: ComputeParticleEntity[] = [];
let word: Word | null = null;
let frameProbe: FrameProbe | null = null;
function currentSpringK(): number {
const durationS = Number(panel.duration.value) / 10;
return REFERENCE_SPRING_K * (REFERENCE_DURATION_S / durationS);
}
function rebuildBuckets(totalParticles: number) {
for (const b of buckets) scene.remove(b);
buckets = [];
const perBucket = Math.max(1, Math.round(totalParticles / BUCKET_COUNT));
const springK = currentSpringK();
for (let i = 0; i < BUCKET_COUNT; i++) {
const t = BUCKET_COUNT === 1 ? 0 : i / (BUCKET_COUNT - 1);
const color = lerpColor(panel.colorA.value, panel.colorB.value, t);
const bucket = new ComputeParticleEntity({
maxParticles: perBucket,
size: 2.6,
color,
springK,
damping: DAMPING,
bounceDamping: 0.4,
maxVelocity: 900,
});
scene.add(bucket);
buckets.push(bucket);
}
seeded = false;
}
// Splits a word's sampled point cloud evenly across BUCKET_COUNT slices by
// HORIZONTAL position (left-to-right), so the gradient reads left-to-right
// across the word rather than randomly speckled.
function bucketizePoints(points: Float32Array, width: number): Float32Array[] {
const n = points.length / 2;
const perBucketPoints: number[][] = Array.from({ length: BUCKET_COUNT }, () => []);
for (let i = 0; i < n; i++) {
const x = points[i * 2];
const y = points[i * 2 + 1];
const t = width > 0 ? Math.min(0.999, x / width) : 0;
const bucketIdx = Math.floor(t * BUCKET_COUNT);
perBucketPoints[bucketIdx].push(x, y);
}
return perBucketPoints.map((arr) => new Float32Array(arr));
}
let fontPx = 120;
let seeded = false;
function layoutWord(text: string, resetPositions: boolean) {
if (!word) return;
word.text = text;
const totalParticles = buckets.reduce((s, b) => s + b.maxParticles, 0);
const { points, width, height } = sampleTextPoints(text, fontPx, totalParticles);
const w = app.clientWidth || 900;
const h = app.clientHeight || 500;
const offsetX = (w - width) / 2;
const offsetY = (h - height) / 2;
const perBucketLocal = bucketizePoints(points, width);
for (let i = 0; i < BUCKET_COUNT; i++) {
const local = perBucketLocal[i];
const maxP = buckets[i].maxParticles;
const n = local.length / 2;
const scenePoints = new Float32Array(maxP * 2);
for (let j = 0; j < maxP; j++) {
const src = n > 1 ? Math.floor((j / maxP) * n) * 2 : 0;
scenePoints[j * 2] = local[src] + offsetX;
scenePoints[j * 2 + 1] = local[src + 1] + offsetY;
}
buckets[i].setOrigins(scenePoints, resetPositions);
}
word.x = offsetX;
word.y = offsetY;
word.width = width;
word.height = height;
scene.markDirty();
}
function fit() {
const w = app.clientWidth;
const h = app.clientHeight;
if (w === 0 || h === 0) return;
scene.resize(w, h);
if (!seeded) {
// ComputeParticleEntity's backing Float32Array starts fully zeroed, so
// every particle's `life` field (offset 7) is 0 — the CPU/GPU render
// paths both skip life===0 as "dead", so calling setOrigins() alone
// (which only ever touches position/velocity/origin) leaves every
// particle invisible forever. initRandomParticles() is what actually
// sets life=-1 (perpetual) and size for the whole buffer; call it once
// up front so every later setOrigins() call only needs to move
// particles that are already alive.
for (const b of buckets) b.initRandomParticles(w, h);
seeded = true;
}
fontPx = Math.max(56, Math.min(130, w * 0.14));
layoutWord(panel.origin.value.toUpperCase() || 'VECTOJS', true);
}
function init() {
word = new Word();
scene.add(word);
frameProbe = new FrameProbe();
scene.add(frameProbe);
rebuildBuckets(Number(panel.count.value));
const observer = new ResizeObserver(fit);
observer.observe(app);
scene.start();
}
init();
// Transform: scatter the current (origin) shape outward from the panel's
// button position, then after the burst has had time to read, reform into
// the target shape. Positions are NOT reset on the reform
// (requestPositionReset=false) — particles fly from wherever the explosion
// left them into the new word's shape.
let transforming = false;
function runTransform() {
if (transforming) return;
const originText = panel.origin.value.toUpperCase() || 'VECTOJS';
const targetText = panel.target.value.toUpperCase() || 'CANVAS';
transforming = true;
panel.transform.disabled = true;
const w = app.clientWidth || 900;
const h = app.clientHeight || 500;
layoutWord(originText, true);
// Give the origin shape one frame to settle into place before scattering
// from the canvas center, then reform into the target shape.
window.setTimeout(() => {
const cx = w / 2;
const cy = h / 2;
for (const b of buckets) b.triggerExplosion(cx, cy, 46000);
scene.markDirty(); // triggerExplosion queues the burst but never marks
// the scene dirty — on an idled scene (2fps throttle) the next
// render pass can be delayed up to the throttle interval (~500ms).
// Without this, the explosion visibly hangs for half a second before
// any particles start moving.
window.setTimeout(() => {
layoutWord(targetText, false);
const durationMs = Number(panel.duration.value) * 100;
window.setTimeout(() => {
transforming = false;
panel.transform.disabled = false;
}, durationMs + 400);
}, 260);
}, 50);
}
panel.transform.addEventListener('click', runTransform);
// Particle-count slider: rebuilds the bucket entities (maxParticles is
// fixed at ComputeParticleEntity construction, not runtime-adjustable) and
// re-seeds + re-lays-out the ORIGIN word immediately so the preview stays
// live while dragging.
panel.count.addEventListener('input', () => {
panel.countValue.textContent = panel.count.value;
});
panel.count.addEventListener('change', () => {
rebuildBuckets(Number(panel.count.value));
fit();
});
panel.duration.addEventListener('input', () => {
panel.durationValue.textContent = `${(Number(panel.duration.value) / 10).toFixed(1)}s`;
});
panel.duration.addEventListener('change', () => {
const springK = currentSpringK();
for (const b of buckets) b.springK = springK;
});
// Color pickers: recolor buckets immediately (baseColor is a plain mutable
// property, no rebuild needed).
function recolorBuckets() {
for (let i = 0; i < BUCKET_COUNT; i++) {
const t = BUCKET_COUNT === 1 ? 0 : i / (BUCKET_COUNT - 1);
buckets[i].baseColor = lerpColor(panel.colorA.value, panel.colorB.value, t);
}
scene.markDirty();
}
panel.colorA.addEventListener('input', recolorBuckets);
panel.colorB.addEventListener('input', recolorBuckets);
// navigator.gpu being present only means the API exists, not that a real
// adapter was found — Scene tries WebGPU lazily/async on the first
// ComputeParticleEntity frame and can still fall back to CPU (e.g.
// headless/software-GL Chrome reports navigator.gpu but "No GPUAdapter
// found"). There's no exposed flag for which path is actually active, so
// the HUD reports what's requested, not a guess at what was granted.
const gpuRequested = !!navigator.gpu;
function updateHud() {
const avg = frameProbe ? frameProbe.avgFrameTime() : 0;
if (avg > 0) {
const fps = 1000 / avg;
const total = buckets.reduce((s, b) => s + b.maxParticles, 0);
hud.textContent =
`${total} particles (${BUCKET_COUNT} gradient buckets) · webgpu ${gpuRequested ? 'requested' : 'unavailable'}\n` +
`frame ${avg.toFixed(1)}ms · ${fps.toFixed(0)} fps\n` +
`edit From/To, then Transform`;
}
setTimeout(updateHud, 250);
}
updateHud();
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/no-ff-webgpu.js"></script>
<title>Particle Text — Motif</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #f7f4ee;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#panel {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font:
500 12px "Inter",
sans-serif;
z-index: 10;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(42, 39, 35, 0.16);
border-radius: 10px;
padding: 10px 12px;
color: #2a2723;
}
#panel .field {
display: flex;
align-items: center;
gap: 6px;
}
#panel label {
color: #6b6459;
font-size: 11px;
white-space: nowrap;
}
#panel input[type="text"] {
width: 92px;
border: 1px solid rgba(42, 39, 35, 0.2);
border-radius: 6px;
padding: 5px 7px;
font: inherit;
background: #fff;
color: #2a2723;
text-transform: uppercase;
}
#panel input[type="text"]:focus {
outline: 2px solid #d97757;
outline-offset: 1px;
}
#panel input[type="range"] {
width: 84px;
accent-color: #d97757;
}
#panel input[type="color"] {
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(42, 39, 35, 0.2);
border-radius: 6px;
cursor: pointer;
background: none;
}
#panel .value {
font:
500 11px "JetBrains Mono",
monospace;
color: #6b6459;
min-width: 32px;
}
#panel button {
border: 1px solid rgba(42, 39, 35, 0.16);
background: #2a2723;
color: #f7f4ee;
border-radius: 7px;
padding: 6px 14px;
cursor: pointer;
font: 600 12px inherit;
}
#panel button:disabled {
opacity: 0.5;
cursor: default;
}
#panel .divider {
width: 1px;
height: 20px;
background: rgba(42, 39, 35, 0.14);
}
#hud {
position: absolute;
top: 68px;
left: 14px;
font:
500 12px "JetBrains Mono",
monospace;
color: #2a2723;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(42, 39, 35, 0.16);
border-radius: 7px;
padding: 8px 12px;
line-height: 1.6;
z-index: 10;
white-space: pre;
}
</style>
<script type="importmap">
{
"imports": {
"@vectojs/core": "https://esm.sh/@vectojs/core@1.26.0"
}
}
</script>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="panel">
<div class="field">
<label for="input-origin">From</label>
<input
id="input-origin"
type="text"
value="VECTOJS"
maxlength="12"
spellcheck="false"
autocomplete="off"
/>
</div>
<div class="field">
<label for="input-target">To</label>
<input
id="input-target"
type="text"
value="CANVAS"
maxlength="12"
spellcheck="false"
autocomplete="off"
/>
</div>
<button id="btn-transform">Transform</button>
<div class="divider"></div>
<div class="field">
<label for="input-count">Particles</label>
<input
id="input-count"
type="range"
min="200"
max="3000"
step="100"
value="1400"
/>
<span class="value" id="value-count">1400</span>
</div>
<div class="field">
<label for="input-duration">Duration</label>
<input
id="input-duration"
type="range"
min="3"
max="30"
step="1"
value="12"
/>
<span class="value" id="value-duration">1.2s</span>
</div>
<div class="divider"></div>
<div class="field">
<label for="input-color-a">Color</label>
<input id="input-color-a" type="color" value="#d97757" />
<input id="input-color-b" type="color" value="#38bdf8" />
</div>
</div>
<div id="hud">measuring…</div>
</div>
<script type="module" src="./demo.js"></script>
</body>
</html>