Building the Grace Cathedral Experience | PlayCanvas Blog
Skip to main content<br>Grace Cathedral is a San Francisco landmark on Nob Hill.
After seeing Vincent Woo's high-quality capture of the cathedral and its surrounding streets as 3D Gaussian splats, we jumped at the chance to collaborate with him. Together, we turned his splats into a browser-based, interactive experience. You can see the finished app below:
▶Grace CathedralExperience the outside and inside of Grace CathedralClick to launch
This article explores how I built this app. Let's dive in.
Under the Hood
The PlayCanvas Engine was the perfect runtime on which to build, thanks to its WebGPU hybrid renderer and streamed SOG format.
WebGPU Hybrid Renderer
The exterior view renders around 3.5 million Gaussian splats at a time. Because they must be sorted back to front, moving the camera means repeatedly sorting millions of splats in real time.
Our first-generation Gaussian splat renderer used WebGL 2, which only provides vertex and fragment shaders. They are well suited to drawing the splats but not to sorting them, so a worker sorted the Gaussians on the CPU and uploaded the results to the GPU. Sorting and uploading could fall behind rendering, making artifacts visible as the camera moved.
WebGPU lets us divide that work more effectively. Compute shaders cull, project and sort the active splat set on the GPU every frame, then vertex and fragment shaders render the result. Keeping the data on the GPU avoids the transfer bottleneck and makes the pipeline dramatically faster than the worker-based WebGL 2 renderer.
The experience uses this WebGPU pipeline whenever the browser supports it and falls back to the WebGL 2 renderer automatically. To support both rendering paths, the cutout, flag animation and transition shaders each have WGSL and GLSL implementations so the experience looks the same on both backends.
Streamed SOG
The splat data is delivered as streamed SOG — our open compressed splat format, extended with chunked levels of detail that the engine fetches on demand.
The experience contains separate splat sets for the exterior and interior. The exterior combines a streamed LOD set covering everything within 400 meters with a static, low-detail set for the landscape beyond it. Each mode loads only its own set and evicts the other, so device memory holds one view at a time.
Startup is tuned for time-to-first-pixel. On load, each streamed splat set starts at a single coarse LOD, so only a small fraction of the data needs to arrive before the loading screen lifts. From there, the engine streams in detail progressively within a per-device budget: 3.5 million rendered splats on desktop and 1.4 million on mobile. Mode switches are covered by a short freeze-fade: the last frame is held and faded down while the next splat set loads its coarse LOD, then the new view fades in and refines.
Optimized to Run Everywhere
Beyond streaming and per-device splat budgets, a few things keep the experience smooth on modest hardware — and cool and quiet on phones:
On-demand rendering. The app only renders when something changes: camera movement, LOD chunks arriving, a cutout animating, cars driving by. When you stop moving, it stops drawing.
Resolution caps. Device pixel ratio is capped on high-DPI displays, with a further render-scale reduction on mobile, where the extra pixels cost more than they show.
Depth pre-pass occlusion. The interior collision mesh doubles as an occluder: rendered depth-only before the splats, it lets early-Z reject splat fragments hidden behind walls.
Bringing the Scene to Life
The Peek Effect
While orbiting outside, the Peek toggle carves away the section of wall nearest the camera so you can look inside. A thin, glowing rim outlines the cut edge.
It is built on the engine's gsplat shader chunk system, which lets a project replace the gsplatModifyVS and gsplatModifyPS chunks to modify every splat and every fragment. The cutouts themselves are just box entities in the Editor, grouped under trigger volumes. As the camera moves, the trigger closest to it eases its cutout open while the others ease closed.
The shader works in two stages to keep it cheap. A per-splat stage — a compute pass on WebGPU — tests each splat's center against the cutout boxes with a signed distance function. Splats fully inside an open box are culled outright and only those near a cut surface are flagged for further processing. The fragment stage then runs a pixel-exact version of the same test for flagged splats. It reconstructs each fragment's world position from depth, fades alpha inside the box and draws a rim that maintains a constant on-screen width at any distance. The rim color is written as an HDR value so bloom picks it up. The chunks are installed only while the exterior view is active, so walk mode pays nothing for the effect.
A Hidden Flag
The same chunk also animates a flag hidden somewhere in the scene. Splats inside an...