I needed to update my personal portfolio. It previously looked similar, but lagged on lower end devices. The background rendering system was a slow old JavaScript file I had created for a different reason and reused here.

I was also interested in how WebAssembly worked and wanted to try and rewrite the background in Rust or C++ for a huge performance increase. I have previous experience in C++, but decided to learn a bit of Rust because it has advantages compared to C++ for WASM.
I created the new Next.js project and wrote some basic layout for the canvas, then jumped straight into the WASM. I firstly defined particle, point, and sim.
#[wasm_bindgen]
pub struct Particle {
x: f64,
y: f64,
vx: f64,
vy: f64,
px: f64,
py: f64,
}
#[wasm_bindgen]
pub struct Point {
x: f64,
y: f64,
fx: f64,
fy: f64,
}
#[wasm_bindgen]
pub struct Sim {
particles: Vec<Particle>,
points: Vec<Point>,
}Each particle has an x and y position, an x and y velocity, and a previous x and y position. The canvas rendering process draws a line from the previous point to the current point, so every time the particle moves and the frame updates a line will be drawn following its path.
The simulation initially fills the canvas with a grid of points, which each have a x and y position and a force x and force y value. The force values are generated using a noise function to create a wavy flow field. I chose a 64-bit floating point type for each of these values. I could have used integers for the point positions but I didn't want to have to convert them later on; it's easier to have everything the same type if you need to use the values in calculations, unless you are on a tight memory constraint.
I then added functions to load points into the Rust environment one by one. I tried loading in a single array but it worked out easier to iterate over them on the TypeScript side. I initially tried storing all the points and particles in the TypeScript environment but I realised that was a bad idea, as I would need to pass these arrays into the Rust environment every time I needed to use them.
#[wasm_bindgen]
impl Sim {
#[wasm_bindgen(constructor)]
pub fn new() -> Sim {
Sim {
particles: Vec::new(),
points: Vec::new(),
}
}
#[wasm_bindgen]
pub fn add_particle(&mut self, x: f64, y: f64) {
self.particles.push(Particle { x, y, vx: 0.0, vy: 0.0, px: x, py: y });
}
#[wasm_bindgen]
pub fn add_point(&mut self, x: f64, y: f64, fx: f64, fy: f64) {
self.points.push(Point { x, y, fx, fy });
}const sim = useRef<any>(null);
const noise2D = createNoise2D();
sim.current = new Sim();
for(let x = 1; x < canvas.width; x += POINTS_GAP){
for(let y = 1; y < canvas.height; y += POINTS_GAP){
const fx: number = (noise2D(x / NOISE_SCALE, y / NOISE_SCALE) * 0.4) - 0.5;
const fy: number = (noise2D(y / NOISE_SCALE, x / NOISE_SCALE) * 0.5) - 0.2;
sim.current.add_point(x, y, fx, fy);
}
}As you can see in the TypeScript nested for-loop, I am offsetting the random noise to ensure that the flow field is generally pointed towards the top left of the screen, so that we can see a lot of the points fly across the screen and it looks consistent.
Now I will generate the particles. They will be offset to sit generally in a big radius around the bottom right area, so that they can flow across the screen. A bigger screen will get more particles, but it will be clamped just in case someone uses a 4K monitor and zooms all the way out to 25%, which would probably lag due to the drawing.
const numParticles = Math.max(window.innerWidth * 1.8, 1500);
for(let i = 0; i < numParticles; i += 1){
const x = (Math.random() * (canvas.width * 3)) + canvas.width / 2;
const y = (Math.random() * (canvas.height * 1.7))
sim.current.add_particle(x, y);
}I do want to use WebGL to render the particles at some point. I have seen some libraries for this, but I would like to do it manually just to see what it is like to write WebGL. It was great to finally get my foot in the door for WASM; it can be applied to many applications. WebGL is probably the next web related thing I will learn, although after I have implemented it myself I will compare it against something like three.js to see if three is more worth it, as it would be super optimised already.
Now we have initialised our points and particles, time to start the animation. I used requestAnimationFrame() initially, but different devices with different refresh rates would run the animation at different speeds, so I instead used setInterval() with a timeout of 16.67ms to aim for a 60fps draw rate. Here is the Rust function which calculates the new particle positions.
#[wasm_bindgen]
pub fn get_updated_positions(&mut self, force_multiplier: f64, speed_multiplier: f64) -> js_sys::Array {
let arr = js_sys::Array::new();
// remove particles out of view
self.particles.retain(|particle| particle.x >= 0.0 && particle.y >= 0.0);
for particle in &mut self.particles {
// find closest point
let mut closest_point_force = (0.0, 0.0);
let mut min_distance = f64::MAX;
for point in &self.points {
let dx = particle.x - point.x;
let dy = particle.y - point.y;
let distance = dx * dx + dy * dy;
if distance < min_distance {
min_distance = distance;
closest_point_force = (point.fx, point.fy);
}
}
// update particle position
particle.px = particle.x;
particle.py = particle.y;
particle.vx += (closest_point_force.0 - particle.vx) * force_multiplier;
particle.vy += (closest_point_force.1 - particle.vy) * force_multiplier;
particle.x += particle.vx * speed_multiplier;
particle.y += particle.vy * speed_multiplier;
// create return array
let js_array = js_sys::Array::new();
js_array.push(&JsValue::from_f64(particle.x));
js_array.push(&JsValue::from_f64(particle.y));
js_array.push(&JsValue::from_f64(particle.px));
js_array.push(&JsValue::from_f64(particle.py));
arr.push(&js_array);
}
arr
}The simulation works by iterating over each particle, finding its closest point, and applying some of that point's force to the particle's velocity. Then the particle normally moves based on its own velocity. The results are added to an array, then once the loop is complete the entire array gets returned back to the TypeScript environment. As a small optimisation, we remove particles that have gone to the left or above the canvas, as they will never be seen again because the forces are generally pointing in that direction.
In TypeScript we create some random colours, interpolate between them, then start the draw loop. The draw loop calculates the colour and opacity for the frame, then calls on Rust to give it the updated positions of all particles.
const interpolateColor = (start: number[], end: number[], factor: number) => start.map((s, i) => s + (end[i] - s) * factor);
const rgbStart: number[] = [ 50 + Math.random() * 205, 50 + Math.random() * 205, 50 + Math.random() * 205 ];
const rgbEnd: number[] = [ 50 + Math.random() * 205, 50 + Math.random() * 205, 50 + Math.random() * 205 ];
const draw = () => {
const factor = currentIteration / MAX_ITERATIONS;
const [red, green, blue] = interpolateColor(rgbStart, rgbEnd, factor);
let alpha;
if(currentIteration > MAX_ITERATIONS - 250){
alpha = Math.min((MAX_ITERATIONS - currentIteration) / 250, ((currentIteration / MAX_ITERATIONS) * 0.7));
} else {
alpha = ((currentIteration / MAX_ITERATIONS) * 0.6) + 0.1;
}
const speedMultiplier: number = (MAX_ITERATIONS - currentIteration) * 0.02;
const forceMultiplier: number = 0.08;
ctx.strokeStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
const particles = sim.current.get_updated_positions(forceMultiplier, speedMultiplier);
particles.forEach((particle: number[]) => {
const prev = { x: particle[2], y: particle[3] }
const pos = { x: particle[0], y: particle[1] }
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
ctx.closePath();
});
if(currentIteration < MAX_ITERATIONS) {
currentIteration++
} else {
clearInterval(intervalID);
}
}When running it as fast as possible, I was able to get around 4ms/frame (250fps) on both my desktop and phone. I am happy with the result of this, although the next step is definitely WebGL as the rendering aspect is now 100% the bottleneck.
Finally, to ensure that if the user switches between pages and comes back to the main page a lot, I implemented a clear function and a destructor to sim so we can make sure that we are not accidentally making a memory leak, because at one point I did see an out of bounds exception in the console.
// inside sim
#[wasm_bindgen]
pub fn clear(&mut self) {
// clear all particles and points
self.particles.clear();
self.points.clear();
}
}
impl Drop for Sim {
fn drop(&mut self) {
self.particles.clear();
self.points.clear();
}
}And we are done. If at some point I implement WebGL rendering, I'll update this article.