@solidrt/3d 0.0.55 → 0.0.57

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 (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
@@ -0,0 +1,492 @@
1
+ // Environment baking: the pure pipeline behind `srt tool 3d/environment`
2
+ // and its tests. A Radiance .hdr panorama is decoded to linear float, laid
3
+ // onto the six faces of a cube, convolved level by level with the GGX lobe
4
+ // `standard`'s split sum samples (Karis's importance sampling, the source
5
+ // lod picked from each sample's solid angle - the Filament and Unity
6
+ // method; Godot and Three convolve the same way at runtime), and packed
7
+ // into the .srte container loadEnvironment uploads as an explicit rgba16f
8
+ // chain. Runs under bun and on the runtime alike: no GPU, no flux imports.
9
+ //
10
+ // Cube faces hold what a GL lookup of a world direction returns (GL's own
11
+ // convention, no library flip): cubeDirection is the texel-to-direction
12
+ // table and cubeLookup its inverse.
13
+
14
+ /** Face edge of the mip level a roughness of 1 samples (ENVIRONMENT in
15
+ * glsl.ts maps roughness onto the chain by it): the chain is convolved
16
+ * down to this edge, and the levels below repeat roughness 1 - never
17
+ * read by a material, present because a chain must reach 1x1. */
18
+ export const ENV_ROUGH_FACE = 4
19
+ // GGX samples per texel of a convolved level.
20
+ const PREFILTER_SAMPLES = 512
21
+ // Bias on the source lod a sample reads (Karis: +1 smooths the estimate
22
+ // without visible extra blur).
23
+ const SOURCE_LOD_BIAS = 1
24
+ // A sample's normal-lobe cosine below this contributes nothing (the lobe
25
+ // mirrored below the horizon).
26
+ const MIN_NOL = 1e-6
27
+ // The mip level count of a chain from `size` down to 1x1 (GL's rule).
28
+ export function mipLevels(size: number): number {
29
+ return 32 - Math.clz32(size)
30
+ }
31
+
32
+ /** A decoded panorama: linear rgb floats, row-major from the top row. */
33
+ export type Panorama = { width: number; height: number; data: Float32Array }
34
+ /** Six rgba float faces in GL order (+X, -X, +Y, -Y, +Z, -Z). */
35
+ export type CubeFaces = Float32Array[]
36
+
37
+ // The Radiance format tag a .hdr carries (or omits, meaning the same).
38
+ const RGBE_FORMAT = "32-bit_rle_rgbe"
39
+ // New-style RLE scanlines exist only in this width range (the format's rule).
40
+ const RLE_MIN_WIDTH = 8
41
+ const RLE_MAX_WIDTH = 0x7fff
42
+ // A run byte above this is a repeat of the next byte (count - 128 times).
43
+ const RUN_FLAG = 128
44
+ // The RGBE exponent bias: a mantissa byte scales by 2^(e - 128) / 255.
45
+ const RGBE_EXPONENT_BIAS = 128
46
+
47
+ /**
48
+ * Decode a Radiance .hdr (RGBE) file into linear rgb floats: the header
49
+ * (`#?` signature, `FORMAT=32-bit_rle_rgbe`), the standard `-Y h +X w`
50
+ * orientation, flat or new-style run-length scanlines - what every HDRI
51
+ * source ships (Three's RGBELoader reads the same subset). Throws on a
52
+ * truncated file, another orientation or the pre-1990 old-style RLE.
53
+ */
54
+ export function decodeHdr(bytes: Uint8Array): Panorama {
55
+ let pos = 0
56
+ let readLine = (): string => {
57
+ let s = ""
58
+ while (pos < bytes.length) {
59
+ let c = bytes[pos++]!
60
+ if (c === 0x0a) break
61
+ s += String.fromCharCode(c)
62
+ }
63
+ return s
64
+ }
65
+ if (!readLine().startsWith("#?")) throw new Error("decodeHdr: not a Radiance .hdr file (no #? signature)")
66
+ let format = RGBE_FORMAT
67
+ for (;;) {
68
+ if (pos >= bytes.length) throw new Error("decodeHdr: truncated header")
69
+ let line = readLine()
70
+ if (line === "") break
71
+ if (line.startsWith("FORMAT=")) format = line.slice("FORMAT=".length)
72
+ }
73
+ if (format !== RGBE_FORMAT) throw new Error("decodeHdr: unsupported FORMAT " + format + " (only " + RGBE_FORMAT + ")")
74
+ let res = readLine().trim().split(/\s+/)
75
+ if (res.length !== 4 || res[0] !== "-Y" || res[2] !== "+X") {
76
+ throw new Error("decodeHdr: unsupported orientation " + res.join(" ") + " (only -Y <height> +X <width>)")
77
+ }
78
+ let height = Number(res[1])
79
+ let width = Number(res[3])
80
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
81
+ throw new Error("decodeHdr: bad resolution " + res.join(" "))
82
+ }
83
+ let data = new Float32Array(width * height * 3)
84
+ let rgbe = new Uint8Array(width * 4)
85
+ let rle = width >= RLE_MIN_WIDTH && width <= RLE_MAX_WIDTH
86
+ let byte = (): number => {
87
+ if (pos >= bytes.length) throw new Error("decodeHdr: truncated pixel data")
88
+ return bytes[pos++]!
89
+ }
90
+ for (let y = 0; y < height; y++) {
91
+ if (rle && bytes[pos] === 2 && bytes[pos + 1] === 2 && ((bytes[pos + 2]! << 8) | bytes[pos + 3]!) === width) {
92
+ pos += 4
93
+ for (let ch = 0; ch < 4; ch++) {
94
+ let x = 0
95
+ while (x < width) {
96
+ let count = byte()
97
+ if (count > RUN_FLAG) {
98
+ count -= RUN_FLAG
99
+ if (x + count > width) throw new Error("decodeHdr: run overflows scanline " + y)
100
+ let v = byte()
101
+ while (count-- > 0) rgbe[x++ * 4 + ch] = v
102
+ } else {
103
+ if (count === 0 || x + count > width) throw new Error("decodeHdr: run overflows scanline " + y)
104
+ while (count-- > 0) rgbe[x++ * 4 + ch] = byte()
105
+ }
106
+ }
107
+ }
108
+ } else {
109
+ for (let x = 0; x < width; x++) {
110
+ let r = byte()
111
+ let g = byte()
112
+ let b = byte()
113
+ let e = byte()
114
+ if (r === 1 && g === 1 && b === 1) throw new Error("decodeHdr: old-style RLE is not supported")
115
+ rgbe[x * 4] = r
116
+ rgbe[x * 4 + 1] = g
117
+ rgbe[x * 4 + 2] = b
118
+ rgbe[x * 4 + 3] = e
119
+ }
120
+ }
121
+ let row = y * width * 3
122
+ for (let x = 0; x < width; x++) {
123
+ let e = rgbe[x * 4 + 3]!
124
+ let scale = e === 0 ? 0 : Math.pow(2, e - RGBE_EXPONENT_BIAS) / 255
125
+ data[row + x * 3] = rgbe[x * 4]! * scale
126
+ data[row + x * 3 + 1] = rgbe[x * 4 + 1]! * scale
127
+ data[row + x * 3 + 2] = rgbe[x * 4 + 2]! * scale
128
+ }
129
+ }
130
+ return { width, height, data }
131
+ }
132
+
133
+ /** The world direction a lookup must have to land on face `face` at
134
+ * (s, t) in 0..1 (t = 0 the first row): the GL cube-map table, written
135
+ * into `out` (unnormalized). */
136
+ export function cubeDirection(face: number, s: number, t: number, out: Float64Array): void {
137
+ let a = 2 * s - 1
138
+ let b = 2 * t - 1
139
+ let x: number
140
+ let y: number
141
+ let z: number
142
+ if (face === 0) (x = 1), (y = -b), (z = -a)
143
+ else if (face === 1) (x = -1), (y = -b), (z = a)
144
+ else if (face === 2) (x = a), (y = 1), (z = b)
145
+ else if (face === 3) (x = a), (y = -1), (z = -b)
146
+ else if (face === 4) (x = a), (y = -b), (z = 1)
147
+ else (x = -a), (y = -b), (z = -1)
148
+ out[0] = x
149
+ out[1] = y
150
+ out[2] = z
151
+ }
152
+
153
+ /** The inverse of cubeDirection: the face and (s, t) a world direction
154
+ * lands on, written into `out` as [face, s, t]. */
155
+ export function cubeLookup(wx: number, wy: number, wz: number, out: Float64Array): void {
156
+ let dx = wx
157
+ let dy = wy
158
+ let dz = wz
159
+ let ax = Math.abs(dx)
160
+ let ay = Math.abs(dy)
161
+ let az = Math.abs(dz)
162
+ let face: number
163
+ let a: number
164
+ let b: number
165
+ if (ax >= ay && ax >= az) {
166
+ if (dx > 0) (face = 0), (a = -dz / dx), (b = -dy / dx)
167
+ else (face = 1), (a = -dz / dx), (b = dy / dx)
168
+ } else if (ay >= az) {
169
+ if (dy > 0) (face = 2), (a = dx / dy), (b = dz / dy)
170
+ else (face = 3), (a = -dx / dy), (b = dz / dy)
171
+ } else {
172
+ if (dz > 0) (face = 4), (a = dx / dz), (b = -dy / dz)
173
+ else (face = 5), (a = dx / dz), (b = dy / dz)
174
+ }
175
+ out[0] = face
176
+ out[1] = (a + 1) / 2
177
+ out[2] = (b + 1) / 2
178
+ }
179
+
180
+ // Bilinear panorama sample along a direction (the GLSL equirect mapping:
181
+ // the center column faces -Z, the top row +Y), wrapping horizontally and
182
+ // clamping at the poles; accumulated into `out` at weight `w`.
183
+ function samplePanorama(p: Panorama, dx: number, dy: number, dz: number, w: number, out: Float64Array): void {
184
+ let u = Math.atan2(dx, -dz) / (2 * Math.PI) + 0.5
185
+ let v = Math.acos(Math.max(-1, Math.min(1, dy))) / Math.PI
186
+ let fx = u * p.width - 0.5
187
+ let fy = v * p.height - 0.5
188
+ let x0 = Math.floor(fx)
189
+ let y0 = Math.floor(fy)
190
+ let tx = fx - x0
191
+ let ty = fy - y0
192
+ let x1 = (((x0 + 1) % p.width) + p.width) % p.width
193
+ x0 = ((x0 % p.width) + p.width) % p.width
194
+ let y1 = Math.min(p.height - 1, Math.max(0, y0 + 1))
195
+ y0 = Math.min(p.height - 1, Math.max(0, y0))
196
+ let d = p.data
197
+ let i00 = (y0 * p.width + x0) * 3
198
+ let i10 = (y0 * p.width + x1) * 3
199
+ let i01 = (y1 * p.width + x0) * 3
200
+ let i11 = (y1 * p.width + x1) * 3
201
+ let w00 = (1 - tx) * (1 - ty) * w
202
+ let w10 = tx * (1 - ty) * w
203
+ let w01 = (1 - tx) * ty * w
204
+ let w11 = tx * ty * w
205
+ for (let c = 0; c < 3; c++) {
206
+ out[c] = out[c]! + d[i00 + c]! * w00 + d[i10 + c]! * w10 + d[i01 + c]! * w01 + d[i11 + c]! * w11
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Lay a panorama onto the six faces of a `size` cube (rgba floats, alpha
212
+ * 1), supersampling each texel enough to average the panorama's finer
213
+ * texels rather than skip them. The CPU form of equirectToCube.
214
+ */
215
+ export function panoramaToCube(p: Panorama, size: number): CubeFaces {
216
+ if (!Number.isInteger(size) || size < 1) throw new Error("panoramaToCube: size must be a positive integer, got " + size)
217
+ // Sub-samples per axis: the panorama's texels along the equator per cube
218
+ // texel (a face spans a quarter turn).
219
+ let ss = Math.max(1, Math.ceil(p.width / (4 * size)))
220
+ let weight = 1 / (ss * ss)
221
+ let dir = new Float64Array(3)
222
+ let acc = new Float64Array(3)
223
+ let faces: CubeFaces = []
224
+ for (let face = 0; face < 6; face++) {
225
+ let px = new Float32Array(size * size * 4)
226
+ for (let y = 0; y < size; y++) {
227
+ for (let x = 0; x < size; x++) {
228
+ acc[0] = acc[1] = acc[2] = 0
229
+ for (let j = 0; j < ss; j++) {
230
+ for (let i = 0; i < ss; i++) {
231
+ cubeDirection(face, (x + (i + 0.5) / ss) / size, (y + (j + 0.5) / ss) / size, dir)
232
+ let n = 1 / Math.hypot(dir[0]!, dir[1]!, dir[2]!)
233
+ samplePanorama(p, dir[0]! * n, dir[1]! * n, dir[2]! * n, weight, acc)
234
+ }
235
+ }
236
+ let o = (y * size + x) * 4
237
+ px[o] = acc[0]!
238
+ px[o + 1] = acc[1]!
239
+ px[o + 2] = acc[2]!
240
+ px[o + 3] = 1
241
+ }
242
+ }
243
+ faces.push(px)
244
+ }
245
+ return faces
246
+ }
247
+
248
+ // The 2x2 box chain of a cube down to 1x1 (within each face; the source
249
+ // the convolution reads at a per-sample lod).
250
+ function boxChain(base: CubeFaces, size: number): CubeFaces[] {
251
+ let chain = [base]
252
+ let edge = size
253
+ while (edge > 1) {
254
+ let prev = chain[chain.length - 1]!
255
+ let next = Math.max(1, edge >> 1)
256
+ let level: CubeFaces = []
257
+ for (let face = 0; face < 6; face++) {
258
+ let src = prev[face]!
259
+ let dst = new Float32Array(next * next * 4)
260
+ for (let y = 0; y < next; y++) {
261
+ for (let x = 0; x < next; x++) {
262
+ let x0 = Math.min(edge - 1, x * 2)
263
+ let x1 = Math.min(edge - 1, x * 2 + 1)
264
+ let y0 = Math.min(edge - 1, y * 2)
265
+ let y1 = Math.min(edge - 1, y * 2 + 1)
266
+ let o = (y * next + x) * 4
267
+ for (let c = 0; c < 4; c++) {
268
+ dst[o + c] =
269
+ (src[(y0 * edge + x0) * 4 + c]! + src[(y0 * edge + x1) * 4 + c]! + src[(y1 * edge + x0) * 4 + c]! + src[(y1 * edge + x1) * 4 + c]!) / 4
270
+ }
271
+ }
272
+ }
273
+ level.push(dst)
274
+ }
275
+ chain.push(level)
276
+ edge = next
277
+ }
278
+ return chain
279
+ }
280
+
281
+ // Bilinear cube fetch within the face a direction lands on (no cross-face
282
+ // blend; the hardware's seamless filtering is not needed for a bake),
283
+ // accumulated into `out` at weight `w`.
284
+ function fetchCube(level: CubeFaces, edge: number, dx: number, dy: number, dz: number, w: number, lk: Float64Array, out: Float64Array): void {
285
+ cubeLookup(dx, dy, dz, lk)
286
+ let px = level[lk[0]!]!
287
+ let fx = Math.min(edge - 1, Math.max(0, lk[1]! * edge - 0.5))
288
+ let fy = Math.min(edge - 1, Math.max(0, lk[2]! * edge - 0.5))
289
+ let x0 = Math.floor(fx)
290
+ let y0 = Math.floor(fy)
291
+ let tx = fx - x0
292
+ let ty = fy - y0
293
+ let x1 = Math.min(edge - 1, x0 + 1)
294
+ let y1 = Math.min(edge - 1, y0 + 1)
295
+ let i00 = (y0 * edge + x0) * 4
296
+ let i10 = (y0 * edge + x1) * 4
297
+ let i01 = (y1 * edge + x0) * 4
298
+ let i11 = (y1 * edge + x1) * 4
299
+ let w00 = (1 - tx) * (1 - ty) * w
300
+ let w10 = tx * (1 - ty) * w
301
+ let w01 = (1 - tx) * ty * w
302
+ let w11 = tx * ty * w
303
+ for (let c = 0; c < 3; c++) {
304
+ out[c] = out[c]! + px[i00 + c]! * w00 + px[i10 + c]! * w10 + px[i01 + c]! * w01 + px[i11 + c]! * w11
305
+ }
306
+ }
307
+
308
+ // Van der Corput radical inverse: the second Hammersley coordinate.
309
+ function radicalInverse(i: number): number {
310
+ let bits = i >>> 0
311
+ bits = ((bits << 16) | (bits >>> 16)) >>> 0
312
+ bits = (((bits & 0x55555555) << 1) | ((bits & 0xaaaaaaaa) >>> 1)) >>> 0
313
+ bits = (((bits & 0x33333333) << 2) | ((bits & 0xcccccccc) >>> 2)) >>> 0
314
+ bits = (((bits & 0x0f0f0f0f) << 4) | ((bits & 0xf0f0f0f0) >>> 4)) >>> 0
315
+ bits = (((bits & 0x00ff00ff) << 8) | ((bits & 0xff00ff00) >>> 8)) >>> 0
316
+ return bits / 4294967296
317
+ }
318
+
319
+ /** The perceptual roughness mip level `level` of a `size` chain holds:
320
+ * linear from 0 at the base to 1 at the ENV_ROUGH_FACE level, 1 below it. */
321
+ export function levelRoughness(size: number, level: number): number {
322
+ let roughLevels = Math.log2(size) - Math.log2(ENV_ROUGH_FACE)
323
+ return roughLevels <= 0 ? 1 : Math.min(1, level / roughLevels)
324
+ }
325
+
326
+ /**
327
+ * Convolve a cube into its full mip chain: level 0 the faces as given, each
328
+ * level below prefiltered with the GGX lobe at levelRoughness (the normal,
329
+ * view and reflection directions taken equal, as every engine's bake
330
+ * does), by importance sampling with the source read at the lod that
331
+ * matches each sample's solid angle. Returns the levels, base first, each
332
+ * six rgba float faces.
333
+ */
334
+ export function prefilterCube(base: CubeFaces, size: number): CubeFaces[] {
335
+ if (base.length !== 6) throw new Error("prefilterCube: six faces expected, got " + base.length)
336
+ let source = boxChain(base, size)
337
+ let levels: CubeFaces[] = [base.map(f => Float32Array.from(f))]
338
+ let count = mipLevels(size)
339
+ // The solid angle of one base-level source texel.
340
+ let texelAngle = (4 * Math.PI) / (6 * size * size)
341
+ let dir = new Float64Array(3)
342
+ let lk = new Float64Array(3)
343
+ let acc = new Float64Array(3)
344
+ // Per level, the sample set in the normal's tangent frame: it depends on
345
+ // roughness only, so it is built once and reused for every texel.
346
+ let sx = new Float64Array(PREFILTER_SAMPLES)
347
+ let sy = new Float64Array(PREFILTER_SAMPLES)
348
+ let sz = new Float64Array(PREFILTER_SAMPLES)
349
+ let sLod = new Int32Array(PREFILTER_SAMPLES)
350
+ let sNol = new Float64Array(PREFILTER_SAMPLES)
351
+ for (let level = 1; level < count; level++) {
352
+ let edge = Math.max(1, size >> level)
353
+ let rough = levelRoughness(size, level)
354
+ let alpha = rough * rough
355
+ let a2 = alpha * alpha
356
+ let samples = 0
357
+ for (let i = 0; i < PREFILTER_SAMPLES; i++) {
358
+ let u1 = (i + 0.5) / PREFILTER_SAMPLES
359
+ let u2 = radicalInverse(i)
360
+ let phi = 2 * Math.PI * u1
361
+ let cosT = Math.sqrt((1 - u2) / (1 + (a2 - 1) * u2))
362
+ let sinT = Math.sqrt(Math.max(0, 1 - cosT * cosT))
363
+ // N = V, so the reflected sample's cosine is 2 cos^2 - 1.
364
+ let nol = 2 * cosT * cosT - 1
365
+ if (nol <= MIN_NOL) continue
366
+ let d = cosT * cosT * (a2 - 1) + 1
367
+ let ggx = a2 / (Math.PI * d * d)
368
+ let pdf = ggx / 4
369
+ let sampleAngle = 1 / (PREFILTER_SAMPLES * pdf)
370
+ let lod = Math.round(Math.max(0, 0.5 * Math.log2(sampleAngle / texelAngle) + SOURCE_LOD_BIAS))
371
+ sx[samples] = sinT * Math.cos(phi)
372
+ sy[samples] = sinT * Math.sin(phi)
373
+ sz[samples] = cosT
374
+ sLod[samples] = Math.min(source.length - 1, lod)
375
+ sNol[samples] = nol
376
+ samples++
377
+ }
378
+ let faces: CubeFaces = []
379
+ for (let face = 0; face < 6; face++) {
380
+ let px = new Float32Array(edge * edge * 4)
381
+ for (let y = 0; y < edge; y++) {
382
+ for (let x = 0; x < edge; x++) {
383
+ cubeDirection(face, (x + 0.5) / edge, (y + 0.5) / edge, dir)
384
+ let inv = 1 / Math.hypot(dir[0]!, dir[1]!, dir[2]!)
385
+ let nx = dir[0]! * inv
386
+ let ny = dir[1]! * inv
387
+ let nz = dir[2]! * inv
388
+ // A tangent frame around the normal.
389
+ let ux = 0
390
+ let uy = 0
391
+ let uz = 1
392
+ if (Math.abs(nz) > 0.999) (ux = 1), (uz = 0)
393
+ let tx = uy * nz - uz * ny
394
+ let ty = uz * nx - ux * nz
395
+ let tz = ux * ny - uy * nx
396
+ let tn = 1 / Math.hypot(tx, ty, tz)
397
+ tx *= tn
398
+ ty *= tn
399
+ tz *= tn
400
+ let bx = ny * tz - nz * ty
401
+ let by = nz * tx - nx * tz
402
+ let bz = nx * ty - ny * tx
403
+ acc[0] = acc[1] = acc[2] = 0
404
+ let weight = 0
405
+ for (let i = 0; i < samples; i++) {
406
+ let hx = tx * sx[i]! + bx * sy[i]! + nx * sz[i]!
407
+ let hy = ty * sx[i]! + by * sy[i]! + ny * sz[i]!
408
+ let hz = tz * sx[i]! + bz * sy[i]! + nz * sz[i]!
409
+ let k = 2 * sz[i]!
410
+ let lx = k * hx - nx
411
+ let ly = k * hy - ny
412
+ let lz = k * hz - nz
413
+ let lod = sLod[i]!
414
+ fetchCube(source[lod]!, Math.max(1, size >> lod), lx, ly, lz, sNol[i]!, lk, acc)
415
+ weight += sNol[i]!
416
+ }
417
+ let o = (y * edge + x) * 4
418
+ px[o] = acc[0]! / weight
419
+ px[o + 1] = acc[1]! / weight
420
+ px[o + 2] = acc[2]! / weight
421
+ px[o + 3] = 1
422
+ }
423
+ }
424
+ faces.push(px)
425
+ }
426
+ levels.push(faces)
427
+ }
428
+ return levels
429
+ }
430
+
431
+ // The .srte container: "SRTE" u32 | version u32 | size u32 | levels u32,
432
+ // then the faces as float32 rgba, level-major, face-major - the exact
433
+ // buffers createCubeTexture's explicit chain takes, viewed in place.
434
+ /** "SRTE" read as a little-endian u32. */
435
+ const MAGIC = 0x45545253
436
+ const VERSION = 1
437
+ const HEADER_BYTES = 16
438
+
439
+ /** Serialize a prefiltered chain (prefilterCube's result) into the .srte container. */
440
+ export function encodeEnvironment(levels: CubeFaces[], size: number): Uint8Array {
441
+ if (levels.length !== mipLevels(size)) {
442
+ throw new Error("encodeEnvironment: a " + size + " chain has " + mipLevels(size) + " levels, got " + levels.length)
443
+ }
444
+ let total = HEADER_BYTES
445
+ for (let faces of levels) for (let f of faces) total += f.byteLength
446
+ let out = new Uint8Array(total)
447
+ let view = new DataView(out.buffer)
448
+ view.setUint32(0, MAGIC, true)
449
+ view.setUint32(4, VERSION, true)
450
+ view.setUint32(8, size, true)
451
+ view.setUint32(12, levels.length, true)
452
+ let pos = HEADER_BYTES
453
+ for (let faces of levels) {
454
+ for (let f of faces) {
455
+ out.set(new Uint8Array(f.buffer, f.byteOffset, f.byteLength), pos)
456
+ pos += f.byteLength
457
+ }
458
+ }
459
+ return out
460
+ }
461
+
462
+ /** Read a .srte container back into its size and chain, the faces viewing
463
+ * the bytes in place (copied only if the buffer is not float-aligned). */
464
+ export function decodeEnvironment(bytes: Uint8Array): { size: number; levels: CubeFaces[] } {
465
+ if (bytes.byteLength < HEADER_BYTES) throw new Error("decodeEnvironment: not an environment file (too short)")
466
+ let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
467
+ if (view.getUint32(0, true) !== MAGIC) throw new Error("decodeEnvironment: not an environment file (bad magic)")
468
+ let version = view.getUint32(4, true)
469
+ if (version !== VERSION) throw new Error("decodeEnvironment: version " + version + ", expected " + VERSION + " - re-bake with srt tool 3d/environment")
470
+ let size = view.getUint32(8, true)
471
+ let count = view.getUint32(12, true)
472
+ if (size < 1 || count !== mipLevels(size)) throw new Error("decodeEnvironment: a " + size + " chain has " + mipLevels(size) + " levels, file says " + count)
473
+ let aligned = (bytes.byteOffset + HEADER_BYTES) % 4 === 0
474
+ let pos = HEADER_BYTES
475
+ let levels: CubeFaces[] = []
476
+ for (let level = 0; level < count; level++) {
477
+ let edge = Math.max(1, size >> level)
478
+ let floats = edge * edge * 4
479
+ let faces: CubeFaces = []
480
+ for (let face = 0; face < 6; face++) {
481
+ if (pos + floats * 4 > bytes.byteLength) throw new Error("decodeEnvironment: truncated at level " + level + " face " + face)
482
+ faces.push(
483
+ aligned
484
+ ? new Float32Array(bytes.buffer, bytes.byteOffset + pos, floats)
485
+ : new Float32Array(bytes.slice(pos, pos + floats * 4).buffer),
486
+ )
487
+ pos += floats * 4
488
+ }
489
+ levels.push(faces)
490
+ }
491
+ return { size, levels }
492
+ }