@volter/blender-engine 0.1.65 → 0.1.67

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.
@@ -148,6 +148,30 @@ export function graphUvChannels(material: THREE.MeshPhysicalMaterial): number[]
148
148
  return Object.values(bindings.get(material)?.channels ?? {});
149
149
  }
150
150
 
151
+ /** Attributes a physical graph draw can read, including its pending program.
152
+ * Resident geometry retains many more authored layers than a shader consumes;
153
+ * only shader inputs count toward WebGL's vertex attribute limit. */
154
+ export function graphDrawAttributes(material: THREE.Material, geometry: THREE.BufferGeometry): Set<string> {
155
+ const names = new Set(['position', 'normal', 'uv']);
156
+ if (geometry.hasAttribute('tangent')) names.add('tangent');
157
+ if (material.vertexColors) names.add('color');
158
+ if (!(material instanceof THREE.MeshPhysicalMaterial)) return names;
159
+ const channels = geometry.userData['blenderUvChannels'] as Record<string, number> | undefined;
160
+ const uv = (name: string) => {
161
+ const channel = name === '' ? 0 : (channels?.[name] ?? 9);
162
+ names.add(channel === 0 ? 'uv' : `uv${channel}`);
163
+ };
164
+ for (const texture of [material.map, material.roughnessMap, material.normalMap])
165
+ if (texture) uv((texture.userData['blenderUvName'] as string | undefined) ?? '');
166
+ for (const binding of [bindings.get(material), pendings.get(material)?.binding]) {
167
+ if (!binding) continue;
168
+ names.add('blenderOrco');
169
+ for (const name of binding.compiled.uvs) uv(name);
170
+ for (const name of binding.compiled.attributes) names.add(graphAttributeName(name));
171
+ }
172
+ return names;
173
+ }
174
+
151
175
  /** The channel each named UV layer is in on `geometry` (a layer the mesh
152
176
  * lacks reads zeros from channel 9, Blender's missing attribute). */
153
177
  function channelsFor(compiled: CompiledGraph, geometry: THREE.BufferGeometry): Record<string, number> {
@@ -322,8 +346,12 @@ ${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
322
346
  '#include <project_vertex>',
323
347
  `vBlenderObjectPosition = transformed;
324
348
  vBlenderObjectNormal = objectNormal;
325
- vBlenderWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
326
- vBlenderWorldNormal = normalize(transpose(inverse(mat3(modelMatrix))) * objectNormal);
349
+ mat4 blenderWorldMatrix = modelMatrix;
350
+ #ifdef USE_INSTANCING
351
+ blenderWorldMatrix = modelMatrix * instanceMatrix;
352
+ #endif
353
+ vBlenderWorldPosition = (blenderWorldMatrix * vec4(transformed, 1.0)).xyz;
354
+ vBlenderWorldNormal = normalize(transpose(inverse(mat3(blenderWorldMatrix))) * objectNormal);
327
355
  vBlenderOrco = blenderOrco;
328
356
  ${compiled.attributes.map(n => `${attributeVarying(n)} = ${graphAttributeName(n)};`).join('\n')}
329
357
  ${compiled.uvs.map(n => `${uvVarying(n)} = ${attribute(channels[n] ?? 0)};`).join('\n')}
@@ -0,0 +1,172 @@
1
+ import * as THREE from 'three';
2
+ import { graphDrawAttributes } from './blender-graph-material';
3
+
4
+ // Small spatial batches retain frustum rejection without duplicating geometry.
5
+ const BATCH_SIZE = 64;
6
+ type Member = { mesh: THREE.Mesh; layers: number };
7
+ type Batch = { draw: THREE.InstancedMesh; members: Member[] };
8
+
9
+ function sameMaterials(a: THREE.Material | THREE.Material[], b: THREE.Material | THREE.Material[]): boolean {
10
+ return Array.isArray(a) && Array.isArray(b)
11
+ ? a.length === b.length && a.every((material, i) => material === b[i])
12
+ : a === b;
13
+ }
14
+
15
+ function shown(object: THREE.Object3D): boolean {
16
+ for (let node: THREE.Object3D | null = object; node; node = node.parent)
17
+ if (!node.visible) return false;
18
+ return true;
19
+ }
20
+
21
+ /** Three's instance normal transform supports positive, orthogonal scale axes.
22
+ * Mirrored, singular and sheared placements keep the ordinary Mesh draw. */
23
+ function supported(matrix: THREE.Matrix4): boolean {
24
+ if (matrix.determinant() <= 0) return false;
25
+ const e = matrix.elements;
26
+ const x = Math.hypot(e[0]!, e[1]!, e[2]!);
27
+ const y = Math.hypot(e[4]!, e[5]!, e[6]!);
28
+ const z = Math.hypot(e[8]!, e[9]!, e[10]!);
29
+ const dot = (a: number, b: number) => e[a]! * e[b]! + e[a + 1]! * e[b + 1]! + e[a + 2]! * e[b + 2]!;
30
+ return Number.isFinite(x + y + z) && Math.min(x, y, z) > 0 &&
31
+ Math.abs(dot(0, 4)) <= 1e-7 * x * y &&
32
+ Math.abs(dot(0, 8)) <= 1e-7 * x * z &&
33
+ Math.abs(dot(4, 8)) <= 1e-7 * y * z;
34
+ }
35
+
36
+ /** Disposable draws only. The original objects, geometry, material slots and
37
+ * parent graph remain the authority for hierarchy, bounds, editing and picking.
38
+ * Layer zero-mask suppresses their ordinary draw; batch raycasts return those
39
+ * same original objects. Selection passes can still temporarily add their mask
40
+ * layer to an original. No vertices or authored data are reduced or replaced. */
41
+ export class BlenderRuntimeInstances {
42
+ private readonly batches: Batch[] = [];
43
+ private readonly inverse = new THREE.Matrix4();
44
+ private readonly matrix = new THREE.Matrix4();
45
+ private readonly box = new THREE.Box3();
46
+ private eligible = 0;
47
+ private excluded: Record<string, number> = {};
48
+
49
+ constructor(private readonly root: THREE.Group) {}
50
+
51
+ rebuild(objects: Iterable<THREE.Object3D>, enabled: boolean): void {
52
+ this.clear();
53
+ if (!enabled) return;
54
+ const groups = new Map<string, THREE.Mesh[]>();
55
+ const exclude = (reason: string) => { this.excluded[reason] = (this.excluded[reason] ?? 0) + 1; };
56
+ this.inverse.copy(this.root.matrixWorld).invert();
57
+ for (const object of objects) {
58
+ const mesh = object as THREE.Mesh;
59
+ if (!mesh.isMesh) continue;
60
+ if ((mesh as THREE.SkinnedMesh).isSkinnedMesh ||
61
+ (mesh as THREE.InstancedMesh).isInstancedMesh || mesh.morphTargetInfluences ||
62
+ mesh.layers.mask !== 1) { exclude('deformationOrLayers'); continue; }
63
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
64
+ if (materials.some(m => m.transparent || (m as THREE.MeshPhysicalMaterial).transmission > 0 ||
65
+ (m as THREE.ShaderMaterial).isShaderMaterial)) { exclude('material'); continue; }
66
+ // Four slots for instanceMatrix, at most twelve for each actual program.
67
+ // Count active inputs, NOT every stored authored layer: retaining unused
68
+ // UVs/colours must not disable batching. No channel is deleted to fit.
69
+ if (materials.some(m => graphDrawAttributes(m, mesh.geometry).size > 12)) { exclude('attributeBudget'); continue; }
70
+ this.matrix.multiplyMatrices(this.inverse, mesh.matrixWorld);
71
+ if (!supported(this.matrix)) { exclude('transform'); continue; }
72
+ this.eligible++;
73
+ const key = `${mesh.geometry.id}:${Array.isArray(mesh.material) ? 'slots' : 'single'}:` +
74
+ materials.map(m => m.uuid).join(',') + `:${mesh.renderOrder}`;
75
+ const group = groups.get(key);
76
+ if (group) group.push(mesh);
77
+ else groups.set(key, [mesh]);
78
+ }
79
+ for (const group of groups.values()) {
80
+ if (group.length < 2) continue;
81
+ // Partition along the widest centre axis, recursively. Adjacent authored
82
+ // names need not be adjacent in space (linked trees span the whole Bridge).
83
+ const partition = (members: THREE.Mesh[]): void => {
84
+ if (members.length > BATCH_SIZE) {
85
+ this.box.makeEmpty();
86
+ for (const mesh of members) this.box.expandByPoint(new THREE.Vector3().setFromMatrixPosition(mesh.matrixWorld));
87
+ const size = this.box.getSize(new THREE.Vector3());
88
+ const axis = size.x >= size.y && size.x >= size.z ? 12 : size.y >= size.z ? 13 : 14;
89
+ members.sort((a, b) => a.matrixWorld.elements[axis]! - b.matrixWorld.elements[axis]!);
90
+ const middle = Math.floor(members.length / 2);
91
+ partition(members.slice(0, middle));
92
+ partition(members.slice(middle));
93
+ return;
94
+ }
95
+ if (members.length < 2) return;
96
+ const first = members[0]!;
97
+ const draw = new THREE.InstancedMesh(first.geometry, first.material, members.length);
98
+ draw.name = 'Blender repeated geometry';
99
+ draw.userData['engineInternal'] = true;
100
+ draw.matrixAutoUpdate = false;
101
+ draw.renderOrder = first.renderOrder;
102
+ draw.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
103
+ const batch: Batch = {draw, members: members.map(mesh => ({mesh, layers: mesh.layers.mask}))};
104
+ // Return the canonical mesh, not the internal batch or an unstable slot.
105
+ draw.raycast = (raycaster, hits) => {
106
+ for (const {mesh} of batch.members)
107
+ if (mesh.layers.mask === 0 && shown(mesh)) mesh.raycast(raycaster, hits);
108
+ };
109
+ this.root.add(draw);
110
+ draw.updateMatrixWorld(true);
111
+ this.batches.push(batch);
112
+ };
113
+ partition(group);
114
+ }
115
+ this.sync();
116
+ }
117
+
118
+ /** Called after the root updates its descendants, before Three builds its
119
+ * render list/uploads attributes. Live gizmo transforms therefore appear on
120
+ * the very next draw, including parent transforms and visibility changes. */
121
+ sync(): void {
122
+ if (!this.batches.length) return;
123
+ this.inverse.copy(this.root.matrixWorld).invert();
124
+ for (const {draw, members} of this.batches) {
125
+ let count = 0;
126
+ let changed = false;
127
+ for (const member of members) {
128
+ const {mesh} = member;
129
+ this.matrix.multiplyMatrices(this.inverse, mesh.matrixWorld);
130
+ // A drag can introduce negative scale/shear, or a skin can replace a
131
+ // mesh between frames. Fall back immediately without touching its data.
132
+ const compatible = mesh.parent !== null && mesh.geometry === draw.geometry &&
133
+ sameMaterials(mesh.material, draw.material) && supported(this.matrix);
134
+ if (!compatible) {
135
+ mesh.layers.mask |= member.layers;
136
+ continue;
137
+ }
138
+ mesh.layers.mask &= ~member.layers;
139
+ if (!shown(mesh)) continue;
140
+ const offset = count * 16;
141
+ if (this.matrix.elements.some((value, i) => Math.fround(value) !== draw.instanceMatrix.array[offset + i])) {
142
+ draw.setMatrixAt(count, this.matrix);
143
+ changed = true;
144
+ }
145
+ count++;
146
+ }
147
+ changed ||= draw.count !== count || draw.boundingSphere === null;
148
+ draw.count = count;
149
+ if (changed) {
150
+ draw.instanceMatrix.needsUpdate = true;
151
+ draw.computeBoundingBox();
152
+ draw.computeBoundingSphere();
153
+ }
154
+ }
155
+ }
156
+
157
+ clear(): void {
158
+ for (const {draw, members} of this.batches) {
159
+ for (const {mesh, layers} of members) mesh.layers.mask |= layers;
160
+ draw.removeFromParent();
161
+ draw.dispose(); // releases instance attributes, never shared geometry/material
162
+ }
163
+ this.batches.length = 0;
164
+ this.eligible = 0;
165
+ this.excluded = {};
166
+ }
167
+
168
+ inspect() {
169
+ return {batches: this.batches.length, instances: this.batches.reduce((count, {draw}) => count + draw.count, 0),
170
+ eligible: this.eligible, excluded: {...this.excluded}};
171
+ }
172
+ }
@@ -42,6 +42,7 @@ import {
42
42
  panCameraView,
43
43
  } from './blender-runtime-camera-view';
44
44
  import { DEFAULT_VIEWPORT_DISPLAY, workbenchMaterial } from './blender-workbench-material';
45
+ import { BlenderRuntimeInstances } from './blender-runtime-instances';
45
46
 
46
47
  /** A saved `View3DShading.type` as the stage's draw mode: Material Preview is `preview`. */
47
48
  const SAVED_SHADING = {
@@ -640,6 +641,7 @@ export interface PhotographRecord {
640
641
 
641
642
  export class BlenderRuntimeView {
642
643
  readonly root = new THREE.Group();
644
+ private readonly instances = new BlenderRuntimeInstances(this.root);
643
645
  private readonly objects = new Map<string, THREE.Object3D>();
644
646
  private readonly meshes = new Map<
645
647
  string,
@@ -1034,11 +1036,17 @@ export class BlenderRuntimeView {
1034
1036
  for (const child of children) next.add(child);
1035
1037
  parent?.add(next);
1036
1038
  this.objects.set(id, next);
1039
+ this.instances.rebuild(this.objects.values(), !this.rendered);
1037
1040
  return;
1038
1041
  }
1039
1042
  }
1040
1043
 
1041
1044
  constructor() {
1045
+ const updateMatrixWorld = this.root.updateMatrixWorld.bind(this.root);
1046
+ this.root.updateMatrixWorld = (force?: boolean) => {
1047
+ updateMatrixWorld(force);
1048
+ this.instances.sync();
1049
+ };
1042
1050
  this.root.name = 'Model';
1043
1051
  // BLENDER IS Z-UP, THREE IS Y-UP: (x, y, z) -> (x, z, -y). Written as the
1044
1052
  // matrix rather than `rotation.x = -Math.PI / 2`, because that Euler is a
@@ -1212,6 +1220,7 @@ export class BlenderRuntimeView {
1212
1220
  material.dispose();
1213
1221
  this.workbenchMaterials.delete(key);
1214
1222
  }
1223
+ this.instances.rebuild(this.objects.values(), !this.rendered);
1215
1224
  }
1216
1225
 
1217
1226
  private workbenchFor(id: string | null, side: THREE.Side, used: Set<string>): THREE.Material {
@@ -2007,6 +2016,7 @@ export class BlenderRuntimeView {
2007
2016
  active: this.frame?.active,
2008
2017
  mode: this.frame?.mode,
2009
2018
  geometryBuilds: this.geometryBuilds,
2019
+ instancing: this.instances.inspect(),
2010
2020
  objects: [...this.objects].map(([id, object]) => ({
2011
2021
  id,
2012
2022
  name: object.name,
@@ -2017,6 +2027,10 @@ export class BlenderRuntimeView {
2017
2027
  };
2018
2028
  }
2019
2029
 
2030
+ drawStatistics() {
2031
+ return this.instances.inspect();
2032
+ }
2033
+
2020
2034
  snapshot() {
2021
2035
  return this.frame;
2022
2036
  }
@@ -2224,6 +2238,7 @@ export class BlenderRuntimeView {
2224
2238
  }
2225
2239
 
2226
2240
  private clear() {
2241
+ this.instances.clear();
2227
2242
  for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
2228
2243
  this.staged = null;
2229
2244
  for (const light of this.lights.values()) {
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.65",
5
+ "version": "0.1.67",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },