pathtracer

webgpu-based path tracer

chore: squash and init public branch

Arjun Choudhary contact@arjunchoudhary.com

commit: fc64562
ADD · .gitignore +25 -0
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.vs<
\ No newline at end of file
ADD · README.md +45 -0
--- a/README.md
+++ b/README.md
@@ -0,0 +1,45 @@
+## Overview
+
+An interactive path tracer implemented in WGSL. Supports multiple sampling methods, physically based materials including micro-facets, and realistic light sources. Primarily written to explore WGSL and the WebGPU API. So it takes some shortcuts and is pretty straightforward.
+
+This is a GPU "software" path tracer, since there is no HW-accel using RT cores, it contains manual scene intersections and hit tests.
+
+- Single megakernel compute pass, that blits the output to a viewport quad texture
+- All primitives are extracted from the model -> a SAH split BVH is constructed from that on the host
+- Expects GLTF models, since the base specification for textures and PBR mapped pretty nicely to my goals here. So roughness, metallic, emission, and albedo. Textures and the normal map
+- A BRDF for these material properties, pretty bog standard. Cosine weighted hemisphere sample for the rougher materials and a GGX distribution based lobe for the specular.
+- There is no support for transmission, IOR, or alpha textures
+- Balanced heuristic based multiple importance sampling; two NEE rays. One for direct emissives and another for the sun
+- Uses stratified animated blue noise for all the screen space level sampling and faster resolves.
+- Contains a free cam and mouse look, typical `[W][A][S][D]` and `[Q][E]` for +Z, -Z respectively. `[SHIFT]` for a speed up. The camera also contains a basic thin lens approximation.
+- Since WebGPU doesn't have bindless textures, the suggested way of doing textures is a storage buffer. This would require generating mipmaps on the host. I just stack the textures and call it a day here.
+- All shader internals are full fat linear color space, that is then tonemapped and clamped to SRGB upon blitting.
+
+## Local setup
+
+```
+pnpm install
+pnpm run dev
+```
+
+`/public` should contain the assets. Just compose the scene manually in `main.ts` position, scale, rotate.
+All the included models are licensed under Creative Commons Attribtution 4.0.
+
+## To-do
+
+- Direct lighting NEE from an HDR equirectangular map
+
+### Resources
+
+- [WebGPU specification](https://www.w3.org/TR/webgpu/)
+- [WGSL Specification](https://www.w3.org/TR/WGSL/)
+- **Jacob Bikker:** [Invaluable BVH resource](https://jacco.ompf2.com/about-me/)
+- **Christoph Peters:** [Math for importance sampling](https://momentsingraphics.de/)
+- **Jakub Boksansky:** [Crash Course in BRDF Implementation](https://boksajak.github.io/files/CrashCourseBRDF.pdf)
+- **Brent Burley:** [Physically Based Shading at Disney](https://media.disneyanimation.com/uploads/production/publication_asset/48/asset/s2012_pbs_disney_brdf_notes_v3.pdf)
+- **Möller–Trumbore:** [Ray triangle intersection test](http://www.graphics.cornell.edu/pubs/1997/MT97.pdf)
+- **Pixar ONB:** [Building an Orthonormal Basis, Revisited
+  ](https://www.jcgt.org/published/0006/01/01/paper-lowres.pdf)
+- **Uncharted 2 tonemap:** [Uncharted 2: HDR Lighting](https://www.gdcvault.com/play/1012351/Uncharted-2-HDR)
+- **Frostbite BRDF:** [Moving Frostbite to Physically Based Rendering 2.0](https://media.contentapi.ea.com/content/dam/eacom/frostbite/files/course-notes-moving-frostbite-to-pbr-v2.pdf)
+- **Reference Books**: Ray tracing Gems 1 and 2, Physically based rendering 4.0.<
\ No newline at end of file
ADD · index.css +44 -0
--- a/index.css
+++ b/index.css
@@ -0,0 +1,44 @@
+html,
+body {
+  --bs-br: 0px !important;
+  padding: 0;
+  height: 100%;
+  margin: 0;
+  font-family: 'Courier New', Courier, monospace;
+  background-color: rgb(82, 82, 82);
+  overflow: hidden;
+}
+
+.tp-rotv, .tp-rotv * {
+  border-radius: 0px !important;
+}
+
+#render {
+  display: block;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+
+  transform: translate(-50%, -50%);
+  margin: 0;
+  border-radius: 8px;
+}
+
+#pane-container {
+  position: absolute;
+  z-index: 9;
+  background-color:hsl(230, 7%, 17%);
+  color: #c8cad0;
+  border: 1px solid black;
+  border-radius: 8px;
+  overflow: clip;
+  text-align: center;
+}
+
+#pane-container-header {
+  font-size: 12px;
+  font-weight: 600;
+  padding: 1px ;
+  cursor: move;
+  z-index: 10;
+}<
\ No newline at end of file
ADD · index.html +18 -0
--- a/index.html
+++ b/index.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <link rel="stylesheet" type="text/css" href="index.css" /> 
+    <script type="module" src="src/main.ts"></script>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Vite + TS</title>
+  </head>
+  <body>
+    <div id="pane-container">
+      <div id="pane-container-header">Controls</div>
+    </div>
+    <canvas id="render" width="800" height="600"></canvas>
+
+</body>
+</html>
ADD · package-lock.json +1262 -0
--- a/package-lock.json
+++ b/package-lock.json
@@ -0,0 +1,1262 @@
+{
+  "name": "vite-project",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "vite-project",
+      "version": "0.0.0",
+      "dependencies": {
+        "@loaders.gl/core": "4.3.3",
+        "@loaders.gl/gltf": "4.3.3",
+        "gl-matrix": "^4.0.0-beta.2",
+        "tweakpane": "^4.0.5"
+      },
+      "devDependencies": {
+        "@tweakpane/core": "^2.0.5",
+        "@webgpu/types": "^0.1.53",
+        "typescript": "~5.6.2",
+        "vite": "^6.0.5",
+        "vite-plugin-glsl": "^1.4.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
+      "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
+      "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
+      "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
+      "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
+      "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
+      "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
+      "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
+      "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
+      "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
+      "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
+      "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
+      "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
+      "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
+      "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
+      "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
+      "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
+      "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
+      "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
+      "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
+      "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
+      "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
+      "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@loaders.gl/core": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.3.tgz",
+      "integrity": "sha512-RaQ3uNg4ZaVqDRgvJ2CjaOjeeHdKvbKuzFFgbGnflVB9is5bu+h3EKc3Jke7NGVvLBsZ6oIXzkwHijVsMfxv8g==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/loader-utils": "4.3.3",
+        "@loaders.gl/schema": "4.3.3",
+        "@loaders.gl/worker-utils": "4.3.3",
+        "@probe.gl/log": "^4.0.2"
+      }
+    },
+    "node_modules/@loaders.gl/draco": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.3.tgz",
+      "integrity": "sha512-f2isxvOoH4Pm5p4mGvNN9gVigUwX84j9gdKNMV1aSo56GS1KE3GS2rXaIoy1qaIHMzkPySUTEcOTwayf0hWU7A==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/loader-utils": "4.3.3",
+        "@loaders.gl/schema": "4.3.3",
+        "@loaders.gl/worker-utils": "4.3.3",
+        "draco3d": "1.5.7"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/gltf": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.3.tgz",
+      "integrity": "sha512-M7jQ7KIB5itctDmGYuT9gndmjNwk1lwQ+BV4l5CoFp38e4xJESPglj2Kj8csWdm3WJhrxIYEP4GpjXK02n8DSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/draco": "4.3.3",
+        "@loaders.gl/images": "4.3.3",
+        "@loaders.gl/loader-utils": "4.3.3",
+        "@loaders.gl/schema": "4.3.3",
+        "@loaders.gl/textures": "4.3.3",
+        "@math.gl/core": "^4.1.0"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/images": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.3.tgz",
+      "integrity": "sha512-s4InjIXqEu0T7anZLj4OBUuDBt2BNnAD0GLzSexSkBfQZfpXY0XJNl4mMf5nUKb5NDfXhIKIqv8y324US+I28A==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/loader-utils": "4.3.3"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/loader-utils": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.3.tgz",
+      "integrity": "sha512-8erUIwWLiIsZX36fFa/seZsfTsWlLk72Sibh/YZJrPAefuVucV4mGGzMBZ96LE2BUfJhadn250eio/59TUFbNw==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/schema": "4.3.3",
+        "@loaders.gl/worker-utils": "4.3.3",
+        "@probe.gl/log": "^4.0.2",
+        "@probe.gl/stats": "^4.0.2"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/schema": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.3.tgz",
+      "integrity": "sha512-zacc9/8je+VbuC6N/QRfiTjRd+BuxsYlddLX1u5/X/cg9s36WZZBlU1oNKUgTYe8eO6+qLyYx77yi+9JbbEehw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "^7946.0.7"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/textures": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.3.tgz",
+      "integrity": "sha512-qIo4ehzZnXFpPKl1BGQG4G3cAhBSczO9mr+H/bT7qFwtSirWVlqsvMlx1Q4VpmouDu+tudwwOlq7B3yqU5P5yQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/images": "4.3.3",
+        "@loaders.gl/loader-utils": "4.3.3",
+        "@loaders.gl/schema": "4.3.3",
+        "@loaders.gl/worker-utils": "4.3.3",
+        "@math.gl/types": "^4.1.0",
+        "ktx-parse": "^0.7.0",
+        "texture-compressor": "^1.0.2"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@loaders.gl/worker-utils": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.3.tgz",
+      "integrity": "sha512-eg45Ux6xqsAfqPUqJkhmbFZh9qfmYuPfA+34VcLtfeXIwAngeP6o4SrTmm9LWLGUKiSh47anCEV1p7borDgvGQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@loaders.gl/core": "^4.3.0"
+      }
+    },
+    "node_modules/@math.gl/core": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
+      "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@math.gl/types": "4.1.0"
+      }
+    },
+    "node_modules/@math.gl/types": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz",
+      "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==",
+      "license": "MIT"
+    },
+    "node_modules/@probe.gl/env": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz",
+      "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==",
+      "license": "MIT"
+    },
+    "node_modules/@probe.gl/log": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz",
+      "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@probe.gl/env": "4.1.0"
+      }
+    },
+    "node_modules/@probe.gl/stats": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz",
+      "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.1.4",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
+      "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^4.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.31.0.tgz",
+      "integrity": "sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.31.0.tgz",
+      "integrity": "sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.31.0.tgz",
+      "integrity": "sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.31.0.tgz",
+      "integrity": "sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.31.0.tgz",
+      "integrity": "sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.31.0.tgz",
+      "integrity": "sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.31.0.tgz",
+      "integrity": "sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.31.0.tgz",
+      "integrity": "sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.31.0.tgz",
+      "integrity": "sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.31.0.tgz",
+      "integrity": "sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.31.0.tgz",
+      "integrity": "sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.31.0.tgz",
+      "integrity": "sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.31.0.tgz",
+      "integrity": "sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.31.0.tgz",
+      "integrity": "sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.31.0.tgz",
+      "integrity": "sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.31.0.tgz",
+      "integrity": "sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.31.0.tgz",
+      "integrity": "sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.31.0.tgz",
+      "integrity": "sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.31.0.tgz",
+      "integrity": "sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@tweakpane/core": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz",
+      "integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "license": "MIT"
+    },
+    "node_modules/@webgpu/types": {
+      "version": "0.1.53",
+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.53.tgz",
+      "integrity": "sha512-x+BLw/opaz9LiVyrMsP75nO1Rg0QfrACUYIbVSfGwY/w0DiWIPYYrpte6us//KZXinxFAOJl0+C17L1Vi2vmDw==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "license": "MIT",
+      "dependencies": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "node_modules/draco3d": {
+      "version": "1.5.7",
+      "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+      "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/esbuild": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
+      "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.24.2",
+        "@esbuild/android-arm": "0.24.2",
+        "@esbuild/android-arm64": "0.24.2",
+        "@esbuild/android-x64": "0.24.2",
+        "@esbuild/darwin-arm64": "0.24.2",
+        "@esbuild/darwin-x64": "0.24.2",
+        "@esbuild/freebsd-arm64": "0.24.2",
+        "@esbuild/freebsd-x64": "0.24.2",
+        "@esbuild/linux-arm": "0.24.2",
+        "@esbuild/linux-arm64": "0.24.2",
+        "@esbuild/linux-ia32": "0.24.2",
+        "@esbuild/linux-loong64": "0.24.2",
+        "@esbuild/linux-mips64el": "0.24.2",
+        "@esbuild/linux-ppc64": "0.24.2",
+        "@esbuild/linux-riscv64": "0.24.2",
+        "@esbuild/linux-s390x": "0.24.2",
+        "@esbuild/linux-x64": "0.24.2",
+        "@esbuild/netbsd-arm64": "0.24.2",
+        "@esbuild/netbsd-x64": "0.24.2",
+        "@esbuild/openbsd-arm64": "0.24.2",
+        "@esbuild/openbsd-x64": "0.24.2",
+        "@esbuild/sunos-x64": "0.24.2",
+        "@esbuild/win32-arm64": "0.24.2",
+        "@esbuild/win32-ia32": "0.24.2",
+        "@esbuild/win32-x64": "0.24.2"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/gl-matrix": {
+      "version": "4.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-4.0.0-beta.2.tgz",
+      "integrity": "sha512-OF6IkQpMkF8p2CZF9EtzYZPlPaW3M41KMsgZGlTKmMv/nWaP6GMJi9V5tI+oPn8FG0io85Q5ZtKpCXP4u6YmDA==",
+      "license": "MIT"
+    },
+    "node_modules/image-size": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz",
+      "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==",
+      "license": "MIT",
+      "bin": {
+        "image-size": "bin/image-size.js"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/ktx-parse": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz",
+      "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.8",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+      "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+      "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.1",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
+      "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.8",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.31.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz",
+      "integrity": "sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.6"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.31.0",
+        "@rollup/rollup-android-arm64": "4.31.0",
+        "@rollup/rollup-darwin-arm64": "4.31.0",
+        "@rollup/rollup-darwin-x64": "4.31.0",
+        "@rollup/rollup-freebsd-arm64": "4.31.0",
+        "@rollup/rollup-freebsd-x64": "4.31.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.31.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.31.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.31.0",
+        "@rollup/rollup-linux-arm64-musl": "4.31.0",
+        "@rollup/rollup-linux-loongarch64-gnu": "4.31.0",
+        "@rollup/rollup-linux-powerpc64le-gnu": "4.31.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.31.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.31.0",
+        "@rollup/rollup-linux-x64-gnu": "4.31.0",
+        "@rollup/rollup-linux-x64-musl": "4.31.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.31.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.31.0",
+        "@rollup/rollup-win32-x64-msvc": "4.31.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/texture-compressor": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
+      "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==",
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^1.0.10",
+        "image-size": "^0.7.4"
+      },
+      "bin": {
+        "texture-compressor": "bin/texture-compressor.js"
+      }
+    },
+    "node_modules/tweakpane": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz",
+      "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/cocopon"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.6.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+      "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.0.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.9.tgz",
+      "integrity": "sha512-MSgUxHcaXLtnBPktkbUSoQUANApKYuxZ6DrbVENlIorbhL2dZydTLaZ01tjUoE3szeFzlFk9ANOKk0xurh4MKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.24.2",
+        "postcss": "^8.4.49",
+        "rollup": "^4.23.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-glsl": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/vite-plugin-glsl/-/vite-plugin-glsl-1.4.0.tgz",
+      "integrity": "sha512-mjT4AaU4qRmlpawgd0M2Qz72tvK4WF0ii2p0WbVRpr7ga6+cRScJUT3oIMv5coT8u/lqUe9u9T5+0zJLZ1uhug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rollup/pluginutils": "^5.1.4"
+      },
+      "engines": {
+        "node": ">= 20.17.0",
+        "npm": ">= 10.8.3"
+      },
+      "peerDependencies": {
+        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
+      }
+    }
+  }
+}
ADD · package.json +27 -0
--- a/package.json
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+  "name": "vite-project",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@loaders.gl/core": "4.3.3",
+    "@loaders.gl/gltf": "4.3.3",
+    "gl-matrix": "^4.0.0-beta.2",
+    "tweakpane": "^4.0.5"
+  },
+  "devDependencies": {
+    "@tweakpane/core": "^2.0.5",
+    "@webgpu/types": "^0.1.53",
+    "typescript": "~5.6.2",
+    "vite": "^6.0.5",
+    "vite-plugin-glsl": "^1.4.0"
+  },
+  "unusedDependencies": {
+    "hdr.js": "^0.2.0"
+  } 
+}
ADD · public/Duck.gltf +219 -0
--- a/public/Duck.gltf
+++ b/public/Duck.gltf
@@ -0,0 +1,219 @@
+{
+    "asset": {
+        "generator": "COLLADA2GLTF",
+        "version": "2.0"
+    },
+    "scene": 0,
+    "scenes": [
+        {
+            "nodes": [
+                0
+            ]
+        }
+    ],
+    "nodes": [
+        {
+            "children": [
+                2,
+                1
+            ],
+            "matrix": [
+                0.009999999776482582,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                0.009999999776482582,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                0.009999999776482582,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0
+            ]
+        },
+        {
+            "matrix": [
+                -0.7289686799049377,
+                0.0,
+                -0.6845470666885376,
+                0.0,
+                -0.4252049028873444,
+                0.7836934328079224,
+                0.4527972936630249,
+                0.0,
+                0.5364750623703003,
+                0.6211478114128113,
+                -0.571287989616394,
+                0.0,
+                400.1130065917969,
+                463.2640075683594,
+                -431.0780334472656,
+                1.0
+            ],
+            "camera": 0
+        },
+        {
+            "mesh": 0
+        }
+    ],
+    "cameras": [
+        {
+            "perspective": {
+                "aspectRatio": 1.5,
+                "yfov": 0.6605925559997559,
+                "zfar": 10000.0,
+                "znear": 1.0
+            },
+            "type": "perspective"
+        }
+    ],
+    "meshes": [
+        {
+            "primitives": [
+                {
+                    "attributes": {
+                        "NORMAL": 1,
+                        "POSITION": 2,
+                        "TEXCOORD_0": 3
+                    },
+                    "indices": 0,
+                    "mode": 4,
+                    "material": 0
+                }
+            ],
+            "name": "LOD3spShape"
+        }
+    ],
+    "accessors": [
+        {
+            "bufferView": 0,
+            "byteOffset": 0,
+            "componentType": 5123,
+            "count": 12636,
+            "max": [
+                2398
+            ],
+            "min": [
+                0
+            ],
+            "type": "SCALAR"
+        },
+        {
+            "bufferView": 1,
+            "byteOffset": 0,
+            "componentType": 5126,
+            "count": 2399,
+            "max": [
+                0.9995989799499512,
+                0.999580979347229,
+                0.9984359741210938
+            ],
+            "min": [
+                -0.9990839958190918,
+                -1.0,
+                -0.9998319745063782
+            ],
+            "type": "VEC3"
+        },
+        {
+            "bufferView": 1,
+            "byteOffset": 28788,
+            "componentType": 5126,
+            "count": 2399,
+            "max": [
+                96.17990112304688,
+                163.97000122070313,
+                53.92519760131836
+            ],
+            "min": [
+                -69.29850006103516,
+                9.929369926452637,
+                -61.32819747924805
+            ],
+            "type": "VEC3"
+        },
+        {
+            "bufferView": 2,
+            "byteOffset": 0,
+            "componentType": 5126,
+            "count": 2399,
+            "max": [
+                0.9833459854125976,
+                0.9800369739532472
+            ],
+            "min": [
+                0.026409000158309938,
+                0.01996302604675293
+            ],
+            "type": "VEC2"
+        }
+    ],
+    "materials": [
+        {
+            "pbrMetallicRoughness": {
+                "baseColorTexture": {
+                    "index": 0
+                },
+                "metallicFactor": 0.0
+            },
+            "emissiveFactor": [
+                0.0,
+                0.0,
+                0.0
+            ],
+            "name": "blinn3-fx"
+        }
+    ],
+    "textures": [
+        {
+            "sampler": 0,
+            "source": 0
+        }
+    ],
+    "images": [
+        {
+            "uri": "DuckCM.png"
+        }
+    ],
+    "samplers": [
+        {
+            "magFilter": 9729,
+            "minFilter": 9986,
+            "wrapS": 10497,
+            "wrapT": 10497
+        }
+    ],
+    "bufferViews": [
+        {
+            "buffer": 0,
+            "byteOffset": 76768,
+            "byteLength": 25272,
+            "target": 34963
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 0,
+            "byteLength": 57576,
+            "byteStride": 12,
+            "target": 34962
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 57576,
+            "byteLength": 19192,
+            "byteStride": 8,
+            "target": 34962
+        }
+    ],
+    "buffers": [
+        {
+            "byteLength": 102040,
+            "uri": "Duck0.bin"
+        }
+    ]
+}
ADD · public/Duck0.bin +0 -0
--- a/public/Duck0.bin
+++ b/public/Duck0.bin
ADD · public/DuckCM.png +0 -0
--- a/public/DuckCM.png
+++ b/public/DuckCM.png
ADD · public/EnvironmentTest.gltf +328 -0
--- a/public/EnvironmentTest.gltf
+++ b/public/EnvironmentTest.gltf
@@ -0,0 +1,328 @@
+{
+    "asset": {
+        "copyright": "2018 (c) Adobe Systems Inc.",
+        "generator": "Adobe Dimension - b417c10282aa66313155856d4a54e84f3f388647",
+        "version": "2.0"
+    },
+    "accessors": [
+        {
+            "bufferView": 0,
+            "componentType": 5126,
+            "count": 4598,
+            "type": "VEC3",
+            "max": [
+                10.647041320800782,
+                1.6470409631729127,
+                0.6470409631729126
+            ],
+            "min": [
+                -10.647041320800782,
+                0.3529590368270874,
+                -0.6470409631729126
+            ]
+        },
+        {
+            "bufferView": 1,
+            "componentType": 5126,
+            "count": 4598,
+            "type": "VEC3"
+        },
+        {
+            "bufferView": 2,
+            "componentType": 5126,
+            "count": 4598,
+            "type": "VEC2"
+        },
+        {
+            "bufferView": 3,
+            "componentType": 5125,
+            "count": 25344,
+            "type": "SCALAR",
+            "max": [
+                4597
+            ],
+            "min": [
+                0
+            ]
+        },
+        {
+            "bufferView": 4,
+            "componentType": 5126,
+            "count": 4598,
+            "type": "VEC3",
+            "max": [
+                10.647041320800782,
+                -0.3529590368270874,
+                0.6470409631729126
+            ],
+            "min": [
+                -10.647041320800782,
+                -1.6470409631729127,
+                -0.6470409631729126
+            ]
+        },
+        {
+            "bufferView": 5,
+            "componentType": 5126,
+            "count": 4598,
+            "type": "VEC2"
+        }
+    ],
+    "bufferViews": [
+        {
+            "buffer": 0,
+            "byteOffset": 0,
+            "byteLength": 55176,
+            "target": 34962
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 55176,
+            "byteLength": 55176,
+            "target": 34962
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 110352,
+            "byteLength": 36784,
+            "target": 34962
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 147136,
+            "byteLength": 101376,
+            "target": 34963
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 248512,
+            "byteLength": 55176,
+            "target": 34962
+        },
+        {
+            "buffer": 0,
+            "byteOffset": 303688,
+            "byteLength": 36784,
+            "target": 34962
+        }
+    ],
+    "buffers": [
+        {
+            "byteLength": 340472,
+            "uri": "EnvironmentTest_binary.bin"
+        }
+    ],
+    "cameras": [
+        {
+            "perspective": {
+                "znear": 0.0010000000474974514,
+                "yfov": 0.6024156808853149,
+                "zfar": 200.0,
+                "aspectRatio": 1.3333333730697632
+            },
+            "type": "perspective",
+            "name": "render_camera"
+        }
+    ],
+    "images": [
+        {
+            "name": "tmp_image_pie_dc1e_1a22_fbf9roughness_map_roughness_tmp_image_pie_dc1e_1a22_fbf9metal_map_metallic_0",
+            "uri": "roughness_metallic_0.jpg",
+            "mimeType": "image/jpeg"
+        },
+        {
+            "name": "tmp_image_pie_b20b_ebb4_317droughness_map2_roughness_tmp_image_pie_b20b_ebb4_317dmetal_map2_metallic_1",
+            "uri": "roughness_metallic_1.jpg",
+            "mimeType": "image/jpeg"
+        }
+    ],
+    "materials": [
+        {
+            "pbrMetallicRoughness": {
+                "metallicRoughnessTexture": {
+                    "index": 0
+                }
+            },
+            "name": "MetallicSpheresMat",
+            "doubleSided": true
+        },
+        {
+            "pbrMetallicRoughness": {
+                "metallicRoughnessTexture": {
+                    "index": 1
+                }
+            },
+            "name": "DielectricSpheresMat",
+            "doubleSided": true
+        },
+        {
+            "pbrMetallicRoughness": {
+                "baseColorFactor": [
+                    0.0,
+                    0.0,
+                    0.0,
+                    1.0
+                ],
+                "metallicRoughnessTexture": {
+                    "index": 1
+                }
+            },
+            "name": "DielectricSpheresMat",
+            "doubleSided": true
+        }
+    ],
+    "meshes": [
+        {
+            "name": "Metallic0_N3D",
+            "primitives": [
+                {
+                    "attributes": {
+                        "POSITION": 0,
+                        "NORMAL": 1,
+                        "TEXCOORD_0": 2
+                    },
+                    "indices": 3,
+                    "material": 0
+                }
+            ]
+        },
+        {
+            "name": "Dielectric0_N3D2",
+            "primitives": [
+                {
+                    "attributes": {
+                        "TEXCOORD_0": 5,
+                        "NORMAL": 1,
+                        "POSITION": 4
+                    },
+                    "indices": 3,
+                    "material": 1
+                }
+            ]
+        },
+        {
+            "name": "Dielectric0_N3D",
+            "primitives": [
+                {
+                    "attributes": {
+                        "POSITION": 4,
+                        "NORMAL": 1,
+                        "TEXCOORD_0": 5
+                    },
+                    "indices": 3,
+                    "material": 2
+                }
+            ]
+        }
+    ],
+    "nodes": [
+        {
+            "matrix": [
+                0.9999533295631409,
+                3.16067598760128e-8,
+                0.009662099182605744,
+                0.0,
+                0.0014864075928926468,
+                0.9880954027175903,
+                -0.15383504331111909,
+                0.0,
+                -0.009547080844640732,
+                0.15384222567081452,
+                0.988049328327179,
+                0.0,
+                -0.7599077224731445,
+                7.708760738372803,
+                27.743375778198243,
+                1.0
+            ],
+            "camera": 0,
+            "name": "render_camera_n3d"
+        },
+        {
+            "name": "ground_plane_n3d"
+        },
+        {
+            "children": [
+                3,
+                4,
+                5
+            ],
+            "matrix": [
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                -0.5564079284667969,
+                4.774584770202637,
+                -1.0962677001953126,
+                1.0
+            ],
+            "name": "ENV_Spheres"
+        },
+        {
+            "mesh": 0,
+            "name": "Metallic0"
+        },
+        {
+            "mesh": 1,
+            "name": "Dielectric0"
+        },
+        {
+            "matrix": [
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                0.0,
+                -1.985867977142334,
+                0.0,
+                1.0
+            ],
+            "mesh": 2,
+            "name": "Dielectric0-Black"
+        }
+    ],
+    "samplers": [
+        {},
+        {}
+    ],
+    "scenes": [
+        {
+            "nodes": [
+                0,
+                1,
+                2
+            ],
+            "name": "scene"
+        }
+    ],
+    "textures": [
+        {
+            "name": "tmp_image_pie_dc1e_1a22_fbf9roughness_map_roughness_tmp_image_pie_dc1e_1a22_fbf9metal_map_metallic_0_texture",
+            "sampler": 0,
+            "source": 0
+        },
+        {
+            "name": "tmp_image_pie_b20b_ebb4_317droughness_map2_roughness_tmp_image_pie_b20b_ebb4_317dmetal_map2_metallic_1_texture",
+            "sampler": 1,
+            "source": 1
+        }
+    ],
+    "scene": 0
+}<
\ No newline at end of file
ADD · public/EnvironmentTest_binary.bin +0 -0
--- a/public/EnvironmentTest_binary.bin
+++ b/public/EnvironmentTest_binary.bin
ADD · public/LDR_RGBA_0.png +0 -0
--- a/public/LDR_RGBA_0.png
+++ b/public/LDR_RGBA_0.png
ADD · public/cornell_empty_rg.bin +0 -0
--- a/public/cornell_empty_rg.bin
+++ b/public/cornell_empty_rg.bin
ADD · public/cornell_empty_rg.gltf +445 -0
--- a/public/cornell_empty_rg.gltf
+++ b/public/cornell_empty_rg.gltf
@@ -0,0 +1,445 @@
+{
+	"asset":{
+		"generator":"Khronos glTF Blender I/O v4.3.47",
+		"version":"2.0"
+	},
+	"extensionsUsed":[
+		"KHR_materials_specular",
+		"KHR_materials_ior"
+	],
+	"scene":0,
+	"scenes":[
+		{
+			"name":"Scene",
+			"nodes":[
+				0
+			]
+		}
+	],
+	"nodes":[
+		{
+			"mesh":0,
+			"name":"CornellBox-Empty-RG",
+			"rotation":[
+				0.7071068286895752,
+				0,
+				0,
+				0.7071068286895752
+			]
+		}
+	],
+	"materials":[
+		{
+			"doubleSided":true,
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				},
+				"KHR_materials_ior":{
+					"ior":1
+				}
+			},
+			"name":"floor.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.7250000238418579,
+					0.7099999785423279,
+					0.6800000071525574,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		},
+		{
+			"doubleSided":true,
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				},
+				"KHR_materials_ior":{
+					"ior":1
+				}
+			},
+			"name":"ceiling.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.7250000238418579,
+					0.7099999785423279,
+					0.6800000071525574,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		},
+		{
+			"doubleSided":true,
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				},
+				"KHR_materials_ior":{
+					"ior":1
+				}
+			},
+			"name":"backWall.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.7250000238418579,
+					0.7099999785423279,
+					0.6800000071525574,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		},
+		{
+			"doubleSided":true,
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				}
+			},
+			"name":"rightWall.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.14000000059604645,
+					0.44999998807907104,
+					0.09099999815225601,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		},
+		{
+			"doubleSided":true,
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				}
+			},
+			"name":"leftWall.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.6299999952316284,
+					0.06499999761581421,
+					0.05000000074505806,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		},
+		{
+			"doubleSided":true,
+			"emissiveFactor":[
+				1,
+				1,
+				1
+			],
+			"extensions":{
+				"KHR_materials_specular":{
+					"specularFactor":0
+				},
+				"KHR_materials_ior":{
+					"ior":1
+				}
+			},
+			"name":"light.002",
+			"pbrMetallicRoughness":{
+				"baseColorFactor":[
+					0.7799999713897705,
+					0.7799999713897705,
+					0.7799999713897705,
+					1
+				],
+				"metallicFactor":0,
+				"roughnessFactor":0.8999999761581421
+			}
+		}
+	],
+	"meshes":[
+		{
+			"name":"CornellBox-Empty-RG.002",
+			"primitives":[
+				{
+					"attributes":{
+						"POSITION":0,
+						"NORMAL":1
+					},
+					"indices":2,
+					"material":0
+				},
+				{
+					"attributes":{
+						"POSITION":3,
+						"NORMAL":4
+					},
+					"indices":2,
+					"material":1
+				},
+				{
+					"attributes":{
+						"POSITION":5,
+						"NORMAL":6
+					},
+					"indices":2,
+					"material":2
+				},
+				{
+					"attributes":{
+						"POSITION":7,
+						"NORMAL":8
+					},
+					"indices":2,
+					"material":3
+				},
+				{
+					"attributes":{
+						"POSITION":9,
+						"NORMAL":10
+					},
+					"indices":2,
+					"material":4
+				},
+				{
+					"attributes":{
+						"POSITION":11,
+						"NORMAL":12
+					},
+					"indices":2,
+					"material":5
+				}
+			]
+		}
+	],
+	"accessors":[
+		{
+			"bufferView":0,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				1,
+				0.9900000095367432,
+				0
+			],
+			"min":[
+				-1.0099999904632568,
+				-1.0399999618530273,
+				0
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":1,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		},
+		{
+			"bufferView":2,
+			"componentType":5123,
+			"count":6,
+			"type":"SCALAR"
+		},
+		{
+			"bufferView":3,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				1,
+				0.9900000095367432,
+				-1.9900000095367432
+			],
+			"min":[
+				-1.0199999809265137,
+				-1.0399999618530273,
+				-1.9900000095367432
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":4,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		},
+		{
+			"bufferView":5,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				1,
+				-1.0399999618530273,
+				0
+			],
+			"min":[
+				-1.0199999809265137,
+				-1.0399999618530273,
+				-1.9900000095367432
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":6,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		},
+		{
+			"bufferView":7,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				1,
+				0.9900000095367432,
+				0
+			],
+			"min":[
+				1,
+				-1.0399999618530273,
+				-1.9900000095367432
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":8,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		},
+		{
+			"bufferView":9,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				-0.9900000095367432,
+				0.9900000095367432,
+				0
+			],
+			"min":[
+				-1.0199999809265137,
+				-1.0399999618530273,
+				-1.9900000095367432
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":10,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		},
+		{
+			"bufferView":11,
+			"componentType":5126,
+			"count":4,
+			"max":[
+				0.23000000417232513,
+				0.1599999964237213,
+				-1.9800000190734863
+			],
+			"min":[
+				-0.23999999463558197,
+				-0.2199999988079071,
+				-1.9800000190734863
+			],
+			"type":"VEC3"
+		},
+		{
+			"bufferView":12,
+			"componentType":5126,
+			"count":4,
+			"type":"VEC3"
+		}
+	],
+	"bufferViews":[
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":0,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":48,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":12,
+			"byteOffset":96,
+			"target":34963
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":108,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":156,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":204,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":252,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":300,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":348,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":396,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":444,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":492,
+			"target":34962
+		},
+		{
+			"buffer":0,
+			"byteLength":48,
+			"byteOffset":540,
+			"target":34962
+		}
+	],
+	"buffers":[
+		{
+			"byteLength":588,
+			"uri":"cornell_empty_rg.bin"
+		}
+	]
+}
ADD · public/roughness_metallic_0.jpg +0 -0
--- a/public/roughness_metallic_0.jpg
+++ b/public/roughness_metallic_0.jpg
ADD · public/roughness_metallic_1.jpg +0 -0
--- a/public/roughness_metallic_1.jpg
+++ b/public/roughness_metallic_1.jpg
ADD · src/bvh.ts +331 -0
--- a/src/bvh.ts
+++ b/src/bvh.ts
@@ -0,0 +1,331 @@
+import { Vec3 } from "gl-matrix";
+
+const MAX_BOUND = 999999;
+
+interface Triangle {
+  cornerA: Vec3;
+  cornerB: Vec3;
+  cornerC: Vec3;
+  centroid: Vec3;
+}
+
+interface Bin {
+  instanceCount: number;
+  bounds: AABB;
+}
+
+// just for documentation
+// interface BVHNode { 
+//   min: Vec3; // 12 
+//   max: Vec3; // 12 
+//   left: number; // 4 
+//   instanceCount: number; // 4 
+// }
+
+class AABB {
+  bmin: Vec3;
+  bmax: Vec3;
+  constructor() {
+    this.bmin = Vec3.fromValues(MAX_BOUND, MAX_BOUND, MAX_BOUND);
+    this.bmax = Vec3.fromValues(-MAX_BOUND, -MAX_BOUND, -MAX_BOUND);
+  }
+
+  grow(p: Vec3) {
+    Vec3.min(this.bmin, this.bmin, p);
+    Vec3.max(this.bmax, this.bmax, p);
+  }
+
+  growAABB(aabb: AABB) {
+    // Only grow if the other AABB is valid.
+    if (aabb.bmin[0] !== MAX_BOUND) {
+      this.grow(aabb.bmin);
+      this.grow(aabb.bmax);
+    }
+  }
+
+  area() {
+    const e = Vec3.create();
+    Vec3.subtract(e, this.bmax, this.bmin);
+    // standard surface area measure (omitting the factor 2 is acceptable since SAH is relative)
+    return e[0] * e[1] + e[1] * e[2] + e[2] * e[0];
+  }
+}
+
+class BVH {
+  nodesMin: Float32Array; // min x,y,z for each node
+  nodesMax: Float32Array; // max x,y,z for each node
+  nodesLeft: Uint32Array;
+  nodesInstanceCount: Uint32Array;
+  nodesUsed: number;
+  triangles: Triangle[];
+  triIdx: Uint32Array;
+
+  constructor(triangles: Triangle[]) {
+    this.triangles = triangles;
+    this.triIdx = new Uint32Array(triangles.length);
+    this.nodesUsed = 0;
+    const maxNodes = 2 * triangles.length - 1;
+    this.nodesMin = new Float32Array(maxNodes * 3);
+    this.nodesMax = new Float32Array(maxNodes * 3);
+    this.nodesLeft = new Uint32Array(maxNodes);
+    this.nodesInstanceCount = new Uint32Array(maxNodes);
+  }
+
+  construct() {
+    for (let i = 0; i < this.triangles.length; i++) {
+      this.triIdx[i] = i;
+    }
+    this.nodesInstanceCount[0] = this.triangles.length;
+    this.nodesLeft[0] = 0; // root node
+    this.nodesUsed = 1;
+    this.bounding(0);
+    this.subdivide(0);
+  }
+
+  bounding(nodeIdx: number) {
+    const off = nodeIdx * 3;
+    // initialize the node's AABB.
+    this.nodesMin[off + 0] = MAX_BOUND;
+    this.nodesMin[off + 1] = MAX_BOUND;
+    this.nodesMin[off + 2] = MAX_BOUND;
+    this.nodesMax[off + 0] = -MAX_BOUND;
+    this.nodesMax[off + 1] = -MAX_BOUND;
+    this.nodesMax[off + 2] = -MAX_BOUND;
+
+    const count = this.nodesInstanceCount[nodeIdx];
+    const start = this.nodesLeft[nodeIdx];
+
+    // temp vectors
+    const minVec = Vec3.create();
+    const maxVec = Vec3.create();
+
+    for (let i = 0; i < count; i++) {
+      const tri = this.triangles[this.triIdx[start + i]];
+
+      // walk through each tri and update the bounds
+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerA);
+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerA);
+      this.nodesMin[off + 0] = minVec[0];
+      this.nodesMin[off + 1] = minVec[1];
+      this.nodesMin[off + 2] = minVec[2];
+      this.nodesMax[off + 0] = maxVec[0];
+      this.nodesMax[off + 1] = maxVec[1];
+      this.nodesMax[off + 2] = maxVec[2];
+
+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerB);
+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerB);
+      this.nodesMin[off + 0] = minVec[0];
+      this.nodesMin[off + 1] = minVec[1];
+      this.nodesMin[off + 2] = minVec[2];
+      this.nodesMax[off + 0] = maxVec[0];
+      this.nodesMax[off + 1] = maxVec[1];
+      this.nodesMax[off + 2] = maxVec[2];
+
+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerC);
+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerC);
+      this.nodesMin[off + 0] = minVec[0];
+      this.nodesMin[off + 1] = minVec[1];
+      this.nodesMin[off + 2] = minVec[2];
+      this.nodesMax[off + 0] = maxVec[0];
+      this.nodesMax[off + 1] = maxVec[1];
+      this.nodesMax[off + 2] = maxVec[2];
+    }
+  }
+
+  subdivide(nodeIdx: number) {
+    // not enough primitives
+    if (this.nodesInstanceCount[nodeIdx] <= 2) return;
+
+    let [split, axis, cost] = this.findBestPlane(nodeIdx);
+
+    // eval the parent node’s extent.
+    const off = nodeIdx * 3;
+    const extent = Vec3.create();
+    Vec3.subtract(
+      extent,
+      [this.nodesMax[off + 0], this.nodesMax[off + 1], this.nodesMax[off + 2]],
+      [this.nodesMin[off + 0], this.nodesMin[off + 1], this.nodesMin[off + 2]]
+    );
+    const parentArea = extent[0] * extent[1] + extent[1] * extent[2] + extent[2] * extent[0];
+    const parentCost = this.nodesInstanceCount[nodeIdx] * parentArea;
+
+    // fallback to median split if SAH cost is not better
+    if (cost >= parentCost) {
+      let longestAxis = 0;
+      if (extent[1] > extent[0]) longestAxis = 1;
+      if (extent[2] > extent[longestAxis]) longestAxis = 2;
+      const start = this.nodesLeft[nodeIdx];
+      const count = this.nodesInstanceCount[nodeIdx];
+      const centroids: number[] = [];
+      for (let i = 0; i < count; i++) {
+        centroids.push(this.triangles[this.triIdx[start + i]].centroid[longestAxis]);
+      }
+      centroids.sort((a, b) => a - b);
+      split = centroids[Math.floor(count / 2)];
+      axis = longestAxis;
+    }
+
+    // partition primitives based on the chosen split
+    let i = this.nodesLeft[nodeIdx];
+    let j = i + this.nodesInstanceCount[nodeIdx] - 1;
+    while (i <= j) {
+      const tri = this.triangles[this.triIdx[i]];
+      if (tri.centroid[axis] < split) {
+        i++;
+      } else {
+        const tmp = this.triIdx[i];
+        this.triIdx[i] = this.triIdx[j];
+        this.triIdx[j] = tmp;
+        j--;
+      }
+    }
+    const leftCount = i - this.nodesLeft[nodeIdx];
+    if (leftCount === 0 || leftCount === this.nodesInstanceCount[nodeIdx]) return;
+
+    // construct child nodes.
+    const leftIdx = this.nodesUsed++;
+    const rightIdx = this.nodesUsed++;
+
+    this.nodesLeft[leftIdx] = this.nodesLeft[nodeIdx];
+    this.nodesInstanceCount[leftIdx] = leftCount;
+    this.nodesLeft[rightIdx] = i;
+    this.nodesInstanceCount[rightIdx] = this.nodesInstanceCount[nodeIdx] - leftCount;
+
+    // internal node
+    this.nodesLeft[nodeIdx] = leftIdx;
+    this.nodesInstanceCount[nodeIdx] = 0;
+
+    // keep going 
+    this.bounding(leftIdx);
+    this.bounding(rightIdx);
+    this.subdivide(leftIdx);
+    this.subdivide(rightIdx);
+  }
+
+  findBestPlane(nodeIdx: number): [number, number, number] {
+    let bestAxis = -1;
+    let bestSplit = 0;
+    let bestCost = Infinity;
+
+    const count = this.nodesInstanceCount[nodeIdx];
+    const start = this.nodesLeft[nodeIdx];
+
+    // eval centroid bounds
+    const centroidMin = [Infinity, Infinity, Infinity];
+    const centroidMax = [-Infinity, -Infinity, -Infinity];
+    for (let i = 0; i < count; i++) {
+      const tri = this.triangles[this.triIdx[start + i]];
+      for (let axis = 0; axis < 3; axis++) {
+        centroidMin[axis] = Math.min(centroidMin[axis], tri.centroid[axis]);
+        centroidMax[axis] = Math.max(centroidMax[axis], tri.centroid[axis]);
+      }
+    }
+
+    // fallback if this centroid has a degenerate centroid distributions
+    const EPSILON = 1e-5;
+    let degenerate = false;
+    for (let axis = 0; axis < 3; axis++) {
+      if (Math.abs(centroidMax[axis] - centroidMin[axis]) < EPSILON) {
+        degenerate = true;
+        break;
+      }
+    }
+    if (degenerate) {
+      // use median split along the longest axis of actual bounds
+      let longestAxis = 0;
+      const actualMin = [Infinity, Infinity, Infinity];
+      const actualMax = [-Infinity, -Infinity, -Infinity];
+      for (let i = 0; i < count; i++) {
+        const tri = this.triangles[this.triIdx[start + i]];
+        for (let axis = 0; axis < 3; axis++) {
+          actualMin[axis] = Math.min(actualMin[axis], tri.cornerA[axis], tri.cornerB[axis], tri.cornerC[axis]);
+          actualMax[axis] = Math.max(actualMax[axis], tri.cornerA[axis], tri.cornerB[axis], tri.cornerC[axis]);
+        }
+      }
+      const extent = [actualMax[0] - actualMin[0], actualMax[1] - actualMin[1], actualMax[2] - actualMin[2]];
+      if (extent[1] > extent[0]) longestAxis = 1;
+      if (extent[2] > extent[longestAxis]) longestAxis = 2;
+      const centroids: number[] = [];
+      for (let i = 0; i < count; i++) {
+        centroids.push(this.triangles[this.triIdx[start + i]].centroid[longestAxis]);
+      }
+      centroids.sort((a, b) => a - b);
+      bestSplit = centroids[Math.floor(count / 2)];
+      bestAxis = longestAxis;
+      return [bestSplit, bestAxis, bestCost];
+    }
+
+    // use adaptive binning (bin count based on number of primitives, clamped between 8 and 32)
+    const BINS = Math.max(8, Math.min(32, count));
+    const bins: Bin[] = Array.from({ length: BINS }, () => ({
+      instanceCount: 0,
+      bounds: new AABB(),
+    }));
+
+    // for each axis, evaluate candidate splits.
+    for (let axis = 0; axis < 3; axis++) {
+      const axisMin = centroidMin[axis];
+      const axisMax = centroidMax[axis];
+      if (axisMax === axisMin) continue; // skip degenerate axis
+
+      const axisScale = BINS / (axisMax - axisMin);
+      // reset bins for this axis.
+      for (let i = 0; i < BINS; i++) {
+        bins[i].instanceCount = 0;
+        bins[i].bounds = new AABB();
+      }
+
+      // distribute primitives into bins.
+      for (let i = 0; i < count; i++) {
+        const tri = this.triangles[this.triIdx[start + i]];
+        let binIDX = Math.floor((tri.centroid[axis] - axisMin) * axisScale);
+        if (binIDX >= BINS) binIDX = BINS - 1;
+        bins[binIDX].instanceCount++;
+        bins[binIDX].bounds.grow(tri.cornerA);
+        bins[binIDX].bounds.grow(tri.cornerB);
+        bins[binIDX].bounds.grow(tri.cornerC);
+      }
+
+
+      // compute cumulative sums from left and right
+      const leftCount = new Array(BINS - 1).fill(0);
+      const leftArea = new Array(BINS - 1).fill(0);
+      const rightCount = new Array(BINS - 1).fill(0);
+      const rightArea = new Array(BINS - 1).fill(0);
+
+      let leftBox = new AABB();
+      for (let i = 0, sum = 0; i < BINS - 1; i++) {
+        sum += bins[i].instanceCount;
+        leftCount[i] = sum;
+        leftBox.growAABB(bins[i].bounds);
+        leftArea[i] = leftBox.area();
+      }
+      let rightBox = new AABB();
+      for (let i = BINS - 1, sum = 0; i > 0; i--) {
+        sum += bins[i].instanceCount;
+        rightCount[i - 1] = sum;
+        rightBox.growAABB(bins[i].bounds);
+        rightArea[i - 1] = rightBox.area();
+      }
+
+      // highly axis aligned polygons like lucy and sponza fail
+      // duct taped solution to handle degen cases
+      const binWidth = (axisMax - axisMin) / BINS;
+      // eval candidate splits.
+      for (let i = 0; i < BINS - 1; i++) {
+        // small epsilon jitter to help break ties.
+        const jitter = 1e-6;
+        const candidatePos = axisMin + binWidth * (i + 1) + jitter;
+        const cost = leftCount[i] * leftArea[i] + rightCount[i] * rightArea[i];
+        if (cost < bestCost) {
+          bestCost = cost;
+          bestAxis = axis;
+          bestSplit = candidatePos;
+        }
+      }
+    }
+    return [bestSplit, bestAxis, bestCost];
+  }
+}
+
+export default BVH;
ADD · src/camera.ts +139 -0
--- a/src/camera.ts
+++ b/src/camera.ts
@@ -0,0 +1,139 @@
+import { Vec3, Mat4 } from "gl-matrix";
+
+export type CameraState = {
+  position: Float32Array; // [x, y, z]
+  rotation: Float32Array; // [theta, phi]
+  view: Float32Array;
+  inverseView: Float32Array;
+  projection: Float32Array;
+  currentViewProj: Float32Array;
+  previousViewProj: Float32Array;
+  dirty: boolean;
+};
+
+const moveVec = Vec3.create();
+const lastPosition = Vec3.create();
+const lastRotation = new Float32Array(2);
+const rotateDelta = new Float32Array(2);
+const keyState = new Set<string>();
+const sprintMultiplier = 2.5;
+
+let isPointerLocked = false;
+let oldTime = 0;
+
+export function createCamera(canvas: HTMLCanvasElement): CameraState {
+  const position = Vec3.fromValues(0, -75, 20);
+  const view = Mat4.create();
+  const projection = Mat4.create();
+  const inverseView = Mat4.create();
+
+  const lookAtTarget = Vec3.fromValues(0, 0, 20);
+  const up = Vec3.fromValues(0, 0, 1);
+  Mat4.lookAt(view, position, lookAtTarget, up);
+  Mat4.invert(inverseView, view);
+  Mat4.perspectiveZO(projection, Math.PI / 4, canvas.width / canvas.height, 0.1, 100);
+
+  const forward = Vec3.create();
+  Vec3.subtract(forward, lookAtTarget, position);
+  Vec3.normalize(forward, forward);
+  const phi = Math.asin(forward[2]);
+  const theta = Math.atan2(forward[0], forward[1]);
+
+  Vec3.copy(lastPosition, position);
+  lastRotation[0] = theta;
+  lastRotation[1] = phi;
+
+  return {
+    position,
+    rotation: new Float32Array([theta, phi]),
+    view,
+    inverseView,
+    projection,
+    currentViewProj: Mat4.create(),
+    previousViewProj: Mat4.create(),
+    dirty: true,
+  };
+}
+
+export function updateCamera(cam: CameraState): void {
+  const now = performance.now() * 0.001;
+  const dt = now - oldTime;
+  oldTime = now;
+
+  const theta = cam.rotation[0] += rotateDelta[0] * dt * 60;
+  const phi = cam.rotation[1] -= rotateDelta[1] * dt * 60;
+  cam.rotation[1] = Math.min(88, Math.max(-88, cam.rotation[1]));
+  rotateDelta[0] = rotateDelta[1] = 0;
+
+  updateMovementInput();
+
+  const scaledMove = Vec3.create();
+  Vec3.scale(scaledMove, moveVec, dt);
+
+  const moved = Vec3.length(scaledMove) > 1e-5;
+  const rotated = theta !== lastRotation[0] || phi !== lastRotation[1];
+  const changedPos = !Vec3.exactEquals(cam.position, lastPosition);
+  cam.dirty = moved || rotated || changedPos;
+
+  if (!cam.dirty) return;
+
+  const forwards = Vec3.fromValues(
+    Math.sin(toRad(theta)) * Math.cos(toRad(phi)),
+    Math.cos(toRad(theta)) * Math.cos(toRad(phi)),
+    Math.sin(toRad(phi))
+  );
+  const right = Vec3.create();
+  Vec3.cross(right, forwards, [0, 0, 1]);
+  Vec3.normalize(right, right);
+
+  const up = Vec3.create();
+  Vec3.cross(up, right, forwards);
+  Vec3.normalize(up, up);
+
+  Vec3.scaleAndAdd(cam.position, cam.position, forwards, scaledMove[0]);
+  Vec3.scaleAndAdd(cam.position, cam.position, right, scaledMove[1]);
+  Vec3.scaleAndAdd(cam.position, cam.position, up, scaledMove[2]);
+
+  const target = Vec3.create();
+  Vec3.add(target, cam.position, forwards);
+  Mat4.lookAt(cam.view, cam.position, target, up);
+  Mat4.invert(cam.inverseView, cam.view);
+
+  Vec3.copy(lastPosition, cam.position);
+  lastRotation[0] = theta;
+  lastRotation[1] = phi;
+}
+
+export function updateMovementInput(): void {
+  moveVec[0] = moveVec[1] = moveVec[2] = 0;
+  const speed = keyState.has("shift") ? 10 * sprintMultiplier : 10;
+
+  if (keyState.has("w")) moveVec[0] += speed;
+  if (keyState.has("s")) moveVec[0] -= speed;
+  if (keyState.has("d")) moveVec[1] += speed;
+  if (keyState.has("a")) moveVec[1] -= speed;
+  if (keyState.has("q")) moveVec[2] += speed;
+  if (keyState.has("e")) moveVec[2] -= speed;
+}
+
+function toRad(deg: number): number {
+  return (deg * Math.PI) / 180;
+}
+
+export function setupCameraInput(canvas: HTMLCanvasElement): void {
+  canvas.addEventListener("click", () => canvas.requestPointerLock());
+
+  document.addEventListener("pointerlockchange", () => {
+    isPointerLocked = document.pointerLockElement != null;
+  });
+
+  window.addEventListener("keydown", e => keyState.add(e.key.toLowerCase()));
+  window.addEventListener("keyup", e => keyState.delete(e.key.toLowerCase()));
+
+  window.addEventListener("mousemove", (e) => {
+    if (!isPointerLocked) return;
+    const sensitivity = 0.5;
+    rotateDelta[0] = e.movementX * sensitivity;
+    rotateDelta[1] = e.movementY * sensitivity;
+  });
+}
ADD · src/gltf.ts +394 -0
--- a/src/gltf.ts
+++ b/src/gltf.ts
@@ -0,0 +1,394 @@
+import { load } from "@loaders.gl/core";
+import { GLTFImagePostprocessed, GLTFLoader, GLTFMeshPrimitivePostprocessed, GLTFPostprocessed, postProcessGLTF } from "@loaders.gl/gltf";
+import { Mat3, Mat4, Mat4Like, Quat, QuatLike, Vec3, Vec3Like } from "gl-matrix";
+
+interface Triangle {
+  centroid: number[];
+  cornerA: number[];
+  cornerB: number[];
+  cornerC: number[];
+  normalA: number[];
+  normalB: number[];
+  normalC: number[];
+  mat: number;
+  uvA: number[];
+  uvB: number[];
+  uvC: number[];
+  tangentA: number[];
+  tangentB: number[];
+  tangentC: number[];
+}
+
+interface ProcessedMaterial {
+  baseColorFactor: number[],
+  baseColorTexture: number, // idx
+  metallicFactor: number,
+  roughnessFactor: number,
+  metallicRoughnessTexture: number, //idx
+  normalTexture: number, //idx
+  emissiveFactor: number[],
+  emissiveTexture: number, //idx
+  alphaMode: number, // parseAlphaMode
+  alphaCutoff: number,
+  doubleSided: number,
+};
+
+interface ProcessedTexture {
+  id: String,
+  sampler: GPUSampler,
+  texture: GPUTexture,
+  view: GPUTextureView,
+  source: GLTFImagePostprocessed,
+  samplerDescriptor: GPUSamplerDescriptor,
+}
+
+export class GLTF2 {
+  triangles: Array<Triangle>;
+  materials: Array<ProcessedMaterial>; // could make material more explicit here, like with textures
+  textures: Array<ProcessedTexture>;
+  device: GPUDevice;
+  gltfData!: GLTFPostprocessed;
+  url: string;
+  scale: number[];
+  position: number[];
+  rotation?: number[];
+
+  // pre allocating these here, faster that way? Intuitevely, I could be wrong. 
+  tempVec3_0: Vec3 = Vec3.create();
+  tempVec3_1: Vec3 = Vec3.create();
+  tempVec3_2: Vec3 = Vec3.create();
+  tempVec3_3: Vec3 = Vec3.create();
+  tempVec3_4: Vec3 = Vec3.create();
+  tempMat4_0: Mat4 = Mat4.create();
+  tempMat4_1: Mat4 = Mat4.create();
+  tempMat3_0: Mat3 = Mat3.create();
+
+  constructor(device: GPUDevice, url: string, scale: number[], position: number[], rotation?: number[]) {
+    this.triangles = [];
+    this.materials = [];
+    this.textures = [];
+    this.device = device;
+    this.url = url;
+    this.scale = scale;
+    this.position = position;
+    this.rotation = rotation;
+  }
+  async initialize() {
+    const t = await load(this.url, GLTFLoader);
+    this.gltfData = postProcessGLTF(t);
+    this.traverseNodes();
+    return [this.triangles, this.materials, this.textures];
+  }
+  // some data swizzling swash buckling utils
+  transformVec3(inputVec: ArrayLike<number>, matrix: Mat4): number[] {
+    const v = this.tempVec3_0;
+    Vec3.set(v, inputVec[0], inputVec[1], inputVec[2]);
+    Vec3.transformMat4(v, v, matrix);
+    return [v[0], v[1], v[2]]; // Return new array copy
+  }
+  transformNormal(inputNormal: ArrayLike<number>, transformMatrix: Mat4): number[] {
+    const normalMatrix = this.tempMat3_0;      // Reused Mat3
+    const tempMatrix = this.tempMat4_1;        // Use tempMat4_1 to avoid conflict with finalTransform
+    const transformedNormal = this.tempVec3_0; // Reused Vec3 for result
+    const inputNormalVec = this.tempVec3_1;    // Reused Vec3 for input
+
+    // calculate transpose(invert(transformMatrix))
+    // tempMat4_1 as scratch space to avoid clobbering tempMat4_0 (finalTransform)
+    Mat4.invert(tempMatrix, transformMatrix);
+    Mat4.transpose(tempMatrix, tempMatrix);
+
+    // upper-left 3x3 submatrix
+    Mat3.fromMat4(normalMatrix, tempMatrix);
+
+    // normal into reusable Vec3
+    Vec3.set(inputNormalVec, inputNormal[0], inputNormal[1], inputNormal[2]);
+
+    // transfrom that normal
+    Vec3.transformMat3(transformedNormal, inputNormalVec, normalMatrix);
+    Vec3.normalize(transformedNormal, transformedNormal);
+
+    // new array copy
+    return [transformedNormal[0], transformedNormal[1], transformedNormal[2]];
+  }
+  parseAlphaMode(alphaMode: string) {
+    if (alphaMode === "MASK") { return 1 }
+    return 2
+  }
+
+
+  // could break this up
+  extractTriangles(primitive: GLTFMeshPrimitivePostprocessed, transform: Mat4, targetArray: Array<Triangle>) {
+    const positions = primitive.attributes["POSITION"].value;
+    const indicesData = primitive.indices ? primitive.indices.value : null;
+    const numVertices = positions.length / 3;
+    const indices = indicesData ?? (() => {
+      const generatedIndices = new Uint32Array(numVertices);
+      for (let i = 0; i < numVertices; i++) generatedIndices[i] = i;
+      return generatedIndices;
+    })();
+    const normals = primitive.attributes["NORMAL"]
+      ? primitive.attributes["NORMAL"].value
+      : null;
+    const uvCoords = primitive.attributes["TEXCOORD_0"]
+      ? primitive.attributes["TEXCOORD_0"].value
+      : null;
+    const tangents = primitive.attributes["TANGENT"]
+      ? primitive.attributes["TANGENT"].value
+      : null;
+
+    const mat = parseInt(primitive.material?.id.match(/\d+$/)?.[0] ?? "-1");
+
+    // ensure these don't clash with temps used in transformNormal/transformVec3
+    // if called within the face normal logic (they aren't).
+    const vA = this.tempVec3_1; // maybe use distinct temps if needed, but seems ok
+    const vB = this.tempVec3_2;
+    const vC = this.tempVec3_3;
+    const edge1 = this.tempVec3_4;
+    const edge2 = this.tempVec3_0;      // tempVec3_0 reused safely after position transforms
+    const faceNormal = this.tempVec3_1; // tempVec3_1 reused safely after normal transforms or for input
+
+    const defaultUV = [0, 0];
+    const defaultTangent = [1, 0, 0, 1];
+
+    for (let i = 0; i < indices.length; i += 3) {
+      const ai = indices[i];
+      const bi = indices[i + 1];
+      const ci = indices[i + 2];
+
+      const posA = [positions[ai * 3], positions[ai * 3 + 1], positions[ai * 3 + 2]];
+      const posB = [positions[bi * 3], positions[bi * 3 + 1], positions[bi * 3 + 2]];
+      const posC = [positions[ci * 3], positions[ci * 3 + 1], positions[ci * 3 + 2]];
+
+      // transform positions uses tempVec3_0 internally
+      const cornerA = this.transformVec3(posA, transform);
+      const cornerB = this.transformVec3(posB, transform);
+      const cornerC = this.transformVec3(posC, transform);
+
+      let normalA: number[], normalB: number[], normalC: number[];
+      if (normals) {
+        // transform normals uses tempVec3_0, tempVec3_1 internally
+        normalA = this.transformNormal([normals[ai * 3], normals[ai * 3 + 1], normals[ai * 3 + 2]], transform);
+        normalB = this.transformNormal([normals[bi * 3], normals[bi * 3 + 1], normals[bi * 3 + 2]], transform);
+        normalC = this.transformNormal([normals[ci * 3], normals[ci * 3 + 1], normals[ci * 3 + 2]], transform);
+      } else {
+        // compute fallback flat face normal
+        Vec3.set(vA, cornerA[0], cornerA[1], cornerA[2]);
+        Vec3.set(vB, cornerB[0], cornerB[1], cornerB[2]);
+        Vec3.set(vC, cornerC[0], cornerC[1], cornerC[2]);
+
+        Vec3.subtract(edge1, vB, vA);
+        Vec3.subtract(edge2, vC, vA);
+        Vec3.cross(faceNormal, edge1, edge2);
+        Vec3.normalize(faceNormal, faceNormal);
+
+        const normalArray = [faceNormal[0], faceNormal[1], faceNormal[2]];
+        normalA = normalArray;
+        normalB = normalArray;
+        normalC = normalArray;
+      }
+
+      const uvA = uvCoords ? [uvCoords[ai * 2], uvCoords[ai * 2 + 1]] : defaultUV;
+      const uvB = uvCoords ? [uvCoords[bi * 2], uvCoords[bi * 2 + 1]] : defaultUV;
+      const uvC = uvCoords ? [uvCoords[ci * 2], uvCoords[ci * 2 + 1]] : defaultUV;
+
+      const tangentA = tangents ? [tangents[ai * 4], tangents[ai * 4 + 1], tangents[ai * 4 + 2], tangents[ai * 4 + 3]] : defaultTangent;
+      const tangentB = tangents ? [tangents[bi * 4], tangents[bi * 4 + 1], tangents[bi * 4 + 2], tangents[bi * 4 + 3]] : defaultTangent;
+      const tangentC = tangents ? [tangents[ci * 4], tangents[ci * 4 + 1], tangents[ci * 4 + 2], tangents[ci * 4 + 3]] : defaultTangent;
+
+      const centroid = [
+        (cornerA[0] + cornerB[0] + cornerC[0]) / 3,
+        (cornerA[1] + cornerB[1] + cornerC[1]) / 3,
+        (cornerA[2] + cornerB[2] + cornerC[2]) / 3,
+      ];
+
+      targetArray.push({
+        centroid,
+        cornerA, cornerB, cornerC,
+        normalA, normalB, normalC, mat,
+        uvA, uvB, uvC,
+        tangentA, tangentB, tangentC,
+      });
+    }
+  }
+
+  traverseNodes() {
+    // texture processing 
+    if (this.gltfData.textures) {
+      this.gltfData.textures.forEach((texture) => {
+        if (!texture.source?.image) {
+          // empty textures are handled on atlas creation
+          return;
+        }
+        const gpuTexture = this.device.createTexture({
+          size: {
+            width: texture.source.image.width ?? 0,
+            height: texture.source.image.height ?? 0,
+            depthOrArrayLayers: 1,
+          },
+          format: "rgba8unorm",
+          usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
+        });
+
+        const view = gpuTexture.createView({ format: "rgba8unorm" });
+
+        // TODO: Process gltfData.samplers[texture.sampler] if it exists
+        let samplerDescriptor: GPUSamplerDescriptor = {
+          magFilter: "linear", minFilter: "linear",
+          addressModeU: "repeat", addressModeV: "repeat",
+        };
+        const sampler = this.device.createSampler(samplerDescriptor);
+        this.textures.push({
+          id: texture.id,
+          texture: gpuTexture,
+          view: view,
+          sampler: sampler,
+          source: texture.source,
+          samplerDescriptor: samplerDescriptor
+        });
+      });
+    }
+    if (this.gltfData.materials) {
+      this.materials = this.gltfData.materials.map(mat => {
+        return {
+          baseColorFactor: mat.pbrMetallicRoughness?.baseColorFactor ?? [1.0, 1.0, 1.0, 1.0],
+          baseColorTexture: mat.pbrMetallicRoughness?.baseColorTexture?.index ?? -1,
+          metallicFactor: mat.pbrMetallicRoughness?.metallicFactor ?? 1.0,
+          roughnessFactor: mat.pbrMetallicRoughness?.roughnessFactor ?? 1.0,
+          metallicRoughnessTexture: mat.pbrMetallicRoughness?.metallicRoughnessTexture?.index ?? -1,
+          normalTexture: mat.normalTexture?.index ?? -1,
+          emissiveFactor: mat.emissiveFactor ?? [0.0, 0.0, 0.0],
+          emissiveTexture: mat.emissiveTexture?.index ?? -1,
+          alphaMode: mat.alphaMode ? this.parseAlphaMode(mat.alphaMode) : 0,
+          alphaCutoff: mat.alphaCutoff ?? 0.5,
+          doubleSided: mat.doubleSided ? 1 : 0,
+        };
+      });
+    }
+
+    // initial node transforms
+    const finalTransform = this.tempMat4_0;     // reused for final calc per node
+    const nodeLocalTransform = this.tempMat4_1; // reused for local calc per node
+    const tMat = Mat4.create();
+    const rMat = Mat4.create();
+    const sMat = Mat4.create();
+    const tMatCustom = Mat4.create();
+    const rMatCustom = Mat4.create();
+    const sMatCustom = Mat4.create();
+    const yToZUp = Mat4.fromValues(
+      1, 0, 0, 0,
+      0, 0, 1, 0,
+      0, -1, 0, 0,
+      0, 0, 0, 1
+    );
+    // scene transforms
+    const sceneTransform = Mat4.create();
+    const sc_translation = this.position || [0, 0, 0];
+    const sc_rotation = this.rotation || [0, 0, 0, 1];
+    const sc_scale = this.scale || [1, 1, 1];
+    Mat4.fromTranslation(tMatCustom, sc_translation as Vec3Like);
+    Quat.normalize(rMatCustom, sc_rotation as QuatLike);
+    Mat4.fromQuat(rMatCustom, rMatCustom);
+    Mat4.fromScaling(sMatCustom, sc_scale as Vec3Like);
+    Mat4.multiply(sceneTransform, rMatCustom, sMatCustom);
+    Mat4.multiply(sceneTransform, tMatCustom, sceneTransform);
+
+    const meshMap = new Map(this.gltfData.meshes.map(m => [m.id, m]));
+
+    for (const node of this.gltfData.nodes) {
+      if (!node.mesh?.id) continue;
+      const mesh = meshMap.get(node.mesh.id);
+      if (!mesh) continue;
+      Mat4.identity(nodeLocalTransform);
+      if (node.matrix) {
+        Mat4.copy(nodeLocalTransform, node.matrix as Mat4Like);
+      } else {
+        const nodeTranslation = node.translation || [0, 0, 0];
+        const nodeRotation = node.rotation || [0, 0, 0, 1];
+        const nodeScale = node.scale || [1, 1, 1];
+        Mat4.fromTranslation(tMat, nodeTranslation as Vec3Like);
+        Mat4.fromQuat(rMat, nodeRotation as QuatLike);
+        Mat4.fromScaling(sMat, nodeScale as Vec3Like);
+        Mat4.multiply(nodeLocalTransform, rMat, sMat);
+        Mat4.multiply(nodeLocalTransform, tMat, nodeLocalTransform);
+      }
+
+      // finalTransform = sceneTransform * yToZUp * nodeLocalTransform
+      Mat4.multiply(finalTransform, yToZUp, nodeLocalTransform);
+      Mat4.multiply(finalTransform, sceneTransform, finalTransform);
+
+      mesh.primitives.forEach((primitive: GLTFMeshPrimitivePostprocessed) => {
+        this.extractTriangles(primitive, finalTransform, this.triangles);
+      });
+    }
+  }
+}
+
+export function combineGLTFs(gltfs: GLTF2[]) {
+  const triangles = [];
+  const materials = [];
+  const textures = [];
+
+  let textureOffset = 0;
+  let materialOffset = 0;
+  let largestTextureDimensions = { width: 0, height: 0 };
+
+  // offset idx
+  const offsetIdx = (idx: any) => {
+    return (typeof idx === 'number' && idx >= 0) ? idx + textureOffset : idx; // Keep original if invalid index
+  };
+
+  for (let i = 0; i < gltfs.length; i++) {
+    const gltf = gltfs[i];
+    const texCount = gltf.textures ? gltf.textures.length : 0;
+    const matCount = gltf.materials ? gltf.materials.length : 0;
+    // just append the textures for now
+    if (gltf.textures && texCount > 0) {
+      for (let t = 0; t < texCount; t++) {
+        const texture = gltf.textures[t];
+        let texHeight = texture.source.image.height as number;
+        let texWidth = texture.source.image.width as number;
+        textures.push(texture);
+        if (texWidth > largestTextureDimensions.width) {
+          largestTextureDimensions.width = texWidth;
+        }
+        if (texHeight > largestTextureDimensions.height) {
+          largestTextureDimensions.height = texHeight;
+        }
+      }
+    }
+    if (gltf.materials && matCount > 0) {
+      for (let m = 0; m < matCount; m++) {
+        const src = gltf.materials[m];
+        materials.push({
+          alphaCutoff: src.alphaCutoff,
+          alphaMode: src.alphaMode,
+          baseColorFactor: src.baseColorFactor,
+          baseColorTexture: offsetIdx(src.baseColorTexture),
+          doubleSided: src.doubleSided,
+          emissiveFactor: src.emissiveFactor,
+          emissiveTexture: offsetIdx(src.emissiveTexture),
+          metallicFactor: src.metallicFactor,
+          metallicRoughnessTexture: offsetIdx(src.metallicRoughnessTexture),
+          normalTexture: offsetIdx(src.normalTexture),
+          roughnessFactor: src.roughnessFactor
+        });
+      }
+    }
+    // update idx if needed
+    if (gltf.triangles) {
+      for (let t = 0; t < gltf.triangles.length; t++) {
+        const tri = gltf.triangles[t];
+        const triCopy = Object.create(tri);
+        if (tri.mat >= 0) {
+          triCopy.mat = tri.mat + materialOffset;
+        } else {
+          triCopy.mat = -1;
+        }
+        triangles.push(triCopy);
+      }
+    }
+    textureOffset += texCount;
+    materialOffset += matCount;
+  }
+  return { triangles, materials, textures, largestTextureDimensions };
+}
ADD · src/main.ts +643 -0
--- a/src/main.ts
+++ b/src/main.ts
@@ -0,0 +1,643 @@
+import { Vec3 } from "gl-matrix";
+import BVH from "./bvh.ts";
+import { createCamera, setupCameraInput, updateCamera, updateMovementInput } from "./camera.ts";
+import { GLTF2, combineGLTFs } from "./gltf.ts";
+import { InitPane } from "./pane.ts";
+import kernel from "./shaders/main.wgsl";
+import viewport from "./shaders/viewport.wgsl";
+
+declare global {
+  interface Window {
+    framecount: number;
+  }
+}
+
+// init device
+const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+const adapter = (await navigator.gpu.requestAdapter()) as GPUAdapter;
+if (!navigator.gpu) {
+  throw new Error("WebGPU not supported on this browser.");
+}
+if (!adapter) {
+  throw new Error("No appropriate GPUAdapter found.");
+}
+const device = await adapter.requestDevice({
+  requiredFeatures: ['timestamp-query']
+});
+
+const width = canvas.clientWidth;
+const height = canvas.clientHeight;
+canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
+canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
+const context = canvas.getContext("webgpu") as GPUCanvasContext;
+const format = navigator.gpu.getPreferredCanvasFormat();
+context.configure({
+  device,
+  format: format,
+});
+
+// compose scene -> triangles -> BVH -> textures
+const x = new GLTF2(device, "Duck.gltf", [0.1, 0.1, 0.1], [-13, -1, -0.34], [0, 0, -1.25, 1]);
+const y = new GLTF2(device, "cornell_empty_rg.gltf", [20, 20, 20], [0, 0, 0.01], [0,0,0,0])
+const z = new GLTF2(device, "EnvironmentTest.gltf", [1.8, 1.8, 1.8], [0, 15, 25], [0, 0, 0, 0]);
+
+await x.initialize()
+await y.initialize()
+await z.initialize()
+const t = combineGLTFs([x,y,z]);
+let ab = new BVH(t.triangles);
+ab.construct();
+const hasTextures = t.textures && t.textures.length > 0;
+const textureCount = hasTextures ? t.textures.length : 0;
+const textureSizes = new Float32Array(textureCount * 4); // [width, height, invWidth, invHeight] per texture
+console.log(t.triangles)
+// viewport texture, rgba32float; we store full fat HDR and tonemap it in this
+const viewportTexture = device.createTexture({
+  size: {
+    width: canvas.width,
+    height: canvas.height,
+  },
+  format: "rgba32float",
+  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
+});
+const viewportTextureColorBuffer = viewportTexture.createView();
+
+
+// offsets for buffer data
+const MaterialSize = 64;
+const materialData = new Float32Array(t.materials.length * (MaterialSize / 4));
+const MaterialInfo = {
+  albedo: { type: Float32Array, byteOffset: 0, length: 4 },
+  metallic: { type: Float32Array, byteOffset: 16, length: 1 },
+  alphaMode: { type: Float32Array, byteOffset: 20, length: 1 },
+  alphaCutoff: { type: Float32Array, byteOffset: 24, length: 1 },
+  doubleSided: { type: Float32Array, byteOffset: 28, length: 1 },
+  emission: { type: Float32Array, byteOffset: 32, length: 3 },
+  roughness: { type: Float32Array, byteOffset: 44, length: 1 },
+  baseColorTexture: { type: Float32Array, byteOffset: 48, length: 1 },
+  normalTexture: { type: Float32Array, byteOffset: 52, length: 1 },
+  metallicRoughnessTexture: { type: Float32Array, byteOffset: 56, length: 1 },
+  emissiveTexture: { type: Float32Array, byteOffset: 60, length: 1 },
+};
+
+// NB: Very fat. Trimming these to vert should be (4*3) * 3 + 12 = 48.
+// at the point if it's just 48, might be cheaper to get rid of the triangle indicies. Skip the BVH -> tri_index lookup.
+// and then resolve the shading data with tri indicies? This would still need material index thought? To do alpha tests in trace()?
+// TODO: Trim these to only verts, the material index, and the shading index. Move the rest to shadingData[]
+// Could also, let this be. Skip the verts here and move verts directly into the BVH. And make sure the indices line up before passing it in.
+const TriangleSize = 176;
+const TriangleData = new Float32Array(t.triangles.length * (TriangleSize / 4));
+const TriangleInfo = {
+  corner_a: { type: Float32Array, byteOffset: 0, length: 3 },
+  corner_b: { type: Float32Array, byteOffset: 16, length: 3 },
+  corner_c: { type: Float32Array, byteOffset: 32, length: 3 },
+  normal_a: { type: Float32Array, byteOffset: 48, length: 3 },
+  normal_b: { type: Float32Array, byteOffset: 64, length: 3 },
+  normal_c: { type: Float32Array, byteOffset: 80, length: 3 },
+  material: { type: Float32Array, byteOffset: 92, length: 1 },
+  uVA: { type: Float32Array, byteOffset: 96, length: 2 },
+  uVB: { type: Float32Array, byteOffset: 104, length: 2 },
+  uVC: { type: Float32Array, byteOffset: 112, length: 2 },
+  tangentA: { type: Float32Array, byteOffset: 128, length: 4 },
+  tangentB: { type: Float32Array, byteOffset: 144, length: 4 },
+  tangentC: { type: Float32Array, byteOffset: 160, length: 4 },
+};
+
+// init scene buffers
+const triangleBuffer = device.createBuffer({
+  label: "Triangle Storage",
+  size: t.triangles.length * TriangleSize,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const materialBuffer = device.createBuffer({
+  label: "Material storage",
+  size: 8 * materialData.length,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const emissiveTrianglesBuffer = device.createBuffer({
+  label: "Emissive triangles",
+  size: t.triangles.length * TriangleSize,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const nodeBuffer = device.createBuffer({
+  size: 32 * ab.nodesUsed,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const triangleIndexBuffer = device.createBuffer({
+  size: 4 * t.triangles.length,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const accumulationBuffer = device.createBuffer({
+  size: canvas.width * canvas.height * 16,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+});
+const uniformBuffer0 = device.createBuffer({
+  label: "Camera Transform Buffer",
+  size: 512,
+  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+});
+const textureSizeBuffer = device.createBuffer({
+  size: Math.max(textureSizes.byteLength, 2048),
+  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+});
+
+// populate buffers
+const emissiveMaterialIndices: number[] = []
+const emissiveTriangleIndices: number[] = []
+const bvhPrimitiveTriangleIndices: Float32Array = new Float32Array(ab.triIdx.length);
+
+type MaterialPropertyName = keyof typeof MaterialInfo;
+t.materials.forEach((mat, i) => {
+  const materialOffset = i * (MaterialSize / 4); // Base offset for the current material
+  const setData = (propertyName: MaterialPropertyName, value: number[]) => {
+    const info = MaterialInfo[propertyName];
+    materialData.set(value, materialOffset + info.byteOffset / 4);
+  };
+  const setFloat = (propertyName: MaterialPropertyName, value: number) => {
+    const info = MaterialInfo[propertyName];
+    materialData[materialOffset + info.byteOffset / 4] = value;
+  };
+  setData("albedo", mat.baseColorFactor); // Now sets 4 floats instead of 3
+  setFloat("metallic", mat.metallicFactor);
+  setFloat("alphaMode", mat.alphaMode);
+  setFloat("alphaCutoff", mat.alphaCutoff);
+  setFloat("doubleSided", mat.doubleSided);
+  setData("emission", mat.emissiveFactor);
+  setFloat("roughness", mat.roughnessFactor);
+  setFloat("baseColorTexture", mat.baseColorTexture);
+  setFloat("normalTexture", mat.normalTexture);
+  setFloat("metallicRoughnessTexture", mat.metallicRoughnessTexture);
+  setFloat("emissiveTexture", mat.emissiveTexture);
+  if (mat.emissiveFactor[0] !== 0 || mat.emissiveFactor[1] !== 0 || mat.emissiveFactor[2] !== 0) {
+    emissiveMaterialIndices.push(i);
+  }
+});
+device.queue.writeBuffer(materialBuffer, 0, materialData);
+
+type TrianglePropertyName = keyof typeof TriangleInfo;
+t.triangles.forEach((tri, i) => {
+  const triOffset = i * (TriangleSize / 4);
+  const setData = (propertyName: TrianglePropertyName, value: number[]) => {
+    const info = TriangleInfo[propertyName];
+    TriangleData.set(value, triOffset + info.byteOffset / 4);
+  };
+  const setFloat = (propertyName: TrianglePropertyName, value: number) => {
+    const info = TriangleInfo[propertyName];
+    TriangleData[triOffset + info.byteOffset / 4] = value;
+  };
+  setData("corner_a", tri.cornerA);
+  setData("corner_b", tri.cornerB);
+  setData("corner_c", tri.cornerC);
+  setData("normal_a", tri.normalA);
+  setData("normal_b", tri.normalB);
+  setData("normal_c", tri.normalC);
+  setFloat("material", tri.mat);
+  setData("uVA", tri.uvA);
+  setData("uVB", tri.uvB);
+  setData("uVC", tri.uvC);
+  setData("tangentA", tri.tangentA);
+  setData("tangentB", tri.tangentB);
+  setData("tangentC", tri.tangentC);
+  if (emissiveMaterialIndices.includes(tri.mat)) {
+    emissiveTriangleIndices.push(i); // Push the triangle's index
+  }
+});
+device.queue.writeBuffer(triangleBuffer, 0, TriangleData);
+device.queue.writeBuffer(emissiveTrianglesBuffer, 0, new Float32Array(emissiveTriangleIndices));
+
+const nodeData: Float32Array = new Float32Array(8 * ab.nodesUsed);
+for (let i = 0; i < ab.nodesUsed; i++) {
+  const minOffset = i * 3;
+  const maxOffset = i * 3;
+  nodeData[8 * i] = ab.nodesMin[minOffset + 0];
+  nodeData[8 * i + 1] = ab.nodesMin[minOffset + 1];
+  nodeData[8 * i + 2] = ab.nodesMin[minOffset + 2];
+  nodeData[8 * i + 3] = ab.nodesLeft[i];
+  nodeData[8 * i + 4] = ab.nodesMax[maxOffset + 0];
+  nodeData[8 * i + 5] = ab.nodesMax[maxOffset + 1];
+  nodeData[8 * i + 6] = ab.nodesMax[maxOffset + 2];
+  nodeData[8 * i + 7] = ab.nodesInstanceCount[i];
+}
+device.queue.writeBuffer(nodeBuffer, 0, nodeData, 0, 8 * ab.nodesUsed);
+
+for (let i = 0; i < ab.triIdx.length; i++) {
+  bvhPrimitiveTriangleIndices[i] = ab.triIdx[i];
+}
+device.queue.writeBuffer(triangleIndexBuffer, 0, bvhPrimitiveTriangleIndices, 0, ab.triIdx.length);
+
+// bluenoise texture 2. 3.24: Bluenoise
+// bluenoise texture form https://momentsingraphics.de/BlueNoise.html
+async function loadImageBitmap(url: string) {
+  const res = await fetch(url);
+  const blob = await res.blob();
+  return await createImageBitmap(blob, { colorSpaceConversion: 'none' });
+}
+const bnnoiseSource = await loadImageBitmap("LDR_RGBA_0.png")
+const blueNoiseTexture = device.createTexture({
+  label: 'bluenoise-texture',
+  format: 'rgba8unorm',
+  size: [bnnoiseSource.width, bnnoiseSource.height],
+  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
+});
+device.queue.copyExternalImageToTexture(
+  { source: bnnoiseSource },
+  { texture: blueNoiseTexture },
+  { width: bnnoiseSource.width, height: bnnoiseSource.height },
+);
+
+
+// construct the texture atlas
+const emptySampler = device.createSampler({
+  addressModeU: "clamp-to-edge",
+  addressModeV: "clamp-to-edge",
+  addressModeW: "clamp-to-edge",
+  magFilter: "nearest",
+  minFilter: "nearest",
+  mipmapFilter: "nearest",
+});
+const emptyTexture = device.createTexture({
+  size: [1, 1, 1],
+  format: "rgba8unorm",
+  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
+});
+const emptyView = emptyTexture.createView({
+  dimension: "2d-array",
+});
+let maxWidth = 1, maxHeight = 1;
+let textureSampler = emptySampler;
+let textureArray = emptyTexture;
+let textureViewArray = emptyView;
+if (hasTextures) {
+  maxWidth = t.largestTextureDimensions.width
+  maxHeight = t.largestTextureDimensions.height
+  textureSampler = t.textures[0].sampler // lazy, store the samplers correctly
+  textureArray = device.createTexture({
+    size: [maxWidth, maxHeight, t.textures.length],
+    format: "rgba8unorm",
+    usage:
+      GPUTextureUsage.TEXTURE_BINDING |
+      GPUTextureUsage.COPY_DST |
+      GPUTextureUsage.RENDER_ATTACHMENT,
+    dimension: "2d",
+  })
+  textureViewArray = textureArray.createView({ dimension: "2d-array" });
+}
+
+// rather wasteful (and sometimes incorrect, but whatever. Fine for now)
+// 1. get each texture's dimension 
+// 2. pad it to the largest one 
+// 3. store the original h w in textureSizes[]
+// 4. stack the padded texture 
+if (t.textures.length) {
+  for (let i = 0; i < t.textures.length; i++) {
+    const source = t.textures[i].source;
+    // @ts-ignore / Poorly defined type for the original GLTFImagePostprocessed
+    const bitmap = source.image as ImageBitmap;
+    textureSizes[i * 4] = bitmap.width;
+    textureSizes[i * 4 + 1] = bitmap.height;
+    textureSizes[i * 4 + 2] = 0.0;
+    textureSizes[i * 4 + 3] = 0.0;
+
+    device.queue.copyExternalImageToTexture(
+      { source: bitmap },
+      { texture: textureArray, origin: [0, 0, i] },
+      [bitmap.width, bitmap.height, 1]
+    );
+  }
+  device.queue.writeBuffer(textureSizeBuffer, 0, textureSizes);
+}
+
+// bind groups and layouts 
+const geometryBindgroupLayout = device.createBindGroupLayout({
+  label: 'geometry-bind-group-layout',
+  entries: [
+    {
+      binding: 0,
+      visibility: GPUShaderStage.COMPUTE,
+      storageTexture: {
+        access: "write-only",
+        format: "rgba32float",
+        viewDimension: "2d",
+      },
+    },
+    {
+      binding: 1,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "read-only-storage",
+        hasDynamicOffset: false,
+      },
+    },
+    {
+      binding: 2,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "uniform",
+      },
+    },
+
+    {
+      binding: 5,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "read-only-storage",
+        hasDynamicOffset: false,
+      },
+    },
+    {
+      binding: 6,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "read-only-storage",
+        hasDynamicOffset: false,
+      },
+    },
+    {
+      binding: 7,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "storage",
+      },
+    },
+  ],
+});
+
+const geometryBindgroup = device.createBindGroup({
+  label: 'geometry-bind-group',
+  layout: geometryBindgroupLayout,
+  entries: [
+    {
+      binding: 0,
+      resource: viewportTextureColorBuffer,
+    },
+    {
+      binding: 1,
+      resource: {
+        buffer: triangleBuffer,
+      },
+    },
+    {
+      binding: 2,
+      resource: {
+        buffer: uniformBuffer0,
+      },
+    },
+    {
+      binding: 5,
+      resource: { buffer: nodeBuffer },
+    },
+    {
+      binding: 6,
+      resource: { buffer: triangleIndexBuffer },
+    },
+    {
+      binding: 7,
+      resource: { buffer: accumulationBuffer },
+    },
+  ],
+});
+
+const shadingBindGroupLayout = device.createBindGroupLayout({
+  label: 'shading-bind-group-layout',
+  entries: [
+    {
+      binding: 0,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "read-only-storage",
+        hasDynamicOffset: false,
+      },
+    },
+    {
+      binding: 1,
+      visibility: GPUShaderStage.COMPUTE,
+      texture: {
+        viewDimension: "2d-array",
+      },
+    },
+    {
+      binding: 2,
+      visibility: GPUShaderStage.COMPUTE,
+      sampler: {},
+    },
+    {
+      binding: 4,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "uniform",
+        hasDynamicOffset: false,
+      },
+    },
+    {
+      binding: 6,
+      visibility: GPUShaderStage.COMPUTE,
+      storageTexture: {
+        access: "read-only",
+        format: "rgba8unorm",
+        viewDimension: "2d",
+      },
+    },
+    {
+      binding: 7,
+      visibility: GPUShaderStage.COMPUTE,
+      buffer: {
+        type: "read-only-storage",
+        hasDynamicOffset: false,
+      },
+    },
+  ],
+});
+
+const shadingBindGroup = device.createBindGroup({
+  label: 'shading-bind-group',
+  layout: shadingBindGroupLayout,
+  entries: [
+    {
+      binding: 0,
+      resource: {
+        buffer: materialBuffer,
+      },
+    },
+    { binding: 1, resource: textureViewArray },
+    { binding: 2, resource: textureSampler },
+    {
+      binding: 4,
+      resource: {
+        buffer: textureSizeBuffer,
+      },
+    },
+    {
+      binding: 6,
+      resource: blueNoiseTexture.createView(),
+    },
+    {
+      binding: 7,
+      resource: {
+        buffer: emissiveTrianglesBuffer,
+      },
+    },
+  ],
+});
+
+const viewportBindgroupLayout = device.createBindGroupLayout({
+  entries: [
+    {
+      binding: 0,
+      visibility: GPUShaderStage.FRAGMENT,
+      texture: {
+        sampleType: 'unfilterable-float',
+        viewDimension: '2d',
+        multisampled: false,
+      },
+
+    },
+  ],
+});
+
+const viewportBindgroup = device.createBindGroup({
+  layout: viewportBindgroupLayout,
+  entries: [
+    {
+      binding: 0,
+      resource: viewportTextureColorBuffer,
+    },
+  ],
+});
+
+// pipelines
+const kernelPipelineLayout = device.createPipelineLayout({
+  bindGroupLayouts: [geometryBindgroupLayout, shadingBindGroupLayout],
+});
+
+const kernelPipeline = device.createComputePipeline({
+  layout: kernelPipelineLayout,
+  compute: {
+    module: device.createShaderModule({
+      code: kernel,
+    }),
+    entryPoint: "main",
+  },
+});
+
+const viewportPipelineLayout = device.createPipelineLayout({
+  bindGroupLayouts: [viewportBindgroupLayout],
+});
+
+const viewportPipeline = device.createRenderPipeline({
+  layout: viewportPipelineLayout,
+  vertex: {
+    module: device.createShaderModule({
+      code: viewport,
+    }),
+    entryPoint: "vert_main",
+  },
+  fragment: {
+    module: device.createShaderModule({
+      code: viewport,
+    }),
+    entryPoint: "frag_main",
+    targets: [
+      {
+        format: format,
+      },
+    ],
+  },
+  primitive: {
+    topology: "triangle-list",
+  },
+});
+
+
+var frametime = 0;
+window.framecount = 0;
+const UNIFORMS = {
+  sample_count: 1.0,
+  bounce_count: 3.0,
+  aperture: 0.1,
+  focal_length: 4.0,
+  frameTimeMs: 0,
+  fps: frametime / 1000,
+  sun_angle: { x: 0.3, y: -0.7, z: 0.3 },
+  sun_color: { r: 1.0, g: 0.96, b: 0.85 },
+  scale: 22000.0, // sun_color rgb -> lux scale 
+  albedo_factor: z.materials[0].baseColorFactor,
+  metallicFactor: z.materials[0].metallicFactor,
+  roughnessFactor: z.materials[0].roughnessFactor,
+  thin_lens: false,
+};
+// initialize values based on UNIFORMS, updates are createPane()
+device.queue.writeBuffer(uniformBuffer0, 208, Vec3.fromValues(UNIFORMS.sun_angle.x, UNIFORMS.sun_angle.y, UNIFORMS.sun_angle.z));
+device.queue.writeBuffer(uniformBuffer0, 220, new Float32Array([0.53 * (Math.PI / 180.0)])); // ~0.5332 degrees / 32.15 arcminutes
+device.queue.writeBuffer(uniformBuffer0, 224, Vec3.fromValues(UNIFORMS.sun_color.r * UNIFORMS.scale, UNIFORMS.sun_color.g * UNIFORMS.scale, UNIFORMS.sun_color.b * UNIFORMS.scale));
+device.queue.writeBuffer(uniformBuffer0, 236, new Float32Array([UNIFORMS.sample_count, UNIFORMS.bounce_count, UNIFORMS.aperture, UNIFORMS.focal_length]));
+device.queue.writeBuffer(uniformBuffer0, 252, new Float32Array([emissiveTriangleIndices.length - 1, UNIFORMS.thin_lens ? 1 : 0]));
+
+let camera = createCamera(canvas);
+InitPane(device, UNIFORMS, uniformBuffer0)
+setupCameraInput(canvas)
+
+device.queue.writeBuffer(uniformBuffer0, 0, camera.position);
+device.queue.writeBuffer(uniformBuffer0, 16, camera.view);
+device.queue.writeBuffer(uniformBuffer0, 80, camera.inverseView);
+device.queue.writeBuffer(uniformBuffer0, 144, camera.projection);
+
+let cpuStart = 0;
+let cpuEnd = 0;
+let frametimeMs;
+const framedata = new Float32Array(1);
+
+const workgroupSize = 16;
+const dispatchX = Math.ceil(width / workgroupSize);
+const dispatchY = Math.ceil(height / workgroupSize);
+
+async function renderFrame() {
+  cpuStart = performance.now();
+  window.framecount++;
+  framedata[0] = window.framecount;
+  updateMovementInput();
+  updateCamera(camera);
+  if (camera.dirty) {
+    window.framecount = 0; // reset accumulation
+    device.queue.writeBuffer(uniformBuffer0, 0, camera.position);
+    device.queue.writeBuffer(uniformBuffer0, 16, camera.view);
+    device.queue.writeBuffer(uniformBuffer0, 80, camera.inverseView);
+    device.queue.writeBuffer(uniformBuffer0, 144, camera.projection);
+  }
+  device.queue.writeBuffer(uniformBuffer0, 12, framedata);
+  const commandEncoder = device.createCommandEncoder();
+  // compute pass
+  var computePass = commandEncoder.beginComputePass();
+  computePass.setPipeline(kernelPipeline);
+  computePass.setBindGroup(0, geometryBindgroup);
+  computePass.setBindGroup(1, shadingBindGroup);
+  computePass.dispatchWorkgroups(dispatchX, dispatchY);
+  computePass.end();
+  // blitt pass
+  const renderPass = commandEncoder.beginRenderPass({
+    label: "main",
+    colorAttachments: [
+      {
+        view: context.getCurrentTexture().createView(),
+        clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }, // rgba
+        loadOp: "clear",
+        storeOp: "store",
+      },
+    ],
+  });
+  renderPass.setPipeline(viewportPipeline);
+  renderPass.setBindGroup(0, viewportBindgroup);
+  renderPass.draw(6, 1, 0, 0);
+  renderPass.end();
+  device.queue.submit([commandEncoder.finish()]);
+  device.queue.onSubmittedWorkDone().then(
+    () => {
+      cpuEnd = performance.now();
+      frametimeMs = cpuEnd - cpuStart;
+      frametime = parseInt(frametimeMs.toFixed(2));
+      UNIFORMS.frameTimeMs = frametime;
+    }
+  );
+  requestAnimationFrame(renderFrame);
+}
+
+requestAnimationFrame(renderFrame);<
\ No newline at end of file
ADD · src/pane.ts +142 -0
--- a/src/pane.ts
+++ b/src/pane.ts
@@ -0,0 +1,142 @@
+import { Vec3 } from 'gl-matrix';
+import { Pane } from 'tweakpane';
+
+export const InitPane = (device: GPUDevice, UNIFORMS: any, uniformBuffer0: GPUBuffer) => {
+  const container = document.getElementById("pane-container") as HTMLElement;
+  const header = document.getElementById("pane-container-header") as HTMLElement;
+
+  const pane = new Pane({
+    container: container,
+  });
+  let offsetX = 0;
+  let offsetY = 0;
+  let isDragging = false;
+  header.addEventListener('mousedown', (e) => {
+    isDragging = true;
+    offsetX = e.clientX - container.offsetLeft;
+    offsetY = e.clientY - container.offsetTop;
+    container.style.cursor = 'move';
+  });
+  document.addEventListener('mousemove', (e) => {
+    if (isDragging) {
+      let x = e.clientX - offsetX;
+      let y = e.clientY - offsetY;
+
+      const viewportWidth = window.innerWidth;
+      const viewportHeight = window.innerHeight;
+
+      const containerWidth = container.offsetWidth;
+      const containerHeight = container.offsetHeight;
+
+      const minX = 0;
+      const maxX = viewportWidth - containerWidth;
+      const minY = 0;
+      const maxY = viewportHeight - containerHeight;
+
+      x = Math.max(minX, Math.min(x, maxX));
+      y = Math.max(minY, Math.min(y, maxY));
+
+      container.style.left = `${x}px`;
+      container.style.top = `${y}px`;
+    }
+  });
+  document.addEventListener('mouseup', () => {
+    isDragging = false;
+    container.style.cursor = 'default';
+  });
+
+  //packed vars: samp_count, bounce, aperture, focal_length]
+  let packed0 = new Float32Array([UNIFORMS.sample_count, UNIFORMS.bounce_count, UNIFORMS.aperture, UNIFORMS.focal_length])
+  pane.addBinding(UNIFORMS, 'frameTimeMs', {
+    readonly: true,
+    label: "Frame Time ",
+    view: 'number',
+    min: 0,
+    max: 120.00,
+  });
+  pane.addBinding(UNIFORMS, 'frameTimeMs', {
+    readonly: true,
+    label: "Frame Time Graph",
+    view: 'graph',
+    min: 0,
+    max: 120.00,
+  });
+  const cameraFolder = pane.addFolder({ title: 'Camera' })
+  cameraFolder.addBinding(UNIFORMS, 'thin_lens', {
+    label: "Enable thin lens approx",
+  }).on('change', (e) => {
+    device.queue.writeBuffer(uniformBuffer0, 256, new Float32Array([e.value]));
+    window.framecount = 0;
+  });
+  cameraFolder.addBinding(UNIFORMS, 'sample_count', {
+    view: 'slider',
+    type: 'number',
+    label: 'Sample Count',
+    min: 1,
+    max: 5,
+    value: 1,
+    step: 1,
+  }).on('change', (e) => {
+    packed0[0] = e.value;
+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
+    window.framecount = 0;
+  });
+  cameraFolder.addBinding(UNIFORMS, 'bounce_count', {
+    view: 'slider',
+    type: 'number',
+    label: 'Max Bounces',
+    min: 1,
+    max: 10,
+    value: 1,
+    step: 1,
+  }).on('change', (e) => {
+    packed0[1] = e.value;
+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
+    window.framecount = 0;
+  });
+  cameraFolder.addBinding(UNIFORMS, 'aperture', {
+    view: 'slider',
+    type: 'number',
+    label: 'Aperture',
+    min: 0.1,
+    max: 1,
+    value: 0.1,
+  }).on('change', (e) => {
+    packed0[2] = e.value;
+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
+    window.framecount = 0;
+  });
+  cameraFolder.addBinding(UNIFORMS, 'focal_length', {
+    view: 'slider',
+    type: 'number',
+    label: 'Focal Length',
+    min: 1,
+    max: 200,
+    value: 1,
+  }).on('change', (e) => {
+    packed0[3] = e.value;
+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
+    window.framecount = 0;
+  });
+  const dirFolder = pane.addFolder({ title: 'Environment' });
+  dirFolder.addBinding(UNIFORMS, 'sun_angle', {
+    label: 'Sun Angle',
+    x: { min: -1, max: 1, step: 0.1 },
+    y: { min: -1, max: 1, step: 0.1 },
+    z: { min: -1, max: 1, step: 0.1 },
+  }).on('change', () => {
+    device.queue.writeBuffer(uniformBuffer0, 208, Vec3.fromValues(UNIFORMS.sun_angle.x, UNIFORMS.sun_angle.y, UNIFORMS.sun_angle.z));
+    window.framecount = 0;
+  });
+  dirFolder.addBinding(UNIFORMS, 'sun_color', {
+    color: { type: 'float' },
+    picker: 'inline',
+    label: 'Sun Color',
+    x: { min: -1, max: 1, step: 0.1 },
+    y: { min: -1, max: 1, step: 0.1 },
+    z: { min: -1, max: 1, step: 0.1 },
+  }).on('change', (e) => {
+    device.queue.writeBuffer(uniformBuffer0, 224, Vec3.fromValues(e.value.r * UNIFORMS.scale, e.value.g * UNIFORMS.scale, e.value.b * UNIFORMS.scale));
+    window.framecount = 0;
+  });
+}<
\ No newline at end of file
ADD · src/shaders/any_hit.wgsl +138 -0
--- a/src/shaders/any_hit.wgsl
+++ b/src/shaders/any_hit.wgsl
@@ -0,0 +1,138 @@
+fn is_occluded(pos: vec3f, normal: vec3f, light_dir: vec3f, light_distance: f32) -> bool {
+    var shadow_ray: Ray;
+    shadow_ray.origin = offset_ray(pos, normal);
+    shadow_ray.direction = light_dir;
+    var shadow_hit = trace_any(shadow_ray, light_distance);
+    return shadow_hit;
+}
+
+fn trace_any(ray: Ray, t_max: f32) -> bool {
+    var node_idx_stack: array<u32, 64>;
+    var stack_ptr: i32 = 0;
+    var current_node_idx: u32 = 0;
+
+    while (true) {
+        let node = node_tree.nodes[current_node_idx];
+
+        let primitive_count = u32(node.primitive_count);
+        let child_or_prim_idx = u32(node.left_child);
+        if (primitive_count == 0u) {
+            // internal node
+            let left_child_idx = child_or_prim_idx;
+            let right_child_idx = child_or_prim_idx + 1u;
+
+            // use t_max for pruning
+            let hit1 = any_hit_aabb(ray, node_tree.nodes[left_child_idx].min_corner, node_tree.nodes[left_child_idx].max_corner, t_max);
+            let hit2 = any_hit_aabb(ray, node_tree.nodes[right_child_idx].min_corner, node_tree.nodes[right_child_idx].max_corner, t_max);
+
+            var near_child_idx = left_child_idx;
+            var far_child_idx = right_child_idx;
+            var dist1_hit = hit1;
+            var dist2_hit = hit2;
+
+            if (!hit1 && hit2) {
+                near_child_idx = right_child_idx;
+                far_child_idx = left_child_idx;
+                dist1_hit = hit2;
+                dist2_hit = hit1;
+            }
+
+            if (dist1_hit) {
+                current_node_idx = near_child_idx;
+                if (dist2_hit) {
+                    if (stack_ptr >= 64) {
+                        break;
+                    }
+                    // overflow
+                    node_idx_stack[stack_ptr] = far_child_idx;
+                    stack_ptr += 1;
+                }
+                continue;
+                // descend into near child
+            }
+            // neither child is relevant, fall through to pop
+
+        }
+        else {
+            // leaf node
+            for (var i = 0u; i < primitive_count; i += 1u) {
+                let prim_index = tri_lut.primitive_indices[child_or_prim_idx + i];
+                let triangle = objects.triangles[i32(prim_index)];
+
+                // any_hit_triangle returns true if hit within range
+                if (any_hit_triangle(ray, triangle, 0.001, t_max)) {
+                    return true;
+                    // found an occlusion, exit immediately
+                }
+            }
+            // finished leaf without finding occlusion, fall through to pop
+        }
+
+        // pop from stack or break if empty
+        if (stack_ptr == 0) {
+            break;
+            // traversal finished without finding occlusion
+        }
+        else {
+            stack_ptr -= 1;
+            current_node_idx = node_idx_stack[stack_ptr];
+        }
+    }
+    // kill sunlight 0.0
+    let floor_z = 0.0;
+    let denom = ray.direction.z;
+    if (abs(denom) > 1e-6) {
+        let t = (floor_z - ray.origin.z) / denom;
+        if (t > 0.001 && t < t_max) {
+            return true;
+            // hit floor within range
+        }
+    }
+    return false;
+    // no occlusion found
+}
+
+fn any_hit_aabb(ray: Ray, aabb_min: vec3f, aabb_max: vec3f, t_max: f32) -> bool {
+    var inverse_dir: vec3<f32> = vec3(1.0) / ray.direction;
+    var tmin = (aabb_min - ray.origin) * inverse_dir;
+    var tmax = (aabb_max - ray.origin) * inverse_dir;
+    var t1 = min(tmin, tmax);
+    var t2 = max(tmin, tmax);
+    var t_near = max(max(t1.x, t1.y), t1.z);
+    var t_far = min(min(t2.x, t2.y), t2.z);
+    return t_near <= t_far && t_far >= 0.001 && t_near <= t_max;
+}
+
+// lazy, just clean this up to only use hit_triangle and move all shading data post hit out
+fn any_hit_triangle(ray: Ray, tri: Triangle, dist_min: f32, dist_max: f32) -> bool {
+    let edge1 = tri.corner_b - tri.corner_a;
+    let edge2 = tri.corner_c - tri.corner_a;
+
+    let pvec = cross(ray.direction, edge2);
+    let determinant = dot(edge1, pvec);
+
+    // reject nearly parallel rays.
+    if abs(determinant) < EPSILON {
+        return false;
+    }
+
+    let inv_det = 1.0 / determinant;
+    let tvec = ray.origin - tri.corner_a;
+
+    // compute barycentric coordinate u.
+    let u = dot(tvec, pvec) * inv_det;
+    if (u < 0.0 || u > 1.0) {
+        return false;
+    }
+
+    // compute barycentric coordinate v.
+    let qvec = cross(tvec, edge1);
+    let v = dot(ray.direction, qvec) * inv_det;
+    if (v < 0.0 || (u + v) > 1.0) {
+        return false;
+    }
+
+    // calculate ray parameter (distance).
+    let dist = dot(edge2, qvec) * inv_det;
+    return dist > dist_min && dist < dist_max;
+}<
\ No newline at end of file
ADD · src/shaders/brdf.wgsl +154 -0
--- a/src/shaders/brdf.wgsl
+++ b/src/shaders/brdf.wgsl
@@ -0,0 +1,154 @@
+// computes the geometry (shadowing/masking) term using smith's method.
+fn smith_geometry(normal: vec3<f32>, view_dir: vec3<f32>, light_dir: vec3<f32>, roughness: f32) -> f32 {
+    let alpha = roughness * roughness;
+    let n_dot_v = max(dot(normal, view_dir), 0.0);
+    let n_dot_l = max(dot(normal, light_dir), 0.0);
+    let k = (alpha + 1.0) * (alpha + 1.0) / 8.0;
+    let geom_v = n_dot_v / (n_dot_v * (1.0 - k) + k);
+    let geom_l = n_dot_l / (n_dot_l * (1.0 - k) + k);
+    return geom_v * geom_l;
+}
+
+// computes the ggx normal distribution function (ndf) d.
+fn ggx_distribution(normal: vec3<f32>, half_vec: vec3<f32>, roughness: f32) -> f32 {
+    let rgh = max(0.03, roughness);
+    let alpha = rgh * rgh;
+    let alpha2 = alpha * alpha;
+    let n_dot_h = max(dot(normal, half_vec), 0.001);
+    let denom = (n_dot_h * n_dot_h) * (alpha2 - 1.0) + 1.0;
+    return alpha2 / (PI * denom * denom);
+}
+
+// samples a half–vector (h) from the ggx distribution in tangent space.
+fn ggx_sample_vndf(view_dir: vec3<f32>, normal: vec3<f32>, roughness: f32, noise: vec2<f32>) -> vec3<f32> {
+    // build local frame (t, b, n)
+    let up: vec3<f32> = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.z) < 0.999);
+    let tangent = normalize(cross(up, normal));
+    let bitangent = cross(normal, tangent);
+
+    // transform view direction to local space
+    let v = normalize(vec3<f32>(dot(view_dir, tangent), dot(view_dir, bitangent), dot(view_dir, normal)));
+
+    // stretch view vector
+    let a = roughness;
+    let vh = normalize(vec3<f32>(a * v.x, a * v.y, v.z));
+
+    // orthonormal basis
+    let lensq = vh.x * vh.x + vh.y * vh.y;
+    let t1 = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(- vh.y, vh.x, 0.0) / sqrt(lensq), lensq > 1e-6);
+    let t2 = cross(vh, t1);
+
+    // sample point with polar coordinates
+    let r = sqrt(noise.x);
+    let phi = 2.0 * PI * noise.y;
+    let r1 = r * cos(phi);
+    let r2 = r * sin(phi);
+    let s = 0.5 * (1.0 + vh.z);
+    let t2_ = mix(sqrt(1.0 - r1 * r1), r2, s);
+
+    // sampled halfway vector in local space
+    let nh = r1 * t1 + t2_ * t2 + sqrt(max(0.0, 1.0 - r1 * r1 - t2_ * t2_)) * vh;
+
+    // unstretch
+    let h = normalize(vec3<f32>(a * nh.x, a * nh.y, max(0.0, nh.z)));
+
+    // transform back to world space
+    return normalize(h.x * tangent + h.y * bitangent + h.z * normal);
+}
+
+fn ggx_specular_sample(view_dir: vec3<f32>, normal: vec3<f32>, seed: vec2<f32>, roughness: f32) -> vec3<f32> {
+    let h = ggx_sample_vndf(view_dir, normal, roughness, seed);
+    let r = reflect(- view_dir, h);
+    return select(normalize(r), vec3<f32>(0.0), dot(r, normal) <= EPSILON);
+}
+
+// cosine-weighted hemisphere sample in local space, then converted to world space.
+fn cosine_hemisphere_sample(normal: vec3<f32>, noise: vec2<f32>) -> vec3<f32> {
+    // var current_seed = seed;
+    let r1 = noise.x;
+    let r2 = noise.y;
+
+    // let r1 = uniform_float(state);
+    // let r2 = uniform_float(state);
+
+    let phi = 2.0 * PI * r2;
+    let cos_theta = sqrt(1.0 - r1);
+    let sin_theta = sqrt(r1);
+
+    // sample in local coordinates (with z as the normal)
+    let local_sample = vec3<f32>(sin_theta * cos(phi), sin_theta * sin(phi), cos_theta);
+
+    // build tangent space and transform sample to world space.
+    // let up = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.z) > 0.999);
+
+    let tangent = normalize(cross(select(vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.y) > 0.99), normal));
+    let bitangent = cross(normal, tangent);
+
+    // let tangent = normalize(cross(up, normal));
+    // let bitangent = cross(normal, tangent);
+    let world_dir = normalize(local_sample.x * tangent + local_sample.y * bitangent + local_sample.z * normal);
+    return world_dir;
+}
+
+fn eval_f0(metallic: f32, albedo: vec3<f32>) -> vec3<f32> {
+    let dielectric_f0 = vec3<f32>(0.04);
+    return mix(dielectric_f0, albedo, metallic);
+}
+
+fn fresnel_schlick_roughness(cos_theta: f32, f0: vec3<f32>, roughness: f32) -> vec3<f32> {
+    let one_minus_cos = 1.0 - cos_theta;
+    let factor = pow(one_minus_cos, 5.0);
+    let fresnel = f0 + (max(vec3f(1.0 - roughness), f0) - f0) * factor;
+    return fresnel;
+}
+
+fn disney_diffuse(albedo: vec3<f32>, roughness: f32, n_dot_l: f32, n_dot_v: f32, l_dot_h: f32) -> vec3<f32> {
+    let fd90 = 0.5 + 2.0 * l_dot_h * l_dot_h * roughness;
+    let light_scatter = 1.0 + (fd90 - 1.0) * pow(1.0 - n_dot_l, 5.0);
+    let view_scatter = 1.0 + (fd90 - 1.0) * pow(1.0 - n_dot_v, 5.0);
+    return albedo * light_scatter * view_scatter * (1.0 / PI);
+}
+
+fn eval_brdf(normal: vec3<f32>, view_dir: vec3<f32>, light_dir: vec3<f32>, material: Material) -> vec3<f32> {
+    let n = normal;
+    let v = view_dir;
+    let l = light_dir;
+    let h = normalize(v + l);
+
+    let ndot_l = max(dot(n, l), EPSILON);
+    let ndot_v = max(dot(n, v), EPSILON);
+    let ndot_h = max(dot(n, h), EPSILON);
+    let vdot_h = max(dot(v, h), EPSILON);
+    let ldot_h = max(dot(l, h), EPSILON);
+
+    let f0 = mix(vec3f(0.04), material.albedo.rgb, material.metallic);
+    let f = fresnel_schlick_roughness(vdot_h, f0, material.roughness);
+
+    // frostbite specular
+    let d = ggx_distribution(n, h, material.roughness);
+    let g = smith_geometry(n, v, l, material.roughness);
+    let spec = (d * g * f) / (4.0 * ndot_v * ndot_l + 0.001);
+
+    let diffuse = (1.0 - material.metallic) * disney_diffuse(material.albedo.rgb, material.roughness, ndot_l, ndot_v, ldot_h);
+    return diffuse + spec;
+}
+
+fn ggx_pdf(view_dir: vec3<f32>, normal: vec3<f32>, h: vec3<f32>, roughness: f32) -> f32 {
+    let d = ggx_distribution(normal, h, roughness);
+    let n_dot_h = max(dot(normal, h), EPSILON);
+    let v_dot_h = max(dot(view_dir, h), EPSILON);
+    // change–of–variables: pdf(r) = d(h) * n_dot_h / (4 * v_dot_h)
+    return (d * n_dot_h) / (4.0 * v_dot_h);
+}
+
+fn cosine_pdf(normal: vec3<f32>, dir: vec3<f32>) -> f32 {
+    // cosine weighted density: d = cos(theta) / PI.
+    let cos_theta = max(dot(normal, dir), EPSILON);
+    if cos_theta <= 0.0 {
+        return 0.0;
+        // no contribution when direction is opposite or perpendicular to the normal.
+    }
+    return cos_theta / PI;
+}
+
+
ADD · src/shaders/main.wgsl +593 -0
--- a/src/shaders/main.wgsl
+++ b/src/shaders/main.wgsl
@@ -0,0 +1,593 @@
+#include random.wgsl
+#include brdf.wgsl
+#include sky.wgsl
+#include any_hit.wgsl
+#include utils.wgsl
+
+@group(0) @binding(0) var output_buffer : texture_storage_2d<rgba32float, write>;
+@group(0) @binding(1) var<storage, read> objects: Objects;
+@group(0) @binding(2) var<uniform> uniforms: UniformLayout;
+@group(0) @binding(5) var<storage, read> node_tree: BVH;
+@group(0) @binding(6) var<storage, read> tri_lut: ObjectIndices;
+@group(0) @binding(7) var<storage, read_write> input_buffer:array<vec3f>;
+
+@group(1) @binding(0) var<storage, read> materials:array<Material>;
+@group(1) @binding(1) var textures: texture_2d_array<f32>;
+@group(1) @binding(2) var t_sampler: sampler;
+@group(1) @binding(4) var<uniform> textureSizes: array<vec4<f32>, 128>;
+@group(1) @binding(6) var blueNoiseTexture : texture_storage_2d<rgba8unorm, read>;
+@group(1) @binding(7) var<storage, read> emissiveTriangleIndices : array<f32>;
+
+// @group(0) @binding(3) var skybox: texture_2d<f32>;
+// @group(0) @binding(4) var skybox_sampler: sampler;
+// @group(1) @binding(5) var skyboxCDF: texture_storage_2d<rg32float, read>;
+// @group(1) @binding(3) var<storage, read> areaLights:array<AreaLight>;
+
+struct Triangle {
+  corner_a: vec3<f32>,
+  corner_b: vec3<f32>,
+  corner_c: vec3<f32>,
+  normal_a: vec3<f32>,
+  normal_b: vec3<f32>,
+  normal_c: vec3<f32>,
+  material_idx: f32,
+  uv_a: vec2<f32>,
+  uv_b: vec2<f32>,
+  uv_c: vec2<f32>,
+  tangent_a: vec4f,
+  tangent_b: vec4f,
+  tangent_c: vec4f,
+}
+
+// struct AreaLight {
+//   center: vec3<f32>,   
+//   u: vec3<f32>,         
+//   v: vec3<f32>,    
+//   normal: vec3<f32>,
+//   emission: vec3<f32>, 
+// };
+
+struct Ray {
+  direction: vec3<f32>,
+  origin: vec3<f32>,
+}
+
+struct HitInfo {
+  dist: f32,
+  hit: bool,
+  position: vec3<f32>,
+  normal: vec3<f32>, 
+  material_idx: i32,
+  geo_normal: vec3f,
+  tri: Triangle,
+  uv: vec2f,
+  tangent: vec3<f32>,
+  bitangent: vec3<f32>,
+}
+
+struct UniformLayout {
+  position: vec3<f32>,
+  frame_idx: f32,
+  view: mat4x4<f32>,    
+  inverse_view: mat4x4<f32>,
+  projection: mat4x4<f32>,
+  sun_direction: vec3<f32>,
+  sun_angular_size: f32,
+  sun_radiance: vec3<f32>,
+  sample_count: f32,
+  max_depth: f32,
+  aperture: f32,
+  focus_distance: f32,
+  emissive_triangle_count: f32,
+  thin_lens: f32,
+}
+
+struct Node {
+    min_corner: vec3<f32>,
+    left_child: f32,
+    max_corner: vec3<f32>,
+    primitive_count: f32,
+}
+
+struct BVH {
+    nodes: array<Node>,
+}
+
+struct ObjectIndices {
+    primitive_indices: array<f32>,
+}
+
+struct Objects {
+  triangles: array<Triangle>,
+}
+
+struct Material {
+    albedo: vec4<f32>,  
+    metallic: f32,
+    alpha_mode: f32, 
+    alpha_cutoff: f32, 
+    double_sided: f32,
+    emission: vec3<f32>,
+    roughness: f32,
+    base_color_texture: f32,
+    normal_texture: f32,
+    metallic_roughness_texture: f32,
+    emissive_texture: f32,
+}
+
+
+
+const EPSILON :f32 = 0.00001f;
+const PI :f32 = 3.1415927f;
+// ray tracing gems part 1 chapter 6
+const FLOAT_SCALE = 1.0 / 65536.0;
+const INT_SCALE = 256.0;
+const ORIGIN = 1.0 / 32.0;
+
+// Slightly offsets a ray to prevent self intersection artifacts
+// Ray tracing gems part 1 chapter 6
+fn offset_ray(p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
+    let of_i = vec3<i32>(
+        i32(INT_SCALE * n.x),
+        i32(INT_SCALE * n.y),
+        i32(INT_SCALE * n.z)
+    );
+
+    let p_i = vec3<f32>(
+        int_to_float(float_to_int(p.x) + select(of_i.x, -of_i.x, p.x < 0.0)),
+        int_to_float(float_to_int(p.y) + select(of_i.y, -of_i.y, p.y < 0.0)),
+        int_to_float(float_to_int(p.z) + select(of_i.z, -of_i.z, p.z < 0.0))
+    );
+
+    return vec3<f32>(
+        select(p.x + FLOAT_SCALE * n.x, p_i.x, abs(p.x) >= ORIGIN),
+        select(p.y + FLOAT_SCALE * n.y, p_i.y, abs(p.y) >= ORIGIN),
+        select(p.z + FLOAT_SCALE * n.z, p_i.z, abs(p.z) >= ORIGIN)
+    );
+}
+
+fn sample_material_texture(uv: vec2<f32>, texture_index: u32) -> vec4<f32> {
+    let tex_size = textureSizes[texture_index].xy;
+    let max_tex_size = vec2<f32>(textureDimensions(textures).xy);
+    // let scaled_uv = uv * tex_size / max_tex_size;
+    // let clamped_uv = clamp(scaled_uv, vec2<f32>(0.0), vec2<f32>(1.0));
+    // compute the valid uv bounds inside the texture array
+    let tex_uv_min = vec2<f32>(0.0); // always starts at (0,0)
+    let tex_uv_max = tex_size / max_tex_size; // upper-right boundary in the atlas
+    // remap u_vs to this valid range
+    let mapped_uv = mix(tex_uv_min, tex_uv_max, uv);
+    return textureSampleLevel(textures, t_sampler, mapped_uv, texture_index, 1.0).rgba;
+}
+
+
+fn parse_textures(curr_material: Material, result: HitInfo) -> Material {
+    var material = curr_material;
+    if material.base_color_texture > -1.0 {
+        material.albedo *= sample_material_texture(result.uv, u32(curr_material.base_color_texture)).rgba;
+    }
+    if material.metallic_roughness_texture > -1.0 {
+        let metallic_roughness_texture = sample_material_texture(result.uv, u32(curr_material.metallic_roughness_texture));
+        material.roughness *= metallic_roughness_texture.g;
+        material.metallic *= metallic_roughness_texture.b;
+    }
+    if material.emissive_texture > -1.0 {
+        material.emission = sample_material_texture(result.uv, u32(curr_material.emissive_texture)).rgb;
+    }
+    return material;
+}
+
+
+fn point_in_unit_disk(u: vec2f) -> vec2f {
+    let r = sqrt(u.x);
+    let theta = 2f * PI * u.y;
+    return vec2f(r * cos(theta), r * sin(theta));
+}
+
+fn generate_pinhole_camera_ray(ndc: vec2<f32>, noise: vec2f) -> Ray {
+    var ray : Ray;
+    let aspect = uniforms.projection[1][1] / uniforms.projection[0][0]; // same as 1/tan_half_fov_y divided by 1/tan_half_fov_x
+    let tan_half_fov_y = 1.0 / uniforms.projection[1][1];
+
+    let x = ndc.x * aspect * tan_half_fov_y;
+    let y = ndc.y * tan_half_fov_y;
+
+    // camera basis vectors from the view matrix
+    let right   =  uniforms.inverse_view[0].xyz;
+    let up      =  uniforms.inverse_view[1].xyz;
+    let forward = -uniforms.inverse_view[2].xyz;
+    let origin  = uniforms.position;
+
+    let pinhole_dir = normalize(x * right + y * up + forward);
+
+    let focus_dist = uniforms.focus_distance;
+    let aperture  = uniforms.aperture;
+    let focus_point = origin + pinhole_dir * focus_dist;
+    
+    // sample lens (in local right-up plane)
+    let lens_sample = point_in_unit_disk(noise) * aperture;
+    let lens_offset = lens_sample.x * right + lens_sample.y * up;
+
+    if (uniforms.thin_lens == 0.0){
+         ray.origin = origin;
+         ray.direction = pinhole_dir;
+    } else {
+        ray.origin = origin + lens_offset;
+        ray.direction = normalize(focus_point - ray.origin);
+    }
+    return ray;
+}
+
+
+@compute @workgroup_size(16, 16)
+fn main(
+    @builtin(global_invocation_id) GlobalInvocationID: vec3<u32>,
+    @builtin(local_invocation_id) LocalInvocationID: vec3<u32>,
+    @builtin(workgroup_id) GroupIndex: vec3<u32>) {
+    // https://www.w3.org/TR/webgpu/#coordinate-systems
+    let output_dimension: vec2<i32> = vec2<i32>(textureDimensions(output_buffer));
+    let pixel_position: vec2<i32> = vec2<i32>(i32(GlobalInvocationID.x), i32(GlobalInvocationID.y));
+    let pixel_idx: i32 = pixel_position.y * output_dimension.x + pixel_position.x;
+    
+    let pixel_center: vec2<f32> = vec2<f32>(pixel_position) + vec2f(0.5);
+    let uv: vec2<f32> = pixel_center / vec2f(output_dimension);
+    let ndc: vec2<f32> = uv * 2.0 - vec2f(1.0);
+
+    let noise = animated_blue_noise(pixel_position, u32(uniforms.frame_idx), u32(64)); 
+    var rnd_state = u32(0);
+    init_random(&rnd_state, u32(uniforms.frame_idx));
+    init_random(&rnd_state, u32(pixel_position.x));
+    init_random(&rnd_state, u32(pixel_position.y));
+
+    let jitter_scale: f32 = 1;   
+    // Apply blue noise instead of uniformFloat
+    let jitter_x: f32 = (noise.x - 0.5) / f32(output_dimension.x) * jitter_scale;
+    let jitter_y: f32 = (noise.y - 0.5) / f32(output_dimension.y) * jitter_scale;
+    
+    let n2 = (ndc.x + jitter_x);
+    let n3 = ndc.y + jitter_y;
+    let ray = generate_pinhole_camera_ray(vec2f(n2, n3), noise);
+
+    var accumulated_color: vec3<f32> = vec3<f32>(0.0);
+    let frame_weight: f32 = 1.0 / (uniforms.frame_idx + 1);
+    let samples_per_pixel: i32 = i32(uniforms.sample_count);
+    for (var i: i32 = 0; i < samples_per_pixel; i ++) {
+        var pixel_color: vec3<f32> = shade_hit(ray, rnd_state, noise);
+        var r = pixel_color.x;
+        var g = pixel_color.y;
+        var b = pixel_color.z;
+        // lazy NaN catching
+        if (r != r){ pixel_color.r = 0.0;};
+        if (g != g){ pixel_color.g = 0.0;};
+        if (b != b){ pixel_color.b = 0.0;};
+        accumulated_color += pixel_color;
+    }
+    
+    accumulated_color = accumulated_color / f32(samples_per_pixel);
+    var prev_color: vec3<f32> = input_buffer[pixel_idx];
+    var final_output : vec3f = (prev_color * uniforms.frame_idx + accumulated_color) / (uniforms.frame_idx + 1.0);
+    input_buffer[pixel_idx] = final_output;
+    textureStore(output_buffer, pixel_position, vec4f(final_output, 1.0));
+}
+
+fn trace(ray: Ray) -> HitInfo {
+    var render_state: HitInfo;
+    render_state.hit = false;
+    var nearest_hit: f32 = 999.0;
+
+    // set up for bvh traversal
+    var node: Node = node_tree.nodes[0];
+    var stack: array<Node, 32>;
+    var stack_location: i32 = 0;
+
+    while true {
+        var primitive_count: u32 = u32(node.primitive_count);
+        var contents: u32 = u32(node.left_child);
+
+        if primitive_count == 0 {
+            var child1: Node = node_tree.nodes[contents];
+            var child2: Node = node_tree.nodes[contents + 1];
+
+            var distance1: f32 = hit_aabb(ray, child1);
+            var distance2: f32 = hit_aabb(ray, child2);
+
+            if distance1 > distance2 {
+                var temp_dist: f32 = distance1;
+                distance1 = distance2;
+                distance2 = temp_dist;
+
+                var temp_child: Node = child1;
+                child1 = child2;
+                child2 = temp_child;
+            }
+
+            if distance1 > nearest_hit {
+                if stack_location == 0 {
+                    break;
+                } else {
+                    stack_location -= 1;
+                    node = stack[stack_location];
+                }
+            } else {
+                node = child1;
+                if distance1 < nearest_hit {
+                    stack[stack_location] = child2;
+                    stack_location += 1;
+                }
+            }
+        } else {
+            for (var i: u32 = 0; i < primitive_count; i++) {
+                var new_render_state: HitInfo = hit_triangle(
+                    ray,
+                    objects.triangles[u32(tri_lut.primitive_indices[i + contents])],
+                    0.001,
+                    nearest_hit,
+                    render_state,
+                );
+                if new_render_state.hit {
+                    nearest_hit = new_render_state.dist;
+                    render_state = new_render_state;
+                }
+            }
+            if stack_location == 0 {
+                break;
+            } else {
+                stack_location -= 1;
+                node = stack[stack_location];
+            }
+        }
+    }
+    return render_state;
+}
+
+fn shade_hit(ray: Ray, seed: u32, noise: vec2f) -> vec3<f32> {
+    var current_seed = seed;
+    var radiance = vec3f(0.0);
+    var throughput = vec3f(1.0);
+    var result: HitInfo;
+
+    var temp_ray = ray;
+    let bounces: u32 = u32(uniforms.max_depth);
+
+    var pdf: f32;
+    var env_pdf: f32;
+    var mis_weight : f32 = 1.0;
+
+    var sun_solid_angle = 2.0 * PI * (1.0 - cos(uniforms.sun_angular_size));
+    let sun_pdf = 1.0 / sun_solid_angle; 
+    let sky_pdf = 1.0 / PI;
+
+    for (var bounce: u32 = 0; bounce < bounces; bounce++) {
+        result = trace(temp_ray);
+        if (!result.hit) {
+            // We hit the environment; skip the sun for now. Atleast till this rudimentry temporal accmulation exists. 
+            // let to_sun = dot(temp_ray.direction, uniforms.sun_direction) > cos(uniforms.sun_angular_size);            
+            // let sun_radiance = sun_glow(temp_ray.direction, uniforms.sun_direction);
+            // if (to_sun) {
+            //  radianceOut += sun_radiance;
+            // }
+            // if (to_sun) {
+            //  env_pdf_eval = 0.5 * sun_pdf;
+            // }
+            let viewZenith = abs(temp_ray.direction.z);
+            let extinction = exp(-2.0 * pow(1.0 - viewZenith, 3.0));
+            let skyRadiance = sky_glow(temp_ray.direction, uniforms.sun_direction) * extinction;
+            let radianceOut = skyRadiance;
+            if (bounce == 0) {
+                radiance += throughput * radianceOut;
+                break;
+            }
+            // bsdf generated ray carries the PDF forward to this bounce
+            var env_pdf_eval = 0.5 * sky_pdf;
+            let env_mis_weight = pdf / (pdf + env_pdf_eval);
+            radiance += clamp_hdr(throughput * radianceOut * env_mis_weight, 10.0);
+            break;
+        }
+
+        let rand = vec2f(uniform_float(&current_seed), uniform_float(&current_seed));
+        var material: Material = parse_textures(materials[result.material_idx], result);
+        if (material.emission.x > 0.0 || material.emission.y > 0.0 || material.emission.z > 0.0) {
+            radiance += throughput * material.emission;
+            // break;
+        }
+
+        // sun nee, mis weight based on prior bounce brdf 
+        let env_dir = sample_sun_cone_dir(rand);
+        let env_color = sun_glow(env_dir, uniforms.sun_direction);
+        let env_pdf = sun_pdf;
+        let n_dot_env = dot(result.normal, env_dir);
+        if (n_dot_env > 0.0 && !is_occluded(result.position, result.geo_normal, env_dir, 99999.9)) {
+            let env_brdf = eval_brdf(result.normal, -temp_ray.direction, env_dir, material);
+            let diffuse_density = cosine_pdf(result.normal, env_dir);
+            let specular_density = ggx_pdf(-temp_ray.direction, result.normal, normalize(-temp_ray.direction + env_dir), material.roughness);
+            let bsdf_pdf = 0.5 * specular_density + 0.5 * diffuse_density;
+            let weight = env_pdf / (env_pdf + bsdf_pdf);
+            radiance += clamp_hdr(throughput * env_brdf * env_color * n_dot_env * weight / env_pdf, 10.0);
+        }
+
+        // TODO: Better selection, and also move this out.
+        // emissive nee, uniformly sample emissives
+        let light_index = min(u32(floor(rand.x * f32(uniforms.emissive_triangle_count))), u32(uniforms.emissive_triangle_count - 1.0));
+        let tri_index = emissiveTriangleIndices[light_index];
+        let tri = objects.triangles[i32(tri_index)];
+        // uniformly sample point on triangle
+        let u = 1.0 - rand.x;
+        let v = rand.x * (1.0 - rand.y);
+        let w = rand.x * rand.y;
+        let light_pos = u * tri.corner_a + v * tri.corner_b + w * tri.corner_c;
+        let light_normal =  normalize(cross(tri.corner_b - tri.corner_a, tri.corner_c - tri.corner_a));
+        let to_light = light_pos - result.position;
+        let dist2 = dot(to_light, to_light);
+        let dist = sqrt(dist2);
+        let light_dir = to_light / dist;
+        let cos_surf = dot(result.normal, light_dir);
+        let cos_light = dot(light_normal, -light_dir);
+
+        if (cos_surf > 0.0 && cos_light > 0.0 && !is_occluded(result.position, result.geo_normal, light_dir, dist)) {
+            var mat = materials[i32(tri.material_idx)];
+            let direct_light_emissive_brdf = eval_brdf(result.normal, -temp_ray.direction, light_dir, material);
+            // compute area of the triangle
+            let edge1 = tri.corner_b - tri.corner_a;
+            let edge2 = tri.corner_c - tri.corner_a;
+            let area = 0.5 * length(cross(edge1, edge2));
+            let light_power = area * mat.emission;
+            // area to solid angle PDF conversion
+            let pdf_solid_angle = dist2 / ( area);
+
+            let diffuse_pdf = cosine_pdf(result.normal, light_dir);
+            let specular_pdf = ggx_pdf(-temp_ray.direction, result.normal, normalize(-temp_ray.direction + light_dir), material.roughness);
+            let bsdf_pdf = 0.5 * diffuse_pdf + 0.5 * specular_pdf;
+            let mis_weight = pdf_solid_angle / (pdf_solid_angle + bsdf_pdf);
+            let contrib = (throughput * direct_light_emissive_brdf * light_power * mis_weight) / pdf_solid_angle;
+            radiance += clamp_hdr(contrib, 10.0);
+        }
+        
+        // rr
+        if (bounce > u32(2)) {
+            let rrProbability = min(0.9, luminance(throughput));
+            if (rrProbability < rand.y) {
+                break;
+            } else {
+                throughput /= rrProbability;
+            }
+        }
+
+        var view_dir = -temp_ray.direction;
+        var new_dir: vec3<f32>;
+        var specular_density: f32;
+        var diffuse_density: f32;
+
+        if (uniform_float(&current_seed) < 0.5) {
+            new_dir = ggx_specular_sample(view_dir, result.normal, rand, material.roughness);
+        } else {
+            new_dir = cosine_hemisphere_sample(result.normal, vec2f(rand.y, rand.x));
+        }
+        let n_dot_l = dot(result.normal, new_dir);
+        if (n_dot_l <= 0.0) { break; }
+        specular_density = ggx_pdf(view_dir, result.normal, normalize(view_dir + new_dir), material.roughness);
+        diffuse_density = cosine_pdf(result.normal, normalize(new_dir));
+        pdf = 0.5  * specular_density + 0.5 * diffuse_density;
+        
+        let indirect_brdf = eval_brdf(result.normal, view_dir, new_dir, material);
+        throughput *= (indirect_brdf * n_dot_l) / pdf;
+        
+        temp_ray.origin = offset_ray(result.position, result.geo_normal);
+        temp_ray.direction = new_dir;
+    }
+
+    return radiance;
+}
+
+fn hit_triangle(ray: Ray, tri: Triangle, dist_min: f32, dist_max: f32, prevRay: HitInfo) -> HitInfo {
+    var hit: HitInfo;
+    hit.hit = false;
+    
+    let edge1 = tri.corner_b - tri.corner_a;
+    let edge2 = tri.corner_c - tri.corner_a;
+    
+    let pvec = cross(ray.direction, edge2);
+    let determinant = dot(edge1, pvec);
+    
+    // reject nearly parallel rays.
+    if abs(determinant) < EPSILON {
+        return hit;
+    }
+    
+    let inv_det = 1.0 / determinant;
+    let tvec = ray.origin - tri.corner_a;
+    
+    // compute barycentric coordinate u.
+    let u = dot(tvec, pvec) * inv_det;
+    if (u < 0.0 || u > 1.0) {
+        return hit;
+    }
+    
+    // compute barycentric coordinate v.
+    let qvec = cross(tvec, edge1);
+    let v = dot(ray.direction, qvec) * inv_det;
+    if (v < 0.0 || (u + v) > 1.0) {
+        return hit;
+    }
+    
+    // calculate ray parameter (distance).
+    let dist = dot(edge2, qvec) * inv_det;
+    if (dist < dist_min || dist > dist_max) {
+        return hit;
+    }
+    
+    // no early outs; valid hit
+    hit.hit = true;
+    hit.dist = dist;
+    hit.position = ray.origin + ray.direction * dist;
+    hit.tri = tri;
+    hit.material_idx = i32(tri.material_idx);
+    
+    var geo_normal = normalize(cross(edge1, edge2));
+    var shading_normal = normalize((1.0 - u - v) * tri.normal_a + u * tri.normal_b + v * tri.normal_c);
+    let tangent = normalize((1.0 - u - v) * tri.tangent_a + u * tri.tangent_b + v * tri.tangent_c);
+    
+    // shadow terminator fix: warp the hit position based on vertex normals
+    // normal aware EPSILON on hit position basically
+    let w = 1.0 - u - v;
+    let tmpu = hit.position - tri.corner_a;
+    let tmpv = hit.position - tri.corner_b;
+    let tmpw = hit.position - tri.corner_c;
+
+    let dotu = min(0.0, dot(tmpu, tri.normal_a));
+    let dotv = min(0.0, dot(tmpv, tri.normal_b));
+    let dotw = min(0.0, dot(tmpw, tri.normal_c));
+
+    let pu = tmpu - dotu * tri.normal_a;
+    let pv = tmpv - dotv * tri.normal_b;
+    let pw = tmpw - dotw * tri.normal_c;
+
+    let warped_offset = w * pu + u * pv + v * pw;
+    // Move the hit point slightly along the warped vector field
+    hit.position = hit.position + warped_offset;
+
+    // TBN
+    let T = normalize(tangent.xyz);
+    let N = normalize(shading_normal);
+    let B = normalize(cross(N, T)) * tangent.w;
+
+    hit.tangent = cross(B, N);
+    hit.normal = shading_normal;
+    hit.uv = (1.0 - u - v) * tri.uv_a + u * tri.uv_b + v * tri.uv_c;
+
+    // If a normal map is present, perturb the shading normal.
+    let material = materials[i32(tri.material_idx)];
+    if (material.normal_texture > -1.0) {
+        var normal_map = sample_material_texture(hit.uv, u32(material.normal_texture));
+        var normalized_map = normalize(normal_map * 2.0 - 1.0);
+        normalized_map.y = -normalized_map.y;
+        let world_normal = normalize(
+            normalized_map.x * T +
+            normalized_map.y * B +
+            normalized_map.z * N
+        );
+        hit.normal = world_normal;
+    }
+    var ray_dot_tri: f32 = dot(ray.direction, geo_normal);
+    if (ray_dot_tri > 0.0) {
+        hit.geo_normal = -hit.geo_normal;
+        hit.normal     = -hit.normal;
+    }
+    return hit;
+}
+
+fn hit_aabb(ray: Ray, node: Node) -> f32 {
+    var reciprocal : vec3<f32> = vec3f(1.0) / ray.direction;
+    var t_near: vec3<f32> = (node.min_corner - ray.origin) * reciprocal;
+    var t_far: vec3<f32> = (node.max_corner - ray.origin) * reciprocal;
+    var t_min: vec3<f32> = min(t_near, t_far);
+    var t_max: vec3<f32> = max(t_near, t_far);
+
+    var min_intersection: f32 = max(max(t_min.x, t_min.y), t_min.z);  // t0
+    var max_intersection: f32 = min(min(t_max.x, t_max.y), t_max.z);  // t1 
+
+    var mask: f32 = step(max_intersection, min_intersection) + step(max_intersection, 0.0);
+    if min_intersection > max_intersection || max_intersection < 0 {
+        return 9999.0;
+    } else {
+        return min_intersection;
+    }
+}<
\ No newline at end of file
ADD · src/shaders/random.wgsl +34 -0
--- a/src/shaders/random.wgsl
+++ b/src/shaders/random.wgsl
@@ -0,0 +1,34 @@
+fn init_random(state: ptr<function, u32>, value: u32) {
+    * state ^= value;
+    * state = pcg(*state);
+}
+
+fn pcg(n: u32) -> u32 {
+    var h = n * 747796405u + 2891336453u;
+    h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
+    return (h >> 22u) ^ h;
+}
+
+fn uniform_uint(state: ptr<function, u32>, max: u32) -> u32 {
+    * state = pcg(*state);
+    return * state % max;
+}
+
+fn uniform_float(state: ptr<function, u32>) -> f32 {
+    * state = pcg(*state);
+    return f32(*state) / 4294967295.0;
+}
+
+fn animated_blue_noise(coord: vec2<i32>, frame_count: u32, frame_count_cycle: u32) -> vec2f {
+    // spatial 
+    let tex_size = vec2<u32>(textureDimensions(blueNoiseTexture).xy);
+    let wrapped_coord = vec2<i32>((coord.x % i32(tex_size.x) + i32(tex_size.x)) % i32(tex_size.x), (coord.y % i32(tex_size.y) + i32(tex_size.y)) % i32(tex_size.y));
+    let blue_noise = textureLoad(blueNoiseTexture, wrapped_coord).xy;
+    let idx = (f32(wrapped_coord.y) % blue_noise.y) * blue_noise.x + (f32(wrapped_coord.x) % blue_noise.x);
+    // temporal
+    let n = frame_count % frame_count_cycle;
+    let a1 = 0.7548776662466927f;
+    let a2 = 0.5698402909980532f;
+    let r2_seq = fract(vec2(a1 * f32(n), a2 * f32(n)));
+    return fract(blue_noise + r2_seq);
+}
ADD · src/shaders/sky.wgsl +67 -0
--- a/src/shaders/sky.wgsl
+++ b/src/shaders/sky.wgsl
@@ -0,0 +1,67 @@
+const sun_angular_size = 1.0 * (PI / 180.0);
+
+fn dir_in_cone(u: vec2f) -> vec3<f32> {
+    let sun_cos_theta_max = cos(0.255f * PI / 180f);
+    let cos_theta = 1f - u.x * (1f - sun_cos_theta_max);
+    let sin_theta = sqrt(1f - cos_theta * cos_theta);
+    let phi = 2f * PI * u.y;
+    let x = cos(phi) * sin_theta;
+    let y = sin(phi) * sin_theta;
+    let z = cos_theta;
+    return vec3(x, y, z);
+}
+
+fn sample_sun_cone_dir(u: vec2f) -> vec3<f32> {
+    let v = dir_in_cone(u);
+    let onb = pixar_onb(normalize(uniforms.sun_direction));
+    return normalize(onb * v);
+}
+
+// https://www.jcgt.org/published/0006/01/01/paper-lowres.pdf
+fn pixar_onb(n: vec3f) -> mat3x3<f32> {
+    let s = select(- 1f, 1f, n.z >= 0f);
+    let a = - 1f / (s + n.z);
+    let b = n.x * n.y * a;
+    let u = vec3(1f + s * n.x * n.x * a, s * b, - s * n.x);
+    let v = vec3(b, s + n.y * n.y * a, - n.y);
+    return mat3x3(u, v, n);
+}
+
+// simplified approximation of preetham
+fn sky_glow(dir: vec3f, sun_dir: vec3f) -> vec3f {
+    let view_dir = normalize(dir);
+    let sun_dir_n = normalize(sun_dir);
+    let cos_theta = dot(view_dir, sun_dir_n);
+    if sun_dir_n.z <= 0.0 {
+        return vec3f(0.0);
+    }
+    // sun altitude still helps modulate overall warmth
+    let sun_altitude = clamp(sun_dir_n.z, 0.0, 1.0);
+    // more saturated warm tone for horizon, and deeper blue for zenith
+    let horizon_color = vec3f(1.1, 0.4, 0.2);
+    // rich orange
+    let zenith_color = vec3f(0.05, 0.2, 0.6);
+    // deep blue
+    // exaggerated curve to preserve saturation
+    let sky_color = mix(horizon_color, zenith_color, pow(sun_altitude, 0.1));
+    // rayleigh-like gradient with vertical bias
+    let rayleigh = sky_color * (0.6 + 0.4 * cos_theta * cos_theta);
+    // warm sun glow with stronger color (no gray falloff)
+    let mie = vec3f(1.3, 0.6, 0.3) * pow(max(cos_theta, 0.0), 12.0) * 0.6;
+    return clamp(rayleigh + mie, vec3f(0.0), vec3f(100.0));
+}
+
+fn sun_glow(dir: vec3f, sun_dir: vec3f) -> vec3f {
+    let view_dir = normalize(dir);
+    let sun_n = normalize(sun_dir);
+    let cos_theta = dot(view_dir, sun_n);
+    // angular radius (half the angular size)
+    let angular_radius = 0.5 * uniforms.sun_angular_size;
+    let inner = cos(angular_radius * 0.9);
+    let outer = cos(angular_radius * 1.1);
+    let sun_disk = smoothstep(outer, inner, max(cos_theta, 0.0));
+    // compute sun altitude (z-up): 0 = horizon, 1 = overhead
+    let sun_altitude = clamp(sun_n.z, 0.0, 1.0);
+    return uniforms.sun_radiance * /*tint*/
+    sun_disk;
+}<
\ No newline at end of file
ADD · src/shaders/types.d.ts +4 -0
--- a/src/shaders/types.d.ts
+++ b/src/shaders/types.d.ts
@@ -0,0 +1,4 @@
+declare module "*.wgsl" {
+  const shader: "string";
+  export default shader;
+}
ADD · src/shaders/utils.wgsl +37 -0
--- a/src/shaders/utils.wgsl
+++ b/src/shaders/utils.wgsl
@@ -0,0 +1,37 @@
+fn is_nan(x: f32) -> bool {
+    return x != x;
+}
+
+fn is_inf(x: f32) -> bool {
+    return abs(x) > 1e20;
+}
+
+fn is_nan3(v: vec3<f32>) -> bool {
+    return is_nan(v.x) || is_nan(v.y) || is_nan(v.z);
+}
+
+fn is_inf3(v: vec3<f32>) -> bool {
+    return abs(v.x) > 1e20 || abs(v.y) > 1e20 || abs(v.z) > 1e20;
+}
+
+fn luminance(color: vec3<f32>) -> f32 {
+    return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
+}
+
+fn float_to_int(f: f32) -> i32 {
+    return bitcast<i32>(f);
+}
+
+fn int_to_float(i: i32) -> f32 {
+    return bitcast<f32>(i);
+}
+
+fn srgb_to_linear(rgb: vec3<f32>) -> vec3<f32> {
+    return select(pow((rgb + 0.055) * (1.0 / 1.055), vec3<f32>(2.4)), rgb * (1.0 / 12.92), rgb <= vec3<f32>(0.04045));
+}
+
+fn clamp_hdr(color: vec3<f32>, max_luminance: f32) -> vec3<f32> {
+    let lum = dot(color, vec3f(0.2126, 0.7152, 0.0722));
+    return color * min(1.0, max_luminance / max(lum, 1e-6));
+}
+
ADD · src/shaders/viewport.wgsl +68 -0
--- a/src/shaders/viewport.wgsl
+++ b/src/shaders/viewport.wgsl
@@ -0,0 +1,68 @@
+#include utils.wgsl
+
+@group(0) @binding(0) var output_buffer: texture_2d<f32>;
+
+struct Interpolator {
+    @builtin(position) position: vec4<f32>,
+    @location(0) tex_coord: vec2<f32>,
+}
+
+
+fn uncharted2_tonemap_base(x : vec3f) -> vec3f
+{
+	let a = 0.15;
+    let b = 0.50;
+    let c = 0.10;
+    let d = 0.20;
+    let e = 0.02;
+    let f = 0.30;
+	return ((x*(a*x+c*b)+d*e)/(x*(a*x+b)+d*f))-e/f;
+}
+
+fn uncharted2_tonemap(color: vec3f) -> vec3f {
+    let exposure = 2.0; // adjustable
+    let white_point = uncharted2_tonemap_base(vec3f(11.2));
+    let mapped = uncharted2_tonemap_base(color * exposure);
+    return mapped / white_point;
+}
+
+fn gamma_correct(color: vec3<f32>) -> vec3<f32> {
+    return pow(color, vec3<f32>(1.0 / 2.2));
+}
+
+// fn reinhard_tonemap(color: vec3<f32>) -> vec3<f32> {
+//     return color / (color + vec3f(1.0));
+// }
+
+
+@vertex
+fn vert_main(@builtin(vertex_index) vertex_index: u32) -> Interpolator {
+    var positions = array<vec2<f32>, 6>(
+        vec2<f32>(1.0, 1.0),
+        vec2<f32>(1.0, -1.0),
+        vec2<f32>(-1.0, -1.0),
+        vec2<f32>(1.0, 1.0),
+        vec2<f32>(-1.0, -1.0),
+        vec2<f32>(-1.0, 1.0)
+    );
+    var tex_coords = array<vec2<f32>, 6>(
+        vec2<f32>(1.0, 1.0),
+        vec2<f32>(1.0, 0.0),
+        vec2<f32>(0.0, 0.0),
+        vec2<f32>(1.0, 1.0),
+        vec2<f32>(0.0, 0.0),
+        vec2<f32>(0.0, 1.0)
+    );
+    var output: Interpolator;
+    output.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
+    output.tex_coord = tex_coords[vertex_index];
+    return output;
+}
+
+@fragment
+fn frag_main(@location(0) tex_coord: vec2<f32>) -> @location(0) vec4<f32> {
+    let dims = textureDimensions(output_buffer);
+    let uv = vec2<u32>(tex_coord * vec2<f32>(dims));
+    let linear_color = textureLoad(output_buffer, uv, 0).rgb;
+    return vec4f(gamma_correct(uncharted2_tonemap(linear_color)), 1.0);
+}
ADD · src/vite-env.d.ts +2 -0
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+/// <reference types="vite/client" />
+/// <reference types="@webgpu/types" /><
\ No newline at end of file
ADD · tsconfig.json +25 -0
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+    "types": ["@webgpu/types", "vite-plugin-glsl/ext"]
+  },
+  "include": ["src"]
+}
ADD · vite.config.ts +15 -0
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vite";
+import glsl from 'vite-plugin-glsl';
+
+
+export default defineConfig({
+  plugins: [glsl()],
+  build: {
+    target: "es2022",
+    modulePreload: true,
+    outDir: "dist",
+  },
+  esbuild: {
+    target: "es2022",
+  },
+});