@tinypoly/gpu-renderer 0.0.0-stage → 0.0.0

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 (194) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +21 -0
  3. package/README.md +258 -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 +207 -0
  11. package/dist/bvh.js.map +1 -0
  12. package/dist/camera.d.ts +29 -0
  13. package/dist/camera.d.ts.map +1 -0
  14. package/dist/camera.js +48 -0
  15. package/dist/camera.js.map +1 -0
  16. package/dist/canvas.d.ts +9 -0
  17. package/dist/canvas.d.ts.map +1 -0
  18. package/dist/canvas.js +11 -0
  19. package/dist/canvas.js.map +1 -0
  20. package/dist/denoise.d.ts +69 -0
  21. package/dist/denoise.d.ts.map +1 -0
  22. package/dist/denoise.js +455 -0
  23. package/dist/denoise.js.map +1 -0
  24. package/dist/device.d.ts +37 -0
  25. package/dist/device.d.ts.map +1 -0
  26. package/dist/device.js +52 -0
  27. package/dist/device.js.map +1 -0
  28. package/dist/dispatchScheduler.d.ts +43 -0
  29. package/dist/dispatchScheduler.d.ts.map +1 -0
  30. package/dist/dispatchScheduler.js +49 -0
  31. package/dist/dispatchScheduler.js.map +1 -0
  32. package/dist/environment.d.ts +39 -0
  33. package/dist/environment.d.ts.map +1 -0
  34. package/dist/environment.js +168 -0
  35. package/dist/environment.js.map +1 -0
  36. package/dist/frameLayout.d.ts +4 -0
  37. package/dist/frameLayout.d.ts.map +1 -0
  38. package/dist/frameLayout.js +58 -0
  39. package/dist/frameLayout.js.map +1 -0
  40. package/dist/geometry.d.ts +75 -0
  41. package/dist/geometry.d.ts.map +1 -0
  42. package/dist/geometry.js +671 -0
  43. package/dist/geometry.js.map +1 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +17 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/lights.d.ts +64 -0
  49. package/dist/lights.d.ts.map +1 -0
  50. package/dist/lights.js +264 -0
  51. package/dist/lights.js.map +1 -0
  52. package/dist/materialLayout.d.ts +143 -0
  53. package/dist/materialLayout.d.ts.map +1 -0
  54. package/dist/materialLayout.js +297 -0
  55. package/dist/materialLayout.js.map +1 -0
  56. package/dist/math.d.ts +10 -0
  57. package/dist/math.d.ts.map +1 -0
  58. package/dist/math.js +40 -0
  59. package/dist/math.js.map +1 -0
  60. package/dist/pathTracePass.d.ts +27 -0
  61. package/dist/pathTracePass.d.ts.map +1 -0
  62. package/dist/pathTracePass.js +83 -0
  63. package/dist/pathTracePass.js.map +1 -0
  64. package/dist/planarCapture.d.ts +38 -0
  65. package/dist/planarCapture.d.ts.map +1 -0
  66. package/dist/planarCapture.js +163 -0
  67. package/dist/planarCapture.js.map +1 -0
  68. package/dist/presentPass.d.ts +31 -0
  69. package/dist/presentPass.d.ts.map +1 -0
  70. package/dist/presentPass.js +78 -0
  71. package/dist/presentPass.js.map +1 -0
  72. package/dist/renderer.d.ts +196 -0
  73. package/dist/renderer.d.ts.map +1 -0
  74. package/dist/renderer.js +839 -0
  75. package/dist/renderer.js.map +1 -0
  76. package/dist/sceneAssets.d.ts +24 -0
  77. package/dist/sceneAssets.d.ts.map +1 -0
  78. package/dist/sceneAssets.js +77 -0
  79. package/dist/sceneAssets.js.map +1 -0
  80. package/dist/sceneUpload.d.ts +61 -0
  81. package/dist/sceneUpload.d.ts.map +1 -0
  82. package/dist/sceneUpload.js +98 -0
  83. package/dist/sceneUpload.js.map +1 -0
  84. package/dist/shaderModule.d.ts +7 -0
  85. package/dist/shaderModule.d.ts.map +1 -0
  86. package/dist/shaderModule.js +19 -0
  87. package/dist/shaderModule.js.map +1 -0
  88. package/dist/shaderProgram.d.ts +124 -0
  89. package/dist/shaderProgram.d.ts.map +1 -0
  90. package/dist/shaderProgram.js +1030 -0
  91. package/dist/shaderProgram.js.map +1 -0
  92. package/dist/structLayout.d.ts +28 -0
  93. package/dist/structLayout.d.ts.map +1 -0
  94. package/dist/structLayout.js +58 -0
  95. package/dist/structLayout.js.map +1 -0
  96. package/dist/texturePack.d.ts +54 -0
  97. package/dist/texturePack.d.ts.map +1 -0
  98. package/dist/texturePack.js +86 -0
  99. package/dist/texturePack.js.map +1 -0
  100. package/dist/upstream.d.ts +7 -0
  101. package/dist/upstream.d.ts.map +1 -0
  102. package/dist/upstream.js +11 -0
  103. package/dist/upstream.js.map +1 -0
  104. package/dist/volumetric.d.ts +10 -0
  105. package/dist/volumetric.d.ts.map +1 -0
  106. package/dist/volumetric.js +34 -0
  107. package/dist/volumetric.js.map +1 -0
  108. package/dist/wgsl/bsdf.wgsl.d.ts +13 -0
  109. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -0
  110. package/dist/wgsl/bsdf.wgsl.js +619 -0
  111. package/dist/wgsl/bsdf.wgsl.js.map +1 -0
  112. package/dist/wgsl/bvh.wgsl.d.ts +3 -0
  113. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -0
  114. package/dist/wgsl/bvh.wgsl.js +218 -0
  115. package/dist/wgsl/bvh.wgsl.js.map +1 -0
  116. package/dist/wgsl/common.wgsl.d.ts +3 -0
  117. package/dist/wgsl/common.wgsl.d.ts.map +1 -0
  118. package/dist/wgsl/common.wgsl.js +193 -0
  119. package/dist/wgsl/common.wgsl.js.map +1 -0
  120. package/dist/wgsl/environment.wgsl.d.ts +10 -0
  121. package/dist/wgsl/environment.wgsl.d.ts.map +1 -0
  122. package/dist/wgsl/environment.wgsl.js +145 -0
  123. package/dist/wgsl/environment.wgsl.js.map +1 -0
  124. package/dist/wgsl/lights.wgsl.d.ts +10 -0
  125. package/dist/wgsl/lights.wgsl.d.ts.map +1 -0
  126. package/dist/wgsl/lights.wgsl.js +434 -0
  127. package/dist/wgsl/lights.wgsl.js.map +1 -0
  128. package/dist/wgsl/pathTrace.wgsl.d.ts +14 -0
  129. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -0
  130. package/dist/wgsl/pathTrace.wgsl.js +1020 -0
  131. package/dist/wgsl/pathTrace.wgsl.js.map +1 -0
  132. package/dist/wgsl/present.wgsl.d.ts +7 -0
  133. package/dist/wgsl/present.wgsl.d.ts.map +1 -0
  134. package/dist/wgsl/present.wgsl.js +154 -0
  135. package/dist/wgsl/present.wgsl.js.map +1 -0
  136. package/dist/wgsl/random.wgsl.d.ts +9 -0
  137. package/dist/wgsl/random.wgsl.d.ts.map +1 -0
  138. package/dist/wgsl/random.wgsl.js +91 -0
  139. package/dist/wgsl/random.wgsl.js.map +1 -0
  140. package/dist/wgsl/sampling.wgsl.d.ts +3 -0
  141. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -0
  142. package/dist/wgsl/sampling.wgsl.js +31 -0
  143. package/dist/wgsl/sampling.wgsl.js.map +1 -0
  144. package/dist/wgsl/shaderSupport.wgsl.d.ts +6 -0
  145. package/dist/wgsl/shaderSupport.wgsl.d.ts.map +1 -0
  146. package/dist/wgsl/shaderSupport.wgsl.js +120 -0
  147. package/dist/wgsl/shaderSupport.wgsl.js.map +1 -0
  148. package/dist/wgsl/surface.wgsl.d.ts +10 -0
  149. package/dist/wgsl/surface.wgsl.d.ts.map +1 -0
  150. package/dist/wgsl/surface.wgsl.js +794 -0
  151. package/dist/wgsl/surface.wgsl.js.map +1 -0
  152. package/dist/wgsl/texture.wgsl.d.ts +11 -0
  153. package/dist/wgsl/texture.wgsl.d.ts.map +1 -0
  154. package/dist/wgsl/texture.wgsl.js +132 -0
  155. package/dist/wgsl/texture.wgsl.js.map +1 -0
  156. package/package.json +76 -3
  157. package/src/accumulator.ts +64 -0
  158. package/src/bvh.ts +272 -0
  159. package/src/camera.ts +77 -0
  160. package/src/canvas.ts +13 -0
  161. package/src/denoise.ts +514 -0
  162. package/src/device.ts +81 -0
  163. package/src/dispatchScheduler.ts +71 -0
  164. package/src/environment.ts +233 -0
  165. package/src/frameLayout.ts +59 -0
  166. package/src/geometry.ts +899 -0
  167. package/src/index.ts +61 -0
  168. package/src/lights.ts +365 -0
  169. package/src/materialLayout.ts +408 -0
  170. package/src/math.ts +48 -0
  171. package/src/pathTracePass.ts +97 -0
  172. package/src/planarCapture.ts +225 -0
  173. package/src/presentPass.ts +99 -0
  174. package/src/renderer.ts +1117 -0
  175. package/src/sceneAssets.ts +118 -0
  176. package/src/sceneUpload.ts +185 -0
  177. package/src/shaderModule.ts +19 -0
  178. package/src/shaderProgram.ts +1370 -0
  179. package/src/structLayout.ts +93 -0
  180. package/src/texturePack.ts +152 -0
  181. package/src/upstream.ts +56 -0
  182. package/src/volumetric.ts +42 -0
  183. package/src/wgsl/bsdf.wgsl.ts +618 -0
  184. package/src/wgsl/bvh.wgsl.ts +217 -0
  185. package/src/wgsl/common.wgsl.ts +193 -0
  186. package/src/wgsl/environment.wgsl.ts +144 -0
  187. package/src/wgsl/lights.wgsl.ts +433 -0
  188. package/src/wgsl/pathTrace.wgsl.ts +1019 -0
  189. package/src/wgsl/present.wgsl.ts +153 -0
  190. package/src/wgsl/random.wgsl.ts +90 -0
  191. package/src/wgsl/sampling.wgsl.ts +30 -0
  192. package/src/wgsl/shaderSupport.wgsl.ts +119 -0
  193. package/src/wgsl/surface.wgsl.ts +793 -0
  194. 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,79 @@
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.0",
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
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "!dist/**/*.test.*",
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "ARCHITECTURE.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "dependencies": {
46
+ "@tinypoly/cpu-renderer": "^0.3.0",
47
+ "naga-wasm": "30.2.0"
48
+ },
49
+ "peerDependencies": {
50
+ "three": ">=0.185.0"
51
+ },
52
+ "devDependencies": {
53
+ "@eslint/js": "^10.0.1",
54
+ "@stylistic/eslint-plugin": "^5.10.0",
55
+ "@types/node": "^24",
56
+ "@types/three": "^0.185.4",
57
+ "@webgpu/types": "^0.1.40",
58
+ "eslint": "^10.11.0",
59
+ "globals": "^17.12.0",
60
+ "three": "^0.185.1",
61
+ "tsx": "^4.23.0",
62
+ "typescript": "^6.0.3",
63
+ "typescript-eslint": "^8.63.0",
64
+ "vite": "^8.1.4",
65
+ "vitest": "^4.1.10"
66
+ },
67
+ "scripts": {
68
+ "build": "tsc -p tsconfig.build.json",
69
+ "typecheck": "tsc --noEmit && tsc --noEmit -p example",
70
+ "lint": "eslint . --max-warnings 0",
71
+ "lint:fix": "eslint . --fix",
72
+ "test": "vitest run",
73
+ "test:watch": "vitest",
74
+ "verify": "node scripts/verify.mjs",
75
+ "capture:scenes": "node scripts/capture-scenes.js",
76
+ "dev": "tsc -p tsconfig.build.json && vite --config example/vite.config.ts",
77
+ "build:example": "tsc -p tsconfig.build.json && vite build --config example/vite.config.ts"
78
+ }
6
79
  }
@@ -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,272 @@
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
+ interface Range {
39
+ start: number;
40
+ count: number;
41
+ node: number;
42
+ depth: number;
43
+ }
44
+
45
+ /** Axis aligned bounds under construction, in doubles so the sweep does not accumulate rounding. */
46
+ class Bounds {
47
+ min = [Infinity, Infinity, Infinity];
48
+ max = [-Infinity, -Infinity, -Infinity];
49
+
50
+ reset() {
51
+ this.min = [Infinity, Infinity, Infinity];
52
+ this.max = [-Infinity, -Infinity, -Infinity];
53
+ }
54
+
55
+ addPoint(x: number, y: number, z: number) {
56
+ const point = [x, y, z];
57
+
58
+ for (let axis = 0; axis < 3; axis++) {
59
+ if (point[axis] < this.min[axis]) this.min[axis] = point[axis];
60
+ if (point[axis] > this.max[axis]) this.max[axis] = point[axis];
61
+ }
62
+ }
63
+
64
+ addBounds(other: Bounds) {
65
+ for (let axis = 0; axis < 3; axis++) {
66
+ if (other.min[axis] < this.min[axis]) this.min[axis] = other.min[axis];
67
+ if (other.max[axis] > this.max[axis]) this.max[axis] = other.max[axis];
68
+ }
69
+ }
70
+
71
+ /** Surface area, the cost the split heuristic weighs each side by. Empty bounds cost nothing. */
72
+ area(): number {
73
+ const x = this.max[0] - this.min[0], y = this.max[1] - this.min[1], z = this.max[2] - this.min[2];
74
+
75
+ return x < 0 || y < 0 || z < 0 ? 0 : 2 * (x * y + y * z + z * x);
76
+ }
77
+ }
78
+
79
+ /** Per-triangle bounds and centroids, the only inputs the split needs. */
80
+ function triangleBounds(positions: Float32Array, triangleCount: number) {
81
+ const min = new Float32Array(triangleCount * 3);
82
+ const max = new Float32Array(triangleCount * 3);
83
+ const centroid = new Float64Array(triangleCount * 3);
84
+
85
+ for (let triangle = 0; triangle < triangleCount; triangle++) {
86
+ for (let axis = 0; axis < 3; axis++) {
87
+ const a = positions[triangle * 9 + axis];
88
+ const b = positions[triangle * 9 + 3 + axis];
89
+ const c = positions[triangle * 9 + 6 + axis];
90
+
91
+ min[triangle * 3 + axis] = Math.min(a, b, c);
92
+ max[triangle * 3 + axis] = Math.max(a, b, c);
93
+ centroid[triangle * 3 + axis] = (a + b + c) / 3;
94
+ }
95
+ }
96
+
97
+ return { min, max, centroid };
98
+ }
99
+
100
+ export function buildBvh(positions: Float32Array, triangleCount: number): PackedBvh {
101
+ const order = new Uint32Array(triangleCount);
102
+
103
+ for (let i = 0; i < triangleCount; i++) order[i] = i;
104
+
105
+ if (triangleCount === 0)
106
+ return { nodes: new ArrayBuffer(0), nodeCount: 0, order, maxDepth: 0 };
107
+
108
+ const { min, max, centroid } = triangleBounds(positions, triangleCount);
109
+ // The heuristic may split down to one triangle per leaf, so the pool starts at the common case and grows.
110
+ let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) * NODE_WORDS * 4);
111
+ let floats = new Float32Array(nodes);
112
+ let ints = new Int32Array(nodes);
113
+
114
+ const reserve = (needed: number) => {
115
+ if (needed * NODE_WORDS * 4 <= nodes.byteLength) return;
116
+
117
+ const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (NODE_WORDS * 4) * 1.6))
118
+ * NODE_WORDS * 4);
119
+
120
+ new Float32Array(grown).set(floats);
121
+ nodes = grown;
122
+ floats = new Float32Array(nodes);
123
+ ints = new Int32Array(nodes);
124
+ };
125
+
126
+ const stack: Range[] = [{ start: 0, count: triangleCount, node: 0, depth: 0 }];
127
+ const bounds = new Bounds();
128
+ let nodeCount = 1;
129
+ let maxDepth = 0;
130
+
131
+ while (stack.length) {
132
+ const range = stack.pop() as Range;
133
+
134
+ maxDepth = Math.max(maxDepth, range.depth);
135
+ bounds.reset();
136
+
137
+ for (let i = range.start; i < range.start + range.count; i++) {
138
+ const triangle = order[i] * 3;
139
+
140
+ bounds.addPoint(min[triangle], min[triangle + 1], min[triangle + 2]);
141
+ bounds.addPoint(max[triangle], max[triangle + 1], max[triangle + 2]);
142
+ }
143
+
144
+ const word = range.node * NODE_WORDS;
145
+
146
+ floats[word] = bounds.min[0];
147
+ floats[word + 1] = bounds.min[1];
148
+ floats[word + 2] = bounds.min[2];
149
+ floats[word + 4] = bounds.max[0];
150
+ floats[word + 5] = bounds.max[1];
151
+ floats[word + 6] = bounds.max[2];
152
+
153
+ const split = range.count > MAX_LEAF_SIZE && range.depth + 1 < MAX_BVH_DEPTH
154
+ ? partition(order, centroid, min, max, range, bounds.area())
155
+ : -1;
156
+
157
+ if (split < 0) {
158
+ // A leaf points into `order` instead of into the node array, which the sign of the first link tells apart.
159
+ ints[word + 3] = -1 - range.start;
160
+ ints[word + 7] = range.count;
161
+ continue;
162
+ }
163
+
164
+ reserve(nodeCount + 2);
165
+
166
+ const left = nodeCount++;
167
+ const right = nodeCount++;
168
+
169
+ ints[word + 3] = left;
170
+ ints[word + 7] = right;
171
+ stack.push({ start: range.start, count: split - range.start, node: left, depth: range.depth + 1 });
172
+ stack.push({ start: split, count: range.start + range.count - split, node: right, depth: range.depth + 1 });
173
+ }
174
+
175
+ return { nodes: nodes.slice(0, nodeCount * NODE_WORDS * 4), nodeCount, order, maxDepth };
176
+ }
177
+
178
+ /**
179
+ * Reorders `order` in place so the range splits in two, and returns where the right side starts, or -1 when no split
180
+ * beats keeping the range as a leaf. Bins the centroids on all three axes and sweeps the surface area heuristic.
181
+ */
182
+ function partition(order: Uint32Array, centroid: Float64Array, min: Float32Array, max: Float32Array,
183
+ range: Range, parentArea: number): number {
184
+ if (parentArea <= 0) return -1;
185
+
186
+ const end = range.start + range.count;
187
+ const centroidBounds = new Bounds();
188
+
189
+ for (let i = range.start; i < end; i++) {
190
+ const triangle = order[i] * 3;
191
+
192
+ centroidBounds.addPoint(centroid[triangle], centroid[triangle + 1], centroid[triangle + 2]);
193
+ }
194
+
195
+ // Splitting costs a traversal step, so a split only wins when it beats shading the whole range as one leaf.
196
+ let bestCost = range.count, bestAxis = -1, bestBin = -1, bestScale = 0;
197
+
198
+ for (let axis = 0; axis < 3; axis++) {
199
+ const extent = centroidBounds.max[axis] - centroidBounds.min[axis];
200
+
201
+ // Every centroid sits at the same place on this axis: no split here separates anything.
202
+ if (extent <= 0) continue;
203
+
204
+ const scale = BIN_COUNT / extent;
205
+ const binBounds = Array.from({ length: BIN_COUNT }, () => new Bounds());
206
+ const binCounts = new Int32Array(BIN_COUNT);
207
+
208
+ for (let i = range.start; i < end; i++) {
209
+ const triangle = order[i] * 3;
210
+ const bin = binOf(centroid[triangle + axis], centroidBounds.min[axis], scale);
211
+
212
+ binCounts[bin]++;
213
+ binBounds[bin].addPoint(min[triangle], min[triangle + 1], min[triangle + 2]);
214
+ binBounds[bin].addPoint(max[triangle], max[triangle + 1], max[triangle + 2]);
215
+ }
216
+
217
+ // Sweeping once from each side turns the heuristic into two passes instead of one per candidate split.
218
+ const rightArea = new Float64Array(BIN_COUNT);
219
+ const rightCount = new Int32Array(BIN_COUNT);
220
+ const accumulated = new Bounds();
221
+ let running = 0;
222
+
223
+ for (let bin = BIN_COUNT - 1; bin > 0; bin--) {
224
+ accumulated.addBounds(binBounds[bin]);
225
+ running += binCounts[bin];
226
+ rightArea[bin] = accumulated.area();
227
+ rightCount[bin] = running;
228
+ }
229
+
230
+ accumulated.reset();
231
+ running = 0;
232
+
233
+ for (let bin = 0; bin < BIN_COUNT - 1; bin++) {
234
+ accumulated.addBounds(binBounds[bin]);
235
+ running += binCounts[bin];
236
+
237
+ if (!running || !rightCount[bin + 1]) continue;
238
+
239
+ const cost = TRAVERSAL_COST
240
+ + (accumulated.area() * running + rightArea[bin + 1] * rightCount[bin + 1]) / parentArea;
241
+
242
+ if (cost < bestCost) {
243
+ bestCost = cost;
244
+ bestAxis = axis;
245
+ bestBin = bin;
246
+ bestScale = scale;
247
+ }
248
+ }
249
+ }
250
+
251
+ if (bestAxis < 0) return -1;
252
+
253
+ const pivot = centroidBounds.min[bestAxis];
254
+ let left = range.start;
255
+
256
+ for (let i = range.start; i < end; i++) {
257
+ if (binOf(centroid[order[i] * 3 + bestAxis], pivot, bestScale) <= bestBin) {
258
+ const swap = order[i];
259
+
260
+ order[i] = order[left];
261
+ order[left] = swap;
262
+ left++;
263
+ }
264
+ }
265
+
266
+ // The sweep only proposes splits both sides occupy, so this is a guard against rounding, not an expected outcome.
267
+ return left === range.start || left === end ? -1 : left;
268
+ }
269
+
270
+ function binOf(value: number, origin: number, scale: number): number {
271
+ return Math.min(BIN_COUNT - 1, Math.max(0, Math.floor((value - origin) * scale)));
272
+ }
package/src/camera.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { Matrix4, OrthographicCamera, PerspectiveCamera } from "three";
2
+ import type { CameraState } from "./upstream.js";
3
+
4
+ /**
5
+ * Everything the renderer derives from a camera snapshot. Rays are cast through the inverse of the camera's own
6
+ * projection, so zoom, film offsets, `setViewOffset` and any custom projection come out exactly as Three draws them,
7
+ * and so do the near and far planes, which clip a camera ray the way they clip a rasterized fragment.
8
+ */
9
+ export interface CameraMatrices {
10
+ /** Column-major, as Three stores them. */
11
+ cameraWorld: number[];
12
+ view: number[];
13
+ projection: number[];
14
+ inverseProjection: number[];
15
+ orthographic: boolean;
16
+ near: number;
17
+ far: number;
18
+ /** Vertical field of view in degrees, for the lens; 50 when the snapshot has none. */
19
+ fov: number;
20
+ position: number[];
21
+ }
22
+
23
+ /** The camera's projection: the snapshot's own when it has one, otherwise rebuilt from its parameters. */
24
+ export function cameraProjection(camera: CameraState, aspect: number): Matrix4 {
25
+ if (camera.projectionMatrix) return new Matrix4().fromArray(camera.projectionMatrix);
26
+
27
+ const projection = camera.type === "orthographic"
28
+ ? new OrthographicCamera(camera.left, camera.right, camera.top, camera.bottom, camera.near, camera.far)
29
+ : new PerspectiveCamera(camera.fov ?? 50, camera.aspect ?? aspect, camera.near, camera.far);
30
+
31
+ projection.zoom = camera.zoom ?? 1;
32
+ projection.updateProjectionMatrix();
33
+
34
+ return projection.projectionMatrix;
35
+ }
36
+
37
+ export function cameraMatrices(camera: CameraState, width: number, height: number): CameraMatrices {
38
+ const world = new Matrix4().fromArray(camera.matrixWorld);
39
+ const projection = cameraProjection(camera, width / height);
40
+ const orthographic = camera.type === "orthographic" || (projection.elements[11] === 0 && projection.elements[15] === 1);
41
+
42
+ return {
43
+ cameraWorld: world.toArray(),
44
+ view: world.clone().invert().toArray(),
45
+ projection: projection.toArray(),
46
+ inverseProjection: projection.clone().invert().toArray(),
47
+ orthographic,
48
+ near: camera.near,
49
+ far: camera.far,
50
+ fov: camera.fov ?? 50,
51
+ position: [camera.matrixWorld[12], camera.matrixWorld[13], camera.matrixWorld[14]],
52
+ };
53
+ }
54
+
55
+ /** A column-major 4x4 matrix times a point, with the w it produced. */
56
+ export function transform4(matrix: ArrayLike<number>, x: number, y: number, z: number, w = 1): number[] {
57
+ return [
58
+ matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12] * w,
59
+ matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13] * w,
60
+ matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14] * w,
61
+ matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15] * w,
62
+ ];
63
+ }
64
+
65
+ /** A point in clip space back to world space, through the camera that produced it. */
66
+ export function clipToWorld(camera: CameraMatrices, clip: readonly number[]): number[] {
67
+ const view = transform4(camera.inverseProjection, clip[0], clip[1], clip[2], clip[3]);
68
+ const w = view[3] || 1e-30;
69
+
70
+ return transform4(camera.cameraWorld, view[0] / w, view[1] / w, view[2] / w).slice(0, 3);
71
+ }
72
+
73
+ export function worldToClip(camera: CameraMatrices, point: readonly number[]): number[] {
74
+ const view = transform4(camera.view, point[0], point[1], point[2]);
75
+
76
+ return transform4(camera.projection, view[0], view[1], view[2], view[3]);
77
+ }
package/src/canvas.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { GpuRenderer } from "./renderer.js";
2
+
3
+ /**
4
+ * Sends the renderer's output to a canvas. It configures the WebGPU context, sizes the canvas to the render
5
+ * resolution and presents after every submit, so the image refines in place while it converges.
6
+ *
7
+ * Returns the function that detaches it.
8
+ */
9
+ export function attachCanvas(renderer: GpuRenderer, canvas: HTMLCanvasElement): () => void {
10
+ renderer.setOutputCanvas(canvas);
11
+
12
+ return () => renderer.setOutputCanvas(null);
13
+ }