Files
luma-home/src/renderer.rs
T
Kaloyan DanchevandClaude Opus 4.6 cbea5a322f
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/
subdirectory. Add upstream Luma v2.3.1 source at repo root with our
additions:

- Page::Ha (4th nav tab) with placeholder empty state
- HA tab config schema and default entity mapping (ha-home/)
- Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings
- All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 11:52:55 +03:00

1371 lines
47 KiB
Rust

//! A small GLES2-compatible GPU painter. Rust owns vector glyphs and the draw list;
//! SDL only supplies the context. No browser, LVGL or Qt runtime.
use crate::{
font::Font,
raster::{Size, Viewport},
state::Rect,
vector,
};
use gl::types::*;
use serde::Serialize;
use std::{
collections::{HashMap, HashSet},
ffi::{CStr, CString},
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
pub type Color = [f32; 4];
pub const WHITE: Color = [0.96, 0.96, 0.96, 1.];
const GLYPH_GEOMETRY_BUDGET: usize = 8 * 1024 * 1024;
const TEXT_GEOMETRY_BUDGET: usize = 32 * 1024 * 1024;
const TEXTURE_BUDGET: usize = 48 * 1024 * 1024;
#[derive(Clone, Copy, Debug)]
pub struct TextStyle {
pub size: f32,
pub weight: u16,
/// CSS letter-spacing in em, not pixels.
pub tracking: f32,
pub line_height: f32,
pub align_right: bool,
}
impl TextStyle {
pub const fn new(size: f32) -> Self {
Self {
size,
weight: 400,
tracking: 0.,
line_height: 1.2,
align_right: false,
}
}
pub const fn weight(mut self, weight: u16) -> Self {
self.weight = weight;
self
}
pub const fn tracking(mut self, tracking: f32) -> Self {
self.tracking = tracking;
self
}
pub const fn leading(mut self, height: f32) -> Self {
self.line_height = height;
self
}
pub const fn right(mut self) -> Self {
self.align_right = true;
self
}
}
#[derive(Clone)]
pub enum Draw {
Shape(Rect, f32, f32, Color),
RoundedGradient(Rect, f32, Color, Color),
Shadow(Rect, f32, f32, Color),
Text(String, Rect, TextStyle, Color, bool),
Image(String, Rect, f32, bool),
TintedImage(String, Rect, f32, bool, Color),
AppImage(Arc<crate::app_artwork::Artwork>, Rect, Color),
Clip(Option<Rect>),
EdgeFade(Option<[f32; 4]>),
}
struct Texture {
id: GLuint,
width: u32,
height: u32,
frame: u64,
}
impl Drop for Texture {
fn drop(&mut self) {
unsafe {
gl::DeleteTextures(1, &self.id);
}
}
}
struct Glyph {
geometry: Vec<luma_vector_geometry::Vertex>,
frame: u64,
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct TextKey {
text: String,
weight: u16,
// Logical size, tracking, line height, available width and height.
layout: [u32; 5],
flags: u8,
}
struct TextRun {
mesh: Option<vector::Mesh>,
missing_glyphs: usize,
failures: usize,
frame: u64,
}
#[derive(Clone, Serialize)]
pub struct GraphicsInfo {
pub vendor: String,
pub renderer: String,
pub version: String,
pub max_texture_size: u32,
pub max_viewport: [i32; 2],
pub fragment_highp: bool,
}
#[derive(Clone, Serialize)]
pub struct RenderStats {
pub target: Size,
pub viewport: Viewport,
pub font_antialiasing: &'static str,
pub multisample_samples: u32,
pub max_logical_font_px: f32,
pub max_physical_font_px: f32,
pub cached_glyphs: usize,
pub glyph_tessellations: usize,
pub glyph_failures: usize,
pub missing_glyphs: usize,
pub missing_images: usize,
pub glyph_geometry_bytes: usize,
pub cached_text_runs: usize,
pub text_geometry_bytes: usize,
pub text_triangles: usize,
pub artwork_bytes: usize,
pub vector_meshes: usize,
pub vector_bytes: usize,
pub vector_triangles: usize,
pub high_resolution_wallpaper: bool,
pub draw_calls: usize,
pub cpu_submission_ms: f64,
}
impl RenderStats {
fn new(target: Size) -> Self {
Self {
target,
viewport: Viewport::new(target),
font_antialiasing: "vector grayscale coverage",
multisample_samples: 0,
max_logical_font_px: 0.,
max_physical_font_px: 0.,
cached_glyphs: 0,
glyph_tessellations: 0,
glyph_failures: 0,
missing_glyphs: 0,
missing_images: 0,
glyph_geometry_bytes: 0,
cached_text_runs: 0,
text_geometry_bytes: 0,
text_triangles: 0,
artwork_bytes: 0,
vector_meshes: 0,
vector_bytes: 0,
vector_triangles: 0,
high_resolution_wallpaper: false,
draw_calls: 0,
cpu_submission_ms: 0.,
}
}
}
enum BatchKind {
Paint {
texture: GLuint,
start: usize,
count: usize,
},
Vector {
path: String,
rect: Rect,
tint: Color,
},
Text {
key: TextKey,
origin: [f32; 2],
tint: Color,
},
}
struct Batch {
kind: BatchKind,
clip: Option<Rect>,
fade: Option<[f32; 4]>,
}
pub struct Renderer {
program: GLuint,
vector_program: GLuint,
text_program: GLuint,
buffer: GLuint,
vao: GLuint,
white: Texture,
font_directory: PathBuf,
fonts: HashMap<u16, Font>,
failed_fonts: HashSet<u16>,
glyphs: HashMap<(char, u16), Glyph>,
text_runs: HashMap<TextKey, TextRun>,
textures: HashMap<String, Option<Texture>>,
vectors: HashMap<String, Option<vector::Mesh>>,
artwork_variants: HashMap<String, String>,
vertices: Vec<f32>,
batches: Vec<Batch>,
clip: Option<Rect>,
fade: Option<[f32; 4]>,
frame: u64,
stats: RenderStats,
pub info: GraphicsInfo,
}
impl Renderer {
pub fn new(font: &Path, gles: bool) -> Result<Self, String> {
let directory = font.parent().ok_or("Invalid font path")?;
let mut fonts = HashMap::new();
// Open only the common face. Metrics and outlines stay lazy even when
// the much larger CJK fallback is needed later.
fonts.insert(400, load_font(directory, 400)?);
let mut max_texture_size = 0;
let mut max_viewport = [0; 2];
let mut precision = 0;
unsafe {
gl::GetIntegerv(gl::MAX_TEXTURE_SIZE, &mut max_texture_size);
gl::GetIntegerv(gl::MAX_VIEWPORT_DIMS, max_viewport.as_mut_ptr());
if gles && gl::GetShaderPrecisionFormat::is_loaded() {
let mut range = [0; 2];
gl::GetShaderPrecisionFormat(
gl::FRAGMENT_SHADER,
gl::HIGH_FLOAT,
range.as_mut_ptr(),
&mut precision,
);
}
}
let fragment_highp = !gles || precision > 0;
let qualifier = if fragment_highp { "highp" } else { "mediump" };
let vertex_prefix = if gles {
format!("#version 100\nprecision highp float;\n#define PAINT_PRECISION {qualifier}\n")
} else {
"#version 150\n#define attribute in\n#define varying out\n#define PAINT_PRECISION\n"
.into()
};
let fragment_prefix = if gles {
format!(
"#version 100\nprecision {qualifier} float;\n#define PAINT_PRECISION {qualifier}\n"
)
} else {
"#version 150\n#define varying in\n#define texture2D texture\nout vec4 out_color;\n#define gl_FragColor out_color\n#define PAINT_PRECISION\n".into()
};
let program = link(
&(vertex_prefix.clone() + include_str!("shaders/paint.vert")),
&(fragment_prefix.clone() + include_str!("shaders/paint.frag")),
&[c"a_position", c"a_uv", c"a_color", c"a_shape", c"a_params"],
)?;
let vector_program = match link(
&(vertex_prefix.clone() + include_str!("shaders/vector.vert")),
&(fragment_prefix.clone() + include_str!("shaders/vector.frag")),
&[c"a_position", c"a_offset", c"a_color", c"a_coverage"],
) {
Ok(program) => program,
Err(error) => {
unsafe { gl::DeleteProgram(program) };
return Err(error);
}
};
let text_program = match link(
&(vertex_prefix + include_str!("shaders/text.vert")),
&(fragment_prefix + include_str!("shaders/vector.frag")),
&[c"a_position", c"a_offset", c"a_origin", c"a_coverage"],
) {
Ok(program) => program,
Err(error) => {
unsafe {
gl::DeleteProgram(program);
gl::DeleteProgram(vector_program);
}
return Err(error);
}
};
let mut buffer = 0;
let mut vao = 0;
unsafe {
if !gles {
gl::GenVertexArrays(1, &mut vao);
gl::BindVertexArray(vao);
}
gl::GenBuffers(1, &mut buffer);
gl::Enable(gl::BLEND);
// Preserve an opaque destination alpha. Using BlendFunc for both
// channels makes translucent UI darken again in PNG/window compositing.
gl::BlendFuncSeparate(
gl::SRC_ALPHA,
gl::ONE_MINUS_SRC_ALPHA,
gl::ONE,
gl::ONE_MINUS_SRC_ALPHA,
);
gl::Disable(gl::DEPTH_TEST);
gl::PixelStorei(gl::UNPACK_ALIGNMENT, 1);
}
let white = texture(1, 1, &[255; 4]);
Ok(Self {
program,
vector_program,
text_program,
buffer,
vao,
white,
font_directory: directory.into(),
fonts,
failed_fonts: HashSet::new(),
glyphs: HashMap::new(),
text_runs: HashMap::new(),
textures: HashMap::new(),
vectors: HashMap::new(),
artwork_variants: HashMap::new(),
vertices: Vec::with_capacity(65536),
batches: Vec::new(),
clip: None,
fade: None,
frame: 0,
stats: RenderStats::new(Size::HD),
info: GraphicsInfo {
vendor: gl_string(gl::VENDOR),
renderer: gl_string(gl::RENDERER),
version: gl_string(gl::VERSION),
max_texture_size: max_texture_size.max(0) as u32,
max_viewport,
fragment_highp,
},
})
}
fn ensure_font(&mut self, id: u16) -> bool {
if self.fonts.contains_key(&id) {
return true;
}
if self.failed_fonts.contains(&id) {
return false;
}
match load_font(&self.font_directory, id) {
Ok(font) => {
self.fonts.insert(id, font);
true
}
Err(error) => {
eprintln!("{error}");
self.failed_fonts.insert(id);
false
}
}
}
fn glyph(&mut self, c: char, font: u16) -> Result<&[luma_vector_geometry::Vertex], String> {
let key = (c, font);
if !self.glyphs.contains_key(&key) {
let geometry = self.fonts[&font].geometry(c)?;
self.glyphs.insert(
key,
Glyph {
geometry,
frame: self.frame,
},
);
self.stats.glyph_tessellations += 1;
}
let glyph = self.glyphs.get_mut(&key).unwrap();
glyph.frame = self.frame;
Ok(&glyph.geometry)
}
fn trim_text_geometry(&mut self) {
let glyph_bytes =
|glyph: &Glyph| glyph.geometry.len() * size_of::<luma_vector_geometry::Vertex>();
let mut bytes: usize = self.glyphs.values().map(glyph_bytes).sum();
if bytes > GLYPH_GEOMETRY_BUDGET || self.glyphs.len() > 4096 {
let mut old: Vec<_> = self
.glyphs
.iter()
.filter(|(_, glyph)| glyph.frame != self.frame)
.map(|(key, glyph)| (*key, glyph.frame))
.collect();
old.sort_by_key(|(_, frame)| *frame);
for (key, _) in old {
if bytes <= GLYPH_GEOMETRY_BUDGET && self.glyphs.len() <= 4096 {
break;
}
if let Some(glyph) = self.glyphs.remove(&key) {
bytes -= glyph_bytes(&glyph);
}
}
}
let run_bytes = |run: &TextRun| run.mesh.as_ref().map_or(0, |mesh| mesh.bytes);
let mut bytes: usize = self.text_runs.values().map(run_bytes).sum();
if bytes > TEXT_GEOMETRY_BUDGET || self.text_runs.len() > 512 {
let mut old: Vec<_> = self
.text_runs
.iter()
.filter(|(_, run)| run.frame != self.frame)
.map(|(key, run)| (key.clone(), run.frame))
.collect();
old.sort_by_key(|(_, frame)| *frame);
for (key, _) in old {
if bytes <= TEXT_GEOMETRY_BUDGET && self.text_runs.len() <= 512 {
break;
}
if let Some(run) = self.text_runs.remove(&key) {
bytes -= run_bytes(&run);
}
}
}
self.stats.cached_glyphs = self.glyphs.len();
self.stats.glyph_geometry_bytes = self.glyphs.values().map(glyph_bytes).sum();
self.stats.cached_text_runs = self.text_runs.len();
self.stats.text_geometry_bytes = self.text_runs.values().map(run_bytes).sum();
}
fn quad(
&mut self,
rect: Rect,
uv: [f32; 4],
radius: f32,
border: f32,
color: Color,
texture: GLuint,
) {
self.gradient_quad(rect, uv, radius, border, [color, color], texture);
}
fn gradient_quad(
&mut self,
rect: Rect,
uv: [f32; 4],
radius: f32,
border: f32,
colors: [Color; 2],
texture: GLuint,
) {
if rect.w <= 0. || rect.h <= 0. {
return;
}
let start = self.vertices.len() / 14;
for (x, y) in [(0., 0.), (1., 0.), (0., 1.), (0., 1.), (1., 0.), (1., 1.)] {
let color = colors[y as usize];
self.vertices.extend_from_slice(&[
rect.x + x * rect.w,
rect.y + y * rect.h,
uv[0] + x * (uv[2] - uv[0]),
uv[1] + y * (uv[3] - uv[1]),
color[0],
color[1],
color[2],
color[3],
(x - 0.5) * rect.w,
(y - 0.5) * rect.h,
rect.w / 2.,
rect.h / 2.,
radius,
border,
]);
}
if let Some(batch) = self.batches.last_mut() {
let same_clip = match (batch.clip, self.clip) {
(None, None) => true,
(Some(a), Some(b)) => a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h,
_ => false,
};
if same_clip
&& batch.fade == self.fade
&& let BatchKind::Paint {
texture: previous,
count,
..
} = &mut batch.kind
&& *previous == texture
{
*count += 6;
return;
}
}
self.batches.push(Batch {
kind: BatchKind::Paint {
texture,
start,
count: 6,
},
clip: self.clip,
fade: self.fade,
});
}
fn text(&mut self, text: &str, rect: Rect, style: TextStyle, color: Color, centered: bool) {
// Layout remains logical. Reuse the same run geometry at every density;
// its shader snaps insertion origins and computes pixel-sized coverage.
let size_f = style.size;
let viewport = self.stats.viewport;
self.stats.max_logical_font_px = self.stats.max_logical_font_px.max(size_f);
self.stats.max_physical_font_px = self
.stats
.max_physical_font_px
.max(size_f * viewport.text_scale());
let padded = rect.inset(-size_f);
if padded.x + padded.w < 0.
|| padded.y + padded.h < 0.
|| padded.x > 1920.
|| padded.y > 1080.
|| self.clip.is_some_and(|clip| {
padded.x + padded.w < clip.x
|| padded.y + padded.h < clip.y
|| padded.x > clip.x + clip.w
|| padded.y > clip.y + clip.h
})
{
return;
}
let origin = [rect.x, rect.y];
let key = TextKey {
text: text.into(),
weight: style.weight,
layout: [
size_f.to_bits(),
style.tracking.to_bits(),
style.line_height.to_bits(),
rect.w.to_bits(),
rect.h.to_bits(),
],
flags: u8::from(style.align_right)
| (u8::from(centered) << 1)
| (u8::from(self.stats.multisample_samples > 1) << 2),
};
if self.text_runs.contains_key(&key) {
self.submit_text(key, origin, color);
return;
}
let rect = Rect::new(0., 0., rect.w, rect.h);
let font_id = style.weight + if style.size >= 32. { 1000 } else { 0 };
let cjk_id = if style.weight >= 500 { 2500 } else { 2400 };
let hangul_id = cjk_id + 1000;
if !self.ensure_font(font_id) {
self.stats.glyph_failures += text.chars().count();
return;
}
let (mut needs_cjk, mut needs_hangul) = (false, false);
for c in text.chars() {
if !c.is_ascii_control() && self.fonts[&font_id].lookup_glyph_index(c) == 0 {
if is_hangul(c) {
needs_hangul = true;
} else {
needs_cjk = true;
}
}
}
// Each fallback loads only when a run needs it, so Korean text never
// pulls in the larger Chinese font and vice versa.
let cjk_ready = needs_cjk && self.ensure_font(cjk_id);
let hangul_ready = needs_hangul && self.ensure_font(hangul_id);
let fonts = &self.fonts;
let choose_font = |c: char| {
if fonts[&font_id].lookup_glyph_index(c) != 0 {
font_id
} else if is_hangul(c) {
if hangul_ready { hangul_id } else { font_id }
} else if cjk_ready {
cjk_id
} else {
font_id
}
};
let font = &fonts[&font_id];
let line_height = size_f * style.line_height;
let baseline = font.horizontal_line_metrics(size_f).map_or(size_f, |m| {
(line_height - m.ascent + m.descent) / 2. + m.ascent
});
let tracking = style.tracking * size_f;
let advance = |c| {
if c == '\n' {
0.
} else {
fonts[&choose_font(c)].metrics(c, size_f).advance_width + tracking
}
};
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
let mut width = 0.;
// Word wrapping, with character fallback for a title wider than the tile.
for word in text.split_inclusive(char::is_whitespace) {
let word_width: f32 = word.chars().map(advance).sum();
if width > 0. && width + word_width > rect.w {
lines.push(line.trim_end().into());
line.clear();
width = 0.;
}
for c in word.chars() {
if c == '\n' {
lines.push(line.trim_end().into());
line.clear();
width = 0.;
continue;
}
let w = advance(c);
if width > 0. && width + w > rect.w {
lines.push(line.trim_end().into());
line.clear();
width = 0.;
}
line.push(c);
width += w;
}
}
if !line.is_empty() {
lines.push(line.trim_end().into());
}
let mut placed = Vec::new();
for (index, line) in lines.iter().enumerate() {
let top = rect.y + index as f32 * line_height;
if index > 0 && top + line_height > rect.y + rect.h + 1. {
break;
}
let mut previous = None;
let width: f32 = line
.chars()
.map(|c| {
let kern = previous
.and_then(|p| font.horizontal_kern(p, c, size_f))
.unwrap_or(0.);
previous = Some(c);
advance(c) + kern
})
.sum();
let mut x = rect.x
+ if style.align_right {
rect.w - width
} else if centered {
(rect.w - width) / 2.
} else {
0.
};
let mut previous = None;
for c in line.chars() {
if let Some(previous) = previous {
x += font.horizontal_kern(previous, c, size_f).unwrap_or(0.);
}
let selected = choose_font(c);
let baseline = if selected == font_id {
baseline
} else {
fonts[&selected]
.horizontal_line_metrics(size_f)
.map_or(baseline, |m| {
(line_height - m.ascent + m.descent) / 2. + m.ascent
})
};
placed.push((c, x, top + baseline, selected));
x += advance(c);
previous = Some(c);
}
}
let mut vertices = Vec::new();
let mut missing_glyphs = 0;
let mut failures = 0;
let multisampled = self.stats.multisample_samples > 1;
for (c, x, baseline, selected) in placed {
if self.fonts[&selected].lookup_glyph_index(c) == 0 && !c.is_whitespace() {
missing_glyphs += 1;
}
match self.glyph(c, selected) {
Ok(geometry) => {
for triangle in geometry.chunks_exact(3) {
// True sample coverage needs only the original fill.
// Exclude the boundary fringe, not just its alpha.
if multisampled && triangle.iter().any(|vertex| vertex.coverage < 1.) {
continue;
}
for vertex in triangle {
vertices.extend_from_slice(&[
vertex.position.x * size_f,
vertex.position.y * size_f,
if multisampled { 0. } else { vertex.offset[0] },
if multisampled { 0. } else { vertex.offset[1] },
x,
baseline,
0.,
0.,
vertex.coverage,
]);
}
}
}
Err(error) => {
eprintln!("Luma glyph {c:?}: {error}");
failures += 1;
}
}
}
let mesh = if vertices.is_empty() {
None
} else {
match vector::Mesh::upload(1., 1., &vertices) {
Ok(mesh) => Some(mesh),
Err(error) => {
eprintln!("Luma text: {error}");
failures += 1;
None
}
}
};
self.text_runs.insert(
key.clone(),
TextRun {
mesh,
missing_glyphs,
failures,
frame: self.frame,
},
);
self.submit_text(key, origin, color);
}
fn submit_text(&mut self, key: TextKey, origin: [f32; 2], tint: Color) {
let run = self.text_runs.get_mut(&key).unwrap();
run.frame = self.frame;
self.stats.missing_glyphs += run.missing_glyphs;
self.stats.glyph_failures += run.failures;
if let Some(mesh) = &run.mesh {
self.stats.text_triangles += mesh.vertices as usize / 3;
self.batches.push(Batch {
kind: BatchKind::Text { key, origin, tint },
clip: self.clip,
fade: self.fade,
});
}
}
fn image(&mut self, path: &str, mut rect: Rect, radius: f32, contain: bool, tint: Color) {
if path.ends_with(".lvec") {
self.vector_image(path, rect, contain, tint);
return;
}
let path = if self.stats.viewport.scale > 1.
&& self.info.max_texture_size >= 3840
&& Path::new(path)
.parent()
.and_then(Path::file_name)
.is_some_and(|p| p == "wallpapers")
{
self.artwork_variants
.entry(path.into())
.or_insert_with(|| {
let original = Path::new(path);
let variant = original.with_file_name(format!(
"{}@2x.jpg",
original.file_stem().unwrap_or_default().to_string_lossy()
));
if variant.is_file() {
variant.to_string_lossy().into_owned()
} else {
path.into()
}
})
.clone()
} else {
path.into()
};
if path.ends_with("@2x.jpg") {
self.stats.high_resolution_wallpaper = true;
}
let frame = self.frame;
let entry = self
.textures
.entry(path.clone())
.or_insert_with(|| match image::open(&path) {
Ok(image) => {
let image = image.into_rgba8();
Some(texture(image.width(), image.height(), image.as_raw()))
}
Err(error) => {
eprintln!("Artwork {path}: {error}");
None
}
});
let Some(texture) = entry else {
self.stats.missing_images += 1;
return;
};
texture.frame = frame;
let id = texture.id;
if contain {
let scale = (rect.w / texture.width as f32).min(rect.h / texture.height as f32);
let w = texture.width as f32 * scale;
let h = texture.height as f32 * scale;
rect = Rect::new(rect.x + (rect.w - w) / 2., rect.y + (rect.h - h) / 2., w, h);
}
self.quad(rect, [0., 0., 1., 1.], radius, 0., tint, id);
}
fn vector_image(&mut self, path: &str, mut rect: Rect, contain: bool, tint: Color) {
if rect.w <= 0. || rect.h <= 0. {
return;
}
let mesh = self.vectors.entry(path.into()).or_insert_with(|| {
match vector::Mesh::load(Path::new(path)) {
Ok(mesh) => Some(mesh),
Err(error) => {
eprintln!("{error}");
None
}
}
});
let Some(mesh) = mesh else {
self.stats.missing_images += 1;
return;
};
if contain {
let scale = (rect.w / mesh.width).min(rect.h / mesh.height);
let width = mesh.width * scale;
let height = mesh.height * scale;
rect = Rect::new(
rect.x + (rect.w - width) / 2.,
rect.y + (rect.h - height) / 2.,
width,
height,
);
}
let clip = if let Some(clip) = self.clip {
let x = rect.x.max(clip.x);
let y = rect.y.max(clip.y);
Rect::new(
x,
y,
(rect.x + rect.w).min(clip.x + clip.w).max(x) - x,
(rect.y + rect.h).min(clip.y + clip.h).max(y) - y,
)
} else {
rect
};
if clip.w <= 0. || clip.h <= 0. {
return;
}
self.stats.vector_triangles += mesh.vertices as usize / 3;
self.batches.push(Batch {
kind: BatchKind::Vector {
path: path.into(),
rect,
tint,
},
clip: Some(clip),
fade: self.fade,
});
}
fn app_image(&mut self, image: &crate::app_artwork::Artwork, rect: Rect, tint: Color) {
let entry = self.textures.entry(image.key.clone()).or_insert_with(|| {
Some(texture(
image.pixels.width(),
image.pixels.height(),
image.pixels.as_raw(),
))
});
let Some(texture) = entry else {
return;
};
texture.frame = self.frame;
let scale = (rect.w / texture.width as f32).min(rect.h / texture.height as f32);
let w = texture.width as f32 * scale;
let h = texture.height as f32 * scale;
let id = texture.id;
self.quad(
Rect::new(rect.x + (rect.w - w) / 2., rect.y + (rect.h - h) / 2., w, h),
[0., 0., 1., 1.],
0.,
0.,
tint,
id,
);
}
pub fn render(&mut self, commands: &[Draw], width: u32, height: u32) {
let started = Instant::now();
self.stats = RenderStats::new(Size::new(width, height));
// Captures can bind a single-sample FBO even when the actual TV window
// is multisampled, so query the active target rather than assume.
let mut samples = 0;
unsafe { gl::GetIntegerv(gl::SAMPLES, &mut samples) };
self.stats.multisample_samples = samples.max(0) as u32;
if samples > 1 {
self.stats.font_antialiasing = "multisample vector coverage";
}
self.frame += 1;
self.vertices.clear();
self.batches.clear();
self.clip = None;
self.fade = None;
for command in commands {
match command {
Draw::Shape(rect, radius, border, color) => self.quad(
*rect,
[0., 0., 1., 1.],
*radius,
*border,
*color,
self.white.id,
),
Draw::RoundedGradient(rect, radius, top, bottom) => self.gradient_quad(
*rect,
[0., 0., 1., 1.],
*radius,
0.,
[*top, *bottom],
self.white.id,
),
Draw::Shadow(rect, radius, blur, color) => self.quad(
rect.inset(-*blur),
[0., 0., 1., 1.],
*radius,
-*blur,
*color,
self.white.id,
),
Draw::Text(text, rect, size, color, centered) => {
self.text(text, *rect, *size, *color, *centered)
}
Draw::Image(path, rect, radius, contain) => {
self.image(path, *rect, *radius, *contain, [1.; 4])
}
Draw::TintedImage(path, rect, radius, contain, tint) => {
self.image(path, *rect, *radius, *contain, *tint)
}
Draw::AppImage(image, rect, tint) => self.app_image(image, *rect, *tint),
Draw::Clip(clip) => self.clip = *clip,
Draw::EdgeFade(fade) => self.fade = *fade,
}
}
let Viewport {
x: vx,
y: vy,
width: vw,
height: vh,
scale,
} = self.stats.viewport;
unsafe {
gl::Disable(gl::SCISSOR_TEST);
gl::Viewport(0, 0, width as i32, height as i32);
gl::ClearColor(0.04, 0.04, 0.05, 1.);
gl::Clear(gl::COLOR_BUFFER_BIT);
gl::Viewport(vx, vy, vw, vh);
if self.vao != 0 {
gl::BindVertexArray(self.vao);
}
gl::BindBuffer(gl::ARRAY_BUFFER, self.buffer);
gl::BufferData(
gl::ARRAY_BUFFER,
(self.vertices.len() * size_of::<f32>()) as isize,
self.vertices.as_ptr().cast(),
gl::DYNAMIC_DRAW,
);
gl::UseProgram(self.program);
gl::Uniform1f(
gl::GetUniformLocation(self.program, c"u_aa".as_ptr()),
0.85 / scale.max(0.01),
);
gl::Uniform1i(
gl::GetUniformLocation(self.program, c"u_texture".as_ptr()),
0,
);
gl::ActiveTexture(gl::TEXTURE0);
let mut paint_bound = false;
let mut current_fade = None;
for batch in &self.batches {
if current_fade != Some(batch.fade) {
// The same cheap alpha mask applies to shapes, textures,
// icons and text; the unmasked wallpaper remains beneath.
let fade = batch.fade.unwrap_or([0., 0., 0., 0.]);
for program in [self.program, self.vector_program, self.text_program] {
gl::UseProgram(program);
gl::Uniform4f(
gl::GetUniformLocation(program, c"u_edge_fade".as_ptr()),
fade[0],
fade[1],
fade[2],
fade[3],
);
}
current_fade = Some(batch.fade);
paint_bound = false;
}
if let Some(clip) = batch.clip {
gl::Enable(gl::SCISSOR_TEST);
gl::Scissor(
vx + (clip.x * vw as f32 / 1920.).round() as i32,
vy + ((1080. - clip.y - clip.h) * vh as f32 / 1080.).round() as i32,
(clip.w * vw as f32 / 1920.).round() as i32,
(clip.h * vh as f32 / 1080.).round() as i32,
);
} else {
gl::Disable(gl::SCISSOR_TEST);
}
match &batch.kind {
BatchKind::Paint {
texture,
start,
count,
} => {
if !paint_bound {
gl::UseProgram(self.program);
gl::BindBuffer(gl::ARRAY_BUFFER, self.buffer);
let mut offset = 0;
for (index, size) in [2, 2, 4, 4, 2].into_iter().enumerate() {
gl::EnableVertexAttribArray(index as u32);
gl::VertexAttribPointer(
index as u32,
size,
gl::FLOAT,
gl::FALSE,
56,
(offset * size_of::<f32>()) as *const _,
);
offset += size as usize;
}
paint_bound = true;
}
gl::BindTexture(gl::TEXTURE_2D, *texture);
gl::DrawArrays(gl::TRIANGLES, *start as i32, *count as i32);
}
BatchKind::Vector { path, rect, tint } => {
let Some(mesh) = self.vectors.get(path).and_then(Option::as_ref) else {
continue;
};
self.draw_mesh(
self.vector_program,
mesh,
[rect.w / mesh.width, rect.h / mesh.height, rect.x, rect.y],
*tint,
);
paint_bound = false;
}
BatchKind::Text { key, origin, tint } => {
let Some(mesh) = self.text_runs.get(key).and_then(|run| run.mesh.as_ref())
else {
continue;
};
self.draw_mesh(
self.text_program,
mesh,
[1., 1., origin[0], origin[1]],
*tint,
);
paint_bound = false;
}
}
}
gl::Disable(gl::SCISSOR_TEST);
}
// Keep the current scene, evict old full-resolution wallpapers before memory grows.
let mut bytes: usize = self
.textures
.values()
.flatten()
.map(|t| t.width as usize * t.height as usize * 4)
.sum();
if bytes > TEXTURE_BUDGET {
let mut old: Vec<_> = self
.textures
.iter()
.filter_map(|(path, t)| {
t.as_ref()
.filter(|t| t.frame != self.frame)
.map(|t| (path.clone(), t.frame))
})
.collect();
old.sort_by_key(|(_, frame)| *frame);
for (path, _) in old {
if bytes <= TEXTURE_BUDGET {
break;
}
if let Some(Some(t)) = self.textures.remove(&path) {
bytes -= t.width as usize * t.height as usize * 4;
}
}
}
self.trim_text_geometry();
self.stats.vector_meshes = self.vectors.values().flatten().count();
self.stats.vector_bytes = self.vectors.values().flatten().map(|mesh| mesh.bytes).sum();
self.stats.artwork_bytes = bytes + self.stats.vector_bytes;
self.stats.draw_calls = self.batches.len();
self.stats.cpu_submission_ms = started.elapsed().as_secs_f64() * 1000.;
}
fn draw_mesh(&self, program: GLuint, mesh: &vector::Mesh, transform: [f32; 4], tint: Color) {
let viewport = self.stats.viewport;
unsafe {
gl::UseProgram(program);
if program == self.text_program {
gl::Uniform1f(
gl::GetUniformLocation(program, c"u_fringe".as_ptr()),
if self.stats.multisample_samples > 1 {
0.
} else {
0.5
},
);
}
gl::BindBuffer(gl::ARRAY_BUFFER, mesh.buffer);
gl::DisableVertexAttribArray(4);
let mut offset = 0;
for (index, size) in [2, 2, 4, 1].into_iter().enumerate() {
gl::EnableVertexAttribArray(index as u32);
gl::VertexAttribPointer(
index as u32,
size,
gl::FLOAT,
gl::FALSE,
36,
(offset * size_of::<f32>()) as *const _,
);
offset += size as usize;
}
gl::Uniform4f(
gl::GetUniformLocation(program, c"u_transform".as_ptr()),
transform[0],
transform[1],
transform[2],
transform[3],
);
gl::Uniform2f(
gl::GetUniformLocation(program, c"u_pixel_scale".as_ptr()),
viewport.width as f32 / 1920.,
viewport.height as f32 / 1080.,
);
gl::Uniform4f(
gl::GetUniformLocation(program, c"u_tint".as_ptr()),
tint[0],
tint[1],
tint[2],
tint[3],
);
gl::DrawArrays(gl::TRIANGLES, 0, mesh.vertices);
}
}
pub fn stats(&self) -> &RenderStats {
&self.stats
}
pub fn screenshot(&self, path: &Path, width: u32, height: u32) -> Result<(), String> {
let mut pixels = vec![0; width as usize * height as usize * 4];
unsafe {
gl::ReadPixels(
0,
0,
width as i32,
height as i32,
gl::RGBA,
gl::UNSIGNED_BYTE,
pixels.as_mut_ptr().cast(),
);
}
let mut image = image::RgbaImage::from_raw(width, height, pixels)
.ok_or("Invalid framebuffer dimensions")?;
image::imageops::flip_vertical_in_place(&mut image);
image.save(path).map_err(|e| e.to_string())
}
/// Capture the authored TV surface, independent of desktop HiDPI/window limits.
pub fn capture(
&mut self,
commands: &[Draw],
path: &Path,
size: Size,
) -> Result<RenderStats, String> {
if size.width > self.info.max_texture_size
|| size.height > self.info.max_texture_size
|| size.width > self.info.max_viewport[0] as u32
|| size.height > self.info.max_viewport[1] as u32
{
return Err(format!(
"Capture {}x{} exceeds GPU limits: {}px texture, {:?} viewport",
size.width, size.height, self.info.max_texture_size, self.info.max_viewport
));
}
let image = texture(size.width, size.height, &[]);
let mut framebuffer = 0;
unsafe {
gl::GenFramebuffers(1, &mut framebuffer);
gl::BindFramebuffer(gl::FRAMEBUFFER, framebuffer);
gl::FramebufferTexture2D(
gl::FRAMEBUFFER,
gl::COLOR_ATTACHMENT0,
gl::TEXTURE_2D,
image.id,
0,
);
if gl::CheckFramebufferStatus(gl::FRAMEBUFFER) != gl::FRAMEBUFFER_COMPLETE {
gl::BindFramebuffer(gl::FRAMEBUFFER, 0);
gl::DeleteFramebuffers(1, &framebuffer);
return Err("Screenshot framebuffer is incomplete".into());
}
}
self.render(commands, size.width, size.height);
let stats = self.stats.clone();
let result = self.screenshot(path, size.width, size.height);
unsafe {
gl::BindFramebuffer(gl::FRAMEBUFFER, 0);
gl::DeleteFramebuffers(1, &framebuffer);
}
result.map(|_| stats)
}
}
// Noto Sans SC (font-cjk) has no Hangul syllables; these go to font-kr instead.
// Keep in sync with is_hangul() in tools/font-weights.py, which builds font-kr.
fn is_hangul(c: char) -> bool {
matches!(
c,
'\u{1100}'..='\u{11FF}'
| '\u{3130}'..='\u{318F}'
| '\u{A960}'..='\u{A97F}'
| '\u{AC00}'..='\u{D7A3}'
| '\u{D7B0}'..='\u{D7FF}'
)
}
fn load_font(directory: &Path, id: u16) -> Result<Font, String> {
let name = if id >= 3000 {
format!("font-kr-{}.ttf", id - 3000)
} else if id >= 2000 {
format!("font-cjk-{}.ttf", id - 2000)
} else if id >= 1000 {
format!("font-display-{}.ttf", id - 1000)
} else {
format!("font-{id}.ttf")
};
let path = directory.join(&name);
let started = Instant::now();
let bytes = std::fs::read(&path)
.map_err(|e| format!("Font {}: {e}. Regenerate native assets.", path.display()))?;
let font = Font::from_bytes(bytes).map_err(|e| format!("Font {}: {e}", path.display()))?;
if cfg!(feature = "webos") {
eprintln!(
"Luma font: {name} loaded in {} ms",
started.elapsed().as_millis()
);
}
Ok(font)
}
impl Drop for Renderer {
fn drop(&mut self) {
unsafe {
gl::DeleteBuffers(1, &self.buffer);
gl::DeleteProgram(self.program);
gl::DeleteProgram(self.vector_program);
gl::DeleteProgram(self.text_program);
if self.vao != 0 {
gl::DeleteVertexArrays(1, &self.vao);
}
}
}
}
fn texture(width: u32, height: u32, pixels: &[u8]) -> Texture {
let mut id = 0;
unsafe {
gl::GenTextures(1, &mut id);
gl::BindTexture(gl::TEXTURE_2D, id);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32);
gl::TexImage2D(
gl::TEXTURE_2D,
0,
gl::RGBA as i32,
width as i32,
height as i32,
0,
gl::RGBA,
gl::UNSIGNED_BYTE,
if pixels.is_empty() {
std::ptr::null()
} else {
pixels.as_ptr().cast()
},
);
}
Texture {
id,
width,
height,
frame: 0,
}
}
fn gl_string(name: GLenum) -> String {
unsafe {
let value = gl::GetString(name);
if value.is_null() {
"unknown".into()
} else {
CStr::from_ptr(value.cast()).to_string_lossy().into_owned()
}
}
}
fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
let source = CString::new(source).map_err(|e| e.to_string())?;
unsafe {
let shader = gl::CreateShader(kind);
gl::ShaderSource(shader, 1, &source.as_ptr(), std::ptr::null());
gl::CompileShader(shader);
let mut success = 0;
gl::GetShaderiv(shader, gl::COMPILE_STATUS, &mut success);
if success == 0 {
let mut log = vec![0u8; 4096];
let mut len = 0;
gl::GetShaderInfoLog(shader, log.len() as i32, &mut len, log.as_mut_ptr().cast());
gl::DeleteShader(shader);
return Err(format!(
"Shader compilation: {}",
String::from_utf8_lossy(&log[..len as usize])
));
}
Ok(shader)
}
}
fn link(vertex: &str, fragment: &str, attributes: &[&CStr]) -> Result<GLuint, String> {
let vertex = compile(gl::VERTEX_SHADER, vertex)?;
let fragment = match compile(gl::FRAGMENT_SHADER, fragment) {
Ok(fragment) => fragment,
Err(error) => {
unsafe {
gl::DeleteShader(vertex);
}
return Err(error);
}
};
unsafe {
let program = gl::CreateProgram();
gl::AttachShader(program, vertex);
gl::AttachShader(program, fragment);
for (index, name) in attributes.iter().enumerate() {
gl::BindAttribLocation(program, index as u32, name.as_ptr());
}
gl::LinkProgram(program);
gl::DeleteShader(vertex);
gl::DeleteShader(fragment);
let mut success = 0;
gl::GetProgramiv(program, gl::LINK_STATUS, &mut success);
if success == 0 {
let mut log = vec![0u8; 4096];
let mut len = 0;
gl::GetProgramInfoLog(program, log.len() as i32, &mut len, log.as_mut_ptr().cast());
gl::DeleteProgram(program);
return Err(format!(
"Shader linking: {}",
String::from_utf8_lossy(&log[..len as usize])
));
}
Ok(program)
}
}