Skip to content

Commit 87a35c1

Browse files
committed
[ts][phaser-v4] Fixed batching issues. See #3086.
1 parent eb588ec commit 87a35c1

5 files changed

Lines changed: 283 additions & 10 deletions

File tree

‎spine-ts/index.html‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ <h1>spine-ts Examples</h1>
6868
<li>
6969
<a href="/spine-phaser-v4/example/batching-test.html">Batching test</a> - (<a href="/spine-phaser-v3/example/batching-test.html">v3</a>)
7070
</li>
71+
<li>
72+
<a href="/spine-phaser-v4/example/batching-context-test.html">Batching context test</a> - (<a href="/spine-phaser-v3/example/batching-context-test.html">v3</a>)
73+
</li>
7174
<li>
7275
<a href="/spine-phaser-v4/example/multi-scene-test.html">Multi-scene test</a> - (<a href="/spine-phaser-v3/example/multi-scene-test.html">v3</a>)
7376
</li>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<script src="//cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.js"></script>
8+
<script src="../dist/iife/spine-phaser-v3.js"></script>
9+
<link rel="stylesheet" href="../../index.css" />
10+
<title>Spine Phaser Batching Context Test</title>
11+
</head>
12+
13+
<body class="p-4 flex flex-col items-center">
14+
<h1>Batching context test</h1>
15+
</body>
16+
<script>
17+
class BatchingContextTest extends Phaser.Scene {
18+
preload() {
19+
this.load.spineBinary("spineboy-data", "/assets/spineboy-pro.skel");
20+
this.load.spineAtlas("spineboy-atlas", "/assets/spineboy-pma.atlas");
21+
}
22+
23+
create() {
24+
const camera = this.cameras.main;
25+
camera.flash(300, 255, 255, 255, false);
26+
camera.shake(500, 0.002);
27+
28+
// Phaser v3 does not have Phaser v4 DrawingContext/filter framebuffer passes.
29+
// Expected batches are: top-level Spine group (1), container Spine children (4), final top-level Spine group (1).
30+
// Container rendering in Phaser v3 computes nextTypeMatch from the raw child list, so invisible children
31+
// inside the container intentionally break Spine batching here.
32+
this.expectedSpineDrawCalls = 6;
33+
34+
this.debugText = document.createElement("pre");
35+
this.debugText.style.position = "absolute";
36+
this.debugText.style.left = "8px";
37+
this.debugText.style.top = "8px";
38+
this.debugText.style.margin = "0";
39+
this.debugText.style.padding = "4px 6px";
40+
this.debugText.style.color = "#ffffff";
41+
this.debugText.style.background = "rgba(0, 0, 0, 0.75)";
42+
this.debugText.style.font = "12px monospace";
43+
this.debugText.style.pointerEvents = "none";
44+
document.body.appendChild(this.debugText);
45+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.debugText.remove());
46+
47+
const makeSpineboy = (x, y, scale = 0.25) => {
48+
const spineboy = this.add.spine(
49+
x,
50+
y,
51+
"spineboy-data",
52+
"spineboy-atlas"
53+
);
54+
spineboy.scale = scale;
55+
spineboy.animationState.setAnimation(0, "walk", true);
56+
return spineboy;
57+
};
58+
59+
// Top-level adjacent Spine objects, with skipped objects between them.
60+
// The invisible objects should not break the Spine batch.
61+
for (let i = 0; i < 5; i++) {
62+
makeSpineboy(100 + i * 120, 520, 0.25);
63+
this.add.rectangle(100 + i * 120 + 45, 520, 20, 20, 0xff00ff).setVisible(false);
64+
}
65+
66+
// A visible Phaser object intentionally breaks the Spine batch.
67+
this.add.rectangle(395, 520, 30, 220, 0x00ffff, 0.35);
68+
69+
// Container children: in Phaser v3, these skipped children still break batching because
70+
// ContainerWebGLRenderer computes nextTypeMatch from the raw child list.
71+
const container = this.add.container(120, 250);
72+
for (let i = 0; i < 4; i++) {
73+
const spineboy = makeSpineboy(i * 120, 0, 0.22);
74+
const hiddenBlock = this.add.rectangle(i * 120 + 45, 0, 24, 24, 0xff0000).setVisible(false);
75+
container.add([spineboy, hiddenBlock]);
76+
}
77+
container.setRotation(0.12);
78+
79+
// Final top-level Spine group after the container.
80+
for (let i = 0; i < 3; i++) {
81+
makeSpineboy(220 + i * 140, 120, 0.2);
82+
}
83+
}
84+
85+
update(time) {
86+
if (this.debugText) {
87+
this.debugText.textContent =
88+
(() => {
89+
const drawCalls = spine.PolygonBatcher.getAndResetGlobalDrawCalls();
90+
const expectedDrawCalls = this.expectedSpineDrawCalls;
91+
const status = drawCalls <= expectedDrawCalls ? "OK" : "TOO MANY";
92+
this.debugText.style.color = drawCalls <= expectedDrawCalls ? "#80ff80" : "#ff8080";
93+
return "Spine draw calls: " + drawCalls + " / expected <= " + expectedDrawCalls + " (" + status + ")" +
94+
"\nCamera flash/shake" +
95+
"\nContainer invisible children break v3 batching";
96+
})();
97+
}
98+
}
99+
}
100+
101+
new Phaser.Game({
102+
type: Phaser.AUTO,
103+
width: 800,
104+
height: 600,
105+
type: Phaser.WEBGL,
106+
scene: [BatchingContextTest],
107+
plugins: {
108+
scene: [
109+
{
110+
key: "spine.SpinePlugin",
111+
plugin: spine.SpinePlugin,
112+
mapping: "spine",
113+
},
114+
],
115+
},
116+
});
117+
</script>
118+
</html>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<script src="//cdn.jsdelivr.net/npm/phaser@4.0.0/dist/phaser.js"></script>
8+
<script src="../dist/iife/spine-phaser-v4.js"></script>
9+
<link rel="stylesheet" href="../../index.css" />
10+
<title>Spine Phaser Batching Context Test</title>
11+
</head>
12+
13+
<body class="p-4 flex flex-col items-center">
14+
<h1>Batching context test</h1>
15+
</body>
16+
<script>
17+
class BatchingContextTest extends Phaser.Scene {
18+
preload() {
19+
this.load.spineBinary("spineboy-data", "/assets/spineboy-pro.skel");
20+
this.load.spineAtlas("spineboy-atlas", "/assets/spineboy-pma.atlas");
21+
}
22+
23+
create() {
24+
const camera = this.cameras.main;
25+
camera.setForceComposite(true); // Forces rendering through a framebuffer.
26+
camera.filters.internal.addVignette(0.5, 0.5, 0.8, 0.25);
27+
camera.filters.external.addPixelate(1);
28+
camera.flash(300, 255, 255, 255, false);
29+
camera.shake(500, 0.002);
30+
31+
this.expectedSpineDrawCalls = 5;
32+
33+
this.debugText = document.createElement("pre");
34+
this.debugText.style.position = "absolute";
35+
this.debugText.style.left = "8px";
36+
this.debugText.style.top = "8px";
37+
this.debugText.style.margin = "0";
38+
this.debugText.style.padding = "4px 6px";
39+
this.debugText.style.color = "#ffffff";
40+
this.debugText.style.background = "rgba(0, 0, 0, 0.75)";
41+
this.debugText.style.font = "12px monospace";
42+
this.debugText.style.pointerEvents = "none";
43+
document.body.appendChild(this.debugText);
44+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.debugText.remove());
45+
46+
const makeSpineboy = (x, y, scale = 0.25) => {
47+
const spineboy = this.add.spine(
48+
x,
49+
y,
50+
"spineboy-data",
51+
"spineboy-atlas"
52+
);
53+
spineboy.scale = scale;
54+
spineboy.animationState.setAnimation(0, "walk", true);
55+
return spineboy;
56+
};
57+
58+
// Top-level adjacent Spine objects, with skipped objects between them.
59+
// The invisible objects should not break the Spine batch.
60+
for (let i = 0; i < 5; i++) {
61+
makeSpineboy(100 + i * 120, 520, 0.25);
62+
this.add.rectangle(100 + i * 120 + 45, 520, 20, 20, 0xff00ff).setVisible(false);
63+
}
64+
65+
// A visible Phaser object intentionally breaks the Spine batch.
66+
this.add.rectangle(395, 520, 30, 220, 0x00ffff, 0.35);
67+
68+
// Container children: skipped children inside containers/layers should not break batching.
69+
const container = this.add.container(120, 250);
70+
for (let i = 0; i < 4; i++) {
71+
const spineboy = makeSpineboy(i * 120, 0, 0.22);
72+
const hiddenBlock = this.add.rectangle(i * 120 + 45, 0, 24, 24, 0xff0000).setVisible(false);
73+
container.add([spineboy, hiddenBlock]);
74+
}
75+
container.setRotation(0.12);
76+
77+
// Filtered Spine objects render through object framebuffers.
78+
// These exercise the filtered drawing context and matrix path.
79+
for (let i = 0; i < 3; i++) {
80+
const filtered = makeSpineboy(220 + i * 140, 120, 0.2).enableFilters();
81+
filtered.filters.internal.addGlow(0xffff00, 2, 0, 1);
82+
}
83+
}
84+
85+
update(time) {
86+
if (this.debugText) {
87+
this.debugText.textContent =
88+
(() => {
89+
const drawCalls = spine.PolygonBatcher.getAndResetGlobalDrawCalls();
90+
const expectedDrawCalls = this.expectedSpineDrawCalls;
91+
const status = drawCalls <= expectedDrawCalls ? "OK" : "TOO MANY";
92+
this.debugText.style.color = drawCalls <= expectedDrawCalls ? "#80ff80" : "#ff8080";
93+
return "Spine draw calls: " + drawCalls + " / expected <= " + expectedDrawCalls + " (" + status + ")" +
94+
"\nCamera forceComposite + internal/external filters" +
95+
"\nContainer + invisible skipped children";
96+
})();
97+
}
98+
}
99+
}
100+
101+
new Phaser.Game({
102+
type: Phaser.AUTO,
103+
width: 800,
104+
height: 600,
105+
type: Phaser.WEBGL,
106+
scene: [BatchingContextTest],
107+
plugins: {
108+
scene: [
109+
{
110+
key: "spine.SpinePlugin",
111+
plugin: spine.SpinePlugin,
112+
mapping: "spine",
113+
},
114+
],
115+
},
116+
});
117+
</script>
118+
</html>

‎spine-ts/spine-phaser-v4/src/SpineGameObject.ts‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export class SpineGameObject extends DepthMixin(
336336

337337
if (this.plugin.gl && this.plugin.phaserRenderer instanceof Phaser.Renderer.WebGL.WebGLRenderer && sceneRenderer.batcher.isDrawing) {
338338
sceneRenderer.end();
339+
this.plugin.currentWebGLDrawingContext = null;
339340
this.plugin.phaserRenderer.renderNodes.getNode("RebindContext")?.run();
340341
}
341342
}
@@ -362,6 +363,31 @@ export class SpineGameObject extends DepthMixin(
362363
sceneRenderer.camera.update();
363364
}
364365

366+
private isRenderableInDisplayList (gameObject: Phaser.GameObjects.GameObject | undefined, camera: Phaser.Cameras.Scene2D.Camera) {
367+
if (!gameObject) return false;
368+
369+
const GameObjectRenderMask = 0xf;
370+
return GameObjectRenderMask === gameObject.renderFlags &&
371+
(gameObject.cameraFilter === 0 || !(gameObject.cameraFilter & camera.id)) &&
372+
(gameObject as Phaser.GameObjects.GameObject & { visible?: boolean }).visible !== false;
373+
}
374+
375+
private isAdjacentRenderableSpineGameObject (
376+
src: SpineGameObject,
377+
camera: Phaser.Cameras.Scene2D.Camera,
378+
displayList: Phaser.GameObjects.GameObject[],
379+
displayListIndex: number,
380+
direction: -1 | 1
381+
) {
382+
for (let i = displayListIndex + direction; i >= 0 && i < displayList.length; i += direction) {
383+
const gameObject = displayList[i];
384+
if (!this.isRenderableInDisplayList(gameObject, camera)) continue;
385+
return gameObject.type === src.type;
386+
}
387+
388+
return false;
389+
}
390+
365391
renderWebGL (
366392
renderer: Phaser.Renderer.WebGL.WebGLRenderer,
367393
src: SpineGameObject,
@@ -377,17 +403,21 @@ export class SpineGameObject extends DepthMixin(
377403

378404
let sceneRenderer = src.plugin.webGLRenderer;
379405

380-
// Determine object type in context.
381-
const previousGameObject = displayList[displayListIndex - 1];
382-
const nextGameObject = displayList[displayListIndex + 1];
383-
const newType = !previousGameObject || previousGameObject.type !== src.type;
384-
const nextTypeMatch = nextGameObject && nextGameObject.type === src.type;
385-
const drawingContextChanged = drawingContext.renderer.renderNodes.currentBatchDrawingContext !== drawingContext;
386-
if (newType || drawingContextChanged) {
406+
// Determine object type in context. Some display lists (containers/layers) contain
407+
// invisible or otherwise skipped children, so scan for the adjacent renderable object.
408+
const newType = !src.isAdjacentRenderableSpineGameObject(src, camera, displayList, displayListIndex, -1);
409+
const nextTypeMatch = src.isAdjacentRenderableSpineGameObject(src, camera, displayList, displayListIndex, 1);
410+
// Phaser's currentBatchDrawingContext is null while an external renderer owns the GL context.
411+
// Track the drawing context ourselves so consecutive Spine objects can share one Spine batch.
412+
const drawingContextChanged = src.plugin.currentWebGLDrawingContext !== drawingContext;
413+
const shouldBeginSpineRenderer = newType || drawingContextChanged || !sceneRenderer.batcher.isDrawing;
414+
if (shouldBeginSpineRenderer) {
415+
if (sceneRenderer.batcher.isDrawing) {
416+
sceneRenderer.end();
417+
src.plugin.currentWebGLDrawingContext = null;
418+
}
419+
387420
if (drawingContextChanged) {
388-
if (sceneRenderer.batcher.isDrawing) {
389-
sceneRenderer.end();
390-
}
391421
// Ensure the framebuffer for the active draw pass is properly set up.
392422
drawingContext.renderer.renderNodes.finishBatch();
393423
drawingContext.beginDraw();
@@ -400,6 +430,7 @@ export class SpineGameObject extends DepthMixin(
400430

401431
// Enter Spine renderer.
402432
sceneRenderer.begin();
433+
src.plugin.currentWebGLDrawingContext = drawingContext;
403434
}
404435

405436
camera.addToRenderList(src);
@@ -437,6 +468,7 @@ export class SpineGameObject extends DepthMixin(
437468
if (!nextTypeMatch) {
438469
// Exit Spine renderer.
439470
sceneRenderer.end();
471+
src.plugin.currentWebGLDrawingContext = null;
440472

441473
// Rebind Phaser state.
442474
renderer.renderNodes.getNode('RebindContext')?.run(drawingContext);

‎spine-ts/spine-phaser-v4/src/SpinePlugin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
8383
}
8484
canvasRenderer: SkeletonRenderer | null;
8585
phaserRenderer: Phaser.Renderer.Canvas.CanvasRenderer | Phaser.Renderer.WebGL.WebGLRenderer;
86+
currentWebGLDrawingContext: Phaser.Renderer.WebGL.DrawingContext | null = null;
8687
private skeletonDataCache: Phaser.Cache.BaseCache;
8788
private atlasCache: Phaser.Cache.BaseCache;
8889

@@ -200,6 +201,7 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
200201
this.pluginManager.removeGameObject((window as any).SPINE_GAME_OBJECT_TYPE ? (window as any).SPINE_GAME_OBJECT_TYPE : SPINE_GAME_OBJECT_TYPE, true, true);
201202
if (this.webGLRenderer) this.webGLRenderer.dispose();
202203
this.gameWebGLRenderer = null;
204+
this.currentWebGLDrawingContext = null;
203205
}
204206

205207
/** Returns the TextureAtlas instance for the given key */

0 commit comments

Comments
 (0)