This commit is contained in:
Andras Schmelczer 2026-05-17 17:21:49 +01:00
parent ced0ac56f3
commit d6a8f898d1
27 changed files with 760 additions and 363 deletions

View file

@ -19,6 +19,8 @@ vi.hoisted(() => {
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel;
const originalStrokeSpawnSpreadBrushSizeMultiplier =
settings.strokeSpawnSpreadBrushSizeMultiplier;
const createPopulation = () => {
const pipeline = {
@ -51,12 +53,16 @@ describe('AgentPopulation adaptive budget', () => {
settings.brushSize = 1;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
settings.strokeSpawnSpreadBrushSizeMultiplier = 1;
});
afterEach(() => {
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
settings.strokeSpawnSpreadBrushSizeMultiplier =
originalStrokeSpawnSpreadBrushSizeMultiplier;
vi.restoreAllMocks();
});
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
@ -102,6 +108,25 @@ describe('AgentPopulation adaptive budget', () => {
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('scales stroke spawn spread by device pixel ratio', () => {
settings.brushSize = 10;
const writeAgents = vi.fn();
const pipeline = {
maxAgentCount: 10_000_000,
writeAgents,
resizeAgents: vi.fn(),
compactAgents: vi.fn(),
} as unknown as AgentGenerationPipeline;
const population = new AgentPopulation(pipeline, 0, () => 2);
vi.spyOn(Math, 'random').mockReturnValue(1);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0));
const firstBatch = writeAgents.mock.calls[0][1] as Float32Array;
expect(firstBatch[0]).toBe(10);
expect(firstBatch[1]).toBe(10);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);

View file

@ -3,6 +3,7 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents';
@ -19,7 +20,8 @@ export class AgentPopulation {
public constructor(
private readonly pipeline: AgentGenerationPipeline,
private readonly introSeed = Math.floor(Math.random() * 0xffffffff)
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
private readonly getDevicePixelRatio = () => 1
) {
this.adaptiveCap = this.clampAdaptiveCap(
appConfig.simulation.budget.adaptiveCapInitial
@ -121,7 +123,10 @@ export class AgentPopulation {
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
const spread = settings.brushSize * settings.strokeSpawnSpreadBrushSizeMultiplier;
const spread =
settings.brushSize *
getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
settings.strokeSpawnSpreadBrushSizeMultiplier;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 2] = angle;

View file

@ -1,5 +1,6 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
import {
estimateExport4KMemory,
getAspectFitExport4KDimensions,
@ -15,7 +16,7 @@ interface Export4KRendererOptions {
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getVibeId: () => string;
getVibeId: () => VibeId;
}
export class Export4KRenderer {

View file

@ -20,6 +20,7 @@ interface FrameParameters extends RenderInputs {
deltaTime: number;
canvasSize: vec2;
activeAgentCount: number;
devicePixelRatio: number;
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
@ -99,6 +100,7 @@ export class GameLoopResources {
deltaTime,
canvasSize,
activeAgentCount,
devicePixelRatio,
introProgress,
selectedColorIndex,
channelColors,
@ -123,6 +125,7 @@ export class GameLoopResources {
});
this.brushPipeline.setParameters({
...settings,
devicePixelRatio,
selectedColorIndex,
});
this.diffusionPipeline.setParameters(settings);

View file

@ -49,7 +49,8 @@ export default class GameLoop {
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue
this.seedValue,
() => this.devicePixelRatio
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
@ -155,7 +156,8 @@ export default class GameLoop {
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const devicePixelRatio = this.devicePixelRatio;
const eraserPixelSize = settings.eraserSize * devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
@ -169,6 +171,7 @@ export default class GameLoop {
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
devicePixelRatio,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,

View file

@ -88,7 +88,9 @@ const makeSwipePipeline = () => ({
clearSwipes: vi.fn(),
});
const createPointerInput = async () => {
const createPointerInput = async ({
devicePixelRatio = 1,
}: { devicePixelRatio?: number } = {}) => {
const { GardenPointerInput } = await import('./pointer-input');
const { settings: runtimeSettings } = await import('../settings');
const canvas = new FakeCanvas();
@ -117,7 +119,7 @@ const createPointerInput = async () => {
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getDevicePixelRatio: () => 1,
getDevicePixelRatio: () => devicePixelRatio,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
onStartDrawing,
@ -277,6 +279,30 @@ describe('GardenPointerInput drawing startup', () => {
expect(toPoint(stroke.to)).toEqual([40, 50]);
});
it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => {
const { audio, brushPipeline, canvas } = await createPointerInput({
devicePixelRatio: 2,
});
canvas.dispatchPointerEvent('pointerdown', {
clientX: 10,
clientY: 20,
pointerId: 9,
timeStamp: 100,
});
canvas.dispatchPointerEvent('pointermove', {
clientX: 40,
clientY: 50,
pointerId: 9,
timeStamp: 150,
});
const firstStroke = audio.stroke.mock.calls[0][0];
expect(toPoint(firstStroke.from)).toEqual([20, 40]);
expect(toPoint(firstStroke.to)).toEqual([80, 100]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]);
});
it('caps curve tessellation with the brush curve resolution setting', async () => {
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
runtimeSettings.brushCurveResolution = 2;

View file

@ -2,7 +2,10 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import {
BrushPipeline,
getSafeDevicePixelRatio,
} from '../pipelines/brush/brush-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { activeVibe, settings } from '../settings';
@ -201,7 +204,9 @@ export class GardenPointerInput {
private getCanvasPointerPosition(event: PointerEvent): vec2 {
const rect = this.canvas.getBoundingClientRect();
const devicePixelRatio = this.options.getDevicePixelRatio();
const devicePixelRatio = getSafeDevicePixelRatio(
this.options.getDevicePixelRatio()
);
return vec2.fromValues(
(event.clientX - rect.left) * devicePixelRatio,
(event.clientY - rect.top) * devicePixelRatio
@ -213,7 +218,8 @@ export class GardenPointerInput {
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <= getBrushSmoothingDistanceSquared()
vec2.squaredDistance(previousSample, position) <=
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
) {
return;
}
@ -247,12 +253,13 @@ export class GardenPointerInput {
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio());
const brushRadius = Math.max(
settings.brushCurveMinBrushRadius,
settings.brushSize / 2
settings.brushCurveMinBrushRadius * devicePixelRatio,
(settings.brushSize * devicePixelRatio) / 2
);
const segmentSpacing = Math.max(
settings.brushCurveMinSegmentSpacing,
settings.brushCurveMinSegmentSpacing * devicePixelRatio,
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
@ -292,7 +299,7 @@ export class GardenPointerInput {
if (
this.lastSmoothedBrushPosition !== null &&
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
getBrushSmoothingDistanceSquared()
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
) {
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
}
@ -374,11 +381,11 @@ const getBrushCurveResolution = (): number => {
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (): number => {
const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance) ** 2;
return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2;
};
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>

View file

@ -1,9 +1,9 @@
import { activeVibe } from '../settings';
import { hexToRgb } from '../vibes';
import { hexToRgb, type VibeId } from '../vibes';
import { RenderInputs } from './game-loop-types';
export class RenderInputCache {
private cachedVibeId: string | null = null;
private cachedVibeId: VibeId | null = null;
private cachedRenderInputs?: RenderInputs;
private previousAccentColor = '';