From dabd2349feaf218e4812d103d8404fc3a2eeb74b Mon Sep 17 00:00:00 2001 From: EmeraldBlock Date: Tue, 23 Apr 2024 23:02:46 -0500 Subject: [PATCH] make tiled sprites pixel-aligned to avoid transparent seams --- src/js/core/draw_utils.js | 55 ++++++++++-- src/js/core/sprites.js | 18 ++-- src/js/game/components/static_map_entity.js | 95 +++++++++++++++------ src/js/game/map_chunk_aggregate.js | 10 +-- src/js/game/systems/belt.js | 16 +++- src/js/game/systems/belt_underlays.js | 38 ++++++--- src/js/game/systems/map_resources.js | 1 + src/js/game/systems/wire.js | 2 +- 8 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/js/core/draw_utils.js b/src/js/core/draw_utils.js index d5183cfba..dab33eac5 100644 --- a/src/js/core/draw_utils.js +++ b/src/js/core/draw_utils.js @@ -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) { @@ -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, @@ -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); } diff --git a/src/js/core/sprites.js b/src/js/core/sprites.js index 4caa599c1..a4ae42a01 100644 --- a/src/js/core/sprites.js +++ b/src/js/core/sprites.js @@ -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 @@ -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 ); } @@ -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 ); } diff --git a/src/js/game/components/static_map_entity.js b/src/js/game/components/static_map_entity.js index a3d6a8ca5..ebf74f466 100644 --- a/src/js/game/components/static_map_entity.js +++ b/src/js/game/components/static_map_entity.js @@ -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; } @@ -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); } } diff --git a/src/js/game/map_chunk_aggregate.js b/src/js/game/map_chunk_aggregate.js index f47ed6764..130de77a9 100644 --- a/src/js/game/map_chunk_aggregate.js +++ b/src/js/game/map_chunk_aggregate.js @@ -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; diff --git a/src/js/game/systems/belt.js b/src/js/game/systems/belt.js index e3868bf0e..fb9a98fb8 100644 --- a/src/js/game/systems/belt.js +++ b/src/js/game/systems/belt.js @@ -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 { @@ -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 + ); } } } diff --git a/src/js/game/systems/belt_underlays.js b/src/js/game/systems/belt_underlays.js index ddbe051a9..f277e6a5a 100644 --- a/src/js/game/systems/belt_underlays.js +++ b/src/js/game/systems/belt_underlays.js @@ -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); } } } diff --git a/src/js/game/systems/map_resources.js b/src/js/game/systems/map_resources.js index 8c70f60b1..41ebf5dd2 100644 --- a/src/js/game/systems/map_resources.js +++ b/src/js/game/systems/map_resources.js @@ -31,6 +31,7 @@ export class MapResourcesSystem extends GameSystem { h: globalConfig.mapChunkWorldSize, originalW: globalConfig.mapChunkSize, originalH: globalConfig.mapChunkSize, + pixelAligned: true, }); parameters.context.imageSmoothingEnabled = true; diff --git a/src/js/game/systems/wire.js b/src/js/game/systems/wire.js index 3ecb7336e..e35a4bbc4 100644 --- a/src/js/game/systems/wire.js +++ b/src/js/game/systems/wire.js @@ -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) {