/** * 🌿 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 u: Uniforms; @group(0) @binding(1) var particles: array; // 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 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(-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;