fserb / canvas2D

Update Canvas 2D API
Other
150 stars 25 forks source link

Integer only coordinate space mode. #29

Open gitspeaks opened 2 years ago

gitspeaks commented 2 years ago

I’m migrating a Windows GDI application to HTML using Canvas and I stumbled across the fact that canvas uses a fractional coordinate space which requires “moving” to the mid of a pixel in order to draw vertical and horizontal lines of width 1.

https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Applying_styles_and_colors

Unfortunately, I couldn’t find any article explaining the motivation behind the use of fractional units in the canvas but after reviewing numerous canvas code examples on the web I noticed that’s its pretty common to see people adding 0.5 when drawing lines, so while I assume there are probably good reasons and uses cases for using fractional pixels (I’m far from an expert on this), perhaps it is equally applicable to have an integer only pixel mode (like in GDI) for simple cases where it is possible to establish a one-to-one mapping between whole number points on the Cartesian system and 1x1 squares on the canvas grid.

Kaiido commented 2 years ago

Disclaimer, I know nothing about GDI.

What you describe applies only to strokes. The actual issue is that strokes in the Canvas2d API just like in many graphics APIs can only be "centered", i.e they'll spread from both inside and outside of the path.
This means that drawing a line from 10,10 to 20,10 with a 1px lineWidth would indeed fill a rectangle with coords 10,9.5 20,10.5.
But the coordinate system isn't to blame here, it's just how stroking works.
There could be a point to allow either inner or outer stroking, but this raises some issues as pointed by SVG specs where this was raised already: https://svgwg.org/specs/strokes/#SpecifyingStrokeAlignment.

gitspeaks commented 2 years ago

How do you know that this a matter of how “stroking” works ? I’m asking seriously. Are you aware of any authoritative reference defining “stroking”. I actually tried several searches and as I understand it "stroking" is about “materializing” the abstract line in a path and I can see how the choice of alignment comes into play when working with a wireframe you can actually see like in adobe illustrator and I’m not dismissing this. However, I’m not quite sure "alignment" is a “feature” of stroke. Like stroke may very well mean “Pen” so I disagree that the actual issue is "stroke" in fact it’s not an issue at all if one chooses to think about the canvas as Cartesian system with infinite points on either axis and stroke having alignment, but this does not necessarily exclude an additional approach where the canvas is viewed as what it is which is grid of pixels where each pixel can be identified by intersection of row, column. IMHO This one-to-one correspondence can very much simplifies things thus I suggested an additional mode of operation that is restricted to a whole number Cartesian system.

I think a side-by-side comparison shows it all:

Here is 50x50 grid cell using the canvas Api.

Note that for some reason the bottom right pixel is slightly gray in color

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.beginPath();

// should y be 0 or 0.5?
ctx.moveTo(0.5, 0);
ctx.lineTo(0.5, 49.5); 

ctx.moveTo(49.5, 0);
ctx.lineTo(49.5, 49.5); 

// should x be 0 or 0.5?
ctx.moveTo(0, 0.5);
ctx.lineTo(49.5, 0.5);

ctx.moveTo(0, 49.5);
ctx.lineTo(49.5, 49.5);

ctx.stroke();

Here is 50x50 grid cell using Gdi.

#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <windowsx.h>
#include <stdio.h>
#include <stdlib.h>

#define WINDOW_CLASS_NAME  "WINCLASS1"
#define WINDOW_W 1024
#define WINDOW_H 768

LRESULT CALLBACK WindowProc(
    HWND hwnd,
    UINT msg,
    WPARAM wparam,
    LPARAM lparam)
{
    PAINTSTRUCT ps;
    HDC hdc;

    switch (msg) {

    case WM_CREATE:
    {
        return 0;
    } break;

    case WM_PAINT:
    {
        hdc = BeginPaint(hwnd, &ps);

        HPEN pen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0));
        HPEN oldPen = (HPEN)SelectObject(hdc, pen);

        // LineTo draws from the current position up to but not including the position.
        MoveToEx(hdc, 0, 0, NULL);
        LineTo(hdc, 0, 50);

        MoveToEx(hdc, 49, 0, NULL);
        LineTo(hdc, 49, 50);

        MoveToEx(hdc, 0, 0, NULL);
        LineTo(hdc, 50, 0);

        MoveToEx(hdc, 0, 49, NULL);
        LineTo(hdc, 50, 49);

        DeleteObject(oldPen);
        DeleteObject(pen);
        ReleaseDC(hwnd, hdc);
        EndPaint(hwnd, &ps);
        return(0);

    } break;

    case WM_DESTROY:
    {
        PostQuitMessage(0);
        return 0;
    } break;

    default: break;

    }

    return (DefWindowProc(hwnd, msg, wparam, lparam));
}

int WINAPI WinMain(
    _In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPSTR lpCmdLine,
    _In_ int nCmdShow)
{
    WNDCLASSEX winclass;
    HWND hwnd;
    MSG msg;
    HDC hdc;

    int screenX = GetSystemMetrics(SM_CXSCREEN);
    int screenY = GetSystemMetrics(SM_CYSCREEN);

    winclass.cbSize = sizeof(WNDCLASSEX);
    winclass.style = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW, CS_VREDRAW;
    winclass.lpfnWndProc = WindowProc;
    winclass.cbClsExtra = 0;
    winclass.cbWndExtra = 0;
    winclass.hInstance = hInstance;
    winclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
    winclass.hCursor = LoadCursor(NULL, IDC_ARROW);
    winclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
    winclass.lpszMenuName = NULL;
    winclass.lpszClassName = WINDOW_CLASS_NAME;
    winclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);

    if (!RegisterClassEx(&winclass))
        return 0;

    if (!(hwnd = CreateWindowEx(
        NULL,
        WINDOW_CLASS_NAME,
        "Gdi Window",
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        (screenX - WINDOW_W) / 2,
        (screenY - WINDOW_H) / 2,
        WINDOW_W, WINDOW_H,
        NULL,
        NULL,
        hInstance,
        NULL
    )))
        return(0);

    while (GetMessage(&msg, NULL, 0, 0))
    {
        if (msg.message == WM_QUIT)
            break;

        TranslateMessage(&msg);
        DispatchMessage(&msg);

    } // end while

    return(msg.wParam);
}
Kaiido commented 2 years ago

Are you aware of any authoritative reference defining “stroking”.

https://html.spec.whatwg.org/multipage/canvas.html#trace-a-path

Create a new path that describes the edge of the areas that would be covered if a straight line of length equal to style's lineWidth was swept along each subpath in path while being kept at an angle such that the line is orthogonal to the path being swept, [...]


IMHO This one-to-one correspondence can very much simplifies things

You have this "one-to-one correspondence" with fill(). An horizontal or vertical line is just a rectangle with lineWidth height or width. If you want to trace an horizontal line from coords (10,10) (60,10) with 1px lineWidth, you can do ctx.fillRect(10, 10, 50, 1) and with the identity matrix transform, it will fill the bitmap as you wish.
Strokes are defined as overlapping over both sides of the path. That's how it is. Changing how the coordinate system works wouldn't solve that.

gitspeaks commented 2 years ago

I understand what you’re saying, and if “trace-a-path” == “stroke” then so be it. I’m not arguing with the the standard (although it would be clearer if they used the word “stroke” there). I’m not arguing about the definition of “stroke”. The only reason I alluded to it is because that's how you display a LineTo (not a rectangle of width 1). I’m not proposing to redefine what stroke means but rather add a mode/api without stroke, modeled after a discrete Cartesian grid where coordinates refer to pixels which are rectangles with width and height of unit length (not points).

Example:

const ctx = canvas.getContext('2d-discrete');

ctx.moveTo(0, 0);
ctx.lineTo(0, 49); 

ctx.moveTo(49, 0);
ctx.lineTo(49, 49); 

ctx.moveTo(0, 0);
ctx.lineTo(49, 0);

ctx.moveTo(0, 49);
ctx.lineTo(49, 49);

ctx.paint();

I’m not saying this is “better” but can be simpler to reason about when working with straight lines as apposed to thinking about the interval between points in a continuous Cartesian Grid when stroking lines. Like if I want to display a line - on the screen - from 0,0 to 0,9 then I think its more intuitive to think about drawing a line from the first pixel down 9 pixels as apposed to thinking about how filling an area between imaginary points map to display pixels.

Kaiido commented 2 years ago

And would that be simpler to reason about for the 99.9% of cases where paths aren't a grid on cartesian coordinates? I mean, what does this mean for a circle? For an oblique line? Etc.
Are you really proposing a new canvas context whose only purpose is to draw a 1px lineWidth grid?

(Also, the trace a path algorithm I linked to is part of the stroke-steps one).

gitspeaks commented 2 years ago

https://docs.microsoft.com/en-us/windows/win32/gdi/path-creation

gitspeaks commented 2 years ago

BTW, it is even suggested not use floating-point coordinates for performance reasons.

"Avoid floating-point coordinates and use integers instead"

https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#avoid_floating-point_coordinates_and_use_integers_instead

I assume this is not strictly about drawImage since I can clearly see that anti aliasing is applied when I draw a tilted line between whole coordinates.

greggman commented 5 days ago

I'm not sure this is entirely related but ... A use case I have. I get that it's niche.

I want to make ASCII graphs. I did this by making a Uint8Array(width * height) an a plot(x, y, value) function. I then plot in it with values from 0 to 255 (In my case 1,2,3) and then at the end, I translate that into ascii

function makeGraph(width, height) {
  const data = new Uint8Array(width * height);

  return {
    width,
    height,
    plot(x, y, c) {
      if (x >= 0 && x < width && y >= 0 && y < height) {
        const offset = (height - (y | 0) - 1) * width + (x | 0);
        data[offset] = c;
      }
    },
    toString(conversion, {border} = {border: false}) {
      const lines = [];
      const end = `+${''.padEnd(width, '-')}+`;
      const edge = border ? '|' : '';
      if (border) {
        lines.push(end);
      }
      for (let y = 0; y < height; ++y) {
        const offset = y * width;
        lines.push(`${edge}${[...data.subarray(offset, offset + width)].map(v => conversion[v]).join('')}${edge}`);
      }
      if (border) {
        lines.push(end);
      }
      return lines.join('\n');
    },
  };
}

I then use it like this

function graph(g, period, v) {
  for (let x = 0; x < g.width; ++x) {
    const u = x / (g.width - 1);
    const y = (Math.sin(u * period) * 0.5 + 0.5) * g.height;
    g.plot(x, y, v);
  }
}

const g = makeGraph(64, 32);
graph(g, Math.PI, 1);
graph(g, Math.PI * 2, 2);
graph(g, Math.PI * 3, 3);
graph(g, Math.PI * 4, 4);
log(g.toString(' abcd', {border: true}));

Which produces

+----------------------------------------------------------------+
|       dddcccbbbbbbb     aaaaaaaaaaaaadddd         cccc         |
|      d c dbbc      b aaa            d aaa        c    c        |
|     d c  b   c     ab                    da     c      c       |
|      c  b d   c  aa  b             d       aa  c        c      |
|    d   b       aa     b                   d  aa                |
|     c b    d aac       b          d           caa        c     |
|   dc b     aa   c       b                  d c   aa       c    |
|           a d            b                         a           |
|     b    a                       d                  a          |
|  dcb   aa        c        b                 d        aa    c   |
|       a      d             b                           a       |
|  cb  a            c             d          c            a   c  |
| db aa                       b                d           aa    |
| c a           d    c         b            c                a c |
| ba                             d                            a  |
|da                   c         b               d              ac|
|                d               b         c                    d|
|                               d                              b |
|                      c          b       c      d               |
|                 d                b                          bd |
|                       c      d         c                   b   |
|                                   b             d              |
|                  d     c           b  c                   b d  |
|                             d                            b     |
|                                     b            d             |
|                   d     c            c                  b  d   |
|                          c d        c b           d    b       |
|                    d                   b              b   d    |
|                           d        c    b          d b         |
|                     d      c      c      b          b    d     |
|                          d  c    c        b       bbd   d      |
|                      dddd    cccc          bbbbbbb   ddd       |
+----------------------------------------------------------------+

If I had a mode for the canvas2D API that didn't anti-alias so it only stored the exact colors I specified, then I could use the lineTo, arc, fill, and stroke to generate the data. As it is, because it anti-aliases, at best I'd have to make some kind of filter to try to guess what values in the canvas to turn into ascii.

gitspeaks mentioned "brushes". In Photoshop you can stroke with a "brush" or with a "pencil"

2024_11_12-12_14_22_Photoshop_87

It would be nice if you could do similar with the canvas 2d API.

To be clear, I'm not specifically asking to stroke with "pixels" like photoshop. i'm just saying there are use cases for not anti-aliasing. Photoshop has the ability to create a "path" and "stroke" it. Which seems analogous to the terms used by Canvas 2D. It has the option to stroke with some option that doesn't anti-alias. It would be nice to have the same ability in Canvas 2D.