topazcube 0.1.30 → 0.1.33

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 (96) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +18200 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +18183 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +94 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +71 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +173 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +572 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +61 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +643 -0
  38. package/src/renderer/Renderer.js +1324 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +359 -0
  42. package/src/renderer/core/CullingSystem.js +307 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +546 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2064 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +417 -0
  58. package/src/renderer/rendering/passes/FogPass.js +419 -0
  59. package/src/renderer/rendering/passes/GBufferPass.js +706 -0
  60. package/src/renderer/rendering/passes/HiZPass.js +714 -0
  61. package/src/renderer/rendering/passes/LightingPass.js +739 -0
  62. package/src/renderer/rendering/passes/ParticlePass.js +835 -0
  63. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  64. package/src/renderer/rendering/passes/PostProcessPass.js +282 -0
  65. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  66. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  67. package/src/renderer/rendering/passes/SSGIPass.js +265 -0
  68. package/src/renderer/rendering/passes/SSGITilePass.js +296 -0
  69. package/src/renderer/rendering/passes/ShadowPass.js +1822 -0
  70. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  71. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  72. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  73. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  74. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  75. package/src/renderer/rendering/shaders/geometry.wgsl +550 -0
  76. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  77. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  78. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  79. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  80. package/src/renderer/rendering/shaders/particle_render.wgsl +525 -0
  81. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  82. package/src/renderer/rendering/shaders/postproc.wgsl +272 -0
  83. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  84. package/src/renderer/rendering/shaders/shadow.wgsl +76 -0
  85. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  86. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  87. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  88. package/src/renderer/utils/BoundingSphere.js +439 -0
  89. package/src/renderer/utils/Frustum.js +281 -0
  90. package/dist/client.d.cts +0 -211
  91. package/dist/client.d.ts +0 -211
  92. package/dist/server.d.cts +0 -120
  93. package/dist/server.d.ts +0 -120
  94. package/dist/terminal.d.cts +0 -64
  95. package/dist/terminal.d.ts +0 -64
  96. package/src/utils.ts +0 -403
@@ -0,0 +1,1495 @@
1
+ import { Texture, generateMips, numMipLevels } from "../Texture.js"
2
+ import { mat4, vec3 } from "../math.js"
3
+
4
+ /**
5
+ * ProbeCapture - Captures environment reflections from a position
6
+ *
7
+ * Renders 6 cube faces and encodes to octahedral format.
8
+ * Can export as HDR PNG for server storage.
9
+ */
10
+ class ProbeCapture {
11
+ constructor(engine) {
12
+ this.engine = engine
13
+
14
+ // Capture resolution per cube face
15
+ this.faceSize = 1024
16
+
17
+ // Output octahedral texture size
18
+ this.octahedralSize = 4096
19
+
20
+ // Cube face render targets (6 faces)
21
+ this.faceTextures = []
22
+
23
+ // Depth textures for each face
24
+ this.faceDepthTextures = []
25
+
26
+ // Output octahedral texture
27
+ this.octahedralTexture = null
28
+
29
+ // Mip levels for roughness
30
+ this.mipLevels = 6
31
+
32
+ // Previous environment map (used during capture to avoid recursion)
33
+ this.fallbackEnvironment = null
34
+
35
+ // Environment encoding: 0 = equirectangular, 1 = octahedral
36
+ this.envEncoding = 0
37
+
38
+ // Scene render callback (set by RenderGraph)
39
+ this.sceneRenderCallback = null
40
+
41
+ // Capture state
42
+ this.isCapturing = false
43
+ this.capturePosition = [0, 0, 0]
44
+
45
+ // Cube face camera directions
46
+ // +X, -X, +Y, -Y, +Z, -Z
47
+ this.faceDirections = [
48
+ { dir: [1, 0, 0], up: [0, 1, 0] }, // +X
49
+ { dir: [-1, 0, 0], up: [0, 1, 0] }, // -X
50
+ { dir: [0, 1, 0], up: [0, 0, -1] }, // +Y (up)
51
+ { dir: [0, -1, 0], up: [0, 0, 1] }, // -Y (down)
52
+ { dir: [0, 0, 1], up: [0, 1, 0] }, // +Z
53
+ { dir: [0, 0, -1], up: [0, 1, 0] }, // -Z
54
+ ]
55
+
56
+ // Pipelines
57
+ this.skyboxPipeline = null
58
+ this.scenePipeline = null
59
+ this.convertPipeline = null
60
+ this.faceBindGroupLayout = null
61
+ this.faceSampler = null
62
+
63
+ // Scene rendering resources
64
+ this.sceneBindGroupLayout = null
65
+ this.sceneUniformBuffer = null
66
+ }
67
+
68
+ /**
69
+ * Set scene render callback - called to render scene for each face
70
+ * @param {Function} callback - (viewMatrix, projMatrix, colorTarget, depthTarget) => void
71
+ */
72
+ setSceneRenderCallback(callback) {
73
+ this.sceneRenderCallback = callback
74
+ }
75
+
76
+ /**
77
+ * Initialize capture resources
78
+ */
79
+ async initialize() {
80
+ const { device } = this.engine
81
+
82
+ // Create 6 face render targets with depth
83
+ for (let i = 0; i < 6; i++) {
84
+ // Color target (needs COPY_DST for scene render callback, COPY_SRC for debug save)
85
+ const colorTexture = device.createTexture({
86
+ label: `probeFace${i}`,
87
+ size: [this.faceSize, this.faceSize],
88
+ format: 'rgba16float',
89
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC
90
+ })
91
+ this.faceTextures.push({
92
+ texture: colorTexture,
93
+ view: colorTexture.createView()
94
+ })
95
+
96
+ // Depth target (depth32float to match GBuffer depth for copying)
97
+ const depthTexture = device.createTexture({
98
+ label: `probeFaceDepth${i}`,
99
+ size: [this.faceSize, this.faceSize],
100
+ format: 'depth32float',
101
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST
102
+ })
103
+ this.faceDepthTextures.push({
104
+ texture: depthTexture,
105
+ view: depthTexture.createView()
106
+ })
107
+ }
108
+
109
+ // Create octahedral output texture
110
+ const octTexture = device.createTexture({
111
+ label: 'probeOctahedral',
112
+ size: [this.octahedralSize, this.octahedralSize],
113
+ format: 'rgba16float',
114
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
115
+ })
116
+ this.octahedralTexture = {
117
+ texture: octTexture,
118
+ view: octTexture.createView()
119
+ }
120
+
121
+ // Create sampler
122
+ this.faceSampler = device.createSampler({
123
+ magFilter: 'linear',
124
+ minFilter: 'linear',
125
+ })
126
+
127
+ // Create skybox render pipeline (samples environment as background)
128
+ await this._createSkyboxPipeline()
129
+
130
+ // Create compute pipeline for cubemap → octahedral conversion
131
+ await this._createConvertPipeline()
132
+ }
133
+
134
+ /**
135
+ * Set fallback environment map (used during capture)
136
+ */
137
+ setFallbackEnvironment(envMap) {
138
+ this.fallbackEnvironment = envMap
139
+ }
140
+
141
+ /**
142
+ * Create pipeline to render skybox (environment map as background)
143
+ */
144
+ async _createSkyboxPipeline() {
145
+ const { device } = this.engine
146
+
147
+ const shaderCode = /* wgsl */`
148
+ struct Uniforms {
149
+ invViewProj: mat4x4f,
150
+ envEncoding: f32, // 0 = equirectangular, 1 = octahedral
151
+ padding: vec3f,
152
+ }
153
+
154
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
155
+ @group(0) @binding(1) var envTexture: texture_2d<f32>;
156
+ @group(0) @binding(2) var envSampler: sampler;
157
+
158
+ struct VertexOutput {
159
+ @builtin(position) position: vec4f,
160
+ @location(0) uv: vec2f,
161
+ }
162
+
163
+ @vertex
164
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
165
+ var output: VertexOutput;
166
+ let x = f32(vertexIndex & 1u) * 4.0 - 1.0;
167
+ let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;
168
+ output.position = vec4f(x, y, 0.0, 1.0);
169
+ output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);
170
+ return output;
171
+ }
172
+
173
+ const PI: f32 = 3.14159265359;
174
+
175
+ // Equirectangular UV mapping
176
+ fn SphToUV(n: vec3f) -> vec2f {
177
+ var uv: vec2f;
178
+ uv.x = atan2(-n.x, n.z);
179
+ uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);
180
+ uv.y = acos(n.y) / PI;
181
+ return uv;
182
+ }
183
+
184
+ // Octahedral UV mapping
185
+ fn octEncode(n: vec3f) -> vec2f {
186
+ var p = n.xz / (abs(n.x) + abs(n.y) + abs(n.z));
187
+ if (n.y < 0.0) {
188
+ p = (1.0 - abs(p.yx)) * vec2f(
189
+ select(-1.0, 1.0, p.x >= 0.0),
190
+ select(-1.0, 1.0, p.y >= 0.0)
191
+ );
192
+ }
193
+ return p * 0.5 + 0.5;
194
+ }
195
+
196
+ fn getEnvUV(dir: vec3f) -> vec2f {
197
+ if (uniforms.envEncoding > 0.5) {
198
+ return octEncode(dir);
199
+ }
200
+ return SphToUV(dir);
201
+ }
202
+
203
+ @fragment
204
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
205
+ // Convert UV to clip space
206
+ let clipPos = vec4f(input.uv * 2.0 - 1.0, 1.0, 1.0);
207
+
208
+ // Transform to world direction
209
+ let worldPos = uniforms.invViewProj * clipPos;
210
+ var dir = normalize(worldPos.xyz / worldPos.w);
211
+
212
+ // For octahedral, negate direction (same as main lighting shader)
213
+ if (uniforms.envEncoding > 0.5) {
214
+ dir = -dir;
215
+ }
216
+
217
+ // Sample environment map using correct UV mapping
218
+ let uv = getEnvUV(dir);
219
+ let envRGBE = textureSample(envTexture, envSampler, uv);
220
+ var color = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);
221
+
222
+ // No exposure or environment levels for probe capture
223
+ // We capture raw HDR values - exposure is applied in main render only
224
+ return vec4f(color, 1.0);
225
+ }
226
+ `
227
+
228
+ const shaderModule = device.createShaderModule({
229
+ label: 'probeSkybox',
230
+ code: shaderCode
231
+ })
232
+
233
+ this.faceBindGroupLayout = device.createBindGroupLayout({
234
+ entries: [
235
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
236
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
237
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
238
+ ]
239
+ })
240
+
241
+ this.skyboxPipeline = device.createRenderPipeline({
242
+ label: 'probeSkybox',
243
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.faceBindGroupLayout] }),
244
+ vertex: {
245
+ module: shaderModule,
246
+ entryPoint: 'vertexMain',
247
+ },
248
+ fragment: {
249
+ module: shaderModule,
250
+ entryPoint: 'fragmentMain',
251
+ targets: [{ format: 'rgba16float' }]
252
+ },
253
+ primitive: { topology: 'triangle-list' },
254
+ depthStencil: {
255
+ format: 'depth32float',
256
+ depthWriteEnabled: false, // Don't write depth
257
+ depthCompare: 'greater-equal', // Only draw where depth is at far plane (1.0)
258
+ }
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Create compute pipeline for cubemap to octahedral conversion
264
+ */
265
+ async _createConvertPipeline() {
266
+ const { device } = this.engine
267
+
268
+ const shaderCode = /* wgsl */`
269
+ @group(0) @binding(0) var outputTex: texture_storage_2d<rgba16float, write>;
270
+ @group(0) @binding(1) var cubeFace0: texture_2d<f32>;
271
+ @group(0) @binding(2) var cubeFace1: texture_2d<f32>;
272
+ @group(0) @binding(3) var cubeFace2: texture_2d<f32>;
273
+ @group(0) @binding(4) var cubeFace3: texture_2d<f32>;
274
+ @group(0) @binding(5) var cubeFace4: texture_2d<f32>;
275
+ @group(0) @binding(6) var cubeFace5: texture_2d<f32>;
276
+ @group(0) @binding(7) var cubeSampler: sampler;
277
+
278
+ // Octahedral decode: UV to direction
279
+ // Maps 2D octahedral UV to 3D direction vector
280
+ fn octDecode(uv: vec2f) -> vec3f {
281
+ var uv2 = uv * 2.0 - 1.0;
282
+
283
+ // Upper hemisphere mapping
284
+ var n = vec3f(uv2.x, 1.0 - abs(uv2.x) - abs(uv2.y), uv2.y);
285
+
286
+ // Lower hemisphere wrapping
287
+ if (n.y < 0.0) {
288
+ let signX = select(-1.0, 1.0, n.x >= 0.0);
289
+ let signZ = select(-1.0, 1.0, n.z >= 0.0);
290
+ n = vec3f(
291
+ (1.0 - abs(n.z)) * signX,
292
+ n.y,
293
+ (1.0 - abs(n.x)) * signZ
294
+ );
295
+ }
296
+ return normalize(n);
297
+ }
298
+
299
+ // Sample cubemap from direction
300
+ // Standard cubemap UV mapping for faces rendered with lookAt
301
+ // V coordinate is negated for Y because texture v=0 is top, world Y=+1 is up
302
+ // Y faces use Z for vertical since they look along Y axis with Z as up vector
303
+ fn sampleCube(dir: vec3f) -> vec4f {
304
+ let absDir = abs(dir);
305
+ var uv: vec2f;
306
+ var faceColor: vec4f;
307
+
308
+ if (absDir.x >= absDir.y && absDir.x >= absDir.z) {
309
+ if (dir.x > 0.0) {
310
+ // +X face: use face1
311
+ uv = vec2f(-dir.z, -dir.y) / absDir.x * 0.5 + 0.5;
312
+ faceColor = textureSampleLevel(cubeFace1, cubeSampler, uv, 0.0);
313
+ } else {
314
+ // -X face: use face0
315
+ uv = vec2f(dir.z, -dir.y) / absDir.x * 0.5 + 0.5;
316
+ faceColor = textureSampleLevel(cubeFace0, cubeSampler, uv, 0.0);
317
+ }
318
+ } else if (absDir.y >= absDir.x && absDir.y >= absDir.z) {
319
+ if (dir.y > 0.0) {
320
+ // +Y face: looking at +Y (up), up vector = -Z, right = +X
321
+ // Screen top (-Z) should map to v=0, so use +z
322
+ uv = vec2f(dir.x, dir.z) / absDir.y * 0.5 + 0.5;
323
+ faceColor = textureSampleLevel(cubeFace2, cubeSampler, uv, 0.0);
324
+ } else {
325
+ // -Y face: looking at -Y (down), up vector = +Z, right = +X
326
+ // Screen top (+Z) should map to v=0, so use -z
327
+ uv = vec2f(dir.x, -dir.z) / absDir.y * 0.5 + 0.5;
328
+ faceColor = textureSampleLevel(cubeFace3, cubeSampler, uv, 0.0);
329
+ }
330
+ } else {
331
+ if (dir.z > 0.0) {
332
+ // +Z face: looking at +Z, up = +Y, right = +X
333
+ uv = vec2f(dir.x, -dir.y) / absDir.z * 0.5 + 0.5;
334
+ faceColor = textureSampleLevel(cubeFace4, cubeSampler, uv, 0.0);
335
+ } else {
336
+ // -Z face: looking at -Z, up = +Y, right = -X
337
+ uv = vec2f(-dir.x, -dir.y) / absDir.z * 0.5 + 0.5;
338
+ faceColor = textureSampleLevel(cubeFace5, cubeSampler, uv, 0.0);
339
+ }
340
+ }
341
+
342
+ return faceColor;
343
+ }
344
+
345
+ @compute @workgroup_size(8, 8)
346
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
347
+ let size = textureDimensions(outputTex);
348
+ if (gid.x >= size.x || gid.y >= size.y) {
349
+ return;
350
+ }
351
+
352
+ let uv = (vec2f(gid.xy) + 0.5) / vec2f(size);
353
+ let dir = octDecode(uv);
354
+ let color = sampleCube(dir);
355
+ textureStore(outputTex, gid.xy, color);
356
+ }
357
+ `
358
+
359
+ const shaderModule = device.createShaderModule({
360
+ label: 'probeConvert',
361
+ code: shaderCode
362
+ })
363
+
364
+ this.convertPipeline = device.createComputePipeline({
365
+ label: 'probeConvert',
366
+ layout: 'auto',
367
+ compute: {
368
+ module: shaderModule,
369
+ entryPoint: 'main'
370
+ }
371
+ })
372
+
373
+ // Create equirectangular to octahedral conversion pipeline
374
+ await this._createEquirectToOctPipeline()
375
+ }
376
+
377
+ /**
378
+ * Create compute pipeline for equirectangular to octahedral conversion
379
+ */
380
+ async _createEquirectToOctPipeline() {
381
+ const { device } = this.engine
382
+
383
+ const shaderCode = /* wgsl */`
384
+ @group(0) @binding(0) var outputTex: texture_storage_2d<rgba16float, write>;
385
+ @group(0) @binding(1) var envTexture: texture_2d<f32>;
386
+ @group(0) @binding(2) var envSampler: sampler;
387
+
388
+ const PI: f32 = 3.14159265359;
389
+
390
+ // Octahedral decode: UV to direction
391
+ fn octDecode(uv: vec2f) -> vec3f {
392
+ var uv2 = uv * 2.0 - 1.0;
393
+ var n = vec3f(uv2.x, 1.0 - abs(uv2.x) - abs(uv2.y), uv2.y);
394
+ if (n.y < 0.0) {
395
+ let signX = select(-1.0, 1.0, n.x >= 0.0);
396
+ let signZ = select(-1.0, 1.0, n.z >= 0.0);
397
+ n = vec3f(
398
+ (1.0 - abs(n.z)) * signX,
399
+ n.y,
400
+ (1.0 - abs(n.x)) * signZ
401
+ );
402
+ }
403
+ return normalize(n);
404
+ }
405
+
406
+ // Equirectangular UV from direction (matching lighting.wgsl SphToUV)
407
+ fn SphToUV(n: vec3f) -> vec2f {
408
+ var uv: vec2f;
409
+ uv.x = atan2(-n.x, n.z);
410
+ uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);
411
+ uv.y = acos(n.y) / PI;
412
+ return uv;
413
+ }
414
+
415
+ @compute @workgroup_size(8, 8)
416
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
417
+ let size = textureDimensions(outputTex);
418
+ if (gid.x >= size.x || gid.y >= size.y) {
419
+ return;
420
+ }
421
+
422
+ let uv = (vec2f(gid.xy) + 0.5) / vec2f(size);
423
+ let dir = octDecode(uv);
424
+
425
+ // Sample equirectangular environment
426
+ let envUV = SphToUV(dir);
427
+ let envRGBE = textureSampleLevel(envTexture, envSampler, envUV, 0.0);
428
+
429
+ // Decode RGBE to linear HDR
430
+ let color = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);
431
+
432
+ textureStore(outputTex, gid.xy, vec4f(color, 1.0));
433
+ }
434
+ `;
435
+
436
+ const shaderModule = device.createShaderModule({
437
+ label: 'equirectToOct',
438
+ code: shaderCode
439
+ })
440
+
441
+ this.equirectToOctPipeline = device.createComputePipeline({
442
+ label: 'equirectToOct',
443
+ layout: 'auto',
444
+ compute: {
445
+ module: shaderModule,
446
+ entryPoint: 'main'
447
+ }
448
+ })
449
+ }
450
+
451
+ /**
452
+ * Convert equirectangular environment map to octahedral format
453
+ * @param {Texture} envMap - Equirectangular RGBE environment map
454
+ */
455
+ async convertEquirectToOctahedral(envMap) {
456
+ const { device } = this.engine
457
+
458
+ if (!this.equirectToOctPipeline) {
459
+ await this._createEquirectToOctPipeline()
460
+ }
461
+
462
+ // Reset RGBE texture so it gets regenerated
463
+ if (this.octahedralRGBE?.texture) {
464
+ this.octahedralRGBE.texture.destroy()
465
+ this.octahedralRGBE = null
466
+ }
467
+
468
+ const bindGroup = device.createBindGroup({
469
+ layout: this.equirectToOctPipeline.getBindGroupLayout(0),
470
+ entries: [
471
+ { binding: 0, resource: this.octahedralTexture.view },
472
+ { binding: 1, resource: envMap.view },
473
+ { binding: 2, resource: this.faceSampler },
474
+ ]
475
+ })
476
+
477
+ const commandEncoder = device.createCommandEncoder()
478
+ const passEncoder = commandEncoder.beginComputePass()
479
+
480
+ passEncoder.setPipeline(this.equirectToOctPipeline)
481
+ passEncoder.setBindGroup(0, bindGroup)
482
+
483
+ const workgroupsX = Math.ceil(this.octahedralSize / 8)
484
+ const workgroupsY = Math.ceil(this.octahedralSize / 8)
485
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY)
486
+
487
+ passEncoder.end()
488
+ device.queue.submit([commandEncoder.finish()])
489
+
490
+ // Wait for GPU to finish
491
+ await device.queue.onSubmittedWorkDone()
492
+
493
+ console.log('ProbeCapture: Converted equirectangular to octahedral')
494
+ }
495
+
496
+ /**
497
+ * Create view/projection matrix for a cube face
498
+ */
499
+ _createFaceMatrix(faceIndex, position) {
500
+ const face = this.faceDirections[faceIndex]
501
+
502
+ const view = mat4.create()
503
+ const target = [
504
+ position[0] + face.dir[0],
505
+ position[1] + face.dir[1],
506
+ position[2] + face.dir[2]
507
+ ]
508
+ mat4.lookAt(view, position, target, face.up)
509
+
510
+ const proj = mat4.create()
511
+ mat4.perspective(proj, Math.PI / 2, 1.0, 0.1, 1000.0)
512
+
513
+ const viewProj = mat4.create()
514
+ mat4.multiply(viewProj, proj, view)
515
+
516
+ const invViewProj = mat4.create()
517
+ mat4.invert(invViewProj, viewProj)
518
+
519
+ return invViewProj
520
+ }
521
+
522
+ /**
523
+ * Create view and projection matrices for a cube face
524
+ * Returns both matrices separately (for scene rendering)
525
+ */
526
+ _createFaceMatrices(faceIndex, position) {
527
+ const face = this.faceDirections[faceIndex]
528
+
529
+ const view = mat4.create()
530
+ const target = [
531
+ position[0] + face.dir[0],
532
+ position[1] + face.dir[1],
533
+ position[2] + face.dir[2]
534
+ ]
535
+ mat4.lookAt(view, position, target, face.up)
536
+
537
+ const proj = mat4.create()
538
+ mat4.perspective(proj, Math.PI / 2, 1.0, 0.1, 10000.0) // Far plane 10000 for distant objects
539
+
540
+ return { view, proj }
541
+ }
542
+
543
+ /**
544
+ * Capture probe from a position
545
+ * Renders scene geometry (if callback set) then skybox as background
546
+ * @param {vec3} position - World position to capture from
547
+ * @returns {Promise<void>}
548
+ */
549
+ async capture(position) {
550
+ if (this.isCapturing) {
551
+ console.warn('ProbeCapture: Already capturing')
552
+ return
553
+ }
554
+
555
+ if (!this.fallbackEnvironment) {
556
+ console.error('ProbeCapture: No environment map set')
557
+ return
558
+ }
559
+
560
+ this.isCapturing = true
561
+ this.capturePosition = [...position]
562
+
563
+ // Reset RGBE texture so it gets regenerated after new capture
564
+ if (this.octahedralRGBE?.texture) {
565
+ this.octahedralRGBE.texture.destroy()
566
+ this.octahedralRGBE = null
567
+ }
568
+
569
+ const { device } = this.engine
570
+
571
+ // Wait for any previous GPU work to complete before starting capture
572
+ // This ensures all previous frame's buffer writes are complete
573
+ await device.queue.onSubmittedWorkDone()
574
+
575
+ // Capture started - position logged by caller
576
+
577
+ try {
578
+ // Create uniform buffer for skybox (mat4x4 + envEncoding + padding)
579
+ const uniformBuffer = device.createBuffer({
580
+ size: 80, // mat4x4 (64) + vec4 (16) for envEncoding + padding
581
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
582
+ })
583
+
584
+ // Render each cube face
585
+ for (let i = 0; i < 6; i++) {
586
+ const { view, proj } = this._createFaceMatrices(i, position)
587
+ const invViewProj = this._createFaceMatrix(i, position)
588
+
589
+ if (this.sceneRenderCallback) {
590
+ // Render scene using RenderGraph's deferred pipeline
591
+ // LightingPass handles BOTH scene geometry AND background (environment)
592
+ // No separate skybox render needed - LightingPass does it all
593
+ await this.sceneRenderCallback(
594
+ view,
595
+ proj,
596
+ this.faceTextures[i],
597
+ this.faceDepthTextures[i],
598
+ i,
599
+ position
600
+ )
601
+ } else {
602
+ // Fallback: No scene callback - render skybox only
603
+ const commandEncoder = device.createCommandEncoder()
604
+ const passEncoder = commandEncoder.beginRenderPass({
605
+ colorAttachments: [{
606
+ view: this.faceTextures[i].view,
607
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
608
+ loadOp: 'clear',
609
+ storeOp: 'store'
610
+ }],
611
+ depthStencilAttachment: {
612
+ view: this.faceDepthTextures[i].view,
613
+ depthClearValue: 1.0,
614
+ depthLoadOp: 'clear',
615
+ depthStoreOp: 'store'
616
+ }
617
+ })
618
+ passEncoder.end()
619
+ device.queue.submit([commandEncoder.finish()])
620
+
621
+ // Render skybox for fallback path only
622
+ const uniformData = new Float32Array(20)
623
+ uniformData.set(invViewProj, 0)
624
+ uniformData[16] = this.envEncoding // 0 = equirect, 1 = octahedral
625
+ uniformData[17] = 0 // padding
626
+ uniformData[18] = 0 // padding
627
+ uniformData[19] = 0 // padding
628
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData)
629
+
630
+ const skyboxBindGroup = device.createBindGroup({
631
+ layout: this.faceBindGroupLayout,
632
+ entries: [
633
+ { binding: 0, resource: { buffer: uniformBuffer } },
634
+ { binding: 1, resource: this.fallbackEnvironment.view },
635
+ { binding: 2, resource: this.faceSampler },
636
+ ]
637
+ })
638
+
639
+ const skyboxEncoder = device.createCommandEncoder()
640
+ const skyboxPass = skyboxEncoder.beginRenderPass({
641
+ colorAttachments: [{
642
+ view: this.faceTextures[i].view,
643
+ loadOp: 'load',
644
+ storeOp: 'store'
645
+ }],
646
+ depthStencilAttachment: {
647
+ view: this.faceDepthTextures[i].view,
648
+ depthLoadOp: 'load',
649
+ depthStoreOp: 'store'
650
+ }
651
+ })
652
+
653
+ skyboxPass.setPipeline(this.skyboxPipeline)
654
+ skyboxPass.setBindGroup(0, skyboxBindGroup)
655
+ skyboxPass.draw(3)
656
+ skyboxPass.end()
657
+
658
+ device.queue.submit([skyboxEncoder.finish()])
659
+ }
660
+ }
661
+
662
+ uniformBuffer.destroy()
663
+
664
+ // Convert cubemap to octahedral
665
+ await this._convertToOctahedral()
666
+
667
+ // Capture complete
668
+
669
+ } finally {
670
+ this.isCapturing = false
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Convert 6 cube faces to octahedral encoding
676
+ */
677
+ async _convertToOctahedral() {
678
+ const { device } = this.engine
679
+
680
+ const bindGroup = device.createBindGroup({
681
+ layout: this.convertPipeline.getBindGroupLayout(0),
682
+ entries: [
683
+ { binding: 0, resource: this.octahedralTexture.view },
684
+ { binding: 1, resource: this.faceTextures[0].view },
685
+ { binding: 2, resource: this.faceTextures[1].view },
686
+ { binding: 3, resource: this.faceTextures[2].view },
687
+ { binding: 4, resource: this.faceTextures[3].view },
688
+ { binding: 5, resource: this.faceTextures[4].view },
689
+ { binding: 6, resource: this.faceTextures[5].view },
690
+ { binding: 7, resource: this.faceSampler },
691
+ ]
692
+ })
693
+
694
+ const commandEncoder = device.createCommandEncoder()
695
+ const passEncoder = commandEncoder.beginComputePass()
696
+
697
+ passEncoder.setPipeline(this.convertPipeline)
698
+ passEncoder.setBindGroup(0, bindGroup)
699
+
700
+ const workgroupsX = Math.ceil(this.octahedralSize / 8)
701
+ const workgroupsY = Math.ceil(this.octahedralSize / 8)
702
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY)
703
+
704
+ passEncoder.end()
705
+ device.queue.submit([commandEncoder.finish()])
706
+ }
707
+
708
+ /**
709
+ * Export probe as DEBUG PNG (regular LDR, tone mapped) for visual inspection
710
+ * @param {string} filename - Download filename
711
+ * @param {number} exposure - Exposure value for tone mapping (uses engine setting if not provided)
712
+ */
713
+ async saveAsDebugPNG(filename = 'probe_debug.png', exposure = null) {
714
+ // Use engine's exposure setting if not explicitly provided
715
+ if (exposure === null) {
716
+ exposure = this.engine?.settings?.environment?.exposure ?? 1.6
717
+ }
718
+ const { device } = this.engine
719
+
720
+ // Read back texture data
721
+ const bytesPerPixel = 8 // rgba16float = 4 * 2 bytes
722
+ const bytesPerRow = Math.ceil(this.octahedralSize * bytesPerPixel / 256) * 256
723
+ const bufferSize = bytesPerRow * this.octahedralSize
724
+
725
+ const readBuffer = device.createBuffer({
726
+ size: bufferSize,
727
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
728
+ })
729
+
730
+ const commandEncoder = device.createCommandEncoder()
731
+ commandEncoder.copyTextureToBuffer(
732
+ { texture: this.octahedralTexture.texture },
733
+ { buffer: readBuffer, bytesPerRow: bytesPerRow },
734
+ { width: this.octahedralSize, height: this.octahedralSize }
735
+ )
736
+ device.queue.submit([commandEncoder.finish()])
737
+
738
+ await readBuffer.mapAsync(GPUMapMode.READ)
739
+ const data = new Uint16Array(readBuffer.getMappedRange())
740
+
741
+ // Convert float16 to regular 8-bit RGBA with ACES tone mapping (matches PostProcess)
742
+ const pixelsPerRow = bytesPerRow / 8 // 8 bytes per pixel (rgba16float)
743
+ const rgbaData = new Uint8ClampedArray(this.octahedralSize * this.octahedralSize * 4)
744
+
745
+ for (let y = 0; y < this.octahedralSize; y++) {
746
+ for (let x = 0; x < this.octahedralSize; x++) {
747
+ const srcIdx = (y * pixelsPerRow + x) * 4
748
+ const dstIdx = (y * this.octahedralSize + x) * 4
749
+
750
+ // Decode float16 values
751
+ const r = this._float16ToFloat32(data[srcIdx])
752
+ const g = this._float16ToFloat32(data[srcIdx + 1])
753
+ const b = this._float16ToFloat32(data[srcIdx + 2])
754
+
755
+ // ACES tone mapping (matches postproc.wgsl)
756
+ const [tr, tg, tb] = this._acesToneMap(r, g, b)
757
+
758
+ // ACES already outputs in sRGB, just clamp and convert to 8-bit
759
+ rgbaData[dstIdx] = Math.min(255, Math.max(0, tr * 255))
760
+ rgbaData[dstIdx + 1] = Math.min(255, Math.max(0, tg * 255))
761
+ rgbaData[dstIdx + 2] = Math.min(255, Math.max(0, tb * 255))
762
+ rgbaData[dstIdx + 3] = 255
763
+ }
764
+ }
765
+
766
+ readBuffer.unmap()
767
+ readBuffer.destroy()
768
+
769
+ // Create canvas and draw image data
770
+ const canvas = document.createElement('canvas')
771
+ canvas.width = this.octahedralSize
772
+ canvas.height = this.octahedralSize
773
+ const ctx = canvas.getContext('2d')
774
+ const imageData = new ImageData(rgbaData, this.octahedralSize, this.octahedralSize)
775
+ ctx.putImageData(imageData, 0, 0)
776
+
777
+ // Trigger download
778
+ const link = document.createElement('a')
779
+ link.download = filename
780
+ link.href = canvas.toDataURL('image/png')
781
+ link.click()
782
+
783
+ console.log(`ProbeCapture: Saved debug PNG as ${filename}`)
784
+ }
785
+
786
+ /**
787
+ * Save individual cube faces for debugging
788
+ * @param {string} prefix - Filename prefix
789
+ * @param {number} exposure - Exposure value for tone mapping (uses engine setting if not provided)
790
+ */
791
+ async saveCubeFaces(prefix = 'face', exposure = null) {
792
+ // Use engine's exposure setting if not explicitly provided
793
+ if (exposure === null) {
794
+ exposure = this.engine?.settings?.environment?.exposure ?? 1.6
795
+ }
796
+ const { device } = this.engine
797
+ const faceNames = ['+X', '-X', '+Y', '-Y', '+Z', '-Z']
798
+
799
+ for (let i = 0; i < 6; i++) {
800
+ const bytesPerPixel = 8
801
+ const bytesPerRow = Math.ceil(this.faceSize * bytesPerPixel / 256) * 256
802
+ const bufferSize = bytesPerRow * this.faceSize
803
+
804
+ const readBuffer = device.createBuffer({
805
+ size: bufferSize,
806
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
807
+ })
808
+
809
+ const commandEncoder = device.createCommandEncoder()
810
+ commandEncoder.copyTextureToBuffer(
811
+ { texture: this.faceTextures[i].texture },
812
+ { buffer: readBuffer, bytesPerRow },
813
+ { width: this.faceSize, height: this.faceSize }
814
+ )
815
+ device.queue.submit([commandEncoder.finish()])
816
+
817
+ await readBuffer.mapAsync(GPUMapMode.READ)
818
+ const data = new Uint16Array(readBuffer.getMappedRange())
819
+
820
+ const pixelsPerRow = bytesPerRow / 8
821
+ const rgbaData = new Uint8ClampedArray(this.faceSize * this.faceSize * 4)
822
+
823
+ for (let y = 0; y < this.faceSize; y++) {
824
+ for (let x = 0; x < this.faceSize; x++) {
825
+ const srcIdx = (y * pixelsPerRow + x) * 4
826
+ const dstIdx = (y * this.faceSize + x) * 4
827
+
828
+ const r = this._float16ToFloat32(data[srcIdx])
829
+ const g = this._float16ToFloat32(data[srcIdx + 1])
830
+ const b = this._float16ToFloat32(data[srcIdx + 2])
831
+
832
+ // ACES tone mapping (matches postproc.wgsl)
833
+ const [tr, tg, tb] = this._acesToneMap(r, g, b)
834
+
835
+ rgbaData[dstIdx] = Math.min(255, Math.max(0, tr * 255))
836
+ rgbaData[dstIdx + 1] = Math.min(255, Math.max(0, tg * 255))
837
+ rgbaData[dstIdx + 2] = Math.min(255, Math.max(0, tb * 255))
838
+ rgbaData[dstIdx + 3] = 255
839
+ }
840
+ }
841
+
842
+ readBuffer.unmap()
843
+ readBuffer.destroy()
844
+
845
+ const canvas = document.createElement('canvas')
846
+ canvas.width = this.faceSize
847
+ canvas.height = this.faceSize
848
+ const ctx = canvas.getContext('2d')
849
+ const imageData = new ImageData(rgbaData, this.faceSize, this.faceSize)
850
+ ctx.putImageData(imageData, 0, 0)
851
+
852
+ const link = document.createElement('a')
853
+ link.download = `${prefix}_${i}_${faceNames[i].replace(/[+-]/g, m => m === '+' ? 'pos' : 'neg')}.png`
854
+ link.href = canvas.toDataURL('image/png')
855
+ link.click()
856
+
857
+ // Small delay between downloads
858
+ await new Promise(r => setTimeout(r, 100))
859
+ }
860
+
861
+ console.log('ProbeCapture: Saved all 6 cube faces')
862
+ }
863
+
864
+ /**
865
+ * Convert float16 to float32
866
+ */
867
+ _float16ToFloat32(h) {
868
+ const s = (h & 0x8000) >> 15
869
+ const e = (h & 0x7C00) >> 10
870
+ const f = h & 0x03FF
871
+
872
+ if (e === 0) {
873
+ return (s ? -1 : 1) * Math.pow(2, -14) * (f / 1024)
874
+ } else if (e === 0x1F) {
875
+ return f ? NaN : ((s ? -1 : 1) * Infinity)
876
+ }
877
+
878
+ return (s ? -1 : 1) * Math.pow(2, e - 15) * (1 + f / 1024)
879
+ }
880
+
881
+ /**
882
+ * ACES tone mapping (matches postproc.wgsl)
883
+ * @param {number} r - Red HDR value
884
+ * @param {number} g - Green HDR value
885
+ * @param {number} b - Blue HDR value
886
+ * @returns {number[]} Tone-mapped [r, g, b] in 0-1 range
887
+ */
888
+ _acesToneMap(r, g, b) {
889
+ // Input transform matrix (RRT_SAT)
890
+ const m1 = [
891
+ [0.59719, 0.35458, 0.04823],
892
+ [0.07600, 0.90834, 0.01566],
893
+ [0.02840, 0.13383, 0.83777]
894
+ ]
895
+ // Output transform matrix (ODT_SAT)
896
+ const m2 = [
897
+ [ 1.60475, -0.53108, -0.07367],
898
+ [-0.10208, 1.10813, -0.00605],
899
+ [-0.00327, -0.07276, 1.07602]
900
+ ]
901
+
902
+ // Apply input transform
903
+ const v0 = m1[0][0] * r + m1[0][1] * g + m1[0][2] * b
904
+ const v1 = m1[1][0] * r + m1[1][1] * g + m1[1][2] * b
905
+ const v2 = m1[2][0] * r + m1[2][1] * g + m1[2][2] * b
906
+
907
+ // RRT and ODT fit
908
+ const a0 = v0 * (v0 + 0.0245786) - 0.000090537
909
+ const b0 = v0 * (0.983729 * v0 + 0.4329510) + 0.238081
910
+ const a1 = v1 * (v1 + 0.0245786) - 0.000090537
911
+ const b1 = v1 * (0.983729 * v1 + 0.4329510) + 0.238081
912
+ const a2 = v2 * (v2 + 0.0245786) - 0.000090537
913
+ const b2 = v2 * (0.983729 * v2 + 0.4329510) + 0.238081
914
+
915
+ const c0 = a0 / b0
916
+ const c1 = a1 / b1
917
+ const c2 = a2 / b2
918
+
919
+ // Apply output transform
920
+ const out0 = m2[0][0] * c0 + m2[0][1] * c1 + m2[0][2] * c2
921
+ const out1 = m2[1][0] * c0 + m2[1][1] * c1 + m2[1][2] * c2
922
+ const out2 = m2[2][0] * c0 + m2[2][1] * c1 + m2[2][2] * c2
923
+
924
+ // Clamp to 0-1
925
+ return [
926
+ Math.max(0, Math.min(1, out0)),
927
+ Math.max(0, Math.min(1, out1)),
928
+ Math.max(0, Math.min(1, out2))
929
+ ]
930
+ }
931
+
932
+ /**
933
+ * Export probe as two JPG files: RGB + Multiplier (RGBM format)
934
+ * RGB stores actual color (with sRGB gamma) - values <= 1.0 stored directly
935
+ * Multiplier only encodes values > 1.0: black = 1.0, white = 32768, logarithmic
936
+ * This means most pixels have multiplier = 1.0 (black), compressing very well
937
+ * Blue noise dithering is applied to reduce banding
938
+ * @param {string} basename - Base filename (without extension), e.g. 'probe_01'
939
+ * @param {number} quality - JPG quality 0-1 (default 0.95 = 95%)
940
+ */
941
+ async saveAsJPG(basename = 'probe_01', quality = 0.95) {
942
+ const { device } = this.engine
943
+
944
+ // Load blue noise texture for dithering
945
+ let blueNoiseData = null
946
+ let blueNoiseSize = 1024
947
+ try {
948
+ const response = await fetch('/bluenoise1024.png')
949
+ const blob = await response.blob()
950
+ const bitmap = await createImageBitmap(blob)
951
+ blueNoiseSize = bitmap.width
952
+ const noiseCanvas = document.createElement('canvas')
953
+ noiseCanvas.width = blueNoiseSize
954
+ noiseCanvas.height = blueNoiseSize
955
+ const noiseCtx = noiseCanvas.getContext('2d')
956
+ noiseCtx.drawImage(bitmap, 0, 0)
957
+ blueNoiseData = noiseCtx.getImageData(0, 0, blueNoiseSize, blueNoiseSize).data
958
+ } catch (e) {
959
+ console.warn('ProbeCapture: Could not load blue noise, using white noise fallback')
960
+ }
961
+
962
+ // Read back texture data
963
+ const bytesPerPixel = 8 // rgba16float = 4 * 2 bytes
964
+ const bytesPerRow = Math.ceil(this.octahedralSize * bytesPerPixel / 256) * 256
965
+ const bufferSize = bytesPerRow * this.octahedralSize
966
+
967
+ const readBuffer = device.createBuffer({
968
+ size: bufferSize,
969
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
970
+ })
971
+
972
+ const commandEncoder = device.createCommandEncoder()
973
+ commandEncoder.copyTextureToBuffer(
974
+ { texture: this.octahedralTexture.texture },
975
+ { buffer: readBuffer, bytesPerRow: bytesPerRow },
976
+ { width: this.octahedralSize, height: this.octahedralSize }
977
+ )
978
+ device.queue.submit([commandEncoder.finish()])
979
+
980
+ await readBuffer.mapAsync(GPUMapMode.READ)
981
+ const float16Data = new Uint16Array(readBuffer.getMappedRange())
982
+
983
+ // RGBM encoding parameters
984
+ // Multiplier range: 1.0 (black) to 32768 (white), logarithmic
985
+ // Most pixels will have multiplier = 1.0, so intensity image is mostly black
986
+ const MULT_MAX = 32768 // 2^15
987
+ const LOG_MULT_MAX = 15 // log2(32768)
988
+ const SRGB_GAMMA = 2.2
989
+
990
+ const size = this.octahedralSize
991
+ const rgbData = new Uint8ClampedArray(size * size * 4) // RGBA for canvas
992
+ const multData = new Uint8ClampedArray(size * size * 4) // RGBA grayscale for canvas
993
+ const stride = bytesPerRow / 2 // stride in uint16 elements
994
+
995
+ for (let y = 0; y < size; y++) {
996
+ for (let x = 0; x < size; x++) {
997
+ const srcIdx = y * stride + x * 4
998
+ const dstIdx = (y * size + x) * 4
999
+
1000
+ // Decode float16 to float32
1001
+ let r = this._float16ToFloat32(float16Data[srcIdx])
1002
+ let g = this._float16ToFloat32(float16Data[srcIdx + 1])
1003
+ let b = this._float16ToFloat32(float16Data[srcIdx + 2])
1004
+
1005
+ // Find max component
1006
+ const maxVal = Math.max(r, g, b, 1e-10)
1007
+
1008
+ let multiplier = 1.0
1009
+ if (maxVal > 1.0) {
1010
+ // HDR range: scale down so max = 1.0
1011
+ multiplier = Math.min(maxVal, MULT_MAX)
1012
+ const scale = 1.0 / multiplier
1013
+ r *= scale
1014
+ g *= scale
1015
+ b *= scale
1016
+ }
1017
+
1018
+ // Apply sRGB gamma and store RGB
1019
+ rgbData[dstIdx] = Math.round(Math.pow(Math.min(1, r), 1 / SRGB_GAMMA) * 255)
1020
+ rgbData[dstIdx + 1] = Math.round(Math.pow(Math.min(1, g), 1 / SRGB_GAMMA) * 255)
1021
+ rgbData[dstIdx + 2] = Math.round(Math.pow(Math.min(1, b), 1 / SRGB_GAMMA) * 255)
1022
+ rgbData[dstIdx + 3] = 255
1023
+
1024
+ // Encode multiplier: 1.0 = black (0), 32768 = white (255), logarithmic
1025
+ // log2(1) = 0 → 0, log2(32768) = 15 → 255
1026
+ const logMult = Math.log2(Math.max(1.0, multiplier)) // 0 to 15
1027
+ const multNorm = logMult / LOG_MULT_MAX // 0 to 1
1028
+
1029
+ // Add blue noise dithering (-0.5 to +0.5) before quantization
1030
+ let dither = 0
1031
+ if (blueNoiseData) {
1032
+ const noiseX = x % blueNoiseSize
1033
+ const noiseY = y % blueNoiseSize
1034
+ const noiseIdx = (noiseY * blueNoiseSize + noiseX) * 4
1035
+ dither = (blueNoiseData[noiseIdx] / 255) - 0.5
1036
+ } else {
1037
+ dither = Math.random() - 0.5
1038
+ }
1039
+
1040
+ const multByte = Math.min(255, Math.max(0, Math.round(multNorm * 255 + dither)))
1041
+
1042
+ multData[dstIdx] = multByte
1043
+ multData[dstIdx + 1] = multByte
1044
+ multData[dstIdx + 2] = multByte
1045
+ multData[dstIdx + 3] = 255
1046
+ }
1047
+ }
1048
+
1049
+ readBuffer.unmap()
1050
+ readBuffer.destroy()
1051
+
1052
+ // Create RGB canvas and save as JPG
1053
+ const rgbCanvas = document.createElement('canvas')
1054
+ rgbCanvas.width = size
1055
+ rgbCanvas.height = size
1056
+ const rgbCtx = rgbCanvas.getContext('2d')
1057
+ const rgbImageData = new ImageData(rgbData, size, size)
1058
+ rgbCtx.putImageData(rgbImageData, 0, 0)
1059
+
1060
+ // Create multiplier canvas and save as JPG
1061
+ const multCanvas = document.createElement('canvas')
1062
+ multCanvas.width = size
1063
+ multCanvas.height = size
1064
+ const multCtx = multCanvas.getContext('2d')
1065
+ const multImageData = new ImageData(multData, size, size)
1066
+ multCtx.putImageData(multImageData, 0, 0)
1067
+
1068
+ // Download RGB JPG
1069
+ const rgbLink = document.createElement('a')
1070
+ rgbLink.download = `${basename}.jpg`
1071
+ rgbLink.href = rgbCanvas.toDataURL('image/jpeg', quality)
1072
+ rgbLink.click()
1073
+
1074
+ // Small delay between downloads
1075
+ await new Promise(r => setTimeout(r, 100))
1076
+
1077
+ // Download multiplier JPG
1078
+ const multLink = document.createElement('a')
1079
+ multLink.download = `${basename}.mult.jpg`
1080
+ multLink.href = multCanvas.toDataURL('image/jpeg', quality)
1081
+ multLink.click()
1082
+
1083
+ console.log(`ProbeCapture: Saved RGBM pair as ${basename}.jpg + ${basename}.mult.jpg (${size}x${size})`)
1084
+ }
1085
+
1086
+ /**
1087
+ * Export probe as Radiance HDR file and trigger download
1088
+ * @param {string} filename - Download filename
1089
+ */
1090
+ async saveAsHDR(filename = 'probe.hdr') {
1091
+ const { device } = this.engine
1092
+
1093
+ // Read back texture data
1094
+ const bytesPerPixel = 8 // rgba16float = 4 * 2 bytes
1095
+ const bytesPerRow = Math.ceil(this.octahedralSize * bytesPerPixel / 256) * 256
1096
+ const bufferSize = bytesPerRow * this.octahedralSize
1097
+
1098
+ const readBuffer = device.createBuffer({
1099
+ size: bufferSize,
1100
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
1101
+ })
1102
+
1103
+ const commandEncoder = device.createCommandEncoder()
1104
+ commandEncoder.copyTextureToBuffer(
1105
+ { texture: this.octahedralTexture.texture },
1106
+ { buffer: readBuffer, bytesPerRow: bytesPerRow },
1107
+ { width: this.octahedralSize, height: this.octahedralSize }
1108
+ )
1109
+ device.queue.submit([commandEncoder.finish()])
1110
+
1111
+ await readBuffer.mapAsync(GPUMapMode.READ)
1112
+ const float16Data = new Uint16Array(readBuffer.getMappedRange())
1113
+
1114
+ // Convert to RGBE format
1115
+ const rgbeData = this._float16ToRGBE(float16Data, bytesPerRow / 2)
1116
+
1117
+ readBuffer.unmap()
1118
+ readBuffer.destroy()
1119
+
1120
+ // Build Radiance HDR file
1121
+ const hdrData = this._buildHDRFile(rgbeData, this.octahedralSize, this.octahedralSize)
1122
+
1123
+ // Download
1124
+ const blob = new Blob([hdrData], { type: 'application/octet-stream' })
1125
+ const link = document.createElement('a')
1126
+ link.download = filename
1127
+ link.href = URL.createObjectURL(blob)
1128
+ link.click()
1129
+ URL.revokeObjectURL(link.href)
1130
+
1131
+ console.log(`ProbeCapture: Saved HDR as ${filename}`)
1132
+ }
1133
+
1134
+ /**
1135
+ * Build Radiance HDR file from RGBE data
1136
+ * Uses simple RLE compression per scanline
1137
+ */
1138
+ _buildHDRFile(rgbeData, width, height) {
1139
+ // Header
1140
+ const header = `#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y ${height} +X ${width}\n`
1141
+ const headerBytes = new TextEncoder().encode(header)
1142
+
1143
+ // Encode scanlines with RLE
1144
+ const scanlines = []
1145
+ for (let y = 0; y < height; y++) {
1146
+ const scanline = this._encodeRLEScanline(rgbeData, y, width)
1147
+ scanlines.push(scanline)
1148
+ }
1149
+
1150
+ // Calculate total size
1151
+ const dataSize = scanlines.reduce((sum, s) => sum + s.length, 0)
1152
+ const totalSize = headerBytes.length + dataSize
1153
+
1154
+ // Build final buffer
1155
+ const result = new Uint8Array(totalSize)
1156
+ result.set(headerBytes, 0)
1157
+
1158
+ let offset = headerBytes.length
1159
+ for (const scanline of scanlines) {
1160
+ result.set(scanline, offset)
1161
+ offset += scanline.length
1162
+ }
1163
+
1164
+ return result
1165
+ }
1166
+
1167
+ /**
1168
+ * Encode a single scanline with RLE compression
1169
+ */
1170
+ _encodeRLEScanline(rgbeData, y, width) {
1171
+ // New RLE format for width >= 8 and <= 32767
1172
+ if (width < 8 || width > 32767) {
1173
+ // Fallback to uncompressed
1174
+ const scanline = new Uint8Array(width * 4)
1175
+ for (let x = 0; x < width; x++) {
1176
+ const srcIdx = (y * width + x) * 4
1177
+ scanline[x * 4 + 0] = rgbeData[srcIdx + 0]
1178
+ scanline[x * 4 + 1] = rgbeData[srcIdx + 1]
1179
+ scanline[x * 4 + 2] = rgbeData[srcIdx + 2]
1180
+ scanline[x * 4 + 3] = rgbeData[srcIdx + 3]
1181
+ }
1182
+ return scanline
1183
+ }
1184
+
1185
+ // New RLE format: 4 bytes header + RLE encoded channels
1186
+ const header = new Uint8Array([2, 2, (width >> 8) & 0xFF, width & 0xFF])
1187
+
1188
+ // Separate channels
1189
+ const channels = [[], [], [], []]
1190
+ for (let x = 0; x < width; x++) {
1191
+ const srcIdx = (y * width + x) * 4
1192
+ channels[0].push(rgbeData[srcIdx + 0])
1193
+ channels[1].push(rgbeData[srcIdx + 1])
1194
+ channels[2].push(rgbeData[srcIdx + 2])
1195
+ channels[3].push(rgbeData[srcIdx + 3])
1196
+ }
1197
+
1198
+ // RLE encode each channel
1199
+ const encodedChannels = channels.map(ch => this._rleEncodeChannel(ch))
1200
+
1201
+ // Combine
1202
+ const totalLen = header.length + encodedChannels.reduce((s, c) => s + c.length, 0)
1203
+ const result = new Uint8Array(totalLen)
1204
+ result.set(header, 0)
1205
+
1206
+ let offset = header.length
1207
+ for (const encoded of encodedChannels) {
1208
+ result.set(encoded, offset)
1209
+ offset += encoded.length
1210
+ }
1211
+
1212
+ return result
1213
+ }
1214
+
1215
+ /**
1216
+ * RLE encode a single channel
1217
+ */
1218
+ _rleEncodeChannel(data) {
1219
+ const result = []
1220
+ let i = 0
1221
+
1222
+ while (i < data.length) {
1223
+ // Check for run
1224
+ let runLen = 1
1225
+ while (i + runLen < data.length && runLen < 127 && data[i + runLen] === data[i]) {
1226
+ runLen++
1227
+ }
1228
+
1229
+ if (runLen > 2) {
1230
+ // Encode run
1231
+ result.push(128 + runLen)
1232
+ result.push(data[i])
1233
+ i += runLen
1234
+ } else {
1235
+ // Encode non-run (literal)
1236
+ let litLen = 1
1237
+ while (i + litLen < data.length && litLen < 128) {
1238
+ // Check if next would start a run
1239
+ if (i + litLen + 2 < data.length &&
1240
+ data[i + litLen] === data[i + litLen + 1] &&
1241
+ data[i + litLen] === data[i + litLen + 2]) {
1242
+ break
1243
+ }
1244
+ litLen++
1245
+ }
1246
+ result.push(litLen)
1247
+ for (let j = 0; j < litLen; j++) {
1248
+ result.push(data[i + j])
1249
+ }
1250
+ i += litLen
1251
+ }
1252
+ }
1253
+
1254
+ return new Uint8Array(result)
1255
+ }
1256
+
1257
+ /**
1258
+ * Export probe as RGBE PNG (for debugging/preview)
1259
+ * @param {string} filename - Download filename
1260
+ */
1261
+ async saveAsPNG(filename = 'probe.png') {
1262
+ const { device } = this.engine
1263
+
1264
+ // Read back texture data
1265
+ const bytesPerPixel = 8 // rgba16float = 4 * 2 bytes
1266
+ const bytesPerRow = Math.ceil(this.octahedralSize * bytesPerPixel / 256) * 256
1267
+ const bufferSize = bytesPerRow * this.octahedralSize
1268
+
1269
+ const readBuffer = device.createBuffer({
1270
+ size: bufferSize,
1271
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
1272
+ })
1273
+
1274
+ const commandEncoder = device.createCommandEncoder()
1275
+ commandEncoder.copyTextureToBuffer(
1276
+ { texture: this.octahedralTexture.texture },
1277
+ { buffer: readBuffer, bytesPerRow: bytesPerRow },
1278
+ { width: this.octahedralSize, height: this.octahedralSize }
1279
+ )
1280
+ device.queue.submit([commandEncoder.finish()])
1281
+
1282
+ await readBuffer.mapAsync(GPUMapMode.READ)
1283
+ const data = new Uint16Array(readBuffer.getMappedRange())
1284
+
1285
+ // Convert float16 to RGBE format for HDR storage
1286
+ const rgbeData = this._float16ToRGBE(data, bytesPerRow / 2)
1287
+
1288
+ readBuffer.unmap()
1289
+ readBuffer.destroy()
1290
+
1291
+ // Create PNG with RGBE data
1292
+ const canvas = document.createElement('canvas')
1293
+ canvas.width = this.octahedralSize
1294
+ canvas.height = this.octahedralSize
1295
+ const ctx = canvas.getContext('2d')
1296
+ const imageData = ctx.createImageData(this.octahedralSize, this.octahedralSize)
1297
+ imageData.data.set(rgbeData)
1298
+ ctx.putImageData(imageData, 0, 0)
1299
+
1300
+ // Download
1301
+ const link = document.createElement('a')
1302
+ link.download = filename
1303
+ link.href = canvas.toDataURL('image/png')
1304
+ link.click()
1305
+
1306
+ console.log(`ProbeCapture: Saved as ${filename}`)
1307
+ }
1308
+
1309
+ /**
1310
+ * Convert float16 RGBA to RGBE format
1311
+ */
1312
+ _float16ToRGBE(float16Data, stride) {
1313
+ const size = this.octahedralSize
1314
+ const rgbe = new Uint8ClampedArray(size * size * 4)
1315
+
1316
+ for (let y = 0; y < size; y++) {
1317
+ for (let x = 0; x < size; x++) {
1318
+ const srcIdx = y * stride + x * 4
1319
+ const dstIdx = (y * size + x) * 4
1320
+
1321
+ // Decode float16 to float32
1322
+ const r = this._float16ToFloat32(float16Data[srcIdx])
1323
+ const g = this._float16ToFloat32(float16Data[srcIdx + 1])
1324
+ const b = this._float16ToFloat32(float16Data[srcIdx + 2])
1325
+
1326
+ // Find max component
1327
+ const maxVal = Math.max(r, g, b)
1328
+
1329
+ if (maxVal < 1e-32) {
1330
+ rgbe[dstIdx] = 0
1331
+ rgbe[dstIdx + 1] = 0
1332
+ rgbe[dstIdx + 2] = 0
1333
+ rgbe[dstIdx + 3] = 0
1334
+ } else {
1335
+ // Compute exponent
1336
+ let exp = Math.ceil(Math.log2(maxVal))
1337
+ const scale = Math.pow(2, -exp) * 255
1338
+
1339
+ rgbe[dstIdx] = Math.min(255, Math.max(0, r * scale))
1340
+ rgbe[dstIdx + 1] = Math.min(255, Math.max(0, g * scale))
1341
+ rgbe[dstIdx + 2] = Math.min(255, Math.max(0, b * scale))
1342
+ rgbe[dstIdx + 3] = exp + 128
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ return rgbe
1348
+ }
1349
+
1350
+ /**
1351
+ * Convert float16 (as uint16) to float32
1352
+ */
1353
+ _float16ToFloat32(h) {
1354
+ const sign = (h & 0x8000) >> 15
1355
+ const exp = (h & 0x7C00) >> 10
1356
+ const frac = h & 0x03FF
1357
+
1358
+ if (exp === 0) {
1359
+ if (frac === 0) return sign ? -0 : 0
1360
+ // Denormalized
1361
+ return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024)
1362
+ } else if (exp === 31) {
1363
+ return frac ? NaN : (sign ? -Infinity : Infinity)
1364
+ }
1365
+
1366
+ return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024)
1367
+ }
1368
+
1369
+ /**
1370
+ * Capture and save as PNG
1371
+ * @param {vec3} position - Capture position
1372
+ * @param {string} filename - Download filename
1373
+ */
1374
+ async captureAndSave(position, filename = 'probe.png') {
1375
+ await this.capture(position)
1376
+ await this.saveAsPNG(filename)
1377
+ }
1378
+
1379
+ /**
1380
+ * Get the octahedral probe texture (raw float16)
1381
+ */
1382
+ getProbeTexture() {
1383
+ return this.octahedralTexture
1384
+ }
1385
+
1386
+ /**
1387
+ * Convert float16 octahedral texture to RGBE-encoded rgba8unorm texture with mips
1388
+ * This makes it compatible with the standard environment map pipeline
1389
+ */
1390
+ async _createRGBETexture() {
1391
+ const { device } = this.engine
1392
+
1393
+ // Read back float16 data
1394
+ const bytesPerPixel = 8 // rgba16float = 4 * 2 bytes
1395
+ const bytesPerRow = Math.ceil(this.octahedralSize * bytesPerPixel / 256) * 256
1396
+ const bufferSize = bytesPerRow * this.octahedralSize
1397
+
1398
+ const readBuffer = device.createBuffer({
1399
+ size: bufferSize,
1400
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
1401
+ })
1402
+
1403
+ const commandEncoder = device.createCommandEncoder()
1404
+ commandEncoder.copyTextureToBuffer(
1405
+ { texture: this.octahedralTexture.texture },
1406
+ { buffer: readBuffer, bytesPerRow: bytesPerRow },
1407
+ { width: this.octahedralSize, height: this.octahedralSize }
1408
+ )
1409
+ device.queue.submit([commandEncoder.finish()])
1410
+
1411
+ await readBuffer.mapAsync(GPUMapMode.READ)
1412
+ const float16Data = new Uint16Array(readBuffer.getMappedRange())
1413
+
1414
+ // Convert to RGBE
1415
+ const rgbeData = this._float16ToRGBE(float16Data, bytesPerRow / 2)
1416
+
1417
+ readBuffer.unmap()
1418
+ readBuffer.destroy()
1419
+
1420
+ // Calculate mip levels
1421
+ const mipCount = numMipLevels(this.octahedralSize, this.octahedralSize)
1422
+
1423
+ // Create RGBE texture with mips (rgba8unorm like loaded HDR files)
1424
+ const rgbeTexture = device.createTexture({
1425
+ label: 'probeOctahedralRGBE',
1426
+ size: [this.octahedralSize, this.octahedralSize],
1427
+ mipLevelCount: mipCount,
1428
+ format: 'rgba8unorm',
1429
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
1430
+ })
1431
+
1432
+ device.queue.writeTexture(
1433
+ { texture: rgbeTexture },
1434
+ rgbeData,
1435
+ { bytesPerRow: this.octahedralSize * 4 },
1436
+ { width: this.octahedralSize, height: this.octahedralSize }
1437
+ )
1438
+
1439
+ // Generate mip levels with RGBE-aware filtering
1440
+ generateMips(device, rgbeTexture, true) // true = RGBE mode
1441
+
1442
+ this.octahedralRGBE = {
1443
+ texture: rgbeTexture,
1444
+ view: rgbeTexture.createView(),
1445
+ mipCount: mipCount
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Get texture in format compatible with lighting pass (RGBE encoded with mips)
1451
+ * Creates a wrapper with .view and .sampler properties
1452
+ */
1453
+ async getAsEnvironmentTexture() {
1454
+ if (!this.octahedralTexture) return null
1455
+
1456
+ // Create RGBE texture with mips if not already done
1457
+ if (!this.octahedralRGBE) {
1458
+ await this._createRGBETexture()
1459
+ }
1460
+
1461
+ return {
1462
+ texture: this.octahedralRGBE.texture,
1463
+ view: this.octahedralRGBE.view,
1464
+ sampler: this.faceSampler,
1465
+ width: this.octahedralSize,
1466
+ height: this.octahedralSize,
1467
+ mipCount: this.octahedralRGBE.mipCount,
1468
+ isHDR: true // Mark as HDR for proper handling
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Destroy resources
1474
+ */
1475
+ destroy() {
1476
+ for (const tex of this.faceTextures) {
1477
+ if (tex?.texture) tex.texture.destroy()
1478
+ }
1479
+ for (const tex of this.faceDepthTextures) {
1480
+ if (tex?.texture) tex.texture.destroy()
1481
+ }
1482
+ if (this.octahedralTexture?.texture) {
1483
+ this.octahedralTexture.texture.destroy()
1484
+ }
1485
+ if (this.octahedralRGBE?.texture) {
1486
+ this.octahedralRGBE.texture.destroy()
1487
+ }
1488
+ this.faceTextures = []
1489
+ this.faceDepthTextures = []
1490
+ this.octahedralTexture = null
1491
+ this.octahedralRGBE = null
1492
+ }
1493
+ }
1494
+
1495
+ export { ProbeCapture }