@doki-land/live2d-renderer 0.0.19 → 0.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-renderer",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Live2D rendering — moc2/moc3 decode + WebGPU, WebGL2, Canvas2D backends for live2d.ts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@doki-land/live2d-core": "0.0.19"
48
+ "@doki-land/live2d-core": "0.0.21"
49
49
  },
50
50
  "sideEffects": false
51
51
  }
@@ -1,9 +1,6 @@
1
1
  import { canvasCompositeForBlendMode } from "../render/blend.js";
2
- import {
3
- fitClippingContexts,
4
- type MaskLayoutRect,
5
- partitionForClipping,
6
- } from "../render/clipping.js";
2
+ import type { MaskLayoutRect } from "../render/clipping.js";
3
+ import { ResidentClippingPlan } from "../render/clipping-plan.js";
7
4
  import {
8
5
  modelXToCanvasPixelX,
9
6
  modelYUpToCanvasPixelY,
@@ -159,6 +156,7 @@ class Canvas2DModelDrawPass implements ModelDrawPass {
159
156
  readonly #ctx: CanvasRenderingContext2D;
160
157
  readonly #options: Canvas2DRendererOptions;
161
158
  #textures: (TextureData | null)[] = [];
159
+ readonly #clipPlan = new ResidentClippingPlan();
162
160
 
163
161
  constructor(
164
162
  ctx: CanvasRenderingContext2D,
@@ -182,10 +180,10 @@ class Canvas2DModelDrawPass implements ModelDrawPass {
182
180
  const byIndex = new Map<number, DrawableMesh>();
183
181
  for (const d of drawables) byIndex.set(d.index, d);
184
182
  // UV-grid only: Canvas2D cannot isolate R/G/B/A channels.
185
- const partitioned = partitionForClipping(drawables, {
183
+ const partitioned = this.#clipPlan.resolveMeshes(drawables, {
186
184
  mode: "uv-grid",
187
185
  });
188
- const contexts = fitClippingContexts(partitioned.contexts, byIndex);
186
+ const contexts = partitioned.contexts;
189
187
  const { maskOnly } = partitioned;
190
188
 
191
189
  if (contexts.length === 0) {
@@ -380,6 +378,7 @@ class Canvas2DModelDrawPass implements ModelDrawPass {
380
378
 
381
379
  destroy(): void {
382
380
  this.#textures = [];
381
+ this.#clipPlan.clear();
383
382
  }
384
383
  }
385
384
 
@@ -7,13 +7,16 @@
7
7
 
8
8
  import { applyWebGl2BlendMode } from "../render/blend.js";
9
9
  import {
10
- fitClippingContexts,
11
10
  type LaidOutClippingContext,
12
11
  type MaskLayoutRect,
13
12
  maskChannelVec4,
14
13
  maskLayoutVec4,
15
- partitionForClipping,
16
14
  } from "../render/clipping.js";
15
+ import { ResidentClippingPlan } from "../render/clipping-plan.js";
16
+ import {
17
+ interleaveByteLength,
18
+ interleavePosUv,
19
+ } from "../render/mesh-interleave.js";
17
20
  import {
18
21
  PREVIEW_FILL,
19
22
  PREVIEW_STROKE,
@@ -149,23 +152,6 @@ function linkProgram(
149
152
  return program;
150
153
  }
151
154
 
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
155
  function requireUniform(
170
156
  gl: WebGL2RenderingContext,
171
157
  program: WebGLProgram,
@@ -208,6 +194,13 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
208
194
  #maskTex: WebGLTexture | null = null;
209
195
  #maskW = 0;
210
196
  #maskH = 0;
197
+ readonly #clipPlan = new ResidentClippingPlan();
198
+ #interleaved: Float32Array<ArrayBufferLike> = new Float32Array(64);
199
+ #indicesRef: Uint16Array | null = null;
200
+ readonly #layoutScratch = new Float32Array(4);
201
+ readonly #boundsScratch = new Float32Array(4);
202
+ readonly #channelScratch = new Float32Array(4);
203
+ readonly #defaultChannel = new Float32Array([0, 0, 0, 1]);
211
204
 
212
205
  constructor(gl: WebGL2RenderingContext) {
213
206
  this.#gl = gl;
@@ -395,13 +388,25 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
395
388
  gl.bindTexture(gl.TEXTURE_2D, null);
396
389
  }
397
390
 
398
- #uploadMesh(d: DrawableMesh): void {
391
+ #uploadMesh(d: DrawableMesh, uploadIndices = true): void {
399
392
  const gl = this.#gl;
400
- const interleaved = interleavePosUv(d.vertexPositions, d.uvs);
393
+ this.#interleaved = interleavePosUv(
394
+ d.vertexPositions,
395
+ d.uvs,
396
+ this.#interleaved,
397
+ );
398
+ const vBytes = interleaveByteLength(d.vertexPositions);
401
399
  gl.bindBuffer(gl.ARRAY_BUFFER, this.#vbo);
402
- gl.bufferData(gl.ARRAY_BUFFER, interleaved, gl.DYNAMIC_DRAW);
400
+ gl.bufferData(
401
+ gl.ARRAY_BUFFER,
402
+ this.#interleaved.subarray(0, vBytes / 4),
403
+ gl.DYNAMIC_DRAW,
404
+ );
403
405
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.#ibo);
404
- gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, d.indices, gl.DYNAMIC_DRAW);
406
+ if (uploadIndices && this.#indicesRef !== d.indices) {
407
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, d.indices, gl.STATIC_DRAW);
408
+ this.#indicesRef = d.indices;
409
+ }
405
410
  }
406
411
 
407
412
  #drawMeshColor(
@@ -434,19 +439,23 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
434
439
  );
435
440
  gl.uniform1f(this.#useMaskLoc, useMask ? 1 : 0);
436
441
  gl.uniform1f(this.#invertMaskLoc, invertMask ? 1 : 0);
437
- const layoutVec = maskLayoutVec4(
438
- layout ?? { x: 0, y: 0, width: 1, height: 1 },
442
+ gl.uniform4fv(
443
+ this.#maskLayoutLoc,
444
+ maskLayoutVec4(
445
+ layout ?? { x: 0, y: 0, width: 1, height: 1 },
446
+ this.#layoutScratch,
447
+ ),
439
448
  );
440
- gl.uniform4fv(this.#maskLayoutLoc, layoutVec);
441
449
  gl.uniform4fv(
442
450
  this.#maskBoundsLoc,
443
451
  maskLayoutVec4(
444
452
  modelBounds ?? { x: -1, y: -1, width: 2, height: 2 },
453
+ this.#boundsScratch,
445
454
  ),
446
455
  );
447
456
  gl.uniform4fv(
448
457
  this.#channelFlagLoc,
449
- channelFlag ?? new Float32Array([0, 0, 0, 1]),
458
+ channelFlag ?? this.#defaultChannel,
450
459
  );
451
460
  if (useMask && this.#maskTex) {
452
461
  gl.activeTexture(gl.TEXTURE1);
@@ -467,6 +476,7 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
467
476
  PREVIEW_STROKE.a * d.opacity,
468
477
  );
469
478
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, lines, gl.DYNAMIC_DRAW);
479
+ this.#indicesRef = null;
470
480
  gl.drawElements(gl.LINES, lines.length, gl.UNSIGNED_SHORT, 0);
471
481
  }
472
482
  }
@@ -486,8 +496,14 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
486
496
  gl.uniform1i(this.#maskTexLoc, 0);
487
497
  gl.uniform1f(this.#maskUseTexLoc, gpuTex ? 1 : 0);
488
498
  gl.uniform1f(this.#maskOpacityLoc, Math.max(d.opacity, 1));
489
- gl.uniform4fv(this.#maskWriteLayoutLoc, maskLayoutVec4(layout));
490
- gl.uniform4fv(this.#maskWriteBoundsLoc, maskLayoutVec4(modelBounds));
499
+ gl.uniform4fv(
500
+ this.#maskWriteLayoutLoc,
501
+ maskLayoutVec4(layout, this.#layoutScratch),
502
+ );
503
+ gl.uniform4fv(
504
+ this.#maskWriteBoundsLoc,
505
+ maskLayoutVec4(modelBounds, this.#boundsScratch),
506
+ );
491
507
  gl.uniform4fv(this.#maskWriteChannelLoc, channelFlag);
492
508
  gl.drawElements(gl.TRIANGLES, d.indices.length, gl.UNSIGNED_SHORT, 0);
493
509
  }
@@ -503,8 +519,8 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
503
519
  const byIndex = new Map<number, DrawableMesh>();
504
520
  for (const d of drawables) byIndex.set(d.index, d);
505
521
 
506
- const partitioned = partitionForClipping(drawables);
507
- const contexts = fitClippingContexts(partitioned.contexts, byIndex);
522
+ const partitioned = this.#clipPlan.resolveMeshes(drawables);
523
+ const contexts = partitioned.contexts;
508
524
  const { maskOnly } = partitioned;
509
525
 
510
526
  gl.bindVertexArray(this.#vao);
@@ -522,7 +538,10 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
522
538
  gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
523
539
 
524
540
  for (const ctx of contexts) {
525
- const flag = maskChannelVec4(ctx.channelFlag);
541
+ const flag = maskChannelVec4(
542
+ ctx.channelFlag,
543
+ this.#channelScratch,
544
+ );
526
545
  for (const mi of ctx.maskIndices) {
527
546
  const maskMesh = byIndex.get(mi);
528
547
  if (maskMesh) {
@@ -570,7 +589,7 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
570
589
  clip.invertedMask,
571
590
  clip.layout,
572
591
  clip.modelBounds,
573
- maskChannelVec4(clip.channelFlag),
592
+ maskChannelVec4(clip.channelFlag, this.#channelScratch),
574
593
  );
575
594
  } else {
576
595
  this.#drawMeshColor(d, false, false, null, null, null);
@@ -587,6 +606,8 @@ class WebGl2ModelDrawPass implements ModelDrawPass {
587
606
  destroy(): void {
588
607
  const gl = this.#gl;
589
608
  this.#clearGpuTextures();
609
+ this.#clipPlan.clear();
610
+ this.#indicesRef = null;
590
611
  if (this.#maskTex) gl.deleteTexture(this.#maskTex);
591
612
  if (this.#maskFbo) gl.deleteFramebuffer(this.#maskFbo);
592
613
  gl.deleteBuffer(this.#vbo);
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Per-drawable WebGPU buffer / bind-group cache.
3
+ * Static index/uv topology stays resident; vertex positions + small UBOs use writeBuffer.
4
+ */
5
+
6
+ import {
7
+ interleaveByteLength,
8
+ interleavePosUv,
9
+ } from "../render/mesh-interleave.js";
10
+
11
+ /** WebGPU INDEX buffers must be a multiple of 4 bytes. */
12
+ export function indexBufferByteSize(byteLength: number): number {
13
+ return (byteLength + 3) & ~3;
14
+ }
15
+
16
+ export interface WebGpuMeshSlot {
17
+ index: number;
18
+ vbo: GPUBuffer;
19
+ vboBytes: number;
20
+ interleaved: Float32Array<ArrayBufferLike>;
21
+ ibo: GPUBuffer;
22
+ iboBytes: number;
23
+ indicesRef: Uint16Array | null;
24
+ uvsRef: Float32Array | null;
25
+ /** Uniform buffer (max 80 bytes covers all shader variants). */
26
+ ubo: GPUBuffer;
27
+ uboFloats: Float32Array<ArrayBufferLike>;
28
+ bgMask: GPUBindGroup | null;
29
+ bgTextured: GPUBindGroup | null;
30
+ bgClippedTextured: GPUBindGroup | null;
31
+ bgSolid: GPUBindGroup | null;
32
+ bgClippedSolid: GPUBindGroup | null;
33
+ texIndex: number;
34
+ useWhite: boolean;
35
+ maskGeneration: number;
36
+ }
37
+
38
+ function createOrGrowBuffer(
39
+ device: GPUDevice,
40
+ prev: GPUBuffer | null,
41
+ prevBytes: number,
42
+ needBytes: number,
43
+ usage: GPUBufferUsageFlags,
44
+ ): { buffer: GPUBuffer; bytes: number } {
45
+ const bytes = Math.max(4, needBytes);
46
+ if (prev && prevBytes >= bytes) return { buffer: prev, bytes: prevBytes };
47
+ prev?.destroy();
48
+ return {
49
+ buffer: device.createBuffer({
50
+ size: bytes,
51
+ usage,
52
+ }),
53
+ bytes,
54
+ };
55
+ }
56
+
57
+ export class WebGpuMeshCache {
58
+ readonly #slots = new Map<number, WebGpuMeshSlot>();
59
+ #maskGeneration = 0;
60
+
61
+ bumpMaskGeneration(): void {
62
+ this.#maskGeneration += 1;
63
+ }
64
+
65
+ get maskGeneration(): number {
66
+ return this.#maskGeneration;
67
+ }
68
+
69
+ clear(): void {
70
+ for (const slot of this.#slots.values()) {
71
+ slot.vbo.destroy();
72
+ slot.ibo.destroy();
73
+ slot.ubo.destroy();
74
+ }
75
+ this.#slots.clear();
76
+ }
77
+
78
+ /** Drop bind groups that reference model textures (after setTextures). */
79
+ invalidateTextureBindGroups(): void {
80
+ for (const slot of this.#slots.values()) {
81
+ slot.bgMask = null;
82
+ slot.bgTextured = null;
83
+ slot.bgClippedTextured = null;
84
+ slot.bgSolid = null;
85
+ slot.bgClippedSolid = null;
86
+ slot.texIndex = -1;
87
+ }
88
+ }
89
+
90
+ ensureSlot(device: GPUDevice, drawableIndex: number): WebGpuMeshSlot {
91
+ let slot = this.#slots.get(drawableIndex);
92
+ if (slot) return slot;
93
+ const vbo = device.createBuffer({
94
+ size: 64,
95
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
96
+ });
97
+ const ibo = device.createBuffer({
98
+ size: 4,
99
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
100
+ });
101
+ const ubo = device.createBuffer({
102
+ size: 80,
103
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
104
+ });
105
+ slot = {
106
+ index: drawableIndex,
107
+ vbo,
108
+ vboBytes: 64,
109
+ interleaved: new Float32Array(16),
110
+ ibo,
111
+ iboBytes: 4,
112
+ indicesRef: null,
113
+ uvsRef: null,
114
+ ubo,
115
+ uboFloats: new Float32Array(20),
116
+ bgMask: null,
117
+ bgTextured: null,
118
+ bgClippedTextured: null,
119
+ bgSolid: null,
120
+ bgClippedSolid: null,
121
+ texIndex: -1,
122
+ useWhite: false,
123
+ maskGeneration: -1,
124
+ };
125
+ this.#slots.set(drawableIndex, slot);
126
+ return slot;
127
+ }
128
+
129
+ /**
130
+ * Upload interleaved pos/uv when positions change every frame; refresh
131
+ * index buffer only when the `indices` TypedArray identity changes.
132
+ */
133
+ uploadMesh(
134
+ device: GPUDevice,
135
+ slot: WebGpuMeshSlot,
136
+ positions: Float32Array,
137
+ uvs: Float32Array,
138
+ indices: Uint16Array,
139
+ ): void {
140
+ slot.interleaved = interleavePosUv(positions, uvs, slot.interleaved);
141
+ const vBytes = interleaveByteLength(positions);
142
+ const grownV = createOrGrowBuffer(
143
+ device,
144
+ slot.vbo,
145
+ slot.vboBytes,
146
+ vBytes,
147
+ GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
148
+ );
149
+ slot.vbo = grownV.buffer;
150
+ slot.vboBytes = grownV.bytes;
151
+ device.queue.writeBuffer(
152
+ slot.vbo,
153
+ 0,
154
+ slot.interleaved.buffer as ArrayBuffer,
155
+ slot.interleaved.byteOffset,
156
+ vBytes,
157
+ );
158
+
159
+ const iBytes = indexBufferByteSize(indices.byteLength);
160
+ const grownI = createOrGrowBuffer(
161
+ device,
162
+ slot.ibo,
163
+ slot.iboBytes,
164
+ iBytes,
165
+ GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
166
+ );
167
+ if (grownI.buffer !== slot.ibo) {
168
+ slot.indicesRef = null;
169
+ }
170
+ slot.ibo = grownI.buffer;
171
+ slot.iboBytes = grownI.bytes;
172
+ if (slot.indicesRef !== indices || slot.uvsRef !== uvs) {
173
+ if (iBytes === indices.byteLength) {
174
+ device.queue.writeBuffer(
175
+ slot.ibo,
176
+ 0,
177
+ indices.buffer as ArrayBuffer,
178
+ indices.byteOffset,
179
+ indices.byteLength,
180
+ );
181
+ } else {
182
+ const padded = new Uint16Array(iBytes / 2);
183
+ padded.set(indices);
184
+ device.queue.writeBuffer(slot.ibo, 0, padded);
185
+ }
186
+ slot.indicesRef = indices;
187
+ slot.uvsRef = uvs;
188
+ }
189
+ }
190
+
191
+ writeUbo(
192
+ device: GPUDevice,
193
+ slot: WebGpuMeshSlot,
194
+ floats: ArrayLike<number>,
195
+ ): void {
196
+ const n = floats.length;
197
+ for (let i = 0; i < n; i++) slot.uboFloats[i] = floats[i]!;
198
+ device.queue.writeBuffer(
199
+ slot.ubo,
200
+ 0,
201
+ slot.uboFloats.buffer as ArrayBuffer,
202
+ slot.uboFloats.byteOffset,
203
+ Math.max(16, (n * 4 + 15) & ~15),
204
+ );
205
+ }
206
+ }