We’ve been building a virtual shadow map for Babylon.js on WebGPU, and it’s public: Sundial. It’s MIT, it’s on npm, and it’s the default sun shadow for every WebGPU visitor to pOqpOq World.
- Repo: github.com/increasinglyHuman/poqpoq-sundial
- Install:
npm i @poqpoq/sundial - Live demo (three.js, needs a WebGPU browser): increasinglyhuman.github.io/poqpoq-sundial
Why it exists. Our worlds are forests and prim-heavy builds. On integrated GPUs, cascaded shadow maps were eating half the frame, and our watchdog kept switching shadows off for players on those machines. Sundial is built from the other direction.
What it is. One directional light, shadowed through a clipmap of 128×128 pages kept in a single depth pool. Each frame, a compute pass reads the camera’s depth and asks for the page whose texels match each visible surface’s pixel footprint, so resolution follows the viewer. A page is drawn once and then kept, so a static scene costs almost nothing to keep shadowed. Marking, paging, culling and rasterization all run on the GPU: three indirect draws a frame, whatever the size of the scene. The finest texels are 7.8 mm.
Moving things are cheap too. Each page also keeps a copy of its static-only depth. When a door, a vehicle or a character moves, the pages it crosses get that copy pasted back, and only the moving caster is redrawn on top, not every tree and building under it. With 40 movers walking through our test forest, shadow drawing went from 0.37 ms to 0.05 ms a frame on an RTX 5060. Skinned meshes cast too: the GPU skins the real mesh in the shadow pass.
Numbers (lab scene at 1600×900 with 4× MSAA, frame time in ms, medians of 5 interleaved rounds):
- Intel Xe-LPG: shadows off 3.8–4.8 · cascades at 3×2048² 8.8–10.5 · Sundial 6.1–7.3
- RTX 5060: shadows off 0.6–0.9 · cascades at 3×2048² 2.9–3.4 · Sundial 1.5–2.4
On a busy community sim in World, the frame rate went from 29 fps to 41 fps.
Using it:
import { SundialBabylon } from "@poqpoq/sundial/babylon";
if (SundialBabylon.isSupported(engine)) { // keep your CSM for WebGL2
const sundial = new SundialBabylon(scene, sun, { sceneMin: [-128, -10, -128], sceneMax: [128, 60, 128] });
sundial.addCaster(terrain);
sundial.addCaster(leaves); // alpha-tested casters just work
sundial.addCaster(windmill, { dynamic: true }); // moves: redraws only itself
sundial.addCaster(hero, { dynamic: true }); // has a skeleton: casts its skinned pose
sundial.addReceivers(scene.materials); // Standard and PBR, via a material plugin
sundial.start();
}
Things we learned the hard way on an Intel iGPU, which may help anyone writing custom shadow or material code on WebGPU:
- A storage write in a material shader, even one that never runs, turns off early-Z for the whole draw. Receivers have to be strictly read-only.
StandardMaterial’s alpha test discards at the end of the shader, so guard any costly per-fragment work on the alpha test yourself.- A dynamically indexed local array in a hot fragment path can spill on Intel. It cost us about 45 ms.
- iGPU A/B tests need warm-ups and “flush” pages between runs, or you end up measuring clock-state transients.
Limits. WebGPU only, and one directional light. For many characters, a small cascaded shadow alongside is still a fine choice: the receiver combines the two with min(), so nothing is darkened twice. That’s what pOqpOq World does for its avatars.
Along the way we sent two fixes upstream, and both shipped in Babylon 9.28.0: #18934 (the atmosphere’s PBR plugin now attaches only to materials in its own scene) and #18936 (StandardMaterial’s depth pre-pass honours every alpha source). Big thanks to the Babylon team for the careful reviews.
Since then: the core never needed Babylon (it talks only to a GPUDevice), and the three.js adapter the forum asked about shipped in 0.3 as @poqpoq/sundial/three, with the live demo above. The package is at 0.4 now.
Feedback and bug reports are very welcome, on GitHub or on the forum.
— Allen