@volter/blender-engine 0.1.94 → 0.1.95

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.
@@ -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 {
@@ -1253,7 +1253,7 @@ export class BlenderRuntimeView {
1253
1253
 
1254
1254
  /** Before the renderer uploads attributes/builds its queues, for this area's
1255
1255
  * actual camera. Transparent instance runs are camera-order dependent. */
1256
- prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean}): () => void {
1256
+ prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}): () => void {
1257
1257
  try {
1258
1258
  const start = performance.now();
1259
1259
  this.instances.prepareDraw(camera);
@@ -1269,7 +1269,7 @@ export class BlenderRuntimeView {
1269
1269
  const motionDone = performance.now();
1270
1270
  this.materialRanges.prepare();
1271
1271
  const rangesDone = performance.now();
1272
- this.transparentInstances.prepare(camera, options?.multiDraw === true);
1272
+ this.transparentInstances.prepare(camera, options?.multiDraw === true, options?.renderer);
1273
1273
  const timing = {opaqueMs: opaqueDone - start, motionMs: motionDone - opaqueDone,
1274
1274
  rangesMs: rangesDone - motionDone, transparentMs: performance.now() - rangesDone};
1275
1275
  this.drawPreparation.last = timing;
@@ -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.95",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },