Weird line bug when animating a falling sprite using JavaScript canvas [duplicate] - javascript
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
This is a rounding issue.
There was already that question about this issue experienced on Safari browser when the context is translated to exactly n.5, Edge an IE are even worse and always bleed one way or an other, Chrome for macOs bleeds on n.5 too, but only when drawing an <img>, <canvas> are fine.
Least to say, that's a buggy area.
I didn't check the specs to know exactly what they should do, but there is an easy workaround.
Compute yourself the transformation of your coordinates so you can control exactly how they'll get rounded and ensure crisp pixels.
// First calculate the scaled translations
const scaled_offset_left = -OFFSET.x * scale + context.canvas.width * 0.5;
const scaled_offset_top = -OFFSET.y * scale + context.canvas.height * 0.5;
// when drawing each tile
const dest_x = Math.floor( scaled_offset_left + (x * scale) );
const dest_y = Math.floor( scaled_offset_top + (y * scale) );
const dest_size = Math.ceil( TILE_SIZE * scale );
context.drawImage( source_image,
frame.x, frame.y, TILE_SIZE, TILE_SIZE,
dest_x, dest_y, dest_size, dest_size,
);
/* This is the tile map. Each value is a frame index in the FRAMES array. Each frame tells drawImage where to blit the source from */
const MAP = [
0, 0, 0, 1, 1, 1, 1, 2, 2, 2,
0, 1, 0, 1, 2, 2, 1, 2, 3, 2,
0, 0, 0, 1, 1, 1, 1, 2, 2, 2,
3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
3, 4, 3, 4, 5, 5, 4, 5, 6, 5,
3, 4, 3, 4, 5, 5, 4, 5, 6, 5,
3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8,
6, 7, 6, 7, 8, 8, 7, 8, 0, 8,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8
];
const TILE_SIZE = 8; // Each tile is 8x8 pixels
const MAP_HEIGHT = 80; // The map is 80 pixels tall by 80 pixels wide
const MAP_WIDTH = 80;
/* Each frame represents the source x, y coordinates of a tile in the source image. They are indexed according to the map values */
const FRAMES = [
{ x:0, y:0 }, // map value = 0
{ x:8, y:0 }, // map value = 1
{ x:16, y:0 }, // map value = 2
{ x:0, y:8 }, // etc.
{ x:8, y:8 },
{ x:16, y:8},
{ x:0, y:16},
{ x:8, y:16},
{ x:16, y:16}
];
/* These represent the state of the keyboard keys being used. false is up and true is down */
const KEYS = {
down: false,
left: false,
right: false,
scale_down: false, // the D key
scale_up: false, // the F key
up: false
}
/* This is the scroll offset. You can also think of it as the position of the red dot in the map. */
const OFFSET = {
x: MAP_WIDTH * 0.5,
y: MAP_HEIGHT * 0.5
}; // It starts out centered in the map.
const MAX_SCALE = 75; // Max scale is 75 times larger than the actual image size.
const MIN_SCALE = 0; // Texture bleeding seems to only occur on upscale, but min scale is 0 in case you want to try it.
var scale = 4.71; // some arbitrary number that will hopefully cause the issue in your browser
/* Get the canvas drawing context. */
var context = document.querySelector('canvas').getContext('2d', {
alpha: false,
desynchronized: true
});
/* The toggle button is the div */
var toggle = document.querySelector('div');
/* The source image is a 24x24 square with 9 tile images of various colors in it. */
var base_64_image_source = 'data:image/png;base64,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';
var source_image = new Image(); // This will be the source image
/* The keyboard event handler */
function keyDownUp(event) {
var state = event.type == 'keydown' ? true : false;
switch (event.keyCode) {
case 37:
KEYS.left = state;
break;
case 38:
KEYS.up = state;
break;
case 39:
KEYS.right = state;
break;
case 40:
KEYS.down = state;
break;
case 68:
KEYS.scale_down = state;
break;
case 70:
KEYS.scale_up = state;
}
}
/* This is the update and rendering loop. It handles input and draws the images. */
function loop() {
window.requestAnimationFrame(loop); // Perpetuate the loop
/* Prepare to move and scale the image with the keyboard input */
if (KEYS.left) OFFSET.x -= 0.5;
if (KEYS.right) OFFSET.x += 0.5;
if (KEYS.up) OFFSET.y -= 0.5;
if (KEYS.down) OFFSET.y += 0.5;
if (KEYS.scale_down) scale -= 0.5 * scale / MAX_SCALE;
if (KEYS.scale_up) scale += 0.5 * scale / MAX_SCALE;
/* Keep the scale size within a defined range */
if (scale > MAX_SCALE) scale = MAX_SCALE;
else if (scale < MIN_SCALE) scale = MIN_SCALE;
/* Clear the canvas to gray. */
context.setTransform(1, 0, 0, 1, 0, 0); // Set the transform back to the identity matrix
context.fillStyle = "#202830"; // Set the fill color to gray
context.fillRect(0, 0, context.canvas.width, context.canvas.height); // fill the entire canvas
/* [EDIT]
Don't set the transform, we will calculate it ourselves
// context.setTransform(scale, 0, 0, scale, -OFFSET.x * scale + context.canvas.width * 0.5, -OFFSET.y * scale + context.canvas.height * 0.5);
First step is calculating the scaled translation
*/
const scaled_offset_left = -OFFSET.x * scale + context.canvas.width * 0.5;
const scaled_offset_top = -OFFSET.y * scale + context.canvas.height * 0.5;
let 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 (let y = 0; y < MAP_HEIGHT; y += TILE_SIZE) { // y first so we draw rows from top to bottom
for (let x = 0; x < MAP_WIDTH; x += TILE_SIZE) {
const frame = FRAMES[MAP[map_index]]; // The frame is the source location of the tile in the source_image.
/* [EDIT]
We transform the coordinates ourselves
We can control a uniform rounding by using floor and ceil
*/
const dest_x = Math.floor( scaled_offset_left + (x * scale) );
const dest_y = Math.floor( scaled_offset_top + (y * scale) );
const dest_size = Math.ceil(TILE_SIZE * scale);
context.drawImage( source_image,
frame.x, frame.y, TILE_SIZE, TILE_SIZE,
dest_x, dest_y, dest_size, dest_size
);
map_index++;
}
}
/* Draw the red dot in the center of the screen. */
context.fillStyle = "#ff0000";
/* [EDIT]
Do the same kind of calculations for the "dot" if you don't want antialiasing
// const dot_x = Math.floor( scaled_offset_left + ((OFFSET.x - 0.5) * scale) );
// const dot_y = Math.floor( scaled_offset_top + ((OFFSET.y - 0.5) * scale) );
// const dot_size = Math.ceil( scale );
// context.fillRect( dot_x, dot_y, dot_size, dot_size ); // center on the dot
But if you do want antialiasing for the dot, then just set the transformation for this drawing
*/
context.setTransform(scale, 0, 0, scale, scaled_offset_left, scaled_offset_top);
context.fillRect( (OFFSET.x - 0.5), (OFFSET.y - 0.5), 1, 1 ); // center on the dot
var smoothing = context.imageSmoothingEnabled; // Get the current smoothing value because we are going to ignore it briefly.
/* Draw the source image in the top left corner for reference. */
context.setTransform(4, 0, 0, 4, 0, 0); // Zoom in on it so it's visible.
context.imageSmoothingEnabled = false; // Set smoothing to false so we get a crisp source image representation (the real source image is not scaled at all).
context.drawImage( source_image, 0, 0 );
context.imageSmoothingEnabled = smoothing; // Set smoothing back the way it was according to the toggle choice.
}
/* Turn image smoothing on and off when you press the toggle. */
function toggleSmoothing(event) {
context.imageSmoothingEnabled = !context.imageSmoothingEnabled;
if (context.imageSmoothingEnabled) toggle.innerText = 'Smoothing Enabled'; // Make sure the button has appropriate text in it.
else toggle.innerText = 'Smoothing Disabled';
}
/* The main loop will start after the source image is loaded to ensure there is something to draw. */
source_image.addEventListener('load', (event) => {
window.requestAnimationFrame(loop); // Start the loop
}, { once: true });
/* Add the toggle smoothing click handler to the div. */
toggle.addEventListener('click', toggleSmoothing);
/* Add keyboard input */
window.addEventListener('keydown', keyDownUp);
window.addEventListener('keyup', keyDownUp);
/* Resize the canvas. */
context.canvas.width = 480;
context.canvas.height = 480;
toggleSmoothing(); // Set imageSmoothingEnabled
/* Load the source image from the base64 string. */
source_image.setAttribute('src', base_64_image_source);
* {
box-sizing: border-box;
margin: 0;
overflow: hidden;
padding: 0;
user-select: none;
}
body,
html {
background-color: #202830;
color: #ffffff;
height: 100%;
width: 100%;
}
body {
align-items: center;
display: grid;
justify-items: center;
}
p {
max-width: 640px;
}
div {
border: #ffffff 2px solid;
bottom: 4px;
cursor: pointer;
padding: 8px;
position: fixed;
right: 4px
}
<div>Smoothing Disabled</div>
<p>Use the arrow keys to scroll and the D and F keys to scale. The source image is represented on the top left. Notice the vertical and horizontal lines that appear between tiles as you scroll and scale. They are the color of the tile's neighbor in the source
image. This may be due to color sampling that occurs during scaling. Click the toggle to set imageSmoothingEnabled on the drawing context.</p>
<canvas></canvas>
Note that to draw your "player" dot, you can either choose to do the same caulcations manually to avoid the blurring caused by antialiasing, or if you actually want that blurring, then you can simply set the transform only for this dot. In your position I would probably even make something modular like after a certain scale round, and below smoothen, but I'll let the reader do that implementation.
Related
Javascript Canvas Relative Mouse Coordinates on Rotated Rectangle?
I am trying to treat a rectangle in canvas as a piece of paper, and get the same relative coordinates returned when I hover over the same point regardless of scale, rotation, or translation of the page. Currently I get accurate results when in portrait or inverted portrait rotations and regardless of scale/translation. However, when I switch to landscape or inverted landscape my results are off. I've attempted to switch to rotating mouse coordinates with some trigonometric functions I found, but math is not my strong suit and it didn't work. If someone could point me in the right direction, I would be grateful. I suspect I need to swap axis or height/width when rotating landscape but that hasn't been fruitful either. R key rotates the "page" through 4, 90 degree changes. Coordinates of your mouse relative to the page, clamped to the page's width/height are displayed in console. https://jsfiddle.net/2hg6u3wd/2/ (Note, JSFiddle offsets coordinates slightly for an unknown reason) const orientation = Object.freeze({ portrait: 0, landscape: -90, invertedPortrait: 180, invertedLandscape: 90 }); function CameraRotate(rotation) { // Rotates using the center of target as origin. ctx.translate(target.width / 2, target.height / 2); ctx.rotate(-(currentRot * Math.PI / 180)); // Negate currentRot because ctx.rotate() is additive. ctx.rotate(rotation * Math.PI / 180); ctx.translate(-(target.width / 2), -(target.height / 2)); currentRot = rotation; } function CameraCalcRelTargetCoords(viewX, viewY) { return { x: clamp((viewX - ctx.getTransform().e) / ctx.getTransform().a, 0, page.width), y: clamp((viewY - ctx.getTransform().f) / ctx.getTransform().d, 0, page.height) }; } function clamp(number, min, max) { return Math.max(min, Math.min(number, max)); } canvas.addEventListener(`mousemove`, function(e) { console.log(CameraCalcRelTargetCoords(e.x, e.y)); });
When rotating (-)180 degrees, the scale is stored as skew since the axis are flipped. Thus you must divide by m12 and m21. This flipping also means x and y mouse coordinates need to be swapped as well. Here is my solution: function CameraCalcRelTargetCoords(viewX, viewY) { // Mouse coordinates are translated to a position within the target's rectangle. let relX, relY if (currentRot == orientation.landscape || currentRot == orientation.invertedLandscape) { // Landscape rotation uses skewing for scale as X/Y axis are flipped. relX = clamp((viewY - ctx.getTransform().f) / ctx.getTransform().b, 0, target.width); relY = clamp((viewX - ctx.getTransform().e) / ctx.getTransform().c, 0, target.height); } else { relX = clamp((viewX - ctx.getTransform().e) / ctx.getTransform().a, 0, target.width), relY = clamp((viewY - ctx.getTransform().f) / ctx.getTransform().d, 0, target.height) } return {x: relX, y: relY}; }
JS canvas white lines when scaling
Using JavaScript I am displaying an array on an html 5 canvas. The program uses c.fillRect() for each value in the array. Everything looks normal until I scale it using c.scale(). After being scaled white lines are visible between the squares. I do know their white because that is the color of the background (When the background changes their color changes too). Since the squares are 5 units apart I tried setting their width to 5.5 instead of 5; this only remove the white lines when zoom in far enough, but when zooming out the white lines were still there. This is my code (unnecessary parts removed): function loop() { c.resetTransform(); c.fillStyle = "white"; c.fillRect(0, 0, c.canvas.width, c.canvas.height); c.scale(scale, scale); c.translate(xViewportOffset, yViewportOffset); ... for(var x = 0; x < array.length; x++) { for(var y = 0; y < array[x].length; y++) { ... c.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')'; c.fillRect(0 + x * 5, 200 + y * 5, 5, 5); } } ... } No scaling: Zoomed in: Zoomed out: (the pattern changes depending on the amount of zoom) Thanks for any help and if any other information is needed please let me know. Update: I am using Google Chrome Version 71.0.3578.98 (Official Build) (64-bit)
This is probably because you are using non-integer values to set the context's scale and/or translate. Doing so, your rects are not on pixel boundaries anymore but on floating values. Let's make a simple example: Two pixels, one at coords (x,y) (11,10) the other at coords (12,10). At default scale, both pixels should be neighbors. Now, if we apply a scale of 1.3, the real pixel-coords of the first square will be at (14.3,13) and the ones of the second one at (15.6,13). None of these coords can hold a single pixel, so browsers will apply antialiasing, which consist in smoothing your color with the background color to give the impression of smaller pixels. This is what makes your grids. const ctx = small.getContext('2d'); ctx.scale(1.3, 1.3); ctx.fillRect(2,10,10,10); ctx.fillRect(12,10,10,10); const mag = magnifier.getContext('2d'); mag.scale(10,10); mag.imageSmoothingEnabled = false; mag.drawImage(small, 0,-10); /* it is actually transparent, not just more white */ body:hover{background:yellow} <canvas id="small" width="50" height="50"></canvas><br> <canvas id="magnifier" width="300" height="300"></canvas> To avoid this, several solutions, all dependent on what you are doing exactly. In your case, it seems you'd win a lot by working on an ImageData which would allow you to replace all these fillRect calls to simpler and faster pixel manipulation. By using a small ImageData, the size of your matrix, you can replace each rect to a single pixel. Then you just need to put this matrix on your canvas and redraw the canvas over itself at the correct scale after disabling the imageSmootingEnabled flag, which allows us to disable antialiasing for drawImage and CanvasPatterns only. // the original matrix will be 20x20 squares const width = 20; const height = 20; const ctx = canvas.getContext('2d'); // create an ImageData the size of our matrix const img = ctx.createImageData(width, height); // wrap it inside an Uint32Array so that we can work on it faster const pixels = new Uint32Array(img.data.buffer); // we could have worked directly with the Uint8 version // but our loop would have needed to iterate 4 pixels every time // just to draw a radial-gradient const rad = width / 2; // iterate over every pixels for(let x=0; x<width; x++) { for(let y=0; y<height; y++) { // make a radial-gradient const dist = Math.min(Math.hypot(rad - x, rad - y), rad); const color = 0xFF * ((rad - dist) / rad) + 0xFF000000; pixels[(y * width) + x] = color; } } // here we are still at 50x50 pixels ctx.putImageData(img, 0, 0); // in case we had transparency, this composite mode will ensure // that only what we draw after is kept on the canvas ctx.globalCompositeOperation = "copy"; // remove anti-aliasing for drawImage ctx.imageSmoothingEnabled = false; // make it bigger ctx.scale(30,30); // draw the canvas over itself ctx.drawImage(canvas, 0,0); // In case we draw again, reset all to defaults ctx.setTransform(1,0,0,1,0,0); ctx.globalCompositeOperation = "source-over"; body:hover{background:yellow} <canvas id="canvas" width="600" height="600"></canvas>
Draw Circle Jimp JavaScript
I'm trying to draw a circle in JavaScript with Jimp using the code below. const Jimp = require("jimp"); const size = 500; const black = [0, 0, 0, 255]; const white = [255, 255, 255, 255]; new Jimp(size, size, (err, image) => { for (let x = 0; x < size; x++) { for (let y = 0; y < size; y++) { const colorToUse = distanceFromCenter(size, x, y) > size / 2 ? black : white; const color = Jimp.rgbaToInt(...colorToUse); image.setPixelColor(color, x, y); } } image.write("circle.png"); }); It produces this. Problem is, when you zoom in, it looks really choppy. How can I make the circle smoother and less choppy?
You need to create anti-aliasing. This is easily done for black and white by simply controlling the level of gray of each pixel based on the floating distance it has to the center. E.g, a pixel with a distance of 250 in your setup should be black, but one with a distance of 250.5 should be gray (~ #808080). So all you have to do, is to take into account these floating points. Here is an example using the Canvas2D API, but the core logic is directly applicable to your code. const size = 500; const rad = size / 2; const black = 0xFF000000; //[0, 0, 0, 255]; const white = 0xFFFFFFFF; //[255, 255, 255, 255]; const img = new ImageData(size, size); const data = new Uint32Array(img.data.buffer); for (let x = 0; x < size; x++) { for (let y = 0; y < size; y++) { const dist = distanceFromCenter(rad, x, y); let color; if (dist >= rad + 1) color = black; else if (dist <= rad) color = white; else { const mult = (255 - Math.floor((dist - rad) * 255)).toString(16).padStart(2, 0); color = '0xff' + mult.repeat(3); // grayscale 0xffnnnnnn } // image.setPixelColor(color, x, y); data[(y * size) + x] = Number(color); } } //image.write("circle.png"); c.getContext('2d').putImageData(img, 0, 0); function distanceFromCenter(rad, x, y) { return Math.hypot(rad - x, rad - y); } <canvas id="c" width="500" height="500"></canvas>
I'm sorry to says this but the answer is that you can't really do it. The problem is that a pixel is the minimal unit that can be drawn and you have to either draw it or not. So as long as you use some raster image format (as opposed to vector graphics) you can't draw a smooth line at a big zoom. If you think about it, you might blame the problem onto the zooming app that doesn't know about the logic of the image (circle) and maps each pixel to many whole pixels. To put it otherwise, your image has only 500x500 pixels of information. You can't reliably build 5,000x5,000 pixels of information (which is effectively what 10x zooming is) from that because there is not enough information in the original image. So you (or whoever does the zooming) have to guess how to fill the missing information and this "chopping" is a result of the simplest (and the most widely used) guessing algorithm there is: just map every pixel on NxN pixels where N is zoom factor. There are three possible workarounds: Draw a much bigger image so you don't need to zoom it in the first place (but it will take much more space everywhere) Use some vector graphics like SVG (but you'll have to change the library, and it might be not what you want in the end because there are some other problems with that) Try to use anti-aliasing which is a clever trick used to subvert how humans see: you draw some pixels around the edge as some gray instead of black-and-white. It will look better at small zooms but at big enough zooms you'll still see the actual details and the magic will stop working.
Rotate object consisting of multiple other rotating objects
I would like to render an object to the canvas which I rotate depending on its direction. However this object consist of multiple other objects which also should be able to rotate independently. As far as I understand the saving and restoring of the context is the problematic part, but how do I achieve this? class MotherObject { childObject1: ChildObject; childObject2: ChildObject; constructor(private x: number, private y: number, private direction: number, private ctx: CanvasRenderingContext2D) { this.childObject1 = new ChildObject(this.x + 50, this.y, 45, this.ctx); this.childObject2 = new ChildObject(this.x - 50, this.y, 135, this.ctx); } render(): void { this.ctx.save(); this.ctx.translate(this.x, this.y); this.ctx.rotate(this.direction * Math.PI / 180); this.childObject1.render(); this.childObject2.render(); this.ctx.restore(); } } class ChildObject { constructor(private x: number, private y: number, private direction: number, private ctx: CanvasRenderingContext2D) { } render(): void { this.ctx.save(); this.ctx.translate(this.x, this.y); this.ctx.rotate(this.direction * Math.PI / 180); this.ctx.fillRect(0, 0, 100, 20); this.ctx.restore(); } }
A complete image render function. The 2D API allows you to draw an image that is scaled, rotated, fade in/out. Rendering a image like this is sometimes called a sprite (From the old 16bit days) Function to draw a scaled rotated faded image / sprite with the rotation around its center. x and y are the position on the canvas where the center will be. scale is 1 for no scale <1 for smaller, and >1 for larger. rot is the rotation with 0 being no rotation. Math.PI is 180 deg. Increasing rot will rotate in a clockwise direction decreasing will rotate the other way. alpha will set how transparent the image will be with 0 being invisible and 1 as fully visible. Trying to set global alpha with a value outside 0-1 range will result in no change. The code below does a check to ensure that alpha is clamped. If you trust the alpha value you can set globalAlpha directly You can call this function without needing to restore the state as setTransform replaces the existing transformation rather than multiplying it as is done with ctx.translate, ctx.scale, ctx.rotate, ctx.transform function drawSprite(image,x,y,scale,rot,alpha){ // if you want non uniform scaling just replace the scale // argument with scaleX, scaleY and use // ctx.setTransform(scaleX,0,0,scaleY,x,y); ctx.setTransform(scale,0,0,scale,x,y); ctx.rotate(rot); ctx.globalAlpha = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha; // if you may have // alpha values outside // the normal range ctx.drawImage(image,-image.width / 2, -image.height / 2); } // usage drawSprite(image,x,y,1,0,1); // draws image without rotation or scale drawSprite(image,x,y,0.5,Math.PI/2,0.5); // draws image rotated 90 deg // scaled to half its size // and semi transparent The function leaves the current transform and alpha as is. If you render elsewhere (not using this function) you need to reset the current state of the 2D context. To default ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha = 1; To keep the current state use ctx.save(); // draw all the sprites ctx.restore();
HTML5 <canvas> without anti-aliasing
I use fillRect(x, y, 9, 9) with integer values and see smoothed rectangles (see below). I tried the following with no luck: this.ctx.webkitImageSmoothingEnabled = false; this.ctx.imageSmoothingEnabled = false; var iStrokeWidth = 1; var iTranslate = (iStrokeWidth % 2) / 2; this.ctx.translate(iTranslate, iTranslate); i would like to see a line of 1 pixel between blue blocks, but i see smoothed gap:
Just add 0.5 pixel to the positions (or pre-translate half pixel): fillRect(x + 0.5, y + 0.5, 9, 9); This works because for some reason the canvas coordinate system define start of a pixel from the pixel's center. When drawn canvas actually has to sub-pixel the single point producing 4 anti-aliased pixels. By adding 0.5 you move it from center to the pixel's corner so it matches with the screen's coordinate system and no sub-pixeling has to take place. These only affects images, not shapes btw.: this.ctx.webkitImageSmoothingEnabled = false; this.ctx.imageSmoothingEnabled = false;