Skip to content

Commit a8aed2e

Browse files
authored
docs: 2d slime mold simulation (#1776)
1 parent bfe0733 commit a8aed2e

File tree

5 files changed

+609
-0
lines changed

5 files changed

+609
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<canvas></canvas>
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import tgpu from 'typegpu';
2+
import * as d from 'typegpu/data';
3+
import * as std from 'typegpu/std';
4+
import { randf } from '@typegpu/noise';
5+
6+
const root = await tgpu.init();
7+
const device = root.device;
8+
9+
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
10+
const context = canvas.getContext('webgpu') as GPUCanvasContext;
11+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
12+
13+
context.configure({
14+
device: device,
15+
format: presentationFormat,
16+
alphaMode: 'premultiplied',
17+
});
18+
19+
const resolution = d.vec2f(canvas.width, canvas.height);
20+
21+
const Agent = d.struct({
22+
position: d.vec2f,
23+
angle: d.f32,
24+
});
25+
26+
const Params = d.struct({
27+
moveSpeed: d.f32,
28+
sensorAngle: d.f32,
29+
sensorDistance: d.f32,
30+
turnSpeed: d.f32,
31+
evaporationRate: d.f32,
32+
});
33+
const defaultParams = {
34+
moveSpeed: 50.0,
35+
sensorAngle: 0.5,
36+
sensorDistance: 15.0,
37+
turnSpeed: 2.0,
38+
evaporationRate: 0.05,
39+
};
40+
41+
const NUM_AGENTS = 200_000;
42+
const agentsData = root.createMutable(d.arrayOf(Agent, NUM_AGENTS));
43+
44+
root['~unstable'].prepareDispatch((x) => {
45+
'use gpu';
46+
randf.seed(x / NUM_AGENTS + 0.1);
47+
const pos = randf.inUnitCircle().mul(resolution.x / 2 - 10).add(
48+
resolution.div(2),
49+
);
50+
const angle = std.atan2(
51+
resolution.y / 2 - pos.y,
52+
resolution.x / 2 - pos.x,
53+
);
54+
agentsData.$[x] = Agent({
55+
position: pos,
56+
angle,
57+
});
58+
}).dispatchThreads(NUM_AGENTS);
59+
60+
const params = root.createUniform(Params, defaultParams);
61+
const deltaTime = root.createUniform(d.f32, 0.016);
62+
63+
const textures = [0, 1].map((i) =>
64+
root['~unstable']
65+
.createTexture({
66+
size: [resolution.x, resolution.y],
67+
format: 'rgba8unorm',
68+
mipLevelCount: 1,
69+
})
70+
.$usage('sampled', 'storage')
71+
);
72+
73+
const computeLayout = tgpu.bindGroupLayout({
74+
oldState: { storageTexture: d.textureStorage2d('rgba8unorm', 'read-only') },
75+
newState: { storageTexture: d.textureStorage2d('rgba8unorm', 'write-only') },
76+
});
77+
const renderLayout = tgpu.bindGroupLayout({
78+
state: { texture: d.texture2d() },
79+
});
80+
81+
const sense = (pos: d.v2f, angle: number, sensorAngleOffset: number) => {
82+
'use gpu';
83+
const sensorAngle = angle + sensorAngleOffset;
84+
const sensorDir = d.vec2f(std.cos(sensorAngle), std.sin(sensorAngle));
85+
const sensorPos = pos.add(sensorDir.mul(params.$.sensorDistance));
86+
const dims = std.textureDimensions(computeLayout.$.oldState);
87+
const dimsf = d.vec2f(dims);
88+
89+
const sensorPosInt = d.vec2u(
90+
std.clamp(sensorPos, d.vec2f(0), dimsf.sub(d.vec2f(1))),
91+
);
92+
const color = std.textureLoad(computeLayout.$.oldState, sensorPosInt).xyz;
93+
94+
return color.x + color.y + color.z;
95+
};
96+
97+
const updateAgents = tgpu['~unstable'].computeFn({
98+
in: { gid: d.builtin.globalInvocationId },
99+
workgroupSize: [64],
100+
})(({ gid }) => {
101+
if (gid.x >= NUM_AGENTS) return;
102+
103+
randf.seed(gid.x / NUM_AGENTS + 0.1);
104+
105+
const dims = std.textureDimensions(computeLayout.$.oldState);
106+
107+
const agent = agentsData.$[gid.x];
108+
const random = randf.sample();
109+
110+
const weightForward = sense(agent.position, agent.angle, d.f32(0));
111+
const weightLeft = sense(agent.position, agent.angle, params.$.sensorAngle);
112+
const weightRight = sense(
113+
agent.position,
114+
agent.angle,
115+
-params.$.sensorAngle,
116+
);
117+
118+
let angle = agent.angle;
119+
120+
if (weightForward > weightLeft && weightForward > weightRight) {
121+
// Go straight
122+
} else if (weightForward < weightLeft && weightForward < weightRight) {
123+
// Turn randomly
124+
angle = angle + (random * 2 - 1) * params.$.turnSpeed * deltaTime.$;
125+
} else if (weightRight > weightLeft) {
126+
// Turn right
127+
angle = angle - params.$.turnSpeed * deltaTime.$;
128+
} else if (weightLeft > weightRight) {
129+
// Turn left
130+
angle = angle + params.$.turnSpeed * deltaTime.$;
131+
}
132+
133+
const dir = d.vec2f(std.cos(angle), std.sin(angle));
134+
let newPos = agent.position.add(
135+
dir.mul(params.$.moveSpeed * deltaTime.$),
136+
);
137+
138+
const dimsf = d.vec2f(dims);
139+
if (
140+
newPos.x < 0 || newPos.x > dimsf.x || newPos.y < 0 || newPos.y > dimsf.y
141+
) {
142+
newPos = std.clamp(newPos, d.vec2f(0), dimsf.sub(d.vec2f(1)));
143+
144+
if (newPos.x <= 0 || newPos.x >= dimsf.x - 1) {
145+
angle = Math.PI - angle;
146+
}
147+
if (newPos.y <= 0 || newPos.y >= dimsf.y - 1) {
148+
angle = -angle;
149+
}
150+
151+
angle += (random - 0.5) * 0.1;
152+
}
153+
154+
agentsData.$[gid.x] = Agent({
155+
position: newPos,
156+
angle,
157+
});
158+
159+
const oldState =
160+
std.textureLoad(computeLayout.$.oldState, d.vec2u(newPos)).xyz;
161+
const newState = oldState.add(d.vec3f(1));
162+
std.textureStore(
163+
computeLayout.$.newState,
164+
d.vec2u(newPos),
165+
d.vec4f(newState, 1),
166+
);
167+
});
168+
169+
const blur = tgpu['~unstable'].computeFn({
170+
in: { gid: d.builtin.globalInvocationId },
171+
workgroupSize: [16, 16],
172+
})(({ gid }) => {
173+
const dims = std.textureDimensions(computeLayout.$.oldState);
174+
if (gid.x >= dims.x || gid.y >= dims.y) return;
175+
176+
let sum = d.vec3f();
177+
let count = d.f32();
178+
179+
// 3x3 blur kernel
180+
for (let offsetY = -1; offsetY <= 1; offsetY++) {
181+
for (let offsetX = -1; offsetX <= 1; offsetX++) {
182+
const samplePos = d.vec2i(gid.xy).add(d.vec2i(offsetX, offsetY));
183+
const dimsi = d.vec2i(dims);
184+
185+
if (
186+
samplePos.x >= 0 && samplePos.x < dimsi.x && samplePos.y >= 0 &&
187+
samplePos.y < dimsi.y
188+
) {
189+
const color =
190+
std.textureLoad(computeLayout.$.oldState, d.vec2u(samplePos)).xyz;
191+
sum = sum.add(color);
192+
count = count + 1;
193+
}
194+
}
195+
}
196+
197+
const blurred = sum.div(count);
198+
const newColor = std.clamp(
199+
blurred.sub(params.$.evaporationRate),
200+
d.vec3f(0),
201+
d.vec3f(1),
202+
);
203+
std.textureStore(
204+
computeLayout.$.newState,
205+
gid.xy,
206+
d.vec4f(newColor, 1),
207+
);
208+
});
209+
210+
const fullScreenTriangle = tgpu['~unstable'].vertexFn({
211+
in: { vertexIndex: d.builtin.vertexIndex },
212+
out: { pos: d.builtin.position, uv: d.vec2f },
213+
})((input) => {
214+
const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
215+
const uv = [d.vec2f(0, 1), d.vec2f(2, 1), d.vec2f(0, -1)];
216+
217+
return {
218+
pos: d.vec4f(pos[input.vertexIndex], 0, 1),
219+
uv: uv[input.vertexIndex],
220+
};
221+
});
222+
223+
const filteringSampler = root['~unstable'].createSampler({
224+
magFilter: 'linear',
225+
minFilter: 'linear',
226+
});
227+
228+
const fragmentShader = tgpu['~unstable'].fragmentFn({
229+
in: { uv: d.vec2f },
230+
out: d.vec4f,
231+
})(({ uv }) => {
232+
return std.textureSample(renderLayout.$.state, filteringSampler.$, uv);
233+
});
234+
235+
const renderPipeline = root['~unstable']
236+
.withVertex(fullScreenTriangle, {})
237+
.withFragment(fragmentShader, { format: presentationFormat })
238+
.createPipeline();
239+
240+
const computePipeline = root['~unstable']
241+
.withCompute(updateAgents)
242+
.createPipeline();
243+
244+
const blurPipeline = root['~unstable']
245+
.withCompute(blur)
246+
.createPipeline();
247+
248+
const bindGroups = [0, 1].map((i) =>
249+
root.createBindGroup(computeLayout, {
250+
oldState: textures[i],
251+
newState: textures[1 - i],
252+
})
253+
);
254+
255+
const renderBindGroups = [0, 1].map((i) =>
256+
root.createBindGroup(renderLayout, {
257+
state: textures[i],
258+
})
259+
);
260+
261+
let lastTime = performance.now();
262+
let currentTexture = 0;
263+
264+
function frame(now: number) {
265+
const deltaTimeValue = Math.min((now - lastTime) / 1000, 0.1);
266+
lastTime = now;
267+
268+
deltaTime.write(deltaTimeValue);
269+
270+
blurPipeline.with(computeLayout, bindGroups[currentTexture])
271+
.dispatchWorkgroups(
272+
Math.ceil(resolution.x / 16),
273+
Math.ceil(resolution.y / 16),
274+
);
275+
276+
computePipeline.with(computeLayout, bindGroups[currentTexture])
277+
.dispatchWorkgroups(
278+
Math.ceil(NUM_AGENTS / 64),
279+
);
280+
281+
renderPipeline
282+
.withColorAttachment({
283+
view: context.getCurrentTexture().createView(),
284+
loadOp: 'clear',
285+
storeOp: 'store',
286+
})
287+
.with(
288+
renderLayout,
289+
renderBindGroups[1 - currentTexture],
290+
).draw(3);
291+
292+
root['~unstable'].flush();
293+
294+
currentTexture = 1 - currentTexture;
295+
296+
requestAnimationFrame(frame);
297+
}
298+
requestAnimationFrame(frame);
299+
300+
// #region Example controls and cleanup
301+
302+
export const controls = {
303+
'Move Speed': {
304+
initial: defaultParams.moveSpeed,
305+
min: 0,
306+
max: 100,
307+
step: 1,
308+
onSliderChange: (newValue: number) => {
309+
params.writePartial({ moveSpeed: newValue });
310+
},
311+
},
312+
'Sensor Angle': {
313+
initial: defaultParams.sensorAngle,
314+
min: 0,
315+
max: 3.14,
316+
step: 0.01,
317+
onSliderChange: (newValue: number) => {
318+
params.writePartial({ sensorAngle: newValue });
319+
},
320+
},
321+
'Sensor Distance': {
322+
initial: defaultParams.sensorDistance,
323+
min: 1,
324+
max: 50,
325+
step: 0.5,
326+
onSliderChange: (newValue: number) => {
327+
params.writePartial({ sensorDistance: newValue });
328+
},
329+
},
330+
'Turn Speed': {
331+
initial: defaultParams.turnSpeed,
332+
min: 0,
333+
max: 10,
334+
step: 0.1,
335+
onSliderChange: (newValue: number) => {
336+
params.writePartial({ turnSpeed: newValue });
337+
},
338+
},
339+
'Evaporation Rate': {
340+
initial: defaultParams.evaporationRate,
341+
min: 0,
342+
max: 0.5,
343+
step: 0.01,
344+
onSliderChange: (newValue: number) => {
345+
params.writePartial({ evaporationRate: newValue });
346+
},
347+
},
348+
};
349+
350+
export function onCleanup() {
351+
root.destroy();
352+
}
353+
354+
// #endregion
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"title": "Slime Mold",
3+
"category": "simulation",
4+
"tags": ["experimental", "compute", "double buffering"]
5+
}
678 KB
Loading

0 commit comments

Comments
 (0)