@volter/blender-engine 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
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, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
7
8
  import {bindNamedUvChannels} from './blender-texture-samplers';
8
9
 
9
10
  const scalar = z.number().finite();
@@ -33,6 +34,28 @@ export function applyWorldExtinction(material: THREE.MeshPhysicalMaterial, extin
33
34
  uniforms.get(material)!.blenderWorldExtinction.value.copy(extinction);
34
35
  }
35
36
 
37
+ /** A copy of `material` with its own hooks and the same physical values:
38
+ * the proxy a pending graph program compiles through, whose program key is
39
+ * the one `material` asks for once the graph is swapped in. */
40
+ function graphShadow(material: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMaterial {
41
+ const shadow = new THREE.MeshPhysicalMaterial();
42
+ const held = uniforms.get(material);
43
+ // The hooks, then the values: `applyPhysicalMaterial` sets its defaults,
44
+ // which `copy` replaces with the material's own (it leaves hooks alone).
45
+ applyPhysicalMaterial(shadow);
46
+ shadow.copy(material);
47
+ const values = uniforms.get(shadow)!;
48
+ if (held) {
49
+ values.blenderCoatIor.value = held.blenderCoatIor.value;
50
+ values.blenderCoatTint.value.copy(held.blenderCoatTint.value);
51
+ values.blenderMapClip.value = held.blenderMapClip.value;
52
+ values.blenderRoughnessClip.value = held.blenderRoughnessClip.value;
53
+ values.blenderNormalClip.value = held.blenderNormalClip.value;
54
+ values.blenderWorldExtinction.value.copy(held.blenderWorldExtinction.value);
55
+ }
56
+ return shadow;
57
+ }
58
+
36
59
  export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, input?: Physical,
37
60
  clips: {map?: boolean; roughness?: boolean; normal?: boolean} = {}): void {
38
61
  const data = input ?? defaults;
@@ -55,12 +78,23 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
55
78
  blenderWorldExtinction: {value: new THREE.Vector3()}};
56
79
  uniforms.set(material, values);
57
80
  const held = values;
58
- material.customProgramCacheKey = () => 'blender-principled-physical-v5';
59
- material.onBeforeRender = (_renderer, _scene, _camera, geometry) => bindNamedUvChannels(material, geometry);
81
+ material.customProgramCacheKey = () => `blender-principled-physical-v5${graphProgramKey(material)}`;
82
+ material.onBeforeRender = (renderer, scene, camera, geometry, object) => {
83
+ bindNamedUvChannels(material, geometry);
84
+ bindGraphDraw(material, geometry, renderer, scene, camera, object, graphShadow);
85
+ setGraphViewport(renderer);
86
+ };
60
87
  material.onBeforeCompile = shader => {
61
- // Three declares channels 0..3. Blender has eight named UV maps in
62
- // addition to the default channel; only the used attributes survive GLSL.
63
- shader.vertexShader = [4, 5, 6, 7, 8, 9].map(i => `attribute vec2 uv${i};`).join('\n') + '\n' + shader.vertexShader;
88
+ // Three declares channels 1..3 only for its own maps, and none past 3.
89
+ // Blender has eight named UV maps beside the default channel (9 is the
90
+ // missing layer's zeros). Declare only the channels this draw reads: a
91
+ // driver may count every declared attribute against its sixteen, and
92
+ // six idle ones leave a graph with a few attributes no room.
93
+ const read = new Set([material.map, material.roughnessMap, material.normalMap]
94
+ .flatMap(texture => (texture ? [texture.channel] : [])).concat(graphUvChannels(material)));
95
+ shader.vertexShader = [...read].filter(i => i > 0).sort((a, b) => a - b)
96
+ .map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
97
+ .join('\n') + '\n' + shader.vertexShader;
64
98
  Object.assign(shader.uniforms, held);
65
99
  shader.fragmentShader = `uniform float blenderCoatIor;
66
100
  uniform vec3 blenderWorldExtinction;
@@ -121,6 +155,9 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
121
155
  blenderCoatIor * blenderCoatIor - (1.0 - dotNVcc * dotNVcc)));
122
156
  outgoingLight *= mix(vec3(1.0), pow(max(blenderCoatTint, vec3(0.0)), vec3(coatPath)), material.clearcoat);
123
157
  vec3 Fcc = F_Schlick`);
158
+ // The material's node graph, when the session shipped one, drives the
159
+ // surface inputs it carries (`blender-graph-material.ts`).
160
+ applyGraphShader(material, shader);
124
161
  };
125
162
  material.needsUpdate = true;
126
163
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * WORK THAT FINISHES AFTER A FRAME WAS APPLIED, announced.
3
+ *
4
+ * A presented frame is not the last change to what the presenter draws: a
5
+ * PNG decodes after the frame that named it, a clone's sampler uploads after
6
+ * that decode, a sky is derived on a worker, the area-light tables load on
7
+ * demand, and a graph's program swaps in once `compileAsync` links it. A
8
+ * stage that draws only when something changed (`BlenderRuntimeView.onChange`)
9
+ * needs every one of those to say so, or the viewport holds the picture from
10
+ * before it -- an untextured mesh, a missing sky -- until something else
11
+ * happens to move. Each such completion calls {@link presenterChanged}.
12
+ */
13
+ const listeners = new Set<() => void>();
14
+
15
+ export function presenterChanged(): void {
16
+ for (const listener of [...listeners]) listener();
17
+ }
18
+
19
+ export function onPresenterChange(listener: () => void): () => void {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ }
@@ -41,6 +41,13 @@ export interface DrawArrays {
41
41
  uv: Float32Array | null;
42
42
  /** All named UV layers; material inputs choose their own layer per draw. */
43
43
  uvLayers?: {name: string; data: Float32Array}[] | undefined;
44
+ /** The mesh's generic attribute layers a material's Attribute or Color
45
+ * Attribute node can read, four floats per drawn vertex as EEVEE loads them
46
+ * (`graphAttributeLayers`). */
47
+ attributeLayers?: {name: string; data: Float32Array}[] | undefined;
48
+ /** Per drawn vertex, a deformed mesh's Generated coordinates (0..1), or
49
+ * null when the positions are undeformed and orco derives from them. */
50
+ orco?: Float32Array | null | undefined;
44
51
  indices: Uint32Array;
45
52
  groups: { start: number; count: number; materialIndex: number }[];
46
53
  /** THE BLENDER VERTEX EACH DRAWN VERTEX CAME FROM, one per drawn vertex.
@@ -61,6 +68,8 @@ interface DrawView {
61
68
  vertexCount: number;
62
69
  normals: Normal[] | null;
63
70
  cornerNormal?: Float32Array | undefined;
71
+ /** Blender's stored orco (-1..1), 3 per vertex. */
72
+ orco?: Float32Array | undefined;
64
73
  co(i: number): THREE.Vector3;
65
74
  /** CSR faces: corner `starts[i]..starts[i+1]` hold vertex indices. */
66
75
  starts: Uint32Array;
@@ -73,6 +82,49 @@ interface DrawView {
73
82
  /** A corner's UV, or undefined when the mesh has no map. */
74
83
  uv: ((corner: number) => [number, number]) | null;
75
84
  uvLayers?: {name: string; read: (corner: number) => [number, number]}[];
85
+ /** A corner's value of each generic attribute layer, by vertex, corner and
86
+ * face; `corner` and `face` are undefined for a loose vertex. */
87
+ attributeLayers?: {name: string; domain: string;
88
+ read: (vertex: number, corner: number | undefined, face: number | undefined) => ArrayLike<number>}[];
89
+ }
90
+
91
+ /** EEVEE's float4 of an attribute value: float (f, f, f, 1), float2 (x, y, 0,
92
+ * 1), float3 (x, y, z, 1), a colour as itself, a byte colour decoded from
93
+ * sRGB (`ColorGeometry4b::decode`). */
94
+ const GRAPH_ATTRIBUTE_TYPES = new Set(['FLOAT', 'FLOAT2', 'FLOAT_VECTOR', 'FLOAT_COLOR', 'BYTE_COLOR']);
95
+ function srgbToLinear(byte: number): number {
96
+ const c = byte / 255;
97
+ return c < 0.04045 ? (c < 0 ? 0 : c * (1 / 12.92)) : ((c + 0.055) * (1 / 1.055)) ** 2.4;
98
+ }
99
+ function attributeFloat4(type: string, values: ArrayLike<number>, i: number): [number, number, number, number] {
100
+ switch (type) {
101
+ case 'FLOAT': { const f = Number(values[i]); return [f, f, f, 1]; }
102
+ case 'FLOAT2': return [Number(values[i * 2]), Number(values[i * 2 + 1]), 0, 1];
103
+ case 'FLOAT_VECTOR': return [Number(values[i * 3]), Number(values[i * 3 + 1]), Number(values[i * 3 + 2]), 1];
104
+ case 'BYTE_COLOR': return [srgbToLinear(Number(values[i * 4])), srgbToLinear(Number(values[i * 4 + 1])),
105
+ srgbToLinear(Number(values[i * 4 + 2])), Number(values[i * 4 + 3]) / 255];
106
+ default: return [Number(values[i * 4]), Number(values[i * 4 + 1]), Number(values[i * 4 + 2]), Number(values[i * 4 + 3])];
107
+ }
108
+ }
109
+ /** The readable layers of `attributes`: every generic layer of a type EEVEE
110
+ * loads as a float4, on a domain a drawn vertex has a value on. UV maps stay
111
+ * `uvLayers`; names Blender keeps internal (`.select_vert`, the positions,
112
+ * the custom normals) are not material inputs. */
113
+ function graphAttributeLayers(
114
+ attributes: readonly {name: string; domain: string; type: string; data: ArrayLike<unknown>}[],
115
+ flat: (data: ArrayLike<unknown>, type: string) => ArrayLike<number>,
116
+ ): NonNullable<DrawView['attributeLayers']> {
117
+ return attributes
118
+ .filter(a => GRAPH_ATTRIBUTE_TYPES.has(a.type) && a.domain !== 'EDGE' && !a.name.startsWith('.')
119
+ && a.name !== 'position' && a.name !== 'custom_normal' && !(a.type === 'FLOAT2' && a.domain === 'CORNER'))
120
+ .map(a => {
121
+ const values = flat(a.data, a.type);
122
+ const zero = [0, 0, 0, 0];
123
+ return {name: a.name, domain: a.domain, read: (vertex: number, corner: number | undefined, face: number | undefined) =>
124
+ a.domain === 'POINT' ? attributeFloat4(a.type, values, vertex)
125
+ : a.domain === 'CORNER' ? (corner === undefined ? zero : attributeFloat4(a.type, values, corner))
126
+ : face === undefined ? zero : attributeFloat4(a.type, values, face)};
127
+ });
76
128
  }
77
129
 
78
130
  function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
@@ -152,14 +204,19 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
152
204
  texcoords = new Float32Array(capacity * 2);
153
205
  const sourceVertex = new Uint32Array(capacity);
154
206
  const uvLayers = (view.uvLayers ?? []).map(layer => ({...layer, data: new Float32Array(capacity * 2)}));
207
+ const attributeLayers = (view.attributeLayers ?? []).map(layer => ({...layer, data: new Float32Array(capacity * 4)}));
208
+ const orco = view.orco ? new Float32Array(capacity * 3) : null;
155
209
  const indices = new Uint32Array(capacity),
156
210
  shared = new Map<string, number>();
157
211
  const groups: DrawArrays['groups'] = [];
158
212
  const normals = view.normals || view.cornerNormal ? new Float32Array(capacity * 3) : null;
159
213
  let count = 0,
160
214
  indexCount = 0;
161
- const emit = (v: number, texture?: [number, number], normal?: Normal, sourceCorner?: number): number => {
215
+ const emit = (v: number, texture?: [number, number], normal?: Normal, sourceCorner?: number, face?: number): number => {
162
216
  const index = count++;
217
+ for (const layer of attributeLayers) layer.data.set(layer.read(v, sourceCorner, face), index * 4);
218
+ // attr_load_orco: the stored -1..1 orco, mapped to 0..1.
219
+ if (orco) for (let k = 0; k < 3; k++) orco[index * 3 + k] = view.orco![v * 3 + k]! * 0.5 + 0.5;
163
220
  sourceVertex[index] = v;
164
221
  view.co(v).toArray(positions, index * 3);
165
222
  if (texture) texcoords.set(texture, index * 2);
@@ -167,16 +224,18 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
167
224
  if (sourceCorner !== undefined) for (const layer of uvLayers) layer.data.set(layer.read(sourceCorner), index * 2);
168
225
  return index;
169
226
  };
170
- const corner = (c: number): number => {
227
+ const corner = (c: number, face: number): number => {
171
228
  const texture = uv?.(c);
172
229
  const normal: Normal | undefined = view.cornerNormal
173
230
  ? [view.cornerNormal[c * 3]!, view.cornerNormal[c * 3 + 1]!, view.cornerNormal[c * 3 + 2]!]
174
231
  : view.normals?.[c];
175
- if (!mixed) return emit(cornerVerts[c]!, texture, normal, c);
176
- const key = `${smooth[c] ? find(c) : c}${texture ? `:${texture[0]}:${texture[1]}` : ''}${normal ? `:n${normal.join(':')}` : ''}${uvLayers.map(layer => ':' + layer.read(c).join(':')).join('')}`;
232
+ if (!mixed) return emit(cornerVerts[c]!, texture, normal, c, face);
233
+ // A POINT layer's value is the vertex's, which the smooth fan already
234
+ // shares; a face or corner layer splits the fan where its values differ.
235
+ const key = `${smooth[c] ? find(c) : c}${texture ? `:${texture[0]}:${texture[1]}` : ''}${normal ? `:n${normal.join(':')}` : ''}${uvLayers.map(layer => ':' + layer.read(c).join(':')).join('')}${attributeLayers.map(layer => layer.domain === 'POINT' ? '' : ':' + Array.from(layer.read(cornerVerts[c]!, c, face)).join(':')).join('')}`;
177
236
  let index = shared.get(key);
178
237
  if (index === undefined) {
179
- index = emit(cornerVerts[c]!, texture, normal, c);
238
+ index = emit(cornerVerts[c]!, texture, normal, c, face);
180
239
  shared.set(key, index);
181
240
  }
182
241
  return index;
@@ -188,7 +247,7 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
188
247
  const points: THREE.Vector3[] = [];
189
248
  for (let j = 0; j < length; j++) points.push(view.co(cornerVerts[faceStart + j]!));
190
249
  for (const triangle of triangulatePolygon(points).triangles)
191
- for (const c of triangle) indices[indexCount++] = corner(faceStart + c);
250
+ for (const c of triangle) indices[indexCount++] = corner(faceStart + c, i);
192
251
  const materialIndex = view.material(i),
193
252
  previous = groups.at(-1);
194
253
  if (previous?.materialIndex === materialIndex) previous.count += indexCount - start;
@@ -200,12 +259,21 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
200
259
  normals: normals?.slice(0, count * 3) ?? null,
201
260
  uv: uv ? texcoords.slice(0, count * 2) : null,
202
261
  uvLayers: uvLayers.map(layer => ({name: layer.name, data: layer.data.slice(0, count * 2)})),
262
+ attributeLayers: attributeLayers.map(layer => ({name: layer.name, data: layer.data.slice(0, count * 4)})),
263
+ orco: orco ? orco.slice(0, count * 3) : null,
203
264
  indices: indices.slice(0, indexCount),
204
265
  sourceVertex: sourceVertex.slice(0, count),
205
266
  groups,
206
267
  };
207
268
  }
208
269
 
270
+ /** The three attribute a graph reads Blender attribute `name` from: a GLSL
271
+ * identifier spelled from the name's UTF-8 bytes, so every name has one and
272
+ * no two names share one. */
273
+ export function graphAttributeName(name: string): string {
274
+ return `blenderA_${Array.from(new TextEncoder().encode(name), b => b.toString(16).padStart(2, '0')).join('')}`;
275
+ }
276
+
209
277
  export function geometryFromDrawArrays(
210
278
  arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
211
279
  ): THREE.BufferGeometry {
@@ -225,6 +293,13 @@ export function geometryFromDrawArrays(
225
293
  geometry.setAttribute(`uv${index + 1}`, new THREE.BufferAttribute(layer.data, 2));
226
294
  }
227
295
  geometry.userData['blenderUvChannels'] = channels;
296
+ for (const layer of arrays.attributeLayers ?? [])
297
+ geometry.setAttribute(graphAttributeName(layer.name), new THREE.BufferAttribute(layer.data, 4));
298
+ geometry.userData['blenderAttributes'] = (arrays.attributeLayers ?? []).map(layer => layer.name);
299
+ if (arrays.orco) {
300
+ geometry.setAttribute('blenderOrco', new THREE.BufferAttribute(arrays.orco, 3));
301
+ geometry.userData['blenderOrcoFromDoor'] = true;
302
+ }
228
303
  for (const group of arrays.groups)
229
304
  geometry.addGroup(group.start, group.count, group.materialIndex);
230
305
  if (arrays.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(arrays.normals, 3));
@@ -288,6 +363,7 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
288
363
  vertexCount: nv,
289
364
  normals: normalMesh ? customCornerNormals(normalMesh, customData) : null,
290
365
  cornerNormal: c.cornerNormal,
366
+ orco: c.orco,
291
367
  co: (i) => scratch.set(c.co[i * 3]!, c.co[i * 3 + 1]!, c.co[i * 3 + 2]!).clone(),
292
368
  starts: c.faceStart,
293
369
  cornerVerts: c.corner,
@@ -298,6 +374,7 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
298
374
  uv: uvData ? (corner) => [uvData[corner * 2]!, uvData[corner * 2 + 1]!] : null,
299
375
  uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
300
376
  [Number(layer.data[corner * 2]), Number(layer.data[corner * 2 + 1])] as [number, number]})),
377
+ attributeLayers: graphAttributeLayers(c.attributes, data => data as ArrayLike<number>),
301
378
  };
302
379
  if (map && ATTRIBUTE_LAYOUT.FLOAT2.size !== 2) throw new Error('FLOAT2 layout');
303
380
  return { ...drawCore(view), hash };
@@ -360,6 +437,9 @@ export function drawRuntimeGeometry(data: MeshData): THREE.BufferGeometry {
360
437
  uv: uv ? (corner) => uv[corner]! : null,
361
438
  uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
362
439
  (layer.data as [number, number][])[corner]!})),
440
+ // The JSON door nests each element's components; the reader wants them flat.
441
+ attributeLayers: graphAttributeLayers(data.attributes ?? [],
442
+ values => (Array.from(values) as (number | number[])[]).flat()),
363
443
  };
364
444
  return geometryFromDrawArrays(drawCore(view));
365
445
  }
@@ -35,6 +35,7 @@
35
35
  * faked with a substitute light the scene never declared.
36
36
  */
37
37
  import * as THREE from 'three';
38
+ import {presenterChanged} from './blender-presenter-change';
38
39
  import { z } from 'zod';
39
40
  import type { GradientType } from './blender-gradient-texture';
40
41
  import { hasSkyTexture, primeSkyTexture, type SkyParameters } from './blender-sky';
@@ -76,6 +77,7 @@ function prepareRectArea(): Promise<void> {
76
77
  rectAreaPromise ??= import('three/examples/jsm/lights/RectAreaLightUniformsLib.js').then(
77
78
  ({ RectAreaLightUniformsLib }) => {
78
79
  RectAreaLightUniformsLib.init();
80
+ presenterChanged();
79
81
  },
80
82
  );
81
83
  return rectAreaPromise;
@@ -765,6 +767,7 @@ export class WorldBackground {
765
767
  // composing now would paint a sky nobody asked for over the current one.
766
768
  if (this.generation !== generation || this.applied?.scene !== scene) return;
767
769
  this.compose(scene, world, expression, camera);
770
+ presenterChanged();
768
771
  })();
769
772
  this.pending = pending;
770
773
  pendingWorld.add(pending);
@@ -4,11 +4,13 @@
4
4
  */
5
5
  import * as THREE from 'three';
6
6
  import { z } from 'zod';
7
+ import { onPresenterChange, presenterChanged } from './blender-presenter-change';
7
8
  import { bytesFromBase64 } from './blender-base64';
8
9
  import { ArmatureOverlay, armatureSchema } from './blender-runtime-armature';
9
10
  import { UNKNOWN_GEOMETRY, UNKNOWN_IMAGE } from './blender-runtime-frame';
10
11
  import {
11
12
  drawArraysFromColumns,
13
+ graphAttributeName,
12
14
  drawRuntimeGeometry,
13
15
  geometryFromDrawArrays,
14
16
  } from './blender-runtime-geometry';
@@ -25,6 +27,8 @@ import {
25
27
  import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
26
28
  import { volumeMesh, volumeSchema } from './blender-runtime-volume';
27
29
  import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
30
+ import { prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
31
+ import { type CompiledGraph, compileMaterialGraph, graphSeeThrough, materialGraphSchema } from './blender-node-graph';
28
32
  import {worldMedium, WorldVolumePass} from './blender-world-volume';
29
33
  import { BlenderTextureSamplers } from './blender-texture-samplers';
30
34
  import { WeightOverlay, weightsSchema } from './blender-runtime-weights';
@@ -107,6 +111,8 @@ const drawArraysSchema = z
107
111
  normals: z.instanceof(Float32Array).nullable(),
108
112
  uv: z.instanceof(Float32Array).nullable(),
109
113
  uvLayers: z.array(z.object({name: z.string(), data: z.instanceof(Float32Array)}).strict()).optional(),
114
+ attributeLayers: z.array(z.object({name: z.string(), data: z.instanceof(Float32Array)}).strict()).optional(),
115
+ orco: z.instanceof(Float32Array).nullable().optional(),
110
116
  indices: z.instanceof(Uint32Array),
111
117
  groups: z.array(
112
118
  z
@@ -163,6 +169,7 @@ const columnsSchema = z
163
169
  .object({
164
170
  co: z.instanceof(Float64Array),
165
171
  cornerNormal: z.instanceof(Float32Array).optional(),
172
+ orco: z.instanceof(Float32Array).optional(),
166
173
  faceStart: z.instanceof(Uint32Array),
167
174
  corner: z.instanceof(Uint32Array),
168
175
  cornerEdge: z.instanceof(Int32Array),
@@ -256,6 +263,7 @@ function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promi
256
263
  }
257
264
  texture.image = bitmap;
258
265
  texture.needsUpdate = true;
266
+ presenterChanged();
259
267
  })
260
268
  .finally(() => {
261
269
  pendingTextures.delete(decoding);
@@ -294,6 +302,47 @@ function rasterTexture(
294
302
  return texture;
295
303
  }
296
304
 
305
+ /**
306
+ * A UDIM IMAGE AS A GRAPH SAMPLES IT: every tile a layer of one array texture
307
+ * (each at its layer's origin, the layer as large as the largest tile), and
308
+ * the tile map `blender_tile_lookup` reads -- row 0 each tile's layer (-1 for
309
+ * none), row 1 its offset and scale within the layer, Blender's
310
+ * `GPU_image_tiled` layout.
311
+ */
312
+ function udimTextures(
313
+ name: string,
314
+ frame: NonNullable<z.infer<typeof rasterImageSchema>['tiles']>,
315
+ colorspace: 'sRGB' | 'data',
316
+ ): {tiles: THREE.DataArrayTexture; map: THREE.DataTexture; frame: typeof frame} {
317
+ if (frame.length === 0) throw new Error(`Runtime image ${name} is tiled with no tiles`);
318
+ const width = Math.max(...frame.map(t => t.width));
319
+ const height = Math.max(...frame.map(t => t.height));
320
+ const layers = new Uint8Array(width * height * 4 * frame.length);
321
+ const count = Math.max(...frame.map(t => t.number - 1001)) + 1;
322
+ const map = new Float32Array(count * 2 * 4);
323
+ for (let i = 0; i < count; i++) map[i * 4] = -1;
324
+ frame.forEach((tile, layer) => {
325
+ const rgba = tile.rgba ?? (tile.rgbaBase64 ? bytesFromBase64(tile.rgbaBase64) : undefined);
326
+ if (!rgba) throw new Error(`Runtime image ${name} tile ${tile.number} carries no raster`);
327
+ for (let row = 0; row < tile.height; row++)
328
+ layers.set(rgba.subarray(row * tile.width * 4, (row + 1) * tile.width * 4),
329
+ ((layer * height + row) * width) * 4);
330
+ const index = tile.number - 1001;
331
+ map[index * 4] = layer;
332
+ map.set([0, 0, tile.width / width, tile.height / height], (count + index) * 4);
333
+ });
334
+ const tiles = new THREE.DataArrayTexture(layers, width, height, frame.length);
335
+ tiles.colorSpace = colorspace === 'data' ? THREE.NoColorSpace : THREE.SRGBColorSpace;
336
+ tiles.magFilter = THREE.LinearFilter;
337
+ tiles.minFilter = THREE.LinearMipmapLinearFilter;
338
+ tiles.generateMipmaps = true;
339
+ tiles.needsUpdate = true;
340
+ const mapTexture = new THREE.DataTexture(map, count, 2, THREE.RGBAFormat, THREE.FloatType);
341
+ mapTexture.magFilter = mapTexture.minFilter = THREE.NearestFilter;
342
+ mapTexture.needsUpdate = true;
343
+ return {tiles, map: mapTexture, frame};
344
+ }
345
+
297
346
  /** One image the frame carries in full, in the only two shapes it comes in.
298
347
  *
299
348
  * A RASTER states its size, because a `DataTexture` cannot be built without
@@ -312,6 +361,14 @@ const rasterImageSchema = z
312
361
  /** Blender's `colorspace_settings.name`, reduced to the two the sampler
313
362
  * tells apart. Absent means sRGB. */
314
363
  colorspace: z.enum(['sRGB', 'data']).optional(),
364
+ /** A UDIM image's tiles, by tile number (the door's `write_image`). */
365
+ tiles: z.array(z.object({
366
+ number: z.number().int().min(1001),
367
+ width: z.number().int().positive(),
368
+ height: z.number().int().positive(),
369
+ rgba: z.instanceof(Uint8Array).optional(),
370
+ rgbaBase64: z.string().optional(),
371
+ }).strict()).optional(),
315
372
  })
316
373
  .strict();
317
374
  const pngImageSchema = z
@@ -360,6 +417,11 @@ const materialSchema = z
360
417
  .object({ color: z.tuple([scalar, scalar, scalar]), strength: scalar })
361
418
  .strict()
362
419
  .optional(),
420
+ /** The node graph driving a Principled BSDF's or Emission's linked
421
+ * inputs (`session.py`'s `material_graph`), compiled by
422
+ * `blender-node-graph.ts`. The constants above still apply to every
423
+ * input the graph does not carry. */
424
+ graph: materialGraphSchema.optional(),
363
425
  })
364
426
  .strict();
365
427
  /** THE FRAME CONTRACT, and the reason it is exported: the C++ export door
@@ -410,6 +472,11 @@ export const frameSchema = z
410
472
  render_visible: z.boolean().default(true),
411
473
  selected: z.boolean(),
412
474
  parent: z.string().nullable(),
475
+ /** A manual texture space (`[location, size]`), which Generated
476
+ * coordinates map through; absent means Blender's automatic one,
477
+ * the evaluated bounds (`blender-graph-material.ts`'s orco). */
478
+ texspace: z.tuple([z.tuple([scalar, scalar, scalar]), z.tuple([scalar, scalar, scalar])]).optional(),
479
+ default_color: z.string().optional(),
413
480
  })
414
481
  .strict(),
415
482
  ),
@@ -505,6 +572,8 @@ export class BlenderRuntimeView {
505
572
  revision: number;
506
573
  png?: Uint8Array;
507
574
  ready?: Promise<void>;
575
+ /** A UDIM image's tiles as a graph samples them (`udimTextures`). */
576
+ udim?: {tiles: THREE.DataArrayTexture; map: THREE.DataTexture; frame: NonNullable<z.infer<typeof rasterImageSchema>['tiles']>};
508
577
  }
509
578
  >();
510
579
  /** Which image each material's `map` is currently pointing at. */
@@ -512,6 +581,24 @@ export class BlenderRuntimeView {
512
581
  /** Which runtime image each material's roughness map is, by material id. */
513
582
  private readonly roughnessTextureNames = new Map<string, string>();
514
583
  private readonly textureSamplers = new BlenderTextureSamplers();
584
+ /** Each material's graph image samplers, by sampler key. */
585
+ private readonly graphSamplers = new Map<string, Set<string>>();
586
+ /** A view built for one photograph: its graph programs compile before its
587
+ * one draw rather than off it (`setMaterialGraph`'s `immediate`). */
588
+ private photograph = false;
589
+ /** Compiled graphs by their JSON: a frame re-sends every material. */
590
+ private readonly graphs = new Map<string, CompiledGraph>();
591
+
592
+ private compiledGraph(graph: z.infer<typeof materialGraphSchema>): CompiledGraph {
593
+ const text = JSON.stringify(graph);
594
+ let compiled = this.graphs.get(text);
595
+ if (!compiled) {
596
+ compiled = compileMaterialGraph(graph);
597
+ if (this.graphs.size > 256) this.graphs.clear();
598
+ this.graphs.set(text, compiled);
599
+ }
600
+ return compiled;
601
+ }
515
602
  private frame: Frame | null = null;
516
603
  private readonly retiredSessions = new Set<string>();
517
604
  private geometryBuilds = 0;
@@ -550,6 +637,18 @@ export class BlenderRuntimeView {
550
637
  };
551
638
  }
552
639
 
640
+ /** EVERY CHANGE TO WHAT THIS VIEW DRAWS: each applied frame, and each piece
641
+ * of work that finishes after one (`blender-presenter-change.ts`). A stage
642
+ * that subscribes may draw only when this fires. */
643
+ onChange(listener: () => void): () => void {
644
+ const stopFrames = this.subscribeFrames(listener);
645
+ const stopWork = onPresenterChange(listener);
646
+ return () => {
647
+ stopFrames();
648
+ stopWork();
649
+ };
650
+ }
651
+
553
652
  /** The Blender datablock NAME of a presented object, or null when this
554
653
  * object is not one of the engine's (an editor helper, a light three.js
555
654
  * parents under one). It is a lookup by IDENTITY, never by `object.name`:
@@ -987,6 +1086,12 @@ export class BlenderRuntimeView {
987
1086
  if ('width' in data) {
988
1087
  const rgba = data.rgba ?? (data.rgbaBase64 ? bytesFromBase64(data.rgbaBase64) : undefined);
989
1088
  if (!rgba) throw new Error(`Runtime image ${name} carries no raster`);
1089
+ const udim = data.tiles ? udimTextures(name, data.tiles, data.colorspace ?? 'sRGB') : undefined;
1090
+ if (held?.udim) {
1091
+ held.udim.tiles.dispose();
1092
+ held.udim.map.dispose();
1093
+ delete held.udim;
1094
+ }
990
1095
  // A REPAINT AT THE SAME SIZE IS AN UPLOAD, not a new texture: the
991
1096
  // bytes go into the resident image and three re-uploads it, so every
992
1097
  // material pointing at it keeps pointing at it. Native does exactly
@@ -997,6 +1102,7 @@ export class BlenderRuntimeView {
997
1102
  image.data.set(rgba);
998
1103
  held.texture.needsUpdate = true;
999
1104
  held.revision = data.revision;
1105
+ if (udim) held.udim = udim;
1000
1106
  continue;
1001
1107
  }
1002
1108
  }
@@ -1006,6 +1112,7 @@ export class BlenderRuntimeView {
1006
1112
  width: data.width,
1007
1113
  height: data.height,
1008
1114
  revision: data.revision,
1115
+ ...(udim ? {udim} : {}),
1009
1116
  });
1010
1117
  continue;
1011
1118
  }
@@ -1016,6 +1123,8 @@ export class BlenderRuntimeView {
1016
1123
  const png = data.png ?? (data.pngBase64 ? bytesFromBase64(data.pngBase64) : undefined);
1017
1124
  if (!png) throw new Error(`Runtime image ${name} carries no bytes`);
1018
1125
  held?.texture.dispose();
1126
+ held?.udim?.tiles.dispose();
1127
+ held?.udim?.map.dispose();
1019
1128
  const { texture, ready } = loadPngTexture(png);
1020
1129
  texture.colorSpace = THREE.SRGBColorSpace;
1021
1130
  this.textures.set(name, {
@@ -1063,7 +1172,10 @@ export class BlenderRuntimeView {
1063
1172
  // carries no alpha on a texture, and Blender's Alpha is a separate
1064
1173
  // Principled socket.
1065
1174
  material.opacity = data.color[3];
1066
- const transparent = material.opacity < 1;
1175
+ // A graph-driven Alpha, or a Transparent BSDF in a shader mix, is per
1176
+ // pixel; the constant cannot say whether the surface is see-through, so
1177
+ // such a graph is.
1178
+ const transparent = material.opacity < 1 || (data.graph !== undefined && graphSeeThrough(data.graph));
1067
1179
  if (material.transparent !== transparent) {
1068
1180
  material.transparent = transparent;
1069
1181
  material.needsUpdate = true;
@@ -1172,6 +1284,37 @@ export class BlenderRuntimeView {
1172
1284
  material.needsUpdate = true;
1173
1285
  }
1174
1286
  }
1287
+ // THE NODE GRAPH, when the session shipped one: compiled once per
1288
+ // distinct graph, its images through the same cache and per-input
1289
+ // sampler ownership as the maps above.
1290
+ const graph = data.graph ? this.compiledGraph(data.graph) : null;
1291
+ const graphSamplers = new Set<string>();
1292
+ setMaterialGraph(material, graph, (image) => {
1293
+ const held = this.textures.get(image.name);
1294
+ if (!held)
1295
+ throw new Error(
1296
+ `${UNKNOWN_IMAGE}: the presenter does not hold runtime image ${image.name} ` +
1297
+ `(revision ${image.revision})`,
1298
+ );
1299
+ if (image.tiled) {
1300
+ if (!held.udim) throw new Error(`${UNKNOWN_IMAGE}: runtime image ${image.name} arrived without its UDIM tiles`);
1301
+ return held.udim;
1302
+ }
1303
+ const key = `${id}:graph:${image.node}`;
1304
+ graphSamplers.add(key);
1305
+ const texture = this.textureSamplers.get(key, held.texture, image.extension, held.ready);
1306
+ const filter = image.closest ? THREE.NearestFilter : THREE.LinearFilter;
1307
+ const minFilter = image.closest ? THREE.NearestFilter
1308
+ : image.mipmap ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter;
1309
+ if (texture.magFilter !== filter || texture.minFilter !== minFilter) {
1310
+ texture.magFilter = filter;
1311
+ texture.minFilter = minFilter;
1312
+ texture.needsUpdate = true;
1313
+ }
1314
+ return texture;
1315
+ }, this.photograph);
1316
+ for (const key of this.graphSamplers.get(id) ?? []) if (!graphSamplers.has(key)) this.textureSamplers.delete(key);
1317
+ this.graphSamplers.set(id, graphSamplers);
1175
1318
  this.materials.set(id, material);
1176
1319
  }
1177
1320
  for (const obj of next.objects) {
@@ -1201,12 +1344,17 @@ export class BlenderRuntimeView {
1201
1344
  object.matrix.premultiply(parent.invert());
1202
1345
  }
1203
1346
  object.matrix.decompose(object.position, object.quaternion, object.scale);
1347
+ if (obj.texspace) object.userData['blenderTexspace'] = obj.texspace;
1348
+ else delete object.userData['blenderTexspace'];
1349
+ if (obj.default_color !== undefined) object.userData['blenderDefaultColor'] = obj.default_color;
1350
+ else delete object.userData['blenderDefaultColor'];
1204
1351
  if (obj.mesh !== null) {
1205
1352
  const mesh = object as THREE.Mesh;
1206
1353
  mesh.geometry = this.meshes.get(obj.mesh)!.geometry;
1207
1354
  mesh.material = obj.materials.length
1208
1355
  ? obj.materials.map((id) => (id === null ? this.fallback : this.materials.get(id)!))
1209
1356
  : this.fallback;
1357
+ prepareGraphGeometry(mesh);
1210
1358
  }
1211
1359
  if (obj.light) {
1212
1360
  // The light hangs on its object's own node, so the object's matrix
@@ -1247,6 +1395,8 @@ export class BlenderRuntimeView {
1247
1395
  for (const [id, material] of this.materials)
1248
1396
  if (!next.materials[id]) {
1249
1397
  for (const slot of ['normal', 'roughness', 'color']) this.textureSamplers.delete(`${id}:${slot}`);
1398
+ for (const key of this.graphSamplers.get(id) ?? []) this.textureSamplers.delete(key);
1399
+ this.graphSamplers.delete(id);
1250
1400
  material.dispose();
1251
1401
  this.materials.delete(id);
1252
1402
  }
@@ -1333,6 +1483,12 @@ export class BlenderRuntimeView {
1333
1483
  if (!data) throw new Error(`Blender capture mesh ${id} is missing UV layer ${name}`);
1334
1484
  return {name, data};
1335
1485
  }),
1486
+ orco: geometry.userData['blenderOrcoFromDoor'] ? attribute('blenderOrco') : null,
1487
+ attributeLayers: (geometry.userData['blenderAttributes'] as string[] | undefined ?? []).map(name => {
1488
+ const data = attribute(graphAttributeName(name));
1489
+ if (!data) throw new Error(`Blender capture mesh ${id} is missing attribute ${name}`);
1490
+ return {name, data};
1491
+ }),
1336
1492
  indices: index
1337
1493
  ? new Uint32Array(index.array)
1338
1494
  : Uint32Array.from({ length: positions.length / 3 }, (_, i) => i),
@@ -1347,9 +1503,15 @@ export class BlenderRuntimeView {
1347
1503
  frame.volumes[id] = volumeSchema.parse(JSON.parse(signature));
1348
1504
  const images = new Set(
1349
1505
  Object.values(frame.materials).flatMap((material) =>
1350
- [material.texture?.image.name, material.roughness_texture?.image.name, material.normal_texture?.image.name].filter(
1351
- (name): name is string => name !== undefined,
1352
- ),
1506
+ [
1507
+ material.texture?.image.name,
1508
+ material.roughness_texture?.image.name,
1509
+ material.normal_texture?.image.name,
1510
+ // The images a material's node graph samples.
1511
+ ...Object.values(material.graph?.nodes ?? {}).map(
1512
+ (node) => (node.props['image'] as { name?: string } | null | undefined)?.name,
1513
+ ),
1514
+ ].filter((name): name is string => name !== undefined),
1353
1515
  ),
1354
1516
  );
1355
1517
  for (const name of images) {
@@ -1367,11 +1529,13 @@ export class BlenderRuntimeView {
1367
1529
  height: held.height,
1368
1530
  rgba: data.slice(),
1369
1531
  colorspace: held.texture.colorSpace === THREE.NoColorSpace ? 'data' : 'sRGB',
1532
+ ...(held.udim ? {tiles: held.udim.frame} : {}),
1370
1533
  };
1371
1534
  }
1372
1535
  }
1373
1536
  const effect = this.createWorldVolumePass();
1374
1537
  const detached = new BlenderRuntimeView();
1538
+ detached.photograph = true;
1375
1539
  let disposed = false;
1376
1540
  const dispose = () => {
1377
1541
  if (disposed) return;
@@ -1461,7 +1625,11 @@ export class BlenderRuntimeView {
1461
1625
  // The pictures go with the session that sent them: the cache OWNS every
1462
1626
  // texture in it (a material only points at one), so this is the one place
1463
1627
  // they are disposed, beside the meshes for the same reason.
1464
- for (const { texture } of this.textures.values()) texture.dispose();
1628
+ for (const { texture, udim } of this.textures.values()) {
1629
+ texture.dispose();
1630
+ udim?.tiles.dispose();
1631
+ udim?.map.dispose();
1632
+ }
1465
1633
  this.textures.clear();
1466
1634
  this.textureNames.clear();
1467
1635
  this.roughnessTextureNames.clear();