@volter/blender-engine 0.1.6 → 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 +5 -0
- package/browser/session.py +505 -2
- package/browser/three/blender-frame-columns.ts +4 -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 +32 -2
- package/browser/three/blender-presenter-change.ts +24 -0
- package/browser/three/blender-runtime-geometry.ts +86 -6
- package/browser/three/blender-runtime-lighting.ts +3 -0
- package/browser/three/blender-runtime-view.ts +173 -5
- package/browser/three/blender-texture-samplers.ts +2 -0
- package/package.json +1 -1
- package/wasm/BUNDLE.json +11 -31
- package/wasm/blender_browser.wasm.br +0 -0
package/browser/runtime.ts
CHANGED
|
@@ -202,6 +202,11 @@ export class BlenderRuntime {
|
|
|
202
202
|
return this.#project;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/** The startup document owned by this worker, including while boot is pending. */
|
|
206
|
+
get document(): string | null {
|
|
207
|
+
return this.#document;
|
|
208
|
+
}
|
|
209
|
+
|
|
205
210
|
/** Idempotent: the first call boots Blender at `project`'s
|
|
206
211
|
* absolute path; later calls await it (a different path is refused).
|
|
207
212
|
*
|
package/browser/session.py
CHANGED
|
@@ -526,6 +526,475 @@ def draw_camera(obj):
|
|
|
526
526
|
}
|
|
527
527
|
|
|
528
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
|
+
|
|
529
998
|
# ------------------------------------------------------------- the overlays
|
|
530
999
|
#
|
|
531
1000
|
# INSPECTION OVERLAYS, READ OFF THE ENGINE (ARCHITECTURE-CORE §Blender north
|
|
@@ -760,7 +1229,12 @@ class Session:
|
|
|
760
1229
|
bones and the active vertex group's weights (`_armatures`,
|
|
761
1230
|
`_weights`). All per-scene, none of them geometry.
|
|
762
1231
|
"""
|
|
763
|
-
|
|
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"])}
|
|
764
1238
|
# THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
|
|
765
1239
|
# an ordered stream of filesystem patches that is the whole
|
|
766
1240
|
# correctness argument: whatever carries `ask/<id>.done` out of this
|
|
@@ -784,7 +1258,36 @@ class Session:
|
|
|
784
1258
|
# nine of 17-workshop-interior's 63 calls died on that refusal.
|
|
785
1259
|
self.revision += 1
|
|
786
1260
|
frame["revision"] = self.revision
|
|
787
|
-
|
|
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
|
|
788
1291
|
frame["world"] = draw_world(scene)
|
|
789
1292
|
frame["cameras"] = {
|
|
790
1293
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
@@ -81,6 +81,10 @@ export interface MeshColumns {
|
|
|
81
81
|
co: Float64Array;
|
|
82
82
|
/** Blender-evaluated normals, 3 per corner, independent of UV splits. */
|
|
83
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;
|
|
84
88
|
/** nf + 1 */
|
|
85
89
|
faceStart: Uint32Array;
|
|
86
90
|
/** faceStart[nf] entries: vertex index per loop */
|