@volter/blender-engine 0.1.64 → 0.1.66

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.
@@ -118,6 +118,9 @@ export interface BlenderCallMetrics {
118
118
  readonly maxCallMs: number | null;
119
119
  /** Operation and load boundary owning the longest request; no payload data. */
120
120
  readonly maxCallLabel?: string | null;
121
+ /** Current cooperative phase and count of settled wire requests; sampled, not pushed per unit. */
122
+ readonly currentPhase?: string | null;
123
+ readonly completedCalls?: number;
121
124
  /** Calls whose duration passed 5s, counting outstanding ones already past it. */
122
125
  readonly callsOver5s: number;
123
126
  /** The same at 30s — a call this side of it is slow, past it is a wedge. */
@@ -168,7 +171,7 @@ export class BlenderRuntime {
168
171
  readonly #callLabels = new Map<number, string>();
169
172
  #loadBoundary = "engine-start";
170
173
  #maxCompletedLabel: string | null = null;
171
- readonly #callWork = new Map<number, () => void>();
174
+ readonly #operationWork = new Set<() => void>();
172
175
  #lastCallMs: number | null = null;
173
176
  #maxCompletedMs = 0;
174
177
  #completedOver5s = 0;
@@ -499,6 +502,7 @@ export class BlenderRuntime {
499
502
  for (const id of [...this.#pending.keys()]) this.#settled(id);
500
503
  for (const pending of this.#pending.values()) pending.reject(error);
501
504
  this.#pending.clear();
505
+ for (const end of this.#operationWork) end();
502
506
  }
503
507
 
504
508
  /**
@@ -534,6 +538,8 @@ export class BlenderRuntime {
534
538
  // different claim from "no call has happened yet".
535
539
  maxCallMs: this.#lastCallMs === null && oldest === null ? null : Math.round(max),
536
540
  maxCallLabel: maxLabel,
541
+ currentPhase: this.#acceptedRequests ? this.#loadBoundary : null,
542
+ completedCalls: this.#completedCalls,
537
543
  callsOver5s: over5,
538
544
  callsOver30s: over30,
539
545
  lastCallWindow: this.#lastCallWindow,
@@ -543,13 +549,12 @@ export class BlenderRuntime {
543
549
 
544
550
  /** Close a call's window, whether it answered, threw, or was terminated. */
545
551
  #settled(id: number): void {
546
- this.#callWork.get(id)?.();
547
- this.#callWork.delete(id);
548
552
  const started = this.#callStarts.get(id);
549
553
  if (started === undefined) return;
550
554
  this.#callStarts.delete(id);
551
555
  const label = this.#callLabels.get(id) ?? null;
552
556
  this.#callLabels.delete(id);
557
+ this.#completedCalls++;
553
558
  const elapsed = performance.now() - started;
554
559
  this.#lastCallMs = elapsed;
555
560
  if (elapsed > this.#maxCompletedMs) { this.#maxCompletedMs = elapsed; this.#maxCompletedLabel = label; }
@@ -559,14 +564,19 @@ export class BlenderRuntime {
559
564
  this.#lastCallWindow = { start: started, end: started + elapsed };
560
565
  }
561
566
 
567
+ #completedCalls = 0;
562
568
  #acceptedRequests = 0;
563
569
  #requestTail: Promise<void> = Promise.resolve();
564
570
  #request(request: Request, shutdown = false): Promise<unknown> {
565
571
  if (this.#terminated || (this.#stopping && !shutdown))
566
572
  return Promise.reject(new Error('The Blender session is stopping or terminated'));
567
573
  const began = performance.now();
574
+ let endWork = () => {};
568
575
  this.#acceptedRequests++;
569
576
  const answer = this.#requestTail.then(async () => {
577
+ const finishWork = this.#work(`running worker ${request.op}`);
578
+ endWork = () => { if (this.#operationWork.delete(endWork)) finishWork(); };
579
+ this.#operationWork.add(endWork);
570
580
  this.#loadBoundary = request.op;
571
581
  let result = await this.#wireRequest(request) as PullResult<unknown>;
572
582
  while (result?.load === 'continue') {
@@ -576,6 +586,7 @@ export class BlenderRuntime {
576
586
  return result?.load === 'done' ? result.value : result;
577
587
  }).finally(() => {
578
588
  this.#acceptedRequests--;
589
+ endWork();
579
590
  const totalMs = Math.round(performance.now() - began);
580
591
  try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${request.op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
581
592
  });
@@ -592,7 +603,6 @@ export class BlenderRuntime {
592
603
  this.#callStarts.set(id, started);
593
604
  this.#callLabels.set(id, request.op === "load-next" ? `load-next after ${this.#loadBoundary}` : request.op);
594
605
  this.#lastCallWindow = { start: started, end: null };
595
- this.#callWork.set(id, this.#work(`waiting for worker ${request.op}`));
596
606
  try {
597
607
  this.#worker.postMessage({ ...request, id });
598
608
  } catch (error) {
@@ -322,8 +322,12 @@ ${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
322
322
  '#include <project_vertex>',
323
323
  `vBlenderObjectPosition = transformed;
324
324
  vBlenderObjectNormal = objectNormal;
325
- vBlenderWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
326
- vBlenderWorldNormal = normalize(transpose(inverse(mat3(modelMatrix))) * objectNormal);
325
+ mat4 blenderWorldMatrix = modelMatrix;
326
+ #ifdef USE_INSTANCING
327
+ blenderWorldMatrix = modelMatrix * instanceMatrix;
328
+ #endif
329
+ vBlenderWorldPosition = (blenderWorldMatrix * vec4(transformed, 1.0)).xyz;
330
+ vBlenderWorldNormal = normalize(transpose(inverse(mat3(blenderWorldMatrix))) * objectNormal);
327
331
  vBlenderOrco = blenderOrco;
328
332
  ${compiled.attributes.map(n => `${attributeVarying(n)} = ${graphAttributeName(n)};`).join('\n')}
329
333
  ${compiled.uvs.map(n => `${uvVarying(n)} = ${attribute(channels[n] ?? 0)};`).join('\n')}
@@ -0,0 +1,163 @@
1
+ import * as THREE from 'three';
2
+
3
+ // Small spatial batches retain frustum rejection without duplicating geometry.
4
+ const BATCH_SIZE = 64;
5
+ type Member = { mesh: THREE.Mesh; layers: number };
6
+ type Batch = { draw: THREE.InstancedMesh; members: Member[] };
7
+
8
+ function sameMaterials(a: THREE.Material | THREE.Material[], b: THREE.Material | THREE.Material[]): boolean {
9
+ return Array.isArray(a) && Array.isArray(b)
10
+ ? a.length === b.length && a.every((material, i) => material === b[i])
11
+ : a === b;
12
+ }
13
+
14
+ function shown(object: THREE.Object3D): boolean {
15
+ for (let node: THREE.Object3D | null = object; node; node = node.parent)
16
+ if (!node.visible) return false;
17
+ return true;
18
+ }
19
+
20
+ /** Three's instance normal transform supports positive, orthogonal scale axes.
21
+ * Mirrored, singular and sheared placements keep the ordinary Mesh draw. */
22
+ function supported(matrix: THREE.Matrix4): boolean {
23
+ if (matrix.determinant() <= 0) return false;
24
+ const e = matrix.elements;
25
+ const x = Math.hypot(e[0]!, e[1]!, e[2]!);
26
+ const y = Math.hypot(e[4]!, e[5]!, e[6]!);
27
+ const z = Math.hypot(e[8]!, e[9]!, e[10]!);
28
+ const dot = (a: number, b: number) => e[a]! * e[b]! + e[a + 1]! * e[b + 1]! + e[a + 2]! * e[b + 2]!;
29
+ return Number.isFinite(x + y + z) && Math.min(x, y, z) > 0 &&
30
+ Math.abs(dot(0, 4)) <= 1e-7 * x * y &&
31
+ Math.abs(dot(0, 8)) <= 1e-7 * x * z &&
32
+ Math.abs(dot(4, 8)) <= 1e-7 * y * z;
33
+ }
34
+
35
+ /** Disposable draws only. The original objects, geometry, material slots and
36
+ * parent graph remain the authority for hierarchy, bounds, editing and picking.
37
+ * Layer zero-mask suppresses their ordinary draw; batch raycasts return those
38
+ * same original objects. Selection passes can still temporarily add their mask
39
+ * layer to an original. No vertices or authored data are reduced or replaced. */
40
+ export class BlenderRuntimeInstances {
41
+ private readonly batches: Batch[] = [];
42
+ private readonly inverse = new THREE.Matrix4();
43
+ private readonly matrix = new THREE.Matrix4();
44
+ private readonly box = new THREE.Box3();
45
+
46
+ constructor(private readonly root: THREE.Group) {}
47
+
48
+ rebuild(objects: Iterable<THREE.Object3D>, enabled: boolean): void {
49
+ this.clear();
50
+ if (!enabled) return;
51
+ const groups = new Map<string, THREE.Mesh[]>();
52
+ this.inverse.copy(this.root.matrixWorld).invert();
53
+ for (const object of objects) {
54
+ const mesh = object as THREE.Mesh;
55
+ if (!mesh.isMesh || (mesh as THREE.SkinnedMesh).isSkinnedMesh ||
56
+ (mesh as THREE.InstancedMesh).isInstancedMesh || mesh.morphTargetInfluences ||
57
+ mesh.layers.mask !== 1) continue;
58
+ // WebGL2 guarantees sixteen vertex attributes; the instance matrix uses
59
+ // four. Keep richly attributed meshes on the ordinary path rather than
60
+ // dropping UV/colour channels or risking a refused shader.
61
+ if (Object.values(mesh.geometry.attributes).reduce((count, attribute) => count + Math.ceil(attribute.itemSize / 4), 0) > 12) continue;
62
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
63
+ if (materials.some(m => m.transparent || (m as THREE.MeshPhysicalMaterial).transmission > 0 ||
64
+ (m as THREE.ShaderMaterial).isShaderMaterial)) continue;
65
+ this.matrix.multiplyMatrices(this.inverse, mesh.matrixWorld);
66
+ if (!supported(this.matrix)) continue;
67
+ const key = `${mesh.geometry.id}:${Array.isArray(mesh.material) ? 'slots' : 'single'}:` +
68
+ materials.map(m => m.uuid).join(',') + `:${mesh.renderOrder}`;
69
+ const group = groups.get(key);
70
+ if (group) group.push(mesh);
71
+ else groups.set(key, [mesh]);
72
+ }
73
+ for (const group of groups.values()) {
74
+ if (group.length < 2) continue;
75
+ // Partition along the widest centre axis, recursively. Adjacent authored
76
+ // names need not be adjacent in space (linked trees span the whole Bridge).
77
+ const partition = (members: THREE.Mesh[]): void => {
78
+ if (members.length > BATCH_SIZE) {
79
+ this.box.makeEmpty();
80
+ for (const mesh of members) this.box.expandByPoint(new THREE.Vector3().setFromMatrixPosition(mesh.matrixWorld));
81
+ const size = this.box.getSize(new THREE.Vector3());
82
+ const axis = size.x >= size.y && size.x >= size.z ? 12 : size.y >= size.z ? 13 : 14;
83
+ members.sort((a, b) => a.matrixWorld.elements[axis]! - b.matrixWorld.elements[axis]!);
84
+ const middle = Math.floor(members.length / 2);
85
+ partition(members.slice(0, middle));
86
+ partition(members.slice(middle));
87
+ return;
88
+ }
89
+ if (members.length < 2) return;
90
+ const first = members[0]!;
91
+ const draw = new THREE.InstancedMesh(first.geometry, first.material, members.length);
92
+ draw.name = 'Blender repeated geometry';
93
+ draw.userData['engineInternal'] = true;
94
+ draw.matrixAutoUpdate = false;
95
+ draw.renderOrder = first.renderOrder;
96
+ draw.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
97
+ const batch: Batch = {draw, members: members.map(mesh => ({mesh, layers: mesh.layers.mask}))};
98
+ // Return the canonical mesh, not the internal batch or an unstable slot.
99
+ draw.raycast = (raycaster, hits) => {
100
+ for (const {mesh} of batch.members)
101
+ if (mesh.layers.mask === 0 && shown(mesh)) mesh.raycast(raycaster, hits);
102
+ };
103
+ this.root.add(draw);
104
+ draw.updateMatrixWorld(true);
105
+ this.batches.push(batch);
106
+ };
107
+ partition(group);
108
+ }
109
+ this.sync();
110
+ }
111
+
112
+ /** Called after the root updates its descendants, before Three builds its
113
+ * render list/uploads attributes. Live gizmo transforms therefore appear on
114
+ * the very next draw, including parent transforms and visibility changes. */
115
+ sync(): void {
116
+ if (!this.batches.length) return;
117
+ this.inverse.copy(this.root.matrixWorld).invert();
118
+ for (const {draw, members} of this.batches) {
119
+ let count = 0;
120
+ let changed = false;
121
+ for (const member of members) {
122
+ const {mesh} = member;
123
+ this.matrix.multiplyMatrices(this.inverse, mesh.matrixWorld);
124
+ // A drag can introduce negative scale/shear, or a skin can replace a
125
+ // mesh between frames. Fall back immediately without touching its data.
126
+ const compatible = mesh.parent !== null && mesh.geometry === draw.geometry &&
127
+ sameMaterials(mesh.material, draw.material) && supported(this.matrix);
128
+ if (!compatible) {
129
+ mesh.layers.mask |= member.layers;
130
+ continue;
131
+ }
132
+ mesh.layers.mask &= ~member.layers;
133
+ if (!shown(mesh)) continue;
134
+ const offset = count * 16;
135
+ if (this.matrix.elements.some((value, i) => Math.fround(value) !== draw.instanceMatrix.array[offset + i])) {
136
+ draw.setMatrixAt(count, this.matrix);
137
+ changed = true;
138
+ }
139
+ count++;
140
+ }
141
+ changed ||= draw.count !== count || draw.boundingSphere === null;
142
+ draw.count = count;
143
+ if (changed) {
144
+ draw.instanceMatrix.needsUpdate = true;
145
+ draw.computeBoundingBox();
146
+ draw.computeBoundingSphere();
147
+ }
148
+ }
149
+ }
150
+
151
+ clear(): void {
152
+ for (const {draw, members} of this.batches) {
153
+ for (const {mesh, layers} of members) mesh.layers.mask |= layers;
154
+ draw.removeFromParent();
155
+ draw.dispose(); // releases instance attributes, never shared geometry/material
156
+ }
157
+ this.batches.length = 0;
158
+ }
159
+
160
+ inspect() {
161
+ return {batches: this.batches.length, instances: this.batches.reduce((count, {draw}) => count + draw.count, 0)};
162
+ }
163
+ }
@@ -42,6 +42,7 @@ import {
42
42
  panCameraView,
43
43
  } from './blender-runtime-camera-view';
44
44
  import { DEFAULT_VIEWPORT_DISPLAY, workbenchMaterial } from './blender-workbench-material';
45
+ import { BlenderRuntimeInstances } from './blender-runtime-instances';
45
46
 
46
47
  /** A saved `View3DShading.type` as the stage's draw mode: Material Preview is `preview`. */
47
48
  const SAVED_SHADING = {
@@ -640,6 +641,7 @@ export interface PhotographRecord {
640
641
 
641
642
  export class BlenderRuntimeView {
642
643
  readonly root = new THREE.Group();
644
+ private readonly instances = new BlenderRuntimeInstances(this.root);
643
645
  private readonly objects = new Map<string, THREE.Object3D>();
644
646
  private readonly meshes = new Map<
645
647
  string,
@@ -1034,11 +1036,17 @@ export class BlenderRuntimeView {
1034
1036
  for (const child of children) next.add(child);
1035
1037
  parent?.add(next);
1036
1038
  this.objects.set(id, next);
1039
+ this.instances.rebuild(this.objects.values(), !this.rendered);
1037
1040
  return;
1038
1041
  }
1039
1042
  }
1040
1043
 
1041
1044
  constructor() {
1045
+ const updateMatrixWorld = this.root.updateMatrixWorld.bind(this.root);
1046
+ this.root.updateMatrixWorld = (force?: boolean) => {
1047
+ updateMatrixWorld(force);
1048
+ this.instances.sync();
1049
+ };
1042
1050
  this.root.name = 'Model';
1043
1051
  // BLENDER IS Z-UP, THREE IS Y-UP: (x, y, z) -> (x, z, -y). Written as the
1044
1052
  // matrix rather than `rotation.x = -Math.PI / 2`, because that Euler is a
@@ -1212,6 +1220,7 @@ export class BlenderRuntimeView {
1212
1220
  material.dispose();
1213
1221
  this.workbenchMaterials.delete(key);
1214
1222
  }
1223
+ this.instances.rebuild(this.objects.values(), !this.rendered);
1215
1224
  }
1216
1225
 
1217
1226
  private workbenchFor(id: string | null, side: THREE.Side, used: Set<string>): THREE.Material {
@@ -2007,6 +2016,7 @@ export class BlenderRuntimeView {
2007
2016
  active: this.frame?.active,
2008
2017
  mode: this.frame?.mode,
2009
2018
  geometryBuilds: this.geometryBuilds,
2019
+ instancing: this.instances.inspect(),
2010
2020
  objects: [...this.objects].map(([id, object]) => ({
2011
2021
  id,
2012
2022
  name: object.name,
@@ -2224,6 +2234,7 @@ export class BlenderRuntimeView {
2224
2234
  }
2225
2235
 
2226
2236
  private clear() {
2237
+ this.instances.clear();
2227
2238
  for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
2228
2239
  this.staged = null;
2229
2240
  for (const light of this.lights.values()) {
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.64",
5
+ "version": "0.1.66",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },