@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.
@@ -0,0 +1,685 @@
1
+ /**
2
+ * WebGL2 renderer with soft clipping-mask support.
3
+ *
4
+ * Mask path: render mask meshes into an offscreen FBO (NDC→UV 1:1), then
5
+ * multiply drawable alpha by the mask (or 1−mask when inverted).
6
+ */
7
+
8
+ import { applyWebGl2BlendMode } from "../blend.js";
9
+ import {
10
+ fitClippingContexts,
11
+ type LaidOutClippingContext,
12
+ type MaskLayoutRect,
13
+ maskChannelVec4,
14
+ maskLayoutVec4,
15
+ partitionForClipping,
16
+ } from "../clipping.js";
17
+ import {
18
+ PREVIEW_FILL,
19
+ PREVIEW_STROKE,
20
+ triangleEdgesToLineList,
21
+ } from "../preview-style.js";
22
+ import type {
23
+ DrawableMesh,
24
+ ModelDrawPass,
25
+ TextureData,
26
+ WebGl2Renderer,
27
+ } from "../types.js";
28
+
29
+ const VS = `#version 300 es
30
+ layout(location = 0) in vec2 a_pos;
31
+ layout(location = 1) in vec2 a_uv;
32
+ out vec2 v_uv;
33
+ out vec2 v_pos;
34
+ void main() {
35
+ v_uv = a_uv;
36
+ v_pos = a_pos;
37
+ gl_Position = vec4(a_pos, 0.0, 1.0);
38
+ }
39
+ `;
40
+
41
+ /** Mask write: map modelBounds → atlas UV cell → clip space. */
42
+ const VS_MASK = `#version 300 es
43
+ layout(location = 0) in vec2 a_pos;
44
+ layout(location = 1) in vec2 a_uv;
45
+ uniform vec4 u_mask_layout; // xy offset, zw size in 0..1
46
+ uniform vec4 u_mask_bounds; // model-space xy min, zw size
47
+ out vec2 v_uv;
48
+ void main() {
49
+ v_uv = a_uv;
50
+ vec2 local = (a_pos - u_mask_bounds.xy) / max(u_mask_bounds.zw, vec2(1e-6));
51
+ vec2 atlasUv = u_mask_layout.xy + local * u_mask_layout.zw;
52
+ gl_Position = vec4(atlasUv * 2.0 - 1.0, 0.0, 1.0);
53
+ }
54
+ `;
55
+
56
+ const FS = `#version 300 es
57
+ precision mediump float;
58
+ in vec2 v_uv;
59
+ in vec2 v_pos;
60
+ uniform sampler2D u_tex;
61
+ uniform sampler2D u_mask;
62
+ uniform vec4 u_color;
63
+ uniform vec4 u_mask_layout;
64
+ uniform vec4 u_mask_bounds;
65
+ uniform vec4 u_channel_flag;
66
+ uniform float u_use_texture;
67
+ uniform float u_opacity;
68
+ uniform float u_use_mask;
69
+ uniform float u_invert_mask;
70
+ out vec4 out_color;
71
+ void main() {
72
+ vec4 base;
73
+ if (u_use_texture > 0.5) {
74
+ vec4 tex = texture(u_tex, v_uv);
75
+ base = vec4(tex.rgb, tex.a * u_opacity);
76
+ } else {
77
+ base = u_color;
78
+ }
79
+ if (u_use_mask > 0.5) {
80
+ vec2 local = (v_pos - u_mask_bounds.xy) / max(u_mask_bounds.zw, vec2(1e-6));
81
+ vec2 muv = u_mask_layout.xy + local * u_mask_layout.zw;
82
+ float m = dot(texture(u_mask, muv), u_channel_flag);
83
+ if (u_invert_mask > 0.5) m = 1.0 - m;
84
+ base.a *= m;
85
+ base.rgb *= m;
86
+ }
87
+ out_color = base;
88
+ }
89
+ `;
90
+
91
+ /** Mask pass: write coverage into the selected RGBA channel. */
92
+ const FS_MASK = `#version 300 es
93
+ precision mediump float;
94
+ in vec2 v_uv;
95
+ uniform sampler2D u_tex;
96
+ uniform vec4 u_channel_flag;
97
+ uniform float u_use_texture;
98
+ uniform float u_opacity;
99
+ out vec4 out_color;
100
+ void main() {
101
+ float a = u_opacity;
102
+ if (u_use_texture > 0.5) {
103
+ a *= texture(u_tex, v_uv).a;
104
+ }
105
+ out_color = u_channel_flag * a;
106
+ }
107
+ `;
108
+
109
+ function compile(
110
+ gl: WebGL2RenderingContext,
111
+ type: number,
112
+ source: string,
113
+ ): WebGLShader {
114
+ const shader = gl.createShader(type);
115
+ if (!shader) {
116
+ throw new Error("@doki-land/live2d-renderer: failed to create shader");
117
+ }
118
+ gl.shaderSource(shader, source);
119
+ gl.compileShader(shader);
120
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
121
+ const info = gl.getShaderInfoLog(shader) ?? "compile failed";
122
+ gl.deleteShader(shader);
123
+ throw new Error(`@doki-land/live2d-renderer: ${info}`);
124
+ }
125
+ return shader;
126
+ }
127
+
128
+ function linkProgram(
129
+ gl: WebGL2RenderingContext,
130
+ vsSource: string,
131
+ fsSource: string,
132
+ ): WebGLProgram {
133
+ const vs = compile(gl, gl.VERTEX_SHADER, vsSource);
134
+ const fs = compile(gl, gl.FRAGMENT_SHADER, fsSource);
135
+ const program = gl.createProgram();
136
+ if (!program) {
137
+ throw new Error("@doki-land/live2d-renderer: failed to create program");
138
+ }
139
+ gl.attachShader(program, vs);
140
+ gl.attachShader(program, fs);
141
+ gl.linkProgram(program);
142
+ gl.deleteShader(vs);
143
+ gl.deleteShader(fs);
144
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
145
+ const info = gl.getProgramInfoLog(program) ?? "link failed";
146
+ gl.deleteProgram(program);
147
+ throw new Error(`@doki-land/live2d-renderer: ${info}`);
148
+ }
149
+ return program;
150
+ }
151
+
152
+ function interleavePosUv(
153
+ positions: Float32Array,
154
+ uvs: Float32Array,
155
+ ): Float32Array {
156
+ const n = Math.floor(positions.length / 2);
157
+ const out = new Float32Array(n * 4);
158
+ for (let i = 0; i < n; i++) {
159
+ const o = i * 4;
160
+ const p = i * 2;
161
+ out[o] = positions[p]!;
162
+ out[o + 1] = positions[p + 1]!;
163
+ out[o + 2] = uvs[p] ?? 0;
164
+ out[o + 3] = uvs[p + 1] ?? 0;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function requireUniform(
170
+ gl: WebGL2RenderingContext,
171
+ program: WebGLProgram,
172
+ name: string,
173
+ ): WebGLUniformLocation {
174
+ const loc = gl.getUniformLocation(program, name);
175
+ if (!loc) {
176
+ throw new Error(
177
+ `@doki-land/live2d-renderer: required uniform missing: ${name}`,
178
+ );
179
+ }
180
+ return loc;
181
+ }
182
+
183
+ class WebGl2ModelDrawPass implements ModelDrawPass {
184
+ readonly #gl: WebGL2RenderingContext;
185
+ readonly #program: WebGLProgram;
186
+ readonly #maskProgram: WebGLProgram;
187
+ readonly #colorLoc: WebGLUniformLocation;
188
+ readonly #opacityLoc: WebGLUniformLocation;
189
+ readonly #useTexLoc: WebGLUniformLocation;
190
+ readonly #texLoc: WebGLUniformLocation;
191
+ readonly #maskLoc: WebGLUniformLocation;
192
+ readonly #useMaskLoc: WebGLUniformLocation;
193
+ readonly #invertMaskLoc: WebGLUniformLocation;
194
+ readonly #maskLayoutLoc: WebGLUniformLocation;
195
+ readonly #maskBoundsLoc: WebGLUniformLocation;
196
+ readonly #channelFlagLoc: WebGLUniformLocation;
197
+ readonly #maskOpacityLoc: WebGLUniformLocation;
198
+ readonly #maskUseTexLoc: WebGLUniformLocation;
199
+ readonly #maskTexLoc: WebGLUniformLocation;
200
+ readonly #maskWriteLayoutLoc: WebGLUniformLocation;
201
+ readonly #maskWriteBoundsLoc: WebGLUniformLocation;
202
+ readonly #maskWriteChannelLoc: WebGLUniformLocation;
203
+ readonly #vao: WebGLVertexArrayObject;
204
+ readonly #vbo: WebGLBuffer;
205
+ readonly #ibo: WebGLBuffer;
206
+ #gpuTextures: (WebGLTexture | null)[] = [];
207
+ #maskFbo: WebGLFramebuffer | null = null;
208
+ #maskTex: WebGLTexture | null = null;
209
+ #maskW = 0;
210
+ #maskH = 0;
211
+
212
+ constructor(gl: WebGL2RenderingContext) {
213
+ this.#gl = gl;
214
+ this.#program = linkProgram(gl, VS, FS);
215
+ this.#maskProgram = linkProgram(gl, VS_MASK, FS_MASK);
216
+
217
+ this.#colorLoc = requireUniform(gl, this.#program, "u_color");
218
+ this.#opacityLoc = requireUniform(gl, this.#program, "u_opacity");
219
+ this.#useTexLoc = requireUniform(gl, this.#program, "u_use_texture");
220
+ this.#texLoc = requireUniform(gl, this.#program, "u_tex");
221
+ this.#maskLoc = requireUniform(gl, this.#program, "u_mask");
222
+ this.#useMaskLoc = requireUniform(gl, this.#program, "u_use_mask");
223
+ this.#invertMaskLoc = requireUniform(
224
+ gl,
225
+ this.#program,
226
+ "u_invert_mask",
227
+ );
228
+ this.#maskLayoutLoc = requireUniform(
229
+ gl,
230
+ this.#program,
231
+ "u_mask_layout",
232
+ );
233
+ this.#maskBoundsLoc = requireUniform(
234
+ gl,
235
+ this.#program,
236
+ "u_mask_bounds",
237
+ );
238
+ this.#channelFlagLoc = requireUniform(
239
+ gl,
240
+ this.#program,
241
+ "u_channel_flag",
242
+ );
243
+
244
+ this.#maskOpacityLoc = requireUniform(
245
+ gl,
246
+ this.#maskProgram,
247
+ "u_opacity",
248
+ );
249
+ this.#maskUseTexLoc = requireUniform(
250
+ gl,
251
+ this.#maskProgram,
252
+ "u_use_texture",
253
+ );
254
+ this.#maskTexLoc = requireUniform(gl, this.#maskProgram, "u_tex");
255
+ this.#maskWriteLayoutLoc = requireUniform(
256
+ gl,
257
+ this.#maskProgram,
258
+ "u_mask_layout",
259
+ );
260
+ this.#maskWriteBoundsLoc = requireUniform(
261
+ gl,
262
+ this.#maskProgram,
263
+ "u_mask_bounds",
264
+ );
265
+ this.#maskWriteChannelLoc = requireUniform(
266
+ gl,
267
+ this.#maskProgram,
268
+ "u_channel_flag",
269
+ );
270
+
271
+ const vao = gl.createVertexArray();
272
+ const vbo = gl.createBuffer();
273
+ const ibo = gl.createBuffer();
274
+ if (!vao || !vbo || !ibo) {
275
+ throw new Error("@doki-land/live2d-renderer: buffer alloc failed");
276
+ }
277
+ this.#vao = vao;
278
+ this.#vbo = vbo;
279
+ this.#ibo = ibo;
280
+
281
+ gl.bindVertexArray(vao);
282
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
283
+ gl.enableVertexAttribArray(0);
284
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0);
285
+ gl.enableVertexAttribArray(1);
286
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8);
287
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
288
+ gl.bindVertexArray(null);
289
+ }
290
+
291
+ #ensureMaskTarget(width: number, height: number): void {
292
+ const gl = this.#gl;
293
+ const w = Math.max(1, width);
294
+ const h = Math.max(1, height);
295
+ if (
296
+ this.#maskFbo &&
297
+ this.#maskTex &&
298
+ this.#maskW === w &&
299
+ this.#maskH === h
300
+ ) {
301
+ return;
302
+ }
303
+ if (this.#maskTex) gl.deleteTexture(this.#maskTex);
304
+ if (this.#maskFbo) gl.deleteFramebuffer(this.#maskFbo);
305
+
306
+ const tex = gl.createTexture();
307
+ const fbo = gl.createFramebuffer();
308
+ if (!tex || !fbo) {
309
+ throw new Error(
310
+ "@doki-land/live2d-renderer: mask framebuffer alloc failed",
311
+ );
312
+ }
313
+ gl.bindTexture(gl.TEXTURE_2D, tex);
314
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
315
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
316
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
317
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
318
+ gl.texImage2D(
319
+ gl.TEXTURE_2D,
320
+ 0,
321
+ gl.RGBA,
322
+ w,
323
+ h,
324
+ 0,
325
+ gl.RGBA,
326
+ gl.UNSIGNED_BYTE,
327
+ null,
328
+ );
329
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
330
+ gl.framebufferTexture2D(
331
+ gl.FRAMEBUFFER,
332
+ gl.COLOR_ATTACHMENT0,
333
+ gl.TEXTURE_2D,
334
+ tex,
335
+ 0,
336
+ );
337
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
338
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
339
+ gl.bindTexture(gl.TEXTURE_2D, null);
340
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
341
+ gl.deleteTexture(tex);
342
+ gl.deleteFramebuffer(fbo);
343
+ throw new Error(
344
+ `@doki-land/live2d-renderer: incomplete mask FBO (${status})`,
345
+ );
346
+ }
347
+ this.#maskTex = tex;
348
+ this.#maskFbo = fbo;
349
+ this.#maskW = w;
350
+ this.#maskH = h;
351
+ }
352
+
353
+ #clearGpuTextures(): void {
354
+ const gl = this.#gl;
355
+ for (const t of this.#gpuTextures) {
356
+ if (t) gl.deleteTexture(t);
357
+ }
358
+ this.#gpuTextures = [];
359
+ }
360
+
361
+ setTextures(textures: TextureData[]): void {
362
+ const gl = this.#gl;
363
+ this.#clearGpuTextures();
364
+ let maxIndex = -1;
365
+ for (const t of textures) maxIndex = Math.max(maxIndex, t.index);
366
+ this.#gpuTextures = new Array(maxIndex + 1).fill(null);
367
+
368
+ for (const t of textures) {
369
+ const tex = gl.createTexture();
370
+ if (!tex) continue;
371
+ gl.bindTexture(gl.TEXTURE_2D, tex);
372
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
373
+ gl.texParameteri(
374
+ gl.TEXTURE_2D,
375
+ gl.TEXTURE_WRAP_S,
376
+ gl.CLAMP_TO_EDGE,
377
+ );
378
+ gl.texParameteri(
379
+ gl.TEXTURE_2D,
380
+ gl.TEXTURE_WRAP_T,
381
+ gl.CLAMP_TO_EDGE,
382
+ );
383
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
384
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
385
+ gl.texImage2D(
386
+ gl.TEXTURE_2D,
387
+ 0,
388
+ gl.RGBA,
389
+ gl.RGBA,
390
+ gl.UNSIGNED_BYTE,
391
+ t.image,
392
+ );
393
+ this.#gpuTextures[t.index] = tex;
394
+ }
395
+ gl.bindTexture(gl.TEXTURE_2D, null);
396
+ }
397
+
398
+ #uploadMesh(d: DrawableMesh): void {
399
+ const gl = this.#gl;
400
+ const interleaved = interleavePosUv(d.vertexPositions, d.uvs);
401
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.#vbo);
402
+ gl.bufferData(gl.ARRAY_BUFFER, interleaved, gl.DYNAMIC_DRAW);
403
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.#ibo);
404
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, d.indices, gl.DYNAMIC_DRAW);
405
+ }
406
+
407
+ #drawMeshColor(
408
+ d: DrawableMesh,
409
+ useMask: boolean,
410
+ invertMask: boolean,
411
+ layout: MaskLayoutRect | null,
412
+ modelBounds: MaskLayoutRect | null,
413
+ channelFlag: Float32Array | null,
414
+ ): void {
415
+ const gl = this.#gl;
416
+ if (!d.visible || d.opacity <= 0) return;
417
+
418
+ applyWebGl2BlendMode(gl, d.blendMode);
419
+ this.#uploadMesh(d);
420
+
421
+ const gpuTex = this.#gpuTextures[d.textureIndex] ?? null;
422
+ const textured = gpuTex !== null;
423
+ gl.activeTexture(gl.TEXTURE0);
424
+ gl.bindTexture(gl.TEXTURE_2D, gpuTex);
425
+ gl.uniform1i(this.#texLoc, 0);
426
+ gl.uniform1f(this.#useTexLoc, textured ? 1 : 0);
427
+ gl.uniform1f(this.#opacityLoc, d.opacity);
428
+ gl.uniform4f(
429
+ this.#colorLoc,
430
+ PREVIEW_FILL.r,
431
+ PREVIEW_FILL.g,
432
+ PREVIEW_FILL.b,
433
+ PREVIEW_FILL.a * d.opacity,
434
+ );
435
+ gl.uniform1f(this.#useMaskLoc, useMask ? 1 : 0);
436
+ gl.uniform1f(this.#invertMaskLoc, invertMask ? 1 : 0);
437
+ const layoutVec = maskLayoutVec4(
438
+ layout ?? { x: 0, y: 0, width: 1, height: 1 },
439
+ );
440
+ gl.uniform4fv(this.#maskLayoutLoc, layoutVec);
441
+ gl.uniform4fv(
442
+ this.#maskBoundsLoc,
443
+ maskLayoutVec4(
444
+ modelBounds ?? { x: -1, y: -1, width: 2, height: 2 },
445
+ ),
446
+ );
447
+ gl.uniform4fv(
448
+ this.#channelFlagLoc,
449
+ channelFlag ?? new Float32Array([0, 0, 0, 1]),
450
+ );
451
+ if (useMask && this.#maskTex) {
452
+ gl.activeTexture(gl.TEXTURE1);
453
+ gl.bindTexture(gl.TEXTURE_2D, this.#maskTex);
454
+ gl.uniform1i(this.#maskLoc, 1);
455
+ }
456
+
457
+ gl.drawElements(gl.TRIANGLES, d.indices.length, gl.UNSIGNED_SHORT, 0);
458
+
459
+ if (!textured && !useMask) {
460
+ const lines = triangleEdgesToLineList(d.indices);
461
+ gl.uniform1f(this.#useTexLoc, 0);
462
+ gl.uniform4f(
463
+ this.#colorLoc,
464
+ PREVIEW_STROKE.r,
465
+ PREVIEW_STROKE.g,
466
+ PREVIEW_STROKE.b,
467
+ PREVIEW_STROKE.a * d.opacity,
468
+ );
469
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, lines, gl.DYNAMIC_DRAW);
470
+ gl.drawElements(gl.LINES, lines.length, gl.UNSIGNED_SHORT, 0);
471
+ }
472
+ }
473
+
474
+ #drawMeshMask(
475
+ d: DrawableMesh,
476
+ layout: MaskLayoutRect,
477
+ modelBounds: MaskLayoutRect,
478
+ channelFlag: Float32Array,
479
+ ): void {
480
+ const gl = this.#gl;
481
+ if (d.opacity <= 0) return;
482
+ this.#uploadMesh(d);
483
+ const gpuTex = this.#gpuTextures[d.textureIndex] ?? null;
484
+ gl.activeTexture(gl.TEXTURE0);
485
+ gl.bindTexture(gl.TEXTURE_2D, gpuTex);
486
+ gl.uniform1i(this.#maskTexLoc, 0);
487
+ gl.uniform1f(this.#maskUseTexLoc, gpuTex ? 1 : 0);
488
+ gl.uniform1f(this.#maskOpacityLoc, Math.max(d.opacity, 1));
489
+ gl.uniform4fv(this.#maskWriteLayoutLoc, maskLayoutVec4(layout));
490
+ gl.uniform4fv(this.#maskWriteBoundsLoc, maskLayoutVec4(modelBounds));
491
+ gl.uniform4fv(this.#maskWriteChannelLoc, channelFlag);
492
+ gl.drawElements(gl.TRIANGLES, d.indices.length, gl.UNSIGNED_SHORT, 0);
493
+ }
494
+
495
+ draw(drawables: DrawableMesh[], _modelMatrix: Float32Array): void {
496
+ const gl = this.#gl;
497
+ const canvas = gl.canvas;
498
+ const width =
499
+ canvas instanceof HTMLCanvasElement ? canvas.width : canvas.width;
500
+ const height =
501
+ canvas instanceof HTMLCanvasElement ? canvas.height : canvas.height;
502
+
503
+ const byIndex = new Map<number, DrawableMesh>();
504
+ for (const d of drawables) byIndex.set(d.index, d);
505
+
506
+ const partitioned = partitionForClipping(drawables);
507
+ const contexts = fitClippingContexts(partitioned.contexts, byIndex);
508
+ const { maskOnly } = partitioned;
509
+
510
+ gl.bindVertexArray(this.#vao);
511
+
512
+ if (contexts.length > 0) {
513
+ this.#ensureMaskTarget(width, height);
514
+ const fbo = this.#maskFbo;
515
+ if (fbo && this.#maskTex) {
516
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
517
+ gl.viewport(0, 0, this.#maskW, this.#maskH);
518
+ gl.clearColor(0, 0, 0, 0);
519
+ gl.clear(gl.COLOR_BUFFER_BIT);
520
+ gl.useProgram(this.#maskProgram);
521
+ gl.enable(gl.BLEND);
522
+ gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
523
+
524
+ for (const ctx of contexts) {
525
+ const flag = maskChannelVec4(ctx.channelFlag);
526
+ for (const mi of ctx.maskIndices) {
527
+ const maskMesh = byIndex.get(mi);
528
+ if (maskMesh) {
529
+ this.#drawMeshMask(
530
+ maskMesh,
531
+ ctx.layout,
532
+ ctx.modelBounds,
533
+ flag,
534
+ );
535
+ }
536
+ }
537
+ }
538
+
539
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
540
+ gl.viewport(0, 0, width, height);
541
+ }
542
+ }
543
+
544
+ gl.useProgram(this.#program);
545
+ const layoutByClippedIndex = new Map<
546
+ number,
547
+ Pick<
548
+ LaidOutClippingContext,
549
+ "layout" | "modelBounds" | "invertedMask" | "channelFlag"
550
+ >
551
+ >();
552
+ for (const ctx of contexts) {
553
+ for (const ci of ctx.clippedIndices) {
554
+ layoutByClippedIndex.set(ci, {
555
+ layout: ctx.layout,
556
+ modelBounds: ctx.modelBounds,
557
+ invertedMask: ctx.invertedMask,
558
+ channelFlag: ctx.channelFlag,
559
+ });
560
+ }
561
+ }
562
+
563
+ for (const d of drawables) {
564
+ if (maskOnly.has(d.index)) continue;
565
+ const clip = layoutByClippedIndex.get(d.index);
566
+ if (clip) {
567
+ this.#drawMeshColor(
568
+ d,
569
+ true,
570
+ clip.invertedMask,
571
+ clip.layout,
572
+ clip.modelBounds,
573
+ maskChannelVec4(clip.channelFlag),
574
+ );
575
+ } else {
576
+ this.#drawMeshColor(d, false, false, null, null, null);
577
+ }
578
+ }
579
+
580
+ gl.activeTexture(gl.TEXTURE1);
581
+ gl.bindTexture(gl.TEXTURE_2D, null);
582
+ gl.activeTexture(gl.TEXTURE0);
583
+ gl.bindTexture(gl.TEXTURE_2D, null);
584
+ gl.bindVertexArray(null);
585
+ }
586
+
587
+ destroy(): void {
588
+ const gl = this.#gl;
589
+ this.#clearGpuTextures();
590
+ if (this.#maskTex) gl.deleteTexture(this.#maskTex);
591
+ if (this.#maskFbo) gl.deleteFramebuffer(this.#maskFbo);
592
+ gl.deleteBuffer(this.#vbo);
593
+ gl.deleteBuffer(this.#ibo);
594
+ gl.deleteVertexArray(this.#vao);
595
+ gl.deleteProgram(this.#program);
596
+ gl.deleteProgram(this.#maskProgram);
597
+ }
598
+ }
599
+
600
+ export interface WebGl2RendererOptions {
601
+ antialias?: boolean;
602
+ alpha?: boolean;
603
+ /**
604
+ * Keep color buffer after present so `canvas.toDataURL` / `toBlob` work.
605
+ * Default true (preview / gallery capture). Set false for max FPS if unused.
606
+ */
607
+ preserveDrawingBuffer?: boolean;
608
+ }
609
+
610
+ /** WebGL2 renderer. */
611
+ export class WebGl2RendererImpl implements WebGl2Renderer {
612
+ readonly kind = "webgl2" as const;
613
+
614
+ #canvas: HTMLCanvasElement | null = null;
615
+ #gl: WebGL2RenderingContext | null = null;
616
+ readonly #options: WebGl2RendererOptions;
617
+
618
+ constructor(options: WebGl2RendererOptions = {}) {
619
+ this.#options = options;
620
+ }
621
+
622
+ async initialize(canvas: HTMLCanvasElement): Promise<void> {
623
+ const gl = canvas.getContext("webgl2", {
624
+ antialias: this.#options.antialias ?? true,
625
+ alpha: this.#options.alpha ?? true,
626
+ premultipliedAlpha: true,
627
+ preserveDrawingBuffer: this.#options.preserveDrawingBuffer ?? true,
628
+ powerPreference: "high-performance",
629
+ });
630
+ if (!gl) {
631
+ throw new Error(
632
+ "@doki-land/live2d-renderer: WebGL2 is not available",
633
+ );
634
+ }
635
+ this.#canvas = canvas;
636
+ this.#gl = gl;
637
+ }
638
+
639
+ createModelDrawPass(): ModelDrawPass {
640
+ if (!this.#gl) {
641
+ throw new Error(
642
+ "@doki-land/live2d-renderer: WebGL2 renderer not initialized",
643
+ );
644
+ }
645
+ return new WebGl2ModelDrawPass(this.#gl);
646
+ }
647
+
648
+ beginFrame(): void {
649
+ const gl = this.#gl;
650
+ if (!gl || !this.#canvas) return;
651
+ gl.viewport(0, 0, this.#canvas.width, this.#canvas.height);
652
+ gl.clearColor(0, 0, 0, 0);
653
+ gl.clear(gl.COLOR_BUFFER_BIT);
654
+ }
655
+
656
+ endFrame(): void {}
657
+
658
+ resize(width: number, height: number): void {
659
+ if (!this.#canvas) return;
660
+ this.#canvas.width = width;
661
+ this.#canvas.height = height;
662
+ }
663
+
664
+ getGL(): WebGL2RenderingContext | null {
665
+ return this.#gl;
666
+ }
667
+
668
+ destroy(): void {
669
+ this.#gl = null;
670
+ this.#canvas = null;
671
+ }
672
+ }
673
+
674
+ export function createWebGl2Renderer(
675
+ options?: WebGl2RendererOptions,
676
+ ): WebGl2Renderer {
677
+ return new WebGl2RendererImpl(options);
678
+ }
679
+
680
+ export function isWebGl2Available(): boolean {
681
+ if (typeof document === "undefined") return false;
682
+ const canvas = document.createElement("canvas");
683
+ const gl = canvas.getContext("webgl2");
684
+ return !!gl;
685
+ }