@volter/blender-engine 0.1.5 → 0.1.7

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,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';
@@ -24,6 +26,11 @@ import {
24
26
  } from './blender-runtime-lighting';
25
27
  import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
26
28
  import { volumeMesh, volumeSchema } from './blender-runtime-volume';
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';
32
+ import {worldMedium, WorldVolumePass} from './blender-world-volume';
33
+ import { BlenderTextureSamplers } from './blender-texture-samplers';
27
34
  import { WeightOverlay, weightsSchema } from './blender-runtime-weights';
28
35
 
29
36
  const scalar = z.number().finite();
@@ -103,6 +110,9 @@ const drawArraysSchema = z
103
110
  positions: z.instanceof(Float32Array),
104
111
  normals: z.instanceof(Float32Array).nullable(),
105
112
  uv: z.instanceof(Float32Array).nullable(),
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(),
106
116
  indices: z.instanceof(Uint32Array),
107
117
  groups: z.array(
108
118
  z
@@ -158,6 +168,8 @@ const unchangedMeshSchema = z
158
168
  const columnsSchema = z
159
169
  .object({
160
170
  co: z.instanceof(Float64Array),
171
+ cornerNormal: z.instanceof(Float32Array).optional(),
172
+ orco: z.instanceof(Float32Array).optional(),
161
173
  faceStart: z.instanceof(Uint32Array),
162
174
  corner: z.instanceof(Uint32Array),
163
175
  cornerEdge: z.instanceof(Int32Array),
@@ -251,6 +263,7 @@ function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promi
251
263
  }
252
264
  texture.image = bitmap;
253
265
  texture.needsUpdate = true;
266
+ presenterChanged();
254
267
  })
255
268
  .finally(() => {
256
269
  pendingTextures.delete(decoding);
@@ -289,6 +302,47 @@ function rasterTexture(
289
302
  return texture;
290
303
  }
291
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
+
292
346
  /** One image the frame carries in full, in the only two shapes it comes in.
293
347
  *
294
348
  * A RASTER states its size, because a `DataTexture` cannot be built without
@@ -307,6 +361,14 @@ const rasterImageSchema = z
307
361
  /** Blender's `colorspace_settings.name`, reduced to the two the sampler
308
362
  * tells apart. Absent means sRGB. */
309
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(),
310
372
  })
311
373
  .strict();
312
374
  const pngImageSchema = z
@@ -321,8 +383,8 @@ const frameImageSchema = z.union([rasterImageSchema, pngImageSchema]);
321
383
  const textureReferenceSchema = z
322
384
  .object({
323
385
  image: z.object({ name: z.string(), revision: z.number().int().nonnegative() }).strict(),
324
- /** Blender's `extension`; CLIP never arrives -- the reducer refuses it. */
325
- extension: z.enum(['REPEAT', 'EXTEND', 'MIRROR']).default('REPEAT'),
386
+ /** CLIP is transparent black outside the image, applied in the shader. */
387
+ extension: z.enum(['REPEAT', 'EXTEND', 'MIRROR', 'CLIP']).default('REPEAT'),
326
388
  uv: z.string(),
327
389
  /** A constant the texture is multiplied by -- Blender's MULTIPLY mix at
328
390
  * full factor, which is what `map * color` already is. */
@@ -337,6 +399,11 @@ const materialSchema = z
337
399
  metallic: scalar,
338
400
  transmission: scalar,
339
401
  ior: scalar,
402
+ physical: physicalMaterialSchema.optional(),
403
+ normal_texture: textureReferenceSchema.optional(),
404
+ normal_strength: scalar.default(1),
405
+ normal_space: z.enum(['TANGENT', 'OBJECT']).default('TANGENT'),
406
+ normal_directx: z.boolean().default(false),
340
407
  /** A Base Color image, NAMED. Its bytes travel in the frame's `images`
341
408
  * map, once per `(name, revision)`. */
342
409
  texture: textureReferenceSchema.optional(),
@@ -350,6 +417,11 @@ const materialSchema = z
350
417
  .object({ color: z.tuple([scalar, scalar, scalar]), strength: scalar })
351
418
  .strict()
352
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(),
353
425
  })
354
426
  .strict();
355
427
  /** THE FRAME CONTRACT, and the reason it is exported: the C++ export door
@@ -400,6 +472,11 @@ export const frameSchema = z
400
472
  render_visible: z.boolean().default(true),
401
473
  selected: z.boolean(),
402
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(),
403
480
  })
404
481
  .strict(),
405
482
  ),
@@ -480,7 +557,7 @@ export class BlenderRuntimeView {
480
557
  private readonly armatureRoot = new THREE.Group();
481
558
  private readonly weightRoot = new THREE.Group();
482
559
  private rendered = false;
483
- private readonly fallback = new THREE.MeshStandardMaterial({ color: 0xb9bec6, roughness: 0.72 });
560
+ private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72 });
484
561
  /** Base Color images, by image name -- ONE texture per image however many
485
562
  * materials read it, and the cache OWNS it: a material points at one and
486
563
  * never disposes it. Held with the size and revision the resident bytes
@@ -495,12 +572,33 @@ export class BlenderRuntimeView {
495
572
  revision: number;
496
573
  png?: Uint8Array;
497
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']>};
498
577
  }
499
578
  >();
500
579
  /** Which image each material's `map` is currently pointing at. */
501
580
  private readonly textureNames = new Map<string, string>();
502
581
  /** Which runtime image each material's roughness map is, by material id. */
503
582
  private readonly roughnessTextureNames = new Map<string, string>();
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
+ }
504
602
  private frame: Frame | null = null;
505
603
  private readonly retiredSessions = new Set<string>();
506
604
  private geometryBuilds = 0;
@@ -539,6 +637,18 @@ export class BlenderRuntimeView {
539
637
  };
540
638
  }
541
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
+
542
652
  /** The Blender datablock NAME of a presented object, or null when this
543
653
  * object is not one of the engine's (an editor helper, a light three.js
544
654
  * parents under one). It is a lookup by IDENTITY, never by `object.name`:
@@ -706,6 +816,8 @@ export class BlenderRuntimeView {
706
816
  * distinction Blender draws between its solid viewport and a render.
707
817
  */
708
818
  async setRendered(rendered: boolean, camera?: THREE.Camera): Promise<void> {
819
+ const extinction = (rendered ? worldMedium(this.frame?.world)?.extinction : null) ?? new THREE.Vector3();
820
+ for (const material of [...this.materials.values(), this.fallback]) applyWorldExtinction(material, extinction);
709
821
  // An area light cannot be DRAWN until its lookup tables are uploaded, and a
710
822
  // render is one photograph with no second chance at it.
711
823
  // A sky is derived off the main thread now, so a render waits for it the
@@ -974,6 +1086,12 @@ export class BlenderRuntimeView {
974
1086
  if ('width' in data) {
975
1087
  const rgba = data.rgba ?? (data.rgbaBase64 ? bytesFromBase64(data.rgbaBase64) : undefined);
976
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
+ }
977
1095
  // A REPAINT AT THE SAME SIZE IS AN UPLOAD, not a new texture: the
978
1096
  // bytes go into the resident image and three re-uploads it, so every
979
1097
  // material pointing at it keeps pointing at it. Native does exactly
@@ -984,6 +1102,7 @@ export class BlenderRuntimeView {
984
1102
  image.data.set(rgba);
985
1103
  held.texture.needsUpdate = true;
986
1104
  held.revision = data.revision;
1105
+ if (udim) held.udim = udim;
987
1106
  continue;
988
1107
  }
989
1108
  }
@@ -993,6 +1112,7 @@ export class BlenderRuntimeView {
993
1112
  width: data.width,
994
1113
  height: data.height,
995
1114
  revision: data.revision,
1115
+ ...(udim ? {udim} : {}),
996
1116
  });
997
1117
  continue;
998
1118
  }
@@ -1003,6 +1123,8 @@ export class BlenderRuntimeView {
1003
1123
  const png = data.png ?? (data.pngBase64 ? bytesFromBase64(data.pngBase64) : undefined);
1004
1124
  if (!png) throw new Error(`Runtime image ${name} carries no bytes`);
1005
1125
  held?.texture.dispose();
1126
+ held?.udim?.tiles.dispose();
1127
+ held?.udim?.map.dispose();
1006
1128
  const { texture, ready } = loadPngTexture(png);
1007
1129
  texture.colorSpace = THREE.SRGBColorSpace;
1008
1130
  this.textures.set(name, {
@@ -1050,7 +1172,10 @@ export class BlenderRuntimeView {
1050
1172
  // carries no alpha on a texture, and Blender's Alpha is a separate
1051
1173
  // Principled socket.
1052
1174
  material.opacity = data.color[3];
1053
- 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));
1054
1179
  if (material.transparent !== transparent) {
1055
1180
  material.transparent = transparent;
1056
1181
  material.needsUpdate = true;
@@ -1059,6 +1184,33 @@ export class BlenderRuntimeView {
1059
1184
  material.metalness = data.metallic;
1060
1185
  material.transmission = data.transmission;
1061
1186
  material.ior = data.ior;
1187
+ applyPhysicalMaterial(material, data.physical, {
1188
+ map: data.texture?.extension === 'CLIP',
1189
+ roughness: data.roughness_texture?.extension === 'CLIP',
1190
+ normal: data.normal_texture?.extension === 'CLIP',
1191
+ });
1192
+ const normalName = data.normal_texture?.image.name;
1193
+ if (normalName === undefined) {
1194
+ if (material.normalMap) {
1195
+ this.textureSamplers.delete(`${id}:normal`);
1196
+ material.normalMap = null;
1197
+ material.needsUpdate = true;
1198
+ }
1199
+ } else {
1200
+ const held = this.textures.get(normalName);
1201
+ if (!held) throw new Error(`${UNKNOWN_IMAGE}: missing normal image ${normalName}`);
1202
+ const normalMap = this.textureSamplers.get(`${id}:normal`, held.texture, data.normal_texture!.extension, held.ready, data.normal_texture!.uv);
1203
+ if (material.normalMap !== normalMap) {
1204
+ material.normalMap = normalMap;
1205
+ material.needsUpdate = true;
1206
+ }
1207
+ const normalType = data.normal_space === 'OBJECT' ? THREE.ObjectSpaceNormalMap : THREE.TangentSpaceNormalMap;
1208
+ if (material.normalMapType !== normalType) {
1209
+ material.normalMapType = normalType;
1210
+ material.needsUpdate = true;
1211
+ }
1212
+ material.normalScale.set(data.normal_strength, data.normal_strength * (data.normal_directx ? -1 : 1));
1213
+ }
1062
1214
  if (data.emission) {
1063
1215
  material.emissive.setRGB(
1064
1216
  data.emission.color[0],
@@ -1077,6 +1229,7 @@ export class BlenderRuntimeView {
1077
1229
  const roughnessWanted = data.roughness_texture?.image.name ?? null;
1078
1230
  if (roughnessWanted === null) {
1079
1231
  if (this.roughnessTextureNames.has(id)) {
1232
+ this.textureSamplers.delete(`${id}:roughness`);
1080
1233
  material.roughnessMap = null;
1081
1234
  this.roughnessTextureNames.delete(id);
1082
1235
  material.needsUpdate = true;
@@ -1088,18 +1241,8 @@ export class BlenderRuntimeView {
1088
1241
  `${UNKNOWN_IMAGE}: the presenter does not hold runtime image ${roughnessWanted} ` +
1089
1242
  `(revision ${data.roughness_texture!.image.revision})`,
1090
1243
  );
1091
- const roughnessMap = heldRoughness.texture;
1092
- const roughnessWrap =
1093
- data.roughness_texture!.extension === 'EXTEND'
1094
- ? THREE.ClampToEdgeWrapping
1095
- : data.roughness_texture!.extension === 'MIRROR'
1096
- ? THREE.MirroredRepeatWrapping
1097
- : THREE.RepeatWrapping;
1098
- if (roughnessMap.wrapS !== roughnessWrap || roughnessMap.wrapT !== roughnessWrap) {
1099
- roughnessMap.wrapS = roughnessWrap;
1100
- roughnessMap.wrapT = roughnessWrap;
1101
- if (roughnessMap.image) roughnessMap.needsUpdate = true;
1102
- }
1244
+ const roughnessMap = this.textureSamplers.get(`${id}:roughness`, heldRoughness.texture,
1245
+ data.roughness_texture!.extension, heldRoughness.ready, data.roughness_texture!.uv);
1103
1246
  if (material.roughnessMap !== roughnessMap) {
1104
1247
  material.roughnessMap = roughnessMap;
1105
1248
  this.roughnessTextureNames.set(id, roughnessWanted);
@@ -1113,6 +1256,7 @@ export class BlenderRuntimeView {
1113
1256
  const wanted = data.texture?.image.name ?? null;
1114
1257
  if (wanted === null) {
1115
1258
  if (this.textureNames.has(id)) {
1259
+ this.textureSamplers.delete(`${id}:color`);
1116
1260
  material.map = null;
1117
1261
  this.textureNames.delete(id);
1118
1262
  material.needsUpdate = true;
@@ -1129,35 +1273,7 @@ export class BlenderRuntimeView {
1129
1273
  `${UNKNOWN_IMAGE}: the presenter does not hold runtime image ${wanted} ` +
1130
1274
  `(revision ${data.texture!.image.revision})`,
1131
1275
  );
1132
- const map = held.texture;
1133
- // REPEAT, because that is what Blender's Image Texture node does:
1134
- // its `extension` defaults to REPEAT (asked the oracle), while
1135
- // three's wrapping defaults to ClampToEdge. Any UV outside [0,1]
1136
- // then smears the edge pixel across the whole surface, which is the
1137
- // "weirdly stretched" texture the owner saw on the courtyard.
1138
- //
1139
- // `extension`, as the description carries it. CLIP never arrives:
1140
- // the reducer refuses it by name, which is why three modes are
1141
- // mapped here and not four.
1142
- const wrap =
1143
- data.texture!.extension === 'EXTEND'
1144
- ? THREE.ClampToEdgeWrapping
1145
- : data.texture!.extension === 'MIRROR'
1146
- ? THREE.MirroredRepeatWrapping
1147
- : THREE.RepeatWrapping;
1148
- if (map.wrapS !== wrap || map.wrapT !== wrap) {
1149
- map.wrapS = wrap;
1150
- map.wrapT = wrap;
1151
- // ONLY IF THERE IS AN IMAGE TO RE-UPLOAD. A wrap change is a sampler
1152
- // parameter and three re-reads it when the texture's version moves,
1153
- // so an already-resident map needs the bump -- but bumping one whose
1154
- // `createImageBitmap` is still in flight is exactly what three warns
1155
- // about, and it did, 34 times on one courtyard replay:
1156
- // `THREE.WebGLRenderer: Texture marked for update but no image data
1157
- // found`. The decode sets `needsUpdate` itself when the bitmap
1158
- // lands, and it carries whatever wrap is on the texture by then.
1159
- if (map.image) map.needsUpdate = true;
1160
- }
1276
+ const map = this.textureSamplers.get(`${id}:color`, held.texture, data.texture!.extension, held.ready, data.texture!.uv);
1161
1277
  // RE-POINTED WHENEVER THE TEXTURE OBJECT CHANGED, which is how a
1162
1278
  // RESIZED image reaches a material: the cache disposes the old texture
1163
1279
  // and builds a new one. A repaint at the same size keeps this exact
@@ -1168,6 +1284,37 @@ export class BlenderRuntimeView {
1168
1284
  material.needsUpdate = true;
1169
1285
  }
1170
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);
1171
1318
  this.materials.set(id, material);
1172
1319
  }
1173
1320
  for (const obj of next.objects) {
@@ -1197,12 +1344,17 @@ export class BlenderRuntimeView {
1197
1344
  object.matrix.premultiply(parent.invert());
1198
1345
  }
1199
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'];
1200
1351
  if (obj.mesh !== null) {
1201
1352
  const mesh = object as THREE.Mesh;
1202
1353
  mesh.geometry = this.meshes.get(obj.mesh)!.geometry;
1203
1354
  mesh.material = obj.materials.length
1204
1355
  ? obj.materials.map((id) => (id === null ? this.fallback : this.materials.get(id)!))
1205
1356
  : this.fallback;
1357
+ prepareGraphGeometry(mesh);
1206
1358
  }
1207
1359
  if (obj.light) {
1208
1360
  // The light hangs on its object's own node, so the object's matrix
@@ -1242,6 +1394,9 @@ export class BlenderRuntimeView {
1242
1394
  }
1243
1395
  for (const [id, material] of this.materials)
1244
1396
  if (!next.materials[id]) {
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);
1245
1400
  material.dispose();
1246
1401
  this.materials.delete(id);
1247
1402
  }
@@ -1323,6 +1478,17 @@ export class BlenderRuntimeView {
1323
1478
  positions,
1324
1479
  normals: attribute('normal'),
1325
1480
  uv: attribute('uv'),
1481
+ uvLayers: Object.entries(geometry.userData['blenderUvChannels'] as Record<string, number> ?? {}).map(([name, channel]) => {
1482
+ const data = attribute(`uv${channel}`);
1483
+ if (!data) throw new Error(`Blender capture mesh ${id} is missing UV layer ${name}`);
1484
+ return {name, data};
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
+ }),
1326
1492
  indices: index
1327
1493
  ? new Uint32Array(index.array)
1328
1494
  : Uint32Array.from({ length: positions.length / 3 }, (_, i) => i),
@@ -1337,9 +1503,15 @@ export class BlenderRuntimeView {
1337
1503
  frame.volumes[id] = volumeSchema.parse(JSON.parse(signature));
1338
1504
  const images = new Set(
1339
1505
  Object.values(frame.materials).flatMap((material) =>
1340
- [material.texture?.image.name, material.roughness_texture?.image.name].filter(
1341
- (name): name is string => name !== undefined,
1342
- ),
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),
1343
1515
  ),
1344
1516
  );
1345
1517
  for (const name of images) {
@@ -1357,14 +1529,18 @@ export class BlenderRuntimeView {
1357
1529
  height: held.height,
1358
1530
  rgba: data.slice(),
1359
1531
  colorspace: held.texture.colorSpace === THREE.NoColorSpace ? 'data' : 'sRGB',
1532
+ ...(held.udim ? {tiles: held.udim.frame} : {}),
1360
1533
  };
1361
1534
  }
1362
1535
  }
1536
+ const effect = this.createWorldVolumePass();
1363
1537
  const detached = new BlenderRuntimeView();
1538
+ detached.photograph = true;
1364
1539
  let disposed = false;
1365
1540
  const dispose = () => {
1366
1541
  if (disposed) return;
1367
1542
  disposed = true;
1543
+ effect?.dispose();
1368
1544
  detached.dispose();
1369
1545
  detached.root.removeFromParent();
1370
1546
  };
@@ -1378,6 +1554,7 @@ export class BlenderRuntimeView {
1378
1554
  throw error;
1379
1555
  }
1380
1556
  return {
1557
+ effect,
1381
1558
  root: detached.root,
1382
1559
  session: source.session,
1383
1560
  revision: source.revision,
@@ -1389,6 +1566,11 @@ export class BlenderRuntimeView {
1389
1566
  };
1390
1567
  }
1391
1568
 
1569
+ createWorldVolumePass(): WorldVolumePass | undefined {
1570
+ const medium = worldMedium(this.frame?.world);
1571
+ return medium ? new WorldVolumePass(medium) : undefined;
1572
+ }
1573
+
1392
1574
  /** Keep the description that arrived with this frame. Called by the tab's
1393
1575
  * Blender host right after `applyFrame`, so a refused frame records
1394
1576
  * nothing: the record must describe what is DISPLAYED. */
@@ -1443,9 +1625,15 @@ export class BlenderRuntimeView {
1443
1625
  // The pictures go with the session that sent them: the cache OWNS every
1444
1626
  // texture in it (a material only points at one), so this is the one place
1445
1627
  // they are disposed, beside the meshes for the same reason.
1446
- 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
+ }
1447
1633
  this.textures.clear();
1448
1634
  this.textureNames.clear();
1635
+ this.roughnessTextureNames.clear();
1636
+ this.textureSamplers.clear();
1449
1637
  this.volumes.clear();
1450
1638
  this.objects.clear();
1451
1639
  this.meshes.clear();
@@ -0,0 +1,79 @@
1
+ import * as THREE from 'three';
2
+ import {presenterChanged} from './blender-presenter-change';
3
+
4
+ export type TextureExtension = 'REPEAT' | 'EXTEND' | 'MIRROR' | 'CLIP';
5
+
6
+ /** A shared material may draw meshes whose named layers have different
7
+ * channel positions. Resolve at Three's per-draw hook, before program choice. */
8
+ export function bindNamedUvChannels(material: THREE.MeshPhysicalMaterial, geometry: THREE.BufferGeometry): void {
9
+ const channels = geometry.userData['blenderUvChannels'] as Record<string, number> | undefined;
10
+ for (const texture of [material.map, material.roughnessMap, material.normalMap]) {
11
+ if (!texture) continue;
12
+ const name = texture.userData['blenderUvName'] as string | undefined;
13
+ let channel = name ? channels?.[name] : 0;
14
+ if (channel === undefined) {
15
+ // Blender's missing named surface attribute is zero, not the active UV
16
+ // map and not an error. Shared materials routinely cover meshes without
17
+ // that layer. Reserve a separate zero channel so other slots retain UV0.
18
+ channel = 9;
19
+ const count = geometry.getAttribute('position')?.count;
20
+ if (count === undefined) throw new Error('A textured Blender draw has no positions');
21
+ if (geometry.getAttribute('uv9')?.count !== count)
22
+ geometry.setAttribute('uv9', new THREE.BufferAttribute(new Float32Array(count*2),2));
23
+ }
24
+ if (texture.channel !== channel) {
25
+ texture.channel = channel;
26
+ material.needsUpdate = true;
27
+ }
28
+ }
29
+ }
30
+
31
+ /** Image pixels are shared; sampler state belongs to each material input.
32
+ * Clones share Three's Source, including asynchronous PNG decoding, but their
33
+ * upload versions are independent and must follow repaints explicitly. */
34
+ export class BlenderTextureSamplers {
35
+ private readonly bindings = new Map<string, {
36
+ source: THREE.Texture; texture: THREE.Texture; version: number; disposed: boolean;
37
+ }>();
38
+
39
+ get(key: string, source: THREE.Texture, extension: TextureExtension, ready?: Promise<void>, uv = ''): THREE.Texture {
40
+ let binding = this.bindings.get(key);
41
+ if (!binding || binding.source !== source) {
42
+ this.delete(key);
43
+ binding = {source, texture: source.clone(), version: -1, disposed: false};
44
+ this.bindings.set(key, binding);
45
+ const held = binding;
46
+ // The owner awaits and reports decode failures. Avoid an unhandled
47
+ // rejection here, and never upload a clone removed during the decode.
48
+ if (ready) void ready.then(() => {
49
+ if (!held.disposed) {
50
+ held.texture.needsUpdate = true;
51
+ held.version = source.version;
52
+ presenterChanged();
53
+ }
54
+ }, () => {});
55
+ }
56
+ const wrap = extension === 'REPEAT' ? THREE.RepeatWrapping :
57
+ extension === 'MIRROR' ? THREE.MirroredRepeatWrapping : THREE.ClampToEdgeWrapping;
58
+ const changed = binding.texture.wrapS !== wrap || binding.texture.wrapT !== wrap;
59
+ binding.texture.wrapS = binding.texture.wrapT = wrap;
60
+ binding.texture.userData['blenderUvName'] = uv;
61
+ if (source.image && (changed || binding.version !== source.version)) {
62
+ binding.texture.needsUpdate = true;
63
+ binding.version = source.version;
64
+ }
65
+ return binding.texture;
66
+ }
67
+
68
+ delete(key: string): void {
69
+ const binding = this.bindings.get(key);
70
+ if (!binding) return;
71
+ binding.disposed = true;
72
+ binding.texture.dispose();
73
+ this.bindings.delete(key);
74
+ }
75
+
76
+ clear(): void {
77
+ for (const key of this.bindings.keys()) this.delete(key);
78
+ }
79
+ }