Rendered headless by the example itself — click to zoom.
blender --background --python examples/sky-texture-sun-elevation/sky_texture_sun_elevation.py --
A runnable example that builds a World whose Background Color is driven by ShaderNodeTexSky, then proves sun_elevation is load-bearing with two tiny Cycles EXR zenith probes — and stages a dual-elevation gallery diptych so the contract is visible at thumbnail scale.
What it witnesses: the Sky Texture contract AI-generated lighting code most often gets wrong across the 4.5 LTS → 5.1 window.
- Sky must drive Background Color. A near-black Background Strength alone is not a sky — the check asserts the Sky → Background → World Output links.
sky_typerenamed. 4.5 LTS usesNISHITA; 5.1 replaced it withMULTIPLE_SCATTERING(Nishita's successor). AssigningNISHITAon 5.1 fails; the enum no longer lists it.dust_density→aerosol_density. 4.5 exposesdust_density; 5.1 raisesAttributeErrorand shipsaerosol_densityinstead. AI code still emitsdust_density.sun_elevationbrightens zenith. Two CPU Cycles OPEN_EXR probes with a straight-up camera, Background Strength 0.05 (unclipped), compare zenith luminance at 8° vs 55°. Measured rise 2.25x on 5.1.2 and 1.50x on 4.5.11 LTS (gate ≥ 1.25).
What each check catches on failure: broken Sky→Background link (exit 6), wrong sky_type for the running version (exit 3), lost sun_elevation round-trip (exit 4), dust_density / NISHITA present on the wrong side of 5.0 (exit 5), a non-working sky (zenith floor, exit 7), and an elevation that does not brighten zenith (exit 8 — rise below 1.25).
Version witness: sky_type and the dust/aerosol rename are the divergence; sun_elevation itself is stable. Zenith rise differs by model (Nishita vs multiple scattering) but clears the same gate on both binaries.
Stage deviation
World carries a Nishita / multiple-scattering Sky Texture instead of the default near-black Background — the contract *is* the sky. Gallery still is a dual-elevation diptych (8° | 55°) so failure (identical panels) is visible at thumbnail scale. Layer 1 still holds: view_transform='Standard', designed terracotta materials, chosen camera, no helpers in frame.
Framing deviation
The hero is the sky itself — a world contract, not an object on the stage. The silhouette matte can only measure the cone reference props (0.369x / 0.489y), so the out-of-band number is a measurement artifact, not the composition; if ever wired to examples/gallery_framing.py, this example reports with deviation="world-contract subject; matte sees only the cone props" rather than enforcing.
Run
# Zenith-luminance correctness check (tiny Cycles CPU EXR probes) — the CI check:
blender --background --python sky_texture_sun_elevation.py --
# Also render the gallery diptych (Cycles):
blender --background --python sky_texture_sun_elevation.py -- --output sky.png
It exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
Source
"""Nishita / multiple-scattering sky + sun_elevation — a runnable example. Witnesses the world Sky Texture contract AI-generated lighting code most often gets wrong across the 4.5 LTS → 5.1 window: 1. ``ShaderNodeTexSky`` must drive the World Background Color. A near-black Background Strength alone is not a sky. 2. ``sky_type`` renamed: ``NISHITA`` on 4.5 LTS, ``MULTIPLE_SCATTERING`` (Nishita's successor) on 5.1 — assigning the legacy identifier on 5.1 fails. 3. ``dust_density`` exists only on 4.5; 5.1 raises AttributeError and exposes ``aerosol_density`` instead (AI still emits ``dust_density``). 4. ``sun_elevation`` is load-bearing: raising it brightens zenith luminance — proven with two tiny Cycles EXR probes (straight-up camera) in one check. By default it runs the correctness check (tiny Cycles CPU renders, no gallery still). Pass --output to also render a still: blender --background --python sky_texture_sun_elevation.py -- blender --background --python sky_texture_sun_elevation.py -- --output s.png """ import bpy, bmesh, sys, os, math, argparse, tempfile, shutil # Radians: low sun vs high sun for the A/B zenith luminance probe ELEV_LOW = math.radians(8.0) ELEV_HIGH = math.radians(55.0) # Tiny probe resolution / samples — deterministic CPU Cycles PXW, PXH = 48, 48 CYCLES_SAMPLES = 24 # Background Strength kept low so the probe stays unclipped under Standard PROBE_STRENGTH = 0.05 # Zenith luminance (EXR, straight-up camera) must rise with sun_elevation ZENITH_RISE_MIN = 1.25 # Absolute floor so a black / unlinked world cannot sneak through HIGH_ZENITH_MIN = 0.05 def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def sky_type_for_version(): """4.5: NISHITA. 5.1: MULTIPLE_SCATTERING (Nishita successor; NISHITA gone).""" if bpy.app.version >= (5, 0, 0): return "MULTIPLE_SCATTERING" return "NISHITA" def build_sky_world(elevation): """Create a World whose Background Color is driven by ShaderNodeTexSky.""" world = bpy.data.worlds.new("SkyWorld") world.use_nodes = True nt = world.node_tree for n in list(nt.nodes): nt.nodes.remove(n) sky = nt.nodes.new("ShaderNodeTexSky") sky.name = "Sky" sky.location = (-300, 0) sky.sky_type = sky_type_for_version() sky.sun_elevation = elevation sky.sun_rotation = math.radians(-35.0) sky.sun_disc = True sky.sun_intensity = 1.0 if bpy.app.version >= (5, 0, 0): sky.aerosol_density = 1.0 else: sky.dust_density = 1.0 sky.air_density = 1.0 sky.ozone_density = 1.0 bg = nt.nodes.new("ShaderNodeBackground") bg.name = "Background" bg.location = (0, 0) # Gallery still restores Strength=1.0 in render_still; probe uses PROBE_STRENGTH bg.inputs["Strength"].default_value = 1.0 out = nt.nodes.new("ShaderNodeOutputWorld") out.name = "World Output" out.location = (250, 0) nt.links.new(sky.outputs["Color"], bg.inputs["Color"]) nt.links.new(bg.outputs["Background"], out.inputs["Surface"]) return world, sky, bg def _principled(name, rgb, rough, metallic=0.0, specular=0.5): mat = bpy.data.materials.new(name) mat.use_nodes = True b = mat.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = (*rgb, 1.0) b.inputs["Roughness"].default_value = rough b.inputs["Metallic"].default_value = metallic # Specular IOR Level renamed across versions — set if present spec = b.inputs.get("Specular IOR Level") or b.inputs.get("Specular") if spec is not None and hasattr(spec, "default_value"): try: spec.default_value = specular except TypeError: pass return mat def _mesh_obj(sc, name, build_bm, loc, mat, smooth=True): me = bpy.data.meshes.new(name) bm = bmesh.new() try: build_bm(bm) bm.to_mesh(me) finally: bm.free() if smooth: for p in me.polygons: p.use_smooth = True me.materials.append(mat) ob = bpy.data.objects.new(name, me) ob.location = loc sc.collection.objects.link(ob) return ob def build_ceramic(sc): """A turned terracotta jar — saturated clay + pale glaze lid.""" clay = _principled("Clay", (0.78, 0.22, 0.08), rough=0.52, metallic=0.0, specular=0.2) glaze = _principled("Glaze", (0.55, 0.18, 0.06), rough=0.28, metallic=0.0, specular=0.4) def body(bm): bmesh.ops.create_cone( bm, cap_ends=True, cap_tris=False, segments=64, radius1=0.62, radius2=0.42, depth=1.35, ) bmesh.ops.translate(bm, verts=bm.verts, vec=(0.0, 0.0, 0.68)) vessel = _mesh_obj(sc, "Vessel", body, (0.0, 0.0, 0.0), clay) bev = vessel.modifiers.new("Bev", "BEVEL") bev.width = 0.025 bev.segments = 3 bev.limit_method = "ANGLE" def lid(bm): bmesh.ops.create_cone( bm, cap_ends=True, cap_tris=False, segments=48, radius1=0.40, radius2=0.10, depth=0.32, ) knob = _mesh_obj(sc, "Lid", lid, (0.0, 0.0, 1.48), glaze) return vessel, knob def build_floor(sc): mat = _principled("Floor", (0.025, 0.026, 0.030), rough=0.85, specular=0.05) def grid(bm): bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) return _mesh_obj(sc, "Floor", grid, (0.0, 0.0, 0.0), mat, smooth=False) def setup_probe_camera(sc): """Camera looking straight up (+Z) so the frame is pure zenith sky.""" cam_data = bpy.data.cameras.new("ProbeCam") cam_data.lens = 24.0 cam = bpy.data.objects.new("ProbeCam", cam_data) cam.location = (0.0, 0.0, 0.0) # Local -Z → world +Z cam.rotation_euler = (math.radians(-90.0), 0.0, 0.0) sc.collection.objects.link(cam) sc.camera = cam return cam def setup_probe_render(sc, w, h, samples): sc.render.engine = "CYCLES" sc.cycles.device = "CPU" sc.cycles.samples = samples sc.cycles.use_denoising = False sc.render.resolution_x = w sc.render.resolution_y = h sc.render.resolution_percentage = 100 # EXR keeps linear values; PNG+Standard clips bright sky toward 1.0 sc.render.image_settings.file_format = "OPEN_EXR" sc.render.image_settings.color_depth = "32" sc.render.film_transparent = False sc.view_settings.view_transform = "Standard" def mean_center_luma(img, half=2): w, h = img.size px = img.pixels acc = 0.0 n = 0 for y in range(h // 2 - half, h // 2 + half + 1): for x in range(w // 2 - half, w // 2 + half + 1): i = (y * w + x) * 4 acc += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] n += 1 return acc / max(n, 1) def render_zenith_luma(sc, world, sky, elevation, tmp, name): sky.sun_elevation = elevation sky.sun_disc = False # disc would dominate the center sample sc.world = world path = os.path.join(tmp, name) sc.render.filepath = path bpy.ops.render.render(write_still=True) img = bpy.data.images.load(path) luma = mean_center_luma(img) bpy.data.images.remove(img) return luma def check_api(sky): """RNA surface + version traps.""" expected = sky_type_for_version() if sky.sky_type != expected: print( f"ERROR: sky_type {sky.sky_type!r} != expected {expected!r}", file=sys.stderr, ) return 3 sky.sun_elevation = ELEV_HIGH if abs(sky.sun_elevation - ELEV_HIGH) > 1e-5: print( f"ERROR: sun_elevation round-trip {sky.sun_elevation} != {ELEV_HIGH}", file=sys.stderr, ) return 4 if bpy.app.version >= (5, 0, 0): if hasattr(sky, "dust_density"): print( "ERROR: dust_density still present on 5.x — expected AttributeError trap", file=sys.stderr, ) return 5 try: _ = sky.dust_density print("ERROR: dust_density read did not raise on 5.x", file=sys.stderr) return 5 except AttributeError: pass if not hasattr(sky, "aerosol_density"): print("ERROR: aerosol_density missing on 5.x", file=sys.stderr) return 5 ids = [i.identifier for i in sky.bl_rna.properties["sky_type"].enum_items] if "NISHITA" in ids: print("ERROR: legacy NISHITA still in 5.x sky_type enum", file=sys.stderr) return 5 if "MULTIPLE_SCATTERING" not in ids: print( f"ERROR: MULTIPLE_SCATTERING missing from 5.x enum {ids}", file=sys.stderr, ) return 5 print( f"5.x contract: sky_type={sky.sky_type} aerosol_density={sky.aerosol_density} " f"dust_density=AttributeError NISHITA=gone" ) else: if not hasattr(sky, "dust_density"): print("ERROR: dust_density missing on 4.5", file=sys.stderr) return 5 ids = [i.identifier for i in sky.bl_rna.properties["sky_type"].enum_items] if "NISHITA" not in ids: print(f"ERROR: NISHITA missing from 4.5 sky_type enum {ids}", file=sys.stderr) return 5 if "MULTIPLE_SCATTERING" in ids: print( "ERROR: MULTIPLE_SCATTERING unexpectedly present on 4.5", file=sys.stderr, ) return 5 print( f"4.5 contract: sky_type={sky.sky_type} dust_density={sky.dust_density} " f"aerosol_density={hasattr(sky, 'aerosol_density')}" ) return 0 def check_links(world): nt = world.node_tree sky = nt.nodes.get("Sky") bg = nt.nodes.get("Background") out = nt.nodes.get("World Output") if sky is None or bg is None or out is None: print("ERROR: Sky / Background / World Output nodes missing", file=sys.stderr) return 6 ok_sky = any( l.from_node == sky and l.from_socket.name == "Color" and l.to_node == bg and l.to_socket.name == "Color" for l in nt.links ) ok_bg = any( l.from_node == bg and l.to_node == out and l.to_socket.name == "Surface" for l in nt.links ) if not ok_sky or not ok_bg: print( f"ERROR: world links broken (sky→bg={ok_sky}, bg→out={ok_bg})", file=sys.stderr, ) return 6 return 0 def check(sc, world, sky, bg): code = check_links(world) if code: return code code = check_api(sky) if code: return code for ob in sc.objects: if ob.type == "MESH": ob.hide_render = True setup_probe_camera(sc) setup_probe_render(sc, PXW, PXH, CYCLES_SAMPLES) bg.inputs["Strength"].default_value = PROBE_STRENGTH tmp = tempfile.mkdtemp(prefix="sky_elev_") try: z_lo = render_zenith_luma(sc, world, sky, ELEV_LOW, tmp, "low.exr") z_hi = render_zenith_luma(sc, world, sky, ELEV_HIGH, tmp, "high.exr") finally: shutil.rmtree(tmp, ignore_errors=True) for ob in sc.objects: if ob.type == "MESH": ob.hide_render = False bg.inputs["Strength"].default_value = 1.0 sky.sun_disc = True if z_hi < HIGH_ZENITH_MIN: print( f"ERROR: high-elevation zenith luma {z_hi:.4f} < {HIGH_ZENITH_MIN} " f"(world is not a working sky)", file=sys.stderr, ) return 7 rise = z_hi / max(z_lo, 1e-6) if rise < ZENITH_RISE_MIN: print( f"ERROR: zenith luma did not rise with elevation: " f"low={z_lo:.4f} high={z_hi:.4f} rise={rise:.4f} < {ZENITH_RISE_MIN}", file=sys.stderr, ) return 8 print( f"sun_elevation low={math.degrees(ELEV_LOW):.1f}deg zenith_L={z_lo:.4f}" ) print( f"sun_elevation high={math.degrees(ELEV_HIGH):.1f}deg zenith_L={z_hi:.4f}" ) print( f"zenith_rise={rise:.4f} (gate>={ZENITH_RISE_MIN}) " f"probe_strength={PROBE_STRENGTH} samples={CYCLES_SAMPLES} " f"sky_type={sky.sky_type} fmt=OPEN_EXR" ) return 0 def _stage_for_still(sc, world, sky, elevation): """Shared beauty staging; elevation is the variable under test.""" sky.sun_elevation = elevation sky.sun_rotation = math.radians(-45.0) sky.sun_intensity = 1.0 sky.sun_disc = False sky.ozone_density = 5.0 sky.air_density = 1.0 if bpy.app.version >= (5, 0, 0): sky.aerosol_density = 0.45 else: sky.dust_density = 0.45 bg = world.node_tree.nodes["Background"] bg.inputs["Strength"].default_value = 0.08 sc.world = world # Remove prior still lights / cams from earlier calls for ob in list(sc.objects): if ob.name.startswith(("Key", "Fill", "Rim", "Cam", "Aim", "ProbeCam", "Label")): bpy.data.objects.remove(ob, do_unlink=True) for cam in list(bpy.data.cameras): if cam.users == 0: bpy.data.cameras.remove(cam) for ld in list(bpy.data.lights): if ld.users == 0: bpy.data.lights.remove(ld) def area(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) sc.collection.objects.link(ob) area("Key", (-3.0, -3.5, 4.0), 420.0, 4.0, (1.0, 0.95, 0.88), (50, 0, -32)) area("Fill", (3.5, -2.2, 1.8), 55.0, 8.0, (0.7, 0.82, 1.0), (70, 0, 42)) area("Rim", (-0.5, 3.8, 2.5), 120.0, 3.5, (0.55, 0.72, 1.0), (-55, 0, 185)) aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, 0.8) sc.collection.objects.link(aim) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 40.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (2.15, -3.55, 1.2) sc.collection.objects.link(cam) sc.camera = cam tr = cam.constraints.new("TRACK_TO") tr.target = aim tr.track_axis = "TRACK_NEGATIVE_Z" tr.up_axis = "UP_Y" def _render_panel(sc, path, engine, samples=64): sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": sc.cycles.device = "CPU" sc.cycles.samples = samples sc.cycles.use_denoising = True else: try: sc.eevee.taa_render_samples = 64 except AttributeError: pass sc.render.resolution_x = 640 sc.render.resolution_y = 720 sc.render.image_settings.file_format = "PNG" sc.render.filepath = path sc.view_settings.view_transform = "Standard" bpy.ops.render.render(write_still=True) def _diptych(left_path, right_path, out_path): """Pack two 640x720 panels into a 1280x720 gallery still.""" left = bpy.data.images.load(left_path) right = bpy.data.images.load(right_path) w, h = 1280, 720 canvas = bpy.data.images.new("Diptych", width=w, height=h, alpha=False) lp = list(left.pixels) rp = list(right.pixels) out = [0.0] * (w * h * 4) pw = 640 for y in range(h): for x in range(pw): si = (y * pw + x) * 4 di_l = (y * w + x) * 4 di_r = (y * w + (x + pw)) * 4 out[di_l:di_l + 4] = lp[si:si + 4] out[di_r:di_r + 4] = rp[si:si + 4] canvas.pixels = out canvas.filepath_raw = out_path canvas.file_format = "PNG" canvas.save() bpy.data.images.remove(left) bpy.data.images.remove(right) bpy.data.images.remove(canvas) def render_still(sc, world, sky, path, engine): """Gallery still: low vs high sun_elevation diptych — the contract at a glance.""" tmp = tempfile.mkdtemp(prefix="sky_still_") try: left = os.path.join(tmp, "low.png") right = os.path.join(tmp, "high.png") _stage_for_still(sc, world, sky, ELEV_LOW) _render_panel(sc, left, engine, samples=72) _stage_for_still(sc, world, sky, ELEV_HIGH) _render_panel(sc, right, engine, samples=72) _diptych(left, right, path) finally: shutil.rmtree(tmp, ignore_errors=True) return os.path.exists(path) and os.path.getsize(path) > 0 def build_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene world, sky, bg = build_sky_world(ELEV_LOW) sc.world = world build_floor(sc) build_ceramic(sc) return sc, world, sky, bg 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="cycles", choices=("eevee", "cycles"), help="render engine for --output (cycles default: sky is a Cycles strength)", ) args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, world, sky, bg = build_scene() code = check(sc, world, sky, bg) if code: return code if args.output: if not render_still(sc, world, sky, os.path.abspath(args.output), args.engine): print("ERROR: render produced no file", file=sys.stderr) return 9 print(f"rendered still {args.output}") print("sky-texture-sun-elevation 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)