GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(plate): backdrop_compression, a frosted plate's legibility control
A frosted plate over something bright washes out however hard it is
blurred, because blur destroys a backdrop's spatial DETAIL and preserves
its mean LUMINANCE — and text contrast is a mean-luminance property.
resolve_blur's closing mix(backdrop, plate, opacity) hands the
backdrop's brightness through at 1 - opacity whatever the kernel does;
at the designer dialog's 0.25 that is 75% of whatever is behind it.
Measured on a row label (#ccccd4) over that dialog: 1.20:1 over a white
viewport against 6.18:1 over the dark one. The bright end is not a
contrast ratio so much as its absence, and no amount of extra blur moves
either number. This is the whole of the "liquid glass" legibility
problem, and the reason refraction and specular cannot help it: they are
shape cues, and legibility is a luminance budget.
style.surface.plate.backdrop_compression (0..1) remaps the blurred
backdrop's luminance toward the plate's own key before the tint, holding
its chromaticity. Not opacity and not "darken": it is SYMMETRIC, pulling
a bright backdrop down and a dark one UP, so both ends converge on the
plate's key. The same measurement at 0.85 gives 4.24:1 and 4.33:1 — the
contrast stops depending on what is behind the window, which is the
goal. Hue, chroma and movement still read through the glass.
Defaults to 0: every config already in the wild keeps today's look, and
a toolkit-wide change to every frosted surface in the DE would otherwise
arrive unannounced in eighteen apps. Opting in is per-app config.
The scalar rides a backdrop_meta vec4 appended to WindowInfo, and the
uniform grew 320 -> 336 bytes. That lockstep was asserted only by a
comment, so window_info_layout_matches_the_uniform_size now reads the
struct out of the shader and checks it against WINDOW_INFO_BYTES —
verified to bite by appending a field to a copy. A field added without
growing the const writes past the end of the buffer.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 24 +++++++++++++++++
src/color.rs | 51 +++++++++++++++++++++++++++++++++++
src/vk/renderer.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++--
src/vk/shader2d.wgsl | 35 ++++++++++++++++++++++--
4 files changed, 181 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f349101..a03f379 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -282,6 +282,30 @@ What this buys, and where the code is heading:
the textbox) falls back to it when the widget's own `corner_radius` key is
unset, so a per-widget key is an override, not a requirement. Do not give a
new control-scale radius getter a literal default; fall back to the rung.
+- **A frosted plate's legibility is `backdrop_compression`, not opacity.**
+ Blur destroys a backdrop's spatial DETAIL and preserves its mean LUMINANCE,
+ and text contrast is a mean-luminance property — so `resolve_blur`'s closing
+ `mix(backdrop, plate, opacity)` hands the backdrop's brightness through at
+ `1 - opacity` whatever the kernel does. At the designer dialog's 0.25 that is
+ 75% of whatever is behind it. Measured on a row label (`#ccccd4`) over the
+ designer's Alt+D plate: **1.20:1 over a white viewport, 6.18:1 over the dark
+ one** — a 5x swing, the bright end of it not a contrast ratio so much as its
+ absence. More blur moves neither number, which is the whole of the "liquid
+ glass" legibility problem, and why refraction and specular cannot help: they
+ are shape cues, and legibility is a luminance budget.
+
+ `style.surface.plate.backdrop_compression` (0..1, `color::plate_backdrop_
+ compression`, **default 0** — every existing config keeps today's look)
+ remaps the blurred backdrop's luminance toward the plate's own key before
+ the tint, holding its chromaticity. It is not opacity and not "darken": it
+ is SYMMETRIC, pulling a bright backdrop down and a dark one UP, so both ends
+ converge on the plate's key. The same measurement at 0.85: **4.24:1 and
+ 4.33:1** — the contrast stops depending on what is behind the window, which
+ is the actual goal. Hue, chroma and movement still read through it.
+
+ Raising it past ~0.9 flattens the view through the glass without buying much
+ more contrast; the convergence point is set by the plate's tint and opacity,
+ so if both ends need to clear 4.5:1 that is the tint to change, not this.
## The `scene/` core rebuild (read `docs/rfc-core-rebuild.md` before touching it)
diff --git a/src/color.rs b/src/color.rs
index 944ecfc..3e2e3ff 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -645,6 +645,12 @@ fn parse_and_set_colors(content: &str) {
if let Some(c) = get_color("/style/surface/param/color") {
if let Ok(mut lock) = PARAM_BG_COLOR.write() { *lock = c; }
}
+ if let Some(c) = val
+ .pointer("/style/surface/plate/backdrop_compression")
+ .and_then(|v| v.as_f64())
+ {
+ if let Ok(mut lock) = PLATE_BACKDROP_COMPRESSION.write() { *lock = (c as f32).clamp(0.0, 1.0); }
+ }
if let Some(blur) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_bool()) {
if let Ok(mut lock) = PLATE_BLUR.write() { *lock = blur; }
} else if let Some(blur_val) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_f64()) {
@@ -1700,6 +1706,30 @@ pub fn set_plate_bevel_width(t: f32) {
style_write(&PLATE_BEVEL_WIDTH, t);
}
+/// How hard a frosted plate pulls its backdrop's LUMINANCE toward its own key
+/// before tinting: 0 = the backdrop passes through untouched, 1 = flat.
+///
+/// This is the plate's legibility control, and it is NOT opacity. Blur
+/// destroys a backdrop's spatial detail but preserves its mean luminance, and
+/// text contrast is a mean-luminance property — so a frosted plate over
+/// something bright washes out however hard it is blurred, which is the whole
+/// of the "liquid glass" legibility problem. Compression remaps the backdrop's
+/// luminance toward the plate's own, symmetrically: a bright backdrop comes
+/// down and a DARK one comes up, so the plate stops swinging through the ink's
+/// luminance while its hue, chroma and movement still read.
+///
+/// Default 0.0 — the behavior every existing config already has.
+static PLATE_BACKDROP_COMPRESSION: RwLock<f32> = RwLock::new(0.0);
+
+pub fn plate_backdrop_compression() -> f32 {
+ load_colors_once();
+ style_read(&PLATE_BACKDROP_COMPRESSION)
+}
+
+pub fn set_plate_backdrop_compression(c: f32) {
+ style_write(&PLATE_BACKDROP_COMPRESSION, c.clamp(0.0, 1.0));
+}
+
static PLATE_BLUR: RwLock<bool> = RwLock::new(false);
pub fn plate_blur() -> bool {
@@ -1874,6 +1904,27 @@ mod color_tests {
mod tests {
use super::*;
+ /// The plate's backdrop compression defaults OFF and clamps.
+ ///
+ /// Off is load-bearing: it is the behaviour every config already in the
+ /// wild has, and a toolkit-wide default that changed how every frosted
+ /// surface in the DE looks would arrive unannounced in eighteen apps.
+ /// Opting in is a per-app `style.surface.plate.backdrop_compression`.
+ #[test]
+ fn backdrop_compression_defaults_off_and_clamps() {
+ assert_eq!(plate_backdrop_compression(), 0.0, "off unless a config asks");
+
+ set_plate_backdrop_compression(0.85);
+ assert_eq!(plate_backdrop_compression(), 0.85);
+
+ // The shader clamps too, but a nonsense config value should not be
+ // able to reach it and make the plate flat or inverted.
+ set_plate_backdrop_compression(4.0);
+ assert_eq!(plate_backdrop_compression(), 1.0);
+ set_plate_backdrop_compression(-1.0);
+ assert_eq!(plate_backdrop_compression(), 0.0);
+ }
+
/// A colour pinned by one test is invisible to a test beside it.
///
/// The invariant that ends the parallel-flake class here. These statics
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index b61cfb7..5d96385 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -132,7 +132,10 @@ const PLATE_FEATURE_BYTES: usize = 48;
/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
/// [8 vec4 of profile slope samples].
// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
-const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
+// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
+// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
+// END — every offset above is addressed by index from both sides.
+const WINDOW_INFO_BYTES: vk::DeviceSize = 336;
pub(crate) struct AllocatedBuffer {
pub(crate) buffer: vk::Buffer,
@@ -256,6 +259,7 @@ pub struct VkRenderer {
/// last uploaded in WindowInfo — compared each frame, since editors set
/// them straight into the style registry with no generation counter.
relief_uploaded: (f32, f32),
+ compression_uploaded: f32,
/// Same for the edge (roll) profile LUT.
roll_profile_gen: u64,
plate_features: AllocatedBuffer,
@@ -872,6 +876,7 @@ impl VkRenderer {
window_info,
profile_gen: 0,
relief_uploaded: (0.0, 0.0),
+ compression_uploaded: 0.0,
roll_profile_gen: 0,
plate_features,
frames,
@@ -908,6 +913,13 @@ impl VkRenderer {
(self.corner_radius_px * crate::layout::corner_span_factor()).min(cap)
}
+ /// How hard a frosted plate compresses its backdrop's luminance toward
+ /// its own key — the plate's legibility control, tracked for re-upload
+ /// like the relief heights because it is live-editable config.
+ fn backdrop_compression(&self) -> f32 {
+ crate::color::plate_backdrop_compression()
+ }
+
/// The pinned relief heights in physical px, 0 = follow the width.
fn relief_px(&self) -> (f32, f32) {
let s = crate::scale::scale_factor().max(0.001);
@@ -920,7 +932,8 @@ impl VkRenderer {
fn write_window_info(&mut self) {
// [size/clip vec4][carve profile meta vec4][8 vec4 carve slopes]
// [roll profile meta vec4][8 vec4 roll slopes][relief heights vec4]
- // — must stay in lockstep with shader2d's WindowInfo.
+ // [backdrop meta vec4] — must stay in lockstep with shader2d's
+ // WindowInfo.
let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
data[0] = self.extent.width as f32;
data[1] = self.extent.height as f32;
@@ -940,6 +953,9 @@ impl VkRenderer {
data[76] = relief.0;
data[77] = relief.1;
self.relief_uploaded = relief;
+ let compression = self.backdrop_compression();
+ data[80] = compression;
+ self.compression_uploaded = compression;
self.profile_gen = crate::layout::bevel_profile_generation();
self.roll_profile_gen = crate::layout::roll_profile_generation();
if let Some(allocation) = self.window_info.allocation.as_mut() {
@@ -1563,6 +1579,7 @@ impl VkRenderer {
if self.profile_gen != crate::layout::bevel_profile_generation()
|| self.roll_profile_gen != crate::layout::roll_profile_generation()
|| self.relief_uploaded != self.relief_px()
+ || self.compression_uploaded != self.backdrop_compression()
{
self.write_window_info();
}
@@ -2140,4 +2157,58 @@ mod tests {
fn scene3d_compiles() {
assert!(!super::scene3d_spirv().is_empty());
}
+
+ /// `WINDOW_INFO_BYTES` sizes the uniform buffer AND its descriptor range,
+ /// and `write_window_info` addresses it by float index — all three have to
+ /// agree with shader2d's `WindowInfo` struct, and nothing but a comment
+ /// said so. A field appended to the WGSL without growing the const writes
+ /// the new value past the end of the buffer, which is a validation error
+ /// on a good day and a garbage uniform on a bad one.
+ ///
+ /// Reads the struct out of the shader source rather than duplicating its
+ /// shape here, so it measures the thing it is guarding.
+ #[test]
+ fn window_info_layout_matches_the_uniform_size() {
+ let src = include_str!("shader2d.wgsl");
+ let body = src
+ .split_once("struct WindowInfo {")
+ .expect("WindowInfo moved; this test scans for it")
+ .1
+ .split_once("\n}")
+ .expect("unterminated WindowInfo")
+ .0;
+
+ let mut floats = 0usize;
+ for line in body.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with("//") {
+ continue;
+ }
+ let ty = line.split_once(':').expect("field: type").1.trim().trim_end_matches(',');
+ floats += match ty {
+ "f32" => 1,
+ "vec2<f32>" | "vec2f" => 2,
+ "vec4<f32>" | "vec4f" => 4,
+ // std140-ish: an array of vec4 is its element count x 4.
+ t if t.starts_with("array<vec4f,") => {
+ let n: usize = t
+ .trim_start_matches("array<vec4f,")
+ .trim_end_matches('>')
+ .trim()
+ .parse()
+ .expect("array length");
+ n * 4
+ }
+ other => panic!("WindowInfo field type {other} is not in this test's size table"),
+ };
+ }
+
+ assert_eq!(
+ floats * 4,
+ super::WINDOW_INFO_BYTES as usize,
+ "WindowInfo is {floats} floats ({} bytes); WINDOW_INFO_BYTES says {}",
+ floats * 4,
+ super::WINDOW_INFO_BYTES,
+ );
+ }
}
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index f98520e..093558d 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -34,6 +34,10 @@ struct WindowInfo {
// (p_light.w) they become the slope scale, so a pinned 0.5 mm drop is
// the same geometry whatever wall it is cut with.
relief_meta: vec4f,
+ // x = how hard a frosted plate pulls its backdrop's luminance toward its
+ // own key (0 = untouched, 1 = flat). See `resolve_blur`. Appended last so
+ // the established offsets above keep their indices.
+ backdrop_meta: vec4f,
}
@group(0) @binding(2) var<uniform> window_info: WindowInfo;
@@ -970,6 +974,33 @@ fn resolve_blur(pos: vec2f, color: vec4f) -> vec4f {
let backdrop_color = blurred / total_weight;
let opacity = -color.a;
- let plate_color = vec4f(color.rgb, 1.0);
- return mix(backdrop_color, plate_color, opacity);
+
+ // Luminance-range compression, the plate's legibility control.
+ //
+ // The blur above destroys the backdrop's spatial DETAIL and preserves its
+ // mean LUMINANCE — and text contrast is a mean-luminance property, so on
+ // its own the mix below hands the backdrop's brightness straight through
+ // at (1 - opacity). At the designer dialog's 0.25 that is 75% of whatever
+ // is behind it: over the dark viewport a row label runs ~12:1, over
+ // something bright ~1.2:1, which is not a contrast ratio so much as its
+ // absence. No amount of extra blur moves either number.
+ //
+ // So remap the backdrop's luminance toward the plate's own key, keeping
+ // its chromaticity. This is not "darken" and not opacity: it is
+ // SYMMETRIC, pulling a bright backdrop down and a dark one UP, so what it
+ // removes is the plate's swing through the ink's luminance rather than
+ // the view through it. Hue, chroma and movement all still read.
+ let k = clamp(window_info.backdrop_meta.x, 0.0, 1.0);
+ let W = vec3f(0.2126, 0.7152, 0.0722);
+ let bl = dot(backdrop_color.rgb, W);
+ let key = dot(color.rgb, W);
+ // `target` is a WGSL reserved word.
+ let keyed = mix(bl, key, k);
+ // Scaling by keyed/bl holds chromaticity exactly, but near black the
+ // ratio explodes (and a lift past 1 would clip a channel), so cross-fade
+ // to the neutral luminance over the bottom of the range instead.
+ let scaled = backdrop_color.rgb * (keyed / max(bl, 1e-4));
+ let compressed = mix(vec3f(keyed), scaled, smoothstep(0.0, 0.05, bl));
+
+ return vec4f(mix(compressed, color.rgb, opacity), 1.0);
}