@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/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 declare(id: NodeId, node: SceneNode): void {
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 uShadowMap<i> binding of a light slot that does not cast: one white
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: shadow slot i is light i's, so the
648
- * cap is MAX_LIGHTS (each map is a sampler unit and a full extra pass). */
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
- declare(node._node, node)
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 declare(node._node, node)
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
- maps["uShadowMap" + i] = shadow !== undefined ? depthTexture(shadow.view.texture) : shadowPlaceholder()
1281
- i++
1314
+ count++
1282
1315
  }
1283
- for (; i < MAX_LIGHTS; i++) {
1284
- cast.push(0)
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
- uShadowCast: cast,
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 view
1367
- // rendering its map (depth texture, depth override, casting meshes
1368
- // only). `lastWorld` is the light's world matrix the shadow camera was
1369
- // last placed from; `dirty` forces a re-place (options changed).
1370
- type Shadow = { light: DirectionalLight; view: ViewRecord; lastWorld: Mat4; dirty: boolean }
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 MAX_LIGHTS matrices, identity in
1375
- // the slots that do not cast.
1376
- let shadowMatrices: number[] = new Array(MAX_LIGHTS * 16).fill(0)
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 view: a square depth-texture target drawing the casting
1470
- // meshes with the depth override from the light's frustum. The light
1471
- // rewrite binds its map in the light's slot on every receiving target.
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 size = light.shadow.mapSize
1474
- let view = makeView(
1475
- {
1476
- width: size,
1477
- height: size,
1478
- depth: "texture",
1479
- overrideMaterial: shadowDepthMaterial(),
1480
- clearColor: [1, 1, 1, 1],
1481
- label: (opts?.label ?? "scene") + "-shadow",
1482
- },
1483
- m => m.castShadow,
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, view, lastWorld: mat4(), dirty: true })
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
- disposeView(shadow.view)
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 camera from its light's world matrix: at its world
1497
- // position, looking along its world direction, the light frustum as the
1498
- // orthographic extents. Compared against the matrix it was last placed
1499
- // from, so a scene animating elsewhere rewrites nothing here.
1500
- let placeShadowCamera = (shadow: Shadow) => {
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
- if (!shadow.dirty && m.every((x, i) => x === shadow.lastWorld[i])) return
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
- let c = light.shadow.camera
1510
- updateCamera(shadow.view.camera, {
1511
- position: [m[12], m[13], m[14]],
1512
- target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
1513
- // A sun straight down is the common case and the degenerate one for
1514
- // world up: roll about z then (the map's orientation is invisible).
1515
- up: Math.abs(d[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0],
1516
- ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
1517
- near: c.near,
1518
- far: c.far,
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 i = 0
1549
- for (let light of lights) {
1550
- if (light.type !== "directional") continue
1551
- let shadow = shadows.get(light)
1552
- let m = shadow !== undefined ? shadow.view.camera.viewProj : IDENTITY
1553
- for (let k = 0; k < 16; k++) shadowMatrices[i * 16 + k] = m[k]!
1554
- i++
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
- let size = light.shadow.mapSize
1612
- if (size !== shadow.view.width) {
1613
- shadow.view.width = size
1614
- shadow.view.height = size
1615
- setTargetSize(shadow.view.texture, size, size)
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.