Skip to content

Commit bdeb916

Browse files
committed
[ts][threejs] Fix spine-threejs sorting with negative bone scales. See #2182.
1 parent f16630a commit bdeb916

3 files changed

Lines changed: 252 additions & 5 deletions

File tree

spine-ts/CHANGELOG.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,31 @@
22

33
## Unreleased
44

5+
### spine-threejs
6+
7+
- Set `forceSinglePass` by default for transparent double-sided Spine materials to preserve slot draw order when negative bone scales flip triangle winding. (`ea0d35121`)
8+
59
## 4.3.5 - 2026-05-26
610

711
### spine-core
812

913
- Fixed JSON transform constraint timelines to carry `mixShearY` forward between keyframes, and fixed world-space `ToShearY` scale-axis handling. (`cef55a40d`)
10-
- Port of 71999c27: Fixed draw order timelines not mixing out to setup pose.
11-
- Port of d463f340: Support nonessential slider max.
14+
- Port of 71999c27: Fixed draw order timelines not mixing out to setup pose. (`7f6f47cb4`)
15+
- Port of d463f340: Support nonessential slider max. (`39f6fc167`)
1216

1317
## 4.3.4 - 2026-05-25
1418

1519
### spine-core
1620

17-
- Fixed `SkeletonClipping.clipTrianglesUnpacked()` to update its typed output views before returning from inverse clipping.
21+
- Fixed `SkeletonClipping.clipTrianglesUnpacked()` to update its typed output views before returning from inverse clipping. (`d1981317b`)
1822

1923
### spine-pixi-v8
2024

21-
- Fixed clipped attachments to force a Pixi batch rebuild when triangle indices change without changing the index count, preventing rendering glitches.
25+
- Fixed clipped attachments to force a Pixi batch rebuild when triangle indices change without changing the index count, preventing rendering glitches. (`6fc9a8bd4`)
2226

2327
### spine-ts
2428

25-
- Updated `publish.sh` to prompt for an npm one-time password and pass it to `npm publish`.
29+
- Updated `publish.sh` to prompt for an npm one-time password and pass it to `npm publish`. (`9624f105a`)
2630

2731
## 4.3.3 - 2026-05-21
2832

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<html>
2+
<head>
3+
<meta charset="UTF-8" />
4+
<title>spine-threejs minimal negative-scale sorting repro</title>
5+
<style>
6+
* { margin: 0; padding: 0; box-sizing: border-box; }
7+
body, html { height: 100%; overflow: hidden; background: #202020; color: #fff; font-family: sans-serif; }
8+
canvas { position: absolute; width: 100%; height: 100%; }
9+
#ui {
10+
position: absolute;
11+
z-index: 1;
12+
top: 12px;
13+
left: 12px;
14+
max-width: 640px;
15+
padding: 12px;
16+
background: rgba(0, 0, 0, 0.72);
17+
border-radius: 6px;
18+
line-height: 1.4;
19+
}
20+
#ui code { color: #ffd479; }
21+
#ui label { display: block; margin-top: 8px; }
22+
#labels {
23+
position: absolute;
24+
z-index: 1;
25+
bottom: 18px;
26+
left: 0;
27+
right: 0;
28+
display: flex;
29+
justify-content: space-around;
30+
pointer-events: none;
31+
font-weight: bold;
32+
text-shadow: 0 1px 3px #000;
33+
}
34+
</style>
35+
36+
<script type="importmap">
37+
{
38+
"imports": {
39+
"three": "https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.module.js",
40+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.162.0/examples/jsm/",
41+
"spine-threejs": "../dist/esm/spine-threejs.mjs"
42+
}
43+
}
44+
</script>
45+
</head>
46+
47+
<body>
48+
<div id="ui">
49+
<b>Minimal negative-scale sorting repro</b><br />
50+
Draw order is <code>blue bottom</code>, <code>red middle</code>, <code>green top</code>.
51+
The red rectangle is attached to a child bone. When that bone has <code>scaleX = -1</code>,
52+
red is mirrored, but green should still draw on top of red.
53+
<label><input id="negativeScale" type="checkbox" checked /> negative scale on red bone</label>
54+
<label><input id="depth" type="checkbox" /> depth test/write</label>
55+
</div>
56+
<div id="labels">
57+
<div>default: two-pass transparent DoubleSide</div>
58+
<div>forceSinglePass: true</div>
59+
</div>
60+
61+
<script type="module">
62+
import * as THREE from "three";
63+
import * as spine from "spine-threejs";
64+
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
65+
66+
let scene, camera, renderer, controls, canvas;
67+
let defaultMesh, singlePassMesh;
68+
const negativeScaleCheckbox = document.getElementById("negativeScale");
69+
const depthCheckbox = document.getElementById("depth");
70+
71+
function makeWhiteTexture() {
72+
const canvas = document.createElement("canvas");
73+
canvas.width = 2;
74+
canvas.height = 2;
75+
const ctx = canvas.getContext("2d");
76+
ctx.fillStyle = "white";
77+
ctx.fillRect(0, 0, 2, 2);
78+
return new spine.ThreeJsTexture(canvas, true);
79+
}
80+
81+
function makeRegion(texture) {
82+
const region = new spine.TextureRegion();
83+
region.texture = texture;
84+
region.u = 0;
85+
region.v = 0;
86+
region.u2 = 1;
87+
region.v2 = 1;
88+
region.width = 2;
89+
region.height = 2;
90+
region.originalWidth = 2;
91+
region.originalHeight = 2;
92+
return region;
93+
}
94+
95+
function makeAttachment(name, region, width, height, rgba) {
96+
const sequence = new spine.Sequence(1, false);
97+
sequence.regions[0] = region;
98+
const attachment = new spine.RegionAttachment(name, sequence);
99+
attachment.width = width;
100+
attachment.height = height;
101+
attachment.color.set(rgba[0], rgba[1], rgba[2], rgba[3]);
102+
attachment.updateSequence();
103+
return attachment;
104+
}
105+
106+
function makeSkeletonData() {
107+
const texture = makeWhiteTexture();
108+
const region = makeRegion(texture);
109+
110+
const attachmentLoader = {
111+
newRegionAttachment(skin, placeholder, name, path, sequence) {
112+
sequence.regions[0] = region;
113+
return new spine.RegionAttachment(name, sequence);
114+
},
115+
newMeshAttachment() { return null; },
116+
newBoundingBoxAttachment() { return null; },
117+
newPathAttachment() { return null; },
118+
newPointAttachment() { return null; },
119+
newClippingAttachment() { return null; },
120+
};
121+
122+
const json = new spine.SkeletonJson(attachmentLoader);
123+
return json.readSkeletonData({
124+
skeleton: { spine: "4.3.8" },
125+
bones: [
126+
{ name: "root" },
127+
{ name: "red-bone", parent: "root" },
128+
],
129+
slots: [
130+
{ name: "blue-bottom", bone: "root", attachment: "blue" },
131+
{ name: "red-middle", bone: "red-bone", attachment: "red" },
132+
{ name: "green-top", bone: "root", attachment: "green" },
133+
],
134+
skins: [{
135+
name: "default",
136+
attachments: {
137+
"blue-bottom": {
138+
blue: { type: "region", width: 260, height: 160, color: "0d40ffb8" },
139+
},
140+
"red-middle": {
141+
red: { type: "region", width: 230, height: 80, color: "ff0d05b8" },
142+
},
143+
"green-top": {
144+
green: { type: "region", width: 190, height: 120, color: "00ff26b8" },
145+
},
146+
},
147+
}],
148+
});
149+
}
150+
151+
function createSkeletonMesh(skeletonData, forceSinglePass) {
152+
const mesh = new spine.SkeletonMesh({
153+
skeletonData,
154+
materialFactory: (parameters) => {
155+
const material = new THREE.MeshBasicMaterial({
156+
...parameters,
157+
depthTest: depthCheckbox.checked,
158+
depthWrite: depthCheckbox.checked,
159+
});
160+
material.forceSinglePass = forceSinglePass;
161+
return material;
162+
},
163+
});
164+
mesh.skeleton.setupPose();
165+
return mesh;
166+
}
167+
168+
function updateSpineMesh(mesh) {
169+
const redBone = mesh.skeleton.findBone("red-bone");
170+
redBone.pose.scaleX = negativeScaleCheckbox.checked ? -1 : 1;
171+
redBone.pose.scaleY = 1;
172+
mesh.skeleton.updateWorldTransform(spine.Physics.update);
173+
mesh["updateGeometry"]();
174+
}
175+
176+
function rebuild() {
177+
if (defaultMesh) scene.remove(defaultMesh);
178+
if (singlePassMesh) scene.remove(singlePassMesh);
179+
180+
const skeletonData = makeSkeletonData();
181+
182+
defaultMesh = createSkeletonMesh(skeletonData, false);
183+
defaultMesh.position.x = -180;
184+
scene.add(defaultMesh);
185+
186+
singlePassMesh = createSkeletonMesh(skeletonData, true);
187+
singlePassMesh.position.x = 180;
188+
scene.add(singlePassMesh);
189+
}
190+
191+
function init() {
192+
const width = window.innerWidth;
193+
const height = window.innerHeight;
194+
195+
camera = new THREE.PerspectiveCamera(75, width / height, 1, 3000);
196+
camera.position.set(0, 0, 520);
197+
198+
scene = new THREE.Scene();
199+
scene.background = new THREE.Color(0x202020);
200+
201+
renderer = new THREE.WebGLRenderer({ antialias: true });
202+
renderer.setSize(width, height);
203+
document.body.appendChild(renderer.domElement);
204+
canvas = renderer.domElement;
205+
206+
controls = new OrbitControls(camera, renderer.domElement);
207+
controls.enableDamping = true;
208+
controls.dampingFactor = 0.05;
209+
controls.enablePan = true;
210+
controls.target.set(0, 0, 0);
211+
controls.update();
212+
213+
depthCheckbox.addEventListener("change", rebuild);
214+
rebuild();
215+
requestAnimationFrame(render);
216+
}
217+
218+
function render() {
219+
resize();
220+
controls.update();
221+
updateSpineMesh(defaultMesh);
222+
updateSpineMesh(singlePassMesh);
223+
renderer.render(scene, camera);
224+
requestAnimationFrame(render);
225+
}
226+
227+
function resize() {
228+
const w = window.innerWidth;
229+
const h = window.innerHeight;
230+
if (canvas.width !== w || canvas.height !== h) {
231+
canvas.width = w;
232+
canvas.height = h;
233+
}
234+
camera.aspect = w / h;
235+
camera.updateProjectionMatrix();
236+
renderer.setSize(w, h);
237+
}
238+
239+
init();
240+
</script>
241+
</body>
242+
</html>

spine-ts/spine-threejs/src/SkeletonMesh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export class SkeletonMesh extends THREE.Object3D {
8383
alphaTest: 0.001,
8484
vertexColors: true,
8585
premultipliedAlpha: true,
86+
forceSinglePass: true,
8687
}
8788

8889
tempPos: Vector2 = new Vector2();

0 commit comments

Comments
 (0)