@volter/blender-engine 0.1.71 → 0.1.73

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,248 @@
1
+ import * as THREE from 'three';
2
+ import {instanceObjectShown} from './blender-runtime-instances';
3
+ import type {MotionMeshRequest, MotionMeshResponse} from './blender-motion-worker';
4
+
5
+ const CACHE_BYTES = 64 * 1024 * 1024;
6
+ const QUIET_MS = 150;
7
+ type Entry = {geometry: THREE.BufferGeometry; error: number; bytes: number};
8
+
9
+ /** A disposable navigation drawing, never Blender's evaluated geometry.
10
+ * Copies are derived off-thread, in importance order, one bounded mesh at a
11
+ * time. Full meshes remain resident and are restored after EVERY draw. */
12
+ export class BlenderMotionGeometry {
13
+ private groups = new Map<THREE.BufferGeometry, THREE.Mesh[]>();
14
+ private readonly cache = new Map<THREE.BufferGeometry, Entry>();
15
+ private readonly swapped: {mesh: THREE.Mesh; geometry: THREE.BufferGeometry}[] = [];
16
+ private queue: THREE.BufferGeometry[] = [];
17
+ private worker: Worker | null = null;
18
+ private pending: {id: number; geometry: THREE.BufferGeometry} | null = null;
19
+ private nextId = 0;
20
+ private signature = '';
21
+ private readonly identities = new WeakMap<object, number>();
22
+ private nextIdentity = 0;
23
+ private bytes = 0;
24
+ private unavailable: string | null = null;
25
+ private timer: ReturnType<typeof setTimeout> | null = null;
26
+ private readonly cameraWorld = new THREE.Matrix4();
27
+ private readonly cameraProjection = new THREE.Matrix4();
28
+ private cameraKnown = false;
29
+ private movedAt = -Infinity;
30
+ private readonly projection = new THREE.Matrix4();
31
+ private readonly frustum = new THREE.Frustum();
32
+ private readonly centre = new THREE.Vector3();
33
+ private drawnMeshes = 0;
34
+ private fullTriangles = 0;
35
+ private motionTriangles = 0;
36
+
37
+ constructor(private readonly root: THREE.Group, private readonly invalidate: () => void) {}
38
+
39
+ setObjects(objects: Iterable<THREE.Object3D>): void {
40
+ const groups = new Map<THREE.BufferGeometry, THREE.Mesh[]>();
41
+ for (const object of objects) {
42
+ const mesh = object as THREE.Mesh;
43
+ if (!mesh.isMesh || (mesh as THREE.SkinnedMesh).isSkinnedMesh ||
44
+ (mesh as THREE.InstancedMesh).isInstancedMesh || mesh.morphTargetInfluences) continue;
45
+ const g = mesh.geometry;
46
+ const position = g.getAttribute('position');
47
+ if (!g.index || g.index.count < 6144 || !position || position.count > 200_000 ||
48
+ Object.values(g.attributes).some(a => !(a instanceof THREE.BufferAttribute)) ||
49
+ Object.keys(g.morphAttributes).length || g.drawRange.start !== 0 || g.drawRange.count < g.index.count) continue;
50
+ // Only a whole, single material surface. Material boundaries are never
51
+ // crossed by the simplifier, nor are authored group ranges shortened.
52
+ if (Array.isArray(mesh.material) && (mesh.material.length !== 1 || g.groups.length !== 1 ||
53
+ g.groups[0]!.start !== 0 || g.groups[0]!.count !== g.index.count || g.groups[0]!.materialIndex !== 0)) continue;
54
+ const held = groups.get(g);
55
+ if (held) held.push(mesh); else groups.set(g, [mesh]);
56
+ }
57
+ this.groups = groups;
58
+ const identity = (object: object): number => {
59
+ let id = this.identities.get(object);
60
+ if (id === undefined) { id = ++this.nextIdentity; this.identities.set(object, id); }
61
+ return id;
62
+ };
63
+ const signature = [...groups].map(([g]) => `${g.id}:${identity(g.index!)}:${g.index!.version}:` +
64
+ Object.entries(g.attributes).map(([name, a]) => `${name}:${identity(a)}:${(a as THREE.BufferAttribute).version}`).join(',')).join(';');
65
+ if (signature === this.signature) return;
66
+ this.releaseCopies();
67
+ this.signature = signature;
68
+ this.unavailable = null;
69
+ const candidates = [...groups].filter(([, meshes]) => meshes.length >= 3);
70
+ // Small scenes use their originals throughout. Prioritize the geometry
71
+ // that costs most draws, not its name or its order in the .blend.
72
+ if (candidates.reduce((n, [g, meshes]) => n + g.index!.count * meshes.length, 0) < 6_000_000) return;
73
+ candidates.sort(([a, am], [b, bm]) => b.index!.count * bm.length - a.index!.count * am.length);
74
+ this.queue = candidates.map(([g]) => g);
75
+ this.next();
76
+ }
77
+
78
+ private next(): void {
79
+ if (this.pending || this.unavailable || this.bytes >= CACHE_BYTES) return;
80
+ const geometry = this.queue.shift();
81
+ if (!geometry) { this.worker?.terminate(); this.worker = null; return; }
82
+ if (!this.worker) {
83
+ try {
84
+ this.worker = new Worker(new URL('./blender-motion-worker.ts', import.meta.url), {type: 'module'});
85
+ this.worker.onmessage = (event: MessageEvent<MotionMeshResponse>) => this.receive(event.data);
86
+ this.worker.onerror = event => {
87
+ this.unavailable = event.message || 'Navigation geometry worker unavailable';
88
+ this.worker?.terminate(); this.worker = null; this.pending = null;
89
+ this.queue.length = 0;
90
+ };
91
+ } catch (error) {
92
+ // This is an optional presentation. Refusal retains full geometry,
93
+ // with a visible diagnostic; never fall back to blocking the UI.
94
+ this.unavailable = String(error); return;
95
+ }
96
+ }
97
+ const position = geometry.getAttribute('position') as THREE.BufferAttribute;
98
+ const positions = new Float32Array(position.count * 3);
99
+ for (let i = 0; i < position.count; i++) {
100
+ positions[i * 3] = position.getX(i); positions[i * 3 + 1] = position.getY(i); positions[i * 3 + 2] = position.getZ(i);
101
+ }
102
+ // Normals and UVs contribute appearance error. All attributes, including
103
+ // those not used to rank collapses, are retained in the resulting copy.
104
+ const attributes = Object.entries(geometry.attributes).filter(([name]) => name === 'normal' || /^uv\d*$/.test(name));
105
+ const stride = attributes.reduce((n, [, a]) => n + a.itemSize, 0);
106
+ const values = new Float32Array(position.count * stride);
107
+ const weights: number[] = [];
108
+ let offset = 0;
109
+ for (const [name, a] of attributes) {
110
+ for (let c = 0; c < a.itemSize; c++) weights.push(name === 'normal' ? 0.5 : 1);
111
+ for (let i = 0; i < position.count; i++) for (let c = 0; c < a.itemSize; c++)
112
+ values[i * stride + offset + c] = (a as THREE.BufferAttribute).getComponent(i, c);
113
+ offset += a.itemSize;
114
+ }
115
+ const indices = new Uint32Array(geometry.index!.array);
116
+ const id = ++this.nextId;
117
+ this.pending = {id, geometry};
118
+ const request: MotionMeshRequest = {id, positions, indices, attributes: values, stride, weights};
119
+ this.worker.postMessage(request, [positions.buffer, indices.buffer, values.buffer]);
120
+ }
121
+
122
+ private receive(response: MotionMeshResponse): void {
123
+ const pending = this.pending;
124
+ if (!pending || pending.id !== response.id) return;
125
+ this.pending = null;
126
+ const {geometry} = pending;
127
+ const indices = response.indices;
128
+ if (response.refusal) {
129
+ this.unavailable = response.refusal;
130
+ this.worker?.terminate(); this.worker = null; this.queue.length = 0;
131
+ }
132
+ if (indices && response.error !== undefined && Number.isFinite(response.error) &&
133
+ indices.length > 0 && indices.length < geometry.index!.count * 0.75) {
134
+ const originals: number[] = [];
135
+ const remap = new Map<number, number>();
136
+ for (let i = 0; i < indices.length; i++) {
137
+ const original = indices[i]!;
138
+ let index = remap.get(original);
139
+ if (index === undefined) { index = originals.length; originals.push(original); remap.set(original, index); }
140
+ indices[i] = index;
141
+ }
142
+ const bytes = indices.byteLength + Object.values(geometry.attributes).reduce((n, a) =>
143
+ n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0);
144
+ if (this.bytes + bytes <= CACHE_BYTES) {
145
+ const copy = new THREE.BufferGeometry();
146
+ copy.setIndex(new THREE.BufferAttribute(indices, 1));
147
+ for (const [name, source] of Object.entries(geometry.attributes)) {
148
+ const attribute = source as THREE.BufferAttribute;
149
+ const ArrayType = attribute.array.constructor as {new(length: number): THREE.TypedArray};
150
+ const data = new ArrayType(originals.length * attribute.itemSize);
151
+ for (let i = 0; i < originals.length; i++) for (let c = 0; c < attribute.itemSize; c++)
152
+ data[i * attribute.itemSize + c] = attribute.array[originals[i]! * attribute.itemSize + c]!;
153
+ copy.setAttribute(name, new THREE.BufferAttribute(data, attribute.itemSize, attribute.normalized));
154
+ }
155
+ // Retain the exact culling/sorting bounds and material assignment.
156
+ if (!geometry.boundingSphere) geometry.computeBoundingSphere();
157
+ copy.boundingSphere = geometry.boundingSphere!.clone();
158
+ copy.boundingBox = geometry.boundingBox?.clone() ?? null;
159
+ if (geometry.groups.length === 1) copy.addGroup(0, indices.length, geometry.groups[0]!.materialIndex);
160
+ copy.userData = {...geometry.userData};
161
+ this.cache.set(geometry, {geometry: copy, error: response.error, bytes});
162
+ this.bytes += bytes;
163
+ }
164
+ }
165
+ // Yield between meshes, including copying their attributes for the worker.
166
+ this.timer = setTimeout(() => { this.timer = null; this.next(); }, 0);
167
+ }
168
+
169
+ prepare(camera: THREE.Camera, interactive: boolean, height: number): void {
170
+ this.finish();
171
+ this.drawnMeshes = this.fullTriangles = this.motionTriangles = 0;
172
+ if (!interactive || !(height > 0)) return;
173
+ const now = performance.now();
174
+ const moved = this.cameraKnown && (!camera.matrixWorld.equals(this.cameraWorld) ||
175
+ !camera.projectionMatrix.equals(this.cameraProjection));
176
+ this.cameraKnown = true;
177
+ this.cameraWorld.copy(camera.matrixWorld); this.cameraProjection.copy(camera.projectionMatrix);
178
+ if (moved) {
179
+ this.movedAt = now;
180
+ // The host draws on change. Ensure a full-detail frame after damping
181
+ // stops even when no more input or scene-change notification arrives.
182
+ if (this.quietTimer) clearTimeout(this.quietTimer);
183
+ this.quietTimer = setTimeout(() => { this.quietTimer = null; this.invalidate(); }, QUIET_MS + 1);
184
+ }
185
+ if (now - this.movedAt > QUIET_MS) return;
186
+ this.projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
187
+ this.frustum.setFromProjectionMatrix(this.projection);
188
+ const allowed = new Map<THREE.BufferGeometry, THREE.BufferGeometry>();
189
+ for (const [source, entry] of this.cache) {
190
+ let fits = true;
191
+ for (const mesh of this.groups.get(source) ?? []) {
192
+ if (!instanceObjectShown(mesh) || (mesh.frustumCulled && !this.frustum.intersectsObject(mesh))) continue;
193
+ const scale = mesh.matrixWorld.getMaxScaleOnAxis();
194
+ const sphere = source.boundingSphere!;
195
+ this.centre.copy(sphere.center).applyMatrix4(mesh.matrixWorld).applyMatrix4(camera.matrixWorldInverse);
196
+ const depth = (camera as THREE.PerspectiveCamera).isPerspectiveCamera
197
+ ? -this.centre.z - sphere.radius * scale : 1;
198
+ const pixels = entry.error * scale * Math.abs(camera.projectionMatrix.elements[5]!) * height / (2 * depth);
199
+ if (!(depth > 0) || pixels > 1) { fits = false; break; }
200
+ }
201
+ if (fits) allowed.set(source, entry.geometry);
202
+ }
203
+ // After instancing/culling chose its exact members: both ordinary and
204
+ // internal draws select the same temporary geometry for this camera.
205
+ this.root.traverse(object => {
206
+ const mesh = object as THREE.Mesh;
207
+ if (!mesh.isMesh || (mesh as THREE.SkinnedMesh).isSkinnedMesh || mesh.morphTargetInfluences ||
208
+ ((mesh as THREE.InstancedMesh).isInstancedMesh && mesh.userData['engineInternal'] !== true)) return;
209
+ if (Array.isArray(mesh.material) && (mesh.material.length !== 1 || mesh.geometry.groups.length !== 1 ||
210
+ mesh.geometry.groups[0]!.start !== 0 || mesh.geometry.groups[0]!.count !== mesh.geometry.index?.count ||
211
+ mesh.geometry.groups[0]!.materialIndex !== 0)) return;
212
+ const copy = allowed.get(mesh.geometry);
213
+ if (!copy) return;
214
+ this.swapped.push({mesh, geometry: mesh.geometry});
215
+ if (mesh.visible && mesh.layers.isEnabled(0)) {
216
+ const count = (mesh as THREE.InstancedMesh).isInstancedMesh ? (mesh as THREE.InstancedMesh).count : 1;
217
+ this.drawnMeshes += count;
218
+ this.fullTriangles += mesh.geometry.index!.count / 3 * count;
219
+ this.motionTriangles += copy.index!.count / 3 * count;
220
+ }
221
+ mesh.geometry = copy;
222
+ });
223
+ }
224
+
225
+ private quietTimer: ReturnType<typeof setTimeout> | null = null;
226
+ finish(): void {
227
+ for (const {mesh, geometry} of this.swapped) mesh.geometry = geometry;
228
+ this.swapped.length = 0;
229
+ }
230
+ inspect() {
231
+ return {copies: this.cache.size, bytes: this.bytes, queued: this.queue.length, pending: this.pending !== null,
232
+ unavailable: this.unavailable, lastDraw: {meshes: this.drawnMeshes, fullTriangles: this.fullTriangles, motionTriangles: this.motionTriangles}};
233
+ }
234
+ private releaseCopies(): void {
235
+ this.finish();
236
+ if (this.timer) clearTimeout(this.timer);
237
+ this.timer = null;
238
+ this.worker?.terminate(); this.worker = null; this.pending = null; this.queue = [];
239
+ for (const entry of this.cache.values()) entry.geometry.dispose();
240
+ this.cache.clear(); this.bytes = 0;
241
+ }
242
+ clear(): void {
243
+ this.releaseCopies();
244
+ if (this.quietTimer) clearTimeout(this.quietTimer);
245
+ this.quietTimer = null;
246
+ this.groups.clear(); this.signature = ''; this.cameraKnown = false; this.movedAt = -Infinity;
247
+ }
248
+ }
@@ -0,0 +1,33 @@
1
+ import {MeshoptSimplifier} from 'meshoptimizer/simplifier';
2
+
3
+ export interface MotionMeshRequest {
4
+ id: number;
5
+ positions: Float32Array;
6
+ indices: Uint32Array;
7
+ attributes: Float32Array;
8
+ stride: number;
9
+ weights: number[];
10
+ }
11
+ export interface MotionMeshResponse {id: number; indices?: Uint32Array; error?: number; refusal?: string}
12
+
13
+ // One copied mesh at a time. The worker never owns or detaches a resident
14
+ // Blender column, and never writes positions or attributes back to the model.
15
+ self.onmessage = async (event: MessageEvent<MotionMeshRequest>) => {
16
+ const {id, positions, indices, attributes, stride, weights} = event.data;
17
+ try {
18
+ await MeshoptSimplifier.ready;
19
+ const target = Math.max(384, Math.floor(indices.length * 0.08 / 3) * 3);
20
+ const [reduced, error] = MeshoptSimplifier.simplifyWithAttributes(
21
+ indices, positions, 3, attributes, stride, weights, null, target, 0.002,
22
+ // Preserve open boundaries and attribute seams. If that prevents a
23
+ // useful reduction, keep the original; never sample/drop triangles.
24
+ ['LockBorder'],
25
+ );
26
+ const response: MotionMeshResponse = {
27
+ id, indices: reduced, error: error * MeshoptSimplifier.getScale(positions, 3),
28
+ };
29
+ self.postMessage(response, {transfer: [reduced.buffer]});
30
+ } catch (error) {
31
+ self.postMessage({id, refusal: error instanceof Error ? error.message : String(error)} satisfies MotionMeshResponse);
32
+ }
33
+ };
@@ -44,6 +44,7 @@ import {
44
44
  import { DEFAULT_VIEWPORT_DISPLAY, workbenchMaterial } from './blender-workbench-material';
45
45
  import { BlenderRuntimeInstances } from './blender-runtime-instances';
46
46
  import { BlenderTransparentInstances } from './blender-transparent-instances';
47
+ import { BlenderMotionGeometry } from './blender-motion-geometry';
47
48
 
48
49
  /** A saved `View3DShading.type` as the stage's draw mode: Material Preview is `preview`. */
49
50
  const SAVED_SHADING = {
@@ -644,6 +645,7 @@ export class BlenderRuntimeView {
644
645
  readonly root = new THREE.Group();
645
646
  private readonly instances = new BlenderRuntimeInstances(this.root);
646
647
  private readonly transparentInstances = new BlenderTransparentInstances(this.root);
648
+ private readonly motionGeometry = new BlenderMotionGeometry(this.root, presenterChanged);
647
649
  private drawBatching = true;
648
650
  private readonly objects = new Map<string, THREE.Object3D>();
649
651
  private readonly meshes = new Map<
@@ -1230,6 +1232,7 @@ export class BlenderRuntimeView {
1230
1232
  this.transparentInstances.clear();
1231
1233
  this.instances.rebuild(this.objects.values(), this.drawBatching && !this.rendered);
1232
1234
  if (this.drawBatching && !this.rendered) this.transparentInstances.setObjects(this.objects.values());
1235
+ this.motionGeometry.setObjects(this.objects.values());
1233
1236
  }
1234
1237
 
1235
1238
  /** Presentation-only comparison door; never changes Blender or its data. */
@@ -1242,17 +1245,20 @@ export class BlenderRuntimeView {
1242
1245
 
1243
1246
  /** Before the renderer uploads attributes/builds its queues, for this area's
1244
1247
  * actual camera. Transparent instance runs are camera-order dependent. */
1245
- prepareDraw(camera: THREE.Camera): () => void {
1248
+ prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number}): () => void {
1246
1249
  try {
1247
1250
  this.instances.prepareDraw(camera);
1248
1251
  this.root.updateMatrixWorld(true);
1249
1252
  this.transparentInstances.prepare(camera);
1253
+ this.motionGeometry.prepare(camera, !this.rendered && options?.interactive === true, options?.height ?? 0);
1250
1254
  } catch (error) {
1255
+ this.motionGeometry.finish();
1251
1256
  this.instances.finishDraw();
1252
1257
  this.transparentInstances.finishDraw();
1253
1258
  throw error;
1254
1259
  }
1255
1260
  return () => {
1261
+ this.motionGeometry.finish();
1256
1262
  this.instances.finishDraw();
1257
1263
  this.transparentInstances.finishDraw();
1258
1264
  };
@@ -2063,7 +2069,7 @@ export class BlenderRuntimeView {
2063
2069
  }
2064
2070
 
2065
2071
  drawStatistics() {
2066
- return {enabled: this.drawBatching, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect()};
2072
+ return {enabled: this.drawBatching, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2067
2073
  }
2068
2074
 
2069
2075
  snapshot() {
@@ -2273,6 +2279,7 @@ export class BlenderRuntimeView {
2273
2279
  }
2274
2280
 
2275
2281
  private clear() {
2282
+ this.motionGeometry.clear();
2276
2283
  this.transparentInstances.clear();
2277
2284
  this.instances.clear();
2278
2285
  for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
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.71",
5
+ "version": "0.1.73",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -44,5 +44,8 @@
44
44
  "scripts": {
45
45
  "build:blender-three": "vite build --config vite.release.config.ts",
46
46
  "typecheck": "tsc -p tsconfig.browser.json"
47
+ },
48
+ "dependencies": {
49
+ "meshoptimizer": "1.2.0"
47
50
  }
48
51
  }