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, | |
| 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<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); | |
| } | |
| } 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<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, | |
| @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, 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); | |
| 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; | |