@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 +74 -25
- package/package.json +2 -2
- package/src/gltf.ts +22 -4
- package/src/material.ts +214 -96
- package/src/model.ts +7 -1
- package/src/scene.ts +4 -1
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
|
-
(
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
476
|
-
world unit, blended across the three axis planes by the normal
|
|
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
|
|
482
|
-
the app's lifetime, one pipeline per vertex layout
|
|
483
|
-
meshes share one program. The view vector comes from the
|
|
484
|
-
`uTriplanar`
|
|
485
|
-
classes
|
|
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
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
and further UV sets
|
|
532
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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.
|
|
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.
|
|
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
|
|
34
|
-
*
|
|
33
|
+
/** glTF doubleSided; createModel's default material draws it with
|
|
34
|
+
* `cull: "none"`. */
|
|
35
35
|
doubleSided: boolean
|
|
36
|
-
/** alphaMode BLEND
|
|
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 = {
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
218
|
-
//
|
|
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
|
-
|
|
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
|
|
303
|
-
let
|
|
304
|
-
let
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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,
|