@solidrt/3d 0.0.53 → 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
@@ -131,9 +131,12 @@ blendMode and pointer events like any element.
131
131
  the upload), so swapping `<Mesh geometry>` reactively never accumulates
132
132
  old generations. `disposeGeometry` is the immediate explicit free.
133
133
  - Materials dedupe hard: one program + one pipeline per material CLASS
134
- (unlit color, unlit map, each opaque or transparent), `depth: true` +
135
- `cull: "back"`; an instance is
136
- just per-entry uniforms (`uColor`) and bindings (`uMap`).
134
+ (a `shaderMaterialClass` per option combination for unlit, lit and
135
+ sprite alike: map x transparent x cull x alphaTest, lit's extras on
136
+ top), `depth: true` + `cull: "back"` unless the material says otherwise
137
+ (`cull: "none"` for double-sided geometry; lit flips the normal on back
138
+ faces); an instance is just per-entry uniforms (`uColor`) and bindings
139
+ (`uMap`).
137
140
  - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
138
141
  `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
139
142
  GPU-free BY DESIGN so they can be checked headless (and, for the two
@@ -149,7 +152,7 @@ blendMode and pointer events like any element.
149
152
 
150
153
  | Component | Props |
151
154
  | --- | --- |
152
- | `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) |
153
156
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
154
157
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
155
158
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
@@ -168,6 +171,31 @@ elsewhere. Called once, untracked, inside the scene context. Scene
168
171
  layout, so render and display size separate - render at 2x and display
169
172
  smaller for supersampling.
170
173
 
174
+ Filling the window: `width`/`height` are DEVICE pixels, the leaf's layout
175
+ is LOGICAL. A `designSize` view fits the leaf to the window but never
176
+ changes the target, so on a HiDPI display a 720-pixel scene is stretched
177
+ across ~1100 device pixels and looks soft, and nothing warns (the
178
+ examples' `SIZE = 720` is a verification convenience, not a sizing
179
+ model). Render at the window's device size and lay the leaf out at its
180
+ logical size:
181
+
182
+ ```tsx
183
+ let target = createMemo(() => {
184
+ let { width, height } = windowSize()
185
+ let scale = displayScale()
186
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
187
+ })
188
+ <Scene width={target().w} height={target().h}
189
+ output={t => <texture src={t} width={windowSize().width}
190
+ height={windowSize().height}
191
+ {...useScene().scene.handlersFor(windowSize)} />}>
192
+ ```
193
+
194
+ `windowSize` and `displayScale` come from `@solidrt/core`. The leaf's
195
+ layout differs from the target, so it takes `handlersFor` (below), not
196
+ `handlers`; `useScene()` works inside `output` because it runs in the
197
+ scene context.
198
+
171
199
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
172
200
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
173
201
  zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
@@ -321,8 +349,10 @@ uint16/uint32 indices by vertex count automatically.
321
349
 
322
350
  Materials:
323
351
 
324
- - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
325
- internally.
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).
326
356
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
327
357
  that turns to face the camera IN THE VERTEX STAGE (off the shared
328
358
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -416,6 +446,37 @@ a background is static art - anything animated is a mesh's own
416
446
  shaderMaterial (or, until blend factors land, a separate shader texture
417
447
  underneath, which translucent grounds also still need).
418
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
+
419
480
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
420
481
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
421
482
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -472,17 +533,27 @@ the directional list, Lambert diffuse, Blinn-Phong highlight when
472
533
  `specular` (0..1 strength) is set with `shininess` (default 30), the
473
534
  same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
474
535
  multiply by the colored layout's aColor (so the geometry must carry it),
475
- and `triplanar: n` to sample `map` by world position at `n` repeats per
476
- world unit, blended across the three axis planes by the normal. Triplanar
536
+ `triplanar: n` to sample `map` by world position at `n` repeats per
537
+ world unit, blended across the three axis planes by the normal, and
538
+ `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
539
+ is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
540
+ sorting, usually with `cull: "none"` for cards. Triplanar
477
541
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
478
542
  map on a plane is a decal (UV) while a map on generated scenery wants one
479
543
  density across parts of any size (triplanar); the map must be created
480
- with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
481
- combination (map x vertexColors x triplanar x transparent), cached for
482
- the app's lifetime, one pipeline per vertex layout - a thousand lit
483
- meshes share one program. The view vector comes from the shared uCamPos;
484
- `uTriplanar` is declared only by the triplanar classes so the other
485
- classes do not warn about an inactive uniform.
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
550
+ combination (map x vertexColors x triplanar x transparent x cull x
551
+ alphaTest), cached for the app's lifetime, one pipeline per vertex layout
552
+ - a thousand lit meshes share one program. The view vector comes from the
553
+ shared uCamPos; `uTriplanar` and `uAlphaTest` are declared only by the
554
+ classes that use them (the cutoff is a per-entry value, so every
555
+ alphaTest material shares one class) so the other classes do not warn
556
+ about an inactive uniform.
486
557
 
487
558
  ## Models
488
559
 
@@ -494,7 +565,8 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
494
565
  bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
495
566
  kept, vertices in the standard layout with the node's WORLD transform
496
567
  baked in), `materials` (base color factor, `map` = index into `images`,
497
- `doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
568
+ `doubleSided`, `transparent` = alphaMode BLEND, `alphaMode` as written
569
+ and `alphaCutoff`, spec default 0.5), `images` (the encoded
498
570
  PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
499
571
  through `resolve(uri)` (uri as written, still percent-encoded;
500
572
  `gltfExternalUris(bytes)` lists them so an async caller can read them
@@ -507,7 +579,7 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
507
579
  Blender exports Draco by DEFAULT, so that is the first error a real
508
580
  file hits.
509
581
  - `createModel(data, { material?, label? })` - uploads the images (repeat
510
- wrap, mipmapped), makes one material per glTF material (default `lit({
582
+ wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `lit({
511
583
  color, map, transparent })`; pass `material(m, map)` for anything else,
512
584
  it is called once per material and shared), one mesh per part, all
513
585
  children of the returned `Model` (a Group): `add(scene.root, model)`,
@@ -526,10 +598,10 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
526
598
  "binary" }` then `createModel(parseGltf(bytes))`, see
527
599
  `examples/model.tsx`); bake anything big.
528
600
 
529
- Not in the subset, reported or dropped: `doubleSided` is reported and NOT
530
- applied (the standard materials cull back faces); vertex colors, tangents
531
- and further UV sets are dropped; samplers are ignored (every texture
532
- repeats); alphaMode MASK draws opaque; emissive/additive parts of a model
601
+ Applied: `doubleSided` (the default material draws it with `cull:
602
+ "none"`) and alphaMode MASK (`alphaTest: alphaCutoff`). Not in the
603
+ subset, dropped: vertex colors, tangents and further UV sets; samplers
604
+ are ignored (every texture repeats); emissive/additive parts of a model
533
605
  draw as their base color (a model's "glow" cards come out as dark wedges).
534
606
  The follow-ups are filed in okf/backlog/3d-model-loader.md.
535
607
 
@@ -568,7 +640,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
568
640
  are opaque to the library, so any inferred box would be a guess. Supply
569
641
  `bounds` for anything pickable or transparent.
570
642
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
571
- [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
643
+ [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
644
+ classes write alpha 1 when not `transparent` (the scene target is
645
+ composited premultiplied, so a leaked texel or color alpha would punch
646
+ a see-through hole in an opaque draw - the source of "white cutouts"
647
+ on an alpha-mapped model drawn without alphaTest). A `shaderMaterial`
648
+ writes its own fragColor: give an opaque look alpha 1 too.
649
+ `unlit({ ..., transparent: true })`
572
650
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
573
651
  `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
574
652
  behind opaques without occluding other translucents). The one inference:
@@ -675,7 +753,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
675
753
  `shadow.normalBias` (world units along the receiver normal, the one to
676
754
  reach for first, ~0.02); the depth pass culls FRONT faces (Three's
677
755
  shadowSide default), so closed casters need little bias but a
678
- single-sided plane casts nothing. Opting out of receiving is on the
756
+ back-culling plane casts only from its back. The shadow side follows
757
+ the material's `cull` (Three's shadowSide rule, Godot's shadow pass):
758
+ a `cull: "none"` foliage card or pane casts from both faces, and a
759
+ UV-mapped `alphaTest` material casts its cutout (leaves, not
760
+ rectangles), through the `Material.shadow` variant the standard
761
+ classes carry (a `shaderMaterial` gets the cull side from its `cull`
762
+ and supplies its own cutout variant as the `shadow` instance option).
763
+ Opting out of receiving is on the
679
764
  MATERIAL here (`receiveShadow: false`), not the object (Three's
680
765
  `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
681
766
  meshes never cast (the depth override cannot know their records) - the
@@ -752,12 +837,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
752
837
  vertex source - a comment counts - selects the "colored" layout, and
753
838
  the material then rejects standard geometry at add(). Do not mention
754
839
  aColor you do not read.
755
- - Picking is the VOLUME tier: a hit means the ray crossed the mesh's
756
- transformed bounding box, not its surface. Never present `point` as a
757
- surface point (it is the box-entry point), and never add a
758
- triangle-accurate path in JS - per-triangle rays at mesh scale are
759
- interpreter-hostile; that tier is core work (BVH descent per the
760
- differentiators ladder).
840
+ - Picking is triangle-accurate for ordinary meshes (`point` is a surface
841
+ point, hits carry `face`/`uv`/`normal`) but box-only for instanced
842
+ meshes: there `point` is the entry point of the population `bounds`
843
+ box, and `face`/`uv`/`normal` are absent. Never present an instanced
844
+ hit as a surface hit. Both tiers run in the spatial core (Rust); never
845
+ add a per-triangle path in JS - rays at mesh scale are
846
+ interpreter-hostile, and the core already does it.
761
847
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
762
848
  LAYOUT frame (every ancestor transform and design-size fit is already
763
849
  undone by the element hit test). `handlers` therefore assumes leaf
@@ -766,6 +852,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
766
852
  leaf under a design size. Only a leaf whose layout size deliberately
767
853
  differs from the target (supersampling) needs `handlersFor`, fed the
768
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.
769
862
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
770
863
  under a still pointer fires nothing until the next move - the same
771
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.53",
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.53"
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/gltf.ts CHANGED
@@ -30,11 +30,16 @@ export type ModelMaterial = {
30
30
  color: [number, number, number, number]
31
31
  /** Index into ModelData.images (the base color texture), or null. */
32
32
  map: number | null
33
- /** glTF doubleSided. Reported, not applied: the standard materials cull
34
- * back faces. */
33
+ /** glTF doubleSided; createModel's default material draws it with
34
+ * `cull: "none"`. */
35
35
  doubleSided: boolean
36
- /** alphaMode BLEND. MASK draws opaque (no alpha test). */
36
+ /** alphaMode BLEND; createModel's default material blends it. */
37
37
  transparent: boolean
38
+ /** glTF alphaMode as written (default OPAQUE). MASK is a cutout:
39
+ * createModel's default material draws it with `alphaTest: alphaCutoff`. */
40
+ alphaMode: "OPAQUE" | "MASK" | "BLEND"
41
+ /** glTF alphaCutoff (default 0.5); meaningful for MASK only. */
42
+ alphaCutoff: number
38
43
  }
39
44
 
40
45
  /** One drawable: a mesh node's primitive, vertices in WORLD space. */
@@ -67,10 +72,21 @@ const CHUNK_JSON = 0x4e4f534a
67
72
  const CHUNK_BIN = 0x004e4942
68
73
  const MODE_TRIANGLES = 4
69
74
 
75
+ // The spec's alphaCutoff when a MASK material leaves it out.
76
+ const GLTF_ALPHA_CUTOFF = 0.5
77
+
70
78
  const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
71
79
  const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
72
80
 
73
- const DEFAULT_MATERIAL: ModelMaterial = { name: "default", color: [1, 1, 1, 1], map: null, doubleSided: false, transparent: false }
81
+ const DEFAULT_MATERIAL: ModelMaterial = {
82
+ name: "default",
83
+ color: [1, 1, 1, 1],
84
+ map: null,
85
+ doubleSided: false,
86
+ transparent: false,
87
+ alphaMode: "OPAQUE",
88
+ alphaCutoff: GLTF_ALPHA_CUTOFF,
89
+ }
74
90
 
75
91
  /** True when the bytes are a .glb container (the "glTF" magic). */
76
92
  export function isGlb(bytes: Uint8Array): boolean {
@@ -171,6 +187,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
171
187
  map,
172
188
  doubleSided: m.doubleSided === true,
173
189
  transparent: m.alphaMode === "BLEND",
190
+ alphaMode: m.alphaMode === "MASK" || m.alphaMode === "BLEND" ? m.alphaMode : "OPAQUE",
191
+ alphaCutoff: typeof m.alphaCutoff === "number" ? m.alphaCutoff : GLTF_ALPHA_CUTOFF,
174
192
  }
175
193
  })
176
194
  // Primitives without a material draw the spec's default; it is appended