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.
- package/LICENSE.txt +0 -0
- package/README.md +0 -0
- package/dist/Renderer.cjs +18200 -0
- package/dist/Renderer.cjs.map +1 -0
- package/dist/Renderer.js +18183 -0
- package/dist/Renderer.js.map +1 -0
- package/dist/client.cjs +94 -260
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +71 -215
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +165 -432
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +117 -370
- package/dist/server.js.map +1 -1
- package/dist/terminal.cjs +113 -200
- package/dist/terminal.cjs.map +1 -1
- package/dist/terminal.js +50 -51
- package/dist/terminal.js.map +1 -1
- package/dist/utils-CRhi1BDa.cjs +259 -0
- package/dist/utils-CRhi1BDa.cjs.map +1 -0
- package/dist/utils-D7tXt6-2.js +260 -0
- package/dist/utils-D7tXt6-2.js.map +1 -0
- package/package.json +19 -15
- package/src/{client.ts → network/client.js} +173 -403
- package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
- package/src/{compress-node.ts → network/compress-node.js} +8 -14
- package/src/{server.ts → network/server.js} +229 -317
- package/src/{terminal.js → network/terminal.js} +0 -0
- package/src/{topazcube.ts → network/topazcube.js} +2 -2
- package/src/network/utils.js +375 -0
- package/src/renderer/Camera.js +191 -0
- package/src/renderer/DebugUI.js +572 -0
- package/src/renderer/Geometry.js +1049 -0
- package/src/renderer/Material.js +61 -0
- package/src/renderer/Mesh.js +211 -0
- package/src/renderer/Node.js +112 -0
- package/src/renderer/Pipeline.js +643 -0
- package/src/renderer/Renderer.js +1324 -0
- package/src/renderer/Skin.js +792 -0
- package/src/renderer/Texture.js +584 -0
- package/src/renderer/core/AssetManager.js +359 -0
- package/src/renderer/core/CullingSystem.js +307 -0
- package/src/renderer/core/EntityManager.js +541 -0
- package/src/renderer/core/InstanceManager.js +343 -0
- package/src/renderer/core/ParticleEmitter.js +358 -0
- package/src/renderer/core/ParticleSystem.js +564 -0
- package/src/renderer/core/SpriteSystem.js +349 -0
- package/src/renderer/gltf.js +546 -0
- package/src/renderer/math.js +161 -0
- package/src/renderer/rendering/HistoryBufferManager.js +333 -0
- package/src/renderer/rendering/ProbeCapture.js +1495 -0
- package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
- package/src/renderer/rendering/RenderGraph.js +2064 -0
- package/src/renderer/rendering/passes/AOPass.js +308 -0
- package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
- package/src/renderer/rendering/passes/BasePass.js +101 -0
- package/src/renderer/rendering/passes/BloomPass.js +417 -0
- package/src/renderer/rendering/passes/FogPass.js +419 -0
- package/src/renderer/rendering/passes/GBufferPass.js +706 -0
- package/src/renderer/rendering/passes/HiZPass.js +714 -0
- package/src/renderer/rendering/passes/LightingPass.js +739 -0
- package/src/renderer/rendering/passes/ParticlePass.js +835 -0
- package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
- package/src/renderer/rendering/passes/PostProcessPass.js +282 -0
- package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
- package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
- package/src/renderer/rendering/passes/SSGIPass.js +265 -0
- package/src/renderer/rendering/passes/SSGITilePass.js +296 -0
- package/src/renderer/rendering/passes/ShadowPass.js +1822 -0
- package/src/renderer/rendering/passes/TransparentPass.js +831 -0
- package/src/renderer/rendering/shaders/ao.wgsl +182 -0
- package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
- package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
- package/src/renderer/rendering/shaders/geometry.wgsl +550 -0
- package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
- package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
- package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
- package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
- package/src/renderer/rendering/shaders/particle_render.wgsl +525 -0
- package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
- package/src/renderer/rendering/shaders/postproc.wgsl +272 -0
- package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
- package/src/renderer/rendering/shaders/shadow.wgsl +76 -0
- package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
- package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
- package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
- package/src/renderer/utils/BoundingSphere.js +439 -0
- package/src/renderer/utils/Frustum.js +281 -0
- package/dist/client.d.cts +0 -211
- package/dist/client.d.ts +0 -211
- package/dist/server.d.cts +0 -120
- package/dist/server.d.ts +0 -120
- package/dist/terminal.d.cts +0 -64
- package/dist/terminal.d.ts +0 -64
- 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 }
|