facett-core/src/render/gpu/scene3d.rs
//! **Scene3D, the GPU lane** (feature `wgpu`) — ONE depth-tested 3D pass for flat
//! quads, screen-width line ribbons and **text as flat planes in world space**, drawn
//! into the shared offscreen colour+depth target and blitted into egui's pass, like the
//! graph cloud.
//!
//! * **Text** is a single-channel signed-distance-field glyph atlas, baked ONCE from
//! egui's own monospace font (`epaint_default_fonts::HACK_REGULAR`, outlines through
//! `ttf-parser`), drawn as textured quads on the [`Text3`]'s plane. The fragment
//! shader puts the 0.5 iso-line one `fwidth` wide, so a glyph is sharp at every zoom
//! and has true perspective; it is DEPTH-TESTED (not written), so a nearer card hides
//! the text behind it. A text whose em projects under [`Scene3D::min_text_px`] is not
//! drawn (it fades in over the next half of that).
//! * **Quads and lines** write depth, so they occlude each other and the text.
//! * **Sheets** ([`Scene3D::sheets`], a translucent source panel) come last, far to
//! near: depth-tested, NOT depth-writing, blended — a spline behind a sheet shows
//! through it — and then the sheets' own words ([`Scene3D::sheet_texts`]) on top.
//!
//! The data types and the camera are the CPU half, [`crate::render::scene3d`] (no
//! feature), re-exported here. A host:
//!
//! 1. installs once at startup: [`install_scene3d_renderer`]`(render_state)`;
//! 2. each frame builds a [`Scene3D`] and calls [`draw_scene3d`]; on `false` (no
//! renderer, nothing visible) it paints its own CPU fallback from the same data.
//!
//! [`render_scene3d_offscreen`] renders headless (tests, proofs).
use std::collections::HashMap;
use std::sync::Arc;
use egui_wgpu::wgpu;
pub use crate::render::scene3d::{layout_text, Camera3, GlyphAt, Line3, Quad3, Scene3D, Text3, LINE_ADVANCE, MONO_ADVANCE};
/// The Scene3D shader.
pub use crate::render::wgsl::SCENE3D_WGSL;
// ------------------------------------------------------------------ the glyph atlas
/// Atlas px per em.
pub const PX_PER_EM: f32 = 40.0;
/// The distance field's reach either side of an edge, atlas px.
pub const SPREAD: f32 = 6.0;
const ATLAS_W: u32 = 1024;
/// One baked glyph: its atlas rect and its quad on the text plane.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Glyph {
/// `u0, v0, u1, v1` (v0 = the top edge).
pub uv: [f32; 4],
/// `x0, y0, x1, y1` in em from the pen (baseline-left), y up.
pub plane: [f32; 4],
}
/// The single-channel SDF atlas: `R8`, 0.5 = the outline, larger = inside.
pub struct SdfAtlas {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
glyphs: HashMap<char, Glyph>,
/// The font's missing-glyph box, drawn for a character the font does not have.
notdef: Option<Glyph>,
}
impl SdfAtlas {
/// Has a baked glyph for `ch` (not the missing-glyph box)?
#[must_use]
pub fn has(&self, ch: char) -> bool {
self.glyphs.contains_key(&ch)
}
/// The glyph `ch` is drawn with: its own, else the missing-glyph box.
#[must_use]
pub fn glyph(&self, ch: char) -> Option<Glyph> {
self.glyphs.get(&ch).copied().or(self.notdef)
}
}
/// The characters baked: printable ASCII and a few the 3D views use.
const EXTRA: &[char] = &['…', '→', '←', '·', '⚠', '│', '─', '▸', '✓', '✗', 'å', 'ä', 'ö', 'Å', 'Ä', 'Ö', 'é', 'ü'];
/// The atlas, baked on first use (≈100 glyphs, a few ms in release).
#[must_use]
pub fn atlas() -> &'static SdfAtlas {
static A: std::sync::OnceLock<SdfAtlas> = std::sync::OnceLock::new();
A.get_or_init(bake)
}
/// Outline flattener: segments in atlas px (font units × scale).
struct Flatten {
s: f32,
segs: Vec<[f32; 4]>,
start: [f32; 2],
cur: [f32; 2],
}
impl Flatten {
fn to(&mut self, p: [f32; 2]) {
if p != self.cur {
self.segs.push([self.cur[0], self.cur[1], p[0], p[1]]);
}
self.cur = p;
}
}
impl ttf_parser::OutlineBuilder for Flatten {
fn move_to(&mut self, x: f32, y: f32) {
let p = [x * self.s, y * self.s];
self.start = p;
self.cur = p;
}
fn line_to(&mut self, x: f32, y: f32) {
self.to([x * self.s, y * self.s]);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let (p0, p1, p2) = (self.cur, [x1 * self.s, y1 * self.s], [x * self.s, y * self.s]);
for i in 1..=8 {
let t = i as f32 / 8.0;
let m = 1.0 - t;
self.to([m * m * p0[0] + 2.0 * m * t * p1[0] + t * t * p2[0], m * m * p0[1] + 2.0 * m * t * p1[1] + t * t * p2[1]]);
}
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let (p0, p1, p2, p3) = (self.cur, [x1 * self.s, y1 * self.s], [x2 * self.s, y2 * self.s], [x * self.s, y * self.s]);
for i in 1..=12 {
let t = i as f32 / 12.0;
let m = 1.0 - t;
let (a, b, c, d) = (m * m * m, 3.0 * m * m * t, 3.0 * m * t * t, t * t * t);
self.to([a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]);
}
}
fn close(&mut self) {
let s = self.start;
self.to(s);
}
}
/// Signed distance (atlas px, > 0 inside, nonzero winding) from `p` to the outline.
fn signed_distance(segs: &[[f32; 4]], p: [f32; 2]) -> f32 {
let mut best = f32::MAX;
let mut winding = 0i32;
for &[ax, ay, bx, by] in segs {
let (dx, dy) = (bx - ax, by - ay);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - ax) * dx + (p[1] - ay) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (ax + t * dx - p[0], ay + t * dy - p[1]);
best = best.min(qx * qx + qy * qy);
// a ray to +x: crossings, signed by direction
let cross = dx * (p[1] - ay) - dy * (p[0] - ax);
if ay <= p[1] && by > p[1] && cross > 0.0 {
winding += 1;
} else if by <= p[1] && ay > p[1] && cross < 0.0 {
winding -= 1;
}
}
let d = best.sqrt();
if winding != 0 { d } else { -d }
}
/// A glyph to bake: its character (`None` = the missing-glyph box), its outline as
/// px segments, its cell `left, bottom, w, h` in px.
type Cell = (Option<char>, Vec<[f32; 4]>, [i32; 4]);
fn bake() -> SdfAtlas {
let mut atlas = SdfAtlas { width: ATLAS_W, height: 1, pixels: Vec::new(), glyphs: HashMap::new(), notdef: None };
let Ok(face) = ttf_parser::Face::parse(epaint_default_fonts::HACK_REGULAR, 0) else {
atlas.pixels = vec![0];
return atlas;
};
let s = PX_PER_EM / f32::from(face.units_per_em().max(1));
// (char or None for notdef, outline segments, cell left/bottom/w/h in px)
let mut cells: Vec<Cell> = Vec::new();
let mut want: Vec<(Option<char>, ttf_parser::GlyphId)> = vec![(None, ttf_parser::GlyphId(0))];
for ch in (32u8..=126).map(char::from).chain(EXTRA.iter().copied()) {
if let Some(g) = face.glyph_index(ch) {
want.push((Some(ch), g));
}
}
let pad = SPREAD.ceil() as i32 + 1;
for (ch, gid) in want {
let mut f = Flatten { s, segs: Vec::new(), start: [0.0; 2], cur: [0.0; 2] };
let Some(bb) = face.outline_glyph(gid, &mut f) else { continue };
let left = (f32::from(bb.x_min) * s).floor() as i32 - pad;
let right = (f32::from(bb.x_max) * s).ceil() as i32 + pad;
let bottom = (f32::from(bb.y_min) * s).floor() as i32 - pad;
let top = (f32::from(bb.y_max) * s).ceil() as i32 + pad;
cells.push((ch, f.segs, [left, bottom, right - left, top - bottom]));
}
// shelf packing
let (mut x, mut y, mut row_h) = (1u32, 1u32, 0u32);
let mut placed: Vec<(u32, u32)> = Vec::with_capacity(cells.len());
for (_, _, [_, _, w, h]) in &cells {
let (w, h) = (*w as u32, *h as u32);
if x + w + 1 > ATLAS_W {
x = 1;
y += row_h + 1;
row_h = 0;
}
placed.push((x, y));
x += w + 1;
row_h = row_h.max(h);
}
let height = (y + row_h + 1).next_power_of_two();
let mut px = vec![0u8; (ATLAS_W * height) as usize];
for ((ch, segs, [left, bottom, w, h]), (cx, cy)) in cells.iter().zip(&placed) {
let top = bottom + h;
for j in 0..*h {
for i in 0..*w {
let p = [*left as f32 + i as f32 + 0.5, top as f32 - j as f32 - 0.5];
let d = signed_distance(segs, p);
let v = (0.5 + d / (2.0 * SPREAD)).clamp(0.0, 1.0);
px[((cy + j as u32) * ATLAS_W + cx + i as u32) as usize] = (v * 255.0).round() as u8;
}
}
let (aw, ah) = (ATLAS_W as f32, height as f32);
let g = Glyph {
uv: [*cx as f32 / aw, *cy as f32 / ah, (*cx as f32 + *w as f32) / aw, (*cy as f32 + *h as f32) / ah],
plane: [*left as f32 / PX_PER_EM, *bottom as f32 / PX_PER_EM, (*left + *w) as f32 / PX_PER_EM, top as f32 / PX_PER_EM],
};
match ch {
Some(c) => {
atlas.glyphs.insert(*c, g);
}
None => atlas.notdef = Some(g),
}
}
atlas.height = height;
atlas.pixels = px;
atlas
}
// ------------------------------------------------------------------ vertices
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct SolidV {
pos: [f32; 3],
color: [u8; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct LineV {
a: [f32; 3],
b: [f32; 3],
end_side_width: [f32; 3],
color: [u8; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct TextV {
pos: [f32; 3],
uv: [f32; 2],
color: [u8; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniforms {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
}
struct Vertices {
solid: Vec<SolidV>,
lines: Vec<LineV>,
text: Vec<TextV>,
sheets: Vec<SolidV>,
sheet_text: Vec<TextV>,
}
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f32; 3], k: f32) -> [f32; 3] {
[a[0] * k, a[1] * k, a[2] * k]
}
/// The frame's vertices; text sized on a `w × h` px pane (the WHOLE pane, also when only
/// part of it is visible).
fn vertices(scene: &Scene3D, cam: &Camera3, w: f32, h: f32) -> Vertices {
let mut v = Vertices {
solid: Vec::with_capacity(scene.quads.len() * 6),
lines: Vec::with_capacity(scene.lines.len() * 6),
text: Vec::new(),
sheets: Vec::with_capacity(scene.sheets.len() * 6),
sheet_text: Vec::new(),
};
let quad = |out: &mut Vec<SolidV>, q: &Quad3| {
let (p00, p10, p11, p01) = (q.origin, add(q.origin, q.u), add(add(q.origin, q.u), q.v), add(q.origin, q.v));
for pos in [p00, p10, p11, p00, p11, p01] {
out.push(SolidV { pos, color: q.color });
}
};
for q in &scene.quads {
quad(&mut v.solid, q);
}
// sheets blend without writing depth: far to near, so a nearer one lies over a farther
let mut sheets: Vec<(f32, &Quad3)> = scene
.sheets
.iter()
.map(|q| (cam.project(add(q.origin, scale(add(q.u, q.v), 0.5)), w, h).map_or(f32::MAX, |p| p.1), q))
.collect();
sheets.sort_by(|a, b| b.0.total_cmp(&a.0));
for (_, q) in sheets {
quad(&mut v.sheets, q);
}
for l in &scene.lines {
for (end, side) in [(0.0, -1.0), (0.0, 1.0), (1.0, 1.0), (0.0, -1.0), (1.0, 1.0), (1.0, -1.0)] {
v.lines.push(LineV { a: l.a, b: l.b, end_side_width: [end, side, l.width_px], color: l.color });
}
}
let atlas = atlas();
let texts = |out: &mut Vec<TextV>, list: &[Text3]| {
for t in list {
let mut alpha = 1.0f32;
if scene.min_text_px > 0.0 {
let em = cam.em_px(t.origin, t.up, w, h);
if em < scene.min_text_px {
continue;
}
alpha = ((em - scene.min_text_px) / (0.5 * scene.min_text_px)).clamp(0.0, 1.0).max(0.05);
}
let color = [t.color[0], t.color[1], t.color[2], (f32::from(t.color[3]) * alpha).round() as u8];
for g in layout_text(t) {
let Some(gl) = atlas.glyph(g.ch) else { continue };
let [x0, y0, x1, y1] = gl.plane;
let [u0, v0, u1, v1] = gl.uv;
let at = |x: f32, y: f32| add(g.pen, add(scale(t.right, x), scale(t.up, y)));
let (bl, br, tr, tl) = (TextV { pos: at(x0, y0), uv: [u0, v1], color }, TextV { pos: at(x1, y0), uv: [u1, v1], color }, TextV { pos: at(x1, y1), uv: [u1, v0], color }, TextV { pos: at(x0, y1), uv: [u0, v0], color });
out.extend_from_slice(&[bl, br, tr, bl, tr, tl]);
}
}
};
texts(&mut v.text, &scene.texts);
texts(&mut v.sheet_text, &scene.sheet_texts);
v
}
// ------------------------------------------------------------------ pipelines
/// The four pipelines + the bind group (uniforms, atlas, sampler) for one colour format.
pub struct Scene3DGpu {
solid: wgpu::RenderPipeline,
line: wgpu::RenderPipeline,
text: wgpu::RenderPipeline,
/// A translucent sheet: the solid shader, depth-tested, NOT depth-writing.
sheet: wgpu::RenderPipeline,
ubuf: wgpu::Buffer,
atlas_tex: wgpu::Texture,
bind: wgpu::BindGroup,
atlas_uploaded: bool,
}
impl Scene3DGpu {
/// Pipelines drawing into `color_format` + [`super::DEPTH_FORMAT`].
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("scene3d"), source: wgpu::ShaderSource::Wgsl(SCENE3D_WGSL.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("scene3d_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
count: None,
},
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None },
],
});
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("scene3d_uniforms"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let a = atlas();
let atlas_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene3d_sdf_atlas"),
size: wgpu::Extent3d { width: a.width, height: a.height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("scene3d_sdf_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let view = atlas_tex.create_view(&Default::default());
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene3d_bind"),
layout: &bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: ubuf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler) },
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("scene3d_pll"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
let pipeline = |label: &str, vs: &str, fs: &str, attrs: &[wgpu::VertexAttribute], stride: u64, depth_write: bool, cmp: wgpu::CompareFunction| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vs),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: stride, step_mode: wgpu::VertexStepMode::Vertex, attributes: attrs })],
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: None, ..Default::default() },
depth_stencil: Some(wgpu::DepthStencilState {
format: super::DEPTH_FORMAT,
depth_write_enabled: Some(depth_write),
depth_compare: Some(cmp),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fs),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL })],
}),
multiview_mask: None,
cache: None,
})
};
let solid = pipeline(
"scene3d_solid",
"solid_vs",
"solid_fs",
&wgpu::vertex_attr_array![0 => Float32x3, 1 => Unorm8x4],
std::mem::size_of::<SolidV>() as u64,
true,
wgpu::CompareFunction::Less,
);
let line = pipeline(
"scene3d_line",
"line_vs",
"solid_fs",
&wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Unorm8x4],
std::mem::size_of::<LineV>() as u64,
true,
wgpu::CompareFunction::Less,
);
let text = pipeline(
"scene3d_text",
"text_vs",
"text_fs",
&wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Unorm8x4],
std::mem::size_of::<TextV>() as u64,
false,
wgpu::CompareFunction::LessEqual,
);
let sheet = pipeline(
"scene3d_sheet",
"solid_vs",
"solid_fs",
&wgpu::vertex_attr_array![0 => Float32x3, 1 => Unorm8x4],
std::mem::size_of::<SolidV>() as u64,
false,
wgpu::CompareFunction::Less,
);
Self { solid, line, text, sheet, ubuf, atlas_tex, bind, atlas_uploaded: false }
}
/// Encode one frame into `color` + `depth` (both `w_px × h_px`): `view_proj` is the
/// whole projection (crop included), `viewport` the target size in px.
#[allow(clippy::too_many_arguments)]
fn encode(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
view_proj: [[f32; 4]; 4],
viewport: [f32; 2],
clear: [f32; 4],
verts: &Vertices,
) -> Vec<wgpu::Buffer> {
if !self.atlas_uploaded {
let a = atlas();
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &self.atlas_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
&a.pixels,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(a.width), rows_per_image: Some(a.height) },
wgpu::Extent3d { width: a.width, height: a.height, depth_or_array_layers: 1 },
);
self.atlas_uploaded = true;
}
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&Uniforms { view_proj, viewport: [viewport[0], viewport[1], 0.0, 0.0] }));
let lane = std::any::type_name::<Scene3DRenderer>();
let make = |label: &str, bytes: &[u8]| -> Option<wgpu::Buffer> {
if bytes.is_empty() {
return None;
}
super::graphcloud::checked_buffer(device, queue, label, bytes, wgpu::BufferUsages::VERTEX).map_err(|why| crate::render::lane::note_gpu_error(lane, &why)).ok()
};
let bufs = [
(make("scene3d_solid", bytemuck::cast_slice(&verts.solid)), verts.solid.len(), &self.solid),
(make("scene3d_lines", bytemuck::cast_slice(&verts.lines)), verts.lines.len(), &self.line),
(make("scene3d_text", bytemuck::cast_slice(&verts.text)), verts.text.len(), &self.text),
(make("scene3d_sheets", bytemuck::cast_slice(&verts.sheets)), verts.sheets.len(), &self.sheet),
(make("scene3d_sheet_text", bytemuck::cast_slice(&verts.sheet_text)), verts.sheet_text.len(), &self.text),
];
{
let c = |x: f32| f64::from(x);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene3d_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: c(clear[0]), g: c(clear[1]), b: c(clear[2]), a: c(clear[3]) }), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, &self.bind, &[]);
for (buf, n, pipe) in &bufs {
if let Some(b) = buf {
pass.set_pipeline(pipe);
pass.set_vertex_buffer(0, b.slice(..));
pass.draw(0..*n as u32, 0..1);
}
}
}
bufs.into_iter().filter_map(|(b, _, _)| b).collect()
}
}
// ------------------------------------------------------------------ the live lane
/// The installed renderer: pipelines for the offscreen HDR format + the shared offscreen
/// colour/depth target and its blit.
pub struct Scene3DRenderer {
gpu: Scene3DGpu,
offscreen: super::OffscreenColorDepth,
/// This frame's vertex buffers, kept until egui submits.
frame: Vec<wgpu::Buffer>,
}
impl Scene3DRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
Self { gpu: Scene3DGpu::new(device, super::OFFSCREEN_FORMAT), offscreen: super::OffscreenColorDepth::new(device, target_format), frame: Vec::new() }
}
}
fn installed_flag() -> &'static std::sync::atomic::AtomicBool {
static FLAG: std::sync::OnceLock<&'static std::sync::atomic::AtomicBool> = std::sync::OnceLock::new();
FLAG.get_or_init(|| crate::render::lane::installed_flag(std::any::type_name::<Scene3DRenderer>()))
}
/// Has a host installed the Scene3D renderer ([`install_scene3d_renderer`])?
#[must_use]
pub fn scene3d_renderer_installed() -> bool {
installed_flag().load(std::sync::atomic::Ordering::Acquire)
}
/// Install the Scene3D renderer into a `RenderState` (once, at host startup;
/// idempotent). Without it [`draw_scene3d`] returns `false` and the host's CPU fallback
/// paints.
pub fn install_scene3d_renderer(render_state: &egui_wgpu::RenderState) {
let name = std::any::type_name::<Scene3DRenderer>();
crate::render::lane::note_reports_frames(name);
crate::render::lane::note_idle_reason(name, Some(crate::render::lane::PANE_NOT_PAINTED_YET));
let _ = super::install_renderer(render_state, Scene3DRenderer::new);
}
/// One live frame.
struct Scene3DPaintCallback {
scene: Arc<Scene3D>,
cam: Camera3,
/// The whole pane, physical px (text is sized on it).
full: [f32; 2],
/// The visible part's target size, physical px.
px: [u32; 2],
/// `view_proj` of the whole pane, cropped to the visible part.
view_proj: [[f32; 4]; 4],
}
impl egui_wgpu::CallbackTrait for Scene3DPaintCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
_sd: &egui_wgpu::ScreenDescriptor,
encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(r) = resources.get_mut::<Scene3DRenderer>() else { return vec![] };
r.offscreen.ensure(device, self.px[0], self.px[1]);
let (Some(color), Some(depth)) = (r.offscreen.color_view(), r.offscreen.depth_view()) else { return vec![] };
let (w, h) = r.offscreen.size();
let verts = vertices(&self.scene, &self.cam, self.full[0], self.full[1]);
let (color, depth) = (color.clone(), depth.clone());
r.frame = r.gpu.encode(device, queue, encoder, &color, &depth, self.view_proj, [w as f32, h as f32], self.scene.clear, &verts);
crate::render::lane::note_frame(std::any::type_name::<Scene3DRenderer>());
vec![]
}
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
let Some(r) = resources.get::<Scene3DRenderer>() else { return };
if r.offscreen.ready() {
let _ = r.offscreen.blit(&info, pass);
}
}
}
/// The clip-space crop that maps the whole `full` px pane's projection onto the visible
/// part `vis` (`x0, y0, w, h` px, y down, relative to the pane): a point lands on the
/// same screen pixel whether the pane is drawn whole or only its visible part.
#[must_use]
pub fn crop_matrix(full: [f32; 2], vis: [f32; 4]) -> [[f32; 4]; 4] {
let [x0, y0, vw, vh] = vis;
let (vw, vh) = (vw.max(1e-3), vh.max(1e-3));
let (sx, sy) = (full[0] / vw, full[1] / vh);
let ox = sx - 1.0 - 2.0 * x0 / vw;
let oy = 1.0 - sy + 2.0 * y0 / vh;
[[sx, 0.0, 0.0, 0.0], [0.0, sy, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [ox, oy, 0.0, 1.0]]
}
fn mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
(glam::Mat4::from_cols_array_2d(&a) * glam::Mat4::from_cols_array_2d(&b)).to_cols_array_2d()
}
/// **Draw a [`Scene3D`] into `rect`** through the GPU lane. `true` when the frame was
/// pushed — the caller then skips its CPU fallback; `false` (no renderer installed, or
/// nothing of `rect` visible) and the caller paints. Only the visible part of `rect` is
/// rendered (a pane laid out bigger than its clip never asks for a texture past the
/// device limit), with the projection cropped so every point lands where it would.
pub fn draw_scene3d(ui: &egui::Ui, rect: egui::Rect, cam: &Camera3, scene: Arc<Scene3D>) -> bool {
if !scene3d_renderer_installed() {
return false;
}
let ppp = ui.ctx().pixels_per_point();
let vis = rect.intersect(ui.clip_rect());
let full = [(rect.width() * ppp).round(), (rect.height() * ppp).round()];
let px = [(vis.width() * ppp).round(), (vis.height() * ppp).round()];
if !(full[0] >= 1.0 && full[1] >= 1.0 && px[0] >= 1.0 && px[1] >= 1.0) {
return false;
}
let off = (vis.min - rect.min) * ppp;
let view_proj = mul(crop_matrix(full, [off.x, off.y, px[0], px[1]]), cam.view_proj(full[0] / full[1]));
ui.painter().add(egui_wgpu::Callback::new_paint_callback(vis, Scene3DPaintCallback { scene, cam: *cam, full, px: [px[0] as u32, px[1] as u32], view_proj }));
true
}
/// **Headless**: render `scene` at `w × h` and read it back (RGBA8, row-major, `w*h*4`
/// bytes). `None` when this host has no usable adapter (software rasterisers count as
/// none unless `$FACETT_ALLOW_SOFTWARE_GPU` is set — the shared probe's rule).
#[must_use]
pub fn render_scene3d_offscreen(w: u32, h: u32, cam: &Camera3, scene: &Scene3D) -> Option<Vec<u8>> {
let probe = crate::render::gputurn::probe::open("scene3d-offscreen", crate::render::gputurn::probe::OnSoftware::Skip, crate::render::gputurn::probe::downlevel)?;
Some(render_on(&probe.device, &probe.queue, w, h, cam, scene))
}
/// [`render_scene3d_offscreen`] on a device the caller opened.
fn render_on(device: &wgpu::Device, queue: &wgpu::Queue, w: u32, h: u32, cam: &Camera3, scene: &Scene3D) -> Vec<u8> {
let fmt = wgpu::TextureFormat::Rgba8Unorm;
let (w, h) = super::texture_extent(device, w, h);
let tex = |label: &str, format: wgpu::TextureFormat, usage: wgpu::TextureUsages| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = tex("scene3d_headless_color", fmt, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC);
let depth = tex("scene3d_headless_depth", super::DEPTH_FORMAT, wgpu::TextureUsages::RENDER_ATTACHMENT);
let mut gpu = Scene3DGpu::new(device, fmt);
let verts = vertices(scene, cam, w as f32, h as f32);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("scene3d_headless") });
let (cv, dv) = (color.create_view(&Default::default()), depth.create_view(&Default::default()));
let keep = gpu.encode(device, queue, &mut enc, &cv, &dv, cam.view_proj(w as f32 / h as f32), [w as f32, h as f32], scene.clear, &verts);
queue.submit(Some(enc.finish()));
let px = super::read_texture_region(device, queue, &color, 4, 0, 0, w, h);
drop(keep);
px
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_atlas_has_the_glyphs_of_code() {
let a = atlas();
for ch in "fn main() -> Vec<u8> { x.y(); } 0123456789".chars().filter(|c| *c != ' ') {
assert!(a.has(ch), "no glyph for {ch:?}");
}
assert!(a.width >= 256 && a.pixels.len() == (a.width * a.height) as usize);
// a distance field, not a bitmap: edge values exist between in and out
let mid = a.pixels.iter().filter(|p| (64..192).contains(*p)).count();
assert!(mid > a.pixels.len() / 50, "{mid} mid texels");
}
/// The visible-part crop keeps every point on its screen pixel.
#[test]
fn the_crop_lands_a_point_where_the_whole_pane_would() {
let cam = Camera3 { eye: [2.0, 3.0, 9.0], target: [0.5, 0.0, 0.0], ..Camera3::default() };
let full = [800.0f32, 500.0];
let vis = [120.0f32, 40.0, 300.0, 200.0];
let m = glam::Mat4::from_cols_array_2d(&mul(crop_matrix(full, vis), cam.view_proj(full[0] / full[1])));
for p in [[0.5, 0.0, 0.0], [1.3, 0.4, -0.2], [0.0, 0.5, 0.6]] {
let whole = cam.project(p, full[0], full[1]).unwrap().0;
let c = m * glam::Vec4::new(p[0], p[1], p[2], 1.0);
let (x, y) = (c.x / c.w, c.y / c.w);
let part = [(x * 0.5 + 0.5) * vis[2] + vis[0], (0.5 - y * 0.5) * vis[3] + vis[1]];
assert!((whole[0] - part[0]).abs() < 1e-2 && (whole[1] - part[1]).abs() < 1e-2, "{whole:?} vs {part:?}");
}
}
// ------------------------------------------------------------- rendered
const W: u32 = 480;
const H: u32 = 240;
/// Render on THIS box's adapter, software included: these are geometry and
/// legibility checks (occlusion, edge width, culling), true on llvmpipe as on a
/// card — not a performance claim. Under the GPU turn, without touching the
/// process environment (the shared probe's software rule stays as it is for every
/// other test). `None` only when there is no adapter at all.
fn render(cam: &Camera3, scene: &Scene3D) -> Option<Vec<u8>> {
let _turn = crate::render::gputurn::hold();
let instance = wgpu::Instance::default();
let Some(adapter) = crate::render::gpu::request_best_adapter(&instance, crate::render::gpu::preferred_backends()) else {
eprintln!("SKIP: no wgpu adapter at all");
return None;
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("scene3d-test"),
required_limits: wgpu::Limits::downlevel_defaults(),
..Default::default()
}))
.expect("a device on the adapter");
Some(render_on(&device, &queue, W, H, cam, scene))
}
fn luma(px: &[u8], x: u32, y: u32) -> f32 {
let i = ((y * W + x) * 4) as usize;
(0.2126 * f32::from(px[i]) + 0.7152 * f32::from(px[i + 1]) + 0.0722 * f32::from(px[i + 2])) / 255.0
}
/// Bright texels (luma > 0.8) inside a px rect.
fn bright(px: &[u8], x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
let mut n = 0;
for y in y0..y1.min(H) {
for x in x0..x1.min(W) {
if luma(px, x, y) > 0.8 {
n += 1;
}
}
}
n
}
fn white(text: &str, origin: [f32; 3]) -> Text3 {
Text3 { origin, right: [1.0, 0.0, 0.0], up: [0.0, 1.0, 0.0], text: text.into(), color: [255, 255, 255, 255] }
}
fn cam_at(z: f32) -> Camera3 {
Camera3 { eye: [0.0, 0.0, z], target: [0.0, 0.0, 0.0], ..Camera3::default() }
}
/// A nearer opaque quad HIDES the text behind it; text beside it is drawn.
#[test]
fn a_nearer_quad_occludes_text_and_uncovered_text_shows() {
let mut s = Scene3D { min_text_px: 0.0, ..Scene3D::default() };
s.texts.push(white("HIDDEN", [-5.5, -0.35, 0.0]));
s.texts.push(white("SHOWN", [1.5, -0.35, 0.0]));
// a grey card one unit nearer, covering x -6..-1
s.quads.push(Quad3 { origin: [-6.0, -1.5, 1.0], u: [5.0, 0.0, 0.0], v: [0.0, 3.0, 0.0], color: [90, 90, 90, 255] });
let cam = cam_at(12.0);
let Some(px) = render(&cam, &s) else { return };
let r = |p: [f32; 3]| cam.project(p, W as f32, H as f32).unwrap().0;
let (a, b) = (r([-6.0, -1.5, 1.0]), r([-1.0, 1.5, 1.0]));
let hidden = bright(&px, a[0] as u32 + 1, b[1] as u32 + 1, b[0] as u32 - 1, a[1] as u32 - 1);
assert_eq!(hidden, 0, "text behind the card leaks through");
let quad_l = luma(&px, ((a[0] + b[0]) * 0.5) as u32, ((a[1] + b[1]) * 0.5) as u32);
assert!((quad_l - 90.0 / 255.0).abs() < 0.08, "the card is drawn: {quad_l}");
let (c, d) = (r([1.5, -1.0, 0.0]), r([1.5 + 5.0 * MONO_ADVANCE, 1.0, 0.0]));
let shown = bright(&px, c[0] as u32, d[1] as u32, d[0] as u32, c[1] as u32);
assert!(shown > 40, "the uncovered text is drawn: {shown} bright px");
}
/// Edge contrast on a scanline through the glyphs: sharp at two zooms (≈24 and
/// ≈71 px a em) — full contrast, and every ink edge at most 2 px wide (a blurred
/// distance field gives wide grey ramps, and they grow with the zoom).
#[test]
fn text_stays_sharp_at_two_zooms() {
for z in [12.0f32, 4.0] {
let mut s = Scene3D { min_text_px: 0.0, ..Scene3D::default() };
s.texts.push(white("fn main", [-2.0, -0.35, 0.0]));
let cam = cam_at(z);
let Some(px) = render(&cam, &s) else { return };
let p = |x: f32, y: f32| cam.project([x, y, 0.0], W as f32, H as f32).unwrap().0;
let em = cam.em_px([0.0, -0.35, 0.0], [0.0, 1.0, 0.0], W as f32, H as f32);
let (l, r) = (p(-2.0, 0.0)[0] as u32, p(-2.0 + 7.0 * MONO_ADVANCE, 0.0)[0] as u32);
// the x-height band: a scanline a quarter em above the baseline
let y = p(0.0, -0.35 + 0.25)[1] as u32;
let row: Vec<f32> = (l..r.min(W)).map(|x| luma(&px, x, y)).collect();
let (lo, hi) = row.iter().fold((1.0f32, 0.0f32), |(a, b), v| (a.min(*v), b.max(*v)));
assert!(hi - lo > 0.6, "z={z} ({em} px/em): contrast {lo}..{hi}");
let ink = row.iter().filter(|v| **v > 0.6).count();
let mut widest = 0;
let mut run = 0;
for v in &row {
run = if *v > 0.15 && *v < 0.85 { run + 1 } else { 0 };
widest = widest.max(run);
}
assert!(ink >= 6, "z={z}: {ink} ink px on the scanline");
assert!(widest <= 2, "z={z} ({em} px/em): an edge {widest} px wide — blurred");
}
}
/// Below `min_text_px` a text is not drawn at all.
#[test]
fn text_too_small_to_read_is_not_drawn() {
let mut s = Scene3D { min_text_px: 8.0, ..Scene3D::default() };
s.texts.push(white("far away", [-2.0, 0.0, 0.0]));
let near = render(&cam_at(8.0), &s);
let Some(near) = near else { return };
assert!(bright(&near, 0, 0, W, H) > 30, "near: drawn");
let far = render(&cam_at(400.0), &s).unwrap();
assert_eq!(bright(&far, 0, 0, W, H), 0, "far: under min_text_px, not drawn");
}
/// **A sheet lets what stands behind it show through** (the unfolded source panel
/// over a spline): drawn as a depth-writing quad it cuts the line away; as a sheet the
/// line shows, dimmed by the sheet's half alpha, while the sheet's own words stay
/// bright on top and a word BEHIND the sheet is dimmed with the line.
#[test]
fn a_sheet_lets_what_is_behind_it_show_through() {
let line = Line3 { a: [-3.0, 0.0, -1.0], b: [3.0, 0.0, -1.0], width_px: 6.0, color: [255, 255, 255, 255] };
let panel = Quad3 { origin: [-4.0, -2.5, 0.0], u: [8.0, 0.0, 0.0], v: [0.0, 5.0, 0.0], color: [0x0c, 0x0f, 0x16, 128] };
let cam = cam_at(10.0);
let c = cam.project([0.0, 0.0, -1.0], W as f32, H as f32).unwrap().0;
let (cx, cy) = (c[0] as u32, c[1] as u32);
let peak = |px: &[u8]| (cy.saturating_sub(4)..cy + 4).map(|y| luma(px, cx, y)).fold(0.0f32, f32::max);
// as a depth-writing quad (the old road): the line is cut away
let mut s = Scene3D { min_text_px: 0.0, ..Scene3D::default() };
s.lines.push(line);
s.quads.push(panel);
let Some(px) = render(&cam, &s) else { return };
assert!(peak(&px) < 0.15, "a depth-writing quad hides the line: {}", peak(&px));
// as a sheet: the line shows through, dimmed
let mut s = Scene3D { min_text_px: 0.0, ..Scene3D::default() };
s.lines.push(line);
s.sheets.push(panel);
s.texts.push(white("behind", [-2.0, 1.0, -1.0]));
s.sheet_texts.push(white("on it", [-2.0, -2.0, 0.03]));
let px = render(&cam, &s).expect("rendered above");
let p = peak(&px);
assert!((0.3..0.8).contains(&p), "the line shows through the half-transparent sheet, dimmed: {p}");
let box_of = |o: [f32; 3], n: usize| {
let a = cam.project([o[0], o[1] - 0.2, o[2]], W as f32, H as f32).unwrap().0;
let b = cam.project([o[0] + n as f32 * MONO_ADVANCE, o[1] + 0.9, o[2]], W as f32, H as f32).unwrap().0;
(a[0] as u32, b[1] as u32, b[0] as u32, a[1] as u32)
};
let (x0, y0, x1, y1) = box_of([-2.0, -2.0, 0.03], 5);
assert!(bright(&px, x0, y0, x1, y1) > 20, "the sheet's own words are bright on top");
let (x0, y0, x1, y1) = box_of([-2.0, 1.0, -1.0], 6);
assert_eq!(bright(&px, x0, y0, x1, y1), 0, "a word behind the sheet is dimmed with the line");
}
#[test]
fn lines_are_drawn_their_width() {
let mut s = Scene3D::default();
s.lines.push(Line3 { a: [-3.0, 0.0, 0.0], b: [3.0, 0.0, 0.0], width_px: 6.0, color: [255, 255, 255, 255] });
let cam = cam_at(10.0);
let Some(px) = render(&cam, &s) else { return };
let c = cam.project([0.0; 3], W as f32, H as f32).unwrap().0;
let col: usize = (0..H).filter(|y| luma(&px, c[0] as u32, *y) > 0.8).count();
assert!((5..=8).contains(&col), "a 6 px ribbon is {col} px tall");
}
}