)]}'
{
  "commit": "cad558fcd7067ea4097cf9299dfedc8ecc53dc41",
  "tree": "d787badeac768dd76fa6047ab5fbda554561a7ab",
  "parents": [
    "4ea47b47c3e04a68b20422c9a12b2b98c735f547"
  ],
  "author": {
    "name": "邻二氮杂菲",
    "email": "40173605+Cupnfish@users.noreply.github.com",
    "time": "Fri Dec 19 17:06:20 2025 +0800"
  },
  "committer": {
    "name": "GitHub",
    "email": "noreply@github.com",
    "time": "Fri Dec 19 09:06:20 2025 +0000"
  },
  "message": "Fix non-deterministic GPU stroke artifacts (vello#1314) (#1323)\n\n## Summary\n\nFix non-deterministic rendering artifacts when GPU-stroking certain\nrounded rectangles\n([vello#1314](https://github.com/linebender/vello/issues/1314)).\n\nThe issue could manifest as severe frame-to-frame corruption even when\nrendering\nthe exact same scene. This PR keeps the change minimal (stroker join\nlogic in\nWGSL plus the CPU mirror) and includes a standalone repro program in the\nPR\ndescription for reviewers to copy/paste and run locally.\n\n## Reproduction (standalone, not committed)\n\nThis PR intentionally does **not** add a new `examples/` crate. Instead,\nbelow is a standalone `repro_1314` program. Reviewers can copy it into a\nscratch Cargo project and run it against a local checkout of this\nbranch.\n\n### How to run\n\nFrom your Vello workspace root (the directory that contains `vello/`):\n\n1. Create a scratch project directory (anywhere; example below uses\n`/tmp`).\n2. Paste the following `Cargo.toml` and `src/main.rs`.\n3. Run:\n\n```sh\ncargo run --manifest-path /tmp/repro_1314/Cargo.toml -- --iters 250\n```\n\nOn affected revisions this tends to fail very quickly (often within the\nfirst couple frames). With this patch it should remain stable and print\n`ok: 250 frames matched`.\n\n### `/tmp/repro_1314/Cargo.toml`\n\n```toml\n[package]\nname \u003d \"repro_1314\"\nversion \u003d \"0.0.0\"\nedition \u003d \"2024\"\npublish \u003d false\n\n[dependencies]\n# Set this path to your local checkout of Vello\u0027s `vello/` crate.\n# If you create the scratch project in /tmp as shown above and you run from the Vello repo root,\n# an absolute path is easiest (edit to match your machine).\nvello \u003d { path \u003d \"/ABS/PATH/TO/your/vello/repo/vello\" }\n\nanyhow \u003d \"1.0\"\nfutures-intrusive \u003d \"0.5\"\npollster \u003d \"0.4\"\n```\n\n### `/tmp/repro_1314/src/main.rs`\n\n```rust\nuse std::num::NonZeroUsize;\n\nuse anyhow::{Context, Result, anyhow};\nuse vello::kurbo::{Affine, Join, Rect, Stroke};\nuse vello::peniko::color::palette;\nuse vello::util::{RenderContext, block_on_wgpu};\nuse vello::wgpu::{\n    self, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, TexelCopyBufferInfo,\n    TextureDescriptor, TextureFormat, TextureUsages,\n};\nuse vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};\n\nconst DEFAULT_WIDTH: u32 \u003d 512;\nconst DEFAULT_HEIGHT: u32 \u003d 512;\nconst DEFAULT_ITERS: usize \u003d 250;\nconst DEFAULT_SHAPES: usize \u003d 96;\n\nfn parse_flag_value(args: \u0026[String], flag: \u0026str) -\u003e Option\u003cString\u003e {\n    args.iter()\n        .position(|arg| arg \u003d\u003d flag)\n        .and_then(|ix| args.get(ix + 1))\n        .cloned()\n}\n\nfn parse_usize(args: \u0026[String], flag: \u0026str, default: usize) -\u003e Result\u003cusize\u003e {\n    match parse_flag_value(args, flag) {\n        Some(v) \u003d\u003e v\n            .parse::\u003cusize\u003e()\n            .with_context(|| format!(\"failed to parse `{flag}` value `{v}`\")),\n        None \u003d\u003e Ok(default),\n    }\n}\n\nfn parse_u32(args: \u0026[String], flag: \u0026str, default: u32) -\u003e Result\u003cu32\u003e {\n    match parse_flag_value(args, flag) {\n        Some(v) \u003d\u003e v\n            .parse::\u003cu32\u003e()\n            .with_context(|| format!(\"failed to parse `{flag}` value `{v}`\")),\n        None \u003d\u003e Ok(default),\n    }\n}\n\nfn fnv1a_64(bytes: \u0026[u8]) -\u003e u64 {\n    let mut hash \u003d 0xcbf29ce484222325_u64;\n    for \u0026b in bytes {\n        hash ^\u003d u64::from(b);\n        hash \u003d hash.wrapping_mul(0x100000001b3_u64);\n    }\n    hash\n}\n\nfn build_scene(scene: \u0026mut Scene, shapes: usize) {\n    // Exact geometry from the issue report:\n    let base_rect \u003d\n        Rect::from_origin_size((2.0, 0.0), (8.0, 327.765_467_171_717_15)).to_rounded_rect(3.91);\n    let base_stroke \u003d Stroke {\n        width: 1.0,\n        join: Join::Miter,\n        ..Default::default()\n    };\n\n    // Add many similar copies to make failures appear quickly while always including the exact\n    // geometry above.\n    let variants \u003d [(8.0, 3.91), (8.0, 3.999), (8.0, 4.0), (7.99, 3.995)];\n\n    let cols \u003d 12_usize;\n    let spacing_x \u003d 16.0_f64;\n    let spacing_y \u003d 12.0_f64;\n    for ix in 0..shapes {\n        let col \u003d (ix % cols) as f64;\n        let row \u003d (ix / cols) as f64;\n        let (w, r) \u003d variants[ix % variants.len()];\n\n        let rect \u003d\n            Rect::from_origin_size((2.0, 0.0), (w, 327.765_467_171_717_15)).to_rounded_rect(r);\n        let transform \u003d Affine::translate((16.0 + col * spacing_x, 16.0 + row * spacing_y));\n        scene.stroke(\u0026base_stroke, transform, palette::css::WHITE, None, \u0026rect);\n    }\n\n    scene.stroke(\n        \u0026base_stroke,\n        Affine::IDENTITY,\n        palette::css::WHITE,\n        None,\n        \u0026base_rect,\n    );\n}\n\nfn main() -\u003e Result\u003c()\u003e {\n    let args: Vec\u003cString\u003e \u003d std::env::args().collect();\n    let iters \u003d parse_usize(\u0026args, \"--iters\", DEFAULT_ITERS)?;\n    let shapes \u003d parse_usize(\u0026args, \"--shapes\", DEFAULT_SHAPES)?;\n    let width \u003d parse_u32(\u0026args, \"--width\", DEFAULT_WIDTH)?;\n    let height \u003d parse_u32(\u0026args, \"--height\", DEFAULT_HEIGHT)?;\n\n    let mut context \u003d RenderContext::new();\n    let device_id \u003d pollster::block_on(context.device(None))\n        .ok_or_else(|| anyhow!(\"no compatible device found\"))?;\n    let device_handle \u003d \u0026mut context.devices[device_id];\n    let device \u003d \u0026device_handle.device;\n    let queue \u003d \u0026device_handle.queue;\n\n    let mut renderer \u003d Renderer::new(\n        device,\n        RendererOptions {\n            use_cpu: false,\n            antialiasing_support: AaSupport::area_only(),\n            num_init_threads: NonZeroUsize::new(1),\n            ..Default::default()\n        },\n    )\n    .map_err(|err| anyhow!(\"Renderer::new failed: {err}\"))?;\n\n    let size \u003d Extent3d {\n        width,\n        height,\n        depth_or_array_layers: 1,\n    };\n    let target \u003d device.create_texture(\u0026TextureDescriptor {\n        label: Some(\"repro_1314.target\"),\n        size,\n        mip_level_count: 1,\n        sample_count: 1,\n        dimension: wgpu::TextureDimension::D2,\n        format: TextureFormat::Rgba8Unorm,\n        usage: TextureUsages::STORAGE_BINDING | TextureUsages::COPY_SRC,\n        view_formats: \u0026[],\n    });\n    let view \u003d target.create_view(\u0026wgpu::TextureViewDescriptor::default());\n\n    let padded_byte_width \u003d (width * 4).next_multiple_of(256);\n    let buffer_size \u003d padded_byte_width as u64 * height as u64;\n    let buffer \u003d device.create_buffer(\u0026BufferDescriptor {\n        label: Some(\"repro_1314.readback\"),\n        size: buffer_size,\n        usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,\n        mapped_at_creation: false,\n    });\n\n    let params \u003d RenderParams {\n        base_color: palette::css::BLACK,\n        width,\n        height,\n        antialiasing_method: AaConfig::Area,\n    };\n\n    let mut baseline: Option\u003cVec\u003cu8\u003e\u003e \u003d None;\n    let mut scene \u003d Scene::new();\n\n    for ix in 0..iters {\n        scene.reset();\n        build_scene(\u0026mut scene, shapes);\n\n        renderer\n            .render_to_texture(device, queue, \u0026scene, \u0026view, \u0026params)\n            .map_err(|err| anyhow!(\"render_to_texture failed: {err}\"))?;\n\n        let mut encoder \u003d device.create_command_encoder(\u0026CommandEncoderDescriptor {\n            label: Some(\"repro_1314.copy_out\"),\n        });\n        encoder.copy_texture_to_buffer(\n            target.as_image_copy(),\n            TexelCopyBufferInfo {\n                buffer: \u0026buffer,\n                layout: wgpu::TexelCopyBufferLayout {\n                    offset: 0,\n                    bytes_per_row: Some(padded_byte_width),\n                    rows_per_image: None,\n                },\n            },\n            size,\n        );\n        queue.submit([encoder.finish()]);\n\n        let buf_slice \u003d buffer.slice(..);\n        let (sender, receiver) \u003d futures_intrusive::channel::shared::oneshot_channel();\n        buf_slice.map_async(wgpu::MapMode::Read, move |v| {\n            sender.send(v).expect(\"receiver dropped\");\n        });\n        if let Some(recv_result) \u003d block_on_wgpu(device, receiver.receive()) {\n            recv_result?;\n        } else {\n            return Err(anyhow!(\"mapping channel was closed\"));\n        }\n\n        let mapped \u003d buf_slice.get_mapped_range();\n        let mut frame \u003d Vec::\u003cu8\u003e::with_capacity((width * height * 4) as usize);\n        for row in 0..height {\n            let start \u003d (row * padded_byte_width) as usize;\n            let end \u003d start + (width * 4) as usize;\n            frame.extend_from_slice(\u0026mapped[start..end]);\n        }\n        drop(mapped);\n        buffer.unmap();\n\n        if let Some(expected) \u003d \u0026baseline {\n            if \u0026frame !\u003d expected {\n                let expected_hash \u003d fnv1a_64(expected);\n                let got_hash \u003d fnv1a_64(\u0026frame);\n                eprintln!(\n                    \"non-deterministic output at iter {ix}: expected\u003d{expected_hash:#x} \\\n                     got\u003d{got_hash:#x}\"\n                );\n                return Err(anyhow!(\"detected non-deterministic rendering (vello#1314)\"));\n            }\n        } else {\n            baseline \u003d Some(frame);\n        }\n    }\n\n    println!(\"ok: {iters} frames matched\");\n    Ok(())\n}\n```\n\n### Options\n\n- `--iters N` number of frames to compare (default: 250)\n- `--width N` render target width (default: 512)\n- `--height N` render target height (default: 512)\n- `--shapes N` number of copies of the triggering geometry (default: 96)\n\n## Root cause\n\nIn `vello_shaders/shader/flatten.wgsl`, the GPU stroker\u0027s miter-join\npath uses a miter-limit predicate to decide whether to emit a miter or\nfall back to bevel.\n\nFor tangents `a \u003d tan_prev` and `b \u003d tan_next`:\n- `d \u003d dot(a, b)`\n- `cr \u003d cross(a, b)`\n- `hypot \u003d length(vec2(cr, d)) \u003d |a| * |b|` (since `cr^2 + d^2 \u003d |a|^2\n|b|^2`)\n\nGiven the two tangents arranged tail-to-tail, the miter length ratio is\n`1 / |cos(θ/2)|`, where θ is the angle between tangents. After\nrearranging, the\nmiter-limit predicate can be expressed in terms of `hypot + d`.\n\nThe bug is that we only guarded on `cr !\u003d 0`. When `cr` is extremely\nsmall but\nnon-zero (near-collinear tangents), the miter-point intersection math\ndivides by\n`cr`, which can produce extremely large geometry. That pathological\ngeometry can\ndestabilize later pipeline stages and show up as frame-to-frame\ncorruption even\nwhen the scene is unchanged.\n\n## Fix\n\nKeep the miter-limit predicate as-is, but treat sufficiently small `cr`\nas\neffectively zero (using `TANGENT_THRESH^2`) and skip the miter\ncomputation in\nthat case (fall back to bevel), avoiding pathological miter points.\n\nOn my machine this stabilizes the repro for 1000 iterations (`ok: 1000\nframes\nmatched`).\n\n## Files changed\n\n- `vello_shaders/shader/flatten.wgsl`: robustify miter join\nnear-collinearity\n- `vello_shaders/src/cpu/flatten.rs`: keep CPU shader mirror in sync\n\n## Related\n\n- [vello#1314](https://github.com/linebender/vello/issues/1314)\n- [xilem#1512](https://github.com/linebender/xilem/pull/1512)\n\n---------\n\nCo-authored-by: Tom Churchman \u003cthomas@churchman.nl\u003e",
  "tree_diff": [
    {
      "type": "modify",
      "old_id": "bcfb05a55b1c005f0c3a3ab500c0c0809d8f57ce",
      "old_mode": 33188,
      "old_path": "CHANGELOG.md",
      "new_id": "10f0c8017ae3da298861b5c28c0e860a11587fe3",
      "new_mode": 33188,
      "new_path": "CHANGELOG.md"
    },
    {
      "type": "modify",
      "old_id": "cebc7f9f2d8e49928f071733f81e68c170aaeb13",
      "old_mode": 33188,
      "old_path": "vello_shaders/shader/flatten.wgsl",
      "new_id": "aa0389d05459a8c8bc5c24daacd2e936ffdf8268",
      "new_mode": 33188,
      "new_path": "vello_shaders/shader/flatten.wgsl"
    },
    {
      "type": "modify",
      "old_id": "4aa2ff42033d12408cef1e80a47bb6f1be3915d5",
      "old_mode": 33188,
      "old_path": "vello_shaders/src/cpu/flatten.rs",
      "new_id": "26e42493c080877438612d33df094defb94ed2e4",
      "new_mode": 33188,
      "new_path": "vello_shaders/src/cpu/flatten.rs"
    }
  ]
}
