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 }