@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.
- package/browser/runtime.ts +31 -2
- package/browser/session.py +563 -11
- package/browser/three/blender-frame-columns.ts +6 -0
- package/browser/three/blender-graph-material.ts +402 -0
- package/browser/three/blender-node-glsl.generated.ts +401 -0
- package/browser/three/blender-node-graph.ts +1026 -0
- package/browser/three/blender-physical-material.ts +162 -0
- package/browser/three/blender-presenter-change.ts +24 -0
- package/browser/three/blender-runtime-geometry.ts +112 -9
- package/browser/three/blender-runtime-lighting.ts +11 -0
- package/browser/three/blender-runtime-view.ts +237 -49
- package/browser/three/blender-texture-samplers.ts +79 -0
- package/browser/three/blender-world-volume.ts +235 -0
- package/browser/three/presenter.ts +6 -1
- package/package.json +1 -1
- package/wasm/BUNDLE.json +13 -25
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
1341
|
-
|
|
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())
|
|
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
|
+
}
|