Examples Gallery
GitHub

swatch-grid

Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.

Rendered headless by the example itself — click to zoom.

witnesses EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2–4.5.
blender --background --python examples/swatch-grid/swatch_grid.py --

A runnable example that renders a 3×2 grid of spheres — one material per cell — to a single PNG. It demonstrates the procedural-materials-and-shaders patterns end to end:

It doubles as a live proof of the EEVEE engine-id behavior: the version-branch helper resolves BLENDER_EEVEE on Blender 5.x and BLENDER_EEVEE_NEXT on 4.2–4.5, and the chosen id is asserted against the running build before rendering — so a regression in that mapping fails the example, not just the docs.

Run

# Default: render with the build's EEVEE engine (needs a GPU/display)
blender --background --python swatch_grid.py -- --output swatch.png

# GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still
# asserted; only the final pixels use Cycles.
blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960

The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no network). It exits non-zero on any failure, including a render that comes out uniformly black or without the expected six distinct swatch regions — the same honest check the CI smoke gate runs on both Blender 4.5 LTS and 5.1.

Verified

Runs headless on Blender 4.5.10 LTS and 5.1.1; exercised on both by the blender-smoke workflow on every PR and weekly schedule.

Source

examples/swatch-grid/swatch_grid.py View on GitHub →
"""Procedural-materials swatch grid -- a runnable BDT example.

Renders a 3x2 grid of spheres, one per material, demonstrating the
`procedural-materials-and-shaders` patterns end to end: Principled BSDF (metal +
dielectric), the emission pattern, the cross-version `set_specular` shim, string socket
lookups, and 4-tuple colors. It also doubles as a live proof of the EEVEE engine-id fix:
the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT`
on 4.2-4.5, and the chosen id is asserted against the build before rendering.

Run headless:
    blender --background --python swatch_grid.py -- --output swatch.png
    blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640

Dependency-light and deterministic (fixed camera/layout, no HDRI, no network). Exits
non-zero on any failure, including a render that comes out black or without the expected
number of distinct swatch regions.
"""
import bpy
import bmesh
import sys
import os
import math
import argparse
import numpy as np

GRID_COLS, GRID_ROWS = 3, 2
MATERIAL_COUNT = GRID_COLS * GRID_ROWS  # 6


# --- patterns copied from the procedural-materials-and-shaders skill ---
def get_eevee_engine_id():
    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'


def set_specular(bsdf, value):
    """'Specular' was renamed to 'Specular IOR Level' in Blender 4.0; support both."""
    if 'Specular IOR Level' in bsdf.inputs:
        bsdf.inputs['Specular IOR Level'].default_value = value
        return 'Specular IOR Level'
    if 'Specular' in bsdf.inputs:
        bsdf.inputs['Specular'].default_value = value
        return 'Specular'
    return None


def make_principled(name, base_color, metallic, roughness, specular=None):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    bsdf.inputs['Base Color'].default_value = base_color
    bsdf.inputs['Metallic'].default_value = metallic
    bsdf.inputs['Roughness'].default_value = roughness
    resolved = set_specular(bsdf, specular) if specular is not None else None
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
    return mat, resolved


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 build_materials():
    """Return a list of (material, label) covering metal, dielectric, emissive, and the
    set_specular shim. The list order maps left-to-right, top-to-bottom across the grid."""
    mats, specular_socket = [], None
    m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15)
    mats.append(m)
    m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28)
    mats.append(m)
    m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5)
    mats.append(m)
    specular_socket = specular_socket or sr
    m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5)
    mats.append(m)
    mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0))
    m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3)
    mats.append(m)
    return mats, specular_socket


def build_scene(mats):
    xs = [-2.2, 0.0, 2.2]
    zs = [1.1, -1.1]
    i = 0
    for r in range(GRID_ROWS):
        for c in range(GRID_COLS):
            me = bpy.data.meshes.new(f"S{i}")
            bm = bmesh.new()
            bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92)
            bm.to_mesh(me)
            bm.free()
            for poly in me.polygons:
                poly.use_smooth = True
            ob = bpy.data.objects.new(f"S{i}", me)
            ob.location = (xs[c], 0.0, zs[r])
            bpy.context.collection.objects.link(ob)
            ob.data.materials.append(mats[i])
            i += 1
    # ortho camera framed exactly on the grid cells
    cam_d = bpy.data.cameras.new("cam")
    cam_d.type = 'ORTHO'
    cam_d.ortho_scale = 6.6
    cam = bpy.data.objects.new("cam", cam_d)
    cam.location = (0.0, -10.0, 0.0)
    cam.rotation_euler = (math.radians(90), 0, 0)
    bpy.context.collection.objects.link(cam)
    bpy.context.scene.camera = cam
    aim = bpy.data.objects.new("Aim", None)
    bpy.context.collection.objects.link(aim)
    for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]:
        ld = bpy.data.lights.new(lname, 'AREA')
        ld.energy = energy
        ld.size = 5.0
        lo = bpy.data.objects.new(lname, ld)
        lo.location = loc
        bpy.context.collection.objects.link(lo)
        con = lo.constraints.new('TRACK_TO')
        con.target = aim
        con.track_axis = 'TRACK_NEGATIVE_Z'
        con.up_axis = 'UP_Y'
    world = bpy.data.worlds.new("W")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1)
    bpy.context.scene.world = world


def verify_png(path):
    """Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT."""
    img = bpy.data.images.load(path)
    w, h = img.size
    arr = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4)[..., :3]
    gmax = float(arr.max())
    cw, ch, ph = w // GRID_COLS, h // GRID_ROWS, 24
    means = []
    for r in range(GRID_ROWS):
        for c in range(GRID_COLS):
            cx, cy = c * cw + cw // 2, r * ch + ch // 2
            means.append(arr[cy - ph:cy + ph, cx - ph:cx + ph, :].reshape(-1, 3).mean(axis=0))
    kept = []
    for cm in means:
        if all(np.linalg.norm(cm - k) > 0.10 for k in kept):
            kept.append(cm)
    return gmax, len(kept)


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.")
    p.add_argument("--output", required=True, help="Output PNG path")
    p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto",
                   help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts")
    p.add_argument("--samples", type=int, default=32)
    p.add_argument("--width", type=int, default=1280)
    p.add_argument("--no-verify", action="store_true")
    args = p.parse_args(argv)

    # Empty the factory file FIRST so the materials we create below survive.
    bpy.ops.wm.read_factory_settings(use_empty=True)
    mats, specular_socket = build_materials()
    build_scene(mats)

    sc = bpy.context.scene
    # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles.
    eid = get_eevee_engine_id()
    expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
    sc.render.engine = eid
    if sc.render.engine != expected:
        print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', "
              f"expected '{expected}'", file=sys.stderr)
        return 5
    print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'")

    render_engine = 'CYCLES' if args.engine == 'cycles' else eid
    sc.render.engine = render_engine
    if render_engine == 'CYCLES':
        sc.cycles.samples = args.samples
    else:
        sc.eevee.taa_render_samples = args.samples
    sc.render.resolution_x = args.width
    sc.render.resolution_y = int(args.width * 9 / 16)
    sc.render.image_settings.file_format = 'PNG'
    sc.render.filepath = args.output
    os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
    bpy.ops.render.render(write_still=True)
    if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0):
        print("ERROR: no output written", file=sys.stderr)
        return 4
    print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)")

    if not args.no_verify:
        gmax, regions = verify_png(args.output)
        non_black = gmax > 0.05
        regions_ok = regions == MATERIAL_COUNT
        print(f"verify: max_pixel={gmax:.3f} non_black={non_black} "
              f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}")
        if not (non_black and regions_ok):
            print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr)
            return 3
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:  # blender exits 0 on an uncaught traceback; force non-zero
        import traceback
        traceback.print_exc()
        print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr)
        sys.exit(1)