244 lines
8.1 KiB
Python
244 lines
8.1 KiB
Python
"""
|
|
Blender Shader Node Exporter
|
|
----------------------------
|
|
Exports the active object's material shader node tree to a JSON file.
|
|
|
|
Usage:
|
|
1. Open Blender's Text Editor
|
|
2. Paste this script
|
|
3. Set EXPORT_PATH below (or leave as None to save next to your .blend file)
|
|
4. Click "Run Script"
|
|
"""
|
|
|
|
import bpy
|
|
import json
|
|
import os
|
|
|
|
|
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
|
EXPORT_PATH = None # e.g. "/home/user/my_shader.json" — None = auto
|
|
MATERIAL_INDEX = 0 # which material slot to export (0 = first)
|
|
# ───────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class BlenderEncoder(json.JSONEncoder):
|
|
"""Handles Blender math types (Vector, Color, Euler, Matrix, etc.)
|
|
and any other iterable that the default encoder can't handle."""
|
|
def default(self, obj):
|
|
# mathutils types all support len() + indexing
|
|
try:
|
|
import mathutils
|
|
if isinstance(obj, (
|
|
mathutils.Vector,
|
|
mathutils.Color,
|
|
mathutils.Euler,
|
|
mathutils.Quaternion,
|
|
mathutils.Matrix,
|
|
)):
|
|
return list(obj)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Generic fallback: anything iterable becomes a list
|
|
try:
|
|
return list(obj)
|
|
except TypeError:
|
|
pass
|
|
|
|
return super().default(obj)
|
|
|
|
|
|
def safe_value(val):
|
|
"""Recursively convert a value to something JSON-safe."""
|
|
import mathutils
|
|
if isinstance(val, (mathutils.Vector, mathutils.Color,
|
|
mathutils.Euler, mathutils.Quaternion)):
|
|
return list(val)
|
|
if isinstance(val, mathutils.Matrix):
|
|
return [list(row) for row in val]
|
|
if isinstance(val, (bool, int, float, str)) or val is None:
|
|
return val
|
|
try:
|
|
return list(val)
|
|
except TypeError:
|
|
return str(val)
|
|
|
|
|
|
def export_socket(socket):
|
|
"""Serialize a single node socket."""
|
|
data = {
|
|
"name": socket.name,
|
|
"identifier": socket.identifier,
|
|
"type": socket.type,
|
|
"is_linked": socket.is_linked,
|
|
}
|
|
|
|
# Try to capture the default value if present
|
|
if hasattr(socket, "default_value"):
|
|
data["default_value"] = safe_value(socket.default_value)
|
|
|
|
return data
|
|
|
|
|
|
def export_node(node):
|
|
"""Serialize a single shader node and all its properties."""
|
|
data = {
|
|
"name": node.name,
|
|
"label": node.label,
|
|
"type": node.type,
|
|
"bl_idname": node.bl_idname,
|
|
"location": [node.location.x, node.location.y],
|
|
"width": node.width,
|
|
"height": node.height,
|
|
"hide": node.hide,
|
|
"mute": node.mute,
|
|
"use_custom_color": node.use_custom_color,
|
|
"color": list(node.color),
|
|
"inputs": [export_socket(s) for s in node.inputs],
|
|
"outputs": [export_socket(s) for s in node.outputs],
|
|
"properties": {},
|
|
}
|
|
|
|
# ── Node-type-specific properties ──────────────────────────────────────────
|
|
prop_names = {
|
|
"ShaderNodeBsdfPrincipled": [
|
|
"distribution", "subsurface_method"
|
|
],
|
|
"ShaderNodeTexImage": [
|
|
"interpolation", "projection", "extension",
|
|
],
|
|
"ShaderNodeTexNoise": ["noise_dimensions"],
|
|
"ShaderNodeTexWave": ["wave_type", "bands_direction", "rings_direction", "wave_profile"],
|
|
"ShaderNodeTexVoronoi": ["voronoi_dimensions", "feature", "distance"],
|
|
"ShaderNodeMapping": ["vector_type"],
|
|
"ShaderNodeMath": ["operation", "use_clamp"],
|
|
"ShaderNodeVectorMath": ["operation"],
|
|
"ShaderNodeMixRGB": ["blend_type", "use_clamp"],
|
|
"ShaderNodeMix": ["blend_type", "data_type", "clamp_factor", "clamp_result", "factor_mode"],
|
|
"ShaderNodeBump": ["invert"],
|
|
"ShaderNodeNormalMap": ["space", "uv_map"],
|
|
"ShaderNodeDisplacement": ["space"],
|
|
"ShaderNodeEmission": [],
|
|
"ShaderNodeRGB": [],
|
|
"ShaderNodeValue": [],
|
|
"ShaderNodeGroup": ["node_tree"],
|
|
"ShaderNodeScript": ["mode", "script", "filepath"],
|
|
}
|
|
|
|
specific = prop_names.get(node.bl_idname, [])
|
|
for prop in specific:
|
|
try:
|
|
val = getattr(node, prop)
|
|
# Node trees: just record name
|
|
if hasattr(val, "name"):
|
|
data["properties"][prop] = val.name
|
|
elif hasattr(val, "__iter__") and not isinstance(val, str):
|
|
data["properties"][prop] = list(val)
|
|
else:
|
|
data["properties"][prop] = val
|
|
except AttributeError:
|
|
pass
|
|
|
|
# For image texture nodes, also capture the image name
|
|
if node.bl_idname == "ShaderNodeTexImage" and node.image:
|
|
data["properties"]["image_name"] = node.image.name
|
|
data["properties"]["image_filepath"] = node.image.filepath
|
|
|
|
# For value nodes, grab the output value directly
|
|
if node.bl_idname == "ShaderNodeValue":
|
|
try:
|
|
data["properties"]["value"] = node.outputs[0].default_value
|
|
except (IndexError, AttributeError):
|
|
pass
|
|
|
|
# For RGB nodes
|
|
if node.bl_idname == "ShaderNodeRGB":
|
|
try:
|
|
data["properties"]["color"] = list(node.outputs[0].default_value)
|
|
except (IndexError, AttributeError):
|
|
pass
|
|
|
|
return data
|
|
|
|
|
|
def export_link(link):
|
|
"""Serialize a single node link (edge in the graph)."""
|
|
return {
|
|
"from_node": link.from_node.name,
|
|
"from_socket": link.from_socket.identifier,
|
|
"to_node": link.to_node.name,
|
|
"to_socket": link.to_socket.identifier,
|
|
"is_muted": link.is_muted,
|
|
}
|
|
|
|
|
|
def export_node_tree(node_tree):
|
|
"""Serialize the entire node tree."""
|
|
return {
|
|
"name": node_tree.name,
|
|
"type": node_tree.type,
|
|
"nodes": [export_node(n) for n in node_tree.nodes],
|
|
"links": [export_link(l) for l in node_tree.links],
|
|
}
|
|
|
|
|
|
def export_material_shader(material):
|
|
"""Top-level: serialize a material and its shader node tree."""
|
|
if not material.use_nodes:
|
|
raise ValueError(f"Material '{material.name}' does not use nodes.")
|
|
return {
|
|
"material_name": material.name,
|
|
"blend_method": material.blend_method,
|
|
"shadow_method": getattr(material, "shadow_method", ""),
|
|
"use_backface_culling": material.use_backface_culling,
|
|
"node_tree": export_node_tree(material.node_tree),
|
|
}
|
|
|
|
|
|
def resolve_export_path(material_name):
|
|
"""Build the output file path."""
|
|
if EXPORT_PATH:
|
|
return EXPORT_PATH
|
|
|
|
blend_path = bpy.data.filepath
|
|
if blend_path:
|
|
base_dir = os.path.dirname(blend_path)
|
|
else:
|
|
base_dir = os.path.expanduser("~")
|
|
|
|
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in material_name)
|
|
return os.path.join(base_dir, f"{safe_name}_shader.json")
|
|
|
|
|
|
def main():
|
|
obj = bpy.context.active_object
|
|
if obj is None:
|
|
raise RuntimeError("No active object selected.")
|
|
|
|
if not obj.material_slots:
|
|
raise RuntimeError(f"Object '{obj.name}' has no material slots.")
|
|
|
|
if MATERIAL_INDEX >= len(obj.material_slots):
|
|
raise RuntimeError(
|
|
f"Material index {MATERIAL_INDEX} is out of range "
|
|
f"(object has {len(obj.material_slots)} slot(s))."
|
|
)
|
|
|
|
mat = obj.material_slots[MATERIAL_INDEX].material
|
|
if mat is None:
|
|
raise RuntimeError(f"Material slot {MATERIAL_INDEX} is empty.")
|
|
|
|
print(f"Exporting shader: '{mat.name}' from '{obj.name}'")
|
|
|
|
data = export_material_shader(mat)
|
|
out_path = resolve_export_path(mat.name)
|
|
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False, cls=BlenderEncoder)
|
|
|
|
print(f"✓ Saved to: {out_path}")
|
|
print(f" Nodes : {len(data['node_tree']['nodes'])}")
|
|
print(f" Links : {len(data['node_tree']['links'])}")
|
|
|
|
|
|
main()
|