Examples Gallery
GitHub

sky-texture-sun-elevation

World ShaderNodeTexSky driving Background Color — the sky contract across 4.5 LTS and 5.1. sky_type is NISHITA on 4.5 and MULTIPLE_SCATTERING on 5.1 (NISHITA gone); dust_density exists only on 4.5 (aerosol_density on 5.1). Two tiny Cycles OPEN_EXR zenith probes prove sun_elevation 8 deg to 55 deg brightens zenith (rise 2.25x on 5.1.2, 1.50x on 4.5.11, gate >= 1.25).

Rendered headless by the example itself — click to zoom.

witnesses AI code still assigns sky_type='NISHITA' and dust_density on 5.1 — both are wrong (NISHITA removed; dust_density is AttributeError; use MULTIPLE_SCATTERING + aerosol_density). A near-black Background Strength alone is not a sky: Sky must link into Background Color. Elevation that does not brighten zenith fails the rise gate.
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.

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

examples/sky-texture-sun-elevation/sky_texture_sun_elevation.py View on GitHub →
"""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)