ivy-gpu-art-studio / js /particles.js
Elysia-Suite's picture
Upload 23 files
e5d943e verified
/**
* 🌿 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;