@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/README.md +1 -1
- package/dist/index.d.ts +540 -0
- package/dist/index.js +5582 -0
- package/package.json +45 -3
- package/src/backends/canvas2d.ts +447 -0
- package/src/backends/webgl2.ts +685 -0
- package/src/backends/webgpu.ts +1336 -0
- package/src/blend.ts +93 -0
- package/src/clipping.ts +423 -0
- package/src/coords.ts +24 -0
- package/src/cpu/cpu-program.ts +176 -0
- package/src/cpu/evaluate.ts +101 -0
- package/src/create-renderer.ts +143 -0
- package/src/detect-format.ts +19 -0
- package/src/index.ts +121 -0
- package/src/moc/decode.ts +73 -0
- package/src/moc/drawable-flags.ts +42 -0
- package/src/moc/moc2-deform.ts +394 -0
- package/src/moc/moc2-keyforms.ts +178 -0
- package/src/moc/moc2-objects.ts +464 -0
- package/src/moc/moc2-reader.ts +224 -0
- package/src/moc/moc2-to-program.ts +213 -0
- package/src/moc/moc2.ts +184 -0
- package/src/moc/moc3-deform.ts +394 -0
- package/src/moc/moc3-glue.ts +151 -0
- package/src/moc/moc3-keyforms.ts +230 -0
- package/src/moc/moc3-layout.ts +534 -0
- package/src/moc/moc3-parts.ts +45 -0
- package/src/moc/moc3-reader.ts +215 -0
- package/src/moc/moc3-to-program.ts +305 -0
- package/src/moc/moc3.ts +222 -0
- package/src/model-runtime.ts +73 -0
- package/src/preview-style.ts +33 -0
- package/src/types.ts +71 -0
package/package.json
CHANGED
|
@@ -1,7 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doki-land/live2d-renderer",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.12",
|
|
4
|
+
"description": "Live2D graphics backends (WebGPU/WebGL2/Canvas2D) plus moc2/moc3 CPU model runtimes",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
+
"author": "Doki Land",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"live2d",
|
|
10
|
+
"webgpu",
|
|
11
|
+
"webgl2",
|
|
12
|
+
"canvas2d",
|
|
13
|
+
"renderer",
|
|
14
|
+
"doki-land"
|
|
15
|
+
],
|
|
16
|
+
"main": "./src/index.ts",
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@doki-land/live2d-core": "0.0.12"
|
|
43
|
+
},
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/doki-land/live2d.ts.git"
|
|
48
|
+
}
|
|
7
49
|
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { canvasCompositeForBlendMode } from "../blend.js";
|
|
2
|
+
import {
|
|
3
|
+
fitClippingContexts,
|
|
4
|
+
type MaskLayoutRect,
|
|
5
|
+
partitionForClipping,
|
|
6
|
+
} from "../clipping.js";
|
|
7
|
+
import { modelXToCanvasPixelX, modelYUpToCanvasPixelY } from "../coords.js";
|
|
8
|
+
import { PREVIEW_FILL, PREVIEW_STROKE } from "../preview-style.js";
|
|
9
|
+
import type {
|
|
10
|
+
DrawableMesh,
|
|
11
|
+
ModelDrawPass,
|
|
12
|
+
Renderer,
|
|
13
|
+
TextureData,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
|
|
16
|
+
export interface Canvas2DRendererOptions {
|
|
17
|
+
/** Fill color for deformed triangles (CSS). */
|
|
18
|
+
fill?: string;
|
|
19
|
+
stroke?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_FILL = "#5b8def";
|
|
23
|
+
const DEFAULT_STROKE = "#1b3a6b";
|
|
24
|
+
|
|
25
|
+
/** Map model/NDC AABB to canvas pixel rect (y-up NDC → y-down canvas). */
|
|
26
|
+
function ndcBoundsToPixels(
|
|
27
|
+
b: MaskLayoutRect,
|
|
28
|
+
w: number,
|
|
29
|
+
h: number,
|
|
30
|
+
): { x: number; y: number; width: number; height: number } {
|
|
31
|
+
const x0 = (b.x + 1) * 0.5 * w;
|
|
32
|
+
const x1 = (b.x + b.width + 1) * 0.5 * w;
|
|
33
|
+
const yTop = (1 - (b.y + b.height)) * 0.5 * h;
|
|
34
|
+
const yBot = (1 - b.y) * 0.5 * h;
|
|
35
|
+
return {
|
|
36
|
+
x: x0,
|
|
37
|
+
y: yTop,
|
|
38
|
+
width: Math.max(1, x1 - x0),
|
|
39
|
+
height: Math.max(1, yBot - yTop),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Draw one textured triangle via affine map from UV pixels → dest positions.
|
|
45
|
+
* Classic canvas mesh approximation used by soft Live2D previews.
|
|
46
|
+
*
|
|
47
|
+
* Triangle clips are expanded slightly to hide anti-aliased seam gaps between
|
|
48
|
+
* adjacent triangles. UV is sampled as authored (same as WebGL without FLIP_Y).
|
|
49
|
+
*/
|
|
50
|
+
function expandTriangle(
|
|
51
|
+
x0: number,
|
|
52
|
+
y0: number,
|
|
53
|
+
x1: number,
|
|
54
|
+
y1: number,
|
|
55
|
+
x2: number,
|
|
56
|
+
y2: number,
|
|
57
|
+
pixels: number,
|
|
58
|
+
): [number, number, number, number, number, number] {
|
|
59
|
+
const cx = (x0 + x1 + x2) / 3;
|
|
60
|
+
const cy = (y0 + y1 + y2) / 3;
|
|
61
|
+
const push = (x: number, y: number): [number, number] => {
|
|
62
|
+
const dx = x - cx;
|
|
63
|
+
const dy = y - cy;
|
|
64
|
+
const len = Math.hypot(dx, dy);
|
|
65
|
+
if (len < 1e-6) return [x, y];
|
|
66
|
+
const s = (len + pixels) / len;
|
|
67
|
+
return [cx + dx * s, cy + dy * s];
|
|
68
|
+
};
|
|
69
|
+
const [ex0, ey0] = push(x0, y0);
|
|
70
|
+
const [ex1, ey1] = push(x1, y1);
|
|
71
|
+
const [ex2, ey2] = push(x2, y2);
|
|
72
|
+
return [ex0, ey0, ex1, ey1, ex2, ey2];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function drawTexturedTriangle(
|
|
76
|
+
ctx: CanvasRenderingContext2D,
|
|
77
|
+
image: CanvasImageSource,
|
|
78
|
+
imgW: number,
|
|
79
|
+
imgH: number,
|
|
80
|
+
x0: number,
|
|
81
|
+
y0: number,
|
|
82
|
+
x1: number,
|
|
83
|
+
y1: number,
|
|
84
|
+
x2: number,
|
|
85
|
+
y2: number,
|
|
86
|
+
u0: number,
|
|
87
|
+
v0: number,
|
|
88
|
+
u1: number,
|
|
89
|
+
v1: number,
|
|
90
|
+
u2: number,
|
|
91
|
+
v2: number,
|
|
92
|
+
): void {
|
|
93
|
+
const sx0 = u0 * imgW;
|
|
94
|
+
const sy0 = v0 * imgH;
|
|
95
|
+
const sx1 = u1 * imgW;
|
|
96
|
+
const sy1 = v1 * imgH;
|
|
97
|
+
const sx2 = u2 * imgW;
|
|
98
|
+
const sy2 = v2 * imgH;
|
|
99
|
+
|
|
100
|
+
const [cx0, cy0, cx1, cy1, cx2, cy2] = expandTriangle(
|
|
101
|
+
x0,
|
|
102
|
+
y0,
|
|
103
|
+
x1,
|
|
104
|
+
y1,
|
|
105
|
+
x2,
|
|
106
|
+
y2,
|
|
107
|
+
0.75,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
ctx.save();
|
|
111
|
+
ctx.beginPath();
|
|
112
|
+
ctx.moveTo(cx0, cy0);
|
|
113
|
+
ctx.lineTo(cx1, cy1);
|
|
114
|
+
ctx.lineTo(cx2, cy2);
|
|
115
|
+
ctx.closePath();
|
|
116
|
+
ctx.clip();
|
|
117
|
+
|
|
118
|
+
// Solve affine: [x y 1] = [u v 1] * M (pixel UV → dest)
|
|
119
|
+
// Use unexpanded verts so texture alignment stays accurate; expansion is
|
|
120
|
+
// only for the clip to cover neighbour AA gaps.
|
|
121
|
+
const denom = sx0 * (sy1 - sy2) + sx1 * (sy2 - sy0) + sx2 * (sy0 - sy1);
|
|
122
|
+
if (Math.abs(denom) < 1e-8) {
|
|
123
|
+
ctx.restore();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const m11 =
|
|
127
|
+
(x0 * (sy1 - sy2) + x1 * (sy2 - sy0) + x2 * (sy0 - sy1)) / denom;
|
|
128
|
+
const m12 =
|
|
129
|
+
(y0 * (sy1 - sy2) + y1 * (sy2 - sy0) + y2 * (sy0 - sy1)) / denom;
|
|
130
|
+
const m21 =
|
|
131
|
+
(x0 * (sx2 - sx1) + x1 * (sx0 - sx2) + x2 * (sx1 - sx0)) / denom;
|
|
132
|
+
const m22 =
|
|
133
|
+
(y0 * (sx2 - sx1) + y1 * (sx0 - sx2) + y2 * (sx1 - sx0)) / denom;
|
|
134
|
+
const dx =
|
|
135
|
+
(x0 * (sx1 * sy2 - sx2 * sy1) +
|
|
136
|
+
x1 * (sx2 * sy0 - sx0 * sy2) +
|
|
137
|
+
x2 * (sx0 * sy1 - sx1 * sy0)) /
|
|
138
|
+
denom;
|
|
139
|
+
const dy =
|
|
140
|
+
(y0 * (sx1 * sy2 - sx2 * sy1) +
|
|
141
|
+
y1 * (sx2 * sy0 - sx0 * sy2) +
|
|
142
|
+
y2 * (sx0 * sy1 - sx1 * sy0)) /
|
|
143
|
+
denom;
|
|
144
|
+
|
|
145
|
+
ctx.setTransform(m11, m12, m21, m22, dx, dy);
|
|
146
|
+
// Expand source slightly to reduce UV edge bleeding under the clip.
|
|
147
|
+
ctx.drawImage(image, -1, -1, imgW + 2, imgH + 2);
|
|
148
|
+
ctx.restore();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function rgbaCss(c: { r: number; g: number; b: number; a: number }): string {
|
|
152
|
+
return `rgba(${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)},${c.a})`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class Canvas2DModelDrawPass implements ModelDrawPass {
|
|
156
|
+
readonly #ctx: CanvasRenderingContext2D;
|
|
157
|
+
readonly #options: Canvas2DRendererOptions;
|
|
158
|
+
#textures: (TextureData | null)[] = [];
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
ctx: CanvasRenderingContext2D,
|
|
162
|
+
options: Canvas2DRendererOptions,
|
|
163
|
+
) {
|
|
164
|
+
this.#ctx = ctx;
|
|
165
|
+
this.#options = options;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setTextures(textures: TextureData[]): void {
|
|
169
|
+
let maxIndex = -1;
|
|
170
|
+
for (const t of textures) maxIndex = Math.max(maxIndex, t.index);
|
|
171
|
+
this.#textures = new Array(Math.max(0, maxIndex + 1)).fill(null);
|
|
172
|
+
for (const t of textures) this.#textures[t.index] = t;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
draw(drawables: DrawableMesh[], _modelMatrix: Float32Array): void {
|
|
176
|
+
const ctx = this.#ctx;
|
|
177
|
+
const w = ctx.canvas.width;
|
|
178
|
+
const h = ctx.canvas.height;
|
|
179
|
+
const byIndex = new Map<number, DrawableMesh>();
|
|
180
|
+
for (const d of drawables) byIndex.set(d.index, d);
|
|
181
|
+
// UV-grid only: Canvas2D cannot isolate R/G/B/A channels.
|
|
182
|
+
const partitioned = partitionForClipping(drawables, {
|
|
183
|
+
mode: "uv-grid",
|
|
184
|
+
});
|
|
185
|
+
const contexts = fitClippingContexts(partitioned.contexts, byIndex);
|
|
186
|
+
const { maskOnly } = partitioned;
|
|
187
|
+
|
|
188
|
+
if (contexts.length === 0) {
|
|
189
|
+
for (const d of drawables) {
|
|
190
|
+
if (maskOnly.has(d.index)) continue;
|
|
191
|
+
this.#drawOne(ctx, d, w, h);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const maskCanvas = document.createElement("canvas");
|
|
197
|
+
maskCanvas.width = w;
|
|
198
|
+
maskCanvas.height = h;
|
|
199
|
+
const maskCtx = maskCanvas.getContext("2d");
|
|
200
|
+
const scratchCanvas = document.createElement("canvas");
|
|
201
|
+
scratchCanvas.width = w;
|
|
202
|
+
scratchCanvas.height = h;
|
|
203
|
+
const scratchCtx = scratchCanvas.getContext("2d");
|
|
204
|
+
const layerCanvas = document.createElement("canvas");
|
|
205
|
+
layerCanvas.width = w;
|
|
206
|
+
layerCanvas.height = h;
|
|
207
|
+
const layerCtx = layerCanvas.getContext("2d");
|
|
208
|
+
const fullMaskCanvas = document.createElement("canvas");
|
|
209
|
+
fullMaskCanvas.width = w;
|
|
210
|
+
fullMaskCanvas.height = h;
|
|
211
|
+
const fullMaskCtx = fullMaskCanvas.getContext("2d");
|
|
212
|
+
if (!maskCtx || !scratchCtx || !layerCtx || !fullMaskCtx) return;
|
|
213
|
+
|
|
214
|
+
maskCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
215
|
+
maskCtx.clearRect(0, 0, w, h);
|
|
216
|
+
for (const c of contexts) {
|
|
217
|
+
scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
218
|
+
scratchCtx.clearRect(0, 0, w, h);
|
|
219
|
+
for (const mi of c.maskIndices) {
|
|
220
|
+
const maskMesh = byIndex.get(mi);
|
|
221
|
+
if (maskMesh) this.#drawOne(scratchCtx, maskMesh, w, h);
|
|
222
|
+
}
|
|
223
|
+
const src = ndcBoundsToPixels(c.modelBounds, w, h);
|
|
224
|
+
const L = c.layout;
|
|
225
|
+
maskCtx.drawImage(
|
|
226
|
+
scratchCanvas,
|
|
227
|
+
src.x,
|
|
228
|
+
src.y,
|
|
229
|
+
src.width,
|
|
230
|
+
src.height,
|
|
231
|
+
L.x * w,
|
|
232
|
+
L.y * h,
|
|
233
|
+
L.width * w,
|
|
234
|
+
L.height * h,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const layoutByClippedIndex = new Map<
|
|
239
|
+
number,
|
|
240
|
+
{
|
|
241
|
+
layout: MaskLayoutRect;
|
|
242
|
+
modelBounds: MaskLayoutRect;
|
|
243
|
+
invertedMask: boolean;
|
|
244
|
+
}
|
|
245
|
+
>();
|
|
246
|
+
for (const c of contexts) {
|
|
247
|
+
for (const ci of c.clippedIndices) {
|
|
248
|
+
layoutByClippedIndex.set(ci, {
|
|
249
|
+
layout: c.layout,
|
|
250
|
+
modelBounds: c.modelBounds,
|
|
251
|
+
invertedMask: c.invertedMask,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const d of drawables) {
|
|
257
|
+
if (maskOnly.has(d.index)) continue;
|
|
258
|
+
const clip = layoutByClippedIndex.get(d.index);
|
|
259
|
+
if (!clip) {
|
|
260
|
+
this.#drawOne(ctx, d, w, h);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
layerCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
264
|
+
layerCtx.globalCompositeOperation = "source-over";
|
|
265
|
+
layerCtx.clearRect(0, 0, w, h);
|
|
266
|
+
this.#drawOne(layerCtx, d, w, h);
|
|
267
|
+
|
|
268
|
+
// Expand atlas cell into the modelBounds region on a full mask.
|
|
269
|
+
fullMaskCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
270
|
+
fullMaskCtx.clearRect(0, 0, w, h);
|
|
271
|
+
const L = clip.layout;
|
|
272
|
+
const dst = ndcBoundsToPixels(clip.modelBounds, w, h);
|
|
273
|
+
fullMaskCtx.drawImage(
|
|
274
|
+
maskCanvas,
|
|
275
|
+
L.x * w,
|
|
276
|
+
L.y * h,
|
|
277
|
+
L.width * w,
|
|
278
|
+
L.height * h,
|
|
279
|
+
dst.x,
|
|
280
|
+
dst.y,
|
|
281
|
+
dst.width,
|
|
282
|
+
dst.height,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
layerCtx.globalCompositeOperation = clip.invertedMask
|
|
286
|
+
? "destination-out"
|
|
287
|
+
: "destination-in";
|
|
288
|
+
layerCtx.drawImage(fullMaskCanvas, 0, 0);
|
|
289
|
+
layerCtx.globalCompositeOperation = "source-over";
|
|
290
|
+
ctx.save();
|
|
291
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
292
|
+
ctx.drawImage(layerCanvas, 0, 0);
|
|
293
|
+
ctx.restore();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#drawOne(
|
|
298
|
+
ctx: CanvasRenderingContext2D,
|
|
299
|
+
d: DrawableMesh,
|
|
300
|
+
w: number,
|
|
301
|
+
h: number,
|
|
302
|
+
): void {
|
|
303
|
+
if (!d.visible || d.opacity <= 0) return;
|
|
304
|
+
const sx = w / 2;
|
|
305
|
+
const sy = h / 2;
|
|
306
|
+
const tex = this.#textures[d.textureIndex] ?? null;
|
|
307
|
+
const pos = d.vertexPositions;
|
|
308
|
+
const uvs = d.uvs;
|
|
309
|
+
const idx = d.indices;
|
|
310
|
+
|
|
311
|
+
if (tex) {
|
|
312
|
+
ctx.save();
|
|
313
|
+
ctx.globalAlpha = d.opacity;
|
|
314
|
+
ctx.globalCompositeOperation = canvasCompositeForBlendMode(
|
|
315
|
+
d.blendMode,
|
|
316
|
+
);
|
|
317
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
318
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
319
|
+
const a = idx[i]!;
|
|
320
|
+
const b = idx[i + 1]!;
|
|
321
|
+
const c = idx[i + 2]!;
|
|
322
|
+
// Geometry: single Y-up → Y-down map. UV stays as authored.
|
|
323
|
+
const ax = modelXToCanvasPixelX(pos[a * 2]!, w);
|
|
324
|
+
const ay = modelYUpToCanvasPixelY(pos[a * 2 + 1]!, h);
|
|
325
|
+
const bx = modelXToCanvasPixelX(pos[b * 2]!, w);
|
|
326
|
+
const by = modelYUpToCanvasPixelY(pos[b * 2 + 1]!, h);
|
|
327
|
+
const cx = modelXToCanvasPixelX(pos[c * 2]!, w);
|
|
328
|
+
const cy = modelYUpToCanvasPixelY(pos[c * 2 + 1]!, h);
|
|
329
|
+
drawTexturedTriangle(
|
|
330
|
+
ctx,
|
|
331
|
+
tex.image,
|
|
332
|
+
tex.width,
|
|
333
|
+
tex.height,
|
|
334
|
+
ax,
|
|
335
|
+
ay,
|
|
336
|
+
bx,
|
|
337
|
+
by,
|
|
338
|
+
cx,
|
|
339
|
+
cy,
|
|
340
|
+
uvs[a * 2] ?? 0,
|
|
341
|
+
uvs[a * 2 + 1] ?? 0,
|
|
342
|
+
uvs[b * 2] ?? 0,
|
|
343
|
+
uvs[b * 2 + 1] ?? 0,
|
|
344
|
+
uvs[c * 2] ?? 0,
|
|
345
|
+
uvs[c * 2 + 1] ?? 0,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
ctx.restore();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
ctx.save();
|
|
353
|
+
ctx.translate(sx, sy);
|
|
354
|
+
ctx.scale(sx, -sy);
|
|
355
|
+
ctx.globalAlpha = d.opacity;
|
|
356
|
+
ctx.globalCompositeOperation = canvasCompositeForBlendMode(d.blendMode);
|
|
357
|
+
ctx.fillStyle =
|
|
358
|
+
this.#options.fill ?? DEFAULT_FILL ?? rgbaCss(PREVIEW_FILL);
|
|
359
|
+
ctx.strokeStyle =
|
|
360
|
+
this.#options.stroke ?? DEFAULT_STROKE ?? rgbaCss(PREVIEW_STROKE);
|
|
361
|
+
ctx.lineWidth = 2 / sx;
|
|
362
|
+
|
|
363
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
364
|
+
const i0 = idx[i]! * 2;
|
|
365
|
+
const i1 = idx[i + 1]! * 2;
|
|
366
|
+
const i2 = idx[i + 2]! * 2;
|
|
367
|
+
ctx.beginPath();
|
|
368
|
+
ctx.moveTo(pos[i0]!, pos[i0 + 1]!);
|
|
369
|
+
ctx.lineTo(pos[i1]!, pos[i1 + 1]!);
|
|
370
|
+
ctx.lineTo(pos[i2]!, pos[i2 + 1]!);
|
|
371
|
+
ctx.closePath();
|
|
372
|
+
ctx.fill();
|
|
373
|
+
ctx.stroke();
|
|
374
|
+
}
|
|
375
|
+
ctx.restore();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
destroy(): void {
|
|
379
|
+
this.#textures = [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Canvas2D soft renderer — CPU FrameSnapshot preview without WebGPU/WebGL. */
|
|
384
|
+
export class Canvas2DRendererImpl implements Renderer {
|
|
385
|
+
readonly kind = "canvas2d" as const;
|
|
386
|
+
|
|
387
|
+
#canvas: HTMLCanvasElement | null = null;
|
|
388
|
+
#ctx: CanvasRenderingContext2D | null = null;
|
|
389
|
+
readonly #options: Canvas2DRendererOptions;
|
|
390
|
+
|
|
391
|
+
constructor(options: Canvas2DRendererOptions = {}) {
|
|
392
|
+
this.#options = options;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async initialize(canvas: HTMLCanvasElement): Promise<void> {
|
|
396
|
+
const ctx = canvas.getContext("2d");
|
|
397
|
+
if (!ctx) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
"@doki-land/live2d-renderer: Canvas2D is not available",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
this.#canvas = canvas;
|
|
403
|
+
this.#ctx = ctx;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
createModelDrawPass(): ModelDrawPass {
|
|
407
|
+
if (!this.#ctx) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
"@doki-land/live2d-renderer: Canvas2D renderer not initialized",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return new Canvas2DModelDrawPass(this.#ctx, this.#options);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
beginFrame(): void {
|
|
416
|
+
const ctx = this.#ctx;
|
|
417
|
+
const canvas = this.#canvas;
|
|
418
|
+
if (!ctx || !canvas) return;
|
|
419
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
420
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
endFrame(): void {}
|
|
424
|
+
|
|
425
|
+
resize(width: number, height: number): void {
|
|
426
|
+
if (!this.#canvas) return;
|
|
427
|
+
this.#canvas.width = width;
|
|
428
|
+
this.#canvas.height = height;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
destroy(): void {
|
|
432
|
+
this.#ctx = null;
|
|
433
|
+
this.#canvas = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function createCanvas2DRenderer(
|
|
438
|
+
options?: Canvas2DRendererOptions,
|
|
439
|
+
): Renderer {
|
|
440
|
+
return new Canvas2DRendererImpl(options);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function isCanvas2DAvailable(): boolean {
|
|
444
|
+
if (typeof document === "undefined") return false;
|
|
445
|
+
const canvas = document.createElement("canvas");
|
|
446
|
+
return !!canvas.getContext("2d");
|
|
447
|
+
}
|