Skip to content

Instantly share code, notes, and snippets.

@rokotyan
Forked from pbeshai/.block
Last active January 13, 2023 17:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rokotyan/d3503afc61bca3d75c34f3aa0f5fd989 to your computer and use it in GitHub Desktop.
Save rokotyan/d3503afc61bca3d75c34f3aa0f5fd989 to your computer and use it in GitHub Desktop.
Animate 100K points with canvas and D3 using getImageData and putImageData
license: mit
height: 620
border: no

Animate 100K points with canvas and D3 using getImageData and putImageData

This is a fork of Animate thousands of points with canvas and D3 showing how using context's getImageData and putImageData can speed up rendering even more. The idea is to assign RGB values to canvas data array manually. This gives you a full control on rendering and if you data has overlapping points and is sorted, you can only draw points that are in front and have pretty reasonable framerates.

This approach is very useful for prototyping however it has it's own liminations:

  1. For retina screens (when using context.scale(window.devicePixelRatio) ) there's alsmost no boost in performance
  2. There's no anti-aliasing
  3. Works best in Firefox. Safari is slightly slower. Chrome is the slowest.

Original Peter Beshai’s blog post.

/**
* Given a set of points, lay them out in a phyllotaxis layout.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} xOffset The x offset to apply to all points
* @param {Number} yOffset The y offset to apply to all points
*
* @return {Object[]} points with modified x and y
*/
function phyllotaxisLayout(points, pointWidth, xOffset = 0, yOffset = 0, iOffset = 0) {
// theta determines the spiral of the layout
const theta = Math.PI * (3 - Math.sqrt(5));
const pointRadius = pointWidth / 2;
points.forEach((point, i) => {
const index = (i + iOffset) % points.length;
const phylloX = pointRadius * Math.sqrt(index) * Math.cos(index * theta);
const phylloY = pointRadius * Math.sqrt(index) * Math.sin(index * theta);
point.x = xOffset + phylloX - pointRadius;
point.y = yOffset + phylloY - pointRadius;
});
return points;
}
/**
* Given a set of points, lay them out in a grid.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} gridWidth The width of the grid of points
*
* @return {Object[]} points with modified x and y
*/
function gridLayout(points, pointWidth, gridWidth) {
const pointHeight = pointWidth;
const pointsPerRow = Math.floor(gridWidth / pointWidth);
const numRows = points.length / pointsPerRow;
points.forEach((point, i) => {
point.x = pointWidth * (i % pointsPerRow);
point.y = pointHeight * Math.floor(i / pointsPerRow);
});
return points;
}
/**
* Given a set of points, lay them out randomly.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function randomLayout(points, pointWidth, width, height) {
points.forEach((point, i) => {
point.x = Math.random() * (width - pointWidth);
point.y = Math.random() * (height - pointWidth);
});
return points;
}
/**
* Given a set of points, lay them out in a sine wave.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function sineLayout(points, pointWidth, width, height) {
const amplitude = 0.3 * (height / 2);
const yOffset = height / 2;
const periods = 3;
const yScale = d3.scaleLinear()
.domain([0, points.length - 1])
.range([0, periods * 2 * Math.PI]);
points.forEach((point, i) => {
point.x = (i / points.length) * (width - pointWidth);
point.y = amplitude * Math.sin(yScale(i)) + yOffset;
});
return points;
}
/**
* Given a set of points, lay them out in a spiral.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function spiralLayout(points, pointWidth, width, height) {
const amplitude = 0.3 * (height / 2);
const xOffset = width / 2;
const yOffset = height / 2;
const periods = 20;
const rScale = d3.scaleLinear()
.domain([0, points.length -1])
.range([0, Math.min(width / 2, height / 2) - pointWidth]);
const thetaScale = d3.scaleLinear()
.domain([0, points.length - 1])
.range([0, periods * 2 * Math.PI]);
points.forEach((point, i) => {
point.x = rScale(i) * Math.cos(thetaScale(i)) + xOffset
point.y = rScale(i) * Math.sin(thetaScale(i)) + yOffset;
});
return points;
}
/**
* Generate an object array of `numPoints` length with unique IDs
* and assigned colors
*/
function createPoints(numPoints, pointWidth, width, height) {
const colorScale = d3.scaleSequential(d3.interpolateInferno)
.domain([numPoints - 1, 0]);
const points = d3.range(numPoints).map(id => {
const color = colorScale(id);
const colorRgb = d3.rgb( color );
return {
id,
color,
rgb: [ colorRgb.r, colorRgb.g, colorRgb.b],
};
});
return randomLayout(points, pointWidth, width, height);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<meta charset='UTF-8'>
<script src="https://d3js.org/d3.v4.min.js"></script>
<title>Animate 100K points with canvas and D3</title>
<style>
html, body {
padding: 0;
margin: 0;
}
canvas {
cursor: pointer;
}
.play-control {
position: absolute;
top: 0px;
left: 0px;
width: 600px;
height: 600px;
text-align: center;
background-color: rgba(0, 0, 0, 0.1);
color: #f4f4f4;
text-shadow: rgba(0, 0, 0, 0.7) 2px 2px 0px;
font-size: 100px;
font-family: 'helvetica neue', calibri, sans-serif;
font-weight: 100;
cursor: pointer;
}
.play-control > div {
margin-top: 25%;
}
.play-control span {
font-size: 70px;
}
.play-control:hover {
color: #fff;
text-shadow: #000 3px 3px 0px;
background-color: rgba(0, 0, 0, 0.04);
}
</style>
</head>
<body>
<script src="common.js"></script>
<script src="script.js"></script>
</body>
</html>
// canvas settings
const screenScale = 1; // window.devicePixelRatio || 1;
const width = 600; // window.innerWidth;
const height = 600; // window.innerHeight;
let canvasData;
// point settings
const numPoints = 100000;
const pointWidth = 2;
const pointMargin = 2;
// animation settings
const duration = 2000;
const ease = d3.easeCubicOut;
let timer;
let currLayout = 0;
// create set of points
const points = createPoints(numPoints, pointWidth, width, height);
// wrap layout helpers so they only take points as an argument
const toGrid = (points) => gridLayout(points,
pointWidth + pointMargin, width);
const toSine = (points) => sineLayout(points,
pointWidth + pointMargin, width, height);
const toSpiral = (points) => spiralLayout(points,
pointWidth + pointMargin, width, height);
const toPhyllotaxis = (points) => phyllotaxisLayout(points,
pointWidth + pointMargin, width / 2, height / 2);
// store the layouts in an array to sequence through
const layouts = [toSine, toPhyllotaxis, toSpiral, toPhyllotaxis, toGrid];
// draw the points based on their current layout
function draw() {
const t0 = performance.now();
const ctx = canvas.node().getContext('2d');
// erase what is on the canvas currently
ctx.clearRect(0, 0, width, height);
// [Original code] draw each point as a rectangle
// for (let i = 0; i < points.length; ++i) {
// const point = points[i];
// ctx.fillStyle = point.color;
// ctx.fillRect(point.x, point.y, pointWidth, pointWidth);
// }
// Draw using getImageData
// get canvas data
canvasData = ctx.getImageData(0, 0, width*screenScale, height*screenScale);
// draw pixels
points.forEach( (p) => {
drawNxNPixel ( pointWidth, [p.x, p.y], p.rgb[0], p.rgb[1], p.rgb[2] );
});
// update canvas with new data
ctx.putImageData(canvasData, 0, 0);
const t1 = performance.now();
console.log('Frame rendered in: ' + (t1 - t0).toFixed() + "ms");
}
function drawNxNPixel( N, loc, r, g, b ) {
N *= screenScale;
const idx0 = ( Math.floor( loc[0]*screenScale ) + Math.floor( loc[1]*screenScale ) * width * screenScale) * 4;
// if ( canvasData.data[idx0 + 3] === 255 ) return; // Don't draw if there's a data point already. Use carefully
const indices = [];
for (let i = 0; i < N*N; i++) {
indices.push( idx0 + i%N*4 + screenScale*width*Math.floor( i/N )*4 );
}
indices.forEach( (idx) => {
canvasData.data[idx + 0] = r;
canvasData.data[idx + 1] = g;
canvasData.data[idx + 2] = b;
canvasData.data[idx + 3] = 255;
});
}
// animate the points to a given layout
function animate(layout) {
// store the source position
points.forEach(point => {
point.sx = point.x;
point.sy = point.y;
});
// get destination x and y position on each point
layout(points);
// store the destination position
points.forEach(point => {
point.tx = point.x;
point.ty = point.y;
});
timer = d3.timer((elapsed) => {
// compute how far through the animation we are (0 to 1)
const t = Math.min(1, ease(elapsed / duration));
// update point positions (interpolate between source and target)
points.forEach(point => {
point.x = point.sx * (1 - t) + point.tx * t;
point.y = point.sy * (1 - t) + point.ty * t;
});
// update what is drawn on screen
draw();
// if this animation is over
if (t === 1) {
// stop this timer for this layout and start a new one
timer.stop();
// update to use next layout
currLayout = (currLayout + 1) % layouts.length;
// start animation for next layout
animate(layouts[currLayout]);
}
});
}
// create the canvas
const canvas = d3.select('body').append('canvas')
.attr('width', width * screenScale)
.attr('height', height * screenScale)
.style('width', `${width}px`)
.style('height', `${height}px`)
.on('click', function () {
d3.select('.play-control').style('display', '');
timer.stop();
});
canvas.node().getContext('2d').scale(screenScale, screenScale);
// start off as a grid
toGrid(points);
draw();
d3.select('body').append('div')
.attr('class', 'play-control')
.html('<div>PLAY <br/><span>100K points</span></div>')
.on('click', function () {
// start the animation
animate(layouts[currLayout]);
// remove the play control
d3.select(this).style('display', 'none');
});
@tansanDOTeth
Copy link

tansanDOTeth commented Jan 12, 2023

This is amazing stuff! I was actually writing a question asking about the interpolation code, but I figured it out as I was writing it 😆

Thanks for sharing this!

@rokotyan
Copy link
Author

@tansanDOTeth You're welcome 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment