This commit is contained in:
parent
39b0160064
commit
2347ecd201
71 changed files with 3799 additions and 1606 deletions
86
src/game-loop/agent-population.test.ts
Normal file
86
src/game-loop/agent-population.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
28
src/game-loop/game-loop-intro.test.ts
Normal file
28
src/game-loop/game-loop-intro.test.ts
Normal 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(');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
277
src/game-loop/pointer-input.test.ts
Normal file
277
src/game-loop/pointer-input.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue