@tinypoly/gpu-renderer 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/ARCHITECTURE.md +339 -0
  2. package/LICENSE +21 -0
  3. package/README.md +284 -2
  4. package/dist/accumulator.d.ts +27 -0
  5. package/dist/accumulator.d.ts.map +1 -0
  6. package/dist/accumulator.js +57 -0
  7. package/dist/accumulator.js.map +1 -0
  8. package/dist/bvh.d.ts +26 -0
  9. package/dist/bvh.d.ts.map +1 -0
  10. package/dist/bvh.js +296 -0
  11. package/dist/bvh.js.map +1 -0
  12. package/dist/bvhBuilder.d.ts +72 -0
  13. package/dist/bvhBuilder.d.ts.map +1 -0
  14. package/dist/bvhBuilder.js +126 -0
  15. package/dist/bvhBuilder.js.map +1 -0
  16. package/dist/bvhWorker.d.ts +2 -0
  17. package/dist/bvhWorker.d.ts.map +1 -0
  18. package/dist/bvhWorker.js +8 -0
  19. package/dist/bvhWorker.js.map +1 -0
  20. package/dist/camera.d.ts +29 -0
  21. package/dist/camera.d.ts.map +1 -0
  22. package/dist/camera.js +48 -0
  23. package/dist/camera.js.map +1 -0
  24. package/dist/canvas.d.ts +9 -0
  25. package/dist/canvas.d.ts.map +1 -0
  26. package/dist/canvas.js +11 -0
  27. package/dist/canvas.js.map +1 -0
  28. package/dist/denoise.d.ts +69 -0
  29. package/dist/denoise.d.ts.map +1 -0
  30. package/dist/denoise.js +455 -0
  31. package/dist/denoise.js.map +1 -0
  32. package/dist/device.d.ts +37 -0
  33. package/dist/device.d.ts.map +1 -0
  34. package/dist/device.js +52 -0
  35. package/dist/device.js.map +1 -0
  36. package/dist/dispatchScheduler.d.ts +43 -0
  37. package/dist/dispatchScheduler.d.ts.map +1 -0
  38. package/dist/dispatchScheduler.js +49 -0
  39. package/dist/dispatchScheduler.js.map +1 -0
  40. package/dist/environment.d.ts +39 -0
  41. package/dist/environment.d.ts.map +1 -0
  42. package/dist/environment.js +168 -0
  43. package/dist/environment.js.map +1 -0
  44. package/dist/frameLayout.d.ts +4 -0
  45. package/dist/frameLayout.d.ts.map +1 -0
  46. package/dist/frameLayout.js +58 -0
  47. package/dist/frameLayout.js.map +1 -0
  48. package/dist/geometry.d.ts +75 -0
  49. package/dist/geometry.d.ts.map +1 -0
  50. package/dist/geometry.js +799 -0
  51. package/dist/geometry.js.map +1 -0
  52. package/dist/index.d.ts +27 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +19 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/lights.d.ts +64 -0
  57. package/dist/lights.d.ts.map +1 -0
  58. package/dist/lights.js +264 -0
  59. package/dist/lights.js.map +1 -0
  60. package/dist/materialLayout.d.ts +143 -0
  61. package/dist/materialLayout.d.ts.map +1 -0
  62. package/dist/materialLayout.js +297 -0
  63. package/dist/materialLayout.js.map +1 -0
  64. package/dist/math.d.ts +10 -0
  65. package/dist/math.d.ts.map +1 -0
  66. package/dist/math.js +40 -0
  67. package/dist/math.js.map +1 -0
  68. package/dist/pathTracePass.d.ts +27 -0
  69. package/dist/pathTracePass.d.ts.map +1 -0
  70. package/dist/pathTracePass.js +83 -0
  71. package/dist/pathTracePass.js.map +1 -0
  72. package/dist/planarCapture.d.ts +38 -0
  73. package/dist/planarCapture.d.ts.map +1 -0
  74. package/dist/planarCapture.js +163 -0
  75. package/dist/planarCapture.js.map +1 -0
  76. package/dist/presentPass.d.ts +31 -0
  77. package/dist/presentPass.d.ts.map +1 -0
  78. package/dist/presentPass.js +78 -0
  79. package/dist/presentPass.js.map +1 -0
  80. package/dist/renderer.d.ts +209 -0
  81. package/dist/renderer.d.ts.map +1 -0
  82. package/dist/renderer.js +847 -0
  83. package/dist/renderer.js.map +1 -0
  84. package/dist/sceneAssets.d.ts +24 -0
  85. package/dist/sceneAssets.d.ts.map +1 -0
  86. package/dist/sceneAssets.js +77 -0
  87. package/dist/sceneAssets.js.map +1 -0
  88. package/dist/sceneUpload.d.ts +61 -0
  89. package/dist/sceneUpload.d.ts.map +1 -0
  90. package/dist/sceneUpload.js +98 -0
  91. package/dist/sceneUpload.js.map +1 -0
  92. package/dist/shaderModule.d.ts +7 -0
  93. package/dist/shaderModule.d.ts.map +1 -0
  94. package/dist/shaderModule.js +19 -0
  95. package/dist/shaderModule.js.map +1 -0
  96. package/dist/shaderProgram.d.ts +124 -0
  97. package/dist/shaderProgram.d.ts.map +1 -0
  98. package/dist/shaderProgram.js +1030 -0
  99. package/dist/shaderProgram.js.map +1 -0
  100. package/dist/structLayout.d.ts +28 -0
  101. package/dist/structLayout.d.ts.map +1 -0
  102. package/dist/structLayout.js +58 -0
  103. package/dist/structLayout.js.map +1 -0
  104. package/dist/texturePack.d.ts +54 -0
  105. package/dist/texturePack.d.ts.map +1 -0
  106. package/dist/texturePack.js +86 -0
  107. package/dist/texturePack.js.map +1 -0
  108. package/dist/upstream.d.ts +7 -0
  109. package/dist/upstream.d.ts.map +1 -0
  110. package/dist/upstream.js +11 -0
  111. package/dist/upstream.js.map +1 -0
  112. package/dist/volumetric.d.ts +10 -0
  113. package/dist/volumetric.d.ts.map +1 -0
  114. package/dist/volumetric.js +34 -0
  115. package/dist/volumetric.js.map +1 -0
  116. package/dist/wgsl/bsdf.wgsl.d.ts +13 -0
  117. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -0
  118. package/dist/wgsl/bsdf.wgsl.js +619 -0
  119. package/dist/wgsl/bsdf.wgsl.js.map +1 -0
  120. package/dist/wgsl/bvh.wgsl.d.ts +3 -0
  121. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -0
  122. package/dist/wgsl/bvh.wgsl.js +218 -0
  123. package/dist/wgsl/bvh.wgsl.js.map +1 -0
  124. package/dist/wgsl/common.wgsl.d.ts +3 -0
  125. package/dist/wgsl/common.wgsl.d.ts.map +1 -0
  126. package/dist/wgsl/common.wgsl.js +193 -0
  127. package/dist/wgsl/common.wgsl.js.map +1 -0
  128. package/dist/wgsl/environment.wgsl.d.ts +10 -0
  129. package/dist/wgsl/environment.wgsl.d.ts.map +1 -0
  130. package/dist/wgsl/environment.wgsl.js +145 -0
  131. package/dist/wgsl/environment.wgsl.js.map +1 -0
  132. package/dist/wgsl/lights.wgsl.d.ts +10 -0
  133. package/dist/wgsl/lights.wgsl.d.ts.map +1 -0
  134. package/dist/wgsl/lights.wgsl.js +434 -0
  135. package/dist/wgsl/lights.wgsl.js.map +1 -0
  136. package/dist/wgsl/pathTrace.wgsl.d.ts +14 -0
  137. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -0
  138. package/dist/wgsl/pathTrace.wgsl.js +1020 -0
  139. package/dist/wgsl/pathTrace.wgsl.js.map +1 -0
  140. package/dist/wgsl/present.wgsl.d.ts +7 -0
  141. package/dist/wgsl/present.wgsl.d.ts.map +1 -0
  142. package/dist/wgsl/present.wgsl.js +154 -0
  143. package/dist/wgsl/present.wgsl.js.map +1 -0
  144. package/dist/wgsl/random.wgsl.d.ts +9 -0
  145. package/dist/wgsl/random.wgsl.d.ts.map +1 -0
  146. package/dist/wgsl/random.wgsl.js +91 -0
  147. package/dist/wgsl/random.wgsl.js.map +1 -0
  148. package/dist/wgsl/sampling.wgsl.d.ts +3 -0
  149. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -0
  150. package/dist/wgsl/sampling.wgsl.js +31 -0
  151. package/dist/wgsl/sampling.wgsl.js.map +1 -0
  152. package/dist/wgsl/shaderSupport.wgsl.d.ts +6 -0
  153. package/dist/wgsl/shaderSupport.wgsl.d.ts.map +1 -0
  154. package/dist/wgsl/shaderSupport.wgsl.js +120 -0
  155. package/dist/wgsl/shaderSupport.wgsl.js.map +1 -0
  156. package/dist/wgsl/surface.wgsl.d.ts +10 -0
  157. package/dist/wgsl/surface.wgsl.d.ts.map +1 -0
  158. package/dist/wgsl/surface.wgsl.js +794 -0
  159. package/dist/wgsl/surface.wgsl.js.map +1 -0
  160. package/dist/wgsl/texture.wgsl.d.ts +11 -0
  161. package/dist/wgsl/texture.wgsl.d.ts.map +1 -0
  162. package/dist/wgsl/texture.wgsl.js +132 -0
  163. package/dist/wgsl/texture.wgsl.js.map +1 -0
  164. package/package.json +77 -3
  165. package/src/accumulator.ts +64 -0
  166. package/src/bvh.ts +336 -0
  167. package/src/bvhBuilder.ts +178 -0
  168. package/src/bvhWorker.ts +13 -0
  169. package/src/camera.ts +77 -0
  170. package/src/canvas.ts +13 -0
  171. package/src/denoise.ts +514 -0
  172. package/src/device.ts +81 -0
  173. package/src/dispatchScheduler.ts +71 -0
  174. package/src/environment.ts +233 -0
  175. package/src/frameLayout.ts +59 -0
  176. package/src/geometry.ts +1067 -0
  177. package/src/index.ts +64 -0
  178. package/src/lights.ts +365 -0
  179. package/src/materialLayout.ts +408 -0
  180. package/src/math.ts +48 -0
  181. package/src/pathTracePass.ts +97 -0
  182. package/src/planarCapture.ts +225 -0
  183. package/src/presentPass.ts +99 -0
  184. package/src/renderer.ts +1145 -0
  185. package/src/sceneAssets.ts +118 -0
  186. package/src/sceneUpload.ts +185 -0
  187. package/src/shaderModule.ts +19 -0
  188. package/src/shaderProgram.ts +1370 -0
  189. package/src/structLayout.ts +93 -0
  190. package/src/texturePack.ts +152 -0
  191. package/src/upstream.ts +56 -0
  192. package/src/volumetric.ts +42 -0
  193. package/src/wgsl/bsdf.wgsl.ts +618 -0
  194. package/src/wgsl/bvh.wgsl.ts +217 -0
  195. package/src/wgsl/common.wgsl.ts +193 -0
  196. package/src/wgsl/environment.wgsl.ts +144 -0
  197. package/src/wgsl/lights.wgsl.ts +433 -0
  198. package/src/wgsl/pathTrace.wgsl.ts +1019 -0
  199. package/src/wgsl/present.wgsl.ts +153 -0
  200. package/src/wgsl/random.wgsl.ts +90 -0
  201. package/src/wgsl/sampling.wgsl.ts +30 -0
  202. package/src/wgsl/shaderSupport.wgsl.ts +119 -0
  203. package/src/wgsl/surface.wgsl.ts +793 -0
  204. package/src/wgsl/texture.wgsl.ts +131 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/surface.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgxBtC,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Reading a texel by hand, because core WebGPU offers no other way here: there are no binding arrays, and one
3
+ * texture_2d_array would force a single size and format on a scene that has neither. The filtering, the wrap modes and
4
+ * the sRGB decode follow texture.js in the CPU renderer so the two agree on what a texture says.
5
+ *
6
+ * Only the top level is read. A rasterizer needs mip levels to stop minified texture detail from aliasing, but this
7
+ * renderer already draws many jittered samples through every pixel, which averages that detail properly instead of
8
+ * approximating it. Levels would buy memory locality, not correctness, so they can wait.
9
+ */
10
+ export declare const TEXTURE_WGSL = "\nconst TEXTURE_FLOAT: u32 = 1u;\nconst TEXTURE_SRGB: u32 = 2u;\nconst TEXTURE_NEAREST: u32 = 4u;\nconst TEXTURE_FLIP_Y: u32 = 8u;\n\n// Three's own wrap constants. Anything else clamps, as it does in the CPU renderer.\nconst WRAP_REPEAT: u32 = 1000u;\nconst WRAP_MIRROR: u32 = 1002u;\n\n/** Brings a texel index inside the texture the way the wrap mode says to. */\nfn wrapIndex(index: i32, size: i32, mode: u32) -> i32 {\n if (mode == WRAP_REPEAT) {\n return ((index % size) + size) % size;\n }\n\n if (mode == WRAP_MIRROR) {\n let period = size * 2;\n let folded = ((index % period) + period) % period;\n\n return select(period - 1 - folded, folded, folded < size);\n }\n\n return clamp(index, 0, size - 1);\n}\n\nfn srgbChannelToLinear(value: f32) -> f32 {\n return select(pow((value + 0.055) / 1.055, 2.4), value / 12.92, value <= 0.04045);\n}\n\n/** One texel, in linear light. Alpha is never encoded, as in Three. */\nfn readTexel(info: TextureInfo, x: i32, y: i32) -> vec4<f32> {\n let index = u32(y) * info.size.x + u32(x);\n\n if ((info.flags & TEXTURE_FLOAT) != 0u) {\n let at = info.offset + index * 4u;\n\n return vec4<f32>(\n bitcast<f32>(textures[at]),\n bitcast<f32>(textures[at + 1u]),\n bitcast<f32>(textures[at + 2u]),\n bitcast<f32>(textures[at + 3u]));\n }\n\n let word = textures[info.offset + index];\n let raw = vec4<f32>(\n f32(word & 0xffu),\n f32((word >> 8u) & 0xffu),\n f32((word >> 16u) & 0xffu),\n f32((word >> 24u) & 0xffu)) / 255.0;\n\n if ((info.flags & TEXTURE_SRGB) != 0u) {\n return vec4<f32>(\n srgbChannelToLinear(raw.r), srgbChannelToLinear(raw.g), srgbChannelToLinear(raw.b), raw.a);\n }\n\n return raw;\n}\n\n/** One texture of the table, sampled with its own transform, wrap modes and filter. */\nfn sampleTextureAt(index: u32, uv: vec2<f32>) -> vec4<f32> {\n let info = textureInfo(index);\n // The affine transform Three keeps on the texture, one row at a time.\n let transformed = vec2<f32>(\n dot(info.rowU.xyz, vec3<f32>(uv, 1.0)),\n dot(info.rowV.xyz, vec3<f32>(uv, 1.0)));\n\n return sampleTextureInfo(info, transformed);\n}\n\n/**\n * A texture read the way GLSL's texture2D reads it: at the coordinates given, with no transform. Three applies a\n * texture's matrix in its own materials' shaders, so a shader of the scene's, an environment lookup and a light's\n * projected map do not get it.\n */\nfn sampleTextureRaw(index: u32, uv: vec2<f32>) -> vec4<f32> {\n return sampleTextureInfo(textureInfo(index), uv);\n}\n\n/** Three's equirectUv: longitude across, and latitude up from the bottom of the image, as GL reads it. */\nfn equirectUv(direction: vec3<f32>) -> vec2<f32> {\n let d = normalize(direction);\n\n return vec2<f32>(atan2(d.z, d.x) / (2.0 * PI) + 0.5, asin(clamp(d.y, -1.0, 1.0)) / PI + 0.5);\n}\n\n/** An environment map of the scene's: an equirectangular image, or a cube map the snapshot already unwrapped into one. */\nfn sampleEquirect(index: u32, direction: vec3<f32>) -> vec4<f32> {\n return sampleTextureRaw(index, equirectUv(direction));\n}\n\nfn sampleTextureInfo(info: TextureInfo, transformed: vec2<f32>) -> vec4<f32> {\n if (!all(transformed == transformed) || info.size.x == 0u || info.size.y == 0u) {\n return vec4<f32>(0.0);\n }\n\n let flipped = select(transformed.y, 1.0 - transformed.y, (info.flags & TEXTURE_FLIP_Y) != 0u);\n let size = vec2<i32>(info.size);\n // The half texel offset that puts a sample in the middle of a texel, as on a graphics pipeline.\n let centred = vec2<f32>(transformed.x, flipped) * vec2<f32>(info.size) - 0.5;\n\n if ((info.flags & TEXTURE_NEAREST) != 0u) {\n let at = vec2<i32>(floor(centred + 0.5));\n\n return readTexel(info, wrapIndex(at.x, size.x, info.wrapS), wrapIndex(at.y, size.y, info.wrapT));\n }\n\n let low = floor(centred);\n let fraction = centred - low;\n let base = vec2<i32>(low);\n let x0 = wrapIndex(base.x, size.x, info.wrapS);\n let x1 = wrapIndex(base.x + 1, size.x, info.wrapS);\n let y0 = wrapIndex(base.y, size.y, info.wrapT);\n let y1 = wrapIndex(base.y + 1, size.y, info.wrapT);\n\n // The four corners are blended in linear light, which is why the decode happens per texel above.\n return mix(\n mix(readTexel(info, x0, y0), readTexel(info, x1, y0), fraction.x),\n mix(readTexel(info, x0, y1), readTexel(info, x1, y1), fraction.x),\n fraction.y);\n}\n";
11
+ //# sourceMappingURL=texture.wgsl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"texture.wgsl.d.ts","sourceRoot":"","sources":["../../src/wgsl/texture.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,s0IAyHxB,CAAC"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Reading a texel by hand, because core WebGPU offers no other way here: there are no binding arrays, and one
3
+ * texture_2d_array would force a single size and format on a scene that has neither. The filtering, the wrap modes and
4
+ * the sRGB decode follow texture.js in the CPU renderer so the two agree on what a texture says.
5
+ *
6
+ * Only the top level is read. A rasterizer needs mip levels to stop minified texture detail from aliasing, but this
7
+ * renderer already draws many jittered samples through every pixel, which averages that detail properly instead of
8
+ * approximating it. Levels would buy memory locality, not correctness, so they can wait.
9
+ */
10
+ export const TEXTURE_WGSL = /* wgsl */ `
11
+ const TEXTURE_FLOAT: u32 = 1u;
12
+ const TEXTURE_SRGB: u32 = 2u;
13
+ const TEXTURE_NEAREST: u32 = 4u;
14
+ const TEXTURE_FLIP_Y: u32 = 8u;
15
+
16
+ // Three's own wrap constants. Anything else clamps, as it does in the CPU renderer.
17
+ const WRAP_REPEAT: u32 = 1000u;
18
+ const WRAP_MIRROR: u32 = 1002u;
19
+
20
+ /** Brings a texel index inside the texture the way the wrap mode says to. */
21
+ fn wrapIndex(index: i32, size: i32, mode: u32) -> i32 {
22
+ if (mode == WRAP_REPEAT) {
23
+ return ((index % size) + size) % size;
24
+ }
25
+
26
+ if (mode == WRAP_MIRROR) {
27
+ let period = size * 2;
28
+ let folded = ((index % period) + period) % period;
29
+
30
+ return select(period - 1 - folded, folded, folded < size);
31
+ }
32
+
33
+ return clamp(index, 0, size - 1);
34
+ }
35
+
36
+ fn srgbChannelToLinear(value: f32) -> f32 {
37
+ return select(pow((value + 0.055) / 1.055, 2.4), value / 12.92, value <= 0.04045);
38
+ }
39
+
40
+ /** One texel, in linear light. Alpha is never encoded, as in Three. */
41
+ fn readTexel(info: TextureInfo, x: i32, y: i32) -> vec4<f32> {
42
+ let index = u32(y) * info.size.x + u32(x);
43
+
44
+ if ((info.flags & TEXTURE_FLOAT) != 0u) {
45
+ let at = info.offset + index * 4u;
46
+
47
+ return vec4<f32>(
48
+ bitcast<f32>(textures[at]),
49
+ bitcast<f32>(textures[at + 1u]),
50
+ bitcast<f32>(textures[at + 2u]),
51
+ bitcast<f32>(textures[at + 3u]));
52
+ }
53
+
54
+ let word = textures[info.offset + index];
55
+ let raw = vec4<f32>(
56
+ f32(word & 0xffu),
57
+ f32((word >> 8u) & 0xffu),
58
+ f32((word >> 16u) & 0xffu),
59
+ f32((word >> 24u) & 0xffu)) / 255.0;
60
+
61
+ if ((info.flags & TEXTURE_SRGB) != 0u) {
62
+ return vec4<f32>(
63
+ srgbChannelToLinear(raw.r), srgbChannelToLinear(raw.g), srgbChannelToLinear(raw.b), raw.a);
64
+ }
65
+
66
+ return raw;
67
+ }
68
+
69
+ /** One texture of the table, sampled with its own transform, wrap modes and filter. */
70
+ fn sampleTextureAt(index: u32, uv: vec2<f32>) -> vec4<f32> {
71
+ let info = textureInfo(index);
72
+ // The affine transform Three keeps on the texture, one row at a time.
73
+ let transformed = vec2<f32>(
74
+ dot(info.rowU.xyz, vec3<f32>(uv, 1.0)),
75
+ dot(info.rowV.xyz, vec3<f32>(uv, 1.0)));
76
+
77
+ return sampleTextureInfo(info, transformed);
78
+ }
79
+
80
+ /**
81
+ * A texture read the way GLSL's texture2D reads it: at the coordinates given, with no transform. Three applies a
82
+ * texture's matrix in its own materials' shaders, so a shader of the scene's, an environment lookup and a light's
83
+ * projected map do not get it.
84
+ */
85
+ fn sampleTextureRaw(index: u32, uv: vec2<f32>) -> vec4<f32> {
86
+ return sampleTextureInfo(textureInfo(index), uv);
87
+ }
88
+
89
+ /** Three's equirectUv: longitude across, and latitude up from the bottom of the image, as GL reads it. */
90
+ fn equirectUv(direction: vec3<f32>) -> vec2<f32> {
91
+ let d = normalize(direction);
92
+
93
+ return vec2<f32>(atan2(d.z, d.x) / (2.0 * PI) + 0.5, asin(clamp(d.y, -1.0, 1.0)) / PI + 0.5);
94
+ }
95
+
96
+ /** An environment map of the scene's: an equirectangular image, or a cube map the snapshot already unwrapped into one. */
97
+ fn sampleEquirect(index: u32, direction: vec3<f32>) -> vec4<f32> {
98
+ return sampleTextureRaw(index, equirectUv(direction));
99
+ }
100
+
101
+ fn sampleTextureInfo(info: TextureInfo, transformed: vec2<f32>) -> vec4<f32> {
102
+ if (!all(transformed == transformed) || info.size.x == 0u || info.size.y == 0u) {
103
+ return vec4<f32>(0.0);
104
+ }
105
+
106
+ let flipped = select(transformed.y, 1.0 - transformed.y, (info.flags & TEXTURE_FLIP_Y) != 0u);
107
+ let size = vec2<i32>(info.size);
108
+ // The half texel offset that puts a sample in the middle of a texel, as on a graphics pipeline.
109
+ let centred = vec2<f32>(transformed.x, flipped) * vec2<f32>(info.size) - 0.5;
110
+
111
+ if ((info.flags & TEXTURE_NEAREST) != 0u) {
112
+ let at = vec2<i32>(floor(centred + 0.5));
113
+
114
+ return readTexel(info, wrapIndex(at.x, size.x, info.wrapS), wrapIndex(at.y, size.y, info.wrapT));
115
+ }
116
+
117
+ let low = floor(centred);
118
+ let fraction = centred - low;
119
+ let base = vec2<i32>(low);
120
+ let x0 = wrapIndex(base.x, size.x, info.wrapS);
121
+ let x1 = wrapIndex(base.x + 1, size.x, info.wrapS);
122
+ let y0 = wrapIndex(base.y, size.y, info.wrapT);
123
+ let y1 = wrapIndex(base.y + 1, size.y, info.wrapT);
124
+
125
+ // The four corners are blended in linear light, which is why the decode happens per texel above.
126
+ return mix(
127
+ mix(readTexel(info, x0, y0), readTexel(info, x1, y0), fraction.x),
128
+ mix(readTexel(info, x0, y1), readTexel(info, x1, y1), fraction.x),
129
+ fraction.y);
130
+ }
131
+ `;
132
+ //# sourceMappingURL=texture.wgsl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"texture.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/texture.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyHtC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "@tinypoly/gpu-renderer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "WebGPU path tracer for Three.js scenes: BVH traversal, progressive accumulation and tone mapping in WGSL compute shaders.",
5
+ "keywords": [
6
+ "three",
7
+ "threejs",
8
+ "renderer",
9
+ "webgpu",
10
+ "wgsl",
11
+ "path-tracing",
12
+ "ray-tracing",
13
+ "global-illumination",
14
+ "compute-shader"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Danilo Gadelha",
18
+ "homepage": "https://github.com/tinypoly/gpu-renderer#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/tinypoly/gpu-renderer.git"
22
+ },
23
+ "bugs": "https://github.com/tinypoly/gpu-renderer/issues",
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./bvhWorker.js": "./dist/bvhWorker.js",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "!dist/**/*.test.*",
39
+ "src",
40
+ "!src/**/*.test.ts",
41
+ "ARCHITECTURE.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "dependencies": {
47
+ "@tinypoly/cpu-renderer": "^0.3.0",
48
+ "naga-wasm": "30.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "three": ">=0.185.0"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
55
+ "@stylistic/eslint-plugin": "^5.10.0",
56
+ "@types/node": "^24",
57
+ "@types/three": "^0.185.4",
58
+ "@webgpu/types": "^0.1.40",
59
+ "eslint": "^10.11.0",
60
+ "globals": "^17.12.0",
61
+ "three": "^0.185.1",
62
+ "tsx": "^4.23.0",
63
+ "typescript": "^6.0.3",
64
+ "typescript-eslint": "^8.63.0",
65
+ "vite": "^8.1.4",
66
+ "vitest": "^4.1.10"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.build.json",
70
+ "typecheck": "tsc --noEmit && tsc --noEmit -p example",
71
+ "lint": "eslint . --max-warnings 0",
72
+ "lint:fix": "eslint . --fix",
73
+ "test": "vitest run",
74
+ "test:watch": "vitest",
75
+ "verify": "node scripts/verify.mjs",
76
+ "capture:scenes": "node scripts/capture-scenes.js",
77
+ "dev": "tsc -p tsconfig.build.json && vite --config example/vite.config.ts",
78
+ "build:example": "tsc -p tsconfig.build.json && vite build --config example/vite.config.ts"
79
+ }
6
80
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Where the samples pile up: one `vec4<f32>` per pixel, rgb summed radiance and w the number of samples that reached
3
+ * it, then a second `vec4<f32>` per pixel whose x sums the coverage, which is what a transparent background reads as
4
+ * alpha, y the depth of the first surface, z and w the squared luminance of the diffuse and the specular signal, which
5
+ * give each its variance. Then the denoiser's guides and signals, each summed: the diffuse albedo with the roughness,
6
+ * the normal with the distance, the diffuse radiance with the specular hit distance, and the specular albedo. Last comes
7
+ * one `vec4<f32>` per row, set when a pass traced anything in that row: adaptive sampling reads it back to know which
8
+ * rows still have pixels that have not converged. A storage buffer rather than a storage texture, because `rgba32float` only allows write access as a texture,
9
+ * and the presentation pass has to read back what is already there.
10
+ */
11
+ export class Accumulator {
12
+ buffer: GPUBuffer;
13
+ width: number;
14
+ height: number;
15
+
16
+ constructor(private readonly device: GPUDevice, width: number, height: number) {
17
+ this.width = Math.max(1, Math.floor(width));
18
+ this.height = Math.max(1, Math.floor(height));
19
+ this.buffer = this.create();
20
+ }
21
+
22
+ get byteLength(): number {
23
+ return (this.width * this.height * 6 + this.height) * 16;
24
+ }
25
+
26
+ /** Where the per-row flags start, in bytes. */
27
+ get rowFlagsOffset(): number {
28
+ return this.width * this.height * 96;
29
+ }
30
+
31
+ get rowFlagsLength(): number {
32
+ return this.height * 16;
33
+ }
34
+
35
+ /** Reallocates only when the size actually changed; the caller clears it either way. */
36
+ resize(width: number, height: number): boolean {
37
+ const next = { width: Math.max(1, Math.floor(width)), height: Math.max(1, Math.floor(height)) };
38
+
39
+ if (next.width === this.width && next.height === this.height) return false;
40
+
41
+ this.buffer.destroy();
42
+ this.width = next.width;
43
+ this.height = next.height;
44
+ this.buffer = this.create();
45
+
46
+ return true;
47
+ }
48
+
49
+ clear(encoder: GPUCommandEncoder) {
50
+ encoder.clearBuffer(this.buffer);
51
+ }
52
+
53
+ destroy() {
54
+ this.buffer.destroy();
55
+ }
56
+
57
+ private create(): GPUBuffer {
58
+ return this.device.createBuffer({
59
+ size: this.byteLength,
60
+ // clearBuffer is a copy into the buffer: without COPY_DST it fails validation and clears nothing.
61
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
62
+ });
63
+ }
64
+ }
package/src/bvh.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Binned SAH bounding volume hierarchy, built straight into the layout the shader reads: eight words per node, the
3
+ * first four a minimum corner and a left child or leaf start, the last four a maximum corner and a right child or
4
+ * triangle count, matching `struct BvhNode` in `wgsl/bvh.wgsl.ts`.
5
+ *
6
+ * The bounds are computed from the same `Float32Array` the shader traverses, so every corner is already a float and
7
+ * needs no widening. Building from double precision positions, as the CPU renderer does, would need each minimum
8
+ * rounded down and each maximum rounded up before they reach the GPU.
9
+ */
10
+
11
+ /** Words per node in the packed buffer. */
12
+ export const NODE_WORDS = 8;
13
+
14
+ /** Triangles a leaf may hold. */
15
+ const MAX_LEAF_SIZE = 4;
16
+
17
+ /** Candidate split positions evaluated per axis. */
18
+ const BIN_COUNT = 12;
19
+
20
+ /** What descending one more level costs, relative to intersecting one triangle. */
21
+ const TRAVERSAL_COST = 0.125;
22
+
23
+ /**
24
+ * Deepest node the builder produces. The shader traverses with a fixed stack, and one entry per level is enough
25
+ * because a node only pushes its far child. Depth is capped rather than trusted.
26
+ */
27
+ export const MAX_BVH_DEPTH = 32;
28
+
29
+ export interface PackedBvh {
30
+ /** `nodeCount * NODE_WORDS` words, read as floats for the corners and as signed integers for the links. */
31
+ nodes: ArrayBuffer;
32
+ nodeCount: number;
33
+ /** Triangle indices in leaf order: a leaf covers `order[start] .. order[start + count - 1]`. */
34
+ order: Uint32Array;
35
+ maxDepth: number;
36
+ }
37
+
38
+ /** Per-triangle bounds and centroids, the only inputs the split needs. */
39
+ function triangleBounds(positions: Float32Array, triangleCount: number) {
40
+ const min = new Float32Array(triangleCount * 3);
41
+ const max = new Float32Array(triangleCount * 3);
42
+ const centroid = new Float64Array(triangleCount * 3);
43
+
44
+ for (let triangle = 0; triangle < triangleCount; triangle++) {
45
+ for (let axis = 0; axis < 3; axis++) {
46
+ const a = positions[triangle * 9 + axis];
47
+ const b = positions[triangle * 9 + 3 + axis];
48
+ const c = positions[triangle * 9 + 6 + axis];
49
+
50
+ min[triangle * 3 + axis] = Math.min(a, b, c);
51
+ max[triangle * 3 + axis] = Math.max(a, b, c);
52
+ centroid[triangle * 3 + axis] = (a + b + c) / 3;
53
+ }
54
+ }
55
+
56
+ return { min, max, centroid };
57
+ }
58
+
59
+ export function buildBvh(positions: Float32Array, triangleCount: number): PackedBvh {
60
+ const order = new Uint32Array(triangleCount);
61
+
62
+ for (let i = 0; i < triangleCount; i++) order[i] = i;
63
+
64
+ if (triangleCount === 0)
65
+ return { nodes: new ArrayBuffer(0), nodeCount: 0, order, maxDepth: 0 };
66
+
67
+ const { min, max, centroid } = triangleBounds(positions, triangleCount);
68
+ // The heuristic may split down to one triangle per leaf, so the pool starts at the common case and grows.
69
+ let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) * NODE_WORDS * 4);
70
+ let floats = new Float32Array(nodes);
71
+ let ints = new Int32Array(nodes);
72
+
73
+ const reserve = (needed: number) => {
74
+ if (needed * NODE_WORDS * 4 <= nodes.byteLength) return;
75
+
76
+ const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (NODE_WORDS * 4) * 1.6))
77
+ * NODE_WORDS * 4);
78
+
79
+ new Float32Array(grown).set(floats);
80
+ nodes = grown;
81
+ floats = new Float32Array(nodes);
82
+ ints = new Int32Array(nodes);
83
+ };
84
+
85
+ // The pending ranges, four integers each: start, count, node and depth. Typed, so the loop allocates nothing.
86
+ let stack = new Int32Array(4 * 2 * MAX_BVH_DEPTH + 8);
87
+ let top = 1;
88
+ const bins = new Bins();
89
+ let nodeCount = 1;
90
+ let maxDepth = 0;
91
+
92
+ stack[0] = 0;
93
+ stack[1] = triangleCount;
94
+ stack[2] = 0;
95
+ stack[3] = 0;
96
+
97
+ while (top) {
98
+ top--;
99
+
100
+ const start = stack[top * 4], count = stack[top * 4 + 1], node = stack[top * 4 + 2], depth = stack[top * 4 + 3];
101
+ const end = start + count;
102
+
103
+ if (depth > maxDepth) maxDepth = depth;
104
+
105
+ // The node's bounds and its centroids' bounds, in one pass. Doubles, so the corners are exactly the floats read.
106
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
107
+ let cMinX = Infinity, cMinY = Infinity, cMinZ = Infinity, cMaxX = -Infinity, cMaxY = -Infinity, cMaxZ = -Infinity;
108
+
109
+ for (let i = start; i < end; i++) {
110
+ const triangle = order[i] * 3;
111
+ let value = min[triangle];
112
+
113
+ if (value < minX) minX = value;
114
+ value = min[triangle + 1];
115
+ if (value < minY) minY = value;
116
+ value = min[triangle + 2];
117
+ if (value < minZ) minZ = value;
118
+ value = max[triangle];
119
+ if (value > maxX) maxX = value;
120
+ value = max[triangle + 1];
121
+ if (value > maxY) maxY = value;
122
+ value = max[triangle + 2];
123
+ if (value > maxZ) maxZ = value;
124
+ value = centroid[triangle];
125
+ if (value < cMinX) cMinX = value;
126
+ if (value > cMaxX) cMaxX = value;
127
+ value = centroid[triangle + 1];
128
+ if (value < cMinY) cMinY = value;
129
+ if (value > cMaxY) cMaxY = value;
130
+ value = centroid[triangle + 2];
131
+ if (value < cMinZ) cMinZ = value;
132
+ if (value > cMaxZ) cMaxZ = value;
133
+ }
134
+
135
+ const word = node * NODE_WORDS;
136
+
137
+ floats[word] = minX;
138
+ floats[word + 1] = minY;
139
+ floats[word + 2] = minZ;
140
+ floats[word + 4] = maxX;
141
+ floats[word + 5] = maxY;
142
+ floats[word + 6] = maxZ;
143
+
144
+ let split = -1;
145
+
146
+ if (count > MAX_LEAF_SIZE && depth + 1 < MAX_BVH_DEPTH) {
147
+ bins.centroidMin[0] = cMinX;
148
+ bins.centroidMin[1] = cMinY;
149
+ bins.centroidMin[2] = cMinZ;
150
+ bins.centroidMax[0] = cMaxX;
151
+ bins.centroidMax[1] = cMaxY;
152
+ bins.centroidMax[2] = cMaxZ;
153
+ split = partition(order, centroid, min, max, start, end, area(minX, minY, minZ, maxX, maxY, maxZ), bins);
154
+ }
155
+
156
+ if (split < 0) {
157
+ // A leaf points into `order` instead of into the node array, which the sign of the first link tells apart.
158
+ ints[word + 3] = -1 - start;
159
+ ints[word + 7] = count;
160
+ continue;
161
+ }
162
+
163
+ reserve(nodeCount + 2);
164
+
165
+ const left = nodeCount++;
166
+ const right = nodeCount++;
167
+
168
+ ints[word + 3] = left;
169
+ ints[word + 7] = right;
170
+
171
+ if ((top + 2) * 4 > stack.length) {
172
+ const grown = new Int32Array(stack.length * 2);
173
+
174
+ grown.set(stack);
175
+ stack = grown;
176
+ }
177
+
178
+ // Left first, then right, so the right side is built first, as the order the tree was always built in.
179
+ stack.set([start, split - start, left, depth + 1, split, end - split, right, depth + 1], top * 4);
180
+ top += 2;
181
+ }
182
+
183
+ return { nodes: nodes.slice(0, nodeCount * NODE_WORDS * 4), nodeCount, order, maxDepth };
184
+ }
185
+
186
+ /** Surface area, the cost the split heuristic weighs each side by. Empty bounds cost nothing. */
187
+ function area(minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number {
188
+ const x = maxX - minX, y = maxY - minY, z = maxZ - minZ;
189
+
190
+ return x < 0 || y < 0 || z < 0 ? 0 : 2 * (x * y + y * z + z * x);
191
+ }
192
+
193
+ /** Scratch space for one split, kept across every node so binning allocates nothing. */
194
+ class Bins {
195
+ readonly centroidMin = new Float64Array(3);
196
+ readonly centroidMax = new Float64Array(3);
197
+ /** Six doubles per bin and axis: the minimum corner, then the maximum. */
198
+ readonly bounds = new Float64Array(3 * BIN_COUNT * 6);
199
+ readonly counts = new Int32Array(3 * BIN_COUNT);
200
+ readonly rightArea = new Float64Array(BIN_COUNT);
201
+ readonly rightCount = new Int32Array(BIN_COUNT);
202
+ readonly scale = new Float64Array(3);
203
+
204
+ reset() {
205
+ const bounds = this.bounds;
206
+
207
+ for (let i = 0; i < bounds.length; i += 6) {
208
+ bounds[i] = bounds[i + 1] = bounds[i + 2] = Infinity;
209
+ bounds[i + 3] = bounds[i + 4] = bounds[i + 5] = -Infinity;
210
+ }
211
+
212
+ this.counts.fill(0);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Reorders `order` in place so the range splits in two, and returns where the right side starts, or -1 when no split
218
+ * beats keeping the range as a leaf. Bins the centroids on all three axes in one pass and sweeps the surface area
219
+ * heuristic on each.
220
+ */
221
+ function partition(order: Uint32Array, centroid: Float64Array, min: Float32Array, max: Float32Array,
222
+ start: number, end: number, parentArea: number, bins: Bins): number {
223
+ if (parentArea <= 0) return -1;
224
+
225
+ const { centroidMin, centroidMax, bounds, counts, rightArea, rightCount, scale } = bins;
226
+ let binned = false;
227
+
228
+ for (let axis = 0; axis < 3; axis++) {
229
+ const extent = centroidMax[axis] - centroidMin[axis];
230
+
231
+ // Every centroid sits at the same place on this axis: no split here separates anything.
232
+ scale[axis] = extent > 0 ? BIN_COUNT / extent : 0;
233
+ binned ||= extent > 0;
234
+ }
235
+
236
+ if (!binned) return -1;
237
+
238
+ bins.reset();
239
+
240
+ for (let i = start; i < end; i++) {
241
+ const triangle = order[i] * 3;
242
+ const x0 = min[triangle], y0 = min[triangle + 1], z0 = min[triangle + 2];
243
+ const x1 = max[triangle], y1 = max[triangle + 1], z1 = max[triangle + 2];
244
+
245
+ for (let axis = 0; axis < 3; axis++) {
246
+ if (scale[axis] === 0) continue;
247
+
248
+ const bin = axis * BIN_COUNT + binOf(centroid[triangle + axis], centroidMin[axis], scale[axis]);
249
+ const at = bin * 6;
250
+
251
+ counts[bin]++;
252
+
253
+ if (x0 < bounds[at]) bounds[at] = x0;
254
+ if (y0 < bounds[at + 1]) bounds[at + 1] = y0;
255
+ if (z0 < bounds[at + 2]) bounds[at + 2] = z0;
256
+ if (x1 > bounds[at + 3]) bounds[at + 3] = x1;
257
+ if (y1 > bounds[at + 4]) bounds[at + 4] = y1;
258
+ if (z1 > bounds[at + 5]) bounds[at + 5] = z1;
259
+ }
260
+ }
261
+
262
+ // Splitting costs a traversal step, so a split only wins when it beats shading the whole range as one leaf.
263
+ let bestCost = end - start, bestAxis = -1, bestBin = -1;
264
+
265
+ for (let axis = 0; axis < 3; axis++) {
266
+ if (scale[axis] === 0) continue;
267
+
268
+ // Sweeping once from each side turns the heuristic into two passes instead of one per candidate split.
269
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
270
+ let running = 0;
271
+
272
+ for (let bin = BIN_COUNT - 1; bin > 0; bin--) {
273
+ const at = (axis * BIN_COUNT + bin) * 6;
274
+
275
+ if (bounds[at] < minX) minX = bounds[at];
276
+ if (bounds[at + 1] < minY) minY = bounds[at + 1];
277
+ if (bounds[at + 2] < minZ) minZ = bounds[at + 2];
278
+ if (bounds[at + 3] > maxX) maxX = bounds[at + 3];
279
+ if (bounds[at + 4] > maxY) maxY = bounds[at + 4];
280
+ if (bounds[at + 5] > maxZ) maxZ = bounds[at + 5];
281
+ running += counts[axis * BIN_COUNT + bin];
282
+ rightArea[bin] = area(minX, minY, minZ, maxX, maxY, maxZ);
283
+ rightCount[bin] = running;
284
+ }
285
+
286
+ minX = minY = minZ = Infinity;
287
+ maxX = maxY = maxZ = -Infinity;
288
+ running = 0;
289
+
290
+ for (let bin = 0; bin < BIN_COUNT - 1; bin++) {
291
+ const at = (axis * BIN_COUNT + bin) * 6;
292
+
293
+ if (bounds[at] < minX) minX = bounds[at];
294
+ if (bounds[at + 1] < minY) minY = bounds[at + 1];
295
+ if (bounds[at + 2] < minZ) minZ = bounds[at + 2];
296
+ if (bounds[at + 3] > maxX) maxX = bounds[at + 3];
297
+ if (bounds[at + 4] > maxY) maxY = bounds[at + 4];
298
+ if (bounds[at + 5] > maxZ) maxZ = bounds[at + 5];
299
+ running += counts[axis * BIN_COUNT + bin];
300
+
301
+ if (!running || !rightCount[bin + 1]) continue;
302
+
303
+ const cost = TRAVERSAL_COST
304
+ + (area(minX, minY, minZ, maxX, maxY, maxZ) * running + rightArea[bin + 1] * rightCount[bin + 1]) / parentArea;
305
+
306
+ if (cost < bestCost) {
307
+ bestCost = cost;
308
+ bestAxis = axis;
309
+ bestBin = bin;
310
+ }
311
+ }
312
+ }
313
+
314
+ if (bestAxis < 0) return -1;
315
+
316
+ const pivot = centroidMin[bestAxis];
317
+ const bestScale = scale[bestAxis];
318
+ let left = start;
319
+
320
+ for (let i = start; i < end; i++) {
321
+ if (binOf(centroid[order[i] * 3 + bestAxis], pivot, bestScale) <= bestBin) {
322
+ const swap = order[i];
323
+
324
+ order[i] = order[left];
325
+ order[left] = swap;
326
+ left++;
327
+ }
328
+ }
329
+
330
+ // The sweep only proposes splits both sides occupy, so this is a guard against rounding, not an expected outcome.
331
+ return left === start || left === end ? -1 : left;
332
+ }
333
+
334
+ function binOf(value: number, origin: number, scale: number): number {
335
+ return Math.min(BIN_COUNT - 1, Math.max(0, Math.floor((value - origin) * scale)));
336
+ }