diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e5537be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.local
diff --git a/README.md b/README.md
index ef8fa4d..bab9f9b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
# Lucent
**A real-time, progressive path tracer that runs entirely on your GPU.**
-TypeScript · WebGL2 · GLSL ES 3.0 · zero runtime dependencies · ~24 kB gzipped
+TypeScript · WebGL2 · GLSL ES 3.0 · zero runtime dependencies · ~46 kB gzipped
**[Open the live demo →](https://clark719664.github.io/lucent/)**
@@ -13,15 +13,15 @@ TypeScript · WebGL2 · GLSL ES 3.0 · zero runtime dependencies · ~24 kB gzipp


-
+
-*Showroom: a 9,680-triangle gold trefoil, glass, brushed copper and depth of field, lit by a physical sun and sky.*
+*Car Concept (glTF, 213,347 triangles) under a Poly Haven dawn HDRI: clearcoat paint, glass and a glossy ground. 1024 samples per pixel at 1440 × 900 in 26 s on a Radeon RX 7600.*
---
-Lucent is a physically based renderer written from scratch: no three.js, no WebGPU polyfills, no shader libraries. Every photon path is traced in a fragment shader against a bounding-volume hierarchy built in TypeScript, and the result converges on screen as you watch. Drag to orbit, click to refocus, and the image restarts from a clean slate.
+Lucent is a physically based renderer written from scratch: no three.js, no WebGPU polyfills, no shader libraries. Drop a glTF model onto the page and it is traced in a fragment shader against a bounding-volume hierarchy built in TypeScript, lit by an importance-sampled HDRI, denoised while it converges and reprojected while you orbit.
It exists to be read as much as to be run. The code is small enough to hold in your head and documented where the ideas are non-obvious.
@@ -29,33 +29,69 @@ It exists to be read as much as to be run. The code is small enough to hold in y
| | |
|:--:|:--:|
-| 
**Cornell Box** — diffuse interreflection and colour bleeding from one area light, sampled explicitly | 
**Nocturne** — no sky; three emitters light a chrome sphere, glass and a (3,4) torus knot |
-| 
**Material Study** — roughness sweeps left to right across rough glass, dielectric, dielectric→metal and gold | 
**Showroom** — sun + sky, caustics through glass, thin-lens bokeh |
+| 
**Toy Car**: clearcoat paint, sheen fabric, glass | 
**Dragon**: refraction with Beer–Lambert absorption |
+| 
**Sheen Chair**: Charlie sheen velvet, texture transforms | 
**Damaged Helmet**: metal-rough, normal and emissive maps |
+| 
**A Beautiful Game**: 1,499,072 triangles, 33 textures | 
**Denoiser**: the same 8 samples per pixel, raw (left) and filtered (right) |
+
+The glTF models above are in the in-app gallery and stream from the [Khronos glTF sample assets](https://github.com/KhronosGroup/glTF-Sample-Assets); the four built-in scenes need no network at all:
+
+| | |
+|:--:|:--:|
+| 
**Showroom**: sun + sky, caustics through glass, thin-lens bokeh | 
**Cornell Box**: diffuse interreflection from one area light |
+| 
**Material Study**: roughness sweeps across glass, dielectric and metal | 
**Nocturne**: no sky; three emitters, chrome, glass and a torus knot |
## What's inside
+**Scenes**
+
+- **glTF 2.0 and GLB loading**, written from scratch and tested under Node: node hierarchies, indexed, strip and fan primitives, interleaved, normalized and sparse accessors, mirrored transforms. Drop a `.glb`, a `.gltf` with its `.bin` and images, or the folder holding them, or use *Open file…*.
+- **PBR materials**: base colour with alpha mask and blend, metal-rough, normal and emissive maps, `KHR_texture_transform`, and the `transmission`, `ior`, `volume`, `clearcoat`, `sheen`, `emissive_strength` and `unlit` material extensions. Extensions it does not model are listed in a toast rather than silently dropped; files that require Draco, meshopt or KTX2 are refused with a clear message.
+- **Textures** are resampled on the GPU (mipmapped, filtered in linear space) into one sRGB and one linear 2D texture array, sized to a 256 MB budget.
+- **Gallery** of Khronos sample models, each paired with a CC0 HDRI from Poly Haven, plus the four built-in scenes.
+
**Rendering**
-- **Unidirectional path tracing** with Russian roulette, progressive accumulation into ping-ponged `RGBA32F` targets, and a separate tone-mapping pass (ACES filmic, Reinhard or clip) with dithering.
-- **Next-event estimation with multiple importance sampling** (power heuristic) against sphere lights, quad lights and a sun of finite angular size. Emitter hits via BSDF sampling are weighted by the matching solid-angle pdf, so there is no double counting and no dependence on luck for small lights.
-- **Microfacet BSDF**: Lambert + GGX with Smith masking, Schlick Fresnel, and [visible-normal sampling](https://jcgt.org/published/0007/04/01/) (Heitz 2018). Metallic, roughness, transmission and IOR are the knobs; glass is a stochastic dielectric with a roughened normal.
-- **Thin-lens camera** with concentric-disk bokeh and a planar focal plane. Click any point to focus there: the pick ray is intersected on the CPU with the same BVH the GPU uses.
-- **Procedural sky** with a horizon gradient and sun glow; the sun disc itself is only ever added with an MIS weight.
-- **Firefly suppression** via a luminance clamp on indirect contributions, user-adjustable because it trades bias for variance.
+- **Unidirectional path tracing** with Russian roulette, per-pixel running averages in ping-ponged multiple render targets, and a separate tone-mapping pass (ACES filmic, Reinhard or clip) with dithering.
+- **HDRI environments with importance sampling.** Radiance `.hdr` files are decoded in TypeScript; the map is split into up to 1024 × 512 cells weighted by luminance × sin θ and turned into a [Walker alias table](https://en.wikipedia.org/wiki/Alias_method), so drawing a light sample costs one texel fetch, and the exact piecewise-constant pdf is available for MIS. The procedural sky is baked into the same path, so even the built-in scenes sample their sky explicitly now.
+- **Next-event estimation with multiple importance sampling** (power heuristic) against the environment map, sphere lights, quad lights and a sun of finite angular size.
+- **Layered BSDF**: a GGX clearcoat (with its own, un-normal-mapped normal) over Charlie sheen over a Lambert + GGX base with [visible-normal sampling](https://jcgt.org/published/0007/04/01/). Every lobe takes part in one mixture pdf, so MIS sees the whole material. F0 follows the IOR; sheen is energy-compensated with an albedo table integrated at start-up.
+- **Glass**: a stochastic dielectric with a roughened normal, thin-walled sheets that transmit without bending, and Beer–Lambert absorption inside volumes.
+- **Alpha cut-outs in the traversal**: alpha-tested triangles are evaluated as an any-hit test in both closest-hit and shadow rays, so leaves cast leaf-shaped shadows.
+- **Thin-lens camera** with click-to-focus (the pick ray is intersected on the CPU with the same BVH the GPU uses), and scale-independent ray offsets ([Wächter & Binder 2019](https://link.springer.com/chapter/10.1007/978-1-4842-4427-2_6)) so a 5 cm model and a 50 m floor need no tuned epsilon.
+
+**Interaction**
+
+- **Denoiser**: an SVGF-style edge-avoiding à-trous filter ([Dammertz 2010](https://jo.dreggn.org/home/2010_atrous.pdf), [Schied 2017](https://research.nvidia.com/publication/2017-07_spatiotemporal-variance-guided-filtering-real-time-reconstruction-path-traced)). The trace pass writes albedo, normal and depth guides at the first non-mirror vertex, so reflections and refractions keep their detail. Lighting is demodulated by albedo, filtered with a variance-guided luminance edge-stop, then multiplied back; the filter's reach shrinks as samples accumulate and it fades out by 1024 spp.
+- **Temporal reprojection**: when the camera moves, every sample projects its primary hit into the previous view and reuses that pixel's history if depth and normal agree. How much history a pixel may keep depends on how glossy it is, so mirrors and glass never smear.
+- **Adaptive preview**: resolution drops (never below half, upsampled with Catmull-Rom) only when frames take longer than 40 ms, and samples per frame adapt to the GPU when the camera is still.
+- **HUD** with samples per pixel, samples per second and camera paths per second, all measured.
**Acceleration**
-- **Binned SAH BVH** built in TypeScript (12 bins, leaf cost vs. split cost, median fallback for degenerate centroids) and flattened to an 8-float node layout where sibling nodes are adjacent, so an interior node stores one child index.
-- **Texture-backed geometry**: triangles, nodes and materials are uploaded as 2048-wide `RGBA32F` textures and addressed with `texelFetch` as flat arrays. Triangles are written in leaf order so leaves index the triangle texture directly, with no indirection table.
-- **Stack-based traversal** on the GPU with near-child-first ordering and a separate any-hit variant for shadow rays.
+- **Binned SAH BVH** built in TypeScript on flat typed arrays with an explicit stack (12 bins; large nodes bin all three axes), on a Web Worker for big models so the page keeps rendering. Nodes flatten to an 8-float layout where siblings are adjacent.
+- **Texture-backed geometry**: triangle positions (3 texels) and shading attributes (4 texels) live in separate 2048-wide `RGBA32F` textures, so traversal touches only what intersection needs. Triangles are written in leaf order; there is no index buffer.
**Engineering**
- Strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), ESLint, Vitest.
+- 69 unit tests: the glTF parser (GLB containers, hierarchies, strips, interleaved and sparse data, extensions), the RGBE decoder, alias tables, the environment pdf integrating to one, the sheen table against Monte Carlo, BVH invariants and BVH-vs-brute-force agreement, mesh generators, material and scene packing.
- A `#include` preprocessor for GLSL that rewrites driver error messages back to chunk names and line numbers.
-- 36 unit tests covering the math library, BVH construction invariants, BVH-vs-brute-force agreement on random soups and real meshes, mesh generators (closed-surface checks), material packing round-trips and scene packing.
- GitHub Actions for lint → typecheck → test → build, and a Pages deployment.
+## Performance
+
+Measured with `Renderer.benchmark()`, which waits for the GPU with a 1-pixel readback, at 1280 × 720 on an AMD Radeon RX 7600 (Chrome, ANGLE/D3D11), 6 bounces:
+
+| Scene | Triangles | Camera paths / s | ms per sample |
+|---|---:|---:|---:|
+| Cornell Box | 36 | 106 M | 8.7 |
+| Car Concept | 213,347 | 62 M | 14.8 |
+| Toy Car | 108,936 | 54 M | 17.2 |
+| A Beautiful Game | 1,499,072 | 49 M | 19.0 |
+| Showroom | 9,682 | 47 M | 19.6 |
+
+Showroom is slower than models with twenty times its triangles because sunlit glass and a mirror-finish gold knot keep paths alive for every bounce. One denoiser run costs 2–10 ms at this size. The 1.5 M-triangle chess set builds its BVH in about 3 s on the worker, then packs and uploads in under a second.
+
## Run it
```bash
@@ -70,7 +106,7 @@ npm run typecheck
npm run build # production bundle in dist/
```
-Requires a browser with WebGL2 and `EXT_color_buffer_float` (every current desktop browser). A discrete GPU gives tens of samples per pixel per second at 1080p; integrated graphics still converge in seconds.
+Requires a browser with WebGL2 and `EXT_color_buffer_float` (every current desktop browser).
### Controls
@@ -80,83 +116,99 @@ Requires a browser with WebGL2 and `EXT_color_buffer_float` (every current deskt
| Shift-drag / right-drag | Pan |
| Wheel | Dolly |
| Click | Set focus distance |
+| Drop files | Load a `.glb`, a `.gltf` with its files or folder, or an `.hdr` |
+| `G` | Gallery |
+| `D` | Denoiser on / off |
| `Space` | Pause / resume |
| `R` | Restart accumulation |
-| `S` | Save PNG |
+| `S` | Save PNG (as displayed, denoised and tone-mapped) |
| `H` | Hide / show panel |
-URL parameters: `?scene=2` selects a preset, `&ui=0` hides the panel.
+URL parameters: `?scene=2` selects a built-in scene, `?model=ToyCar` a gallery model, `&ui=0` hides all chrome.
## How it works
```
- TypeScript (once per scene) GPU (every frame)
- ┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────┐
- │ presets.ts ──► Scene { spheres, meshes, │ │ trace.frag │
- │ camera, sun, sky } │ │ ├─ cameraRay() thin lens + jitter │
- │ │ │ │ ├─ traceScene() BVH + spheres │
- │ ▼ │ │ ├─ NEE: sun + one emitter (MIS) │
- │ packScene() ─┬─ buildBVH() SAH, flattened │ │ ├─ sampleOpaque() / sampleDielectric│
- │ ├─ triangles (leaf order) │──►│ └─ running average ──► RGBA32F │
- │ ├─ materials (4 texels each) │ │ ▲ │ ping │
- │ └─ lights (sphere | quad) │ │ └──────┘ pong │
- │ │ │ │ display.frag │
- │ ▼ │ │ └─ exposure → ACES → sRGB → dither │
- │ createDataTexture() ×3 (2048-wide RGBA32F) │ └──────────────────────────────────────┘
- └──────────────────────────────────────────────┘
+ TypeScript (once per scene) GPU (every frame)
+ ┌──────────────────────────────────────────┐ ┌────────────────────────────────────────────┐
+ │ loaders/gltf.ts ─► meshes, materials, │ │ trace.frag (MRT, per pixel) │
+ │ loaders/hdr.ts images, HDR map │ │ ├─ cameraRay() thin lens + jitter │
+ │ │ │ │ ├─ traceScene() BVH, alpha any-hit │
+ │ ▼ │ │ ├─ NEE: env (alias) + sun + emitter, MIS │
+ │ bvh.worker.ts ─ binned SAH, flattened │──►│ ├─ layered BSDF / dielectric │
+ │ packScene() ─ geometry, attributes, │ │ └─ reproject or accumulate ─► 4 targets │
+ │ materials, texture layers│ │ variance.frag + atrous.frag ×1–5 denoise │
+ │ buildEnvSampler() ─ alias table │ │ display.frag remodulate, upsample, tone │
+ └──────────────────────────────────────────┘ └────────────────────────────────────────────┘
```
-**One sample, one pixel.** Each frame the trace shader generates a jittered camera ray, follows it for up to *N* bounces, and blends the returned radiance into the running average `(prev · n + L) / (n + 1)`. Any change to the camera, lights or integrator settings resets `n` to zero.
+**One sample, one pixel.** Each frame the trace shader generates a jittered camera ray, follows it for up to *N* bounces and blends the result into the pixel's running average `(prev · n + L) / (n + 1)`, where *n* is stored per pixel. Lighting or scene changes reset *n*; camera changes reproject it.
-**At every opaque vertex** the integrator does three things: samples a direction towards the sun, samples a point on one randomly chosen emitter, and samples the BSDF to continue the path. The first two are shadow-tested with the any-hit traversal and weighted with the power heuristic against the BSDF pdf; when the continued path later hits an emitter, the emitter's own solid-angle pdf is recomputed from the previous vertex so the two estimators combine without bias.
+**At every opaque vertex** the integrator samples the environment map, the sun and one randomly chosen emitter, shadow-tests each with the any-hit traversal and weights it with the power heuristic against the BSDF pdf; then it samples the BSDF to continue. When the continued path later escapes or hits an emitter, the matching light pdf is recomputed so the estimators combine without bias.
-**Glass is a delta lobe.** Dielectrics pick reflection or refraction by Fresnel, flag the bounce as specular, and skip light sampling; whatever they hit next is counted in full. Roughness perturbs the microfacet normal before the Fresnel decision, which is a cheap approximation to rough transmission that looks right.
+**The environment sampler** picks a cell uniformly, keeps it with the alias table's probability or jumps to its alias, then picks a uniform point inside it. The cell's probability divided by the equirect Jacobian `2π² sin θ` is the exact solid-angle pdf, which `envPdf()` recomputes for any direction a BSDF sample escapes along.
-**The BVH layout** is the detail that makes the shader simple. A node is two texels: `(min.xyz, leftFirst)` and `(max.xyz, count)`. `count > 0` means a leaf whose triangles start at `leftFirst`; otherwise the children are `leftFirst` and `leftFirst + 1`. Because triangles are reordered into leaf order at pack time, the GPU never touches an index buffer.
+**The denoiser never sees textures.** Radiance is divided by the guide albedo before filtering and multiplied back after, so blurring the illumination cannot blur a decal. Guides are taken at the first non-mirror vertex, so a car's windscreen is denoised with the albedo of the seats behind it.
+
+**The BVH layout** keeps the shader simple. A node is two texels: `(min.xyz, leftFirst)` and `(max.xyz, count)`. `count > 0` means a leaf whose triangles start at `leftFirst`; otherwise the children are `leftFirst` and `leftFirst + 1`.
## Project layout
```
src/
-├── main.ts app wiring, keyboard shortcuts, render loop
-├── renderer.ts Renderer: targets, uniforms, accumulate/present, picking
+├── main.ts app wiring: gallery, drag-and-drop, keyboard, render loop
+├── renderer.ts Renderer: targets, uniforms, accumulation, reprojection, denoiser, picking
+├── gallery.ts remote models and HDRIs
├── gl/
-│ ├── context.ts context creation, Program, RenderTarget, data textures
+│ ├── context.ts context, Program, MRT targets, data textures, texture arrays
│ └── preprocess.ts #include resolver with #line remapping
+├── loaders/
+│ ├── gltf.ts glTF 2.0 / GLB parser (DOM-free)
+│ ├── hdr.ts Radiance RGBE decoder
+│ └── model.ts files and URLs → decoded images → framed Scene
├── math/
-│ ├── vec3.ts immutable tuple vectors
-│ ├── aabb.ts bounds, SAH surface area, slab test
-│ ├── ray.ts Möller–Trumbore and sphere intersection
-│ └── bvh.ts binned SAH build, flattened layout, CPU traversal
+│ ├── vec3.ts, aabb.ts, ray.ts
+│ ├── bvh.ts binned SAH build, flattened layout, CPU traversal
+│ ├── bvh.worker.ts off-thread builds; bvh-async.ts picks the thread
+│ └── alias.ts Walker/Vose alias tables
├── scene/
-│ ├── material.ts principled-lite material + texel packing
+│ ├── material.ts principled material + texel packing
+│ ├── environment.ts HDR images, importance sampler, sky baking
+│ ├── sheen.ts sheen albedo table
│ ├── mesh.ts quad, box, floor, torus knot, transforms
│ ├── scene.ts Scene types, packScene(), light extraction, picking
-│ └── presets.ts the four demo scenes
+│ └── presets.ts the built-in scenes
├── shaders/
-│ ├── common.glsl PCG RNG, bases, sampling, MIS
-│ ├── scene.glsl texture fetches, BVH traversal, any-hit
-│ ├── lights.glsl sky, sun, sphere/quad light sampling & pdfs
-│ ├── bsdf.glsl GGX/VNDF, Fresnel, opaque & dielectric sampling
-│ ├── trace.frag the integrator
-│ └── display.frag tone mapping
-└── ui/ orbit controls, panel, DOM helper, styles
-test/ Vitest suites (math, BVH, meshes, scene packing, preprocessor)
+│ ├── common.glsl PCG RNG, bases, sampling, MIS, ray offsets
+│ ├── scene.glsl geometry and material fetches, BVH traversal, alpha any-hit
+│ ├── lights.glsl environment, sun, sphere/quad lights and their pdfs
+│ ├── bsdf.glsl clearcoat, sheen, GGX/VNDF, dielectrics
+│ ├── trace.frag the integrator and temporal reprojection
+│ ├── variance.frag, atrous.frag, denoise.glsl the denoiser
+│ ├── display.frag remodulation, upsampling, tone mapping
+│ └── resample.frag texture-array layer resampling
+└── ui/ panel, HUD, gallery, drag-and-drop, orbit controls, DOM helper, styles
+test/ Vitest suites
```
## Design notes
-- **Why uniform arrays for spheres but textures for triangles?** Spheres are few and analytic; a uniform array is the fastest path and keeps them out of the BVH. Triangles can number in the hundreds of thousands, which only fits in textures.
-- **Why not WebGPU?** Compute shaders would allow wavefront path tracing and a cleaner traversal, but WebGL2 runs everywhere today including iOS, and a fragment-shader megakernel is a better teaching artefact: the entire integrator is one readable file.
-- **Why clamp fireflies at all?** Caustic paths (diffuse → glass → light) have unbounded variance in a unidirectional tracer. The clamp only affects indirect bounces and is exposed in the UI, so you can see exactly what it costs.
-- **Shading normals** on the knot are guarded against facing away from the geometric normal, which otherwise produces black silhouettes on smooth meshes.
+- **Why an alias table rather than a CDF?** A two-level CDF needs a binary search per light sample, about 20 dependent texture fetches on a 1024 × 512 grid; the alias table needs one. Both give the same exact pdf.
+- **Why are mirrors excluded from reprojection?** A reflection does not move with the surface that shows it, so reprojected mirror radiance smears. Pixels keep history in proportion to how rough and how non-metallic their surface is; glass and polished metal start from fresh samples each frame and lean on the denoiser instead.
+- **Why texture arrays?** WebGL2 has no bindless textures. Two arrays (sRGB and linear) at one shared size keep the sampler count fixed whatever the model; resampling happens once on the GPU.
+- **Why not WebGPU?** Compute shaders would allow wavefront path tracing, but WebGL2 runs everywhere today, and a fragment-shader megakernel keeps the whole integrator in one readable file.
+- **Why clamp fireflies at all?** Caustic paths (diffuse → glass → light) have unbounded variance in a unidirectional tracer. The clamp only affects indirect bounces and is exposed in the UI.
## Roadmap
-- Triangle-mesh loading (glTF) and textured materials
-- Environment maps with importance sampling
+- Next-event estimation for emissive triangles (glTF emitters are found by BSDF sampling only)
+- `KHR_lights_punctual`, Draco / meshopt geometry and KTX2 textures
- Spectral dispersion for dielectrics
-- A temporal denoiser for the first few samples
+- OpenEXR environments
+
+## Credits
+
+Gallery models: [Khronos glTF sample assets](https://github.com/KhronosGroup/glTF-Sample-Assets); Toy Car (Guido Odendahl, Eric Chadwick, CC0), Dragon (Stanford CG Lab and Morgan McGuire; glTF CC0), Sheen Chair and Velvet Sofa (Wayfair; CC0 and CC BY), Damaged Helmet (theblueturtle_, CC BY-NC; ctxwing, CC BY), Car Concept and Chronograph Watch (Darmstadt Graphics Group, CC BY), Antique Camera (UX3D, CC0), A Beautiful Game (ASWF, Ed Mackey, CC BY). HDRIs: [Poly Haven](https://polyhaven.com/hdris), CC0.
## License
diff --git a/docs/screenshots/chess.jpg b/docs/screenshots/chess.jpg
new file mode 100644
index 0000000..a303ae6
Binary files /dev/null and b/docs/screenshots/chess.jpg differ
diff --git a/docs/screenshots/denoiser.jpg b/docs/screenshots/denoiser.jpg
new file mode 100644
index 0000000..d1de20e
Binary files /dev/null and b/docs/screenshots/denoiser.jpg differ
diff --git a/docs/screenshots/dragon.jpg b/docs/screenshots/dragon.jpg
new file mode 100644
index 0000000..b29ecd9
Binary files /dev/null and b/docs/screenshots/dragon.jpg differ
diff --git a/docs/screenshots/helmet.jpg b/docs/screenshots/helmet.jpg
new file mode 100644
index 0000000..86a6085
Binary files /dev/null and b/docs/screenshots/helmet.jpg differ
diff --git a/docs/screenshots/hero.jpg b/docs/screenshots/hero.jpg
new file mode 100644
index 0000000..59b3c92
Binary files /dev/null and b/docs/screenshots/hero.jpg differ
diff --git a/docs/screenshots/hero.png b/docs/screenshots/hero.png
deleted file mode 100644
index 80b7c1d..0000000
Binary files a/docs/screenshots/hero.png and /dev/null differ
diff --git a/docs/screenshots/sheen-chair.jpg b/docs/screenshots/sheen-chair.jpg
new file mode 100644
index 0000000..28017b4
Binary files /dev/null and b/docs/screenshots/sheen-chair.jpg differ
diff --git a/docs/screenshots/toycar.jpg b/docs/screenshots/toycar.jpg
new file mode 100644
index 0000000..6387450
Binary files /dev/null and b/docs/screenshots/toycar.jpg differ
diff --git a/public/thumbs/cornell.jpg b/public/thumbs/cornell.jpg
new file mode 100644
index 0000000..d5d17f3
Binary files /dev/null and b/public/thumbs/cornell.jpg differ
diff --git a/public/thumbs/material.jpg b/public/thumbs/material.jpg
new file mode 100644
index 0000000..e5cef12
Binary files /dev/null and b/public/thumbs/material.jpg differ
diff --git a/public/thumbs/nocturne.jpg b/public/thumbs/nocturne.jpg
new file mode 100644
index 0000000..906bf06
Binary files /dev/null and b/public/thumbs/nocturne.jpg differ
diff --git a/public/thumbs/showroom.jpg b/public/thumbs/showroom.jpg
new file mode 100644
index 0000000..e56230a
Binary files /dev/null and b/public/thumbs/showroom.jpg differ
diff --git a/src/gallery.ts b/src/gallery.ts
new file mode 100644
index 0000000..4b9ece1
--- /dev/null
+++ b/src/gallery.ts
@@ -0,0 +1,91 @@
+/**
+ * Remote content for the gallery: Khronos glTF sample models and Poly Haven
+ * HDRIs, fetched on demand (both hosts send CORS headers). Nothing here is
+ * bundled, so the app stays small and works offline with the built-in scenes.
+ */
+
+export interface HDRIEntry {
+ id: string;
+ name: string;
+}
+
+/** CC0 HDRIs from polyhaven.com. */
+export const HDRIS: readonly HDRIEntry[] = [
+ { id: 'studio_small_08', name: 'Studio' },
+ { id: 'brown_photostudio_02', name: 'Photo studio' },
+ { id: 'venice_sunset', name: 'Venice sunset' },
+ { id: 'royal_esplanade', name: 'Royal esplanade' },
+ { id: 'kiara_1_dawn', name: 'Kiara dawn' },
+ { id: 'sunflowers_puresky', name: 'Sunflowers sky' },
+ { id: 'aerodynamics_workshop', name: 'Workshop' },
+ { id: 'lebombo', name: 'Lebombo interior' },
+];
+
+export const hdriURL = (id: string): string => `https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/${id}_2k.hdr`;
+
+export interface GalleryModel {
+ id: string;
+ name: string;
+ /** What the model exercises. */
+ features: string;
+ credit: string;
+ /** Download size of the .glb, for the card. */
+ megabytes: number;
+ hdri: string;
+ rotation?: number;
+ exposure?: number;
+ azimuth?: number;
+ elevation?: number;
+ distance?: number;
+ groundColor?: readonly [number, number, number];
+ groundRoughness?: number;
+ thumbnail: string;
+}
+
+const KHRONOS = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models';
+
+export const modelURL = (id: string): string => `${KHRONOS}/${id}/glTF-Binary/${id}.glb`;
+const shot = (id: string, file = 'screenshot.jpg') => `${KHRONOS}/${id}/screenshot/${file}`;
+
+export const MODELS: readonly GalleryModel[] = [
+ {
+ id: 'ToyCar', name: 'Toy Car', features: 'Clearcoat · sheen · transmission',
+ credit: 'Guido Odendahl, Eric Chadwick · CC0', megabytes: 5.4, hdri: 'studio_small_08', thumbnail: shot('ToyCar'),
+ azimuth: 24, elevation: 12, distance: 0.8,
+ },
+ {
+ id: 'DragonAttenuation', name: 'Dragon', features: 'Volumetric absorption · refraction',
+ credit: 'Stanford CG Lab, Morgan McGuire · glTF CC0', megabytes: 6.4, hdri: 'venice_sunset', thumbnail: shot('DragonAttenuation'),
+ azimuth: 10, elevation: 8, distance: 0.7,
+ },
+ {
+ id: 'SheenChair', name: 'Sheen Chair', features: 'Fabric sheen · texture transforms',
+ credit: 'Wayfair · CC0', megabytes: 4.1, hdri: 'brown_photostudio_02', thumbnail: shot('SheenChair'),
+ },
+ {
+ id: 'DamagedHelmet', name: 'Damaged Helmet', features: 'PBR maps · normal mapping · emissive',
+ credit: 'theblueturtle_ (CC BY-NC), ctxwing (CC BY)', megabytes: 3.8, hdri: 'royal_esplanade', thumbnail: shot('DamagedHelmet', 'screenshot.png'),
+ },
+ {
+ id: 'CarConcept', name: 'Car Concept', features: 'Clearcoat car paint · glass',
+ credit: 'Darmstadt Graphics Group · CC BY', megabytes: 11.8, hdri: 'kiara_1_dawn', thumbnail: shot('CarConcept'),
+ azimuth: 38, elevation: 6, distance: 0.95, groundColor: [0.05, 0.05, 0.055], groundRoughness: 0.35,
+ },
+ {
+ id: 'ChronographWatch', name: 'Chronograph Watch', features: 'Transmission · fine detail',
+ credit: 'Darmstadt Graphics Group · CC BY', megabytes: 7.4, hdri: 'studio_small_08', thumbnail: shot('ChronographWatch'),
+ },
+ {
+ id: 'AntiqueCamera', name: 'Antique Camera', features: 'Metal-rough textures',
+ credit: 'UX3D · CC0', megabytes: 17.5, hdri: 'aerodynamics_workshop', thumbnail: shot('AntiqueCamera'),
+ },
+ {
+ id: 'GlamVelvetSofa', name: 'Velvet Sofa', features: 'Sheen velvet',
+ credit: 'Wayfair · CC BY', megabytes: 3.1, hdri: 'lebombo', thumbnail: shot('GlamVelvetSofa'),
+ },
+ {
+ id: 'ABeautifulGame', name: 'A Beautiful Game', features: '1.5M triangles · glass pieces',
+ credit: 'ASWF, Ed Mackey · CC BY', megabytes: 43, hdri: 'brown_photostudio_02', thumbnail: shot('ABeautifulGame'),
+ azimuth: 20, elevation: 28, distance: 0.75, groundColor: [0.08, 0.07, 0.065], groundRoughness: 0.5,
+ },
+];
diff --git a/src/gl/context.ts b/src/gl/context.ts
index 274fe60..11a61a6 100644
--- a/src/gl/context.ts
+++ b/src/gl/context.ts
@@ -5,6 +5,9 @@ export class GLError extends Error {}
export interface GLCapabilities {
gl: WebGL2RenderingContext;
maxTextureSize: number;
+ maxArrayLayers: number;
+ /** Bilinear filtering of RGB(A)32F textures (OES_texture_float_linear). */
+ floatLinear: boolean;
renderer: string;
}
@@ -27,7 +30,13 @@ export function createContext(canvas: HTMLCanvasElement): GLCapabilities {
}
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : 'unknown GPU';
- return { gl, maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number, renderer };
+ return {
+ gl,
+ maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
+ maxArrayLayers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number,
+ floatLinear: gl.getExtension('OES_texture_float_linear') !== null,
+ renderer,
+ };
}
function compileShader(gl: WebGL2RenderingContext, type: number, source: string, sources: ShaderSources): WebGLShader {
@@ -90,6 +99,7 @@ export class Program {
i1(name: string, v: number) { this.gl.uniform1i(this.loc(name), v); }
u1(name: string, v: number) { this.gl.uniform1ui(this.loc(name), v); }
f2(name: string, x: number, y: number) { this.gl.uniform2f(this.loc(name), x, y); }
+ i2(name: string, x: number, y: number) { this.gl.uniform2i(this.loc(name), x, y); }
f3(name: string, v: ArrayLike) { this.gl.uniform3f(this.loc(name), v[0]!, v[1]!, v[2]!); }
f4v(name: string, v: Float32Array) { this.gl.uniform4fv(this.loc(name), v); }
i1v(name: string, v: Int32Array) { this.gl.uniform1iv(this.loc(name), v); }
@@ -120,51 +130,145 @@ export function createDataTexture(gl: WebGL2RenderingContext, data: Float32Array
return tex;
}
-/** A float render target. */
+export interface TextureOptions {
+ internalFormat: number;
+ format: number;
+ type: number;
+ filter?: number;
+ wrapS?: number;
+ wrapT?: number;
+}
+
+/** Allocates a 2D texture, optionally filling it. */
+export function createTexture(
+ gl: WebGL2RenderingContext,
+ width: number,
+ height: number,
+ opts: TextureOptions,
+ data: ArrayBufferView | null = null,
+): WebGLTexture {
+ const tex = gl.createTexture();
+ if (!tex) throw new GLError('Failed to allocate texture');
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ const filter = opts.filter ?? gl.NEAREST;
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, opts.wrapS ?? gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, opts.wrapT ?? gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(gl.TEXTURE_2D, 0, opts.internalFormat, width, height, 0, opts.format, opts.type, data);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ return tex;
+}
+
+/** A float render target with one or more colour attachments (MRT). */
export class RenderTarget {
- readonly texture: WebGLTexture;
+ readonly textures: WebGLTexture[];
readonly framebuffer: WebGLFramebuffer;
constructor(
private readonly gl: WebGL2RenderingContext,
readonly width: number,
readonly height: number,
+ formats: number[] = [gl.RGBA32F],
) {
- const tex = gl.createTexture();
const fb = gl.createFramebuffer();
- if (!tex || !fb) throw new GLError('Failed to allocate render target');
- gl.bindTexture(gl.TEXTURE_2D, tex);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
+ if (!fb) throw new GLError('Failed to allocate render target');
+ this.textures = formats.map((f) =>
+ createTexture(gl, width, height, {
+ internalFormat: f,
+ format: f === gl.R32F ? gl.RED : gl.RGBA,
+ type: f === gl.RGBA16F ? gl.HALF_FLOAT : gl.FLOAT,
+ }),
+ );
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
+ this.textures.forEach((tex, i) => gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0));
+ gl.drawBuffers(this.textures.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
throw new GLError(`Float framebuffer incomplete (status 0x${status.toString(16)})`);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
- // Never leave the attachment bound on a sampler unit: that is a feedback loop waiting to happen.
- gl.bindTexture(gl.TEXTURE_2D, null);
- this.texture = tex;
this.framebuffer = fb;
}
- bind(): void {
+ get texture(): WebGLTexture {
+ return this.textures[0]!;
+ }
+
+ /** Binds for drawing into the bottom-left `width × height` region (the whole target by default). */
+ bind(width = this.width, height = this.height): void {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.framebuffer);
- this.gl.viewport(0, 0, this.width, this.height);
+ this.gl.viewport(0, 0, width, height);
}
clear(): void {
this.bind();
- this.gl.clearColor(0, 0, 0, 1);
+ this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
dispose(): void {
- this.gl.deleteTexture(this.texture);
+ for (const t of this.textures) this.gl.deleteTexture(t);
this.gl.deleteFramebuffer(this.framebuffer);
}
}
+
+/**
+ * Builds a `size × size × layers` texture array by drawing each image into its
+ * layer through `resample` (a fullscreen program sampling unit 0). Images are
+ * uploaded mipmapped so downscaling filters properly. sRGB arrays decode on
+ * fetch and re-encode on write, so filtering happens in linear space.
+ */
+export function createTextureArray(
+ gl: WebGL2RenderingContext,
+ images: readonly TexImageSource[],
+ size: number,
+ srgb: boolean,
+ resample: Program,
+ vao: WebGLVertexArrayObject,
+): WebGLTexture {
+ const internal = srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8;
+ const layers = Math.max(1, images.length);
+ const array = gl.createTexture();
+ const fb = gl.createFramebuffer();
+ if (!array || !fb) throw new GLError('Failed to allocate texture array');
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, array);
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, internal, size, size, layers);
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
+
+ resample.use();
+ resample.i1('uSource', 0);
+ resample.f1('uSize', size);
+ gl.bindVertexArray(vao);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
+ gl.drawBuffers([gl.COLOR_ATTACHMENT0]);
+ gl.viewport(0, 0, size, size);
+ gl.activeTexture(gl.TEXTURE0);
+ images.forEach((img, layer) => {
+ const src = gl.createTexture();
+ if (!src) throw new GLError('Failed to allocate texture');
+ gl.bindTexture(gl.TEXTURE_2D, src);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
+ gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
+ gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, img);
+ gl.generateMipmap(gl.TEXTURE_2D);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.framebufferTextureLayer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, array, 0, layer);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ gl.deleteTexture(src);
+ });
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ gl.deleteFramebuffer(fb);
+ return array;
+}
diff --git a/src/loaders/gltf.ts b/src/loaders/gltf.ts
new file mode 100644
index 0000000..f0a0c37
--- /dev/null
+++ b/src/loaders/gltf.ts
@@ -0,0 +1,502 @@
+import type { Vec3 } from '../math/vec3';
+import { IDENTITY_UV, material, type AlphaMode, type Material, type UVTransform } from '../scene/material';
+import type { Mesh } from '../scene/mesh';
+
+/**
+ * glTF 2.0 / GLB parser that produces Lucent meshes and materials directly.
+ * It needs no DOM: images come back as encoded bytes for the caller to decode,
+ * so everything here runs (and is tested) under Node.
+ */
+
+export interface GLTFImage {
+ data: Uint8Array;
+ mimeType: string;
+ name: string;
+}
+
+export interface GLTFAsset {
+ /** One entry per primitive, already in world space. `material` indexes `materials`. */
+ meshes: { mesh: Mesh; material: number }[];
+ /** Map fields (`baseColorMap` …) index `images`. */
+ materials: Material[];
+ /** Only images that some material samples. */
+ images: GLTFImage[];
+ bounds: { min: Vec3; max: Vec3 };
+ triangleCount: number;
+ warnings: string[];
+}
+
+/** Fetches an external resource referenced by a relative URI. */
+export type ResourceResolver = (uri: string) => Promise;
+
+/* eslint-disable @typescript-eslint/no-explicit-any -- glTF JSON is validated field by field below. */
+type Json = any;
+
+const GLB_MAGIC = 0x46546c67;
+const CHUNK_JSON = 0x4e4f534a;
+const CHUNK_BIN = 0x004e4942;
+
+/** Extensions whose absence would break geometry or textures; refuse rather than render garbage. */
+const UNSUPPORTED_REQUIRED = new Set([
+ 'KHR_draco_mesh_compression',
+ 'EXT_meshopt_compression',
+ 'KHR_texture_basisu',
+ 'MSFT_texture_dds',
+ 'EXT_mesh_gpu_instancing',
+]);
+
+const HANDLED_MATERIAL_EXTENSIONS = new Set([
+ 'KHR_materials_emissive_strength',
+ 'KHR_materials_transmission',
+ 'KHR_materials_ior',
+ 'KHR_materials_volume',
+ 'KHR_materials_clearcoat',
+ 'KHR_materials_sheen',
+ 'KHR_materials_unlit',
+]);
+
+export function isGLB(buffer: ArrayBuffer): boolean {
+ return buffer.byteLength >= 12 && new DataView(buffer).getUint32(0, true) === GLB_MAGIC;
+}
+
+export function parseGLB(buffer: ArrayBuffer): { json: Json; bin: Uint8Array | null } {
+ const view = new DataView(buffer);
+ if (!isGLB(buffer)) throw new Error('not a GLB file (bad magic)');
+ const version = view.getUint32(4, true);
+ if (version !== 2) throw new Error(`GLB version ${version} is not supported; only glTF 2.0`);
+ const length = Math.min(view.getUint32(8, true), buffer.byteLength);
+ let offset = 12;
+ let json: Json = null;
+ let bin: Uint8Array | null = null;
+ while (offset + 8 <= length) {
+ const chunkLength = view.getUint32(offset, true);
+ const chunkType = view.getUint32(offset + 4, true);
+ const start = offset + 8;
+ if (start + chunkLength > length) throw new Error('GLB chunk runs past the end of the file');
+ if (chunkType === CHUNK_JSON) json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, start, chunkLength)));
+ else if (chunkType === CHUNK_BIN && bin === null) bin = new Uint8Array(buffer, start, chunkLength);
+ offset = start + chunkLength + ((4 - (chunkLength % 4)) % 4);
+ }
+ if (!json) throw new Error('GLB has no JSON chunk');
+ return { json, bin };
+}
+
+function decodeDataUri(uri: string): ArrayBuffer {
+ const comma = uri.indexOf(',');
+ if (comma < 0) throw new Error('malformed data URI');
+ const meta = uri.slice(5, comma);
+ const payload = uri.slice(comma + 1);
+ if (!meta.endsWith(';base64')) return new TextEncoder().encode(decodeURIComponent(payload)).buffer as ArrayBuffer;
+ const bin = atob(payload);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out.buffer;
+}
+
+const mimeFromDataUri = (uri: string): string => /^data:([^;,]+)/.exec(uri)?.[1] ?? '';
+
+function mimeFromName(name: string): string {
+ const ext = name.split(/[?#]/)[0]!.split('.').pop()?.toLowerCase();
+ if (ext === 'png') return 'image/png';
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
+ if (ext === 'webp') return 'image/webp';
+ if (ext === 'ktx2') return 'image/ktx2';
+ return 'application/octet-stream';
+}
+
+const COMPONENTS: Record = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
+const COMPONENT_BYTES: Record = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
+
+type Reader = (dv: DataView, offset: number) => number;
+function componentReader(type: number, normalized: boolean): Reader {
+ switch (type) {
+ case 5126: return (dv, o) => dv.getFloat32(o, true);
+ case 5125: return (dv, o) => dv.getUint32(o, true);
+ case 5123: return normalized ? (dv, o) => dv.getUint16(o, true) / 65535 : (dv, o) => dv.getUint16(o, true);
+ case 5122: return normalized ? (dv, o) => Math.max(dv.getInt16(o, true) / 32767, -1) : (dv, o) => dv.getInt16(o, true);
+ case 5121: return normalized ? (dv, o) => dv.getUint8(o) / 255 : (dv, o) => dv.getUint8(o);
+ case 5120: return normalized ? (dv, o) => Math.max(dv.getInt8(o) / 127, -1) : (dv, o) => dv.getInt8(o);
+ default: throw new Error(`unknown accessor componentType ${type}`);
+ }
+}
+
+type Mat4 = Float64Array;
+
+const identity = (): Mat4 => {
+ const m = new Float64Array(16);
+ m[0] = m[5] = m[10] = m[15] = 1;
+ return m;
+};
+
+function multiply(a: Mat4, b: Mat4): Mat4 {
+ const out = new Float64Array(16);
+ for (let c = 0; c < 4; c++) {
+ for (let r = 0; r < 4; r++) {
+ let s = 0;
+ for (let k = 0; k < 4; k++) s += a[k * 4 + r]! * b[c * 4 + k]!;
+ out[c * 4 + r] = s;
+ }
+ }
+ return out;
+}
+
+/** Column-major T · R · S, as glTF specifies for node transforms. */
+export function composeTRS(t: readonly number[], q: readonly number[], s: readonly number[]): Mat4 {
+ const [x, y, z, w] = [q[0]!, q[1]!, q[2]!, q[3]!];
+ const m = new Float64Array(16);
+ m[0] = (1 - 2 * (y * y + z * z)) * s[0]!;
+ m[1] = 2 * (x * y + z * w) * s[0]!;
+ m[2] = 2 * (x * z - y * w) * s[0]!;
+ m[4] = 2 * (x * y - z * w) * s[1]!;
+ m[5] = (1 - 2 * (x * x + z * z)) * s[1]!;
+ m[6] = 2 * (y * z + x * w) * s[1]!;
+ m[8] = 2 * (x * z + y * w) * s[2]!;
+ m[9] = 2 * (y * z - x * w) * s[2]!;
+ m[10] = (1 - 2 * (x * x + y * y)) * s[2]!;
+ m[12] = t[0]!;
+ m[13] = t[1]!;
+ m[14] = t[2]!;
+ m[15] = 1;
+ return m;
+}
+
+/** Inverse-transpose of the upper 3×3 (row-major result) and its determinant. */
+function normalMatrix(m: Mat4): { n: number[]; det: number } {
+ const a = m[0]!, b = m[4]!, c = m[8]!;
+ const d = m[1]!, e = m[5]!, f = m[9]!;
+ const g = m[2]!, h = m[6]!, i = m[10]!;
+ const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
+ const det = a * A + b * B + c * C;
+ const inv = det === 0 ? 0 : 1 / det;
+ // Cofactor matrix / det == inverse-transpose.
+ const n = [
+ A * inv, B * inv, C * inv,
+ -(b * i - c * h) * inv, (a * i - c * g) * inv, -(a * h - b * g) * inv,
+ (b * f - c * e) * inv, -(a * f - c * d) * inv, (a * e - b * d) * inv,
+ ];
+ return { n, det };
+}
+
+/** KHR_texture_transform: T(offset) · R(rotation) · S(scale) as a row-major 2×3 matrix. */
+export function textureTransform(ext: Json): UVTransform {
+ if (!ext) return IDENTITY_UV;
+ const [ox, oy] = ext.offset ?? [0, 0];
+ const [sx, sy] = ext.scale ?? [1, 1];
+ const r = ext.rotation ?? 0;
+ const c = Math.cos(r);
+ const s = Math.sin(r);
+ return [c * sx, -s * sy, ox, s * sx, c * sy, oy];
+}
+
+export async function loadGLTF(source: ArrayBuffer, resolve: ResourceResolver): Promise {
+ let json: Json;
+ let glbBin: Uint8Array | null = null;
+ if (isGLB(source)) ({ json, bin: glbBin } = parseGLB(source));
+ else json = JSON.parse(new TextDecoder().decode(source));
+
+ const version = String(json?.asset?.version ?? '');
+ if (!version.startsWith('2')) throw new Error(`glTF version "${version || 'unknown'}" is not supported; only 2.0`);
+ const warnings: string[] = [];
+ const warn = (msg: string) => {
+ if (!warnings.includes(msg)) warnings.push(msg);
+ };
+
+ for (const ext of (json.extensionsRequired ?? []) as string[]) {
+ if (UNSUPPORTED_REQUIRED.has(ext)) throw new Error(`this file requires ${ext}, which Lucent does not support`);
+ }
+
+ const buffers: Uint8Array[] = await Promise.all(
+ ((json.buffers ?? []) as Json[]).map(async (b, i): Promise => {
+ if (b.uri === undefined) {
+ if (i === 0 && glbBin) return glbBin;
+ throw new Error(`buffer ${i} has no uri and there is no GLB binary chunk`);
+ }
+ const data = String(b.uri).startsWith('data:') ? decodeDataUri(b.uri) : await resolve(b.uri);
+ return new Uint8Array(data);
+ }),
+ );
+
+ const bufferViewBytes = (index: number): { bytes: Uint8Array; stride: number } => {
+ const view = json.bufferViews?.[index];
+ if (!view) throw new Error(`missing bufferView ${index}`);
+ const buf = buffers[view.buffer];
+ if (!buf) throw new Error(`bufferView ${index} references missing buffer ${view.buffer}`);
+ const start = view.byteOffset ?? 0;
+ if (start + view.byteLength > buf.byteLength) throw new Error(`bufferView ${index} runs past its buffer`);
+ return { bytes: buf.subarray(start, start + view.byteLength), stride: view.byteStride ?? 0 };
+ };
+
+ const accessorCache = new Map();
+ const readAccessor = (index: number): { data: Float32Array; size: number; count: number } => {
+ const cached = accessorCache.get(index);
+ if (cached) return cached;
+ const acc = json.accessors?.[index];
+ if (!acc) throw new Error(`missing accessor ${index}`);
+ const size = COMPONENTS[acc.type];
+ const compBytes = COMPONENT_BYTES[acc.componentType];
+ if (!size || !compBytes) throw new Error(`accessor ${index} has unsupported type ${acc.type}/${acc.componentType}`);
+ const count: number = acc.count;
+ const out = new Float32Array(count * size);
+ const read = componentReader(acc.componentType, acc.normalized === true);
+
+ if (acc.bufferView !== undefined) {
+ const { bytes, stride } = bufferViewBytes(acc.bufferView);
+ const elementBytes = size * compBytes;
+ const step = stride || elementBytes;
+ const base = acc.byteOffset ?? 0;
+ if (count > 0 && base + step * (count - 1) + elementBytes > bytes.byteLength) {
+ throw new Error(`accessor ${index} reads past its bufferView`);
+ }
+ const absolute = bytes.byteOffset + base;
+ if (acc.componentType === 5126 && step === elementBytes && absolute % 4 === 0) {
+ out.set(new Float32Array(bytes.buffer, absolute, count * size));
+ } else {
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ for (let i = 0; i < count; i++) {
+ const o = base + i * step;
+ for (let c = 0; c < size; c++) out[i * size + c] = read(dv, o + c * compBytes);
+ }
+ }
+ }
+
+ if (acc.sparse) {
+ const { count: n, indices, values } = acc.sparse;
+ const idx = bufferViewBytes(indices.bufferView);
+ const val = bufferViewBytes(values.bufferView);
+ const idxBytes = COMPONENT_BYTES[indices.componentType]!;
+ const readIdx = componentReader(indices.componentType, false);
+ const idv = new DataView(idx.bytes.buffer, idx.bytes.byteOffset, idx.bytes.byteLength);
+ const vdv = new DataView(val.bytes.buffer, val.bytes.byteOffset, val.bytes.byteLength);
+ for (let k = 0; k < n; k++) {
+ const target = readIdx(idv, (indices.byteOffset ?? 0) + k * idxBytes);
+ if (target >= count) throw new Error(`sparse index ${target} out of range in accessor ${index}`);
+ for (let c = 0; c < size; c++) {
+ out[target * size + c] = read(vdv, (values.byteOffset ?? 0) + (k * size + c) * compBytes);
+ }
+ }
+ }
+ const result = { data: out, size, count };
+ accessorCache.set(index, result);
+ return result;
+ };
+
+ // Images are resolved lazily so that unused ones are never fetched.
+ const imageRemap = new Map();
+ const imageLoads: Promise[] = [];
+ const useImage = (imageIndex: number): number => {
+ let slot = imageRemap.get(imageIndex);
+ if (slot !== undefined) return slot;
+ const img = json.images?.[imageIndex];
+ if (!img) return -1;
+ slot = imageLoads.length;
+ imageRemap.set(imageIndex, slot);
+ const name = String(img.name ?? img.uri ?? `image ${imageIndex}`);
+ imageLoads.push((async (): Promise => {
+ if (img.bufferView !== undefined) {
+ const { bytes } = bufferViewBytes(img.bufferView);
+ return { data: bytes, mimeType: img.mimeType ?? 'image/png', name };
+ }
+ const uri = String(img.uri);
+ if (uri.startsWith('data:')) return { data: new Uint8Array(decodeDataUri(uri)), mimeType: mimeFromDataUri(uri), name };
+ return { data: new Uint8Array(await resolve(uri)), mimeType: img.mimeType ?? mimeFromName(uri), name };
+ })());
+ return slot;
+ };
+
+ const textureSlot = (info: Json): number => {
+ if (!info || info.index === undefined) return -1;
+ const tex = json.textures?.[info.index];
+ if (!tex) return -1;
+ if ((info.texCoord ?? 0) !== 0) warn('textures on TEXCOORD_1 or higher are sampled with TEXCOORD_0');
+ const source = tex.extensions?.EXT_texture_webp?.source ?? tex.source;
+ if (source === undefined) {
+ if (tex.extensions?.KHR_texture_basisu) warn('KTX2/Basis textures are not supported and were skipped');
+ return -1;
+ }
+ const mime = json.images?.[source]?.mimeType ?? mimeFromName(String(json.images?.[source]?.uri ?? ''));
+ if (mime === 'image/ktx2') {
+ warn('KTX2/Basis textures are not supported and were skipped');
+ return -1;
+ }
+ return useImage(source);
+ };
+
+ const convertMaterial = (m: Json): Material => {
+ const pbr = m.pbrMetallicRoughness ?? {};
+ const ext = m.extensions ?? {};
+ for (const name of Object.keys(ext)) {
+ if (!HANDLED_MATERIAL_EXTENSIONS.has(name)) warn(`material extension ${name} is ignored`);
+ }
+ const bc: number[] = pbr.baseColorFactor ?? [1, 1, 1, 1];
+ const emissiveStrength = ext.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
+ const ef: number[] = m.emissiveFactor ?? [0, 0, 0];
+ const transformSource = [pbr.baseColorTexture, pbr.metallicRoughnessTexture, m.normalTexture, m.emissiveTexture]
+ .find((t) => t?.extensions?.KHR_texture_transform);
+ const volume = ext.KHR_materials_volume;
+ const alphaMode: AlphaMode = m.alphaMode === 'MASK' ? 'mask' : m.alphaMode === 'BLEND' ? 'blend' : 'opaque';
+ const out = material({
+ albedo: [bc[0]!, bc[1]!, bc[2]!],
+ alpha: bc[3] ?? 1,
+ alphaMode,
+ alphaCutoff: m.alphaCutoff ?? 0.5,
+ metallic: pbr.metallicFactor ?? 1,
+ roughness: pbr.roughnessFactor ?? 1,
+ baseColorMap: textureSlot(pbr.baseColorTexture),
+ metalRoughMap: textureSlot(pbr.metallicRoughnessTexture),
+ normalMap: textureSlot(m.normalTexture),
+ normalScale: m.normalTexture?.scale ?? 1,
+ emissiveMap: textureSlot(m.emissiveTexture),
+ emission: [ef[0]! * emissiveStrength, ef[1]! * emissiveStrength, ef[2]! * emissiveStrength],
+ transmission: ext.KHR_materials_transmission?.transmissionFactor ?? 0,
+ ior: ext.KHR_materials_ior?.ior ?? 1.5,
+ thinWalled: !volume || (volume.thicknessFactor ?? 0) === 0,
+ attenuationColor: volume?.attenuationColor ?? [1, 1, 1],
+ attenuationDistance: volume?.attenuationDistance ?? Infinity,
+ clearcoat: ext.KHR_materials_clearcoat?.clearcoatFactor ?? 0,
+ clearcoatRoughness: ext.KHR_materials_clearcoat?.clearcoatRoughnessFactor ?? 0,
+ sheenColor: ext.KHR_materials_sheen?.sheenColorFactor ?? [0, 0, 0],
+ sheenRoughness: ext.KHR_materials_sheen?.sheenRoughnessFactor ?? 0,
+ uvTransform: textureTransform(transformSource?.extensions?.KHR_texture_transform),
+ });
+ if (ext.KHR_materials_unlit) {
+ return { ...out, emission: out.albedo, emissiveMap: out.baseColorMap, albedo: [0, 0, 0], metallic: 0 };
+ }
+ return out;
+ };
+
+ const materials: Material[] = ((json.materials ?? []) as Json[]).map(convertMaterial);
+ let defaultMaterial = -1;
+ const materialFor = (index: number | undefined): number => {
+ if (index !== undefined && materials[index]) return index;
+ if (defaultMaterial < 0) {
+ // The glTF default material: white, fully metallic, fully rough.
+ defaultMaterial = materials.length;
+ materials.push(material({ albedo: [1, 1, 1], metallic: 1, roughness: 1 }));
+ }
+ return defaultMaterial;
+ };
+
+ const meshes: GLTFAsset['meshes'] = [];
+ const min: [number, number, number] = [Infinity, Infinity, Infinity];
+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
+ let triangleCount = 0;
+
+ const emitPrimitive = (prim: Json, world: Mat4): void => {
+ const mode = prim.mode ?? 4;
+ if (mode !== 4 && mode !== 5 && mode !== 6) {
+ warn('point and line primitives are skipped');
+ return;
+ }
+ if (prim.extensions?.KHR_draco_mesh_compression) throw new Error('Draco-compressed meshes are not supported');
+ if (prim.attributes?.POSITION === undefined) return;
+ const pos = readAccessor(prim.attributes.POSITION);
+ const nrm = prim.attributes.NORMAL !== undefined ? readAccessor(prim.attributes.NORMAL) : null;
+ const uv = prim.attributes.TEXCOORD_0 !== undefined ? readAccessor(prim.attributes.TEXCOORD_0) : null;
+ const vertexCount = pos.count;
+ const idx = prim.indices !== undefined ? readAccessor(prim.indices).data : null;
+ const n = idx ? idx.length : vertexCount;
+ const vertex = (k: number): number => (idx ? idx[k]! : k);
+
+ const tris: number[] = [];
+ if (mode === 4) {
+ for (let k = 0; k + 2 < n; k += 3) tris.push(vertex(k), vertex(k + 1), vertex(k + 2));
+ } else if (mode === 5) {
+ for (let k = 0; k + 2 < n; k++) {
+ if (k % 2 === 0) tris.push(vertex(k), vertex(k + 1), vertex(k + 2));
+ else tris.push(vertex(k), vertex(k + 2), vertex(k + 1));
+ }
+ } else {
+ for (let k = 1; k + 1 < n; k++) tris.push(vertex(k), vertex(k + 1), vertex(0));
+ }
+ const triCount = tris.length / 3;
+ if (triCount === 0) return;
+
+ const { n: nm, det } = normalMatrix(world);
+ const flip = det < 0;
+ const positions = new Float32Array(triCount * 9);
+ const normals = new Float32Array(triCount * 9);
+ const uvs = uv ? new Float32Array(triCount * 6) : null;
+ const p = pos.data;
+
+ for (let t = 0; t < triCount; t++) {
+ for (let c = 0; c < 3; c++) {
+ // Mirroring transforms reverse winding; swap two corners to keep normals and winding agreeing.
+ const corner = flip && c > 0 ? 3 - c : c;
+ const v = tris[t * 3 + corner]!;
+ if (v >= vertexCount) throw new Error(`index ${v} out of range (${vertexCount} vertices)`);
+ const x = p[v * 3]!, y = p[v * 3 + 1]!, z = p[v * 3 + 2]!;
+ const o = t * 9 + c * 3;
+ const wx = world[0]! * x + world[4]! * y + world[8]! * z + world[12]!;
+ const wy = world[1]! * x + world[5]! * y + world[9]! * z + world[13]!;
+ const wz = world[2]! * x + world[6]! * y + world[10]! * z + world[14]!;
+ positions[o] = wx;
+ positions[o + 1] = wy;
+ positions[o + 2] = wz;
+ if (wx < min[0]) min[0] = wx;
+ if (wy < min[1]) min[1] = wy;
+ if (wz < min[2]) min[2] = wz;
+ if (wx > max[0]) max[0] = wx;
+ if (wy > max[1]) max[1] = wy;
+ if (wz > max[2]) max[2] = wz;
+ if (nrm) {
+ const a = nrm.data[v * 3]!, b = nrm.data[v * 3 + 1]!, d = nrm.data[v * 3 + 2]!;
+ const nx = nm[0]! * a + nm[1]! * b + nm[2]! * d;
+ const ny = nm[3]! * a + nm[4]! * b + nm[5]! * d;
+ const nz = nm[6]! * a + nm[7]! * b + nm[8]! * d;
+ const len = Math.hypot(nx, ny, nz) || 1;
+ normals[o] = nx / len;
+ normals[o + 1] = ny / len;
+ normals[o + 2] = nz / len;
+ }
+ if (uvs && uv) {
+ uvs[t * 6 + c * 2] = uv.data[v * uv.size]!;
+ uvs[t * 6 + c * 2 + 1] = uv.data[v * uv.size + 1]!;
+ }
+ }
+ if (!nrm) {
+ // glTF: missing normals mean flat shading.
+ const o = t * 9;
+ const e1x = positions[o + 3]! - positions[o]!, e1y = positions[o + 4]! - positions[o + 1]!, e1z = positions[o + 5]! - positions[o + 2]!;
+ const e2x = positions[o + 6]! - positions[o]!, e2y = positions[o + 7]! - positions[o + 1]!, e2z = positions[o + 8]! - positions[o + 2]!;
+ let cx = e1y * e2z - e1z * e2y, cy = e1z * e2x - e1x * e2z, cz = e1x * e2y - e1y * e2x;
+ const len = Math.hypot(cx, cy, cz) || 1;
+ cx /= len; cy /= len; cz /= len;
+ for (let c = 0; c < 3; c++) normals.set([cx, cy, cz], o + c * 3);
+ }
+ }
+ triangleCount += triCount;
+ const mesh: Mesh = uvs ? { positions, normals, uvs } : { positions, normals };
+ meshes.push({ mesh, material: materialFor(prim.material) });
+ };
+
+ const visit = (nodeIndex: number, parent: Mat4, depth: number): void => {
+ if (depth > 64) throw new Error('node hierarchy is deeper than 64 levels (cycle?)');
+ const node = json.nodes?.[nodeIndex];
+ if (!node) throw new Error(`missing node ${nodeIndex}`);
+ const local: Mat4 = node.matrix
+ ? Float64Array.from(node.matrix as number[])
+ : composeTRS(node.translation ?? [0, 0, 0], node.rotation ?? [0, 0, 0, 1], node.scale ?? [1, 1, 1]);
+ const world = multiply(parent, local);
+ if (node.mesh !== undefined) {
+ const mesh = json.meshes?.[node.mesh];
+ if (!mesh) throw new Error(`node ${nodeIndex} references missing mesh ${node.mesh}`);
+ for (const prim of mesh.primitives ?? []) emitPrimitive(prim, world);
+ }
+ for (const child of (node.children ?? []) as number[]) visit(child, world, depth + 1);
+ };
+
+ let roots: number[];
+ const sceneIndex = json.scene ?? 0;
+ if (json.scenes?.[sceneIndex]) {
+ roots = json.scenes[sceneIndex].nodes ?? [];
+ } else {
+ const children = new Set();
+ for (const node of (json.nodes ?? []) as Json[]) for (const c of (node.children ?? []) as number[]) children.add(c);
+ roots = ((json.nodes ?? []) as Json[]).map((_, i) => i).filter((i) => !children.has(i));
+ }
+ for (const root of roots) visit(root, identity(), 0);
+ if (triangleCount === 0) throw new Error('the file contains no triangle meshes');
+
+ const images = await Promise.all(imageLoads);
+ return { meshes, materials, images, bounds: { min, max }, triangleCount, warnings };
+}
diff --git a/src/loaders/hdr.ts b/src/loaders/hdr.ts
new file mode 100644
index 0000000..c145ff6
--- /dev/null
+++ b/src/loaders/hdr.ts
@@ -0,0 +1,93 @@
+import type { HDRImage } from '../scene/environment';
+
+/**
+ * Radiance RGBE (.hdr) decoder: flat and "new" run-length encoded scanlines,
+ * any of the four axis orientations. Output rows run top to bottom, RGB floats.
+ */
+export function parseHDR(buffer: ArrayBuffer): HDRImage {
+ const bytes = new Uint8Array(buffer);
+ let pos = 0;
+
+ const readLine = (): string => {
+ const start = pos;
+ while (pos < bytes.length && bytes[pos] !== 0x0a) pos++;
+ if (pos >= bytes.length) throw new Error('HDR: unexpected end of header');
+ const line = String.fromCharCode(...bytes.subarray(start, pos));
+ pos++;
+ return line;
+ };
+
+ const magic = readLine();
+ if (!magic.startsWith('#?')) throw new Error('HDR: missing #? signature, not a Radiance file');
+ for (;;) {
+ const line = readLine().trim();
+ if (line === '') break;
+ const fmt = /^FORMAT=(.+)$/.exec(line);
+ if (fmt && fmt[1] !== '32-bit_rle_rgbe') throw new Error(`HDR: unsupported format ${fmt[1]}`);
+ }
+
+ const res = /^([-+])([XY]) (\d+) ([-+])([XY]) (\d+)$/.exec(readLine().trim());
+ if (!res || res[2] === res[5]) throw new Error('HDR: malformed resolution line');
+ const rowMajorY = res[2] === 'Y';
+ const n1 = Number(res[3]);
+ const n2 = Number(res[6]);
+ const width = rowMajorY ? n2 : n1;
+ const height = rowMajorY ? n1 : n2;
+ const scanlines = n1;
+ const scanLength = n2;
+ if (width <= 0 || height <= 0) throw new Error('HDR: empty image');
+
+ const rgbe = new Uint8Array(scanLength * 4);
+ const data = new Float32Array(width * height * 3);
+ const flipY = (rowMajorY ? res[1] : res[4]) === '+';
+ const flipX = (rowMajorY ? res[4] : res[1]) === '-';
+
+ const readScanline = (): void => {
+ const rle =
+ scanLength >= 8 && scanLength < 0x8000 && pos + 4 <= bytes.length &&
+ bytes[pos] === 2 && bytes[pos + 1] === 2 && (bytes[pos + 2]! & 0x80) === 0;
+ if (!rle) {
+ if (pos + scanLength * 4 > bytes.length) throw new Error('HDR: truncated pixel data');
+ rgbe.set(bytes.subarray(pos, pos + scanLength * 4));
+ pos += scanLength * 4;
+ return;
+ }
+ if (((bytes[pos + 2]! << 8) | bytes[pos + 3]!) !== scanLength) throw new Error('HDR: scanline width mismatch');
+ pos += 4;
+ for (let c = 0; c < 4; c++) {
+ let x = 0;
+ while (x < scanLength) {
+ if (pos >= bytes.length) throw new Error('HDR: truncated run-length data');
+ let count = bytes[pos++]!;
+ if (count > 128) {
+ count -= 128;
+ if (x + count > scanLength) throw new Error('HDR: run overflows scanline');
+ const v = bytes[pos++]!;
+ for (let k = 0; k < count; k++) rgbe[(x++) * 4 + c] = v;
+ } else {
+ if (count === 0 || x + count > scanLength) throw new Error('HDR: bad literal run');
+ for (let k = 0; k < count; k++) rgbe[(x++) * 4 + c] = bytes[pos++]!;
+ }
+ }
+ }
+ };
+
+ for (let s = 0; s < scanlines; s++) {
+ readScanline();
+ for (let i = 0; i < scanLength; i++) {
+ let x = rowMajorY ? i : s;
+ let y = rowMajorY ? s : i;
+ if (flipX) x = width - 1 - x;
+ if (flipY) y = height - 1 - y;
+ const e = rgbe[i * 4 + 3]!;
+ const o = (y * width + x) * 3;
+ if (e === 0) continue;
+ // Radiance's colr_color(): mantissa centred in its bucket.
+ const f = 2 ** (e - 136);
+ data[o] = (rgbe[i * 4]! + 0.5) * f;
+ data[o + 1] = (rgbe[i * 4 + 1]! + 0.5) * f;
+ data[o + 2] = (rgbe[i * 4 + 2]! + 0.5) * f;
+ }
+ }
+ return { width, height, data };
+}
diff --git a/src/loaders/model.ts b/src/loaders/model.ts
new file mode 100644
index 0000000..0df5e73
--- /dev/null
+++ b/src/loaders/model.ts
@@ -0,0 +1,166 @@
+import type { Vec3 } from '../math/vec3';
+import type { Environment, HDRImage } from '../scene/environment';
+import { matte } from '../scene/material';
+import type { Mesh } from '../scene/mesh';
+import { daySky, defaultSun } from '../scene/presets';
+import type { Scene, SceneTexture } from '../scene/scene';
+import { loadGLTF, type GLTFAsset, type ResourceResolver } from './gltf';
+import { parseHDR } from './hdr';
+
+/** A dropped or picked file and its path inside the drop (folders keep their structure). */
+export interface InputFile {
+ file: File;
+ path: string;
+}
+
+export interface LoadedModel {
+ name: string;
+ asset: GLTFAsset;
+ textures: SceneTexture[];
+ warnings: string[];
+}
+
+const basename = (p: string): string => p.split('/').pop() ?? p;
+const stem = (p: string): string => basename(p).replace(/\.[^.]+$/, '');
+
+function normalizePath(uri: string): string {
+ let p = uri.split(/[?#]/)[0]!.replace(/\\/g, '/');
+ try {
+ p = decodeURIComponent(p);
+ } catch {
+ // Not percent-encoded after all.
+ }
+ return p.replace(/^\.\//, '');
+}
+
+async function fetchBuffer(url: string): Promise {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText} fetching ${url}`);
+ return res.arrayBuffer();
+}
+
+/** Decodes every image; one that fails is dropped from the materials rather than failing the model. */
+async function decodeImages(asset: GLTFAsset): Promise<{ textures: SceneTexture[]; warnings: string[] }> {
+ const warnings: string[] = [];
+ const results = await Promise.allSettled(asset.images.map((img) =>
+ createImageBitmap(new Blob([img.data as Uint8Array], { type: img.mimeType }), {
+ premultiplyAlpha: 'none',
+ colorSpaceConversion: 'none',
+ })));
+ let blank: ImageBitmap | null = null;
+ const placeholder = (): ImageBitmap => {
+ if (!blank) {
+ const canvas = new OffscreenCanvas(1, 1);
+ canvas.getContext('2d');
+ blank = canvas.transferToImageBitmap();
+ }
+ return blank;
+ };
+ const textures = results.map((r, i): SceneTexture => {
+ const name = asset.images[i]!.name;
+ if (r.status === 'fulfilled') return { image: r.value, name };
+ warnings.push(`could not decode image "${name}"`);
+ for (const m of asset.materials) {
+ if (m.baseColorMap === i) m.baseColorMap = -1;
+ if (m.metalRoughMap === i) m.metalRoughMap = -1;
+ if (m.normalMap === i) m.normalMap = -1;
+ if (m.emissiveMap === i) m.emissiveMap = -1;
+ }
+ return { image: placeholder(), name };
+ });
+ return { textures, warnings };
+}
+
+async function finish(name: string, asset: GLTFAsset): Promise {
+ const { textures, warnings } = await decodeImages(asset);
+ return { name, asset, textures, warnings: [...asset.warnings, ...warnings] };
+}
+
+/** Loads a .glb, or a .gltf with its buffers and images found among `files` by relative path or name. */
+export async function loadModelFiles(files: InputFile[]): Promise {
+ const main = files.find((f) => /\.glb$/i.test(f.path)) ?? files.find((f) => /\.gltf$/i.test(f.path));
+ if (!main) throw new Error('drop a .glb or .gltf file (with its .bin and textures, or the folder holding them)');
+ const dir = main.path.includes('/') ? main.path.slice(0, main.path.lastIndexOf('/') + 1) : '';
+ const byPath = new Map(files.map((f) => [f.path, f.file]));
+ const byName = new Map(files.map((f) => [basename(f.path).toLowerCase(), f.file]));
+ const resolve: ResourceResolver = async (uri) => {
+ const rel = normalizePath(uri);
+ const file = byPath.get(dir + rel) ?? byPath.get(rel) ?? byName.get(basename(rel).toLowerCase());
+ if (!file) throw new Error(`"${rel}" is missing; drop it together with ${basename(main.path)}`);
+ return file.arrayBuffer();
+ };
+ return finish(stem(main.path), await loadGLTF(await main.file.arrayBuffer(), resolve));
+}
+
+export async function loadModelURL(url: string, name: string): Promise {
+ const resolve: ResourceResolver = (uri) => fetchBuffer(new URL(uri, url).href);
+ return finish(name, await loadGLTF(await fetchBuffer(url), resolve));
+}
+
+export const loadHDRFile = async (file: File): Promise => parseHDR(await file.arrayBuffer());
+export const loadHDRURL = async (url: string): Promise => parseHDR(await fetchBuffer(url));
+
+function placeMesh(mesh: Mesh, offset: Vec3, s: number): Mesh {
+ const positions = new Float32Array(mesh.positions.length);
+ for (let i = 0; i < positions.length; i += 3) {
+ positions[i] = (mesh.positions[i]! - offset[0]) * s;
+ positions[i + 1] = (mesh.positions[i + 1]! - offset[1]) * s;
+ positions[i + 2] = (mesh.positions[i + 2]! - offset[2]) * s;
+ }
+ return mesh.uvs ? { positions, normals: mesh.normals, uvs: mesh.uvs } : { positions, normals: mesh.normals };
+}
+
+export interface FramingOptions {
+ /** Camera azimuth and elevation around the model, in degrees. */
+ azimuth?: number;
+ elevation?: number;
+ /** Multiplies the distance that just fits the bounding sphere. */
+ distance?: number;
+ exposure?: number;
+ groundColor?: Vec3;
+ groundRoughness?: number;
+}
+
+/**
+ * Wraps a loaded model in a scene: rescaled so its bounding sphere has radius 1,
+ * standing on an analytic ground plane at y = 0, framed by the camera. Volume
+ * attenuation distances are rescaled with the geometry so glass keeps its colour.
+ */
+export function sceneFromModel(model: LoadedModel, env: Environment, opts: FramingOptions = {}): Scene {
+ const { min, max } = model.asset.bounds;
+ const radius = 0.5 * Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1;
+ const s = 1 / radius;
+ const offset: Vec3 = [(min[0] + max[0]) / 2, min[1], (min[2] + max[2]) / 2];
+ const materials = model.asset.materials.map((m) =>
+ Number.isFinite(m.attenuationDistance) ? { ...m, attenuationDistance: m.attenuationDistance * s } : m);
+ const height = (max[1] - min[1]) * s;
+ const halfWidth = 0.5 * Math.max(max[0] - min[0], max[2] - min[2]) * s;
+ const fov = 40;
+ const tan = Math.tan((fov * Math.PI) / 360);
+ const target: Vec3 = [0, height / 2, 0];
+ // Fit the box, not its bounding sphere, assuming a landscape view about 1.5 : 1.
+ const dist = Math.max(height / 2 / tan, halfWidth / (tan * 1.5)) * (opts.distance ?? 1.1) + halfWidth;
+ const az = ((opts.azimuth ?? 30) * Math.PI) / 180;
+ const el = ((opts.elevation ?? 14) * Math.PI) / 180;
+ const position: Vec3 = [
+ target[0] + dist * Math.sin(az) * Math.cos(el),
+ target[1] + dist * Math.sin(el),
+ target[2] + dist * Math.cos(az) * Math.cos(el),
+ ];
+ const tris = model.asset.triangleCount.toLocaleString('en-US');
+ const maps = model.textures.length;
+ return {
+ name: model.name,
+ description: `${tris} triangles · ${materials.length} material${materials.length === 1 ? '' : 's'} · ${maps} texture${maps === 1 ? '' : 's'}`,
+ exposure: opts.exposure ?? 1,
+ camera: { position, target, fov, aperture: 0, focusDistance: dist },
+ sun: defaultSun(),
+ sky: daySky(1),
+ env,
+ // A hair below the model so flat bases do not z-fight with the plane.
+ ground: { enabled: true, height: -1e-3, material: matte(opts.groundColor ?? [0.4, 0.4, 0.42], opts.groundRoughness ?? 0.9) },
+ spheres: [],
+ meshes: model.asset.meshes.map(({ mesh, material }) => ({ mesh: placeMesh(mesh, offset, s), material: materials[material]! })),
+ textures: model.textures,
+ };
+}
diff --git a/src/main.ts b/src/main.ts
index b17cd86..62c79a0 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,10 +1,23 @@
+import { HDRIS, hdriURL, MODELS, modelURL, type GalleryModel } from './gallery';
+import { buildBVHAsync } from './math/bvh-async';
+import type { BVH } from './math/bvh';
+import { loadHDRFile, loadHDRURL, loadModelFiles, loadModelURL, sceneFromModel, type InputFile, type LoadedModel } from './loaders/model';
import { Renderer } from './renderer';
+import type { Environment, HDRImage } from './scene/environment';
import { PRESETS } from './scene/presets';
+import { mergeMeshes } from './scene/mesh';
+import type { Scene } from './scene/scene';
import { h } from './ui/dom';
+import { installDropZone, pickFiles } from './ui/drop';
+import { GalleryView } from './ui/gallery-view';
+import { Hud } from './ui/hud';
import { OrbitControls } from './ui/orbit';
import { Panel } from './ui/panel';
import './ui/styles.css';
+const PRESET_THUMBS = ['showroom', 'cornell', 'material', 'nocturne'];
+const DEFAULT_HDRI = 'studio_small_08';
+
function showFatal(root: HTMLElement, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
root.replaceChildren(
@@ -18,6 +31,9 @@ function showFatal(root: HTMLElement, error: unknown): void {
);
}
+const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
+const stem = (path: string) => (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '');
+
function main(): void {
const root = document.getElementById('app');
if (!root) throw new Error('#app missing');
@@ -34,21 +50,167 @@ function main(): void {
}
const params = new URLSearchParams(location.search);
- const sceneIndex = Math.max(0, Math.min(PRESETS.length - 1, Number(params.get('scene') ?? 0)));
+ const sceneIndex = Math.max(0, Math.min(PRESETS.length - 1, Number(params.get('scene') ?? 0) || 0));
let scene = PRESETS[sceneIndex]!();
renderer.settings.exposure = scene.exposure;
renderer.applySettings();
renderer.loadScene(scene);
renderer.resize();
- const toast = h('div', { class: 'toast' });
+ const toast = h('div', { class: 'toast', role: 'status' });
const progress = h('div', { class: 'progress' });
+ const hud = new Hud();
let toastTimer = 0;
- const notify = (msg: string, ms = 2200) => {
+ /** `ms = 0` keeps the message up until the next one. */
+ const notify = (msg: string, ms = 2600) => {
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toastTimer);
- toastTimer = window.setTimeout(() => toast.classList.remove('show'), ms);
+ if (ms > 0) toastTimer = window.setTimeout(() => toast.classList.remove('show'), ms);
+ };
+
+ const hdris = new Map>();
+ const getHDRI = (id: string): Promise => {
+ let p = hdris.get(id);
+ if (!p) {
+ p = loadHDRURL(hdriURL(id));
+ hdris.set(id, p);
+ p.catch(() => hdris.delete(id));
+ }
+ return p;
+ };
+ let dropped: { name: string; image: HDRImage } | null = null;
+
+ const setURL = (key: 'scene' | 'model', value: string) => {
+ params.delete('scene');
+ params.delete('model');
+ params.set(key, value);
+ history.replaceState(null, '', `?${params}`);
+ };
+
+ /** Swaps in a scene; `bvh` comes from buildBVHAsync for models. */
+ const setScene = (next: Scene, bvh?: BVH) => {
+ renderer.settings.exposure = next.exposure;
+ renderer.loadScene(next, bvh);
+ scene = next;
+ controls.setCamera(next.camera);
+ panel.setScene(next);
+ };
+
+ let loadToken = 0;
+ /** Builds the BVH off the main thread, then swaps the scene in. Resolves false if a newer load won. */
+ const showModel = async (token: number, model: LoadedModel, env: Environment, framing: Partial = {}, envId?: string) => {
+ const next = sceneFromModel(model, env, framing);
+ const tris = model.asset.triangleCount.toLocaleString('en-US');
+ notify(`Building BVH for ${tris} triangles…`, 0);
+ const t0 = performance.now();
+ const bvh = await buildBVHAsync(mergeMeshes(next.meshes.map((m) => m.mesh)).positions);
+ if (token !== loadToken) return false;
+ const t1 = performance.now();
+ setScene(next, bvh);
+ const t2 = performance.now();
+ if (envId) panel.setEnvironment(envId, dropped?.name);
+ const warn = model.warnings.length ? `\n${model.warnings.join('\n')}` : '';
+ notify(`${model.name}: ${tris} triangles · BVH ${(t1 - t0).toFixed(0)} ms · upload ${(t2 - t1).toFixed(0)} ms${warn}`, warn ? 7000 : 3200);
+ return true;
+ };
+
+ const hdriEnvironment = (image: HDRImage, name: string, rotation = 0): Environment => ({
+ map: image, name, rotation, intensity: 1, background: true, backdrop: [0.02, 0.02, 0.025],
+ });
+
+ const openModel = async (m: GalleryModel) => {
+ const token = ++loadToken;
+ notify(`Loading ${m.name} (${m.megabytes} MB)…`, 0);
+ try {
+ const [model, image] = await Promise.all([loadModelURL(modelURL(m.id), m.name), getHDRI(m.hdri)]);
+ if (token !== loadToken) return;
+ const env = HDRIS.find((e) => e.id === m.hdri)!;
+ if (await showModel(token, model, hdriEnvironment(image, env.name, m.rotation), m, m.hdri)) setURL('model', m.id);
+ } catch (err) {
+ if (token === loadToken) notify(`Couldn't load ${m.name}: ${errorText(err)}`, 7000);
+ }
+ };
+
+ const openPreset = (i: number) => {
+ ++loadToken;
+ setScene(PRESETS[i]!());
+ setURL('scene', String(i));
+ };
+
+ const openFiles = async (files: InputFile[]) => {
+ if (files.length === 0) return;
+ const token = ++loadToken;
+ const hasModel = files.some((f) => /\.(glb|gltf)$/i.test(f.path));
+ const hdr = files.find((f) => /\.hdr$/i.test(f.path));
+ try {
+ if (hdr) {
+ notify(`Reading ${hdr.file.name}…`, 0);
+ dropped = { name: stem(hdr.path), image: await loadHDRFile(hdr.file) };
+ }
+ if (hasModel) {
+ notify('Loading model…', 0);
+ const model = await loadModelFiles(files);
+ if (token !== loadToken) return;
+ let env: Environment = { ...scene.env };
+ let envId = scene.env.map ? undefined : 'sky';
+ if (dropped && hdr) {
+ env = hdriEnvironment(dropped.image, dropped.name);
+ envId = 'custom';
+ } else if (!scene.env.map) {
+ try {
+ env = hdriEnvironment(await getHDRI(DEFAULT_HDRI), HDRIS[0]!.name);
+ envId = DEFAULT_HDRI;
+ } catch {
+ // Offline: the procedural sky still lights the model.
+ }
+ }
+ if (!(await showModel(token, model, env, {}, envId))) return;
+ params.delete('scene');
+ params.delete('model');
+ history.replaceState(null, '', params.size ? `?${params}` : location.pathname);
+ } else if (dropped && hdr) {
+ Object.assign(scene.env, hdriEnvironment(dropped.image, dropped.name, scene.env.rotation));
+ renderer.uploadLighting(scene);
+ panel.setEnvironment('custom', dropped.name);
+ notify(`Environment: ${dropped.name} (${dropped.image.width} × ${dropped.image.height})`);
+ } else {
+ notify('Nothing to load: drop a .glb, a .gltf with its files, or an .hdr', 5000);
+ }
+ } catch (err) {
+ if (token === loadToken) notify(`Couldn't load: ${errorText(err)}`, 7000);
+ }
+ };
+
+ const selectEnvironment = async (id: string) => {
+ if (id === 'sky') {
+ Object.assign(scene.env, { map: null, name: 'Procedural sky' });
+ renderer.uploadLighting(scene);
+ panel.refresh();
+ return;
+ }
+ const target = scene;
+ try {
+ let image: HDRImage;
+ let name: string;
+ if (id === 'custom' && dropped) {
+ ({ image, name } = dropped);
+ } else {
+ const entry = HDRIS.find((e) => e.id === id);
+ if (!entry) return;
+ notify(`Loading ${entry.name}…`, 0);
+ image = await getHDRI(id);
+ name = entry.name;
+ notify(`Environment: ${name}`);
+ }
+ if (target !== scene) return;
+ Object.assign(scene.env, { map: image, name });
+ renderer.uploadLighting(scene);
+ panel.setEnvironment(id);
+ } catch (err) {
+ notify(`Couldn't load the HDRI: ${errorText(err)}`, 6000);
+ panel.refresh();
+ }
};
const controls = new OrbitControls(canvas, scene.camera, {
@@ -57,7 +219,7 @@ function main(): void {
const d = renderer.pickFocus(x, y);
if (d === null) return;
scene.camera.focusDistance = d;
- renderer.uploadCamera(scene.camera);
+ renderer.uploadCamera(scene.camera, false);
panel.refresh();
notify(`Focus → ${d.toFixed(2)} units`);
},
@@ -72,17 +234,20 @@ function main(): void {
notify('Saved PNG');
};
- const panel = new Panel(renderer, sceneIndex, {
- onSelectScene: (i) => {
- scene = PRESETS[i]!();
- renderer.settings.exposure = scene.exposure;
- renderer.loadScene(scene);
- controls.setCamera(scene.camera);
- panel.setScene(scene);
- params.set('scene', String(i));
- history.replaceState(null, '', `?${params}`);
- },
- onLightingChange: () => renderer.uploadLighting(scene.sun, scene.sky),
+ const gallery = new GalleryView(
+ PRESETS.map((make, index) => {
+ const s = make();
+ return { index, name: s.name, description: s.description, thumbnail: `thumbs/${PRESET_THUMBS[index]}.jpg` };
+ }),
+ { onPreset: openPreset, onModel: (m) => void openModel(m) },
+ );
+
+ const panel = new Panel(renderer, scene, {
+ onOpenGallery: () => gallery.open(),
+ onOpenFile: () => void pickFiles().then(openFiles),
+ onSelectEnvironment: (id) => void selectEnvironment(id),
+ onLightingChange: () => renderer.uploadLighting(scene),
+ onGroundChange: () => renderer.uploadGround(scene.ground),
onCameraChange: () => renderer.uploadCamera(scene.camera),
onSettingsChange: () => {
renderer.applySettings();
@@ -93,8 +258,9 @@ function main(): void {
onScreenshot: () => void saveScreenshot(),
});
- const toggle = h('button', { class: 'toggle-panel', title: 'Show panel (H)', onClick: () => setPanelHidden(false) }, '☰');
- root.append(panel.root, toggle, progress, toast);
+ const toggle = h('button', { class: 'toggle-panel', title: 'Show panel (H)', 'aria-label': 'Show panel', onClick: () => setPanelHidden(false) }, '☰');
+ root.append(panel.root, toggle, hud.root, progress, toast, gallery.root);
+ installDropZone(root, (files) => void openFiles(files));
const setPanelHidden = (hidden: boolean) => {
panel.root.classList.toggle('hidden', hidden);
@@ -103,12 +269,16 @@ function main(): void {
if (params.get('ui') === '0') {
// Bare mode for embedding and screenshots: no chrome at all.
setPanelHidden(true);
- toggle.classList.add('hidden');
- progress.classList.add('hidden');
+ for (const el of [toggle, progress, hud.root]) el.classList.add('hidden-el');
}
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return;
+ if (e.key === 'Escape' && gallery.isOpen) {
+ gallery.close();
+ return;
+ }
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key.toLowerCase()) {
case ' ':
e.preventDefault();
@@ -123,6 +293,15 @@ function main(): void {
case 'h':
setPanelHidden(!panel.root.classList.contains('hidden'));
break;
+ case 'g':
+ if (gallery.isOpen) gallery.close();
+ else gallery.open();
+ break;
+ case 'd':
+ renderer.settings.denoise = !renderer.settings.denoise;
+ panel.refresh();
+ notify(`Denoiser ${renderer.settings.denoise ? 'on' : 'off'}`);
+ break;
}
});
@@ -142,6 +321,15 @@ function main(): void {
panel.syncPauseButton();
});
+ const requested = params.get('model');
+ const initialModel = MODELS.find((m) => m.id === requested);
+ if (initialModel) void openModel(initialModel);
+
+ if (import.meta.env.DEV) {
+ // Console handle for debugging and for capturing README screenshots.
+ Object.assign(window, { lucent: { renderer, scene: () => scene, openModel, openPreset, selectEnvironment, MODELS } });
+ }
+
let lastStats = 0;
const loop = (now: number) => {
try {
@@ -153,6 +341,7 @@ function main(): void {
if (now - lastStats > 250) {
const st = renderer.stats();
panel.updateStats(st);
+ hud.update(st);
// Visual convergence indicator: ~1k samples reads as "done" for most scenes.
const target = renderer.settings.maxSamples || 1024;
const p = Math.min(1, st.samples / target);
diff --git a/src/math/alias.ts b/src/math/alias.ts
new file mode 100644
index 0000000..2a6a0cd
--- /dev/null
+++ b/src/math/alias.ts
@@ -0,0 +1,72 @@
+/**
+ * Walker's alias method, built with Vose's numerically stable algorithm.
+ *
+ * Sampling is O(1): pick bucket `i` uniformly, keep it with probability
+ * `prob[i]`, otherwise take `alias[i]`. The GPU environment sampler uses this
+ * so that an HDRI sample costs one texel fetch instead of a binary search.
+ */
+export interface AliasTable {
+ prob: Float32Array;
+ alias: Uint32Array;
+ /** Normalised probability of each bucket. */
+ pmf: Float64Array;
+ /** Sum of the input weights; 0 means the table fell back to uniform. */
+ total: number;
+}
+
+export function buildAliasTable(weights: ArrayLike): AliasTable {
+ const n = weights.length;
+ if (n === 0) throw new Error('alias table needs at least one weight');
+
+ let total = 0;
+ for (let i = 0; i < n; i++) {
+ const w = weights[i]!;
+ if (!(w >= 0) || !Number.isFinite(w)) throw new Error(`weight ${i} is ${w}; weights must be finite and >= 0`);
+ total += w;
+ }
+
+ const pmf = new Float64Array(n);
+ for (let i = 0; i < n; i++) pmf[i] = total > 0 ? weights[i]! / total : 1 / n;
+
+ const scaled = new Float64Array(n);
+ const small = new Uint32Array(n);
+ const large = new Uint32Array(n);
+ let ns = 0;
+ let nl = 0;
+ for (let i = 0; i < n; i++) {
+ scaled[i] = pmf[i]! * n;
+ if (scaled[i]! < 1) small[ns++] = i;
+ else large[nl++] = i;
+ }
+
+ const prob = new Float32Array(n);
+ const alias = new Uint32Array(n);
+ while (ns > 0 && nl > 0) {
+ const s = small[--ns]!;
+ const l = large[--nl]!;
+ prob[s] = scaled[s]!;
+ alias[s] = l;
+ scaled[l] = scaled[l]! + scaled[s]! - 1;
+ if (scaled[l]! < 1) small[ns++] = l;
+ else large[nl++] = l;
+ }
+ // Leftovers are 1 up to rounding error.
+ while (nl > 0) {
+ const l = large[--nl]!;
+ prob[l] = 1;
+ alias[l] = l;
+ }
+ while (ns > 0) {
+ const s = small[--ns]!;
+ prob[s] = 1;
+ alias[s] = s;
+ }
+ return { prob, alias, pmf, total };
+}
+
+/** Draws a bucket with two uniforms in [0, 1). Mirrors the GLSL sampler. */
+export function sampleAlias(table: AliasTable, u0: number, u1: number): number {
+ const n = table.prob.length;
+ const i = Math.min(n - 1, Math.floor(u0 * n));
+ return u1 < table.prob[i]! ? i : table.alias[i]!;
+}
diff --git a/src/math/bvh-async.ts b/src/math/bvh-async.ts
new file mode 100644
index 0000000..4d74612
--- /dev/null
+++ b/src/math/bvh-async.ts
@@ -0,0 +1,31 @@
+import { AABB } from './aabb';
+import { buildBVH, type BVH } from './bvh';
+
+/** Below this, a worker's start-up costs more than the build it saves. */
+const WORKER_THRESHOLD = 50_000;
+
+/** Builds the BVH on a worker thread for large inputs so the page keeps rendering. */
+export function buildBVHAsync(positions: Float32Array): Promise {
+ if (positions.length / 9 < WORKER_THRESHOLD || typeof Worker === 'undefined') {
+ return Promise.resolve(buildBVH(positions));
+ }
+ return new Promise((resolve, reject) => {
+ const worker = new Worker(new URL('./bvh.worker.ts', import.meta.url), { type: 'module' });
+ worker.onmessage = (e: MessageEvent) => {
+ worker.terminate();
+ const d = e.data as { nodes: Float32Array; nodeCount: number; triIndices: Uint32Array; min: number[]; max: number[] };
+ resolve({
+ nodes: d.nodes,
+ nodeCount: d.nodeCount,
+ triIndices: d.triIndices,
+ bounds: new AABB([d.min[0]!, d.min[1]!, d.min[2]!], [d.max[0]!, d.max[1]!, d.max[2]!]),
+ });
+ };
+ worker.onerror = (e) => {
+ worker.terminate();
+ reject(new Error(`BVH worker failed: ${e.message}`));
+ };
+ const copy = positions.slice();
+ worker.postMessage(copy, [copy.buffer]);
+ });
+}
diff --git a/src/math/bvh.ts b/src/math/bvh.ts
index 9e14fed..8caea04 100644
--- a/src/math/bvh.ts
+++ b/src/math/bvh.ts
@@ -32,9 +32,21 @@ export interface BVHOptions {
const DEFAULTS: Required = { maxLeafSize: 4, bins: 12 };
+const boxArea = (x0: number, y0: number, z0: number, x1: number, y1: number, z1: number): number => {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const dz = z1 - z0;
+ return dx < 0 ? 0 : 2 * (dx * dy + dy * dz + dz * dx);
+};
+
/**
* Builds a BVH over a triangle soup with a binned Surface Area Heuristic.
*
+ * Large nodes bin all three axes in one pass; small ones, the vast majority,
+ * only their longest. Bounds and bins live in flat typed arrays and the
+ * recursion is an explicit stack, so a million-triangle model builds without
+ * allocating per triangle or per node.
+ *
* @param positions Flat xyz array, 9 floats per triangle (v0, v1, v2).
*/
export function buildBVH(positions: Float32Array, options: BVHOptions = {}): BVH {
@@ -44,20 +56,20 @@ export function buildBVH(positions: Float32Array, options: BVHOptions = {}): BVH
throw new Error(`positions length ${positions.length} is not a multiple of 9`);
}
- // Precompute per-triangle bounds and centroids once.
- const triBounds: AABB[] = new Array(triCount);
- const centroids = new Float32Array(triCount * 3);
+ // Per-triangle bounds (min xyz, max xyz) and centroids, computed once.
+ const tb = new Float32Array(triCount * 6);
+ const cen = new Float32Array(triCount * 3);
for (let i = 0; i < triCount; i++) {
- const b = new AABB();
- for (let k = 0; k < 3; k++) {
- const o = i * 9 + k * 3;
- b.expandPoint([positions[o]!, positions[o + 1]!, positions[o + 2]!]);
+ for (let a = 0; a < 3; a++) {
+ const p0 = positions[i * 9 + a]!;
+ const p1 = positions[i * 9 + 3 + a]!;
+ const p2 = positions[i * 9 + 6 + a]!;
+ const lo = Math.min(p0, p1, p2);
+ const hi = Math.max(p0, p1, p2);
+ tb[i * 6 + a] = lo;
+ tb[i * 6 + 3 + a] = hi;
+ cen[i * 3 + a] = (lo + hi) * 0.5;
}
- triBounds[i] = b;
- const c = b.centroid();
- centroids[i * 3] = c[0];
- centroids[i * 3 + 1] = c[1];
- centroids[i * 3 + 2] = c[2];
}
const triIndices = new Uint32Array(triCount);
@@ -67,95 +79,55 @@ export function buildBVH(positions: Float32Array, options: BVHOptions = {}): BVH
const nodes = new Float32Array(Math.max(1, 2 * triCount - 1) * NODE_STRIDE);
let nodeCount = 0;
- const allocNode = (): number => nodeCount++;
+ const bins = opts.bins;
+ const binBox = new Float64Array(3 * bins * 6);
+ const binCount = new Uint32Array(3 * bins);
+ const rightArea = new Float64Array(bins);
+ const rightCount = new Uint32Array(bins);
+ const box = new Float64Array(6);
+ const cbox = new Float64Array(6);
+ const splittable = [false, false, false];
- const writeNode = (idx: number, box: AABB, leftFirst: number, count: number) => {
+ const writeNode = (idx: number, b: Float64Array, leftFirst: number, count: number) => {
const o = idx * NODE_STRIDE;
- nodes[o] = box.min[0];
- nodes[o + 1] = box.min[1];
- nodes[o + 2] = box.min[2];
+ nodes[o] = b[0]!;
+ nodes[o + 1] = b[1]!;
+ nodes[o + 2] = b[2]!;
nodes[o + 3] = leftFirst;
- nodes[o + 4] = box.max[0];
- nodes[o + 5] = box.max[1];
- nodes[o + 6] = box.max[2];
+ nodes[o + 4] = b[3]!;
+ nodes[o + 5] = b[4]!;
+ nodes[o + 6] = b[5]!;
nodes[o + 7] = count;
};
- const rangeBounds = (start: number, end: number): AABB => {
- const box = new AABB();
- for (let i = start; i < end; i++) box.expandBox(triBounds[triIndices[i]!]!);
- return box;
- };
-
- const centroidBounds = (start: number, end: number): AABB => {
- const box = new AABB();
+ /** Fills `box` with the triangles' bounds and `cbox` with their centroids' bounds. */
+ const measure = (start: number, end: number) => {
+ box.fill(Infinity, 0, 3).fill(-Infinity, 3, 6);
+ cbox.fill(Infinity, 0, 3).fill(-Infinity, 3, 6);
for (let i = start; i < end; i++) {
- const t = triIndices[i]! * 3;
- box.expandPoint([centroids[t]!, centroids[t + 1]!, centroids[t + 2]!]);
- }
- return box;
- };
-
- interface Split {
- axis: number;
- position: number;
- cost: number;
- }
-
- /** Evaluate binned SAH on the longest centroid axis; returns the cheapest split. */
- const findBestSplit = (start: number, end: number, cb: AABB): Split | null => {
- const axis = cb.longestAxis();
- const lo = cb.min[axis];
- const hi = cb.max[axis];
- if (hi - lo < 1e-9) return null;
-
- const binCount = opts.bins;
- const binBounds: AABB[] = Array.from({ length: binCount }, () => new AABB());
- const binCounts = new Uint32Array(binCount);
- const scale = binCount / (hi - lo);
-
- for (let i = start; i < end; i++) {
- const tri = triIndices[i]!;
- const c = centroids[tri * 3 + axis]!;
- const b = Math.min(binCount - 1, Math.floor((c - lo) * scale));
- binCounts[b]!++;
- binBounds[b]!.expandBox(triBounds[tri]!);
- }
-
- // Sweep from the right to accumulate suffix bounds, then from the left for prefixes.
- const rightArea = new Float64Array(binCount);
- const rightCount = new Uint32Array(binCount);
- const acc = new AABB();
- let n = 0;
- for (let b = binCount - 1; b > 0; b--) {
- acc.expandBox(binBounds[b]!);
- n += binCounts[b]!;
- rightArea[b] = acc.surfaceArea();
- rightCount[b] = n;
- }
-
- let best: Split | null = null;
- const leftAcc = new AABB();
- let leftN = 0;
- for (let b = 0; b < binCount - 1; b++) {
- leftAcc.expandBox(binBounds[b]!);
- leftN += binCounts[b]!;
- const rn = rightCount[b + 1]!;
- if (leftN === 0 || rn === 0) continue;
- const cost = leftAcc.surfaceArea() * leftN + rightArea[b + 1]! * rn;
- if (best === null || cost < best.cost) {
- best = { axis, position: lo + ((b + 1) / binCount) * (hi - lo), cost };
+ const t = triIndices[i]!;
+ for (let a = 0; a < 3; a++) {
+ const lo = tb[t * 6 + a]!;
+ const hi = tb[t * 6 + 3 + a]!;
+ const c = cen[t * 3 + a]!;
+ if (lo < box[a]!) box[a] = lo;
+ if (hi > box[a + 3]!) box[a + 3] = hi;
+ if (c < cbox[a]!) cbox[a] = c;
+ if (c > cbox[a + 3]!) cbox[a + 3] = c;
}
}
- return best;
};
- /** In-place partition of triIndices[start, end) by centroid; returns the split index. */
- const partition = (start: number, end: number, axis: number, pos: number): number => {
+ const binScale = new Float64Array(3);
+ const binOf = (t: number, axis: number): number =>
+ Math.min(bins - 1, Math.floor((cen[t * 3 + axis]! - cbox[axis]!) * binScale[axis]!));
+
+ /** In-place partition of triIndices[start, end) into bins <= `split` on `axis`; returns the split index. */
+ const partition = (start: number, end: number, axis: number, split: number): number => {
let i = start;
let j = end - 1;
while (i <= j) {
- if (centroids[triIndices[i]! * 3 + axis]! < pos) {
+ if (binOf(triIndices[i]!, axis) <= split) {
i++;
} else {
const tmp = triIndices[i]!;
@@ -167,51 +139,115 @@ export function buildBVH(positions: Float32Array, options: BVHOptions = {}): BVH
return i;
};
- const build = (nodeIdx: number, start: number, end: number): void => {
+ const root = nodeCount++;
+ if (triCount === 0) {
+ box.fill(0);
+ writeNode(root, box, 0, 0);
+ return { nodes: nodes.subarray(0, NODE_STRIDE), nodeCount, triIndices, bounds: new AABB([0, 0, 0], [0, 0, 0]) };
+ }
+
+ const stack: number[] = [root, 0, triCount];
+ while (stack.length > 0) {
+ const end = stack.pop()!;
+ const start = stack.pop()!;
+ const nodeIdx = stack.pop()!;
const count = end - start;
- const box = rangeBounds(start, end);
+ measure(start, end);
if (count <= opts.maxLeafSize) {
writeNode(nodeIdx, box, start, count);
- return;
+ continue;
}
- const cb = centroidBounds(start, end);
- const split = findBestSplit(start, end, cb);
+ // Small nodes are the vast majority; binning only their longest axis keeps per-node cost low.
+ const longest = cbox[3]! - cbox[0]! >= cbox[4]! - cbox[1]! && cbox[3]! - cbox[0]! >= cbox[5]! - cbox[2]! ? 0
+ : cbox[4]! - cbox[1]! >= cbox[5]! - cbox[2]! ? 1 : 2;
+ for (let a = 0; a < 3; a++) {
+ const extent = cbox[a + 3]! - cbox[a]!;
+ splittable[a] = extent > 1e-9 && (count >= 512 || a === longest);
+ binScale[a] = bins / extent;
+ if (!splittable[a]) continue;
+ for (let k = a * bins; k < (a + 1) * bins; k++) {
+ binBox.fill(Infinity, k * 6, k * 6 + 3).fill(-Infinity, k * 6 + 3, k * 6 + 6);
+ binCount[k] = 0;
+ }
+ }
+ for (let i = start; i < end; i++) {
+ const t = triIndices[i]!;
+ for (let a = 0; a < 3; a++) {
+ if (!splittable[a]) continue;
+ const k = a * bins + binOf(t, a);
+ binCount[k]!++;
+ const o = k * 6;
+ for (let c = 0; c < 3; c++) {
+ const lo = tb[t * 6 + c]!;
+ const hi = tb[t * 6 + 3 + c]!;
+ if (lo < binBox[o + c]!) binBox[o + c] = lo;
+ if (hi > binBox[o + 3 + c]!) binBox[o + 3 + c] = hi;
+ }
+ }
+ }
+
+ let bestCost = Infinity;
+ let bestAxis = -1;
+ let bestBin = -1;
+ for (let a = 0; a < 3; a++) {
+ if (!splittable[a]) continue;
+ // Sweep from the right to accumulate suffix areas, then from the left for prefixes.
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
+ let n = 0;
+ for (let b = bins - 1; b > 0; b--) {
+ const o = (a * bins + b) * 6;
+ x0 = Math.min(x0, binBox[o]!); y0 = Math.min(y0, binBox[o + 1]!); z0 = Math.min(z0, binBox[o + 2]!);
+ x1 = Math.max(x1, binBox[o + 3]!); y1 = Math.max(y1, binBox[o + 4]!); z1 = Math.max(z1, binBox[o + 5]!);
+ n += binCount[a * bins + b]!;
+ rightArea[b] = boxArea(x0, y0, z0, x1, y1, z1);
+ rightCount[b] = n;
+ }
+ x0 = Infinity; y0 = Infinity; z0 = Infinity; x1 = -Infinity; y1 = -Infinity; z1 = -Infinity;
+ let leftN = 0;
+ for (let b = 0; b < bins - 1; b++) {
+ const o = (a * bins + b) * 6;
+ x0 = Math.min(x0, binBox[o]!); y0 = Math.min(y0, binBox[o + 1]!); z0 = Math.min(z0, binBox[o + 2]!);
+ x1 = Math.max(x1, binBox[o + 3]!); y1 = Math.max(y1, binBox[o + 4]!); z1 = Math.max(z1, binBox[o + 5]!);
+ leftN += binCount[a * bins + b]!;
+ const rn = rightCount[b + 1]!;
+ if (leftN === 0 || rn === 0) continue;
+ const cost = boxArea(x0, y0, z0, x1, y1, z1) * leftN + rightArea[b + 1]! * rn;
+ if (cost < bestCost) {
+ bestCost = cost;
+ bestAxis = a;
+ bestBin = b;
+ }
+ }
+ }
// SAH cost of a leaf is `count * area`. Stop when a split is unprofitable,
// but never let a leaf grow unbounded (coincident centroids, flat SAH).
- const leafCost = count * box.surfaceArea();
- const unprofitable = split === null || split.cost >= leafCost;
+ const leafCost = count * boxArea(box[0]!, box[1]!, box[2]!, box[3]!, box[4]!, box[5]!);
+ const unprofitable = bestAxis < 0 || bestCost >= leafCost;
if (unprofitable && count <= opts.maxLeafSize * 4) {
writeNode(nodeIdx, box, start, count);
- return;
+ continue;
}
- let mid = unprofitable ? (start + end) >> 1 : partition(start, end, split.axis, split.position);
- // A partition that moves nothing would recurse forever; fall back to a median split.
+ let mid = unprofitable ? (start + end) >> 1 : partition(start, end, bestAxis, bestBin);
+ // A partition that moves nothing would loop forever; fall back to a median split.
if (mid === start || mid === end) mid = (start + end) >> 1;
- const left = allocNode();
- const right = allocNode();
- if (right !== left + 1) throw new Error('BVH invariant violated: children not adjacent');
+ const left = nodeCount++;
+ const right = nodeCount++;
writeNode(nodeIdx, box, left, 0);
- build(left, start, mid);
- build(right, mid, end);
- };
-
- const root = allocNode();
- if (triCount === 0) {
- writeNode(root, new AABB([0, 0, 0], [0, 0, 0]), 0, 0);
- } else {
- build(root, 0, triCount);
+ // Right is pushed first so the left subtree is built (and numbered) first.
+ stack.push(right, mid, end, left, start, mid);
}
+ measure(0, triCount);
return {
nodes: nodes.subarray(0, nodeCount * NODE_STRIDE),
nodeCount,
triIndices,
- bounds: triCount === 0 ? new AABB([0, 0, 0], [0, 0, 0]) : rangeBounds(0, triCount),
+ bounds: new AABB([box[0]!, box[1]!, box[2]!], [box[3]!, box[4]!, box[5]!]),
};
}
diff --git a/src/math/bvh.worker.ts b/src/math/bvh.worker.ts
new file mode 100644
index 0000000..16a7a06
--- /dev/null
+++ b/src/math/bvh.worker.ts
@@ -0,0 +1,18 @@
+import { buildBVH } from './bvh';
+
+interface WorkerScope {
+ onmessage: ((e: MessageEvent) => void) | null;
+ postMessage(message: unknown, transfer: Transferable[]): void;
+}
+
+const scope = self as unknown as WorkerScope;
+
+scope.onmessage = (e) => {
+ const bvh = buildBVH(e.data);
+ // `nodes` is a view into an oversized buffer; copy it so only what is used crosses back.
+ const nodes = bvh.nodes.slice();
+ scope.postMessage(
+ { nodes, nodeCount: bvh.nodeCount, triIndices: bvh.triIndices, min: bvh.bounds.min, max: bvh.bounds.max },
+ [nodes.buffer, bvh.triIndices.buffer],
+ );
+};
diff --git a/src/renderer.ts b/src/renderer.ts
index fd197ea..a81d0ad 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,22 +1,40 @@
-import { createContext, createDataTexture, Program, RenderTarget, type GLCapabilities } from './gl/context';
+import {
+ createContext,
+ createDataTexture,
+ createTexture,
+ createTextureArray,
+ GLError,
+ Program,
+ RenderTarget,
+ type GLCapabilities,
+} from './gl/context';
import { preprocess, type ShaderSources } from './gl/preprocess';
+import type { BVH } from './math/bvh';
import { cross, fromSpherical, normalize, scale, sub, type Vec3 } from './math/vec3';
-import { packScene, pickDistance, type Camera, type PackedScene, type Scene, type Sky, type Sun } from './scene/scene';
+import { bakeSky, buildEnvSampler, type HDRImage } from './scene/environment';
+import { packScene, pickDistance, type Camera, type Ground, type PackedScene, type Scene } from './scene/scene';
+import { SHEEN_LUT_SIZE, sheenAlbedoLUT } from './scene/sheen';
+import atrousFrag from './shaders/atrous.frag?raw';
import bsdfGlsl from './shaders/bsdf.glsl?raw';
import commonGlsl from './shaders/common.glsl?raw';
+import denoiseGlsl from './shaders/denoise.glsl?raw';
import displayFrag from './shaders/display.frag?raw';
import fullscreenVert from './shaders/fullscreen.vert?raw';
import lightsGlsl from './shaders/lights.glsl?raw';
+import resampleFrag from './shaders/resample.frag?raw';
import sceneGlsl from './shaders/scene.glsl?raw';
import traceFrag from './shaders/trace.frag?raw';
+import varianceFrag from './shaders/variance.frag?raw';
-const SOURCES: ShaderSources = {
+const TRACE_SOURCES: ShaderSources = {
'trace.frag': traceFrag,
'common.glsl': commonGlsl,
'scene.glsl': sceneGlsl,
'lights.glsl': lightsGlsl,
'bsdf.glsl': bsdfGlsl,
};
+const VARIANCE_SOURCES: ShaderSources = { 'variance.frag': varianceFrag, 'denoise.glsl': denoiseGlsl };
+const ATROUS_SOURCES: ShaderSources = { 'atrous.frag': atrousFrag, 'denoise.glsl': denoiseGlsl };
export type Tonemap = 'aces' | 'reinhard' | 'none';
@@ -26,12 +44,18 @@ export interface RenderSettings {
tonemap: Tonemap;
/** Internal resolution as a fraction of the canvas' device-pixel size. */
resolutionScale: number;
- /** Samples traced per animation frame; higher is faster to converge but less responsive. */
+ /** Samples traced per animation frame; 0 adapts it to keep frames near 30 ms. */
samplesPerFrame: number;
/** Luminance cap for indirect contributions; 0 disables. */
fireflyClamp: number;
/** Stop accumulating after this many samples; 0 means never. */
maxSamples: number;
+ /** Edge-aware à-trous filtering of the displayed image. */
+ denoise: boolean;
+ /** Drop resolution while the camera moves if frames get slow. */
+ adaptiveResolution: boolean;
+ /** Reuse accumulated samples across camera moves by reprojecting them. */
+ temporal: boolean;
}
export const DEFAULT_SETTINGS: RenderSettings = {
@@ -39,29 +63,90 @@ export const DEFAULT_SETTINGS: RenderSettings = {
exposure: 1,
tonemap: 'aces',
resolutionScale: 1,
- samplesPerFrame: 1,
+ samplesPerFrame: 0,
fireflyClamp: 8,
maxSamples: 0,
+ denoise: true,
+ adaptiveResolution: true,
+ temporal: true,
};
export interface RenderStats {
samples: number;
elapsedMs: number;
- /** Million primary samples per second over the last second. */
+ /** Million camera paths per second over the last second. */
msamplesPerSec: number;
+ /** Samples per pixel per second over the last second. */
+ sppPerSec: number;
+ /** Traced resolution; below `viewWidth × viewHeight` while previewing. */
width: number;
height: number;
+ viewWidth: number;
+ viewHeight: number;
+ interactive: boolean;
+ denoised: boolean;
triangles: number;
bvhNodes: number;
+ textures: number;
+ textureSize: number;
renderer: string;
}
+/** Texture units: 0–2 hold the accumulation being read; scene data starts at 3. */
+const UNIT = {
+ prevColor: 0,
+ prevAlbedo: 1,
+ prevNormal: 2,
+ prevHistory: 12,
+ triGeometry: 3,
+ triAttributes: 4,
+ bvh: 5,
+ materials: 6,
+ colorMaps: 7,
+ dataMaps: 8,
+ env: 9,
+ envAlias: 10,
+ sheen: 11,
+} as const;
+
+const TEXTURE_BUDGET_BYTES = 256 * 1024 * 1024;
+const MOTION_HOLD_MS = 160;
+/** Frame time above which the moving preview drops resolution, and below which it recovers. */
+const PREVIEW_SLOW_MS = 40;
+const PREVIEW_FAST_MS = 24;
+const PREVIEW_MIN_SCALE = 0.5;
+/** Samples a reprojected pixel may carry while moving; lower means less lag, more noise. */
+const HISTORY_CAP = 8;
+const ATROUS_STEPS = [1, 2, 4, 8, 16];
+/** Noise falls as 1/√n, so the filter's reach shrinks as samples accumulate. */
+const atrousLevels = (spp: number): number => (spp <= 4 ? 5 : spp <= 16 ? 4 : spp <= 64 ? 3 : spp <= 256 ? 2 : 1);
+/** Weight of the denoised image: full until 128 spp, then a fade to the raw image by 1024. */
+const denoiseWeight = (spp: number): number => Math.min(1, Math.max(0, (1024 - spp) / (1024 - 128)));
+
+const nextPow2 = (n: number) => 2 ** Math.ceil(Math.log2(Math.max(1, n)));
+
+interface CameraState {
+ position: Vec3;
+ forward: Vec3;
+ right: Vec3;
+ up: Vec3;
+ tanHalfFov: number;
+}
+
+function cameraBasis(cam: Camera): { forward: Vec3; right: Vec3; up: Vec3 } {
+ const forward = normalize(sub(cam.target, cam.position));
+ const worldUp: Vec3 = Math.abs(forward[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
+ const right = normalize(cross(forward, worldUp));
+ return { forward, right, up: cross(right, forward) };
+}
+
/**
* Progressive GPU path tracer.
*
- * Each frame traces one (or more) paths per pixel into a ping-ponged RGBA32F
- * accumulation buffer, then a display pass tone-maps the running average onto
- * the canvas. Any change to the camera, scene or lighting resets the average.
+ * Each frame traces one or more paths per pixel into ping-ponged MRT targets
+ * (radiance, albedo guide, normal/depth guide), optionally runs the denoiser on
+ * the running average, then a display pass tone-maps it onto the canvas. Any
+ * change to the camera, scene or lighting resets the average.
*/
export class Renderer {
readonly caps: GLCapabilities;
@@ -70,30 +155,79 @@ export class Renderer {
private readonly gl: WebGL2RenderingContext;
private readonly trace: Program;
private readonly display: Program;
+ private readonly variance: Program;
+ private readonly atrous: Program;
+ private readonly resample: Program;
private readonly vao: WebGLVertexArrayObject;
-
- private targets: [RenderTarget, RenderTarget] | null = null;
- private textures: WebGLTexture[] = [];
+ private readonly sheenLUT: WebGLTexture;
+
+ private accum: [RenderTarget, RenderTarget] | null = null;
+ private filtered: [RenderTarget, RenderTarget] | null = null;
+ private sceneTextures: WebGLTexture[] = [];
+ private textureArrays: WebGLTexture[] = [];
+ private envTextures: WebGLTexture[] = [];
+ private envSource: HDRImage | null = null;
+ private envKey = '';
private packed: PackedScene | null = null;
private scene: Scene | null = null;
+ private textureSize = 0;
+ private camera: CameraState = { position: [0, 0, 0], forward: [0, 0, -1], right: [1, 0, 0], up: [0, 1, 0], tanHalfFov: 1 };
+ /** Camera and resolution the accumulated history was traced with. */
+ private history: { camera: CameraState; width: number; height: number } | null = null;
+ private reprojectPending = false;
+ /** Effective samples per pixel behind the image (capped while moving). */
private frame = 0;
+ /** RNG stream index; advances every sample and never resets, so moving frames never repeat noise. */
+ private seed = 0;
private startTime = 0;
- private sampleWindow = { time: 0, samples: 0, rate: 0 };
+ private sampleWindow = { time: 0, samples: 0, paths: 0, sppRate: 0, pathRate: 0 };
private width = 1;
private height = 1;
+ private renderWidth = 1;
+ private renderHeight = 1;
private paused = false;
private elapsedAtPause = 0;
+ private lastMotion = -Infinity;
+ private lastFrameTime = 0;
+ private interactive = false;
+ private previewScale = 1;
+ private autoSpp = 1;
+ private denoisedFrame = -1;
+ private denoised: RenderTarget | null = null;
constructor(readonly canvas: HTMLCanvasElement) {
this.caps = createContext(canvas);
this.gl = this.caps.gl;
const gl = this.gl;
- this.trace = new Program(gl, fullscreenVert, preprocess('trace.frag', SOURCES), SOURCES);
+ this.trace = new Program(gl, fullscreenVert, preprocess('trace.frag', TRACE_SOURCES), TRACE_SOURCES);
this.display = new Program(gl, fullscreenVert, displayFrag);
+ this.variance = new Program(gl, fullscreenVert, preprocess('variance.frag', VARIANCE_SOURCES), VARIANCE_SOURCES);
+ this.atrous = new Program(gl, fullscreenVert, preprocess('atrous.frag', ATROUS_SOURCES), ATROUS_SOURCES);
+ this.resample = new Program(gl, fullscreenVert, resampleFrag);
const vao = gl.createVertexArray();
if (!vao) throw new Error('Failed to create VAO');
this.vao = vao;
+
+ this.sheenLUT = createTexture(gl, SHEEN_LUT_SIZE, SHEEN_LUT_SIZE,
+ { internalFormat: gl.R16F, format: gl.RED, type: gl.FLOAT, filter: gl.LINEAR }, sheenAlbedoLUT());
+
+ const t = this.trace;
+ t.use();
+ t.i1('uPrevColor', UNIT.prevColor);
+ t.i1('uPrevAlbedo', UNIT.prevAlbedo);
+ t.i1('uPrevNormal', UNIT.prevNormal);
+ t.i1('uPrevHistory', UNIT.prevHistory);
+ t.f1('uHistoryCap', HISTORY_CAP);
+ t.i1('uTriGeometry', UNIT.triGeometry);
+ t.i1('uTriAttributes', UNIT.triAttributes);
+ t.i1('uBVH', UNIT.bvh);
+ t.i1('uMaterials', UNIT.materials);
+ t.i1('uColorMaps', UNIT.colorMaps);
+ t.i1('uDataMaps', UNIT.dataMaps);
+ t.i1('uEnvMap', UNIT.env);
+ t.i1('uEnvAlias', UNIT.envAlias);
+ t.i1('uSheenLUT', UNIT.sheen);
}
get currentScene(): Scene | null {
@@ -112,87 +246,178 @@ export class Renderer {
else this.elapsedAtPause = performance.now() - this.startTime;
}
- /** Uploads a scene; subsequent frames render it from scratch. */
- loadScene(scene: Scene): void {
+ /** Uploads a scene; subsequent frames render it from scratch. `bvh` may be prebuilt (see buildBVHAsync). */
+ loadScene(scene: Scene, bvh?: BVH): void {
const gl = this.gl;
- for (const t of this.textures) gl.deleteTexture(t);
- this.textures = [];
+ for (const t of [...this.sceneTextures, ...this.textureArrays]) gl.deleteTexture(t);
+ this.sceneTextures = [];
+ this.textureArrays = [];
- const packed = packScene(scene);
+ const packed = packScene(scene, bvh);
this.packed = packed;
this.scene = scene;
- const triTex = createDataTexture(gl, packed.triangles);
- const bvhTex = createDataTexture(gl, packed.bvhNodes);
- const matTex = createDataTexture(gl, packed.materials);
- this.textures = [triTex, bvhTex, matTex];
+ this.sceneTextures = [
+ createDataTexture(gl, packed.triGeometry),
+ createDataTexture(gl, packed.triAttributes),
+ createDataTexture(gl, packed.bvhNodes),
+ createDataTexture(gl, packed.materials),
+ ];
+ this.textureArrays = this.buildTextureArrays(scene, packed);
- this.trace.use();
- this.bindSceneTextures();
- this.trace.i1('uTriangles', 1);
- this.trace.i1('uBVH', 2);
- this.trace.i1('uMaterials', 3);
- this.trace.i1('uPrevious', 0);
- this.trace.i1('uTriCount', packed.triCount);
-
- this.trace.f4v('uSpheres', packed.spheres);
- this.trace.i1v('uSphereMaterial', packed.sphereMaterials);
- this.trace.i1v('uSphereLight', packed.sphereLights);
- this.trace.i1('uSphereCount', packed.sphereCount);
- this.trace.f4v('uLights', packed.lights);
- this.trace.i1('uLightCount', packed.lightCount);
-
- this.uploadLighting(scene.sun, scene.sky);
- this.uploadCamera(scene.camera);
+ const p = this.trace;
+ p.use();
+ p.i1('uTriCount', packed.triCount);
+ p.f4v('uSpheres', packed.spheres);
+ p.i1v('uSphereMaterial', packed.sphereMaterials);
+ p.i1v('uSphereLight', packed.sphereLights);
+ p.i1('uSphereCount', packed.sphereCount);
+ p.f4v('uLights', packed.lights);
+ p.i1('uLightCount', packed.lightCount);
+
+ this.uploadGround(scene.ground);
+ this.uploadLighting(scene);
+ this.uploadCamera(scene.camera, false);
this.reset();
}
- /** Scene data lives on units 1–3; unit 0 is reserved for the accumulation buffer. */
+ /** Resamples every referenced image into one sRGB and one linear texture array. */
+ private buildTextureArrays(scene: Scene, packed: PackedScene): WebGLTexture[] {
+ const used = [...packed.colorLayers, ...packed.dataLayers];
+ let size = 1;
+ if (used.length > 0) {
+ const largest = Math.max(...used.map((i) => Math.max(scene.textures[i]!.image.width, scene.textures[i]!.image.height)));
+ size = Math.min(2048, this.caps.maxTextureSize, nextPow2(largest));
+ while (size > 256 && used.length * size * size * 4 > TEXTURE_BUDGET_BYTES) size /= 2;
+ }
+ for (const layers of [packed.colorLayers, packed.dataLayers]) {
+ if (layers.length > this.caps.maxArrayLayers) {
+ throw new GLError(`scene uses ${layers.length} textures; this GPU allows ${this.caps.maxArrayLayers} per array`);
+ }
+ }
+ this.textureSize = used.length > 0 ? size : 0;
+ const images = (layers: number[]) => layers.map((i) => scene.textures[i]!.image);
+ return [
+ createTextureArray(this.gl, images(packed.colorLayers), size, true, this.resample, this.vao),
+ createTextureArray(this.gl, images(packed.dataLayers), size, false, this.resample, this.vao),
+ ];
+ }
+
+ /** Scene data lives on units 3+; units 0–2 are reserved for the accumulation buffer. */
private bindSceneTextures(): void {
const gl = this.gl;
- this.textures.forEach((tex, i) => {
- gl.activeTexture(gl.TEXTURE1 + i);
- gl.bindTexture(gl.TEXTURE_2D, tex);
- });
+ const bind = (unit: number, target: number, tex: WebGLTexture | undefined) => {
+ gl.activeTexture(gl.TEXTURE0 + unit);
+ gl.bindTexture(target, tex ?? null);
+ };
+ bind(UNIT.triGeometry, gl.TEXTURE_2D, this.sceneTextures[0]);
+ bind(UNIT.triAttributes, gl.TEXTURE_2D, this.sceneTextures[1]);
+ bind(UNIT.bvh, gl.TEXTURE_2D, this.sceneTextures[2]);
+ bind(UNIT.materials, gl.TEXTURE_2D, this.sceneTextures[3]);
+ bind(UNIT.colorMaps, gl.TEXTURE_2D_ARRAY, this.textureArrays[0]);
+ bind(UNIT.dataMaps, gl.TEXTURE_2D_ARRAY, this.textureArrays[1]);
+ bind(UNIT.env, gl.TEXTURE_2D, this.envTextures[0]);
+ bind(UNIT.envAlias, gl.TEXTURE_2D, this.envTextures[1]);
+ bind(UNIT.sheen, gl.TEXTURE_2D, this.sheenLUT);
}
- /** Re-uploads sun and sky after the UI edits them. */
- uploadLighting(sun: Sun, sky: Sky): void {
+ /** Re-uploads sun, sky and environment after the UI edits them. Rebuilds maps only when their inputs changed. */
+ uploadLighting(scene: Scene): void {
+ const { sun, sky, env } = scene;
const p = this.trace;
p.use();
const dir = fromSpherical((sun.elevation * Math.PI) / 180, (sun.azimuth * Math.PI) / 180);
const cosAngle = Math.cos((sun.angularRadius * Math.PI) / 180);
const solidAngle = 2 * Math.PI * (1 - cosAngle);
- const enabled = sun.intensity > 0 && sun.elevation > -sun.angularRadius;
+ const enabled = !env.map && sun.intensity > 0 && sun.elevation > -sun.angularRadius;
p.i1('uSunEnabled', enabled ? 1 : 0);
p.f3('uSunDir', dir);
- p.f3('uSunColor', sun.color);
p.f3('uSunRadiance', scale(sun.color, sun.intensity / solidAngle));
p.f1('uSunCosAngle', cosAngle);
p.f1('uSunPdf', 1 / solidAngle);
- p.i1('uSkyEnabled', sky.enabled ? 1 : 0);
- p.f3('uSkyZenith', sky.zenith);
- p.f3('uSkyHorizon', sky.horizon);
- p.f3('uSkyGround', sky.ground);
- p.f1('uSkyIntensity', sky.intensity);
+
+ if (env.map) {
+ if (env.map !== this.envSource) this.uploadEnvironmentMap(env.map);
+ } else {
+ const key = JSON.stringify([sky, sun]);
+ if (key !== this.envKey || this.envSource) {
+ this.uploadEnvironmentMap(bakeSky(sky, sun));
+ this.envSource = null;
+ this.envKey = key;
+ }
+ }
+ // The procedural sky is baked in world orientation at unit intensity.
+ const rot = env.map ? (env.rotation * Math.PI) / 180 : 0;
+ p.use();
+ p.f2('uEnvRotation', Math.cos(rot), Math.sin(rot));
+ p.f1('uEnvIntensity', env.map ? env.intensity : 1);
+ p.i1('uEnvBackground', env.background ? 1 : 0);
+ p.f3('uBackdrop', env.backdrop);
this.reset();
}
- uploadCamera(cam: Camera): void {
+ private uploadEnvironmentMap(img: HDRImage): void {
+ const gl = this.gl;
+ if (img.width > this.caps.maxTextureSize) {
+ throw new GLError(`environment is ${img.width} px wide; this GPU allows ${this.caps.maxTextureSize}`);
+ }
+ for (const t of this.envTextures) gl.deleteTexture(t);
+ const sampler = buildEnvSampler(img);
+ this.envTextures = [
+ createTexture(gl, img.width, img.height, {
+ internalFormat: gl.RGB32F,
+ format: gl.RGB,
+ type: gl.FLOAT,
+ filter: this.caps.floatLinear ? gl.LINEAR : gl.NEAREST,
+ wrapS: gl.REPEAT,
+ }, img.data),
+ createTexture(gl, sampler.gridWidth, sampler.gridHeight,
+ { internalFormat: gl.RGBA32F, format: gl.RGBA, type: gl.FLOAT }, sampler.table),
+ ];
+ this.envSource = img;
+ this.envKey = '';
+ const p = this.trace;
+ p.use();
+ p.i2('uEnvGrid', sampler.gridWidth, sampler.gridHeight);
+ p.i1('uEnvSampling', sampler.total > 0 ? 1 : 0);
+ }
+
+ uploadGround(ground: Ground): void {
+ if (!this.packed) return;
+ this.trace.use();
+ this.trace.i1('uGroundMaterial', ground.enabled ? this.packed.groundMaterial : -1);
+ this.trace.f1('uGroundHeight', ground.height);
+ this.reset();
+ }
+
+ /** `moving` marks the change as interactive, which switches to the low-resolution preview. */
+ uploadCamera(cam: Camera, moving = true): void {
+ const { forward, right, up } = cameraBasis(cam);
+ const tanHalfFov = Math.tan((cam.fov * Math.PI) / 360);
+ this.camera = { position: [...cam.position], forward, right, up, tanHalfFov };
const p = this.trace;
p.use();
- const forward = normalize(sub(cam.target, cam.position));
- const worldUp: Vec3 = Math.abs(forward[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
- const right = normalize(cross(forward, worldUp));
- const up = cross(right, forward);
p.f3('uCamPos', cam.position);
p.f3('uCamRight', right);
p.f3('uCamUp', up);
p.f3('uCamForward', forward);
- p.f1('uTanHalfFov', Math.tan((cam.fov * Math.PI) / 360));
+ p.f1('uTanHalfFov', tanHalfFov);
p.f1('uAperture', cam.aperture);
p.f1('uFocusDist', cam.focusDistance);
- this.reset();
+ if (moving) this.lastMotion = performance.now();
+ this.invalidateView();
+ }
+
+ /** The view changed but the scene did not: reproject the history if allowed, else start over. */
+ private invalidateView(): void {
+ if (this.settings.temporal && this.history && this.frame > 0) {
+ this.reprojectPending = true;
+ this.frame = Math.min(this.frame, HISTORY_CAP);
+ this.startTime = performance.now();
+ this.denoisedFrame = -1;
+ } else {
+ this.reset();
+ }
}
/** Call after any settings change that affects the integrator. */
@@ -200,30 +425,40 @@ export class Renderer {
this.trace.use();
this.trace.i1('uMaxBounces', this.settings.maxBounces);
this.trace.f1('uClamp', this.settings.fireflyClamp);
+ this.denoisedFrame = -1;
}
/** Resets accumulation; cheap, so callers can be liberal. */
reset(): void {
this.frame = 0;
+ this.history = null;
+ this.reprojectPending = false;
this.startTime = performance.now();
this.elapsedAtPause = 0;
- this.sampleWindow = { time: performance.now(), samples: 0, rate: 0 };
- this.targets?.[0].clear();
- this.targets?.[1].clear();
+ this.denoisedFrame = -1;
+ this.sampleWindow = { time: performance.now(), samples: 0, paths: 0, sppRate: 0, pathRate: 0 };
+ this.accum?.[0].clear();
+ this.accum?.[1].clear();
}
/** Matches the internal buffers to the canvas' CSS size × DPR × resolution scale. */
resize(): boolean {
+ const gl = this.gl;
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr * this.settings.resolutionScale));
const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr * this.settings.resolutionScale));
- if (w === this.width && h === this.height && this.targets) return false;
+ if (w === this.width && h === this.height && this.accum) return false;
this.width = w;
this.height = h;
+ this.renderWidth = w;
+ this.renderHeight = h;
this.canvas.width = w;
this.canvas.height = h;
- this.targets?.forEach((t) => t.dispose());
- this.targets = [new RenderTarget(this.gl, w, h), new RenderTarget(this.gl, w, h)];
+ this.accum?.forEach((t) => t.dispose());
+ this.filtered?.forEach((t) => t.dispose());
+ const guides = [gl.RGBA32F, gl.RGBA16F, gl.RGBA16F, gl.R32F];
+ this.accum = [new RenderTarget(gl, w, h, guides), new RenderTarget(gl, w, h, guides)];
+ this.filtered = [new RenderTarget(gl, w, h), new RenderTarget(gl, w, h)];
this.reset();
return true;
}
@@ -235,10 +470,7 @@ export class Renderer {
const u = ((clientX - rect.left) / rect.width) * 2 - 1;
const v = (1 - (clientY - rect.top) / rect.height) * 2 - 1;
const cam = this.scene.camera;
- const forward = normalize(sub(cam.target, cam.position));
- const worldUp: Vec3 = Math.abs(forward[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
- const right = normalize(cross(forward, worldUp));
- const up = cross(right, forward);
+ const { forward, right, up } = cameraBasis(cam);
const tanHalf = Math.tan((cam.fov * Math.PI) / 360);
const aspect = rect.width / rect.height;
const dir = normalize([
@@ -252,69 +484,238 @@ export class Renderer {
return t * (dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2]);
}
- /** Traces up to `samplesPerFrame` samples then presents. Returns true if any work was done. */
+ /**
+ * Picks the traced resolution. While the camera moves, a frame slower than
+ * PREVIEW_SLOW_MS steps the resolution down (never below half) and a fast one
+ * steps it back up; throttled frames from a hidden page are ignored.
+ */
+ private updatePreview(now: number, dt: number): void {
+ const moving = now - this.lastMotion < MOTION_HOLD_MS;
+ this.interactive = moving;
+ if (moving && this.settings.adaptiveResolution && dt > 0 && dt < 250) {
+ if (dt > PREVIEW_SLOW_MS) this.previewScale = Math.max(PREVIEW_MIN_SCALE, this.previewScale * 0.85);
+ else if (dt < PREVIEW_FAST_MS) this.previewScale = Math.min(1, this.previewScale * 1.1);
+ }
+ const s = moving && this.settings.adaptiveResolution ? this.previewScale : 1;
+ const rw = Math.max(1, Math.round(this.width * s));
+ const rh = Math.max(1, Math.round(this.height * s));
+ if (rw !== this.renderWidth || rh !== this.renderHeight) {
+ this.renderWidth = rw;
+ this.renderHeight = rh;
+ this.invalidateView();
+ }
+ }
+
+ /** Traces this frame's samples, filters, then presents. Returns true if any tracing was done. */
render(): boolean {
- if (!this.targets || !this.packed) return false;
+ if (!this.accum || !this.filtered || !this.packed) return false;
const gl = this.gl;
+ const now = performance.now();
+ const dt = this.lastFrameTime ? now - this.lastFrameTime : 0;
+ this.lastFrameTime = now;
+ this.updatePreview(now, dt);
+
const { maxSamples, samplesPerFrame } = this.settings;
- const canTrace = !this.paused && (maxSamples === 0 || this.frame < maxSamples);
+ const auto = samplesPerFrame <= 0;
+ let spp = this.interactive ? 1 : auto ? this.autoSpp : samplesPerFrame;
+ if (maxSamples > 0) spp = Math.min(spp, maxSamples - this.frame);
+ const canTrace = !this.paused && spp > 0;
+ const rw = this.renderWidth;
+ const rh = this.renderHeight;
if (canTrace) {
- this.trace.use();
- gl.bindVertexArray(this.vao);
- this.bindSceneTextures();
- this.trace.f2('uResolution', this.width, this.height);
- for (let i = 0; i < samplesPerFrame; i++) {
- const read: RenderTarget = this.targets[0];
- const write: RenderTarget = this.targets[1];
- write.bind();
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, read.texture);
- this.trace.u1('uFrame', this.frame);
- gl.drawArrays(gl.TRIANGLES, 0, 3);
- this.targets = [write, read];
- this.frame++;
- this.sampleWindow.samples++;
+ if (auto && !this.interactive && dt > 0) {
+ if (dt < 24) this.autoSpp = Math.min(32, this.autoSpp + 1);
+ else if (dt > 40) this.autoSpp = Math.max(1, Math.floor(this.autoSpp * 0.75));
}
+ this.traceSamples(spp, rw, rh);
+ }
+
+ const latest = this.accum[0];
+ const denoise = this.settings.denoise && this.frame > 0 && denoiseWeight(this.frame) > 0;
+ if (denoise && this.denoisedFrame !== this.frame) {
+ this.runDenoiser(latest, rw, rh);
+ this.denoisedFrame = this.frame;
}
- // Present the latest average.
- const latest = this.targets[0];
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this.width, this.height);
- this.display.use();
+ const d = this.display;
+ d.use();
gl.bindVertexArray(this.vao);
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, latest.texture);
- this.display.i1('uImage', 0);
- this.display.f1('uExposure', this.settings.exposure);
- this.display.i1('uTonemap', this.settings.tonemap === 'aces' ? 0 : this.settings.tonemap === 'reinhard' ? 1 : 2);
+ this.bindUnits([latest.textures[0]!, latest.textures[1]!, (this.denoised ?? this.filtered[0]).texture]);
+ d.i1('uColor', 0);
+ d.i1('uAlbedo', 1);
+ d.i1('uDenoised', 2);
+ d.f1('uDenoiseWeight', denoise ? denoiseWeight(this.frame) : 0);
+ d.f2('uRenderSize', rw, rh);
+ d.f2('uViewSize', this.width, this.height);
+ d.f1('uExposure', this.settings.exposure);
+ d.i1('uTonemap', this.settings.tonemap === 'aces' ? 0 : this.settings.tonemap === 'reinhard' ? 1 : 2);
gl.drawArrays(gl.TRIANGLES, 0, 3);
- const now = performance.now();
- if (now - this.sampleWindow.time > 1000) {
- const secs = (now - this.sampleWindow.time) / 1000;
- this.sampleWindow.rate = (this.sampleWindow.samples * this.width * this.height) / secs / 1e6;
- this.sampleWindow.time = now;
- this.sampleWindow.samples = 0;
+ const w = this.sampleWindow;
+ if (now - w.time > 1000) {
+ const secs = (now - w.time) / 1000;
+ w.sppRate = w.samples / secs;
+ w.pathRate = w.paths / secs / 1e6;
+ w.time = now;
+ w.samples = 0;
+ w.paths = 0;
}
return canTrace;
}
+ private traceSamples(count: number, rw: number, rh: number): void {
+ const gl = this.gl;
+ this.trace.use();
+ gl.bindVertexArray(this.vao);
+ this.bindSceneTextures();
+ this.trace.f2('uResolution', rw, rh);
+ const units = [UNIT.prevColor, UNIT.prevAlbedo, UNIT.prevNormal, UNIT.prevHistory];
+ for (let i = 0; i < count; i++) {
+ const read: RenderTarget = this.accum![0];
+ const write: RenderTarget = this.accum![1];
+ write.bind(rw, rh);
+ read.textures.forEach((tex, k) => {
+ gl.activeTexture(gl.TEXTURE0 + units[k]!);
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ });
+ const reproject = this.reprojectPending && this.history !== null;
+ if (reproject) {
+ const { camera: c, width, height } = this.history!;
+ this.trace.f3('uPrevCamPos', c.position);
+ this.trace.f3('uPrevCamRight', c.right);
+ this.trace.f3('uPrevCamUp', c.up);
+ this.trace.f3('uPrevCamForward', c.forward);
+ this.trace.f1('uPrevTanHalfFov', c.tanHalfFov);
+ this.trace.f2('uPrevResolution', width, height);
+ }
+ this.trace.i1('uReproject', reproject ? 1 : 0);
+ this.trace.u1('uSeed', this.seed++);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ this.reprojectPending = false;
+ this.history = { camera: this.camera, width: rw, height: rh };
+ this.accum = [write, read];
+ this.frame++;
+ this.sampleWindow.samples++;
+ this.sampleWindow.paths += rw * rh;
+ }
+ }
+
+ /**
+ * Traces `samples` full-resolution samples and waits for the GPU (a 1-pixel readback;
+ * Chrome's gl.finish does not block),
+ * so the timing is real even when the page is throttled. Also reports the
+ * denoiser's cost for one run at this resolution.
+ */
+ benchmark(samples: number): { traceMs: number; mpathsPerSec: number; denoiseMs: number; width: number; height: number } {
+ const gl = this.gl;
+ if (!this.accum) throw new Error('benchmark before resize()');
+ this.reset();
+ this.renderWidth = this.width;
+ this.renderHeight = this.height;
+ const pixel = new Float32Array(4);
+ const sync = (target: RenderTarget) => {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, target.framebuffer);
+ gl.readBuffer(gl.COLOR_ATTACHMENT0);
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.FLOAT, pixel);
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ };
+ this.traceSamples(1, this.width, this.height);
+ sync(this.accum[0]);
+ let t0 = performance.now();
+ this.traceSamples(samples, this.width, this.height);
+ sync(this.accum[0]);
+ const traceMs = performance.now() - t0;
+ t0 = performance.now();
+ this.runDenoiser(this.accum[0], this.width, this.height);
+ sync(this.denoised!);
+ const denoiseMs = performance.now() - t0;
+ this.denoisedFrame = this.frame;
+ return {
+ traceMs,
+ mpathsPerSec: (samples * this.width * this.height) / (traceMs / 1000) / 1e6,
+ denoiseMs,
+ width: this.width,
+ height: this.height,
+ };
+ }
+
+ private bindUnits(textures: WebGLTexture[]): void {
+ const gl = this.gl;
+ textures.forEach((tex, unit) => {
+ gl.activeTexture(gl.TEXTURE0 + unit);
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ });
+ }
+
+ /** Variance estimate, then up to five à-trous iterations ping-ponging between the filter targets. */
+ private runDenoiser(source: RenderTarget, rw: number, rh: number): void {
+ const gl = this.gl;
+ const [a, b] = this.filtered!;
+ gl.bindVertexArray(this.vao);
+
+ const v = this.variance;
+ v.use();
+ a.bind(rw, rh);
+ this.bindUnits([source.textures[0]!, source.textures[1]!, source.textures[2]!, source.textures[3]!]);
+ v.i1('uColor', 0);
+ v.i1('uAlbedo', 1);
+ v.i1('uNormal', 2);
+ v.i1('uHistory', 3);
+ v.i2('uSize', rw, rh);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+
+ const t = this.atrous;
+ const cam = this.scene!.camera;
+ const { forward, right, up, tanHalfFov } = this.camera;
+ t.use();
+ t.i1('uInput', 0);
+ t.i1('uAlbedo', 1);
+ t.i1('uNormal', 2);
+ t.i2('uSize', rw, rh);
+ t.f3('uCamPos', cam.position);
+ t.f3('uCamRight', right);
+ t.f3('uCamUp', up);
+ t.f3('uCamForward', forward);
+ t.f1('uTanHalfFov', tanHalfFov);
+ t.f1('uSigmaL', 4);
+ t.f1('uSigmaN', 64);
+ t.f1('uSigmaP', 1.5);
+ let read = a;
+ let write = b;
+ for (const step of ATROUS_STEPS.slice(0, atrousLevels(this.frame))) {
+ write.bind(rw, rh);
+ this.bindUnits([read.texture, source.textures[1]!, source.textures[2]!]);
+ t.i1('uStep', step);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ [read, write] = [write, read];
+ }
+ this.denoised = read;
+ }
+
stats(): RenderStats {
return {
samples: this.frame,
elapsedMs: this.paused ? this.elapsedAtPause : performance.now() - this.startTime,
- msamplesPerSec: this.sampleWindow.rate,
- width: this.width,
- height: this.height,
+ msamplesPerSec: this.sampleWindow.pathRate,
+ sppPerSec: this.sampleWindow.sppRate,
+ width: this.renderWidth,
+ height: this.renderHeight,
+ viewWidth: this.width,
+ viewHeight: this.height,
+ interactive: this.interactive,
+ denoised: this.settings.denoise && this.frame > 0 && denoiseWeight(this.frame) > 0,
triangles: this.packed?.triCount ?? 0,
bvhNodes: this.packed?.bvhNodeCount ?? 0,
+ textures: (this.packed?.colorLayers.length ?? 0) + (this.packed?.dataLayers.length ?? 0),
+ textureSize: this.textureSize,
renderer: this.caps.renderer,
};
}
- /** Encodes the current canvas as a PNG blob. */
+ /** Encodes the current canvas (denoised and tone-mapped, as displayed) as a PNG blob. */
screenshot(): Promise {
this.render();
return new Promise((resolve, reject) => {
@@ -323,10 +724,10 @@ export class Renderer {
}
dispose(): void {
- this.targets?.forEach((t) => t.dispose());
- for (const t of this.textures) this.gl.deleteTexture(t);
- this.trace.dispose();
- this.display.dispose();
+ this.accum?.forEach((t) => t.dispose());
+ this.filtered?.forEach((t) => t.dispose());
+ for (const t of [...this.sceneTextures, ...this.textureArrays, ...this.envTextures, this.sheenLUT]) this.gl.deleteTexture(t);
+ for (const p of [this.trace, this.display, this.variance, this.atrous, this.resample]) p.dispose();
this.gl.deleteVertexArray(this.vao);
}
}
diff --git a/src/scene/environment.ts b/src/scene/environment.ts
new file mode 100644
index 0000000..79fec53
--- /dev/null
+++ b/src/scene/environment.ts
@@ -0,0 +1,132 @@
+import { buildAliasTable } from '../math/alias';
+import { fromSpherical, type Vec3 } from '../math/vec3';
+import type { Sky, Sun } from './scene';
+
+/** Linear RGB floats, rows top to bottom, equirectangular. */
+export interface HDRImage {
+ width: number;
+ height: number;
+ data: Float32Array;
+}
+
+export interface Environment {
+ /** Equirectangular radiance map; null uses the procedural sky and sun. */
+ map: HDRImage | null;
+ name: string;
+ /** Rotation about the vertical axis in degrees. */
+ rotation: number;
+ intensity: number;
+ /** Camera rays that miss see the environment (true) or the flat `backdrop`. */
+ background: boolean;
+ backdrop: Vec3;
+}
+
+export const proceduralEnvironment = (): Environment => ({
+ map: null,
+ name: 'Procedural sky',
+ rotation: 0,
+ intensity: 1,
+ background: true,
+ backdrop: [0.02, 0.02, 0.025],
+});
+
+/**
+ * Importance-sampling data for an environment map, built over a grid of cells
+ * that is at most `maxWidth` wide. Each cell gets a weight of mean luminance ×
+ * sin θ (the equirect Jacobian), and the cells go into an alias table.
+ *
+ * `table` holds 4 floats per cell: keep probability, alias cell, the cell's
+ * pdf in uv space, and the alias cell's pdf in uv space. The solid-angle pdf
+ * of a direction is `pdfUV / (2π² sin θ)`, which the shader evaluates for MIS.
+ */
+export interface EnvSampler {
+ gridWidth: number;
+ gridHeight: number;
+ table: Float32Array;
+ /** Luminance-weighted sum; 0 for a black map (light sampling is then skipped). */
+ total: number;
+}
+
+export const luminance = (r: number, g: number, b: number): number => 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+export function buildEnvSampler(img: HDRImage, maxWidth = 1024): EnvSampler {
+ const gw = Math.max(1, Math.min(img.width, maxWidth));
+ const gh = Math.max(1, Math.min(img.height, Math.round((img.height * gw) / img.width)));
+ const weights = new Float64Array(gw * gh);
+ for (let cy = 0; cy < gh; cy++) {
+ const y0 = Math.floor((cy * img.height) / gh);
+ const y1 = Math.max(y0 + 1, Math.floor(((cy + 1) * img.height) / gh));
+ const sinTheta = Math.sin((Math.PI * (cy + 0.5)) / gh);
+ for (let cx = 0; cx < gw; cx++) {
+ const x0 = Math.floor((cx * img.width) / gw);
+ const x1 = Math.max(x0 + 1, Math.floor(((cx + 1) * img.width) / gw));
+ let sum = 0;
+ for (let y = y0; y < y1; y++) {
+ for (let x = x0; x < x1; x++) {
+ const o = (y * img.width + x) * 3;
+ sum += luminance(img.data[o]!, img.data[o + 1]!, img.data[o + 2]!);
+ }
+ }
+ weights[cy * gw + cx] = (sum / ((y1 - y0) * (x1 - x0))) * sinTheta;
+ }
+ }
+
+ const alias = buildAliasTable(weights);
+ const n = gw * gh;
+ const table = new Float32Array(n * 4);
+ for (let i = 0; i < n; i++) {
+ const a = alias.alias[i]!;
+ table[i * 4] = alias.prob[i]!;
+ table[i * 4 + 1] = a;
+ table[i * 4 + 2] = alias.pmf[i]! * n;
+ table[i * 4 + 3] = alias.pmf[a]! * n;
+ }
+ return { gridWidth: gw, gridHeight: gh, table, total: alias.total };
+}
+
+/** Equirect convention shared with lights.glsl: u wraps around +Y, v = 0 is straight up. */
+export function directionFromUV(u: number, v: number): Vec3 {
+ const phi = (u - 0.5) * 2 * Math.PI;
+ const theta = v * Math.PI;
+ const s = Math.sin(theta);
+ return [s * Math.cos(phi), Math.cos(theta), s * Math.sin(phi)];
+}
+
+export function uvFromDirection(d: Vec3): [number, number] {
+ const u = Math.atan2(d[2], d[0]) / (2 * Math.PI) + 0.5;
+ const v = Math.acos(Math.max(-1, Math.min(1, d[1]))) / Math.PI;
+ return [u, v];
+}
+
+/**
+ * Bakes the procedural gradient sky (and the sun's glow, but not its disc) into
+ * an equirect map, so sky light is importance-sampled exactly like an HDRI.
+ * The disc stays analytic: it is far too small for a map of this size.
+ */
+export function bakeSky(sky: Sky, sun: Sun, width = 1024): HDRImage {
+ const height = width / 2;
+ const data = new Float32Array(width * height * 3);
+ if (!sky.enabled || sky.intensity <= 0) return { width, height, data };
+ const sunDir = fromSpherical((sun.elevation * Math.PI) / 180, (sun.azimuth * Math.PI) / 180);
+ const sunOn = sun.intensity > 0 && sun.elevation > -sun.angularRadius;
+ const mix = (a: number, b: number, t: number) => a + (b - a) * t;
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const d = directionFromUV((x + 0.5) / width, (y + 0.5) / height);
+ const t = d[1];
+ const o = (y * width + x) * 3;
+ for (let c = 0; c < 3; c++) {
+ data[o + c] = t >= 0
+ ? mix(sky.horizon[c]!, sky.zenith[c]!, Math.pow(t, 0.55))
+ : mix(sky.horizon[c]!, sky.ground[c]!, Math.min(1, -t * 4));
+ }
+ if (sunOn) {
+ const s = Math.max(0, d[0] * sunDir[0] + d[1] * sunDir[1] + d[2] * sunDir[2]);
+ const glow = 0.12 * Math.pow(s, 24) + 0.5 * Math.pow(s, 400);
+ for (let c = 0; c < 3; c++) data[o + c] = data[o + c]! + sun.color[c]! * glow;
+ }
+ for (let c = 0; c < 3; c++) data[o + c] = data[o + c]! * sky.intensity;
+ }
+ }
+ return { width, height, data };
+}
diff --git a/src/scene/material.ts b/src/scene/material.ts
index 5867842..4f911c9 100644
--- a/src/scene/material.ts
+++ b/src/scene/material.ts
@@ -1,11 +1,18 @@
import type { Vec3 } from '../math/vec3';
+export type AlphaMode = 'opaque' | 'mask' | 'blend';
+
+/** Row-major 2×3 affine transform applied to texture coordinates. */
+export type UVTransform = readonly [number, number, number, number, number, number];
+
+export const IDENTITY_UV: UVTransform = [1, 0, 0, 0, 1, 0];
+
/**
- * A compact "principled-lite" surface description.
+ * A "principled" surface description that covers the glTF 2.0 metal-rough
+ * model plus the clearcoat, sheen, transmission and volume extensions.
*
- * Everything is physically-motivated but deliberately small: one struct covers
- * matte paint, brushed metal, glass, and emitters, which keeps the GLSL BSDF
- * branch-light and the UI understandable.
+ * One struct covers matte paint, brushed metal, car paint, velvet, glass and
+ * emitters, which keeps the GLSL BSDF a single function and the UI legible.
*/
export interface Material {
/** Base colour in linear RGB. For dielectrics this tints transmitted light. */
@@ -16,7 +23,7 @@ export interface Material {
roughness: number;
/** 0 = opaque, 1 = refractive. Fractions blend stochastically. */
transmission: number;
- /** Index of refraction used when `transmission > 0`. Glass ≈ 1.5, water ≈ 1.33, diamond ≈ 2.4. */
+ /** Index of refraction. Sets the dielectric F0 and the refraction angle. Glass ≈ 1.5. */
ior: number;
/** Emitted radiance in linear RGB. Non-zero makes the surface a light. */
emission: Vec3;
@@ -24,9 +31,32 @@ export interface Material {
checkerScale: number;
/** Second checker colour. */
albedo2: Vec3;
+ /** Base-colour alpha; `alphaMode` decides whether it cuts out (mask) or fades (blend). */
+ alpha: number;
+ alphaMode: AlphaMode;
+ alphaCutoff: number;
+ /** Weight of a smooth IOR-1.5 coat over the base, as on car paint; 0 disables. */
+ clearcoat: number;
+ clearcoatRoughness: number;
+ /** Retro-reflective fabric sheen (Charlie distribution); black disables. */
+ sheenColor: Vec3;
+ sheenRoughness: number;
+ /** Transmission passes straight through instead of refracting (a glTF material without a volume). */
+ thinWalled: boolean;
+ /** Beer–Lambert absorption: the colour white light becomes after `attenuationDistance` inside. */
+ attenuationColor: Vec3;
+ /** Infinity disables absorption. */
+ attenuationDistance: number;
+ /** Indices into `Scene.textures`, or -1. Colour maps are sRGB; metal-rough and normal maps are linear. */
+ baseColorMap: number;
+ metalRoughMap: number;
+ normalMap: number;
+ emissiveMap: number;
+ normalScale: number;
+ uvTransform: UVTransform;
}
-export const MATERIAL_TEXELS = 4;
+export const MATERIAL_TEXELS = 10;
const DEFAULT_MATERIAL: Material = {
albedo: [0.8, 0.8, 0.8],
@@ -37,6 +67,22 @@ const DEFAULT_MATERIAL: Material = {
emission: [0, 0, 0],
checkerScale: 0,
albedo2: [0.2, 0.2, 0.2],
+ alpha: 1,
+ alphaMode: 'opaque',
+ alphaCutoff: 0.5,
+ clearcoat: 0,
+ clearcoatRoughness: 0,
+ sheenColor: [0, 0, 0],
+ sheenRoughness: 0,
+ thinWalled: false,
+ attenuationColor: [1, 1, 1],
+ attenuationDistance: Infinity,
+ baseColorMap: -1,
+ metalRoughMap: -1,
+ normalMap: -1,
+ emissiveMap: -1,
+ normalScale: 1,
+ uvTransform: IDENTITY_UV,
};
export const material = (overrides: Partial = {}): Material => ({
@@ -61,22 +107,43 @@ export const checker = (a: Vec3, b: Vec3, scale: number, roughness = 0.9): Mater
export const isEmissive = (m: Material): boolean =>
m.emission[0] > 0 || m.emission[1] > 0 || m.emission[2] > 0;
+/** True if the traversal must evaluate alpha before accepting a hit on this material. */
+export const needsAlphaTest = (m: Material): boolean =>
+ m.alphaMode !== 'opaque' && (m.alpha < 1 || m.baseColorMap >= 0);
+
+const ALPHA_CODE: Record = { opaque: 0, mask: 1, blend: 2 };
+const ALPHA_MODES: readonly AlphaMode[] = ['opaque', 'mask', 'blend'];
+
/**
- * Packs materials into RGBA32F texels, 4 per material:
+ * Packs materials into RGBA32F texels, 10 per material. Map fields must
+ * already hold texture-array layer indices (see `packScene`).
*
- * 0: albedo.rgb, metallic
- * 1: roughness, ior, transmission, checkerScale
- * 2: emission.rgb, (unused)
- * 3: albedo2.rgb, (unused)
+ * 0: albedo.rgb, metallic
+ * 1: roughness, ior, transmission, checkerScale
+ * 2: emission.rgb, alpha
+ * 3: albedo2.rgb, alphaMode (0 opaque, 1 mask, 2 blend)
+ * 4: baseColorMap, metalRoughMap, normalMap, emissiveMap
+ * 5: normalScale, clearcoat, clearcoatRoughness, alphaCutoff
+ * 6: sheenColor.rgb, sheenRoughness
+ * 7: attenuation.rgb, attenuationDistance (0 = none)
+ * 8: uv row 0 (a b c), thinWalled
+ * 9: uv row 1 (d e f), 0
*/
export function packMaterials(materials: readonly Material[]): Float32Array {
const out = new Float32Array(materials.length * MATERIAL_TEXELS * 4);
materials.forEach((m, i) => {
const o = i * MATERIAL_TEXELS * 4;
+ const uv = m.uvTransform;
out.set([...m.albedo, m.metallic], o);
out.set([m.roughness, m.ior, m.transmission, m.checkerScale], o + 4);
- out.set([...m.emission, 0], o + 8);
- out.set([...m.albedo2, 0], o + 12);
+ out.set([...m.emission, m.alpha], o + 8);
+ out.set([...m.albedo2, ALPHA_CODE[m.alphaMode]], o + 12);
+ out.set([m.baseColorMap, m.metalRoughMap, m.normalMap, m.emissiveMap], o + 16);
+ out.set([m.normalScale, m.clearcoat, m.clearcoatRoughness, m.alphaCutoff], o + 20);
+ out.set([...m.sheenColor, m.sheenRoughness], o + 24);
+ out.set([...m.attenuationColor, Number.isFinite(m.attenuationDistance) ? m.attenuationDistance : 0], o + 28);
+ out.set([uv[0], uv[1], uv[2], m.thinWalled ? 1 : 0], o + 32);
+ out.set([uv[3], uv[4], uv[5], 0], o + 36);
});
return out;
}
@@ -85,14 +152,31 @@ export function packMaterials(materials: readonly Material[]): Float32Array {
export function unpackMaterial(data: Float32Array, index: number): Material {
const o = index * MATERIAL_TEXELS * 4;
const f = (k: number) => data[o + k]!;
+ const v = (k: number): Vec3 => [f(k), f(k + 1), f(k + 2)];
return {
- albedo: [f(0), f(1), f(2)],
+ albedo: v(0),
metallic: f(3),
roughness: f(4),
ior: f(5),
transmission: f(6),
checkerScale: f(7),
- emission: [f(8), f(9), f(10)],
- albedo2: [f(12), f(13), f(14)],
+ emission: v(8),
+ alpha: f(11),
+ albedo2: v(12),
+ alphaMode: ALPHA_MODES[f(15)] ?? 'opaque',
+ baseColorMap: f(16),
+ metalRoughMap: f(17),
+ normalMap: f(18),
+ emissiveMap: f(19),
+ normalScale: f(20),
+ clearcoat: f(21),
+ clearcoatRoughness: f(22),
+ alphaCutoff: f(23),
+ sheenColor: v(24),
+ sheenRoughness: f(27),
+ attenuationColor: v(28),
+ attenuationDistance: f(31) > 0 ? f(31) : Infinity,
+ uvTransform: [f(32), f(33), f(34), f(36), f(37), f(38)],
+ thinWalled: f(35) > 0.5,
};
}
diff --git a/src/scene/mesh.ts b/src/scene/mesh.ts
index 58038b3..f67716e 100644
--- a/src/scene/mesh.ts
+++ b/src/scene/mesh.ts
@@ -2,27 +2,32 @@ import type { Vec3 } from '../math/vec3';
import { add, cross, normalize, scale, sub } from '../math/vec3';
/**
- * Triangle soup with per-vertex normals: 9 floats per triangle in each array.
+ * Triangle soup with per-vertex normals: 9 floats per triangle in each array,
+ * plus optional texture coordinates (6 floats per triangle).
* Non-indexed on purpose; the GPU reads triangles straight out of a texture.
*/
export interface Mesh {
positions: Float32Array;
normals: Float32Array;
+ uvs?: Float32Array;
}
export const triangleCount = (m: Mesh): number => m.positions.length / 9;
+/** Concatenates meshes; texture coordinates are kept (zero-filled) if any input has them. */
export function mergeMeshes(meshes: readonly Mesh[]): Mesh {
const total = meshes.reduce((n, m) => n + m.positions.length, 0);
const positions = new Float32Array(total);
const normals = new Float32Array(total);
+ const uvs = meshes.some((m) => m.uvs) ? new Float32Array((total / 9) * 6) : null;
let offset = 0;
for (const m of meshes) {
positions.set(m.positions, offset);
normals.set(m.normals, offset);
+ if (uvs && m.uvs) uvs.set(m.uvs, (offset / 9) * 6);
offset += m.positions.length;
}
- return { positions, normals };
+ return uvs ? { positions, normals, uvs } : { positions, normals };
}
/** Builds a mesh from an explicit triangle list with flat (face) normals. */
@@ -173,5 +178,5 @@ export function transform(
normals[i + 1] = ny;
normals[i + 2] = -nx * sin + nz * cos;
}
- return { positions, normals };
+ return mesh.uvs ? { positions, normals, uvs: mesh.uvs } : { positions, normals };
}
diff --git a/src/scene/presets.ts b/src/scene/presets.ts
index 8a1976a..c16f1a5 100644
--- a/src/scene/presets.ts
+++ b/src/scene/presets.ts
@@ -1,8 +1,19 @@
import { checker, glass, light, matte, metal, material, type Material } from './material';
import { box, floor, quad, torusKnot, transform } from './mesh';
+import { proceduralEnvironment } from './environment';
import type { Scene, Sphere } from './scene';
-const defaultSun = (overrides: Partial = {}): Scene['sun'] => ({
+type PresetSpec = Omit;
+
+/** Presets light themselves with the procedural sky and model their own floors. */
+const preset = (spec: PresetSpec): Scene => ({
+ ...spec,
+ env: proceduralEnvironment(),
+ ground: { enabled: false, height: 0, material: matte([0.5, 0.5, 0.5]) },
+ textures: [],
+});
+
+export const defaultSun = (overrides: Partial = {}): Scene['sun'] => ({
elevation: 38,
azimuth: 215,
color: [1, 0.93, 0.82],
@@ -11,7 +22,7 @@ const defaultSun = (overrides: Partial = {}): Scene['sun'] => ({
...overrides,
});
-const daySky = (intensity = 1): Scene['sky'] => ({
+export const daySky = (intensity = 1): Scene['sky'] => ({
enabled: true,
zenith: [0.22, 0.42, 0.85],
horizon: [0.78, 0.86, 0.98],
@@ -47,7 +58,7 @@ function cornell(): Scene {
const tallBlock = transform(box([-0.3, 0, -0.3], [0.3, 1.2, 0.3]), { rotateY: 0.3, translate: [-0.35, 0, -0.3] });
const shortBlock = transform(box([-0.3, 0, -0.3], [0.3, 0.6, 0.3]), { rotateY: -0.28, translate: [0.38, 0, 0.35] });
- return {
+ return preset({
name: 'Cornell Box',
exposure: 1,
description: 'Diffuse interreflection, colour bleeding and soft shadows from a single area light.',
@@ -62,7 +73,7 @@ function cornell(): Scene {
{ mesh: tallBlock, material: white },
{ mesh: shortBlock, material: white },
],
- };
+ });
}
/** Outdoor showroom: sun, sky, a gold trefoil knot and a few hero spheres. */
@@ -82,7 +93,7 @@ function showroom(): Scene {
{ center: [0.2, 0.22, 2.1], radius: 0.22, material: glass([0.9, 1, 0.9], 1.33, 0.08) },
];
- return {
+ return preset({
name: 'Showroom',
exposure: 0.55,
description: 'Sun + sky lighting, a BVH-accelerated 9,680-triangle gold knot, caustics and depth of field.',
@@ -94,7 +105,7 @@ function showroom(): Scene {
{ mesh: floor([0, 0, 0], 30), material: ground },
{ mesh: knot, material: metal([1, 0.78, 0.35], 0.18) },
],
- };
+ });
}
/** Grid of spheres sweeping roughness (x) against metallic (y). */
@@ -119,7 +130,7 @@ function materialStudy(): Scene {
});
}
}
- return {
+ return preset({
name: 'Material Study',
exposure: 0.5,
description: 'Roughness sweeps left→right. Rows: rough glass, dielectric, dielectric→metal, gold.',
@@ -128,7 +139,7 @@ function materialStudy(): Scene {
sky: daySky(1.0),
spheres,
meshes: [{ mesh: floor([0, 0, 0], 30), material: matte([0.6, 0.6, 0.62], 0.9) }],
- };
+ });
}
/** Night scene: only emissive spheres light the world, a stress test for light sampling. */
@@ -139,7 +150,7 @@ function nocturne(): Scene {
translate: [0, 0.9, 0],
rotateY: -0.6,
});
- return {
+ return preset({
name: 'Nocturne',
exposure: 1,
description: 'No sky. Three coloured emitters sampled explicitly; glass and chrome catch the rest.',
@@ -157,7 +168,7 @@ function nocturne(): Scene {
{ mesh: floor([0, 0, 0], 30), material: ground },
{ mesh: knot, material: material({ albedo: [0.85, 0.2, 0.55], metallic: 0.6, roughness: 0.3 }) },
],
- };
+ });
}
export const PRESETS: readonly (() => Scene)[] = [showroom, cornell, materialStudy, nocturne];
diff --git a/src/scene/scene.ts b/src/scene/scene.ts
index 30ea0af..b448499 100644
--- a/src/scene/scene.ts
+++ b/src/scene/scene.ts
@@ -3,7 +3,8 @@ import type { Ray } from '../math/ray';
import { intersectSphere } from '../math/ray';
import type { Vec3 } from '../math/vec3';
import { cross, length, sub } from '../math/vec3';
-import { isEmissive, packMaterials, type Material } from './material';
+import type { Environment } from './environment';
+import { isEmissive, needsAlphaTest, packMaterials, type Material } from './material';
import { mergeMeshes, triangleCount, type Mesh } from './mesh';
export interface Camera {
@@ -36,6 +37,13 @@ export interface Sky {
intensity: number;
}
+/** An analytic infinite plane `y = height`; cheap, and needs no BVH rebuild when toggled. */
+export interface Ground {
+ enabled: boolean;
+ height: number;
+ material: Material;
+}
+
export interface Sphere {
center: Vec3;
radius: number;
@@ -47,6 +55,12 @@ export interface MeshInstance {
material: Material;
}
+/** A decoded image; resampled on upload to the shared texture-array size. */
+export interface SceneTexture {
+ image: ImageBitmap;
+ name: string;
+}
+
export interface Scene {
name: string;
description: string;
@@ -55,13 +69,19 @@ export interface Scene {
camera: Camera;
sun: Sun;
sky: Sky;
+ env: Environment;
+ ground: Ground;
spheres: Sphere[];
meshes: MeshInstance[];
+ textures: SceneTexture[];
}
export const MAX_SPHERES = 64;
export const MAX_LIGHTS = 8;
-export const TRI_TEXELS = 6;
+/** Triangle texels used by traversal: v0 (w = material), v1 (w = light), v2 (w = alpha-test flag). */
+export const GEO_TEXELS = 3;
+/** Shading texels, read once per hit: (n0, u0) (n1, u1) (n2, u2) (v0, v1, v2, 0). */
+export const ATTR_TEXELS = 4;
export const BVH_TEXELS = 2;
export const LIGHT_TEXELS = 4;
@@ -84,7 +104,8 @@ export interface Light {
/** Everything the GPU needs, already laid out as texel arrays. */
export interface PackedScene {
- triangles: Float32Array;
+ triGeometry: Float32Array;
+ triAttributes: Float32Array;
triCount: number;
bvhNodes: Float32Array;
bvhNodeCount: number;
@@ -98,11 +119,36 @@ export interface PackedScene {
lights: Float32Array;
lightCount: number;
lightList: Light[];
+ /** Material index of the ground plane (packed even while it is disabled). */
+ groundMaterial: number;
+ /** Scene texture index stored in each layer of the sRGB (colour) and linear (data) arrays. */
+ colorLayers: number[];
+ dataLayers: number[];
/** CPU-side copies kept for picking. */
cpu: {
positions: Float32Array;
bvh: BVH;
sphereList: Sphere[];
+ ground: Ground;
+ };
+}
+
+/** Assigns each referenced texture one layer in its array, in first-use order. */
+function layerMap(): { layers: number[]; layer: (tex: number) => number } {
+ const layers: number[] = [];
+ const index = new Map();
+ return {
+ layers,
+ layer: (tex) => {
+ if (tex < 0) return -1;
+ let l = index.get(tex);
+ if (l === undefined) {
+ l = layers.length;
+ layers.push(tex);
+ index.set(tex, l);
+ }
+ return l;
+ },
};
}
@@ -110,20 +156,23 @@ export interface PackedScene {
* Flattens a scene into GPU texel arrays.
*
* Triangles are written in BVH leaf order, so the shader indexes the triangle
- * texture directly from a leaf's `leftFirst` with no indirection table.
+ * textures directly from a leaf's `leftFirst` with no indirection table.
+ * `prebuilt` is a BVH over the merged meshes built elsewhere (a worker).
*/
-export function packScene(scene: Scene): PackedScene {
+export function packScene(scene: Scene, prebuilt?: BVH): PackedScene {
if (scene.spheres.length > MAX_SPHERES) {
throw new Error(`scene has ${scene.spheres.length} spheres; max is ${MAX_SPHERES}`);
}
// Materials are deduplicated by reference so shared library entries pack once.
const materials: Material[] = [];
+ const materialIds = new Map();
const materialIndex = (m: Material): number => {
- let i = materials.indexOf(m);
- if (i === -1) {
+ let i = materialIds.get(m);
+ if (i === undefined) {
i = materials.length;
materials.push(m);
+ materialIds.set(m, i);
}
return i;
};
@@ -136,12 +185,15 @@ export function packScene(scene: Scene): PackedScene {
};
const merged = mergeMeshes(scene.meshes.map((m) => m.mesh));
- const triMaterial = new Int32Array(triangleCount(merged));
- const triLight = new Int32Array(triangleCount(merged)).fill(-1);
+ const triCount = triangleCount(merged);
+ const triMaterial = new Int32Array(triCount);
+ const triLight = new Int32Array(triCount).fill(-1);
+ const triAlpha = new Uint8Array(triCount);
let cursor = 0;
for (const inst of scene.meshes) {
const n = triangleCount(inst.mesh);
triMaterial.fill(materialIndex(inst.material), cursor, cursor + n);
+ if (needsAlphaTest(inst.material)) triAlpha.fill(1, cursor, cursor + n);
// Emissive quads built with `quad()` become explicitly sampled area lights.
const asQuad = isEmissive(inst.material) ? quadFromMesh(inst.mesh) : null;
if (asQuad) {
@@ -151,21 +203,35 @@ export function packScene(scene: Scene): PackedScene {
cursor += n;
}
- const bvh = buildBVH(merged.positions);
- const triCount = triangleCount(merged);
- const triangles = new Float32Array(triCount * TRI_TEXELS * 4);
+ if (prebuilt && prebuilt.triIndices.length !== triCount) {
+ throw new Error(`prebuilt BVH covers ${prebuilt.triIndices.length} triangles; scene has ${triCount}`);
+ }
+ const bvh = prebuilt ?? buildBVH(merged.positions);
+ const pos = merged.positions;
+ const nrm = merged.normals;
+ const uvs = merged.uvs;
+ const geo = new Float32Array(triCount * GEO_TEXELS * 4);
+ const attr = new Float32Array(triCount * ATTR_TEXELS * 4);
for (let slot = 0; slot < triCount; slot++) {
const src = bvh.triIndices[slot]!;
const p = src * 9;
- const o = slot * TRI_TEXELS * 4;
- const pos = merged.positions;
- const nrm = merged.normals;
- triangles.set([pos[p]!, pos[p + 1]!, pos[p + 2]!, triMaterial[src]!], o);
- triangles.set([pos[p + 3]!, pos[p + 4]!, pos[p + 5]!, triLight[src]!], o + 4);
- triangles.set([pos[p + 6]!, pos[p + 7]!, pos[p + 8]!, 0], o + 8);
- triangles.set([nrm[p]!, nrm[p + 1]!, nrm[p + 2]!, 0], o + 12);
- triangles.set([nrm[p + 3]!, nrm[p + 4]!, nrm[p + 5]!, 0], o + 16);
- triangles.set([nrm[p + 6]!, nrm[p + 7]!, nrm[p + 8]!, 0], o + 20);
+ const g = slot * GEO_TEXELS * 4;
+ const a = slot * ATTR_TEXELS * 4;
+ for (let v = 0; v < 3; v++) {
+ geo[g + v * 4] = pos[p + v * 3]!;
+ geo[g + v * 4 + 1] = pos[p + v * 3 + 1]!;
+ geo[g + v * 4 + 2] = pos[p + v * 3 + 2]!;
+ attr[a + v * 4] = nrm[p + v * 3]!;
+ attr[a + v * 4 + 1] = nrm[p + v * 3 + 1]!;
+ attr[a + v * 4 + 2] = nrm[p + v * 3 + 2]!;
+ if (uvs) {
+ attr[a + v * 4 + 3] = uvs[src * 6 + v * 2]!;
+ attr[a + 12 + v] = uvs[src * 6 + v * 2 + 1]!;
+ }
+ }
+ geo[g + 3] = triMaterial[src]!;
+ geo[g + 7] = triLight[src]!;
+ geo[g + 11] = triAlpha[src]!;
}
// BVH nodes are already 8 floats = 2 texels each, exactly the texture layout.
@@ -190,6 +256,9 @@ export function packScene(scene: Scene): PackedScene {
}
});
+ // Always packed, so the ground can be toggled with a uniform instead of a repack.
+ const groundMaterial = materialIndex(scene.ground.material);
+
const lights = new Float32Array(MAX_LIGHTS * LIGHT_TEXELS * 4);
lightList.forEach((l, i) => {
const o = i * LIGHT_TEXELS * 4;
@@ -199,12 +268,27 @@ export function packScene(scene: Scene): PackedScene {
lights.set([...l.radiance, 0], o + 12);
});
+ // sRGB maps (base colour, emissive) and linear maps (metal-rough, normal) live in separate arrays.
+ const color = layerMap();
+ const data = layerMap();
+ const layered = materials.map((m) => ({
+ ...m,
+ baseColorMap: color.layer(m.baseColorMap),
+ emissiveMap: color.layer(m.emissiveMap),
+ metalRoughMap: data.layer(m.metalRoughMap),
+ normalMap: data.layer(m.normalMap),
+ }));
+ for (const t of [...color.layers, ...data.layers]) {
+ if (t >= scene.textures.length) throw new Error(`material references texture ${t}, scene has ${scene.textures.length}`);
+ }
+
return {
- triangles,
+ triGeometry: geo,
+ triAttributes: attr,
triCount,
bvhNodes,
bvhNodeCount: bvh.nodeCount,
- materials: packMaterials(materials),
+ materials: packMaterials(layered),
materialCount: materials.length,
spheres,
sphereMaterials,
@@ -213,7 +297,10 @@ export function packScene(scene: Scene): PackedScene {
lights,
lightCount: lightList.length,
lightList,
- cpu: { positions: merged.positions, bvh, sphereList: scene.spheres },
+ groundMaterial,
+ colorLayers: color.layers,
+ dataLayers: data.layers,
+ cpu: { positions: merged.positions, bvh, sphereList: scene.spheres, ground: scene.ground },
};
}
@@ -245,5 +332,10 @@ export function pickDistance(packed: PackedScene, ray: Ray): number | null {
const t = intersectSphere(ray, s.center, s.radius, 1e-5, best);
if (t !== null) best = t;
}
+ const g = packed.cpu.ground;
+ if (g.enabled && Math.abs(ray.dir[1]) > 1e-9) {
+ const t = (g.height - ray.origin[1]) / ray.dir[1];
+ if (t > 1e-5 && t < best) best = t;
+ }
return Number.isFinite(best) ? best : null;
}
diff --git a/src/scene/sheen.ts b/src/scene/sheen.ts
new file mode 100644
index 0000000..e7fe5b5
--- /dev/null
+++ b/src/scene/sheen.ts
@@ -0,0 +1,61 @@
+/**
+ * Directional albedo E(cos θv, roughness) of the sheen lobe — Charlie
+ * distribution (Estevez & Kulla 2017) with Neubelt's visibility term — for a
+ * white sheen colour. KHR_materials_sheen scales the base layer by
+ * `1 − max(sheenColor) · E` so that adding sheen does not add energy.
+ *
+ * The shader evaluates the same lobe, so the table is computed here by
+ * quadrature rather than taken from a paper's fit.
+ */
+export const SHEEN_LUT_SIZE = 16;
+export const MIN_SHEEN_ROUGHNESS = 0.07;
+
+export function charlieD(alpha: number, cosH: number): number {
+ const inv = 1 / alpha;
+ const sin2 = Math.max(0, 1 - cosH * cosH);
+ return ((2 + inv) * Math.pow(sin2, inv * 0.5)) / (2 * Math.PI);
+}
+
+export function neubeltV(cosL: number, cosV: number): number {
+ return 1 / (4 * (cosL + cosV - cosL * cosV));
+}
+
+/** E for one view angle and roughness, integrated over half-vectors (where the lobe is peaked). */
+export function sheenAlbedo(cosV: number, roughness: number, steps = 128): number {
+ const r = Math.max(MIN_SHEEN_ROUGHNESS, roughness);
+ const alpha = r * r;
+ const v = [Math.sqrt(Math.max(0, 1 - cosV * cosV)), 0, cosV] as const;
+ const phiSteps = steps / 2;
+ const dTheta = Math.PI / 2 / steps;
+ const dPhi = Math.PI / phiSteps; // half circle, doubled by symmetry about the view plane
+ let e = 0;
+ for (let i = 0; i < steps; i++) {
+ const th = (i + 0.5) * dTheta;
+ const ch = Math.cos(th);
+ const sh = Math.sin(th);
+ const d = charlieD(alpha, ch);
+ for (let j = 0; j < phiSteps; j++) {
+ const ph = (j + 0.5) * dPhi;
+ const h = [sh * Math.cos(ph), sh * Math.sin(ph), ch];
+ const vh = v[0] * h[0]! + v[2] * h[2]!;
+ if (vh <= 0) continue;
+ const cosL = 2 * vh * h[2]! - v[2];
+ if (cosL <= 0) continue;
+ // dω_l = 4 (v·h) dω_h
+ e += d * neubeltV(cosL, cosV) * cosL * 4 * vh * sh * dTheta * dPhi;
+ }
+ }
+ return Math.min(1, 2 * e);
+}
+
+/** Row-major table: x = cos θv in [0, 1], y = roughness in [0, 1], sampled at texel centres' end points. */
+export function sheenAlbedoLUT(size = SHEEN_LUT_SIZE, steps = 96): Float32Array {
+ const out = new Float32Array(size * size);
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const cosV = Math.max(1e-3, x / (size - 1));
+ out[y * size + x] = sheenAlbedo(cosV, y / (size - 1), steps);
+ }
+ }
+ return out;
+}
diff --git a/src/shaders/atrous.frag b/src/shaders/atrous.frag
new file mode 100644
index 0000000..1faa43f
--- /dev/null
+++ b/src/shaders/atrous.frag
@@ -0,0 +1,82 @@
+#version 300 es
+precision highp float;
+precision highp sampler2D;
+
+#include "denoise.glsl"
+
+uniform sampler2D uInput; // illumination (rgb), variance (a)
+uniform int uStep; // 1, 2, 4, 8, 16: the à-trous "holes"
+
+uniform vec3 uCamPos;
+uniform vec3 uCamRight;
+uniform vec3 uCamUp;
+uniform vec3 uCamForward;
+uniform float uTanHalfFov;
+
+uniform float uSigmaL; // luminance edge-stop, in standard deviations
+uniform float uSigmaN; // normal edge-stop exponent
+uniform float uSigmaP; // plane-distance edge-stop, in pixel footprints
+
+out vec4 fragColor;
+
+vec3 primaryDir(ivec2 p) {
+ vec2 uv = (vec2(p) + 0.5) / vec2(uSize) * 2.0 - 1.0;
+ float aspect = float(uSize.x) / float(uSize.y);
+ return normalize(uCamForward + uCamRight * (uv.x * aspect * uTanHalfFov) + uCamUp * (uv.y * uTanHalfFov));
+}
+
+// One iteration of the edge-avoiding à-trous wavelet filter (Dammertz et al. 2010)
+// with SVGF's variance-guided luminance weight (Schied et al. 2017).
+void main() {
+ ivec2 p = ivec2(gl_FragCoord.xy);
+ vec4 cp = texelFetch(uInput, p, 0);
+ if (texelFetch(uAlbedo, p, 0).w < 0.5) {
+ fragColor = cp;
+ return;
+ }
+ vec4 np = texelFetch(uNormal, p, 0);
+ vec3 n = unitNormal(np.xyz);
+ vec3 P = uCamPos + primaryDir(p) * np.w;
+ float footprint = 2.0 * uTanHalfFov * np.w / float(uSize.y);
+
+ // The luminance edge-stop uses a 3×3 blur of the variance, which is itself noisy.
+ float var = 0.0;
+ float wv = 0.0;
+ for (int dy = -1; dy <= 1; dy++) {
+ for (int dx = -1; dx <= 1; dx++) {
+ ivec2 q = p + ivec2(dx, dy);
+ if (!inside(q)) continue;
+ float k = (dx == 0 ? 0.5 : 0.25) * (dy == 0 ? 0.5 : 0.25);
+ var += texelFetch(uInput, q, 0).a * k;
+ wv += k;
+ }
+ }
+ float denom = uSigmaL * sqrt(max(var / wv, 0.0)) + 1e-6;
+ float lp = luminance(cp.rgb);
+
+ const float h[3] = float[3](0.375, 0.25, 0.0625);
+ vec3 sum = cp.rgb * (h[0] * h[0]);
+ float sumVar = cp.a * (h[0] * h[0]) * (h[0] * h[0]);
+ float wsum = h[0] * h[0];
+
+ for (int dy = -2; dy <= 2; dy++) {
+ for (int dx = -2; dx <= 2; dx++) {
+ if (dx == 0 && dy == 0) continue;
+ ivec2 q = p + ivec2(dx, dy) * uStep;
+ if (!inside(q) || texelFetch(uAlbedo, q, 0).w < 0.5) continue;
+ vec4 cq = texelFetch(uInput, q, 0);
+ vec4 nq = texelFetch(uNormal, q, 0);
+ float k = h[abs(dx)] * h[abs(dy)];
+ float wn = pow(max(0.0, dot(n, unitNormal(nq.xyz))), uSigmaN);
+ vec3 Q = uCamPos + primaryDir(q) * nq.w;
+ float reach = uSigmaP * footprint * float(uStep) * length(vec2(dx, dy));
+ float wp = exp(-abs(dot(n, Q - P)) / (reach + 1e-6));
+ float wl = exp(-abs(lp - luminance(cq.rgb)) / denom);
+ float w = k * wn * wp * wl;
+ sum += cq.rgb * w;
+ sumVar += cq.a * w * w;
+ wsum += w;
+ }
+ }
+ fragColor = vec4(sum / wsum, sumVar / (wsum * wsum));
+}
diff --git a/src/shaders/bsdf.glsl b/src/shaders/bsdf.glsl
index 4a5ee9e..4ade588 100644
--- a/src/shaders/bsdf.glsl
+++ b/src/shaders/bsdf.glsl
@@ -1,8 +1,30 @@
// ---------------------------------------------------------------------------
-// Surface scattering. Opaque surfaces use a Lambert + GGX microfacet model
-// with visible-normal sampling; glass is a (optionally roughened) dielectric
-// handled as a delta lobe.
+// Surface scattering. Opaque surfaces layer a GGX clearcoat over Charlie sheen
+// over a Lambert + GGX base, all sampled with one mixture pdf so every lobe
+// takes part in MIS. Glass is an (optionally roughened) delta dielectric.
// ---------------------------------------------------------------------------
+uniform sampler2D uSheenLUT; // directional albedo of the sheen lobe, see sheen.ts
+
+const float SHEEN_LUT_SIZE = 16.0;
+const float MIN_SHEEN_ROUGHNESS = 0.07;
+
+struct Surface {
+ vec3 albedo;
+ float metallic;
+ float roughness;
+ float ior;
+ float transmission;
+ vec3 emission;
+ float clearcoat;
+ float clearcoatRoughness;
+ vec3 sheenColor;
+ float sheenRoughness;
+ bool thinWalled;
+ vec3 absorption; // Beer–Lambert coefficient inside the volume; 0 = clear
+ vec3 n; // shading normal (normal-mapped)
+ vec3 nc; // clearcoat normal (interpolated, not normal-mapped)
+};
+
float D_GGX(float NdotH, float a) {
float a2 = a * a;
float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
@@ -20,10 +42,17 @@ vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * f;
}
+float fresnelSchlick(float cosTheta, float F0) {
+ return F0 + (1.0 - F0) * pow(1.0 - clamp(cosTheta, 0.0, 1.0), 5.0);
+}
+
+float dielectricF0(float ior) {
+ float r = (ior - 1.0) / (ior + 1.0);
+ return r * r;
+}
+
float fresnelDielectric(float cosTheta, float ior) {
- float f0 = (1.0 - ior) / (1.0 + ior);
- f0 *= f0;
- return f0 + (1.0 - f0) * pow(1.0 - clamp(cosTheta, 0.0, 1.0), 5.0);
+ return fresnelSchlick(cosTheta, dielectricF0(ior));
}
// Heitz 2018, "Sampling the GGX Distribution of Visible Normals". Tangent space, Ve = view dir.
@@ -46,39 +75,94 @@ float alphaFromRoughness(float roughness) {
return max(roughness * roughness, 1e-3);
}
-// Probability of choosing the specular lobe; roughly the average Fresnel at this view angle.
+// Charlie sheen distribution (Estevez & Kulla 2017) and Neubelt's visibility.
+float D_Charlie(float alpha, float NdotH) {
+ float inv = 1.0 / alpha;
+ float sin2 = max(1.0 - NdotH * NdotH, 0.0);
+ return (2.0 + inv) * pow(sin2, inv * 0.5) / TWO_PI;
+}
+
+float V_Neubelt(float NdotL, float NdotV) {
+ return 1.0 / (4.0 * (NdotL + NdotV - NdotL * NdotV));
+}
+
+float sheenAlbedo(float NdotV, float roughness) {
+ vec2 uv = (vec2(NdotV, roughness) * (SHEEN_LUT_SIZE - 1.0) + 0.5) / SHEEN_LUT_SIZE;
+ return texture(uSheenLUT, uv).r;
+}
+
+// Probability of choosing the base specular lobe; roughly the average Fresnel at this view angle.
float specularProbability(vec3 F0, float metallic, float NdotV) {
float f = luminance(fresnelSchlick(NdotV, F0));
return mix(clamp(f, 0.1, 0.9), 1.0, metallic);
}
+// Probability of sampling the clearcoat, from its reflectance relative to the base's.
+float coatProbability(Surface s, vec3 wo) {
+ if (s.clearcoat <= 0.0) return 0.0;
+ float Fc = s.clearcoat * fresnelSchlick(max(dot(s.nc, wo), 0.0), 0.04);
+ float base = (1.0 - Fc) * max(luminance(s.albedo), 0.04);
+ return clamp(Fc / (Fc + base), 0.15, 0.85);
+}
+
+vec3 baseF0(Surface s) {
+ return mix(vec3(dielectricF0(s.ior)), s.albedo, s.metallic);
+}
+
// Evaluates the opaque BSDF (not multiplied by cosine) and its sampling pdf.
-// All vectors are world space; `n` is the shading normal, `wo` points to the viewer.
-vec3 evalOpaque(vec3 albedo, float metallic, float roughness, vec3 n, vec3 wo, vec3 wi, out float pdf) {
+// All vectors are world space; `wo` points to the viewer.
+vec3 evalOpaque(Surface s, vec3 wo, vec3 wi, out float pdf) {
+ vec3 n = s.n;
float NdotL = dot(n, wi);
float NdotV = dot(n, wo);
pdf = 0.0;
if (NdotL <= 0.0 || NdotV <= 0.0) return vec3(0.0);
- float a = alphaFromRoughness(roughness);
+ float a = alphaFromRoughness(s.roughness);
vec3 H = normalize(wo + wi);
float NdotH = max(dot(n, H), 0.0);
float VdotH = max(dot(wo, H), 0.0);
- vec3 F0 = mix(vec3(0.04), albedo, metallic);
+ vec3 F0 = baseF0(s);
vec3 F = fresnelSchlick(VdotH, F0);
float D = D_GGX(NdotH, a);
float G1v = G1_GGX(NdotV, a);
float G = G1v * G1_GGX(NdotL, a);
vec3 specular = D * G * F / max(4.0 * NdotV * NdotL, 1e-6);
- vec3 diffuse = (1.0 - F) * albedo * (1.0 - metallic) * INV_PI;
+ vec3 diffuse = (1.0 - F) * s.albedo * (1.0 - s.metallic) * INV_PI;
+ vec3 f = diffuse + specular;
+
+ float pSpec = specularProbability(F0, s.metallic, NdotV);
+ pdf = mix(NdotL * INV_PI, G1v * D / max(4.0 * NdotV, 1e-6), pSpec);
+
+ float sheenMax = max3(s.sheenColor);
+ if (sheenMax > 0.0) {
+ float sr = max(s.sheenRoughness, MIN_SHEEN_ROUGHNESS);
+ vec3 sheen = s.sheenColor * D_Charlie(sr * sr, NdotH) * V_Neubelt(NdotL, NdotV);
+ float scale = 1.0 - sheenMax * max(sheenAlbedo(NdotV, sr), sheenAlbedo(NdotL, sr));
+ f = sheen + f * scale;
+ }
- float pSpec = specularProbability(F0, metallic, NdotV);
- float pdfSpec = G1v * D / max(4.0 * NdotV, 1e-6);
- float pdfDiff = NdotL * INV_PI;
- pdf = mix(pdfDiff, pdfSpec, pSpec);
- return diffuse + specular;
+ float pCoat = coatProbability(s, wo);
+ if (pCoat > 0.0) {
+ float cV = dot(s.nc, wo);
+ float cL = dot(s.nc, wi);
+ vec3 coat = vec3(0.0);
+ float pdfCoat = 0.0;
+ if (cV > 0.0 && cL > 0.0) {
+ float ac = alphaFromRoughness(s.clearcoatRoughness);
+ float cH = max(dot(s.nc, H), 0.0);
+ float Dc = D_GGX(cH, ac);
+ float G1c = G1_GGX(cV, ac);
+ coat = vec3(Dc * G1c * G1_GGX(cL, ac) * fresnelSchlick(VdotH, 0.04) / (4.0 * cV * cL));
+ pdfCoat = G1c * Dc / (4.0 * cV);
+ }
+ float Fcv = fresnelSchlick(max(cV, 0.0), 0.04);
+ f = f * (1.0 - s.clearcoat * Fcv) + s.clearcoat * coat;
+ pdf = mix(pdf, pdfCoat, pCoat);
+ }
+ return f;
}
struct BSDFSample {
@@ -87,57 +171,69 @@ struct BSDFSample {
float pdf;
};
-BSDFSample sampleOpaque(vec3 albedo, float metallic, float roughness, vec3 n, vec3 wo) {
- BSDFSample s;
- s.pdf = 0.0;
- s.weight = vec3(0.0);
-
- vec3 t, b;
- basis(n, t, b);
- vec3 woL = toLocal(wo, t, b, n);
- if (woL.z <= 0.0) return s;
-
- vec3 F0 = mix(vec3(0.04), albedo, metallic);
- float pSpec = specularProbability(F0, metallic, woL.z);
- vec3 wiL;
- if (rand() < pSpec) {
- vec3 H = sampleGGXVNDF(woL, alphaFromRoughness(roughness), rand2());
- wiL = reflect(-woL, H);
+BSDFSample sampleOpaque(Surface s, vec3 wo) {
+ BSDFSample bs;
+ bs.pdf = 0.0;
+ bs.weight = vec3(0.0);
+
+ vec3 wi;
+ if (rand() < coatProbability(s, wo)) {
+ vec3 t, b;
+ basis(s.nc, t, b);
+ vec3 woL = toLocal(wo, t, b, s.nc);
+ if (woL.z <= 0.0) return bs;
+ vec3 H = sampleGGXVNDF(woL, alphaFromRoughness(s.clearcoatRoughness), rand2());
+ wi = toWorld(reflect(-woL, H), t, b, s.nc);
} else {
- wiL = cosineHemisphere(rand2());
+ vec3 t, b;
+ basis(s.n, t, b);
+ vec3 woL = toLocal(wo, t, b, s.n);
+ if (woL.z <= 0.0) return bs;
+ vec3 wiL;
+ if (rand() < specularProbability(baseF0(s), s.metallic, woL.z)) {
+ vec3 H = sampleGGXVNDF(woL, alphaFromRoughness(s.roughness), rand2());
+ wiL = reflect(-woL, H);
+ } else {
+ wiL = cosineHemisphere(rand2());
+ }
+ wi = toWorld(wiL, t, b, s.n);
}
- if (wiL.z <= 0.0) return s;
- vec3 wi = toWorld(wiL, t, b, n);
+ float NdotL = dot(s.n, wi);
+ if (NdotL <= 0.0) return bs;
float pdf;
- vec3 f = evalOpaque(albedo, metallic, roughness, n, wo, wi, pdf);
- if (pdf <= 0.0) return s;
- s.dir = wi;
- s.pdf = pdf;
- s.weight = f * wiL.z / pdf;
- return s;
+ vec3 f = evalOpaque(s, wo, wi, pdf);
+ if (pdf <= 0.0) return bs;
+ bs.dir = wi;
+ bs.pdf = pdf;
+ bs.weight = f * NdotL / pdf;
+ return bs;
}
-// Delta dielectric with a microfacet-perturbed normal for roughness. Returns false if the
+// Delta dielectric with a microfacet-perturbed normal for roughness. Thin-walled
+// surfaces transmit without bending (a sheet, not a solid). Returns false if the
// sampled direction ends up on the wrong side of the surface (path is terminated).
-bool sampleDielectric(vec3 albedo, float roughness, float ior, bool frontFace, vec3 n, vec3 ng, vec3 wo,
+bool sampleDielectric(Surface s, bool frontFace, vec3 ng, vec3 wo,
out vec3 wi, out vec3 weight, out bool reflected) {
+ vec3 n = s.n;
vec3 t, b;
basis(n, t, b);
vec3 woL = toLocal(wo, t, b, n);
if (woL.z <= 0.0) return false;
vec3 H = n;
- if (roughness > 0.0) {
- vec3 HL = sampleGGXVNDF(woL, alphaFromRoughness(roughness), rand2());
+ if (s.roughness > 0.0) {
+ vec3 HL = sampleGGXVNDF(woL, alphaFromRoughness(s.roughness), rand2());
H = toWorld(HL, t, b, n);
}
float cosI = dot(wo, H);
if (cosI <= 0.0) { H = n; cosI = woL.z; }
- float eta = frontFace ? 1.0 / ior : ior;
+ float eta = frontFace ? 1.0 / s.ior : s.ior;
float sin2T = eta * eta * (1.0 - cosI * cosI);
- float Fr = sin2T > 1.0 ? 1.0 : fresnelDielectric(cosI, ior);
+ float Fr = fresnelDielectric(cosI, s.ior);
+ if (s.thinWalled) Fr = 2.0 * Fr / (1.0 + Fr); // both interfaces of the sheet
+ else if (sin2T > 1.0) Fr = 1.0;
if (rand() < Fr) {
wi = reflect(-wo, H);
@@ -145,8 +241,8 @@ bool sampleDielectric(vec3 albedo, float roughness, float ior, bool frontFace, v
reflected = true;
return dot(wi, ng) > 0.0;
}
- wi = refract(-wo, H, eta);
- weight = albedo;
+ wi = s.thinWalled ? -wo : refract(-wo, H, eta);
+ weight = s.albedo;
reflected = false;
return dot(wi, ng) < 0.0;
}
diff --git a/src/shaders/common.glsl b/src/shaders/common.glsl
index 8d4fd94..f344dba 100644
--- a/src/shaders/common.glsl
+++ b/src/shaders/common.glsl
@@ -5,7 +5,7 @@ const float PI = 3.14159265358979323846;
const float INV_PI = 0.31830988618379067154;
const float TWO_PI = 6.28318530717958647692;
const float INF = 1e30;
-const float EPS = 1e-4;
+const float EPS = 1e-6;
// PCG hash (Jarzynski & Olano 2020). Fast, well-distributed, no texture lookups.
uint g_seed;
@@ -84,3 +84,25 @@ float misWeight(float a, float b) {
float b2 = b * b;
return a2 / max(a2 + b2, 1e-10);
}
+
+float max3(vec3 v) {
+ return max(v.x, max(v.y, v.z));
+}
+
+// Offsets a ray origin off a surface by a few ULPs along `n` (Wächter & Binder 2019,
+// "A Fast and Robust Method for Avoiding Self-Intersection"). Scale-independent, so a
+// 5 cm model and a 50 m floor need no tuned epsilon.
+vec3 offsetRay(vec3 p, vec3 n) {
+ const float origin = 1.0 / 32.0;
+ const float floatScale = 1.0 / 65536.0;
+ const float intScale = 256.0;
+ ivec3 o = ivec3(intScale * n);
+ vec3 pi = vec3(
+ intBitsToFloat(floatBitsToInt(p.x) + (p.x < 0.0 ? -o.x : o.x)),
+ intBitsToFloat(floatBitsToInt(p.y) + (p.y < 0.0 ? -o.y : o.y)),
+ intBitsToFloat(floatBitsToInt(p.z) + (p.z < 0.0 ? -o.z : o.z)));
+ return vec3(
+ abs(p.x) < origin ? p.x + floatScale * n.x : pi.x,
+ abs(p.y) < origin ? p.y + floatScale * n.y : pi.y,
+ abs(p.z) < origin ? p.z + floatScale * n.z : pi.z);
+}
diff --git a/src/shaders/denoise.glsl b/src/shaders/denoise.glsl
new file mode 100644
index 0000000..6872f2c
--- /dev/null
+++ b/src/shaders/denoise.glsl
@@ -0,0 +1,25 @@
+// ---------------------------------------------------------------------------
+// Shared by the denoiser passes. The illumination being filtered is radiance
+// divided by the guide albedo, so texture detail never gets blurred: only
+// lighting is, and the albedo is multiplied back in at display time.
+// ---------------------------------------------------------------------------
+uniform sampler2D uAlbedo; // guide albedo (rgb), coverage (a)
+uniform sampler2D uNormal; // guide normal (xyz), primary depth (w)
+uniform ivec2 uSize;
+
+float luminance(vec3 c) {
+ return dot(c, vec3(0.2126, 0.7152, 0.0722));
+}
+
+vec3 demodulate(vec3 c, vec3 albedo) {
+ return c / max(albedo, vec3(1e-3));
+}
+
+vec3 unitNormal(vec3 n) {
+ float l = length(n);
+ return l > 1e-6 ? n / l : vec3(0.0, 0.0, 1.0);
+}
+
+bool inside(ivec2 q) {
+ return q.x >= 0 && q.y >= 0 && q.x < uSize.x && q.y < uSize.y;
+}
diff --git a/src/shaders/display.frag b/src/shaders/display.frag
index f2197ee..6a29cd9 100644
--- a/src/shaders/display.frag
+++ b/src/shaders/display.frag
@@ -1,7 +1,13 @@
#version 300 es
precision highp float;
+precision highp sampler2D;
-uniform sampler2D uImage;
+uniform sampler2D uColor; // running mean radiance
+uniform sampler2D uAlbedo; // guide albedo, coverage
+uniform sampler2D uDenoised; // filtered illumination
+uniform float uDenoiseWeight; // 0 = raw accumulation
+uniform vec2 uRenderSize; // traced region; smaller than the view while orbiting
+uniform vec2 uViewSize;
uniform float uExposure;
uniform int uTonemap; // 0 = ACES, 1 = Reinhard, 2 = none (clip)
@@ -23,8 +29,37 @@ float ign(vec2 p) {
return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y));
}
+vec3 radiance(ivec2 p) {
+ p = clamp(p, ivec2(0), ivec2(uRenderSize) - 1);
+ vec3 raw = texelFetch(uColor, p, 0).rgb;
+ if (uDenoiseWeight > 0.0) {
+ vec4 a = texelFetch(uAlbedo, p, 0);
+ if (a.w >= 0.5) return mix(raw, texelFetch(uDenoised, p, 0).rgb * max(a.rgb, vec3(1e-3)), uDenoiseWeight);
+ }
+ return raw;
+}
+
void main() {
- vec3 c = texelFetch(uImage, ivec2(gl_FragCoord.xy), 0).rgb * uExposure;
+ vec3 c;
+ if (uRenderSize == uViewSize) {
+ c = radiance(ivec2(gl_FragCoord.xy));
+ } else {
+ // Catmull-Rom upsampling of the reduced-resolution preview: sharper than bilinear.
+ vec2 src = gl_FragCoord.xy * (uRenderSize / uViewSize) - 0.5;
+ ivec2 i = ivec2(floor(src));
+ vec2 f = src - floor(src);
+ vec2 w[4];
+ w[0] = f * (-0.5 + f * (1.0 - 0.5 * f));
+ w[1] = 1.0 + f * f * (-2.5 + 1.5 * f);
+ w[2] = f * (0.5 + f * (2.0 - 1.5 * f));
+ w[3] = f * f * (-0.5 + 0.5 * f);
+ c = vec3(0.0);
+ for (int y = 0; y < 4; y++) {
+ for (int x = 0; x < 4; x++) c += radiance(i + ivec2(x - 1, y - 1)) * (w[x].x * w[y].y);
+ }
+ c = max(c, vec3(0.0));
+ }
+ c *= uExposure;
if (uTonemap == 0) c = aces(c);
else if (uTonemap == 1) c = c / (1.0 + c);
c = linearToSrgb(clamp(c, 0.0, 1.0));
diff --git a/src/shaders/lights.glsl b/src/shaders/lights.glsl
index 57f05d7..4e96e56 100644
--- a/src/shaders/lights.glsl
+++ b/src/shaders/lights.glsl
@@ -1,23 +1,26 @@
// ---------------------------------------------------------------------------
-// Explicit light sampling (next-event estimation) for sphere and quad emitters
-// plus a directional sun with finite angular size. Every pdf is in solid angle
-// so it can be combined with BSDF pdfs using multiple importance sampling.
+// Explicit light sampling (next-event estimation) for sphere and quad emitters,
+// an importance-sampled environment map, and a directional sun with finite
+// angular size. Every pdf is in solid angle so it can be combined with BSDF
+// pdfs using multiple importance sampling.
// ---------------------------------------------------------------------------
uniform vec4 uLights[MAX_LIGHTS * 4]; // see scene.ts for the layout
uniform int uLightCount;
uniform vec3 uSunDir; // unit vector towards the sun
uniform vec3 uSunRadiance; // colour * irradiance / solid angle
-uniform vec3 uSunColor; // tint used for the sky glow
uniform float uSunCosAngle; // cos(angular radius)
uniform float uSunPdf; // 1 / solid angle
uniform int uSunEnabled;
-uniform int uSkyEnabled;
-uniform vec3 uSkyZenith;
-uniform vec3 uSkyHorizon;
-uniform vec3 uSkyGround;
-uniform float uSkyIntensity;
+uniform sampler2D uEnvMap; // equirect radiance (procedural sky baked, or an HDRI)
+uniform sampler2D uEnvAlias; // per cell: keep probability, alias cell, pdfUV, alias pdfUV
+uniform ivec2 uEnvGrid;
+uniform vec2 uEnvRotation; // cos, sin of the map's rotation about +Y
+uniform float uEnvIntensity;
+uniform int uEnvSampling; // 0 when the map is black: skip light sampling
+uniform int uEnvBackground; // 0 = camera rays that miss see uBackdrop
+uniform vec3 uBackdrop;
struct LightSample {
vec3 dir;
@@ -26,23 +29,60 @@ struct LightSample {
vec3 radiance;
};
-vec3 sky(vec3 d) {
- if (uSkyEnabled == 0) return vec3(0.0);
- float t = clamp(d.y, -1.0, 1.0);
- vec3 c = t >= 0.0
- ? mix(uSkyHorizon, uSkyZenith, pow(t, 0.55))
- : mix(uSkyHorizon, uSkyGround, clamp(-t * 4.0, 0.0, 1.0));
- if (uSunEnabled != 0) {
- float s = max(dot(d, uSunDir), 0.0);
- c += uSunColor * (0.12 * pow(s, 24.0) + 0.5 * pow(s, 400.0));
+vec3 envToLocal(vec3 d) {
+ return vec3(uEnvRotation.x * d.x - uEnvRotation.y * d.z, d.y, uEnvRotation.y * d.x + uEnvRotation.x * d.z);
+}
+
+vec3 envToWorld(vec3 l) {
+ return vec3(uEnvRotation.x * l.x + uEnvRotation.y * l.z, l.y, -uEnvRotation.y * l.x + uEnvRotation.x * l.z);
+}
+
+// Equirect convention shared with environment.ts: v = 0 is straight up.
+vec2 equirectUV(vec3 l) {
+ return vec2(atan(l.z, l.x) * (0.5 * INV_PI) + 0.5, acos(clamp(l.y, -1.0, 1.0)) * INV_PI);
+}
+
+vec3 envRadiance(vec3 d) {
+ return textureLod(uEnvMap, equirectUV(envToLocal(d)), 0.0).rgb * uEnvIntensity;
+}
+
+// Solid-angle pdf of `sampleEnvironment` producing direction `d`.
+float envPdf(vec3 d) {
+ vec3 l = envToLocal(d);
+ float sinTheta = sqrt(max(0.0, 1.0 - l.y * l.y));
+ if (sinTheta < 1e-6) return 0.0;
+ ivec2 cell = min(ivec2(equirectUV(l) * vec2(uEnvGrid)), uEnvGrid - 1);
+ return texelFetch(uEnvAlias, cell, 0).z / (2.0 * PI * PI * sinTheta);
+}
+
+// O(1) alias-table sample of a map cell, then a uniform point inside it.
+LightSample sampleEnvironment() {
+ LightSample ls;
+ int n = uEnvGrid.x * uEnvGrid.y;
+ int i = min(int(rand() * float(n)), n - 1);
+ vec4 e = texelFetch(uEnvAlias, ivec2(i % uEnvGrid.x, i / uEnvGrid.x), 0);
+ int cell = i;
+ float pdfUV = e.z;
+ if (rand() >= e.x) {
+ cell = int(e.y);
+ pdfUV = e.w;
}
- return c * uSkyIntensity;
+ vec2 uv = (vec2(float(cell % uEnvGrid.x), float(cell / uEnvGrid.x)) + rand2()) / vec2(uEnvGrid);
+ float phi = (uv.x - 0.5) * TWO_PI;
+ float theta = uv.y * PI;
+ float sinTheta = sin(theta);
+ ls.dir = envToWorld(vec3(sinTheta * cos(phi), cos(theta), sinTheta * sin(phi)));
+ ls.dist = INF;
+ ls.pdf = sinTheta > 1e-6 ? pdfUV / (2.0 * PI * PI * sinTheta) : 0.0;
+ ls.radiance = textureLod(uEnvMap, uv, 0.0).rgb * uEnvIntensity;
+ return ls;
}
-// Radiance arriving from infinity. The sun disc is only added with an MIS
-// weight because it is also sampled explicitly at diffuse/glossy vertices.
+// Radiance arriving from infinity along a path. The map and the sun disc are
+// weighted against their light-sampling pdfs unless the bounce was specular.
vec3 environment(vec3 d, bool specularBounce, float bsdfPdf) {
- vec3 L = sky(d);
+ vec3 L = envRadiance(d);
+ if (!specularBounce && uEnvSampling != 0) L *= misWeight(bsdfPdf, envPdf(d));
if (uSunEnabled != 0 && dot(d, uSunDir) >= uSunCosAngle) {
float w = specularBounce ? 1.0 : misWeight(bsdfPdf, uSunPdf);
L += uSunRadiance * w;
diff --git a/src/shaders/resample.frag b/src/shaders/resample.frag
new file mode 100644
index 0000000..8441824
--- /dev/null
+++ b/src/shaders/resample.frag
@@ -0,0 +1,13 @@
+#version 300 es
+precision highp float;
+
+// Resamples an uploaded image into one texture-array layer. The source is
+// mipmapped, so downscaling picks the right level from screen derivatives.
+uniform sampler2D uSource;
+uniform float uSize;
+
+out vec4 fragColor;
+
+void main() {
+ fragColor = texture(uSource, gl_FragCoord.xy / uSize);
+}
diff --git a/src/shaders/scene.glsl b/src/shaders/scene.glsl
index 70c0add..52f1dcd 100644
--- a/src/shaders/scene.glsl
+++ b/src/shaders/scene.glsl
@@ -8,16 +8,22 @@ const int TEX_MASK = 2047;
const int MAX_SPHERES = 64;
const int MAX_LIGHTS = 8;
+const int MATERIAL_TEXELS = 10;
-uniform sampler2D uTriangles; // 6 texels per triangle: v0(w=mat) v1(w=light) v2 n0 n1 n2
-uniform sampler2D uBVH; // 2 texels per node: (min, leftFirst) (max, count)
-uniform sampler2D uMaterials; // 4 texels per material
+uniform sampler2D uTriGeometry; // 3 texels per triangle: v0 (w = material) v1 (w = light) v2 (w = alpha test)
+uniform sampler2D uTriAttributes; // 4 texels per triangle: (n0, u0) (n1, u1) (n2, u2) (v0, v1, v2, -)
+uniform sampler2D uBVH; // 2 texels per node: (min, leftFirst) (max, count)
+uniform sampler2D uMaterials; // 10 texels per material, see material.ts
+uniform sampler2DArray uColorMaps; // sRGB layers: base colour, emissive
+uniform sampler2DArray uDataMaps; // linear layers: metal-rough, normal
uniform vec4 uSpheres[MAX_SPHERES]; // xyz = centre, w = radius
uniform int uSphereMaterial[MAX_SPHERES];
uniform int uSphereLight[MAX_SPHERES]; // light index or -1
uniform int uSphereCount;
uniform int uTriCount;
+uniform int uGroundMaterial; // -1 = no ground plane
+uniform float uGroundHeight;
vec4 fetchTexel(sampler2D tex, int i) {
return texelFetch(tex, ivec2(i & TEX_MASK, i >> TEX_SHIFT), 0);
@@ -31,27 +37,65 @@ struct Material {
float transmission;
float checkerScale;
vec3 emission;
+ float alpha;
vec3 albedo2;
+ int alphaMode;
+ ivec4 maps; // base colour, metal-rough, normal, emissive (layer or -1)
+ float normalScale;
+ float clearcoat;
+ float clearcoatRoughness;
+ float alphaCutoff;
+ vec3 sheenColor;
+ float sheenRoughness;
+ vec3 attenuationColor;
+ float attenuationDistance;
+ vec3 uv0;
+ vec3 uv1;
+ bool thinWalled;
};
Material fetchMaterial(int index) {
- int base = index * 4;
- vec4 a = fetchTexel(uMaterials, base);
- vec4 b = fetchTexel(uMaterials, base + 1);
- vec4 c = fetchTexel(uMaterials, base + 2);
- vec4 d = fetchTexel(uMaterials, base + 3);
+ int base = index * MATERIAL_TEXELS;
+ vec4 t0 = fetchTexel(uMaterials, base);
+ vec4 t1 = fetchTexel(uMaterials, base + 1);
+ vec4 t2 = fetchTexel(uMaterials, base + 2);
+ vec4 t3 = fetchTexel(uMaterials, base + 3);
+ vec4 t4 = fetchTexel(uMaterials, base + 4);
+ vec4 t5 = fetchTexel(uMaterials, base + 5);
+ vec4 t6 = fetchTexel(uMaterials, base + 6);
+ vec4 t7 = fetchTexel(uMaterials, base + 7);
+ vec4 t8 = fetchTexel(uMaterials, base + 8);
+ vec4 t9 = fetchTexel(uMaterials, base + 9);
Material m;
- m.albedo = a.rgb;
- m.metallic = a.w;
- m.roughness = b.x;
- m.ior = b.y;
- m.transmission = b.z;
- m.checkerScale = b.w;
- m.emission = c.rgb;
- m.albedo2 = d.rgb;
+ m.albedo = t0.rgb;
+ m.metallic = t0.w;
+ m.roughness = t1.x;
+ m.ior = t1.y;
+ m.transmission = t1.z;
+ m.checkerScale = t1.w;
+ m.emission = t2.rgb;
+ m.alpha = t2.w;
+ m.albedo2 = t3.rgb;
+ m.alphaMode = int(t3.w);
+ m.maps = ivec4(t4);
+ m.normalScale = t5.x;
+ m.clearcoat = t5.y;
+ m.clearcoatRoughness = t5.z;
+ m.alphaCutoff = t5.w;
+ m.sheenColor = t6.rgb;
+ m.sheenRoughness = t6.w;
+ m.attenuationColor = t7.rgb;
+ m.attenuationDistance = t7.w;
+ m.uv0 = t8.xyz;
+ m.thinWalled = t8.w > 0.5;
+ m.uv1 = t9.xyz;
return m;
}
+vec2 transformUV(vec2 uv, vec3 row0, vec3 row1) {
+ return vec2(dot(row0, vec3(uv, 1.0)), dot(row1, vec3(uv, 1.0)));
+}
+
struct Hit {
float t;
vec3 position;
@@ -60,15 +104,18 @@ struct Hit {
bool frontFace; // did we hit the outside of the surface?
int material;
int light; // explicit light index for MIS, or -1
+ vec2 uv;
+ vec3 dpdu; // surface derivatives for normal mapping; zero without UVs
+ vec3 dpdv;
};
-// Möller–Trumbore, two-sided. Writes barycentrics so callers can interpolate normals.
+// Möller–Trumbore, two-sided. Writes barycentrics so callers can interpolate attributes.
bool hitTriangle(vec3 ro, vec3 rd, vec3 v0, vec3 v1, vec3 v2, float tMax, out float t, out vec2 bary) {
vec3 e1 = v1 - v0;
vec3 e2 = v2 - v0;
vec3 p = cross(rd, e2);
float det = dot(e1, p);
- if (abs(det) < 1e-9) return false;
+ if (abs(det) < 1e-12) return false;
float invDet = 1.0 / det;
vec3 s = ro - v0;
float u = dot(s, p) * invDet;
@@ -96,6 +143,12 @@ float hitSphere(vec3 ro, vec3 rd, vec4 s, float tMax) {
return INF;
}
+float hitGround(vec3 ro, vec3 rd, float tMax) {
+ if (uGroundMaterial < 0 || abs(rd.y) < 1e-9) return INF;
+ float t = (uGroundHeight - ro.y) / rd.y;
+ return t > EPS && t < tMax ? t : INF;
+}
+
// Slab test against a node's box. Returns entry distance or INF.
float hitAABB(vec3 ro, vec3 invDir, vec3 bmin, vec3 bmax, float tMax) {
vec3 t0 = (bmin - ro) * invDir;
@@ -107,12 +160,41 @@ float hitAABB(vec3 ro, vec3 invDir, vec3 bmin, vec3 bmax, float tMax) {
return tf >= tn ? tn : INF;
}
+vec2 triangleUV(int tri, vec2 bary, out vec2 uv0, out vec2 uv1, out vec2 uv2) {
+ int a = tri * 4;
+ vec4 a0 = fetchTexel(uTriAttributes, a);
+ vec4 a1 = fetchTexel(uTriAttributes, a + 1);
+ vec4 a2 = fetchTexel(uTriAttributes, a + 2);
+ vec4 a3 = fetchTexel(uTriAttributes, a + 3);
+ uv0 = vec2(a0.w, a3.x);
+ uv1 = vec2(a1.w, a3.y);
+ uv2 = vec2(a2.w, a3.z);
+ return uv0 * (1.0 - bary.x - bary.y) + uv1 * bary.x + uv2 * bary.y;
+}
+
+// Any-hit for alpha-tested triangles: does this hit block the ray?
+bool alphaOpaque(int tri, vec2 bary, int matIndex) {
+ vec2 uv0, uv1, uv2;
+ vec2 uv = triangleUV(tri, bary, uv0, uv1, uv2);
+ int base = matIndex * MATERIAL_TEXELS;
+ float alpha = fetchTexel(uMaterials, base + 2).w;
+ int mode = int(fetchTexel(uMaterials, base + 3).w);
+ int map = int(fetchTexel(uMaterials, base + 4).x);
+ if (map >= 0) {
+ vec2 tuv = transformUV(uv, fetchTexel(uMaterials, base + 8).xyz, fetchTexel(uMaterials, base + 9).xyz);
+ alpha *= textureLod(uColorMaps, vec3(tuv, float(map)), 0.0).a;
+ }
+ if (mode == 1) return alpha >= fetchTexel(uMaterials, base + 5).w;
+ return rand() < alpha;
+}
+
// Closest-hit BVH traversal with a small explicit stack and near-first ordering.
bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
float closest = tMax;
int bestTri = -1;
vec2 bestBary = vec2(0.0);
int bestSphere = -1;
+ bool ground = false;
for (int i = 0; i < uSphereCount; i++) {
float t = hitSphere(ro, rd, uSpheres[i], closest);
@@ -121,13 +203,19 @@ bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
bestSphere = i;
}
}
+ float tg = hitGround(ro, rd, closest);
+ if (tg < closest) {
+ closest = tg;
+ bestSphere = -1;
+ ground = true;
+ }
if (uTriCount > 0) {
vec3 invDir = 1.0 / rd;
- int stack[32];
+ int stack[40];
int sp = 0;
int node = 0;
- for (int iter = 0; iter < 2048; iter++) {
+ for (int iter = 0; iter < 8192; iter++) {
vec4 a = fetchTexel(uBVH, node * 2);
vec4 b = fetchTexel(uBVH, node * 2 + 1);
int leftFirst = int(a.w);
@@ -136,17 +224,19 @@ bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
if (count > 0) {
for (int k = 0; k < count; k++) {
int tri = leftFirst + k;
- int base = tri * 6;
- vec3 v0 = fetchTexel(uTriangles, base).xyz;
- vec3 v1 = fetchTexel(uTriangles, base + 1).xyz;
- vec3 v2 = fetchTexel(uTriangles, base + 2).xyz;
+ int base = tri * 3;
+ vec4 v0 = fetchTexel(uTriGeometry, base);
+ vec3 v1 = fetchTexel(uTriGeometry, base + 1).xyz;
+ vec4 v2 = fetchTexel(uTriGeometry, base + 2);
float t;
vec2 bary;
- if (hitTriangle(ro, rd, v0, v1, v2, closest, t, bary)) {
+ if (hitTriangle(ro, rd, v0.xyz, v1, v2.xyz, closest, t, bary) &&
+ (v2.w < 0.5 || alphaOpaque(tri, bary, int(v0.w)))) {
closest = t;
bestTri = tri;
bestBary = bary;
bestSphere = -1;
+ ground = false;
}
}
if (sp == 0) break;
@@ -172,15 +262,28 @@ bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
node = stack[--sp];
} else {
node = left;
- if (tr != INF && sp < 32) stack[sp++] = right;
+ if (tr != INF && sp < 40) stack[sp++] = right;
}
}
}
- if (bestTri < 0 && bestSphere < 0) return false;
+ if (bestTri < 0 && bestSphere < 0 && !ground) return false;
hit.t = closest;
hit.position = ro + rd * closest;
+ hit.uv = vec2(0.0);
+ hit.dpdu = vec3(0.0);
+ hit.dpdv = vec3(0.0);
+ hit.light = -1;
+
+ if (ground) {
+ hit.frontFace = rd.y < 0.0;
+ hit.normal = vec3(0.0, hit.frontFace ? 1.0 : -1.0, 0.0);
+ hit.geomNormal = hit.normal;
+ hit.material = uGroundMaterial;
+ hit.uv = hit.position.xz;
+ return true;
+ }
if (bestSphere >= 0) {
vec4 s = uSpheres[bestSphere];
@@ -193,15 +296,30 @@ bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
return true;
}
- int base = bestTri * 6;
- vec4 t0 = fetchTexel(uTriangles, base);
- vec4 t1 = fetchTexel(uTriangles, base + 1);
- vec3 v2 = fetchTexel(uTriangles, base + 2).xyz;
- vec3 n0 = fetchTexel(uTriangles, base + 3).xyz;
- vec3 n1 = fetchTexel(uTriangles, base + 4).xyz;
- vec3 n2 = fetchTexel(uTriangles, base + 5).xyz;
+ int g = bestTri * 3;
+ vec4 p0 = fetchTexel(uTriGeometry, g);
+ vec4 p1 = fetchTexel(uTriGeometry, g + 1);
+ vec3 p2 = fetchTexel(uTriGeometry, g + 2).xyz;
+ int at = bestTri * 4;
+ vec3 n0 = fetchTexel(uTriAttributes, at).xyz;
+ vec3 n1 = fetchTexel(uTriAttributes, at + 1).xyz;
+ vec3 n2 = fetchTexel(uTriAttributes, at + 2).xyz;
+ vec2 uv0, uv1, uv2;
+ hit.uv = triangleUV(bestTri, bestBary, uv0, uv1, uv2);
+
+ vec3 e1 = p1.xyz - p0.xyz;
+ vec3 e2 = p2 - p0.xyz;
+ vec2 d1 = uv1 - uv0;
+ vec2 d2 = uv2 - uv0;
+ float det = d1.x * d2.y - d1.y * d2.x;
+ if (abs(det) > 1e-12) {
+ float r = 1.0 / det;
+ hit.dpdu = (e1 * d2.y - e2 * d1.y) * r;
+ hit.dpdv = (e2 * d1.x - e1 * d2.x) * r;
+ }
+
float w = 1.0 - bestBary.x - bestBary.y;
- vec3 ng = normalize(cross(t1.xyz - t0.xyz, v2 - t0.xyz));
+ vec3 ng = normalize(cross(e1, e2));
vec3 ns = normalize(n0 * w + n1 * bestBary.x + n2 * bestBary.y);
hit.frontFace = dot(rd, ng) < 0.0;
if (!hit.frontFace) { ng = -ng; ns = -ns; }
@@ -209,8 +327,8 @@ bool traceScene(vec3 ro, vec3 rd, float tMax, out Hit hit) {
if (dot(ns, ng) < 0.05) ns = ng;
hit.normal = ns;
hit.geomNormal = ng;
- hit.material = int(t0.w);
- hit.light = int(t1.w);
+ hit.material = int(p0.w);
+ hit.light = int(p1.w);
return true;
}
@@ -219,13 +337,14 @@ bool occluded(vec3 ro, vec3 rd, float tMax) {
for (int i = 0; i < uSphereCount; i++) {
if (hitSphere(ro, rd, uSpheres[i], tMax) < tMax) return true;
}
+ if (hitGround(ro, rd, tMax) < tMax) return true;
if (uTriCount == 0) return false;
vec3 invDir = 1.0 / rd;
- int stack[32];
+ int stack[40];
int sp = 0;
int node = 0;
- for (int iter = 0; iter < 2048; iter++) {
+ for (int iter = 0; iter < 8192; iter++) {
vec4 a = fetchTexel(uBVH, node * 2);
vec4 b = fetchTexel(uBVH, node * 2 + 1);
if (hitAABB(ro, invDir, a.xyz, b.xyz, tMax) == INF) {
@@ -237,19 +356,21 @@ bool occluded(vec3 ro, vec3 rd, float tMax) {
int count = int(b.w);
if (count > 0) {
for (int k = 0; k < count; k++) {
- int base = (leftFirst + k) * 6;
- vec3 v0 = fetchTexel(uTriangles, base).xyz;
- vec3 v1 = fetchTexel(uTriangles, base + 1).xyz;
- vec3 v2 = fetchTexel(uTriangles, base + 2).xyz;
+ int tri = leftFirst + k;
+ int base = tri * 3;
+ vec4 v0 = fetchTexel(uTriGeometry, base);
+ vec3 v1 = fetchTexel(uTriGeometry, base + 1).xyz;
+ vec4 v2 = fetchTexel(uTriGeometry, base + 2);
float t;
vec2 bary;
- if (hitTriangle(ro, rd, v0, v1, v2, tMax, t, bary)) return true;
+ if (hitTriangle(ro, rd, v0.xyz, v1, v2.xyz, tMax, t, bary) &&
+ (v2.w < 0.5 || alphaOpaque(tri, bary, int(v0.w)))) return true;
}
if (sp == 0) return false;
node = stack[--sp];
} else {
node = leftFirst;
- if (sp < 32) stack[sp++] = leftFirst + 1;
+ if (sp < 40) stack[sp++] = leftFirst + 1;
}
}
return false;
diff --git a/src/shaders/trace.frag b/src/shaders/trace.frag
index 343649f..18dacf1 100644
--- a/src/shaders/trace.frag
+++ b/src/shaders/trace.frag
@@ -2,6 +2,7 @@
precision highp float;
precision highp int;
precision highp sampler2D;
+precision highp sampler2DArray;
#include "common.glsl"
#include "scene.glsl"
@@ -9,8 +10,23 @@ precision highp sampler2D;
#include "bsdf.glsl"
uniform vec2 uResolution;
-uniform uint uFrame; // samples accumulated so far
-uniform sampler2D uPrevious; // running average
+uniform uint uSeed; // advances every sample, never resets
+uniform sampler2D uPrevColor; // running averages, see the outputs below
+uniform sampler2D uPrevAlbedo;
+uniform sampler2D uPrevNormal;
+uniform sampler2D uPrevHistory; // samples behind each pixel's averages
+
+// Temporal reprojection: when the camera has moved since the history was
+// written, each sample looks its primary hit up in the previous view instead
+// of discarding everything accumulated so far.
+uniform int uReproject;
+uniform vec3 uPrevCamPos;
+uniform vec3 uPrevCamRight;
+uniform vec3 uPrevCamUp;
+uniform vec3 uPrevCamForward;
+uniform float uPrevTanHalfFov;
+uniform vec2 uPrevResolution;
+uniform float uHistoryCap; // bounds lag and ghosting while moving
uniform vec3 uCamPos;
uniform vec3 uCamRight;
@@ -23,17 +39,15 @@ uniform float uFocusDist;
uniform int uMaxBounces;
uniform float uClamp; // max luminance an indirect sample may contribute
-out vec4 fragColor;
-
-const float RAY_OFFSET = 1e-3;
+// Running means: radiance + luminance² (for the denoiser's variance), and the
+// denoiser's guides — albedo + coverage, normal + depth — taken at the first
+// non-mirror vertex so that reflections and refractions keep their detail.
+layout(location = 0) out vec4 outColor;
+layout(location = 1) out vec4 outAlbedo;
+layout(location = 2) out vec4 outNormal;
+layout(location = 3) out vec4 outHistory;
-vec3 surfaceAlbedo(Material m, vec3 p) {
- if (m.checkerScale <= 0.0) return m.albedo;
- // Half-cell offset keeps surfaces lying exactly on a lattice plane (y = 0 floors) stable.
- vec3 q = floor(p * m.checkerScale + 0.5);
- float c = mod(q.x + q.y + q.z, 2.0);
- return c < 0.5 ? m.albedo : m.albedo2;
-}
+const float FAR = 1e4;
// Thin-lens camera ray with sub-pixel jitter for antialiasing.
void cameraRay(vec2 fragCoord, out vec3 ro, out vec3 rd) {
@@ -54,47 +68,149 @@ vec3 clampContribution(vec3 c, int bounce) {
return l > uClamp ? c * (uClamp / l) : c;
}
-vec3 tracePath(vec3 ro, vec3 rd) {
+// Resolves textures, checkers and normal maps into the BSDF inputs at a hit.
+Surface surfaceAt(Hit hit, vec3 wo, out vec3 emission) {
+ Material m = fetchMaterial(hit.material);
+ Surface s;
+ vec2 uv = transformUV(hit.uv, m.uv0, m.uv1);
+
+ s.albedo = m.albedo;
+ if (m.checkerScale > 0.0) {
+ // Half-cell offset keeps surfaces lying exactly on a lattice plane (y = 0 floors) stable.
+ vec3 q = floor(hit.position * m.checkerScale + 0.5);
+ if (mod(q.x + q.y + q.z, 2.0) >= 0.5) s.albedo = m.albedo2;
+ }
+ if (m.maps.x >= 0) s.albedo *= textureLod(uColorMaps, vec3(uv, float(m.maps.x)), 0.0).rgb;
+
+ s.metallic = m.metallic;
+ s.roughness = m.roughness;
+ if (m.maps.y >= 0) {
+ vec4 mr = textureLod(uDataMaps, vec3(uv, float(m.maps.y)), 0.0);
+ s.roughness *= mr.g;
+ s.metallic *= mr.b;
+ }
+
+ emission = m.emission;
+ if (m.maps.w >= 0) emission *= textureLod(uColorMaps, vec3(uv, float(m.maps.w)), 0.0).rgb;
+
+ s.nc = hit.normal;
+ s.n = hit.normal;
+ if (m.maps.z >= 0 && dot(hit.dpdu, hit.dpdu) > 0.0) {
+ // Tangent frame from the triangle's UV derivatives, built on the outward normal so
+ // back faces see the mirrored perturbation. glTF's +Y points to decreasing v.
+ vec3 N = hit.frontFace ? hit.normal : -hit.normal;
+ vec3 T = normalize(hit.dpdu - N * dot(N, hit.dpdu));
+ vec3 B = cross(N, T);
+ if (dot(B, -hit.dpdv) < 0.0) B = -B;
+ vec3 tn = textureLod(uDataMaps, vec3(uv, float(m.maps.z)), 0.0).xyz * 2.0 - 1.0;
+ tn.xy *= m.normalScale;
+ vec3 mapped = normalize(T * tn.x + B * tn.y + N * max(tn.z, 1e-3));
+ mapped = hit.frontFace ? mapped : -mapped;
+ if (dot(mapped, wo) > 1e-3 && dot(mapped, hit.geomNormal) > 0.0) s.n = mapped;
+ }
+
+ s.ior = m.ior;
+ s.transmission = m.transmission * (1.0 - s.metallic);
+ s.emission = emission;
+ s.clearcoat = m.clearcoat;
+ s.clearcoatRoughness = m.clearcoatRoughness;
+ s.sheenColor = m.sheenColor;
+ s.sheenRoughness = m.sheenRoughness;
+ s.thinWalled = m.thinWalled;
+ s.absorption = m.attenuationDistance > 0.0
+ ? -log(max(m.attenuationColor, vec3(1e-4))) / m.attenuationDistance
+ : vec3(0.0);
+ return s;
+}
+
+struct Guide {
+ vec3 albedo;
+ vec3 normal;
+ float depth;
+ float coverage;
+ vec3 position; // primary hit, for reprojection
+ float reuse; // 0–1: how view-independent the primary surface is, so how much history it may keep
+ bool done;
+};
+
+vec3 tracePath(vec3 ro, vec3 rd, out Guide guide) {
vec3 radiance = vec3(0.0);
vec3 throughput = vec3(1.0);
bool specularBounce = true;
float bsdfPdf = 0.0;
+ vec3 guideTint = vec3(1.0);
+ guide = Guide(vec3(1.0), -rd, FAR, 0.0, vec3(0.0), 1.0, false);
for (int bounce = 0; bounce <= uMaxBounces; bounce++) {
Hit hit;
if (!traceScene(ro, rd, INF, hit)) {
- radiance += clampContribution(throughput * environment(rd, specularBounce, bsdfPdf), bounce);
+ vec3 env = bounce == 0 && uEnvBackground == 0 ? uBackdrop : environment(rd, specularBounce, bsdfPdf);
+ radiance += clampContribution(throughput * env, bounce);
+ if (!guide.done) {
+ guide.albedo = guideTint;
+ guide.normal = -rd;
+ guide.done = true;
+ }
break;
}
+ if (bounce == 0) {
+ guide.depth = hit.t;
+ guide.coverage = 1.0;
+ guide.position = hit.position;
+ }
+
+ vec3 wo = -rd;
+ vec3 emission;
+ Surface s = surfaceAt(hit, wo, emission);
+ vec3 ng = hit.geomNormal;
- Material m = fetchMaterial(hit.material);
+ // Leaving a volume: absorb along the segment just travelled inside it.
+ if (!hit.frontFace && !s.thinWalled && s.transmission > 0.0) throughput *= exp(-s.absorption * hit.t);
- if (any(greaterThan(m.emission, vec3(0.0)))) {
+ if (any(greaterThan(emission, vec3(0.0)))) {
float w = 1.0;
if (!specularBounce && hit.light >= 0) {
float pl = lightPdf(hit.light, ro, rd, hit.t) / float(uLightCount);
w = misWeight(bsdfPdf, pl);
}
- radiance += clampContribution(throughput * m.emission * w, bounce);
- break; // emitters are treated as black bodies
+ radiance += clampContribution(throughput * emission * w, bounce);
+ if (!guide.done) {
+ guide.albedo = guideTint;
+ guide.normal = s.n;
+ guide.done = true;
+ }
+ // Explicit lights are black bodies; textured glTF emitters also reflect.
+ if (hit.light >= 0) break;
}
- vec3 albedo = surfaceAlbedo(m, hit.position);
- vec3 wo = -rd;
- vec3 n = hit.normal;
- vec3 ng = hit.geomNormal;
+ bool glass = s.transmission > 0.0 && rand() < s.transmission;
+ if (bounce == 0) {
+ // Reflections and refractions do not move with the surface, so reprojecting them smears.
+ float sharp = 1.0 - smoothstep(0.1, 0.7, s.roughness);
+ float glossy = max(sharp * mix(0.5, 1.0, s.metallic), s.transmission);
+ glossy = max(glossy, s.clearcoat * 0.5 * (1.0 - smoothstep(0.08, 0.5, s.clearcoatRoughness)));
+ guide.reuse = 1.0 - glossy;
+ }
+ bool mirror = !glass && s.metallic > 0.9 && s.roughness < 0.1;
+ if (!guide.done && !((glass || mirror) && bounce < 3)) {
+ guide.albedo = guideTint * s.albedo;
+ guide.normal = s.n;
+ guide.done = true;
+ }
- bool glass = m.transmission > 0.0 && rand() < m.transmission;
if (glass) {
vec3 wi, weight;
bool reflected;
- if (!sampleDielectric(albedo, m.roughness, m.ior, hit.frontFace, n, ng, wo, wi, weight, reflected)) break;
+ if (!sampleDielectric(s, hit.frontFace, ng, wo, wi, weight, reflected)) break;
throughput *= weight;
- ro = hit.position + ng * (reflected ? RAY_OFFSET : -RAY_OFFSET);
+ if (!guide.done) guideTint *= weight;
+ ro = offsetRay(hit.position, reflected ? ng : -ng);
rd = wi;
specularBounce = true;
} else {
- vec3 shadowOrigin = hit.position + ng * RAY_OFFSET;
+ if (!guide.done) guideTint *= s.albedo;
+ vec3 shadowOrigin = offsetRay(hit.position, ng);
+ vec3 n = s.n;
// --- Next-event estimation: sun ---------------------------------------
if (uSunEnabled != 0) {
@@ -102,40 +218,52 @@ vec3 tracePath(vec3 ro, vec3 rd) {
float NdotL = dot(n, wi);
if (NdotL > 0.0 && dot(ng, wi) > 0.0 && !occluded(shadowOrigin, wi, INF)) {
float pb;
- vec3 f = evalOpaque(albedo, m.metallic, m.roughness, n, wo, wi, pb);
+ vec3 f = evalOpaque(s, wo, wi, pb);
float w = misWeight(uSunPdf, pb);
radiance += clampContribution(throughput * f * NdotL * uSunRadiance * (w / uSunPdf), bounce);
}
}
+ // --- Next-event estimation: environment map -----------------------------
+ if (uEnvSampling != 0) {
+ LightSample ls = sampleEnvironment();
+ float NdotL = dot(n, ls.dir);
+ if (ls.pdf > 0.0 && NdotL > 0.0 && dot(ng, ls.dir) > 0.0 && !occluded(shadowOrigin, ls.dir, INF)) {
+ float pb;
+ vec3 f = evalOpaque(s, wo, ls.dir, pb);
+ float w = misWeight(ls.pdf, pb);
+ radiance += clampContribution(throughput * f * NdotL * ls.radiance * (w / ls.pdf), bounce);
+ }
+ }
+
// --- Next-event estimation: one emitter chosen uniformly --------------
if (uLightCount > 0) {
int li = min(int(rand() * float(uLightCount)), uLightCount - 1);
LightSample ls = sampleLight(li, hit.position, rand2());
float NdotL = dot(n, ls.dir);
if (ls.pdf > 0.0 && NdotL > 0.0 && dot(ng, ls.dir) > 0.0 &&
- !occluded(shadowOrigin, ls.dir, ls.dist - RAY_OFFSET)) {
+ !occluded(shadowOrigin, ls.dir, ls.dist * (1.0 - 1e-4))) {
float pl = ls.pdf / float(uLightCount);
float pb;
- vec3 f = evalOpaque(albedo, m.metallic, m.roughness, n, wo, ls.dir, pb);
+ vec3 f = evalOpaque(s, wo, ls.dir, pb);
float w = misWeight(pl, pb);
radiance += clampContribution(throughput * f * NdotL * ls.radiance * (w / pl), bounce);
}
}
// --- Continue the path -------------------------------------------------
- BSDFSample s = sampleOpaque(albedo, m.metallic, m.roughness, n, wo);
- if (s.pdf <= 0.0 || dot(s.dir, ng) <= 0.0) break;
- throughput *= s.weight;
- bsdfPdf = s.pdf;
+ BSDFSample bs = sampleOpaque(s, wo);
+ if (bs.pdf <= 0.0 || dot(bs.dir, ng) <= 0.0) break;
+ throughput *= bs.weight;
+ bsdfPdf = bs.pdf;
specularBounce = false;
ro = shadowOrigin;
- rd = s.dir;
+ rd = bs.dir;
}
// Russian roulette once the path has had a chance to carry energy.
if (bounce >= 2) {
- float p = min(0.95, max(throughput.r, max(throughput.g, throughput.b)));
+ float p = min(0.95, max3(throughput));
if (rand() >= p) break;
throughput /= p;
}
@@ -143,20 +271,85 @@ vec3 tracePath(vec3 ro, vec3 rd) {
return radiance;
}
+// Continuous pixel position of `d` (a point relative to the old camera, or a direction) in the previous view.
+bool projectToPrevious(vec3 d, out vec2 prevPix) {
+ float z = dot(d, uPrevCamForward);
+ if (z <= 1e-6) return false;
+ float aspect = uPrevResolution.x / uPrevResolution.y;
+ vec2 ndc = vec2(dot(d, uPrevCamRight) / (z * uPrevTanHalfFov * aspect), dot(d, uPrevCamUp) / (z * uPrevTanHalfFov));
+ prevPix = (ndc * 0.5 + 0.5) * uPrevResolution;
+ return all(greaterThanEqual(prevPix, vec2(0.0))) && all(lessThan(prevPix, uPrevResolution));
+}
+
+// Bilinear fetch of the previous averages from the taps that saw the same surface (or the same sky).
+bool reprojectHistory(Guide g, vec3 rd, out vec4 color, out vec4 albedo, out vec4 normal, out float samples) {
+ color = vec4(0.0);
+ albedo = vec4(0.0);
+ normal = vec4(0.0);
+ samples = 0.0;
+ bool surface = g.coverage > 0.5;
+ vec3 d = surface ? g.position - uPrevCamPos : rd;
+ vec2 prevPix;
+ if (!projectToPrevious(d, prevPix)) return false;
+ float expected = length(d);
+ vec2 f = prevPix - 0.5;
+ ivec2 base = ivec2(floor(f));
+ vec2 t = f - floor(f);
+ float wsum = 0.0;
+ for (int i = 0; i < 4; i++) {
+ ivec2 o = ivec2(i & 1, i >> 1);
+ ivec2 q = base + o;
+ if (any(lessThan(q, ivec2(0))) || any(greaterThanEqual(q, ivec2(uPrevResolution)))) continue;
+ vec4 a = texelFetch(uPrevAlbedo, q, 0);
+ vec4 n = texelFetch(uPrevNormal, q, 0);
+ bool same = surface
+ ? a.w > 0.5 && abs(n.w - expected) < 0.02 * expected && dot(normalize(n.xyz + vec3(1e-6)), g.normal) > 0.85
+ : a.w < 0.5;
+ if (!same) continue;
+ float w = (o.x == 1 ? t.x : 1.0 - t.x) * (o.y == 1 ? t.y : 1.0 - t.y);
+ color += texelFetch(uPrevColor, q, 0) * w;
+ albedo += a * w;
+ normal += n * w;
+ samples += texelFetch(uPrevHistory, q, 0).r * w;
+ wsum += w;
+ }
+ if (wsum < 1e-3) return false;
+ color /= wsum;
+ albedo /= wsum;
+ normal /= wsum;
+ samples = min(samples / wsum, uHistoryCap * g.reuse);
+ return true;
+}
+
void main() {
ivec2 pix = ivec2(gl_FragCoord.xy);
uint pixelIndex = uint(pix.x) + uint(pix.y) * uint(uResolution.x);
- g_seed = pixelIndex * 1973u + uFrame * 9277u + 26699u;
+ g_seed = pixelIndex * 1973u + uSeed * 9277u + 26699u;
pcg(g_seed);
vec3 ro, rd;
cameraRay(gl_FragCoord.xy, ro, rd);
- vec3 color = tracePath(ro, rd);
+ Guide guide;
+ vec3 color = tracePath(ro, rd, guide);
// Guard against NaNs poisoning the running average.
if (any(isnan(color)) || any(isinf(color))) color = vec3(0.0);
+ float lum = luminance(color);
+
+ vec4 prevColor, prevAlbedo, prevNormal;
+ float n;
+ if (uReproject == 0) {
+ prevColor = texelFetch(uPrevColor, pix, 0);
+ prevAlbedo = texelFetch(uPrevAlbedo, pix, 0);
+ prevNormal = texelFetch(uPrevNormal, pix, 0);
+ n = texelFetch(uPrevHistory, pix, 0).r;
+ } else if (!reprojectHistory(guide, rd, prevColor, prevAlbedo, prevNormal, n)) {
+ n = 0.0; // disoccluded: start this pixel over
+ }
- vec3 prev = texelFetch(uPrevious, pix, 0).rgb;
- float n = float(uFrame);
- fragColor = vec4((prev * n + color) / (n + 1.0), 1.0);
+ float k = 1.0 / (n + 1.0);
+ outColor = mix(prevColor, vec4(color, lum * lum), k);
+ outAlbedo = mix(prevAlbedo, vec4(guide.albedo, guide.coverage), k);
+ outNormal = mix(prevNormal, vec4(guide.normal, guide.depth), k);
+ outHistory = vec4(n + 1.0);
}
diff --git a/src/shaders/variance.frag b/src/shaders/variance.frag
new file mode 100644
index 0000000..f0cbc5f
--- /dev/null
+++ b/src/shaders/variance.frag
@@ -0,0 +1,55 @@
+#version 300 es
+precision highp float;
+precision highp sampler2D;
+
+#include "denoise.glsl"
+
+uniform sampler2D uColor; // running mean radiance (rgb), running mean luminance² (a)
+uniform sampler2D uHistory; // samples behind each pixel's mean
+
+out vec4 fragColor;
+
+// Demodulates and estimates the variance of each pixel's mean. With enough
+// samples the accumulator's second moment gives it directly; for the first few
+// (the moving camera) it is estimated from similar neighbours, as in SVGF.
+void main() {
+ ivec2 p = ivec2(gl_FragCoord.xy);
+ vec4 c = texelFetch(uColor, p, 0);
+ vec4 a = texelFetch(uAlbedo, p, 0);
+ if (a.w < 0.5) {
+ fragColor = vec4(c.rgb, 0.0);
+ return;
+ }
+ vec3 illum = demodulate(c.rgb, a.rgb);
+ float samples = max(texelFetch(uHistory, p, 0).r, 1.0);
+ float la = max(luminance(a.rgb), 1e-3);
+
+ float variance;
+ if (samples >= 4.0) {
+ float l = luminance(c.rgb);
+ variance = max(0.0, c.a - l * l) / (la * la) / samples;
+ } else {
+ vec4 np = texelFetch(uNormal, p, 0);
+ vec3 n = unitNormal(np.xyz);
+ float s1 = 0.0;
+ float s2 = 0.0;
+ float ws = 0.0;
+ for (int dy = -3; dy <= 3; dy++) {
+ for (int dx = -3; dx <= 3; dx++) {
+ ivec2 q = p + ivec2(dx, dy);
+ if (!inside(q)) continue;
+ vec4 aq = texelFetch(uAlbedo, q, 0);
+ if (aq.w < 0.5) continue;
+ vec4 nq = texelFetch(uNormal, q, 0);
+ float w = pow(max(0.0, dot(n, unitNormal(nq.xyz))), 32.0) * step(abs(np.w - nq.w), 0.1 * np.w);
+ float l = luminance(demodulate(texelFetch(uColor, q, 0).rgb, aq.rgb));
+ s1 += w * l;
+ s2 += w * l * l;
+ ws += w;
+ }
+ }
+ float m1 = s1 / max(ws, 1e-6);
+ variance = max(0.0, s2 / max(ws, 1e-6) - m1 * m1) / samples;
+ }
+ fragColor = vec4(illum, variance);
+}
diff --git a/src/ui/drop.ts b/src/ui/drop.ts
new file mode 100644
index 0000000..4b2faff
--- /dev/null
+++ b/src/ui/drop.ts
@@ -0,0 +1,72 @@
+import type { InputFile } from '../loaders/model';
+import { h } from './dom';
+
+/** Reads a dropped folder recursively, keeping relative paths so .gltf URIs resolve. */
+async function readEntry(entry: FileSystemEntry, prefix: string, out: InputFile[]): Promise {
+ if (entry.isFile) {
+ const file = await new Promise((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject));
+ out.push({ file, path: prefix + file.name });
+ return;
+ }
+ if (!entry.isDirectory) return;
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
+ // readEntries returns results in batches until it yields an empty array.
+ for (;;) {
+ const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
+ if (batch.length === 0) break;
+ for (const child of batch) await readEntry(child, `${prefix}${entry.name}/`, out);
+ }
+}
+
+export async function filesFromDrop(dt: DataTransfer): Promise {
+ const entries = Array.from(dt.items)
+ .map((item) => (item.kind === 'file' ? item.webkitGetAsEntry() : null))
+ .filter((e): e is FileSystemEntry => e !== null);
+ if (entries.length === 0) return Array.from(dt.files).map((file) => ({ file, path: file.name }));
+ const out: InputFile[] = [];
+ for (const e of entries) await readEntry(e, '', out);
+ return out;
+}
+
+/** Full-window drop target that appears while files are dragged over the page. */
+export function installDropZone(root: HTMLElement, onFiles: (files: InputFile[]) => void): void {
+ const overlay = h('div', { class: 'dropzone', hidden: true },
+ h('div', { class: 'dropzone-card' },
+ h('strong', {}, 'Drop to load'),
+ h('span', {}, 'A .glb, a .gltf with its .bin and textures (or their folder), or an .hdr environment.'),
+ ),
+ );
+ root.append(overlay);
+ let depth = 0;
+ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
+ window.addEventListener('dragenter', (e) => {
+ if (!hasFiles(e)) return;
+ depth++;
+ overlay.hidden = false;
+ });
+ window.addEventListener('dragleave', () => {
+ depth = Math.max(0, depth - 1);
+ if (depth === 0) overlay.hidden = true;
+ });
+ window.addEventListener('dragover', (e) => {
+ if (!hasFiles(e)) return;
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
+ });
+ window.addEventListener('drop', (e) => {
+ e.preventDefault();
+ depth = 0;
+ overlay.hidden = true;
+ if (e.dataTransfer) void filesFromDrop(e.dataTransfer).then(onFiles);
+ });
+}
+
+/** Opens the native file picker; resolves with nothing if the user cancels. */
+export function pickFiles(): Promise {
+ return new Promise((resolve) => {
+ const input = h('input', { type: 'file', multiple: true, accept: '.glb,.gltf,.bin,.png,.jpg,.jpeg,.webp,.hdr' });
+ input.addEventListener('change', () => resolve(Array.from(input.files ?? []).map((file) => ({ file, path: file.name }))));
+ input.addEventListener('cancel', () => resolve([]));
+ input.click();
+ });
+}
diff --git a/src/ui/gallery-view.ts b/src/ui/gallery-view.ts
new file mode 100644
index 0000000..fa3276a
--- /dev/null
+++ b/src/ui/gallery-view.ts
@@ -0,0 +1,74 @@
+import { MODELS, type GalleryModel } from '../gallery';
+import { h } from './dom';
+
+export interface PresetCard {
+ index: number;
+ name: string;
+ description: string;
+ thumbnail: string;
+}
+
+export interface GalleryEvents {
+ onPreset: (index: number) => void;
+ onModel: (model: GalleryModel) => void;
+}
+
+/** Modal grid of built-in scenes and remote glTF sample models. */
+export class GalleryView {
+ readonly root: HTMLElement;
+
+ constructor(presets: PresetCard[], events: GalleryEvents) {
+ const card = (thumb: string, title: string, lines: string[], onPick: () => void) =>
+ h('button', {
+ class: 'card',
+ onClick: () => {
+ this.close();
+ onPick();
+ },
+ },
+ h('img', { src: thumb, alt: '', loading: 'lazy', decoding: 'async' }),
+ h('span', { class: 'card-title' }, title),
+ ...lines.map((l, i) => h('span', { class: i === 0 ? 'card-line' : 'card-meta' }, l)),
+ );
+
+ this.root = h('div', {
+ class: 'gallery',
+ role: 'dialog',
+ 'aria-modal': 'true',
+ 'aria-label': 'Gallery',
+ hidden: true,
+ onClick: (e: Event) => {
+ if (e.target === this.root) this.close();
+ },
+ },
+ h('div', { class: 'gallery-sheet' },
+ h('header', { class: 'gallery-header' },
+ h('h2', {}, 'Gallery'),
+ h('button', { class: 'btn gallery-close', 'aria-label': 'Close gallery', onClick: () => this.close() }, 'Close'),
+ ),
+ h('h3', {}, 'Built-in scenes'),
+ h('div', { class: 'card-grid' },
+ ...presets.map((p) => card(p.thumbnail, p.name, [p.description], () => events.onPreset(p.index))),
+ ),
+ h('h3', {}, 'glTF sample models'),
+ h('p', { class: 'gallery-note' },
+ 'Streamed from the Khronos glTF-Sample-Assets repository and lit by Poly Haven HDRIs (CC0).'),
+ h('div', { class: 'card-grid' },
+ ...MODELS.map((m) => card(m.thumbnail, m.name, [m.features, `${m.credit} · ${m.megabytes} MB`], () => events.onModel(m))),
+ ),
+ ),
+ );
+ }
+
+ get isOpen(): boolean {
+ return !this.root.hidden;
+ }
+
+ open(): void {
+ this.root.hidden = false;
+ }
+
+ close(): void {
+ this.root.hidden = true;
+ }
+}
diff --git a/src/ui/hud.ts b/src/ui/hud.ts
new file mode 100644
index 0000000..9d1d748
--- /dev/null
+++ b/src/ui/hud.ts
@@ -0,0 +1,19 @@
+import type { RenderStats } from '../renderer';
+import { formatCount, formatDuration, h } from './dom';
+
+/** Corner readout of convergence and measured throughput; visible even with the panel hidden. */
+export class Hud {
+ readonly root = h('div', { class: 'hud', 'aria-live': 'off' });
+
+ update(st: RenderStats): void {
+ const items: (string | HTMLElement)[] = [
+ `${formatCount(st.samples)} spp`,
+ `${st.sppPerSec >= 100 ? st.sppPerSec.toFixed(0) : st.sppPerSec.toFixed(1)} spp/s`,
+ `${st.msamplesPerSec.toFixed(1)} Mpaths/s`,
+ formatDuration(st.elapsedMs),
+ ];
+ if (st.interactive) items.push(h('span', { class: 'hud-tag' }, `preview ${Math.round((st.width / st.viewWidth) * 100)}%`));
+ if (st.denoised) items.push(h('span', { class: 'hud-tag' }, 'denoised'));
+ this.root.replaceChildren(...items.map((i) => (typeof i === 'string' ? h('span', {}, i) : i)));
+ }
+}
diff --git a/src/ui/panel.ts b/src/ui/panel.ts
index 31fdcea..8e4ac65 100644
--- a/src/ui/panel.ts
+++ b/src/ui/panel.ts
@@ -1,11 +1,15 @@
+import { HDRIS } from '../gallery';
import type { Renderer, RenderStats, Tonemap } from '../renderer';
-import { PRESETS } from '../scene/presets';
import type { Scene } from '../scene/scene';
import { formatCount, formatDuration, h } from './dom';
export interface PanelEvents {
- onSelectScene: (index: number) => void;
+ onOpenGallery: () => void;
+ onOpenFile: () => void;
+ /** `sky`, a Poly Haven id from `HDRIS`, or `custom` for the last dropped .hdr. */
+ onSelectEnvironment: (id: string) => void;
onLightingChange: () => void;
+ onGroundChange: () => void;
onCameraChange: () => void;
onSettingsChange: () => void;
onResolutionChange: () => void;
@@ -24,29 +28,53 @@ interface SliderSpec {
onInput: () => void;
}
+interface ToggleSpec {
+ label: string;
+ get: () => boolean;
+ set: (v: boolean) => void;
+ onChange: () => void;
+}
+
+/** "ANGLE (AMD, AMD Radeon RX 7600 (0x…) Direct3D11 …)" → "AMD Radeon RX 7600". */
+export const gpuName = (renderer: string): string =>
+ /^ANGLE \([^,]+, ([^,(]+)/.exec(renderer)?.[1]?.trim() ?? renderer;
+
/** Builds the control panel. Pure DOM, no framework. */
export class Panel {
readonly root: HTMLElement;
private readonly statsEl: HTMLElement;
private readonly sliders: { spec: SliderSpec; input: HTMLInputElement; value: HTMLElement }[] = [];
+ private readonly toggles: { spec: ToggleSpec; input: HTMLInputElement }[] = [];
+ private readonly sceneName: HTMLElement;
private readonly sceneDesc: HTMLElement;
private readonly pauseBtn: HTMLButtonElement;
+ private readonly envSelect: HTMLSelectElement;
+ private readonly customOption: HTMLOptionElement;
+ private readonly sunSection: HTMLElement;
+ private readonly hdriFields: HTMLElement;
private scene: Scene;
constructor(
private readonly renderer: Renderer,
- initialScene: number,
+ scene: Scene,
events: PanelEvents,
) {
- this.scene = renderer.currentScene ?? PRESETS[initialScene]!();
+ this.scene = scene;
this.statsEl = h('div', { class: 'stats' });
- this.sceneDesc = h('p', { class: 'scene-desc' }, this.scene.description);
+ this.sceneName = h('h2', { class: 'scene-name' }, scene.name);
+ this.sceneDesc = h('p', { class: 'scene-desc' }, scene.description);
this.pauseBtn = h('button', { class: 'btn', onClick: () => this.togglePause() }, 'Pause');
- const sceneSelect = h('select', {
+ this.customOption = h('option', { value: 'custom', hidden: true }, 'Dropped HDRI');
+ this.envSelect = h('select', {
class: 'select',
- onChange: (e: Event) => events.onSelectScene(Number((e.target as HTMLSelectElement).value)),
- }, ...PRESETS.map((p, i) => h('option', { value: i, selected: i === initialScene }, p().name)));
+ 'aria-label': 'Environment',
+ onChange: (e: Event) => events.onSelectEnvironment((e.target as HTMLSelectElement).value),
+ },
+ h('option', { value: 'sky' }, 'Procedural sky + sun'),
+ ...HDRIS.map((e) => h('option', { value: e.id }, `${e.name} (HDRI)`)),
+ this.customOption,
+ );
const tonemapSelect = h('select', {
class: 'select',
@@ -61,6 +89,20 @@ export class Panel {
const cam = () => this.scene.camera;
const sun = () => this.scene.sun;
const sky = () => this.scene.sky;
+ const env = () => this.scene.env;
+
+ this.hdriFields = h('div', { class: 'section-body' },
+ this.slider({ label: 'Rotation', min: 0, max: 360, step: 1, get: () => env().rotation, set: (v) => (env().rotation = v), format: (v) => `${v}°`, onInput: events.onLightingChange }),
+ this.slider({ label: 'Intensity', min: 0, max: 4, step: 0.05, get: () => env().intensity, set: (v) => (env().intensity = v), format: (v) => `${v.toFixed(2)}×`, onInput: events.onLightingChange }),
+ );
+
+ this.sunSection = this.section('Sun & sky',
+ this.slider({ label: 'Sun elevation', min: -5, max: 90, step: 0.5, get: () => sun().elevation, set: (v) => (sun().elevation = v), format: (v) => `${v}°`, onInput: events.onLightingChange }),
+ this.slider({ label: 'Sun azimuth', min: 0, max: 360, step: 1, get: () => sun().azimuth, set: (v) => (sun().azimuth = v), format: (v) => `${v}°`, onInput: events.onLightingChange }),
+ this.slider({ label: 'Sun intensity', min: 0, max: 60, step: 0.5, get: () => sun().intensity, set: (v) => (sun().intensity = v), onInput: events.onLightingChange }),
+ this.slider({ label: 'Sun size', min: 0.25, max: 8, step: 0.05, get: () => sun().angularRadius, set: (v) => (sun().angularRadius = v), format: (v) => `${v.toFixed(2)}°`, onInput: events.onLightingChange }),
+ this.slider({ label: 'Sky intensity', min: 0, max: 4, step: 0.05, get: () => sky().intensity, set: (v) => (sky().intensity = v), format: (v) => v.toFixed(2), onInput: events.onLightingChange }),
+ );
this.root = h('aside', { class: 'panel' },
h('header', { class: 'panel-header' },
@@ -72,15 +114,29 @@ export class Panel {
),
this.section('Scene',
- sceneSelect,
+ h('div', { class: 'row' },
+ h('button', { class: 'btn btn-accent', title: 'Built-in scenes and glTF sample models (G)', onClick: () => events.onOpenGallery() }, 'Gallery'),
+ h('button', { class: 'btn', title: 'Open a .glb, .gltf (with its files) or .hdr', onClick: () => events.onOpenFile() }, 'Open file…'),
+ ),
+ this.sceneName,
this.sceneDesc,
+ h('p', { class: 'hint' }, 'Or drop a .glb / .gltf / .hdr anywhere.'),
h('div', { class: 'row' },
this.pauseBtn,
h('button', { class: 'btn', onClick: () => events.onReset() }, 'Restart'),
- h('button', { class: 'btn btn-accent', onClick: () => events.onScreenshot() }, 'Save PNG'),
+ h('button', { class: 'btn', onClick: () => events.onScreenshot() }, 'Save PNG'),
),
),
+ this.section('Environment',
+ this.envSelect,
+ this.hdriFields,
+ this.toggle({ label: 'Show background', get: () => env().background, set: (v) => (env().background = v), onChange: events.onLightingChange }),
+ this.toggle({ label: 'Ground plane', get: () => this.scene.ground.enabled, set: (v) => (this.scene.ground.enabled = v), onChange: events.onGroundChange }),
+ ),
+
+ this.sunSection,
+
this.section('Camera',
this.slider({ label: 'Field of view', min: 10, max: 110, step: 1, get: () => cam().fov, set: (v) => (cam().fov = v), format: (v) => `${v}°`, onInput: events.onCameraChange }),
this.slider({ label: 'Aperture', min: 0, max: 0.25, step: 0.001, get: () => cam().aperture, set: (v) => (cam().aperture = v), format: (v) => v.toFixed(3), onInput: events.onCameraChange }),
@@ -88,18 +144,13 @@ export class Panel {
h('p', { class: 'hint' }, 'Click anywhere in the image to focus there.'),
),
- this.section('Lighting',
- this.slider({ label: 'Sun elevation', min: -5, max: 90, step: 0.5, get: () => sun().elevation, set: (v) => (sun().elevation = v), format: (v) => `${v}°`, onInput: events.onLightingChange }),
- this.slider({ label: 'Sun azimuth', min: 0, max: 360, step: 1, get: () => sun().azimuth, set: (v) => (sun().azimuth = v), format: (v) => `${v}°`, onInput: events.onLightingChange }),
- this.slider({ label: 'Sun intensity', min: 0, max: 60, step: 0.5, get: () => sun().intensity, set: (v) => (sun().intensity = v), onInput: events.onLightingChange }),
- this.slider({ label: 'Sun size', min: 0.25, max: 8, step: 0.05, get: () => sun().angularRadius, set: (v) => (sun().angularRadius = v), format: (v) => `${v.toFixed(2)}°`, onInput: events.onLightingChange }),
- this.slider({ label: 'Sky intensity', min: 0, max: 4, step: 0.05, get: () => sky().intensity, set: (v) => (sky().intensity = v), format: (v) => v.toFixed(2), onInput: events.onLightingChange }),
- ),
-
this.section('Integrator',
+ this.toggle({ label: 'Denoiser', get: () => s.denoise, set: (v) => (s.denoise = v), onChange: () => undefined }),
+ this.toggle({ label: 'Reuse samples while moving', get: () => s.temporal, set: (v) => (s.temporal = v), onChange: () => undefined }),
+ this.toggle({ label: 'Lower resolution when slow', get: () => s.adaptiveResolution, set: (v) => (s.adaptiveResolution = v), onChange: () => undefined }),
this.slider({ label: 'Max bounces', min: 1, max: 24, step: 1, get: () => s.maxBounces, set: (v) => (s.maxBounces = v), onInput: events.onSettingsChange }),
this.slider({ label: 'Firefly clamp', min: 0, max: 50, step: 0.5, get: () => s.fireflyClamp, set: (v) => (s.fireflyClamp = v), format: (v) => (v === 0 ? 'off' : v.toFixed(1)), onInput: events.onSettingsChange }),
- this.slider({ label: 'Samples / frame', min: 1, max: 8, step: 1, get: () => s.samplesPerFrame, set: (v) => (s.samplesPerFrame = v), onInput: () => undefined }),
+ this.slider({ label: 'Samples / frame', min: 0, max: 16, step: 1, get: () => s.samplesPerFrame, set: (v) => (s.samplesPerFrame = v), format: (v) => (v === 0 ? 'auto' : String(v)), onInput: () => undefined }),
this.slider({ label: 'Resolution', min: 0.25, max: 2, step: 0.25, get: () => s.resolutionScale, set: (v) => (s.resolutionScale = v), format: (v) => `${Math.round(v * 100)}%`, onInput: events.onResolutionChange }),
),
@@ -111,24 +162,42 @@ export class Panel {
this.section('Stats', this.statsEl),
h('footer', { class: 'panel-footer' },
- h('kbd', {}, 'Space'), ' pause · ', h('kbd', {}, 'R'), ' restart · ', h('kbd', {}, 'S'), ' save · ', h('kbd', {}, 'H'), ' hide panel',
+ h('kbd', {}, 'Space'), ' pause · ', h('kbd', {}, 'R'), ' restart · ', h('kbd', {}, 'S'), ' save · ',
+ h('kbd', {}, 'G'), ' gallery · ', h('kbd', {}, 'D'), ' denoiser · ', h('kbd', {}, 'H'), ' hide',
),
);
+ this.setScene(scene);
}
- /** Points the sliders at a new scene object and refreshes their values. */
+ /** Points the controls at a new scene object and refreshes their values. */
setScene(scene: Scene): void {
this.scene = scene;
+ this.sceneName.textContent = scene.name;
this.sceneDesc.textContent = scene.description;
this.refresh();
}
+ /** Selects the environment entry; `customName` labels a dropped file. */
+ setEnvironment(id: string, customName?: string): void {
+ if (customName) {
+ this.customOption.textContent = `${customName} (dropped)`;
+ this.customOption.hidden = false;
+ }
+ this.envSelect.value = id;
+ this.refresh();
+ }
+
refresh(): void {
for (const { spec, input, value } of this.sliders) {
const v = spec.get();
input.value = String(v);
value.textContent = spec.format ? spec.format(v) : String(v);
}
+ for (const { spec, input } of this.toggles) input.checked = spec.get();
+ const hdri = this.scene.env.map !== null;
+ this.sunSection.hidden = hdri;
+ this.hdriFields.hidden = !hdri;
+ if (!hdri) this.envSelect.value = 'sky';
}
togglePause(): void {
@@ -141,14 +210,18 @@ export class Panel {
}
updateStats(st: RenderStats): void {
+ const res = st.interactive
+ ? `${st.width} × ${st.height} of ${st.viewWidth} × ${st.viewHeight}`
+ : `${st.width} × ${st.height}`;
const rows: [string, string][] = [
['Samples / pixel', formatCount(st.samples)],
['Elapsed', formatDuration(st.elapsedMs)],
- ['Throughput', `${st.msamplesPerSec.toFixed(1)} Msamples/s`],
- ['Resolution', `${st.width} × ${st.height}`],
+ ['Throughput', `${st.msamplesPerSec.toFixed(1)} Mpaths/s`],
+ ['Resolution', res],
['Triangles', formatCount(st.triangles)],
['BVH nodes', formatCount(st.bvhNodes)],
- ['GPU', st.renderer],
+ ['Textures', st.textures > 0 ? `${st.textures} @ ${st.textureSize}²` : 'none'],
+ ['GPU', gpuName(st.renderer)],
];
this.statsEl.replaceChildren(
...rows.map(([k, v]) => h('div', { class: 'stat' }, h('span', { class: 'stat-k' }, k), h('span', { class: 'stat-v', title: v }, v))),
@@ -157,11 +230,10 @@ export class Panel {
private section(title: string, ...children: (Node | string)[]): HTMLElement {
const body = h('div', { class: 'section-body' }, ...children);
- const el = h('details', { class: 'section', open: true },
+ return h('details', { class: 'section', open: true },
h('summary', {}, title),
body,
);
- return el;
}
private slider(spec: SliderSpec): HTMLElement {
@@ -186,4 +258,17 @@ export class Panel {
input,
);
}
+
+ private toggle(spec: ToggleSpec): HTMLElement {
+ const input = h('input', {
+ type: 'checkbox',
+ checked: spec.get(),
+ onChange: (e: Event) => {
+ spec.set((e.target as HTMLInputElement).checked);
+ spec.onChange();
+ },
+ });
+ this.toggles.push({ spec, input });
+ return h('label', { class: 'toggle' }, input, h('span', { class: 'switch', 'aria-hidden': 'true' }), h('span', {}, spec.label));
+ }
}
diff --git a/src/ui/styles.css b/src/ui/styles.css
index 0b0e693..10fa22c 100644
--- a/src/ui/styles.css
+++ b/src/ui/styles.css
@@ -13,6 +13,7 @@
}
* { box-sizing: border-box; }
+[hidden] { display: none !important; }
html, body {
margin: 0;
@@ -99,6 +100,7 @@ canvas.view:active { cursor: grabbing; }
.section[open] summary::before { transform: rotate(45deg) translateY(-2px); }
.section-body { display: flex; flex-direction: column; gap: 8px; }
+.scene-name { margin: 2px 0 0; font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.scene-desc { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.hint { margin: 0; color: var(--muted); font-size: 11.5px; font-style: italic; }
@@ -150,6 +152,30 @@ input[type='range']:focus-visible { outline: 2px solid var(--accent-2); outline-
.btn-accent:hover { background: rgba(255, 184, 107, 0.24); }
.row { display: flex; gap: 6px; }
+.toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; line-height: 1.3; }
+.toggle input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
+.switch {
+ position: relative;
+ flex: none;
+ width: 28px; height: 16px;
+ border-radius: 8px;
+ background: var(--track);
+ border: 1px solid var(--panel-border);
+ transition: background 0.15s, border-color 0.15s;
+}
+.switch::after {
+ content: '';
+ position: absolute;
+ top: 2px; left: 2px;
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ background: var(--muted);
+ transition: transform 0.15s, background 0.15s;
+}
+.toggle input:checked + .switch { background: rgba(255, 184, 107, 0.28); border-color: rgba(255, 184, 107, 0.5); }
+.toggle input:checked + .switch::after { transform: translateX(12px); background: var(--accent); }
+.toggle input:focus-visible + .switch { outline: 2px solid var(--accent-2); outline-offset: 2px; }
+
.stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-family: var(--mono); font-size: 11.5px; }
.stat { display: contents; }
.stat-k { color: var(--muted); white-space: nowrap; }
@@ -167,6 +193,123 @@ kbd {
}
/* ---- Overlays ---------------------------------------------------------- */
+.hud {
+ position: absolute;
+ top: 16px; right: 16px;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 4px 12px;
+ max-width: calc(100% - 32px);
+ padding: 7px 12px;
+ border-radius: 10px;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ font-family: var(--mono);
+ font-size: 11.5px;
+ line-height: 1.4;
+ white-space: nowrap;
+ pointer-events: none;
+}
+.hud-tag {
+ padding: 1px 7px;
+ border-radius: 6px;
+ background: rgba(124, 196, 255, 0.14);
+ color: var(--accent-2);
+}
+
+.gallery {
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(5, 6, 9, 0.6);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+}
+.gallery-sheet {
+ width: min(1040px, 100%);
+ max-height: 100%;
+ overflow-y: auto;
+ padding: 20px 24px 24px;
+ border-radius: var(--radius);
+ background: rgba(18, 20, 27, 0.94);
+ border: 1px solid var(--panel-border);
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
+ scrollbar-width: thin;
+}
+.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.gallery-header h2 { margin: 0; font-size: 18px; font-weight: 650; }
+.gallery-close { flex: none; }
+.gallery h3 {
+ margin: 20px 0 10px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.gallery-note { margin: -4px 0 12px; color: var(--muted); font-size: 12px; line-height: 1.45; }
+.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 0 0 12px;
+ overflow: hidden;
+ text-align: left;
+ font: inherit;
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s, transform 0.15s;
+}
+.card:hover { border-color: rgba(255, 184, 107, 0.45); background: rgba(255, 255, 255, 0.07); }
+.card:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
+.card img {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+ margin-bottom: 6px;
+ background: rgba(255, 255, 255, 0.04);
+}
+.card-title { padding: 0 12px; font-size: 13px; font-weight: 600; line-height: 1.3; }
+.card-line { padding: 0 12px; color: var(--text); opacity: 0.85; font-size: 12px; line-height: 1.4; }
+.card-meta { padding: 0 12px; color: var(--muted); font-size: 11.5px; line-height: 1.4; }
+
+.dropzone {
+ position: absolute;
+ inset: 12px;
+ z-index: 20;
+ display: grid;
+ place-items: center;
+ border: 2px dashed rgba(255, 184, 107, 0.6);
+ border-radius: 16px;
+ background: rgba(5, 6, 9, 0.55);
+ pointer-events: none;
+}
+.dropzone-card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-width: 420px;
+ padding: 18px 22px;
+ text-align: center;
+ line-height: 1.45;
+ border-radius: var(--radius);
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+}
+.dropzone-card strong { font-size: 15px; color: var(--accent); }
+.dropzone-card span { color: var(--muted); }
+
.progress {
position: absolute;
left: 0; right: 0; top: 0;
@@ -235,5 +378,10 @@ kbd {
.fatal-card pre { font-family: var(--mono); font-size: 11.5px; white-space: pre-wrap; color: #ffb3b3; max-height: 40vh; overflow: auto; }
@media (max-width: 720px) {
- .panel { width: calc(100% - 32px); bottom: auto; max-height: 70vh; }
+ .panel { width: calc(100% - 32px); bottom: auto; max-height: 60vh; }
+ .hud { top: auto; bottom: 16px; left: 16px; right: 16px; justify-content: center; }
+ .toast { bottom: 64px; }
+ .gallery { padding: 12px; }
+ .gallery-sheet { padding: 16px; }
+ .card-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}
diff --git a/test/environment.test.ts b/test/environment.test.ts
new file mode 100644
index 0000000..bfb439c
--- /dev/null
+++ b/test/environment.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from 'vitest';
+import { buildAliasTable, sampleAlias } from '../src/math/alias';
+import { parseHDR } from '../src/loaders/hdr';
+import { bakeSky, buildEnvSampler, directionFromUV, uvFromDirection, type HDRImage } from '../src/scene/environment';
+import { sheenAlbedo, sheenAlbedoLUT } from '../src/scene/sheen';
+
+function rng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s * 1664525 + 1013904223) >>> 0;
+ return s / 4294967296;
+ };
+}
+
+describe('alias table', () => {
+ it('reproduces the input distribution exactly', () => {
+ const w = [1, 0, 3, 6, 0.5, 9.5];
+ const t = buildAliasTable(w);
+ const n = w.length;
+ const total = w.reduce((a, b) => a + b, 0);
+ // Probability of bucket i = (prob[i] + Σ over j aliasing to i of (1 - prob[j])) / n.
+ const p = new Array(n).fill(0);
+ for (let j = 0; j < n; j++) {
+ p[j]! += t.prob[j]! / n;
+ p[t.alias[j]!]! += (1 - t.prob[j]!) / n;
+ }
+ p.forEach((pi, i) => expect(pi).toBeCloseTo(w[i]! / total, 6));
+ expect(t.total).toBe(total);
+ });
+
+ it('never returns a zero-weight bucket when sampled', () => {
+ const t = buildAliasTable([0, 5, 0, 1]);
+ const rand = rng(7);
+ for (let i = 0; i < 2000; i++) expect([1, 3]).toContain(sampleAlias(t, rand(), rand()));
+ });
+
+ it('falls back to uniform for all-zero weights and rejects bad input', () => {
+ const t = buildAliasTable([0, 0, 0, 0]);
+ expect(Array.from(t.pmf)).toEqual([0.25, 0.25, 0.25, 0.25]);
+ expect(t.total).toBe(0);
+ expect(() => buildAliasTable([1, -1])).toThrow();
+ expect(() => buildAliasTable([1, NaN])).toThrow();
+ expect(() => buildAliasTable([])).toThrow();
+ });
+});
+
+const header = (res: string) => new TextEncoder().encode(`#?RADIANCE\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1\n\n${res}\n`);
+const concat = (...parts: Uint8Array[]) => {
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let o = 0;
+ for (const p of parts) {
+ out.set(p, o);
+ o += p.length;
+ }
+ return out.buffer;
+};
+
+describe('parseHDR', () => {
+ it('decodes flat RGBE pixels', () => {
+ // e = 128 + 1 → scale 2^(129-136) = 1/128; (127 + 0.5) / 128 ≈ 0.996
+ const px = new Uint8Array([127, 63, 0, 129, 0, 0, 0, 0]);
+ const img = parseHDR(concat(header('-Y 1 +X 2'), px));
+ expect([img.width, img.height]).toEqual([2, 1]);
+ expect(img.data[0]).toBeCloseTo(127.5 / 128);
+ expect(img.data[1]).toBeCloseTo(63.5 / 128);
+ expect(img.data[2]).toBeCloseTo(0.5 / 128);
+ expect(Array.from(img.data.subarray(3))).toEqual([0, 0, 0]);
+ });
+
+ it('decodes run-length encoded scanlines', () => {
+ const width = 8;
+ const rle = new Uint8Array([
+ 2, 2, 0, width,
+ 128 + 8, 200, // R: run of 8 × 200
+ 4, 1, 2, 3, 4, 128 + 4, 9, // G: literal 1,2,3,4 then run of 4 × 9
+ 128 + 8, 0, // B
+ 128 + 8, 128, // E: 2^(128-136)
+ ]);
+ const img = parseHDR(concat(header(`-Y 1 +X ${width}`), rle));
+ const f = 2 ** -8;
+ for (let x = 0; x < width; x++) expect(img.data[x * 3]).toBeCloseTo(200.5 * f);
+ expect([1, 2, 3, 4, 9, 9, 9, 9].map((g) => (g + 0.5) * f)).toEqual(Array.from({ length: 8 }, (_, x) => img.data[x * 3 + 1]));
+ });
+
+ it('flips bottom-up files so rows run top to bottom', () => {
+ const top = [255, 0, 0, 128];
+ const bottom = [0, 0, 255, 128];
+ // "+Y" stores the bottom row first.
+ const img = parseHDR(concat(header('+Y 2 +X 1'), new Uint8Array([...bottom, ...top])));
+ expect(img.data[0]).toBeGreaterThan(0.5);
+ expect(img.data[5]).toBeGreaterThan(0.5);
+ });
+
+ it('rejects files that are not Radiance HDR', () => {
+ expect(() => parseHDR(new TextEncoder().encode('P6\n1 1\n255\n').buffer as ArrayBuffer)).toThrow(/Radiance/);
+ expect(() => parseHDR(concat(header('-Y 2 +X 2'), new Uint8Array(4)))).toThrow(/truncated/);
+ });
+});
+
+describe('environment sampling', () => {
+ it('maps directions to equirect coordinates and back', () => {
+ const rand = rng(3);
+ for (let i = 0; i < 100; i++) {
+ const [u, v] = [rand(), 0.01 + rand() * 0.98];
+ const [u2, v2] = uvFromDirection(directionFromUV(u, v));
+ expect(u2).toBeCloseTo(u, 9);
+ expect(v2).toBeCloseTo(v, 9);
+ }
+ expect(directionFromUV(0.5, 0).map((x) => Math.abs(+x.toFixed(9)))).toEqual([0, 1, 0]);
+ });
+
+ it('has a solid-angle pdf that integrates to one and favours bright texels', () => {
+ const width = 64;
+ const height = 32;
+ const data = new Float32Array(width * height * 3).fill(0.1);
+ const bright = (10 * width + 40) * 3;
+ data.fill(500, bright, bright + 3);
+ const img: HDRImage = { width, height, data };
+ const s = buildEnvSampler(img, 32);
+ expect([s.gridWidth, s.gridHeight]).toEqual([32, 16]);
+
+ // ∫ pdf dω over the sphere, with pdf = pdfUV / (2π² sin θ) and dω = 2π² sin θ du dv.
+ let integral = 0;
+ let maxCell = 0;
+ for (let i = 0; i < s.gridWidth * s.gridHeight; i++) {
+ integral += s.table[i * 4 + 2]! / (s.gridWidth * s.gridHeight);
+ if (s.table[i * 4 + 2]! > s.table[maxCell * 4 + 2]!) maxCell = i;
+ }
+ expect(integral).toBeCloseTo(1, 6);
+ expect(maxCell).toBe(5 * 32 + 20);
+ // The alias column stores the alias cell's pdf.
+ for (let i = 0; i < 20; i++) {
+ const a = s.table[i * 4 + 1]!;
+ expect(s.table[i * 4 + 3]).toBeCloseTo(s.table[a * 4 + 2]!, 6);
+ }
+ });
+
+ it('bakes the procedural sky so the zenith is bluer than the horizon', () => {
+ const sky = { enabled: true, zenith: [0.2, 0.4, 0.9] as const, horizon: [0.8, 0.85, 0.95] as const, ground: [0.3, 0.25, 0.2] as const, intensity: 2 };
+ const sun = { elevation: 40, azimuth: 0, color: [1, 1, 1] as const, intensity: 0, angularRadius: 1 };
+ const img = bakeSky(sky, sun, 64);
+ const at = (x: number, y: number) => Array.from(img.data.subarray((y * 64 + x) * 3, (y * 64 + x) * 3 + 3));
+ expect(at(10, 0)[2]).toBeGreaterThan(at(10, 0)[0]!);
+ expect(at(10, 0)[2]).toBeCloseTo(2 * 0.9, 1);
+ expect(at(10, 31)[0]).toBeGreaterThan(at(10, 31)[2]!);
+ expect(bakeSky({ ...sky, enabled: false }, sun, 64).data.every((x) => x === 0)).toBe(true);
+ });
+});
+
+describe('sheen albedo', () => {
+ it('stays within [0, 1] and grows with roughness', () => {
+ const lut = sheenAlbedoLUT(8, 48);
+ for (const e of lut) {
+ expect(e).toBeGreaterThanOrEqual(0);
+ expect(e).toBeLessThanOrEqual(1);
+ }
+ expect(sheenAlbedo(0.7, 1, 96)).toBeGreaterThan(sheenAlbedo(0.7, 0.2, 96));
+ });
+
+ it('matches a brute-force Monte Carlo estimate', () => {
+ // Uniform hemisphere sampling of l: E = (2π / N) Σ f(l, v) cos θl.
+ const cosV = 0.5;
+ const r = 0.6;
+ const alpha = r * r;
+ const v = [Math.sqrt(1 - cosV * cosV), 0, cosV];
+ const rand = rng(11);
+ let sum = 0;
+ const n = 200000;
+ for (let i = 0; i < n; i++) {
+ const z = rand();
+ const phi = 2 * Math.PI * rand();
+ const s = Math.sqrt(1 - z * z);
+ const l = [s * Math.cos(phi), s * Math.sin(phi), z];
+ const h = [l[0]! + v[0]!, l[1]! + v[1]!, l[2]! + v[2]!];
+ const len = Math.hypot(h[0]!, h[1]!, h[2]!);
+ const cosH = h[2]! / len;
+ const inv = 1 / alpha;
+ const d = ((2 + inv) * Math.pow(Math.max(0, 1 - cosH * cosH), inv / 2)) / (2 * Math.PI);
+ const vis = 1 / (4 * (z + cosV - z * cosV));
+ sum += d * vis * z;
+ }
+ expect(sheenAlbedo(cosV, r, 256)).toBeCloseTo((2 * Math.PI * sum) / n, 2);
+ });
+});
diff --git a/test/gltf.test.ts b/test/gltf.test.ts
new file mode 100644
index 0000000..d4d7e18
--- /dev/null
+++ b/test/gltf.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, it } from 'vitest';
+import { loadGLTF, parseGLB, textureTransform } from '../src/loaders/gltf';
+import { makeGLB, pack, toBase64 } from './helpers/glb';
+
+const noFetch = async (uri: string): Promise => {
+ throw new Error(`unexpected fetch of ${uri}`);
+};
+
+const FLOAT = 5126;
+
+/** One triangle in the XY plane, facing +Z, with normals and UVs, as a GLB. */
+function triangleGLB(node: Record = {}, extra: Record = {}) {
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
+ const indices = new Uint16Array([0, 1, 2]);
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
+ const { bytes, offsets } = pack(positions, normals, uvs, indices, png);
+ const json = {
+ asset: { version: '2.0' },
+ scene: 0,
+ scenes: [{ nodes: [0] }],
+ nodes: [{ mesh: 0, ...node }],
+ meshes: [{ primitives: [{ attributes: { POSITION: 0, NORMAL: 1, TEXCOORD_0: 2 }, indices: 3, material: 0 }] }],
+ materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 }, baseColorFactor: [1, 0.5, 0.25, 1] } }],
+ textures: [{ source: 0 }],
+ images: [{ bufferView: 4, mimeType: 'image/png' }],
+ accessors: [
+ { bufferView: 0, componentType: FLOAT, count: 3, type: 'VEC3' },
+ { bufferView: 1, componentType: FLOAT, count: 3, type: 'VEC3' },
+ { bufferView: 2, componentType: FLOAT, count: 3, type: 'VEC2' },
+ { bufferView: 3, componentType: 5123, count: 3, type: 'SCALAR' },
+ ],
+ bufferViews: [
+ { buffer: 0, byteOffset: offsets[0], byteLength: 36 },
+ { buffer: 0, byteOffset: offsets[1], byteLength: 36 },
+ { buffer: 0, byteOffset: offsets[2], byteLength: 24 },
+ { buffer: 0, byteOffset: offsets[3], byteLength: 6 },
+ { buffer: 0, byteOffset: offsets[4], byteLength: 4 },
+ ],
+ buffers: [{ byteLength: bytes.length }],
+ ...extra,
+ };
+ return makeGLB(json, bytes);
+}
+
+const vertex = (p: Float32Array, i: number) => [p[i * 3]!, p[i * 3 + 1]!, p[i * 3 + 2]!].map((x) => +x.toFixed(5) + 0);
+
+describe('parseGLB', () => {
+ it('splits the JSON and BIN chunks', () => {
+ const glb = makeGLB({ asset: { version: '2.0' } }, new Uint8Array([1, 2, 3]));
+ const { json, bin } = parseGLB(glb);
+ expect(json.asset.version).toBe('2.0');
+ expect(Array.from(bin!.subarray(0, 3))).toEqual([1, 2, 3]);
+ });
+
+ it('rejects other files', () => {
+ expect(() => parseGLB(new TextEncoder().encode('{"asset":{}}').buffer as ArrayBuffer)).toThrow(/magic/);
+ });
+});
+
+describe('loadGLTF', () => {
+ it('reads an indexed, textured triangle from a GLB', async () => {
+ const asset = await loadGLTF(triangleGLB(), noFetch);
+ expect(asset.triangleCount).toBe(1);
+ const { mesh, material } = asset.meshes[0]!;
+ expect(vertex(mesh.positions, 1)).toEqual([1, 0, 0]);
+ expect(vertex(mesh.normals, 0)).toEqual([0, 0, 1]);
+ expect(Array.from(mesh.uvs!)).toEqual([0, 0, 1, 0, 0, 1]);
+ const m = asset.materials[material]!;
+ expect(m.albedo).toEqual([1, 0.5, 0.25]);
+ expect(m.baseColorMap).toBe(0);
+ expect(asset.images).toHaveLength(1);
+ expect(asset.images[0]!.mimeType).toBe('image/png');
+ expect(Array.from(asset.images[0]!.data)).toEqual([0x89, 0x50, 0x4e, 0x47]);
+ expect(asset.bounds).toEqual({ min: [0, 0, 0], max: [1, 1, 0] });
+ });
+
+ it('applies TRS node transforms, rotating normals with the geometry', async () => {
+ // 90° about +Y maps +X to -Z and +Z to +X.
+ const s = Math.SQRT1_2;
+ const asset = await loadGLTF(triangleGLB({ translation: [1, 2, 3], rotation: [0, s, 0, s], scale: [2, 2, 2] }), noFetch);
+ const { positions, normals } = asset.meshes[0]!.mesh;
+ expect(vertex(positions, 0)).toEqual([1, 2, 3]);
+ expect(vertex(positions, 1)).toEqual([1, 2, 1]);
+ expect(vertex(positions, 2)).toEqual([1, 4, 3]);
+ expect(vertex(normals, 0)).toEqual([1, 0, 0]);
+ });
+
+ it('accumulates transforms down the node hierarchy', async () => {
+ const glb = triangleGLB({}, {
+ scenes: [{ nodes: [1] }],
+ nodes: [
+ { mesh: 0, translation: [0, 0, 5] },
+ { children: [0], matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1] },
+ ],
+ });
+ const asset = await loadGLTF(glb, noFetch);
+ expect(vertex(asset.meshes[0]!.mesh.positions, 0)).toEqual([10, 0, 5]);
+ });
+
+ it('keeps winding and normals consistent under a mirroring transform', async () => {
+ const asset = await loadGLTF(triangleGLB({ scale: [-1, 1, 1] }), noFetch);
+ const { positions: p, normals } = asset.meshes[0]!.mesh;
+ const e1 = [p[3]! - p[0]!, p[4]! - p[1]!, p[5]! - p[2]!];
+ const e2 = [p[6]! - p[0]!, p[7]! - p[1]!, p[8]! - p[2]!];
+ const gz = e1[0]! * e2[1]! - e1[1]! * e2[0]!;
+ expect(Math.sign(gz)).toBe(Math.sign(normals[2]!));
+ expect(normals[2]).toBeCloseTo(1);
+ });
+
+ it('generates flat normals when NORMAL is missing', async () => {
+ const positions = new Float32Array([0, 0, 0, 0, 0, 1, 1, 0, 0]);
+ const { bytes } = pack(positions);
+ const json = {
+ asset: { version: '2.0' },
+ nodes: [{ mesh: 0 }],
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
+ accessors: [{ bufferView: 0, componentType: FLOAT, count: 3, type: 'VEC3' }],
+ bufferViews: [{ buffer: 0, byteLength: 36 }],
+ buffers: [{ byteLength: bytes.length }],
+ };
+ const asset = await loadGLTF(makeGLB(json, bytes), noFetch);
+ expect(vertex(asset.meshes[0]!.mesh.normals, 2)).toEqual([0, 1, 0]);
+ // No material on the primitive: glTF's default white, metallic, rough material.
+ const m = asset.materials[asset.meshes[0]!.material]!;
+ expect([m.albedo, m.metallic, m.roughness]).toEqual([[1, 1, 1], 1, 1]);
+ });
+
+ it('reads interleaved, normalized and strip-ordered vertex data', async () => {
+ // Interleaved: float32 xyz + uint16 normalized uv per vertex (16 bytes stride).
+ const stride = 16;
+ const buf = new ArrayBuffer(stride * 4);
+ const dv = new DataView(buf);
+ const verts = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]];
+ verts.forEach((v, i) => {
+ v.forEach((c, k) => dv.setFloat32(i * stride + k * 4, c, true));
+ dv.setUint16(i * stride + 12, v[0]! * 65535, true);
+ dv.setUint16(i * stride + 14, 32768, true);
+ });
+ const bytes = new Uint8Array(buf);
+ const json = {
+ asset: { version: '2.0' },
+ nodes: [{ mesh: 0 }],
+ meshes: [{ primitives: [{ mode: 5, attributes: { POSITION: 0, TEXCOORD_0: 1 } }] }],
+ accessors: [
+ { bufferView: 0, componentType: FLOAT, count: 4, type: 'VEC3' },
+ { bufferView: 0, byteOffset: 12, componentType: 5123, normalized: true, count: 4, type: 'VEC2' },
+ ],
+ bufferViews: [{ buffer: 0, byteLength: bytes.length, byteStride: stride }],
+ buffers: [{ byteLength: bytes.length }],
+ };
+ const asset = await loadGLTF(makeGLB(json, bytes), noFetch);
+ const { positions: p, normals: n, uvs } = asset.meshes[0]!.mesh;
+ expect(asset.triangleCount).toBe(2);
+ // Strip triangles (0,1,2) and (1,3,2) both face +Z.
+ expect(n[2]).toBeCloseTo(1);
+ expect(n[11]).toBeCloseTo(1);
+ expect(vertex(p, 4)).toEqual([1, 1, 0]);
+ expect(uvs![2]).toBeCloseTo(1);
+ expect(uvs![3]).toBeCloseTo(32768 / 65535);
+ });
+
+ it('applies sparse accessor overrides', async () => {
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
+ const sparseIdx = new Uint16Array([2]);
+ const sparseVal = new Float32Array([0, 7, 0]);
+ const { bytes, offsets } = pack(positions, sparseIdx, sparseVal);
+ const json = {
+ asset: { version: '2.0' },
+ nodes: [{ mesh: 0 }],
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
+ accessors: [{
+ bufferView: 0, componentType: FLOAT, count: 3, type: 'VEC3',
+ sparse: { count: 1, indices: { bufferView: 1, componentType: 5123 }, values: { bufferView: 2 } },
+ }],
+ bufferViews: [
+ { buffer: 0, byteOffset: offsets[0], byteLength: 36 },
+ { buffer: 0, byteOffset: offsets[1], byteLength: 2 },
+ { buffer: 0, byteOffset: offsets[2], byteLength: 12 },
+ ],
+ buffers: [{ byteLength: bytes.length }],
+ };
+ const asset = await loadGLTF(makeGLB(json, bytes), noFetch);
+ expect(vertex(asset.meshes[0]!.mesh.positions, 2)).toEqual([0, 7, 0]);
+ });
+
+ it('loads .gltf JSON with data-URI buffers and only fetches images that are used', async () => {
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
+ const { bytes, offsets } = pack(positions, uvs);
+ const json = {
+ asset: { version: '2.0' },
+ nodes: [{ mesh: 0 }],
+ meshes: [{ primitives: [{ attributes: { POSITION: 0, TEXCOORD_0: 1 }, material: 0 }] }],
+ materials: [{ normalTexture: { index: 0, scale: 0.5 } }],
+ textures: [{ source: 1 }],
+ images: [{ uri: 'unused.png' }, { uri: 'tex%20normal.jpg' }],
+ accessors: [
+ { bufferView: 0, componentType: FLOAT, count: 3, type: 'VEC3' },
+ { bufferView: 1, componentType: FLOAT, count: 3, type: 'VEC2' },
+ ],
+ bufferViews: [
+ { buffer: 0, byteOffset: offsets[0], byteLength: 36 },
+ { buffer: 0, byteOffset: offsets[1], byteLength: 24 },
+ ],
+ buffers: [{ byteLength: bytes.length, uri: `data:application/octet-stream;base64,${toBase64(bytes)}` }],
+ };
+ const fetched: string[] = [];
+ const asset = await loadGLTF(new TextEncoder().encode(JSON.stringify(json)).buffer as ArrayBuffer, async (uri) => {
+ fetched.push(uri);
+ return new Uint8Array([9]).buffer;
+ });
+ expect(fetched).toEqual(['tex%20normal.jpg']);
+ expect(asset.images[0]!.mimeType).toBe('image/jpeg');
+ const m = asset.materials[0]!;
+ expect([m.normalMap, m.normalScale, m.baseColorMap]).toEqual([0, 0.5, -1]);
+ });
+
+ it('maps PBR extensions onto the material model', async () => {
+ const glb = triangleGLB({}, {
+ materials: [{
+ alphaMode: 'MASK',
+ alphaCutoff: 0.3,
+ emissiveFactor: [1, 0.5, 0],
+ pbrMetallicRoughness: { metallicFactor: 0.25, roughnessFactor: 0.75 },
+ extensions: {
+ KHR_materials_emissive_strength: { emissiveStrength: 4 },
+ KHR_materials_transmission: { transmissionFactor: 0.9 },
+ KHR_materials_ior: { ior: 1.33 },
+ KHR_materials_volume: { thicknessFactor: 0.1, attenuationDistance: 2, attenuationColor: [1, 0.5, 0.5] },
+ KHR_materials_clearcoat: { clearcoatFactor: 1, clearcoatRoughnessFactor: 0.1 },
+ KHR_materials_sheen: { sheenColorFactor: [0.2, 0.3, 0.4], sheenRoughnessFactor: 0.5 },
+ KHR_materials_iridescence: { iridescenceFactor: 1 },
+ },
+ }],
+ });
+ const asset = await loadGLTF(glb, noFetch);
+ const m = asset.materials[0]!;
+ expect(m.alphaMode).toBe('mask');
+ expect(m.alphaCutoff).toBe(0.3);
+ expect(m.emission).toEqual([4, 2, 0]);
+ expect([m.metallic, m.roughness, m.transmission, m.ior]).toEqual([0.25, 0.75, 0.9, 1.33]);
+ expect([m.thinWalled, m.attenuationDistance, m.attenuationColor]).toEqual([false, 2, [1, 0.5, 0.5]]);
+ expect([m.clearcoat, m.clearcoatRoughness, m.sheenRoughness]).toEqual([1, 0.1, 0.5]);
+ expect(m.sheenColor).toEqual([0.2, 0.3, 0.4]);
+ expect(asset.warnings.some((w) => w.includes('KHR_materials_iridescence'))).toBe(true);
+ // No textures referenced, so the embedded image is not returned.
+ expect(asset.images).toHaveLength(0);
+ });
+
+ it('treats transmission without a volume as thin-walled', async () => {
+ const asset = await loadGLTF(triangleGLB({}, {
+ materials: [{ extensions: { KHR_materials_transmission: { transmissionFactor: 1 } } }],
+ }), noFetch);
+ expect(asset.materials[0]!.thinWalled).toBe(true);
+ });
+
+ it('refuses files that require unsupported compression', async () => {
+ const glb = triangleGLB({}, { extensionsRequired: ['KHR_draco_mesh_compression'] });
+ await expect(loadGLTF(glb, noFetch)).rejects.toThrow(/KHR_draco_mesh_compression/);
+ });
+
+ it('rejects glTF 1.0', async () => {
+ const buf = new TextEncoder().encode(JSON.stringify({ asset: { version: '1.0' } })).buffer as ArrayBuffer;
+ await expect(loadGLTF(buf, noFetch)).rejects.toThrow(/only 2.0/);
+ });
+});
+
+describe('textureTransform', () => {
+ const apply = (m: readonly number[], u: number, v: number) =>
+ [m[0]! * u + m[1]! * v + m[2]!, m[3]! * u + m[4]! * v + m[5]!].map((x) => +x.toFixed(6) + 0);
+
+ it('is the identity without the extension', () => {
+ expect(apply(textureTransform(undefined), 0.3, 0.7)).toEqual([0.3, 0.7]);
+ });
+
+ it('scales, then rotates counter-clockwise, then offsets', () => {
+ const m = textureTransform({ offset: [0.5, 0.25], scale: [2, 3], rotation: Math.PI / 2 });
+ // scale (1, 0) → (2, 0); rotate 90° → (0, 2); offset → (0.5, 2.25)
+ expect(apply(m, 1, 0)).toEqual([0.5, 2.25]);
+ // scale (0, 1) → (0, 3); rotate 90° → (-3, 0); offset → (-2.5, 0.25)
+ expect(apply(m, 0, 1)).toEqual([-2.5, 0.25]);
+ });
+});
diff --git a/test/helpers/glb.ts b/test/helpers/glb.ts
new file mode 100644
index 0000000..e08b664
--- /dev/null
+++ b/test/helpers/glb.ts
@@ -0,0 +1,49 @@
+/** Builds GLB containers and binary buffers for parser tests. */
+export function makeGLB(json: unknown, bin?: Uint8Array): ArrayBuffer {
+ const enc = new TextEncoder();
+ let jsonBytes = enc.encode(JSON.stringify(json));
+ const jsonPad = (4 - (jsonBytes.length % 4)) % 4;
+ if (jsonPad) {
+ const padded = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20);
+ padded.set(jsonBytes);
+ jsonBytes = padded;
+ }
+ const binPad = bin ? (4 - (bin.length % 4)) % 4 : 0;
+ const binLength = bin ? bin.length + binPad : 0;
+ const total = 12 + 8 + jsonBytes.length + (bin ? 8 + binLength : 0);
+ const out = new ArrayBuffer(total);
+ const dv = new DataView(out);
+ const u8 = new Uint8Array(out);
+ dv.setUint32(0, 0x46546c67, true);
+ dv.setUint32(4, 2, true);
+ dv.setUint32(8, total, true);
+ dv.setUint32(12, jsonBytes.length, true);
+ dv.setUint32(16, 0x4e4f534a, true);
+ u8.set(jsonBytes, 20);
+ if (bin) {
+ const o = 20 + jsonBytes.length;
+ dv.setUint32(o, binLength, true);
+ dv.setUint32(o + 4, 0x004e4942, true);
+ u8.set(bin, o + 8);
+ }
+ return out;
+}
+
+/** Concatenates typed arrays into one 4-byte-aligned buffer, returning each part's byte offset. */
+export function pack(...parts: ArrayBufferView[]): { bytes: Uint8Array; offsets: number[] } {
+ const offsets: number[] = [];
+ let size = 0;
+ for (const p of parts) {
+ offsets.push(size);
+ size += p.byteLength + ((4 - (p.byteLength % 4)) % 4);
+ }
+ const bytes = new Uint8Array(size);
+ parts.forEach((p, i) => bytes.set(new Uint8Array(p.buffer, p.byteOffset, p.byteLength), offsets[i]));
+ return { bytes, offsets };
+}
+
+export const toBase64 = (bytes: Uint8Array): string => {
+ let s = '';
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s);
+};
diff --git a/test/scene.test.ts b/test/scene.test.ts
index c3e63bc..17720d8 100644
--- a/test/scene.test.ts
+++ b/test/scene.test.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { glass, light, matte, metal, packMaterials, unpackMaterial } from '../src/scene/material';
+import { proceduralEnvironment } from '../src/scene/environment';
+import { glass, light, material, matte, metal, packMaterials, unpackMaterial } from '../src/scene/material';
import { quad, torusKnot } from '../src/scene/mesh';
import { PRESETS } from '../src/scene/presets';
-import { packScene, pickDistance, quadFromMesh, TRI_TEXELS, type Scene } from '../src/scene/scene';
+import { ATTR_TEXELS, GEO_TEXELS, packScene, pickDistance, quadFromMesh, type Scene, type SceneTexture } from '../src/scene/scene';
const baseScene = (): Scene => ({
name: 't',
@@ -11,10 +12,23 @@ const baseScene = (): Scene => ({
camera: { position: [0, 0, 5], target: [0, 0, 0], fov: 45, aperture: 0, focusDistance: 5 },
sun: { elevation: 45, azimuth: 0, color: [1, 1, 1], intensity: 1, angularRadius: 1 },
sky: { enabled: true, zenith: [0, 0, 1], horizon: [1, 1, 1], ground: [0, 0, 0], intensity: 1 },
+ env: proceduralEnvironment(),
+ ground: { enabled: false, height: 0, material: matte([0.5, 0.5, 0.5]) },
spheres: [],
meshes: [],
+ textures: [],
});
+/** Rounds every number in a structure so float32 storage compares equal to the float64 source. */
+const rounded = (x: unknown): unknown =>
+ typeof x === 'number' ? (Number.isFinite(x) ? +x.toFixed(5) : x)
+ : Array.isArray(x) ? x.map(rounded)
+ : x && typeof x === 'object' ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, rounded(v)]))
+ : x;
+
+const fakeTextures = (n: number): SceneTexture[] =>
+ Array.from({ length: n }, (_, i) => ({ image: {} as ImageBitmap, name: `t${i}` }));
+
describe('materials', () => {
it('round-trip through the texel layout', () => {
const mats = [matte([0.1, 0.2, 0.3]), metal([1, 0.8, 0.5], 0.2), glass([0.9, 1, 0.9], 1.33, 0.1), light([1, 0.5, 0.25], 10)];
@@ -29,6 +43,30 @@ describe('materials', () => {
expect(u.emission.map((x) => +x.toFixed(4))).toEqual(m.emission.map((x) => +x.toFixed(4)));
});
});
+
+ it('round-trip the glTF extension fields', () => {
+ const m = material({
+ alpha: 0.5,
+ alphaMode: 'blend',
+ alphaCutoff: 0.25,
+ clearcoat: 1,
+ clearcoatRoughness: 0.125,
+ sheenColor: [0.5, 0.25, 0.125],
+ sheenRoughness: 0.75,
+ thinWalled: true,
+ attenuationColor: [1, 0.5, 0.25],
+ attenuationDistance: 3,
+ baseColorMap: 2,
+ metalRoughMap: 0,
+ normalMap: 1,
+ emissiveMap: 3,
+ normalScale: 0.5,
+ uvTransform: [2, 0, 0.5, 0, 2, 0.25],
+ });
+ expect(rounded(unpackMaterial(packMaterials([m]), 0))).toEqual(rounded(m));
+ // Infinite attenuation distance survives as "no absorption".
+ expect(unpackMaterial(packMaterials([material()]), 0).attenuationDistance).toBe(Infinity);
+ });
});
describe('quadFromMesh', () => {
@@ -55,9 +93,9 @@ describe('packScene', () => {
];
s.spheres = [{ center: [0, 0, 0], radius: 1, material: white }];
const p = packScene(s);
- expect(p.materialCount).toBe(1);
+ expect(p.materialCount).toBe(2); // white + the (disabled) ground
expect(p.triCount).toBe(4);
- for (let t = 0; t < p.triCount; t++) expect(p.triangles[t * TRI_TEXELS * 4 + 3]).toBe(0);
+ for (let t = 0; t < p.triCount; t++) expect(p.triGeometry[t * GEO_TEXELS * 4 + 3]).toBe(0);
expect(p.sphereMaterials[0]).toBe(0);
expect(p.lightCount).toBe(0);
});
@@ -73,13 +111,73 @@ describe('packScene', () => {
expect(p.lightList[1]!.type).toBe('sphere');
expect(p.sphereLights[0]).toBe(1);
// Both quad triangles carry light index 0 in v1.w.
- expect(p.triangles[0 * TRI_TEXELS * 4 + 7]).toBe(0);
- expect(p.triangles[1 * TRI_TEXELS * 4 + 7]).toBe(0);
+ expect(p.triGeometry[0 * GEO_TEXELS * 4 + 7]).toBe(0);
+ expect(p.triGeometry[1 * GEO_TEXELS * 4 + 7]).toBe(0);
// Light table: quad at slot 0 with type 1 and radiance 5.
expect(p.lights[3]).toBe(1);
expect(p.lights[12]).toBe(5);
});
+ it('assigns sRGB and linear texture layers per usage and remaps material maps', () => {
+ const s = baseScene();
+ s.textures = fakeTextures(4);
+ const a = material({ baseColorMap: 3, normalMap: 1, metalRoughMap: 1 });
+ const b = material({ baseColorMap: 0, emissiveMap: 3, metalRoughMap: 2 });
+ s.meshes = [
+ { mesh: quad([0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]), material: a },
+ { mesh: quad([2, 0, 0], [3, 0, 0], [3, 1, 0], [2, 1, 0]), material: b },
+ ];
+ const p = packScene(s);
+ expect(p.colorLayers).toEqual([3, 0]);
+ expect(p.dataLayers).toEqual([1, 2]);
+ const ua = unpackMaterial(p.materials, 0);
+ const ub = unpackMaterial(p.materials, 1);
+ expect([ua.baseColorMap, ua.normalMap, ua.metalRoughMap, ua.emissiveMap]).toEqual([0, 0, 0, -1]);
+ expect([ub.baseColorMap, ub.emissiveMap, ub.metalRoughMap]).toEqual([1, 0, 1]);
+ });
+
+ it('rejects maps that point past the scene textures', () => {
+ const s = baseScene();
+ s.meshes = [{ mesh: quad([0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]), material: material({ normalMap: 0 }) }];
+ expect(() => packScene(s)).toThrow(/texture 0/);
+ });
+
+ it('writes UVs into the attribute texels and flags alpha-tested triangles', () => {
+ const s = baseScene();
+ s.textures = fakeTextures(1);
+ const mesh = quad([0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]);
+ const uvs = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.1, 0.2, 0.5, 0.6, 0.7, 0.8]);
+ s.meshes = [
+ { mesh: { ...mesh, uvs }, material: material({ alphaMode: 'mask', baseColorMap: 0 }) },
+ { mesh: quad([2, 0, 0], [3, 0, 0], [3, 1, 0], [2, 1, 0]), material: matte([1, 1, 1]) },
+ ];
+ const p = packScene(s);
+ const bySlot = Array.from({ length: p.triCount }, (_, slot) => {
+ const g = slot * GEO_TEXELS * 4;
+ const a = slot * ATTR_TEXELS * 4;
+ const us = [p.triAttributes[a + 3]!, p.triAttributes[a + 7]!, p.triAttributes[a + 11]!];
+ const vs = [p.triAttributes[a + 12]!, p.triAttributes[a + 13]!, p.triAttributes[a + 14]!];
+ return { mat: p.triGeometry[g + 3], alpha: p.triGeometry[g + 11], uv: us.flatMap((u, i) => [u, vs[i]!]).map((x) => +x.toFixed(4)) };
+ });
+ const textured = bySlot.filter((t) => t.mat === 0);
+ expect(textured).toHaveLength(2);
+ expect(textured.every((t) => t.alpha === 1)).toBe(true);
+ expect(textured.map((t) => t.uv)).toContainEqual([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]);
+ expect(bySlot.filter((t) => t.mat === 1).every((t) => t.alpha === 0 && t.uv.every((x) => x === 0))).toBe(true);
+ });
+
+ it('packs the ground plane material and picks against it', () => {
+ const s = baseScene();
+ s.ground = { enabled: true, height: -1, material: matte([0.3, 0.3, 0.3]) };
+ const p = packScene(s);
+ expect(p.groundMaterial).toBe(0);
+ expect(pickDistance(p, { origin: [0, 1, 0], dir: [0, -1, 0] })).toBeCloseTo(2);
+ expect(pickDistance(p, { origin: [0, 1, 0], dir: [0, 1, 0] })).toBeNull();
+ // Toggling the shared ground object is picked up without repacking.
+ s.ground.enabled = false;
+ expect(pickDistance(p, { origin: [0, 1, 0], dir: [0, -1, 0] })).toBeNull();
+ });
+
it('rejects too many spheres', () => {
const s = baseScene();
s.spheres = Array.from({ length: 65 }, () => ({ center: [0, 0, 0] as const, radius: 1, material: matte([1, 1, 1]) }));