@solidrt/3d 0.0.46 → 0.0.47

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
@@ -1,6 +1,11 @@
1
- // Geometry: interleaved vertex data in the one layout every scene material
2
- // shares - position vec3, normal vec3, uv vec2 (8 floats per vertex) - plus
3
- // uint16 indices. Winding is counter-clockwise seen from outside in the
1
+ // Geometry: interleaved vertex data in one of two named layouts - the
2
+ // "standard" position vec3, normal vec3, uv vec2 (8 floats per vertex)
3
+ // every generator emits, and "colored", which appends an aColor vec4
4
+ // (12 floats, derived with withColors) as the per-vertex data channel for
5
+ // custom materials (tint, baked AO, any four scalars) - plus
6
+ // indices, uint16 or uint32 (the generators here emit uint16; hand-built
7
+ // geometry past 64k vertices uses a Uint32Array and the draw entry follows
8
+ // the array type). Winding is counter-clockwise seen from outside in the
4
9
  // y-up world, which the standard camera rig (perspective() with its baked
5
10
  // y flip) presents as the engine's displayed-CCW front faces: every
6
11
  // generator here culls correctly with cull: "back". Normals ride along
@@ -14,29 +19,53 @@
14
19
  // disposeGeometry frees them when an app is done with a geometry for good.
15
20
 
16
21
  import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
17
- import type { BufferId, VertexAttribute } from "@solidrt/core/gpu"
22
+ import type { BufferId, IndexFormat, VertexAttribute } from "@solidrt/core/gpu"
18
23
  import { add, cross, normalize, sub } from "./math.ts"
19
- import type { Vec3 } from "./math.ts"
24
+ import type { Vec2, Vec3, Vec4 } from "./math.ts"
20
25
 
21
- export const VERTEX_LAYOUT: VertexAttribute[] = [
26
+ export type VertexLayout = "standard" | "colored"
27
+
28
+ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
22
29
  { name: "aPos", format: "vec3" },
23
30
  { name: "aNormal", format: "vec3" },
24
31
  { name: "aUV", format: "vec2" },
25
32
  ]
33
+
34
+ /** The pipeline attribute list for each named layout. A deliberately small
35
+ * set (not an open per-geometry model): every layout shares the standard
36
+ * prefix, so one shader vocabulary serves all of them. */
37
+ export const VERTEX_LAYOUTS: Record<VertexLayout, VertexAttribute[]> = {
38
+ standard: STANDARD_ATTRIBUTES,
39
+ colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
40
+ }
41
+
42
+ /** Floats per vertex in the "standard" layout (what every generator emits). */
26
43
  export const FLOATS_PER_VERTEX = 8
44
+ const COLORED_FLOATS = 12
27
45
 
28
46
  export type Geometry = {
29
- /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex. */
47
+ /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex, plus color.rgba
48
+ * in the "colored" layout. */
30
49
  vertices: Float32Array
31
- indices: Uint16Array
50
+ /** The array type picks the draw's index format: Uint32Array past 64k
51
+ * vertices. */
52
+ indices: Uint16Array | Uint32Array
53
+ /** Vertex layout; absent means "standard". Must match the material's
54
+ * layout - the scene rejects a mismatched pair at add(). */
55
+ layout?: VertexLayout
32
56
  /** Debug name for the lazily-created GPU buffers. */
33
57
  label?: string
34
58
  _buffer?: BufferId
35
59
  _index?: BufferId
36
60
  }
37
61
 
38
- /** The geometry's GPU buffers, created on first use and cached on it. */
39
- export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index: BufferId } {
62
+ /** The geometry's GPU buffers, created on first use and cached on it,
63
+ * plus the index format the draw entry must bind them with. */
64
+ export function geometryBuffers(geometry: Geometry): {
65
+ buffer: BufferId
66
+ index: BufferId
67
+ indexFormat: IndexFormat
68
+ } {
40
69
  let buffer = geometry._buffer
41
70
  let index = geometry._index
42
71
  if (buffer === undefined || index === undefined) {
@@ -51,7 +80,7 @@ export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index:
51
80
  geometry._buffer = buffer
52
81
  geometry._index = index
53
82
  }
54
- return { buffer, index }
83
+ return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
55
84
  }
56
85
 
57
86
  /**
@@ -66,6 +95,106 @@ export function disposeGeometry(geometry: Geometry): void {
66
95
  geometry._index = undefined
67
96
  }
68
97
 
98
+ /** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
99
+ * array, or a callback deriving each vertex's vec4 from the vertex data. */
100
+ export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
101
+
102
+ /**
103
+ * Derive a "colored"-layout geometry from a standard one: the same
104
+ * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
105
+ * the data channel for materials whose vertex stage reads `in vec4 aColor`
106
+ * (a tint, baked ambient occlusion, any four scalars; the name is the
107
+ * standard vocabulary, the contents are yours). The callback form receives
108
+ * each vertex's position, normal and uv - what a baker wants. The source
109
+ * geometry is untouched and its GPU buffers stay independent.
110
+ */
111
+ export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
112
+ if (geometry.layout === "colored") {
113
+ throw new Error("withColors: geometry already carries an aColor channel")
114
+ }
115
+ if (geometry.vertices.length % FLOATS_PER_VERTEX !== 0) {
116
+ throw new Error("withColors: vertex data is not a whole number of standard-layout vertices")
117
+ }
118
+ let count = geometry.vertices.length / FLOATS_PER_VERTEX
119
+ if (typeof fill !== "function" && fill.length !== count * 4) {
120
+ throw new Error("withColors: fill has " + fill.length + " floats, expected 4 per vertex (" + count * 4 + ")")
121
+ }
122
+ let src = geometry.vertices
123
+ let out = new Float32Array(count * COLORED_FLOATS)
124
+ for (let i = 0; i < count; i++) {
125
+ let s = i * FLOATS_PER_VERTEX
126
+ let d = i * COLORED_FLOATS
127
+ for (let k = 0; k < FLOATS_PER_VERTEX; k++) out[d + k] = src[s + k]!
128
+ }
129
+ fillColors(out, fill)
130
+ return {
131
+ vertices: out,
132
+ indices: geometry.indices,
133
+ layout: "colored",
134
+ label: label ?? (geometry.label ? geometry.label + "-colored" : undefined),
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The in-place primitive under withColors: write the aColor slots of a
140
+ * colored-layout interleave you already own - the hook for a merging
141
+ * builder baking colors over its packed buffer (the pos/normal/uv the
142
+ * callback receives are read from the buffer itself, so a packer that
143
+ * bakes transforms while writing hands the baker world-space vertices).
144
+ * Fills vertices [first, first + count) - count defaults to the rest of
145
+ * the buffer - and `fill` indexes relative to `first`, so a per-part
146
+ * callback works unchanged for both APIs. Returns `vertices`.
147
+ *
148
+ * This trusts the buffer to BE colored-layout data - a bare array carries
149
+ * no layout tag, so only the arithmetic is checked. The Geometry-level
150
+ * withColors stays the checked path.
151
+ */
152
+ export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, count?: number): Float32Array {
153
+ if (vertices.length % COLORED_FLOATS !== 0) {
154
+ throw new Error("fillColors: vertex data is not a whole number of colored-layout vertices")
155
+ }
156
+ let total = vertices.length / COLORED_FLOATS
157
+ let n = count ?? total - first
158
+ if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
159
+ throw new Error("fillColors: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
160
+ }
161
+ let fn = typeof fill === "function" ? fill : null
162
+ if (!fn && fill.length !== n * 4) {
163
+ throw new Error("fillColors: fill has " + fill.length + " floats, expected 4 per vertex (" + n * 4 + ")")
164
+ }
165
+ for (let i = 0; i < n; i++) {
166
+ let d = (first + i) * COLORED_FLOATS
167
+ let c: Vec4 = fn
168
+ ? fn(i, [vertices[d]!, vertices[d + 1]!, vertices[d + 2]!], [vertices[d + 3]!, vertices[d + 4]!, vertices[d + 5]!], [vertices[d + 6]!, vertices[d + 7]!])
169
+ : [(fill as ArrayLike<number>)[i * 4]!, (fill as ArrayLike<number>)[i * 4 + 1]!, (fill as ArrayLike<number>)[i * 4 + 2]!, (fill as ArrayLike<number>)[i * 4 + 3]!]
170
+ vertices[d + 8] = c[0]
171
+ vertices[d + 9] = c[1]
172
+ vertices[d + 10] = c[2]
173
+ vertices[d + 11] = c[3]
174
+ }
175
+ return vertices
176
+ }
177
+
178
+ // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
179
+ // CCW triangles per cell, split across the row0col0-row1col1 diagonal -
180
+ // the one quad pattern every grid generator here shares (rows run along
181
+ // the surface, columns around, same handedness everywhere). A collapsed
182
+ // first/last vertex row (sphere pole, cone apex) skips its zero-area
183
+ // triangle per cell.
184
+ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skipLast = false): number[] {
185
+ let cols = cellCols + 1
186
+ let out: number[] = []
187
+ for (let r = 0; r < cellRows; r++) {
188
+ for (let c = 0; c < cellCols; c++) {
189
+ let r0 = r * cols + c
190
+ let r1 = r0 + cols
191
+ if (!skipFirst || r > 0) out.push(r0 + 1, r0, r1 + 1)
192
+ if (!skipLast || r < cellRows - 1) out.push(r0, r1, r1 + 1)
193
+ }
194
+ }
195
+ return out
196
+ }
197
+
69
198
  /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
70
199
  export function box(width = 1, height = 1, depth = 1, label?: string): Geometry {
71
200
  let x = width / 2
@@ -174,30 +303,165 @@ export function torusKnot(
174
303
  }
175
304
  }
176
305
 
177
- let indices = new Uint16Array(tubularSegments * radialSegments * 6)
178
- let n = 0
179
- for (let i = 0; i < tubularSegments; i++) {
180
- for (let j = 0; j < radialSegments; j++) {
181
- let a = i * cols + j
182
- let b = (i + 1) * cols + j
183
- let c = (i + 1) * cols + j + 1
184
- let d = i * cols + j + 1
185
- indices[n++] = a
186
- indices[n++] = b
187
- indices[n++] = c
188
- indices[n++] = a
189
- indices[n++] = c
190
- indices[n++] = d
306
+ let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
307
+
308
+ return { vertices, indices, label }
309
+ }
310
+
311
+ /**
312
+ * A capped cylinder on the y axis, centered on the origin. Different top
313
+ * and bottom radii make it a truncated cone (`cone()` is the zero-top
314
+ * case); side normals tilt with the taper. Side UVs: u around the
315
+ * circumference, v 0 at the top to 1 at the bottom; caps get a planar
316
+ * disc map. A zero radius skips that cap and the degenerate side
317
+ * triangles at the apex.
318
+ */
319
+ export function cylinder(
320
+ radiusTop = 0.5,
321
+ radiusBottom = 0.5,
322
+ height = 1,
323
+ radialSegments = 24,
324
+ label?: string,
325
+ ): Geometry {
326
+ let h = height / 2
327
+ let cols = radialSegments + 1
328
+ let verts: number[] = []
329
+ // Side normal: perpendicular to the slant line in the (radial, y) plane.
330
+ let slant = Math.hypot(height, radiusBottom - radiusTop) || 1
331
+ let nr = height / slant
332
+ let ny = (radiusBottom - radiusTop) / slant
333
+ let rows = [
334
+ { r: radiusTop, y: h, v: 0 },
335
+ { r: radiusBottom, y: -h, v: 1 },
336
+ ]
337
+ for (let row of rows) {
338
+ for (let ix = 0; ix < cols; ix++) {
339
+ let u = ix / radialSegments
340
+ let phi = u * Math.PI * 2
341
+ let dx = -Math.cos(phi)
342
+ let dz = Math.sin(phi)
343
+ verts.push(row.r * dx, row.y, row.r * dz, nr * dx, ny, nr * dz, u, row.v)
344
+ }
345
+ }
346
+ let indices = gridIndices(1, radialSegments, radiusTop <= 0, radiusBottom <= 0)
347
+ // Caps fan around a center vertex; the planar UV map has no seam, so the
348
+ // ring wraps with modulo instead of duplicating a column.
349
+ let cap = (r: number, y: number, up: number) => {
350
+ let base = verts.length / FLOATS_PER_VERTEX
351
+ verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
352
+ for (let i = 0; i < radialSegments; i++) {
353
+ let phi = (i / radialSegments) * Math.PI * 2
354
+ let x = -Math.cos(phi) * r
355
+ let z = Math.sin(phi) * r
356
+ verts.push(x, y, z, 0, up, 0, 0.5 + x / (2 * r), 0.5 + (up > 0 ? z : -z) / (2 * r))
357
+ }
358
+ for (let i = 0; i < radialSegments; i++) {
359
+ let j = (i + 1) % radialSegments
360
+ if (up > 0) indices.push(base, base + 1 + i, base + 1 + j)
361
+ else indices.push(base, base + 1 + j, base + 1 + i)
191
362
  }
192
363
  }
364
+ if (radiusTop > 0) cap(radiusTop, h, 1)
365
+ if (radiusBottom > 0) cap(radiusBottom, -h, -1)
366
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
367
+ }
193
368
 
369
+ /** A capped cone on the y axis, centered on the origin: `cylinder()` with
370
+ * a zero top radius (each apex vertex carries its column's side normal, so
371
+ * the surface shades smoothly around). */
372
+ export function cone(radius = 0.5, height = 1, radialSegments = 24, label?: string): Geometry {
373
+ return cylinder(0, radius, height, radialSegments, label)
374
+ }
375
+
376
+ /**
377
+ * A torus lying flat, centered on the origin: the ring lies in the XZ
378
+ * plane with the hole on the y axis - the y-up orientation torusKnot also
379
+ * uses (Three's equivalent stands in XY). Signature order is Three's:
380
+ * radialSegments subdivides the tube cross-section, tubularSegments the
381
+ * ring. UVs: u 0..1 around the ring, v 0..1 around the tube, seam
382
+ * row/column duplicated like torusKnot.
383
+ */
384
+ export function torus(
385
+ radius = 0.5,
386
+ tube = 0.2,
387
+ radialSegments = 12,
388
+ tubularSegments = 32,
389
+ label?: string,
390
+ ): Geometry {
391
+ let rows = tubularSegments + 1
392
+ let cols = radialSegments + 1
393
+ let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
394
+ let at = 0
395
+ for (let i = 0; i < rows; i++) {
396
+ let phi = (i / tubularSegments) * Math.PI * 2
397
+ let dx = -Math.cos(phi)
398
+ let dz = Math.sin(phi)
399
+ for (let j = 0; j < cols; j++) {
400
+ let psi = (j / radialSegments) * Math.PI * 2
401
+ let cp = Math.cos(psi)
402
+ let sp = Math.sin(psi)
403
+ let r = radius + tube * cp
404
+ vertices[at] = r * dx
405
+ vertices[at + 1] = tube * sp
406
+ vertices[at + 2] = r * dz
407
+ vertices[at + 3] = cp * dx
408
+ vertices[at + 4] = sp
409
+ vertices[at + 5] = cp * dz
410
+ vertices[at + 6] = i / tubularSegments
411
+ vertices[at + 7] = j / radialSegments
412
+ at += FLOATS_PER_VERTEX
413
+ }
414
+ }
415
+ let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
194
416
  return { vertices, indices, label }
195
417
  }
196
418
 
419
+ /**
420
+ * A disc in the XY plane facing +z, centered on the origin (rotate flat
421
+ * like plane()). UVs are the planar map of the disc inscribed in the unit
422
+ * square.
423
+ */
424
+ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
425
+ let verts: number[] = [0, 0, 0, 0, 0, 1, 0.5, 0.5]
426
+ let indices: number[] = []
427
+ for (let i = 0; i < segments; i++) {
428
+ let a = (i / segments) * Math.PI * 2
429
+ let c = Math.cos(a)
430
+ let s = Math.sin(a)
431
+ verts.push(radius * c, radius * s, 0, 0, 0, 1, 0.5 + c * 0.5, 0.5 - s * 0.5)
432
+ }
433
+ for (let i = 0; i < segments; i++) {
434
+ indices.push(0, 1 + i, 1 + ((i + 1) % segments))
435
+ }
436
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
437
+ }
438
+
439
+ /**
440
+ * A flat annulus in the XY plane facing +z, centered on the origin. UVs
441
+ * are the planar map of the OUTER disc, so a ring textures like the
442
+ * matching circle() with the middle cut out.
443
+ */
444
+ export function ring(innerRadius = 0.25, outerRadius = 0.5, segments = 32, label?: string): Geometry {
445
+ let verts: number[] = []
446
+ let indices: number[] = []
447
+ for (let i = 0; i < segments; i++) {
448
+ let a = (i / segments) * Math.PI * 2
449
+ let c = Math.cos(a)
450
+ let s = Math.sin(a)
451
+ for (let r of [innerRadius, outerRadius]) {
452
+ verts.push(r * c, r * s, 0, 0, 0, 1, 0.5 + (r * c) / (2 * outerRadius), 0.5 - (r * s) / (2 * outerRadius))
453
+ }
454
+ }
455
+ for (let i = 0; i < segments; i++) {
456
+ let j = (i + 1) % segments
457
+ indices.push(i * 2, i * 2 + 1, j * 2 + 1, i * 2, j * 2 + 1, j * 2)
458
+ }
459
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
460
+ }
461
+
197
462
  /** A UV sphere centered on the origin (poles on the y axis). */
198
463
  export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, label?: string): Geometry {
199
464
  let verts: number[] = []
200
- let indices: number[] = []
201
465
  for (let iy = 0; iy <= heightSegments; iy++) {
202
466
  let v = iy / heightSegments
203
467
  let theta = v * Math.PI
@@ -212,16 +476,7 @@ export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, la
212
476
  verts.push(radius * nx, radius * ny, radius * nz, nx, ny, nz, u, v)
213
477
  }
214
478
  }
215
- let cols = widthSegments + 1
216
- for (let iy = 0; iy < heightSegments; iy++) {
217
- for (let ix = 0; ix < widthSegments; ix++) {
218
- let a = iy * cols + ix + 1
219
- let b = iy * cols + ix
220
- let c = (iy + 1) * cols + ix
221
- let d = (iy + 1) * cols + ix + 1
222
- if (iy !== 0) indices.push(a, b, d)
223
- if (iy !== heightSegments - 1) indices.push(b, c, d)
224
- }
225
- }
479
+ // Both pole rows are collapsed to the pole point.
480
+ let indices = gridIndices(heightSegments, widthSegments, true, true)
226
481
  return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
227
482
  }
package/src/glsl.ts ADDED
@@ -0,0 +1,112 @@
1
+ // The exported lighting GLSL: string constants an app composes into its
2
+ // own shaderMaterial sources with plain template literals - the same
3
+ // pieces the package's future lit materials will be built from, so a
4
+ // custom material never becomes second-class (no preprocessor, no include
5
+ // resolver; the policy is argued in okf/research/3d-differentiators.md).
6
+ //
7
+ // The light-model functions are PURE: normals, view vectors, light
8
+ // directions, colors and exponents all arrive as arguments, so these
9
+ // constants pin nothing but function names. The LIT_VERTEX pair are the
10
+ // deliberate exception - they pin the standard varying interface
11
+ // (vWorldPos, vNormal, vUv, plus vColor on the colored variant) and
12
+ // consume the standard uniform set (uModel, uViewProj, uNormal);
13
+ // fragments written against those names compose with them directly. All directions are expected normalized;
14
+ // every function returns its raw term, weighting and color belong to the
15
+ // caller.
16
+
17
+ import { glsl } from "@solidrt/core/gpu"
18
+
19
+ /**
20
+ * The standard lit vertex stage: clip position via uViewProj * uModel,
21
+ * with world position, world normal (via `mat3(uNormal)`, correct under
22
+ * non-uniform scale) and UV as varyings:
23
+ *
24
+ * in vec3 vWorldPos; in vec3 vNormal; in vec2 vUv;
25
+ *
26
+ * Pair it with your own fragment; the view vector there is
27
+ * `normalize(uCamPos - vWorldPos)`.
28
+ */
29
+ export const LIT_VERTEX = glsl`
30
+ in vec3 aPos;
31
+ in vec3 aNormal;
32
+ in vec2 aUV;
33
+ uniform mat4 uModel;
34
+ uniform mat4 uViewProj;
35
+ uniform mat4 uNormal;
36
+ out vec3 vWorldPos;
37
+ out vec3 vNormal;
38
+ out vec2 vUv;
39
+
40
+ void main() {
41
+ vec4 world = uModel * vec4(aPos, 1.0);
42
+ gl_Position = uViewProj * world;
43
+ vWorldPos = world.xyz;
44
+ vNormal = mat3(uNormal) * aNormal;
45
+ vUv = aUV;
46
+ }
47
+ `
48
+
49
+ /**
50
+ * LIT_VERTEX for "colored"-layout geometry: the same interface plus the
51
+ * per-vertex aColor vec4 forwarded raw as `in vec4 vColor` - what it means
52
+ * (a tint, baked AO in one channel, anything) is the fragment's business.
53
+ * Using this constant opts the material into the colored layout (its
54
+ * meshes need withColors() geometry), because shaderMaterial detects
55
+ * aColor in the vertex source.
56
+ */
57
+ export const LIT_VERTEX_COLORED = glsl`
58
+ in vec3 aPos;
59
+ in vec3 aNormal;
60
+ in vec2 aUV;
61
+ in vec4 aColor;
62
+ uniform mat4 uModel;
63
+ uniform mat4 uViewProj;
64
+ uniform mat4 uNormal;
65
+ out vec3 vWorldPos;
66
+ out vec3 vNormal;
67
+ out vec2 vUv;
68
+ out vec4 vColor;
69
+
70
+ void main() {
71
+ vec4 world = uModel * vec4(aPos, 1.0);
72
+ gl_Position = uViewProj * world;
73
+ vWorldPos = world.xyz;
74
+ vNormal = mat3(uNormal) * aNormal;
75
+ vUv = aUV;
76
+ vColor = aColor;
77
+ }
78
+ `
79
+
80
+ /** `vec3 hemisphere(vec3 n, vec3 sky, vec3 ground)` - ambient from a
81
+ * sky/ground gradient by the normal's vertical tilt: sky straight up,
82
+ * ground bounce straight down. */
83
+ export const HEMISPHERE = glsl`
84
+ vec3 hemisphere(vec3 n, vec3 sky, vec3 ground) {
85
+ return mix(ground, sky, n.y * 0.5 + 0.5);
86
+ }
87
+ `
88
+
89
+ /** `float lambert(vec3 n, vec3 l)` - the diffuse term for a directional
90
+ * light pointing TOWARD the light (multiply by your light color). */
91
+ export const LAMBERT = glsl`
92
+ float lambert(vec3 n, vec3 l) {
93
+ return max(dot(n, l), 0.0);
94
+ }
95
+ `
96
+
97
+ /** `float blinnSpecular(vec3 n, vec3 v, vec3 l, float shininess)` - the
98
+ * Blinn-Phong highlight from the half vector between view and light;
99
+ * shininess runs from wide matte sheen (~8) to tight mirror dot (~150). */
100
+ export const BLINN_SPECULAR = glsl`
101
+ float blinnSpecular(vec3 n, vec3 v, vec3 l, float shininess) {
102
+ return pow(max(dot(n, normalize(l + v)), 0.0), shininess);
103
+ }
104
+ `
105
+
106
+ /** `float fresnel(vec3 n, vec3 v, float power)` - the grazing-angle rim
107
+ * weight (1 at silhouettes, 0 face-on); typical power 3 to 5. */
108
+ export const FRESNEL = glsl`
109
+ float fresnel(vec3 n, vec3 v, float power) {
110
+ return pow(1.0 - max(dot(n, v), 0.0), power);
111
+ }
112
+ `
package/src/index.ts CHANGED
@@ -7,13 +7,15 @@
7
7
 
8
8
  export { add, createGroup, createMesh, createScene, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible } from "./scene.ts"
9
9
  export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
10
- export { box, disposeGeometry, plane, sphere, torusKnot, FLOATS_PER_VERTEX, VERTEX_LAYOUT } from "./geometry.ts"
11
- export type { Geometry } from "./geometry.ts"
10
+ export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
11
+ export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
12
+ export { extrude, fillet, lathe, roundRect, shape, triangulate } from "./profile.ts"
13
+ export type { Profile, ProfilePoint } from "./profile.ts"
12
14
  export { shaderMaterial, unlit } from "./material.ts"
13
15
  export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
14
16
  export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
15
17
  export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
16
18
  export { createOrbitCamera } from "./orbit.ts"
17
19
  export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
18
- export { compose, copy, identity, lookAt, mat4, multiply, perspective } from "./math.ts"
19
- export type { Mat4, Vec3 } from "./math.ts"
20
+ export { compose, copy, identity, lookAt, mat4, multiply, normalMatrix, perspective } from "./math.ts"
21
+ export type { Mat4, Vec2, Vec3 } from "./math.ts"
package/src/material.ts CHANGED
@@ -36,7 +36,8 @@ import type {
36
36
  TextureId,
37
37
  Topology,
38
38
  } from "@solidrt/core/gpu"
39
- import { VERTEX_LAYOUT } from "./geometry.ts"
39
+ import { VERTEX_LAYOUTS } from "./geometry.ts"
40
+ import type { VertexLayout } from "./geometry.ts"
40
41
 
41
42
  export type Material = {
42
43
  /** The pipeline this material draws with (lazily created). */
@@ -45,6 +46,15 @@ export type Material = {
45
46
  params: ShaderParams
46
47
  /** Per-entry sampler bindings, when the material samples textures. */
47
48
  textures?: Record<string, TextureId>
49
+ /** True when the vertex stage declares `uNormal`: the scene then writes
50
+ * the world matrix's inverse-transpose alongside uModel for meshes using
51
+ * this material (set automatically by shaderMaterial). */
52
+ normalMatrix?: boolean
53
+ /** The vertex layout the pipeline is built for; absent means "standard".
54
+ * shaderMaterial sets "colored" when the vertex stage reads `aColor`. A
55
+ * mesh whose geometry layout differs is rejected at add() - the strides
56
+ * disagree, so a mismatch would render garbage, not just miss a channel. */
57
+ layout?: VertexLayout
48
58
  /** Present on materials that own their pipeline (shaderMaterial). */
49
59
  dispose?(): void
50
60
  }
@@ -97,7 +107,7 @@ function pipelineFor(kind: "color" | "map"): RenderPipelineId {
97
107
  })
98
108
  let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + kind })
99
109
  let pipeline = createRenderPipeline(program, {
100
- attributes: VERTEX_LAYOUT,
110
+ attributes: VERTEX_LAYOUTS.standard,
101
111
  depth: true,
102
112
  cull: "back",
103
113
  label: "scene-unlit-" + kind,
@@ -140,13 +150,24 @@ export type ShaderMaterialOptions = {
140
150
  * mesh's world matrix, written per entry whenever the mesh moves) and
141
151
  * `uniform mat4 uViewProj` (the camera's view-projection, shared by the
142
152
  * whole scene target and written once per camera move) - transform with
143
- * `uViewProj * uModel * vec4(aPos, 1.0)`. Declare any of the shared
153
+ * `uViewProj * uModel * vec4(aPos, 1.0)`; a source mentioning neither
154
+ * throws right here. The rest of the standard uniform set is opt-in by
155
+ * declare-and-use: `uniform mat4 uNormal` (either stage) receives the
156
+ * world inverse-transpose beside uModel - take `mat3(uNormal)` for
157
+ * normals, correct under non-uniform scale - and `uniform vec3 uCamPos`
158
+ * the camera's world position, shared like uViewProj (the specular /
159
+ * fresnel view vector: `uCamPos - worldPos`). Declare any of the
144
160
  * layout's `in` attributes (aPos vec3, aNormal vec3, aUV vec2);
145
- * undeclared ones are skipped.
161
+ * undeclared ones are skipped. Reading `in vec4 aColor` opts the
162
+ * material into the "colored" 12-float layout - the per-vertex data
163
+ * channel (tint, baked AO, any four scalars); its meshes then need
164
+ * withColors() geometry, and a layout mismatch throws at add().
165
+ * `@solidrt/3d/glsl` exports a standard
166
+ * vertex stage and lighting pieces built on exactly this contract.
146
167
  */
147
168
  vertex: string
148
169
  fragment: string
149
- /** Uniform seeds beyond uModel/uViewProj; update per mesh later with
170
+ /** Uniform seeds beyond the standard set; update per mesh later with
150
171
  * setMeshParams. */
151
172
  params?: ShaderParams
152
173
  textures?: Record<string, TextureId>
@@ -171,9 +192,25 @@ export type ShaderMaterialOptions = {
171
192
  * and `dispose()` it if the app is done with the look for good.
172
193
  */
173
194
  export function shaderMaterial(opts: ShaderMaterialOptions): Material {
195
+ // The standard-set contract, checked where the mistake is made: a vertex
196
+ // stage that never mentions the matrices cannot place meshes, and with
197
+ // shared params skipping undeclared names the omission would otherwise
198
+ // surface as a silently untransformed render, not an error.
199
+ for (let name of ["uModel", "uViewProj"]) {
200
+ if (!new RegExp("\\b" + name + "\\b").test(opts.vertex)) {
201
+ throw new Error(
202
+ "shaderMaterial vertex stage must declare and use '" + name + "' (see the standard uniform set in AGENTS.md)",
203
+ )
204
+ }
205
+ }
174
206
  let program: ProgramId | undefined
175
207
  let pipeline: RenderPipelineId | undefined
208
+ // Attributes live in the vertex stage only, so unlike the uNormal scan
209
+ // there is nothing to look for in the fragment source.
210
+ let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
176
211
  return {
212
+ normalMatrix: /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment),
213
+ layout,
177
214
  pipeline() {
178
215
  if (pipeline === undefined) {
179
216
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
@@ -182,7 +219,7 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
182
219
  destroyShader(vs)
183
220
  destroyShader(fs)
184
221
  pipeline = createRenderPipeline(program, {
185
- attributes: VERTEX_LAYOUT,
222
+ attributes: VERTEX_LAYOUTS[layout],
186
223
  depth: opts.depth ?? true,
187
224
  depthWrite: opts.depthWrite,
188
225
  blend: opts.blend,