Examples Gallery
GitHub

compositor-glare

Bloom where it actually lives — a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.

Rendered headless by the example itself — click to zoom.

witnesses scene.node_tree is gone in 5.x and EEVEE has no use_bloom on either side: the halo beyond the ring silhouette falls off strictly with the compositor on and is exactly zero with it off.
blender --background --python examples/compositor-glare/compositor_glare.py --

A runnable example that puts bloom where it actually lives: the compositor. Three emissive rings are rendered, then a Glare (Fog Glow) node fed by Render Layers grows the halo — wired through scene.compositing_node_group on Blender 5.x and through scene.node_tree on 4.x, because the 5.0 compositor rewrite removed the scene-level tree entirely (scene.use_nodes = True now raises AttributeError).

The Glare node changed shape with it: on 4.x it takes legacy enum properties (glare_type='FOG_GLOW', quality='HIGH', size=6), while on 5.x the same choices are menu/float input sockets (inputs['Type'].default_value = 'Fog Glow'). The 4.x threshold property is a dead shim — the real threshold is the Threshold input socket on both versions. And EEVEE has no use_bloom toggle on either version (removed in 4.2): bloom is a compositor node, period.

What it witnesses: the halo is the compositor's output, proven with pixels. The check renders the scene twice at 96×54 (single-sample Cycles, noise-free) — once with use_compositing on, once off — and samples a column of pixels above the middle ring's silhouette via world_to_camera_view. With the compositor on, the halo luminance just outside the silhouette is far above zero and falls off strictly with distance (the Fog Glow kernel signature); with it off, those same pixels are exactly zero. The structural check pins the wiring (Render Layers → Glare → output), the version-correct tree plumbing, and the absence of any EEVEE bloom toggle.

Run

# Cheap correctness check (two tiny renders) — the CI check:
blender --background --python compositor_glare.py --

# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python compositor_glare.py -- --output rings.png
blender --background --python compositor_glare.py -- --output rings.png --engine cycles

It exits non-zero on failure (wrong tree plumbing, wrong Glare configuration, a missing or non-falling halo, or halo pixels that appear without the compositor). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

Source

examples/compositor-glare/compositor_glare.py View on GitHub →
"""Bloom through the compositor across the 4.x -> 5.x boundary — a runnable example.

Witnesses the compositor-plumbing contract that AI-generated Blender code breaks
constantly:

- Blender 5.x removed `scene.node_tree` / `scene.use_nodes`: the scene compositor
  is now a node GROUP assigned to `scene.compositing_node_group`, with an
  `Image` output declared via `tree.interface.new_socket`. The old one-liner
  `scene.use_nodes = True` raises AttributeError on 5.x.
- The Glare node itself changed shape: on 4.x it is configured with legacy enum
  properties (`glare_type='FOG_GLOW'`, `quality='HIGH'`, `size=6`); on 5.x the
  same choices are menu/float INPUT sockets (`inputs['Type'].default_value =
  'Fog Glow'`). The legacy `threshold` property is a dead shim on 4.x — the real
  threshold lives in the `Threshold` input socket on BOTH versions.
- EEVEE has no `use_bloom` toggle on either version (removed in 4.2). Bloom is
  a compositor node, and the check proves it with pixels: a halo appears beyond
  the ring silhouette, falls off strictly with distance, and vanishes entirely
  when `scene.render.use_compositing` is off.

By default it runs only the correctness check (two 96x54 single-sample Cycles
renders, compositor on vs off) — the CI smoke check. Pass --output to also
render a still:

    blender --background --python compositor_glare.py --                 # check only
    blender --background --python compositor_glare.py -- --output n.png  # + render
"""
import bpy
import sys
import os
import math
import tempfile
import argparse
from bpy_extras.object_utils import world_to_camera_view
from mathutils import Vector

RING_MAJOR = 0.7
RING_MINOR = 0.12
RING_Z = 0.85                # ring center height; bottom edge grazes the floor
RING_SPECS = (               # three neon rings standing in the XZ plane (name, x, y, color, strength)
    ("RingViolet", -1.55, 0.55, (0.55, 0.15, 1.0, 1.0), 12.0),
    ("RingCyan",    0.0,  0.0,  (0.10, 0.90, 1.0, 1.0), 18.0),  # the ring the check samples
    ("RingAmber",   1.55, 0.45, (1.00, 0.45, 0.08, 1.0), 9.0),
)

CHECK_W, CHECK_H = 96, 54    # tiny, noise-free: 1-sample Cycles, pure emission


def eevee_engine_id():
    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'


def make_emissive(name, color, strength):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    emis = nt.nodes.new('ShaderNodeEmission')
    emis.inputs['Color'].default_value = color
    emis.inputs['Strength'].default_value = strength
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    nt.links.new(emis.outputs['Emission'], out.inputs['Surface'])
    return mat


def configure_fog_glow(glare):
    """Same Fog Glow on both sides of the 5.0 compositor rewrite.

    4.x: legacy enum properties; `size` is the old kernel int and the real
    threshold is the `Threshold` input socket (the `threshold` property is a
    dead shim — it always reads 0.0 on 4.5).
    5.x: `glare_type`/`quality`/`size` properties are gone; Type and Quality
    are menu sockets that take their DISPLAY names, Size is a 0..1 factor.
    """
    if bpy.app.version >= (5, 0, 0):
        glare.inputs['Type'].default_value = 'Fog Glow'
        glare.inputs['Quality'].default_value = 'High'
        glare.inputs['Size'].default_value = 0.85
    else:
        glare.glare_type = 'FOG_GLOW'
        glare.quality = 'HIGH'
        glare.size = 6
    glare.inputs['Threshold'].default_value = 1.0  # input socket on BOTH versions


def build_compositor(scene):
    """Wire Render Layers -> Glare (Fog Glow) -> output, cross-version.

    Returns (tree, glare_node). 5.x builds a compositor node group and assigns
    it to scene.compositing_node_group; 4.x uses the scene's own node tree.
    """
    if bpy.app.version >= (5, 0, 0):
        tree = bpy.data.node_groups.new("BloomComposite", 'CompositorNodeTree')
        tree.interface.new_socket(name="Image", in_out='INPUT', socket_type='NodeSocketColor')
        tree.interface.new_socket(name="Image", in_out='OUTPUT', socket_type='NodeSocketColor')
        out_node = tree.nodes.new("NodeGroupOutput")
        scene.compositing_node_group = tree
    else:
        scene.use_nodes = True
        tree = scene.node_tree
        tree.nodes.clear()
        out_node = tree.nodes.new("CompositorNodeComposite")
    rl = tree.nodes.new("CompositorNodeRLayers")
    glare = tree.nodes.new("CompositorNodeGlare")
    configure_fog_glow(glare)
    tree.links.new(rl.outputs["Image"], glare.inputs["Image"])
    tree.links.new(glare.outputs["Image"], out_node.inputs["Image"])
    return tree, glare


def build_scene():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene

    for name, x, y, color, strength in RING_SPECS:
        bpy.ops.mesh.primitive_torus_add(
            major_radius=RING_MAJOR, minor_radius=RING_MINOR,
            major_segments=64, minor_segments=16,
            location=(x, y, RING_Z), rotation=(math.radians(90), 0.0, 0.0))
        ring = bpy.context.active_object
        ring.name = name
        ring.data.materials.append(make_emissive(f"Neon{name[4:]}", color, strength))
        for poly in ring.data.polygons:
            poly.use_smooth = True

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0)
    scene.world = world

    cam_data = bpy.data.cameras.new("Cam")
    cam = bpy.data.objects.new("Cam", cam_data)
    # level, centered camera: the top silhouette of the middle ring is exactly
    # (0, 0, RING_Z + RING_MAJOR + RING_MINOR) with pure black world above it
    cam.location = (0.0, -6.0, RING_Z)
    cam.rotation_euler = (math.radians(90), 0.0, 0.0)
    scene.collection.objects.link(cam)
    scene.camera = cam

    tree, glare = build_compositor(scene)
    bpy.context.view_layer.update()
    return scene, tree, glare


def link_chain_ok(tree, glare, out_node):
    """Every hop Render Layers -> Glare -> output must be a real link."""
    rl = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None)
    if rl is None:
        return False
    hop1 = any(l.from_node == rl and l.to_node == glare for l in tree.links)
    hop2 = any(l.from_node == glare and l.to_node == out_node for l in tree.links)
    return hop1 and hop2


def check_structure(scene, tree, glare):
    is_5x = bpy.app.version >= (5, 0, 0)
    # the divergence itself: scene.node_tree exists only on 4.x
    if hasattr(scene, "node_tree") == is_5x:
        print(f"ERROR: scene.node_tree presence {hasattr(scene, 'node_tree')} "
              f"contradicts version {bpy.app.version_string}", file=sys.stderr)
        return 3
    if is_5x:
        if scene.compositing_node_group != tree:
            print("ERROR: compositing_node_group is not the tree we built", file=sys.stderr)
            return 3
        outs = [s for s in tree.interface.items_tree
                if getattr(s, "in_out", None) == 'OUTPUT' and s.name == "Image"]
        if not outs:
            print("ERROR: compositor group has no Image output socket", file=sys.stderr)
            return 3
        out_node = next(n for n in tree.nodes if n.bl_idname == "NodeGroupOutput")
        if glare.inputs['Type'].default_value != 'Fog Glow':
            print("ERROR: 5.x Glare Type menu is not 'Fog Glow'", file=sys.stderr)
            return 3
    else:
        out_node = next(n for n in tree.nodes if n.bl_idname == "CompositorNodeComposite")
        if not (glare.glare_type == 'FOG_GLOW' and glare.quality == 'HIGH' and glare.size == 6):
            print("ERROR: 4.x Glare legacy properties not as configured", file=sys.stderr)
            return 3
    if glare.inputs['Threshold'].default_value != 1.0:
        print("ERROR: Glare Threshold input != 1.0", file=sys.stderr)
        return 3
    if not link_chain_ok(tree, glare, out_node):
        print("ERROR: Render Layers -> Glare -> output link chain broken", file=sys.stderr)
        return 3
    # negative witness: bloom cannot come from EEVEE on either version
    if getattr(getattr(scene, "eevee", None), "use_bloom", None) is not None:
        print("ERROR: unexpected EEVEE use_bloom toggle — the example's premise is stale",
              file=sys.stderr)
        return 3
    return 0


def render_frame(scene, path, use_compositing):
    scene.render.engine = 'CYCLES'
    scene.cycles.samples = 1
    scene.cycles.use_denoising = False
    scene.render.resolution_x = CHECK_W
    scene.render.resolution_y = CHECK_H
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = 'PNG'
    scene.render.use_compositing = use_compositing
    scene.render.filepath = path
    bpy.ops.render.render(write_still=True)
    return os.path.exists(path) and os.path.getsize(path) > 0


def sample_column(scene, path):
    """Luminance (R+G+B) on the middle ring's tube, then marching up in image
    space from its top silhouette pixel: the halo profile of the bloom kernel."""
    img = bpy.data.images.load(path)
    w, h = img.size
    if (w, h) != (CHECK_W, CHECK_H):
        bpy.data.images.remove(img)
        raise RuntimeError(f"reloaded {path} at {(w, h)}, expected {(CHECK_W, CHECK_H)}")
    px = img.pixels[:]
    bpy.data.images.remove(img)

    def lum(x, y):
        x, y = max(0, min(w - 1, x)), max(0, min(h - 1, y))
        i = (y * w + x) * 4
        return px[i] + px[i + 1] + px[i + 2]

    def project(co):
        ndc = world_to_camera_view(scene, scene.camera, Vector(co))
        return int(ndc.x * w), int(ndc.y * h)

    on_x, on_y = project((0.0, 0.0, RING_Z + RING_MAJOR))            # on the tube
    sil_x, sil_y = project((0.0, 0.0, RING_Z + RING_MAJOR + RING_MINOR))  # silhouette
    return (lum(on_x, on_y), [lum(sil_x, sil_y + d) for d in (1, 2, 3)])


def check_pixels(scene):
    tmp = tempfile.mkdtemp(prefix="compositor_glare_")
    try:
        on_path = os.path.join(tmp, "on.png")
        off_path = os.path.join(tmp, "off.png")
        if not render_frame(scene, on_path, True):
            print("ERROR: compositor-on check render produced no file", file=sys.stderr)
            return 4
        if not render_frame(scene, off_path, False):
            print("ERROR: compositor-off check render produced no file", file=sys.stderr)
            return 4
        tube_on, halo_on = sample_column(scene, on_path)
        tube_off, halo_off = sample_column(scene, off_path)

        if tube_on < 1.5 or tube_off < 1.5:
            print(f"ERROR: ring tube not bright (on={tube_on:.3f} off={tube_off:.3f})",
                  file=sys.stderr)
            return 4
        if halo_on[0] < 0.10:
            print(f"ERROR: no bloom halo beyond the silhouette (halo+1px={halo_on[0]:.3f})",
                  file=sys.stderr)
            return 4
        if not (halo_on[0] > halo_on[1] > halo_on[2]):
            print(f"ERROR: halo does not fall off strictly: "
                  f"{['%.3f' % v for v in halo_on]}", file=sys.stderr)
            return 4
        if max(halo_off) > 0.02:
            print(f"ERROR: halo present WITHOUT the compositor "
                  f"({['%.3f' % v for v in halo_off]}) — bloom is not from the Glare node",
                  file=sys.stderr)
            return 5
        print(f"pixels: tube={tube_on:.3f} halo(+1/+2/+3px)="
              f"{'/'.join('%.3f' % v for v in halo_on)} "
              f"compositor-off halo max={max(halo_off):.3f}")
        return 0
    finally:
        for f in ("on.png", "off.png"):
            p = os.path.join(tmp, f)
            if os.path.exists(p):
                os.remove(p)
        os.rmdir(tmp)


def render_still(scene, path, engine, samples, width):
    """Dark-studio beauty pass: the same rings, now grounded on a reflective
    floor — the glow and its reflection are the compositor's doing."""
    # glossy dark floor stays below the bloom threshold; only the rings bloom
    mesh = bpy.data.meshes.new("Floor")
    half = 30.0
    mesh.from_pydata([(-half, -half, 0.0), (half, -half, 0.0),
                      (half, half, 0.0), (-half, half, 0.0)], [], [(0, 1, 2, 3)])
    fmat = bpy.data.materials.new("Studio")
    fmat.use_nodes = True
    fb = fmat.node_tree.nodes["Principled BSDF"]
    fb.inputs["Base Color"].default_value = (0.04, 0.045, 0.055, 1.0)
    fb.inputs["Roughness"].default_value = 0.25
    fb.inputs["Metallic"].default_value = 0.6
    mesh.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", mesh)
    scene.collection.objects.link(floor)

    def light(name, loc, energy, size, col, rot):
        ld = bpy.data.lights.new(name, 'AREA')
        ld.energy = energy
        ld.size = size
        ld.color = col
        ob = bpy.data.objects.new(name, ld)
        ob.location = loc
        ob.rotation_euler = tuple(math.radians(a) for a in rot)
        scene.collection.objects.link(ob)

    light("Key", (-4.5, -4.0, 5.5), 500.0, 6.0, (0.85, 0.9, 1.0), (50, 0, -30))
    light("Fill", (4.5, -3.0, 2.5), 300.0, 8.0, (1.0, 0.85, 0.7), (65, 0, 40))

    # a slightly elevated, centered camera for the still — all three rings in
    # frame with headroom for the glow, floor reflection anchoring the bottom
    cam = scene.camera
    cam.location = (0.0, -8.4, 2.0)
    cam.rotation_euler = (math.radians(79), 0.0, 0.0)

    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
    if engine == 'cycles':
        scene.cycles.samples = samples
        scene.cycles.use_denoising = True
    else:
        try:
            scene.eevee.taa_render_samples = samples
            scene.eevee.use_raytracing = True
        except AttributeError:
            pass
    scene.render.resolution_x = width
    scene.render.resolution_y = int(width * 9 / 16)
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = 'WEBP' if path.lower().endswith(".webp") else 'PNG'
    scene.render.use_compositing = True
    scene.render.filepath = path
    bpy.ops.render.render(write_still=True)
    return os.path.exists(path) and os.path.getsize(path) > 0


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser()
    p.add_argument("--output", default=None, help="optional: render a still here (.png or .webp)")
    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
                   help="render engine for --output (cycles for GPU-less hosts)")
    p.add_argument("--samples", type=int, default=32, help="--output sample count")
    p.add_argument("--width", type=int, default=1280, help="--output width; height is width*9/16")
    args = p.parse_args(argv)

    scene, tree, glare = build_scene()
    code = check_structure(scene, tree, glare)
    if code:
        return code
    print(f"structure OK on {bpy.app.version_string}: "
          f"{'compositing_node_group' if bpy.app.version >= (5, 0, 0) else 'scene.node_tree'} "
          f"-> Glare(Fog Glow) -> output; no EEVEE use_bloom")

    code = check_pixels(scene)
    if code:
        return code

    if args.output:
        if not render_still(scene, os.path.abspath(args.output), args.engine, args.samples,
                            args.width):
            print("ERROR: render produced no file", file=sys.stderr)
            return 6
        print(f"rendered still {args.output}")

    print("compositor-glare OK")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)