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()