lecodes-sdk 2.4.6 → 2.4.8
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/dist/global.d.ts +7 -0
- package/dist/types/gl/Camera.d.ts +100 -13
- package/dist/types/gl/Lightmap.d.ts +25 -4
- package/dist/types/gl/Model.d.ts +4 -4
- package/dist/types/gl/Node.d.ts +13 -0
- package/dist/types/gl/ReflectionProbe.d.ts +87 -0
- package/dist/types/gl/RenderTexture.d.ts +29 -0
- package/dist/types/gl/Scene.d.ts +21 -0
- package/dist/types/inject.d.ts +4 -0
- package/dist/types/scene/defineScene.d.ts +15 -2
- package/dist/types/ui/tree.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/bridges/gl.d.ts +72 -17
- package/src/gl/Camera.ts +198 -25
- package/src/gl/Lightmap.ts +127 -48
- package/src/gl/Model.ts +4 -4
- package/src/gl/Node.ts +61 -0
- package/src/gl/ReflectionProbe.ts +141 -0
- package/src/gl/RenderTexture.ts +93 -0
- package/src/gl/Scene.ts +54 -2
- package/src/inject.ts +4 -0
- package/src/math/vec.ts +1 -1
- package/src/scene/defineScene.ts +28 -13
- package/src/ui/tree.ts +1 -0
- package/src/version.ts +1 -1
package/src/gl/Lightmap.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
|
|
4
4
|
// and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
|
|
5
5
|
// Hand-built scenes register what never moves with `Lightmap.add`, bake once with
|
|
6
|
-
// `lecodes lightmap bake` (the
|
|
7
|
-
// on the
|
|
6
|
+
// `lecodes lightmap bake` (the bake tool lecodes-bake — the windowless host with the bake built in —
|
|
7
|
+
// describes the live scene to creator-bake, which traces it on the GPU and writes the atlas), and
|
|
8
|
+
// `Lightmap.load` applies it at runtime:
|
|
8
9
|
//
|
|
9
10
|
// const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
|
|
10
11
|
// Lightmap.add(holder) // a Model, a Mesh, or a node holding them
|
|
@@ -67,6 +68,11 @@ export type LightmapLoadOptions = {
|
|
|
67
68
|
* `{ through, diffuse }` adds the share the fibres SCATTER - the cloth's underside glows like a matte panel and lights
|
|
68
69
|
* what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
|
|
69
70
|
transmit?: Record<string, LightmapTransmit>
|
|
71
|
+
/** FOR THE BAKE: CUBES BY NAME — regular expressions (ECMAScript, case-sensitive) searched in every record's key
|
|
72
|
+
* (`<node key>@<ordinal>`: `"SM_pipe"` takes every pipe node, `"@1$"` the second mesh of each): a record that matches
|
|
73
|
+
* leaves the atlas like a small or thin one — an occluder lit by ambient cubes baked where it stands — whatever its size
|
|
74
|
+
* or charts. Added to the CLI's `--cube` patterns (both apply). */
|
|
75
|
+
cube?: string[]
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
|
|
@@ -98,11 +104,17 @@ export type LightmapFiles = {
|
|
|
98
104
|
/** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
|
|
99
105
|
* place a mover can be. Absent = movers keep the scene's IBL. */
|
|
100
106
|
volume?: string
|
|
107
|
+
/** THE PACKED LIGHTMAP UV1 (`<stem>-uv1.bin`, the .bake's `uv1`): the bake packs every mesh's lightmap charts into a
|
|
108
|
+
* square of their own and its rects map that uv1 — the engine swaps it into the models' TEXCOORD_1 first. Required
|
|
109
|
+
* when the bake names one. */
|
|
110
|
+
uv1?: string
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
|
|
104
114
|
export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
|
|
105
115
|
const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
|
|
116
|
+
const TRACERS = { auto: 0, optix: 1, vulkan: 2 } as const // CBAKE_TRACER_*
|
|
117
|
+
const DENOISERS = { auto: 0, optix: 1, oidn: 2 } as const // CBAKE_DENOISER_*
|
|
106
118
|
const DEBUG_MODES: LightmapDebugMode[] = [ "off", "irradiance", "albedo", "normal", "shadingNormal", "share", "skyVis", "atlas", "direction", "lux", "relief", "direct", "indirect", "probe", "probeMap", "skyPath", "probePath" ]
|
|
107
119
|
const PAGE_VIEWS: Record<number, "direct" | "indirect"> = { 11: "direct", 12: "indirect" }
|
|
108
120
|
|
|
@@ -125,15 +137,32 @@ export type LightmapProbe = {
|
|
|
125
137
|
distance: number
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
/** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host
|
|
129
|
-
|
|
140
|
+
/** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host;
|
|
141
|
+
* `cube` = every record of it bakes as ambient cubes (`LightmapAddOptions.cube`, a scene node's `lightmap: 'cube'`) */
|
|
142
|
+
type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node, cube?: boolean }
|
|
143
|
+
|
|
144
|
+
/** `Lightmap.add`'s options */
|
|
145
|
+
export type LightmapAddOptions = {
|
|
146
|
+
/** Bake it as AMBIENT CUBES instead of atlas texels: an occluder (its shadow and bounce stay in the atlas) whose own
|
|
147
|
+
* light is baked where it stands — the way the bake treats small and thin objects (`--min-texels`, `--thin-texels`),
|
|
148
|
+
* here by hand: a pipe run, a fence, a prop whose charts show their seams. A long one takes a slab of cubes per metre
|
|
149
|
+
* (`--object-slab`). Default false. */
|
|
150
|
+
cube?: boolean
|
|
151
|
+
}
|
|
130
152
|
|
|
131
153
|
type BakeConfig = {
|
|
132
154
|
outDir: string, stem: string, size?: number, texel?: number, pages?: number,
|
|
133
155
|
sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
|
|
134
156
|
bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
|
|
135
|
-
/**
|
|
157
|
+
/** The AI denoiser (see `denoiser`) over: `"no-sun"` (the default) the bounce, the sky and the lamps, `"indirect"` the bounce alone, `"all"` + the sun (its shadow edges too), `"none"`. */
|
|
136
158
|
denoise?: "none" | "indirect" | "no-sun" | "all",
|
|
159
|
+
/** Which GPU API traces: `"auto"` (the default) = OptiX where an NVIDIA driver with OptiX opens, else Vulkan ray query — any card
|
|
160
|
+
* with hardware ray tracing (RTX, Radeon RX 6000+, Arc) with its driver alone; `"optix"` / `"vulkan"` by name (the bake fails
|
|
161
|
+
* when it cannot open). */
|
|
162
|
+
tracer?: "auto" | "optix" | "vulkan",
|
|
163
|
+
/** Which AI denoiser `denoise` runs: `"auto"` (the default) = OptiX's on an NVIDIA card, else Intel Open Image Denoise (its CUDA /
|
|
164
|
+
* HIP / CPU device); `"optix"` / `"oidn"` by name. Independent of the tracer. One that cannot open is a warning: the pages stay as traced. */
|
|
165
|
+
denoiser?: "auto" | "optix" | "oidn",
|
|
137
166
|
/** The texel's reconstruction filter radius in texels: 1 (the default) = a ±1 texel tent, 0.5 = within the texel, 0 = the centre alone. */
|
|
138
167
|
filter?: number,
|
|
139
168
|
/** Reflection probes: the grid spacing in metres (4; 0 = none), a probe face's edge in texels (32) and the paths per probe texel (128). */
|
|
@@ -153,6 +182,19 @@ type BakeConfig = {
|
|
|
153
182
|
* how many texels it gets (a fire extinguisher of 0.55 m: 300 charts, the largest 29 texels - every chart edge a step).
|
|
154
183
|
* 0 = everything bakes. Default 12. */
|
|
155
184
|
minTexels?: number,
|
|
185
|
+
/** ROUND AND NARROW objects leave the atlas the same way: an instance none of whose lightmap charts is wider than this
|
|
186
|
+
* many texels across its short side (at `texel`) — a railing's tubes, a cable, a pipe run, blinds. A strip of 1-3 texels
|
|
187
|
+
* around a tube cannot hold the light's change around it and its uv seam shows as a step; the ambient cube baked where
|
|
188
|
+
* it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off. Default 4 (20 cm at 5 cm texels). */
|
|
189
|
+
thinTexels?: number,
|
|
190
|
+
/** A LONG object the atlas left out takes its light in SLABS of this many metres along the longest axis of its bounds
|
|
191
|
+
* (round(length / objectSlab), 16 at most) — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in
|
|
192
|
+
* the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis.
|
|
193
|
+
* 0 = one cube an object. Default 1. */
|
|
194
|
+
objectSlab?: number,
|
|
195
|
+
/** CUBES BY NAME (the CLI's `--cube`): regular expressions over the records' keys, joined with the level's own
|
|
196
|
+
* (`LightmapLoadOptions.cube`) into one — each wrapped in `(?: )`, `|` between. */
|
|
197
|
+
cube?: string[],
|
|
156
198
|
/** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
|
|
157
199
|
volumeSpacing?: number,
|
|
158
200
|
/** A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid
|
|
@@ -170,9 +212,12 @@ type BakeConfig = {
|
|
|
170
212
|
debugDir?: string,
|
|
171
213
|
}
|
|
172
214
|
|
|
173
|
-
/** a small static's light where it stands (creator-bake objects.h):
|
|
174
|
-
*
|
|
175
|
-
|
|
215
|
+
/** a small static's light where it stands (creator-bake objects.h): its SLABS along `axis` (0 x, 1 y, 2 z; its bounds run
|
|
216
|
+
* from `from` to `to` along it) — one for a small one, round(length / objectSlab) for a long one — and per slab per side
|
|
217
|
+
* of its box (+x -x +y -y +z -z) the irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none,
|
|
218
|
+
* -2 = the sky) */
|
|
219
|
+
type BakeObjectLight = { at: number[], axis: number, from: number, to: number, cubes: number[][][], probes: number[] }
|
|
220
|
+
/** a RECORD of the .bake: a registered node's key, or `<key>@<ordinal>` for one mesh of a model (creator-bake atlas.h) */
|
|
176
221
|
type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
|
|
177
222
|
type BakeProbes = {
|
|
178
223
|
file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
|
|
@@ -189,6 +234,8 @@ type BakeData = {
|
|
|
189
234
|
probes?: BakeProbes | null,
|
|
190
235
|
/** the light grid (creator-bake volume.h): its file and lattice */
|
|
191
236
|
volume?: { file: string, n: number[], origin: number[], cell: number[], valid: number } | null,
|
|
237
|
+
/** the packed lightmap uv1 the rects map (creator-bake atlas.h), null = the models' own */
|
|
238
|
+
uv1?: string | null,
|
|
192
239
|
sun?: { direction: number[], color: number[], lux: number } | null, sunMap?: boolean, sunMapLevels?: number, mips?: number, emissiveNits?: number,
|
|
193
240
|
instances: BakeInstance[],
|
|
194
241
|
}
|
|
@@ -225,16 +272,17 @@ export class Lightmap {
|
|
|
225
272
|
* both receivers and occluders in the bake (a model without lightmap UVs, or with materials the lightmap shader
|
|
226
273
|
* declines — alpha cards — is an occluder alone; a terrain is ONE rect, built with `Material.terrainLightmap()`).
|
|
227
274
|
* `key` names the entry in the .bake (default: the node's name + a running number, `container#3`); pass one when
|
|
228
|
-
* names are not stable. */
|
|
229
|
-
static add(node: Node, key?: string): Node {
|
|
275
|
+
* names are not stable. `{ cube: true }` bakes it as ambient cubes instead of atlas texels (`LightmapAddOptions`). */
|
|
276
|
+
static add(node: Node, key?: string, options: LightmapAddOptions = {}): Node {
|
|
230
277
|
const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
|
|
278
|
+
const cube = options.cube === true
|
|
231
279
|
const found: Entry[] = []
|
|
232
280
|
const walk = (n: Node): void => {
|
|
233
281
|
// the node `Terrain.create` returned carries its terrain (Terrain.of reads the same field): the host keeps the
|
|
234
282
|
// terrain's triangles under this node's id for the bake and binds the rect to its chunks
|
|
235
|
-
if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n }); return }
|
|
236
|
-
if (n instanceof Model) { found.push({ key: base, model: n }); return }
|
|
237
|
-
if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
|
|
283
|
+
if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n, cube }); return }
|
|
284
|
+
if (n instanceof Model) { found.push({ key: base, model: n, cube }); return }
|
|
285
|
+
if (n instanceof Mesh) { found.push({ key: base, mesh: n, cube }); return }
|
|
238
286
|
for (const c of n.children) walk(c)
|
|
239
287
|
}
|
|
240
288
|
walk(node)
|
|
@@ -244,13 +292,13 @@ export class Lightmap {
|
|
|
244
292
|
return node
|
|
245
293
|
}
|
|
246
294
|
|
|
247
|
-
/** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk)
|
|
248
|
-
static _register(node: Model | Mesh, key: string): void {
|
|
249
|
-
Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
|
|
295
|
+
/** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk); `cube` = `lightmap: 'cube'`. */
|
|
296
|
+
static _register(node: Model | Mesh, key: string, cube = false): void {
|
|
297
|
+
Lightmap.entries.push(node instanceof Model ? { key, model: node, cube } : { key, mesh: node, cube })
|
|
250
298
|
}
|
|
251
299
|
/** @internal Scene-file runtime: a terrain node - a receiver with one rect (the host keeps its triangles for the bake). */
|
|
252
|
-
static _registerGround(node: Node, key: string): void {
|
|
253
|
-
Lightmap.entries.push({ key, ground: node })
|
|
300
|
+
static _registerGround(node: Node, key: string, cube = false): void {
|
|
301
|
+
Lightmap.entries.push({ key, ground: node, cube })
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
private static next(name: string): number {
|
|
@@ -308,12 +356,31 @@ export class Lightmap {
|
|
|
308
356
|
console.log(`[lightmap] light grid ${data.volume.n.join(" x ")} cells of ${data.volume.cell.map((v) => v.toFixed(2)).join(" x ")} m, ${Lightmap.movers.size} mover(s)`)
|
|
309
357
|
}
|
|
310
358
|
|
|
359
|
+
/** THE PACKED UV1 the bake's rects map (the .bake's `uv1`), into the host BEFORE any rect is applied: the host swaps it
|
|
360
|
+
* into the models' TEXCOORD_1 as their rects go on. A bake without one clears the last (the models' own uv1 back).
|
|
361
|
+
* False = the bake needs it and it cannot be had - its rects would read the wrong texels. */
|
|
362
|
+
private static async loadUv1(data: BakeData, files: LightmapFiles): Promise<boolean> {
|
|
363
|
+
if (!data.uv1) { _creator.lightmapUv1Clear?.(); return true }
|
|
364
|
+
if (!files.uv1) { console.warn(`Lightmap: the bake packed the models' lightmap charts (${data.uv1}) but the scene does not list it — add uv1: asset('…/${data.uv1}') to env.lightmap; the bake is not applied until then`); return false }
|
|
365
|
+
if (!_creator.lightmapUv1Load) { console.warn("Lightmap: this host cannot take the bake's packed lightmap uv1 (rebuild it) — the bake is not applied"); return false }
|
|
366
|
+
try {
|
|
367
|
+
const resp = await fetch(files.uv1, { useOnce: true })
|
|
368
|
+
if (resp.status >= 400) { console.warn(`Lightmap: no packed uv1 at ${files.uv1} (HTTP ${resp.status}) — the bake is not applied`); return false }
|
|
369
|
+
if (!_creator.lightmapUv1Load((resp as unknown as { _h: Handle })._h.id)) { console.warn(`Lightmap: ${files.uv1} is not a packed uv1 — rebake`); return false }
|
|
370
|
+
} catch (e) {
|
|
371
|
+
console.warn(`Lightmap: could not read ${files.uv1}: ${String(e)}`)
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
return true
|
|
375
|
+
}
|
|
376
|
+
|
|
311
377
|
/** Forget every registration (a scene rebuild). */
|
|
312
378
|
static clear(): void {
|
|
313
379
|
for (const l of Light._points) l._holdDark(false)
|
|
314
380
|
Lightmap.movers.clear()
|
|
315
381
|
Lightmap.volumeOn = false
|
|
316
382
|
_creator.lightVolumeClear?.()
|
|
383
|
+
_creator.lightmapUv1Clear?.()
|
|
317
384
|
Lightmap.entries = []
|
|
318
385
|
Lightmap.ordinals.clear()
|
|
319
386
|
Lightmap.bound = null
|
|
@@ -350,7 +417,7 @@ export class Lightmap {
|
|
|
350
417
|
static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
|
|
351
418
|
Lightmap.scene = scene
|
|
352
419
|
const bake = bakeConfig()
|
|
353
|
-
if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
|
|
420
|
+
if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit, options.cube); return null }
|
|
354
421
|
if (!_creator.lightmapApply) {
|
|
355
422
|
if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
|
|
356
423
|
return null
|
|
@@ -369,6 +436,7 @@ export class Lightmap {
|
|
|
369
436
|
return null
|
|
370
437
|
}
|
|
371
438
|
if (!data.pages || !(data.lightScale > 0)) return null // the CLI's placeholder: nothing baked yet
|
|
439
|
+
if (!(await Lightmap.loadUv1(data, files))) return null // the rects map the packed uv1: it goes in first
|
|
372
440
|
const pages = Math.max(1, data.pages)
|
|
373
441
|
const lightFiles = list(files.light), auxFiles = list(files.aux)
|
|
374
442
|
if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
|
|
@@ -410,9 +478,15 @@ export class Lightmap {
|
|
|
410
478
|
const declined = new Set<string>()
|
|
411
479
|
for (const inst of data.instances) if (!inst.receiver && !inst.small) declined.add(inst.key)
|
|
412
480
|
const probeId = (k: number) => k === -2 ? 65534 : k < 0 ? 65535 : k
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
481
|
+
// lightmapObjectApply's numbers: axis, from, to, n, then n slabs x six sides x (rgb, sky), then probe a, probe b, a's weight
|
|
482
|
+
const objectData = (l: BakeObjectLight) =>
|
|
483
|
+
Float32Array.from([ l.axis, l.from, l.to, l.cubes.length, ...l.cubes.flat(2), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
|
|
484
|
+
// a bake from before the slabs (`light.cube`, one cube): not read — its small statics stay unlit until it is rebaked
|
|
485
|
+
const lit = (l?: BakeObjectLight): l is BakeObjectLight => !!l && Array.isArray(l.cubes)
|
|
486
|
+
if (data.instances.some((i) => i.small && i.light && !lit(i.light))) console.warn(`Lightmap: ${files.data} has small statics without \`light.cubes\` (baked before the slabbed object light) — they stay unlit; rebake with \`lecodes lightmap bake\``)
|
|
487
|
+
// THE BAKE'S UNIT IS THE MESH: a model's primitives are records of their own, `<key>@<ordinal>` (ordinal = the primitive's
|
|
488
|
+
// index over the file's meshes) - a rect, or a small static's light, each; the host binds record k to that primitive
|
|
489
|
+
const parts = Lightmap.recordsOf(data.instances)
|
|
416
490
|
let smallLit = 0
|
|
417
491
|
const byEntity = new Map<number, Entry>()
|
|
418
492
|
let applied = 0
|
|
@@ -420,19 +494,20 @@ export class Lightmap {
|
|
|
420
494
|
for (const e of Lightmap.entries) {
|
|
421
495
|
const node = e.model ?? e.mesh ?? e.ground!
|
|
422
496
|
byEntity.set(node.id, e)
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
let took = 0
|
|
426
|
-
for (const [ k, inst ] of
|
|
427
|
-
if (inst.small
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
497
|
+
const records = parts.get(e.key)
|
|
498
|
+
if (records) {
|
|
499
|
+
let took = 0, receivers = 0
|
|
500
|
+
for (const [ k, inst ] of records) {
|
|
501
|
+
if (inst.small) {
|
|
502
|
+
if (lit(inst.light) && _creator.lightmapObjectApply && n > 0 && _creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(inst.light), k) > 0) { took++; smallLit++ }
|
|
503
|
+
} else if (inst.receiver) {
|
|
504
|
+
receivers++
|
|
505
|
+
if (!inst.st || inst.page === undefined || inst.page >= n) { missing.push(inst.key); continue }
|
|
506
|
+
if (_creator.lightmapApply(node.id, light[inst.page]._h.id, aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k) > 0) took++
|
|
432
507
|
}
|
|
433
508
|
}
|
|
434
|
-
if (took
|
|
435
|
-
else
|
|
509
|
+
if (took > 0) applied++
|
|
510
|
+
else if (receivers > 0) console.warn(`Lightmap: ${e.key} (${records.size} mesh record(s)) took no rect — not loaded lightmapped (Model.load { lightmap: true }), or the host binds no records (rebuild it)`)
|
|
436
511
|
continue
|
|
437
512
|
}
|
|
438
513
|
const r = rects.get(e.key)
|
|
@@ -440,10 +515,9 @@ export class Lightmap {
|
|
|
440
515
|
if (tiny) {
|
|
441
516
|
// no rect: the light baked where it stands (any page's samplers — the probes' records sit under every aux page)
|
|
442
517
|
const l = tiny.light
|
|
443
|
-
if (!l || !_creator.lightmapObjectApply || n === 0) continue
|
|
518
|
+
if (!lit(l) || !_creator.lightmapObjectApply || n === 0) continue
|
|
444
519
|
if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
|
|
445
|
-
|
|
446
|
-
if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, d) > 0) smallLit++
|
|
520
|
+
if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(l)) > 0) smallLit++
|
|
447
521
|
continue
|
|
448
522
|
}
|
|
449
523
|
if (!r && declined.has(e.key)) continue // glass / a declined material: the bake knows it as an occluder - the light grid lights it (loadVolume)
|
|
@@ -455,19 +529,19 @@ export class Lightmap {
|
|
|
455
529
|
if (took === 0) { console.warn(`Lightmap: ${e.key} was not loaded lightmapped (Model.load { lightmap: true } / Material.lightmap())`); continue }
|
|
456
530
|
applied++
|
|
457
531
|
}
|
|
458
|
-
if (small.size) console.log(`[lightmap] ${small.size} small
|
|
459
|
-
if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
|
|
532
|
+
if (small.size) console.log(`[lightmap] ${small.size} small, thin or cube-marked record(s) are not in the atlas (the bake's minTexels / thinTexels / cube): ${smallLit} lit by the light baked where they stand`)
|
|
533
|
+
if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) or mesh record(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
|
|
460
534
|
Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
|
|
461
535
|
await Lightmap.loadVolume(data, files)
|
|
462
536
|
return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
|
|
463
537
|
}
|
|
464
538
|
|
|
465
|
-
/** The bake's `<key
|
|
466
|
-
private static
|
|
539
|
+
/** The bake's `<key>@k` records by their node key: a model's meshes (k = the primitive's ordinal in its file). */
|
|
540
|
+
private static recordsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
|
|
467
541
|
const out = new Map<string, Map<number, BakeInstance>>()
|
|
468
542
|
const registered = new Set(Lightmap.entries.map((e) => e.key))
|
|
469
543
|
for (const inst of instances) {
|
|
470
|
-
const m = /^(.*)
|
|
544
|
+
const m = /^(.*)@(\d+)$/.exec(inst.key)
|
|
471
545
|
if (!m || !registered.has(m[1])) continue
|
|
472
546
|
let g = out.get(m[1])
|
|
473
547
|
if (!g) { g = new Map(); out.set(m[1], g) }
|
|
@@ -502,7 +576,7 @@ export class Lightmap {
|
|
|
502
576
|
b[kind] = tex
|
|
503
577
|
}
|
|
504
578
|
b.showing = kind
|
|
505
|
-
const parts = Lightmap.
|
|
579
|
+
const parts = Lightmap.recordsOf(b.data.instances)
|
|
506
580
|
for (const [ id, e ] of b.byEntity) {
|
|
507
581
|
for (const [ k, inst ] of parts.get(e.key) ?? []) {
|
|
508
582
|
if (inst.receiver && inst.st && inst.page !== undefined && inst.page < b.pages) _creator.lightmapApply(id, tex[inst.page]._h.id, b.aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
|
|
@@ -553,7 +627,7 @@ export class Lightmap {
|
|
|
553
627
|
static probe(screenX: number, screenY: number): LightmapProbe | null {
|
|
554
628
|
const b = Lightmap.bound
|
|
555
629
|
if (!b || !_creator.pickTriangle) return null
|
|
556
|
-
type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number],
|
|
630
|
+
type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapOrdinal?: number }
|
|
557
631
|
// the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
|
|
558
632
|
// every GLB instance including the off-scene templates and is what a scene file's clones confuse)
|
|
559
633
|
let hit: Hit | null = null
|
|
@@ -564,7 +638,7 @@ export class Lightmap {
|
|
|
564
638
|
if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
|
|
565
639
|
const entry = b.byEntity.get(hit.entity)
|
|
566
640
|
if (!entry) { console.log(`[probe] hit entity ${hit.entity} node "${hit.nodeName}" at ${hit.point.map((c) => c.toFixed(2)).join(",")} is not a registered static`); return null }
|
|
567
|
-
const hitKey = hit.
|
|
641
|
+
const hitKey = hit.lightmapOrdinal !== undefined && b.rects.has(`${entry.key}@${hit.lightmapOrdinal}`) ? `${entry.key}@${hit.lightmapOrdinal}` : entry.key
|
|
568
642
|
const inst = b.rects.get(hitKey)
|
|
569
643
|
if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
|
|
570
644
|
if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
|
|
@@ -593,10 +667,14 @@ export class Lightmap {
|
|
|
593
667
|
return m
|
|
594
668
|
}
|
|
595
669
|
|
|
596
|
-
private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit
|
|
597
|
-
if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (
|
|
598
|
-
const
|
|
670
|
+
private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>, levelCube?: string[]): void {
|
|
671
|
+
if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (the bake runs in lecodes-bake: LECODES_BAKE_EXE names a host without it)"); return }
|
|
672
|
+
const idOf = (e: Entry): number => (e.model ?? e.mesh ?? e.ground!).id
|
|
673
|
+
const ids = Uint32Array.from(Lightmap.entries.map(idOf))
|
|
599
674
|
const keys = Lightmap.entries.map((e) => e.key).join("\n")
|
|
675
|
+
// the statics baked as cubes: by hand per node (`cube`), and by key - the CLI's --cube and the level's env.lightmap.cube, one regex
|
|
676
|
+
const cubeIds = Uint32Array.from(Lightmap.entries.filter((e) => e.cube).map(idOf))
|
|
677
|
+
const cubeKeys = [ ...(cfg.cube ?? []), ...(levelCube ?? []) ].filter((p) => p !== "").map((p) => `(?:${p})`).join("|")
|
|
600
678
|
if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
|
|
601
679
|
// a `baked: false` light is held dark for the bake — the engine bakes every lit point light it finds
|
|
602
680
|
for (const l of Light._points) l._holdDark(false) // a bake started over a loaded level
|
|
@@ -609,7 +687,8 @@ export class Lightmap {
|
|
|
609
687
|
cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
|
|
610
688
|
cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
|
|
611
689
|
cfg.probeSpacing ?? 4, cfg.probeSize ?? 32, cfg.probeRays ?? 128, cfg.probeIndoor ?? false, cfg.probeLayout === "grid" ? 1 : 0, cfg.probeBox ? Float32Array.from(cfg.probeBox) : null, (cfg.minTexels ?? 12) * (cfg.texel ?? 0.05), cfg.volumeSpacing ?? 1, emissiveBake ? emissiveNits : -emissiveNits, cfg.detailTexels ?? 2000, cfg.debugDir ?? "",
|
|
612
|
-
Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";")
|
|
690
|
+
Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"),
|
|
691
|
+
TRACERS[cfg.tracer ?? "auto"] ?? 0, DENOISERS[cfg.denoiser ?? "auto"] ?? 0, cfg.thinTexels ?? 4, cfg.objectSlab ?? 1, cubeKeys, cubeIds)
|
|
613
692
|
for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
|
|
614
693
|
}
|
|
615
694
|
}
|
package/src/gl/Model.ts
CHANGED
|
@@ -20,8 +20,8 @@ export interface TrianglePick {
|
|
|
20
20
|
mesh: string
|
|
21
21
|
primitive: number
|
|
22
22
|
material: string
|
|
23
|
-
/** the
|
|
24
|
-
|
|
23
|
+
/** the primitive's index over its file's meshes: the lightmap bake's record `<key>@<ordinal>` */
|
|
24
|
+
lightmapOrdinal: number
|
|
25
25
|
/** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
|
|
26
26
|
triangle: number
|
|
27
27
|
backface: boolean
|
|
@@ -130,8 +130,8 @@ export class Model extends Node {
|
|
|
130
130
|
culling?: boolean
|
|
131
131
|
onProgress?: (p: { loaded: number, total?: number }) => void
|
|
132
132
|
/** Baked lighting (engines/bake). `true` = a STATIC: loads through the lightmap material so
|
|
133
|
-
* `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 —
|
|
134
|
-
*
|
|
133
|
+
* `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 —
|
|
134
|
+
* `lecodes assets unwrap`, which `lightmap bake` runs itself on raw GLBs) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
|
|
135
135
|
* the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
|
|
136
136
|
lightmap?: boolean
|
|
137
137
|
/** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
|
package/src/gl/Node.ts
CHANGED
|
@@ -94,6 +94,43 @@ const findNodeByWalk = (rootId: number, name: string): number => {
|
|
|
94
94
|
// Scratch for the world-position read-back in _setOwnedPosition (one per module, never handed out).
|
|
95
95
|
const worldScratch = new Float32Array(3)
|
|
96
96
|
|
|
97
|
+
/** @internal The scene a node is in: the one `scene.add` put it or its nearest such ancestor in (`_inScene`), -1 = none
|
|
98
|
+
* yet. A scene's nodes are pinned, so the walk meets the very wrappers `scene.add` stamped. */
|
|
99
|
+
export const sceneIdOf = (node: Node): number => {
|
|
100
|
+
if (node._inScene >= 0) return node._inScene
|
|
101
|
+
for (let id = _creator.getParent(node.id); id !== 0; id = _creator.getParent(id)) {
|
|
102
|
+
const n = nodes.get(id)
|
|
103
|
+
if (n && n._inScene >= 0) return n._inScene
|
|
104
|
+
}
|
|
105
|
+
return -1
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @internal A node whose render view starts once the node is in a scene (a secondary Camera's `renderToTexture`, a
|
|
109
|
+
* ReflectionProbe): `_bindView(sceneId)` starts it if it has none. */
|
|
110
|
+
export type ViewBinder = Node & { _bindView(sceneId: number): void }
|
|
111
|
+
// The binders waiting for a scene, WEAKLY: a view never placed must not pin its node. Tried whenever the scene graph
|
|
112
|
+
// grows (bindPendingViews) — an empty set costs that call one size check.
|
|
113
|
+
const pendingViews = new Set<WeakRef<ViewBinder>>()
|
|
114
|
+
/** @internal Start `binder`'s view now if its node is in a scene, else at the scene.add / node.add that puts it in one.
|
|
115
|
+
* One try per placement: a view the host refuses (an overlay scene) warns and is not retried. */
|
|
116
|
+
export const awaitScene = (binder: ViewBinder): void => {
|
|
117
|
+
const sceneId = sceneIdOf(binder)
|
|
118
|
+
if (sceneId >= 0) binder._bindView(sceneId)
|
|
119
|
+
else pendingViews.add(new WeakRef(binder))
|
|
120
|
+
}
|
|
121
|
+
/** @internal The scene graph grew (scene.add, node.add, setParent): start the waiting views whose node is in a scene now. */
|
|
122
|
+
export const bindPendingViews = (): void => {
|
|
123
|
+
if (pendingViews.size === 0) return
|
|
124
|
+
for (const ref of pendingViews) {
|
|
125
|
+
const binder = ref.deref()
|
|
126
|
+
if (!binder) { pendingViews.delete(ref); continue }
|
|
127
|
+
const sceneId = sceneIdOf(binder)
|
|
128
|
+
if (sceneId < 0) continue
|
|
129
|
+
pendingViews.delete(ref)
|
|
130
|
+
binder._bindView(sceneId)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
97
134
|
export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
98
135
|
/** @internal The entity's owned handle (`_h.id` = the entity id; DEAD_HANDLE once destroyed). An
|
|
99
136
|
* engine-created entity (a glTF part, an anchor) is wrapped from its number and pinned as the
|
|
@@ -101,6 +138,9 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
|
101
138
|
_h: Handle
|
|
102
139
|
/** Native entity id. */
|
|
103
140
|
get id(): number { return this._h.id }
|
|
141
|
+
/** @internal The id of the scene `scene.add` put this node in (-1 = none; `scene.remove` clears it) — how a render
|
|
142
|
+
* view finds its scene (sceneIdOf). */
|
|
143
|
+
_inScene = -1
|
|
104
144
|
|
|
105
145
|
private _matrix?: Float32Array
|
|
106
146
|
private _worldMatrix?: Float32Array
|
|
@@ -132,6 +172,25 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
|
132
172
|
set name(value: string) { _creator.setName(this.id, value) }
|
|
133
173
|
get visible(): boolean { return _creator.isVisible(this.id) }
|
|
134
174
|
set visible(value: boolean) { _creator.setVisible(this.id, value) }
|
|
175
|
+
/**
|
|
176
|
+
* The node's visibility LAYERS, a bit mask 1..255. The scene's own view draws every layer; a render
|
|
177
|
+
* view (a secondary `Camera`'s or a `ReflectionProbe`'s, their `layers` option) draws only the
|
|
178
|
+
* renderables whose layers share a bit with its mask — which is what layers are for: leaving a subtree
|
|
179
|
+
* out of a second camera (the car out of the reflection probe riding on it, the player's own body out
|
|
180
|
+
* of a mirror). A node without a layer
|
|
181
|
+
* of its own takes its nearest ancestor's, else 1, so a car's layer covers everything parented under
|
|
182
|
+
* it; `0` clears the node's own (inherit again). `visible = false` wins over any layer. Reads the
|
|
183
|
+
* effective bits. A host without layers draws everything everywhere (reads 1, a write warns once).
|
|
184
|
+
*/
|
|
185
|
+
get layer(): number { return _creator.getLayer ? _creator.getLayer(this.id) : 1 }
|
|
186
|
+
set layer(mask: number) {
|
|
187
|
+
if (_creator.setLayer) _creator.setLayer(this.id, mask & 0xff)
|
|
188
|
+
else if (!Node._warnedLayer) {
|
|
189
|
+
Node._warnedLayer = true
|
|
190
|
+
console.warn("[node] this host has no setLayer — node layers are ignored, every view draws everything")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
private static _warnedLayer = false
|
|
135
194
|
|
|
136
195
|
// --- local transform ---
|
|
137
196
|
// Native owns the authoritative matrix. `_sync()` reads it into a per-node scratch buffer on EVERY
|
|
@@ -317,6 +376,7 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
|
317
376
|
/** Parent the given nodes under this one. */
|
|
318
377
|
add(...children: Node[]): this {
|
|
319
378
|
for (const c of children) { _creator.setParent(c.id, this.id, false); nodes.pin(c) }
|
|
379
|
+
bindPendingViews()
|
|
320
380
|
return this
|
|
321
381
|
}
|
|
322
382
|
setParent(parent: Node | null, worldPositionStays = false): this {
|
|
@@ -326,6 +386,7 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
|
326
386
|
} else {
|
|
327
387
|
_creator.setParent(this.id, parent.id, worldPositionStays)
|
|
328
388
|
nodes.pin(this)
|
|
389
|
+
bindPendingViews()
|
|
329
390
|
}
|
|
330
391
|
return this
|
|
331
392
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// A reflection probe: a cube of the scene's light drawn around a point, live (creator-gl renderview.cpp: a render view of
|
|
2
|
+
// six faces into an HDR cube render texture; with PREFILTER, Filament's libs/iblprefilter filter after each completed
|
|
3
|
+
// cube). A Node, placed and parented like any; its view starts once the node is in a scene (Node.ts awaitScene) — or at
|
|
4
|
+
// `scene.environment = probe` while it is in none. RenderTexture.ts holds the half it shares with a secondary Camera.
|
|
5
|
+
|
|
6
|
+
import { Node, awaitScene } from "./Node"
|
|
7
|
+
import { CAMERA_DEFAULTS } from "./Camera"
|
|
8
|
+
import {
|
|
9
|
+
RenderTexture, RT_FLAG_CUBEMAP, RT_FLAG_HDR, RT_FLAG_PREFILTER, RT_MAX_SIDE, makeRenderTexture, requireRenderTextures, startRenderView,
|
|
10
|
+
} from "./RenderTexture"
|
|
11
|
+
|
|
12
|
+
/** `new ReflectionProbe(options)`: the cube, what its view draws, and how often. */
|
|
13
|
+
export type ReflectionProbeOptions = {
|
|
14
|
+
/** The cube's edge, texels per face side (default 128): a power of two, 16..4096, when prefiltered; 1..4096 otherwise.
|
|
15
|
+
* A probe is light, not a picture — 64..128 is plenty for a car's reflections. */
|
|
16
|
+
size?: number
|
|
17
|
+
/** The layers its view draws, a bit mask over `Node.layer` (default 0xff = everything): leave out the probe's own
|
|
18
|
+
* carrier, or the cube is the inside of the car it rides on. Fixed. */
|
|
19
|
+
layers?: number
|
|
20
|
+
/** Shadows in its view (default `false`): with them, a full cube renders its own shadow maps six times. Fixed. */
|
|
21
|
+
shadows?: boolean
|
|
22
|
+
/** Faces drawn per render, 1..6, round-robin (default 6 = the whole cube) — `probe.facesPerFrame`. */
|
|
23
|
+
facesPerFrame?: number
|
|
24
|
+
/** Render once every N frames drawn (default 1 = every frame) — `probe.everyNth`. */
|
|
25
|
+
everyNth?: number
|
|
26
|
+
/** Filter the cube for ROUGHNESS (default `true`; `size` a power of two then). Without it the cube has one sharp level:
|
|
27
|
+
* under `scene.environment` only mirror-like surfaces (roughness near 0) are right, and the diffuse ambient stays the
|
|
28
|
+
* scene's own probe's. With it the GPU filters every completed cube into five roughness levels — a brushed or painted
|
|
29
|
+
* body shows its surroundings blurred as much as its roughness says — and the roughest level becomes the diffuse
|
|
30
|
+
* ambient: what the probe sees lights matte surfaces too (a red wall tints them red).
|
|
31
|
+
*
|
|
32
|
+
* COST: one filtering per completed cube (every render at `facesPerFrame` 6, every third at 2), on the frame after:
|
|
33
|
+
* the cube's mip chain plus a pass per level whose taps are the level's texels × `samples` — for a 64 cube at the
|
|
34
|
+
* default 256 samples about 2 million cube taps — and ten small driver submissions on the CPU. `everyNth` /
|
|
35
|
+
* `facesPerFrame` space the filterings out. Fixed. */
|
|
36
|
+
prefilter?: boolean
|
|
37
|
+
/** The filter's samples per texel, 1..2048 (default 256 — a quarter of Filament's offline default: each sample reads
|
|
38
|
+
* the mip that matches its footprint, which keeps a low count smooth; raise it if a mid-roughness surface reads
|
|
39
|
+
* blotchy). Smoothness for time; read with `prefilter` only. */
|
|
40
|
+
samples?: number
|
|
41
|
+
/** Near clip plane in metres (default 0.01). Fixed. */
|
|
42
|
+
near?: number
|
|
43
|
+
/** Far clip plane in metres (default 1000). Fixed. */
|
|
44
|
+
far?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A REFLECTION PROBE: a cube of the scene's light drawn around a point, live — chrome and glass show what is actually
|
|
49
|
+
* around them, moving objects included. A Node: place it, or parent it (`car.add(probe)` — it rides along); the six faces
|
|
50
|
+
* look along the world axes from its WORLD position (its rotation and scale are ignored). It draws once it is in a scene
|
|
51
|
+
* (added to one, or under a node that is), every frame that scene is on show — after the frame's update passes, before
|
|
52
|
+
* the scene's own picture — in linear HDR light (no tone mapping) at the scene camera's exposure. It sees the scene's
|
|
53
|
+
* renderables, lights, environment and sky, not its overlays.
|
|
54
|
+
*
|
|
55
|
+
* Two ways to use it:
|
|
56
|
+
* - `scene.environment = probe`: the scene's reflections come from the cube instead of the scene's own probe
|
|
57
|
+
* (`SceneOptions.ibl`) — and, prefiltered (the default), its diffuse ambient too. A probe in no scene yet starts in
|
|
58
|
+
* that one.
|
|
59
|
+
* - `probe.texture` in a material that samples a cube itself (a custom shader's samplerCubemap): the same cube as a
|
|
60
|
+
* `RenderTexture` — when prefiltered, the filtered one, five roughness levels.
|
|
61
|
+
*
|
|
62
|
+
* COST: a full render is six passes over the scene, plus (prefiltered) a GPU filtering per completed cube (`prefilter`
|
|
63
|
+
* says what one costs). Keep `size` small (128 is a lot), spread the cube over frames (`facesPerFrame: 2` = a whole cube
|
|
64
|
+
* every 3 frames, a third of the cost each), render less often (`everyNth`), leave out what it need not see (`layers` —
|
|
65
|
+
* its carrier first), keep `shadows` off, and turn it off once a static surrounding is drawn (`enabled = false` keeps the
|
|
66
|
+
* last cube).
|
|
67
|
+
*
|
|
68
|
+
* LIFETIME: the cube and its view live as long as the probe — keep a reference (`scene.environment` holds it too).
|
|
69
|
+
* Destroying the probe's node stops the view (the cube keeps its last light); moving it to another scene later does not
|
|
70
|
+
* move the view. Throws on a host without render textures (`createRenderTexture` — the web hosts, for now).
|
|
71
|
+
*/
|
|
72
|
+
export class ReflectionProbe extends Node {
|
|
73
|
+
/** The cube the probe draws (`cubemap`, linear HDR light) — the filtered one, five roughness levels, when `prefilter`. */
|
|
74
|
+
readonly texture: RenderTexture
|
|
75
|
+
/** Filtered for roughness (the `prefilter` option). */
|
|
76
|
+
readonly prefilter: boolean
|
|
77
|
+
/** @internal The render view's owned handle (`_view.id` = the engine's view id), null until the view starts: it lives
|
|
78
|
+
* exactly as long as this probe holds it. */
|
|
79
|
+
_view: Handle | null = null
|
|
80
|
+
private readonly _layers: number
|
|
81
|
+
private readonly _shadows: boolean
|
|
82
|
+
private readonly _near: number
|
|
83
|
+
private readonly _far: number
|
|
84
|
+
private _enabled = true
|
|
85
|
+
private _everyNth: number
|
|
86
|
+
private _facesPerFrame: number
|
|
87
|
+
|
|
88
|
+
constructor(options: ReflectionProbeOptions = {}) {
|
|
89
|
+
requireRenderTextures("new ReflectionProbe")
|
|
90
|
+
const size = Math.max(1, Math.floor(options.size ?? 128))
|
|
91
|
+
const prefilter = options.prefilter !== false
|
|
92
|
+
if (prefilter && (size < 16 || size > RT_MAX_SIDE || (size & (size - 1)) !== 0)) {
|
|
93
|
+
throw new Error(`new ReflectionProbe: a prefiltered probe's size is a power of two, 16..${RT_MAX_SIDE} (got ${size}) — or prefilter: false`)
|
|
94
|
+
}
|
|
95
|
+
super()
|
|
96
|
+
const samples = prefilter ? Math.max(0, Math.floor(options.samples ?? 0)) : 0
|
|
97
|
+
this.texture = makeRenderTexture("new ReflectionProbe", size, size, RT_FLAG_CUBEMAP | RT_FLAG_HDR | (prefilter ? RT_FLAG_PREFILTER : 0), samples)
|
|
98
|
+
this.prefilter = prefilter
|
|
99
|
+
this._layers = (options.layers ?? 0xff) & 0xff
|
|
100
|
+
this._shadows = options.shadows === true
|
|
101
|
+
this._near = options.near ?? CAMERA_DEFAULTS.near
|
|
102
|
+
this._far = options.far ?? CAMERA_DEFAULTS.far
|
|
103
|
+
this._everyNth = Math.max(1, Math.floor(options.everyNth ?? 1))
|
|
104
|
+
this._facesPerFrame = Math.min(6, Math.max(1, Math.floor(options.facesPerFrame ?? 6)))
|
|
105
|
+
awaitScene(this)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @internal Node.ts awaitScene / Scene.environment: the probe is in scene `sceneId` — start its view into its cube. */
|
|
109
|
+
_bindView(sceneId: number): void {
|
|
110
|
+
if (this._view) return
|
|
111
|
+
this._view = startRenderView("ReflectionProbe", sceneId, this.texture, this.id, this._layers, 90, this._near, this._far, this._shadows)
|
|
112
|
+
if (this._view && (!this._enabled || this._everyNth !== 1 || this._facesPerFrame !== 6)) this._sendSchedule()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether the probe draws (default `true`). Off keeps the last cube and costs nothing — a probe that has drawn its
|
|
116
|
+
* static surroundings once; back on, it renders on the next frame drawn. */
|
|
117
|
+
get enabled(): boolean { return this._enabled }
|
|
118
|
+
set enabled(on: boolean) {
|
|
119
|
+
this._enabled = on
|
|
120
|
+
this._sendSchedule()
|
|
121
|
+
}
|
|
122
|
+
/** Render once every N frames DRAWN (default 1 = every frame). The count restarts with each change: the next frame
|
|
123
|
+
* drawn renders, then one in every N. */
|
|
124
|
+
get everyNth(): number { return this._everyNth }
|
|
125
|
+
set everyNth(n: number) {
|
|
126
|
+
this._everyNth = Math.max(1, Math.floor(n))
|
|
127
|
+
this._sendSchedule()
|
|
128
|
+
}
|
|
129
|
+
/** The cube's faces drawn per render, 1..6 (default 6), round-robin from where the last render stopped: 2 spreads a
|
|
130
|
+
* whole cube over 3 renders at a third of the cost each. A prefiltered cube is filtered each time it completes. */
|
|
131
|
+
get facesPerFrame(): number { return this._facesPerFrame }
|
|
132
|
+
set facesPerFrame(n: number) {
|
|
133
|
+
this._facesPerFrame = Math.min(6, Math.max(1, Math.floor(n)))
|
|
134
|
+
this._sendSchedule()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @internal Send the whole schedule (`setRenderViewOptions` restarts the count, so the next frame drawn renders). */
|
|
138
|
+
private _sendSchedule(): void {
|
|
139
|
+
if (this._view) _creator.setRenderViewOptions?.(this._view.id, this._enabled, this._everyNth, this._facesPerFrame)
|
|
140
|
+
}
|
|
141
|
+
}
|