@solidrt/3d 0.0.53 → 0.0.54

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
@@ -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,9 @@ 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? })` - straight
353
+ `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and `alphaTest`
354
+ as on lit (a mapped cutout casts its cutout).
326
355
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
327
356
  that turns to face the camera IN THE VERTEX STAGE (off the shared
328
357
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -472,17 +501,22 @@ the directional list, Lambert diffuse, Blinn-Phong highlight when
472
501
  `specular` (0..1 strength) is set with `shininess` (default 30), the
473
502
  same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
474
503
  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
504
+ `triplanar: n` to sample `map` by world position at `n` repeats per
505
+ world unit, blended across the three axis planes by the normal, and
506
+ `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
507
+ is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
508
+ sorting, usually with `cull: "none"` for cards. Triplanar
477
509
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
478
510
  map on a plane is a decal (UV) while a map on generated scenery wants one
479
511
  density across parts of any size (triplanar); the map must be created
480
512
  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.
513
+ combination (map x vertexColors x triplanar x transparent x cull x
514
+ alphaTest), cached for the app's lifetime, one pipeline per vertex layout
515
+ - a thousand lit meshes share one program. The view vector comes from the
516
+ shared uCamPos; `uTriplanar` and `uAlphaTest` are declared only by the
517
+ classes that use them (the cutoff is a per-entry value, so every
518
+ alphaTest material shares one class) so the other classes do not warn
519
+ about an inactive uniform.
486
520
 
487
521
  ## Models
488
522
 
@@ -494,7 +528,8 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
494
528
  bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
495
529
  kept, vertices in the standard layout with the node's WORLD transform
496
530
  baked in), `materials` (base color factor, `map` = index into `images`,
497
- `doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
531
+ `doubleSided`, `transparent` = alphaMode BLEND, `alphaMode` as written
532
+ and `alphaCutoff`, spec default 0.5), `images` (the encoded
498
533
  PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
499
534
  through `resolve(uri)` (uri as written, still percent-encoded;
500
535
  `gltfExternalUris(bytes)` lists them so an async caller can read them
@@ -526,10 +561,10 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
526
561
  "binary" }` then `createModel(parseGltf(bytes))`, see
527
562
  `examples/model.tsx`); bake anything big.
528
563
 
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
564
+ Applied: `doubleSided` (the default material draws it with `cull:
565
+ "none"`) and alphaMode MASK (`alphaTest: alphaCutoff`). Not in the
566
+ subset, dropped: vertex colors, tangents and further UV sets; samplers
567
+ are ignored (every texture repeats); emissive/additive parts of a model
533
568
  draw as their base color (a model's "glow" cards come out as dark wedges).
534
569
  The follow-ups are filed in okf/backlog/3d-model-loader.md.
535
570
 
@@ -568,7 +603,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
568
603
  are opaque to the library, so any inferred box would be a guess. Supply
569
604
  `bounds` for anything pickable or transparent.
570
605
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
571
- [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
606
+ [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
607
+ classes write alpha 1 when not `transparent` (the scene target is
608
+ composited premultiplied, so a leaked texel or color alpha would punch
609
+ a see-through hole in an opaque draw - the source of "white cutouts"
610
+ on an alpha-mapped model drawn without alphaTest). A `shaderMaterial`
611
+ writes its own fragColor: give an opaque look alpha 1 too.
612
+ `unlit({ ..., transparent: true })`
572
613
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
573
614
  `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
574
615
  behind opaques without occluding other translucents). The one inference:
@@ -675,7 +716,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
675
716
  `shadow.normalBias` (world units along the receiver normal, the one to
676
717
  reach for first, ~0.02); the depth pass culls FRONT faces (Three's
677
718
  shadowSide default), so closed casters need little bias but a
678
- single-sided plane casts nothing. Opting out of receiving is on the
719
+ back-culling plane casts only from its back. The shadow side follows
720
+ the material's `cull` (Three's shadowSide rule, Godot's shadow pass):
721
+ a `cull: "none"` foliage card or pane casts from both faces, and a
722
+ UV-mapped `alphaTest` material casts its cutout (leaves, not
723
+ rectangles), through the `Material.shadow` variant the standard
724
+ classes carry (a `shaderMaterial` gets the cull side from its `cull`
725
+ and supplies its own cutout variant as the `shadow` instance option).
726
+ Opting out of receiving is on the
679
727
  MATERIAL here (`receiveShadow: false`), not the object (Three's
680
728
  `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
681
729
  meshes never cast (the depth override cannot know their records) - the
@@ -752,12 +800,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
752
800
  vertex source - a comment counts - selects the "colored" layout, and
753
801
  the material then rejects standard geometry at add(). Do not mention
754
802
  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).
803
+ - Picking is triangle-accurate for ordinary meshes (`point` is a surface
804
+ point, hits carry `face`/`uv`/`normal`) but box-only for instanced
805
+ meshes: there `point` is the entry point of the population `bounds`
806
+ box, and `face`/`uv`/`normal` are absent. Never present an instanced
807
+ hit as a surface hit. Both tiers run in the spatial core (Rust); never
808
+ add a per-triangle path in JS - rays at mesh scale are
809
+ interpreter-hostile, and the core already does it.
761
810
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
762
811
  LAYOUT frame (every ancestor transform and design-size fit is already
763
812
  undone by the element hit test). `handlers` therefore assumes leaf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -19,6 +19,6 @@
19
19
  ],
20
20
  "peerDependencies": {
21
21
  "@solidjs/signals": "2.0.0-rc.3",
22
- "@solidrt/core": "0.0.53"
22
+ "@solidrt/core": "0.0.54"
23
23
  }
24
24
  }
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
package/src/material.ts CHANGED
@@ -35,7 +35,6 @@ import type {
35
35
  ProgramId,
36
36
  RenderPipelineId,
37
37
  ShaderParams,
38
- ShaderStageId,
39
38
  TextureBindings,
40
39
  TextureId,
41
40
  Topology,
@@ -73,18 +72,27 @@ export type Material = {
73
72
  * instanced meshes only - createInstancedMesh supplies the record buffer,
74
73
  * and createMesh meshes are rejected at add(). */
75
74
  instanceAttributes?: VertexAttribute[]
75
+ /** What a shadow view draws this material's meshes with instead of its
76
+ * default depth override: the depth pass culling the side this material
77
+ * culls (Three's shadowSide rule, Godot's shadow pass), so a `cull:
78
+ * "none"` caster casts from both faces, and for a cutout (lit
79
+ * alphaTest with a map) the same discard, so a plant casts leaves and
80
+ * not rectangles. Absent = the default (a back-culling material casts
81
+ * from its back faces). A shaderMaterial supplies its own through the
82
+ * instance option of the same name. */
83
+ shadow?: Material
76
84
  /** Present on materials that own their pipeline (shaderMaterial). */
77
85
  dispose?(): void
78
86
  }
79
87
 
80
- // One vertex stage serves every unlit class: model then view-projection,
81
- // plus the UV varying. uModel is per-entry (the scene writes it when the
82
- // mesh moves), uViewProj is target-shared (one write per camera move) - the
83
- // split is what keeps camera motion O(1) instead of O(meshes), and the
84
- // extra per-vertex mat4 multiply is free on the GPU. aNormal from the
85
- // shared layout is deliberately not declared - inactive attributes are
86
- // skipped and only the stride accounts for them.
87
- const VERTEX_SRC = glsl`
88
+ // The unlit vertex stage: model then view-projection, plus the UV
89
+ // varying. uModel is per-entry (the scene writes it when the mesh moves),
90
+ // uViewProj is target-shared (one write per camera move) - the split is
91
+ // what keeps camera motion O(1) instead of O(meshes), and the extra
92
+ // per-vertex mat4 multiply is free on the GPU. aNormal from the shared
93
+ // layout is deliberately not declared - inactive attributes are skipped
94
+ // and only the stride accounts for them.
95
+ const UNLIT_VERTEX = glsl`
88
96
  in vec3 aPos;
89
97
  in vec2 aUV;
90
98
  out vec2 vUv;
@@ -97,61 +105,28 @@ const VERTEX_SRC = glsl`
97
105
  }
98
106
  `
99
107
 
100
- const FRAGMENT_COLOR_SRC = glsl`
101
- uniform vec4 uColor;
102
- void main() {
103
- fragColor = uColor;
104
- }
105
- `
106
-
107
- const FRAGMENT_MAP_SRC = glsl`
108
- in vec2 vUv;
109
- uniform sampler2D uMap;
110
- uniform vec4 uColor;
111
- void main() {
112
- fragColor = texture(uMap, vUv) * uColor;
113
- }
114
- `
115
-
116
- let sharedVertex: ShaderStageId | undefined
117
- let programs: Partial<Record<UnlitClass, ProgramId>> = {}
118
- let pipelines = new Map<string, RenderPipelineId>()
119
-
120
- // One program per unlit CLASS: fragment kind x transparency. Blend state is
121
- // pipeline state and so is the attribute list, so the pipeline is keyed by
122
- // class and vertex layout.
123
- type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
124
-
125
- function programFor(cls: UnlitClass): ProgramId {
126
- let program = programs[cls]
127
- if (program === undefined) {
128
- if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
129
- let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
130
- header: true,
131
- })
132
- program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
133
- programs[cls] = program
134
- }
135
- return program
108
+ // The unlit fragment (sprites share it): the color, times the map when
109
+ // there is one, with the alphaTest discard when asked for. An opaque
110
+ // class writes alpha 1: the scene target is composited premultiplied, so
111
+ // a leaked texel alpha would punch a hole through an opaque draw.
112
+ function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean): string {
113
+ return glsl`
114
+ ${map ? "in vec2 vUv;" : ""}
115
+ ${map ? "uniform sampler2D uMap;" : ""}
116
+ uniform vec4 uColor;
117
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
118
+ void main() {
119
+ vec4 base = ${map ? "texture(uMap, vUv) * uColor" : "uColor"};
120
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
121
+ fragColor = ${transparent ? "base" : "vec4(base.rgb, 1.0)"};
122
+ }
123
+ `
136
124
  }
137
125
 
138
- function pipelineFor(cls: UnlitClass, layout: VertexLayout | undefined): RenderPipelineId {
139
- let key = cls + "|" + layoutKey(layout)
140
- let existing = pipelines.get(key)
141
- if (existing !== undefined) return existing
142
- let program = programFor(cls)
143
- let transparent = cls.endsWith("-transparent")
144
- let pipeline = createRenderPipeline(program, {
145
- attributes: layoutAttributes(layout),
146
- depth: true,
147
- depthWrite: transparent ? false : undefined,
148
- blend: transparent ? "alpha" : undefined,
149
- cull: "back",
150
- label: "scene-unlit-" + cls,
151
- })
152
- pipelines.set(key, pipeline)
153
- return pipeline
154
- }
126
+ // One shaderMaterialClass per unlit option combination (map x transparent
127
+ // x cull x alphaTest), cached for the app's lifetime like lit's; one
128
+ // pipeline per vertex layout inside each.
129
+ let unlitClasses = new Map<string, ShaderMaterialClass>()
155
130
 
156
131
  export type UnlitOptions = {
157
132
  /** Straight [r, g, b] or [r, g, b, a], 0..1. Default white. */
@@ -161,6 +136,18 @@ export type UnlitOptions = {
161
136
  /** Blend over what is behind (color alpha and map alpha both count).
162
137
  * Without it an alpha below 1 still draws opaque. See Material.transparent. */
163
138
  transparent?: boolean
139
+ /** Which faces to drop; default "back". "none" draws both sides of
140
+ * single-layer geometry (foliage cards, glass, a mirrored part), and
141
+ * lit materials then light a back face with its normal flipped, as
142
+ * Three's DoubleSide and Godot's CULL_DISABLED do. */
143
+ cull?: CullMode
144
+ /** Cutout: drop a fragment whose final alpha (color x map, and for lit
145
+ * the vertex color too) is below this, 0..1 (Three's alphaTest, glTF
146
+ * alphaMode MASK with its alphaCutoff). Opaque otherwise:
147
+ * depth-written, not sorted, unlike `transparent`. Foliage cards and
148
+ * fences want it with `cull: "none"`; a mapped cutout casts its cutout
149
+ * (Material.shadow). */
150
+ alphaTest?: number
164
151
  }
165
152
 
166
153
  /**
@@ -172,15 +159,29 @@ export function unlit(opts: UnlitOptions = {}): Material {
172
159
  let color = opts.color ?? [1, 1, 1]
173
160
  let a = color.length === 4 ? color[3] : 1
174
161
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
162
+ let map = opts.map !== undefined
175
163
  let transparent = opts.transparent === true
176
- let cls: UnlitClass = opts.map !== undefined ? (transparent ? "map-transparent" : "map") : transparent ? "color-transparent" : "color"
177
- return {
178
- pipeline: layout => pipelineFor(cls, layout),
179
- attributes: () => programAttributes(programFor(cls)),
180
- params: { uColor },
181
- textures: opts.map !== undefined ? { uMap: opts.map } : undefined,
182
- transparent,
164
+ let cull = opts.cull ?? "back"
165
+ let alphaTest = opts.alphaTest !== undefined
166
+ let key = [map, transparent, cull, alphaTest].join("|")
167
+ let cls = unlitClasses.get(key)
168
+ if (cls === undefined) {
169
+ cls = shaderMaterialClass({
170
+ vertex: UNLIT_VERTEX,
171
+ fragment: unlitFragment(map, alphaTest, transparent),
172
+ transparent,
173
+ cull,
174
+ label: "scene-unlit-" + key,
175
+ })
176
+ unlitClasses.set(key, cls)
183
177
  }
178
+ let params: ShaderParams = { uColor }
179
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
180
+ return cls.instance({
181
+ params,
182
+ textures: map ? { uMap: opts.map! } : undefined,
183
+ shadow: alphaTest && map ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
184
+ })
184
185
  }
185
186
 
186
187
  export type LitOptions = UnlitOptions & {
@@ -214,14 +215,37 @@ export type LitOptions = UnlitOptions & {
214
215
 
215
216
  // The lit fragment is composed from the same exported pieces an app
216
217
  // composes by hand, per flag: map x vertexColors x triplanar x shadow x
217
- // transparent. Lights arrive through the scene's shared params
218
- // (light nodes); the base color, map and highlight are per entry. The
218
+ // transparent x cull (a class that shows back faces lights them with the
219
+ // normal flipped, else a double-sided leaf's back is black) x alphaTest
220
+ // (the cutoff itself is a per-entry uniform, one class for every value).
221
+ // An opaque class writes alpha 1 (see unlitFragment). Lights arrive
222
+ // through the scene's shared params (light nodes); the base color, map
223
+ // and highlight are per entry. The
219
224
  // shadow set is shared too and indexed like the lights: one atlas sampler,
220
225
  // directional light i's maps (one, or its cascades) as map slots
221
226
  // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
222
227
  // each, and its biases (target-level, bound by the scene); uShadowCount
223
228
  // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
224
- function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, shadow: boolean): string {
229
+ // The option combination that picks a lit class: the class-cache key is
230
+ // its values in this order, and the fragment builder reads the same
231
+ // object, so the two cannot drift apart.
232
+ type LitClass = {
233
+ map: boolean
234
+ vertexColors: boolean
235
+ triplanar: boolean
236
+ transparent: boolean
237
+ shadow: boolean
238
+ cull: CullMode
239
+ alphaTest: boolean
240
+ }
241
+
242
+ function litClassKey(c: LitClass): string {
243
+ return Object.values(c).join("|")
244
+ }
245
+
246
+ function litFragment(c: LitClass): string {
247
+ let { map, vertexColors, triplanar, shadow, alphaTest } = c
248
+ let backFaces = c.cull !== "back"
225
249
  return glsl`
226
250
  in vec3 vWorldPos;
227
251
  in vec3 vNormal;
@@ -232,6 +256,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
232
256
  uniform float uSpecular;
233
257
  uniform float uShininess;
234
258
  ${triplanar ? "uniform float uTriplanar;" : ""}
259
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
235
260
  uniform vec3 uCamPos;
236
261
  uniform vec3 uHemiSky;
237
262
  uniform vec3 uHemiGround;
@@ -251,6 +276,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
251
276
 
252
277
  void main() {
253
278
  vec3 n = normalize(vNormal);
279
+ ${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
254
280
  vec4 base = uColor;
255
281
  ${
256
282
  map
@@ -263,6 +289,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
263
289
  : ""
264
290
  }
265
291
  ${vertexColors ? "base *= vColor;" : ""}
292
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
266
293
  vec3 v = normalize(uCamPos - vWorldPos);
267
294
  vec3 light = hemisphere(n, uHemiSky, uHemiGround);
268
295
  vec3 spec = vec3(0.0);
@@ -275,7 +302,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
275
302
  light += uLightColor[i] * lambert(n, l) * s;
276
303
  spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
277
304
  }
278
- fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, base.a);
305
+ fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, ${c.transparent ? "base.a" : "1.0"});
279
306
  }
280
307
  `
281
308
  }
@@ -285,8 +312,8 @@ let litClasses = new Map<string, ShaderMaterialClass>()
285
312
  /**
286
313
  * A lit material: hemisphere ambient plus the scene's directional lights
287
314
  * (DirectionalLight nodes), Lambert diffuse, optional
288
- * Blinn-Phong highlight. Same options as unlit (color, map, transparent)
289
- * plus vertexColors, specular/shininess and triplanar mapping. One program
315
+ * Blinn-Phong highlight. Same options as unlit (color, map, transparent,
316
+ * cull, alphaTest) plus vertexColors, specular/shininess and triplanar mapping. One program
290
317
  * per option combination, one pipeline per vertex layout met, shared by
291
318
  * every instance - a thousand lit meshes still share one pipeline. No
292
319
  * lights set means black except for the hemisphere term, which also
@@ -297,26 +324,40 @@ export function lit(opts: LitOptions = {}): Material {
297
324
  let a = color.length === 4 ? color[3] : 1
298
325
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
299
326
  let map = opts.map !== undefined
300
- let vertexColors = opts.vertexColors === true
301
327
  let triplanar = map && opts.triplanar !== undefined
302
- let transparent = opts.transparent === true
303
- let shadow = opts.receiveShadow !== false
304
- let key = [map, vertexColors, triplanar, transparent, shadow].join("|")
328
+ let alphaTest = opts.alphaTest !== undefined
329
+ let cull = opts.cull ?? "back"
330
+ let flags: LitClass = {
331
+ map,
332
+ vertexColors: opts.vertexColors === true,
333
+ triplanar,
334
+ transparent: opts.transparent === true,
335
+ shadow: opts.receiveShadow !== false,
336
+ cull,
337
+ alphaTest,
338
+ }
339
+ let key = litClassKey(flags)
305
340
  let cls = litClasses.get(key)
306
341
  if (cls === undefined) {
307
342
  cls = shaderMaterialClass({
308
- vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
309
- fragment: litFragment(map, vertexColors, triplanar, shadow),
310
- transparent,
343
+ vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
344
+ fragment: litFragment(flags),
345
+ transparent: flags.transparent,
346
+ cull,
311
347
  label: "scene-lit-" + key,
312
348
  })
313
349
  litClasses.set(key, cls)
314
350
  }
351
+ let params: ShaderParams = { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 }
352
+ if (triplanar) params.uTriplanar = opts.triplanar!
353
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
354
+ // A UV-mapped cutout casts its cutout; a color-only or triplanar
355
+ // alphaTest keeps the plain (cull-only) variant.
356
+ let cutout = alphaTest && map && !triplanar
315
357
  let material = cls.instance({
316
- params: triplanar
317
- ? { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30, uTriplanar: opts.triplanar! }
318
- : { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 },
358
+ params,
319
359
  textures: map ? { uMap: opts.map! } : undefined,
360
+ shadow: cutout ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
320
361
  })
321
362
  return material
322
363
  }
@@ -341,20 +382,79 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
341
382
  }
342
383
  `
343
384
 
344
- let shadowDepth: Material | undefined
385
+ let shadowDepth = new Map<CullMode, Material>()
345
386
 
346
387
  /** The override material of a scene's shadow view (internal): one class
347
- * for the app, built on first use. */
348
- export function shadowDepthMaterial(): Material {
349
- if (shadowDepth === undefined) {
350
- shadowDepth = shaderMaterialClass({
388
+ * per cull mode for the app, built on first use. The default, "front",
389
+ * is the caster's back surface (see above); a material culling
390
+ * otherwise carries its own variant as Material.shadow. */
391
+ export function shadowDepthMaterial(cull: CullMode = "front"): Material {
392
+ let material = shadowDepth.get(cull)
393
+ if (material === undefined) {
394
+ material = shaderMaterialClass({
351
395
  vertex: SHADOW_DEPTH_VERTEX,
352
396
  fragment: SHADOW_DEPTH_FRAGMENT,
353
- cull: "front",
354
- label: "scene-shadow-depth",
397
+ cull,
398
+ label: "scene-shadow-depth-" + cull,
355
399
  }).instance()
400
+ shadowDepth.set(cull, material)
356
401
  }
357
- return shadowDepth
402
+ return material
403
+ }
404
+
405
+ /** The shadow pass's cull for a material's cull: the opposite side
406
+ * (Three's shadowSide default), none stays none. */
407
+ function shadowCull(cull: CullMode): CullMode {
408
+ return cull === "none" ? "none" : cull === "back" ? "front" : "back"
409
+ }
410
+
411
+ /** "back" maps to the default depth material, so its variant is
412
+ * undefined. */
413
+ function shadowVariant(cull: CullMode): Material | undefined {
414
+ return cull === "back" ? undefined : shadowDepthMaterial(shadowCull(cull))
415
+ }
416
+
417
+ // The cutout depth pass: the caster's map alpha (times its color alpha)
418
+ // against its alphaTest, the lit fragment's test minus everything else.
419
+ const SHADOW_CUTOUT_VERTEX = glsl`
420
+ in vec3 aPos;
421
+ in vec2 aUV;
422
+ out vec2 vUv;
423
+ uniform mat4 uModel;
424
+ uniform mat4 uViewProj;
425
+ void main() {
426
+ gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
427
+ vUv = aUV;
428
+ }
429
+ `
430
+
431
+ const SHADOW_CUTOUT_FRAGMENT = glsl`
432
+ in vec2 vUv;
433
+ uniform sampler2D uMap;
434
+ uniform vec4 uColor;
435
+ uniform float uAlphaTest;
436
+ void main() {
437
+ if (texture(uMap, vUv).a * uColor.a < uAlphaTest) discard;
438
+ fragColor = vec4(1.0);
439
+ }
440
+ `
441
+
442
+ let shadowCutout = new Map<CullMode, ShaderMaterialClass>()
443
+
444
+ /** The shadow variant of a UV-mapped cutout material: one class per
445
+ * shadow cull mode, an instance per material (its map, color, cutoff). */
446
+ function shadowCutoutMaterial(cull: CullMode, uColor: number[], uAlphaTest: number, uMap: TextureId): Material {
447
+ let cls = shadowCutout.get(cull)
448
+ if (cls === undefined) {
449
+ cls = shaderMaterialClass({
450
+ vertex: SHADOW_CUTOUT_VERTEX,
451
+ fragment: SHADOW_CUTOUT_FRAGMENT,
452
+ cull,
453
+ label: "scene-shadow-cutout-" + cull,
454
+ })
455
+ shadowCutout.set(cull, cls)
456
+ }
457
+ return cls.instance({ params: { uColor, uAlphaTest }, textures: { uMap } })
358
458
  }
359
459
 
360
460
  export type SpriteOptions = UnlitOptions & {
@@ -436,7 +536,7 @@ export function sprite(opts: SpriteOptions = {}): Material {
436
536
  if (cls === undefined) {
437
537
  cls = shaderMaterialClass({
438
538
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
439
- fragment: map ? FRAGMENT_MAP_SRC : FRAGMENT_COLOR_SRC,
539
+ fragment: unlitFragment(map, false, transparent),
440
540
  transparent,
441
541
  cull: "none",
442
542
  label: "scene-sprite-" + key,
@@ -564,6 +664,10 @@ export type ShaderMaterialInstanceOptions = {
564
664
  * setMeshParams. */
565
665
  params?: ShaderParams
566
666
  textures?: TextureBindings
667
+ /** The depth variant a shadow view draws this instance with (see
668
+ * Material.shadow): a cutout's discard, an instanced class's vertex
669
+ * placement. Default: the depth pass with this class's cull side. */
670
+ shadow?: Material
567
671
  }
568
672
 
569
673
  export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
@@ -610,6 +714,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
610
714
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
611
715
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
612
716
  let depth = opts.depth ?? true
717
+ let cull = opts.cull ?? "back"
613
718
  // An empty list declares nothing - same as absent (the engine requires an
614
719
  // instance buffer exactly when attributes are declared).
615
720
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
@@ -640,7 +745,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
640
745
  // only applies when there is one.
641
746
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
642
747
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
643
- cull: opts.cull ?? "back",
748
+ cull,
644
749
  topology: opts.topology,
645
750
  label: opts.label,
646
751
  })
@@ -650,7 +755,20 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
650
755
  }
651
756
  return {
652
757
  instance(inst = {}) {
653
- return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
758
+ return {
759
+ normalMatrix,
760
+ attributes,
761
+ transparent,
762
+ instanceAttributes,
763
+ pipeline: pipelineFor,
764
+ params: inst.params ?? {},
765
+ textures: inst.textures,
766
+ // Lazy: the depth materials are shaderMaterialClass instances
767
+ // themselves, so an eager variant would recurse into its own cache.
768
+ get shadow() {
769
+ return inst.shadow ?? shadowVariant(cull)
770
+ },
771
+ }
654
772
  },
655
773
  dispose() {
656
774
  for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
package/src/model.ts CHANGED
@@ -58,7 +58,13 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
58
58
  label: label ? label + "-image" + i : undefined,
59
59
  })
60
60
  })
61
- let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({ color: m.color, map: map ?? undefined, transparent: m.transparent }))
61
+ let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({
62
+ color: m.color,
63
+ map: map ?? undefined,
64
+ transparent: m.transparent,
65
+ cull: m.doubleSided ? "none" : "back",
66
+ alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
67
+ }))
62
68
  let materials = data.materials.map((m) => make(m, m.map === null ? null : textures[m.map]!))
63
69
 
64
70
  let model = createGroup() as Model
package/src/scene.ts CHANGED
@@ -1537,7 +1537,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1537
1537
  let inst = mesh._instances
1538
1538
  if (v.override !== null && inst !== null) return
1539
1539
  if (v.filter !== null && !v.filter(mesh)) return
1540
- let material = v.override ?? mesh.material
1540
+ // A shadow view (the filtered kind) lets a caster's material pick its
1541
+ // own depth variant (its cull side); any other override view draws
1542
+ // exactly what it was given.
1543
+ let material = v.override !== null ? (v.filter !== null ? (mesh.material.shadow ?? v.override) : v.override) : mesh.material
1541
1544
  let bufs = mesh._buffers!
1542
1545
  let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1543
1546
  buffer: bufs.buffer,