@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.
@@ -0,0 +1,162 @@
1
+ /** Principled socket values stay Blender-owned; this file applies them to the
2
+ * actual Three material. Units cross the wire unchanged (turns and nanometres).
3
+ * Shader adjustments cover the coat IOR/tint and specular grazing behavior
4
+ * that MeshPhysicalMaterial does not expose as properties. */
5
+ import * as THREE from 'three';
6
+ import {z} from 'zod';
7
+ import {applyGraphShader, bindGraphDraw, graphProgramKey, setGraphViewport} from './blender-graph-material';
8
+ import {bindNamedUvChannels} from './blender-texture-samplers';
9
+
10
+ const scalar = z.number().finite();
11
+ const color = z.tuple([scalar, scalar, scalar]);
12
+ export const physicalMaterialSchema = z.object({
13
+ coat: scalar, coat_roughness: scalar, coat_ior: scalar, coat_tint: color,
14
+ sheen: scalar, sheen_roughness: scalar, sheen_tint: color,
15
+ anisotropy: scalar, anisotropy_rotation: scalar,
16
+ specular_level: scalar, specular_tint: color,
17
+ film_thickness: scalar, film_ior: scalar,
18
+ }).strict();
19
+ type Physical = z.infer<typeof physicalMaterialSchema>;
20
+ const defaults: Physical = {
21
+ coat: 0, coat_roughness: 0.03, coat_ior: 1.5, coat_tint: [1, 1, 1],
22
+ sheen: 0, sheen_roughness: 0.5, sheen_tint: [1, 1, 1],
23
+ anisotropy: 0, anisotropy_rotation: 0,
24
+ specular_level: 0.5, specular_tint: [1, 1, 1], film_thickness: 0, film_ior: 1.33,
25
+ };
26
+ const uniforms = new WeakMap<THREE.MeshPhysicalMaterial, {
27
+ blenderCoatIor: {value: number}; blenderCoatTint: {value: THREE.Color};
28
+ blenderMapClip: {value: boolean}; blenderRoughnessClip: {value: boolean}; blenderNormalClip: {value: boolean};
29
+ blenderWorldExtinction: {value: THREE.Vector3};
30
+ }>();
31
+
32
+ export function applyWorldExtinction(material: THREE.MeshPhysicalMaterial, extinction: THREE.Vector3): void {
33
+ if (!uniforms.has(material)) applyPhysicalMaterial(material);
34
+ uniforms.get(material)!.blenderWorldExtinction.value.copy(extinction);
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
+
59
+ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, input?: Physical,
60
+ clips: {map?: boolean; roughness?: boolean; normal?: boolean} = {}): void {
61
+ const data = input ?? defaults;
62
+ material.clearcoat = data.coat;
63
+ material.clearcoatRoughness = data.coat_roughness;
64
+ material.sheen = data.sheen;
65
+ material.sheenRoughness = data.sheen_roughness;
66
+ material.sheenColor.setRGB(...data.sheen_tint, THREE.LinearSRGBColorSpace);
67
+ material.anisotropy = data.anisotropy;
68
+ material.anisotropyRotation = data.anisotropy_rotation * Math.PI * 2;
69
+ material.specularIntensity = data.specular_level * 2;
70
+ material.specularColor.setRGB(...data.specular_tint, THREE.LinearSRGBColorSpace);
71
+ material.iridescence = data.film_thickness > 0 ? 1 : 0;
72
+ material.iridescenceIOR = data.film_ior;
73
+ material.iridescenceThicknessRange = [data.film_thickness, data.film_thickness];
74
+ let values = uniforms.get(material);
75
+ if (!values) {
76
+ values = {blenderCoatIor: {value: 1.5}, blenderCoatTint: {value: new THREE.Color(1, 1, 1)},
77
+ blenderMapClip: {value: false}, blenderRoughnessClip: {value: false}, blenderNormalClip: {value: false},
78
+ blenderWorldExtinction: {value: new THREE.Vector3()}};
79
+ uniforms.set(material, values);
80
+ const held = values;
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
+ };
87
+ material.onBeforeCompile = shader => {
88
+ // Three declares channels 0..3. Blender has eight named UV maps in
89
+ // addition to the default channel; only the used attributes survive GLSL.
90
+ shader.vertexShader = [4, 5, 6, 7, 8, 9].map(i => `attribute vec2 uv${i};`).join('\n') + '\n' + shader.vertexShader;
91
+ Object.assign(shader.uniforms, held);
92
+ shader.fragmentShader = `uniform float blenderCoatIor;
93
+ uniform vec3 blenderWorldExtinction;
94
+ vec3 blenderInfiniteTransmission() { return vec3(
95
+ blenderWorldExtinction.x>0.?0.:1., blenderWorldExtinction.y>0.?0.:1., blenderWorldExtinction.z>0.?0.:1.); }
96
+ uniform vec3 blenderCoatTint;
97
+ uniform bool blenderMapClip, blenderRoughnessClip, blenderNormalClip;
98
+ vec4 blenderImageSample(sampler2D image, vec2 uv, bool clipImage) {
99
+ vec4 pixel = texture2D(image, uv);
100
+ if (clipImage && (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))))) pixel = vec4(0.0);
101
+ return pixel;
102
+ }
103
+ ` + shader.fragmentShader;
104
+ const physical = THREE.ShaderChunk.lights_physical_fragment
105
+ .replace('material.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor );',
106
+ 'material.specularF90 = 1.0;')
107
+ .replace('material.clearcoatF0 = vec3( 0.04 );',
108
+ 'material.clearcoatF0 = vec3( pow2( ( blenderCoatIor - 1.0 ) / ( blenderCoatIor + 1.0 ) ) );');
109
+ const normals = THREE.ShaderChunk.normal_fragment_maps
110
+ .replace('normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;',
111
+ 'vec3 blenderBaseNormal = normal; normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0; normal.y *= normalScale.y < 0.0 ? -1.0 : 1.0;')
112
+ .replace('normal = normalize( normalMatrix * normal );',
113
+ 'normal = normalize(mix(blenderBaseNormal, normalize(normalMatrix * normal), max(0.0, normalScale.x)));')
114
+ .replace('mapN.xy *= normalScale;',
115
+ 'mapN.xy *= normalScale; mapN.z = mix(1.0, mapN.z, clamp(normalScale.x, 0.0, 1.0));')
116
+ .replaceAll('texture2D( normalMap, vNormalMapUv )', 'blenderImageSample(normalMap, vNormalMapUv, blenderNormalClip)');
117
+ const baseMap = THREE.ShaderChunk.map_fragment
118
+ .replace('texture2D( map, vMapUv )', 'blenderImageSample(map, vMapUv, blenderMapClip)')
119
+ // Blender's Color socket does not connect Image Alpha to Principled Alpha.
120
+ .replace('diffuseColor *= sampledDiffuseColor;', 'diffuseColor.rgb *= sampledDiffuseColor.rgb;');
121
+ const roughnessMap = THREE.ShaderChunk.roughnessmap_fragment
122
+ .replace('texture2D( roughnessMap, vRoughnessMapUv )', 'blenderImageSample(roughnessMap, vRoughnessMapUv, blenderRoughnessClip)');
123
+ const lighting = THREE.ShaderChunk.lights_fragment_begin
124
+ .replace('getPointLightInfo( pointLight, geometryPosition, directLight );',
125
+ 'getPointLightInfo( pointLight, geometryPosition, directLight ); directLight.color *= exp(-blenderWorldExtinction * length(pointLight.position-geometryPosition));')
126
+ .replace('getSpotLightInfo( spotLight, geometryPosition, directLight );',
127
+ 'getSpotLightInfo( spotLight, geometryPosition, directLight ); directLight.color *= exp(-blenderWorldExtinction * length(spotLight.position-geometryPosition));')
128
+ .replace('getDirectionalLightInfo( directionalLight, directLight );',
129
+ 'getDirectionalLightInfo( directionalLight, directLight ); directLight.color *= blenderInfiniteTransmission();')
130
+ // LTC integrates the rectangle without participating media. Its center
131
+ // distance is the attenuation approximation; the volume pass samples
132
+ // the rectangle itself. Neither is claimed as path-traced area lighting.
133
+ .replace('rectAreaLight = rectAreaLights[ i ];',
134
+ 'rectAreaLight = rectAreaLights[ i ]; rectAreaLight.color *= exp(-blenderWorldExtinction * length(rectAreaLight.position-geometryPosition));')
135
+ .replace('getAmbientLightIrradiance( ambientLightColor )', 'getAmbientLightIrradiance( ambientLightColor ) * blenderInfiniteTransmission()');
136
+ const environment = THREE.ShaderChunk.lights_fragment_maps
137
+ .replaceAll(/(getIBL(?:Irradiance|Radiance|AnisotropyRadiance)\([^;]+\))/g, '$1 * blenderInfiniteTransmission()');
138
+ shader.fragmentShader = shader.fragmentShader.replace('#include <lights_physical_fragment>', physical)
139
+ .replace('#include <lights_fragment_begin>', lighting)
140
+ .replace('#include <lights_fragment_maps>', environment)
141
+ .replace('#include <normal_fragment_maps>', normals)
142
+ .replace('#include <map_fragment>', baseMap)
143
+ .replace('#include <roughnessmap_fragment>', roughnessMap)
144
+ .replace('vec3 Fcc = F_Schlick', `
145
+ // Blender's slab_transmittance_at_angle (gpu_shader_material_principled.glsl):
146
+ // Beer-Lambert attenuation through a refracting coat at this view angle.
147
+ float coatPath = blenderCoatIor * inversesqrt(max(1e-8,
148
+ blenderCoatIor * blenderCoatIor - (1.0 - dotNVcc * dotNVcc)));
149
+ outgoingLight *= mix(vec3(1.0), pow(max(blenderCoatTint, vec3(0.0)), vec3(coatPath)), material.clearcoat);
150
+ vec3 Fcc = F_Schlick`);
151
+ // The material's node graph, when the session shipped one, drives the
152
+ // surface inputs it carries (`blender-graph-material.ts`).
153
+ applyGraphShader(material, shader);
154
+ };
155
+ material.needsUpdate = true;
156
+ }
157
+ values.blenderCoatIor.value = Math.max(1, data.coat_ior);
158
+ values.blenderCoatTint.value.setRGB(...data.coat_tint, THREE.LinearSRGBColorSpace);
159
+ values.blenderMapClip.value = clips.map === true;
160
+ values.blenderRoughnessClip.value = clips.roughness === true;
161
+ values.blenderNormalClip.value = clips.normal === true;
162
+ }
@@ -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
+ }
@@ -39,6 +39,15 @@ export interface DrawArrays {
39
39
  normals: Float32Array | null;
40
40
  /** Per drawn vertex, or null when the mesh has no UV map. */
41
41
  uv: Float32Array | null;
42
+ /** All named UV layers; material inputs choose their own layer per draw. */
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;
42
51
  indices: Uint32Array;
43
52
  groups: { start: number; count: number; materialIndex: number }[];
44
53
  /** THE BLENDER VERTEX EACH DRAWN VERTEX CAME FROM, one per drawn vertex.
@@ -58,6 +67,9 @@ export interface DrawArrays {
58
67
  interface DrawView {
59
68
  vertexCount: number;
60
69
  normals: Normal[] | null;
70
+ cornerNormal?: Float32Array | undefined;
71
+ /** Blender's stored orco (-1..1), 3 per vertex. */
72
+ orco?: Float32Array | undefined;
61
73
  co(i: number): THREE.Vector3;
62
74
  /** CSR faces: corner `starts[i]..starts[i+1]` hold vertex indices. */
63
75
  starts: Uint32Array;
@@ -69,6 +81,50 @@ interface DrawView {
69
81
  sharp: Iterable<[number, number]>;
70
82
  /** A corner's UV, or undefined when the mesh has no map. */
71
83
  uv: ((corner: number) => [number, number]) | null;
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
+ });
72
128
  }
73
129
 
74
130
  function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
@@ -147,28 +203,39 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
147
203
  const positions = new Float32Array(capacity * 3),
148
204
  texcoords = new Float32Array(capacity * 2);
149
205
  const sourceVertex = new Uint32Array(capacity);
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;
150
209
  const indices = new Uint32Array(capacity),
151
210
  shared = new Map<string, number>();
152
211
  const groups: DrawArrays['groups'] = [];
153
- const normals = view.normals ? new Float32Array(capacity * 3) : null;
212
+ const normals = view.normals || view.cornerNormal ? new Float32Array(capacity * 3) : null;
154
213
  let count = 0,
155
214
  indexCount = 0;
156
- const emit = (v: number, texture?: [number, number], normal?: Normal): number => {
215
+ const emit = (v: number, texture?: [number, number], normal?: Normal, sourceCorner?: number, face?: number): number => {
157
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;
158
220
  sourceVertex[index] = v;
159
221
  view.co(v).toArray(positions, index * 3);
160
222
  if (texture) texcoords.set(texture, index * 2);
161
223
  if (normal) normals!.set(normal, index * 3);
224
+ if (sourceCorner !== undefined) for (const layer of uvLayers) layer.data.set(layer.read(sourceCorner), index * 2);
162
225
  return index;
163
226
  };
164
- const corner = (c: number): number => {
227
+ const corner = (c: number, face: number): number => {
165
228
  const texture = uv?.(c);
166
- const normal = view.normals?.[c];
167
- if (!mixed) return emit(cornerVerts[c]!, texture, normal);
168
- const key = `${smooth[c] ? find(c) : c}${texture ? `:${texture[0]}:${texture[1]}` : ''}${normal ? `:n${normal.join(':')}` : ''}`;
229
+ const normal: Normal | undefined = view.cornerNormal
230
+ ? [view.cornerNormal[c * 3]!, view.cornerNormal[c * 3 + 1]!, view.cornerNormal[c * 3 + 2]!]
231
+ : view.normals?.[c];
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('')}`;
169
236
  let index = shared.get(key);
170
237
  if (index === undefined) {
171
- index = emit(cornerVerts[c]!, texture, normal);
238
+ index = emit(cornerVerts[c]!, texture, normal, c, face);
172
239
  shared.set(key, index);
173
240
  }
174
241
  return index;
@@ -180,7 +247,7 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
180
247
  const points: THREE.Vector3[] = [];
181
248
  for (let j = 0; j < length; j++) points.push(view.co(cornerVerts[faceStart + j]!));
182
249
  for (const triangle of triangulatePolygon(points).triangles)
183
- for (const c of triangle) indices[indexCount++] = corner(faceStart + c);
250
+ for (const c of triangle) indices[indexCount++] = corner(faceStart + c, i);
184
251
  const materialIndex = view.material(i),
185
252
  previous = groups.at(-1);
186
253
  if (previous?.materialIndex === materialIndex) previous.count += indexCount - start;
@@ -191,12 +258,22 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
191
258
  positions: positions.slice(0, count * 3),
192
259
  normals: normals?.slice(0, count * 3) ?? null,
193
260
  uv: uv ? texcoords.slice(0, count * 2) : null,
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,
194
264
  indices: indices.slice(0, indexCount),
195
265
  sourceVertex: sourceVertex.slice(0, count),
196
266
  groups,
197
267
  };
198
268
  }
199
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
+
200
277
  export function geometryFromDrawArrays(
201
278
  arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
202
279
  ): THREE.BufferGeometry {
@@ -207,6 +284,22 @@ export function geometryFromDrawArrays(
207
284
  new THREE.BufferAttribute(arrays.uv ?? new Float32Array((arrays.positions.length / 3) * 2), 2),
208
285
  );
209
286
  geometry.setIndex(new THREE.BufferAttribute(arrays.indices, 1));
287
+ const channels: Record<string, number> = Object.create(null);
288
+ for (const [index, layer] of (arrays.uvLayers ?? []).entries()) {
289
+ // Blender's MAX_MTFACE is eight. Channel zero is the active render map;
290
+ // named inputs use their independently selectable channel, including it.
291
+ if (index >= 8) throw new Error('A Blender mesh supports at most eight UV maps');
292
+ channels[layer.name] = index + 1;
293
+ geometry.setAttribute(`uv${index + 1}`, new THREE.BufferAttribute(layer.data, 2));
294
+ }
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
+ }
210
303
  for (const group of arrays.groups)
211
304
  geometry.addGroup(group.start, group.count, group.materialIndex);
212
305
  if (arrays.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(arrays.normals, 3));
@@ -233,7 +326,7 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
233
326
  for (let i = 0; i < ne; i++) edges[i] = [c.edge[i * 2]!, c.edge[i * 2 + 1]!];
234
327
  const sharp: [number, number][] = [];
235
328
  for (let i = 0; i < ne; i++) if (c.edgeSharp[i] === 1) sharp.push(edges[i]!);
236
- const custom = c.attributes.find((a) => a.name === 'custom_normal');
329
+ const custom = c.cornerNormal ? undefined : c.attributes.find((a) => a.name === 'custom_normal');
237
330
  const normalMesh = custom
238
331
  ? {
239
332
  v: Array.from(
@@ -269,6 +362,8 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
269
362
  const view: DrawView = {
270
363
  vertexCount: nv,
271
364
  normals: normalMesh ? customCornerNormals(normalMesh, customData) : null,
365
+ cornerNormal: c.cornerNormal,
366
+ orco: c.orco,
272
367
  co: (i) => scratch.set(c.co[i * 3]!, c.co[i * 3 + 1]!, c.co[i * 3 + 2]!).clone(),
273
368
  starts: c.faceStart,
274
369
  cornerVerts: c.corner,
@@ -277,6 +372,9 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
277
372
  edges,
278
373
  sharp,
279
374
  uv: uvData ? (corner) => [uvData[corner * 2]!, uvData[corner * 2 + 1]!] : null,
375
+ uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
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>),
280
378
  };
281
379
  if (map && ATTRIBUTE_LAYOUT.FLOAT2.size !== 2) throw new Error('FLOAT2 layout');
282
380
  return { ...drawCore(view), hash };
@@ -337,6 +435,11 @@ export function drawRuntimeGeometry(data: MeshData): THREE.BufferGeometry {
337
435
  edges: data.edge_order ?? data.e ?? null,
338
436
  sharp: data.sharp ?? [],
339
437
  uv: uv ? (corner) => uv[corner]! : null,
438
+ uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
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()),
340
443
  };
341
444
  return geometryFromDrawArrays(drawCore(view));
342
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;
@@ -531,6 +533,14 @@ const worldRadiance = z.object({
531
533
  });
532
534
  export const worldSchema = worldRadiance
533
535
  .extend({
536
+ volume: z.array(z.object({
537
+ kind: z.enum(['absorption', 'scatter', 'principled', 'emission']),
538
+ phase: z.string(), alpha: worldExpression,
539
+ weight: worldExpression, color: worldExpression, density: worldExpression,
540
+ anisotropy: worldExpression, absorption_color: worldExpression,
541
+ emission_color: worldExpression, emission_strength: worldExpression,
542
+ blackbody_intensity: worldExpression, temperature: worldExpression, blackbody_tint: worldExpression,
543
+ }).strict()).optional(),
534
544
  /** The world that LIGHTS the scene, present only when Blender's Light Path
535
545
  * `Is Camera Ray` split it from the world the camera photographs -- one
536
546
  * sky at two strengths is how a scene gets a bright backdrop and
@@ -757,6 +767,7 @@ export class WorldBackground {
757
767
  // composing now would paint a sky nobody asked for over the current one.
758
768
  if (this.generation !== generation || this.applied?.scene !== scene) return;
759
769
  this.compose(scene, world, expression, camera);
770
+ presenterChanged();
760
771
  })();
761
772
  this.pending = pending;
762
773
  pendingWorld.add(pending);