Visitar URL original
fix: Resolve equal gsplat GPU sort keys in the same order every frame by simonbaronti · Pull Request #9549 · playcanvas/engine · GitHub
Skip to content

fix: Resolve equal gsplat GPU sort keys in the same order every frame - #9549

Open
simonbaronti wants to merge 1 commit into
playcanvas:mainfrom
simonbaronti:fix/gsplat-gpu-sort-deterministic-ties
Open

simonbaronti wants to merge 1 commit into
playcanvas:mainfrom
simonbaronti:fix/gsplat-gpu-sort-deterministic-ties

Conversation

@simonbaronti

Copy link
Copy Markdown

Description

Fixes flicker in the WebGPU GPU-sort gsplat renderer (GSPLAT_RENDERER_RASTER_GPU_SORT): splats with equal sort keys were blended in a different order every frame, visible as shimmer with a static camera and as a pop on every re-render with on-demand rendering.

Fixes #9548

  • The projector now ranks the splats that survive projection by thread index inside each 256-thread workgroup (survivor bitmask + countOneBits, the same idea as the radix reorder shader), instead of by the order of their workgroup atomicAdd, which now only counts them. Each workgroup's output range is therefore in a fixed order.
  • The order of the workgroup ranges in the global output still depends on scheduling, so the projector packs its workgroup index into the low bits of the sort key, below the depth key (new tieBits projector uniform). GSplatHybridRenderer sizes tieBits to the maximum workgroup count, rounded up to whole radix passes and capped at a 32-bit key, and sorts over numBits + tieBits.
  • Stochastic mode is unchanged (it does not sort).

Technical details

The radix sort is stable, so equal keys keep their input order. Before this change that order came from two atomics (wgCount per thread, renderCounter per workgroup) and changed every frame. Depth keys are at most 20 bits (clamp(round(log2(n / 4)), 10, 20)), so ties are common in dense scenes. The CPU worker sort receives its input in a fixed order, which is why WebGL2 was not affected.

With more than 2^tieBits projector workgroups (over 1M candidate splats at 20 depth bits) the workgroup index wraps, so two tied splats at least 1M candidates apart could in principle still swap. Frames were bit-identical in every test below.

Public API changes

None.

Performance

GPU timestamps (engine gpuProfiler), medians of 120 frames, static camera, Apple M4 Pro / Chrome 153 / WebGPU, 1280×800. Before = main @ 25b638c, after = this branch:

Scene Radix sort (ms) Projector (ms) Forward (ms) Whole frame (ms)
4M-splat capture 0.41 → 0.64 0.86 → 0.90 9.07 → 8.97 10.77 → 10.96
20M-splat capture 0.28 → 0.42 2.61 → 2.72 5.99 → 5.71 10.43 → 10.51

About +0.25 ms of GPU time per frame in total: the extra radix passes (which only run over the splats that survive projection) and the rank (computed by survivors only). Forward and whole-frame differences are within run-to-run noise (up to 0.5 ms between repeated runs of the same build). The forward pass is unaffected because the draw order within a depth bucket stays in candidate (roughly spatial) order. Letting the workgroup leader sum the mask instead of keeping the wgCount atomic was measurably slower (projector +0.17 ms at 20M instead of +0.11 ms).

Alternatives measured (on 2.22.4) and rejected:

  • Keeping every candidate in its own slot and giving culled ones a max key: exact, but the sort then runs over all frustum candidates instead of only the survivors: +2.2 ms at 4M, +13.8 ms at 20M.
  • Putting the splat index (instead of the workgroup index) in the spare key bits, without the in-workgroup rank: cheap, but it interleaves chunks within a depth bucket (+1 ms forward pass at 20M) and was not bit-exact.

Testing

  • Static-camera screenshot diffs (repro in the issue, plus private 4M and 20M captures): equal-depth repro 37–40% → 0.00% (pixels differing at all: 0); 4M-splat capture 4.5–4.8% → 0.00% (pixels differing at all: 0); 20M-splat capture 1.0% → 0.00% (pixels differing at all: 0)
  • WebGL2 / CPU sort unaffected (0% before and after).
  • npm run lint (changed files), npm test (3591 passing, rebased on main @ 211b142), npm run build:types + npm run test:types.

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

The GPU-sort gsplat renderer's projector compacted visible splats with a
per-thread atomicAdd inside each workgroup and a per-workgroup atomicAdd on
the global counter, so the input order of the stable radix sort, and with it
the blend order of splats with equal depth keys, changed every frame. With a
static camera this shows as flicker; with on-demand rendering, as a pop on
every re-render.

Survivors are now ranked by thread index inside each workgroup (survivor
bitmask + popcount), and the projector packs its workgroup index below the
depth key, so ties between workgroups also resolve the same way every frame.
The sort runs over the depth bits plus these tie bits, at most 32.

Fixed playcanvas#9548

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: graphics Graphics related issue bugfix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gsplat: WebGPU GPU-sort renderer flickers with a static camera (equal depth keys resolve in a different order every frame)

2 participants