PAJAMA / BUILD

SOURCE STUDY / 30 SEP 2026

Inside Spark & PlayCanvas

Follow a Gaussian from a downloaded file to the final pixel.

Three real rendering routes. Fixed-commit source links. Math beside the shader. Ideas for our Rust/wgpu viewer.

Walk through a frame →

01 / START HERE

Three routes, one goal: pixels

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.

Spark 2.3.02026-09-29cce78e2cf550 ↗
PlayCanvas 2.23.0-beta.242026-09-30b52b7c1c15af ↗

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.

02 / FOLLOW A FRAME

Step through the actual pipeline

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.

03

Generate this view

GPU
Input
Source + camera + modifiers
Output
Unified splat data + depth keys

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.

05

Sort in Rust/WASM

CPU / Worker
Input
Depth keys + source IDs
Output
Back-to-front ID sequence

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.

06

Publish the order

CPU → GPU
Input
Sorted IDs + mapping version
Output
RGBA32UI order texture

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.

08

Shade & composite

GPU
Input
Ellipse-local coordinates + RGBA
Output
Blended framebuffer pixels

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.

PlayCanvas · CPU sort
04

Send sort inputs

CPU / Worker
Input
CPU centers + active intervals + camera
Output
Worker input snapshot

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.

06

Apply the result

CPU → GPU
Input
Count + version + order data
Output
GPU-readable order

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.

07

Project the quad

GPU
Input
Sorted ID + unified parameters
Output
Ellipse corner positions

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.

08

Shade & blend

GPU
Input
Local UV + view color + alpha
Output
Premultiplied color attachment

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.

PlayCanvas · WebGPU sort
02

Cull intervals

GPU
Input
Active intervals + camera frustum
Output
Coarse candidate intervals

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.

03

Compact candidates

GPU
Input
Surviving intervals
Output
Dense candidate IDs + indirect count

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.

04

Project & reject

GPU
Input
Candidate Gaussian + camera
Output
Valid projected ellipse + color

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.

05

Cache & compact visible

GPU
Input
Surviving projected splats
Output
Projection cache + keys + visible count

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.

06

Sort visible IDs

GPU
Input
Quantized keys + GPU count
Output
Sorted projection-cache indices

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.

07

Draw cached quads

GPU
Input
Sorted index + projection cache
Output
Indirect instanced triangles

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.

08

Composite pixels

GPU
Input
Covered fragments + RGBA
Output
Final color attachment

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.

03 / BYTES TO PARAMETERS

What is actually stored on the GPU?

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.

LayerSparkPlayCanvas unified
ResourcePackedSplats / ExtSplats / paged sources; PackedSplats has a 16-byte base.PLY, compressed PLY, SOG and octree resources retain format-specific decoding.
View dataGenerated 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.
OrderInteger texture: instance ID → original/generated splat ID.CPU route: work-buffer indices. GPU route: sorted projection-cache indices.
Memory accountingCount source + SH + generated buffers + ordering + readback staging + LoD residency.Count source + work buffer + candidate/visible lists + radix scratch + projection cache + order.
Why textures instead of an array of objects?

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.

04 / PARAMETERS TO ELLIPSES

One Gaussian, from 3D to a pixel

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)TS=\operatorname{diag}(s_x,s_y,s_z),\qquad \Sigma=R(q)S^2R(q)^\mathsf{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.

2. A small 3D displacement becomes a screen displacement.

μc=Wμ+t,J=[fx/z0−fxx/z20fy/z−fyy/z2],Σ2D=JWΣWTJT+βI\mu_c=W\mu+t,\quad J=\begin{bmatrix}f_x/z&0&-f_x x/z^2\\0&f_y/z&-f_y y/z^2\end{bmatrix},\quad \Sigma_{2D}=JW\Sigma W^\mathsf{T}J^\mathsf{T}+\beta I

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=0K−1ak,jYk(v),j∈{R,G,B},∥v∥=1c_j(\mathbf v)=b_j+\sum_{k=0}^{K-1}a_{k,j}Y_k(\mathbf v),\qquad j\in\{R,G,B\},\quad \|\mathbf 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.

Rotate the SH basis and inspect RGB channels →

4. Evaluate coverage, then put the front splat over the back.

α(Δ)=oexp⁡ ⁣(−12ΔTΣ2D−1Δ),Cnew=αc+(1−α)Cold\alpha(\Delta)=o\exp\!\left(-\tfrac12\Delta^\mathsf{T}\Sigma_{2D}^{-1}\Delta\right),\qquad C_{\mathrm{new}}=\alpha c+(1-\alpha)C_{\mathrm{old}}

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.

05 / THE ORDER IS DATA

What “GPU sorting” really means here

RouteKey and algorithmTransfer / execution
SparkPositive distance float-bit keys; two 16-bit radix passes. Radial distance by default.GPU key readback → Rust/WASM Worker → integer order texture upload.
PlayCanvas CPUWeighted, quantized depth buckets; JavaScript counting sort, nominal 10–20 bits.CPU centers / camera inputs → Worker → GPU-readable order.
PlayCanvas WebGPUQuantized depth keys rounded to the radix width; portable 4-bit or gated 8-bit OneSweep.GPU projection / compaction → indirect GPU sort → indirect draw.
Radix sort, with four cards

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.

destination=bucketStart+earlierWorkgroups+rankWithinWorkgroup\mathrm{destination}=\mathrm{bucketStart}+\mathrm{earlierWorkgroups}+\mathrm{rankWithinWorkgroup}

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.

Two traps: stable ties and “fewer passes”

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.

06 / SAME MODEL ≠ SAME PIXELS

Where the images can diverge

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 (ordinary branch):α=oexp⁡(−12r2)PlayCanvas (forward):α=o exp⁡(−4A)−exp⁡(−4)1−exp⁡(−4),A=uv ⁣⋅ ⁣uv≤1\begin{aligned}\text{Spark (ordinary branch):}\quad &\alpha=o\exp(-\tfrac12 r^2)\\\text{PlayCanvas (forward):}\quad &\alpha=o\,\frac{\exp(-4A)-\exp(-4)}{1-\exp(-4)},\quad A=\mathbf{uv}\!\cdot\!\mathbf{uv}\leq1\end{aligned}

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.

ContractWhat to align
Depth orderRadial distance versus camera-forward depth; quantization; tie behavior; stale versus current camera snapshot.
Shape and filteringScale/quaternion decoding, covariance clamps, diagonal blur, support radius, AA opacity compensation and minimum pixel size.
ColorSH basis/order/sign/degree, packed coefficient precision, direction convention, gamma, exposure, tone mapping and attachment format.
Coverage and selectionAlpha thresholds, projected-size culling, source LoD, splat budget and currently resident pages.
Working precisionHalf-packed projection axes and color versus f32 intermediates; shader f16 capability.
Two concrete upstream portability workarounds

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.

Continue with the Metal / DX12 / WebGPU diagnosis guide →

07 / SPATIAL PAGES TO TIME

What changes for a true 4D viewer?

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.

  1. Select time first. Resolve global seconds to a segment and its local model time. Fetch every chunk whose temporal support intersects the requested interval.
  2. 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.
  3. 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.
  4. 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.

Inspect our time-range streaming implementation →

08 / APPLY THE IDEAS

What we should take into Rust/wgpu

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.

PriorityConcrete changeEvidence required
1 · Projection cacheFollow 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 sortingSort 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 streamingUse 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 modesEvaluate 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.

Read our existing measured GPU results →

09 / OPEN THE CODE

A source-reading route you can reproduce

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.

Spark

SplatLoader → SplatMesh.generator → SplatAccumulator.generate → SparkRenderer.driveSort → worker.sortSplats32 → Rust sort32 → orderTexture → splatVertex → splatFragment

PlayCanvas · CPU

GSplatHandler → resource / placement → GSplatManager / world → work-buffer bake → UnifiedSortWorker → onSorted → GSplatQuadRenderer → gsplatCorner → gsplat fragment

PlayCanvas · WebGPU

GSplatManager → GSplatHybridRenderer.sortAndProjectForCamera → frustum cull → interval compaction → projector → sortIndirect → gsplatHybrid vertex → gsplat fragment

Source index · paths, lines and fixed commits
Five questions to check your understanding
  1. 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.
  2. Does an async Worker imply GPU sorting? No. A Worker executes CPU code, including WASM; transfers and versioning remain part of the pipeline.
  3. Why project before sorting in the hybrid path? Projection enables detailed rejection and produces a reusable cache; only surviving cache indices need sorting.
  4. 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.
  5. Does spatial paging finish a 4D player? No. Time selection, model evaluation, motion bounds and coherent segment transitions are still required.

Download the source-reference manifest