Skip to content

Commit 60f17d6

Browse files
authored
fix(game): repair seams in generated scrolling backgrounds (#5684)
1 parent bf9a850 commit 60f17d6

4 files changed

Lines changed: 130 additions & 3 deletions

File tree

packages/image-nodes/src/nodes/game.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,38 @@ function meanEdgeDifference(
468468
return total / (length * 3);
469469
}
470470

471+
function repairEdgeBand(
472+
rgba: Buffer,
473+
width: number,
474+
height: number,
475+
axis: "x" | "y"
476+
): void {
477+
const extent = axis === "x" ? width : height;
478+
const length = axis === "x" ? height : width;
479+
const band = Math.min(Math.floor(extent / 2), Math.max(1, Math.ceil(extent * 0.1)));
480+
for (let depth = 0; depth < band; depth++) {
481+
const t = depth / band;
482+
const weight = (1 - t * t * (3 - 2 * t)) / 2;
483+
for (let i = 0; i < length; i++) {
484+
const a = (axis === "x" ? i * width + depth : depth * width + i) * 4;
485+
const b = (axis === "x" ? i * width + width - 1 - depth : (height - 1 - depth) * width + i) * 4;
486+
// Blend premultiplied colors so invisible RGB cannot tint opaque pixels.
487+
const alphaA = rgba[a + 3];
488+
const alphaB = rgba[b + 3];
489+
const blendedA = alphaA * (1 - weight) + alphaB * weight;
490+
const blendedB = alphaB * (1 - weight) + alphaA * weight;
491+
for (let c = 0; c < 3; c++) {
492+
const colorA = rgba[a + c] * alphaA;
493+
const colorB = rgba[b + c] * alphaB;
494+
rgba[a + c] = blendedA === 0 ? 0 : Math.round((colorA * (1 - weight) + colorB * weight) / blendedA);
495+
rgba[b + c] = blendedB === 0 ? 0 : Math.round((colorB * (1 - weight) + colorA * weight) / blendedB);
496+
}
497+
rgba[a + 3] = Math.round(blendedA);
498+
rgba[b + 3] = Math.round(blendedB);
499+
}
500+
}
501+
}
502+
471503
export class SeamlessImageNode extends BaseNode {
472504
static readonly nodeType = "nodetool.game.SeamlessImage";
473505
static readonly title = "Seamless Image";
@@ -477,7 +509,7 @@ export class SeamlessImageNode extends BaseNode {
477509
output: "image",
478510
fill: "dict"
479511
};
480-
static readonly inlineFields = ["check_x", "check_y", "threshold"];
512+
static readonly inlineFields = ["check_x", "check_y", "repair", "threshold"];
481513
static readonly inputFields = ["image", "slot"];
482514

483515
@prop({
@@ -533,6 +565,15 @@ export class SeamlessImageNode extends BaseNode {
533565
})
534566
declare threshold: number;
535567

568+
@prop({
569+
type: "bool",
570+
default: false,
571+
title: "Repair seams",
572+
description:
573+
"Blend the outer 10% on each checked axis before measuring. Preserves dimensions and the center, but softens detail near the edges."
574+
})
575+
declare repair: boolean;
576+
536577
async process(context?: ProcessingContext): Promise<Record<string, unknown>> {
537578
const name = SeamlessImageNode.title;
538579
const fromSlot = slotOverrides(name, this.slot, "image");
@@ -553,11 +594,18 @@ export class SeamlessImageNode extends BaseNode {
553594
throw new Error(`${name}: ${SHARP_UNAVAILABLE_MESSAGE}`);
554595
}
555596
const rgba = await sharp(img.buf, { failOn: "none" })
597+
.toColourspace("srgb")
556598
.ensureAlpha()
557599
.raw()
558600
.toBuffer();
559601
const { width, height } = img;
560602
const stride = width * 4;
603+
if (this.repair) {
604+
if (checkX) repairEdgeBand(rgba, width, height, "x");
605+
if (checkY) repairEdgeBand(rgba, width, height, "y");
606+
img.buf = await sharp(rgba, { raw: { width, height, channels: 4 } })
607+
.png().toBuffer();
608+
}
561609
if (checkX) {
562610
const diff = meanEdgeDifference(
563611
rgba,
@@ -588,12 +636,18 @@ export class SeamlessImageNode extends BaseNode {
588636
if (!parsed.success) {
589637
throw formatZodIssues(name, parsed.error);
590638
}
639+
const stampedImage: Record<string, unknown> = stampFill(this.image, img, parsed.data);
640+
if (this.repair && (checkX || checkY)) {
641+
// The repaired bytes must not retain the original asset's identity.
642+
stampedImage.uri = "";
643+
stampedImage.asset_id = null;
644+
}
591645
return {
592646
output: await persistStamped(
593647
context,
594648
name,
595649
parsed.data.slot_id,
596-
stampFill(this.image, img, parsed.data)
650+
stampedImage
597651
),
598652
fill: parsed.data
599653
};

packages/image-nodes/tests/game.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,68 @@ describe("nodetool.game.Tileset", () => {
243243
});
244244

245245
describe("nodetool.game.SeamlessImage", () => {
246+
it.each([[true, false], [false, true], [true, true]])(
247+
"repairs requested axes x=%s y=%s and preserves the center",
248+
async (checkX, checkY) => {
249+
const image = await makeImage(64, 64, (x, y) => [x * 4, y * 4, 80]);
250+
const result = await runNode(".SeamlessImage", {
251+
image, slot_id: "bg.space", check_x: checkX, check_y: checkY,
252+
repair: true, threshold: 0
253+
});
254+
const output = result.output as { data: Uint8Array; uri: string; asset_id: string | null };
255+
expect(output.uri).toBe("");
256+
expect(output.asset_id).toBeNull();
257+
const { data, info } = await sharp(output.data).ensureAlpha().raw()
258+
.toBuffer({ resolveWithObject: true });
259+
expect([info.width, info.height]).toEqual([64, 64]);
260+
const pixel = (x: number, y: number) => [...data.subarray((y * 64 + x) * 4, (y * 64 + x) * 4 + 4)];
261+
expect(pixel(32, 32)).toEqual([128, 128, 80, 255]);
262+
for (let i = 0; i < 64; i++) {
263+
if (checkX) expect(pixel(0, i)).toEqual(pixel(63, i));
264+
if (checkY) expect(pixel(i, 0)).toEqual(pixel(i, 63));
265+
}
266+
expect(result.fill).toMatchObject({ seamless_x: checkX, seamless_y: checkY });
267+
expect(stamped(result)).toEqual(result.fill);
268+
}
269+
);
270+
271+
it.each([[1, 1], [1, 7], [7, 1], [2, 2], [9, 11], [1920, 1080]])(
272+
"repairs images sized %s by %s",
273+
async (width, height) => {
274+
const image = await makeImage(width, height, (x, y) => [x * 20, y * 20, 50]);
275+
const result = await runNode(".SeamlessImage", {
276+
image, slot_id: "bg.space", check_x: true, check_y: true,
277+
repair: true, threshold: 0
278+
});
279+
expect(result.fill).toMatchObject({ size: [width, height], seamless_x: true, seamless_y: true });
280+
}
281+
);
282+
283+
it("does not alter artwork when neither axis requires tiling", async () => {
284+
const image = await makeImage(16, 16, (x, y) => [x * 10, y * 10, 50]);
285+
const result = await runNode(".SeamlessImage", {
286+
image, slot_id: "title", check_x: false, check_y: false, repair: true
287+
});
288+
const output = result.output as { data: Uint8Array };
289+
expect(Buffer.from(output.data)).toEqual(Buffer.from(image.data as string, "base64"));
290+
});
291+
292+
it("blends transparency without leaking invisible colors into the edge", async () => {
293+
const pixels = Buffer.alloc(16 * 16 * 4);
294+
for (let i = 0; i < 16 * 16; i++) {
295+
pixels.set(i < 128 ? [255, 0, 0, 0] : [0, 0, 255, 255], i * 4);
296+
}
297+
const data = await sharp(pixels, { raw: { width: 16, height: 16, channels: 4 } }).png().toBuffer();
298+
const result = await runNode(".SeamlessImage", {
299+
image: { type: "image", data }, slot_id: "bg.space",
300+
check_x: false, check_y: true, repair: true, threshold: 0
301+
});
302+
const output = result.output as { data: Uint8Array };
303+
const rgba = await sharp(output.data).ensureAlpha().raw().toBuffer();
304+
expect([...rgba.subarray(0, 4)]).toEqual([0, 0, 255, 128]);
305+
expect([...rgba.subarray(15 * 16 * 4, 15 * 16 * 4 + 4)]).toEqual([0, 0, 255, 128]);
306+
});
307+
246308
const bgFar = slot("bg.far");
247309
if (bgFar.kind !== "image") throw new Error("bg.far is an image");
248310
const [w, h] = bgFar.size;

packages/protocol/src/game-graph.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ function checkerPropertiesFor(slot: GameSlotSpec): Record<string, unknown> {
263263
return {
264264
slot_id: slot.id,
265265
check_x: slot.seamless_x,
266-
check_y: slot.seamless_y
266+
check_y: slot.seamless_y,
267+
repair: slot.seamless_x || slot.seamless_y
267268
};
268269
case "sfx":
269270
return { slot_id: slot.id, seconds: slot.seconds };

packages/protocol/tests/game-graph.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const SHAPES: Record<string, PlanNodeShape> = {
105105
{ name: "slot_id", type: "str" },
106106
{ name: "check_x", type: "bool" },
107107
{ name: "check_y", type: "bool" },
108+
{ name: "repair", type: "bool" },
108109
{ name: "threshold", type: "float" }
109110
],
110111
outputs: [
@@ -244,6 +245,15 @@ const typesOf = (placement: WorkflowPlacement, slotId: string): string[] =>
244245
.map((node) => node.type);
245246

246247
describe("gameGraphPlacement", () => {
248+
it("repairs scrolling backgrounds but leaves title artwork untouched", () => {
249+
const placement = gameGraphPlacement(manifestOf("shmup"), chipDesign("shmup"), choices(), lookup);
250+
const checkers = placement.nodes.filter((node) => node.type === GAME_SEAMLESS_IMAGE_NODE_TYPE);
251+
expect(checkers.find((node) => node.setupStepId === "bg.space")?.properties)
252+
.toMatchObject({ check_x: false, check_y: true, repair: true });
253+
expect(checkers.find((node) => node.setupStepId === "title")?.properties)
254+
.toMatchObject({ check_x: false, check_y: false, repair: false });
255+
});
256+
247257
it("builds one chain per slot kind, with nothing left to report", () => {
248258
const placement = gameGraphPlacement(platformer, design, choices(), lookup);
249259
expect(placement.issues).toEqual([]);

0 commit comments

Comments
 (0)