3

I recognize that Canvas drawImage is inexplicably offset by 1 Pixel is a very similar issue, but I was already applying the advice given in that question's answer before I even came across this problem.

I'm implementing a sprite sheet system for an HTML5-based game. Individual frames are defined simply:

frame = new AnimationFrame(img, x, y, w, h);

Inside the AnimationFrame constructor, all of the numeric parameters are truncated to integers.

Then when I go to draw it on the canvas, I use what should also be simple code:

context.drawImage(frame.img,
  frame.x,
  frame.y,
  frame.w,
  frame.h,
  this.position.x | 0,
  this.position.y | 0,
  frame.w,
  frame.h,
);

Unfortunately, I get different results in different browsers.

In Chrome on Mac and Firefox on Mac, slicing the sprite sheet this way causes the individual frames to be offset by half a pixel, causing the top edge of the character's head to be drawn too thin and the top of the next sprite down to peek into the bottom of this one.

I can accommodate this problem with frame.x - 0.5 and frame.y - 0.5 but if I do that then I get the opposite problem in Safari on Mac and iOS and in Firefox on Windows.

I don't particularly WANT to do a browser detect to decide how to nudge the coordinate system, so I'm looking for suggestions for a way to either (1) force the various browsers to behave the same way, or (2) detect the issue at page load time with a test so I can just store the pixel grid offset in a variable.

NB: I'm going for a chunky pixel aesthetic, so my canvas is scaled by a factor of 2. This works fine without blurring, but it makes the half-pixel issue more clearly visible. Without scaling, the edges of the sprite still get distorted, so that's not the problem.

Coda Highland
  • 125
  • 10
  • Can you share a live example where you experience this issue? – Kaiido Feb 27 '18 at 01:27
  • Certainly: https://jsfiddle.net/qxu9ogLn/15/ The left image looks right in Chrome on both Windows and Mac. The right image looks right in Mac Firefox and Mac Safari. – Coda Highland Feb 27 '18 at 03:27
  • 1
    No, don't play with transform like that, this is your failed attempt to create a workaround, but not the real issue you are having. You should draw at integer values if you want consistent results. For your root issue, in your position, I would dig in your Camera.js script. It does a lot of transforms, which might very well lead to rounding issues / bugs. You would probably be better always resetting your context transform at the beginning of your frame, and use the scale in drawImage call directly. – Kaiido Feb 27 '18 at 03:46
  • Thanks for digging into it, although I'm very explicitly NOT doing that with the context transform because the performance impact is ridiculous. – Coda Highland Feb 27 '18 at 13:19

2 Answers2

4

Sprites can not share edges!

What you are seeing is normal. It is not only the browsers that will give different results, but different hardware and device settings will also show this problem to different degrees.

The problem is in the hardware and these artifacts may appear even when you set all your render values to integers.

Why is this happening?

The reason is because you are trying drawing on two side of the same line. The way GPU's samples images means that there will always be a little bit of error. You can never get rid of it. If you draw at location y = 16 a little bit of pixel 15 will bleed in. If you are using nearest neighbor, a little bit no matter how small equate to a whole display pixel.

The image shows your original sprite sheet. The top sprite's bottom is where the next sprite sprite starts. They share the boundary represented by the red line. A shared boundary will always bleed across.

enter image description here

This is not a browser bug, it is inherent in the hardware. You can not fix it with half offsets. There is only one way to solve the problem.

Simple solution

The solution is very simple. No two sprites may share an edge, you need a 1 pixel transparent space between consecutive sprites.

Image shows sprites on left share edge and bleed across the line. Sprites on right fixed with 1 pixel transparent space.

enter image description here

Eg

If you have sprite 16, by 16 then the coords you are using

// Format x,y,w,h
0,0,16,16    // right edge
16,0,16,16   // touches left edge. BAD!
... 
// next row
0,16,16,16   // Top touch sprite above bottom
16,16,16,16  // Left edge touches right edge. BAD!

Add a single pixel between all, they can still touch the image edge

0,0,16,16    // spr 0
17,0,16,16   // spr 1  No shared edge between them
... 
// next row
0,17,16,16   // spr 8  no shared edge above
17,17,16,16  // spr 9

In the render function the source coordinates should always be floored. However the transform, and the destination coordinates can be floats.

ctx.drawImage(image,int,int,int,int,float,float,float,float);

The next image is your sprite sheet correctly spaced. Sprites are still 16 by 16 but they are spaced 17 pixels apart so no two sprites share a boundary.

enter image description here

To draw a sprite by index from top left to right and same for each row down.

var posx,posy; // location of sprite on display canvas
var sprCols = 6; // number of columns
var sprWidth = 16; 
var sprHeight = 16; 
var sprIndex = ?  // index of sprite 0 top left
var x = (sprIndex % sprCols) * (sprWidth + 1);
var y = (sprIndex / sprCols | 0) * (sprHeight + 1);
ctx.drawImage(spriteSheet, x, y, sprWidth, sprHeight, posx, posy, sprWidth, sprHeight);

Back to game creation.

Now you can forget that crazy half pixel stuff, its just nuts, as it does not work. This is how how its done, and its been done this way since the first GPU's hit the market in the mid 1990's.

Note All images sourced from OP's fiddle

Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • If it was an hardware issue how come different browsers have different results on the same device and the same browser have the same output on different devixes? – Kaiido Feb 28 '18 at 15:02
  • 1
    Also adding margin wouldn't fix wrong neirest-neighbor bug like bleeding eye in this case, and you'll end up with fuzzy drawings instead of pixel art. – Kaiido Feb 28 '18 at 15:09
  • @Kaiido Same device does not mean same precision qualifiers. To get performance you drop the sampler precision, trade quality for performance. Its just a sampler artifact. – Blindman67 Feb 28 '18 at 15:54
  • 1
    This just passes the buck. Yes, it avoids sprite edge bleeding, but at the cost of sacrificing pixel accuracy -- the edge of the sprite could vary by as much as a whole pixel from the bounding box that you expected. – Coda Highland Feb 28 '18 at 18:03
  • Also, it's only relatively recently that sprites have used GPU rendering at all, so it's not exactly accurate to say this has gone on since the 1990s. 2D graphics have mostly been software rendering, specifically BECAUSE GPUs aren't designed for pixel-precise work. And it's not like software rendering for this stuff is expensive. It's only been recently (post-2010) that doing sprites on the GPU has become especially common -- hardware-accelerated HTML5 2D canvas was added to Chrome in 2012. – Coda Highland Feb 28 '18 at 18:07
  • 1
    Finally, texture bleeding isn't inevitable. When you're applying textures in 3D, you don't see bleeding edges around textures there. I've done similar sprite work in Unity, and fixing this problem there is a matter of properly configuring the input texture's interpolation mode. While the half-pixel hack is admittedly an edge case and I shouldn't expect consistent results when the SOURCE coordinates are non-integer, it's pretty clearly a browser bug that the source image is being incorrectly cropped when configuring the texture before painting it at non-integer destination coordinates. – Coda Highland Feb 28 '18 at 18:13
  • @CodaHighland "...but at the cost of sacrificing pixel accuracy..." Ok?? if you say so?? ....ah... whatever mate I was just trying to help you, but what would I know compared to you. Such an impressive understanding of computer graphics and its history.. and you used Unity, wow awesome ..best of luck, not that you need it, and Ill get back to doing what I do... – Blindman67 Mar 01 '18 at 06:33
  • I know you were trying to help, and I do appreciate the attempt. I'm not mad or anything. I'm just documenting the facts for future developers that come through and see the question. – Coda Highland Mar 02 '18 at 00:52
  • Has there been any progress on this? I'm having the same problem when scaling my tile map. There's lots of texture bleeding that results in annoying lines between my rendered tiles. I could go with a transparent border around my individual tiles, but I feel like the ideal solution would be to somehow stop the scaling algorithm from sampling outside of the source rectangle. If my source rect is (0, 0, 16, 16) I shouldn't be getting a color value from x = 16 in my rendered image. – Frank Feb 21 '20 at 00:15
  • No, rbga introduces this inaccuracy. Try to map out correct pixels and don't work with ones that can't be worked with. – Tae Soo Kim Nov 29 '20 at 14:20
1

I can't tell for IE, but at least for Safari, this sounds like a bug in their nearest-neighbor algorithm when the transformation matrix translate is set to exactly n.5.

onload = function() {

  var ctx = document.querySelector('canvas').getContext('2d');
  ctx.imageSmoothingEnabled = false;
  var img = document.querySelector('#hero');

  ctx.setTransform(2, 0, 0, 2, 99.49, 99.49);
  ctx.drawImage(img, 32, 32, 16, 16, 0, 0, 16, 16);

  ctx.setTransform(2, 0, 0, 2, 99.5, 99.5);
  ctx.drawImage(img, 32, 32, 16, 16, 16, 0, 16, 16);

  ctx.setTransform(2, 0, 0, 2, 99.51, 99.51);
  ctx.drawImage(img, 32, 32, 16, 16, 32, 0, 16, 16);

};
<img src='http://xmpps.greenmaw.com/~coda/html52d/hero.png' id='hero' style='display:none' />
<canvas width='200' height='200'></canvas>

Result in Safari 11.0.3

Result of previous snippet: the center image contains a bit of the upper sprite

You may want to let them know about it from their bug-tracker.

The workaround would be to make sure that you never lay on floating coordinates, even in your transformation matrix.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Let me give that a try and I'll get back to you. – Coda Highland Feb 27 '18 at 13:55
  • Using `|0` to truncate the `e` and `f` parameters to `setTransform` as well as removing the half-pixel adjustment factors from the drawing functions worked for sprites but it caused line drawing to become blurry -- which is the reason I was using the half-pixel adjustments in the first place. It's a little disappointing that I can't have both sprites and lines come out pixel-clean without having to apply a fudge factor somewhere, but I'd rather have CORRECT sprites and fudging on the lines than to have INCORRECT sprites but unfudged numbers. – Coda Highland Feb 27 '18 at 22:14
  • 1/2 @CodaHighland yes the O.5 translate trick for troke is actually only useful for strokes. All other methods will suffer from it. So the best is to understand why this hack works, and to handle it only for your strokes. So I guess you've got the idea: strokes extend from both sides of your line, so if your line is 1px wide and set to perfect pixels boundaries, you'll get an antialiased line extending on two pixels wide. – Kaiido Feb 28 '18 at 00:28
  • 2/2 I didn't saw where you draw these lines, nor am I sure what's your scaling factor, but if e.g you set your lineWidth to 1 but scale the matrix by 2, then you don't even need this hack since your lines will be two pixels wide and will be drawn with antialiasing. And if you really want to draw a 1px wide stroke (e.g at scale(2) + lineWidth 0.5) then I think it's better to draw your line with the 0.5px offset, moreover for *pixel-art* graphics, you'll get less headache to fit it with other methods like fill or drawImage, or even for collision algos. – Kaiido Feb 28 '18 at 00:30
  • The lines are drawn in a different demo module (http://xmpps.greenmaw.com/~coda/html52d/?demo=gas) which delegates to the `commonRender` function in `index.html`. I always did understand why it works, and it's only this corner case of using `drawImage` sliced from inside a source image while using a half-integer translation transform that causes any trouble. One of the other workarounds I was considering was splitting out the sprite sheet into individual images at load time, because that works consistently even with the fractional translate. – Coda Highland Feb 28 '18 at 03:34
  • 1
    @CodaHighland but then you'll understand that every time you'll use an other drawing method than `stroke`, the neirest neighbor algo will have to kick in in order to place all the pixels at int boundaries. – Kaiido Feb 28 '18 at 03:41
  • Not a bug, normal GPU behaviour, with easy fix. Just need to ensure that sprites don't share edges with opaque pixels. – Blindman67 Feb 28 '18 at 14:47
  • @Blindman67 I could agree, but in this case it really looks like a bug. Look at how every pixel (e.g the eye) bleeds in the middle image (0.5 translate) while it doesn't for others which should be rounded anyway by iSE false. – Kaiido Feb 28 '18 at 14:58
  • 1
    @Blindman67 but I agree, the solution is easy: apply the 0.5 translate hack **only** for what it's supposed to workaround: strokes. – Kaiido Feb 28 '18 at 15:08