,
This commit is contained in:
parent
70423851ba
commit
1fe5015056
55 changed files with 2036 additions and 685 deletions
|
|
@ -15,22 +15,70 @@ struct Counters {
|
|||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
||||
|
||||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||
var<workgroup> workgroupCompactedOffset: u32;
|
||||
var<workgroup> workgroupCopyCount: u32;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, workgroup_count);
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
if local_id.x == 0u {
|
||||
atomicStore(&workgroupAliveCount, 0u);
|
||||
}
|
||||
|
||||
let agent = agents[id];
|
||||
if agent.colorIndex < 0.0 {
|
||||
return;
|
||||
workgroupBarrier();
|
||||
|
||||
var localCompactedIndex = 0u;
|
||||
if id < settings.agentCount {
|
||||
let agent = agents[id];
|
||||
if agent.colorIndex >= 0.0 {
|
||||
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
||||
}
|
||||
}
|
||||
|
||||
let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
|
||||
compactedAgents[compactedIndex] = agent;
|
||||
workgroupBarrier();
|
||||
|
||||
if local_id.x == 0u {
|
||||
let groupAliveCount = atomicLoad(&workgroupAliveCount);
|
||||
if groupAliveCount > 0u {
|
||||
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount);
|
||||
} else {
|
||||
workgroupCompactedOffset = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
workgroupBarrier();
|
||||
|
||||
if id < settings.agentCount {
|
||||
let agent = agents[id];
|
||||
if agent.colorIndex >= 0.0 {
|
||||
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn copyCompactedAgents(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, workgroup_count);
|
||||
|
||||
if local_id.x == 0u {
|
||||
workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);
|
||||
}
|
||||
|
||||
workgroupBarrier();
|
||||
|
||||
if id >= workgroupCopyCount {
|
||||
return;
|
||||
}
|
||||
|
||||
agents[id] = compactedAgents[id];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CommonState } from '../../common-state/common-state';
|
||||
import { AGENT_SIZE_IN_BYTES } from './agent';
|
||||
import { AgentGenerationPipeline } from './agent-generation-pipeline';
|
||||
|
||||
const installGpuConstants = () => {
|
||||
Object.defineProperties(globalThis, {
|
||||
GPUBufferUsage: {
|
||||
configurable: true,
|
||||
value: {
|
||||
MAP_READ: 1,
|
||||
COPY_DST: 2,
|
||||
COPY_SRC: 4,
|
||||
STORAGE: 8,
|
||||
UNIFORM: 16,
|
||||
},
|
||||
},
|
||||
GPUMapMode: {
|
||||
configurable: true,
|
||||
value: {
|
||||
READ: 1,
|
||||
},
|
||||
},
|
||||
GPUShaderStage: {
|
||||
configurable: true,
|
||||
value: {
|
||||
COMPUTE: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type CopyCall = {
|
||||
source: GPUBuffer;
|
||||
sourceOffset: number;
|
||||
destination: GPUBuffer;
|
||||
destinationOffset: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type DispatchCall = {
|
||||
entryPoint: string;
|
||||
workgroups: [number, number, number];
|
||||
};
|
||||
|
||||
type FakePipeline = {
|
||||
entryPoint: string;
|
||||
};
|
||||
|
||||
class FakeBuffer {
|
||||
private readonly mappedRange: ArrayBuffer;
|
||||
|
||||
public readonly destroy = vi.fn();
|
||||
public readonly mapAsync = vi.fn(async (_mode: number) => undefined);
|
||||
public readonly getMappedRange = vi.fn(() => this.mappedRange);
|
||||
public readonly unmap = vi.fn();
|
||||
|
||||
public constructor(
|
||||
public readonly label: string,
|
||||
size: number,
|
||||
mappedValue = 0
|
||||
) {
|
||||
this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT));
|
||||
new Uint32Array(this.mappedRange)[0] = mappedValue;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeComputePass {
|
||||
private pipeline: FakePipeline | null = null;
|
||||
|
||||
public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
|
||||
this.pipeline = pipeline as unknown as FakePipeline;
|
||||
});
|
||||
public readonly setBindGroup = vi.fn(
|
||||
(_index: number, _bindGroup: GPUBindGroup) => undefined
|
||||
);
|
||||
public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
|
||||
this.device.dispatchCalls.push({
|
||||
entryPoint: this.pipeline?.entryPoint ?? 'unset',
|
||||
workgroups: [x, y, z],
|
||||
});
|
||||
});
|
||||
public readonly end = vi.fn();
|
||||
|
||||
public constructor(private readonly device: FakeDevice) {}
|
||||
}
|
||||
|
||||
class FakeCommandEncoder {
|
||||
public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device));
|
||||
public readonly copyBufferToBuffer = vi.fn(
|
||||
(
|
||||
source: GPUBuffer,
|
||||
sourceOffset: number,
|
||||
destination: GPUBuffer,
|
||||
destinationOffset: number,
|
||||
size: number
|
||||
) => {
|
||||
this.device.copyCalls.push({
|
||||
source,
|
||||
sourceOffset,
|
||||
destination,
|
||||
destinationOffset,
|
||||
size,
|
||||
});
|
||||
}
|
||||
);
|
||||
public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer);
|
||||
|
||||
public constructor(private readonly device: FakeDevice) {}
|
||||
}
|
||||
|
||||
class FakeQueue {
|
||||
public readonly writeBuffer = vi.fn(
|
||||
(_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined
|
||||
);
|
||||
public readonly submit = vi.fn(
|
||||
(_commandBuffers: Iterable<GPUCommandBuffer>) => undefined
|
||||
);
|
||||
}
|
||||
|
||||
class FakeShaderModule {
|
||||
public readonly getCompilationInfo = vi.fn(async () => ({
|
||||
messages: [],
|
||||
}));
|
||||
}
|
||||
|
||||
class FakeDevice {
|
||||
public readonly copyCalls: Array<CopyCall> = [];
|
||||
public readonly dispatchCalls: Array<DispatchCall> = [];
|
||||
public readonly createdComputeEntryPoints: Array<string> = [];
|
||||
public readonly limits = {
|
||||
maxBufferSize: 1024 * 1024 * 1024,
|
||||
maxComputeWorkgroupsPerDimension: 65_535,
|
||||
};
|
||||
public readonly queue = new FakeQueue();
|
||||
|
||||
private bufferIndex = 0;
|
||||
|
||||
public readonly createBindGroupLayout = vi.fn(
|
||||
(_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout
|
||||
);
|
||||
public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
|
||||
const label =
|
||||
['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
|
||||
this.bufferIndex
|
||||
] ?? `buffer${this.bufferIndex}`;
|
||||
this.bufferIndex += 1;
|
||||
|
||||
const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0;
|
||||
|
||||
return new FakeBuffer(
|
||||
label,
|
||||
Number(descriptor.size),
|
||||
isMappedReadBuffer ? this.compactedCount : 0
|
||||
) as unknown as GPUBuffer;
|
||||
});
|
||||
public readonly createBindGroup = vi.fn(
|
||||
(_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup
|
||||
);
|
||||
public readonly createPipelineLayout = vi.fn(
|
||||
(_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout
|
||||
);
|
||||
public readonly createShaderModule = vi.fn(
|
||||
(_descriptor: GPUShaderModuleDescriptor) =>
|
||||
new FakeShaderModule() as unknown as GPUShaderModule
|
||||
);
|
||||
public readonly createComputePipeline = vi.fn(
|
||||
(descriptor: GPUComputePipelineDescriptor) => {
|
||||
const pipeline = {
|
||||
entryPoint: descriptor.compute.entryPoint ?? 'main',
|
||||
};
|
||||
this.createdComputeEntryPoints.push(pipeline.entryPoint);
|
||||
return pipeline as unknown as GPUComputePipeline;
|
||||
}
|
||||
);
|
||||
public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this));
|
||||
|
||||
public constructor(private readonly compactedCount: number) {}
|
||||
}
|
||||
|
||||
const createPipeline = (compactedCount: number) => {
|
||||
installGpuConstants();
|
||||
|
||||
const device = new FakeDevice(compactedCount);
|
||||
const commonState = {
|
||||
bindGroupLayout: {} as GPUBindGroupLayout,
|
||||
execute: vi.fn(),
|
||||
} as unknown as CommonState;
|
||||
|
||||
return {
|
||||
device,
|
||||
pipeline: new AgentGenerationPipeline(
|
||||
device as unknown as GPUDevice,
|
||||
commonState,
|
||||
1024
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe('AgentGenerationPipeline compaction', () => {
|
||||
it('copies compacted agents back with compute instead of a full agent buffer copy', async () => {
|
||||
const agentCount = 10;
|
||||
const { device, pipeline } = createPipeline(3);
|
||||
|
||||
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
|
||||
|
||||
expect(device.createdComputeEntryPoints).toContain('copyCompactedAgents');
|
||||
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual([
|
||||
'main',
|
||||
'copyCompactedAgents',
|
||||
]);
|
||||
expect(device.copyCalls.map((call) => call.size)).toEqual([
|
||||
Uint32Array.BYTES_PER_ELEMENT,
|
||||
]);
|
||||
expect(
|
||||
device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES)
|
||||
).toBe(false);
|
||||
expect(device.queue.submit).toHaveBeenCalledTimes(1);
|
||||
|
||||
pipeline.destroy();
|
||||
});
|
||||
|
||||
it('does not encode work for empty compaction requests', async () => {
|
||||
const { device, pipeline } = createPipeline(0);
|
||||
|
||||
await expect(pipeline.compactAgents(0)).resolves.toBe(0);
|
||||
|
||||
expect(device.dispatchCalls).toEqual([]);
|
||||
expect(device.copyCalls).toEqual([]);
|
||||
expect(device.queue.submit).not.toHaveBeenCalled();
|
||||
|
||||
pipeline.destroy();
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,7 @@ export class AgentGenerationPipeline {
|
|||
private readonly countingPipeline: GPUComputePipeline;
|
||||
private readonly resizePipeline: GPUComputePipeline;
|
||||
private readonly compactionPipeline: GPUComputePipeline;
|
||||
private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
|
||||
|
||||
public readonly agentsBuffer: GPUBuffer;
|
||||
private readonly compactedAgentsBuffer: GPUBuffer;
|
||||
|
|
@ -109,7 +110,7 @@ export class AgentGenerationPipeline {
|
|||
|
||||
this.compactedAgentsBuffer = this.device.createBuffer({
|
||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
||||
usage: GPUBufferUsage.STORAGE,
|
||||
});
|
||||
|
||||
this.countersBuffer = this.device.createBuffer({
|
||||
|
|
@ -216,20 +217,32 @@ export class AgentGenerationPipeline {
|
|||
},
|
||||
});
|
||||
|
||||
const compactionModule = smartCompile(
|
||||
device,
|
||||
CommonState.shaderCode,
|
||||
agentSchema,
|
||||
compactionShader
|
||||
);
|
||||
|
||||
this.compactionPipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: smartCompile(
|
||||
device,
|
||||
CommonState.shaderCode,
|
||||
agentSchema,
|
||||
compactionShader
|
||||
),
|
||||
module: compactionModule,
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
this.compactedAgentsCopyPipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: compactionModule,
|
||||
entryPoint: 'copyCompactedAgents',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
|
|
@ -364,13 +377,19 @@ export class AgentGenerationPipeline {
|
|||
);
|
||||
passEncoder.end();
|
||||
|
||||
commandEncoder.copyBufferToBuffer(
|
||||
this.compactedAgentsBuffer,
|
||||
0,
|
||||
this.agentsBuffer,
|
||||
0,
|
||||
agentCount * AGENT_SIZE_IN_BYTES
|
||||
const copyPassEncoder = commandEncoder.beginComputePass();
|
||||
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
|
||||
this.commonState.execute(copyPassEncoder);
|
||||
copyPassEncoder.setBindGroup(1, this.compactionBindGroup);
|
||||
copyPassEncoder.dispatchWorkgroups(
|
||||
...getWorkgroupCounts(
|
||||
this.device,
|
||||
agentCount,
|
||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
||||
)
|
||||
);
|
||||
copyPassEncoder.end();
|
||||
|
||||
commandEncoder.copyBufferToBuffer(
|
||||
this.countersBuffer,
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,27 @@ describe('Agent TS/WGSL contract', () => {
|
|||
expect(agentSchema).toContain('workgroup_count.x * 64');
|
||||
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
|
||||
expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
|
||||
expect(compactionShader).toContain('if id >= settings.agentCount');
|
||||
expect(compactionShader).toContain('if id < settings.agentCount');
|
||||
});
|
||||
|
||||
it('keeps compaction copy-back bounded by the compacted count', () => {
|
||||
expect(compactionShader).toContain('fn copyCompactedAgents');
|
||||
expect(compactionShader).toContain(
|
||||
'workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);'
|
||||
);
|
||||
expect(compactionShader).toContain('if id >= workgroupCopyCount');
|
||||
expect(compactionShader).toContain('agents[id] = compactedAgents[id];');
|
||||
});
|
||||
|
||||
it('uses workgroup-local counting before allocating global compacted ranges', () => {
|
||||
expect(compactionShader).toContain(
|
||||
'var<workgroup> workgroupAliveCount: atomic<u32>;'
|
||||
);
|
||||
expect(compactionShader).toContain(
|
||||
'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);'
|
||||
);
|
||||
expect(
|
||||
compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? []
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ fn main(
|
|||
}
|
||||
|
||||
var agent = agents[id];
|
||||
if agent.colorIndex < 0.0 {
|
||||
if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +48,7 @@ fn main(
|
|||
return;
|
||||
}
|
||||
|
||||
let random = textureSampleLevel(
|
||||
noise,
|
||||
noiseSampler,
|
||||
fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
|
||||
0
|
||||
);
|
||||
let random = random_vec4(id, state.time);
|
||||
|
||||
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
|
||||
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
|
||||
|
|
@ -154,7 +149,10 @@ fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
|||
if colorIndex < 1.5 {
|
||||
return vec3<f32>(0, 1, 0);
|
||||
}
|
||||
return vec3<f32>(0, 0, 1);
|
||||
if colorIndex < 2.5 {
|
||||
return vec3<f32>(0, 0, 1);
|
||||
}
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||
|
|
@ -172,13 +170,37 @@ fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
|||
settings.color2ToColor3
|
||||
);
|
||||
}
|
||||
return vec3<f32>(
|
||||
settings.color3ToColor1,
|
||||
settings.color3ToColor2,
|
||||
settings.color3ToColor3
|
||||
);
|
||||
if colorIndex < 2.5 {
|
||||
return vec3<f32>(
|
||||
settings.color3ToColor1,
|
||||
settings.color3ToColor2,
|
||||
settings.color3ToColor3
|
||||
);
|
||||
}
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
||||
}
|
||||
|
||||
fn random_vec4(id: u32, time: f32) -> vec4<f32> {
|
||||
let timeSeed = u32(time * 0.34816);
|
||||
let seed = id * 747796405u + timeSeed * 2891336453u;
|
||||
return vec4<f32>(
|
||||
random_float(seed),
|
||||
random_float(seed + 1013904223u),
|
||||
random_float(seed + 1664525u),
|
||||
random_float(seed + 22695477u)
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export class BrushPipeline {
|
|||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly bindGroup: GPUBindGroup;
|
||||
private readonly pipeline: GPURenderPipeline;
|
||||
private readonly multiTargetPipeline: GPURenderPipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
|
|
@ -57,68 +58,16 @@ export class BrushPipeline {
|
|||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
vertex: {
|
||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
||||
entryPoint: 'fragment',
|
||||
targets: [
|
||||
{
|
||||
format: 'rgba16float',
|
||||
blend: {
|
||||
color: {
|
||||
operation: 'max',
|
||||
srcFactor: 'one',
|
||||
dstFactor: 'one',
|
||||
},
|
||||
alpha: {
|
||||
operation: 'max',
|
||||
srcFactor: 'one',
|
||||
dstFactor: 'one',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
primitive: {
|
||||
topology: 'triangle-list',
|
||||
},
|
||||
});
|
||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
|
||||
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.bindGroup = this.bindGroup = this.device.createBindGroup({
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
|
|
@ -315,23 +264,40 @@ export class BrushPipeline {
|
|||
return offset;
|
||||
}
|
||||
|
||||
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
|
||||
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView): void {
|
||||
this.executeWithPipeline(commandEncoder, this.pipeline, [trailMapOut]);
|
||||
}
|
||||
|
||||
public executeMultiTarget(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
sourceMapOut: GPUTextureView,
|
||||
influenceMapOut: GPUTextureView
|
||||
): void {
|
||||
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
|
||||
sourceMapOut,
|
||||
influenceMapOut,
|
||||
]);
|
||||
}
|
||||
|
||||
private executeWithPipeline(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
pipeline: GPURenderPipeline,
|
||||
textureViews: Array<GPUTextureView>
|
||||
): void {
|
||||
if (this.lineCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||
colorAttachments: [
|
||||
{
|
||||
view: trailMapOut,
|
||||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
},
|
||||
],
|
||||
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
|
||||
view,
|
||||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
})),
|
||||
};
|
||||
|
||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setPipeline(pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, this.bindGroup);
|
||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
||||
|
|
@ -344,6 +310,73 @@ export class BrushPipeline {
|
|||
this.uniforms.destroy();
|
||||
}
|
||||
|
||||
private createPipeline(
|
||||
shaderModule: GPUShaderModule,
|
||||
fragmentEntryPoint: string,
|
||||
colorTargetCount: number
|
||||
): GPURenderPipeline {
|
||||
return this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
vertex: {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: fragmentEntryPoint,
|
||||
targets: Array.from(
|
||||
{ length: colorTargetCount },
|
||||
() => BrushPipeline.colorTarget
|
||||
),
|
||||
},
|
||||
primitive: {
|
||||
topology: 'triangle-list',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static get colorTarget(): GPUColorTargetState {
|
||||
return {
|
||||
format: 'rgba16float',
|
||||
blend: {
|
||||
color: {
|
||||
operation: 'max',
|
||||
srcFactor: 'one',
|
||||
dstFactor: 'one',
|
||||
},
|
||||
alpha: {
|
||||
operation: 'max',
|
||||
srcFactor: 'one',
|
||||
dstFactor: 'one',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
||||
return {
|
||||
entries: [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ struct VertexOutput {
|
|||
@location(2) end: vec2<f32>
|
||||
}
|
||||
|
||||
struct BrushTargets {
|
||||
@location(0) source: vec4<f32>,
|
||||
@location(1) influence: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vertex(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
|
|
@ -32,23 +37,47 @@ fn fragment(
|
|||
@location(1) start: vec2<f32>,
|
||||
@location(2) end: vec2<f32>
|
||||
) -> @location(0) vec4<f32> {
|
||||
let distance = distanceFromLine(screenPosition, start, end);
|
||||
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
|
||||
let grainNoise = textureSample(
|
||||
noise,
|
||||
noiseSampler,
|
||||
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
|
||||
).r;
|
||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
|
||||
let feather = max(1.0, settings.brushSize * 0.22);
|
||||
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
||||
let strength = edge * mix(0.45, 1.0, grainNoise);
|
||||
let strength = brushStrength(screenPosition, start, end);
|
||||
|
||||
if(strength < 0.02) {
|
||||
discard;
|
||||
}
|
||||
if(strength < 0.02) {
|
||||
discard;
|
||||
}
|
||||
|
||||
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
||||
return brushOutput(strength);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentMrt(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) start: vec2<f32>,
|
||||
@location(2) end: vec2<f32>
|
||||
) -> BrushTargets {
|
||||
let strength = brushStrength(screenPosition, start, end);
|
||||
|
||||
if(strength < 0.02) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let color = brushOutput(strength);
|
||||
return BrushTargets(color, color);
|
||||
}
|
||||
|
||||
fn brushStrength(screenPosition: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
let distance = distanceFromLine(screenPosition, start, end);
|
||||
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
|
||||
let grainNoise = textureSample(
|
||||
noise,
|
||||
noiseSampler,
|
||||
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
|
||||
).r;
|
||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
|
||||
let feather = max(1.0, settings.brushSize * 0.22);
|
||||
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
||||
return edge * mix(0.45, 1.0, grainNoise);
|
||||
}
|
||||
|
||||
fn brushOutput(strength: f32) -> vec4<f32> {
|
||||
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
||||
}
|
||||
|
||||
fn distanceFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
|
|
|
|||
|
|
@ -7,24 +7,28 @@ struct Settings {
|
|||
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(1) var Sampler: sampler;
|
||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||
|
||||
|
||||
@fragment
|
||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||
var current = textureSample(trailMap, Sampler, uv);
|
||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||
let pixel = clamp(vec2<i32>(position.xy), vec2<i32>(0), textureSize - vec2<i32>(1));
|
||||
var current = textureLoad(trailMap, pixel, 0);
|
||||
let random = random_from_pixel(pixel);
|
||||
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
||||
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
|
||||
|
||||
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)
|
||||
propagate(pixel, textureSize, vec2<i32>(-1, -1), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(-1, 1), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(1, -1), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(1, 1), current, trailWeight, brushWeight)
|
||||
|
||||
+ 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)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(-1, 0), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(0, -1), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(1, 0), current, trailWeight, brushWeight)
|
||||
+ propagate(pixel, textureSize, vec2<i32>(0, 1), current, trailWeight, brushWeight)
|
||||
) / 8;
|
||||
|
||||
let decayed = clamp(vec4(
|
||||
|
|
@ -36,13 +40,64 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
|||
}
|
||||
|
||||
|
||||
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;
|
||||
fn propagate(
|
||||
pixel: vec2<i32>,
|
||||
textureSize: vec2<i32>,
|
||||
offset: vec2<i32>,
|
||||
currentColor: vec4<f32>,
|
||||
trailWeight: f32,
|
||||
brushWeight: f32
|
||||
) -> vec4<f32> {
|
||||
let neighbour = textureLoad(
|
||||
trailMap,
|
||||
clamp(pixel + offset, vec2<i32>(0), textureSize - vec2<i32>(1)),
|
||||
0
|
||||
);
|
||||
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
|
||||
|
||||
return vec4(
|
||||
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
|
||||
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
|
||||
vec3(length(neighbour.rgb) * trailWeight),
|
||||
neighbour.a * brushWeight
|
||||
) * 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) * 2.3283064365386963e-10;
|
||||
}
|
||||
|
||||
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
||||
let r = clamp(random, 0.0, 1.0);
|
||||
let r2 = r * r;
|
||||
let r4 = r2 * r2;
|
||||
let r8 = r4 * r4;
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
if inverseRate < 2.0 {
|
||||
return mix(r, r2, inverseRate - 1.0);
|
||||
}
|
||||
|
||||
if inverseRate < 4.0 {
|
||||
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
|
||||
}
|
||||
|
||||
if inverseRate < 8.0 {
|
||||
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
|
||||
}
|
||||
|
||||
let r16 = r8 * r8;
|
||||
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
|
||||
* min(1.0, 16.0 / inverseRate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import shader from './diffuse.wgsl?raw';
|
||||
import {
|
||||
getSafeInverseDiffusionRate,
|
||||
setDiffusionUniformValues,
|
||||
|
|
@ -26,4 +27,11 @@ describe('diffusion pipeline parameters', () => {
|
|||
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
|
||||
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the diffusion shader on the low-cost trail sampling path', () => {
|
||||
expect(shader).toContain('textureLoad');
|
||||
expect(shader).not.toContain('textureSample');
|
||||
expect(shader).not.toContain('pow(');
|
||||
expect(shader).not.toContain('noise');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export class DiffusionPipeline {
|
|||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
DiffusionPipeline.UNIFORM_COUNT
|
||||
);
|
||||
private readonly sampler: GPUSampler;
|
||||
private readonly vertexBuffer: GPUBuffer;
|
||||
|
||||
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||
|
|
@ -86,11 +85,6 @@ export class DiffusionPipeline {
|
|||
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.sampler = this.device.createSampler({
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
});
|
||||
}
|
||||
|
||||
public setParameters({
|
||||
|
|
@ -155,10 +149,6 @@ export class DiffusionPipeline {
|
|||
buffer: this.uniforms,
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: this.sampler,
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: trailMapIn,
|
||||
|
|
@ -185,13 +175,6 @@ export class DiffusionPipeline {
|
|||
type: 'uniform',
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
sampler: {
|
||||
type: 'filtering',
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
|
|
|
|||
|
|
@ -11,40 +11,22 @@ import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
|||
import { CommonState } from '../common-state/common-state';
|
||||
import shader from './eraser-agent.wgsl?raw';
|
||||
|
||||
interface LineSegment {
|
||||
from: vec2;
|
||||
to: vec2;
|
||||
}
|
||||
|
||||
const shaderWithConfig = shader.replace(
|
||||
'const MAX_SEGMENT_COUNT = 384u;',
|
||||
`const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;`
|
||||
);
|
||||
|
||||
export class EraserAgentPipeline {
|
||||
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
|
||||
private static readonly UNIFORM_COUNT = 4;
|
||||
private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount;
|
||||
private static readonly SEGMENT_FLOAT_COUNT =
|
||||
appConfig.pipelines.eraser.segmentFloatCount;
|
||||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly bindGroup: GPUBindGroup;
|
||||
private readonly pipeline: GPUComputePipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
EraserAgentPipeline.UNIFORM_COUNT
|
||||
);
|
||||
private readonly segmentsBuffer: GPUBuffer;
|
||||
private readonly segmentUploadData = new Float32Array(
|
||||
EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
|
||||
);
|
||||
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||
|
||||
private linePoints: Array<vec2> = [];
|
||||
private lineSegments: Array<LineSegment> = [];
|
||||
private actualSegments: Array<LineSegment> = [];
|
||||
private segmentCount = 0;
|
||||
private pendingSegmentCount = 0;
|
||||
private activeSegmentCount = 0;
|
||||
private agentCount = 0;
|
||||
|
||||
public constructor(
|
||||
|
|
@ -71,8 +53,8 @@ export class EraserAgentPipeline {
|
|||
{
|
||||
binding: 2,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
buffer: {
|
||||
type: 'read-only-storage',
|
||||
texture: {
|
||||
sampleType: 'float',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -83,15 +65,93 @@ export class EraserAgentPipeline {
|
|||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.segmentsBuffer = this.device.createBuffer({
|
||||
size:
|
||||
EraserAgentPipeline.MAX_SEGMENT_COUNT *
|
||||
EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
|
||||
Float32Array.BYTES_PER_ELEMENT,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
this.pipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
public addSwipe(position: vec2): void {
|
||||
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
|
||||
this.addSwipeSegment(previousPosition, position);
|
||||
this.linePoints.push(vec2.clone(position));
|
||||
}
|
||||
|
||||
public addSwipeSegment(from: vec2, to: vec2): void {
|
||||
void from;
|
||||
void to;
|
||||
this.pendingSegmentCount += 1;
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.linePoints.length = 0;
|
||||
this.pendingSegmentCount = 0;
|
||||
this.activeSegmentCount = 0;
|
||||
}
|
||||
|
||||
public setParameters({
|
||||
agentCount,
|
||||
eraserSize: _eraserSize,
|
||||
}: {
|
||||
agentCount: number;
|
||||
eraserSize: number;
|
||||
}): void {
|
||||
void _eraserSize;
|
||||
this.agentCount = agentCount;
|
||||
this.activeSegmentCount = this.pendingSegmentCount;
|
||||
this.pendingSegmentCount = 0;
|
||||
|
||||
this.uniformValues[0] = agentCount;
|
||||
this.uniformValues[1] = 0;
|
||||
this.uniformValues[2] = 0;
|
||||
this.uniformValues[3] = 0;
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
this.uniformValues,
|
||||
this.uniformCache
|
||||
);
|
||||
}
|
||||
|
||||
public hasActiveMask(): boolean {
|
||||
return this.activeSegmentCount > 0;
|
||||
}
|
||||
|
||||
public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void {
|
||||
if (!this.hasActiveMask() || this.agentCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
|
||||
passEncoder.dispatchWorkgroups(
|
||||
...getWorkgroupCounts(
|
||||
this.device,
|
||||
this.agentCount,
|
||||
EraserAgentPipeline.WORKGROUP_SIZE
|
||||
)
|
||||
);
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.uniforms.destroy();
|
||||
}
|
||||
|
||||
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
|
||||
const cached = this.bindGroupsByMask.get(eraserMask);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
|
|
@ -108,137 +168,12 @@ export class EraserAgentPipeline {
|
|||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: {
|
||||
buffer: this.segmentsBuffer,
|
||||
},
|
||||
resource: eraserMask,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.pipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: smartCompile(
|
||||
device,
|
||||
CommonState.shaderCode,
|
||||
agentSchema,
|
||||
shaderWithConfig
|
||||
),
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public addSwipe(position: vec2): void {
|
||||
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
|
||||
this.addSwipeSegment(previousPosition, position);
|
||||
this.linePoints.push(vec2.clone(position));
|
||||
}
|
||||
|
||||
public addSwipeSegment(from: vec2, to: vec2): void {
|
||||
this.lineSegments.push({
|
||||
from: vec2.clone(from),
|
||||
to: vec2.clone(to),
|
||||
});
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.linePoints.length = 0;
|
||||
this.lineSegments.length = 0;
|
||||
this.actualSegments.length = 0;
|
||||
this.segmentCount = 0;
|
||||
}
|
||||
|
||||
public setParameters({
|
||||
agentCount,
|
||||
eraserSize,
|
||||
}: {
|
||||
agentCount: number;
|
||||
eraserSize: number;
|
||||
}): void {
|
||||
this.agentCount = agentCount;
|
||||
this.actualSegments = this.lineSegments.slice();
|
||||
this.lineSegments.length = 0;
|
||||
|
||||
if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) {
|
||||
this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments);
|
||||
}
|
||||
|
||||
this.segmentCount = Math.max(0, this.actualSegments.length);
|
||||
|
||||
const eraserRadius = eraserSize / 2;
|
||||
this.uniformValues[0] = eraserRadius;
|
||||
this.uniformValues[1] = this.segmentCount;
|
||||
this.uniformValues[2] = agentCount;
|
||||
this.uniformValues[3] = eraserRadius * eraserRadius;
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
this.uniformValues,
|
||||
this.uniformCache
|
||||
);
|
||||
|
||||
if (this.segmentCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.segmentCount; i++) {
|
||||
const { from, to } = this.actualSegments[i];
|
||||
const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT;
|
||||
this.segmentUploadData[offset] = from[0];
|
||||
this.segmentUploadData[offset + 1] = from[1];
|
||||
this.segmentUploadData[offset + 2] = to[0];
|
||||
this.segmentUploadData[offset + 3] = to[1];
|
||||
}
|
||||
|
||||
this.device.queue.writeBuffer(
|
||||
this.segmentsBuffer,
|
||||
0,
|
||||
this.segmentUploadData,
|
||||
0,
|
||||
this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
public execute(commandEncoder: GPUCommandEncoder): void {
|
||||
if (this.segmentCount === 0 || this.agentCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, this.bindGroup);
|
||||
passEncoder.dispatchWorkgroups(
|
||||
...getWorkgroupCounts(
|
||||
this.device,
|
||||
this.agentCount,
|
||||
EraserAgentPipeline.WORKGROUP_SIZE
|
||||
)
|
||||
);
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.uniforms.destroy();
|
||||
this.segmentsBuffer.destroy();
|
||||
}
|
||||
|
||||
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
|
||||
if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
const result: Array<LineSegment> = [];
|
||||
for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) {
|
||||
const index = Math.round(
|
||||
(i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1)
|
||||
);
|
||||
result.push(segments[index]);
|
||||
}
|
||||
|
||||
return result;
|
||||
this.bindGroupsByMask.set(eraserMask, bindGroup);
|
||||
return bindGroup;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
struct Settings {
|
||||
eraserRadius: f32,
|
||||
segmentCount: f32,
|
||||
agentCount: f32,
|
||||
eraserRadiusSquared: f32,
|
||||
padding0: f32,
|
||||
padding1: f32,
|
||||
padding2: f32,
|
||||
};
|
||||
|
||||
const MAX_SEGMENT_COUNT = 384u;
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(2) var<storage, read> segments: array<vec4<f32>>;
|
||||
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
|
|
@ -26,38 +24,16 @@ fn main(
|
|||
return;
|
||||
}
|
||||
|
||||
for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
|
||||
if i >= u32(settings.segmentCount) {
|
||||
break;
|
||||
}
|
||||
let maskSize = vec2<i32>(textureDimensions(eraserMask));
|
||||
let maskPosition = clamp(
|
||||
vec2<i32>(agent.position),
|
||||
vec2<i32>(0, 0),
|
||||
maskSize - vec2<i32>(1, 1)
|
||||
);
|
||||
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
||||
|
||||
let segment = segments[i];
|
||||
let distanceSquared = distanceSquaredFromLine(
|
||||
agent.position,
|
||||
segment.xy,
|
||||
segment.zw
|
||||
);
|
||||
|
||||
if distanceSquared <= settings.eraserRadiusSquared {
|
||||
agent.position = vec2<f32>(-1.0, -1.0);
|
||||
agent.targetPosition = vec2<f32>(-1.0, -1.0);
|
||||
agent.colorIndex = -1.0;
|
||||
agents[id] = agent;
|
||||
return;
|
||||
}
|
||||
if maskSample.a < 0.5 {
|
||||
agent.colorIndex = -1.0;
|
||||
agents[id] = agent;
|
||||
}
|
||||
}
|
||||
|
||||
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
let pa = position - start;
|
||||
let direction = end - start;
|
||||
let denominator = dot(direction, direction);
|
||||
|
||||
if denominator <= 0.0001 {
|
||||
return dot(pa, pa);
|
||||
}
|
||||
|
||||
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
|
||||
let nearestOffset = pa - direction * q;
|
||||
return dot(nearestOffset, nearestOffset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class EraserTexturePipeline {
|
|||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly bindGroup: GPUBindGroup;
|
||||
private readonly pipeline: GPURenderPipeline;
|
||||
private readonly multiTargetPipeline: GPURenderPipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
|
|
@ -65,49 +66,9 @@ export class EraserTexturePipeline {
|
|||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
vertex: {
|
||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
||||
entryPoint: 'fragment',
|
||||
targets: [
|
||||
{
|
||||
format: 'rgba16float',
|
||||
},
|
||||
],
|
||||
},
|
||||
primitive: {
|
||||
topology: 'triangle-list',
|
||||
},
|
||||
});
|
||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
|
||||
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3);
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -194,22 +155,41 @@ export class EraserTexturePipeline {
|
|||
}
|
||||
|
||||
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
|
||||
this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]);
|
||||
}
|
||||
|
||||
public executeMultiTarget(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
sourceMapOut: GPUTextureView,
|
||||
influenceMapOut: GPUTextureView,
|
||||
trailMapOut: GPUTextureView
|
||||
): void {
|
||||
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
|
||||
sourceMapOut,
|
||||
influenceMapOut,
|
||||
trailMapOut,
|
||||
]);
|
||||
}
|
||||
|
||||
private executeWithPipeline(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
pipeline: GPURenderPipeline,
|
||||
textureViews: Array<GPUTextureView>
|
||||
): void {
|
||||
if (this.lineCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||
colorAttachments: [
|
||||
{
|
||||
view: textureOut,
|
||||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
},
|
||||
],
|
||||
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
|
||||
view,
|
||||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
})),
|
||||
};
|
||||
|
||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setPipeline(pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, this.bindGroup);
|
||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
||||
|
|
@ -222,6 +202,54 @@ export class EraserTexturePipeline {
|
|||
this.uniforms.destroy();
|
||||
}
|
||||
|
||||
private createPipeline(
|
||||
shaderModule: GPUShaderModule,
|
||||
fragmentEntryPoint: string,
|
||||
colorTargetCount: number
|
||||
): GPURenderPipeline {
|
||||
return this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
vertex: {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: fragmentEntryPoint,
|
||||
targets: Array.from({ length: colorTargetCount }, () => ({
|
||||
format: 'rgba16float' as const,
|
||||
})),
|
||||
},
|
||||
primitive: {
|
||||
topology: 'triangle-list',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
|
||||
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
|
||||
return segments;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ struct VertexOutput {
|
|||
@location(2) end: vec2<f32>
|
||||
}
|
||||
|
||||
struct EraserTextureTargets {
|
||||
@location(0) source: vec4<f32>,
|
||||
@location(1) influence: vec4<f32>,
|
||||
@location(2) trail: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vertex(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
|
|
@ -31,13 +37,35 @@ fn fragment(
|
|||
@location(1) start: vec2<f32>,
|
||||
@location(2) end: vec2<f32>
|
||||
) -> @location(0) vec4<f32> {
|
||||
if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||
discard;
|
||||
}
|
||||
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentMrt(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) start: vec2<f32>,
|
||||
@location(2) end: vec2<f32>
|
||||
) -> EraserTextureTargets {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let cleared = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
return EraserTextureTargets(cleared, cleared, cleared);
|
||||
}
|
||||
|
||||
fn shouldDiscardEraserFragment(
|
||||
screenPosition: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
end: vec2<f32>
|
||||
) -> bool {
|
||||
return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
|
||||
}
|
||||
|
||||
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
let pa = position - start;
|
||||
let direction = end - start;
|
||||
|
|
|
|||
|
|
@ -121,13 +121,14 @@ export class RenderPipeline {
|
|||
commandEncoder: GPUCommandEncoder,
|
||||
colorTexture: GPUTextureView,
|
||||
sourceTexture: GPUTextureView
|
||||
) {
|
||||
): GPUTexture {
|
||||
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
|
||||
const canvasTexture = this.context.getCurrentTexture();
|
||||
|
||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||
colorAttachments: [
|
||||
{
|
||||
view: this.context.getCurrentTexture().createView(),
|
||||
view: canvasTexture.createView(),
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
|
|
@ -141,6 +142,8 @@ export class RenderPipeline {
|
|||
passEncoder.setBindGroup(1, bindGroup);
|
||||
passEncoder.draw(4, 1);
|
||||
passEncoder.end();
|
||||
|
||||
return canvasTexture;
|
||||
}
|
||||
|
||||
public executeToView(
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ describe('WGSL uniform layout contracts', () => {
|
|||
pipeline: EraserAgentPipeline,
|
||||
source: eraserAgentShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
|
||||
fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
pipeline: EraserTexturePipeline,
|
||||
|
|
@ -199,4 +199,10 @@ describe('WGSL uniform layout contracts', () => {
|
|||
getUniformCount(AgentGenerationPipeline)
|
||||
);
|
||||
});
|
||||
|
||||
it('guards invalid high agent color indexes instead of treating them as color 3', () => {
|
||||
expect(agentShader).toContain('agent.colorIndex < 0.0 || agent.colorIndex >= 2.5');
|
||||
expect(agentShader).toContain('if colorIndex < 2.5');
|
||||
expect(agentShader).toContain('return vec3<f32>(0.0, 0.0, 0.0);');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue