Fix non-deterministic GPU stroke artifacts (vello#1314) (#1323)

## Summary

Fix non-deterministic rendering artifacts when GPU-stroking certain
rounded rectangles
([vello#1314](https://github.com/linebender/vello/issues/1314)).

The issue could manifest as severe frame-to-frame corruption even when
rendering
the exact same scene. This PR keeps the change minimal (stroker join
logic in
WGSL plus the CPU mirror) and includes a standalone repro program in the
PR
description for reviewers to copy/paste and run locally.

## Reproduction (standalone, not committed)

This PR intentionally does **not** add a new `examples/` crate. Instead,
below is a standalone `repro_1314` program. Reviewers can copy it into a
scratch Cargo project and run it against a local checkout of this
branch.

### How to run

From your Vello workspace root (the directory that contains `vello/`):

1. Create a scratch project directory (anywhere; example below uses
`/tmp`).
2. Paste the following `Cargo.toml` and `src/main.rs`.
3. Run:

```sh
cargo run --manifest-path /tmp/repro_1314/Cargo.toml -- --iters 250
```

On affected revisions this tends to fail very quickly (often within the
first couple frames). With this patch it should remain stable and print
`ok: 250 frames matched`.

### `/tmp/repro_1314/Cargo.toml`

```toml
[package]
name = "repro_1314"
version = "0.0.0"
edition = "2024"
publish = false

[dependencies]
# Set this path to your local checkout of Vello's `vello/` crate.
# If you create the scratch project in /tmp as shown above and you run from the Vello repo root,
# an absolute path is easiest (edit to match your machine).
vello = { path = "/ABS/PATH/TO/your/vello/repo/vello" }

anyhow = "1.0"
futures-intrusive = "0.5"
pollster = "0.4"
```

### `/tmp/repro_1314/src/main.rs`

```rust
use std::num::NonZeroUsize;

use anyhow::{Context, Result, anyhow};
use vello::kurbo::{Affine, Join, Rect, Stroke};
use vello::peniko::color::palette;
use vello::util::{RenderContext, block_on_wgpu};
use vello::wgpu::{
    self, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, TexelCopyBufferInfo,
    TextureDescriptor, TextureFormat, TextureUsages,
};
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};

const DEFAULT_WIDTH: u32 = 512;
const DEFAULT_HEIGHT: u32 = 512;
const DEFAULT_ITERS: usize = 250;
const DEFAULT_SHAPES: usize = 96;

fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
    args.iter()
        .position(|arg| arg == flag)
        .and_then(|ix| args.get(ix + 1))
        .cloned()
}

fn parse_usize(args: &[String], flag: &str, default: usize) -> Result<usize> {
    match parse_flag_value(args, flag) {
        Some(v) => v
            .parse::<usize>()
            .with_context(|| format!("failed to parse `{flag}` value `{v}`")),
        None => Ok(default),
    }
}

fn parse_u32(args: &[String], flag: &str, default: u32) -> Result<u32> {
    match parse_flag_value(args, flag) {
        Some(v) => v
            .parse::<u32>()
            .with_context(|| format!("failed to parse `{flag}` value `{v}`")),
        None => Ok(default),
    }
}

fn fnv1a_64(bytes: &[u8]) -> u64 {
    let mut hash = 0xcbf29ce484222325_u64;
    for &b in bytes {
        hash ^= u64::from(b);
        hash = hash.wrapping_mul(0x100000001b3_u64);
    }
    hash
}

fn build_scene(scene: &mut Scene, shapes: usize) {
    // Exact geometry from the issue report:
    let base_rect =
        Rect::from_origin_size((2.0, 0.0), (8.0, 327.765_467_171_717_15)).to_rounded_rect(3.91);
    let base_stroke = Stroke {
        width: 1.0,
        join: Join::Miter,
        ..Default::default()
    };

    // Add many similar copies to make failures appear quickly while always including the exact
    // geometry above.
    let variants = [(8.0, 3.91), (8.0, 3.999), (8.0, 4.0), (7.99, 3.995)];

    let cols = 12_usize;
    let spacing_x = 16.0_f64;
    let spacing_y = 12.0_f64;
    for ix in 0..shapes {
        let col = (ix % cols) as f64;
        let row = (ix / cols) as f64;
        let (w, r) = variants[ix % variants.len()];

        let rect =
            Rect::from_origin_size((2.0, 0.0), (w, 327.765_467_171_717_15)).to_rounded_rect(r);
        let transform = Affine::translate((16.0 + col * spacing_x, 16.0 + row * spacing_y));
        scene.stroke(&base_stroke, transform, palette::css::WHITE, None, &rect);
    }

    scene.stroke(
        &base_stroke,
        Affine::IDENTITY,
        palette::css::WHITE,
        None,
        &base_rect,
    );
}

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    let iters = parse_usize(&args, "--iters", DEFAULT_ITERS)?;
    let shapes = parse_usize(&args, "--shapes", DEFAULT_SHAPES)?;
    let width = parse_u32(&args, "--width", DEFAULT_WIDTH)?;
    let height = parse_u32(&args, "--height", DEFAULT_HEIGHT)?;

    let mut context = RenderContext::new();
    let device_id = pollster::block_on(context.device(None))
        .ok_or_else(|| anyhow!("no compatible device found"))?;
    let device_handle = &mut context.devices[device_id];
    let device = &device_handle.device;
    let queue = &device_handle.queue;

    let mut renderer = Renderer::new(
        device,
        RendererOptions {
            use_cpu: false,
            antialiasing_support: AaSupport::area_only(),
            num_init_threads: NonZeroUsize::new(1),
            ..Default::default()
        },
    )
    .map_err(|err| anyhow!("Renderer::new failed: {err}"))?;

    let size = Extent3d {
        width,
        height,
        depth_or_array_layers: 1,
    };
    let target = device.create_texture(&TextureDescriptor {
        label: Some("repro_1314.target"),
        size,
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: TextureFormat::Rgba8Unorm,
        usage: TextureUsages::STORAGE_BINDING | TextureUsages::COPY_SRC,
        view_formats: &[],
    });
    let view = target.create_view(&wgpu::TextureViewDescriptor::default());

    let padded_byte_width = (width * 4).next_multiple_of(256);
    let buffer_size = padded_byte_width as u64 * height as u64;
    let buffer = device.create_buffer(&BufferDescriptor {
        label: Some("repro_1314.readback"),
        size: buffer_size,
        usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });

    let params = RenderParams {
        base_color: palette::css::BLACK,
        width,
        height,
        antialiasing_method: AaConfig::Area,
    };

    let mut baseline: Option<Vec<u8>> = None;
    let mut scene = Scene::new();

    for ix in 0..iters {
        scene.reset();
        build_scene(&mut scene, shapes);

        renderer
            .render_to_texture(device, queue, &scene, &view, &params)
            .map_err(|err| anyhow!("render_to_texture failed: {err}"))?;

        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
            label: Some("repro_1314.copy_out"),
        });
        encoder.copy_texture_to_buffer(
            target.as_image_copy(),
            TexelCopyBufferInfo {
                buffer: &buffer,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(padded_byte_width),
                    rows_per_image: None,
                },
            },
            size,
        );
        queue.submit([encoder.finish()]);

        let buf_slice = buffer.slice(..);
        let (sender, receiver) = futures_intrusive::channel::shared::oneshot_channel();
        buf_slice.map_async(wgpu::MapMode::Read, move |v| {
            sender.send(v).expect("receiver dropped");
        });
        if let Some(recv_result) = block_on_wgpu(device, receiver.receive()) {
            recv_result?;
        } else {
            return Err(anyhow!("mapping channel was closed"));
        }

        let mapped = buf_slice.get_mapped_range();
        let mut frame = Vec::<u8>::with_capacity((width * height * 4) as usize);
        for row in 0..height {
            let start = (row * padded_byte_width) as usize;
            let end = start + (width * 4) as usize;
            frame.extend_from_slice(&mapped[start..end]);
        }
        drop(mapped);
        buffer.unmap();

        if let Some(expected) = &baseline {
            if &frame != expected {
                let expected_hash = fnv1a_64(expected);
                let got_hash = fnv1a_64(&frame);
                eprintln!(
                    "non-deterministic output at iter {ix}: expected={expected_hash:#x} \
                     got={got_hash:#x}"
                );
                return Err(anyhow!("detected non-deterministic rendering (vello#1314)"));
            }
        } else {
            baseline = Some(frame);
        }
    }

    println!("ok: {iters} frames matched");
    Ok(())
}
```

### Options

- `--iters N` number of frames to compare (default: 250)
- `--width N` render target width (default: 512)
- `--height N` render target height (default: 512)
- `--shapes N` number of copies of the triggering geometry (default: 96)

## Root cause

In `vello_shaders/shader/flatten.wgsl`, the GPU stroker's miter-join
path uses a miter-limit predicate to decide whether to emit a miter or
fall back to bevel.

For tangents `a = tan_prev` and `b = tan_next`:
- `d = dot(a, b)`
- `cr = cross(a, b)`
- `hypot = length(vec2(cr, d)) = |a| * |b|` (since `cr^2 + d^2 = |a|^2
|b|^2`)

Given the two tangents arranged tail-to-tail, the miter length ratio is
`1 / |cos(θ/2)|`, where θ is the angle between tangents. After
rearranging, the
miter-limit predicate can be expressed in terms of `hypot + d`.

The bug is that we only guarded on `cr != 0`. When `cr` is extremely
small but
non-zero (near-collinear tangents), the miter-point intersection math
divides by
`cr`, which can produce extremely large geometry. That pathological
geometry can
destabilize later pipeline stages and show up as frame-to-frame
corruption even
when the scene is unchanged.

## Fix

Keep the miter-limit predicate as-is, but treat sufficiently small `cr`
as
effectively zero (using `TANGENT_THRESH^2`) and skip the miter
computation in
that case (fall back to bevel), avoiding pathological miter points.

On my machine this stabilizes the repro for 1000 iterations (`ok: 1000
frames
matched`).

## Files changed

- `vello_shaders/shader/flatten.wgsl`: robustify miter join
near-collinearity
- `vello_shaders/src/cpu/flatten.rs`: keep CPU shader mirror in sync

## Related

- [vello#1314](https://github.com/linebender/vello/issues/1314)
- [xilem#1512](https://github.com/linebender/xilem/pull/1512)

---------

Co-authored-by: Tom Churchman <thomas@churchman.nl>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcfb05a..10f0c80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@
 ### Fixed
 
 - Bitmap emoji displayed at an incorrect position when scaled. ([#1273][] by [@ArthurCose][])
+- Miter joins for path segments with near-parallel endpoint tangents no longer cause rendering artifacts. ([#1323][] by [@Cupnfish][] and [@tomcur][])
 
 ## [0.6.0][] - 2025-10-03
 
@@ -270,6 +271,7 @@
 [@ArthurCose]: https://github.com/ArthurCose
 [@armansito]: https://github.com/armansito
 [@cfagot]: https://github.com/cfagot
+[@Cupnfish]: https://github.com/Cupnfish
 [@DasLixou]: https://github.com/DasLixou
 [@dfrg]: https://github.com/drfg
 [@DJMcNab]: https://github.com/DJMcNab
@@ -380,6 +382,7 @@
 [#1229]: https://github.com/linebender/vello/pull/1229
 [#1273]: https://github.com/linebender/vello/pull/1273
 [#1280]: https://github.com/linebender/vello/pull/1280
+[#1323]: https://github.com/linebender/vello/pull/1323
 
 <!-- Note that this still comparing against 0.5.0, because 0.5.1 is a cherry-picked patch -->
 [Unreleased]: https://github.com/linebender/vello/compare/v0.5.0...HEAD
diff --git a/vello_shaders/shader/flatten.wgsl b/vello_shaders/shader/flatten.wgsl
index cebc7f9..aa0389d 100644
--- a/vello_shaders/shader/flatten.wgsl
+++ b/vello_shaders/shader/flatten.wgsl
@@ -569,7 +569,20 @@
             let miter_limit = unpack2x16float(style_flags & STYLE_MITER_LIMIT_MASK)[0];
 
             var line_ix: u32;
-            if 2. * hypot < (hypot + d) * miter_limit * miter_limit && cr != 0. {
+            // Given the two tangents `tan_prev` and `tan_next` arranged tail-to-tail, the
+            // miter length ratio is `1 / |cos(theta/2)|`, where `theta` is the angle
+            // between the tangents.
+            //
+            // `hypot` is `|tan_prev| * |tan_next|` (since cr^2 + d^2 = |a|^2 |b|^2) and
+            // `hypot + d` is `2 * |tan_prev| * |tan_next| * cos^2(theta/2)`. After
+            // rearranging, the following tests whether `1/|cos(theta/2)| < miter_limit`.
+            //
+            // Also avoid the miter computation when `cr` is very small; the intersection
+            // math divides by `cr` and becomes numerically unstable for near-collinear
+            // tangents.
+            if 2. * hypot < (hypot + d) * miter_limit * miter_limit
+                && abs(cr) > TANGENT_THRESH * TANGENT_THRESH
+            {
                 let is_backside = cr > 0.;
                 let fp_last = select(front0, back1, is_backside);
                 let fp_this = select(front1, back0, is_backside);
diff --git a/vello_shaders/src/cpu/flatten.rs b/vello_shaders/src/cpu/flatten.rs
index 4aa2ff4..26e4249 100644
--- a/vello_shaders/src/cpu/flatten.rs
+++ b/vello_shaders/src/cpu/flatten.rs
@@ -457,7 +457,9 @@
             let hypot = cr.hypot(d);
             let miter_limit = f16_to_f32((style_flags & Style::MITER_LIMIT_MASK) as u16);
 
-            if 2. * hypot < (hypot + d) * miter_limit * miter_limit && cr != 0. {
+            if 2. * hypot < (hypot + d) * miter_limit * miter_limit
+                && cr.abs() > TANGENT_THRESH.powi(2)
+            {
                 let is_backside = cr > 0.;
                 let fp_last = if is_backside { back1 } else { front0 };
                 let fp_this = if is_backside { back0 } else { front1 };