@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.
@@ -8,13 +8,12 @@
8
8
 
9
9
  import { webGpuBlendState } from "../render/blend.js";
10
10
  import {
11
- fitClippingContexts,
12
11
  type LaidOutClippingContext,
13
12
  type MaskLayoutRect,
14
13
  maskChannelVec4,
15
14
  maskLayoutVec4,
16
- partitionForClipping,
17
15
  } from "../render/clipping.js";
16
+ import { ResidentClippingPlan } from "../render/clipping-plan.js";
18
17
  import {
19
18
  PREVIEW_FILL,
20
19
  PREVIEW_STROKE,
@@ -27,6 +26,7 @@ import type {
27
26
  WebGpuRenderer,
28
27
  } from "../types.js";
29
28
  import { BlendMode } from "../types.js";
29
+ import { WebGpuMeshCache } from "./webgpu-mesh-cache.js";
30
30
 
31
31
  const SOLID_WGSL = /* wgsl */ `
32
32
  struct Uniforms {
@@ -218,40 +218,6 @@ const BLEND_MODES: BlendMode[] = [
218
218
  BlendMode.Multiplicative,
219
219
  ];
220
220
 
221
- /** WebGPU INDEX buffers must be a multiple of 4 bytes. */
222
- function indexBufferSize(byteLength: number): number {
223
- return (byteLength + 3) & ~3;
224
- }
225
-
226
- function writeIndexBuffer(device: GPUDevice, indices: Uint16Array): GPUBuffer {
227
- const size = indexBufferSize(indices.byteLength);
228
- const ibo = device.createBuffer({
229
- size: Math.max(4, size),
230
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
231
- mappedAtCreation: true,
232
- });
233
- new Uint16Array(ibo.getMappedRange()).set(indices);
234
- ibo.unmap();
235
- return ibo;
236
- }
237
-
238
- function interleavePosUv(
239
- positions: Float32Array,
240
- uvs: Float32Array,
241
- ): Float32Array {
242
- const n = Math.floor(positions.length / 2);
243
- const out = new Float32Array(n * 4);
244
- for (let i = 0; i < n; i++) {
245
- const o = i * 4;
246
- const p = i * 2;
247
- out[o] = positions[p]!;
248
- out[o + 1] = positions[p + 1]!;
249
- out[o + 2] = uvs[p] ?? 0;
250
- out[o + 3] = uvs[p + 1] ?? 0;
251
- }
252
- return out;
253
- }
254
-
255
221
  /** 1x1 white texture used when a mask mesh has no model texture. */
256
222
  function createWhiteTexture(device: GPUDevice): GPUTexture {
257
223
  const tex = device.createTexture({
@@ -324,8 +290,17 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
324
290
  #maskW = 0;
325
291
  #maskH = 0;
326
292
  #whiteTexture: GPUTexture | null = null;
327
- #transient: GPUBuffer[] = [];
293
+ #whiteView: GPUTextureView | null = null;
294
+ #maskView: GPUTextureView | null = null;
328
295
  #gpuTextures: (GPUTexture | null)[] = [];
296
+ #gpuTextureViews: (GPUTextureView | null)[] = [];
297
+ readonly #meshCache = new WebGpuMeshCache();
298
+ readonly #clipPlan = new ResidentClippingPlan();
299
+ readonly #layoutScratch = new Float32Array(4);
300
+ readonly #channelScratch = new Float32Array(4);
301
+ readonly #boundsScratch = new Float32Array(4);
302
+ /** Preview-only line index buffers destroyed at endFrame. */
303
+ #transient: GPUBuffer[] = [];
329
304
  readonly #options: WebGpuRendererOptions;
330
305
 
331
306
  constructor(options: WebGpuRendererOptions = {}) {
@@ -709,6 +684,7 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
709
684
  addressModeV: "clamp-to-edge",
710
685
  });
711
686
  this.#whiteTexture = createWhiteTexture(device);
687
+ this.#whiteView = this.#whiteTexture.createView();
712
688
  this.#ensureMsaa();
713
689
  }
714
690
 
@@ -751,12 +727,16 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
751
727
  });
752
728
  this.#maskW = w;
753
729
  this.#maskH = h;
730
+ this.#maskView = this.#maskTexture.createView();
731
+ this.#meshCache.bumpMaskGeneration();
754
732
  return this.#maskTexture;
755
733
  }
756
734
 
757
735
  #clearGpuTextures(): void {
758
736
  for (const t of this.#gpuTextures) t?.destroy();
759
737
  this.#gpuTextures = [];
738
+ this.#gpuTextureViews = [];
739
+ this.#meshCache.invalidateTextureBindGroups();
760
740
  }
761
741
 
762
742
  setTextures(textures: TextureData[]): void {
@@ -767,6 +747,7 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
767
747
  let maxIndex = -1;
768
748
  for (const t of textures) maxIndex = Math.max(maxIndex, t.index);
769
749
  this.#gpuTextures = new Array(Math.max(0, maxIndex + 1)).fill(null);
750
+ this.#gpuTextureViews = new Array(Math.max(0, maxIndex + 1)).fill(null);
770
751
 
771
752
  for (const t of textures) {
772
753
  const gpuTex = device.createTexture({
@@ -783,6 +764,7 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
783
764
  [t.width, t.height],
784
765
  );
785
766
  this.#gpuTextures[t.index] = gpuTex;
767
+ this.#gpuTextureViews[t.index] = gpuTex.createView();
786
768
  }
787
769
  }
788
770
 
@@ -877,8 +859,8 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
877
859
  const byIndex = new Map<number, DrawableMesh>();
878
860
  for (const d of drawables) byIndex.set(d.index, d);
879
861
 
880
- const partitioned = partitionForClipping(drawables);
881
- const contexts = fitClippingContexts(partitioned.contexts, byIndex);
862
+ const partitioned = this.#clipPlan.resolveMeshes(drawables);
863
+ const contexts = partitioned.contexts;
882
864
  const { maskOnly } = partitioned;
883
865
 
884
866
  let maskTex: GPUTexture | null = null;
@@ -969,29 +951,25 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
969
951
  channelFlag: LaidOutClippingContext["channelFlag"],
970
952
  ): void {
971
953
  const device = this.#device;
972
- if (!device || d.opacity <= 0) return;
954
+ const whiteView = this.#whiteView;
955
+ if (!device || !whiteView || d.opacity <= 0) return;
973
956
 
974
- const interleaved = interleavePosUv(d.vertexPositions, d.uvs);
975
- const vbo = device.createBuffer({
976
- size: interleaved.byteLength,
977
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
978
- mappedAtCreation: true,
979
- });
980
- new Float32Array(vbo.getMappedRange()).set(interleaved);
981
- vbo.unmap();
957
+ const slot = this.#meshCache.ensureSlot(device, d.index);
958
+ this.#meshCache.uploadMesh(
959
+ device,
960
+ slot,
961
+ d.vertexPositions,
962
+ d.uvs,
963
+ d.indices,
964
+ );
982
965
 
983
- const gpuTex = this.#gpuTextures[d.textureIndex] ?? whiteTex;
984
- const ubo = device.createBuffer({
985
- size: 64,
986
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
987
- mappedAtCreation: true,
988
- });
989
- const layoutVec = maskLayoutVec4(atlasLayout);
990
- const ch = maskChannelVec4(channelFlag);
991
- const boundsVec = maskLayoutVec4(modelBounds);
992
- new Float32Array(ubo.getMappedRange()).set([
966
+ const layoutVec = maskLayoutVec4(atlasLayout, this.#layoutScratch);
967
+ const ch = maskChannelVec4(channelFlag, this.#channelScratch);
968
+ const boundsVec = maskLayoutVec4(modelBounds, this.#boundsScratch);
969
+ const useWhite = !this.#gpuTextures[d.textureIndex];
970
+ this.#meshCache.writeUbo(device, slot, [
993
971
  Math.max(d.opacity, 1),
994
- this.#gpuTextures[d.textureIndex] ? 1 : 0,
972
+ useWhite ? 0 : 1,
995
973
  0,
996
974
  0,
997
975
  layoutVec[0]!,
@@ -1007,24 +985,30 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1007
985
  boundsVec[2]!,
1008
986
  boundsVec[3]!,
1009
987
  ]);
1010
- ubo.unmap();
1011
988
 
1012
- const ibo = writeIndexBuffer(device, d.indices);
1013
- pass.setBindGroup(
1014
- 0,
1015
- device.createBindGroup({
989
+ const texView = this.#gpuTextureViews[d.textureIndex] ?? whiteView;
990
+ if (
991
+ !slot.bgMask ||
992
+ slot.texIndex !== d.textureIndex ||
993
+ slot.useWhite !== useWhite
994
+ ) {
995
+ slot.bgMask = device.createBindGroup({
1016
996
  layout,
1017
997
  entries: [
1018
- { binding: 0, resource: { buffer: ubo } },
998
+ { binding: 0, resource: { buffer: slot.ubo } },
1019
999
  { binding: 1, resource: sampler },
1020
- { binding: 2, resource: gpuTex.createView() },
1000
+ { binding: 2, resource: texView },
1021
1001
  ],
1022
- }),
1023
- );
1024
- pass.setVertexBuffer(0, vbo);
1025
- pass.setIndexBuffer(ibo, "uint16");
1002
+ });
1003
+ slot.texIndex = d.textureIndex;
1004
+ slot.useWhite = useWhite;
1005
+ }
1006
+
1007
+ pass.setBindGroup(0, slot.bgMask);
1008
+ pass.setVertexBuffer(0, slot.vbo);
1009
+ pass.setIndexBuffer(slot.ibo, "uint16");
1026
1010
  pass.drawIndexed(d.indices.length);
1027
- this.#transient.push(vbo, ubo, ibo);
1011
+ void whiteTex;
1028
1012
  }
1029
1013
 
1030
1014
  #drawColorMesh(
@@ -1043,6 +1027,7 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1043
1027
  const texturedLayout = this.#texturedBindGroupLayout;
1044
1028
  const clippedSolidLayout = this.#clippedSolidBindGroupLayout;
1045
1029
  const clippedTexturedLayout = this.#clippedTexturedBindGroupLayout;
1030
+ const maskView = this.#maskView;
1046
1031
  if (
1047
1032
  !device ||
1048
1033
  !sampler ||
@@ -1061,31 +1046,31 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1061
1046
 
1062
1047
  const layoutVec = maskLayoutVec4(
1063
1048
  atlasLayout ?? { x: 0, y: 0, width: 1, height: 1 },
1049
+ this.#layoutScratch,
1050
+ );
1051
+ const ch = maskChannelVec4(
1052
+ channelFlag ?? [0, 0, 0, 1],
1053
+ this.#channelScratch,
1064
1054
  );
1065
- const ch = maskChannelVec4(channelFlag ?? [0, 0, 0, 1]);
1066
1055
  const boundsVec = maskLayoutVec4(
1067
1056
  modelBounds ?? { x: -1, y: -1, width: 2, height: 2 },
1057
+ this.#boundsScratch,
1068
1058
  );
1069
1059
 
1060
+ const slot = this.#meshCache.ensureSlot(device, d.index);
1070
1061
  const gpuTex = this.#gpuTextures[d.textureIndex] ?? null;
1071
- if (gpuTex) {
1072
- const interleaved = interleavePosUv(d.vertexPositions, d.uvs);
1073
- const vbo = device.createBuffer({
1074
- size: interleaved.byteLength,
1075
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1076
- mappedAtCreation: true,
1077
- });
1078
- new Float32Array(vbo.getMappedRange()).set(interleaved);
1079
- vbo.unmap();
1080
-
1081
- const uboSize = useMask && maskTex ? 64 : 16;
1082
- const ubo = device.createBuffer({
1083
- size: uboSize,
1084
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1085
- mappedAtCreation: true,
1086
- });
1087
- if (useMask && maskTex) {
1088
- new Float32Array(ubo.getMappedRange()).set([
1062
+ const texView = this.#gpuTextureViews[d.textureIndex] ?? null;
1063
+
1064
+ if (gpuTex && texView) {
1065
+ this.#meshCache.uploadMesh(
1066
+ device,
1067
+ slot,
1068
+ d.vertexPositions,
1069
+ d.uvs,
1070
+ d.indices,
1071
+ );
1072
+ if (useMask && maskTex && maskView) {
1073
+ this.#meshCache.writeUbo(device, slot, [
1089
1074
  d.opacity,
1090
1075
  invertMask ? 1 : 0,
1091
1076
  0,
@@ -1103,67 +1088,57 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1103
1088
  boundsVec[2]!,
1104
1089
  boundsVec[3]!,
1105
1090
  ]);
1106
- } else {
1107
- new Float32Array(ubo.getMappedRange()).set([
1108
- d.opacity,
1109
- 0,
1110
- 0,
1111
- 0,
1112
- ]);
1113
- }
1114
- ubo.unmap();
1115
-
1116
- const ibo = writeIndexBuffer(device, d.indices);
1117
- if (useMask && maskTex) {
1118
- pass.setPipeline(set.clippedTextured);
1119
- pass.setBindGroup(
1120
- 0,
1121
- device.createBindGroup({
1091
+ if (
1092
+ !slot.bgClippedTextured ||
1093
+ slot.texIndex !== d.textureIndex ||
1094
+ slot.maskGeneration !== this.#meshCache.maskGeneration
1095
+ ) {
1096
+ slot.bgClippedTextured = device.createBindGroup({
1122
1097
  layout: clippedTexturedLayout,
1123
1098
  entries: [
1124
- { binding: 0, resource: { buffer: ubo } },
1099
+ { binding: 0, resource: { buffer: slot.ubo } },
1125
1100
  { binding: 1, resource: sampler },
1126
- { binding: 2, resource: gpuTex.createView() },
1127
- { binding: 3, resource: maskTex.createView() },
1101
+ { binding: 2, resource: texView },
1102
+ { binding: 3, resource: maskView },
1128
1103
  ],
1129
- }),
1130
- );
1104
+ });
1105
+ slot.texIndex = d.textureIndex;
1106
+ slot.maskGeneration = this.#meshCache.maskGeneration;
1107
+ }
1108
+ pass.setPipeline(set.clippedTextured);
1109
+ pass.setBindGroup(0, slot.bgClippedTextured);
1131
1110
  } else {
1132
- pass.setPipeline(set.textured);
1133
- pass.setBindGroup(
1134
- 0,
1135
- device.createBindGroup({
1111
+ this.#meshCache.writeUbo(device, slot, [d.opacity, 0, 0, 0]);
1112
+ if (!slot.bgTextured || slot.texIndex !== d.textureIndex) {
1113
+ slot.bgTextured = device.createBindGroup({
1136
1114
  layout: texturedLayout,
1137
1115
  entries: [
1138
- { binding: 0, resource: { buffer: ubo } },
1116
+ { binding: 0, resource: { buffer: slot.ubo } },
1139
1117
  { binding: 1, resource: sampler },
1140
- { binding: 2, resource: gpuTex.createView() },
1118
+ { binding: 2, resource: texView },
1141
1119
  ],
1142
- }),
1143
- );
1120
+ });
1121
+ slot.texIndex = d.textureIndex;
1122
+ }
1123
+ pass.setPipeline(set.textured);
1124
+ pass.setBindGroup(0, slot.bgTextured);
1144
1125
  }
1145
- pass.setVertexBuffer(0, vbo);
1146
- pass.setIndexBuffer(ibo, "uint16");
1126
+ pass.setVertexBuffer(0, slot.vbo);
1127
+ pass.setIndexBuffer(slot.ibo, "uint16");
1147
1128
  pass.drawIndexed(d.indices.length);
1148
- this.#transient.push(vbo, ubo, ibo);
1149
1129
  return;
1150
1130
  }
1151
1131
 
1152
- const vbo = device.createBuffer({
1153
- size: d.vertexPositions.byteLength,
1154
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1155
- mappedAtCreation: true,
1156
- });
1157
- new Float32Array(vbo.getMappedRange()).set(d.vertexPositions);
1158
- vbo.unmap();
1159
-
1160
- if (useMask && maskTex) {
1161
- const ubo = device.createBuffer({
1162
- size: 80,
1163
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1164
- mappedAtCreation: true,
1165
- });
1166
- new Float32Array(ubo.getMappedRange()).set([
1132
+ // Preview path: solid fill (+ optional stroke) without model textures.
1133
+ this.#meshCache.uploadMesh(
1134
+ device,
1135
+ slot,
1136
+ d.vertexPositions,
1137
+ d.uvs,
1138
+ d.indices,
1139
+ );
1140
+ if (useMask && maskTex && maskView) {
1141
+ this.#meshCache.writeUbo(device, slot, [
1167
1142
  PREVIEW_FILL.r,
1168
1143
  PREVIEW_FILL.g,
1169
1144
  PREVIEW_FILL.b,
@@ -1185,56 +1160,66 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1185
1160
  boundsVec[2]!,
1186
1161
  boundsVec[3]!,
1187
1162
  ]);
1188
- ubo.unmap();
1189
- const ibo = writeIndexBuffer(device, d.indices);
1190
- pass.setPipeline(set.clippedFill);
1191
- pass.setBindGroup(
1192
- 0,
1193
- device.createBindGroup({
1163
+ if (
1164
+ !slot.bgClippedSolid ||
1165
+ slot.maskGeneration !== this.#meshCache.maskGeneration
1166
+ ) {
1167
+ slot.bgClippedSolid = device.createBindGroup({
1194
1168
  layout: clippedSolidLayout,
1195
1169
  entries: [
1196
- { binding: 0, resource: { buffer: ubo } },
1170
+ { binding: 0, resource: { buffer: slot.ubo } },
1197
1171
  { binding: 1, resource: sampler },
1198
- { binding: 2, resource: maskTex.createView() },
1172
+ { binding: 2, resource: maskView },
1199
1173
  ],
1200
- }),
1201
- );
1202
- pass.setVertexBuffer(0, vbo);
1203
- pass.setIndexBuffer(ibo, "uint16");
1174
+ });
1175
+ slot.maskGeneration = this.#meshCache.maskGeneration;
1176
+ }
1177
+ pass.setPipeline(set.clippedFill);
1178
+ pass.setBindGroup(0, slot.bgClippedSolid);
1179
+ pass.setVertexBuffer(0, slot.vbo);
1180
+ pass.setIndexBuffer(slot.ibo, "uint16");
1204
1181
  pass.drawIndexed(d.indices.length);
1205
- this.#transient.push(vbo, ubo, ibo);
1206
1182
  return;
1207
1183
  }
1208
1184
 
1209
- const fillUbo = this.#makeColorUbo(
1210
- device,
1185
+ this.#meshCache.writeUbo(device, slot, [
1211
1186
  PREVIEW_FILL.r,
1212
1187
  PREVIEW_FILL.g,
1213
1188
  PREVIEW_FILL.b,
1214
1189
  PREVIEW_FILL.a * d.opacity,
1215
- );
1216
- const fillIbo = writeIndexBuffer(device, d.indices);
1217
- pass.setPipeline(set.fill);
1218
- pass.setBindGroup(
1219
- 0,
1220
- device.createBindGroup({
1190
+ ]);
1191
+ if (!slot.bgSolid) {
1192
+ slot.bgSolid = device.createBindGroup({
1221
1193
  layout: solidLayout,
1222
- entries: [{ binding: 0, resource: { buffer: fillUbo } }],
1223
- }),
1224
- );
1225
- pass.setVertexBuffer(0, vbo);
1226
- pass.setIndexBuffer(fillIbo, "uint16");
1194
+ entries: [{ binding: 0, resource: { buffer: slot.ubo } }],
1195
+ });
1196
+ }
1197
+ pass.setPipeline(set.fill);
1198
+ pass.setBindGroup(0, slot.bgSolid);
1199
+ pass.setVertexBuffer(0, slot.vbo);
1200
+ pass.setIndexBuffer(slot.ibo, "uint16");
1227
1201
  pass.drawIndexed(d.indices.length);
1228
1202
 
1229
1203
  const lines = triangleEdgesToLineList(d.indices);
1230
- const lineUbo = this.#makeColorUbo(
1231
- device,
1204
+ const lineUbo = device.createBuffer({
1205
+ size: 16,
1206
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1207
+ mappedAtCreation: true,
1208
+ });
1209
+ new Float32Array(lineUbo.getMappedRange()).set([
1232
1210
  PREVIEW_STROKE.r,
1233
1211
  PREVIEW_STROKE.g,
1234
1212
  PREVIEW_STROKE.b,
1235
1213
  PREVIEW_STROKE.a * d.opacity,
1236
- );
1237
- const lineIbo = writeIndexBuffer(device, lines);
1214
+ ]);
1215
+ lineUbo.unmap();
1216
+ const lineIbo = device.createBuffer({
1217
+ size: Math.max(4, (lines.byteLength + 3) & ~3),
1218
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1219
+ mappedAtCreation: true,
1220
+ });
1221
+ new Uint16Array(lineIbo.getMappedRange()).set(lines);
1222
+ lineIbo.unmap();
1238
1223
  pass.setPipeline(set.line);
1239
1224
  pass.setBindGroup(
1240
1225
  0,
@@ -1243,27 +1228,10 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1243
1228
  entries: [{ binding: 0, resource: { buffer: lineUbo } }],
1244
1229
  }),
1245
1230
  );
1246
- pass.setVertexBuffer(0, vbo);
1231
+ pass.setVertexBuffer(0, slot.vbo);
1247
1232
  pass.setIndexBuffer(lineIbo, "uint16");
1248
1233
  pass.drawIndexed(lines.length);
1249
- this.#transient.push(vbo, fillUbo, fillIbo, lineUbo, lineIbo);
1250
- }
1251
-
1252
- #makeColorUbo(
1253
- device: GPUDevice,
1254
- r: number,
1255
- g: number,
1256
- b: number,
1257
- a: number,
1258
- ): GPUBuffer {
1259
- const ubo = device.createBuffer({
1260
- size: 16,
1261
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1262
- mappedAtCreation: true,
1263
- });
1264
- new Float32Array(ubo.getMappedRange()).set([r, g, b, a]);
1265
- ubo.unmap();
1266
- return ubo;
1234
+ this.#transient.push(lineUbo, lineIbo);
1267
1235
  }
1268
1236
 
1269
1237
  endFrame(): void {
@@ -1299,13 +1267,17 @@ export class WebGpuRendererImpl implements WebGpuRenderer {
1299
1267
  destroy(): void {
1300
1268
  for (const b of this.#transient) b.destroy();
1301
1269
  this.#transient = [];
1270
+ this.#meshCache.clear();
1271
+ this.#clipPlan.clear();
1302
1272
  this.#clearGpuTextures();
1303
1273
  this.#msaaTexture?.destroy();
1304
1274
  this.#msaaTexture = null;
1305
1275
  this.#maskTexture?.destroy();
1306
1276
  this.#maskTexture = null;
1277
+ this.#maskView = null;
1307
1278
  this.#whiteTexture?.destroy();
1308
1279
  this.#whiteTexture = null;
1280
+ this.#whiteView = null;
1309
1281
  this.#device?.destroy();
1310
1282
  this.#device = null;
1311
1283
  this.#context = null;
package/src/index.ts CHANGED
@@ -95,6 +95,15 @@ export {
95
95
  maskLayoutVec4,
96
96
  partitionForClipping,
97
97
  } from "./render/clipping.js";
98
+ export {
99
+ clippingTopologyKey,
100
+ fitClippingContextsInPlace,
101
+ ResidentClippingPlan,
102
+ } from "./render/clipping-plan.js";
103
+ export {
104
+ interleaveByteLength,
105
+ interleavePosUv,
106
+ } from "./render/mesh-interleave.js";
98
107
  export {
99
108
  PREVIEW_FILL,
100
109
  PREVIEW_STROKE,
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Resident clipping / mask atlas plan.
3
+ *
4
+ * Topology (maskIndices / invert / packing options) is compiled once; per frame
5
+ * only `modelBounds` is refreshed from current vertex positions.
6
+ */
7
+
8
+ import type { DrawableMesh } from "../types.js";
9
+ import {
10
+ type ClippingDrawableRef,
11
+ type ClippingPartition,
12
+ calcClippedDrawableBounds,
13
+ type LaidOutClippingContext,
14
+ type MaskAtlasOptions,
15
+ type MaskBoundsMeshRef,
16
+ type MaskLayoutRect,
17
+ partitionForClipping,
18
+ } from "./clipping.js";
19
+
20
+ /** Stable key for mask topology (ignores vertex positions / opacity). */
21
+ export function clippingTopologyKey(
22
+ drawables: readonly ClippingDrawableRef[],
23
+ ): string {
24
+ let key = `n:${drawables.length}`;
25
+ for (let i = 0; i < drawables.length; i++) {
26
+ const d = drawables[i]!;
27
+ key += `|${d.index}:${d.invertedMask ? 1 : 0}:`;
28
+ const masks = d.maskIndices;
29
+ for (let j = 0; j < masks.length; j++) {
30
+ key += `${masks[j]!},`;
31
+ }
32
+ }
33
+ return key;
34
+ }
35
+
36
+ function atlasOptionsKey(options: MaskAtlasOptions): string {
37
+ return `${options.mode ?? "rgba"}:${options.inset ?? ""}:${options.renderTextureCount ?? ""}`;
38
+ }
39
+
40
+ type ResidentContext = {
41
+ -readonly [K in keyof LaidOutClippingContext]: LaidOutClippingContext[K];
42
+ } & { modelBounds: MaskLayoutRect };
43
+
44
+ /**
45
+ * Update `modelBounds` on resident contexts without reallocating the plan.
46
+ */
47
+ export function fitClippingContextsInPlace(
48
+ contexts: readonly ResidentContext[],
49
+ byIndex: ReadonlyMap<number, MaskBoundsMeshRef>,
50
+ margin = 0.05,
51
+ ): void {
52
+ for (let i = 0; i < contexts.length; i++) {
53
+ const ctx = contexts[i]!;
54
+ ctx.modelBounds = calcClippedDrawableBounds(
55
+ ctx.clippedIndices,
56
+ byIndex,
57
+ margin,
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Caches atlas layout + mask/clipped sets while drawable mask topology is
64
+ * unchanged. Call {@link resolve} each frame; call {@link clear} on destroy.
65
+ */
66
+ export class ResidentClippingPlan {
67
+ #cacheKey = "";
68
+ #contexts: ResidentContext[] = [];
69
+ #clipped: Set<number> = new Set();
70
+ #maskOnly: Set<number> = new Set();
71
+ readonly #byIndex = new Map<number, MaskBoundsMeshRef>();
72
+
73
+ /**
74
+ * Returns a partition whose `contexts` array identity is stable across
75
+ * frames with the same topology. `modelBounds` are refreshed in place.
76
+ */
77
+ resolve(
78
+ drawables: readonly ClippingDrawableRef[],
79
+ options: MaskAtlasOptions = {},
80
+ ): ClippingPartition {
81
+ const nextKey = `${clippingTopologyKey(drawables)}|${atlasOptionsKey(options)}`;
82
+ if (nextKey !== this.#cacheKey) {
83
+ const partitioned = partitionForClipping(drawables, options);
84
+ this.#contexts = partitioned.contexts.map((ctx) => ({
85
+ key: ctx.key,
86
+ maskIndices: ctx.maskIndices,
87
+ clippedIndices: ctx.clippedIndices,
88
+ invertedMask: ctx.invertedMask,
89
+ layout: ctx.layout,
90
+ channelIndex: ctx.channelIndex,
91
+ channelFlag: ctx.channelFlag,
92
+ bufferIndex: ctx.bufferIndex,
93
+ modelBounds: { ...ctx.modelBounds },
94
+ }));
95
+ this.#clipped = new Set(partitioned.clipped);
96
+ this.#maskOnly = new Set(partitioned.maskOnly);
97
+ this.#cacheKey = nextKey;
98
+ }
99
+
100
+ this.#byIndex.clear();
101
+ for (let i = 0; i < drawables.length; i++) {
102
+ const d = drawables[i]!;
103
+ if ("vertexPositions" in d) {
104
+ this.#byIndex.set(d.index, d as MaskBoundsMeshRef);
105
+ }
106
+ }
107
+ fitClippingContextsInPlace(this.#contexts, this.#byIndex);
108
+ return {
109
+ contexts: this.#contexts,
110
+ clipped: this.#clipped,
111
+ maskOnly: this.#maskOnly,
112
+ };
113
+ }
114
+
115
+ /** Convenience for full drawable meshes (GPU backends). */
116
+ resolveMeshes(
117
+ drawables: readonly DrawableMesh[],
118
+ options: MaskAtlasOptions = {},
119
+ ): ClippingPartition {
120
+ return this.resolve(drawables, options);
121
+ }
122
+
123
+ /** True when the last {@link resolve} reused the cached atlas layout. */
124
+ get hasPlan(): boolean {
125
+ return this.#cacheKey.length > 0;
126
+ }
127
+
128
+ /** Exposed for tests — context array identity while topology holds. */
129
+ get residentContexts(): readonly LaidOutClippingContext[] {
130
+ return this.#contexts;
131
+ }
132
+
133
+ clear(): void {
134
+ this.#cacheKey = "";
135
+ this.#contexts = [];
136
+ this.#clipped = new Set();
137
+ this.#maskOnly = new Set();
138
+ this.#byIndex.clear();
139
+ }
140
+ }