Rework garden simulation pipelines

This commit is contained in:
Andras Schmelczer 2026-05-24 10:58:11 +01:00
commit 018f8c9d4d
67 changed files with 6499 additions and 1682 deletions

View file

@ -0,0 +1,49 @@
const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
const AGENT_WORKGROUP_SIZE_TARGETS = {
// Keep shader-specific targets conservative. Using the device maximum can
// hurt occupancy and makes compaction's workgroup scan more expensive.
simulation: 256,
eraser: 256,
resize: 256,
compaction: 256,
} satisfies Record<AgentWorkgroupKind, number>;
export const getAgentWorkgroupSize = (
device: GPUDevice,
kind: AgentWorkgroupKind = 'simulation'
): number => {
const deviceLimit = Math.max(
1,
Math.floor(
Math.min(
device.limits.maxComputeInvocationsPerWorkgroup,
device.limits.maxComputeWorkgroupSizeX
)
)
);
return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
};
export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
export const substituteAgentWorkgroupSize = (
device: GPUDevice,
shaderCode: string,
kind: AgentWorkgroupKind = 'simulation'
): string =>
shaderCode.replaceAll(
'__AGENT_WORKGROUP_SIZE__',
String(getAgentWorkgroupSize(device, kind))
);
export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder,
workgroupSize: number,
agentCount: number
): void => {
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / workgroupSize), 1);
};

View file

@ -0,0 +1,97 @@
struct Settings {
agentCount: u32,
padding0: u32,
padding1: u32,
padding2: u32,
};
struct Counters {
aliveAgentCount: atomic<u32>,
};
const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var<storage, read_write> counters: Counters;
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
var<workgroup> workgroupCompactedOffset: u32;
var<workgroup> scanData: array<u32, agentWorkgroupSize>;
var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id);
let lid = local_id.x;
var isAlive = false;
var agent: Agent;
if id < settings.agentCount {
isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
if isAlive {
agent = agents[id];
}
}
// Hillis-Steele inclusive prefix sum across the workgroup. Replaces a
// per-thread atomicAdd to a workgroup counter, eliminating serialization
// on dense workgroups.
scanData[lid] = select(0u, 1u, isAlive);
workgroupBarrier();
var offset: u32 = 1u;
while offset < agentWorkgroupSize {
let own = scanData[lid];
var contribution: u32 = 0u;
if lid >= offset {
contribution = scanData[lid - offset];
}
workgroupBarrier();
scanData[lid] = own + contribution;
workgroupBarrier();
offset = offset * 2u;
}
let inclusivePrefix = scanData[lid];
let workgroupAliveTotal = scanData[agentWorkgroupSize - 1u];
let exclusivePrefix = inclusivePrefix - select(0u, 1u, isAlive);
if lid == 0u {
if workgroupAliveTotal > 0u {
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, workgroupAliveTotal);
} else {
workgroupCompactedOffset = 0u;
}
}
workgroupBarrier();
if isAlive {
compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
}
}
@compute @workgroup_size(agentWorkgroupSize)
fn clearCompactedTail(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id);
if local_id.x == 0u {
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
}
workgroupBarrier();
let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride;
for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) {
let clearId = firstClearId + offset;
if clearId < settings.agentCount {
compactedAgents[clearId].colorIndex = -1.0;
}
}
}

View file

@ -1,31 +0,0 @@
struct Settings {
agentCount: u32 // might be smaller than the length of the agents array
};
@group(1) @binding(0) var<uniform> settings: Settings;
struct Counters {
evenGenerationAlive: atomic<u32>,
oddGenerationAlive: atomic<u32>,
};
@group(1) @binding(2) var<storage, read_write> counters: Counters;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
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;
}
if agents[id].generation % 2 == 0 {
atomicAdd(&counters.evenGenerationAlive, 1);
} else {
atomicAdd(&counters.oddGenerationAlive, 1);
}
}

View file

@ -1,34 +0,0 @@
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= arrayLength(&agents) {
return;
}
let clusterId = f32(id % 1000);
let random = textureSampleLevel(
noise,
noiseSampler,
vec2(f32(id % 1999) / 2000, f32(id) / 1999 / 2000),
0
);
let randomPosition = textureSampleLevel(
noise,
noiseSampler,
vec2(clusterId / 2000, clusterId / 2000),
0
);
agents[id] = Agent(
randomPosition.xz * state.size,
random.r * 3.14 * 2,
0,
);
}

View file

@ -1,33 +1,65 @@
import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile';
import { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from '../agent-dispatch';
import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
import compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 1;
private static readonly COUNTER_COUNT = 3;
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1;
private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
private readonly bindGroup: GPUBindGroup;
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
(active, inactive) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: active } },
{ binding: 2, resource: { buffer: this.countersBuffer } },
{ binding: 3, resource: { buffer: inactive } },
],
})
);
private readonly firstGenerationPipeline: GPUComputePipeline;
private readonly countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
private readonly clearCompactedTailPipeline: GPUComputePipeline;
private readonly resizeWorkgroupSize: number;
private readonly compactionWorkgroupSize: number;
public readonly agentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer;
private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: GPUBuffer;
private allocatedMaxAgentCount: number;
private readonly countersBuffer: GPUBuffer;
private readonly countersStagingBuffer: GPUBuffer;
private readonly agentCountUniformValues = new Uint32Array(
AgentGenerationPipeline.UNIFORM_COUNT
);
private readonly resizeUniformBuffer = new ArrayBuffer(
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
);
private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer);
private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer);
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly maxAgentCountUpperLimit: number
initialMaxAgentCount: number,
private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
) {
this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
@ -51,13 +83,18 @@ export class AgentGenerationPipeline {
type: 'storage',
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
],
});
this.agentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE,
});
this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.countersBuffer = this.device.createBuffer({
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@ -74,99 +111,182 @@ export class AgentGenerationPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
],
});
this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
const compactionSchema = substituteAgentWorkgroupSize(
device,
agentSchema,
'compaction'
);
this.firstGenerationPipeline = device.createComputePipeline({
this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
firstGenerationShader
),
module: smartCompile(device, resizeSchema, resizeShader),
entryPoint: 'main',
},
});
this.countingPipeline = device.createComputePipeline({
const compactionModule = smartCompile(
device,
compactionSchema,
compactionShader.replaceAll(
'__CLEAR_COMPACTED_TAIL_STRIDE__',
AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
)
);
this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, countingShader),
module: compactionModule,
entryPoint: 'main',
},
});
this.clearCompactedTailPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: compactionModule,
entryPoint: 'clearCompactedTail',
},
});
}
public get agentsBuffer(): GPUBuffer {
return this.activeAgentsBuffer;
}
private createAgentsBuffer(): GPUBuffer {
return this.device.createBuffer({
size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
}
public get maxAgentCount(): number {
return this.allocatedMaxAgentCount;
}
public get maxSupportedAgentCount(): number {
return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
}
public ensureMaxAgentCount(
requestedMaxAgentCount: number,
activeAgentCount: number
): number {
const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) {
return this.allocatedMaxAgentCount;
}
const nextMaxAgentCount = this.clampMaxAgentCount(
Math.max(
requestedClampedMaxAgentCount,
Math.ceil(
this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR
)
)
);
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
const previousMaxAgentCount = this.allocatedMaxAgentCount;
this.allocatedMaxAgentCount = nextMaxAgentCount;
this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
const copyAgentCount = Math.min(
Math.max(0, Math.floor(activeAgentCount)),
previousMaxAgentCount,
nextMaxAgentCount
);
if (copyAgentCount > 0) {
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.copyBufferToBuffer(
previousActiveAgentsBuffer,
0,
this.activeAgentsBuffer,
0,
copyAgentCount * AGENT_SIZE_IN_BYTES
);
this.device.queue.submit([commandEncoder.finish()]);
}
// GPUBuffer.destroy() defers actual freeing until pending submissions
// finish, so calling it synchronously after submit is safe.
previousActiveAgentsBuffer.destroy();
previousInactiveAgentsBuffer.destroy();
return this.allocatedMaxAgentCount;
}
private clampMaxAgentCount(value: number): number {
const requestedMaxAgentCount =
value === Number.POSITIVE_INFINITY
? Number.POSITIVE_INFINITY
: Number.isFinite(value)
? Math.floor(value)
: 0;
return Math.min(
this.maxAgentCountUpperLimit,
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
this.device.limits.maxComputeWorkgroupsPerDimension ** 3
getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit),
Math.max(0, requestedMaxAgentCount)
);
}
public spawnFirstGeneration(): void {
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.firstGenerationPipeline);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.maxAgentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
public writeAgents(agentOffset: number, data: Float32Array): void {
this.device.queue.writeBuffer(
this.activeAgentsBuffer,
agentOffset * AGENT_SIZE_IN_BYTES,
data
);
}
public resizeAgents(agentCount: number, scale: vec2): void {
if (agentCount <= 0 || vec2.equals(scale, vec2.fromValues(1, 1))) {
return;
}
this.resizeUniformFloatValues[0] = scale[0];
this.resizeUniformFloatValues[1] = scale[1];
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]);
}
public async countAgents(agentCount: number): Promise<GenerationCounts> {
this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
public async compactAgents(agentCount: number): Promise<number> {
if (agentCount <= 0) {
return 0;
}
this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.countingPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups(
passEncoder,
this.compactionWorkgroupSize,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
);
passEncoder.end();
@ -175,25 +295,39 @@ export class AgentGenerationPipeline {
0,
this.countersStagingBuffer,
0,
AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT
Uint32Array.BYTES_PER_ELEMENT
);
this.device.queue.submit([commandEncoder.finish()]);
this.swapAgentBuffers();
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0));
const compactedCount = new Uint32Array(
this.countersStagingBuffer.getMappedRange(),
0,
1
)[0];
this.countersStagingBuffer.unmap();
return {
evenGenerationCount: data[0],
oddGenerationCount: data[1],
};
return compactedCount;
}
private getBindGroup(): GPUBindGroup {
return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
}
private swapAgentBuffers(): void {
[this.activeAgentsBuffer, this.inactiveAgentsBuffer] = [
this.inactiveAgentsBuffer,
this.activeAgentsBuffer,
];
}
public destroy() {
this.uniforms.destroy();
this.countersBuffer.destroy();
this.countersStagingBuffer.destroy();
this.agentsBuffer.destroy();
this.inactiveAgentsBuffer.destroy();
this.activeAgentsBuffer.destroy();
}
}

View file

@ -0,0 +1,21 @@
struct ResizeSettings {
scale: vec2<f32>,
agentCount: u32,
};
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id);
if id >= resizeSettings.agentCount {
return;
}
let scale = resizeSettings.scale;
agents[id].position = agents[id].position * scale;
agents[id].targetPosition = agents[id].targetPosition * scale;
}

View file

@ -1,11 +1,16 @@
struct Agent {
position: vec2<f32>,
angle: f32,
generation: f32,
colorIndex: f32,
targetPosition: vec2<f32>,
targetAngle: f32,
introDelay: f32,
}
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
fn get_id(global_id: vec3<u32>, workgroup_count: vec3<u32>) -> u32 {
return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
}
const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
fn get_id(global_id: vec3<u32>) -> u32 {
return global_id.x;
}

View file

@ -1,9 +0,0 @@
import { vec2 } from 'gl-matrix';
export interface Agent {
position: vec2;
angle: number;
generation: number;
}
export const AGENT_SIZE_IN_BYTES = 4 * Float32Array.BYTES_PER_ELEMENT;

View file

@ -1,4 +0,0 @@
export interface GenerationCounts {
evenGenerationCount: number;
oddGenerationCount: number;
}

View file

@ -0,0 +1,64 @@
import { getMinAgentWorkgroupSize } from './agent-dispatch';
export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
const AGENT_LAYOUT = {
positionX: 0,
positionY: 1,
angle: 2,
colorIndex: 3,
targetPositionX: 4,
targetPositionY: 5,
targetAngle: 6,
introDelay: 7,
} as const;
export interface AgentLayoutValues {
angle: number;
colorIndex: number;
introDelay: number;
positionX: number;
positionY: number;
targetAngle: number;
targetPositionX: number;
targetPositionY: number;
}
export const writeAgentValues = (
target: Float32Array,
agentIndex: number,
values: AgentLayoutValues
): void => {
const base = agentIndex * AGENT_FLOAT_COUNT;
target[base + AGENT_LAYOUT.positionX] = values.positionX;
target[base + AGENT_LAYOUT.positionY] = values.positionY;
target[base + AGENT_LAYOUT.angle] = values.angle;
target[base + AGENT_LAYOUT.colorIndex] = values.colorIndex;
target[base + AGENT_LAYOUT.targetPositionX] = values.targetPositionX;
target[base + AGENT_LAYOUT.targetPositionY] = values.targetPositionY;
target[base + AGENT_LAYOUT.targetAngle] = values.targetAngle;
target[base + AGENT_LAYOUT.introDelay] = values.introDelay;
};
export const getMaxSupportedAgentCount = (
device: GPUDevice,
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
): number => {
const storageBufferBindingSize =
device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize;
const upperLimit = Number.isFinite(maxAgentCountUpperLimit)
? Math.floor(maxAgentCountUpperLimit)
: Number.POSITIVE_INFINITY;
return Math.max(
0,
Math.min(
upperLimit,
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
getMinAgentWorkgroupSize(device)
)
);
};

View file

@ -1,197 +1,255 @@
import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from './agent-dispatch';
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 19;
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
introTargetAngleBlend: number;
introProgressCutoff: number;
introNearDistanceInner: number;
introTurnRateMultiplier: number;
introRandomTurnMultiplier: number;
introStepStopDistance: number;
randomTimeScale: number;
}
// The Settings struct in WGSL starts with a mat3x3<f32> reactionMatrix.
// In uniform layout each of its 3 columns is stored as a vec3<f32> padded to
// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
// padding). Remaining scalars pack tightly from float 12 onward.
const UNIFORM_COUNT = 32;
const REACTION_MATRIX_COL0 = 0;
const REACTION_MATRIX_COL1 = 4;
const REACTION_MATRIX_COL2 = 8;
const SCALAR_BASE = 12;
export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly pipelineFull: GPUComputePipeline;
private readonly pipelineSteady: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
private previousTrailMapOut?: GPUTextureView;
private readonly workgroupSize: number;
private useSteadyPipeline = false;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly bindGroupCache = createBindGroupCache<
[GPUBuffer, GPUTextureView, GPUTextureView]
>((agentsBuffer, trailMapIn, trailMapOut) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: agentsBuffer } },
{ binding: 2, resource: trailMapIn },
{ binding: 3, resource: trailMapOut },
],
})
);
private agentCount = 0;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly agentsBuffer: GPUBuffer // doesn't get destroyed
private readonly getAgentsBuffer: () => GPUBuffer
) {
this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'uniform' },
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'storage' },
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
texture: { sampleType: 'float' },
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
shader
);
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
});
this.pipelineFull = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
module: shaderModule,
entryPoint: 'main',
},
});
this.pipelineSteady = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainSteady',
},
});
this.uniforms = this.device.createBuffer({
size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
public setParameters({
deltaTime,
center,
radius,
brushTrailWeight,
moveSpeed,
turnSpeed,
sensorOffsetAngle,
sensorOffsetDistance,
nextGenerationSensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSpeed,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
color1ToColor1,
color1ToColor2,
color1ToColor3,
color2ToColor1,
color2ToColor2,
color2ToColor3,
color3ToColor1,
color3ToColor2,
color3ToColor3,
forwardRotationScale,
introNearDistanceInner,
introNearDistanceMin,
introNearSensorOffsetMultiplier,
introTargetAngleBlend,
introProgressCutoff,
introTurnRateMultiplier,
introRandomTurnMultiplier,
introMoveSpeed,
introStepStopDistance,
randomTimeScale,
time,
agentCount,
introProgress,
}: AgentSettings & {
deltaTime: number;
currentGenerationAggression: number;
nextGenerationAggression: number;
nextGenerationSensorOffsetDistance: number;
nextGenerationSpeed: number;
isNextGenerationOdd: number;
center: vec2;
radius: number;
infectionProbability: number;
time: number;
agentCount: number;
introMoveSpeed: number;
introProgress?: number;
}) {
this.agentCount = agentCount;
this.device.queue.writeBuffer(
const resolvedIntroProgress = introProgress ?? 1;
// Once the intro target phase ends nothing reads intro fields again, so the
// steady-only pipeline can replace the full one for the rest of the session.
this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
// Reaction matrix: column N holds the weights for source colorIndex == N.
this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
this.uniformUintValues[SCALAR_BASE + 19] =
Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
...center,
radius,
brushTrailWeight,
moveSpeed * deltaTime,
turnSpeed * deltaTime,
(sensorOffsetAngle * Math.PI) / 180,
sensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSensorOffsetDistance,
nextGenerationSpeed * deltaTime,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
agentCount,
])
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
trailMapOut: GPUTextureView,
timestampWrites?: GPUComputePassTimestampWrites
) {
this.ensureBindGroupExists(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView, trailMapOut: GPUTextureView) {
if (
this.previousTrailMapIn !== trailMapIn ||
this.previousTrailMapOut !== trailMapOut
) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: trailMapIn,
},
{
binding: 3,
resource: trailMapOut,
},
],
});
this.previousTrailMapIn = trailMapIn;
this.previousTrailMapOut = trailMapOut;
if (this.agentCount <= 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(
this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(
1,
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
);
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end();
}
public destroy() {
this.uniforms.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
format: 'rgba16float',
},
},
],
};
}
}

View file

@ -1,11 +0,0 @@
export interface AgentSettings {
brushTrailWeight: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
currentGenerationAggression: number;
nextGenerationAggression: number;
}

View file

@ -1,134 +1,273 @@
const PI: f32 = 3.14159265359;
const TAU: f32 = 6.28318530718;
const INV_TAU: f32 = 0.15915494309;
const CHANNEL_MASKS = array<vec3<f32>, 3>(
vec3<f32>(1.0, 0.0, 0.0),
vec3<f32>(0.0, 1.0, 0.0),
vec3<f32>(0.0, 0.0, 1.0),
);
struct Settings {
center: vec2<f32>,
radius: f32,
brushTrailWeight: f32,
currentGenerationMoveRate: f32,
// Columns are indexed by source colorIndex; each column holds the per-target
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
reactionMatrix: mat3x3<f32>,
moveRate: f32,
turnRate: f32,
sensorAngle: f32,
sensorAngleSin: f32,
sensorAngleCos: f32,
sensorOffset: f32,
currentGenerationAggression: f32,
nextGenerationAggression: f32,
nextGenerationSensorOffsetDistance: f32,
nextGenerationMoveRate: f32,
isNextGenerationOdd: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
infectionProbability: f32,
agentCount: f32 // might be smaller than the length of the agents array
agentCount: u32,
introProgress: f32,
forwardRotationScale: f32,
introNearDistanceInner: f32,
introNearDistanceMin: f32,
introNearSensorOffsetMultiplier: f32,
introTargetAngleBlend: f32,
introProgressCutoff: f32,
introTurnRateMultiplier: f32,
introRandomTurnMultiplier: f32,
introMoveRate: f32,
introStepStopDistance: f32,
randomTimeSeed: u32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
// even generation's trail -> red channel
// odd generation's trail -> green channel
// unused -> blue channel
// brush -> alpha channel
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
struct AgentMovement {
rotation: f32,
step: vec2<f32>,
}
@compute @workgroup_size(64)
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
let id = get_id(global_id);
if id >= u32(settings.agentCount) {
if id >= settings.agentCount {
return;
}
var agent = agents[id];
let random = textureSampleLevel(
noise,
noiseSampler,
vec2(
f32(id) % 23647 / 2000,
state.time % 3243 / 2000
),
0
);
let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
let isFromOddGeneration = agent.generation % 2;
let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration);
let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration);
let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
let trailLeft = sense(agent.position, agent.angle, sensorOffset, settings.sensorAngle);
let trailRight = sense(agent.position, agent.angle, sensorOffset, -settings.sensorAngle);
var weightForward = brushWeight * trailForward.a;
var weightLeft = brushWeight * trailLeft.a;
var weightRight = brushWeight * trailRight.a;
let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward;
weightForward += mix(trailForward.r + agression * trailForward.g, trailForward.g + agression * trailForward.r, isFromOddGeneration);
weightLeft += mix(trailLeft.r + agression * trailLeft.g, trailLeft.g + agression * trailLeft.r, isFromOddGeneration);
weightRight += mix(trailRight.r + agression * trailRight.g, trailRight.g + agression * trailRight.r, isFromOddGeneration);
var rotation: f32;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = 0;
} else {
rotation = sign(weightLeft - weightRight) * settings.turnRate;
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
let nextPosition = clamp(
agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
vec2<f32>(0, 0),
state.size
);
if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
rotation = 3.14159265359 + random.a - 0.5;
}
var trail = vec4<f32>(settings.individualTrailWeight, 0, 0, 0);
if isFromOddGeneration == 1.0 {
trail = vec4<f32>(0, settings.individualTrailWeight, 0, 0);
}
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
agent.angle += rotation;
trailBelow += trail;
if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
agent.generation = settings.isNextGenerationOdd;
// clear trail map below so the agent won't die immediately
// trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
// trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
} else {
let relativeWeight = mix(trailBelow.g - trailBelow.r, trailBelow.r - trailBelow.g, isFromOddGeneration);
if (relativeWeight > 0 && (
(isFromCurrentGeneration == 1.0 && trailBelow.a == 0 && random.b < settings.infectionProbability)
|| (isFromCurrentGeneration == 0.0 && trailBelow.a > 0)
)) || (trailBelow.a > 0 && isFromCurrentGeneration == 0.0){
// trailBelow.r = isFromOddGeneration * (trailBelow.r + trailBelow.g);
// trailBelow.g = (1 - isFromOddGeneration) * (trailBelow.r + trailBelow.g);
agent.generation = (agent.generation + 1) % 2;
let position = agents[id].position;
let angle = agents[id].angle;
var targetPosition = vec2<f32>(-1.0, -1.0);
var hasIntroTarget = false;
if settings.introProgress < settings.introProgressCutoff {
targetPosition = agents[id].targetPosition;
hasIntroTarget = targetPosition.x >= 0.0 && targetPosition.y >= 0.0;
if hasIntroTarget && settings.introProgress < agents[id].introDelay {
return;
}
}
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agent.position = nextPosition;
agents[id] = agent;
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
var movement = AgentMovement(0.0, vec2<f32>(0.0, 0.0));
if hasIntroTarget {
movement = intro_decide(id, position, angle, targetPosition, randomSeed);
} else {
movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
}
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
}
fn sense(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4<f32> {
let sensorAngle = agentAngle + sensorOffsetAngle;
let sensorPosition = vec2<i32>(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
return textureLoad(trailMapIn, sensorPosition, 0);
// Steady-state-only entry point used after introProgress >= introProgressCutoff.
// Drops the intro target reads, atan2/smoothstep math, and introDelay check —
// once intro completes those paths are dead for the rest of the session.
@compute @workgroup_size(agentWorkgroupSize)
fn mainSteady(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id);
if id >= settings.agentCount {
return;
}
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
let position = agents[id].position;
let angle = agents[id].angle;
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
}
fn steady_decide(
position: vec2<f32>,
angle: f32,
reactionMask: vec3<f32>,
randomSeed: u32,
maxPosition: vec2<f32>
) -> AgentMovement {
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
return AgentMovement(rotation, direction * settings.moveRate);
}
fn intro_decide(
id: u32,
position: vec2<f32>,
angle: f32,
targetPosition: vec2<f32>,
randomSeed: u32
) -> AgentMovement {
let introTargetOffset = targetPosition - position;
let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
max(
settings.introNearDistanceMin,
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
),
introTargetDistance
);
let desiredAngle = mix(
targetAngle,
agents[id].targetAngle,
nearTitle * settings.introTargetAngleBlend
);
let introTurn = angle_delta(angle, desiredAngle);
let rotation = clamp(
introTurn,
-settings.turnRate * settings.introTurnRateMultiplier,
settings.turnRate * settings.introTurnRateMultiplier
)
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
let moveRate = min(settings.introMoveRate, introTargetDistance);
var step = vec2<f32>(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
return AgentMovement(rotation, step);
}
fn agent_finalize(
id: u32,
position: vec2<f32>,
angle: f32,
channelMask: vec3<f32>,
randomSeed: u32,
maxPosition: vec2<f32>,
movement: AgentMovement
) {
let nextPosition = clamp(position + movement.step, vec2<f32>(0, 0), maxPosition);
var rotation = movement.rotation;
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
}
// Writes only this agent's last-writer-wins deposit into a per-frame-cleared
// depositMap. Storage textures do not blend concurrent compute writes, so
// overlapping agents intentionally collapse to whichever write wins. The
// diffusion pass then sums trailMap + depositMap at tile-load time.
textureStore(
trailMapOut,
vec2<i32>(nextPosition),
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
);
agents[id].angle = angle + rotation;
agents[id].position = nextPosition;
}
fn sensor_position(
agentPosition: vec2<f32>,
direction: vec2<f32>,
sensorOffset: f32,
maxPosition: vec2<f32>
) -> vec2<i32> {
return vec2<i32>(clamp(
agentPosition + direction * sensorOffset,
vec2<f32>(0, 0),
maxPosition
));
}
fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<f32> {
return vec2<f32>(
direction.x * angleCos - direction.y * angleSin,
direction.x * angleSin + direction.y * angleCos
);
}
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
}
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
}
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
// Wraps to (-π, π] via fract(); replaces atan2(sin(d), cos(d)).
return (fract((targetAngle - sourceAngle) * INV_TAU + 0.5) - 0.5) * TAU;
}
fn random_seed(id: u32) -> u32 {
return id * 747796405u + settings.randomTimeSeed * 2891336453u;
}
fn random_float(seed: u32) -> f32 {
return f32(hash_u32(seed) >> 8u) * (1.0 / 16777216.0);
}
fn hash_u32(seed: u32) -> u32 {
let value = seed * 747796405u + 2891336453u;
let word = ((value >> ((value >> 28u) + 4u)) ^ value) * 277803737u;
return (word >> 22u) ^ word;
}

View file

@ -1,261 +1,206 @@
import { vec2 } from 'gl-matrix';
import { clamp } from '../../utils/clamp';
import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import {
LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
LINE_SEGMENT_VERTICES,
LineSegmentBuffer,
} from '../common/line-segment-buffer';
import lineSegmentShader from '../common/line-segment.wgsl?raw';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './brush.wgsl?raw';
export class BrushPipeline {
private static readonly UNIFORM_COUNT = 2;
private static readonly MAX_LINE_COUNT = 20;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
export interface BrushSettings {
brushSize: number;
brushAlpha: number;
brushDiscardThreshold: number;
brushGrainNoiseScale: number;
brushGrainNoiseOffsetX: number;
brushGrainNoiseOffsetY: number;
brushGrainMinStrength: number;
brushGrainMaxStrength: number;
}
interface BrushParameters extends BrushSettings {
internalRenderAreaMegapixels: number;
pixelRatio?: number;
selectedColorIndex: number;
}
export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
? pixelRatio
: 1;
const UNIFORM_COUNT = 16;
const setBrushUniformValues = (
target: Float32Array,
{
brushSize,
brushAlpha,
brushDiscardThreshold,
brushGrainNoiseScale,
brushGrainNoiseOffsetX,
brushGrainNoiseOffsetY,
brushGrainMinStrength,
brushGrainMaxStrength,
internalRenderAreaMegapixels,
selectedColorIndex,
pixelRatio,
}: BrushParameters
): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius =
(getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
safePixelRatio) /
2;
target[0] = brushRadius;
target[1] = brushRadius * brushRadius;
// target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader.
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[9] = brushGrainNoiseOffsetX;
target[10] = brushGrainNoiseOffsetY;
target[11] = brushDiscardThreshold;
target[12] = brushGrainMinStrength;
target[13] = brushGrainMaxStrength;
};
export class BrushPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
private linePoints: Array<vec2> = [];
private actualPoints: Array<vec2> = [];
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly segments: LineSegmentBuffer;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout);
this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
this.vertexBuffer = device.createBuffer({
size:
BrushPipeline.MAX_LINE_COUNT *
BrushPipeline.VERTICES_PER_LINE_SEGMENT *
BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT *
Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' },
},
],
});
this.pipeline = device.createRenderPipeline({
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
lineSegmentShader,
shader
);
this.renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, CommonState.shaderCode, shader),
module: shaderModule,
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
},
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
module: shaderModule,
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
format: TRAIL_SOURCE_TEXTURE_FORMAT,
blend: {
color: {
operation: 'add',
srcFactor: 'zero',
dstFactor: 'one',
},
alpha: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
},
},
],
},
primitive: {
topology: 'triangle-list',
},
primitive: { topology: 'triangle-list' },
});
this.uniforms = this.device.createBuffer({
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.bindGroup = this.device.createBindGroup({
this.bindGroup = device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
],
entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
});
}
public addSwipe(position: vec2) {
this.linePoints.push(position);
public addSwipeSegment(from: vec2, to: vec2): void {
this.segments.add(from, to);
}
public clearSwipes() {
this.linePoints.length = 0;
public clearSwipes(): void {
this.segments.clear();
}
public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
this.device.queue.writeBuffer(
public setParameters(parameters: BrushParameters): void {
setBrushUniformValues(this.uniformValues, parameters);
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
);
this.actualPoints = this.linePoints.slice();
this.linePoints.splice(0, this.linePoints.length - 1);
if (this.actualPoints.length === 0) {
return;
}
if (this.actualPoints.length === 1) {
this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
}
if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
}
this.device.queue.writeBuffer(
this.vertexBuffer,
0,
new Float32Array(
new Array(this.lineCount).fill(0).flatMap((_, i) => {
const from = this.actualPoints[i];
const to = this.actualPoints[i + 1];
const [a, b, c, d] = this.getSegmentBoundingBox(from, to, brushSize / 2);
return [a, b, c, b, c, d].flatMap((v) => [...v, ...from, ...to]);
})
)
this.uniformValues,
this.uniformCache
);
this.segments.flush();
}
private static subsampleLinePoints(points: Array<vec2>): Array<vec2> {
const lines = [];
for (let i = 0; i < points.length - 2; i++) {
lines.push({
from: points[i],
to: points[i + 1],
length: vec2.dist(points[i], points[i + 1]),
});
public executeSource(
commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites
): boolean {
const lineCount = this.segments.activeCount;
if (lineCount === 0) {
return false;
}
const sumLength = lines.reduce((sum, line) => sum + line.length, 0);
let currentLineIndex = 0;
let lineLengthSoFar = 0;
const result: Array<vec2> = [points[0]];
for (let i = 1; i < BrushPipeline.MAX_LINE_COUNT; i++) {
const t = (i * sumLength) / (BrushPipeline.MAX_LINE_COUNT + 1);
while (lineLengthSoFar + lines[currentLineIndex].length < t) {
lineLengthSoFar += lines[currentLineIndex].length;
currentLineIndex++;
}
const line = lines[currentLineIndex];
const position = vec2.lerp(
vec2.create(),
line.from,
line.to,
(t - lineLengthSoFar) / line.length
);
result.push(position);
}
result.push(points[points.length - 1]);
return result;
}
private getSegmentBoundingBox(from: vec2, to: vec2, width: number): Array<vec2> {
let dir = vec2.sub(vec2.create(), to, from);
vec2.normalize(dir, dir);
if (vec2.len(dir) === 0) {
dir = vec2.fromValues(1, 0); // allow single point swipes
}
const perp = vec2.fromValues(dir[1], -dir[0]);
vec2.scale(dir, dir, width);
vec2.scale(perp, perp, width);
const offsetStart = vec2.sub(vec2.create(), from, dir);
const offsetEnd = vec2.add(vec2.create(), to, dir);
return [
vec2.add(vec2.create(), offsetStart, perp),
vec2.sub(vec2.create(), offsetStart, perp),
vec2.add(vec2.create(), offsetEnd, perp),
vec2.sub(vec2.create(), offsetEnd, perp),
];
}
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: trailMapOut,
loadOp: 'load',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
recordBrushPassForE2e();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
timestampWrites,
});
passEncoder.setPipeline(this.renderPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end();
return true;
}
public destroy() {
this.vertexBuffer.destroy();
public destroy(): void {
this.segments.destroy();
this.uniforms.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
},
],
};
}
private get lineCount() {
return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
}
}
const recordBrushPassForE2e = (): void => {
if (typeof window === 'undefined') {
return;
}
const state = window as Window & { __fleetingGardenBrushPasses?: number };
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
};

View file

@ -1,4 +0,0 @@
export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
}

View file

@ -1,6 +1,18 @@
const SEGMENT_LENGTH_EPSILON: f32 = 0.0001;
struct Settings {
brushSize: f32,
brushSizeVariation: f32
brushRadius: f32,
brushRadiusSquared: f32,
// padding to 16-byte alignment for the following vec4
_pad0: f32,
_pad1: f32,
brushValue: vec4<f32>,
brushGrainNoiseScale: f32,
brushGrainNoiseOffsetX: f32,
brushGrainNoiseOffsetY: f32,
brushDiscardThreshold: f32,
brushGrainMinStrength: f32,
brushGrainMaxStrength: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -8,41 +20,100 @@ struct Settings {
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct BrushTargets {
@location(0) source: vec4<f32>,
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@builtin(vertex_index) vertexIndex: u32,
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > SEGMENT_LENGTH_EPSILON {
inverseLengthSquared = 1.0 / denominator;
normalizedDirection = direction * inverseSqrt(denominator);
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.brushRadius);
let uv = screenPosition / state.size;
let position = uv * 2.0 - 1.0;
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
var distance = distanceFromLine(screenPosition, start, end);
let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
distance += noise.r * settings.brushSizeVariation;
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
if(distance > settings.brushSize) {
discard;
}
if(strength < settings.brushDiscardThreshold) {
discard;
}
return vec4(0, 0, 0, 1);
let color = brushOutput(strength);
return BrushTargets(color);
}
fn distanceFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;
let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
return length(pa - direction * q);
fn brushStrength(
screenPosition: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let distanceSquared = distance_squared_from_segment(
screenPosition,
start,
direction,
inverseLengthSquared
);
if distanceSquared > settings.brushRadiusSquared {
return 0.0;
}
let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength);
if maxGrainStrength < settings.brushDiscardThreshold {
return 0.0;
}
// smoothstep(0.35, 1.0, sqrt(d²/r²)) reparameterized to squared distance:
// squaring the edges gives smoothstep(0.1225·r², r², d²), avoiding the sqrt.
let safeRadiusSquared = max(settings.brushRadiusSquared, 0.0001);
let feather = 1.0 - smoothstep(0.1225 * safeRadiusSquared, safeRadiusSquared, distanceSquared);
if feather <= 0.0 {
return 0.0;
}
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
return settings.brushGrainMinStrength * feather;
}
let grainNoise = textureSampleLevel(
noise,
noiseSampler,
screenPosition * settings.brushGrainNoiseScale +
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0
).r;
let grainStrength = mix(
settings.brushGrainMinStrength,
settings.brushGrainMaxStrength,
grainNoise
);
return grainStrength * feather;
}
fn brushOutput(strength: f32) -> vec4<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}

View file

@ -1,12 +1,21 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
private readonly uniforms: GPUBuffer;
private readonly noise: GPUTextureView;
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite(
CommonState.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly noise: GPUTexture;
private readonly bindGroup: GPUBindGroup;
public readonly bindGroupLayout: GPUBindGroupLayout;
@ -14,10 +23,9 @@ export class CommonState {
public static readonly shaderCode = /* wgsl */ `
struct State {
size: vec2<f32>,
deltaTime: f32,
time: f32,
_padding: vec2<f32>,
};
@group(0) @binding(0) var<uniform> state: State;
@group(0) @binding(1) var noiseSampler: sampler;
@group(0) @binding(2) var noise: texture_2d<f32>;
@ -29,11 +37,12 @@ export class CommonState {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.noise = generateNoise({
const noise = generateNoise({
device,
width: 2048,
height: 2048,
width: appConfig.pipelines.common.noiseTextureSize,
height: appConfig.pipelines.common.noiseTextureSize,
});
this.noise = noise.texture;
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
@ -74,31 +83,28 @@ export class CommonState {
{
binding: 1,
resource: this.device.createSampler({
addressModeU: 'repeat',
addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: this.noise,
resource: noise.view,
},
],
});
}
public setParameters({
canvasSize,
deltaTime,
time,
}: {
canvasSize: vec2;
deltaTime: number;
time: number;
}) {
this.device.queue.writeBuffer(
public setParameters({ canvasSize }: { canvasSize: vec2 }) {
this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1];
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([...canvasSize, deltaTime, time])
this.uniformValues,
this.uniformCache
);
}
@ -108,5 +114,6 @@ export class CommonState {
public destroy() {
this.uniforms.destroy();
this.noise.destroy();
}
}

View file

@ -0,0 +1,92 @@
import { vec2 } from 'gl-matrix';
export interface LineSegment {
from: vec2;
to: vec2;
}
export const LINE_SEGMENT_VERTICES = 6;
const LINE_SEGMENT_ATTRIBUTES = 4;
export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = {
arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES,
stepMode: 'instance',
attributes: [
{ shaderLocation: 0, format: 'float32x2', offset: 0 },
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
],
};
export class LineSegmentBuffer {
public readonly vertexBuffer: GPUBuffer;
private readonly device: GPUDevice;
private readonly maxSegments: number;
private readonly uploadData: Float32Array;
private pending: Array<LineSegment> = [];
private active: Array<LineSegment> = [];
public constructor(device: GPUDevice, maxSegments: number) {
this.device = device;
this.maxSegments = maxSegments;
this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES);
this.vertexBuffer = device.createBuffer({
size: this.uploadData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
}
public add(from: vec2, to: vec2): void {
this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) });
}
public clear(): void {
this.pending.length = 0;
this.active.length = 0;
}
public get activeCount(): number {
return this.active.length;
}
public flush(): void {
this.active = this.pending.slice();
this.pending.length = 0;
if (this.active.length === 0) {
return;
}
if (this.active.length > this.maxSegments) {
this.active = subsample(this.active, this.maxSegments);
}
let offset = 0;
for (const segment of this.active) {
this.uploadData[offset++] = segment.from[0];
this.uploadData[offset++] = segment.from[1];
this.uploadData[offset++] = segment.to[0];
this.uploadData[offset++] = segment.to[1];
}
this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset);
}
public destroy(): void {
this.vertexBuffer.destroy();
}
}
const subsample = (segments: Array<LineSegment>, count: number): Array<LineSegment> => {
const result: Array<LineSegment> = [];
for (let i = 0; i < count; i++) {
const index = Math.round((i * (segments.length - 1)) / (count - 1));
result.push(segments[index]);
}
return result;
};

View file

@ -0,0 +1,35 @@
// Six corners forming two triangles for an instanced segment quad.
// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular.
fn segment_vertex_corner(index: u32) -> vec2<f32> {
let isRight = index == 2u || index >= 4u;
let isTop = index == 0u || index == 2u || index == 4u;
return vec2<f32>(
select(-1.0, 1.0, isRight),
select(-1.0, 1.0, isTop)
);
}
fn segment_vertex_position(
vertexIndex: u32,
start: vec2<f32>,
end: vec2<f32>,
direction: vec2<f32>,
radius: f32
) -> vec2<f32> {
let perpendicular = vec2<f32>(direction.y, -direction.x);
let corner = segment_vertex_corner(vertexIndex % 6u);
let center = mix(start, end, (corner.x + 1.0) * 0.5);
return center + direction * corner.x * radius + perpendicular * corner.y * radius;
}
fn distance_squared_from_segment(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let pa = position - start;
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -1,165 +0,0 @@
import { vec2 } from 'gl-matrix';
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './copy.wgsl?raw';
export class CopyPipeline {
private static readonly UNIFORM_COUNT = 2;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
this.uniforms = this.device.createBuffer({
size: CopyPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.vertexBuffer = device.createBuffer({
size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2<f32>
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
// prettier-ignore
const vertexData = [
// U V
0.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 0.0,
];
new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData);
this.vertexBuffer.unmap();
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, shader),
entryPoint: 'vertex',
buffers: [
{
arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex',
attributes: [
{
shaderLocation: 0,
offset: 0,
format: 'float32x2',
},
],
},
],
},
fragment: {
module: smartCompile(device, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
scale: vec2 = vec2.fromValues(1, 1)
) {
this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: trailMapOut,
loadOp: 'clear',
storeOp: 'store',
},
],
};
this.ensureBindGroupExists(trailMapIn);
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
}
public destroy() {
this.vertexBuffer.destroy();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
if (this.previousTrailMapIn !== trailMapIn) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.previousTrailMapIn = trailMapIn;
}
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
},
},
],
};
}
}

View file

@ -1,19 +0,0 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vertex(@location(0) uv: vec2<f32>) -> VertexOutput {
let ndc = uv * sourceScaler * vec2(2) - vec2(1);
return VertexOutput(vec4(ndc.x, -ndc.y, 0, 1), uv);
}
@group(0) @binding(0) var<uniform> sourceScaler: vec2<f32>;
@group(0) @binding(1) var Sampler: sampler;
@group(0) @binding(2) var original: texture_2d<f32>;
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return textureSample(original, Sampler, uv);
}

View file

@ -1,48 +1,171 @@
struct Settings {
inverseDiffusionRateTrails: f32,
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborScale: f32,
brushDecayAlphaMultiplier: f32,
brushDecayAlphaSubtract: f32,
padding0: f32,
padding1: f32,
padding2: f32,
};
const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
const WORKGROUP_SIZE_Y = __WORKGROUP_SIZE__u;
// Half a quantization step of rgba8unorm (1/255 ≈ 0.00392). Subtracted from
// RGB each frame so multiplicative decay can fall through the unorm
// quantization floor; without it, the smallest nonzero level (1/255) is a
// fixed point and trails never reach pure black.
const TRAIL_RGB_DECAY_SUBTRACT: f32 = 0.00196;
// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
// can be served from workgroup memory without bounds checks for interior tiles.
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
// 1.0 / 2^32, used to map a 32-bit hash to [0, 1).
const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d<f32>;
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
// Per-frame deposit accumulator written sparsely by agents. Summed with
// trailMap at tile-load so deposits propagate through the diffusion kernel
// in the same frame.
@group(0) @binding(3) var depositMap: texture_2d<f32>;
var<workgroup> tile: array<vec4<f32>, TILE_TEXEL_COUNT>;
var<workgroup> tileTrailStrength: array<f32, TILE_TEXEL_COUNT>;
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
var current = textureSample(trailMap, Sampler, uv);
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(workgroup_id) workgroup_id: vec3<u32>
) {
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
let textureBound = textureSize - vec2<i32>(1, 1);
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
current += (
propagate(uv, vec2(-1.0, -1.0), current)
+ propagate(uv, vec2(-1.0, 1.0), current)
+ propagate(uv, vec2(1.0, -1.0), current)
+ propagate(uv, vec2(1.0, 1.0), current)
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
let sourcePixel = clamp(
vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
vec2<i32>(0, 0),
textureBound
);
let texel = textureLoad(trailMap, sourcePixel, 0)
+ textureLoad(depositMap, sourcePixel, 0);
tile[tileIndex] = texel;
tileTrailStrength[tileIndex] = length(texel.rgb);
}
+ propagate(uv, vec2(-1.0, 0.0), current)
+ propagate(uv, vec2(0.0, -1.0), current)
+ propagate(uv, vec2(1.0, 0.0), current)
+ propagate(uv, vec2(0.0, 1.0), current)
) / 8;
workgroupBarrier();
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
return;
}
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
let rowNorth = c - TILE_SIZE_X;
let rowSouth = c + TILE_SIZE_X;
// Batch-load all 8 neighbour texels and strengths into registers up front
// so the compiler can schedule LDS reads in parallel.
let current = tile[c];
let nTL = tile[rowNorth - 1u];
let nT = tile[rowNorth];
let nTR = tile[rowNorth + 1u];
let nL = tile[c - 1u];
let nR = tile[c + 1u];
let nBL = tile[rowSouth - 1u];
let nB = tile[rowSouth];
let nBR = tile[rowSouth + 1u];
let sTL = tileTrailStrength[rowNorth - 1u];
let sT = tileTrailStrength[rowNorth];
let sTR = tileTrailStrength[rowNorth + 1u];
let sL = tileTrailStrength[c - 1u];
let sR = tileTrailStrength[c + 1u];
let sBL = tileTrailStrength[rowSouth - 1u];
let sB = tileTrailStrength[rowSouth];
let sBR = tileTrailStrength[rowSouth + 1u];
let random = random_from_pixel(pixel);
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
let propagated =
propagate_value(nTL, sTL, current, trailWeight)
+ propagate_value(nT, sT, current, trailWeight)
+ propagate_value(nTR, sTR, current, trailWeight)
+ propagate_value(nL, sL, current, trailWeight)
+ propagate_value(nR, sR, current, trailWeight)
+ propagate_value(nBL, sBL, current, trailWeight)
+ propagate_value(nB, sB, current, trailWeight)
+ propagate_value(nBR, sBR, current, trailWeight);
let updated = current + propagated * settings.diffusionNeighborScale;
let decayed = clamp(vec4(
current.rgb * settings.decayRateTrails,
max(0, current.a + (current.a - 1.001) * settings.decayRateBrush)
updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
), vec4(0), vec4(1));
return decayed;
textureStore(trailMapOut, pixel, decayed);
}
fn propagate(uv: vec2<f32>, offset: vec2<f32>, currentColor: vec4<f32>) -> vec4<f32> {
let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
fn propagate_value(
neighbour: vec4<f32>,
neighbourStrength: f32,
current: vec4<f32>,
trailWeight: f32
) -> vec4<f32> {
let difference = clamp(neighbour - current, vec4(0), vec4(1));
return vec4(
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
vec3(neighbourStrength * trailWeight),
neighbour.a * trailWeight
) * difference;
}
fn random_from_pixel(pixel: vec2<i32>) -> f32 {
let p = vec2<u32>(pixel);
var hash = p.x * 1664525u + p.y * 1013904223u + 374761393u;
hash = (hash ^ (hash >> 16u)) * 2246822519u;
hash = (hash ^ (hash >> 13u)) * 3266489917u;
hash = hash ^ (hash >> 16u);
return f32(hash) * HASH_TO_UNIT_FLOAT;
}
// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16)
// so we can vary diffusion sharpness without paying for a real pow() per pixel.
fn diffusion_weight(
r: f32,
inverseRate: f32
) -> f32 {
if inverseRate < 1.0 {
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
return mix(
rootApproximation,
r,
clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
);
}
let r2 = r * r;
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
let r4 = r2 * r2;
if inverseRate < 4.0 {
// (inverseRate - 2.0) / (4.0 - 2.0)
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
let r8 = r4 * r4;
if inverseRate < 8.0 {
// (inverseRate - 4.0) / (8.0 - 4.0)
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
}
let r16 = r8 * r8;
// (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate.
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
* min(1.0, 16.0 / inverseRate);
}

View file

@ -1,47 +1,109 @@
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
decayRateBrush: number;
diffusionDecayRateDivisor: number;
diffusionNeighborDivisor: number;
brushDecayAlphaOffset: number;
}
type DiffusionUniformSettings = Pick<
DiffusionSettings,
| 'diffusionRateTrails'
| 'decayRateTrails'
| 'decayRateBrush'
| 'diffusionDecayRateDivisor'
| 'diffusionNeighborDivisor'
| 'brushDecayAlphaOffset'
>;
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
(Number.isFinite(diffusionRate) &&
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
? diffusionRate
: appConfig.pipelines.diffusion.minDiffusionRate);
const setDiffusionUniformValues = (
target: Float32Array,
{
diffusionRateTrails,
decayRateTrails,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
brushDecayAlphaOffset,
}: DiffusionUniformSettings
): void => {
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
const brushDecayRate = decayRateBrush / decayDivisor;
const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
? Math.max(1, diffusionNeighborDivisor)
: 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor;
target[2] = 1 / neighborDivisor;
target[3] = 1 + brushDecayRate;
target[4] = brushDecayAlphaOffset * brushDecayRate;
target[5] = 0;
target[6] = 0;
target[7] = 0;
};
export class DiffusionPipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly WORKGROUP_SIZE = 16;
private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
// 1x1 zero texture used as the depositMap binding when callers don't supply
// one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
// out-of-bounds coordinates, so the diffusion shader sums in zeros.
private readonly emptyDepositTexture: GPUTexture;
private readonly emptyDepositTextureView: GPUTextureView;
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite(
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly getBindGroup = createBindGroupCache<
[GPUTextureView, GPUTextureView, GPUTextureView]
>((trailMapIn, trailMapOut, depositMap) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: trailMapIn },
{ binding: 2, resource: trailMapOut },
{ binding: 3, resource: depositMap },
],
})
);
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(
DiffusionPipeline.bindGroupLayout
);
const { buffer, vertex } = setUpFullScreenQuad(device);
this.vertexBuffer = buffer;
this.pipeline = device.createRenderPipeline({
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex,
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-strip',
compute: {
module: smartCompile(device, this.shaderCode),
entryPoint: 'main',
},
});
@ -49,85 +111,81 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.emptyDepositTexture = device.createTexture({
format: TRAIL_SOURCE_TEXTURE_FORMAT,
size: { width: 1, height: 1 },
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
this.emptyDepositTextureView = this.emptyDepositTexture.createView();
const clearEncoder = device.createCommandEncoder();
const clearPass = clearEncoder.beginRenderPass({
colorAttachments: [
{
view: this.emptyDepositTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
clearPass.end();
device.queue.submit([clearEncoder.finish()]);
}
public setParameters({
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
brushDecayAlphaOffset,
}: DiffusionSettings) {
this.device.queue.writeBuffer(
setDiffusionUniformValues(this.uniformValues, {
diffusionRateTrails,
decayRateTrails,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
brushDecayAlphaOffset,
});
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
1 / diffusionRateTrails,
decayRateTrails / 1000,
1 / diffusionRateBrush,
decayRateBrush / 1000,
])
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
trailMapOut: GPUTextureView,
size: vec2,
depositMap: GPUTextureView | null,
timestampWrites?: GPUComputePassTimestampWrites
) {
this.ensureBindGroupExists(trailMapIn);
const bindGroup = this.getBindGroup(
trailMapIn,
trailMapOut,
depositMap ?? this.emptyDepositTextureView
);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: trailMapOut,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.pipeline);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.draw(4, 1);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(
Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
if (this.previousTrailMapIn !== trailMapIn) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.previousTrailMapIn = trailMapIn;
}
}
public destroy() {
this.vertexBuffer.destroy();
this.uniforms.destroy();
this.emptyDepositTexture.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
@ -135,21 +193,29 @@ export class DiffusionPipeline {
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: 'write-only',
format: TRAIL_SOURCE_TEXTURE_FORMAT,
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
@ -157,4 +223,11 @@ export class DiffusionPipeline {
],
};
}
private get shaderCode(): string {
return shader.replaceAll(
'__WORKGROUP_SIZE__',
DiffusionPipeline.WORKGROUP_SIZE.toString()
);
}
}

View file

@ -1,6 +0,0 @@
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
}

View file

@ -0,0 +1,208 @@
import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from '../agents/agent-dispatch';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import shader from './eraser-agent.wgsl?raw';
interface Bounds {
maxX: number;
maxY: number;
minX: number;
minY: number;
}
export class EraserAgentPipeline {
private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedBufferWrite(
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
(agentsBuffer, eraserMask) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: agentsBuffer } },
{ binding: 2, resource: eraserMask },
],
})
);
private pendingSegmentCount = 0;
private activeSegmentCount = 0;
private pendingBounds: Bounds | null = null;
private agentCount = 0;
private readonly workgroupSize: number;
public constructor(
private readonly device: GPUDevice,
private readonly getAgentsBuffer: () => GPUBuffer
) {
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
],
});
this.uniforms = this.device.createBuffer({
size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
shader
),
entryPoint: 'main',
},
});
}
public addSwipeSegment(from: vec2, to: vec2): void {
this.pendingSegmentCount += 1;
this.pendingBounds = includeSegment(this.pendingBounds, from, to);
}
public clearSwipes(): void {
this.pendingSegmentCount = 0;
this.activeSegmentCount = 0;
this.pendingBounds = null;
}
public setParameters({
agentCount,
eraserMaskAlphaThreshold,
eraserSize,
maskSize,
}: {
agentCount: number;
eraserMaskAlphaThreshold: number;
eraserSize: number;
maskSize: vec2;
}): void {
this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount;
const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize);
this.pendingSegmentCount = 0;
this.pendingBounds = null;
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
this.uniformValues[4] = activeBounds.minX;
this.uniformValues[5] = activeBounds.minY;
this.uniformValues[6] = activeBounds.maxX;
this.uniformValues[7] = activeBounds.maxY;
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
}
public hasActiveMask(): boolean {
return this.activeSegmentCount > 0;
}
public execute(
commandEncoder: GPUCommandEncoder,
eraserMask: GPUTextureView,
timestampWrites?: GPUComputePassTimestampWrites
): void {
if (!this.hasActiveMask() || this.agentCount === 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end();
}
public destroy(): void {
this.uniforms.destroy();
}
}
const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => {
const minX = Math.min(from[0], to[0]);
const minY = Math.min(from[1], to[1]);
const maxX = Math.max(from[0], to[0]);
const maxY = Math.max(from[1], to[1]);
if (!bounds) {
return { maxX, maxY, minX, minY };
}
return {
maxX: Math.max(bounds.maxX, maxX),
maxY: Math.max(bounds.maxY, maxY),
minX: Math.min(bounds.minX, minX),
minY: Math.min(bounds.minY, minY),
};
};
const expandBoundsToMask = (
bounds: Bounds | null,
radius: number,
maskSize: vec2
): Bounds => {
const maxX = Math.max(0, maskSize[0] - 1);
const maxY = Math.max(0, maskSize[1] - 1);
if (!bounds) {
return { maxX, maxY, minX: 0, minY: 0 };
}
return {
maxX: Math.min(maxX, bounds.maxX + radius),
maxY: Math.min(maxY, bounds.maxY + radius),
minX: Math.max(0, bounds.minX - radius),
minY: Math.max(0, bounds.minY - radius),
};
};

View file

@ -0,0 +1,44 @@
struct Settings {
agentCount: u32,
eraserMaskAlphaThreshold: f32,
maskWidth: u32,
maskHeight: u32,
boundsMin: vec2<f32>,
boundsMax: vec2<f32>,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id);
if id >= settings.agentCount {
return;
}
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
let position = agents[id].position;
if any(position < settings.boundsMin) || any(position > settings.boundsMax) {
return;
}
let maskSize = vec2<i32>(i32(settings.maskWidth), i32(settings.maskHeight));
let maskPosition = clamp(
vec2<i32>(position),
vec2<i32>(0, 0),
maskSize - vec2<i32>(1, 1)
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
if maskSample.r < settings.eraserMaskAlphaThreshold {
agents[id].colorIndex = -1.0;
}
}

View file

@ -0,0 +1,186 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import {
LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
LINE_SEGMENT_VERTICES,
LineSegmentBuffer,
} from '../common/line-segment-buffer';
import lineSegmentShader from '../common/line-segment.wgsl?raw';
import {
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
} from '../texture-formats';
import shader from './eraser-texture.wgsl?raw';
interface EraserTextureParameters {
eraserSize: number;
eraserLineDistanceEpsilon: number;
eraserClearRed: number;
eraserClearGreen: number;
eraserClearBlue: number;
eraserClearAlpha: number;
}
const UNIFORM_COUNT = 8;
const TARGET_FORMATS: Array<GPUTextureFormat> = [
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
];
export class EraserTexturePipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly segments: LineSegmentBuffer;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
this.segments = new LineSegmentBuffer(
device,
appConfig.pipelines.eraser.maxTextureLineCount
);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' },
},
],
});
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
lineSegmentShader,
shader
);
this.combinedPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: shaderModule,
entryPoint: 'vertex',
buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
},
fragment: {
module: shaderModule,
entryPoint: 'fragmentCombined',
targets: TARGET_FORMATS.map((format) => ({ format })),
},
primitive: { topology: 'triangle-list' },
});
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = device.createBindGroup({
layout: this.bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
});
}
public addSwipeSegment(from: vec2, to: vec2): void {
this.segments.add(from, to);
}
public clearSwipes(): void {
this.segments.clear();
}
public setParameters({
eraserSize,
eraserLineDistanceEpsilon,
eraserClearRed,
eraserClearGreen,
eraserClearBlue,
eraserClearAlpha,
}: EraserTextureParameters): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius * eraserRadius;
this.uniformValues[1] = eraserLineDistanceEpsilon;
this.uniformValues[2] = eraserClearRed;
this.uniformValues[3] = eraserClearGreen;
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = eraserRadius;
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
this.segments.flush();
}
public executeCombined(
commandEncoder: GPUCommandEncoder,
eraserMaskOut: GPUTextureView,
sourceMapOut: GPUTextureView,
trailMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites
): void {
const lineCount = this.segments.activeCount;
if (lineCount === 0) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: eraserMaskOut,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
timestampWrites,
});
passEncoder.end();
return;
}
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: eraserMaskOut,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' },
{ view: trailMapOut, loadOp: 'load', storeOp: 'store' },
],
timestampWrites,
});
passEncoder.setPipeline(this.combinedPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end();
}
public destroy(): void {
this.segments.destroy();
this.uniforms.destroy();
}
}

View file

@ -0,0 +1,79 @@
struct Settings {
eraserRadiusSquared: f32,
lineDistanceEpsilon: f32,
clearRed: f32,
clearGreen: f32,
clearBlue: f32,
clearAlpha: f32,
eraserRadius: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct EraserCombinedTargets {
@location(0) mask: vec4<f32>,
@location(1) source: vec4<f32>,
@location(2) trail: vec4<f32>,
}
@vertex
fn vertex(
@builtin(vertex_index) vertexIndex: u32,
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > settings.lineDistanceEpsilon {
inverseLengthSquared = 1.0 / denominator;
normalizedDirection = direction * inverseSqrt(denominator);
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.eraserRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragmentCombined(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets {
let distanceSquared = distance_squared_from_segment(
screenPosition,
start,
direction,
inverseLengthSquared
);
if distanceSquared > settings.eraserRadiusSquared {
discard;
}
let cleared = getEraserClearValue();
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared);
}
fn getEraserMaskValue() -> vec4<f32> {
return vec4<f32>(settings.clearAlpha, 0.0, 0.0, 1.0);
}
fn getEraserClearValue() -> vec4<f32> {
return vec4<f32>(
settings.clearRed,
settings.clearGreen,
settings.clearBlue,
settings.clearAlpha
);
}

View file

@ -1,162 +1,220 @@
import { vec3 } from 'gl-matrix';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import shader from './render.wgsl?raw';
export class RenderPipeline {
private static readonly UNIFORM_COUNT = 13;
export interface RenderSettings {
clarity: number;
renderTraceNormalizationFloor: number;
renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number;
}
// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 4 scalars,
// rounded up to 20 floats for 16-byte uniform alignment.
const UNIFORM_COUNT = 20;
export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private bindGroup?: GPUBindGroup;
private previousColorTexture?: GPUTextureView;
private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
(colorTexture, sourceTexture) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 2, resource: colorTexture },
{ binding: 3, resource: sourceTexture },
],
})
);
public constructor(
private readonly context: GPUCanvasContext,
private readonly device: GPUDevice,
private readonly commonState: CommonState
private readonly canvasFormat: GPUTextureFormat
) {
this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
const { buffer, vertex } = setUpFullScreenQuad(device);
this.vertexBuffer = buffer;
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex,
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: navigator.gpu.getPreferredCanvasFormat(),
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
public setParameters({
brushColor,
evenGenerationColor,
oddGenerationColor,
clarity,
}: RenderSettings & {
brushColor: vec3;
evenGenerationColor: vec3;
oddGenerationColor: vec3;
}) {
this.device.queue.writeBuffer(
this.uniforms,
0,
new Float32Array([
...brushColor,
0, //padding
...evenGenerationColor,
0, //padding
...oddGenerationColor,
clarity,
])
);
}
public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
this.ensureBindGroupExists(colorTexture);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
private ensureBindGroupExists(colorTexture: GPUTextureView) {
if (this.previousColorTexture !== colorTexture) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: colorTexture,
},
],
});
this.previousColorTexture = colorTexture;
}
}
public destroy() {
this.vertexBuffer.destroy();
this.uniforms.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
buffer: { type: 'uniform' },
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
},
texture: { sampleType: 'float' },
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float' },
},
],
};
});
const shaderModule = smartCompile(device, shader);
const vertex = setUpFullScreenQuad(device);
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
});
this.pipeline = this.createPipeline(
pipelineLayout,
vertex,
shaderModule,
this.canvasFormat,
'fragment'
);
this.noSourcePipeline = this.createPipeline(
pipelineLayout,
vertex,
shaderModule,
this.canvasFormat,
'fragmentNoSource'
);
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
private createPipeline(
layout: GPUPipelineLayout,
vertex: GPUVertexState,
shaderModule: GPUShaderModule,
format: GPUTextureFormat,
fragmentEntryPoint: string
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout,
vertex,
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
targets: [{ format }],
},
primitive: { topology: 'triangle-list' },
});
}
public setParameters({
channelColors,
backgroundColor,
clarity,
renderTraceNormalizationFloor,
renderBrushColorBase,
renderBrushColorStrengthMultiplier,
}: RenderSettings & {
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}) {
const [a, b, c] = channelColors;
this.uniformValues[0] = rgbChannelToUnit(a[0]);
this.uniformValues[1] = rgbChannelToUnit(a[1]);
this.uniformValues[2] = rgbChannelToUnit(a[2]);
// uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding.
this.uniformValues[4] = rgbChannelToUnit(b[0]);
this.uniformValues[5] = rgbChannelToUnit(b[1]);
this.uniformValues[6] = rgbChannelToUnit(b[2]);
this.uniformValues[8] = rgbChannelToUnit(c[0]);
this.uniformValues[9] = rgbChannelToUnit(c[1]);
this.uniformValues[10] = rgbChannelToUnit(c[2]);
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
this.uniformValues[15] = clarity;
this.uniformValues[16] = renderTraceNormalizationFloor;
this.uniformValues[17] = renderBrushColorBase;
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
useSourceTexture = true,
timestampWrites?: GPURenderPassTimestampWrites
): GPUTexture {
const canvasTexture = this.context.getCurrentTexture();
this.encodePass(
commandEncoder,
colorTexture,
sourceTexture,
canvasTexture.createView(),
useSourceTexture,
timestampWrites
);
return canvasTexture;
}
public executeToView(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
outputTexture: GPUTextureView,
useSourceTexture = true,
timestampWrites?: GPURenderPassTimestampWrites
) {
this.encodePass(
commandEncoder,
colorTexture,
sourceTexture,
outputTexture,
useSourceTexture,
timestampWrites
);
}
private encodePass(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
output: GPUTextureView,
useSourceTexture: boolean,
timestampWrites?: GPURenderPassTimestampWrites
) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: output,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
timestampWrites,
});
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.draw(3, 1);
passEncoder.end();
}
private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
return useSourceTexture ? this.pipeline : this.noSourcePipeline;
}
public destroy() {
this.uniforms.destroy();
}
}

View file

@ -1,3 +0,0 @@
export interface RenderSettings {
clarity: number;
}

View file

@ -1,39 +1,158 @@
struct Settings {
brushColor: vec3<f32>,
evenGenerationColor: vec3<f32>,
oddGenerationColor: vec3<f32>,
colorA: vec3<f32>,
_colorAPadding: f32,
colorB: vec3<f32>,
_colorBPadding: f32,
colorC: vec3<f32>,
_colorCPadding: f32,
backgroundColor: vec3<f32>,
clarity: f32,
traceNormalizationFloor: f32,
brushColorBase: f32,
brushColorStrengthMultiplier: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
const OVERLAP_SATURATION_BOOST: f32 = 1.35;
const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65;
const LOW_SATURATION_RESCUE_MIN: f32 = 0.08;
const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
const LUMA_WEIGHTS: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
@group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(2) var trailMap: texture_2d<f32>;
@group(0) @binding(3) var sourceMap: texture_2d<f32>;
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let traces = textureSample(trailMap, Sampler, uv);
let random = textureSample(noise, noiseSampler, uv);
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, getFlatBackground());
}
let backgroundColor = vec3(0.9) + 0.075 * random.r;
@fragment
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
}
let evenGenerationStrength = clarity(traces.r);
let oddGenerationStrength = clarity(traces.g);
let brushStrength = traces.a;
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
let traceStrengths = clarity(traces.rgb);
let sourceStrengths = clarity(sources.rgb);
let traceStrength = maxComponent(traceStrengths);
let brushStrength = maxComponent(sourceStrengths);
if max(traceStrength, brushStrength) <= 0.0 {
return vec4(background, 1);
}
let color = max(
mix(
evenGenerationStrength * settings.evenGenerationColor,
oddGenerationStrength * settings.oddGenerationColor,
oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
if brushStrength <= 0.0 {
let traceColor = colorFromChannelStrengths(traceStrengths);
return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
}
let strengths = max(traceStrengths, sourceStrengths);
let traceColor = colorFromChannelStrengths(strengths);
let brushColor = colorFromChannelStrengths(sourceStrengths);
let brushVisibility = clamp(
brushStrength * (
settings.brushColorBase +
brushStrength * settings.brushColorStrengthMultiplier
),
brushStrength * settings.brushColor
0,
1
);
let color = mix(traceColor, brushColor, brushVisibility);
let strength = max(maxComponent(strengths), brushVisibility);
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
}
fn maxComponent(v: vec3<f32>) -> f32 {
return max(max(v.r, v.g), v.b);
}
fn minComponent(v: vec3<f32>) -> f32 {
return min(min(v.r, v.g), v.b);
}
fn componentSum(v: vec3<f32>) -> f32 {
return v.r + v.g + v.b;
}
fn clarity(strength: vec3<f32>) -> vec3<f32> {
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
}
fn colorFromChannelStrengths(strengths: vec3<f32>) -> vec3<f32> {
if maxComponent(strengths) <= 0.0 {
return vec3<f32>(0.0);
}
let weights = colorWeights(strengths);
let color =
weights.r * settings.colorA
+ weights.g * settings.colorB
+ weights.b * settings.colorC;
return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths);
}
fn colorWeights(strengths: vec3<f32>) -> vec3<f32> {
let commonStrength = minComponent(strengths);
var weightBase = max(
strengths - vec3<f32>(commonStrength * COMMON_CHANNEL_REDUCTION),
vec3<f32>(0.0)
);
if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON {
weightBase = strengths;
}
let sharpenedWeights = weightBase * weightBase;
return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights));
}
fn preserveOverlapVibrancy(color: vec3<f32>, strengths: vec3<f32>) -> vec3<f32> {
let strongest = maxComponent(strengths);
let overlapAmount = clamp(
(componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest),
0.0,
1.0
);
let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
let luminance = dot(color, LUMA_WEIGHTS);
var vibrantColor = clamp(
vec3<f32>(luminance) +
(color - vec3<f32>(luminance)) *
mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount),
vec3<f32>(0.0),
vec3<f32>(1.0)
);
return vec4(mix(backgroundColor, color, strength), 1);
let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor);
let rescueAmount =
overlapAmount *
(1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) *
LOW_SATURATION_RESCUE_AMOUNT;
return mix(vibrantColor, dominantColor(strengths), rescueAmount);
}
fn clarity(strength: f32) -> f32 {
return pow(strength, settings.clarity);
fn dominantColor(strengths: vec3<f32>) -> vec3<f32> {
if strengths.r >= strengths.g && strengths.r >= strengths.b {
return normalizeColorIntensity(settings.colorA);
}
if strengths.g >= strengths.b {
return normalizeColorIntensity(settings.colorB);
}
return normalizeColorIntensity(settings.colorC);
}
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
let brightestChannel = maxComponent(color);
return color / max(settings.traceNormalizationFloor, brightestChannel);
}
fn getFlatBackground() -> vec3<f32> {
return clamp(settings.backgroundColor, vec3(0), vec3(1));
}

View file

@ -0,0 +1,2 @@
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;