Skip to main content

concinnity_core/gfx/lines/
expand.rs

1// The expansion that turns a world-space `Line` into the camera-facing ribbon
2// the line pass rasterises.
3//
4// A hardware line primitive cannot carry a pixel width portably, so each segment
5// expands into a quad whose corners are offset perpendicular to the line and
6// perpendicular to the eye vector, scaled by the world-per-pixel size at that
7// corner's depth. Both edges of the ribbon are straight world-space lines, so
8// they project to straight screen-space lines separated by exactly the
9// requested pixel width along the whole run, however far the far end reaches.
10
11use super::Line;
12use crate::gfx::render_types::LineVertex;
13use crate::math::tan;
14use crate::math::vec3::{cross, dot, length, lerp as lerp3, sub};
15use alloc::vec::Vec;
16
17// Vertices emitted per expanded segment: two triangles, unindexed.
18const VERTS_PER_SEGMENT: usize = 6;
19
20/// The camera the expansion projects against. `view` is the world-to-view matrix
21/// the frame renders with (column-major, `view[col][row]`) and `cam_pos` its
22/// world-space position: in a camera-relative world both are the rebased pair,
23/// so callers must express their lines in that same space.
24#[derive(Copy, Clone, Debug)]
25pub struct LineCamera {
26    /// View matrix, column-major.
27    pub view: [[f32; 4]; 4],
28    /// World-space camera position.
29    pub cam_pos: [f32; 3],
30    /// Vertical field of view in radians.
31    pub fov_y_radians: f32,
32    /// Render-target size in pixels.
33    pub viewport: [f32; 2],
34    /// Near clip distance in world units.
35    pub near: f32,
36}
37
38impl LineCamera {
39    // The camera's world-space forward (the -Z view axis).
40    fn forward(&self) -> [f32; 3] {
41        [-self.view[0][2], -self.view[1][2], -self.view[2][2]]
42    }
43
44    // The camera's world-space right (the +X view axis), used as the ribbon
45    // normal when a line points straight at the eye.
46    fn right(&self) -> [f32; 3] {
47        [self.view[0][0], self.view[1][0], self.view[2][0]]
48    }
49
50    // Tangent of the half vertical FOV. Fixed for a camera, so a caller that
51    // needs it per vertex computes it once and passes it down.
52    fn tan_half_fov(&self) -> f32 {
53        tan(self.fov_y_radians * 0.5)
54    }
55
56    // World units per vertical pixel at view depth `depth`.
57    fn world_per_pixel(&self, depth: f32, tan_half: f32) -> f32 {
58        2.0 * depth * tan_half / self.viewport[1]
59    }
60
61    fn usable(&self, tan_half: f32) -> bool {
62        self.viewport[0] > 0.0 && self.viewport[1] > 0.0 && tan_half > 0.0 && tan_half.is_finite()
63    }
64}
65
66fn normalize(v: [f32; 3]) -> Option<[f32; 3]> {
67    let len = length(v);
68    (len > 1e-6).then(|| [v[0] / len, v[1] / len, v[2] / len])
69}
70
71fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
72    [
73        a[0] + (b[0] - a[0]) * t,
74        a[1] + (b[1] - a[1]) * t,
75        a[2] + (b[2] - a[2]) * t,
76        a[3] + (b[3] - a[3]) * t,
77    ]
78}
79
80// A segment trimmed to the part in front of the near plane, with its colours
81// interpolated to wherever the trim landed.
82struct Clipped {
83    start: [f32; 3],
84    end: [f32; 3],
85    start_color: [f32; 4],
86    end_color: [f32; 4],
87}
88
89// Trim a segment to the part in front of the near plane. `None` when the whole
90// segment is at or behind the plane (nothing to draw).
91fn clip_to_near(line: &Line, cam: &LineCamera) -> Option<Clipped> {
92    let fwd = cam.forward();
93    let d0 = dot(sub(line.start, cam.cam_pos), fwd);
94    let d1 = dot(sub(line.end, cam.cam_pos), fwd);
95    let near = cam.near.max(1e-4);
96    match (d0 >= near, d1 >= near) {
97        (true, true) => Some(Clipped {
98            start: line.start,
99            end: line.end,
100            start_color: line.start_color,
101            end_color: line.end_color,
102        }),
103        (false, false) => None,
104        (true, false) => {
105            let t = (d0 - near) / (d0 - d1);
106            Some(Clipped {
107                start: line.start,
108                end: lerp3(line.start, line.end, t),
109                start_color: line.start_color,
110                end_color: lerp4(line.start_color, line.end_color, t),
111            })
112        }
113        (false, true) => {
114            let t = (near - d0) / (d1 - d0);
115            Some(Clipped {
116                start: lerp3(line.start, line.end, t),
117                end: line.end,
118                start_color: lerp4(line.start_color, line.end_color, t),
119                end_color: line.end_color,
120            })
121        }
122    }
123}
124
125/// Expand `lines` into the ribbon triangles the line pass draws. Segments
126/// wholly behind the near plane, degenerate segments, and fully transparent
127/// segments contribute nothing, so an empty result means the pass can be
128/// skipped entirely.
129pub fn build_vertices(lines: &[Line], cam: &LineCamera) -> Vec<LineVertex> {
130    let mut out = Vec::new();
131    build_vertices_into(lines.iter().copied(), cam, &mut out);
132    out
133}
134
135/// `build_vertices`, writing into `out` (cleared first) so a per-frame caller
136/// reuses its buffer.
137pub fn build_vertices_into(
138    lines: impl Iterator<Item = Line>,
139    cam: &LineCamera,
140    out: &mut Vec<LineVertex>,
141) {
142    out.clear();
143    let tan_half = cam.tan_half_fov();
144    if !cam.usable(tan_half) {
145        return;
146    }
147    out.reserve(lines.size_hint().0 * VERTS_PER_SEGMENT);
148    let fwd = cam.forward();
149    for line in lines {
150        let line = &line;
151        if line.width_px <= 0.0 || (line.start_color[3] <= 0.0 && line.end_color[3] <= 0.0) {
152            continue;
153        }
154        let Some(seg) = clip_to_near(line, cam) else {
155            continue;
156        };
157        let Some(dir) = normalize(sub(seg.end, seg.start)) else {
158            continue;
159        };
160        let half_px = line.width_px * 0.5;
161        // Ribbon normal per endpoint: perpendicular to both the line and the
162        // eye vector, so the quad always faces the camera. A line aimed at the
163        // eye has no such perpendicular; the camera right is the stable
164        // fallback (the ribbon is a dot on screen there anyway).
165        let corner = |p: [f32; 3], color: [f32; 4], side: f32| {
166            let to_eye = sub(p, cam.cam_pos);
167            let normal = normalize(cross(dir, to_eye)).unwrap_or_else(|| cam.right());
168            let half =
169                half_px * cam.world_per_pixel(dot(to_eye, fwd).max(cam.near.max(1e-4)), tan_half);
170            LineVertex {
171                pos: [
172                    p[0] + normal[0] * half * side,
173                    p[1] + normal[1] * half * side,
174                    p[2] + normal[2] * half * side,
175                ],
176                edge: side,
177                color,
178            }
179        };
180        let a = corner(seg.start, seg.start_color, -1.0);
181        let b = corner(seg.start, seg.start_color, 1.0);
182        let c = corner(seg.end, seg.end_color, -1.0);
183        let d = corner(seg.end, seg.end_color, 1.0);
184        out.extend_from_slice(&[a, b, c, c, b, d]);
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::gfx::camera::view_matrix;
192
193    const VP: [f32; 2] = [1280.0, 720.0];
194
195    fn camera() -> LineCamera {
196        LineCamera {
197            // Facing -Z from the origin.
198            view: view_matrix([0.0; 3], 0.0, 0.0),
199            cam_pos: [0.0; 3],
200            fov_y_radians: core::f32::consts::FRAC_PI_2,
201            viewport: VP,
202            near: 0.1,
203        }
204    }
205
206    fn line(start: [f32; 3], end: [f32; 3]) -> Line {
207        Line {
208            start,
209            end,
210            start_color: [1.0, 0.0, 0.0, 1.0],
211            end_color: [1.0, 0.0, 0.0, 0.0],
212            width_px: 2.0,
213        }
214    }
215
216    // Project a world point to pixels with the same convention the renderer
217    // uses, so a test can measure the ribbon's on-screen width.
218    fn project(cam: &LineCamera, p: [f32; 3]) -> [f32; 2] {
219        let v = &cam.view;
220        let x = v[0][0] * p[0] + v[1][0] * p[1] + v[2][0] * p[2] + v[3][0];
221        let y = v[0][1] * p[0] + v[1][1] * p[1] + v[2][1] * p[2] + v[3][1];
222        let z = v[0][2] * p[0] + v[1][2] * p[1] + v[2][2] * p[2] + v[3][2];
223        let depth = -z;
224        let tan_half = (cam.fov_y_radians * 0.5).tan();
225        let aspect = cam.viewport[0] / cam.viewport[1];
226        [
227            (x / (depth * tan_half * aspect) + 1.0) * 0.5 * cam.viewport[0],
228            (1.0 - y / (depth * tan_half)) * 0.5 * cam.viewport[1],
229        ]
230    }
231
232    #[test]
233    fn each_segment_expands_to_two_triangles() {
234        let cam = camera();
235        let verts = build_vertices(&[line([0.0, 0.0, -5.0], [0.0, 0.0, -50.0])], &cam);
236        assert_eq!(verts.len(), VERTS_PER_SEGMENT);
237        // Opposite ribbon edges, so the fragment fade has a full -1..1 span.
238        assert!(verts.iter().any(|v| v.edge == -1.0));
239        assert!(verts.iter().any(|v| v.edge == 1.0));
240    }
241
242    #[test]
243    fn ribbon_holds_its_pixel_width_at_any_depth() {
244        let cam = camera();
245        // A line running left to right across the view, one end 4x further out
246        // than the other: both ends must still measure `width_px` on screen.
247        let l = line([-5.0, 0.0, -5.0], [40.0, 0.0, -20.0]);
248        let verts = build_vertices(&[l], &cam);
249        let near_w = {
250            let a = project(&cam, verts[0].pos);
251            let b = project(&cam, verts[1].pos);
252            ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
253        };
254        let far_w = {
255            let a = project(&cam, verts[2].pos);
256            let b = project(&cam, verts[5].pos);
257            ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
258        };
259        assert!((near_w - l.width_px).abs() < 0.05, "near end {near_w}");
260        assert!((far_w - l.width_px).abs() < 0.05, "far end {far_w}");
261    }
262
263    #[test]
264    fn colors_carry_the_fade_to_the_far_end() {
265        let cam = camera();
266        let verts = build_vertices(&[line([0.0, 0.0, -5.0], [0.0, 0.0, -500.0])], &cam);
267        assert_eq!(verts[0].color[3], 1.0, "solid at the near end");
268        assert_eq!(verts[5].color[3], 0.0, "faded out at the far end");
269    }
270
271    #[test]
272    fn segments_behind_the_camera_are_clipped_away() {
273        let cam = camera();
274        // Wholly behind: nothing to draw.
275        assert!(build_vertices(&[line([0.0, 0.0, 5.0], [0.0, 0.0, 50.0])], &cam).is_empty());
276        // Straddling the near plane: trimmed to the visible part, and every
277        // emitted corner sits in front of the camera.
278        let verts = build_vertices(&[line([0.0, 0.0, 10.0], [0.0, 0.0, -10.0])], &cam);
279        assert_eq!(verts.len(), VERTS_PER_SEGMENT);
280        for v in &verts {
281            assert!(
282                v.pos[2] <= -cam.near,
283                "corner {:?} is behind the near",
284                v.pos
285            );
286        }
287    }
288
289    #[test]
290    fn clipping_interpolates_the_endpoint_colour() {
291        let cam = camera();
292        // Half the run is behind the camera, so the near-plane end takes
293        // (nearly) the midpoint colour rather than the authored start colour.
294        let l = Line {
295            start: [0.0, 0.0, 10.0],
296            end: [0.0, 0.0, -10.0],
297            start_color: [1.0, 0.0, 0.0, 1.0],
298            end_color: [1.0, 0.0, 0.0, 0.0],
299            width_px: 2.0,
300        };
301        let verts = build_vertices(&[l], &cam);
302        assert!(
303            (verts[0].color[3] - 0.5).abs() < 0.02,
304            "{:?}",
305            verts[0].color
306        );
307    }
308
309    #[test]
310    fn nothing_to_draw_yields_no_vertices() {
311        let cam = camera();
312        assert!(build_vertices(&[], &cam).is_empty());
313        // Degenerate (zero-length), zero-width, and fully transparent lines all
314        // drop out before expansion.
315        let zero_len = line([1.0, 2.0, -3.0], [1.0, 2.0, -3.0]);
316        assert!(build_vertices(&[zero_len], &cam).is_empty());
317        let mut no_width = line([0.0, 0.0, -5.0], [0.0, 0.0, -50.0]);
318        no_width.width_px = 0.0;
319        assert!(build_vertices(&[no_width], &cam).is_empty());
320        let mut invisible = line([0.0, 0.0, -5.0], [0.0, 0.0, -50.0]);
321        invisible.start_color[3] = 0.0;
322        invisible.end_color[3] = 0.0;
323        assert!(build_vertices(&[invisible], &cam).is_empty());
324    }
325
326    #[test]
327    fn a_degenerate_viewport_draws_nothing() {
328        let mut cam = camera();
329        cam.viewport = [0.0, 720.0];
330        assert!(build_vertices(&[line([0.0, 0.0, -5.0], [0.0, 0.0, -50.0])], &cam).is_empty());
331    }
332
333    #[test]
334    fn a_line_aimed_at_the_eye_falls_back_to_the_camera_right() {
335        let cam = camera();
336        // Running straight away from the camera: the eye vector and the line
337        // are parallel, so the cross product is degenerate.
338        let verts = build_vertices(&[line([0.0, 0.0, -5.0], [0.0, 0.0, -50.0])], &cam);
339        assert_eq!(verts.len(), VERTS_PER_SEGMENT);
340        // The fallback normal is the camera right, so the corners separate
341        // along world X and stay finite.
342        assert!(verts[0].pos[0] < 0.0 && verts[1].pos[0] > 0.0);
343        for v in &verts {
344            assert!(v.pos.iter().all(|c| c.is_finite()));
345        }
346    }
347}