clean up
This commit is contained in:
parent
d6a8f898d1
commit
15e99380b5
84 changed files with 1659 additions and 4726 deletions
|
|
@ -16,12 +16,17 @@ struct Counters {
|
|||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||
var<workgroup> workgroupCompactedOffset: u32;
|
||||
|
||||
fn dead_agent() -> Agent {
|
||||
return Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id);
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if local_id.x == 0u {
|
||||
atomicStore(&workgroupAliveCount, 0u);
|
||||
|
|
@ -30,7 +35,7 @@ fn main(
|
|||
workgroupBarrier();
|
||||
|
||||
var localCompactedIndex = 0u;
|
||||
var agent = Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
||||
var agent = dead_agent();
|
||||
var isAlive = false;
|
||||
if id < settings.agentCount {
|
||||
agent = agents[id];
|
||||
|
|
@ -57,3 +62,20 @@ fn main(
|
|||
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
||||
}
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn clearCompactedTail(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||
if id >= aliveAgentCount {
|
||||
compactedAgents[id] = dead_agent();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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 () => 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(() => 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(() => undefined);
|
||||
public readonly submit = vi.fn(() => 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(() => ({}) 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(() => ({}) as GPUBindGroup);
|
||||
public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout);
|
||||
public readonly createShaderModule = vi.fn(
|
||||
() => 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);
|
||||
|
||||
return {
|
||||
device,
|
||||
pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024),
|
||||
};
|
||||
};
|
||||
|
||||
describe('AgentGenerationPipeline compaction', () => {
|
||||
it('swaps compacted agents into the active buffer without a copy-back dispatch', async () => {
|
||||
const agentCount = 10;
|
||||
const { device, pipeline } = createPipeline(3);
|
||||
|
||||
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
|
||||
|
||||
expect(device.createdComputeEntryPoints).not.toContain('copyCompactedAgents');
|
||||
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual(['main']);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { getWorkgroupCount } from '../../../utils/graphics/get-workgroup-count';
|
||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||
import { AGENT_SIZE_IN_BYTES } from './agent';
|
||||
import {
|
||||
AGENT_MAX_DISPATCHABLE_COUNT,
|
||||
dispatchAgentWorkgroups,
|
||||
} from '../agent-dispatch';
|
||||
import compactionShader from './agent-compaction.wgsl?raw';
|
||||
import resizeShader from './agent-resize.wgsl?raw';
|
||||
import agentSchema from './agent-schema.wgsl?raw';
|
||||
|
||||
export const AGENT_FLOAT_COUNT = 8;
|
||||
export const AGENT_SIZE_IN_BYTES =
|
||||
AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
||||
export class AgentGenerationPipeline {
|
||||
private static readonly WORKGROUP_SIZE = 64;
|
||||
private static readonly UNIFORM_COUNT = 4;
|
||||
private static readonly COUNTER_COUNT = 1;
|
||||
|
||||
|
|
@ -21,9 +26,11 @@ export class AgentGenerationPipeline {
|
|||
|
||||
private readonly resizePipeline: GPUComputePipeline;
|
||||
private readonly compactionPipeline: GPUComputePipeline;
|
||||
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||
|
||||
private activeAgentsBuffer: GPUBuffer;
|
||||
private inactiveAgentsBuffer: GPUBuffer;
|
||||
private allocatedMaxAgentCount: number;
|
||||
private readonly countersBuffer: GPUBuffer;
|
||||
private readonly countersStagingBuffer: GPUBuffer;
|
||||
private readonly counterClearValues = new Uint32Array(
|
||||
|
|
@ -35,8 +42,10 @@ export class AgentGenerationPipeline {
|
|||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
private readonly maxAgentCountUpperLimit: number
|
||||
initialMaxAgentCount: number,
|
||||
private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
||||
) {
|
||||
this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
|
||||
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
|
|
@ -72,7 +81,7 @@ export class AgentGenerationPipeline {
|
|||
});
|
||||
|
||||
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||
this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer();
|
||||
|
||||
this.countersBuffer = this.device.createBuffer({
|
||||
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -110,6 +119,16 @@ export class AgentGenerationPipeline {
|
|||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
this.clearCompactedTailPipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: compactionModule,
|
||||
entryPoint: 'clearCompactedTail',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public get agentsBuffer(): GPUBuffer {
|
||||
|
|
@ -118,23 +137,86 @@ export class AgentGenerationPipeline {
|
|||
|
||||
private createAgentsBuffer(): GPUBuffer {
|
||||
return this.device.createBuffer({
|
||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
}
|
||||
|
||||
// The inactive slot only needs a real allocation during compaction. The rest of
|
||||
// the time we keep a one-agent placeholder so the bind group at binding 3 stays
|
||||
// valid for resize without holding a second N-agent buffer in GPU memory.
|
||||
private createInactivePlaceholderBuffer(): GPUBuffer {
|
||||
return this.device.createBuffer({
|
||||
size: AGENT_SIZE_IN_BYTES,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.allocatedMaxAgentCount;
|
||||
}
|
||||
|
||||
public get maxSupportedAgentCount(): number {
|
||||
return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
public ensureMaxAgentCount(
|
||||
requestedMaxAgentCount: number,
|
||||
activeAgentCount: number
|
||||
): number {
|
||||
const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
|
||||
if (nextMaxAgentCount <= this.allocatedMaxAgentCount) {
|
||||
return this.allocatedMaxAgentCount;
|
||||
}
|
||||
|
||||
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
||||
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
||||
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
||||
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||
|
||||
const copyAgentCount = Math.min(
|
||||
Math.max(0, Math.floor(activeAgentCount)),
|
||||
previousMaxAgentCount,
|
||||
nextMaxAgentCount
|
||||
);
|
||||
if (copyAgentCount > 0) {
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
commandEncoder.copyBufferToBuffer(
|
||||
previousActiveAgentsBuffer,
|
||||
0,
|
||||
this.activeAgentsBuffer,
|
||||
0,
|
||||
copyAgentCount * AGENT_SIZE_IN_BYTES
|
||||
);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
}
|
||||
|
||||
// GPUBuffer.destroy() defers actual freeing until pending submissions
|
||||
// finish, so calling it synchronously after submit is safe and avoids the
|
||||
// transient 4-buffers-live spike that pushes iOS Safari past its per-tab
|
||||
// memory ceiling.
|
||||
previousActiveAgentsBuffer.destroy();
|
||||
return this.allocatedMaxAgentCount;
|
||||
}
|
||||
|
||||
private clampMaxAgentCount(value: number): number {
|
||||
const requestedMaxAgentCount =
|
||||
value === Number.POSITIVE_INFINITY
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number.isFinite(value)
|
||||
? Math.floor(value)
|
||||
: 0;
|
||||
return Math.min(
|
||||
Number.isFinite(this.maxAgentCountUpperLimit)
|
||||
? this.maxAgentCountUpperLimit
|
||||
: Number.POSITIVE_INFINITY,
|
||||
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
|
||||
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||
Math.floor(
|
||||
((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
|
||||
this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
|
||||
) - 1,
|
||||
this.device.limits.maxComputeWorkgroupsPerDimension *
|
||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
||||
),
|
||||
AGENT_MAX_DISPATCHABLE_COUNT,
|
||||
Math.max(0, requestedMaxAgentCount)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +243,7 @@ export class AgentGenerationPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.resizePipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||
passEncoder.dispatchWorkgroups(
|
||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
||||
);
|
||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||
passEncoder.end();
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
|
@ -174,6 +254,12 @@ export class AgentGenerationPipeline {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Stash the placeholder, swap in a real N-agent destination buffer just
|
||||
// for this compaction so the rest of the time we only carry one full
|
||||
// agent buffer in memory.
|
||||
const placeholder = this.inactiveAgentsBuffer;
|
||||
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||
|
||||
this.agentCountUniformValues[0] = agentCount;
|
||||
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
||||
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||
|
|
@ -182,9 +268,9 @@ export class AgentGenerationPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.compactionPipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||
passEncoder.dispatchWorkgroups(
|
||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
||||
);
|
||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||
passEncoder.end();
|
||||
|
||||
commandEncoder.copyBufferToBuffer(
|
||||
|
|
@ -196,6 +282,14 @@ export class AgentGenerationPipeline {
|
|||
);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
this.swapAgentBuffers();
|
||||
|
||||
// After swap, inactive is the previous active (full size). Destroy it and
|
||||
// restore the placeholder; the destroy is deferred by WebGPU until the
|
||||
// submitted compaction work has finished.
|
||||
const previousActiveAgentsBuffer = this.inactiveAgentsBuffer;
|
||||
this.inactiveAgentsBuffer = placeholder;
|
||||
previousActiveAgentsBuffer.destroy();
|
||||
|
||||
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
||||
const compactedCount = new Uint32Array(
|
||||
|
|
@ -204,7 +298,6 @@ export class AgentGenerationPipeline {
|
|||
1
|
||||
)[0];
|
||||
this.countersStagingBuffer.unmap();
|
||||
this.swapAgentBuffers();
|
||||
|
||||
return compactedCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ struct ResizeSettings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id);
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(resizeSettings.agentCount) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
|
||||
import compactionShader from './agent-compaction.wgsl?raw';
|
||||
import resizeShader from './agent-resize.wgsl?raw';
|
||||
import agentSchema from './agent-schema.wgsl?raw';
|
||||
|
||||
const wgslFloatCountByType: Record<string, number> = {
|
||||
f32: 1,
|
||||
'vec2<f32>': 2,
|
||||
};
|
||||
|
||||
const getAgentStructFields = () => {
|
||||
const match = /struct Agent\s*\{(?<body>[\s\S]*?)\n\}/.exec(agentSchema);
|
||||
if (!match?.groups?.body) {
|
||||
throw new Error('Agent struct was not found in agent-schema.wgsl');
|
||||
}
|
||||
|
||||
return match.groups.body
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/,$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
|
||||
if (!fieldMatch?.groups) {
|
||||
throw new Error(`Unsupported Agent field syntax: ${line}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: fieldMatch.groups.name,
|
||||
type: fieldMatch.groups.type,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
describe('Agent TS/WGSL contract', () => {
|
||||
it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
|
||||
const fields = getAgentStructFields();
|
||||
const wgslFloatCount = fields.reduce((sum, field) => {
|
||||
const count = wgslFloatCountByType[field.type];
|
||||
if (!count) {
|
||||
throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
|
||||
}
|
||||
|
||||
return sum + count;
|
||||
}, 0);
|
||||
|
||||
expect(fields.map((field) => field.name)).toEqual([
|
||||
'position',
|
||||
'angle',
|
||||
'colorIndex',
|
||||
'targetPosition',
|
||||
'targetAngle',
|
||||
'introDelay',
|
||||
]);
|
||||
expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
|
||||
expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
|
||||
});
|
||||
|
||||
it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
|
||||
[resizeShader, compactionShader].forEach((shader) => {
|
||||
expect(shader).toMatch(/@workgroup_size\(64\)/);
|
||||
});
|
||||
|
||||
expect(agentSchema).toContain('return global_id.x;');
|
||||
expect(compactionShader).toContain('let id = get_id(global_id);');
|
||||
expect(compactionShader).toContain('if id < settings.agentCount');
|
||||
});
|
||||
|
||||
it('keeps compaction as a ping-pong write without copy-back shader work', () => {
|
||||
expect(compactionShader).not.toContain('fn copyCompactedAgents');
|
||||
expect(compactionShader).not.toContain('agents[id] = compactedAgents[id];');
|
||||
expect(compactionShader).toContain(
|
||||
'compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;'
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@ struct Agent {
|
|||
|
||||
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
||||
|
||||
fn get_id(global_id: vec3<u32>) -> u32 {
|
||||
return global_id.x;
|
||||
const agentWorkgroupSize = 64u;
|
||||
|
||||
fn get_id(global_id: vec3<u32>, num_workgroups: vec3<u32>) -> u32 {
|
||||
return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export const AGENT_FLOAT_COUNT = 8;
|
||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
|
@ -2,15 +2,14 @@ import {
|
|||
createCachedFloat32BufferWrite,
|
||||
writeFloat32BufferIfChanged,
|
||||
} from '../../utils/graphics/cached-buffer-write';
|
||||
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||
import { CommonState } from '../common-state/common-state';
|
||||
import { dispatchAgentWorkgroups } from './agent-dispatch';
|
||||
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
||||
import { AgentSettings } from './agent-settings';
|
||||
import shader from './agent.wgsl?raw';
|
||||
|
||||
export class AgentPipeline {
|
||||
private static readonly WORKGROUP_SIZE = 64;
|
||||
private static readonly UNIFORM_COUNT = 33;
|
||||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
|
|
@ -151,9 +150,7 @@ export class AgentPipeline {
|
|||
passEncoder.setPipeline(this.pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, bindGroup);
|
||||
passEncoder.dispatchWorkgroups(
|
||||
getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE)
|
||||
);
|
||||
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ struct Settings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id);
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(settings.agentCount) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline';
|
||||
|
||||
const brushSettings = {
|
||||
brushAlpha: 0.75,
|
||||
brushCoarseNoiseScale: 100,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushFeatherRatio: 0.25,
|
||||
brushGrainMaxStrength: 1,
|
||||
brushGrainMinStrength: 0.4,
|
||||
brushGrainNoiseOffsetX: 0.1,
|
||||
brushGrainNoiseOffsetY: 0.2,
|
||||
brushGrainNoiseScale: 25,
|
||||
brushMinimumFeather: 2,
|
||||
brushSize: 10,
|
||||
brushSizeVariation: 0.5,
|
||||
selectedColorIndex: 1,
|
||||
};
|
||||
|
||||
describe('brush pipeline parameters', () => {
|
||||
it('scales pixel-space brush uniforms by device pixel ratio', () => {
|
||||
const uniformValues = new Float32Array(16);
|
||||
|
||||
setBrushUniformValues(uniformValues, {
|
||||
...brushSettings,
|
||||
devicePixelRatio: 2,
|
||||
});
|
||||
|
||||
expect(uniformValues[0]).toBe(10);
|
||||
expect(uniformValues[1]).toBe(5);
|
||||
expect(uniformValues[3]).toBe(4);
|
||||
expect(uniformValues[5]).toBe(1);
|
||||
expect(uniformValues[8]).toBe(200);
|
||||
expect(uniformValues[9]).toBe(50);
|
||||
expect(uniformValues[15]).toBe(19);
|
||||
});
|
||||
|
||||
it('falls back to a 1x pixel ratio for invalid values', () => {
|
||||
expect(getSafeDevicePixelRatio(0)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(undefined)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(1.5)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -16,15 +16,13 @@ interface LineSegment {
|
|||
}
|
||||
|
||||
interface BrushParameterSettings extends BrushSettings {
|
||||
devicePixelRatio?: number;
|
||||
pixelRatio?: number;
|
||||
selectedColorIndex: number;
|
||||
}
|
||||
|
||||
export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number =>
|
||||
typeof devicePixelRatio === 'number' &&
|
||||
Number.isFinite(devicePixelRatio) &&
|
||||
devicePixelRatio > 0
|
||||
? devicePixelRatio
|
||||
export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
|
||||
typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
|
||||
? pixelRatio
|
||||
: 1;
|
||||
|
||||
export const setBrushUniformValues = (
|
||||
|
|
@ -43,13 +41,13 @@ export const setBrushUniformValues = (
|
|||
brushGrainMinStrength,
|
||||
brushGrainMaxStrength,
|
||||
selectedColorIndex,
|
||||
devicePixelRatio,
|
||||
pixelRatio,
|
||||
}: BrushParameterSettings
|
||||
): void => {
|
||||
const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio);
|
||||
const brushRadius = (brushSize * pixelRatio) / 2;
|
||||
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||
const brushRadius = (brushSize * safePixelRatio) / 2;
|
||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||
const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio;
|
||||
const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
|
||||
const brushFeather = Math.max(
|
||||
brushMinimumFeatherPixels,
|
||||
brushRadius * brushFeatherRatio
|
||||
|
|
@ -65,8 +63,8 @@ export const setBrushUniformValues = (
|
|||
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||
target[7] = brushAlpha;
|
||||
target[8] = brushCoarseNoiseScale * pixelRatio;
|
||||
target[9] = brushGrainNoiseScale * pixelRatio;
|
||||
target[8] = brushCoarseNoiseScale * safePixelRatio;
|
||||
target[9] = brushGrainNoiseScale * safePixelRatio;
|
||||
target[10] = brushGrainNoiseOffsetX;
|
||||
target[11] = brushGrainNoiseOffsetY;
|
||||
target[12] = brushDiscardThreshold;
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import shader from './diffuse.wgsl?raw';
|
||||
import {
|
||||
getSafeInverseDiffusionRate,
|
||||
setDiffusionUniformValues,
|
||||
} from './diffusion-pipeline';
|
||||
|
||||
describe('diffusion pipeline parameters', () => {
|
||||
it('keeps zero diffusion rates finite before writing shader uniforms', () => {
|
||||
const uniformValues = new Float32Array(8);
|
||||
|
||||
setDiffusionUniformValues(uniformValues, {
|
||||
brushDecayAlphaOffset: 1.001,
|
||||
decayRateBrush: 900,
|
||||
decayRateTrails: 970,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
diffusionRateBrush: 0,
|
||||
diffusionRateTrails: 0,
|
||||
});
|
||||
|
||||
expect(Number.isFinite(uniformValues[0])).toBe(true);
|
||||
expect(Number.isFinite(uniformValues[2])).toBe(true);
|
||||
expect(uniformValues[0]).toBeGreaterThan(0);
|
||||
expect(uniformValues[2]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes valid diffusion rates through as inverse values', () => {
|
||||
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
|
||||
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the diffusion shader on the tiled compute sampling path', () => {
|
||||
expect(shader).toContain('@compute @workgroup_size(16, 16)');
|
||||
expect(shader).toContain('var<workgroup> tile');
|
||||
expect(shader).toContain('textureLoad');
|
||||
expect(shader).not.toContain('textureSample');
|
||||
expect(shader).not.toContain('pow(');
|
||||
expect(shader).not.toContain('noise');
|
||||
});
|
||||
|
||||
it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
|
||||
expect(shader).toContain('@group(0) @binding(0) var<uniform> settings');
|
||||
expect(shader).toContain('@group(0) @binding(1) var trailMap');
|
||||
expect(shader).toContain('@group(0) @binding(2) var trailMapOut');
|
||||
expect(shader).not.toContain('@group(1)');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,13 +2,12 @@ import {
|
|||
createCachedFloat32BufferWrite,
|
||||
writeFloat32BufferIfChanged,
|
||||
} from '../../utils/graphics/cached-buffer-write';
|
||||
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||
import { dispatchAgentWorkgroups } from '../agents/agent-dispatch';
|
||||
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
||||
import shader from './eraser-agent.wgsl?raw';
|
||||
|
||||
export class EraserAgentPipeline {
|
||||
private static readonly WORKGROUP_SIZE = 64;
|
||||
private static readonly UNIFORM_COUNT = 4;
|
||||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
|
|
@ -118,9 +117,7 @@ export class EraserAgentPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
|
||||
passEncoder.dispatchWorkgroups(
|
||||
getWorkgroupCount(this.agentCount, EraserAgentPipeline.WORKGROUP_SIZE)
|
||||
);
|
||||
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ struct Settings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id);
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(settings.agentCount) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -58,19 +58,24 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
|||
strengths.r * settings.colorA
|
||||
+ strengths.g * settings.colorB
|
||||
+ strengths.b * settings.colorC;
|
||||
let normalizedTraceColor =
|
||||
traceColor / max(settings.traceNormalizationFloor, strengths.r + strengths.g + strengths.b);
|
||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
||||
let brushColor =
|
||||
sourceStrengths.r * settings.colorA
|
||||
+ sourceStrengths.g * settings.colorB
|
||||
+ sourceStrengths.b * settings.colorC;
|
||||
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
||||
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
|
||||
let color = max(
|
||||
normalizedTraceColor,
|
||||
brushColor * (settings.brushColorBase + brushStrength * settings.brushColorStrengthMultiplier)
|
||||
let brushVisibility = clamp(
|
||||
brushStrength * (
|
||||
settings.brushColorBase +
|
||||
brushStrength * settings.brushColorStrengthMultiplier
|
||||
),
|
||||
0,
|
||||
1
|
||||
);
|
||||
let color = max(normalizedTraceColor, normalizedBrushColor);
|
||||
|
||||
let strength = max(max(strengths.r, strengths.g), strengths.b);
|
||||
let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility);
|
||||
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +83,11 @@ fn clarity(strength: f32) -> f32 {
|
|||
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||
}
|
||||
|
||||
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||
let brightestChannel = max(max(color.r, color.g), color.b);
|
||||
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
||||
}
|
||||
|
||||
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
||||
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
|
||||
let noiseCoord = pixel % noiseSize;
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
|
||||
import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
|
||||
import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
|
||||
import { AgentPipeline } from './agents/agent-pipeline';
|
||||
import agentShader from './agents/agent.wgsl?raw';
|
||||
import { BrushPipeline } from './brush/brush-pipeline';
|
||||
import brushShader from './brush/brush.wgsl?raw';
|
||||
import { CommonState } from './common-state/common-state';
|
||||
import diffusionShader from './diffusion/diffuse.wgsl?raw';
|
||||
import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
|
||||
import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
|
||||
import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
|
||||
import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
|
||||
import { RenderPipeline } from './render/render-pipeline';
|
||||
import renderShader from './render/render.wgsl?raw';
|
||||
|
||||
const wgslFloatCountsByType: Record<string, number> = {
|
||||
f32: 1,
|
||||
u32: 1,
|
||||
'vec2<f32>': 2,
|
||||
'vec3<f32>': 3,
|
||||
'vec4<f32>': 4,
|
||||
};
|
||||
|
||||
const stripComments = (source: string): string =>
|
||||
source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
const getStructFields = (source: string, structName: string) => {
|
||||
const match = new RegExp(
|
||||
`struct ${structName}\\s*\\{(?<body>[\\s\\S]*?)\\n\\s*\\}`
|
||||
).exec(stripComments(source));
|
||||
if (!match?.groups?.body) {
|
||||
throw new Error(`${structName} struct was not found`);
|
||||
}
|
||||
|
||||
return match.groups.body
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/,$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
|
||||
if (!fieldMatch?.groups) {
|
||||
throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: fieldMatch.groups.name,
|
||||
type: fieldMatch.groups.type,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const countUniformScalars = (source: string, structName: string): number =>
|
||||
getStructFields(source, structName).reduce((sum, field) => {
|
||||
const count = wgslFloatCountsByType[field.type];
|
||||
if (!count) {
|
||||
throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
|
||||
}
|
||||
|
||||
return sum + count;
|
||||
}, 0);
|
||||
|
||||
const getUniformCount = (pipeline: unknown): number =>
|
||||
(pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
|
||||
|
||||
const expectStructUniformLayout = ({
|
||||
pipeline,
|
||||
source,
|
||||
structName,
|
||||
fieldNames,
|
||||
}: {
|
||||
pipeline: unknown;
|
||||
source: string;
|
||||
structName: string;
|
||||
fieldNames: Array<string>;
|
||||
}) => {
|
||||
const fields = getStructFields(source, structName);
|
||||
|
||||
expect(fields.map((field) => field.name)).toEqual(fieldNames);
|
||||
expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
|
||||
};
|
||||
|
||||
describe('WGSL uniform layout contracts', () => {
|
||||
it('keeps shared common-state uniforms aligned with WGSL', () => {
|
||||
expectStructUniformLayout({
|
||||
pipeline: CommonState,
|
||||
source: CommonState.shaderCode,
|
||||
structName: 'State',
|
||||
fieldNames: ['size', 'time', 'padding0'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps render and simulation uniforms aligned with WGSL', () => {
|
||||
expectStructUniformLayout({
|
||||
pipeline: AgentPipeline,
|
||||
source: agentShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: [
|
||||
'moveRate',
|
||||
'turnRate',
|
||||
'sensorAngleSin',
|
||||
'sensorAngleCos',
|
||||
'sensorOffset',
|
||||
'turnWhenLost',
|
||||
'individualTrailWeight',
|
||||
'agentCount',
|
||||
'introProgress',
|
||||
'color1ToColor1',
|
||||
'color1ToColor2',
|
||||
'color1ToColor3',
|
||||
'color2ToColor1',
|
||||
'color2ToColor2',
|
||||
'color2ToColor3',
|
||||
'color3ToColor1',
|
||||
'color3ToColor2',
|
||||
'color3ToColor3',
|
||||
'sourceAttractionWeight',
|
||||
'sourceSlowMoveRate',
|
||||
'sourceTrailWeightMultiplier',
|
||||
'forwardRotationScale',
|
||||
'introNearDistanceInner',
|
||||
'introNearDistanceMin',
|
||||
'introNearSensorOffsetMultiplier',
|
||||
'introTargetAngleBlend',
|
||||
'introProgressCutoff',
|
||||
'introTurnRateMultiplier',
|
||||
'introRandomTurnMultiplier',
|
||||
'introFarMoveMultiplier',
|
||||
'introNearMoveMultiplier',
|
||||
'introStepStopDistance',
|
||||
'randomTimeScale',
|
||||
],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
pipeline: BrushPipeline,
|
||||
source: brushShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: [
|
||||
'brushSize',
|
||||
'brushSizeVariation',
|
||||
'brushFeatherRatio',
|
||||
'brushMinimumFeather',
|
||||
'brushValue',
|
||||
'brushCoarseNoiseScale',
|
||||
'brushGrainNoiseScale',
|
||||
'brushGrainNoiseOffsetX',
|
||||
'brushGrainNoiseOffsetY',
|
||||
'brushDiscardThreshold',
|
||||
'brushGrainMinStrength',
|
||||
'brushGrainMaxStrength',
|
||||
'brushGeometryRadius',
|
||||
],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
pipeline: DiffusionPipeline,
|
||||
source: diffusionShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: [
|
||||
'inverseDiffusionRateTrails',
|
||||
'decayRateTrails',
|
||||
'inverseDiffusionRateBrush',
|
||||
'decayRateBrush',
|
||||
'diffusionNeighborDivisor',
|
||||
'brushDecayAlphaOffset',
|
||||
'padding0',
|
||||
'padding1',
|
||||
],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
pipeline: RenderPipeline,
|
||||
source: renderShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: [
|
||||
'colorA',
|
||||
'backgroundColorPadding0',
|
||||
'colorB',
|
||||
'backgroundColorPadding1',
|
||||
'colorC',
|
||||
'backgroundColorPadding2',
|
||||
'backgroundColor',
|
||||
'clarity',
|
||||
'traceNormalizationFloor',
|
||||
'brushColorBase',
|
||||
'brushColorStrengthMultiplier',
|
||||
'backgroundGrainStrength',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps eraser uniforms aligned with WGSL', () => {
|
||||
expectStructUniformLayout({
|
||||
pipeline: EraserAgentPipeline,
|
||||
source: eraserAgentShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: ['agentCount', 'eraserMaskAlphaThreshold', 'padding1', 'padding2'],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
pipeline: EraserTexturePipeline,
|
||||
source: eraserTextureShader,
|
||||
structName: 'Settings',
|
||||
fieldNames: [
|
||||
'eraserRadiusSquared',
|
||||
'lineDistanceEpsilon',
|
||||
'clearRed',
|
||||
'clearGreen',
|
||||
'clearBlue',
|
||||
'clearAlpha',
|
||||
'padding0',
|
||||
'padding1',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps agent-generation uniforms large enough for every generation shader', () => {
|
||||
const generationUniformCounts = [
|
||||
countUniformScalars(resizeShader, 'ResizeSettings'),
|
||||
countUniformScalars(compactionShader, 'Settings'),
|
||||
];
|
||||
|
||||
expect(Math.max(...generationUniformCounts)).toBe(
|
||||
getUniformCount(AgentGenerationPipeline)
|
||||
);
|
||||
});
|
||||
|
||||
it('guards invalid high agent color indexes instead of treating them as color 3', () => {
|
||||
expect(agentShader).toContain('colorIndex < 0.0 || 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