WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7144 additions and 1965 deletions
181
src/game-loop/agent-population.ts
Normal file
181
src/game-loop/agent-population.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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 { 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;
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
||||
private replacementCursor = 0;
|
||||
private shouldCompactAfterErase = false;
|
||||
private isCompacting = false;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
|
||||
|
||||
public get activeAgentCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
public get targetAgentBudget(): number {
|
||||
return this.targetBudget;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.pipeline.maxAgentCount;
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
this.targetBudget = Math.min(
|
||||
this.pipeline.maxAgentCount,
|
||||
settings.agentBudgetMax,
|
||||
INITIAL_AGENT_COUNT
|
||||
);
|
||||
this.writeAgentBatch(
|
||||
createIntroTitleAgents({
|
||||
count: this.targetBudget,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.targetBudget = Math.min(
|
||||
this.targetBudget,
|
||||
settings.agentBudgetMax,
|
||||
this.pipeline.maxAgentCount
|
||||
);
|
||||
}
|
||||
|
||||
public growBudget(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
|
||||
if (
|
||||
this.targetBudget < cap &&
|
||||
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
||||
) {
|
||||
this.targetBudget = Math.min(
|
||||
cap,
|
||||
this.targetBudget +
|
||||
Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||
}
|
||||
|
||||
public requestCompactionAfterErase(): void {
|
||||
this.shouldCompactAfterErase = true;
|
||||
}
|
||||
|
||||
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
|
||||
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldCompactAfterErase = false;
|
||||
if (this.activeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCompacting = true;
|
||||
try {
|
||||
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
|
||||
this.activeCount = compactedAgentCount;
|
||||
this.replacementCursor =
|
||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
|
||||
} finally {
|
||||
this.isCompacting = false;
|
||||
}
|
||||
}
|
||||
|
||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||
const length = Math.max(1, vec2.dist(from, to));
|
||||
const count = Math.max(
|
||||
MIN_STROKE_AGENT_COUNT,
|
||||
Math.min(
|
||||
MAX_STROKE_AGENT_COUNT,
|
||||
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
|
||||
)
|
||||
);
|
||||
const direction = vec2.sub(vec2.create(), to, from);
|
||||
const baseAngle = Math.atan2(direction[1], direction[0]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 1 : i / (count - 1);
|
||||
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) +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 2] = angle;
|
||||
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
||||
this.strokeAgentData[base + 4] = -1;
|
||||
this.strokeAgentData[base + 5] = -1;
|
||||
this.strokeAgentData[base + 6] = angle;
|
||||
this.strokeAgentData[base + 7] = 0;
|
||||
}
|
||||
|
||||
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
|
||||
}
|
||||
|
||||
private writeAgentBatch(data: Float32Array): void {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
||||
const appendCount = Math.min(count, available);
|
||||
|
||||
if (appendCount > 0) {
|
||||
this.pipeline.writeAgents(
|
||||
this.activeCount,
|
||||
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||
);
|
||||
this.activeCount += appendCount;
|
||||
}
|
||||
|
||||
let sourceAgentOffset = appendCount;
|
||||
while (sourceAgentOffset < count && this.activeCount > 0) {
|
||||
const targetAgentOffset = this.replacementCursor % this.activeCount;
|
||||
const chunkAgentCount = Math.min(
|
||||
count - sourceAgentOffset,
|
||||
this.activeCount - targetAgentOffset
|
||||
);
|
||||
|
||||
this.pipeline.writeAgents(
|
||||
targetAgentOffset,
|
||||
data.subarray(
|
||||
sourceAgentOffset * AGENT_FLOAT_COUNT,
|
||||
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||
)
|
||||
);
|
||||
|
||||
sourceAgentOffset += chunkAgentCount;
|
||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/game-loop/eraser-preview.ts
Normal file
80
src/game-loop/eraser-preview.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { settings } from '../settings';
|
||||
|
||||
export class EraserPreview {
|
||||
private previewClientPosition: { x: number; y: number } | null = null;
|
||||
private isErasing = false;
|
||||
private isPointerHoveringCanvas = false;
|
||||
private previousSize: number | null = null;
|
||||
private previousLeft = '';
|
||||
private previousTop = '';
|
||||
private isVisible = false;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly element: HTMLElement
|
||||
) {}
|
||||
|
||||
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.update(undefined, isSwipeActive);
|
||||
}
|
||||
|
||||
public setPointerHoveringCanvas(isHovering: boolean): void {
|
||||
this.isPointerHoveringCanvas = isHovering;
|
||||
}
|
||||
|
||||
public update(event?: PointerEvent, isSwipeActive = false): void {
|
||||
if (event) {
|
||||
this.previewClientPosition = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.previousSize !== settings.eraserSize) {
|
||||
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
||||
this.previousSize = settings.eraserSize;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isErasing ||
|
||||
this.previewClientPosition === null ||
|
||||
(!this.isPointerHoveringCanvas && !isSwipeActive)
|
||||
) {
|
||||
this.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||
if (this.previousLeft !== left) {
|
||||
this.element.style.left = left;
|
||||
this.previousLeft = left;
|
||||
}
|
||||
if (this.previousTop !== top) {
|
||||
this.element.style.top = top;
|
||||
this.previousTop = top;
|
||||
}
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
public isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private setVisible(isVisible: boolean): void {
|
||||
if (this.isVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isVisible = isVisible;
|
||||
this.element.classList.toggle('visible', isVisible);
|
||||
}
|
||||
}
|
||||
194
src/game-loop/export-4k-renderer.ts
Normal file
194
src/game-loop/export-4k-renderer.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
getAspectFitExport4KDimensions,
|
||||
getBrowserExportMemoryInfo,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
interface Export4KRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
statusElement: HTMLElement;
|
||||
seed: string;
|
||||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getVibeId: () => string;
|
||||
}
|
||||
|
||||
export class Export4KRenderer {
|
||||
private isExporting = false;
|
||||
|
||||
public constructor(private readonly options: Export4KRendererOptions) {}
|
||||
|
||||
public async export(): Promise<void> {
|
||||
if (this.isExporting) {
|
||||
this.statusElement.textContent = '4K upscale already rendering...';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExporting = true;
|
||||
this.statusElement.textContent = 'Rendering 4K upscale...';
|
||||
|
||||
try {
|
||||
const sourceSize = this.options.getSourceSize();
|
||||
const exportDimensions = getAspectFitExport4KDimensions(
|
||||
sourceSize.width,
|
||||
sourceSize.height
|
||||
);
|
||||
const estimate = estimateExport4KMemory(
|
||||
exportDimensions.width,
|
||||
exportDimensions.height
|
||||
);
|
||||
const preflightError = getExport4KPreflightError({
|
||||
limits: this.device.limits,
|
||||
memoryInfo: getBrowserExportMemoryInfo(),
|
||||
estimate,
|
||||
});
|
||||
if (preflightError) {
|
||||
this.statusElement.textContent = '4K upscale unavailable';
|
||||
throw preflightError;
|
||||
}
|
||||
|
||||
await this.renderExport(estimate);
|
||||
this.statusElement.textContent = '';
|
||||
} finally {
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderExport(
|
||||
estimate: ReturnType<typeof estimateExport4KMemory>
|
||||
): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
let texture: GPUTexture | null = null;
|
||||
let output: GPUBuffer | null = null;
|
||||
let isOutputMapped = false;
|
||||
|
||||
try {
|
||||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format,
|
||||
usage:
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.TEXTURE_BINDING,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: estimate.readbackBufferBytes,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
this.options.renderPipeline.executeToView(
|
||||
commandEncoder,
|
||||
this.options.getColorTextureView(),
|
||||
this.options.getSourceTextureView(),
|
||||
texture.createView()
|
||||
);
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{ texture },
|
||||
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||
{ width, height }
|
||||
);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
||||
await output.mapAsync(GPUMapMode.READ);
|
||||
isOutputMapped = true;
|
||||
const pixels = readExportPixels({
|
||||
mapped: new Uint8Array(output.getMappedRange()),
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra: format === 'bgra8unorm',
|
||||
});
|
||||
output.unmap();
|
||||
isOutputMapped = false;
|
||||
output.destroy();
|
||||
output = null;
|
||||
texture.destroy();
|
||||
texture = null;
|
||||
|
||||
await this.downloadPixels(pixels, width, height);
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = '4K upscale failed';
|
||||
throw error;
|
||||
} finally {
|
||||
if (output && isOutputMapped) {
|
||||
output.unmap();
|
||||
}
|
||||
output?.destroy();
|
||||
texture?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadPixels(
|
||||
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not create export canvas');
|
||||
}
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `fleeting-garden_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}-upscale.png`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private get device(): GPUDevice {
|
||||
return this.options.device;
|
||||
}
|
||||
|
||||
private get statusElement(): HTMLElement {
|
||||
return this.options.statusElement;
|
||||
}
|
||||
}
|
||||
|
||||
const readExportPixels = ({
|
||||
mapped,
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra,
|
||||
}: {
|
||||
mapped: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
isBgra: boolean;
|
||||
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||
unpaddedBytesPerRow * height
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * 4;
|
||||
const target = targetOffset + x * 4;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
pixels[target + 3] = mapped[source + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
};
|
||||
87
src/game-loop/export-4k.test.ts
Normal file
87
src/game-loop/export-4k.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
formatByteSize,
|
||||
getAspectFitExport4KDimensions,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
const generousLimits = {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
|
||||
describe('4K export preflight', () => {
|
||||
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
|
||||
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
|
||||
width: 2880,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
|
||||
width: 1620,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
|
||||
width: 2160,
|
||||
height: 2160,
|
||||
});
|
||||
});
|
||||
|
||||
it('estimates padded readback and temporary memory for the export', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
|
||||
expect(estimate.width).toBe(3840);
|
||||
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', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-texture-too-large');
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the readback buffer', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: estimate.readbackBufferBytes - 1,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
estimate,
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-readback-too-large');
|
||||
});
|
||||
|
||||
it('rejects browser-reported low-memory devices', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
memoryInfo: {
|
||||
deviceMemoryBytes: 2 * 1024 ** 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-low-device-memory');
|
||||
});
|
||||
|
||||
it('allows export when memory hints are unavailable', () => {
|
||||
expect(
|
||||
getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
222
src/game-loop/export-4k.ts
Normal file
222
src/game-loop/export-4k.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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 BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
||||
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
||||
const GIBIBYTE = 1024 ** 3;
|
||||
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 {
|
||||
width: number;
|
||||
height: number;
|
||||
bytesPerPixel: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
textureBytes: number;
|
||||
readbackBufferBytes: number;
|
||||
pixelBytes: number;
|
||||
canvasBytes: number;
|
||||
encoderSafetyBytes: number;
|
||||
estimatedJsHeapBytes: number;
|
||||
estimatedPeakBytes: number;
|
||||
}
|
||||
|
||||
export interface Export4KDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BrowserMemoryInfo {
|
||||
deviceMemoryBytes?: number;
|
||||
jsHeapSizeLimitBytes?: number;
|
||||
usedJsHeapSizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface Export4KPreflightOptions {
|
||||
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||
memoryInfo?: BrowserMemoryInfo;
|
||||
estimate?: Export4KMemoryEstimate;
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
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`;
|
||||
|
||||
export const getAspectFitExport4KDimensions = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maxWidth = EXPORT_4K_WIDTH,
|
||||
maxHeight = EXPORT_4K_HEIGHT
|
||||
): Export4KDimensions => {
|
||||
if (
|
||||
!Number.isFinite(sourceWidth) ||
|
||||
!Number.isFinite(sourceHeight) ||
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0
|
||||
) {
|
||||
return { width: maxWidth, height: maxHeight };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
|
||||
return {
|
||||
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
|
||||
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
|
||||
};
|
||||
};
|
||||
|
||||
export const estimateExport4KMemory = (
|
||||
width = EXPORT_4K_WIDTH,
|
||||
height = EXPORT_4K_HEIGHT
|
||||
): Export4KMemoryEstimate => {
|
||||
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
|
||||
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;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bytesPerPixel: BYTES_PER_PIXEL,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
textureBytes,
|
||||
readbackBufferBytes,
|
||||
pixelBytes,
|
||||
canvasBytes,
|
||||
encoderSafetyBytes,
|
||||
estimatedJsHeapBytes,
|
||||
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
|
||||
const navigatorWithMemory =
|
||||
typeof navigator === 'undefined'
|
||||
? undefined
|
||||
: (navigator as Navigator & { deviceMemory?: number });
|
||||
const performanceWithMemory =
|
||||
typeof performance === 'undefined'
|
||||
? undefined
|
||||
: (performance as Performance & {
|
||||
memory?: {
|
||||
jsHeapSizeLimit?: number;
|
||||
usedJSHeapSize?: number;
|
||||
};
|
||||
});
|
||||
|
||||
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
|
||||
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.jsHeapSizeLimit
|
||||
);
|
||||
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.usedJSHeapSize
|
||||
);
|
||||
|
||||
return {
|
||||
...(deviceMemoryGib === undefined
|
||||
? {}
|
||||
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
|
||||
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
|
||||
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExport4KPreflightError = ({
|
||||
limits,
|
||||
memoryInfo = {},
|
||||
estimate = estimateExport4KMemory(),
|
||||
}: Export4KPreflightOptions): RuntimeError | null => {
|
||||
if (
|
||||
estimate.width > limits.maxTextureDimension2D ||
|
||||
estimate.height > limits.maxTextureDimension2D
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-texture-too-large',
|
||||
'This GPU cannot create a 3840x2160 export texture.',
|
||||
{
|
||||
details: {
|
||||
exportWidth: estimate.width,
|
||||
exportHeight: estimate.height,
|
||||
maxTextureDimension2D: limits.maxTextureDimension2D,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
|
||||
return new RuntimeError(
|
||||
'export-4k-readback-too-large',
|
||||
'This GPU cannot allocate the 4K export readback buffer.',
|
||||
{
|
||||
details: {
|
||||
readbackBufferBytes: estimate.readbackBufferBytes,
|
||||
maxBufferSize: limits.maxBufferSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
|
||||
estimate.estimatedPeakBytes >
|
||||
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-device-memory',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedPeakBytes
|
||||
)} of temporary memory, which is not safe on this low-memory device.`,
|
||||
{
|
||||
details: {
|
||||
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
|
||||
estimatedPeakBytes: estimate.estimatedPeakBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
|
||||
memoryInfo.usedJsHeapSizeBytes !== undefined
|
||||
) {
|
||||
const availableJsHeapBytes =
|
||||
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||
if (
|
||||
availableJsHeapBytes <
|
||||
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-js-heap',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedJsHeapBytes
|
||||
)} of JavaScript heap, and this browser does not report enough free heap.`,
|
||||
{
|
||||
details: {
|
||||
availableJsHeapBytes,
|
||||
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
|
||||
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
|
||||
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
73
src/game-loop/frame-performance.ts
Normal file
73
src/game-loop/frame-performance.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
interface TelemetrySnapshot {
|
||||
frameCpuStartedAt: number;
|
||||
encodeCpuMs: number;
|
||||
activeAgentCount: number;
|
||||
targetAgentBudget: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
devicePixelRatio: number;
|
||||
renderSpeed: number;
|
||||
}
|
||||
|
||||
export class FramePerformance {
|
||||
public latestFps = 60;
|
||||
public smoothedFps = 60;
|
||||
public refreshTargetFps = 60;
|
||||
|
||||
private lastTelemetryAt = 0;
|
||||
|
||||
public markCpuStart(): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
||||
}
|
||||
|
||||
public measureSince(startedAt: number): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
||||
}
|
||||
|
||||
public update(deltaTime: number): void {
|
||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||
this.latestFps = fps;
|
||||
this.refreshTargetFps = Math.max(
|
||||
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
|
||||
fps
|
||||
);
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
}
|
||||
|
||||
public renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
canvas,
|
||||
devicePixelRatio,
|
||||
renderSpeed,
|
||||
}: TelemetrySnapshot): void {
|
||||
if (!appConfig.telemetry.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastTelemetryAt = now;
|
||||
console.debug('Fleeting Garden telemetry', {
|
||||
fps: Math.round(this.latestFps),
|
||||
smoothedFps: Math.round(this.smoothedFps),
|
||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
dpr: devicePixelRatio,
|
||||
renderSpeed,
|
||||
frameCpuMs: now - frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
50
src/game-loop/game-loop-ping-pong.test.ts
Normal file
50
src/game-loop/game-loop-ping-pong.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const simulationFrameSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const simulationTexturesSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getRenderStepSource = () => {
|
||||
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
|
||||
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the render-speed simulation loop');
|
||||
}
|
||||
|
||||
return simulationFrameSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop ping-pong texture flow', () => {
|
||||
it('copies only the trail map and swaps source/influence references after diffusion', () => {
|
||||
const renderStepSource = getRenderStepSource();
|
||||
|
||||
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\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\(\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
||||
);
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
|
||||
);
|
||||
});
|
||||
});
|
||||
192
src/game-loop/game-loop-resources.ts
Normal file
192
src/game-loop/game-loop-resources.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CommonState } from '../pipelines/common-state/common-state';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
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 { RenderInputs } from './game-loop-types';
|
||||
import { SimulationFrameRenderer } from './simulation-frame';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
interface FrameParameters extends RenderInputs {
|
||||
time: number;
|
||||
deltaTime: number;
|
||||
canvasSize: vec2;
|
||||
activeAgentCount: number;
|
||||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
cameraCenter: [number, number];
|
||||
cameraZoom: number;
|
||||
eraserPixelSize: number;
|
||||
}
|
||||
|
||||
export class GameLoopResources {
|
||||
public readonly textures: SimulationTextures;
|
||||
public readonly commonState: CommonState;
|
||||
public readonly copyPipeline: CopyPipeline;
|
||||
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||
public readonly agentPipeline: AgentPipeline;
|
||||
public readonly brushPipeline: BrushPipeline;
|
||||
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||
public readonly diffusionPipeline: DiffusionPipeline;
|
||||
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
public readonly renderPipeline: RenderPipeline;
|
||||
|
||||
private readonly frameRenderer: SimulationFrameRenderer;
|
||||
|
||||
public constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
|
||||
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
|
||||
this.commonState = new CommonState(this.device);
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
});
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
GLOBAL_AGENT_CAP
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
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.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||
copyPipeline: this.copyPipeline,
|
||||
agentPipeline: this.agentPipeline,
|
||||
brushPipeline: this.brushPipeline,
|
||||
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||
diffusionPipeline: this.diffusionPipeline,
|
||||
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
|
||||
renderPipeline: this.renderPipeline,
|
||||
});
|
||||
}
|
||||
|
||||
public resizeSimulationTo(nextSize: vec2): vec2 | null {
|
||||
return this.textures.resizeTo(nextSize);
|
||||
}
|
||||
|
||||
public setFrameParameters({
|
||||
time,
|
||||
deltaTime,
|
||||
canvasSize,
|
||||
activeAgentCount,
|
||||
introProgress,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
}: FrameParameters): void {
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time,
|
||||
deltaTime,
|
||||
});
|
||||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
deltaTime,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed:
|
||||
settings.moveSpeed *
|
||||
(introProgress >= 1
|
||||
? 1
|
||||
: appConfig.simulation.introMoveSpeedBaseMultiplier +
|
||||
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
|
||||
introProgress,
|
||||
});
|
||||
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,
|
||||
});
|
||||
this.setBrushEffectDiffusionParameters();
|
||||
}
|
||||
|
||||
public executeFrame(renderSpeed: number, isErasing: boolean): void {
|
||||
this.frameRenderer.execute(renderSpeed, isErasing);
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.frameRenderer.clearSwipes();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.copyPipeline.destroy();
|
||||
this.agentGenerationPipeline.destroy();
|
||||
this.agentPipeline.destroy();
|
||||
this.brushPipeline.destroy();
|
||||
this.eraserAgentPipeline.destroy();
|
||||
this.eraserTexturePipeline.destroy();
|
||||
this.diffusionPipeline.destroy();
|
||||
this.brushEffectDiffusionPipeline.destroy();
|
||||
this.renderPipeline.destroy();
|
||||
this.commonState.destroy();
|
||||
this.textures.destroy();
|
||||
}
|
||||
|
||||
private setBrushEffectDiffusionParameters(): void {
|
||||
const framesToOneE = Math.max(
|
||||
1,
|
||||
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
|
||||
);
|
||||
this.brushEffectDiffusionPipeline.setParameters({
|
||||
...settings,
|
||||
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/game-loop/game-loop-types.ts
Normal file
17
src/game-loop/game-loop-types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export interface GardenUi {
|
||||
prompt: HTMLElement;
|
||||
eraserPreview: HTMLElement;
|
||||
exportStatus: HTMLElement;
|
||||
}
|
||||
|
||||
export interface RenderInputs {
|
||||
channelColors: Array<[number, number, number]>;
|
||||
backgroundColor: [number, number, number];
|
||||
}
|
||||
|
||||
export interface StrokeSegment {
|
||||
from: vec2;
|
||||
to: vec2;
|
||||
}
|
||||
|
|
@ -1,258 +1,244 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CommonState } from '../pipelines/common-state/common-state';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||
import { sleep } from '../utils/sleep';
|
||||
import { GamePresentation } from './game-presentation';
|
||||
import { GameRules } from './game-rules';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { Export4KRenderer } from './export-4k-renderer';
|
||||
import { FramePerformance } from './frame-performance';
|
||||
import { GameLoopResources } from './game-loop-resources';
|
||||
import { GardenUi } from './game-loop-types';
|
||||
import { IntroPrompt } from './intro-prompt';
|
||||
import { GardenPointerInput } from './pointer-input';
|
||||
import { RenderInputCache } from './render-input-cache';
|
||||
|
||||
export default class GameLoop {
|
||||
private readonly trailMapA: ResizableTexture;
|
||||
private readonly trailMapB: ResizableTexture;
|
||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||
appConfig.simulation.maxMirrorSegmentCount;
|
||||
|
||||
private readonly commonState: CommonState;
|
||||
private readonly copyPipeline: CopyPipeline;
|
||||
private readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||
private readonly agentPipeline: AgentPipeline;
|
||||
private readonly renderPipeline: RenderPipeline;
|
||||
private readonly brushPipeline: BrushPipeline;
|
||||
private readonly diffusionPipeline: DiffusionPipeline;
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||
private readonly renderInputs = new RenderInputCache();
|
||||
private readonly introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
private readonly pointerInput: GardenPointerInput;
|
||||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly export4KRenderer: Export4KRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||
private readonly resizeListener = this.resize.bind(this);
|
||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||
|
||||
private hasFinished = false;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
private activePointerId: number | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
device: GPUDevice,
|
||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||
private readonly gameRules: GameRules
|
||||
ui: GardenUi
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
|
||||
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
|
||||
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
|
||||
this.resize();
|
||||
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
|
||||
this.commonState = new CommonState(this.device);
|
||||
this.commonState.setParameters({
|
||||
canvasSize: this.canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||
this.pointerInput = new GardenPointerInput({
|
||||
canvas,
|
||||
audio: this.audio,
|
||||
brushPipeline: this.resources.brushPipeline,
|
||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||
eraserPreview: this.eraserPreview,
|
||||
getCanvasSize: () => this.canvasSize,
|
||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||
onStartDrawing: () => {
|
||||
this.introPrompt.markStartedDrawing();
|
||||
this.introPrompt.complete();
|
||||
},
|
||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||
});
|
||||
this.export4KRenderer = new Export4KRenderer({
|
||||
device,
|
||||
renderPipeline: this.resources.renderPipeline,
|
||||
statusElement: ui.exportStatus,
|
||||
seed: this.seed,
|
||||
getSourceSize: () => ({
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
}),
|
||||
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||
getVibeId: () => activeVibe.id,
|
||||
});
|
||||
this.keydownListener = (event: KeyboardEvent) => {
|
||||
this.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.introPrompt.complete();
|
||||
};
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
settings.maxAgentCountUpperLimit
|
||||
);
|
||||
this.agentGenerationPipeline.spawnFirstGeneration();
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
window.addEventListener('resize', this.resize.bind(this));
|
||||
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
|
||||
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
|
||||
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
|
||||
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
window.addEventListener('keydown', this.keydownListener, { once: true });
|
||||
this.pointerInput.attach();
|
||||
}
|
||||
|
||||
private onPointerDown(event: PointerEvent) {
|
||||
if (this.activePointerId !== null) {
|
||||
return;
|
||||
}
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.brushPipeline.clearSwipes();
|
||||
this.addSwipeAt(event);
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.pointerInput.setEraseMode(isErasing);
|
||||
}
|
||||
|
||||
private onPointerMove(event: PointerEvent) {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event);
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.pointerInput.updateEraserPreview(event);
|
||||
}
|
||||
|
||||
private onPointerUp(event: PointerEvent) {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event);
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
this.activePointerId = null;
|
||||
public onVibeChanged(): void {
|
||||
this.agentPopulation.onVibeChanged();
|
||||
this.renderInputs.invalidate();
|
||||
}
|
||||
|
||||
private addSwipeAt(event: PointerEvent) {
|
||||
const position = vec2.fromValues(
|
||||
event.clientX * this.devicePixelRatio,
|
||||
this.canvas.height - event.clientY * this.devicePixelRatio
|
||||
);
|
||||
this.brushPipeline.addSwipe(position);
|
||||
public setAudioMuted(isMuted: boolean): void {
|
||||
this.audio.setMuted(isMuted);
|
||||
}
|
||||
|
||||
private get isSwipeActive(): boolean {
|
||||
return this.activePointerId !== null;
|
||||
public startAudio(userGesture = false): void {
|
||||
this.audio.start(activeVibe, { userGesture });
|
||||
}
|
||||
|
||||
public playVibeChangeAudio(userGesture = false): void {
|
||||
this.audio.changeVibe(activeVibe, { userGesture });
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
requestAnimationFrame(this.render.bind(this));
|
||||
requestAnimationFrame(this.updateCounts.bind(this));
|
||||
requestAnimationFrame(this.render);
|
||||
return this.finished.promise;
|
||||
}
|
||||
|
||||
private async updateCounts(): Promise<void> {
|
||||
if (this.hasFinished) {
|
||||
return;
|
||||
}
|
||||
const generationCounts = await this.agentGenerationPipeline.countAgents(
|
||||
settings.agentCount
|
||||
);
|
||||
this.gameRules.updateGenerationCounts(generationCounts);
|
||||
requestAnimationFrame(this.updateCounts.bind(this));
|
||||
}
|
||||
|
||||
public get aliveAgentCounts(): {
|
||||
currentGenerationCount: number;
|
||||
nextGenerationCount: number;
|
||||
} {
|
||||
return this.gameRules.generationCounts;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.agentGenerationPipeline.maxAgentCount;
|
||||
return this.agentPopulation.maxAgentCount;
|
||||
}
|
||||
|
||||
private resize() {
|
||||
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
|
||||
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
|
||||
public async export4K(): Promise<void> {
|
||||
return this.export4KRenderer.export();
|
||||
}
|
||||
|
||||
private async render(time: DOMHighResTimeStamp) {
|
||||
public async destroy(): Promise<void> {
|
||||
this.hasFinished = true;
|
||||
await this.finished.promise;
|
||||
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
window.removeEventListener('keydown', this.keydownListener);
|
||||
this.pointerInput.detach();
|
||||
this.introPrompt.destroy();
|
||||
this.resources.destroy();
|
||||
await this.audio.destroy();
|
||||
}
|
||||
|
||||
private readonly render = async (time: DOMHighResTimeStamp) => {
|
||||
if (this.hasFinished) {
|
||||
this.finished.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const accentColor = GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId - 1
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--accent-color',
|
||||
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
|
||||
);
|
||||
|
||||
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||
|
||||
time *= settings.renderSpeed;
|
||||
const timeInSeconds = time / 1000;
|
||||
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
|
||||
|
||||
[
|
||||
this.commonState,
|
||||
this.agentPipeline,
|
||||
this.brushPipeline,
|
||||
this.diffusionPipeline,
|
||||
this.renderPipeline,
|
||||
].forEach((pipeline) =>
|
||||
pipeline.setParameters({
|
||||
time,
|
||||
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
|
||||
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
|
||||
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
|
||||
infectionProbability: this.gameRules.getInfectionProbability(),
|
||||
deltaTime,
|
||||
canvasSize: this.canvasSize,
|
||||
brushColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
evenGenerationColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId % 2 == 0
|
||||
? this.gameRules.nextGenerationId
|
||||
: this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
oddGenerationColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId % 2 == 1
|
||||
? this.gameRules.nextGenerationId
|
||||
: this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
...settings,
|
||||
center: spawnAction.position,
|
||||
radius: spawnAction.radius,
|
||||
})
|
||||
this.framePerformance.update(deltaTime);
|
||||
this.agentPopulation.growBudget(
|
||||
deltaTime,
|
||||
this.framePerformance.smoothedFps,
|
||||
this.framePerformance.refreshTargetFps
|
||||
);
|
||||
this.introPrompt.update();
|
||||
this.resize();
|
||||
this.resizeSimulationToCanvas();
|
||||
|
||||
for (let i = 0; i < settings.renderSpeed; i++) {
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
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 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,
|
||||
});
|
||||
|
||||
this.copyPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapA.getTextureView(),
|
||||
this.trailMapB.getTextureView()
|
||||
);
|
||||
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
|
||||
this.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapA.getTextureView(),
|
||||
this.trailMapB.getTextureView()
|
||||
);
|
||||
this.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapB.getTextureView(),
|
||||
this.trailMapA.getTextureView()
|
||||
);
|
||||
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
|
||||
this.resources.setFrameParameters({
|
||||
time: scaledTime,
|
||||
deltaTime,
|
||||
canvasSize: this.canvasSize,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
introProgress,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
});
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
}
|
||||
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
this.resources.executeFrame(settings.renderSpeed, isErasing);
|
||||
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
||||
|
||||
if (!this.isSwipeActive) {
|
||||
this.brushPipeline.clearSwipes();
|
||||
}
|
||||
this.pointerInput.clearSwipesIfIdle();
|
||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||
|
||||
this.framePerformance.renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
targetAgentBudget: this.agentPopulation.targetAgentBudget,
|
||||
canvas: this.canvas,
|
||||
devicePixelRatio: this.devicePixelRatio,
|
||||
renderSpeed: settings.renderSpeed,
|
||||
});
|
||||
|
||||
if (settings.simulatedDelayMs > 0) {
|
||||
await sleep(settings.simulatedDelayMs);
|
||||
}
|
||||
|
||||
// avoid resizing during rendering
|
||||
this.trailMapA.resize(this.canvasSize);
|
||||
this.trailMapB.resize(this.canvasSize);
|
||||
requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
requestAnimationFrame(this.render.bind(this));
|
||||
private resize(): void {
|
||||
const width = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
||||
);
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
||||
);
|
||||
|
||||
if (this.canvas.width === width && this.canvas.height === height) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
this.hasFinished = true;
|
||||
await this.finished.promise;
|
||||
private resizeSimulationToCanvas(): void {
|
||||
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||
if (!scale) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.copyPipeline?.destroy();
|
||||
this.agentGenerationPipeline?.destroy();
|
||||
this.agentPipeline?.destroy();
|
||||
this.brushPipeline?.destroy();
|
||||
this.diffusionPipeline?.destroy();
|
||||
this.renderPipeline?.destroy();
|
||||
this.commonState?.destroy();
|
||||
this.trailMapA?.destroy();
|
||||
this.trailMapB?.destroy();
|
||||
this.agentPopulation.resizeAgents(scale);
|
||||
this.pointerInput.scaleLastPointerPosition(scale);
|
||||
}
|
||||
|
||||
private get canvasSize(): vec2 {
|
||||
|
|
@ -260,6 +246,14 @@ export default class GameLoop {
|
|||
}
|
||||
|
||||
private get devicePixelRatio(): number {
|
||||
return window.devicePixelRatio || 1;
|
||||
const ratio = window.devicePixelRatio;
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||
}
|
||||
|
||||
private get mirrorSegmentCount(): number {
|
||||
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||
? settings.mirrorSegmentCount
|
||||
: 1;
|
||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
src/game-loop/intro-prompt.ts
Normal file
80
src/game-loop/intro-prompt.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
|
||||
|
||||
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) {}
|
||||
|
||||
public get progress(): number {
|
||||
return this.introComplete
|
||||
? 1
|
||||
: Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS);
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
const now = performance.now();
|
||||
|
||||
if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) {
|
||||
this.complete(now);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.introComplete ||
|
||||
this.hasStartedDrawing ||
|
||||
this.introCompletedAt === null ||
|
||||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showDrawHint();
|
||||
}
|
||||
|
||||
public complete(completedAt = performance.now()): void {
|
||||
if (this.introComplete) {
|
||||
return;
|
||||
}
|
||||
this.introComplete = true;
|
||||
this.introCompletedAt = completedAt;
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
public markStartedDrawing(): void {
|
||||
this.hasStartedDrawing = true;
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
private showDrawHint(): void {
|
||||
if (this.isDrawHintVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDrawHintVisible = true;
|
||||
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
|
||||
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" />
|
||||
<path class="draw-hint-stroke" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
private hideDrawHint(): void {
|
||||
this.isDrawHintVisible = false;
|
||||
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
|
||||
this.prompt.replaceChildren();
|
||||
}
|
||||
}
|
||||
354
src/game-loop/intro-title-agents.ts
Normal file
354
src/game-loop/intro-title-agents.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { appConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||
|
||||
interface IntroTitlePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
tangent: number | null;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
interface IntroTitleAgentOptions {
|
||||
count: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||
|
||||
export const createIntroTitleAgents = ({
|
||||
count,
|
||||
width,
|
||||
height,
|
||||
}: IntroTitleAgentOptions): Float32Array => {
|
||||
if (count <= 0) {
|
||||
return new Float32Array();
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(1, width);
|
||||
const safeHeight = Math.max(1, height);
|
||||
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
||||
if (points.length === 0) {
|
||||
return new Float32Array();
|
||||
}
|
||||
|
||||
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
||||
const minSide = Math.min(safeWidth, safeHeight);
|
||||
const targetJitter = Math.max(
|
||||
appConfig.simulation.intro.minTargetJitterPx,
|
||||
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
||||
);
|
||||
const entryJitter = Math.max(
|
||||
appConfig.simulation.intro.minEntryJitterPx,
|
||||
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
||||
);
|
||||
const titleRadius = points.reduce(
|
||||
(radius, point) =>
|
||||
Math.max(
|
||||
radius,
|
||||
Math.hypot(
|
||||
point.x - safeWidth / 2,
|
||||
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
const introCircleRadius = Math.min(
|
||||
Math.max(
|
||||
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
||||
minSide * appConfig.simulation.intro.circleMinSideRatio
|
||||
),
|
||||
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
||||
);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const point = points[Math.floor(Math.random() * points.length)];
|
||||
const targetX = Math.max(
|
||||
0,
|
||||
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
|
||||
);
|
||||
const targetY = Math.max(
|
||||
0,
|
||||
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
|
||||
);
|
||||
const [startX, startY] = getIntroRadialStart(
|
||||
targetX,
|
||||
targetY,
|
||||
safeWidth,
|
||||
safeHeight,
|
||||
introCircleRadius,
|
||||
entryJitter
|
||||
);
|
||||
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||
let targetAngle = point.tangent ?? approachAngle;
|
||||
if (Math.cos(targetAngle - approachAngle) < 0) {
|
||||
targetAngle += Math.PI;
|
||||
}
|
||||
|
||||
const distanceFraction =
|
||||
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
data[base] = startX;
|
||||
data[base + 1] = startY;
|
||||
data[base + 2] =
|
||||
approachAngle +
|
||||
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||
data[base + 3] = point.colorIndex;
|
||||
data[base + 4] = targetX;
|
||||
data[base + 5] = targetY;
|
||||
data[base + 6] = targetAngle;
|
||||
data[base + 7] = Math.min(
|
||||
appConfig.simulation.intro.targetDelayMax,
|
||||
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getIntroRadialStart = (
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
jitter: number
|
||||
): [number, number] => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||
const offsetX = targetX - centerX;
|
||||
const offsetY = targetY - centerY;
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const angle =
|
||||
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
|
||||
const directionX = Math.cos(angle);
|
||||
const directionY = Math.sin(angle);
|
||||
const tangentX = -directionY;
|
||||
const tangentY = directionX;
|
||||
const tangentJitter = (Math.random() - 0.5) * jitter;
|
||||
const radialJitter =
|
||||
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||
const startX =
|
||||
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||
const startY =
|
||||
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
|
||||
|
||||
return [
|
||||
Math.max(0, Math.min(width - 1, startX)),
|
||||
Math.max(0, Math.min(height - 1, startY)),
|
||||
];
|
||||
};
|
||||
|
||||
const createIntroTitlePoints = (
|
||||
width: number,
|
||||
height: number
|
||||
): Array<IntroTitlePoint> => {
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = width;
|
||||
maskCanvas.height = height;
|
||||
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fontSize = getIntroTitleFontSize(context, width, height);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillStyle = '#fff';
|
||||
context.strokeStyle = '#fff';
|
||||
context.lineJoin = 'round';
|
||||
context.lineWidth = Math.max(
|
||||
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
||||
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
||||
);
|
||||
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'stroke'
|
||||
);
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'fill'
|
||||
);
|
||||
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
||||
);
|
||||
const points: Array<IntroTitlePoint> = [];
|
||||
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||
context,
|
||||
width,
|
||||
letterSpacing
|
||||
);
|
||||
|
||||
for (let y = 0; y < height; y += step) {
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const alpha = getMaskAlpha(data, width, height, x, y);
|
||||
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
points.push({
|
||||
x,
|
||||
y,
|
||||
tangent: estimateMaskTangent(data, width, height, x, y),
|
||||
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
const getIntroTitleColorBoundaries = (
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
letterSpacing: number
|
||||
): [number, number] => {
|
||||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = width / 2 - totalWidth / 2;
|
||||
const [firstCutLetter, secondCutLetter] =
|
||||
appConfig.simulation.intro.titleColorCutLetters;
|
||||
const letterBoxes = letters.map((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
const box = {
|
||||
left: x,
|
||||
right: x + letterWidth,
|
||||
};
|
||||
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||
return box;
|
||||
});
|
||||
|
||||
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
|
||||
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
|
||||
|
||||
return [
|
||||
getBoundaryBetweenLetters(firstCutLetter - 1),
|
||||
getBoundaryBetweenLetters(secondCutLetter - 1),
|
||||
];
|
||||
};
|
||||
|
||||
const drawIntroTitleText = (
|
||||
context: CanvasRenderingContext2D,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
letterSpacing: number,
|
||||
mode: 'fill' | 'stroke'
|
||||
): void => {
|
||||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = centerX - totalWidth / 2;
|
||||
|
||||
letters.forEach((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
const drawX = x + letterWidth / 2;
|
||||
if (mode === 'fill') {
|
||||
context.fillText(letter, drawX, centerY);
|
||||
} else {
|
||||
context.strokeText(letter, drawX, centerY);
|
||||
}
|
||||
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||
});
|
||||
};
|
||||
|
||||
const measureIntroTitleText = (
|
||||
context: CanvasRenderingContext2D,
|
||||
letters: Array<string>,
|
||||
letterSpacing: number
|
||||
): number => {
|
||||
const textWidth = letters.reduce(
|
||||
(width, letter) => width + context.measureText(letter).width,
|
||||
0
|
||||
);
|
||||
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
|
||||
};
|
||||
|
||||
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
|
||||
if (x < boundaries[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x < boundaries[1]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
};
|
||||
|
||||
const getIntroTitleFontSize = (
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): number => {
|
||||
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
||||
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
||||
let fontSize = Math.floor(
|
||||
Math.min(
|
||||
height * appConfig.simulation.intro.initialFontHeightRatio,
|
||||
width * appConfig.simulation.intro.initialFontWidthRatio
|
||||
)
|
||||
);
|
||||
|
||||
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||
const metrics = context.measureText(INTRO_TITLE);
|
||||
const measuredHeight =
|
||||
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||
|
||||
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
||||
}
|
||||
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
const estimateMaskTangent = (
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number
|
||||
): number | null => {
|
||||
const gradientX =
|
||||
getMaskAlpha(data, width, height, x + 1, y) -
|
||||
getMaskAlpha(data, width, height, x - 1, y);
|
||||
const gradientY =
|
||||
getMaskAlpha(data, width, height, x, y + 1) -
|
||||
getMaskAlpha(data, width, height, x, y - 1);
|
||||
|
||||
if (
|
||||
Math.abs(gradientX) + Math.abs(gradientY) <
|
||||
appConfig.simulation.intro.maskGradientThreshold
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.atan2(gradientX, -gradientY);
|
||||
};
|
||||
|
||||
const getMaskAlpha = (
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number
|
||||
): number => {
|
||||
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
|
||||
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
||||
return data[(clampedY * width + clampedX) * 4 + 3];
|
||||
};
|
||||
248
src/game-loop/pointer-input.ts
Normal file
248
src/game-loop/pointer-input.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { BrushPipeline } 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';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { StrokeSegment } from './game-loop-types';
|
||||
|
||||
interface GardenPointerInputOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
audio: GardenAudio;
|
||||
brushPipeline: BrushPipeline;
|
||||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
eraserPreview: EraserPreview;
|
||||
getCanvasSize: () => vec2;
|
||||
getDevicePixelRatio: () => number;
|
||||
getMirrorSegmentCount: () => number;
|
||||
onStartDrawing: () => void;
|
||||
onEraseGestureEnded: () => void;
|
||||
spawnStrokeAgents: (from: vec2, to: vec2) => void;
|
||||
}
|
||||
|
||||
export class GardenPointerInput {
|
||||
private activePointerId: number | null = null;
|
||||
private lastPointerPosition: vec2 | null = null;
|
||||
private lastPointerEventTimeMs: number | null = null;
|
||||
private lastPointerPressure = 0.5;
|
||||
private isErasing = false;
|
||||
|
||||
public constructor(private readonly options: GardenPointerInputOptions) {}
|
||||
|
||||
public attach(): void {
|
||||
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public detach(): void {
|
||||
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.options.eraserPreview.update(event, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public clearSwipesIfIdle(): void {
|
||||
if (this.isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
}
|
||||
|
||||
public scaleLastPointerPosition(scale: vec2): void {
|
||||
if (this.lastPointerPosition !== null) {
|
||||
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
||||
}
|
||||
}
|
||||
|
||||
public get isSwipeActive(): boolean {
|
||||
return this.activePointerId !== null;
|
||||
}
|
||||
|
||||
public get isEraseMode(): boolean {
|
||||
return this.isErasing;
|
||||
}
|
||||
|
||||
private get canvas(): HTMLCanvasElement {
|
||||
return this.options.canvas;
|
||||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
if (this.activePointerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.options.audio.beginGesture();
|
||||
this.options.onStartDrawing();
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.lastPointerPressure = this.getPointerPressure(event);
|
||||
this.addSwipeAt(event);
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.updateEraserPreview(event);
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event);
|
||||
};
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event, { emitAudio: false });
|
||||
this.options.audio.endGesture();
|
||||
if (this.isErasing) {
|
||||
this.options.onEraseGestureEnded();
|
||||
}
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
this.activePointerId = null;
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||
);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(false);
|
||||
this.updateEraserPreview();
|
||||
};
|
||||
|
||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||
const position = vec2.fromValues(
|
||||
(event.clientX - rect.left) * devicePixelRatio,
|
||||
(event.clientY - rect.top) * devicePixelRatio
|
||||
);
|
||||
const previousPosition = this.lastPointerPosition ?? position;
|
||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||
const elapsedSeconds = Math.max(
|
||||
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||
(event.timeStamp - previousTimeMs) / 1000
|
||||
);
|
||||
const distancePixels = vec2.distance(previousPosition, position);
|
||||
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
|
||||
const pressure = this.getPointerPressure(event);
|
||||
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
|
||||
|
||||
const segments = this.isErasing
|
||||
? [{ from: previousPosition, to: position }]
|
||||
: this.getMirroredStrokeSegments(previousPosition, position);
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (this.isErasing) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.spawnStrokeAgents(segment.from, segment.to);
|
||||
});
|
||||
}
|
||||
if (options.emitAudio !== false) {
|
||||
this.options.audio.stroke({
|
||||
vibe: activeVibe,
|
||||
from: previousPosition,
|
||||
to: position,
|
||||
canvasSize: this.options.getCanvasSize(),
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
isErasing: this.isErasing,
|
||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||
velocityPixelsPerSecond,
|
||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||
pointerType: event.pointerType,
|
||||
});
|
||||
}
|
||||
this.lastPointerPosition = position;
|
||||
this.lastPointerEventTimeMs = event.timeStamp;
|
||||
}
|
||||
|
||||
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||
const segmentCount = this.options.getMirrorSegmentCount();
|
||||
if (segmentCount <= 1) {
|
||||
return [{ from, to }];
|
||||
}
|
||||
|
||||
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
|
||||
const angleStep = (Math.PI * 2) / segmentCount;
|
||||
const segments: Array<StrokeSegment> = [];
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const angle = angleStep * i;
|
||||
segments.push({
|
||||
from: rotatePointAround(from, center, angle),
|
||||
to: rotatePointAround(to, center, angle),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private getPointerPressure(event: PointerEvent): number {
|
||||
if (Number.isFinite(event.pressure) && event.pressure > 0) {
|
||||
return Math.min(1, Math.max(0, event.pressure));
|
||||
}
|
||||
|
||||
return event.buttons > 0 || event.type === 'pointerdown'
|
||||
? gardenAudioConfig.input.pressureFallback
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||
if (angle === 0) {
|
||||
return point;
|
||||
}
|
||||
|
||||
const offsetX = point[0] - center[0];
|
||||
const offsetY = point[1] - center[1];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return vec2.fromValues(
|
||||
center[0] + offsetX * cos - offsetY * sin,
|
||||
center[1] + offsetX * sin + offsetY * cos
|
||||
);
|
||||
};
|
||||
40
src/game-loop/render-input-cache.ts
Normal file
40
src/game-loop/render-input-cache.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { activeVibe } from '../settings';
|
||||
import { hexToRgb } from '../vibes';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
|
||||
export class RenderInputCache {
|
||||
private cachedVibeId: string | null = null;
|
||||
private cachedRenderInputs?: RenderInputs;
|
||||
private previousAccentColor = '';
|
||||
|
||||
public invalidate(): void {
|
||||
this.cachedVibeId = null;
|
||||
this.cachedRenderInputs = undefined;
|
||||
}
|
||||
|
||||
public get(): RenderInputs {
|
||||
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
this.cachedVibeId = activeVibe.id;
|
||||
this.cachedRenderInputs = {
|
||||
channelColors: activeVibe.colors.map(hexToRgb),
|
||||
backgroundColor: hexToRgb(activeVibe.backgroundColor),
|
||||
};
|
||||
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
public updateAccentColor(color: [number, number, number]): void {
|
||||
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
|
||||
color[1] * 255
|
||||
)},${Math.round(color[2] * 255)})`;
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousAccentColor = accentColor;
|
||||
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
}
|
||||
99
src/game-loop/simulation-frame.ts
Normal file
99
src/game-loop/simulation-frame.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
export interface SimulationFramePipelines {
|
||||
copyPipeline: CopyPipeline;
|
||||
agentPipeline: AgentPipeline;
|
||||
brushPipeline: BrushPipeline;
|
||||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
diffusionPipeline: DiffusionPipeline;
|
||||
brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
renderPipeline: RenderPipeline;
|
||||
}
|
||||
|
||||
export class SimulationFrameRenderer {
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
private readonly textures: SimulationTextures,
|
||||
private readonly pipelines: SimulationFramePipelines
|
||||
) {}
|
||||
|
||||
public execute(renderSpeed: number, isErasing: boolean): void {
|
||||
for (let i = 0; i < renderSpeed; i++) {
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
|
||||
this.pipelines.copyPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
if (isErasing) {
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserAgentPipeline.execute(commandEncoder);
|
||||
} else {
|
||||
this.pipelines.brushPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.brushPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
}
|
||||
this.pipelines.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.trailMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.renderPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.brushEffectDiffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView(),
|
||||
this.textures.influenceMapB.getTextureView()
|
||||
);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
this.textures.swapSourceMaps();
|
||||
this.textures.swapInfluenceMaps();
|
||||
}
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.pipelines.brushPipeline.clearSwipes();
|
||||
this.pipelines.eraserAgentPipeline.clearSwipes();
|
||||
this.pipelines.eraserTexturePipeline.clearSwipes();
|
||||
}
|
||||
}
|
||||
58
src/game-loop/simulation-textures.ts
Normal file
58
src/game-loop/simulation-textures.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||
|
||||
export class SimulationTextures {
|
||||
public readonly trailMapA: ResizableTexture;
|
||||
public readonly trailMapB: ResizableTexture;
|
||||
public sourceMapA: ResizableTexture;
|
||||
public sourceMapB: ResizableTexture;
|
||||
public influenceMapA: ResizableTexture;
|
||||
public influenceMapB: ResizableTexture;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public resizeTo(nextSize: vec2): vec2 | null {
|
||||
const previousSize = this.trailMapA.getSize();
|
||||
if (vec2.equals(previousSize, nextSize)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = vec2.div(vec2.create(), nextSize, previousSize);
|
||||
this.trailMapA.resize(nextSize);
|
||||
this.trailMapB.resize(nextSize);
|
||||
this.sourceMapA.resize(nextSize);
|
||||
this.sourceMapB.resize(nextSize);
|
||||
this.influenceMapA.resize(nextSize);
|
||||
this.influenceMapB.resize(nextSize);
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
public swapSourceMaps(): void {
|
||||
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||
}
|
||||
|
||||
public swapInfluenceMaps(): void {
|
||||
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.trailMapA.destroy();
|
||||
this.trailMapB.destroy();
|
||||
this.sourceMapA.destroy();
|
||||
this.sourceMapB.destroy();
|
||||
this.influenceMapA.destroy();
|
||||
this.influenceMapB.destroy();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue