This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:53 +01:00
commit 7c70f15e49
65 changed files with 1122 additions and 1906 deletions

View file

@ -15,10 +15,7 @@ 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);
}
var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(64)
fn main(
@ -35,12 +32,12 @@ fn main(
workgroupBarrier();
var localCompactedIndex = 0u;
var agent = dead_agent();
var agent: Agent;
var isAlive = false;
if id < settings.agentCount {
agent = agents[id];
isAlive = agent.colorIndex >= 0.0;
isAlive = agents[id].colorIndex >= 0.0;
if isAlive {
agent = agents[id];
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
}
}
@ -66,16 +63,22 @@ fn main(
@compute @workgroup_size(64)
fn clearCompactedTail(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
if local_id.x == 0u {
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
}
workgroupBarrier();
if id >= settings.agentCount {
return;
}
let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
if id >= aliveAgentCount {
compactedAgents[id] = dead_agent();
if id >= clearAliveAgentCount {
compactedAgents[id].colorIndex = -1.0;
}
}

View file

@ -39,6 +39,15 @@ export class AgentGenerationPipeline {
private readonly agentCountUniformValues = new Uint32Array(
AgentGenerationPipeline.UNIFORM_COUNT
);
private readonly resizeUniformBuffer = new ArrayBuffer(
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
);
private readonly resizeUniformFloatValues = new Float32Array(
this.resizeUniformBuffer
);
private readonly resizeUniformUintValues = new Uint32Array(
this.resizeUniformBuffer
);
public constructor(
private readonly device: GPUDevice,
@ -233,11 +242,11 @@ export class AgentGenerationPipeline {
return;
}
this.device.queue.writeBuffer(
this.uniforms,
0,
new Float32Array([scale[0], scale[1], agentCount, 0])
);
this.resizeUniformFloatValues[0] = scale[0];
this.resizeUniformFloatValues[1] = scale[1];
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
this.resizeUniformUintValues[3] = 0;
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();

View file

@ -1,7 +1,7 @@
struct ResizeSettings {
scale: vec2<f32>,
agentCount: f32,
padding: f32,
agentCount: u32,
padding: u32,
};
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@ -13,10 +13,11 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(resizeSettings.agentCount) {
if id >= resizeSettings.agentCount {
return;
}
agents[id].position = agents[id].position * resizeSettings.scale;
agents[id].targetPosition = agents[id].targetPosition * resizeSettings.scale;
let scale = resizeSettings.scale;
agents[id].position = agents[id].position * scale;
agents[id].targetPosition = agents[id].targetPosition * scale;
}

View file

@ -10,21 +10,19 @@ import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly UNIFORM_COUNT = 33;
private static readonly UNIFORM_COUNT = 30;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
AgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer,
WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>();
private agentCount = 0;
@ -69,9 +67,6 @@ export class AgentPipeline {
color3ToColor1,
color3ToColor2,
color3ToColor3,
sourceAttractionWeight,
sourceSlowMoveRate,
sourceTrailWeightMultiplier,
forwardRotationScale,
introNearDistanceInner,
introNearDistanceMin,
@ -100,7 +95,7 @@ export class AgentPipeline {
this.uniformValues[4] = sensorOffsetDistance;
this.uniformValues[5] = turnWhenLost;
this.uniformValues[6] = individualTrailWeight;
this.uniformValues[7] = agentCount;
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
this.uniformValues[8] = introProgress ?? 1;
this.uniformValues[9] = color1ToColor1;
this.uniformValues[10] = color1ToColor2;
@ -111,21 +106,18 @@ export class AgentPipeline {
this.uniformValues[15] = color3ToColor1;
this.uniformValues[16] = color3ToColor2;
this.uniformValues[17] = color3ToColor3;
this.uniformValues[18] = sourceAttractionWeight;
this.uniformValues[19] = sourceSlowMoveRate;
this.uniformValues[20] = sourceTrailWeightMultiplier;
this.uniformValues[21] = forwardRotationScale;
this.uniformValues[22] = introNearDistanceInner;
this.uniformValues[23] = introNearDistanceMin;
this.uniformValues[24] = introNearSensorOffsetMultiplier;
this.uniformValues[25] = introTargetAngleBlend;
this.uniformValues[26] = introProgressCutoff;
this.uniformValues[27] = introTurnRateMultiplier;
this.uniformValues[28] = introRandomTurnMultiplier;
this.uniformValues[29] = introFarMoveMultiplier;
this.uniformValues[30] = introNearMoveMultiplier;
this.uniformValues[31] = introStepStopDistance;
this.uniformValues[32] = randomTimeScale;
this.uniformValues[18] = forwardRotationScale;
this.uniformValues[19] = introNearDistanceInner;
this.uniformValues[20] = introNearDistanceMin;
this.uniformValues[21] = introNearSensorOffsetMultiplier;
this.uniformValues[22] = introTargetAngleBlend;
this.uniformValues[23] = introProgressCutoff;
this.uniformValues[24] = introTurnRateMultiplier;
this.uniformValues[25] = introRandomTurnMultiplier;
this.uniformValues[26] = introFarMoveMultiplier;
this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance;
this.uniformValues[29] = randomTimeScale;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@ -137,14 +129,13 @@ export class AgentPipeline {
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
trailMapOut: GPUTextureView
) {
if (this.agentCount <= 0) {
return;
}
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
@ -156,32 +147,22 @@ export class AgentPipeline {
private getBindGroup(
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
trailMapOut: GPUTextureView
): GPUBindGroup {
const agentsBuffer = this.getAgentsBuffer();
let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!textureCache) {
textureCache = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>();
textureCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
}
let outputCache = textureCache.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
textureCache.set(trailMapIn, outputCache);
}
let sourceCache = outputCache.get(trailMapOut);
if (!sourceCache) {
sourceCache = new WeakMap<GPUTextureView, GPUBindGroup>();
outputCache.set(trailMapOut, sourceCache);
}
const cached = sourceCache.get(sourceMap);
const cached = outputCache.get(trailMapOut);
if (cached) {
return cached;
}
@ -209,14 +190,10 @@ export class AgentPipeline {
binding: 3,
resource: trailMapOut,
},
{
binding: 4,
resource: sourceMap,
},
],
});
sourceCache.set(sourceMap, bindGroup);
outputCache.set(trailMapOut, bindGroup);
return bindGroup;
}
@ -255,13 +232,6 @@ export class AgentPipeline {
format: 'rgba16float',
},
},
{
binding: 4,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
],
};
}

View file

@ -14,9 +14,6 @@ export interface AgentSettings {
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
sourceAttractionWeight: number;
sourceSlowMoveRate: number;
sourceTrailWeightMultiplier: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;

View file

@ -6,7 +6,7 @@ struct Settings {
sensorOffset: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
agentCount: f32,
agentCount: u32,
introProgress: f32,
color1ToColor1: f32,
color1ToColor2: f32,
@ -17,9 +17,6 @@ struct Settings {
color3ToColor1: f32,
color3ToColor2: f32,
color3ToColor3: f32,
sourceAttractionWeight: f32,
sourceSlowMoveRate: f32,
sourceTrailWeightMultiplier: f32,
forwardRotationScale: f32,
introNearDistanceInner: f32,
introNearDistanceMin: f32,
@ -37,7 +34,6 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
@group(1) @binding(4) var sourceMap: texture_2d<f32>;
@compute @workgroup_size(64)
fn main(
@ -46,7 +42,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
if id >= settings.agentCount {
return;
}
@ -67,63 +63,15 @@ fn main(
}
}
let randomSeed = random_seed(id, state.time);
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
let trailRightWeight = dot(trailRight.rgb, reactionMask);
let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
let weightForward =
trailForwardWeight + sourceForwardWeight * settings.sourceAttractionWeight;
let weightLeft = trailLeftWeight + sourceLeftWeight * settings.sourceAttractionWeight;
let weightRight =
trailRightWeight + sourceRightWeight * settings.sourceAttractionWeight;
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(position), 0);
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
var moveRate = settings.moveRate * mix(1.0, settings.sourceSlowMoveRate, sourceAtAgentStrength);
var introTargetOffset = vec2<f32>(0.0, 0.0);
var introTargetDistance = 0.0;
let randomSeed = random_seed(id, state.time);
var rotation = 0.0;
var step = vec2<f32>(0.0, 0.0);
if hasIntroTarget {
introTargetOffset = targetPosition - position;
introTargetDistance = length(introTargetOffset);
let introTargetOffset = targetPosition - position;
let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
@ -148,19 +96,46 @@ fn main(
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
moveRate = min(
let moveRate = min(
settings.moveRate *
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
introTargetDistance
);
}
var step = direction * moveRate;
if hasIntroTarget {
step = vec2<f32>(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
} else {
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
step = direction * settings.moveRate;
}
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
@ -169,14 +144,9 @@ fn main(
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
}
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
let trailWeight =
settings.individualTrailWeight *
(1.0 + sourceBelowStrength * settings.sourceTrailWeightMultiplier);
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * trailWeight,
trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0)
);

View file

@ -31,8 +31,6 @@ export const setBrushUniformValues = (
brushSize,
brushSizeVariation,
brushAlpha,
brushFeatherRatio,
brushMinimumFeather,
brushDiscardThreshold,
brushCoarseNoiseScale,
brushGrainNoiseScale,
@ -47,30 +45,24 @@ export const setBrushUniformValues = (
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
const brushFeather = Math.max(
brushMinimumFeatherPixels,
brushRadius * brushFeatherRatio
);
const brushGeometryRadius =
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
target[0] = brushRadius;
target[1] = brushRadiusVariation;
target[2] = brushFeatherRatio;
target[3] = brushMinimumFeatherPixels;
target[1] = brushRadiusVariation * 2;
target[2] = brushGeometryRadius * brushGeometryRadius;
target[3] = brushGeometryRadius;
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
target[8] = brushCoarseNoiseScale * safePixelRatio;
target[9] = brushGrainNoiseScale * safePixelRatio;
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[10] = brushGrainNoiseOffsetX;
target[11] = brushGrainNoiseOffsetY;
target[12] = brushDiscardThreshold;
target[13] = brushGrainMinStrength;
target[14] = brushGrainMaxStrength;
target[15] = brushGeometryRadius;
target[15] = 0;
};
export class BrushPipeline {

View file

@ -2,8 +2,6 @@ export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
brushAlpha: number;
brushFeatherRatio: number;
brushMinimumFeather: number;
brushDiscardThreshold: number;
brushCoarseNoiseScale: number;
brushGrainNoiseScale: number;

View file

@ -1,8 +1,8 @@
struct Settings {
brushSize: f32,
brushSizeVariation: f32,
brushFeatherRatio: f32,
brushMinimumFeather: f32,
brushGeometryRadiusSquared: f32,
brushGeometryRadius: f32,
brushValue: vec4<f32>,
brushCoarseNoiseScale: f32,
brushGrainNoiseScale: f32,
@ -11,7 +11,6 @@ struct Settings {
brushDiscardThreshold: f32,
brushGrainMinStrength: f32,
brushGrainMaxStrength: f32,
brushGeometryRadius: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -20,7 +19,8 @@ struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct BrushTargets {
@ -34,24 +34,36 @@ fn vertex(
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
if denominator > 0.0001 {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
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);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
let distanceSquared = distanceSquaredFromLine(screenPosition, start, end);
if distanceSquared > settings.brushGeometryRadius * settings.brushGeometryRadius {
let distanceSquared = distanceSquaredFromLine(
screenPosition,
start,
direction,
inverseLengthSquared
);
if distanceSquared > settings.brushGeometryRadiusSquared {
discard;
}
let strength = brushStrength(screenPosition, start, end, distanceSquared);
let strength = brushStrength(screenPosition, distanceSquared);
if(strength < settings.brushDiscardThreshold) {
discard;
@ -63,27 +75,28 @@ fn fragmentMrt(
fn brushStrength(
screenPosition: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>,
distanceSquared: f32
) -> f32 {
let distance = sqrt(distanceSquared);
let coarseNoise = textureSample(
let coarseNoise = textureSampleLevel(
noise,
noiseSampler,
fract(screenPosition / settings.brushCoarseNoiseScale)
screenPosition * settings.brushCoarseNoiseScale,
0.0
).r;
let grainNoise = textureSample(
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
let edge = 1.0 - step(radius, distance);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
return 0.0;
}
let grainNoise = textureSampleLevel(
noise,
noiseSampler,
fract(
screenPosition / settings.brushGrainNoiseScale +
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY)
)
screenPosition * settings.brushGrainNoiseScale +
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
let feather = max(settings.brushMinimumFeather, settings.brushSize * settings.brushFeatherRatio);
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
}
@ -91,16 +104,19 @@ fn brushOutput(strength: f32) -> vec4<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0, 1);
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -83,6 +83,8 @@ export class CommonState {
{
binding: 1,
resource: this.device.createSampler({
addressModeU: 'repeat',
addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
}),

View file

@ -3,9 +3,9 @@ struct Settings {
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborDivisor: f32,
brushDecayAlphaOffset: f32,
padding0: f32,
diffusionNeighborScale: f32,
brushDecayAlphaMultiplier: f32,
brushDecayAlphaSubtract: f32,
padding1: f32,
};
@ -60,8 +60,26 @@ fn main(
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex];
let random = random_from_pixel(pixel);
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
let r2 = random * random;
let r4 = r2 * r2;
let r8 = r4 * r4;
let r16 = r8 * r8;
let trailWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateTrails
);
let brushWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateBrush
);
current += (
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
@ -73,11 +91,11 @@ fn main(
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
) / max(1.0, settings.diffusionNeighborDivisor);
) * settings.diffusionNeighborScale;
let decayed = clamp(vec4(
current.rgb * settings.decayRateTrails,
max(0, current.a + (current.a - settings.brushDecayAlphaOffset) * settings.decayRateBrush)
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
), vec4(0), vec4(1));
textureStore(trailMapOut, pixel, decayed);
@ -110,9 +128,14 @@ fn random_from_pixel(pixel: vec2<i32>) -> f32 {
return f32(hash) * 2.3283064365386963e-10;
}
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
let r = clamp(random, 0.0, 1.0);
fn diffusion_weight(
r: f32,
r2: f32,
r4: f32,
r8: f32,
r16: f32,
inverseRate: f32
) -> f32 {
if inverseRate < 1.0 {
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
return mix(
@ -122,22 +145,18 @@ fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
);
}
let r2 = r * r;
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
let r4 = r2 * r2;
if inverseRate < 4.0 {
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
let r8 = r4 * r4;
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

@ -41,12 +41,18 @@ export const setDiffusionUniformValues = (
}: DiffusionUniformSettings
): void => {
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
const brushDecayRate = decayRateBrush / decayDivisor;
const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
? Math.max(1, diffusionNeighborDivisor)
: 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
target[3] = decayRateBrush / decayDivisor;
target[4] = diffusionNeighborDivisor;
target[5] = brushDecayAlphaOffset;
target[3] = brushDecayRate;
target[4] = 1 / neighborDivisor;
target[5] = 1 + brushDecayRate;
target[6] = brushDecayAlphaOffset * brushDecayRate;
target[7] = 0;
};
export class DiffusionPipeline {

View file

@ -14,6 +14,7 @@ export class EraserAgentPipeline {
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
@ -93,7 +94,7 @@ export class EraserAgentPipeline {
this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0;
this.uniformValues[0] = agentCount;
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;

View file

@ -1,5 +1,5 @@
struct Settings {
agentCount: f32,
agentCount: u32,
eraserMaskAlphaThreshold: f32,
padding1: f32,
padding2: f32,
@ -15,7 +15,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
if id >= settings.agentCount {
return;
}
@ -32,7 +32,7 @@ fn main(
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
if maskSample.a < settings.eraserMaskAlphaThreshold {
if maskSample.r < settings.eraserMaskAlphaThreshold {
agents[id].colorIndex = -1.0;
}
}

View file

@ -64,7 +64,12 @@ export class EraserTexturePipeline {
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [
'r8unorm',
'rgba16float',
'rgba16float',
'rgba16float',
]);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -119,7 +124,7 @@ export class EraserTexturePipeline {
this.uniformValues[3] = eraserClearGreen;
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = 0;
this.uniformValues[6] = eraserRadius;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged(
this.device,
@ -225,7 +230,7 @@ export class EraserTexturePipeline {
private createPipeline(
shaderModule: GPUShaderModule,
fragmentEntryPoint: string,
colorTargetCount: number
targetFormats: Array<GPUTextureFormat>
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
@ -258,9 +263,7 @@ export class EraserTexturePipeline {
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
targets: Array.from({ length: colorTargetCount }, () => ({
format: 'rgba16float' as const,
})),
targets: targetFormats.map((format) => ({ format })),
},
primitive: {
topology: 'triangle-list',

View file

@ -5,7 +5,7 @@ struct Settings {
clearGreen: f32,
clearBlue: f32,
clearAlpha: f32,
padding0: f32,
eraserRadius: f32,
padding1: f32,
};
@ -15,7 +15,8 @@ struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct EraserTextureTargets {
@ -37,19 +38,26 @@ fn vertex(
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
if denominator > settings.lineDistanceEpsilon {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius);
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);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> @location(0) vec4<f32> {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@ -60,9 +68,10 @@ fn fragment(
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserTextureTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@ -74,14 +83,19 @@ fn fragmentMrt(
fn fragmentCombined(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
let cleared = getEraserClearValue();
return EraserCombinedTargets(cleared, cleared, cleared, cleared);
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
}
fn getEraserMaskValue() -> vec4<f32> {
return vec4<f32>(settings.clearAlpha, 0.0, 0.0, 1.0);
}
fn getEraserClearValue() -> vec4<f32> {
@ -96,21 +110,25 @@ fn getEraserClearValue() -> vec4<f32> {
fn shouldDiscardEraserFragment(
screenPosition: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>
direction: vec2<f32>,
inverseLengthSquared: f32
) -> bool {
return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= settings.lineDistanceEpsilon {
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -4,6 +4,7 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
@ -13,8 +14,6 @@ export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly sampledPipeline: GPURenderPipeline;
private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -35,14 +34,8 @@ export class RenderPipeline {
const vertex = setUpFullScreenQuad(device);
this.sampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
const format = navigator.gpu.getPreferredCanvasFormat();
this.pipeline = this.createPipeline(format, vertex, 'fragment');
this.sampledPipeline = this.createPipeline(format, vertex, 'fragmentSampled');
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -84,25 +77,25 @@ export class RenderPipeline {
renderBrushColorStrengthMultiplier,
backgroundGrainStrength,
}: RenderSettings & {
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}) {
const [a, b, c] = channelColors;
this.uniformValues[0] = a[0];
this.uniformValues[1] = a[1];
this.uniformValues[2] = a[2];
this.uniformValues[0] = rgbChannelToUnit(a[0]);
this.uniformValues[1] = rgbChannelToUnit(a[1]);
this.uniformValues[2] = rgbChannelToUnit(a[2]);
this.uniformValues[3] = 0;
this.uniformValues[4] = b[0];
this.uniformValues[5] = b[1];
this.uniformValues[6] = b[2];
this.uniformValues[4] = rgbChannelToUnit(b[0]);
this.uniformValues[5] = rgbChannelToUnit(b[1]);
this.uniformValues[6] = rgbChannelToUnit(b[2]);
this.uniformValues[7] = 0;
this.uniformValues[8] = c[0];
this.uniformValues[9] = c[1];
this.uniformValues[10] = c[2];
this.uniformValues[8] = rgbChannelToUnit(c[0]);
this.uniformValues[9] = rgbChannelToUnit(c[1]);
this.uniformValues[10] = rgbChannelToUnit(c[2]);
this.uniformValues[11] = 0;
this.uniformValues[12] = backgroundColor[0];
this.uniformValues[13] = backgroundColor[1];
this.uniformValues[14] = backgroundColor[2];
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
this.uniformValues[15] = clarity;
this.uniformValues[16] = renderTraceNormalizationFloor;
this.uniformValues[17] = renderBrushColorBase;
@ -162,7 +155,7 @@ export class RenderPipeline {
},
],
});
passEncoder.setPipeline(this.sampledPipeline);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(3, 1);
@ -193,10 +186,6 @@ export class RenderPipeline {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: colorTexture,
@ -226,13 +215,6 @@ export class RenderPipeline {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,

View file

@ -14,27 +14,17 @@ 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>;
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
@fragment
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, 0), textureSize - vec2<i32>(1, 1));
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, vec2<i32>(position.xy));
}
const NOISE_TEXTURE_MASK = 2047u;
@fragment
fn fragmentSampled(
@location(0) uv: vec2<f32>,
@builtin(position) position: vec4<f32>
) -> @location(0) vec4<f32> {
let traces = textureSample(trailMap, Sampler, uv);
let sources = textureSample(sourceMap, Sampler, uv);
return renderColor(traces, sources, vec2<i32>(position.xy));
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, pixel);
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
@ -48,6 +38,16 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
clarity(traces.g),
clarity(traces.b)
);
if max(max(sources.r, sources.g), sources.b) <= 0.0 {
let traceColor =
traceStrengths.r * settings.colorA
+ traceStrengths.g * settings.colorB
+ traceStrengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor);
let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b);
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
}
let sourceStrengths = vec3(
clarity(sources.r),
clarity(sources.g),
@ -89,8 +89,7 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
let noiseCoord = pixel % noiseSize;
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
return clamp(