Skip to content

Commit

Permalink
make tiled sprites pixel-aligned to avoid transparent seams
Browse files Browse the repository at this point in the history
  • Loading branch information
EmeraldBlock committed Apr 24, 2024
1 parent 34c1ecc commit dabd234
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 59 deletions.
55 changes: 50 additions & 5 deletions src/js/core/draw_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,20 @@ let warningsShown = 0;
* @param {number} param0.h
* @param {number} param0.originalW
* @param {number} param0.originalH
* @param {boolean=} param0.pixelAligned
* Whether to round the canvas coordinates, to avoid issues with transparency between tiling images
*/
export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, originalH }) {
export function drawSpriteClipped({
parameters,
sprite,
x,
y,
w,
h,
originalW,
originalH,
pixelAligned = false,
}) {
const rect = new Rectangle(x, y, w, h);
const intersection = rect.getIntersection(parameters.visibleRect);
if (!intersection) {
Expand All @@ -103,6 +115,38 @@ export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, o
return;
}

if (!pixelAligned) {
parameters.context.drawImage(
sprite,

// src pos and size
((intersection.x - x) / w) * originalW,
((intersection.y - y) / h) * originalH,
(originalW * intersection.w) / w,
(originalH * intersection.h) / h,

// dest pos and size
intersection.x,
intersection.y,
intersection.w,
intersection.h
);
}

const matrix = parameters.context.getTransform();
let { x: x1, y: y1 } = matrix.transformPoint(new DOMPoint(intersection.x, intersection.y));
let { x: x2, y: y2 } = matrix.transformPoint(
new DOMPoint(intersection.x + intersection.w, intersection.y + intersection.h)
);
x1 = Math.round(x1);
y1 = Math.round(y1);
x2 = Math.round(x2);
y2 = Math.round(y2);
if (x2 - x1 == 0 || y2 - y1 == 0) {
return;
}

parameters.context.resetTransform();
parameters.context.drawImage(
sprite,

Expand All @@ -113,9 +157,10 @@ export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, o
(originalH * intersection.h) / h,

// dest pos and size
intersection.x,
intersection.y,
intersection.w,
intersection.h
x1,
y1,
x2 - x1,
y2 - y1
);
parameters.context.setTransform(matrix);
}
18 changes: 8 additions & 10 deletions src/js/core/sprites.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { round3Digits } from "./utils";
export const ORIGINAL_SPRITE_SCALE = "0.75";
export const FULL_CLIP_RECT = new Rectangle(0, 0, 1, 1);

const EXTRUDE = 0.1;

export class BaseSprite {
/**
* Returns the raw handle
Expand Down Expand Up @@ -227,10 +225,10 @@ export class AtlasSprite extends BaseSprite {
srcH,

// dest pos and size
destX - EXTRUDE,
destY - EXTRUDE,
destW + 2 * EXTRUDE,
destH + 2 * EXTRUDE
destX,
destY,
destW,
destH
);
}

Expand Down Expand Up @@ -291,10 +289,10 @@ export class AtlasSprite extends BaseSprite {
srcH,

// dest pos and size
destX - EXTRUDE,
destY - EXTRUDE,
destW + 2 * EXTRUDE,
destH + 2 * EXTRUDE
destX,
destY,
destW,
destH
);
}

Expand Down
95 changes: 70 additions & 25 deletions src/js/game/components/static_map_entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,16 @@ export class StaticMapEntityComponent extends Component {
* @param {AtlasSprite} sprite
* @param {number=} extrudePixels How many pixels to extrude the sprite
* @param {Vector=} overridePosition Whether to drwa the entity at a different location
* @param {boolean=} pixelAligned
* Whether to round the canvas coordinates, to avoid issues with transparency between tiling images
*/
drawSpriteOnBoundsClipped(parameters, sprite, extrudePixels = 0, overridePosition = null) {
drawSpriteOnBoundsClipped(
parameters,
sprite,
extrudePixels = 0,
overridePosition = null,
pixelAligned = false
) {
if (!this.shouldBeDrawn(parameters) && !overridePosition) {
return;
}
Expand All @@ -269,31 +277,68 @@ export class StaticMapEntityComponent extends Component {
worldY = overridePosition.y * globalConfig.tileSize;
}

if (this.rotation === 0) {
// Early out, is faster
sprite.drawCached(
parameters,
worldX - extrudePixels * size.x,
worldY - extrudePixels * size.y,
globalConfig.tileSize * size.x + 2 * extrudePixels * size.x,
globalConfig.tileSize * size.y + 2 * extrudePixels * size.y
);
} else {
const rotationCenterX = worldX + globalConfig.halfTileSize;
const rotationCenterY = worldY + globalConfig.halfTileSize;
if (!pixelAligned) {
if (this.rotation === 0) {
// Early out, is faster
sprite.drawCached(
parameters,
worldX - extrudePixels * size.x,
worldY - extrudePixels * size.y,
globalConfig.tileSize * size.x + 2 * extrudePixels * size.x,
globalConfig.tileSize * size.y + 2 * extrudePixels * size.y
);
} else {
const rotationCenterX = worldX + globalConfig.halfTileSize;
const rotationCenterY = worldY + globalConfig.halfTileSize;

parameters.context.translate(rotationCenterX, rotationCenterY);
parameters.context.rotate(Math.radians(this.rotation));
sprite.drawCached(
parameters,
-globalConfig.halfTileSize - extrudePixels * size.x,
-globalConfig.halfTileSize - extrudePixels * size.y,
globalConfig.tileSize * size.x + 2 * extrudePixels * size.x,
globalConfig.tileSize * size.y + 2 * extrudePixels * size.y,
false // no clipping possible here
);
parameters.context.rotate(-Math.radians(this.rotation));
parameters.context.translate(-rotationCenterX, -rotationCenterY);
parameters.context.translate(rotationCenterX, rotationCenterY);
parameters.context.rotate(Math.radians(this.rotation));
sprite.drawCached(
parameters,
-globalConfig.halfTileSize - extrudePixels * size.x,
-globalConfig.halfTileSize - extrudePixels * size.y,
globalConfig.tileSize * size.x + 2 * extrudePixels * size.x,
globalConfig.tileSize * size.y + 2 * extrudePixels * size.y,
false // no clipping possible here
);
parameters.context.rotate(-Math.radians(this.rotation));
parameters.context.translate(-rotationCenterX, -rotationCenterY);
}
return;
}

const transform = parameters.context.getTransform();
const matrix = new DOMMatrix().rotate(0, 0, -this.rotation).multiplySelf(transform);
let { x: x1, y: y1 } = matrix.transformPoint(
new DOMPoint(worldX - extrudePixels * size.x, worldY - extrudePixels * size.y)
);
let { x: x2, y: y2 } = matrix.transformPoint(
new DOMPoint(
worldX + globalConfig.tileSize * size.x + extrudePixels * size.x,
worldY + globalConfig.tileSize * size.y + extrudePixels * size.y
)
);
if (x1 > x2) {
[x1, x2] = [x2, x1];
}
if (y1 > y2) {
[y1, y2] = [y2, y1];
}
// Even though drawCached may scale the coordinates,
// that scaling is for sprites that don't take up their full tile space,
// so they should be interpolated exactly between the rounded tile coordinates.
// E.g. rounding in drawCached causes curved belts to look misaligned.
x1 = Math.round(x1);
y1 = Math.round(y1);
x2 = Math.round(x2);
y2 = Math.round(y2);
if (x2 - x1 == 0 || y2 - y1 == 0) {
return;
}

parameters.context.resetTransform();
parameters.context.rotate(Math.radians(this.rotation));
sprite.drawCached(parameters, x1, y1, x2 - x1, y2 - y1, false);
parameters.context.setTransform(transform);
}
}
10 changes: 5 additions & 5 deletions src/js/game/map_chunk_aggregate.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,19 +106,19 @@ export class MapChunkAggregate {
});

const dims = globalConfig.mapChunkWorldSize * globalConfig.chunkAggregateSize;
const extrude = 0.05;

// Draw chunk "pixel" art
parameters.context.imageSmoothingEnabled = false;
drawSpriteClipped({
parameters,
sprite,
x: this.x * dims - extrude,
y: this.y * dims - extrude,
w: dims + 2 * extrude,
h: dims + 2 * extrude,
x: this.x * dims,
y: this.y * dims,
w: dims,
h: dims,
originalW: aggregateOverlaySize,
originalH: aggregateOverlaySize,
pixelAligned: true,
});

parameters.context.imageSmoothingEnabled = true;
Expand Down
16 changes: 14 additions & 2 deletions src/js/game/systems/belt.js
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,13 @@ export class BeltSystem extends GameSystem {
}

// Culling happens within the static map entity component
entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(parameters, sprite, 0);
entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(
parameters,
sprite,
0,
null,
true
);
}
}
} else {
Expand All @@ -544,7 +550,13 @@ export class BeltSystem extends GameSystem {
const sprite = this.beltAnimations[direction][animationIndex % BELT_ANIM_COUNT];

// Culling happens within the static map entity component
entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(parameters, sprite, 0);
entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(
parameters,
sprite,
0,
null,
true
);
}
}
}
Expand Down
38 changes: 27 additions & 11 deletions src/js/game/systems/belt_underlays.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,20 +278,36 @@ export class BeltUnderlaysSystem extends GameSystem {
((this.root.time.realtimeNow() * speedMultiplier * BELT_ANIM_COUNT * 126) / 42) *
globalConfig.itemSpacingOnBelts
);
parameters.context.translate(x, y);

// See components/static_map_entity.js:drawSpriteOnBoundsClipped
const transform = parameters.context.getTransform();
const matrix = new DOMMatrix().rotate(0, 0, -angle).multiplySelf(transform);
let { x: x1, y: y1 } = matrix.transformPoint(
new DOMPoint(x - globalConfig.halfTileSize, y - globalConfig.halfTileSize)
);
let { x: x2, y: y2 } = matrix.transformPoint(
new DOMPoint(x + globalConfig.halfTileSize, y + globalConfig.halfTileSize)
);
if (x1 > x2) {
[x1, x2] = [x2, x1];
}
if (y1 > y2) {
[y1, y2] = [y2, y1];
}
x1 = Math.round(x1);
y1 = Math.round(y1);
x2 = Math.round(x2);
y2 = Math.round(y2);
if (x2 - x1 == 0 || y2 - y1 == 0) {
continue;
}

parameters.context.resetTransform();
parameters.context.rotate(angleRadians);
this.underlayBeltSprites[
animationIndex % this.underlayBeltSprites.length
].drawCachedWithClipRect(
parameters,
-globalConfig.halfTileSize,
-globalConfig.halfTileSize,
globalConfig.tileSize,
globalConfig.tileSize,
clipRect
);
parameters.context.rotate(-angleRadians);
parameters.context.translate(-x, -y);
].drawCachedWithClipRect(parameters, x1, y1, x2 - x1, y2 - y1, clipRect);
parameters.context.setTransform(transform);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/js/game/systems/map_resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class MapResourcesSystem extends GameSystem {
h: globalConfig.mapChunkWorldSize,
originalW: globalConfig.mapChunkSize,
originalH: globalConfig.mapChunkSize,
pixelAligned: true,
});
parameters.context.imageSmoothingEnabled = true;

Expand Down
2 changes: 1 addition & 1 deletion src/js/game/systems/wire.js
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ export class WireSystem extends GameSystem {
assert(sprite, "Unknown wire type: " + wireType);
const staticComp = entity.components.StaticMapEntity;
parameters.context.globalAlpha = opacity;
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 0);
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 0, null, true);

// DEBUG Rendering
if (G_IS_DEV && globalConfig.debug.renderWireRotations) {
Expand Down

0 comments on commit dabd234

Please sign in to comment.