From 3fddc3523c6fed0d655d42766278d9b289b75834 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 15 Aug 2026 09:48:23 -0500 Subject: [PATCH] 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 --- .../bonsai/bim/module/status_render/NOTES.md | 28 ++++ .../bim/module/status_render/operator.py | 135 +++++++++++++++--- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/status_render/NOTES.md b/src/bonsai/bonsai/bim/module/status_render/NOTES.md index 41c273226c..77438309e3 100644 --- a/src/bonsai/bonsai/bim/module/status_render/NOTES.md +++ b/src/bonsai/bonsai/bim/module/status_render/NOTES.md @@ -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). diff --git a/src/bonsai/bonsai/bim/module/status_render/operator.py b/src/bonsai/bonsai/bim/module/status_render/operator.py index d620297ffe..db0f7856da 100644 --- a/src/bonsai/bonsai/bim/module/status_render/operator.py +++ b/src/bonsai/bonsai/bim/module/status_render/operator.py @@ -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 --------------------------------------------------------------