I've been working on a game which requires thousands of very small images (20^20 px) to be rendered and rotated each frame. A sample snippet is provided.
I've used every trick I know to speed it up to increase frame rates but I suspect there are other things I can do to optimise this.
Current optimisations include:
Tried but not present in example:
//initial canvas and context
var canvas = document.getElementById('canvas');
canvas.width = 800;
canvas.height = 800;
var ctx = canvas.getContext('2d');
//create an image (I) to render
let myImage = new OffscreenCanvas(10,10);
let myImageCtx = myImage.getContext('2d');
myImageCtx.fillRect(0,2.5,10,5);
myImageCtx.fillRect(0,0,2.5,10);
myImageCtx.fillRect(7.5,0,2.5,10);
//animation
let animation = requestAnimationFrame(frame);
//fill an initial array of [n] object positions and angles
let myObjects = [];
for (let i = 0; i <1500; i++){
myObjects.push({
x : Math.floor(Math.random() * 800),
y : Math.floor(Math.random() * 800),
angle : Math.floor(Math.random() * 360),
});
}
//render a specific frame
function frame(){
ctx.clearRect(0,0,canvas.width, canvas.height);
//draw each object and update its position
for (let i = 0, l = myObjects.length; i<l;i++){
drawImageNoReset(ctx, myImage, myObjects[i].x, myObjects[i].y, myObjects[i].angle);
myObjects[i].x += 1; if (myObjects[i].x > 800) {myObjects[i].x = 0}
myObjects[i].y += .5; if (myObjects[i].y > 800) {myObjects[i].y = 0}
myObjects[i].angle += .01; if (myObjects[i].angle > 360) {myObjects[i].angle = 0}
}
//reset the transform and call next frame
ctx.setTransform(1, 0, 0, 1, 0, 0);
requestAnimationFrame(frame);
}
//fastest transform draw method - no transform reset
function drawImageNoReset(myCtx, image, x, y, rotation) {
myCtx.setTransform(1, 0, 0, 1, x, y);
myCtx.rotate(rotation);
myCtx.drawImage(image, 0,0,image.width, image.height,-image.width / 2, -image.height / 2, image.width, image.height);
}
<canvas name = "canvas" id = "canvas"></canvas>
You are very close to the max throughput using the 2D API and a single thread, however there are some minor points that can improve performance.
First though, if you are after the best performance possible using javascript you must use WebGL
With WebGL2 you can draw 8 or more times as many 2D sprites than with the 2D API and have a larger range of FX (eg color, shadow, bump, single call smart tile maps...)
WebGL is VERY worth the effort
globalAlpha
is applied every drawImage
call, values other than 1 do not affect performance.
Avoid the call to rotate
The two math calls (including a scale) are a tiny bit quicker than the rotate
. eg ax = Math..cos(rot) * scale; ay = Math.sin(rot) * scale; ctx.setTransform(ax,ay,-ay,ax,x,y)
Rather than use many images, put all the images in a single image (sprite sheet). Not applicable in this case
Don`t litter the global scope. Keep object close as possible to functions scope and pass object by reference. Access to global scoped variable is MUCH slower the local scoped variables.
Best to use modules as they hove their own local scope
Use radians. Converting angles to deg and back is a waste of processing time. Learn to use radians Math.PI * 2 === 360
Math.PI === 180
and so on
For positive integers don't use Math.floor
use a bit-wise operator as they automatically convert Doubles to Int32 eg Math.floor(Math.random() * 800)
is faster as Math.random() * 800 | 0
( |
is OR )
Be aware of the Number type in use. Converting to an integer will cost cycles if every time you use it you convert it back to double.
Always Pre-calculate when ever possible. Eg each time you render an image you negate and divide both the width and height. These values can be pre calculated.
Avoid array lookup (indexing). Indexing an object in an array is slower than direct reference. Eg the main loop indexes myObject
11 times. Use a for of
loop so there is only one array lookup per iteration and the counter is a more performant internal counter. (See example)
Though there is a performance penalty for this, if you separate update and render loops on slower rendering devices you will gain performance, by updating game state twice for every rendered frame. eg Slow render device drops to 30FPS and game slows to half speed, if you detect this update state twice, and render once. The game will still present at 30FPS but still play and normal speed (and may even save the occasional drooped frame as you have halved the rendering load)
Do not be tempted to use delta time, there are some negative performance overheads (Forces doubles for many values that can be Ints) and will actually reduce animation quality.
When ever possible avoid conditional branching, or use the more performant alternatives. EG in your example you loop object across boundaries using if statements. This can be done using the remainder operator %
(see example)
You check rotation > 360
. This is not needed as rotation is cyclic A value of 360 is the same as 44444160. (Math.PI * 2
is same rotation as Math.PI * 246912
)
Each animation call you are preparing a frame for the next (upcoming) display refresh. In your code you are displaying the game state then updating. That means your game state is one frame ahead of what the client sees. Always update state, then display.
This example has added some additional load to the objects
The example includes a utility that attempts to balance the frame rate by varying the number of objects.
Every 15 frames the (work) load is updated. Eventually it will reach a stable rate.
DON`T NOT gauge the performance by running this snippet, SO snippets sits under all the code that runs the page, the code is also modified and monitored (to protect against infinite loops). The code you see is not the code that runs in the snippet. Just moving the mouse can cause dozens of dropped frames in the SO snippet
For accurate results copy the code and run it alone on a page (remove any extensions that may be on the browser while testing)
Use this or similar to regularly test your code and help you gain experience in knowing what is good and bad for performance.
"30fps 5dropped"
the five drop frames are at 30fps, the total time of dropped frames is 5 * (1000 / 30)
const IMAGE_SIZE = 10;
const IMAGE_DIAGONAL = (IMAGE_SIZE ** 2 * 2) ** 0.5 / 2;
const DISPLAY_WIDTH = 800;
const DISPLAY_HEIGHT = 800;
const DISPLAY_OFFSET_WIDTH = DISPLAY_WIDTH + IMAGE_DIAGONAL * 2;
const DISPLAY_OFFSET_HEIGHT = DISPLAY_HEIGHT + IMAGE_DIAGONAL * 2;
const PERFORMANCE_SAMPLE_INTERVAL = 15; // rendered frames
const INIT_OBJ_COUNT = 500;
const MAX_CPU_COST = 8; // in ms
const MAX_ADD_OBJ = 10;
const MAX_REMOVE_OBJ = 5;
canvas.width = DISPLAY_WIDTH;
canvas.height = DISPLAY_HEIGHT;
requestAnimationFrame(start);
function createImage() {
const image = new OffscreenCanvas(IMAGE_SIZE,IMAGE_SIZE);
const ctx = image.getContext('2d');
ctx.fillRect(0, IMAGE_SIZE / 4, IMAGE_SIZE, IMAGE_SIZE / 2);
ctx.fillRect(0, 0, IMAGE_SIZE / 4, IMAGE_SIZE);
ctx.fillRect(IMAGE_SIZE * (3/4), 0, IMAGE_SIZE / 4, IMAGE_SIZE);
image.neg_half_width = -IMAGE_SIZE / 2; // snake case to ensure future proof (no name clash)
image.neg_half_height = -IMAGE_SIZE / 2; // use of Image API
return image;
}
function createObject() {
return {
x : Math.random() * DISPLAY_WIDTH,
y : Math.random() * DISPLAY_HEIGHT,
r : Math.random() * Math.PI * 2,
dx: (Math.random() - 0.5) * 2,
dy: (Math.random() - 0.5) * 2,
dr: (Math.random() - 0.5) * 0.1,
};
}
function createObjects() {
const objects = [];
var i = INIT_OBJ_COUNT;
while (i--) { objects.push(createObject()) }
return objects;
}
function update(objects){
for (const obj of objects) {
obj.x = ((obj.x + DISPLAY_OFFSET_WIDTH + obj.dx) % DISPLAY_OFFSET_WIDTH);
obj.y = ((obj.y + DISPLAY_OFFSET_HEIGHT + obj.dy) % DISPLAY_OFFSET_HEIGHT);
obj.r += obj.dr;
}
}
function render(ctx, img, objects){
for (const obj of objects) { drawImage(ctx, img, obj) }
}
function drawImage(ctx, image, {x, y, r}) {
const ax = Math.cos(r), ay = Math.sin(r);
ctx.setTransform(ax, ay, -ay, ax, x - IMAGE_DIAGONAL, y - IMAGE_DIAGONAL);
ctx.drawImage(image, image.neg_half_width, image.neg_half_height);
}
function timing(framesPerTick) { // creates a running mean frame time
const samples = [0,0,0,0,0,0,0,0,0,0];
const sCount = samples.length;
var samplePos = 0;
var now = performance.now();
const maxRate = framesPerTick * (1000 / 60);
const API = {
get FPS() {
var time = performance.now();
const FPS = 1000 / ((time - now) / framesPerTick);
const dropped = ((time - now) - maxRate) / (1000 / 60) | 0;
now = time;
if (FPS > 30) { return "60fps " + dropped + "dropped" };
if (FPS > 20) { return "30fps " + (dropped / 2 | 0) + "dropped" };
if (FPS > 15) { return "20fps " + (dropped / 3 | 0) + "dropped" };
if (FPS > 10) { return "15fps " + (dropped / 4 | 0) + "dropped" };
return "Too slow";
},
time(time) { samples[(samplePos++) % sCount] = time },
get mean() { return samples.reduce((total, val) => total += val, 0) / sCount },
};
return API;
}
function updateStats(CPUCost, objects) {
const fps = CPUCost.FPS;
const mean = CPUCost.mean;
const cost = mean / objects.length; // estimate per object CPU cost
const count = MAX_CPU_COST / cost | 0;
const objCount = objects.length;
var str = "0";
if (count < objects.length) {
var remove = Math.min(MAX_REMOVE_OBJ, objects.length - count);
str = "-" + remove;
objects.length -= remove;
} else if (count > objects.length + MAX_ADD_OBJ) {
let i = MAX_ADD_OBJ;
while (i--) {
objects.push(createObject());
}
str = "+" + MAX_ADD_OBJ;
}
info.textContent = str + ": " + objCount + " sprites " + mean.toFixed(3) + "ms " + fps;
}
function start() {
var frameCount = 0;
const CPUCost = timing(PERFORMANCE_SAMPLE_INTERVAL);
const ctx = canvas.getContext('2d');
const image = createImage();
const objects = createObjects();
function frame(time) {
frameCount ++;
const start = performance.now();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, DISPLAY_WIDTH, DISPLAY_WIDTH);
update(objects);
render(ctx, image, objects);
requestAnimationFrame(frame);
CPUCost.time(performance.now() - start);
if (frameCount % PERFORMANCE_SAMPLE_INTERVAL === 0) {
updateStats(CPUCost, objects);
}
}
requestAnimationFrame(frame);
}
#info {
position: absolute;
top: 10px;
left: 10px;
background: #DDD;
font-family: arial;
font-size: 18px;
}
<canvas name = "canvas" id = "canvas"></canvas>
<div id="info"></div>