@solidrt/3d 0.0.49 → 0.0.51
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 +147 -15
- package/README.md +13 -4
- package/examples/README.md +5 -0
- package/examples/instanced.tsx +158 -0
- package/package.json +3 -2
- package/src/bvh.ts +2 -2
- package/src/components.tsx +80 -1
- package/src/geometry-gpu.ts +86 -0
- package/src/geometry.ts +94 -47
- package/src/index.ts +9 -7
- package/src/material.ts +147 -55
- package/src/math.ts +45 -0
- package/src/order.ts +51 -0
- package/src/scene.ts +354 -59
package/src/geometry.ts
CHANGED
|
@@ -12,16 +12,13 @@
|
|
|
12
12
|
// unused by the unlit materials so the layout is ready for lights without
|
|
13
13
|
// a geometry change (inactive attributes are skipped but keep the stride).
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// owner-scoped auto-free would free a buffer other scenes still draw from.
|
|
19
|
-
// disposeGeometry frees them when an app is done with a geometry for good.
|
|
15
|
+
// Pure module by design - geometry is data, and every function here is
|
|
16
|
+
// array math (the check rig checks/geometry-check.ts runs it headless on
|
|
17
|
+
// flux). The GPU buffer step lives in geometry-gpu.ts.
|
|
20
18
|
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import type { Vec2, Vec3, Vec4 } from "./math.ts"
|
|
19
|
+
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
20
|
+
import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
|
|
21
|
+
import type { Quat, TransformUpdate, Vec2, Vec3, Vec4 } from "./math.ts"
|
|
25
22
|
|
|
26
23
|
export type VertexLayout = "standard" | "colored"
|
|
27
24
|
|
|
@@ -61,8 +58,6 @@ export type Geometry = {
|
|
|
61
58
|
layout?: VertexLayout
|
|
62
59
|
/** Debug name for the lazily-created GPU buffers. */
|
|
63
60
|
label?: string
|
|
64
|
-
_buffer?: BufferId
|
|
65
|
-
_index?: BufferId
|
|
66
61
|
_bounds?: Float32Array
|
|
67
62
|
}
|
|
68
63
|
|
|
@@ -94,42 +89,6 @@ export function geometryBounds(geometry: Geometry): Float32Array {
|
|
|
94
89
|
return bounds
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
/** The geometry's GPU buffers, created on first use and cached on it,
|
|
98
|
-
* plus the index format the draw entry must bind them with. */
|
|
99
|
-
export function geometryBuffers(geometry: Geometry): {
|
|
100
|
-
buffer: BufferId
|
|
101
|
-
index: BufferId
|
|
102
|
-
indexFormat: IndexFormat
|
|
103
|
-
} {
|
|
104
|
-
let buffer = geometry._buffer
|
|
105
|
-
let index = geometry._index
|
|
106
|
-
if (buffer === undefined || index === undefined) {
|
|
107
|
-
buffer = createBuffer(geometry.vertices, {
|
|
108
|
-
autoFree: false,
|
|
109
|
-
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
110
|
-
})
|
|
111
|
-
index = createBuffer(geometry.indices, {
|
|
112
|
-
autoFree: false,
|
|
113
|
-
label: geometry.label ? geometry.label + "-indices" : undefined,
|
|
114
|
-
})
|
|
115
|
-
geometry._buffer = buffer
|
|
116
|
-
geometry._index = index
|
|
117
|
-
}
|
|
118
|
-
return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Free the geometry's GPU buffers. Draw entries created from them hold
|
|
123
|
-
* their own reference, so destruction order is safe; the geometry can be
|
|
124
|
-
* used again afterwards (fresh buffers are created on next use).
|
|
125
|
-
*/
|
|
126
|
-
export function disposeGeometry(geometry: Geometry): void {
|
|
127
|
-
if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
|
|
128
|
-
if (geometry._index !== undefined) destroyBuffer(geometry._index)
|
|
129
|
-
geometry._buffer = undefined
|
|
130
|
-
geometry._index = undefined
|
|
131
|
-
}
|
|
132
|
-
|
|
133
92
|
/** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
|
|
134
93
|
* array, or a callback deriving each vertex's vec4 from the vertex data. */
|
|
135
94
|
export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
|
|
@@ -210,6 +169,94 @@ export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, c
|
|
|
210
169
|
return vertices
|
|
211
170
|
}
|
|
212
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Bake a placement (the setTransform shape: Euler XYZ radians or a
|
|
174
|
+
* quaternion, not both; number = uniform scale; absent = identity) into a
|
|
175
|
+
* geometry: a new geometry (the source is
|
|
176
|
+
* untouched, its GPU buffers stay independent) whose positions are moved
|
|
177
|
+
* by the transform and whose normals follow through the inverse-transpose,
|
|
178
|
+
* renormalized - correct under non-uniform scale. UVs, colors, indices and
|
|
179
|
+
* layout copy through. This is Three's `geometry.applyMatrix4`, the first
|
|
180
|
+
* half of authoring a static scene as data: transform each part into place,
|
|
181
|
+
* mergeGeometries the parts, draw one mesh.
|
|
182
|
+
*/
|
|
183
|
+
export function transformGeometry(geometry: Geometry, transform: TransformUpdate, label?: string): Geometry {
|
|
184
|
+
let rot: Quat = [0, 0, 0, 1]
|
|
185
|
+
updateRotation(rot, transform, "transformGeometry")
|
|
186
|
+
let scl: Vec3 = [1, 1, 1]
|
|
187
|
+
if (transform.scale !== undefined) updateScale(scl, transform.scale)
|
|
188
|
+
let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
|
|
189
|
+
let n = normalMatrix(mat4(), m)
|
|
190
|
+
let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
|
|
191
|
+
let src = geometry.vertices
|
|
192
|
+
if (src.length % stride !== 0) {
|
|
193
|
+
throw new Error("transformGeometry: vertex data is not a whole number of " + (geometry.layout ?? "standard") + "-layout vertices")
|
|
194
|
+
}
|
|
195
|
+
let out = new Float32Array(src)
|
|
196
|
+
for (let i = 0; i < out.length; i += stride) {
|
|
197
|
+
let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
|
|
198
|
+
out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
199
|
+
out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
200
|
+
out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
201
|
+
let nx = src[i + 3]!, ny = src[i + 4]!, nz = src[i + 5]!
|
|
202
|
+
let tx = n[0] * nx + n[4] * ny + n[8] * nz
|
|
203
|
+
let ty = n[1] * nx + n[5] * ny + n[9] * nz
|
|
204
|
+
let tz = n[2] * nx + n[6] * ny + n[10] * nz
|
|
205
|
+
let len = Math.hypot(tx, ty, tz) || 1
|
|
206
|
+
out[i + 3] = tx / len
|
|
207
|
+
out[i + 4] = ty / len
|
|
208
|
+
out[i + 5] = tz / len
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
vertices: out,
|
|
212
|
+
indices: geometry.indices,
|
|
213
|
+
layout: geometry.layout,
|
|
214
|
+
label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Concatenate geometries into one: vertices appended in order, indices
|
|
220
|
+
* offset to match, uint32 indices past 64k vertices. Every part must share
|
|
221
|
+
* one layout - a mixed list throws, because the strides differ and a merge
|
|
222
|
+
* that picked one would draw garbage, not a mesh missing a channel. The
|
|
223
|
+
* second half of authoring a static scene as data (Three's
|
|
224
|
+
* `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
|
|
225
|
+
* one uModel write however many parts went in, so only what actually moves
|
|
226
|
+
* keeps a node of its own.
|
|
227
|
+
*/
|
|
228
|
+
export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
|
|
229
|
+
if (parts.length === 0) throw new Error("mergeGeometries: no parts")
|
|
230
|
+
let layout = parts[0]!.layout ?? "standard"
|
|
231
|
+
let stride = layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
|
|
232
|
+
let floats = 0
|
|
233
|
+
let indexCount = 0
|
|
234
|
+
for (let part of parts) {
|
|
235
|
+
if ((part.layout ?? "standard") !== layout) {
|
|
236
|
+
throw new Error("mergeGeometries: mixed layouts (" + layout + " and " + (part.layout ?? "standard") + ")")
|
|
237
|
+
}
|
|
238
|
+
if (part.vertices.length % stride !== 0) {
|
|
239
|
+
throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + layout + "-layout vertices")
|
|
240
|
+
}
|
|
241
|
+
floats += part.vertices.length
|
|
242
|
+
indexCount += part.indices.length
|
|
243
|
+
}
|
|
244
|
+
let vertexCount = floats / stride
|
|
245
|
+
let vertices = new Float32Array(floats)
|
|
246
|
+
let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
|
|
247
|
+
let vOffset = 0
|
|
248
|
+
let iOffset = 0
|
|
249
|
+
for (let part of parts) {
|
|
250
|
+
vertices.set(part.vertices, vOffset)
|
|
251
|
+
let base = vOffset / stride
|
|
252
|
+
let src = part.indices
|
|
253
|
+
for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
|
|
254
|
+
vOffset += part.vertices.length
|
|
255
|
+
iOffset += src.length
|
|
256
|
+
}
|
|
257
|
+
return { vertices, indices, layout: parts[0]!.layout, label }
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
// Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
|
|
214
261
|
// CCW triangles per cell, split across the row0col0-row1col1 diagonal -
|
|
215
262
|
// the one quad pattern every grid generator here shares (rows run along
|
package/src/index.ts
CHANGED
|
@@ -5,18 +5,20 @@
|
|
|
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, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible, worldPosition } from "./scene.ts"
|
|
9
|
-
export type { CameraUpdate, Hit, Mesh as MeshNode, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
|
|
10
|
-
export {
|
|
8
|
+
export { add, createGroup, createInstancedMesh, createMesh, createScene, disposeInstances, getRotation, lookAt, remove, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
|
|
9
|
+
export type { CameraUpdate, Hit, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
|
|
10
|
+
export { disposeGeometry } from "./geometry-gpu.ts"
|
|
11
|
+
export { box, circle, cone, cylinder, fillColors, geometryBounds, mergeGeometries, plane, ring, sphere, torus, torusKnot, transformGeometry, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
|
|
11
12
|
export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
|
|
13
|
+
export { rayBoxDistance } from "./bvh.ts"
|
|
12
14
|
export { fillet, roundRect, shape, triangulate } from "./profile.ts"
|
|
13
15
|
export type { Profile, ProfilePoint } from "./profile.ts"
|
|
14
16
|
export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
|
|
15
17
|
export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
|
|
16
|
-
export { shaderMaterial, unlit } from "./material.ts"
|
|
17
|
-
export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
|
|
18
|
-
export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
19
|
-
export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
|
|
18
|
+
export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
|
|
19
|
+
export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
|
|
20
|
+
export { Group, InstancedMesh, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
21
|
+
export type { InstancedMeshProps, MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
|
|
20
22
|
export { createOrbitCamera } from "./orbit.ts"
|
|
21
23
|
export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
|
|
22
24
|
// math's lookAt (the camera view matrix) stays on the /math subpath: the
|
package/src/material.ts
CHANGED
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Colors are straight [r, g, b, a?] 0..1 at the API and premultiplied here
|
|
9
9
|
// once, at the boundary (the engine's pixel contract). An alpha below 1
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// with
|
|
13
|
-
//
|
|
10
|
+
// blends only on a `transparent: true` material (Three's rule: the flag is
|
|
11
|
+
// explicit, alpha alone still draws opaque). Transparent materials build
|
|
12
|
+
// their pipeline with blend "alpha" and depthWrite off, and the scene draws
|
|
13
|
+
// their meshes after the opaque ones, sorted back-to-front per mesh.
|
|
14
14
|
//
|
|
15
|
-
// Custom looks
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// Custom looks get the same split through shaderMaterialClass (one
|
|
16
|
+
// program, instance() per parameterisation); shaderMaterial is a class with
|
|
17
|
+
// a single instance. The raw layer (compileShader / createRenderPipeline in
|
|
18
|
+
// @solidrt/core/gpu) stays first-class beneath both.
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
compileShader,
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ShaderStageId,
|
|
36
36
|
TextureId,
|
|
37
37
|
Topology,
|
|
38
|
+
VertexAttribute,
|
|
38
39
|
} from "@solidrt/core/gpu"
|
|
39
40
|
import { VERTEX_LAYOUTS } from "./geometry.ts"
|
|
40
41
|
import type { VertexLayout } from "./geometry.ts"
|
|
@@ -55,6 +56,15 @@ export type Material = {
|
|
|
55
56
|
* mesh whose geometry layout differs is rejected at add() - the strides
|
|
56
57
|
* disagree, so a mismatch would render garbage, not just miss a channel. */
|
|
57
58
|
layout?: VertexLayout
|
|
59
|
+
/** True when the pipeline blends over (blend "alpha", depthWrite off):
|
|
60
|
+
* the scene draws this material's meshes after every opaque one, sorted
|
|
61
|
+
* back-to-front by mesh origin, and re-sorts them when the camera moves. */
|
|
62
|
+
transparent?: boolean
|
|
63
|
+
/** Per-instance attributes, when the material's pipeline declares them
|
|
64
|
+
* (shaderMaterialClass's `instanceAttributes`). Such a material draws
|
|
65
|
+
* instanced meshes only - createInstancedMesh supplies the record buffer,
|
|
66
|
+
* and createMesh meshes are rejected at add(). */
|
|
67
|
+
instanceAttributes?: VertexAttribute[]
|
|
58
68
|
/** Present on materials that own their pipeline (shaderMaterial). */
|
|
59
69
|
dispose?(): void
|
|
60
70
|
}
|
|
@@ -96,23 +106,30 @@ const FRAGMENT_MAP_SRC = glsl`
|
|
|
96
106
|
`
|
|
97
107
|
|
|
98
108
|
let sharedVertex: ShaderStageId | undefined
|
|
99
|
-
let pipelines:
|
|
109
|
+
let pipelines: Partial<Record<UnlitClass, RenderPipelineId>> = {}
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
// One pipeline per unlit CLASS: fragment kind x transparency, since blend
|
|
112
|
+
// state is pipeline state.
|
|
113
|
+
type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
|
|
114
|
+
|
|
115
|
+
function pipelineFor(cls: UnlitClass): RenderPipelineId {
|
|
116
|
+
let existing = pipelines[cls]
|
|
103
117
|
if (existing !== undefined) return existing
|
|
104
118
|
if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
|
|
105
|
-
let
|
|
119
|
+
let transparent = cls.endsWith("-transparent")
|
|
120
|
+
let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
|
|
106
121
|
header: true,
|
|
107
122
|
})
|
|
108
|
-
let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" +
|
|
123
|
+
let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
|
|
109
124
|
let pipeline = createRenderPipeline(program, {
|
|
110
125
|
attributes: VERTEX_LAYOUTS.standard,
|
|
111
126
|
depth: true,
|
|
127
|
+
depthWrite: transparent ? false : undefined,
|
|
128
|
+
blend: transparent ? "alpha" : undefined,
|
|
112
129
|
cull: "back",
|
|
113
|
-
label: "scene-unlit-" +
|
|
130
|
+
label: "scene-unlit-" + cls,
|
|
114
131
|
})
|
|
115
|
-
pipelines[
|
|
132
|
+
pipelines[cls] = pipeline
|
|
116
133
|
return pipeline
|
|
117
134
|
}
|
|
118
135
|
|
|
@@ -121,6 +138,9 @@ export type UnlitOptions = {
|
|
|
121
138
|
color?: [number, number, number] | [number, number, number, number]
|
|
122
139
|
/** A texture id to sample (tinted by `color` when both are given). */
|
|
123
140
|
map?: TextureId
|
|
141
|
+
/** Blend over what is behind (color alpha and map alpha both count).
|
|
142
|
+
* Without it an alpha below 1 still draws opaque. See Material.transparent. */
|
|
143
|
+
transparent?: boolean
|
|
124
144
|
}
|
|
125
145
|
|
|
126
146
|
/**
|
|
@@ -132,10 +152,16 @@ export function unlit(opts: UnlitOptions = {}): Material {
|
|
|
132
152
|
let color = opts.color ?? [1, 1, 1]
|
|
133
153
|
let a = color.length === 4 ? color[3] : 1
|
|
134
154
|
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
155
|
+
let transparent = opts.transparent === true
|
|
135
156
|
if (opts.map !== undefined) {
|
|
136
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
pipeline: () => pipelineFor(transparent ? "map-transparent" : "map"),
|
|
159
|
+
params: { uColor },
|
|
160
|
+
textures: { uMap: opts.map },
|
|
161
|
+
transparent,
|
|
162
|
+
}
|
|
137
163
|
}
|
|
138
|
-
return { pipeline: () => pipelineFor("color"), params: { uColor } }
|
|
164
|
+
return { pipeline: () => pipelineFor(transparent ? "color-transparent" : "color"), params: { uColor }, transparent }
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
// Mirrors the engine's own preamble rule: a source carrying its own
|
|
@@ -181,7 +207,9 @@ export function backgroundPipeline(fragment: string, label: string): { pipeline:
|
|
|
181
207
|
return { pipeline, program }
|
|
182
208
|
}
|
|
183
209
|
|
|
184
|
-
|
|
210
|
+
/** The class half of a shader material: sources and pipeline state, the
|
|
211
|
+
* things one compiled program fixes. */
|
|
212
|
+
export type ShaderMaterialClassOptions = {
|
|
185
213
|
/**
|
|
186
214
|
* Vertex stage GLSL. MUST declare and use `uniform mat4 uModel` (the
|
|
187
215
|
* mesh's world matrix, written per entry whenever the mesh moves) and
|
|
@@ -204,11 +232,27 @@ export type ShaderMaterialOptions = {
|
|
|
204
232
|
*/
|
|
205
233
|
vertex: string
|
|
206
234
|
fragment: string
|
|
207
|
-
/**
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Per-instance attributes: the vertex stage reads these as `in` variables
|
|
237
|
+
* beside the layout's own, and each drawn instance gets one record from
|
|
238
|
+
* the mesh's instance buffer (interleaved floats in this order). A class
|
|
239
|
+
* with instance attributes makes INSTANCED materials: attach their meshes
|
|
240
|
+
* with createInstancedMesh, which carries the records - a createMesh mesh
|
|
241
|
+
* is rejected at add(). A per-instance transform is data, not a matrix:
|
|
242
|
+
* a position/yaw/scale record beats four vec4 columns for most fleets,
|
|
243
|
+
* and the composed uModel still places the whole population.
|
|
244
|
+
*/
|
|
245
|
+
instanceAttributes?: VertexAttribute[]
|
|
246
|
+
/** Blend over what is behind, with the scene sorting this material's
|
|
247
|
+
* meshes back-to-front after the opaque ones (see Material.transparent).
|
|
248
|
+
* Sets the pipeline defaults blend "alpha" and depthWrite false; the
|
|
249
|
+
* fragment must write premultiplied output (`vec4(rgb * a, a)`). Defaults
|
|
250
|
+
* to true whenever `blend` is set to anything but "none": every blended
|
|
251
|
+
* draw belongs after the opaques so it depth-tests against them, and
|
|
252
|
+
* back-to-front is harmless for the order-independent modes. */
|
|
253
|
+
transparent?: boolean
|
|
254
|
+
/** Pipeline state; defaults match unlit: depth: true, cull: "back",
|
|
255
|
+
* and for transparent materials blend "alpha", depthWrite: false. */
|
|
212
256
|
depth?: boolean
|
|
213
257
|
depthWrite?: boolean
|
|
214
258
|
blend?: BlendMode
|
|
@@ -217,18 +261,41 @@ export type ShaderMaterialOptions = {
|
|
|
217
261
|
label?: string
|
|
218
262
|
}
|
|
219
263
|
|
|
264
|
+
/** The instance half of a shader material: uniform seeds and sampler
|
|
265
|
+
* bindings for one parameterisation of a class's program. */
|
|
266
|
+
export type ShaderMaterialInstanceOptions = {
|
|
267
|
+
/** Uniform seeds beyond the standard set; update per mesh later with
|
|
268
|
+
* setMeshParams. */
|
|
269
|
+
params?: ShaderParams
|
|
270
|
+
textures?: Record<string, TextureId>
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
|
|
274
|
+
|
|
220
275
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* pipeline
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* compile two pipelines - there is no dedupe by source value (a hidden
|
|
227
|
-
* cache keyed by content is the anti-pattern the GPU layer avoids
|
|
228
|
-
* throughout). Create one per look at app scope, share it across meshes,
|
|
229
|
-
* and `dispose()` it if the app is done with the look for good.
|
|
276
|
+
* One program and pipeline, many parameterisations: the class/instance
|
|
277
|
+
* split unlit has internally, for your own GLSL. `instance()` returns a
|
|
278
|
+
* Material sharing the class's pipeline with its own params/textures - the
|
|
279
|
+
* class compiles once, and dispose() is on the class alone (instances hold
|
|
280
|
+
* nothing of their own).
|
|
230
281
|
*/
|
|
231
|
-
export
|
|
282
|
+
export type ShaderMaterialClass = {
|
|
283
|
+
instance(opts?: ShaderMaterialInstanceOptions): Material
|
|
284
|
+
/** Destroy the shared program and pipeline. Instances still in use draw
|
|
285
|
+
* nothing valid afterwards. */
|
|
286
|
+
dispose(): void
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A material class from your own GLSL: sources without a `#version` line
|
|
291
|
+
* get the standard pipeline preamble (`fragColor`, `iResolution`). Two
|
|
292
|
+
* calls with identical sources compile two programs - there is no dedupe by
|
|
293
|
+
* source value (a hidden cache keyed by content is the anti-pattern the GPU
|
|
294
|
+
* layer avoids throughout); the class IS the app-owned split. Create one
|
|
295
|
+
* per program at app scope, `instance()` per look, and `dispose()` the class
|
|
296
|
+
* when the app is done with the look for good.
|
|
297
|
+
*/
|
|
298
|
+
export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMaterialClass {
|
|
232
299
|
// The standard-set contract, checked where the mistake is made: a vertex
|
|
233
300
|
// stage that never mentions the matrices cannot place meshes, and with
|
|
234
301
|
// shared params skipping undeclared names the omission would otherwise
|
|
@@ -245,30 +312,38 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
|
|
|
245
312
|
// Attributes live in the vertex stage only, so unlike the uNormal scan
|
|
246
313
|
// there is nothing to look for in the fragment source.
|
|
247
314
|
let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
|
|
315
|
+
let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
|
|
316
|
+
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
317
|
+
let depth = opts.depth ?? true
|
|
318
|
+
// An empty list declares nothing - same as absent (the engine requires an
|
|
319
|
+
// instance buffer exactly when attributes are declared).
|
|
320
|
+
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
321
|
+
let pipelineFor = (): RenderPipelineId => {
|
|
322
|
+
if (pipeline === undefined) {
|
|
323
|
+
let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
|
|
324
|
+
let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
|
|
325
|
+
program = linkProgram(vs, fs, { label: opts.label })
|
|
326
|
+
destroyShader(vs)
|
|
327
|
+
destroyShader(fs)
|
|
328
|
+
pipeline = createRenderPipeline(program, {
|
|
329
|
+
attributes: VERTEX_LAYOUTS[layout],
|
|
330
|
+
instanceAttributes,
|
|
331
|
+
depth,
|
|
332
|
+
// depthWrite needs a depth buffer, so the transparent default
|
|
333
|
+
// only applies when there is one.
|
|
334
|
+
depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
|
|
335
|
+
blend: opts.blend ?? (transparent ? "alpha" : undefined),
|
|
336
|
+
cull: opts.cull ?? "back",
|
|
337
|
+
topology: opts.topology,
|
|
338
|
+
label: opts.label,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
return pipeline
|
|
342
|
+
}
|
|
248
343
|
return {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
pipeline() {
|
|
252
|
-
if (pipeline === undefined) {
|
|
253
|
-
let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
|
|
254
|
-
let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
|
|
255
|
-
program = linkProgram(vs, fs, { label: opts.label })
|
|
256
|
-
destroyShader(vs)
|
|
257
|
-
destroyShader(fs)
|
|
258
|
-
pipeline = createRenderPipeline(program, {
|
|
259
|
-
attributes: VERTEX_LAYOUTS[layout],
|
|
260
|
-
depth: opts.depth ?? true,
|
|
261
|
-
depthWrite: opts.depthWrite,
|
|
262
|
-
blend: opts.blend,
|
|
263
|
-
cull: opts.cull ?? "back",
|
|
264
|
-
topology: opts.topology,
|
|
265
|
-
label: opts.label,
|
|
266
|
-
})
|
|
267
|
-
}
|
|
268
|
-
return pipeline
|
|
344
|
+
instance(inst = {}) {
|
|
345
|
+
return { normalMatrix, layout, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
|
|
269
346
|
},
|
|
270
|
-
params: opts.params ?? {},
|
|
271
|
-
textures: opts.textures,
|
|
272
347
|
dispose() {
|
|
273
348
|
if (pipeline !== undefined) {
|
|
274
349
|
destroyRenderPipeline(pipeline)
|
|
@@ -281,3 +356,20 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
|
|
|
281
356
|
},
|
|
282
357
|
}
|
|
283
358
|
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* A material from your own GLSL: the custom-look escape hatch, first-class
|
|
362
|
+
* next to unlit. A class with a single instance - `shaderMaterialClass()`
|
|
363
|
+
* is the form for one program with many parameterisations.
|
|
364
|
+
*
|
|
365
|
+
* The INSTANCE is the pipeline handle: two calls with identical sources
|
|
366
|
+
* compile two pipelines - there is no dedupe by source value. Create one
|
|
367
|
+
* per look at app scope, share it across meshes, and `dispose()` it if the
|
|
368
|
+
* app is done with the look for good.
|
|
369
|
+
*/
|
|
370
|
+
export function shaderMaterial(opts: ShaderMaterialOptions): Material {
|
|
371
|
+
let cls = shaderMaterialClass(opts)
|
|
372
|
+
let material = cls.instance(opts)
|
|
373
|
+
material.dispose = cls.dispose
|
|
374
|
+
return material
|
|
375
|
+
}
|
package/src/math.ts
CHANGED
|
@@ -213,6 +213,51 @@ export function quatNormalize(out: Quat, q: Quat): Quat {
|
|
|
213
213
|
return out
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A transform update - the shape setTransform writes and transformGeometry
|
|
218
|
+
* bakes. Absent keys mean "keep" (nodes) or identity (geometry).
|
|
219
|
+
*/
|
|
220
|
+
export type TransformUpdate = {
|
|
221
|
+
position?: Vec3
|
|
222
|
+
/** Euler radians in XYZ order (x first), Three's `Euler` default -
|
|
223
|
+
* converted to a quaternion on use. */
|
|
224
|
+
rotation?: Vec3
|
|
225
|
+
/** The rotation itself. Normalized on use, so a hand-built or drifted
|
|
226
|
+
* quaternion cannot silently scale the geometry. Passing this together
|
|
227
|
+
* with `rotation` is an error, not a precedence question. */
|
|
228
|
+
quaternion?: Quat
|
|
229
|
+
/** A number is uniform scale. */
|
|
230
|
+
scale?: Vec3 | number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve an update's rotation into `out`: euler converted, quaternion
|
|
235
|
+
* normalized. Returns false (out untouched) when the update carries
|
|
236
|
+
* neither; throws when it carries both. `caller` names the verb in the
|
|
237
|
+
* error.
|
|
238
|
+
*/
|
|
239
|
+
export function updateRotation(out: Quat, update: TransformUpdate, caller: string): boolean {
|
|
240
|
+
let r = update.rotation
|
|
241
|
+
let q = update.quaternion
|
|
242
|
+
if (r !== undefined && q !== undefined) {
|
|
243
|
+
throw new Error("Pass rotation or quaternion to " + caller + ", not both")
|
|
244
|
+
}
|
|
245
|
+
if (r !== undefined) quatFromEuler(out, r)
|
|
246
|
+
else if (q !== undefined) quatNormalize(out, q)
|
|
247
|
+
else return false
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Expand an update's scale (number = uniform) into `out`. */
|
|
252
|
+
export function updateScale(out: Vec3, scale: Vec3 | number): Vec3 {
|
|
253
|
+
if (typeof scale === "number") {
|
|
254
|
+
out[0] = scale; out[1] = scale; out[2] = scale
|
|
255
|
+
} else {
|
|
256
|
+
out[0] = scale[0]; out[1] = scale[1]; out[2] = scale[2]
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
216
261
|
/**
|
|
217
262
|
* Euler radians to a quaternion, in XYZ order: x applied first, then y,
|
|
218
263
|
* then z (R = Rx * Ry * Rz on column vectors), Three's `Euler` default - a
|
package/src/order.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Draw-list ordering for a scene: a pure function of the live meshes and the
|
|
2
|
+
// camera's view matrix, with no GUI import, so the check rig
|
|
3
|
+
// (checks/order-check.ts) runs it headless on flux against a linear oracle.
|
|
4
|
+
// The scene calls it whenever the order is dirty and hands the result to
|
|
5
|
+
// setDrawOrder.
|
|
6
|
+
|
|
7
|
+
import type { Mat4, Vec3 } from "./math.ts"
|
|
8
|
+
|
|
9
|
+
/** The slice of a Mesh the sort reads (field names match Mesh so the
|
|
10
|
+
* scene passes its meshes straight through). */
|
|
11
|
+
export type Orderable<T> = {
|
|
12
|
+
_entry: T | null
|
|
13
|
+
_transparent: boolean
|
|
14
|
+
renderOrder: number
|
|
15
|
+
_center: Vec3
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Draw order: `first` (the background entry, if any), then opaque meshes by
|
|
20
|
+
* renderOrder with add order within a key, then transparent meshes by
|
|
21
|
+
* renderOrder then back-to-front by the view-space depth of the world-bounds
|
|
22
|
+
* center. The center, not the origin (Three's key), so geometry built
|
|
23
|
+
* off-origin sorts by where it is; and not the nearest bounds point, which
|
|
24
|
+
* would draw a large translucent ground plane over the small translucents
|
|
25
|
+
* resting on it. Per-mesh only: no per-triangle sort, no OIT.
|
|
26
|
+
*/
|
|
27
|
+
export function orderEntries<T>(meshes: readonly Orderable<T>[], view: Mat4, first?: T): T[] {
|
|
28
|
+
let opaque: Orderable<T>[] = []
|
|
29
|
+
let transparent: Orderable<T>[] = []
|
|
30
|
+
for (let m of meshes) {
|
|
31
|
+
if (m._entry === null) continue
|
|
32
|
+
;(m._transparent ? transparent : opaque).push(m)
|
|
33
|
+
}
|
|
34
|
+
// Array sort is stable, so equal keys keep add order.
|
|
35
|
+
opaque.sort((a, b) => a.renderOrder - b.renderOrder)
|
|
36
|
+
if (transparent.length > 1) {
|
|
37
|
+
// The camera looks down -z in view space, so farther is more negative
|
|
38
|
+
// and ascending depth is back-to-front.
|
|
39
|
+
let depth = new Map<Orderable<T>, number>()
|
|
40
|
+
for (let m of transparent) {
|
|
41
|
+
let c = m._center
|
|
42
|
+
depth.set(m, view[2] * c[0] + view[6] * c[1] + view[10] * c[2] + view[14])
|
|
43
|
+
}
|
|
44
|
+
transparent.sort((a, b) => a.renderOrder - b.renderOrder || depth.get(a)! - depth.get(b)!)
|
|
45
|
+
}
|
|
46
|
+
let order: T[] = []
|
|
47
|
+
if (first !== undefined) order.push(first)
|
|
48
|
+
for (let m of opaque) order.push(m._entry!)
|
|
49
|
+
for (let m of transparent) order.push(m._entry!)
|
|
50
|
+
return order
|
|
51
|
+
}
|