@volter/blender-engine 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser/runtime.ts +31 -2
- package/browser/session.py +563 -11
- package/browser/three/blender-frame-columns.ts +6 -0
- package/browser/three/blender-graph-material.ts +402 -0
- package/browser/three/blender-node-glsl.generated.ts +401 -0
- package/browser/three/blender-node-graph.ts +1026 -0
- package/browser/three/blender-physical-material.ts +162 -0
- package/browser/three/blender-presenter-change.ts +24 -0
- package/browser/three/blender-runtime-geometry.ts +112 -9
- package/browser/three/blender-runtime-lighting.ts +11 -0
- package/browser/three/blender-runtime-view.ts +237 -49
- package/browser/three/blender-texture-samplers.ts +79 -0
- package/browser/three/blender-world-volume.ts +235 -0
- package/browser/three/presenter.ts +6 -1
- package/package.json +1 -1
- package/wasm/BUNDLE.json +13 -25
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.wasm.br +0 -0
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")):
|
|
@@ -477,6 +526,475 @@ def draw_camera(obj):
|
|
|
477
526
|
}
|
|
478
527
|
|
|
479
528
|
|
|
529
|
+
# ------------------------------------------------------- the material graphs
|
|
530
|
+
|
|
531
|
+
# THE MATERIAL GRAPH, for a surface whose inputs are more than constants.
|
|
532
|
+
#
|
|
533
|
+
# The export door reduces each material to what a three.js standard material
|
|
534
|
+
# holds -- a constant or one image per input. A LINKED input of a Principled
|
|
535
|
+
# BSDF or an Emission is instead shipped as the node graph that drives it, and
|
|
536
|
+
# the presenter compiles that graph with Blender's own node GLSL
|
|
537
|
+
# (`three/blender-node-graph.ts`, `three/blender-node-glsl.generated.ts`). The
|
|
538
|
+
# door is told which materials those are (`graph_materials`), so it stays quiet
|
|
539
|
+
# about reducing them, which images their graphs sample (`graph_images`), so it
|
|
540
|
+
# ships those pictures with the same revisions as every other, and which read
|
|
541
|
+
# Generated coordinates (`graph_generated`), so a deformed mesh wearing one
|
|
542
|
+
# ships Blender's orco -- or the door names why it cannot.
|
|
543
|
+
#
|
|
544
|
+
# The graph is flattened here: reroutes and muted nodes are followed the way
|
|
545
|
+
# Blender's own node tree evaluation follows them, and group nodes are inlined
|
|
546
|
+
# (their nodes keyed by the group path), so the presenter sees one acyclic
|
|
547
|
+
# list of Blender nodes. What reaches the presenter is Blender's own data:
|
|
548
|
+
# every input socket in declaration order with its GPU type and value, every
|
|
549
|
+
# output socket's GPU type, and the node's own RNA properties.
|
|
550
|
+
|
|
551
|
+
# The node types the presenter compiles. A graph reaching any other type is
|
|
552
|
+
# not shipped; the door's reduction stands and a warning names the node.
|
|
553
|
+
|
|
554
|
+
# EVERY LINK INTO A SOCKET, from one pass over its tree. `NodeSocket.links`
|
|
555
|
+
# is Python in Blender (`_bpy_types.py`): each read walks ALL of the tree's
|
|
556
|
+
# links, and the graph pass reads it per socket per present -- measured on a
|
|
557
|
+
# 28-material scene, 875 reads and 9 of the pass's 15 profiled seconds. The
|
|
558
|
+
# map is built once per tree per present (`material_graphs` clears it).
|
|
559
|
+
_TREE_LINKS = {}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _links_into(socket):
|
|
563
|
+
"""The valid, unmuted links into `socket` (Blender's own `links`, filtered)."""
|
|
564
|
+
tree = socket.id_data
|
|
565
|
+
held = _TREE_LINKS.get(tree.as_pointer())
|
|
566
|
+
if held is None:
|
|
567
|
+
held = {}
|
|
568
|
+
for link in tree.links:
|
|
569
|
+
if link.is_valid and not link.is_muted:
|
|
570
|
+
held.setdefault(link.to_socket.as_pointer(), []).append(link)
|
|
571
|
+
_TREE_LINKS[tree.as_pointer()] = held
|
|
572
|
+
return held.get(socket.as_pointer(), [])
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
_GRAPH_NODES = frozenset((
|
|
576
|
+
"ShaderNodeTexCoord", "ShaderNodeUVMap", "ShaderNodeValue", "ShaderNodeRGB",
|
|
577
|
+
"ShaderNodeTexImage", "ShaderNodeMapping", "ShaderNodeMath", "ShaderNodeVectorMath",
|
|
578
|
+
"ShaderNodeMix", "ShaderNodeMixRGB", "ShaderNodeValToRGB", "ShaderNodeInvert",
|
|
579
|
+
"ShaderNodeSeparateXYZ", "ShaderNodeCombineXYZ", "ShaderNodeTexNoise",
|
|
580
|
+
"ShaderNodeTexVoronoi", "ShaderNodeTexChecker", "ShaderNodeTexWhiteNoise",
|
|
581
|
+
"ShaderNodeTexWave", "ShaderNodeTexGradient", "ShaderNodeTexMagic", "ShaderNodeTexBrick",
|
|
582
|
+
"ShaderNodeMapRange", "ShaderNodeClamp", "ShaderNodeHueSaturation", "ShaderNodeBrightContrast",
|
|
583
|
+
"ShaderNodeGamma", "ShaderNodeRGBToBW", "ShaderNodeSeparateColor", "ShaderNodeCombineColor",
|
|
584
|
+
"ShaderNodeVectorRotate", "ShaderNodeFresnel", "ShaderNodeLayerWeight", "ShaderNodeBump",
|
|
585
|
+
"ShaderNodeNormalMap", "ShaderNodeNewGeometry", "ShaderNodeVertexColor", "ShaderNodeAttribute",
|
|
586
|
+
"ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve",
|
|
587
|
+
))
|
|
588
|
+
# The surfaces whose inputs map onto the presenter's standard material, and
|
|
589
|
+
# the inputs of each the presenter reads from a graph.
|
|
590
|
+
_GRAPH_SURFACES = {
|
|
591
|
+
"ShaderNodeBsdfPrincipled": ("Base Color", "Metallic", "Roughness", "Alpha",
|
|
592
|
+
"Emission Color", "Emission Strength", "Normal"),
|
|
593
|
+
"ShaderNodeEmission": ("Color", "Strength"),
|
|
594
|
+
}
|
|
595
|
+
# Procedural textures whose unlinked Vector is Generated coordinates.
|
|
596
|
+
_GENERATED_BY_DEFAULT = frozenset((
|
|
597
|
+
"ShaderNodeTexNoise", "ShaderNodeTexVoronoi", "ShaderNodeTexChecker", "ShaderNodeTexWave",
|
|
598
|
+
"ShaderNodeTexGradient", "ShaderNodeTexMagic", "ShaderNodeTexBrick",
|
|
599
|
+
))
|
|
600
|
+
# RNA properties every node has, which describe the node's place in the editor
|
|
601
|
+
# rather than what it computes.
|
|
602
|
+
_NODE_BASE_PROPERTIES = frozenset(p.identifier for p in bpy.types.ShaderNode.bl_rna.properties)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _gpu_type(socket):
|
|
606
|
+
"""Blender's `node_gpu_stack_from_data`: the GLSL type a socket is passed
|
|
607
|
+
as, or None for a socket GPU codegen leaves out of the call."""
|
|
608
|
+
kind = socket.type
|
|
609
|
+
if kind in ("VALUE", "INT", "BOOLEAN"):
|
|
610
|
+
return "float"
|
|
611
|
+
if kind == "VECTOR":
|
|
612
|
+
return "vec%d" % len(socket.default_value) if hasattr(socket, "default_value") else "vec3"
|
|
613
|
+
if kind in ("RGBA", "ROTATION"):
|
|
614
|
+
return "vec4"
|
|
615
|
+
if kind == "SHADER":
|
|
616
|
+
return "closure"
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _socket_value(socket, gpu):
|
|
621
|
+
"""An unlinked socket's value in its GPU type (`nodestack_get_vec`)."""
|
|
622
|
+
if not hasattr(socket, "default_value"):
|
|
623
|
+
return 0.0 if gpu == "float" else [0.0] * int(gpu[3:])
|
|
624
|
+
value = socket.default_value
|
|
625
|
+
if gpu == "float":
|
|
626
|
+
return float(value)
|
|
627
|
+
values = [float(v) for v in value]
|
|
628
|
+
if socket.type == "ROTATION":
|
|
629
|
+
# A rotation socket's value is an Euler; codegen carries it as a vec4.
|
|
630
|
+
values = values + [0.0]
|
|
631
|
+
return values
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _curve_mapping(mapping):
|
|
635
|
+
"""What `gpu_shader_curve_*` hands the GPU, read through Blender itself:
|
|
636
|
+
`BKE_curvemapping_table_RGBA`'s 257 samples per curve, each curve's table
|
|
637
|
+
range (`mintable`/`maxtable`: the clip rectangle widened to the points,
|
|
638
|
+
`curvemap_make_table`) and its extrapolation slopes in that normalized
|
|
639
|
+
range (`BKE_curvemapping_compute_slopes`; horizontal without Extrapolated).
|
|
640
|
+
The samples and slopes come from `CurveMapping.evaluate`, the same
|
|
641
|
+
evaluation the table holds -- never a transcribed Bezier."""
|
|
642
|
+
curves = list(mapping.curves)
|
|
643
|
+
table = [[0.0, 0.0, 0.0, 0.0] for _ in range(_RAMP_SAMPLES)]
|
|
644
|
+
minimums, dividers, starts, ends = [0.0] * 4, [1e8] * 4, [0.0] * 4, [0.0] * 4
|
|
645
|
+
extrapolate = mapping.extend == "EXTRAPOLATED"
|
|
646
|
+
for index, curve in enumerate(curves[:4]):
|
|
647
|
+
points = [p.location for p in curve.points]
|
|
648
|
+
if len(points) >= 2:
|
|
649
|
+
low = min([mapping.clip_min_x] + [p[0] for p in points])
|
|
650
|
+
high = max([mapping.clip_max_x] + [p[0] for p in points])
|
|
651
|
+
else:
|
|
652
|
+
low = high = 0.0
|
|
653
|
+
span = high - low
|
|
654
|
+
minimums[index] = low
|
|
655
|
+
dividers[index] = 1.0 / max(1e-8, span)
|
|
656
|
+
for i in range(_RAMP_SAMPLES):
|
|
657
|
+
table[i][index] = float(mapping.evaluate(curve, low + span * i / (_RAMP_SAMPLES - 1)))
|
|
658
|
+
if extrapolate and span > 0:
|
|
659
|
+
step = span * 1e-3
|
|
660
|
+
starts[index] = (mapping.evaluate(curve, low) - mapping.evaluate(curve, low - step)) / step * span
|
|
661
|
+
ends[index] = (mapping.evaluate(curve, high + step) - mapping.evaluate(curve, high)) / step * span
|
|
662
|
+
return {"table": table, "minimums": minimums, "dividers": dividers, "starts": starts, "ends": ends}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _node_properties(node):
|
|
666
|
+
"""The node's own RNA properties: enums, flags and numbers, plus the two
|
|
667
|
+
data-block values a compiled node reads (its colour ramp, its image)."""
|
|
668
|
+
props = {}
|
|
669
|
+
for prop in node.bl_rna.properties:
|
|
670
|
+
name = prop.identifier
|
|
671
|
+
if name in _NODE_BASE_PROPERTIES:
|
|
672
|
+
continue
|
|
673
|
+
if prop.type in ("ENUM", "BOOLEAN", "INT", "FLOAT", "STRING") and not getattr(prop, "is_array", False):
|
|
674
|
+
value = getattr(node, name)
|
|
675
|
+
props[name] = sorted(value) if isinstance(value, set) else value
|
|
676
|
+
if prop.type == "ENUM" and not prop.is_enum_flag:
|
|
677
|
+
# The enum's DNA value, which is what the GPU binding passes as
|
|
678
|
+
# a constant (`GPU_constant(&tex->wave_type)`): read from RNA,
|
|
679
|
+
# never transcribed.
|
|
680
|
+
try:
|
|
681
|
+
props[name + "#value"] = prop.enum_items[value].value
|
|
682
|
+
except (KeyError, TypeError):
|
|
683
|
+
pass # a dynamic enum has no static item table
|
|
684
|
+
if node.bl_idname == "ShaderNodeValToRGB":
|
|
685
|
+
ramp = node.color_ramp
|
|
686
|
+
props["color_ramp"] = {
|
|
687
|
+
"interpolation": ramp.interpolation,
|
|
688
|
+
"color_mode": ramp.color_mode,
|
|
689
|
+
"elements": [[float(e.position)] + [float(c) for c in e.color] for e in ramp.elements],
|
|
690
|
+
# The table Blender's GPU path samples (BKE_colorband_evaluate_table_rgba),
|
|
691
|
+
# read through Blender's own evaluator.
|
|
692
|
+
"table": [[float(c) for c in ramp.evaluate(i / 256)] for i in range(_RAMP_SAMPLES)],
|
|
693
|
+
}
|
|
694
|
+
if node.bl_idname in ("ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve"):
|
|
695
|
+
props["curve_mapping"] = _curve_mapping(node.mapping)
|
|
696
|
+
if node.bl_idname == "ShaderNodeTexImage":
|
|
697
|
+
image = node.image
|
|
698
|
+
props["image"] = image.name if image is not None else None
|
|
699
|
+
if image is not None:
|
|
700
|
+
# What decides the Color output's alpha handling
|
|
701
|
+
# (`node_shader_gpu_tex_image`): the image's alpha mode, and
|
|
702
|
+
# whether its colour space is data.
|
|
703
|
+
props["image_alpha_mode"] = image.alpha_mode
|
|
704
|
+
props["image_is_data"] = bool(image.colorspace_settings.is_data)
|
|
705
|
+
# UDIM: a flat projection samples every tile (the door ships them).
|
|
706
|
+
props["image_tiled"] = image.source == "TILED"
|
|
707
|
+
if node.bl_idname in ("ShaderNodeValue", "ShaderNodeRGB"):
|
|
708
|
+
# These nodes' value is their OUTPUT socket's (`set_value`/`set_rgba`
|
|
709
|
+
# of a uniform in Blender's GPU path).
|
|
710
|
+
props["value"] = _socket_value(node.outputs[0], _gpu_type(node.outputs[0]))
|
|
711
|
+
return props
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _refusal(node):
|
|
715
|
+
"""What of a supported node type the presenter does not compile yet, or None."""
|
|
716
|
+
kind = node.bl_idname
|
|
717
|
+
if kind == "ShaderNodeTexImage":
|
|
718
|
+
image = node.image
|
|
719
|
+
if node.projection == "BOX" and node.extension == "CLIP":
|
|
720
|
+
return "%s clips a Box projection, which is not compiled" % node.name
|
|
721
|
+
if image is not None and image.source not in ("FILE", "GENERATED", "TILED"):
|
|
722
|
+
return "%s's image is a %s source" % (node.name, image.source)
|
|
723
|
+
if kind == "ShaderNodeNewGeometry" and node.outputs["Parametric"].is_linked:
|
|
724
|
+
# EEVEE's parametric is the triangle's barycentrics, which the
|
|
725
|
+
# presenter's meshes do not carry.
|
|
726
|
+
return "%s's Parametric output is not compiled" % node.name
|
|
727
|
+
if kind == "ShaderNodeAttribute" and node.attribute_type != "GEOMETRY":
|
|
728
|
+
return "%s reads an %s attribute; only Geometry attributes are compiled" % (
|
|
729
|
+
node.name, node.attribute_type.replace("_", " ").title())
|
|
730
|
+
if kind == "ShaderNodeTexCoord" and node.object is not None:
|
|
731
|
+
return "%s reads object coordinates of %s" % (node.name, node.object.name)
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class _GraphRefusal(Exception):
|
|
736
|
+
pass
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
class _MaterialGraph:
|
|
740
|
+
"""One material's graph, flattened. `nodes` is keyed by group path."""
|
|
741
|
+
|
|
742
|
+
def __init__(self, material):
|
|
743
|
+
self.material = material
|
|
744
|
+
self.nodes = {}
|
|
745
|
+
self.images = set()
|
|
746
|
+
self._building = set()
|
|
747
|
+
# Whether the graph reads Generated coordinates: Texture Coordinate's
|
|
748
|
+
# first output, or a procedural texture's unlinked Vector, which
|
|
749
|
+
# Blender defaults to Generated (`node_shader_gpu_default_tex_coord`).
|
|
750
|
+
self.generated = False
|
|
751
|
+
|
|
752
|
+
# A socket is addressed inside a STACK of group nodes; the empty stack is
|
|
753
|
+
# the material's own tree.
|
|
754
|
+
|
|
755
|
+
def source(self, stack, socket):
|
|
756
|
+
"""An input socket's value: `{"value": v}` or `{"link": [node, output]}`."""
|
|
757
|
+
gpu = _gpu_type(socket)
|
|
758
|
+
links = _links_into(socket)
|
|
759
|
+
if not links:
|
|
760
|
+
return {"value": _socket_value(socket, gpu)}
|
|
761
|
+
return self.output(stack, links[0].from_node, links[0].from_socket, gpu)
|
|
762
|
+
|
|
763
|
+
def output(self, stack, node, socket, wanted):
|
|
764
|
+
kind = node.bl_idname
|
|
765
|
+
if kind == "NodeReroute":
|
|
766
|
+
return self.source(stack, node.inputs[0])
|
|
767
|
+
if node.mute:
|
|
768
|
+
for link in node.internal_links:
|
|
769
|
+
if link.to_socket == socket:
|
|
770
|
+
return self.source(stack, link.from_socket)
|
|
771
|
+
return {"value": 0.0 if wanted == "float" else [0.0] * int(wanted[3:])}
|
|
772
|
+
if kind == "ShaderNodeGroup":
|
|
773
|
+
tree = node.node_tree
|
|
774
|
+
if tree is None:
|
|
775
|
+
raise _GraphRefusal("group %s has no node tree" % node.name)
|
|
776
|
+
outputs = [n for n in tree.nodes if n.bl_idname == "NodeGroupOutput"]
|
|
777
|
+
active = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
|
|
778
|
+
if active is None:
|
|
779
|
+
raise _GraphRefusal("group %s has no Group Output" % tree.name)
|
|
780
|
+
inner = next(s for s in active.inputs if s.identifier == socket.identifier)
|
|
781
|
+
return self.source(stack + [node], inner)
|
|
782
|
+
if kind == "NodeGroupInput":
|
|
783
|
+
if not stack:
|
|
784
|
+
raise _GraphRefusal("a Group Input outside a group")
|
|
785
|
+
outer = stack[-1]
|
|
786
|
+
outer_socket = next(s for s in outer.inputs if s.identifier == socket.identifier)
|
|
787
|
+
return self.source(stack[:-1], outer_socket)
|
|
788
|
+
if kind not in _GRAPH_NODES:
|
|
789
|
+
raise _GraphRefusal("%s (%s) is not compiled by the presenter" % (node.name, kind))
|
|
790
|
+
refusal = _refusal(node)
|
|
791
|
+
if refusal is not None:
|
|
792
|
+
raise _GraphRefusal(refusal)
|
|
793
|
+
key = "/".join([group.name for group in stack] + [node.name])
|
|
794
|
+
if key not in self.nodes:
|
|
795
|
+
if key in self._building:
|
|
796
|
+
raise _GraphRefusal("%s is part of a cycle" % node.name)
|
|
797
|
+
self._building.add(key)
|
|
798
|
+
self.nodes[key] = self._describe(stack, node)
|
|
799
|
+
self._building.discard(key)
|
|
800
|
+
index = next(i for i, s in enumerate(node.outputs) if s == socket)
|
|
801
|
+
# Generated coordinates, and the Geometry node's Tangent (node_tangent
|
|
802
|
+
# of the orco).
|
|
803
|
+
if (kind == "ShaderNodeTexCoord" and index == 0) or (
|
|
804
|
+
kind == "ShaderNodeNewGeometry" and socket.name == "Tangent"):
|
|
805
|
+
self.generated = True
|
|
806
|
+
return {"link": [key, index]}
|
|
807
|
+
|
|
808
|
+
def _describe(self, stack, node):
|
|
809
|
+
if node.bl_idname in _GENERATED_BY_DEFAULT and not bool(_links_into(node.inputs[0])):
|
|
810
|
+
self.generated = True
|
|
811
|
+
props = _node_properties(node)
|
|
812
|
+
if props.get("image"):
|
|
813
|
+
self.images.add(props["image"])
|
|
814
|
+
return {
|
|
815
|
+
"type": node.bl_idname,
|
|
816
|
+
"props": props,
|
|
817
|
+
"inputs": [
|
|
818
|
+
dict(self.source(stack, s), id=s.identifier, gpu=_gpu_type(s), enabled=s.enabled)
|
|
819
|
+
for s in node.inputs if _gpu_type(s) not in (None, "closure")
|
|
820
|
+
],
|
|
821
|
+
"outputs": [{"id": s.identifier, "gpu": _gpu_type(s)} for s in node.outputs],
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _surface_node(tree):
|
|
826
|
+
"""The shader node Surface reaches, through reroutes and groups, or None."""
|
|
827
|
+
outputs = [n for n in tree.nodes
|
|
828
|
+
if n.bl_idname == "ShaderNodeOutputMaterial" and n.target in ("ALL", "EEVEE")]
|
|
829
|
+
output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
|
|
830
|
+
if output is None:
|
|
831
|
+
return None, None
|
|
832
|
+
return _follow(output.inputs["Surface"], [])
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _follow(socket, stack):
|
|
836
|
+
"""The node an input socket's link comes from, through reroutes and groups,
|
|
837
|
+
as `(stack, node)`, or `(None, None)` when nothing is linked."""
|
|
838
|
+
while True:
|
|
839
|
+
links = _links_into(socket)
|
|
840
|
+
if not links:
|
|
841
|
+
return None, None
|
|
842
|
+
node, out = links[0].from_node, links[0].from_socket
|
|
843
|
+
if node.bl_idname == "NodeReroute":
|
|
844
|
+
socket = node.inputs[0]
|
|
845
|
+
elif node.bl_idname == "ShaderNodeGroup" and node.node_tree is not None:
|
|
846
|
+
inner = [n for n in node.node_tree.nodes if n.bl_idname == "NodeGroupOutput"]
|
|
847
|
+
if not inner:
|
|
848
|
+
return None, None
|
|
849
|
+
stack = stack + [node]
|
|
850
|
+
socket = next(s for s in inner[0].inputs if s.identifier == out.identifier)
|
|
851
|
+
elif node.bl_idname == "NodeGroupInput" and stack:
|
|
852
|
+
socket = next(s for s in stack[-1].inputs if s.identifier == out.identifier)
|
|
853
|
+
stack = stack[:-1]
|
|
854
|
+
else:
|
|
855
|
+
return stack, node
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _light_path_side(socket, stack):
|
|
859
|
+
"""A Mix Shader factor that separates the camera's surface (a Light Path's
|
|
860
|
+
Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
|
|
861
|
+
as the input index the camera sees; None for any other factor. The door
|
|
862
|
+
collapses exactly these (`bpy_web_export.cc`)."""
|
|
863
|
+
links = _links_into(socket)
|
|
864
|
+
if not links:
|
|
865
|
+
value = socket.default_value
|
|
866
|
+
return 1 if value == 0.0 else 2 if value == 1.0 else None
|
|
867
|
+
link = links[0]
|
|
868
|
+
if link.from_node.bl_idname == "ShaderNodeLightPath":
|
|
869
|
+
return {"Is Shadow Ray": 1, "Is Camera Ray": 2}.get(link.from_socket.name)
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _collapse(stack, node):
|
|
874
|
+
"""Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
|
|
875
|
+
while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
|
|
876
|
+
side = _light_path_side(node.inputs[0], stack)
|
|
877
|
+
if side is None:
|
|
878
|
+
break
|
|
879
|
+
stack, node = _follow(node.inputs[side], stack)
|
|
880
|
+
return stack, node
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _closure(graph, stack, node, lit):
|
|
884
|
+
"""A shader mix as the tree the presenter composes: `{"mix": [factor, a,
|
|
885
|
+
b]}`, `{"add": [a, b]}`, and its leaves `{"principled": true}`,
|
|
886
|
+
`{"emission": [color, strength]}`, `{"transparent": gray}` and `{"none":
|
|
887
|
+
true}` (an empty shader socket). The one Principled BSDF is appended to
|
|
888
|
+
`lit`; the door reduces its constants, and it reaches it only through
|
|
889
|
+
mixes and reroutes, never a group."""
|
|
890
|
+
stack, node = _collapse(stack, node)
|
|
891
|
+
if node is None:
|
|
892
|
+
return {"none": True}
|
|
893
|
+
if stack:
|
|
894
|
+
raise _GraphRefusal("its shader mix passes through group %s" % stack[-1].name)
|
|
895
|
+
if node.mute:
|
|
896
|
+
raise _GraphRefusal("%s is muted" % node.name)
|
|
897
|
+
kind = node.bl_idname
|
|
898
|
+
if kind in ("ShaderNodeMixShader", "ShaderNodeAddShader"):
|
|
899
|
+
sides = [_closure(graph, *_follow(node.inputs[i], stack), lit)
|
|
900
|
+
for i in ((1, 2) if kind == "ShaderNodeMixShader" else (0, 1))]
|
|
901
|
+
if kind == "ShaderNodeAddShader":
|
|
902
|
+
return {"add": sides}
|
|
903
|
+
return {"mix": [graph.source(stack, node.inputs[0])] + sides}
|
|
904
|
+
if kind == "ShaderNodeBsdfPrincipled":
|
|
905
|
+
if lit and lit[0] != node:
|
|
906
|
+
raise _GraphRefusal("it mixes two Principled BSDFs (%s and %s)" % (lit[0].name, node.name))
|
|
907
|
+
lit[:] = [node]
|
|
908
|
+
return {"principled": True}
|
|
909
|
+
if kind == "ShaderNodeEmission":
|
|
910
|
+
return {"emission": [graph.source(stack, node.inputs["Color"]),
|
|
911
|
+
graph.source(stack, node.inputs["Strength"])]}
|
|
912
|
+
if kind == "ShaderNodeBsdfTransparent":
|
|
913
|
+
color = node.inputs["Color"]
|
|
914
|
+
if color.is_linked:
|
|
915
|
+
raise _GraphRefusal("%s's Color is linked; a tinted transparency is not compiled" % node.name)
|
|
916
|
+
r, g, b = color.default_value[:3]
|
|
917
|
+
if not (r == g == b):
|
|
918
|
+
raise _GraphRefusal("%s is tinted; only a gray transparency is compiled" % node.name)
|
|
919
|
+
return {"transparent": float(r)}
|
|
920
|
+
raise _GraphRefusal("%s (%s) is not a shader the presenter composes" % (node.name, kind))
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def material_graph(material):
|
|
924
|
+
"""The graph the presenter compiles for this material, or None when the
|
|
925
|
+
door's reduction describes it fully (or the surface is not one the graph
|
|
926
|
+
covers). A graph outside `_GRAPH_NODES` is a warning naming the node."""
|
|
927
|
+
if not material.use_nodes or material.node_tree is None:
|
|
928
|
+
return None
|
|
929
|
+
stack, surface = _collapse(*_surface_node(material.node_tree))
|
|
930
|
+
if surface is not None and surface.bl_idname in ("ShaderNodeMixShader", "ShaderNodeAddShader"):
|
|
931
|
+
return _mixed_graph(material, stack, surface)
|
|
932
|
+
if surface is None or surface.bl_idname not in _GRAPH_SURFACES:
|
|
933
|
+
return None
|
|
934
|
+
sockets = [surface.inputs[name] for name in _GRAPH_SURFACES[surface.bl_idname]]
|
|
935
|
+
if not any(_links_into(s) for s in sockets):
|
|
936
|
+
return None
|
|
937
|
+
# The door is quiet about a material whose graph ships, so a linked input
|
|
938
|
+
# the graph does not carry is named here.
|
|
939
|
+
for other in surface.inputs:
|
|
940
|
+
linked = bool(_links_into(other))
|
|
941
|
+
if not linked or other in sockets:
|
|
942
|
+
continue
|
|
943
|
+
warn("%s: %s is linked; only its constant is drawn" % (material.name, other.name))
|
|
944
|
+
graph = _MaterialGraph(material)
|
|
945
|
+
try:
|
|
946
|
+
inputs = {s.name: graph.source(stack, s) for s in sockets}
|
|
947
|
+
except _GraphRefusal as refusal:
|
|
948
|
+
warn("%s: its graph is drawn as constants; %s" % (material.name, refusal))
|
|
949
|
+
return None
|
|
950
|
+
return {
|
|
951
|
+
"surface": surface.bl_idname,
|
|
952
|
+
"inputs": inputs,
|
|
953
|
+
"nodes": graph.nodes,
|
|
954
|
+
"images": sorted(graph.images),
|
|
955
|
+
"generated": graph.generated,
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _mixed_graph(material, stack, surface):
|
|
960
|
+
"""A surface that mixes or adds shaders: the closure tree, and the one
|
|
961
|
+
Principled BSDF's graph-driven inputs (`_closure`)."""
|
|
962
|
+
graph = _MaterialGraph(material)
|
|
963
|
+
lit = []
|
|
964
|
+
try:
|
|
965
|
+
closure = _closure(graph, stack, surface, lit)
|
|
966
|
+
inputs = {}
|
|
967
|
+
if lit:
|
|
968
|
+
sockets = [lit[0].inputs[name] for name in _GRAPH_SURFACES["ShaderNodeBsdfPrincipled"]]
|
|
969
|
+
for other in lit[0].inputs:
|
|
970
|
+
if other not in sockets and _links_into(other):
|
|
971
|
+
warn("%s: %s is linked; only its constant is drawn" % (material.name, other.name))
|
|
972
|
+
inputs = {s.name: graph.source([], s) for s in sockets}
|
|
973
|
+
except _GraphRefusal as refusal:
|
|
974
|
+
warn("%s: its shader mix is not drawn; %s" % (material.name, refusal))
|
|
975
|
+
return None
|
|
976
|
+
return {
|
|
977
|
+
"surface": "ShaderNodeBsdfPrincipled" if lit else "closure",
|
|
978
|
+
"inputs": inputs,
|
|
979
|
+
"closure": closure,
|
|
980
|
+
"nodes": graph.nodes,
|
|
981
|
+
"images": sorted(graph.images),
|
|
982
|
+
"generated": graph.generated,
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def material_graphs(scene):
|
|
987
|
+
"""Every graph the scene's materials need, by material name."""
|
|
988
|
+
_TREE_LINKS.clear()
|
|
989
|
+
graphs = {}
|
|
990
|
+
for obj in scene.objects:
|
|
991
|
+
for slot in getattr(obj, "material_slots", ()):
|
|
992
|
+
material = slot.material
|
|
993
|
+
if material is not None and material.name not in graphs:
|
|
994
|
+
graphs[material.name] = material_graph(material)
|
|
995
|
+
return {name: graph for name, graph in graphs.items() if graph is not None}
|
|
996
|
+
|
|
997
|
+
|
|
480
998
|
# ------------------------------------------------------------- the overlays
|
|
481
999
|
#
|
|
482
1000
|
# INSPECTION OVERLAYS, READ OFF THE ENGINE (ARCHITECTURE-CORE §Blender north
|
|
@@ -711,7 +1229,12 @@ class Session:
|
|
|
711
1229
|
bones and the active vertex group's weights (`_armatures`,
|
|
712
1230
|
`_weights`). All per-scene, none of them geometry.
|
|
713
1231
|
"""
|
|
714
|
-
|
|
1232
|
+
scene = bpy.context.scene
|
|
1233
|
+
graphs = material_graphs(scene)
|
|
1234
|
+
options = {"session": self.session, "evaluate": True, "known": self._known,
|
|
1235
|
+
"graph_materials": sorted(graphs),
|
|
1236
|
+
"graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
|
|
1237
|
+
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
715
1238
|
# THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
|
|
716
1239
|
# an ordered stream of filesystem patches that is the whole
|
|
717
1240
|
# correctness argument: whatever carries `ask/<id>.done` out of this
|
|
@@ -735,7 +1258,36 @@ class Session:
|
|
|
735
1258
|
# nine of 17-workshop-interior's 63 calls died on that refusal.
|
|
736
1259
|
self.revision += 1
|
|
737
1260
|
frame["revision"] = self.revision
|
|
738
|
-
|
|
1261
|
+
# THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
|
|
1262
|
+
# named at the revision the door holds it at (`graph_images`, which is
|
|
1263
|
+
# the session's to read and not part of the presenter's frame).
|
|
1264
|
+
revisions = frame.pop("graph_images", {})
|
|
1265
|
+
for name, graph in graphs.items():
|
|
1266
|
+
if name not in frame["materials"]:
|
|
1267
|
+
continue
|
|
1268
|
+
for node in graph["nodes"].values():
|
|
1269
|
+
image = node["props"].get("image")
|
|
1270
|
+
if image is not None:
|
|
1271
|
+
node["props"]["image"] = {"name": image, "revision": int(revisions.get(image, 0))}
|
|
1272
|
+
del graph["images"]
|
|
1273
|
+
del graph["generated"]
|
|
1274
|
+
frame["materials"][name]["graph"] = graph
|
|
1275
|
+
# A MANUAL TEXTURE SPACE, which Generated coordinates map through; the
|
|
1276
|
+
# automatic one is the evaluated bounds the presenter already holds.
|
|
1277
|
+
if graphs:
|
|
1278
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
1279
|
+
for row in frame["objects"]:
|
|
1280
|
+
if not any(m in graphs for m in row["materials"] if m is not None):
|
|
1281
|
+
continue
|
|
1282
|
+
obj = scene.objects.get(row["name"])
|
|
1283
|
+
data = obj.evaluated_get(depsgraph).data if obj is not None else None
|
|
1284
|
+
if getattr(data, "use_auto_texspace", True) is False:
|
|
1285
|
+
row["texspace"] = [[float(v) for v in data.texspace_location],
|
|
1286
|
+
[float(v) for v in data.texspace_size]]
|
|
1287
|
+
# The layer a Color Attribute node with no name reads.
|
|
1288
|
+
default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
|
|
1289
|
+
if default_color:
|
|
1290
|
+
row["default_color"] = default_color
|
|
739
1291
|
frame["world"] = draw_world(scene)
|
|
740
1292
|
frame["cameras"] = {
|
|
741
1293
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
@@ -870,7 +1422,7 @@ class Session:
|
|
|
870
1422
|
"""
|
|
871
1423
|
carried = frame.get("images") or {}
|
|
872
1424
|
for material in (frame.get("materials") or {}).values():
|
|
873
|
-
for slot in ("texture", "roughness_texture"):
|
|
1425
|
+
for slot in ("texture", "roughness_texture", "normal_texture"):
|
|
874
1426
|
reference = material.get(slot)
|
|
875
1427
|
if not isinstance(reference, dict):
|
|
876
1428
|
continue
|
|
@@ -79,6 +79,12 @@ 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;
|
|
84
|
+
/** 3 * nv, a deformed mesh's Generated coordinates in Blender's stored
|
|
85
|
+
* -1..1 range (the door's `deformed_orco`); absent when the drawn
|
|
86
|
+
* positions are the undeformed ones. */
|
|
87
|
+
orco?: Float32Array | undefined;
|
|
82
88
|
/** nf + 1 */
|
|
83
89
|
faceStart: Uint32Array;
|
|
84
90
|
/** faceStart[nf] entries: vertex index per loop */
|