2

I'm drawing a visualization of data on a 600x600 pixel HTML canvas. Each pixel requires about 10 additions/multiplications, so a little computation, but not a ton. Unfortunately, this takes about half a second to draw. Which means my sliders, which affect every single pixel usually, do not update the visualization smoothly.

Is it possible to improve performance by drawing in a more efficient manner? I'm currently calling the following for every pixel:

ctx.fillStyle = computedPixelColor
ctx.fillRect(x, y, 1, 1)

I read that creating an image and modifying the pixels, then inserting that image in the HTML is slower. Is that true? Are there hacks to this process?

at.
  • 50,922
  • 104
  • 292
  • 461
  • https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas – tevemadar Oct 21 '19 at 08:07
  • 1
    With `getImageData` / `putImageData` you speed this by manipulating the in-memory array representing the image. Check [this](https://www.wiktorzychla.com/2014/02/animated-javascript-julia-fractals.html) old tutorial I wrote few years ago, 768x768 image, also several additions/multiplication per pixel but the animation is smooth. – Wiktor Zychla Oct 21 '19 at 08:40
  • I just tried the `getImageData`/`putImageData` mechanism and it's roughly the same speed :(. – at. Oct 21 '19 at 09:04
  • 1
    Make sure you are putting the whole image in a single call, rather than updating it every pixel. Consult linked example. If still have trouble, create a [minimal verifiable example](https://stackoverflow.com/help/minimal-reproducible-example) with actual code. – Wiktor Zychla Oct 21 '19 at 09:24
  • Can you share a bit of what you have? Using an ImageData (note, only one) will definitely be faster than 360000 fillRect. Also, depending on how you get your pixels, you could work on an Uint32Array view of the ImageData's buffer for even faster access. – Kaiido Oct 21 '19 at 11:26
  • your best bet is to use webGL. The fragment shader lets you write each pixel at GPU speed. Most devices have GPU tuned to their displays so you will get full frame rate on 99% of devices. The following answer does about 34 floating point calcs per pixel and has plenty of time to spare. https://stackoverflow.com/a/38966946/3877726 – Blindman67 Oct 21 '19 at 12:51

1 Answers1

4

Example Moiré effect, popular in demos in the '90s. This implementation, with direct ImageData manipulation has 4-5 ms frame time for me (calculating 600x600 pixels, and displaying the result with putImageData()), though it is a relatively new PC. Of course it may matter a lot what your calculations are doing, this one is simple.

var ctx=cnv.getContext("2d");
var data=ctx.createImageData(600,600);
var buf=new Uint32Array(data.data.buffer);

function draw(x1,y1,x2,y2){
  var i=0;
  for(var y=0;y<600;y++)
    for(var x=0;x<600;x++){
      var d1=(Math.sqrt((x-x1)*(x-x1)+(y-y1)*(y-y1))/10) & 1;
      var d2=(Math.sqrt((x-x2)*(x-x2)+(y-y2)*(y-y2))/10) & 1;
      buf[i++]=d1==d2?0xFF000000:0xFFFFFFFF;
    }
  ctx.putImageData(data,0,0);
}

var cnt=0;
setInterval(function(){
  cnt++;
  var start=Date.now();
  draw(300+300*Math.sin(cnt*Math.PI/180),
       300+300*Math.cos(cnt*Math.PI/180),
       500+100*Math.sin(cnt*Math.PI/100),
       500+100*Math.cos(cnt*Math.PI/100));
  t.innerText="Frame time:"+(Date.now()-start)+"ms";
},20);
<div id="t"></div>
<canvas id="cnv" width="600" height="600"></canvas>

For comparison, the same effect with per-pixel fillRect()s produces 205 ms frame-time:

var ctx=cnv.getContext("2d");

function draw(x1,y1,x2,y2){
  //ctx.clearRect(0,0,600,600);
  //ctx.fillStyle="black";
  for(var y=0;y<600;y++)
    for(var x=0;x<600;x++){
      var d1=(Math.sqrt((x-x1)*(x-x1)+(y-y1)*(y-y1))/10) & 1;
      var d2=(Math.sqrt((x-x2)*(x-x2)+(y-y2)*(y-y2))/10) & 1;
      //if(d1==d2)ctx.fillRect(x,y,1,1);
      ctx.fillStyle=d1==d2?"black":"white";
      ctx.fillRect(x,y,1,1);
    }
}

var cnt=0;
setInterval(function(){
  cnt++;
  var start=Date.now();
  draw(300+300*Math.sin(cnt*Math.PI/180),
       300+300*Math.cos(cnt*Math.PI/180),
       500+100*Math.sin(cnt*Math.PI/100),
       500+100*Math.cos(cnt*Math.PI/100));
  t.innerText="Frame time:"+(Date.now()-start)+"ms";
},20);
<div id="t"></div>
<canvas id="cnv" width="600" height="600"></canvas>

Even if the "optimized" variant is used (using a global clearRect() and fillRect()-ing the black pixels only, see commented parts), that still needs 60-70-80 ms per frame (speed jumps around a lot).
So whatever per-pixel stuff you are doing, really read the Mozilla tutorial about ImageData as suggested in the comments already, using a direct buffer matters a lot.

tevemadar
  • 12,389
  • 3
  • 21
  • 49
  • 1
    You can write a whole pixel at a time. Get 32bit array `var buf = new Uint32Array(data.data.buffer);` then channel order is `ABGR` so to write white & black pixels, replace last 3 lines of inner loop with `buf[i++] = d1===d2 ? 0xFFFFFFFF : 0xFF000000;` – Blindman67 Oct 21 '19 at 12:38
  • @Blindman67 yeah, sure. In fact, originally I did not want to optimize it at all (`ctx` and `createBuffer()` were both inside the function), but then I thought it was wasteful coding and moved them outside, so let the suggestion be too (it gains 0.5-1 ms, btw). – tevemadar Oct 21 '19 at 13:20
  • Most probably in the code above what is taking time is the sqrt operation. Using LUTs instead should make it much faster. – Manuel Astudillo Dec 16 '21 at 12:34