spine-rigc 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rasteriser — one code path for reference frames and for candidates.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ Why this is a module and not a script. `bench/render_reference.ts` renders
|
|
5
|
+
* the official export to the PNG frames an authoring agent is allowed to see;
|
|
6
|
+
* `rigc check` renders the agent's own candidate and compares it against those
|
|
7
|
+
* frames. If those two drew pixels differently, every number `check` reports
|
|
8
|
+
* would carry the difference between two renderers on top of the difference
|
|
9
|
+
* between two rigs — and the second is the only one anybody wants to read. So
|
|
10
|
+
* there is exactly one rasteriser, and both callers are thin.
|
|
11
|
+
*
|
|
12
|
+
* ## What it draws
|
|
13
|
+
*
|
|
14
|
+
* Region and mesh attachments, in draw order, tinted by slot colour x attachment
|
|
15
|
+
* colour. Both are **affine** texture maps and neither divides by w:
|
|
16
|
+
*
|
|
17
|
+
* - a **region** is one quad. `spine-core` computes its four world vertices on
|
|
18
|
+
* the CPU, a destination pixel maps back into the region's rectangle by
|
|
19
|
+
* inverting one 2x2, and there is no triangle split at all;
|
|
20
|
+
* - a **mesh** is a triangle list. `MeshAttachment.computeWorldVertices` does the
|
|
21
|
+
* work — it is the runtime's own routine, so weighted vertices resolve through
|
|
22
|
+
* their bones and a `deform` timeline's offsets are applied, exactly the way a
|
|
23
|
+
* real runtime would. Each triangle is then filled with barycentric UV
|
|
24
|
+
* interpolation.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ **Sampling is bilinear on both paths, and the source is straight alpha.**
|
|
27
|
+
* One filter rather than two is not a detail: `check` measures a candidate
|
|
28
|
+
* against reference frames, and a mesh triangle sampled nearest against a
|
|
29
|
+
* reference sampled bilinear would put a filter difference into the residual
|
|
30
|
+
* where only a rig difference belongs. Bilinear rather than nearest because the
|
|
31
|
+
* region path was already bilinear and the five committed rungs are rendered
|
|
32
|
+
* with it — see `bilinear`.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ **Region rasterising is untouched by the mesh path**, deliberately. A region
|
|
35
|
+
* could be drawn as two triangles and very nearly the same pixels would come out;
|
|
36
|
+
* "very nearly" would have silently rewritten five rungs of committed reference
|
|
37
|
+
* frames. `rasteriseQuad` still owns regions, `rasteriseMesh` owns meshes, and
|
|
38
|
+
* `rasterisePiece` picks.
|
|
39
|
+
*
|
|
40
|
+
* ## The fill rule, and why a mesh needs one
|
|
41
|
+
*
|
|
42
|
+
* Two triangles that share an edge must cover the pixels along it exactly once.
|
|
43
|
+
* Include the boundary in both and every interior edge of a mesh blends twice —
|
|
44
|
+
* a visible lattice of seams wherever the art is not opaque. Exclude it in both
|
|
45
|
+
* and the seams become holes.
|
|
46
|
+
*
|
|
47
|
+
* So `rasteriseMesh` normalises each triangle's winding and applies the standard
|
|
48
|
+
* **top-left rule**: a pixel centre exactly on an edge belongs to the triangle
|
|
49
|
+
* only when that edge is a top or a left one. The two triangles sharing an edge
|
|
50
|
+
* traverse it in opposite directions, so exactly one of them calls it top-left —
|
|
51
|
+
* which is the property that makes the rule watertight without an epsilon.
|
|
52
|
+
*
|
|
53
|
+
* ## Two conventions this file owns
|
|
54
|
+
*
|
|
55
|
+
* - **Spine world is y up; an image is y down.** The projection from world to
|
|
56
|
+
* frame pixels lives in `projector` and nowhere else.
|
|
57
|
+
* - **The framing box is measured at `FRAMING_FPS`, whatever rate frames are
|
|
58
|
+
* written at.** The union of the posed vertices depends on WHICH TIMES you
|
|
59
|
+
* sample, so taking it at the output rate made the viewport a property of the
|
|
60
|
+
* rate: rung 1's `balls` framed to 256x240 at 12 fps and 256x239 at 24 fps.
|
|
61
|
+
* One pixel is enough to be a trap — the two sets look comparable, an author
|
|
62
|
+
* measures a distance in one and a time in the other, and the scale between
|
|
63
|
+
* them is silently off.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ Two notes on where this sits. It imports `spine-core`, which `src/` is
|
|
66
|
+
* otherwise careful about: posing a skeleton *is* running the runtime, and there
|
|
67
|
+
* is no honest way to render one without it. The rule that matters is unchanged
|
|
68
|
+
* — `src/compile.ts` must stay independent of the runtime so the compiler and
|
|
69
|
+
* the gate are not checking each other's assumptions — and this file is neither.
|
|
70
|
+
* It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
|
|
71
|
+
*/
|
|
72
|
+
import {
|
|
73
|
+
AnimationState,
|
|
74
|
+
AnimationStateData,
|
|
75
|
+
AtlasAttachmentLoader,
|
|
76
|
+
MeshAttachment,
|
|
77
|
+
Physics,
|
|
78
|
+
RegionAttachment,
|
|
79
|
+
Skeleton,
|
|
80
|
+
SkeletonJson,
|
|
81
|
+
TextureAtlas,
|
|
82
|
+
TextureAtlasRegion,
|
|
83
|
+
type SkeletonData,
|
|
84
|
+
} from '@esotericsoftware/spine-core';
|
|
85
|
+
import { readFileSync } from 'node:fs';
|
|
86
|
+
import { join } from 'node:path';
|
|
87
|
+
import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
|
|
88
|
+
|
|
89
|
+
/** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
|
|
90
|
+
export const BACKGROUND: RGBA = [232, 232, 232, 255];
|
|
91
|
+
/** Padding around the union bounding box, as a fraction of its long side. */
|
|
92
|
+
export const PAD = 0.04;
|
|
93
|
+
/**
|
|
94
|
+
* Directory a skeleton with no animation writes its one frame into.
|
|
95
|
+
*
|
|
96
|
+
* It cannot collide with an animation's directory, because an animation named
|
|
97
|
+
* `setup` would have to live in a skeleton that has at least one animation, and
|
|
98
|
+
* this name is only ever used when there are none.
|
|
99
|
+
*/
|
|
100
|
+
export const SETUP_POSE_DIR = 'setup';
|
|
101
|
+
/**
|
|
102
|
+
* The sampling rate the ladder's briefs are written against.
|
|
103
|
+
*
|
|
104
|
+
* It is a constant rather than a bare `12` in the default because it is also the
|
|
105
|
+
* rate at which the directory name says nothing: a rung rendered at the protocol
|
|
106
|
+
* rate writes `<animation>/`, and any other rate writes `<animation>@<fps>fps/`.
|
|
107
|
+
*/
|
|
108
|
+
export const PROTOCOL_FPS = 12;
|
|
109
|
+
/** The rate the framing box is measured at, whatever `--fps` writes frames at. */
|
|
110
|
+
export const FRAMING_FPS = 60;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// the frame-set sidecar
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
//
|
|
116
|
+
// ⭐ A rendered frame set is a picture of a world box, and the box used to be
|
|
117
|
+
// nowhere. That cost two things. An author measuring a distance in pixels had no
|
|
118
|
+
// way to turn it into the units a rig is authored in except by finding something
|
|
119
|
+
// of a known size in the shot; and nothing could render a SECOND skeleton onto
|
|
120
|
+
// the same pixel grid, because the grid was a number that existed only inside one
|
|
121
|
+
// run of `render_reference.ts`. `frames.json` writes it down.
|
|
122
|
+
|
|
123
|
+
/** The sidecar's file name and format tag. */
|
|
124
|
+
export const FRAMES_SIDECAR = 'frames.json';
|
|
125
|
+
export const FRAMES_SPEC = 'rigc-frames/1';
|
|
126
|
+
|
|
127
|
+
/** One rendered frame directory: which animation, at what rate, and what is on disk. */
|
|
128
|
+
export interface FrameSet {
|
|
129
|
+
/** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
|
|
130
|
+
dir: string;
|
|
131
|
+
/** The animation these frames show, or `null` for a skeleton with none. */
|
|
132
|
+
animation: string | null;
|
|
133
|
+
fps: number;
|
|
134
|
+
/** How many frames the animation sampled to at this rate. */
|
|
135
|
+
sampled: number;
|
|
136
|
+
/** How many were actually written (a stride writes fewer). */
|
|
137
|
+
written: number;
|
|
138
|
+
stride: number;
|
|
139
|
+
/**
|
|
140
|
+
* The last sampled frame's time, in seconds.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ Which indices are on disk is deliberately NOT recorded here. The
|
|
143
|
+
* directory is the only author of that fact, and a second copy of it in this
|
|
144
|
+
* file could only ever be the stale one.
|
|
145
|
+
*/
|
|
146
|
+
duration: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface FramesSidecar {
|
|
150
|
+
spec: string;
|
|
151
|
+
example?: string;
|
|
152
|
+
rung?: string;
|
|
153
|
+
skeleton?: string;
|
|
154
|
+
/** The colour the frames were cleared to, straight RGBA 0..255. */
|
|
155
|
+
background: RGBA;
|
|
156
|
+
viewport: {
|
|
157
|
+
/** World box, y up, matching Spine's own coordinates. */
|
|
158
|
+
x: number;
|
|
159
|
+
y: number;
|
|
160
|
+
width: number;
|
|
161
|
+
height: number;
|
|
162
|
+
/** Frame pixels per world unit. */
|
|
163
|
+
scale: number;
|
|
164
|
+
pixelWidth: number;
|
|
165
|
+
pixelHeight: number;
|
|
166
|
+
};
|
|
167
|
+
sets: FrameSet[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// posing
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** What every drawable has in common, whatever shape it is. */
|
|
175
|
+
export interface PieceCommon {
|
|
176
|
+
/**
|
|
177
|
+
* World-space vertex positions, `x, y` per vertex.
|
|
178
|
+
*
|
|
179
|
+
* ⭐ The one field the framing code reads, and the reason it is spelled the
|
|
180
|
+
* same on both shapes: a union over "every posed point" is a loop over this
|
|
181
|
+
* array in steps of two, and it does not need to know whether four numbers are
|
|
182
|
+
* a rectangle's corners or two hundred are a mesh's hull.
|
|
183
|
+
*/
|
|
184
|
+
world: number[];
|
|
185
|
+
/** Slot colour x attachment colour, straight alpha, 0..1. */
|
|
186
|
+
tint: [number, number, number, number];
|
|
187
|
+
/** The slot this was drawn for — what per-slot tracking is keyed by. */
|
|
188
|
+
slot: string;
|
|
189
|
+
/** The atlas page name this samples, so a multi-page atlas resolves. */
|
|
190
|
+
page: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface Quad extends PieceCommon {
|
|
194
|
+
kind: 'region';
|
|
195
|
+
/** World-space corners, in spine-core's region order: br, bl, ul, ur. */
|
|
196
|
+
world: number[];
|
|
197
|
+
/** Page UVs for the same four corners. */
|
|
198
|
+
uvs: ArrayLike<number>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A posed mesh attachment: world vertices, page UVs, and the triangulation.
|
|
203
|
+
*
|
|
204
|
+
* The vertices arrive from `MeshAttachment.computeWorldVertices`, which is the
|
|
205
|
+
* runtime's own routine and therefore the only place the weighting and deform
|
|
206
|
+
* arithmetic lives. Reimplementing either here would give `check` a second
|
|
207
|
+
* opinion about where a vertex is, and a second opinion is exactly what a gate
|
|
208
|
+
* must not have.
|
|
209
|
+
*/
|
|
210
|
+
export interface Mesh extends PieceCommon {
|
|
211
|
+
kind: 'mesh';
|
|
212
|
+
/** Page UVs, `u, v` per vertex, parallel to `world`. */
|
|
213
|
+
uvs: ArrayLike<number>;
|
|
214
|
+
/** Vertex index triplets. */
|
|
215
|
+
triangles: ArrayLike<number>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One drawable in a posed frame. */
|
|
219
|
+
export type Piece = Quad | Mesh;
|
|
220
|
+
|
|
221
|
+
export interface Frame {
|
|
222
|
+
/** Index within the sampled sequence — the number in `f0000.png`. */
|
|
223
|
+
index: number;
|
|
224
|
+
time: number;
|
|
225
|
+
/**
|
|
226
|
+
* Everything the frame draws, in draw order.
|
|
227
|
+
*
|
|
228
|
+
* Named `pieces` rather than `quads` since meshes joined it: a mesh is not a
|
|
229
|
+
* quad, and a field that says otherwise is the kind of name a reader trusts
|
|
230
|
+
* and then indexes `world[6]` through.
|
|
231
|
+
*/
|
|
232
|
+
pieces: Piece[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Where the world sits in a frame: the four world numbers plus the scale. */
|
|
236
|
+
export interface Viewport {
|
|
237
|
+
minX: number;
|
|
238
|
+
minY: number;
|
|
239
|
+
maxX: number;
|
|
240
|
+
maxY: number;
|
|
241
|
+
/** Frame pixels per world unit. */
|
|
242
|
+
scale: number;
|
|
243
|
+
/** Frame size in pixels. */
|
|
244
|
+
width: number;
|
|
245
|
+
height: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A loaded skeleton and every atlas page it can sample, keyed by page name. */
|
|
249
|
+
export interface Posable {
|
|
250
|
+
data: SkeletonData;
|
|
251
|
+
pages: Map<string, Plate>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Load a skeleton, its atlas and every page the atlas declares.
|
|
256
|
+
*
|
|
257
|
+
* Every page, not the first: rigc emits **one part per page**, so a rigc
|
|
258
|
+
* candidate for rung 1 has eight of them. `render_reference.ts` used to insist
|
|
259
|
+
* on exactly one because an editor export packs into one — that assumption is
|
|
260
|
+
* true of the reference and false of every candidate, and a renderer both sides
|
|
261
|
+
* share cannot hold it.
|
|
262
|
+
*/
|
|
263
|
+
export function loadPosable(skeletonPath: string, atlasPath: string, atlasDir: string): Posable {
|
|
264
|
+
return posableFromText(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), atlasDir);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Same, for artifacts held in memory rather than on disk. */
|
|
268
|
+
export function posableFromText(skeletonText: string, atlasText: string, atlasDir: string): Posable {
|
|
269
|
+
const atlas = new TextureAtlas(atlasText);
|
|
270
|
+
const pages = new Map<string, Plate>();
|
|
271
|
+
for (const page of atlas.pages) pages.set(page.name, readPlate(join(atlasDir, page.name)));
|
|
272
|
+
const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(JSON.parse(skeletonText));
|
|
273
|
+
return { data, pages };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Sample one animation at a fixed rate and collect the posed pieces per frame.
|
|
278
|
+
*
|
|
279
|
+
* The pose is driven through `AnimationState` rather than `Animation.apply`
|
|
280
|
+
* because that is the path a runtime actually takes, and 4.3's `Animation.apply`
|
|
281
|
+
* takes a `MixFrom` that only the state machine has any business choosing.
|
|
282
|
+
*/
|
|
283
|
+
export function sampleAnimation(data: SkeletonData, name: string, fps: number): Frame[] {
|
|
284
|
+
const animation = data.findAnimation(name);
|
|
285
|
+
if (!animation) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`no animation "${name}" in this skeleton; it has [${data.animations.map((a) => a.name).join(', ') || 'none'}]`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const skeleton = new Skeleton(data);
|
|
291
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
292
|
+
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
293
|
+
// entry would wrap it back onto the first pose.
|
|
294
|
+
state.setAnimation(0, name, false);
|
|
295
|
+
skeleton.setupPose();
|
|
296
|
+
|
|
297
|
+
const step = 1 / fps;
|
|
298
|
+
const count = Math.round(animation.duration * fps);
|
|
299
|
+
const frames: Frame[] = [];
|
|
300
|
+
for (let i = 0; i <= count; i++) {
|
|
301
|
+
if (i > 0) {
|
|
302
|
+
state.update(step);
|
|
303
|
+
state.apply(skeleton);
|
|
304
|
+
skeleton.update(step);
|
|
305
|
+
skeleton.updateWorldTransform(Physics.update);
|
|
306
|
+
} else {
|
|
307
|
+
state.apply(skeleton);
|
|
308
|
+
skeleton.update(0);
|
|
309
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
310
|
+
}
|
|
311
|
+
frames.push({ index: i, time: i * step, pieces: piecesOf(skeleton) });
|
|
312
|
+
}
|
|
313
|
+
return frames;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The setup pose as a single frame — what a skeleton with **no animation at all**
|
|
318
|
+
* looks like.
|
|
319
|
+
*
|
|
320
|
+
* ⭐ Not a degenerate case to be tolerated: a static rig is a deliverable. The
|
|
321
|
+
* ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
|
|
322
|
+
* whole content is the setup pose.
|
|
323
|
+
*/
|
|
324
|
+
export function sampleSetupPose(data: SkeletonData): Frame[] {
|
|
325
|
+
const skeleton = new Skeleton(data);
|
|
326
|
+
skeleton.setupPose();
|
|
327
|
+
skeleton.update(0);
|
|
328
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
329
|
+
return [{ index: 0, time: 0, pieces: piecesOf(skeleton) }];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Every animation of one skeleton at one rate, keyed by the name its frames are
|
|
334
|
+
* filed under. A skeleton with no animation at all contributes its setup pose
|
|
335
|
+
* under `SETUP_POSE_DIR`.
|
|
336
|
+
*/
|
|
337
|
+
export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]> {
|
|
338
|
+
const out = new Map<string, Frame[]>();
|
|
339
|
+
if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data));
|
|
340
|
+
else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps));
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The posed drawables of one frame, in draw order.
|
|
346
|
+
*
|
|
347
|
+
* Regions and meshes take the same three steps — resolve the sequence index,
|
|
348
|
+
* ask `spine-core` for the world vertices, read the page UVs back off the same
|
|
349
|
+
* sequence — and differ only in which runtime call does step two. An attachment
|
|
350
|
+
* type that is neither is skipped rather than refused: a bounding box, a point
|
|
351
|
+
* and a clipping attachment are all things a rig legitimately carries and none
|
|
352
|
+
* of them draws a pixel.
|
|
353
|
+
*/
|
|
354
|
+
export function piecesOf(skeleton: Skeleton): Piece[] {
|
|
355
|
+
const pieces: Piece[] = [];
|
|
356
|
+
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
357
|
+
const pose = slot.appliedPose;
|
|
358
|
+
const attachment = pose.attachment;
|
|
359
|
+
if (!attachment) continue;
|
|
360
|
+
const isMesh = attachment instanceof MeshAttachment;
|
|
361
|
+
if (!isMesh && !(attachment instanceof RegionAttachment)) continue;
|
|
362
|
+
|
|
363
|
+
const index = attachment.sequence.resolveIndex(pose);
|
|
364
|
+
const region = attachment.sequence.regions[index];
|
|
365
|
+
if (!(region instanceof TextureAtlasRegion)) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`slot "${slot.data.name}" attachment "${attachment.name}" resolved to no atlas region; ` +
|
|
368
|
+
'the attachment names a region the atlas does not have',
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const colour = pose.color;
|
|
372
|
+
const own = attachment.color;
|
|
373
|
+
const tint: [number, number, number, number] = [
|
|
374
|
+
colour.r * own.r,
|
|
375
|
+
colour.g * own.g,
|
|
376
|
+
colour.b * own.b,
|
|
377
|
+
colour.a * own.a,
|
|
378
|
+
];
|
|
379
|
+
const common = { tint, slot: slot.data.name, page: region.page.name };
|
|
380
|
+
|
|
381
|
+
if (isMesh) {
|
|
382
|
+
// `worldVerticesLength` is 2 per vertex whether or not the mesh is
|
|
383
|
+
// weighted — the weight runs live in `vertices`, not here — so this is the
|
|
384
|
+
// full output length and the whole mesh is computed in one call. Deform
|
|
385
|
+
// offsets, if the pose carries any, are applied inside it.
|
|
386
|
+
const world = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
387
|
+
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
|
|
388
|
+
pieces.push({ kind: 'mesh', ...common, world, uvs: attachment.sequence.getUVs(index), triangles: attachment.triangles });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const world = new Array<number>(8).fill(0);
|
|
393
|
+
attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
|
|
394
|
+
pieces.push({ kind: 'region', ...common, world, uvs: attachment.sequence.getUVs(index) });
|
|
395
|
+
}
|
|
396
|
+
return pieces;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// framing
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The world-space box every posed vertex of these frames fits inside.
|
|
405
|
+
*
|
|
406
|
+
* `world.length` rather than a literal 8: a region contributes its four corners
|
|
407
|
+
* and a mesh every one of its vertices, and the loop does not need to know which
|
|
408
|
+
* it is holding.
|
|
409
|
+
*/
|
|
410
|
+
export function unionBounds(frameSets: Iterable<Frame[]>): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
411
|
+
let minX = Infinity;
|
|
412
|
+
let minY = Infinity;
|
|
413
|
+
let maxX = -Infinity;
|
|
414
|
+
let maxY = -Infinity;
|
|
415
|
+
for (const frames of frameSets) {
|
|
416
|
+
for (const frame of frames) {
|
|
417
|
+
for (const piece of frame.pieces) {
|
|
418
|
+
for (let i = 0; i < piece.world.length; i += 2) {
|
|
419
|
+
minX = Math.min(minX, piece.world[i]);
|
|
420
|
+
maxX = Math.max(maxX, piece.world[i]);
|
|
421
|
+
minY = Math.min(minY, piece.world[i + 1]);
|
|
422
|
+
maxY = Math.max(maxY, piece.world[i + 1]);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { minX, minY, maxX, maxY };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The opaque sub-rectangle of one quad's region, in the quad's own `(s, t)`.
|
|
432
|
+
*
|
|
433
|
+
* `(0,0)` is the region's bottom-left corner and `(1,1)` its top-right, so a trim
|
|
434
|
+
* of `{0, 0, 1, 1}` is a region whose art fills it and anything smaller is the
|
|
435
|
+
* transparent margin the art was exported with.
|
|
436
|
+
*/
|
|
437
|
+
export interface RegionTrim {
|
|
438
|
+
minS: number;
|
|
439
|
+
minT: number;
|
|
440
|
+
maxS: number;
|
|
441
|
+
maxT: number;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Where a quad's artwork actually is, as opposed to where its rectangle is.
|
|
446
|
+
*
|
|
447
|
+
* ⭐ This is what stops an invisible margin from being able to move anything. A
|
|
448
|
+
* region attachment's quad is the whole PNG, transparent border included, so two
|
|
449
|
+
* exports of the same drawing with different margins pose to different quads and
|
|
450
|
+
* frame themselves differently — which is how rung 5 reported MAE 39.00 for a rig
|
|
451
|
+
* whose every key was right (issue #34). Trimming to the opaque texels makes the
|
|
452
|
+
* box a property of the drawing.
|
|
453
|
+
*
|
|
454
|
+
* Alpha above zero rather than the rasteriser's coverage threshold, deliberately:
|
|
455
|
+
* this is the box that has to CONTAIN the drawing, and a box that is a texel too
|
|
456
|
+
* generous costs nothing while one that is a texel short clips.
|
|
457
|
+
*
|
|
458
|
+
* `cache` is keyed by page and region rectangle, because a scan per quad per frame
|
|
459
|
+
* would be a scan per quad per frame.
|
|
460
|
+
*/
|
|
461
|
+
export function regionTrim(page: Plate, quad: Quad, cache: Map<string, RegionTrim | null>): RegionTrim | null {
|
|
462
|
+
const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
|
|
463
|
+
const key = `${quad.page}|${ubr},${vbr},${ubl},${vbl},${uul},${vul}`;
|
|
464
|
+
const seen = cache.get(key);
|
|
465
|
+
if (seen !== undefined) return seen;
|
|
466
|
+
|
|
467
|
+
const ox = ubl * page.width;
|
|
468
|
+
const oy = vbl * page.height;
|
|
469
|
+
const ex = [(ubr - ubl) * page.width, (vbr - vbl) * page.height];
|
|
470
|
+
const ey = [(uul - ubl) * page.width, (vul - vbl) * page.height];
|
|
471
|
+
const det = ex[0] * ey[1] - ex[1] * ey[0];
|
|
472
|
+
if (Math.abs(det) < 1e-9) {
|
|
473
|
+
cache.set(key, null);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const corners = [
|
|
477
|
+
[ox, oy],
|
|
478
|
+
[ox + ex[0], oy + ex[1]],
|
|
479
|
+
[ox + ey[0], oy + ey[1]],
|
|
480
|
+
[ox + ex[0] + ey[0], oy + ex[1] + ey[1]],
|
|
481
|
+
];
|
|
482
|
+
const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
|
|
483
|
+
const x1 = Math.min(page.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
|
|
484
|
+
const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
|
|
485
|
+
const y1 = Math.min(page.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
|
|
486
|
+
|
|
487
|
+
let minS = Infinity;
|
|
488
|
+
let minT = Infinity;
|
|
489
|
+
let maxS = -Infinity;
|
|
490
|
+
let maxT = -Infinity;
|
|
491
|
+
for (let y = y0; y <= y1; y++) {
|
|
492
|
+
for (let x = x0; x <= x1; x++) {
|
|
493
|
+
if (page.get(x, y)[3] === 0) continue;
|
|
494
|
+
const rx = x + 0.5 - ox;
|
|
495
|
+
const ry = y + 0.5 - oy;
|
|
496
|
+
const s = (rx * ey[1] - ry * ey[0]) / det;
|
|
497
|
+
const t = (ex[0] * ry - ex[1] * rx) / det;
|
|
498
|
+
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
|
|
499
|
+
if (s < minS) minS = s;
|
|
500
|
+
if (s > maxS) maxS = s;
|
|
501
|
+
if (t < minT) minT = t;
|
|
502
|
+
if (t > maxT) maxT = t;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const trim = Number.isFinite(minS) ? { minS, minT, maxS, maxT } : null;
|
|
506
|
+
cache.set(key, trim);
|
|
507
|
+
return trim;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The world box every piece's **artwork** fits inside, over these frames.
|
|
512
|
+
*
|
|
513
|
+
* The same union as `unionBounds`, taken over the trimmed rectangles instead of
|
|
514
|
+
* the quads. It is a starting box for `check`'s framing and nothing more — the
|
|
515
|
+
* framing itself is fitted on rendered pixels — but the start has to be free of
|
|
516
|
+
* transparent margins too, or the path the fit takes still depends on them.
|
|
517
|
+
*
|
|
518
|
+
* ⚠️ **A mesh contributes its raw vertices and is not trimmed.** The trim exists
|
|
519
|
+
* because a region attachment's quad is the whole PNG, transparent border and
|
|
520
|
+
* all, so its corners sit where no pixel is. A mesh's hull is authored *onto the
|
|
521
|
+
* drawing* — that is what makes it a mesh — so its vertices already are where the
|
|
522
|
+
* artwork is, and there is no rectangle to invert a margin out of. Passing a
|
|
523
|
+
* triangle fan through the rectangle trim would not be a better estimate of the
|
|
524
|
+
* same box; it would be a different box, computed from a rectangle the mesh does
|
|
525
|
+
* not have.
|
|
526
|
+
*/
|
|
527
|
+
export function trimmedUnionBounds(
|
|
528
|
+
frameSets: Iterable<Frame[]>,
|
|
529
|
+
pages: Map<string, Plate>,
|
|
530
|
+
): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
531
|
+
const cache = new Map<string, RegionTrim | null>();
|
|
532
|
+
let minX = Infinity;
|
|
533
|
+
let minY = Infinity;
|
|
534
|
+
let maxX = -Infinity;
|
|
535
|
+
let maxY = -Infinity;
|
|
536
|
+
const see = (x: number, y: number): void => {
|
|
537
|
+
if (x < minX) minX = x;
|
|
538
|
+
if (x > maxX) maxX = x;
|
|
539
|
+
if (y < minY) minY = y;
|
|
540
|
+
if (y > maxY) maxY = y;
|
|
541
|
+
};
|
|
542
|
+
for (const frames of frameSets) {
|
|
543
|
+
for (const frame of frames) {
|
|
544
|
+
for (const piece of frame.pieces) {
|
|
545
|
+
if (piece.kind === 'mesh') {
|
|
546
|
+
for (let i = 0; i < piece.world.length; i += 2) see(piece.world[i], piece.world[i + 1]);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const quad = piece;
|
|
550
|
+
const [brx, bry, blx, bly, ulx, uly] = quad.world;
|
|
551
|
+
const trim = regionTrim(pageFor(pages, quad), quad, cache);
|
|
552
|
+
if (!trim) {
|
|
553
|
+
for (let i = 0; i < 8; i += 2) see(quad.world[i], quad.world[i + 1]);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const ex = [brx - blx, bry - bly];
|
|
557
|
+
const ey = [ulx - blx, uly - bly];
|
|
558
|
+
for (const [s, t] of [
|
|
559
|
+
[trim.minS, trim.minT],
|
|
560
|
+
[trim.maxS, trim.minT],
|
|
561
|
+
[trim.minS, trim.maxT],
|
|
562
|
+
[trim.maxS, trim.maxT],
|
|
563
|
+
]) {
|
|
564
|
+
see(blx + s * ex[0] + t * ey[0], bly + s * ex[1] + t * ey[1]);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { minX, minY, maxX, maxY };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
|
|
574
|
+
* scaled so the long side is `maxSide` pixels.
|
|
575
|
+
*
|
|
576
|
+
* Measuring the box densely and once makes the framing a property of the SHOT,
|
|
577
|
+
* so every rate of one skeleton lands on the same pixels.
|
|
578
|
+
*/
|
|
579
|
+
export function framingViewport(data: SkeletonData, maxSide: number): Viewport | null {
|
|
580
|
+
const sets =
|
|
581
|
+
data.animations.length === 0
|
|
582
|
+
? [sampleSetupPose(data)]
|
|
583
|
+
: data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS));
|
|
584
|
+
const box = unionBounds(sets);
|
|
585
|
+
if (!Number.isFinite(box.minX)) return null;
|
|
586
|
+
const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
|
|
587
|
+
return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** A viewport over an explicit world box, scaled so its long side is `maxSide`. */
|
|
591
|
+
export function viewportFor(minX: number, minY: number, maxX: number, maxY: number, maxSide: number): Viewport {
|
|
592
|
+
const scale = maxSide / Math.max(maxX - minX, maxY - minY);
|
|
593
|
+
return {
|
|
594
|
+
minX,
|
|
595
|
+
minY,
|
|
596
|
+
maxX,
|
|
597
|
+
maxY,
|
|
598
|
+
scale,
|
|
599
|
+
width: Math.max(1, Math.round((maxX - minX) * scale)),
|
|
600
|
+
height: Math.max(1, Math.round((maxY - minY) * scale)),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* A viewport over an explicit world box whose pixel size is already known.
|
|
606
|
+
*
|
|
607
|
+
* This is the shape `check` needs: the frames on disk fix the pixel size, and
|
|
608
|
+
* re-deriving it from the box would round to a different integer and silently
|
|
609
|
+
* shift every measurement by up to half a pixel.
|
|
610
|
+
*/
|
|
611
|
+
export function viewportOfSize(
|
|
612
|
+
minX: number,
|
|
613
|
+
minY: number,
|
|
614
|
+
width: number,
|
|
615
|
+
height: number,
|
|
616
|
+
scale: number,
|
|
617
|
+
pixelWidth: number,
|
|
618
|
+
pixelHeight: number,
|
|
619
|
+
): Viewport {
|
|
620
|
+
return { minX, minY, maxX: minX + width, maxY: minY + height, scale, width: pixelWidth, height: pixelHeight };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** World (y up) to frame pixels (y down). The only place that conversion lives. */
|
|
624
|
+
export function projector(v: Viewport): (wx: number, wy: number) => [number, number] {
|
|
625
|
+
return (wx, wy) => [(wx - v.minX) * v.scale, (v.maxY - wy) * v.scale];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
// rasterising
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Walk the destination pixels one affine quad covers, sampling the page.
|
|
634
|
+
*
|
|
635
|
+
* The quad is an affine image of the region's rectangle, so a destination pixel
|
|
636
|
+
* maps back to a (s, t) inside it by inverting one 2x2 — no perspective divide,
|
|
637
|
+
* no triangle split. `emit` is called for every covered pixel whose composited
|
|
638
|
+
* alpha clears the coverage threshold, which is what makes "draw it" and
|
|
639
|
+
* "measure where it landed" the same traversal rather than two that can drift.
|
|
640
|
+
*/
|
|
641
|
+
export function rasteriseQuad(
|
|
642
|
+
page: Plate,
|
|
643
|
+
quad: Quad,
|
|
644
|
+
project: (wx: number, wy: number) => [number, number],
|
|
645
|
+
clip: { width: number; height: number },
|
|
646
|
+
emit: (px: number, py: number, r: number, g: number, b: number, a: number) => void,
|
|
647
|
+
): void {
|
|
648
|
+
// spine-core's region order is br, bl, ul, ur.
|
|
649
|
+
const [brx, bry, blx, bly, ulx, uly] = quad.world;
|
|
650
|
+
const bl = project(blx, bly);
|
|
651
|
+
const br = project(brx, bry);
|
|
652
|
+
const ul = project(ulx, uly);
|
|
653
|
+
const ex = [br[0] - bl[0], br[1] - bl[1]];
|
|
654
|
+
const ey = [ul[0] - bl[0], ul[1] - bl[1]];
|
|
655
|
+
const det = ex[0] * ey[1] - ex[1] * ey[0];
|
|
656
|
+
if (Math.abs(det) < 1e-9) return; // degenerate: zero scale, nothing to draw
|
|
657
|
+
const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
|
|
658
|
+
|
|
659
|
+
const corners = [bl, br, ul, [br[0] + ey[0], br[1] + ey[1]]];
|
|
660
|
+
const minX = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
|
|
661
|
+
const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
|
|
662
|
+
const minY = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
|
|
663
|
+
const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
|
|
664
|
+
|
|
665
|
+
for (let py = minY; py <= maxY; py++) {
|
|
666
|
+
for (let px = minX; px <= maxX; px++) {
|
|
667
|
+
const rx = px + 0.5 - bl[0];
|
|
668
|
+
const ry = py + 0.5 - bl[1];
|
|
669
|
+
const s = (rx * ey[1] - ry * ey[0]) / det;
|
|
670
|
+
const t = (ex[0] * ry - ex[1] * rx) / det;
|
|
671
|
+
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
|
|
672
|
+
const u = ubl + s * (ubr - ubl) + t * (uul - ubl);
|
|
673
|
+
const v = vbl + s * (vbr - vbl) + t * (vul - vbl);
|
|
674
|
+
const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
|
|
675
|
+
const alpha = sample[3] * quad.tint[3];
|
|
676
|
+
if (alpha <= 0.5) continue;
|
|
677
|
+
emit(
|
|
678
|
+
px,
|
|
679
|
+
py,
|
|
680
|
+
Math.round(sample[0] * quad.tint[0]),
|
|
681
|
+
Math.round(sample[1] * quad.tint[1]),
|
|
682
|
+
Math.round(sample[2] * quad.tint[2]),
|
|
683
|
+
Math.round(alpha),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** A destination pixel and the straight-alpha colour a piece put there. */
|
|
690
|
+
export type EmitPixel = (px: number, py: number, r: number, g: number, b: number, a: number) => void;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Is this edge a top or a left one, for the winding `rasteriseMesh` normalises to?
|
|
694
|
+
*
|
|
695
|
+
* Derived rather than copied, because the answer depends on the sign convention
|
|
696
|
+
* of the edge function and the direction of y. With `edge(p) = dx·(py−y0) −
|
|
697
|
+
* dy·(px−x0)` and y pointing **down**, the triangle `(0,0) → (1,0) → (0,1)` has
|
|
698
|
+
* positive area, and its horizontal edge `(0,0) → (1,0)` — `dx > 0`, `dy = 0` —
|
|
699
|
+
* is the one along its top. Its `(0,1) → (0,0)` edge — `dy < 0`, going up — is
|
|
700
|
+
* the one down its left.
|
|
701
|
+
*
|
|
702
|
+
* What actually makes the rule watertight needs neither of those facts: the two
|
|
703
|
+
* triangles sharing an edge traverse it in opposite directions, so `dy < 0` holds
|
|
704
|
+
* for exactly one of them, and when `dy` is 0 for both, `dx > 0` holds for
|
|
705
|
+
* exactly one. Every shared edge is therefore claimed once. Getting the
|
|
706
|
+
* orientation right on top of that is what keeps the classic meaning — a pixel
|
|
707
|
+
* centre on a boundary belongs to the triangle below-right of it.
|
|
708
|
+
*/
|
|
709
|
+
function isTopLeftEdge(dx: number, dy: number): boolean {
|
|
710
|
+
return dy < 0 || (dy === 0 && dx > 0);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Walk the destination pixels one posed mesh covers, sampling the page.
|
|
715
|
+
*
|
|
716
|
+
* Each triangle is filled independently with barycentric UV interpolation and no
|
|
717
|
+
* perspective divide — a Spine mesh is a flat 2D deformation, so its UVs are
|
|
718
|
+
* affine in screen space and there is no `w` to divide by. The winding is
|
|
719
|
+
* normalised per triangle (a mesh's triangles are not guaranteed to agree, and a
|
|
720
|
+
* bone with negative scale flips them all anyway), and the top-left rule then
|
|
721
|
+
* makes every interior edge belong to exactly one of the two triangles that
|
|
722
|
+
* share it.
|
|
723
|
+
*
|
|
724
|
+
* `emit` has the same contract as `rasteriseQuad`'s — every covered pixel whose
|
|
725
|
+
* composited alpha clears the same 0.5 threshold — so "draw it" and "measure
|
|
726
|
+
* where it landed" stay one traversal for meshes exactly as they are for regions.
|
|
727
|
+
*/
|
|
728
|
+
export function rasteriseMesh(
|
|
729
|
+
page: Plate,
|
|
730
|
+
mesh: Mesh,
|
|
731
|
+
project: (wx: number, wy: number) => [number, number],
|
|
732
|
+
clip: { width: number; height: number },
|
|
733
|
+
emit: EmitPixel,
|
|
734
|
+
): void {
|
|
735
|
+
const count = mesh.world.length / 2;
|
|
736
|
+
// Project once per vertex, not once per triangle: an interior vertex of a
|
|
737
|
+
// 40-vertex hull belongs to half a dozen triangles, and projecting it six times
|
|
738
|
+
// invites six answers the moment anything about `project` stops being exact.
|
|
739
|
+
const px = new Float64Array(count);
|
|
740
|
+
const py = new Float64Array(count);
|
|
741
|
+
for (let i = 0; i < count; i++) {
|
|
742
|
+
const [x, y] = project(mesh.world[i * 2], mesh.world[i * 2 + 1]);
|
|
743
|
+
px[i] = x;
|
|
744
|
+
py[i] = y;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
|
|
748
|
+
let i0 = mesh.triangles[t];
|
|
749
|
+
let i1 = mesh.triangles[t + 1];
|
|
750
|
+
const i2 = mesh.triangles[t + 2];
|
|
751
|
+
let area = (px[i1] - px[i0]) * (py[i2] - py[i0]) - (py[i1] - py[i0]) * (px[i2] - px[i0]);
|
|
752
|
+
if (area === 0) continue; // degenerate: a zero-height triangle covers nothing
|
|
753
|
+
if (area < 0) {
|
|
754
|
+
const swap = i0;
|
|
755
|
+
i0 = i1;
|
|
756
|
+
i1 = swap;
|
|
757
|
+
area = -area;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const x0 = px[i0];
|
|
761
|
+
const y0 = py[i0];
|
|
762
|
+
const x1 = px[i1];
|
|
763
|
+
const y1 = py[i1];
|
|
764
|
+
const x2 = px[i2];
|
|
765
|
+
const y2 = py[i2];
|
|
766
|
+
const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
767
|
+
const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(x0, x1, x2)));
|
|
768
|
+
const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
769
|
+
const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(y0, y1, y2)));
|
|
770
|
+
if (maxX < minX || maxY < minY) continue;
|
|
771
|
+
|
|
772
|
+
// Edge `k` is the one opposite vertex `k`, so its edge function IS the
|
|
773
|
+
// unnormalised barycentric weight of that vertex.
|
|
774
|
+
const topLeft0 = isTopLeftEdge(x2 - x1, y2 - y1);
|
|
775
|
+
const topLeft1 = isTopLeftEdge(x0 - x2, y0 - y2);
|
|
776
|
+
const topLeft2 = isTopLeftEdge(x1 - x0, y1 - y0);
|
|
777
|
+
|
|
778
|
+
const u0 = mesh.uvs[i0 * 2];
|
|
779
|
+
const v0 = mesh.uvs[i0 * 2 + 1];
|
|
780
|
+
const u1 = mesh.uvs[i1 * 2];
|
|
781
|
+
const v1 = mesh.uvs[i1 * 2 + 1];
|
|
782
|
+
const u2 = mesh.uvs[i2 * 2];
|
|
783
|
+
const v2 = mesh.uvs[i2 * 2 + 1];
|
|
784
|
+
|
|
785
|
+
for (let y = minY; y <= maxY; y++) {
|
|
786
|
+
const sy = y + 0.5;
|
|
787
|
+
for (let x = minX; x <= maxX; x++) {
|
|
788
|
+
const sx = x + 0.5;
|
|
789
|
+
const w0 = (x2 - x1) * (sy - y1) - (y2 - y1) * (sx - x1);
|
|
790
|
+
if (topLeft0 ? w0 < 0 : w0 <= 0) continue;
|
|
791
|
+
const w1 = (x0 - x2) * (sy - y2) - (y0 - y2) * (sx - x2);
|
|
792
|
+
if (topLeft1 ? w1 < 0 : w1 <= 0) continue;
|
|
793
|
+
const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
|
|
794
|
+
if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
|
|
795
|
+
|
|
796
|
+
const b0 = w0 / area;
|
|
797
|
+
const b1 = w1 / area;
|
|
798
|
+
const b2 = w2 / area;
|
|
799
|
+
const u = b0 * u0 + b1 * u1 + b2 * u2;
|
|
800
|
+
const v = b0 * v0 + b1 * v1 + b2 * v2;
|
|
801
|
+
const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
|
|
802
|
+
const alpha = sample[3] * mesh.tint[3];
|
|
803
|
+
if (alpha <= 0.5) continue;
|
|
804
|
+
emit(
|
|
805
|
+
x,
|
|
806
|
+
y,
|
|
807
|
+
Math.round(sample[0] * mesh.tint[0]),
|
|
808
|
+
Math.round(sample[1] * mesh.tint[1]),
|
|
809
|
+
Math.round(sample[2] * mesh.tint[2]),
|
|
810
|
+
Math.round(alpha),
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Rasterise whichever shape this piece is.
|
|
819
|
+
*
|
|
820
|
+
* ⭐ Every caller that used to reach for `rasteriseQuad` goes through here, so
|
|
821
|
+
* "what counts as a covered pixel" has one definition for both shapes — which is
|
|
822
|
+
* what lets `frameGeometry`, the framing box and the drawn frame agree about a
|
|
823
|
+
* mesh without any of them knowing what a triangle is.
|
|
824
|
+
*/
|
|
825
|
+
export function rasterisePiece(
|
|
826
|
+
page: Plate,
|
|
827
|
+
piece: Piece,
|
|
828
|
+
project: (wx: number, wy: number) => [number, number],
|
|
829
|
+
clip: { width: number; height: number },
|
|
830
|
+
emit: EmitPixel,
|
|
831
|
+
): void {
|
|
832
|
+
if (piece.kind === 'mesh') rasteriseMesh(page, piece, project, clip, emit);
|
|
833
|
+
else rasteriseQuad(page, piece, project, clip, emit);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Blit one piece onto the plate, source-over. */
|
|
837
|
+
export function blitPiece(
|
|
838
|
+
dst: Plate,
|
|
839
|
+
page: Plate,
|
|
840
|
+
piece: Piece,
|
|
841
|
+
project: (wx: number, wy: number) => [number, number],
|
|
842
|
+
): void {
|
|
843
|
+
rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
|
|
847
|
+
const x0 = Math.floor(x);
|
|
848
|
+
const y0 = Math.floor(y);
|
|
849
|
+
const fx = x - x0;
|
|
850
|
+
const fy = y - y0;
|
|
851
|
+
const at = (ix: number, iy: number): RGBA => {
|
|
852
|
+
const cx = Math.max(0, Math.min(page.width - 1, ix));
|
|
853
|
+
const cy = Math.max(0, Math.min(page.height - 1, iy));
|
|
854
|
+
return page.get(cx, cy);
|
|
855
|
+
};
|
|
856
|
+
const c00 = at(x0, y0);
|
|
857
|
+
const c10 = at(x0 + 1, y0);
|
|
858
|
+
const c01 = at(x0, y0 + 1);
|
|
859
|
+
const c11 = at(x0 + 1, y0 + 1);
|
|
860
|
+
const out: [number, number, number, number] = [0, 0, 0, 0];
|
|
861
|
+
for (let c = 0; c < 4; c++) {
|
|
862
|
+
const top = c00[c] + (c10[c] - c00[c]) * fx;
|
|
863
|
+
const bottom = c01[c] + (c11[c] - c01[c]) * fx;
|
|
864
|
+
out[c] = top + (bottom - top) * fy;
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function fill(plate: Plate, colour: RGBA): void {
|
|
870
|
+
for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Look a page up by name, with a failure that names what the atlas did declare. */
|
|
874
|
+
export function pageFor(pages: Map<string, Plate>, piece: Piece): Plate {
|
|
875
|
+
const page = pages.get(piece.page);
|
|
876
|
+
if (!page) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
return page;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** One frame, composited over `background`, at the viewport's pixel size. */
|
|
885
|
+
export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: Viewport, background: RGBA): Plate {
|
|
886
|
+
const plate = new Plate(viewport.width, viewport.height);
|
|
887
|
+
fill(plate, background);
|
|
888
|
+
const project = projector(viewport);
|
|
889
|
+
for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
|
|
890
|
+
return plate;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Where one thing landed in a frame, in frame pixels. */
|
|
894
|
+
export interface Footprint {
|
|
895
|
+
/** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
|
|
896
|
+
pixels: number;
|
|
897
|
+
cx: number;
|
|
898
|
+
cy: number;
|
|
899
|
+
minX: number;
|
|
900
|
+
minY: number;
|
|
901
|
+
maxX: number;
|
|
902
|
+
maxY: number;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export const EMPTY_FOOTPRINT: Footprint = { pixels: 0, cx: 0, cy: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
906
|
+
|
|
907
|
+
/** Where a frame's pixels went: the coverage mask, and each slot's own footprint. */
|
|
908
|
+
export interface FrameGeometry {
|
|
909
|
+
/** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
|
|
910
|
+
coverage: Uint8Array;
|
|
911
|
+
footprints: Map<string, Footprint>;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Rasterise one frame for measurement rather than for looking at: which pixels
|
|
916
|
+
* it covers, and where each slot landed.
|
|
917
|
+
*
|
|
918
|
+
* ⚠️ A slot's footprint is measured on the pixels **that slot draws**, ignoring
|
|
919
|
+
* what is drawn over it. That is deliberate. A slot hidden behind another still
|
|
920
|
+
* has a position, and it is the position the rig gives it; measuring it on the
|
|
921
|
+
* composite would report the occluder's geometry instead and call the rig wrong
|
|
922
|
+
* for being covered up. What the composite costs is on the reference side, where
|
|
923
|
+
* an occluded part merges into its occluder's component — and that is what the
|
|
924
|
+
* matcher reports as ambiguity rather than as drift.
|
|
925
|
+
*/
|
|
926
|
+
export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport: Viewport): FrameGeometry {
|
|
927
|
+
const coverage = new Uint8Array(viewport.width * viewport.height);
|
|
928
|
+
const footprints = new Map<string, Footprint>();
|
|
929
|
+
const project = projector(viewport);
|
|
930
|
+
for (const piece of frame.pieces) {
|
|
931
|
+
let weight = 0;
|
|
932
|
+
let sx = 0;
|
|
933
|
+
let sy = 0;
|
|
934
|
+
let minX = Infinity;
|
|
935
|
+
let minY = Infinity;
|
|
936
|
+
let maxX = -Infinity;
|
|
937
|
+
let maxY = -Infinity;
|
|
938
|
+
rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
|
|
939
|
+
coverage[py * viewport.width + px] = 1;
|
|
940
|
+
const w = a / 255;
|
|
941
|
+
weight += w;
|
|
942
|
+
sx += (px + 0.5) * w;
|
|
943
|
+
sy += (py + 0.5) * w;
|
|
944
|
+
if (px < minX) minX = px;
|
|
945
|
+
if (px > maxX) maxX = px;
|
|
946
|
+
if (py < minY) minY = py;
|
|
947
|
+
if (py > maxY) maxY = py;
|
|
948
|
+
});
|
|
949
|
+
const previous = footprints.get(piece.slot);
|
|
950
|
+
const here: Footprint =
|
|
951
|
+
weight === 0
|
|
952
|
+
? EMPTY_FOOTPRINT
|
|
953
|
+
: { pixels: weight, cx: sx / weight, cy: sy / weight, minX, minY, maxX: maxX + 1, maxY: maxY + 1 };
|
|
954
|
+
// A slot shows one attachment at a time, so this only merges when a caller
|
|
955
|
+
// hands us a frame with two pieces on one slot; merging is still the honest
|
|
956
|
+
// answer, and it keeps the map keyed by slot the way the report reads it.
|
|
957
|
+
footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
|
|
958
|
+
}
|
|
959
|
+
return { coverage, footprints };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function mergeFootprints(a: Footprint, b: Footprint): Footprint {
|
|
963
|
+
if (b.pixels === 0) return a;
|
|
964
|
+
const pixels = a.pixels + b.pixels;
|
|
965
|
+
return {
|
|
966
|
+
pixels,
|
|
967
|
+
cx: (a.cx * a.pixels + b.cx * b.pixels) / pixels,
|
|
968
|
+
cy: (a.cy * a.pixels + b.cy * b.pixels) / pixels,
|
|
969
|
+
minX: Math.min(a.minX, b.minX),
|
|
970
|
+
minY: Math.min(a.minY, b.minY),
|
|
971
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
972
|
+
maxY: Math.max(a.maxY, b.maxY),
|
|
973
|
+
};
|
|
974
|
+
}
|