@solidrt/3d 0.0.70 → 0.0.72

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/src/geometry.ts CHANGED
@@ -40,7 +40,8 @@
40
40
  // array math (tests/geometry.test.ts runs it headless on
41
41
  // flux). The GPU buffer step lives in geometry-gpu.ts.
42
42
 
43
- import type { Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"
43
+ import { attributeAccess as coreAccess, checkLayout as coreCheck, isFloatFormat, isFloatLayout as coreIsFloat, layoutBytes, layoutKey as coreKey, layoutSlot as coreSlot, layoutStride as coreStride, layoutView, VERTEX_FORMATS } from "@solidrt/core/gpu"
44
+ import type { AttributeAccess, Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"
44
45
  import { premultipliedColor } from "./color.ts"
45
46
  import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
46
47
  import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
@@ -78,144 +79,15 @@ export const VERTEX_LAYOUTS: Record<"base" | "colored" | "skinned", VertexAttrib
78
79
  * format before packing). */
79
80
  export const BASE_FLOATS = 8
80
81
 
81
- /** The shader `in` family a vertex format feeds (WebGPU's rule: the
82
- * format decides): "float" for the float and normalized formats (`in
83
- * vec*`), "uint" for uint* (`in uvec*`), "sint" for sint* (`in ivec*`). */
84
- export type FormatKind = "float" | "uint" | "sint"
85
-
86
- /** One vertex format's codec: its size, its component count, its kind,
87
- * and the read and write of component `k` of an attribute at byte `at`
88
- * of a DataView, in the value the shader sees (a normalized integer
89
- * decodes to 0..1 / -1..1, an unnormalized one to its exact value).
90
- * Every format is a multiple of 4 bytes (WebGPU's alignment rule), which
91
- * is what keeps every offset and stride 4-aligned with no padding
92
- * arithmetic anywhere. */
93
- export type FormatCodec = {
94
- bytes: number
95
- components: number
96
- kind: FormatKind
97
- get(dv: DataView, at: number, k: number): number
98
- set(dv: DataView, at: number, k: number, v: number): void
99
- }
100
-
101
- // The integer component kinds behind the packed formats: width, the
102
- // value that maps to 1 (GL ES 3.0's normalization rule, -max mapping to
103
- // -1 and the one value below it clamped there), and the raw accessors.
104
- type IntKind = { bytes: number; max: number; get(dv: DataView, at: number): number; set(dv: DataView, at: number, v: number): void }
105
- const U8: IntKind = { bytes: 1, max: 255, get: (dv, at) => dv.getUint8(at), set: (dv, at, v) => dv.setUint8(at, v) }
106
- const S8: IntKind = { bytes: 1, max: 127, get: (dv, at) => dv.getInt8(at), set: (dv, at, v) => dv.setInt8(at, v) }
107
- const U16: IntKind = { bytes: 2, max: 65535, get: (dv, at) => dv.getUint16(at, true), set: (dv, at, v) => dv.setUint16(at, v, true) }
108
- const S16: IntKind = { bytes: 2, max: 32767, get: (dv, at) => dv.getInt16(at, true), set: (dv, at, v) => dv.setInt16(at, v, true) }
109
- const U32: IntKind = { bytes: 4, max: 4294967295, get: (dv, at) => dv.getUint32(at, true), set: (dv, at, v) => dv.setUint32(at, v, true) }
110
- const S32: IntKind = { bytes: 4, max: 2147483647, get: (dv, at) => dv.getInt32(at, true), set: (dv, at, v) => dv.setInt32(at, v, true) }
111
-
112
- function clamp(v: number, lo: number, hi: number): number {
113
- return v < lo ? lo : v > hi ? hi : v
114
- }
115
-
116
- function floatCodec(components: number, bytes: number, get: (dv: DataView, at: number) => number, set: (dv: DataView, at: number, v: number) => void): FormatCodec {
117
- return {
118
- bytes: components * bytes,
119
- components,
120
- kind: "float",
121
- get: (dv, at, k) => get(dv, at + k * bytes),
122
- set: (dv, at, k, v) => set(dv, at + k * bytes, v),
123
- }
124
- }
125
-
126
- function unormCodec(components: number, int: IntKind): FormatCodec {
127
- return {
128
- bytes: components * int.bytes,
129
- components,
130
- kind: "float",
131
- get: (dv, at, k) => int.get(dv, at + k * int.bytes) / int.max,
132
- set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, 1) * int.max)),
133
- }
134
- }
135
-
136
- function snormCodec(components: number, int: IntKind): FormatCodec {
137
- return {
138
- bytes: components * int.bytes,
139
- components,
140
- kind: "float",
141
- get: (dv, at, k) => Math.max(int.get(dv, at + k * int.bytes) / int.max, -1),
142
- set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -1, 1) * int.max)),
143
- }
144
- }
145
-
146
- function uintCodec(components: number, int: IntKind): FormatCodec {
147
- return {
148
- bytes: components * int.bytes,
149
- components,
150
- kind: "uint",
151
- get: (dv, at, k) => int.get(dv, at + k * int.bytes),
152
- set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, int.max))),
153
- }
154
- }
155
-
156
- function sintCodec(components: number, int: IntKind): FormatCodec {
157
- return {
158
- bytes: components * int.bytes,
159
- components,
160
- kind: "sint",
161
- get: (dv, at, k) => int.get(dv, at + k * int.bytes),
162
- set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -int.max - 1, int.max))),
163
- }
164
- }
165
-
166
- let getF32 = (dv: DataView, at: number) => dv.getFloat32(at, true)
167
- let setF32 = (dv: DataView, at: number, v: number) => dv.setFloat32(at, v, true)
168
- let getF16 = (dv: DataView, at: number) => dv.getFloat16(at, true)
169
- let setF16 = (dv: DataView, at: number, v: number) => dv.setFloat16(at, v, true)
170
-
171
- /** The vertex vocabulary: one codec per format (the engine's table in
172
- * the same spelling), so it is also the list a declared format must be
173
- * one of. A format feeds the shader `in` of its kind and component
174
- * count (`formatFeeds`). */
175
- export const VERTEX_FORMATS: Record<VertexFormat, FormatCodec> = {
176
- float32: floatCodec(1, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
177
- float32x2: floatCodec(2, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
178
- float32x3: floatCodec(3, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
179
- float32x4: floatCodec(4, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
180
- float16x2: floatCodec(2, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
181
- float16x4: floatCodec(4, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
182
- unorm8x4: unormCodec(4, U8),
183
- snorm8x4: snormCodec(4, S8),
184
- unorm16x2: unormCodec(2, U16),
185
- unorm16x4: unormCodec(4, U16),
186
- snorm16x2: snormCodec(2, S16),
187
- snorm16x4: snormCodec(4, S16),
188
- uint8x4: uintCodec(4, U8),
189
- uint16x2: uintCodec(2, U16),
190
- uint16x4: uintCodec(4, U16),
191
- uint32: uintCodec(1, U32),
192
- uint32x2: uintCodec(2, U32),
193
- uint32x3: uintCodec(3, U32),
194
- uint32x4: uintCodec(4, U32),
195
- sint8x4: sintCodec(4, S8),
196
- sint16x2: sintCodec(2, S16),
197
- sint16x4: sintCodec(4, S16),
198
- sint32: sintCodec(1, S32),
199
- sint32x2: sintCodec(2, S32),
200
- sint32x3: sintCodec(3, S32),
201
- sint32x4: sintCodec(4, S32),
202
- }
203
-
204
- /** Whether a layout declaring `declared` may feed a program input the
205
- * engine reflects as `reflected` (float32xN for `vec*`, uint32xN for
206
- * `uvec*`, sint32xN for `ivec*`): same component count and same kind,
207
- * the engine's own pipeline rule. */
208
- export function formatFeeds(declared: VertexFormat, reflected: VertexFormat): boolean {
209
- let d = VERTEX_FORMATS[declared]
210
- let r = VERTEX_FORMATS[reflected]
211
- return d.components === r.components && d.kind === r.kind
212
- }
213
-
214
- /** Whether a format is one of the float32 family: what a Float32Array
215
- * holds directly, and what the generators write. */
216
- export function isFloatFormat(format: VertexFormat): boolean {
217
- return format.startsWith("float32")
218
- }
82
+ // The vertex vocabulary - the format codecs, the stride and key of an
83
+ // attribute list, the record accessors - is core's (`@solidrt/core/gpu`,
84
+ // vertex.ts), shared with the 2d package's material style streams;
85
+ // re-exported here so the package's own modules and callers keep one
86
+ // import for layouts. What stays here is the PRESET side: the named
87
+ // layouts and the functions taking a VertexLayout (a preset name or a
88
+ // list).
89
+ export { formatFeeds, isFloatFormat, VERTEX_FORMATS } from "@solidrt/core/gpu"
90
+ export type { AttributeAccess, FormatCodec, FormatKind } from "@solidrt/core/gpu"
219
91
 
220
92
  /** The attribute list of a layout (a preset name resolves to its list). */
221
93
  export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
@@ -227,64 +99,48 @@ export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
227
99
 
228
100
  /** Bytes per vertex of a layout - its interleave stride. */
229
101
  export function layoutStride(layout?: VertexLayout): number {
230
- let stride = 0
231
- for (let attr of layoutAttributes(layout)) stride += VERTEX_FORMATS[attr.format].bytes
232
- return stride
102
+ return coreStride(layoutAttributes(layout))
233
103
  }
234
104
 
235
105
  /** A layout's identity as a string (name:format per attribute, in order):
236
106
  * two layouts with equal keys interleave identically. */
237
107
  export function layoutKey(layout?: VertexLayout): string {
238
- return layoutAttributes(layout)
239
- .map(a => a.name + ":" + a.format)
240
- .join(",")
108
+ return coreKey(layoutAttributes(layout))
241
109
  }
242
110
 
243
111
  /** Where an attribute sits in the interleave: its byte offset, format
244
112
  * and component count. Null when the layout does not carry that name. */
245
113
  export function layoutSlot(layout: VertexLayout | undefined, name: string): { offset: number; format: VertexFormat; components: number } | null {
246
- let offset = 0
247
- for (let attr of layoutAttributes(layout)) {
248
- let codec = VERTEX_FORMATS[attr.format]
249
- if (attr.name === name) return { offset, format: attr.format, components: codec.components }
250
- offset += codec.bytes
251
- }
252
- return null
114
+ return coreSlot(layoutAttributes(layout), name)
253
115
  }
254
116
 
255
117
  /** Whether every attribute of a layout is float32-family: such a buffer
256
118
  * is a Float32Array of `layoutStride / 4` floats per vertex, the
257
119
  * generators' write form. */
258
120
  export function isFloatLayout(layout?: VertexLayout): boolean {
259
- return layoutAttributes(layout).every(a => isFloatFormat(a.format))
121
+ return coreIsFloat(layoutAttributes(layout))
260
122
  }
261
123
 
262
- /** The check every layout must pass: aPos float32x3 first and no
263
- * duplicate names. */
124
+ /** The check every layout must pass: aPos float32x3 first, known
125
+ * formats and no duplicate names. */
264
126
  function checkLayout(layout: VertexAttribute[], where: string): void {
265
127
  let first = layout[0]
266
128
  if (first === undefined || first.name !== "aPos" || first.format !== "float32x3") {
267
129
  throw new Error(where + ": a layout must start with aPos float32x3")
268
130
  }
269
- let seen = new Set<string>()
270
- for (let attr of layout) {
271
- if (seen.has(attr.name)) throw new Error(where + ": duplicate attribute '" + attr.name + "'")
272
- seen.add(attr.name)
273
- }
131
+ coreCheck(layout, where)
274
132
  }
275
133
 
276
134
  /** The bytes of a vertex buffer, whatever view holds them. */
277
135
  export function vertexBytes(vertices: ArrayBufferView): Uint8Array {
278
- return new Uint8Array(vertices.buffer, vertices.byteOffset, vertices.byteLength)
136
+ return layoutBytes(vertices)
279
137
  }
280
138
 
281
139
  /** The view a vertex buffer of `layout` is handed out as: a Float32Array
282
140
  * over an all-float layout (the generators' form, indexable as floats),
283
141
  * a Uint8Array over a layout with a packed channel. */
284
142
  export function vertexView(layout: VertexLayout | undefined, buffer: ArrayBuffer, byteOffset = 0, byteLength = buffer.byteLength - byteOffset): ArrayBufferView {
285
- return isFloatLayout(layout)
286
- ? new Float32Array(buffer, byteOffset, byteLength / Float32Array.BYTES_PER_ELEMENT)
287
- : new Uint8Array(buffer, byteOffset, byteLength)
143
+ return layoutView(layoutAttributes(layout), buffer, byteOffset, byteLength)
288
144
  }
289
145
 
290
146
  /** Vertices in a buffer of `layout`. Throws naming `where` when the byte
@@ -302,32 +158,11 @@ export function vertexCount(vertices: ArrayBufferView, layout: VertexLayout | un
302
158
  return vertices.byteLength / stride
303
159
  }
304
160
 
305
- /** A view on one attribute of a vertex buffer: component `k` of vertex
306
- * `i`, read and written as the float the shader sees, through the
307
- * format's codec. The one way anything touches vertex data, so no
308
- * caller knows an offset, a stride or a format. */
309
- export type AttributeAccess = {
310
- format: VertexFormat
311
- components: number
312
- get(i: number, k: number): number
313
- set(i: number, k: number, v: number): void
314
- }
315
-
316
161
  /** The accessor for `name` over a bare vertex buffer of `layout`; null
317
- * when the layout lacks the name. */
162
+ * when the layout lacks the name (core's attributeAccess over the
163
+ * resolved list). */
318
164
  export function attributeAccess(vertices: ArrayBufferView, layout: VertexLayout | undefined, name: string): AttributeAccess | null {
319
- let slot = layoutSlot(layout, name)
320
- if (slot === null) return null
321
- let stride = layoutStride(layout)
322
- let codec = VERTEX_FORMATS[slot.format]
323
- let offset = slot.offset
324
- let dv = new DataView(vertices.buffer, vertices.byteOffset, vertices.byteLength)
325
- return {
326
- format: slot.format,
327
- components: codec.components,
328
- get: (i, k) => codec.get(dv, i * stride + offset, k),
329
- set: (i, k, v) => codec.set(dv, i * stride + offset, k, v),
330
- }
165
+ return coreAccess(vertices, layoutAttributes(layout), name)
331
166
  }
332
167
 
333
168
  /** One extra vertex buffer of a geometry (see `Geometry.streams`): its
package/src/glsl.ts CHANGED
@@ -1739,6 +1739,12 @@ export type UnlitSourceOptions = {
1739
1739
  /** Multiply the base by the per-instance `iColor` (implies `instanced`;
1740
1740
  * see the lit option): the one per-copy value an unlit fleet wants. */
1741
1741
  instanceColors?: boolean
1742
+ /** The sprite material's radial shape: declare `uniform float
1743
+ * uFalloff` and multiply the base (color and alpha, the premultiplied
1744
+ * rule) by `(1 - 2 * |vUv - 0.5|)^uFalloff` clamped over the quad's
1745
+ * inscribed disc, after the cutout and before the surface slot. Reads
1746
+ * vUv, so the fragment declares it with or without a map. */
1747
+ radial?: boolean
1742
1748
  /** GLSL spliced at file scope, before main. */
1743
1749
  prelude?: string
1744
1750
  /** GLSL declaring `void surface(inout Surface s)`, called with the base
@@ -1759,6 +1765,7 @@ type UnlitSource = {
1759
1765
  instanced: boolean
1760
1766
  instanceColors: boolean
1761
1767
  morph: boolean
1768
+ radial: boolean
1762
1769
  prelude: string
1763
1770
  surface: string
1764
1771
  }
@@ -1775,6 +1782,7 @@ function resolveUnlit(o: UnlitSourceOptions): UnlitSource {
1775
1782
  instanced: o.instanced === true || o.instanceColors === true,
1776
1783
  instanceColors: o.instanceColors === true,
1777
1784
  morph: o.morph === true,
1785
+ radial: o.radial === true,
1778
1786
  prelude: o.prelude ?? "",
1779
1787
  surface: o.surface ?? "",
1780
1788
  }
@@ -1837,20 +1845,21 @@ function unlitBase(c: UnlitSource): string {
1837
1845
  * does - vWorldPos is read whether or not the program fogs).
1838
1846
  *
1839
1847
  * Per-entry uniforms on instance(): `uColor` (linear light, premultiplied
1840
- * vec4), plus `uMap`/`uAlphaTest` for the options that declare them, plus
1841
- * whatever `prelude` declares.
1848
+ * vec4), plus `uMap`/`uAlphaTest`/`uFalloff` for the options that declare
1849
+ * them, plus whatever `prelude` declares.
1842
1850
  */
1843
1851
  export function unlitFragment(o: UnlitSourceOptions = {}): string {
1844
1852
  let c = resolveUnlit(o)
1845
1853
  let alpha = c.transparent ? "base.a" : "1.0"
1846
1854
  return glsl`
1847
- ${c.map ? "in vec2 vUv;" : ""}
1855
+ ${c.map || c.radial ? "in vec2 vUv;" : ""}
1848
1856
  in vec3 vWorldPos;
1849
1857
  ${c.vertexColors || c.instanceColors ? "in vec4 vColor;" : ""}
1850
1858
  ${c.map ? "uniform sampler2D uMap;" : ""}
1851
1859
  uniform vec4 uColor;
1852
1860
  ${c.alphaTest ? "uniform float uAlphaTest;" : ""}
1853
1861
  ${c.mapTransform ? "uniform vec4 uMapTransform;" : ""}
1862
+ ${c.radial ? "uniform float uFalloff;" : ""}
1854
1863
  ${sceneSource({ lights: false, fog: c.fog })}
1855
1864
  ${LOD_FADE}
1856
1865
  ${c.prelude}
@@ -1859,6 +1868,7 @@ export function unlitFragment(o: UnlitSourceOptions = {}): string {
1859
1868
  lodFade();
1860
1869
  ${unlitBase(c)}
1861
1870
  ${c.alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
1871
+ ${c.radial ? "base *= pow(clamp(1.0 - 2.0 * length(vUv - 0.5), 0.0, 1.0), uFalloff);" : ""}
1862
1872
  ${c.surface ? "Surface s = surfaceOf(base, vec3(0.0));\n surface(s);\n base = s.base;" : ""}
1863
1873
  fragColor = sceneOutput(base.rgb, ${alpha}, vWorldPos);
1864
1874
  }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, destroy, getMorphNames, getMorphWeights, getRotation, getTransform, lookAt, remove, setMorphWeights, setTransform, setTransition, setVisible, worldPosition } from "./node.ts"
8
+ export { add, createGroup, destroy, getMorphNames, getMorphWeights, getRotation, getTransform, lookAt, remove, setMorphWeights, setTimeScale, setTransform, setTransition, setVisible, timeRate, worldPosition } from "./node.ts"
9
9
  export type { HoverHandlers, LodConfig, MorphWeights, NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneEventBase, SceneNode, ScenePointerEvent, ScenePointerListener, SceneTapEvent, SceneWheelEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
10
10
  export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, setCastShadow, setCulling, setDrawRange, setEnvironment, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, instanceAttribute, records, updateRecords, INSTANCE_FLOATS } from "./mesh.ts"
11
11
  export type { InstancedMesh as InstancedMeshNode, InstancedMeshOptions, InstanceNode, InstanceOrderOptions, InstanceSlots, InstanceStream, Mesh as MeshNode, MeshInstances, RecordMesh as RecordMeshNode, RecordMeshOptions, UpdateRecordsOptions } from "./mesh.ts"
package/src/material.ts CHANGED
@@ -257,6 +257,19 @@ export type UnlitOptions = {
257
257
  * MultiMesh instance color. A cutout's shadow multiplies by the same
258
258
  * color, so alpha below `alphaTest` cuts the shadow with the pixels. */
259
259
  instanceColors?: boolean
260
+ /** GLSL at file scope for `surface`: uniforms (each an ordinary
261
+ * per-entry param, `params` on the mesh or setMeshParams) and helper
262
+ * functions. See the tier-2 look in AGENTS.md "Custom looks". */
263
+ prelude?: string
264
+ /** GLSL declaring `void surface(inout Surface s)`, called once the
265
+ * program has resolved the base (the color, the map, the vertex and
266
+ * instance colors, the cutout) and before the scene tail: rewrite
267
+ * `s.base` or `discard`. The unlit program shades nothing, so `base`
268
+ * is the only field read back and `s.normal` is zero. Runs in the
269
+ * shadow twin too (attached whenever a surface is given), so what it
270
+ * discards casts no shadow, the twin seeing a `prelude` uniform at its
271
+ * zero value. One program per distinct source, like every option. */
272
+ surface?: string
260
273
  }
261
274
 
262
275
  // The fog an unlit program composes: none when opted out, the additive
@@ -306,13 +319,15 @@ export function unlit(opts: UnlitOptions = {}): Material {
306
319
  let instanceColors = opts.instanceColors === true
307
320
  let instanced = opts.instanced === true || instanceColors
308
321
  let vertexColors = opts.vertexColors === true
322
+ let prelude = opts.prelude ?? ""
323
+ let surface = opts.surface ?? ""
309
324
  if (mapTransform && !map) throw new Error("unlit: mapTransform without a map to transform")
310
- let key = [map, vertexColors, transparent, blend, cull, alphaTest, fog, mapTransform, skinned, instanced, instanceColors, morph].join("|")
325
+ let key = [map, vertexColors, transparent, blend, cull, alphaTest, fog, mapTransform, skinned, instanced, instanceColors, morph, prelude, surface].join("|")
311
326
  let cls = unlitClasses.get(key)
312
327
  if (cls === undefined) {
313
328
  cls = shaderMaterialClass({
314
329
  vertex: unlitVertex({ vertexColors, skinned, instanced, instanceColors, morph }),
315
- fragment: unlitFragment({ map, vertexColors, alphaTest, transparent, fog, mapTransform, instanceColors }),
330
+ fragment: unlitFragment({ map, vertexColors, alphaTest, transparent, fog, mapTransform, instanceColors, prelude, surface }),
316
331
  shadowVertex: instanced ? shadowDepthVertex(skinned, true, morph) : undefined,
317
332
  instanceBuffers: stockInstanceBuffers(instanced, instanceColors),
318
333
  instanceStyle: instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
@@ -327,12 +342,21 @@ export function unlit(opts: UnlitOptions = {}): Material {
327
342
  let params: ShaderParams = { uColor }
328
343
  if (alphaTest) params.uAlphaTest = opts.alphaTest!
329
344
  if (mapTransform) params.uMapTransform = mapTransformParam(opts.mapTransform!)
345
+ // A mapped cutout casts its cutout and a surface function casts its
346
+ // discards (lit's rule); a color-only alphaTest is all or nothing and
347
+ // keeps the plain depth variant.
330
348
  return cls.instance({
331
349
  params,
332
350
  textures: map ? { uMap: opts.map! } : undefined,
333
351
  shadow:
334
- alphaTest && map
335
- ? unlitShadowMaterial(shadowCull(cull), vertexColors, skinned, instanced, instanceColors, morph, uColor, opts.alphaTest!, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
352
+ (alphaTest && map) || surface !== ""
353
+ ? unlitShadowMaterial(
354
+ { cull: shadowCull(cull), map, vertexColors, alphaTest: alphaTest && map, skinned, instanced, instanceColors, morph, mapTransform, prelude, surface },
355
+ uColor,
356
+ opts.alphaTest,
357
+ opts.map,
358
+ mapTransform ? (params.uMapTransform as number[]) : undefined,
359
+ )
336
360
  : undefined,
337
361
  })
338
362
  }
@@ -892,42 +916,59 @@ function shadowVariant(cull: CullMode, skinned: boolean, morphed: boolean): Mate
892
916
 
893
917
  let unlitShadowClasses = new Map<string, ShaderMaterialClass>()
894
918
 
919
+ /** The flags an unlit shadow twin is keyed and built on: the shadow
920
+ * cull, the source options the discard depends on, and the vertex
921
+ * stage's own (skinning, instancing, morphing, so the cutout casts its
922
+ * pose and placement). */
923
+ type UnlitShadowFlags = {
924
+ cull: CullMode
925
+ map: boolean
926
+ vertexColors: boolean
927
+ alphaTest: boolean
928
+ skinned: boolean
929
+ instanced: boolean
930
+ instanceColors: boolean
931
+ morph: boolean
932
+ mapTransform: boolean
933
+ prelude: string
934
+ surface: string
935
+ }
936
+
895
937
  /** The shadow variant of a discarding unlit material: unlitShadowFragment
896
938
  * on the same vertex stage (skinned when the material skins, so the
897
- * cutout casts its pose), litShadowMaterial's unlit twin. unlit()'s
898
- * only discard is the mapped cutout, so what varies is the shadow cull,
899
- * skinning and whether the cutout's uv is transformed - one class per
900
- * combination. An instance per material (its map, color, cutoff,
901
- * transform). */
902
- function unlitShadowMaterial(
903
- cull: CullMode,
904
- vertexColors: boolean,
905
- skinned: boolean,
906
- instanced: boolean,
907
- instanceColors: boolean,
908
- morph: boolean,
909
- uColor: number[],
910
- uAlphaTest: number,
911
- uMap: TextureId,
912
- uMapTransform?: number[],
913
- ): Material {
914
- let key = [cull, uMapTransform !== undefined, vertexColors, skinned, instanced, instanceColors, morph].join("|")
939
+ * cutout casts its pose), litShadowMaterial's unlit twin. unlit()
940
+ * discards through the mapped cutout and through a surface function,
941
+ * so what varies is the shadow cull, the source flags and the sources
942
+ * themselves - one class per combination. An instance per material (its
943
+ * map, color, cutoff, transform); a prelude uniform reaches the twin at
944
+ * its zero value, mesh params going to the main entry only. */
945
+ function unlitShadowMaterial(flags: UnlitShadowFlags, uColor: number[], uAlphaTest: number | undefined, uMap: TextureId | undefined, uMapTransform?: number[]): Material {
946
+ let key = [flags.cull, flags.map, flags.vertexColors, flags.alphaTest, flags.skinned, flags.instanced, flags.instanceColors, flags.morph, flags.mapTransform, flags.prelude, flags.surface].join("|")
915
947
  let cls = unlitShadowClasses.get(key)
916
948
  if (cls === undefined) {
917
- let fragment = unlitShadowFragment({ map: true, vertexColors, alphaTest: true, mapTransform: uMapTransform !== undefined, instanceColors })
918
- if (fragment === undefined) throw new Error("unlitShadowMaterial: the cutout options cannot discard")
949
+ let fragment = unlitShadowFragment({
950
+ map: flags.map,
951
+ vertexColors: flags.vertexColors,
952
+ alphaTest: flags.alphaTest,
953
+ mapTransform: flags.mapTransform,
954
+ instanceColors: flags.instanceColors,
955
+ prelude: flags.prelude,
956
+ surface: flags.surface,
957
+ })
958
+ if (fragment === undefined) throw new Error("unlitShadowMaterial: the options cannot discard")
919
959
  cls = shaderMaterialClass({
920
- vertex: unlitVertex({ vertexColors, skinned, instanced, instanceColors, morph }),
960
+ vertex: unlitVertex({ vertexColors: flags.vertexColors, skinned: flags.skinned, instanced: flags.instanced, instanceColors: flags.instanceColors, morph: flags.morph }),
921
961
  fragment,
922
- cull,
923
- instanceBuffers: stockInstanceBuffers(instanced, instanceColors),
962
+ cull: flags.cull,
963
+ instanceBuffers: stockInstanceBuffers(flags.instanced, flags.instanceColors),
924
964
  label: "scene-unlit-shadow-" + key,
925
965
  })
926
966
  unlitShadowClasses.set(key, cls)
927
967
  }
928
- let params: ShaderParams = { uColor, uAlphaTest }
968
+ let params: ShaderParams = { uColor }
969
+ if (flags.alphaTest) params.uAlphaTest = uAlphaTest!
929
970
  if (uMapTransform !== undefined) params.uMapTransform = uMapTransform
930
- return cls.instance({ params, textures: { uMap } })
971
+ return cls.instance({ params, textures: flags.map ? { uMap: uMap! } : undefined })
931
972
  }
932
973
 
933
974
  /** A sprite's options: unlit's, minus instancing (the billboard stage
@@ -974,19 +1015,6 @@ export type SpriteOptions = Omit<UnlitOptions, "instanced" | "instanceColors" |
974
1015
  maxScreenPx?: number
975
1016
  }
976
1017
 
977
- // The radial shape as a tier-2 surface on the unlit fragment: the
978
- // vertex stages always write vUv, which the fragment declares only with
979
- // a map, so the mapless prelude declares it itself.
980
- const RADIAL_PRELUDE = glsl`
981
- uniform float uFalloff;
982
- `
983
- const RADIAL_SURFACE = glsl`
984
- void surface(inout Surface s) {
985
- float disc = clamp(1.0 - 2.0 * length(vUv - 0.5), 0.0, 1.0);
986
- s.base *= pow(disc, uFalloff);
987
- }
988
- `
989
-
990
1018
  // The billboard vertex stages: the unit quad's corners placed along the
991
1019
  // camera axes at the mesh's world position, with the quad's size read
992
1020
  // off uModel's column lengths so `scale` sizes the sprite like any mesh,
@@ -1103,18 +1131,14 @@ export function sprite(opts: SpriteOptions = {}): Material {
1103
1131
  let minScreenPx = opts.minScreenPx ?? 0
1104
1132
  let maxScreenPx = opts.maxScreenPx ?? 0
1105
1133
  checkScreenSize("sprite", minScreenPx, maxScreenPx)
1106
- let key = [map, transparent, blend, fixedY, fog, radial].join("|")
1134
+ let prelude = opts.prelude ?? ""
1135
+ let surface = opts.surface ?? ""
1136
+ let key = [map, transparent, blend, fixedY, fog, radial, prelude, surface].join("|")
1107
1137
  let cls = spriteClasses.get(key)
1108
1138
  if (cls === undefined) {
1109
1139
  cls = shaderMaterialClass({
1110
1140
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
1111
- fragment: unlitFragment({
1112
- map,
1113
- transparent,
1114
- fog,
1115
- prelude: radial ? (map ? "" : "in vec2 vUv;\n") + RADIAL_PRELUDE : undefined,
1116
- surface: radial ? RADIAL_SURFACE : undefined,
1117
- }),
1141
+ fragment: unlitFragment({ map, transparent, fog, radial, prelude, surface }),
1118
1142
  transparent,
1119
1143
  blend,
1120
1144
  cull: "none",