This commit is contained in:
Andras Schmelczer 2026-05-17 14:12:01 +01:00
commit 80ed37298b
73 changed files with 3619 additions and 1295 deletions

View file

@ -18,7 +18,10 @@ export class EraserAgentPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer,
WeakMap<GPUTextureView, GPUBindGroup>
>();
private pendingSegmentCount = 0;
private activeSegmentCount = 0;
@ -26,7 +29,7 @@ export class EraserAgentPipeline {
public constructor(
private readonly device: GPUDevice,
private readonly agentsBuffer: GPUBuffer
private readonly getAgentsBuffer: () => GPUBuffer
) {
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
@ -80,13 +83,19 @@ export class EraserAgentPipeline {
this.activeSegmentCount = 0;
}
public setParameters({ agentCount }: { agentCount: number }): void {
public setParameters({
agentCount,
eraserMaskAlphaThreshold,
}: {
agentCount: number;
eraserMaskAlphaThreshold: number;
}): void {
this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0;
this.uniformValues[0] = agentCount;
this.uniformValues[1] = 0;
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
@ -120,7 +129,14 @@ export class EraserAgentPipeline {
}
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
const cached = this.bindGroupsByMask.get(eraserMask);
const agentsBuffer = this.getAgentsBuffer();
let maskCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!maskCache) {
maskCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, maskCache);
}
const cached = maskCache.get(eraserMask);
if (cached) {
return cached;
}
@ -137,7 +153,7 @@ export class EraserAgentPipeline {
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
buffer: agentsBuffer,
},
},
{
@ -147,7 +163,7 @@ export class EraserAgentPipeline {
],
});
this.bindGroupsByMask.set(eraserMask, bindGroup);
maskCache.set(eraserMask, bindGroup);
return bindGroup;
}
}

View file

@ -1,6 +1,6 @@
struct Settings {
agentCount: f32,
padding0: f32,
eraserMaskAlphaThreshold: f32,
padding1: f32,
padding2: f32,
};
@ -18,21 +18,20 @@ fn main(
return;
}
var agent = agents[id];
if agent.colorIndex < 0.0 {
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 {
return;
}
let maskSize = vec2<i32>(textureDimensions(eraserMask));
let maskPosition = clamp(
vec2<i32>(agent.position),
vec2<i32>(agents[id].position),
vec2<i32>(0, 0),
maskSize - vec2<i32>(1, 1)
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
if maskSample.a < 0.5 {
agent.colorIndex = -1.0;
agents[id] = agent;
if maskSample.a < settings.eraserMaskAlphaThreshold {
agents[id].colorIndex = -1.0;
}
}

View file

@ -15,15 +15,14 @@ interface LineSegment {
}
export class EraserTexturePipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly UNIFORM_COUNT = 8;
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 static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly multiTargetPipeline: GPURenderPipeline;
private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -47,7 +46,7 @@ export class EraserTexturePipeline {
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
@ -65,8 +64,7 @@ export class EraserTexturePipeline {
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3);
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -98,13 +96,31 @@ export class EraserTexturePipeline {
this.actualSegments.length = 0;
}
public setParameters({ eraserSize }: { eraserSize: number }): void {
public setParameters({
eraserSize,
eraserLineDistanceEpsilon,
eraserClearRed,
eraserClearGreen,
eraserClearBlue,
eraserClearAlpha,
}: {
eraserSize: number;
eraserLineDistanceEpsilon: number;
eraserClearRed: number;
eraserClearGreen: number;
eraserClearBlue: number;
eraserClearAlpha: number;
}): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius * eraserRadius;
this.uniformValues[1] = 0;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
this.uniformValues[1] = eraserLineDistanceEpsilon;
this.uniformValues[2] = eraserClearRed;
this.uniformValues[3] = eraserClearGreen;
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = 0;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@ -131,8 +147,7 @@ export class EraserTexturePipeline {
this.vertexUploadData,
floatOffset,
segment.from,
segment.to,
eraserRadius
segment.to
);
}
@ -145,46 +160,60 @@ export class EraserTexturePipeline {
);
}
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]);
}
public executeMultiTarget(
public executeCombined(
commandEncoder: GPUCommandEncoder,
eraserMaskOut: GPUTextureView,
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) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: eraserMaskOut,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.end();
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
view,
loadOp: 'load',
storeOp: 'store',
})),
colorAttachments: [
{
view: eraserMaskOut,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
{
view: sourceMapOut,
loadOp: 'load',
storeOp: 'store',
},
{
view: influenceMapOut,
loadOp: 'load',
storeOp: 'store',
},
{
view: trailMapOut,
loadOp: 'load',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.setPipeline(this.combinedPipeline);
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.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
passEncoder.end();
}
@ -207,7 +236,10 @@ export class EraserTexturePipeline {
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
arrayStride:
Float32Array.BYTES_PER_ELEMENT *
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT,
stepMode: 'instance',
attributes: [
{
shaderLocation: 0,
@ -219,11 +251,6 @@ export class EraserTexturePipeline {
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
@ -261,84 +288,8 @@ export class EraserTexturePipeline {
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];

View file

@ -1,8 +1,12 @@
struct Settings {
eraserRadiusSquared: f32,
lineDistanceEpsilon: f32,
clearRed: f32,
clearGreen: f32,
clearBlue: f32,
clearAlpha: f32,
padding0: f32,
padding1: f32,
padding2: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -10,8 +14,8 @@ 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) end: vec2<f32>
}
struct EraserTextureTargets {
@ -20,12 +24,20 @@ struct EraserTextureTargets {
@location(2) trail: vec4<f32>,
}
struct EraserCombinedTargets {
@location(0) mask: vec4<f32>,
@location(1) source: vec4<f32>,
@location(2) influence: vec4<f32>,
@location(3) trail: 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 screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
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);
@ -34,30 +46,53 @@ fn vertex(
@fragment
fn fragment(
@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) end: vec2<f32>
) -> @location(0) vec4<f32> {
if shouldDiscardEraserFragment(screenPosition, start, end) {
discard;
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
return getEraserClearValue();
}
@fragment
fn fragmentMrt(
@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) end: vec2<f32>
) -> EraserTextureTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
discard;
}
let cleared = vec4<f32>(0.0, 0.0, 0.0, 0.0);
let cleared = getEraserClearValue();
return EraserTextureTargets(cleared, cleared, cleared);
}
@fragment
fn fragmentCombined(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
) -> EraserCombinedTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
discard;
}
let cleared = getEraserClearValue();
return EraserCombinedTargets(cleared, cleared, cleared, cleared);
}
fn getEraserClearValue() -> vec4<f32> {
return vec4<f32>(
settings.clearRed,
settings.clearGreen,
settings.clearBlue,
settings.clearAlpha
);
}
fn shouldDiscardEraserFragment(
screenPosition: vec2<f32>,
start: vec2<f32>,
@ -71,7 +106,7 @@ fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
if denominator <= settings.lineDistanceEpsilon {
return dot(pa, pa);
}
@ -79,3 +114,34 @@ fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}
fn segment_vertex_position(
vertexIndex: u32,
start: vec2<f32>,
end: vec2<f32>,
radius: f32
) -> vec2<f32> {
let directionVector = end - start;
let segmentLength = length(directionVector);
var direction = vec2<f32>(1.0, 0.0);
if segmentLength > 0.0 {
direction = directionVector / segmentLength;
}
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 segment_vertex_corner(index: u32) -> vec2<f32> {
if index == 0u {
return vec2<f32>(-1.0, 1.0);
}
if index == 1u || index == 3u {
return vec2<f32>(-1.0, -1.0);
}
if index == 2u || index == 4u {
return vec2<f32>(1.0, 1.0);
}
return vec2<f32>(1.0, -1.0);
}