Particles connected using simple springs (penalty-based forces). The springs are connected to a fixed location (calculated at startup). As you move the cursor around the window the springs respond by moving out of the way.
<?php
let div = document.createElement('div');
document.body.appendChild( div );
div.style['font-size'] = '20pt';
function log( s )
{
console.log( s );
let args = [...arguments].join(' ');
div.innerHTML += args + '<br><br>';
}
log('WebGPU Compute Example');
if (!navigator.gpu) { log("WebGPU is not supported (or is it disabled? flags/settings)"); return; }
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
// ----------------------------------------------------------
const timestep = new Float32Array( [0.0] );
const timerUniformBuffer = device.createBuffer({ size: timestep.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(timerUniformBuffer, 0, timestep );
// ----------------------------------------------------------
const mouse = new Float32Array( [900.0, 900.0 ] );
const mouseBuffer = device.createBuffer({ size: mouse.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer( mouseBuffer, 0, mouse );
// ----------------------------------------------------------
function Particle( x, y ) {
this.x = x;
this.y = y;
this.xBase = x;
this.yBase = y;
this.size = 3,
this.density = ((Math.random() * 30) + 1);
this.random = Math.random();
this.spriteSize = Math.random() * 50 + 50;
this.frameX = Math.floor(Math.random() * 3);
this.frameY = Math.floor(Math.random() * 8);
this.angle = Math.random() * 2;
this.pad0 = 0;
this.pad1 = 0;
this.pad2 = 0;
this.pad3 = 0;
this.pad4 = 0;
}
const canvas = document.createElement('canvas');
canvas.style.border = '1px solid orange';
document.body.appendChild( canvas );
canvas.id = "canvas1";
canvas.width = 1024;
canvas.height = 512;
const ctx = canvas.getContext("2d");
ctx.font = 'bold 18px Verdana';
ctx.fillText('XBDEV', 0, 20 );
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
console.log( 'canvas:', canvas.width, canvas.height );
const sprite = new Image();
sprite.src = 'https://xbdev.net/retro/code/games/images/spiders.png';
let particleSpacing = 15;
let particles = [];
for (var y = 0, y2 = canvas.height; y < y2; y++) {
for (var x = 0, x2 = canvas.width; x < x2; x++) {
if (imgData.data[(y * 4 * canvas.width) + (x * 4) + 3] > 128) {
let positionX = x;
let positionY = y;
particles.push(new Particle(positionX * particleSpacing, positionY * particleSpacing));
}
}
}
console.log('number particles:', particles.length );
let particlesTmp = particles.map( (a)=>{ return Object.values(a); } ).flat();
const particleArray = new Float32Array( particlesTmp );
const particleStride = particleArray.length / particles.length;
console.log('particle stride:', particleStride );
console.log('number particles:', particles.length, particleArray.length );
console.log('length buffer in bytes:', particleArray.byteLength );
const particleBuffer = device.createBuffer({ size: particleArray.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
device.queue.writeBuffer(particleBuffer, 0, particleArray);
const particleBufferTmp = device.createBuffer({ size: particleArray.byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
// ----------------------------------------------------------
const GCOMPUTE = GPUShaderStage.COMPUTE;
// Bind group layout and bind group
const bindGroupLayout = device.createBindGroupLayout({
entries: [ {binding: 0, visibility: GCOMPUTE, buffer: { type: "uniform" } },
{binding: 1, visibility: GCOMPUTE, buffer: { type: "uniform" } },
{binding: 2, visibility: GCOMPUTE, buffer: { type: "storage" } }
]
});
const bindGroup0 = device.createBindGroup({
layout: bindGroupLayout,
entries: [ { binding: 0, resource: { buffer: timerUniformBuffer } },
{ binding: 1, resource: { buffer: mouseBuffer } },
{ binding: 2, resource: { buffer: particleBuffer } }
]
});
// Compute shader code
const computeShader = `
struct Particle
{
@align(4) @size(4) x : f32,
@align(4) @size(4) y : f32,
@align(4) @size(4) baseX : f32,
@align(4) @size(4) baseY : f32,
@align(4) @size(4) size : f32,
@align(4) @size(4) density : f32,
@align(4) @size(4) random : f32,
@align(4) pad : array< f32, 9 >
}
@group(0) @binding(0) var<uniform> mytimer : f32; // timer increments each frame
@group(0) @binding(1) var<uniform> mymouse : vec2<f32>; // oouse coordinates
@group(0) @binding(2) var<storage, read_write> particles : array< Particle, ${particles.length} >;
@compute @workgroup_size( 8 )
fn main(@builtin(global_invocation_id) globalId : vec3<u32>,
@builtin(local_invocation_id) localId : vec3<u32>,
@builtin(workgroup_id) workgroupId : vec3<u32>,
@builtin(num_workgroups) workgroupSize : vec3<u32>
)
{
let index = globalId.x;
const mouseRadius = 150.0;
var dx = mymouse.x - particles[index].x;
var dy = mymouse.y - particles[index].y;
var distance = sqrt(dx*dx + dy*dy);
var forceDirectionX = dx / distance;
var forceDirectionY = dy / distance;
// distance past which the force is zero
var maxDistance = mouseRadius;
var force = (maxDistance - distance) / maxDistance;
// if we went below zero, set it to zero.
if (force < 0) { force = 0; }
var directionX = (forceDirectionX * force * particles[index].density);
var directionY = (forceDirectionY * force * particles[index].density);
if (distance < mouseRadius + particles[index].size){
particles[index].x -= directionX;
particles[index].y -= directionY;
} else {
if (particles[index].x != particles[index].baseX ) {
let dx = particles[index].x - particles[index].baseX;
particles[index].x -= dx/10;
} if (particles[index].y != particles[index].baseY) {
let dy = particles[index].y - particles[index].baseY;
particles[index].y -= dy/10;
}
}
}
`;
// Pipeline setup
const computePipeline = device.createComputePipeline({
layout : device.createPipelineLayout({bindGroupLayouts: [bindGroupLayout]}),
compute: { module : device.createShaderModule({code:computeShader}),
entryPoint: "main" }
});
document.onmousemove = function( e )
{
mouse[0] = e.x + canvas.clientLeft/2;
mouse[1] = e.y + canvas.clientTop/2;
}
async function frame()
{
timestep[0] = timestep[0] + 0.01;
device.queue.writeBuffer(timerUniformBuffer, 0, timestep );
device.queue.writeBuffer( mouseBuffer, 0, mouse );
// Commands submission
const commandEncoder = device.createCommandEncoder();
{
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup0);
// workgroup size of 8x8 on the wgsl shader
passEncoder.dispatchWorkgroups( particles.length/8, 1 );
await passEncoder.end();
}
// --------------------------
commandEncoder.copyBufferToBuffer(particleBuffer,
0,
particleBufferTmp,
0,
particleArray.byteLength);
const gpuCommands = commandEncoder.finish();
await device.queue.submit([gpuCommands]);
await particleBufferTmp.mapAsync(GPUMapMode.READ);
const particleArrayTmp = new Float32Array(particleBufferTmp.getMappedRange());
const particleArrayOut = Array.from( particleArrayTmp );
particleBufferTmp.unmap();
// ----------------------
ctx.clearRect(0,0, canvas.width, canvas.height );
for (let i=0; i<particles.length; i++)
{
let particle = particles[i];
let newx = particleArrayOut[ particleStride * i + 0];
let newy = particleArrayOut[ particleStride * i + 1];
particles[i].x = newx;
particles[i].y = newy;
particle.x = newx;
particle.y = newy;
if (particle.random > 0.05)
{
ctx.fillStyle = 'black';
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
}
else
{
ctx.save();
ctx.translate(particle.x, particle.y);
ctx.rotate(particle.angle);
ctx.drawImage(sprite,
particle.frameX * 213.3,
particle.frameY * 213.3, 213.3,213.3,
0 - particle.spriteSize/2,
0 - particle.spriteSize/2, particle.spriteSize,
particle.spriteSize);
ctx.restore();
}
}// end for i
// ---------------------
// Draw the links so it looks 'webby' (fits in with all the spider images)
let mouseRadius = 150;
let opacityValue = 1;
for (let a = 0; a < particles.length; a++) {
for (let b = a; b < particles.length; b++) {
let distance = (( particles[a].x - particles[b].x) * (particles[a].x - particles[b].x))
+ ((particles[a].y - particles[b].y) * (particles[a].y - particles[b].y));
const maxdist = 2000;
if (distance < maxdist) {
opacityValue = 1 - (distance/maxdist);
let dx = mouse[0] - particles[a].x;
let dy = mouse[1] - particles[a].y;
let mouseDistance = Math.sqrt(dx*dx+dy*dy);
if (mouseDistance < mouseRadius / 2) {
ctx.strokeStyle='rgba(0,0,0,' + opacityValue + ')';
} else if (mouseDistance < mouseRadius - 50) {
ctx.strokeStyle='rgba(0,0,90,' + opacityValue + ')';
} else if (mouseDistance < mouseRadius + 20) {
ctx.strokeStyle='rgba(0,0,50,' + opacityValue + ')';
} else {
ctx.strokeStyle='rgba(0,0,0,' + opacityValue + ')';
}
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particles[a].x, particles[a].y);
ctx.lineTo(particles[b].x, particles[b].y);
ctx.stroke();
}
}
}
// ---------------------
requestAnimationFrame(frame);
}
frame();
Things to Try
• Try other text and patterns
• Add other disturbances (e.g., rippling due to noises)
• Mix in more colors (particles change color based on velocity/penalty)