.
Some checks failed
Deploy to Pages / build (pull_request) Failing after 3m15s

This commit is contained in:
Andras Schmelczer 2026-05-13 22:13:15 +01:00
parent 39b0160064
commit 2347ecd201
71 changed files with 3799 additions and 1606 deletions

View file

@ -0,0 +1,86 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: vi.fn(() => null),
setItem: vi.fn(),
},
});
});
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel;
const createPopulation = () => {
const pipeline = {
maxAgentCount: 10_000_000,
writeAgents: vi.fn(),
resizeAgents: vi.fn(),
compactAgents: vi.fn(),
} as unknown as AgentGenerationPipeline;
return new AgentPopulation(pipeline);
};
const setPopulationCounts = (
population: AgentPopulation,
activeCount: number,
targetBudget: number
) => {
Object.assign(population as unknown as Record<string, number>, {
activeCount,
targetBudget,
});
};
describe('AgentPopulation adaptive budget', () => {
beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
});
afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
});
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
const population = createPopulation();
setPopulationCounts(population, 1_000_000, 1_000_000);
population.growBudget(1 / 60, 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
appConfig.simulation.globalAgentCap
);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationCounts(population, 1_000_000, 1_000_000);
population.growBudget(10, 50, 60);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(population.activeAgentCount).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
});
});

View file

@ -12,11 +12,15 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
export class AgentPopulation {
private activeCount = 0;
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
@ -38,6 +42,7 @@ export class AgentPopulation {
}
public initializeIntroAgents(canvasSize: vec2): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.pipeline.maxAgentCount,
settings.agentBudgetMax,
@ -53,6 +58,7 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.targetBudget,
settings.agentBudgetMax,
@ -65,7 +71,9 @@ export class AgentPopulation {
smoothedFps: number,
refreshTargetFps: number
): void {
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
if (
this.targetBudget < cap &&
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
@ -147,6 +155,8 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, this.targetBudget - this.activeCount);
const appendCount = Math.min(count, available);
@ -178,4 +188,60 @@ export class AgentPopulation {
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
private updateAdaptiveCap(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
this.canExpandAdaptiveCap =
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) {
settings.agentBudgetMax = previousCap;
return;
}
const decrease = Math.max(
1,
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
);
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
settings.agentBudgetMax = nextCap;
this.targetBudget = Math.min(this.targetBudget, nextCap);
if (this.activeCount > this.targetBudget) {
this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease);
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
}
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, this.targetBudget - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
if (this.targetBudget < currentCap) {
return;
}
const pendingAgentCount = requestedAgentCount - available;
const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
settings.agentBudgetMax = nextCap;
this.targetBudget = Math.max(
this.targetBudget,
Math.min(nextCap, this.activeCount + requestedAgentCount)
);
}
private clampAdaptiveCap(value: number): number {
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
}
}

View file

@ -1,8 +1,8 @@
import { appConfig } from '../config';
import { RuntimeError } from '../utils/error-handler';
export const EXPORT_4K_WIDTH = appConfig.export4k.width;
export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const EXPORT_4K_WIDTH = appConfig.export4k.width;
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
@ -11,7 +11,7 @@ const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
export interface Export4KMemoryEstimate {
interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
@ -26,18 +26,18 @@ export interface Export4KMemoryEstimate {
estimatedPeakBytes: number;
}
export interface Export4KDimensions {
interface Export4KDimensions {
width: number;
height: number;
}
export interface BrowserMemoryInfo {
interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
export interface Export4KPreflightOptions {
interface Export4KPreflightOptions {
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
memoryInfo?: BrowserMemoryInfo;
estimate?: Export4KMemoryEstimate;

View file

@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const gameLoopSource = readFileSync(
join(process.cwd(), 'src/game-loop/game-loop.ts'),
'utf8'
);
const getStartDrawingHandlerSource = () => {
const start = gameLoopSource.indexOf('onStartDrawing:');
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the pointer drawing intro handler');
}
return gameLoopSource.slice(start, end);
};
describe('GameLoop intro drawing policy', () => {
it('allows drawing to start without completing the intro sequence', () => {
const handlerSource = getStartDrawingHandlerSource();
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
expect(handlerSource).not.toContain('this.introPrompt.complete(');
});
});

View file

@ -19,6 +19,7 @@ import { RenderInputCache } from './render-input-cache';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
@ -29,10 +30,12 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsElement: HTMLDivElement | null = null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private lastDevStatsUpdateAt = 0;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
@ -43,6 +46,9 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
if (import.meta.env.DEV) {
this.devStatsElement = this.createDevStatsElement();
}
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -58,10 +64,7 @@ export default class GameLoop {
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.introPrompt.complete();
},
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
@ -133,6 +136,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
@ -159,7 +163,7 @@ export default class GameLoop {
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
@ -172,6 +176,7 @@ export default class GameLoop {
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
@ -205,6 +210,7 @@ export default class GameLoop {
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
@ -213,6 +219,42 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
private createDevStatsElement(): HTMLDivElement | null {
const container = this.canvas.parentElement;
if (!container) {
return null;
}
const element = document.createElement('div');
element.className = 'dev-stats-overlay';
element.setAttribute('aria-hidden', 'true');
container.appendChild(element);
return element;
}
private updateDevStats(time: DOMHighResTimeStamp): void {
if (
!this.devStatsElement ||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
) {
return;
}
this.lastDevStatsUpdateAt = time;
this.devStatsElement.textContent = [
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
this.framePerformance.refreshTargetFps
)}`,
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
`Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
].join('\n');
}
private formatDevStatNumber(value: number): string {
return Math.max(0, Math.round(value)).toLocaleString('en-US');
}
private resize(): void {
const width = Math.max(
1,

View file

@ -1,21 +0,0 @@
import { vec3 } from 'gl-matrix';
import { settings } from '../settings';
import { hsl } from '../utils/hsl';
import { Random } from '../utils/random';
const hues = [settings.startColorHue];
for (let i = 0; i < 100; i++) {
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
}
const colors = hues.map((hue) =>
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
);
export class GamePresentation {
public static getGenerationColor(generation: number): vec3 {
return colors[generation % colors.length];
}
}

View file

@ -1,124 +0,0 @@
import { vec2 } from 'gl-matrix';
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
import { settings } from '../settings';
import { clamp, clamp01 } from '../utils/clamp';
import { mix } from '../utils/mix';
import { Random } from '../utils/random';
export interface SpawnAction {
generation: number;
position: vec2;
radius: number;
}
export class GameRules {
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_RADIUS = 20;
private lastSpawnTimeInSeconds = 0;
private currentSpawnInterval = 0;
private currentSpawnRadius = 0;
private lastGenerationChangeTimeInSeconds = 0;
public nextGenerationId = 1;
public generationCounts: {
currentGenerationCount: number;
nextGenerationCount: number;
} = {
currentGenerationCount: 0,
nextGenerationCount: 1,
};
public constructor(startingTimeInSeconds: number) {
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
}
private lastSpawnAction: SpawnAction | undefined;
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
if (
this.lastSpawnAction &&
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
) {
return this.lastSpawnAction;
}
this.currentSpawnInterval = mix(
GameRules.DEFAULT_SPAWN_INTERVAL,
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
this.currentSpawnRadius = mix(
GameRules.DEFAULT_SPAWN_RADIUS,
GameRules.DEFAULT_SPAWN_RADIUS * 3,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
if (
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
q > 0.05
) {
return {
generation: this.nextGenerationId,
position: vec2.create(),
radius: 0,
};
}
this.lastSpawnTimeInSeconds = timeInSeconds;
this.lastSpawnAction = {
generation: this.nextGenerationId,
position: vec2.fromValues(
Random.randomBetween(0, canvasSize[0]),
Random.randomBetween(0, canvasSize[1])
),
radius: this.currentSpawnRadius,
};
return this.lastSpawnAction;
}
public updateGenerationCounts({
evenGenerationCount,
oddGenerationCount,
}: GenerationCounts): void {
const nextGenerationCount =
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
const currentGenerationCount =
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
const q = currentGenerationCount / settings.agentCount;
if (currentGenerationCount <= 100 && q < 0.05) {
this.nextGenerationId++;
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
}
this.generationCounts = {
currentGenerationCount,
nextGenerationCount,
};
}
public getNextGenerationMoveSpeed(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
}
public getInfectionProbability(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
}
public getSensorOffset(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return mix(20, settings.sensorOffsetDistance, q);
}
}

View file

@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type PointerListener = (event: PointerEvent) => void;
const makePointerEvent = (
type: string,
event: Partial<PointerEvent> = {}
): PointerEvent =>
({
buttons: 1,
clientX: 10,
clientY: 20,
isTrusted: true,
pointerId: 1,
pointerType: 'mouse',
pressure: 0.5,
timeStamp: 100,
type,
...event,
}) as PointerEvent;
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
class FakeCanvas {
public readonly capturedPointerIds: Array<number> = [];
public readonly releasedPointerIds: Array<number> = [];
public width = 300;
public height = 200;
private readonly listeners = new Map<string, Set<PointerListener>>();
public addEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
const pointerListener =
typeof listener === 'function'
? listener
: (event: Event) => listener.handleEvent(event);
listeners.add(pointerListener as PointerListener);
this.listeners.set(type, listeners);
}
public removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(listener as PointerListener);
}
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
const pointerEvent = makePointerEvent(type, event);
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
}
public getBoundingClientRect(): DOMRect {
return {
bottom: this.height,
height: this.height,
left: 0,
right: this.width,
toJSON: () => ({}),
top: 0,
width: this.width,
x: 0,
y: 0,
} as DOMRect;
}
public setPointerCapture(pointerId: number): void {
this.capturedPointerIds.push(pointerId);
}
public releasePointerCapture(pointerId: number): void {
this.releasedPointerIds.push(pointerId);
}
}
const makeSwipePipeline = () => ({
addSwipeSegment: vi.fn(),
clearSwipes: vi.fn(),
});
const createPointerInput = async () => {
const { GardenPointerInput } = await import('./pointer-input');
const { settings: runtimeSettings } = await import('../settings');
const canvas = new FakeCanvas();
const audio = {
beginGesture: vi.fn(),
endGesture: vi.fn(),
start: vi.fn(),
stroke: vi.fn(),
touchDown: vi.fn(),
};
const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline();
const eraserTexturePipeline = makeSwipePipeline();
const eraserPreview = {
isPointerInsideCanvas: vi.fn(() => true),
setEraseMode: vi.fn(),
setPointerHoveringCanvas: vi.fn(),
update: vi.fn(),
};
const onStartDrawing = vi.fn();
const onEraseGestureEnded = vi.fn();
const spawnStrokeAgents = vi.fn();
const input = new GardenPointerInput({
audio,
brushPipeline,
canvas: canvas as unknown as HTMLCanvasElement,
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getCanvasSize: () => [canvas.width, canvas.height],
getDevicePixelRatio: () => 1,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
onStartDrawing,
spawnStrokeAgents,
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
input.attach();
return {
audio,
brushPipeline,
canvas,
input,
onStartDrawing,
runtimeSettings,
spawnStrokeAgents,
};
};
describe('GardenPointerInput drawing startup', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('localStorage', {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('allows pointer drawing immediately', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 7,
timeStamp: 120,
});
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
expect(canvas.capturedPointerIds).toEqual([7]);
});
it('starts drawing from a fresh pointerdown', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
expect(canvas.capturedPointerIds).toEqual([9]);
});
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
const { brushPipeline, canvas } = await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointerup', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
});
it('uses coalesced pointer samples for smoother brush segments', async () => {
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
audio.stroke.mockClear();
brushPipeline.addSwipeSegment.mockClear();
spawnStrokeAgents.mockClear();
canvas.dispatchPointerEvent('pointermove', {
clientX: 40,
clientY: 20,
getCoalescedEvents: () => [
makePointerEvent('pointermove', {
clientX: 20,
clientY: 20,
pointerId: 9,
timeStamp: 110,
}),
makePointerEvent('pointermove', {
clientX: 30,
clientY: 20,
pointerId: 9,
timeStamp: 115,
}),
makePointerEvent('pointermove', {
clientX: 40,
clientY: 20,
pointerId: 9,
timeStamp: 120,
}),
],
pointerId: 9,
timeStamp: 120,
});
expect(audio.stroke).toHaveBeenCalledTimes(3);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
});
it('caps curve tessellation with the brush curve resolution setting', async () => {
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
runtimeSettings.brushCurveResolution = 2;
runtimeSettings.brushSize = 1;
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 10,
clientY: 60,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 60,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
});
});

View file

@ -26,10 +26,16 @@ interface GardenPointerInputOptions {
}
export class GardenPointerInput {
private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25;
private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4;
private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65;
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
public constructor(private readonly options: GardenPointerInputOptions) {}
@ -75,6 +81,14 @@ export class GardenPointerInput {
if (this.lastPointerPosition !== null) {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
this.smoothedStrokePoints.forEach((point) => {
vec2.mul(point, point, scale);
});
if (this.lastSmoothedBrushPosition !== null) {
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
}
}
public get isSwipeActive(): boolean {
@ -98,6 +112,13 @@ export class GardenPointerInput {
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
this.options.audio.beginGesture();
this.options.audio.touchDown({
vibe: activeVibe,
colorIndex: settings.selectedColorIndex,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pressure: this.getPointerPressure(event),
pointerType: event.pointerType,
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
@ -106,8 +127,9 @@ export class GardenPointerInput {
this.options.eraserTexturePipeline.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.lastPointerPressure = this.getPointerPressure(event);
this.addSwipeAt(event);
this.addSwipeAt(event, { emitAudio: false });
};
private readonly onPointerMove = (event: PointerEvent) => {
@ -115,7 +137,9 @@ export class GardenPointerInput {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
this.addSwipeAt(coalescedEvent);
});
};
private readonly onPointerUp = (event: PointerEvent) => {
@ -123,6 +147,7 @@ export class GardenPointerInput {
return;
}
this.addSwipeAt(event, { emitAudio: false });
this.finishSmoothedStroke();
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
@ -131,6 +156,7 @@ export class GardenPointerInput {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
@ -169,14 +195,14 @@ export class GardenPointerInput {
? [{ from: previousPosition, to: position }]
: this.getMirroredStrokeSegments(previousPosition, position);
segments.forEach((segment) => {
if (this.isErasing) {
if (this.isErasing) {
segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
} else {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
}
});
});
} else {
this.addSmoothedBrushSample(position);
}
if (!this.isErasing) {
segments.forEach((segment) => {
@ -194,6 +220,7 @@ export class GardenPointerInput {
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pointerType: event.pointerType,
});
}
@ -201,6 +228,113 @@ export class GardenPointerInput {
this.lastPointerEventTimeMs = event.timeStamp;
}
private addSmoothedBrushSample(position: vec2): void {
const previousSample =
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
) {
return;
}
this.smoothedStrokePoints.push(vec2.clone(position));
if (this.smoothedStrokePoints.length > 3) {
this.smoothedStrokePoints.shift();
}
if (this.smoothedStrokePoints.length === 1) {
this.addMirroredBrushSegment(position, position);
this.lastSmoothedBrushPosition = vec2.clone(position);
return;
}
if (this.smoothedStrokePoints.length === 2) {
const [start, end] = this.smoothedStrokePoints;
const midpoint = getMidpoint(start, end);
this.addMirroredBrushSegment(start, midpoint);
this.lastSmoothedBrushPosition = midpoint;
return;
}
const [start, control, end] = this.smoothedStrokePoints;
const curveStart = getMidpoint(start, control);
const curveEnd = getMidpoint(control, end);
this.addQuadraticBrushSegments(curveStart, control, curveEnd);
this.lastSmoothedBrushPosition = curveEnd;
}
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const brushRadius = Math.max(1, settings.brushSize / 2);
const segmentSpacing = Math.max(
GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS,
brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
const curveResolution = getBrushCurveResolution();
const maxCurveSegments = Math.max(
1,
Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount))
);
const segmentCount = Math.min(
maxCurveSegments,
Math.max(1, Math.ceil(curveLength / segmentSpacing))
);
let previousPoint = start;
for (let i = 1; i <= segmentCount; i++) {
const point = getQuadraticPoint(start, control, end, i / segmentCount);
this.addMirroredBrushSegment(previousPoint, point);
previousPoint = point;
}
}
private addMirroredBrushSegment(from: vec2, to: vec2): void {
this.getMirroredStrokeSegments(from, to).forEach((segment) => {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
});
}
private finishSmoothedStroke(): void {
if (this.isErasing || this.smoothedStrokePoints.length === 0) {
return;
}
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
this.lastSmoothedBrushPosition !== null &&
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
) {
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
}
}
private clearSmoothedStroke(): void {
this.smoothedStrokePoints.length = 0;
this.lastSmoothedBrushPosition = null;
}
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
const getCoalescedEvents = (
event as PointerEvent & { getCoalescedEvents?: () => Array<PointerEvent> }
).getCoalescedEvents;
const coalescedEvents =
typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : [];
if (coalescedEvents.length === 0) {
return [event];
}
const lastEvent = coalescedEvents[coalescedEvents.length - 1];
return isSamePointerSample(lastEvent, event)
? coalescedEvents
: [...coalescedEvents, event];
}
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
const segmentCount = this.options.getMirrorSegmentCount();
if (segmentCount <= 1) {
@ -246,3 +380,27 @@ const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
center[1] + offsetX * sin + offsetY * cos
);
};
const getMidpoint = (from: vec2, to: vec2): vec2 =>
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
const inverseT = 1 - t;
return vec2.fromValues(
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
);
};
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.runtimeSettings.defaults.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX &&
left.clientY === right.clientY &&
left.pressure === right.pressure &&
left.buttons === right.buttons;

View file

@ -7,7 +7,7 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { SimulationTextures } from './simulation-textures';
export interface SimulationFramePipelines {
interface SimulationFramePipelines {
copyPipeline: CopyPipeline;
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;