Hi all,
Sundial is a virtual (paged) shadow map for one directional light, and it now has a three.js adapter for WebGPURenderer:
-
Live demo: Sundial for three.js (needs a WebGPU browser)
-
Minimal example: Sundial: minimal three.js example
-
npm:
@poqpoq/sundial(three r186; the adapter is@poqpoq/sundial/three)
The forest road in the live demo: every dapple on the ground is a leaf card’s shadow.
How it plugs in. It installs as the sun’s light.shadow.shadowNode, the same hook CSMShadowNode uses. So every node material lit by the sun receives the shadow with no per-material changes, and your other lights keep three’s shadows as usual.
import * as THREE from "three/webgpu";
import { SundialThree } from "@poqpoq/sundial/three";
await renderer.init();
if (SundialThree.isSupported(renderer)) {
const sundial = new SundialThree(renderer, scene, camera, sun, {
sceneMin: [-128, -10, -128], sceneMax: [128, 60, 128],
});
sundial.setCasters(SundialThree.castersIn(scene)); // every mesh with castShadow
sundial.start();
}
What it does. The sun’s shadow is a clipmap of 128×128 pages in one depth pool. A compute pass reads the camera depth each frame and requests the page whose texels match each visible surface’s pixel footprint. A page is drawn once and then kept, so a static scene costs very little to keep shadowed, and near the camera the texels get small (7.8 mm at the finest level). Page marking, allocation, culling and drawing all run on the GPU.
Casters can be static meshes, InstancedMesh, alpha-tested foliage (read from map or alphaMap at alphaTest), moving meshes and SkinnedMesh. When something moves, only the moving caster is redrawn over each page’s cached static depth. Antialiasing, reversedDepthBuffer and post-processing all work. blendShadowMap: true also folds in three’s standard shadow map for the same light, for casters you’d rather keep there (animated characters, say).
Numbers. These are from our lab scene (a 256 m sim: terrain, 1,400 alpha-tested trees and a small village, 1600×900 with MSAA), frame ms on an Intel Xe-LPG iGPU. The comparison is CSMShadowNode configured to match:
| village | overview | forest | |
|---|---|---|---|
| shadows off | 3.84 | 3.96 | 3.53 |
| CSMShadowNode 3×2048² | 7.08 | 6.76 | 6.62 |
| CSMShadowNode 4×4096² | 10.77 | 9.92 | 9.98 |
| Sundial | 6.43 | 6.69 | 5.89 |
On an RTX 5060 the pattern is similar (details in the README). The lab is in the repo (three.html) if you’d like to measure on your own hardware.
Honest limits. WebGPU only, and one directional light. The adapter relies on a few three.js internals (the README lists them), so it’s tested against r186 and the peer range is narrow; we’ll follow three’s releases. Morph targets aren’t applied to casters. And the cost is in receiving the shadow, so it grows with how many shadowed pixels overlap on screen: looking down onto a dense canopy of leaf cards (the demo’s Overview), Sundial runs a little slower than CSMShadowNode on an Intel iGPU. A cheaper receiver path for foliage is on our list.
It was built for poqpoq World (https://poqpoq.com/), where it’s the default sun shadow on WebGPU. It began life as a Babylon.js project, and three.js support arrived in 0.3. Both adapters drive the same core, so the shadows are identical.
Alpha testers wanted. If you have a WebGPU browser, we’d love you to walk around poqpoq World and tell us how it runs on your GPU. Any engine background is welcome. Feedback here or on GitHub.
Thanks to the three.js team for the node system. shadowNode made this a clean fit.
Allen (p0qp0q)

