@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.
@@ -283,8 +283,8 @@ def _surface_backgrounds(node):
283
283
  return branches[0], branches[1]
284
284
 
285
285
 
286
- def _describe_background(background, camera_ray):
287
- """The Background's Color as the presenter's world expression (its grammar
286
+ def _describe_world_socket(root_socket, camera_ray):
287
+ """A World input as the presenter's expression (its grammar
288
288
  is `blender-runtime-lighting.ts`'s `worldExpression`); anything outside it
289
289
  is refused BY NAME through NotImplementedError."""
290
290
  visiting = set()
@@ -402,8 +402,12 @@ def _describe_background(background, camera_ray):
402
402
  finally:
403
403
  visiting.remove(key)
404
404
 
405
- color = value(background.inputs["Color"])
406
- strength = value(background.inputs["Strength"])
405
+ return value(root_socket)
406
+
407
+
408
+ def _describe_background(background, camera_ray):
409
+ color = _describe_world_socket(background.inputs["Color"], camera_ray)
410
+ strength = _describe_world_socket(background.inputs["Strength"], camera_ray)
407
411
  # The field evaluator already implements an unclamped color mix. Mixing
408
412
  # black with radiance at factor strength is exact multiplication, including
409
413
  # spatially varying strength, with no second shader implementation.
@@ -415,20 +419,65 @@ def _describe_background(background, camera_ray):
415
419
  "strength": float(strength), "shader": color}
416
420
 
417
421
 
422
+ def _describe_world_volume(socket, weight=1.0, visiting=None):
423
+ """Volume closures and their authored inputs, not a guessed fog color.
424
+ Mix/Add preserve individual scatter lobes rather than averaging anisotropy."""
425
+ links = list(socket.links)
426
+ if not links:
427
+ return []
428
+ node = links[0].from_node
429
+ visiting = set() if visiting is None else visiting
430
+ key = node.as_pointer()
431
+ if key in visiting:
432
+ raise NotImplementedError("World volume contains a cycle")
433
+ visiting.add(key)
434
+ try:
435
+ kind = node.bl_idname
436
+ if kind == "NodeReroute":
437
+ return _describe_world_volume(node.inputs[0], weight, visiting)
438
+ if kind == "ShaderNodeAddShader":
439
+ return sum((_describe_world_volume(s, weight, visiting) for s in node.inputs), [])
440
+ if kind == "ShaderNodeMixShader":
441
+ factor = _describe_world_socket(node.inputs[0], False)
442
+ factor = {"kind": "math", "operation": "MULTIPLY", "clamp": True, "inputs": [factor, 1.0]}
443
+ first = {"kind": "math", "operation": "SUBTRACT", "clamp": False, "inputs": [1.0, factor]}
444
+ return sum((_describe_world_volume(node.inputs[i + 1],
445
+ {"kind": "math", "operation": "MULTIPLY", "clamp": False, "inputs": [weight, f]}, visiting)
446
+ for i, f in enumerate((first, factor))), [])
447
+ kinds = {"ShaderNodeVolumeAbsorption": "absorption", "ShaderNodeVolumeScatter": "scatter",
448
+ "ShaderNodeVolumePrincipled": "principled", "ShaderNodeEmission": "emission"}
449
+ if kind not in kinds:
450
+ raise NotImplementedError("World volume shader %s" % kind)
451
+ def read(name, default):
452
+ value = node.inputs.get(name)
453
+ return _describe_world_socket(value, False) if value is not None else default
454
+ return [{"kind": kinds[kind], "weight": weight,
455
+ "phase": getattr(node, "phase", "HENYEY_GREENSTEIN"), "alpha": read("Alpha", 0.0),
456
+ "color": read("Color", [1.0, 1.0, 1.0]), "density": read("Density", 0.0),
457
+ "anisotropy": read("Anisotropy", 0.0), "absorption_color": read("Absorption Color", [0.0, 0.0, 0.0]),
458
+ "emission_color": read("Emission Color", [1.0, 1.0, 1.0]),
459
+ "emission_strength": read("Strength" if kinds[kind] == "emission" else "Emission Strength", 0.0),
460
+ "blackbody_intensity": read("Blackbody Intensity", 0.0), "temperature": read("Temperature", 1000.0),
461
+ "blackbody_tint": read("Blackbody Tint", [1.0, 1.0, 1.0])}]
462
+ finally:
463
+ visiting.remove(key)
464
+
465
+
418
466
  def _describe_world(world):
419
467
  outputs = [n for n in world.node_tree.nodes if n.bl_idname == "ShaderNodeOutputWorld" and n.is_active_output]
420
468
  if len(outputs) != 1:
421
469
  raise NotImplementedError("World rendering needs one active World Output")
422
470
  surface = list(outputs[0].inputs["Surface"].links)
423
- if len(surface) != 1:
424
- raise NotImplementedError("World Surface must connect to Background")
425
- if outputs[0].inputs["Volume"].links:
426
- raise NotImplementedError("World volume rendering is not implemented")
471
+ volume = _describe_world_volume(outputs[0].inputs["Volume"])
472
+ if not surface:
473
+ return {"color": [0.0, 0.0, 0.0], "strength": 0.0, "volume": volume}
427
474
  background, lighting = _surface_backgrounds(surface[0].from_node)
428
475
  described = _describe_background(background, camera_ray=True)
429
476
  lighting_data = _describe_background(lighting or background, camera_ray=False)
430
477
  if lighting_data != described:
431
478
  described["lighting"] = lighting_data
479
+ if volume:
480
+ described["volume"] = volume
432
481
  # Only a wholly constant background can drop the expression: linked
433
482
  # Strength may carry spatial radiance even when Color itself is constant.
434
483
  if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
@@ -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
- options = {"session": self.session, "evaluate": True, "known": self._known}
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
- scene = bpy.context.scene
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 */