I am using the HTML5 canvas API to draw a tile map for a pixel art game. The rendered tile map is comprised of many smaller images that are cut out of a single source image called a tile sheet. I am using drawImage(src_img, sx, sy, sw, sh, dx, dy, dw, dh)
to cut the individual tiles out of the source image and draw them onto the destination canvas. I am using setTransform(sx, 0, 0, sy, tx, ty)
to apply scale and translation to the final rendered image.
The color "bleeding" issue I need to fix is caused by the sampler, which uses interpolation to blend colors during scale operations in order to make things not look pixelated. This is great for scaling digital photographs, but not for pixel art. While this doesn't do much visual damage to the centers of the tiles, the sampler is blending colors along the edges of adjacent tiles in the source image which creates unexpected colors in the rendered tile map. Instead of only using colors that fall within the source rectangle passed to drawImage
, the sampler blends in colors from just outside of its boundaries causing what appear to be gaps between the tiles.
Below is my tile sheet's source image. Its actual size is 24x24 pixels, but I scaled it up to 96x96 pixels in GIMP so you could see it. I used the "Interpolation: None" setting on GIMP's scaling tool. As you can see there are no gaps or blurred borders around the individual tiles because the sampler did not interpolate the colors. The canvas API's sampler apparently does interpolate colors even when imageSmoothingEnabled
is set to false
.
Below is a section of the rendered tile map with imageSmoothingEnabled
set to true
. The left arrow points to some red bleeding into the bottom of the gray tile. This is because the red tile is directly below the gray tile in the tile sheet. The sampler is blending the red into the bottom edge of the gray tile.
The arrow on the right points to the right edge of the green tile. As you can see, no color is bleeding into it. This is because there is nothing to the right of the green tile in the source image and therefore nothing for the sampler to blend.
Below is the rendered tile map with imageSmoothingEnabled
set to false
. Depending on the scale and translation, texture bleeding still occurs. The left arrow is pointing to red bleeding in from the red tile in the source image. The visual damage is reduced, but still present.
The right arrow points to an issue with the far right green tile, which has a thin gray line bleeding in from the gray tile in the source image, which is to the left of the green tile.
The two images above were screen captured from Edge. Chrome and Firefox do a better job of hiding the bleeding. Edge seems to bleed on all sides, but Chrome and Firefox seem to only bleed on the right and bottom sides of the source rectangle.
If anyone knows how to fix this please let me know. People ask about this problem in a lot of forums and get work around answers like:
- Pad your source tiles with border color so the sampler blends in the same color along the edges.
- Put your source tiles in individual files so the sampler has nothing to sample past the borders.
- Draw everything to an unscaled buffer canvas and then scale the buffer, ensuring that the sampler is blending in colors from adjacent tiles that are part of the final image, mitigating the visual damage.
- Draw everything to the unscaled canvas and then scale it using CSS using
image-rendering:pixelated
, which basically works the same as the previous work around.
I would like to avoid work arounds, however if you know of another one, please post it. I want to know if there is a way to turn off sampling or interpolation or if there is any other way to stop texture bleeding that isn't one of the work arounds I listed.
Here is a fiddle showcasing the issue: https://jsfiddle.net/0rv1upjf/
You can see the same example on my Github Pages page: https://frankpoth.info/pages/javascript-projects/content/texture-bleeding/texture-bleeding.html
Update:
The problem arose due to floating point numbers being used when plotting pixels. The solution is to avoid floats and only draw on integers. Unfortunately, this means setTransform cannot be used efficiently because scaling generally results in floats, but I still managed to keep a good bit of math out of the tile rendering loop. Here's the code:
function drawRounded(source_image, context, scale) {
var offset_x = -OFFSET.x * scale + context.canvas.width * 0.5;
var offset_y = -OFFSET.y * scale + context.canvas.height * 0.5;
var map_height = (MAP_HEIGHT * scale)|0; // Similar to Math.trunc(MAP_HEIGHT * scale);
var map_width = (MAP_WIDTH * scale)|0;
var tile_size = TILE_SIZE * scale;
var rendered_tile_size = (tile_size + 1)|0; // Similar to Math.ceil(tile_size);
var map_index = 0; // Track the tile index in the map. This increases once per draw loop.
/* Loop through all tile positions in actual coordinate space so no additional calculations based on grid index are needed. */
for (var y = 0; y < map_height; y += tile_size) { // y first so we draw rows from top to bottom
for (var x = 0; x < map_width; x += tile_size) {
var frame = FRAMES[MAP[map_index]]; // The frame is the source location of the tile in the source_image.
// We have to keep the dx, dy truncation inside the loop to ensure the highest level of accuracy possible.
context.drawImage(source_image, frame.x, frame.y, TILE_SIZE, TILE_SIZE, (offset_x + x)|0, (offset_y + y)|0, rendered_tile_size, rendered_tile_size);
map_index ++;
}
}
}
I'm using Bitwise OR or the | operator to do my rounding. Bitwise Or returns a 1 in each bit position for which the corresponding bits of either or both operands are 1s. Bitwise operations will convert a float to an int. Using 0 as the right operand will match all the bits in the left operand and truncate the decimals. The downside to this is it only supports 32 bits, but I doubt I'll ever need more than 32 bits for my tile positions.
For example:
-10.5 | 0 == -10
10.1 | 0 == 10
10.5 | 0 == 10
In binary:
1010 | 0000 == 1010
devicePixelRatio
(when page not zoomed). Values > 1 indicate a retina or hiRes device. If so the blur is the result of the canvas being composited onto the page by the DOM and can not fixed using theCanvasRenderingContext2D
API. You need to tell the compositor not to smooth the canvas,. Use CSS ruleimage-rendering: pixelated;
for canvas. Some will advise that you up the resolution of the canvas DONT as this squares or more RAM use and render time. – Blindman67window.devicePixelRatio
and I got 1.5. I changed my laptop's display settings to scale the screen to 100% instead of 150% and then I got an output of 1. Even when DPR is 1, I'm still seeing textures bleed. Here's the modified fiddle: jsfiddle.net/89a36nrt I think this is an issue with the browser's nearest neighbor sampling implementation not clamping to the boundaries of the source rectangle, but rather to the boundaries of the source image. This allows it to pull in the unwanted pixels. – Frankimage-rendering:pixelated
set on the style of the canvas: jsfiddle.net/89a36nrt/1 Unfortunately, I still get the same texture bleeding.By the way, I recognize your handle. Your answers on similar topics have helped me a lot with transforms. – Frank