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 }