@volter/blender-engine 0.1.73 → 0.1.75
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.
|
@@ -4,6 +4,11 @@ import type {MotionMeshRequest, MotionMeshResponse} from './blender-motion-worke
|
|
|
4
4
|
|
|
5
5
|
const CACHE_BYTES = 64 * 1024 * 1024;
|
|
6
6
|
const QUIET_MS = 150;
|
|
7
|
+
// At most 16,384 triangles are copied/simplified in a single worker request.
|
|
8
|
+
const CHUNK_INDICES = 49_152;
|
|
9
|
+
const COPY_ITEMS = 8_192;
|
|
10
|
+
const cancelled = Symbol('navigation geometry replaced');
|
|
11
|
+
type Range = {start: number; count: number; materialIndex?: number | undefined};
|
|
7
12
|
type Entry = {geometry: THREE.BufferGeometry; error: number; bytes: number};
|
|
8
13
|
|
|
9
14
|
/** A disposable navigation drawing, never Blender's evaluated geometry.
|
|
@@ -15,13 +20,16 @@ export class BlenderMotionGeometry {
|
|
|
15
20
|
private readonly swapped: {mesh: THREE.Mesh; geometry: THREE.BufferGeometry}[] = [];
|
|
16
21
|
private queue: THREE.BufferGeometry[] = [];
|
|
17
22
|
private worker: Worker | null = null;
|
|
18
|
-
private pending: {id: number;
|
|
23
|
+
private pending: {id: number; resolve: (response: MotionMeshResponse) => void} | null = null;
|
|
24
|
+
private generation = 0;
|
|
25
|
+
private building = false;
|
|
19
26
|
private nextId = 0;
|
|
20
27
|
private signature = '';
|
|
21
28
|
private readonly identities = new WeakMap<object, number>();
|
|
22
29
|
private nextIdentity = 0;
|
|
23
30
|
private bytes = 0;
|
|
24
31
|
private unavailable: string | null = null;
|
|
32
|
+
private reductions: {vertices: number; fullTriangles: number; reducedTriangles: number; error: number; accepted: boolean}[] = [];
|
|
25
33
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
26
34
|
private readonly cameraWorld = new THREE.Matrix4();
|
|
27
35
|
private readonly cameraProjection = new THREE.Matrix4();
|
|
@@ -33,8 +41,9 @@ export class BlenderMotionGeometry {
|
|
|
33
41
|
private drawnMeshes = 0;
|
|
34
42
|
private fullTriangles = 0;
|
|
35
43
|
private motionTriangles = 0;
|
|
44
|
+
private lastNavigation = {meshes: 0, fullTriangles: 0, motionTriangles: 0, tooClose: 0};
|
|
36
45
|
|
|
37
|
-
constructor(private readonly
|
|
46
|
+
constructor(private readonly invalidate: () => void) {}
|
|
38
47
|
|
|
39
48
|
setObjects(objects: Iterable<THREE.Object3D>): void {
|
|
40
49
|
const groups = new Map<THREE.BufferGeometry, THREE.Mesh[]>();
|
|
@@ -44,13 +53,22 @@ export class BlenderMotionGeometry {
|
|
|
44
53
|
(mesh as THREE.InstancedMesh).isInstancedMesh || mesh.morphTargetInfluences) continue;
|
|
45
54
|
const g = mesh.geometry;
|
|
46
55
|
const position = g.getAttribute('position');
|
|
47
|
-
if (!g.index || g.index.count < 6144 || !position || position.count >
|
|
48
|
-
Object.values(g.attributes).some(a => !(a instanceof THREE.BufferAttribute)
|
|
56
|
+
if (!g.index || g.index.count < 6144 || !position || position.count > 8_000_000 ||
|
|
57
|
+
Object.values(g.attributes).some(a => !(a instanceof THREE.BufferAttribute) ||
|
|
58
|
+
(a as THREE.BufferAttribute & {isFloat16BufferAttribute?: boolean}).isFloat16BufferAttribute ||
|
|
59
|
+
(a as THREE.InstancedBufferAttribute).isInstancedBufferAttribute) ||
|
|
49
60
|
Object.keys(g.morphAttributes).length || g.drawRange.start !== 0 || g.drawRange.count < g.index.count) continue;
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
if (Array.isArray(mesh.material)
|
|
53
|
-
|
|
61
|
+
// Support ordered, disjoint material ranges. Overlaps/gaps or a shared
|
|
62
|
+
// geometry interpreted differently by two objects retain full detail.
|
|
63
|
+
if (Array.isArray(mesh.material)) {
|
|
64
|
+
let end = 0;
|
|
65
|
+
if (!g.groups.length || g.groups.some(range => {
|
|
66
|
+
const valid = range.start === end && range.count > 0 && range.count % 3 === 0 &&
|
|
67
|
+
Number.isInteger(range.materialIndex) && range.materialIndex! >= 0 && range.materialIndex! < (mesh.material as THREE.Material[]).length;
|
|
68
|
+
end = range.start + range.count;
|
|
69
|
+
return !valid;
|
|
70
|
+
}) || end !== g.index.count) continue;
|
|
71
|
+
}
|
|
54
72
|
const held = groups.get(g);
|
|
55
73
|
if (held) held.push(mesh); else groups.set(g, [mesh]);
|
|
56
74
|
}
|
|
@@ -60,7 +78,7 @@ export class BlenderMotionGeometry {
|
|
|
60
78
|
if (id === undefined) { id = ++this.nextIdentity; this.identities.set(object, id); }
|
|
61
79
|
return id;
|
|
62
80
|
};
|
|
63
|
-
const signature = [...groups].map(([g]) => `${g.id}:${identity(g.index!)}:${g.index!.version}:` +
|
|
81
|
+
const signature = [...groups].map(([g, meshes]) => `${g.id}:${identity(g.index!)}:${g.index!.version}:${Array.isArray(meshes[0]!.material)}:${JSON.stringify(g.groups)}:` +
|
|
64
82
|
Object.entries(g.attributes).map(([name, a]) => `${name}:${identity(a)}:${(a as THREE.BufferAttribute).version}`).join(',')).join(';');
|
|
65
83
|
if (signature === this.signature) return;
|
|
66
84
|
this.releaseCopies();
|
|
@@ -75,101 +93,165 @@ export class BlenderMotionGeometry {
|
|
|
75
93
|
this.next();
|
|
76
94
|
}
|
|
77
95
|
|
|
78
|
-
private next(): void {
|
|
79
|
-
if (this.
|
|
96
|
+
private async next(): Promise<void> {
|
|
97
|
+
if (this.building || this.unavailable || this.bytes >= CACHE_BYTES) return;
|
|
80
98
|
const geometry = this.queue.shift();
|
|
81
99
|
if (!geometry) { this.worker?.terminate(); this.worker = null; return; }
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
this.building = true;
|
|
101
|
+
const generation = this.generation;
|
|
102
|
+
const current = () => {
|
|
103
|
+
if (generation !== this.generation) throw cancelled;
|
|
104
|
+
if (this.unavailable) throw new Error(this.unavailable);
|
|
105
|
+
};
|
|
106
|
+
const yieldCopy = async () => { await new Promise<void>(resolve => setTimeout(resolve, 0)); current(); };
|
|
107
|
+
try {
|
|
108
|
+
if (!this.worker) {
|
|
84
109
|
this.worker = new Worker(new URL('./blender-motion-worker.ts', import.meta.url), {type: 'module'});
|
|
85
|
-
this.worker.onmessage = (event: MessageEvent<MotionMeshResponse>) =>
|
|
110
|
+
this.worker.onmessage = (event: MessageEvent<MotionMeshResponse>) => {
|
|
111
|
+
if (this.pending?.id === event.data.id) {
|
|
112
|
+
const pending = this.pending; this.pending = null; pending.resolve(event.data);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
86
115
|
this.worker.onerror = event => {
|
|
87
116
|
this.unavailable = event.message || 'Navigation geometry worker unavailable';
|
|
88
|
-
this.worker?.terminate(); this.worker = null;
|
|
89
|
-
this.
|
|
117
|
+
this.worker?.terminate(); this.worker = null;
|
|
118
|
+
const pending = this.pending; this.pending = null;
|
|
119
|
+
pending?.resolve({id: pending.id, refusal: this.unavailable});
|
|
90
120
|
};
|
|
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
121
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
122
|
+
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
|
|
123
|
+
const meshes = this.groups.get(geometry)!;
|
|
124
|
+
const multi = Array.isArray(meshes[0]!.material);
|
|
125
|
+
if (meshes.some(mesh => Array.isArray(mesh.material) !== multi)) return;
|
|
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));
|
|
128
|
+
const stride = appearance.reduce((n, [, a]) => n + a.itemSize, 0);
|
|
129
|
+
const weights = appearance.flatMap(([name, a]) => Array(a.itemSize).fill(name === 'normal' ? 0.5 : 1));
|
|
130
|
+
// Reuse bounds when present, otherwise derive the error scale in
|
|
131
|
+
// yielding slices. Never scan a large mesh in one UI-thread call.
|
|
132
|
+
const bounds = geometry.boundingBox?.clone() ?? new THREE.Box3();
|
|
133
|
+
if (!geometry.boundingBox) {
|
|
134
|
+
const point = new THREE.Vector3();
|
|
135
|
+
for (let first = 0; first < position.count; first += COPY_ITEMS) {
|
|
136
|
+
await yieldCopy();
|
|
137
|
+
for (let i = first; i < Math.min(first + COPY_ITEMS, position.count); i++)
|
|
138
|
+
bounds.expandByPoint(point.fromBufferAttribute(position, i));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!geometry.boundingSphere) return;
|
|
142
|
+
const absoluteError = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y,
|
|
143
|
+
bounds.max.z - bounds.min.z) * 0.002;
|
|
144
|
+
const parts: Uint32Array[] = [];
|
|
145
|
+
const outputGroups: Range[] = [];
|
|
146
|
+
let count = 0, error = 0;
|
|
147
|
+
for (const range of ranges) {
|
|
148
|
+
const groupStart = count;
|
|
149
|
+
for (let start = range.start; start < range.start + range.count; start += CHUNK_INDICES) {
|
|
150
|
+
await yieldCopy();
|
|
151
|
+
const end = Math.min(start + CHUNK_INDICES, range.start + range.count);
|
|
152
|
+
const originals: number[] = [], remap = new Map<number, number>();
|
|
153
|
+
const indices = new Uint32Array(end - start);
|
|
154
|
+
for (let i = start; i < end; i++) {
|
|
155
|
+
const original = geometry.index!.getX(i);
|
|
156
|
+
let local = remap.get(original);
|
|
157
|
+
if (local === undefined) { local = originals.length; originals.push(original); remap.set(original, local); }
|
|
158
|
+
indices[i - start] = local;
|
|
159
|
+
}
|
|
160
|
+
const positions = new Float32Array(originals.length * 3);
|
|
161
|
+
const values = new Float32Array(originals.length * stride);
|
|
162
|
+
for (let first = 0; first < originals.length; first += COPY_ITEMS) {
|
|
163
|
+
await yieldCopy();
|
|
164
|
+
for (let i = first; i < Math.min(first + COPY_ITEMS, originals.length); i++) {
|
|
165
|
+
const original = originals[i]!;
|
|
166
|
+
for (let c = 0; c < 3; c++) positions[i * 3 + c] = position.getComponent(original, c);
|
|
167
|
+
let offset = 0;
|
|
168
|
+
for (const [, attribute] of appearance) {
|
|
169
|
+
for (let c = 0; c < attribute.itemSize; c++)
|
|
170
|
+
values[i * stride + offset + c] = (attribute as THREE.BufferAttribute).getComponent(original, c);
|
|
171
|
+
offset += attribute.itemSize;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const id = ++this.nextId;
|
|
176
|
+
const response = await new Promise<MotionMeshResponse>(resolve => {
|
|
177
|
+
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]);
|
|
180
|
+
});
|
|
181
|
+
current();
|
|
182
|
+
if (response.refusal) throw new Error(response.refusal);
|
|
183
|
+
if (!response.indices || !Number.isFinite(response.error)) throw new Error('Invalid navigation geometry response');
|
|
184
|
+
const reduced = response.indices;
|
|
185
|
+
for (let i = 0; i < reduced.length; i++) reduced[i] = originals[reduced[i]!]!;
|
|
186
|
+
count += reduced.length;
|
|
187
|
+
if (count * 4 > CACHE_BYTES - this.bytes) return;
|
|
188
|
+
parts.push(reduced);
|
|
189
|
+
error = Math.max(error, response.error!);
|
|
190
|
+
}
|
|
191
|
+
if (multi) outputGroups.push({start: groupStart, count: count - groupStart, materialIndex: range.materialIndex});
|
|
192
|
+
}
|
|
193
|
+
const reduction = {vertices: position.count, fullTriangles: geometry.index!.count / 3,
|
|
194
|
+
reducedTriangles: count / 3, error, accepted: false};
|
|
195
|
+
if (this.reductions.length < 32) this.reductions.push(reduction);
|
|
196
|
+
if (!count || count >= geometry.index!.count * 0.75) return;
|
|
197
|
+
// Compact only after all material ranges succeed. Yield while remapping
|
|
198
|
+
// and copying; cancellation never publishes a partial surface.
|
|
199
|
+
const remap = new Int32Array(position.count).fill(-1);
|
|
134
200
|
const originals: number[] = [];
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let
|
|
139
|
-
|
|
140
|
-
|
|
201
|
+
const indices = new Uint32Array(count);
|
|
202
|
+
let offset = 0;
|
|
203
|
+
for (const part of parts) {
|
|
204
|
+
for (let first = 0; first < part.length; first += COPY_ITEMS) {
|
|
205
|
+
await yieldCopy();
|
|
206
|
+
for (let i = first; i < Math.min(first + COPY_ITEMS, part.length); i++) {
|
|
207
|
+
const original = part[i]!;
|
|
208
|
+
let local = remap[original]!;
|
|
209
|
+
if (local < 0) { local = originals.length; originals.push(original); remap[original] = local; }
|
|
210
|
+
indices[offset + i] = local;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
offset += part.length;
|
|
141
214
|
}
|
|
142
215
|
const bytes = indices.byteLength + Object.values(geometry.attributes).reduce((n, a) =>
|
|
143
216
|
n + originals.length * a.itemSize * a.array.BYTES_PER_ELEMENT, 0);
|
|
144
|
-
if (this.bytes + bytes
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
217
|
+
if (this.bytes + bytes > CACHE_BYTES) return;
|
|
218
|
+
const copy = new THREE.BufferGeometry();
|
|
219
|
+
copy.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
220
|
+
for (const [name, source] of Object.entries(geometry.attributes)) {
|
|
221
|
+
const attribute = source as THREE.BufferAttribute;
|
|
222
|
+
const ArrayType = attribute.array.constructor as {new(length: number): THREE.TypedArray};
|
|
223
|
+
const data = new ArrayType(originals.length * attribute.itemSize);
|
|
224
|
+
for (let first = 0; first < originals.length; first += COPY_ITEMS) {
|
|
225
|
+
await yieldCopy();
|
|
226
|
+
for (let i = first; i < Math.min(first + COPY_ITEMS, originals.length); i++)
|
|
227
|
+
for (let c = 0; c < attribute.itemSize; c++)
|
|
228
|
+
data[i * attribute.itemSize + c] = attribute.array[originals[i]! * attribute.itemSize + c]!;
|
|
154
229
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
230
|
+
copy.setAttribute(name, new THREE.BufferAttribute(data, attribute.itemSize, attribute.normalized));
|
|
231
|
+
}
|
|
232
|
+
copy.boundingSphere = geometry.boundingSphere.clone();
|
|
233
|
+
copy.boundingBox = bounds.clone();
|
|
234
|
+
for (const group of outputGroups) copy.addGroup(group.start, group.count, group.materialIndex);
|
|
235
|
+
copy.userData = {...geometry.userData};
|
|
236
|
+
reduction.accepted = true;
|
|
237
|
+
this.cache.set(geometry, {geometry: copy, error, bytes});
|
|
238
|
+
this.bytes += bytes;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error === cancelled) return;
|
|
241
|
+
this.unavailable = String(error);
|
|
242
|
+
this.worker?.terminate(); this.worker = null; this.pending = null; this.queue.length = 0;
|
|
243
|
+
} finally {
|
|
244
|
+
if (generation === this.generation) {
|
|
245
|
+
this.building = false;
|
|
246
|
+
this.timer = setTimeout(() => { this.timer = null; void this.next(); }, 0);
|
|
163
247
|
}
|
|
164
248
|
}
|
|
165
|
-
// Yield between meshes, including copying their attributes for the worker.
|
|
166
|
-
this.timer = setTimeout(() => { this.timer = null; this.next(); }, 0);
|
|
167
249
|
}
|
|
168
250
|
|
|
169
|
-
prepare(camera: THREE.Camera, interactive: boolean, height: number):
|
|
251
|
+
prepare(camera: THREE.Camera, interactive: boolean, height: number): boolean {
|
|
170
252
|
this.finish();
|
|
171
253
|
this.drawnMeshes = this.fullTriangles = this.motionTriangles = 0;
|
|
172
|
-
if (!interactive || !(height > 0)) return;
|
|
254
|
+
if (!interactive || !(height > 0)) return false;
|
|
173
255
|
const now = performance.now();
|
|
174
256
|
const moved = this.cameraKnown && (!camera.matrixWorld.equals(this.cameraWorld) ||
|
|
175
257
|
!camera.projectionMatrix.equals(this.cameraProjection));
|
|
@@ -182,44 +264,37 @@ export class BlenderMotionGeometry {
|
|
|
182
264
|
if (this.quietTimer) clearTimeout(this.quietTimer);
|
|
183
265
|
this.quietTimer = setTimeout(() => { this.quietTimer = null; this.invalidate(); }, QUIET_MS + 1);
|
|
184
266
|
}
|
|
185
|
-
if (now - this.movedAt > QUIET_MS) return;
|
|
267
|
+
if (now - this.movedAt > QUIET_MS) return false;
|
|
186
268
|
this.projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
187
269
|
this.frustum.setFromProjectionMatrix(this.projection);
|
|
188
|
-
|
|
270
|
+
let tooClose = 0;
|
|
189
271
|
for (const [source, entry] of this.cache) {
|
|
190
|
-
let fits = true;
|
|
191
272
|
for (const mesh of this.groups.get(source) ?? []) {
|
|
192
273
|
if (!instanceObjectShown(mesh) || (mesh.frustumCulled && !this.frustum.intersectsObject(mesh))) continue;
|
|
193
|
-
|
|
274
|
+
// Frobenius norm bounds the linear transform even under shear; the
|
|
275
|
+
// longest axis alone can understate the projected simplifier error.
|
|
276
|
+
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]!);
|
|
194
278
|
const sphere = source.boundingSphere!;
|
|
195
279
|
this.centre.copy(sphere.center).applyMatrix4(mesh.matrixWorld).applyMatrix4(camera.matrixWorldInverse);
|
|
196
280
|
const depth = (camera as THREE.PerspectiveCamera).isPerspectiveCamera
|
|
197
281
|
? -this.centre.z - sphere.radius * scale : 1;
|
|
198
282
|
const pixels = entry.error * scale * Math.abs(camera.projectionMatrix.elements[5]!) * height / (2 * depth);
|
|
199
|
-
if (!(depth > 0) || pixels > 1) {
|
|
283
|
+
if (!(depth > 0) || pixels > 1) { tooClose++; continue; }
|
|
284
|
+
// Decide per placement: one near-camera blade must not veto thousands
|
|
285
|
+
// of distant placements of its shared geometry. The following draw
|
|
286
|
+
// planner sees these geometry identities, so transparency still batches
|
|
287
|
+
// only consecutive compatible surfaces in the original camera order.
|
|
288
|
+
this.swapped.push({mesh, geometry: source});
|
|
289
|
+
mesh.geometry = entry.geometry;
|
|
290
|
+
this.drawnMeshes++;
|
|
291
|
+
this.fullTriangles += source.index!.count / 3;
|
|
292
|
+
this.motionTriangles += entry.geometry.index!.count / 3;
|
|
200
293
|
}
|
|
201
|
-
if (fits) allowed.set(source, entry.geometry);
|
|
202
294
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.
|
|
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
|
-
});
|
|
295
|
+
this.lastNavigation = {meshes: this.drawnMeshes, fullTriangles: this.fullTriangles,
|
|
296
|
+
motionTriangles: this.motionTriangles, tooClose};
|
|
297
|
+
return this.swapped.length > 0;
|
|
223
298
|
}
|
|
224
299
|
|
|
225
300
|
private quietTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -228,16 +303,19 @@ export class BlenderMotionGeometry {
|
|
|
228
303
|
this.swapped.length = 0;
|
|
229
304
|
}
|
|
230
305
|
inspect() {
|
|
231
|
-
return {copies: this.cache.size, bytes: this.bytes, queued: this.queue.length, pending: this.
|
|
232
|
-
unavailable: this.unavailable,
|
|
306
|
+
return {copies: this.cache.size, bytes: this.bytes, queued: this.queue.length, pending: this.building,
|
|
307
|
+
unavailable: this.unavailable, reductions: this.reductions.map(r => ({...r})), lastNavigation: {...this.lastNavigation}};
|
|
233
308
|
}
|
|
234
309
|
private releaseCopies(): void {
|
|
235
310
|
this.finish();
|
|
236
311
|
if (this.timer) clearTimeout(this.timer);
|
|
237
312
|
this.timer = null;
|
|
238
|
-
this.
|
|
313
|
+
this.generation++;
|
|
314
|
+
this.worker?.terminate(); this.worker = null;
|
|
315
|
+
this.pending?.resolve({id: this.pending.id, refusal: 'Navigation geometry replaced'});
|
|
316
|
+
this.pending = null; this.building = false; this.queue = [];
|
|
239
317
|
for (const entry of this.cache.values()) entry.geometry.dispose();
|
|
240
|
-
this.cache.clear(); this.bytes = 0;
|
|
318
|
+
this.cache.clear(); this.bytes = 0; this.reductions = [];
|
|
241
319
|
}
|
|
242
320
|
clear(): void {
|
|
243
321
|
this.releaseCopies();
|
|
@@ -7,24 +7,25 @@ export interface MotionMeshRequest {
|
|
|
7
7
|
attributes: Float32Array;
|
|
8
8
|
stride: number;
|
|
9
9
|
weights: number[];
|
|
10
|
+
absoluteError: number;
|
|
10
11
|
}
|
|
11
12
|
export interface MotionMeshResponse {id: number; indices?: Uint32Array; error?: number; refusal?: string}
|
|
12
13
|
|
|
13
14
|
// One copied mesh at a time. The worker never owns or detaches a resident
|
|
14
15
|
// Blender column, and never writes positions or attributes back to the model.
|
|
15
16
|
self.onmessage = async (event: MessageEvent<MotionMeshRequest>) => {
|
|
16
|
-
const {id, positions, indices, attributes, stride, weights} = event.data;
|
|
17
|
+
const {id, positions, indices, attributes, stride, weights, absoluteError} = event.data;
|
|
17
18
|
try {
|
|
18
19
|
await MeshoptSimplifier.ready;
|
|
19
20
|
const target = Math.max(384, Math.floor(indices.length * 0.08 / 3) * 3);
|
|
20
21
|
const [reduced, error] = MeshoptSimplifier.simplifyWithAttributes(
|
|
21
|
-
indices, positions, 3, attributes, stride, weights, null, target,
|
|
22
|
+
indices, positions, 3, attributes, stride, weights, null, target, absoluteError,
|
|
22
23
|
// Preserve open boundaries and attribute seams. If that prevents a
|
|
23
24
|
// useful reduction, keep the original; never sample/drop triangles.
|
|
24
|
-
['LockBorder'],
|
|
25
|
+
['LockBorder', 'ErrorAbsolute'],
|
|
25
26
|
);
|
|
26
27
|
const response: MotionMeshResponse = {
|
|
27
|
-
id, indices: reduced, error
|
|
28
|
+
id, indices: reduced, error,
|
|
28
29
|
};
|
|
29
30
|
self.postMessage(response, {transfer: [reduced.buffer]});
|
|
30
31
|
} catch (error) {
|
|
@@ -645,7 +645,7 @@ export class BlenderRuntimeView {
|
|
|
645
645
|
readonly root = new THREE.Group();
|
|
646
646
|
private readonly instances = new BlenderRuntimeInstances(this.root);
|
|
647
647
|
private readonly transparentInstances = new BlenderTransparentInstances(this.root);
|
|
648
|
-
private readonly motionGeometry = new BlenderMotionGeometry(
|
|
648
|
+
private readonly motionGeometry = new BlenderMotionGeometry(presenterChanged);
|
|
649
649
|
private drawBatching = true;
|
|
650
650
|
private readonly objects = new Map<string, THREE.Object3D>();
|
|
651
651
|
private readonly meshes = new Map<
|
|
@@ -1249,8 +1249,14 @@ export class BlenderRuntimeView {
|
|
|
1249
1249
|
try {
|
|
1250
1250
|
this.instances.prepareDraw(camera);
|
|
1251
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();
|
|
1252
1259
|
this.transparentInstances.prepare(camera);
|
|
1253
|
-
this.motionGeometry.prepare(camera, !this.rendered && options?.interactive === true, options?.height ?? 0);
|
|
1254
1260
|
} catch (error) {
|
|
1255
1261
|
this.motionGeometry.finish();
|
|
1256
1262
|
this.instances.finishDraw();
|