Pajama / Gaussian Lab

RENDERER FIELD NOTES · 2026-09-30

Same code. Different pixels.

A practical guide to Gaussian rendering across Metal, DirectX 12 and browser WebGPU. Start with the symptom, follow it to the responsible stage, then verify with the same data.

Default: English · 中文 available · Three interactive examples · Code reviewed at 07a2910

What looks wrong?

Choose a symptom to get a short investigation order.

First priority in this code: check the pow domain, log the chosen color format, and compare a frozen offscreen frame.

Follow real cross-platform engines

See how Spark and PlayCanvas load, sort, project and blend Gaussians, including upstream workarounds recorded in their source.

Open the end-to-end source study →
01

Same API, different execution paths

wgpu gives the application a shared rendering contract. It does not promise bit-identical pixels across every GPU, compiler and presentation surface. “WebGPU” is an API layer; it can itself run over Metal or D3D12. Here, “Metal” means Apple’s graphics API.

Native · Apple

Rust / wgpu → Naga / MSL → Metal → GPU

Native · Windows

Rust / wgpu → Naga / HLSL → D3D12 → GPU

Browser

Rust / WASM → wgpu → browser WebGPU → native backend → GPU

The browser adds another implementation and version boundary: Chromium uses Dawn/Tint; Firefox uses wgpu. Never infer the real backend just from “Windows” or “Mac”. Record it when available, or write “unknown”.

Separate three questions: are the inputs/configuration identical; is the numerical difference within an expected tolerance; or is there a reproducible implementation defect? A flipped image, scrambled buffer or missing synchronization is not normal floating-point noise.

wgpu backends & Naga · Dawn / Tint

02

The whole image is brighter or darker

Check the output contract first: learned RGB → shader output → attachment conversion → canvas/compositor → display. An sRGB attachment encodes RGB on write; a plain Unorm attachment does not. Canvas color space and the attachment’s transfer function are separate settings.

Current code: the browser prefers an available sRGB surface format, then falls back to the first format; color space is Auto. The native A/B benchmark fixes Rgba8Unorm. This is a configuration risk to inspect, not proof that today’s browser picked sRGB.

Try it · encode the same value

Missing encoding
Encoded once
Encoded twice

A JavaScript illustration using CSS sRGB swatches, not captures of three backends. It assumes a linear input; a trained RGB feature may use a different convention.

How to isolate color errors

Render known gray values into one explicitly chosen offscreen format on every backend and compare raw pixels first. Only then compare presentation, using the same transfer function, tone mapping, exposure, canvas color space and background. A screenshot also includes OS/browser color management and possibly HDR.

Our STG pass keeps the model’s learned RGB convention; it does not establish that the features are linear radiance. Match the training/reference renderer before adding a gamma conversion. For an HDR host, define this conversion explicitly and blend in the chosen working space.

For direct browser WebGPU, configure a supported non-sRGB canvas format; to render through an sRGB view, declare the corresponding view format and create that view. Do not copy a native swapchain format into browser configuration without checking the API contract.

wgpu texture formats · WebGPU color & canvas

03

Dark halos, wrong overlap colors

The fragment shader returns premultiplied color (RGB × alpha, alpha). The pass uses PREMULTIPLIED_ALPHA_BLENDING: source factor One, destination factor OneMinusSrcAlpha. Multiplying by alpha a second time darkens the splat; omitting it makes the splat too bright.

Cout=Csrcpremul+(1−αsrc)Cdst,αout=αsrc+(1−αsrc)αdstC_{\mathrm{out}}=C_{\mathrm{src}}^{\mathrm{premul}}+(1-\alpha_{\mathrm{src}})C_{\mathrm{dst}},\quad \alpha_{\mathrm{out}}=\alpha_{\mathrm{src}}+(1-\alpha_{\mathrm{src}})\alpha_{\mathrm{dst}}

Our splats are drawn back to front. Reversing their order without changing the compositing algorithm changes the image. The canvas alpha mode is a separate contract with the page compositor; also match the clear color, clear alpha and background.

Try it · two overlapping Gaussians

Red in front
Blue in front

The numbers sample their shared center on black; the plots show the full falloff. Linear RGB is encoded once for display. This CPU illustration isolates ordering, not GPU precision.

wgpu BlendState

04

A concrete portability hazard: pow on a negative base

FOUND IN CODE · NOT REPRODUCED ON METAL

In splat.wgsl at revision 07a2910, temporal opacity uses pow(dt / temporal_scale, 2.0). Before a Gaussian’s time center, dt is negative. WGSL excludes a negative base from pow’s domain, even with exponent 2. A compiler may optimize it to a multiply, but the application cannot rely on that.

// Current vertex shader expression:
let temporal_opacity = exp(-pow(dt / temporal_scale, 2.0));

// Proposed domain-safe square, matching the culling kernel:
let ratio = dt / temporal_scale;
let temporal_opacity = exp(-(ratio * ratio));

In ordinary algebra, (−2)² = 4. A GPU pow implementation can instead be based on exp2(y × log2(x)); log2(−2) is outside the real-valued domain. The same source can therefore appear to work on one compiler and fail on another. This document records the proposed fix; it does not change the production renderer or re-label the old benchmark evidence.

Test the domain, not only a pretty frame

Use symmetric offsets dt = −s, 0, +s with a fixed nonzero quaternion. Opacity should be exp(−1), 1, exp(−1), before the base opacity multiplier. Read back opacity before rasterization. Repeat on the targeted Metal, D3D12 and browser implementations.

Also audit normalize(q + dt × qVelocity): the interpolated quaternion must not become zero. Existing input validation checks finite stored values, but cannot guarantee that every derived value stays finite and in-domain. Reject invalid data or define a documented fallback; do not treat NaN/Inf propagation as a portable control mechanism.

WGSL pow · WGSL normalize

05

A tiny numerical error can become a missing point

WGSL permits bounded differences in floating-point evaluation, including some reassociation, fusion and subnormal handling. exp, division and normalization need not match CPU arithmetic bit for bit. Gaussian rendering turns continuous values into discrete decisions at opacity, frustum, radius and fragment-discard boundaries.

α(t)=sigmoid⁡(o)exp⁡ ⁣[−(t−τst)2],keep=[α≥1255]\alpha(t)=\operatorname{sigmoid}(o)\exp\!\left[-\left(\frac{t-\tau}{s_t}\right)^2\right],\qquad \mathrm{keep}=\left[\alpha\geq\frac{1}{255}\right]

Try it · move one representable f32 value

Exact f32 cutoff
Illustrated opacity

A ULP is one adjacent floating-point step at this magnitude. The slider walks actual f32 bit patterns around the rounded cutoff. It does not model Metal’s exp or predict an allowed error for a whole shader.

Existing evidence: in one Windows/DX12 Flames CPU-versus-GPU case, GPU preparation retained ID 324748 at the cutoff. That frame’s maximum channel error was 1 in 8-bit units. This is a local CPU/GPU result, not a Metal-versus-DX12 comparison.

How to judge the difference

Capture the divergent IDs and their intermediate alpha/depth values. Check whether they sit near a declared decision boundary, then measure the image error. The old benchmark uses 16 × f32::EPSILON × cutoff as a local diagnostic band: despite its field name, that is not an exact count of 16 adjacent ULPs and is not a WGSL-wide guarantee.

For very large scenes, world positions can lose small offsets in f32 before projection. A backend that introduces f16 storage makes that budget smaller still. Keep the precision policy explicit; consider camera-relative coordinates or local chunk origins, and validate their transforms against the same reference.

Choose tolerances before evaluating results, with both aggregate and worst-pixel checks. Do not enlarge them until a failure disappears. Large missing regions, NaNs or repeated instability need investigation rather than a bigger epsilon.

WGSL evaluation · Raw Flames evidence

06

Shimmering when the camera moves

Our stable radix sorter orders full 32-bit depth keys and preserves source-ID order for equal keys. It is deterministic for the same key/ID input. It cannot make two backends generate the same depth bits. Nearly coincident Gaussians can exchange order after a tiny change in center or dot-product evaluation; their overlap colors can then change.

Diagnosis: freeze the time and camera, dump key/ID pairs, and compare the first divergence. Replay the exact same sorted IDs on both backends to isolate rasterization. Track the source IDs across streaming replacements too: changing input order can change tie-breaking.

Global center-depth sorting is an approximation shared by this renderer. Popping from large intersecting splats can happen on every backend. Quantizing depth to force agreement changes the algorithm and may worsen the image; it needs its own quality/performance evaluation.

07

Flips, stretched ellipses, clipping at the wrong plane

WebGPU defines NDC x/y in [−1, 1], z in [0, 1], and framebuffer origin at the top left. wgpu handles backend translation. Do not add a “Metal-only Y flip” simply because native APIs differ. First check the application’s camera and image conventions.

A camera and ellipse checklist

Check world-to-camera versus camera-to-world, row/column storage versus multiplication order, handedness, quaternion component order, projection depth range, focal lengths and principal point. An OpenGL-style [−1, 1] depth matrix is not interchangeable with [0, 1].

Σscreen≈JWΣWTJT,J=∂(xpixel,ypixel)∂(x,y,z)\Sigma_{\mathrm{screen}}\approx J W\Sigma W^{\mathsf T}J^{\mathsf T},\qquad J=\frac{\partial(x_{\mathrm{pixel}},y_{\mathrm{pixel}})}{\partial(x,y,z)}

Our shader projects covariance axes using the homogeneous perspective derivative. Its viewport is measured in backing pixels. Using CSS width for one path and canvas.width for another changes footprint, density and antialiasing. Fix the backing resolution, viewport and camera intrinsics before comparing images.

Near-zero clip.w, cancellation in the minor eigenvalue and the near-circular ellipse axis branch are sensitive points. Existing guards include a w² floor, a 0.3 covariance diagonal term, a 0.1 eigenvalue floor and a 192-pixel sigma cap. These are renderer choices, not universal 3DGS constants. Compare intermediate covariance/eigenvalues, not just final pixels.

WebGPU coordinates

08

Exploding splats, random colors, missing chunks

Check bytes before math. WGSL layout is specified; Metal and D3D12 do not get to interpret the same declared struct arbitrarily. A Rust/WGSL layout mismatch is an application bug, even if one device happens to hide it. vec3<f32> has size 12 but alignment 16; an array of these has stride 16.

Our STG-Lite record uses eight vec4 rows: 32 little-endian f32 values, 128 bytes. The parser verifies field order and finite inputs. Trace one known ID through the upload and verify every row. Match uniform offsets, matrix order, storage stride and binding sizes.

For screenshot readbacks, respect bytesPerRow padding and the actual channel order. Treating BGRA bytes as RGBA swaps red and blue; leaving row padding in the image shifts later rows. That is a readback error, not necessarily a rendering error.

WGSL memory layout

09

Random results between runs or GPU hangs

workgroupBarrier synchronizes a workgroup, not the whole GPU. A global spin-wait can deadlock if the producer group has not been scheduled. Atomic allocation order is not a stable depth tie-breaker. Portable sorting must not depend on thread arrival order or a fixed subgroup width.

Our path separates histogram → prefix → scatter into dispatches, with wgpu managing resource dependencies. Integer masks compute a stable local rank; no subgroup is required. Check bounds and initialization for tail groups, empty visibility and reused scratch buffers.

The host contract matters too: encode_prepare must precede the render pass. Submit the current frame before preparing another frame that reuses the same uniforms and indirect buffers. Otherwise two recorded frames can observe the wrong update. This is sequencing to fix, not a tolerated platform difference.

WGSL synchronization

10

Works natively, fails or looks incomplete in a browser

Query enabled features and device limits, not just GPU marketing specs. Buffer/binding limits can force a smaller resident time window. Unsupported formats, sample counts or features normally need a clear validation failure or an explicit fallback; silent point loss is not acceptable.

Our browser checks the resident STG storage size. The sorter uses workgroups of 128 and 256 threads and requires storage/indirect buffers. Its performance does not transfer automatically from an RTX 4090 to an Apple GPU. Optional timestamp support affects measurements, not the intended image.

Depth, sampling and future features

The standalone viewer has no depth attachment and uses one sample per pixel. StgPass can test against a host depth buffer but never writes Gaussian depth. Match depth format, comparison and clear value: conventional Z often uses LessEqual/1; reverse Z often uses GreaterEqual/0, with a matching projection. Depth24Plus is not a promise of an identical physical depth representation.

Compare identical MSAA and alpha-to-coverage settings. WebGPU specifies standard sample patterns; “every backend uses arbitrary sample positions” is not an explanation. If future SH textures, f16, subgroup kernels or compressed assets are introduced, validate those paths separately.

Current STG-Lite shading clamps a stored RGB feature; it does not evaluate the full SH expansion in the live renderer. If an imported renderer adds SH, align basis signs/order, view-direction convention, degree and coefficient layout. These are model-contract differences, not Metal-specific SH behavior.

wgpu limits · Texture/depth formats · WebGPU rasterization

11

Only playback differs; paused frames look right

Fix the requested normalized time, not “frame 120” or wall-clock time. Different refresh rates, dropped frames and buffering can show different moments of the same model. Our scene position is cubic in time; a small time mismatch can move many splats.

Record the manifest/model/chunk hashes, resident window, actual rendered time and sorted IDs. After an async seek, discard superseded results. First compare a fully resident paused scene, then reintroduce streaming. Do not compare a low-memory fallback window against a complete checkpoint and call the difference GPU precision.

12

Prove parity one stage at a time

A useful cross-platform comparison fixes the inputs and compares intermediate results before judging the final image. The sequence below is a proposed validation protocol, not a claim that all platforms have passed.

  1. Freeze a frame. Same model and shader hashes, source-record order, time, camera matrices, backing resolution and all pipeline settings. Disable adaptive quality for this comparison.
  2. Check preparation. Compare center, temporal opacity, projected covariance, visibility and depth-key/ID pairs. Report counts, differing IDs and first divergence.
  3. Replay fixed IDs. Feed the same visible order to each renderer. Render offscreen into the same explicit format, clear value and sample count. Read back without presentation.
  4. Compare images. Save reference, candidate and amplified difference images. Report RGB RMSE, maximum channel error and fraction of pixels above a chosen threshold; check alpha separately. Log whether comparisons use encoded or linear values.
  5. Test presentation, then motion. Align color management, background and DPR. Sweep time and camera, then enable streaming and repeat seeks. Test repeatability, not just one frame.
  6. Reduce a real failure. One splat → two overlapping splats → small boundary fixture. Capture adapter/driver/browser versions, validation errors and generated shader where available. Only then attribute the defect to a backend or compiler.
RMSE=13WH∑p=1WH∑c∈{R,G,B}(Ip,c−I^p,c)2\mathrm{RMSE}=\sqrt{\frac{1}{3WH}\sum_{p=1}^{WH}\sum_{c\in\{R,G,B\}}(I_{p,c}-\hat I_{p,c})^2}

Small global RMSE can hide one broken region. Keep worst-pixel and spatial diagnostics. No single universal threshold proves visual equivalence for all Gaussian scenes.

What is actually verified in this project
PathEvidenceBoundary
Windows · RTX 4090 · DX12CPU/GPU A/B: 24 real-scene frames; 18 synthetic GPU sorting checks.Within one native backend, not cross-backend image parity.
Browser · WindowsCPU/GPU timings, playback and manual rendering checks.No paired native/browser raw-pixel parity report.
Metal · macOS / iOSNot measured here.Must run on the actual target devices.
Vulkan · other GPUsNot covered by this evidence.Do not infer results from DX12.
Reproduce the existing local checks
cargo run --release --example gpu_prepare_check
cargo run --release --example gpu_driven_bench -- public/data/n3d-sear-steak-stg-lite.ply.gz public/data/n3d-sear-steak-reference-cameras.json artifacts/gpu-driven-sear

These are existing within-device checks. The examples force DX12 on Windows, so setting WGPU_BACKEND alone does not select a different backend there. A cross-platform runner still needs explicit backend selection, saved intermediate buffers and identical offscreen configuration. The benchmark also currently requires timestamp-query support.

A test manifest to keep with every capture
{
  "status": "template-not-a-measurement",
  "sourceRevision": null, "modelSha256": null, "shaderSha256": null,
  "os": null, "gpu": null, "driver": null,
  "wgpuVersion": null, "browserVersion": null, "actualBackend": null,
  "enabledFeatures": [], "deviceLimits": {},
  "timeNormalized": null, "viewMatrix": [], "projectionMatrix": [],
  "widthPixels": null, "heightPixels": null, "dpr": null,
  "attachmentFormat": null, "viewFormat": null, "colorSpace": null,
  "alphaMode": null, "blendState": null, "clearRgba": [],
  "sampleCount": null, "depthState": null,
  "residentChunkHashes": [], "visibleIdsHash": null,
  "rgbRmse": null, "maxChannelError": null, "pixelsOverThreshold": null
}

Measured GPU report · Native raw evidence · Browser evidence