Skip to main content

unlit3d_examples/scenes/
outline.rs

1//! The outline scene: one picture drawn by both ways of drawing an outline.
2//!
3//! Every body is one mesh drawn twice from one buffer — lit, and again spread
4//! along its own normals with its front faces culled — so the second draw
5//! shows only where it reaches past the first: a band around the silhouette.
6//! The band is a width in world units, so a body further from the camera wears
7//! a thinner one.
8//!
9//! Beside them, screen-space strokes draw what a silhouette cannot: the orbit
10//! the bodies stand on, the ticks along it, and a pair of armillary rings
11//! circling the largest body. A stroke is a width in physical pixels, so it
12//! keeps its own width across the whole ring — the near side and the far side
13//! alike — where the band narrows with distance.
14//!
15//! The strokes come in both of the source's ink behaviours. The ticks are
16//! opaque: they cover what they draw over and write the depth that hides the
17//! strokes behind them. The orbit and the armillary rings are washes, blended
18//! over the frame instead of covering it, so a ring shows the body it passes
19//! in front of and two crossing rings deepen where they share a pixel. The
20//! rings circle a body whose far side they pass behind, so the same frame
21//! holds a wash in front of a body and one hidden by it.
22//!
23//! Three short opaque strokes stand left of the orbit to pin the ways a
24//! stroke ends: flat-cut, round at one end, and a dot with both ends
25//! rounded. The rings are closed and the ticks are hidden among their
26//! neighbours, so without them no picture exercised the caps a stroke's
27//! fragment shader draws its ends with.
28//!
29//! The two are in one scene because the picture is the argument: a frame
30//! holding either alone would leave the other's promise unpinned. The scene
31//! is declared twice — once for a single-sampled target and once for a
32//! multisampled one — because an opaque stroke's antialiasing follows the
33//! target's sample count: single-sampled it cuts at half coverage, and
34//! multisampled the cut yields to alpha-to-coverage, which spreads that same
35//! coverage over the samples and keeps the edge smooth.
36//!
37//! The bodies carry vertex normals because the shell is pushed along them; a
38//! mesh without normals cannot be spread at all. One body in the frame carries
39//! none, and is drawn lit anyway: its normals come from the screen-space
40//! derivatives of its own surface, which is the fallback a lit draw takes when a
41//! mesh has no normal channel. The two paths are in one frame because the
42//! library has both, and a capture holding only the baked one would leave the
43//! fallback unverified.
44
45use super::{SceneControl, SceneDef, SceneOptions, TEST_SIZE, camera_looking_at};
46use unlit_ecs::{Bundle, DynamicBundle, bundle};
47use unlit_wgpu::pipeline::LitOptions;
48use unlit3d::prelude::*;
49
50/// Where the camera looks down from.
51const CAMERA_EYE: glam::Vec3 = glam::Vec3::new(0.00, 2.75, 4.05);
52
53/// What it looks at: a little above the orbit, so the ring is seen from above.
54const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.00, 0.55, 0.00);
55
56/// The band's width, in world units.
57const HULL_WIDTH: f32 = 0.055;
58
59/// The colour the band is drawn in.
60const INK: glam::Vec4 = glam::Vec4::new(0.01, 0.01, 0.04, 1.0);
61
62/// The snapshot the single-sampled capture freezes its frame under.
63const SNAPSHOT: &str = "outline.webp";
64
65/// The snapshot the multisampled capture freezes its frame under.
66const MSAA_SNAPSHOT: &str = "outline_msaa.webp";
67
68/// The ink the opaque ticks carry: the bytes the segment stream holds, which
69/// the fragment writes into a linear target before it is encoded.
70///
71/// The band's colour reaches the shader as a float and is quantized to bytes on
72/// the way in, so the stroke states the quantized bytes rather than the floats
73/// again: two colours that look alike in the source would otherwise differ by
74/// a few counts on screen and the scene would show two inks.
75const INK_STROKE: [u8; 4] = [3, 3, 10, 255];
76
77/// [`INK_STROKE`] washed by [`WASH_ALPHA`]: the blended orbit's ink, the same
78/// colour at the alpha a wash blends with.
79const INK_WASH: [u8; 4] = [3, 3, 10, 102];
80
81/// The paper the whole scene is drawn on.
82const PAPER: wgpu::Color = wgpu::Color {
83    r: 0.8,
84    g: 0.78,
85    b: 0.72,
86    a: 1.0,
87};
88
89/// The ambient term every body receives before the sun is added.
90const AMBIENT: glam::Vec4 = glam::Vec4::new(0.2, 0.2, 0.24, 0.0);
91
92/// Where the one directional light hangs and what it aims at: from above and
93/// to the left, so each body is lit on one side and turned away on the other.
94const SUN: (glam::Vec3, glam::Vec3) = (
95    glam::Vec3::new(-3.0, 4.0, 2.0),
96    glam::Vec3::new(0.0, 0.45, 0.0),
97);
98
99/// How bright that light is.
100const SUN_INTENSITY: f32 = 0.55;
101
102/// The orbit's radius, and the height it is drawn at.
103///
104/// Wide enough to enclose every body's whole silhouette, including the band
105/// around it: a ring the bodies straddle would cross their own surfaces, and a
106/// stroke drawn through a body reads as a mistake rather than as ink.
107const ORBIT_RADIUS: f32 = 1.75;
108const ORBIT_HEIGHT: f32 = 0.0;
109
110/// How many segments the orbit is drawn with, and its half width in physical
111/// pixels.
112const ORBIT_SEGMENTS: u32 = 128;
113const ORBIT_HALF_WIDTH: f32 = 1.75;
114
115/// How many ticks stand on the orbit, how tall they are and how thick.
116const TICKS: u32 = 24;
117const TICK_HEIGHT: f32 = 0.22;
118const TICK_HALF_WIDTH: f32 = 1.25;
119
120/// The alpha the blended inks are washed to.
121///
122/// Light enough that what a ring passes in front of reads through it, deep
123/// enough that two rings crossing share a pixel of clearly deeper ink.
124const WASH_ALPHA: f32 = 0.4;
125
126/// The armillary rings circling the largest body: each is drawn around an
127/// axis the frame tilts differently, so the two cross twice and each passes
128/// in front of the body it circles on one side and behind it on the other.
129///
130/// The colour is a saturated ink washed to [`WASH_ALPHA`]; the rings are drawn
131/// wider than the orbit so their crossings read at a glance.
132const ARMILLARY: [(glam::Vec3, [u8; 3]); 2] = [
133    (glam::Vec3::new(0.0, 0.9, 1.0), [36, 96, 220]),
134    (glam::Vec3::new(0.9, 0.3, 0.6), [210, 60, 60]),
135];
136const ARMILLARY_RADIUS: f32 = 0.98;
137const ARMILLARY_SEGMENTS: u32 = 96;
138const ARMILLARY_HALF_WIDTH: f32 = 2.5;
139
140/// The point both armillary rings are centred on: the largest body's centre,
141/// so each ring hugs it — outside the band [`HULL_WIDTH`] draws, inside the
142/// orbit that encloses the whole group.
143const ARMILLARY_CENTRE: glam::Vec3 = glam::Vec3::new(0.0, 0.92, -0.2);
144
145/// The half width the cap strokes wear, wide enough for the ends to read.
146const CAPS_HALF_WIDTH: f32 = 3.5;
147
148/// The ink the cap strokes wear: dark enough to read against the paper and
149/// against the orbit they stand beside, opaque so the coverage is the
150/// shader's own.
151const CAPS_INK: [u8; 4] = [40, 36, 32, 255];
152
153/// How finely the smooth spheres are split — fine enough that a baked normal is
154/// what the gradient follows rather than the facets.
155const SLICES: u32 = 48;
156const STACKS: u32 = 24;
157
158/// How coarsely the one faceted body is split.
159///
160/// Coarse on purpose: it carries no normals, so it is shaded by the normals its
161/// own triangles give it, and a facet whose corners disagree about their
162/// direction is exactly what makes that shading visible as facets rather than
163/// as the smooth gradient the baked normal would produce.
164const FACETS: (u32, u32) = (7, 5);
165
166/// The two bodies without normals: where each stands, how big it is and what it
167/// is tinted.
168///
169/// They stand in front of the subject, mirrored about the middle of the frame,
170/// so the derivative-shaded pair is the nearest thing in the picture — its
171/// facets are what a frame is read against.
172const FACETED: [(glam::Vec3, f32, glam::Vec4); 2] = [
173    (
174        glam::Vec3::new(-0.62, 0.56, 0.95),
175        0.3,
176        glam::Vec4::new(0.34, 0.29, 0.52, 1.0),
177    ),
178    (
179        glam::Vec3::new(0.62, 0.56, 0.95),
180        0.3,
181        glam::Vec4::new(0.29, 0.36, 0.54, 1.0),
182    ),
183];
184
185/// The bodies with normals: where each stands, how big it is and what it is
186/// tinted.
187///
188/// Mirrored about the middle of the frame, with the largest in the centre and
189/// the two smaller ones standing back. The same world-space band comes out
190/// narrower on the ones further from the camera — the one thing a picture of a
191/// single body could not show.
192const BODIES: [(glam::Vec3, f32, glam::Vec4); 3] = [
193    (
194        glam::Vec3::new(0.0, 0.92, -0.2),
195        0.62,
196        glam::Vec4::new(0.85, 0.44, 0.2, 1.0),
197    ),
198    (
199        glam::Vec3::new(-1.2, 0.72, -0.3),
200        0.42,
201        glam::Vec4::new(0.22, 0.5, 0.72, 1.0),
202    ),
203    (
204        glam::Vec3::new(1.2, 0.72, -0.3),
205        0.42,
206        glam::Vec4::new(0.72, 0.66, 0.26, 1.0),
207    ),
208];
209
210/// A body: the mesh both of its draws read, and the two pipelines they use.
211///
212/// The two entities spawned from it share the mesh handle, so the shell adds
213/// no memory to the body it surrounds — the pair is one buffer and two
214/// variants of one pipeline family.
215struct Outlined {
216    /// Where the body stands and how big it is.
217    transform: Transform,
218    /// The mesh both draws read.
219    mesh: GpuMesh,
220    /// The pipeline the body is drawn with.
221    body: GpuRenderPipeline,
222    /// The pipeline its shell is drawn with.
223    shell: GpuRenderPipeline,
224    /// The body's own tint, which the shell does not use.
225    tint: glam::Vec4,
226}
227
228impl Outlined {
229    /// The shell: the same mesh, spread and with the body's faces culled.
230    fn spawn_shell(&self, world: &mut World, scope: Entity) {
231        super::spawn_in(
232            world,
233            scope,
234            bundle![
235                self.transform.clone(),
236                self.mesh.clone(),
237                self.shell.clone(),
238                InstanceColor::new(INK),
239                InstanceHullWidth::new(HULL_WIDTH),
240            ],
241        );
242    }
243}
244
245impl Bundle for Outlined {
246    fn into_bundle(self) -> DynamicBundle {
247        bundle![
248            self.transform,
249            InstanceColor::new(self.tint),
250            self.mesh,
251            self.body,
252        ]
253    }
254}
255
256/// The scene: outlined bodies standing on an orbit drawn with strokes, on a
257/// single-sampled target.
258pub static SCENE: SceneDef = SceneDef {
259    id: "outline",
260    title: "Inked outlines",
261    description: "inverted-hull bands beside screen-space strokes",
262    size: TEST_SIZE,
263    baseline: Some(TEST_SIZE),
264    clear: Some(PAPER),
265    frames: 1,
266    step_seconds: None,
267    samples: 1,
268    depth: true,
269    ui: false,
270    reproducible_ui: false,
271    build,
272};
273
274/// The same scene on a multisampled target.
275///
276/// The world is the one [`SCENE`] builds — the difference is the sample count
277/// the capture binds, which is the one thing that changes how an opaque stroke
278/// is antialiased: single-sampled it cuts at half its coverage, and
279/// multisampled the cut yields to alpha-to-coverage, which spreads that same
280/// coverage over the samples and keeps the edge smooth.
281pub static MSAA_SCENE: SceneDef = SceneDef {
282    id: "outline_msaa",
283    title: "Inked outlines, multisampled",
284    description: "the outline scene on a four-sample target",
285    size: TEST_SIZE,
286    baseline: Some(TEST_SIZE),
287    clear: Some(PAPER),
288    frames: 1,
289    step_seconds: None,
290    samples: 4,
291    depth: true,
292    ui: false,
293    reproducible_ui: false,
294    build: build_msaa,
295};
296
297/// Build the single-sampled scene's world content.
298fn build(
299    world: &mut World,
300    context: RenderContext,
301    pass: Entity,
302    ui: Option<Entity>,
303    size: (u32, u32),
304    options: SceneOptions,
305) -> SceneControl {
306    built(SNAPSHOT, world, context, pass, ui, size, options)
307}
308
309/// Build the multisampled scene's world content: the same world, frozen under
310/// the other snapshot's name.
311fn build_msaa(
312    world: &mut World,
313    context: RenderContext,
314    pass: Entity,
315    ui: Option<Entity>,
316    size: (u32, u32),
317    options: SceneOptions,
318) -> SceneControl {
319    built(MSAA_SNAPSHOT, world, context, pass, ui, size, options)
320}
321
322/// The core both builds share; `snapshot` is the name frame 0 freezes under.
323fn built(
324    snapshot: &'static str,
325    world: &mut World,
326    context: RenderContext,
327    pass: Entity,
328    _ui: Option<Entity>,
329    size: (u32, u32),
330    _options: SceneOptions,
331) -> SceneControl {
332    let mut source = MeshSource::new(&world.root_scope(), context);
333    source.register_lit_family(&world.root_scope());
334
335    // The default options draw unlit; these bodies are lit, so the flag is the
336    // one thing that differs.
337    let mut lit = super::default_options();
338    lit.unlit = false;
339    let body_key = LitPipelineKey::new(lit);
340    // The shell is a second variant of the same family: the one that spreads
341    // each vertex along its normal and culls the faces the body draws. It tests
342    // depth but writes none, which is what keeps the band visible whatever order
343    // the two draws land in.
344    let shell_key = LitPipelineKey::new(LitOptions::inverted_hull());
345
346    // The camera hangs in the shared subtree. Two sources draw this frame — the
347    // meshes and the strokes — and a source reads only its own subtree, so a
348    // camera beside one of them would be invisible to the other.
349    let camera_entity = super::spawn_shared(
350        world,
351        bundle![camera_looking_at(CAMERA_EYE, CAMERA_TARGET, size)],
352    );
353
354    let source_entity = world
355        .scope_mut(pass)
356        .expect("the pass is alive")
357        .spawn(bundle![Source::new(source)]);
358
359    // One smooth sphere, shared by every body and by both of its draws.
360    let (positions, normals, indices) = sphere(SLICES, STACKS);
361    let mesh = super::with_mesh_source(world, source_entity, |source, world| {
362        source.allocate_lit_mesh(
363            world,
364            &body_key.options,
365            LitMeshDesc {
366                positions: &positions,
367                normals: Some(&normals),
368                indices: Some(&indices),
369                ..Default::default()
370            },
371        )
372    });
373
374    // The faceted body's mesh carries positions and indices alone. A lit draw of
375    // it shades by the normals its own surface gives it, derived per fragment
376    // from the screen-space derivatives of the positions — the fallback a mesh
377    // without a normal channel takes. It is coarse so the facets read.
378    let (positions, _, indices) = sphere(FACETS.0, FACETS.1);
379    let faceted = super::with_mesh_source(world, source_entity, |source, world| {
380        source.allocate_lit_mesh(
381            world,
382            &body_key.options,
383            LitMeshDesc {
384                positions: &positions,
385                indices: Some(&indices),
386                ..Default::default()
387            },
388        )
389    });
390
391    for (translation, radius, tint) in BODIES {
392        let outlined = Outlined {
393            transform: Transform {
394                translation,
395                rotation: glam::Quat::IDENTITY,
396                scale: glam::Vec3::splat(radius),
397            },
398            mesh: mesh.clone(),
399            body: GpuRenderPipeline::new(body_key.clone()),
400            shell: GpuRenderPipeline::new(shell_key.clone()),
401            tint,
402        };
403        // The shell draws the faces the body culls, and at every pixel it is
404        // behind the body, so the body covers it wherever the two coincide
405        // whatever order they land in. The spawn order here is arbitrary.
406        outlined.spawn_shell(world, source_entity);
407        super::spawn_in(world, source_entity, outlined);
408    }
409
410    // The bodies without normals. They have no shell — the shell is pushed along
411    // the normals a mesh carries, and these carry none — so they are here for the
412    // shading their own facets give them.
413    for (translation, radius, tint) in FACETED {
414        super::spawn_in(
415            world,
416            source_entity,
417            bundle![
418                Transform {
419                    translation,
420                    rotation: glam::Quat::IDENTITY,
421                    scale: glam::Vec3::splat(radius),
422                },
423                InstanceColor::new(tint),
424                faceted.clone(),
425                GpuRenderPipeline::new(body_key.clone()),
426            ],
427        );
428    }
429
430    super::spawn_in(world, source_entity, bundle![AmbientLight::new(AMBIENT)]);
431    super::spawn_in(
432        world,
433        source_entity,
434        super::PlacedLight::aiming(
435            LightSource::new(DirectionalLight {
436                color: glam::Vec4::new(1.0, 0.97, 0.9, 0.0),
437                intensity: SUN_INTENSITY,
438            }),
439            SUN.0,
440            SUN.1,
441        ),
442    );
443
444    // The strokes are their own source, and each one belongs to its entity:
445    // the source collects the Line components its scope sees every frame, so
446    // the ring is edited the way the bodies are. The entities go under the
447    // source's own, because a source draws its subtree and the shared one.
448    //
449    // The orbit and the armillary rings are washes — blended over the frame
450    // and hidden by what stands in front of them — and the ticks are opaque,
451    // so one source draws both of the source's ink behaviours.
452    let lines = world
453        .scope_mut(pass)
454        .expect("the pass is alive")
455        .spawn(bundle![Source::new(LineSource::new())]);
456    let mut strokes = orbit();
457    strokes.extend(armillary());
458    strokes.extend(caps_strokes());
459    for stroke in strokes {
460        super::spawn_in(world, lines, bundle![stroke]);
461    }
462
463    SceneControl {
464        // A single frame: nothing moves, so only the camera's aspect is
465        // advanced, keeping a resized window from stretching the ring.
466        advance: Box::new(move |world, _frame, _delta, frame_size| {
467            super::aim_camera(
468                world,
469                world.shared(),
470                camera_entity,
471                CAMERA_EYE,
472                CAMERA_TARGET,
473                frame_size,
474            );
475        }),
476        snapshot: Box::new(move |frame| (frame == 0).then(|| snapshot.to_owned())),
477    }
478}
479
480/// The unit sphere every body is scaled from, as `(positions, normals,
481/// indices)`.
482///
483/// The normals are the unit positions themselves, so the shell spreads along
484/// the same directions the body shades by. The winding is counter-clockwise
485/// seen from outside, which is what makes the faces the body draws the ones
486/// the shell culls.
487///
488/// A body that carries no normals is the same geometry: whether the vertices
489/// disagree about their direction is what decides between the two shading paths,
490/// not the mesh the allocator was handed.
491fn sphere(slices: u32, stacks: u32) -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<u32>) {
492    let row = slices + 1;
493    let mut positions = Vec::with_capacity((row * (stacks + 1)) as usize);
494    let mut normals = Vec::with_capacity(positions.capacity());
495    for stack in 0..=stacks {
496        let polar = core::f32::consts::PI * stack as f32 / stacks as f32;
497        for slice in 0..=slices {
498            let azimuth = core::f32::consts::TAU * slice as f32 / slices as f32;
499            let normal = glam::Vec3::new(
500                polar.sin() * azimuth.cos(),
501                polar.cos(),
502                polar.sin() * azimuth.sin(),
503            );
504            normals.push(normal.to_array());
505            positions.push(normal.to_array());
506        }
507    }
508    let mut indices = Vec::with_capacity((slices * stacks * 6) as usize);
509    for stack in 0..stacks {
510        for slice in 0..slices {
511            let corner = stack * row + slice;
512            indices.extend_from_slice(&[
513                corner,
514                corner + row,
515                corner + row + 1,
516                corner,
517                corner + row + 1,
518                corner + 1,
519            ]);
520        }
521    }
522    (positions, normals, indices)
523}
524
525/// The orbit and its ticks, as the strokes the line source draws.
526///
527/// The ring is closed by repeating its first station last, and every tick is
528/// its own segment, so no two of them are joined by a stroke. The ring is a
529/// wash — blended over the frame, its ink carrying [`WASH_ALPHA`] — and the
530/// ticks are opaque, so one orbit holds both of the source's ink behaviours.
531fn orbit() -> Vec<Line> {
532    let mut segments = Vec::with_capacity(ORBIT_SEGMENTS as usize + TICKS as usize);
533    let station = |step: u32| {
534        let angle = core::f32::consts::TAU * step as f32 / ORBIT_SEGMENTS as f32;
535        glam::Vec3::new(
536            ORBIT_RADIUS * angle.cos(),
537            ORBIT_HEIGHT,
538            ORBIT_RADIUS * angle.sin(),
539        )
540    };
541    for step in 0..ORBIT_SEGMENTS {
542        segments.push(Line::blended(
543            LineSegment::new(station(step).to_array(), station(step + 1).to_array())
544                .with_color(INK_WASH)
545                .with_half_width(ORBIT_HALF_WIDTH),
546        ));
547    }
548    // The ticks stand on the ring, spaced round it rather than at one per
549    // segment: they are the marks the ring is read against, and one at every
550    // few stations keeps them from merging into a second, ragged ring.
551    for tick in 0..TICKS {
552        let step = tick * ORBIT_SEGMENTS / TICKS;
553        let foot = station(step);
554        let head = foot + glam::Vec3::Y * TICK_HEIGHT;
555        segments.push(Line::opaque(
556            LineSegment::new(foot.to_array(), head.to_array())
557                .with_color(INK_STROKE)
558                .with_half_width(TICK_HALF_WIDTH),
559        ));
560    }
561    segments
562}
563
564/// The armillary rings, as the segments the stroke source draws.
565///
566/// Each ring circles the largest body around its own tilted axis: the axis is
567/// the ring's normal, and the frame it is drawn in is the pair of directions
568/// `glam` reports orthogonal to it. Two different tilts make the rings cross
569/// twice, and circling a body puts each ring in front of it on one half and
570/// behind it on the other — the picture a wash needs, of depth acting on a
571/// stroke that covers nothing.
572fn armillary() -> impl Iterator<Item = Line> {
573    ARMILLARY.iter().flat_map(|(axis, ink)| {
574        let (u, v) = axis.any_orthonormal_pair();
575        let station = move |step: u32| {
576            let angle = core::f32::consts::TAU * step as f32 / ARMILLARY_SEGMENTS as f32;
577            ARMILLARY_CENTRE
578                + u * (ARMILLARY_RADIUS * angle.cos())
579                + v * (ARMILLARY_RADIUS * angle.sin())
580        };
581        (0..ARMILLARY_SEGMENTS).map(move |step| {
582            Line::blended(
583                LineSegment::new(station(step).to_array(), station(step + 1).to_array())
584                    .with_color(wash(*ink))
585                    .with_half_width(ARMILLARY_HALF_WIDTH),
586            )
587        })
588    })
589}
590
591/// The ink a wash carries: `rgb` washed to [`WASH_ALPHA`].
592fn wash(rgb: [u8; 3]) -> [u8; 4] {
593    [rgb[0], rgb[1], rgb[2], (WASH_ALPHA * 255.0) as u8]
594}
595
596/// The three cap strokes standing left of the orbit, where nothing else
597/// draws: flat-cut, round at the head end, and a dot — both ends rounded,
598/// the ends coincident. The frame freezes them so a change in how a stroke
599/// ends cannot land unreviewed: the caps are a shader decision the rings
600/// and the ticks never exercise, closed rings having no ends at all.
601/// unreviewed: the caps are a shader decision the rings and the ticks never
602/// exercise, closed rings having no ends at all.
603fn caps_strokes() -> [Line; 3] {
604    // Flat: neither end rounded, the record's default.
605    let flat = stroke(
606        glam::Vec3::new(-2.15, 0.30, 0.0),
607        glam::Vec3::new(-2.15, 1.05, 0.0),
608        0,
609    );
610    // Round at the head end only.
611    let round_head = stroke(
612        glam::Vec3::new(-1.75, 0.30, 0.0),
613        glam::Vec3::new(-1.75, 1.05, 0.0),
614        LineSegment::ROUND_B,
615    );
616    // A dot: both ends rounded and the ends coincident.
617    let dot = stroke(
618        glam::Vec3::new(-1.35, 0.30, 0.0),
619        glam::Vec3::new(-1.35, 0.30, 0.0),
620        LineSegment::ROUND_A | LineSegment::ROUND_B,
621    );
622    [flat, round_head, dot]
623}
624
625/// One opaque vertical cap stroke from `foot` up to `head`, with `caps` as
626/// the record's end flags.
627fn stroke(foot: glam::Vec3, head: glam::Vec3, caps: u32) -> Line {
628    let mut segment = LineSegment::new(foot.to_array(), head.to_array())
629        .with_color(CAPS_INK)
630        .with_half_width(CAPS_HALF_WIDTH);
631    segment.caps = caps;
632    Line::opaque(segment)
633}