|
| 1 | +"""Procedural-materials swatch grid -- a runnable BDT example. |
| 2 | +
|
| 3 | +Renders a 3x2 grid of spheres, one per material, demonstrating the |
| 4 | +`procedural-materials-and-shaders` patterns end to end: Principled BSDF (metal + |
| 5 | +dielectric), the emission pattern, the cross-version `set_specular` shim, string socket |
| 6 | +lookups, and 4-tuple colors. It also doubles as a live proof of the EEVEE engine-id fix: |
| 7 | +the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` |
| 8 | +on 4.2-4.5, and the chosen id is asserted against the build before rendering. |
| 9 | +
|
| 10 | +Run headless: |
| 11 | + blender --background --python swatch_grid.py -- --output swatch.png |
| 12 | + blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640 |
| 13 | +
|
| 14 | +Dependency-light and deterministic (fixed camera/layout, no HDRI, no network). Exits |
| 15 | +non-zero on any failure, including a render that comes out black or without the expected |
| 16 | +number of distinct swatch regions. |
| 17 | +""" |
| 18 | +import bpy |
| 19 | +import bmesh |
| 20 | +import sys |
| 21 | +import os |
| 22 | +import math |
| 23 | +import argparse |
| 24 | +import numpy as np |
| 25 | + |
| 26 | +GRID_COLS, GRID_ROWS = 3, 2 |
| 27 | +MATERIAL_COUNT = GRID_COLS * GRID_ROWS # 6 |
| 28 | + |
| 29 | + |
| 30 | +# --- patterns copied from the procedural-materials-and-shaders skill --- |
| 31 | +def get_eevee_engine_id(): |
| 32 | + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" |
| 33 | + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' |
| 34 | + |
| 35 | + |
| 36 | +def set_specular(bsdf, value): |
| 37 | + """'Specular' was renamed to 'Specular IOR Level' in Blender 4.0; support both.""" |
| 38 | + if 'Specular IOR Level' in bsdf.inputs: |
| 39 | + bsdf.inputs['Specular IOR Level'].default_value = value |
| 40 | + return 'Specular IOR Level' |
| 41 | + if 'Specular' in bsdf.inputs: |
| 42 | + bsdf.inputs['Specular'].default_value = value |
| 43 | + return 'Specular' |
| 44 | + return None |
| 45 | + |
| 46 | + |
| 47 | +def make_principled(name, base_color, metallic, roughness, specular=None): |
| 48 | + mat = bpy.data.materials.new(name) |
| 49 | + mat.use_nodes = True |
| 50 | + nt = mat.node_tree |
| 51 | + nt.nodes.clear() |
| 52 | + bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled') |
| 53 | + bsdf.inputs['Base Color'].default_value = base_color |
| 54 | + bsdf.inputs['Metallic'].default_value = metallic |
| 55 | + bsdf.inputs['Roughness'].default_value = roughness |
| 56 | + resolved = set_specular(bsdf, specular) if specular is not None else None |
| 57 | + out = nt.nodes.new('ShaderNodeOutputMaterial') |
| 58 | + nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface']) |
| 59 | + return mat, resolved |
| 60 | + |
| 61 | + |
| 62 | +def make_emissive(name, color, strength): |
| 63 | + mat = bpy.data.materials.new(name) |
| 64 | + mat.use_nodes = True |
| 65 | + nt = mat.node_tree |
| 66 | + nt.nodes.clear() |
| 67 | + emis = nt.nodes.new('ShaderNodeEmission') |
| 68 | + emis.inputs['Color'].default_value = color |
| 69 | + emis.inputs['Strength'].default_value = strength |
| 70 | + out = nt.nodes.new('ShaderNodeOutputMaterial') |
| 71 | + nt.links.new(emis.outputs['Emission'], out.inputs['Surface']) |
| 72 | + return mat |
| 73 | + |
| 74 | + |
| 75 | +def build_materials(): |
| 76 | + """Return a list of (material, label) covering metal, dielectric, emissive, and the |
| 77 | + set_specular shim. The list order maps left-to-right, top-to-bottom across the grid.""" |
| 78 | + mats, specular_socket = [], None |
| 79 | + m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15) |
| 80 | + mats.append(m) |
| 81 | + m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28) |
| 82 | + mats.append(m) |
| 83 | + m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5) |
| 84 | + mats.append(m) |
| 85 | + specular_socket = specular_socket or sr |
| 86 | + m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5) |
| 87 | + mats.append(m) |
| 88 | + mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0)) |
| 89 | + m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3) |
| 90 | + mats.append(m) |
| 91 | + return mats, specular_socket |
| 92 | + |
| 93 | + |
| 94 | +def build_scene(mats): |
| 95 | + xs = [-2.2, 0.0, 2.2] |
| 96 | + zs = [1.1, -1.1] |
| 97 | + i = 0 |
| 98 | + for r in range(GRID_ROWS): |
| 99 | + for c in range(GRID_COLS): |
| 100 | + me = bpy.data.meshes.new(f"S{i}") |
| 101 | + bm = bmesh.new() |
| 102 | + bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92) |
| 103 | + bm.to_mesh(me) |
| 104 | + bm.free() |
| 105 | + for poly in me.polygons: |
| 106 | + poly.use_smooth = True |
| 107 | + ob = bpy.data.objects.new(f"S{i}", me) |
| 108 | + ob.location = (xs[c], 0.0, zs[r]) |
| 109 | + bpy.context.collection.objects.link(ob) |
| 110 | + ob.data.materials.append(mats[i]) |
| 111 | + i += 1 |
| 112 | + # ortho camera framed exactly on the grid cells |
| 113 | + cam_d = bpy.data.cameras.new("cam") |
| 114 | + cam_d.type = 'ORTHO' |
| 115 | + cam_d.ortho_scale = 6.6 |
| 116 | + cam = bpy.data.objects.new("cam", cam_d) |
| 117 | + cam.location = (0.0, -10.0, 0.0) |
| 118 | + cam.rotation_euler = (math.radians(90), 0, 0) |
| 119 | + bpy.context.collection.objects.link(cam) |
| 120 | + bpy.context.scene.camera = cam |
| 121 | + aim = bpy.data.objects.new("Aim", None) |
| 122 | + bpy.context.collection.objects.link(aim) |
| 123 | + for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]: |
| 124 | + ld = bpy.data.lights.new(lname, 'AREA') |
| 125 | + ld.energy = energy |
| 126 | + ld.size = 5.0 |
| 127 | + lo = bpy.data.objects.new(lname, ld) |
| 128 | + lo.location = loc |
| 129 | + bpy.context.collection.objects.link(lo) |
| 130 | + con = lo.constraints.new('TRACK_TO') |
| 131 | + con.target = aim |
| 132 | + con.track_axis = 'TRACK_NEGATIVE_Z' |
| 133 | + con.up_axis = 'UP_Y' |
| 134 | + world = bpy.data.worlds.new("W") |
| 135 | + world.use_nodes = True |
| 136 | + world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1) |
| 137 | + bpy.context.scene.world = world |
| 138 | + |
| 139 | + |
| 140 | +def verify_png(path): |
| 141 | + """Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT.""" |
| 142 | + img = bpy.data.images.load(path) |
| 143 | + w, h = img.size |
| 144 | + arr = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4)[..., :3] |
| 145 | + gmax = float(arr.max()) |
| 146 | + cw, ch, ph = w // GRID_COLS, h // GRID_ROWS, 24 |
| 147 | + means = [] |
| 148 | + for r in range(GRID_ROWS): |
| 149 | + for c in range(GRID_COLS): |
| 150 | + cx, cy = c * cw + cw // 2, r * ch + ch // 2 |
| 151 | + means.append(arr[cy - ph:cy + ph, cx - ph:cx + ph, :].reshape(-1, 3).mean(axis=0)) |
| 152 | + kept = [] |
| 153 | + for cm in means: |
| 154 | + if all(np.linalg.norm(cm - k) > 0.10 for k in kept): |
| 155 | + kept.append(cm) |
| 156 | + return gmax, len(kept) |
| 157 | + |
| 158 | + |
| 159 | +def main(): |
| 160 | + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] |
| 161 | + p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.") |
| 162 | + p.add_argument("--output", required=True, help="Output PNG path") |
| 163 | + p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto", |
| 164 | + help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts") |
| 165 | + p.add_argument("--samples", type=int, default=32) |
| 166 | + p.add_argument("--width", type=int, default=1280) |
| 167 | + p.add_argument("--no-verify", action="store_true") |
| 168 | + args = p.parse_args(argv) |
| 169 | + |
| 170 | + # Empty the factory file FIRST so the materials we create below survive. |
| 171 | + bpy.ops.wm.read_factory_settings(use_empty=True) |
| 172 | + mats, specular_socket = build_materials() |
| 173 | + build_scene(mats) |
| 174 | + |
| 175 | + sc = bpy.context.scene |
| 176 | + # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles. |
| 177 | + eid = get_eevee_engine_id() |
| 178 | + expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' |
| 179 | + sc.render.engine = eid |
| 180 | + if sc.render.engine != expected: |
| 181 | + print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', " |
| 182 | + f"expected '{expected}'", file=sys.stderr) |
| 183 | + return 5 |
| 184 | + print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'") |
| 185 | + |
| 186 | + render_engine = 'CYCLES' if args.engine == 'cycles' else eid |
| 187 | + sc.render.engine = render_engine |
| 188 | + if render_engine == 'CYCLES': |
| 189 | + sc.cycles.samples = args.samples |
| 190 | + else: |
| 191 | + sc.eevee.taa_render_samples = args.samples |
| 192 | + sc.render.resolution_x = args.width |
| 193 | + sc.render.resolution_y = int(args.width * 9 / 16) |
| 194 | + sc.render.image_settings.file_format = 'PNG' |
| 195 | + sc.render.filepath = args.output |
| 196 | + os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) |
| 197 | + bpy.ops.render.render(write_still=True) |
| 198 | + if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0): |
| 199 | + print("ERROR: no output written", file=sys.stderr) |
| 200 | + return 4 |
| 201 | + print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)") |
| 202 | + |
| 203 | + if not args.no_verify: |
| 204 | + gmax, regions = verify_png(args.output) |
| 205 | + non_black = gmax > 0.05 |
| 206 | + regions_ok = regions == MATERIAL_COUNT |
| 207 | + print(f"verify: max_pixel={gmax:.3f} non_black={non_black} " |
| 208 | + f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}") |
| 209 | + if not (non_black and regions_ok): |
| 210 | + print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) |
| 211 | + return 3 |
| 212 | + return 0 |
| 213 | + |
| 214 | + |
| 215 | +if __name__ == "__main__": |
| 216 | + try: |
| 217 | + sys.exit(main()) |
| 218 | + except Exception as exc: # blender exits 0 on an uncaught traceback; force non-zero |
| 219 | + import traceback |
| 220 | + traceback.print_exc() |
| 221 | + print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr) |
| 222 | + sys.exit(1) |
0 commit comments