Refactor and enhance geometry node groups for depth plane and material layers

- Updated the depth plane node group to load from an asset and simplified the interface.
- Created a new build script for depth plane geometry nodes.
- Introduced material layer groups for image and depth materials with appropriate sockets.
- Added functionality to connect balloon normal scale to the image material.
- Enhanced tests to validate the new node groups and their connections.
- Ensured that production code does not create node groups directly.
This commit is contained in:
2026-08-31 12:25:23 +08:00
parent 87d7721128
commit 377eee43f5
18 changed files with 602 additions and 405 deletions

View File

@@ -17,6 +17,8 @@ from build_cutout import (
build_cutout_modifier_group,
build_node_group,
)
from build_depth_plane import build_depth_plane_group
from build_material_layers import build_material_layer_group
from build_mesh_plane import build_mesh_plane_group
from build_shadeless import build_shadeless_group
OUTPUT_PATH = (
@@ -31,7 +33,10 @@ OUTPUT_PATH = (
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
build_mesh_plane_group()
mesh_plane = build_mesh_plane_group()
build_depth_plane_group(mesh_plane)
build_material_layer_group()
build_material_layer_group(depth_plane=True)
build_shadeless_group()
shape_groups = {
shape: build_node_group(shape)

View File

@@ -0,0 +1,190 @@
"""Build the Depth Plane Geometry Nodes group."""
import bpy
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
def _interface_socket(group, name, in_out, socket_type):
socket = group.interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def _group_input(nodes, location, visible_names):
node = nodes.new("NodeGroupInput")
node.location = location
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def build_depth_plane_group(mesh_plane_group):
group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME)
if group is not None:
return group
group = bpy.data.node_groups.new(
DEPTH_PLANE_NODE_GROUP_NAME,
"GeometryNodeTree",
)
group.is_modifier = True
_interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value = 7
subdivide.min_value = 0
subdivide.max_value = 10
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.1
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
depth_amount = _interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat")
depth_amount.default_value = 1.0
depth_amount.min_value = 0.0
reference_depth = _interface_socket(
group, "Reference Depth", "INPUT", "NodeSocketFloat"
)
reference_depth.hide_in_modifier = True
uniform_scale = _interface_socket(
group, "Uniform Scale", "INPUT", "NodeSocketFloat"
)
uniform_scale.hide_in_modifier = True
depth_image = _interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage")
depth_image.hide_in_modifier = True
material = _interface_socket(group, "Material", "INPUT", "NodeSocketMaterial")
material.hide_in_modifier = True
_interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = _group_input(
nodes,
(-1850.0, 450.0),
{"Geometry", "Subdivide", "Material"},
)
shape_input = _group_input(
nodes,
(-1250.0, 850.0),
{"Thickness", "Depth Amount"},
)
image_input = _group_input(
nodes,
(-1050.0, 50.0),
{"Depth Image", "Reference Depth", "Uniform Scale"},
)
cleanup_input = _group_input(nodes, (650.0, -200.0), {"Thickness"})
group_output = nodes.new("NodeGroupOutput")
group_output.location = (1850.0, 450.0)
mesh_plane = nodes.new("GeometryNodeGroup")
mesh_plane.node_tree = mesh_plane_group
mesh_plane.inputs["Thickness"].default_value = 0.0
mesh_plane.location = (-1550.0, 450.0)
links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"])
links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"])
links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0)
extrude.inputs["Individual"].default_value = False
extrude.location = (-1200.0, 450.0)
links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"])
links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"])
geometry = extrude.outputs["Mesh"]
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
uv.location = (-1000.0, 50.0)
texture = nodes.new("GeometryNodeImageTexture")
texture.location = (-800.0, 50.0)
links.new(image_input.outputs["Depth Image"], texture.inputs["Image"])
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (-600.0, 50.0)
links.new(texture.outputs["Color"], separate.inputs["Vector"])
relative_depth = nodes.new("ShaderNodeMath")
relative_depth.operation = "SUBTRACT"
relative_depth.location = (-600.0, -150.0)
links.new(image_input.outputs["Reference Depth"], relative_depth.inputs[0])
links.new(separate.outputs["X"], relative_depth.inputs[1])
local_depth = nodes.new("ShaderNodeMath")
local_depth.operation = "MULTIPLY"
local_depth.location = (-350.0, -150.0)
links.new(relative_depth.outputs[0], local_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], local_depth.inputs[1])
raw_depth = nodes.new("ShaderNodeMath")
raw_depth.operation = "MULTIPLY"
raw_depth.location = (-100.0, 150.0)
links.new(local_depth.outputs[0], raw_depth.inputs[0])
links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1])
inward_limit = nodes.new("ShaderNodeMath")
inward_limit.operation = "MULTIPLY"
inward_limit.inputs[1].default_value = -0.98
inward_limit.location = (-100.0, -50.0)
links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0])
clamp = nodes.new("ShaderNodeMath")
clamp.operation = "MAXIMUM"
clamp.location = (150.0, 50.0)
links.new(raw_depth.outputs[0], clamp.inputs[0])
links.new(inward_limit.outputs[0], clamp.inputs[1])
has_thickness = nodes.new("ShaderNodeMath")
has_thickness.operation = "GREATER_THAN"
has_thickness.inputs[1].default_value = 1e-6
has_thickness.location = (-100.0, -350.0)
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
choose_depth = nodes.new("GeometryNodeSwitch")
choose_depth.input_type = "FLOAT"
choose_depth.location = (400.0, 50.0)
links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"])
links.new(raw_depth.outputs[0], choose_depth.inputs["False"])
links.new(clamp.outputs[0], choose_depth.inputs["True"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (600.0, 50.0)
links.new(choose_depth.outputs["Output"], combine.inputs["Z"])
front = nodes.new("FunctionNodeBooleanMath")
front.operation = "NOT"
front.location = (400.0, 300.0)
links.new(extrude.outputs["Top"], front.inputs[0])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (800.0, 450.0)
links.new(geometry, set_position.inputs["Geometry"])
links.new(front.outputs[0], set_position.inputs["Selection"])
links.new(combine.outputs["Vector"], set_position.inputs["Offset"])
geometry = set_position.outputs["Geometry"]
zero_thickness = nodes.new("ShaderNodeMath")
zero_thickness.operation = "LESS_THAN"
zero_thickness.inputs[1].default_value = 1e-6
zero_thickness.location = (1000.0, -150.0)
links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0])
back_or_side = nodes.new("FunctionNodeBooleanMath")
back_or_side.operation = "OR"
back_or_side.location = (1000.0, 150.0)
links.new(extrude.outputs["Top"], back_or_side.inputs[0])
links.new(extrude.outputs["Side"], back_or_side.inputs[1])
delete_selection = nodes.new("FunctionNodeBooleanMath")
delete_selection.operation = "AND"
delete_selection.location = (1200.0, 50.0)
links.new(zero_thickness.outputs[0], delete_selection.inputs[0])
links.new(back_or_side.outputs[0], delete_selection.inputs[1])
delete_geometry = nodes.new("GeometryNodeDeleteGeometry")
delete_geometry.domain = "FACE"
delete_geometry.location = (1350.0, 450.0)
links.new(geometry, delete_geometry.inputs["Geometry"])
links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"])
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
shade_smooth.domain = "FACE"
shade_smooth.inputs["Shade Smooth"].default_value = True
shade_smooth.location = (1600.0, 450.0)
links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"])
links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"])
group.use_fake_user = True
return group

View File

@@ -0,0 +1,195 @@
"""Build the image material Shader node groups."""
import bpy
from anyimage.operators.common.mesh_result import (
DEPTH_MATERIAL_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
)
BUMP_BASE_DISTANCE = 0.001
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
def build_material_layer_group(depth_plane=False):
group_name = (
DEPTH_MATERIAL_NODE_GROUP_NAME
if depth_plane
else IMAGE_MATERIAL_NODE_GROUP_NAME
)
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(group_name, "ShaderNodeTree")
interface = group.interface
input_sockets = [
("Color", "NodeSocketColor"),
("Alpha", "NodeSocketFloat"),
("Bump Scale", "NodeSocketFloat"),
]
normal_group = not depth_plane
if normal_group:
input_sockets.insert(2, ("Normal", "NodeSocketColor"))
input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat"))
input_sockets.insert(4, ("Object Space", "NodeSocketBool"))
if depth_plane:
input_sockets.insert(2, ("Depth", "NodeSocketFloat"))
for name, socket_type in input_sockets:
socket = interface.new_socket(
name=name,
in_out="INPUT",
socket_type=socket_type,
)
if name == "Alpha":
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 1.0
socket.subtype = "FACTOR"
elif name == "Bump Scale":
socket.default_value = 0.0
socket.min_value = -10000.0
socket.max_value = 10000.0
elif name == "Normal":
socket.default_value = (0.5, 0.5, 1.0, 1.0)
elif name == "Normal Scale":
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 10.0
elif name == "Object Space":
socket.default_value = False
output_sockets = [
("Color", "NodeSocketColor"),
("Roughness", "NodeSocketFloat"),
("Alpha", "NodeSocketFloat"),
("Normal", "NodeSocketVector"),
]
if depth_plane:
output_sockets.append(("Displacement", "NodeSocketVector"))
for name, socket_type in output_sockets:
interface.new_socket(
name=name,
in_out="OUTPUT",
socket_type=socket_type,
)
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_input.location = (-500.0, 100.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (300.0, 100.0)
roughness_curve = nodes.new("ShaderNodeFloatCurve")
roughness_curve.mapping.initialize()
curve_points = roughness_curve.mapping.curves[0].points
curve_points[0].location = (0.0, 0.5)
curve_points[-1].location = (1.0, 0.1)
roughness_curve.mapping.update()
roughness_curve.location = (-250.0, 350.0)
bump = nodes.new("ShaderNodeBump")
bump.location = (100.0, 0.0)
if normal_group:
tangent_normal = nodes.new("ShaderNodeNormalMap")
tangent_normal.space = "TANGENT"
tangent_normal.location = (-500.0, -100.0)
object_normal = nodes.new("ShaderNodeNormalMap")
object_normal.space = "OBJECT"
object_normal.location = (-500.0, -350.0)
choose_normal = nodes.new("ShaderNodeMix")
choose_normal.data_type = "VECTOR"
choose_normal.location = (-150.0, -150.0)
for normal_map in (tangent_normal, object_normal):
links.new(group_input.outputs["Normal"], normal_map.inputs["Color"])
links.new(
group_input.outputs["Normal Scale"],
normal_map.inputs["Strength"],
)
links.new(group_input.outputs["Object Space"], choose_normal.inputs[0])
links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4])
links.new(object_normal.outputs["Normal"], choose_normal.inputs[5])
links.new(choose_normal.outputs["Result"], bump.inputs["Normal"])
displacement = None
processed_alpha = group_input.outputs["Alpha"]
bump_depth = group_input.outputs["Color"]
if depth_plane:
displacement = nodes.new("ShaderNodeDisplacement")
displacement.inputs["Midlevel"].default_value = 0.0
displacement.inputs["Scale"].default_value = 1.0
group_input.location = (-1200.0, 100.0)
group_output.location = (1050.0, 100.0)
roughness_curve.location = (-300.0, 450.0)
bump.location = (750.0, 50.0)
displacement.location = (750.0, -300.0)
background_attribute = nodes.new("ShaderNodeAttribute")
background_attribute.attribute_type = "GEOMETRY"
background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
background_attribute.location = (-950.0, -500.0)
material_attribute = nodes.new("ShaderNodeAttribute")
material_attribute.attribute_type = "GEOMETRY"
material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
material_attribute.location = (-50.0, -650.0)
strength_attribute = nodes.new("ShaderNodeAttribute")
strength_attribute.attribute_type = "GEOMETRY"
strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE
strength_attribute.location = (200.0, -650.0)
foreground_mask = nodes.new("ShaderNodeMath")
foreground_mask.operation = "GREATER_THAN"
foreground_mask.inputs[1].default_value = 0.5
foreground_mask.location = (-950.0, -250.0)
clamp_foreground = nodes.new("ShaderNodeMath")
clamp_foreground.operation = "MAXIMUM"
clamp_foreground.location = (-650.0, -400.0)
masked_depth = nodes.new("ShaderNodeMix")
masked_depth.data_type = "FLOAT"
masked_depth.location = (-350.0, -350.0)
enabled_depth = nodes.new("ShaderNodeMath")
enabled_depth.operation = "MULTIPLY"
enabled_depth.location = (250.0, -350.0)
scaled_depth = nodes.new("ShaderNodeMath")
scaled_depth.operation = "MULTIPLY"
scaled_depth.location = (450.0, -350.0)
links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0])
links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0])
links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1])
links.new(foreground_mask.outputs[0], masked_depth.inputs[0])
links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2])
links.new(clamp_foreground.outputs[0], masked_depth.inputs[3])
links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0])
links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1])
links.new(enabled_depth.outputs[0], scaled_depth.inputs[0])
links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1])
processed_alpha = foreground_mask.outputs[0]
processed_depth = scaled_depth.outputs[0]
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"])
links.new(
roughness_curve.outputs["Value"],
group_output.inputs["Roughness"],
)
links.new(processed_alpha, group_output.inputs["Alpha"])
links.new(bump_depth, bump.inputs["Height"])
bump_distance = nodes.new("ShaderNodeMath")
bump_distance.operation = "MULTIPLY"
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0)
links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0])
links.new(bump_distance.outputs[0], bump.inputs["Distance"])
links.new(bump.outputs["Normal"], group_output.inputs["Normal"])
if displacement is not None:
links.new(processed_depth, displacement.inputs["Height"])
links.new(
displacement.outputs["Displacement"],
group_output.inputs["Displacement"],
)
group.color_tag = "SHADER"
group.description = (
"Prepare image depth material outputs."
if depth_plane
else f"Prepare {group_name} material outputs."
)
group.use_fake_user = True
return group

View File

@@ -11,8 +11,12 @@ sys.path.insert(
)
from anyimage.operators.common.mesh_result import (
DEPTH_MATERIAL_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
load_asset_node_group,
node_asset_path,
)
from anyimage.operators.convert_to_plane.nodes import DEPTH_PLANE_NODE_GROUP_NAME
from anyimage.operators.cutout_tool.shape import (
CUTOUT_NODE_GROUP_NAME,
CUTOUT_NODE_GROUP_NAMES,
@@ -30,11 +34,25 @@ def input_names(group):
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh_plane = load_asset_node_group("O Mesh Plane")
depth_plane = load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME)
image_layer = load_asset_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME)
depth_layer = load_asset_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME)
shadeless = load_asset_node_group("O Shadeless")
cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME)
cutout_groups = {
name: bpy.data.node_groups[name] for name in CUTOUT_NODE_GROUP_NAMES.values()
}
expected_names = {
"O Mesh Plane",
DEPTH_PLANE_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
DEPTH_MATERIAL_NODE_GROUP_NAME,
"O Shadeless",
CUTOUT_NODE_GROUP_NAME,
*CUTOUT_NODE_GROUP_NAMES.values(),
}
with bpy.data.libraries.load(str(node_asset_path()), link=False) as (source, _):
assert set(source.node_groups) == expected_names
assert mesh_plane.name == "O Mesh Plane"
assert mesh_plane.bl_idname == "GeometryNodeTree"
@@ -45,6 +63,34 @@ def main():
"Thickness",
"Material",
]
assert depth_plane.bl_idname == "GeometryNodeTree"
assert depth_plane.is_modifier
assert input_names(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Amount",
"Reference Depth",
"Uniform Scale",
"Depth Image",
"Material",
]
assert image_layer.bl_idname == "ShaderNodeTree"
assert input_names(image_layer) == [
"Color",
"Alpha",
"Normal",
"Normal Scale",
"Object Space",
"Bump Scale",
]
assert depth_layer.bl_idname == "ShaderNodeTree"
assert input_names(depth_layer) == [
"Color",
"Alpha",
"Depth",
"Bump Scale",
]
assert shadeless.name == "O Shadeless"
assert shadeless.bl_idname == "ShaderNodeTree"
assert input_names(shadeless) == ["Color"]
@@ -59,7 +105,14 @@ def main():
assert not group.is_modifier
assert input_names(group)[0] == "Geometry"
public_groups = {mesh_plane, shadeless, cutout}
public_groups = {
mesh_plane,
depth_plane,
image_layer,
depth_layer,
shadeless,
cutout,
}
loaded = {*public_groups, *cutout_groups.values()}
assert all(group.use_fake_user for group in public_groups)
assert all(group.users > 0 for group in cutout_groups.values())