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); + }" + `); + }); +});