Skip to content

Commit

Permalink
Text rendering (#491)
Browse files Browse the repository at this point in the history
* Text example

* Text layout

* Clean-up

* More clean-up

* More clean-up

* Immutable methods

* new_with_scaler

* Example clean-up

* Move text generation to three-d

* Documentation + import

* Add posibility to move camera

* Improved text example

* Fix text example on web

* Handle line breaks

* Improved text example

* White background

* Don't expose swash

* Size as input to generator

* Avoid exposing swash

* Font index as input parameter + error handling
  • Loading branch information
asny authored Oct 1, 2024
1 parent c475f83 commit 06b1280
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 1 deletion.
10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ default = ["window"]
window = ["glutin", "winit", "raw-window-handle", "wasm-bindgen", "serde", "serde-wasm-bindgen", "web-sys"] # Window module
headless = ["glutin_029"] # Headless rendering
egui-gui = ["egui_glow", "egui", "getrandom"] # Additional GUI features
text = ["swash", "lyon"] # Text mesh generation features

[dependencies]
glow = "0.13"
cgmath = "0.18"
three-d-asset = {version = "0.7"}
thiserror = "1"
open-enum = "0.5"
winit = {version = "0.28", optional = true}
egui = { version = "0.28", optional = true }
egui_glow = { version = "0.28", optional = true }
getrandom = { version = "0.2", features = ["js"], optional = true }
open-enum = "0.5"
swash = { version = "0.1", optional = true }
lyon = { version = "1", optional = true }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
glutin = { version = "0.30", optional = true }
Expand Down Expand Up @@ -59,6 +62,11 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
name = "triangle"
path = "examples/triangle/src/main.rs"

[[example]]
name = "text"
path = "examples/text/src/main.rs"
required-features = ["text"]

[[example]]
name = "triangle_core"
path = "examples/triangle_core/src/main.rs"
Expand Down
18 changes: 18 additions & 0 deletions examples/text/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "text"
version = "0.1.0"
authors = ["Asger Nyman Christiansen <[email protected]>"]
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
three-d = { path = "../../", features = ["text"] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
log = "0.4"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
console_error_panic_hook = "0.1"
console_log = "1"
Binary file added examples/text/src/font0.ttf
Binary file not shown.
Binary file added examples/text/src/font1.ttf
Binary file not shown.
19 changes: 19 additions & 0 deletions examples/text/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#![allow(special_module_name)]
mod main;

// Entry point for wasm
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;

#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
console_log::init_with_level(log::Level::Debug).unwrap();

use log::info;
info!("Logging works!");

std::panic::set_hook(Box::new(console_error_panic_hook::hook));
main::main();
Ok(())
}
88 changes: 88 additions & 0 deletions examples/text/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use three_d::*;

pub fn main() {
let window = Window::new(WindowSettings {
title: "Text!".to_string(),
max_size: Some((1280, 720)),
..Default::default()
})
.unwrap();

let context = window.gl();

let mut camera = Camera::new_orthographic(
window.viewport(),
vec3(window.viewport().width as f32 * 0.5, 0.0, 2.0),
vec3(window.viewport().width as f32 * 0.5, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
window.viewport().height as f32,
0.1,
10.0,
);

let text_generator = TextGenerator::new(include_bytes!("font0.ttf"), 0, 30.0).unwrap();
let text_mesh0 = text_generator.generate("Hello, World!");
let text_mesh1 = text_generator.generate("Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Vivamus rutrum, augue vitae interdum dapibus, risus velit interdum dui, sit amet condimentum wisi sem vel odio. Nam lorem. Sed et leo sed est vehicula suscipit. Nunc volutpat, sapien non laoreet cursus, ipsum ipsum varius velit, sit amet lacinia nulla enim quis erat. Curabitur sagittis. Donec quis nulla et wisi molestie consequat. Nulla vel neque. Proin dignissim volutpat leo.
Suspendisse ac libero sit amet leo bibendum aliquam. Pellentesque nisl. Etiam sed sem et purus convallis mattis. Sed fringilla eros id risus.
Aliquam fermentum mattis lectus. Nunc luctus. Integer accumsan pede quis risus. Vestibulum et ante.
Morbi dolor. In nisl. Curabitur malesuada.
Morbi tincidunt semper tortor. Maecenas hendrerit. Vivamus fermentum ante ut wisi. Nunc mattis. Praesent nunc. Suspendisse potenti. Morbi sapien.
Quisque sapien libero, ornare eget, tincidunt semper, convallis vel, sem. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; ");

let text_generator = TextGenerator::new(include_bytes!("font1.ttf"), 0, 100.0).unwrap();
let text_mesh2 = text_generator.generate("Hi!\nHow are you?");

// Create models
let mut text0 = Gm::new(
Mesh::new(&context, &text_mesh0),
ColorMaterial {
color: Srgba::RED,
..Default::default()
},
);
text0.set_transformation(Mat4::from_scale(10.0));

let mut text1 = Gm::new(
Mesh::new(&context, &text_mesh1),
ColorMaterial {
color: Srgba::BLACK,
..Default::default()
},
);
text1.set_transformation(Mat4::from_translation(vec3(50.0, 500.0, 0.0)));

let mut text2 = Gm::new(
Mesh::new(&context, &text_mesh2),
ColorMaterial {
color: Srgba::BLACK,
..Default::default()
},
);
text2.set_transformation(Mat4::from_translation(vec3(1000.0, -200.0, 0.0)));

// Render loop
window.render_loop(move |frame_input| {
camera.set_viewport(frame_input.viewport);

for event in frame_input.events.iter() {
match *event {
Event::MouseMotion { delta, button, .. } => {
if button == Some(MouseButton::Left) {
let speed = 1.3;
let right = camera.right_direction();
let up = right.cross(camera.view_direction());
let delta = -right * speed * delta.0 + up * speed * delta.1;
camera.translate(&delta);
}
}
_ => {}
}
}

frame_input
.screen()
.clear(ClearState::color_and_depth(1.0, 1.0, 1.0, 1.0, 1.0))
.render(&camera, &[&text0, &text1, &text2], &[]);
FrameOutput::default()
});
}
8 changes: 8 additions & 0 deletions src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub enum RendererError {
InvalidBufferLength(String, usize, usize),
#[error("the material {0} is required by the geometry {1} but could not be found")]
MissingMaterial(String, String),
#[cfg(feature = "text")]
#[error("Failed to find font with index {0} in the given font collection")]
MissingFont(u32),
}

mod shader_ids;
Expand All @@ -55,6 +58,11 @@ pub use object::*;
pub mod control;
pub use control::*;

#[cfg(feature = "text")]
mod text;
#[cfg(feature = "text")]
pub use text::*;

macro_rules! impl_render_target_extensions_body {
() => {
///
Expand Down
134 changes: 134 additions & 0 deletions src/renderer/text.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use crate::*;
use lyon::math::Point;
use lyon::path::Path;
use lyon::tessellation::*;
use std::collections::HashMap;
use swash::zeno::{Command, PathData};
use swash::{scale::ScaleContext, shape::ShapeContext, FontRef, GlyphId};

///
/// A utility struct for generating a [CpuMesh] from a text string with a given font.
///
pub struct TextGenerator<'a> {
map: HashMap<GlyphId, CpuMesh>,
font: FontRef<'a>,
line_height: f32,
size: f32,
}

impl<'a> TextGenerator<'a> {
///
/// Creates a new TextGenerator with the given font and size in pixels per em.
/// The index indicates the specific font in a font collection. Set to 0 if unsure.
///
pub fn new(font_bytes: &'a [u8], font_index: u32, size: f32) -> Result<Self, RendererError> {
let font = FontRef::from_index(font_bytes, font_index as usize)
.ok_or(RendererError::MissingFont(font_index))?;
let mut context = ScaleContext::new();
let mut scaler = context.builder(font).size(size).build();
let mut map = HashMap::new();
let mut line_height: f32 = 0.0;
font.charmap().enumerate(|_, id| {
if let Some(outline) = scaler.scale_outline(id) {
let mut builder = Path::builder();
for command in outline.path().commands() {
match command {
Command::MoveTo(p) => {
builder.begin(Point::new(p.x, p.y));
}
Command::LineTo(p) => {
builder.line_to(Point::new(p.x, p.y));
}
Command::CurveTo(p1, p2, p3) => {
builder.cubic_bezier_to(
Point::new(p1.x, p1.y),
Point::new(p2.x, p2.y),
Point::new(p3.x, p3.y),
);
}
Command::QuadTo(p1, p2) => {
builder.quadratic_bezier_to(
Point::new(p1.x, p1.y),
Point::new(p2.x, p2.y),
);
}
Command::Close => builder.close(),
}
}
let path = builder.build();

let mut tessellator = FillTessellator::new();
let mut geometry: VertexBuffers<Vec3, u32> = VertexBuffers::new();
let options = FillOptions::default();
if tessellator
.tessellate_path(
&path,
&options,
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| {
vec3(vertex.position().x, vertex.position().y, 0.0)
}),
)
.is_ok()
{
let mesh = CpuMesh {
positions: Positions::F32(geometry.vertices),
indices: Indices::U32(geometry.indices),
..Default::default()
};
line_height = line_height.max(mesh.compute_aabb().size().y);
map.insert(id, mesh);
}
}
});
Ok(Self {
map,
font,
line_height,
size,
})
}

///
/// Generates a [CpuMesh] from the given text string.
///
pub fn generate(&self, text: &str) -> CpuMesh {
let mut shape_context = ShapeContext::new();
let mut shaper = shape_context.builder(self.font).size(self.size).build();
let mut positions = Vec::new();
let mut indices = Vec::new();
let mut y = 0.0;
let mut x = 0.0;

shaper.add_str(text);
shaper.shape_with(|cluster| {
let t = text.get(cluster.source.to_range());
if matches!(t, Some("\n")) {
// Move to the next line
y -= self.line_height * 1.2; // Add 20% extra space between lines
x = 0.0;
}
for glyph in cluster.glyphs {
let mesh = self.map.get(&glyph.id).unwrap();

let index_offset = positions.len() as u32;
let Indices::U32(mesh_indices) = &mesh.indices else {
unreachable!()
};
indices.extend(mesh_indices.iter().map(|i| i + index_offset));

let position = vec3(x + glyph.x, y + glyph.y, 0.0);
let Positions::F32(mesh_positions) = &mesh.positions else {
unreachable!()
};
positions.extend(mesh_positions.iter().map(|p| p + position));
}
x += cluster.advance();
});

CpuMesh {
positions: Positions::F32(positions),
indices: Indices::U32(indices),
..Default::default()
}
}
}

0 comments on commit 06b1280

Please sign in to comment.