@volter/blender-engine 0.1.94 → 0.1.96

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.
@@ -806,6 +806,8 @@ def _saved_view():
806
806
  "distance": float(region.view_distance),
807
807
  "perspective": region.view_perspective,
808
808
  "shading": space.shading.type,
809
+ "scene_world": bool(space.shading.use_scene_world),
810
+ "scene_lights": bool(space.shading.use_scene_lights),
809
811
  }
810
812
  return None
811
813
 
@@ -4901,13 +4903,12 @@ def rna_outliner(selected=None):
4901
4903
  # `add_layer_collections_recursive`: an EXCLUDED collection's objects
4902
4904
  # are not added at all -- it is not in the view layer.
4903
4905
  if not lc.exclude:
4904
- page, more = _outliner_page(collection.objects)
4905
- for obj in page:
4906
+ # Fold parenting before paging: a parent may follow its children in
4907
+ # collection order, and must not disappear behind the flat cap.
4908
+ for obj in collection.objects:
4906
4909
  object_row = _outliner_object(obj, view_layer, seen, chosen, active)
4907
4910
  place(obj, object_row, row)
4908
4911
  children.append(object_row)
4909
- if more:
4910
- row["more"] = more
4911
4912
  row["children"] = children
4912
4913
  return row
4913
4914
 
@@ -4920,13 +4921,10 @@ def rna_outliner(selected=None):
4920
4921
  _outliner_key("%s.layer_collection.children" % view_layer_path,
4921
4922
  child.collection.name))
4922
4923
  for child in root.children]
4923
- page, more = _outliner_page(root.collection.objects)
4924
- for obj in page:
4924
+ for obj in root.collection.objects:
4925
4925
  object_row = _outliner_object(obj, view_layer, seen, chosen, active)
4926
4926
  place(obj, object_row, scene_collection)
4927
4927
  children.append(object_row)
4928
- if more:
4929
- scene_collection["more"] = more
4930
4928
  scene_collection["children"] = children
4931
4929
 
4932
4930
  # `ObjectsChildrenBuilder::make_object_parent_hierarchy_collections`, parents
@@ -4974,6 +4972,18 @@ def rna_outliner(selected=None):
4974
4972
  parent_row["children"].append(duplicate)
4975
4973
  mine.append([duplicate, parent_row, parent_collection])
4976
4974
 
4975
+ # Cap the hierarchy's visible child lists, not the collection's flat input.
4976
+ # Every parent participates in folding even when its child list needs a page.
4977
+ def page_tree(row):
4978
+ children, more = _outliner_page(row.get("children", []))
4979
+ row["children"] = children
4980
+ if more:
4981
+ row["more"] = more
4982
+ for child in children:
4983
+ page_tree(child)
4984
+
4985
+ page_tree(scene_collection)
4986
+
4977
4987
  return {
4978
4988
  "scene": scene_path,
4979
4989
  "viewLayer": view_layer_path,
@@ -24,6 +24,7 @@ interface Binding {
24
24
  ramps: THREE.DataTexture[];
25
25
  /** Named UV layer to the channel the current draw's geometry holds it in. */
26
26
  channels: Record<string, number>;
27
+ palette?: {indices: Int32Array; sources: readonly THREE.MeshPhysicalMaterial[]};
27
28
  }
28
29
  const bindings = new WeakMap<THREE.MeshPhysicalMaterial, Binding>();
29
30
  /** Graph structures whose program the GPU refused. A material whose graph is
@@ -35,6 +36,59 @@ export function materialGraph(material: THREE.MeshPhysicalMaterial): CompiledGra
35
36
  return bindings.get(material)?.compiled ?? null;
36
37
  }
37
38
 
39
+ /** Only identical graph programs can share an ordered draw. The originals
40
+ * keep ownership of textures and ramp tables; their live uniform objects are
41
+ * borrowed, so value edits do not turn into baked shader constants. */
42
+ function compatiblePaletteGraphs(sources: readonly THREE.MeshPhysicalMaterial[]): CompiledGraph | null {
43
+ if (sources.length !== 2 || sources.some(source => pendings.has(source))) return null;
44
+ const graphs = sources.map(source => bindings.get(source)?.compiled);
45
+ const graph = graphs[0];
46
+ if (!graph || failed.has(graph.key) || graphs.some(other => !other || other.key !== graph.key ||
47
+ other.declarations !== graph.declarations || other.library !== graph.library ||
48
+ JSON.stringify([other.uvs, other.attributes, other.outputs]) !== JSON.stringify([graph.uvs, graph.attributes, graph.outputs]))) return null;
49
+ // Leave room for the physical material's environment and lighting samplers.
50
+ if (sources.reduce((n, source) => n + graph.images.reduce((n, image) => n + (image.tiled ? 2 : 1), 0) +
51
+ graph.ramps.length + [source.map, source.normalMap, source.roughnessMap].filter(Boolean).length, 0) > 8) return null;
52
+ return graph;
53
+ }
54
+
55
+ export function paletteGraphReady(source: THREE.MeshPhysicalMaterial): boolean {
56
+ const graph = bindings.get(source)?.compiled;
57
+ return !!graph && !pendings.has(source) && !failed.has(graph.key);
58
+ }
59
+
60
+ export function paletteGraphs(sources: readonly THREE.MeshPhysicalMaterial[]): CompiledGraph | null {
61
+ const graph = compatiblePaletteGraphs(sources);
62
+ if (!graph) return null;
63
+ const rename = (text: string, index: number) => text.replace(/\b(bg\w+|blenderGraph)\b/g, name => `bp${index}_${name}`);
64
+ const outputs = Object.values(graph.outputs).map(output => `${output.type} ${output.global};`);
65
+ if (graph.closure) outputs.push('float bgClosureP;', 'vec3 bgClosureE;', 'float bgClosureT;');
66
+ const names = [...Object.values(graph.outputs).map(output => output.global), ...(graph.closure ? ['bgClosureP', 'bgClosureE', 'bgClosureT'] : [])];
67
+ const program = [rename(graph.program, 0), rename(graph.program, 1), ...outputs,
68
+ // Evaluate both graphs before selecting. Derivatives and implicit texture
69
+ // LOD must never occur inside a potentially divergent fragment branch.
70
+ `void blenderGraph() { bp0_blenderGraph(); bp1_blenderGraph(); ${names.map(name =>
71
+ `${name} = vBlenderPaletteIndex == 0 ? bp0_${name} : bp1_${name};`).join(' ')} }`].join('\n');
72
+ return {...graph, key: `blender-palette-v1:${graph.key}`, program,
73
+ declarations: `${graph.library}\n${program}`, images: [], ramps: [], uniforms: new Map()};
74
+ }
75
+
76
+ export function bindPaletteGraph(material: THREE.MeshPhysicalMaterial, compiled: CompiledGraph,
77
+ sources: readonly THREE.MeshPhysicalMaterial[], indices: Int32Array, geometry: THREE.BufferGeometry): boolean {
78
+ if (!compatiblePaletteGraphs(sources)) return false;
79
+ const values: Record<string, THREE.IUniform> = {blenderPaletteIndices: {value: indices}};
80
+ for (let index = 0; index < sources.length; index++) {
81
+ const source = sources[index]!;
82
+ for (const [name, uniform] of Object.entries(bindings.get(source)!.uniforms)) values[`bp${index}_${name}`] = uniform;
83
+ for (const name of ['map', 'normalMap', 'roughnessMap'] as const) {
84
+ if (source[name]) values[`bp${index}_${name}`] = {value: source[name]};
85
+ }
86
+ }
87
+ bindings.set(material, {compiled, uniforms: values, ramps: [], channels: channelsFor(compiled, geometry), palette: {indices, sources}});
88
+ material.needsUpdate = true;
89
+ return true;
90
+ }
91
+
38
92
  type ImageTexture = THREE.Texture | {tiles: THREE.Texture; map: THREE.Texture} | null;
39
93
 
40
94
  /**
@@ -395,6 +449,22 @@ ${compiled.attributes.map(n => `${attributeVarying(n)} = ${graphAttributeName(n)
395
449
  ${compiled.uvs.map(n => `${uvVarying(n)} = ${attribute(channels[n] ?? 0)};`).join('\n')}
396
450
  #include <project_vertex>`,
397
451
  );
452
+ if (binding.palette) {
453
+ shader.vertexShader = `uniform int blenderPaletteIndices[64];\nflat varying int vBlenderPaletteIndex;\n${shader.vertexShader}`
454
+ .replace('#include <project_vertex>', 'vBlenderPaletteIndex = blenderPaletteIndices[int(getIndirectIndex(gl_DrawID))];\n#include <project_vertex>');
455
+ shader.fragmentShader = `flat varying int vBlenderPaletteIndex;\n${shader.fragmentShader}`;
456
+ for (const [name, uv, clip] of [['map', 'vMapUv', 'blenderMapClip'], ['normalMap', 'vNormalMapUv', 'blenderNormalClip'],
457
+ ['roughnessMap', 'vRoughnessMapUv', 'blenderRoughnessClip']] as const) {
458
+ if (!material[name]) continue;
459
+ shader.fragmentShader = `uniform sampler2D bp0_${name}, bp1_${name};\n${shader.fragmentShader}`
460
+ .replaceAll(`blenderImageSample(${name}, ${uv}, ${clip})`, `blenderPalette_${name}(${uv}, ${clip})`)
461
+ .replace('void main() {', `vec4 blenderPalette_${name}(vec2 uv, bool clipImage) {
462
+ vec4 first = blenderImageSample(bp0_${name}, uv, clipImage);
463
+ vec4 second = blenderImageSample(bp1_${name}, uv, clipImage);
464
+ return vBlenderPaletteIndex == 0 ? first : second;
465
+ }\nvoid main() {`);
466
+ }
467
+ }
398
468
  const out = (name: string) => compiled.outputs[name]?.global;
399
469
  // [chunk, line, whether the line goes BEFORE the chunk]
400
470
  const fragment: [string, string, boolean][] = [];
@@ -0,0 +1,65 @@
1
+ import * as THREE from 'three';
2
+ import {materialGraph, paletteGraphReady, paletteGraphs} from './blender-graph-material';
3
+ import {physicalPaletteState, samePhysicalPaletteState} from './blender-physical-material';
4
+ import type {OrderedPalette} from './blender-ordered-batches';
5
+
6
+ type Snapshot = {source: THREE.MeshPhysicalMaterial; state: NonNullable<ReturnType<typeof physicalPaletteState>>;
7
+ compatible: NonNullable<ReturnType<typeof physicalPaletteState>>};
8
+
9
+ /** Pair only complete, equal shader/render states. Each frame observes shared
10
+ * sources once; member meshes keep their authored materials. Any change drops
11
+ * the disposable packed families before their old identities are released. */
12
+ export class BlenderMaterialPalettes {
13
+ private snapshots: Snapshot[] = [];
14
+ private readonly palettes = new Map<THREE.Material, OrderedPalette>();
15
+ private readonly owned: OrderedPalette[] = [];
16
+
17
+ prepare(materials: Iterable<THREE.Material>, releaseDraws: () => void): boolean {
18
+ const snapshots: Snapshot[] = [];
19
+ for (const source of new Set(materials)) {
20
+ if (!(source instanceof THREE.MeshPhysicalMaterial) || !source.visible || !source.transparent ||
21
+ source.transmission > 0 || (source.side === THREE.DoubleSide && !source.forceSinglePass) ||
22
+ !paletteGraphReady(source)) continue;
23
+ const state = physicalPaletteState(source, true);
24
+ const compatible = physicalPaletteState(source);
25
+ const graph = materialGraph(source);
26
+ if (!state || !compatible || !graph) continue;
27
+ state.push(graph.key, graph.declarations);
28
+ snapshots.push({source, state, compatible});
29
+ }
30
+ snapshots.sort((a, b) => a.source.uuid.localeCompare(b.source.uuid));
31
+ if (snapshots.length === this.snapshots.length && snapshots.every((snapshot, index) =>
32
+ snapshot.source === this.snapshots[index]!.source && samePhysicalPaletteState(snapshot.state, this.snapshots[index]!.state))) return false;
33
+ releaseDraws();
34
+ this.clear();
35
+ this.snapshots = snapshots;
36
+ const paired = new Set<THREE.Material>();
37
+ for (let first = 0; first < snapshots.length && this.owned.length < 16; first++) {
38
+ const a = snapshots[first]!;
39
+ if (paired.has(a.source)) continue;
40
+ for (let second = first + 1; second < snapshots.length; second++) {
41
+ const b = snapshots[second]!;
42
+ if (paired.has(b.source) || !samePhysicalPaletteState(a.compatible, b.compatible)) continue;
43
+ const sources = [a.source, b.source];
44
+ const compiled = paletteGraphs(sources);
45
+ if (!compiled) continue;
46
+ const palette: OrderedPalette = {identity: new THREE.Material(), sources, compiled};
47
+ this.owned.push(palette);
48
+ for (const source of sources) { paired.add(source); this.palettes.set(source, palette); }
49
+ break;
50
+ }
51
+ }
52
+ return true;
53
+ }
54
+
55
+ get(source: THREE.Material): OrderedPalette | undefined { return this.palettes.get(source); }
56
+
57
+ clear(): void {
58
+ for (const palette of this.owned) palette.identity.dispose();
59
+ this.owned.length = 0;
60
+ this.palettes.clear();
61
+ this.snapshots = [];
62
+ }
63
+
64
+ inspect() { return {pairs: this.owned.length, limit: 16}; }
65
+ }
@@ -129,6 +129,11 @@ export interface CompiledGraph {
129
129
  /** Declarations: the prelude, the library files in dependency order, the
130
130
  * uniforms, the graph function. Goes before `main`. */
131
131
  readonly declarations: string;
132
+ /** Shared library and graph-local declarations, kept separately so an
133
+ * ordered material palette can namespace graph inputs without copying the
134
+ * coordinate/library globals. Their join is exactly `declarations`. */
135
+ readonly library: string;
136
+ readonly program: string;
132
137
  /** Uniform name to value; refreshed on every frame without recompiling. */
133
138
  readonly uniforms: ReadonlyMap<string, number | readonly number[]>;
134
139
  readonly images: readonly GraphImage[];
@@ -935,10 +940,12 @@ class Compiler {
935
940
  };
936
941
  for (const file of this.s.files) visit(file);
937
942
  const graphBody = `void blenderGraph() {\n blender_init_globals();\n ${[...this.lines, ...assignments].join('\n ')}\n}`;
938
- const declarations = [
943
+ const library = [
939
944
  BLENDER_NODE_GLSL_PRELUDE,
940
945
  shakeLibrary(EEVEE_GLOBALS + ordered.map(f => BLENDER_NODE_GLSL[f]!.code).join('\n') + '\n' + COORDINATES,
941
946
  [...this.s.functions, graphBody].join('\n')),
947
+ ].join('\n');
948
+ const program = [
942
949
  ...[...this.s.uniformTypes].map(([n, t]) => `uniform ${t} ${n};`),
943
950
  ...this.images.map(i => i.tiled
944
951
  ? `uniform highp sampler2DArray ${i.uniform};\nuniform highp sampler2D ${i.uniform}Map;`
@@ -951,7 +958,9 @@ class Compiler {
951
958
  ].join('\n');
952
959
  return {
953
960
  key: JSON.stringify([this.graph.surface, this.s.structure, [...this.s.uvs].sort()]),
954
- declarations,
961
+ declarations: `${library}\n${program}`,
962
+ library,
963
+ program,
955
964
  uniforms: this.uniforms,
956
965
  images: this.images,
957
966
  ramps: this.ramps,
@@ -1,12 +1,19 @@
1
1
  import * as THREE from 'three';
2
+ import type {CompiledGraph} from './blender-node-graph';
3
+ import {physicalPaletteMaterial} from './blender-physical-material';
4
+ import {presenterChanged} from './blender-presenter-change';
2
5
 
3
6
  export const ORDERED_BATCH_CAPACITY = 64;
4
7
  const FAMILY_BYTES = 4 * 1024 * 1024;
5
8
  const CACHE_BYTES = 32 * 1024 * 1024;
6
9
  const MAX_DRAWS = 1024;
7
10
  type Shape = {geometry: THREE.BufferGeometry; stamp: string; bytes: number};
11
+ export type OrderedPalette = {identity: THREE.Material; sources: readonly THREE.MeshPhysicalMaterial[]; compiled: CompiledGraph};
12
+ type Compilation = {material: THREE.MeshPhysicalMaterial; ready: boolean; refused: boolean; pending: boolean; program: unknown};
8
13
  type Family = {material: THREE.Material; shapes: Map<number, Shape>; template: THREE.BatchedMesh | null;
9
- ids: Map<number, number>; draws: THREE.BatchedMesh[]; dirty: boolean; bytes: number; reservedBytes: number; refused: boolean};
14
+ ids: Map<number, number>; draws: THREE.BatchedMesh[]; dirty: boolean; bytes: number; reservedBytes: number; refused: boolean;
15
+ palette: OrderedPalette | undefined; compilations: Map<THREE.WebGLRenderer, Compilation>;
16
+ drawMaterials: Map<THREE.BatchedMesh, {material: THREE.MeshPhysicalMaterial; indices: Int32Array}>};
10
17
 
11
18
  /** Immutable packed attributes are shared between ordered draw runs. Only the
12
19
  * matrix/indirection textures and command lists belong to an individual run.
@@ -18,11 +25,14 @@ export class BlenderOrderedBatches {
18
25
  private bytes = 0;
19
26
  private drawCount = 0;
20
27
  private created = 0;
28
+ private compiling = 0;
21
29
  pending = false;
30
+ generation = 0;
22
31
 
23
32
  begin(): void { this.created = 0; this.pending = false; this.registrations.clear(); }
33
+ beginPaletteFrame(): void { this.compiling = 0; }
24
34
 
25
- register(geometry: THREE.BufferGeometry, material: THREE.Material, order: number): string | null {
35
+ register(geometry: THREE.BufferGeometry, material: THREE.Material, order: number, palette?: OrderedPalette): string | null {
26
36
  const registration = `${geometry.id}:${material.uuid}:${order}`;
27
37
  if (this.registrations.has(registration)) return this.registrations.get(registration)!;
28
38
  this.registrations.set(registration, null);
@@ -56,7 +66,7 @@ export class BlenderOrderedBatches {
56
66
  let family = this.families.get(key);
57
67
  if (!family) {
58
68
  family = {material, shapes: new Map(), template: null, ids: new Map(), draws: [], dirty: true,
59
- bytes: 0, reservedBytes: 0, refused: false};
69
+ bytes: 0, reservedBytes: 0, refused: false, palette, compilations: new Map(), drawMaterials: new Map()};
60
70
  this.families.set(key, family);
61
71
  }
62
72
  const stamp = JSON.stringify(versions);
@@ -105,12 +115,67 @@ export class BlenderOrderedBatches {
105
115
  }
106
116
  }
107
117
 
108
- available(key: string): boolean { return !!this.families.get(key)?.template; }
118
+ /** Compile before replacing a single canonical draw, with this renderer's
119
+ * context and actual scene lighting. Each frame starts at most two programs.
120
+ * The proxy borrows packed geometry and owns only its matrix textures. */
121
+ compilePalettes(renderer: THREE.WebGLRenderer, camera: THREE.Camera, scene: THREE.Scene): void {
122
+ if (renderer.getContext().isContextLost()) return;
123
+ for (const family of this.families.values()) {
124
+ const {template, palette} = family;
125
+ if (!template || !palette) continue;
126
+ let compilation = family.compilations.get(renderer);
127
+ if (compilation?.ready && (renderer.properties.get(compilation.material) as {currentProgram?: unknown}).currentProgram !== compilation.program) {
128
+ compilation.ready = false; // Context restoration clears renderer properties.
129
+ this.generation++;
130
+ }
131
+ if (compilation?.ready || compilation?.pending || compilation?.refused) continue;
132
+ if (this.compiling >= 2) { this.pending = true; continue; }
133
+ if (!compilation) {
134
+ const material = physicalPaletteMaterial(palette.compiled, palette.sources, new Int32Array(ORDERED_BATCH_CAPACITY), template.geometry);
135
+ if (!material) continue;
136
+ compilation = {material, ready: false, refused: false, pending: false, program: null};
137
+ family.compilations.set(renderer, compilation);
138
+ }
139
+ const held = compilation;
140
+ held.pending = true;
141
+ held.material.needsUpdate = true;
142
+ const proxy = new THREE.BatchedMesh(1, 0, 0, held.material);
143
+ proxy.geometry.dispose();
144
+ proxy.geometry = template.geometry;
145
+ Object.defineProperty(proxy, 'colorTexture', {value: null});
146
+ this.compiling++;
147
+ const settle = (resolved: boolean) => {
148
+ proxy.geometry = new THREE.BufferGeometry();
149
+ proxy.dispose();
150
+ if (family.template !== template || family.compilations.get(renderer) !== held) {
151
+ held.material.dispose(); return;
152
+ }
153
+ held.pending = false;
154
+ // compileAsync signals completion, including a refused program. Check
155
+ // the actual link result; never let a failed palette hide originals.
156
+ const program = (renderer.properties.get(held.material) as {currentProgram?: {program?: WebGLProgram}}).currentProgram;
157
+ const gl = renderer.getContext();
158
+ held.ready = resolved && !gl.isContextLost() && !!program?.program && gl.getProgramParameter(program.program, gl.LINK_STATUS) === true;
159
+ held.program = program;
160
+ held.refused = !held.ready && !gl.isContextLost();
161
+ this.generation++;
162
+ if (held.refused) console.warn('Blender ordered material palette did not link; canonical draws retained.');
163
+ presenterChanged();
164
+ };
165
+ try { renderer.compileAsync(proxy, camera, scene).then(() => settle(true), () => settle(false)); }
166
+ catch { settle(false); }
167
+ }
168
+ }
169
+
170
+ available(key: string, renderer?: THREE.WebGLRenderer): boolean {
171
+ const family = this.families.get(key);
172
+ return !!family?.template && (!family.palette || (!!renderer && family.compilations.get(renderer)?.ready === true));
173
+ }
109
174
 
110
- get(key: string, index: number): THREE.BatchedMesh | null {
175
+ get(key: string, index: number, renderer?: THREE.WebGLRenderer): THREE.BatchedMesh | null {
111
176
  const family = this.families.get(key)!;
112
177
  const template = family.template;
113
- if (!template) return null;
178
+ if (!template || !this.available(key, renderer)) return null;
114
179
  let draw = family.draws[index];
115
180
  if (!draw) {
116
181
  if (this.drawCount >= MAX_DRAWS) return null;
@@ -133,6 +198,14 @@ export class BlenderOrderedBatches {
133
198
  draw.addInstance(firstId);
134
199
  draw.setVisibleAt(i, false);
135
200
  }
201
+ if (family.palette) {
202
+ const indices = new Int32Array(ORDERED_BATCH_CAPACITY);
203
+ const material = physicalPaletteMaterial(family.palette.compiled, family.palette.sources, indices, draw.geometry);
204
+ if (!material) { for (const name of Object.keys(draw.geometry.attributes)) draw.geometry.deleteAttribute(name);
205
+ draw.geometry.setIndex(null); draw.dispose(); return null; }
206
+ draw.material = material;
207
+ family.drawMaterials.set(draw, {material, indices});
208
+ }
136
209
  family.draws.push(draw);
137
210
  this.created++;
138
211
  this.drawCount++;
@@ -142,10 +215,26 @@ export class BlenderOrderedBatches {
142
215
 
143
216
  geometryId(key: string, geometry: THREE.BufferGeometry): number { return this.families.get(key)!.ids.get(geometry.id)!; }
144
217
 
218
+ setMaterialAt(key: string, draw: THREE.BatchedMesh, index: number, source: THREE.Material): void {
219
+ const family = this.families.get(key)!;
220
+ const held = family.drawMaterials.get(draw);
221
+ if (held) held.indices[index] = family.palette!.sources.indexOf(source as THREE.MeshPhysicalMaterial);
222
+ }
223
+
224
+ isPalette(key: string): boolean { return !!this.families.get(key)?.palette; }
225
+
145
226
  inspect() { return {bytes: this.bytes, draws: this.drawCount, pending: this.pending,
227
+ paletteFamilies: [...this.families.values()].filter(f => !!f.palette).length,
228
+ paletteReady: [...this.families.values()].filter(f => [...f.compilations.values()].some(c => c.ready)).length,
229
+ paletteRefused: [...this.families.values()].filter(f => [...f.compilations.values()].some(c => c.refused)).length,
146
230
  refusedFamilies: [...this.families.values()].filter(f => f.refused).length}; }
147
231
 
148
232
  private release(family: Family): void {
233
+ this.generation++;
234
+ for (const compilation of family.compilations.values()) if (!compilation.pending) compilation.material.dispose();
235
+ family.compilations.clear();
236
+ for (const {material} of family.drawMaterials.values()) material.dispose();
237
+ family.drawMaterials.clear();
149
238
  for (const draw of family.draws) {
150
239
  draw.removeFromParent();
151
240
  // BatchedMesh.dispose owns its geometry. Detach shared buffer attributes
@@ -4,7 +4,8 @@
4
4
  * that MeshPhysicalMaterial does not expose as properties. */
5
5
  import * as THREE from 'three';
6
6
  import {z} from 'zod';
7
- import {applyGraphShader, bindGraphDraw, borrowGraphDrawBinding, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
7
+ import {applyGraphShader, bindGraphDraw, bindPaletteGraph, borrowGraphDrawBinding, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
8
+ import type {CompiledGraph} from './blender-node-graph';
8
9
  import {bindNamedUvChannels} from './blender-texture-samplers';
9
10
 
10
11
  const scalar = z.number().finite();
@@ -66,6 +67,77 @@ function graphShadow(material: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMa
66
67
  return shadow;
67
68
  }
68
69
 
70
+ type PaletteState = (string | number | boolean | null)[];
71
+ const identities = new WeakMap<object, number>();
72
+ let nextIdentity = 0;
73
+ const ignoredMaterial = new Set(['id', 'uuid', 'name', 'userData', 'version', '_listeners',
74
+ 'onBeforeRender', 'onBeforeCompile', 'customProgramCacheKey']);
75
+ const ignoredTexture = new Set(['id', 'uuid', 'name', 'source', 'mipmaps', 'version', '_listeners', 'userData']);
76
+ const paletteMaps = new Set(['map', 'normalMap', 'roughnessMap']);
77
+ const identity = (object: object): number => {
78
+ let id = identities.get(object);
79
+ if (id === undefined) { id = ++nextIdentity; identities.set(object, id); }
80
+ return id;
81
+ };
82
+
83
+ /** An exact, small snapshot of shader/render state, including the engine's
84
+ * private physical uniforms. No toJSON/image readback, lossy hash, or version
85
+ * assumption: direct property edits must invalidate a palette too. Only the
86
+ * three sampled image identities may differ, with identical sampler/UV state. */
87
+ export function physicalPaletteState(material: THREE.MeshPhysicalMaterial, imageIdentities = false): PaletteState | null {
88
+ const held = uniforms.get(material);
89
+ if (!held || !materialDrawHooksSupported(material)) return null;
90
+ const state: PaletteState = [];
91
+ const append = (value: unknown, depth = 0): void => {
92
+ if (value === null || typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') state.push(value);
93
+ else if (value === undefined) state.push('undefined');
94
+ else if (typeof value === 'object' || typeof value === 'function') {
95
+ if (depth > 8) { state.push('reference', identity(value)); return; }
96
+ if (Array.isArray(value)) { state.push('array', value.length); for (const entry of value) append(entry, depth + 1); }
97
+ else if (Object.getPrototypeOf(value) === Object.prototype || value instanceof THREE.Color ||
98
+ value instanceof THREE.Vector2 || value instanceof THREE.Vector3 || value instanceof THREE.Vector4 ||
99
+ value instanceof THREE.Matrix3 || value instanceof THREE.Matrix4 || value instanceof THREE.Plane) {
100
+ state.push('values');
101
+ for (const key of Object.keys(value).sort()) { state.push(key); append((value as Record<string, unknown>)[key], depth + 1); }
102
+ } else state.push('reference', identity(value));
103
+ }
104
+ };
105
+ for (const key of Object.keys(material).sort()) {
106
+ if (ignoredMaterial.has(key)) continue;
107
+ state.push(key);
108
+ const value = (material as unknown as Record<string, unknown>)[key];
109
+ if (paletteMaps.has(key) && value instanceof THREE.Texture) {
110
+ // Special sampler kinds require different GLSL declarations/conversions.
111
+ if (value.constructor !== THREE.Texture) return null;
112
+ if (value.matrixAutoUpdate) value.updateMatrix();
113
+ state.push('sampled-image');
114
+ if (imageIdentities) state.push(value.uuid);
115
+ for (const name of Object.keys(value).sort()) {
116
+ if (ignoredTexture.has(name)) continue;
117
+ state.push(name); append((value as unknown as Record<string, unknown>)[name]);
118
+ }
119
+ append(value.userData['blenderUvName'] ?? '');
120
+ } else append(value);
121
+ }
122
+ for (const [name, value] of Object.entries(held)) { state.push(name); append(value.value); }
123
+ return state;
124
+ }
125
+
126
+ export function samePhysicalPaletteState(first: PaletteState, second: PaletteState): boolean {
127
+ return first.length === second.length && first.every((value, index) => value === second[index]);
128
+ }
129
+
130
+ /** A palette draw owns its material/uniform index array, borrowing all image
131
+ * and ramp storage. It never substitutes a source material's hooks. */
132
+ export function physicalPaletteMaterial(compiled: CompiledGraph, sources: readonly THREE.MeshPhysicalMaterial[],
133
+ indices: Int32Array, geometry: THREE.BufferGeometry): THREE.MeshPhysicalMaterial | null {
134
+ for (const source of sources) bindNamedUvChannels(source, geometry);
135
+ const copy = graphShadow(sources[0]!);
136
+ if (bindPaletteGraph(copy, compiled, sources, indices, geometry)) return copy;
137
+ copy.dispose();
138
+ return null;
139
+ }
140
+
69
141
  /** Copy physical values into an independently cached draw form. Textures and
70
142
  * graph uniform values remain borrowed from the authoritative source material. */
71
143
  export function syncPhysicalDrawMaterial(source: THREE.Material, target?: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMaterial | null {
@@ -576,6 +576,8 @@ export const frameSchema = z
576
576
  perspective: z.enum(['PERSP', 'ORTHO', 'CAMERA']),
577
577
  lens: z.number().finite().positive().default(50),
578
578
  shading: z.enum(['WIREFRAME', 'SOLID', 'MATERIAL', 'RENDERED']).optional(),
579
+ scene_world: z.boolean().optional(),
580
+ scene_lights: z.boolean().optional(),
579
581
  })
580
582
  .nullable()
581
583
  .optional(),
@@ -693,9 +695,18 @@ export class BlenderRuntimeView {
693
695
  private readonly lightExtrasRoot = new THREE.Group();
694
696
  private readonly emptyExtrasRoot = new THREE.Group();
695
697
  private rendered = false;
698
+ private sceneLights = false;
699
+ private shadowFit: {
700
+ camera: THREE.Camera | undefined;
701
+ cameraMatrix: THREE.Matrix4 | undefined;
702
+ projection: THREE.Matrix4 | undefined;
703
+ objects: Map<THREE.Object3D, { matrix: THREE.Matrix4; visible: boolean }>;
704
+ } | null = null;
696
705
  /** The camera the VIEWPORT is held in render lighting through (Blender's Rendered shading),
697
706
  * or null when the viewport shows modeling lighting. See {@link holdRendered}. */
698
707
  private heldRendered: (() => THREE.Camera) | null = null;
708
+ /** Material Preview can use scene lighting while retaining viewport visibility. */
709
+ private heldVisibility: 'viewport' | 'render' = 'render';
699
710
  /** What the World was last composed for ({@link worldKeyFor}), or null when it is cleared:
700
711
  * composing rebuilds its textures, so it happens only when the key changes. */
701
712
  private worldApplied: string | null = null;
@@ -881,6 +892,8 @@ export class BlenderRuntimeView {
881
892
  readonly projection: 'perspective' | 'orthographic';
882
893
  readonly lens: number;
883
894
  readonly drawMode: 'wireframe' | 'solid' | 'preview' | 'rendered' | null;
895
+ readonly sceneWorld: boolean;
896
+ readonly sceneLights: boolean;
884
897
  } | null {
885
898
  const saved = this.frame?.view;
886
899
  if (!saved) return null;
@@ -896,6 +909,8 @@ export class BlenderRuntimeView {
896
909
  projection: saved.perspective === 'ORTHO' ? 'orthographic' : 'perspective',
897
910
  lens: saved.lens,
898
911
  drawMode: saved.shading ? SAVED_SHADING[saved.shading] : null,
912
+ sceneWorld: saved.scene_world ?? false,
913
+ sceneLights: saved.scene_lights ?? false,
899
914
  };
900
915
  }
901
916
 
@@ -1005,7 +1020,16 @@ export class BlenderRuntimeView {
1005
1020
  }
1006
1021
 
1007
1022
  stageMode(): string | null {
1008
- return this.mode;
1023
+ return this.playing ? 'PLAY' : this.mode;
1024
+ }
1025
+
1026
+ /** PLAY IS A MODE OF THE STAGE: while the document plays, the keys are the player's, and the
1027
+ * mode this context publishes is what the frame's key rules stand down on
1028
+ * (`scripts/workbench/generate-keymaps.mjs`, `whenFor`). Blender's own mode returns on stop. */
1029
+ private playing = false;
1030
+
1031
+ setPlaying(playing: boolean): void {
1032
+ this.playing = playing;
1009
1033
  }
1010
1034
 
1011
1035
  /** The inverse: the presented object for a Blender datablock NAME, or null
@@ -1153,15 +1177,26 @@ export class BlenderRuntimeView {
1153
1177
  * ({@link setRendered}) — the scene's own lights, its World behind and around the model,
1154
1178
  * `hide_render` visibility and shadows — seen through `drawCamera()`, the camera the stage
1155
1179
  * draws with (orthographic in an orthographic view). `null` returns the viewport to modeling.
1180
+ * Material Preview passes viewport visibility independently of its lighting choices.
1156
1181
  * A render taken meanwhile ends back in this state rather than in modeling.
1157
1182
  */
1158
- holdRendered(drawCamera: (() => THREE.Camera) | null): void {
1159
- if (drawCamera === this.heldRendered) return;
1183
+ private heldSceneLighting: { world: boolean; lights: boolean } | undefined;
1184
+ holdRendered(drawCamera: (() => THREE.Camera) | null, sceneLighting?: { world: boolean; lights: boolean }, visibility: 'viewport' | 'render' = 'render'): void {
1185
+ if (drawCamera === this.heldRendered && sceneLighting?.world === this.heldSceneLighting?.world && sceneLighting?.lights === this.heldSceneLighting?.lights && visibility === this.heldVisibility) return;
1160
1186
  this.heldRendered = drawCamera;
1187
+ this.heldSceneLighting = sceneLighting;
1188
+ this.heldVisibility = visibility;
1161
1189
  if (this.capturing) return;
1162
1190
  this.report(drawCamera === null ? this.applyRendered(false) : this.applyRendered(true, drawCamera()));
1163
1191
  }
1164
1192
 
1193
+ /** Prepare a rendered viewport before revealing its first frame, using the
1194
+ * same lighting/image/World readiness as a photograph. Call after the stage
1195
+ * has attached the root to its scene. */
1196
+ async prepareRendered(camera: THREE.Camera): Promise<void> {
1197
+ await this.applyRendered(true, camera);
1198
+ }
1199
+
1165
1200
  /**
1166
1201
  * Re-apply the held render lighting if the draw camera or the World changed since it was
1167
1202
  * last applied. Cheap when nothing did; the stage calls it every frame it draws.
@@ -1170,7 +1205,8 @@ export class BlenderRuntimeView {
1170
1205
  // A photograph in progress keeps its own state; it returns to this one when it ends.
1171
1206
  if (this.heldRendered === null || this.capturing) return;
1172
1207
  const camera = this.heldRendered();
1173
- if (this.worldKeyFor(camera) === this.worldApplied) return;
1208
+ const worldKey = this.heldSceneLighting?.world === false ? null : this.worldKeyFor(camera);
1209
+ if (worldKey === this.worldApplied) return;
1174
1210
  this.report(this.applyRendered(true, camera));
1175
1211
  }
1176
1212
 
@@ -1183,7 +1219,12 @@ export class BlenderRuntimeView {
1183
1219
  const ortho = (camera as THREE.OrthographicCamera).isOrthographicCamera;
1184
1220
  // Coarse, so an orbit recomposes a handful of times rather than every frame.
1185
1221
  const turn = ortho ? camera.getWorldDirection(new THREE.Vector3()).toArray().map((v) => v.toFixed(1)).join(',') : '';
1186
- return `${camera.uuid}:${turn}:${this.worldKey}`;
1222
+ // A stage can announce itself before parenting the model into its Scene.
1223
+ // Retry after that attachment; the first World apply otherwise finds no Scene
1224
+ // and its cached key leaves the viewport with no sky or ambient light.
1225
+ let owner: THREE.Object3D = this.root;
1226
+ while (owner.parent) owner = owner.parent;
1227
+ return `${owner.uuid}:${camera.uuid}:${turn}:${this.worldKey}`;
1187
1228
  }
1188
1229
 
1189
1230
  private report(work: Promise<void>): void {
@@ -1253,11 +1294,15 @@ export class BlenderRuntimeView {
1253
1294
 
1254
1295
  /** Before the renderer uploads attributes/builds its queues, for this area's
1255
1296
  * actual camera. Transparent instance runs are camera-order dependent. */
1256
- prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean}): () => void {
1297
+ prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}): () => void {
1257
1298
  try {
1258
1299
  const start = performance.now();
1259
1300
  this.instances.prepareDraw(camera);
1260
1301
  this.root.updateMatrixWorld(true);
1302
+ // The script/navigation has just posed the camera and the detached
1303
+ // objects. Fit shadows for that draw, rather than the camera's pose
1304
+ // when Rendered shading was first entered. Captures use this path too.
1305
+ if (this.rendered) this.applyShadows(true, camera);
1261
1306
  const opaqueDone = performance.now();
1262
1307
  const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1263
1308
  options?.interactive === true, options?.height ?? 0);
@@ -1269,7 +1314,7 @@ export class BlenderRuntimeView {
1269
1314
  const motionDone = performance.now();
1270
1315
  this.materialRanges.prepare();
1271
1316
  const rangesDone = performance.now();
1272
- this.transparentInstances.prepare(camera, options?.multiDraw === true);
1317
+ this.transparentInstances.prepare(camera, options?.multiDraw === true, options?.renderer);
1273
1318
  const timing = {opaqueMs: opaqueDone - start, motionMs: motionDone - opaqueDone,
1274
1319
  rangesMs: rangesDone - motionDone, transparentMs: performance.now() - rangesDone};
1275
1320
  this.drawPreparation.last = timing;
@@ -1321,7 +1366,11 @@ export class BlenderRuntimeView {
1321
1366
  }
1322
1367
 
1323
1368
  private async applyRendered(rendered: boolean, camera?: THREE.Camera): Promise<void> {
1324
- const extinction = (rendered ? worldMedium(this.frame?.world)?.extinction : null) ?? new THREE.Vector3();
1369
+ // A frame or shading change can replace geometry, visibility and lights.
1370
+ this.shadowFit = null;
1371
+ const sceneWorld = rendered && (this.capturing || this.heldSceneLighting?.world !== false);
1372
+ const sceneLights = rendered && (this.capturing || this.heldSceneLighting?.lights !== false);
1373
+ const extinction = (sceneWorld ? worldMedium(this.frame?.world)?.extinction : null) ?? new THREE.Vector3();
1325
1374
  for (const material of [...this.materials.values(), this.fallback]) applyWorldExtinction(material, extinction);
1326
1375
  // An area light cannot be DRAWN until its lookup tables are uploaded, and a
1327
1376
  // render is one photograph with no second chance at it.
@@ -1329,12 +1378,15 @@ export class BlenderRuntimeView {
1329
1378
  // same way it waits for area-light tables and image decodes: one
1330
1379
  // photograph, no second chance at it.
1331
1380
  this.rendered = rendered;
1381
+ this.sceneLights = sceneLights;
1382
+ // This is the studio rig, not the authored lights. A scene-lit preview
1383
+ // never adds the Solid studio, including World-only Material Preview.
1332
1384
  this.lighting.setRendered(rendered);
1333
1385
  // The scene's world is what a render sees past the geometry AND its
1334
1386
  // ambient light; modeling keeps the document's own backdrop and fill.
1335
1387
  // A photograph always composes its own; the viewport recomposes only when the key changed.
1336
- const worldKey = rendered && camera ? this.worldKeyFor(camera) : null;
1337
- if (!rendered) this.world.clear();
1388
+ const worldKey = sceneWorld && camera ? this.worldKeyFor(camera) : null;
1389
+ if (!sceneWorld) this.world.clear();
1338
1390
  else if (this.capturing || worldKey !== this.worldApplied) this.world.apply(this.root, this.frame?.world ?? null, camera);
1339
1391
  this.worldApplied = this.capturing ? null : worldKey;
1340
1392
  this.applyVisibility();
@@ -1372,6 +1424,21 @@ export class BlenderRuntimeView {
1372
1424
  */
1373
1425
  private applyShadows(rendered: boolean, camera?: THREE.Camera): void {
1374
1426
  this.root.updateMatrixWorld(true);
1427
+ const fitted = this.shadowFit;
1428
+ if (rendered && fitted && fitted.camera === camera &&
1429
+ (!camera || (fitted.cameraMatrix?.equals(camera.matrixWorld) && fitted.projection?.equals(camera.projectionMatrix))) &&
1430
+ fitted.objects.size === this.objects.size &&
1431
+ [...this.objects.values()].every(object => {
1432
+ const previous = fitted.objects.get(object);
1433
+ return previous?.visible === object.visible && previous.matrix.equals(object.matrixWorld);
1434
+ })) return;
1435
+ this.shadowFit = rendered ? {
1436
+ camera,
1437
+ cameraMatrix: camera?.matrixWorld.clone(),
1438
+ projection: camera?.projectionMatrix.clone(),
1439
+ objects: new Map([...this.objects.values()].map(object =>
1440
+ [object, { matrix: object.matrixWorld.clone(), visible: object.visible }])),
1441
+ } : null;
1375
1442
  const boxes: THREE.Box3[] = [];
1376
1443
  for (const object of this.objects.values()) {
1377
1444
  const mesh = object as THREE.Mesh;
@@ -1497,12 +1564,14 @@ export class BlenderRuntimeView {
1497
1564
  }
1498
1565
 
1499
1566
  private applyVisibility(): void {
1567
+ const renderVisibility = this.rendered && (this.capturing || this.heldVisibility === 'render');
1500
1568
  for (const obj of this.frame?.objects ?? []) {
1501
1569
  const object = this.objects.get(obj.id);
1502
- if (object) object.visible = this.rendered ? obj.render_visible : obj.visible;
1570
+ const visible = renderVisibility ? obj.render_visible : obj.visible;
1571
+ if (object) object.visible = visible;
1503
1572
  if (!obj.light) continue;
1504
1573
  const light = this.lights.get(obj.light);
1505
- if (light) light.visible = this.rendered && obj.render_visible;
1574
+ if (light) light.visible = this.sceneLights && visible;
1506
1575
  }
1507
1576
  // AN OVERLAY IS MODELING CHROME AND IS NEVER PHOTOGRAPHED. The same
1508
1577
  // distinction the loop above draws between what the viewport shows and
@@ -2224,6 +2293,26 @@ export class BlenderRuntimeView {
2224
2293
  };
2225
2294
  }
2226
2295
 
2296
+ /**
2297
+ * A COPY OF THE MODEL THAT NO LONGER FOLLOWS IT, for Play: the graph `follow` builds, from the
2298
+ * frame this view holds now, and handed nothing afterwards. What moves its objects is the
2299
+ * caller's; Blender's data and this view never see it, and disposing it is the whole undo.
2300
+ */
2301
+ detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2302
+ const detached = new BlenderRuntimeView();
2303
+ if (this.frame) detached.applyFrame(this.fullFrame());
2304
+ let disposed = false;
2305
+ return {
2306
+ view: detached,
2307
+ dispose: () => {
2308
+ if (disposed) return;
2309
+ disposed = true;
2310
+ detached.dispose();
2311
+ detached.root.removeFromParent();
2312
+ },
2313
+ };
2314
+ }
2315
+
2227
2316
  captureSnapshot() {
2228
2317
  const frame = this.fullFrame();
2229
2318
  const source = this.frame!;
@@ -1,6 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import {BlenderOrderedBatches, ORDERED_BATCH_CAPACITY} from './blender-ordered-batches';
3
3
  import {BlenderDrawMaterials} from './blender-draw-materials';
4
+ import {BlenderMaterialPalettes} from './blender-material-palettes';
4
5
  import {presenterChanged} from './blender-presenter-change';
5
6
  import {graphDrawAttributes} from './blender-graph-material';
6
7
  import {materialDrawHooksSupported} from './blender-physical-material';
@@ -8,7 +9,7 @@ import {instanceMatrixSupported, instanceObjectShown, instanceObjectHooksSupport
8
9
 
9
10
  const CAPACITY = ORDERED_BATCH_CAPACITY;
10
11
  type Entry = {mesh: THREE.Mesh; material: THREE.Material | null; key: string | null; batchKey: string | null; groupOrder: number; z: number; centre: THREE.Vector3};
11
- type Draw = {mesh: THREE.InstancedMesh | THREE.BatchedMesh; members: THREE.Mesh[]};
12
+ type Draw = {mesh: THREE.InstancedMesh | THREE.BatchedMesh; members: THREE.Mesh[]; palette: boolean};
12
13
 
13
14
  function groupOrder(mesh: THREE.Object3D): number {
14
15
  for (let parent = mesh.parent; parent; parent = parent.parent)
@@ -25,6 +26,10 @@ export class BlenderTransparentInstances {
25
26
  private objects: THREE.Mesh[] = [];
26
27
  private readonly drawMaterials = new BlenderDrawMaterials();
27
28
  private readonly ordered = new BlenderOrderedBatches();
29
+ private readonly palettes = new BlenderMaterialPalettes();
30
+ private readonly sourceMaterials = new Set<THREE.Material>();
31
+ private plannedGeneration = -1;
32
+ private renderer: THREE.WebGLRenderer | undefined;
28
33
  private readonly mixedDraws = new Map<THREE.BatchedMesh, Draw>();
29
34
  private multiDraw = false;
30
35
  private readonly pools = new Map<string, Draw[]>();
@@ -52,23 +57,36 @@ export class BlenderTransparentInstances {
52
57
  this.objects = [...objects].filter((object): object is THREE.Mesh => (object as THREE.Mesh).isMesh === true);
53
58
  }
54
59
 
55
- prepare(camera: THREE.Camera, multiDraw = false): void {
60
+ prepare(camera: THREE.Camera, multiDraw = false, renderer?: THREE.WebGLRenderer): void {
56
61
  this.finishDraw();
57
62
  this.drawMaterials.begin();
58
63
  if (!this.objects.length) return;
59
64
  camera.updateMatrixWorld();
60
65
  this.projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
61
- const capabilityChanged = this.multiDraw !== multiDraw;
66
+ let scene: THREE.Object3D = this.root;
67
+ while (scene.parent) scene = scene.parent;
68
+ const targetScene = scene instanceof THREE.Scene ? scene : null;
69
+ const capabilityChanged = this.multiDraw !== multiDraw || this.renderer !== renderer;
70
+ this.renderer = renderer;
62
71
  this.multiDraw = multiDraw;
63
72
  const inputsChanged = this.planChanged(camera);
64
- if (!inputsChanged && !capabilityChanged && !this.ordered.pending) {
73
+ if (inputsChanged) {
74
+ this.sourceMaterials.clear();
75
+ for (const mesh of this.objects) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) this.sourceMaterials.add(material);
76
+ }
77
+ const palettesChanged = this.palettes.prepare(this.sourceMaterials, () => {
78
+ this.ordered.clear(); this.mixedDraws.clear(); this.planned.length = 0;
79
+ });
80
+ this.ordered.beginPaletteFrame();
81
+ if (renderer && targetScene) this.ordered.compilePalettes(renderer, camera, targetScene);
82
+ if (!inputsChanged && !capabilityChanged && !palettesChanged && this.plannedGeneration === this.ordered.generation && !this.ordered.pending) {
65
83
  // The draw plan is a disposable presentation, not evaluated scene data.
66
84
  // Reuse only after exact comparison of every input to culling/ordering;
67
85
  // a camera move, drag, visibility or material change rebuilds it below.
68
86
  for (const draw of this.planned) {
69
87
  draw.mesh.visible = true;
70
88
  const source = draw.members[0]!.material;
71
- draw.mesh.material = this.drawMaterials.get(Array.isArray(source) ? source[0]! : source, draw.mesh);
89
+ if (!draw.palette) draw.mesh.material = this.drawMaterials.get(Array.isArray(source) ? source[0]! : source, draw.mesh);
72
90
  for (const member of draw.members) {
73
91
  member.layers.disable(0);
74
92
  this.hidden.push(member);
@@ -147,13 +165,15 @@ export class BlenderTransparentInstances {
147
165
  : 'geometryLayersOrTransform';
148
166
  this.excluded[reason] = (this.excluded[reason] ?? 0) + 1;
149
167
  }
150
- const batchKey = multiDraw && key && material ? this.ordered.register(mesh.geometry, material, mesh.renderOrder) : null;
168
+ const palette = renderer && targetScene && material ? this.palettes.get(material) : undefined;
169
+ const batchKey = multiDraw && key && material ? this.ordered.register(mesh.geometry, palette?.identity ?? material, mesh.renderOrder, palette) : null;
151
170
  entries.push({mesh, material, key, batchKey, groupOrder: order, z, centre});
152
171
  }
153
172
  this.ordered.pack();
173
+ if (renderer && targetScene) this.ordered.compilePalettes(renderer, camera, targetScene);
154
174
  // A family may exceed the bounded cache. Retain its original instancing
155
175
  // key rather than merging incompatible geometries on the fallback path.
156
- for (const entry of entries) if (entry.batchKey && this.ordered.available(entry.batchKey)) entry.key = entry.batchKey;
176
+ for (const entry of entries) if (entry.batchKey && this.ordered.available(entry.batchKey, renderer)) entry.key = entry.batchKey;
157
177
  for (const [mesh] of this.mixedDraws) if (!mesh.parent) this.mixedDraws.delete(mesh);
158
178
  entries.sort((a, b) => a.groupOrder - b.groupOrder || a.mesh.renderOrder - b.mesh.renderOrder || b.z - a.z || a.mesh.id - b.mesh.id);
159
179
  const used = new Map<string, number>();
@@ -181,11 +201,11 @@ export class BlenderTransparentInstances {
181
201
  const mixed = first.key.startsWith('multi:');
182
202
  let draw: Draw | undefined;
183
203
  if (mixed) {
184
- const mesh = this.ordered.get(first.key, index);
204
+ const mesh = this.ordered.get(first.key, index, renderer);
185
205
  if (!mesh) continue;
186
206
  draw = this.mixedDraws.get(mesh);
187
207
  if (!draw) {
188
- draw = {mesh, members: []};
208
+ draw = {mesh, members: [], palette: this.ordered.isPalette(first.key)};
189
209
  this.mixedDraws.set(mesh, draw);
190
210
  const held = draw;
191
211
  mesh.raycast = (raycaster, hits) => {
@@ -208,7 +228,7 @@ export class BlenderTransparentInstances {
208
228
  mesh.frustumCulled = false;
209
229
  mesh.boundingSphere = new THREE.Sphere();
210
230
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
211
- draw = {mesh, members: []};
231
+ draw = {mesh, members: [], palette: false};
212
232
  const held = draw;
213
233
  mesh.raycast = (raycaster, hits) => {
214
234
  if (!mesh.visible) return;
@@ -218,7 +238,7 @@ export class BlenderTransparentInstances {
218
238
  pool.push(draw);
219
239
  this.root.add(mesh);
220
240
  }
221
- draw.mesh.material = this.drawMaterials.get(first.material!, draw.mesh);
241
+ if (!draw.palette) draw.mesh.material = this.drawMaterials.get(first.material!, draw.mesh);
222
242
  draw.mesh.visible = true;
223
243
  draw.mesh.renderOrder = head.mesh.renderOrder;
224
244
  draw.mesh.boundingSphere!.center.copy(head.centre).applyMatrix4(this.inverse);
@@ -229,6 +249,7 @@ export class BlenderTransparentInstances {
229
249
  draw.mesh.setMatrixAt(i, this.matrix);
230
250
  if (draw.mesh instanceof THREE.BatchedMesh) {
231
251
  draw.mesh.setGeometryIdAt(i, this.ordered.geometryId(first.key, member.geometry));
252
+ this.ordered.setMaterialAt(first.key, draw.mesh, i, entries[offset + i]!.material!);
232
253
  draw.mesh.setVisibleAt(i, true);
233
254
  }
234
255
  draw.members.push(member);
@@ -251,9 +272,10 @@ export class BlenderTransparentInstances {
251
272
  // A quiet viewport must also finish bounded pool construction. Notify only
252
273
  // after this draw's temporary layer/geometry swaps have been restored.
253
274
  if (this.ordered.pending) queueMicrotask(presenterChanged);
275
+ this.plannedGeneration = this.ordered.generation;
254
276
  }
255
277
 
256
- inspect() { return {batches: this.batches, instances: this.instances, excluded: {...this.excluded}, multiDraw: this.ordered.inspect(), materials: this.drawMaterials.inspect()}; }
278
+ inspect() { return {batches: this.batches, instances: this.instances, excluded: {...this.excluded}, multiDraw: this.ordered.inspect(), materials: this.drawMaterials.inspect(), palettes: this.palettes.inspect()}; }
257
279
 
258
280
  private observe(value: number | string): void {
259
281
  if (this.planState[this.stateIndex] !== value) {
@@ -361,6 +383,10 @@ export class BlenderTransparentInstances {
361
383
  }
362
384
  this.pools.clear();
363
385
  this.ordered.clear();
386
+ this.palettes.clear();
387
+ this.sourceMaterials.clear();
388
+ this.plannedGeneration = -1;
389
+ this.renderer = undefined;
364
390
  this.drawMaterials.clear();
365
391
  this.mixedDraws.clear();
366
392
  this.planned.length = 0;
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.94",
5
+ "version": "0.1.96",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },