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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [

View file

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

View file

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

View file

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

View file

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

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;

View file

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

View file

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