Files
punktfunk/crates/pf-console-ui/src/skia_overlay.rs
T
enricobuehler 2271f67202
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 4m26s
ci / bench (push) Successful in 5m53s
ci / rust (push) Failing after 7m35s
windows-host / package (push) Failing after 8m56s
flatpak / build-publish (push) Failing after 8m12s
arch / build-publish (push) Successful in 11m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
android / android (push) Successful in 13m28s
deb / build-publish (push) Successful in 13m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
docker / deploy-docs (push) Successful in 20s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
release / apple (push) Successful in 25m4s
apple / screenshots (push) Successful in 19m32s
style: rustfmt the recovery + resize changes (Windows CI fmt check)
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was
failing: the mid-stream loss-recovery and resize-overlay commits landed
with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core,
pf-console-ui, and a few host files.

Applied `cargo fmt`, and hand-relocated two trailing comments in
session.rs (a decoded-frame note and the wrap-counter note) to their own
lines so rustfmt no longer column-aligns the following comment block to
a deep indent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:25:06 +02:00

626 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Skia on the presenter's device: `DirectContext` over the shared handles, a ring of
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
//!
//! Two personas over one `Overlay`: the CONSOLE (the full shell — home, library,
//! settings, pairing — always dirty, the aurora animates) and the stream chrome (stats
//! OSD, capture hint, the auto-fading start banner).
use crate::model::{ConsoleBus, ConsoleShared, HostRow};
use crate::screens::Screen;
use crate::shell::{ConsoleOptions, Shell};
use crate::theme::{match_first_family, Fonts};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_presenter::overlay::{
FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice,
};
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
use std::time::Instant;
/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
/// Deck-class shared memory.
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
/// How long the start-of-stream banner lingers (fading through the tail).
const BANNER_S: f64 = 6.0;
const BANNER_FADE_S: f64 = 0.6;
/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
struct Slot {
surface: Surface,
image: avk::Image,
view: avk::ImageView,
width: u32,
height: u32,
}
/// What the current ring slot has drawn — re-render only when this changes.
#[derive(PartialEq, Clone, Default)]
struct Drawn {
width: u32,
height: u32,
stats: Option<String>,
hint: Option<String>,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8,
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
/// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0`
/// when no resize is showing (so a still stream stays damage-free).
resize_step: u16,
}
/// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry {
/// The host list (bare `--browse`).
Home,
/// Home with this host's library already pushed (`--browse host` — the Decky
/// per-host launch; B backs out to Home).
Library(HostRow),
}
/// The binary's ends of the console: models to write, commands to serve.
pub struct ConsoleHandles {
pub console: ConsoleShared,
pub library: crate::library::LibraryShared,
pub bus: ConsoleBus,
}
pub struct SkiaOverlay {
/// Set by `init`; `None` until then (and after an init failure the run loop drops
/// the whole overlay, so mid-session these are always `Some`).
gpu: Option<Gpu>,
slots: [Option<Slot>; 2],
/// Which slot the LAST returned frame lives in — the next render takes the other.
current: usize,
drawn: Drawn,
/// The stats OSD's monospace face (system-resolved; the console uses Geist).
font: Option<Font>,
fonts: Option<Fonts>,
/// The console shell (`--browse`) — `None` for a plain `--connect` session.
shell: Option<Shell>,
/// When the current stream started presenting — drives the start banner.
streaming_since: Option<Instant>,
/// The banner's words (set per stream from the active-pad state).
banner_text: Option<String>,
/// When the current mid-stream resize scrim began showing — drives its spinner phase.
/// `None` = no resize in flight (`FrameCtx::resizing` was false last frame).
resizing_since: Option<Instant>,
}
struct Gpu {
device: ash::Device,
queue_family_index: u32,
context: DirectContext,
/// The device's shared queue lock (see `SharedDevice::queue_lock`): Skia submits
/// on the presenter's graphics queue, which FFmpeg's decode prep also uses from
/// the pump thread — every `flush*`/`submit` below holds this.
queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
// Keep the loader library + instance dispatch alive as long as the DirectContext
// (its baked fn pointers live inside libvulkan).
_entry: ash::Entry,
_instance: ash::Instance,
}
impl SkiaOverlay {
#[allow(clippy::new_without_default)]
pub fn new() -> SkiaOverlay {
SkiaOverlay {
gpu: None,
slots: [None, None],
current: 0,
drawn: Drawn::default(),
font: None,
fonts: None,
shell: None,
streaming_since: None,
banner_text: None,
resizing_since: None,
}
}
/// The `--browse` overlay: the full console shell between streams, stream chrome
/// during them. Returns the binary's handles (models + command bus).
pub fn console(
opts: ConsoleOptions,
entry: ConsoleEntry,
) -> Result<(SkiaOverlay, ConsoleHandles)> {
let console = ConsoleShared::default();
let library = crate::library::LibraryShared::default();
let bus = ConsoleBus::default();
let stack = match entry {
ConsoleEntry::Home => vec![Screen::Home(crate::screens::home::HomeScreen::new())],
ConsoleEntry::Library(host) => vec![
Screen::Home(crate::screens::home::HomeScreen::new()),
Screen::Library(crate::screens::library::LibraryScreen::new(&host)),
],
};
let shell = Shell::new(console.clone(), library.clone(), bus.clone(), opts, stack)?;
let mut o = SkiaOverlay::new();
o.shell = Some(shell);
Ok((
o,
ConsoleHandles {
console,
library,
bus,
},
))
}
fn console_visible(&self) -> bool {
self.shell.as_ref().is_some_and(|s| !s.in_stream)
}
}
impl Drop for SkiaOverlay {
/// The run loop quiesces the queue before dropping us; releasing the views + Skia
/// surfaces (which free their VkImages) is then safe. Field order drops the slots
/// before the DirectContext.
fn drop(&mut self) {
if let Some(gpu) = &mut self.gpu {
for slot in self.slots.iter_mut().flat_map(Option::take) {
unsafe { gpu.device.destroy_image_view(slot.view, None) };
drop(slot.surface);
}
let _q = gpu.queue_lock.guard(); // queue external sync vs FFmpeg's pump
gpu.context.flush_and_submit();
}
}
}
impl Overlay for SkiaOverlay {
fn init(&mut self, shared: &SharedDevice) -> Result<()> {
// Skia resolves its Vulkan entry points through us: instance-scoped names via
// the loader, device-scoped via the device — the exact same dispatch ash uses.
// Resolution completes inside `make_vulkan` (the DirectContext bakes its fn
// table); the closure and its clones end with `init`.
let entry = shared.entry.clone();
let instance = shared.instance.clone();
let get_proc = move |of: skvk::GetProcOf| -> *const std::ffi::c_void {
unsafe {
match of {
skvk::GetProcOf::Instance(raw_instance, name) => entry
.get_instance_proc_addr(avk::Instance::from_raw(raw_instance as _), name)
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void),
skvk::GetProcOf::Device(raw_device, name) => {
(instance.fp_v1_0().get_device_proc_addr)(
avk::Device::from_raw(raw_device as _),
name,
)
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void)
}
}
}
};
let backend = unsafe {
skvk::BackendContext::new(
shared.instance.handle().as_raw() as _,
shared.physical_device.as_raw() as _,
shared.device.handle().as_raw() as _,
(
shared.queue.as_raw() as _,
shared.queue_family_index as usize,
),
&get_proc,
)
};
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
let typeface = match_first_family(
&FontMgr::new(),
&["monospace", "Consolas", "Cascadia Mono", "Courier New"],
skia_safe::FontStyle::normal(),
)
.context("no monospace typeface (fontconfig alias or system family)")?;
self.font = Some(Font::new(typeface, 14.0));
self.fonts = Some(crate::theme::build_fonts()?);
self.gpu = Some(Gpu {
device: shared.device.clone(),
queue_family_index: shared.queue_family_index,
context,
queue_lock: shared.queue_lock.clone(),
_entry: shared.entry.clone(),
_instance: shared.instance.clone(),
});
tracing::info!("Skia console UI on the presenter's device");
Ok(())
}
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
// Keyboard + text into the console while it's on screen — never for
// chord-modified keys (those stay the run loop's), never during a stream.
if !self.console_visible() {
return false;
}
let Some(shell) = &mut self.shell else {
return false;
};
match event {
sdl3::event::Event::KeyDown {
scancode: Some(sc),
keymod,
repeat,
..
} => {
use sdl3::keyboard::Mod;
if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
return false;
}
shell.key(*sc, *repeat)
}
sdl3::event::Event::TextInput { text, .. } => {
shell.text_input(text);
true
}
_ => false,
}
}
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
if self.console_visible() {
self.shell.as_mut().and_then(|s| s.handle_menu(event))
} else {
None
}
}
fn take_action(&mut self) -> Option<OverlayAction> {
self.shell.as_mut().and_then(|s| s.take_action())
}
fn text_input_active(&self) -> bool {
self.console_visible() && self.shell.as_ref().is_some_and(Shell::editing)
}
fn session_phase(&mut self, phase: SessionPhase) {
let Some(shell) = &mut self.shell else { return };
match phase {
SessionPhase::Connecting => {} // the shell raised the Launch; already showing
SessionPhase::Streaming => {
shell.session_streaming();
self.streaming_since = Some(Instant::now());
}
SessionPhase::Failed(msg) => shell.session_failed(msg),
SessionPhase::Ended(reason) => {
shell.session_ended(reason);
self.streaming_since = None;
}
}
}
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
// The console: full-screen, opaque, and always dirty (the aurora animates
// every frame — the GPU port's whole point).
if self.console_visible() {
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let Self {
gpu,
slots,
shell,
fonts,
..
} = self;
let gpu = gpu.as_mut().expect("init ran");
let slot = slots[next].as_mut().expect("just ensured");
let shell = shell.as_mut().expect("console_visible");
let fonts = fonts.as_ref().expect("init ran");
shell.render(
slot.surface.canvas(),
ctx.width,
ctx.height,
fonts,
ctx.pad,
ctx.pad_pref,
ctx.pads,
);
{
// Queue external sync vs FFmpeg's pump-thread submits (same queue).
let _q = gpu.queue_lock.guard();
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
Some(&skvk::mutable_texture_states::new_vulkan(
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
gpu.queue_family_index,
)),
);
gpu.context.submit(None);
}
self.current = next;
self.drawn = Drawn::default(); // stream chrome re-renders when it returns
let slot = self.slots[next].as_ref().expect("just rendered");
return Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
width: slot.width,
height: slot.height,
}));
}
// --- Stream chrome: stats OSD + capture hint + start banner + resize scrim -----
let banner_alpha = self.banner_alpha(ctx);
let banner_step = (banner_alpha * 32.0).round() as u8;
let resize_phase = self.resize_phase(ctx);
// 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps
// spinning through the damage gate; `+ 1` keeps an active resize's step nonzero
// even on its first frame (phase 0) so the guard below doesn't skip it.
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
let want = Drawn {
width: ctx.width,
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
banner_step,
resize_step,
};
if want == self.drawn {
// Unchanged — hand the presenter the already-rendered image.
return Ok(self.slots[self.current].as_ref().map(|s| OverlayFrame {
image: s.image,
view: s.view,
width: s.width,
height: s.height,
}));
}
// Render into the OTHER slot — the presenter may still be sampling the current
// one (one frame in flight; the ring of two is exactly deep enough).
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let gpu = self.gpu.as_mut().expect("init ran");
let slot = self.slots[next].as_mut().expect("just ensured");
let canvas = slot.surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
let font = self.font.as_ref().expect("init ran");
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
if let Some(phase) = resize_phase {
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
}
if let Some(stats) = &want.stats {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
}
if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
} else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own —
// discoverable without the stats overlay, gone before it annoys.
if let Some(text) = &self.banner_text {
draw_hint_pill(
canvas,
font,
text,
ctx.width,
ctx.height,
banner_alpha as f32,
);
}
}
// Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
// layout the presenter's composite samples (its own barrier covers visibility).
// Lock: queue external sync vs FFmpeg's pump-thread submits (same queue).
{
let _q = gpu.queue_lock.guard();
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
Some(&skvk::mutable_texture_states::new_vulkan(
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
gpu.queue_family_index,
)),
);
gpu.context.submit(None);
}
self.current = next;
self.drawn = want;
let slot = self.slots[next].as_ref().expect("just rendered");
Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
width: slot.width,
height: slot.height,
}))
}
}
impl SkiaOverlay {
/// The start banner's current alpha (1 → 0 across the fade tail), refreshing its
/// words while fully visible so a pad hot-plug updates the leave hint.
fn banner_alpha(&mut self, ctx: &FrameCtx) -> f64 {
let Some(since) = self.streaming_since else {
self.banner_text = None;
return 0.0;
};
let age = since.elapsed().as_secs_f64();
if age >= BANNER_S {
self.streaming_since = None;
self.banner_text = None;
return 0.0;
}
self.banner_text = Some(if ctx.pad.is_some() {
"Hold L1 + R1 + Start + Select to leave · Ctrl+Alt+Shift+S stats".to_string()
} else {
"Ctrl+Alt+Shift+Q releases input · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"
.to_string()
});
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
}
/// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or
/// `None` when no resize is in flight. Latches the start on the first `resizing` frame
/// and clears it the moment the run loop drops the flag (the target frame landed or the
/// switch timed out), so the next resize starts its spinner from zero.
fn resize_phase(&mut self, ctx: &FrameCtx) -> Option<f64> {
if !ctx.resizing {
self.resizing_since = None;
return None;
}
Some(
self.resizing_since
.get_or_insert_with(Instant::now)
.elapsed()
.as_secs_f64(),
)
}
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
if self.slots[i]
.as_ref()
.is_some_and(|s| s.width == width && s.height == height)
{
return Ok(());
}
let gpu = self.gpu.as_mut().expect("init ran");
if let Some(old) = self.slots[i].take() {
// Any in-flight sampling of THIS slot ended two presents ago (the ring
// alternates and the presenter waits its fence before each record).
unsafe { gpu.device.destroy_image_view(old.view, None) };
}
let info =
skia_safe::ImageInfo::new_n32_premul((width.max(1) as i32, height.max(1) as i32), None);
let mut surface = gpu::surfaces::render_target(
&mut gpu.context,
gpu::Budgeted::Yes,
&info,
None,
SurfaceOrigin::TopLeft,
None,
false,
None,
)
.context("Skia render-target surface")?;
let texture = gpu::surfaces::get_backend_texture(
&mut surface,
skia_safe::surface::BackendHandleAccess::FlushRead,
)
.context("surface backend texture")?;
let image_info = texture
.vulkan_image_info()
.context("backend texture is not Vulkan")?;
let image = avk::Image::from_raw(*image_info.image() as u64);
let view = unsafe {
gpu.device.create_image_view(
&avk::ImageViewCreateInfo::default()
.image(image)
.view_type(avk::ImageViewType::TYPE_2D)
.format(avk::Format::from_raw(image_info.format as i32))
.subresource_range(
avk::ImageSubresourceRange::default()
.aspect_mask(avk::ImageAspectFlags::COLOR)
.level_count(1)
.layer_count(1),
),
None,
)
}
.context("overlay image view")?;
self.slots[i] = Some(Slot {
surface,
image,
view,
width,
height,
});
Ok(())
}
}
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
/// look, minus the toolkit).
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent + metrics.leading;
let lines: Vec<&str> = text.lines().collect();
let widest = lines
.iter()
.map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max);
let (pad_x, pad_y) = (10.0, 8.0);
let panel = Rect::from_xywh(
x,
y,
widest + 2.0 * pad_x,
line_h * lines.len() as f32 + 2.0 * pad_y,
);
canvas.draw_rrect(
RRect::new_rect_xy(panel, 8.0, 8.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
);
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
for (i, line) in lines.iter().enumerate() {
canvas.draw_str(
line,
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
font,
&text_paint,
);
}
}
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
/// a "Resizing…" label centered over it — so the host's 0.32 s virtual-display + encoder
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
/// hides the stretched in-between frame instead (same intent, one draw).
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
let (wf, hf) = (width as f32, height as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
// Spinner slightly above center; the label sits below it.
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0);
crate::theme::spinner(canvas, cx, cy - r, r, phase);
let (_, metrics) = font.metrics();
let label = "Resizing\u{2026}";
let tw = font.measure_str(label, None).0;
canvas.draw_str(
label,
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
);
}
/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0;
let (pad_x, pad_y) = (14.0, 8.0);
let w = text_w + 2.0 * pad_x;
let h = line_h + 2.0 * pad_y;
let x = (width as f32 - w) / 2.0;
let y = height as f32 - h - 24.0;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
);
}