This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

View file

@ -0,0 +1,244 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
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 linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
private segmentCount = 0;
private agentCount = 0;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly agentsBuffer: GPUBuffer
) {
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,
buffer: {
type: 'read-only-storage',
},
},
],
});
this.uniforms = this.device.createBuffer({
size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
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.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.segmentsBuffer,
},
},
],
});
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;
}
}

View file

@ -0,0 +1,63 @@
struct Settings {
eraserRadius: f32,
segmentCount: f32,
agentCount: f32,
eraserRadiusSquared: 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>>;
@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 >= u32(settings.agentCount) {
return;
}
var agent = agents[id];
if agent.colorIndex < 0.0 {
return;
}
for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
if i >= u32(settings.segmentCount) {
break;
}
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;
}
}
}
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

@ -0,0 +1,333 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import shader from './eraser-texture.wgsl?raw';
interface LineSegment {
from: vec2;
to: vec2;
}
export class EraserTexturePipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserTexturePipeline.UNIFORM_COUNT
);
private readonly vertexBuffer: GPUBuffer;
private readonly vertexUploadData = new Float32Array(
EraserTexturePipeline.MAX_LINE_COUNT *
EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
);
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
},
],
});
this.vertexBuffer = device.createBuffer({
size:
EraserTexturePipeline.MAX_LINE_COUNT *
EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT *
Float32Array.BYTES_PER_ELEMENT,
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',
},
});
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
],
});
}
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;
}
public setParameters({ eraserSize }: { eraserSize: number }): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius;
this.uniformValues[1] = eraserRadius * eraserRadius;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
this.actualSegments = this.lineSegments.slice();
this.lineSegments.length = 0;
if (this.actualSegments.length === 0) {
return;
}
if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) {
this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments);
}
const lineCount = this.lineCount;
let floatOffset = 0;
for (let i = 0; i < lineCount; i++) {
const segment = this.actualSegments[i];
floatOffset = this.writeSegmentVertices(
this.vertexUploadData,
floatOffset,
segment.from,
segment.to,
eraserRadius
);
}
this.device.queue.writeBuffer(
this.vertexBuffer,
0,
this.vertexUploadData,
0,
floatOffset
);
}
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
if (this.lineCount === 0) {
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: textureOut,
loadOp: 'load',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
passEncoder.end();
}
public destroy(): void {
this.vertexBuffer.destroy();
this.uniforms.destroy();
}
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) {
const index = Math.round(
(i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1)
);
result.push(segments[index]);
}
return result;
}
private writeSegmentVertices(
target: Float32Array,
offset: number,
from: vec2,
to: vec2,
width: number
): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const length = Math.hypot(dx, dy);
const directionX = length > 0 ? dx / length : 1;
const directionY = length > 0 ? dy / length : 0;
const scaledDirectionX = directionX * width;
const scaledDirectionY = directionY * width;
const perpendicularX = directionY * width;
const perpendicularY = -directionX * width;
const startX = from[0] - scaledDirectionX;
const startY = from[1] - scaledDirectionY;
const endX = to[0] + scaledDirectionX;
const endY = to[1] + scaledDirectionY;
offset = this.writeVertex(
target,
offset,
startX + perpendicularX,
startY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
return this.writeVertex(
target,
offset,
endX - perpendicularX,
endY - perpendicularY,
from,
to
);
}
private writeVertex(
target: Float32Array,
offset: number,
screenX: number,
screenY: number,
from: vec2,
to: vec2
): number {
target[offset++] = screenX;
target[offset++] = screenY;
target[offset++] = from[0];
target[offset++] = from[1];
target[offset++] = to[0];
target[offset++] = to[1];
return offset;
}
private get lineCount(): number {
return clamp(this.actualSegments.length, 0, EraserTexturePipeline.MAX_LINE_COUNT);
}
}

View file

@ -0,0 +1,53 @@
struct Settings {
eraserRadius: f32,
eraserRadiusSquared: f32,
padding1: f32,
padding2: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
) -> VertexOutput {
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, end);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
discard;
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
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);
}