feat(engine): add ui_glass liquid-glass material module
Layered bevy_ui treatment (sheen gradient, specular BorderGradient rim, BoxShadow) approximating frosted glass without shaders or backdrop sampling - WebGL2-safe by construction. Layout callers depend only on glass_surface() + GLASS_BORDER_PX so the material can be swapped later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
//! "Liquid glass" material for floating UI surfaces.
|
||||
//!
|
||||
//! Produces the translucent, light-catching treatment used by the floating
|
||||
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
|
||||
//! vertical sheen, a specular rim highlight around the border, and a soft
|
||||
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
|
||||
//! UI pipeline has no backdrop-blur concept, so this module approximates
|
||||
//! frosted glass with layered gradients instead of sampling what's behind
|
||||
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
|
||||
//! extra render passes, and identical behaviour on desktop, Android, and
|
||||
//! WebGL2.
|
||||
//!
|
||||
//! # Swapping the treatment
|
||||
//!
|
||||
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
|
||||
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
|
||||
//! render target) can replace this module's internals without touching any
|
||||
//! caller — that boundary is the reason this file exists separately from
|
||||
//! `hud_plugin/tab_bar.rs`.
|
||||
//!
|
||||
//! # Departure from the Terminal design system
|
||||
//!
|
||||
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
|
||||
//! bar is a deliberate, user-approved exception for the floating touch tab
|
||||
//! bar only; do not reuse this material for modals or the top HUD band
|
||||
//! without a matching design decision.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{
|
||||
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
|
||||
};
|
||||
|
||||
/// Border width every glass surface must reserve in its `Node::border` so
|
||||
/// the rim gradient has a strip to paint into.
|
||||
pub const GLASS_BORDER_PX: f32 = 1.0;
|
||||
|
||||
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
|
||||
/// opacity so the felt and cards remain visible through the bar while
|
||||
/// keeping icon/label contrast comfortable.
|
||||
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
|
||||
|
||||
/// Fill at the *top* of the sheet — the same glass lifted towards white,
|
||||
/// reading as the curved surface catching overhead light.
|
||||
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
|
||||
|
||||
/// Rim highlight at the top edge — the bright specular line where a real
|
||||
/// glass pill would catch the light source.
|
||||
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
|
||||
|
||||
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
|
||||
/// rather than a drawn outline.
|
||||
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
|
||||
|
||||
/// Rim at the bottom edge — a faint secondary catch-light, as on the
|
||||
/// underside of a curved surface above a bright table.
|
||||
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
|
||||
|
||||
/// Drop shadow under the floating surface.
|
||||
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||
|
||||
/// Visual components for a floating glass surface.
|
||||
///
|
||||
/// The caller owns the `Node` and must set two fields for the material to
|
||||
/// render correctly:
|
||||
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
|
||||
/// paints into the border strip and is invisible without it.
|
||||
/// - `border_radius` — half the node height for a fully-round pill.
|
||||
///
|
||||
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
|
||||
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
|
||||
pub fn glass_surface() -> impl Bundle {
|
||||
(
|
||||
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||
// the lighting so there is a single source of truth for the surface
|
||||
// colour (a separate `BackgroundColor` would just be painted over).
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Specular rim: bright at the top edge, fading out through the
|
||||
// sides, with a faint return at the bottom.
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)),
|
||||
ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Soft shadow underneath sells the "floating above the table" read.
|
||||
BoxShadow(vec![ShadowStyle {
|
||||
color: GLASS_SHADOW,
|
||||
x_offset: Val::Px(0.0),
|
||||
y_offset: Val::Px(6.0),
|
||||
spread_radius: Val::Px(0.0),
|
||||
blur_radius: Val::Px(16.0),
|
||||
}]),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bundle must insert all three visual components — a regression
|
||||
/// here (e.g. a refactor dropping the rim) would silently flatten the
|
||||
/// glass into a plain translucent box.
|
||||
#[test]
|
||||
fn glass_surface_inserts_sheen_rim_and_shadow() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn(glass_surface()).id();
|
||||
assert!(world.get::<BackgroundGradient>(e).is_some());
|
||||
assert!(world.get::<BorderGradient>(e).is_some());
|
||||
assert!(world.get::<BoxShadow>(e).is_some());
|
||||
}
|
||||
|
||||
/// The fill must stay translucent (that is the whole point of glass) but
|
||||
/// opaque enough that text keeps contrast over busy card art.
|
||||
#[test]
|
||||
fn glass_fill_alpha_stays_in_readable_band() {
|
||||
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
|
||||
let a = fill.alpha();
|
||||
assert!(
|
||||
(0.4..=0.85).contains(&a),
|
||||
"glass fill alpha {a} outside readable 0.4–0.85 band"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rim gradient must actually glint: strictly brightest at the top,
|
||||
/// dimmest in the middle.
|
||||
#[test]
|
||||
fn rim_highlight_peaks_at_top() {
|
||||
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
|
||||
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user