/** * 🌿 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, tunnel: 5, // Wormhole tunnel dna: 6, // DNA helix sparkle galaxy: 7, // Galaxy vortex wavegrid: 8, // NEW: Wave grid (image 2) splatter: 9 // NEW: Paint splatter clusters (image 3) }; this.params.mode = modes[mode] || 0; // Respawn particles for special modes if (mode === "tunnel" || mode === "dna" || mode === "galaxy") { this.respawnParticles3D(); } else if (mode === "wavegrid") { this.respawnParticlesGrid(); } else if (mode === "splatter") { this.respawnParticlesClusters(); } } // Grid spawn for wave effect respawnParticlesGrid() { const data = new Float32Array(this.maxParticles * 4); const gridSize = Math.floor(Math.sqrt(this.maxParticles)); for (let i = 0; i < this.maxParticles; i++) { const offset = i * 4; const gx = (i % gridSize) / gridSize; const gy = Math.floor(i / gridSize) / gridSize; // Grid position (-1 to 1) data[offset] = gx * 2 - 1; // x data[offset + 1] = gy * 2 - 1; // y data[offset + 2] = gx; // store grid coord for coloring data[offset + 3] = gy; } this.device.queue.writeBuffer(this.particleBuffer, 0, data); } // Cluster spawn for paint splatter effect respawnParticlesClusters() { const data = new Float32Array(this.maxParticles * 4); const numClusters = 30 + Math.floor(Math.random() * 20); const clusters = []; // Create cluster centers with random colors for (let c = 0; c < numClusters; c++) { clusters.push({ x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, size: 0.1 + Math.random() * 0.25, hue: Math.random() // Color identifier }); } for (let i = 0; i < this.maxParticles; i++) { const offset = i * 4; // Pick a random cluster const cluster = clusters[Math.floor(Math.random() * numClusters)]; // Spawn within cluster with gaussian-like distribution const angle = Math.random() * Math.PI * 2; const dist = Math.random() * Math.random() * cluster.size; // Squared for density at center data[offset] = cluster.x + Math.cos(angle) * dist; // x data[offset + 1] = cluster.y + Math.sin(angle) * dist; // y data[offset + 2] = cluster.hue; // store hue for color data[offset + 3] = dist / cluster.size; // distance from center for variation } this.device.queue.writeBuffer(this.particleBuffer, 0, data); } // Special respawn for 3D-like effects respawnParticles3D() { const data = new Float32Array(this.maxParticles * 4); for (let i = 0; i < this.maxParticles; i++) { const offset = i * 4; // Spawn in a cylinder/tube shape for better 3D effect const angle = Math.random() * Math.PI * 2; const radius = 0.3 + Math.random() * 0.7; const z = Math.random() * 2 - 1; // Pseudo-depth data[offset] = Math.cos(angle) * radius; // x data[offset + 1] = Math.sin(angle) * radius; // y data[offset + 2] = z * 0.01; // vx (store z as velocity for shader) data[offset + 3] = (Math.random() - 0.5) * 0.01; // vy } this.device.queue.writeBuffer(this.particleBuffer, 0, data); } 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, cosmic: 6 }; 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 // Clamped to prevent values below 0.05 which would cause issues const trailAlpha = Math.max(0.05, 1.0 - this.params.trail * 1.9); // Better trail range 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); } } else if (mode == 5) { // 🌀 WORMHOLE TUNNEL - Particles fly toward center creating tunnel effect let noise = hash(p.pos + vec2f(f32(idx) * 0.01, 0.0)); // Distance from center let centerDist = length(p.pos); // Spiral inward let angle = atan2(p.pos.y, p.pos.x); let spiralSpeed = 0.1 + noise * 0.1; let newAngle = angle + u.time * spiralSpeed; // Pull toward center (tunnel effect) let pullStrength = 0.05 * (1.0 + centerDist); force = -normalize(p.pos + vec2f(0.001)) * pullStrength; // Add rotation let tangent = vec2f(-p.pos.y, p.pos.x); force += normalize(tangent) * 0.1; // When very close to center, respawn at edge if (centerDist < 0.05) { let spawnAngle = noise * 6.28318 + u.time; p.pos = vec2f(cos(spawnAngle), sin(spawnAngle)) * (0.9 + noise * 0.2); p.vel = vec2f(0.0); } } else if (mode == 6) { // 🧬 DNA HELIX - Double helix sparkle spiral let noise = hash(p.pos + vec2f(f32(idx) * 0.01, 0.0)); let particlePhase = f32(idx) / u.count; // Two helices (DNA strands) let strand = select(0.0, 3.14159, f32(idx % 2u) > 0.5); let helixAngle = particlePhase * 20.0 + u.time * 2.0 + strand; let helixRadius = 0.3 + 0.1 * sin(particlePhase * 10.0); // Target position on helix let targetX = cos(helixAngle) * helixRadius; let targetY = (particlePhase * 2.0 - 1.0); // Vertical spread let destPos = vec2f(targetX, targetY); // Move toward helix position force = (destPos - p.pos) * 0.1; // Add sparkle jitter force.x += sin(u.time * 10.0 + noise * 100.0) * 0.01; force.y += cos(u.time * 8.0 + noise * 50.0) * 0.01; // Mouse interaction - expand helix if (u.mousePressed > 0.5) { let expand = dir * 0.1; force += expand; } } else if (mode == 7) { // 🌌 GALAXY VORTEX - Spiral galaxy with arms let noise = hash(p.pos + vec2f(f32(idx) * 0.01, 0.0)); let particlePhase = f32(idx) / u.count; // Galaxy arm assignment (4 arms) let armIndex = f32(idx % 4u); let armOffset = armIndex * 1.5708; // PI/2 // Spiral formula let spiralAngle = particlePhase * 15.0 + u.time * 0.5 + armOffset; let spiralRadius = particlePhase * 0.8 + 0.1; // Add arm spread let spread = noise * 0.15; let targetX = cos(spiralAngle) * (spiralRadius + spread); let targetY = sin(spiralAngle) * (spiralRadius + spread); let destPos = vec2f(targetX, targetY); // Smooth movement toward spiral position force = (destPos - p.pos) * 0.05; // Orbital velocity (rotation) let tangent = vec2f(-p.pos.y, p.pos.x); force += normalize(tangent + vec2f(0.001)) * 0.02; // Mouse creates gravity well if (u.mousePressed > 0.5 && dist < 0.5) { force += dir * 0.3 / (dist + 0.1); } } else if (mode == 8) { // 🌊 WAVE GRID - Particles on grid with color waves passing through // Grid particles don't move much - the color does the work // Just subtle oscillation let gridX = p.vel.x; // We stored grid coords in vel let gridY = p.vel.y; // Subtle wave movement let waveX = sin(gridY * 10.0 + u.time * 2.0) * 0.005; let waveY = cos(gridX * 10.0 + u.time * 1.5) * 0.005; // Restore to grid position with wave offset let targetX = (gridX * 2.0 - 1.0) + waveX; let targetY = (gridY * 2.0 - 1.0) + waveY; force = (vec2f(targetX, targetY) - p.pos) * 0.5; // Mouse interaction - push particles away if (dist < 0.2) { force -= dir * 0.05 / (dist + 0.05); } } else if (mode == 9) { // 🎨 PAINT SPLATTER - Clustered particles, minimal movement // The beauty is in the static clusters, so minimal force let noise = hash(p.pos + vec2f(f32(idx) * 0.01, u.time * 0.01)); // Very subtle jitter to keep them alive force.x = sin(u.time * 3.0 + noise * 100.0) * 0.002; force.y = cos(u.time * 2.5 + noise * 50.0) * 0.002; // Mouse click explodes nearby clusters if (u.mousePressed > 0.5 && dist < 0.3) { force -= dir * 0.2 / (dist + 0.05); } } // 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, @location(2) vel: vec2f, } 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 if (paletteId == 5) { // Gold return vec3f(1.0, 0.8 * tt + 0.2, 0.2 * tt); } else { // Cosmic - Purple/Pink/Blue sparkle like image 2 return vec3f( 0.4 + 0.6 * sin(tt * 8.0 + 1.0), 0.2 + 0.3 * sin(tt * 6.0 + 2.0), 0.6 + 0.4 * sin(tt * 10.0 + 4.0) ); } } @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); output.vel = input.vel; // Pass velocity for special modes return output; } @fragment fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { // Circular particle with glow let dist = length(input.uv - 0.5) * 2.0; if (dist > 1.0) { discard; } let paletteId = i32(u.palette); let mode = i32(u.mode); var hue = fract(input.speed * 20.0 + u.time * 0.1); var color = getPaletteColor(hue, paletteId); var alpha: f32; if (mode == 8) { // 🌊 WAVE GRID - Color based on wave function, not speed let gridX = input.vel.x; let gridY = input.vel.y; // Multiple wave layers for color let wave1 = sin(gridX * 8.0 + u.time * 1.5) * 0.5 + 0.5; let wave2 = sin(gridY * 6.0 - u.time * 1.2) * 0.5 + 0.5; let wave3 = sin((gridX + gridY) * 5.0 + u.time) * 0.5 + 0.5; hue = fract(wave1 * 0.4 + wave2 * 0.3 + wave3 * 0.3); color = getPaletteColor(hue, paletteId); // Small dots with soft glow let core = 1.0 - smoothstep(0.0, 0.4, dist); let glow = 1.0 - smoothstep(0.0, 1.0, dist); alpha = core * 0.95 + glow * 0.3; color *= 1.2; } else if (mode == 9) { // 🎨 PAINT SPLATTER - Color based on cluster hue stored in vel.x let clusterHue = input.vel.x; let distFromCenter = input.vel.y; // Vibrant distinct colors per cluster hue = clusterHue; color = getPaletteColor(hue, 1); // Force rainbow for best splatter effect // Vary brightness based on distance from cluster center let brightness = 0.8 + distFromCenter * 0.4; color *= brightness; // Solid dots with slight soft edge alpha = 1.0 - smoothstep(0.7, 1.0, dist); } else if (mode >= 5 && mode <= 7) { // Enhanced glow for tunnel, dna, galaxy let core = 1.0 - smoothstep(0.0, 0.3, dist); let glow = 1.0 - smoothstep(0.0, 1.0, dist); alpha = core * 0.9 + glow * 0.4; let sparkle = sin(u.time * 20.0 + input.speed * 100.0) * 0.3 + 0.7; color *= sparkle * 1.5; } else { // Standard soft edge for other modes alpha = 1.0 - smoothstep(0.5, 1.0, dist); } return vec4f(color * alpha * 0.8, alpha * 0.6); } `; } } // Export window.ParticlesRenderer = ParticlesRenderer;