Skip to content

The sampler: shuffled, Owen-scrambled Sobol (Burley 2020, as Cycles) - #81

Merged
yujiaxu-ops merged 2 commits into
mainfrom
kernel-sampler
Nov 14, 2026
Merged

yujiaxu-ops merged 2 commits into
mainfrom
kernel-sampler

Conversation

@yujiaxu-ops

@yujiaxu-ops yujiaxu-ops commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

The kernel used to draw white noise from a single PCG stream, so a draw's position in that stream depended on which branches the path took. It now uses shuffled, Owen-scrambled Sobol (Burley 2020, the same design as Cycles' Sobol-Burley), in shaders/sample.wgsl.

  • Dimensions. The sequence has four Sobol dimensions; higher dimensions come from padding by dimension id. Each decision at a path vertex has its own id:

    • rng_dim builds the id from the path, the vertex and a named slot (D_*);
    • rng_sub builds the id for one iteration of a loop or for one light.

    The id seeds the scramble.

  • Sample index. Offline, the index is the sample number; live, it is the frame. It is 16 bits wide.

  • Offline: every draw comes from Sobol, except the steps of delta tracking. Those form a loop with no fixed length, so each step draws from a hash instead, as pbrt-v4 does.

  • Live: only the camera path's first vertex uses Sobol; everything else uses a hash, as NVIDIA's RTXPT does. Using Sobol for every draw cost 2.1 ms of Bistro's 8.4 ms live trace.

  • Sobol dimensions 1–3 are unrolled, with the direction numbers written inline.

  • The loop guard now skips counted loops, i.e. for (var i = N; i < M; i++) where N and M are literals. Tint does the same. Without this, the sampler's own loops would use up the guard's iteration budget.

  • sample.rs is the same sampler on the CPU. Its tests check:

    • the (0, m, 2)-net property;
    • the direction numbers against Cycles' table;
    • the WGSL against the Rust version on the GPU.

Checks

  • Offline convergence. Raw HDR at 320×180 with no denoiser. Each build's error is measured against the other build's render at 4096 spp. The table gives RMSE of this branch divided by RMSE of main:

    spp Cornell box Light Room Bistro
    1 0.99 0.97 1.00
    4 0.49 0.67 0.63
    16 0.33 0.37 0.44
    64 0.29 0.38 0.35
    256 0.32 0.46 0.32

    At 4096 spp the means of the two builds agree within 0.01 %.

  • Live cost. Bistro's trace takes the same time as on main: 8.60 ms against 8.61 ms natively, 47.5 ms against 47.6 ms merged.

  • Live quality. bench-window shows the same results on every shot:

    • still Bistro: dE 1.82 on both;
    • Kabukichō: dE 4.64 against 4.63;
    • slice and the three Tokyo shots: within 0.03 dE and 0.002 SSIM.
  • Atomic tests. test-render passes 190/190 natively. Merged, the same 24 known software-traversal failures as main, with identical names.

  • Unit tests. cargo test --release with the guard: 142 passed.

  • Browser. web-hello passes in Chrome.

Fixed after the first review (6e670ad):

  • The render seed (THREERT_SEED) had stopped affecting renders. It is mixed into each pixel's seed again.
  • Delta tracking now draws from a hash. With Sobol, a dense grid's region mean varied 0.9 % between seeds at 4096 spp; with the hash it varies 0.3 %, the same as white noise.
  • The parity test declares LIVE (Bugbot).
  • volume/lamp-inside now expects 6.72, its converged value. Both samplers give 6.70–6.74 at 32768 spp. The old 6.607 came from the original repository and was never converged: the build from the day of the extraction also reads 6.697.

With these fixes, test-render passes 190/190 natively, and merged shows the same 24 failures as main.

The blue-noise pixel distribution (Cycles 4.2) comes in its own PR.

🤖 Generated with Claude Code


Note

Medium Risk
Wide-reaching changes to how every path-tracing random draw is produced, though live cost is unchanged and tests/parity checks cover the new sampler.

Overview
Replaces the path kernel’s PCG white-noise stream (draw order tied to branch paths) with shuffled, Owen-scrambled Sobol (Burley 2020 / Cycles-style) in new sample.wgsl, compiled into the kernel via KERNEL_FILES.

Each stochastic decision now uses a stable dimension id (rng_dim / rng_sub and D_* slots) so samples stratify by index offline (per spp) and live (per frame). Offline draws are Sobol everywhere; live uses Sobol only on the camera path’s first vertex and hashed RNG elsewhere (RTXPT-style) to avoid trace cost. pathtrace.wgsl and translucent.wgsl drop rnd()/rng_state and thread rd through lighting, BSDF, media, cache, and fog paths.

Adds sample.rs with matching CPU RNG plus (0,m,2)-net, table, GPU parity, and decorrelation tests. The loop guard skips provably finite literal for loops so Sobol’s own loops don’t burn the guard budget. Docs (RUNTIME.md, ROADMAP.md) document the scheme; volume/lamp-inside tightens its offline radiance expectation after better convergence.

Reviewed by Cursor Bugbot for commit 6e670ad. Bugbot is set up for automated code reviews on this repo. Configure here.

… a dimension id a decision

The kernel drew white noise from one PCG stream; a draw's place in it depended on the path's branches. Now
shaders/sample.wgsl: Owen-scrambled Sobol with Burley's hash (Vegdahl's constants), four dimensions padded by
dimension ids. Each decision at a vertex has an id (rng_dim: path, vertex, slot; rng_sub: a loop's iteration or a
light), which seeds the scramble; the sample index is the sample offline and the frame live (16 bits).

- Offline every draw is Sobol: RMSE x0.29-0.46 at 16-256 spp (Cornell box, Light Room, Bistro), means within 0.01 %.
- Live only the camera path's first vertex is Sobol, a hash elsewhere (as NVIDIA's RTXPT): all-Sobol cost 2.1 ms of
  Bistro's 8.4 ms live trace; this costs nothing measurable, and the live shots (bench-window) are unchanged.
- Sobol dimensions 1-3 unrolled with the direction numbers inline (no loop, no memory).
- The loop guard skips counted loops (for (var i = N; i < M; i++), literals), as Tint does.
- sample.rs: the same sampler on the CPU; tests of the (0, m, 2)-net property and of the shader against it on the GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit a37eb18. Configure here.

Comment thread renderer/src/sample.rs Outdated
};
let d = &g.device;
let seeds = [0u32, 0x1234_5678, hash_hp(99)];
let src = format!("{}\nvar<private> cur_depth: u32;\n@group(0) @binding(0) var<storage, read_write> out: array<u32>;\nconst SEEDS = array<u32, 3>({}u, {}u, {}u);\n@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {{\n let i = id.x;\n RNG_SEED = SEEDS[i % 3u];\n RNG_INDEX = i;\n let r = rng4(7u + i % 5u);\n for (var k = 0u; k < 4u; k++) {{ out[i * 4u + k] = bitcast<u32>(r[k]); }}\n}}\n", include_str!("../shaders/sample.wgsl"), seeds[0], seeds[1], seeds[2]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPU sampler test omits LIVE

Low Severity

The standalone shader in the_shader_is_this includes sample.wgsl, which reads LIVE inside rng_white, but never declares that override. pathtrace.wgsl provides it in the real kernel; this test module does not. On a GPU the module fails to compile, so the CPU/GPU sampler check never runs; without a GPU the test skips and the mismatch stays hidden.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a37eb18. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right: since rng_white the test's shader needed LIVE, and the test only runs where there is a GPU. Fixed in 6e670ad (override LIVE = false); it passes on this Mac's GPU again.

…y test's LIVE; lamp-inside's converged value

- rng_start mixes the render's seed (P.size.w: THREERT_SEED offline) into each pixel's again: renders had stopped
  changing with THREERT_SEED.
- Delta tracking's steps (media_track, media_tr: a loop of no fixed length) draw from a hash, offline too, as pbrt-v4
  does: a new Sobol dimension a step is QMC's worst case; a dense grid's region mean varied 0.9 % between seeds at
  4096 spp (0.3 % now, as white noise).
- The parity test's shader declares LIVE (Bugbot): it had stopped compiling when rng_white came.
- volume/lamp-inside expects 6.72, its converged value (6.70-6.74 at 32768 spp, both samplers); the 6.607 it had came
  unconverged from the original repository (the extraction-day build reads 6.697).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiaxu-ops
yujiaxu-ops merged commit cd6ca30 into main Nov 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant