@volter/blender-engine 0.1.72 → 0.1.74

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,244 @@
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
+ private lastNavigation = {meshes: 0, fullTriangles: 0, motionTriangles: 0, tooClose: 0};
37
+
38
+ constructor(private readonly invalidate: () => void) {}
39
+
40
+ setObjects(objects: Iterable<THREE.Object3D>): void {
41
+ const groups = new Map<THREE.BufferGeometry, THREE.Mesh[]>();
42
+ for (const object of objects) {
43
+ const mesh = object as THREE.Mesh;
44
+ if (!mesh.isMesh || (mesh as THREE.SkinnedMesh).isSkinnedMesh ||
45
+ (mesh as THREE.InstancedMesh).isInstancedMesh || mesh.morphTargetInfluences) continue;
46
+ const g = mesh.geometry;
47
+ const position = g.getAttribute('position');
48
+ if (!g.index || g.index.count < 6144 || !position || position.count > 200_000 ||
49
+ Object.values(g.attributes).some(a => !(a instanceof THREE.BufferAttribute) ||
50
+ (a as THREE.BufferAttribute & {isFloat16BufferAttribute?: boolean}).isFloat16BufferAttribute ||
51
+ (a as THREE.InstancedBufferAttribute).isInstancedBufferAttribute) ||
52
+ Object.keys(g.morphAttributes).length || g.drawRange.start !== 0 || g.drawRange.count < g.index.count) continue;
53
+ // Only a whole, single material surface. Material boundaries are never
54
+ // crossed by the simplifier, nor are authored group ranges shortened.
55
+ if (Array.isArray(mesh.material) && (mesh.material.length !== 1 || g.groups.length !== 1 ||
56
+ g.groups[0]!.start !== 0 || g.groups[0]!.count !== g.index.count || g.groups[0]!.materialIndex !== 0)) continue;
57
+ const held = groups.get(g);
58
+ if (held) held.push(mesh); else groups.set(g, [mesh]);
59
+ }
60
+ this.groups = groups;
61
+ const identity = (object: object): number => {
62
+ let id = this.identities.get(object);
63
+ if (id === undefined) { id = ++this.nextIdentity; this.identities.set(object, id); }
64
+ return id;
65
+ };
66
+ const signature = [...groups].map(([g]) => `${g.id}:${identity(g.index!)}:${g.index!.version}:` +
67
+ Object.entries(g.attributes).map(([name, a]) => `${name}:${identity(a)}:${(a as THREE.BufferAttribute).version}`).join(',')).join(';');
68
+ if (signature === this.signature) return;
69
+ this.releaseCopies();
70
+ this.signature = signature;
71
+ this.unavailable = null;
72
+ const candidates = [...groups].filter(([, meshes]) => meshes.length >= 3);
73
+ // Small scenes use their originals throughout. Prioritize the geometry
74
+ // that costs most draws, not its name or its order in the .blend.
75
+ if (candidates.reduce((n, [g, meshes]) => n + g.index!.count * meshes.length, 0) < 6_000_000) return;
76
+ candidates.sort(([a, am], [b, bm]) => b.index!.count * bm.length - a.index!.count * am.length);
77
+ this.queue = candidates.map(([g]) => g);
78
+ this.next();
79
+ }
80
+
81
+ private next(): void {
82
+ if (this.pending || this.unavailable || this.bytes >= CACHE_BYTES) return;
83
+ const geometry = this.queue.shift();
84
+ if (!geometry) { this.worker?.terminate(); this.worker = null; return; }
85
+ if (!this.worker) {
86
+ try {
87
+ this.worker = new Worker(new URL('./blender-motion-worker.ts', import.meta.url), {type: 'module'});
88
+ this.worker.onmessage = (event: MessageEvent<MotionMeshResponse>) => this.receive(event.data);
89
+ this.worker.onerror = event => {
90
+ this.unavailable = event.message || 'Navigation geometry worker unavailable';
91
+ this.worker?.terminate(); this.worker = null; this.pending = null;
92
+ this.queue.length = 0;
93
+ };
94
+ } catch (error) {
95
+ // This is an optional presentation. Refusal retains full geometry,
96
+ // with a visible diagnostic; never fall back to blocking the UI.
97
+ this.unavailable = String(error); return;
98
+ }
99
+ }
100
+ const position = geometry.getAttribute('position') as THREE.BufferAttribute;
101
+ const positions = new Float32Array(position.count * 3);
102
+ for (let i = 0; i < position.count; i++) {
103
+ positions[i * 3] = position.getX(i); positions[i * 3 + 1] = position.getY(i); positions[i * 3 + 2] = position.getZ(i);
104
+ }
105
+ // Normals and UVs contribute appearance error. All attributes, including
106
+ // those not used to rank collapses, are retained in the resulting copy.
107
+ const attributes = Object.entries(geometry.attributes).filter(([name]) => name === 'normal' || /^uv\d*$/.test(name));
108
+ const stride = attributes.reduce((n, [, a]) => n + a.itemSize, 0);
109
+ const values = new Float32Array(position.count * stride);
110
+ const weights: number[] = [];
111
+ let offset = 0;
112
+ for (const [name, a] of attributes) {
113
+ for (let c = 0; c < a.itemSize; c++) weights.push(name === 'normal' ? 0.5 : 1);
114
+ for (let i = 0; i < position.count; i++) for (let c = 0; c < a.itemSize; c++)
115
+ values[i * stride + offset + c] = (a as THREE.BufferAttribute).getComponent(i, c);
116
+ offset += a.itemSize;
117
+ }
118
+ const indices = new Uint32Array(geometry.index!.array);
119
+ const id = ++this.nextId;
120
+ this.pending = {id, geometry};
121
+ const request: MotionMeshRequest = {id, positions, indices, attributes: values, stride, weights};
122
+ this.worker.postMessage(request, [positions.buffer, indices.buffer, values.buffer]);
123
+ }
124
+
125
+ private receive(response: MotionMeshResponse): void {
126
+ const pending = this.pending;
127
+ if (!pending || pending.id !== response.id) return;
128
+ this.pending = null;
129
+ const {geometry} = pending;
130
+ const indices = response.indices;
131
+ if (response.refusal) {
132
+ this.unavailable = response.refusal;
133
+ this.worker?.terminate(); this.worker = null; this.queue.length = 0;
134
+ }
135
+ if (indices && response.error !== undefined && Number.isFinite(response.error) &&
136
+ indices.length > 0 && indices.length < geometry.index!.count * 0.75) {
137
+ const originals: number[] = [];
138
+ const remap = new Map<number, number>();
139
+ for (let i = 0; i < indices.length; i++) {
140
+ const original = indices[i]!;
141
+ let index = remap.get(original);
142
+ if (index === undefined) { index = originals.length; originals.push(original); remap.set(original, index); }
143
+ indices[i] = index;
144
+ }
145
+ const bytes = indices.byteLength + Object.values(geometry.attributes).reduce((n, a) =>
146
+ n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0);
147
+ if (this.bytes + bytes <= CACHE_BYTES) {
148
+ const copy = new THREE.BufferGeometry();
149
+ copy.setIndex(new THREE.BufferAttribute(indices, 1));
150
+ for (const [name, source] of Object.entries(geometry.attributes)) {
151
+ const attribute = source as THREE.BufferAttribute;
152
+ const ArrayType = attribute.array.constructor as {new(length: number): THREE.TypedArray};
153
+ const data = new ArrayType(originals.length * attribute.itemSize);
154
+ for (let i = 0; i < originals.length; i++) for (let c = 0; c < attribute.itemSize; c++)
155
+ data[i * attribute.itemSize + c] = attribute.array[originals[i]! * attribute.itemSize + c]!;
156
+ copy.setAttribute(name, new THREE.BufferAttribute(data, attribute.itemSize, attribute.normalized));
157
+ }
158
+ // Retain the exact culling/sorting bounds and material assignment.
159
+ if (!geometry.boundingSphere) geometry.computeBoundingSphere();
160
+ copy.boundingSphere = geometry.boundingSphere!.clone();
161
+ copy.boundingBox = geometry.boundingBox?.clone() ?? null;
162
+ if (geometry.groups.length === 1) copy.addGroup(0, indices.length, geometry.groups[0]!.materialIndex);
163
+ copy.userData = {...geometry.userData};
164
+ this.cache.set(geometry, {geometry: copy, error: response.error, bytes});
165
+ this.bytes += bytes;
166
+ }
167
+ }
168
+ // Yield between meshes, including copying their attributes for the worker.
169
+ this.timer = setTimeout(() => { this.timer = null; this.next(); }, 0);
170
+ }
171
+
172
+ prepare(camera: THREE.Camera, interactive: boolean, height: number): boolean {
173
+ this.finish();
174
+ this.drawnMeshes = this.fullTriangles = this.motionTriangles = 0;
175
+ if (!interactive || !(height > 0)) return false;
176
+ const now = performance.now();
177
+ const moved = this.cameraKnown && (!camera.matrixWorld.equals(this.cameraWorld) ||
178
+ !camera.projectionMatrix.equals(this.cameraProjection));
179
+ this.cameraKnown = true;
180
+ this.cameraWorld.copy(camera.matrixWorld); this.cameraProjection.copy(camera.projectionMatrix);
181
+ if (moved) {
182
+ this.movedAt = now;
183
+ // The host draws on change. Ensure a full-detail frame after damping
184
+ // stops even when no more input or scene-change notification arrives.
185
+ if (this.quietTimer) clearTimeout(this.quietTimer);
186
+ this.quietTimer = setTimeout(() => { this.quietTimer = null; this.invalidate(); }, QUIET_MS + 1);
187
+ }
188
+ if (now - this.movedAt > QUIET_MS) return false;
189
+ this.projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
190
+ this.frustum.setFromProjectionMatrix(this.projection);
191
+ let tooClose = 0;
192
+ for (const [source, entry] of this.cache) {
193
+ for (const mesh of this.groups.get(source) ?? []) {
194
+ if (!instanceObjectShown(mesh) || (mesh.frustumCulled && !this.frustum.intersectsObject(mesh))) continue;
195
+ // Frobenius norm bounds the linear transform even under shear; the
196
+ // longest axis alone can understate the projected simplifier error.
197
+ const e = mesh.matrixWorld.elements;
198
+ const scale = Math.hypot(e[0]!, e[1]!, e[2]!, e[4]!, e[5]!, e[6]!, e[8]!, e[9]!, e[10]!);
199
+ const sphere = source.boundingSphere!;
200
+ this.centre.copy(sphere.center).applyMatrix4(mesh.matrixWorld).applyMatrix4(camera.matrixWorldInverse);
201
+ const depth = (camera as THREE.PerspectiveCamera).isPerspectiveCamera
202
+ ? -this.centre.z - sphere.radius * scale : 1;
203
+ const pixels = entry.error * scale * Math.abs(camera.projectionMatrix.elements[5]!) * height / (2 * depth);
204
+ if (!(depth > 0) || pixels > 1) { tooClose++; continue; }
205
+ // Decide per placement: one near-camera blade must not veto thousands
206
+ // of distant placements of its shared geometry. The following draw
207
+ // planner sees these geometry identities, so transparency still batches
208
+ // only consecutive compatible surfaces in the original camera order.
209
+ this.swapped.push({mesh, geometry: source});
210
+ mesh.geometry = entry.geometry;
211
+ this.drawnMeshes++;
212
+ this.fullTriangles += source.index!.count / 3;
213
+ this.motionTriangles += entry.geometry.index!.count / 3;
214
+ }
215
+ }
216
+ this.lastNavigation = {meshes: this.drawnMeshes, fullTriangles: this.fullTriangles,
217
+ motionTriangles: this.motionTriangles, tooClose};
218
+ return this.swapped.length > 0;
219
+ }
220
+
221
+ private quietTimer: ReturnType<typeof setTimeout> | null = null;
222
+ finish(): void {
223
+ for (const {mesh, geometry} of this.swapped) mesh.geometry = geometry;
224
+ this.swapped.length = 0;
225
+ }
226
+ inspect() {
227
+ return {copies: this.cache.size, bytes: this.bytes, queued: this.queue.length, pending: this.pending !== null,
228
+ unavailable: this.unavailable, lastNavigation: {...this.lastNavigation}};
229
+ }
230
+ private releaseCopies(): void {
231
+ this.finish();
232
+ if (this.timer) clearTimeout(this.timer);
233
+ this.timer = null;
234
+ this.worker?.terminate(); this.worker = null; this.pending = null; this.queue = [];
235
+ for (const entry of this.cache.values()) entry.geometry.dispose();
236
+ this.cache.clear(); this.bytes = 0;
237
+ }
238
+ clear(): void {
239
+ this.releaseCopies();
240
+ if (this.quietTimer) clearTimeout(this.quietTimer);
241
+ this.quietTimer = null;
242
+ this.groups.clear(); this.signature = ''; this.cameraKnown = false; this.movedAt = -Infinity;
243
+ }
244
+ }
@@ -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(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,26 @@ 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);
1252
+ const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1253
+ options?.interactive === true, options?.height ?? 0);
1254
+ // A changed navigation geometry is an ordinary-draw fallback for an
1255
+ // opaque batch; its full-detail members remain instanced. Transparent
1256
+ // runs are planned AFTER per-placement geometry selection, preserving
1257
+ // their sorting barriers and compatible geometry grouping.
1258
+ if (motion) this.instances.sync();
1249
1259
  this.transparentInstances.prepare(camera);
1250
1260
  } catch (error) {
1261
+ this.motionGeometry.finish();
1251
1262
  this.instances.finishDraw();
1252
1263
  this.transparentInstances.finishDraw();
1253
1264
  throw error;
1254
1265
  }
1255
1266
  return () => {
1267
+ this.motionGeometry.finish();
1256
1268
  this.instances.finishDraw();
1257
1269
  this.transparentInstances.finishDraw();
1258
1270
  };
@@ -2063,7 +2075,7 @@ export class BlenderRuntimeView {
2063
2075
  }
2064
2076
 
2065
2077
  drawStatistics() {
2066
- return {enabled: this.drawBatching, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect()};
2078
+ return {enabled: this.drawBatching, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2067
2079
  }
2068
2080
 
2069
2081
  snapshot() {
@@ -2273,6 +2285,7 @@ export class BlenderRuntimeView {
2273
2285
  }
2274
2286
 
2275
2287
  private clear() {
2288
+ this.motionGeometry.clear();
2276
2289
  this.transparentInstances.clear();
2277
2290
  this.instances.clear();
2278
2291
  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.72",
5
+ "version": "0.1.74",
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
  }