@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
|
|
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
|
-
//
|
|
275
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
['
|
|
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) {
|