@solidrt/3d 0.0.52 → 0.0.53
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/AGENTS.md +38 -16
- package/examples/README.md +8 -0
- package/examples/cascades.tsx +121 -0
- package/package.json +4 -4
- package/src/components.tsx +2 -2
- package/src/glsl.ts +95 -40
- package/src/material.ts +5 -4
- package/src/math.ts +76 -0
- package/src/scene.ts +312 -94
- package/tools/model.ts +52 -0
- package/demos/README.md +0 -15
- package/demos/assets/icon.svg +0 -23
- package/demos/package.json +0 -9
- package/demos/src/the-third-dimension.tsx +0 -866
- package/demos/tsconfig.json +0 -15
package/src/scene.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// params. They read world matrices back from the core, and only for the
|
|
29
29
|
// subtrees that moved since they last looked.
|
|
30
30
|
|
|
31
|
-
import { addDraw, createBuffer, createDrawTarget, createTexture, depthTexture, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setTargetParams, setTargetSize, setTargetTextures, writeBuffer } from "@solidrt/core/gpu"
|
|
31
|
+
import { addDraw, createBuffer, createDrawTarget, createTexture, depthTexture, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, limits, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setTargetParams, setTargetRect, setTargetSize, setTargetTextures, writeBuffer } from "@solidrt/core/gpu"
|
|
32
32
|
import * as spatial from "flux:spatial"
|
|
33
33
|
import type { NodeId, NodeTransition } from "flux:spatial"
|
|
34
34
|
import { on } from "srt:events"
|
|
@@ -37,9 +37,9 @@ import { getOwner, onCleanup } from "@solidrt/core"
|
|
|
37
37
|
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
38
38
|
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
39
39
|
// the same pairing (and the same name) as Three's Object3D/Matrix4.
|
|
40
|
-
import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, orthographic, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
|
|
40
|
+
import { cascadeSplit, compose, copy, eulerFromQuat, frustumSliceSphere, identity, lookAt as lookAtMatrix, mat4, multiply, orthographic, perspective, quat, quatFromFrame, snapToGrid, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
|
|
41
41
|
import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
|
|
42
|
-
import { MAX_LIGHTS } from "./glsl.ts"
|
|
42
|
+
import { MAX_CASCADES, MAX_LIGHTS, MAX_SHADOW_MAPS } from "./glsl.ts"
|
|
43
43
|
import { geometryBounds, layoutKey, plane, validateGeometry } from "./geometry.ts"
|
|
44
44
|
import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
|
|
45
45
|
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
@@ -50,6 +50,13 @@ import type { Material } from "./material.ts"
|
|
|
50
50
|
|
|
51
51
|
const IDENTITY = mat4()
|
|
52
52
|
const RESOLVED = Promise.resolve()
|
|
53
|
+
// How a cascaded light slices the camera range: 0 uniform, 1
|
|
54
|
+
// logarithmic, halfway the "practical" split (near slices small, far
|
|
55
|
+
// ones not starved).
|
|
56
|
+
const CASCADE_SPLIT_LAMBDA = 0.5
|
|
57
|
+
// |y| of a light direction above this is straight up or down, where
|
|
58
|
+
// world up cannot serve as the shadow map's roll reference.
|
|
59
|
+
const VERTICAL_LIGHT = 0.99
|
|
53
60
|
// lookAt()'s default roll reference. Read-only: quatFromFrame never
|
|
54
61
|
// writes its inputs, so one shared vector is safe.
|
|
55
62
|
const WORLD_UP: Vec3 = [0, 1, 0]
|
|
@@ -73,7 +80,7 @@ let pointScratch: Vec4 = [0, 0, 0, 0]
|
|
|
73
80
|
let declared = new Map<NodeId, SceneNode>()
|
|
74
81
|
let subscribed = false
|
|
75
82
|
|
|
76
|
-
function
|
|
83
|
+
function declareTransition(id: NodeId, node: SceneNode): void {
|
|
77
84
|
declared.set(id, node)
|
|
78
85
|
if (subscribed) return
|
|
79
86
|
subscribed = true
|
|
@@ -168,8 +175,25 @@ export type ShadowOptions = {
|
|
|
168
175
|
/** Offset a receiving point along its normal before the lookup, in
|
|
169
176
|
* world units (default 0) - the acne fix that keeps contact shadows. */
|
|
170
177
|
normalBias?: number
|
|
171
|
-
/** The light frustum; absent keys keep the defaults +-5, 0.5..500.
|
|
178
|
+
/** The light frustum; absent keys keep the defaults +-5, 0.5..500.
|
|
179
|
+
* Ignored by a cascaded light (its frustums follow the scene camera). */
|
|
172
180
|
camera?: Partial<ShadowCamera>
|
|
181
|
+
/** Split the shadow into this many cascades (1..MAX_CASCADES, default
|
|
182
|
+
* 1 = the `camera` box). With more, the light renders one map per slice
|
|
183
|
+
* of the SCENE camera's frustum (near .. far, tightest first), each
|
|
184
|
+
* `mapSize` texels wide and fitted every time the camera or the light
|
|
185
|
+
* moves, and a receiver samples the tightest one that has the point:
|
|
186
|
+
* sharp contact shadows near the camera and coarser ones toward the
|
|
187
|
+
* horizon, for a scene the box cannot cover at one map's resolution.
|
|
188
|
+
* Views (`scene.createView`) sample the same maps, fitted to the scene
|
|
189
|
+
* camera, not their own. Each cascade is a tile of the atlas. */
|
|
190
|
+
cascades?: number
|
|
191
|
+
/** How far from the scene camera a cascaded light shadows, in world
|
|
192
|
+
* units (default null = the camera's far). The cascades span
|
|
193
|
+
* near..distance and a point past it is lit, so pulling it in sharpens
|
|
194
|
+
* every cascade; it also bounds the maps' depth range, which is what
|
|
195
|
+
* `bias` is measured against. A box light ignores it. */
|
|
196
|
+
distance?: number | null
|
|
173
197
|
}
|
|
174
198
|
|
|
175
199
|
/** A directional light node: parallel rays travelling along `direction`
|
|
@@ -192,7 +216,7 @@ export type DirectionalLight = SceneNode & {
|
|
|
192
216
|
* `receiveShadow: false`). */
|
|
193
217
|
castShadow: boolean
|
|
194
218
|
/** The resolved shadow options (read; write through setLight). */
|
|
195
|
-
shadow: { mapSize: number; bias: number; normalBias: number; camera: ShadowCamera }
|
|
219
|
+
shadow: { mapSize: number; bias: number; normalBias: number; camera: ShadowCamera; cascades: number; distance: number | null }
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
/** The ambient term: a sky/ground gradient by the WORLD normal's
|
|
@@ -377,13 +401,23 @@ export type ViewOptions = {
|
|
|
377
401
|
*/
|
|
378
402
|
overrideMaterial?: Material
|
|
379
403
|
/** The view target's depth storage: true (default) for a buffer,
|
|
380
|
-
* "texture" for a sampleable one exposed as `view.depthTexture`.
|
|
404
|
+
* "texture" for a sampleable one exposed as `view.depthTexture`. Not
|
|
405
|
+
* with `into` (the depth is the parent's). */
|
|
381
406
|
depth?: true | "texture"
|
|
382
407
|
clearColor?: [number, number, number, number]
|
|
383
408
|
samples?: 1 | 2 | 4 | 8
|
|
384
409
|
filter?: FilterMode
|
|
385
410
|
wrap?: WrapMode
|
|
386
411
|
label?: string
|
|
412
|
+
/** Render into a rectangle of this draw target (an app-owned atlas)
|
|
413
|
+
* instead of a target of the view's own: every view into one atlas
|
|
414
|
+
* costs ONE pass. `x`/`y` (top-left origin, default 0) place the tile;
|
|
415
|
+
* display it with `<d-texture src={atlas} srcX srcY srcW srcH>`. The
|
|
416
|
+
* atlas carries depth and samples; `view.texture` is then the tile's id
|
|
417
|
+
* (a draw target, not a texture) and `view.depthTexture` is null. */
|
|
418
|
+
into?: TextureId
|
|
419
|
+
x?: number
|
|
420
|
+
y?: number
|
|
387
421
|
}
|
|
388
422
|
|
|
389
423
|
/** A second rendering of a scene from its own camera; see Scene.createView. */
|
|
@@ -396,6 +430,9 @@ export type View = {
|
|
|
396
430
|
/** Partial camera update, exactly scene.setCamera. */
|
|
397
431
|
setCamera(update: CameraUpdate): void
|
|
398
432
|
setSize(width: number, height: number): void
|
|
433
|
+
/** Move and resize a view created `into` an atlas (top-left origin);
|
|
434
|
+
* throws on a view with a target of its own. */
|
|
435
|
+
setRect(rect: { x: number; y: number; width: number; height: number }): void
|
|
399
436
|
/** View-owned shared params on the view's target (the scene's own
|
|
400
437
|
* setParams names fan out to every view already). */
|
|
401
438
|
setParams(params: ShaderParams): void
|
|
@@ -603,7 +640,7 @@ function entrySeed(material: Material, params: ShaderParams | null): ShaderParam
|
|
|
603
640
|
: { uModel: IDENTITY, ...material.params, ...params }
|
|
604
641
|
}
|
|
605
642
|
|
|
606
|
-
// The
|
|
643
|
+
// The uShadowAtlas binding while nothing casts: one white
|
|
607
644
|
// texel (depth 1, never shadowed), shared by every scene for the app.
|
|
608
645
|
let placeholder: TextureId | undefined
|
|
609
646
|
|
|
@@ -644,15 +681,27 @@ export type DirectionalLightOptions = {
|
|
|
644
681
|
}
|
|
645
682
|
export type HemisphereLightOptions = { sky?: Vec3; ground?: Vec3; intensity?: number }
|
|
646
683
|
|
|
647
|
-
/** Every directional light may cast:
|
|
648
|
-
*
|
|
684
|
+
/** Every directional light may cast: the cap is MAX_LIGHTS (every map
|
|
685
|
+
* is a tile of the scene's one shadow atlas, so the pass count does not
|
|
686
|
+
* follow it, the fill does). */
|
|
649
687
|
export const MAX_SHADOWS = MAX_LIGHTS
|
|
688
|
+
export { MAX_CASCADES }
|
|
650
689
|
|
|
651
690
|
function mergeShadow(into: DirectionalLight["shadow"], update: ShadowOptions): void {
|
|
652
691
|
if (update.mapSize !== undefined) into.mapSize = update.mapSize
|
|
653
692
|
if (update.bias !== undefined) into.bias = update.bias
|
|
654
693
|
if (update.normalBias !== undefined) into.normalBias = update.normalBias
|
|
655
694
|
if (update.camera !== undefined) Object.assign(into.camera, update.camera)
|
|
695
|
+
if (update.cascades !== undefined) {
|
|
696
|
+
let n = update.cascades
|
|
697
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_CASCADES) throw new Error("shadow.cascades must be an integer from 1 to " + MAX_CASCADES)
|
|
698
|
+
into.cascades = n
|
|
699
|
+
}
|
|
700
|
+
if (update.distance !== undefined) {
|
|
701
|
+
let d = update.distance
|
|
702
|
+
if (d !== null && !(d > 0)) throw new Error("shadow.distance must be a positive number or null")
|
|
703
|
+
into.distance = d
|
|
704
|
+
}
|
|
656
705
|
}
|
|
657
706
|
|
|
658
707
|
export function createDirectionalLight(opts: DirectionalLightOptions = {}): DirectionalLight {
|
|
@@ -662,7 +711,7 @@ export function createDirectionalLight(opts: DirectionalLightOptions = {}): Dire
|
|
|
662
711
|
light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
|
|
663
712
|
light.intensity = opts.intensity ?? 1
|
|
664
713
|
light.castShadow = opts.castShadow === true
|
|
665
|
-
light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 } }
|
|
714
|
+
light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 }, cascades: 1, distance: null }
|
|
666
715
|
if (opts.shadow !== undefined) mergeShadow(light.shadow, opts.shadow)
|
|
667
716
|
return light
|
|
668
717
|
}
|
|
@@ -902,7 +951,7 @@ function enterScene(node: SceneNode, scene: SceneHooks): void {
|
|
|
902
951
|
node._node = spatial.createNode(fillTransform(node), node.visible)
|
|
903
952
|
if (node._transition !== null) {
|
|
904
953
|
spatial.setTransition(node._node, node._transition)
|
|
905
|
-
|
|
954
|
+
declareTransition(node._node, node)
|
|
906
955
|
}
|
|
907
956
|
// The parent is in the scene already (add() enters the child only then),
|
|
908
957
|
// and the scene root is the one node without a parent.
|
|
@@ -966,7 +1015,7 @@ export function setTransition(node: SceneNode, transition: NodeTransition | stri
|
|
|
966
1015
|
if (node._node !== null) {
|
|
967
1016
|
spatial.setTransition(node._node, transition)
|
|
968
1017
|
if (transition === null) declared.delete(node._node)
|
|
969
|
-
else
|
|
1018
|
+
else declareTransition(node._node, node)
|
|
970
1019
|
}
|
|
971
1020
|
}
|
|
972
1021
|
|
|
@@ -1242,6 +1291,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1242
1291
|
let sky: Vec3 = [0, 0, 0]
|
|
1243
1292
|
let ground: Vec3 = [0, 0, 0]
|
|
1244
1293
|
let colors: number[] = []
|
|
1294
|
+
let bias: number[] = []
|
|
1295
|
+
let normalBias: number[] = []
|
|
1245
1296
|
let count = 0
|
|
1246
1297
|
for (let light of lights) {
|
|
1247
1298
|
if (light.type === "hemisphere") {
|
|
@@ -1258,42 +1309,45 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1258
1309
|
let c = light.color
|
|
1259
1310
|
let k = light.intensity
|
|
1260
1311
|
colors.push(c[0] * k, c[1] * k, c[2] * k)
|
|
1261
|
-
count++
|
|
1262
|
-
}
|
|
1263
|
-
for (let i = count; i < MAX_LIGHTS; i++) colors.push(0, 0, 0)
|
|
1264
|
-
// The shadow set rides with the lights, slot i = directional light i:
|
|
1265
|
-
// whether it casts (0 = a receiving material draws that light plain),
|
|
1266
|
-
// its biases, and its map bound as uShadowMap<i> - the light's depth
|
|
1267
|
-
// id when it casts, else the white placeholder, so every receiving
|
|
1268
|
-
// target always has all MAX_LIGHTS samplers bound.
|
|
1269
|
-
let cast: number[] = []
|
|
1270
|
-
let bias: number[] = []
|
|
1271
|
-
let normalBias: number[] = []
|
|
1272
|
-
let maps: Record<string, TextureId> = {}
|
|
1273
|
-
let i = 0
|
|
1274
|
-
for (let light of lights) {
|
|
1275
|
-
if (light.type !== "directional") continue
|
|
1276
|
-
let shadow = shadows.get(light)
|
|
1277
|
-
cast.push(shadow !== undefined ? 1 : 0)
|
|
1278
1312
|
bias.push(light.shadow.bias)
|
|
1279
1313
|
normalBias.push(light.shadow.normalBias)
|
|
1280
|
-
|
|
1281
|
-
i++
|
|
1314
|
+
count++
|
|
1282
1315
|
}
|
|
1283
|
-
for (; i < MAX_LIGHTS; i++) {
|
|
1284
|
-
|
|
1316
|
+
for (let i = count; i < MAX_LIGHTS; i++) {
|
|
1317
|
+
colors.push(0, 0, 0)
|
|
1285
1318
|
bias.push(0)
|
|
1286
1319
|
normalBias.push(0)
|
|
1287
|
-
maps["uShadowMap" + i] = shadowPlaceholder()
|
|
1288
1320
|
}
|
|
1321
|
+
// The shadow set rides with the lights. Per directional light i: its
|
|
1322
|
+
// map slots as uShadowFirst[i] + uShadowCount[i] (0 = a receiving
|
|
1323
|
+
// material draws that light plain) and its biases; per map slot j its
|
|
1324
|
+
// tile of the atlas as uShadowRect[j] in atlas UV (the whole map in
|
|
1325
|
+
// an unused slot - never read). The atlas depth binds once as
|
|
1326
|
+
// uShadowAtlas, the white placeholder when nothing casts, so every
|
|
1327
|
+
// receiving target always has the sampler bound.
|
|
1328
|
+
let first: number[] = new Array(MAX_LIGHTS).fill(0)
|
|
1329
|
+
let counts: number[] = new Array(MAX_LIGHTS).fill(0)
|
|
1330
|
+
let rects: number[] = []
|
|
1331
|
+
let atlas = shadowAtlas
|
|
1332
|
+
let maps: Record<string, TextureId> = { uShadowAtlas: atlas !== null ? depthTexture(atlas.texture) : shadowPlaceholder() }
|
|
1333
|
+
forEachShadowSlot((slot, i, shadow, c) => {
|
|
1334
|
+
if (c === 0) first[i] = slot
|
|
1335
|
+
counts[i] = counts[i]! + 1
|
|
1336
|
+
let r = shadow.rects[c]!
|
|
1337
|
+
let a = atlas!
|
|
1338
|
+
rects.push(r.x / a.width, r.y / a.height, r.width / a.width, r.height / a.height)
|
|
1339
|
+
})
|
|
1340
|
+
for (let slot = rects.length / 4; slot < MAX_SHADOW_MAPS; slot++) rects.push(0, 0, 1, 1)
|
|
1289
1341
|
let params: ShaderParams = {
|
|
1290
1342
|
uHemiSky: sky,
|
|
1291
1343
|
uHemiGround: ground,
|
|
1292
1344
|
uLightCount: count,
|
|
1293
1345
|
uLightColor: colors,
|
|
1294
|
-
|
|
1346
|
+
uShadowFirst: first,
|
|
1347
|
+
uShadowCount: counts,
|
|
1295
1348
|
uShadowBias: bias,
|
|
1296
1349
|
uShadowNormalBias: normalBias,
|
|
1350
|
+
uShadowRect: rects,
|
|
1297
1351
|
}
|
|
1298
1352
|
receivingTargets(t => {
|
|
1299
1353
|
setTargetParams(t, params)
|
|
@@ -1363,17 +1417,114 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1363
1417
|
let views: ViewRecord[] = []
|
|
1364
1418
|
// Every name scene.setParams has merged so far, replayed on a new view.
|
|
1365
1419
|
let sceneParams: ShaderParams = {}
|
|
1366
|
-
// The shadows, one per casting directional light: the internal
|
|
1367
|
-
// rendering its
|
|
1368
|
-
// only
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1420
|
+
// The shadows, one per casting directional light: the internal views
|
|
1421
|
+
// rendering its maps (tiles of the shadow atlas, depth override, casting
|
|
1422
|
+
// meshes only; one for a box light, `shadow.cascades` for a cascaded
|
|
1423
|
+
// one, tightest first) and `rects`, each tile's place in the atlas in
|
|
1424
|
+
// texels. `lastWorld` is the light's world matrix the shadow cameras
|
|
1425
|
+
// were last placed from; `dirty` forces a re-place (options changed).
|
|
1426
|
+
type ShadowRect = { x: number; y: number; width: number; height: number }
|
|
1427
|
+
type Shadow = { light: DirectionalLight; views: ViewRecord[]; lastWorld: Mat4; dirty: boolean; rects: ShadowRect[] }
|
|
1371
1428
|
let shadows = new Map<DirectionalLight, Shadow>()
|
|
1429
|
+
// The map slots: every casting light's maps in light order, a light's
|
|
1430
|
+
// cascades consecutive and tightest first. The ONE enumeration the
|
|
1431
|
+
// receiving side is dealt by - rects and first/count in writeLights,
|
|
1432
|
+
// matrices in sync - so uShadowFirst/uShadowCount agree with both.
|
|
1433
|
+
// `i` is the light's directional index (its uShadow*[i] slot).
|
|
1434
|
+
let forEachShadowSlot = (fn: (slot: number, i: number, shadow: Shadow, cascade: number) => void): void => {
|
|
1435
|
+
let slot = 0
|
|
1436
|
+
let i = 0
|
|
1437
|
+
for (let light of lights) {
|
|
1438
|
+
if (light.type !== "directional") continue
|
|
1439
|
+
let shadow = shadows.get(light)
|
|
1440
|
+
if (shadow !== undefined) for (let c = 0; c < shadow.views.length; c++) fn(slot++, i, shadow, c)
|
|
1441
|
+
i++
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// The shadow atlas: ONE depth-texture target every casting light's map
|
|
1445
|
+
// is a tile of, so N maps render as one pass and receivers sample one
|
|
1446
|
+
// sampler through per-map rects (uShadowRect). Created with the first
|
|
1447
|
+
// caster, destroyed with the last. Laid out as a grid of cells the
|
|
1448
|
+
// largest mapSize wide, scaled down uniformly when that would exceed
|
|
1449
|
+
// the device's texture size: tile size follows the budget.
|
|
1450
|
+
let shadowAtlas: { texture: TextureId; width: number; height: number } | null = null
|
|
1451
|
+
let shadowLayout = (count: number, maxSize: number) => {
|
|
1452
|
+
let cols = Math.ceil(Math.sqrt(count))
|
|
1453
|
+
let rows = Math.ceil(count / cols)
|
|
1454
|
+
let scale = Math.min(1, limits.maxTextureSize / (cols * maxSize), limits.maxTextureSize / (rows * maxSize))
|
|
1455
|
+
let cell = Math.max(1, Math.floor(maxSize * scale))
|
|
1456
|
+
return { cols, cell, scale, width: cols * cell, height: rows * cell }
|
|
1457
|
+
}
|
|
1458
|
+
// Place every shadow tile for the current caster set plus `adding` (not
|
|
1459
|
+
// yet in `shadows`; its rects are returned for the view creates), in
|
|
1460
|
+
// light order, a light's cascades consecutive. Sizes the atlas, moves
|
|
1461
|
+
// tiles whose place changed, and drops the atlas when nothing casts.
|
|
1462
|
+
// The rects reach receivers through the next light rewrite.
|
|
1463
|
+
let placeShadows = (adding: DirectionalLight | null): ShadowRect[] | null => {
|
|
1464
|
+
let casters: DirectionalLight[] = []
|
|
1465
|
+
for (let l of lights) if (l.type === "directional" && (shadows.has(l) || l === adding)) casters.push(l)
|
|
1466
|
+
if (adding !== null && !casters.includes(adding)) casters.push(adding)
|
|
1467
|
+
lightsDirty = true
|
|
1468
|
+
if (casters.length === 0) {
|
|
1469
|
+
if (shadowAtlas !== null) {
|
|
1470
|
+
destroyTexture(shadowAtlas.texture)
|
|
1471
|
+
shadowAtlas = null
|
|
1472
|
+
}
|
|
1473
|
+
return null
|
|
1474
|
+
}
|
|
1475
|
+
let maxSize = 1
|
|
1476
|
+
let tiles = 0
|
|
1477
|
+
for (let l of casters) {
|
|
1478
|
+
maxSize = Math.max(maxSize, l.shadow.mapSize)
|
|
1479
|
+
tiles += l.shadow.cascades
|
|
1480
|
+
}
|
|
1481
|
+
let lay = shadowLayout(tiles, maxSize)
|
|
1482
|
+
if (shadowAtlas === null) {
|
|
1483
|
+
shadowAtlas = {
|
|
1484
|
+
texture: createDrawTarget(lay.width, lay.height, null, {
|
|
1485
|
+
depth: "texture",
|
|
1486
|
+
clearColor: [1, 1, 1, 1],
|
|
1487
|
+
label: (opts?.label ?? "scene") + "-shadow-atlas",
|
|
1488
|
+
autoFree: false,
|
|
1489
|
+
}),
|
|
1490
|
+
width: lay.width,
|
|
1491
|
+
height: lay.height,
|
|
1492
|
+
}
|
|
1493
|
+
} else if (shadowAtlas.width !== lay.width || shadowAtlas.height !== lay.height) {
|
|
1494
|
+
setTargetSize(shadowAtlas.texture, lay.width, lay.height)
|
|
1495
|
+
shadowAtlas.width = lay.width
|
|
1496
|
+
shadowAtlas.height = lay.height
|
|
1497
|
+
}
|
|
1498
|
+
let placed: ShadowRect[] | null = null
|
|
1499
|
+
let k = 0
|
|
1500
|
+
for (let l of casters) {
|
|
1501
|
+
let size = Math.max(1, Math.floor(l.shadow.mapSize * lay.scale))
|
|
1502
|
+
let shadow = shadows.get(l)
|
|
1503
|
+
for (let c = 0; c < l.shadow.cascades; c++, k++) {
|
|
1504
|
+
let rect: ShadowRect = { x: (k % lay.cols) * lay.cell, y: Math.floor(k / lay.cols) * lay.cell, width: size, height: size }
|
|
1505
|
+
if (shadow === undefined) {
|
|
1506
|
+
if (placed === null) placed = []
|
|
1507
|
+
placed.push(rect)
|
|
1508
|
+
continue
|
|
1509
|
+
}
|
|
1510
|
+
let r = shadow.rects[c]!
|
|
1511
|
+
if (r.x === rect.x && r.y === rect.y && r.width === rect.width && r.height === rect.height) continue
|
|
1512
|
+
shadow.rects[c] = rect
|
|
1513
|
+
let view = shadow.views[c]!
|
|
1514
|
+
setTargetRect(view.texture, rect)
|
|
1515
|
+
view.width = rect.width
|
|
1516
|
+
view.height = rect.height
|
|
1517
|
+
// A tile's texel size moved: the cascade fit snaps to it.
|
|
1518
|
+
shadow.dirty = true
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return placed
|
|
1522
|
+
}
|
|
1372
1523
|
let shadowDir: Vec3 = [0, 0, 0]
|
|
1373
1524
|
// uShadowMatrix is one array param (the engine writes whole arrays), so
|
|
1374
|
-
// any shadow camera move rewrites all
|
|
1375
|
-
// the slots that
|
|
1376
|
-
let shadowMatrices: number[] = new Array(
|
|
1525
|
+
// any shadow camera move rewrites all MAX_SHADOW_MAPS matrices, identity
|
|
1526
|
+
// in the slots that are not dealt.
|
|
1527
|
+
let shadowMatrices: number[] = new Array(MAX_SHADOW_MAPS * 16).fill(0)
|
|
1377
1528
|
let shadowMatricesDirty = false
|
|
1378
1529
|
// Every target a receiving material can draw into: the scene's and each
|
|
1379
1530
|
// view's but the shadow views (binding a target's own depth into it
|
|
@@ -1437,15 +1588,19 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1437
1588
|
if (override !== null) {
|
|
1438
1589
|
for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
|
|
1439
1590
|
}
|
|
1591
|
+
let tiled = vopts.into !== undefined
|
|
1440
1592
|
let v: ViewRecord = {
|
|
1441
1593
|
texture: createDrawTarget(vopts.width, vopts.height, null, {
|
|
1442
|
-
depth: vopts.depth ?? true,
|
|
1594
|
+
depth: tiled ? undefined : (vopts.depth ?? true),
|
|
1443
1595
|
clearColor: vopts.clearColor,
|
|
1444
1596
|
filter: vopts.filter,
|
|
1445
1597
|
wrap: vopts.wrap,
|
|
1446
1598
|
samples: vopts.samples,
|
|
1447
1599
|
label: vopts.label ?? (opts?.label ?? "scene") + "-view",
|
|
1448
1600
|
autoFree: false,
|
|
1601
|
+
into: vopts.into,
|
|
1602
|
+
x: vopts.x,
|
|
1603
|
+
y: vopts.y,
|
|
1449
1604
|
}),
|
|
1450
1605
|
width: vopts.width,
|
|
1451
1606
|
height: vopts.height,
|
|
@@ -1466,69 +1621,118 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1466
1621
|
hooks._schedule()
|
|
1467
1622
|
return v
|
|
1468
1623
|
}
|
|
1469
|
-
// A shadow
|
|
1470
|
-
// meshes with the depth override from
|
|
1471
|
-
// rewrite
|
|
1624
|
+
// A shadow's views: one square tile of the shadow atlas per map drawing
|
|
1625
|
+
// the casting meshes with the depth override from that map's frustum.
|
|
1626
|
+
// The light rewrite writes the rects in the light's slots on every
|
|
1627
|
+
// receiving target.
|
|
1472
1628
|
let createShadow = (light: DirectionalLight) => {
|
|
1473
|
-
let
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1629
|
+
let rects = placeShadows(light)
|
|
1630
|
+
if (rects === null || shadowAtlas === null) return
|
|
1631
|
+
let atlas = shadowAtlas
|
|
1632
|
+
let views = rects.map(rect =>
|
|
1633
|
+
makeView(
|
|
1634
|
+
{
|
|
1635
|
+
width: rect.width,
|
|
1636
|
+
height: rect.height,
|
|
1637
|
+
into: atlas.texture,
|
|
1638
|
+
x: rect.x,
|
|
1639
|
+
y: rect.y,
|
|
1640
|
+
overrideMaterial: shadowDepthMaterial(),
|
|
1641
|
+
clearColor: [1, 1, 1, 1],
|
|
1642
|
+
label: (opts?.label ?? "scene") + "-shadow",
|
|
1643
|
+
},
|
|
1644
|
+
m => m.castShadow,
|
|
1645
|
+
),
|
|
1484
1646
|
)
|
|
1485
|
-
shadows.set(light, { light,
|
|
1647
|
+
shadows.set(light, { light, views, lastWorld: mat4(), dirty: true, rects })
|
|
1486
1648
|
lightsDirty = true
|
|
1487
1649
|
}
|
|
1488
1650
|
let destroyShadow = (light: DirectionalLight) => {
|
|
1489
1651
|
let shadow = shadows.get(light)
|
|
1490
1652
|
if (shadow === undefined) return
|
|
1491
1653
|
shadows.delete(light)
|
|
1492
|
-
|
|
1654
|
+
for (let v of shadow.views) disposeView(v)
|
|
1655
|
+
placeShadows(null)
|
|
1493
1656
|
lightsDirty = true
|
|
1494
1657
|
hooks._schedule()
|
|
1495
1658
|
}
|
|
1496
|
-
// Place a shadow
|
|
1497
|
-
// position, looking along its world direction, the light
|
|
1498
|
-
// orthographic extents. Compared against the matrix it
|
|
1499
|
-
// from, so a scene animating elsewhere rewrites nothing
|
|
1500
|
-
|
|
1659
|
+
// Place a shadow's cameras from its light's world matrix. A box light:
|
|
1660
|
+
// at its world position, looking along its world direction, the light
|
|
1661
|
+
// frustum as the orthographic extents. Compared against the matrix it
|
|
1662
|
+
// was last placed from, so a scene animating elsewhere rewrites nothing
|
|
1663
|
+
// here. A cascaded light also follows the scene camera (`cameraMoved`).
|
|
1664
|
+
let cascadeScratch = mat4()
|
|
1665
|
+
let cascadeCenter: Vec3 = [0, 0, 0]
|
|
1666
|
+
let placeShadowCamera = (shadow: Shadow, cameraMoved: boolean) => {
|
|
1501
1667
|
let light = shadow.light
|
|
1502
1668
|
let m = worldInto(worldScratch, light)
|
|
1503
|
-
|
|
1669
|
+
let cascaded = shadow.views.length > 1
|
|
1670
|
+
if (!shadow.dirty && !(cascaded && cameraMoved) && m.every((x, i) => x === shadow.lastWorld[i])) return
|
|
1504
1671
|
shadow.dirty = false
|
|
1505
1672
|
copy(shadow.lastWorld, m)
|
|
1506
1673
|
transformVector(shadowDir, m, light.direction)
|
|
1507
1674
|
let len = Math.hypot(shadowDir[0], shadowDir[1], shadowDir[2]) || 1
|
|
1508
1675
|
let d: Vec3 = [shadowDir[0] / len, shadowDir[1] / len, shadowDir[2] / len]
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1676
|
+
// A sun straight down is the common case and the degenerate one for
|
|
1677
|
+
// world up: roll about z then (the map's orientation is invisible).
|
|
1678
|
+
let up: Vec3 = Math.abs(d[1]) > VERTICAL_LIGHT ? [0, 0, 1] : [0, 1, 0]
|
|
1679
|
+
if (!cascaded) {
|
|
1680
|
+
let c = light.shadow.camera
|
|
1681
|
+
updateCamera(shadow.views[0]!.camera, {
|
|
1682
|
+
position: [m[12], m[13], m[14]],
|
|
1683
|
+
target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
|
|
1684
|
+
up,
|
|
1685
|
+
ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
|
|
1686
|
+
near: c.near,
|
|
1687
|
+
far: c.far,
|
|
1688
|
+
})
|
|
1689
|
+
return
|
|
1690
|
+
}
|
|
1691
|
+
// Cascades: the scene camera's range near..far (far capped by
|
|
1692
|
+
// shadow.distance) sliced by cascadeSplit, each slice's bounding
|
|
1693
|
+
// sphere (frustumSliceSphere) as an orthographic box looking along
|
|
1694
|
+
// the light, its centre snapped to the map's texel grid in light
|
|
1695
|
+
// space (snapToGrid) so the shadow edges do not swim as the camera
|
|
1696
|
+
// moves. The box reaches back toward the light by the whole range,
|
|
1697
|
+
// so a caster outside the slice still casts into it.
|
|
1698
|
+
let n = shadow.views.length
|
|
1699
|
+
let near = camera.near
|
|
1700
|
+
let far = Math.min(camera.far, light.shadow.distance ?? Infinity)
|
|
1701
|
+
if (!(far > near)) far = near + 1
|
|
1702
|
+
// The light's rotation only: rows are its right, up and back axes.
|
|
1703
|
+
let basis = lookAtMatrix(cascadeScratch, [0, 0, 0], d, up)
|
|
1704
|
+
let aspect = width / height
|
|
1705
|
+
let zn = near
|
|
1706
|
+
for (let c = 0; c < n; c++) {
|
|
1707
|
+
let zf = cascadeSplit(near, far, c, n, CASCADE_SPLIT_LAMBDA)
|
|
1708
|
+
let radius = frustumSliceSphere(cascadeCenter, camera, aspect, zn, zf)
|
|
1709
|
+
zn = zf
|
|
1710
|
+
let view = shadow.views[c]!
|
|
1711
|
+
// A texel is 2r / mapSize world units.
|
|
1712
|
+
snapToGrid(cascadeCenter, cascadeCenter, basis, (2 * radius) / view.width)
|
|
1713
|
+
let back = radius + far
|
|
1714
|
+
updateCamera(view.camera, {
|
|
1715
|
+
position: [cascadeCenter[0] - d[0] * back, cascadeCenter[1] - d[1] * back, cascadeCenter[2] - d[2] * back],
|
|
1716
|
+
target: cascadeCenter,
|
|
1717
|
+
up,
|
|
1718
|
+
ortho: { left: -radius, right: radius, top: radius, bottom: -radius },
|
|
1719
|
+
near: 0,
|
|
1720
|
+
far: back + radius,
|
|
1721
|
+
})
|
|
1722
|
+
}
|
|
1520
1723
|
}
|
|
1521
1724
|
|
|
1522
1725
|
let sync = () => {
|
|
1523
1726
|
scheduled = false
|
|
1524
1727
|
if (disposed) return
|
|
1525
1728
|
ensureCamera(camera, width, height)
|
|
1729
|
+
let cameraMoved = camera.pending
|
|
1526
1730
|
if (camera.pending) {
|
|
1527
1731
|
camera.pending = false
|
|
1528
1732
|
setTargetParams(texture, cameraParams(camera))
|
|
1529
1733
|
if (transparentCount > 1) orderDirty = true
|
|
1530
1734
|
}
|
|
1531
|
-
for (let shadow of shadows.values()) placeShadowCamera(shadow)
|
|
1735
|
+
for (let shadow of shadows.values()) placeShadowCamera(shadow, cameraMoved)
|
|
1532
1736
|
for (let v of views) {
|
|
1533
1737
|
ensureCamera(v.camera, v.width, v.height)
|
|
1534
1738
|
if (v.camera.pending) {
|
|
@@ -1545,15 +1749,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1545
1749
|
// with: one array to every receiving target per shadow-camera move.
|
|
1546
1750
|
if (shadowMatricesDirty) {
|
|
1547
1751
|
shadowMatricesDirty = false
|
|
1548
|
-
let
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
let
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
for (; i < MAX_LIGHTS; i++) for (let k = 0; k < 16; k++) shadowMatrices[i * 16 + k] = IDENTITY[k]!
|
|
1752
|
+
let dealt = 0
|
|
1753
|
+
forEachShadowSlot((slot, _i, shadow, c) => {
|
|
1754
|
+
let m = shadow.views[c]!.camera.viewProj
|
|
1755
|
+
for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = m[k]!
|
|
1756
|
+
dealt = slot + 1
|
|
1757
|
+
})
|
|
1758
|
+
for (let slot = dealt; slot < MAX_SHADOW_MAPS; slot++) for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = IDENTITY[k]!
|
|
1557
1759
|
let params: ShaderParams = { uShadowMatrix: shadowMatrices }
|
|
1558
1760
|
receivingTargets(t => setTargetParams(t, params))
|
|
1559
1761
|
}
|
|
@@ -1608,12 +1810,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1608
1810
|
destroyShadow(light)
|
|
1609
1811
|
return
|
|
1610
1812
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1813
|
+
// A cascade count change is a different view set: rebuild it. A
|
|
1814
|
+
// mapSize change re-places every tile (the grid cell follows the
|
|
1815
|
+
// largest map).
|
|
1816
|
+
if (shadow.views.length !== light.shadow.cascades) {
|
|
1817
|
+
destroyShadow(light)
|
|
1818
|
+
createShadow(light)
|
|
1819
|
+
return
|
|
1616
1820
|
}
|
|
1821
|
+
placeShadows(null)
|
|
1617
1822
|
shadow.dirty = true
|
|
1618
1823
|
lightsDirty = true
|
|
1619
1824
|
hooks._schedule()
|
|
@@ -2003,7 +2208,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
2003
2208
|
let v = makeView(vopts, null)
|
|
2004
2209
|
return {
|
|
2005
2210
|
texture: v.texture,
|
|
2006
|
-
depthTexture: vopts.depth === "texture" ? depthTexture(v.texture) : null,
|
|
2211
|
+
depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
|
|
2007
2212
|
setCamera(update) {
|
|
2008
2213
|
updateCamera(v.camera, update)
|
|
2009
2214
|
hooks._schedule()
|
|
@@ -2016,6 +2221,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
2016
2221
|
v.camera.dirty = true
|
|
2017
2222
|
hooks._schedule()
|
|
2018
2223
|
},
|
|
2224
|
+
setRect(rect) {
|
|
2225
|
+
if (v.disposed) return
|
|
2226
|
+
setTargetRect(v.texture, rect)
|
|
2227
|
+
if (rect.width === v.width && rect.height === v.height) return
|
|
2228
|
+
v.width = rect.width
|
|
2229
|
+
v.height = rect.height
|
|
2230
|
+
v.camera.dirty = true
|
|
2231
|
+
hooks._schedule()
|
|
2232
|
+
},
|
|
2019
2233
|
setParams(params) {
|
|
2020
2234
|
if (!v.disposed) setTargetParams(v.texture, params)
|
|
2021
2235
|
},
|
|
@@ -2043,6 +2257,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
2043
2257
|
destroyTexture(texture)
|
|
2044
2258
|
shadows.clear()
|
|
2045
2259
|
for (let v of views.slice()) disposeView(v)
|
|
2260
|
+
if (shadowAtlas !== null) {
|
|
2261
|
+
destroyTexture(shadowAtlas.texture)
|
|
2262
|
+
shadowAtlas = null
|
|
2263
|
+
}
|
|
2046
2264
|
if (background !== null) {
|
|
2047
2265
|
// The entry died with the target; the pipeline and program are the
|
|
2048
2266
|
// scene's own (unlike shared material pipelines), so they go too.
|