teksilo-render 0.17.0

wgpu renderer for Teksilo — rect, SDF and path pipelines with a glyph atlas.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

use std::sync::{OnceLock, mpsc};

use crate::Renderer;

/// Why an offscreen readback could not produce pixels.
///
/// Every variant means the same thing to a caller — no image this time — but
/// they are kept apart because they point at different causes: a lost device
/// is a driver / compositor event, a failed map is usually memory pressure.
#[derive(Debug, thiserror::Error)]
pub enum ReadbackError {
    /// `map_async`'s callback was dropped without firing — the device was lost
    /// before the mapping completed.
    #[error("readback failed: the GPU device was lost while mapping")]
    DeviceLost,
    /// The buffer mapping itself failed.
    #[error("readback failed: buffer mapping was refused ({0})")]
    MapFailed(String),
    /// `poll` reported a failure before the mapping could be observed.
    #[error("readback failed: polling the device failed ({0})")]
    PollFailed(String),
}

/// The process-wide GPU device every offscreen renderer shares.
///
/// One device per process, not one per caller. Two D3D12 **WARP** devices
/// rasterizing at the same time fault inside `d3d10warp.dll` — Microsoft's
/// software rasterizer, which is exactly what a GPU-less Windows host and the
/// CI runners use — so a device per caller turned any two concurrent offscreen
/// renders into a crash the faulting-module log pins on WARP itself, not on
/// wgpu or on us. It is not something we can fix downstream; the only remedy is
/// to stop creating the second device.
///
/// Sharing is also simply right: a GPU device is a process-level resource, and
/// nothing here ever wanted a private one. Callers still get their **own**
/// [`Renderer`] — that is where the glyph and path atlases live, so no caller
/// can see another's cached glyphs.
///
/// `None` means this host can open no usable device at all; it is cached too,
/// so a GPU-less machine pays the failed search once rather than per call.
static SHARED_DEVICE: OnceLock<Option<(wgpu::Device, wgpu::Queue, crate::DeviceHealth)>> =
    OnceLock::new();

/// Open the one device, searching for an adapter that actually yields one.
///
/// Adapter selection is a *search*, not a single request. A host can enumerate
/// an adapter it cannot actually open — a VM's OpenGL driver is the common
/// case — while a perfectly good software device sits behind
/// `force_fallback_adapter`. Treating the first `request_device` failure as
/// fatal reports "no GPU" on a machine that has one, which is what made
/// screenshots unavailable on GPU-less Windows hosts and CI runners (where
/// DX12 WARP is present and works). So: try the preferred adapter, then an
/// explicit software fallback, and only give up when neither yields a device.
async fn open_shared_device(label: &'static str) -> Option<(wgpu::Device, wgpu::Queue)> {
    #[cfg(test)]
    DEVICE_OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    // Built `_from_env`, as the window path's instance is, so wgpu's own
    // variables apply here too: `WGPU_BACKEND=vulkan` then yields a Vulkan
    // adapter (lavapipe on a GPU-less CI runner) or none, never a silent fall
    // back to GL. With it set, the `force_fallback_adapter` retry below can
    // only find a software adapter of that backend.
    //
    // Same flags the window path uses — see `crate::instance::instance_flags`.
    // Not a tidiness point: without it this instance keeps
    // `VALIDATION_INDIRECT_CALL`, and on a driver that cannot build wgpu's
    // indirect-validation pipelines `request_device` panics rather than
    // returning the error the search below is written to survive.
    let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
    descriptor.flags = crate::instance_flags();
    let instance = wgpu::Instance::new(descriptor);

    for force_fallback_adapter in [false, true] {
        let Ok(adapter) = instance
            .request_adapter(&wgpu::RequestAdapterOptions {
                // `WGPU_POWER_PREF`, as the window path reads it, so a
                // hybrid-GPU machine renders offscreen on the GPU its windows
                // use.
                power_preference: wgpu::PowerPreference::from_env()
                    .unwrap_or(wgpu::PowerPreference::LowPower),
                compatible_surface: None,
                force_fallback_adapter,
                ..Default::default()
            })
            .await
        else {
            continue;
        };

        // `downlevel_defaults` caps `max_texture_dimension_2d` at 2048, but the
        // path atlas grows past that — so a path-heavy frame would fail here
        // while rendering fine anywhere the cap is lifted. `using_resolution`
        // lifts exactly the resolution limits to whatever this adapter really
        // supports, keeping every other downlevel bound. The live window path
        // asks for the same set, so a frame that renders in a test renders in
        // a window too.
        let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits());

        if let Ok((device, queue)) = adapter
            .request_device(&wgpu::DeviceDescriptor {
                label: Some(label),
                required_features: wgpu::Features::empty(),
                required_limits: limits,
                ..Default::default()
            })
            .await
        {
            return Some((device, queue));
        }
    }
    None
}

/// Build an offscreen renderer on the shared device, or `None` if this host can
/// open no usable GPU device at all.
///
/// The [`Renderer`] is fresh per call; the device and queue behind it are
/// shared process-wide, which is load-bearing rather than an optimisation: two
/// D3D12 **WARP** devices rasterizing at once fault inside Microsoft's software
/// rasterizer — exactly what a GPU-less Windows host and the CI runners use —
/// so a device per caller turns any two concurrent offscreen renders into a
/// crash. Atlases still live on the per-call `Renderer`, so no caller can see
/// another's cached glyphs.
///
/// `label` names the device, so it only takes effect on the call that actually
/// opens it; later callers join a device someone else already named.
///
/// This is the entry shipped code uses (the headless automation server's
/// screenshots, the previewer's PNG export). It never panics: a host without a
/// GPU gets `None`, whatever the environment says. Tests call
/// [`require_test_renderer`], which can insist on an adapter.
pub async fn create_offscreen_renderer(
    label: &'static str,
) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
    let (device, queue, health) = shared_device(label)?;
    let renderer = Renderer::with_device_health(
        device.clone(),
        queue.clone(),
        wgpu::TextureFormat::Rgba8UnormSrgb,
        health.clone(),
    );
    Some((renderer, device.clone(), queue.clone()))
}

/// [`create_offscreen_renderer`], under its earlier name.
pub async fn create_test_renderer(
    label: &'static str,
) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
    create_offscreen_renderer(label).await
}

/// The variable a test run sets to insist on a GPU adapter.
///
/// - unset: [`require_test_renderer`] behaves like
///   [`create_offscreen_renderer`], so a GPU test returns early on a host
///   without an adapter;
/// - `any`: a missing adapter fails the test;
/// - `lavapipe`: a missing adapter, or one that is not Mesa's lavapipe
///   ([`is_lavapipe`]), fails the test.
///
/// CI sets it so that an adapter that stopped loading fails the job instead of
/// turning every GPU test into an early return counted as a pass.
pub const REQUIRE_ADAPTER_VAR: &str = "TEKSILO_TEST_REQUIRE_ADAPTER";

/// `true` when [`REQUIRE_ADAPTER_VAR`] asks for an adapter.
///
/// For a test that reaches the GPU through a path that must stay panic-free
/// (the headless automation server) and accepts a "no GPU" reply: under the
/// requirement, that reply is a failure, not a pass.
pub fn adapter_required() -> bool {
    std::env::var_os(REQUIRE_ADAPTER_VAR).is_some_and(|v| !v.is_empty())
}

/// An offscreen renderer for a GPU test, honouring [`REQUIRE_ADAPTER_VAR`].
///
/// Returns `None` only when the variable is unset and the host has no
/// adapter; a test then returns early. Panics, naming the adapter it found,
/// when the variable asks for an adapter this host does not provide.
pub async fn require_test_renderer(
    label: &'static str,
) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
    let requirement = std::env::var(REQUIRE_ADAPTER_VAR).ok();
    let renderer = create_offscreen_renderer(label).await;
    let found = renderer
        .as_ref()
        .map(|(_, device, _)| device.adapter_info());
    if let Err(message) = check_adapter_requirement(requirement.as_deref(), found.as_ref()) {
        panic!("{message}");
    }
    renderer
}

/// The decision behind [`require_test_renderer`], separated so it can be
/// tested without the variable or a device.
fn check_adapter_requirement(
    requirement: Option<&str>,
    found: Option<&wgpu::AdapterInfo>,
) -> Result<(), String> {
    match (requirement, found) {
        (None, _) => Ok(()),
        (Some("any"), Some(_)) => Ok(()),
        (Some("lavapipe"), Some(info)) if is_lavapipe(info) => Ok(()),
        (Some(wanted @ ("any" | "lavapipe")), None) => Err(format!(
            "{REQUIRE_ADAPTER_VAR}={wanted}, but no GPU adapter could be opened"
        )),
        (Some("lavapipe"), Some(info)) => Err(format!(
            "{REQUIRE_ADAPTER_VAR}=lavapipe, but the adapter opened is not lavapipe: {info:?}"
        )),
        (Some(other), _) => Err(format!(
            "{REQUIRE_ADAPTER_VAR}={other:?} is not one of \"any\" or \"lavapipe\""
        )),
    }
}

/// `true` for Mesa's lavapipe, the Vulkan rasteriser that runs on the CPU.
///
/// `device_type == Cpu` alone means any software rasteriser, which also
/// matches GL llvmpipe and D3D12 WARP; the backend and the driver name tell
/// lavapipe apart.
pub fn is_lavapipe(info: &wgpu::AdapterInfo) -> bool {
    info.backend == wgpu::Backend::Vulkan
        && info.device_type == wgpu::DeviceType::Cpu
        && info.driver == "llvmpipe"
}

/// The shared device, opening it on the first call.
///
/// Synchronous on purpose. `OnceLock::get_or_init` gives "exactly one caller
/// runs the initialiser, the rest wait" for free, and opening a GPU device is
/// blocking work whichever way it is spelled — every caller already reaches
/// this through `pollster::block_on`. The alternative, holding a lock across
/// the `await` inside an async fn, is the shape `clippy::await_holding_lock`
/// warns about, and it would deadlock the first caller that ever drove this
/// from a single-threaded executor.
/// The shared device, its queue and its lost-device latch, installed once
/// when it is opened and shared by every renderer on it.
fn shared_device(
    label: &'static str,
) -> Option<&'static (wgpu::Device, wgpu::Queue, crate::DeviceHealth)> {
    SHARED_DEVICE
        .get_or_init(|| {
            pollster::block_on(open_shared_device(label)).map(|(device, queue)| {
                let health = crate::DeviceHealth::install(&device);
                (device, queue, health)
            })
        })
        .as_ref()
}

/// Read a texture back as tightly-packed RGBA, panicking on GPU failure.
///
/// Kept for tests, where a lost device is a test failure and a panic is the
/// clearest report. Anything user-facing — a screenshot tool that must survive
/// a driver restart — should call [`try_read_texture_rgba`] instead.
pub fn read_texture_rgba(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    texture: &wgpu::Texture,
    width: u32,
    height: u32,
) -> Vec<u8> {
    try_read_texture_rgba(device, queue, texture, width, height).expect("texture readback failed")
}

/// Read a texture back as tightly-packed RGBA.
///
/// The GPU copy needs each row aligned to
/// [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`]; the padding is added for the copy
/// and stripped back out here, so the returned buffer is exactly
/// `width * height * 4` bytes with no stride.
pub fn try_read_texture_rgba(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    texture: &wgpu::Texture,
    width: u32,
    height: u32,
) -> Result<Vec<u8>, ReadbackError> {
    try_read_texture_level_rgba(device, queue, texture, 0, width, height)
}

/// [`try_read_texture_rgba`] of mip level `level`, which is `width ×
/// height`.
pub fn try_read_texture_level_rgba(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    texture: &wgpu::Texture,
    level: u32,
    width: u32,
    height: u32,
) -> Result<Vec<u8>, ReadbackError> {
    let bytes_per_pixel = 4u32;
    let unpadded_bytes_per_row = width * bytes_per_pixel;
    let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
        * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
    let buffer_size = padded_bytes_per_row as u64 * height as u64;

    let buffer = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("teksilo_render_test_readback"),
        size: buffer_size,
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("teksilo_render_test_copy"),
    });
    encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture,
            mip_level: level,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &buffer,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(padded_bytes_per_row),
                rows_per_image: Some(height),
            },
        },
        wgpu::Extent3d {
            width,
            height,
            depth_or_array_layers: 1,
        },
    );
    queue.submit(std::iter::once(encoder.finish()));

    let slice = buffer.slice(..);
    let (tx, rx) = mpsc::channel();
    slice.map_async(wgpu::MapMode::Read, move |result| {
        let _ = tx.send(result);
    });
    device
        .poll(wgpu::PollType::Wait {
            submission_index: None,
            timeout: None,
        })
        .map_err(|e| ReadbackError::PollFailed(e.to_string()))?;
    rx.recv()
        .map_err(|_| ReadbackError::DeviceLost)?
        .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;

    let mapped = slice
        .get_mapped_range()
        .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;
    let mut pixels = vec![0u8; (width * height * bytes_per_pixel) as usize];
    for row in 0..height as usize {
        let src_offset = row * padded_bytes_per_row as usize;
        let dst_offset = row * unpadded_bytes_per_row as usize;
        pixels[dst_offset..dst_offset + unpadded_bytes_per_row as usize]
            .copy_from_slice(&mapped[src_offset..src_offset + unpadded_bytes_per_row as usize]);
    }
    drop(mapped);
    buffer.unmap();
    Ok(pixels)
}

/// How many times a GPU device has actually been opened in this process.
///
/// Exists only so [`exactly_one_device_is_opened_per_process`] can assert the
/// invariant the WARP crash depends on.
#[cfg(test)]
static DEVICE_OPENS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

#[cfg(test)]
mod shared_device_tests {
    use super::*;

    /// Every caller must land on the SAME device, even under contention.
    ///
    /// This is the invariant that keeps the offscreen renderer alive on a
    /// GPU-less Windows host. Two D3D12 WARP devices rasterizing concurrently
    /// fault inside `d3d10warp.dll`, which no amount of care on our side can
    /// catch — it is a wild access violation in Microsoft's software
    /// rasterizer, so the process dies mid-test. The only defence is to never
    /// open the second device, and that is what this pins.
    ///
    /// Asserted through a counter rather than by comparing handles because
    /// `wgpu::Device` exposes no identity: cloning is the supported way to
    /// share one, so two clones are indistinguishable from two devices at the
    /// type level — exactly the confusion that let a second device appear.
    #[test]
    fn exactly_one_device_is_opened_per_process() {
        use std::sync::atomic::Ordering;

        // Race several threads at the initialiser; `OnceLock` plus the init
        // lock must let exactly one of them reach `open_shared_device`.
        let barrier = std::sync::Arc::new(std::sync::Barrier::new(4));
        let handles: Vec<_> = (0..4)
            .map(|_| {
                let b = barrier.clone();
                std::thread::spawn(move || {
                    b.wait();
                    pollster::block_on(create_test_renderer("shared-device-test")).is_some()
                })
            })
            .collect();
        let got: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();

        // Either this host has a device and every caller got one, or it has
        // none and nobody did — never a mix.
        assert!(
            got.iter().all(|g| *g) || got.iter().all(|g| !*g),
            "callers disagreed about whether a GPU exists: {got:?}"
        );

        let opens = DEVICE_OPENS.load(Ordering::Relaxed);
        assert_eq!(
            opens, 1,
            "the device must be opened exactly once per process, not {opens} times - a second \
             concurrent WARP device is an access violation inside d3d10warp.dll, not a slowdown"
        );
    }

    fn adapter(
        backend: wgpu::Backend,
        device_type: wgpu::DeviceType,
        driver: &str,
    ) -> wgpu::AdapterInfo {
        let mut info = wgpu::AdapterInfo::new(device_type, backend);
        info.driver = driver.into();
        info
    }

    #[test]
    fn lavapipe_is_told_apart_from_other_software_rasterisers() {
        use wgpu::{Backend, DeviceType};
        assert!(is_lavapipe(&adapter(
            Backend::Vulkan,
            DeviceType::Cpu,
            "llvmpipe"
        )));
        // GL llvmpipe reports no driver name; WARP is D3D12.
        assert!(!is_lavapipe(&adapter(Backend::Gl, DeviceType::Cpu, "")));
        assert!(!is_lavapipe(&adapter(Backend::Dx12, DeviceType::Cpu, "")));
        assert!(!is_lavapipe(&adapter(
            Backend::Vulkan,
            DeviceType::IntegratedGpu,
            "radv"
        )));
    }

    #[test]
    fn the_adapter_requirement_fails_only_when_it_is_unmet() {
        use wgpu::{Backend, DeviceType};
        let lavapipe = adapter(Backend::Vulkan, DeviceType::Cpu, "llvmpipe");
        let radv = adapter(Backend::Vulkan, DeviceType::IntegratedGpu, "radv");

        assert!(check_adapter_requirement(None, None).is_ok());
        assert!(check_adapter_requirement(None, Some(&radv)).is_ok());
        assert!(check_adapter_requirement(Some("any"), Some(&radv)).is_ok());
        assert!(check_adapter_requirement(Some("lavapipe"), Some(&lavapipe)).is_ok());

        assert!(check_adapter_requirement(Some("any"), None).is_err());
        assert!(check_adapter_requirement(Some("lavapipe"), None).is_err());
        let wrong = check_adapter_requirement(Some("lavapipe"), Some(&radv)).unwrap_err();
        assert!(
            wrong.contains("radv"),
            "the message names the adapter found: {wrong}"
        );
        assert!(check_adapter_requirement(Some("vulkan"), Some(&lavapipe)).is_err());
    }
}