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

src/scene/relief_shade.rs (15.7K)

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