diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index 6df1e95..85b2f90 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -1,4 +1,5 @@ export interface GameLoopSettings { + maxAgentCountUpperLimit: number; agentCount: number; renderSpeed: number; simulatedDelayMs: number; diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 42401e8..79514f3 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -59,7 +59,7 @@ export default class GameLoop { this.agentGenerationPipeline = new AgentGenerationPipeline( this.device, this.commonState, - settings.agentCount + settings.maxAgentCountUpperLimit ); this.agentGenerationPipeline.spawnFirstGeneration(); @@ -117,7 +117,9 @@ export default class GameLoop { if (this.hasFinished) { return; } - const generationCounts = await this.agentGenerationPipeline.countAgents(); + const generationCounts = await this.agentGenerationPipeline.countAgents( + settings.agentCount + ); this.gameRules.updateGenerationCounts(generationCounts); requestAnimationFrame(this.updateCounts.bind(this)); } @@ -168,7 +170,7 @@ export default class GameLoop { ); document.documentElement.style.setProperty( '--accent-color', - `rgb(${accentColor.map((v) => v * 255).join(',')})` + `rgb(${accentColor.map((v: number) => v * 255).join(',')})` ); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); diff --git a/src/index.ts b/src/index.ts index 3a24d06..6c9b18b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,14 @@ import '../assets/icons/info.svg'; import GameLoop from './game-loop/game-loop'; import { GameRules } from './game-loop/game-rules'; import './index.scss'; +import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { FullScreenHandler } from './page/full-screen-handler'; -import { InfoPageHandler } from './page/info-page-handler'; import { MenuHider } from './page/menu-hider'; +import { setUpSettingsPage } from './page/set-up-settings-page'; import { applyArrayPlugins } from './utils/array'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { ErrorHandler, Severity } from './utils/error-handler'; +import { formatNumber } from './utils/format-number'; import { initializeGpu } from './utils/graphics/initialize-gpu'; declare global { @@ -30,13 +32,15 @@ declare global { const getElements = () => ({ aside: document.querySelector('aside') as HTMLDivElement, infoButton: document.querySelector('button.info') as HTMLButtonElement, - infoElement: document.querySelector('.pages') as HTMLDivElement, + infoElement: document.querySelector('.info-page') as HTMLDivElement, + settingsPage: document.querySelector('.settings-page') as HTMLDivElement, minimizeFullScreenButton: document.querySelector( 'button.minimize-full-screen' ) as HTMLButtonElement, maximizeFullScreenButton: document.querySelector( 'button.maximize-full-screen' ) as HTMLButtonElement, + settingsButton: document.querySelector('button.settings') as HTMLButtonElement, restartButton: document.querySelector('button.restart') as HTMLButtonElement, canvas: document.querySelector('canvas') as HTMLCanvasElement, canvasContainer: document.querySelector('main.canvas-container') as HTMLCanvasElement, @@ -61,7 +65,17 @@ const main = async () => { try { applyArrayPlugins(); - new InfoPageHandler(elements.infoButton, elements.infoElement); + const infoPageHandler = new CollapsiblePanelAnimator( + elements.infoButton, + elements.infoElement + ); + const settingsPageHandler = new CollapsiblePanelAnimator( + elements.settingsButton, + elements.settingsPage + ); + settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler); + infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler); + new MenuHider(elements.aside, FullScreenHandler.isInFullScreenMode); new FullScreenHandler( elements.minimizeFullScreenButton, @@ -75,22 +89,30 @@ const main = async () => { const deltaTimeCalculator = new DeltaTimeCalculator(); const gameRules = new GameRules(performance.now() / 1000); + let isSettingsPageSetUp = false; - console.log(gameRules.nextGenerationId); const updateCounters = () => { elements.counters.innerHTML = `FPS: ${deltaTimeCalculator.fps.toFixed(2)} -current gen: ${game?.aliveAgentCounts.currentGenerationCount ?? 0} -next gen: ${game?.aliveAgentCounts.nextGenerationCount ?? 0}`; +current gen: ${formatNumber(game?.aliveAgentCounts.currentGenerationCount ?? 0)} +next gen: ${formatNumber(game?.aliveAgentCounts.nextGenerationCount ?? 0)}`; window.requestAnimationFrame(updateCounters); }; updateCounters(); while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules); + if (!isSettingsPageSetUp) { + isSettingsPageSetUp = true; + setUpSettingsPage(elements.settingsPage, game.maxAgentCount, () => + game?.destroy() + ); + } + await game.start(); } } catch (e) { - ErrorHandler.addError(Severity.ERROR, e.message); + ErrorHandler.addError(Severity.ERROR, e.stack); + console.error(e); } }; diff --git a/src/pipelines/agents/agent-generation/agent-counting.wgsl b/src/pipelines/agents/agent-generation/agent-counting.wgsl index d5dc43f..ff81335 100644 --- a/src/pipelines/agents/agent-generation/agent-counting.wgsl +++ b/src/pipelines/agents/agent-generation/agent-counting.wgsl @@ -1,15 +1,25 @@ +struct Settings { + agentCount: u32 // might be smaller than the length of the agents array +}; + +@group(1) @binding(0) var settings: Settings; + struct Counters { - evenGenerationAlive: atomic, - oddGenerationAlive: atomic, + evenGenerationAlive: atomic, + oddGenerationAlive: atomic, }; @group(1) @binding(2) var counters: Counters; -@compute @workgroup_size(64) -fn main(@builtin(global_invocation_id) global_id: vec3) { - let id = global_id.x; - if id >= arrayLength(&agents) { +@compute @workgroup_size(64) +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64); + + if id >= settings.agentCount { return; } @@ -18,4 +28,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { } else { atomicAdd(&counters.oddGenerationAlive, 1); } + + // atomicStore(&counters.evenGenerationAlive, settings.agentCount); + // atomicStore(&counters.oddGenerationAlive, workgroup_count.y); } diff --git a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl index 92b1564..57e3d9b 100644 --- a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl +++ b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl @@ -1,6 +1,9 @@ @compute @workgroup_size(64) -fn main(@builtin(global_invocation_id) global_id: vec3) { - let id = global_id.x; +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64); if id >= arrayLength(&agents) { return; diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index ba30736..0e38c4e 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -1,3 +1,4 @@ +import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts'; import random from '../../../utils/graphics/random.wgsl'; import { smartCompile } from '../../../utils/graphics/smart-compile'; import { CommonState } from '../../common-state/common-state'; @@ -9,7 +10,7 @@ import { GenerationCounts } from './generation-counts'; export class AgentGenerationPipeline { private static readonly WORKGROUP_SIZE = 64; - private static readonly UNIFORM_COUNT = 4; + private static readonly UNIFORM_COUNT = 1; private static readonly COUNTER_COUNT = 3; private readonly bindGroupLayout: GPUBindGroupLayout; @@ -60,17 +61,17 @@ export class AgentGenerationPipeline { }); this.countersBuffer = this.device.createBuffer({ - size: AgentGenerationPipeline.COUNTER_COUNT * Int32Array.BYTES_PER_ELEMENT, + size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, }); this.countersStagingBuffer = this.device.createBuffer({ - size: AgentGenerationPipeline.COUNTER_COUNT * Int32Array.BYTES_PER_ELEMENT, + size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, }); this.uniforms = this.device.createBuffer({ - size: AgentGenerationPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + size: AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); @@ -134,7 +135,8 @@ export class AgentGenerationPipeline { public get maxAgentCount(): number { return Math.min( this.maxAgentCountUpperLimit, - Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) + Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), + this.device.limits.maxComputeWorkgroupsPerDimension ** 3 ); } @@ -146,15 +148,20 @@ export class AgentGenerationPipeline { passEncoder.setPipeline(this.firstGenerationPipeline); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.dispatchWorkgroups( - Math.ceil(this.maxAgentCount / AgentGenerationPipeline.WORKGROUP_SIZE) + ...getWorkgroupCounts( + this.device, + this.maxAgentCount, + AgentGenerationPipeline.WORKGROUP_SIZE + ) ); passEncoder.end(); this.device.queue.submit([commandEncoder.finish()]); } - public async countAgents(): Promise { - this.device.queue.writeBuffer(this.countersBuffer, 0, new Int32Array([0, 0])); + public async countAgents(agentCount: number): Promise { + this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0])); + this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount])); const commandEncoder = this.device.createCommandEncoder(); @@ -163,7 +170,11 @@ export class AgentGenerationPipeline { this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.dispatchWorkgroups( - Math.ceil(this.maxAgentCount / AgentGenerationPipeline.WORKGROUP_SIZE) + ...getWorkgroupCounts( + this.device, + agentCount, + AgentGenerationPipeline.WORKGROUP_SIZE + ) ); passEncoder.end(); @@ -172,14 +183,14 @@ export class AgentGenerationPipeline { 0, this.countersStagingBuffer, 0, - AgentGenerationPipeline.COUNTER_COUNT * Int32Array.BYTES_PER_ELEMENT + AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT ); this.device.queue.submit([commandEncoder.finish()]); await this.countersStagingBuffer.mapAsync(GPUMapMode.READ); - const data = new Int32Array(this.countersStagingBuffer.getMappedRange().slice(0)); + const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0)); this.countersStagingBuffer.unmap(); return { evenGenerationCount: data[0], diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index b74f720..3123b59 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -1,7 +1,7 @@ +import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts'; import random from '../../utils/graphics/random.wgsl'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; -import { AGENT_SIZE_IN_BYTES } from './agent-generation/agent'; import agentSchme from './agent-generation/agent-schema.wgsl'; import { AgentSettings } from './agent-settings'; import shader from './agent.wgsl'; @@ -10,7 +10,7 @@ import { vec2 } from 'gl-matrix'; export class AgentPipeline { private static readonly WORKGROUP_SIZE = 64; - private static readonly UNIFORM_COUNT = 16; + private static readonly UNIFORM_COUNT = 17; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; @@ -19,6 +19,8 @@ export class AgentPipeline { private previousTrailMapIn?: GPUTextureView; private previousTrailMapOut?: GPUTextureView; + private agentCount = 0; + public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState, @@ -57,13 +59,16 @@ export class AgentPipeline { turnWhenLost, individualTrailWeight, deinfectionProbability, + agentCount, }: AgentSettings & { evenGenerationAggression: number; oddGenerationAggression: number; nextGenerationId: number; center: vec2; radius: number; + agentCount: number; }) { + this.agentCount = agentCount; this.device.queue.writeBuffer( this.uniforms, 0, @@ -82,6 +87,7 @@ export class AgentPipeline { turnWhenLost, individualTrailWeight, deinfectionProbability, + agentCount, ]) ); } @@ -98,9 +104,7 @@ export class AgentPipeline { this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.dispatchWorkgroups( - Math.ceil( - this.agentsBuffer.size / AGENT_SIZE_IN_BYTES / AgentPipeline.WORKGROUP_SIZE - ) + ...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE) ); passEncoder.end(); } diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 21d7c9e..140b938 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -17,7 +17,9 @@ struct Settings { turnWhenGoingInTheRightDirection: f32, turnWhenLost: f32, individualTrailWeight: f32, - deinfectionProbability: f32 + deinfectionProbability: f32, + + agentCount: f32 // might be smaller than the length of the agents array }; @@ -31,10 +33,13 @@ struct Settings { @group(1) @binding(3) var trailMapOut: texture_storage_2d; @compute @workgroup_size(64) -fn main(@builtin(global_invocation_id) global_id: vec3) { - let id = global_id.x; +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64); - if (id >= arrayLength(&agents)) { + if id >= u32(settings.agentCount) { return; } diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts new file mode 100644 index 0000000..8375628 --- /dev/null +++ b/src/utils/format-number.ts @@ -0,0 +1,11 @@ +export const formatNumber = (value: number, unit = ''): string => { + if (value >= 1e6) { + return `${(value / 1e6).toFixed(1)} million ${unit}`; + } + + if (value >= 1e3) { + return `${(value / 1e3).toFixed(1)} thousand ${unit}`; + } + + return `${value === Math.floor(value) ? value : value.toFixed(2)}${unit}`; +}; diff --git a/src/utils/graphics/get-workgroup-counts.ts b/src/utils/graphics/get-workgroup-counts.ts new file mode 100644 index 0000000..fe016e7 --- /dev/null +++ b/src/utils/graphics/get-workgroup-counts.ts @@ -0,0 +1,28 @@ +export const getWorkgroupCounts = ( + device: GPUDevice, + invocationCount: number, + workgroupSize: number +): [number, number, number] => { + const workgroupCount = Math.ceil(invocationCount / workgroupSize); + + const workgroupCountX = Math.min( + device.limits.maxComputeWorkgroupsPerDimension, + workgroupCount + ); + + const workgroupCountY = Math.min( + device.limits.maxComputeWorkgroupsPerDimension, + Math.ceil(workgroupCount / workgroupCountX) + ); + + const workgroupCountZ = Math.min( + device.limits.maxComputeWorkgroupsPerDimension, + Math.ceil(workgroupCount / workgroupCountX / workgroupCountY) + ); + + if (workgroupCountX * workgroupCountY * workgroupCountZ < workgroupCount) { + throw new Error('Cannot have this many invocations'); + } + + return [workgroupCountX, workgroupCountY, workgroupCountZ]; +}; diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 47e9e4c..17e4f15 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -22,10 +22,7 @@ export const initializeGpu = async (): Promise => { requiredLimits: { maxBufferSize: adapter.limits.maxBufferSize, maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, - maxComputeInvocationsPerWorkgroup: adapter.limits.maxComputeInvocationsPerWorkgroup, - maxComputeWorkgroupSizeX: adapter.limits.maxComputeWorkgroupSizeX, - maxComputeWorkgroupSizeY: adapter.limits.maxComputeWorkgroupSizeY, - maxComputeWorkgroupSizeZ: adapter.limits.maxComputeWorkgroupSizeZ, + maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension, }, }); diff --git a/src/utils/settings-slider.ts b/src/utils/settings-slider.ts index 322444b..1082c53 100644 --- a/src/utils/settings-slider.ts +++ b/src/utils/settings-slider.ts @@ -1,3 +1,5 @@ +import { formatNumber } from './format-number'; + export interface SliderConfiguration { min: number; max: number; @@ -72,17 +74,13 @@ export class SettingsSlider> { ); } - private get formattedValue(): string { - const value = this.settings[this.settingName]; - const unit = this.config.unit ?? ''; - - return `${value === Math.floor(value) ? value : value.toFixed(2)}${unit}`; - } - private onChange() { this.settings[this.settingName] = Number(this.slider.value) as any; this.config.onChangeCallback?.(this.settings[this.settingName]); - this.valueDisplay.innerText = this.formattedValue; + this.valueDisplay.innerText = formatNumber( + this.settings[this.settingName], + this.config.unit + ); } public updateConfig(config: Partial) {