Rendered headless by the example itself — click to zoom.
blender --background --python examples/bake-normal-high-to-low/bake_normal_high_to_low.py --
A runnable example that cage-bakes a ribbed bronze hatch plate onto a DECIMATE COLLAPSE LOD and asserts the tangent-space normal map carries measurable surface detail — following bake-high-to-low.
What it witnesses: Cycles selected-to-active normal bake is a statistical process, not a byte-identical one. A high-poly source produces a map that deviates from flat tangent (0.5, 0.5, 1.0); the same bake from an undisplaced source does not.
Byte-identity across 4.5 / 5.1 / 5.2 is not the contract. Tile order and float accumulation differ even at one CPU sample. The gates are fraction of pixels beyond Euclidean 0.04 from flat, mean absolute deviation, and a monotonic gap versus a flat control. Tolerances sit well inside the measured gap (detail frac 0.7211 vs flat 0.0000) so they are not tuned-until-green.
- Detail bake is not flat.
frac >= 0.40andMAD >= 0.05(measured 0.7211 / 0.09356 on 4.5.11, 5.1.2, and 5.2.1). Catches an inactive Image Texture node, reversed selection, or EEVEE/GPU mis-setup that writes a blank map. MAD floor is half the measured hatch value, still ~30× a flat bake. - Flat control is flat.
frac <= 0.05andMAD <= 0.03(measured 0.0000 / 0.00277). Catches a noisy or wrongly-typed bake that would also satisfy the detail gates. - Monotonic gap.
detail_frac - flat_frac >= 0.30. The two maps must separate; a tolerance wide enough to pass both would have no discriminating power. --flat-sourceis the falsifier. Skips the ribs and still runs the detail gates. Must exit 5. Analogous to--same-axisinexport-preset-axis.
Neighbor of lod-decimate-chain (the LOD is the cage target; collapse keeps UVs) and image-pixels-testcard (save_render, not Image.save(), if you persist the datablock). UV transfer and atlas packing are out of scope.
The still stages the baked map as an unlit card beside the LOD wearing it. If the bake were flat, the card would be uniform (128, 128, 255) periwinkle and the plate would shade like the undisplaced cage.
Operator RNA (type='NORMAL', use_selected_to_active, cage_extrusion, cage_object as a string, normal_space='TANGENT', margin_type) is identical on 4.5.11, 5.1.2, and 5.2.1 — no shim.
Run
# Cheap correctness check (no render) - the CI check:
blender --background --python bake_normal_high_to_low.py --
# Falsifier: undisplaced high. Must exit non-zero (detail frac gate).
blender --background --python bake_normal_high_to_low.py -- --flat-source
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python bake_normal_high_to_low.py -- --output hatch.png
blender --background --python bake_normal_high_to_low.py -- --output hatch.png --engine cycles
Exit codes
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Missing UV layer on the target | | 4 | Bake did not FINISHED or image has_data is false | | 5 | Detail deviant-pixel fraction below 0.40 (--flat-source lands here) | | 6 | Detail MAD below 0.05 | | 7 | Flat control above 0.05 frac / 0.03 MAD | | 8 | Monotonic gap below 0.30 | | 9 | --output produced no file | | 10 | Gallery framing violation |
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output.
Source
"""High-to-low tangent normal bake — a runnable example. Witnesses transferring high-poly surface detail onto a collapse-decimated LOD via Cycles cage bake. Pixel buffers are stochastic: this example does **not** assert byte-identity across 4.5 / 5.1 / 5.2. The contract is statistical. 1. A bake from a ribbed hatch plate produces a map whose pixels deviate from flat tangent-space ``(0.5, 0.5, 1.0)`` above a stated fraction and MAD. 2. The same bake from an undisplaced source does not. 3. ``--flat-source`` skips the ribs and rivets and still runs the *detail* gates, so the assertion fails. That is the falsifier (``--same-axis`` in export-preset-axis). Operator RNA is ``type='NORMAL'``, not ``bake_type``. Identifiers match on 4.5.11, 5.1.2, and 5.2.1 — no shim. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python bake_normal_high_to_low.py -- blender --background --python bake_normal_high_to_low.py -- --flat-source blender --background --python bake_normal_high_to_low.py -- --output p.png """ import argparse import math import os import sys import bmesh import bpy sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) sys.dont_write_bytecode = True import gallery_framing BAKE_RES = 256 CAGE_EXTRUSION = 0.20 MARGIN = 16 THRESH = 0.04 DETAIL_FRAC_MIN = 0.40 DETAIL_MAD_MIN = 0.05 FLAT_FRAC_MAX = 0.05 FLAT_MAD_MAX = 0.03 MONO_FRAC_GAP = 0.30 GRID_SEGS = 40 HATCH_SIZE = 1.15 THICKNESS = 0.12 RIB_AMP = 0.10 RIVET_AMP = 0.05 TARGET_TRIS = 900 FLAT_RGB = (0.5, 0.5, 1.0) def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) return code def duplicate_object(obj, name): dup = obj.copy() dup.data = obj.data.copy() dup.name = name bpy.context.scene.collection.objects.link(dup) return dup def make_hatch(name): bm = bmesh.new() try: bmesh.ops.create_grid( bm, x_segments=GRID_SEGS, y_segments=GRID_SEGS, size=HATCH_SIZE ) uv = bm.loops.layers.uv.new("UVMap") span = 2.0 * HATCH_SIZE for face in bm.faces: face.smooth = True for loop in face.loops: loop[uv].uv = ( (loop.vert.co.x + HATCH_SIZE) / span, (loop.vert.co.y + HATCH_SIZE) / span, ) me = bpy.data.meshes.new(name) bm.to_mesh(me) finally: bm.free() obj = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(obj) return obj def displace_hatch(obj): me = obj.data n = len(me.vertices) buf = [0.0] * (n * 3) me.vertices.foreach_get("co", buf) for i in range(n): x, y, z = buf[i * 3], buf[i * 3 + 1], buf[i * 3 + 2] rad = math.sqrt(x * x + y * y) theta = math.atan2(y, x) falloff = max(0.0, 1.0 - rad / HATCH_SIZE) rib = RIB_AMP * math.cos(6.0 * theta) * falloff rivet = 0.0 for k in range(8): ang = k * math.pi / 4.0 px = 0.70 * HATCH_SIZE * math.cos(ang) py = 0.70 * HATCH_SIZE * math.sin(ang) d2 = (x - px) ** 2 + (y - py) ** 2 rivet += RIVET_AMP * math.exp(-d2 / 0.010) rim = 0.025 * math.exp(-((rad - 0.92 * HATCH_SIZE) ** 2) / 0.008) buf[i * 3 + 2] = z + rib + rivet + rim me.vertices.foreach_set("co", buf) me.update() def evaluated_triangle_count(obj): depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) eval_mesh = eval_obj.to_mesh() try: eval_mesh.calc_loop_triangles() return len(eval_mesh.loop_triangles) finally: eval_obj.to_mesh_clear() def decimate_apply(obj, target_tris): current = evaluated_triangle_count(obj) if current == 0 or current <= target_tris: return current, current ratio = min(1.0, target_tris / current) mod = obj.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" mod.ratio = ratio bpy.context.view_layer.objects.active = obj obj.select_set(True) with bpy.context.temp_override( object=obj, active_object=obj, selected_objects=[obj] ): bpy.ops.object.modifier_apply(modifier=mod.name) for poly in obj.data.polygons: poly.use_smooth = True obj.data.update() return current, evaluated_triangle_count(obj) def setup_bake_target(obj, name, size=BAKE_RES): if not obj.data.uv_layers: return None, None, None img = bpy.data.images.new(name, size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" mat = bpy.data.materials.new(name + "Mat") mat.use_nodes = True nodes = mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex tex.select = True if obj.data.materials: obj.data.materials[0] = mat else: obj.data.materials.append(mat) return img, mat, tex def configure_cycles_cpu(): scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False def isolate_select(high, low): for ob in bpy.context.view_layer.objects: ob.select_set(False) high.select_set(True) low.select_set(True) bpy.context.view_layer.objects.active = low def bake_normal(high, low): configure_cycles_cpu() isolate_select(high, low) return bpy.ops.object.bake( type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, use_cage=False, normal_space="TANGENT", margin=MARGIN, margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", ) def map_stats(image, thresh=THRESH): width, height = image.size n = width * height buf = [0.0] * (n * 4) image.pixels.foreach_get(buf) deviant = 0 mad_acc = 0.0 fr, fg, fb = FLAT_RGB for i in range(n): r, g, b = buf[i * 4], buf[i * 4 + 1], buf[i * 4 + 2] d = math.sqrt((r - fr) ** 2 + (g - fg) ** 2 + (b - fb) ** 2) mad_acc += d if d > thresh: deviant += 1 return deviant / n, mad_acc / n def paint_principled(mat, color, metallic, roughness): bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED") bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness return bsdf def wire_normal_map(mat, tex): nodes = mat.node_tree.nodes links = mat.node_tree.links bsdf = paint_principled(mat, (0.22, 0.13, 0.07, 1.0), 0.58, 0.44) nrm = nodes.new("ShaderNodeNormalMap") nrm.space = "TANGENT" links.new(tex.outputs["Color"], nrm.inputs["Color"]) links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) def new_image(name, size=BAKE_RES): img = bpy.data.images.new(name, size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" return img def check(flat_source): base = make_hatch("BakeBase") if not base.data.uv_layers: return fail("base mesh has no UV layer", 3), None, None, None, None low = duplicate_object(base, "BakeLow") before, after = decimate_apply(low, TARGET_TRIS) print(f"lod_tris before={before} after={after} target={TARGET_TRIS}") if not low.data.uv_layers: return fail("decimated LOD lost its UV layer", 3), None, None, None, None high_detail = duplicate_object(base, "BakeHigh") if not flat_source: displace_hatch(high_detail) img_detail, mat, tex = setup_bake_target(low, "BakeNrmDetail") if img_detail is None: return fail("low mesh has no UV layer", 3), None, None, None, None result = bake_normal(high_detail, low) if result != {"FINISHED"}: return fail(f"detail bake returned {result}", 4), None, None, None, None if not img_detail.has_data: return fail("detail bake image has_data is False", 4), None, None, None, None detail_frac, detail_mad = map_stats(img_detail) src_label = "flat-source" if flat_source else "detail" print( f"bake_stats source={src_label} frac={detail_frac:.4f} mad={detail_mad:.5f} " f"thresh={THRESH} flat_rgb={FLAT_RGB}" ) if detail_frac < DETAIL_FRAC_MIN: return ( fail( f"detail frac {detail_frac:.4f} < {DETAIL_FRAC_MIN} " "(map is too close to flat tangent; --flat-source is the " "designed fail for this gate)", 5, ), None, None, None, None, ) if detail_mad < DETAIL_MAD_MIN: return ( fail( f"detail MAD {detail_mad:.5f} < {DETAIL_MAD_MIN}", 6, ), None, None, None, None, ) if flat_source: return 0, high_detail, low, img_detail, mat img_flat = new_image("BakeNrmFlat") tex.image = img_flat mat.node_tree.nodes.active = tex result = bake_normal(base, low) if result != {"FINISHED"}: return fail(f"flat bake returned {result}", 4), None, None, None, None if not img_flat.has_data: return fail("flat bake image has_data is False", 4), None, None, None, None flat_frac, flat_mad = map_stats(img_flat) print( f"bake_stats source=flat frac={flat_frac:.4f} mad={flat_mad:.5f} " f"thresh={THRESH}" ) if flat_frac > FLAT_FRAC_MAX or flat_mad > FLAT_MAD_MAX: return ( fail( f"flat control not flat frac={flat_frac:.4f} " f"(max {FLAT_FRAC_MAX}) mad={flat_mad:.5f} (max {FLAT_MAD_MAX})", 7, ), None, None, None, None, ) if detail_frac - flat_frac < MONO_FRAC_GAP: return ( fail( f"monotonic gap {detail_frac - flat_frac:.4f} < {MONO_FRAC_GAP} " f"(detail={detail_frac:.4f} flat={flat_frac:.4f})", 8, ), None, None, None, None, ) tex.image = img_detail mat.node_tree.nodes.active = tex return 0, high_detail, low, img_detail, mat def make_map_card(image, name="BakeCard"): me = bpy.data.meshes.new(name) bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1.05) for vert in bm.verts: vert.co.x, vert.co.y, vert.co.z = vert.co.x, 0.0, vert.co.y uv = bm.loops.layers.uv.new("UVMap") for face in bm.faces: for loop in face.loops: loop[uv].uv = ( (loop.vert.co.x + 1.05) / 2.10, (loop.vert.co.z + 1.05) / 2.10, ) bm.to_mesh(me) finally: bm.free() mat = bpy.data.materials.new(name + "Mat") mat.use_nodes = True nodes = mat.node_tree.nodes links = mat.node_tree.links nodes.clear() tex = nodes.new("ShaderNodeTexImage") tex.image = image emit = nodes.new("ShaderNodeEmission") emit.inputs["Strength"].default_value = 1.0 out = nodes.new("ShaderNodeOutputMaterial") links.new(tex.outputs["Color"], emit.inputs["Color"]) links.new(emit.outputs["Emission"], out.inputs["Surface"]) me.materials.append(mat) ob = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(ob) return ob def render_still(low, mat, tex, path, engine): scene = bpy.context.scene for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True wire_normal_map(mat, tex) solid = low.modifiers.new("SolidifyDisplay", "SOLIDIFY") solid.thickness = THICKNESS solid.offset = 1.0 low.rotation_euler.x = math.radians(72.0) low.location = (1.20, 0.0, 1.05) card = make_map_card(tex.image) card.location = (-1.50, 0.0, 1.05) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=12.0) bm.to_mesh(floor_me) finally: bm.free() fmat = bpy.data.materials.new("Floor") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = ( 0.02, 0.021, 0.025, 1.0, ) scene.world = world 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.0, -5.0, 6.0), 600.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -38)) light("Fill", (5.0, -4.0, 3.0), 110.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50)) light("Rim", (0.5, 4.5, 5.0), 350.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 175)) light("Wedge", (2.5, 3.5, 4.2), 480.0, 6.0, (1.0, 0.76, 0.5), (-72, 0, 195)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (0.0, -8.4, 3.15) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, 1.05) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" con.up_axis = "UP_Y" scene.camera = cam scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": scene.cycles.samples = 32 scene.cycles.device = "CPU" else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = "PNG" scene.render.filepath = path scene.view_settings.view_transform = "Standard" fcode = gallery_framing.check_framing( scene, cam, hero=[card, low], elements=[card, low], stage=[floor, wall], ) if fcode: return fcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): return fail("render produced no file", 9) return 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 PNG here") p.add_argument( "--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)", ) p.add_argument( "--flat-source", action="store_true", help="bake from undisplaced high; detail gates must fail", ) args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) code, high, low, img, mat = check(args.flat_source) if code: return code if args.output: tex = next( n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image == img ) rcode = render_still(low, mat, tex, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("bake-normal-high-to-low 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)