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

commitf2fc3c8e03f0f7c0f49fe11e51f416014f28b8b3
parentb437ebd15d
authorLucas Galante <[email protected]>
date2026-09-21 10:01
Plate crest is light-facing: the far edges of a raised plate shade dark

The crest — the ambient-catching rim term on a raised plate's perimeter
roll — was a flat +PLATE_CREST on every side, regardless of where the
light is. On the edges facing away from the light that out-measured the
roll's own diffuse fall-off at every point of the profile (about -0.20
at the silhouette against +0.25 of crest with the DE's 135° light), so
a raised plate had a bright rim toward the light and NO dark rim away
from it: the glint had no counterpart.

The crest is now weighted by how squarely the rim faces the light's
azimuth (crest_weight, clamped at zero). Light-facing edges keep their
crest; down-light edges keep only their diffuse shading and fall to
about 0.80 of the face at the silhouette. A light from straight
overhead has no near or far side and keeps the crest everywhere. Both
the plate branch and the fill-less window roll (MODE_ROLL) take the
weight; the droplet's fresnel crest is its own term and is untouched.

relief_shade mirrors the weight so cce-relief's prediction still matches
the shader, with a test that the near edge shades brighter than the
face and the far edge darker.

Verified in a scale-2 shadow on opaque raised plates: at the silhouette
the bottom/right edge of a 196-luma face went from 202 to 184, the top
from 239 to 235. On the designer's frosted panes over a dark viewport
the same shading is only 1-5 luma — a multiply on a ~20-luma face —
which is the face's brightness, not the roll.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/scene/relief_shade.rs | 35 ++++++++++++++++++++++++++++++++++-
 src/vk/shader2d.wgsl      | 22 ++++++++++++++++++++--
 2 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index a6865d0..bc6198d 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -160,6 +160,21 @@ pub fn analytic_roll_slope(f: f32) -> f32 {
     }
 }
 
+/// How squarely a rim faces the light's azimuth, 0..1 — the weight on the
+/// plate crest. Mirrors `crest_weight`. The crest used to be a flat
+/// `PLATE_CREST` on every side, which out-measured the far edges' diffuse
+/// fall-off everywhere along the roll, so a raised plate had a lit rim and no
+/// shadowed one; weighted, the down-light edges keep only their diffuse
+/// shading and read as the glint's dark counterpart. A light from straight
+/// overhead has no near or far side and keeps the crest everywhere.
+pub fn crest_weight(facing: [f32; 2], light: [f32; 3]) -> f32 {
+    let m = (light[0] * light[0] + light[1] * light[1]).sqrt();
+    if m < 1e-4 {
+        return 1.0;
+    }
+    ((facing[0] * light[0] + facing[1] * light[1]) / m).max(0.0)
+}
+
 /// The plate's own surface colour across its perimeter roll — mode 1, which is
 /// NOT the free-carve branch and does not composite like one.
 ///
@@ -191,7 +206,7 @@ pub fn plate_surface(
     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;
     // The crest: the ambient-catching convex rim that makes glass read as glass.
-    let extra = PLATE_CREST * f * f * f;
+    let extra = PLATE_CREST * f * f * f * crest_weight(facing, light);
     let shade = 1.0 + (diff / flat_shade(light) - 1.0 + extra) * mat.strength;
     let spec = roll_spec(sv, light, mat) * mat.strength;
     Some([
@@ -244,6 +259,24 @@ mod tests {
         assert_eq!(wgsl_const("ROLL_CUT"), ROLL_CUT);
     }
 
+    /// The crest is light-facing: at the silhouette the edge toward the light
+    /// shades brighter than the face and the edge away from it darker. Before
+    /// the weight, the flat crest left the far edge at or above the face.
+    #[test]
+    fn far_edge_shades_darker_than_the_face() {
+        let light = light_vector();
+        let mat = Finish::from_style();
+        let base = [0.5f32; 3];
+        let lxy = [light[0], light[1]];
+        let m = (lxy[0] * lxy[0] + lxy[1] * lxy[1]).sqrt();
+        let near = [lxy[0] / m, lxy[1] / m];
+        let far = [-near[0], -near[1]];
+        let lit = plate_surface(base, 1.0, near, &analytic_roll_slope, light, &mat).unwrap();
+        let dark = plate_surface(base, 1.0, far, &analytic_roll_slope, light, &mat).unwrap();
+        assert!(lit[0] > base[0] + 0.02, "near edge {} vs face {}", lit[0], base[0]);
+        assert!(dark[0] < base[0] - 0.02, "far edge {} vs face {}", dark[0], base[0]);
+    }
+
     /// The plate's face must come through as exactly the app's colour, or a
     /// plate silently recolours whatever it is filled with.
     #[test]
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index c8e4e4a..1d81009 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -277,6 +277,24 @@ fn roll_spec_wrap(sv: vec2f) -> f32 {
     return rrect_clip.p_mat.y * max(pow(prof, shininess) - pow(hv.z, shininess), 0.0);
 }
 
+// How squarely a rim faces the light's azimuth, 0..1 — the weight on the
+// plate crest. The crest was a flat +PLATE_CREST on every side, and on the
+// far (down-light) edges that out-measured the roll's own diffuse fall-off at
+// every point of the profile: a raised plate had a bright rim toward the
+// light and NO dark rim away from it. Weighted this way the near edges keep
+// their crest and the far edges keep only their diffuse shading, so the
+// silhouette reads lit on one side and shadowed on the other, like the
+// glint's counterpart. Light from straight overhead has no near or far side
+// and keeps the crest everywhere.
+fn crest_weight(facing: vec2f) -> f32 {
+    let lxy = rrect_clip.p_light.xy;
+    let m = length(lxy);
+    if (m < 1e-4) {
+        return 1.0;
+    }
+    return max(dot(facing, lxy) / m, 0.0);
+}
+
 // Slope of the raised roll's height profile at f (0 at the face join, 1 at the
 // silhouette). Circular (shape 2): a quarter-round h = sqrt(1 - f²) — tangent-
 // continuous with the face but with a curvature JUMP at the join (1/t → 0), the
@@ -612,7 +630,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         if (vcol.a < 0.0) {
             base = resolve_blur(frag, vcol, sv_rim * (refr * t), refr * f * f, k_plate, stride);
         }
-        var extra = PLATE_CREST * f * f * f;
+        var extra = PLATE_CREST * f * f * f * crest_weight(gd.xy);
         let f_off = u32(rrect_clip.p_host.x);
         let f_cnt = u32(rrect_clip.p_host.y);
         for (var i = 0u; i < f_cnt; i = i + 1u) {
@@ -686,7 +704,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         let u = clamp(d / t, 0.0, 1.0);
         let f = 1.0 - u;
         let sv = gd.xy * roll_slope(f);
-        let extra = PLATE_CREST * f * f * f;
+        let extra = PLATE_CREST * f * f * f * crest_weight(gd.xy);
         let n = normalize(vec3f(sv, 1.0));
         let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
         let spec = roll_spec(sv);