@solidrt/3d 0.0.52 → 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 +112 -41
- package/examples/README.md +8 -0
- package/examples/cascades.tsx +121 -0
- package/package.json +4 -4
- package/src/components.tsx +2 -2
- package/src/glsl.ts +95 -40
- package/src/gltf.ts +22 -4
- package/src/material.ts +219 -100
- package/src/math.ts +76 -0
- package/src/model.ts +7 -1
- package/src/scene.ts +316 -95
- package/tools/model.ts +52 -0
- package/demos/README.md +0 -15
- package/demos/assets/icon.svg +0 -23
- package/demos/package.json +0 -9
- package/demos/src/the-third-dimension.tsx +0 -866
- package/demos/tsconfig.json +0 -15
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,13 +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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// the scene
|
|
222
|
-
//
|
|
223
|
-
|
|
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
|
|
224
|
+
// shadow set is shared too and indexed like the lights: one atlas sampler,
|
|
225
|
+
// directional light i's maps (one, or its cascades) as map slots
|
|
226
|
+
// uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
|
|
227
|
+
// each, and its biases (target-level, bound by the scene); uShadowCount
|
|
228
|
+
// 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
|
|
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"
|
|
224
249
|
return glsl`
|
|
225
250
|
in vec3 vWorldPos;
|
|
226
251
|
in vec3 vNormal;
|
|
@@ -231,6 +256,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
|
|
|
231
256
|
uniform float uSpecular;
|
|
232
257
|
uniform float uShininess;
|
|
233
258
|
${triplanar ? "uniform float uTriplanar;" : ""}
|
|
259
|
+
${alphaTest ? "uniform float uAlphaTest;" : ""}
|
|
234
260
|
uniform vec3 uCamPos;
|
|
235
261
|
uniform vec3 uHemiSky;
|
|
236
262
|
uniform vec3 uHemiGround;
|
|
@@ -250,6 +276,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
|
|
|
250
276
|
|
|
251
277
|
void main() {
|
|
252
278
|
vec3 n = normalize(vNormal);
|
|
279
|
+
${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
|
|
253
280
|
vec4 base = uColor;
|
|
254
281
|
${
|
|
255
282
|
map
|
|
@@ -262,6 +289,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
|
|
|
262
289
|
: ""
|
|
263
290
|
}
|
|
264
291
|
${vertexColors ? "base *= vColor;" : ""}
|
|
292
|
+
${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
|
|
265
293
|
vec3 v = normalize(uCamPos - vWorldPos);
|
|
266
294
|
vec3 light = hemisphere(n, uHemiSky, uHemiGround);
|
|
267
295
|
vec3 spec = vec3(0.0);
|
|
@@ -274,7 +302,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
|
|
|
274
302
|
light += uLightColor[i] * lambert(n, l) * s;
|
|
275
303
|
spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
|
|
276
304
|
}
|
|
277
|
-
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"});
|
|
278
306
|
}
|
|
279
307
|
`
|
|
280
308
|
}
|
|
@@ -284,8 +312,8 @@ let litClasses = new Map<string, ShaderMaterialClass>()
|
|
|
284
312
|
/**
|
|
285
313
|
* A lit material: hemisphere ambient plus the scene's directional lights
|
|
286
314
|
* (DirectionalLight nodes), Lambert diffuse, optional
|
|
287
|
-
* Blinn-Phong highlight. Same options as unlit (color, map, transparent
|
|
288
|
-
* 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
|
|
289
317
|
* per option combination, one pipeline per vertex layout met, shared by
|
|
290
318
|
* every instance - a thousand lit meshes still share one pipeline. No
|
|
291
319
|
* lights set means black except for the hemisphere term, which also
|
|
@@ -296,26 +324,40 @@ export function lit(opts: LitOptions = {}): Material {
|
|
|
296
324
|
let a = color.length === 4 ? color[3] : 1
|
|
297
325
|
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
298
326
|
let map = opts.map !== undefined
|
|
299
|
-
let vertexColors = opts.vertexColors === true
|
|
300
327
|
let triplanar = map && opts.triplanar !== undefined
|
|
301
|
-
let
|
|
302
|
-
let
|
|
303
|
-
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)
|
|
304
340
|
let cls = litClasses.get(key)
|
|
305
341
|
if (cls === undefined) {
|
|
306
342
|
cls = shaderMaterialClass({
|
|
307
|
-
vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
|
|
308
|
-
fragment: litFragment(
|
|
309
|
-
transparent,
|
|
343
|
+
vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
|
|
344
|
+
fragment: litFragment(flags),
|
|
345
|
+
transparent: flags.transparent,
|
|
346
|
+
cull,
|
|
310
347
|
label: "scene-lit-" + key,
|
|
311
348
|
})
|
|
312
349
|
litClasses.set(key, cls)
|
|
313
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
|
|
314
357
|
let material = cls.instance({
|
|
315
|
-
params
|
|
316
|
-
? { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30, uTriplanar: opts.triplanar! }
|
|
317
|
-
: { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 },
|
|
358
|
+
params,
|
|
318
359
|
textures: map ? { uMap: opts.map! } : undefined,
|
|
360
|
+
shadow: cutout ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
|
|
319
361
|
})
|
|
320
362
|
return material
|
|
321
363
|
}
|
|
@@ -340,20 +382,79 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
|
|
|
340
382
|
}
|
|
341
383
|
`
|
|
342
384
|
|
|
343
|
-
let shadowDepth
|
|
385
|
+
let shadowDepth = new Map<CullMode, Material>()
|
|
344
386
|
|
|
345
387
|
/** The override material of a scene's shadow view (internal): one class
|
|
346
|
-
* for the app, built on first use.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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({
|
|
350
395
|
vertex: SHADOW_DEPTH_VERTEX,
|
|
351
396
|
fragment: SHADOW_DEPTH_FRAGMENT,
|
|
352
|
-
cull
|
|
353
|
-
label: "scene-shadow-depth",
|
|
397
|
+
cull,
|
|
398
|
+
label: "scene-shadow-depth-" + cull,
|
|
354
399
|
}).instance()
|
|
400
|
+
shadowDepth.set(cull, material)
|
|
355
401
|
}
|
|
356
|
-
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 } })
|
|
357
458
|
}
|
|
358
459
|
|
|
359
460
|
export type SpriteOptions = UnlitOptions & {
|
|
@@ -435,7 +536,7 @@ export function sprite(opts: SpriteOptions = {}): Material {
|
|
|
435
536
|
if (cls === undefined) {
|
|
436
537
|
cls = shaderMaterialClass({
|
|
437
538
|
vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
|
|
438
|
-
fragment: map
|
|
539
|
+
fragment: unlitFragment(map, false, transparent),
|
|
439
540
|
transparent,
|
|
440
541
|
cull: "none",
|
|
441
542
|
label: "scene-sprite-" + key,
|
|
@@ -563,6 +664,10 @@ export type ShaderMaterialInstanceOptions = {
|
|
|
563
664
|
* setMeshParams. */
|
|
564
665
|
params?: ShaderParams
|
|
565
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
|
|
566
671
|
}
|
|
567
672
|
|
|
568
673
|
export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
|
|
@@ -609,6 +714,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
609
714
|
let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
|
|
610
715
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
611
716
|
let depth = opts.depth ?? true
|
|
717
|
+
let cull = opts.cull ?? "back"
|
|
612
718
|
// An empty list declares nothing - same as absent (the engine requires an
|
|
613
719
|
// instance buffer exactly when attributes are declared).
|
|
614
720
|
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
@@ -639,7 +745,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
639
745
|
// only applies when there is one.
|
|
640
746
|
depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
|
|
641
747
|
blend: opts.blend ?? (transparent ? "alpha" : undefined),
|
|
642
|
-
cull
|
|
748
|
+
cull,
|
|
643
749
|
topology: opts.topology,
|
|
644
750
|
label: opts.label,
|
|
645
751
|
})
|
|
@@ -649,7 +755,20 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
649
755
|
}
|
|
650
756
|
return {
|
|
651
757
|
instance(inst = {}) {
|
|
652
|
-
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
|
+
}
|
|
653
772
|
},
|
|
654
773
|
dispose() {
|
|
655
774
|
for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
|
package/src/math.ts
CHANGED
|
@@ -648,3 +648,79 @@ export function rayBoxDistance(
|
|
|
648
648
|
}
|
|
649
649
|
return tFar >= tNear ? tNear : -1
|
|
650
650
|
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The far bound of slice `index` of `count` when a range near..far is
|
|
654
|
+
* split for shadow cascades: `lambda` 0 slices it uniformly, 1
|
|
655
|
+
* logarithmically (equal texel density per unit of view depth, which
|
|
656
|
+
* starves the far slices), between the two in between. The last slice
|
|
657
|
+
* ends at `far`; a near of 0 has no logarithm and slices uniformly.
|
|
658
|
+
*/
|
|
659
|
+
export function cascadeSplit(near: number, far: number, index: number, count: number, lambda: number): number {
|
|
660
|
+
if (index >= count - 1) return far
|
|
661
|
+
let t = (index + 1) / count
|
|
662
|
+
let uniform = near + (far - near) * t
|
|
663
|
+
if (!(near > 0)) return uniform
|
|
664
|
+
let log = near * Math.pow(far / near, t)
|
|
665
|
+
return uniform + (log - uniform) * lambda
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The camera facts a frustum slice depends on: its view matrix (rows are
|
|
669
|
+
* its right, up and back axes), eye, vertical fov in degrees and, for an
|
|
670
|
+
* orthographic camera, the extents (fov ignored then). */
|
|
671
|
+
export type FrustumSpec = { view: Mat4; eye: Vec3; fov: number; ortho: { left: number; right: number; top: number; bottom: number } | null }
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The bounding sphere of the slice zn..zf of a camera's view frustum
|
|
675
|
+
* (`aspect` = width / height): writes the centre to `out`, returns the
|
|
676
|
+
* radius. Perspective: the centre sits on the view axis where the near
|
|
677
|
+
* and far corner rings are equidistant, clamped into the slice, so it
|
|
678
|
+
* is the tightest sphere on the axis; orthographic: the slice box's
|
|
679
|
+
* centre and half-diagonal. A sphere rather than the slice's own corners
|
|
680
|
+
* so a shadow box fitted to it keeps its size while the camera turns.
|
|
681
|
+
*/
|
|
682
|
+
export function frustumSliceSphere(out: Vec3, cam: FrustumSpec, aspect: number, zn: number, zf: number): number {
|
|
683
|
+
let v = cam.view
|
|
684
|
+
let fx = -v[2]
|
|
685
|
+
let fy = -v[6]
|
|
686
|
+
let fz = -v[10]
|
|
687
|
+
let o = cam.ortho
|
|
688
|
+
if (o === null) {
|
|
689
|
+
// Corner distance from the axis per unit of depth.
|
|
690
|
+
let k = Math.tan((cam.fov * Math.PI) / 360) * Math.hypot(1, aspect)
|
|
691
|
+
let rn = zn * k
|
|
692
|
+
let rf = zf * k
|
|
693
|
+
let zc = zf > zn ? Math.min(zf, Math.max(zn, (zf * zf + rf * rf - zn * zn - rn * rn) / (2 * (zf - zn)))) : zn
|
|
694
|
+
out[0] = cam.eye[0] + fx * zc
|
|
695
|
+
out[1] = cam.eye[1] + fy * zc
|
|
696
|
+
out[2] = cam.eye[2] + fz * zc
|
|
697
|
+
return Math.hypot(zf - zc, rf)
|
|
698
|
+
}
|
|
699
|
+
let zc = 0.5 * (zn + zf)
|
|
700
|
+
let cx = 0.5 * (o.left + o.right)
|
|
701
|
+
let cy = 0.5 * (o.top + o.bottom)
|
|
702
|
+
out[0] = cam.eye[0] + fx * zc + v[0] * cx + v[1] * cy
|
|
703
|
+
out[1] = cam.eye[1] + fy * zc + v[4] * cx + v[5] * cy
|
|
704
|
+
out[2] = cam.eye[2] + fz * zc + v[8] * cx + v[9] * cy
|
|
705
|
+
return Math.hypot(0.5 * (o.right - o.left), 0.5 * (o.top - o.bottom), 0.5 * (zf - zn))
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Snap `p`'s coordinates along the first two axes of `basis` (a rotation
|
|
710
|
+
* matrix whose rows are the frame's axes - `lookAt([0, 0, 0], dir, up)`
|
|
711
|
+
* for a light) to multiples of `step`, leaving the third as it is;
|
|
712
|
+
* writes to `out`, which may be `p`. A shadow box centred on the result
|
|
713
|
+
* moves by whole texels only, so its shadows do not swim as the camera
|
|
714
|
+
* creeps.
|
|
715
|
+
*/
|
|
716
|
+
export function snapToGrid(out: Vec3, p: Vec3, basis: Mat4, step: number): Vec3 {
|
|
717
|
+
let x = basis[0] * p[0] + basis[4] * p[1] + basis[8] * p[2]
|
|
718
|
+
let y = basis[1] * p[0] + basis[5] * p[1] + basis[9] * p[2]
|
|
719
|
+
let z = basis[2] * p[0] + basis[6] * p[1] + basis[10] * p[2]
|
|
720
|
+
x = Math.round(x / step) * step
|
|
721
|
+
y = Math.round(y / step) * step
|
|
722
|
+
out[0] = basis[0] * x + basis[1] * y + basis[2] * z
|
|
723
|
+
out[1] = basis[4] * x + basis[5] * y + basis[6] * z
|
|
724
|
+
out[2] = basis[8] * x + basis[9] * y + basis[10] * z
|
|
725
|
+
return out
|
|
726
|
+
}
|
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
|