mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-17 02:49:12 +00:00
Fix render overrides on Blender 5.x (compositor API rewrite)
The status_render compositor path worked in Blender 4.5 but did nothing in 5.2: Blender 5.0 reworked the compositor and removed every API it used. - scene.node_tree is gone -- the compositor tree is now a standalone CompositorNodeTree ID at scene.compositing_node_group, and scene.use_nodes is a deprecated no-op that no longer creates one. Reading scene.node_tree raised AttributeError inside the render_init handler, so build_compositor died before adding any nodes. - CompositorNodeComposite was removed; the final image now goes to a NodeGroupOutput backed by an interface socket. - CompositorNodeGamma -> ShaderNodeGamma (its image socket is named "Color"). - CompositorNodeMixRGB -> ShaderNodeMix with data_type "RGBA", whose sockets have to be picked by identifier since several share a display name. Render Layers, Exposure and Cryptomatte are unchanged, and the transparency layer (temporary materials) was never affected. All of this is funnelled through get_compositor_tree / get_output_socket / add_gamma_node / add_mix_node / find_socket so the graph-building code stays version agnostic. Two 5.x-only wrinkles: - A compositing group is a standalone ID, so a Render Layers node created in one is not implicitly bound to the scene being rendered; set .scene when it is None. - When no group exists we create one and tag it, and clear_compositor deletes it again, so a scene that never composited does not end up permanently compositing through a passthrough graph. Also replaces the deprecated material.use_nodes write with tool.Style.set_use_nodes. Verified end-to-end headless on 4.5.7 and 5.2.0 (Cryptomatte-masked exposure and gamma applied to the matched object and not the background, teardown, and the transparency swap/restore) with identical rendered pixel values. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,34 @@ Exposure/gamma stay in the compositor because that's the faithful colour-managem
|
||||
tonemap. They are render-only: the viewport compositor can't read render passes
|
||||
(Cryptomatte), so per-object masking isn't available there.
|
||||
|
||||
## Blender version compatibility (4.x vs 5.x)
|
||||
|
||||
Blender 5.0 reworked the compositor and broke every API the first version of this
|
||||
module used. All of it is funnelled through helpers in `operator.py` (`get_compositor_tree`,
|
||||
`get_output_socket`, `add_gamma_node`, `add_mix_node`, `find_socket`) so the
|
||||
graph-building code reads the same on both:
|
||||
|
||||
| | ≤ 4.5 | ≥ 5.0 |
|
||||
|---|---|---|
|
||||
| Node tree | `scene.node_tree` (embedded), enabled via `scene.use_nodes` | `scene.compositing_node_group` — a standalone `CompositorNodeTree` ID in `bpy.data.node_groups`. `scene.node_tree` is **gone**; `scene.use_nodes` is a deprecated no-op that no longer creates a tree |
|
||||
| Final output | `CompositorNodeComposite` | node **removed** — use `NodeGroupOutput` plus `tree.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")` |
|
||||
| Gamma | `CompositorNodeGamma` (image socket "Image") | `ShaderNodeGamma` (image socket "Color") — addressed positionally |
|
||||
| Mix | `CompositorNodeMixRGB` | `ShaderNodeMix` with `data_type = "RGBA"`; sockets picked by identifier (`Factor_Float`, `A_Color`, `B_Color`, `Result_Color`) since several share a display name |
|
||||
| Render Layers / Exposure / Cryptomatte | unchanged | unchanged |
|
||||
|
||||
Two 5.x-only wrinkles, both handled in `build_compositor`/`clear_compositor`:
|
||||
|
||||
- A compositing group is a standalone ID, so a Render Layers node created inside one
|
||||
is not implicitly bound to the scene being rendered — `render_layers.scene` is set
|
||||
when it comes back `None`.
|
||||
- If no group existed, we create one and tag it with `MARKER`; `clear_compositor`
|
||||
deletes it and resets `scene.compositing_node_group` to `None`, so a scene that
|
||||
never composited doesn't end up permanently compositing through a passthrough graph.
|
||||
|
||||
Verified end-to-end (Cryptomatte-masked exposure and gamma both applied to the matched
|
||||
object and not the background, everything torn down afterwards) on 4.5.7 and 5.2.0 —
|
||||
identical rendered pixel values.
|
||||
|
||||
## Why Cryptomatte (not the Object Index pass)
|
||||
|
||||
EEVEE Next always renders the Object Index pass as 0 (Blender bug #121690).
|
||||
|
||||
@@ -91,6 +91,90 @@ def get_or_create(tree, bl_idname):
|
||||
return tree.nodes.new(bl_idname)
|
||||
|
||||
|
||||
# --- Blender 5 compositor compatibility ------------------------------------
|
||||
# Blender 5.0 reworked the compositor and broke every API this module used:
|
||||
# * the scene's embedded node tree became a standalone CompositorNodeTree ID
|
||||
# (``scene.compositing_node_group``); ``scene.node_tree`` is gone and
|
||||
# ``scene.use_nodes`` is a deprecated no-op,
|
||||
# * the Composite output node was replaced by the node group's output,
|
||||
# * Gamma and MixRGB moved to the unified shader node types.
|
||||
# Render Layers, Exposure and Cryptomatte are unchanged. Everything below funnels
|
||||
# through these helpers so the graph-building code stays version agnostic.
|
||||
|
||||
|
||||
def find_socket(sockets, identifier):
|
||||
"""Look a socket up by ``identifier`` -- socket collections key by identifier, and
|
||||
the unified 5.x nodes carry several same-named sockets (one per data type)."""
|
||||
return next(socket for socket in sockets if socket.identifier == identifier)
|
||||
|
||||
|
||||
def get_compositor_tree(scene, create=False):
|
||||
"""Return the scene's compositor node tree, optionally creating it. None if absent."""
|
||||
if tool.Blender.BLENDER_5:
|
||||
tree = scene.compositing_node_group
|
||||
if tree is None and create:
|
||||
tree = bpy.data.node_groups.new("Compositor Nodes", "CompositorNodeTree")
|
||||
tree[MARKER] = True # ours -- torn down again in clear_compositor
|
||||
scene.compositing_node_group = tree
|
||||
return tree
|
||||
if create:
|
||||
scene.use_nodes = True
|
||||
return scene.node_tree if scene.use_nodes else None
|
||||
|
||||
|
||||
def get_output_socket(tree, create=False):
|
||||
"""Return the socket that receives the tree's final image, or None.
|
||||
|
||||
Pre-5.0 that is the Composite node's Image input; from 5.0 it is the first input
|
||||
of the group output node, backed by an interface socket.
|
||||
"""
|
||||
if not tool.Blender.BLENDER_5:
|
||||
composite = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeComposite"), None)
|
||||
if composite is None:
|
||||
if not create:
|
||||
return None
|
||||
composite = tree.nodes.new("CompositorNodeComposite")
|
||||
return composite.inputs["Image"]
|
||||
output = next((n for n in tree.nodes if n.bl_idname == "NodeGroupOutput"), None)
|
||||
if output is None:
|
||||
if not create:
|
||||
return None
|
||||
output = tree.nodes.new("NodeGroupOutput")
|
||||
if not any(i.item_type == "SOCKET" and i.in_out == "OUTPUT" for i in tree.interface.items_tree):
|
||||
if not create:
|
||||
return None
|
||||
tree.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")
|
||||
return output.inputs[0] # the trailing "__extend__" virtual socket is always last
|
||||
|
||||
|
||||
def add_gamma_node(tree, gamma):
|
||||
"""Create a Gamma node and return ``(node, image_in, image_out)``.
|
||||
|
||||
ShaderNodeGamma names its image sockets "Color", so they are addressed positionally.
|
||||
"""
|
||||
node = tree.nodes.new("ShaderNodeGamma" if tool.Blender.BLENDER_5 else "CompositorNodeGamma")
|
||||
node.inputs["Gamma"].default_value = gamma
|
||||
return node, node.inputs[0], node.outputs[0]
|
||||
|
||||
|
||||
def add_mix_node(tree):
|
||||
"""Create a colour Mix node and return ``(node, fac_in, a_in, b_in, result_out)``."""
|
||||
if tool.Blender.BLENDER_5:
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "RGBA"
|
||||
node.blend_type = "MIX"
|
||||
return (
|
||||
node,
|
||||
find_socket(node.inputs, "Factor_Float"),
|
||||
find_socket(node.inputs, "A_Color"),
|
||||
find_socket(node.inputs, "B_Color"),
|
||||
find_socket(node.outputs, "Result_Color"),
|
||||
)
|
||||
node = tree.nodes.new("CompositorNodeMixRGB")
|
||||
node.blend_type = "MIX"
|
||||
return node, node.inputs[0], node.inputs[1], node.inputs[2], node.outputs[0]
|
||||
|
||||
|
||||
# Transparency is a real material effect (so geometry behind shows through), applied
|
||||
# before the render and restored after. These hold the swap state between the render
|
||||
# handlers. Only one render runs at a time, so module-level state is safe.
|
||||
@@ -102,7 +186,7 @@ def make_transparent_material(material, amount):
|
||||
"""Copy a material and mix a Transparent BSDF into its surface by ``amount``."""
|
||||
dup = material.copy()
|
||||
dup[MARKER] = True
|
||||
dup.use_nodes = True
|
||||
tool.Style.set_use_nodes(dup, True) # deprecated no-op on Blender 5+, where it is always on
|
||||
tree = dup.node_tree
|
||||
output = next((n for n in tree.nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None)
|
||||
output = output or next((n for n in tree.nodes if n.type == "OUTPUT_MATERIAL"), None)
|
||||
@@ -182,18 +266,20 @@ def build_color_rule(tree, scene, view_layer, rule, objects, input_socket, x, y)
|
||||
exposure.inputs["Exposure"].default_value = rule.exposure
|
||||
links.new(input_socket, exposure.inputs["Image"])
|
||||
|
||||
gamma = node("CompositorNodeGamma", (x + 180, y))
|
||||
gamma, gamma_in, gamma_out = add_gamma_node(tree, rule.gamma)
|
||||
gamma[MARKER] = True
|
||||
gamma.location = (x + 180, y)
|
||||
gamma.label = f"Gamma: {rule.name}"
|
||||
gamma.inputs["Gamma"].default_value = rule.gamma
|
||||
links.new(exposure.outputs["Image"], gamma.inputs["Image"])
|
||||
links.new(exposure.outputs["Image"], gamma_in)
|
||||
|
||||
mix = node("CompositorNodeMixRGB", (x + 360, y))
|
||||
mix, fac_in, base_in, effect_in, mix_out = add_mix_node(tree)
|
||||
mix[MARKER] = True
|
||||
mix.location = (x + 360, y)
|
||||
mix.label = f"Apply: {rule.name}"
|
||||
mix.blend_type = "MIX"
|
||||
links.new(matte, mix.inputs["Fac"])
|
||||
links.new(input_socket, mix.inputs[1])
|
||||
links.new(gamma.outputs["Image"], mix.inputs[2])
|
||||
return mix.outputs["Image"], matte
|
||||
links.new(matte, fac_in)
|
||||
links.new(input_socket, base_in)
|
||||
links.new(gamma_out, effect_in)
|
||||
return mix_out, matte
|
||||
|
||||
|
||||
def build_compositor(scene, props):
|
||||
@@ -203,14 +289,17 @@ def build_compositor(scene, props):
|
||||
sync_live_effects so it also shows in the viewport. This only adds the colour
|
||||
nodes layered per rule.
|
||||
"""
|
||||
scene.use_nodes = True
|
||||
tree = get_compositor_tree(scene, create=True)
|
||||
# The node tree is only applied to renders when compositing is enabled.
|
||||
scene.render.use_compositing = True
|
||||
tree = scene.node_tree
|
||||
clear_marked_nodes(tree)
|
||||
|
||||
render_layers = get_or_create(tree, "CompositorNodeRLayers")
|
||||
composite = get_or_create(tree, "CompositorNodeComposite")
|
||||
if render_layers.scene is None:
|
||||
# A 5.x compositing group is a standalone ID, so a fresh Render Layers node in
|
||||
# one isn't implicitly bound to the scene we are rendering.
|
||||
render_layers.scene = scene
|
||||
output_socket = get_output_socket(tree, create=True)
|
||||
view_layer = scene.view_layers.get(render_layers.layer) or scene.view_layers[0]
|
||||
|
||||
current = render_layers.outputs["Image"]
|
||||
@@ -230,8 +319,8 @@ def build_compositor(scene, props):
|
||||
current, _ = build_color_rule(tree, scene, view_layer, rule, objects, current, x, y)
|
||||
x += 700
|
||||
|
||||
composite.location = (x, y)
|
||||
tree.links.new(current, composite.inputs["Image"])
|
||||
output_socket.node.location = (x, y)
|
||||
tree.links.new(current, output_socket)
|
||||
|
||||
|
||||
def get_camera_override_props(camera):
|
||||
@@ -366,14 +455,20 @@ def clear_compositor(scene):
|
||||
|
||||
Leaves live materials untouched (those are managed by sync_live_effects).
|
||||
"""
|
||||
if not (scene.use_nodes and scene.node_tree):
|
||||
tree = get_compositor_tree(scene)
|
||||
if tree is None:
|
||||
return
|
||||
tree = scene.node_tree
|
||||
clear_marked_nodes(tree)
|
||||
if tree.get(MARKER):
|
||||
# A 5.x group we created just for the override -- remove it wholesale rather than
|
||||
# leaving the scene compositing through a passthrough graph it never had.
|
||||
scene.compositing_node_group = None
|
||||
bpy.data.node_groups.remove(tree)
|
||||
return
|
||||
rlayers = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None)
|
||||
composite = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeComposite"), None)
|
||||
if rlayers and composite and not composite.inputs["Image"].links:
|
||||
tree.links.new(rlayers.outputs["Image"], composite.inputs["Image"])
|
||||
output_socket = get_output_socket(tree)
|
||||
if rlayers and output_socket and not output_socket.links:
|
||||
tree.links.new(rlayers.outputs["Image"], output_socket)
|
||||
|
||||
|
||||
# --- Handlers --------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user