Think of a Gaussian as an ID card: center, size, rotation, opacity and color coefficients. A viewer must decode that card, decide which cards matter for the current view, order overlapping cards, and turn each one into a soft ellipse. Sorting changes the list of IDs; it usually does not rearrange all the original parameter records.
This is a source reading of the normal forward, sorted 3DGS paths in the pinned revisions below. Spark’s inspected renderer uses THREE.WebGLRenderer and GLSL3. PlayCanvas has both CPU-sort and WebGPU compute-sort routes. Both eventually draw triangles through the graphics pipeline; neither route described here is a CUDA-style per-tile pixel compositor.
Shader-language note: PlayCanvas’s WebGL route uses GLSL chunks; the WebGPU route uses WGSL chunks. The CPU-sort quad renderer can serve either backend. Links include the matching GLSL bake, projection and fragment code alongside WGSL, which is closest to our wgpu work.
Read on 30 September 2026 from the official repositories. PlayCanvas is a main-branch beta snapshot, not a claim about every stable release. Legacy rendering, XR, picking, shadows, stochastic coverage and Spark’s special LoD/2D variants have extra branches. This article does not benchmark the two engines.
Choose a route, then advance one step. Notice the ownership badges: a GPU operation, a CPU Worker and a transfer are three different kinds of work. The order below is a data-dependency explanation; asynchronous implementations can overlap work from different frames.
01
Load & decode
CPU / Worker
Input
URL, bytes or stream
Output
PackedSplats / ExtSplats
SplatLoader delegates decoding to a Worker. The decoded typed arrays become a splat source; a PLY vertex is a parameter record, not a triangle vertex to draw directly.
The walkthrough traces control and data flow, not a running copy of either engine. Expand a full route below for the whole story, including without JavaScript. Every source button opens the exact audited commit and lines.
Spark · WebGL + WASM sort
01
Load & decode
CPU / Worker
Input
URL, bytes or stream
Output
PackedSplats / ExtSplats
SplatLoader delegates decoding to a Worker. The decoded typed arrays become a splat source; a PLY vertex is a parameter record, not a triangle vertex to draw directly.
PackedSplats uses a 16-byte base record; higher SH bands live in extra arrays/textures. If LoD/paging is enabled, selection maps the chosen IDs to resident data. “16 bytes” is not the complete per-splat or total renderer memory cost.
SplatMesh builds a generator graph: fetch, evaluate SH, apply skinning/modifiers and transforms. SplatAccumulator executes GPU render-to-texture work, producing renderable parameters and sort depth. This accumulator collects splat records; it is not the final pixel alpha blend.
driveSort awaits readbackDepth. Readback uses readRenderTargetPixelsAsync through a staging target. Asynchronous readback allows other work to continue, but still has transfer cost and latency. It does not move the sort algorithm onto the GPU.
The active call is sortSplats32 → sort32_splats. Rust filters sentinel keys, reverses positive-distance float-bit keys, then runs two stable 16-bit radix passes. sortRadial defaults to true: distance from the camera, not necessarily camera-forward depth. The file also has a 16-bit sorter; that is not the call followed here.
driveSort uploads an integer order texture. The renderer tracks mappings and versions before pairing an order with generated data. An old order may remain on screen while work is in flight; an order must never index a different generation of records.
The instanced quad has four vertices and six indices. The vertex shader fetches the sorted splat, rejects invalid/offscreen cases, builds covariance, projects it with a Jacobian, adds filtering and finds ellipse axes. Each quad vertex runs this vertex program.
For ordinary opacity ≤ 1 and the default falloff, alpha is opacity × exp(−r²/2). Fragments outside the support or below minAlpha are discarded. The default material outputs premultiplied alpha, tests depth and disables depth writes. Optional color conversion and special LoD opacity branches also affect the result.
GSplatHandler chooses a parser. PLY can remain compressed or become an ordinary GSplatResource; SOG loads texture assets. SPZ needs the additional parser registration described in the handler. Do not assume every format becomes the same float array.
This revision defaults components to unified rendering. A manager chooses GSplatQuadRenderer and initializes the CPU sorter for this route. AUTO selects the GPU-sort renderer on WebGPU, so this CPU route is the non-WebGPU default or an explicit choice.
Per-format shader variants read the resource and write unified work-buffer textures. The shader applies transforms and evaluates SH for the view. Geometry and color work can be separated: a view change can require new SH color without rewriting unchanged geometry.
The CPU sorter works from per-resource centers and placement/camera information. This is not Spark’s generated-depth GPU readback route. Active intervals avoid sorting padding; asynchronous jobs and versioned pending results separate worker completion from frame submission.
The JavaScript Worker computes weighted depth bins and uses counting sort. Its nominal precision adapts between 10 and 20 bits with the active count. This differs from sorting all 32 float bits, and can put nearby depths into the same bucket. Radial sorting defaults to false.
The sorter keeps a pending result and reusable order buffers; the manager applies the result to the matching world state. Only the order needs to change for a pure camera-depth reorder. The full source records do not need to be copied into sorted order.
The quad route computes covariance and screen-space axes in the vertex shader, adds a 0.3 diagonal filter term, and performs extent-aware screen rejection. The WGSL variant promotes covariance calculations to f32 to avoid squaring large scales in half precision; the GLSL counterpart is linked too.
The ordinary forward path uses normExp(dot(UV, UV)), cuts off outside the unit disk, clips low alpha and outputs premultiplied color. The quad material enables premultiplied blending and disables depth writes in the non-dithered mode. Output gamma/tone mapping is part of the pipeline.
Loading and work-buffer baking are shared with the CPU route. AUTO resolves to GSplatHybridRenderer on WebGPU in this snapshot. “Hybrid” here means compute projection/sorting combined with raster drawing, not CPU sorting.
sortAndProjectForCamera uploads interval descriptors and runs frustum culling. Rejecting a whole interval before reading every splat saves downstream work. This is visibility rejection, not an occlusion-buffer test.
Interval compaction forms the projector’s candidate list and prepares indirect dispatch slots. Allocated capacity and actual work count are different: buffers may remain large while only a smaller candidate set is dispatched.
A compute kernel projects each candidate and rejects splats using screen bounds, size, alpha and contribution settings. Projection happens before sorting. The expensive covariance-to-ellipse result can therefore be reused by the final quad vertices.
Workgroups reserve output ranges with atomics and pack valid splats. The default non-XR cache uses eight u32 words (32 bytes): clip center, two half-packed screen axes, and half-packed RGBA. Extra user cache words and XR layouts can change this. These 32 bytes are additional working memory, not the complete source representation.
sortIndirect consumes the GPU-written visible count. A nominal 10–20-bit depth key is rounded up to a radix multiple. AUTO chooses a portable 4-bit multi-pass sort, or 8-bit OneSweep only under this revision’s NVIDIA/subgroup capability checks. Full 32-bit sorting is not required for this caller.
The lightweight vertex shader reads the sorted cache record and expands its precomputed axes into quad corners. Indirect drawing uses the GPU count without waiting for a CPU readback of that count. The final image still uses hardware triangle rasterization.
The ordinary sorted path shares the Gaussian fragment behavior with the quad route. Stochastic rendering is a separate optional path: it skips sorting and uses dithered opaque coverage/depth behavior. It is excluded from this walk-through so its quality trade-off is not confused with exact transparent ordering.
Separate three layers: the downloaded file, resident source parameters, and temporary per-view working data. A compressed download does not imply the same compression in the working buffer. A small source record does not include every SH texture, order array, projection cache or double buffer.
Layer
Spark
PlayCanvas unified
Resource
PackedSplats / ExtSplats / paged sources; PackedSplats has a 16-byte base.
PLY, compressed PLY, SOG and octree resources retain format-specific decoding.
View data
Generated splat textures plus sort-depth data. SH is evaluated before final rasterization.
Unified work-buffer textures with transformed parameters and view color; the GPU route adds a projection cache.
Order
Integer texture: instance ID → original/generated splat ID.
CPU route: work-buffer indices. GPU route: sorted projection-cache indices.
Integer or floating-point textures can act as structured data storage. A splat ID becomes a texel address; shader code unpacks that texel into parameters. WebGL’s capabilities made this a useful design. WebGPU adds storage buffers and compute, but an engine can still keep texture-based work buffers to share infrastructure across routes. A “texture” here need not be a photograph.
The equations below describe the shared mathematical idea. They are not a claim that the implementations use identical storage, cutoffs or filters. Follow the source links beside each step to see where an idea becomes code.
1. Three lengths and a rotation describe the blob.
S=diag(sx,sy,sz),Σ=R(q)S2R(q)T
Start with a round blob. Stretch it along three axes by s, then rotate those axes by q. Covariance Σ packages that shape into one matrix. Squaring the scales converts standard deviations into variances. The loader must already have decoded conventions such as log-scales, opacity logits and quaternion component order correctly.
Here W is the rotation part of world-to-camera, and (x,y,z) is the center in camera space. In this illustrative convention, screen coordinates are fₓx/z and fᵧy/z. The Jacobian J is the local “how much does the screen move?” conversion. The extra βI slightly widens the footprint for filtering. Actual shaders incorporate their own camera signs, pixel scale, clamps and antialiasing compensation.
The eigenvectors give the ellipse directions; the square roots of the eigenvalues give its radii before the support multiplier. Draw a rectangle enclosing that ellipse. The fragment shader trims its corners and fades its interior. This is why a point-like representation still ends in triangle rasterization.
3. Direction becomes three independent color sums.
cj(v)=bj+k=0∑K−1ak,jYk(v),j∈{R,G,B},∥v∥=1
The basis functions Y are shared; each RGB channel has its own coefficients a. The three results combine into one color. The offset/DC convention b depends on the encoding. Spark fetchSplat evaluates packed SH using the view direction. PlayCanvas’s work-buffer shader evaluates SH while baking the view color. Neither ordinary path recomputes the whole SH sum for every covered pixel.
4. Evaluate coverage, then put the front splat over the back.
α(Δ)=oexp(−21ΔTΣ2D−1Δ),Cnew=αc+(1−α)Cold
The first expression is the ideal Gaussian envelope; production kernels modify/truncate it as shown next. For back-to-front drawing, C_old contains the background and farther splats. A fragment outputs premultiplied RGB = αc, and the blend unit applies the remaining transmittance (1−α). Drawing order matters whenever colored transparent splats overlap.
Use ascending encoded keys for this example: A:0x21, B:0x12, C:0x11, D:0x22. “0x” means hexadecimal; each digit is four bits. First sort by the last digit, preserving order inside a bucket: A, C, B, D. Then sort by the first digit, again stably: C, B, A, D. We now have 0x11, 0x12, 0x21, 0x22. A renderer can encode depth so this ascending order means far to near.
The portable PlayCanvas path counts each workgroup’s bucket sizes, prefix-scans them to find global destinations, then uses local ranks to scatter key/ID pairs. Repeating with the next digit finishes the ordering. A workgroup barrier is not a global barrier: histogram, scan and scatter are separate dispatch stages.
Stable radix preserves the order of equal keys in its input. It does not guarantee original-source-ID order if a previous atomic compaction reordered those IDs. Quantization creates more equal keys, so temporal stability must be tested after the whole pipeline, not inferred from the word “stable”.
For the GPU caller, a nominal 20-bit key rounds to 20 bits with a 4-bit radix, but to 24 with an 8-bit radix. OneSweep is not enabled indiscriminately: the audited facade checks NVIDIA and subgroup capabilities. Wider radix and fewer passes do not prove a faster or portable renderer on another GPU.
Even before considering Metal versus DX12, two renderers can make different algorithmic choices. Match these contracts before treating a difference as a backend bug. The formulas here use each shader’s own UV convention; the two horizontal axes are not automatically the same physical radius.
Spark’s expression above assumes opacity ≤ 1 and falloff = 1, with a finite support and minAlpha cutoff. PlayCanvas’s normalized exponential reaches zero at its unit-disk boundary; it also applies alpha clipping. These are different finite-support kernels, not merely two spellings of an identical exponential.
The PlayCanvas work-buffer shader records a Firefox/Windows D3D12 matrix-struct indexing issue and copies the full matrix into a local before indexing. The hybrid renderer also documents a Windows/NVIDIA Dawn/D3D12 picking case, forcing a submit boundary before OneSweep to avoid stale indirect arguments. Its comment explicitly keeps the ordinary forward path in one submission.
These are workarounds documented by the upstream authors at this commit, not defects we reproduced on every current browser. They illustrate why backend capability policy and tiny regression fixtures belong beside a shared shader implementation. Do not add a submit after every dispatch or flip all matrix axes as a blanket fix.
Spark’s RAD pager can fetch byte ranges and map decoded chunks to resident pages. PlayCanvas’s octree selects spatial LoD and can show an already loaded coarser level while a desired level loads. These are valuable spatial-streaming designs. They do not by themselves define a [t₀,t₁] time-range query, a trained motion model or continuous switching between temporal checkpoints.
Select time first. Resolve global seconds to a segment and its local model time. Fetch every chunk whose temporal support intersects the requested interval.
Evaluate the representation. Our STG-Lite path evaluates motion and temporal opacity. Other 4DGS families may require a deformation network, feature grids or different parameters; a static GS loader cannot infer that contract.
Cull with motion-aware bounds. A bound valid at the start of a segment may exclude an object later. Then project and sort using the evaluated positions at the same time.
Publish a coherent snapshot: resident records, mappings, camera, time and sorted IDs must agree. Keep the previous valid frame while required data is incomplete, and measure the resulting playback lag.
Animation hooks in Spark and custom shader chunks in PlayCanvas are extension points. They are not evidence that either static loading path directly evaluates every research 4D checkpoint. Our current time-range demo operates inside one STG-Lite checkpoint; long multi-checkpoint continuity still needs segment-aware validation.
Our audited renderer revision is 07a2910. It already generates visibility/depth keys on the GPU, performs eight stable 4-bit radix passes over resident records, and draws indirectly. Invisible records use sentinel keys; the current sorter still processes them. Its vertex shader still computes the ellipse for each of the six quad vertices.
Follow PlayCanvas: evaluate motion, covariance and ellipse once in compute; share the result with culling and the draw shader. Start with f32, then evaluate packing.
Compare GPU projection + draw time, memory traffic and raw pixels. Added cache writes can outweigh saved arithmetic.
2 · Compact before sorting
Sort M visible records instead of N residents; keep count and dispatch arguments on the GPU. Explicitly define equal-depth ID behavior.
Measure M/N across wide and narrow views, include compaction time and check no missing footprints or new flicker.
3 · Versioned streaming
Use Spark’s mapping discipline and PlayCanvas’s render-ready world concept for temporal pages, camera/time snapshots and in-flight work.
Cold start, random seek p95, frame-time p95, peak residency and correct IDs at segment boundaries.
4 · Optional quality / speed modes
Evaluate quantized keys and LoD budgets separately. Keep full-precision ordering as the reference; gate subgroup algorithms per tested adapter.
Equal scene/time/view/format; image error plus temporal flicker and GPU pass timing on DX12 and Metal separately.
These are proposed next changes, not newly implemented optimizations in this article. Our existing native/browser evidence remains linked below; it is not a Spark-versus-PlayCanvas benchmark. This source study does not establish a speedup percentage for any proposed change.
First follow the host orchestration, then one data record, then the shader. Starting from a large shader file without knowing what its input ID indexes makes the code much harder to understand. Use this map alongside the frame walkthrough.
Where does SH run? Before final rasterization: in Spark’s generated splat path and PlayCanvas’s work-buffer bake. The output is a view-dependent RGB value.
Does an async Worker imply GPU sorting? No. A Worker executes CPU code, including WASM; transfers and versioning remain part of the pipeline.
Why project before sorting in the hybrid path? Projection enables detailed rejection and produces a reusable cache; only surviving cache indices need sorting.
Does “stable radix” guarantee stable original IDs? Only if its input preserves that tie order. Earlier atomic compaction and depth quantization must also be considered.
Does spatial paging finish a 4D player? No. Time selection, model evaluation, motion bounds and coherent segment transitions are still required.