@solidrt/3d 0.0.63 → 0.0.65

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/examples/fog.tsx CHANGED
@@ -16,11 +16,12 @@
16
16
  // command sets the mode and its knobs (`{ mode: "linear" | "exp2" |
17
17
  // "height" | "off", near, far, density, height, falloff }`) and returns
18
18
  // the state; `pan` parks the camera (`{ t: seconds }`), so a capture
19
- // repeats.
19
+ // repeats; `suns` is the two suns' world positions and `project`
20
+ // (`{ point }`) is where a world point is on screen, for a pixel probe.
20
21
  import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
21
22
  import { registerDebug } from "srt:dev"
22
23
  import { cone, cylinder, DirectionalLight, HemisphereLight, phong, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
23
- import type { FogOptions, Vec3 } from "@solidrt/3d"
24
+ import type { FogOptions, SceneHandle, Vec3 } from "@solidrt/3d"
24
25
 
25
26
  const FAR = 400
26
27
  // The sky, shared by the clear and the fog so the horizon has no band.
@@ -36,6 +37,10 @@ const LINEAR_FAR = FAR
36
37
  const DENSITY = 0.006
37
38
  const HEIGHT = 10
38
39
  const HEIGHT_FALLOFF = 0.12
40
+ // The two suns, side by side on the far ridge: the left one opts out of
41
+ // the fog, the right one fogs like everything else.
42
+ const SUN_LIT: Vec3 = [-30, 100, -220]
43
+ const SUN_FOGGED: Vec3 = [30, 100, -220]
39
44
 
40
45
  type Mode = "linear" | "exp2" | "height" | "off"
41
46
  const MODES: Mode[] = ["linear", "exp2", "height", "off"]
@@ -48,6 +53,7 @@ let [height, setHeight] = createSignal(HEIGHT)
48
53
  let [falloff, setFalloff] = createSignal(HEIGHT_FALLOFF)
49
54
  let [time, setTime] = createSignal(0)
50
55
  let parked: number | null = null
56
+ let scene: SceneHandle | null = null
51
57
 
52
58
  let num = (v: unknown, set: (n: number) => void) => {
53
59
  if (typeof v === "number") set(v)
@@ -73,6 +79,13 @@ registerDebug("pan", (args?: Record<string, unknown>) => {
73
79
  flush()
74
80
  return { t: time(), parked: parked !== null }
75
81
  })
82
+ registerDebug("suns", () => ({ lit: SUN_LIT, fogged: SUN_FOGGED }))
83
+ registerDebug("project", (args?: Record<string, unknown>) => {
84
+ if (scene === null) throw new Error("project: the scene is not mounted")
85
+ let point = args?.point
86
+ if (!Array.isArray(point) || point.length !== 3 || !point.every(v => typeof v === "number")) throw new Error("project: pass { point: [x, y, z] }")
87
+ return scene.project(point as Vec3)
88
+ })
76
89
 
77
90
  function fog(): FogOptions | undefined {
78
91
  switch (mode()) {
@@ -148,7 +161,7 @@ function App() {
148
161
  return (
149
162
  <window>
150
163
  <view width={pct(100)} height={pct(100)} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
151
- <Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
164
+ <Scene ref={s => (scene = s)} clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
152
165
  <PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
153
166
  <HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
154
167
  <DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
@@ -162,8 +175,8 @@ function App() {
162
175
  <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]} />
163
176
  </>
164
177
  ))}
165
- <Mesh geometry={sun} material={sunLit} position={[-30, 100, -220]} />
166
- <Mesh geometry={sun} material={sunFogged} position={[30, 100, -220]} />
178
+ <Mesh geometry={sun} material={sunLit} position={SUN_LIT} />
179
+ <Mesh geometry={sun} material={sunFogged} position={SUN_FOGGED} />
167
180
  </Scene>
168
181
  </view>
169
182
  </window>
@@ -3,7 +3,7 @@
3
3
  // bundle - the shape every app with real content ends up needing. Bake
4
4
  // the asset once and drop it in your app's assets tree:
5
5
  //
6
- // bunx srt tool 3d/model examples/model.glb -o assets/model.srtm
6
+ // bun run srt tool 3d/model examples/model.glb -o assets/model.srtm
7
7
  //
8
8
  // (loadGltf("assets/model.glb") has the exact same async shape; the bake
9
9
  // just removes the runtime parse.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/index.ts",
11
11
  "./model": "./src/model-data.ts",
12
+ "./splat": "./src/splat-data.ts",
12
13
  "./math": "./src/math.ts",
13
14
  "./glsl": "./src/glsl.ts"
14
15
  },
@@ -18,8 +19,11 @@
18
19
  "examples/",
19
20
  "AGENTS.md"
20
21
  ],
22
+ "devDependencies": {
23
+ "@solidrt/test": "0.0.65"
24
+ },
21
25
  "peerDependencies": {
22
- "@solidjs/signals": "2.0.0-rc.9",
23
- "@solidrt/core": "0.0.63"
26
+ "@solidjs/signals": "2.0.0-rc.13",
27
+ "@solidrt/core": "0.0.65"
24
28
  }
25
29
  }
package/src/camera.ts CHANGED
@@ -135,8 +135,11 @@ export function ensureCamera(cam: Camera, width: number, height: number): void {
135
135
  // billboard needs no reconstruction from uViewProj. The inverse rides too:
136
136
  // uInvViewProj carries a clip position back to world, which is how the
137
137
  // background slot (and any shader declaring it) gets a world-space ray
138
- // per pixel without knowing the projection.
139
- export function cameraParams(cam: Camera): ShaderParams {
138
+ // per pixel without knowing the projection. The target's pixel size rides
139
+ // as uViewport (the size the camera's aspect came from), so a pixel-space
140
+ // footprint (the splat material) needs no app write and is per target -
141
+ // each view feeds its own.
142
+ export function cameraParams(cam: Camera, width: number, height: number): ShaderParams {
140
143
  let v = cam.view
141
144
  return {
142
145
  uViewProj: cam.viewProj,
@@ -144,5 +147,6 @@ export function cameraParams(cam: Camera): ShaderParams {
144
147
  uCamPos: cam.eye,
145
148
  uCamRight: [v[0], v[4], v[8]],
146
149
  uCamUp: [v[1], v[5], v[9]],
150
+ uViewport: [width, height],
147
151
  }
148
152
  }
@@ -13,6 +13,9 @@ export type DirectionalLightProps = TransformProps & {
13
13
  direction?: Vec3
14
14
  color?: Vec3
15
15
  intensity?: number
16
+ /** Layer filter bitmask, default all bits set: the light lights and
17
+ * shadows only the meshes whose `layers` intersect it. */
18
+ layers?: number
16
19
  /** Render a shadow map from this light (any directional light may;
17
20
  * each is a pass). Its shadow camera sits at the light's WORLD
18
21
  * position, so give a casting light a `position` above the scene. */
@@ -32,6 +35,7 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
32
35
  direction: props.direction,
33
36
  color: props.color,
34
37
  intensity: props.intensity,
38
+ layers: props.layers,
35
39
  castShadow: props.castShadow,
36
40
  shadow: props.shadow,
37
41
  }),
@@ -39,8 +43,8 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
39
43
  add(ctx.parent, light)
40
44
  syncNode(light, props)
41
45
  createEffect(
42
- () => [props.direction, props.color, props.intensity, props.castShadow, props.shadow] as const,
43
- ([direction, color, intensity, castShadow, shadow]) => setLight(light, { direction, color, intensity, castShadow, shadow }),
46
+ () => [props.direction, props.color, props.intensity, props.layers, props.castShadow, props.shadow] as const,
47
+ ([direction, color, intensity, layers, castShadow, shadow]) => setLight(light, { direction, color, intensity, layers, castShadow, shadow }),
44
48
  )
45
49
  untrack(() => props.ref)?.(light)
46
50
  onCleanup(() => destroy(light))
@@ -6,17 +6,25 @@ import { createHemisphereLight, setLight } from "../light.ts"
6
6
  import type { HemisphereLight as HemisphereLightNode } from "../light.ts"
7
7
  import type { Vec3 } from "../math.ts"
8
8
 
9
- export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
9
+ export type HemisphereLightProps = {
10
+ sky?: Vec3
11
+ ground?: Vec3
12
+ intensity?: number
13
+ /** Layer filter bitmask, default all bits set: a mesh outside it gets
14
+ * no hemisphere term. */
15
+ layers?: number
16
+ ref?: (light: HemisphereLightNode) => void
17
+ }
10
18
 
11
19
  /** The scene's ambient term as a node (createHemisphereLight); one per
12
20
  * scene, the last mounted wins. */
13
21
  export let HemisphereLight: VoidComponent<HemisphereLightProps> = props => {
14
22
  let ctx = useContext(SceneContext)
15
- let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity }))
23
+ let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity, layers: props.layers }))
16
24
  add(ctx.parent, light)
17
25
  createEffect(
18
- () => [props.sky, props.ground, props.intensity] as const,
19
- ([sky, ground, intensity]) => setLight(light, { sky, ground, intensity }),
26
+ () => [props.sky, props.ground, props.intensity, props.layers] as const,
27
+ ([sky, ground, intensity, layers]) => setLight(light, { sky, ground, intensity, layers }),
20
28
  )
21
29
  untrack(() => props.ref)?.(light)
22
30
  onCleanup(() => destroy(light))
@@ -33,6 +33,8 @@ export { InstancedLod, Lod } from "./lod.tsx"
33
33
  export type { InstancedLodProps, LodProps } from "./lod.tsx"
34
34
  export { RecordMesh } from "./record-mesh.tsx"
35
35
  export type { RecordMeshProps } from "./record-mesh.tsx"
36
+ export { SplatMesh } from "./splat-mesh.tsx"
37
+ export type { SplatMeshProps } from "./splat-mesh.tsx"
36
38
  export { PerspectiveCamera } from "./perspective-camera.tsx"
37
39
  export type { PerspectiveCameraProps } from "./perspective-camera.tsx"
38
40
  export { OrbitCamera } from "./orbit-camera.tsx"
@@ -8,7 +8,7 @@ import type { PopulatedMeshProps } from "./mesh.tsx"
8
8
  import { add, destroy, setMorphWeights } from "../node.ts"
9
9
  import type { MorphWeights, SceneNode } from "../node.ts"
10
10
  import { addInstance, createInstancedMesh, disposeInstances, setCastShadow, setGeometry, setInstanceStyle } from "../mesh.ts"
11
- import type { InstancedMesh as InstancedMeshNode, InstanceNode } from "../mesh.ts"
11
+ import type { InstancedMesh as InstancedMeshNode, InstanceNode, InstanceOrderOptions } from "../mesh.ts"
12
12
  import type { Geometry } from "../geometry.ts"
13
13
  import type { Material } from "../material.ts"
14
14
 
@@ -30,6 +30,10 @@ export type InstancedMeshProps = Omit<PopulatedMeshProps, "onPointerEnter" | "on
30
30
  * transparent sort. Optional here - instances pick by themselves, and
31
31
  * without it the mesh culls by the union of their boxes. */
32
32
  bounds?: ArrayLike<number>
33
+ /** Draw the records in key order, decided core-side (fixed at
34
+ * creation; see InstanceOrderOptions): back-to-front for a transparent
35
+ * population, an explicit sort field for a stepped one. */
36
+ instanceOrder?: InstanceOrderOptions
33
37
  /** Debug label for the record buffers. */
34
38
  label?: string
35
39
  /** The node the instance records are relative to (default the mesh;
@@ -63,7 +67,7 @@ export let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
63
67
  export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
64
68
  let ctx = useContext(SceneContext)
65
69
  let mesh = untrack(() =>
66
- createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, anchor: props.anchor, label: props.label }),
70
+ createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, anchor: props.anchor, instanceOrder: props.instanceOrder, label: props.label }),
67
71
  )
68
72
  add(ctx.parent, mesh)
69
73
  createEffect(
@@ -5,8 +5,9 @@ import { syncNode } from "./node-props.ts"
5
5
  import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
6
  import { add, destroy, setMorphWeights } from "../node.ts"
7
7
  import type { MorphWeights } from "../node.ts"
8
- import { createMesh, setCastShadow, setCulling, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
8
+ import { createMesh, setCastShadow, setCulling, setEnvironment, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
9
9
  import type { Mesh as MeshNode } from "../mesh.ts"
10
+ import type { EnvironmentOptions } from "../environment.ts"
10
11
  import type { ShaderParams } from "@solidrt/core/gpu"
11
12
  import type { Geometry } from "../geometry.ts"
12
13
  import type { Material } from "../material.ts"
@@ -29,6 +30,12 @@ export type MeshProps = TransformProps & PointerEventProps & {
29
30
  * draws the mesh when its mask intersects this. Not inherited from
30
31
  * ancestor Groups. */
31
32
  layers?: number
33
+ /** This mesh's environment instead of the scene's (setEnvironment as a
34
+ * prop): `null` removes the environment term - the sealed-interior
35
+ * pair to masked lights - a `{ cube, intensity?, rotation? }` samples
36
+ * that cube, absent follows the scene. Rebuilds the mesh's entries on
37
+ * change: a state switch, not a per-frame dial. */
38
+ environment?: EnvironmentOptions | null
32
39
  /** Frustum culling switch (setCulling as a prop; default true). */
33
40
  frustumCulled?: boolean
34
41
  /** World units the culled box grows by (setCulling as a prop; default 0). */
@@ -71,6 +78,10 @@ export function syncMesh(mesh: MeshNode, props: Omit<MeshProps, "geometry" | "ma
71
78
  () => props.layers,
72
79
  l => setLayers(mesh, l ?? 1),
73
80
  )
81
+ createEffect(
82
+ () => props.environment,
83
+ e => setEnvironment(mesh, e),
84
+ )
74
85
  createEffect(
75
86
  () => [props.frustumCulled, props.cullMargin] as const,
76
87
  ([culled, margin]) => setCulling(mesh, { frustumCulled: culled !== false, cullMargin: margin ?? 0 }),
@@ -11,6 +11,9 @@ import type { Vec3 } from "../math.ts"
11
11
  export type PointLightProps = TransformProps & {
12
12
  color?: Vec3
13
13
  intensity?: number
14
+ /** Layer filter bitmask, default all bits set: the light lights and
15
+ * shadows only the meshes whose `layers` intersect it. */
16
+ layers?: number
14
17
  /** Falloff cutoff in world units (0 = no cutoff). */
15
18
  distance?: number
16
19
  /** Falloff exponent; default 2 (inverse square). */
@@ -34,6 +37,7 @@ export let PointLight: VoidComponent<PointLightProps> = props => {
34
37
  createPointLight({
35
38
  color: props.color,
36
39
  intensity: props.intensity,
40
+ layers: props.layers,
37
41
  distance: props.distance,
38
42
  decay: props.decay,
39
43
  castShadow: props.castShadow,
@@ -43,8 +47,8 @@ export let PointLight: VoidComponent<PointLightProps> = props => {
43
47
  add(ctx.parent, light)
44
48
  syncNode(light, props)
45
49
  createEffect(
46
- () => [props.color, props.intensity, props.distance, props.decay, props.castShadow, props.shadow] as const,
47
- ([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
50
+ () => [props.color, props.intensity, props.layers, props.distance, props.decay, props.castShadow, props.shadow] as const,
51
+ ([color, intensity, layers, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, layers, distance, decay, castShadow, shadow }),
48
52
  )
49
53
  untrack(() => props.ref)?.(light)
50
54
  onCleanup(() => destroy(light))
@@ -5,7 +5,7 @@ import { syncMesh } from "./mesh.tsx"
5
5
  import type { PopulatedMeshProps } from "./mesh.tsx"
6
6
  import { add, destroy } from "../node.ts"
7
7
  import { createRecordMesh, disposeInstances, setCastShadow, setGeometry, setRecordCount, setRecords } from "../mesh.ts"
8
- import type { RecordMesh as RecordMeshNode } from "../mesh.ts"
8
+ import type { InstanceOrderOptions, RecordMesh as RecordMeshNode } from "../mesh.ts"
9
9
  import type { Geometry } from "../geometry.ts"
10
10
  import type { Material } from "../material.ts"
11
11
 
@@ -25,6 +25,14 @@ export type RecordMeshProps = PopulatedMeshProps & {
25
25
  * creation. Without them the mesh has no picking leaf, so pointer events
26
26
  * never target it. */
27
27
  bounds?: ArrayLike<number>
28
+ /** Draw the records in key order, decided core-side (fixed at
29
+ * creation; see InstanceOrderOptions): back-to-front for a transparent
30
+ * population, an explicit sort field for a JS-stepped one. */
31
+ instanceOrder?: InstanceOrderOptions
32
+ /** Transfer the records to the engine at creation (fixed; see
33
+ * RecordMeshOptions.transfer): the write-once form - no JS mirror, so
34
+ * a later `records` value throws instead of rewriting. */
35
+ transfer?: boolean
28
36
  /** Draw into the scene's shadow map (setCastShadow as a prop); default
29
37
  * false. Needs a `castShadow` light AND a material class declaring
30
38
  * `shadowVertex` (the depth pass with the instance placement) - the
@@ -39,7 +47,11 @@ export type RecordMeshProps = PopulatedMeshProps & {
39
47
  export let RecordMesh: VoidComponent<RecordMeshProps> = props => {
40
48
  let ctx = useContext(SceneContext)
41
49
  let mesh = untrack(() =>
42
- createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
50
+ createRecordMesh(props.geometry, props.material, props.records, props.count, {
51
+ bounds: props.bounds,
52
+ instanceOrder: props.instanceOrder,
53
+ transfer: props.transfer,
54
+ }),
43
55
  )
44
56
  add(ctx.parent, mesh)
45
57
  createEffect(
@@ -3,7 +3,7 @@ import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { createScene } from "../scene.ts"
5
5
  import { feedPointer } from "../scene-pointer.ts"
6
- import type { BloomOptions, EnvironmentOptions, FogOptions, ResolveInput, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
6
+ import type { BlendSpace, BloomOptions, EnvironmentOptions, FogOptions, ResolveInput, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
7
7
  import type { ScenePointerEvent, SceneTapEvent, SceneWheelEvent } from "../node.ts"
8
8
  import type { CameraUpdate } from "../camera.ts"
9
9
 
@@ -75,6 +75,12 @@ export type SceneProps = ScenePointerProps & {
75
75
  toneMapping?: ToneMapping
76
76
  /** Output exposure (scene.setExposure), default 1. Reactive. */
77
77
  exposure?: number
78
+ /** The buffer's blend space (SceneOptions.blendSpace), default
79
+ * "linear"; fixed at creation. "display" blends sRGB-encoded values
80
+ * in an rgba8 buffer that is itself the displayed image - the mode a
81
+ * splat capture is faithful in - and rejects resolve, bloom,
82
+ * toneMapping and exposure. */
83
+ blendSpace?: BlendSpace
78
84
  /** The scene target's layer mask (scene.setLayers as a prop; default 1):
79
85
  * the scene draws the meshes whose `layers` intersect it. Reactive. */
80
86
  layers?: number
@@ -175,6 +181,7 @@ export let Scene: ParentComponent<SceneProps> = props => {
175
181
  fog: props.fog,
176
182
  toneMapping: props.toneMapping,
177
183
  exposure: props.exposure,
184
+ blendSpace: props.blendSpace,
178
185
  resolve: props.resolve,
179
186
  bloom: props.bloom,
180
187
  layers: props.layers,
@@ -0,0 +1,50 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncMesh } from "./mesh.tsx"
5
+ import type { PopulatedMeshProps } from "./mesh.tsx"
6
+ import { add, destroy } from "../node.ts"
7
+ import { disposeInstances, setRecordCount } from "../mesh.ts"
8
+ import { createSplatMesh } from "../splat.ts"
9
+ import type { SplatMesh as SplatMeshNode, SplatMeshOptions } from "../splat.ts"
10
+ import type { SplatData } from "../splat-data.ts"
11
+
12
+ export type SplatMeshProps = PopulatedMeshProps & {
13
+ /** The baked cloud (loadSplat). Write-once: the records went to the
14
+ * engine and the data textures at creation, so treat the SplatData as
15
+ * consumed and swap clouds by remounting (a keyed <Show>), not by a new
16
+ * `data` value. */
17
+ data: SplatData
18
+ /** How many splats draw; default all. Records are importance-sorted at
19
+ * bake, so the first n are the scene at n - the LOD/bench dial (rounded
20
+ * up to whole groups of SPLAT_GROUP). */
21
+ count?: number
22
+ /** A custom splat material (see SplatMeshOptions); fixed at creation. */
23
+ material?: SplatMeshOptions["material"]
24
+ ref?: (mesh: SplatMeshNode) => void
25
+ }
26
+
27
+ /** A baked splat cloud in the scene (createSplatMesh as a component): an
28
+ * ordinary record mesh in the indexed form, drawn back to front by the
29
+ * core-side order with the scene feeding the view direction - no
30
+ * per-frame JS anywhere. The id buffer, the engine's record copy and the
31
+ * data textures are component-owned and freed on unmount. */
32
+ export let SplatMesh: VoidComponent<SplatMeshProps> = props => {
33
+ let ctx = useContext(SceneContext)
34
+ let mesh = untrack(() => createSplatMesh(props.data, { count: props.count, material: props.material }))
35
+ add(ctx.parent, mesh)
36
+ createEffect(
37
+ () => props.count,
38
+ c => {
39
+ if (c !== undefined) setRecordCount(mesh, c)
40
+ },
41
+ { defer: true },
42
+ )
43
+ syncMesh(mesh, props)
44
+ untrack(() => props.ref)?.(mesh)
45
+ onCleanup(() => {
46
+ destroy(mesh)
47
+ disposeInstances(mesh)
48
+ })
49
+ return null
50
+ }
@@ -14,6 +14,9 @@ export type SpotLightProps = TransformProps & {
14
14
  direction?: Vec3
15
15
  color?: Vec3
16
16
  intensity?: number
17
+ /** Layer filter bitmask, default all bits set: the light lights and
18
+ * shadows only the meshes whose `layers` intersect it. */
19
+ layers?: number
17
20
  /** Falloff cutoff in world units (0 = no cutoff). */
18
21
  distance?: number
19
22
  /** Cone half-angle in DEGREES, (0, 90]; default 60 (degrees like
@@ -43,6 +46,7 @@ export let SpotLight: VoidComponent<SpotLightProps> = props => {
43
46
  direction: props.direction,
44
47
  color: props.color,
45
48
  intensity: props.intensity,
49
+ layers: props.layers,
46
50
  distance: props.distance,
47
51
  angle: props.angle,
48
52
  penumbra: props.penumbra,
@@ -55,9 +59,9 @@ export let SpotLight: VoidComponent<SpotLightProps> = props => {
55
59
  syncNode(light, props)
56
60
  createEffect(
57
61
  () =>
58
- [props.direction, props.color, props.intensity, props.distance, props.angle, props.penumbra, props.decay, props.castShadow, props.shadow] as const,
59
- ([direction, color, intensity, distance, angle, penumbra, decay, castShadow, shadow]) =>
60
- setLight(light, { direction, color, intensity, distance, angle, penumbra, decay, castShadow, shadow }),
62
+ [props.direction, props.color, props.intensity, props.layers, props.distance, props.angle, props.penumbra, props.decay, props.castShadow, props.shadow] as const,
63
+ ([direction, color, intensity, layers, distance, angle, penumbra, decay, castShadow, shadow]) =>
64
+ setLight(light, { direction, color, intensity, layers, distance, angle, penumbra, decay, castShadow, shadow }),
61
65
  )
62
66
  untrack(() => props.ref)?.(light)
63
67
  onCleanup(() => destroy(light))
@@ -324,3 +324,58 @@ export function createEnvironmentPlaceholder(label: string): TextureId {
324
324
  let black = new Uint8Array([0, 0, 0, 255])
325
325
  return createCubeTexture([black, black, black, black, black, black], 1, { autoFree: false, label })
326
326
  }
327
+
328
+ /**
329
+ * An environment: a cube map every image-lit and reflective term samples
330
+ * (scene.setEnvironment for the scene's, `Mesh.environment` for one
331
+ * mesh's own). The same three fields as a skybox, and typically the same
332
+ * cube - Three's `scene.environment` with `environmentIntensity` and
333
+ * `environmentRotation`; Unity's environment reflections source, Godot's
334
+ * reflected light from the sky.
335
+ */
336
+ export type EnvironmentOptions = {
337
+ /** A cube map (loadEnvironment's baked chain; equirectToCube for an LDR
338
+ * panorama; createCubeTexture with `mipmap: true` for a hand-baked
339
+ * sky), faces in +X, -X, +Y, -Y, +Z, -Z order, looked up like the
340
+ * skybox (no flip). A 2D texture id throws. */
341
+ cube: TextureId
342
+ /** Multiplier on the reflected color, >= 0; default 1. */
343
+ intensity?: number
344
+ /** Turn about world y in RADIANS (default 0), the skybox's convention:
345
+ * the environment turns as a node with rotation [0, r, 0] would. */
346
+ rotation?: number
347
+ }
348
+
349
+ // The uniform turn a rotated cube map is looked up through: the INVERSE
350
+ // of the sky's turn, because a lookup along view direction v must find
351
+ // the texel that sat at R(-r) v before the sky turned by +r.
352
+ export function cubeTurn(rotation: number): number[] {
353
+ let c = Math.cos(rotation)
354
+ let n = Math.sin(rotation)
355
+ // prettier-ignore
356
+ return [
357
+ c, 0, n, 0,
358
+ 0, 1, 0, 0,
359
+ -n, 0, c, 0,
360
+ 0, 0, 0, 1,
361
+ ]
362
+ }
363
+
364
+ export function checkCubeKnobs(o: { intensity?: number; rotation?: number }, site: string): { intensity: number; rotation: number } {
365
+ let intensity = o.intensity ?? 1
366
+ let rotation = o.rotation ?? 0
367
+ if (!Number.isFinite(intensity) || intensity < 0) throw new Error(site + ": intensity must be a finite number >= 0, got " + intensity)
368
+ if (!Number.isFinite(rotation)) throw new Error(site + ": rotation must be a finite angle in radians, got " + rotation)
369
+ return { intensity, rotation }
370
+ }
371
+
372
+ // The params an environment compiles to (null = off: uEnvOn 0 makes the
373
+ // phong weight vanish and uEnvIntensity 0 zeroes the radiance and
374
+ // irradiance; the set ENVIRONMENT in `@solidrt/3d/glsl` declares them).
375
+ // Target params for the scene's environment, entry params for a mesh's
376
+ // own - the same names, and the entry's beat the target's.
377
+ export function environmentParams(env: EnvironmentOptions | null, site: string): Record<string, number | number[]> {
378
+ if (env === null) return { uEnvIntensity: 0, uEnvRotation: cubeTurn(0), uEnvOn: 0 }
379
+ let k = checkCubeKnobs(env, site)
380
+ return { uEnvIntensity: k.intensity, uEnvRotation: cubeTurn(k.rotation), uEnvOn: 1 }
381
+ }
package/src/geometry.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  // format's codec.
38
38
  //
39
39
  // Pure module by design - geometry is data, and every function here is
40
- // array math (the check rig checks/geometry-check.ts runs it headless on
40
+ // array math (tests/geometry.test.ts runs it headless on
41
41
  // flux). The GPU buffer step lives in geometry-gpu.ts.
42
42
 
43
43
  import type { Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"