Repository navigation
subPixel: snap positions once, the same way on every backend (target 20.12) #1765
Description
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
-
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: trueit does not move. The flip istranslate(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. -
The backends snap nested translations differently.
translate(10.6)thentranslate(-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. -
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
subPixeldoc already mentions it. -
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 fillRectat +0.5half-grey edges on both sides crisp, follows the GPU pixel-centre rule fillRect5.5 widea half-grey last column 5 columns, the half dropped On Canvas any fractional shape edge is anti-aliased even with
subPixel: false(softUIPanelborders, a soft edge moving along aProgressBaras 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'sfillRectalso disagrees with itself: a gradient fill goes throughdrawImageand snaps, a solid fill does not. -
Canvas
drawPatterntranslates with the raw 2D context, bypassing snapping. Harmless today (its one caller,ImageLayer, passes 0, 0 after a snappedtranslate), but a trap for any other caller. -
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
drawPatternthrough 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
fillRectat the same fractional position cover the same pixels; no half-grey shape edges on Canvas withsubPixel: 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).
Activity