WIP: MaterialX addon #104594

Closed
Bogdan Nagirniak wants to merge 34 commits from BogdanNagirniak/blender-addons:materialx-addon into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
18 changed files with 2947 additions and 0 deletions

50
materialx/__init__.py Normal file
View File

@ -0,0 +1,50 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
"""
MaterialX nodes addon
"""
bl_info = {
"name": "MaterialX nodes",
"description": "MaterialX nodes addon",
"author": "AMD",
"version": (1, 0, 0),
"blender": (3, 4, 0),
"location": "Editor Type -> Shader Editor",
"doc_url": "{BLENDER_MANUAL_URL}/addons/materials/materialx.html",
"warning": "Alpha",
"support": "TESTING",
"category": "Material",
}
ADDON_ALIAS = "materialx"
from . import (
preferences,
nodes,
ui,
utils,
)
from . import logging
log = logging.Log("__init__")
def register():
log("register")
preferences.register()
nodes.register()
ui.register()
def unregister():
log("unregister")
utils.clear_temp_dir()
ui.unregister()
nodes.unregister()
preferences.unregister()

View File

@ -0,0 +1,38 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from .. import logging
log = logging.Log("bl_nodes")
from . import (
color,
converter,
input,
output,
shader,
texture,
vector,
)
node_parser_classes = (
output.ShaderNodeOutputMaterial,
color.ShaderNodeInvert,
color.ShaderNodeMixRGB,
converter.ShaderNodeMath,
input.ShaderNodeValue,
input.ShaderNodeRGB,
shader.ShaderNodeAddShader,
shader.ShaderNodeMixShader,
shader.ShaderNodeEmission,
shader.ShaderNodeBsdfGlass,
shader.ShaderNodeBsdfDiffuse,
shader.ShaderNodeBsdfPrincipled,
texture.ShaderNodeTexImage,
vector.ShaderNodeNormalMap,
)

View File

@ -0,0 +1,76 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from .node_parser import NodeParser
from . import log
class ShaderNodeInvert(NodeParser):
def export(self):
fac = self.get_input_value('Fac')
color = self.get_input_value('Color')
return fac.blend(color, 1.0 - color)
class ShaderNodeMixRGB(NodeParser):
def export(self):
fac = self.get_input_value('Fac')
color1 = self.get_input_value('Color1')
color2 = self.get_input_value('Color2')
# these mix types are copied from cycles OSL
blend_type = self.node.blend_type
if blend_type in ('MIX', 'COLOR'):
res = fac.blend(color1, color2)
elif blend_type == 'ADD':
res = fac.blend(color1, color1 + color2)
elif blend_type == 'MULTIPLY':
res = fac.blend(color1, color1 * color2)
elif blend_type == 'SUBTRACT':
res = fac.blend(color1, color1 - color2)
elif blend_type == 'DIVIDE':
res = fac.blend(color1, color1 / color2)
elif blend_type == 'DIFFERENCE':
res = fac.blend(color1, abs(color1 - color2))
elif blend_type == 'DARKEN':
res = fac.blend(color1, color1.min(color2))
elif blend_type == 'LIGHTEN':
res = fac.blend(color1, color1.max(color2))
elif blend_type == 'VALUE':
res = color1
elif blend_type == 'SCREEN':
tm = 1.0 - fac
res = 1.0 - (tm + fac * (1.0 - color2)) * (1.0 - color1)
elif blend_type == 'SOFT_LIGHT':
tm = 1.0 - fac
scr = 1.0 - (1.0 - color2) * (1.0 - color1)
res = tm * color1 + fac * ((1.0 - color1) * color2 * color1 + color1 * scr)
elif blend_type == 'LINEAR_LIGHT':
test_val = color2 > 0.5
res = test_val.if_else(color1 + fac * (2.0 * (color2 - 0.5)),
color1 + fac * (2.0 * color2 - 1.0))
else:
# TODO: support operations SATURATION, HUE, SCREEN, BURN, OVERLAY
log.warn("Ignoring unsupported Blend Type", blend_type, self.node, self.material,
"mix will be used")
res = fac.blend(color1, color2)
if self.node.use_clamp:
res = res.clamp()
return res

View File

@ -0,0 +1,71 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from .node_parser import NodeParser
from . import log
class ShaderNodeMath(NodeParser):
""" Map Blender operations to MaterialX definitions, see the stdlib_defs.mtlx in MaterialX """
def export(self):
op = self.node.operation
in1 = self.get_input_value(0)
# single operand operations
if op == 'SINE':
res = in1.sin()
elif op == 'COSINE':
res = in1.cos()
elif op == 'TANGENT':
res = in1.tan()
elif op == 'ARCSINE':
res = in1.asin()
elif op == 'ARCCOSINE':
res = in1.acos()
elif op == 'ARCTANGENT':
res = in1.atan()
elif op == 'LOGARITHM':
res = in1.log()
elif op == 'ABSOLUTE':
res = abs(in1)
elif op == 'FLOOR':
res = in1.floor()
elif op == 'FRACT':
res = in1 % 1.0
elif op == 'CEIL':
res = in1.ceil()
elif op == 'ROUND':
f = in1.floor()
res = (in1 % 1.0).if_else('>=', 0.5, f + 1.0, f)
else: # 2-operand operations
in2 = self.get_input_value(1)
if op == 'ADD':
res = in1 + in2
elif op == 'SUBTRACT':
res = in1 - in2
elif op == 'MULTIPLY':
res = in1 * in2
elif op == 'DIVIDE':
res = in1 / in2
elif op == 'POWER':
res = in1 ** in2
elif op == 'MINIMUM':
res = in1.min(in2)
elif op == 'MAXIMUM':
res = in1.max(in2)
else:
in3 = self.get_input_value(2)
if op == 'MULTIPLY_ADD':
res = in1 * in2 + in3
else:
log.warn("Unsupported math operation", op)
return None
if self.node.use_clamp:
res = res.clamp()
return res

View File

@ -0,0 +1,18 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from .node_parser import NodeParser
class ShaderNodeValue(NodeParser):
""" Returns float value """
def export(self):
return self.get_output_default()
class ShaderNodeRGB(NodeParser):
""" Returns color value """
def export(self):
return self.get_output_default()

View File

@ -0,0 +1,390 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import math
import bpy
import MaterialX as mx
from .. import utils
from ..utils import pass_node_reroute
from ..nodes import get_mx_node_cls
from ..nodes.node import MxNode
from .. import logging
log = logging.Log("bl_nodes.node_parser")
OUTPUT_TYPE = {'RGBA': 'color3',
'VALUE': 'float',
'VECTOR': 'vector3'}
class Id:
def __init__(self):
self.id = 0
def __call__(self):
self.id += 1
return self.id
class NodeItem:
"""This class is a wrapper used for doing operations on MaterialX nodes, floats, and tuples"""
def __init__(self, id: Id, ng: [mx.Document, mx.NodeGraph], data: [tuple, float, mx.Node], prefix=''):
self.id = id
self.nodegraph = ng
self.data = data
self.nodedef = None
if isinstance(data, mx.Node):
MxNode_cls, _ = get_mx_node_cls(data, prefix)
self.nodedef = MxNode_cls.get_nodedef(self.type)
def node_item(self, value):
if isinstance(value, NodeItem):
return value
return NodeItem(self.id, self.nodegraph, value)
@property
def type(self):
if isinstance(self.data, float):
return 'float_'
elif isinstance(self.data, tuple):
return 'tuple_'
else:
return self.data.getType()
def set_input(self, name, value):
if value is None:
return
val_data = value.data if isinstance(value, NodeItem) else value
nd_input = self.nodedef.getActiveInput(name)
input = self.data.addInput(name, nd_input.getType())
utils.set_param_value(input, val_data, input.getType())
def set_inputs(self, inputs):
for name, value in inputs.items():
self.set_input(name, value)
# MATH OPERATIONS
def _arithmetic_helper(self, other, op_node, func):
''' helper function for overridden math functions.
This simply creates an arithmetic node of rpr_type
if one of the operands has node data, else maps the function to data '''
if other is None:
if isinstance(self.data, float):
result_data = func(self.data)
elif isinstance(self.data, tuple):
result_data = tuple(map(func, self.data))
else:
result_data = self.nodegraph.addNode(op_node, f"{op_node}_{self.id()}",
self.data.getType())
input = result_data.addInput('in', self.data.getType())
utils.set_param_value(input, self.data, self.data.getType())
else:
other_data = other.data if isinstance(other, NodeItem) else other
if isinstance(self.data, (float, tuple)) and isinstance(other_data, (float, tuple)):
if isinstance(self.data, float) and isinstance(other_data, float):
result_data = func(self.data, other_data)
else:
data = self.data
# converting data or other_data to have equal length
if isinstance(data, float):
data = (data,) * len(other_data)
elif isinstance(other_data, float):
other_data = (other_data,) * len(data)
elif len(data) < len(other_data):
data = (*data, 1.0)
elif len(other_data) < len(data):
other_data = (*other_data, 1.0)
result_data = tuple(map(func, data, other_data))
else:
nd_type = self.data.getType() if isinstance(self.data, mx.Node) else \
other_data.getType()
result_data = self.nodegraph.addNode(op_node, f"{op_node}_{self.id()}", nd_type)
input1 = result_data.addInput('in1', nd_type)
utils.set_param_value(input1, self.data, nd_type)
input2 = result_data.addInput('in2', nd_type)
utils.set_param_value(input2, other_data, nd_type)
return self.node_item(result_data)
def __add__(self, other):
return self._arithmetic_helper(other, 'add', lambda a, b: a + b)
def __sub__(self, other):
return self._arithmetic_helper(other, 'subtract', lambda a, b: a - b)
def __mul__(self, other):
return self._arithmetic_helper(other, 'multiply', lambda a, b: a * b)
def __truediv__(self, other):
return self._arithmetic_helper(other, 'divide',
lambda a, b: a / b if not math.isclose(b, 0.0) else 0.0)
def __mod__(self, other):
return self._arithmetic_helper(other, 'modulo', lambda a, b: a % b)
def __pow__(self, other):
return self._arithmetic_helper(other, 'power', lambda a, b: a ** b)
def __neg__(self):
return 0.0 - self
def __abs__(self):
return self._arithmetic_helper(None, 'absval', lambda a: abs(a))
def floor(self):
return self._arithmetic_helper(None, 'floor', lambda a: float(math.floor(a)))
def ceil(self):
return self._arithmetic_helper(None, 'ceil', lambda a: float(math.ceil(a)))
# right hand methods for doing something like 1.0 - Node
def __radd__(self, other):
return self + other
def __rsub__(self, other):
return self.node_item(other) - self
def __rmul__(self, other):
return self * other
def __rtruediv__(self, other):
return self.node_item(other) / self
def __rmod__(self, other):
return self.node_item(other) % self
def __rpow__(self, other):
return self.node_item(other) ** self
def dot(self, other):
dot = self._arithmetic_helper(other, 'dotproduct', lambda a, b: a * b)
if isinstance(dot.data, tuple):
dot.data = sum(dot.data)
return dot
def if_else(self, cond: str, other, if_value, else_value):
if cond == '>':
res = self._arithmetic_helper(other, 'ifgreater', lambda a, b: float(a > b))
elif cond == '>=':
res = self._arithmetic_helper(other, 'ifgreatereq', lambda a, b: float(a >= b))
elif cond == '==':
res = self._arithmetic_helper(other, 'ifequal', lambda a, b: float(a == b))
elif cond == '<':
return self.node_item(other).if_else('>', self, else_value, if_value)
elif cond == '<=':
return self.node_item(other).if_else('>=', self, else_value, if_value)
elif cond == '!=':
return self.if_else('==', other, else_value, if_value)
else:
raise ValueError("Incorrect condition:", cond)
if isinstance(res.data, float):
return if_value if res.data == 1.0 else else_value
elif isinstance(res.data, tuple):
return if_value if res.data[0] == 1.0 else else_value
else:
res.set_input('value1', if_value)
res.set_input('value2', else_value)
return res
def min(self, other):
return self._arithmetic_helper(other, 'min', lambda a, b: min(a, b))
def max(self, other):
return self._arithmetic_helper(other, 'max', lambda a, b: max(a, b))
def clamp(self, min_val=0.0, max_val=1.0):
""" clamp data to min/max """
return self.min(max_val).max(min_val)
def sin(self):
return self._arithmetic_helper(None, 'sin', lambda a: math.sin(a))
def cos(self):
return self._arithmetic_helper(None, 'cos', lambda a: math.cos(a))
def tan(self):
return self._arithmetic_helper(None, 'tan', lambda a: math.tan(a))
def asin(self):
return self._arithmetic_helper(None, 'asin', lambda a: math.asin(a))
def acos(self):
return self._arithmetic_helper(None, 'acos', lambda a: math.acos(a))
def atan(self):
return self._arithmetic_helper(None, 'atan', lambda a: math.atan(a))
def log(self):
return self._arithmetic_helper(None, 'ln', lambda a: math.log(a))
def blend(self, value1, value2):
""" Line interpolate value between value1(0.0) and value2(1.0) by self.data as factor """
return self * value2 + (1.0 - self) * value1
class NodeParser:
"""
This is the base class that parses a blender node.
Subclasses should override only export() function.
"""
nodegraph_path = "NG"
def __init__(self, id: Id, doc: mx.Document, material: bpy.types.Material,
node: bpy.types.Node, obj: bpy.types.Object, out_key, output_type, cached_nodes,
group_nodes=(), **kwargs):
self.id = id
self.doc = doc
self.material = material
self.node = node
self.object = obj
self.out_key = out_key
self.out_type = output_type
self.cached_nodes = cached_nodes
self.group_nodes = group_nodes
self.kwargs = kwargs
@staticmethod
def get_output_type(to_socket):
# Need to check ShaderNodeNormalMap separately because
# if has input color3 type but materialx normalmap got vector3
return 'vector3' if to_socket.node.type == 'NORMAL_MAP' else OUTPUT_TYPE.get(to_socket.type, 'color3')
@staticmethod
def get_node_parser_cls(bl_idname):
""" Returns NodeParser class for node_idname or None if not found """
from . import node_parser_classes
return next((cls for cls in node_parser_classes if cls.__name__ == bl_idname), None)
# INTERNAL FUNCTIONS
def _export_node(self, node, out_key, to_socket, group_node=None):
if group_node:
if self.group_nodes:
group_nodes = self.group_nodes + (group_node,)
else:
group_nodes = (group_node,)
else:
group_nodes = self.group_nodes
# dynamically define output type of node
output_type = self.get_output_type(to_socket)
# check if this node was already parsed and cached
node_item = self.cached_nodes.get((node.name, out_key, output_type))
if node_item:
return node_item
# getting corresponded NodeParser class
NodeParser_cls = self.get_node_parser_cls(node.bl_idname)
if not NodeParser_cls:
log.warn(f"Ignoring unsupported node {node.bl_idname}", node, self.material)
self.cached_nodes[(node.name, out_key, output_type)] = None
return None
node_parser = NodeParser_cls(self.id, self.doc, self.material, node, self.object,
out_key, output_type, self.cached_nodes, group_nodes, **self.kwargs)
node_item = node_parser.export()
self.cached_nodes[(node.name, out_key, output_type)] = node_item
return node_item
def _parse_val(self, val):
"""Turn blender socket value into python's value"""
if isinstance(val, (int, float)):
return float(val)
if len(val) in (3, 4):
return tuple(val)
if isinstance(val, str):
return val
raise TypeError("Unknown value type to pass to rpr", val)
def node_item(self, value):
if isinstance(value, NodeItem):
return value
nodegraph = utils.get_nodegraph_by_path(self.doc, self.nodegraph_path, True)
return NodeItem(self.id, nodegraph, value)
# HELPER FUNCTIONS
# Child classes should use them to do their export
def get_output_default(self):
""" Returns default value of output socket """
socket_out = self.node.outputs[self.out_key]
return self.node_item(self._parse_val(socket_out.default_value))
def get_input_default(self, in_key):
""" Returns default value of input socket """
socket_in = self.node.inputs[in_key]
return self.node_item(self._parse_val(socket_in.default_value))
def get_input_link(self, in_key: [str, int]):
"""Returns linked parsed node or None if nothing is linked or not link is not valid"""
socket_in = self.node.inputs[in_key]
if not socket_in.links:
return None
link = socket_in.links[0]
if not link.is_valid:
log.warn("Invalid link ignored", link, socket_in, self.node, self.material)
return None
link = pass_node_reroute(link)
if not link:
return None
if isinstance(link.from_node, MxNode):
mx_node = link.from_node.compute(link.from_socket.name, doc=self.doc)
return mx_node
return self._export_node(link.from_node, link.from_socket.name, link.to_socket)
def get_input_value(self, in_key):
""" Returns linked node or default socket value """
val = self.get_input_link(in_key)
if val is not None:
return val
return self.get_input_default(in_key)
def create_node(self, node_name, nd_type, *, prefix='', inputs=None):
nodegraph = utils.get_nodegraph_by_path(self.doc, self.nodegraph_path, True)
node = nodegraph.addNode(node_name, f"{node_name}_{self.id()}", nd_type)
node_item = NodeItem(self.id, nodegraph, node, prefix)
mx_type = node_item.nodedef.getType()
if mx_type != nd_type:
node.setType(mx_type)
if inputs:
node_item.set_inputs(inputs)
return node_item
# EXPORT FUNCTION
def export(self) -> [NodeItem, None]:
"""Main export function which should be overridable in child classes"""
return None

View File

@ -0,0 +1,30 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import MaterialX as mx
from .node_parser import NodeParser, Id, log
class ShaderNodeOutputMaterial(NodeParser):
nodegraph_path = ""
def __init__(self, doc, material, node, obj, **kwargs):
super().__init__(Id(), doc, material, node, obj, None, None, {}, **kwargs)
def export(self):
surface = self.get_input_link('Surface')
if surface is None:
return None
linked_input_type = surface.getType() if isinstance(surface, mx.Node) else surface.type
if linked_input_type != 'surfaceshader':
log.warn("Incorrect node tree to export: output node doesn't have correct input")
return None
result = self.create_node('surfacematerial', 'material', inputs={
'surfaceshader': surface,
})
return result

View File

@ -0,0 +1,349 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import math
import MaterialX as mx
from .node_parser import NodeParser
from ..utils import get_mx_node_input_types
from . import log
def enabled(val):
if val is None:
return False
if isinstance(val, mx.Node):
return True
if isinstance(val.data, float) and math.isclose(val.data, 0.0):
return False
if isinstance(val.data, tuple) and \
math.isclose(val.data[0], 0.0) and \
math.isclose(val.data[1], 0.0) and \
math.isclose(val.data[2], 0.0):
return False
return True
def get_node_type(node):
return node.getType() if isinstance(node, mx.Node) else node.nodedef.getType()
class ShaderNodeBsdfPrincipled(NodeParser):
nodegraph_path = ""
def export(self):
# GETTING REQUIRED INPUTS
# Note: if some inputs are not needed they won't be taken
base_color = self.get_input_value('Base Color')
subsurface = self.get_input_value('Subsurface')
subsurface_radius = None
subsurface_color = None
if enabled(subsurface):
subsurface_radius = self.get_input_value('Subsurface Radius')
subsurface_color = self.get_input_value('Subsurface Color')
metallic = self.get_input_value('Metallic')
specular = self.get_input_value('Specular')
# specular_tint = self.get_input_value('Specular Tint')
roughness = self.get_input_value('Roughness')
anisotropic = None
anisotropic_rotation = None
if enabled(metallic):
# TODO: use Specular Tint input
anisotropic = self.get_input_value('Anisotropic')
if enabled(anisotropic):
anisotropic_rotation = self.get_input_value('Anisotropic Rotation')
# anisotropic_rotation = 0.5 - (anisotropic_rotation % 1.0)
sheen = self.get_input_value('Sheen')
# sheen_tint = None
# if enabled(sheen):
# sheen_tint = self.get_input_value('Sheen Tint')
clearcoat = self.get_input_value('Clearcoat')
clearcoat_roughness = None
if enabled(clearcoat):
clearcoat_roughness = self.get_input_value('Clearcoat Roughness')
ior = self.get_input_value('IOR')
transmission = self.get_input_value('Transmission')
transmission_roughness = None
if enabled(transmission):
transmission_roughness = self.get_input_value('Transmission Roughness')
emission = self.get_input_value('Emission')
emission_strength = self.get_input_value('Emission Strength')
alpha = self.get_input_value('Alpha')
# transparency = 1.0 - alpha
normal = self.get_input_link('Normal')
clearcoat_normal = self.get_input_link('Clearcoat Normal')
tangent = self.get_input_link('Tangent')
# CREATING STANDARD SURFACE
result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={
'base': 1.0,
'base_color': base_color,
'diffuse_roughness': roughness,
'normal': normal,
'tangent': tangent,
})
if enabled(metallic):
result.set_input('metalness', metallic)
if enabled(specular):
result.set_inputs({
'specular': specular,
'specular_color': base_color,
'specular_roughness': roughness,
'specular_IOR': ior,
'specular_anisotropy': anisotropic,
'specular_rotation': anisotropic_rotation,
})
if enabled(transmission):
result.set_inputs({
'transmission': transmission,
'transmission_color': base_color,
'transmission_extra_roughness': transmission_roughness,
})
if enabled(subsurface):
result.set_inputs({
'subsurface': subsurface,
'subsurface_color': subsurface_color,
'subsurface_radius': subsurface_radius,
'subsurface_anisotropy': anisotropic,
})
if enabled(sheen):
result.set_inputs({
'sheen': sheen,
'sheen_color': base_color,
'sheen_roughness': roughness,
})
if enabled(clearcoat):
result.set_inputs({
'coat': clearcoat,
'coat_color': base_color,
'coat_roughness': clearcoat_roughness,
'coat_IOR': ior,
'coat_anisotropy': anisotropic,
'coat_rotation': anisotropic_rotation,
'coat_normal': clearcoat_normal,
})
if enabled(emission):
result.set_inputs({
'emission': emission_strength,
'emission_color': emission,
})
return result
class ShaderNodeBsdfDiffuse(NodeParser):
nodegraph_path = ""
def export(self):
color = self.get_input_value('Color')
roughness = self.get_input_value('Roughness')
normal = self.get_input_link('Normal')
# Also tried burley_diffuse_bsdf and oren_nayar_diffuse_bsdf here, but Blender crashes with them
# CREATING STANDARD SURFACE
result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={
'base_color': color,
'diffuse_roughness': 1.0 - roughness,
'normal': normal,
})
return result
class ShaderNodeBsdfGlass(NodeParser):
def export(self):
color = self.get_input_value('Color')
roughness = self.get_input_value('Roughness')
ior = self.get_input_value('IOR')
normal = self.get_input_link('Normal')
# CREATING STANDARD SURFACE
result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={
'base': 0.0,
'normal': normal,
'specular': 1.0,
'specular_color': color,
'specular_roughness': roughness,
'specular_IOR': ior,
'specular_anisotropy': 0.0,
'specular_rotation': 0.0,
'transmission': 1.0,
'transmission_color': color,
'transmission_extra_roughness': roughness,
})
return result
class ShaderNodeEmission(NodeParser):
nodegraph_path = ""
def export(self):
result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF')
color = self.get_input_value('Color')
strength = self.get_input_value('Strength')
if enabled(color) and enabled(strength):
result.set_inputs({
'emission': 1.0,
'emission_color': color * strength,
})
return result
class ShaderNodeMixShader(NodeParser):
nodegraph_path = ""
def export(self):
factor = self.get_input_value(0)
shader1 = self.get_input_link(1)
shader2 = self.get_input_link(2)
mix = None
input_types = get_mx_node_input_types('mix', 'PBR')
if shader1 is None and shader2 is None:
return None
if shader2 is None:
shader1_type = get_node_type(shader1)
if shader1_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader1_type}')
return shader1
mix = self.create_node('mix', shader1_type.lower(), prefix='PBR', inputs={
'fg': shader1,
'mix': factor
})
if shader1 is None:
shader2_type = get_node_type(shader2)
if shader2_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader2_type}')
return shader2
mix = self.create_node('mix', shader2_type.lower(), prefix='PBR', inputs={
'bg': shader2,
'mix': factor
})
if shader1 is not None and shader2 is not None:
shader1_type = get_node_type(shader1)
shader2_type = get_node_type(shader2)
if shader1_type != shader2_type:
log.warn(f'Types of input shaders must be the same. '
f'First shader type: {shader1_type}, second shader type: {shader2_type}')
return shader1
if shader1_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader1_type}, {shader2_type}')
return shader1
mix = self.create_node('mix', shader1_type.lower(), prefix='PBR', inputs={
'fg': shader1,
'bg': shader2,
'mix': factor
})
if not mix:
return None
result = self.create_node('surface', 'surfaceshader', prefix='PBR', inputs={
mix.nodedef.getType().lower(): mix,
'opacity': 1.0
})
return result
class ShaderNodeAddShader(NodeParser):
nodegraph_path = ""
def export(self):
shader1 = self.get_input_link(0)
shader2 = self.get_input_link(1)
add = None
input_types = get_mx_node_input_types('add', 'PBR')
if shader1 is None and shader2 is None:
return None
if shader2 is None:
shader1_type = get_node_type(shader1)
if shader1_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader1_type}')
return shader1
add = self.create_node('add', shader1_type.lower(), prefix='PBR', inputs={
'in1': shader1
})
if shader1 is None:
shader2_type = get_node_type(shader2)
if shader2_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader2_type}')
return shader2
add = self.create_node('add', shader2_type.lower(), prefix='PBR', inputs={
'in2': shader2
})
if shader1 is not None and shader2 is not None:
shader1_type = get_node_type(shader1)
shader2_type = get_node_type(shader2)
if shader1_type != shader2_type:
log.warn(f'Types of input shaders must be the same. '
f'First shader type: {shader1_type}, second shader type: {shader2_type}')
return shader1
if shader1_type not in input_types:
log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. '
f'Input type must be of types {input_types}, actual: {shader1_type}, {shader2_type}')
return shader1
add = self.create_node('add', shader1_type.lower(), prefix='PBR', inputs={
'in1': shader1,
'in2': shader2
})
if not add:
return None
result = self.create_node('surface', 'surfaceshader', prefix='PBR', inputs={
add.nodedef.getType().lower(): add,
'opacity': 1.0
})
return result

View File

@ -0,0 +1,32 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from .node_parser import NodeParser
from ..utils import cache_image_file
TEXTURE_ERROR_COLOR = (1.0, 0.0, 1.0) # following Cycles color for wrong Texture nodes
class ShaderNodeTexImage(NodeParser):
def export(self):
image_error_result = self.node_item(TEXTURE_ERROR_COLOR)
image = self.node.image
# TODO support UDIM Tilesets and SEQUENCE
if not image or image.source in ('TILED', 'SEQUENCE'):
return image_error_result
img_path = cache_image_file(image)
if not img_path:
return image_error_result
# TODO use Vector input for UV
uv = self.create_node('texcoord', 'vector2')
result = self.create_node('image', self.out_type, inputs={
'file': img_path,
'texcoord': uv,
})
return result

View File

@ -0,0 +1,34 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import bpy
from .node_parser import NodeParser
from . import log
DEFAULT_SPACE = 'OBJECT'
class ShaderNodeNormalMap(NodeParser):
def export(self):
color = self.get_input_value('Color')
strength = self.get_input_value('Strength')
space = self.node.space
if space not in ('TANGENT', 'OBJECT'):
log.warn("Ignoring unsupported Space", space, self.node, self.material,
f"{DEFAULT_SPACE} will be used")
space = DEFAULT_SPACE
if space == 'TANGENT' and bpy.context.scene.render.engine == 'HYDRA_STORM':
log.warn("Known issue: HdStorm doesn't work good with tangent space. Consider changing to object space",
space, self.node, self.material)
result = self.create_node('normalmap', 'vector3', inputs={
'in': color ,
'scale': strength,
'space': space.lower(),
})
return result

67
materialx/logging.py Normal file
View File

@ -0,0 +1,67 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import sys
import logging.handlers
from . import ADDON_ALIAS
FORMAT_STR = "%(asctime)s %(levelname)s %(name)s [%(thread)d]: %(message)s"
# root logger for the addon
logger = logging.getLogger(ADDON_ALIAS)
logger.setLevel('INFO')
# file_handler = logging.handlers.RotatingFileHandler(PLUGIN_ROOT_DIR / 'usdhydra.log',
# mode='w', encoding='utf-8', delay=True,
# backupCount=config.logging_backups)
# file_handler.doRollover()
# file_handler.setFormatter(logging.Formatter(FORMAT_STR))
# logger.addHandler(file_handler)
console_handler = logging.StreamHandler(stream=sys.stdout)
console_handler.setFormatter(logging.Formatter(FORMAT_STR))
logger.addHandler(console_handler)
def msg(args):
return ", ".join(str(arg) for arg in args)
class Log:
def __init__(self, tag):
self.logger = logger.getChild(tag)
def __call__(self, *args):
self.debug(*args)
def debug(self, *args):
self.logger.debug(msg(args))
def info(self, *args):
self.logger.info(msg(args))
def warn(self, *args):
self.logger.warning(msg(args))
def error(self, *args):
self.logger.error(msg(args))
def critical(self, *args):
self.logger.critical(msg(args))
def dump_args(self, func):
"""This decorator dumps out the arguments passed to a function before calling it"""
arg_names = func.__code__.co_varnames[:func.__code__.co_argcount]
def echo_func(*args, **kwargs):
self.debug("<{}>: {}{}".format(
func.__name__,
tuple("{}={}".format(name, arg) for name, arg in zip(arg_names, args)),
# args if args else "",
" {}".format(kwargs.items()) if kwargs else "",
))
return func(*args, **kwargs)
return echo_func

View File

@ -0,0 +1,74 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import importlib
import bpy
import nodeitems_utils
import sys
from . import node, categories, generate_node_classes
from .. import utils
sys.path.append(str(utils.ADDON_DATA_DIR))
generate_node_classes.generate_basic_classes()
gen_modules = [importlib.import_module(f"{utils.NODE_CLASSES_FOLDER}.{f.name[:-len(f.suffix)]}")
for f in utils.NODE_CLASSES_DIR.glob("gen_*.py")]
mx_node_classes = []
for mod in gen_modules:
mx_node_classes.extend(mod.mx_node_classes)
# sorting by category and label
mx_node_classes = sorted(mx_node_classes, key=lambda cls: (cls.category.lower(), cls.bl_label.lower()))
register_sockets, unregister_sockets = bpy.utils.register_classes_factory([
node.MxNodeInputSocket,
node.MxNodeOutputSocket,
])
register_nodes, unregister_nodes = bpy.utils.register_classes_factory(mx_node_classes)
def register():
register_sockets()
register_nodes()
nodeitems_utils.register_node_categories(utils.with_prefix("MX_NODES"), categories.get_node_categories())
def unregister():
nodeitems_utils.unregister_node_categories(utils.with_prefix("MX_NODES"))
unregister_nodes()
unregister_sockets()
def get_mx_node_cls(mx_node, prefix=''):
node_name = mx_node.getCategory()
suffix = f'_{node_name}'
if prefix:
suffix = prefix + suffix
classes = tuple(cls for cls in mx_node_classes if cls.__name__.endswith(suffix))
if not classes:
raise KeyError(f"Unable to find MxNode class for {mx_node}")
def params_set(node, out_type):
return {f"in_{p.getName()}:{p.getType()}" for p in node.getActiveInputs()} | \
{out_type.lower()}
node_params_set = params_set(mx_node, mx_node.getType())
for cls in classes:
for nodedef, data_type in cls.get_nodedefs():
nd_outputs = nodedef.getActiveOutputs()
nd_params_set = params_set(nodedef, 'multioutput' if len(nd_outputs) > 1 else
nd_outputs[0].getType())
if node_params_set.issubset(nd_params_set):
return cls, data_type
raise TypeError(f"Unable to find suitable nodedef for {mx_node}")

View File

@ -0,0 +1,38 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import bpy
from collections import defaultdict
from nodeitems_utils import NodeCategory, NodeItem
from ..utils import title_str, code_str, with_prefix
class MxNodeCategory(NodeCategory):
@classmethod
def poll(cls, context):
return context.space_data.tree_type == 'ShaderNodeTree'
def get_node_categories():
from . import mx_node_classes
d = defaultdict(list)
for MxNode_cls in mx_node_classes:
d[MxNode_cls.category].append(MxNode_cls)
categories = []
for category, category_classes in d.items():
categories.append(
MxNodeCategory(with_prefix(code_str(category), '_MX_NG_'), title_str(category),
items=[NodeItem(MxNode_cls.bl_idname)
for MxNode_cls in category_classes]))
categories.append(
MxNodeCategory(with_prefix('LAYOUT', '_MX_NG_'), 'Layout',
items=[NodeItem("NodeFrame"),
NodeItem("NodeReroute")]))
return categories

View File

@ -0,0 +1,354 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import re
from collections import defaultdict
import MaterialX as mx
from .. import utils
from .. import ADDON_ALIAS
from .. import logging
log = logging.Log("nodes.generate_node_classes")
def parse_value_str(val_str, mx_type, *, first_only=False, is_enum=False):
if mx_type == 'string':
if is_enum:
res = tuple(x.strip() for x in val_str.split(','))
return res[0] if first_only else res
return val_str
if mx_type == 'integer':
return int(val_str)
if mx_type in ('float', 'angle'):
return float(val_str)
if mx_type == 'boolean':
return val_str == "true"
if mx_type.endswith('array'):
return val_str
if mx_type.startswith('color') or mx_type.startswith('vector') or mx_type.startswith('matrix'):
res = tuple(float(x) for x in val_str.split(','))
return res[0] if first_only else res
return val_str
def generate_property_code(mx_param, category):
mx_type = mx_param.getType()
prop_attrs = {}
prop_attrs['name'] = mx_param.getAttribute('uiname') if mx_param.hasAttribute('uiname') \
else utils.title_str(mx_param.getName())
prop_attrs['description'] = mx_param.getAttribute('doc')
while True: # one way loop just for having break instead using nested 'if else'
if mx_type == 'string':
if mx_param.hasAttribute('enum'):
prop_type = "EnumProperty"
items = parse_value_str(mx_param.getAttribute('enum'), mx_type, is_enum=True)
prop_attrs['items'] = tuple((it, utils.title_str(it), utils.title_str(it)) for it in items)
break
prop_type = "StringProperty"
break
if mx_type == 'filename':
if category in ("texture2d", "texture3d"):
prop_type = "PointerProperty"
break
prop_type = "StringProperty"
prop_attrs['subtype'] = 'FILE_PATH'
break
if mx_type == 'integer':
prop_type = "IntProperty"
break
if mx_type == 'float':
prop_type = "FloatProperty"
break
if mx_type == 'boolean':
prop_type = "BoolProperty"
break
if mx_type == 'angle':
prop_type = "FloatProperty"
prop_attrs['subtype'] = 'ANGLE'
break
if mx_type in ('surfaceshader', 'displacementshader', 'volumeshader', 'lightshader',
'material', 'BSDF', 'VDF', 'EDF'):
prop_type = "StringProperty"
break
m = re.fullmatch(r'matrix(\d)(\d)', mx_type)
if m:
prop_type = "FloatVectorProperty"
prop_attrs['subtype'] = 'MATRIX'
prop_attrs['size'] = int(m[1]) * int(m[2])
break
m = re.fullmatch(r'color(\d)', mx_type)
if m:
prop_type = "FloatVectorProperty"
prop_attrs['subtype'] = 'COLOR'
prop_attrs['size'] = int(m[1])
prop_attrs['soft_min'] = 0.0
prop_attrs['soft_max'] = 1.0
break
m = re.fullmatch(r'vector(\d)', mx_type)
if m:
prop_type = "FloatVectorProperty"
dim = int(m[1])
prop_attrs['subtype'] = 'XYZ' if dim == 3 else 'NONE'
prop_attrs['size'] = dim
break
m = re.fullmatch(r'(.+)array', mx_type)
if m:
prop_type = "StringProperty"
# TODO: Change to CollectionProperty
break
prop_type = "StringProperty"
log.warn("Unsupported mx_type", mx_type, mx_param, mx_param.getParent().getName())
break
for mx_attr, prop_attr in (('uimin', 'min'), ('uimax', 'max'),
('uisoftmin', 'soft_min'), ('uisoftmax', 'soft_max'),
('value', 'default')):
if mx_param.hasAttribute(mx_attr):
if prop_attr == 'default' and category in ("texture2d", "texture3d") and mx_type == 'filename':
continue
prop_attrs[prop_attr] = parse_value_str(
mx_param.getAttribute(mx_attr), mx_type, first_only=mx_attr != 'value')
prop_attr_strings = []
for name, val in prop_attrs.items():
val_str = f'"{val}"' if isinstance(val, str) else str(val)
prop_attr_strings.append(f"{name}={val_str}")
if mx_type == 'filename' and category in ("texture2d", "texture3d"):
prop_attr_strings.insert(0, "type=bpy.types.Image")
prop_attr_strings.append('update=MxNode.update_prop')
return f"{prop_type}({', '.join(prop_attr_strings)})"
def get_attr(mx_param, name, else_val=""):
return mx_param.getAttribute(name) if mx_param.hasAttribute(name) else else_val
def nodedef_data_type(nodedef):
nd_name = nodedef.getName()
node_name = nodedef.getNodeString()
if nd_name.startswith('rpr_'):
return nodedef.getActiveOutputs()[0].getType()
m = re.fullmatch(rf'ND_{node_name}_(.+)', nd_name)
if m:
return m[1]
return nodedef.getActiveOutputs()[0].getType()
def generate_data_type(nodedef):
outputs = nodedef.getActiveOutputs()
if len(outputs) != 1:
return f"{{'multitypes': {{'{nodedef.getName()}': None, 'nodedef_name': '{nodedef.getName()}'}}}}"
return f"{{'{nodedef.getActiveOutputs()[0].getType()}': {{'{nodedef.getName()}': None, 'nodedef_name': '{nodedef.getName()}'}}}}"
def input_prop_name(nd_type, name):
return f'nd_{nd_type}_in_{name}'
def output_prop_name(nd_type, name):
return f'nd_{nd_type}_out_{name}'
def folder_prop_name(name):
return 'f_' + utils.code_str(name.lower())
def get_mx_node_class_name(nodedef, prefix):
return f"MxNode_{prefix}_{nodedef.getNodeString()}"
def generate_mx_node_class_code(nodedefs, prefix, category):
nodedef = nodedefs[0]
if not category:
category = get_attr(nodedef, 'nodegroup', prefix)
class_name = get_mx_node_class_name(nodedef, prefix)
code_strings = []
data_types = {}
for nd in nodedefs:
data_types[nodedef_data_type(nd)] = {'nd_name': nd.getName(), 'nd': None }
code_strings.append(
f"""
class {class_name}(MxNode):
_file_path = FILE_PATH
_data_types = {data_types}
bl_label = '{get_attr(nodedef, 'uiname', utils.title_str(nodedef.getNodeString()))}'
bl_idname = '{utils.with_prefix(class_name)}'
bl_description = "{get_attr(nodedef, 'doc')}"
category = '{category}'
""")
ui_folders = []
for mx_param in [*nodedef.getParameters(), *nodedef.getActiveInputs()]:
f = mx_param.getAttribute("uifolder")
if f and f not in ui_folders:
ui_folders.append(f)
if len(ui_folders) > 2 or category in ("texture2d", "texture3d"):
code_strings += [" bl_width_default = 250", ""]
if ui_folders:
code_strings.append(f" _ui_folders = {tuple(ui_folders)}")
data_type_items = []
index_default = 0
for i, nd in enumerate(nodedefs):
nd_type = nodedef_data_type(nd)
data_type_items.append((nd_type, utils.title_str(nd_type), utils.title_str(nd_type)))
if nd_type == 'color3':
index_default = i
code_strings += [
f' data_type: EnumProperty(name="Type", description="Input Data Type", '
f"items={data_type_items}, default='{data_type_items[index_default][0]}', "
f"update=MxNode.update_data_type)",
]
for i, f in enumerate(ui_folders):
if i == 0:
code_strings.append("")
code_strings.append(
f' {folder_prop_name(f)}: BoolProperty(name="{f}", '
f'description="Enable {f}", default={i == 0}, update=MxNode.update_ui_folders)')
for nd in nodedefs:
nd_type = nodedef_data_type(nd)
code_strings.append("")
for input in nd.getActiveInputs():
prop_code = generate_property_code(input, category)
code_strings.append(f" {input_prop_name(nd_type, input.getName())}: {prop_code}")
for output in nd.getActiveOutputs():
prop_code = generate_property_code(output, category)
code_strings.append(f" {output_prop_name(nd_type, output.getName())}: {prop_code}")
code_strings.append("")
return '\n'.join(code_strings)
def generate_classes_code(file_path, prefix, category):
IGNORE_NODEDEF_DATA_TYPE = ('matrix33', 'matrix44', 'matrix33FA', 'matrix44FA')
code_strings = []
code_strings.append(
f"""# Automatically generated classes for MaterialX nodes.
# Do not edit manually, changes will be overwritten.
import bpy
from bpy.props import (
EnumProperty,
FloatProperty,
IntProperty,
BoolProperty,
StringProperty,
PointerProperty,
FloatVectorProperty,
)
from {ADDON_ALIAS}.nodes.node import MxNode
""")
if file_path.is_relative_to(utils.MX_LIBS_DIR):
code_strings.append(
f"""from {ADDON_ALIAS}.utils import MX_LIBS_DIR
FILE_PATH = MX_LIBS_DIR / "{(file_path.relative_to(utils.MX_LIBS_DIR)).as_posix()}"
""")
elif file_path.is_relative_to(utils.MX_ADDON_LIBS_DIR):
code_strings.append(
f"""from {ADDON_ALIAS}.utils import MX_ADDON_LIBS_DIR
FILE_PATH = MX_ADDON_LIBS_DIR / "{(file_path.relative_to(utils.MX_ADDON_LIBS_DIR)).as_posix()}"
""")
else:
code_strings.append(
f"""
FILE_PATH = "{file_path.as_posix()}"
""")
doc = mx.createDocument()
search_path = mx.FileSearchPath(str(utils.MX_LIBS_DIR))
mx.readFromXmlFile(doc, str(file_path), searchPath=search_path)
nodedefs = doc.getNodeDefs()
# grouping node_def_classes by node and nodegroup
node_def_classes_by_node = defaultdict(list)
for nodedef in nodedefs:
if nodedef.getSourceUri():
continue
if nodedef_data_type(nodedef) in IGNORE_NODEDEF_DATA_TYPE:
log.warn(f"Ignoring nodedef {nodedef.getName()}")
continue
node_def_classes_by_node[(nodedef.getNodeString(), nodedef.getAttribute('nodegroup'))].\
append(nodedef)
# creating MxNode types
mx_node_class_names = []
for nodedefs_by_node in node_def_classes_by_node.values():
code_strings.append(generate_mx_node_class_code(nodedefs_by_node, prefix, category))
mx_node_class_names.append(get_mx_node_class_name(nodedefs_by_node[0], prefix))
code_strings.append(f"""
mx_node_classes = [{', '.join(mx_node_class_names)}]
""")
return '\n'.join(code_strings)
def generate_basic_classes():
gen_code_dir = utils.NODE_CLASSES_DIR
gen_code_dir.mkdir(exist_ok=True)
files = [
('BXDF', "PBR", utils.MX_LIBS_DIR / "bxdf/standard_surface.mtlx"),
('USD', "USD", utils.MX_LIBS_DIR / "bxdf/usd_preview_surface.mtlx"),
('STD', None, utils.MX_LIBS_DIR / "stdlib/stdlib_defs.mtlx"),
('PBR', "PBR", utils.MX_LIBS_DIR / "pbrlib/pbrlib_defs.mtlx"),
]
for prefix, category, file_path in files:
module_name = f"gen_{file_path.name[:-len(file_path.suffix)]}"
module_file = gen_code_dir / f"{module_name}.py"
if module_file.is_file():
continue
log(f"Generating {module_file} from {file_path}")
module_code = generate_classes_code(file_path, prefix, category)
module_file.write_text(module_code)
module_file = gen_code_dir / "__init__.py"
module_file.write_text(
"""# Automatically generated classes for MaterialX nodes.
# Do not edit manually, changes will be overwritten.
""")

419
materialx/nodes/node.py Normal file
View File

@ -0,0 +1,419 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import MaterialX as mx
import bpy
from .. import utils
from .. import logging
log = logging.Log("nodes.node")
mtlx_documents = {}
class MxNodeInputSocket(bpy.types.NodeSocket):
bl_idname = utils.with_prefix('MxNodeInputSocket')
bl_label = "MX Input Socket"
def draw(self, context, layout, node, text):
if not is_mx_node_valid(node):
return
nd = node.nodedef
nd_input = nd.getActiveInput(self.name)
nd_type = nd_input.getType()
uiname = utils.get_attr(nd_input, 'uiname', utils.title_str(nd_input.getName()))
is_prop_area = context.area.type == 'PROPERTIES'
if self.is_linked or utils.is_shader_type(nd_type) or nd_input.getValue() is None:
uitype = utils.title_str(nd_type)
layout.label(text=uitype if uiname.lower() == uitype.lower() or is_prop_area else f"{uiname}: {uitype}")
else:
if nd_type == 'boolean':
layout.use_property_split = False
layout.alignment = 'LEFT'
layout.prop(node, node._input_prop_name(self.name), text='' if is_prop_area else uiname)
def draw_color(self, context, node):
return utils.get_socket_color(node.nodedef.getActiveInput(self.name).getType()
if is_mx_node_valid(node) else 'undefined')
class MxNodeOutputSocket(bpy.types.NodeSocket):
bl_idname = utils.with_prefix('MxNodeOutputSocket')
bl_label = "MX Output Socket"
def draw(self, context, layout, node, text):
if not is_mx_node_valid(node):
return
nd = node.nodedef
mx_output = nd.getActiveOutput(self.name)
uiname = utils.get_attr(mx_output, 'uiname', utils.title_str(mx_output.getName()))
uitype = utils.title_str(mx_output.getType())
if uiname.lower() == uitype.lower() or len(nd.getActiveOutputs()) == 1:
layout.label(text=uitype)
else:
layout.label(text=f"{uiname}: {uitype}")
def draw_color(self, context, node):
return utils.get_socket_color(node.nodedef.getActiveOutput(self.name).getType()
if is_mx_node_valid(node) else 'undefined')
class MxNode(bpy.types.ShaderNode):
"""Base node from which all MaterialX nodes will be made"""
_file_path: str
# bl_compatibility = {'USDHydra'}
# bl_icon = 'MATERIAL'
bl_label = ""
bl_description = ""
bl_width_default = 200
_data_types = {} # available types and nodedefs
_ui_folders = () # list of ui folders mentioned in nodedef
category = ""
def update_prop(self, context):
self.socket_value_update(context)
@classmethod
def get_nodedef(cls, data_type):
if not cls._data_types[data_type]['nd']:
# loading nodedefs
if cls._file_path not in mtlx_documents:
doc = mx.createDocument()
search_path = mx.FileSearchPath(str(utils.MX_LIBS_DIR))
mx.readFromXmlFile(doc, str(utils.MX_LIBS_DIR / cls._file_path), searchPath=search_path)
mtlx_documents[cls._file_path] = doc
doc = mtlx_documents[cls._file_path]
for val in cls._data_types.values():
val['nd'] = doc.getNodeDef(val['nd_name'])
return cls._data_types[data_type]['nd']
@classmethod
def get_nodedefs(cls):
for data_type in cls._data_types.keys():
yield cls.get_nodedef(data_type), data_type
@property
def nodedef(self):
return self.get_nodedef(self.data_type)
@property
def mx_node_path(self):
nd = self.nodedef
if '/' in self.name or utils.is_shader_type(nd.getActiveOutputs()[0].getType()):
return self.name
return f"NG/{self.name}"
def _folder_prop_name(self, name):
return f"f_{utils.code_str(name.lower())}"
def _input_prop_name(self, name):
return f"nd_{self.data_type}_in_{name}"
def update(self):
bpy.app.timers.register(self.mark_invalid_links)
self.socket_value_update(bpy.context)
def mark_invalid_links(self):
if not is_mx_node_valid(self):
return
nodetree = self.id_data
if not (hasattr(nodetree, 'links')):
return
for link in nodetree.links:
if hasattr(link.from_socket.node, 'nodedef') and hasattr(link.to_socket.node, 'nodedef'):
socket_from_type = link.from_socket.node.nodedef.getActiveOutput(link.from_socket.name).getType()
socket_to_type = link.to_socket.node.nodedef.getActiveInput(link.to_socket.name).getType()
if socket_to_type != socket_from_type:
link.is_valid = False
continue
link.is_valid = True
def update_data_type(self, context):
# updating names for inputs and outputs
nodedef = self.nodedef
for i, nd_input in enumerate(utils.get_nodedef_inputs(nodedef, False)):
self.inputs[i].name = nd_input.getName()
for i, nd_output in enumerate(nodedef.getActiveOutputs()):
self.outputs[i].name = nd_output.getName()
self.update_prop(context)
def init(self, context):
nodedef = self.nodedef
for nd_input in utils.get_nodedef_inputs(nodedef, False):
self.create_input(nd_input)
for nd_output in nodedef.getActiveOutputs():
self.create_output(nd_output)
if self._ui_folders:
self.update_ui_folders(context)
def draw_buttons(self, context, layout):
is_prop_area = context.area.type == 'PROPERTIES'
if len(self._data_types) > 1:
layout1 = layout
if is_prop_area:
layout1 = layout1.split(factor=0.012, align=True)
col = layout1.column()
layout1 = layout1.column()
layout1.prop(self, 'data_type')
nodedef = self.nodedef
if self._ui_folders:
col = layout.column(align=True)
r = None
for i, f in enumerate(self._ui_folders):
if i % 3 == 0: # putting 3 buttons per row
col.use_property_split = False
col.use_property_decorate = False
r = col.row(align=True)
r.prop(self, self._folder_prop_name(f), toggle=True)
for nd_input in utils.get_nodedef_inputs(nodedef, True):
f = nd_input.getAttribute('uifolder')
if f and not getattr(self, self._folder_prop_name(f)):
continue
name = nd_input.getName()
if self.category in ("texture2d", "texture3d") and nd_input.getType() == 'filename':
split = layout.row(align=True).split(factor=0.4 if is_prop_area else 0.25, align=True)
col = split.column()
col.alignment='RIGHT' if is_prop_area else 'EXPAND'
col.label(text=nd_input.getAttribute('uiname') if nd_input.hasAttribute('uiname')
else utils.title_str(name))
col = split.column()
col.template_ID(self, self._input_prop_name(name),
open="image.open", new="image.new")
else:
layout1 = layout
if is_prop_area:
layout1 = layout1.split(factor=0.012, align=True)
col = layout1.column()
layout1 = layout1.column()
layout1.prop(self, self._input_prop_name(name))
# COMPUTE FUNCTION
def compute(self, out_key, **kwargs):
from ..bl_nodes.node_parser import NodeItem
log("compute", self, out_key)
doc = kwargs['doc']
nodedef = self.nodedef
nd_output = self.get_nodedef_output(out_key)
node_path = self.mx_node_path
values = []
for in_key in range(len(self.inputs)):
nd_input = self.get_nodedef_input(in_key)
f = nd_input.getAttribute('uifolder')
if f and not getattr(self, self._folder_prop_name(f)):
continue
values.append((in_key, self.get_input_value(in_key, **kwargs)))
mx_nodegraph = utils.get_nodegraph_by_node_path(doc, node_path, True)
node_name = utils.get_node_name_by_node_path(node_path)
mx_node = mx_nodegraph.addNode(nodedef.getNodeString(), node_name, nd_output.getType())
for in_key, val in values:
nd_input = self.get_nodedef_input(in_key)
nd_type = nd_input.getType()
if isinstance(val, (mx.Node, NodeItem)):
mx_input = mx_node.addInput(nd_input.getName(), nd_type)
utils.set_param_value(mx_input, val, nd_type)
continue
if isinstance(val, tuple) and isinstance(val[0], mx.Node):
# node with multioutput type
in_node, in_nd_output = val
mx_input = mx_node.addInput(nd_input.getName(), nd_type)
utils.set_param_value(mx_input, in_node, nd_type, in_nd_output)
continue
if utils.is_shader_type(nd_type):
continue
nd_val = nd_input.getValue()
if nd_val is None:
continue
mx_input = mx_node.addInput(nd_input.getName(), nd_type)
utils.set_param_value(mx_input, val, nd_type)
for nd_input in utils.get_nodedef_inputs(nodedef, True):
f = nd_input.getAttribute('uifolder')
if f and not getattr(self, self._folder_prop_name(f)):
continue
val = self.get_param_value(nd_input.getName())
nd_type = nd_input.getType()
mx_param = mx_node.addInput(nd_input.getName(), nd_type)
utils.set_param_value(mx_param, val, nd_type)
if len(nodedef.getActiveOutputs()) > 1:
mx_node.setType('multioutput')
return mx_node, nd_output
return mx_node
def _compute_node(self, node, out_key, **kwargs):
# checking if node is already in nodegraph
doc = kwargs['doc']
node_path = node.mx_node_path
mx_nodegraph = utils.get_nodegraph_by_node_path(doc, node_path)
if mx_nodegraph:
node_name = utils.get_node_name_by_node_path(node_path)
mx_node = mx_nodegraph.getNode(node_name)
if mx_node:
if mx_node.getType() == 'multioutput':
nd_output = node.get_nodedef_output(out_key)
return mx_node, nd_output
return mx_node
return node.compute(out_key, **kwargs)
def get_input_link(self, in_key: [str, int], **kwargs):
"""Returns linked parsed node or None if nothing is linked or not link is not valid"""
from ..bl_nodes import node_parser
socket_in = self.inputs[in_key]
if not socket_in.links:
return None
link = socket_in.links[0]
if not link.is_valid:
log.warn("Invalid link found", link, socket_in, self)
return None
link = utils.pass_node_reroute(link)
if not link:
return None
if isinstance(link.from_node, MxNode):
if not is_mx_node_valid(link.from_node):
log.warn(f"Ignoring unsupported node {link.from_node.bl_idname}", link.from_node,
link.from_node.id_data)
return None
return self._compute_node(link.from_node, link.from_socket.name, **kwargs)
NodeParser_cls = node_parser.NodeParser.get_node_parser_cls(link.from_node.bl_idname)
if not NodeParser_cls:
log.warn(f"Ignoring unsupported node {link.from_node.bl_idname}", link.from_node, self.material)
return None
output_type = NodeParser_cls.get_output_type(link.to_socket)
node_parser_cls = NodeParser_cls(node_parser.Id(), kwargs['doc'], None, link.from_node, None,
link.from_socket.name, output_type, {})
node_item = node_parser_cls.export()
return node_item
def get_input_value(self, in_key: [str, int], **kwargs):
node = self.get_input_link(in_key, **kwargs)
if node:
return node
return self.get_input_default(in_key)
def get_input_default(self, in_key: [str, int]):
return getattr(self, self._input_prop_name(self.inputs[in_key].name))
def get_param_value(self, name):
return getattr(self, self._input_prop_name(name))
def get_nodedef_input(self, in_key: [str, int]):
return self.nodedef.getActiveInput(self.inputs[in_key].name)
def get_nodedef_output(self, out_key: [str, int]):
return self.nodedef.getActiveOutput(self.outputs[out_key].name)
def set_input_value(self, in_key, value):
setattr(self, self._input_prop_name(self.inputs[in_key].name), value)
def set_param_value(self, name, value):
setattr(self, self._input_prop_name(name), value)
@classmethod
def poll(cls, tree):
return tree.bl_idname == 'ShaderNodeTree'
def update_ui_folders(self, context):
for i, nd_input in enumerate(utils.get_nodedef_inputs(self.nodedef, False)):
f = nd_input.getAttribute('uifolder')
if f:
self.inputs[i].hide = not getattr(self, self._folder_prop_name(f))
if context:
self.update_prop(context)
def check_ui_folders(self):
if not self._ui_folders:
return
for f in self._ui_folders:
setattr(self, self._folder_prop_name(f), False)
for in_key, nd_input in enumerate(utils.get_nodedef_inputs(self.nodedef, False)):
f = nd_input.getAttribute('uifolder')
if not f:
continue
if self.inputs[in_key].links:
setattr(self, self._folder_prop_name(f), True)
continue
nd_input = self.get_nodedef_input(in_key)
val = self.get_input_default(in_key)
nd_val = nd_input.getValue()
if nd_val is None or utils.is_value_equal(nd_val, val, nd_input.getType()):
continue
setattr(self, self._folder_prop_name(f), True)
self.update_ui_folders(None)
def create_input(self, nd_input):
input = self.inputs.new(MxNodeInputSocket.bl_idname, f'in_{len(self.inputs)}')
input.name = nd_input.getName()
return input
def create_output(self, mx_output):
output = self.outputs.new(MxNodeOutputSocket.bl_idname, f'out_{len(self.outputs)}')
output.name = mx_output.getName()
return output
def is_mx_node_valid(node):
# handle MaterialX 1.37 nodes
return hasattr(node, 'nodedef')

48
materialx/preferences.py Normal file
View File

@ -0,0 +1,48 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import bpy
from . import logging, ADDON_ALIAS
class AddonPreferences(bpy.types.AddonPreferences):
bl_idname = ADDON_ALIAS
def update_log_level(self, context):
logging.logger.setLevel(self.log_level)
dev_tools: bpy.props.BoolProperty(
name="Developer Tools",
description="Enable developer tools",
default=False,
)
log_level: bpy.props.EnumProperty(
name="Log Level",
description="Select logging level",
items=(('DEBUG', "Debug", "Log level DEBUG"),
('INFO', "Info", "Log level INFO"),
('WARNING', "Warning", "Log level WARN"),
('ERROR', "Error", "Log level ERROR"),
('CRITICAL', "Critical", "Log level CRITICAL")),
default='INFO',
update=update_log_level,
)
def draw(self, context):
layout = self.layout
col = layout.column()
col.prop(self, "dev_tools")
col.prop(self, "log_level")
def addon_preferences():
if ADDON_ALIAS not in bpy.context.preferences.addons:
return None
return bpy.context.preferences.addons[ADDON_ALIAS].preferences
register, unregister = bpy.utils.register_classes_factory([
AddonPreferences,
])

166
materialx/ui.py Normal file
View File

@ -0,0 +1,166 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
from pathlib import Path
import traceback
import MaterialX as mx
import bpy
from bpy_extras.io_utils import ImportHelper, ExportHelper
from . import utils
from .preferences import addon_preferences
from .utils import logging
log = logging.Log(tag='ui')
class MATERIALX_OP_import_file(bpy.types.Operator, ImportHelper):
bl_idname = utils.with_prefix('materialx_import_file')
bl_label = "Import from File"
bl_description = "Import MaterialX node tree from .mtlx file"
filename_ext = ".mtlx"
filepath: bpy.props.StringProperty(
name="File Path",
description="File path used for importing MaterialX node tree from .mtlx file",
maxlen=1024, subtype="FILE_PATH"
)
filter_glob: bpy.props.StringProperty(default="*.mtlx", options={'HIDDEN'}, )
def execute(self, context):
mx_node_tree = context.space_data.edit_tree
mtlx_file = Path(self.filepath)
doc = mx.createDocument()
search_path = mx.FileSearchPath(str(mtlx_file.parent))
search_path.append(str(utils.MX_LIBS_DIR))
try:
mx.readFromXmlFile(doc, str(mtlx_file))
utils.import_materialx_from_file(mx_node_tree, doc, mtlx_file)
except Exception as e:
log.error(traceback.format_exc(), mtlx_file)
return {'CANCELLED'}
return {'FINISHED'}
class MATERIALX_OP_export_file(bpy.types.Operator, ExportHelper):
bl_idname = utils.with_prefix('materialx_export_file')
bl_label = "Export to File"
bl_description = "Export material as MaterialX node tree to .mtlx file"
filename_ext = ".mtlx"
filepath: bpy.props.StringProperty(
name="File Path",
description="File path used for exporting material as MaterialX node tree to .mtlx file",
maxlen=1024,
subtype="FILE_PATH"
)
filter_glob: bpy.props.StringProperty(
default="*.mtlx",
options={'HIDDEN'},
)
export_textures: bpy.props.BoolProperty(
name="Export Textures",
description="Export bound textures to corresponded folder",
default=True
)
texture_dir_name: bpy.props.StringProperty(
name="Folder Name",
description="Texture folder name used for exporting files",
default='textures',
maxlen=1024,
)
export_deps: bpy.props.BoolProperty(
name="Export Dependencies",
description="Export MaterialX library dependencies",
default=True
)
def execute(self, context):
doc = utils.export(context.material, None)
if not doc:
return {'CANCELLED'}
utils.export_to_file(doc, self.filepath,
export_textures=self.export_textures,
texture_dir_name=self.texture_dir_name,
export_deps=self.export_deps,
copy_deps=self.export_deps)
log.info(f"Succesfully exported material '{context.material.name}' into {self.filepath}")
return {'FINISHED'}
def draw(self, context):
col = self.layout.column(align=False)
col.prop(self, 'export_textures')
row = col.row()
row.enabled = self.export_textures
row.prop(self, 'texture_dir_name', text='')
self.layout.prop(self, 'export_deps')
class MATERIALX_OP_export_console(bpy.types.Operator):
bl_idname = utils.with_prefix('materialx_export_console')
bl_label = "Export to Console"
bl_description = "Export material as MaterialX node tree to console"
def execute(self, context):
doc = utils.export(context.material, context.object)
if not doc:
return {'CANCELLED'}
print(mx.writeToXmlString(doc))
return {'FINISHED'}
class MATERIALX_PT_tools(bpy.types.Panel):
bl_idname = utils.with_prefix('MATERIALX_PT_tools', '_', True)
bl_label = "MaterialX Tools"
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_category = "Tool"
@classmethod
def poll(cls, context):
tree = context.space_data.edit_tree
return tree and tree.bl_idname == 'ShaderNodeTree'
def draw(self, context):
layout = self.layout
layout.operator(MATERIALX_OP_import_file.bl_idname, icon='IMPORT')
layout.operator(MATERIALX_OP_export_file.bl_idname, icon='EXPORT')
class MATERIALX_PT_dev(bpy.types.Panel):
bl_idname = utils.with_prefix('MATERIALX_PT_dev', '_', True)
bl_label = "Dev"
bl_parent_id = MATERIALX_PT_tools.bl_idname
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
@classmethod
def poll(cls, context):
preferences = addon_preferences()
return preferences.dev_tools if preferences else True
def draw(self, context):
layout = self.layout
layout.operator(MATERIALX_OP_export_console.bl_idname)
register, unregister = bpy.utils.register_classes_factory([
MATERIALX_OP_import_file,
MATERIALX_OP_export_file,
MATERIALX_OP_export_console,
MATERIALX_PT_tools,
MATERIALX_PT_dev,
])

693
materialx/utils.py Normal file
View File

@ -0,0 +1,693 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2022, AMD
import os
from pathlib import Path
import tempfile
import shutil
import platform
import MaterialX as mx
import bpy
from . import ADDON_ALIAS
from . import logging
log = logging.Log('utils')
ADDON_ROOT_DIR = Path(__file__).parent
ADDON_DATA_DIR = Path(bpy.utils.user_resource('SCRIPTS', path=f"addons/{ADDON_ALIAS}_data", create=True))
BL_DATA_DIR = Path(bpy.utils.resource_path('LOCAL')).parent / "materialx"
if platform.system() == 'Windows':
BL_DATA_DIR = BL_DATA_DIR.parent / "blender.shared" / BL_DATA_DIR.name
MX_LIBS_FOLDER = "libraries"
MX_LIBS_DIR = BL_DATA_DIR / MX_LIBS_FOLDER
MX_ADDON_LIBS_DIR = ADDON_ROOT_DIR / MX_LIBS_FOLDER
NODE_CLASSES_FOLDER = "materialx_nodes"
NODE_CLASSES_DIR = ADDON_DATA_DIR / NODE_CLASSES_FOLDER
TEMP_FOLDER = "bl-materialx"
NODE_LAYER_SEPARATION_WIDTH = 280
NODE_LAYER_SHIFT_X = 30
NODE_LAYER_SHIFT_Y = 100
def with_prefix(name, separator='.', upper=False):
return f"{ADDON_ALIAS.upper() if upper else ADDON_ALIAS}{separator}{name}"
def title_str(val):
s = val.replace('_', ' ')
return s[:1].upper() + s[1:]
def code_str(val):
return val.replace(' ', '_').replace('.', '_')
def set_param_value(mx_param, val, nd_type, nd_output=None):
from .bl_nodes.node_parser import NodeItem
if isinstance(val, mx.Node):
param_nodegraph = mx_param.getParent().getParent()
val_nodegraph = val.getParent()
node_name = val.getName()
if val_nodegraph == param_nodegraph:
mx_param.setNodeName(node_name)
if nd_output:
mx_param.setAttribute('output', nd_output.getName())
else:
# checking nodegraph paths
val_ng_path = val_nodegraph.getNamePath()
param_ng_path = param_nodegraph.getNamePath()
ind = val_ng_path.rfind('/')
ind = ind if ind >= 0 else 0
if param_ng_path != val_ng_path[:ind]:
raise ValueError(f"Inconsistent nodegraphs. Cannot connect input "
f"{mx_param.getNamePath()} to {val.getNamePath()}")
mx_output_name = f'out_{node_name}'
if nd_output:
mx_output_name += f'_{nd_output.getName()}'
mx_output = val_nodegraph.getActiveOutput(mx_output_name)
if not mx_output:
mx_output = val_nodegraph.addOutput(mx_output_name, val.getType())
mx_output.setNodeName(node_name)
if nd_output:
mx_output.setType(nd_output.getType())
mx_output.setAttribute('output', nd_output.getName())
mx_param.setAttribute('nodegraph', val_nodegraph.getName())
mx_param.setAttribute('output', mx_output.getName())
elif nd_type == 'filename':
if isinstance(val, bpy.types.Image):
image_path = cache_image_file(val)
if image_path:
mx_param.setValueString(str(image_path))
else:
mx_param.setValueString(str(val))
elif hasattr(val, 'data') and isinstance(val.data, mx.Node):
set_param_value(mx_param, val.data, nd_type, nd_output)
else:
mx_type = getattr(mx, title_str(nd_type), None)
if mx_type:
val = mx_type(val.data) if isinstance(val, NodeItem) else mx_type(val)
elif nd_type == 'float':
if isinstance(val, NodeItem):
val = val.data
if isinstance(val, tuple):
val = val[0]
mx_param.setValue(val)
def is_value_equal(mx_val, val, nd_type):
if nd_type in ('string', 'float', 'integer', 'boolean', 'angle'):
if nd_type == 'filename' and val is None:
val = ""
return mx_val == val
if nd_type == 'filename':
val = "" if val is None else val
return mx_val == val
return tuple(mx_val) == tuple(val)
def is_shader_type(mx_type):
return not (mx_type in ('string', 'float', 'integer', 'boolean', 'filename', 'angle') or
mx_type.startswith('color') or
mx_type.startswith('vector') or
mx_type.endswith('array'))
def get_attr(mx_param, name, else_val=None):
return mx_param.getAttribute(name) if mx_param.hasAttribute(name) else else_val
def parse_value(node, mx_val, mx_type, file_prefix=None):
if mx_type in ('string', 'float', 'integer', 'boolean', 'filename', 'angle'):
if file_prefix and mx_type == 'filename':
mx_val = str((file_prefix / mx_val).resolve())
if node.category in ('texture2d', 'texture3d') and mx_type == 'filename':
file_path = Path(mx_val)
if file_path.exists():
image = bpy.data.images.get(file_path.name)
if image and image.filepath_from_user() == str(file_path):
return image
image = bpy.data.images.load(str(file_path))
return image
return None
return mx_val
return tuple(mx_val)
def parse_value_str(val_str, mx_type, *, first_only=False, is_enum=False):
if mx_type == 'string':
if is_enum:
res = tuple(x.strip() for x in val_str.split(','))
return res[0] if first_only else res
return val_str
if mx_type == 'integer':
return int(val_str)
if mx_type in ('float', 'angle'):
return float(val_str)
if mx_type == 'boolean':
return val_str == "true"
if mx_type.endswith('array'):
return val_str
if mx_type.startswith('color') or mx_type.startswith('vector') or mx_type.startswith('matrix'):
res = tuple(float(x) for x in val_str.split(','))
return res[0] if first_only else res
return val_str
def get_nodedef_inputs(nodedef, uniform=None):
for nd_input in nodedef.getActiveInputs():
if (uniform is True and nd_input.getAttribute('uniform') != 'true') or \
(uniform is False and nd_input.getAttribute('uniform') == 'true'):
continue
yield nd_input
def get_file_prefix(mx_node, file_path):
file_prefix = file_path.parent
n = mx_node
while True:
n = n.getParent()
file_prefix /= n.getFilePrefix()
if isinstance(n, mx.Document):
break
return file_prefix.resolve()
def get_nodegraph_by_path(doc, ng_path, do_create=False):
nodegraph_names = code_str(ng_path).split('/') if ng_path else ()
mx_nodegraph = doc
for nodegraph_name in nodegraph_names:
next_mx_nodegraph = mx_nodegraph.getNodeGraph(nodegraph_name)
if not next_mx_nodegraph:
if do_create:
next_mx_nodegraph = mx_nodegraph.addNodeGraph(nodegraph_name)
else:
return None
mx_nodegraph = next_mx_nodegraph
return mx_nodegraph
def get_nodegraph_by_node_path(doc, node_path, do_create=False):
nodegraph_names = code_str(node_path).split('/')[:-1]
return get_nodegraph_by_path(doc, '/'.join(nodegraph_names), do_create)
def get_node_name_by_node_path(node_path):
return code_str(node_path.split('/')[-1])
def get_socket_color(mx_type):
if mx_type.startswith('color'):
return (0.78, 0.78, 0.16, 1.0)
if mx_type in ('integer', 'float', 'boolean'):
return (0.63, 0.63, 0.63, 1.0)
if mx_type.startswith(('vector', 'matrix')) or mx_type in ('displacementshader'):
return (0.39, 0.39, 0.78, 1.0)
if mx_type in ('string', 'filename'):
return (0.44, 0.7, 1.0, 1.0)
if mx_type.endswith(('shader', 'material')) or mx_type in ('BSDF', 'EDF', 'VDF'):
return (0.39, 0.78, 0.39, 1.0)
return (0.63, 0.63, 0.63, 1.0)
def export_to_file(doc, filepath, *, export_textures=False, texture_dir_name='textures',
export_deps=False, copy_deps=False):
root_dir = Path(filepath).parent
root_dir.mkdir(parents=True, exist_ok=True)
if export_textures:
texture_dir = root_dir / texture_dir_name
image_paths = set()
mx_input_files = (v for v in doc.traverseTree() if isinstance(v, mx.Input) and v.getType() == 'filename')
for mx_input in mx_input_files:
texture_dir.mkdir(parents=True, exist_ok=True)
val = mx_input.getValue()
if not val:
log.warn(f"Skipping wrong {mx_input.getType()} input value. Expected: path, got {val}")
continue
source_path = Path(val)
if not source_path.is_file():
log.warn("Image is missing", source_path)
continue
if source_path in image_paths:
continue
dest_path = texture_dir / source_path.name
if source_path not in image_paths:
image_paths.add(source_path)
dest_path = texture_dir / source_path.name
shutil.copy(source_path, dest_path)
log(f"Export file {source_path} to {dest_path}: completed successfully")
rel_dest_path = dest_path.relative_to(root_dir)
mx_input.setValue(rel_dest_path.as_posix(), mx_input.getType())
if export_deps:
from .nodes import get_mx_node_cls
deps_files = {get_mx_node_cls(mx_node)[0]._file_path
for mx_node in (it for it in doc.traverseTree() if isinstance(it, mx.Node))}
for deps_file in deps_files:
deps_file = Path(deps_file)
if copy_deps:
rel_path = deps_file.relative_to(deps_file.parent.parent)
dest_path = root_dir / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(deps_file, dest_path)
deps_file = rel_path
mx.prependXInclude(doc, str(deps_file))
mx.writeToXmlFile(doc, str(filepath))
log(f"Export MaterialX to {filepath}: completed successfully")
def temp_dir():
d = Path(tempfile.gettempdir()) / TEMP_FOLDER
if not d.is_dir():
log("Creating temp dir", d)
d.mkdir()
return d
def clear_temp_dir():
d = temp_dir()
paths = tuple(d.iterdir())
if not paths:
return
log("Clearing temp dir", d)
for path in paths:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
os.remove(path)
def get_temp_file(suffix, name=None, is_rand=False):
if not name:
return Path(tempfile.mktemp(suffix, "tmp", temp_dir()))
if suffix:
if is_rand:
return Path(tempfile.mktemp(suffix, f"{name}_", temp_dir()))
name += suffix
return temp_dir() / name
SUPPORTED_FORMATS = {".png", ".jpeg", ".jpg", ".hdr", ".tga", ".bmp",
".exr", ".open_exr", ".tif", ".tiff", ".zfile", ".tx"}
DEFAULT_FORMAT = ".hdr"
BLENDER_DEFAULT_FORMAT = "HDR"
BLENDER_DEFAULT_COLOR_MODE = "RGB"
READONLY_IMAGE_FORMATS = {".dds"} # blender can read these formats, but can't write
def cache_image_file(image: bpy.types.Image, cache_check=True):
try:
import _bpy_hydra
return _bpy_hydra.cache_or_get_image_file(bpy.context.as_pointer(), image.as_pointer())
except ImportError:
# without bpy_hydra we are going to cache image through python
pass
image_path = Path(image.filepath_from_user())
if not image.packed_file and image.source != 'GENERATED':
if not image_path.is_file():
# log.warn("Image is missing", image, image_path)
return None
image_suffix = image_path.suffix.lower()
if image_suffix in SUPPORTED_FORMATS and \
f".{image.file_format.lower()}" in SUPPORTED_FORMATS and not image.is_dirty:
return image_path
if image_suffix in READONLY_IMAGE_FORMATS:
return image_path
temp_path = get_temp_file(DEFAULT_FORMAT, image_path.stem, False)
if cache_check and image.source != 'GENERATED' and temp_path.is_file():
return temp_path
scene = bpy.context.scene
user_format = scene.render.image_settings.file_format
user_color_mode = scene.render.image_settings.color_mode
# in some scenes the color_mode is undefined
# we can read it but unable to assign back, so switch it to 'RGB' if color_mode isn't selected
if not user_color_mode:
user_color_mode = 'RGB'
scene.render.image_settings.file_format = BLENDER_DEFAULT_FORMAT
scene.render.image_settings.color_mode = BLENDER_DEFAULT_COLOR_MODE
try:
image.save_render(filepath=str(temp_path))
finally:
scene.render.image_settings.file_format = user_format
scene.render.image_settings.color_mode = user_color_mode
return temp_path
def cache_image_file_path(image_path, cache_check=True):
if image_path.suffix.lower() in SUPPORTED_FORMATS:
return image_path
if cache_check:
temp_path = get_temp_file(DEFAULT_FORMAT, image_path.name)
if temp_path.is_file():
return temp_path
image = bpy.data.images.load(str(image_path))
try:
return cache_image_file(image, cache_check)
finally:
bpy.data.images.remove(image)
def pass_node_reroute(link):
while isinstance(link.from_node, bpy.types.NodeReroute):
if not link.from_node.inputs[0].links:
return None
link = link.from_node.inputs[0].links[0]
return link if link.is_valid else None
def update_ui(area_type='PROPERTIES', region_type='WINDOW'):
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == area_type:
for region in area.regions:
if region.type == region_type:
region.tag_redraw()
def update_materialx_data(depsgraph, materialx_data):
if not depsgraph.updates:
return
for node_tree in (upd.id for upd in depsgraph.updates if isinstance(upd.id, bpy.types.ShaderNodeTree)):
for material in bpy.data.materials:
if material.node_tree and material.node_tree.name == node_tree.name:
doc = export(material, None)
if not doc:
# log.warn("MX export failed", mat)
continue
matx_data = next((mat for mat in materialx_data if mat[0] == material.name), None)
if not matx_data:
mx_file = get_temp_file(".mtlx",
f'{material.name}{material.node_tree.name if material.node_tree else ""}',
False)
mx.writeToXmlFile(doc, str(mx_file))
surfacematerial = next((node for node in doc.getNodes()
if node.getCategory() == 'surfacematerial'))
materialx_data.append((material.name, str(mx_file), surfacematerial.getName()))
else:
mx.writeToXmlFile(doc, str(matx_data[1]))
def import_materialx_from_file(node_tree, doc: mx.Document, file_path):
def prepare_for_import():
surfacematerial = next(
(n for n in doc.getNodes() if n.getCategory() == 'surfacematerial'), None)
if surfacematerial:
return
mat = doc.getMaterials()[0]
sr = mat.getShaderRefs()[0]
doc.removeMaterial(mat.getName())
node_name = sr.getName()
if not node_name.startswith("SR_"):
node_name = f"SR_{node_name}"
node = doc.addNode(sr.getNodeString(), node_name, 'surfaceshader')
for sr_input in sr.getBindInputs():
input = node.addInput(sr_input.getName(), sr_input.getType())
ng_name = sr_input.getNodeGraphString()
if ng_name:
input.setAttribute('nodegraph', ng_name)
input.setAttribute('output', sr_input.getOutputString())
else:
input.setValue(sr_input.getValue())
surfacematerial = doc.addNode('surfacematerial', mat.getName(), 'material')
input = surfacematerial.addInput('surfaceshader', node.getType())
input.setNodeName(node.getName())
def do_import():
from .nodes import get_mx_node_cls
node_tree.nodes.clear()
def import_node(mx_node, mx_output_name=None, look_nodedef=True):
mx_nodegraph = mx_node.getParent()
node_path = mx_node.getNamePath()
file_prefix = get_file_prefix(mx_node, file_path)
if node_path in node_tree.nodes:
return node_tree.nodes[node_path]
try:
MxNode_cls, data_type = get_mx_node_cls(mx_node)
except KeyError as e:
if not look_nodedef:
log.warn(e)
return None
# looking for nodedef and switching to another nodegraph defined in doc
nodedef = next(nd for nd in doc.getNodeDefs()
if nd.getNodeString() == mx_node.getCategory() and
nd.getType() == mx_node.getType())
new_mx_nodegraph = next(ng for ng in doc.getNodeGraphs()
if ng.getNodeDefString() == nodedef.getName())
mx_output = new_mx_nodegraph.getActiveOutput(mx_output_name)
node_name = mx_output.getNodeName()
new_mx_node = new_mx_nodegraph.getNode(node_name)
return import_node(new_mx_node, None, False)
node = node_tree.nodes.new(MxNode_cls.bl_idname)
node.name = node_path
node.data_type = data_type
nodedef = node.nodedef
for mx_input in mx_node.getActiveInputs():
input_name = mx_input.getName()
nd_input = nodedef.getActiveInput(input_name)
if nd_input.getAttribute('uniform') == 'true':
node.set_param_value(input_name, parse_value(
node, mx_input.getValue(), mx_input.getType(), file_prefix))
continue
if input_name not in node.inputs:
log.error(f"Incorrect input name '{input_name}' for node {node}")
continue
val = mx_input.getValue()
if val is not None:
node.set_input_value(input_name, parse_value(
node, val, mx_input.getType(), file_prefix))
continue
node_name = mx_input.getNodeName()
if node_name:
new_mx_node = mx_nodegraph.getNode(node_name)
if not new_mx_node:
log.error(f"Couldn't find node '{node_name}' in nodegraph '{mx_nodegraph.getNamePath()}'")
continue
new_node = import_node(new_mx_node)
out_name = mx_input.getAttribute('output')
if len(new_node.nodedef.getActiveOutputs()) > 1 and out_name:
new_node_output = new_node.outputs[out_name]
else:
new_node_output = new_node.outputs[0]
node_tree.links.new(new_node_output, node.inputs[input_name])
continue
new_nodegraph_name = mx_input.getAttribute('nodegraph')
if new_nodegraph_name:
mx_output_name = mx_input.getAttribute('output')
new_mx_nodegraph = mx_nodegraph.getNodeGraph(new_nodegraph_name)
mx_output = new_mx_nodegraph.getActiveOutput(mx_output_name)
node_name = mx_output.getNodeName()
new_mx_node = new_mx_nodegraph.getNode(node_name)
new_node = import_node(new_mx_node, mx_output_name)
if not new_node:
continue
out_name = mx_output.getAttribute('output')
if len(new_node.nodedef.getActiveOutputs()) > 1 and out_name:
new_node_output = new_node.outputs[out_name]
else:
new_node_output = new_node.outputs[0]
node_tree.links.new(new_node_output, node.inputs[input_name])
continue
node.check_ui_folders()
return node
mx_node = next(n for n in doc.getNodes() if n.getCategory() == 'surfacematerial')
output_node = import_node(mx_node, 0)
if not output_node:
return
# arranging nodes by layers
layer = {output_node}
layer_index = 0
layers = {}
while layer:
new_layer = set()
for node in layer:
layers[node] = layer_index
for inp in node.inputs:
for link in inp.links:
new_layer.add(link.from_node)
layer = new_layer
layer_index += 1
node_layers = [[] for _ in range(max(layers.values()) + 1)]
for node in node_tree.nodes:
node_layers[layers[node]].append(node)
# placing nodes by layers
loc_x = 0
for i, nodes in enumerate(node_layers):
loc_y = 0
for node in nodes:
node.location = (loc_x, loc_y)
loc_y -= NODE_LAYER_SHIFT_Y
loc_x -= NODE_LAYER_SHIFT_X
loc_x -= NODE_LAYER_SEPARATION_WIDTH
prepare_for_import()
do_import()
def export(material, obj: bpy.types.Object) -> [mx.Document, None]:
from .bl_nodes.output import ShaderNodeOutputMaterial
from .nodes.node import MxNode
output_node = get_output_node(material)
if not output_node:
return None
doc = mx.createDocument()
if isinstance(output_node, MxNode):
mx_node = output_node.compute('out', doc=doc)
return doc
node_parser = ShaderNodeOutputMaterial(doc, material, output_node, obj)
if not node_parser.export():
return None
return doc
def get_materialx_data(material, obj: bpy.types.Object):
doc = export(obj)
if not doc:
return None, None
mtlx_file = get_temp_file(".mtlx", f'{material.name}_{material.node_tree.name if material.node_tree else ""}')
mx.writeToXmlFile(doc, str(mtlx_file))
return mtlx_file, doc
def get_output_node(material):
if not material.node_tree:
return None
bl_output_node = next((node for node in material.node_tree.nodes if
node.bl_idname == 'ShaderNodeOutputMaterial' and
node.is_active_output and node.inputs['Surface'].links), None)
if bl_output_node:
return bl_output_node
mx_output_node = next((node for node in material.node_tree.nodes if
node.bl_idname == with_prefix('MxNode_STD_surfacematerial') and
node.inputs['surfaceshader'].links), None)
return mx_output_node
def get_mx_node_input_types(node_name, prefix=''):
from .nodes import mx_node_classes
suffix = f'_{node_name}'
if prefix:
suffix = prefix + suffix
input_types = set()
classes = tuple(cls for cls in mx_node_classes if cls.__name__.endswith(suffix))
for cls in classes:
for nodedef, data_type in cls.get_nodedefs():
input_types |= {p.getType() for p in nodedef.getActiveInputs()}
return input_types