pathtracer

webgpu-based path tracer

chore: squash and init public branch

Arjun Choudhary contact@arjunchoudhary.com

commit: c17be57 parent: c17be57
A · .gitignore +25, -0
 1@@ -0,0 +1,25 @@
 2+# Logs
 3+logs
 4+*.log
 5+npm-debug.log*
 6+yarn-debug.log*
 7+yarn-error.log*
 8+pnpm-debug.log*
 9+lerna-debug.log*
10+
11+node_modules
12+dist
13+dist-ssr
14+*.local
15+
16+# Editor directories and files
17+.vscode/*
18+!.vscode/extensions.json
19+.idea
20+.DS_Store
21+*.suo
22+*.ntvs*
23+*.njsproj
24+*.sln
25+*.sw?
26+.vs
A · README.md +45, -0
 1@@ -0,0 +1,45 @@
 2+## Overview
 3+
 4+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.
 5+
 6+This is a GPU "software" path tracer, since there is no HW-accel using RT cores, it contains manual scene intersections and hit tests.
 7+
 8+- Single megakernel compute pass, that blits the output to a viewport quad texture
 9+- All primitives are extracted from the model -> a SAH split BVH is constructed from that on the host
10+- 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
11+- 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.
12+- There is no support for transmission, IOR, or alpha textures
13+- Balanced heuristic based multiple importance sampling; two NEE rays. One for direct emissives and another for the sun
14+- Uses stratified animated blue noise for all the screen space level sampling and faster resolves.
15+- 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.
16+- 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.
17+- All shader internals are full fat linear color space, that is then tonemapped and clamped to SRGB upon blitting.
18+
19+## Local setup
20+
21+```
22+pnpm install
23+pnpm run dev
24+```
25+
26+`/public` should contain the assets. Just compose the scene manually in `main.ts` position, scale, rotate.
27+All the included models are licensed under Creative Commons Attribtution 4.0.
28+
29+## To-do
30+
31+- Direct lighting NEE from an HDR equirectangular map
32+
33+### Resources
34+
35+- [WebGPU specification](https://www.w3.org/TR/webgpu/)
36+- [WGSL Specification](https://www.w3.org/TR/WGSL/)
37+- **Jacob Bikker:** [Invaluable BVH resource](https://jacco.ompf2.com/about-me/)
38+- **Christoph Peters:** [Math for importance sampling](https://momentsingraphics.de/)
39+- **Jakub Boksansky:** [Crash Course in BRDF Implementation](https://boksajak.github.io/files/CrashCourseBRDF.pdf)
40+- **Brent Burley:** [Physically Based Shading at Disney](https://media.disneyanimation.com/uploads/production/publication_asset/48/asset/s2012_pbs_disney_brdf_notes_v3.pdf)
41+- **Möller–Trumbore:** [Ray triangle intersection test](http://www.graphics.cornell.edu/pubs/1997/MT97.pdf)
42+- **Pixar ONB:** [Building an Orthonormal Basis, Revisited
43+  ](https://www.jcgt.org/published/0006/01/01/paper-lowres.pdf)
44+- **Uncharted 2 tonemap:** [Uncharted 2: HDR Lighting](https://www.gdcvault.com/play/1012351/Uncharted-2-HDR)
45+- **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)
46+- **Reference Books**: Ray tracing Gems 1 and 2, Physically based rendering 4.0.
A · index.css +44, -0
 1@@ -0,0 +1,44 @@
 2+html,
 3+body {
 4+  --bs-br: 0px !important;
 5+  padding: 0;
 6+  height: 100%;
 7+  margin: 0;
 8+  font-family: 'Courier New', Courier, monospace;
 9+  background-color: rgb(82, 82, 82);
10+  overflow: hidden;
11+}
12+
13+.tp-rotv, .tp-rotv * {
14+  border-radius: 0px !important;
15+}
16+
17+#render {
18+  display: block;
19+  position: absolute;
20+  top: 50%;
21+  left: 50%;
22+
23+  transform: translate(-50%, -50%);
24+  margin: 0;
25+  border-radius: 8px;
26+}
27+
28+#pane-container {
29+  position: absolute;
30+  z-index: 9;
31+  background-color:hsl(230, 7%, 17%);
32+  color: #c8cad0;
33+  border: 1px solid black;
34+  border-radius: 8px;
35+  overflow: clip;
36+  text-align: center;
37+}
38+
39+#pane-container-header {
40+  font-size: 12px;
41+  font-weight: 600;
42+  padding: 1px ;
43+  cursor: move;
44+  z-index: 10;
45+}
A · index.html +18, -0
 1@@ -0,0 +1,18 @@
 2+<!doctype html>
 3+<html lang="en">
 4+  <head>
 5+    <meta charset="UTF-8" />
 6+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
 7+    <link rel="stylesheet" type="text/css" href="index.css" /> 
 8+    <script type="module" src="src/main.ts"></script>
 9+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10+    <title>Vite + TS</title>
11+  </head>
12+  <body>
13+    <div id="pane-container">
14+      <div id="pane-container-header">Controls</div>
15+    </div>
16+    <canvas id="render" width="800" height="600"></canvas>
17+
18+</body>
19+</html>
A · package-lock.json +1262, -0
   1@@ -0,0 +1,1262 @@
   2+{
   3+  "name": "vite-project",
   4+  "version": "0.0.0",
   5+  "lockfileVersion": 3,
   6+  "requires": true,
   7+  "packages": {
   8+    "": {
   9+      "name": "vite-project",
  10+      "version": "0.0.0",
  11+      "dependencies": {
  12+        "@loaders.gl/core": "4.3.3",
  13+        "@loaders.gl/gltf": "4.3.3",
  14+        "gl-matrix": "^4.0.0-beta.2",
  15+        "tweakpane": "^4.0.5"
  16+      },
  17+      "devDependencies": {
  18+        "@tweakpane/core": "^2.0.5",
  19+        "@webgpu/types": "^0.1.53",
  20+        "typescript": "~5.6.2",
  21+        "vite": "^6.0.5",
  22+        "vite-plugin-glsl": "^1.4.0"
  23+      }
  24+    },
  25+    "node_modules/@esbuild/aix-ppc64": {
  26+      "version": "0.24.2",
  27+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
  28+      "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
  29+      "cpu": [
  30+        "ppc64"
  31+      ],
  32+      "dev": true,
  33+      "license": "MIT",
  34+      "optional": true,
  35+      "os": [
  36+        "aix"
  37+      ],
  38+      "engines": {
  39+        "node": ">=18"
  40+      }
  41+    },
  42+    "node_modules/@esbuild/android-arm": {
  43+      "version": "0.24.2",
  44+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
  45+      "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
  46+      "cpu": [
  47+        "arm"
  48+      ],
  49+      "dev": true,
  50+      "license": "MIT",
  51+      "optional": true,
  52+      "os": [
  53+        "android"
  54+      ],
  55+      "engines": {
  56+        "node": ">=18"
  57+      }
  58+    },
  59+    "node_modules/@esbuild/android-arm64": {
  60+      "version": "0.24.2",
  61+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
  62+      "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
  63+      "cpu": [
  64+        "arm64"
  65+      ],
  66+      "dev": true,
  67+      "license": "MIT",
  68+      "optional": true,
  69+      "os": [
  70+        "android"
  71+      ],
  72+      "engines": {
  73+        "node": ">=18"
  74+      }
  75+    },
  76+    "node_modules/@esbuild/android-x64": {
  77+      "version": "0.24.2",
  78+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
  79+      "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
  80+      "cpu": [
  81+        "x64"
  82+      ],
  83+      "dev": true,
  84+      "license": "MIT",
  85+      "optional": true,
  86+      "os": [
  87+        "android"
  88+      ],
  89+      "engines": {
  90+        "node": ">=18"
  91+      }
  92+    },
  93+    "node_modules/@esbuild/darwin-arm64": {
  94+      "version": "0.24.2",
  95+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
  96+      "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
  97+      "cpu": [
  98+        "arm64"
  99+      ],
 100+      "dev": true,
 101+      "license": "MIT",
 102+      "optional": true,
 103+      "os": [
 104+        "darwin"
 105+      ],
 106+      "engines": {
 107+        "node": ">=18"
 108+      }
 109+    },
 110+    "node_modules/@esbuild/darwin-x64": {
 111+      "version": "0.24.2",
 112+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
 113+      "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
 114+      "cpu": [
 115+        "x64"
 116+      ],
 117+      "dev": true,
 118+      "license": "MIT",
 119+      "optional": true,
 120+      "os": [
 121+        "darwin"
 122+      ],
 123+      "engines": {
 124+        "node": ">=18"
 125+      }
 126+    },
 127+    "node_modules/@esbuild/freebsd-arm64": {
 128+      "version": "0.24.2",
 129+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
 130+      "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
 131+      "cpu": [
 132+        "arm64"
 133+      ],
 134+      "dev": true,
 135+      "license": "MIT",
 136+      "optional": true,
 137+      "os": [
 138+        "freebsd"
 139+      ],
 140+      "engines": {
 141+        "node": ">=18"
 142+      }
 143+    },
 144+    "node_modules/@esbuild/freebsd-x64": {
 145+      "version": "0.24.2",
 146+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
 147+      "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
 148+      "cpu": [
 149+        "x64"
 150+      ],
 151+      "dev": true,
 152+      "license": "MIT",
 153+      "optional": true,
 154+      "os": [
 155+        "freebsd"
 156+      ],
 157+      "engines": {
 158+        "node": ">=18"
 159+      }
 160+    },
 161+    "node_modules/@esbuild/linux-arm": {
 162+      "version": "0.24.2",
 163+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
 164+      "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
 165+      "cpu": [
 166+        "arm"
 167+      ],
 168+      "dev": true,
 169+      "license": "MIT",
 170+      "optional": true,
 171+      "os": [
 172+        "linux"
 173+      ],
 174+      "engines": {
 175+        "node": ">=18"
 176+      }
 177+    },
 178+    "node_modules/@esbuild/linux-arm64": {
 179+      "version": "0.24.2",
 180+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
 181+      "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
 182+      "cpu": [
 183+        "arm64"
 184+      ],
 185+      "dev": true,
 186+      "license": "MIT",
 187+      "optional": true,
 188+      "os": [
 189+        "linux"
 190+      ],
 191+      "engines": {
 192+        "node": ">=18"
 193+      }
 194+    },
 195+    "node_modules/@esbuild/linux-ia32": {
 196+      "version": "0.24.2",
 197+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
 198+      "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
 199+      "cpu": [
 200+        "ia32"
 201+      ],
 202+      "dev": true,
 203+      "license": "MIT",
 204+      "optional": true,
 205+      "os": [
 206+        "linux"
 207+      ],
 208+      "engines": {
 209+        "node": ">=18"
 210+      }
 211+    },
 212+    "node_modules/@esbuild/linux-loong64": {
 213+      "version": "0.24.2",
 214+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
 215+      "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
 216+      "cpu": [
 217+        "loong64"
 218+      ],
 219+      "dev": true,
 220+      "license": "MIT",
 221+      "optional": true,
 222+      "os": [
 223+        "linux"
 224+      ],
 225+      "engines": {
 226+        "node": ">=18"
 227+      }
 228+    },
 229+    "node_modules/@esbuild/linux-mips64el": {
 230+      "version": "0.24.2",
 231+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
 232+      "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
 233+      "cpu": [
 234+        "mips64el"
 235+      ],
 236+      "dev": true,
 237+      "license": "MIT",
 238+      "optional": true,
 239+      "os": [
 240+        "linux"
 241+      ],
 242+      "engines": {
 243+        "node": ">=18"
 244+      }
 245+    },
 246+    "node_modules/@esbuild/linux-ppc64": {
 247+      "version": "0.24.2",
 248+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
 249+      "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
 250+      "cpu": [
 251+        "ppc64"
 252+      ],
 253+      "dev": true,
 254+      "license": "MIT",
 255+      "optional": true,
 256+      "os": [
 257+        "linux"
 258+      ],
 259+      "engines": {
 260+        "node": ">=18"
 261+      }
 262+    },
 263+    "node_modules/@esbuild/linux-riscv64": {
 264+      "version": "0.24.2",
 265+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
 266+      "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
 267+      "cpu": [
 268+        "riscv64"
 269+      ],
 270+      "dev": true,
 271+      "license": "MIT",
 272+      "optional": true,
 273+      "os": [
 274+        "linux"
 275+      ],
 276+      "engines": {
 277+        "node": ">=18"
 278+      }
 279+    },
 280+    "node_modules/@esbuild/linux-s390x": {
 281+      "version": "0.24.2",
 282+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
 283+      "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
 284+      "cpu": [
 285+        "s390x"
 286+      ],
 287+      "dev": true,
 288+      "license": "MIT",
 289+      "optional": true,
 290+      "os": [
 291+        "linux"
 292+      ],
 293+      "engines": {
 294+        "node": ">=18"
 295+      }
 296+    },
 297+    "node_modules/@esbuild/linux-x64": {
 298+      "version": "0.24.2",
 299+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
 300+      "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
 301+      "cpu": [
 302+        "x64"
 303+      ],
 304+      "dev": true,
 305+      "license": "MIT",
 306+      "optional": true,
 307+      "os": [
 308+        "linux"
 309+      ],
 310+      "engines": {
 311+        "node": ">=18"
 312+      }
 313+    },
 314+    "node_modules/@esbuild/netbsd-arm64": {
 315+      "version": "0.24.2",
 316+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
 317+      "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
 318+      "cpu": [
 319+        "arm64"
 320+      ],
 321+      "dev": true,
 322+      "license": "MIT",
 323+      "optional": true,
 324+      "os": [
 325+        "netbsd"
 326+      ],
 327+      "engines": {
 328+        "node": ">=18"
 329+      }
 330+    },
 331+    "node_modules/@esbuild/netbsd-x64": {
 332+      "version": "0.24.2",
 333+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
 334+      "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
 335+      "cpu": [
 336+        "x64"
 337+      ],
 338+      "dev": true,
 339+      "license": "MIT",
 340+      "optional": true,
 341+      "os": [
 342+        "netbsd"
 343+      ],
 344+      "engines": {
 345+        "node": ">=18"
 346+      }
 347+    },
 348+    "node_modules/@esbuild/openbsd-arm64": {
 349+      "version": "0.24.2",
 350+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
 351+      "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
 352+      "cpu": [
 353+        "arm64"
 354+      ],
 355+      "dev": true,
 356+      "license": "MIT",
 357+      "optional": true,
 358+      "os": [
 359+        "openbsd"
 360+      ],
 361+      "engines": {
 362+        "node": ">=18"
 363+      }
 364+    },
 365+    "node_modules/@esbuild/openbsd-x64": {
 366+      "version": "0.24.2",
 367+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
 368+      "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
 369+      "cpu": [
 370+        "x64"
 371+      ],
 372+      "dev": true,
 373+      "license": "MIT",
 374+      "optional": true,
 375+      "os": [
 376+        "openbsd"
 377+      ],
 378+      "engines": {
 379+        "node": ">=18"
 380+      }
 381+    },
 382+    "node_modules/@esbuild/sunos-x64": {
 383+      "version": "0.24.2",
 384+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
 385+      "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
 386+      "cpu": [
 387+        "x64"
 388+      ],
 389+      "dev": true,
 390+      "license": "MIT",
 391+      "optional": true,
 392+      "os": [
 393+        "sunos"
 394+      ],
 395+      "engines": {
 396+        "node": ">=18"
 397+      }
 398+    },
 399+    "node_modules/@esbuild/win32-arm64": {
 400+      "version": "0.24.2",
 401+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
 402+      "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
 403+      "cpu": [
 404+        "arm64"
 405+      ],
 406+      "dev": true,
 407+      "license": "MIT",
 408+      "optional": true,
 409+      "os": [
 410+        "win32"
 411+      ],
 412+      "engines": {
 413+        "node": ">=18"
 414+      }
 415+    },
 416+    "node_modules/@esbuild/win32-ia32": {
 417+      "version": "0.24.2",
 418+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
 419+      "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
 420+      "cpu": [
 421+        "ia32"
 422+      ],
 423+      "dev": true,
 424+      "license": "MIT",
 425+      "optional": true,
 426+      "os": [
 427+        "win32"
 428+      ],
 429+      "engines": {
 430+        "node": ">=18"
 431+      }
 432+    },
 433+    "node_modules/@esbuild/win32-x64": {
 434+      "version": "0.24.2",
 435+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
 436+      "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
 437+      "cpu": [
 438+        "x64"
 439+      ],
 440+      "dev": true,
 441+      "license": "MIT",
 442+      "optional": true,
 443+      "os": [
 444+        "win32"
 445+      ],
 446+      "engines": {
 447+        "node": ">=18"
 448+      }
 449+    },
 450+    "node_modules/@loaders.gl/core": {
 451+      "version": "4.3.3",
 452+      "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.3.tgz",
 453+      "integrity": "sha512-RaQ3uNg4ZaVqDRgvJ2CjaOjeeHdKvbKuzFFgbGnflVB9is5bu+h3EKc3Jke7NGVvLBsZ6oIXzkwHijVsMfxv8g==",
 454+      "license": "MIT",
 455+      "dependencies": {
 456+        "@loaders.gl/loader-utils": "4.3.3",
 457+        "@loaders.gl/schema": "4.3.3",
 458+        "@loaders.gl/worker-utils": "4.3.3",
 459+        "@probe.gl/log": "^4.0.2"
 460+      }
 461+    },
 462+    "node_modules/@loaders.gl/draco": {
 463+      "version": "4.3.3",
 464+      "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.3.tgz",
 465+      "integrity": "sha512-f2isxvOoH4Pm5p4mGvNN9gVigUwX84j9gdKNMV1aSo56GS1KE3GS2rXaIoy1qaIHMzkPySUTEcOTwayf0hWU7A==",
 466+      "license": "MIT",
 467+      "dependencies": {
 468+        "@loaders.gl/loader-utils": "4.3.3",
 469+        "@loaders.gl/schema": "4.3.3",
 470+        "@loaders.gl/worker-utils": "4.3.3",
 471+        "draco3d": "1.5.7"
 472+      },
 473+      "peerDependencies": {
 474+        "@loaders.gl/core": "^4.3.0"
 475+      }
 476+    },
 477+    "node_modules/@loaders.gl/gltf": {
 478+      "version": "4.3.3",
 479+      "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.3.tgz",
 480+      "integrity": "sha512-M7jQ7KIB5itctDmGYuT9gndmjNwk1lwQ+BV4l5CoFp38e4xJESPglj2Kj8csWdm3WJhrxIYEP4GpjXK02n8DSQ==",
 481+      "license": "MIT",
 482+      "dependencies": {
 483+        "@loaders.gl/draco": "4.3.3",
 484+        "@loaders.gl/images": "4.3.3",
 485+        "@loaders.gl/loader-utils": "4.3.3",
 486+        "@loaders.gl/schema": "4.3.3",
 487+        "@loaders.gl/textures": "4.3.3",
 488+        "@math.gl/core": "^4.1.0"
 489+      },
 490+      "peerDependencies": {
 491+        "@loaders.gl/core": "^4.3.0"
 492+      }
 493+    },
 494+    "node_modules/@loaders.gl/images": {
 495+      "version": "4.3.3",
 496+      "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.3.tgz",
 497+      "integrity": "sha512-s4InjIXqEu0T7anZLj4OBUuDBt2BNnAD0GLzSexSkBfQZfpXY0XJNl4mMf5nUKb5NDfXhIKIqv8y324US+I28A==",
 498+      "license": "MIT",
 499+      "dependencies": {
 500+        "@loaders.gl/loader-utils": "4.3.3"
 501+      },
 502+      "peerDependencies": {
 503+        "@loaders.gl/core": "^4.3.0"
 504+      }
 505+    },
 506+    "node_modules/@loaders.gl/loader-utils": {
 507+      "version": "4.3.3",
 508+      "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.3.tgz",
 509+      "integrity": "sha512-8erUIwWLiIsZX36fFa/seZsfTsWlLk72Sibh/YZJrPAefuVucV4mGGzMBZ96LE2BUfJhadn250eio/59TUFbNw==",
 510+      "license": "MIT",
 511+      "dependencies": {
 512+        "@loaders.gl/schema": "4.3.3",
 513+        "@loaders.gl/worker-utils": "4.3.3",
 514+        "@probe.gl/log": "^4.0.2",
 515+        "@probe.gl/stats": "^4.0.2"
 516+      },
 517+      "peerDependencies": {
 518+        "@loaders.gl/core": "^4.3.0"
 519+      }
 520+    },
 521+    "node_modules/@loaders.gl/schema": {
 522+      "version": "4.3.3",
 523+      "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.3.tgz",
 524+      "integrity": "sha512-zacc9/8je+VbuC6N/QRfiTjRd+BuxsYlddLX1u5/X/cg9s36WZZBlU1oNKUgTYe8eO6+qLyYx77yi+9JbbEehw==",
 525+      "license": "MIT",
 526+      "dependencies": {
 527+        "@types/geojson": "^7946.0.7"
 528+      },
 529+      "peerDependencies": {
 530+        "@loaders.gl/core": "^4.3.0"
 531+      }
 532+    },
 533+    "node_modules/@loaders.gl/textures": {
 534+      "version": "4.3.3",
 535+      "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.3.tgz",
 536+      "integrity": "sha512-qIo4ehzZnXFpPKl1BGQG4G3cAhBSczO9mr+H/bT7qFwtSirWVlqsvMlx1Q4VpmouDu+tudwwOlq7B3yqU5P5yQ==",
 537+      "license": "MIT",
 538+      "dependencies": {
 539+        "@loaders.gl/images": "4.3.3",
 540+        "@loaders.gl/loader-utils": "4.3.3",
 541+        "@loaders.gl/schema": "4.3.3",
 542+        "@loaders.gl/worker-utils": "4.3.3",
 543+        "@math.gl/types": "^4.1.0",
 544+        "ktx-parse": "^0.7.0",
 545+        "texture-compressor": "^1.0.2"
 546+      },
 547+      "peerDependencies": {
 548+        "@loaders.gl/core": "^4.3.0"
 549+      }
 550+    },
 551+    "node_modules/@loaders.gl/worker-utils": {
 552+      "version": "4.3.3",
 553+      "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.3.tgz",
 554+      "integrity": "sha512-eg45Ux6xqsAfqPUqJkhmbFZh9qfmYuPfA+34VcLtfeXIwAngeP6o4SrTmm9LWLGUKiSh47anCEV1p7borDgvGQ==",
 555+      "license": "MIT",
 556+      "peerDependencies": {
 557+        "@loaders.gl/core": "^4.3.0"
 558+      }
 559+    },
 560+    "node_modules/@math.gl/core": {
 561+      "version": "4.1.0",
 562+      "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
 563+      "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==",
 564+      "license": "MIT",
 565+      "dependencies": {
 566+        "@math.gl/types": "4.1.0"
 567+      }
 568+    },
 569+    "node_modules/@math.gl/types": {
 570+      "version": "4.1.0",
 571+      "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz",
 572+      "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==",
 573+      "license": "MIT"
 574+    },
 575+    "node_modules/@probe.gl/env": {
 576+      "version": "4.1.0",
 577+      "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz",
 578+      "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==",
 579+      "license": "MIT"
 580+    },
 581+    "node_modules/@probe.gl/log": {
 582+      "version": "4.1.0",
 583+      "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz",
 584+      "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==",
 585+      "license": "MIT",
 586+      "dependencies": {
 587+        "@probe.gl/env": "4.1.0"
 588+      }
 589+    },
 590+    "node_modules/@probe.gl/stats": {
 591+      "version": "4.1.0",
 592+      "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz",
 593+      "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
 594+      "license": "MIT"
 595+    },
 596+    "node_modules/@rollup/pluginutils": {
 597+      "version": "5.1.4",
 598+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
 599+      "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
 600+      "dev": true,
 601+      "license": "MIT",
 602+      "dependencies": {
 603+        "@types/estree": "^1.0.0",
 604+        "estree-walker": "^2.0.2",
 605+        "picomatch": "^4.0.2"
 606+      },
 607+      "engines": {
 608+        "node": ">=14.0.0"
 609+      },
 610+      "peerDependencies": {
 611+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
 612+      },
 613+      "peerDependenciesMeta": {
 614+        "rollup": {
 615+          "optional": true
 616+        }
 617+      }
 618+    },
 619+    "node_modules/@rollup/rollup-android-arm-eabi": {
 620+      "version": "4.31.0",
 621+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.31.0.tgz",
 622+      "integrity": "sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==",
 623+      "cpu": [
 624+        "arm"
 625+      ],
 626+      "dev": true,
 627+      "license": "MIT",
 628+      "optional": true,
 629+      "os": [
 630+        "android"
 631+      ]
 632+    },
 633+    "node_modules/@rollup/rollup-android-arm64": {
 634+      "version": "4.31.0",
 635+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.31.0.tgz",
 636+      "integrity": "sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==",
 637+      "cpu": [
 638+        "arm64"
 639+      ],
 640+      "dev": true,
 641+      "license": "MIT",
 642+      "optional": true,
 643+      "os": [
 644+        "android"
 645+      ]
 646+    },
 647+    "node_modules/@rollup/rollup-darwin-arm64": {
 648+      "version": "4.31.0",
 649+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.31.0.tgz",
 650+      "integrity": "sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==",
 651+      "cpu": [
 652+        "arm64"
 653+      ],
 654+      "dev": true,
 655+      "license": "MIT",
 656+      "optional": true,
 657+      "os": [
 658+        "darwin"
 659+      ]
 660+    },
 661+    "node_modules/@rollup/rollup-darwin-x64": {
 662+      "version": "4.31.0",
 663+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.31.0.tgz",
 664+      "integrity": "sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==",
 665+      "cpu": [
 666+        "x64"
 667+      ],
 668+      "dev": true,
 669+      "license": "MIT",
 670+      "optional": true,
 671+      "os": [
 672+        "darwin"
 673+      ]
 674+    },
 675+    "node_modules/@rollup/rollup-freebsd-arm64": {
 676+      "version": "4.31.0",
 677+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.31.0.tgz",
 678+      "integrity": "sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==",
 679+      "cpu": [
 680+        "arm64"
 681+      ],
 682+      "dev": true,
 683+      "license": "MIT",
 684+      "optional": true,
 685+      "os": [
 686+        "freebsd"
 687+      ]
 688+    },
 689+    "node_modules/@rollup/rollup-freebsd-x64": {
 690+      "version": "4.31.0",
 691+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.31.0.tgz",
 692+      "integrity": "sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==",
 693+      "cpu": [
 694+        "x64"
 695+      ],
 696+      "dev": true,
 697+      "license": "MIT",
 698+      "optional": true,
 699+      "os": [
 700+        "freebsd"
 701+      ]
 702+    },
 703+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
 704+      "version": "4.31.0",
 705+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.31.0.tgz",
 706+      "integrity": "sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==",
 707+      "cpu": [
 708+        "arm"
 709+      ],
 710+      "dev": true,
 711+      "license": "MIT",
 712+      "optional": true,
 713+      "os": [
 714+        "linux"
 715+      ]
 716+    },
 717+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
 718+      "version": "4.31.0",
 719+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.31.0.tgz",
 720+      "integrity": "sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==",
 721+      "cpu": [
 722+        "arm"
 723+      ],
 724+      "dev": true,
 725+      "license": "MIT",
 726+      "optional": true,
 727+      "os": [
 728+        "linux"
 729+      ]
 730+    },
 731+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
 732+      "version": "4.31.0",
 733+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.31.0.tgz",
 734+      "integrity": "sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==",
 735+      "cpu": [
 736+        "arm64"
 737+      ],
 738+      "dev": true,
 739+      "license": "MIT",
 740+      "optional": true,
 741+      "os": [
 742+        "linux"
 743+      ]
 744+    },
 745+    "node_modules/@rollup/rollup-linux-arm64-musl": {
 746+      "version": "4.31.0",
 747+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.31.0.tgz",
 748+      "integrity": "sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==",
 749+      "cpu": [
 750+        "arm64"
 751+      ],
 752+      "dev": true,
 753+      "license": "MIT",
 754+      "optional": true,
 755+      "os": [
 756+        "linux"
 757+      ]
 758+    },
 759+    "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
 760+      "version": "4.31.0",
 761+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.31.0.tgz",
 762+      "integrity": "sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==",
 763+      "cpu": [
 764+        "loong64"
 765+      ],
 766+      "dev": true,
 767+      "license": "MIT",
 768+      "optional": true,
 769+      "os": [
 770+        "linux"
 771+      ]
 772+    },
 773+    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
 774+      "version": "4.31.0",
 775+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.31.0.tgz",
 776+      "integrity": "sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==",
 777+      "cpu": [
 778+        "ppc64"
 779+      ],
 780+      "dev": true,
 781+      "license": "MIT",
 782+      "optional": true,
 783+      "os": [
 784+        "linux"
 785+      ]
 786+    },
 787+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
 788+      "version": "4.31.0",
 789+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.31.0.tgz",
 790+      "integrity": "sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==",
 791+      "cpu": [
 792+        "riscv64"
 793+      ],
 794+      "dev": true,
 795+      "license": "MIT",
 796+      "optional": true,
 797+      "os": [
 798+        "linux"
 799+      ]
 800+    },
 801+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
 802+      "version": "4.31.0",
 803+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.31.0.tgz",
 804+      "integrity": "sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==",
 805+      "cpu": [
 806+        "s390x"
 807+      ],
 808+      "dev": true,
 809+      "license": "MIT",
 810+      "optional": true,
 811+      "os": [
 812+        "linux"
 813+      ]
 814+    },
 815+    "node_modules/@rollup/rollup-linux-x64-gnu": {
 816+      "version": "4.31.0",
 817+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.31.0.tgz",
 818+      "integrity": "sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==",
 819+      "cpu": [
 820+        "x64"
 821+      ],
 822+      "dev": true,
 823+      "license": "MIT",
 824+      "optional": true,
 825+      "os": [
 826+        "linux"
 827+      ]
 828+    },
 829+    "node_modules/@rollup/rollup-linux-x64-musl": {
 830+      "version": "4.31.0",
 831+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.31.0.tgz",
 832+      "integrity": "sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==",
 833+      "cpu": [
 834+        "x64"
 835+      ],
 836+      "dev": true,
 837+      "license": "MIT",
 838+      "optional": true,
 839+      "os": [
 840+        "linux"
 841+      ]
 842+    },
 843+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
 844+      "version": "4.31.0",
 845+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.31.0.tgz",
 846+      "integrity": "sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==",
 847+      "cpu": [
 848+        "arm64"
 849+      ],
 850+      "dev": true,
 851+      "license": "MIT",
 852+      "optional": true,
 853+      "os": [
 854+        "win32"
 855+      ]
 856+    },
 857+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
 858+      "version": "4.31.0",
 859+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.31.0.tgz",
 860+      "integrity": "sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==",
 861+      "cpu": [
 862+        "ia32"
 863+      ],
 864+      "dev": true,
 865+      "license": "MIT",
 866+      "optional": true,
 867+      "os": [
 868+        "win32"
 869+      ]
 870+    },
 871+    "node_modules/@rollup/rollup-win32-x64-msvc": {
 872+      "version": "4.31.0",
 873+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.31.0.tgz",
 874+      "integrity": "sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==",
 875+      "cpu": [
 876+        "x64"
 877+      ],
 878+      "dev": true,
 879+      "license": "MIT",
 880+      "optional": true,
 881+      "os": [
 882+        "win32"
 883+      ]
 884+    },
 885+    "node_modules/@tweakpane/core": {
 886+      "version": "2.0.5",
 887+      "resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz",
 888+      "integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==",
 889+      "dev": true,
 890+      "license": "MIT"
 891+    },
 892+    "node_modules/@types/estree": {
 893+      "version": "1.0.6",
 894+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
 895+      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
 896+      "dev": true,
 897+      "license": "MIT"
 898+    },
 899+    "node_modules/@types/geojson": {
 900+      "version": "7946.0.16",
 901+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
 902+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
 903+      "license": "MIT"
 904+    },
 905+    "node_modules/@webgpu/types": {
 906+      "version": "0.1.53",
 907+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.53.tgz",
 908+      "integrity": "sha512-x+BLw/opaz9LiVyrMsP75nO1Rg0QfrACUYIbVSfGwY/w0DiWIPYYrpte6us//KZXinxFAOJl0+C17L1Vi2vmDw==",
 909+      "dev": true,
 910+      "license": "BSD-3-Clause"
 911+    },
 912+    "node_modules/argparse": {
 913+      "version": "1.0.10",
 914+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
 915+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
 916+      "license": "MIT",
 917+      "dependencies": {
 918+        "sprintf-js": "~1.0.2"
 919+      }
 920+    },
 921+    "node_modules/draco3d": {
 922+      "version": "1.5.7",
 923+      "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
 924+      "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
 925+      "license": "Apache-2.0"
 926+    },
 927+    "node_modules/esbuild": {
 928+      "version": "0.24.2",
 929+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
 930+      "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
 931+      "dev": true,
 932+      "hasInstallScript": true,
 933+      "license": "MIT",
 934+      "bin": {
 935+        "esbuild": "bin/esbuild"
 936+      },
 937+      "engines": {
 938+        "node": ">=18"
 939+      },
 940+      "optionalDependencies": {
 941+        "@esbuild/aix-ppc64": "0.24.2",
 942+        "@esbuild/android-arm": "0.24.2",
 943+        "@esbuild/android-arm64": "0.24.2",
 944+        "@esbuild/android-x64": "0.24.2",
 945+        "@esbuild/darwin-arm64": "0.24.2",
 946+        "@esbuild/darwin-x64": "0.24.2",
 947+        "@esbuild/freebsd-arm64": "0.24.2",
 948+        "@esbuild/freebsd-x64": "0.24.2",
 949+        "@esbuild/linux-arm": "0.24.2",
 950+        "@esbuild/linux-arm64": "0.24.2",
 951+        "@esbuild/linux-ia32": "0.24.2",
 952+        "@esbuild/linux-loong64": "0.24.2",
 953+        "@esbuild/linux-mips64el": "0.24.2",
 954+        "@esbuild/linux-ppc64": "0.24.2",
 955+        "@esbuild/linux-riscv64": "0.24.2",
 956+        "@esbuild/linux-s390x": "0.24.2",
 957+        "@esbuild/linux-x64": "0.24.2",
 958+        "@esbuild/netbsd-arm64": "0.24.2",
 959+        "@esbuild/netbsd-x64": "0.24.2",
 960+        "@esbuild/openbsd-arm64": "0.24.2",
 961+        "@esbuild/openbsd-x64": "0.24.2",
 962+        "@esbuild/sunos-x64": "0.24.2",
 963+        "@esbuild/win32-arm64": "0.24.2",
 964+        "@esbuild/win32-ia32": "0.24.2",
 965+        "@esbuild/win32-x64": "0.24.2"
 966+      }
 967+    },
 968+    "node_modules/estree-walker": {
 969+      "version": "2.0.2",
 970+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
 971+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
 972+      "dev": true,
 973+      "license": "MIT"
 974+    },
 975+    "node_modules/fsevents": {
 976+      "version": "2.3.3",
 977+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 978+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
 979+      "dev": true,
 980+      "hasInstallScript": true,
 981+      "license": "MIT",
 982+      "optional": true,
 983+      "os": [
 984+        "darwin"
 985+      ],
 986+      "engines": {
 987+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
 988+      }
 989+    },
 990+    "node_modules/gl-matrix": {
 991+      "version": "4.0.0-beta.2",
 992+      "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-4.0.0-beta.2.tgz",
 993+      "integrity": "sha512-OF6IkQpMkF8p2CZF9EtzYZPlPaW3M41KMsgZGlTKmMv/nWaP6GMJi9V5tI+oPn8FG0io85Q5ZtKpCXP4u6YmDA==",
 994+      "license": "MIT"
 995+    },
 996+    "node_modules/image-size": {
 997+      "version": "0.7.5",
 998+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz",
 999+      "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==",
1000+      "license": "MIT",
1001+      "bin": {
1002+        "image-size": "bin/image-size.js"
1003+      },
1004+      "engines": {
1005+        "node": ">=6.9.0"
1006+      }
1007+    },
1008+    "node_modules/ktx-parse": {
1009+      "version": "0.7.1",
1010+      "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz",
1011+      "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==",
1012+      "license": "MIT"
1013+    },
1014+    "node_modules/nanoid": {
1015+      "version": "3.3.8",
1016+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
1017+      "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
1018+      "dev": true,
1019+      "funding": [
1020+        {
1021+          "type": "github",
1022+          "url": "https://github.com/sponsors/ai"
1023+        }
1024+      ],
1025+      "license": "MIT",
1026+      "bin": {
1027+        "nanoid": "bin/nanoid.cjs"
1028+      },
1029+      "engines": {
1030+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1031+      }
1032+    },
1033+    "node_modules/picocolors": {
1034+      "version": "1.1.1",
1035+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1036+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1037+      "dev": true,
1038+      "license": "ISC"
1039+    },
1040+    "node_modules/picomatch": {
1041+      "version": "4.0.2",
1042+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
1043+      "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
1044+      "dev": true,
1045+      "license": "MIT",
1046+      "engines": {
1047+        "node": ">=12"
1048+      },
1049+      "funding": {
1050+        "url": "https://github.com/sponsors/jonschlinkert"
1051+      }
1052+    },
1053+    "node_modules/postcss": {
1054+      "version": "8.5.1",
1055+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
1056+      "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
1057+      "dev": true,
1058+      "funding": [
1059+        {
1060+          "type": "opencollective",
1061+          "url": "https://opencollective.com/postcss/"
1062+        },
1063+        {
1064+          "type": "tidelift",
1065+          "url": "https://tidelift.com/funding/github/npm/postcss"
1066+        },
1067+        {
1068+          "type": "github",
1069+          "url": "https://github.com/sponsors/ai"
1070+        }
1071+      ],
1072+      "license": "MIT",
1073+      "dependencies": {
1074+        "nanoid": "^3.3.8",
1075+        "picocolors": "^1.1.1",
1076+        "source-map-js": "^1.2.1"
1077+      },
1078+      "engines": {
1079+        "node": "^10 || ^12 || >=14"
1080+      }
1081+    },
1082+    "node_modules/rollup": {
1083+      "version": "4.31.0",
1084+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz",
1085+      "integrity": "sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==",
1086+      "dev": true,
1087+      "license": "MIT",
1088+      "dependencies": {
1089+        "@types/estree": "1.0.6"
1090+      },
1091+      "bin": {
1092+        "rollup": "dist/bin/rollup"
1093+      },
1094+      "engines": {
1095+        "node": ">=18.0.0",
1096+        "npm": ">=8.0.0"
1097+      },
1098+      "optionalDependencies": {
1099+        "@rollup/rollup-android-arm-eabi": "4.31.0",
1100+        "@rollup/rollup-android-arm64": "4.31.0",
1101+        "@rollup/rollup-darwin-arm64": "4.31.0",
1102+        "@rollup/rollup-darwin-x64": "4.31.0",
1103+        "@rollup/rollup-freebsd-arm64": "4.31.0",
1104+        "@rollup/rollup-freebsd-x64": "4.31.0",
1105+        "@rollup/rollup-linux-arm-gnueabihf": "4.31.0",
1106+        "@rollup/rollup-linux-arm-musleabihf": "4.31.0",
1107+        "@rollup/rollup-linux-arm64-gnu": "4.31.0",
1108+        "@rollup/rollup-linux-arm64-musl": "4.31.0",
1109+        "@rollup/rollup-linux-loongarch64-gnu": "4.31.0",
1110+        "@rollup/rollup-linux-powerpc64le-gnu": "4.31.0",
1111+        "@rollup/rollup-linux-riscv64-gnu": "4.31.0",
1112+        "@rollup/rollup-linux-s390x-gnu": "4.31.0",
1113+        "@rollup/rollup-linux-x64-gnu": "4.31.0",
1114+        "@rollup/rollup-linux-x64-musl": "4.31.0",
1115+        "@rollup/rollup-win32-arm64-msvc": "4.31.0",
1116+        "@rollup/rollup-win32-ia32-msvc": "4.31.0",
1117+        "@rollup/rollup-win32-x64-msvc": "4.31.0",
1118+        "fsevents": "~2.3.2"
1119+      }
1120+    },
1121+    "node_modules/source-map-js": {
1122+      "version": "1.2.1",
1123+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1124+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1125+      "dev": true,
1126+      "license": "BSD-3-Clause",
1127+      "engines": {
1128+        "node": ">=0.10.0"
1129+      }
1130+    },
1131+    "node_modules/sprintf-js": {
1132+      "version": "1.0.3",
1133+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
1134+      "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
1135+      "license": "BSD-3-Clause"
1136+    },
1137+    "node_modules/texture-compressor": {
1138+      "version": "1.0.2",
1139+      "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
1140+      "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==",
1141+      "license": "MIT",
1142+      "dependencies": {
1143+        "argparse": "^1.0.10",
1144+        "image-size": "^0.7.4"
1145+      },
1146+      "bin": {
1147+        "texture-compressor": "bin/texture-compressor.js"
1148+      }
1149+    },
1150+    "node_modules/tweakpane": {
1151+      "version": "4.0.5",
1152+      "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz",
1153+      "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==",
1154+      "license": "MIT",
1155+      "funding": {
1156+        "url": "https://github.com/sponsors/cocopon"
1157+      }
1158+    },
1159+    "node_modules/typescript": {
1160+      "version": "5.6.3",
1161+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
1162+      "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
1163+      "dev": true,
1164+      "license": "Apache-2.0",
1165+      "bin": {
1166+        "tsc": "bin/tsc",
1167+        "tsserver": "bin/tsserver"
1168+      },
1169+      "engines": {
1170+        "node": ">=14.17"
1171+      }
1172+    },
1173+    "node_modules/vite": {
1174+      "version": "6.0.9",
1175+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.9.tgz",
1176+      "integrity": "sha512-MSgUxHcaXLtnBPktkbUSoQUANApKYuxZ6DrbVENlIorbhL2dZydTLaZ01tjUoE3szeFzlFk9ANOKk0xurh4MKA==",
1177+      "dev": true,
1178+      "license": "MIT",
1179+      "dependencies": {
1180+        "esbuild": "^0.24.2",
1181+        "postcss": "^8.4.49",
1182+        "rollup": "^4.23.0"
1183+      },
1184+      "bin": {
1185+        "vite": "bin/vite.js"
1186+      },
1187+      "engines": {
1188+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1189+      },
1190+      "funding": {
1191+        "url": "https://github.com/vitejs/vite?sponsor=1"
1192+      },
1193+      "optionalDependencies": {
1194+        "fsevents": "~2.3.3"
1195+      },
1196+      "peerDependencies": {
1197+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1198+        "jiti": ">=1.21.0",
1199+        "less": "*",
1200+        "lightningcss": "^1.21.0",
1201+        "sass": "*",
1202+        "sass-embedded": "*",
1203+        "stylus": "*",
1204+        "sugarss": "*",
1205+        "terser": "^5.16.0",
1206+        "tsx": "^4.8.1",
1207+        "yaml": "^2.4.2"
1208+      },
1209+      "peerDependenciesMeta": {
1210+        "@types/node": {
1211+          "optional": true
1212+        },
1213+        "jiti": {
1214+          "optional": true
1215+        },
1216+        "less": {
1217+          "optional": true
1218+        },
1219+        "lightningcss": {
1220+          "optional": true
1221+        },
1222+        "sass": {
1223+          "optional": true
1224+        },
1225+        "sass-embedded": {
1226+          "optional": true
1227+        },
1228+        "stylus": {
1229+          "optional": true
1230+        },
1231+        "sugarss": {
1232+          "optional": true
1233+        },
1234+        "terser": {
1235+          "optional": true
1236+        },
1237+        "tsx": {
1238+          "optional": true
1239+        },
1240+        "yaml": {
1241+          "optional": true
1242+        }
1243+      }
1244+    },
1245+    "node_modules/vite-plugin-glsl": {
1246+      "version": "1.4.0",
1247+      "resolved": "https://registry.npmjs.org/vite-plugin-glsl/-/vite-plugin-glsl-1.4.0.tgz",
1248+      "integrity": "sha512-mjT4AaU4qRmlpawgd0M2Qz72tvK4WF0ii2p0WbVRpr7ga6+cRScJUT3oIMv5coT8u/lqUe9u9T5+0zJLZ1uhug==",
1249+      "dev": true,
1250+      "license": "MIT",
1251+      "dependencies": {
1252+        "@rollup/pluginutils": "^5.1.4"
1253+      },
1254+      "engines": {
1255+        "node": ">= 20.17.0",
1256+        "npm": ">= 10.8.3"
1257+      },
1258+      "peerDependencies": {
1259+        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
1260+      }
1261+    }
1262+  }
1263+}
A · package.json +27, -0
 1@@ -0,0 +1,27 @@
 2+{
 3+  "name": "vite-project",
 4+  "private": true,
 5+  "version": "0.0.0",
 6+  "type": "module",
 7+  "scripts": {
 8+    "dev": "vite",
 9+    "build": "tsc && vite build",
10+    "preview": "vite preview"
11+  },
12+  "dependencies": {
13+    "@loaders.gl/core": "4.3.3",
14+    "@loaders.gl/gltf": "4.3.3",
15+    "gl-matrix": "^4.0.0-beta.2",
16+    "tweakpane": "^4.0.5"
17+  },
18+  "devDependencies": {
19+    "@tweakpane/core": "^2.0.5",
20+    "@webgpu/types": "^0.1.53",
21+    "typescript": "~5.6.2",
22+    "vite": "^6.0.5",
23+    "vite-plugin-glsl": "^1.4.0"
24+  },
25+  "unusedDependencies": {
26+    "hdr.js": "^0.2.0"
27+  } 
28+}
A · public/Duck.gltf +219, -0
  1@@ -0,0 +1,219 @@
  2+{
  3+    "asset": {
  4+        "generator": "COLLADA2GLTF",
  5+        "version": "2.0"
  6+    },
  7+    "scene": 0,
  8+    "scenes": [
  9+        {
 10+            "nodes": [
 11+                0
 12+            ]
 13+        }
 14+    ],
 15+    "nodes": [
 16+        {
 17+            "children": [
 18+                2,
 19+                1
 20+            ],
 21+            "matrix": [
 22+                0.009999999776482582,
 23+                0.0,
 24+                0.0,
 25+                0.0,
 26+                0.0,
 27+                0.009999999776482582,
 28+                0.0,
 29+                0.0,
 30+                0.0,
 31+                0.0,
 32+                0.009999999776482582,
 33+                0.0,
 34+                0.0,
 35+                0.0,
 36+                0.0,
 37+                1.0
 38+            ]
 39+        },
 40+        {
 41+            "matrix": [
 42+                -0.7289686799049377,
 43+                0.0,
 44+                -0.6845470666885376,
 45+                0.0,
 46+                -0.4252049028873444,
 47+                0.7836934328079224,
 48+                0.4527972936630249,
 49+                0.0,
 50+                0.5364750623703003,
 51+                0.6211478114128113,
 52+                -0.571287989616394,
 53+                0.0,
 54+                400.1130065917969,
 55+                463.2640075683594,
 56+                -431.0780334472656,
 57+                1.0
 58+            ],
 59+            "camera": 0
 60+        },
 61+        {
 62+            "mesh": 0
 63+        }
 64+    ],
 65+    "cameras": [
 66+        {
 67+            "perspective": {
 68+                "aspectRatio": 1.5,
 69+                "yfov": 0.6605925559997559,
 70+                "zfar": 10000.0,
 71+                "znear": 1.0
 72+            },
 73+            "type": "perspective"
 74+        }
 75+    ],
 76+    "meshes": [
 77+        {
 78+            "primitives": [
 79+                {
 80+                    "attributes": {
 81+                        "NORMAL": 1,
 82+                        "POSITION": 2,
 83+                        "TEXCOORD_0": 3
 84+                    },
 85+                    "indices": 0,
 86+                    "mode": 4,
 87+                    "material": 0
 88+                }
 89+            ],
 90+            "name": "LOD3spShape"
 91+        }
 92+    ],
 93+    "accessors": [
 94+        {
 95+            "bufferView": 0,
 96+            "byteOffset": 0,
 97+            "componentType": 5123,
 98+            "count": 12636,
 99+            "max": [
100+                2398
101+            ],
102+            "min": [
103+                0
104+            ],
105+            "type": "SCALAR"
106+        },
107+        {
108+            "bufferView": 1,
109+            "byteOffset": 0,
110+            "componentType": 5126,
111+            "count": 2399,
112+            "max": [
113+                0.9995989799499512,
114+                0.999580979347229,
115+                0.9984359741210938
116+            ],
117+            "min": [
118+                -0.9990839958190918,
119+                -1.0,
120+                -0.9998319745063782
121+            ],
122+            "type": "VEC3"
123+        },
124+        {
125+            "bufferView": 1,
126+            "byteOffset": 28788,
127+            "componentType": 5126,
128+            "count": 2399,
129+            "max": [
130+                96.17990112304688,
131+                163.97000122070313,
132+                53.92519760131836
133+            ],
134+            "min": [
135+                -69.29850006103516,
136+                9.929369926452637,
137+                -61.32819747924805
138+            ],
139+            "type": "VEC3"
140+        },
141+        {
142+            "bufferView": 2,
143+            "byteOffset": 0,
144+            "componentType": 5126,
145+            "count": 2399,
146+            "max": [
147+                0.9833459854125976,
148+                0.9800369739532472
149+            ],
150+            "min": [
151+                0.026409000158309938,
152+                0.01996302604675293
153+            ],
154+            "type": "VEC2"
155+        }
156+    ],
157+    "materials": [
158+        {
159+            "pbrMetallicRoughness": {
160+                "baseColorTexture": {
161+                    "index": 0
162+                },
163+                "metallicFactor": 0.0
164+            },
165+            "emissiveFactor": [
166+                0.0,
167+                0.0,
168+                0.0
169+            ],
170+            "name": "blinn3-fx"
171+        }
172+    ],
173+    "textures": [
174+        {
175+            "sampler": 0,
176+            "source": 0
177+        }
178+    ],
179+    "images": [
180+        {
181+            "uri": "DuckCM.png"
182+        }
183+    ],
184+    "samplers": [
185+        {
186+            "magFilter": 9729,
187+            "minFilter": 9986,
188+            "wrapS": 10497,
189+            "wrapT": 10497
190+        }
191+    ],
192+    "bufferViews": [
193+        {
194+            "buffer": 0,
195+            "byteOffset": 76768,
196+            "byteLength": 25272,
197+            "target": 34963
198+        },
199+        {
200+            "buffer": 0,
201+            "byteOffset": 0,
202+            "byteLength": 57576,
203+            "byteStride": 12,
204+            "target": 34962
205+        },
206+        {
207+            "buffer": 0,
208+            "byteOffset": 57576,
209+            "byteLength": 19192,
210+            "byteStride": 8,
211+            "target": 34962
212+        }
213+    ],
214+    "buffers": [
215+        {
216+            "byteLength": 102040,
217+            "uri": "Duck0.bin"
218+        }
219+    ]
220+}
A · public/Duck0.bin +0, -0
A · public/DuckCM.png +0, -0
A · public/EnvironmentTest.gltf +328, -0
  1@@ -0,0 +1,328 @@
  2+{
  3+    "asset": {
  4+        "copyright": "2018 (c) Adobe Systems Inc.",
  5+        "generator": "Adobe Dimension - b417c10282aa66313155856d4a54e84f3f388647",
  6+        "version": "2.0"
  7+    },
  8+    "accessors": [
  9+        {
 10+            "bufferView": 0,
 11+            "componentType": 5126,
 12+            "count": 4598,
 13+            "type": "VEC3",
 14+            "max": [
 15+                10.647041320800782,
 16+                1.6470409631729127,
 17+                0.6470409631729126
 18+            ],
 19+            "min": [
 20+                -10.647041320800782,
 21+                0.3529590368270874,
 22+                -0.6470409631729126
 23+            ]
 24+        },
 25+        {
 26+            "bufferView": 1,
 27+            "componentType": 5126,
 28+            "count": 4598,
 29+            "type": "VEC3"
 30+        },
 31+        {
 32+            "bufferView": 2,
 33+            "componentType": 5126,
 34+            "count": 4598,
 35+            "type": "VEC2"
 36+        },
 37+        {
 38+            "bufferView": 3,
 39+            "componentType": 5125,
 40+            "count": 25344,
 41+            "type": "SCALAR",
 42+            "max": [
 43+                4597
 44+            ],
 45+            "min": [
 46+                0
 47+            ]
 48+        },
 49+        {
 50+            "bufferView": 4,
 51+            "componentType": 5126,
 52+            "count": 4598,
 53+            "type": "VEC3",
 54+            "max": [
 55+                10.647041320800782,
 56+                -0.3529590368270874,
 57+                0.6470409631729126
 58+            ],
 59+            "min": [
 60+                -10.647041320800782,
 61+                -1.6470409631729127,
 62+                -0.6470409631729126
 63+            ]
 64+        },
 65+        {
 66+            "bufferView": 5,
 67+            "componentType": 5126,
 68+            "count": 4598,
 69+            "type": "VEC2"
 70+        }
 71+    ],
 72+    "bufferViews": [
 73+        {
 74+            "buffer": 0,
 75+            "byteOffset": 0,
 76+            "byteLength": 55176,
 77+            "target": 34962
 78+        },
 79+        {
 80+            "buffer": 0,
 81+            "byteOffset": 55176,
 82+            "byteLength": 55176,
 83+            "target": 34962
 84+        },
 85+        {
 86+            "buffer": 0,
 87+            "byteOffset": 110352,
 88+            "byteLength": 36784,
 89+            "target": 34962
 90+        },
 91+        {
 92+            "buffer": 0,
 93+            "byteOffset": 147136,
 94+            "byteLength": 101376,
 95+            "target": 34963
 96+        },
 97+        {
 98+            "buffer": 0,
 99+            "byteOffset": 248512,
100+            "byteLength": 55176,
101+            "target": 34962
102+        },
103+        {
104+            "buffer": 0,
105+            "byteOffset": 303688,
106+            "byteLength": 36784,
107+            "target": 34962
108+        }
109+    ],
110+    "buffers": [
111+        {
112+            "byteLength": 340472,
113+            "uri": "EnvironmentTest_binary.bin"
114+        }
115+    ],
116+    "cameras": [
117+        {
118+            "perspective": {
119+                "znear": 0.0010000000474974514,
120+                "yfov": 0.6024156808853149,
121+                "zfar": 200.0,
122+                "aspectRatio": 1.3333333730697632
123+            },
124+            "type": "perspective",
125+            "name": "render_camera"
126+        }
127+    ],
128+    "images": [
129+        {
130+            "name": "tmp_image_pie_dc1e_1a22_fbf9roughness_map_roughness_tmp_image_pie_dc1e_1a22_fbf9metal_map_metallic_0",
131+            "uri": "roughness_metallic_0.jpg",
132+            "mimeType": "image/jpeg"
133+        },
134+        {
135+            "name": "tmp_image_pie_b20b_ebb4_317droughness_map2_roughness_tmp_image_pie_b20b_ebb4_317dmetal_map2_metallic_1",
136+            "uri": "roughness_metallic_1.jpg",
137+            "mimeType": "image/jpeg"
138+        }
139+    ],
140+    "materials": [
141+        {
142+            "pbrMetallicRoughness": {
143+                "metallicRoughnessTexture": {
144+                    "index": 0
145+                }
146+            },
147+            "name": "MetallicSpheresMat",
148+            "doubleSided": true
149+        },
150+        {
151+            "pbrMetallicRoughness": {
152+                "metallicRoughnessTexture": {
153+                    "index": 1
154+                }
155+            },
156+            "name": "DielectricSpheresMat",
157+            "doubleSided": true
158+        },
159+        {
160+            "pbrMetallicRoughness": {
161+                "baseColorFactor": [
162+                    0.0,
163+                    0.0,
164+                    0.0,
165+                    1.0
166+                ],
167+                "metallicRoughnessTexture": {
168+                    "index": 1
169+                }
170+            },
171+            "name": "DielectricSpheresMat",
172+            "doubleSided": true
173+        }
174+    ],
175+    "meshes": [
176+        {
177+            "name": "Metallic0_N3D",
178+            "primitives": [
179+                {
180+                    "attributes": {
181+                        "POSITION": 0,
182+                        "NORMAL": 1,
183+                        "TEXCOORD_0": 2
184+                    },
185+                    "indices": 3,
186+                    "material": 0
187+                }
188+            ]
189+        },
190+        {
191+            "name": "Dielectric0_N3D2",
192+            "primitives": [
193+                {
194+                    "attributes": {
195+                        "TEXCOORD_0": 5,
196+                        "NORMAL": 1,
197+                        "POSITION": 4
198+                    },
199+                    "indices": 3,
200+                    "material": 1
201+                }
202+            ]
203+        },
204+        {
205+            "name": "Dielectric0_N3D",
206+            "primitives": [
207+                {
208+                    "attributes": {
209+                        "POSITION": 4,
210+                        "NORMAL": 1,
211+                        "TEXCOORD_0": 5
212+                    },
213+                    "indices": 3,
214+                    "material": 2
215+                }
216+            ]
217+        }
218+    ],
219+    "nodes": [
220+        {
221+            "matrix": [
222+                0.9999533295631409,
223+                3.16067598760128e-8,
224+                0.009662099182605744,
225+                0.0,
226+                0.0014864075928926468,
227+                0.9880954027175903,
228+                -0.15383504331111909,
229+                0.0,
230+                -0.009547080844640732,
231+                0.15384222567081452,
232+                0.988049328327179,
233+                0.0,
234+                -0.7599077224731445,
235+                7.708760738372803,
236+                27.743375778198243,
237+                1.0
238+            ],
239+            "camera": 0,
240+            "name": "render_camera_n3d"
241+        },
242+        {
243+            "name": "ground_plane_n3d"
244+        },
245+        {
246+            "children": [
247+                3,
248+                4,
249+                5
250+            ],
251+            "matrix": [
252+                1.0,
253+                0.0,
254+                0.0,
255+                0.0,
256+                0.0,
257+                1.0,
258+                0.0,
259+                0.0,
260+                0.0,
261+                0.0,
262+                1.0,
263+                0.0,
264+                -0.5564079284667969,
265+                4.774584770202637,
266+                -1.0962677001953126,
267+                1.0
268+            ],
269+            "name": "ENV_Spheres"
270+        },
271+        {
272+            "mesh": 0,
273+            "name": "Metallic0"
274+        },
275+        {
276+            "mesh": 1,
277+            "name": "Dielectric0"
278+        },
279+        {
280+            "matrix": [
281+                1.0,
282+                0.0,
283+                0.0,
284+                0.0,
285+                0.0,
286+                1.0,
287+                0.0,
288+                0.0,
289+                0.0,
290+                0.0,
291+                1.0,
292+                0.0,
293+                0.0,
294+                -1.985867977142334,
295+                0.0,
296+                1.0
297+            ],
298+            "mesh": 2,
299+            "name": "Dielectric0-Black"
300+        }
301+    ],
302+    "samplers": [
303+        {},
304+        {}
305+    ],
306+    "scenes": [
307+        {
308+            "nodes": [
309+                0,
310+                1,
311+                2
312+            ],
313+            "name": "scene"
314+        }
315+    ],
316+    "textures": [
317+        {
318+            "name": "tmp_image_pie_dc1e_1a22_fbf9roughness_map_roughness_tmp_image_pie_dc1e_1a22_fbf9metal_map_metallic_0_texture",
319+            "sampler": 0,
320+            "source": 0
321+        },
322+        {
323+            "name": "tmp_image_pie_b20b_ebb4_317droughness_map2_roughness_tmp_image_pie_b20b_ebb4_317dmetal_map2_metallic_1_texture",
324+            "sampler": 1,
325+            "source": 1
326+        }
327+    ],
328+    "scene": 0
329+}
A · public/EnvironmentTest_binary.bin +0, -0
A · public/LDR_RGBA_0.png +0, -0
A · public/cornell_empty_rg.bin +0, -0
A · public/cornell_empty_rg.gltf +445, -0
  1@@ -0,0 +1,445 @@
  2+{
  3+	"asset":{
  4+		"generator":"Khronos glTF Blender I/O v4.3.47",
  5+		"version":"2.0"
  6+	},
  7+	"extensionsUsed":[
  8+		"KHR_materials_specular",
  9+		"KHR_materials_ior"
 10+	],
 11+	"scene":0,
 12+	"scenes":[
 13+		{
 14+			"name":"Scene",
 15+			"nodes":[
 16+				0
 17+			]
 18+		}
 19+	],
 20+	"nodes":[
 21+		{
 22+			"mesh":0,
 23+			"name":"CornellBox-Empty-RG",
 24+			"rotation":[
 25+				0.7071068286895752,
 26+				0,
 27+				0,
 28+				0.7071068286895752
 29+			]
 30+		}
 31+	],
 32+	"materials":[
 33+		{
 34+			"doubleSided":true,
 35+			"extensions":{
 36+				"KHR_materials_specular":{
 37+					"specularFactor":0
 38+				},
 39+				"KHR_materials_ior":{
 40+					"ior":1
 41+				}
 42+			},
 43+			"name":"floor.002",
 44+			"pbrMetallicRoughness":{
 45+				"baseColorFactor":[
 46+					0.7250000238418579,
 47+					0.7099999785423279,
 48+					0.6800000071525574,
 49+					1
 50+				],
 51+				"metallicFactor":0,
 52+				"roughnessFactor":0.8999999761581421
 53+			}
 54+		},
 55+		{
 56+			"doubleSided":true,
 57+			"extensions":{
 58+				"KHR_materials_specular":{
 59+					"specularFactor":0
 60+				},
 61+				"KHR_materials_ior":{
 62+					"ior":1
 63+				}
 64+			},
 65+			"name":"ceiling.002",
 66+			"pbrMetallicRoughness":{
 67+				"baseColorFactor":[
 68+					0.7250000238418579,
 69+					0.7099999785423279,
 70+					0.6800000071525574,
 71+					1
 72+				],
 73+				"metallicFactor":0,
 74+				"roughnessFactor":0.8999999761581421
 75+			}
 76+		},
 77+		{
 78+			"doubleSided":true,
 79+			"extensions":{
 80+				"KHR_materials_specular":{
 81+					"specularFactor":0
 82+				},
 83+				"KHR_materials_ior":{
 84+					"ior":1
 85+				}
 86+			},
 87+			"name":"backWall.002",
 88+			"pbrMetallicRoughness":{
 89+				"baseColorFactor":[
 90+					0.7250000238418579,
 91+					0.7099999785423279,
 92+					0.6800000071525574,
 93+					1
 94+				],
 95+				"metallicFactor":0,
 96+				"roughnessFactor":0.8999999761581421
 97+			}
 98+		},
 99+		{
100+			"doubleSided":true,
101+			"extensions":{
102+				"KHR_materials_specular":{
103+					"specularFactor":0
104+				}
105+			},
106+			"name":"rightWall.002",
107+			"pbrMetallicRoughness":{
108+				"baseColorFactor":[
109+					0.14000000059604645,
110+					0.44999998807907104,
111+					0.09099999815225601,
112+					1
113+				],
114+				"metallicFactor":0,
115+				"roughnessFactor":0.8999999761581421
116+			}
117+		},
118+		{
119+			"doubleSided":true,
120+			"extensions":{
121+				"KHR_materials_specular":{
122+					"specularFactor":0
123+				}
124+			},
125+			"name":"leftWall.002",
126+			"pbrMetallicRoughness":{
127+				"baseColorFactor":[
128+					0.6299999952316284,
129+					0.06499999761581421,
130+					0.05000000074505806,
131+					1
132+				],
133+				"metallicFactor":0,
134+				"roughnessFactor":0.8999999761581421
135+			}
136+		},
137+		{
138+			"doubleSided":true,
139+			"emissiveFactor":[
140+				1,
141+				1,
142+				1
143+			],
144+			"extensions":{
145+				"KHR_materials_specular":{
146+					"specularFactor":0
147+				},
148+				"KHR_materials_ior":{
149+					"ior":1
150+				}
151+			},
152+			"name":"light.002",
153+			"pbrMetallicRoughness":{
154+				"baseColorFactor":[
155+					0.7799999713897705,
156+					0.7799999713897705,
157+					0.7799999713897705,
158+					1
159+				],
160+				"metallicFactor":0,
161+				"roughnessFactor":0.8999999761581421
162+			}
163+		}
164+	],
165+	"meshes":[
166+		{
167+			"name":"CornellBox-Empty-RG.002",
168+			"primitives":[
169+				{
170+					"attributes":{
171+						"POSITION":0,
172+						"NORMAL":1
173+					},
174+					"indices":2,
175+					"material":0
176+				},
177+				{
178+					"attributes":{
179+						"POSITION":3,
180+						"NORMAL":4
181+					},
182+					"indices":2,
183+					"material":1
184+				},
185+				{
186+					"attributes":{
187+						"POSITION":5,
188+						"NORMAL":6
189+					},
190+					"indices":2,
191+					"material":2
192+				},
193+				{
194+					"attributes":{
195+						"POSITION":7,
196+						"NORMAL":8
197+					},
198+					"indices":2,
199+					"material":3
200+				},
201+				{
202+					"attributes":{
203+						"POSITION":9,
204+						"NORMAL":10
205+					},
206+					"indices":2,
207+					"material":4
208+				},
209+				{
210+					"attributes":{
211+						"POSITION":11,
212+						"NORMAL":12
213+					},
214+					"indices":2,
215+					"material":5
216+				}
217+			]
218+		}
219+	],
220+	"accessors":[
221+		{
222+			"bufferView":0,
223+			"componentType":5126,
224+			"count":4,
225+			"max":[
226+				1,
227+				0.9900000095367432,
228+				0
229+			],
230+			"min":[
231+				-1.0099999904632568,
232+				-1.0399999618530273,
233+				0
234+			],
235+			"type":"VEC3"
236+		},
237+		{
238+			"bufferView":1,
239+			"componentType":5126,
240+			"count":4,
241+			"type":"VEC3"
242+		},
243+		{
244+			"bufferView":2,
245+			"componentType":5123,
246+			"count":6,
247+			"type":"SCALAR"
248+		},
249+		{
250+			"bufferView":3,
251+			"componentType":5126,
252+			"count":4,
253+			"max":[
254+				1,
255+				0.9900000095367432,
256+				-1.9900000095367432
257+			],
258+			"min":[
259+				-1.0199999809265137,
260+				-1.0399999618530273,
261+				-1.9900000095367432
262+			],
263+			"type":"VEC3"
264+		},
265+		{
266+			"bufferView":4,
267+			"componentType":5126,
268+			"count":4,
269+			"type":"VEC3"
270+		},
271+		{
272+			"bufferView":5,
273+			"componentType":5126,
274+			"count":4,
275+			"max":[
276+				1,
277+				-1.0399999618530273,
278+				0
279+			],
280+			"min":[
281+				-1.0199999809265137,
282+				-1.0399999618530273,
283+				-1.9900000095367432
284+			],
285+			"type":"VEC3"
286+		},
287+		{
288+			"bufferView":6,
289+			"componentType":5126,
290+			"count":4,
291+			"type":"VEC3"
292+		},
293+		{
294+			"bufferView":7,
295+			"componentType":5126,
296+			"count":4,
297+			"max":[
298+				1,
299+				0.9900000095367432,
300+				0
301+			],
302+			"min":[
303+				1,
304+				-1.0399999618530273,
305+				-1.9900000095367432
306+			],
307+			"type":"VEC3"
308+		},
309+		{
310+			"bufferView":8,
311+			"componentType":5126,
312+			"count":4,
313+			"type":"VEC3"
314+		},
315+		{
316+			"bufferView":9,
317+			"componentType":5126,
318+			"count":4,
319+			"max":[
320+				-0.9900000095367432,
321+				0.9900000095367432,
322+				0
323+			],
324+			"min":[
325+				-1.0199999809265137,
326+				-1.0399999618530273,
327+				-1.9900000095367432
328+			],
329+			"type":"VEC3"
330+		},
331+		{
332+			"bufferView":10,
333+			"componentType":5126,
334+			"count":4,
335+			"type":"VEC3"
336+		},
337+		{
338+			"bufferView":11,
339+			"componentType":5126,
340+			"count":4,
341+			"max":[
342+				0.23000000417232513,
343+				0.1599999964237213,
344+				-1.9800000190734863
345+			],
346+			"min":[
347+				-0.23999999463558197,
348+				-0.2199999988079071,
349+				-1.9800000190734863
350+			],
351+			"type":"VEC3"
352+		},
353+		{
354+			"bufferView":12,
355+			"componentType":5126,
356+			"count":4,
357+			"type":"VEC3"
358+		}
359+	],
360+	"bufferViews":[
361+		{
362+			"buffer":0,
363+			"byteLength":48,
364+			"byteOffset":0,
365+			"target":34962
366+		},
367+		{
368+			"buffer":0,
369+			"byteLength":48,
370+			"byteOffset":48,
371+			"target":34962
372+		},
373+		{
374+			"buffer":0,
375+			"byteLength":12,
376+			"byteOffset":96,
377+			"target":34963
378+		},
379+		{
380+			"buffer":0,
381+			"byteLength":48,
382+			"byteOffset":108,
383+			"target":34962
384+		},
385+		{
386+			"buffer":0,
387+			"byteLength":48,
388+			"byteOffset":156,
389+			"target":34962
390+		},
391+		{
392+			"buffer":0,
393+			"byteLength":48,
394+			"byteOffset":204,
395+			"target":34962
396+		},
397+		{
398+			"buffer":0,
399+			"byteLength":48,
400+			"byteOffset":252,
401+			"target":34962
402+		},
403+		{
404+			"buffer":0,
405+			"byteLength":48,
406+			"byteOffset":300,
407+			"target":34962
408+		},
409+		{
410+			"buffer":0,
411+			"byteLength":48,
412+			"byteOffset":348,
413+			"target":34962
414+		},
415+		{
416+			"buffer":0,
417+			"byteLength":48,
418+			"byteOffset":396,
419+			"target":34962
420+		},
421+		{
422+			"buffer":0,
423+			"byteLength":48,
424+			"byteOffset":444,
425+			"target":34962
426+		},
427+		{
428+			"buffer":0,
429+			"byteLength":48,
430+			"byteOffset":492,
431+			"target":34962
432+		},
433+		{
434+			"buffer":0,
435+			"byteLength":48,
436+			"byteOffset":540,
437+			"target":34962
438+		}
439+	],
440+	"buffers":[
441+		{
442+			"byteLength":588,
443+			"uri":"cornell_empty_rg.bin"
444+		}
445+	]
446+}
A · public/roughness_metallic_0.jpg +0, -0
A · public/roughness_metallic_1.jpg +0, -0
A · src/bvh.ts +331, -0
  1@@ -0,0 +1,331 @@
  2+import { Vec3 } from "gl-matrix";
  3+
  4+const MAX_BOUND = 999999;
  5+
  6+interface Triangle {
  7+  cornerA: Vec3;
  8+  cornerB: Vec3;
  9+  cornerC: Vec3;
 10+  centroid: Vec3;
 11+}
 12+
 13+interface Bin {
 14+  instanceCount: number;
 15+  bounds: AABB;
 16+}
 17+
 18+// just for documentation
 19+// interface BVHNode { 
 20+//   min: Vec3; // 12 
 21+//   max: Vec3; // 12 
 22+//   left: number; // 4 
 23+//   instanceCount: number; // 4 
 24+// }
 25+
 26+class AABB {
 27+  bmin: Vec3;
 28+  bmax: Vec3;
 29+  constructor() {
 30+    this.bmin = Vec3.fromValues(MAX_BOUND, MAX_BOUND, MAX_BOUND);
 31+    this.bmax = Vec3.fromValues(-MAX_BOUND, -MAX_BOUND, -MAX_BOUND);
 32+  }
 33+
 34+  grow(p: Vec3) {
 35+    Vec3.min(this.bmin, this.bmin, p);
 36+    Vec3.max(this.bmax, this.bmax, p);
 37+  }
 38+
 39+  growAABB(aabb: AABB) {
 40+    // Only grow if the other AABB is valid.
 41+    if (aabb.bmin[0] !== MAX_BOUND) {
 42+      this.grow(aabb.bmin);
 43+      this.grow(aabb.bmax);
 44+    }
 45+  }
 46+
 47+  area() {
 48+    const e = Vec3.create();
 49+    Vec3.subtract(e, this.bmax, this.bmin);
 50+    // standard surface area measure (omitting the factor 2 is acceptable since SAH is relative)
 51+    return e[0] * e[1] + e[1] * e[2] + e[2] * e[0];
 52+  }
 53+}
 54+
 55+class BVH {
 56+  nodesMin: Float32Array; // min x,y,z for each node
 57+  nodesMax: Float32Array; // max x,y,z for each node
 58+  nodesLeft: Uint32Array;
 59+  nodesInstanceCount: Uint32Array;
 60+  nodesUsed: number;
 61+  triangles: Triangle[];
 62+  triIdx: Uint32Array;
 63+
 64+  constructor(triangles: Triangle[]) {
 65+    this.triangles = triangles;
 66+    this.triIdx = new Uint32Array(triangles.length);
 67+    this.nodesUsed = 0;
 68+    const maxNodes = 2 * triangles.length - 1;
 69+    this.nodesMin = new Float32Array(maxNodes * 3);
 70+    this.nodesMax = new Float32Array(maxNodes * 3);
 71+    this.nodesLeft = new Uint32Array(maxNodes);
 72+    this.nodesInstanceCount = new Uint32Array(maxNodes);
 73+  }
 74+
 75+  construct() {
 76+    for (let i = 0; i < this.triangles.length; i++) {
 77+      this.triIdx[i] = i;
 78+    }
 79+    this.nodesInstanceCount[0] = this.triangles.length;
 80+    this.nodesLeft[0] = 0; // root node
 81+    this.nodesUsed = 1;
 82+    this.bounding(0);
 83+    this.subdivide(0);
 84+  }
 85+
 86+  bounding(nodeIdx: number) {
 87+    const off = nodeIdx * 3;
 88+    // initialize the node's AABB.
 89+    this.nodesMin[off + 0] = MAX_BOUND;
 90+    this.nodesMin[off + 1] = MAX_BOUND;
 91+    this.nodesMin[off + 2] = MAX_BOUND;
 92+    this.nodesMax[off + 0] = -MAX_BOUND;
 93+    this.nodesMax[off + 1] = -MAX_BOUND;
 94+    this.nodesMax[off + 2] = -MAX_BOUND;
 95+
 96+    const count = this.nodesInstanceCount[nodeIdx];
 97+    const start = this.nodesLeft[nodeIdx];
 98+
 99+    // temp vectors
100+    const minVec = Vec3.create();
101+    const maxVec = Vec3.create();
102+
103+    for (let i = 0; i < count; i++) {
104+      const tri = this.triangles[this.triIdx[start + i]];
105+
106+      // walk through each tri and update the bounds
107+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerA);
108+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerA);
109+      this.nodesMin[off + 0] = minVec[0];
110+      this.nodesMin[off + 1] = minVec[1];
111+      this.nodesMin[off + 2] = minVec[2];
112+      this.nodesMax[off + 0] = maxVec[0];
113+      this.nodesMax[off + 1] = maxVec[1];
114+      this.nodesMax[off + 2] = maxVec[2];
115+
116+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerB);
117+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerB);
118+      this.nodesMin[off + 0] = minVec[0];
119+      this.nodesMin[off + 1] = minVec[1];
120+      this.nodesMin[off + 2] = minVec[2];
121+      this.nodesMax[off + 0] = maxVec[0];
122+      this.nodesMax[off + 1] = maxVec[1];
123+      this.nodesMax[off + 2] = maxVec[2];
124+
125+      Vec3.min(minVec, [this.nodesMin[off], this.nodesMin[off + 1], this.nodesMin[off + 2]], tri.cornerC);
126+      Vec3.max(maxVec, [this.nodesMax[off], this.nodesMax[off + 1], this.nodesMax[off + 2]], tri.cornerC);
127+      this.nodesMin[off + 0] = minVec[0];
128+      this.nodesMin[off + 1] = minVec[1];
129+      this.nodesMin[off + 2] = minVec[2];
130+      this.nodesMax[off + 0] = maxVec[0];
131+      this.nodesMax[off + 1] = maxVec[1];
132+      this.nodesMax[off + 2] = maxVec[2];
133+    }
134+  }
135+
136+  subdivide(nodeIdx: number) {
137+    // not enough primitives
138+    if (this.nodesInstanceCount[nodeIdx] <= 2) return;
139+
140+    let [split, axis, cost] = this.findBestPlane(nodeIdx);
141+
142+    // eval the parent node’s extent.
143+    const off = nodeIdx * 3;
144+    const extent = Vec3.create();
145+    Vec3.subtract(
146+      extent,
147+      [this.nodesMax[off + 0], this.nodesMax[off + 1], this.nodesMax[off + 2]],
148+      [this.nodesMin[off + 0], this.nodesMin[off + 1], this.nodesMin[off + 2]]
149+    );
150+    const parentArea = extent[0] * extent[1] + extent[1] * extent[2] + extent[2] * extent[0];
151+    const parentCost = this.nodesInstanceCount[nodeIdx] * parentArea;
152+
153+    // fallback to median split if SAH cost is not better
154+    if (cost >= parentCost) {
155+      let longestAxis = 0;
156+      if (extent[1] > extent[0]) longestAxis = 1;
157+      if (extent[2] > extent[longestAxis]) longestAxis = 2;
158+      const start = this.nodesLeft[nodeIdx];
159+      const count = this.nodesInstanceCount[nodeIdx];
160+      const centroids: number[] = [];
161+      for (let i = 0; i < count; i++) {
162+        centroids.push(this.triangles[this.triIdx[start + i]].centroid[longestAxis]);
163+      }
164+      centroids.sort((a, b) => a - b);
165+      split = centroids[Math.floor(count / 2)];
166+      axis = longestAxis;
167+    }
168+
169+    // partition primitives based on the chosen split
170+    let i = this.nodesLeft[nodeIdx];
171+    let j = i + this.nodesInstanceCount[nodeIdx] - 1;
172+    while (i <= j) {
173+      const tri = this.triangles[this.triIdx[i]];
174+      if (tri.centroid[axis] < split) {
175+        i++;
176+      } else {
177+        const tmp = this.triIdx[i];
178+        this.triIdx[i] = this.triIdx[j];
179+        this.triIdx[j] = tmp;
180+        j--;
181+      }
182+    }
183+    const leftCount = i - this.nodesLeft[nodeIdx];
184+    if (leftCount === 0 || leftCount === this.nodesInstanceCount[nodeIdx]) return;
185+
186+    // construct child nodes.
187+    const leftIdx = this.nodesUsed++;
188+    const rightIdx = this.nodesUsed++;
189+
190+    this.nodesLeft[leftIdx] = this.nodesLeft[nodeIdx];
191+    this.nodesInstanceCount[leftIdx] = leftCount;
192+    this.nodesLeft[rightIdx] = i;
193+    this.nodesInstanceCount[rightIdx] = this.nodesInstanceCount[nodeIdx] - leftCount;
194+
195+    // internal node
196+    this.nodesLeft[nodeIdx] = leftIdx;
197+    this.nodesInstanceCount[nodeIdx] = 0;
198+
199+    // keep going 
200+    this.bounding(leftIdx);
201+    this.bounding(rightIdx);
202+    this.subdivide(leftIdx);
203+    this.subdivide(rightIdx);
204+  }
205+
206+  findBestPlane(nodeIdx: number): [number, number, number] {
207+    let bestAxis = -1;
208+    let bestSplit = 0;
209+    let bestCost = Infinity;
210+
211+    const count = this.nodesInstanceCount[nodeIdx];
212+    const start = this.nodesLeft[nodeIdx];
213+
214+    // eval centroid bounds
215+    const centroidMin = [Infinity, Infinity, Infinity];
216+    const centroidMax = [-Infinity, -Infinity, -Infinity];
217+    for (let i = 0; i < count; i++) {
218+      const tri = this.triangles[this.triIdx[start + i]];
219+      for (let axis = 0; axis < 3; axis++) {
220+        centroidMin[axis] = Math.min(centroidMin[axis], tri.centroid[axis]);
221+        centroidMax[axis] = Math.max(centroidMax[axis], tri.centroid[axis]);
222+      }
223+    }
224+
225+    // fallback if this centroid has a degenerate centroid distributions
226+    const EPSILON = 1e-5;
227+    let degenerate = false;
228+    for (let axis = 0; axis < 3; axis++) {
229+      if (Math.abs(centroidMax[axis] - centroidMin[axis]) < EPSILON) {
230+        degenerate = true;
231+        break;
232+      }
233+    }
234+    if (degenerate) {
235+      // use median split along the longest axis of actual bounds
236+      let longestAxis = 0;
237+      const actualMin = [Infinity, Infinity, Infinity];
238+      const actualMax = [-Infinity, -Infinity, -Infinity];
239+      for (let i = 0; i < count; i++) {
240+        const tri = this.triangles[this.triIdx[start + i]];
241+        for (let axis = 0; axis < 3; axis++) {
242+          actualMin[axis] = Math.min(actualMin[axis], tri.cornerA[axis], tri.cornerB[axis], tri.cornerC[axis]);
243+          actualMax[axis] = Math.max(actualMax[axis], tri.cornerA[axis], tri.cornerB[axis], tri.cornerC[axis]);
244+        }
245+      }
246+      const extent = [actualMax[0] - actualMin[0], actualMax[1] - actualMin[1], actualMax[2] - actualMin[2]];
247+      if (extent[1] > extent[0]) longestAxis = 1;
248+      if (extent[2] > extent[longestAxis]) longestAxis = 2;
249+      const centroids: number[] = [];
250+      for (let i = 0; i < count; i++) {
251+        centroids.push(this.triangles[this.triIdx[start + i]].centroid[longestAxis]);
252+      }
253+      centroids.sort((a, b) => a - b);
254+      bestSplit = centroids[Math.floor(count / 2)];
255+      bestAxis = longestAxis;
256+      return [bestSplit, bestAxis, bestCost];
257+    }
258+
259+    // use adaptive binning (bin count based on number of primitives, clamped between 8 and 32)
260+    const BINS = Math.max(8, Math.min(32, count));
261+    const bins: Bin[] = Array.from({ length: BINS }, () => ({
262+      instanceCount: 0,
263+      bounds: new AABB(),
264+    }));
265+
266+    // for each axis, evaluate candidate splits.
267+    for (let axis = 0; axis < 3; axis++) {
268+      const axisMin = centroidMin[axis];
269+      const axisMax = centroidMax[axis];
270+      if (axisMax === axisMin) continue; // skip degenerate axis
271+
272+      const axisScale = BINS / (axisMax - axisMin);
273+      // reset bins for this axis.
274+      for (let i = 0; i < BINS; i++) {
275+        bins[i].instanceCount = 0;
276+        bins[i].bounds = new AABB();
277+      }
278+
279+      // distribute primitives into bins.
280+      for (let i = 0; i < count; i++) {
281+        const tri = this.triangles[this.triIdx[start + i]];
282+        let binIDX = Math.floor((tri.centroid[axis] - axisMin) * axisScale);
283+        if (binIDX >= BINS) binIDX = BINS - 1;
284+        bins[binIDX].instanceCount++;
285+        bins[binIDX].bounds.grow(tri.cornerA);
286+        bins[binIDX].bounds.grow(tri.cornerB);
287+        bins[binIDX].bounds.grow(tri.cornerC);
288+      }
289+
290+
291+      // compute cumulative sums from left and right
292+      const leftCount = new Array(BINS - 1).fill(0);
293+      const leftArea = new Array(BINS - 1).fill(0);
294+      const rightCount = new Array(BINS - 1).fill(0);
295+      const rightArea = new Array(BINS - 1).fill(0);
296+
297+      let leftBox = new AABB();
298+      for (let i = 0, sum = 0; i < BINS - 1; i++) {
299+        sum += bins[i].instanceCount;
300+        leftCount[i] = sum;
301+        leftBox.growAABB(bins[i].bounds);
302+        leftArea[i] = leftBox.area();
303+      }
304+      let rightBox = new AABB();
305+      for (let i = BINS - 1, sum = 0; i > 0; i--) {
306+        sum += bins[i].instanceCount;
307+        rightCount[i - 1] = sum;
308+        rightBox.growAABB(bins[i].bounds);
309+        rightArea[i - 1] = rightBox.area();
310+      }
311+
312+      // highly axis aligned polygons like lucy and sponza fail
313+      // duct taped solution to handle degen cases
314+      const binWidth = (axisMax - axisMin) / BINS;
315+      // eval candidate splits.
316+      for (let i = 0; i < BINS - 1; i++) {
317+        // small epsilon jitter to help break ties.
318+        const jitter = 1e-6;
319+        const candidatePos = axisMin + binWidth * (i + 1) + jitter;
320+        const cost = leftCount[i] * leftArea[i] + rightCount[i] * rightArea[i];
321+        if (cost < bestCost) {
322+          bestCost = cost;
323+          bestAxis = axis;
324+          bestSplit = candidatePos;
325+        }
326+      }
327+    }
328+    return [bestSplit, bestAxis, bestCost];
329+  }
330+}
331+
332+export default BVH;
A · src/camera.ts +139, -0
  1@@ -0,0 +1,139 @@
  2+import { Vec3, Mat4 } from "gl-matrix";
  3+
  4+export type CameraState = {
  5+  position: Float32Array; // [x, y, z]
  6+  rotation: Float32Array; // [theta, phi]
  7+  view: Float32Array;
  8+  inverseView: Float32Array;
  9+  projection: Float32Array;
 10+  currentViewProj: Float32Array;
 11+  previousViewProj: Float32Array;
 12+  dirty: boolean;
 13+};
 14+
 15+const moveVec = Vec3.create();
 16+const lastPosition = Vec3.create();
 17+const lastRotation = new Float32Array(2);
 18+const rotateDelta = new Float32Array(2);
 19+const keyState = new Set<string>();
 20+const sprintMultiplier = 2.5;
 21+
 22+let isPointerLocked = false;
 23+let oldTime = 0;
 24+
 25+export function createCamera(canvas: HTMLCanvasElement): CameraState {
 26+  const position = Vec3.fromValues(0, -75, 20);
 27+  const view = Mat4.create();
 28+  const projection = Mat4.create();
 29+  const inverseView = Mat4.create();
 30+
 31+  const lookAtTarget = Vec3.fromValues(0, 0, 20);
 32+  const up = Vec3.fromValues(0, 0, 1);
 33+  Mat4.lookAt(view, position, lookAtTarget, up);
 34+  Mat4.invert(inverseView, view);
 35+  Mat4.perspectiveZO(projection, Math.PI / 4, canvas.width / canvas.height, 0.1, 100);
 36+
 37+  const forward = Vec3.create();
 38+  Vec3.subtract(forward, lookAtTarget, position);
 39+  Vec3.normalize(forward, forward);
 40+  const phi = Math.asin(forward[2]);
 41+  const theta = Math.atan2(forward[0], forward[1]);
 42+
 43+  Vec3.copy(lastPosition, position);
 44+  lastRotation[0] = theta;
 45+  lastRotation[1] = phi;
 46+
 47+  return {
 48+    position,
 49+    rotation: new Float32Array([theta, phi]),
 50+    view,
 51+    inverseView,
 52+    projection,
 53+    currentViewProj: Mat4.create(),
 54+    previousViewProj: Mat4.create(),
 55+    dirty: true,
 56+  };
 57+}
 58+
 59+export function updateCamera(cam: CameraState): void {
 60+  const now = performance.now() * 0.001;
 61+  const dt = now - oldTime;
 62+  oldTime = now;
 63+
 64+  const theta = cam.rotation[0] += rotateDelta[0] * dt * 60;
 65+  const phi = cam.rotation[1] -= rotateDelta[1] * dt * 60;
 66+  cam.rotation[1] = Math.min(88, Math.max(-88, cam.rotation[1]));
 67+  rotateDelta[0] = rotateDelta[1] = 0;
 68+
 69+  updateMovementInput();
 70+
 71+  const scaledMove = Vec3.create();
 72+  Vec3.scale(scaledMove, moveVec, dt);
 73+
 74+  const moved = Vec3.length(scaledMove) > 1e-5;
 75+  const rotated = theta !== lastRotation[0] || phi !== lastRotation[1];
 76+  const changedPos = !Vec3.exactEquals(cam.position, lastPosition);
 77+  cam.dirty = moved || rotated || changedPos;
 78+
 79+  if (!cam.dirty) return;
 80+
 81+  const forwards = Vec3.fromValues(
 82+    Math.sin(toRad(theta)) * Math.cos(toRad(phi)),
 83+    Math.cos(toRad(theta)) * Math.cos(toRad(phi)),
 84+    Math.sin(toRad(phi))
 85+  );
 86+  const right = Vec3.create();
 87+  Vec3.cross(right, forwards, [0, 0, 1]);
 88+  Vec3.normalize(right, right);
 89+
 90+  const up = Vec3.create();
 91+  Vec3.cross(up, right, forwards);
 92+  Vec3.normalize(up, up);
 93+
 94+  Vec3.scaleAndAdd(cam.position, cam.position, forwards, scaledMove[0]);
 95+  Vec3.scaleAndAdd(cam.position, cam.position, right, scaledMove[1]);
 96+  Vec3.scaleAndAdd(cam.position, cam.position, up, scaledMove[2]);
 97+
 98+  const target = Vec3.create();
 99+  Vec3.add(target, cam.position, forwards);
100+  Mat4.lookAt(cam.view, cam.position, target, up);
101+  Mat4.invert(cam.inverseView, cam.view);
102+
103+  Vec3.copy(lastPosition, cam.position);
104+  lastRotation[0] = theta;
105+  lastRotation[1] = phi;
106+}
107+
108+export function updateMovementInput(): void {
109+  moveVec[0] = moveVec[1] = moveVec[2] = 0;
110+  const speed = keyState.has("shift") ? 10 * sprintMultiplier : 10;
111+
112+  if (keyState.has("w")) moveVec[0] += speed;
113+  if (keyState.has("s")) moveVec[0] -= speed;
114+  if (keyState.has("d")) moveVec[1] += speed;
115+  if (keyState.has("a")) moveVec[1] -= speed;
116+  if (keyState.has("q")) moveVec[2] += speed;
117+  if (keyState.has("e")) moveVec[2] -= speed;
118+}
119+
120+function toRad(deg: number): number {
121+  return (deg * Math.PI) / 180;
122+}
123+
124+export function setupCameraInput(canvas: HTMLCanvasElement): void {
125+  canvas.addEventListener("click", () => canvas.requestPointerLock());
126+
127+  document.addEventListener("pointerlockchange", () => {
128+    isPointerLocked = document.pointerLockElement != null;
129+  });
130+
131+  window.addEventListener("keydown", e => keyState.add(e.key.toLowerCase()));
132+  window.addEventListener("keyup", e => keyState.delete(e.key.toLowerCase()));
133+
134+  window.addEventListener("mousemove", (e) => {
135+    if (!isPointerLocked) return;
136+    const sensitivity = 0.5;
137+    rotateDelta[0] = e.movementX * sensitivity;
138+    rotateDelta[1] = e.movementY * sensitivity;
139+  });
140+}
A · src/gltf.ts +394, -0
  1@@ -0,0 +1,394 @@
  2+import { load } from "@loaders.gl/core";
  3+import { GLTFImagePostprocessed, GLTFLoader, GLTFMeshPrimitivePostprocessed, GLTFPostprocessed, postProcessGLTF } from "@loaders.gl/gltf";
  4+import { Mat3, Mat4, Mat4Like, Quat, QuatLike, Vec3, Vec3Like } from "gl-matrix";
  5+
  6+interface Triangle {
  7+  centroid: number[];
  8+  cornerA: number[];
  9+  cornerB: number[];
 10+  cornerC: number[];
 11+  normalA: number[];
 12+  normalB: number[];
 13+  normalC: number[];
 14+  mat: number;
 15+  uvA: number[];
 16+  uvB: number[];
 17+  uvC: number[];
 18+  tangentA: number[];
 19+  tangentB: number[];
 20+  tangentC: number[];
 21+}
 22+
 23+interface ProcessedMaterial {
 24+  baseColorFactor: number[],
 25+  baseColorTexture: number, // idx
 26+  metallicFactor: number,
 27+  roughnessFactor: number,
 28+  metallicRoughnessTexture: number, //idx
 29+  normalTexture: number, //idx
 30+  emissiveFactor: number[],
 31+  emissiveTexture: number, //idx
 32+  alphaMode: number, // parseAlphaMode
 33+  alphaCutoff: number,
 34+  doubleSided: number,
 35+};
 36+
 37+interface ProcessedTexture {
 38+  id: String,
 39+  sampler: GPUSampler,
 40+  texture: GPUTexture,
 41+  view: GPUTextureView,
 42+  source: GLTFImagePostprocessed,
 43+  samplerDescriptor: GPUSamplerDescriptor,
 44+}
 45+
 46+export class GLTF2 {
 47+  triangles: Array<Triangle>;
 48+  materials: Array<ProcessedMaterial>; // could make material more explicit here, like with textures
 49+  textures: Array<ProcessedTexture>;
 50+  device: GPUDevice;
 51+  gltfData!: GLTFPostprocessed;
 52+  url: string;
 53+  scale: number[];
 54+  position: number[];
 55+  rotation?: number[];
 56+
 57+  // pre allocating these here, faster that way? Intuitevely, I could be wrong. 
 58+  tempVec3_0: Vec3 = Vec3.create();
 59+  tempVec3_1: Vec3 = Vec3.create();
 60+  tempVec3_2: Vec3 = Vec3.create();
 61+  tempVec3_3: Vec3 = Vec3.create();
 62+  tempVec3_4: Vec3 = Vec3.create();
 63+  tempMat4_0: Mat4 = Mat4.create();
 64+  tempMat4_1: Mat4 = Mat4.create();
 65+  tempMat3_0: Mat3 = Mat3.create();
 66+
 67+  constructor(device: GPUDevice, url: string, scale: number[], position: number[], rotation?: number[]) {
 68+    this.triangles = [];
 69+    this.materials = [];
 70+    this.textures = [];
 71+    this.device = device;
 72+    this.url = url;
 73+    this.scale = scale;
 74+    this.position = position;
 75+    this.rotation = rotation;
 76+  }
 77+  async initialize() {
 78+    const t = await load(this.url, GLTFLoader);
 79+    this.gltfData = postProcessGLTF(t);
 80+    this.traverseNodes();
 81+    return [this.triangles, this.materials, this.textures];
 82+  }
 83+  // some data swizzling swash buckling utils
 84+  transformVec3(inputVec: ArrayLike<number>, matrix: Mat4): number[] {
 85+    const v = this.tempVec3_0;
 86+    Vec3.set(v, inputVec[0], inputVec[1], inputVec[2]);
 87+    Vec3.transformMat4(v, v, matrix);
 88+    return [v[0], v[1], v[2]]; // Return new array copy
 89+  }
 90+  transformNormal(inputNormal: ArrayLike<number>, transformMatrix: Mat4): number[] {
 91+    const normalMatrix = this.tempMat3_0;      // Reused Mat3
 92+    const tempMatrix = this.tempMat4_1;        // Use tempMat4_1 to avoid conflict with finalTransform
 93+    const transformedNormal = this.tempVec3_0; // Reused Vec3 for result
 94+    const inputNormalVec = this.tempVec3_1;    // Reused Vec3 for input
 95+
 96+    // calculate transpose(invert(transformMatrix))
 97+    // tempMat4_1 as scratch space to avoid clobbering tempMat4_0 (finalTransform)
 98+    Mat4.invert(tempMatrix, transformMatrix);
 99+    Mat4.transpose(tempMatrix, tempMatrix);
100+
101+    // upper-left 3x3 submatrix
102+    Mat3.fromMat4(normalMatrix, tempMatrix);
103+
104+    // normal into reusable Vec3
105+    Vec3.set(inputNormalVec, inputNormal[0], inputNormal[1], inputNormal[2]);
106+
107+    // transfrom that normal
108+    Vec3.transformMat3(transformedNormal, inputNormalVec, normalMatrix);
109+    Vec3.normalize(transformedNormal, transformedNormal);
110+
111+    // new array copy
112+    return [transformedNormal[0], transformedNormal[1], transformedNormal[2]];
113+  }
114+  parseAlphaMode(alphaMode: string) {
115+    if (alphaMode === "MASK") { return 1 }
116+    return 2
117+  }
118+
119+
120+  // could break this up
121+  extractTriangles(primitive: GLTFMeshPrimitivePostprocessed, transform: Mat4, targetArray: Array<Triangle>) {
122+    const positions = primitive.attributes["POSITION"].value;
123+    const indicesData = primitive.indices ? primitive.indices.value : null;
124+    const numVertices = positions.length / 3;
125+    const indices = indicesData ?? (() => {
126+      const generatedIndices = new Uint32Array(numVertices);
127+      for (let i = 0; i < numVertices; i++) generatedIndices[i] = i;
128+      return generatedIndices;
129+    })();
130+    const normals = primitive.attributes["NORMAL"]
131+      ? primitive.attributes["NORMAL"].value
132+      : null;
133+    const uvCoords = primitive.attributes["TEXCOORD_0"]
134+      ? primitive.attributes["TEXCOORD_0"].value
135+      : null;
136+    const tangents = primitive.attributes["TANGENT"]
137+      ? primitive.attributes["TANGENT"].value
138+      : null;
139+
140+    const mat = parseInt(primitive.material?.id.match(/\d+$/)?.[0] ?? "-1");
141+
142+    // ensure these don't clash with temps used in transformNormal/transformVec3
143+    // if called within the face normal logic (they aren't).
144+    const vA = this.tempVec3_1; // maybe use distinct temps if needed, but seems ok
145+    const vB = this.tempVec3_2;
146+    const vC = this.tempVec3_3;
147+    const edge1 = this.tempVec3_4;
148+    const edge2 = this.tempVec3_0;      // tempVec3_0 reused safely after position transforms
149+    const faceNormal = this.tempVec3_1; // tempVec3_1 reused safely after normal transforms or for input
150+
151+    const defaultUV = [0, 0];
152+    const defaultTangent = [1, 0, 0, 1];
153+
154+    for (let i = 0; i < indices.length; i += 3) {
155+      const ai = indices[i];
156+      const bi = indices[i + 1];
157+      const ci = indices[i + 2];
158+
159+      const posA = [positions[ai * 3], positions[ai * 3 + 1], positions[ai * 3 + 2]];
160+      const posB = [positions[bi * 3], positions[bi * 3 + 1], positions[bi * 3 + 2]];
161+      const posC = [positions[ci * 3], positions[ci * 3 + 1], positions[ci * 3 + 2]];
162+
163+      // transform positions uses tempVec3_0 internally
164+      const cornerA = this.transformVec3(posA, transform);
165+      const cornerB = this.transformVec3(posB, transform);
166+      const cornerC = this.transformVec3(posC, transform);
167+
168+      let normalA: number[], normalB: number[], normalC: number[];
169+      if (normals) {
170+        // transform normals uses tempVec3_0, tempVec3_1 internally
171+        normalA = this.transformNormal([normals[ai * 3], normals[ai * 3 + 1], normals[ai * 3 + 2]], transform);
172+        normalB = this.transformNormal([normals[bi * 3], normals[bi * 3 + 1], normals[bi * 3 + 2]], transform);
173+        normalC = this.transformNormal([normals[ci * 3], normals[ci * 3 + 1], normals[ci * 3 + 2]], transform);
174+      } else {
175+        // compute fallback flat face normal
176+        Vec3.set(vA, cornerA[0], cornerA[1], cornerA[2]);
177+        Vec3.set(vB, cornerB[0], cornerB[1], cornerB[2]);
178+        Vec3.set(vC, cornerC[0], cornerC[1], cornerC[2]);
179+
180+        Vec3.subtract(edge1, vB, vA);
181+        Vec3.subtract(edge2, vC, vA);
182+        Vec3.cross(faceNormal, edge1, edge2);
183+        Vec3.normalize(faceNormal, faceNormal);
184+
185+        const normalArray = [faceNormal[0], faceNormal[1], faceNormal[2]];
186+        normalA = normalArray;
187+        normalB = normalArray;
188+        normalC = normalArray;
189+      }
190+
191+      const uvA = uvCoords ? [uvCoords[ai * 2], uvCoords[ai * 2 + 1]] : defaultUV;
192+      const uvB = uvCoords ? [uvCoords[bi * 2], uvCoords[bi * 2 + 1]] : defaultUV;
193+      const uvC = uvCoords ? [uvCoords[ci * 2], uvCoords[ci * 2 + 1]] : defaultUV;
194+
195+      const tangentA = tangents ? [tangents[ai * 4], tangents[ai * 4 + 1], tangents[ai * 4 + 2], tangents[ai * 4 + 3]] : defaultTangent;
196+      const tangentB = tangents ? [tangents[bi * 4], tangents[bi * 4 + 1], tangents[bi * 4 + 2], tangents[bi * 4 + 3]] : defaultTangent;
197+      const tangentC = tangents ? [tangents[ci * 4], tangents[ci * 4 + 1], tangents[ci * 4 + 2], tangents[ci * 4 + 3]] : defaultTangent;
198+
199+      const centroid = [
200+        (cornerA[0] + cornerB[0] + cornerC[0]) / 3,
201+        (cornerA[1] + cornerB[1] + cornerC[1]) / 3,
202+        (cornerA[2] + cornerB[2] + cornerC[2]) / 3,
203+      ];
204+
205+      targetArray.push({
206+        centroid,
207+        cornerA, cornerB, cornerC,
208+        normalA, normalB, normalC, mat,
209+        uvA, uvB, uvC,
210+        tangentA, tangentB, tangentC,
211+      });
212+    }
213+  }
214+
215+  traverseNodes() {
216+    // texture processing 
217+    if (this.gltfData.textures) {
218+      this.gltfData.textures.forEach((texture) => {
219+        if (!texture.source?.image) {
220+          // empty textures are handled on atlas creation
221+          return;
222+        }
223+        const gpuTexture = this.device.createTexture({
224+          size: {
225+            width: texture.source.image.width ?? 0,
226+            height: texture.source.image.height ?? 0,
227+            depthOrArrayLayers: 1,
228+          },
229+          format: "rgba8unorm",
230+          usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
231+        });
232+
233+        const view = gpuTexture.createView({ format: "rgba8unorm" });
234+
235+        // TODO: Process gltfData.samplers[texture.sampler] if it exists
236+        let samplerDescriptor: GPUSamplerDescriptor = {
237+          magFilter: "linear", minFilter: "linear",
238+          addressModeU: "repeat", addressModeV: "repeat",
239+        };
240+        const sampler = this.device.createSampler(samplerDescriptor);
241+        this.textures.push({
242+          id: texture.id,
243+          texture: gpuTexture,
244+          view: view,
245+          sampler: sampler,
246+          source: texture.source,
247+          samplerDescriptor: samplerDescriptor
248+        });
249+      });
250+    }
251+    if (this.gltfData.materials) {
252+      this.materials = this.gltfData.materials.map(mat => {
253+        return {
254+          baseColorFactor: mat.pbrMetallicRoughness?.baseColorFactor ?? [1.0, 1.0, 1.0, 1.0],
255+          baseColorTexture: mat.pbrMetallicRoughness?.baseColorTexture?.index ?? -1,
256+          metallicFactor: mat.pbrMetallicRoughness?.metallicFactor ?? 1.0,
257+          roughnessFactor: mat.pbrMetallicRoughness?.roughnessFactor ?? 1.0,
258+          metallicRoughnessTexture: mat.pbrMetallicRoughness?.metallicRoughnessTexture?.index ?? -1,
259+          normalTexture: mat.normalTexture?.index ?? -1,
260+          emissiveFactor: mat.emissiveFactor ?? [0.0, 0.0, 0.0],
261+          emissiveTexture: mat.emissiveTexture?.index ?? -1,
262+          alphaMode: mat.alphaMode ? this.parseAlphaMode(mat.alphaMode) : 0,
263+          alphaCutoff: mat.alphaCutoff ?? 0.5,
264+          doubleSided: mat.doubleSided ? 1 : 0,
265+        };
266+      });
267+    }
268+
269+    // initial node transforms
270+    const finalTransform = this.tempMat4_0;     // reused for final calc per node
271+    const nodeLocalTransform = this.tempMat4_1; // reused for local calc per node
272+    const tMat = Mat4.create();
273+    const rMat = Mat4.create();
274+    const sMat = Mat4.create();
275+    const tMatCustom = Mat4.create();
276+    const rMatCustom = Mat4.create();
277+    const sMatCustom = Mat4.create();
278+    const yToZUp = Mat4.fromValues(
279+      1, 0, 0, 0,
280+      0, 0, 1, 0,
281+      0, -1, 0, 0,
282+      0, 0, 0, 1
283+    );
284+    // scene transforms
285+    const sceneTransform = Mat4.create();
286+    const sc_translation = this.position || [0, 0, 0];
287+    const sc_rotation = this.rotation || [0, 0, 0, 1];
288+    const sc_scale = this.scale || [1, 1, 1];
289+    Mat4.fromTranslation(tMatCustom, sc_translation as Vec3Like);
290+    Quat.normalize(rMatCustom, sc_rotation as QuatLike);
291+    Mat4.fromQuat(rMatCustom, rMatCustom);
292+    Mat4.fromScaling(sMatCustom, sc_scale as Vec3Like);
293+    Mat4.multiply(sceneTransform, rMatCustom, sMatCustom);
294+    Mat4.multiply(sceneTransform, tMatCustom, sceneTransform);
295+
296+    const meshMap = new Map(this.gltfData.meshes.map(m => [m.id, m]));
297+
298+    for (const node of this.gltfData.nodes) {
299+      if (!node.mesh?.id) continue;
300+      const mesh = meshMap.get(node.mesh.id);
301+      if (!mesh) continue;
302+      Mat4.identity(nodeLocalTransform);
303+      if (node.matrix) {
304+        Mat4.copy(nodeLocalTransform, node.matrix as Mat4Like);
305+      } else {
306+        const nodeTranslation = node.translation || [0, 0, 0];
307+        const nodeRotation = node.rotation || [0, 0, 0, 1];
308+        const nodeScale = node.scale || [1, 1, 1];
309+        Mat4.fromTranslation(tMat, nodeTranslation as Vec3Like);
310+        Mat4.fromQuat(rMat, nodeRotation as QuatLike);
311+        Mat4.fromScaling(sMat, nodeScale as Vec3Like);
312+        Mat4.multiply(nodeLocalTransform, rMat, sMat);
313+        Mat4.multiply(nodeLocalTransform, tMat, nodeLocalTransform);
314+      }
315+
316+      // finalTransform = sceneTransform * yToZUp * nodeLocalTransform
317+      Mat4.multiply(finalTransform, yToZUp, nodeLocalTransform);
318+      Mat4.multiply(finalTransform, sceneTransform, finalTransform);
319+
320+      mesh.primitives.forEach((primitive: GLTFMeshPrimitivePostprocessed) => {
321+        this.extractTriangles(primitive, finalTransform, this.triangles);
322+      });
323+    }
324+  }
325+}
326+
327+export function combineGLTFs(gltfs: GLTF2[]) {
328+  const triangles = [];
329+  const materials = [];
330+  const textures = [];
331+
332+  let textureOffset = 0;
333+  let materialOffset = 0;
334+  let largestTextureDimensions = { width: 0, height: 0 };
335+
336+  // offset idx
337+  const offsetIdx = (idx: any) => {
338+    return (typeof idx === 'number' && idx >= 0) ? idx + textureOffset : idx; // Keep original if invalid index
339+  };
340+
341+  for (let i = 0; i < gltfs.length; i++) {
342+    const gltf = gltfs[i];
343+    const texCount = gltf.textures ? gltf.textures.length : 0;
344+    const matCount = gltf.materials ? gltf.materials.length : 0;
345+    // just append the textures for now
346+    if (gltf.textures && texCount > 0) {
347+      for (let t = 0; t < texCount; t++) {
348+        const texture = gltf.textures[t];
349+        let texHeight = texture.source.image.height as number;
350+        let texWidth = texture.source.image.width as number;
351+        textures.push(texture);
352+        if (texWidth > largestTextureDimensions.width) {
353+          largestTextureDimensions.width = texWidth;
354+        }
355+        if (texHeight > largestTextureDimensions.height) {
356+          largestTextureDimensions.height = texHeight;
357+        }
358+      }
359+    }
360+    if (gltf.materials && matCount > 0) {
361+      for (let m = 0; m < matCount; m++) {
362+        const src = gltf.materials[m];
363+        materials.push({
364+          alphaCutoff: src.alphaCutoff,
365+          alphaMode: src.alphaMode,
366+          baseColorFactor: src.baseColorFactor,
367+          baseColorTexture: offsetIdx(src.baseColorTexture),
368+          doubleSided: src.doubleSided,
369+          emissiveFactor: src.emissiveFactor,
370+          emissiveTexture: offsetIdx(src.emissiveTexture),
371+          metallicFactor: src.metallicFactor,
372+          metallicRoughnessTexture: offsetIdx(src.metallicRoughnessTexture),
373+          normalTexture: offsetIdx(src.normalTexture),
374+          roughnessFactor: src.roughnessFactor
375+        });
376+      }
377+    }
378+    // update idx if needed
379+    if (gltf.triangles) {
380+      for (let t = 0; t < gltf.triangles.length; t++) {
381+        const tri = gltf.triangles[t];
382+        const triCopy = Object.create(tri);
383+        if (tri.mat >= 0) {
384+          triCopy.mat = tri.mat + materialOffset;
385+        } else {
386+          triCopy.mat = -1;
387+        }
388+        triangles.push(triCopy);
389+      }
390+    }
391+    textureOffset += texCount;
392+    materialOffset += matCount;
393+  }
394+  return { triangles, materials, textures, largestTextureDimensions };
395+}
A · src/main.ts +643, -0
  1@@ -0,0 +1,643 @@
  2+import { Vec3 } from "gl-matrix";
  3+import BVH from "./bvh.ts";
  4+import { createCamera, setupCameraInput, updateCamera, updateMovementInput } from "./camera.ts";
  5+import { GLTF2, combineGLTFs } from "./gltf.ts";
  6+import { InitPane } from "./pane.ts";
  7+import kernel from "./shaders/main.wgsl";
  8+import viewport from "./shaders/viewport.wgsl";
  9+
 10+declare global {
 11+  interface Window {
 12+    framecount: number;
 13+  }
 14+}
 15+
 16+// init device
 17+const canvas = document.querySelector("canvas") as HTMLCanvasElement;
 18+const adapter = (await navigator.gpu.requestAdapter()) as GPUAdapter;
 19+if (!navigator.gpu) {
 20+  throw new Error("WebGPU not supported on this browser.");
 21+}
 22+if (!adapter) {
 23+  throw new Error("No appropriate GPUAdapter found.");
 24+}
 25+const device = await adapter.requestDevice({
 26+  requiredFeatures: ['timestamp-query']
 27+});
 28+
 29+const width = canvas.clientWidth;
 30+const height = canvas.clientHeight;
 31+canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
 32+canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
 33+const context = canvas.getContext("webgpu") as GPUCanvasContext;
 34+const format = navigator.gpu.getPreferredCanvasFormat();
 35+context.configure({
 36+  device,
 37+  format: format,
 38+});
 39+
 40+// compose scene -> triangles -> BVH -> textures
 41+const x = new GLTF2(device, "Duck.gltf", [0.1, 0.1, 0.1], [-13, -1, -0.34], [0, 0, -1.25, 1]);
 42+const y = new GLTF2(device, "cornell_empty_rg.gltf", [20, 20, 20], [0, 0, 0.01], [0,0,0,0])
 43+const z = new GLTF2(device, "EnvironmentTest.gltf", [1.8, 1.8, 1.8], [0, 15, 25], [0, 0, 0, 0]);
 44+
 45+await x.initialize()
 46+await y.initialize()
 47+await z.initialize()
 48+const t = combineGLTFs([x,y,z]);
 49+let ab = new BVH(t.triangles);
 50+ab.construct();
 51+const hasTextures = t.textures && t.textures.length > 0;
 52+const textureCount = hasTextures ? t.textures.length : 0;
 53+const textureSizes = new Float32Array(textureCount * 4); // [width, height, invWidth, invHeight] per texture
 54+console.log(t.triangles)
 55+// viewport texture, rgba32float; we store full fat HDR and tonemap it in this
 56+const viewportTexture = device.createTexture({
 57+  size: {
 58+    width: canvas.width,
 59+    height: canvas.height,
 60+  },
 61+  format: "rgba32float",
 62+  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
 63+});
 64+const viewportTextureColorBuffer = viewportTexture.createView();
 65+
 66+
 67+// offsets for buffer data
 68+const MaterialSize = 64;
 69+const materialData = new Float32Array(t.materials.length * (MaterialSize / 4));
 70+const MaterialInfo = {
 71+  albedo: { type: Float32Array, byteOffset: 0, length: 4 },
 72+  metallic: { type: Float32Array, byteOffset: 16, length: 1 },
 73+  alphaMode: { type: Float32Array, byteOffset: 20, length: 1 },
 74+  alphaCutoff: { type: Float32Array, byteOffset: 24, length: 1 },
 75+  doubleSided: { type: Float32Array, byteOffset: 28, length: 1 },
 76+  emission: { type: Float32Array, byteOffset: 32, length: 3 },
 77+  roughness: { type: Float32Array, byteOffset: 44, length: 1 },
 78+  baseColorTexture: { type: Float32Array, byteOffset: 48, length: 1 },
 79+  normalTexture: { type: Float32Array, byteOffset: 52, length: 1 },
 80+  metallicRoughnessTexture: { type: Float32Array, byteOffset: 56, length: 1 },
 81+  emissiveTexture: { type: Float32Array, byteOffset: 60, length: 1 },
 82+};
 83+
 84+// NB: Very fat. Trimming these to vert should be (4*3) * 3 + 12 = 48.
 85+// at the point if it's just 48, might be cheaper to get rid of the triangle indicies. Skip the BVH -> tri_index lookup.
 86+// and then resolve the shading data with tri indicies? This would still need material index thought? To do alpha tests in trace()?
 87+// TODO: Trim these to only verts, the material index, and the shading index. Move the rest to shadingData[]
 88+// 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.
 89+const TriangleSize = 176;
 90+const TriangleData = new Float32Array(t.triangles.length * (TriangleSize / 4));
 91+const TriangleInfo = {
 92+  corner_a: { type: Float32Array, byteOffset: 0, length: 3 },
 93+  corner_b: { type: Float32Array, byteOffset: 16, length: 3 },
 94+  corner_c: { type: Float32Array, byteOffset: 32, length: 3 },
 95+  normal_a: { type: Float32Array, byteOffset: 48, length: 3 },
 96+  normal_b: { type: Float32Array, byteOffset: 64, length: 3 },
 97+  normal_c: { type: Float32Array, byteOffset: 80, length: 3 },
 98+  material: { type: Float32Array, byteOffset: 92, length: 1 },
 99+  uVA: { type: Float32Array, byteOffset: 96, length: 2 },
100+  uVB: { type: Float32Array, byteOffset: 104, length: 2 },
101+  uVC: { type: Float32Array, byteOffset: 112, length: 2 },
102+  tangentA: { type: Float32Array, byteOffset: 128, length: 4 },
103+  tangentB: { type: Float32Array, byteOffset: 144, length: 4 },
104+  tangentC: { type: Float32Array, byteOffset: 160, length: 4 },
105+};
106+
107+// init scene buffers
108+const triangleBuffer = device.createBuffer({
109+  label: "Triangle Storage",
110+  size: t.triangles.length * TriangleSize,
111+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
112+});
113+const materialBuffer = device.createBuffer({
114+  label: "Material storage",
115+  size: 8 * materialData.length,
116+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
117+});
118+const emissiveTrianglesBuffer = device.createBuffer({
119+  label: "Emissive triangles",
120+  size: t.triangles.length * TriangleSize,
121+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
122+});
123+const nodeBuffer = device.createBuffer({
124+  size: 32 * ab.nodesUsed,
125+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
126+});
127+const triangleIndexBuffer = device.createBuffer({
128+  size: 4 * t.triangles.length,
129+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
130+});
131+const accumulationBuffer = device.createBuffer({
132+  size: canvas.width * canvas.height * 16,
133+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
134+});
135+const uniformBuffer0 = device.createBuffer({
136+  label: "Camera Transform Buffer",
137+  size: 512,
138+  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
139+});
140+const textureSizeBuffer = device.createBuffer({
141+  size: Math.max(textureSizes.byteLength, 2048),
142+  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
143+});
144+
145+// populate buffers
146+const emissiveMaterialIndices: number[] = []
147+const emissiveTriangleIndices: number[] = []
148+const bvhPrimitiveTriangleIndices: Float32Array = new Float32Array(ab.triIdx.length);
149+
150+type MaterialPropertyName = keyof typeof MaterialInfo;
151+t.materials.forEach((mat, i) => {
152+  const materialOffset = i * (MaterialSize / 4); // Base offset for the current material
153+  const setData = (propertyName: MaterialPropertyName, value: number[]) => {
154+    const info = MaterialInfo[propertyName];
155+    materialData.set(value, materialOffset + info.byteOffset / 4);
156+  };
157+  const setFloat = (propertyName: MaterialPropertyName, value: number) => {
158+    const info = MaterialInfo[propertyName];
159+    materialData[materialOffset + info.byteOffset / 4] = value;
160+  };
161+  setData("albedo", mat.baseColorFactor); // Now sets 4 floats instead of 3
162+  setFloat("metallic", mat.metallicFactor);
163+  setFloat("alphaMode", mat.alphaMode);
164+  setFloat("alphaCutoff", mat.alphaCutoff);
165+  setFloat("doubleSided", mat.doubleSided);
166+  setData("emission", mat.emissiveFactor);
167+  setFloat("roughness", mat.roughnessFactor);
168+  setFloat("baseColorTexture", mat.baseColorTexture);
169+  setFloat("normalTexture", mat.normalTexture);
170+  setFloat("metallicRoughnessTexture", mat.metallicRoughnessTexture);
171+  setFloat("emissiveTexture", mat.emissiveTexture);
172+  if (mat.emissiveFactor[0] !== 0 || mat.emissiveFactor[1] !== 0 || mat.emissiveFactor[2] !== 0) {
173+    emissiveMaterialIndices.push(i);
174+  }
175+});
176+device.queue.writeBuffer(materialBuffer, 0, materialData);
177+
178+type TrianglePropertyName = keyof typeof TriangleInfo;
179+t.triangles.forEach((tri, i) => {
180+  const triOffset = i * (TriangleSize / 4);
181+  const setData = (propertyName: TrianglePropertyName, value: number[]) => {
182+    const info = TriangleInfo[propertyName];
183+    TriangleData.set(value, triOffset + info.byteOffset / 4);
184+  };
185+  const setFloat = (propertyName: TrianglePropertyName, value: number) => {
186+    const info = TriangleInfo[propertyName];
187+    TriangleData[triOffset + info.byteOffset / 4] = value;
188+  };
189+  setData("corner_a", tri.cornerA);
190+  setData("corner_b", tri.cornerB);
191+  setData("corner_c", tri.cornerC);
192+  setData("normal_a", tri.normalA);
193+  setData("normal_b", tri.normalB);
194+  setData("normal_c", tri.normalC);
195+  setFloat("material", tri.mat);
196+  setData("uVA", tri.uvA);
197+  setData("uVB", tri.uvB);
198+  setData("uVC", tri.uvC);
199+  setData("tangentA", tri.tangentA);
200+  setData("tangentB", tri.tangentB);
201+  setData("tangentC", tri.tangentC);
202+  if (emissiveMaterialIndices.includes(tri.mat)) {
203+    emissiveTriangleIndices.push(i); // Push the triangle's index
204+  }
205+});
206+device.queue.writeBuffer(triangleBuffer, 0, TriangleData);
207+device.queue.writeBuffer(emissiveTrianglesBuffer, 0, new Float32Array(emissiveTriangleIndices));
208+
209+const nodeData: Float32Array = new Float32Array(8 * ab.nodesUsed);
210+for (let i = 0; i < ab.nodesUsed; i++) {
211+  const minOffset = i * 3;
212+  const maxOffset = i * 3;
213+  nodeData[8 * i] = ab.nodesMin[minOffset + 0];
214+  nodeData[8 * i + 1] = ab.nodesMin[minOffset + 1];
215+  nodeData[8 * i + 2] = ab.nodesMin[minOffset + 2];
216+  nodeData[8 * i + 3] = ab.nodesLeft[i];
217+  nodeData[8 * i + 4] = ab.nodesMax[maxOffset + 0];
218+  nodeData[8 * i + 5] = ab.nodesMax[maxOffset + 1];
219+  nodeData[8 * i + 6] = ab.nodesMax[maxOffset + 2];
220+  nodeData[8 * i + 7] = ab.nodesInstanceCount[i];
221+}
222+device.queue.writeBuffer(nodeBuffer, 0, nodeData, 0, 8 * ab.nodesUsed);
223+
224+for (let i = 0; i < ab.triIdx.length; i++) {
225+  bvhPrimitiveTriangleIndices[i] = ab.triIdx[i];
226+}
227+device.queue.writeBuffer(triangleIndexBuffer, 0, bvhPrimitiveTriangleIndices, 0, ab.triIdx.length);
228+
229+// bluenoise texture 2. 3.24: Bluenoise
230+// bluenoise texture form https://momentsingraphics.de/BlueNoise.html
231+async function loadImageBitmap(url: string) {
232+  const res = await fetch(url);
233+  const blob = await res.blob();
234+  return await createImageBitmap(blob, { colorSpaceConversion: 'none' });
235+}
236+const bnnoiseSource = await loadImageBitmap("LDR_RGBA_0.png")
237+const blueNoiseTexture = device.createTexture({
238+  label: 'bluenoise-texture',
239+  format: 'rgba8unorm',
240+  size: [bnnoiseSource.width, bnnoiseSource.height],
241+  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
242+});
243+device.queue.copyExternalImageToTexture(
244+  { source: bnnoiseSource },
245+  { texture: blueNoiseTexture },
246+  { width: bnnoiseSource.width, height: bnnoiseSource.height },
247+);
248+
249+
250+// construct the texture atlas
251+const emptySampler = device.createSampler({
252+  addressModeU: "clamp-to-edge",
253+  addressModeV: "clamp-to-edge",
254+  addressModeW: "clamp-to-edge",
255+  magFilter: "nearest",
256+  minFilter: "nearest",
257+  mipmapFilter: "nearest",
258+});
259+const emptyTexture = device.createTexture({
260+  size: [1, 1, 1],
261+  format: "rgba8unorm",
262+  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
263+});
264+const emptyView = emptyTexture.createView({
265+  dimension: "2d-array",
266+});
267+let maxWidth = 1, maxHeight = 1;
268+let textureSampler = emptySampler;
269+let textureArray = emptyTexture;
270+let textureViewArray = emptyView;
271+if (hasTextures) {
272+  maxWidth = t.largestTextureDimensions.width
273+  maxHeight = t.largestTextureDimensions.height
274+  textureSampler = t.textures[0].sampler // lazy, store the samplers correctly
275+  textureArray = device.createTexture({
276+    size: [maxWidth, maxHeight, t.textures.length],
277+    format: "rgba8unorm",
278+    usage:
279+      GPUTextureUsage.TEXTURE_BINDING |
280+      GPUTextureUsage.COPY_DST |
281+      GPUTextureUsage.RENDER_ATTACHMENT,
282+    dimension: "2d",
283+  })
284+  textureViewArray = textureArray.createView({ dimension: "2d-array" });
285+}
286+
287+// rather wasteful (and sometimes incorrect, but whatever. Fine for now)
288+// 1. get each texture's dimension 
289+// 2. pad it to the largest one 
290+// 3. store the original h w in textureSizes[]
291+// 4. stack the padded texture 
292+if (t.textures.length) {
293+  for (let i = 0; i < t.textures.length; i++) {
294+    const source = t.textures[i].source;
295+    // @ts-ignore / Poorly defined type for the original GLTFImagePostprocessed
296+    const bitmap = source.image as ImageBitmap;
297+    textureSizes[i * 4] = bitmap.width;
298+    textureSizes[i * 4 + 1] = bitmap.height;
299+    textureSizes[i * 4 + 2] = 0.0;
300+    textureSizes[i * 4 + 3] = 0.0;
301+
302+    device.queue.copyExternalImageToTexture(
303+      { source: bitmap },
304+      { texture: textureArray, origin: [0, 0, i] },
305+      [bitmap.width, bitmap.height, 1]
306+    );
307+  }
308+  device.queue.writeBuffer(textureSizeBuffer, 0, textureSizes);
309+}
310+
311+// bind groups and layouts 
312+const geometryBindgroupLayout = device.createBindGroupLayout({
313+  label: 'geometry-bind-group-layout',
314+  entries: [
315+    {
316+      binding: 0,
317+      visibility: GPUShaderStage.COMPUTE,
318+      storageTexture: {
319+        access: "write-only",
320+        format: "rgba32float",
321+        viewDimension: "2d",
322+      },
323+    },
324+    {
325+      binding: 1,
326+      visibility: GPUShaderStage.COMPUTE,
327+      buffer: {
328+        type: "read-only-storage",
329+        hasDynamicOffset: false,
330+      },
331+    },
332+    {
333+      binding: 2,
334+      visibility: GPUShaderStage.COMPUTE,
335+      buffer: {
336+        type: "uniform",
337+      },
338+    },
339+
340+    {
341+      binding: 5,
342+      visibility: GPUShaderStage.COMPUTE,
343+      buffer: {
344+        type: "read-only-storage",
345+        hasDynamicOffset: false,
346+      },
347+    },
348+    {
349+      binding: 6,
350+      visibility: GPUShaderStage.COMPUTE,
351+      buffer: {
352+        type: "read-only-storage",
353+        hasDynamicOffset: false,
354+      },
355+    },
356+    {
357+      binding: 7,
358+      visibility: GPUShaderStage.COMPUTE,
359+      buffer: {
360+        type: "storage",
361+      },
362+    },
363+  ],
364+});
365+
366+const geometryBindgroup = device.createBindGroup({
367+  label: 'geometry-bind-group',
368+  layout: geometryBindgroupLayout,
369+  entries: [
370+    {
371+      binding: 0,
372+      resource: viewportTextureColorBuffer,
373+    },
374+    {
375+      binding: 1,
376+      resource: {
377+        buffer: triangleBuffer,
378+      },
379+    },
380+    {
381+      binding: 2,
382+      resource: {
383+        buffer: uniformBuffer0,
384+      },
385+    },
386+    {
387+      binding: 5,
388+      resource: { buffer: nodeBuffer },
389+    },
390+    {
391+      binding: 6,
392+      resource: { buffer: triangleIndexBuffer },
393+    },
394+    {
395+      binding: 7,
396+      resource: { buffer: accumulationBuffer },
397+    },
398+  ],
399+});
400+
401+const shadingBindGroupLayout = device.createBindGroupLayout({
402+  label: 'shading-bind-group-layout',
403+  entries: [
404+    {
405+      binding: 0,
406+      visibility: GPUShaderStage.COMPUTE,
407+      buffer: {
408+        type: "read-only-storage",
409+        hasDynamicOffset: false,
410+      },
411+    },
412+    {
413+      binding: 1,
414+      visibility: GPUShaderStage.COMPUTE,
415+      texture: {
416+        viewDimension: "2d-array",
417+      },
418+    },
419+    {
420+      binding: 2,
421+      visibility: GPUShaderStage.COMPUTE,
422+      sampler: {},
423+    },
424+    {
425+      binding: 4,
426+      visibility: GPUShaderStage.COMPUTE,
427+      buffer: {
428+        type: "uniform",
429+        hasDynamicOffset: false,
430+      },
431+    },
432+    {
433+      binding: 6,
434+      visibility: GPUShaderStage.COMPUTE,
435+      storageTexture: {
436+        access: "read-only",
437+        format: "rgba8unorm",
438+        viewDimension: "2d",
439+      },
440+    },
441+    {
442+      binding: 7,
443+      visibility: GPUShaderStage.COMPUTE,
444+      buffer: {
445+        type: "read-only-storage",
446+        hasDynamicOffset: false,
447+      },
448+    },
449+  ],
450+});
451+
452+const shadingBindGroup = device.createBindGroup({
453+  label: 'shading-bind-group',
454+  layout: shadingBindGroupLayout,
455+  entries: [
456+    {
457+      binding: 0,
458+      resource: {
459+        buffer: materialBuffer,
460+      },
461+    },
462+    { binding: 1, resource: textureViewArray },
463+    { binding: 2, resource: textureSampler },
464+    {
465+      binding: 4,
466+      resource: {
467+        buffer: textureSizeBuffer,
468+      },
469+    },
470+    {
471+      binding: 6,
472+      resource: blueNoiseTexture.createView(),
473+    },
474+    {
475+      binding: 7,
476+      resource: {
477+        buffer: emissiveTrianglesBuffer,
478+      },
479+    },
480+  ],
481+});
482+
483+const viewportBindgroupLayout = device.createBindGroupLayout({
484+  entries: [
485+    {
486+      binding: 0,
487+      visibility: GPUShaderStage.FRAGMENT,
488+      texture: {
489+        sampleType: 'unfilterable-float',
490+        viewDimension: '2d',
491+        multisampled: false,
492+      },
493+
494+    },
495+  ],
496+});
497+
498+const viewportBindgroup = device.createBindGroup({
499+  layout: viewportBindgroupLayout,
500+  entries: [
501+    {
502+      binding: 0,
503+      resource: viewportTextureColorBuffer,
504+    },
505+  ],
506+});
507+
508+// pipelines
509+const kernelPipelineLayout = device.createPipelineLayout({
510+  bindGroupLayouts: [geometryBindgroupLayout, shadingBindGroupLayout],
511+});
512+
513+const kernelPipeline = device.createComputePipeline({
514+  layout: kernelPipelineLayout,
515+  compute: {
516+    module: device.createShaderModule({
517+      code: kernel,
518+    }),
519+    entryPoint: "main",
520+  },
521+});
522+
523+const viewportPipelineLayout = device.createPipelineLayout({
524+  bindGroupLayouts: [viewportBindgroupLayout],
525+});
526+
527+const viewportPipeline = device.createRenderPipeline({
528+  layout: viewportPipelineLayout,
529+  vertex: {
530+    module: device.createShaderModule({
531+      code: viewport,
532+    }),
533+    entryPoint: "vert_main",
534+  },
535+  fragment: {
536+    module: device.createShaderModule({
537+      code: viewport,
538+    }),
539+    entryPoint: "frag_main",
540+    targets: [
541+      {
542+        format: format,
543+      },
544+    ],
545+  },
546+  primitive: {
547+    topology: "triangle-list",
548+  },
549+});
550+
551+
552+var frametime = 0;
553+window.framecount = 0;
554+const UNIFORMS = {
555+  sample_count: 1.0,
556+  bounce_count: 3.0,
557+  aperture: 0.1,
558+  focal_length: 4.0,
559+  frameTimeMs: 0,
560+  fps: frametime / 1000,
561+  sun_angle: { x: 0.3, y: -0.7, z: 0.3 },
562+  sun_color: { r: 1.0, g: 0.96, b: 0.85 },
563+  scale: 22000.0, // sun_color rgb -> lux scale 
564+  albedo_factor: z.materials[0].baseColorFactor,
565+  metallicFactor: z.materials[0].metallicFactor,
566+  roughnessFactor: z.materials[0].roughnessFactor,
567+  thin_lens: false,
568+};
569+// initialize values based on UNIFORMS, updates are createPane()
570+device.queue.writeBuffer(uniformBuffer0, 208, Vec3.fromValues(UNIFORMS.sun_angle.x, UNIFORMS.sun_angle.y, UNIFORMS.sun_angle.z));
571+device.queue.writeBuffer(uniformBuffer0, 220, new Float32Array([0.53 * (Math.PI / 180.0)])); // ~0.5332 degrees / 32.15 arcminutes
572+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));
573+device.queue.writeBuffer(uniformBuffer0, 236, new Float32Array([UNIFORMS.sample_count, UNIFORMS.bounce_count, UNIFORMS.aperture, UNIFORMS.focal_length]));
574+device.queue.writeBuffer(uniformBuffer0, 252, new Float32Array([emissiveTriangleIndices.length - 1, UNIFORMS.thin_lens ? 1 : 0]));
575+
576+let camera = createCamera(canvas);
577+InitPane(device, UNIFORMS, uniformBuffer0)
578+setupCameraInput(canvas)
579+
580+device.queue.writeBuffer(uniformBuffer0, 0, camera.position);
581+device.queue.writeBuffer(uniformBuffer0, 16, camera.view);
582+device.queue.writeBuffer(uniformBuffer0, 80, camera.inverseView);
583+device.queue.writeBuffer(uniformBuffer0, 144, camera.projection);
584+
585+let cpuStart = 0;
586+let cpuEnd = 0;
587+let frametimeMs;
588+const framedata = new Float32Array(1);
589+
590+const workgroupSize = 16;
591+const dispatchX = Math.ceil(width / workgroupSize);
592+const dispatchY = Math.ceil(height / workgroupSize);
593+
594+async function renderFrame() {
595+  cpuStart = performance.now();
596+  window.framecount++;
597+  framedata[0] = window.framecount;
598+  updateMovementInput();
599+  updateCamera(camera);
600+  if (camera.dirty) {
601+    window.framecount = 0; // reset accumulation
602+    device.queue.writeBuffer(uniformBuffer0, 0, camera.position);
603+    device.queue.writeBuffer(uniformBuffer0, 16, camera.view);
604+    device.queue.writeBuffer(uniformBuffer0, 80, camera.inverseView);
605+    device.queue.writeBuffer(uniformBuffer0, 144, camera.projection);
606+  }
607+  device.queue.writeBuffer(uniformBuffer0, 12, framedata);
608+  const commandEncoder = device.createCommandEncoder();
609+  // compute pass
610+  var computePass = commandEncoder.beginComputePass();
611+  computePass.setPipeline(kernelPipeline);
612+  computePass.setBindGroup(0, geometryBindgroup);
613+  computePass.setBindGroup(1, shadingBindGroup);
614+  computePass.dispatchWorkgroups(dispatchX, dispatchY);
615+  computePass.end();
616+  // blitt pass
617+  const renderPass = commandEncoder.beginRenderPass({
618+    label: "main",
619+    colorAttachments: [
620+      {
621+        view: context.getCurrentTexture().createView(),
622+        clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }, // rgba
623+        loadOp: "clear",
624+        storeOp: "store",
625+      },
626+    ],
627+  });
628+  renderPass.setPipeline(viewportPipeline);
629+  renderPass.setBindGroup(0, viewportBindgroup);
630+  renderPass.draw(6, 1, 0, 0);
631+  renderPass.end();
632+  device.queue.submit([commandEncoder.finish()]);
633+  device.queue.onSubmittedWorkDone().then(
634+    () => {
635+      cpuEnd = performance.now();
636+      frametimeMs = cpuEnd - cpuStart;
637+      frametime = parseInt(frametimeMs.toFixed(2));
638+      UNIFORMS.frameTimeMs = frametime;
639+    }
640+  );
641+  requestAnimationFrame(renderFrame);
642+}
643+
644+requestAnimationFrame(renderFrame);
A · src/pane.ts +142, -0
  1@@ -0,0 +1,142 @@
  2+import { Vec3 } from 'gl-matrix';
  3+import { Pane } from 'tweakpane';
  4+
  5+export const InitPane = (device: GPUDevice, UNIFORMS: any, uniformBuffer0: GPUBuffer) => {
  6+  const container = document.getElementById("pane-container") as HTMLElement;
  7+  const header = document.getElementById("pane-container-header") as HTMLElement;
  8+
  9+  const pane = new Pane({
 10+    container: container,
 11+  });
 12+  let offsetX = 0;
 13+  let offsetY = 0;
 14+  let isDragging = false;
 15+  header.addEventListener('mousedown', (e) => {
 16+    isDragging = true;
 17+    offsetX = e.clientX - container.offsetLeft;
 18+    offsetY = e.clientY - container.offsetTop;
 19+    container.style.cursor = 'move';
 20+  });
 21+  document.addEventListener('mousemove', (e) => {
 22+    if (isDragging) {
 23+      let x = e.clientX - offsetX;
 24+      let y = e.clientY - offsetY;
 25+
 26+      const viewportWidth = window.innerWidth;
 27+      const viewportHeight = window.innerHeight;
 28+
 29+      const containerWidth = container.offsetWidth;
 30+      const containerHeight = container.offsetHeight;
 31+
 32+      const minX = 0;
 33+      const maxX = viewportWidth - containerWidth;
 34+      const minY = 0;
 35+      const maxY = viewportHeight - containerHeight;
 36+
 37+      x = Math.max(minX, Math.min(x, maxX));
 38+      y = Math.max(minY, Math.min(y, maxY));
 39+
 40+      container.style.left = `${x}px`;
 41+      container.style.top = `${y}px`;
 42+    }
 43+  });
 44+  document.addEventListener('mouseup', () => {
 45+    isDragging = false;
 46+    container.style.cursor = 'default';
 47+  });
 48+
 49+  //packed vars: samp_count, bounce, aperture, focal_length]
 50+  let packed0 = new Float32Array([UNIFORMS.sample_count, UNIFORMS.bounce_count, UNIFORMS.aperture, UNIFORMS.focal_length])
 51+  pane.addBinding(UNIFORMS, 'frameTimeMs', {
 52+    readonly: true,
 53+    label: "Frame Time ",
 54+    view: 'number',
 55+    min: 0,
 56+    max: 120.00,
 57+  });
 58+  pane.addBinding(UNIFORMS, 'frameTimeMs', {
 59+    readonly: true,
 60+    label: "Frame Time Graph",
 61+    view: 'graph',
 62+    min: 0,
 63+    max: 120.00,
 64+  });
 65+  const cameraFolder = pane.addFolder({ title: 'Camera' })
 66+  cameraFolder.addBinding(UNIFORMS, 'thin_lens', {
 67+    label: "Enable thin lens approx",
 68+  }).on('change', (e) => {
 69+    device.queue.writeBuffer(uniformBuffer0, 256, new Float32Array([e.value]));
 70+    window.framecount = 0;
 71+  });
 72+  cameraFolder.addBinding(UNIFORMS, 'sample_count', {
 73+    view: 'slider',
 74+    type: 'number',
 75+    label: 'Sample Count',
 76+    min: 1,
 77+    max: 5,
 78+    value: 1,
 79+    step: 1,
 80+  }).on('change', (e) => {
 81+    packed0[0] = e.value;
 82+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
 83+    window.framecount = 0;
 84+  });
 85+  cameraFolder.addBinding(UNIFORMS, 'bounce_count', {
 86+    view: 'slider',
 87+    type: 'number',
 88+    label: 'Max Bounces',
 89+    min: 1,
 90+    max: 10,
 91+    value: 1,
 92+    step: 1,
 93+  }).on('change', (e) => {
 94+    packed0[1] = e.value;
 95+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
 96+    window.framecount = 0;
 97+  });
 98+  cameraFolder.addBinding(UNIFORMS, 'aperture', {
 99+    view: 'slider',
100+    type: 'number',
101+    label: 'Aperture',
102+    min: 0.1,
103+    max: 1,
104+    value: 0.1,
105+  }).on('change', (e) => {
106+    packed0[2] = e.value;
107+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
108+    window.framecount = 0;
109+  });
110+  cameraFolder.addBinding(UNIFORMS, 'focal_length', {
111+    view: 'slider',
112+    type: 'number',
113+    label: 'Focal Length',
114+    min: 1,
115+    max: 200,
116+    value: 1,
117+  }).on('change', (e) => {
118+    packed0[3] = e.value;
119+    device.queue.writeBuffer(uniformBuffer0, 236, packed0);
120+    window.framecount = 0;
121+  });
122+  const dirFolder = pane.addFolder({ title: 'Environment' });
123+  dirFolder.addBinding(UNIFORMS, 'sun_angle', {
124+    label: 'Sun Angle',
125+    x: { min: -1, max: 1, step: 0.1 },
126+    y: { min: -1, max: 1, step: 0.1 },
127+    z: { min: -1, max: 1, step: 0.1 },
128+  }).on('change', () => {
129+    device.queue.writeBuffer(uniformBuffer0, 208, Vec3.fromValues(UNIFORMS.sun_angle.x, UNIFORMS.sun_angle.y, UNIFORMS.sun_angle.z));
130+    window.framecount = 0;
131+  });
132+  dirFolder.addBinding(UNIFORMS, 'sun_color', {
133+    color: { type: 'float' },
134+    picker: 'inline',
135+    label: 'Sun Color',
136+    x: { min: -1, max: 1, step: 0.1 },
137+    y: { min: -1, max: 1, step: 0.1 },
138+    z: { min: -1, max: 1, step: 0.1 },
139+  }).on('change', (e) => {
140+    device.queue.writeBuffer(uniformBuffer0, 224, Vec3.fromValues(e.value.r * UNIFORMS.scale, e.value.g * UNIFORMS.scale, e.value.b * UNIFORMS.scale));
141+    window.framecount = 0;
142+  });
143+}
A · src/shaders/any_hit.wgsl +138, -0
  1@@ -0,0 +1,138 @@
  2+fn is_occluded(pos: vec3f, normal: vec3f, light_dir: vec3f, light_distance: f32) -> bool {
  3+    var shadow_ray: Ray;
  4+    shadow_ray.origin = offset_ray(pos, normal);
  5+    shadow_ray.direction = light_dir;
  6+    var shadow_hit = trace_any(shadow_ray, light_distance);
  7+    return shadow_hit;
  8+}
  9+
 10+fn trace_any(ray: Ray, t_max: f32) -> bool {
 11+    var node_idx_stack: array<u32, 64>;
 12+    var stack_ptr: i32 = 0;
 13+    var current_node_idx: u32 = 0;
 14+
 15+    while (true) {
 16+        let node = node_tree.nodes[current_node_idx];
 17+
 18+        let primitive_count = u32(node.primitive_count);
 19+        let child_or_prim_idx = u32(node.left_child);
 20+        if (primitive_count == 0u) {
 21+            // internal node
 22+            let left_child_idx = child_or_prim_idx;
 23+            let right_child_idx = child_or_prim_idx + 1u;
 24+
 25+            // use t_max for pruning
 26+            let hit1 = any_hit_aabb(ray, node_tree.nodes[left_child_idx].min_corner, node_tree.nodes[left_child_idx].max_corner, t_max);
 27+            let hit2 = any_hit_aabb(ray, node_tree.nodes[right_child_idx].min_corner, node_tree.nodes[right_child_idx].max_corner, t_max);
 28+
 29+            var near_child_idx = left_child_idx;
 30+            var far_child_idx = right_child_idx;
 31+            var dist1_hit = hit1;
 32+            var dist2_hit = hit2;
 33+
 34+            if (!hit1 && hit2) {
 35+                near_child_idx = right_child_idx;
 36+                far_child_idx = left_child_idx;
 37+                dist1_hit = hit2;
 38+                dist2_hit = hit1;
 39+            }
 40+
 41+            if (dist1_hit) {
 42+                current_node_idx = near_child_idx;
 43+                if (dist2_hit) {
 44+                    if (stack_ptr >= 64) {
 45+                        break;
 46+                    }
 47+                    // overflow
 48+                    node_idx_stack[stack_ptr] = far_child_idx;
 49+                    stack_ptr += 1;
 50+                }
 51+                continue;
 52+                // descend into near child
 53+            }
 54+            // neither child is relevant, fall through to pop
 55+
 56+        }
 57+        else {
 58+            // leaf node
 59+            for (var i = 0u; i < primitive_count; i += 1u) {
 60+                let prim_index = tri_lut.primitive_indices[child_or_prim_idx + i];
 61+                let triangle = objects.triangles[i32(prim_index)];
 62+
 63+                // any_hit_triangle returns true if hit within range
 64+                if (any_hit_triangle(ray, triangle, 0.001, t_max)) {
 65+                    return true;
 66+                    // found an occlusion, exit immediately
 67+                }
 68+            }
 69+            // finished leaf without finding occlusion, fall through to pop
 70+        }
 71+
 72+        // pop from stack or break if empty
 73+        if (stack_ptr == 0) {
 74+            break;
 75+            // traversal finished without finding occlusion
 76+        }
 77+        else {
 78+            stack_ptr -= 1;
 79+            current_node_idx = node_idx_stack[stack_ptr];
 80+        }
 81+    }
 82+    // kill sunlight 0.0
 83+    let floor_z = 0.0;
 84+    let denom = ray.direction.z;
 85+    if (abs(denom) > 1e-6) {
 86+        let t = (floor_z - ray.origin.z) / denom;
 87+        if (t > 0.001 && t < t_max) {
 88+            return true;
 89+            // hit floor within range
 90+        }
 91+    }
 92+    return false;
 93+    // no occlusion found
 94+}
 95+
 96+fn any_hit_aabb(ray: Ray, aabb_min: vec3f, aabb_max: vec3f, t_max: f32) -> bool {
 97+    var inverse_dir: vec3<f32> = vec3(1.0) / ray.direction;
 98+    var tmin = (aabb_min - ray.origin) * inverse_dir;
 99+    var tmax = (aabb_max - ray.origin) * inverse_dir;
100+    var t1 = min(tmin, tmax);
101+    var t2 = max(tmin, tmax);
102+    var t_near = max(max(t1.x, t1.y), t1.z);
103+    var t_far = min(min(t2.x, t2.y), t2.z);
104+    return t_near <= t_far && t_far >= 0.001 && t_near <= t_max;
105+}
106+
107+// lazy, just clean this up to only use hit_triangle and move all shading data post hit out
108+fn any_hit_triangle(ray: Ray, tri: Triangle, dist_min: f32, dist_max: f32) -> bool {
109+    let edge1 = tri.corner_b - tri.corner_a;
110+    let edge2 = tri.corner_c - tri.corner_a;
111+
112+    let pvec = cross(ray.direction, edge2);
113+    let determinant = dot(edge1, pvec);
114+
115+    // reject nearly parallel rays.
116+    if abs(determinant) < EPSILON {
117+        return false;
118+    }
119+
120+    let inv_det = 1.0 / determinant;
121+    let tvec = ray.origin - tri.corner_a;
122+
123+    // compute barycentric coordinate u.
124+    let u = dot(tvec, pvec) * inv_det;
125+    if (u < 0.0 || u > 1.0) {
126+        return false;
127+    }
128+
129+    // compute barycentric coordinate v.
130+    let qvec = cross(tvec, edge1);
131+    let v = dot(ray.direction, qvec) * inv_det;
132+    if (v < 0.0 || (u + v) > 1.0) {
133+        return false;
134+    }
135+
136+    // calculate ray parameter (distance).
137+    let dist = dot(edge2, qvec) * inv_det;
138+    return dist > dist_min && dist < dist_max;
139+}
A · src/shaders/brdf.wgsl +154, -0
  1@@ -0,0 +1,154 @@
  2+// computes the geometry (shadowing/masking) term using smith's method.
  3+fn smith_geometry(normal: vec3<f32>, view_dir: vec3<f32>, light_dir: vec3<f32>, roughness: f32) -> f32 {
  4+    let alpha = roughness * roughness;
  5+    let n_dot_v = max(dot(normal, view_dir), 0.0);
  6+    let n_dot_l = max(dot(normal, light_dir), 0.0);
  7+    let k = (alpha + 1.0) * (alpha + 1.0) / 8.0;
  8+    let geom_v = n_dot_v / (n_dot_v * (1.0 - k) + k);
  9+    let geom_l = n_dot_l / (n_dot_l * (1.0 - k) + k);
 10+    return geom_v * geom_l;
 11+}
 12+
 13+// computes the ggx normal distribution function (ndf) d.
 14+fn ggx_distribution(normal: vec3<f32>, half_vec: vec3<f32>, roughness: f32) -> f32 {
 15+    let rgh = max(0.03, roughness);
 16+    let alpha = rgh * rgh;
 17+    let alpha2 = alpha * alpha;
 18+    let n_dot_h = max(dot(normal, half_vec), 0.001);
 19+    let denom = (n_dot_h * n_dot_h) * (alpha2 - 1.0) + 1.0;
 20+    return alpha2 / (PI * denom * denom);
 21+}
 22+
 23+// samples a half–vector (h) from the ggx distribution in tangent space.
 24+fn ggx_sample_vndf(view_dir: vec3<f32>, normal: vec3<f32>, roughness: f32, noise: vec2<f32>) -> vec3<f32> {
 25+    // build local frame (t, b, n)
 26+    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);
 27+    let tangent = normalize(cross(up, normal));
 28+    let bitangent = cross(normal, tangent);
 29+
 30+    // transform view direction to local space
 31+    let v = normalize(vec3<f32>(dot(view_dir, tangent), dot(view_dir, bitangent), dot(view_dir, normal)));
 32+
 33+    // stretch view vector
 34+    let a = roughness;
 35+    let vh = normalize(vec3<f32>(a * v.x, a * v.y, v.z));
 36+
 37+    // orthonormal basis
 38+    let lensq = vh.x * vh.x + vh.y * vh.y;
 39+    let t1 = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(- vh.y, vh.x, 0.0) / sqrt(lensq), lensq > 1e-6);
 40+    let t2 = cross(vh, t1);
 41+
 42+    // sample point with polar coordinates
 43+    let r = sqrt(noise.x);
 44+    let phi = 2.0 * PI * noise.y;
 45+    let r1 = r * cos(phi);
 46+    let r2 = r * sin(phi);
 47+    let s = 0.5 * (1.0 + vh.z);
 48+    let t2_ = mix(sqrt(1.0 - r1 * r1), r2, s);
 49+
 50+    // sampled halfway vector in local space
 51+    let nh = r1 * t1 + t2_ * t2 + sqrt(max(0.0, 1.0 - r1 * r1 - t2_ * t2_)) * vh;
 52+
 53+    // unstretch
 54+    let h = normalize(vec3<f32>(a * nh.x, a * nh.y, max(0.0, nh.z)));
 55+
 56+    // transform back to world space
 57+    return normalize(h.x * tangent + h.y * bitangent + h.z * normal);
 58+}
 59+
 60+fn ggx_specular_sample(view_dir: vec3<f32>, normal: vec3<f32>, seed: vec2<f32>, roughness: f32) -> vec3<f32> {
 61+    let h = ggx_sample_vndf(view_dir, normal, roughness, seed);
 62+    let r = reflect(- view_dir, h);
 63+    return select(normalize(r), vec3<f32>(0.0), dot(r, normal) <= EPSILON);
 64+}
 65+
 66+// cosine-weighted hemisphere sample in local space, then converted to world space.
 67+fn cosine_hemisphere_sample(normal: vec3<f32>, noise: vec2<f32>) -> vec3<f32> {
 68+    // var current_seed = seed;
 69+    let r1 = noise.x;
 70+    let r2 = noise.y;
 71+
 72+    // let r1 = uniform_float(state);
 73+    // let r2 = uniform_float(state);
 74+
 75+    let phi = 2.0 * PI * r2;
 76+    let cos_theta = sqrt(1.0 - r1);
 77+    let sin_theta = sqrt(r1);
 78+
 79+    // sample in local coordinates (with z as the normal)
 80+    let local_sample = vec3<f32>(sin_theta * cos(phi), sin_theta * sin(phi), cos_theta);
 81+
 82+    // build tangent space and transform sample to world space.
 83+    // let up = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.z) > 0.999);
 84+
 85+    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));
 86+    let bitangent = cross(normal, tangent);
 87+
 88+    // let tangent = normalize(cross(up, normal));
 89+    // let bitangent = cross(normal, tangent);
 90+    let world_dir = normalize(local_sample.x * tangent + local_sample.y * bitangent + local_sample.z * normal);
 91+    return world_dir;
 92+}
 93+
 94+fn eval_f0(metallic: f32, albedo: vec3<f32>) -> vec3<f32> {
 95+    let dielectric_f0 = vec3<f32>(0.04);
 96+    return mix(dielectric_f0, albedo, metallic);
 97+}
 98+
 99+fn fresnel_schlick_roughness(cos_theta: f32, f0: vec3<f32>, roughness: f32) -> vec3<f32> {
100+    let one_minus_cos = 1.0 - cos_theta;
101+    let factor = pow(one_minus_cos, 5.0);
102+    let fresnel = f0 + (max(vec3f(1.0 - roughness), f0) - f0) * factor;
103+    return fresnel;
104+}
105+
106+fn disney_diffuse(albedo: vec3<f32>, roughness: f32, n_dot_l: f32, n_dot_v: f32, l_dot_h: f32) -> vec3<f32> {
107+    let fd90 = 0.5 + 2.0 * l_dot_h * l_dot_h * roughness;
108+    let light_scatter = 1.0 + (fd90 - 1.0) * pow(1.0 - n_dot_l, 5.0);
109+    let view_scatter = 1.0 + (fd90 - 1.0) * pow(1.0 - n_dot_v, 5.0);
110+    return albedo * light_scatter * view_scatter * (1.0 / PI);
111+}
112+
113+fn eval_brdf(normal: vec3<f32>, view_dir: vec3<f32>, light_dir: vec3<f32>, material: Material) -> vec3<f32> {
114+    let n = normal;
115+    let v = view_dir;
116+    let l = light_dir;
117+    let h = normalize(v + l);
118+
119+    let ndot_l = max(dot(n, l), EPSILON);
120+    let ndot_v = max(dot(n, v), EPSILON);
121+    let ndot_h = max(dot(n, h), EPSILON);
122+    let vdot_h = max(dot(v, h), EPSILON);
123+    let ldot_h = max(dot(l, h), EPSILON);
124+
125+    let f0 = mix(vec3f(0.04), material.albedo.rgb, material.metallic);
126+    let f = fresnel_schlick_roughness(vdot_h, f0, material.roughness);
127+
128+    // frostbite specular
129+    let d = ggx_distribution(n, h, material.roughness);
130+    let g = smith_geometry(n, v, l, material.roughness);
131+    let spec = (d * g * f) / (4.0 * ndot_v * ndot_l + 0.001);
132+
133+    let diffuse = (1.0 - material.metallic) * disney_diffuse(material.albedo.rgb, material.roughness, ndot_l, ndot_v, ldot_h);
134+    return diffuse + spec;
135+}
136+
137+fn ggx_pdf(view_dir: vec3<f32>, normal: vec3<f32>, h: vec3<f32>, roughness: f32) -> f32 {
138+    let d = ggx_distribution(normal, h, roughness);
139+    let n_dot_h = max(dot(normal, h), EPSILON);
140+    let v_dot_h = max(dot(view_dir, h), EPSILON);
141+    // change–of–variables: pdf(r) = d(h) * n_dot_h / (4 * v_dot_h)
142+    return (d * n_dot_h) / (4.0 * v_dot_h);
143+}
144+
145+fn cosine_pdf(normal: vec3<f32>, dir: vec3<f32>) -> f32 {
146+    // cosine weighted density: d = cos(theta) / PI.
147+    let cos_theta = max(dot(normal, dir), EPSILON);
148+    if cos_theta <= 0.0 {
149+        return 0.0;
150+        // no contribution when direction is opposite or perpendicular to the normal.
151+    }
152+    return cos_theta / PI;
153+}
154+
155+
A · src/shaders/main.wgsl +593, -0
  1@@ -0,0 +1,593 @@
  2+#include random.wgsl
  3+#include brdf.wgsl
  4+#include sky.wgsl
  5+#include any_hit.wgsl
  6+#include utils.wgsl
  7+
  8+@group(0) @binding(0) var output_buffer : texture_storage_2d<rgba32float, write>;
  9+@group(0) @binding(1) var<storage, read> objects: Objects;
 10+@group(0) @binding(2) var<uniform> uniforms: UniformLayout;
 11+@group(0) @binding(5) var<storage, read> node_tree: BVH;
 12+@group(0) @binding(6) var<storage, read> tri_lut: ObjectIndices;
 13+@group(0) @binding(7) var<storage, read_write> input_buffer:array<vec3f>;
 14+
 15+@group(1) @binding(0) var<storage, read> materials:array<Material>;
 16+@group(1) @binding(1) var textures: texture_2d_array<f32>;
 17+@group(1) @binding(2) var t_sampler: sampler;
 18+@group(1) @binding(4) var<uniform> textureSizes: array<vec4<f32>, 128>;
 19+@group(1) @binding(6) var blueNoiseTexture : texture_storage_2d<rgba8unorm, read>;
 20+@group(1) @binding(7) var<storage, read> emissiveTriangleIndices : array<f32>;
 21+
 22+// @group(0) @binding(3) var skybox: texture_2d<f32>;
 23+// @group(0) @binding(4) var skybox_sampler: sampler;
 24+// @group(1) @binding(5) var skyboxCDF: texture_storage_2d<rg32float, read>;
 25+// @group(1) @binding(3) var<storage, read> areaLights:array<AreaLight>;
 26+
 27+struct Triangle {
 28+  corner_a: vec3<f32>,
 29+  corner_b: vec3<f32>,
 30+  corner_c: vec3<f32>,
 31+  normal_a: vec3<f32>,
 32+  normal_b: vec3<f32>,
 33+  normal_c: vec3<f32>,
 34+  material_idx: f32,
 35+  uv_a: vec2<f32>,
 36+  uv_b: vec2<f32>,
 37+  uv_c: vec2<f32>,
 38+  tangent_a: vec4f,
 39+  tangent_b: vec4f,
 40+  tangent_c: vec4f,
 41+}
 42+
 43+// struct AreaLight {
 44+//   center: vec3<f32>,   
 45+//   u: vec3<f32>,         
 46+//   v: vec3<f32>,    
 47+//   normal: vec3<f32>,
 48+//   emission: vec3<f32>, 
 49+// };
 50+
 51+struct Ray {
 52+  direction: vec3<f32>,
 53+  origin: vec3<f32>,
 54+}
 55+
 56+struct HitInfo {
 57+  dist: f32,
 58+  hit: bool,
 59+  position: vec3<f32>,
 60+  normal: vec3<f32>, 
 61+  material_idx: i32,
 62+  geo_normal: vec3f,
 63+  tri: Triangle,
 64+  uv: vec2f,
 65+  tangent: vec3<f32>,
 66+  bitangent: vec3<f32>,
 67+}
 68+
 69+struct UniformLayout {
 70+  position: vec3<f32>,
 71+  frame_idx: f32,
 72+  view: mat4x4<f32>,    
 73+  inverse_view: mat4x4<f32>,
 74+  projection: mat4x4<f32>,
 75+  sun_direction: vec3<f32>,
 76+  sun_angular_size: f32,
 77+  sun_radiance: vec3<f32>,
 78+  sample_count: f32,
 79+  max_depth: f32,
 80+  aperture: f32,
 81+  focus_distance: f32,
 82+  emissive_triangle_count: f32,
 83+  thin_lens: f32,
 84+}
 85+
 86+struct Node {
 87+    min_corner: vec3<f32>,
 88+    left_child: f32,
 89+    max_corner: vec3<f32>,
 90+    primitive_count: f32,
 91+}
 92+
 93+struct BVH {
 94+    nodes: array<Node>,
 95+}
 96+
 97+struct ObjectIndices {
 98+    primitive_indices: array<f32>,
 99+}
100+
101+struct Objects {
102+  triangles: array<Triangle>,
103+}
104+
105+struct Material {
106+    albedo: vec4<f32>,  
107+    metallic: f32,
108+    alpha_mode: f32, 
109+    alpha_cutoff: f32, 
110+    double_sided: f32,
111+    emission: vec3<f32>,
112+    roughness: f32,
113+    base_color_texture: f32,
114+    normal_texture: f32,
115+    metallic_roughness_texture: f32,
116+    emissive_texture: f32,
117+}
118+
119+
120+
121+const EPSILON :f32 = 0.00001f;
122+const PI :f32 = 3.1415927f;
123+// ray tracing gems part 1 chapter 6
124+const FLOAT_SCALE = 1.0 / 65536.0;
125+const INT_SCALE = 256.0;
126+const ORIGIN = 1.0 / 32.0;
127+
128+// Slightly offsets a ray to prevent self intersection artifacts
129+// Ray tracing gems part 1 chapter 6
130+fn offset_ray(p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
131+    let of_i = vec3<i32>(
132+        i32(INT_SCALE * n.x),
133+        i32(INT_SCALE * n.y),
134+        i32(INT_SCALE * n.z)
135+    );
136+
137+    let p_i = vec3<f32>(
138+        int_to_float(float_to_int(p.x) + select(of_i.x, -of_i.x, p.x < 0.0)),
139+        int_to_float(float_to_int(p.y) + select(of_i.y, -of_i.y, p.y < 0.0)),
140+        int_to_float(float_to_int(p.z) + select(of_i.z, -of_i.z, p.z < 0.0))
141+    );
142+
143+    return vec3<f32>(
144+        select(p.x + FLOAT_SCALE * n.x, p_i.x, abs(p.x) >= ORIGIN),
145+        select(p.y + FLOAT_SCALE * n.y, p_i.y, abs(p.y) >= ORIGIN),
146+        select(p.z + FLOAT_SCALE * n.z, p_i.z, abs(p.z) >= ORIGIN)
147+    );
148+}
149+
150+fn sample_material_texture(uv: vec2<f32>, texture_index: u32) -> vec4<f32> {
151+    let tex_size = textureSizes[texture_index].xy;
152+    let max_tex_size = vec2<f32>(textureDimensions(textures).xy);
153+    // let scaled_uv = uv * tex_size / max_tex_size;
154+    // let clamped_uv = clamp(scaled_uv, vec2<f32>(0.0), vec2<f32>(1.0));
155+    // compute the valid uv bounds inside the texture array
156+    let tex_uv_min = vec2<f32>(0.0); // always starts at (0,0)
157+    let tex_uv_max = tex_size / max_tex_size; // upper-right boundary in the atlas
158+    // remap u_vs to this valid range
159+    let mapped_uv = mix(tex_uv_min, tex_uv_max, uv);
160+    return textureSampleLevel(textures, t_sampler, mapped_uv, texture_index, 1.0).rgba;
161+}
162+
163+
164+fn parse_textures(curr_material: Material, result: HitInfo) -> Material {
165+    var material = curr_material;
166+    if material.base_color_texture > -1.0 {
167+        material.albedo *= sample_material_texture(result.uv, u32(curr_material.base_color_texture)).rgba;
168+    }
169+    if material.metallic_roughness_texture > -1.0 {
170+        let metallic_roughness_texture = sample_material_texture(result.uv, u32(curr_material.metallic_roughness_texture));
171+        material.roughness *= metallic_roughness_texture.g;
172+        material.metallic *= metallic_roughness_texture.b;
173+    }
174+    if material.emissive_texture > -1.0 {
175+        material.emission = sample_material_texture(result.uv, u32(curr_material.emissive_texture)).rgb;
176+    }
177+    return material;
178+}
179+
180+
181+fn point_in_unit_disk(u: vec2f) -> vec2f {
182+    let r = sqrt(u.x);
183+    let theta = 2f * PI * u.y;
184+    return vec2f(r * cos(theta), r * sin(theta));
185+}
186+
187+fn generate_pinhole_camera_ray(ndc: vec2<f32>, noise: vec2f) -> Ray {
188+    var ray : Ray;
189+    let aspect = uniforms.projection[1][1] / uniforms.projection[0][0]; // same as 1/tan_half_fov_y divided by 1/tan_half_fov_x
190+    let tan_half_fov_y = 1.0 / uniforms.projection[1][1];
191+
192+    let x = ndc.x * aspect * tan_half_fov_y;
193+    let y = ndc.y * tan_half_fov_y;
194+
195+    // camera basis vectors from the view matrix
196+    let right   =  uniforms.inverse_view[0].xyz;
197+    let up      =  uniforms.inverse_view[1].xyz;
198+    let forward = -uniforms.inverse_view[2].xyz;
199+    let origin  = uniforms.position;
200+
201+    let pinhole_dir = normalize(x * right + y * up + forward);
202+
203+    let focus_dist = uniforms.focus_distance;
204+    let aperture  = uniforms.aperture;
205+    let focus_point = origin + pinhole_dir * focus_dist;
206+    
207+    // sample lens (in local right-up plane)
208+    let lens_sample = point_in_unit_disk(noise) * aperture;
209+    let lens_offset = lens_sample.x * right + lens_sample.y * up;
210+
211+    if (uniforms.thin_lens == 0.0){
212+         ray.origin = origin;
213+         ray.direction = pinhole_dir;
214+    } else {
215+        ray.origin = origin + lens_offset;
216+        ray.direction = normalize(focus_point - ray.origin);
217+    }
218+    return ray;
219+}
220+
221+
222+@compute @workgroup_size(16, 16)
223+fn main(
224+    @builtin(global_invocation_id) GlobalInvocationID: vec3<u32>,
225+    @builtin(local_invocation_id) LocalInvocationID: vec3<u32>,
226+    @builtin(workgroup_id) GroupIndex: vec3<u32>) {
227+    // https://www.w3.org/TR/webgpu/#coordinate-systems
228+    let output_dimension: vec2<i32> = vec2<i32>(textureDimensions(output_buffer));
229+    let pixel_position: vec2<i32> = vec2<i32>(i32(GlobalInvocationID.x), i32(GlobalInvocationID.y));
230+    let pixel_idx: i32 = pixel_position.y * output_dimension.x + pixel_position.x;
231+    
232+    let pixel_center: vec2<f32> = vec2<f32>(pixel_position) + vec2f(0.5);
233+    let uv: vec2<f32> = pixel_center / vec2f(output_dimension);
234+    let ndc: vec2<f32> = uv * 2.0 - vec2f(1.0);
235+
236+    let noise = animated_blue_noise(pixel_position, u32(uniforms.frame_idx), u32(64)); 
237+    var rnd_state = u32(0);
238+    init_random(&rnd_state, u32(uniforms.frame_idx));
239+    init_random(&rnd_state, u32(pixel_position.x));
240+    init_random(&rnd_state, u32(pixel_position.y));
241+
242+    let jitter_scale: f32 = 1;   
243+    // Apply blue noise instead of uniformFloat
244+    let jitter_x: f32 = (noise.x - 0.5) / f32(output_dimension.x) * jitter_scale;
245+    let jitter_y: f32 = (noise.y - 0.5) / f32(output_dimension.y) * jitter_scale;
246+    
247+    let n2 = (ndc.x + jitter_x);
248+    let n3 = ndc.y + jitter_y;
249+    let ray = generate_pinhole_camera_ray(vec2f(n2, n3), noise);
250+
251+    var accumulated_color: vec3<f32> = vec3<f32>(0.0);
252+    let frame_weight: f32 = 1.0 / (uniforms.frame_idx + 1);
253+    let samples_per_pixel: i32 = i32(uniforms.sample_count);
254+    for (var i: i32 = 0; i < samples_per_pixel; i ++) {
255+        var pixel_color: vec3<f32> = shade_hit(ray, rnd_state, noise);
256+        var r = pixel_color.x;
257+        var g = pixel_color.y;
258+        var b = pixel_color.z;
259+        // lazy NaN catching
260+        if (r != r){ pixel_color.r = 0.0;};
261+        if (g != g){ pixel_color.g = 0.0;};
262+        if (b != b){ pixel_color.b = 0.0;};
263+        accumulated_color += pixel_color;
264+    }
265+    
266+    accumulated_color = accumulated_color / f32(samples_per_pixel);
267+    var prev_color: vec3<f32> = input_buffer[pixel_idx];
268+    var final_output : vec3f = (prev_color * uniforms.frame_idx + accumulated_color) / (uniforms.frame_idx + 1.0);
269+    input_buffer[pixel_idx] = final_output;
270+    textureStore(output_buffer, pixel_position, vec4f(final_output, 1.0));
271+}
272+
273+fn trace(ray: Ray) -> HitInfo {
274+    var render_state: HitInfo;
275+    render_state.hit = false;
276+    var nearest_hit: f32 = 999.0;
277+
278+    // set up for bvh traversal
279+    var node: Node = node_tree.nodes[0];
280+    var stack: array<Node, 32>;
281+    var stack_location: i32 = 0;
282+
283+    while true {
284+        var primitive_count: u32 = u32(node.primitive_count);
285+        var contents: u32 = u32(node.left_child);
286+
287+        if primitive_count == 0 {
288+            var child1: Node = node_tree.nodes[contents];
289+            var child2: Node = node_tree.nodes[contents + 1];
290+
291+            var distance1: f32 = hit_aabb(ray, child1);
292+            var distance2: f32 = hit_aabb(ray, child2);
293+
294+            if distance1 > distance2 {
295+                var temp_dist: f32 = distance1;
296+                distance1 = distance2;
297+                distance2 = temp_dist;
298+
299+                var temp_child: Node = child1;
300+                child1 = child2;
301+                child2 = temp_child;
302+            }
303+
304+            if distance1 > nearest_hit {
305+                if stack_location == 0 {
306+                    break;
307+                } else {
308+                    stack_location -= 1;
309+                    node = stack[stack_location];
310+                }
311+            } else {
312+                node = child1;
313+                if distance1 < nearest_hit {
314+                    stack[stack_location] = child2;
315+                    stack_location += 1;
316+                }
317+            }
318+        } else {
319+            for (var i: u32 = 0; i < primitive_count; i++) {
320+                var new_render_state: HitInfo = hit_triangle(
321+                    ray,
322+                    objects.triangles[u32(tri_lut.primitive_indices[i + contents])],
323+                    0.001,
324+                    nearest_hit,
325+                    render_state,
326+                );
327+                if new_render_state.hit {
328+                    nearest_hit = new_render_state.dist;
329+                    render_state = new_render_state;
330+                }
331+            }
332+            if stack_location == 0 {
333+                break;
334+            } else {
335+                stack_location -= 1;
336+                node = stack[stack_location];
337+            }
338+        }
339+    }
340+    return render_state;
341+}
342+
343+fn shade_hit(ray: Ray, seed: u32, noise: vec2f) -> vec3<f32> {
344+    var current_seed = seed;
345+    var radiance = vec3f(0.0);
346+    var throughput = vec3f(1.0);
347+    var result: HitInfo;
348+
349+    var temp_ray = ray;
350+    let bounces: u32 = u32(uniforms.max_depth);
351+
352+    var pdf: f32;
353+    var env_pdf: f32;
354+    var mis_weight : f32 = 1.0;
355+
356+    var sun_solid_angle = 2.0 * PI * (1.0 - cos(uniforms.sun_angular_size));
357+    let sun_pdf = 1.0 / sun_solid_angle; 
358+    let sky_pdf = 1.0 / PI;
359+
360+    for (var bounce: u32 = 0; bounce < bounces; bounce++) {
361+        result = trace(temp_ray);
362+        if (!result.hit) {
363+            // We hit the environment; skip the sun for now. Atleast till this rudimentry temporal accmulation exists. 
364+            // let to_sun = dot(temp_ray.direction, uniforms.sun_direction) > cos(uniforms.sun_angular_size);            
365+            // let sun_radiance = sun_glow(temp_ray.direction, uniforms.sun_direction);
366+            // if (to_sun) {
367+            //  radianceOut += sun_radiance;
368+            // }
369+            // if (to_sun) {
370+            //  env_pdf_eval = 0.5 * sun_pdf;
371+            // }
372+            let viewZenith = abs(temp_ray.direction.z);
373+            let extinction = exp(-2.0 * pow(1.0 - viewZenith, 3.0));
374+            let skyRadiance = sky_glow(temp_ray.direction, uniforms.sun_direction) * extinction;
375+            let radianceOut = skyRadiance;
376+            if (bounce == 0) {
377+                radiance += throughput * radianceOut;
378+                break;
379+            }
380+            // bsdf generated ray carries the PDF forward to this bounce
381+            var env_pdf_eval = 0.5 * sky_pdf;
382+            let env_mis_weight = pdf / (pdf + env_pdf_eval);
383+            radiance += clamp_hdr(throughput * radianceOut * env_mis_weight, 10.0);
384+            break;
385+        }
386+
387+        let rand = vec2f(uniform_float(&current_seed), uniform_float(&current_seed));
388+        var material: Material = parse_textures(materials[result.material_idx], result);
389+        if (material.emission.x > 0.0 || material.emission.y > 0.0 || material.emission.z > 0.0) {
390+            radiance += throughput * material.emission;
391+            // break;
392+        }
393+
394+        // sun nee, mis weight based on prior bounce brdf 
395+        let env_dir = sample_sun_cone_dir(rand);
396+        let env_color = sun_glow(env_dir, uniforms.sun_direction);
397+        let env_pdf = sun_pdf;
398+        let n_dot_env = dot(result.normal, env_dir);
399+        if (n_dot_env > 0.0 && !is_occluded(result.position, result.geo_normal, env_dir, 99999.9)) {
400+            let env_brdf = eval_brdf(result.normal, -temp_ray.direction, env_dir, material);
401+            let diffuse_density = cosine_pdf(result.normal, env_dir);
402+            let specular_density = ggx_pdf(-temp_ray.direction, result.normal, normalize(-temp_ray.direction + env_dir), material.roughness);
403+            let bsdf_pdf = 0.5 * specular_density + 0.5 * diffuse_density;
404+            let weight = env_pdf / (env_pdf + bsdf_pdf);
405+            radiance += clamp_hdr(throughput * env_brdf * env_color * n_dot_env * weight / env_pdf, 10.0);
406+        }
407+
408+        // TODO: Better selection, and also move this out.
409+        // emissive nee, uniformly sample emissives
410+        let light_index = min(u32(floor(rand.x * f32(uniforms.emissive_triangle_count))), u32(uniforms.emissive_triangle_count - 1.0));
411+        let tri_index = emissiveTriangleIndices[light_index];
412+        let tri = objects.triangles[i32(tri_index)];
413+        // uniformly sample point on triangle
414+        let u = 1.0 - rand.x;
415+        let v = rand.x * (1.0 - rand.y);
416+        let w = rand.x * rand.y;
417+        let light_pos = u * tri.corner_a + v * tri.corner_b + w * tri.corner_c;
418+        let light_normal =  normalize(cross(tri.corner_b - tri.corner_a, tri.corner_c - tri.corner_a));
419+        let to_light = light_pos - result.position;
420+        let dist2 = dot(to_light, to_light);
421+        let dist = sqrt(dist2);
422+        let light_dir = to_light / dist;
423+        let cos_surf = dot(result.normal, light_dir);
424+        let cos_light = dot(light_normal, -light_dir);
425+
426+        if (cos_surf > 0.0 && cos_light > 0.0 && !is_occluded(result.position, result.geo_normal, light_dir, dist)) {
427+            var mat = materials[i32(tri.material_idx)];
428+            let direct_light_emissive_brdf = eval_brdf(result.normal, -temp_ray.direction, light_dir, material);
429+            // compute area of the triangle
430+            let edge1 = tri.corner_b - tri.corner_a;
431+            let edge2 = tri.corner_c - tri.corner_a;
432+            let area = 0.5 * length(cross(edge1, edge2));
433+            let light_power = area * mat.emission;
434+            // area to solid angle PDF conversion
435+            let pdf_solid_angle = dist2 / ( area);
436+
437+            let diffuse_pdf = cosine_pdf(result.normal, light_dir);
438+            let specular_pdf = ggx_pdf(-temp_ray.direction, result.normal, normalize(-temp_ray.direction + light_dir), material.roughness);
439+            let bsdf_pdf = 0.5 * diffuse_pdf + 0.5 * specular_pdf;
440+            let mis_weight = pdf_solid_angle / (pdf_solid_angle + bsdf_pdf);
441+            let contrib = (throughput * direct_light_emissive_brdf * light_power * mis_weight) / pdf_solid_angle;
442+            radiance += clamp_hdr(contrib, 10.0);
443+        }
444+        
445+        // rr
446+        if (bounce > u32(2)) {
447+            let rrProbability = min(0.9, luminance(throughput));
448+            if (rrProbability < rand.y) {
449+                break;
450+            } else {
451+                throughput /= rrProbability;
452+            }
453+        }
454+
455+        var view_dir = -temp_ray.direction;
456+        var new_dir: vec3<f32>;
457+        var specular_density: f32;
458+        var diffuse_density: f32;
459+
460+        if (uniform_float(&current_seed) < 0.5) {
461+            new_dir = ggx_specular_sample(view_dir, result.normal, rand, material.roughness);
462+        } else {
463+            new_dir = cosine_hemisphere_sample(result.normal, vec2f(rand.y, rand.x));
464+        }
465+        let n_dot_l = dot(result.normal, new_dir);
466+        if (n_dot_l <= 0.0) { break; }
467+        specular_density = ggx_pdf(view_dir, result.normal, normalize(view_dir + new_dir), material.roughness);
468+        diffuse_density = cosine_pdf(result.normal, normalize(new_dir));
469+        pdf = 0.5  * specular_density + 0.5 * diffuse_density;
470+        
471+        let indirect_brdf = eval_brdf(result.normal, view_dir, new_dir, material);
472+        throughput *= (indirect_brdf * n_dot_l) / pdf;
473+        
474+        temp_ray.origin = offset_ray(result.position, result.geo_normal);
475+        temp_ray.direction = new_dir;
476+    }
477+
478+    return radiance;
479+}
480+
481+fn hit_triangle(ray: Ray, tri: Triangle, dist_min: f32, dist_max: f32, prevRay: HitInfo) -> HitInfo {
482+    var hit: HitInfo;
483+    hit.hit = false;
484+    
485+    let edge1 = tri.corner_b - tri.corner_a;
486+    let edge2 = tri.corner_c - tri.corner_a;
487+    
488+    let pvec = cross(ray.direction, edge2);
489+    let determinant = dot(edge1, pvec);
490+    
491+    // reject nearly parallel rays.
492+    if abs(determinant) < EPSILON {
493+        return hit;
494+    }
495+    
496+    let inv_det = 1.0 / determinant;
497+    let tvec = ray.origin - tri.corner_a;
498+    
499+    // compute barycentric coordinate u.
500+    let u = dot(tvec, pvec) * inv_det;
501+    if (u < 0.0 || u > 1.0) {
502+        return hit;
503+    }
504+    
505+    // compute barycentric coordinate v.
506+    let qvec = cross(tvec, edge1);
507+    let v = dot(ray.direction, qvec) * inv_det;
508+    if (v < 0.0 || (u + v) > 1.0) {
509+        return hit;
510+    }
511+    
512+    // calculate ray parameter (distance).
513+    let dist = dot(edge2, qvec) * inv_det;
514+    if (dist < dist_min || dist > dist_max) {
515+        return hit;
516+    }
517+    
518+    // no early outs; valid hit
519+    hit.hit = true;
520+    hit.dist = dist;
521+    hit.position = ray.origin + ray.direction * dist;
522+    hit.tri = tri;
523+    hit.material_idx = i32(tri.material_idx);
524+    
525+    var geo_normal = normalize(cross(edge1, edge2));
526+    var shading_normal = normalize((1.0 - u - v) * tri.normal_a + u * tri.normal_b + v * tri.normal_c);
527+    let tangent = normalize((1.0 - u - v) * tri.tangent_a + u * tri.tangent_b + v * tri.tangent_c);
528+    
529+    // shadow terminator fix: warp the hit position based on vertex normals
530+    // normal aware EPSILON on hit position basically
531+    let w = 1.0 - u - v;
532+    let tmpu = hit.position - tri.corner_a;
533+    let tmpv = hit.position - tri.corner_b;
534+    let tmpw = hit.position - tri.corner_c;
535+
536+    let dotu = min(0.0, dot(tmpu, tri.normal_a));
537+    let dotv = min(0.0, dot(tmpv, tri.normal_b));
538+    let dotw = min(0.0, dot(tmpw, tri.normal_c));
539+
540+    let pu = tmpu - dotu * tri.normal_a;
541+    let pv = tmpv - dotv * tri.normal_b;
542+    let pw = tmpw - dotw * tri.normal_c;
543+
544+    let warped_offset = w * pu + u * pv + v * pw;
545+    // Move the hit point slightly along the warped vector field
546+    hit.position = hit.position + warped_offset;
547+
548+    // TBN
549+    let T = normalize(tangent.xyz);
550+    let N = normalize(shading_normal);
551+    let B = normalize(cross(N, T)) * tangent.w;
552+
553+    hit.tangent = cross(B, N);
554+    hit.normal = shading_normal;
555+    hit.uv = (1.0 - u - v) * tri.uv_a + u * tri.uv_b + v * tri.uv_c;
556+
557+    // If a normal map is present, perturb the shading normal.
558+    let material = materials[i32(tri.material_idx)];
559+    if (material.normal_texture > -1.0) {
560+        var normal_map = sample_material_texture(hit.uv, u32(material.normal_texture));
561+        var normalized_map = normalize(normal_map * 2.0 - 1.0);
562+        normalized_map.y = -normalized_map.y;
563+        let world_normal = normalize(
564+            normalized_map.x * T +
565+            normalized_map.y * B +
566+            normalized_map.z * N
567+        );
568+        hit.normal = world_normal;
569+    }
570+    var ray_dot_tri: f32 = dot(ray.direction, geo_normal);
571+    if (ray_dot_tri > 0.0) {
572+        hit.geo_normal = -hit.geo_normal;
573+        hit.normal     = -hit.normal;
574+    }
575+    return hit;
576+}
577+
578+fn hit_aabb(ray: Ray, node: Node) -> f32 {
579+    var reciprocal : vec3<f32> = vec3f(1.0) / ray.direction;
580+    var t_near: vec3<f32> = (node.min_corner - ray.origin) * reciprocal;
581+    var t_far: vec3<f32> = (node.max_corner - ray.origin) * reciprocal;
582+    var t_min: vec3<f32> = min(t_near, t_far);
583+    var t_max: vec3<f32> = max(t_near, t_far);
584+
585+    var min_intersection: f32 = max(max(t_min.x, t_min.y), t_min.z);  // t0
586+    var max_intersection: f32 = min(min(t_max.x, t_max.y), t_max.z);  // t1 
587+
588+    var mask: f32 = step(max_intersection, min_intersection) + step(max_intersection, 0.0);
589+    if min_intersection > max_intersection || max_intersection < 0 {
590+        return 9999.0;
591+    } else {
592+        return min_intersection;
593+    }
594+}
A · src/shaders/random.wgsl +34, -0
 1@@ -0,0 +1,34 @@
 2+fn init_random(state: ptr<function, u32>, value: u32) {
 3+    * state ^= value;
 4+    * state = pcg(*state);
 5+}
 6+
 7+fn pcg(n: u32) -> u32 {
 8+    var h = n * 747796405u + 2891336453u;
 9+    h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
10+    return (h >> 22u) ^ h;
11+}
12+
13+fn uniform_uint(state: ptr<function, u32>, max: u32) -> u32 {
14+    * state = pcg(*state);
15+    return * state % max;
16+}
17+
18+fn uniform_float(state: ptr<function, u32>) -> f32 {
19+    * state = pcg(*state);
20+    return f32(*state) / 4294967295.0;
21+}
22+
23+fn animated_blue_noise(coord: vec2<i32>, frame_count: u32, frame_count_cycle: u32) -> vec2f {
24+    // spatial 
25+    let tex_size = vec2<u32>(textureDimensions(blueNoiseTexture).xy);
26+    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));
27+    let blue_noise = textureLoad(blueNoiseTexture, wrapped_coord).xy;
28+    let idx = (f32(wrapped_coord.y) % blue_noise.y) * blue_noise.x + (f32(wrapped_coord.x) % blue_noise.x);
29+    // temporal
30+    let n = frame_count % frame_count_cycle;
31+    let a1 = 0.7548776662466927f;
32+    let a2 = 0.5698402909980532f;
33+    let r2_seq = fract(vec2(a1 * f32(n), a2 * f32(n)));
34+    return fract(blue_noise + r2_seq);
35+}
A · src/shaders/sky.wgsl +67, -0
 1@@ -0,0 +1,67 @@
 2+const sun_angular_size = 1.0 * (PI / 180.0);
 3+
 4+fn dir_in_cone(u: vec2f) -> vec3<f32> {
 5+    let sun_cos_theta_max = cos(0.255f * PI / 180f);
 6+    let cos_theta = 1f - u.x * (1f - sun_cos_theta_max);
 7+    let sin_theta = sqrt(1f - cos_theta * cos_theta);
 8+    let phi = 2f * PI * u.y;
 9+    let x = cos(phi) * sin_theta;
10+    let y = sin(phi) * sin_theta;
11+    let z = cos_theta;
12+    return vec3(x, y, z);
13+}
14+
15+fn sample_sun_cone_dir(u: vec2f) -> vec3<f32> {
16+    let v = dir_in_cone(u);
17+    let onb = pixar_onb(normalize(uniforms.sun_direction));
18+    return normalize(onb * v);
19+}
20+
21+// https://www.jcgt.org/published/0006/01/01/paper-lowres.pdf
22+fn pixar_onb(n: vec3f) -> mat3x3<f32> {
23+    let s = select(- 1f, 1f, n.z >= 0f);
24+    let a = - 1f / (s + n.z);
25+    let b = n.x * n.y * a;
26+    let u = vec3(1f + s * n.x * n.x * a, s * b, - s * n.x);
27+    let v = vec3(b, s + n.y * n.y * a, - n.y);
28+    return mat3x3(u, v, n);
29+}
30+
31+// simplified approximation of preetham
32+fn sky_glow(dir: vec3f, sun_dir: vec3f) -> vec3f {
33+    let view_dir = normalize(dir);
34+    let sun_dir_n = normalize(sun_dir);
35+    let cos_theta = dot(view_dir, sun_dir_n);
36+    if sun_dir_n.z <= 0.0 {
37+        return vec3f(0.0);
38+    }
39+    // sun altitude still helps modulate overall warmth
40+    let sun_altitude = clamp(sun_dir_n.z, 0.0, 1.0);
41+    // more saturated warm tone for horizon, and deeper blue for zenith
42+    let horizon_color = vec3f(1.1, 0.4, 0.2);
43+    // rich orange
44+    let zenith_color = vec3f(0.05, 0.2, 0.6);
45+    // deep blue
46+    // exaggerated curve to preserve saturation
47+    let sky_color = mix(horizon_color, zenith_color, pow(sun_altitude, 0.1));
48+    // rayleigh-like gradient with vertical bias
49+    let rayleigh = sky_color * (0.6 + 0.4 * cos_theta * cos_theta);
50+    // warm sun glow with stronger color (no gray falloff)
51+    let mie = vec3f(1.3, 0.6, 0.3) * pow(max(cos_theta, 0.0), 12.0) * 0.6;
52+    return clamp(rayleigh + mie, vec3f(0.0), vec3f(100.0));
53+}
54+
55+fn sun_glow(dir: vec3f, sun_dir: vec3f) -> vec3f {
56+    let view_dir = normalize(dir);
57+    let sun_n = normalize(sun_dir);
58+    let cos_theta = dot(view_dir, sun_n);
59+    // angular radius (half the angular size)
60+    let angular_radius = 0.5 * uniforms.sun_angular_size;
61+    let inner = cos(angular_radius * 0.9);
62+    let outer = cos(angular_radius * 1.1);
63+    let sun_disk = smoothstep(outer, inner, max(cos_theta, 0.0));
64+    // compute sun altitude (z-up): 0 = horizon, 1 = overhead
65+    let sun_altitude = clamp(sun_n.z, 0.0, 1.0);
66+    return uniforms.sun_radiance * /*tint*/
67+    sun_disk;
68+}
A · src/shaders/types.d.ts +4, -0
1@@ -0,0 +1,4 @@
2+declare module "*.wgsl" {
3+  const shader: "string";
4+  export default shader;
5+}
A · src/shaders/utils.wgsl +37, -0
 1@@ -0,0 +1,37 @@
 2+fn is_nan(x: f32) -> bool {
 3+    return x != x;
 4+}
 5+
 6+fn is_inf(x: f32) -> bool {
 7+    return abs(x) > 1e20;
 8+}
 9+
10+fn is_nan3(v: vec3<f32>) -> bool {
11+    return is_nan(v.x) || is_nan(v.y) || is_nan(v.z);
12+}
13+
14+fn is_inf3(v: vec3<f32>) -> bool {
15+    return abs(v.x) > 1e20 || abs(v.y) > 1e20 || abs(v.z) > 1e20;
16+}
17+
18+fn luminance(color: vec3<f32>) -> f32 {
19+    return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
20+}
21+
22+fn float_to_int(f: f32) -> i32 {
23+    return bitcast<i32>(f);
24+}
25+
26+fn int_to_float(i: i32) -> f32 {
27+    return bitcast<f32>(i);
28+}
29+
30+fn srgb_to_linear(rgb: vec3<f32>) -> vec3<f32> {
31+    return select(pow((rgb + 0.055) * (1.0 / 1.055), vec3<f32>(2.4)), rgb * (1.0 / 12.92), rgb <= vec3<f32>(0.04045));
32+}
33+
34+fn clamp_hdr(color: vec3<f32>, max_luminance: f32) -> vec3<f32> {
35+    let lum = dot(color, vec3f(0.2126, 0.7152, 0.0722));
36+    return color * min(1.0, max_luminance / max(lum, 1e-6));
37+}
38+
A · src/shaders/viewport.wgsl +68, -0
 1@@ -0,0 +1,68 @@
 2+#include utils.wgsl
 3+
 4+@group(0) @binding(0) var output_buffer: texture_2d<f32>;
 5+
 6+struct Interpolator {
 7+    @builtin(position) position: vec4<f32>,
 8+    @location(0) tex_coord: vec2<f32>,
 9+}
10+
11+
12+fn uncharted2_tonemap_base(x : vec3f) -> vec3f
13+{
14+	let a = 0.15;
15+    let b = 0.50;
16+    let c = 0.10;
17+    let d = 0.20;
18+    let e = 0.02;
19+    let f = 0.30;
20+	return ((x*(a*x+c*b)+d*e)/(x*(a*x+b)+d*f))-e/f;
21+}
22+
23+fn uncharted2_tonemap(color: vec3f) -> vec3f {
24+    let exposure = 2.0; // adjustable
25+    let white_point = uncharted2_tonemap_base(vec3f(11.2));
26+    let mapped = uncharted2_tonemap_base(color * exposure);
27+    return mapped / white_point;
28+}
29+
30+fn gamma_correct(color: vec3<f32>) -> vec3<f32> {
31+    return pow(color, vec3<f32>(1.0 / 2.2));
32+}
33+
34+// fn reinhard_tonemap(color: vec3<f32>) -> vec3<f32> {
35+//     return color / (color + vec3f(1.0));
36+// }
37+
38+
39+@vertex
40+fn vert_main(@builtin(vertex_index) vertex_index: u32) -> Interpolator {
41+    var positions = array<vec2<f32>, 6>(
42+        vec2<f32>(1.0, 1.0),
43+        vec2<f32>(1.0, -1.0),
44+        vec2<f32>(-1.0, -1.0),
45+        vec2<f32>(1.0, 1.0),
46+        vec2<f32>(-1.0, -1.0),
47+        vec2<f32>(-1.0, 1.0)
48+    );
49+    var tex_coords = array<vec2<f32>, 6>(
50+        vec2<f32>(1.0, 1.0),
51+        vec2<f32>(1.0, 0.0),
52+        vec2<f32>(0.0, 0.0),
53+        vec2<f32>(1.0, 1.0),
54+        vec2<f32>(0.0, 0.0),
55+        vec2<f32>(0.0, 1.0)
56+    );
57+    var output: Interpolator;
58+    output.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
59+    output.tex_coord = tex_coords[vertex_index];
60+    return output;
61+}
62+
63+@fragment
64+fn frag_main(@location(0) tex_coord: vec2<f32>) -> @location(0) vec4<f32> {
65+    let dims = textureDimensions(output_buffer);
66+    let uv = vec2<u32>(tex_coord * vec2<f32>(dims));
67+    let linear_color = textureLoad(output_buffer, uv, 0).rgb;
68+    return vec4f(gamma_correct(uncharted2_tonemap(linear_color)), 1.0);
69+}
A · src/vite-env.d.ts +2, -0
1@@ -0,0 +1,2 @@
2+/// <reference types="vite/client" />
3+/// <reference types="@webgpu/types" />
A · tsconfig.json +25, -0
 1@@ -0,0 +1,25 @@
 2+{
 3+  "compilerOptions": {
 4+    "target": "ES2022",
 5+    "useDefineForClassFields": true,
 6+    "module": "ESNext",
 7+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
 8+    "skipLibCheck": true,
 9+
10+    /* Bundler mode */
11+    "moduleResolution": "bundler",
12+    "allowImportingTsExtensions": true,
13+    "isolatedModules": true,
14+    "moduleDetection": "force",
15+    "noEmit": true,
16+
17+    /* Linting */
18+    "strict": true,
19+    "noUnusedLocals": true,
20+    "noUnusedParameters": true,
21+    "noFallthroughCasesInSwitch": true,
22+    "noUncheckedSideEffectImports": true,
23+    "types": ["@webgpu/types", "vite-plugin-glsl/ext"]
24+  },
25+  "include": ["src"]
26+}
A · vite.config.ts +15, -0
 1@@ -0,0 +1,15 @@
 2+import { defineConfig } from "vite";
 3+import glsl from 'vite-plugin-glsl';
 4+
 5+
 6+export default defineConfig({
 7+  plugins: [glsl()],
 8+  build: {
 9+    target: "es2022",
10+    modulePreload: true,
11+    outDir: "dist",
12+  },
13+  esbuild: {
14+    target: "es2022",
15+  },
16+});