Spaces:
Running
Running
| /** | |
| * 🌿 Ivy's GPU Art Studio | |
| * Tab 3: Particle Art | |
| * | |
| * GPU-computed particle systems with various behaviors | |
| */ | |
| class ParticlesRenderer { | |
| constructor() { | |
| this.device = null; | |
| this.context = null; | |
| this.format = null; | |
| // Particle parameters | |
| this.params = { | |
| count: 10000, | |
| mode: 0, // 0=attract, 1=repel, 2=orbit, 3=swarm, 4=ivy | |
| size: 2.0, | |
| speed: 1.0, | |
| palette: 0, // 0=ivy, 1=rainbow, 2=fire, 3=ocean, 4=neon, 5=gold | |
| trail: 0.1 // 0=no trail, higher=more trail | |
| }; | |
| this.maxParticles = 100000; | |
| this.input = null; | |
| this.animationLoop = null; | |
| this.isActive = false; | |
| this.time = 0; | |
| } | |
| async init(device, context, format, canvas) { | |
| this.device = device; | |
| this.context = context; | |
| this.format = format; | |
| this.canvas = canvas; | |
| await this.createBuffers(); | |
| await this.createPipelines(); | |
| this.input = new WebGPUUtils.InputHandler(canvas); | |
| this.animationLoop = new WebGPUUtils.AnimationLoop((dt, time) => { | |
| this.time = time; | |
| this.simulate(dt); | |
| this.render(); | |
| }); | |
| } | |
| async createBuffers() { | |
| // Particle positions (vec2) and velocities (vec2) = 16 bytes per particle | |
| this.particleBuffer = this.device.createBuffer({ | |
| label: "Particle Buffer", | |
| size: this.maxParticles * 16, | |
| usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | |
| }); | |
| // Uniform buffer | |
| this.uniformBuffer = this.device.createBuffer({ | |
| label: "Particle Uniforms", | |
| size: 64, | |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST | |
| }); | |
| // Initialize particles | |
| this.respawnParticles(); | |
| } | |
| respawnParticles() { | |
| const data = new Float32Array(this.maxParticles * 4); | |
| for (let i = 0; i < this.maxParticles; i++) { | |
| const offset = i * 4; | |
| // Random position | |
| data[offset] = Math.random() * 2 - 1; // x | |
| data[offset + 1] = Math.random() * 2 - 1; // y | |
| // Random velocity | |
| const angle = Math.random() * Math.PI * 2; | |
| const speed = Math.random() * 0.01; | |
| data[offset + 2] = Math.cos(angle) * speed; // vx | |
| data[offset + 3] = Math.sin(angle) * speed; // vy | |
| } | |
| this.device.queue.writeBuffer(this.particleBuffer, 0, data); | |
| } | |
| async createPipelines() { | |
| // Compute shader | |
| const computeShader = this.device.createShaderModule({ | |
| label: "Particle Compute Shader", | |
| code: this.getComputeShaderCode() | |
| }); | |
| // Render shader | |
| const renderShader = this.device.createShaderModule({ | |
| label: "Particle Render Shader", | |
| code: this.getRenderShaderCode() | |
| }); | |
| // Bind group layout for compute | |
| this.computeBindGroupLayout = this.device.createBindGroupLayout({ | |
| entries: [ | |
| { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } }, | |
| { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } } | |
| ] | |
| }); | |
| // Bind group layout for render | |
| this.renderBindGroupLayout = this.device.createBindGroupLayout({ | |
| entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }] | |
| }); | |
| // Compute pipeline | |
| this.computePipeline = this.device.createComputePipeline({ | |
| label: "Particle Compute Pipeline", | |
| layout: this.device.createPipelineLayout({ | |
| bindGroupLayouts: [this.computeBindGroupLayout] | |
| }), | |
| compute: { | |
| module: computeShader, | |
| entryPoint: "main" | |
| } | |
| }); | |
| // Render pipeline | |
| this.renderPipeline = this.device.createRenderPipeline({ | |
| label: "Particle Render Pipeline", | |
| layout: this.device.createPipelineLayout({ | |
| bindGroupLayouts: [this.renderBindGroupLayout] | |
| }), | |
| vertex: { | |
| module: renderShader, | |
| entryPoint: "vertexMain", | |
| buffers: [ | |
| { | |
| arrayStride: 16, // vec4f (pos.xy, vel.xy) | |
| stepMode: "instance", | |
| attributes: [ | |
| { shaderLocation: 0, offset: 0, format: "float32x2" }, // position | |
| { shaderLocation: 1, offset: 8, format: "float32x2" } // velocity | |
| ] | |
| } | |
| ] | |
| }, | |
| fragment: { | |
| module: renderShader, | |
| entryPoint: "fragmentMain", | |
| targets: [ | |
| { | |
| format: this.format, | |
| blend: { | |
| color: { | |
| srcFactor: "src-alpha", | |
| dstFactor: "one", | |
| operation: "add" | |
| }, | |
| alpha: { | |
| srcFactor: "one", | |
| dstFactor: "one", | |
| operation: "add" | |
| } | |
| } | |
| } | |
| ] | |
| }, | |
| primitive: { | |
| topology: "triangle-list" | |
| } | |
| }); | |
| // Create bind groups | |
| this.computeBindGroup = this.device.createBindGroup({ | |
| layout: this.computeBindGroupLayout, | |
| entries: [ | |
| { binding: 0, resource: { buffer: this.uniformBuffer } }, | |
| { binding: 1, resource: { buffer: this.particleBuffer } } | |
| ] | |
| }); | |
| this.renderBindGroup = this.device.createBindGroup({ | |
| layout: this.renderBindGroupLayout, | |
| entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }] | |
| }); | |
| } | |
| start() { | |
| this.isActive = true; | |
| this.animationLoop.start(); | |
| } | |
| stop() { | |
| this.isActive = false; | |
| this.animationLoop.stop(); | |
| } | |
| reset() { | |
| this.respawnParticles(); | |
| } | |
| setCount(count) { | |
| this.params.count = Math.min(count, this.maxParticles); | |
| } | |
| setMode(mode) { | |
| const modes = { attract: 0, repel: 1, orbit: 2, swarm: 3, ivy: 4 }; | |
| this.params.mode = modes[mode] || 0; | |
| } | |
| setSize(size) { | |
| this.params.size = size; | |
| } | |
| setSpeed(speed) { | |
| this.params.speed = speed; | |
| } | |
| setPalette(palette) { | |
| const palettes = { ivy: 0, rainbow: 1, fire: 2, ocean: 3, neon: 4, gold: 5 }; | |
| this.params.palette = palettes[palette] ?? 0; | |
| } | |
| setTrail(trail) { | |
| this.params.trail = trail; | |
| } | |
| simulate(dt) { | |
| if (!this.isActive) return; | |
| const aspect = this.canvas.width / this.canvas.height; | |
| // Update uniforms | |
| const uniforms = new Float32Array([ | |
| this.params.count, | |
| dt * this.params.speed, | |
| this.params.mode, | |
| this.params.size, | |
| this.input.mouseX * 2 - 1, // Normalized to -1..1 | |
| this.input.mouseY * 2 - 1, | |
| this.input.isPressed ? 1.0 : 0.0, | |
| this.time, | |
| aspect, | |
| this.params.palette, | |
| this.params.trail, | |
| 0.0 // padding | |
| ]); | |
| this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms); | |
| // Run compute shader | |
| const commandEncoder = this.device.createCommandEncoder(); | |
| const computePass = commandEncoder.beginComputePass(); | |
| computePass.setPipeline(this.computePipeline); | |
| computePass.setBindGroup(0, this.computeBindGroup); | |
| computePass.dispatchWorkgroups(Math.ceil(this.params.count / 64)); | |
| computePass.end(); | |
| this.device.queue.submit([commandEncoder.finish()]); | |
| } | |
| render() { | |
| if (!this.isActive) return; | |
| WebGPUUtils.resizeCanvasToDisplaySize(this.canvas, window.devicePixelRatio); | |
| // Trail effect: use semi-transparent clear based on trail value | |
| // Lower alpha = more trail persistence | |
| const trailAlpha = 1.0 - this.params.trail * 1.8; // 0.1 trail => 0.82 alpha | |
| const commandEncoder = this.device.createCommandEncoder(); | |
| const renderPass = commandEncoder.beginRenderPass({ | |
| colorAttachments: [ | |
| { | |
| view: this.context.getCurrentTexture().createView(), | |
| clearValue: { r: 0.02 * trailAlpha, g: 0.02 * trailAlpha, b: 0.05 * trailAlpha, a: trailAlpha }, | |
| loadOp: "clear", | |
| storeOp: "store" | |
| } | |
| ] | |
| }); | |
| renderPass.setPipeline(this.renderPipeline); | |
| renderPass.setBindGroup(0, this.renderBindGroup); | |
| renderPass.setVertexBuffer(0, this.particleBuffer); | |
| renderPass.draw(6, this.params.count); // 6 vertices per quad, instanced | |
| renderPass.end(); | |
| this.device.queue.submit([commandEncoder.finish()]); | |
| } | |
| getComputeShaderCode() { | |
| return /* wgsl */ ` | |
| struct Uniforms { | |
| count: f32, | |
| dt: f32, | |
| mode: f32, | |
| size: f32, | |
| mouseX: f32, | |
| mouseY: f32, | |
| mousePressed: f32, | |
| time: f32, | |
| aspect: f32, | |
| palette: f32, | |
| trail: f32, | |
| } | |
| struct Particle { | |
| pos: vec2f, | |
| vel: vec2f, | |
| } | |
| @group(0) @binding(0) var<uniform> u: Uniforms; | |
| @group(0) @binding(1) var<storage, read_write> particles: array<Particle>; | |
| // Simple hash function for randomness | |
| fn hash(p: vec2f) -> f32 { | |
| var h = dot(p, vec2f(127.1, 311.7)); | |
| return fract(sin(h) * 43758.5453123); | |
| } | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid: vec3u) { | |
| let idx = gid.x; | |
| if (idx >= u32(u.count)) { | |
| return; | |
| } | |
| var p = particles[idx]; | |
| let mouse = vec2f(u.mouseX, u.mouseY); | |
| // Calculate force based on mode | |
| var force = vec2f(0.0, 0.0); | |
| let toMouse = mouse - p.pos; | |
| let dist = length(toMouse); | |
| let dir = normalize(toMouse + vec2f(0.0001, 0.0001)); | |
| let mode = i32(u.mode); | |
| if (mode == 0) { | |
| // Attract to mouse | |
| if (u.mousePressed > 0.5 && dist > 0.01) { | |
| force = dir * 0.5 / (dist * dist + 0.1); | |
| } | |
| } else if (mode == 1) { | |
| // Repel from mouse | |
| if (u.mousePressed > 0.5 && dist > 0.01) { | |
| force = -dir * 0.5 / (dist * dist + 0.1); | |
| } | |
| } else if (mode == 2) { | |
| // Orbit around mouse | |
| if (dist > 0.01) { | |
| let perpendicular = vec2f(-dir.y, dir.x); | |
| force = perpendicular * 0.2 / (dist + 0.1); | |
| force += dir * (0.5 - dist) * 0.1; // Pull toward orbit radius | |
| } | |
| } else if (mode == 3) { | |
| // Swarm behavior | |
| let noise = hash(p.pos + vec2f(u.time * 0.1, 0.0)); | |
| let angle = noise * 6.28318 + u.time; | |
| force = vec2f(cos(angle), sin(angle)) * 0.05; | |
| if (u.mousePressed > 0.5 && dist < 0.3) { | |
| force += dir * 0.3; | |
| } | |
| } else if (mode == 4) { | |
| // 🌿 Ivy mode - Falling leaves that grow/spiral like ivy | |
| let noise = hash(p.pos + vec2f(f32(idx) * 0.01, 0.0)); | |
| // Gentle falling | |
| force.y = -0.02; | |
| // Swaying left-right like leaves in wind | |
| let swayFreq = noise * 2.0 + 1.0; | |
| let swayAmp = 0.03 + noise * 0.02; | |
| force.x = sin(u.time * swayFreq + p.pos.y * 3.0 + noise * 6.28) * swayAmp; | |
| // Spiral pattern (like ivy growing) | |
| let spiralAngle = u.time * 0.5 + p.pos.y * 5.0 + noise * 6.28; | |
| force.x += cos(spiralAngle) * 0.01; | |
| // Mouse interaction - leaves follow cursor | |
| if (u.mousePressed > 0.5) { | |
| force += dir * 0.2 / (dist + 0.2); | |
| } else if (dist < 0.3) { | |
| // Gentle attract even without click | |
| force += dir * 0.05 / (dist + 0.1); | |
| } | |
| } | |
| // Apply force | |
| p.vel += force * u.dt; | |
| // Damping | |
| p.vel *= 0.99; | |
| // Limit speed | |
| let speed = length(p.vel); | |
| if (speed > 0.1) { | |
| p.vel = normalize(p.vel) * 0.1; | |
| } | |
| // Update position | |
| p.pos += p.vel * u.dt * 10.0; | |
| // Wrap around edges | |
| if (p.pos.x < -1.1) { p.pos.x = 1.1; } | |
| if (p.pos.x > 1.1) { p.pos.x = -1.1; } | |
| if (p.pos.y < -1.1) { p.pos.y = 1.1; } | |
| if (p.pos.y > 1.1) { p.pos.y = -1.1; } | |
| particles[idx] = p; | |
| } | |
| `; | |
| } | |
| getRenderShaderCode() { | |
| return /* wgsl */ ` | |
| struct Uniforms { | |
| count: f32, | |
| dt: f32, | |
| mode: f32, | |
| size: f32, | |
| mouseX: f32, | |
| mouseY: f32, | |
| mousePressed: f32, | |
| time: f32, | |
| aspect: f32, | |
| palette: f32, | |
| trail: f32, | |
| } | |
| @group(0) @binding(0) var<uniform> u: Uniforms; | |
| struct VertexInput { | |
| @builtin(vertex_index) vertexIndex: u32, | |
| @builtin(instance_index) instanceIndex: u32, | |
| @location(0) pos: vec2f, | |
| @location(1) vel: vec2f, | |
| } | |
| struct VertexOutput { | |
| @builtin(position) position: vec4f, | |
| @location(0) uv: vec2f, | |
| @location(1) speed: f32, | |
| } | |
| fn getPaletteColor(t: f32, paletteId: i32) -> vec3f { | |
| let tt = fract(t); | |
| if (paletteId == 0) { // Ivy Green | |
| return vec3f(0.13 + 0.2 * tt, 0.5 + 0.4 * tt, 0.2 + 0.2 * tt); | |
| } else if (paletteId == 1) { // Rainbow | |
| return vec3f( | |
| 0.5 + 0.5 * cos(6.28318 * (tt + 0.0)), | |
| 0.5 + 0.5 * cos(6.28318 * (tt + 0.33)), | |
| 0.5 + 0.5 * cos(6.28318 * (tt + 0.67)) | |
| ); | |
| } else if (paletteId == 2) { // Fire | |
| return vec3f(1.0, 0.3 + 0.5 * tt, tt * 0.2); | |
| } else if (paletteId == 3) { // Ocean | |
| return vec3f(0.1 * tt, 0.3 + 0.4 * tt, 0.6 + 0.4 * tt); | |
| } else if (paletteId == 4) { // Neon | |
| return vec3f( | |
| 0.5 + 0.5 * sin(tt * 12.0), | |
| 0.5 + 0.5 * sin(tt * 12.0 + 2.0), | |
| 0.5 + 0.5 * sin(tt * 12.0 + 4.0) | |
| ); | |
| } else { // Gold | |
| return vec3f(1.0, 0.8 * tt + 0.2, 0.2 * tt); | |
| } | |
| } | |
| @vertex | |
| fn vertexMain(input: VertexInput) -> VertexOutput { | |
| // Quad vertices | |
| var quadPos = array<vec2f, 6>( | |
| vec2f(-1.0, -1.0), | |
| vec2f(1.0, -1.0), | |
| vec2f(1.0, 1.0), | |
| vec2f(-1.0, -1.0), | |
| vec2f(1.0, 1.0), | |
| vec2f(-1.0, 1.0) | |
| ); | |
| let size = u.size * 0.01; | |
| let offset = quadPos[input.vertexIndex] * size; | |
| var output: VertexOutput; | |
| output.position = vec4f( | |
| input.pos.x + offset.x / u.aspect, | |
| input.pos.y + offset.y, | |
| 0.0, 1.0 | |
| ); | |
| output.uv = quadPos[input.vertexIndex] * 0.5 + 0.5; | |
| output.speed = length(input.vel); | |
| return output; | |
| } | |
| @fragment | |
| fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { | |
| // Circular particle | |
| let dist = length(input.uv - 0.5) * 2.0; | |
| if (dist > 1.0) { | |
| discard; | |
| } | |
| let paletteId = i32(u.palette); | |
| let hue = fract(input.speed * 20.0 + u.time * 0.1); | |
| let color = getPaletteColor(hue, paletteId); | |
| // Soft edge | |
| let alpha = 1.0 - smoothstep(0.5, 1.0, dist); | |
| return vec4f(color * alpha * 0.8, alpha * 0.5); | |
| } | |
| `; | |
| } | |
| } | |
| // Export | |
| window.ParticlesRenderer = ParticlesRenderer; | |