@volter/blender-engine 0.1.4 → 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.
- package/browser/runtime.ts +26 -2
- package/browser/session.py +58 -9
- package/browser/three/blender-frame-columns.ts +2 -0
- package/browser/three/blender-physical-material.ts +132 -0
- package/browser/three/blender-runtime-geometry.ts +30 -7
- package/browser/three/blender-runtime-lighting.ts +8 -0
- package/browser/three/blender-runtime-view.ts +65 -45
- package/browser/three/blender-texture-samplers.ts +77 -0
- package/browser/three/blender-world-volume.ts +235 -0
- package/browser/three/presenter.ts +6 -1
- package/package.json +1 -1
- package/wasm/BUNDLE.json +25 -17
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.wasm.br +0 -0
package/browser/runtime.ts
CHANGED
|
@@ -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
|
|
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.#
|
|
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
|
}
|
package/browser/session.py
CHANGED
|
@@ -283,8 +283,8 @@ def _surface_backgrounds(node):
|
|
|
283
283
|
return branches[0], branches[1]
|
|
284
284
|
|
|
285
285
|
|
|
286
|
-
def
|
|
287
|
-
"""
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
package/wasm/BUNDLE.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"blender": "5.2.0 LTS",
|
|
3
|
-
"source": "
|
|
4
|
-
"reviewedSource": "
|
|
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/
|
|
8
|
-
"sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.
|
|
9
|
-
"verification": "https://github.com/volter-ai/blender/
|
|
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":
|
|
23
|
-
"sha256": "
|
|
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":
|
|
37
|
-
"sha256": "
|
|
36
|
+
"bytes": 18601052,
|
|
37
|
+
"sha256": "1038c911623c80cf08d1aeee2a0395097c8e6736a0c7d339c0f3b4c69be7840e"
|
|
38
38
|
},
|
|
39
39
|
"blender_browser.data.br": {
|
|
40
|
-
"bytes":
|
|
41
|
-
"sha256": "
|
|
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": "
|
|
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
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
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
|