@solidrt/3d 0.0.62 → 0.0.64

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.
@@ -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/glsl.ts CHANGED
@@ -368,9 +368,12 @@ export const PBR = glsl`
368
368
  * unit vector TOWARD a directional light; a spot's axis toward the
369
369
  * light; unused for a point), `uLightPos[i]` (a spot's or point's world
370
370
  * position; unused for a directional), `uLightColor[i]` (rgb, intensity
371
- * folded in) and `uLightParams[i]` (cosInner, cosOuter, distance, decay
371
+ * folded in), `uLightParams[i]` (cosInner, cosOuter, distance, decay
372
372
  * - the cone cosines a spot fades between, the falloff cutoff and
373
- * exponent a spot or point attenuates by; unused for a directional).
373
+ * exponent a spot or point attenuates by; unused for a directional) and
374
+ * `uLightMask[i]` (the light's `layers` filter bitmask, all bits by
375
+ * default - lightVector skips a light whose mask misses the mesh's
376
+ * `uLayers`, see LIGHT_LOOKUP).
374
377
  * Compose it before LIGHT_LOOKUP; the stock lit program is the shape.
375
378
  */
376
379
  export const LIGHT_SLOTS = glsl`
@@ -380,6 +383,7 @@ export const LIGHT_SLOTS = glsl`
380
383
  uniform vec3 uLightPos[${MAX_LIGHTS}];
381
384
  uniform vec3 uLightColor[${MAX_LIGHTS}];
382
385
  uniform vec4 uLightParams[${MAX_LIGHTS}];
386
+ uniform int uLightMask[${MAX_LIGHTS}];
383
387
  const int LIGHT_DIRECTIONAL = 0;
384
388
  const int LIGHT_SPOT = 1;
385
389
  const int LIGHT_POINT = 2;
@@ -395,13 +399,24 @@ export const LIGHT_SLOTS = glsl`
395
399
  * spot's additionally faded across its cone from cosInner to cosOuter.
396
400
  * Zero means the light cannot reach the fragment - skip its shadow
397
401
  * lookup and its terms (the stock lit program does exactly that).
402
+ * The first test is the LAYER gate: a light whose `uLightMask[i]`
403
+ * misses the mesh's `uLayers` (the per-entry membership bitmask the
404
+ * scene seeds from `mesh.layers` and follows through setLayers) cannot
405
+ * reach it at all - Unity's cullingMask, Godot's light_cull_mask. Both
406
+ * operands are uniforms, so the branch is coherent per draw.
398
407
  */
399
408
  export const LIGHT_LOOKUP = glsl`
400
409
  // Floors the falloff divisors so a fragment at the light's own
401
410
  // position stays finite (Three's punctual-light rule).
402
411
  const float FALLOFF_MIN = 0.01;
403
412
 
413
+ uniform int uLayers;
414
+
404
415
  float lightVector(int i, vec3 worldPos, out vec3 l) {
416
+ if ((uLightMask[i] & uLayers) == 0) {
417
+ l = vec3(0.0);
418
+ return 0.0;
419
+ }
405
420
  if (uLightType[i] == LIGHT_DIRECTIONAL) {
406
421
  l = uLightDir[i];
407
422
  return 1.0;
@@ -523,6 +538,36 @@ export const SRGB = glsl`
523
538
  }
524
539
  `
525
540
 
541
+ /**
542
+ * The buffer's blend space (SceneOptions.blendSpace): `uBlendSpace` is 0
543
+ * in a linear-light scene (the default; every scene writes it, since a
544
+ * shared program's uniform keeps the last drawn value across targets)
545
+ * and 1 in a display-space one, where the buffer holds sRGB-encoded
546
+ * display pixels and BLENDING runs on encoded values - what a capture
547
+ * trained against sRGB compositing expects. `vec3 blendSpaceOutput(vec3
548
+ * rgb, float alpha)` is the branch a buffer-writing fragment ends with:
549
+ * premultiplied linear in, and out either the same value (linear scene)
550
+ * or the un-premultiplied color clamped, encoded and re-premultiplied
551
+ * (display scene). Every stock fragment composes it through sceneOutput
552
+ * (and the skybox on its own); a CUSTOM fragment written for a
553
+ * display-space scene must end with it too, or its output blends in the
554
+ * wrong space. The encode carries its own name (not SRGB's
555
+ * linearToSrgb) so composing this set never collides with a fragment
556
+ * that also composes SRGB.
557
+ */
558
+ export const BLEND_SPACE = glsl`
559
+ uniform float uBlendSpace;
560
+ vec3 blendSpaceEncode(vec3 c) {
561
+ c = clamp(c, 0.0, 1.0);
562
+ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
563
+ }
564
+ vec3 blendSpaceOutput(vec3 rgb, float alpha) {
565
+ if (uBlendSpace < 0.5) return rgb;
566
+ vec3 c = alpha > 0.0 ? rgb / alpha : rgb;
567
+ return blendSpaceEncode(c) * alpha;
568
+ }
569
+ `
570
+
526
571
  /**
527
572
  * The LOD cross-fade: `uLodFade` is (threshold, side) - the scene writes
528
573
  * the band position of the level this entry draws (see createLod's
@@ -1079,7 +1124,8 @@ export type SceneSourceOptions = {
1079
1124
  * vec4 sceneOutput(vec3 rgb, float alpha, vec3 position);
1080
1125
  *
1081
1126
  * `sceneLight` is light i seen from a world point: `dir` the unit vector
1082
- * TOWARD it, `color` its rgb already attenuated (falloff, cone) and
1127
+ * TOWARD it, `color` its rgb already attenuated (falloff, cone), layer
1128
+ * masked (a light whose `layers` misses the mesh's, see LIGHT_LOOKUP) and
1083
1129
  * shadowed - zero when it cannot reach the fragment, and then no shadow
1084
1130
  * tap was spent. It is the accessor a custom light model loops over to
1085
1131
  * `uLightCount` (URP's GetAdditionalLight, Godot's light() inputs), and
@@ -1095,12 +1141,17 @@ export type SceneSourceOptions = {
1095
1141
  * stage carries the world position in.
1096
1142
  *
1097
1143
  * `sceneOutput` is the tail every library fragment ends with: fog over
1098
- * the premultiplied color, clamped at zero, and that is the pixel the
1099
- * scene buffer holds - LINEAR light, unclamped above (a sun disc of 40
1100
- * stays 40 in a half-float buffer). Exposure, tone mapping and the sRGB
1101
- * encode happen once per pixel in the target's resolve pass (RESOLVE),
1102
- * never in a scene fragment: a fragment that writes fragColor directly
1103
- * writes linear premultiplied light too.
1144
+ * the premultiplied color, clamped at zero, then the blend-space branch
1145
+ * (BLEND_SPACE), and that is the pixel the scene buffer holds. In a
1146
+ * linear scene (the default) that is LINEAR light, unclamped above (a
1147
+ * sun disc of 40 stays 40 in a half-float buffer), and exposure, tone
1148
+ * mapping and the sRGB encode happen once per pixel in the target's
1149
+ * resolve pass (RESOLVE), never in a scene fragment. In a display-space
1150
+ * scene (SceneOptions.blendSpace) the tail encodes here instead, the
1151
+ * buffer holds display pixels and there is no resolve. A fragment that
1152
+ * writes fragColor directly follows the same contract: linear
1153
+ * premultiplied light in a linear scene, blendSpaceOutput's result in a
1154
+ * display-space one.
1104
1155
  *
1105
1156
  * ${SCENE}
1106
1157
  * void main() {
@@ -1125,6 +1176,7 @@ export function sceneSource(o: SceneSourceOptions = {}): string {
1125
1176
  lights
1126
1177
  ? `uniform vec3 uHemiSky;
1127
1178
  uniform vec3 uHemiGround;
1179
+ uniform int uHemiMask;
1128
1180
  ${LIGHT_SLOTS}
1129
1181
  ${LIGHT_LOOKUP}`
1130
1182
  : ""
@@ -1147,10 +1199,11 @@ export function sceneSource(o: SceneSourceOptions = {}): string {
1147
1199
  ${env ? ENVIRONMENT : ""}
1148
1200
  ${fog ? FOG : ""}
1149
1201
  ${lights ? sceneShadeSource(receiveShadow, env) : ""}
1202
+ ${BLEND_SPACE}
1150
1203
 
1151
1204
  vec4 sceneOutput(vec3 rgb, float alpha, vec3 position) {
1152
1205
  ${fog === "additive" ? "rgb = fogAdditive(rgb, position, uCamPos);" : fog ? "rgb = fog(rgb, alpha, position, uCamPos);" : ""}
1153
- return vec4(max(rgb, vec3(0.0)), alpha);
1206
+ return vec4(blendSpaceOutput(max(rgb, vec3(0.0)), alpha), alpha);
1154
1207
  }
1155
1208
  `
1156
1209
  }
@@ -1160,8 +1213,9 @@ export const SCENE = sceneSource()
1160
1213
 
1161
1214
  // The light accessor and the two shade functions, over the sets
1162
1215
  // sceneSource composed before them. A light that cannot reach the point
1163
- // is skipped whole (no shadow tap, no terms), the gate the stock lit
1164
- // program always had.
1216
+ // - out of falloff, outside its cone, or layer-masked off the mesh - is
1217
+ // skipped whole (no shadow tap, no terms), the gate the stock lit
1218
+ // program always had. The hemisphere masks the same way (uHemiMask).
1165
1219
  function sceneShadeSource(receiveShadow: boolean, env: boolean): string {
1166
1220
  return glsl`
1167
1221
  struct SceneLight {
@@ -1180,7 +1234,7 @@ function sceneShadeSource(receiveShadow: boolean, env: boolean): string {
1180
1234
  vec3 shadeBlinn(Surface s, vec3 position) {
1181
1235
  vec3 n = s.normal;
1182
1236
  vec3 v = normalize(uCamPos - position);
1183
- vec3 light = hemisphere(n, uHemiSky, uHemiGround);
1237
+ vec3 light = (uHemiMask & uLayers) != 0 ? hemisphere(n, uHemiSky, uHemiGround) : vec3(0.0);
1184
1238
  light += s.ambient;
1185
1239
  vec3 spec = vec3(0.0);
1186
1240
  for (int i = 0; i < ${MAX_LIGHTS}; i++) {
@@ -1201,7 +1255,7 @@ function sceneShadeSource(receiveShadow: boolean, env: boolean): string {
1201
1255
  vec3 v = normalize(uCamPos - position);
1202
1256
  vec3 diffuseColor = s.base.rgb * (1.0 - s.metalness);
1203
1257
  vec3 f0 = mix(vec3(DIELECTRIC_F0) * s.base.a, s.base.rgb, s.metalness);
1204
- vec3 light = hemisphere(n, uHemiSky, uHemiGround);
1258
+ vec3 light = (uHemiMask & uLayers) != 0 ? hemisphere(n, uHemiSky, uHemiGround) : vec3(0.0);
1205
1259
  ${env ? "light += envIrradiance(n);" : ""}
1206
1260
  light += s.ambient;
1207
1261
  vec3 spec = vec3(0.0);
package/src/index.ts CHANGED
@@ -7,8 +7,8 @@
7
7
 
8
8
  export { add, createGroup, destroy, getMorphNames, getMorphWeights, getRotation, getTransform, lookAt, remove, setMorphWeights, setTransform, setTransition, setVisible, worldPosition } from "./node.ts"
9
9
  export type { LodConfig, MorphWeights, NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneEventBase, SceneNode, ScenePointerEvent, ScenePointerListener, SceneTapEvent, SceneWheelEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
10
- export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, setCastShadow, setCulling, setDrawRange, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, instanceAttribute, updateRecords, INSTANCE_FLOATS } from "./mesh.ts"
11
- export type { InstancedMesh as InstancedMeshNode, InstancedMeshOptions, InstanceNode, InstanceSlots, InstanceStream, Mesh as MeshNode, MeshInstances, RecordMesh as RecordMeshNode, RecordMeshOptions, UpdateRecordsOptions } from "./mesh.ts"
10
+ export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, setCastShadow, setCulling, setDrawRange, setEnvironment, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, instanceAttribute, updateRecords, INSTANCE_FLOATS } from "./mesh.ts"
11
+ export type { InstancedMesh as InstancedMeshNode, InstancedMeshOptions, InstanceNode, InstanceOrderOptions, InstanceSlots, InstanceStream, Mesh as MeshNode, MeshInstances, RecordMesh as RecordMeshNode, RecordMeshOptions, UpdateRecordsOptions } from "./mesh.ts"
12
12
  export { createDirectionalLight, createHemisphereLight, createPointLight, createSpotLight, setLight } from "./light.ts"
13
13
  // The shader-source caps, also on the /glsl subpath next to the sources they size.
14
14
  export { MAX_CASCADES, MAX_LIGHTS, MAX_SHADOW_MAPS } from "./glsl.ts"
@@ -31,7 +31,7 @@ export { createInstancedLod, createLod, setLod } from "./lod.ts"
31
31
  export type { InstancedLodLevel, InstancedLodOptions, LodLevel, LodOptions } from "./lod.ts"
32
32
  export { createScene, resolveParams } from "./scene.ts"
33
33
  export { feedPointer } from "./scene-pointer.ts"
34
- export type { BloomOptions, Capsule, EnvironmentOptions, FogOptions, Hit, Impact, MoveOptions, MoveResult, OrientedBox, Overlap, QueryOptions, ReflectionProbe, ReflectionProbeOptions, ResolveInput, ResolveOptions, Scene as SceneHandle, SceneHandlers, SceneOptions, ScreenRay, SkyboxOptions, Sphere, ToneMapping, ViewHandle, ViewOptions, Volume } from "./scene.ts"
34
+ export type { BlendSpace, BloomOptions, Capsule, EnvironmentOptions, FogOptions, Hit, Impact, MoveOptions, MoveResult, OrientedBox, Overlap, QueryOptions, ReflectionProbe, ReflectionProbeOptions, ResolveInput, ResolveOptions, Scene as SceneHandle, SceneHandlers, SceneOptions, ScreenRay, SkyboxOptions, Sphere, ToneMapping, ViewHandle, ViewOptions, Volume } from "./scene.ts"
35
35
  export { moveAndSlide } from "./collision.ts"
36
36
  export type { MoveScene } from "./collision.ts"
37
37
  export { bufferFormat, equirectToCube, loadEnvironment } from "./environment.ts"
@@ -49,7 +49,7 @@ export { extrude, lathe, pathFrames, polygon, sweep, tube } from "./sweep.ts"
49
49
  export type { ExtrudeOptions, LatheOptions, PathFrames, PathPoint, SweepPath, TubeOptions } from "./sweep.ts"
50
50
  export { phong, shaderMaterial, shaderMaterialClass, sprite, standard, unlit } from "./material.ts"
51
51
  export type { LitOptions, Material, PhongOptions, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, SpriteOptions, StandardOptions, UnlitOptions } from "./material.ts"
52
- export { DirectionalLight, FirstPersonCamera, Group, HemisphereLight, Instance, InstancedLod, InstancedMesh, Lod, Mesh, OrbitCamera, PerspectiveCamera, PointLight, RecordMesh, Scene, SpotLight, Sprite, View3d, useScene, Shot, Shots } from "./components/index.ts"
52
+ export { DirectionalLight, FirstPersonCamera, Group, HemisphereLight, Instance, InstancedLod, InstancedMesh, Lod, Mesh, OrbitCamera, PerspectiveCamera, PointLight, RecordMesh, Scene, SplatMesh, SpotLight, Sprite, View3d, useScene, Shot, Shots } from "./components/index.ts"
53
53
  export type {
54
54
  CameraTarget,
55
55
  DirectionalLightProps,
@@ -69,6 +69,7 @@ export type {
69
69
  RecordMeshProps,
70
70
  ScenePointerProps,
71
71
  SceneProps,
72
+ SplatMeshProps,
72
73
  SpotLightProps,
73
74
  SpriteProps,
74
75
  TransformProps,
@@ -81,6 +82,10 @@ export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelMaterial, Mo
81
82
  export { decodeModel, encodeModel } from "./model-file.ts"
82
83
  export { createModel, loadGltf, loadModel } from "./model.ts"
83
84
  export type { Model, ModelMaps, ModelOptions } from "./model.ts"
85
+ export { decodeSplat, encodeSplat, parseSplat, SPLAT_ATTRIBUTES, SPLAT_RECORD_BYTES, SPLAT_RECORD_TEXELS, SPLAT_SH_TEXELS, SPLAT_TEXEL_BYTES } from "./splat-data.ts"
86
+ export type { ParseSplatOptions, SplatData, SplatFormat } from "./splat-data.ts"
87
+ export { createSplatMesh, loadSplat, splatMaterialClass, splatVertexSource, SPLAT_FRAGMENT, SPLAT_GROUP, SPLAT_ID_ATTRIBUTES } from "./splat.ts"
88
+ export type { SplatMesh as SplatMeshNode, SplatMeshOptions } from "./splat.ts"
84
89
  export { bindSkeleton } from "./skeleton.ts"
85
90
  export type { BindSkeletonOptions } from "./skeleton.ts"
86
91
  export { createMixer } from "./mixer.ts"
package/src/light.ts CHANGED
@@ -4,9 +4,14 @@
4
4
 
5
5
  import { MAX_CASCADES } from "./glsl.ts"
6
6
  import type { Vec3 } from "./math.ts"
7
+ import { checkMask } from "./mesh.ts"
7
8
  import { makeNode } from "./node.ts"
8
9
  import type { SceneNode } from "./node.ts"
9
10
 
11
+ // The default light layers mask: every bit set, so a light lights every
12
+ // mesh unless told otherwise (the Unity and Godot default).
13
+ const ALL_LAYERS = 0xffffffff
14
+
10
15
  /** The orthographic frustum a casting light renders its shadow map from,
11
16
  * in the light's own space (x right, y up, looking along its direction),
12
17
  * Three's DirectionalLightShadow camera. Everything outside it is lit. */
@@ -68,6 +73,10 @@ export type DirectionalLight = SceneNode & {
68
73
  /** Linear [r, g, b] 0..1. */
69
74
  color: Vec3
70
75
  intensity: number
76
+ /** Layer filter bitmask (default all bits set): the light lights and
77
+ * shadows only the meshes whose `layers` intersect it - Unity's
78
+ * cullingMask, Godot's light_cull_mask. Write through setLight. */
79
+ layers: number
71
80
  /** Render a shadow map from this light (any directional light may;
72
81
  * each map is a full extra pass over the casting meshes); meshes with
73
82
  * `castShadow` draw into it, `lit` materials read it (unless
@@ -91,6 +100,9 @@ export type SpotLight = SceneNode & {
91
100
  /** Linear [r, g, b] 0..1. */
92
101
  color: Vec3
93
102
  intensity: number
103
+ /** Layer filter bitmask (default all bits set; DirectionalLight.layers
104
+ * is the contract). */
105
+ layers: number
94
106
  /** Falloff cutoff in world units; the light is zero past it (0 = no
95
107
  * cutoff, Three's rule). */
96
108
  distance: number
@@ -146,6 +158,9 @@ export type PointLight = SceneNode & {
146
158
  /** Linear [r, g, b] 0..1. */
147
159
  color: Vec3
148
160
  intensity: number
161
+ /** Layer filter bitmask (default all bits set; DirectionalLight.layers
162
+ * is the contract). */
163
+ layers: number
149
164
  /** Falloff cutoff in world units (0 = no cutoff). */
150
165
  distance: number
151
166
  /** Falloff exponent (default 2). */
@@ -169,6 +184,10 @@ export type HemisphereLight = SceneNode & {
169
184
  sky: Vec3
170
185
  ground: Vec3
171
186
  intensity: number
187
+ /** Layer filter bitmask (default all bits set; DirectionalLight.layers
188
+ * is the contract): a mesh outside it gets no hemisphere term (its own
189
+ * `Surface.ambient` still applies). */
190
+ layers: number
172
191
  }
173
192
 
174
193
  export type Light = DirectionalLight | SpotLight | PointLight | HemisphereLight
@@ -185,6 +204,9 @@ export type DirectionalLightOptions = {
185
204
  direction?: Vec3
186
205
  color?: Vec3
187
206
  intensity?: number
207
+ /** Layer filter bitmask, default all bits set (the light lights and
208
+ * shadows only meshes whose `layers` intersect it). */
209
+ layers?: number
188
210
  castShadow?: boolean
189
211
  /** Shadow-map options, merged key by key (setLight keeps unmentioned ones). */
190
212
  shadow?: ShadowOptions
@@ -193,6 +215,8 @@ export type SpotLightOptions = {
193
215
  direction?: Vec3
194
216
  color?: Vec3
195
217
  intensity?: number
218
+ /** Layer filter bitmask, default all bits set. */
219
+ layers?: number
196
220
  distance?: number
197
221
  angle?: number
198
222
  penumbra?: number
@@ -204,13 +228,21 @@ export type SpotLightOptions = {
204
228
  export type PointLightOptions = {
205
229
  color?: Vec3
206
230
  intensity?: number
231
+ /** Layer filter bitmask, default all bits set. */
232
+ layers?: number
207
233
  distance?: number
208
234
  decay?: number
209
235
  castShadow?: boolean
210
236
  /** Shadow-map options, merged key by key (setLight keeps unmentioned ones). */
211
237
  shadow?: SpotShadowOptions
212
238
  }
213
- export type HemisphereLightOptions = { sky?: Vec3; ground?: Vec3; intensity?: number }
239
+ export type HemisphereLightOptions = {
240
+ sky?: Vec3
241
+ ground?: Vec3
242
+ intensity?: number
243
+ /** Layer filter bitmask, default all bits set. */
244
+ layers?: number
245
+ }
214
246
 
215
247
  // Every light may cast; the bound is the shadow-slot budget
216
248
  // (MAX_SHADOW_MAPS in glsl.ts, exported from the root with MAX_LIGHTS and
@@ -248,7 +280,14 @@ function mergeShadow(into: DirectionalLight["shadow"], update: ShadowOptions): v
248
280
  if (update.bias !== undefined) into.bias = update.bias
249
281
  if (update.normalBias !== undefined) into.normalBias = update.normalBias
250
282
  if (update.radius !== undefined) into.radius = checkRadius(update.radius)
251
- if (update.camera !== undefined) Object.assign(into.camera, update.camera)
283
+ if (update.camera !== undefined) {
284
+ // Only the keys with a value: an undefined one is absent and keeps
285
+ // the extent, which the light projection reads as is.
286
+ for (let key of Object.keys(update.camera) as (keyof ShadowCamera)[]) {
287
+ let value = update.camera[key]
288
+ if (value !== undefined) into.camera[key] = value
289
+ }
290
+ }
252
291
  if (update.cascades !== undefined) {
253
292
  let n = update.cascades
254
293
  if (!Number.isInteger(n) || n < 1 || n > MAX_CASCADES) throw new Error("shadow.cascades must be an integer from 1 to " + MAX_CASCADES)
@@ -282,6 +321,7 @@ export function createDirectionalLight(opts: DirectionalLightOptions = {}): Dire
282
321
  light.direction = [...(opts.direction ?? [0, -1, 0])] as Vec3
283
322
  light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
284
323
  light.intensity = opts.intensity ?? 1
324
+ light.layers = opts.layers !== undefined ? checkMask(opts.layers, "createDirectionalLight") : ALL_LAYERS
285
325
  light.castShadow = opts.castShadow === true
286
326
  light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, radius: 1, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 }, cascades: 1, distance: null, splits: null }
287
327
  if (opts.shadow !== undefined) mergeShadow(light.shadow, opts.shadow)
@@ -295,6 +335,7 @@ export function createSpotLight(opts: SpotLightOptions = {}): SpotLight {
295
335
  light.direction = [...(opts.direction ?? [0, -1, 0])] as Vec3
296
336
  light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
297
337
  light.intensity = opts.intensity ?? 1
338
+ light.layers = opts.layers !== undefined ? checkMask(opts.layers, "createSpotLight") : ALL_LAYERS
298
339
  light.distance = opts.distance ?? 0
299
340
  light.angle = opts.angle ?? 60
300
341
  light.penumbra = opts.penumbra ?? 0
@@ -321,6 +362,7 @@ export function createPointLight(opts: PointLightOptions = {}): PointLight {
321
362
  light.type = "point"
322
363
  light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
323
364
  light.intensity = opts.intensity ?? 1
365
+ light.layers = opts.layers !== undefined ? checkMask(opts.layers, "createPointLight") : ALL_LAYERS
324
366
  light.distance = opts.distance ?? 0
325
367
  light.decay = opts.decay ?? 2
326
368
  light.castShadow = opts.castShadow === true
@@ -335,12 +377,13 @@ export function createHemisphereLight(opts: HemisphereLightOptions = {}): Hemisp
335
377
  light.sky = [...(opts.sky ?? [1, 1, 1])] as Vec3
336
378
  light.ground = [...(opts.ground ?? [0.2, 0.2, 0.2])] as Vec3
337
379
  light.intensity = opts.intensity ?? 1
380
+ light.layers = opts.layers !== undefined ? checkMask(opts.layers, "createHemisphereLight") : ALL_LAYERS
338
381
  return light
339
382
  }
340
383
 
341
384
  /** The write path for a light's own fields (color, intensity, direction,
342
- * the cone and falloff, or sky/ground); absent keys keep their value.
343
- * Its placement goes through setTransform like any node.
385
+ * layers, the cone and falloff, or sky/ground); absent keys keep their
386
+ * value. Its placement goes through setTransform like any node.
344
387
  * Frame-rate-safe. */
345
388
  export function setLight(light: DirectionalLight, update: DirectionalLightOptions): void
346
389
  export function setLight(light: SpotLight, update: SpotLightOptions): void
@@ -351,6 +394,15 @@ export function setLight(
351
394
  update: DirectionalLightOptions & SpotLightOptions & PointLightOptions & HemisphereLightOptions,
352
395
  ): void {
353
396
  if (update.intensity !== undefined) light.intensity = update.intensity
397
+ if (update.layers !== undefined) {
398
+ let mask = checkMask(update.layers, "setLight")
399
+ if (mask !== light.layers) {
400
+ light.layers = mask
401
+ // A casting light's shadow views re-filter their casters by the new
402
+ // mask (a re-attach, never a tile rebuild - the maps stay).
403
+ light._scene?._lightLayersChanged(light)
404
+ }
405
+ }
354
406
  if (light.type === "directional") {
355
407
  if (update.direction !== undefined) light.direction = [...update.direction] as Vec3
356
408
  if (update.color !== undefined) light.color = [...update.color] as Vec3
package/src/material.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  phongFragment,
53
53
  litShadowFragment,
54
54
  litVertex,
55
+ BLEND_SPACE,
55
56
  SKIN_DECLS,
56
57
  SKIN_MATRIX,
57
58
  standardFragment,
@@ -140,6 +141,20 @@ export type Material = {
140
141
  * an entry under a fading LOD level switches hard at the band's
141
142
  * midpoint. */
142
143
  lodFade?: boolean
144
+ /** True when a stage declares `uLayers` (read from the source, like
145
+ * lodFade; every lit source composing LIGHT_LOOKUP does): the scene
146
+ * then seeds and follows the mesh's `layers` bitmask on entries drawn
147
+ * with this material, which is what lightVector masks lights against.
148
+ * Like the other declared-uniform flags, a source that declares
149
+ * uLayers without using it is rejected at add(). */
150
+ layered?: boolean
151
+ /** True when the fragment declares `uEnv` (read from the source, like
152
+ * layered; every source composing ENVIRONMENT does - `standard`
153
+ * always, `phong` with `reflectivity`): the scene then applies a
154
+ * mesh's own `environment` to entries drawn with this material. On a
155
+ * material without it the mesh's environment is a no-op, never an
156
+ * error - "no reflection" is already true. */
157
+ env?: boolean
143
158
  /** Present on materials that own their pipeline (shaderMaterial). */
144
159
  dispose?(): void
145
160
  }
@@ -1112,9 +1127,10 @@ export const SKYBOX_FRAGMENT = glsl`
1112
1127
  uniform samplerCube uSky;
1113
1128
  uniform float uSkyIntensity;
1114
1129
  uniform mat4 uSkyRotation;
1130
+ ${BLEND_SPACE}
1115
1131
  void main() {
1116
1132
  vec3 dir = mat3(uSkyRotation) * normalize(vRay);
1117
- fragColor = vec4(max(texture(uSky, dir).rgb * uSkyIntensity, vec3(0.0)), 1.0);
1133
+ fragColor = vec4(blendSpaceOutput(max(texture(uSky, dir).rgb * uSkyIntensity, vec3(0.0)), 1.0), 1.0);
1118
1134
  }
1119
1135
  `
1120
1136
 
@@ -1324,6 +1340,13 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1324
1340
  let morphed = /\buMorphs\b/.test(opts.vertex)
1325
1341
  // The LOD cross-fade is a fragment-stage discard (Material.lodFade).
1326
1342
  let lodFade = /\buLodFade\b/.test(opts.fragment)
1343
+ // A source mentioning uLayers masks lights by the mesh's layers
1344
+ // (Material.layered); either stage may light, so both are scanned.
1345
+ let layered = /\buLayers\b/.test(opts.vertex) || /\buLayers\b/.test(opts.fragment)
1346
+ // A source mentioning uEnv reflects the environment (Material.env; the
1347
+ // \b keeps uEnvIntensity and friends from matching, but ENVIRONMENT
1348
+ // always declares the sampler itself).
1349
+ let env = /\buEnv\b/.test(opts.fragment)
1327
1350
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
1328
1351
  let cutout = opts.cutout === true
1329
1352
  let depth = opts.depth ?? true
@@ -1424,6 +1447,8 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1424
1447
  skinned,
1425
1448
  morphed,
1426
1449
  lodFade,
1450
+ layered,
1451
+ env,
1427
1452
  attributes,
1428
1453
  transparent,
1429
1454
  cutout,