@volter/blender-engine 0.1.5 → 0.1.6

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.
@@ -27,6 +27,9 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
27
27
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
28
28
 
29
29
  export interface BlenderRuntimeOptions {
30
+ /** Operation-only diagnostics, never request payloads. The optional observer
31
+ * must not affect work, even if it throws. End calls balance overlapping work. */
32
+ work?(label: string): () => void;
30
33
  history?(entries: readonly NativeHistoryEntry[]): void;
31
34
  /** Display a frame. A screenshot `capture` has its view REMEMBERED so the
32
35
  * next document capture photographs what the agent asked for; a render
@@ -149,6 +152,7 @@ export class BlenderRuntime {
149
152
  };
150
153
  /** `performance.now()` at the `postMessage` of every outstanding call. */
151
154
  readonly #callStarts = new Map<number, number>();
155
+ readonly #callWork = new Map<number, () => void>();
152
156
  #lastCallMs: number | null = null;
153
157
  #maxCompletedMs = 0;
154
158
  #completedOver5s = 0;
@@ -442,7 +446,8 @@ export class BlenderRuntime {
442
446
  if (this.#terminated) return;
443
447
  this.#terminated = true;
444
448
  globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
445
- this.#worker.terminate();
449
+ const end = this.#work('terminating worker');
450
+ try { this.#worker.terminate(); } finally { end(); }
446
451
  const error = new Error('The Blender session was terminated');
447
452
  for (const id of [...this.#pending.keys()]) this.#settled(id);
448
453
  for (const pending of this.#pending.values()) pending.reject(error);
@@ -489,6 +494,8 @@ export class BlenderRuntime {
489
494
 
490
495
  /** Close a call's window, whether it answered, threw, or was terminated. */
491
496
  #settled(id: number): void {
497
+ this.#callWork.get(id)?.();
498
+ this.#callWork.delete(id);
492
499
  const started = this.#callStarts.get(id);
493
500
  if (started === undefined) return;
494
501
  this.#callStarts.delete(id);
@@ -510,10 +517,24 @@ export class BlenderRuntime {
510
517
  const started = performance.now();
511
518
  this.#callStarts.set(id, started);
512
519
  this.#lastCallWindow = { start: started, end: null };
513
- this.#worker.postMessage({ ...request, id });
520
+ this.#callWork.set(id, this.#work(`waiting for worker ${request.op}`));
521
+ try {
522
+ this.#worker.postMessage({ ...request, id });
523
+ } catch (error) {
524
+ this.#pending.delete(id);
525
+ this.#settled(id);
526
+ reject(error instanceof Error ? error : new Error(String(error)));
527
+ }
514
528
  });
515
529
  }
516
530
 
531
+ #work(label: string): () => void {
532
+ try {
533
+ const end = this.#options.work?.(label);
534
+ return () => { try { end?.(); } catch { /* diagnostics never fail work */ } };
535
+ } catch { return () => {}; }
536
+ }
537
+
517
538
  async #receive(reply: WorkerReply): Promise<void> {
518
539
  if ('op' in reply) {
519
540
  if (reply.op === 'document-dirty') {
@@ -539,6 +560,7 @@ export class BlenderRuntime {
539
560
  const frame = reply.frame as { session?: unknown; revision?: unknown } | null;
540
561
  if (typeof frame?.session === 'string' && typeof frame.revision === 'number')
541
562
  this.#presented = { session: frame.session, revision: frame.revision };
563
+ const end = this.#work('presenting worker frame');
542
564
  try {
543
565
  const answer = await this.#options.present(reply.frame, reply.description, reply.capture);
544
566
  this.#worker.postMessage({
@@ -555,6 +577,8 @@ export class BlenderRuntime {
555
577
  id: reply.id,
556
578
  error: error instanceof Error ? error.message : String(error),
557
579
  } satisfies WorkerRequest);
580
+ } finally {
581
+ end();
558
582
  }
559
583
  return;
560
584
  }
@@ -283,8 +283,8 @@ def _surface_backgrounds(node):
283
283
  return branches[0], branches[1]
284
284
 
285
285
 
286
- def _describe_background(background, camera_ray):
287
- """The Background's Color as the presenter's world expression (its grammar
286
+ def _describe_world_socket(root_socket, camera_ray):
287
+ """A World input as the presenter's expression (its grammar
288
288
  is `blender-runtime-lighting.ts`'s `worldExpression`); anything outside it
289
289
  is refused BY NAME through NotImplementedError."""
290
290
  visiting = set()
@@ -402,8 +402,12 @@ def _describe_background(background, camera_ray):
402
402
  finally:
403
403
  visiting.remove(key)
404
404
 
405
- color = value(background.inputs["Color"])
406
- strength = value(background.inputs["Strength"])
405
+ return value(root_socket)
406
+
407
+
408
+ def _describe_background(background, camera_ray):
409
+ color = _describe_world_socket(background.inputs["Color"], camera_ray)
410
+ strength = _describe_world_socket(background.inputs["Strength"], camera_ray)
407
411
  # The field evaluator already implements an unclamped color mix. Mixing
408
412
  # black with radiance at factor strength is exact multiplication, including
409
413
  # spatially varying strength, with no second shader implementation.
@@ -415,20 +419,65 @@ def _describe_background(background, camera_ray):
415
419
  "strength": float(strength), "shader": color}
416
420
 
417
421
 
422
+ def _describe_world_volume(socket, weight=1.0, visiting=None):
423
+ """Volume closures and their authored inputs, not a guessed fog color.
424
+ Mix/Add preserve individual scatter lobes rather than averaging anisotropy."""
425
+ links = list(socket.links)
426
+ if not links:
427
+ return []
428
+ node = links[0].from_node
429
+ visiting = set() if visiting is None else visiting
430
+ key = node.as_pointer()
431
+ if key in visiting:
432
+ raise NotImplementedError("World volume contains a cycle")
433
+ visiting.add(key)
434
+ try:
435
+ kind = node.bl_idname
436
+ if kind == "NodeReroute":
437
+ return _describe_world_volume(node.inputs[0], weight, visiting)
438
+ if kind == "ShaderNodeAddShader":
439
+ return sum((_describe_world_volume(s, weight, visiting) for s in node.inputs), [])
440
+ if kind == "ShaderNodeMixShader":
441
+ factor = _describe_world_socket(node.inputs[0], False)
442
+ factor = {"kind": "math", "operation": "MULTIPLY", "clamp": True, "inputs": [factor, 1.0]}
443
+ first = {"kind": "math", "operation": "SUBTRACT", "clamp": False, "inputs": [1.0, factor]}
444
+ return sum((_describe_world_volume(node.inputs[i + 1],
445
+ {"kind": "math", "operation": "MULTIPLY", "clamp": False, "inputs": [weight, f]}, visiting)
446
+ for i, f in enumerate((first, factor))), [])
447
+ kinds = {"ShaderNodeVolumeAbsorption": "absorption", "ShaderNodeVolumeScatter": "scatter",
448
+ "ShaderNodeVolumePrincipled": "principled", "ShaderNodeEmission": "emission"}
449
+ if kind not in kinds:
450
+ raise NotImplementedError("World volume shader %s" % kind)
451
+ def read(name, default):
452
+ value = node.inputs.get(name)
453
+ return _describe_world_socket(value, False) if value is not None else default
454
+ return [{"kind": kinds[kind], "weight": weight,
455
+ "phase": getattr(node, "phase", "HENYEY_GREENSTEIN"), "alpha": read("Alpha", 0.0),
456
+ "color": read("Color", [1.0, 1.0, 1.0]), "density": read("Density", 0.0),
457
+ "anisotropy": read("Anisotropy", 0.0), "absorption_color": read("Absorption Color", [0.0, 0.0, 0.0]),
458
+ "emission_color": read("Emission Color", [1.0, 1.0, 1.0]),
459
+ "emission_strength": read("Strength" if kinds[kind] == "emission" else "Emission Strength", 0.0),
460
+ "blackbody_intensity": read("Blackbody Intensity", 0.0), "temperature": read("Temperature", 1000.0),
461
+ "blackbody_tint": read("Blackbody Tint", [1.0, 1.0, 1.0])}]
462
+ finally:
463
+ visiting.remove(key)
464
+
465
+
418
466
  def _describe_world(world):
419
467
  outputs = [n for n in world.node_tree.nodes if n.bl_idname == "ShaderNodeOutputWorld" and n.is_active_output]
420
468
  if len(outputs) != 1:
421
469
  raise NotImplementedError("World rendering needs one active World Output")
422
470
  surface = list(outputs[0].inputs["Surface"].links)
423
- if len(surface) != 1:
424
- raise NotImplementedError("World Surface must connect to Background")
425
- if outputs[0].inputs["Volume"].links:
426
- raise NotImplementedError("World volume rendering is not implemented")
471
+ volume = _describe_world_volume(outputs[0].inputs["Volume"])
472
+ if not surface:
473
+ return {"color": [0.0, 0.0, 0.0], "strength": 0.0, "volume": volume}
427
474
  background, lighting = _surface_backgrounds(surface[0].from_node)
428
475
  described = _describe_background(background, camera_ray=True)
429
476
  lighting_data = _describe_background(lighting or background, camera_ray=False)
430
477
  if lighting_data != described:
431
478
  described["lighting"] = lighting_data
479
+ if volume:
480
+ described["volume"] = volume
432
481
  # Only a wholly constant background can drop the expression: linked
433
482
  # Strength may carry spatial radiance even when Color itself is constant.
434
483
  if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
@@ -870,7 +919,7 @@ class Session:
870
919
  """
871
920
  carried = frame.get("images") or {}
872
921
  for material in (frame.get("materials") or {}).values():
873
- for slot in ("texture", "roughness_texture"):
922
+ for slot in ("texture", "roughness_texture", "normal_texture"):
874
923
  reference = material.get(slot)
875
924
  if not isinstance(reference, dict):
876
925
  continue
@@ -79,6 +79,8 @@ export const ATTRIBUTE_LAYOUT: Record<AttributeType, { size: number }> = {
79
79
  export interface MeshColumns {
80
80
  /** 3 * nv */
81
81
  co: Float64Array;
82
+ /** Blender-evaluated normals, 3 per corner, independent of UV splits. */
83
+ cornerNormal?: Float32Array | undefined;
82
84
  /** nf + 1 */
83
85
  faceStart: Uint32Array;
84
86
  /** faceStart[nf] entries: vertex index per loop */
@@ -0,0 +1,132 @@
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 {bindNamedUvChannels} from './blender-texture-samplers';
8
+
9
+ const scalar = z.number().finite();
10
+ const color = z.tuple([scalar, scalar, scalar]);
11
+ export const physicalMaterialSchema = z.object({
12
+ coat: scalar, coat_roughness: scalar, coat_ior: scalar, coat_tint: color,
13
+ sheen: scalar, sheen_roughness: scalar, sheen_tint: color,
14
+ anisotropy: scalar, anisotropy_rotation: scalar,
15
+ specular_level: scalar, specular_tint: color,
16
+ film_thickness: scalar, film_ior: scalar,
17
+ }).strict();
18
+ type Physical = z.infer<typeof physicalMaterialSchema>;
19
+ const defaults: Physical = {
20
+ coat: 0, coat_roughness: 0.03, coat_ior: 1.5, coat_tint: [1, 1, 1],
21
+ sheen: 0, sheen_roughness: 0.5, sheen_tint: [1, 1, 1],
22
+ anisotropy: 0, anisotropy_rotation: 0,
23
+ specular_level: 0.5, specular_tint: [1, 1, 1], film_thickness: 0, film_ior: 1.33,
24
+ };
25
+ const uniforms = new WeakMap<THREE.MeshPhysicalMaterial, {
26
+ blenderCoatIor: {value: number}; blenderCoatTint: {value: THREE.Color};
27
+ blenderMapClip: {value: boolean}; blenderRoughnessClip: {value: boolean}; blenderNormalClip: {value: boolean};
28
+ blenderWorldExtinction: {value: THREE.Vector3};
29
+ }>();
30
+
31
+ export function applyWorldExtinction(material: THREE.MeshPhysicalMaterial, extinction: THREE.Vector3): void {
32
+ if (!uniforms.has(material)) applyPhysicalMaterial(material);
33
+ uniforms.get(material)!.blenderWorldExtinction.value.copy(extinction);
34
+ }
35
+
36
+ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, input?: Physical,
37
+ clips: {map?: boolean; roughness?: boolean; normal?: boolean} = {}): void {
38
+ const data = input ?? defaults;
39
+ material.clearcoat = data.coat;
40
+ material.clearcoatRoughness = data.coat_roughness;
41
+ material.sheen = data.sheen;
42
+ material.sheenRoughness = data.sheen_roughness;
43
+ material.sheenColor.setRGB(...data.sheen_tint, THREE.LinearSRGBColorSpace);
44
+ material.anisotropy = data.anisotropy;
45
+ material.anisotropyRotation = data.anisotropy_rotation * Math.PI * 2;
46
+ material.specularIntensity = data.specular_level * 2;
47
+ material.specularColor.setRGB(...data.specular_tint, THREE.LinearSRGBColorSpace);
48
+ material.iridescence = data.film_thickness > 0 ? 1 : 0;
49
+ material.iridescenceIOR = data.film_ior;
50
+ material.iridescenceThicknessRange = [data.film_thickness, data.film_thickness];
51
+ let values = uniforms.get(material);
52
+ if (!values) {
53
+ values = {blenderCoatIor: {value: 1.5}, blenderCoatTint: {value: new THREE.Color(1, 1, 1)},
54
+ blenderMapClip: {value: false}, blenderRoughnessClip: {value: false}, blenderNormalClip: {value: false},
55
+ blenderWorldExtinction: {value: new THREE.Vector3()}};
56
+ uniforms.set(material, values);
57
+ const held = values;
58
+ material.customProgramCacheKey = () => 'blender-principled-physical-v5';
59
+ material.onBeforeRender = (_renderer, _scene, _camera, geometry) => bindNamedUvChannels(material, geometry);
60
+ material.onBeforeCompile = shader => {
61
+ // Three declares channels 0..3. Blender has eight named UV maps in
62
+ // addition to the default channel; only the used attributes survive GLSL.
63
+ shader.vertexShader = [4, 5, 6, 7, 8, 9].map(i => `attribute vec2 uv${i};`).join('\n') + '\n' + shader.vertexShader;
64
+ Object.assign(shader.uniforms, held);
65
+ shader.fragmentShader = `uniform float blenderCoatIor;
66
+ uniform vec3 blenderWorldExtinction;
67
+ vec3 blenderInfiniteTransmission() { return vec3(
68
+ blenderWorldExtinction.x>0.?0.:1., blenderWorldExtinction.y>0.?0.:1., blenderWorldExtinction.z>0.?0.:1.); }
69
+ uniform vec3 blenderCoatTint;
70
+ uniform bool blenderMapClip, blenderRoughnessClip, blenderNormalClip;
71
+ vec4 blenderImageSample(sampler2D image, vec2 uv, bool clipImage) {
72
+ vec4 pixel = texture2D(image, uv);
73
+ if (clipImage && (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))))) pixel = vec4(0.0);
74
+ return pixel;
75
+ }
76
+ ` + shader.fragmentShader;
77
+ const physical = THREE.ShaderChunk.lights_physical_fragment
78
+ .replace('material.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor );',
79
+ 'material.specularF90 = 1.0;')
80
+ .replace('material.clearcoatF0 = vec3( 0.04 );',
81
+ 'material.clearcoatF0 = vec3( pow2( ( blenderCoatIor - 1.0 ) / ( blenderCoatIor + 1.0 ) ) );');
82
+ const normals = THREE.ShaderChunk.normal_fragment_maps
83
+ .replace('normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;',
84
+ 'vec3 blenderBaseNormal = normal; normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0; normal.y *= normalScale.y < 0.0 ? -1.0 : 1.0;')
85
+ .replace('normal = normalize( normalMatrix * normal );',
86
+ 'normal = normalize(mix(blenderBaseNormal, normalize(normalMatrix * normal), max(0.0, normalScale.x)));')
87
+ .replace('mapN.xy *= normalScale;',
88
+ 'mapN.xy *= normalScale; mapN.z = mix(1.0, mapN.z, clamp(normalScale.x, 0.0, 1.0));')
89
+ .replaceAll('texture2D( normalMap, vNormalMapUv )', 'blenderImageSample(normalMap, vNormalMapUv, blenderNormalClip)');
90
+ const baseMap = THREE.ShaderChunk.map_fragment
91
+ .replace('texture2D( map, vMapUv )', 'blenderImageSample(map, vMapUv, blenderMapClip)')
92
+ // Blender's Color socket does not connect Image Alpha to Principled Alpha.
93
+ .replace('diffuseColor *= sampledDiffuseColor;', 'diffuseColor.rgb *= sampledDiffuseColor.rgb;');
94
+ const roughnessMap = THREE.ShaderChunk.roughnessmap_fragment
95
+ .replace('texture2D( roughnessMap, vRoughnessMapUv )', 'blenderImageSample(roughnessMap, vRoughnessMapUv, blenderRoughnessClip)');
96
+ const lighting = THREE.ShaderChunk.lights_fragment_begin
97
+ .replace('getPointLightInfo( pointLight, geometryPosition, directLight );',
98
+ 'getPointLightInfo( pointLight, geometryPosition, directLight ); directLight.color *= exp(-blenderWorldExtinction * length(pointLight.position-geometryPosition));')
99
+ .replace('getSpotLightInfo( spotLight, geometryPosition, directLight );',
100
+ 'getSpotLightInfo( spotLight, geometryPosition, directLight ); directLight.color *= exp(-blenderWorldExtinction * length(spotLight.position-geometryPosition));')
101
+ .replace('getDirectionalLightInfo( directionalLight, directLight );',
102
+ 'getDirectionalLightInfo( directionalLight, directLight ); directLight.color *= blenderInfiniteTransmission();')
103
+ // LTC integrates the rectangle without participating media. Its center
104
+ // distance is the attenuation approximation; the volume pass samples
105
+ // the rectangle itself. Neither is claimed as path-traced area lighting.
106
+ .replace('rectAreaLight = rectAreaLights[ i ];',
107
+ 'rectAreaLight = rectAreaLights[ i ]; rectAreaLight.color *= exp(-blenderWorldExtinction * length(rectAreaLight.position-geometryPosition));')
108
+ .replace('getAmbientLightIrradiance( ambientLightColor )', 'getAmbientLightIrradiance( ambientLightColor ) * blenderInfiniteTransmission()');
109
+ const environment = THREE.ShaderChunk.lights_fragment_maps
110
+ .replaceAll(/(getIBL(?:Irradiance|Radiance|AnisotropyRadiance)\([^;]+\))/g, '$1 * blenderInfiniteTransmission()');
111
+ shader.fragmentShader = shader.fragmentShader.replace('#include <lights_physical_fragment>', physical)
112
+ .replace('#include <lights_fragment_begin>', lighting)
113
+ .replace('#include <lights_fragment_maps>', environment)
114
+ .replace('#include <normal_fragment_maps>', normals)
115
+ .replace('#include <map_fragment>', baseMap)
116
+ .replace('#include <roughnessmap_fragment>', roughnessMap)
117
+ .replace('vec3 Fcc = F_Schlick', `
118
+ // Blender's slab_transmittance_at_angle (gpu_shader_material_principled.glsl):
119
+ // Beer-Lambert attenuation through a refracting coat at this view angle.
120
+ float coatPath = blenderCoatIor * inversesqrt(max(1e-8,
121
+ blenderCoatIor * blenderCoatIor - (1.0 - dotNVcc * dotNVcc)));
122
+ outgoingLight *= mix(vec3(1.0), pow(max(blenderCoatTint, vec3(0.0)), vec3(coatPath)), material.clearcoat);
123
+ vec3 Fcc = F_Schlick`);
124
+ };
125
+ material.needsUpdate = true;
126
+ }
127
+ values.blenderCoatIor.value = Math.max(1, data.coat_ior);
128
+ values.blenderCoatTint.value.setRGB(...data.coat_tint, THREE.LinearSRGBColorSpace);
129
+ values.blenderMapClip.value = clips.map === true;
130
+ values.blenderRoughnessClip.value = clips.roughness === true;
131
+ values.blenderNormalClip.value = clips.normal === true;
132
+ }
@@ -39,6 +39,8 @@ 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;
42
44
  indices: Uint32Array;
43
45
  groups: { start: number; count: number; materialIndex: number }[];
44
46
  /** THE BLENDER VERTEX EACH DRAWN VERTEX CAME FROM, one per drawn vertex.
@@ -58,6 +60,7 @@ export interface DrawArrays {
58
60
  interface DrawView {
59
61
  vertexCount: number;
60
62
  normals: Normal[] | null;
63
+ cornerNormal?: Float32Array | undefined;
61
64
  co(i: number): THREE.Vector3;
62
65
  /** CSR faces: corner `starts[i]..starts[i+1]` hold vertex indices. */
63
66
  starts: Uint32Array;
@@ -69,6 +72,7 @@ interface DrawView {
69
72
  sharp: Iterable<[number, number]>;
70
73
  /** A corner's UV, or undefined when the mesh has no map. */
71
74
  uv: ((corner: number) => [number, number]) | null;
75
+ uvLayers?: {name: string; read: (corner: number) => [number, number]}[];
72
76
  }
73
77
 
74
78
  function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
@@ -147,28 +151,32 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
147
151
  const positions = new Float32Array(capacity * 3),
148
152
  texcoords = new Float32Array(capacity * 2);
149
153
  const sourceVertex = new Uint32Array(capacity);
154
+ const uvLayers = (view.uvLayers ?? []).map(layer => ({...layer, data: new Float32Array(capacity * 2)}));
150
155
  const indices = new Uint32Array(capacity),
151
156
  shared = new Map<string, number>();
152
157
  const groups: DrawArrays['groups'] = [];
153
- const normals = view.normals ? new Float32Array(capacity * 3) : null;
158
+ const normals = view.normals || view.cornerNormal ? new Float32Array(capacity * 3) : null;
154
159
  let count = 0,
155
160
  indexCount = 0;
156
- const emit = (v: number, texture?: [number, number], normal?: Normal): number => {
161
+ const emit = (v: number, texture?: [number, number], normal?: Normal, sourceCorner?: number): number => {
157
162
  const index = count++;
158
163
  sourceVertex[index] = v;
159
164
  view.co(v).toArray(positions, index * 3);
160
165
  if (texture) texcoords.set(texture, index * 2);
161
166
  if (normal) normals!.set(normal, index * 3);
167
+ if (sourceCorner !== undefined) for (const layer of uvLayers) layer.data.set(layer.read(sourceCorner), index * 2);
162
168
  return index;
163
169
  };
164
170
  const corner = (c: number): number => {
165
171
  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(':')}` : ''}`;
172
+ const normal: Normal | undefined = view.cornerNormal
173
+ ? [view.cornerNormal[c * 3]!, view.cornerNormal[c * 3 + 1]!, view.cornerNormal[c * 3 + 2]!]
174
+ : view.normals?.[c];
175
+ if (!mixed) return emit(cornerVerts[c]!, texture, normal, c);
176
+ const key = `${smooth[c] ? find(c) : c}${texture ? `:${texture[0]}:${texture[1]}` : ''}${normal ? `:n${normal.join(':')}` : ''}${uvLayers.map(layer => ':' + layer.read(c).join(':')).join('')}`;
169
177
  let index = shared.get(key);
170
178
  if (index === undefined) {
171
- index = emit(cornerVerts[c]!, texture, normal);
179
+ index = emit(cornerVerts[c]!, texture, normal, c);
172
180
  shared.set(key, index);
173
181
  }
174
182
  return index;
@@ -191,6 +199,7 @@ function drawCore(view: DrawView): Omit<DrawArrays, 'hash'> {
191
199
  positions: positions.slice(0, count * 3),
192
200
  normals: normals?.slice(0, count * 3) ?? null,
193
201
  uv: uv ? texcoords.slice(0, count * 2) : null,
202
+ uvLayers: uvLayers.map(layer => ({name: layer.name, data: layer.data.slice(0, count * 2)})),
194
203
  indices: indices.slice(0, indexCount),
195
204
  sourceVertex: sourceVertex.slice(0, count),
196
205
  groups,
@@ -207,6 +216,15 @@ export function geometryFromDrawArrays(
207
216
  new THREE.BufferAttribute(arrays.uv ?? new Float32Array((arrays.positions.length / 3) * 2), 2),
208
217
  );
209
218
  geometry.setIndex(new THREE.BufferAttribute(arrays.indices, 1));
219
+ const channels: Record<string, number> = Object.create(null);
220
+ for (const [index, layer] of (arrays.uvLayers ?? []).entries()) {
221
+ // Blender's MAX_MTFACE is eight. Channel zero is the active render map;
222
+ // named inputs use their independently selectable channel, including it.
223
+ if (index >= 8) throw new Error('A Blender mesh supports at most eight UV maps');
224
+ channels[layer.name] = index + 1;
225
+ geometry.setAttribute(`uv${index + 1}`, new THREE.BufferAttribute(layer.data, 2));
226
+ }
227
+ geometry.userData['blenderUvChannels'] = channels;
210
228
  for (const group of arrays.groups)
211
229
  geometry.addGroup(group.start, group.count, group.materialIndex);
212
230
  if (arrays.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(arrays.normals, 3));
@@ -233,7 +251,7 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
233
251
  for (let i = 0; i < ne; i++) edges[i] = [c.edge[i * 2]!, c.edge[i * 2 + 1]!];
234
252
  const sharp: [number, number][] = [];
235
253
  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');
254
+ const custom = c.cornerNormal ? undefined : c.attributes.find((a) => a.name === 'custom_normal');
237
255
  const normalMesh = custom
238
256
  ? {
239
257
  v: Array.from(
@@ -269,6 +287,7 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
269
287
  const view: DrawView = {
270
288
  vertexCount: nv,
271
289
  normals: normalMesh ? customCornerNormals(normalMesh, customData) : null,
290
+ cornerNormal: c.cornerNormal,
272
291
  co: (i) => scratch.set(c.co[i * 3]!, c.co[i * 3 + 1]!, c.co[i * 3 + 2]!).clone(),
273
292
  starts: c.faceStart,
274
293
  cornerVerts: c.corner,
@@ -277,6 +296,8 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
277
296
  edges,
278
297
  sharp,
279
298
  uv: uvData ? (corner) => [uvData[corner * 2]!, uvData[corner * 2 + 1]!] : null,
299
+ uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
300
+ [Number(layer.data[corner * 2]), Number(layer.data[corner * 2 + 1])] as [number, number]})),
280
301
  };
281
302
  if (map && ATTRIBUTE_LAYOUT.FLOAT2.size !== 2) throw new Error('FLOAT2 layout');
282
303
  return { ...drawCore(view), hash };
@@ -337,6 +358,8 @@ export function drawRuntimeGeometry(data: MeshData): THREE.BufferGeometry {
337
358
  edges: data.edge_order ?? data.e ?? null,
338
359
  sharp: data.sharp ?? [],
339
360
  uv: uv ? (corner) => uv[corner]! : null,
361
+ uvLayers: maps.map(layer => ({name: layer.name, read: (corner: number) =>
362
+ (layer.data as [number, number][])[corner]!})),
340
363
  };
341
364
  return geometryFromDrawArrays(drawCore(view));
342
365
  }
@@ -531,6 +531,14 @@ const worldRadiance = z.object({
531
531
  });
532
532
  export const worldSchema = worldRadiance
533
533
  .extend({
534
+ volume: z.array(z.object({
535
+ kind: z.enum(['absorption', 'scatter', 'principled', 'emission']),
536
+ phase: z.string(), alpha: worldExpression,
537
+ weight: worldExpression, color: worldExpression, density: worldExpression,
538
+ anisotropy: worldExpression, absorption_color: worldExpression,
539
+ emission_color: worldExpression, emission_strength: worldExpression,
540
+ blackbody_intensity: worldExpression, temperature: worldExpression, blackbody_tint: worldExpression,
541
+ }).strict()).optional(),
534
542
  /** The world that LIGHTS the scene, present only when Blender's Light Path
535
543
  * `Is Camera Ray` split it from the world the camera photographs -- one
536
544
  * sky at two strengths is how a scene gets a bright backdrop and
@@ -24,6 +24,9 @@ import {
24
24
  } from './blender-runtime-lighting';
25
25
  import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
26
26
  import { volumeMesh, volumeSchema } from './blender-runtime-volume';
27
+ import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
28
+ import {worldMedium, WorldVolumePass} from './blender-world-volume';
29
+ import { BlenderTextureSamplers } from './blender-texture-samplers';
27
30
  import { WeightOverlay, weightsSchema } from './blender-runtime-weights';
28
31
 
29
32
  const scalar = z.number().finite();
@@ -103,6 +106,7 @@ const drawArraysSchema = z
103
106
  positions: z.instanceof(Float32Array),
104
107
  normals: z.instanceof(Float32Array).nullable(),
105
108
  uv: z.instanceof(Float32Array).nullable(),
109
+ uvLayers: z.array(z.object({name: z.string(), data: z.instanceof(Float32Array)}).strict()).optional(),
106
110
  indices: z.instanceof(Uint32Array),
107
111
  groups: z.array(
108
112
  z
@@ -158,6 +162,7 @@ const unchangedMeshSchema = z
158
162
  const columnsSchema = z
159
163
  .object({
160
164
  co: z.instanceof(Float64Array),
165
+ cornerNormal: z.instanceof(Float32Array).optional(),
161
166
  faceStart: z.instanceof(Uint32Array),
162
167
  corner: z.instanceof(Uint32Array),
163
168
  cornerEdge: z.instanceof(Int32Array),
@@ -321,8 +326,8 @@ const frameImageSchema = z.union([rasterImageSchema, pngImageSchema]);
321
326
  const textureReferenceSchema = z
322
327
  .object({
323
328
  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'),
329
+ /** CLIP is transparent black outside the image, applied in the shader. */
330
+ extension: z.enum(['REPEAT', 'EXTEND', 'MIRROR', 'CLIP']).default('REPEAT'),
326
331
  uv: z.string(),
327
332
  /** A constant the texture is multiplied by -- Blender's MULTIPLY mix at
328
333
  * full factor, which is what `map * color` already is. */
@@ -337,6 +342,11 @@ const materialSchema = z
337
342
  metallic: scalar,
338
343
  transmission: scalar,
339
344
  ior: scalar,
345
+ physical: physicalMaterialSchema.optional(),
346
+ normal_texture: textureReferenceSchema.optional(),
347
+ normal_strength: scalar.default(1),
348
+ normal_space: z.enum(['TANGENT', 'OBJECT']).default('TANGENT'),
349
+ normal_directx: z.boolean().default(false),
340
350
  /** A Base Color image, NAMED. Its bytes travel in the frame's `images`
341
351
  * map, once per `(name, revision)`. */
342
352
  texture: textureReferenceSchema.optional(),
@@ -480,7 +490,7 @@ export class BlenderRuntimeView {
480
490
  private readonly armatureRoot = new THREE.Group();
481
491
  private readonly weightRoot = new THREE.Group();
482
492
  private rendered = false;
483
- private readonly fallback = new THREE.MeshStandardMaterial({ color: 0xb9bec6, roughness: 0.72 });
493
+ private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72 });
484
494
  /** Base Color images, by image name -- ONE texture per image however many
485
495
  * materials read it, and the cache OWNS it: a material points at one and
486
496
  * never disposes it. Held with the size and revision the resident bytes
@@ -501,6 +511,7 @@ export class BlenderRuntimeView {
501
511
  private readonly textureNames = new Map<string, string>();
502
512
  /** Which runtime image each material's roughness map is, by material id. */
503
513
  private readonly roughnessTextureNames = new Map<string, string>();
514
+ private readonly textureSamplers = new BlenderTextureSamplers();
504
515
  private frame: Frame | null = null;
505
516
  private readonly retiredSessions = new Set<string>();
506
517
  private geometryBuilds = 0;
@@ -706,6 +717,8 @@ export class BlenderRuntimeView {
706
717
  * distinction Blender draws between its solid viewport and a render.
707
718
  */
708
719
  async setRendered(rendered: boolean, camera?: THREE.Camera): Promise<void> {
720
+ const extinction = (rendered ? worldMedium(this.frame?.world)?.extinction : null) ?? new THREE.Vector3();
721
+ for (const material of [...this.materials.values(), this.fallback]) applyWorldExtinction(material, extinction);
709
722
  // An area light cannot be DRAWN until its lookup tables are uploaded, and a
710
723
  // render is one photograph with no second chance at it.
711
724
  // A sky is derived off the main thread now, so a render waits for it the
@@ -1059,6 +1072,33 @@ export class BlenderRuntimeView {
1059
1072
  material.metalness = data.metallic;
1060
1073
  material.transmission = data.transmission;
1061
1074
  material.ior = data.ior;
1075
+ applyPhysicalMaterial(material, data.physical, {
1076
+ map: data.texture?.extension === 'CLIP',
1077
+ roughness: data.roughness_texture?.extension === 'CLIP',
1078
+ normal: data.normal_texture?.extension === 'CLIP',
1079
+ });
1080
+ const normalName = data.normal_texture?.image.name;
1081
+ if (normalName === undefined) {
1082
+ if (material.normalMap) {
1083
+ this.textureSamplers.delete(`${id}:normal`);
1084
+ material.normalMap = null;
1085
+ material.needsUpdate = true;
1086
+ }
1087
+ } else {
1088
+ const held = this.textures.get(normalName);
1089
+ if (!held) throw new Error(`${UNKNOWN_IMAGE}: missing normal image ${normalName}`);
1090
+ const normalMap = this.textureSamplers.get(`${id}:normal`, held.texture, data.normal_texture!.extension, held.ready, data.normal_texture!.uv);
1091
+ if (material.normalMap !== normalMap) {
1092
+ material.normalMap = normalMap;
1093
+ material.needsUpdate = true;
1094
+ }
1095
+ const normalType = data.normal_space === 'OBJECT' ? THREE.ObjectSpaceNormalMap : THREE.TangentSpaceNormalMap;
1096
+ if (material.normalMapType !== normalType) {
1097
+ material.normalMapType = normalType;
1098
+ material.needsUpdate = true;
1099
+ }
1100
+ material.normalScale.set(data.normal_strength, data.normal_strength * (data.normal_directx ? -1 : 1));
1101
+ }
1062
1102
  if (data.emission) {
1063
1103
  material.emissive.setRGB(
1064
1104
  data.emission.color[0],
@@ -1077,6 +1117,7 @@ export class BlenderRuntimeView {
1077
1117
  const roughnessWanted = data.roughness_texture?.image.name ?? null;
1078
1118
  if (roughnessWanted === null) {
1079
1119
  if (this.roughnessTextureNames.has(id)) {
1120
+ this.textureSamplers.delete(`${id}:roughness`);
1080
1121
  material.roughnessMap = null;
1081
1122
  this.roughnessTextureNames.delete(id);
1082
1123
  material.needsUpdate = true;
@@ -1088,18 +1129,8 @@ export class BlenderRuntimeView {
1088
1129
  `${UNKNOWN_IMAGE}: the presenter does not hold runtime image ${roughnessWanted} ` +
1089
1130
  `(revision ${data.roughness_texture!.image.revision})`,
1090
1131
  );
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
- }
1132
+ const roughnessMap = this.textureSamplers.get(`${id}:roughness`, heldRoughness.texture,
1133
+ data.roughness_texture!.extension, heldRoughness.ready, data.roughness_texture!.uv);
1103
1134
  if (material.roughnessMap !== roughnessMap) {
1104
1135
  material.roughnessMap = roughnessMap;
1105
1136
  this.roughnessTextureNames.set(id, roughnessWanted);
@@ -1113,6 +1144,7 @@ export class BlenderRuntimeView {
1113
1144
  const wanted = data.texture?.image.name ?? null;
1114
1145
  if (wanted === null) {
1115
1146
  if (this.textureNames.has(id)) {
1147
+ this.textureSamplers.delete(`${id}:color`);
1116
1148
  material.map = null;
1117
1149
  this.textureNames.delete(id);
1118
1150
  material.needsUpdate = true;
@@ -1129,35 +1161,7 @@ export class BlenderRuntimeView {
1129
1161
  `${UNKNOWN_IMAGE}: the presenter does not hold runtime image ${wanted} ` +
1130
1162
  `(revision ${data.texture!.image.revision})`,
1131
1163
  );
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
- }
1164
+ const map = this.textureSamplers.get(`${id}:color`, held.texture, data.texture!.extension, held.ready, data.texture!.uv);
1161
1165
  // RE-POINTED WHENEVER THE TEXTURE OBJECT CHANGED, which is how a
1162
1166
  // RESIZED image reaches a material: the cache disposes the old texture
1163
1167
  // and builds a new one. A repaint at the same size keeps this exact
@@ -1242,6 +1246,7 @@ export class BlenderRuntimeView {
1242
1246
  }
1243
1247
  for (const [id, material] of this.materials)
1244
1248
  if (!next.materials[id]) {
1249
+ for (const slot of ['normal', 'roughness', 'color']) this.textureSamplers.delete(`${id}:${slot}`);
1245
1250
  material.dispose();
1246
1251
  this.materials.delete(id);
1247
1252
  }
@@ -1323,6 +1328,11 @@ export class BlenderRuntimeView {
1323
1328
  positions,
1324
1329
  normals: attribute('normal'),
1325
1330
  uv: attribute('uv'),
1331
+ uvLayers: Object.entries(geometry.userData['blenderUvChannels'] as Record<string, number> ?? {}).map(([name, channel]) => {
1332
+ const data = attribute(`uv${channel}`);
1333
+ if (!data) throw new Error(`Blender capture mesh ${id} is missing UV layer ${name}`);
1334
+ return {name, data};
1335
+ }),
1326
1336
  indices: index
1327
1337
  ? new Uint32Array(index.array)
1328
1338
  : Uint32Array.from({ length: positions.length / 3 }, (_, i) => i),
@@ -1337,7 +1347,7 @@ export class BlenderRuntimeView {
1337
1347
  frame.volumes[id] = volumeSchema.parse(JSON.parse(signature));
1338
1348
  const images = new Set(
1339
1349
  Object.values(frame.materials).flatMap((material) =>
1340
- [material.texture?.image.name, material.roughness_texture?.image.name].filter(
1350
+ [material.texture?.image.name, material.roughness_texture?.image.name, material.normal_texture?.image.name].filter(
1341
1351
  (name): name is string => name !== undefined,
1342
1352
  ),
1343
1353
  ),
@@ -1360,11 +1370,13 @@ export class BlenderRuntimeView {
1360
1370
  };
1361
1371
  }
1362
1372
  }
1373
+ const effect = this.createWorldVolumePass();
1363
1374
  const detached = new BlenderRuntimeView();
1364
1375
  let disposed = false;
1365
1376
  const dispose = () => {
1366
1377
  if (disposed) return;
1367
1378
  disposed = true;
1379
+ effect?.dispose();
1368
1380
  detached.dispose();
1369
1381
  detached.root.removeFromParent();
1370
1382
  };
@@ -1378,6 +1390,7 @@ export class BlenderRuntimeView {
1378
1390
  throw error;
1379
1391
  }
1380
1392
  return {
1393
+ effect,
1381
1394
  root: detached.root,
1382
1395
  session: source.session,
1383
1396
  revision: source.revision,
@@ -1389,6 +1402,11 @@ export class BlenderRuntimeView {
1389
1402
  };
1390
1403
  }
1391
1404
 
1405
+ createWorldVolumePass(): WorldVolumePass | undefined {
1406
+ const medium = worldMedium(this.frame?.world);
1407
+ return medium ? new WorldVolumePass(medium) : undefined;
1408
+ }
1409
+
1392
1410
  /** Keep the description that arrived with this frame. Called by the tab's
1393
1411
  * Blender host right after `applyFrame`, so a refused frame records
1394
1412
  * nothing: the record must describe what is DISPLAYED. */
@@ -1446,6 +1464,8 @@ export class BlenderRuntimeView {
1446
1464
  for (const { texture } of this.textures.values()) texture.dispose();
1447
1465
  this.textures.clear();
1448
1466
  this.textureNames.clear();
1467
+ this.roughnessTextureNames.clear();
1468
+ this.textureSamplers.clear();
1449
1469
  this.volumes.clear();
1450
1470
  this.objects.clear();
1451
1471
  this.meshes.clear();
@@ -0,0 +1,77 @@
1
+ import * as THREE from 'three';
2
+
3
+ export type TextureExtension = 'REPEAT' | 'EXTEND' | 'MIRROR' | 'CLIP';
4
+
5
+ /** A shared material may draw meshes whose named layers have different
6
+ * channel positions. Resolve at Three's per-draw hook, before program choice. */
7
+ export function bindNamedUvChannels(material: THREE.MeshPhysicalMaterial, geometry: THREE.BufferGeometry): void {
8
+ const channels = geometry.userData['blenderUvChannels'] as Record<string, number> | undefined;
9
+ for (const texture of [material.map, material.roughnessMap, material.normalMap]) {
10
+ if (!texture) continue;
11
+ const name = texture.userData['blenderUvName'] as string | undefined;
12
+ let channel = name ? channels?.[name] : 0;
13
+ if (channel === undefined) {
14
+ // Blender's missing named surface attribute is zero, not the active UV
15
+ // map and not an error. Shared materials routinely cover meshes without
16
+ // that layer. Reserve a separate zero channel so other slots retain UV0.
17
+ channel = 9;
18
+ const count = geometry.getAttribute('position')?.count;
19
+ if (count === undefined) throw new Error('A textured Blender draw has no positions');
20
+ if (geometry.getAttribute('uv9')?.count !== count)
21
+ geometry.setAttribute('uv9', new THREE.BufferAttribute(new Float32Array(count*2),2));
22
+ }
23
+ if (texture.channel !== channel) {
24
+ texture.channel = channel;
25
+ material.needsUpdate = true;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Image pixels are shared; sampler state belongs to each material input.
31
+ * Clones share Three's Source, including asynchronous PNG decoding, but their
32
+ * upload versions are independent and must follow repaints explicitly. */
33
+ export class BlenderTextureSamplers {
34
+ private readonly bindings = new Map<string, {
35
+ source: THREE.Texture; texture: THREE.Texture; version: number; disposed: boolean;
36
+ }>();
37
+
38
+ get(key: string, source: THREE.Texture, extension: TextureExtension, ready?: Promise<void>, uv = ''): THREE.Texture {
39
+ let binding = this.bindings.get(key);
40
+ if (!binding || binding.source !== source) {
41
+ this.delete(key);
42
+ binding = {source, texture: source.clone(), version: -1, disposed: false};
43
+ this.bindings.set(key, binding);
44
+ const held = binding;
45
+ // The owner awaits and reports decode failures. Avoid an unhandled
46
+ // rejection here, and never upload a clone removed during the decode.
47
+ if (ready) void ready.then(() => {
48
+ if (!held.disposed) {
49
+ held.texture.needsUpdate = true;
50
+ held.version = source.version;
51
+ }
52
+ }, () => {});
53
+ }
54
+ const wrap = extension === 'REPEAT' ? THREE.RepeatWrapping :
55
+ extension === 'MIRROR' ? THREE.MirroredRepeatWrapping : THREE.ClampToEdgeWrapping;
56
+ const changed = binding.texture.wrapS !== wrap || binding.texture.wrapT !== wrap;
57
+ binding.texture.wrapS = binding.texture.wrapT = wrap;
58
+ binding.texture.userData['blenderUvName'] = uv;
59
+ if (source.image && (changed || binding.version !== source.version)) {
60
+ binding.texture.needsUpdate = true;
61
+ binding.version = source.version;
62
+ }
63
+ return binding.texture;
64
+ }
65
+
66
+ delete(key: string): void {
67
+ const binding = this.bindings.get(key);
68
+ if (!binding) return;
69
+ binding.disposed = true;
70
+ binding.texture.dispose();
71
+ this.bindings.delete(key);
72
+ }
73
+
74
+ clear(): void {
75
+ for (const key of this.bindings.keys()) this.delete(key);
76
+ }
77
+ }
@@ -0,0 +1,235 @@
1
+ /** Homogeneous World transport, in scene-linear radiance. Surface depth bounds
2
+ * the camera segment; a World with no hit is unbounded, not a camera-far fog box.
3
+ * Absorption/emission are analytic. Local-light single scattering is numerical;
4
+ * multiple scattering and heterogeneous fields are not implemented here. */
5
+ import * as THREE from 'three';
6
+ import type {WorldData, WorldExpression} from './blender-runtime-lighting';
7
+ import {worldField} from './world-field-sampler';
8
+
9
+ export interface WorldMedium {
10
+ extinction: THREE.Vector3;
11
+ emission: THREE.Vector3;
12
+ lobes: {coefficient: THREE.Vector3; g: number; alpha: number}[];
13
+ }
14
+
15
+ function constant(expression: WorldExpression): number | THREE.Vector3 {
16
+ const visit = (value: unknown): void => {
17
+ if (!value || typeof value !== 'object') return;
18
+ if ('kind' in value && ['direction', 'window', 'sky'].includes(String(value.kind)))
19
+ throw new Error(`World volume spatial input ${value.kind} is not implemented`);
20
+ for (const child of Object.values(value)) visit(child);
21
+ };
22
+ visit(expression);
23
+ const value = worldField(expression)(new THREE.Vector3());
24
+ const components = typeof value === 'number' ? [value] : value.toArray();
25
+ if (!components.every(Number.isFinite)) throw new Error('World volume input is not finite');
26
+ return value;
27
+ }
28
+ function scalar(expression: WorldExpression): number {
29
+ const value = constant(expression);
30
+ if (typeof value !== 'number') throw new Error('World volume scalar input resolved to a color');
31
+ return Math.max(0, value);
32
+ }
33
+ function color(expression: WorldExpression): THREE.Vector3 {
34
+ const value = constant(expression);
35
+ return (typeof value === 'number' ? new THREE.Vector3().setScalar(value) : value.clone()).max(new THREE.Vector3());
36
+ }
37
+
38
+ // Blender IMB_colormanagement_blackbody_temperature_to_rgb, default linear Rec.709.
39
+ // Coefficients from source/blender/imbuf/intern/colormanagement.cc (GPL-2.0-or-later).
40
+ export function blackbody(t: number): THREE.Vector3 {
41
+ if (t < 800) return new THREE.Vector3(5.413294490189271, 0, 0);
42
+ if (t >= 12000) return new THREE.Vector3(.8262954810464208, .9945080501520986, 1.566307710274283);
43
+ const i = [965,1167,1449,1902,3315,6365].filter(limit => t >= limit).length;
44
+ const r = [[1619.19106,-.00205010916,5.02995757],[2488.45471,-.00111330907,3.22621544],
45
+ [3341.43193,-.000486551192,1.76486769],[4094.61742,-.000127446582,.725731635],
46
+ [4670.28036,.0000291258199,.126703442],[4595.09185,.0000287495649,.150345020],
47
+ [3787.17450,.00000935907826,.399075871]][i]!;
48
+ const g = [[-488.999748,.000604330754,-.0755807526],[-755.994277,.000316730098,.478306139],
49
+ [-1023.63977,.000120223470,.936662319],[-1265.71316,.00000487340896,1.27054498],
50
+ [-1425.29332,-.0000401150431,1.43972784],[-1175.54822,-.0000216378048,1.30408023],
51
+ [-500.799571,-.00000459832026,1.09098763]][i]!;
52
+ const b = [[5.96945309e-11,-4.85742887e-8,-9.70622247e-5,-.00407936148],
53
+ [2.40430366e-11,5.55021075e-8,-1.98503712e-4,.0289312858],
54
+ [-1.40949732e-11,1.89878968e-7,-3.56632824e-4,.0910767778],
55
+ [-3.61460868e-11,2.84822009e-7,-4.93211319e-4,.156723440],
56
+ [-1.97075738e-11,1.75359352e-7,-2.50542825e-4,-.0222783266],
57
+ [-1.61997957e-13,-1.64216008e-8,3.86216271e-4,-.738077418],
58
+ [6.72650283e-13,-2.73078809e-8,4.24098264e-4,-.752335691]][i]!;
59
+ return new THREE.Vector3(r[0]!/t+r[1]!*t+r[2]!, g[0]!/t+g[1]!*t+g[2]!,
60
+ ((b[0]!*t+b[1]!)*t+b[2]!)*t+b[3]!).max(new THREE.Vector3());
61
+ }
62
+
63
+ export function worldMedium(world: WorldData | null | undefined): WorldMedium | null {
64
+ if (!world?.volume?.length) return null;
65
+ const medium: WorldMedium = {extinction: new THREE.Vector3(), emission: new THREE.Vector3(), lobes: []};
66
+ for (const node of world.volume) {
67
+ const weight = scalar(node.weight);
68
+ if (weight === 0) continue;
69
+ const c = color(node.color), density = scalar(node.density);
70
+ const absorption = new THREE.Vector3(), scatter = new THREE.Vector3();
71
+ if (node.kind === 'absorption') absorption.set(1-c.x,1-c.y,1-c.z).max(new THREE.Vector3()).multiplyScalar(density);
72
+ if (node.kind === 'scatter' || node.kind === 'principled') {
73
+ scatter.copy(c).multiplyScalar(density);
74
+ const phase = node.kind === 'principled' ? 'HENYEY_GREENSTEIN' : node.phase;
75
+ if (!['HENYEY_GREENSTEIN','DRAINE','RAYLEIGH'].includes(phase))
76
+ throw new Error(`World volume ${phase} phase is not implemented`);
77
+ const anisotropy = constant(node.anisotropy);
78
+ if (typeof anisotropy !== 'number') throw new Error('World volume anisotropy must be scalar');
79
+ medium.lobes.push({coefficient: scatter.clone().multiplyScalar(weight),
80
+ g: phase === 'RAYLEIGH' ? 0 : THREE.MathUtils.clamp(anisotropy, -.999, .999),
81
+ alpha: phase === 'RAYLEIGH' ? 1 : phase === 'DRAINE' ? scalar(node.alpha) : 0});
82
+ }
83
+ if (node.kind === 'principled') {
84
+ const a = color(node.absorption_color);
85
+ absorption.set(Math.max(1-c.x,0)*Math.max(1-Math.sqrt(a.x),0),
86
+ Math.max(1-c.y,0)*Math.max(1-Math.sqrt(a.y),0), Math.max(1-c.z,0)*Math.max(1-Math.sqrt(a.z),0)).multiplyScalar(density);
87
+ const bb = scalar(node.blackbody_intensity);
88
+ if (bb > .001) {
89
+ const t = scalar(node.temperature);
90
+ const power = 5.670373e-14 / Math.PI * (1 + bb * (t**4 - 1));
91
+ medium.emission.add(blackbody(t).multiply(color(node.blackbody_tint)).multiplyScalar(power * weight));
92
+ }
93
+ }
94
+ if (node.kind === 'principled' || node.kind === 'emission')
95
+ medium.emission.add((node.kind === 'emission' ? c : color(node.emission_color)).multiplyScalar(scalar(node.emission_strength)*weight));
96
+ medium.extinction.add(absorption.add(scatter).multiplyScalar(weight));
97
+ }
98
+ for (const axis of ['x','y','z'] as const)
99
+ if (medium.extinction[axis] === 0 && medium.emission[axis] > 0)
100
+ throw new Error('Unbounded emissive World volume with zero extinction has divergent radiance');
101
+ return medium;
102
+ }
103
+
104
+ /** Integral of exp(-sigma*t) on a finite or infinite segment. */
105
+ export function mediumIntegral(sigma: number, distance: number): number {
106
+ return sigma > 0 ? -Math.expm1(-sigma*distance)/sigma : distance;
107
+ }
108
+
109
+ export function phaseDraine(cosine: number, g: number, alpha: number): number {
110
+ return (1-g*g)*(1+alpha*cosine*cosine) /
111
+ (4*Math.PI*(1+alpha*(1+2*g*g)/3)*Math.pow(1+g*g-2*g*cosine,1.5));
112
+ }
113
+
114
+ /** Caller owns the returned target until dispose. Construct per revision-owned
115
+ * capture, so a concurrent model edit cannot change its medium or resources. */
116
+ export class WorldVolumePass {
117
+ private readonly target = new THREE.WebGLRenderTarget(1,1,{type: THREE.HalfFloatType, depthBuffer: false});
118
+ private readonly geometry = new THREE.PlaneGeometry(2,2);
119
+ private readonly scene = new THREE.Scene();
120
+ private readonly camera = new THREE.Camera();
121
+ private material: THREE.ShaderMaterial | undefined;
122
+ constructor(readonly medium: WorldMedium) {}
123
+
124
+ render(renderer: THREE.WebGLRenderer, input: THREE.WebGLRenderTarget, scene: THREE.Scene, camera: THREE.Camera): THREE.WebGLRenderTarget {
125
+ if (!input.depthTexture) throw new Error('World volume render requires surface depth');
126
+ const uniforms: Record<string, THREE.IUniform> = {
127
+ surface: {value: input.texture}, depth: {value: input.depthTexture},
128
+ inverseProjection: {value: camera.projectionMatrixInverse}, cameraWorld: {value: camera.matrixWorld},
129
+ orthographic: {value: (camera as THREE.OrthographicCamera).isOrthographicCamera === true},
130
+ sigma: {value: this.medium.extinction}, emission: {value: this.medium.emission},
131
+ };
132
+ let declarations = '', lighting = '';
133
+ let lightIndex = 0, samplers = 2;
134
+ scene.traverseVisible(object => {
135
+ if (!(object instanceof THREE.PointLight || object instanceof THREE.SpotLight || object instanceof THREE.RectAreaLight)) return;
136
+ const light = object, id = lightIndex++;
137
+ const position = light.getWorldPosition(new THREE.Vector3());
138
+ uniforms[`lp${id}`] = {value: position};
139
+ uniforms[`lc${id}`] = {value: light.color.clone().multiplyScalar(light.intensity)};
140
+ declarations += `uniform vec3 lp${id}, lc${id};\n`;
141
+ let visibility = '1.0', spot = '1.0';
142
+ if (light instanceof THREE.SpotLight) {
143
+ uniforms[`ld${id}`] = {value: light.target.getWorldPosition(new THREE.Vector3()).sub(position).normalize()};
144
+ uniforms[`cone${id}`] = {value: new THREE.Vector2(Math.cos(light.angle), Math.cos(light.angle*(1-light.penumbra)))};
145
+ declarations += `uniform vec3 ld${id}; uniform vec2 cone${id};\n`;
146
+ spot = `smoothstep(cone${id}.x, max(cone${id}.x+1e-6,cone${id}.y),dot(-L,ld${id}))`;
147
+ }
148
+ if (!(light instanceof THREE.RectAreaLight) && light.castShadow && light.shadow.map) {
149
+ if (++samplers > renderer.capabilities.maxTextures) throw new Error('World volume shadow samplers exceed this GPU texture-unit limit');
150
+ uniforms[`sm${id}`] = {value: light.shadow.map.texture};
151
+ uniforms[`sx${id}`] = {value: light.shadow.matrix};
152
+ uniforms[`ss${id}`] = {value: light.shadow.mapSize};
153
+ uniforms[`sb${id}`] = {value: light.shadow.bias};
154
+ uniforms[`sn${id}`] = {value: light.shadow.camera.near};
155
+ uniforms[`sf${id}`] = {value: light.shadow.camera.far};
156
+ declarations += `uniform sampler2D sm${id}; uniform mat4 sx${id}; uniform vec2 ss${id}; uniform float sb${id},sn${id},sf${id};\n`;
157
+ visibility = light instanceof THREE.PointLight
158
+ ? `getPointShadow(sm${id},ss${id},1.0,sb${id},1.0,sx${id}*vec4(p,1.0),sn${id},sf${id})`
159
+ : `getShadow(sm${id},ss${id},1.0,sb${id},1.0,sx${id}*vec4(p,1.0))`;
160
+ }
161
+ if (light instanceof THREE.RectAreaLight) {
162
+ const rotation = new THREE.Matrix4().extractRotation(light.matrixWorld);
163
+ uniforms[`lx${id}`] = {value: new THREE.Vector3(light.width,0,0).applyMatrix4(rotation)};
164
+ uniforms[`ly${id}`] = {value: new THREE.Vector3(0,light.height,0).applyMatrix4(rotation)};
165
+ uniforms[`ln${id}`] = {value: new THREE.Vector3(0,0,-1).transformDirection(rotation)};
166
+ declarations += `uniform vec3 lx${id},ly${id},ln${id};\n`;
167
+ lighting += `for(int ax=0;ax<4;ax++) for(int ay=0;ay<4;ay++) {
168
+ vec3 delta=lp${id}+lx${id}*((float(ax)+.5)/4.-.5)+ly${id}*((float(ay)+.5)/4.-.5)-p;
169
+ float d=length(delta); vec3 L=delta/max(d,1e-6);
170
+ sum+=lc${id}*exp(-sigma*d)*phase(dot(L,ray))*max(dot(-L,ln${id}),0.)*
171
+ length(lx${id})*length(ly${id})/(16.*max(d*d,1e-4)); }\n`;
172
+ } else {
173
+ uniforms[`cut${id}`] = {value: light.distance};
174
+ uniforms[`decay${id}`] = {value: light.decay};
175
+ declarations += `uniform float cut${id},decay${id};\n`;
176
+ lighting += `{ vec3 delta=lp${id}-p; float d=length(delta); vec3 L=delta/max(d,1e-6);
177
+ float cutoff=cut${id}>0.?pow(clamp(1.-pow(d/cut${id},4.),0.,1.),2.):1.;
178
+ sum+=lc${id}*exp(-sigma*d)*phase(dot(L,ray))*${spot}*${visibility}*cutoff/max(pow(d,decay${id}),.01); }\n`;
179
+ }
180
+ });
181
+ let phase = 'vec3 result=vec3(0.);';
182
+ this.medium.lobes.forEach((lobe,i) => {
183
+ uniforms[`scatter${i}`] = {value: lobe.coefficient};
184
+ uniforms[`g${i}`] = {value: lobe.g}; uniforms[`a${i}`] = {value: lobe.alpha};
185
+ declarations += `uniform vec3 scatter${i}; uniform float g${i},a${i};\n`;
186
+ phase += `result+=scatter${i}*(1.-g${i}*g${i})*(1.+a${i}*c*c)/(12.56637061436*(1.+a${i}*(1.+2.*g${i}*g${i})/3.)*pow(1.+g${i}*g${i}-2.*g${i}*c,1.5));`;
187
+ });
188
+ // The finite quadrature tail is bounded at 20 optical depths (2e-9).
189
+ const positive = this.medium.extinction.toArray().filter(v => v > 0);
190
+ uniforms['range'] = {value: positive.length ? 20/Math.min(...positive) : 1};
191
+ this.material?.dispose();
192
+ this.material = new THREE.ShaderMaterial({uniforms, depthTest: false, depthWrite: false, toneMapped: false,
193
+ defines: {USE_SHADOWMAP: '', NUM_DIR_LIGHT_SHADOWS: 0, NUM_SPOT_LIGHT_SHADOWS: 0, NUM_POINT_LIGHT_SHADOWS: 0, NUM_SPOT_LIGHT_COORDS: 0},
194
+ vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=vec4(position.xy,0.,1.);}',
195
+ fragmentShader: `
196
+ #include <common>
197
+ #include <packing>
198
+ #include <shadowmap_pars_fragment>
199
+ varying vec2 vUv; uniform sampler2D surface,depth;
200
+ uniform mat4 inverseProjection,cameraWorld; uniform bool orthographic;
201
+ uniform vec3 sigma,emission; uniform float range;
202
+ ${declarations}
203
+ vec3 phase(float c){${phase} return result;}
204
+ vec3 incident(vec3 p,vec3 ray){vec3 sum=vec3(0.);${lighting}return sum;}
205
+ float integral(float s,float d){float x=s*d;return x<.001 ? d*(1.-x*.5+x*x/6.) : (1.-exp(-x))/s;}
206
+ void main(){
207
+ float z=texture2D(depth,vUv).r; vec4 base=texture2D(surface,vUv);
208
+ vec4 v=inverseProjection*vec4(vUv*2.-1.,z*2.-1.,1.);v/=v.w;
209
+ vec3 hit=(cameraWorld*v).xyz;
210
+ vec3 origin=cameraWorld[3].xyz;
211
+ if(orthographic) origin=(cameraWorld*vec4(v.xy,0.,1.)).xyz;
212
+ vec3 ray=normalize(hit-origin); float d=length(hit-origin); bool background=z>=1.;
213
+ vec3 tr=exp(-sigma*d);
214
+ vec3 integrals=vec3(integral(sigma.x,d),integral(sigma.y,d),integral(sigma.z,d));
215
+ if(background){tr=vec3(sigma.x>0.?0.:1.,sigma.y>0.?0.:1.,sigma.z>0.?0.:1.);
216
+ integrals=vec3(sigma.x>0.?1./sigma.x:0.,sigma.y>0.?1./sigma.y:0.,sigma.z>0.?1./sigma.z:0.);}
217
+ vec3 radiance=base.rgb*tr+emission*integrals;
218
+ float end=background?range:min(range,d);
219
+ for(int i=0;i<256;i++){
220
+ float lo=end*pow(float(i)/256.,3.); float hi=end*pow(float(i+1)/256.,3.);
221
+ float t=(lo+hi)*.5;
222
+ radiance+=incident(origin+ray*t,ray)*exp(-sigma*t)*(hi-lo);
223
+ }
224
+ gl_FragColor=vec4(radiance,base.a+(1.-base.a)*(1.-dot(tr,vec3(1./3.))));
225
+ }`});
226
+ this.scene.clear();
227
+ this.scene.add(new THREE.Mesh(this.geometry,this.material));
228
+ this.target.setSize(input.width,input.height);
229
+ const previous = renderer.getRenderTarget();
230
+ try { renderer.setRenderTarget(this.target); renderer.render(this.scene,this.camera); }
231
+ finally { renderer.setRenderTarget(previous); }
232
+ return this.target;
233
+ }
234
+ dispose(): void {this.material?.dispose();this.geometry.dispose();this.target.dispose();this.scene.clear();}
235
+ }
@@ -191,7 +191,10 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
191
191
  renderer.toneMapping = mappings[render.toneMapping] ?? THREE.AgXToneMapping;
192
192
  renderer.toneMappingExposure = render.exposure;
193
193
  const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
194
+ let effect: ReturnType<BlenderRuntimeView['createWorldVolumePass']>;
194
195
  try {
196
+ effect = render.linearInput ? undefined : view.createWorldVolumePass();
197
+ if (effect) sceneTarget.depthTexture = new THREE.DepthTexture(width, height);
195
198
  await view.setRendered(true, camera);
196
199
  let pixels: Uint16Array;
197
200
  if (render.linearInput) {
@@ -210,7 +213,8 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
210
213
  scene.background = background;
211
214
  }
212
215
  pixels = new Uint16Array(width * height * 4);
213
- renderer.readRenderTargetPixels(sceneTarget, 0, 0, width, height, pixels);
216
+ const resolved = effect?.render(renderer, sceneTarget, scene, camera) ?? sceneTarget;
217
+ renderer.readRenderTargetPixels(resolved, 0, 0, width, height, pixels);
214
218
  renderer.setRenderTarget(null);
215
219
  }
216
220
  const count = width * height;
@@ -239,6 +243,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
239
243
  }
240
244
  return answer(photograph);
241
245
  } finally {
246
+ effect?.dispose();
242
247
  sceneTarget.dispose();
243
248
  renderer.toneMapping = previousMapping;
244
249
  renderer.toneMappingExposure = previousExposure;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.5",
5
+ "version": "0.1.6",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "blender": "5.2.0 LTS",
3
- "source": "94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c",
4
- "reviewedSource": "a4dff7ba16a0cca24504d81ed0086457493e48dc",
3
+ "source": "68bba09924c2cf08cbceb6608be4a9e1500a62bb",
4
+ "reviewedSource": "68bba09924c2cf08cbceb6608be4a9e1500a62bb",
5
5
  "upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
6
6
  "sourceRepository": "https://github.com/volter-ai/blender",
7
- "sourceRecipe": "https://github.com/volter-ai/blender/blob/94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c/build_files/emscripten/RELEASE.md",
8
- "sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.1",
9
- "verification": "https://github.com/volter-ai/blender/blob/94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c/build_files/emscripten/VERIFICATION.json",
7
+ "sourceRecipe": "https://github.com/volter-ai/blender/blob/68bba09924c2cf08cbceb6608be4a9e1500a62bb/build_files/emscripten/RELEASE.md",
8
+ "sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.2",
9
+ "verification": "https://github.com/volter-ai/blender/releases/download/blender-5.2.0-wasm.2/VERIFICATION.json",
10
10
  "factoryEngine": "BLENDER_EEVEE",
11
11
  "files": {
12
12
  "blender_browser.js": "raw",
@@ -19,8 +19,8 @@
19
19
  "sha256": "2a5d4dbcc90cab571eb16eef9672ef8583e1c0a3638d0b85165d1944d191d9a7"
20
20
  },
21
21
  "blender_browser.wasm": {
22
- "bytes": 86289759,
23
- "sha256": "cdf49c116bd5ce56bc4e1c87f7b5da306310eeb86bdf237487b8673e4d6f54a0"
22
+ "bytes": 86296622,
23
+ "sha256": "fcc411449beadda7e7a73ec151844f62e0beaf1371f41d43a04a53b884c81c4d"
24
24
  },
25
25
  "blender_browser.data": {
26
26
  "bytes": 52698568,
@@ -33,12 +33,12 @@
33
33
  "sha256": "2a5d4dbcc90cab571eb16eef9672ef8583e1c0a3638d0b85165d1944d191d9a7"
34
34
  },
35
35
  "blender_browser.wasm.br": {
36
- "bytes": 18238560,
37
- "sha256": "58c481c0500009276041dfee684ad5e5d392917fb1b569d7192225b395aa7501"
36
+ "bytes": 18601052,
37
+ "sha256": "1038c911623c80cf08d1aeee2a0395097c8e6736a0c7d339c0f3b4c69be7840e"
38
38
  },
39
39
  "blender_browser.data.br": {
40
- "bytes": 7969535,
41
- "sha256": "9bb922432659580c938b0124d84c3b95d1f54797e3df0ebe26463bbf0be7284c"
40
+ "bytes": 8056563,
41
+ "sha256": "1d63c1250e2ab9538df36d7d07338396b271577c49227508ae7530f7bde2ee4a"
42
42
  },
43
43
  "DEPENDENCY-LICENSES.txt": {
44
44
  "bytes": 241352,
@@ -48,18 +48,26 @@
48
48
  "note": "Official Blender v5.2.0 plus our headless wasm changes and our existing export/session integration. Dependencies are rebuilt from pinned official sources. No third-party Blender port commits or build scripts are inputs. PROXY_TO_PTHREAD, WasmFS, 512 MiB initial / 4 GiB maximum memory, twelve-worker pool. Build information is disabled. The source release includes the exact source tree, materialized startup.blend, dependency archives, pure Python wheels, and notices.",
49
49
  "callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
50
50
  "engineIds": "The existing session registers Python render engines under CYCLES, BLENDER_EEVEE and BLENDER_WORKBENCH; the headless build permits built-in IDs to be shadowed.",
51
- "exportDoor": "Our existing bpy_web_export.cc and blender_web_session.cc are retained unchanged.",
51
+ "exportDoor": "The source-owned bpy_web_export.cc now exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals; blender_web_session.cc is unchanged.",
52
52
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained.",
53
53
  "verificationSummary": {
54
54
  "completedCalls": 362,
55
55
  "expectedCalls": 362,
56
56
  "freshEnginePerModel": true,
57
57
  "harnessFailures": 0,
58
- "baselineLimitations": [
59
- "The original working bundle also has the recorded geometry/image differences; no oracle or comparison threshold was changed.",
60
- "On the current editor host both bundles produce the same courier render getX errors (27 total recorded error positions); the historical host recorded four.",
61
- "Both bundles report geometry-less curve profile nodes in bridge/tram presentation.",
62
- "The current shared-engine board stalled on the frozen baseline; the historical fresh-engine-per-model mode was used for this comparison."
58
+ "modelResults": {
59
+ "16-market-courtyard": "match",
60
+ "17-workshop-interior": "match",
61
+ "18-riverside-bridge": "different",
62
+ "19-tram-stop": "different",
63
+ "20-rigged-courier": "different"
64
+ },
65
+ "limitations": [
66
+ "Exact native geometry comparisons remain different for bridge and courier; thresholds and oracles are unchanged.",
67
+ "All 70 bridge snapshots match the previous accepted binary byte-for-byte. Courier matches that binary after index remapping except two normal components differing by 0.000001.",
68
+ "Bridge/tram retain the previous geometry-less curve helper presentation reports.",
69
+ "Native Cycles image equality is not claimed. All photographed camera poses match.",
70
+ "The release adapter changes package imports, product document addressing and project setup, not frozen requests or comparison rules."
63
71
  ]
64
72
  }
65
73
  }
Binary file
Binary file