@solidrt/3d 0.0.57 → 0.0.59

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.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
package/src/scene.ts CHANGED
@@ -36,31 +36,33 @@
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, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
39
+ import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
40
40
  import * as spatial from "flux:spatial"
41
41
  import type { Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
42
- import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
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"
45
45
  import { copy, mat4, transformPoint } from "./math.ts"
46
- import { linearColor } from "./color.ts"
46
+ import { linearColor, premultipliedColor } from "./color.ts"
47
47
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
48
48
  import { MAX_LIGHTS, MAX_SHADOW_MAPS } from "./glsl.ts"
49
49
  import { cameraParams, cameraState, ensureCamera, makeCamera, updateCamera } from "./camera.ts"
50
50
  import type { Camera, CameraState, CameraUpdate } from "./camera.ts"
51
51
  import { makeShadowSystem } from "./scene-shadows.ts"
52
52
  import { makePointerInput } from "./scene-pointer.ts"
53
- import { geometryBounds, layoutKey, validateGeometry } from "./geometry.ts"
53
+ import { geometryKey, geometryTopology, validateGeometry } from "./geometry.ts"
54
54
  import type { Geometry } from "./geometry.ts"
55
55
  import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
56
56
  import { backgroundPipeline, missingAttributes, SKYBOX_FRAGMENT } from "./material.ts"
57
- import { createEnvironmentPlaceholder, createPrefilter, probeFormat } from "./environment.ts"
57
+ import { disposeResolve, makeResolve, replaceResolve, resizeResolve, setResolveBloom } from "./resolve.ts"
58
+ import type { BloomOptions, ResolveInput, ResolveOptions, ResolveRecord } from "./resolve.ts"
59
+ import { createEnvironmentPlaceholder, createPrefilter, bufferFormat } from "./environment.ts"
58
60
  import type { Prefilter } from "./environment.ts"
59
61
  import type { Material } from "./material.ts"
60
62
  import { orderEntries } from "./order.ts"
61
- import { fillTransform, leaveScene, makeNode, worldInto } from "./node.ts"
63
+ import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition, worldInto } from "./node.ts"
62
64
  import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
63
- import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishInstanceStyle } from "./mesh.ts"
65
+ import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishRecords } from "./mesh.ts"
64
66
  import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
65
67
  import type { CastingLight, Light } from "./light.ts"
66
68
 
@@ -397,25 +399,55 @@ function environmentParams(env: EnvironmentOptions | null): ShaderParams {
397
399
  return { uEnvIntensity: k.intensity, uEnvRotation: cubeTurn(k.rotation), uEnvOn: 1 }
398
400
  }
399
401
 
400
- /** The output tone mapping (setToneMapping): "none" clamps, "aces" is the
401
- * filmic curve every engine ships (Three's ACESFilmic, Godot's ACES,
402
- * Unity's ACES). */
403
- export type ToneMapping = "none" | "aces"
402
+ export type { BloomOptions, ResolveInput, ResolveOptions } from "./resolve.ts"
404
403
 
405
- // The uToneMapping value per mode; the OUTPUT set branches on it.
406
- const TONE_MAPPING_CODE: Record<ToneMapping, number> = { none: 0, aces: 1 }
404
+ /** The resolve's tone mapping (setToneMapping): "none" clamps, "aces" is
405
+ * the filmic curve every engine ships (Three's ACESFilmic, Godot's ACES,
406
+ * Unity's ACES), "agx" Blender's AgX (Three, Godot 4.3+) and "neutral"
407
+ * the Khronos PBR Neutral curve (Three, Unity) that keeps product colors
408
+ * where a filmic curve shifts them. */
409
+ export type ToneMapping = "none" | "aces" | "agx" | "neutral"
410
+
411
+ // The uToneMapping value per mode; the RESOLVE set branches on it.
412
+ const TONE_MAPPING_CODE: Record<ToneMapping, number> = { none: 0, aces: 1, agx: 2, neutral: 3 }
413
+
414
+ /** The RESOLVE set's params for a resolve pass of your own (an app-owned
415
+ * atlas tiled views render into, resolved through `resolveFragment` from
416
+ * `/glsl`): `uToneMapping` and `uExposure` as the scene's setToneMapping
417
+ * and setExposure would write them - a shader target validates its
418
+ * params strictly, so both are always written. */
419
+ export function resolveParams(opts?: { toneMapping?: ToneMapping; exposure?: number }): ShaderParams {
420
+ let mode = opts?.toneMapping ?? "none"
421
+ let code = TONE_MAPPING_CODE[mode]
422
+ if (code === undefined) throw new Error('resolveParams: expected "none", "aces", "agx" or "neutral", got ' + mode)
423
+ let exposure = opts?.exposure ?? 1
424
+ if (!Number.isFinite(exposure) || exposure < 0) throw new Error("resolveParams: expected a finite exposure >= 0, got " + exposure)
425
+ return { uToneMapping: code, uExposure: exposure }
426
+ }
407
427
 
408
428
  export type SceneOptions = {
409
- /** The target's clear, written as given: encoded pixels, untouched by
410
- * exposure and tone mapping (a GLSL background or skybox goes through
411
- * both). */
429
+ /** The buffer's clear: an sRGB color like every color option (decoded
430
+ * to linear light on write), so the backdrop takes the scene's exposure
431
+ * and tone mapping in the resolve exactly as a background does. */
412
432
  clearColor?: [number, number, number, number]
433
+ /** The resolve fragment (see setResolve), or a function of the buffer
434
+ * id returning it - the chain over `hdrTexture` is built right there;
435
+ * absent is DEFAULT_RESOLVE. */
436
+ resolve?: ResolveInput
437
+ /** The stock bloom on the resolve; see setBloom. Views follow it
438
+ * unless they carry a `bloom` of their own. */
439
+ bloom?: BloomOptions
413
440
  /** Scene-wide fog; see setFog. */
414
441
  fog?: FogOptions
415
442
  /** Output tone mapping, default "none"; see setToneMapping. */
416
443
  toneMapping?: ToneMapping
417
444
  /** Output exposure, default 1; see setExposure. */
418
445
  exposure?: number
446
+ /** LOD quality knob, default 1: every measured projected size is
447
+ * multiplied by it before the level select, so below 1 the groups hand
448
+ * over to their far levels sooner (Unity's lodBias). Live via
449
+ * scene.setLodBias; views and shadow tiles follow it. */
450
+ lodBias?: number
419
451
  /** The scene target's layer mask (bitmask, default 1): the scene draws
420
452
  * the meshes whose `layers` intersect it. Live via scene.setLayers. */
421
453
  layers?: number
@@ -431,6 +463,12 @@ export type SceneOptions = {
431
463
  /** The cube map reflective materials mirror; see setEnvironment. */
432
464
  environment?: EnvironmentOptions
433
465
  label?: string
466
+ /** `stagger` (ms) on the scene's root node - `setTransition(scene.root,
467
+ * { stagger })` at creation: every node added straight under the root
468
+ * that enters or leaves in one frame is spaced by `index * stagger`,
469
+ * the whole-scene form of a Group's stagger (nested groups declaring
470
+ * their own win for what is under them). */
471
+ stagger?: number
434
472
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
435
473
  autoFree?: boolean
436
474
  filter?: FilterMode
@@ -438,6 +476,13 @@ export type SceneOptions = {
438
476
  /** Multisample count of the target (1, 2, 4 or 8; default 1). Storage-only
439
477
  * anti-aliasing of mesh edges; see createDrawTarget. */
440
478
  samples?: 1 | 2 | 4 | 8
479
+ /** Where a per-scene budget error goes: the light cap and the
480
+ * shadow-slot budget are checked over the settled set at the sync and
481
+ * reported once per change of the set, the scene rendering on with
482
+ * what fits. Without a handler the sync throws it, uncaught (a
483
+ * microtask): the `<Scene>` component hands one in and rethrows inside
484
+ * the tree, so the app's error boundary shows it. */
485
+ onError?: (error: Error) => void
441
486
  }
442
487
 
443
488
  export type ViewOptions = {
@@ -467,17 +512,29 @@ export type ViewOptions = {
467
512
  * "texture" for a sampleable one exposed as `view.depthTexture`. Not
468
513
  * with `into` (the depth is the parent's). */
469
514
  depth?: true | "texture"
515
+ /** The view buffer's clear, an sRGB color like the scene's. */
470
516
  clearColor?: [number, number, number, number]
471
517
  samples?: 1 | 2 | 4 | 8
472
518
  filter?: FilterMode
473
519
  wrap?: WrapMode
474
520
  label?: string
521
+ /** The view's resolve fragment (see Scene.setResolve), or a function
522
+ * of the view's buffer id; not with `into` (a tile renders linear into
523
+ * the atlas, which the app resolves once). */
524
+ resolve?: ResolveInput
525
+ /** The view's own bloom: BloomOptions overrides the scene's, null turns
526
+ * it off in this view (a clean minimap). Absent follows the scene's
527
+ * setBloom, like `fog`. Not with `into`. */
528
+ bloom?: BloomOptions | null
475
529
  /** Render into a rectangle of this draw target (an app-owned atlas)
476
530
  * instead of a target of the view's own: every view into one atlas
477
531
  * costs ONE pass. `x`/`y` (top-left origin, default 0) place the tile;
478
532
  * display it with `<d-texture src={atlas} srcX srcY srcW srcH>`. The
479
- * atlas carries depth and samples; `view.texture` is then the tile's id
480
- * (a draw target, not a texture) and `view.depthTexture` is null. */
533
+ * atlas carries depth, samples and the format; `view.texture` is then
534
+ * the tile's id (a draw target, not a texture), `view.depthTexture` and
535
+ * `view.hdrTexture` are null, and the tile holds LINEAR light like every
536
+ * buffer - resolve the atlas once with a DEFAULT_RESOLVE pass over it
537
+ * (`uScene` bound to the atlas) and display that. */
481
538
  into?: TextureId
482
539
  x?: number
483
540
  y?: number
@@ -485,16 +542,28 @@ export type ViewOptions = {
485
542
 
486
543
  /** A second rendering of a scene from its own camera; see Scene.createView. */
487
544
  export type ViewHandle = {
488
- /** The view's output, an ordinary texture id. */
545
+ /** The view's output, an ordinary texture id: its resolve (or, tiled,
546
+ * the tile's id). */
489
547
  texture: TextureId
490
- /** The view target's depth as a sampler-only texture id when created
548
+ /** The view's buffer, the linear light its resolve reads (sampler-only;
549
+ * null for a tiled view). */
550
+ hdrTexture: TextureId | null
551
+ /** The view buffer's depth as a sampler-only texture id when created
491
552
  * with `depth: "texture"` (the shadow-map input), else null. */
492
553
  depthTexture: TextureId | null
554
+ /** Replace the view's resolve fragment; exactly Scene.setResolve. Throws
555
+ * on a tiled view. */
556
+ setResolve(resolve: ResolveInput): void
557
+ /** The view's own bloom from now on (null = off): the scene's setBloom
558
+ * skips this view afterwards. Throws on a tiled view. */
559
+ setBloom(bloom: BloomOptions | null): void
493
560
  /** Partial camera update, exactly scene.setCamera. */
494
561
  setCamera(update: CameraUpdate): void
495
562
  /** Current camera state, exactly scene.camera. */
496
563
  camera(): CameraState
497
564
  setSize(width: number, height: number): void
565
+ /** The view's target size in pixels, as setSize/setRect last left it. */
566
+ size(): { width: number; height: number }
498
567
  /** Move and resize a view created `into` an atlas (top-left origin);
499
568
  * throws on a view with a target of its own. */
500
569
  setRect(rect: { x: number; y: number; width: number; height: number }): void
@@ -564,7 +633,10 @@ export type ReflectionProbe = {
564
633
  * sampled by the faces it renders (a black environment stands in): one
565
634
  * bounce. */
566
635
  update(): void
567
- /** Destroy the cube (idempotent; probes also die with the scene). */
636
+ /** Destroy the cube (idempotent; probes also die with the scene). A
637
+ * scene whose `environment` or `background` names this cube drops it
638
+ * first (setEnvironment(null), setBackground(null)), so nothing samples
639
+ * a destroyed texture: set another when the probe's owner leaves. */
568
640
  dispose(): void
569
641
  }
570
642
 
@@ -594,16 +666,17 @@ const PROBE_FACE_UP: Vec3[] = [
594
666
  const PROBE_SIZE = 128
595
667
  // A cube face spans a quarter turn.
596
668
  const PROBE_FOV = 90
597
- // The output stage of a cube that holds light rather than display pixels
598
- // (a probe's faces, a baked sky): no sRGB encode, no tone mapping, unit
599
- // exposure - so the environment lookups read radiance.
600
- const LINEAR_OUTPUT: ShaderParams = { uOutputEncode: 0, uToneMapping: TONE_MAPPING_CODE.none, uExposure: 1 }
601
-
602
669
  export type Scene = {
603
- /** The scene's output: an ordinary texture id (`<texture src>`). */
670
+ /** The scene's output: an ordinary texture id (`<texture src>`) - the
671
+ * resolve of the buffer, encoded display pixels. */
604
672
  texture: TextureId
605
- /** The scene target's depth as a sampler-only texture id when created
606
- * with `depth: "texture"`, else null. */
673
+ /** The scene's BUFFER: the target the meshes draw into, premultiplied
674
+ * linear light (half float where the device renders it, see
675
+ * bufferFormat), sampler-only - the input of a radiance-reading pass (a
676
+ * bloom chain) whose result a custom resolve adds back in. */
677
+ hdrTexture: TextureId
678
+ /** The buffer's depth as a sampler-only texture id when created with
679
+ * `depth: "texture"`, else null. */
607
680
  depthTexture: TextureId | null
608
681
  /** The tree root; add(scene.root, node) attaches top-level nodes. */
609
682
  root: SceneNode
@@ -614,6 +687,11 @@ export type Scene = {
614
687
  * back through setCamera). Reflects a pending setCamera immediately. */
615
688
  camera(): CameraState
616
689
  setSize(width: number, height: number): void
690
+ /** The target size in pixels, as setSize last left it (a fill-mode
691
+ * `<Scene>` sets it from its layout box, so before the first layout
692
+ * this is the creation size). The aspect a camera control's fit()
693
+ * frames against. */
694
+ size(): { width: number; height: number }
617
695
  /**
618
696
  * Scene-wide uniforms: merge app-owned names into the target's SHARED
619
697
  * params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
@@ -680,24 +758,55 @@ export type Scene = {
680
758
  */
681
759
  setEnvironment(env: EnvironmentOptions | null): void
682
760
  /**
683
- * The output stage's tone mapping, applied by every library material,
684
- * the skybox and a GLSL background that ends with outputColor: "none"
761
+ * The resolve's tone mapping, applied once per pixel to the buffer -
762
+ * every material, the background, the clearColor alike: "none"
685
763
  * (default) clamps the linear result, "aces" compresses highlights on
686
- * the filmic curve. One shared-params write (`uToneMapping`, the OUTPUT
687
- * set in `@solidrt/3d/glsl` declares), like Three's
688
- * renderer.toneMapping and Godot's Environment tonemap; a custom
689
- * fragment that writes fragColor directly is untouched. The clearColor
690
- * is not tone mapped: with a curve on, draw the backdrop as a
691
- * background.
764
+ * the filmic curve. One shared-params write (`uToneMapping`, the
765
+ * RESOLVE set in `@solidrt/3d/glsl` declares), like Three's
766
+ * renderer.toneMapping and Godot's Environment tonemap.
692
767
  */
693
768
  setToneMapping(mode: ToneMapping): void
694
769
  /**
695
- * The output stage's exposure (default 1): the linear result is scaled
696
- * by it before tone mapping, so a scene lit in physical-ish units is
697
- * brought into range here rather than by dimming every light. One
698
- * shared-params write (`uExposure`), like Three's toneMappingExposure.
770
+ * The resolve's exposure (default 1): the buffer is scaled by it before
771
+ * tone mapping, so a scene lit in physical-ish units is brought into
772
+ * range here rather than by dimming every light. One shared-params
773
+ * write (`uExposure`), like Three's toneMappingExposure.
699
774
  */
700
775
  setExposure(exposure: number): void
776
+ /**
777
+ * Replace the scene's RESOLVE: the one full-screen pass that turns the
778
+ * buffer (`hdrTexture`, premultiplied linear light) into `texture`
779
+ * (display pixels) - the place a post effect that needs radiance
780
+ * lives (Godot's glow sits in its tonemap pass; Three's bloom reads the
781
+ * HDR render before OutputPass). Fragment GLSL with the shader-target
782
+ * contract (vUV 0..1 top-left origin, iResolution, fragColor; no
783
+ * `#version` line means the standard preamble), `uniform sampler2D
784
+ * uScene` (the buffer) and the RESOLVE set declared: end with
785
+ * `fragColor = resolveColor(rgb, alpha)` for the scene's exposure, tone
786
+ * mapping and encode. The object form binds extra textures the source
787
+ * declares (a bloom chain's result). Any name written through
788
+ * scene.setParams is readable. The default is DEFAULT_RESOLVE (one
789
+ * sample through resolveColor). A textures-only change rebinds in
790
+ * place; a source change recompiles. A function form receives the
791
+ * buffer id, like the option.
792
+ */
793
+ setResolve(resolve: ResolveInput): void
794
+ /**
795
+ * The stock bloom, or null for none (default): radiance above
796
+ * `threshold` (default 1) blurred over `radius` rounds (default 2) at a
797
+ * quarter of the target's size and added back at `intensity` (default
798
+ * 0.5) - Godot's glow, Unity's Bloom, Three's UnrealBloomPass. A chain
799
+ * of small passes per resolving target, re-rendered when the buffer
800
+ * is; the default resolve composes it, and a custom one does by
801
+ * declaring `uBloom` / `uBloomIntensity` (start from BLOOM_RESOLVE in
802
+ * `@solidrt/3d/glsl`). Fans out to every view that has not set a
803
+ * bloom of its own, like setFog.
804
+ */
805
+ setBloom(bloom: BloomOptions | null): void
806
+ /** The LOD quality knob (SceneOptions.lodBias): a multiplier on every
807
+ * measured projected size, for the scene, its views and its shadow
808
+ * tiles. Below 1 switches to far levels sooner. */
809
+ setLodBias(bias: number): void
701
810
  /**
702
811
  * Project a world point to scene pixels: origin top-left, y down - the
703
812
  * output texture's own coordinate space, ready for overlay layout (HUD
@@ -865,9 +974,9 @@ export type Scene = {
865
974
  * Bake the background into an environment: a reflection probe at the
866
975
  * origin that sees no mesh (layer mask 0), so its six `size` faces
867
976
  * (default 128) hold the background alone - the GLSL sky or the skybox,
868
- * exactly as the scene draws it, written LINEAR (uOutputEncode 0, no tone mapping, unit exposure,
869
- * so a sky ending in outputColor bakes its light; one writing fragColor
870
- * raw bakes those bytes as light) - then GGX-prefiltered into the chain
977
+ * exactly as the scene draws it, LINEAR like every buffer (a sky
978
+ * fragment writes light, never display pixels) - then GGX-prefiltered
979
+ * into the chain
871
980
  * `standard` samples by roughness: Godot's sky-to-radiance bake, a
872
981
  * procedural sky lighting the scene, or a hi-res LDR skybox reduced to
873
982
  * an environment. A snapshot: bake again when the sky changed. Returns
@@ -889,23 +998,96 @@ export type Scene = {
889
998
  // missing channel would have no home) - an error, like the rest of the
890
999
  // strict entry path. Extra channels are fine.
891
1000
  function checkLayout(material: Material, geometry: Geometry, what: string): void {
892
- let missing = missingAttributes(material, geometry.layout)
1001
+ let missing = missingAttributes(material, geometry)
893
1002
  if (missing.length > 0) {
894
1003
  throw new Error(
895
- what + " reads attributes the geometry layout (" + layoutKey(geometry.layout) + ") lacks: " +
1004
+ what + " reads attributes the geometry layout (" + geometryKey(geometry) + ") lacks: " +
896
1005
  missing.map(a => a.name + " " + a.format).join(", ") +
897
1006
  " - add the channel with withAttribute()/withColors(), or use a material that does not read it",
898
1007
  )
899
1008
  }
900
1009
  }
901
1010
 
1011
+ // The index range a mesh's entries draw: its own (setDrawRange) or the
1012
+ // whole index list.
1013
+ function meshRange(mesh: Mesh): { firstIndex: number; indexCount: number } {
1014
+ return mesh._range === null ? { firstIndex: 0, indexCount: mesh.geometry.indices.length } : { firstIndex: mesh._range.first, indexCount: mesh._range.count }
1015
+ }
1016
+
902
1017
  // An entry's initial params. The uNormal seed keys off the material flag
903
1018
  // because entry params validate strictly - and a material declaring
904
1019
  // uNormal without using it therefore throws right here, at add().
1020
+ // The solid uLodFade: a fresh entry draws whole until the core writes a
1021
+ // band position (a GL uniform reads zero until written, which would
1022
+ // discard everything).
1023
+ const LOD_SOLID = [1, 1]
1024
+
905
1025
  function entrySeed(material: Material, params: ShaderParams | null): ShaderParams {
906
- return material.normalMatrix
907
- ? { uModel: IDENTITY, uNormal: IDENTITY, ...material.params, ...params }
908
- : { uModel: IDENTITY, ...material.params, ...params }
1026
+ let seed: ShaderParams = { uModel: IDENTITY }
1027
+ if (material.normalMatrix) seed.uNormal = IDENTITY
1028
+ if (material.lodFade) seed.uLodFade = LOD_SOLID
1029
+ return { ...seed, ...material.params, ...params }
1030
+ }
1031
+
1032
+ // A morphing entry's bindings: the geometry's packed targets (uMorphs and
1033
+ // its width) and the weights texture (uMorphWeights): the mesh's owner's
1034
+ // one row, or the population's row-per-slot texture on an instanced mesh
1035
+ // (the shader reads row uMorphRow + gl_InstanceID). Only for a material
1036
+ // whose program declares them (Material.morphed - the mesh's own, a
1037
+ // morphed shadow stand-in); any other validates its bindings strictly.
1038
+ // A plain owner's register is bound here if it was not yet (a mesh owner
1039
+ // nothing wrote before its first morphing entry).
1040
+ type MorphEntry = { textures: TextureBindings; params: ShaderParams }
1041
+ function morphEntry(material: Material, mesh: Mesh): MorphEntry | null {
1042
+ if (material.morphed !== true) return null
1043
+ let packed = mesh._buffers?.morphs
1044
+ if (packed === null || packed === undefined) return null
1045
+ let weights = morphWeightsTexture(mesh)
1046
+ if (weights === null) return null
1047
+ return {
1048
+ textures: { uMorphs: packed.texture, uMorphWeights: weights },
1049
+ params: { uMorphWidth: packed.width, uMorphRow: 0 },
1050
+ }
1051
+ }
1052
+
1053
+ // The weights texture a mesh's morphing entries bind, activating a plain
1054
+ // owner's register on the way; null when nothing owns weights.
1055
+ function morphWeightsTexture(mesh: Mesh): TextureId | null {
1056
+ let inst = mesh._instances
1057
+ if (inst !== null) return inst.morph?.texture ?? null
1058
+ return mesh._morphOwner === null ? null : activateMorph(mesh._morphOwner).texture
1059
+ }
1060
+
1061
+ // An entry's sampler bindings: the material's, the mesh's own (a skin's
1062
+ // uBones) when the material is the mesh's or a skinned stand-in (a
1063
+ // skinned shadow variant declares uBones and needs the mesh's palette;
1064
+ // other override programs may not declare the names, and per-entry
1065
+ // bindings validate strictly), and the morph bindings when it morphs.
1066
+ // A morphing material over geometry without targets has nothing to walk
1067
+ // (and its uMorphs binding nothing to bind): rejected at add(), like a
1068
+ // layout mismatch. A record mesh has no instance nodes to own weights,
1069
+ // so its records cannot morph; an instanced mesh morphs per instance.
1070
+ function checkMorph(material: Material, mesh: Mesh, site: string): void {
1071
+ if (material.morphed !== true) return
1072
+ if (mesh.geometry.morphs === undefined) throw new Error(site + " morphs (morph: true) but the geometry carries no morph targets")
1073
+ let inst = mesh._instances
1074
+ if (inst !== null && inst.nodes === null) throw new Error(site + " morphs, but a record mesh has no instance nodes to own weights (createInstancedMesh morphs per instance)")
1075
+ if (inst === null && mesh._morphOwner === null) throw new Error(site + " morphs but the mesh has no morph owner")
1076
+ }
1077
+
1078
+ function entryTextures(material: Material, mesh: Mesh, morph: MorphEntry | null): TextureBindings | undefined {
1079
+ let textures = material.textures
1080
+ if ((material === mesh.material || material.skinned === true) && mesh._textures !== null) textures = { ...textures, ...mesh._textures }
1081
+ if (morph !== null) textures = { ...textures, ...morph.textures }
1082
+ return textures
1083
+ }
1084
+
1085
+ // A buffer's clear color: the sRGB option decoded to premultiplied linear
1086
+ // light, what the buffer holds and the resolve tone maps.
1087
+ function bufferClear(color: [number, number, number, number] | undefined): [number, number, number, number] | undefined {
1088
+ if (color === undefined) return undefined
1089
+ let c = premultipliedColor(color)
1090
+ return [c[0]!, c[1]!, c[2]!, c[3]!]
909
1091
  }
910
1092
 
911
1093
  /**
@@ -922,15 +1104,19 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
922
1104
  // The scene target's layer mask; shadow views follow it (a mesh the
923
1105
  // scene cannot see must not darken it).
924
1106
  let sceneMask = checkMask(opts?.layers ?? 1, "createScene")
1107
+ let label = opts?.label ?? "scene"
1108
+ // `texture` is the scene's BUFFER throughout this file: the draw target
1109
+ // every entry, sink, sort and param write names. The handle's `texture`
1110
+ // is its resolve (below), the displayable id.
925
1111
  let texture = createDrawTarget(width, height, null, {
926
1112
  depth: depthMode,
927
- clearColor: opts?.clearColor,
928
- filter: opts?.filter,
929
- wrap: opts?.wrap,
1113
+ format: bufferFormat(),
1114
+ clearColor: bufferClear(opts?.clearColor),
930
1115
  samples: opts?.samples,
931
- label: opts?.label ?? "scene",
1116
+ label,
932
1117
  autoFree: false,
933
1118
  })
1119
+ let resolve = makeResolve(texture, width, height, opts?.resolve, { filter: opts?.filter, wrap: opts?.wrap }, label)
934
1120
  let disposed = false
935
1121
  let scheduled = false
936
1122
 
@@ -946,8 +1132,11 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
946
1132
  // One filter object per scene, refilled per query (the bindings read it
947
1133
  // synchronously), so a query allocates nothing but its hits.
948
1134
  let filter: QueryFilter = {}
949
- let queryFilter = (qopts: QueryOptions | undefined, site: string): QueryFilter => {
1135
+ // `target` names the draw target whose LOD levels the query sees: the
1136
+ // scene's for its own queries, a view's for view.pick.
1137
+ let queryFilter = (qopts: QueryOptions | undefined, site: string, target: TextureId = texture): QueryFilter => {
950
1138
  filter.root = root._node!
1139
+ filter.target = target
951
1140
  filter.layers = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
952
1141
  if (qopts?.meshes === undefined) {
953
1142
  filter.nodes = undefined
@@ -981,12 +1170,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
981
1170
  // _moved flag): what the light and transparent-order bookkeeping
982
1171
  // reacts to, since which meshes moved is the core's knowledge now.
983
1172
  let moved: SceneNode[] = []
984
- // Instanced meshes with style writes to publish at the next sync.
985
- let styleDirty = new Set<InstancedMesh>()
1173
+ // Populated meshes with record writes to publish at the next sync.
1174
+ let recordsDirty = new Set<Mesh>()
986
1175
  // Instanced meshes whose cull group - their live instance nodes - changed
987
1176
  // since the last sync: one setCullGroup per mesh however many entered
988
1177
  // or left.
989
1178
  let groupDirty = new Set<InstancedMesh>()
1179
+ // bindMatrixRecord's buffer list, refilled per instance.
1180
+ let instanceBuffers: BufferId[] = []
990
1181
  let instanceGroup = (mesh: InstancedMesh): NodeId[] => {
991
1182
  let members: NodeId[] = []
992
1183
  for (let n of mesh._instances.nodes.slots) if (n !== null && n._node !== null) members.push(n._node)
@@ -1005,6 +1196,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1005
1196
  // light params at the end of the sync - one write, however many meshes.
1006
1197
  let lights: Light[] = []
1007
1198
  let lightsDirty = false
1199
+ // The list changed shape (attach, detach) since sync last checked the
1200
+ // light cap.
1201
+ let lightSetDirty = false
1008
1202
  // uLightDir and uLightPos are CORE-DRIVEN: each light's slots are
1009
1203
  // shared-slot sinks following the node's world transform - the
1010
1204
  // direction slot (bindDirectionSlot) with -direction as the local
@@ -1039,6 +1233,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1039
1233
  ground = [g[0]! * k, g[1]! * k, g[2]! * k]
1040
1234
  continue
1041
1235
  }
1236
+ // A light past the cap is not lit (sync reports the set once): its
1237
+ // slots would overrun the MAX_LIGHTS arrays.
1238
+ if (count >= MAX_LIGHTS) continue
1042
1239
  if (light.type !== "point") {
1043
1240
  vecScratch[0] = -light.direction[0]
1044
1241
  vecScratch[1] = -light.direction[1]
@@ -1147,7 +1344,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1147
1344
  // light rewrite seeds it on new targets); null binds the placeholder,
1148
1345
  // the scene's own 1x1 black cube.
1149
1346
  let environment: TextureId | null = null
1150
- let envPlaceholder = createEnvironmentPlaceholder((opts?.label ?? "scene") + "-env-none")
1347
+ let envPlaceholder = createEnvironmentPlaceholder(label + "-env-none")
1151
1348
  let sortEntries = () => {
1152
1349
  orderDirty = false
1153
1350
  let order = orderEntries(meshes, camera.view, background?.entries.get(texture))
@@ -1228,10 +1425,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1228
1425
  // scene (view-space keys from the view's own camera); an overridden
1229
1426
  // view is not sorted at all.
1230
1427
  type ViewRecord = {
1428
+ /** The view's BUFFER (a 2D draw target, a tile, or a cube). */
1231
1429
  texture: TextureId
1430
+ /** The view's resolve, the displayable output; null for a shadow
1431
+ * view, a probe and a tile (the atlas is the app's to resolve). */
1432
+ resolve: ResolveRecord | null
1433
+ /** Whether the view set its own bloom (the `bloom` option or
1434
+ * view.setBloom): the scene's setBloom then skips it. */
1435
+ ownBloom: boolean
1232
1436
  width: number
1233
1437
  height: number
1438
+ /** Debug label: the option, else the scene's plus -view/-probe. */
1439
+ label: string
1234
1440
  override: Material | null
1441
+ /** The instanced meshes an override view leaves out: the override
1442
+ * declares no instanceBuffers, so it cannot place their records
1443
+ * (ViewOptions.overrideMaterial). Kept so sync() reports them once
1444
+ * per view, over the settled set. */
1445
+ skipped: Set<Mesh>
1446
+ skippedReported: boolean
1235
1447
  /** Non-null marks a SHADOW view and names its caster set (m =>
1236
1448
  * m.castShadow). Re-evaluated per mesh by _setCast; also picks the
1237
1449
  * caster's own shadow material variant over the depth override. */
@@ -1247,6 +1459,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1247
1459
  entries: Map<Mesh, DrawId>
1248
1460
  orderDirty: boolean
1249
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
1250
1465
  /** A reflection probe's target is a cube draw target of this face
1251
1466
  * edge, rendered face by face through probe.update(); null for a
1252
1467
  * 2D view. */
@@ -1264,6 +1479,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1264
1479
  let views: ViewRecord[] = []
1265
1480
  // Every name scene.setParams has merged so far, replayed on a new view.
1266
1481
  let sceneParams: ShaderParams = {}
1482
+ // The scene's bloom (setBloom), applied to every resolving view that
1483
+ // has not set its own.
1484
+ let sceneBloom: BloomOptions | null = null
1267
1485
  // Every target a receiving material can draw into: the scene's and each
1268
1486
  // view's but the shadow views (binding a target's own depth into it
1269
1487
  // would be same-pass feedback).
@@ -1289,17 +1507,18 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1289
1507
  // so added live it would draw at the seeded identity until then. The
1290
1508
  // mismatch branch in sync() turns it on in the same pass that writes
1291
1509
  // uModel.
1292
- mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
1293
- buffer: bufs.buffer,
1510
+ let morph = morphEntry(mesh.material, mesh)
1511
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry, geometryTopology(mesh.geometry)), entrySeed(mesh.material, morph === null ? mesh._params : { ...morph.params, ...mesh._params }), {
1512
+ buffers: [...bufs.buffers, ...(inst !== null ? instanceBinding(mesh.material, inst) : [])],
1294
1513
  indexBuffer: bufs.index,
1295
1514
  indexFormat: bufs.indexFormat,
1296
- textures: mesh._textures !== null ? { ...mesh.material.textures, ...mesh._textures } : mesh.material.textures,
1297
- ...(inst !== null ? instanceBinding(mesh.material, inst) : {}),
1515
+ ...meshRange(mesh),
1516
+ textures: entryTextures(mesh.material, mesh, morph),
1298
1517
  instanceCount: 0,
1299
1518
  })
1300
1519
  // The core turns the entry on (with the world matrix) at the next
1301
1520
  // flush, and off again whenever the node or an ancestor hides.
1302
- spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1)
1521
+ spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1, mesh.material.lodFade === true)
1303
1522
  orderDirty = true
1304
1523
  }
1305
1524
  let detachScene = (mesh: Mesh) => {
@@ -1319,6 +1538,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1319
1538
  if (v.entries.has(mesh)) return
1320
1539
  let inst = mesh._instances
1321
1540
  if (v.shadowFilter !== null && !v.shadowFilter(mesh)) return
1541
+ // Lines and points cast nothing: a one-pixel wireframe in a shadow
1542
+ // map is speckle on the floor, never a shadow anyone wanted.
1543
+ if (v.shadowFilter !== null && geometryTopology(mesh.geometry) !== "triangles") return
1322
1544
  if ((mesh.layers & v.mask) === 0) return
1323
1545
  let material = viewMaterial(v, mesh)
1324
1546
  // An override pipeline cannot know a populated mesh's record layout,
@@ -1327,27 +1549,32 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1327
1549
  // instanced depth pass), whose attributes must fit the records like
1328
1550
  // the main material's did at add().
1329
1551
  if (v.override !== null && inst !== null) {
1330
- if (material.instanceAttributes === undefined) return
1552
+ if (material.instanceBuffers === undefined) {
1553
+ // A caster without an instanced shadow variant casts none by
1554
+ // that rule; an app's override view dropping a mesh is invisible
1555
+ // in its output, so it is recorded for sync() to report.
1556
+ if (v.shadowFilter === null) v.skipped.add(mesh)
1557
+ return
1558
+ }
1331
1559
  checkInstancePairing(material, inst, "Shadow material")
1332
1560
  }
1333
1561
  let bufs = mesh._buffers!
1334
- let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1335
- buffer: bufs.buffer,
1562
+ let morph = morphEntry(material, mesh)
1563
+ let params = v.override !== null ? (morph === null ? null : morph.params) : morph === null ? mesh._params : { ...morph.params, ...mesh._params }
1564
+ let entry = addDraw(v.texture, material.pipeline(mesh.geometry, geometryTopology(mesh.geometry)), entrySeed(material, params), {
1565
+ buffers: [...bufs.buffers, ...(inst !== null ? instanceBinding(material, inst) : [])],
1336
1566
  indexBuffer: bufs.index,
1337
1567
  indexFormat: bufs.indexFormat,
1338
- // The mesh's own bindings ride with its own material and with any
1339
- // skinned stand-in (a skinned shadow variant declares uBones and
1340
- // needs the mesh's palette); other override programs may not
1341
- // declare the names, and per-entry bindings validate strictly.
1342
- textures: (material === mesh.material || material.skinned === true) && mesh._textures !== null ? { ...material.textures, ...mesh._textures } : material.textures,
1343
- ...(inst !== null ? instanceBinding(material, inst) : {}),
1568
+ ...meshRange(mesh),
1569
+ textures: entryTextures(material, mesh, morph),
1344
1570
  instanceCount: 0,
1345
1571
  })
1346
- spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1)
1572
+ spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1, material.lodFade === true)
1347
1573
  v.entries.set(mesh, entry)
1348
1574
  v.orderDirty = true
1349
1575
  }
1350
1576
  let detachView = (v: ViewRecord, mesh: Mesh) => {
1577
+ v.skipped.delete(mesh)
1351
1578
  let entry = v.entries.get(mesh)
1352
1579
  if (entry === undefined) return
1353
1580
  v.entries.delete(mesh)
@@ -1371,8 +1598,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1371
1598
  v.entries.clear()
1372
1599
  for (let light of lights) if (light.type === "directional" && light._node !== null) spatial.unbindSlot(light._node, v.texture)
1373
1600
  spatial.setFrustum(v.texture, null)
1601
+ spatial.setLodView(v.texture, null)
1374
1602
  // Drain the zeroed direction slots while the target still exists.
1375
1603
  spatial.flush()
1604
+ if (v.resolve !== null) disposeResolve(v.resolve)
1376
1605
  destroyTexture(v.texture)
1377
1606
  let i = views.indexOf(v)
1378
1607
  if (i >= 0) views.splice(i, 1)
@@ -1415,35 +1644,47 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1415
1644
  for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
1416
1645
  }
1417
1646
  let tiled = vopts.into !== undefined
1647
+ if (tiled && vopts.resolve !== undefined) throw new Error("createView: a tiled view (into) has no resolve of its own - resolve the atlas")
1648
+ if (tiled && vopts.bloom !== undefined) throw new Error("createView: a tiled view (into) has no resolve to bloom on - resolve the atlas")
1649
+ let viewLabel = vopts.label ?? label + (cube !== null ? "-probe" : "-view")
1650
+ let buffer =
1651
+ cube !== null
1652
+ ? createCubeDrawTarget(cube, null, {
1653
+ depth: true,
1654
+ // The prefilter reads the faces at the lod of each sample's
1655
+ // solid angle: a generated chain, refreshed per face render.
1656
+ mipmap,
1657
+ format: bufferFormat(),
1658
+ clearColor: bufferClear(vopts.clearColor),
1659
+ label: viewLabel,
1660
+ autoFree: false,
1661
+ })
1662
+ : createDrawTarget(vopts.width, vopts.height, null, {
1663
+ depth: tiled ? undefined : (vopts.depth ?? true),
1664
+ format: tiled ? undefined : bufferFormat(),
1665
+ clearColor: bufferClear(vopts.clearColor),
1666
+ samples: vopts.samples,
1667
+ label: viewLabel,
1668
+ autoFree: false,
1669
+ into: vopts.into,
1670
+ x: vopts.x,
1671
+ y: vopts.y,
1672
+ })
1418
1673
  let v: ViewRecord = {
1419
- texture:
1420
- cube !== null
1421
- ? createCubeDrawTarget(cube, null, {
1422
- depth: true,
1423
- // The prefilter reads the faces at the lod of each sample's
1424
- // solid angle: a generated chain, refreshed per face render.
1425
- mipmap,
1426
- // Linear radiance, HDR where the device renders half float.
1427
- format: probeFormat(),
1428
- clearColor: vopts.clearColor,
1429
- label: vopts.label ?? (opts?.label ?? "scene") + "-probe",
1430
- autoFree: false,
1431
- })
1432
- : createDrawTarget(vopts.width, vopts.height, null, {
1433
- depth: tiled ? undefined : (vopts.depth ?? true),
1434
- clearColor: vopts.clearColor,
1435
- filter: vopts.filter,
1436
- wrap: vopts.wrap,
1437
- samples: vopts.samples,
1438
- label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1439
- autoFree: false,
1440
- into: vopts.into,
1441
- x: vopts.x,
1442
- y: vopts.y,
1443
- }),
1674
+ texture: buffer,
1675
+ // A shadow view is depth only, a probe's faces are read as a cube,
1676
+ // a tile is the atlas's: none of them displays, so none resolves.
1677
+ resolve:
1678
+ cube === null && shadowFilter === null && !tiled
1679
+ ? makeResolve(buffer, vopts.width, vopts.height, vopts.resolve, { filter: vopts.filter, wrap: vopts.wrap }, viewLabel)
1680
+ : null,
1681
+ ownBloom: vopts.bloom !== undefined,
1444
1682
  width: vopts.width,
1445
1683
  height: vopts.height,
1684
+ label: viewLabel,
1446
1685
  override,
1686
+ skipped: new Set(),
1687
+ skippedReported: false,
1447
1688
  shadowFilter,
1448
1689
  mask: shadowFilter !== null ? sceneMask : checkMask(vopts.layers ?? 1, "createView"),
1449
1690
  ownNames: new Set(),
@@ -1451,6 +1692,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1451
1692
  entries: new Map(),
1452
1693
  orderDirty: true,
1453
1694
  lastOrder: [],
1695
+ lodViewSet: false,
1454
1696
  cube,
1455
1697
  probeCube: null,
1456
1698
  sky,
@@ -1469,6 +1711,11 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1469
1711
  if (ownFog !== null) for (let k of Object.keys(ownFog)) v.ownNames.add(k)
1470
1712
  setTargetParams(v.texture, ownFog === null ? sceneParams : withoutNames(sceneParams, v.ownNames))
1471
1713
  if (ownFog !== null) setTargetParams(v.texture, ownFog)
1714
+ if (v.resolve !== null) {
1715
+ setTargetParams(v.resolve.target, sceneParams)
1716
+ let bloom = vopts.bloom !== undefined ? vopts.bloom : sceneBloom
1717
+ if (bloom !== null) setResolveBloom(v.resolve, v.texture, vopts.width, vopts.height, bloom)
1718
+ }
1472
1719
  for (let mesh of meshes) attachView(v, mesh)
1473
1720
  hooks._schedule()
1474
1721
  return v
@@ -1481,7 +1728,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1481
1728
  lights,
1482
1729
  camera,
1483
1730
  targetSize: () => ({ width, height }),
1484
- label: opts?.label ?? "scene",
1731
+ label,
1485
1732
  makeView,
1486
1733
  disposeView,
1487
1734
  markLightsDirty: () => {
@@ -1500,36 +1747,101 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1500
1747
  frustumScratch.set(cam.viewProj)
1501
1748
  spatial.setFrustum(target, frustumScratch)
1502
1749
  }
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
+ let lodBias = opts?.lodBias ?? 1
1758
+ 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
+ }
1503
1770
  let sync = () => {
1504
1771
  scheduled = false
1505
1772
  if (disposed) return
1773
+ // An override view's skipped instanced meshes (attachView), reported
1774
+ // once per view over the settled set, as the budgets below are: the
1775
+ // meshes of one flush are all in by this microtask, so the count is
1776
+ // the whole set, not the first arrival.
1777
+ for (let v of views) {
1778
+ if (v.skippedReported || v.skipped.size === 0) continue
1779
+ v.skippedReported = true
1780
+ let n = v.skipped.size
1781
+ console.warn(
1782
+ `View '${v.label}' override material skips ${n} instanced ${n === 1 ? "mesh" : "meshes"}: it declares no instanceBuffers, so it cannot place their records, and they draw in no pass of this view; give it an instanced variant to include them`,
1783
+ )
1784
+ }
1785
+ // A per-scene budget - the light cap, the shadow slots - is tested
1786
+ // here over the settled set, never at attach: a declarative swap
1787
+ // (<Show>, <Switch>) attaches the incoming branch before the outgoing
1788
+ // one detaches, inside one Solid flush, and both are gone by this
1789
+ // microtask. A set past a budget is reported once, at the end, after
1790
+ // every write below, so the scene is never left half-written.
1791
+ let budgetError: Error | null = null
1792
+ if (lightSetDirty) {
1793
+ lightSetDirty = false
1794
+ let n = lights.filter(l => l.type !== "hemisphere").length
1795
+ if (n > MAX_LIGHTS) {
1796
+ budgetError = new Error(
1797
+ "A scene takes at most " + MAX_LIGHTS + " lights (directional, spot and point together): " + n + " attached, the lights past the cap are not lit",
1798
+ )
1799
+ }
1800
+ }
1801
+ let shadowError = shadowSys.settle()
1802
+ if (budgetError === null) budgetError = shadowError
1506
1803
  ensureCamera(camera, width, height)
1507
1804
  let cameraMoved = camera.pending
1805
+ let biasChanged = lodBiasDirty
1806
+ lodBiasDirty = false
1508
1807
  if (camera.pending) {
1509
1808
  camera.pending = false
1510
1809
  setTargetParams(texture, cameraParams(camera))
1511
1810
  setFrustum(texture, camera)
1512
1811
  if (transparentCount > 1) orderDirty = true
1513
1812
  }
1813
+ if (cameraMoved || biasChanged) setLodView(texture, camera)
1514
1814
  shadowSys.placeCameras(cameraMoved)
1515
1815
  for (let v of views) {
1516
1816
  ensureCamera(v.camera, v.width, v.height)
1517
- if (v.camera.pending) {
1817
+ let viewMoved = v.camera.pending
1818
+ if (viewMoved) {
1518
1819
  v.camera.pending = false
1519
1820
  setTargetParams(v.texture, cameraParams(v.camera))
1520
1821
  setFrustum(v.texture, v.camera)
1521
1822
  if (transparentCount > 1) v.orderDirty = true
1522
1823
  if (v.shadowFilter !== null) shadowSys.markMatricesDirty()
1523
1824
  }
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
+ }
1524
1836
  }
1525
1837
  // Light bookkeeping first, so a fresh direction-slot bind is seeded
1526
1838
  // by the flush below in the same sync.
1527
1839
  if (lightsDirty) writeLights()
1528
- // The instanced meshes' style writes since the last sync: one
1529
- // coalesced buffer write per mesh.
1530
- if (styleDirty.size > 0) {
1531
- for (let m of styleDirty) if (m._instances !== null) publishInstanceStyle(m)
1532
- styleDirty.clear()
1840
+ // The populated meshes' record writes since the last sync: one
1841
+ // coalesced buffer write per dirty stream.
1842
+ if (recordsDirty.size > 0) {
1843
+ for (let m of recordsDirty) publishRecords(m)
1844
+ recordsDirty.clear()
1533
1845
  }
1534
1846
  // An instanced mesh without explicit bounds is culled by the union of
1535
1847
  // its instances' boxes: the core's cull group (a skinned part's joint
@@ -1560,6 +1872,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1560
1872
  if (orderDirty || views.some(v => v.orderDirty)) refreshCenters()
1561
1873
  if (orderDirty) sortEntries()
1562
1874
  for (let v of views) if (v.orderDirty) sortView(v)
1875
+ if (budgetError !== null) {
1876
+ if (opts?.onError !== undefined) opts.onError(budgetError)
1877
+ else throw budgetError
1878
+ }
1563
1879
  }
1564
1880
 
1565
1881
  let hooks: SceneHooks = {
@@ -1570,23 +1886,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1570
1886
  },
1571
1887
  _attachLight(light) {
1572
1888
  if (disposed) return
1573
- if (light.type !== "hemisphere" && lights.filter(l => l.type !== "hemisphere").length >= MAX_LIGHTS) {
1574
- throw new Error("A scene takes at most " + MAX_LIGHTS + " lights (directional, spot and point together)")
1575
- }
1576
1889
  lights.push(light)
1577
1890
  lightsDirty = true
1578
- if (light.type !== "hemisphere" && light.castShadow) shadowSys.createShadow(light)
1891
+ if (light.type === "hemisphere") return
1892
+ lightSetDirty = true
1893
+ if (light.castShadow) shadowSys.invalidate(light)
1579
1894
  },
1580
1895
  _detachLight(light) {
1581
1896
  let i = lights.indexOf(light)
1582
1897
  if (i >= 0) lights.splice(i, 1)
1583
1898
  lightsDirty = true
1584
- if (light.type !== "hemisphere") shadowSys.destroyShadow(light)
1899
+ if (light.type !== "hemisphere") {
1900
+ lightSetDirty = true
1901
+ shadowSys.invalidate(light)
1902
+ }
1585
1903
  hooks._schedule()
1586
1904
  },
1587
1905
  _shadowChanged(light) {
1588
1906
  if (disposed) return
1589
- shadowSys.shadowChanged(light)
1907
+ shadowSys.invalidate(light)
1590
1908
  },
1591
1909
  _setCast(mesh) {
1592
1910
  if (mesh._buffers === null || disposed) return
@@ -1619,19 +1937,23 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1619
1937
  if (disposed) return
1620
1938
  validateGeometry(mesh.geometry)
1621
1939
  checkLayout(mesh.material, mesh.geometry, "Mesh material")
1940
+ checkMorph(mesh.material, mesh, "Mesh material")
1622
1941
  // Every check before any mutation, so a rejected mesh is attached
1623
1942
  // nowhere - the views' override materials included.
1624
1943
  for (let v of views) {
1625
- if (v.override !== null && mesh._instances === null) checkLayout(v.override, mesh.geometry, "View override material")
1944
+ if (v.override !== null && mesh._instances === null) {
1945
+ checkLayout(v.override, mesh.geometry, "View override material")
1946
+ checkMorph(v.override, mesh, "View override material")
1947
+ }
1626
1948
  }
1627
1949
  // Instancing pairs the same way layout does: the pipeline's instance
1628
1950
  // attributes describe the mesh's record buffers, so one without the
1629
1951
  // other (or a record stride from a different attribute list) would
1630
1952
  // bind garbage - errors here, at add().
1631
1953
  let inst = mesh._instances
1632
- if (mesh.material.instanceAttributes !== undefined && inst === null) {
1954
+ if (mesh.material.instanceBuffers !== undefined && inst === null) {
1633
1955
  throw new Error(
1634
- "Material declares instanceAttributes - create its meshes with createInstancedMesh or createRecordMesh, not createMesh",
1956
+ "Material declares instanceBuffers - create its meshes with createInstancedMesh or createRecordMesh, not createMesh",
1635
1957
  )
1636
1958
  }
1637
1959
  if (inst !== null) checkInstancePairing(mesh.material, inst, "Mesh material")
@@ -1639,30 +1961,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1639
1961
  mesh._buffers = bufs
1640
1962
  attachScene(mesh)
1641
1963
  for (let v of views) attachView(v, mesh)
1642
- // The style mirror may have been written while the mesh was out of
1643
- // a scene (or into a buffer this scene never saw): republish it.
1644
- if (inst !== null && inst.style !== null) {
1645
- inst.style.dirty = inst.count > 0 ? [0, inst.count * inst.style.stride] : null
1646
- if (inst.style.dirty !== null) styleDirty.add(mesh as InstancedMesh)
1647
- }
1648
- // Picking: the local box puts the node in the core index; an
1649
- // ordinary mesh also gets its geometry's triangle shape, a
1650
- // populated one is box-only (its instances are the leaves that
1651
- // pick, each with the shape; a record mesh's records are opaque, so
1652
- // without explicit bounds it is not picked at all), as is a sprite
1653
- // (its triangles lie wherever the camera is, not where the geometry
1654
- // says).
1655
- spatial.setBounds(mesh._node!, localBounds(mesh))
1656
- spatial.setShape(mesh._node!, inst === null && !mesh._sprite ? bufs.shape : null)
1657
- // A rebuilt entry (setGeometry) re-boxes the live instances to the
1964
+ // Record writes made while the mesh was out of a scene kept their
1965
+ // dirty ranges: publish them now.
1966
+ if (inst !== null && inst.streams.some(s => s.dirty !== null)) recordsDirty.add(mesh)
1967
+ // Picking: an ordinary mesh's node carries its geometry's shape,
1968
+ // 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).
1975
+ if (inst === null && !mesh._sprite) spatial.setShape(mesh._node!, bufs.shape)
1976
+ else spatial.setBounds(mesh._node!, localBounds(mesh))
1977
+ // A rebuilt entry (setGeometry) re-shapes the live instances to the
1658
1978
  // new geometry; their record bindings are untouched. On a first
1659
1979
  // entry none has a core node yet - they enter after the mesh.
1660
1980
  if (inst !== null && inst.nodes !== null) {
1661
1981
  for (let n of inst.nodes.slots) {
1662
- if (n !== null && n._node !== null) {
1663
- spatial.setBounds(n._node, geometryBounds(mesh.geometry))
1664
- spatial.setShape(n._node, bufs.shape)
1665
- }
1982
+ if (n !== null && n._node !== null) spatial.setShape(n._node, bufs.shape)
1666
1983
  }
1667
1984
  }
1668
1985
  // Culling: the gate and margin only when off the defaults, and a
@@ -1687,7 +2004,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1687
2004
  },
1688
2005
  _detach(mesh) {
1689
2006
  if (mesh._buffers !== null) {
1690
- styleDirty.delete(mesh as InstancedMesh)
2007
+ recordsDirty.delete(mesh)
1691
2008
  groupDirty.delete(mesh as InstancedMesh)
1692
2009
  for (let v of views) detachView(v, mesh)
1693
2010
  detachScene(mesh)
@@ -1712,21 +2029,33 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1712
2029
  // geometry buffers and core node are live - a mesh masked out of
1713
2030
  // the scene included, whose instances still pick.
1714
2031
  if (mesh === null || mesh._buffers === null || mesh._node === null || instance._node === null) return
1715
- spatial.setBounds(instance._node, geometryBounds(mesh.geometry))
1716
2032
  spatial.setShape(instance._node, mesh._buffers.shape)
1717
2033
  spatial.setLayers(instance._node, mesh.layers)
1718
2034
  // The record is the instance's placement inside the mesh (the mesh
1719
2035
  // node is the anchor), staged by the core at the next flush.
1720
- spatial.bindMatrixRecord(instance._node, mesh._instances.buffer, instance._slot, mesh._node)
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).
2038
+ let levels = mesh._instances.levels
2039
+ instanceBuffers.length = 0
2040
+ if (levels === null) instanceBuffers.push(mesh._instances.matrix)
2041
+ else for (let l of levels) instanceBuffers.push(l._instances.matrix)
2042
+ spatial.bindMatrixRecord(instance._node, instanceBuffers, instance._slot, mesh._node)
2043
+ // A morphing population: the instance's register takes its slot's
2044
+ // row now (a recycled slot's row is overwritten, not inherited).
2045
+ if (mesh._instances.morph !== null) activateMorph(instance)
1721
2046
  byNode.set(instance._node, instance)
1722
2047
  if (mesh._instances.bounds === null) groupDirty.add(mesh)
1723
2048
  this._schedule()
1724
2049
  },
2050
+ _exiting(mesh) {
2051
+ if (mesh._node !== null) byNode.delete(mesh._node)
2052
+ },
1725
2053
  _detachInstance(instance) {
1726
- // The core node is destroyed right after: its box, shape and record
1727
- // binding go with it, and the slot hides at the next flush. The
1728
- // mesh's group loses the member at the sync (a mesh leaving with
1729
- // its instances is skipped there: its node is gone by then).
2054
+ // The core node is destroyed right after (or animates out, picked
2055
+ // by nothing): its box, shape and record binding go with it, and
2056
+ // the slot hides at the next flush. The mesh's group loses the
2057
+ // member at the sync (a mesh leaving with its instances is skipped
2058
+ // there: its node is gone by then).
1730
2059
  if (instance._node !== null) byNode.delete(instance._node)
1731
2060
  let mesh = instance.mesh
1732
2061
  if (mesh !== null && mesh._instances !== null && mesh._instances.bounds === null) groupDirty.add(mesh)
@@ -1753,19 +2082,46 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1753
2082
  // The entries keep their range (at most the old capacity, so the
1754
2083
  // larger buffers always pass the swap's bounds check); the caller
1755
2084
  // destroys the old buffers after this, which the entries held alive
1756
- // until now. Each entry swaps the slots its own material binds.
2085
+ // until now. Each entry swaps the buffers its own material binds
2086
+ // (the full list: the geometry's buffer stays, the instance
2087
+ // buffers follow the population).
1757
2088
  let inst = mesh._instances
1758
- if (mesh._buffers !== null && !disposed && inst !== null) {
1759
- if (mesh._entry !== null) setDrawBuffers(texture, mesh._entry, instanceBinding(mesh.material, inst))
2089
+ let bufs = mesh._buffers
2090
+ if (bufs !== null && !disposed && inst !== null) {
2091
+ if (mesh._entry !== null) setDrawBuffers(texture, mesh._entry, { buffers: [...bufs.buffers, ...instanceBinding(mesh.material, inst)] })
1760
2092
  for (let v of views) {
1761
2093
  let entry = v.entries.get(mesh)
1762
- if (entry !== undefined) setDrawBuffers(v.texture, entry, instanceBinding(viewMaterial(v, mesh), inst))
2094
+ if (entry !== undefined) setDrawBuffers(v.texture, entry, { buffers: [...bufs.buffers, ...instanceBinding(viewMaterial(v, mesh), inst)] })
1763
2095
  }
1764
2096
  }
1765
2097
  },
1766
- _setStyle(mesh) {
2098
+ _setMorphTexture(mesh) {
2099
+ // The replaced population texture goes to every morphing entry of
2100
+ // the mesh (its own, a morphed shadow stand-in's); the caller
2101
+ // destroys the old texture after this.
2102
+ let weights = mesh._instances?.morph?.texture
2103
+ if (weights === undefined || mesh._buffers === null || disposed) return
2104
+ if (mesh._entry !== null && mesh.material.morphed === true) setDrawTextures(texture, mesh._entry, { uMorphWeights: weights })
2105
+ for (let v of views) {
2106
+ let entry = v.entries.get(mesh)
2107
+ if (entry !== undefined && viewMaterial(v, mesh).morphed === true) setDrawTextures(v.texture, entry, { uMorphWeights: weights })
2108
+ }
2109
+ },
2110
+ _setRange(mesh) {
2111
+ // The index range travels to every entry of the mesh; the instance
2112
+ // count (the core's visibility switch) is not in the update, so it
2113
+ // keeps whatever the core last wrote.
2114
+ if (mesh._buffers === null || disposed) return
2115
+ let range = meshRange(mesh)
2116
+ if (mesh._entry !== null) setDrawRange(texture, mesh._entry, range)
2117
+ for (let v of views) {
2118
+ let entry = v.entries.get(mesh)
2119
+ if (entry !== undefined) setDrawRange(v.texture, entry, range)
2120
+ }
2121
+ },
2122
+ _setRecords(mesh) {
1767
2123
  if (disposed || mesh._buffers === null) return
1768
- styleDirty.add(mesh)
2124
+ recordsDirty.add(mesh)
1769
2125
  this._schedule()
1770
2126
  },
1771
2127
  _reorder() {
@@ -1780,11 +2136,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1780
2136
  }
1781
2137
  this._schedule()
1782
2138
  },
2139
+ _bindLod(node) {
2140
+ if (disposed || node._node === null) return
2141
+ let config = node._lod
2142
+ let levels: { node?: NodeId; size: number }[] = []
2143
+ if (config !== null) {
2144
+ for (let l of config.levels) {
2145
+ if (l.node === null) levels.push({ size: l.size })
2146
+ else if (l.node._node !== null) levels.push({ node: l.node._node, size: l.size })
2147
+ }
2148
+ }
2149
+ spatial.setLod(node._node, levels, config?.fade ?? 0, texture)
2150
+ this._schedule()
2151
+ },
1783
2152
  }
1784
2153
 
1785
2154
  let root = makeNode("group")
1786
2155
  root._scene = hooks
1787
2156
  root._node = spatial.createNode(fillTransform(root), true)
2157
+ if (opts?.stagger !== undefined) setTransition(root, { stagger: opts.stagger })
1788
2158
  // The first light rewrite seeds the (empty) light set and the shadow
1789
2159
  // slots - placeholders, no casts - so receivers draw plain from the
1790
2160
  // first frame.
@@ -1795,8 +2165,37 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1795
2165
  // pointer walk scene-pointer.ts runs behind scene.handlers.
1796
2166
  let listeners = new Set<ScenePointerListener>()
1797
2167
 
2168
+ // The raycast every pick shares: `target` names the draw target whose
2169
+ // LOD levels the query sees (the scene's own, or a view's).
2170
+ let raycastAs = (target: TextureId, origin: Vec3, direction: Vec3, rayOpts: QueryOptions | undefined): Hit[] => {
2171
+ // Flush pending writes: picking sees the tree as the app just wrote
2172
+ // it, the same immediacy contract as lookAt()/project(). (The queued
2173
+ // microtask still runs and finds nothing dirty - harmless.)
2174
+ if (scheduled) sync()
2175
+ if (disposed) return []
2176
+ let f = queryFilter(rayOpts, "raycast", target)
2177
+ let hits: Hit[] = []
2178
+ rayOriginScratch[0] = origin[0]
2179
+ rayOriginScratch[1] = origin[1]
2180
+ rayOriginScratch[2] = origin[2]
2181
+ rayDirScratch[0] = direction[0]
2182
+ rayDirScratch[1] = direction[1]
2183
+ rayDirScratch[2] = direction[2]
2184
+ for (let h of spatial.raycast(rayOriginScratch, rayDirScratch, f)) {
2185
+ let leaf = leafOf(h.node)
2186
+ if (leaf === null) continue
2187
+ let hit: Hit = { mesh: leaf.mesh, distance: h.distance, point: h.point, normal: h.normal }
2188
+ if (leaf.instance !== null) hit.instance = leaf.instance
2189
+ if (h.face !== undefined) hit.face = h.face
2190
+ if (h.uv !== undefined) hit.uv = h.uv
2191
+ hits.push(hit)
2192
+ }
2193
+ return hits
2194
+ }
2195
+
1798
2196
  let scene: Scene = {
1799
- texture,
2197
+ texture: resolve.target,
2198
+ hdrTexture: texture,
1800
2199
  depthTexture: depthMode === "texture" ? depthTexture(texture) : null,
1801
2200
  root,
1802
2201
  setCamera(update) {
@@ -1809,24 +2208,35 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1809
2208
  width = w
1810
2209
  height = h
1811
2210
  setTargetSize(texture, w, h)
2211
+ resizeResolve(resolve, w, h)
1812
2212
  camera.dirty = true
1813
2213
  hooks._schedule()
1814
2214
  },
2215
+ size: () => ({ width, height }),
1815
2216
  setParams(params) {
1816
2217
  if (disposed) return
1817
2218
  Object.assign(sceneParams, params)
1818
2219
  setTargetParams(texture, params)
2220
+ setTargetParams(resolve.target, params)
1819
2221
  for (let v of views) {
1820
2222
  // A view's own names (view.setParams, its fog) win over the
1821
2223
  // scene-wide fan-out.
1822
2224
  let fanned = v.ownNames.size === 0 ? params : withoutNames(params, v.ownNames)
1823
2225
  if (fanned !== params && Object.keys(fanned).length === 0) continue
1824
2226
  setTargetParams(v.texture, fanned)
2227
+ if (v.resolve !== null) setTargetParams(v.resolve.target, fanned)
1825
2228
  }
1826
2229
  },
1827
2230
  setFog(fog) {
1828
2231
  scene.setParams(fogParams(fog))
1829
2232
  },
2233
+ setLodBias(bias) {
2234
+ if (!(bias > 0 && Number.isFinite(bias))) throw new Error("scene.setLodBias: bias must be a positive number, got " + bias)
2235
+ if (disposed || bias === lodBias) return
2236
+ lodBias = bias
2237
+ lodBiasDirty = true
2238
+ hooks._schedule()
2239
+ },
1830
2240
  setLayers(mask) {
1831
2241
  checkMask(mask, "scene.setLayers")
1832
2242
  if (disposed || sceneMask === mask) return
@@ -1869,7 +2279,6 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1869
2279
  background = null
1870
2280
  }
1871
2281
  if (source === null) return
1872
- let label = opts?.label ?? "scene"
1873
2282
  let built = sky === null ? backgroundPipeline(source as string, label + "-background") : backgroundPipeline(SKYBOX_FRAGMENT, label + "-skybox")
1874
2283
  background = { pipeline: built.pipeline, program: built.program, sky, entries: new Map() }
1875
2284
  // First in list order on every target that draws it: inserted before
@@ -1889,13 +2298,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1889
2298
  },
1890
2299
  setToneMapping(mode) {
1891
2300
  let code = TONE_MAPPING_CODE[mode]
1892
- if (code === undefined) throw new Error('scene.setToneMapping: expected "none" or "aces", got ' + mode)
2301
+ if (code === undefined) throw new Error('scene.setToneMapping: expected "none", "aces", "agx" or "neutral", got ' + mode)
1893
2302
  scene.setParams({ uToneMapping: code })
1894
2303
  },
1895
2304
  setExposure(exposure) {
1896
2305
  if (!Number.isFinite(exposure) || exposure < 0) throw new Error("scene.setExposure: expected a finite number >= 0, got " + exposure)
1897
2306
  scene.setParams({ uExposure: exposure })
1898
2307
  },
2308
+ setResolve(r) {
2309
+ if (disposed) return
2310
+ replaceResolve(resolve, texture, r)
2311
+ hooks._schedule()
2312
+ },
2313
+ setBloom(bloom) {
2314
+ if (disposed) return
2315
+ sceneBloom = bloom
2316
+ setResolveBloom(resolve, texture, width, height, bloom)
2317
+ for (let v of views) if (v.resolve !== null && !v.ownBloom) setResolveBloom(v.resolve, v.texture, v.width, v.height, bloom)
2318
+ hooks._schedule()
2319
+ },
1899
2320
  project(point) {
1900
2321
  ensureCamera(camera, width, height)
1901
2322
  transformPoint(clip, camera.viewProj, point)
@@ -1921,7 +2342,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1921
2342
  },
1922
2343
  pick(x, y) {
1923
2344
  pixelRay(x, y)
1924
- return scene.raycast(pickOrigin, pickDir)
2345
+ return raycastAs(texture, pickOrigin, pickDir, undefined)
1925
2346
  },
1926
2347
  unproject(x, y, w, out = [0, 0, 0]) {
1927
2348
  pixelRay(x, y)
@@ -1938,29 +2359,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1938
2359
  }
1939
2360
  },
1940
2361
  raycast(origin, direction, rayOpts) {
1941
- // Flush pending writes: picking sees the tree as the app just wrote
1942
- // it, the same immediacy contract as lookAt()/project(). (The queued
1943
- // microtask still runs and finds nothing dirty - harmless.)
1944
- if (scheduled) sync()
1945
- if (disposed) return []
1946
- let f = queryFilter(rayOpts, "raycast")
1947
- let hits: Hit[] = []
1948
- rayOriginScratch[0] = origin[0]
1949
- rayOriginScratch[1] = origin[1]
1950
- rayOriginScratch[2] = origin[2]
1951
- rayDirScratch[0] = direction[0]
1952
- rayDirScratch[1] = direction[1]
1953
- rayDirScratch[2] = direction[2]
1954
- for (let h of spatial.raycast(rayOriginScratch, rayDirScratch, f)) {
1955
- let leaf = leafOf(h.node)
1956
- if (leaf === null) continue
1957
- let hit: Hit = { mesh: leaf.mesh, distance: h.distance, point: h.point, normal: h.normal }
1958
- if (leaf.instance !== null) hit.instance = leaf.instance
1959
- if (h.face !== undefined) hit.face = h.face
1960
- if (h.uv !== undefined) hit.uv = h.uv
1961
- hits.push(hit)
1962
- }
1963
- return hits
2362
+ return raycastAs(texture, origin, direction, rayOpts)
1964
2363
  },
1965
2364
  overlap(volume, qopts) {
1966
2365
  if (scheduled) sync()
@@ -2027,7 +2426,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2027
2426
  // dispatch with its own capture and hover bookkeeping.
2028
2427
  let viewListeners = new Set<ScenePointerListener>()
2029
2428
  let handle: ViewHandle = {
2030
- texture: v.texture,
2429
+ texture: v.resolve !== null ? v.resolve.target : v.texture,
2430
+ hdrTexture: v.resolve !== null ? v.texture : null,
2031
2431
  depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
2032
2432
  setCamera(update) {
2033
2433
  updateCamera(v.camera, update)
@@ -2039,9 +2439,24 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2039
2439
  v.width = w
2040
2440
  v.height = h
2041
2441
  setTargetSize(v.texture, w, h)
2442
+ if (v.resolve !== null) resizeResolve(v.resolve, w, h)
2042
2443
  v.camera.dirty = true
2043
2444
  hooks._schedule()
2044
2445
  },
2446
+ size: () => ({ width: v.width, height: v.height }),
2447
+ setResolve(r) {
2448
+ if (v.resolve === null) throw new Error("view.setResolve: a tiled view has no resolve of its own - resolve the atlas")
2449
+ if (v.disposed) return
2450
+ replaceResolve(v.resolve, v.texture, r)
2451
+ hooks._schedule()
2452
+ },
2453
+ setBloom(bloom) {
2454
+ if (v.resolve === null) throw new Error("view.setBloom: a tiled view has no resolve to bloom on - resolve the atlas")
2455
+ if (v.disposed) return
2456
+ v.ownBloom = true
2457
+ setResolveBloom(v.resolve, v.texture, v.width, v.height, bloom)
2458
+ hooks._schedule()
2459
+ },
2045
2460
  setRect(rect) {
2046
2461
  if (v.disposed) return
2047
2462
  setTargetRect(v.texture, rect)
@@ -2055,6 +2470,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2055
2470
  if (v.disposed) return
2056
2471
  for (let k of Object.keys(params)) v.ownNames.add(k)
2057
2472
  setTargetParams(v.texture, params)
2473
+ if (v.resolve !== null) setTargetParams(v.resolve.target, params)
2058
2474
  },
2059
2475
  setLayers(mask) {
2060
2476
  checkMask(mask, "view.setLayers")
@@ -2069,7 +2485,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2069
2485
  pick(x, y) {
2070
2486
  if (v.disposed) return []
2071
2487
  pixelRayOf(v.camera, v.width, v.height, x, y)
2072
- return scene.raycast(pickOrigin, pickDir)
2488
+ return raycastAs(v.texture, pickOrigin, pickDir, undefined)
2073
2489
  },
2074
2490
  get handlers() {
2075
2491
  return viewPointer.handlers
@@ -2107,7 +2523,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2107
2523
  if (!Number.isInteger(size) || size < 1) throw new Error("scene.bakeBackground: size must be a positive integer, got " + size)
2108
2524
  // A probe that sees no mesh (mask 0) draws the background alone: the
2109
2525
  // same face cameras, linear output and prefilter as any probe.
2110
- let probe = makeProbe({ position: [0, 0, 0], size, label: (opts?.label ?? "scene") + "-sky" }, 0)
2526
+ let probe = makeProbe({ position: [0, 0, 0], size, label: label + "-sky" }, 0)
2111
2527
  probe.update()
2112
2528
  return probe.finish()
2113
2529
  },
@@ -2119,6 +2535,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2119
2535
  // the scene (entries' geometry-buffer references and pick leaves
2120
2536
  // dropped, core nodes freed), so a disposed scene leaves no
2121
2537
  // bookkeeping behind and the JS tree survives as plain data.
2538
+ // Destroyed nodes still animating out are no longer in the tree:
2539
+ // they free on the spot first.
2540
+ freeLeaving((_, s) => s === hooks)
2122
2541
  for (let c of root.children.slice()) leaveScene(c)
2123
2542
  root._scene = null
2124
2543
  if (root._node !== null) {
@@ -2126,9 +2545,11 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2126
2545
  root._node = null
2127
2546
  }
2128
2547
  spatial.setFrustum(texture, null)
2548
+ spatial.setLodView(texture, null)
2129
2549
  // Drain the zeroed direction slots the teardown queued while the
2130
2550
  // targets still exist; afterwards their groups are gone.
2131
2551
  spatial.flush()
2552
+ disposeResolve(resolve)
2132
2553
  destroyTexture(texture)
2133
2554
  for (let v of views.slice()) disposeView(v)
2134
2555
  shadowSys.dispose()
@@ -2167,12 +2588,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2167
2588
  )
2168
2589
  v.camera.mirror = true
2169
2590
  updateCamera(v.camera, { fov: PROBE_FOV, near: popts.near, far: popts.far })
2170
- // The faces hold LINEAR radiance, untouched by the scene's output
2171
- // stage: no sRGB encode, no tone mapping, unit exposure - the
2172
- // probe's own names, so the scene's fan-out leaves them alone.
2173
- for (let k of Object.keys(LINEAR_OUTPUT)) v.ownNames.add(k)
2174
- setTargetParams(v.texture, LINEAR_OUTPUT)
2175
- let chain = prefilter ? createPrefilter(size, v.texture, probeFormat(), (popts.label ?? (opts?.label ?? "scene") + "-probe") + "-chain") : null
2591
+ let chain = prefilter ? createPrefilter(size, v.texture, bufferFormat(), (popts.label ?? label + "-probe") + "-chain") : null
2176
2592
  v.probeCube = chain?.cube ?? v.texture
2177
2593
  let position: Vec3 = [popts.position[0], popts.position[1], popts.position[2]]
2178
2594
  return {
@@ -2193,6 +2609,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2193
2609
  chain?.run()
2194
2610
  },
2195
2611
  dispose() {
2612
+ if (v.disposed) return
2613
+ // The scene may sample this very cube - `environment={{ cube }}`,
2614
+ // a skybox `background` - so those drop first: a subtree that
2615
+ // owned the environment leaves the scene unlit by it, never
2616
+ // sampling a destroyed texture until something re-bakes.
2617
+ if (environment === v.probeCube) scene.setEnvironment(null)
2618
+ if (background !== null && background.sky !== null && background.sky.cube === v.probeCube) scene.setBackground(null)
2196
2619
  chain?.dispose()
2197
2620
  disposeView(v)
2198
2621
  },
@@ -2212,12 +2635,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2212
2635
  // Likewise the environment set starts at "none" (uEnvOn 0), so a
2213
2636
  // reflective material has coverage before setEnvironment.
2214
2637
  scene.setParams(environmentParams(null))
2215
- // And the output stage at its defaults (exposure 1, no tone mapping),
2216
- // which every library fragment declares.
2217
- scene.setParams({ uExposure: 1, uToneMapping: TONE_MAPPING_CODE.none, uOutputEncode: 1 })
2638
+ // And the resolve at its defaults (exposure 1, no tone mapping).
2639
+ scene.setParams({ uExposure: 1, uToneMapping: TONE_MAPPING_CODE.none })
2218
2640
  if (opts?.fog !== undefined) scene.setFog(opts.fog)
2219
2641
  if (opts?.toneMapping !== undefined) scene.setToneMapping(opts.toneMapping)
2220
2642
  if (opts?.exposure !== undefined) scene.setExposure(opts.exposure)
2643
+ if (opts?.bloom !== undefined) scene.setBloom(opts.bloom)
2221
2644
  if (opts?.background !== undefined) scene.setBackground(opts.background)
2222
2645
  if (opts?.environment !== undefined) scene.setEnvironment(opts.environment)
2223
2646
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())