@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/AGENTS.md +145 -35
- package/README.md +22 -5
- package/examples/scene-post-effect.tsx +82 -0
- package/package.json +4 -3
- package/src/components.tsx +20 -4
- package/src/geometry.ts +292 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +6 -4
- package/src/material.ts +43 -6
- package/src/math.ts +40 -0
- package/src/orbit.ts +187 -27
- package/src/profile.ts +520 -0
- package/src/scene.ts +91 -37
package/src/geometry.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
// Geometry: interleaved vertex data in
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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,
|
|
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 {
|
|
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:
|
|
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)
|
|
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
|
|
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:
|
|
222
|
+
attributes: VERTEX_LAYOUTS[layout],
|
|
186
223
|
depth: opts.depth ?? true,
|
|
187
224
|
depthWrite: opts.depthWrite,
|
|
188
225
|
blend: opts.blend,
|