THE ORIGINAL PROCEDURAL PROGRAM
Where the image begins.
The exact scene source used by the frozen experiment. Blender 5.1.2 supplies mesh primitives, procedural texture nodes, materials and Cycles light transport. The numbered links let each equation point to its implementation.
001 """Procedural cinematic basalt sea inlet for the equation-art challenge. 002 003 This file is intentionally self-contained. It can be executed by Blender in 004 background mode to build, save, and render the scene, or imported into a long 005 running Blender Python process. Geometry and shader variation are generated 006 from fixed integer seeds; no external assets are needed. 007 """ 008 009 import math 010 import os 011 import random 012 import sys 013 from pathlib import Path 014 015 import bpy 016 import bmesh 017 from mathutils import Vector, noise as mnoise 018 019 020 DEFAULTS = { 021 "mist_density": 0.028, 022 "key_energy": 5.5, 023 "water_roughness": 0.14, 024 "camera_shift": 0.0, 025 } 026 BOUNDS = { 027 "mist_density": (0.012, 0.06), 028 "key_energy": (3.0, 8.0), 029 "water_roughness": (0.08, 0.25), 030 "camera_shift": (-0.6, 0.6), 031 } 032 033 034 def _clamp(name, value): 035 lo, hi = BOUNDS[name] 036 return max(lo, min(hi, float(value))) 037 038 039 def _set_input(node, names, value): 040 for name in names: 041 socket = node.inputs.get(name) 042 if socket is not None: 043 socket.default_value = value 044 return socket 045 return None 046 047 048 def _look_at(obj, target): 049 obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() 050 051 052 def _material_output(material): 053 return material.node_tree.nodes.get("Material Output") 054 055 056 def _make_rock_material(): 057 mat = bpy.data.materials.new("MAT_slate_wet_basalt") 058 mat.use_nodes = True 059 nodes = mat.node_tree.nodes 060 links = mat.node_tree.links 061 nodes.clear() 062 out = nodes.new("ShaderNodeOutputMaterial") 063 out.location = (680, 0) 064 bsdf = nodes.new("ShaderNodeBsdfPrincipled") 065 bsdf.location = (410, 0) 066 _set_input(bsdf, ["Metallic"], 0.0) 067 _set_input(bsdf, ["Roughness"], 0.38) 068 _set_input(bsdf, ["Coat Weight"], 0.12) 069 _set_input(bsdf, ["Coat Roughness"], 0.22) 070 _set_input(bsdf, ["IOR"], 1.46) 071 _set_input(bsdf, ["Specular IOR Level", "Specular"], 0.52) 072 noise = nodes.new("ShaderNodeTexNoise") 073 noise.location = (-600, 100) 074 noise.inputs["Scale"].default_value = 3.4 075 noise.inputs["Detail"].default_value = 9.0 076 noise.inputs["Roughness"].default_value = 0.72 077 noise.inputs["Distortion"].default_value = 0.16 078 noise.name = "Slate macro grain" 079 noise2 = nodes.new("ShaderNodeTexNoise") 080 noise2.location = (-600, -230) 081 noise2.inputs["Scale"].default_value = 145.0 082 noise2.inputs["Detail"].default_value = 4.0 083 noise2.inputs["Roughness"].default_value = 0.65 084 noise2.name = "Slate fine grain" 085 tex = nodes.new("ShaderNodeTexCoord") 086 tex.location = (-850, 70) 087 ramp = nodes.new("ShaderNodeValToRGB") 088 ramp.location = (-120, 130) 089 ramp.color_ramp.elements.remove(ramp.color_ramp.elements[1]) 090 e0 = ramp.color_ramp.elements[0] 091 e0.position = 0.18 092 e0.color = (0.012, 0.014, 0.017, 1) 093 e1 = ramp.color_ramp.elements.new(0.48) 094 e1.color = (0.045, 0.052, 0.057, 1) 095 e2 = ramp.color_ramp.elements.new(0.76) 096 e2.color = (0.15, 0.16, 0.16, 1) 097 e3 = ramp.color_ramp.elements.new(0.94) 098 e3.color = (0.29, 0.28, 0.25, 1) 099 bump_mix = nodes.new("ShaderNodeMixRGB") 100 bump_mix.blend_type = "MULTIPLY" 101 bump_mix.inputs[0].default_value = 0.72 102 bump_mix.location = (-120, -150) 103 bump = nodes.new("ShaderNodeBump") 104 bump.location = (175, -150) 105 bump.inputs["Strength"].default_value = 0.65 106 bump.inputs["Distance"].default_value = 0.038 107 links.new(tex.outputs["Generated"], noise.inputs["Vector"]) 108 links.new(tex.outputs["Generated"], noise2.inputs["Vector"]) 109 links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) 110 links.new(noise.outputs["Fac"], bump_mix.inputs[1]) 111 links.new(noise2.outputs["Fac"], bump_mix.inputs[2]) 112 links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) 113 links.new(bump_mix.outputs["Color"], bump.inputs["Height"]) 114 links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) 115 links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) 116 return mat 117 118 119 def _make_water_material(): 120 mat = bpy.data.materials.new("MAT_teal_inlet_water") 121 mat.use_nodes = True 122 mat["water_roughness"] = DEFAULTS["water_roughness"] 123 nodes = mat.node_tree.nodes 124 links = mat.node_tree.links 125 nodes.clear() 126 out = nodes.new("ShaderNodeOutputMaterial") 127 out.location = (680, 0) 128 bsdf = nodes.new("ShaderNodeBsdfPrincipled") 129 bsdf.location = (410, 0) 130 bsdf.name = "Water Principled" 131 _set_input(bsdf, ["Base Color"], (0.008, 0.027, 0.032, 1)) 132 _set_input(bsdf, ["Metallic"], 0.0) 133 _set_input(bsdf, ["Transmission Weight"], 0.25) 134 _set_input(bsdf, ["Roughness"], DEFAULTS["water_roughness"]) 135 _set_input(bsdf, ["IOR"], 1.333) 136 _set_input(bsdf, ["Specular IOR Level", "Specular"], 0.50) 137 tex = nodes.new("ShaderNodeTexCoord") 138 tex.location = (-980, 80) 139 mapping = nodes.new("ShaderNodeMapping") 140 mapping.location = (-800, 80) 141 # Object coordinates stay in physical water scale; the previous Generated 142 # mapping made the entire 64x116m inlet read as one soft plastic gradient. 143 mapping.inputs["Scale"].default_value = (0.22, 0.22, 1.0) 144 noise = nodes.new("ShaderNodeTexNoise") 145 noise.location = (-590, 160) 146 noise.inputs["Scale"].default_value = 3.6 147 noise.inputs["Detail"].default_value = 7.0 148 noise.inputs["Roughness"].default_value = 0.56 149 noise.inputs["Distortion"].default_value = 0.45 150 short = nodes.new("ShaderNodeTexNoise") 151 short.location = (-590, -110) 152 short.inputs["Scale"].default_value = 14.0 153 short.inputs["Detail"].default_value = 3.0 154 short.inputs["Roughness"].default_value = 0.55 155 wave_x = nodes.new("ShaderNodeTexWave") 156 wave_x.location = (-590, -340) 157 wave_x.wave_type = "BANDS" 158 wave_x.bands_direction = "X" 159 wave_x.inputs["Scale"].default_value = 4.2 160 wave_x.inputs["Distortion"].default_value = 6.0 161 wave_x.inputs["Detail"].default_value = 5.0 162 wave_y = nodes.new("ShaderNodeTexWave") 163 wave_y.location = (-380, -340) 164 wave_y.wave_type = "BANDS" 165 wave_y.bands_direction = "Y" 166 wave_y.inputs["Scale"].default_value = 2.7 167 wave_y.inputs["Distortion"].default_value = 4.0 168 wave_y.inputs["Detail"].default_value = 4.0 169 ramp = nodes.new("ShaderNodeValToRGB") 170 ramp.location = (-80, 135) 171 ramp.color_ramp.elements[0].position = 0.30 172 ramp.color_ramp.elements[1].position = 0.68 173 ramp.color_ramp.elements[0].color = (0.003, 0.032, 0.050, 1) 174 ramp.color_ramp.elements[1].color = (0.045, 0.25, 0.28, 1) 175 ripple_ramp = nodes.new("ShaderNodeValToRGB") 176 ripple_ramp.location = (-90, -330) 177 ripple_ramp.color_ramp.elements[0].color = (0.004, 0.028, 0.042, 1) 178 ripple_ramp.color_ramp.elements[1].color = (0.025, 0.18, 0.20, 1) 179 color_mix = nodes.new("ShaderNodeMixRGB") 180 color_mix.blend_type = "ADD" 181 color_mix.inputs[0].default_value = 0.22 182 color_mix.location = (170, 80) 183 bump_mix = nodes.new("ShaderNodeMixRGB") 184 bump_mix.blend_type = "MULTIPLY" 185 bump_mix.inputs[0].default_value = 0.62 186 bump_mix.location = (100, -120) 187 bump = nodes.new("ShaderNodeBump") 188 bump.location = (250, -100) 189 bump.inputs["Strength"].default_value = 0.32 190 bump.inputs["Distance"].default_value = 0.085 191 links.new(tex.outputs["Object"], mapping.inputs["Vector"]) 192 links.new(mapping.outputs["Vector"], noise.inputs["Vector"]) 193 links.new(mapping.outputs["Vector"], short.inputs["Vector"]) 194 links.new(mapping.outputs["Vector"], wave_x.inputs["Vector"]) 195 links.new(mapping.outputs["Vector"], wave_y.inputs["Vector"]) 196 links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) 197 links.new(wave_x.outputs["Color"], bump_mix.inputs[1]) 198 links.new(wave_y.outputs["Color"], bump_mix.inputs[2]) 199 links.new(wave_x.outputs["Fac"], ripple_ramp.inputs["Fac"]) 200 links.new(ramp.outputs["Color"], color_mix.inputs[1]) 201 links.new(ripple_ramp.outputs["Color"], color_mix.inputs[2]) 202 links.new(bump_mix.outputs["Color"], bump.inputs["Height"]) 203 links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) 204 links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) 205 return mat 206 207 208 def _make_sand_material(): 209 mat = bpy.data.materials.new("MAT_wet_shore_silt") 210 mat.use_nodes = True 211 nodes = mat.node_tree.nodes 212 bsdf = nodes.get("Principled BSDF") 213 _set_input(bsdf, ["Base Color"], (0.08, 0.105, 0.10, 1)) 214 _set_input(bsdf, ["Roughness"], 0.34) 215 _set_input(bsdf, ["Specular IOR Level", "Specular"], 0.3) 216 return mat 217 218 219 def _make_volume_material(density): 220 mat = bpy.data.materials.new("MAT_mist_volume") 221 mat.use_nodes = True 222 nodes = mat.node_tree.nodes 223 links = mat.node_tree.links 224 nodes.clear() 225 out = nodes.new("ShaderNodeOutputMaterial") 226 vol = nodes.new("ShaderNodeVolumePrincipled") 227 vol.inputs["Color"].default_value = (0.80, 0.87, 0.94, 1) 228 _set_input(vol, ["Anisotropy"], 0.35) 229 tex = nodes.new("ShaderNodeTexCoord") 230 tex.location = (-720, -80) 231 sep = nodes.new("ShaderNodeSeparateXYZ") 232 sep.location = (-520, -80) 233 height = nodes.new("ShaderNodeMapRange") 234 height.location = (-320, -80) 235 height.inputs["From Min"].default_value = 0.0 236 height.inputs["From Max"].default_value = 1.0 237 height.inputs["To Min"].default_value = 0.65 238 height.inputs["To Max"].default_value = 0.03 239 height.clamp = True 240 base = nodes.new("ShaderNodeValue") 241 base.name = "Mist Density" 242 base.label = "Mist Density control" 243 base.outputs[0].default_value = density 244 mult = nodes.new("ShaderNodeMath") 245 mult.operation = "MULTIPLY" 246 mult.location = (-40, -40) 247 links.new(tex.outputs["Generated"], sep.inputs[0]) 248 links.new(sep.outputs["Z"], height.inputs["Value"]) 249 links.new(base.outputs[0], mult.inputs[0]) 250 links.new(height.outputs["Result"], mult.inputs[1]) 251 links.new(mult.outputs[0], vol.inputs["Density"]) 252 links.new(vol.outputs["Volume"], out.inputs["Volume"]) 253 return mat 254 255 256 def _rock_mesh(name, scale=(1, 1, 1), segments=18, rings=8, seed=1, peak=False): 257 """Eroded rock: broad asymmetric shape plus deterministic fractal relief.""" 258 rng = random.Random(seed) 259 mesh = bpy.data.meshes.new(name + "_mesh") 260 bm = bmesh.new() 261 bmesh.ops.create_icosphere(bm, subdivisions=5 if max(scale) < 3 else 6, radius=1.0) 262 bm.to_mesh(mesh) 263 bm.free() 264 phase = rng.random() * math.tau 265 sx, sy, sz = scale 266 for vert in mesh.vertices: 267 p = vert.co.normalized() 268 low = 0.12 * math.sin(2.7 * p.x + phase) + 0.10 * math.cos(3.4 * p.y - phase) 269 q = p * 3.2 + Vector((phase, phase * 1.7, -phase)) 270 coarse = mnoise.noise(q, noise_basis='PERLIN_ORIGINAL') 271 medium = mnoise.noise(q * 3.8, noise_basis='PERLIN_ORIGINAL') 272 fine = mnoise.noise(q * 15.1, noise_basis='PERLIN_ORIGINAL') 273 relief = 0.12 * coarse + 0.035 * medium + 0.012 * fine 274 radius = 1.0 + low + relief 275 x = p.x * radius 276 y = p.y * radius 277 z = p.z * (1.0 + relief + 0.13 * math.sin(3.0 * p.x - 2.0 * p.y + phase)) 278 if z > 0.56: 279 z = 0.56 + (z - 0.56) * 0.52 280 if z < -0.70: 281 z = -0.70 + (z + 0.70) * 0.45 282 vert.co = (x * sx, y * sy, z * sz) 283 mesh.update() 284 return bpy.data.objects.new(name, mesh) 285 286 287 def _add_rock(collection, mat, name, location, scale, seed, peak=False, rotation=0.0): 288 obj = _rock_mesh(name, scale, segments=22 if scale[2] < 3 else 18, rings=9, seed=seed, peak=peak) 289 collection.objects.link(obj) 290 obj.location = location 291 obj.rotation_euler[2] = rotation 292 obj.data.materials.append(mat) 293 for p in obj.data.polygons: 294 p.use_smooth = True 295 return obj 296 297 298 def _add_shoreline(collection, mat, side, seed): 299 rng = random.Random(seed) 300 # A low irregular shelf at either edge keeps the water framed right down to 301 # the lens without making the scene read as a dry valley. 302 for i in range(8): 303 x = side * (4.5 + 1.0 * i + rng.uniform(-0.35, 0.35)) 304 y = -7.5 + i * 5.2 + rng.uniform(-1.0, 1.0) 305 scale = (1.7 + rng.uniform(-0.2, 0.45), 2.0 + rng.uniform(-0.2, 0.5), 0.45 + rng.uniform(-0.12, 0.3)) 306 _add_rock(collection, mat, f"shore_{side:+d}_{i:02d}", (x, y, 0.10), scale, seed + i, peak=False, rotation=rng.random() * math.tau) 307 308 309 def _add_sun(scene): 310 data = bpy.data.lights.new("KEY_amber_dawn", "SUN") 311 data.energy = DEFAULTS["key_energy"] 312 data.angle = math.radians(1.2) 313 data.color = (1.0, 0.69, 0.40) 314 obj = bpy.data.objects.new("KEY_amber_dawn", data) 315 scene.collection.objects.link(obj) 316 # Low backlight from the warm horizon, grazing across wet planes toward the 317 # camera instead of flattening the scene from overhead. 318 obj.rotation_euler = Vector((-0.4, -1.0, -0.18)).to_track_quat("-Z", "Y").to_euler() 319 return obj 320 321 322 def _add_fill(scene): 323 data = bpy.data.lights.new("FILL_cyan_sky", "AREA") 324 data.energy = 6500 325 data.shape = "DISK" 326 data.size = 18 327 data.color = (0.53, 0.70, 1.0) 328 obj = bpy.data.objects.new("FILL_cyan_sky", data) 329 scene.collection.objects.link(obj) 330 obj.location = (-10, -8, 15) 331 _look_at(obj, (0, 18, 1.5)) 332 return obj 333 334 335 def _add_water_skim(scene): 336 data = bpy.data.lights.new("SKIM_cyan_ripples", "AREA") 337 data.energy = 260 338 data.shape = "RECTANGLE" 339 data.size = 8 340 data.size_y = 4 341 data.color = (0.12, 0.40, 0.46) 342 obj = bpy.data.objects.new("SKIM_cyan_ripples", data) 343 scene.collection.objects.link(obj) 344 obj.location = (-7, -12, 4.5) 345 _look_at(obj, (0, 9, 0)) 346 return obj 347 348 349 def _add_warm_window(scene): 350 data = bpy.data.lights.new("GLOW_horizon_window", "AREA") 351 data.energy = 42 352 data.shape = "RECTANGLE" 353 data.size = 10 354 data.size_y = 3 355 data.color = (1.0, 0.16, 0.035) 356 obj = bpy.data.objects.new("GLOW_horizon_window", data) 357 scene.collection.objects.link(obj) 358 obj.location = (2, 52, 12) 359 _look_at(obj, (0, 18, 1.0)) 360 return obj 361 362 363 def _add_camera(scene): 364 data = bpy.data.cameras.new("CAM_low_waterline") 365 data.lens = 31 366 data.sensor_width = 36 367 data.dof.use_dof = True 368 data.dof.focus_distance = 18 369 data.dof.aperture_fstop = 16.0 370 cam = bpy.data.objects.new("CAM_low_waterline", data) 371 scene.collection.objects.link(cam) 372 cam.location = (0.0, -18.5, 1.42) 373 _look_at(cam, (0, 28, 0.35)) 374 scene.camera = cam 375 return cam 376 377 378 def _add_water(collection, mat): 379 bpy.ops.mesh.primitive_grid_add(x_subdivisions=120, y_subdivisions=180, size=2, location=(0, 25, -0.04)) 380 water = bpy.context.object 381 water.name = "WATER_still_ripples" 382 water.scale = (32, 58, 1) 383 # Subtle deterministic displacement gives the water a real, low-frequency 384 # surface break in addition to the layered shader normals. 385 for vert in water.data.vertices: 386 wx = vert.co.x * 32.0 387 wy = vert.co.y * 58.0 388 vert.co.z = (0.060 * math.sin(0.42 * wx + 0.095 * wy) + 389 0.030 * math.sin(0.93 * wx - 0.16 * wy + 1.7) + 390 0.015 * math.sin(1.8 * wx + 0.41 * wy)) 391 water.data.update() 392 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) 393 for poly in water.data.polygons: 394 poly.use_smooth = True 395 water.data.materials.append(mat) 396 # Grid normals are stable and the shader bump supplies the small ripples. 397 return water 398 399 400 def _make_sky_backdrop_material(): 401 mat = bpy.data.materials.new("MAT_procedural_dawn_backdrop") 402 mat.use_nodes = True 403 nodes = mat.node_tree.nodes 404 links = mat.node_tree.links 405 nodes.clear() 406 tex = nodes.new("ShaderNodeTexCoord") 407 sep = nodes.new("ShaderNodeSeparateXYZ") 408 ramp = nodes.new("ShaderNodeValToRGB") 409 ramp.color_ramp.elements.remove(ramp.color_ramp.elements[1]) 410 e0 = ramp.color_ramp.elements[0] 411 e0.position = 0.0 412 e0.color = (0.52, 0.12, 0.035, 1) 413 e1 = ramp.color_ramp.elements.new(0.25) 414 e1.color = (0.34, 0.11, 0.10, 1) 415 e2 = ramp.color_ramp.elements.new(0.58) 416 e2.color = (0.10, 0.16, 0.38, 1) 417 e3 = ramp.color_ramp.elements.new(1.0) 418 e3.color = (0.02, 0.055, 0.20, 1) 419 emission = nodes.new("ShaderNodeEmission") 420 _set_input(emission, ["Strength"], 1.10) 421 out = nodes.new("ShaderNodeOutputMaterial") 422 links.new(tex.outputs["Generated"], sep.inputs[0]) 423 links.new(sep.outputs["Y"], ramp.inputs["Fac"]) 424 links.new(ramp.outputs["Color"], emission.inputs["Color"]) 425 links.new(emission.outputs["Emission"], out.inputs["Surface"]) 426 return mat 427 428 429 def _add_sky_backdrop(collection): 430 bpy.ops.mesh.primitive_plane_add(size=2, location=(0, 92, 35), rotation=(math.pi / 2, 0, 0)) 431 sky = bpy.context.object 432 sky.name = "SKY_procedural_dawn_gradient" 433 sky.scale = (72, 50, 1) 434 sky.data.materials.append(_make_sky_backdrop_material()) 435 return sky 436 437 438 def _add_volume(scene, density): 439 bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 24, 12)) 440 volume = bpy.context.object 441 volume.name = "ATMOSPHERE_mist_volume" 442 volume.scale = (34, 42, 13) 443 volume.data.materials.append(_make_volume_material(density)) 444 volume.display_type = "WIRE" 445 return volume 446 447 448 def _configure_sky(scene): 449 """Set a clear cool-overhead, warm-horizon dawn sky in world nodes.""" 450 world = scene.world or bpy.data.worlds.new("WORLD_dawn_inlet") 451 scene.world = world 452 world.use_nodes = True 453 nodes = world.node_tree.nodes 454 links = world.node_tree.links 455 nodes.clear() 456 sky = nodes.new("ShaderNodeTexSky") 457 sky.sky_type = "MULTIPLE_SCATTERING" 458 sky.sun_disc = False 459 sky.sun_elevation = math.radians(7.0) 460 sky.sun_rotation = math.radians(170) 461 sky.altitude = 0.35 462 if hasattr(sky, "air_density"): 463 sky.air_density = 1.15 464 if hasattr(sky, "turbidity"): 465 sky.turbidity = 2.2 466 if hasattr(sky, "dust_density"): 467 sky.dust_density = 0.55 468 if hasattr(sky, "ozone_density"): 469 sky.ozone_density = 0.28 470 sky.location = (-320, 40) 471 bg = nodes.new("ShaderNodeBackground") 472 bg.inputs["Strength"].default_value = 0.045 473 bg.location = (-40, 40) 474 coord = nodes.new("ShaderNodeTexCoord") 475 coord.location = (-720, -180) 476 sep = nodes.new("ShaderNodeSeparateXYZ") 477 sep.location = (-520, -180) 478 horizon = nodes.new("ShaderNodeMapRange") 479 horizon.location = (-340, -180) 480 horizon.inputs["From Min"].default_value = -0.16 481 horizon.inputs["From Max"].default_value = 0.30 482 horizon.inputs["To Min"].default_value = 0.0 483 horizon.inputs["To Max"].default_value = 1.0 484 horizon.clamp = True 485 gradient = nodes.new("ShaderNodeValToRGB") 486 gradient.location = (-100, -170) 487 gradient.color_ramp.elements.remove(gradient.color_ramp.elements[1]) 488 g0 = gradient.color_ramp.elements[0] 489 g0.position = 0.0 490 g0.color = (0.16, 0.045, 0.026, 1) 491 g1 = gradient.color_ramp.elements.new(0.22) 492 g1.color = (0.17, 0.075, 0.065, 1) 493 g2 = gradient.color_ramp.elements.new(0.56) 494 g2.color = (0.050, 0.075, 0.145, 1) 495 g3 = gradient.color_ramp.elements.new(1.0) 496 g3.color = (0.008, 0.018, 0.055, 1) 497 # A small contribution from the physically based sky keeps the gradient 498 # atmosphere alive while the authored ramp preserves the filmic palette. 499 mix = nodes.new("ShaderNodeMixRGB") 500 mix.blend_type = "MIX" 501 mix.inputs[0].default_value = 0.16 502 mix.location = (80, -50) 503 links.new(coord.outputs["Normal"], sep.inputs[0]) 504 links.new(sep.outputs["Z"], horizon.inputs["Value"]) 505 links.new(horizon.outputs["Result"], gradient.inputs["Fac"]) 506 links.new(gradient.outputs["Color"], mix.inputs[1]) 507 links.new(sky.outputs["Color"], mix.inputs[2]) 508 out = nodes.new("ShaderNodeOutputWorld") 509 out.location = (220, 40) 510 links.new(sky.outputs["Color"], bg.inputs["Color"]) 511 links.new(bg.outputs["Background"], out.inputs["Surface"]) 512 513 514 def _configure_render(scene): 515 scene.render.engine = "BLENDER_EEVEE" 516 # The direct render command switches to Cycles below after the scene is 517 # built. Eevee remains a responsive viewport fallback for inspection. 518 scene.render.resolution_x = 960 519 scene.render.resolution_y = 540 520 scene.render.resolution_percentage = 100 521 scene.render.image_settings.file_format = "PNG" 522 scene.render.film_transparent = False 523 scene.render.image_settings.color_mode = "RGBA" 524 scene.render.image_settings.color_depth = "8" 525 scene.render.use_file_extension = True 526 scene.render.filepath = "inlet_preview.png" 527 scene.render.image_settings.color_mode = "RGB" 528 scene.view_settings.look = "AgX - Medium High Contrast" 529 scene.view_settings.exposure = -0.65 530 scene.render.use_compositing = True 531 scene.render.image_settings.file_format = "PNG" 532 scene.world.color = (0.004, 0.012, 0.02) 533 scene["scene_schema"] = "cinematic-inlet-1" 534 scene["defaults"] = str(DEFAULTS) 535 536 537 def _build_compositor(scene): 538 # Blender 5.1's compositor API has moved to the new node-group interface; 539 # the scene deliberately keeps the render path dependency-free. The 540 # atmospheric bloom comes from the physical volume and wide area window. 541 return None 542 543 544 def build_scene(scene=None): 545 """Clear and build the deterministic inlet. Returns the Blender scene.""" 546 if scene is None: 547 scene = bpy.context.scene 548 bpy.ops.object.select_all(action="SELECT") 549 bpy.ops.object.delete(use_global=False) 550 for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights): 551 # Do not remove data blocks from unrelated linked scenes when imported. 552 pass 553 for child in list(scene.collection.children): 554 scene.collection.children.unlink(child) 555 scene.render.engine = "BLENDER_EEVEE" 556 rock_mat = _make_rock_material() 557 water_mat = _make_water_material() 558 shore_mat = _make_sand_material() 559 rocks = bpy.data.collections.new("ROCKS_procedural_basalt") 560 scene.collection.children.link(rocks) 561 water_collection = bpy.data.collections.new("WATER_and_SHORE") 562 scene.collection.children.link(water_collection) 563 564 _add_water(water_collection, water_mat) 565 _add_shoreline(rocks, rock_mat, -1, 110) 566 # The left wall is a dominant, broken cliff; the right bank is deliberately 567 # lower and discontinuous so the inlet opens toward the dawn. 568 foreground = [ 569 (-7.1, -5.2, 0.55, (4.6, 5.2, 6.6), 17), 570 (-7.8, 4.0, 0.20, (3.3, 3.8, 5.0), 41), 571 (-5.8, 11.0, 0.08, (2.6, 3.4, 3.6), 67), 572 (5.2, -6.0, 0.18, (2.6, 3.1, 2.0), 29), 573 (6.8, -0.8, 0.14, (2.1, 2.6, 2.4), 53), 574 (5.4, 7.6, 0.12, (2.0, 2.7, 2.1), 79), 575 (3.7, 13.3, 0.05, (1.3, 1.8, 1.5), 87), 576 ] 577 for i, (x, y, z, sc, seed) in enumerate(foreground): 578 _add_rock(rocks, rock_mat, f"foreground_mass_{i:02d}", (x, y, z), sc, seed, peak=True, rotation=0.2 * i) 579 # Distant staggered stacks create depth and scale. Their smaller geometry 580 # is still fully procedural, with no impostor images or external assets. 581 distant = [ 582 (-12.5, 38.0, 0.0, (3.5, 3.0, 8.0), 301), 583 (-5.6, 46.0, 0.0, (2.1, 2.2, 5.5), 313), 584 (1.8, 55.0, 0.0, (1.8, 2.1, 4.2), 327), 585 (7.5, 41.0, 0.0, (2.6, 2.4, 6.5), 337), 586 (13.5, 49.0, 0.0, (3.7, 3.0, 7.5), 349), 587 (-1.8, 64.0, 0.0, (2.5, 2.8, 5.7), 359), 588 ] 589 for i, (x, y, z, sc, seed) in enumerate(distant): 590 _add_rock(rocks, rock_mat, f"sea_stack_{i:02d}", (x, y, z), sc, seed, peak=True, rotation=0.13 * i) 591 # Small mid-channel rocks break the otherwise perfect reflection line. 592 for i, (x, y, sc) in enumerate([(-2.1, 15.0, (0.9, 1.6, 1.1)), (2.0, 22.5, (1.2, 1.4, 1.6)), (-3.2, 29.5, (1.1, 1.4, 1.9))]): 593 _add_rock(rocks, rock_mat, f"channel_rock_{i:02d}", (x, y, 0.0), sc, 501 + i, peak=True, rotation=0.4 * i) 594 595 cam = _add_camera(scene) 596 _add_sun(scene) 597 _add_fill(scene) 598 _add_water_skim(scene) 599 _add_warm_window(scene) 600 _add_volume(scene, DEFAULTS["mist_density"]) 601 _configure_sky(scene) 602 _configure_render(scene) 603 _build_compositor(scene) 604 apply_parameters(scene, DEFAULTS) 605 return scene 606 607 608 def apply_parameters(scene_or_params, params=None): 609 """Apply bounded visible controls to an existing scene. 610 611 Supports both ``apply_parameters(scene, params)`` and the convenient 612 persistent-process form ``apply_parameters(params)``. 613 """ 614 if params is None: 615 scene = bpy.context.scene 616 params = scene_or_params 617 else: 618 scene = scene_or_params 619 merged = dict(DEFAULTS) 620 if params: 621 merged.update(params) 622 for key in BOUNDS: 623 merged[key] = _clamp(key, merged[key]) 624 scene["parameters"] = str(merged) 625 # The node names are stable because this script builds them exactly once. 626 sun = bpy.data.objects.get("KEY_amber_dawn") 627 if sun and sun.data: 628 sun.data.energy = merged["key_energy"] 629 volume = bpy.data.objects.get("ATMOSPHERE_mist_volume") 630 if volume and volume.data and volume.data.materials: 631 mat = volume.data.materials[0] 632 density_node = mat.node_tree.nodes.get("Mist Density") 633 if density_node: 634 density_node.outputs[0].default_value = merged["mist_density"] 635 else: 636 node = next((n for n in mat.node_tree.nodes if n.bl_idname == "ShaderNodeVolumePrincipled"), None) 637 if node: 638 _set_input(node, ["Density"], merged["mist_density"]) 639 water = bpy.data.materials.get("MAT_teal_inlet_water") 640 if water: 641 water["water_roughness"] = merged["water_roughness"] 642 node = water.node_tree.nodes.get("Water Principled") 643 if node: 644 _set_input(node, ["Roughness"], merged["water_roughness"]) 645 set_camera_shift(scene, merged["camera_shift"]) 646 return merged 647 648 649 def set_camera_shift(scene_or_shift, shift=None): 650 if shift is None: 651 scene = bpy.context.scene 652 shift = scene_or_shift 653 else: 654 scene = scene_or_shift 655 camera = scene.camera or bpy.data.objects.get("CAM_low_waterline") 656 if camera and camera.data: 657 value = _clamp("camera_shift", shift) 658 # Held-out views use a real lateral camera displacement and re-aim at 659 # the same distant target; lens shift stays neutral. 660 camera.location.x = value * 2.4 661 camera.data.shift_x = 0.0 662 _look_at(camera, (0.0, 28.0, 0.35)) 663 camera["camera_shift"] = value 664 return _clamp("camera_shift", shift) 665 666 667 def _configure_cycles(scene, samples=48): 668 scene.render.engine = "CYCLES" 669 scene.cycles.samples = int(samples) 670 scene.cycles.use_denoising = True 671 scene.cycles.max_bounces = 8 672 scene.cycles.diffuse_bounces = 3 673 scene.cycles.glossy_bounces = 4 674 scene.cycles.transmission_bounces = 4 675 scene.cycles.volume_bounces = 2 676 scene.cycles.use_animated_seed = False 677 scene.cycles.seed = 13579 678 scene.render.image_settings.file_format = "PNG" 679 scene.render.image_settings.color_mode = "RGB" 680 try: 681 prefs = bpy.context.preferences.addons["cycles"].preferences 682 prefs.refresh_devices() 683 devices = getattr(prefs, "devices", []) 684 metal = next((d for d in devices if getattr(d, "type", "") == "METAL"), None) 685 if metal: 686 prefs.compute_device_type = "METAL" 687 scene.cycles.device = "GPU" 688 scene["render_device"] = "METAL" 689 return "METAL" 690 except Exception as exc: 691 scene["cycles_device_note"] = str(exc) 692 scene.cycles.device = "CPU" 693 scene["render_device"] = "CPU" 694 return "CPU" 695 696 697 def render(scene, path, width=960, height=540, samples=48, seed=13579, exr=False, exr_path=None): 698 """Render PNG/EXR, optionally saving both from one Render Result.""" 699 _configure_cycles(scene, samples=samples) 700 scene.cycles.seed = int(seed) 701 scene.render.resolution_x = int(width) 702 scene.render.resolution_y = int(height) 703 scene.render.resolution_percentage = 100 704 scene.render.filepath = os.fspath(path) 705 if exr: 706 scene.render.image_settings.file_format = "OPEN_EXR" 707 scene.render.image_settings.color_mode = "RGBA" 708 scene.render.image_settings.color_depth = "32" 709 scene.render.image_settings.exr_codec = "ZIP" 710 else: 711 scene.render.image_settings.file_format = "PNG" 712 scene.render.image_settings.color_mode = "RGB" 713 primary_path = os.fspath(path) 714 bpy.ops.render.render(write_still=True) 715 if exr_path is not None and not exr: 716 result = bpy.data.images.get("Render Result") 717 if result is not None: 718 scene.render.filepath = os.fspath(exr_path) 719 scene.render.image_settings.file_format = "OPEN_EXR" 720 scene.render.image_settings.color_mode = "RGBA" 721 scene.render.image_settings.color_depth = "32" 722 scene.render.image_settings.exr_codec = "ZIP" 723 result.save_render(filepath=os.fspath(exr_path), scene=scene) 724 scene.render.filepath = primary_path 725 scene.render.image_settings.file_format = "PNG" 726 scene.render.image_settings.color_mode = "RGB" 727 return os.fspath(path) 728 729 730 def _parse_command_line(): 731 args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] 732 outdir = Path(args[0]).expanduser().resolve() if args else Path.cwd() 733 outdir.mkdir(parents=True, exist_ok=True) 734 return outdir 735 736 737 def main(): 738 outdir = _parse_command_line() 739 scene = build_scene() 740 params = apply_parameters(scene, DEFAULTS) 741 preview = outdir / "inlet_preview.png" 742 render(scene, preview, width=960, height=540, samples=48, seed=13579) 743 blend = outdir / "inlet_scene.blend" 744 bpy.ops.wm.save_as_mainfile(filepath=os.fspath(blend)) 745 print("CINEMATIC_INLET_READY") 746 print(f"BLENDER_VERSION={bpy.app.version_string}") 747 print(f"RENDER_DEVICE={scene.get('render_device', 'unknown')}") 748 print(f"PARAMETERS={params}") 749 print(f"PREVIEW={preview}") 750 print(f"BLEND={blend}") 751 752 753 if __name__ == "__main__": 754 main()