This commit is contained in:
Andras Schmelczer 2026-05-16 15:05:35 +01:00
commit 1fe5015056
55 changed files with 2036 additions and 685 deletions

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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;