git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/scene/relief_shade.rs (18K)

  1 //! The relief shading model in Rust — the arithmetic `shader2d.wgsl` performs
  2 //! per pixel, so code outside the GPU can PREDICT the pixels instead of
  3 //! sketching them. Both branches: the free carves ([`carve_shade`]) and the
  4 //! plate's own perimeter roll ([`plate_surface`]), which do not composite the
  5 //! same way — a carve is a translucent overlay, a plate is a multiply on its
  6 //! own fill plus an additive specular. A carve GROUPED into a plate is both:
  7 //! the plate's roll as [`plate_surface`], then what the carve adds composited
  8 //! over it as [`carve_shade`]'s value is ([`composite`]) — on the face, exactly
  9 //! the free carve.
 10 //!
 11 //! This exists because `cce-relief` drew its cross-sections with a stand-in
 12 //! (`lit = dot(normal, light) * 0.35`) that shares nothing with the shader but
 13 //! a light azimuth. A section drawn that way shows the geometry honestly and
 14 //! the shading not at all — it cannot tell you that a wall reads hot, which is
 15 //! the single most common thing you go to the editor to judge.
 16 //!
 17 //! **Drift is the hazard**, since WGSL and Rust cannot share a function body.
 18 //! Two defences: the light vector lives HERE and the finish in
 19 //! [`crate::scene::material`], and the renderer reads both from there
 20 //! (`window_runner`'s `plate_light`/`plate_mat`); and the constants below are
 21 //! checked against the shader's own source text by a unit test. Anything that
 22 //! is only a comment away from disagreeing is not shared.
 23 
 24 /// The finish — how a surface answers light — moved to `scene::material` as
 25 /// [`Finish`] (RFC material, § 11 (4)). `Material` survives here as an alias
 26 /// through step 2 so out-of-crate readers build untouched.
 27 pub use crate::scene::material::Finish;
 28 pub use crate::scene::material::Finish as Material;
 29 
 30 /// Ambient floor of the plate lighting model. Mirrors `PLATE_AMBIENT`.
 31 pub const PLATE_AMBIENT: f32 = 0.55;
 32 /// A carve's drop as a fraction of its wall width when the material pins no
 33 /// height (`layout::bevel_height`). Mirrors `RECESS_DEPTH`.
 34 pub const RECESS_DEPTH: f32 = 0.6;
 35 
 36 /// Amplitude of the bright crest hugging a raised plate's silhouette.
 37 /// Mirrors `PLATE_CREST`.
 38 pub const PLATE_CREST: f32 = 0.25;
 39 /// The far-edge shade line's strength relative to the glint. Mirrors
 40 /// `PLATE_SHADE_LINE`.
 41 pub const PLATE_SHADE_LINE: f32 = 0.5;
 42 /// The roll's descent is truncated at this fraction of the quadrant, so the
 43 /// profile ends on a bounded slope instead of plunging vertical at the
 44 /// silhouette. Mirrors `ROLL_CUT`.
 45 pub const ROLL_CUT: f32 = 0.8;
 46 
 47 /// The free-carve modes, matching the shader's `MODE_*`.
 48 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 49 pub enum CarveMode {
 50     Recess,
 51     Boss,
 52     Ridge,
 53     Trough,
 54 }
 55 
 56 /// The DE's light as a unit vector in screen space (+z out of the screen), at
 57 /// the fixed 45° elevation the renderer uses. The ONE definition, as with
 58 /// [`Finish::from_style`].
 59 pub fn light_vector() -> [f32; 3] {
 60     let az = crate::layout::light_source_position();
 61     let el = std::f32::consts::FRAC_PI_4;
 62     [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
 63 }
 64 
 65 /// Shading of the flat face — the denominator every carve is expressed
 66 /// relative to, so an untouched surface composites to exactly nothing.
 67 pub fn flat_shade(light: [f32; 3]) -> f32 {
 68     PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * light[2]
 69 }
 70 
 71 /// The analytic carve slope: smoothstep normally, smootherstep under a
 72 /// continuous-curvature `corner_shape`. Mirrors `carve_slope`'s analytic
 73 /// branch; a custom profile replaces it with the LUT, which callers model by
 74 /// passing their own slope function to [`carve_shade`].
 75 pub fn analytic_carve_slope(v: f32) -> f32 {
 76     if crate::layout::corner_shape() > 2.001 {
 77         let w = v * (1.0 - v);
 78         30.0 * w * w
 79     } else {
 80         6.0 * v * (1.0 - v)
 81     }
 82 }
 83 
 84 /// Specular term of a tilted surface under the DE light. Mirrors `roll_spec`.
 85 pub fn roll_spec(sv: [f32; 2], light: [f32; 3], mat: &Finish) -> f32 {
 86     let m = (sv[0] * sv[0] + sv[1] * sv[1]).sqrt();
 87     if m < 1e-5 {
 88         return 0.0;
 89     }
 90     let hv = {
 91         let h = [light[0], light[1], light[2] + 1.0];
 92         let n = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt().max(1e-6);
 93         [h[0] / n, h[1] / n, h[2] / n]
 94     };
 95     let facing = [sv[0] / m, sv[1] / m];
 96     let cos_t = 1.0 / (1.0 + m * m).sqrt();
 97     let sin_t = m * cos_t;
 98     let hxy = (hv[0] * hv[0] + hv[1] * hv[1]).sqrt();
 99     let prof = cos_t * hv[2] + sin_t * hxy;
100     let az = ((facing[0] * hv[0] + facing[1] * hv[1]) / hxy.max(1e-4)).clamp(0.0, 1.0);
101     mat.spec * (prof.powf(mat.shininess) - hv[2].powf(mat.shininess)).max(0.0) * az * az
102 }
103 
104 /// The glint's dark counterpart: [`roll_spec`] under the light's azimuth
105 /// mirrored, so the same lobe lands on the edges facing away from the light,
106 /// scaled by [`PLATE_SHADE_LINE`]. Mirrors `roll_shade_line`. Subtracted in
107 /// colour units where the glint is added.
108 pub fn roll_shade_line(sv: [f32; 2], light: [f32; 3], mat: &Finish) -> f32 {
109     roll_spec(sv, [-light[0], -light[1], light[2]], mat) * PLATE_SHADE_LINE
110 }
111 
112 /// The signed shading value one carve contributes at `u` across its wall —
113 /// the shader's `v`, before the tint branch. Positive is a white screen over
114 /// what is beneath, negative a black multiply; magnitude is the alpha.
115 ///
116 /// `facing` is the SDF gradient direction (unit, pointing OUT of the carve's
117 /// box). `slope_at` is the profile's slope function — pass
118 /// [`analytic_carve_slope`] for the default material, or the derivative of a
119 /// custom height curve to model an installed LUT.
120 ///
121 /// `att` (the host-box roll fade) is left to the caller: it depends on where
122 /// the carve sits inside its host, not on the profile.
123 pub fn carve_shade(
124     mode: CarveMode,
125     u: f32,
126     facing: [f32; 2],
127     slope_at: &dyn Fn(f32) -> f32,
128     light: [f32; 3],
129     mat: &Finish,
130 ) -> f32 {
131     let u = u.clamp(0.0, 1.0);
132     let (slope, curv) = match mode {
133         // The straddling pair: ONE profile evaluation on the folded coordinate,
134         // amplitude halved so the wall tilt matches a step's.
135         CarveMode::Ridge | CarveMode::Trough => {
136             let w = (2.0 * u).min(2.0 - 2.0 * u).clamp(0.0, 1.0);
137             let up = if mode == CarveMode::Ridge { 1.0 } else { -1.0 };
138             let rising = if u <= 0.5 { 1.0 } else { -1.0 } * up;
139             (
140                 rising * 0.5 * mat.carve_depth * 2.0 * slope_at(w),
141                 -up * mat.curvature * (w * std::f32::consts::TAU).sin(),
142             )
143         }
144         _ => {
145             let dir = if mode == CarveMode::Boss { 1.0 } else { -1.0 };
146             (
147                 dir * mat.carve_depth * slope_at(u),
148                 -dir * mat.curvature * (u * std::f32::consts::TAU).sin(),
149             )
150         }
151     };
152     let sv = [facing[0] * slope, facing[1] * slope];
153     let n = {
154         let len = (sv[0] * sv[0] + sv[1] * sv[1] + 1.0).sqrt();
155         [sv[0] / len, sv[1] / len, 1.0 / len]
156     };
157     let ndl = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]).max(0.0);
158     let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * ndl;
159     let spec = roll_spec(sv, light, mat);
160     (diff / flat_shade(light) - 1.0 + curv + spec) * mat.strength
161 }
162 
163 /// The analytic roll slope at `f` (0 where the roll meets the face, 1 at the
164 /// silhouette). Mirrors `roll_slope`'s analytic branch: the one-exponent
165 /// generalisation of the circular quadrant, truncated at [`ROLL_CUT`].
166 pub fn analytic_roll_slope(f: f32) -> f32 {
167     let shape = crate::layout::corner_shape();
168     let fc = f * ROLL_CUT;
169     if shape > 2.001 {
170         let h = (1.0 - fc.powf(shape)).max(1e-4).powf(1.0 / shape);
171         (fc / h).powf(shape - 1.0)
172     } else {
173         fc / (1.0 - fc * fc).max(1e-4).sqrt()
174     }
175 }
176 
177 /// How squarely a rim faces the light's azimuth, 0..1 — the weight on the
178 /// plate crest. Mirrors `crest_weight`. The crest used to be a flat
179 /// `PLATE_CREST` on every side, which out-measured the far edges' diffuse
180 /// fall-off everywhere along the roll, so a raised plate had a lit rim and no
181 /// shadowed one; weighted, the down-light edges keep only their diffuse
182 /// shading and read as the glint's dark counterpart. A light from straight
183 /// overhead has no near or far side and keeps the crest everywhere.
184 pub fn crest_weight(facing: [f32; 2], light: [f32; 3]) -> f32 {
185     let m = (light[0] * light[0] + light[1] * light[1]).sqrt();
186     if m < 1e-4 {
187         return 1.0;
188     }
189     ((facing[0] * light[0] + facing[1] * light[1]) / m).max(0.0)
190 }
191 
192 /// The plate's own surface colour across its perimeter roll — mode 1, which is
193 /// NOT the free-carve branch and does not composite like one.
194 ///
195 /// A carve emits a translucent overlay; a plate emits its material directly, as
196 /// a MULTIPLY on the face colour plus an additive specular. Expressed relative
197 /// to the flat face (shade 1.0, specular 0.0) so the face keeps exactly the
198 /// app's chosen colour — which is why a plate can be tinted freely and a carve
199 /// cannot.
200 ///
201 /// `f` is 0 where the roll meets the face and 1 at the silhouette. Returns
202 /// `None` past the silhouette, where the shader discards.
203 pub fn plate_surface(
204     base: [f32; 3],
205     f: f32,
206     facing: [f32; 2],
207     roll_slope_at: &dyn Fn(f32) -> f32,
208     light: [f32; 3],
209     mat: &Finish,
210 ) -> Option<[f32; 3]> {
211     if !(0.0..=1.0).contains(&f) {
212         return None;
213     }
214     let slope = roll_slope_at(f) * mat.roll_height;
215     let sv = [facing[0] * slope, facing[1] * slope];
216     let n = {
217         let len = (sv[0] * sv[0] + sv[1] * sv[1] + 1.0).sqrt();
218         [sv[0] / len, sv[1] / len, 1.0 / len]
219     };
220     let ndl = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]).max(0.0);
221     let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * ndl;
222     // The crest: the ambient-catching convex rim that makes glass read as glass.
223     let extra = PLATE_CREST * f * f * f * crest_weight(facing, light);
224     let shade = 1.0 + (diff / flat_shade(light) - 1.0 + extra) * mat.strength;
225     let spec = (roll_spec(sv, light, mat) - roll_shade_line(sv, light, mat)) * mat.strength;
226     Some([
227         (base[0] * shade + spec).clamp(0.0, 1.0),
228         (base[1] * shade + spec).clamp(0.0, 1.0),
229         (base[2] * shade + spec).clamp(0.0, 1.0),
230     ])
231 }
232 
233 /// Composite one carve's shading over what is already there, the way the
234 /// renderer's alpha blend does.
235 ///
236 /// This asymmetry is load-bearing and is why a wall's bright side always
237 /// out-measures its dark side: brightening screens toward WHITE, darkening
238 /// multiplies toward BLACK, so on a mid-grey surface the same |v| moves the
239 /// pixel about twice as far up as down.
240 pub fn composite(base: [f32; 3], v: f32) -> [f32; 3] {
241     let a = v.abs().min(1.0);
242     let target = if v >= 0.0 { 1.0f32 } else { 0.0f32 };
243     [
244         base[0] * (1.0 - a) + target * a,
245         base[1] * (1.0 - a) + target * a,
246         base[2] * (1.0 - a) + target * a,
247     ]
248 }
249 
250 #[cfg(test)]
251 mod tests {
252     use super::*;
253 
254     /// The shader's own source, so the constants below are checked against the
255     /// thing they mirror rather than against a comment.
256     const WGSL: &str = include_str!("../draw/shader2d.wgsl");
257 
258     fn wgsl_const(name: &str) -> f32 {
259         let needle = format!("const {name}: f32 = ");
260         let rest = WGSL
261             .split(&needle)
262             .nth(1)
263             .unwrap_or_else(|| panic!("{name} not found in shader2d.wgsl"));
264         let lit: String = rest.chars().take_while(|c| *c != ';').collect();
265         lit.trim().parse().expect("numeric literal")
266     }
267 
268     /// What these tests shade with: the toolkit's defaults, spelled out —
269     /// the default light azimuth, the default finish, the smoothstep wall and
270     /// the circular roll of a corner_shape of 2. NOT `light_vector()`,
271     /// `Finish::from_style()` or the `analytic_*` slopes, which read the
272     /// machine's `~/.config/cce`, and read it LAZILY: the style registry loads
273     /// on the first read that asks for it, so a value taken before it and one
274     /// taken after came from two different configurations.
275     /// `deeper_carve_shades_harder` compared exactly such a pair, and failed
276     /// run alone while passing in the full suite (until 2026-10-02).
277     fn pinned_light() -> [f32; 3] {
278         let (az, el) = (2.356_194_5f32, std::f32::consts::FRAC_PI_4);
279         [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
280     }
281 
282     fn pinned_finish() -> Finish {
283         Finish { strength: 1.0, spec: 0.4, shininess: 24.0, curvature: 0.2, carve_depth: RECESS_DEPTH, roll_height: 1.0 }
284     }
285 
286     fn smoothstep_slope(v: f32) -> f32 {
287         6.0 * v * (1.0 - v)
288     }
289 
290     fn circular_roll_slope(f: f32) -> f32 {
291         let fc = f * ROLL_CUT;
292         fc / (1.0 - fc * fc).max(1e-4).sqrt()
293     }
294 
295     #[test]
296     fn constants_match_the_shader() {
297         assert_eq!(wgsl_const("PLATE_AMBIENT"), PLATE_AMBIENT);
298         assert_eq!(wgsl_const("RECESS_DEPTH"), RECESS_DEPTH);
299         assert_eq!(wgsl_const("PLATE_CREST"), PLATE_CREST);
300         assert_eq!(wgsl_const("ROLL_CUT"), ROLL_CUT);
301         assert_eq!(wgsl_const("PLATE_SHADE_LINE"), PLATE_SHADE_LINE);
302     }
303 
304     /// A carve grouped into a plate shades as its overlay does: the plate path
305     /// takes the shade line from its own roll alone, and composites what the
306     /// carves add as `carve_shade`'s value is composited ([`composite`]). With
307     /// the summed slope the shade line fired twice down every carve wall and
308     /// drew a doubled outline (2026-10-02). WGSL has no test harness here, so
309     /// this reads the plate branch's source.
310     #[test]
311     fn a_grouped_carve_takes_no_shade_line() {
312         let plate = WGSL
313             .split("if (mode == MODE_PLATE || mode == MODE_FRAME) {")
314             .nth(1)
315             .and_then(|rest| rest.split("if (mode == MODE_ROLL) {").next())
316             .expect("the plate branch");
317         assert!(!plate.contains("roll_shade_line(sv)"), "a carve's slope reached the shade line");
318         assert!(plate.contains("roll_shade_line(sv_rim)"));
319         assert!(plate.contains("carve_over("), "the carves are composited as an overlay");
320     }
321 
322     /// The crest is light-facing: at the silhouette the edge toward the light
323     /// shades brighter than the face and the edge away from it darker. Before
324     /// the weight, the flat crest left the far edge at or above the face.
325     #[test]
326     fn far_edge_shades_darker_than_the_face() {
327         let light = pinned_light();
328         let mat = pinned_finish();
329         let base = [0.5f32; 3];
330         let lxy = [light[0], light[1]];
331         let m = (lxy[0] * lxy[0] + lxy[1] * lxy[1]).sqrt();
332         let near = [lxy[0] / m, lxy[1] / m];
333         let far = [-near[0], -near[1]];
334         let lit = plate_surface(base, 1.0, near, &circular_roll_slope, light, &mat).unwrap();
335         let dark = plate_surface(base, 1.0, far, &circular_roll_slope, light, &mat).unwrap();
336         assert!(lit[0] > base[0] + 0.02, "near edge {} vs face {}", lit[0], base[0]);
337         assert!(dark[0] < base[0] - 0.02, "far edge {} vs face {}", dark[0], base[0]);
338     }
339 
340     /// The plate's face must come through as exactly the app's colour, or a
341     /// plate silently recolours whatever it is filled with.
342     #[test]
343     fn plate_face_is_untouched() {
344         let light = pinned_light();
345         let mat = pinned_finish();
346         let base = [0.3f32, 0.4, 0.5];
347         let out = plate_surface(base, 0.0, [-1.0, 0.0], &circular_roll_slope, light, &mat).unwrap();
348         for i in 0..3 {
349             assert!((out[i] - base[i]).abs() < 1e-4, "face channel {i}: {} vs {}", out[i], base[i]);
350         }
351     }
352 
353     /// A flat surface must composite to nothing, or the cover quad tints
354     /// everything it covers — the property the whole "relative to the flat
355     /// face" formulation exists to guarantee.
356     #[test]
357     fn flat_ground_shades_to_zero() {
358         let light = pinned_light();
359         let mat = pinned_finish();
360         for mode in [CarveMode::Recess, CarveMode::Boss, CarveMode::Ridge, CarveMode::Trough] {
361             for u in [0.0f32, 1.0] {
362                 let v = carve_shade(mode, u, [-1.0, 0.0], &smoothstep_slope, light, &mat);
363                 assert!(v.abs() < 1e-4, "{mode:?} at u={u} shaded {v}, expected 0");
364             }
365         }
366     }
367 
368     /// A pinned height is geometry: a deeper carve tilts its wall more and
369     /// shades harder, with nothing else changed.
370     #[test]
371     fn deeper_carve_shades_harder() {
372         let light = pinned_light();
373         let shallow = Finish { carve_depth: 0.3, ..pinned_finish() };
374         let deep = Finish { carve_depth: 1.2, ..pinned_finish() };
375         let at = |m: &Finish| carve_shade(CarveMode::Recess, 0.5, [-1.0, 0.0], &smoothstep_slope, light, m).abs();
376         assert!(at(&deep) > at(&shallow) * 1.5, "deep {} vs shallow {}", at(&deep), at(&shallow));
377         // And the flat plateaus still composite to nothing.
378         assert!(carve_shade(CarveMode::Recess, 0.0, [-1.0, 0.0], &smoothstep_slope, light, &deep).abs() < 1e-4);
379     }
380 
381     /// Ridge and trough are the same wall with the height sign flipped, so
382     /// their shading is opposite WHERE THE WALL IS STEEP.
383     ///
384     /// Not everywhere, which is worth stating because it is the first thing you
385     /// would assume: the response has an EVEN component. Tilting a surface
386     /// either way shortens the face-on `n.z` term and adds a non-negative
387     /// specular, so near the plateau lips — where the directional part is
388     /// nearly nothing — a ridge and a trough both darken slightly. That is the
389     /// faint lip lobe visible on both, not an asymmetry bug.
390     #[test]
391     fn ridge_and_trough_oppose_where_the_wall_is_steep() {
392         let light = pinned_light();
393         let mat = pinned_finish();
394         let sample = |m: CarveMode, u: f32| {
395             carve_shade(m, u, [-1.0, 0.0], &smoothstep_slope, light, &mat)
396         };
397         // Steepest point of the folded profile: w = 1 at u = 0.5 is the crest
398         // (zero slope), so the extremes sit either side of it.
399         let steep = (0..=100)
400             .map(|i| i as f32 / 100.0)
401             .max_by(|a, b| sample(CarveMode::Ridge, *a).abs().total_cmp(&sample(CarveMode::Ridge, *b).abs()))
402             .unwrap();
403         let (r, t) = (sample(CarveMode::Ridge, steep), sample(CarveMode::Trough, steep));
404         assert!(r.abs() > 0.05, "ridge shading {r} at u={steep} is too faint to test");
405         assert!(r * t < 0.0, "ridge {r} and trough {t} agree in sign at u={steep}");
406     }
407 }