Skip to content

subPixel: snap positions once, the same way on every backend (target 20.12) #1765

Open

Description

@obiot

With subPixel: false (the default), positions are snapped to whole pixels in several places that disagree with each other, and some drawing never snaps at all. Found while reviewing scrolling smoothness on the isometric dungeon example; each point below was measured on Canvas and WebGL.

Where snapping happens today

All of them truncate toward zero (~~x / x | 0):

Where What is truncated
drawImage (Canvas, WebGL, WebGPU) the destination dx, dy
translate() on Canvas each call's own argument
translate() on WebGL / WebGPU the accumulated transform, after every call
transform() / setTransform() the argument on Canvas, the accumulated result on WebGL / WebGPU
Text#draw the text's position

Problems

  1. Flipping a sprite moves it by one pixel, on every backend. A 13 px sprite anchored at its centre covers columns 14-26, and 13-25 when flipped, at every position tried (20, 20.25, 20.5, 20.75). With subPixel: true it does not move. The flip is translate(dx), scale(-1), translate(-dx), and truncating between the steps breaks the symmetry: a character with an odd-width sprite jumps a pixel each time it turns around.

  2. The backends snap nested translations differently. translate(10.6) then translate(-6.5), exact 4.1: Canvas lands at 4 (10 + -6), WebGL at 3 (10.6 to 10, then 3.5 to 3, so the two errors add up to more than a pixel). The same scene can place a sprite inside a container a pixel apart on Canvas and WebGL.

  3. Truncating toward zero is lopsided. -6.5 becomes -6 while 6.5 becomes 6, so values left of or above the origin round the other way (part of 1), and anything partly off the left or top edge of the screen sits a pixel off. The subPixel doc already mentions it.

  4. Shapes never read subPixel (fillRect, strokeRect, round rects, ellipses, lines, polygons). Drawn at x = 10:

    Canvas WebGL
    sprite at +0.5 snapped to 10 snapped to 10
    fillRect at +0.5 half-grey edges on both sides crisp, follows the GPU pixel-centre rule
    fillRect 5.5 wide a half-grey last column 5 columns, the half dropped

    On Canvas any fractional shape edge is anti-aliased even with subPixel: false (soft UIPanel borders, a soft edge moving along a ProgressBar as it fills). On WebGL shape edges follow the rasterizer while sprites truncate, so a shape and a sprite at the same fractional position can be a pixel apart. WebGL's fillRect also disagrees with itself: a gradient fill goes through drawImage and snaps, a solid fill does not.

  5. Canvas drawPattern translates with the raw 2D context, bypassing snapping. Harmless today (its one caller, ImageLayer, passes 0, 0 after a snapped translate), but a trap for any other caller.

  6. Pointer coordinates and culling use the camera's exact position while the frame is drawn at the snapped one: a click, or the edge culling, can be off by under a pixel.

Not problems: a zoomed camera snaps in world units (its zoom is in the projection), so zoom 2 moves in 2 px steps, which is what pixel art expects. Bitmap text, particles, nine-slice sprites, ImageLayer and the GPU tile layer all go through the snapping paths.

Proposed direction

  • snap once, at draw time, on the final screen position, rounding down, the same on every backend (instead of after every translate / transform);
  • flip about the snapped rectangle, so a flipped sprite covers the same pixels as an unflipped one;
  • snap shapes with the same rule;
  • route Canvas drawPattern through it.

This moves output by up to a pixel in existing subPixel: false games, so it needs before/after screenshots of every example.

Done when

  • tests on Canvas, WebGL and WebGPU: flipped and unflipped sprites cover the same pixels; nested translations land on the same pixel on every backend; a sprite and a fillRect at the same fractional position cover the same pixels; no half-grey shape edges on Canvas with subPixel: false;
  • each test fails with its fix reverted.

Related, already fixed for 20.11: the lighting's edge margin (#1764) and the camera follow deadzone's fractional edges (pending in the next batch).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions