git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit45c1c097e924eff86aebb3d95d59037025e0c51b
parent7b4dcb58aa
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 15:04
feat: an Environment node lights both views from one sun

The raster pass and the path tracer had a light each, hard-coded and
pointing different ways (the raster one, read the right way round,
from below), so switching modes moved the lit side of a model. One
Environment now lights both: its sun direction goes to the flat shader
(set_scene_light) and the smooth bake, and the whole of it - sun
direction, colour and intensity, sky and ground colours - to the
tracer's sky (set_rt_environment).

The `environment` node stands at the root. The scene's environment is
the first one there that is not bypassed; with none, the defaults,
which are the template's and the tracer's old sky. Rows evaluate at
the current frame; sync_environment re-bakes a smooth fill from the
last evaluated scene when the light moves. --thumbnail reads it too.

Verified in a shadow session: sun at azimuth 90, both views are
brighter on the right; at 270, both on the left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md              |  61 +++++++++++++++++++---
 nodes/environment.json |  15 ++++++
 src/app.rs             |  11 +++-
 src/context.rs         |  10 ++--
 src/environment.rs     | 135 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/geometry.rs        |  33 ++++++------
 src/main.rs            |  86 +++++++++++++++++++++++++------
 src/render.rs          |  26 +++++++++-
 src/thumbnail.rs       |   2 +
 9 files changed, 333 insertions(+), 46 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cfcb046..eb7483b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -402,14 +402,15 @@ is the introspection surface.
 ### The root is the object level; geometry goes in a Geometry node (since 2026-10-02)
 
 Houdini's `/obj` and its geometry objects. The root holds **Geometry**
-nodes (`nodes/geometry.json`, type `geometry`), cameras and pages; every
+nodes (`nodes/geometry.json`, type `geometry`), cameras, the Environment
+node and pages; every
 operator — generators, modifiers, subnets, simnets, repeats, the subnet
 templates — stands inside a Geometry node, at any depth. Until this every
 node could stand anywhere and the root was one big geometry level.
 `src/context.rs` is the whole rule.
 
 - **Placement is by node type** (`context::placement`): Object (the
-  `geometry` container and `camera`, root only), Any (the page nodes, which
+  `geometry` container, `camera` and `environment`, root only), Any (the page nodes, which
   are a 2D context of their own and stay where they always could, and
   `export`, which writes a page or a mesh), Geometry (everything else, so a
   new node type is a geometry operator without a line anywhere). A level's
@@ -461,6 +462,49 @@ node could stand anywhere and the root was one big geometry level.
 `geometry_nodes_at_the_root_each_show_their_own` and
 `the_add_node_list_offers_what_belongs_at_the_level` are the tests.
 
+### The Environment node: one light for both views (since 2026-10-02)
+
+`src/environment.rs` and `nodes/environment.json`. The raster pass and the
+path tracer each had a light of their own, hard-coded and pointing
+different ways — the raster one, read the right way round, from BELOW
+(`(-0.55, 0.45, 0.7)` dotted with the shader's inward normal), the
+tracer's sun at `(0.45, 0.75, 0.35)` — so switching modes moved the lit
+side of a model. Now one `Environment` lights both: its sun direction goes
+to the flat shader (`VkRenderer::set_scene_light`, cce-ui) and the smooth
+bake (`shade_factor(n, toward)`), and all of it to the tracer's sky
+(`VkRenderer::set_rt_environment` / `RtOffscreen::set_environment`, an
+`RtEnvironment`: sun direction, sun radiance, zenith and nadir colours).
+
+- **The node stands at the root** (an Object placement). The scene's
+  environment is the root's first `environment` node that is NOT BYPASSED;
+  with none it is `Environment::default()`, which is the template's
+  defaults (`the_environment_node_lights_both_views` holds the two equal) —
+  so adding one changes nothing until a row moves, and Bypass is how to
+  compare. Not the display flag: a node arrives with it off, and an
+  environment that did nothing until `e` would read as broken.
+- **Rows**: Sun Azimuth (degrees about +Y, 0 toward +Z, 90 toward +X),
+  Sun Elevation (above the horizon; below 0 lights from underneath), Sun
+  Intensity and Sun Color, Sky Color (overhead), Ground Color (straight
+  down) and Sky Intensity. The defaults are the tracer's old sky to the
+  nearest degree, so the traced view looks as it did; the raster view now
+  lights from that sun, from above, where it was lit from below.
+- **What each view takes**: the raster pass the DIRECTION only — its
+  shading is a 0.55..1 wrap of the surface colour, not a light with a
+  strength; the tracer everything. Colours are LINEAR, as the tracer reads
+  them, and an intensity may push them past 1. The sky stays the tracer's
+  only light, and with the Background Color behind the scene (above) it is
+  seen only in what it lights.
+- **Rows evaluate at the current frame** (`Environment::of_scene`, through
+  `resolve_param_refs`), so a sun can move with `$F`. `State::environment`
+  is the value in use: `present_scene` reads it for the bake, and
+  `sync_environment` runs from the tick, re-baking a smooth fill from
+  `scene_base` (no evaluation) when it moved, so an animated sun or an edit
+  that rebuilt nothing is still seen. `--thumbnail` reads it from the
+  project at the frame it renders.
+
+Verified in a shadow session against a grey sphere: with the sun at
+azimuth 90 both views are brighter on the right, at 270 both on the left.
+
 ### There are no meta nodes (retired 2026-09-23)
 
 Two different things were called `meta`, and both are gone. What replaced
@@ -3049,10 +3093,15 @@ exact: `geometry::smooth_lit_vertices` multiplies each corner's colour by
 `shade_factor(point_normals[p])`, and the fill draws with
 `SceneDraw::prelit` (cce-ui, 2026-09-24) so the shader does not shade it
 twice. `shade_factor` has to agree with the shader about which side is
-lit: the shader's normal is screen-right × framebuffer-DOWN, which for any
-visible surface points AWAY from the viewer — into the surface — so the
-bake uses `dot(-n_outward, l)`; on a plane the two modes give identical
-brightness (`smooth_shading_bakes_the_flat_shaders_light_per_vertex`). The
+lit, and since 2026-10-02 both take the environment's sun as the direction
+TOWARD the light: the shader's derivative normal is screen-right ×
+framebuffer-DOWN, which points INTO a visible surface, so it dots `-n`
+with the light where the bake dots the outward normal; on a plane the two
+modes give identical brightness
+(`smooth_shading_bakes_the_flat_shaders_light_per_vertex`). Before that
+date both dotted the INWARD normal with a constant `(-0.55, 0.45, 0.7)` —
+a light from below, read the right way round. See "The Environment
+node". The
 lit copy is `State::scene_smooth_verts`, kept only while smooth is on; the
 path tracer keeps reading the unlit `rt_sphere_verts`, whose colours are
 its materials. Smoothing follows topology, so a welded mesh rounds off and
diff --git a/nodes/environment.json b/nodes/environment.json
new file mode 100644
index 0000000..b79650a
--- /dev/null
+++ b/nodes/environment.json
@@ -0,0 +1,15 @@
+{
+  "name": "Environment",
+  "type": "environment",
+  "inputs": 0,
+  "outputs": 0,
+  "params": [
+    { "name": "sun_azimuth", "label": "Sun Azimuth", "type": "slider", "default": "52", "min": 0.0, "max": 360.0, "step": 1.0, "description": "Which way the sun is, in degrees around the up axis: 0 is toward +Z, 90 toward +X. Both the shaded and the path-traced view are lit from this direction.", "group": "sun" },
+    { "name": "sun_elevation", "label": "Sun Elevation", "type": "slider", "default": "53", "min": -90.0, "max": 90.0, "step": 1.0, "description": "How high the sun stands, in degrees above the horizon: 90 is straight overhead, below 0 it lights from underneath.", "group": "sun" },
+    { "name": "sun_intensity", "label": "Sun Intensity", "type": "float", "default": "8", "description": "How bright the sun is in the path-traced view; 0 leaves the sky as the only light. The shaded view takes the sun's direction only.", "group": "sun" },
+    { "name": "sun_color", "label": "Sun Color", "type": "float3", "default": "1.00:0.95:0.85", "min": 0.0, "max": 1.0, "description": "Colour of the sunlight in the path-traced view, as linear values, scaled by Sun Intensity.", "group": "sun" },
+    { "name": "sky_color", "label": "Sky Color", "type": "float3", "default": "0.72:0.82:0.98", "min": 0.0, "max": 1.0, "description": "Colour of the sky straight overhead in the path-traced view, as linear values; it grades into Ground Color toward straight down.", "group": "sky" },
+    { "name": "ground_color", "label": "Ground Color", "type": "float3", "default": "0.32:0.31:0.35", "min": 0.0, "max": 1.0, "description": "Colour of the sky straight down in the path-traced view, as linear values: the light a surface facing the ground receives.", "group": "sky" },
+    { "name": "sky_intensity", "label": "Sky Intensity", "type": "float", "default": "1", "description": "Multiplies Sky Color and Ground Color: how much light the sky itself gives in the path-traced view.", "group": "sky" }
+  ]
+}
diff --git a/src/app.rs b/src/app.rs
index f576e40..19d15e4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2932,6 +2932,10 @@ pub struct State {
     /// uploads this in place of `rt_sphere_verts`, which the path tracer
     /// keeps reading unlit.
     pub scene_smooth_verts: Vec<Vertex3D>,
+    /// The scene's light, as the Environment node at the root says (or its
+    /// defaults) — read by `present_scene` and `sync_environment`, handed
+    /// to both views by the stage pass. See `crate::environment`.
+    pub environment: crate::environment::Environment,
     /// See-through fill (`toggle_show_occluded`), in effect only below full
     /// opacity — at 100% there is nothing to see through, and the ordinary
     /// depth-writing fill is exact.
@@ -7999,6 +8003,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             show_occluded: settings.render.show_occluded,
             sorted_fill_key: None,
             scene_smooth_verts: Vec::new(),
+            environment: crate::environment::Environment::default(),
             group_point_verts: Vec::new(),
             spreadsheet_points: Vec::new(),
             row_marker_verts: Vec::new(),
@@ -11662,6 +11667,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// a rebuild. The render half lives in [`State::stage_frame`].
     pub fn tick_frame(&mut self, dt: f32) -> bool {
         let now = Instant::now();
+        let light_moved = self.sync_environment();
 
         // A replacement renderer left the page pane with no image; recompose
         // and re-upload it now that the frame has settled.
@@ -11975,7 +11981,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.read_panel_offsets();
         }
 
-        tick_changed || panned || reclaimed || glow_animating || frame_moved || config_changed
+        tick_changed || panned || reclaimed || glow_animating || frame_moved || config_changed || light_moved
     }
 
     /// Flush CPU-staged mesh updates to the renderer's persistent meshes.
@@ -12453,6 +12459,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         }
                         draws.extend(fill);
                     }
+                    // One light for both views: the environment's sun.
+                    renderer.set_scene_light(self.environment.sun_direction.to_array());
                     renderer.stage_scene((sx, sy, cw, ch), draws);
                     if let (Some(image), Some(shown)) = (self.page_image, &self.page_shown) {
                         renderer.stage_scene_images(vec![cce_ui::vk::SceneImage {
@@ -12520,6 +12528,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     // pass draws it (since 2026-10-02; it was the tracer's
                     // sky). The sky still lights the scene.
                     renderer.set_rt_background(Some(cce_ui::colors::to_linear_rgb(self.viewport().bg_color)));
+                    renderer.set_rt_environment(self.environment.to_rt());
                     renderer.stage_rt((sx, sy, cw, ch), cce_ui::vk::RtCamera { inv_mvp });
                 }
             } else if self.viewport_dirty {
diff --git a/src/context.rs b/src/context.rs
index e11a681..4fd412e 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -10,8 +10,9 @@
 //!
 //! Three placements, by node type ([`placement`]):
 //!
-//! - **Object** — the root only: the `geometry` container itself, and
-//!   cameras, which are seen from every level (`State::camera_level`).
+//! - **Object** — the root only: the `geometry` container itself, cameras,
+//!   which are seen from every level (`State::camera_level`), and the
+//!   `environment` node, the scene's light (`crate::environment`).
 //! - **Geometry** — inside a geometry node only: every operator, subnets,
 //!   simnets, repeats and the subnet templates (the Embryo, the Remesh).
 //! - **Any** — the page nodes, which are a 2D context of their own and stay
@@ -49,7 +50,10 @@ pub fn is_geometry_container(node_type: &str) -> bool {
 /// Where a node of `node_type` may stand. Everything not named is an
 /// operator, so a new node type is a geometry node without a line here.
 pub fn placement(node_type: &str) -> Placement {
-    if is_geometry_container(node_type) || node_type.eq_ignore_ascii_case("camera") {
+    if is_geometry_container(node_type)
+        || node_type.eq_ignore_ascii_case("camera")
+        || node_type.eq_ignore_ascii_case(crate::environment::ENVIRONMENT)
+    {
         Placement::Object
     } else if crate::page::is_page_node(node_type) || node_type.eq_ignore_ascii_case("export") {
         Placement::Any
diff --git a/src/environment.rs b/src/environment.rs
new file mode 100644
index 0000000..b445320
--- /dev/null
+++ b/src/environment.rs
@@ -0,0 +1,135 @@
+//! The scene's light: the Environment node (since 2026-10-02).
+//!
+//! One sun direction lights BOTH views. The raster pass's flat shading and
+//! the smooth bake light by it (`VkRenderer::set_scene_light`,
+//! `geometry::shade_factor`), and the path tracer's sky puts its sun there
+//! (`RtEnvironment`). Until this the two had a light each, hard-coded and
+//! pointing different ways — the raster one, read the right way round,
+//! from BELOW — so switching modes moved the lit side of a model.
+//!
+//! The `environment` node stands at the root, the object level
+//! (`context::placement`). The first one there that is not bypassed is the
+//! scene's environment; with none, [`Environment::default`] is, which is
+//! the template's defaults — so adding the node changes nothing until a
+//! row is moved, and bypassing it is how to compare. Its rows evaluate at
+//! the current frame, so a sun can move with `$F`.
+//!
+//! What each view takes from it: the raster pass takes the DIRECTION only —
+//! its shading is a fixed 0.55..1 wrap of the surface colour, not a light
+//! with a strength. The tracer takes all of it: the sun's colour times its
+//! intensity, and the sky's two colours times theirs. Colours are LINEAR,
+//! as the tracer reads them, and may be pushed past 1 by an intensity.
+
+use glam::Vec3;
+
+use crate::app::FsNode;
+use crate::geometry::{node_param_f32, node_param_vec3, resolve_param_refs};
+
+/// The node type.
+pub const ENVIRONMENT: &str = "environment";
+
+/// The template's defaults — `nodes/environment.json` must say the same,
+/// which `the_environment_node_lights_both_views` checks. The sun is the
+/// tracer's old one, (0.45, 0.75, 0.35), to the nearest degree.
+pub const DEFAULT_SUN_AZIMUTH: f32 = 52.0;
+pub const DEFAULT_SUN_ELEVATION: f32 = 53.0;
+pub const DEFAULT_SUN_INTENSITY: f32 = 8.0;
+pub const DEFAULT_SUN_COLOR: [f32; 3] = [1.0, 0.95, 0.85];
+pub const DEFAULT_SKY_COLOR: [f32; 3] = [0.72, 0.82, 0.98];
+pub const DEFAULT_GROUND_COLOR: [f32; 3] = [0.32, 0.31, 0.35];
+pub const DEFAULT_SKY_INTENSITY: f32 = 1.0;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Environment {
+    /// Toward the sun, unit length, world space.
+    pub sun_direction: Vec3,
+    /// The sun's colour times its intensity.
+    pub sun_radiance: [f32; 3],
+    /// The sky straight up and straight down, times the sky's intensity.
+    pub sky_zenith: [f32; 3],
+    pub sky_nadir: [f32; 3],
+}
+
+impl Default for Environment {
+    fn default() -> Self {
+        Self::from_rows(
+            DEFAULT_SUN_AZIMUTH,
+            DEFAULT_SUN_ELEVATION,
+            DEFAULT_SUN_COLOR,
+            DEFAULT_SUN_INTENSITY,
+            DEFAULT_SKY_COLOR,
+            DEFAULT_GROUND_COLOR,
+            DEFAULT_SKY_INTENSITY,
+        )
+    }
+}
+
+/// The direction an azimuth and an elevation (degrees) name: azimuth turns
+/// from +Z toward +X about the up axis, elevation lifts from the horizon
+/// toward +Y.
+pub fn sun_direction(azimuth: f32, elevation: f32) -> Vec3 {
+    let (az, el) = (azimuth.to_radians(), elevation.to_radians());
+    Vec3::new(az.sin() * el.cos(), el.sin(), az.cos() * el.cos()).normalize()
+}
+
+impl Environment {
+    fn from_rows(
+        azimuth: f32,
+        elevation: f32,
+        sun_color: [f32; 3],
+        sun_intensity: f32,
+        sky: [f32; 3],
+        ground: [f32; 3],
+        sky_intensity: f32,
+    ) -> Self {
+        let scale = |c: [f32; 3], k: f32| c.map(|v| (v * k).max(0.0));
+        Self {
+            sun_direction: sun_direction(azimuth, elevation),
+            sun_radiance: scale(sun_color, sun_intensity),
+            sky_zenith: scale(sky, sky_intensity),
+            sky_nadir: scale(ground, sky_intensity),
+        }
+    }
+
+    /// An environment node's rows, read as they stand (no expressions).
+    pub fn of_node(node: &FsNode) -> Self {
+        let color = |name: &str, fallback: [f32; 3]| node_param_vec3(node, name, Vec3::from_array(fallback)).to_array();
+        Self::from_rows(
+            node_param_f32(node, "sun_azimuth", DEFAULT_SUN_AZIMUTH),
+            node_param_f32(node, "sun_elevation", DEFAULT_SUN_ELEVATION),
+            color("sun_color", DEFAULT_SUN_COLOR),
+            node_param_f32(node, "sun_intensity", DEFAULT_SUN_INTENSITY),
+            color("sky_color", DEFAULT_SKY_COLOR),
+            color("ground_color", DEFAULT_GROUND_COLOR),
+            node_param_f32(node, "sky_intensity", DEFAULT_SKY_INTENSITY),
+        )
+    }
+
+    /// The scene's environment at `frame`: the root's first environment
+    /// node that is not bypassed, its expressions evaluated; the default
+    /// when there is none.
+    pub fn of_scene(root: &FsNode, frame: i32) -> Self {
+        let Some(node) = root
+            .children
+            .iter()
+            .find(|c| c.node_type.eq_ignore_ascii_case(ENVIRONMENT) && !c.bypassed)
+        else {
+            return Self::default();
+        };
+        let mut error = None;
+        match resolve_param_refs(root, node, frame, &mut error) {
+            Some(resolved) => Self::of_node(&resolved),
+            None => Self::of_node(node),
+        }
+    }
+
+    /// What the path tracer is handed.
+    pub fn to_rt(&self) -> cce_ui::vk::RtEnvironment {
+        cce_ui::vk::RtEnvironment {
+            sun_direction: self.sun_direction.to_array(),
+            sun_color: self.sun_radiance,
+            sky_zenith: self.sky_zenith,
+            sky_nadir: self.sky_nadir,
+        }
+    }
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 656850c..40514cc 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -220,22 +220,18 @@ pub fn point_transmittance(
         .collect()
 }
 
-/// The raster pass's light, in WORLD space — `scene3d.wgsl`'s `l`, which the
-/// smooth bake below has to match or switching shading modes would move
-/// the lit side of the model.
-pub const SCENE_LIGHT: [f32; 3] = [-0.55, 0.45, 0.7];
-
 /// The raster pass's shading factor for a surface whose OUTWARD normal is
-/// `n` — the multiplier `scene3d.wgsl` applies to a fragment's colour.
+/// `n`, lit from `toward` (unit, toward the light — the environment's sun,
+/// which `scene3d.wgsl` is handed too): the multiplier the shader applies
+/// to a fragment's colour, its wrap term and its 0.55 floor. A zero normal
+/// (a point on no primitive) takes the midpoint.
 ///
-/// The shader's normal is `cross(dpdx(world), dpdy(world))`: screen right
-/// crossed with framebuffer DOWN, which for any visible surface points away
-/// from the viewer — into the surface. So the shader's `dot(n, l)` is this
-/// function's `dot(-n, l)`, and the wrap term and the 0.55 floor are its
-/// own. A zero normal (a point on no primitive) takes the midpoint.
-pub fn shade_factor(n: Vec3) -> f32 {
-    let l = Vec3::from_array(SCENE_LIGHT).normalize();
-    let d = if n.length_squared() > 0.0 { ((-n).dot(l) * 0.5 + 0.5).clamp(0.0, 1.0) } else { 0.5 };
+/// Until 2026-10-02 the light was a constant here and in the shader,
+/// `(-0.55, 0.45, 0.7)`, dotted with the shader's INWARD normal — a light
+/// from below, read the right way round — and the tracer's sun was another
+/// direction altogether. See `crate::environment`.
+pub fn shade_factor(n: Vec3, toward: Vec3) -> f32 {
+    let d = if n.length_squared() > 0.0 { (n.dot(toward) * 0.5 + 0.5).clamp(0.0, 1.0) } else { 0.5 };
     0.55 + 0.45 * d
 }
 
@@ -251,9 +247,9 @@ pub fn shade_factor(n: Vec3) -> f32 {
 /// triangles or a cut along a seam stays faceted there — as it would in
 /// any smooth-shaded viewport. Colours here only; the path tracer keeps
 /// reading the unlit ones.
-pub fn smooth_lit_vertices(d: &Detail) -> Vec<Vertex3D> {
+pub fn smooth_lit_vertices(d: &Detail, toward: Vec3) -> Vec<Vertex3D> {
     let normals = point_normals(d);
-    let lit: Vec<f32> = normals.iter().map(|n| shade_factor(*n)).collect();
+    let lit: Vec<f32> = normals.iter().map(|n| shade_factor(*n, toward)).collect();
     // Vertex normals, where the geometry carries them, are the normals it
     // asked to be shaded by: a corner is lit by its own, so a crease the
     // Normal node cusped reads hard and the rest smooth.
@@ -272,7 +268,7 @@ pub fn smooth_lit_vertices(d: &Detail) -> Vec<Vertex3D> {
                     .and_then(|n| n.get(first + corner))
                     .map(|n| n.as_vec3())
                     .filter(|n| n.length_squared() > 1e-12)
-                    .map(|n| shade_factor(n.normalize()));
+                    .map(|n| shade_factor(n.normalize(), toward));
                 let k = by_vertex.unwrap_or_else(|| lit.get(p).copied().unwrap_or(1.0));
                 let c = d.color(p);
                 out.push(Vertex3D { position: d.pos(p).to_array(), color: [c[0] * k, c[1] * k, c[2] * k] });
@@ -6587,7 +6583,8 @@ mod tests {
                 || ty.eq_ignore_ascii_case("node")
                 || crate::context::is_geometry_container(ty)
                 || crate::page::is_page_node(ty)
-                || ty.eq_ignore_ascii_case("camera");
+                || ty.eq_ignore_ascii_case("camera")
+                || ty.eq_ignore_ascii_case(crate::environment::ENVIRONMENT);
             if !resolvable {
                 orphans.push(format!("{} (type {ty:?})", t.name));
             }
diff --git a/src/main.rs b/src/main.rs
index 00854bb..3db5b9f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -30,6 +30,7 @@ pub mod api;
 pub mod window;
 pub mod geometry;
 pub mod context;
+pub mod environment;
 pub mod project;
 pub mod render;
 pub mod shortcut;
@@ -2067,13 +2068,14 @@ mod tests {
     fn smooth_shading_bakes_the_flat_shaders_light_per_vertex() {
         use crate::geometry::{shade_factor, smooth_lit_vertices, sphere_detail, detail_vertices};
         use glam::Vec3;
-        // The shader's normal faces away from the viewer, so a face turned
-        // TOWARD the light (outward normal along l) is its darkest, and one
-        // turned away its brightest — the bake keeps that convention.
-        let l = Vec3::from_array(crate::geometry::SCENE_LIGHT).normalize();
-        assert!((shade_factor(l) - 0.55).abs() < 1e-5);
-        assert!((shade_factor(-l) - 1.0).abs() < 1e-5);
-        assert!((shade_factor(Vec3::ZERO) - (0.55 + 0.45 * 0.5)).abs() < 1e-5);
+        // Lit by the environment's sun: a face turned toward it is its
+        // brightest, one turned away its darkest. (Until 2026-10-02 it was
+        // the other way round, a constant read against the shader's inward
+        // normal — a light from below.)
+        let l = crate::environment::Environment::default().sun_direction;
+        assert!((shade_factor(l, l) - 1.0).abs() < 1e-5);
+        assert!((shade_factor(-l, l) - 0.55).abs() < 1e-5);
+        assert!((shade_factor(Vec3::ZERO, l) - (0.55 + 0.45 * 0.5)).abs() < 1e-5);
 
         // A single quad in the XZ plane, wound to face +y.
         let mut quad = crate::detail::Detail::new();
@@ -2084,10 +2086,10 @@ mod tests {
         quad.add_prim(&[a, b, c, d]);
         let n = crate::geometry::point_normals(&quad)[0];
         assert!((n - Vec3::Y).length() < 1e-5, "the quad faces +y: {n}");
-        let lit = smooth_lit_vertices(&quad);
+        let lit = smooth_lit_vertices(&quad, l);
         let flat = detail_vertices(&quad);
         assert_eq!(lit.len(), flat.len(), "same triangles as the unlit fill");
-        let k = shade_factor(Vec3::Y);
+        let k = shade_factor(Vec3::Y, l);
         for (l, f) in lit.iter().zip(&flat) {
             assert_eq!(l.position, f.position);
             for ch in 0..3 {
@@ -2098,7 +2100,7 @@ mod tests {
         // A closed UV sphere: same triangle list, and corners of one face
         // no longer share one brightness.
         let sphere = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
-        let lit = smooth_lit_vertices(&sphere);
+        let lit = smooth_lit_vertices(&sphere, l);
         assert_eq!(lit.len(), detail_vertices(&sphere).len());
         let varied = lit.chunks(3).filter(|t| {
             let b = |v: &crate::geometry::Vertex3D| v.color[0] + v.color[1] + v.color[2];
@@ -11145,8 +11147,8 @@ mod tests {
             .verts_mut()
             .insert("N", crate::detail::AttribData::Float3(hard.iter().map(|n| n.to_array()).collect()))
             .unwrap();
-        let lit = crate::geometry::smooth_lit_vertices(&cusped);
-        let plain = crate::geometry::smooth_lit_vertices(&d);
+        let lit = crate::geometry::smooth_lit_vertices(&cusped, crate::environment::Environment::default().sun_direction);
+        let plain = crate::geometry::smooth_lit_vertices(&d, crate::environment::Environment::default().sun_direction);
         assert_eq!(lit.len(), plain.len());
         for tri in lit.chunks(3) {
             assert!(tri.iter().all(|c| (c.color[0] - tri[0].color[0]).abs() < 1e-6), "one face, one light");
@@ -16184,8 +16186,8 @@ mod tests {
         assert!(state.dialog_visible());
         assert_eq!(state.slots.dialog.mode, Mode::AddNode);
         // Inside the bundled project's Geometry node: every template but the
-        // two that stand at the root.
-        assert_eq!(state.slots.dialog.rows.len(), state.node_templates.len() - 2);
+        // three that stand at the root.
+        assert_eq!(state.slots.dialog.rows.len(), state.node_templates.len() - 3);
         assert!(
             state.slots.dialog.rows.iter().all(|r| r.chord.is_empty()),
             "a template has no chord to teach"
@@ -16271,6 +16273,58 @@ mod tests {
         assert_eq!(input_of(&state, "c").as_deref(), Some(mid.as_str()), "{gen} did not cut the wire");
     }
 
+    /// The Environment node is the scene's light, for both views: its sun
+    /// direction lights the raster shading (and the smooth bake) and the
+    /// tracer's sky; with none, or with it bypassed, the defaults do, which
+    /// are the template's — so adding one changes nothing until a row
+    /// moves. It stands at the root, and its rows follow the frame.
+    #[test]
+    fn the_environment_node_lights_both_views() {
+        use crate::environment::{sun_direction, Environment};
+        let templates = crate::app::load_fs_tree();
+        let template = templates.children.iter().find(|t| t.name == "Environment").expect("an Environment template");
+        assert_eq!(Environment::of_node(template), Environment::default(), "the template says what the defaults say");
+        let old_sun = Vec3::new(0.45, 0.75, 0.35).normalize();
+        assert!(Environment::default().sun_direction.angle_between(old_sun) < 1.0f32.to_radians(), "the default sun is the tracer's old one");
+        assert!((sun_direction(90.0, 0.0) - Vec3::X).length() < 1e-5 && (sun_direction(0.0, 90.0) - Vec3::Y).length() < 1e-5);
+
+        // At the root, through MCP.
+        let mut state = State::new(false);
+        state.current_path.clear();
+        state.on_path_changed();
+        state.smooth_shading = true;
+        state.rebuild_scene_geometry();
+        assert_eq!(state.environment, Environment::default());
+        let colours = |state: &State| state.scene_smooth_verts.iter().map(|v| v.color).collect::<Vec<_>>();
+        let baked = colours(&state);
+        assert!(!baked.is_empty());
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Environment".into(), name: None, x: 3.0, y: 0.0 }, &mut redraw).unwrap();
+        assert!(!state.sync_environment(), "adding one changes nothing");
+        let slot = state.current_dir().children.iter().position(|c| c.node_type == "environment").unwrap();
+        state.apply_action(McpAction::SetParam { slot, name: "sun_elevation".into(), value: "-40".into() }, &mut redraw).unwrap();
+        state.sync_environment();
+        assert!(state.environment.sun_direction.y < -0.5, "lit from below now: {:?}", state.environment.sun_direction);
+        assert_eq!(state.environment.to_rt().sun_direction, state.environment.sun_direction.to_array(), "the tracer's sun is the same one");
+        assert_ne!(colours(&state), baked, "the smooth bake is lit by it");
+
+        // Bypassed, it is as if it were not there.
+        state.set_bypassed(&[slot], true);
+        state.sync_environment(); // the bypass's own rebuild has already read it
+        assert_eq!(state.environment, Environment::default());
+        assert_eq!(colours(&state), baked);
+
+        // Its rows evaluate at the frame.
+        state.set_bypassed(&[slot], false);
+        let node = &mut state.current_dir_mut().children[slot];
+        let az = node.params.iter_mut().find(|p| p.name == "sun_azimuth").unwrap();
+        az.set_text("$F * 10".to_string());
+        az.set_expr(true);
+        let at = |frame| Environment::of_scene(&state.fs_root, frame).sun_direction;
+        assert!((at(9) - sun_direction(90.0, -40.0)).length() < 1e-4, "{:?}", at(9));
+        assert!((at(0) - sun_direction(0.0, -40.0)).length() < 1e-4);
+    }
+
     /// The Add Node list offers what may stand at the level (since
     /// 2026-10-02, `context`): at the root, the object level, the Geometry
     /// node, cameras and the page nodes, and no operator; inside a Geometry
@@ -16294,7 +16348,7 @@ mod tests {
         for operator in ["Sphere", "Box", "Grid", "Subnet", "Simnet", "Embryo", "Page", "Export"] {
             assert!(here.iter().any(|l| l == operator), "{operator} missing inside: {here:?}");
         }
-        assert!(!here.iter().any(|l| l == "Geometry" || l == "Camera"), "{here:?}");
+        assert!(!here.iter().any(|l| l == "Geometry" || l == "Camera" || l == "Environment"), "{here:?}");
         state.close_dialog();
 
         // At the root.
@@ -16303,7 +16357,7 @@ mod tests {
         state.open_node_palette();
         let mut root = labels(&state);
         root.sort();
-        assert_eq!(root, ["Camera", "Export", "Geometry", "Page", "Page Border", "Page Grid", "Page Shape", "Page Text"]);
+        assert_eq!(root, ["Camera", "Environment", "Export", "Geometry", "Page", "Page Border", "Page Grid", "Page Shape", "Page Text"]);
         state.close_dialog();
 
         // MCP: an operator at the root is refused, with why.
diff --git a/src/render.rs b/src/render.rs
index c532d4f..047b27c 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1236,6 +1236,23 @@ impl State {
     /// Show the scene last evaluated again, under the visualizers as they
     /// now stand — what an edit to one runs. Nothing before the first
     /// evaluation.
+    /// Read the scene's environment again (`crate::environment`), and when
+    /// it moved, redraw: a smooth-shaded fill re-bakes its light from the
+    /// scene as last evaluated, which evaluates nothing. Run from the tick,
+    /// so a sun driven by `$F`, or an edit that rebuilt nothing, is seen.
+    pub(crate) fn sync_environment(&mut self) -> bool {
+        let env = crate::environment::Environment::of_scene(&self.fs_root, self.sim_frame());
+        if env == self.environment {
+            return false;
+        }
+        self.environment = env;
+        if self.smooth_shading {
+            self.revisualize();
+        }
+        self.viewport_dirty = true;
+        true
+    }
+
     pub(crate) fn revisualize(&mut self) {
         if let Some(base) = self.scene_base.clone() {
             self.present_scene(base);
@@ -1253,8 +1270,13 @@ impl State {
         // `verts` stays unlit for the path tracer, whose materials are
         // these colours. Same triangles in the same order, so the count
         // above serves both.
-        self.scene_smooth_verts =
-            if self.smooth_shading { crate::geometry::smooth_lit_vertices(&geom) } else { Vec::new() };
+        // Lit by the environment's sun, which the flat shader is handed too.
+        self.environment = crate::environment::Environment::of_scene(&self.fs_root, self.sim_frame());
+        self.scene_smooth_verts = if self.smooth_shading {
+            crate::geometry::smooth_lit_vertices(&geom, self.environment.sun_direction)
+        } else {
+            Vec::new()
+        };
         // Cache for the path tracer, so RT mode never re-runs the node
         // graph; the version bump invalidates its scene.
         // The raster mesh uploads from this same cache on the next
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 6597353..4aeee59 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -85,6 +85,8 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
         _ => None,
     };
     off.set_scene_with_image(&tris, &mats, image);
+    // The project's Environment node, at the frame rendered.
+    off.set_environment(crate::environment::Environment::of_scene(&proj.root, frame.unwrap_or(0)).to_rt());
     // Behind the scene, the Background Color the project was saved with, as
     // the traced viewport shows it; the sky, which still lights the scene,
     // for a save without display settings.