@volter/blender-engine 0.1.91 → 0.1.93

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.
@@ -9,16 +9,17 @@ const CHUNK_INDICES = 49_152;
9
9
  const COPY_ITEMS = 8_192;
10
10
  const cancelled = Symbol('navigation geometry replaced');
11
11
  type Range = {start: number; count: number; materialIndex?: number | undefined};
12
- type Entry = {geometry: THREE.BufferGeometry; error: number; bytes: number};
12
+ type Entry = {geometry: THREE.BufferGeometry; error: number; bytes: number; sharedAttributes: boolean};
13
+ type Job = {geometry: THREE.BufferGeometry; fine: boolean};
13
14
 
14
15
  /** A disposable navigation drawing, never Blender's evaluated geometry.
15
16
  * Copies are derived off-thread, in importance order, one bounded mesh at a
16
17
  * time. Full meshes remain resident and are restored after EVERY draw. */
17
18
  export class BlenderMotionGeometry {
18
19
  private groups = new Map<THREE.BufferGeometry, THREE.Mesh[]>();
19
- private readonly cache = new Map<THREE.BufferGeometry, Entry>();
20
+ private readonly cache = new Map<THREE.BufferGeometry, Entry[]>();
20
21
  private readonly swapped: {mesh: THREE.Mesh; geometry: THREE.BufferGeometry}[] = [];
21
- private queue: THREE.BufferGeometry[] = [];
22
+ private queue: Job[] = [];
22
23
  private worker: Worker | null = null;
23
24
  private pending: {id: number; resolve: (response: MotionMeshResponse) => void} | null = null;
24
25
  private generation = 0;
@@ -29,7 +30,7 @@ export class BlenderMotionGeometry {
29
30
  private nextIdentity = 0;
30
31
  private bytes = 0;
31
32
  private unavailable: string | null = null;
32
- private reductions: {vertices: number; fullTriangles: number; reducedTriangles: number; error: number; accepted: boolean; bytes?: number}[] = [];
33
+ private reductions: {vertices: number; fullTriangles: number; reducedTriangles: number; error: number; fine: boolean; accepted: boolean; bytes?: number}[] = [];
33
34
  private timer: ReturnType<typeof setTimeout> | null = null;
34
35
  private readonly cameraWorld = new THREE.Matrix4();
35
36
  private readonly cameraProjection = new THREE.Matrix4();
@@ -92,14 +93,15 @@ export class BlenderMotionGeometry {
92
93
  // that costs most draws, not its name or its order in the .blend.
93
94
  if (candidates.reduce((n, [g, meshes]) => n + g.index!.count * meshes.length, 0) < 6_000_000) return;
94
95
  candidates.sort(([a, am], [b, bm]) => b.index!.count * bm.length - a.index!.count * am.length);
95
- this.queue = candidates.map(([g]) => g);
96
+ this.queue = candidates.map(([geometry]) => ({geometry, fine: false}));
96
97
  this.next();
97
98
  }
98
99
 
99
100
  private async next(): Promise<void> {
100
101
  if (this.building || this.unavailable || this.bytes >= CACHE_BYTES) return;
101
- const geometry = this.queue.shift();
102
- if (!geometry) { this.worker?.terminate(); this.worker = null; return; }
102
+ const job = this.queue.shift();
103
+ if (!job) { this.worker?.terminate(); this.worker = null; return; }
104
+ const {geometry, fine} = job;
103
105
  this.building = true;
104
106
  const generation = this.generation;
105
107
  const current = () => {
@@ -175,7 +177,7 @@ export class BlenderMotionGeometry {
175
177
  }
176
178
  if (!geometry.boundingSphere) return;
177
179
  const absoluteError = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y,
178
- bounds.max.z - bounds.min.z) * 0.002;
180
+ bounds.max.z - bounds.min.z) * (fine ? 0.0005 : 0.002);
179
181
  const parts: Uint32Array[] = [];
180
182
  const outputGroups: Range[] = [];
181
183
  let count = 0, error = 0;
@@ -228,12 +230,14 @@ export class BlenderMotionGeometry {
228
230
  if (multi) outputGroups.push({start: groupStart, count: count - groupStart, materialIndex: range.materialIndex});
229
231
  }
230
232
  const reduction: (typeof this.reductions)[number] = {vertices: position.count, fullTriangles: geometry.index!.count / 3,
231
- reducedTriangles: count / 3, error, accepted: false};
233
+ reducedTriangles: count / 3, error, fine, accepted: false};
232
234
  if (this.reductions.length < 32) this.reductions.push(reduction);
233
235
  if (!count || count >= geometry.index!.count * 0.75) return;
234
- // Compact only after all material ranges succeed. Yield while remapping
235
- // and copying; cancellation never publishes a partial surface.
236
- const remap = new Int32Array(position.count).fill(-1);
236
+ // A finer level borrows the resident vertex columns and owns only its
237
+ // indices. This avoids another large attribute allocation beside the
238
+ // coarse copy; source versions still invalidate both levels together.
239
+ // Coarse copies remain compact for bounded packed draw families.
240
+ const remap = fine ? null : new Int32Array(position.count).fill(-1);
237
241
  const originals: number[] = [];
238
242
  const indices = new Uint32Array(count);
239
243
  let offset = 0;
@@ -242,20 +246,21 @@ export class BlenderMotionGeometry {
242
246
  await yieldCopy();
243
247
  for (let i = first; i < Math.min(first + COPY_ITEMS, part.length); i++) {
244
248
  const original = part[i]!;
245
- let local = remap[original]!;
246
- if (local < 0) { local = originals.length; originals.push(original); remap[original] = local; }
249
+ let local = remap ? remap[original]! : original;
250
+ if (local < 0) { local = originals.length; originals.push(original); remap![original] = local; }
247
251
  indices[offset + i] = local;
248
252
  }
249
253
  }
250
254
  offset += part.length;
251
255
  }
252
- const bytes = indices.byteLength + Object.values(geometry.attributes).reduce((n, a) =>
253
- n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0);
256
+ const bytes = indices.byteLength + (fine ? 0 : Object.values(geometry.attributes).reduce((n, a) =>
257
+ n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0));
254
258
  reduction.bytes = bytes;
255
259
  if (this.bytes + bytes > CACHE_BYTES) return;
256
260
  const copy = new THREE.BufferGeometry();
257
261
  copy.setIndex(new THREE.BufferAttribute(indices, 1));
258
262
  for (const [name, source] of Object.entries(geometry.attributes)) {
263
+ if (fine) { copy.setAttribute(name, source); continue; }
259
264
  const attribute = source as THREE.BufferAttribute;
260
265
  const ArrayType = attribute.array.constructor as {new(length: number): THREE.TypedArray};
261
266
  const data = new ArrayType(originals.length * attribute.itemSize);
@@ -272,8 +277,15 @@ export class BlenderMotionGeometry {
272
277
  for (const group of outputGroups) copy.addGroup(group.start, group.count, group.materialIndex);
273
278
  copy.userData = {...geometry.userData};
274
279
  reduction.accepted = true;
275
- this.cache.set(geometry, {geometry: copy, error, bytes});
280
+ const levels = this.cache.get(geometry) ?? [];
281
+ levels.push({geometry: copy, error, bytes, sharedAttributes: fine});
282
+ this.cache.set(geometry, levels);
276
283
  this.bytes += bytes;
284
+ // Large repeated shapes can be cheap at distance yet still cost millions
285
+ // of triangles just beyond the coarse copy's pixel gate. Build one finer
286
+ // level after the initial queue; every placement keeps the same gate.
287
+ if (!fine && geometry.index!.count >= 300_000 && count < geometry.index!.count * 0.25)
288
+ this.queue.push({geometry, fine: true});
277
289
  } catch (error) {
278
290
  if (error === cancelled) return;
279
291
  this.unavailable = String(error);
@@ -306,10 +318,10 @@ export class BlenderMotionGeometry {
306
318
  this.projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
307
319
  this.frustum.setFromProjectionMatrix(this.projection);
308
320
  let tooClose = 0;
309
- for (const [source, entry] of this.cache) {
321
+ for (const [source, levels] of this.cache) {
310
322
  // The copy carries the ORIGINAL bounds derived during its yielding
311
323
  // build. Do not scan vertices or use the smaller simplified bounds.
312
- const bounds = entry.geometry.boundingBox!;
324
+ const bounds = levels[0]!.geometry.boundingBox!;
313
325
  bounds.getSize(this.halfExtent).multiplyScalar(0.5);
314
326
  for (const mesh of this.groups.get(source) ?? []) {
315
327
  if (!instanceObjectShown(mesh) || (mesh.frustumCulled && !this.frustum.intersectsObject(mesh))) continue;
@@ -342,8 +354,12 @@ export class BlenderMotionGeometry {
342
354
  // camera. This tightens the bound without relaxing the pixel gate.
343
355
  depth = Math.max(sphereDepth, -this.boxCentre.z - radiusZ);
344
356
  }
345
- const pixels = entry.error * scale * Math.abs(camera.projectionMatrix.elements[5]!) * height / (2 * depth);
346
- if (!(depth > 0) || pixels > 1) { tooClose++; continue; }
357
+ const pixelsPerUnit = scale * Math.abs(camera.projectionMatrix.elements[5]!) * height / (2 * depth);
358
+ let entry: Entry | undefined;
359
+ if (depth > 0) for (const level of levels) {
360
+ if (level.error * pixelsPerUnit <= 1) { entry = level; break; }
361
+ }
362
+ if (!entry) { tooClose++; continue; }
347
363
  // Decide per placement: one near-camera blade must not veto thousands
348
364
  // of distant placements of its shared geometry. The following draw
349
365
  // planner sees these geometry identities, so transparency still batches
@@ -366,7 +382,8 @@ export class BlenderMotionGeometry {
366
382
  this.swapped.length = 0;
367
383
  }
368
384
  inspect() {
369
- return {copies: this.cache.size, bytes: this.bytes, queued: this.queue.length, pending: this.building,
385
+ return {copies: this.cache.size, levels: [...this.cache.values()].reduce((n, levels) => n + levels.length, 0),
386
+ bytes: this.bytes, queued: this.queue.length, pending: this.building,
370
387
  unavailable: this.unavailable, reductions: this.reductions.map(r => ({...r})), lastNavigation: {...this.lastNavigation}};
371
388
  }
372
389
  private releaseCopies(): void {
@@ -377,7 +394,13 @@ export class BlenderMotionGeometry {
377
394
  this.worker?.terminate(); this.worker = null;
378
395
  this.pending?.resolve({id: this.pending.id, refusal: 'Navigation geometry replaced'});
379
396
  this.pending = null; this.building = false; this.queue = [];
380
- for (const entry of this.cache.values()) entry.geometry.dispose();
397
+ for (const levels of this.cache.values()) for (const entry of levels) {
398
+ // Geometry.dispose releases its attributes' GPU buffers. A borrowed
399
+ // column remains owned by the original geometry and must stay resident.
400
+ if (entry.sharedAttributes)
401
+ for (const name of Object.keys(entry.geometry.attributes)) entry.geometry.deleteAttribute(name);
402
+ entry.geometry.dispose();
403
+ }
381
404
  this.cache.clear(); this.bytes = 0; this.reductions = [];
382
405
  }
383
406
  clear(): void {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.91",
5
+ "version": "0.1.93",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },