KhronosGroup / WebGL

The Official Khronos WebGL Repository
Other
2.63k stars 668 forks source link

CSS pixel size -> WebGL canvas size example is subtly wrong? #2460

Open juj opened 7 years ago

juj commented 7 years ago

Consider the example of choosing a WebGL backbuffer size based on the CSS pixel size of the canvas window.devicePixelRatio to match 1:1 with high DPI/physical screen pixels at

https://www.khronos.org/webgl/wiki/HandlingHighDPI

The example reads

<style>
 #theCanvas {
    width: 50%;
    height: 50%;
}
<style>
<canvas id="theCanvas"></canvas>
<script>
window.onload = main();

function main() {
  var canvas = document.getElementById("theCanvas");
  var devicePixelRatio = window.devicePixelRatio || 1;

  // set the size of the drawingBuffer based on the size it's displayed.
  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;

  var gl = canvas.getContext("webgl");
  ...
}
</script>

Here window.devicePixelRatio can be an arbitrary number like 1.353239x (it is affected by page zoom level) and the CSS pixel size (canvas.clientWidth/Height) can also be arbitrary float numbers like 732.4315135px as they come from width: 50%, height: 50% sizes, but canvas.width/.height need to be integers, so there's a double->int conversion that takes place.

In this example the physical size of the canvas is set to truncate/round down the computed CSS pixel size to integer units at

  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;

however to my understanding there is no guarantee that rounding down is the correct action to perform, nor that rounding to nearest, or rounding up would be the correct action to perform either, in order to get a WebGL rendering output that matches up 1:1 with the pixel size on screen.

In practice this kind of "I don't know whether I should round down, round up or round to nearest" problem can result in off-by-one pixel scenarios for the WebGL render target in different browser implementations, and as result, either the whole WebGL canvas output becomes blurry, as its composited to widen/tallen/shrink by one pixel, or one column or row of the WebGL canvas is duplicated in the middle, or there will be a one-pixel black/white border on the canvas.

To my experience the only correct way to choose the backbuffer size of a WebGL canvas, given an (arbitrary) CSS pixel size, is to first compute the WebGL backbuffer size from the CSS pixel size, e.g. using the above formula, but then after having set the WebGL backbuffer size, propagate that integer size back to the CSS pixel size to ensure that the two match up. That is,


  // set the size of the drawingBuffer based on the size it's displayed.
  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;

  // propagate the integer size back to CSS pixels to ensure they align up 1:1.
  canvas.style.width = (canvas.width / devicePixelRatio) + 'px';
  canvas.style.height = (canvas.height / devicePixelRatio) + 'px';

A result of this issue is that to avoid compositing artifacts, it's not possible to use automatic CSS directives such as width: 50% to size a canvas, while expecting a 1:1 match with physical screen size.

Is there anything better to ensure 1:1 match for a canvas size without glitches, or if not, perhaps the example should be updated to instruct developers with this gotcha?

kenrussell commented 7 years ago

It's been a longstanding problem that it's not possible to reliably query the dimensions in device pixels of a canvas element. This was raised as an issue some time ago but I don't remember exactly where; sorry, don't have time to dig it up right now.

At one point measurement APIs for the Canvas element in device pixels were proposed; then this was supposed to be generally subsumed into the CSSOM APIs, which I don't think happened.

@grorg was responsible for this and @junov was a collaborator; hopefully one of them can comment on it.

kenrussell commented 7 years ago

Thinking a bit more, I think that the drawingBufferWidth and drawingBufferHeight properties may already work for this case.

juj commented 7 years ago

The values drawingBufferWidth and drawingBufferHeight don't seem to help this case.

Here is a small example page that follows the advice from the HandlingHighDPI article, and illustrates how it goes wrong:

<html style='height:100%'><body style='margin:0; height:100%'>
<canvas id="webgl-canvas" style="width: 50%; height: 50%;"></canvas> <br>
<input type="checkbox" id="checkbox" checked onclick='redraw();'>Use buggy high DPI handling that does not align up (drag and zoom to resize browser window to see the effect) </input>
<div>window.devicePixelRatio = <span id='wdpr'></span></div>
<script type="vertex" id="vs">
  #version 300 es
  layout (location=0) in vec4 position;
  out vec2 pos;
  void main() {
    pos = (position.xy + vec2(1,1)) * 0.5;
    gl_Position = position;
  }
</script>
<script type="fragment" id="fs">
  #version 300 es
  precision highp float;
  in vec2 pos;
  out vec4 fragColor;
  uniform uvec2 canvasSize;
  void main() {
    uvec2 xy = uvec2(vec2(canvasSize) * pos) % 2u;
    fragColor = vec4(xy, 0, 1);
  }
</script>
<script>
function redraw() {
  document.getElementById('wdpr').innerHTML = window.devicePixelRatio + 'x';
  if (document.getElementById('checkbox').checked) {
    // First choosing a CSS size for a canvas..
    canvas.style.width = '50%';
    canvas.style.height = '50%';
    var size = canvas.getBoundingClientRect();

    // .. and then sizing the WebGL backbuffer size based on that, is doomed.
    canvas.width = size.width * window.devicePixelRatio;
    canvas.height = size.height * window.devicePixelRatio;
  } else {
    // First choosing a CSS size for a canvas..
    var cssWidth = window.innerWidth / 2;
    var cssHeight = window.innerHeight / 2;

    // .. and then sizing the WebGL backbuffer size to that size..
    canvas.width = cssWidth * window.devicePixelRatio;
    canvas.height = cssHeight * window.devicePixelRatio;

    // .. and third, re-snapping back the CSS size of the canvas to match the chosen WebGL backbuffer size, will produce glitch free sizing.
    canvas.style.width = (canvas.width / window.devicePixelRatio) + 'px';
    canvas.style.height = (canvas.height / window.devicePixelRatio) + 'px';
    size = canvas.getBoundingClientRect();
  }

  gl.uniform2ui(gl.getUniformLocation(program, "canvasSize"), canvas.width, canvas.height);
  gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  console.log('draw: CSS size: ' + size.width + 'x' + size.height + ', WebGL backbuffer size: ' + canvas.width + 'x' + canvas.height + ', drawingBufferSize: ' + gl.drawingBufferWidth + 'x' + gl.drawingBufferHeight);
}

var canvas = document.getElementById("webgl-canvas");
var gl = canvas.getContext("webgl2");
var vsSource = document.getElementById("vs").text.trim();
var fsSource = document.getElementById("fs").text.trim();
var vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.clearColor(0, 0, 1, 1); // Blue color should never show up
gl.shaderSource(vertexShader, vsSource);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(vertexShader));
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fsSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(fragmentShader));
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) console.error(gl.getProgramInfoLog(program));
gl.useProgram(program);
var triangleArray = gl.createVertexArray();
gl.bindVertexArray(triangleArray);
var positions = new Float32Array([-1, -1, 1, -1, 1,  1, 1,  1, -1, -1, -1,  1]);
var positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);

redraw();
window.addEventListener('resize', redraw);
</script></body></html>

You can navigate to a live version of that page at http://clb.demon.fi/dump/webgl_pixel_perfect_rendering.html

The page renders a checkerboard where every second vertical pixel column is black and every second is red, and every second horizontal pixel row is black and every second is green. Zoomed in, the pattern looks like this:

checkerboard

If the rendering is displayed pixel perfect on screen, then the outputted colors should look uniform across the whole canvas (please click on the image to show 1:1 scaled, GitHub shrinks the images):

correct_pixel_perfect_rendering

but in the HandlingHighDPI example, the canvas CSS size is set to (50%, 50%), and the WebGL backbuffer size is set to (int(canvas.clientWidth * devicePixelRatio), int(canvas.clientHeight * devicePixelRatio)), which will not align up, depending on the size of the browser window, and the output looks incorrect and nonuniformly shaded like this (again click to show the full image):

incorrect_pixel_perfect_rendering

Here the example uses the second method of the HandlingHighDPI page to illustrate the issue, but looking through the page, all three methods suffer from this fault. I.e. all of the three following snippets are incorrect:

// set the display size of the canvas.
canvas.style.width = desiredWidthInCSSPixels + "px";
canvas.style.height = desiredHeightInCSSPixels + "px";

// set the size of the drawingBuffer
var devicePixelRatio = window.devicePixelRatio || 1;
canvas.width = desiredWidthInCSSPixels * devicePixelRatio;
canvas.height = desiredHeightInCSSPixels * devicePixelRatio;
  // set the size of the drawingBuffer based on the size it's displayed.
  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;
  canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
  canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);

The text passage saying "Note specifically that in some situations it may be necessary to use Math.round() when computing the canvas's width and height from the client width and height and devicePixelRatio:" is particularly bad, since that does not make any difference. I think the root scenario is that browsers are happily doing subpixel precise compositing of the WebGL canvas which will produce the observed stretching.

Currently the only correct way to define a pixel perfect size for a WebGL canvas that I'm aware of is a three step process, as illustrated in the example page:

  1. Choose the desired CSS size for the canvas: canvas.style.{width,height} = '2342px;'
  2. Compute the appropriate WebGL backbuffer size by multiplying the size from step 1 by window.devicePixelRatio: canvas.{width,height} = canvas css {width,height} * window.devicePixelRatio; (round, truncate, etc, doesn't matter, since step 3 below will fix)
  3. Resize the CSS size of the canvas again to line up with the chosen WebGL backbuffer size from step 2: canvas.style.{width,height} = (canvas.{width,height} / window.devicePixelRatio) + 'px';

This is because for any arbitrary WebGL backbuffer size (an integer), there exists a CSS size (a double) that it can match 1:1 with, but the opposite direction won't work. That is, most CSS sizes for canvases are "tainted" and cannot be matched up with an appropriate WebGL backbuffer size, but only those CSS sizes that align up with integer quantas after window.devicePixelRatio scaling are safe.

The result of this is that automatic CSS-based page layouting mechanisms, such as style "width: 50%;" cannot be used for canvases if one wants to render in high DPI and match 1:1 WebGL pixels to the screen without artifacts, but one must lay out browser and DOM element resized manually. For such an example page above, it is quite easy to do, but for complex webpages, it may be somewhat difficult.

I don't know if this could be fixed at a spec level, but the HandlingHighDPI article would be good to note this fact and avoid the glitchy examples.

greggman commented 7 years ago

your example isn't working for me. It doesn't match 1:1 checked or unchecked and strobes with moire pattern as I size the window on my Mid 2014 Macbook Pro

https://www.youtube.com/watch?v=LxHVGrafOr8

This is because for any arbitrary WebGL backbuffer size (an integer), there exists a CSS size (a double) that it can match 1:1 with

I don't see how that's true. If make a backbuffer that's 3x3 on a devicePixelRatio = 2 machine what CSS size will display that 1 to 1? Setting the CSS to size to 1px means drawn pixels are 2x2 but backbuffer is 3x3. That's not 1 to 1. Setting the CSS size to 2px means drawn pixels are 4x4 but backbuffer is 3x3 so that's not 1 to 1 either. Maybe I'm mis-understanding your statement that

for any arbitrary WebGL backbuffer size (an integer), there exists a CSS size (a double) that it can match 1:1 with

Here's a smaller repo using the wiki method: https://jsfiddle.net/greggman/n8tv8t2q/

And here's a smaller version of your method: https://jsfiddle.net/greggman/db0cyLr4/

They both seem to get the exact same results which is resizing the window works but zooming the window (when devicePixelRatio becomes something other than 2 doesn't work which is what I'd expect, at least in Chrome

AFAICT the only way to make it work would be to add an API or change the spec to specified the math.

greggman commented 7 years ago

update:

checking in firefox I see the suggested method is working. That said it seems like luck that the math matches firefox and not chrome's.

It's also arguably not really recommendable solution. Manually setting the size of the canvas is arguably fighting the platform. You need to be able to set the canvas size to percentages, and other measurements like 50vw or 12em so the layout engine can resize things as needed. This is especially important when the canvas needs to fit the size of its parent like if it's in a grid or a flexbox.

It seems like we either need a standard formula from CSS size to device pixels, or we need an API to be able to ask.

juj commented 7 years ago

If make a backbuffer that's 3x3 on a devicePixelRatio = 2 machine what CSS size will display that 1 to 1?

For a 3x3 backbuffer (canvas.width=canvas.height=3) with window.devicePixelRatio=2, a CSS size of 1.5px should display it 1:1. (canvas.style.width = canvas.style.height = '1.5px';). CSS pixels coordinate system is not restricted to integers, but CSS pixel coordinates are like em or other units, and can take arbitrary fractions.

your example isn't working for me. It doesn't match 1:1 checked or unchecked and strobes with moire pattern as I size the window on my Mid 2014 Macbook Pro

Testing on my MacBook Pro (13-inch, 2016, Four Thunderbolt 3 Ports) macOS Sierra 10.12.5 (16F73), I can reproduce this observation, on Chrome 61.0.3154.0 (Official Build) canary (64-bit) neither method works but moire is present on both. On the same laptop on Firefox Nightly, if zoom is at 100% so that window.devicePixelRatio=2, I see that both methods work, but that is due to the CSS style of the canvas being at exactly width: 50%; height: 50%;. If I change the zoom level to something != 100% or the CSS percentage to e.g. 45%, then the checkbox ticked method no longer works, but only the unchecked mode works.

On Windows 10 on both Chrome and Firefox with window.devicePixelRatio=1.5 at 100% zoom, I can observe that the checked method produces moire on both Firefox and Chrome, and the unchecked method renders correctly on both when zooming in or out arbitrarily.

It looks like the only scenario that is not working with the unchecked method is Chrome on OS X with DPR=2. I'm not sure at all why that is.

juj commented 7 years ago

It seems like we either need a standard formula from CSS size to device pixels, or we need an API to be able to ask.

The issue is not about missing a particular formula to do this conversion, but that if you first take an arbitrary box size for a DOM element, say, canvas.style.width = '1280.42322px;' canvas.style.height = '730.51566px;, then none of round down, round up, round nearest combination after multiplying by window.devicePixelRatio will produce a WebGL backbuffer size that would be composited pixel perfectly. When using CSS-based layouting with width: 50%; and similar, it will arbitrarily scale content and produce these kind of bad sizes that cannot line up.

It's also arguably not really recommendable solution.

Sorry, I was not proposing a solution here, but describing the only scheme I am aware of that produces good results for me (except for the Chrome on OS X specific scenario which you noticed, which to me looks like a bug).

You need to be able to set the canvas size to percentages,

Agreed, that is an important feature.

greggman commented 7 years ago

This seems to work

  const bounds = canvas.getBoundingClientRect();
  canvas.width = Math.round(bounds.width * window.devicePixelRatio);
  canvas.height = Math.round(bounds.height * window.devicePixelRatio);

In both Firefox and Chrome

here's a live sample: https://jsfiddle.net/greggman/ghqkufts/

The reason it works is because getBoundingClientRect returns fractional values

Note that sample runs constantly using requestAnimationFrame. The reason is is without that there's at least a frame when the browser has decided to display in a new size and it's rendered the canvas but JavaScript has not been given time to update the canvas so I'd see these flickers of moire patterns as I was sizing but they'd always disappear. Updating every frame removes that issue so there's no flicker of a frame or two

juj commented 7 years ago

Using Math.round() does not work for me on Chrome on either Windows or OS X, but I get the same aliasing when zooming in or out, i.e. when window.devicePixelRatio != 2. It does look like Math.round() works on Firefox though.

juj commented 7 years ago

Here is a version of the original page, but with

    // .. and then sizing the WebGL backbuffer size based on that, is doomed.
    canvas.width = size.width * window.devicePixelRatio;
    canvas.height = size.height * window.devicePixelRatio;

above changed to

    // .. and then sizing the WebGL backbuffer size based on that, is doomed.
    canvas.width = Math.round(size.width * window.devicePixelRatio);
    canvas.height = Math.round(size.height * window.devicePixelRatio);

http://clb.demon.fi/dump/webgl_pixel_perfect_rendering_with_math_round.html, which gives the same aliasing artifacts.

juj commented 7 years ago

It does look like Math.round() works on Firefox though.

Err, I was too hasty to conclude, it looks like it does not work on Firefox either.

kenrussell commented 7 years ago

@junov and @grorg worked on this problem a couple of years ago, resulting in the following proposal: https://wiki.whatwg.org/wiki/CanvasRenderedPixelSize https://www.chromestatus.com/feature/5477772331843584

It was never implemented. One problem is that if you query the canvas's size in device pixels and try to set its width and height to match, setting the width and height is layout-inducing, potentially changing the size in device pixels again, and resulting in an oscillating loop.

Here's a modified version of Gregg's sample that takes the canvas element fullscreen when clicked: https://jsfiddle.net/fkpndkxy/8/

This screenshot makes it look like it might be doing the right thing: https://photos.app.goo.gl/jjPZp94rm1Oi1XXq2

but on-screen there's a significant moire pattern on the Pixel phone. On the Nexus 5X it looks a lot better.

The size this example computes after the fullscreen event is 1082x1922. It seems unlikely that this is the phone's actual size; 1080x1920 seems more likely. So there probably is a moire pattern.

If we solved this problem for the full-screen case, would that help? Returning the number of device pixels for the full-screen case is simpler than the scenario where the canvas is displayed as part of a page.

greggman commented 7 years ago

The Math.round sample is working for me on Firefox on my 2014 Macbook Pro

This video starts off with Chrome where you can clearly see it flicker as the pattern changes It then switches to Firefox which is having no problems.

https://www.youtube.com/watch?v=85PmCfGNCn8

The only time I see issues is the same as I mentioned above, without doing it inside requestAnimationFrame there's often one or two frames of latency before it gets re-rendered but otherwise it always renders correctly in Firefox using

  canvas.width = Math.round(bounds.width * devicePixelRatio);

On the other hand the sample that was working in Chrome, if I switch the CSS from width: 100vw; to width: 50vw breaks it in Chrome so I guess it was just getting lucky before in Chrome.

But, that does make it seem like it's just a bug in Chrome. If Chrome matched Firefox's calculations then it seems like the math above would work. Unless of course it just happens to be working on my machine or if there are more circumstances.

Hmmm, well, checking on Windows I get it works in Chrome but not Firefox 🙄

kenrussell commented 7 years ago

Talking with @junov the behavior of the browsers' compositors as they snap DOM elements to device pixels is not specified. It's basically luck whether it's possible to match the compositor's layout algorithm. In Chrome there is the additional complexity that the compositor runs on a different thread. It's not possible to synchronously query the size in device pixels of a given element, although it may be possible to synchronously give an answer which would be updated asynchronously the next frame or the frame after (and potentially send notifications of that via events).

arodic commented 6 years ago

Has there been any progress with this?

I noticed that 1px lines look horrible on high-dpi displays, especially if antialiasing is disabled.

For example, this three.js example, and many others clearly show improper aliasing artifacts on the lines. (tested on retina mac).

The weirdest thing is that taking a screenshot on mac results with perfectly fine lines. However, if you look closely at physical pixels something is obviously off.

This is what 1px lines should look like (and they do in screenshot):

line2 screenshot

However, the actual pixels render more like this:

line1 actual pixels
kenrussell commented 6 years ago

Sorry, progress on this stalled. I've just emailed blink-dev@ to ask what the current thinking is on this topic.

arodic commented 6 years ago

Thanks Ken!

I did some further investigation with retina MBP and things got even weirder... I was intrigued by the difference between the screenshot data and what I saw with my eyes so I poked some more.

Using screenutil I was able to force MacOS into native resolution (2880 x 1800) and pixelAspectRatio of 1. However, 1px lines still had very bad aliasing artifacts. Then I checked Greg's jsfiddle and noticed that rows and columns of the test pattern have variable widths. I don't have access to a microscope right now but my best guess is that there is something about the subpixel arrangement that makes pixel-perfect graphics physically impossible on retina display panels - so this may be entirely different issue.

retina

arodic commented 6 years ago

Another observation: Apparently, three.js multiplies gl.linewidth() with pixelRatio which mitigates the artifacts. However, this does not work on Chrome because of crbug#675308

kenrussell commented 6 years ago

Great news! The above WICG issue could resolve this in an elegant way!

arodic commented 6 years ago

I just found the reason for weirdly aliased lines on some Macbook Pro displays. It might also affect this issue on some machines.

It was difficult to believe my eyes, but it turns out MacOS is by default setting wrong resolution on some laptops. It is difficult to spot because of high-density pixel matrix but it is definitely off.

I have 2017 13.3-inch MBP with 2560 x 1600 display panel and from the following display scaling options, only the first two result with correct 1:1 pixel mapping. Both default and more space option set the real display resolution to something other display's hardware resolution. My guess is 2880 x 1800 hence the aliased lines and uneven line widths.

Am I wrong to assume that MacOS resolution should always match the hardware resolution of the display panel regardless of UI scaling?

screen shot 2018-09-17 at 12 41 29 pm
dmikis commented 5 years ago

The strange thing is that on macOS for every "logical" resolution besides the largest one (which usually is 1 to 1 with display physical resolution) devicePixelRation is 2 (using data:text/html,<h1><script>document.write(devicePixelRatio)</script></h1> to print it):

screen shot 2018-09-19 at 17 45 42

screen shot 2018-09-19 at 17 45 57 screen shot 2018-09-19 at 17 46 11 screen shot 2018-09-19 at 17 46 33 screen shot 2018-09-19 at 17 46 46

Same in Firefox and Safari.

I think it contributes to the problem being discussed in this issue and creates another one: enormous canvases and, hence, poor performance.

On Win10 devicePixelRation seems to behave correctly: it basically the same as system scaling (i.e. 1.25 for 125%).

All of this, of course, assuming 100% page scale.

kenrussell commented 5 years ago

Linking to #587 to make it easier to find these two issues from each other.

JohnRDOrazio commented 4 years ago

I have been having quite a time trying to get pixel perfect straight lines on a canvas element. I have tried pretty much all of the solutions proposed, starting from the one on the mozilla website then going through a number of those proposed here. I'm still not sure what I'm missing, maybe I missed or didn't understand a solution here. I'm trying to draw a ruler (like a word processor ruler) with a triangle that represents current indentation. A paragraph of text below this ruler should have the left indent of the paragraph match the position of the triangle on the ruler. I have succeeded in doing so, but not all of the vertical lines on the ruler are perfectly sharp, some are slightly thicker than others. Here is my test sandbox for this case. The only solution that worked very nicely (as regards sharpness) on my screen (which has 1.25 devicePixelRatio) does not however work (as regards ruler size and paragraph alignment with the ruler) on screens of different dPR. The solution that works (as regards ruler size and paragraph alignment with the ruler) for all screens I have tested on does not work (as regards line sharpness) on any of the screens I have tested on. Any ideas how I can get the vertical lines to be perfectly sharp? I could certainly live with them not being perfect, but I believe in the end it makes for a better user experience when generated graphic ui interfaces are pixel perfect.

In my initial quest I had naively set out to make a ruler with exact real life unit size, until researching I found that this is currently impossible (and considered not important for web standards) so I gave up on trying to get the ruler to match real life unit inches or centimeters (since this UI is going to be part of an add-on for Google Docs, I even tried measuring the ruler at the top of a Google Docs document and in fact it does not correpond to real unit inches). And so I gave the title "the CSS inch ruler", I have abandoned attempting a real unit inch ruler and have accepted to create a "CSS unit inch" ruler. But my main priority now, other than keeping the paragraph and the ruler in sync, is that of getting pixel sharp lines...

JohnRDOrazio commented 4 years ago

Choosing "INCHES" and "Greater Draw Precision (on my 1.25 dPR monitor)" from the above mentioned test gives me this result: image But the soluton only works on my 1.25 dPR monitor, and only when drawing "INCHES".

Choosing the "Recommended Draw" (solution from the Mozilla website) makes the paragraph line up nicely with the ruler on all displays but gives me this kind of vertical lines: image

I have tried translating the context by 0.5, I've tried translating by dPR, I've tried rounding the x position of the line that I need to draw, nothing seems to work. Some lines are sharp, others aren't. (I've only been testing on Chrome btw.) I have tried the 3 step process mentioned towards the end of this comment, still didn't work with and without rounding, with and without translating...

I haven't tried getBoundingClientRect as mentioned in this comment, I'm not sure how I would implement it, if that could be a solution and anyone can help I would be grateful. Feel free to touch up the code on the jsbin sandbox test.

ghost commented 4 years ago

I can also confirm it is impossible to create a pixel perfect WebGL image, except for anecdotal situations for certain combinations of devicePixelRatio, css canvas size and browser which happen to work. Sometimes. Generally it cannot be done, because the reasons user "juj" mentions.

I personally tried forcing my CSS and Canvas elements to be always multiples of 240 which is divisible by all denominators of all possible zoom level and devicePixelRatio combinations. That should have in theory guaranteed one gets an exact integer after the multiplication of size and ratio. I hoped that would ensure the DOM engine's internal math and my math would agree and no rescaling+filtering would ever happen, at least if CSS sizes were integers. But I failed, probably because browsers use a floating point number to represent devicePixelRatio (instead of a ratio). That means that no choice of floor(), round() or ceil() in our js code will consistently reproduce the DOM's computations. Consequently, ugly filtering cannot be avoided nor predicted across browsers and devices.

Knowing how the CSS standard works... the only true solution I can see is to get WebGL to accepted an extra attribute during getContext() that instructs the WebGL-to-DOM buffer blit to be done without scaling, just with clipping (if canvas bigger than css size) or filling with black bands (if canvas smaller than css size). Something like { resize: false }. Please note that "image-rendering: pixelated;" is not what we need, since that just sets point sampling during rescaling. We really want no-scaling.

Now with { resize: false } in place, and provided we can still approximately reserve our canvas size to match the CSS size x devicePixelRatio, then the pixel gap or clamp would be of at most one row/column. Which I'd totally take in exchange of having pixel accurate rendering, which is important for many applications. To recap, the code would be just:

let gl = canvas.getContext( "webgl2", {scale:false, alpha: ... canvas.width = Math.floor( canvas.offsetWidth() * (window.devicePixelRatio||1) );

If you WebGL guys have any saying on the WebGPU standard, I'd speak to them to ensure they DO implement something like {scale: false}. WebGL is usually used (but not always like in my case) for rendering 3D objects, and in that case a slightly blurry edge doesn't matter much. But WebGPU and its compute capabilities is probably going to used for simulations and data analysis where the actual pixel colors need to be exact to be meaningful. I'd argue that even in WebGL it's important to have pixel exact rendering (again, in my application I needed it), if only because it would allow browsers to blit to the screen with a simple copy instead of a blinear/bicubic filter and hopefully safe some cycles/battery?

kdashg commented 4 years ago

I have a version of the ruler demo hacked up enough to show proof-of-concept 1:1 pixel rendering in most cases (dpr=1.7647058823529411 on this machine), though not stitched back together like the original: https://jsbin.com/qosozowuma/edit?html,js,output

This is based on my anti-moire work from #587, which has a demo at https://jdashg.github.io/misc/webgl/device-pixel-presnap.html .

The key as before is that you have to let CSS layout a dummy element of your target size. Then, knowing that pixel snapping is going to occur, pre-snap to where you infer the device pixels are going to be based on devicePixelRatio and the position within the window:

  1. Create a dummy element of approximately the right size with CSS
  2. Use getBoundingClientRect() to get the bounding rect in CSS pixels
  3. Convert CSS pixels to device pixels
  4. Snap/round the device pixels to the device pixel grid
  5. Convert the snapped/rounded device pixel rect back to a CSS pixel rect
  6. Set the canvas.width/height to the device pixel rect width/height (always integer!)
  7. Set the canvas.style.width/height to the CSS pixel rect width/height (after presnapping usually non-integer!)

There was a fairly recent addition a few months ago to the CSS spec to add a ResizeObserver type that will just give you the size of an element in device pixels: https://drafts.csswg.org/resize-observer/#dom-resizeobserverboxoptions-device-pixel-content-box There's more background and info on this API here: https://github.com/w3c/csswg-drafts/issues/3554

However, there is not widespread support for this API yet. As such, the presnapping I described above is, as far as I'm aware, current best practice for portable 1:1 pixel content.

These are touched on in the WebGL Best Practices article on MDN: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices#devicePixelRatio_and_high-dpi_rendering

I believe this issue is really only open because https://www.khronos.org/webgl/wiki/HandlingHighDPI hasn't been updated accordingly yet.

587 is the better issue for "it would be nice if this were easier and/or more reliable".

greggman commented 4 years ago

I'm not sure how to go about understanding what the demo is trying to show. Things don't align. The green background doesn't fill the red outline. If I size the window larger things jump around. If I click the buttons I get different drawings piled on top of each other.

Ideally I have multiple examples of canvases, each being responsive, and each drawing correctly?

For example:

https://jsfiddle.net/greggman/wtcxj8n3/

The example above tries to use ResizeObseverer with devicePixelContextBox and doesn't try to do anything special otherwise so it doesn't really work but it does show examples of what's supposed to work and a few usecases ?

devicePixelContextBox appears to be implemented in Chrome Canary 84.0.4137.2 and it seems to work

Here was an attempt at using the solution in the JSBIN

https://jsfiddle.net/greggman/ymubztdg/

But certain elements are not stretching correctly

kdashg commented 4 years ago

We need to observe the parentNodes, not the canvases: https://jsfiddle.net/4mnfosa9/1/

kainino0x commented 4 years ago

If you WebGL guys have any saying on the WebGPU standard, I'd speak to them to ensure they DO implement something like {scale: false}. WebGL is usually used (...)

Any change to support this ought to be orthogonal to WebGL or WebGPU anyway, as it's a matter of canvas sizing which is mostly separate from the rendering APIs. In WebGPU we'll be implicitly relying on solutions figured out for WebGL and 2D canvas.

I think it's equally important for WebGL, as it is used for hardware acceleration in a lot of non-3D cases (pixi, figma, CAD, etc.)

greggman commented 4 years ago

The code above using jdashg's code (now that jdashg found my bug) seems to work

https://jsfiddle.net/greggman/ymubztdg/

mobile friendly version here

https://codepen.io/greggman/full/WNQMgOG

The example is using canvas 2D but it would work just as well with WebGL or WebGPU. It tries to use devicePixelBoxSize and falls back to jdashg's solution otherwise. Works in Chrome 84 and current Firefox.

You do need to structure your HTML specifically to support jdashg's solution but it does seem to work.

kdashg commented 4 years ago

Yeah my device-pixel-presnap.html is pretty reliable because it runs in RAF, which is useful for a lot of content at least. Observer-based approaches either need zoom and scroll offset callbacks, or to run in RAF and only redraw if the device rect changed size. (probably simpler than observing All The Things)

greggman commented 4 years ago

I use rAF solutions in other places as well but then I waste the user's CPU and battery even when I don't need to so yes, rAF is easier but it's not nice for the user if your diagrams are mostly static except when interacted with or when resized. I notice because my fan comes on and then I go track it down to some page running a rAF that's unneeded.

It's sad to me rAF wasn't made per element so that if that element is off the screen the rAF wouldn't fire. Even Flash, back in the day knew not to redraw if its embed was scrolled offscreen. The rAF spec, instead of the default being what's best for the user, it's instead the default is what's worst and then it's left for every web dev on the planet to somehow get their code correct if they want to be nice.

photopea commented 4 years ago

Hi guys, I think there is an easy way to draw pixel-perfect raster images on any device. I made a demo, which contains a checkerboard of black / white pixels. If you use a magnifying glass, you should see a perfect (in terms of screen pixels) checkerboard on any device at any devicePixelRatio.

https://jsfiddle.net/1ch7n6wm/

The solution is simple. Just take any number S of hardware pixels you want to use, set the canvas width to S, and CSS width to S / devicePixelRatio .

In your solutions above, you want the CSS size to be an integer, which is crazy and I have no idea why you want it. The only way to display a 1x1 pixel canvas as 1 screen pixel at devicePixelRatio=1.37134 is to set its CSS size to 0.7292137617221. Just use a simple division.

greggman commented 4 years ago

It would be nice if you could leave a useful example that handles resize and handles the canvas being sized by CSS

Any solution needs to cover things like

#someCanvas { 
  width: 50%;
  height: 12%;
}

or

<style>
   .stuff {
     display: flex;
   }
   .stuff>* {
      flex: 1 1 auto;
      border: 1px solid black;
    }
    canvas {
       width: 100%;
       height: 100%;
    }
</style>
<div class="stuff">
   <div>left</div>
   <div><canvas></canvas></div>
   <div>right</div>
 </div>

Setting a canvas's size to specific pixels is an anti-pattern unless it's a diagram in the middle of some paragraph. Most canvases are stretched by css fit some portion of their container

Ideally take this example (https://jsfiddle.net/greggman/ymubztdg) and replace the code with your solution so that it behaves the same.

photopea commented 4 years ago

My function shows you, how to render S logical pixels precisely as S screen pixel.

The way you obtain a number S is up to you. You can listen to the "resize" event with Javascript, to make S dependent on a screen size.

There is no way to avoid Javascript code and have pure CSS. You can not change the logical pixel resolution of a canvas (canvas.width, canvas.height) with CSS. Even if you could, you would still have to run some JS to generate a new raster image with a different number of pixels.

greggman commented 4 years ago

I'm not asking for a solution with no JavaScript. I'm asking for a solution where I let the browser choose the display size of the canvas and then respond to that, making the canvas's drawingbuffer resolution match.

Your solution doesn't seem to allow that since it's setting the size of the canvas in CSS pixels which means the browser itself can no longer stretch it as it sees fit. That basically means throwing away all of the browser's layout features. That's not really a solution.

photopea commented 4 years ago

First of all, if a browser resizes some element automatically, according to some CSS rules, the same resizing could be achieved by JS, which is executed on a "resize" event.

The idea of letting the browser resize a canvas, and then, adjsuting logical width / height (to keep a pixel-perfect image), makes no sense to me. Many CSS sizes, which the browser sets to the canvas, do not correspond to integer pixel sizes, so you would have to change that CSS size anyway (to avoid resampling). That is why you should always start with a number of logical = physical pixels you want to achieve.

greggman commented 4 years ago

The idea of letting the browser resize a canvas, and then, adjsuting logical width / height (to keep a pixel-perfect image), makes no sense to me.

The browser adjusts the size of all elements. Why should canvas be special?

Many CSS sizes, which the browser sets to the canvas, do not correspond to integer pixel sizes, so you would have to change that CSS size anyway (to avoid resampling). That is why you should always start with a number of logical = physical pixels you want to achieve.

This is just moving the problem. In order to know what size you want the canvas to be you have to find out from the browser what size the space is it made available. That's back to the same issue that the original solution solves, finding out from the browser how many device pixels are in a given space.

Maybe your solution solves this too but if it does I don't see how which I why I suggested you modify this example (https://jsfiddle.net/greggman/ymubztdg) to be solved by your solution.

kenrussell commented 4 years ago

@photopea I'm afraid your example doesn't solve the problem - it shows a Moiré pattern in Chrome on a Retina MacBook Pro:

Screen Shot 2020-06-18 at 8 07 44 PM

The way to conclusively solve this is to observe device-pixel-content-box using ResizeObserver. https://github.com/kenrussell/device-pixel-content-box-tests/ has a simple example. Please see whether it behaves better. This is a recent addition to the specification and isn't yet supported in all browsers - it's present in Chrome Canary and Dev now.

kenrussell commented 4 years ago

Note also that Chrome's DevRel team is working on writing more and better examples of how to use this API.

photopea commented 4 years ago

@kenrussell Did you zoom the page after it was loaded? If yes, you need to reload the webpage, to let the code run again, to adapt to a new devicePixelRatio .

kenrussell commented 4 years ago

@photopea no - https://jsfiddle.net/1ch7n6wm/ has the moire effect upon initial page load, with browser zoom 100%, even with the simple case of window.devicePixelRatio = 2.

Especially on mobile devices which tend to have strange, non-rational devicePixelRatio values, the browser's snapping of DOM elements to device pixel boundaries means that it's not possible for the application to guess exactly how many hardware pixels are going to be covered when setting the CSS pixel to a fractional value.

See also: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices#Canvas_and_WebGL-Related

and specifically "devicePixelRatio and high-dpi rendering". The pre-snap example still generates moire effects on my machine - the only reliable way to get this information, finally specified, is observing device-pixel-content-box.

photopea commented 4 years ago

@kenrussell In your screenshot, the width of a rendered canvas is exactly 1 pixel larger than it should be (318 instead of 317). I am confident to say, that it is a bug in your browser. Are you using the latest Safari? If not, could you update it?

greggman commented 4 years ago

They aren't bugs in the browser. A major point of this thread is that what the browsers do is not specified hence the need for an API to ask the browser what it did. Even jdashg's solution is probably just luck of figuring out what current browsers do.

Here's a screenshot from My mac with display resolution set to default

Top Left = Safari Tech Preview: Release 108 (Safari 13.2, WebKit 15610.1.16.3) Bottom Left = Safari: Version 13.1.1 (15609.2.9.1.2) Top Right = Firefox 77.0.1 (64-bit) Bottom Right = Chrome 83.0.4103.106 (Official Build) (64-bit)

Screen Shot 2020-06-22 at 10 31 30
photopea commented 4 years ago

@greggman In your results, three out of four are wrong by 1 pixel, only one is correct.

The devicePixelRatio (dpr) value is defined as the ratio between one CSS pixel and one hardware pixel. So when you set the CSS size to X/dpr, it should be clear to every browser, that a logical pixel should be rendered with one hardware pixel.

So it is either the bug in a browser, or the bug in Mac OS (the same version of Chrome renders a correct result in Windows in my case).

greggman commented 4 years ago

So it is either the bug in a browser or the bug in Mac OS

It's a bug neither in the browser nor the OS. It's the way most browsers run (Chrome, Edge, Safari, Opera). Your understanding how of it works or how it should work doesn't match how it actually works.

photopea commented 4 years ago

@greggman I disagree with you. The web standards are the same and browsers should follow them.

Even the fact, that the same thing looks differently in two browsers (on the same device and resolution), says, that one of them should be incorrect.

I think that your idea of "fixing" means replacing the standard, which was implemented incorrectly, with a new standard, which you hope will be impmemented correctly.

greggman commented 4 years ago

From above

Talking with @junov the behavior of the browsers' compositors as they snap DOM elements to device pixels is not specified. It's basically luck whether it's possible to match the compositor's layout algorithm

photopea commented 4 years ago

I think half of people in this discussion are mixing the specification and the implementation. Web developers should not care about any "canvas backing store".

The web content is vector by definition, and it is rasterized as a vector content. If a black canvas on a white backgorund has a width of 10.5 CSS pixels and the devicePixelRatio is 1, the right edge of the canvas should be rendered on a screen with grey pixels (as just half of each pixel is covered by a canvas). No snapping should occur at all (and we should not discuss what way of snapping is correct).

kenrussell commented 4 years ago

@photopea Please be respectful to others.

Gregg is correct. This area of HTML - how and whether fractional CSS pixels are snapped to device pixels - is underspecified, and until ResizeObserver's device-pixel-border-box was added, there was no way for a web application to measure how many device pixels were covered by a given element. See this six-year-old Firefox bug with a comment from one of Firefox's Principal Engineers, and member of the W3C TAG, which clearly indicates that Firefox snaps the edges of elements to device pixel boundaries.

https://jsfiddle.net/gfxprogrammer/u1wqfn2e is an attempt to do the precise measurement on browsers which support it. However, I'm still seeing Moiré patterns in Chrome on macOS, and have just filed http://crbug.com/1098574 to ask for more investigation.

We still need to update https://www.khronos.org/webgl/wiki/HandlingHighDPI with the latest recommendations; then this issue will be closed.