diff --git a/apps/typegpu-docs/src/examples/rendering/perlin-noise/index.ts b/apps/typegpu-docs/src/examples/rendering/perlin-noise/index.ts
index 1a78f5579..fd8da17ba 100644
--- a/apps/typegpu-docs/src/examples/rendering/perlin-noise/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/perlin-noise/index.ts
@@ -86,7 +86,7 @@ const renderPipelineBase = root['~unstable']
.with(gridSizeAccess, gridSize)
.with(timeAccess, time)
.with(sharpnessAccess, sharpness)
- .pipe(perlinCacheConfig.inject(dynamicLayout.$));
+ .s(perlinCacheConfig.inject(dynamicLayout.$));
const renderPipelines = {
exponential: renderPipelineBase
diff --git a/apps/typegpu-docs/src/examples/simulation/slime-mold/index.html b/apps/typegpu-docs/src/examples/simulation/slime-mold/index.html
new file mode 100644
index 000000000..aa8cc321b
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simulation/slime-mold/index.html
@@ -0,0 +1 @@
+
diff --git a/apps/typegpu-docs/src/examples/simulation/slime-mold/index.ts b/apps/typegpu-docs/src/examples/simulation/slime-mold/index.ts
new file mode 100644
index 000000000..c18655b15
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simulation/slime-mold/index.ts
@@ -0,0 +1,354 @@
+import tgpu, { prepareDispatch } from 'typegpu';
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+import { randf } from '@typegpu/noise';
+
+const root = await tgpu.init();
+const device = root.device;
+
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = canvas.getContext('webgpu') as GPUCanvasContext;
+const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+
+context.configure({
+ device: device,
+ format: presentationFormat,
+ alphaMode: 'premultiplied',
+});
+
+const resolution = d.vec2f(canvas.width, canvas.height);
+
+const Agent = d.struct({
+ position: d.vec2f,
+ angle: d.f32,
+});
+
+const Params = d.struct({
+ moveSpeed: d.f32,
+ sensorAngle: d.f32,
+ sensorDistance: d.f32,
+ turnSpeed: d.f32,
+ evaporationRate: d.f32,
+});
+const defaultParams = {
+ moveSpeed: 30.0,
+ sensorAngle: 0.5,
+ sensorDistance: 9.0,
+ turnSpeed: 2.0,
+ evaporationRate: 0.05,
+};
+
+const NUM_AGENTS = 200_000;
+const agentsData = root.createMutable(d.arrayOf(Agent, NUM_AGENTS));
+
+prepareDispatch(root, (x) => {
+ 'kernel';
+ randf.seed(x / NUM_AGENTS + 0.1);
+ const pos = randf.inUnitCircle().mul(resolution.x / 2 - 10).add(
+ resolution.div(2),
+ );
+ const angle = std.atan2(
+ resolution.y / 2 - pos.y,
+ resolution.x / 2 - pos.x,
+ );
+ agentsData.$[x] = Agent({
+ position: pos,
+ angle,
+ });
+}).dispatch(NUM_AGENTS);
+
+const params = root.createUniform(Params, defaultParams);
+const deltaTime = root.createUniform(d.f32, 0.016);
+
+const textures = [0, 1].map((i) =>
+ root['~unstable']
+ .createTexture({
+ size: [resolution.x, resolution.y],
+ format: 'rgba8unorm',
+ mipLevelCount: 1,
+ })
+ .$usage('sampled', 'storage')
+);
+
+const computeLayout = tgpu.bindGroupLayout({
+ oldState: { storageTexture: d.textureStorage2d('rgba8unorm', 'read-only') },
+ newState: { storageTexture: d.textureStorage2d('rgba8unorm', 'write-only') },
+});
+const renderLayout = tgpu.bindGroupLayout({
+ state: { texture: d.texture2d() },
+});
+
+const sense = (pos: d.v2f, angle: number, sensorAngleOffset: number) => {
+ 'kernel';
+ const sensorAngle = angle + sensorAngleOffset;
+ const sensorDir = d.vec2f(std.cos(sensorAngle), std.sin(sensorAngle));
+ const sensorPos = pos.add(sensorDir.mul(params.$.sensorDistance));
+ const dims = std.textureDimensions(computeLayout.$.oldState);
+ const dimsf = d.vec2f(dims);
+
+ const sensorPosInt = d.vec2u(
+ std.clamp(sensorPos, d.vec2f(0), dimsf.sub(d.vec2f(1))),
+ );
+ const color = std.textureLoad(computeLayout.$.oldState, sensorPosInt).xyz;
+
+ return color.x + color.y + color.z;
+};
+
+const updateAgents = tgpu['~unstable'].computeFn({
+ in: { gid: d.builtin.globalInvocationId },
+ workgroupSize: [64],
+})(({ gid }) => {
+ if (gid.x >= NUM_AGENTS) return;
+
+ randf.seed(gid.x / NUM_AGENTS + 0.1);
+
+ const dims = std.textureDimensions(computeLayout.$.oldState);
+
+ const agent = agentsData.$[gid.x];
+ const random = randf.sample();
+
+ const weightForward = sense(agent.position, agent.angle, d.f32(0));
+ const weightLeft = sense(agent.position, agent.angle, params.$.sensorAngle);
+ const weightRight = sense(
+ agent.position,
+ agent.angle,
+ -params.$.sensorAngle,
+ );
+
+ let angle = agent.angle;
+
+ if (weightForward > weightLeft && weightForward > weightRight) {
+ // Go straight
+ } else if (weightForward < weightLeft && weightForward < weightRight) {
+ // Turn randomly
+ angle = angle + (random * 2 - 1) * params.$.turnSpeed * deltaTime.$;
+ } else if (weightRight > weightLeft) {
+ // Turn right
+ angle = angle - params.$.turnSpeed * deltaTime.$;
+ } else if (weightLeft > weightRight) {
+ // Turn left
+ angle = angle + params.$.turnSpeed * deltaTime.$;
+ }
+
+ const dir = d.vec2f(std.cos(angle), std.sin(angle));
+ let newPos = agent.position.add(
+ dir.mul(params.$.moveSpeed * deltaTime.$),
+ );
+
+ const dimsf = d.vec2f(dims);
+ if (
+ newPos.x < 0 || newPos.x > dimsf.x || newPos.y < 0 || newPos.y > dimsf.y
+ ) {
+ newPos = std.clamp(newPos, d.vec2f(0), dimsf.sub(d.vec2f(1)));
+
+ if (newPos.x <= 0 || newPos.x >= dimsf.x - 1) {
+ angle = Math.PI - angle;
+ }
+ if (newPos.y <= 0 || newPos.y >= dimsf.y - 1) {
+ angle = -angle;
+ }
+
+ angle += (random - 0.5) * 0.1;
+ }
+
+ agentsData.$[gid.x] = Agent({
+ position: newPos,
+ angle,
+ });
+
+ const oldState =
+ std.textureLoad(computeLayout.$.oldState, d.vec2u(newPos)).xyz;
+ const newState = oldState.add(d.vec3f(1));
+ std.textureStore(
+ computeLayout.$.newState,
+ d.vec2u(newPos),
+ d.vec4f(newState, 1),
+ );
+});
+
+const blur = tgpu['~unstable'].computeFn({
+ in: { gid: d.builtin.globalInvocationId },
+ workgroupSize: [16, 16],
+})(({ gid }) => {
+ const dims = std.textureDimensions(computeLayout.$.oldState);
+ if (gid.x >= dims.x || gid.y >= dims.y) return;
+
+ let sum = d.vec3f();
+ let count = d.f32();
+
+ // 3x3 blur kernel
+ for (let offsetY = -1; offsetY <= 1; offsetY++) {
+ for (let offsetX = -1; offsetX <= 1; offsetX++) {
+ const samplePos = d.vec2i(gid.xy).add(d.vec2i(offsetX, offsetY));
+ const dimsi = d.vec2i(dims);
+
+ if (
+ samplePos.x >= 0 && samplePos.x < dimsi.x && samplePos.y >= 0 &&
+ samplePos.y < dimsi.y
+ ) {
+ const color =
+ std.textureLoad(computeLayout.$.oldState, d.vec2u(samplePos)).xyz;
+ sum = sum.add(color);
+ count = count + 1;
+ }
+ }
+ }
+
+ const blurred = sum.div(count);
+ const newColor = std.clamp(
+ blurred.sub(params.$.evaporationRate),
+ d.vec3f(0),
+ d.vec3f(1),
+ );
+ std.textureStore(
+ computeLayout.$.newState,
+ gid.xy,
+ d.vec4f(newColor, 1),
+ );
+});
+
+const fullScreenTriangle = tgpu['~unstable'].vertexFn({
+ in: { vertexIndex: d.builtin.vertexIndex },
+ out: { pos: d.builtin.position, uv: d.vec2f },
+})((input) => {
+ const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
+ const uv = [d.vec2f(0, 1), d.vec2f(2, 1), d.vec2f(0, -1)];
+
+ return {
+ pos: d.vec4f(pos[input.vertexIndex], 0, 1),
+ uv: uv[input.vertexIndex],
+ };
+});
+
+const filteringSampler = tgpu['~unstable'].sampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+});
+
+const fragmentShader = tgpu['~unstable'].fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+})(({ uv }) => {
+ return std.textureSample(renderLayout.$.state, filteringSampler, uv);
+});
+
+const renderPipeline = root['~unstable']
+ .withVertex(fullScreenTriangle, {})
+ .withFragment(fragmentShader, { format: presentationFormat })
+ .createPipeline();
+
+const computePipeline = root['~unstable']
+ .withCompute(updateAgents)
+ .createPipeline();
+
+const blurPipeline = root['~unstable']
+ .withCompute(blur)
+ .createPipeline();
+
+const bindGroups = [0, 1].map((i) =>
+ root.createBindGroup(computeLayout, {
+ oldState: textures[i],
+ newState: textures[1 - i],
+ })
+);
+
+const renderBindGroups = [0, 1].map((i) =>
+ root.createBindGroup(renderLayout, {
+ state: textures[i],
+ })
+);
+
+let lastTime = performance.now();
+let currentTexture = 0;
+
+function frame(now: number) {
+ const deltaTimeValue = Math.min((now - lastTime) / 1000, 0.1);
+ lastTime = now;
+
+ deltaTime.write(deltaTimeValue);
+
+ blurPipeline.with(computeLayout, bindGroups[currentTexture])
+ .dispatchWorkgroups(
+ Math.ceil(resolution.x / 16),
+ Math.ceil(resolution.y / 16),
+ );
+
+ computePipeline.with(computeLayout, bindGroups[currentTexture])
+ .dispatchWorkgroups(
+ Math.ceil(NUM_AGENTS / 64),
+ );
+
+ renderPipeline
+ .withColorAttachment({
+ view: context.getCurrentTexture().createView(),
+ loadOp: 'clear',
+ storeOp: 'store',
+ })
+ .with(
+ renderLayout,
+ renderBindGroups[1 - currentTexture],
+ ).draw(3);
+
+ root['~unstable'].flush();
+
+ currentTexture = 1 - currentTexture;
+
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+// #region Example controls and cleanup
+
+export const controls = {
+ 'Move Speed': {
+ initial: defaultParams.moveSpeed,
+ min: 0,
+ max: 100,
+ step: 1,
+ onSliderChange: (newValue: number) => {
+ params.writePartial({ moveSpeed: newValue });
+ },
+ },
+ 'Sensor Angle': {
+ initial: defaultParams.sensorAngle,
+ min: 0,
+ max: 3.14,
+ step: 0.01,
+ onSliderChange: (newValue: number) => {
+ params.writePartial({ sensorAngle: newValue });
+ },
+ },
+ 'Sensor Distance': {
+ initial: defaultParams.sensorDistance,
+ min: 1,
+ max: 50,
+ step: 0.5,
+ onSliderChange: (newValue: number) => {
+ params.writePartial({ sensorDistance: newValue });
+ },
+ },
+ 'Turn Speed': {
+ initial: defaultParams.turnSpeed,
+ min: 0,
+ max: 10,
+ step: 0.1,
+ onSliderChange: (newValue: number) => {
+ params.writePartial({ turnSpeed: newValue });
+ },
+ },
+ 'Evaporation Rate': {
+ initial: defaultParams.evaporationRate,
+ min: 0,
+ max: 0.5,
+ step: 0.01,
+ onSliderChange: (newValue: number) => {
+ params.writePartial({ evaporationRate: newValue });
+ },
+ },
+};
+
+export function onCleanup() {
+ root.destroy();
+}
+
+// #endregion
diff --git a/apps/typegpu-docs/src/examples/simulation/slime-mold/meta.json b/apps/typegpu-docs/src/examples/simulation/slime-mold/meta.json
new file mode 100644
index 000000000..db9e3e1d8
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simulation/slime-mold/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Slime Mold",
+ "category": "simulation",
+ "tags": ["experimental", "compute", "double buffering"]
+}
diff --git a/apps/typegpu-docs/src/examples/simulation/slime-mold/thumbnail.png b/apps/typegpu-docs/src/examples/simulation/slime-mold/thumbnail.png
new file mode 100644
index 000000000..6cf2edb21
Binary files /dev/null and b/apps/typegpu-docs/src/examples/simulation/slime-mold/thumbnail.png differ
diff --git a/packages/typegpu/tests/examples/individual/slime-mold.test.ts b/packages/typegpu/tests/examples/individual/slime-mold.test.ts
new file mode 100644
index 000000000..da6fc3ced
--- /dev/null
+++ b/packages/typegpu/tests/examples/individual/slime-mold.test.ts
@@ -0,0 +1,249 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { describe, expect } from 'vitest';
+import { it } from '../../utils/extendedIt.ts';
+import { runExampleTest, setupCommonMocks } from '../utils/baseTest.ts';
+
+describe('slime mold example', () => {
+ setupCommonMocks();
+
+ it('should produce valid code', async ({ device }) => {
+ const shaderCodes = await runExampleTest({
+ category: 'simulation',
+ name: 'slime-mold',
+ expectedCalls: 4,
+ }, device);
+
+ expect(shaderCodes).toMatchInlineSnapshot(`
+ "@group(0) @binding(0) var sizeUniform_1: vec3u;
+
+ var seed_5: vec2f;
+
+ fn seed_4(value: f32) {
+ seed_5 = vec2f(value, 0);
+ }
+
+ fn randSeed_3(seed: f32) {
+ seed_4(seed);
+ }
+
+ fn item_7() -> f32 {
+ var a = dot(seed_5, vec2f(23.140779495239258, 232.6168975830078));
+ var b = dot(seed_5, vec2f(54.47856521606445, 345.8415222167969));
+ seed_5.x = fract((cos(a) * 136.8168));
+ seed_5.y = fract((cos(b) * 534.7645));
+ return seed_5.y;
+ }
+
+ fn randInUnitCircle_6() -> vec2f {
+ var radius = sqrt(item_7());
+ var angle = (item_7() * 6.283185307179586);
+ return vec2f((cos(angle) * radius), (sin(angle) * radius));
+ }
+
+ struct Agent_9 {
+ position: vec2f,
+ angle: f32,
+ }
+
+ @group(0) @binding(1) var agentsData_8: array;
+
+ fn wrappedCallback_2(x: u32, _arg_1: u32, _arg_2: u32) {
+ randSeed_3(((f32(x) / 2e+5f) + 0.1));
+ var pos = ((randInUnitCircle_6() * 140) + vec2f(150, 75));
+ var angle = atan2((75 - pos.y), (150 - pos.x));
+ agentsData_8[x] = Agent_9(pos, angle);
+ }
+
+ struct mainCompute_Input_10 {
+ @builtin(global_invocation_id) id: vec3u,
+ }
+
+ @compute @workgroup_size(256, 1, 1) fn mainCompute_0(in: mainCompute_Input_10) {
+ if (any(in.id >= sizeUniform_1)) {
+ return;
+ }
+ wrappedCallback_2(in.id.x, in.id.y, in.id.z);
+ }
+
+ @group(1) @binding(0) var oldState_1: texture_storage_2d;
+
+ struct Params_3 {
+ deltaTime: f32,
+ moveSpeed: f32,
+ sensorAngle: f32,
+ sensorDistance: f32,
+ turnSpeed: f32,
+ evaporationRate: f32,
+ }
+
+ @group(0) @binding(0) var params_2: Params_3;
+
+ @group(1) @binding(1) var newState_4: texture_storage_2d;
+
+ struct blur_Input_5 {
+ @builtin(global_invocation_id) gid: vec3u,
+ }
+
+ @compute @workgroup_size(16, 16) fn blur_0(_arg_0: blur_Input_5) {
+ var dims = textureDimensions(oldState_1);
+ if (((_arg_0.gid.x >= dims.x) || (_arg_0.gid.y >= dims.y))) {
+ return;
+ }
+ var sum = vec3f();
+ var count = 0f;
+ for (var offsetY = -1; (offsetY <= 1); offsetY++) {
+ for (var offsetX = -1; (offsetX <= 1); offsetX++) {
+ var samplePos = (vec2i(_arg_0.gid.xy) + vec2i(offsetX, offsetY));
+ var dimsi = vec2i(dims);
+ if (((((samplePos.x >= 0) && (samplePos.x < dimsi.x)) && (samplePos.y >= 0)) && (samplePos.y < dimsi.y))) {
+ var color = textureLoad(oldState_1, vec2u(samplePos)).xyz;
+ sum = (sum + color);
+ count = (count + 1);
+ }
+ }
+ }
+ var blurred = (sum / count);
+ var newColor = clamp((blurred - params_2.evaporationRate), vec3f(), vec3f(1));
+ textureStore(newState_4, _arg_0.gid.xy, vec4f(newColor, 1));
+ }
+
+ var seed_3: vec2f;
+
+ fn seed_2(value: f32) {
+ seed_3 = vec2f(value, 0);
+ }
+
+ fn randSeed_1(seed: f32) {
+ seed_2(seed);
+ }
+
+ @group(1) @binding(0) var oldState_4: texture_storage_2d;
+
+ struct Agent_6 {
+ position: vec2f,
+ angle: f32,
+ }
+
+ @group(0) @binding(0) var agentsData_5: array;
+
+ fn item_8() -> f32 {
+ var a = dot(seed_3, vec2f(23.140779495239258, 232.6168975830078));
+ var b = dot(seed_3, vec2f(54.47856521606445, 345.8415222167969));
+ seed_3.x = fract((cos(a) * 136.8168));
+ seed_3.y = fract((cos(b) * 534.7645));
+ return seed_3.y;
+ }
+
+ fn randFloat01_7() -> f32 {
+ return item_8();
+ }
+
+ struct Params_11 {
+ deltaTime: f32,
+ moveSpeed: f32,
+ sensorAngle: f32,
+ sensorDistance: f32,
+ turnSpeed: f32,
+ evaporationRate: f32,
+ }
+
+ @group(0) @binding(1) var params_10: Params_11;
+
+ fn sense_9(pos: vec2f, angle: f32, sensorAngleOffset: f32) -> f32 {
+ var sensorAngle = (angle + sensorAngleOffset);
+ var sensorDir = vec2f(cos(sensorAngle), sin(sensorAngle));
+ var sensorPos = (pos + (sensorDir * params_10.sensorDistance));
+ var dims = textureDimensions(oldState_4);
+ var dimsf = vec2f(dims);
+ var sensorPosInt = vec2u(clamp(sensorPos, vec2f(), (dimsf - vec2f(1))));
+ var color = textureLoad(oldState_4, sensorPosInt).xyz;
+ return ((color.x + color.y) + color.z);
+ }
+
+ @group(1) @binding(1) var newState_12: texture_storage_2d;
+
+ struct updateAgents_Input_13 {
+ @builtin(global_invocation_id) gid: vec3u,
+ }
+
+ @compute @workgroup_size(64) fn updateAgents_0(_arg_0: updateAgents_Input_13) {
+ if ((_arg_0.gid.x >= 200000)) {
+ return;
+ }
+ randSeed_1(((f32(_arg_0.gid.x) / 2e+5f) + 0.1));
+ var dims = textureDimensions(oldState_4);
+ var agent = agentsData_5[_arg_0.gid.x];
+ var random = randFloat01_7();
+ var weightForward = sense_9(agent.position, agent.angle, 0);
+ var weightLeft = sense_9(agent.position, agent.angle, params_10.sensorAngle);
+ var weightRight = sense_9(agent.position, agent.angle, -params_10.sensorAngle);
+ var angle = agent.angle;
+ if (((weightForward > weightLeft) && (weightForward > weightRight))) {
+
+ }
+ else {
+ if (((weightForward < weightLeft) && (weightForward < weightRight))) {
+ angle = (angle + ((((random * 2) - 1) * params_10.turnSpeed) * params_10.deltaTime));
+ }
+ else {
+ if ((weightRight > weightLeft)) {
+ angle = (angle - (params_10.turnSpeed * params_10.deltaTime));
+ }
+ else {
+ if ((weightLeft > weightRight)) {
+ angle = (angle + (params_10.turnSpeed * params_10.deltaTime));
+ }
+ }
+ }
+ }
+ var dir = vec2f(cos(angle), sin(angle));
+ var newPos = (agent.position + (dir * (params_10.moveSpeed * params_10.deltaTime)));
+ var dimsf = vec2f(dims);
+ if (((((newPos.x < 0) || (newPos.x > dimsf.x)) || (newPos.y < 0)) || (newPos.y > dimsf.y))) {
+ newPos = clamp(newPos, vec2f(), (dimsf - vec2f(1)));
+ if (((newPos.x <= 0) || (newPos.x >= (dimsf.x - 1)))) {
+ angle = (3.141592653589793 - angle);
+ }
+ if (((newPos.y <= 0) || (newPos.y >= (dimsf.y - 1)))) {
+ angle = -angle;
+ }
+ angle += ((random - 0.5) * 0.1);
+ }
+ agentsData_5[_arg_0.gid.x] = Agent_6(newPos, angle);
+ var oldState = textureLoad(oldState_4, vec2u(newPos)).xyz;
+ var newState = (oldState + vec3f(1));
+ textureStore(newState_12, vec2u(newPos), vec4f(newState, 1));
+ }
+
+ struct fullScreenTriangle_Output_1 {
+ @builtin(position) pos: vec4f,
+ @location(0) uv: vec2f,
+ }
+
+ struct fullScreenTriangle_Input_2 {
+ @builtin(vertex_index) vertexIndex: u32,
+ }
+
+ @vertex fn fullScreenTriangle_0(input: fullScreenTriangle_Input_2) -> fullScreenTriangle_Output_1 {
+ var pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
+ var uv = array(vec2f(0, 1), vec2f(2, 1), vec2f(0, -1));
+ return fullScreenTriangle_Output_1(vec4f(pos[input.vertexIndex], 0, 1), uv[input.vertexIndex]);
+ }
+
+ @group(1) @binding(0) var state_4: texture_2d;
+
+ @group(0) @binding(0) var filteringSampler_5: sampler;
+
+ struct fragmentShader_Input_6 {
+ @location(0) uv: vec2f,
+ }
+
+ @fragment fn fragmentShader_3(_arg_0: fragmentShader_Input_6) -> @location(0) vec4f {
+ return textureSample(state_4, filteringSampler_5, _arg_0.uv);
+ }"
+ `);
+ });
+});