GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: cce-relief predicts pixels — the real shading model, shared
The section drew its lighting with a stand-in: `lit = dot(n, l) * 0.35`,
sharing nothing with the shader but a light azimuth. That draws geometry
honestly and shading not at all, so the tool could not answer the question
you most often open it to ask — does this wall read hot.
scene::relief_shade is the free-carve branch of shader2d.wgsl in Rust:
carve_slope, roll_spec, the ambient/diffuse/specular/curvature terms, and
the alpha composite. cce-relief now runs each of a shape's carves through
it and composites them IN EMISSION ORDER into a strip under the section, so
a composed shape gets one pass per carve exactly as it would in a frame.
The section says what the shape is; the strip says what it will look like.
Drift is the hazard, since WGSL and Rust cannot share a function body.
Three defences, in descending order of how much they actually buy:
- The light vector and material now live in relief_shade and the RENDERER
reads them from there (window_runner's plate_light/plate_mat). One
definition, not two that agree today.
- A unit test parses PLATE_AMBIENT and RECESS_DEPTH out of the shader's own
source text and asserts the Rust constants match.
- Property tests: flat ground must composite to exactly zero (or the cover
quad tints everything it covers), and ridge/trough must oppose where the
wall is steep.
That last test failed as first written, and the assertion was wrong rather
than the model: the response has an EVEN component. Tilting a surface
either way shortens the face-on n.z term and adds a non-negative specular,
so near the plateau lips a ridge and a trough BOTH darken slightly. It is
now asserted only where the wall is steep, with the reason written down —
that faint lip lobe is real, and I had already puzzled over it in pixel
measurements without recognising it.
The strip composites over page_low_color, not a swatch grey, because the
composite is asymmetric: brightening screens toward white, darkening
multiplies toward black, so which lobe dominates depends on the base and
INVERTS between a dark plate and a light one. Over my first (pale) base the
strip reported dark-dominant where the real frame is bright-dominant ~4:1.
Measured on the DE's plate it now reads +85/-25, matching the +91/-20 taken
off a real rendering.
Also fixed, found while wiring this: the knob row was laid out on
`profile_dropdown.selected == 0`, which was right when index 0 WAS the wall
profile and wrong the moment the picker listed shapes — layout positioned
one knob set while paint drew the other, parking all three sliders
off-screen. Both sides now read active_shape()/active_curve().
src/backend/window_runner.rs | 11 +-
src/bin/cce-relief.rs | 153 +++++++++++++++++++++++++--
src/scene/mod.rs | 1 +
src/scene/relief_shade.rs | 242 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 392 insertions(+), 15 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 6b506f8..b320dff 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1574,17 +1574,16 @@ pub fn tessellate_display_list(
// SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
// shading, plus the frame-constant lighting inputs it pushes per plate.
let shader_plates = crate::layout::bevel_shader();
- let plate_light = {
- let az = crate::layout::light_source_position();
- let el = std::f32::consts::FRAC_PI_4; // light elevation above the screen plane
- [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
- };
+ // Light and material come from `scene::relief_shade`, which is also what
+ // cce-relief predicts pixels with — one definition, so the editor cannot
+ // draw a different material than the renderer applies.
+ let plate_light = crate::scene::relief_shade::light_vector();
// [shading strength (1.0 at the default bevel_depth), specular strength,
// shininess, curvature/AO strength] — the plastic material. Curvature is
// kept near the raised path's crest amplitude: the recess shoulder's
// brightening lands on the same pixels as its specular line, and the two
// stack — at 0.5 the step read several times hotter than a plate roll.
- let plate_mat = [crate::layout::bevel_depth() / 0.15, 0.4, 24.0, 0.2];
+ let plate_mat = crate::scene::relief_shade::Material::from_style().to_array();
for item in &dl.items {
let start = verts.len() as u32;
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 28d6814..23f046f 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -9,7 +9,16 @@
//! reserves "bevel" for the `Bevel` prim and the shared edge treatment, and
//! calls the family relief primitives — which is also what `control_relief`
//! gates and what the `relief` config node is called. This was `cce-bevel`
-//! until the shape picker landed and made the mismatch untenable. Every edit applies live to this process (the
+//! until the shape picker landed and made the mismatch untenable.
+//!
+//! Under the section runs the SHADING STRIP: the same shape put through
+//! `scene::relief_shade`, the shader's own arithmetic in Rust, composited one
+//! carve at a time in emission order. The section says what the shape is; the
+//! strip says what it will look like. A composed shape gets one pass per carve
+//! there, which is how a stack whose geometry looks reasonable can still read
+//! hot.
+//!
+//! Every edit applies live to this process (the
//! popup's own plate, wells, and buttons ARE the preview) and logs the
//! sampled spec to stdout; Save persists to `~/.config/cce/config.kdl`
//! (`style.surface.relief`) so every cce app starts with the material —
@@ -28,6 +37,7 @@ use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, Win
use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
+use cce_ui::scene::relief_shade::{self, CarveMode};
use cce_ui::widget::{
Adapted, Button, Dropdown, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta,
Slider, WidgetHost, WidgetId,
@@ -56,6 +66,9 @@ const GUTTER_L: f32 = 34.0;
const GUTTER_B: f32 = 16.0;
const MIN_BAND: f32 = 36.0;
const UNDERSIDE: f32 = 18.0;
+/// Height of the shading strip under the section — the band that shows what
+/// the shape will actually LOOK like, as opposed to what it is.
+const SHADE_STRIP_H: f32 = 14.0;
#[derive(Debug, Clone)]
enum BevelMsg {
@@ -202,6 +215,34 @@ impl Shape {
})
}
+ /// The carve(s) this shape emits, as (mode, run start, run end). The
+ /// shading strip composites these IN ORDER, exactly as the renderer
+ /// composites one prim's cover quad over another's — which is the whole
+ /// reason a stacked shape can read hot while its geometry looks fine.
+ ///
+ /// The plate's edge roll returns nothing: it is the shader's plate branch
+ /// (fill + roll + CSG features), not the free-carve branch this models, so
+ /// the strip says so rather than inventing a number.
+ fn walls(self) -> Vec<(CarveMode, f32, f32)> {
+ match self {
+ Shape::Recess => vec![(CarveMode::Recess, 0.0, 1.0)],
+ Shape::Boss => vec![(CarveMode::Boss, 0.0, 1.0)],
+ Shape::Ridge => vec![(CarveMode::Ridge, 0.0, 1.0)],
+ Shape::Trough | Shape::InsetPlate => vec![(CarveMode::Trough, 0.0, 1.0)],
+ // The groove rejoins the free-carve path as a recess on |distance to
+ // the line| - halfwidth; across the section that is a trough spread
+ // over the wider run.
+ Shape::Groove => vec![(CarveMode::Trough, 0.0, 1.0 + GROOVE_FLOOR)],
+ // The fillet rejoins the shared path as its flat equivalent.
+ Shape::Fillet => vec![(CarveMode::Recess, 0.0, 1.0)],
+ Shape::EdgeRoll => Vec::new(),
+ // The pre-35f5183 pair, in emission order.
+ Shape::InsetStacked => {
+ vec![(CarveMode::Recess, 0.0, 1.0), (CarveMode::Boss, 0.5, 1.5)]
+ }
+ }
+ }
+
/// For a composed shape, the run interval where TWO walls are live at once
/// — the band that gets two lighting evaluations instead of one, and so the
/// band that reads hot. `None` for a primitive shape, which has one wall
@@ -361,8 +402,14 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
let x_l = rect.x + m + GUTTER_L;
let x_r = rect.x + rect.width - m;
let avail_w = x_r - x_l;
+ // The shading strip owns a reserved band along the bottom of the opening,
+ // and the SECTION lays out in what is left. Reserving it up front is what
+ // keeps the slab from expanding over it — the slab grows to the bottom of
+ // whatever area it is given.
+ let strip_band = SHADE_STRIP_H + 4.0;
+ let sec_h = rect.height - strip_band;
// stroke + slab-underside room, plus the bottom gutter
- let avail_h = rect.height - 2.0 * m - UNDERSIDE - GUTTER_B;
+ let avail_h = sec_h - 2.0 * m - UNDERSIDE - GUTTER_B;
let run = shape.run();
// Vertical extent of THIS shape, sampled — a ridge lives above the surface,
@@ -388,7 +435,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
// y of h = 0 (the surrounding surface), placed so the whole excursion fits.
let drawn_h = h_span * unit;
let y_zero = rect.y
- + ((rect.height - drawn_h - UNDERSIDE - GUTTER_B) / 2.0).max(m)
+ + ((sec_h - drawn_h - UNDERSIDE - GUTTER_B) / 2.0).max(m)
- h_lo * unit;
let y_top = y_zero + h_lo * unit;
let y_bot = y_zero + h_hi * unit;
@@ -459,7 +506,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
slab = [slab[0] * 1.25 + 0.03, slab[1] * 1.25 + 0.03, slab[2] * 1.25 + 0.03, 1.0];
// A fixed slab thickness under the lowest surface, so vertical centering
// doesn't grow a bottomless block of material.
- let slab_bot = (y_bot + 16.0).min(rect.y + rect.height - 8.0);
+ let slab_bot = (y_bot + 16.0).min(rect.y + sec_h - 8.0);
let step = 2.0f32;
let mut x = x_l;
while x < x_r {
@@ -488,6 +535,76 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
rt += 0.25;
}
+ // THE SHADING STRIP: what the shader will actually put on screen along
+ // this section, as opposed to the geometry drawn above it.
+ //
+ // Each of the shape's carves is evaluated with the real model and
+ // composited in emission order onto the surface colour, so a shape that
+ // emits two overlapping walls gets two passes here exactly as it would in
+ // the frame. That is the difference the geometry cannot show: the stacked
+ // inset's dip is only half again as deep as the trough's, but its strip is
+ // visibly hotter, because the overlap region is lit twice.
+ let strip_h = SHADE_STRIP_H;
+ let strip_y = rect.y + rect.height - strip_band + 2.0;
+ {
+ let walls = shape.walls();
+ let light = relief_shade::light_vector();
+ let mat = relief_shade::Material::from_style();
+ // The installed profile's slope, so the strip follows the knobs: the
+ // renderer differentiates the same height curve into its LUT.
+ let slope_at = |v: f32| -> f32 {
+ let d = 1.0 / 32.0;
+ let (a, b) = ((v - d * 0.5).clamp(0.0, 1.0), (v + d * 0.5).clamp(0.0, 1.0));
+ let taper = (v.min(1.0 - v) * 32.0 * 0.667).clamp(0.0, 1.0);
+ if b <= a { 0.0 } else { (profile.eval(b) - profile.eval(a)) / (b - a) * taper }
+ };
+ // The DE's own plate colour, NOT a swatch grey. The composite is
+ // asymmetric — brightening screens toward white, darkening multiplies
+ // toward black — so which lobe dominates depends on how light the
+ // surface under it is, and it INVERTS between a dark plate and a light
+ // one. Drawn over the wrong base the strip reverses the very thing you
+ // came to judge: on this plate a wall's bright side out-measures its
+ // dark side about 4:1, and over a pale swatch it reads the other way.
+ let plate = cce_ui::color::page_low_color();
+ let surface = [plate[0], plate[1], plate[2]];
+ let step = 1.0f32;
+ let mut x = x_l;
+ while x < x_r {
+ let t = (x - x0) / unit;
+ let mut c = surface;
+ for (mode, w0, w1) in &walls {
+ let u = (t - w0) / (w1 - w0);
+ if !(-0.02..=1.02).contains(&u) {
+ continue;
+ }
+ // Facing: the SDF gradient along the section, pointing out of
+ // the carve. The section is drawn descending to the right, so
+ // that is -x. The OTHER three walls of a real rect face other
+ // ways and shade differently — the same profile reads brighter
+ // on one side of a control than the other, which is why a
+ // seam's two rims never match.
+ let v = relief_shade::carve_shade(*mode, u, [-1.0, 0.0], &slope_at, light, &mat);
+ c = relief_shade::composite(c, v);
+ }
+ pc.quad(
+ Rect { x, y: strip_y, width: step.min(x_r - x), height: strip_h },
+ [c[0], c[1], c[2], 1.0],
+ );
+ x += step;
+ }
+ if walls.is_empty() {
+ pc.text_with(
+ "plate branch — not the free-carve model".to_string(),
+ x_l + 4.0,
+ strip_y + 1.0,
+ 10.0,
+ num_color,
+ Some("monospace".to_string()),
+ None,
+ );
+ }
+ }
+
// The surface stroke, lit per segment: outward normal (material below)
// against the DE light azimuth — the same light the real walls shade by.
let az = cce_ui::layout::light_source_position();
@@ -524,6 +641,20 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
}
impl BevelPopup {
+ /// The selected shape. The dropdown index is the ONLY source; read it
+ /// through here so layout and paint cannot disagree about it.
+ fn active_shape(&self) -> Shape {
+ Shape::ALL
+ .get(self.profile_dropdown.selected)
+ .copied()
+ .unwrap_or(Shape::Recess)
+ }
+
+ /// The curve the knobs are editing for the selected shape.
+ fn active_curve(&self) -> Curve {
+ self.active_shape().curve()
+ }
+
fn root_ids(&self) -> [WidgetId; 11] {
[
self.profile_dropdown.id(),
@@ -860,6 +991,8 @@ impl Application for BevelPopup {
let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
+ 2.0 * CUT_MARGIN
+ UNDERSIDE
+ + GUTTER_B
+ + SHADE_STRIP_H
+ GUTTER_B;
let cut_h = (self.height as f32 - 2.0 * pad - fixed).min(natural).max(90.0);
@@ -880,7 +1013,12 @@ impl Application for BevelPopup {
y += knob_h + gap;
self.cut_rect = Rect { x, y, width: w, height: cut_h };
y += cut_h + gap;
- if self.profile_dropdown.selected == 0 {
+ // Keyed off the SHAPE's curve, never the dropdown index — several
+ // shapes share the Wall curve, and this has to agree with the paint
+ // side's `wall_active` or the row is laid out for one set and drawn
+ // from the other, which parks every knob off-screen and looks like
+ // the sliders vanished.
+ if self.active_curve() == Curve::Wall {
knob_row(&mut self.wall, x, y);
park(&mut self.edge);
} else {
@@ -933,10 +1071,7 @@ impl Application for BevelPopup {
None,
);
- let shape = Shape::ALL
- .get(self.profile_dropdown.selected)
- .copied()
- .unwrap_or(Shape::Recess);
+ let shape = self.active_shape();
let wall_active = shape.curve() == Curve::Wall;
let active = if wall_active { &self.wall } else { &self.edge };
draw_section(&mut pc, self.cut_rect, active, shape);
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 8d7daa0..6e40385 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -10,6 +10,7 @@ pub mod arena;
pub mod layout;
pub mod paint;
pub mod painter;
+pub mod relief_shade;
pub mod tree;
pub use arena::{Arena, Node, NodeId};
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
new file mode 100644
index 0000000..3f559f6
--- /dev/null
+++ b/src/scene/relief_shade.rs
@@ -0,0 +1,242 @@
+//! The relief shading model in Rust — the arithmetic `shader2d.wgsl`'s
+//! free-carve branch performs per pixel, so code outside the GPU can PREDICT
+//! the pixels instead of sketching them.
+//!
+//! This exists because `cce-relief` drew its cross-sections with a stand-in
+//! (`lit = dot(normal, light) * 0.35`) that shares nothing with the shader but
+//! a light azimuth. A section drawn that way shows the geometry honestly and
+//! the shading not at all — it cannot tell you that a wall reads hot, which is
+//! the single most common thing you go to the editor to judge.
+//!
+//! **Drift is the hazard**, since WGSL and Rust cannot share a function body.
+//! Two defences: the light vector and material live HERE and the renderer
+//! reads them from here (`window_runner`'s `plate_light`/`plate_mat`), and the
+//! constants below are checked against the shader's own source text by a unit
+//! test. Anything that is only a comment away from disagreeing is not shared.
+
+/// Ambient floor of the plate lighting model. Mirrors `PLATE_AMBIENT`.
+pub const PLATE_AMBIENT: f32 = 0.55;
+/// Recess depth as a fraction of the roll width. Mirrors `RECESS_DEPTH`.
+pub const RECESS_DEPTH: f32 = 0.6;
+
+/// The free-carve modes, matching the shader's `MODE_*` for the branch this
+/// module reproduces. The plate's own perimeter roll (mode 1) is a different
+/// branch and is deliberately not modelled here.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum CarveMode {
+ Recess,
+ Boss,
+ Ridge,
+ Trough,
+}
+
+/// The plastic material: `[shading strength, specular strength, shininess,
+/// curvature/AO strength]` as carried in `PlatePush::mat`.
+#[derive(Clone, Copy, Debug)]
+pub struct Material {
+ pub strength: f32,
+ pub spec: f32,
+ pub shininess: f32,
+ pub curvature: f32,
+}
+
+impl Material {
+ /// The DE's material, strength tracking `bevel_depth` against the default.
+ /// This is the ONE definition — the renderer's push constants come from
+ /// here too.
+ pub fn from_style() -> Self {
+ Self {
+ strength: crate::layout::bevel_depth() / 0.15,
+ spec: 0.4,
+ shininess: 24.0,
+ curvature: 0.2,
+ }
+ }
+
+ pub fn to_array(self) -> [f32; 4] {
+ [self.strength, self.spec, self.shininess, self.curvature]
+ }
+}
+
+/// The DE's light as a unit vector in screen space (+z out of the screen), at
+/// the fixed 45° elevation the renderer uses. The ONE definition, as with
+/// [`Material::from_style`].
+pub fn light_vector() -> [f32; 3] {
+ let az = crate::layout::light_source_position();
+ let el = std::f32::consts::FRAC_PI_4;
+ [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
+}
+
+/// Shading of the flat face — the denominator every carve is expressed
+/// relative to, so an untouched surface composites to exactly nothing.
+pub fn flat_shade(light: [f32; 3]) -> f32 {
+ PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * light[2]
+}
+
+/// The analytic carve slope: smoothstep normally, smootherstep under a
+/// continuous-curvature `corner_shape`. Mirrors `carve_slope`'s analytic
+/// branch; a custom profile replaces it with the LUT, which callers model by
+/// passing their own slope function to [`carve_shade`].
+pub fn analytic_carve_slope(v: f32) -> f32 {
+ if crate::layout::corner_shape() > 2.001 {
+ let w = v * (1.0 - v);
+ 30.0 * w * w
+ } else {
+ 6.0 * v * (1.0 - v)
+ }
+}
+
+/// Specular term of a tilted surface under the DE light. Mirrors `roll_spec`.
+pub fn roll_spec(sv: [f32; 2], light: [f32; 3], mat: &Material) -> f32 {
+ let m = (sv[0] * sv[0] + sv[1] * sv[1]).sqrt();
+ if m < 1e-5 {
+ return 0.0;
+ }
+ let hv = {
+ let h = [light[0], light[1], light[2] + 1.0];
+ let n = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt().max(1e-6);
+ [h[0] / n, h[1] / n, h[2] / n]
+ };
+ let facing = [sv[0] / m, sv[1] / m];
+ let cos_t = 1.0 / (1.0 + m * m).sqrt();
+ let sin_t = m * cos_t;
+ let hxy = (hv[0] * hv[0] + hv[1] * hv[1]).sqrt();
+ let prof = cos_t * hv[2] + sin_t * hxy;
+ let az = ((facing[0] * hv[0] + facing[1] * hv[1]) / hxy.max(1e-4)).clamp(0.0, 1.0);
+ mat.spec * (prof.powf(mat.shininess) - hv[2].powf(mat.shininess)).max(0.0) * az * az
+}
+
+/// The signed shading value one carve contributes at `u` across its wall —
+/// the shader's `v`, before the tint branch. Positive is a white screen over
+/// what is beneath, negative a black multiply; magnitude is the alpha.
+///
+/// `facing` is the SDF gradient direction (unit, pointing OUT of the carve's
+/// box). `slope_at` is the profile's slope function — pass
+/// [`analytic_carve_slope`] for the default material, or the derivative of a
+/// custom height curve to model an installed LUT.
+///
+/// `att` (the host-box roll fade) is left to the caller: it depends on where
+/// the carve sits inside its host, not on the profile.
+pub fn carve_shade(
+ mode: CarveMode,
+ u: f32,
+ facing: [f32; 2],
+ slope_at: &dyn Fn(f32) -> f32,
+ light: [f32; 3],
+ mat: &Material,
+) -> f32 {
+ let u = u.clamp(0.0, 1.0);
+ let (slope, curv) = match mode {
+ // The straddling pair: ONE profile evaluation on the folded coordinate,
+ // amplitude halved so the wall tilt matches a step's.
+ CarveMode::Ridge | CarveMode::Trough => {
+ let w = (2.0 * u).min(2.0 - 2.0 * u).clamp(0.0, 1.0);
+ let up = if mode == CarveMode::Ridge { 1.0 } else { -1.0 };
+ let rising = if u <= 0.5 { 1.0 } else { -1.0 } * up;
+ (
+ rising * 0.5 * RECESS_DEPTH * 2.0 * slope_at(w),
+ -up * mat.curvature * (w * std::f32::consts::TAU).sin(),
+ )
+ }
+ _ => {
+ let dir = if mode == CarveMode::Boss { 1.0 } else { -1.0 };
+ (
+ dir * RECESS_DEPTH * slope_at(u),
+ -dir * mat.curvature * (u * std::f32::consts::TAU).sin(),
+ )
+ }
+ };
+ let sv = [facing[0] * slope, facing[1] * slope];
+ let n = {
+ let len = (sv[0] * sv[0] + sv[1] * sv[1] + 1.0).sqrt();
+ [sv[0] / len, sv[1] / len, 1.0 / len]
+ };
+ let ndl = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]).max(0.0);
+ let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * ndl;
+ let spec = roll_spec(sv, light, mat);
+ (diff / flat_shade(light) - 1.0 + curv + spec) * mat.strength
+}
+
+/// Composite one carve's shading over what is already there, the way the
+/// renderer's alpha blend does.
+///
+/// This asymmetry is load-bearing and is why a wall's bright side always
+/// out-measures its dark side: brightening screens toward WHITE, darkening
+/// multiplies toward BLACK, so on a mid-grey surface the same |v| moves the
+/// pixel about twice as far up as down.
+pub fn composite(base: [f32; 3], v: f32) -> [f32; 3] {
+ let a = v.abs().min(1.0);
+ let target = if v >= 0.0 { 1.0f32 } else { 0.0f32 };
+ [
+ base[0] * (1.0 - a) + target * a,
+ base[1] * (1.0 - a) + target * a,
+ base[2] * (1.0 - a) + target * a,
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The shader's own source, so the constants below are checked against the
+ /// thing they mirror rather than against a comment.
+ const WGSL: &str = include_str!("../vk/shader2d.wgsl");
+
+ fn wgsl_const(name: &str) -> f32 {
+ let needle = format!("const {name}: f32 = ");
+ let rest = WGSL
+ .split(&needle)
+ .nth(1)
+ .unwrap_or_else(|| panic!("{name} not found in shader2d.wgsl"));
+ let lit: String = rest.chars().take_while(|c| *c != ';').collect();
+ lit.trim().parse().expect("numeric literal")
+ }
+
+ #[test]
+ fn constants_match_the_shader() {
+ assert_eq!(wgsl_const("PLATE_AMBIENT"), PLATE_AMBIENT);
+ assert_eq!(wgsl_const("RECESS_DEPTH"), RECESS_DEPTH);
+ }
+
+ /// A flat surface must composite to nothing, or the cover quad tints
+ /// everything it covers — the property the whole "relative to the flat
+ /// face" formulation exists to guarantee.
+ #[test]
+ fn flat_ground_shades_to_zero() {
+ let light = light_vector();
+ let mat = Material::from_style();
+ for mode in [CarveMode::Recess, CarveMode::Boss, CarveMode::Ridge, CarveMode::Trough] {
+ for u in [0.0f32, 1.0] {
+ let v = carve_shade(mode, u, [-1.0, 0.0], &analytic_carve_slope, light, &mat);
+ assert!(v.abs() < 1e-4, "{mode:?} at u={u} shaded {v}, expected 0");
+ }
+ }
+ }
+
+ /// Ridge and trough are the same wall with the height sign flipped, so
+ /// their shading is opposite WHERE THE WALL IS STEEP.
+ ///
+ /// Not everywhere, which is worth stating because it is the first thing you
+ /// would assume: the response has an EVEN component. Tilting a surface
+ /// either way shortens the face-on `n.z` term and adds a non-negative
+ /// specular, so near the plateau lips — where the directional part is
+ /// nearly nothing — a ridge and a trough both darken slightly. That is the
+ /// faint lip lobe visible on both, not an asymmetry bug.
+ #[test]
+ fn ridge_and_trough_oppose_where_the_wall_is_steep() {
+ let light = light_vector();
+ let mat = Material::from_style();
+ let sample = |m: CarveMode, u: f32| {
+ carve_shade(m, u, [-1.0, 0.0], &analytic_carve_slope, light, &mat)
+ };
+ // Steepest point of the folded profile: w = 1 at u = 0.5 is the crest
+ // (zero slope), so the extremes sit either side of it.
+ let steep = (0..=100)
+ .map(|i| i as f32 / 100.0)
+ .max_by(|a, b| sample(CarveMode::Ridge, *a).abs().total_cmp(&sample(CarveMode::Ridge, *b).abs()))
+ .unwrap();
+ let (r, t) = (sample(CarveMode::Ridge, steep), sample(CarveMode::Trough, steep));
+ assert!(r.abs() > 0.05, "ridge shading {r} at u={steep} is too faint to test");
+ assert!(r * t < 0.0, "ridge {r} and trough {t} agree in sign at u={steep}");
+ }
+}