@doki-land/live2d-renderer 0.0.0 → 0.0.12

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/src/blend.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Map Live2D blend modes to WebGL / Canvas compositing.
3
+ */
4
+
5
+ import { BlendMode } from "./types.js";
6
+
7
+ /** Apply premultiplied-friendly Live2D blend factors on a WebGL2 context. */
8
+ export function applyWebGl2BlendMode(
9
+ gl: WebGL2RenderingContext,
10
+ mode: BlendMode,
11
+ ): void {
12
+ gl.enable(gl.BLEND);
13
+ switch (mode) {
14
+ case BlendMode.Additive:
15
+ gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
16
+ break;
17
+ case BlendMode.Multiplicative:
18
+ gl.blendFuncSeparate(
19
+ gl.DST_COLOR,
20
+ gl.ONE_MINUS_SRC_ALPHA,
21
+ gl.ZERO,
22
+ gl.ONE,
23
+ );
24
+ break;
25
+ default:
26
+ gl.blendFuncSeparate(
27
+ gl.SRC_ALPHA,
28
+ gl.ONE_MINUS_SRC_ALPHA,
29
+ gl.ONE,
30
+ gl.ONE_MINUS_SRC_ALPHA,
31
+ );
32
+ break;
33
+ }
34
+ }
35
+
36
+ /** Canvas2D globalCompositeOperation for Live2D blend modes. */
37
+ export function canvasCompositeForBlendMode(
38
+ mode: BlendMode,
39
+ ): GlobalCompositeOperation {
40
+ switch (mode) {
41
+ case BlendMode.Additive:
42
+ return "lighter";
43
+ case BlendMode.Multiplicative:
44
+ return "multiply";
45
+ default:
46
+ return "source-over";
47
+ }
48
+ }
49
+
50
+ /** WebGPU blend state for Live2D modes (straight alpha draw path). */
51
+ export function webGpuBlendState(mode: BlendMode): GPUBlendState {
52
+ switch (mode) {
53
+ case BlendMode.Additive:
54
+ return {
55
+ color: {
56
+ srcFactor: "src-alpha",
57
+ dstFactor: "one",
58
+ operation: "add",
59
+ },
60
+ alpha: {
61
+ srcFactor: "zero",
62
+ dstFactor: "one",
63
+ operation: "add",
64
+ },
65
+ };
66
+ case BlendMode.Multiplicative:
67
+ return {
68
+ color: {
69
+ srcFactor: "dst",
70
+ dstFactor: "one-minus-src-alpha",
71
+ operation: "add",
72
+ },
73
+ alpha: {
74
+ srcFactor: "zero",
75
+ dstFactor: "one",
76
+ operation: "add",
77
+ },
78
+ };
79
+ default:
80
+ return {
81
+ color: {
82
+ srcFactor: "src-alpha",
83
+ dstFactor: "one-minus-src-alpha",
84
+ operation: "add",
85
+ },
86
+ alpha: {
87
+ srcFactor: "one",
88
+ dstFactor: "one-minus-src-alpha",
89
+ operation: "add",
90
+ },
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Clipping-mask context grouping + mask atlas layout.
3
+ *
4
+ * Two packing modes:
5
+ * - `uv-grid`: √N UV cells, alpha channel only (Canvas2D / simple path)
6
+ * - `rgba`: Cubism-style channel × UV packing (≤4 → full UV on R/G/B/A)
7
+ */
8
+
9
+ export interface ClippingDrawableRef {
10
+ readonly index: number;
11
+ readonly maskIndices: readonly number[];
12
+ readonly invertedMask: boolean;
13
+ }
14
+
15
+ export interface ClippingContext {
16
+ /** Stable key: invert flag + sorted mask drawable indices. */
17
+ readonly key: string;
18
+ readonly maskIndices: readonly number[];
19
+ readonly clippedIndices: readonly number[];
20
+ readonly invertedMask: boolean;
21
+ }
22
+
23
+ /** UV-space rectangle inside the shared mask atlas (0..1). */
24
+ export interface MaskLayoutRect {
25
+ readonly x: number;
26
+ readonly y: number;
27
+ readonly width: number;
28
+ readonly height: number;
29
+ }
30
+
31
+ /** R/G/B/A write/sample selector (Cubism channelFlag). */
32
+ export type MaskChannelFlag = readonly [number, number, number, number];
33
+
34
+ export const MASK_CHANNEL_FLAGS: readonly MaskChannelFlag[] = [
35
+ [1, 0, 0, 0],
36
+ [0, 1, 0, 0],
37
+ [0, 0, 1, 0],
38
+ [0, 0, 0, 1],
39
+ ] as const;
40
+
41
+ export type MaskAtlasMode = "uv-grid" | "rgba";
42
+
43
+ export interface MaskAtlasOptions {
44
+ /** Packing strategy. Default `rgba` (Cubism density). */
45
+ readonly mode?: MaskAtlasMode;
46
+ /** Cell inset for `uv-grid` only (reduces neighbour bleed). */
47
+ readonly inset?: number;
48
+ /** Render-texture count for `rgba` (default 1 → max 36). */
49
+ readonly renderTextureCount?: number;
50
+ }
51
+
52
+ export interface LaidOutClippingContext extends ClippingContext {
53
+ readonly layout: MaskLayoutRect;
54
+ /** 0=R … 3=A. `uv-grid` always uses A (3). */
55
+ readonly channelIndex: number;
56
+ readonly channelFlag: MaskChannelFlag;
57
+ /** Cubism multi-RT buffer index; currently always 0. */
58
+ readonly bufferIndex: number;
59
+ /**
60
+ * Expanded AABB of clipped drawables in vertex/NDC space.
61
+ * Mask write/sample map this rect into `layout` (Cubism bounds-fit).
62
+ * Default before `fitClippingContexts`: full NDC (-1..1)².
63
+ */
64
+ readonly modelBounds: MaskLayoutRect;
65
+ }
66
+
67
+ /** Full clip-space quad used when no valid clipped bounds exist. */
68
+ export const FULL_NDC_BOUNDS: MaskLayoutRect = {
69
+ x: -1,
70
+ y: -1,
71
+ width: 2,
72
+ height: 2,
73
+ };
74
+
75
+ export interface ClippingPartition {
76
+ readonly contexts: readonly LaidOutClippingContext[];
77
+ /** Drawables that consume a mask (drawn after mask pass). */
78
+ readonly clipped: ReadonlySet<number>;
79
+ /** Drawables used as masks (mask buffer only, not color target). */
80
+ readonly maskOnly: ReadonlySet<number>;
81
+ }
82
+
83
+ function maskKey(
84
+ maskIndices: readonly number[],
85
+ invertedMask: boolean,
86
+ ): string {
87
+ const sorted = [...maskIndices].sort((a, b) => a - b);
88
+ return `${invertedMask ? "i" : "n"}:${sorted.join(",")}`;
89
+ }
90
+
91
+ /**
92
+ * Group drawables that consume clipping masks into shared contexts.
93
+ * Drawables with empty maskIndices are omitted.
94
+ */
95
+ export function buildClippingContexts(
96
+ drawables: readonly ClippingDrawableRef[],
97
+ ): ClippingContext[] {
98
+ const byKey = new Map<
99
+ string,
100
+ {
101
+ maskIndices: number[];
102
+ clippedIndices: number[];
103
+ invertedMask: boolean;
104
+ }
105
+ >();
106
+
107
+ for (const d of drawables) {
108
+ if (!d.maskIndices.length) continue;
109
+ const key = maskKey(d.maskIndices, d.invertedMask);
110
+ let ctx = byKey.get(key);
111
+ if (!ctx) {
112
+ ctx = {
113
+ maskIndices: [...d.maskIndices].sort((a, b) => a - b),
114
+ clippedIndices: [],
115
+ invertedMask: d.invertedMask,
116
+ };
117
+ byKey.set(key, ctx);
118
+ }
119
+ ctx.clippedIndices.push(d.index);
120
+ }
121
+
122
+ return [...byKey.entries()].map(([key, ctx]) => ({
123
+ key,
124
+ maskIndices: ctx.maskIndices,
125
+ clippedIndices: ctx.clippedIndices,
126
+ invertedMask: ctx.invertedMask,
127
+ }));
128
+ }
129
+
130
+ function withAlphaChannel(
131
+ ctx: ClippingContext,
132
+ layout: MaskLayoutRect,
133
+ ): LaidOutClippingContext {
134
+ return {
135
+ ...ctx,
136
+ layout,
137
+ channelIndex: 3,
138
+ channelFlag: MASK_CHANNEL_FLAGS[3]!,
139
+ bufferIndex: 0,
140
+ modelBounds: FULL_NDC_BOUNDS,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Pack clipping contexts into a square-ish UV grid on one atlas (alpha only).
146
+ */
147
+ export function layoutMaskAtlasUvGrid(
148
+ contexts: readonly ClippingContext[],
149
+ options: { inset?: number } = {},
150
+ ): LaidOutClippingContext[] {
151
+ const n = contexts.length;
152
+ if (n === 0) return [];
153
+
154
+ const inset = options.inset ?? 0.02;
155
+ if (n === 1) {
156
+ const pad = inset * 0.5;
157
+ return [
158
+ withAlphaChannel(contexts[0]!, {
159
+ x: pad,
160
+ y: pad,
161
+ width: 1 - inset,
162
+ height: 1 - inset,
163
+ }),
164
+ ];
165
+ }
166
+
167
+ const cols = Math.ceil(Math.sqrt(n));
168
+ const rows = Math.ceil(n / cols);
169
+ const cellW = 1 / cols;
170
+ const cellH = 1 / rows;
171
+
172
+ return contexts.map((ctx, i) => {
173
+ const col = i % cols;
174
+ const row = Math.floor(i / cols);
175
+ const padX = cellW * inset * 0.5;
176
+ const padY = cellH * inset * 0.5;
177
+ return withAlphaChannel(ctx, {
178
+ x: col * cellW + padX,
179
+ y: row * cellH + padY,
180
+ width: cellW * (1 - inset),
181
+ height: cellH * (1 - inset),
182
+ });
183
+ });
184
+ }
185
+
186
+ const COLOR_CHANNEL_COUNT = 4;
187
+ const CLIPPING_MASK_MAX_DEFAULT = 36;
188
+ const CLIPPING_MASK_MAX_MULTI = 32;
189
+
190
+ function cubismCellBounds(
191
+ layoutCount: number,
192
+ i: number,
193
+ layoutCountMax: number,
194
+ ): MaskLayoutRect {
195
+ if (layoutCount === 1) {
196
+ return { x: 0, y: 0, width: 1, height: 1 };
197
+ }
198
+ if (layoutCount === 2) {
199
+ const xpos = i % 2;
200
+ return { x: xpos * 0.5, y: 0, width: 0.5, height: 1 };
201
+ }
202
+ if (layoutCount <= 4) {
203
+ const xpos = i % 2;
204
+ const ypos = Math.floor(i / 2);
205
+ return { x: xpos * 0.5, y: ypos * 0.5, width: 0.5, height: 0.5 };
206
+ }
207
+ if (layoutCount <= layoutCountMax) {
208
+ const xpos = i % 3;
209
+ const ypos = Math.floor(i / 3);
210
+ return {
211
+ x: xpos / 3,
212
+ y: ypos / 3,
213
+ width: 1 / 3,
214
+ height: 1 / 3,
215
+ };
216
+ }
217
+ return { x: 0, y: 0, width: 1, height: 1 };
218
+ }
219
+
220
+ /**
221
+ * Cubism `setupLayoutBounds`: pack across R/G/B/A then UV cells.
222
+ * Single RT → max 36 (4×9); multi-RT → 32 per sheet.
223
+ */
224
+ export function layoutMaskAtlasRgba(
225
+ contexts: readonly ClippingContext[],
226
+ options: { renderTextureCount?: number } = {},
227
+ ): LaidOutClippingContext[] {
228
+ const n = contexts.length;
229
+ if (n === 0) return [];
230
+
231
+ const renderTextureCount = Math.max(1, options.renderTextureCount ?? 1);
232
+ const maxCount =
233
+ renderTextureCount <= 1
234
+ ? CLIPPING_MASK_MAX_DEFAULT
235
+ : CLIPPING_MASK_MAX_MULTI * renderTextureCount;
236
+ const layoutCountMax = renderTextureCount <= 1 ? 9 : 8;
237
+
238
+ if (n > maxCount) {
239
+ // Over cap: every context reuses full-quad channel 0 (Cubism fallback).
240
+ return contexts.map((ctx) => ({
241
+ ...ctx,
242
+ layout: { x: 0, y: 0, width: 1, height: 1 },
243
+ channelIndex: 0,
244
+ channelFlag: MASK_CHANNEL_FLAGS[0]!,
245
+ bufferIndex: 0,
246
+ modelBounds: FULL_NDC_BOUNDS,
247
+ }));
248
+ }
249
+
250
+ const countPerSheetDiv = Math.ceil(n / renderTextureCount);
251
+ const reduceLayoutTextureCount = n % renderTextureCount;
252
+ const divCount = Math.floor(countPerSheetDiv / COLOR_CHANNEL_COUNT);
253
+ const modCount = countPerSheetDiv % COLOR_CHANNEL_COUNT;
254
+
255
+ const out: LaidOutClippingContext[] = [];
256
+ let cur = 0;
257
+
258
+ for (let rt = 0; rt < renderTextureCount; rt++) {
259
+ for (
260
+ let channelIndex = 0;
261
+ channelIndex < COLOR_CHANNEL_COUNT;
262
+ channelIndex++
263
+ ) {
264
+ let layoutCount = divCount + (channelIndex < modCount ? 1 : 0);
265
+ const checkChannelIndex = modCount + (divCount < 1 ? -1 : 0);
266
+ if (
267
+ channelIndex === checkChannelIndex &&
268
+ reduceLayoutTextureCount > 0
269
+ ) {
270
+ layoutCount -= rt < reduceLayoutTextureCount ? 0 : 1;
271
+ }
272
+ if (layoutCount <= 0) continue;
273
+
274
+ for (let i = 0; i < layoutCount; i++) {
275
+ const ctx = contexts[cur++];
276
+ if (!ctx) return out;
277
+ out.push({
278
+ ...ctx,
279
+ layout: cubismCellBounds(layoutCount, i, layoutCountMax),
280
+ channelIndex,
281
+ channelFlag: MASK_CHANNEL_FLAGS[channelIndex]!,
282
+ bufferIndex: rt,
283
+ modelBounds: FULL_NDC_BOUNDS,
284
+ });
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /** Pack clipping contexts (default: Cubism RGBA density). */
292
+ export function layoutMaskAtlas(
293
+ contexts: readonly ClippingContext[],
294
+ options: MaskAtlasOptions = {},
295
+ ): LaidOutClippingContext[] {
296
+ const mode = options.mode ?? "rgba";
297
+ if (mode === "uv-grid") {
298
+ return layoutMaskAtlasUvGrid(contexts, { inset: options.inset });
299
+ }
300
+ return layoutMaskAtlasRgba(contexts, {
301
+ renderTextureCount: options.renderTextureCount,
302
+ });
303
+ }
304
+
305
+ /** Partition drawables and assign atlas layouts. */
306
+ export function partitionForClipping(
307
+ drawables: readonly ClippingDrawableRef[],
308
+ options: MaskAtlasOptions = {},
309
+ ): ClippingPartition {
310
+ const contexts = layoutMaskAtlas(buildClippingContexts(drawables), options);
311
+ const clipped = new Set<number>();
312
+ const maskOnly = new Set<number>();
313
+ for (const ctx of contexts) {
314
+ for (const i of ctx.clippedIndices) clipped.add(i);
315
+ for (const m of ctx.maskIndices) maskOnly.add(m);
316
+ }
317
+ return { contexts, clipped, maskOnly };
318
+ }
319
+
320
+ /** Axis-aligned bounds of interleaved xy vertex positions. */
321
+ export function calcVertexBounds(
322
+ positions: Float32Array,
323
+ ): MaskLayoutRect | null {
324
+ if (positions.length < 2) return null;
325
+ let minX = Infinity;
326
+ let minY = Infinity;
327
+ let maxX = -Infinity;
328
+ let maxY = -Infinity;
329
+ for (let i = 0; i + 1 < positions.length; i += 2) {
330
+ const x = positions[i]!;
331
+ const y = positions[i + 1]!;
332
+ if (x < minX) minX = x;
333
+ if (y < minY) minY = y;
334
+ if (x > maxX) maxX = x;
335
+ if (y > maxY) maxY = y;
336
+ }
337
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
338
+ const width = maxX - minX;
339
+ const height = maxY - minY;
340
+ if (width <= 0 || height <= 0) return null;
341
+ return { x: minX, y: minY, width, height };
342
+ }
343
+
344
+ /** Expand bounds by a relative margin on each axis (Cubism uses 0.05). */
345
+ export function expandBounds(
346
+ bounds: MaskLayoutRect,
347
+ margin = 0.05,
348
+ ): MaskLayoutRect {
349
+ const dx = bounds.width * margin;
350
+ const dy = bounds.height * margin;
351
+ return {
352
+ x: bounds.x - dx,
353
+ y: bounds.y - dy,
354
+ width: bounds.width + dx * 2,
355
+ height: bounds.height + dy * 2,
356
+ };
357
+ }
358
+
359
+ export interface MaskBoundsMeshRef {
360
+ readonly index: number;
361
+ readonly vertexPositions: Float32Array;
362
+ }
363
+
364
+ /**
365
+ * Union AABB of clipped drawables, expanded by margin (Cubism
366
+ * `calcClippedDrawableTotalBounds` + 0.05 expand).
367
+ */
368
+ export function calcClippedDrawableBounds(
369
+ clippedIndices: readonly number[],
370
+ byIndex: ReadonlyMap<number, MaskBoundsMeshRef>,
371
+ margin = 0.05,
372
+ ): MaskLayoutRect {
373
+ let minX = Infinity;
374
+ let minY = Infinity;
375
+ let maxX = -Infinity;
376
+ let maxY = -Infinity;
377
+ let any = false;
378
+ for (const id of clippedIndices) {
379
+ const mesh = byIndex.get(id);
380
+ if (!mesh) continue;
381
+ const b = calcVertexBounds(mesh.vertexPositions);
382
+ if (!b) continue;
383
+ any = true;
384
+ if (b.x < minX) minX = b.x;
385
+ if (b.y < minY) minY = b.y;
386
+ if (b.x + b.width > maxX) maxX = b.x + b.width;
387
+ if (b.y + b.height > maxY) maxY = b.y + b.height;
388
+ }
389
+ if (!any) return FULL_NDC_BOUNDS;
390
+ return expandBounds(
391
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
392
+ margin,
393
+ );
394
+ }
395
+
396
+ /**
397
+ * Attach per-context `modelBounds` so mask write/sample fit the clipped
398
+ * drawable AABB into the atlas cell (higher mask texel density).
399
+ */
400
+ export function fitClippingContexts(
401
+ contexts: readonly LaidOutClippingContext[],
402
+ byIndex: ReadonlyMap<number, MaskBoundsMeshRef>,
403
+ margin = 0.05,
404
+ ): LaidOutClippingContext[] {
405
+ return contexts.map((ctx) => ({
406
+ ...ctx,
407
+ modelBounds: calcClippedDrawableBounds(
408
+ ctx.clippedIndices,
409
+ byIndex,
410
+ margin,
411
+ ),
412
+ }));
413
+ }
414
+
415
+ /** Flatten layout to `[x, y, w, h]` for GPU uniforms. */
416
+ export function maskLayoutVec4(layout: MaskLayoutRect): Float32Array {
417
+ return new Float32Array([layout.x, layout.y, layout.width, layout.height]);
418
+ }
419
+
420
+ /** Flatten channel flag to `[r, g, b, a]` for GPU uniforms. */
421
+ export function maskChannelVec4(flag: MaskChannelFlag): Float32Array {
422
+ return new Float32Array([flag[0], flag[1], flag[2], flag[3]]);
423
+ }
package/src/coords.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared model ↔ canvas coordinate helpers.
3
+ *
4
+ * Contract:
5
+ * - Model / NDC positions are Y-up.
6
+ * - Canvas2D pixels are Y-down.
7
+ * - Convert geometry **once**. Never also flip texture V, and never CSS-flip the canvas.
8
+ */
9
+
10
+ /** Model Y-up NDC → canvas pixel Y (origin top-left, Y-down). */
11
+ export function modelYUpToCanvasPixelY(
12
+ modelY: number,
13
+ canvasHeight: number,
14
+ ): number {
15
+ return (1 - modelY) * (canvasHeight / 2);
16
+ }
17
+
18
+ /** Model X NDC → canvas pixel X (origin top-left). */
19
+ export function modelXToCanvasPixelX(
20
+ modelX: number,
21
+ canvasWidth: number,
22
+ ): number {
23
+ return (modelX + 1) * (canvasWidth / 2);
24
+ }
@@ -0,0 +1,176 @@
1
+ import type {
2
+ DrawableProgram,
3
+ ModelProgram,
4
+ ParameterProgram,
5
+ } from "@doki-land/live2d-core";
6
+
7
+ const CPU_PROGRAM_KIND = "cpu-program" as const;
8
+
9
+ export interface CpuProgramFile {
10
+ readonly kind: typeof CPU_PROGRAM_KIND;
11
+ readonly version: 1;
12
+ readonly program: {
13
+ readonly format: "moc2" | "moc3";
14
+ readonly parameters: readonly ParameterProgram[];
15
+ readonly drawables: readonly {
16
+ readonly index: number;
17
+ readonly textureIndex: number;
18
+ readonly positions: number[];
19
+ readonly uvs: number[];
20
+ readonly indices: number[];
21
+ readonly opacity: number;
22
+ readonly renderOrder: number;
23
+ readonly blendMode?: number;
24
+ readonly invertedMask?: boolean;
25
+ readonly maskIndices?: number[];
26
+ readonly visible?: boolean;
27
+ readonly deformParamIndex: number;
28
+ readonly deformDeltas: number[] | null;
29
+ }[];
30
+ };
31
+ }
32
+
33
+ function assert(cond: unknown, message: string): asserts cond {
34
+ if (!cond) {
35
+ throw new Error(`@doki-land/live2d-renderer: ${message}`);
36
+ }
37
+ }
38
+
39
+ /** Build a minimal one-quad CPU program for fixtures / homepage. */
40
+ export function createQuadProgram(
41
+ options: {
42
+ parameterId?: string;
43
+ /** Delta applied to top-right vertex at param max (x,y). */
44
+ topRightDelta?: readonly [number, number];
45
+ } = {},
46
+ ): ModelProgram {
47
+ const parameterId = options.parameterId ?? "PARAM_ANGLE_X";
48
+ const [dx, dy] = options.topRightDelta ?? [0.25, 0.1];
49
+
50
+ const parameters: ParameterProgram[] = [
51
+ {
52
+ id: parameterId,
53
+ min: -1,
54
+ max: 1,
55
+ defaultValue: 0,
56
+ },
57
+ ];
58
+
59
+ const positions = new Float32Array([
60
+ -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5,
61
+ ]);
62
+ const uvs = new Float32Array([0, 1, 1, 1, 1, 0, 0, 0]);
63
+ const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
64
+ const deformDeltas = new Float32Array(8);
65
+ deformDeltas[4] = dx;
66
+ deformDeltas[5] = dy;
67
+
68
+ const drawable: DrawableProgram = {
69
+ index: 0,
70
+ textureIndex: 0,
71
+ positions,
72
+ uvs,
73
+ indices,
74
+ opacity: 1,
75
+ renderOrder: 0,
76
+ blendMode: 0,
77
+ invertedMask: false,
78
+ maskIndices: [],
79
+ visible: true,
80
+ deformParamIndex: 0,
81
+ deformDeltas,
82
+ };
83
+
84
+ return {
85
+ format: "moc3",
86
+ codec: "cpu-program",
87
+ parameters,
88
+ drawables: [drawable],
89
+ };
90
+ }
91
+
92
+ /** Serialize a CPU ModelProgram to UTF-8 JSON bytes. */
93
+ export function serializeCpuProgram(program: ModelProgram): ArrayBuffer {
94
+ assert(
95
+ program.codec === "cpu-program",
96
+ "serializeCpuProgram expects codec cpu-program",
97
+ );
98
+ const file: CpuProgramFile = {
99
+ kind: CPU_PROGRAM_KIND,
100
+ version: 1,
101
+ program: {
102
+ format: program.format,
103
+ parameters: program.parameters,
104
+ drawables: program.drawables.map((d) => ({
105
+ index: d.index,
106
+ textureIndex: d.textureIndex,
107
+ positions: [...d.positions],
108
+ uvs: [...d.uvs],
109
+ indices: [...d.indices],
110
+ opacity: d.opacity,
111
+ renderOrder: d.renderOrder,
112
+ blendMode: d.blendMode,
113
+ invertedMask: d.invertedMask,
114
+ maskIndices: [...d.maskIndices],
115
+ visible: d.visible,
116
+ deformParamIndex: d.deformParamIndex,
117
+ deformDeltas: d.deformDeltas ? [...d.deformDeltas] : null,
118
+ })),
119
+ },
120
+ };
121
+ return new TextEncoder().encode(`${JSON.stringify(file, null, 2)}\n`)
122
+ .buffer;
123
+ }
124
+
125
+ export function isCpuProgramBytes(bytes: ArrayBuffer): boolean {
126
+ try {
127
+ const text = new TextDecoder().decode(bytes).trimStart();
128
+ if (!text.startsWith("{")) return false;
129
+ const parsed = JSON.parse(text) as { kind?: string };
130
+ return parsed.kind === CPU_PROGRAM_KIND;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** Parse CPU program JSON bytes into a ModelProgram. */
137
+ export function parseCpuProgram(bytes: ArrayBuffer): ModelProgram {
138
+ let file: CpuProgramFile;
139
+ try {
140
+ file = JSON.parse(new TextDecoder().decode(bytes)) as CpuProgramFile;
141
+ } catch {
142
+ throw new Error("@doki-land/live2d-renderer: invalid cpu-program JSON");
143
+ }
144
+ assert(file.kind === CPU_PROGRAM_KIND, "not a cpu-program file");
145
+ assert(
146
+ file.version === 1,
147
+ `unsupported cpu-program version ${file.version}`,
148
+ );
149
+ assert(file.program, "cpu-program missing program");
150
+
151
+ return {
152
+ format: file.program.format,
153
+ codec: "cpu-program",
154
+ parameters: file.program.parameters,
155
+ drawables: file.program.drawables.map((d) => ({
156
+ index: d.index,
157
+ textureIndex: d.textureIndex,
158
+ positions: new Float32Array(d.positions),
159
+ uvs: new Float32Array(d.uvs),
160
+ indices: new Uint16Array(d.indices),
161
+ opacity: d.opacity,
162
+ renderOrder: d.renderOrder,
163
+ blendMode: d.blendMode ?? 0,
164
+ invertedMask: d.invertedMask ?? false,
165
+ maskIndices: d.maskIndices ?? [],
166
+ visible: d.visible ?? true,
167
+ deformParamIndex: d.deformParamIndex,
168
+ deformDeltas:
169
+ d.deformDeltas === null
170
+ ? null
171
+ : new Float32Array(d.deformDeltas),
172
+ })),
173
+ };
174
+ }
175
+
176
+ export { CPU_PROGRAM_KIND };