@doki-land/live2d-renderer 0.0.0 → 0.0.11

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.
@@ -0,0 +1,101 @@
1
+ import type {
2
+ FrameBlendMode,
3
+ FrameDrawable,
4
+ FrameSnapshot,
5
+ ModelInstance,
6
+ ModelProgram,
7
+ } from "@doki-land/live2d-core";
8
+
9
+ /** Create a runtime instance with default parameter values. */
10
+ export function createModelInstance(program: ModelProgram): ModelInstance {
11
+ const parameterValues = new Float32Array(program.parameters.length);
12
+ for (let i = 0; i < program.parameters.length; i++) {
13
+ parameterValues[i] = program.parameters[i]?.defaultValue ?? 0;
14
+ }
15
+ return {
16
+ program,
17
+ parameterValues,
18
+ timeSeconds: 0,
19
+ };
20
+ }
21
+
22
+ export function setParameterValue(
23
+ instance: ModelInstance,
24
+ parameterId: string,
25
+ value: number,
26
+ ): void {
27
+ const index = instance.program.parameters.findIndex(
28
+ (p) => p.id === parameterId,
29
+ );
30
+ if (index < 0) {
31
+ throw new Error(
32
+ `@doki-land/live2d-renderer: unknown parameter "${parameterId}"`,
33
+ );
34
+ }
35
+ const p = instance.program.parameters[index]!;
36
+ instance.parameterValues[index] = Math.min(p.max, Math.max(p.min, value));
37
+ }
38
+
39
+ function paramWeight(instance: ModelInstance, paramIndex: number): number {
40
+ const p = instance.program.parameters[paramIndex];
41
+ if (!p) return 0;
42
+ const v = instance.parameterValues[paramIndex] ?? p.defaultValue;
43
+ if (v >= p.defaultValue) {
44
+ const span = p.max - p.defaultValue;
45
+ return span === 0 ? 0 : (v - p.defaultValue) / span;
46
+ }
47
+ const span = p.defaultValue - p.min;
48
+ return span === 0 ? 0 : (v - p.defaultValue) / span;
49
+ }
50
+
51
+ /** CPU deform 鈫?FrameSnapshot. */
52
+ export function evaluateFrame(instance: ModelInstance): FrameSnapshot {
53
+ const drawables: FrameDrawable[] = [];
54
+
55
+ for (const d of instance.program.drawables) {
56
+ const positions = new Float32Array(d.positions);
57
+ if (d.deformParamIndex >= 0 && d.deformDeltas) {
58
+ const w = paramWeight(instance, d.deformParamIndex);
59
+ for (let i = 0; i < positions.length; i++) {
60
+ positions[i] = positions[i]! + w * d.deformDeltas[i]!;
61
+ }
62
+ }
63
+ drawables.push({
64
+ index: d.index,
65
+ textureIndex: d.textureIndex,
66
+ positions,
67
+ uvs: d.uvs,
68
+ indices: d.indices,
69
+ opacity: d.opacity,
70
+ blendMode: d.blendMode as FrameBlendMode,
71
+ renderOrder: d.renderOrder,
72
+ visible: d.visible,
73
+ invertedMask: d.invertedMask,
74
+ maskIndices: d.maskIndices,
75
+ });
76
+ }
77
+
78
+ drawables.sort((a, b) => a.renderOrder - b.renderOrder);
79
+
80
+ return {
81
+ timeSeconds: instance.timeSeconds,
82
+ drawables,
83
+ };
84
+ }
85
+
86
+ /** Stable fingerprint for golden tests (positions + topology). */
87
+ export function fingerprintSnapshot(snapshot: FrameSnapshot): string {
88
+ const parts: string[] = [`t=${snapshot.timeSeconds.toFixed(4)}`];
89
+ for (const d of snapshot.drawables) {
90
+ parts.push(
91
+ `d${d.index}:tex=${d.textureIndex};nV=${d.positions.length / 2};nI=${d.indices.length}`,
92
+ );
93
+ for (let i = 0; i < d.positions.length; i++) {
94
+ parts.push(d.positions[i]?.toFixed(5) ?? "0");
95
+ }
96
+ for (let i = 0; i < d.indices.length; i++) {
97
+ parts.push(String(d.indices[i]));
98
+ }
99
+ }
100
+ return parts.join("|");
101
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ type Canvas2DRendererOptions,
3
+ createCanvas2DRenderer,
4
+ } from "./backends/canvas2d.js";
5
+ import {
6
+ createWebGl2Renderer,
7
+ type WebGl2RendererOptions,
8
+ } from "./backends/webgl2.js";
9
+ import {
10
+ createWebGpuRenderer,
11
+ type WebGpuRendererOptions,
12
+ } from "./backends/webgpu.js";
13
+ import type { Renderer, RendererKind } from "./types.js";
14
+
15
+ export interface CreateRendererOptions {
16
+ /**
17
+ * Backend try order at initialize time.
18
+ * Default: `["webgpu", "webgl2", "canvas2d"]`.
19
+ */
20
+ prefer?: RendererKind[];
21
+ webgpu?: WebGpuRendererOptions;
22
+ webgl2?: WebGl2RendererOptions;
23
+ canvas2d?: Canvas2DRendererOptions;
24
+ }
25
+
26
+ const DEFAULT_PREFER: RendererKind[] = ["webgpu", "webgl2", "canvas2d"];
27
+
28
+ /** Avoid indefinite hangs on `requestAdapter` / `requestDevice`. */
29
+ const INIT_TIMEOUT_MS: Record<RendererKind, number> = {
30
+ webgpu: 2500,
31
+ webgl2: 1500,
32
+ canvas2d: 1000,
33
+ };
34
+
35
+ function instantiate(
36
+ kind: RendererKind,
37
+ options: CreateRendererOptions,
38
+ ): Renderer {
39
+ if (kind === "webgpu") return createWebGpuRenderer(options.webgpu);
40
+ if (kind === "webgl2") return createWebGl2Renderer(options.webgl2);
41
+ return createCanvas2DRenderer(options.canvas2d);
42
+ }
43
+
44
+ async function initializeWithTimeout(
45
+ kind: RendererKind,
46
+ candidate: Renderer,
47
+ canvas: HTMLCanvasElement,
48
+ ): Promise<void> {
49
+ const ms = INIT_TIMEOUT_MS[kind];
50
+ let timer: ReturnType<typeof setTimeout> | undefined;
51
+ try {
52
+ await Promise.race([
53
+ candidate.initialize(canvas),
54
+ new Promise<never>((_, reject) => {
55
+ timer = setTimeout(() => {
56
+ reject(
57
+ new Error(
58
+ `@doki-land/live2d-renderer: ${kind} initialize timed out after ${ms}ms`,
59
+ ),
60
+ );
61
+ }, ms);
62
+ }),
63
+ ]);
64
+ } finally {
65
+ if (timer !== undefined) clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Renderer that picks the first backend whose `initialize` succeeds.
71
+ * Availability is decided at initialize time, not via sync navigator checks.
72
+ */
73
+ class FallbackRenderer implements Renderer {
74
+ #inner: Renderer | null = null;
75
+ #kind: RendererKind = "webgpu";
76
+ readonly #prefer: RendererKind[];
77
+ readonly #options: CreateRendererOptions;
78
+
79
+ constructor(prefer: RendererKind[], options: CreateRendererOptions) {
80
+ this.#prefer = prefer;
81
+ this.#options = options;
82
+ }
83
+
84
+ get kind(): RendererKind {
85
+ return this.#inner?.kind ?? this.#kind;
86
+ }
87
+
88
+ async initialize(canvas: HTMLCanvasElement): Promise<void> {
89
+ const errors: string[] = [];
90
+ for (const kind of this.#prefer) {
91
+ const candidate = instantiate(kind, this.#options);
92
+ try {
93
+ await initializeWithTimeout(kind, candidate, canvas);
94
+ this.#inner = candidate;
95
+ this.#kind = candidate.kind;
96
+ return;
97
+ } catch (err) {
98
+ candidate.destroy();
99
+ errors.push(
100
+ `${kind}: ${err instanceof Error ? err.message : String(err)}`,
101
+ );
102
+ }
103
+ }
104
+ throw new Error(
105
+ `@doki-land/live2d-renderer: no renderer initialized (${errors.join("; ")})`,
106
+ );
107
+ }
108
+
109
+ createModelDrawPass() {
110
+ if (!this.#inner) {
111
+ throw new Error(
112
+ "@doki-land/live2d-renderer: renderer not initialized",
113
+ );
114
+ }
115
+ return this.#inner.createModelDrawPass();
116
+ }
117
+
118
+ beginFrame(): void {
119
+ this.#inner?.beginFrame();
120
+ }
121
+
122
+ endFrame(): void {
123
+ this.#inner?.endFrame();
124
+ }
125
+
126
+ resize(width: number, height: number): void {
127
+ this.#inner?.resize(width, height);
128
+ }
129
+
130
+ destroy(): void {
131
+ this.#inner?.destroy();
132
+ this.#inner = null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Create a renderer provider. Actual backend is chosen on `initialize`
138
+ * (WebGPU → WebGL2 → Canvas2D by default).
139
+ */
140
+ export function createRenderer(options: CreateRendererOptions = {}): Renderer {
141
+ const prefer = options.prefer?.length ? options.prefer : DEFAULT_PREFER;
142
+ return new FallbackRenderer(prefer, options);
143
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * moc binary format peek. Settings JSON detection lives in live2d-core
3
+ * (`detectModelSettingsFormat`) so loader and renderer share one helper.
4
+ */
5
+
6
+ import type { ModelFormat } from "@doki-land/live2d-core";
7
+
8
+ export { detectModelSettingsFormat } from "@doki-land/live2d-core";
9
+
10
+ /** Peek moc2 vs moc3 from magic bytes. */
11
+ export function detectMocBinaryFormat(bytes: ArrayBuffer): ModelFormat | null {
12
+ if (bytes.byteLength < 4) return null;
13
+ const u8 = new Uint8Array(bytes, 0, 4);
14
+ const magic4 = String.fromCharCode(u8[0]!, u8[1]!, u8[2]!, u8[3]!);
15
+ if (magic4 === "MOC3") return "moc3";
16
+ const magic3 = String.fromCharCode(u8[0]!, u8[1]!, u8[2]!);
17
+ if (magic3 === "moc") return "moc2";
18
+ return null;
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `@doki-land/live2d-renderer`
3
+ *
4
+ * Layout:
5
+ * - `backends/` — graphics only (webgpu / webgl2 / canvas2d)
6
+ * - `moc/` — moc2 / moc3 decode + model runtimes (not graphics backends)
7
+ * - `cpu/` — CPU evaluate + cpu-program fixture codec
8
+ * - `tests/` — all vitest suites (package root)
9
+ */
10
+
11
+ export {
12
+ Canvas2DRendererImpl,
13
+ type Canvas2DRendererOptions,
14
+ createCanvas2DRenderer,
15
+ isCanvas2DAvailable,
16
+ } from "./backends/canvas2d.js";
17
+ export {
18
+ createWebGl2Renderer,
19
+ isWebGl2Available,
20
+ WebGl2RendererImpl,
21
+ type WebGl2RendererOptions,
22
+ } from "./backends/webgl2.js";
23
+ export {
24
+ createWebGpuRenderer,
25
+ isWebGpuAvailable,
26
+ WebGpuRendererImpl,
27
+ type WebGpuRendererOptions,
28
+ } from "./backends/webgpu.js";
29
+ export {
30
+ applyWebGl2BlendMode,
31
+ canvasCompositeForBlendMode,
32
+ webGpuBlendState,
33
+ } from "./blend.js";
34
+ export {
35
+ buildClippingContexts,
36
+ type ClippingContext,
37
+ type ClippingDrawableRef,
38
+ type ClippingPartition,
39
+ calcClippedDrawableBounds,
40
+ calcVertexBounds,
41
+ expandBounds,
42
+ FULL_NDC_BOUNDS,
43
+ fitClippingContexts,
44
+ type LaidOutClippingContext,
45
+ layoutMaskAtlas,
46
+ layoutMaskAtlasRgba,
47
+ layoutMaskAtlasUvGrid,
48
+ MASK_CHANNEL_FLAGS,
49
+ type MaskAtlasMode,
50
+ type MaskAtlasOptions,
51
+ type MaskChannelFlag,
52
+ type MaskLayoutRect,
53
+ maskChannelVec4,
54
+ maskLayoutVec4,
55
+ partitionForClipping,
56
+ } from "./clipping.js";
57
+ export {
58
+ CPU_PROGRAM_KIND,
59
+ type CpuProgramFile,
60
+ createQuadProgram,
61
+ isCpuProgramBytes,
62
+ parseCpuProgram,
63
+ serializeCpuProgram,
64
+ } from "./cpu/cpu-program.js";
65
+ export {
66
+ createModelInstance,
67
+ evaluateFrame,
68
+ fingerprintSnapshot,
69
+ setParameterValue,
70
+ } from "./cpu/evaluate.js";
71
+ export {
72
+ type CreateRendererOptions,
73
+ createRenderer,
74
+ } from "./create-renderer.js";
75
+ export {
76
+ detectMocBinaryFormat,
77
+ detectModelSettingsFormat,
78
+ } from "./detect-format.js";
79
+ export {
80
+ type DecodedMoc2,
81
+ type DecodedMoc3,
82
+ decodeMoc2,
83
+ decodeMoc3,
84
+ } from "./moc/decode.js";
85
+ export {
86
+ decodeMoc2ColorComposition,
87
+ decodeMoc3DrawableFlags,
88
+ Moc3DrawableFlag,
89
+ } from "./moc/drawable-flags.js";
90
+ export { createMoc2Backend, Moc2Backend } from "./moc/moc2.js";
91
+ export { createMoc3Backend, Moc3Backend } from "./moc/moc3.js";
92
+ export {
93
+ applyMoc3Glues,
94
+ loadMoc3Glues,
95
+ type Moc3GlueDef,
96
+ type Moc3GluePair,
97
+ meanGlueSeamDistance,
98
+ } from "./moc/moc3-glue.js";
99
+ export {
100
+ type ModelBackend,
101
+ type ModelBackendOptions,
102
+ type ParameterBinding,
103
+ selectModelBackend,
104
+ } from "./model-runtime.js";
105
+ export {
106
+ PREVIEW_FILL,
107
+ PREVIEW_STROKE,
108
+ triangleEdgesToLineList,
109
+ } from "./preview-style.js";
110
+ export {
111
+ BlendMode,
112
+ type DrawableMesh,
113
+ type ModelDrawPass,
114
+ type Renderer,
115
+ type RendererKind,
116
+ type TextureData,
117
+ type WebGl2Renderer,
118
+ type WebGpuRenderer,
119
+ } from "./types.js";
120
+
121
+ export const LIVE2D_RENDERER_VERSION = "0.0.0" as const;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * moc binary decode: ArrayBuffer → CPU ModelProgram.
3
+ */
4
+
5
+ import type { ModelProgram } from "@doki-land/live2d-core";
6
+ import { Moc2Parser } from "./moc2-objects.js";
7
+ import { moc2ModelToProgram } from "./moc2-to-program.js";
8
+ import { parseMoc3Document } from "./moc3-reader.js";
9
+ import { moc3DocumentToProgram } from "./moc3-to-program.js";
10
+
11
+ export interface DecodedMoc2 {
12
+ readonly format: "moc2";
13
+ readonly program: ModelProgram;
14
+ }
15
+
16
+ export interface DecodedMoc3 {
17
+ readonly format: "moc3";
18
+ readonly program: ModelProgram;
19
+ }
20
+
21
+ function readMagic(bytes: ArrayBuffer): string {
22
+ if (bytes.byteLength < 4) return "";
23
+ return String.fromCharCode(...new Uint8Array(bytes, 0, 4));
24
+ }
25
+
26
+ /** Decode official moc2 (`.moc`) bytes into a default-pose ModelProgram. */
27
+ export async function decodeMoc2(bytes: ArrayBuffer): Promise<DecodedMoc2> {
28
+ if (bytes.byteLength < 4) {
29
+ throw new Error("@doki-land/live2d-renderer: truncated moc2 header");
30
+ }
31
+ const magic3 = String.fromCharCode(...new Uint8Array(bytes, 0, 3));
32
+ if (magic3 !== "moc") {
33
+ throw new Error(
34
+ `@doki-land/live2d-renderer: unrecognized moc2 bytes (magic=${JSON.stringify(magic3)})`,
35
+ );
36
+ }
37
+ try {
38
+ const model = new Moc2Parser(bytes).parseModel();
39
+ const program = moc2ModelToProgram(model);
40
+ return { format: "moc2", program };
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ if (msg.startsWith("@doki-land/live2d-renderer:")) throw err;
44
+ throw new Error(
45
+ `@doki-land/live2d-renderer: moc2 decode failed: ${msg}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Decode official moc3 bytes into a ModelProgram.
52
+ * Applies keyform blending + warp/rotation deformer chains at the given pose
53
+ * (defaults when called via decodeMoc3).
54
+ */
55
+ export async function decodeMoc3(bytes: ArrayBuffer): Promise<DecodedMoc3> {
56
+ const magic = readMagic(bytes);
57
+ if (magic !== "MOC3") {
58
+ throw new Error(
59
+ `@doki-land/live2d-renderer: unrecognized moc3 bytes (magic=${JSON.stringify(magic)})`,
60
+ );
61
+ }
62
+ try {
63
+ const doc = parseMoc3Document(bytes);
64
+ const program = moc3DocumentToProgram(doc);
65
+ return { format: "moc3", program };
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ if (msg.startsWith("@doki-land/live2d-renderer:")) throw err;
69
+ throw new Error(
70
+ `@doki-land/live2d-renderer: moc3 decode failed: ${msg}`,
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared moc2/moc3 drawable constant-flag decode → blend / invert-mask.
3
+ */
4
+
5
+ import { FrameBlendMode } from "@doki-land/live2d-core";
6
+
7
+ /** Cubism Core constant drawable flag bits (moc3 art_mesh.drawable_flags). */
8
+ export const Moc3DrawableFlag = {
9
+ BlendAdditive: 1 << 0,
10
+ BlendMultiplicative: 1 << 1,
11
+ IsDoubleSided: 1 << 2,
12
+ IsInvertedMask: 1 << 3,
13
+ } as const;
14
+
15
+ /** Decode moc3 constant flags into blend + invert-mask. */
16
+ export function decodeMoc3DrawableFlags(flags: number): {
17
+ blendMode: number;
18
+ invertedMask: boolean;
19
+ doubleSided: boolean;
20
+ } {
21
+ let blendMode: number = FrameBlendMode.Normal;
22
+ if ((flags & Moc3DrawableFlag.BlendAdditive) !== 0) {
23
+ blendMode = FrameBlendMode.Additive;
24
+ } else if ((flags & Moc3DrawableFlag.BlendMultiplicative) !== 0) {
25
+ blendMode = FrameBlendMode.Multiplicative;
26
+ }
27
+ return {
28
+ blendMode,
29
+ invertedMask: (flags & Moc3DrawableFlag.IsInvertedMask) !== 0,
30
+ doubleSided: (flags & Moc3DrawableFlag.IsDoubleSided) !== 0,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Decode moc2 color-composition enum (after optionFlags bit0).
36
+ * 0 normal, 1 additive, 2 multiplicative.
37
+ */
38
+ export function decodeMoc2ColorComposition(composition: number): number {
39
+ if (composition === 1) return FrameBlendMode.Additive;
40
+ if (composition === 2) return FrameBlendMode.Multiplicative;
41
+ return FrameBlendMode.Normal;
42
+ }