@solidrt/3d 0.0.52 → 0.0.54

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
@@ -1386,7 +1537,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1386
1537
  let inst = mesh._instances
1387
1538
  if (v.override !== null && inst !== null) return
1388
1539
  if (v.filter !== null && !v.filter(mesh)) return
1389
- let material = v.override ?? mesh.material
1540
+ // A shadow view (the filtered kind) lets a caster's material pick its
1541
+ // own depth variant (its cull side); any other override view draws
1542
+ // exactly what it was given.
1543
+ let material = v.override !== null ? (v.filter !== null ? (mesh.material.shadow ?? v.override) : v.override) : mesh.material
1390
1544
  let bufs = mesh._buffers!
1391
1545
  let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1392
1546
  buffer: bufs.buffer,
@@ -1437,15 +1591,19 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1437
1591
  if (override !== null) {
1438
1592
  for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
1439
1593
  }
1594
+ let tiled = vopts.into !== undefined
1440
1595
  let v: ViewRecord = {
1441
1596
  texture: createDrawTarget(vopts.width, vopts.height, null, {
1442
- depth: vopts.depth ?? true,
1597
+ depth: tiled ? undefined : (vopts.depth ?? true),
1443
1598
  clearColor: vopts.clearColor,
1444
1599
  filter: vopts.filter,
1445
1600
  wrap: vopts.wrap,
1446
1601
  samples: vopts.samples,
1447
1602
  label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1448
1603
  autoFree: false,
1604
+ into: vopts.into,
1605
+ x: vopts.x,
1606
+ y: vopts.y,
1449
1607
  }),
1450
1608
  width: vopts.width,
1451
1609
  height: vopts.height,
@@ -1466,69 +1624,118 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1466
1624
  hooks._schedule()
1467
1625
  return v
1468
1626
  }
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.
1627
+ // A shadow's views: one square tile of the shadow atlas per map drawing
1628
+ // the casting meshes with the depth override from that map's frustum.
1629
+ // The light rewrite writes the rects in the light's slots on every
1630
+ // receiving target.
1472
1631
  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,
1632
+ let rects = placeShadows(light)
1633
+ if (rects === null || shadowAtlas === null) return
1634
+ let atlas = shadowAtlas
1635
+ let views = rects.map(rect =>
1636
+ makeView(
1637
+ {
1638
+ width: rect.width,
1639
+ height: rect.height,
1640
+ into: atlas.texture,
1641
+ x: rect.x,
1642
+ y: rect.y,
1643
+ overrideMaterial: shadowDepthMaterial(),
1644
+ clearColor: [1, 1, 1, 1],
1645
+ label: (opts?.label ?? "scene") + "-shadow",
1646
+ },
1647
+ m => m.castShadow,
1648
+ ),
1484
1649
  )
1485
- shadows.set(light, { light, view, lastWorld: mat4(), dirty: true })
1650
+ shadows.set(light, { light, views, lastWorld: mat4(), dirty: true, rects })
1486
1651
  lightsDirty = true
1487
1652
  }
1488
1653
  let destroyShadow = (light: DirectionalLight) => {
1489
1654
  let shadow = shadows.get(light)
1490
1655
  if (shadow === undefined) return
1491
1656
  shadows.delete(light)
1492
- disposeView(shadow.view)
1657
+ for (let v of shadow.views) disposeView(v)
1658
+ placeShadows(null)
1493
1659
  lightsDirty = true
1494
1660
  hooks._schedule()
1495
1661
  }
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) => {
1662
+ // Place a shadow's cameras from its light's world matrix. A box light:
1663
+ // at its world position, looking along its world direction, the light
1664
+ // frustum as the orthographic extents. Compared against the matrix it
1665
+ // was last placed from, so a scene animating elsewhere rewrites nothing
1666
+ // here. A cascaded light also follows the scene camera (`cameraMoved`).
1667
+ let cascadeScratch = mat4()
1668
+ let cascadeCenter: Vec3 = [0, 0, 0]
1669
+ let placeShadowCamera = (shadow: Shadow, cameraMoved: boolean) => {
1501
1670
  let light = shadow.light
1502
1671
  let m = worldInto(worldScratch, light)
1503
- if (!shadow.dirty && m.every((x, i) => x === shadow.lastWorld[i])) return
1672
+ let cascaded = shadow.views.length > 1
1673
+ if (!shadow.dirty && !(cascaded && cameraMoved) && m.every((x, i) => x === shadow.lastWorld[i])) return
1504
1674
  shadow.dirty = false
1505
1675
  copy(shadow.lastWorld, m)
1506
1676
  transformVector(shadowDir, m, light.direction)
1507
1677
  let len = Math.hypot(shadowDir[0], shadowDir[1], shadowDir[2]) || 1
1508
1678
  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
- })
1679
+ // A sun straight down is the common case and the degenerate one for
1680
+ // world up: roll about z then (the map's orientation is invisible).
1681
+ let up: Vec3 = Math.abs(d[1]) > VERTICAL_LIGHT ? [0, 0, 1] : [0, 1, 0]
1682
+ if (!cascaded) {
1683
+ let c = light.shadow.camera
1684
+ updateCamera(shadow.views[0]!.camera, {
1685
+ position: [m[12], m[13], m[14]],
1686
+ target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
1687
+ up,
1688
+ ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
1689
+ near: c.near,
1690
+ far: c.far,
1691
+ })
1692
+ return
1693
+ }
1694
+ // Cascades: the scene camera's range near..far (far capped by
1695
+ // shadow.distance) sliced by cascadeSplit, each slice's bounding
1696
+ // sphere (frustumSliceSphere) as an orthographic box looking along
1697
+ // the light, its centre snapped to the map's texel grid in light
1698
+ // space (snapToGrid) so the shadow edges do not swim as the camera
1699
+ // moves. The box reaches back toward the light by the whole range,
1700
+ // so a caster outside the slice still casts into it.
1701
+ let n = shadow.views.length
1702
+ let near = camera.near
1703
+ let far = Math.min(camera.far, light.shadow.distance ?? Infinity)
1704
+ if (!(far > near)) far = near + 1
1705
+ // The light's rotation only: rows are its right, up and back axes.
1706
+ let basis = lookAtMatrix(cascadeScratch, [0, 0, 0], d, up)
1707
+ let aspect = width / height
1708
+ let zn = near
1709
+ for (let c = 0; c < n; c++) {
1710
+ let zf = cascadeSplit(near, far, c, n, CASCADE_SPLIT_LAMBDA)
1711
+ let radius = frustumSliceSphere(cascadeCenter, camera, aspect, zn, zf)
1712
+ zn = zf
1713
+ let view = shadow.views[c]!
1714
+ // A texel is 2r / mapSize world units.
1715
+ snapToGrid(cascadeCenter, cascadeCenter, basis, (2 * radius) / view.width)
1716
+ let back = radius + far
1717
+ updateCamera(view.camera, {
1718
+ position: [cascadeCenter[0] - d[0] * back, cascadeCenter[1] - d[1] * back, cascadeCenter[2] - d[2] * back],
1719
+ target: cascadeCenter,
1720
+ up,
1721
+ ortho: { left: -radius, right: radius, top: radius, bottom: -radius },
1722
+ near: 0,
1723
+ far: back + radius,
1724
+ })
1725
+ }
1520
1726
  }
1521
1727
 
1522
1728
  let sync = () => {
1523
1729
  scheduled = false
1524
1730
  if (disposed) return
1525
1731
  ensureCamera(camera, width, height)
1732
+ let cameraMoved = camera.pending
1526
1733
  if (camera.pending) {
1527
1734
  camera.pending = false
1528
1735
  setTargetParams(texture, cameraParams(camera))
1529
1736
  if (transparentCount > 1) orderDirty = true
1530
1737
  }
1531
- for (let shadow of shadows.values()) placeShadowCamera(shadow)
1738
+ for (let shadow of shadows.values()) placeShadowCamera(shadow, cameraMoved)
1532
1739
  for (let v of views) {
1533
1740
  ensureCamera(v.camera, v.width, v.height)
1534
1741
  if (v.camera.pending) {
@@ -1545,15 +1752,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1545
1752
  // with: one array to every receiving target per shadow-camera move.
1546
1753
  if (shadowMatricesDirty) {
1547
1754
  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]!
1755
+ let dealt = 0
1756
+ forEachShadowSlot((slot, _i, shadow, c) => {
1757
+ let m = shadow.views[c]!.camera.viewProj
1758
+ for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = m[k]!
1759
+ dealt = slot + 1
1760
+ })
1761
+ for (let slot = dealt; slot < MAX_SHADOW_MAPS; slot++) for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = IDENTITY[k]!
1557
1762
  let params: ShaderParams = { uShadowMatrix: shadowMatrices }
1558
1763
  receivingTargets(t => setTargetParams(t, params))
1559
1764
  }
@@ -1608,12 +1813,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1608
1813
  destroyShadow(light)
1609
1814
  return
1610
1815
  }
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)
1816
+ // A cascade count change is a different view set: rebuild it. A
1817
+ // mapSize change re-places every tile (the grid cell follows the
1818
+ // largest map).
1819
+ if (shadow.views.length !== light.shadow.cascades) {
1820
+ destroyShadow(light)
1821
+ createShadow(light)
1822
+ return
1616
1823
  }
1824
+ placeShadows(null)
1617
1825
  shadow.dirty = true
1618
1826
  lightsDirty = true
1619
1827
  hooks._schedule()
@@ -2003,7 +2211,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2003
2211
  let v = makeView(vopts, null)
2004
2212
  return {
2005
2213
  texture: v.texture,
2006
- depthTexture: vopts.depth === "texture" ? depthTexture(v.texture) : null,
2214
+ depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
2007
2215
  setCamera(update) {
2008
2216
  updateCamera(v.camera, update)
2009
2217
  hooks._schedule()
@@ -2016,6 +2224,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2016
2224
  v.camera.dirty = true
2017
2225
  hooks._schedule()
2018
2226
  },
2227
+ setRect(rect) {
2228
+ if (v.disposed) return
2229
+ setTargetRect(v.texture, rect)
2230
+ if (rect.width === v.width && rect.height === v.height) return
2231
+ v.width = rect.width
2232
+ v.height = rect.height
2233
+ v.camera.dirty = true
2234
+ hooks._schedule()
2235
+ },
2019
2236
  setParams(params) {
2020
2237
  if (!v.disposed) setTargetParams(v.texture, params)
2021
2238
  },
@@ -2043,6 +2260,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2043
2260
  destroyTexture(texture)
2044
2261
  shadows.clear()
2045
2262
  for (let v of views.slice()) disposeView(v)
2263
+ if (shadowAtlas !== null) {
2264
+ destroyTexture(shadowAtlas.texture)
2265
+ shadowAtlas = null
2266
+ }
2046
2267
  if (background !== null) {
2047
2268
  // The entry died with the target; the pipeline and program are the
2048
2269
  // scene's own (unlike shared material pipelines), so they go too.