blob: 3c31cc89ee2fd7dc698d4658935053c3884438f8 [file]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Text rendering example scene.
use core::fmt;
use parley::FontFamily;
use parley::{
Alignment, AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext,
PositionedLayoutItem, StyleProperty,
};
use vello_common::color::palette::css::WHITE;
use vello_common::color::{AlphaColor, Srgb};
use vello_common::glyph::Glyph;
use vello_common::kurbo::Affine;
use vello_common::recording::{Recorder, Recording};
use crate::{ExampleScene, RenderingContext};
#[derive(Clone, Copy, Debug, PartialEq)]
struct ColorBrush {
color: AlphaColor<Srgb>,
}
impl Default for ColorBrush {
fn default() -> Self {
Self { color: WHITE }
}
}
// Wasm doesn't support system fonts, so we need to include the font data directly.
#[cfg(target_arch = "wasm32")]
const ROBOTO_FONT: &[u8] = include_bytes!("../../../examples/assets/roboto/Roboto-Regular.ttf");
/// State for the text example.
pub struct TextScene {
layout: Layout<ColorBrush>,
/// Whether recording functionality is enabled
recording_enabled: bool,
/// The recording to reuse
recording: CachedRecording,
}
impl fmt::Debug for TextScene {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TextScene")
}
}
impl ExampleScene for TextScene {
fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
if self.recording_enabled {
// Try to reuse existing recording if possible
let render_result = try_reuse_recording(ctx, &mut self.recording, root_transform);
if render_result.is_reused {
return;
}
// If we get here, we need to record fresh
record_fresh(self, ctx, root_transform);
} else {
// Direct rendering mode (no recording/caching)
#[cfg(not(target_arch = "wasm32"))]
let start = std::time::Instant::now();
ctx.set_transform(root_transform);
render_text(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
{
let elapsed = start.elapsed();
println!(
"Direct : {:.3}ms | No caching",
elapsed.as_secs_f64() * 1000.0
);
}
}
}
fn handle_key(&mut self, key: &str) -> bool {
match key {
"r" | "R" => {
self.toggle_recording();
true
}
_ => false,
}
}
}
impl TextScene {
/// Create a new `TextScene` with the given text.
pub fn new(text: &str) -> Self {
// Typically, you'd want to store 1 `layout_cx` and `font_cx` for the
// duration of the program (or have an instance per thread).
let mut layout_cx = LayoutContext::new();
#[cfg(not(target_arch = "wasm32"))]
let mut font_cx = FontContext::new();
#[cfg(target_arch = "wasm32")]
let mut font_cx = {
let mut font_cx = FontContext::new();
font_cx
.collection
.register_fonts(ROBOTO_FONT.to_vec().into(), None);
font_cx
};
let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
builder.push_default(FontFamily::parse("Roboto").unwrap());
builder.push_default(StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(1.3),
));
builder.push_default(StyleProperty::FontSize(32.0));
let mut layout: Layout<ColorBrush> = builder.build(text);
let max_advance = Some(400.0);
layout.break_all_lines(max_advance);
layout.align(max_advance, Alignment::Middle, AlignmentOptions::default());
Self {
layout,
recording_enabled: true,
recording: CachedRecording::new(),
}
}
}
impl Default for TextScene {
fn default() -> Self {
Self::new("Hello, Vello!")
}
}
struct RenderResult {
is_reused: bool,
}
struct CachedRecording {
// The transform the recording was taken at. Informs if recording can be re-used or if it needs
// to be re-recorded.
pub(crate) transform_key: Option<Affine>,
// The recording absolutely positioned from the `transform_key`.
recording: Recording,
}
impl CachedRecording {
fn new() -> Self {
Self {
transform_key: None,
recording: Recording::new(),
}
}
}
/// Try to reuse an existing recording, either directly (TODO: or with translation)
fn try_reuse_recording(
ctx: &mut impl RenderingContext,
recording: &mut CachedRecording,
current_transform: Affine,
) -> RenderResult {
// There is no `transform_key` meaning there is no valid recording to execute.
let Some(recording_transform) = recording.transform_key else {
return RenderResult { is_reused: false };
};
#[cfg(not(target_arch = "wasm32"))]
let start = std::time::Instant::now();
// Case 1: Identical transforms - can reuse directly
if transforms_are_identical(recording_transform, current_transform) {
ctx.execute_recording(&recording.recording);
#[cfg(not(target_arch = "wasm32"))]
print_render_stats("Identical ", start.elapsed(), &recording.recording);
return RenderResult { is_reused: true };
}
// TODO: Implement "Case 2: Check if we can use with translation"
// Case 3: Can't optimize, need to record fresh
RenderResult { is_reused: false }
}
/// Record a fresh scene from scratch
fn record_fresh(
scene_obj: &mut TextScene,
ctx: &mut impl RenderingContext,
current_transform: Affine,
) {
#[cfg(not(target_arch = "wasm32"))]
let start = std::time::Instant::now();
scene_obj.recording.transform_key = Some(current_transform);
let recording = &mut scene_obj.recording.recording;
recording.clear();
ctx.record(recording, |recorder| {
render_text_record(&mut scene_obj.layout, recorder, current_transform);
});
ctx.prepare_recording(recording);
ctx.execute_recording(recording);
#[cfg(not(target_arch = "wasm32"))]
print_render_stats("Fresh ", start.elapsed(), recording);
}
/// Print timing and statistics for a render operation
#[cfg(not(target_arch = "wasm32"))]
fn print_render_stats(render_type: &str, elapsed: std::time::Duration, recording: &Recording) {
println!(
"Text | {}: {:.3}ms | Strips: {} | Alphas: {}",
render_type,
elapsed.as_secs_f64() * 1000.0,
recording.strip_count(),
recording.alpha_count()
);
}
/// Check if two transforms are identical (within tolerance)
fn transforms_are_identical(a: Affine, b: Affine) -> bool {
let a_coeffs = a.as_coeffs();
let b_coeffs = b.as_coeffs();
let tolerance = 1e-6;
for i in 0..6 {
if (a_coeffs[i] - b_coeffs[i]).abs() > tolerance {
return false;
}
}
true
}
impl TextScene {
/// Toggle recording functionality on/off
/// Returns the new state (true = enabled, false = disabled)
pub fn toggle_recording(&mut self) -> bool {
self.recording_enabled = !self.recording_enabled;
self.recording_enabled
}
}
fn render_text(state: &mut TextScene, ctx: &mut impl RenderingContext) {
for line in state.layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
render_glyph_run(ctx, &glyph_run, 30);
}
}
}
}
/// Render text to recording
fn render_text_record(layout: &mut Layout<ColorBrush>, ctx: &mut Recorder<'_>, transform: Affine) {
ctx.set_transform(transform);
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
render_glyph_run_record(ctx, &glyph_run, 30);
}
}
}
}
fn render_glyph_run(
ctx: &mut impl RenderingContext,
glyph_run: &GlyphRun<'_, ColorBrush>,
padding: u32,
) {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let glyphs = glyph_run.glyphs().map(|glyph| {
let glyph_x = run_x + glyph.x + padding as f32;
let glyph_y = run_y - glyph.y + padding as f32;
run_x += glyph.advance;
Glyph {
id: glyph.id as u32,
x: glyph_x,
y: glyph_y,
}
});
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let normalized_coords = bytemuck::cast_slice(run.normalized_coords());
let style = glyph_run.style();
ctx.set_paint(style.brush.color);
ctx.glyph_run(font)
.font_size(font_size)
.normalized_coords(normalized_coords)
.hint(true)
.fill_glyphs(glyphs);
}
fn render_glyph_run_record(
ctx: &mut Recorder<'_>,
glyph_run: &GlyphRun<'_, ColorBrush>,
padding: u32,
) {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let glyphs = glyph_run.glyphs().map(|glyph| {
let glyph_x = run_x + glyph.x + padding as f32;
let glyph_y = run_y - glyph.y + padding as f32;
run_x += glyph.advance;
Glyph {
id: glyph.id as u32,
x: glyph_x,
y: glyph_y,
}
});
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let normalized_coords = bytemuck::cast_slice(run.normalized_coords());
let style = glyph_run.style();
ctx.set_paint(style.brush.color);
ctx.glyph_run(font)
.font_size(font_size)
.normalized_coords(normalized_coords)
.hint(true)
.fill_glyphs(glyphs);
}