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:
@@ -63,6 +63,7 @@ pub mod theme_store_plugin;
|
|||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
|
pub mod ui_glass;
|
||||||
pub mod ui_modal;
|
pub mod ui_modal;
|
||||||
pub mod ui_theme;
|
pub mod ui_theme;
|
||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
|
|||||||
@@ -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