import { type AdapterResult, budgetForTier } from './adapter';
import {
PARAMS_BYTE_SIZE,
POST_PARAMS_BYTE_SIZE,
type PostUniformInput,
type UniformInput,
packParams,
packPostParams,
} from './params';
import { PingPong } from './pingPong';
import { AGENT_BYTE_STRIDE, seedAgents } from './seeding';
// src/engine/Engine.ts
import {
type AudioFrame,
type EngineHandle,
MOD_TARGETS,
type ModTarget,
type PerfTier,
type PresetParams,
} from './types';
type AudioFactors = Record<ModTarget, number>;
const FACTOR_ONES: AudioFactors = Object.fromEntries(
MOD_TARGETS.map((t) => [t, 1]),
) as AudioFactors;
import agentStepSrc from './shaders/agentStep.wgsl?raw';
import bloomSrc from './shaders/bloom.wgsl?raw';
import blurSrc from './shaders/blur.wgsl?raw';
import compositeSrc from './shaders/composite.wgsl?raw';
import depositSrc from './shaders/deposit.wgsl?raw';
import diffuseSrc from './shaders/diffuse.wgsl?raw';
import feedbackSrc from './shaders/feedback.wgsl?raw';
import renderSrc from './shaders/render.wgsl?raw';
interface EngineDeps {
adapter: AdapterResult;
canvas: HTMLCanvasElement;
initialPreset: PresetParams;
}
// HDR intermediate format for the post-processing chain. rgba16float is both
// renderable and linearly filterable per the WebGPU spec.
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
export class Engine implements EngineHandle {
readonly canvas: HTMLCanvasElement;
readonly tier: PerfTier;
private readonly device: GPUDevice;
private readonly context: GPUCanvasContext;
private readonly canvasFormat: GPUTextureFormat;
private fieldWidth: number;
private fieldHeight: number;
private readonly maxAgents: number;
private agentCount: number;
private preset: PresetParams;
private audio: AudioFrame | null = null;
// Exponential moving average of the loudest band per frame, used to drive
// auto-gain normalisation in writeParams(). Reset to 0 whenever audio is
// turned off (setAudio(null)) so a quiet source after restart starts fresh.
private audioRunningPeak = 0;
private paramsBuffer!: GPUBuffer;
private postParamsBuffer!: GPUBuffer;
private agentBuffers!: PingPong<GPUBuffer>;
private pheromoneBuffers!: PingPong<GPUBuffer>;
private agentHeadsBuffer!: GPUBuffer;
// Post-processing textures (all HDR_FORMAT). These run at renderWidth/Height
// — capped well below the canvas pixel count, since the simulation grid is
// only ~640px anyway. The composite pass upscales to the canvas.
private sampler!: GPUSampler;
private sceneTex!: GPUTexture;
private feedbackTex!: PingPong<GPUTexture>;
private bloomTex!: PingPong<GPUTexture>;
private renderWidth = 1;
private renderHeight = 1;
private agentStepPipeline!: GPUComputePipeline;
private depositPipeline!: GPUComputePipeline;
private diffusePipeline!: GPUComputePipeline;
private scenePipeline!: GPURenderPipeline;
private feedbackPipeline!: GPURenderPipeline;
private bloomPipeline!: GPURenderPipeline;
private blurHPipeline!: GPURenderPipeline;
private blurVPipeline!: GPURenderPipeline;
private compositePipeline!: GPURenderPipeline;
private agentStepBG!: GPUBindGroup;
private depositBG!: GPUBindGroup;
private diffuseBG!: GPUBindGroup;
private sceneBG!: GPUBindGroup;
private time = 0;
private frame = 0;
private disposed = false;
constructor(deps: EngineDeps) {
this.canvas = deps.canvas;
this.device = deps.adapter.device;
this.tier = deps.adapter.tier;
this.preset = deps.initialPreset;
const budget = budgetForTier(deps.adapter.tier);
this.maxAgents = budget.agentCount;
this.agentCount = Math.min(budget.agentCount, deps.initialPreset.agentCount);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = Math.max(1, deps.canvas.clientWidth || window.innerWidth);
const cssH = Math.max(1, deps.canvas.clientHeight || window.innerHeight);
deps.canvas.width = Math.floor(cssW * dpr);
deps.canvas.height = Math.floor(cssH * dpr);
// The simulation grid is decoupled from the canvas pixel count — a fixed,
// modest resolution keeps the pattern scale (sensor radius, diffusion,
// movement) constant regardless of display size or DPR. render.wgsl
// upscales the field bilinearly to the canvas.
const field = Engine.fieldSizeFor(cssW, cssH, budget.fieldScale);
this.fieldWidth = field.w;
this.fieldHeight = field.h;
const ctx = deps.canvas.getContext('webgpu');
if (!ctx) throw new Error('Failed to get webgpu canvas context');
this.context = ctx;
this.canvasFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
device: this.device,
format: this.canvasFormat,
alphaMode: 'opaque',
});
this.updateRenderSize();
this.allocateBuffers();
this.allocateTextures();
this.buildPipelines();
this.writeParams();
this.writePostParams();
}
// Fixed simulation grid: the long edge is SIM_LONG_EDGE (scaled down on
// weaker perf tiers), the short edge follows the canvas aspect ratio.
private static readonly SIM_LONG_EDGE = 640;
// Cap for the post-processing render targets. Above this the composite pass
// upscales to the canvas — invisible, since the sim grid is far smaller.
private static readonly MAX_RENDER_LONG_EDGE = 1600;
// Derives the post-processing resolution from the current canvas size,
// capped at MAX_RENDER_LONG_EDGE on the long edge.
private updateRenderSize(): void {
const cw = Math.max(1, this.canvas.width);
const ch = Math.max(1, this.canvas.height);
const scale = Math.min(1, Engine.MAX_RENDER_LONG_EDGE / Math.max(cw, ch));
this.renderWidth = Math.max(1, Math.round(cw * scale));
this.renderHeight = Math.max(1, Math.round(ch * scale));
}
private static fieldSizeFor(
cssW: number,
cssH: number,
tierScale: number,
): { w: number; h: number } {
const longEdge = Math.max(128, Math.round(Engine.SIM_LONG_EDGE * tierScale));
const aspect = Math.max(0.2, cssW / Math.max(1, cssH));
if (aspect >= 1) {
return { w: longEdge, h: Math.max(64, Math.round(longEdge / aspect)) };
}
return { w: Math.max(64, Math.round(longEdge * aspect)), h: longEdge };
}
private allocateBuffers(): void {
this.paramsBuffer = this.device.createBuffer({
label: 'params',
size: PARAMS_BYTE_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.postParamsBuffer = this.device.createBuffer({
label: 'postParams',
size: POST_PARAMS_BYTE_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// Agent buffers sized to the tier maximum so agentCount can change on respawn.
// Agent struct: pos vec2 + angle + species + rhythmPhase = 24-byte stride.
const agentBytes = this.maxAgents * AGENT_BYTE_STRIDE;
const a0 = this.device.createBuffer({
label: 'agentsA',
size: agentBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const a1 = this.device.createBuffer({
label: 'agentsB',
size: agentBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.agentBuffers = new PingPong(a0, a1);
this.seedAgentBuffer();
// Pheromone field: vec4<f32> per cell (RGB used, A reserved), ping-pong.
const cellBytes = this.fieldWidth * this.fieldHeight * 16;
const p0 = this.device.createBuffer({
label: 'pheromoneA',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const p1 = this.device.createBuffer({
label: 'pheromoneB',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.pheromoneBuffers = new PingPong(p0, p1);
// Heads buffer: per-frame agent-position stamps that don't decay or
// diffuse. Cleared at the start of every tick, written by the deposit
// pass, sampled by the scene pass. Drives the headIntensity slider —
// adding a distinct "where they are right now" highlight on top of
// the smudgier persistent pheromone trail behind. Long-exposure feel.
this.agentHeadsBuffer = this.device.createBuffer({
label: 'agentHeads',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
}
// Allocates the post-processing render targets at the current canvas size.
// sceneTex/feedbackTex are full-resolution; bloomTex is half-resolution
// (bloom is a soft glow — quarter the pixels is plenty and much cheaper).
private allocateTextures(): void {
const w = this.renderWidth;
const h = this.renderHeight;
const bw = Math.max(1, w >> 1);
const bh = Math.max(1, h >> 1);
const usage = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING;
if (this.sampler === undefined) {
this.sampler = this.device.createSampler({
label: 'postSampler',
magFilter: 'linear',
minFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge',
});
}
this.sceneTex = this.device.createTexture({
label: 'sceneTex',
size: [w, h],
format: HDR_FORMAT,
usage,
});
const f0 = this.device.createTexture({
label: 'feedbackA',
size: [w, h],
format: HDR_FORMAT,
usage,
});
const f1 = this.device.createTexture({
label: 'feedbackB',
size: [w, h],
format: HDR_FORMAT,
usage,
});
this.feedbackTex = new PingPong(f0, f1);
const b0 = this.device.createTexture({
label: 'bloomA',
size: [bw, bh],
format: HDR_FORMAT,
usage,
});
const b1 = this.device.createTexture({
label: 'bloomB',
size: [bw, bh],
format: HDR_FORMAT,
usage,
});
this.bloomTex = new PingPong(b0, b1);
}
// Writes only [0, agentCount) of the agent buffer. When agentCount shrinks
// (e.g. via respawn), the stale tail [agentCount, maxAgents) is left as-is —
// safe because every shader and dispatch guards on params.agentCount.
private seedAgentBuffer(): void {
const s = this.preset.species;
const shares: readonly [number, number, number] = [
s[0].populationShare,
s[1].populationShare,
s[2].populationShare,
];
const data = seedAgents(
this.preset.spawnPattern,
this.agentCount,
shares,
this.fieldWidth,
this.fieldHeight,
);
this.device.queue.writeBuffer(this.agentBuffers.read, 0, data);
}
// Wipes the pheromone field and the feedback (echo) textures to zero, so
// a respawn or preset switch starts from a genuinely blank canvas instead
// of bleeding the previous pattern through.
private clearField(): void {
const encoder = this.device.createCommandEncoder({ label: 'clearField' });
encoder.clearBuffer(this.pheromoneBuffers.read);
encoder.clearBuffer(this.pheromoneBuffers.write);
for (const tex of [this.feedbackTex.read, this.feedbackTex.write]) {
const pass = encoder.beginRenderPass({
label: 'clearFeedback',
colorAttachments: [
{
view: tex.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
pass.end();
}
this.device.queue.submit([encoder.finish()]);
}
private buildPipelines(): void {
const agentStepModule = this.device.createShaderModule({ code: agentStepSrc });
const depositModule = this.device.createShaderModule({ code: depositSrc });
const diffuseModule = this.device.createShaderModule({ code: diffuseSrc });
const sceneModule = this.device.createShaderModule({ code: renderSrc });
const feedbackModule = this.device.createShaderModule({ code: feedbackSrc });
const bloomModule = this.device.createShaderModule({ code: bloomSrc });
const blurModule = this.device.createShaderModule({ code: blurSrc });
const compositeModule = this.device.createShaderModule({ code: compositeSrc });
this.agentStepPipeline = this.device.createComputePipeline({
label: 'agentStep',
layout: 'auto',
compute: { module: agentStepModule, entryPoint: 'main' },
});
this.depositPipeline = this.device.createComputePipeline({
label: 'deposit',
layout: 'auto',
compute: { module: depositModule, entryPoint: 'main' },
});
this.diffusePipeline = this.device.createComputePipeline({
label: 'diffuse',
layout: 'auto',
compute: { module: diffuseModule, entryPoint: 'main' },
});
// Scene pass: composes the field into a linear HDR texture.
this.scenePipeline = this.device.createRenderPipeline({
label: 'scene',
layout: 'auto',
vertex: { module: sceneModule, entryPoint: 'vs_main' },
fragment: {
module: sceneModule,
entryPoint: 'fs_main',
targets: [{ format: HDR_FORMAT }],
},
primitive: { topology: 'triangle-list' },
});
// Feedback pass: accumulates motion trails.
this.feedbackPipeline = this.device.createRenderPipeline({
label: 'feedback',
layout: 'auto',
vertex: { module: feedbackModule, entryPoint: 'vs_main' },
fragment: {
module: feedbackModule,
entryPoint: 'fs_main',
targets: [{ format: HDR_FORMAT }],
},
primitive: { topology: 'triangle-list' },
});
// Bloom bright-pass.
this.bloomPipeline = this.device.createRenderPipeline({
label: 'bloom',
layout: 'auto',
vertex: { module: bloomModule, entryPoint: 'vs_main' },
fragment: {
module: bloomModule,
entryPoint: 'fs_main',
targets: [{ format: HDR_FORMAT }],
},
primitive: { topology: 'triangle-list' },
});
// Separable blur — two pipelines from one module via override constants.
this.blurHPipeline = this.device.createRenderPipeline({
label: 'blurH',
layout: 'auto',
vertex: { module: blurModule, entryPoint: 'vs_main' },
fragment: {
module: blurModule,
entryPoint: 'fs_main',
constants: { BLUR_DIR_X: 1, BLUR_DIR_Y: 0 },
targets: [{ format: HDR_FORMAT }],
},
primitive: { topology: 'triangle-list' },
});
this.blurVPipeline = this.device.createRenderPipeline({
label: 'blurV',
layout: 'auto',
vertex: { module: blurModule, entryPoint: 'vs_main' },
fragment: {
module: blurModule,
entryPoint: 'fs_main',
constants: { BLUR_DIR_X: 0, BLUR_DIR_Y: 1 },
targets: [{ format: HDR_FORMAT }],
},
primitive: { topology: 'triangle-list' },
});
// Composite pass: scene + bloom, tone-map, grade, vignette → canvas.
this.compositePipeline = this.device.createRenderPipeline({
label: 'composite',
layout: 'auto',
vertex: { module: compositeModule, entryPoint: 'vs_main' },
fragment: {
module: compositeModule,
entryPoint: 'fs_main',
targets: [{ format: this.canvasFormat }],
},
primitive: { topology: 'triangle-list' },
});
}
// Auto-gain tuning: levels in [0..1], target peak ≈ 0.7. EMA factor 0.005
// gives a half-life of ~138 frames (~2.3 s at 60 fps) — slow enough to ride
// a song's average, fast enough to recover when the source becomes louder.
private static readonly AUTO_GAIN_TARGET = 0.7;
private static readonly AUTO_GAIN_EMA = 0.005;
private static readonly AUTO_GAIN_PEAK_FLOOR = 0.05;
private static readonly AUTO_GAIN_MAX = 20;
// Sum up route depths per target. Also advances the auto-gain EMA. Called
// once per tick; the resulting factors are passed to both writeParams (sim
// side) and writePostParams (post side).
private computeAudioFactors(): AudioFactors {
const ar = this.preset.audioReactive;
const bass = this.audio?.bass ?? 0;
const mid = this.audio?.mid ?? 0;
const high = this.audio?.high ?? 0;
// Track the per-frame loudest band, smooth with an EMA.
if (this.audio !== null) {
const framePeak = Math.max(bass, mid, high);
this.audioRunningPeak += Engine.AUTO_GAIN_EMA * (framePeak - this.audioRunningPeak);
}
const autoGainMul = ar.autoGain
? Math.min(
Engine.AUTO_GAIN_MAX,
Engine.AUTO_GAIN_TARGET / Math.max(this.audioRunningPeak, Engine.AUTO_GAIN_PEAK_FLOOR),
)
: 1;
const effGain = ar.gain * autoGainMul;
const factors: AudioFactors = { ...FACTOR_ONES };
if (ar.enabled && this.audio !== null) {
for (const route of ar.routes) {
const level = route.source === 'bass' ? bass : route.source === 'mid' ? mid : high;
const contribution = level * route.depth * effGain;
factors[route.target] += contribution;
}
}
return factors;
}
private writeParams(factors: AudioFactors = FACTOR_ONES): void {
const p = this.preset;
const input: UniformInput = {
fieldWidth: this.fieldWidth,
fieldHeight: this.fieldHeight,
agentCount: this.agentCount,
frame: this.frame,
time: this.time,
decayRate: p.decayRate,
fadeStrength: p.fadeStrength,
trailSaturation: p.trailSaturation,
diffusionFreq: p.diffusionFreq,
chaosInterval: p.chaosInterval,
chaosStrength: p.chaosStrength,
rhythmAmplitude: p.rhythmAmplitude,
exposure: p.exposure,
spawnPattern: p.spawnPattern,
headIntensity: p.headIntensity,
backgroundColor: p.backgroundColor,
species: p.species,
matrix: p.interactionMatrix,
audio: {
moveSpeedFactor: factors.moveSpeed,
turnSpeedFactor: factors.turnSpeed,
depositFactor: factors.depositAmount,
sensorDistanceFactor: factors.sensorDistance,
rhythmAmplitudeFactor: factors.rhythmAmplitude,
chaosStrengthFactor: factors.chaosStrength,
},
};
this.device.queue.writeBuffer(this.paramsBuffer, 0, packParams(input));
}
private writePostParams(factors: AudioFactors = FACTOR_ONES): void {
const p = this.preset;
const input: PostUniformInput = {
resolutionX: this.renderWidth,
resolutionY: this.renderHeight,
exposure: p.exposure,
time: this.time,
feedbackAmount: p.feedbackAmount,
bloomThreshold: p.bloomThreshold,
bloomIntensity: p.bloomIntensity,
vignette: p.vignette,
saturation: p.saturation,
contrast: p.contrast,
backgroundColor: p.backgroundColor,
blendMode: p.blendMode,
kaleidoscope: p.kaleidoscope,
exposureFactor: factors.exposure,
bloomIntensityFactor: factors.bloomIntensity,
saturationFactor: factors.saturation,
contrastFactor: factors.contrast,
};
this.device.queue.writeBuffer(this.postParamsBuffer, 0, packPostParams(input));
}
setPreset(preset: PresetParams): void {
this.preset = preset;
// Live slider edits morph in seamlessly. A genuine preset switch is
// followed by an explicit respawn() (triggered by the Canvas component).
this.writeParams();
this.writePostParams();
}
// Clears the field and re-seeds agents from this.preset — i.e. the preset
// most recently passed to setPreset(). The Canvas edited-sync effect keeps
// this.preset current, so a respawn always uses the latest spawnPattern /
// populationShare / agentCount, starting from a blank canvas.
respawn(): void {
if (this.disposed) return;
this.agentCount = Math.min(this.maxAgents, this.preset.agentCount);
this.clearField();
this.seedAgentBuffer();
this.writeParams();
}
setAudio(frame: AudioFrame | null): void {
this.audio = frame;
if (frame === null) this.audioRunningPeak = 0;
// writeParams is called in tick() each frame anyway, so this only stages the value
}
// Captures a downscaled JPEG of the current canvas. Call right after a
// tick() so the canvas still holds the freshly rendered frame.
snapshot(longEdge = 320): string {
if (this.disposed) return '';
const src = this.canvas;
const sw = Math.max(1, src.width);
const sh = Math.max(1, src.height);
const scale = longEdge / Math.max(sw, sh);
const w = Math.max(1, Math.round(sw * scale));
const h = Math.max(1, Math.round(sh * scale));
const off = document.createElement('canvas');
off.width = w;
off.height = h;
const ctx = off.getContext('2d');
if (!ctx) return '';
try {
ctx.drawImage(src, 0, 0, w, h);
return off.toDataURL('image/jpeg', 0.82);
} catch {
return '';
}
}
// Full-resolution PNG capture of the current canvas. Like snapshot(), call
// right after a tick() so the WebGPU drawing buffer still holds the frame.
async exportPng(): Promise<Blob | null> {
if (this.disposed) return null;
const src = this.canvas;
const w = Math.max(1, src.width);
const h = Math.max(1, src.height);
const off = document.createElement('canvas');
off.width = w;
off.height = h;
const ctx = off.getContext('2d');
if (!ctx) return null;
try {
ctx.drawImage(src, 0, 0);
} catch {
return null;
}
return new Promise<Blob | null>((resolve) => off.toBlob((b) => resolve(b), 'image/png'));
}
tick(): void {
if (this.disposed) return;
this.time += 1 / 60;
this.frame += 1;
const factors = this.computeAudioFactors();
this.writeParams(factors);
this.writePostParams(factors);
this.ensureBindGroups();
const encoder = this.device.createCommandEncoder({ label: 'frame' });
// Heads buffer is rebuilt per frame: clear it before deposit writes
// this tick's agent positions into it. Sampling happens later in the
// scene pass via the headIntensity uniform.
encoder.clearBuffer(this.agentHeadsBuffer);
// Pass 1: agent step
{
const pass = encoder.beginComputePass({ label: 'agentStep' });
pass.setPipeline(this.agentStepPipeline);
pass.setBindGroup(0, this.agentStepBG);
pass.dispatchWorkgroups(Math.ceil(this.agentCount / 64));
pass.end();
}
// Swap agent ping-pong so deposit reads the just-written agents
this.agentBuffers.swap();
this.rebuildAgentStepBG();
this.rebuildDepositBG();
// Pass 2: deposit
{
const pass = encoder.beginComputePass({ label: 'deposit' });
pass.setPipeline(this.depositPipeline);
pass.setBindGroup(0, this.depositBG);
pass.dispatchWorkgroups(Math.ceil(this.agentCount / 64));
pass.end();
}
// Pass 3: diffuse + decay — runs every frame. The shader applies decay
// every frame and the box blur only every diffusionFreq-th frame.
{
const pass = encoder.beginComputePass({ label: 'diffuse' });
pass.setPipeline(this.diffusePipeline);
pass.setBindGroup(0, this.diffuseBG);
pass.dispatchWorkgroups(Math.ceil(this.fieldWidth / 8), Math.ceil(this.fieldHeight / 8));
pass.end();
}
this.pheromoneBuffers.swap();
this.rebuildSceneBG();
this.rebuildAgentStepBG();
this.rebuildDepositBG();
this.rebuildDiffuseBG();
// Pass 4: scene → sceneTex (linear HDR)
this.fullscreenPass(
encoder,
'scene',
this.sceneTex.createView(),
this.scenePipeline,
this.sceneBG,
);
// Pass 5: feedback — current scene + decayed echo → feedbackTex
this.fullscreenPass(
encoder,
'feedback',
this.feedbackTex.write.createView(),
this.feedbackPipeline,
this.postBindGroup(this.feedbackPipeline, [this.sceneTex, this.feedbackTex.read]),
);
this.feedbackTex.swap();
// Pass 6: bloom bright-pass → bloomTex (half resolution)
this.fullscreenPass(
encoder,
'bloomBright',
this.bloomTex.write.createView(),
this.bloomPipeline,
this.postBindGroup(this.bloomPipeline, [this.feedbackTex.read]),
);
this.bloomTex.swap();
// Pass 7: blur horizontal
this.fullscreenPass(
encoder,
'blurH',
this.bloomTex.write.createView(),
this.blurHPipeline,
this.postBindGroup(this.blurHPipeline, [this.bloomTex.read]),
);
this.bloomTex.swap();
// Pass 8: blur vertical
this.fullscreenPass(
encoder,
'blurV',
this.bloomTex.write.createView(),
this.blurVPipeline,
this.postBindGroup(this.blurVPipeline, [this.bloomTex.read]),
);
this.bloomTex.swap();
// Pass 9: composite → canvas
this.fullscreenPass(
encoder,
'composite',
this.context.getCurrentTexture().createView(),
this.compositePipeline,
this.postBindGroup(this.compositePipeline, [this.feedbackTex.read, this.bloomTex.read]),
);
this.device.queue.submit([encoder.finish()]);
}
// Draws a fullscreen triangle into `view` with the given pipeline.
private fullscreenPass(
encoder: GPUCommandEncoder,
label: string,
view: GPUTextureView,
pipeline: GPURenderPipeline,
bindGroup: GPUBindGroup,
): void {
const pass = encoder.beginRenderPass({
label,
colorAttachments: [
{
view,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3, 1, 0, 0);
pass.end();
}
// Builds a bind group for a post-processing pass: postParams + sampler at
// bindings 0/1, then one texture view per entry starting at binding 2.
private postBindGroup(
pipeline: GPURenderPipeline,
textures: readonly GPUTexture[],
): GPUBindGroup {
const entries: GPUBindGroupEntry[] = [
{ binding: 0, resource: { buffer: this.postParamsBuffer } },
{ binding: 1, resource: this.sampler },
];
textures.forEach((tex, i) => {
entries.push({ binding: 2 + i, resource: tex.createView() });
});
return this.device.createBindGroup({
label: 'postBG',
layout: pipeline.getBindGroupLayout(0),
entries,
});
}
private ensureBindGroups(): void {
if (!this.agentStepBG) this.rebuildAgentStepBG();
if (!this.depositBG) this.rebuildDepositBG();
if (!this.diffuseBG) this.rebuildDiffuseBG();
if (!this.sceneBG) this.rebuildSceneBG();
}
private rebuildAgentStepBG(): void {
this.agentStepBG = this.device.createBindGroup({
label: 'agentStepBG',
layout: this.agentStepPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.paramsBuffer } },
{ binding: 1, resource: { buffer: this.pheromoneBuffers.read } },
{ binding: 2, resource: { buffer: this.agentBuffers.read } },
{ binding: 3, resource: { buffer: this.agentBuffers.write } },
],
});
}
private rebuildDepositBG(): void {
this.depositBG = this.device.createBindGroup({
label: 'depositBG',
layout: this.depositPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.paramsBuffer } },
{ binding: 1, resource: { buffer: this.pheromoneBuffers.read } },
{ binding: 2, resource: { buffer: this.agentBuffers.read } },
{ binding: 3, resource: { buffer: this.agentHeadsBuffer } },
],
});
}
private rebuildDiffuseBG(): void {
this.diffuseBG = this.device.createBindGroup({
label: 'diffuseBG',
layout: this.diffusePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.paramsBuffer } },
{ binding: 1, resource: { buffer: this.pheromoneBuffers.read } },
{ binding: 2, resource: { buffer: this.pheromoneBuffers.write } },
],
});
}
private rebuildSceneBG(): void {
this.sceneBG = this.device.createBindGroup({
label: 'sceneBG',
layout: this.scenePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.paramsBuffer } },
{ binding: 1, resource: { buffer: this.pheromoneBuffers.read } },
{ binding: 2, resource: { buffer: this.agentHeadsBuffer } },
],
});
}
resize(width: number, height: number): void {
if (this.disposed) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const newW = Math.max(1, Math.floor(width * dpr));
const newH = Math.max(1, Math.floor(height * dpr));
if (newW === this.canvas.width && newH === this.canvas.height) return;
this.canvas.width = newW;
this.canvas.height = newH;
const budget = budgetForTier(this.tier);
const field = Engine.fieldSizeFor(width, height, budget.fieldScale);
this.fieldWidth = field.w;
this.fieldHeight = field.h;
// Destroy old pheromone buffers, reallocate at vec4 size for the new field
this.pheromoneBuffers.destroy((b) => b.destroy());
const cellBytes = this.fieldWidth * this.fieldHeight * 16;
const p0 = this.device.createBuffer({
label: 'pheromoneA',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const p1 = this.device.createBuffer({
label: 'pheromoneB',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.pheromoneBuffers = new PingPong<GPUBuffer>(p0, p1);
// Heads buffer needs the same cell count as pheromone — rebuild it too.
this.agentHeadsBuffer.destroy();
this.agentHeadsBuffer = this.device.createBuffer({
label: 'agentHeads',
size: cellBytes,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// Reallocate post-processing targets at the new render size
this.updateRenderSize();
this.sceneTex.destroy();
this.feedbackTex.destroy((t) => t.destroy());
this.bloomTex.destroy((t) => t.destroy());
this.allocateTextures();
// Reseed agents into the new field bounds
this.seedAgentBuffer();
this.writeParams();
this.writePostParams();
// Force bind-group rebuild on next tick
this.rebuildAgentStepBG();
this.rebuildDepositBG();
this.rebuildDiffuseBG();
this.rebuildSceneBG();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.paramsBuffer.destroy();
this.postParamsBuffer.destroy();
this.agentBuffers.destroy((b) => b.destroy());
this.pheromoneBuffers.destroy((b) => b.destroy());
this.agentHeadsBuffer.destroy();
this.sceneTex.destroy();
this.feedbackTex.destroy((t) => t.destroy());
this.bloomTex.destroy((t) => t.destroy());
}
}