@solidrt/3d 0.0.54 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -152,7 +152,7 @@ blendMode and pointer events like any element.
152
152
 
153
153
  | Component | Props |
154
154
  | --- | --- |
155
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
155
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `fog?` (`{ color, near, far }`, linear by camera distance), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
156
156
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
157
157
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
158
158
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
@@ -349,9 +349,10 @@ uint16/uint32 indices by vertex count automatically.
349
349
 
350
350
  Materials:
351
351
 
352
- - `unlit({ color?, map?, transparent?, cull?, alphaTest? })` - straight
353
- `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and `alphaTest`
354
- as on lit (a mapped cutout casts its cutout).
352
+ - `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
353
+ straight `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and
354
+ `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
355
+ opts out of the scene's fog (all three standard materials take it).
355
356
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
356
357
  that turns to face the camera IN THE VERTEX STAGE (off the shared
357
358
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -445,6 +446,37 @@ a background is static art - anything animated is a mesh's own
445
446
  shaderMaterial (or, until blend factors land, a separate shader texture
446
447
  underneath, which translucent grounds also still need).
447
448
 
449
+ Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
450
+ the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
451
+ far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
452
+ `{ color, density }` (`FogExp2`, Unity's default; `1 - exp(-(d *
453
+ density)^2)`, no start band, never quite opaque - 0.01 is ~63% at 100
454
+ units). Either form takes `height` + `heightFalloff` (Godot's fog
455
+ height, Unreal's height falloff): full fog at and below `height` (world
456
+ y, default 0), thinning by `exp(-(y - height) * heightFalloff)` above -
457
+ a valley fills, the hilltops and the sky stay clear; per fragment
458
+ height, not integrated along the ray, the cheap tier every engine ships
459
+ first. A fragment fades toward `color` by its RADIAL distance from
460
+ `uCamPos` (not view depth). It is ONE shared-params write (`uFogColor`,
461
+ `uFogNear`, `uFogInv` = 1/(far-near), `uFogDensity`, `uFogHeight`,
462
+ `uFogHeightFalloff`; the form not in use is 0, "no fog" is every rate
463
+ 0, which the scene seeds at creation so there is no enable flag and no
464
+ branch - the shader takes the larger of the two distance factors times
465
+ the height term), fanned out to every view, so fogging costs nothing
466
+ per frame however many meshes. Every standard material (unlit,
467
+ lit, sprite) composes it after its alphaTest discard, mixed at the alpha
468
+ it writes (premultiplied stays premultiplied); `fog: false` on the
469
+ material drops the code from the program (Three's `material.fog`) - a
470
+ sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
471
+ from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
472
+ `fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
473
+ fades toward black instead of the fog color).
474
+ The BACKGROUND is not fogged: it is entry zero with no depth or
475
+ distance, so match the fog color to `clearColor` or the background's
476
+ horizon, and put `far` at or inside the camera's far plane to hide the
477
+ clip. `examples/fog.tsx` cycles the forms over a valley;
478
+ `examples/cascades.tsx` fogs its field to the sky.
479
+
448
480
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
449
481
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
450
482
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -509,7 +541,12 @@ sorting, usually with `cull: "none"` for cards. Triplanar
509
541
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
510
542
  map on a plane is a decal (UV) while a map on generated scenery wants one
511
543
  density across parts of any size (triplanar); the map must be created
512
- with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
544
+ with `wrap: "repeat"`. Any `map` on a surface seen at distance also wants
545
+ `mipmap: true` at creation, or it aliases as it recedes, and a tiled
546
+ surface seen at a grazing angle (a floor, a road) wants `anisotropy: 4`
547
+ or more beside it, or trilinear smears the far half into the mip its long
548
+ axis picked (`createModel` uploads its images with both; the device clamps
549
+ the level, `limits.maxAnisotropy` reports it). Internally one `shaderMaterialClass` per option
513
550
  combination (map x vertexColors x triplanar x transparent x cull x
514
551
  alphaTest), cached for the app's lifetime, one pipeline per vertex layout
515
552
  - a thousand lit meshes share one program. The view vector comes from the
@@ -542,7 +579,7 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
542
579
  Blender exports Draco by DEFAULT, so that is the first error a real
543
580
  file hits.
544
581
  - `createModel(data, { material?, label? })` - uploads the images (repeat
545
- wrap, mipmapped), makes one material per glTF material (default `lit({
582
+ wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `lit({
546
583
  color, map, transparent })`; pass `material(m, map)` for anything else,
547
584
  it is called once per material and shared), one mesh per part, all
548
585
  children of the returned `Model` (a Group): `add(scene.root, model)`,
@@ -815,6 +852,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
815
852
  leaf under a design size. Only a leaf whose layout size deliberately
816
853
  differs from the target (supersampling) needs `handlersFor`, fed the
817
854
  layout size the app itself set.
855
+ - Scene-wide effects reach a custom material ONLY by composition: a
856
+ `shaderMaterial` that does not compose `FOG` is unfogged, one that does
857
+ not compose the `SHADOW_*` trio is unshadowed, and since every
858
+ instanced mesh has a custom material, an instanced forest stays crisp
859
+ in a fogged scene until its fragment calls `fog()`. The engine cannot
860
+ inject it (what you declare is what runs); check both when a custom
861
+ look sits beside standard ones and reads wrong at distance.
818
862
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
819
863
  under a still pointer fires nothing until the next move - the same
820
864
  limit the element hit test has (hit-test-per-frame is an open platform
@@ -58,6 +58,13 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
58
58
  the plain box widened to cover the field: one map's texels spread over
59
59
  it, blocky everywhere) and the `cascades`/`fly` debug commands set the
60
60
  count and the shadow distance and park the flight.
61
+ - `fog.tsx` - scene fog over a valley of pines between two ridges: a
62
+ click cycles LINEAR (`{ near, far }`, a clear band then a fade to the
63
+ far plane), EXP2 (`{ density }`, thickening from the first metre) and
64
+ HEIGHT (`heightFalloff`: the valley floor fills, the hilltops and sky
65
+ stay clear), then off; two suns show the material opt-out (`unlit({
66
+ fog: false })` stays bright, its twin fogs). The `fog` debug command
67
+ sets the mode and its knobs and `pan` parks the camera.
61
68
  - `model.tsx` - a model from a file: `model.glb` (a small rover with
62
69
  nested node transforms, a mirrored node, a textured material, a
63
70
  transparent dome and a mesh without normals) parsed with `parseGltf`
@@ -14,8 +14,11 @@
14
14
  // The `cascades` debug command sets the count and the shadow distance
15
15
  // (`{ count, distance }`; the range the cascades split, the camera's far
16
16
  // by default - pulling it in sharpens every cascade) and `fly` parks the
17
- // flight (`{ t: seconds }`), so a capture repeats.
18
- import { createSignal, onFrame, pct, render } from "@solidrt/core"
17
+ // flight (`{ t: seconds }`), so a capture repeats. The field is fogged
18
+ // toward the sky color from FOG_NEAR to FOG_FAR, inside the camera's far
19
+ // plane, so the far pillars sink into the horizon instead of clipping
20
+ // (`examples/fog.tsx` is the fog tour).
21
+ import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
19
22
  import { registerDebug } from "srt:dev"
20
23
  import { box, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere } from "@solidrt/3d"
21
24
  import type { Geometry, Vec3 } from "@solidrt/3d"
@@ -27,6 +30,12 @@ const FAR = 200
27
30
  const RADIUS = 50
28
31
  const HEIGHT = 5
29
32
  const PERIOD = 90
33
+ // The sky, shared by the clear and the fog so the horizon has no band.
34
+ const SKY: [number, number, number] = [0.6, 0.72, 0.88]
35
+ // The fog band: clear up to FOG_NEAR, fully sky at FOG_FAR (the far edge
36
+ // of the field is about 150 units out at the flight's radius).
37
+ const FOG_NEAR = 30
38
+ const FOG_FAR = 150
30
39
 
31
40
  let [cascades, setCascades] = createSignal(3)
32
41
  let [distance, setDistance] = createSignal<number | null>(null)
@@ -37,6 +46,7 @@ let parked: number | null = null
37
46
  registerDebug("cascades", (args?: Record<string, unknown>) => {
38
47
  if (typeof args?.count === "number") setCascades(args.count)
39
48
  if (typeof args?.distance === "number" || args?.distance === null) setDistance(args.distance)
49
+ flush()
40
50
  return { cascades: cascades(), distance: distance() }
41
51
  })
42
52
  registerDebug("fly", (args?: Record<string, unknown>) => {
@@ -46,6 +56,7 @@ registerDebug("fly", (args?: Record<string, unknown>) => {
46
56
  } else if (args?.t === null) {
47
57
  parked = null
48
58
  }
59
+ flush()
49
60
  return { t: time(), parked: parked !== null }
50
61
  })
51
62
 
@@ -87,7 +98,14 @@ function App() {
87
98
  return (
88
99
  <window>
89
100
  <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
90
- <Scene width={SIZE} height={SIZE} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="cascades">
101
+ <Scene
102
+ width={SIZE}
103
+ height={SIZE}
104
+ clearColor={[SKY[0], SKY[1], SKY[2], 1]}
105
+ fog={{ color: SKY, near: FOG_NEAR, far: FOG_FAR }}
106
+ samples={4}
107
+ label="cascades"
108
+ >
91
109
  <PerspectiveCamera fov={50} near={0.5} far={FAR} position={eye()} lookAt={ahead()} />
92
110
  <HemisphereLight sky={[0.5, 0.58, 0.7]} ground={[0.25, 0.22, 0.18]} />
93
111
  <DirectionalLight
@@ -0,0 +1,174 @@
1
+ // Scene fog, all three forms over one valley: a camera on a hillside
2
+ // panning slowly across a floor of pines between two ridges, the far
3
+ // ridge near the camera's far plane. LINEAR fog (`{ near, far }`,
4
+ // Three's Fog) is a clear band then a fade, and hides the clip when
5
+ // `far` sits at the camera's; EXP2 (`{ density }`, Three's FogExp2 /
6
+ // Unity's default) thickens from the first metre with no band and
7
+ // never quite closes; HEIGHT (`heightFalloff` on either) fills the
8
+ // valley floor and thins on the way up, so the hilltops and the sky
9
+ // stay clear while the pines below drown - per fragment height, the
10
+ // cheap tier. Fog is one shared-params write per change, whatever the
11
+ // mesh count, and every standard material takes it; the two suns show
12
+ // the opt-out: the left one is `unlit({ fog: false })` and stays
13
+ // bright in every mode, the right one fogs like everything else.
14
+ //
15
+ // A click cycles linear -> exp2 -> height -> off. The `fog` debug
16
+ // command sets the mode and its knobs (`{ mode: "linear" | "exp2" |
17
+ // "height" | "off", near, far, density, height, falloff }`) and returns
18
+ // the state; `pan` parks the camera (`{ t: seconds }`), so a capture
19
+ // repeats.
20
+ import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
21
+ import { registerDebug } from "srt:dev"
22
+ import { cone, cylinder, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
23
+ import type { FogOptions, Vec3 } from "@solidrt/3d"
24
+
25
+ const SIZE = 720
26
+ const FAR = 400
27
+ // The sky, shared by the clear and the fog so the horizon has no band.
28
+ const SKY: [number, number, number] = [0.72, 0.78, 0.86]
29
+ // The camera: on the near hillside, panning slowly across the valley.
30
+ const EYE: Vec3 = [-40, 42, 140]
31
+ const PAN_PERIOD = 60
32
+ // How far the look-at point swings left and right of the valley axis.
33
+ const PAN_SWING = 70
34
+ // The linear band, the exp2 thickness and the height layer.
35
+ const NEAR = 40
36
+ const LINEAR_FAR = FAR
37
+ const DENSITY = 0.006
38
+ const HEIGHT = 10
39
+ const HEIGHT_FALLOFF = 0.12
40
+
41
+ type Mode = "linear" | "exp2" | "height" | "off"
42
+ const MODES: Mode[] = ["linear", "exp2", "height", "off"]
43
+
44
+ let [mode, setMode] = createSignal<Mode>("linear")
45
+ let [near, setNear] = createSignal(NEAR)
46
+ let [far, setFar] = createSignal(LINEAR_FAR)
47
+ let [density, setDensity] = createSignal(DENSITY)
48
+ let [height, setHeight] = createSignal(HEIGHT)
49
+ let [falloff, setFalloff] = createSignal(HEIGHT_FALLOFF)
50
+ let [time, setTime] = createSignal(0)
51
+ let parked: number | null = null
52
+
53
+ let num = (v: unknown, set: (n: number) => void) => {
54
+ if (typeof v === "number") set(v)
55
+ }
56
+
57
+ registerDebug("fog", (args?: Record<string, unknown>) => {
58
+ if (typeof args?.mode === "string" && MODES.includes(args.mode as Mode)) setMode(args.mode as Mode)
59
+ num(args?.near, setNear)
60
+ num(args?.far, setFar)
61
+ num(args?.density, setDensity)
62
+ num(args?.height, setHeight)
63
+ num(args?.falloff, setFalloff)
64
+ flush()
65
+ return { mode: mode(), near: near(), far: far(), density: density(), height: height(), falloff: falloff() }
66
+ })
67
+ registerDebug("pan", (args?: Record<string, unknown>) => {
68
+ if (typeof args?.t === "number") {
69
+ parked = args.t
70
+ setTime(args.t)
71
+ } else if (args?.t === null) {
72
+ parked = null
73
+ }
74
+ flush()
75
+ return { t: time(), parked: parked !== null }
76
+ })
77
+
78
+ function fog(): FogOptions | undefined {
79
+ switch (mode()) {
80
+ case "linear":
81
+ return { color: SKY, near: near(), far: far() }
82
+ case "exp2":
83
+ return { color: SKY, density: density() }
84
+ case "height":
85
+ return { color: SKY, density: density(), height: height(), heightFalloff: falloff() }
86
+ case "off":
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ // The two ridges: flattened spheres sunk into the ground, and the height
92
+ // they give the ground at (x, z) so the pines stand on them.
93
+ type Hill = { position: Vec3; scale: Vec3 }
94
+ let hills: Hill[] = [
95
+ { position: [-90, -8, 120], scale: [110, 44, 90] },
96
+ { position: [60, -6, -140], scale: [150, 40, 90] },
97
+ { position: [-140, -10, -60], scale: [90, 36, 120] },
98
+ { position: [150, -4, 40], scale: [80, 26, 70] },
99
+ ]
100
+ function groundHeight(x: number, z: number): number {
101
+ let y = 0
102
+ for (let h of hills) {
103
+ let dx = (x - h.position[0]) / h.scale[0]
104
+ let dz = (z - h.position[2]) / h.scale[2]
105
+ let r = 1 - dx * dx - dz * dz
106
+ if (r > 0) y = Math.max(y, h.position[1] + h.scale[1] * Math.sqrt(r))
107
+ }
108
+ return y
109
+ }
110
+
111
+ // The pines, on a jittered grid over the valley and up the ridges.
112
+ type Pine = { position: Vec3; size: number }
113
+ let pines: Pine[] = []
114
+ let step = 18
115
+ for (let i = -12; i <= 12; i++) {
116
+ for (let j = -12; j <= 12; j++) {
117
+ let x = i * step + ((i * 7 + j * 13) % 9) - 4
118
+ let z = j * step + ((i * 11 + j * 5) % 9) - 4
119
+ let y = groundHeight(x, z)
120
+ // No pines on the near hillside under the camera, none above the tree line.
121
+ if (y > 30 || (x < -20 && z > 90)) continue
122
+ let size = 5 + ((i * 3 + j * 5 + 20) % 5)
123
+ pines.push({ position: [x, y, z], size })
124
+ }
125
+ }
126
+
127
+ function App() {
128
+ onFrame(tick => {
129
+ if (parked === null) setTime(tick / 1000)
130
+ })
131
+ let lookAt = () => {
132
+ let a = Math.sin((time() / PAN_PERIOD) * 2 * Math.PI)
133
+ return [a * PAN_SWING, 6, -60] as Vec3
134
+ }
135
+
136
+ let ground = lit({ color: [0.36, 0.48, 0.3] })
137
+ let rock = lit({ color: [0.5, 0.47, 0.42] })
138
+ let needles = lit({ color: [0.16, 0.36, 0.22] })
139
+ let trunk = lit({ color: [0.35, 0.25, 0.16] })
140
+ let sunLit = unlit({ color: [1, 0.92, 0.6], fog: false })
141
+ let sunFogged = unlit({ color: [1, 0.92, 0.6] })
142
+
143
+ let floor = plane({ width: 800, height: 800 })
144
+ let hill = sphere({ radius: 1, widthSegments: 48, heightSegments: 24 })
145
+ let crown = cone({ radius: 0.5, height: 1, radialSegments: 10 })
146
+ let stem = cylinder({ radiusTop: 0.08, radiusBottom: 0.1, height: 1, radialSegments: 6 })
147
+ let sun = sphere({ radius: 14, widthSegments: 24, heightSegments: 12 })
148
+
149
+ return (
150
+ <window>
151
+ <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
152
+ <Scene width={SIZE} height={SIZE} clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
153
+ <PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
154
+ <HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
155
+ <DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
156
+ <Mesh geometry={floor} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
157
+ {hills.map(h => (
158
+ <Mesh geometry={hill} material={rock} position={h.position} scale={h.scale} />
159
+ ))}
160
+ {pines.map(p => (
161
+ <>
162
+ <Mesh geometry={stem} material={trunk} position={[p.position[0], p.position[1] + p.size * 0.15, p.position[2]]} scale={[p.size, p.size * 0.3, p.size]} />
163
+ <Mesh geometry={crown} material={needles} position={[p.position[0], p.position[1] + p.size * 0.3 + p.size * 0.5, p.position[2]]} scale={[p.size * 0.9, p.size, p.size * 0.9]} />
164
+ </>
165
+ ))}
166
+ <Mesh geometry={sun} material={sunLit} position={[-30, 100, -220]} />
167
+ <Mesh geometry={sun} material={sunFogged} position={[30, 100, -220]} />
168
+ </Scene>
169
+ </view>
170
+ </window>
171
+ )
172
+ }
173
+
174
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.54",
3
+ "version": "0.0.55",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -18,7 +18,7 @@
18
18
  "AGENTS.md"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@solidjs/signals": "2.0.0-rc.3",
22
- "@solidrt/core": "0.0.54"
21
+ "@solidjs/signals": "2.0.0-rc.4",
22
+ "@solidrt/core": "0.0.55"
23
23
  }
24
24
  }
@@ -31,7 +31,7 @@ import {
31
31
  setVisible,
32
32
  } from "./scene.ts"
33
33
  import type { ShaderParams } from "@solidrt/core/gpu"
34
- import type { DirectionalLight as DirectionalLightNode, HemisphereLight as HemisphereLightNode, InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent, ShadowOptions } from "./scene.ts"
34
+ import type { DirectionalLight as DirectionalLightNode, FogOptions, HemisphereLight as HemisphereLightNode, InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent, ShadowOptions } from "./scene.ts"
35
35
  import type { Geometry } from "./geometry.ts"
36
36
  import type { Material } from "./material.ts"
37
37
  import type { Quat, Vec3 } from "./math.ts"
@@ -107,6 +107,12 @@ export type SceneProps = {
107
107
  * source replaces the background; undefined removes it. Three's
108
108
  * `scene.background = color` is `clearColor` here. */
109
109
  background?: string
110
+ /** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
111
+ * `{ color, density }`, optionally thinning above `height` by
112
+ * `heightFalloff`; every standard material fades toward `color` by
113
+ * distance from the camera. Reactive; undefined removes it. Match
114
+ * `color` to clearColor or the background, which is not fogged. */
115
+ fog?: FogOptions
110
116
  label?: string
111
117
  /** Multisample count (1, 2, 4 or 8; default 1): anti-aliased mesh edges.
112
118
  * Fixed at creation. */
@@ -149,6 +155,10 @@ export let Scene: ParentComponent<SceneProps> = props => {
149
155
  () => props.background,
150
156
  b => scene.setBackground(b ?? null),
151
157
  )
158
+ createEffect(
159
+ () => props.fog,
160
+ f => scene.setFog(f ?? null),
161
+ )
152
162
  untrack(() => props.ref)?.(scene)
153
163
  let output = untrack(() => props.output)
154
164
  let events = untrack(() => props.events) !== false
package/src/glsl.ts CHANGED
@@ -127,6 +127,53 @@ export const FRESNEL = glsl`
127
127
  }
128
128
  `
129
129
 
130
+ /**
131
+ * The scene's fog (`scene.setFog`): the uniform set it writes - `uFogColor`,
132
+ * the linear band `uFogNear` / `uFogInv` (1 / (far - near)), the exp2
133
+ * `uFogDensity`, and the height attenuation `uFogHeight` /
134
+ * `uFogHeightFalloff`; the form not in use is 0, a fogless scene writes
135
+ * every rate 0, so the factor is 0 with no branch and no enable flag -
136
+ * plus `vec3 fog(vec3 rgb, float alpha, vec3 worldPos, vec3 camPos)`: the
137
+ * factor by the RADIAL distance from the camera (the larger of the two
138
+ * forms), thinned by `exp(-(y - height) * falloff)` above the fog height,
139
+ * mixing toward the fog color at the fragment's written alpha
140
+ * (premultiplied output stays premultiplied). Compose it last, after the
141
+ * alphaTest discard, with the alpha you are about to write:
142
+ *
143
+ * fragColor = vec4(fog(rgb, a, vWorldPos, uCamPos), a);
144
+ *
145
+ * The standard materials compose exactly this; `fog: false` on one drops
146
+ * it (a sky sphere, a far backdrop). The background is not fogged.
147
+ *
148
+ * An ADDITIVE blend (`blend: "add"`) must not fade toward the fog color
149
+ * - a distant glow would brighten into a sky-colored halo - so it uses
150
+ * `vec3 fogAdditive(vec3 rgb, vec3 worldPos, vec3 camPos)`, the same
151
+ * factor fading toward black.
152
+ *
153
+ * Only what composes one of these is fogged: a shaderMaterial that does
154
+ * not - and so every instanced mesh, whose material is always custom -
155
+ * stays crisp in a fogged scene. The engine cannot inject it for you.
156
+ */
157
+ export const FOG = glsl`
158
+ uniform vec3 uFogColor;
159
+ uniform float uFogNear;
160
+ uniform float uFogInv;
161
+ uniform float uFogDensity;
162
+ uniform float uFogHeight;
163
+ uniform float uFogHeightFalloff;
164
+ vec3 fog(vec3 rgb, float alpha, vec3 worldPos, vec3 camPos) {
165
+ float d = distance(worldPos, camPos);
166
+ float linear = clamp((d - uFogNear) * uFogInv, 0.0, 1.0);
167
+ float dd = d * uFogDensity;
168
+ float exp2 = 1.0 - exp(-dd * dd);
169
+ float h = exp(-max(worldPos.y - uFogHeight, 0.0) * uFogHeightFalloff);
170
+ return mix(rgb, uFogColor * alpha, max(linear, exp2) * h);
171
+ }
172
+ vec3 fogAdditive(vec3 rgb, vec3 worldPos, vec3 camPos) {
173
+ return fog(rgb, 0.0, worldPos, camPos);
174
+ }
175
+ `
176
+
130
177
  /**
131
178
  * The scene's shadow set as a receiving program declares it: ONE
132
179
  * `uShadowAtlas` (every casting light's depth map is a tile of it, so N
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
8
  export { add, createDirectionalLight, createGroup, createHemisphereLight, createInstancedMesh, createMesh, createScene, createSprite, setLight, disposeInstances, getRotation, lookAt, remove, setCastShadow, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setTransition, setVisible, worldPosition, MAX_SHADOWS } from "./scene.ts"
9
- export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
9
+ export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, FogOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
10
10
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
11
11
  export { disposeGeometry } from "./geometry-gpu.ts"
12
12
  export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
package/src/material.ts CHANGED
@@ -42,7 +42,7 @@ import type {
42
42
  } from "@solidrt/core/gpu"
43
43
  import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
44
44
  import type { VertexLayout } from "./geometry.ts"
45
- import { BLINN_SPECULAR, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
45
+ import { BLINN_SPECULAR, FOG, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
46
46
 
47
47
  export type Material = {
48
48
  /** The pipeline this material draws with for geometry of `layout`
@@ -91,34 +91,46 @@ export type Material = {
91
91
  // what keeps camera motion O(1) instead of O(meshes), and the extra
92
92
  // per-vertex mat4 multiply is free on the GPU. aNormal from the shared
93
93
  // layout is deliberately not declared - inactive attributes are skipped
94
- // and only the stride accounts for them.
94
+ // and only the stride accounts for them. vWorldPos is the fog distance
95
+ // input; a fragment that does not read it (fog: false) leaves the out
96
+ // unmatched, which links fine.
95
97
  const UNLIT_VERTEX = glsl`
96
98
  in vec3 aPos;
97
99
  in vec2 aUV;
98
100
  out vec2 vUv;
101
+ out vec3 vWorldPos;
99
102
  uniform mat4 uModel;
100
103
  uniform mat4 uViewProj;
101
104
 
102
105
  void main() {
103
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
106
+ vec4 world = uModel * vec4(aPos, 1.0);
107
+ vWorldPos = world.xyz;
108
+ gl_Position = uViewProj * world;
104
109
  vUv = aUV;
105
110
  }
106
111
  `
107
112
 
108
113
  // The unlit fragment (sprites share it): the color, times the map when
109
- // there is one, with the alphaTest discard when asked for. An opaque
114
+ // there is one, with the alphaTest discard when asked for, then the
115
+ // scene's fog (FOG from ./glsl, mixed at the alpha about to be written)
116
+ // unless the material opted out. An opaque
110
117
  // class writes alpha 1: the scene target is composited premultiplied, so
111
118
  // a leaked texel alpha would punch a hole through an opaque draw.
112
- function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean): string {
119
+ function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean, fog: boolean): string {
120
+ let alpha = transparent ? "base.a" : "1.0"
113
121
  return glsl`
114
122
  ${map ? "in vec2 vUv;" : ""}
123
+ ${fog ? "in vec3 vWorldPos;" : ""}
115
124
  ${map ? "uniform sampler2D uMap;" : ""}
116
125
  uniform vec4 uColor;
117
126
  ${alphaTest ? "uniform float uAlphaTest;" : ""}
127
+ ${fog ? "uniform vec3 uCamPos;" : ""}
128
+ ${fog ? FOG : ""}
118
129
  void main() {
119
130
  vec4 base = ${map ? "texture(uMap, vUv) * uColor" : "uColor"};
120
131
  ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
121
- fragColor = ${transparent ? "base" : "vec4(base.rgb, 1.0)"};
132
+ ${fog ? `base.rgb = fog(base.rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
133
+ fragColor = vec4(base.rgb, ${alpha});
122
134
  }
123
135
  `
124
136
  }
@@ -148,6 +160,12 @@ export type UnlitOptions = {
148
160
  * fences want it with `cull: "none"`; a mapped cutout casts its cutout
149
161
  * (Material.shadow). */
150
162
  alphaTest?: number
163
+ /** Take the scene's fog (default true, Three's `material.fog`): the
164
+ * fragment fades toward the fog color with its distance from the
165
+ * camera once `scene.setFog` is set. `false` drops the fog code from
166
+ * the program - a sky sphere or a far backdrop that must keep its
167
+ * color, an emissive marker. */
168
+ fog?: boolean
151
169
  }
152
170
 
153
171
  /**
@@ -163,12 +181,13 @@ export function unlit(opts: UnlitOptions = {}): Material {
163
181
  let transparent = opts.transparent === true
164
182
  let cull = opts.cull ?? "back"
165
183
  let alphaTest = opts.alphaTest !== undefined
166
- let key = [map, transparent, cull, alphaTest].join("|")
184
+ let fog = opts.fog !== false
185
+ let key = [map, transparent, cull, alphaTest, fog].join("|")
167
186
  let cls = unlitClasses.get(key)
168
187
  if (cls === undefined) {
169
188
  cls = shaderMaterialClass({
170
189
  vertex: UNLIT_VERTEX,
171
- fragment: unlitFragment(map, alphaTest, transparent),
190
+ fragment: unlitFragment(map, alphaTest, transparent, fog),
172
191
  transparent,
173
192
  cull,
174
193
  label: "scene-unlit-" + key,
@@ -217,7 +236,8 @@ export type LitOptions = UnlitOptions & {
217
236
  // composes by hand, per flag: map x vertexColors x triplanar x shadow x
218
237
  // transparent x cull (a class that shows back faces lights them with the
219
238
  // normal flipped, else a double-sided leaf's back is black) x alphaTest
220
- // (the cutoff itself is a per-entry uniform, one class for every value).
239
+ // (the cutoff itself is a per-entry uniform, one class for every value)
240
+ // x fog (the scene's fog composed last, or left out of the program).
221
241
  // An opaque class writes alpha 1 (see unlitFragment). Lights arrive
222
242
  // through the scene's shared params (light nodes); the base color, map
223
243
  // and highlight are per entry. The
@@ -237,6 +257,7 @@ type LitClass = {
237
257
  shadow: boolean
238
258
  cull: CullMode
239
259
  alphaTest: boolean
260
+ fog: boolean
240
261
  }
241
262
 
242
263
  function litClassKey(c: LitClass): string {
@@ -244,8 +265,9 @@ function litClassKey(c: LitClass): string {
244
265
  }
245
266
 
246
267
  function litFragment(c: LitClass): string {
247
- let { map, vertexColors, triplanar, shadow, alphaTest } = c
268
+ let { map, vertexColors, triplanar, shadow, alphaTest, fog } = c
248
269
  let backFaces = c.cull !== "back"
270
+ let alpha = c.transparent ? "base.a" : "1.0"
249
271
  return glsl`
250
272
  in vec3 vWorldPos;
251
273
  in vec3 vNormal;
@@ -273,6 +295,7 @@ function litFragment(c: LitClass): string {
273
295
  ${HEMISPHERE}
274
296
  ${LAMBERT}
275
297
  ${BLINN_SPECULAR}
298
+ ${fog ? FOG : ""}
276
299
 
277
300
  void main() {
278
301
  vec3 n = normalize(vNormal);
@@ -302,7 +325,9 @@ function litFragment(c: LitClass): string {
302
325
  light += uLightColor[i] * lambert(n, l) * s;
303
326
  spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
304
327
  }
305
- fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, ${c.transparent ? "base.a" : "1.0"});
328
+ vec3 rgb = base.rgb * light + spec * uSpecular * base.a;
329
+ ${fog ? `rgb = fog(rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
330
+ fragColor = vec4(rgb, ${alpha});
306
331
  }
307
332
  `
308
333
  }
@@ -335,6 +360,7 @@ export function lit(opts: LitOptions = {}): Material {
335
360
  shadow: opts.receiveShadow !== false,
336
361
  cull,
337
362
  alphaTest,
363
+ fog: opts.fog !== false,
338
364
  }
339
365
  let key = litClassKey(flags)
340
366
  let cls = litClasses.get(key)
@@ -477,6 +503,7 @@ const SPRITE_VERTEX_SRC = glsl`
477
503
  in vec3 aPos;
478
504
  in vec2 aUV;
479
505
  out vec2 vUv;
506
+ out vec3 vWorldPos;
480
507
  uniform mat4 uModel;
481
508
  uniform mat4 uViewProj;
482
509
  uniform vec3 uCamRight;
@@ -486,6 +513,7 @@ const SPRITE_VERTEX_SRC = glsl`
486
513
  vec3 center = uModel[3].xyz;
487
514
  vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
488
515
  vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
516
+ vWorldPos = world;
489
517
  gl_Position = uViewProj * vec4(world, 1.0);
490
518
  vUv = aUV;
491
519
  }
@@ -495,6 +523,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
495
523
  in vec3 aPos;
496
524
  in vec2 aUV;
497
525
  out vec2 vUv;
526
+ out vec3 vWorldPos;
498
527
  uniform mat4 uModel;
499
528
  uniform mat4 uViewProj;
500
529
  uniform vec3 uCamPos;
@@ -507,6 +536,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
507
536
  float len = length(toCam);
508
537
  vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
509
538
  vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
539
+ vWorldPos = world;
510
540
  gl_Position = uViewProj * vec4(world, 1.0);
511
541
  vUv = aUV;
512
542
  }
@@ -531,12 +561,13 @@ export function sprite(opts: SpriteOptions = {}): Material {
531
561
  let map = opts.map !== undefined
532
562
  let transparent = opts.transparent !== false
533
563
  let fixedY = opts.billboard === "fixed-y"
534
- let key = [map, transparent, fixedY].join("|")
564
+ let fog = opts.fog !== false
565
+ let key = [map, transparent, fixedY, fog].join("|")
535
566
  let cls = spriteClasses.get(key)
536
567
  if (cls === undefined) {
537
568
  cls = shaderMaterialClass({
538
569
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
539
- fragment: unlitFragment(map, false, transparent),
570
+ fragment: unlitFragment(map, false, transparent, fog),
540
571
  transparent,
541
572
  cull: "none",
542
573
  label: "scene-sprite-" + key,
package/src/model.ts CHANGED
@@ -20,6 +20,12 @@ import type { Material } from "./material.ts"
20
20
  import { add, createGroup, createMesh, remove } from "./scene.ts"
21
21
  import type { Mesh, SceneNode } from "./scene.ts"
22
22
 
23
+ /** Anisotropic filtering level for a model's textures: the engines' usual
24
+ * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
25
+ * tiled surface legible at a grazing angle, cheap on every GPU. Clamped to
26
+ * the device by the runtime. */
27
+ const MODEL_ANISOTROPY = 4
28
+
23
29
  export type ModelOptions = {
24
30
  /** The material for each glTF material (default: `lit` with its color,
25
31
  * map and transparency). `map` is the uploaded base color texture, or
@@ -44,8 +50,9 @@ export type Model = SceneNode & {
44
50
 
45
51
  /**
46
52
  * Build the scene object for parsed model data: upload its images (repeat
47
- * wrap, mipmapped), make a material per glTF material, a mesh per part,
48
- * all under one Group. Synchronous - the data is already in memory.
53
+ * wrap, mipmapped, MODEL_ANISOTROPY), make a material per glTF material, a
54
+ * mesh per part, all under one Group. Synchronous - the data is already in
55
+ * memory.
49
56
  */
50
57
  export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
51
58
  let label = opts.label
@@ -54,6 +61,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
54
61
  return createTexture(image.data, image.width, image.height, {
55
62
  wrap: "repeat",
56
63
  mipmap: true,
64
+ anisotropy: MODEL_ANISOTROPY,
57
65
  autoFree: false,
58
66
  label: label ? label + "-image" + i : undefined,
59
67
  })
package/src/scene.ts CHANGED
@@ -373,8 +373,50 @@ export type CameraUpdate = {
373
373
  ortho?: OrthoExtent | null
374
374
  }
375
375
 
376
+ /**
377
+ * Scene fog, by RADIAL distance from the camera, in one of two forms:
378
+ * linear (Three's `Fog`, Unity's linear mode) fades toward `color` from
379
+ * `near` to `far` world units and is fully fogged past `far`; exp2
380
+ * (Three's `FogExp2`, Unity's default) thickens as
381
+ * `1 - exp(-(distance * density)^2)`, no start band, never quite opaque
382
+ * - `density` 0.01 is about 63% fog at 100 units, 98% at 200. Either
383
+ * form takes the height attenuation (Godot's fog height, Unreal's
384
+ * height falloff): the fog is full at and below `height` (world y) and
385
+ * thins above it by `exp(-(y - height) * heightFalloff)` - a valley
386
+ * fills while the hilltops and the sky stay clear; `heightFalloff` 0.1
387
+ * halves the fog every ~7 units of climb. Match
388
+ * `color` to the clearColor or background - the background is not
389
+ * fogged, so a mismatch shows as a band at the horizon - and, for the
390
+ * linear form, set `far` at or inside the camera's far plane to hide
391
+ * the clip.
392
+ */
393
+ export type FogOptions = (
394
+ | {
395
+ /** Distance where the fade starts, world units; negative puts the
396
+ * camera itself part-way into the fog (Three allows the same). */
397
+ near: number
398
+ /** Distance where the fade completes, world units; must exceed near. */
399
+ far: number
400
+ }
401
+ | {
402
+ /** Exp2 thickness per world unit, > 0 (0.005 haze .. 0.05 pea soup). */
403
+ density: number
404
+ }
405
+ ) & {
406
+ /** Straight [r, g, b], 0..1. */
407
+ color: [number, number, number]
408
+ /** World y at and below which the fog is full; default 0. Only acts
409
+ * with a `heightFalloff`. */
410
+ height?: number
411
+ /** How fast the fog thins above `height`, per world unit (e-fold rate,
412
+ * >= 0); default 0, no height attenuation. */
413
+ heightFalloff?: number
414
+ }
415
+
376
416
  export type SceneOptions = {
377
417
  clearColor?: [number, number, number, number]
418
+ /** Scene-wide fog; see setFog. */
419
+ fog?: FogOptions
378
420
  /** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
379
421
  * see setBackground. */
380
422
  background?: string
@@ -458,6 +500,19 @@ export type Scene = {
458
500
  * declare a name simply skips it. Frame-rate-safe like setTransform.
459
501
  */
460
502
  setParams(params: ShaderParams): void
503
+ /**
504
+ * Set, replace, or remove (null) the scene's fog, linear
505
+ * (`{ color, near, far }`) or exp2 (`{ color, density }`), either with
506
+ * the optional `height`/`heightFalloff`. One shared-params write
507
+ * (`uFogColor`, `uFogNear`, `uFogInv`, `uFogDensity`, `uFogHeight`,
508
+ * `uFogHeightFalloff` - the set FOG in `@solidrt/3d/glsl` declares;
509
+ * the form not in use is written 0), fanned out to every view like
510
+ * setParams, so however many meshes fog it costs nothing per frame.
511
+ * Every standard material (unlit, lit, sprite) composes it unless
512
+ * created with `fog: false`; a shaderMaterial opts in by composing FOG.
513
+ * The background is not fogged: match colors (see FogOptions).
514
+ */
515
+ setFog(fog: FogOptions | null): void
461
516
  /**
462
517
  * Set, replace, or remove (null) the scene's background: fragment GLSL
463
518
  * drawn as the FIRST entry of the scene's own pass - one target, no
@@ -2119,6 +2174,32 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2119
2174
  setTargetParams(texture, params)
2120
2175
  for (let v of views) setTargetParams(v.texture, params)
2121
2176
  },
2177
+ setFog(fog) {
2178
+ if (fog === null) {
2179
+ scene.setParams({ uFogInv: 0, uFogDensity: 0, uFogHeightFalloff: 0 })
2180
+ return
2181
+ }
2182
+ let color = [fog.color[0], fog.color[1], fog.color[2]]
2183
+ let height = fog.height ?? 0
2184
+ let falloff = fog.heightFalloff ?? 0
2185
+ if (!Number.isFinite(height) || !Number.isFinite(falloff) || falloff < 0) {
2186
+ throw new Error(`setFog: height must be finite and heightFalloff finite and >= 0, got ${height} / ${falloff}`)
2187
+ }
2188
+ let params: ShaderParams = { uFogColor: color, uFogHeight: height, uFogHeightFalloff: falloff }
2189
+ if ("density" in fog) {
2190
+ let { density } = fog
2191
+ if (!Number.isFinite(density) || density <= 0) {
2192
+ throw new Error(`setFog: density must be finite and > 0, got ${density}`)
2193
+ }
2194
+ scene.setParams({ ...params, uFogInv: 0, uFogDensity: density })
2195
+ return
2196
+ }
2197
+ let { near, far } = fog
2198
+ if (!(Number.isFinite(near) && Number.isFinite(far)) || far <= near) {
2199
+ throw new Error(`setFog: near/far must be finite with near < far, got ${near}..${far}`)
2200
+ }
2201
+ scene.setParams({ ...params, uFogNear: near, uFogInv: 1 / (far - near), uFogDensity: 0 })
2202
+ },
2122
2203
  setBackground(source) {
2123
2204
  if (disposed) return
2124
2205
  if (background !== null) {
@@ -2273,6 +2354,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2273
2354
  }
2274
2355
  },
2275
2356
  }
2357
+ // The fog set starts at "none" (uFogInv and uFogDensity 0 are factor 0,
2358
+ // uFogHeightFalloff 0 is no attenuation) so every material that declares
2359
+ // it has coverage from the first frame; a target tolerates the names
2360
+ // when nothing declares them.
2361
+ scene.setParams({ uFogColor: [0, 0, 0], uFogNear: 0, uFogInv: 0, uFogDensity: 0, uFogHeight: 0, uFogHeightFalloff: 0 })
2362
+ if (opts?.fog !== undefined) scene.setFog(opts.fog)
2276
2363
  if (opts?.background !== undefined) scene.setBackground(opts.background)
2277
2364
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
2278
2365
  return scene