WebGPU notes / 08
WebGPU 3D Text Mesh in Rust and wgpu
Turn font outlines into 3D geometry with Rust and wgpu. This WebGPU example shapes Latin and Persian text, builds extruded strokes along the glyph contours, and draws both lines as one indexed mesh with lighting in WGSL.
- Text lines
- 2
- Draw calls
- 1
- Curve steps
- 10
Text as geometry in a 3D scene
The text overlay example drew glyph images from a texture atlas in screen coordinates. Here the letters have positions, normals, and vertex colors in a 3D scene. Their side faces become visible as the mesh tilts, and lighting makes the extrusion easier to read.
The upper line says Hey WGPU!
in gold. The blue line beneath it contains this Persian text:
هی وب جی پی یو!
The visible letters are extruded contour strokes. The helper builds narrow prisms along the font outlines, leaving the glyph interiors open. Both lines gently rock around Y with a fixed 8° tilt around X. The application runs through sib::render and has no mouse, keyboard, or text editing controls.
Shape the text before building the mesh
The Rust example embeds Vazirmatn-Regular.ttf with include_bytes!. It calls TextMesh::from_font_bytes once for each line, supplying the font, string, color, and mesh options:
let ltr = text_mesh::TextMesh::from_font_bytes(
FONT_BYTES,
"Hey WGPU!",
[1.0, 0.72, 0.28, 1.0],
options,
)?;
The text mesh helper used by this checkout creates a glyphon text buffer backed by cosmic-text. Its default Shaping::Advanced selects and positions glyphs before any triangles are generated. The Persian string stays in normal Unicode order; shaping and bidirectional layout determine its visual arrangement.
For each shaped glyph, ttf_parser::Face::outline_glyph supplies its outline. The helper scales the font coordinates by glyph.font_size / units_per_em and applies the glyph’s layout position and offsets. It also flips the layout’s vertical origin into the mesh coordinate system.
Outline extraction uses the supplied font face. Choose a font that contains the characters you need; this helper does not resolve outlines from arbitrary fallback fonts. Glyphs without outlines are skipped, and a string that produces no vertices returns an error.
Extrude the font outlines
The example requests the Vazirmatn family and uses these options. Distances are mesh and layout units before the model scale is applied:
Scroll sideways to see all table columns.
| Option | Value | Effect |
|---|---|---|
font_size | 1.0 | Scale used for text layout and glyph outlines |
line_height | 1.35 | Line metric within each shaping buffer |
depth | 0.18 | Extrusion from Z = −0.09 to +0.09 |
stroke_width | 0.032 | Full width of each contour stroke |
curve_steps | 10 | Fixed subdivisions per Bézier curve |
OutlineCollector implements ttf_parser::OutlineBuilder. Straight lines contribute their endpoints. Quadratic and cubic Bézier curves are sampled at evenly spaced parameter values, producing a sequence of straight segments. Closing a contour connects its final point back to its first.
Each segment becomes a separate rectangular prism. A perpendicular offset of half the stroke width establishes its sides, and half the depth places its front and back faces. Two end caps complete the prism.
That is six quads, or 24 vertices and 36 indices per nondegenerate segment. Each face has its own vertices and flat normal. Inner contours receive the same treatment as outer contours.
This simple construction does not triangulate filled glyph interiors or weld neighboring prisms into one solid. Prisms can overlap at joins, and there are no bevels. Increasing curve_steps makes curved outlines more detailed while increasing geometry and CPU construction work; it does not change how those joins are built.
Combine and position the two lines
With the default center: true, each line is centered in X and Y after construction. The example appends them to a combined mesh with separate vertical offsets, then centers the result:
let mut mesh = text_mesh::TextMesh::default();
mesh.append(<r, [0.0, 0.58, 0.0])?;
mesh.append(&rtl, [0.0, -0.58, 0.0])?;
let center = mesh.bounds.center();
mesh.translate([-center[0], -center[1], 0.0]);
append copies the vertices and offsets the incoming indices to refer to their new positions in the combined vertex array. The two offsets differ by 1.16 units. This separation comes from the append operations, not the line_height option, because the lines were shaped in separate buffers.
The initial model scale is (4.7 / mesh.bounds.width().max(1.0)).min(1.25). The camera uses a distance of 5.5 for wide viewports and 7.1 for portrait viewports. Very narrow views or longer replacement strings may still need a smaller model scale or a different camera distance to keep the full text visible.
The gentle yaw animation advances with a frame counter. Its speed therefore depends on frame rate. Animation updates the uniform buffer; it does not rebuild the text geometry. Resizing recreates the depth texture and updates the camera and projection uniforms.
Render and light the mesh in WGSL
TextMeshVertex has a 40-byte stride: position occupies 12 bytes at shader location 0, normal occupies 12 bytes at location 1, and RGBA color occupies 16 bytes at location 2. Both lines are uploaded into one vertex buffer and one u32 index buffer during initialization.
The only bind group entry is a uniform buffer at @group(0) @binding(0), containing the model-view-projection matrix, model matrix, and light direction. There are no sampled textures in this render pipeline. One pass clears color and depth, then draws both lines:
render_pass.draw_indexed(0..self.index_count, 0, 0..1);
The pipeline writes depth and uses LessEqual testing, with face culling disabled. The WGSL shader transforms and normalizes the vertex normals. In the fragment stage, a directional diffuse term and a cool rim accent shade the vertex color:
let shaded = input.color.rgb * (0.34 + diffuse * 0.74)
+ vec3<f32>(0.18, 0.24, 0.32) * rim;
The rim term compares the surface normal with the fixed +Z direction, rather than a calculated camera vector. Both text colors have alpha 1, and this pipeline does not enable alpha blending.
Run and modify the example
From a local checkout with Rust installed, run the native version:
cargo run --example textmesh
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release textmesh
cargo run --bin serve
Open http://127.0.0.1:8080/textmesh/. The font is bundled, so no separate font download is needed. Live rendering requires WebGPU support in the browser; the article, text sample, and screenshot remain readable without it.
Try these source changes and rebuild:
- Adjust
depthandstroke_widthindependently to compare extrusion depth with outline thickness. - Change
curve_stepsfrom 10 to 20 and compare the curves and generated vertex count. - Replace either string with text covered by the bundled font, then adjust the mesh offsets and camera framing.
The buffers are built at startup. Adding live text editing would require regenerating the mesh and updating its GPU buffers; changing the string alone would not update an already uploaded mesh.