samizdatco / skia-canvas

A GPU-accelerated 2D graphics environment for Node.js
MIT License
1.67k stars 63 forks source link

clearRect doesn't really clear the canvas #107

Closed AliFlux closed 2 years ago

AliFlux commented 2 years ago

Hi.

I am trying to clear the canvas but I noticed that the canvas was not properly cleared when calling clearRect.

let src = new Canvas(300, 300);
let srcCtx = src.getContext("2d");

srcCtx.lineWidth = 4
srcCtx.beginPath()
srcCtx.moveTo(50,50)
srcCtx.lineTo(120,120)
srcCtx.moveTo(50,120)
srcCtx.lineTo(120,50)
srcCtx.stroke()

srcCtx.font = "30px Arial";
srcCtx.fillText("Cross", 150, 100); 

let img = await loadImage('lime-cat.jpg')
srcCtx.drawImage(img, 50, 170, 70, 70)

await src.saveAs("img1.pdf")

// clearing and redrawing it...
srcCtx.clearRect(0, 0, src.width, src.height)

srcCtx.font = "40px Georgia";
srcCtx.fillText("AI", 10, 50);

await src.saveAs("img2.pdf")

// img2.pdf size is bigger than img1.pdf
// img2 stores content from img1 (but its hidden)

This is specially observed in pdf because when we clear the canvas and redraw text, it contains artifacts from previous render and the size keeps on increasing.

Also, when we export it to svg, the entire canvas is black. And using dev-tools we can see that previous content isn't properly cleared. Only a black rect is overlayed when we call clearRect.

aastefian commented 2 years ago

Hi, i am experiencing something which is possibly caused by this issue as well. When running getImageData() or canvas.saveAs() or canvas.png inside a loop which updates the canvas every frame, the time to execute those functions increases dramatically, and i think it is caused by the canvas not properly being cleared out.

Using this snippet for example (1920x1080 canvas with an fullscreen image rotated every frame):

const { Canvas, loadImage } = require('skia-canvas');
const fs = require('fs');

let canvas = new Canvas(1920, 1080),
  ctx = canvas.getContext("2d"),
  { width, height } = canvas;

async function drawImage(image, x, y, scale, rotation) {
  ctx.setTransform(scale, 0, 0, scale, x, y); // sets scale and origin
  ctx.rotate(rotation);
  ctx.drawImage(image, -image.width / 2, -image.height / 2);
}

async function benchmark_CanvasPngCall(frame) {
  console.time(`Call: canvas.png() - frame ${frame} - `);
  let data = await canvas.png;
  console.timeEnd(`Call: canvas.png() - frame ${frame} - `);
  fs.writeFile(`output/${frame}.png`, data, () => { })
}

async function benchmark_CanvasSaveAsCall(frame) {
  console.time(`Call: canvas.saveAs() - frame ${frame} - `);
  await canvas.saveAs(`output/${frame}.png`);
  console.timeEnd(`Call: canvas.saveAs() - frame ${frame} - `);
}

async function benchmark_GetImageDataCall(frame) {
  console.time(`Call: ctx.getImageData() - frame ${frame} - `);
  ctx.getImageData(0, 0, width, height).data;
  console.timeEnd(`Call: ctx.getImageData() - frame ${frame} - `);
}

async function main() {
  let img = await loadImage('https://image.shutterstock.com/image-illustration/science-fiction-cyborg-female-shooting-600w-1430260730.jpg')

  for (frame = 0; frame < 50; frame += 1) {

    // Rotate image every frame
    drawImage(img, 300, 300, 5, frame);

    benchmark_GetImageDataCall(frame);
    // await benchmark_CanvasSaveAsCall(frame);
    // await benchmark_CanvasPngCall(frame);

    ctx.clearRect(0, 0, 1920, 1080);
    ctx.beginPath();
  }
}

main();

The output is

Call: ctx.getImageData() - frame 0 - : 22.813ms
Call: ctx.getImageData() - frame 1 - : 65.215ms
Call: ctx.getImageData() - frame 2 - : 107.227ms
Call: ctx.getImageData() - frame 3 - : 187.912ms
Call: ctx.getImageData() - frame 4 - : 173.946ms
Call: ctx.getImageData() - frame 5 - : 142.837ms
Call: ctx.getImageData() - frame 6 - : 122.078ms
Call: ctx.getImageData() - frame 7 - : 132.296ms
Call: ctx.getImageData() - frame 8 - : 147.534ms
Call: ctx.getImageData() - frame 9 - : 175.372ms
Call: ctx.getImageData() - frame 10 - : 182.73ms
Call: ctx.getImageData() - frame 11 - : 195.362ms
Call: ctx.getImageData() - frame 12 - : 220.351ms
Call: ctx.getImageData() - frame 13 - : 249.577ms
Call: ctx.getImageData() - frame 14 - : 256.086ms
Call: ctx.getImageData() - frame 15 - : 261.054ms
Call: ctx.getImageData() - frame 16 - : 283.185ms
Call: ctx.getImageData() - frame 17 - : 292.166ms
Call: ctx.getImageData() - frame 18 - : 311.941ms
Call: ctx.getImageData() - frame 19 - : 326.646ms
Call: ctx.getImageData() - frame 20 - : 338.903ms
Call: ctx.getImageData() - frame 21 - : 404.965ms
Call: ctx.getImageData() - frame 22 - : 424.276ms
Call: ctx.getImageData() - frame 23 - : 455.074ms
Call: ctx.getImageData() - frame 24 - : 486.741ms
Call: ctx.getImageData() - frame 25 - : 509.859ms
Call: ctx.getImageData() - frame 26 - : 541.983ms
Call: ctx.getImageData() - frame 27 - : 497.698ms
Call: ctx.getImageData() - frame 28 - : 596.351ms
Call: ctx.getImageData() - frame 29 - : 579.769ms
Call: ctx.getImageData() - frame 30 - : 556.684ms
Call: ctx.getImageData() - frame 31 - : 570.487ms
Call: ctx.getImageData() - frame 32 - : 552.103ms
Call: ctx.getImageData() - frame 33 - : 601.873ms
Call: ctx.getImageData() - frame 34 - : 594.887ms
Call: ctx.getImageData() - frame 35 - : 632.101ms
Call: ctx.getImageData() - frame 36 - : 627.938ms
Call: ctx.getImageData() - frame 37 - : 672.587ms
Call: ctx.getImageData() - frame 38 - : 644.82ms
Call: ctx.getImageData() - frame 39 - : 717.496ms
Call: ctx.getImageData() - frame 40 - : 690.576ms
Call: ctx.getImageData() - frame 41 - : 711.939ms
Call: ctx.getImageData() - frame 42 - : 698.927ms
Call: ctx.getImageData() - frame 43 - : 724.177ms
Call: ctx.getImageData() - frame 44 - : 782.166ms
Call: ctx.getImageData() - frame 45 - : 758.398ms
Call: ctx.getImageData() - frame 46 - : 817.652ms
Call: ctx.getImageData() - frame 47 - : 849.355ms
Call: ctx.getImageData() - frame 48 - : 921.884ms
Call: ctx.getImageData() - frame 49 - : 916.684ms

The call went from 22ms to 916ms (and I have more frames to generate for my use case so it keeps going up to even 3s per call)

And if i run benchmark_CanvasSaveAsCall in order to save images to files i can see older states of the image still present (rotations from previous frames) 18 image If i run ctx.resetTransform() right after the draw call then the render looks good, but the functions still take same amount of time to run.

Original image: image

samizdatco commented 2 years ago

The actual HTML Canvas API is designed around the idea that it's just a way of setting pixel colors in a fixed-sized bitmap. In that model 'clearing' the canvas is the same thing as setting all those pixels to the same color—whatever state they had before that is wiped away.

Skia Canvas, on the other hand, is actually creating vector objects with every drawing command and rendering them sequentially every time you generate an output image. This is what allows for resolution independence (via the density export option) and the ability to create PDF and SVGs in the first place. But because of this, the clearRect method doesn't truly ‘erase’ the objects that overlap with a given rectangle—as you've noticed, it just ensures that the whole region is set to a single color.

This is also the reason you're seeing a slow-down in rendering times as you keep ‘clearing’ and redrawing the frame, since none of the previous frames are truly gone—they're just hidden behind a rectangle. What you actually want is to reset the canvas. This proposal for adding a reset() context method would give you an unambiguous way to accomplish that (and seems like it would be worth adding).

But, in the meantime, your best workaround is this absurd method from the browser world:

canvas.width = canvas.width

Any time you set the canvas's width or height, it also erases all existing content as a side effect.

aastefian commented 2 years ago

Thanks for the explanation, pretty clear. I will give that hack (canvas.width = canvas.width) a try sometime.

For now the fix I found is to recreate the Canvas instance every frame (which is slower normally, but overall is way faster because it removes the increase in time for the getImageData() call)

I see you added a commit recently, is that gonna automatically clear canvas when calling clearRect() with the width and height equal to the one's of the canvas ?

samizdatco commented 2 years ago

I see you added a commit recently, is that gonna automatically clear canvas when calling clearRect() with the width and height equal to the one's of the canvas ?

That's right. As of the 1.0 release, calling clearRect with dimensions that are equal to (or larger than) the canvas size will erase all the content rather than simply covering it up. Some things to keep in mind though:

The 1.0 release also added the ctx.reset() method mentioned above which both erases the canvas and resets all the context state in one go.