Rework garden simulation pipelines
This commit is contained in:
parent
c2efb33683
commit
018f8c9d4d
67 changed files with 6499 additions and 1682 deletions
49
src/pipelines/agents/agent-dispatch.ts
Normal file
49
src/pipelines/agents/agent-dispatch.ts
Normal 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);
|
||||
};
|
||||
97
src/pipelines/agents/agent-generation/agent-compaction.wgsl
Normal file
97
src/pipelines/agents/agent-generation/agent-compaction.wgsl
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal file
21
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export interface GenerationCounts {
|
||||
evenGenerationCount: number;
|
||||
oddGenerationCount: number;
|
||||
}
|
||||
64
src/pipelines/agents/agent-limits.ts
Normal file
64
src/pipelines/agents/agent-limits.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export interface BrushSettings {
|
||||
brushSize: number;
|
||||
brushSizeVariation: number;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
src/pipelines/common/line-segment-buffer.ts
Normal file
92
src/pipelines/common/line-segment-buffer.ts
Normal 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;
|
||||
};
|
||||
35
src/pipelines/common/line-segment.wgsl
Normal file
35
src/pipelines/common/line-segment.wgsl
Normal 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);
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export interface DiffusionSettings {
|
||||
diffusionRateTrails: number;
|
||||
decayRateTrails: number;
|
||||
diffusionRateBrush: number;
|
||||
decayRateBrush: number;
|
||||
}
|
||||
208
src/pipelines/eraser/eraser-agent-pipeline.ts
Normal file
208
src/pipelines/eraser/eraser-agent-pipeline.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
44
src/pipelines/eraser/eraser-agent.wgsl
Normal file
44
src/pipelines/eraser/eraser-agent.wgsl
Normal 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;
|
||||
}
|
||||
}
|
||||
186
src/pipelines/eraser/eraser-texture-pipeline.ts
Normal file
186
src/pipelines/eraser/eraser-texture-pipeline.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
79
src/pipelines/eraser/eraser-texture.wgsl
Normal file
79
src/pipelines/eraser/eraser-texture.wgsl
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export interface RenderSettings {
|
||||
clarity: number;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
2
src/pipelines/texture-formats.ts
Normal file
2
src/pipelines/texture-formats.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
|
||||
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;
|
||||
Loading…
Reference in a new issue