A particle generator pushed to 20,000 elements with Canvas
Our SVG-based particle generator caps out at a few thousand elements. Animation becomes slow as times between iterations of our game loop increase.
Old elements leave the screen and get pruned faster than we can create new ones. This creates a natural upper limit to how many elements we can push into the page.
We can render many more elements if we take out SVG and use HTML5 Canvas instead. I was able to push the code up to almost 20,000 smoothly animated elements. Then JavaScript became the bottleneck.
Well, I say JavaScript was the bottleneck, but monitor size plays a role too. It goes up to 20,000 on my laptop screen, juuuust grazes 30,000 on my large desktop monitor, and averages about 17,000 on my iPhone 5SE.
Friends with newer laptops got it up to 35,000.
You can see it in action hosted on Github pages.
We're keeping most of our existing code. The real changes happen in
src/components/index.jsx
, where a Konva stage replaces the <svg>
element,
and in src/components/Particles.jsx
, where we change what we render. There's
a small tweak in the reducer to generate more particles per tick.
You should go into your particle generator directory, install Konva and react-konva, and then make the changes below. Trying things out is better than just reading my code 😉
$ npm install --save konva react-konva
react-konva is a thin wrapper on Konva itself. There's no need to think about it as its own thing. For the most part, you can go into the Konva docs, read about something, and it Just Works™ in react-konva.
Preparing a canvas layer
Our changes start in src/components/index.jsx
. We have to throw away the
<svg>
element and replace it with a Konva stage.
You can think of a Konva stage as a Canvas element with a bunch of helper methods attached. Some of them Konva uses internally; others are exposed as an API. Functions like exporting to an image file, detecting intersections, etc.
// src/components/index.jsx// ...import { Stage } from 'react-konva';// ...class App extends Component {// ..render() {return (// ..<Stage width={this.props.svgWidth} height={this.props.svgHeight}><Particles particles={this.props.particles} /></Stage></div><Footer N={this.props.particles.length} /></div>);}}
We import Stage
from react-konva
, then use it instead of the <svg>
element in the render
method. It gets a width
and a height
.
Inside, we render the Particles
component. It's going to create a Konva layer
and use low-level Canvas methods to render particles as sprites.
Using sprites for max redraw speed
Our SVG-based Particles
component was simple. Iterate through a list of particles, render a
<Particle>
component for each.
We're going to completely rewrite that. Our new approach goes like this:
- Cache a sprite on
componentDidMount
- Clear canvas
- Redraw all particles
- Repeat
Because the new approach renders a flat image, and because we don't care about
interaction with individual particles, we can get rid of the Particle
component. The unnecessary layer of nesting was slowing us down.
The new Particles
component looks like this:
// src/components/Particles.jsximport React, { Component } from "react"import { FastLayer } from "react-konva"class Particles extends Component {layerRef = React.createRef()componentDidMount() {this.canvas = this.layerRef.current.canvas._canvasthis.canvasContext = this.canvas.getContext("2d")this.sprite = new Image()this.sprite.src = "https://i.imgur.com/m5l6lhr.png"}drawParticle(particle) {let { x, y } = particlethis.canvasContext.drawImage(this.sprite, 0, 0, 128, 128, x, y, 15, 15)}componentDidUpdate() {let particles = this.props.particlesconsole.time("drawing")this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height)for (let i = 0; i < particles.length; i++) {this.drawParticle(particles[i])}console.timeEnd("drawing")}render() {return <FastLayer ref={this.layerRef} listening="false" />}}export default Particles
40 lines of code is a lot all at once. Let's walk through step by step.
componentDidMount
// src/components/Particles.jsx// ...componentDidMount() {this.canvas = this.refs.layer.canvas._canvas;this.canvasContext = this.canvas.getContext('2d');this.sprite = new Image();this.sprite.src = 'https://i.imgur.com/m5l6lhr.png';}
React calls componentDidMount
when our component first renders. We use it to
set up 3 instance properties.
this.canvas
is a reference to the HTML5 Canvas element. We get it through a
ref to the Konva layer, then spelunk through Konva internals to get the canvas
itself. As the _
prefix indicates, Anton Lavrenov did not intend this to be a
public API.
Thanks to JavaScript's permissiveness, we can use it anyway. 🙌
this.canvasContext
is a reference to our canvas's
CanvasRenderingContext2D.
It's the interface we use to draw basic shapes, perform transformations, and so
on. Context is the only part of canvas you ever interact with as a developer.
Why it's not just Canvas, I don't know.
this.sprite
is a cached image. A small minion that we are going to copy-paste
all over as our particle. Creating a new image object with new Image()
and
setting the src
property downloads our sprite from the internet into browser
memory.
It looks like this:
You might think it's unsafe to copy references to rendered elements into component properties like that, but it's okay. Our render function always renders the same thing, so the reference never changes. It just makes our code cleaner.
Should our component unmount and re-mount, React will call componentDidMount
again and update our reference.
drawParticle
// src/components/Particles.jsx// ...drawParticle(particle) {let { x, y } = particle;this.canvasContext.drawImage(this.sprite, 0, 0, 128, 128, x, y, 15, 15);}
drawParticle
draws a single particle on the canvas. It gets coordinates from
the particle
argument and uses drawImage
to copy our sprite into position.
We use the whole sprite, corner (0, 0)
to corner (128, 128)
. That's how big
our sprite is. And we copy it to position (x, y)
with a width and height of
15
pixels.
drawImage
is the fastest method I've found to put pixels on canvas. I don't
know why it's so fast, but here's a
helpful benchmark so
you can see for yourself.
componentDidUpdate
// src/components/Particles.jsx// ...componentDidUpdate() {let particles = this.props.particles;console.time('drawing');this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);for (let i = 0; i < particles.length; i++) {this.drawParticle(particles[i]);}console.timeEnd('drawing');}
componentDidUpdate
is where the magic happens. React calls this lifecycle
method every time our list of particles changes. After the render
method.
Just like the D3 blackbox approach, we move rendering out of the render
method and into componentDidUpdate
.
Here's how it works:
this.canvasContext.clearRect
clears the entire canvas from coordinate(0, 0)
to coordinate(width, height)
. We delete everything and make the canvas transparent.- We iterate our
particles
list with afor
loop and calldrawParticle
on each element.
Clearing and redrawing the canvas is faster than moving individual particles.
For loops are faster than .map
or any other form of iteration. I tested. A
lot.
Open your browser console and see how long each frame takes to draw. The
console.time
- console.timeEnd
pair measures how long it takes your code to
get from time
to timeEnd
. You can have as many of these timers running as
you want as long as you give them different names.
render
// src/components/Particles.jsx// ...render() {return (<FastLayer ref={this.layerRef} listening="false" />);}
After all that work, our render
method is quite short.
We render a Konva FastLayer
, give it a ref
and turn off listening
for
mouse events. That makes the fast layer even faster.
Ideas for this combination of settings came from Konva's official performance tips documentation. This makes sense when you think about it.
A FastLayer
is faster than a Layer
. It's in the name. Ignoring mouse events
means you don't have to keep track of elements. It reduces computation.
This was empirically the fastest solution with the most particles on screen.
But why, Swizec?
I'm glad you asked. This was a silly example. I devised the experiment because at my first React+D3 workshop somebody asked, "What if we have thousands of datapoints, and we want to animate all of them?". I didn't have a good answer.
Now I do. You put them in Canvas. You drive the animation with a game loop. You're good.
You can even do it as an overlay. Have an SVG for your graphs and charts, overlay with a transparent canvas for your high speed animation.