use glam::{Vec2, Vec3};
use super::{Camera3D, Transform3D, linear};
use crate::text::{SDF_PX, SDF_SPREAD, Text};
use crate::{Color, Ctx, Font, Texture};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u32)]
pub enum TextAlign3D {
Left = 0,
#[default]
Center = 1,
Right = 2,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text3D {
pub text: String,
pub font: Font,
pub size: f32,
pub color: Color,
pub outline: Color,
pub outline_width: f32,
pub softness: f32,
pub align: TextAlign3D,
pub billboard: bool,
}
impl Text3D {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
font: Font::DEFAULT,
size: 0.25,
color: Color::WHITE,
outline: Color::TRANSPARENT,
outline_width: 0.0,
softness: 0.0,
align: TextAlign3D::Center,
billboard: false,
}
}
}
pub(crate) const MAX_REACH: f32 = (SDF_SPREAD as f32 - 0.5) / SDF_PX;
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct TextVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
pub color: [f32; 4],
pub outline: [f32; 4],
pub params: [f32; 2],
}
#[derive(Default)]
pub(crate) struct TextFrame {
pub vertices: Vec<TextVertex>,
pub atlas: Option<Texture>,
order: Vec<(f32, crate::Entity)>,
}
impl TextFrame {
pub(crate) fn push(&mut self, t: &Text3D, at: &Transform3D, camera: &Camera3D, text: &mut Text) {
let block = text.layout_sdf(&t.text, t.font);
let side = Text::ATLAS_SIZE as f32;
let meters = t.size.max(0.0) / SDF_PX;
let widths = text.line_widths();
let place = |local: Vec2| -> Vec3 {
let local = local * meters;
if t.billboard {
let (right, up) = (camera.rotation * Vec3::X, camera.rotation * Vec3::Y);
at.position + (right * local.x * at.scale.x) + (up * local.y * at.scale.y)
} else {
at.matrix().transform_point3(local.extend(0.0))
}
};
let color = premultiplied(t.color);
let outline = premultiplied(t.outline);
let units = |lines: f32| lines.clamp(0.0, MAX_REACH) * SDF_PX / (2.0 * SDF_SPREAD as f32);
let params = [units(t.outline_width), units(t.softness)];
for p in text.placed() {
let Some(atlas_at) = p.glyph.at else { continue };
let size = Vec2::new(p.glyph.size[0] as f32, p.glyph.size[1] as f32);
let line = widths.get(p.line as usize).copied().unwrap_or(0.0);
let shift = match t.align {
TextAlign3D::Left => -block.x / 2.0,
TextAlign3D::Center => -line / 2.0,
TextAlign3D::Right => block.x / 2.0 - line,
};
let corner =
|dx: f32, dy: f32| Vec2::new(p.pos.x + dx * size.x + shift, block.y / 2.0 - (p.pos.y + dy * size.y));
let uv0 = Vec2::new(atlas_at[0] as f32, atlas_at[1] as f32) / side;
let uv = |dx: f32, dy: f32| (uv0 + Vec2::new(dx, dy) * size / side).to_array();
let v = |dx: f32, dy: f32| TextVertex {
position: place(corner(dx, dy)).to_array(),
uv: uv(dx, dy),
color,
outline,
params,
};
let (tl, tr, br, bl) = (v(0.0, 0.0), v(1.0, 0.0), v(1.0, 1.0), v(0.0, 1.0));
self.vertices.extend([tl, bl, br, tl, br, tr]);
}
}
}
fn premultiplied(c: Color) -> [f32; 4] {
let [r, g, b, a] = linear(c);
[r * a, g * a, b * a, a]
}
impl Ctx {
pub(crate) fn collect_text3d(&mut self) {
let frame = &mut self.frame3d.particles.texts;
frame.vertices.clear();
frame.order.clear();
let camera = &self.camera3d;
let (eye, forward) = (camera.position, camera.forward());
self.world
.query::<(crate::Entity, &Text3D, &Transform3D)>()
.for_each(|(e, t, at)| {
if !t.text.is_empty() && t.size > 0.0 {
frame.order.push(((at.position - eye).dot(forward), e));
}
});
if frame.order.is_empty() {
return;
}
frame.order.sort_by(|a, b| b.0.total_cmp(&a.0));
let atlas = match frame.atlas {
Some(t) => t,
None => {
let side = Text::ATLAS_SIZE;
let t = self.create_texture(side, side, &vec![0; (side * side * 4) as usize]);
self.frame3d.particles.texts.atlas = Some(t);
t
}
};
let mut frame = std::mem::take(&mut self.frame3d.particles.texts);
for &(_, e) in &frame.order.clone() {
let (Some(t), Some(at)) = (self.world.get::<Text3D>(e), self.world.get::<Transform3D>(e)) else {
continue;
};
frame.push(t, at, &self.camera3d, &mut self.text);
}
if let Some(renderer) = self.renderer.as_mut() {
for (at, size, rgba) in self.text.sdf.uploads.drain(..) {
renderer.update_texture_region(atlas.id, at, size, &rgba);
}
} else {
self.text.sdf.uploads.clear();
}
self.frame3d.particles.texts = frame;
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Quat;
fn quads(t: &Text3D, at: &Transform3D) -> Vec<TextVertex> {
let mut frame = TextFrame::default();
let mut camera = Camera3D::new(Vec2::new(800.0, 600.0));
camera.rotation = Quat::IDENTITY;
frame.push(t, at, &camera, &mut Text::new());
frame.vertices
}
fn bounds(v: &[TextVertex]) -> (Vec3, Vec3) {
v.iter()
.fold((Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), |(lo, hi), v| {
let p = Vec3::from_array(v.position);
(lo.min(p), hi.max(p))
})
}
#[test]
fn text_is_centered_on_its_entity_and_sized_in_meters() {
let t = Text3D::new("Hello");
let at = Transform3D::at(Vec3::new(1.0, 2.0, 3.0));
let v = quads(&t, &at);
assert_eq!(v.len(), 5 * 6, "a quad per letter");
let (lo, hi) = bounds(&v);
let center = (lo + hi) / 2.0;
assert!(
(center.x - 1.0).abs() < 0.05 && (center.y - 2.0).abs() < 0.08,
"{center}"
);
assert!(lo.z == 3.0 && hi.z == 3.0, "flat in its XY plane");
let pad = 2.0 * SDF_SPREAD as f32 / SDF_PX * t.size;
assert!(hi.y - lo.y < t.size + pad, "{}", hi.y - lo.y);
let (lo2, hi2) = bounds(&quads(&Text3D { size: 0.5, ..t.clone() }, &at));
assert!(((hi2.x - lo2.x) / (hi.x - lo.x) - 2.0).abs() < 1e-3);
}
#[test]
fn lines_line_up_as_asked() {
let at = Transform3D::default();
let text = "a\nwide line";
let left_of_first = |align| {
let v = quads(
&Text3D {
align,
..Text3D::new(text)
},
&at,
);
v[..6].iter().map(|v| v.position[0]).fold(f32::MAX, f32::min)
};
let (l, c, r) = (
left_of_first(TextAlign3D::Left),
left_of_first(TextAlign3D::Center),
left_of_first(TextAlign3D::Right),
);
assert!(l < c && c < r, "{l} {c} {r}");
let wide = |align| {
bounds(&quads(
&Text3D {
align,
..Text3D::new(text)
},
&at,
))
};
assert!((wide(TextAlign3D::Left).0.x - wide(TextAlign3D::Right).0.x).abs() < 1e-4);
}
#[test]
fn turned_text_follows_its_transform_and_billboards_face_the_camera() {
let turned = Transform3D::default().rotated(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
let (lo, hi) = bounds(&quads(&Text3D::new("Hi"), &turned));
assert!(hi.x - lo.x < 1e-4, "turned to face +X, it's flat in YZ");
assert!(hi.z - lo.z > 0.05);
let t = Text3D {
billboard: true,
..Text3D::new("Hi")
};
let (lo, hi) = bounds(&quads(&t, &turned));
assert!(hi.z - lo.z < 1e-4 && hi.x - lo.x > 0.05);
}
#[test]
fn outlines_and_softness_are_kept_within_the_glyphs_reach() {
let t = Text3D {
outline: Color::BLACK,
outline_width: 1.0,
softness: 0.05,
..Text3D::new("x")
};
let v = quads(&t, &Transform3D::default());
let [outline, soft] = v[0].params;
assert!(outline < 0.5 && outline > 0.4, "clamped below the spread: {outline}");
assert!((soft - 0.05 * SDF_PX / (2.0 * SDF_SPREAD as f32)).abs() < 1e-6);
assert_eq!(v[0].outline, [0.0, 0.0, 0.0, 1.0]);
assert_eq!(quads(&Text3D::new("a b"), &Transform3D::default()).len(), 12);
assert!(quads(&Text3D::new(""), &Transform3D::default()).is_empty());
}
}