@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/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
- // GPU buffers are created lazily on first use and shared by every mesh and
16
- // scene drawing the geometry. They are app-lifetime by design - one
17
- // geometry commonly outlives the component that first drew it, so
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 { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
22
- import type { BufferId, IndexFormat, VertexAttribute } from "@solidrt/core/gpu"
23
- import { add, cross, normalize, sub } from "./math.ts"
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 { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
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
- // does NOT blend yet: v1 pipelines draw opaque (blend "none"), so a
11
- // translucent color overwrites what is behind it. Transparency arrives
12
- // with the blend-factor vocabulary and back-to-front sorting (see
13
- // okf/research/scene-graph-3d.md, staging step 4).
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 need no material system: the raw layer (compileShader /
16
- // createRenderPipeline in @solidrt/core/gpu) is first-class, and a scene
17
- // draws into an ordinary draw target - a custom-shaded mesh is a future
18
- // material class here, or the app's own addDraw beside the scene's.
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: { color?: RenderPipelineId; map?: RenderPipelineId } = {}
109
+ let pipelines: Partial<Record<UnlitClass, RenderPipelineId>> = {}
100
110
 
101
- function pipelineFor(kind: "color" | "map"): RenderPipelineId {
102
- let existing = pipelines[kind]
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 fragment = compileShader("fragment", kind === "color" ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
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-" + kind })
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-" + kind,
130
+ label: "scene-unlit-" + cls,
114
131
  })
115
- pipelines[kind] = pipeline
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 { pipeline: () => pipelineFor("map"), params: { uColor }, textures: { uMap: opts.map } }
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
- export type ShaderMaterialOptions = {
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
- /** Uniform seeds beyond the standard set; update per mesh later with
208
- * setMeshParams. */
209
- params?: ShaderParams
210
- textures?: Record<string, TextureId>
211
- /** Pipeline state; defaults match unlit: depth: true, cull: "back". */
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
- * A material from your own GLSL: the custom-look escape hatch, first-class
222
- * next to unlit. Sources without a `#version` line get the standard
223
- * pipeline preamble (`fragColor`, `iResolution`).
224
- *
225
- * The INSTANCE is the pipeline handle: two calls with identical sources
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 function shaderMaterial(opts: ShaderMaterialOptions): Material {
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
- normalMatrix: /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment),
250
- layout,
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
+ }