@volter/blender-engine 0.1.75 → 0.1.77

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.
@@ -29,7 +29,7 @@ export class BlenderMotionGeometry {
29
29
  private nextIdentity = 0;
30
30
  private bytes = 0;
31
31
  private unavailable: string | null = null;
32
- private reductions: {vertices: number; fullTriangles: number; reducedTriangles: number; error: number; accepted: boolean}[] = [];
32
+ private reductions: {vertices: number; fullTriangles: number; reducedTriangles: number; error: number; accepted: boolean; bytes?: number}[] = [];
33
33
  private timer: ReturnType<typeof setTimeout> | null = null;
34
34
  private readonly cameraWorld = new THREE.Matrix4();
35
35
  private readonly cameraProjection = new THREE.Matrix4();
@@ -124,8 +124,40 @@ export class BlenderMotionGeometry {
124
124
  const multi = Array.isArray(meshes[0]!.material);
125
125
  if (meshes.some(mesh => Array.isArray(mesh.material) !== multi)) return;
126
126
  const ranges: Range[] = multi ? geometry.groups : [{start: 0, count: geometry.index!.count}];
127
- const appearance = Object.entries(geometry.attributes).filter(([name]) => name === 'normal' || /^uv\d*$/.test(name));
127
+ const chunks = ranges.flatMap(range => {
128
+ const chunks: Range[] = [];
129
+ for (let start = range.start; start < range.start + range.count; start += CHUNK_INDICES)
130
+ chunks.push({start, count: Math.min(CHUNK_INDICES, range.start + range.count - start), materialIndex: range.materialIndex});
131
+ return chunks;
132
+ });
133
+ // Lock only the boundaries introduced by partitioning. LockBorder would
134
+ // also pin every vertex on a real leaf's silhouette, defeating LOD even
135
+ // when its edge could simplify within the allowed appearance error.
136
+ // Blender's source-point map joins UV/normal splits at the same point.
137
+ const sourcePoints = geometry.getAttribute('blenderVertex') as THREE.BufferAttribute | undefined;
138
+ const partition = new Uint32Array(position.count);
139
+ const boundary = new Uint8Array(position.count);
140
+ const pointIndex = (vertex: number) => sourcePoints ? sourcePoints.getX(vertex) : vertex;
141
+ for (let chunk = 0; chunk < chunks.length; chunk++) {
142
+ const range = chunks[chunk]!;
143
+ for (let first = range.start; first < range.start + range.count; first += COPY_ITEMS) {
144
+ await yieldCopy();
145
+ for (let i = first; i < Math.min(first + COPY_ITEMS, range.start + range.count); i++) {
146
+ const point = pointIndex(geometry.index!.getX(i));
147
+ if (!Number.isInteger(point) || point < 0 || point >= partition.length) return;
148
+ if (!partition[point]) partition[point] = chunk + 1;
149
+ else if (partition[point] !== chunk + 1) boundary[point] = 1;
150
+ }
151
+ }
152
+ }
153
+ const appearance = Object.entries(geometry.attributes).filter(([name]) => name !== 'position' && name !== 'blenderVertex');
128
154
  const stride = appearance.reduce((n, [, a]) => n + a.itemSize, 0);
155
+ if (stride > 32) return; // meshoptimizer's attribute component limit
156
+ const protectedComponents: number[] = [];
157
+ let component = 0;
158
+ for (const [name, attribute] of appearance) {
159
+ for (let c = 0; c < attribute.itemSize; c++, component++) if (name !== 'normal') protectedComponents.push(component);
160
+ }
129
161
  const weights = appearance.flatMap(([name, a]) => Array(a.itemSize).fill(name === 'normal' ? 0.5 : 1));
130
162
  // Reuse bounds when present, otherwise derive the error scale in
131
163
  // yielding slices. Never scan a large mesh in one UI-thread call.
@@ -157,12 +189,14 @@ export class BlenderMotionGeometry {
157
189
  if (local === undefined) { local = originals.length; originals.push(original); remap.set(original, local); }
158
190
  indices[i - start] = local;
159
191
  }
192
+ const locks = new Uint8Array(originals.length);
160
193
  const positions = new Float32Array(originals.length * 3);
161
194
  const values = new Float32Array(originals.length * stride);
162
195
  for (let first = 0; first < originals.length; first += COPY_ITEMS) {
163
196
  await yieldCopy();
164
197
  for (let i = first; i < Math.min(first + COPY_ITEMS, originals.length); i++) {
165
198
  const original = originals[i]!;
199
+ locks[i] = boundary[pointIndex(original)]!;
166
200
  for (let c = 0; c < 3; c++) positions[i * 3 + c] = position.getComponent(original, c);
167
201
  let offset = 0;
168
202
  for (const [, attribute] of appearance) {
@@ -175,8 +209,8 @@ export class BlenderMotionGeometry {
175
209
  const id = ++this.nextId;
176
210
  const response = await new Promise<MotionMeshResponse>(resolve => {
177
211
  this.pending = {id, resolve};
178
- const request: MotionMeshRequest = {id, positions, indices, attributes: values, stride, weights, absoluteError};
179
- this.worker!.postMessage(request, [positions.buffer, indices.buffer, values.buffer]);
212
+ const request: MotionMeshRequest = {id, positions, indices, attributes: values, stride, weights, absoluteError, protectedComponents, locks};
213
+ this.worker!.postMessage(request, [positions.buffer, indices.buffer, values.buffer, locks.buffer]);
180
214
  });
181
215
  current();
182
216
  if (response.refusal) throw new Error(response.refusal);
@@ -190,7 +224,7 @@ export class BlenderMotionGeometry {
190
224
  }
191
225
  if (multi) outputGroups.push({start: groupStart, count: count - groupStart, materialIndex: range.materialIndex});
192
226
  }
193
- const reduction = {vertices: position.count, fullTriangles: geometry.index!.count / 3,
227
+ const reduction: (typeof this.reductions)[number] = {vertices: position.count, fullTriangles: geometry.index!.count / 3,
194
228
  reducedTriangles: count / 3, error, accepted: false};
195
229
  if (this.reductions.length < 32) this.reductions.push(reduction);
196
230
  if (!count || count >= geometry.index!.count * 0.75) return;
@@ -214,6 +248,7 @@ export class BlenderMotionGeometry {
214
248
  }
215
249
  const bytes = indices.byteLength + Object.values(geometry.attributes).reduce((n, a) =>
216
250
  n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0);
251
+ reduction.bytes = bytes;
217
252
  if (this.bytes + bytes > CACHE_BYTES) return;
218
253
  const copy = new THREE.BufferGeometry();
219
254
  copy.setIndex(new THREE.BufferAttribute(indices, 1));
@@ -271,10 +306,18 @@ export class BlenderMotionGeometry {
271
306
  for (const [source, entry] of this.cache) {
272
307
  for (const mesh of this.groups.get(source) ?? []) {
273
308
  if (!instanceObjectShown(mesh) || (mesh.frustumCulled && !this.frustum.intersectsObject(mesh))) continue;
274
- // Frobenius norm bounds the linear transform even under shear; the
275
- // longest axis alone can understate the projected simplifier error.
309
+ // Bound the largest singular value by the maximum absolute row sum
310
+ // of AᵀA. Unlike Frobenius this is tight for orthogonal TRS columns,
311
+ // while still bounding shear. A uniform rotation must not inflate
312
+ // both error and radius by sqrt(3) and veto nearby placements.
276
313
  const e = mesh.matrixWorld.elements;
277
- const scale = Math.hypot(e[0]!, e[1]!, e[2]!, e[4]!, e[5]!, e[6]!, e[8]!, e[9]!, e[10]!);
314
+ const x2 = e[0]! ** 2 + e[1]! ** 2 + e[2]! ** 2;
315
+ const y2 = e[4]! ** 2 + e[5]! ** 2 + e[6]! ** 2;
316
+ const z2 = e[8]! ** 2 + e[9]! ** 2 + e[10]! ** 2;
317
+ const xy = Math.abs(e[0]! * e[4]! + e[1]! * e[5]! + e[2]! * e[6]!);
318
+ const xz = Math.abs(e[0]! * e[8]! + e[1]! * e[9]! + e[2]! * e[10]!);
319
+ const yz = Math.abs(e[4]! * e[8]! + e[5]! * e[9]! + e[6]! * e[10]!);
320
+ const scale = Math.sqrt(Math.max(x2 + xy + xz, y2 + xy + yz, z2 + xz + yz));
278
321
  const sphere = source.boundingSphere!;
279
322
  this.centre.copy(sphere.center).applyMatrix4(mesh.matrixWorld).applyMatrix4(camera.matrixWorldInverse);
280
323
  const depth = (camera as THREE.PerspectiveCamera).isPerspectiveCamera
@@ -8,21 +8,33 @@ export interface MotionMeshRequest {
8
8
  stride: number;
9
9
  weights: number[];
10
10
  absoluteError: number;
11
+ protectedComponents: number[];
12
+ locks: Uint8Array;
11
13
  }
12
14
  export interface MotionMeshResponse {id: number; indices?: Uint32Array; error?: number; refusal?: string}
13
15
 
14
16
  // One copied mesh at a time. The worker never owns or detaches a resident
15
17
  // Blender column, and never writes positions or attributes back to the model.
16
18
  self.onmessage = async (event: MessageEvent<MotionMeshRequest>) => {
17
- const {id, positions, indices, attributes, stride, weights, absoluteError} = event.data;
19
+ const {id, positions, indices, attributes, stride, weights, absoluteError, protectedComponents, locks} = event.data;
18
20
  try {
19
21
  await MeshoptSimplifier.ready;
20
- const target = Math.max(384, Math.floor(indices.length * 0.08 / 3) * 3);
22
+ // Permit collapses across normal splits, while explicitly protecting UV
23
+ // and other shader-attribute discontinuities. This follows meshoptimizer
24
+ // 1.2's attribute-aware permissive recipe; no position/attribute is edited.
25
+ const positionsRemap = MeshoptSimplifier.generatePositionRemap(positions, 3);
26
+ for (let i = 0; i < positionsRemap.length; i++) {
27
+ const other = positionsRemap[i]!;
28
+ if (other !== i && protectedComponents.some(c => attributes[i * stride + c] !== attributes[other * stride + c])) {
29
+ locks[i]! |= 2; locks[other]! |= 2; // meshopt_SimplifyVertex_Protect
30
+ }
31
+ }
32
+ const target = Math.min(indices.length, Math.max(384, Math.floor(indices.length * 0.08 / 3) * 3));
21
33
  const [reduced, error] = MeshoptSimplifier.simplifyWithAttributes(
22
- indices, positions, 3, attributes, stride, weights, null, target, absoluteError,
23
- // Preserve open boundaries and attribute seams. If that prevents a
24
- // useful reduction, keep the original; never sample/drop triangles.
25
- ['LockBorder', 'ErrorAbsolute'],
34
+ indices, positions, 3, attributes, stride, weights, locks, target, absoluteError,
35
+ // Explicit locks preserve material/chunk borders. The appearance error
36
+ // includes normals; unsuccessful reductions retain complete originals.
37
+ ['ErrorAbsolute', 'Permissive'],
26
38
  );
27
39
  const response: MotionMeshResponse = {
28
40
  id, indices: reduced, error,
@@ -1243,12 +1243,19 @@ export class BlenderRuntimeView {
1243
1243
  presenterChanged();
1244
1244
  }
1245
1245
 
1246
+ private drawPreparation: {
1247
+ last: {opaqueMs: number; motionMs: number; transparentMs: number} | null;
1248
+ navigation: {opaqueMs: number; motionMs: number; transparentMs: number} | null;
1249
+ } = {last: null, navigation: null};
1250
+
1246
1251
  /** Before the renderer uploads attributes/builds its queues, for this area's
1247
1252
  * actual camera. Transparent instance runs are camera-order dependent. */
1248
1253
  prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number}): () => void {
1249
1254
  try {
1255
+ const start = performance.now();
1250
1256
  this.instances.prepareDraw(camera);
1251
1257
  this.root.updateMatrixWorld(true);
1258
+ const opaqueDone = performance.now();
1252
1259
  const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1253
1260
  options?.interactive === true, options?.height ?? 0);
1254
1261
  // A changed navigation geometry is an ordinary-draw fallback for an
@@ -1256,7 +1263,12 @@ export class BlenderRuntimeView {
1256
1263
  // runs are planned AFTER per-placement geometry selection, preserving
1257
1264
  // their sorting barriers and compatible geometry grouping.
1258
1265
  if (motion) this.instances.sync();
1266
+ const motionDone = performance.now();
1259
1267
  this.transparentInstances.prepare(camera);
1268
+ const timing = {opaqueMs: opaqueDone - start, motionMs: motionDone - opaqueDone,
1269
+ transparentMs: performance.now() - motionDone};
1270
+ this.drawPreparation.last = timing;
1271
+ if (motion) this.drawPreparation.navigation = timing;
1260
1272
  } catch (error) {
1261
1273
  this.motionGeometry.finish();
1262
1274
  this.instances.finishDraw();
@@ -2075,7 +2087,7 @@ export class BlenderRuntimeView {
2075
2087
  }
2076
2088
 
2077
2089
  drawStatistics() {
2078
- return {enabled: this.drawBatching, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2090
+ return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2079
2091
  }
2080
2092
 
2081
2093
  snapshot() {
@@ -29,6 +29,7 @@ export class BlenderTransparentInstances {
29
29
  private readonly depth = new THREE.Vector4();
30
30
  private batches = 0;
31
31
  private instances = 0;
32
+ private excluded: Record<string, number> = {};
32
33
  private readonly planState: (number | string)[] = [];
33
34
  private readonly planned: Draw[] = [];
34
35
  private stateIndex = 0;
@@ -65,6 +66,7 @@ export class BlenderTransparentInstances {
65
66
  draw.members.length = 0;
66
67
  }
67
68
  this.batches = this.instances = 0;
69
+ this.excluded = {};
68
70
  this.frustum.setFromProjectionMatrix(this.projection);
69
71
  this.inverse.copy(this.root.matrixWorld).invert();
70
72
  const entries: Entry[] = [];
@@ -118,6 +120,14 @@ export class BlenderTransparentInstances {
118
120
  key = groupKey;
119
121
  }
120
122
  }
123
+ if (key === null) {
124
+ const reason = !candidate ? 'multipleMaterials'
125
+ : (candidate as THREE.ShaderMaterial).isShaderMaterial ? 'customShader'
126
+ : (candidate as THREE.MeshPhysicalMaterial).transmission > 0 ? 'transmission'
127
+ : candidate.side === THREE.DoubleSide && !candidate.forceSinglePass ? 'twoPass'
128
+ : 'geometryLayersOrTransform';
129
+ this.excluded[reason] = (this.excluded[reason] ?? 0) + 1;
130
+ }
121
131
  entries.push({mesh, material, key, groupOrder: order, z, centre});
122
132
  }
123
133
  entries.sort((a, b) => a.groupOrder - b.groupOrder || a.mesh.renderOrder - b.mesh.renderOrder || b.z - a.z || a.mesh.id - b.mesh.id);
@@ -189,7 +199,7 @@ export class BlenderTransparentInstances {
189
199
  }
190
200
  }
191
201
 
192
- inspect() { return {batches: this.batches, instances: this.instances}; }
202
+ inspect() { return {batches: this.batches, instances: this.instances, excluded: {...this.excluded}}; }
193
203
 
194
204
  private observe(value: number | string): void {
195
205
  if (this.planState[this.stateIndex] !== value) {
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.75",
5
+ "version": "0.1.77",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },