more cleaning up
This commit is contained in:
parent
2c7d72a699
commit
560398fefb
110 changed files with 933 additions and 2647 deletions
|
|
@ -16,7 +16,6 @@ vi.hoisted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||
const originalBrushSize = settings.brushSize;
|
||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||
|
|
@ -38,16 +37,23 @@ const setPopulationActiveCount = (population: AgentPopulation, activeCount: numb
|
|||
});
|
||||
};
|
||||
|
||||
const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => {
|
||||
Object.assign(population as unknown as Record<string, number>, {
|
||||
adaptiveCap,
|
||||
});
|
||||
};
|
||||
|
||||
const getPopulationAdaptiveCap = (population: AgentPopulation): number =>
|
||||
(population as unknown as { adaptiveCap: number }).adaptiveCap;
|
||||
|
||||
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;
|
||||
|
|
@ -57,12 +63,16 @@ describe('AgentPopulation adaptive budget', () => {
|
|||
const population = createPopulation();
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
population.growBudget(1 / 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(
|
||||
expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
|
||||
appConfig.simulation.budget.adaptiveCapInitial
|
||||
);
|
||||
expect(population.activeAgentCount).toBeGreaterThan(
|
||||
appConfig.simulation.budget.adaptiveCapInitial
|
||||
);
|
||||
expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
|
||||
appConfig.simulation.budget.adaptiveCapMax
|
||||
);
|
||||
});
|
||||
|
|
@ -70,25 +80,25 @@ describe('AgentPopulation adaptive budget', () => {
|
|||
it('does not grow the cap above the adaptive max agent count', () => {
|
||||
const population = createPopulation();
|
||||
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
||||
settings.agentBudgetMax = maxAgentCount - 1;
|
||||
setPopulationAdaptiveCap(population, maxAgentCount - 1);
|
||||
setPopulationActiveCount(population, maxAgentCount - 1);
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
population.growBudget(1 / 60, 60);
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(maxAgentCount);
|
||||
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
|
||||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||
});
|
||||
|
||||
it('clamps a manually raised cap before adding agents', () => {
|
||||
it('clamps a stale cap before adding agents', () => {
|
||||
const population = createPopulation();
|
||||
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
||||
settings.agentBudgetMax = maxAgentCount + 1_000;
|
||||
setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
|
||||
setPopulationActiveCount(population, maxAgentCount);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(maxAgentCount);
|
||||
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
|
||||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||
});
|
||||
|
||||
|
|
@ -96,9 +106,11 @@ describe('AgentPopulation adaptive budget', () => {
|
|||
const population = createPopulation();
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
|
||||
population.growBudget(10, 50, 60);
|
||||
population.growBudget(10, 50);
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||
expect(getPopulationAdaptiveCap(population)).toBe(
|
||||
appConfig.simulation.budget.adaptiveCapMin
|
||||
);
|
||||
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
|
|||
import { settings } from '../settings';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
|
||||
|
||||
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_MAX = appConfig.simulation.budget.adaptiveCapMax;
|
||||
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
||||
const ADAPTIVE_CAP_INITIAL = appConfig.simulation.budget.adaptiveCapInitial;
|
||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
||||
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
private adaptiveCap: number;
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
|
|
@ -27,19 +28,17 @@ export class AgentPopulation {
|
|||
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
|
||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {
|
||||
this.adaptiveCap = this.clampAdaptiveCap(ADAPTIVE_CAP_INITIAL);
|
||||
}
|
||||
|
||||
public get activeAgentCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.pipeline.maxAgentCount;
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
const introAgentCount = Math.min(this.adaptiveCap, INITIAL_AGENT_COUNT);
|
||||
this.writeAgentBatch(
|
||||
createIntroTitleAgents({
|
||||
count: introAgentCount,
|
||||
|
|
@ -50,16 +49,12 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.trimActiveCountToBudget();
|
||||
}
|
||||
|
||||
public growBudget(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
||||
public growBudget(deltaTime: number, smoothedFps: number): void {
|
||||
this.updateAdaptiveCap(deltaTime, smoothedFps);
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
|
|
@ -108,7 +103,7 @@ export class AgentPopulation {
|
|||
const x = from[0] + (to[0] - from[0]) * t;
|
||||
const y = from[1] + (to[1] - from[1]) * t;
|
||||
const angle =
|
||||
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
|
||||
baseAngle +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
||||
|
|
@ -130,10 +125,10 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.expandAdaptiveCapForPendingAgents(count);
|
||||
|
||||
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
const appendCount = Math.min(count, available);
|
||||
|
||||
if (appendCount > 0) {
|
||||
|
|
@ -165,18 +160,14 @@ export class AgentPopulation {
|
|||
}
|
||||
}
|
||||
|
||||
private updateAdaptiveCap(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
|
||||
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.canExpandAdaptiveCap =
|
||||
refreshTargetFps <= 0 ||
|
||||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
||||
smoothedFps >=
|
||||
ADAPTIVE_REFRESH_TARGET_FPS * appConfig.simulation.budget.fpsHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
settings.agentBudgetMax = previousCap;
|
||||
this.adaptiveCap = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
|
@ -186,28 +177,28 @@ export class AgentPopulation {
|
|||
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
|
||||
);
|
||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.adaptiveCap = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
}
|
||||
|
||||
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
|
||||
if (this.activeCount <= settings.agentBudgetMax) {
|
||||
if (this.activeCount <= this.adaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCount = Math.max(
|
||||
settings.agentBudgetMax,
|
||||
this.adaptiveCap,
|
||||
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||
);
|
||||
this.replacementCursor =
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@ export class Export4KRenderer {
|
|||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format,
|
||||
usage:
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.TEXTURE_BINDING,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: estimate.readbackBufferBytes,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
formatByteSize,
|
||||
getAspectFitExport4KDimensions,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
|
@ -39,7 +38,6 @@ describe('4K export preflight', () => {
|
|||
expect(estimate.height).toBe(2160);
|
||||
expect(estimate.bytesPerRow % 256).toBe(0);
|
||||
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
|
||||
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the export texture', () => {
|
||||
|
|
|
|||
|
|
@ -14,14 +14,10 @@ const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
|||
interface Export4KMemoryEstimate {
|
||||
width: number;
|
||||
height: number;
|
||||
bytesPerPixel: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
textureBytes: number;
|
||||
readbackBufferBytes: number;
|
||||
pixelBytes: number;
|
||||
canvasBytes: number;
|
||||
encoderSafetyBytes: number;
|
||||
estimatedJsHeapBytes: number;
|
||||
estimatedPeakBytes: number;
|
||||
}
|
||||
|
|
@ -49,8 +45,7 @@ const alignTo = (value: number, alignment: number): number =>
|
|||
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
|
||||
export const formatByteSize = (bytes: number): string =>
|
||||
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||
const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||
|
||||
export const getAspectFitExport4KDimensions = (
|
||||
sourceWidth: number,
|
||||
|
|
@ -83,22 +78,15 @@ export const estimateExport4KMemory = (
|
|||
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
|
||||
const textureBytes = unpaddedBytesPerRow * height;
|
||||
const readbackBufferBytes = bytesPerRow * height;
|
||||
const pixelBytes = textureBytes;
|
||||
const canvasBytes = textureBytes;
|
||||
const encoderSafetyBytes = textureBytes * 2;
|
||||
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
|
||||
const estimatedJsHeapBytes = textureBytes * 4;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bytesPerPixel: BYTES_PER_PIXEL,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
textureBytes,
|
||||
readbackBufferBytes,
|
||||
pixelBytes,
|
||||
canvasBytes,
|
||||
encoderSafetyBytes,
|
||||
estimatedJsHeapBytes,
|
||||
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import { FramePerformance } from './frame-performance';
|
||||
|
||||
const INITIAL_FPS = 60;
|
||||
|
||||
function createScenario() {
|
||||
const performance = new FramePerformance();
|
||||
let time = 0;
|
||||
|
|
@ -13,52 +15,31 @@ function createScenario() {
|
|||
return { performance, advance };
|
||||
}
|
||||
|
||||
describe('FramePerformance refresh target', () => {
|
||||
it('uses 60 FPS as the fixed adaptive budget target', () => {
|
||||
const { performance, advance } = createScenario();
|
||||
describe('FramePerformance', () => {
|
||||
it('starts at the adaptive budget target', () => {
|
||||
const { performance } = createScenario();
|
||||
|
||||
[123, 126, 130, 121, 60, 30].forEach(advance);
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.smoothedFps).toBe(INITIAL_FPS);
|
||||
});
|
||||
|
||||
it('keeps latest and smoothed FPS separate from the fixed target', () => {
|
||||
it('smooths measured frame rates', () => {
|
||||
const { performance, advance } = createScenario();
|
||||
|
||||
advance(120);
|
||||
|
||||
expect(performance.latestFps).toBe(120);
|
||||
expect(performance.smoothedFps).toBeGreaterThan(60);
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
|
||||
expect(performance.smoothedFps).toBeLessThan(120);
|
||||
});
|
||||
|
||||
it('reports true FPS even when the simulation delta would clamp', () => {
|
||||
const { performance, advance } = createScenario();
|
||||
it('ignores long gaps before smoothing resumes', () => {
|
||||
const performance = new FramePerformance();
|
||||
performance.update(0);
|
||||
performance.update(2_000);
|
||||
|
||||
[5, 5, 5, 5, 5].forEach(advance);
|
||||
expect(performance.smoothedFps).toBe(INITIAL_FPS);
|
||||
|
||||
expect(performance.latestFps).toBeCloseTo(5, 5);
|
||||
});
|
||||
performance.update(2_000 + 1000 / 30);
|
||||
|
||||
it('snaps the display refresh estimate to a stable screen frequency', () => {
|
||||
const { performance, advance } = createScenario();
|
||||
|
||||
[123, 126, 130, 121, 124, 127, 125, 122].forEach(advance);
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.displayRefreshFps).toBe(120);
|
||||
});
|
||||
|
||||
it('ignores a single startup spike before settling the display refresh estimate', () => {
|
||||
const { performance, advance } = createScenario();
|
||||
|
||||
advance(240);
|
||||
|
||||
expect(performance.displayRefreshFps).toBe(60);
|
||||
|
||||
Array.from({ length: 8 }).forEach(() => advance(120));
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.displayRefreshFps).toBe(120);
|
||||
expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,12 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
||||
] as const;
|
||||
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
||||
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
||||
const INITIAL_FPS = 60;
|
||||
const FRAME_GAP_RESET_SECONDS = 1;
|
||||
|
||||
export class FramePerformance {
|
||||
public latestFps = 60;
|
||||
public smoothedFps = 60;
|
||||
public displayRefreshFps = 60;
|
||||
public readonly refreshTargetFps = 60;
|
||||
public smoothedFps = INITIAL_FPS;
|
||||
|
||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||
private hasConfirmedDisplayRefreshFps = false;
|
||||
private pendingDisplayRefreshFps = 0;
|
||||
private pendingDisplayRefreshFrameCount = 0;
|
||||
|
||||
public update(time: DOMHighResTimeStamp): void {
|
||||
const previous = this.previousFrameTime;
|
||||
|
|
@ -31,67 +21,8 @@ export class FramePerformance {
|
|||
}
|
||||
|
||||
const fps = 1 / deltaSeconds;
|
||||
this.latestFps = fps;
|
||||
this.updateDisplayRefreshEstimate(fps);
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
}
|
||||
|
||||
private updateDisplayRefreshEstimate(fps: number): void {
|
||||
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
|
||||
if (displayRefreshFps === null) {
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.hasConfirmedDisplayRefreshFps &&
|
||||
displayRefreshFps < this.displayRefreshFps
|
||||
) {
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayRefreshFps !== this.pendingDisplayRefreshFps) {
|
||||
this.pendingDisplayRefreshFps = displayRefreshFps;
|
||||
this.pendingDisplayRefreshFrameCount = 1;
|
||||
} else {
|
||||
this.pendingDisplayRefreshFrameCount += 1;
|
||||
}
|
||||
|
||||
if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.displayRefreshFps = displayRefreshFps;
|
||||
this.hasConfirmedDisplayRefreshFps = true;
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
}
|
||||
|
||||
private snapDisplayRefreshRate(fps: number): number | null {
|
||||
if (!Number.isFinite(fps) || fps <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0];
|
||||
let nearestDifference = Math.abs(fps - nearestRefreshRate);
|
||||
|
||||
COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => {
|
||||
const difference = Math.abs(fps - refreshRate);
|
||||
if (difference < nearestDifference) {
|
||||
nearestRefreshRate = refreshRate;
|
||||
nearestDifference = difference;
|
||||
}
|
||||
});
|
||||
|
||||
return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE
|
||||
? nearestRefreshRate
|
||||
: null;
|
||||
}
|
||||
|
||||
private resetPendingDisplayRefreshEstimate(): void {
|
||||
this.pendingDisplayRefreshFps = 0;
|
||||
this.pendingDisplayRefreshFrameCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ const getRenderStepSource = () => {
|
|||
const start = simulationFrameSource.indexOf(
|
||||
'const commandEncoder = this.device.createCommandEncoder();'
|
||||
);
|
||||
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||
const swapCall = ' this.textures.swapBrushEffectMaps();';
|
||||
const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length;
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the simulation frame execution body');
|
||||
|
|
@ -38,7 +39,7 @@ describe('GameLoop ping-pong texture flow', () => {
|
|||
/commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
|
||||
);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
|
||||
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ describe('GameLoop ping-pong texture flow', () => {
|
|||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
|
||||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
|
||||
expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
|
||||
expect(simulationTexturesSource).toContain('private readonly copyPipeline');
|
||||
});
|
||||
|
||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||
|
|
@ -54,6 +56,7 @@ describe('GameLoop ping-pong texture flow', () => {
|
|||
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void');
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
|
|||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { GLOBAL_AGENT_CAP } from './agent-population';
|
||||
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||
import { SimulationFrameRenderer } from './simulation-frame';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
|
@ -24,8 +23,6 @@ interface FrameParameters extends RenderInputs {
|
|||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
cameraCenter: [number, number];
|
||||
cameraZoom: number;
|
||||
eraserPixelSize: number;
|
||||
}
|
||||
|
||||
|
|
@ -56,13 +53,11 @@ export class GameLoopResources {
|
|||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
});
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
GLOBAL_AGENT_CAP
|
||||
appConfig.simulation.budget.adaptiveCapMax
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
|
|
@ -73,15 +68,11 @@ export class GameLoopResources {
|
|||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
|
||||
this.device,
|
||||
this.commonState
|
||||
);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||
|
|
@ -106,17 +97,13 @@ export class GameLoopResources {
|
|||
activeAgentCount,
|
||||
introProgress,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
}: FrameParameters): void {
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time,
|
||||
deltaTime,
|
||||
});
|
||||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
|
|
@ -133,19 +120,15 @@ export class GameLoopResources {
|
|||
this.brushPipeline.setParameters({
|
||||
...settings,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
});
|
||||
this.diffusionPipeline.setParameters(settings);
|
||||
this.renderPipeline.setParameters({
|
||||
...settings,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
});
|
||||
this.eraserAgentPipeline.setParameters({
|
||||
agentCount: activeAgentCount,
|
||||
eraserSize: eraserPixelSize,
|
||||
});
|
||||
this.eraserTexturePipeline.setParameters({
|
||||
eraserSize: eraserPixelSize,
|
||||
|
|
@ -160,10 +143,6 @@ export class GameLoopResources {
|
|||
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.frameRenderer.clearSwipes();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.agentGenerationPipeline.destroy();
|
||||
this.agentPipeline.destroy();
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export interface GameLoopSettings {
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { gardenAudioConfig } from '../audio/garden-audio-config';
|
|||
import { appConfig } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { sleep } from '../utils/sleep';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { Export4KRenderer } from './export-4k-renderer';
|
||||
|
|
@ -18,9 +17,7 @@ import { RenderInputCache } from './render-input-cache';
|
|||
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||
|
||||
export default class GameLoop {
|
||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||
appConfig.simulation.maxMirrorSegmentCount;
|
||||
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||
private static readonly MAX_MIRROR_SEGMENT_COUNT = appConfig.toolbar.mirror.max;
|
||||
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||
|
|
@ -32,13 +29,10 @@ export default class GameLoop {
|
|||
private readonly export4KRenderer: Export4KRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||
private readonly devStatsElement: HTMLDivElement | 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 isStatsOverlayPinned = false;
|
||||
private hasFinished = false;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
|
|
@ -49,8 +43,6 @@ export default class GameLoop {
|
|||
ui: GardenUi
|
||||
) {
|
||||
this.resize();
|
||||
this.devStatsElement = this.createDevStatsElement();
|
||||
this.syncDevStatsVisibility();
|
||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
|
|
@ -102,25 +94,14 @@ export default class GameLoop {
|
|||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.agentPopulation.onVibeChanged();
|
||||
this.renderInputs.invalidate();
|
||||
this.agentPopulation.onVibeChanged();
|
||||
}
|
||||
|
||||
public setAudioMuted(isMuted: boolean): void {
|
||||
this.audio.setMuted(isMuted);
|
||||
}
|
||||
|
||||
public setStatsOverlayPinned(isPinned: boolean): void {
|
||||
const wasVisible = this.shouldShowDevStats;
|
||||
this.isStatsOverlayPinned = isPinned;
|
||||
this.syncDevStatsVisibility();
|
||||
|
||||
if (!wasVisible && this.shouldShowDevStats) {
|
||||
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
|
||||
this.updateDevStats(performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
public startAudio(userGesture = false): void {
|
||||
this.audio.start(activeVibe, { userGesture });
|
||||
}
|
||||
|
|
@ -134,10 +115,6 @@ export default class GameLoop {
|
|||
return this.finished.promise;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.agentPopulation.maxAgentCount;
|
||||
}
|
||||
|
||||
public async export4K(): Promise<void> {
|
||||
return this.export4KRenderer.export();
|
||||
}
|
||||
|
|
@ -150,7 +127,6 @@ export default class GameLoop {
|
|||
window.removeEventListener('keydown', this.keydownListener);
|
||||
this.pointerInput.detach();
|
||||
this.toolbarContrastMonitor.destroy();
|
||||
this.devStatsElement?.remove();
|
||||
this.introPrompt.destroy();
|
||||
this.resources.destroy();
|
||||
await this.audio.destroy();
|
||||
|
|
@ -164,29 +140,19 @@ export default class GameLoop {
|
|||
|
||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||
this.framePerformance.update(time);
|
||||
this.agentPopulation.growBudget(
|
||||
deltaTime,
|
||||
this.framePerformance.smoothedFps,
|
||||
this.framePerformance.refreshTargetFps
|
||||
);
|
||||
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
|
||||
this.introPrompt.update();
|
||||
this.resize();
|
||||
this.resizeSimulationToCanvas();
|
||||
|
||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const cameraZoom = 1;
|
||||
const cameraCenter: [number, number] = [
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2,
|
||||
];
|
||||
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
this.renderInputs.updateAccentColor(accentColor);
|
||||
this.audio.update({
|
||||
vibe: activeVibe,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
});
|
||||
|
||||
|
|
@ -200,8 +166,6 @@ export default class GameLoop {
|
|||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
});
|
||||
|
||||
|
|
@ -213,60 +177,9 @@ export default class GameLoop {
|
|||
this.pointerInput.clearSwipesIfIdle();
|
||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||
|
||||
this.updateDevStats(time);
|
||||
|
||||
if (settings.simulatedDelayMs > 0) {
|
||||
await sleep(settings.simulatedDelayMs);
|
||||
}
|
||||
|
||||
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 ||
|
||||
!this.shouldShowDevStats ||
|
||||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDevStatsUpdateAt = time;
|
||||
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
|
||||
this.devStatsElement.textContent = [
|
||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
|
||||
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private syncDevStatsVisibility(): void {
|
||||
if (!this.devStatsElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = this.shouldShowDevStats;
|
||||
this.devStatsElement.hidden = !isVisible;
|
||||
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
|
||||
}
|
||||
|
||||
private formatDevStatNumber(value: number): string {
|
||||
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const width = Math.max(
|
||||
1,
|
||||
|
|
@ -310,8 +223,4 @@ export default class GameLoop {
|
|||
: 1;
|
||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||
}
|
||||
|
||||
private get shouldShowDevStats(): boolean {
|
||||
return import.meta.env.DEV || this.isStatsOverlayPinned;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
|
||||
const DRAW_HINT_CLASS = 'draw-hint';
|
||||
|
||||
export class IntroPrompt {
|
||||
private introComplete = false;
|
||||
private introStartedAt = performance.now();
|
||||
private introCompletedAt: number | null = null;
|
||||
private hasStartedDrawing = false;
|
||||
private isDrawHintVisible = false;
|
||||
|
||||
public constructor(private readonly prompt: HTMLElement) {}
|
||||
|
||||
|
|
@ -55,12 +55,11 @@ export class IntroPrompt {
|
|||
}
|
||||
|
||||
private showDrawHint(): void {
|
||||
if (this.isDrawHintVisible) {
|
||||
if (this.prompt.classList.contains(DRAW_HINT_CLASS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDrawHintVisible = true;
|
||||
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
|
||||
this.prompt.classList.add(DRAW_HINT_CLASS);
|
||||
this.prompt.innerHTML = `
|
||||
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
|
||||
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||
|
|
@ -68,13 +67,12 @@ export class IntroPrompt {
|
|||
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
|
||||
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
|
||||
</svg>
|
||||
<span class="draw-hint-text">Draw on the screen</span>
|
||||
<span>Draw on the screen</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private hideDrawHint(): void {
|
||||
this.isDrawHintVisible = false;
|
||||
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
|
||||
this.prompt.classList.remove(DRAW_HINT_CLASS);
|
||||
this.prompt.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ const createPointerInput = async () => {
|
|||
endGesture: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
touchDown: vi.fn(),
|
||||
};
|
||||
const brushPipeline = makeSwipePipeline();
|
||||
const eraserAgentPipeline = makeSwipePipeline();
|
||||
|
|
@ -182,11 +181,6 @@ describe('GardenPointerInput drawing startup', () => {
|
|||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(audio.touchDown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
colorIndex: 0,
|
||||
})
|
||||
);
|
||||
expect(audio.stroke).not.toHaveBeenCalled();
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||
|
|
|
|||
|
|
@ -111,9 +111,6 @@ export class GardenPointerInput {
|
|||
this.options.audio.start(activeVibe, { userGesture: true });
|
||||
}
|
||||
this.options.audio.beginGesture();
|
||||
this.options.audio.touchDown({
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
});
|
||||
this.options.onStartDrawing();
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
|
|
@ -183,14 +180,11 @@ export class GardenPointerInput {
|
|||
|
||||
if (this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
|
||||
this.options.eraserAgentPipeline.addSwipeSegment();
|
||||
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
||||
});
|
||||
} else {
|
||||
this.addSmoothedBrushSample(position);
|
||||
}
|
||||
|
||||
if (!this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.spawnStrokeAgents(segment.from, segment.to);
|
||||
});
|
||||
|
|
@ -200,7 +194,6 @@ export class GardenPointerInput {
|
|||
vibe: activeVibe,
|
||||
from: previousPosition,
|
||||
to: position,
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
isErasing: this.isErasing,
|
||||
elapsedSeconds,
|
||||
});
|
||||
|
|
@ -375,7 +368,7 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
|
|||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.runtimeSettings.defaults.brushCurveResolution;
|
||||
: appConfig.defaultSettings.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -80,13 +80,6 @@ export class SimulationFrameRenderer {
|
|||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
canvasReadbackRequest?.afterSubmit();
|
||||
this.textures.swapSourceMaps();
|
||||
this.textures.swapInfluenceMaps();
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.pipelines.brushPipeline.clearSwipes();
|
||||
this.pipelines.eraserAgentPipeline.clearSwipes();
|
||||
this.pipelines.eraserTexturePipeline.clearSwipes();
|
||||
this.textures.swapBrushEffectMaps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||
|
||||
export class SimulationTextures {
|
||||
|
|
@ -10,18 +11,20 @@ export class SimulationTextures {
|
|||
public influenceMapA: ResizableTexture;
|
||||
public influenceMapB: ResizableTexture;
|
||||
public eraserMask: ResizableTexture;
|
||||
private readonly copyPipeline: CopyPipeline;
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
) {
|
||||
this.trailMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.trailMapB = new ResizableTexture(this.device, canvasSize);
|
||||
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
|
||||
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
|
||||
this.eraserMask = new ResizableTexture(this.device, canvasSize);
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
this.trailMapA = this.createTexture(canvasSize);
|
||||
this.trailMapB = this.createTexture(canvasSize);
|
||||
this.sourceMapA = this.createTexture(canvasSize);
|
||||
this.sourceMapB = this.createTexture(canvasSize);
|
||||
this.influenceMapA = this.createTexture(canvasSize);
|
||||
this.influenceMapB = this.createTexture(canvasSize);
|
||||
this.eraserMask = this.createTexture(canvasSize);
|
||||
}
|
||||
|
||||
public resizeTo(nextSize: vec2): vec2 | null {
|
||||
|
|
@ -69,11 +72,8 @@ export class SimulationTextures {
|
|||
);
|
||||
}
|
||||
|
||||
public swapSourceMaps(): void {
|
||||
public swapBrushEffectMaps(): void {
|
||||
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||
}
|
||||
|
||||
public swapInfluenceMaps(): void {
|
||||
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
|
||||
}
|
||||
|
||||
|
|
@ -85,5 +85,10 @@ export class SimulationTextures {
|
|||
this.influenceMapA.destroy();
|
||||
this.influenceMapB.destroy();
|
||||
this.eraserMask.destroy();
|
||||
this.copyPipeline.destroy();
|
||||
}
|
||||
|
||||
private createTexture(size: vec2): ResizableTexture {
|
||||
return new ResizableTexture(this.device, this.copyPipeline, size);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getToolbarContrastMetrics,
|
||||
shouldDimToolbarBackground,
|
||||
} from './toolbar-contrast-monitor';
|
||||
import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
|
||||
|
||||
const makePixels = (
|
||||
samples: ReadonlyArray<readonly [number, number, number]>
|
||||
|
|
@ -27,12 +24,28 @@ describe('toolbar contrast monitoring', () => {
|
|||
false
|
||||
);
|
||||
|
||||
expect(metrics.dimmingStrength).toBe(0);
|
||||
expect(metrics.backgroundOpacity).toBe(0);
|
||||
expect(metrics.lowContrastRatio).toBe(0);
|
||||
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('dims the toolbar when enough samples have poor contrast with white controls', () => {
|
||||
it('ramps background opacity as canvas samples get lighter', () => {
|
||||
const dimMetrics = getToolbarContrastMetrics(
|
||||
makePixels(Array.from({ length: 91 }, () => [130, 130, 130])),
|
||||
91,
|
||||
false
|
||||
);
|
||||
const brightMetrics = getToolbarContrastMetrics(
|
||||
makePixels(Array.from({ length: 91 }, () => [210, 210, 210])),
|
||||
91,
|
||||
false
|
||||
);
|
||||
|
||||
expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0);
|
||||
expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity);
|
||||
expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82);
|
||||
});
|
||||
|
||||
it('raises background opacity when enough samples have poor contrast with white controls', () => {
|
||||
const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
|
||||
const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
|
||||
const metrics = getToolbarContrastMetrics(
|
||||
|
|
@ -42,21 +55,7 @@ describe('toolbar contrast monitoring', () => {
|
|||
);
|
||||
|
||||
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
|
||||
expect(shouldDimToolbarBackground(metrics, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the dimmed state until contrast has clearly recovered', () => {
|
||||
const metrics = getToolbarContrastMetrics(
|
||||
makePixels([
|
||||
...Array.from({ length: 86 }, () => [8, 12, 18] as const),
|
||||
...Array.from({ length: 5 }, () => [245, 240, 218] as const),
|
||||
]),
|
||||
91,
|
||||
false
|
||||
);
|
||||
|
||||
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
|
||||
expect(shouldDimToolbarBackground(metrics, true)).toBe(true);
|
||||
expect(metrics.backgroundOpacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reads bgra canvas samples in the correct channel order', () => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ interface CanvasSamplePoint {
|
|||
|
||||
interface ToolbarContrastMetrics {
|
||||
averageLuminance: number;
|
||||
backgroundOpacity: number;
|
||||
brightRatio: number;
|
||||
dimmingStrength: number;
|
||||
lowContrastRatio: number;
|
||||
}
|
||||
|
||||
|
|
@ -16,10 +16,9 @@ const BYTES_PER_SAMPLE = 4;
|
|||
const SAMPLE_COLUMNS = 13;
|
||||
const SAMPLE_ROWS = 7;
|
||||
const SAMPLE_INTERVAL_MS = 300;
|
||||
const LOW_CONTRAST_RATIO_TO_DIM = 0.08;
|
||||
const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04;
|
||||
const DIMMING_STRENGTH_TO_DIM = 0.18;
|
||||
const DIMMING_STRENGTH_TO_CLEAR = 0.1;
|
||||
const BACKGROUND_OPACITY_MAX = 0.82;
|
||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
|
|
@ -44,8 +43,8 @@ export const getToolbarContrastMetrics = (
|
|||
if (count === 0) {
|
||||
return {
|
||||
averageLuminance: 0,
|
||||
backgroundOpacity: 0,
|
||||
brightRatio: 0,
|
||||
dimmingStrength: 0,
|
||||
lowContrastRatio: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -74,34 +73,24 @@ export const getToolbarContrastMetrics = (
|
|||
const averageLuminance = luminanceTotal / count;
|
||||
const brightRatio = brightCount / count;
|
||||
const lowContrastRatio = lowContrastCount / count;
|
||||
const dimmingStrength = clamp01(
|
||||
const backgroundStrength = clamp01(
|
||||
Math.max(0, averageLuminance - 0.11) / 0.28 +
|
||||
brightRatio * 0.65 +
|
||||
lowContrastRatio * 1.8
|
||||
);
|
||||
const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
|
||||
|
||||
return {
|
||||
averageLuminance,
|
||||
backgroundOpacity,
|
||||
brightRatio,
|
||||
dimmingStrength,
|
||||
lowContrastRatio,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldDimToolbarBackground = (
|
||||
metrics: ToolbarContrastMetrics,
|
||||
wasDimmed: boolean
|
||||
): boolean =>
|
||||
wasDimmed
|
||||
? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR ||
|
||||
metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR
|
||||
: metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM ||
|
||||
metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM;
|
||||
|
||||
export class ToolbarContrastMonitor {
|
||||
private readonly isBgra: boolean;
|
||||
private isDestroyed = false;
|
||||
private isDimmed = false;
|
||||
private isReadbackPending = false;
|
||||
private lastSampleAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -210,7 +199,28 @@ export class ToolbarContrastMonitor {
|
|||
|
||||
public destroy(): void {
|
||||
this.isDestroyed = true;
|
||||
this.toolbar.classList.remove('needs-contrast-background');
|
||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
|
||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
|
||||
}
|
||||
|
||||
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
|
||||
const safeBackgroundOpacity = Math.min(
|
||||
BACKGROUND_OPACITY_MAX,
|
||||
Math.max(0, backgroundOpacity)
|
||||
);
|
||||
const backgroundStrength =
|
||||
BACKGROUND_OPACITY_MAX > 0
|
||||
? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
|
||||
: 0;
|
||||
|
||||
this.toolbar.style.setProperty(
|
||||
TOOLBAR_BACKGROUND_OPACITY_PROPERTY,
|
||||
`${(safeBackgroundOpacity * 100).toFixed(1)}%`
|
||||
);
|
||||
this.toolbar.style.setProperty(
|
||||
TOOLBAR_BACKGROUND_STRENGTH_PROPERTY,
|
||||
backgroundStrength.toFixed(3)
|
||||
);
|
||||
}
|
||||
|
||||
private getSamplePoints(): Array<CanvasSamplePoint> {
|
||||
|
|
@ -268,8 +278,7 @@ export class ToolbarContrastMonitor {
|
|||
if (!this.isDestroyed) {
|
||||
const pixels = new Uint8Array(buffer.getMappedRange());
|
||||
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra);
|
||||
this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed);
|
||||
this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed);
|
||||
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
|
||||
}
|
||||
} catch {
|
||||
// Readback is an enhancement; leave rendering alone if the GPU rejects it.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue