@solidrt/3d 0.0.60 → 0.0.61

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
@@ -36,9 +36,9 @@
36
36
  // and pointer subsystems are scene-shadows.ts / scene-pointer.ts,
37
37
  // built here with the scene's seams as their deps.
38
38
 
39
- import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
39
+ import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawParams, setDrawRange, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
40
40
  import * as spatial from "flux:spatial"
41
- import type { Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
41
+ import type { BindDrawOptions, Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
42
42
  import type { BufferId, DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureBindings, TextureId, WrapMode } from "@solidrt/core/gpu"
43
43
  import { getOwner, onCleanup } from "@solidrt/core"
44
44
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
@@ -59,8 +59,7 @@ import type { BloomOptions, ResolveInput, ResolveOptions, ResolveRecord } from "
59
59
  import { createEnvironmentPlaceholder, createPrefilter, bufferFormat } from "./environment.ts"
60
60
  import type { Prefilter } from "./environment.ts"
61
61
  import type { Material } from "./material.ts"
62
- import { orderEntries } from "./order.ts"
63
- import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition, worldInto } from "./node.ts"
62
+ import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition } from "./node.ts"
64
63
  import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
65
64
  import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishRecords } from "./mesh.ts"
66
65
  import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
@@ -117,9 +116,6 @@ function packVolume(volume: Volume, site: string): "capsule" | "box" {
117
116
 
118
117
  // pick()'s camera-ray scratch.
119
118
  let pickDir: Vec3 = [0, 0, 0]
120
- // worldInto's out for the light/center readbacks below; nothing here
121
- // outlives a single call.
122
- let worldScratch = mat4()
123
119
 
124
120
  /** One picking intersection, Three's intersect result: the mesh, the
125
121
  * camera-ray distance in world units, the world-space point, and for a
@@ -410,6 +406,22 @@ export type ToneMapping = "none" | "aces" | "agx" | "neutral"
410
406
 
411
407
  // The uToneMapping value per mode; the RESOLVE set branches on it.
412
408
  const TONE_MAPPING_CODE: Record<ToneMapping, number> = { none: 0, aces: 1, agx: 2, neutral: 3 }
409
+ // The core's draw queues (flux:spatial bindDraw), drawn in this order.
410
+ const QUEUE_BACKGROUND = 0
411
+ const QUEUE_OPAQUE = 1
412
+ const QUEUE_CUTOUT = 2
413
+ const QUEUE_TRANSPARENT = 3
414
+ let drawQueue = (m: Material): number =>
415
+ m.transparent === true ? QUEUE_TRANSPARENT : m.cutout === true ? QUEUE_CUTOUT : QUEUE_OPAQUE
416
+ // A mesh entry's draw sink: what the core writes into it and where it
417
+ // sorts, from the material the entry draws with.
418
+ let drawBinding = (material: Material, mesh: Mesh, inst: { count: number } | null): BindDrawOptions => ({
419
+ normal: material.normalMatrix === true,
420
+ count: inst !== null ? inst.count : 1,
421
+ fade: material.lodFade === true,
422
+ queue: drawQueue(material),
423
+ renderOrder: mesh.renderOrder,
424
+ })
413
425
 
414
426
  /** The RESOLVE set's params for a resolve pass of your own (an app-owned
415
427
  * atlas tiled views render into, resolved through `resolveFragment` from
@@ -575,11 +587,24 @@ export type ViewHandle = {
575
587
  /** Replace the view's layer mask (bitmask): entries for newly admitted
576
588
  * meshes attach, masked-out ones detach. */
577
589
  setLayers(mask: number): void
590
+ /** The level a LOD group draws in this view (scene.lodLevel through
591
+ * the view's camera). */
592
+ lodLevel(group: SceneNode): number | null
578
593
  /** The camera ray through one of the VIEW's pixels, hits nearest first:
579
594
  * scene.pick through this view's camera and size, so a mesh under a
580
595
  * minimap is picked where the minimap shows it (the scene's query
581
596
  * filters apply: visibility, the view's own layer mask is not one). */
582
597
  pick(x: number, y: number): Hit[]
598
+ /** scene.project through this view's camera and size: a world point
599
+ * to VIEW pixels. */
600
+ project(point: Vec3): { x: number; y: number; w: number } | null
601
+ /** scene.unproject through this view's camera and size. */
602
+ unproject(x: number, y: number, w: number, out?: Vec3): Vec3
603
+ /** scene.screenRay through this view's camera and size. */
604
+ screenRay(x: number, y: number): ScreenRay
605
+ /** Exactly scene.raycast: the query is the scene's, a view has no
606
+ * camera in it. */
607
+ raycast(origin: Vec3, direction: Vec3, opts?: QueryOptions): Hit[]
583
608
  /** Element pointer handlers for the element showing `texture`: the
584
609
  * scene's dispatch with THIS VIEW as the root of the walk - nodes get
585
610
  * their ordinary handlers, picked through the view's camera, the
@@ -807,6 +832,13 @@ export type Scene = {
807
832
  * measured projected size, for the scene, its views and its shadow
808
833
  * tiles. Below 1 switches to far levels sooner. */
809
834
  setLodBias(bias: number): void
835
+ /** The level a LOD group draws in the scene's own render (the thing to
836
+ * read while tuning thresholds): the index into its levels, nearest
837
+ * first, `levels.length` when culled past the last one, null before
838
+ * the scene measured it. Views read their own through view.lodLevel;
839
+ * an InstancedLod picks per instance, so this is its group's own
840
+ * pick, not any instance's. Throws for a node with no levels. */
841
+ lodLevel(group: SceneNode): number | null
810
842
  /**
811
843
  * Project a world point to scene pixels: origin top-left, y down - the
812
844
  * output texture's own coordinate space, ready for overlay layout (HUD
@@ -1008,6 +1040,14 @@ function checkLayout(material: Material, geometry: Geometry, what: string): void
1008
1040
  }
1009
1041
  }
1010
1042
 
1043
+ // The debug name a mesh's draw entries report in the resource inventory
1044
+ // (`/gpu`): its geometry's label (a model part's name, a generator's) -
1045
+ // the same name whether the part is a plain mesh or a population - else a
1046
+ // population's own label, else none.
1047
+ function drawLabel(mesh: Mesh): string | undefined {
1048
+ return mesh.geometry.label ?? mesh._instances?.label
1049
+ }
1050
+
1011
1051
  // The index range a mesh's entries draw: its own (setDrawRange) or the
1012
1052
  // whole index list.
1013
1053
  function meshRange(mesh: Mesh): { firstIndex: number; indexCount: number } {
@@ -1116,6 +1156,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1116
1156
  label,
1117
1157
  autoFree: false,
1118
1158
  })
1159
+ spatial.setDrawSort(texture, true)
1119
1160
  let resolve = makeResolve(texture, width, height, opts?.resolve, { filter: opts?.filter, wrap: opts?.wrap }, label)
1120
1161
  let disposed = false
1121
1162
  let scheduled = false
@@ -1185,12 +1226,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1185
1226
  }
1186
1227
 
1187
1228
  // Live meshes (those holding a draw entry) in add order; the background
1188
- // entry never joins this list. Draw order is derived from it by
1189
- // orderEntries (order.ts) whenever orderDirty. Camera moves and
1190
- // transparent-mesh moves only dirty the order when two or more transparent
1191
- // meshes exist - fewer cannot change relative order.
1229
+ // entry never joins this list. Draw order is the core's (setDrawSort on
1230
+ // every sorted target): each mesh's bind carries its sort key (setDrawKey
1231
+ // re-keys on a renderOrder change), and a flush that moved a node,
1232
+ // changed a binding or moved a target's view re-keys that target's
1233
+ // entries.
1192
1234
  let meshes: Mesh[] = []
1193
- let transparentCount = 0
1235
+ let keyDraw = (mesh: Mesh) => spatial.setDrawKey(mesh._node!, drawQueue(mesh.material), mesh.renderOrder)
1194
1236
  // Attached lights in attach order (= light index); any change to the
1195
1237
  // set, a light's fields, or a light's world matrix rewrites the shared
1196
1238
  // light params at the end of the sync - one write, however many meshes.
@@ -1319,60 +1361,34 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1319
1361
  // matrix too: rewrite the whole array once.
1320
1362
  shadowSys.markMatricesDirty()
1321
1363
  }
1322
- let orderDirty = false
1323
- // The order last handed to the engine: a resort that lands on the same
1324
- // permutation (the common case under a moving camera) issues nothing.
1325
- let lastOrder: DrawId[] = []
1326
1364
  // `skybox`: the entry runs the library's cube-map fragment (its params
1327
1365
  // and textures are rewritable in place); false = app GLSL.
1328
1366
  // `sky` is the skybox source (its cube and knobs) for the bake, null
1329
1367
  // for a GLSL background.
1330
1368
  // `entries` is the background's draw entry per target that draws it:
1331
- // the scene's own and every sky view's (a reflection probe), each first
1332
- // in its list.
1369
+ // the scene's own and every sky view's (a reflection probe). Each is
1370
+ // bound to the scene root in the background queue, so the core's sort
1371
+ // keeps it first and its visibility follows the root's; the root's
1372
+ // matrix is not written (the program has no uModel).
1333
1373
  let background: { pipeline: RenderPipelineId; program: ProgramId; sky: SkyboxOptions | null; entries: Map<TextureId, DrawId> } | null = null
1334
- // Add the current background to `target` before entry `before` (its
1335
- // first mesh entry; undefined appends, right for a target with none yet).
1336
- let attachBackground = (target: TextureId, before: DrawId | undefined) => {
1374
+ let attachBackground = (target: TextureId) => {
1337
1375
  if (background === null) return
1338
1376
  let sky = background.sky
1339
1377
  let params = sky === null ? null : skyboxParams(sky, "scene.setBackground")
1340
1378
  let textures = sky === null ? undefined : { uSky: sky.cube }
1341
- background.entries.set(target, addDraw(target, background.pipeline, params, { vertexCount: 3, before, textures }))
1379
+ let entry = addDraw(target, background.pipeline, params, { vertexCount: 3, textures, instanceCount: 0 })
1380
+ background.entries.set(target, entry)
1381
+ spatial.bindDraw(root._node!, target, entry, { params: false, queue: QUEUE_BACKGROUND })
1382
+ }
1383
+ let detachBackground = (target: TextureId, entry: DrawId) => {
1384
+ if (root._node !== null) spatial.unbindDraw(root._node, target)
1385
+ removeDraw(target, entry)
1342
1386
  }
1343
1387
  // The environment cube bound as uEnv on every receiving target (the
1344
1388
  // light rewrite seeds it on new targets); null binds the placeholder,
1345
1389
  // the scene's own 1x1 black cube.
1346
1390
  let environment: TextureId | null = null
1347
1391
  let envPlaceholder = createEnvironmentPlaceholder(label + "-env-none")
1348
- let sortEntries = () => {
1349
- orderDirty = false
1350
- let order = orderEntries(meshes, camera.view, background?.entries.get(texture))
1351
- if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
1352
- lastOrder = order
1353
- setDrawOrder(texture, order)
1354
- }
1355
-
1356
- // The transparent sort keys: each transparent mesh's local-bounds
1357
- // center carried through its world matrix (read from the core), at
1358
- // sort time only - opaque meshes never need one.
1359
- let refreshCenters = () => {
1360
- if (transparentCount < 2) return
1361
- for (let mesh of meshes) {
1362
- if (!mesh._transparent || mesh._node === null) continue
1363
- let b = localBounds(mesh)
1364
- let m = worldInto(worldScratch, mesh)
1365
- let cx = 0, cy = 0, cz = 0
1366
- if (b !== null) {
1367
- cx = (b[0]! + b[3]!) / 2
1368
- cy = (b[1]! + b[4]!) / 2
1369
- cz = (b[2]! + b[5]!) / 2
1370
- }
1371
- mesh._center[0] = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
1372
- mesh._center[1] = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
1373
- mesh._center[2] = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
1374
- }
1375
- }
1376
1392
  let camera = makeCamera()
1377
1393
  let clip: Vec4 = [0, 0, 0, 0]
1378
1394
  let pickOrigin: Vec3 = [0, 0, 0]
@@ -1417,6 +1433,26 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1417
1433
  pickDir[2] = -v[10]
1418
1434
  }
1419
1435
  let pixelRay = (x: number, y: number): void => pixelRayOf(camera, width, height, x, y)
1436
+ // project() for any camera over a tw x th target (the scene's, a view's).
1437
+ let projectWith = (cam: Camera, tw: number, th: number, point: Vec3): { x: number; y: number; w: number } | null => {
1438
+ ensureCamera(cam, tw, th)
1439
+ transformPoint(clip, cam.viewProj, point)
1440
+ let w = clip[3]
1441
+ if (w < 1e-6) return null
1442
+ // perspective() bakes the y-down clip flip, so NDC maps straight to
1443
+ // top-left-origin pixels with no negation here.
1444
+ let x = ((clip[0] / w) * 0.5 + 0.5) * tw
1445
+ let y = ((clip[1] / w) * 0.5 + 0.5) * th
1446
+ if (cam.ortho !== null) {
1447
+ // An orthographic clip w is 1 everywhere (every point projects, the
1448
+ // divides above are no-ops) and carries no depth, so `w` reports
1449
+ // the camera-forward distance off the view row instead - the same
1450
+ // meaning as the perspective clip w, and unproject's exact input.
1451
+ let v = cam.view
1452
+ w = -(v[2] * point[0] + v[6] * point[1] + v[10] * point[2] + v[14])
1453
+ }
1454
+ return { x, y, w }
1455
+ }
1420
1456
 
1421
1457
  // Views (scene.createView): more targets drawing the same meshes from
1422
1458
  // their own cameras. A view holds one entry per mesh in its target,
@@ -1457,11 +1493,6 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1457
1493
  ownNames: Set<string>
1458
1494
  camera: Camera
1459
1495
  entries: Map<Mesh, DrawId>
1460
- orderDirty: boolean
1461
- lastOrder: DrawId[]
1462
- /** A shadow tile measures LOD by the SCENE camera: set once at
1463
- * creation, then on every scene camera move. */
1464
- lodViewSet: boolean
1465
1496
  /** A reflection probe's target is a cube draw target of this face
1466
1497
  * edge, rendered face by face through probe.update(); null for a
1467
1498
  * 2D view. */
@@ -1515,18 +1546,17 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1515
1546
  ...meshRange(mesh),
1516
1547
  textures: entryTextures(mesh.material, mesh, morph),
1517
1548
  instanceCount: 0,
1549
+ label: drawLabel(mesh),
1518
1550
  })
1519
1551
  // The core turns the entry on (with the world matrix) at the next
1520
1552
  // flush, and off again whenever the node or an ancestor hides.
1521
- spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1, mesh.material.lodFade === true)
1522
- orderDirty = true
1553
+ spatial.bindDraw(mesh._node!, texture, mesh._entry, drawBinding(mesh.material, mesh, inst))
1523
1554
  }
1524
1555
  let detachScene = (mesh: Mesh) => {
1525
1556
  if (mesh._entry === null) return
1526
1557
  if (mesh._node !== null) spatial.unbindDraw(mesh._node, texture)
1527
1558
  if (!disposed) removeDraw(texture, mesh._entry)
1528
1559
  mesh._entry = null
1529
- orderDirty = true
1530
1560
  }
1531
1561
  // The material a view draws a mesh with: a shadow view lets a caster's
1532
1562
  // material pick its own depth variant (its cull side, cutout, skinning
@@ -1568,10 +1598,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1568
1598
  ...meshRange(mesh),
1569
1599
  textures: entryTextures(material, mesh, morph),
1570
1600
  instanceCount: 0,
1601
+ label: drawLabel(mesh),
1571
1602
  })
1572
- spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1, material.lodFade === true)
1603
+ spatial.bindDraw(mesh._node!, v.texture, entry, drawBinding(material, mesh, inst))
1573
1604
  v.entries.set(mesh, entry)
1574
- v.orderDirty = true
1575
1605
  }
1576
1606
  let detachView = (v: ViewRecord, mesh: Mesh) => {
1577
1607
  v.skipped.delete(mesh)
@@ -1580,25 +1610,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1580
1610
  v.entries.delete(mesh)
1581
1611
  if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1582
1612
  if (!v.disposed) removeDraw(v.texture, entry)
1583
- v.orderDirty = true
1584
- }
1585
- let sortView = (v: ViewRecord) => {
1586
- v.orderDirty = false
1587
- if (v.override !== null) return
1588
- let order = orderEntries(meshes, v.camera.view, background?.entries.get(v.texture), m => v.entries.get(m as Mesh) ?? null)
1589
- if (order.length === v.lastOrder.length && order.every((id, i) => id === v.lastOrder[i])) return
1590
- v.lastOrder = order
1591
- setDrawOrder(v.texture, order)
1592
1613
  }
1593
1614
  let disposeView = (v: ViewRecord) => {
1594
1615
  if (v.disposed) return
1595
1616
  v.disposed = true
1596
- background?.entries.delete(v.texture)
1617
+ if (background?.entries.delete(v.texture) && root._node !== null) spatial.unbindDraw(root._node, v.texture)
1597
1618
  for (let mesh of v.entries.keys()) if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1598
1619
  v.entries.clear()
1599
1620
  for (let light of lights) if (light.type === "directional" && light._node !== null) spatial.unbindSlot(light._node, v.texture)
1600
- spatial.setFrustum(v.texture, null)
1601
- spatial.setLodView(v.texture, null)
1621
+ spatial.setView(v.texture, null)
1602
1622
  // Drain the zeroed direction slots while the target still exists.
1603
1623
  spatial.flush()
1604
1624
  if (v.resolve !== null) disposeResolve(v.resolve)
@@ -1670,6 +1690,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1670
1690
  x: vopts.x,
1671
1691
  y: vopts.y,
1672
1692
  })
1693
+ // An override view (a shadow tile, a depth or id pass) draws in add
1694
+ // order; every other view sorts like the scene.
1695
+ if (override === null) spatial.setDrawSort(buffer, true)
1696
+ // A shadow tile measures LOD by the scene camera; every other view
1697
+ // by its own, under the scene's bias.
1698
+ if (shadowFilter !== null) spatial.setLodReference(buffer, texture)
1699
+ else spatial.setLodBias(buffer, lodBias)
1673
1700
  let v: ViewRecord = {
1674
1701
  texture: buffer,
1675
1702
  // A shadow view is depth only, a probe's faces are read as a cube,
@@ -1690,18 +1717,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1690
1717
  ownNames: new Set(),
1691
1718
  camera: makeCamera(),
1692
1719
  entries: new Map(),
1693
- orderDirty: true,
1694
- lastOrder: [],
1695
- lodViewSet: false,
1696
1720
  cube,
1697
1721
  probeCube: null,
1698
1722
  sky,
1699
1723
  disposed: false,
1700
1724
  }
1701
1725
  views.push(v)
1702
- // The background goes in first (no mesh entry exists yet), where every
1703
- // later sort keeps it.
1704
- if (sky) attachBackground(v.texture, undefined)
1726
+ if (sky) attachBackground(v.texture)
1705
1727
  // The light rewrite seeds the shadow set (maps, casts, biases,
1706
1728
  // matrices) on the new target too.
1707
1729
  lightsDirty = true
@@ -1737,36 +1759,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1737
1759
  schedule: () => hooks._schedule(),
1738
1760
  })
1739
1761
 
1740
- // A camera write is also the target's frustum for the core's culling:
1741
- // the scene's own, every view's, every shadow tile's - set before the
1742
- // flush below, which switches the entries outside it off. Cube face
1743
- // renders (probes) set no frustum: six cameras share one target and the
1744
- // core would only ever see the last.
1745
- let frustumScratch = new Float32Array(16)
1746
- let setFrustum = (target: TextureId, cam: Camera) => {
1747
- frustumScratch.set(cam.viewProj)
1748
- spatial.setFrustum(target, frustumScratch)
1762
+ // A camera write is also the target's view for the core: what it culls
1763
+ // by, measures projected size with (eye, forward, focal, ortho) and
1764
+ // sorts depth against - set before the flush below, which switches the
1765
+ // entries outside it off. The scene and each view measure by their own
1766
+ // camera; a shadow tile by the SCENE camera, through the LOD reference
1767
+ // set at its creation, so a caster draws the level the camera sees and
1768
+ // its shadow matches. Cube face renders (probes) set it per face: six
1769
+ // cameras share one target, so each face sets its view and flushes
1770
+ // before it renders.
1771
+ let viewScratch = new Float32Array(16)
1772
+ let projScratch = new Float32Array(16)
1773
+ let setView = (target: TextureId, cam: Camera) => {
1774
+ viewScratch.set(cam.view)
1775
+ projScratch.set(cam.proj)
1776
+ spatial.setView(target, viewScratch, projScratch)
1749
1777
  }
1750
- // A camera write is also the target's LOD view - eye, vertical focal
1751
- // factor (the magnitude of proj[5] under either projection: the
1752
- // projections bake in a y-down clip flip, so the element is negative),
1753
- // the ortho flag and the scene's bias - what the core measures
1754
- // projected size with. The scene and each view measure by their own
1755
- // camera; a shadow tile by the SCENE camera, so a caster draws the
1756
- // level the camera sees and its shadow matches.
1757
1778
  let lodBias = opts?.lodBias ?? 1
1758
1779
  if (!(lodBias > 0 && Number.isFinite(lodBias))) throw new Error("createScene: lodBias must be a positive number, got " + lodBias)
1759
- let lodBiasDirty = false
1760
- let lodScratch = new Float32Array(6)
1761
- let setLodView = (target: TextureId, cam: Camera) => {
1762
- lodScratch[0] = cam.eye[0]
1763
- lodScratch[1] = cam.eye[1]
1764
- lodScratch[2] = cam.eye[2]
1765
- lodScratch[3] = Math.abs(cam.proj[5]!)
1766
- lodScratch[4] = cam.ortho !== null ? 1 : 0
1767
- lodScratch[5] = lodBias
1768
- spatial.setLodView(target, lodScratch)
1769
- }
1780
+ spatial.setLodBias(texture, lodBias)
1770
1781
  let sync = () => {
1771
1782
  scheduled = false
1772
1783
  if (disposed) return
@@ -1802,37 +1813,20 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1802
1813
  if (budgetError === null) budgetError = shadowError
1803
1814
  ensureCamera(camera, width, height)
1804
1815
  let cameraMoved = camera.pending
1805
- let biasChanged = lodBiasDirty
1806
- lodBiasDirty = false
1807
1816
  if (camera.pending) {
1808
1817
  camera.pending = false
1809
1818
  setTargetParams(texture, cameraParams(camera))
1810
- setFrustum(texture, camera)
1811
- if (transparentCount > 1) orderDirty = true
1819
+ setView(texture, camera)
1812
1820
  }
1813
- if (cameraMoved || biasChanged) setLodView(texture, camera)
1814
1821
  shadowSys.placeCameras(cameraMoved)
1815
1822
  for (let v of views) {
1816
1823
  ensureCamera(v.camera, v.width, v.height)
1817
- let viewMoved = v.camera.pending
1818
- if (viewMoved) {
1824
+ if (v.camera.pending) {
1819
1825
  v.camera.pending = false
1820
1826
  setTargetParams(v.texture, cameraParams(v.camera))
1821
- setFrustum(v.texture, v.camera)
1822
- if (transparentCount > 1) v.orderDirty = true
1827
+ setView(v.texture, v.camera)
1823
1828
  if (v.shadowFilter !== null) shadowSys.markMatricesDirty()
1824
1829
  }
1825
- // Probe faces measure nothing (six cameras, one target), like their
1826
- // frustum.
1827
- if (v.cube !== null) continue
1828
- if (v.shadowFilter !== null) {
1829
- if (cameraMoved || biasChanged || !v.lodViewSet) {
1830
- v.lodViewSet = true
1831
- setLodView(v.texture, camera)
1832
- }
1833
- } else if (viewMoved || biasChanged) {
1834
- setLodView(v.texture, v.camera)
1835
- }
1836
1830
  }
1837
1831
  // Light bookkeeping first, so a fresh direction-slot bind is seeded
1838
1832
  // by the flush below in the same sync.
@@ -1857,21 +1851,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1857
1851
  // uModel/uNormal, visibility switch and direction slots.
1858
1852
  spatial.flush()
1859
1853
  if (moved.length > 0) {
1860
- // Which meshes moved is the core's knowledge now, so any move with
1861
- // two or more transparent meshes re-sorts (sortEntries issues nothing
1862
- // when the permutation is unchanged).
1863
- if (transparentCount > 1) {
1864
- orderDirty = true
1865
- for (let v of views) v.orderDirty = true
1866
- }
1867
1854
  for (let n of moved) n._moved = false
1868
1855
  moved.length = 0
1869
1856
  }
1870
- // The sort keys are world-space and camera-independent: refreshed once
1871
- // for every sort this sync.
1872
- if (orderDirty || views.some(v => v.orderDirty)) refreshCenters()
1873
- if (orderDirty) sortEntries()
1874
- for (let v of views) if (v.orderDirty) sortView(v)
1875
1857
  if (budgetError !== null) {
1876
1858
  if (opts?.onError !== undefined) opts.onError(budgetError)
1877
1859
  else throw budgetError
@@ -1957,6 +1939,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1957
1939
  )
1958
1940
  }
1959
1941
  if (inst !== null) checkInstancePairing(mesh.material, inst, "Mesh material")
1942
+ // An anchored population's records are relative to the anchor, so
1943
+ // it must be an ancestor (the core's contract: an anchor move then
1944
+ // restages every record); anything else binds records that never
1945
+ // follow it - errors here, at add().
1946
+ if (inst !== null && inst.anchor !== null) {
1947
+ let p: SceneNode | null = mesh.parent
1948
+ while (p !== null && p !== inst.anchor) p = p.parent
1949
+ if (p === null) throw new Error("An instanced mesh's anchor must be one of its ancestors when it is added to a scene")
1950
+ }
1960
1951
  let bufs = acquireGeometryBuffers(mesh.geometry)
1961
1952
  mesh._buffers = bufs
1962
1953
  attachScene(mesh)
@@ -1966,20 +1957,27 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1966
1957
  if (inst !== null && inst.streams.some(s => s.dirty !== null)) recordsDirty.add(mesh)
1967
1958
  // Picking: an ordinary mesh's node carries its geometry's shape,
1968
1959
  // which is its box in the core index (following every
1969
- // updateVertices) and its triangle narrowphase. A populated mesh is
1970
- // box-only by its explicit population bounds (its instances are the
1971
- // leaves that pick, each with the shape; a record mesh's records are
1972
- // opaque, so without explicit bounds it is not picked at all), as is
1973
- // a sprite by the unit box (its triangles lie wherever the camera
1974
- // is, not where the geometry says).
1960
+ // updateVertices) and its triangle narrowphase. An instanced mesh's
1961
+ // instances are the leaves that pick, each with the shape, so its
1962
+ // own node stays OUT of the index: explicit population bounds are
1963
+ // its cull box only (else the cull group of its instances). A
1964
+ // record mesh's records are opaque, so its explicit bounds are its
1965
+ // box in the index (without them it is not picked at all), as is a
1966
+ // sprite's unit box (its triangles lie wherever the camera is, not
1967
+ // where the geometry says).
1975
1968
  if (inst === null && !mesh._sprite) spatial.setShape(mesh._node!, bufs.shape)
1969
+ else if (inst !== null && inst.nodes !== null) spatial.setCullBounds(mesh._node!, inst.bounds)
1976
1970
  else spatial.setBounds(mesh._node!, localBounds(mesh))
1977
1971
  // A rebuilt entry (setGeometry) re-shapes the live instances to the
1978
1972
  // new geometry; their record bindings are untouched. On a first
1979
- // entry none has a core node yet - they enter after the mesh.
1973
+ // entry none has a core node yet - they enter after the mesh -
1974
+ // except under an anchor, where an instance in an earlier branch
1975
+ // of the anchor's subtree is already in and waiting to be bound.
1980
1976
  if (inst !== null && inst.nodes !== null) {
1981
1977
  for (let n of inst.nodes.slots) {
1982
- if (n !== null && n._node !== null) spatial.setShape(n._node, bufs.shape)
1978
+ if (n === null || n._node === null) continue
1979
+ if (inst.anchor !== null && !byNode.has(n._node)) this._attachInstance(n)
1980
+ else spatial.setShape(n._node, bufs.shape)
1983
1981
  }
1984
1982
  }
1985
1983
  // Culling: the gate and margin only when off the defaults, and a
@@ -1997,9 +1995,6 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1997
1995
  if (inst !== null && inst.nodes !== null && inst.bounds === null) groupDirty.add(mesh as InstancedMesh)
1998
1996
  byNode.set(mesh._node!, mesh)
1999
1997
  meshes.push(mesh)
2000
- mesh._transparent = mesh.material.transparent === true
2001
- if (mesh._transparent) transparentCount++
2002
- orderDirty = true
2003
1998
  this._schedule()
2004
1999
  },
2005
2000
  _detach(mesh) {
@@ -2010,15 +2005,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2010
2005
  detachScene(mesh)
2011
2006
  if (mesh._node !== null) {
2012
2007
  spatial.setShape(mesh._node, null)
2013
- spatial.setBounds(mesh._node, null)
2008
+ if (mesh._instances !== null && mesh._instances.nodes !== null) spatial.setCullBounds(mesh._node, null)
2009
+ else spatial.setBounds(mesh._node, null)
2014
2010
  byNode.delete(mesh._node)
2015
2011
  }
2016
2012
  releaseGeometryBuffers(mesh._buffers)
2017
2013
  mesh._buffers = null
2018
2014
  let i = meshes.indexOf(mesh)
2019
2015
  if (i >= 0) meshes.splice(i, 1)
2020
- if (mesh._transparent) transparentCount--
2021
- orderDirty = true
2022
2016
  }
2023
2017
  mesh._entry = null
2024
2018
  },
@@ -2029,17 +2023,23 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2029
2023
  // geometry buffers and core node are live - a mesh masked out of
2030
2024
  // the scene included, whose instances still pick.
2031
2025
  if (mesh === null || mesh._buffers === null || mesh._node === null || instance._node === null) return
2026
+ // A population with an anchor may enter AFTER an instance living
2027
+ // elsewhere in the anchor's subtree (a later sibling branch); the
2028
+ // mesh's own attach then binds the instances it finds waiting.
2029
+ let anchor = (mesh._instances.anchor ?? mesh)._node
2030
+ if (anchor === null || byNode.has(instance._node)) return
2032
2031
  spatial.setShape(instance._node, mesh._buffers.shape)
2033
2032
  spatial.setLayers(instance._node, mesh.layers)
2034
- // The record is the instance's placement inside the mesh (the mesh
2035
- // node is the anchor), staged by the core at the next flush.
2036
- // One buffer per LOD level of an instanced LOD (the population is
2037
- // the group and the anchor, its levels its children at identity).
2033
+ // The record is the instance's placement relative to the anchor -
2034
+ // the mesh node itself, or the ancestor the mesh sits under at
2035
+ // identity - staged by the core at the next flush. One buffer per
2036
+ // LOD level of an instanced LOD (the population is the group and
2037
+ // the anchor, its levels its children at identity).
2038
2038
  let levels = mesh._instances.levels
2039
2039
  instanceBuffers.length = 0
2040
2040
  if (levels === null) instanceBuffers.push(mesh._instances.matrix)
2041
2041
  else for (let l of levels) instanceBuffers.push(l._instances.matrix)
2042
- spatial.bindMatrixRecord(instance._node, instanceBuffers, instance._slot, mesh._node)
2042
+ spatial.bindMatrixRecord(instance._node, instanceBuffers, instance._slot, anchor)
2043
2043
  // A morphing population: the instance's register takes its slot's
2044
2044
  // row now (a recycled slot's row is overwritten, not inherited).
2045
2045
  if (mesh._instances.morph !== null) activateMorph(instance)
@@ -2124,9 +2124,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2124
2124
  recordsDirty.add(mesh)
2125
2125
  this._schedule()
2126
2126
  },
2127
- _reorder() {
2128
- orderDirty = true
2129
- for (let v of views) v.orderDirty = true
2127
+ _reorder(mesh) {
2128
+ if (disposed || mesh._buffers === null || mesh._node === null) return
2129
+ keyDraw(mesh)
2130
2130
  this._schedule()
2131
2131
  },
2132
2132
  _moved(node) {
@@ -2234,9 +2234,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2234
2234
  if (!(bias > 0 && Number.isFinite(bias))) throw new Error("scene.setLodBias: bias must be a positive number, got " + bias)
2235
2235
  if (disposed || bias === lodBias) return
2236
2236
  lodBias = bias
2237
- lodBiasDirty = true
2237
+ spatial.setLodBias(texture, bias)
2238
+ // Shadow tiles take the scene's through their LOD reference.
2239
+ for (let v of views) if (v.shadowFilter === null && !v.disposed) spatial.setLodBias(v.texture, bias)
2238
2240
  hooks._schedule()
2239
2241
  },
2242
+ lodLevel(group) {
2243
+ if (disposed || group._node === null) return null
2244
+ return spatial.lodLevel(group._node, texture)
2245
+ },
2240
2246
  setLayers(mask) {
2241
2247
  checkMask(mask, "scene.setLayers")
2242
2248
  if (disposed || sceneMask === mask) return
@@ -2273,7 +2279,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2273
2279
  let sky = source === null || typeof source === "string" ? null : source
2274
2280
  if (sky !== null) skyboxParams(sky, "scene.setBackground")
2275
2281
  if (background !== null) {
2276
- for (let [target, entry] of background.entries) removeDraw(target, entry)
2282
+ for (let [target, entry] of background.entries) detachBackground(target, entry)
2277
2283
  destroyRenderPipeline(background.pipeline)
2278
2284
  destroyProgram(background.program)
2279
2285
  background = null
@@ -2281,11 +2287,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2281
2287
  if (source === null) return
2282
2288
  let built = sky === null ? backgroundPipeline(source as string, label + "-background") : backgroundPipeline(SKYBOX_FRAGMENT, label + "-skybox")
2283
2289
  background = { pipeline: built.pipeline, program: built.program, sky, entries: new Map() }
2284
- // First in list order on every target that draws it: inserted before
2285
- // the first mesh ENTRY (a layers-masked mesh has none), and every
2286
- // later sort keeps it there.
2287
- attachBackground(texture, meshes.find(m => m._entry !== null)?._entry ?? undefined)
2288
- for (let v of views) if (v.sky && !v.disposed) attachBackground(v.texture, v.lastOrder[0] ?? v.entries.values().next().value)
2290
+ attachBackground(texture)
2291
+ for (let v of views) if (v.sky && !v.disposed) attachBackground(v.texture)
2289
2292
  },
2290
2293
  setEnvironment(env) {
2291
2294
  if (disposed) return
@@ -2318,23 +2321,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2318
2321
  hooks._schedule()
2319
2322
  },
2320
2323
  project(point) {
2321
- ensureCamera(camera, width, height)
2322
- transformPoint(clip, camera.viewProj, point)
2323
- let w = clip[3]
2324
- if (w < 1e-6) return null
2325
- // perspective() bakes the y-down clip flip, so NDC maps straight to
2326
- // top-left-origin pixels with no negation here.
2327
- let x = ((clip[0] / w) * 0.5 + 0.5) * width
2328
- let y = ((clip[1] / w) * 0.5 + 0.5) * height
2329
- if (camera.ortho !== null) {
2330
- // An orthographic clip w is 1 everywhere (every point projects, the
2331
- // divides above are no-ops) and carries no depth, so `w` reports
2332
- // the camera-forward distance off the view row instead - the same
2333
- // meaning as the perspective clip w, and unproject's exact input.
2334
- let v = camera.view
2335
- w = -(v[2] * point[0] + v[6] * point[1] + v[10] * point[2] + v[14])
2336
- }
2337
- return { x, y, w }
2324
+ return projectWith(camera, width, height, point)
2338
2325
  },
2339
2326
  viewProj(out) {
2340
2327
  ensureCamera(camera, width, height)
@@ -2482,11 +2469,35 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2482
2469
  }
2483
2470
  hooks._schedule()
2484
2471
  },
2472
+ lodLevel(group) {
2473
+ if (v.disposed || group._node === null) return null
2474
+ return spatial.lodLevel(group._node, v.texture)
2475
+ },
2485
2476
  pick(x, y) {
2486
2477
  if (v.disposed) return []
2487
2478
  pixelRayOf(v.camera, v.width, v.height, x, y)
2488
2479
  return raycastAs(v.texture, pickOrigin, pickDir, undefined)
2489
2480
  },
2481
+ project(point) {
2482
+ return projectWith(v.camera, v.width, v.height, point)
2483
+ },
2484
+ unproject(x, y, w, out = [0, 0, 0]) {
2485
+ pixelRayOf(v.camera, v.width, v.height, x, y)
2486
+ out[0] = pickOrigin[0] + w * pickDir[0]
2487
+ out[1] = pickOrigin[1] + w * pickDir[1]
2488
+ out[2] = pickOrigin[2] + w * pickDir[2]
2489
+ return out
2490
+ },
2491
+ screenRay(x, y) {
2492
+ pixelRayOf(v.camera, v.width, v.height, x, y)
2493
+ return {
2494
+ origin: [pickOrigin[0], pickOrigin[1], pickOrigin[2]],
2495
+ direction: [pickDir[0], pickDir[1], pickDir[2]],
2496
+ }
2497
+ },
2498
+ raycast(origin, direction, rayOpts) {
2499
+ return raycastAs(texture, origin, direction, rayOpts)
2500
+ },
2490
2501
  get handlers() {
2491
2502
  return viewPointer.handlers
2492
2503
  },
@@ -2544,8 +2555,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2544
2555
  spatial.destroyNode(root._node)
2545
2556
  root._node = null
2546
2557
  }
2547
- spatial.setFrustum(texture, null)
2548
- spatial.setLodView(texture, null)
2558
+ spatial.setView(texture, null)
2549
2559
  // Drain the zeroed direction slots the teardown queued while the
2550
2560
  // targets still exist; afterwards their groups are gone.
2551
2561
  spatial.flush()
@@ -2601,10 +2611,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2601
2611
  // The scene's pending state (lights, params, the fan-out to this
2602
2612
  // target) lands before the faces read it.
2603
2613
  sync()
2604
- renderCubeFaces(v.texture, v.camera, size, position, chain !== null, face => {
2605
- // Opaque order is a hint (front to back); only transparency
2606
- // needs the per-face sort.
2607
- if (face === 0 || transparentCount > 1) sortView(v)
2614
+ renderCubeFaces(v.texture, v.camera, size, position, chain !== null, () => {
2615
+ // Each face looks a different way: its own view, flushed so
2616
+ // the core culls, sorts and measures the face before it
2617
+ // renders.
2618
+ setView(v.texture, v.camera)
2619
+ spatial.flush()
2608
2620
  })
2609
2621
  chain?.run()
2610
2622
  },