@solidrt/3d 0.0.49 → 0.0.50

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 CHANGED
@@ -11,7 +11,7 @@ blendMode and pointer events like any element. Design rationale:
11
11
 
12
12
  - Two layers. The imperative core is Solid-free: `createScene`,
13
13
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
- `lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
14
+ `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects with dirty flags, batched to a
15
15
  microtask,
16
16
  one `setDrawParams` (uModel, plus uNormal for materials declaring it)
17
17
  per changed mesh and ONE `setTargetParams` (the shared uViewProj +
@@ -42,8 +42,14 @@ blendMode and pointer events like any element. Design rationale:
42
42
  lazy, shared, and app-lifetime (owner-scoped free would break sharing);
43
43
  `disposeGeometry` frees them.
44
44
  - Materials dedupe hard: one program + one pipeline per material CLASS
45
- (unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
45
+ (unlit color, unlit map, each opaque or transparent), `depth: true` +
46
+ `cull: "back"`; an instance is
46
47
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
48
+ - The pure pieces (`math.ts`, `bvh.ts`, `order.ts`) have check rigs in
49
+ `checks/`, run headless on flux from the repo root:
50
+ `bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
51
+ They print PASS or FAIL lines and throw on failure (the flux binary exits
52
+ 0 either way - read the output). Extend the rig when you change the module.
47
53
 
48
54
  ## Components
49
55
 
@@ -194,7 +200,10 @@ Materials:
194
200
  at shaderMaterial() creation. The rest is opt-in by declare-and-use:
195
201
  `uniform vec3 uCamPos` (the camera's world position, shared and written
196
202
  with uViewProj - the specular/fresnel view vector is
197
- `normalize(uCamPos - worldPos)`) and `uniform mat4 uNormal` (the world
203
+ `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
204
+ (the camera's world-space view axes, shared likewise - a billboard is
205
+ `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
206
+ uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
198
207
  inverse-transpose, written beside uModel for this material's meshes;
199
208
  take `mat3(uNormal)` - correct under non-uniform scale, where
200
209
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -208,6 +217,14 @@ Materials:
208
217
  with the `Mesh` `params` prop (same merge semantics - a key that
209
218
  disappears from the object keeps its old value; for per-frame values
210
219
  prefer `ref` + setMeshParams from onFrame, the setTransform split).
220
+ Scene-wide values (a clock, a sun direction, fog) go through
221
+ `scene.setParams({ uTime })` instead - one write for every mesh.
222
+ - `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
223
+ class/instance split for your own GLSL: compiles once, and
224
+ `cls.instance({ params?, textures? })` returns a Material sharing that
225
+ pipeline with its own values. `dispose()` lives on the class alone.
226
+ `shaderMaterial(opts)` is exactly a class with one instance (its
227
+ `dispose` forwards to the class).
211
228
 
212
229
  Background: `scene.setBackground(source | null)`, the `background` option
213
230
  on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
@@ -250,9 +267,26 @@ system.
250
267
  sync() turns it on when it writes uModel - never add one live: it has no
251
268
  world matrix yet, and drawn before the sync microtask it flashes at the
252
269
  world origin for a frame.
253
- - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
254
- translucent color overwrites. Transparency waits on blend factors +
255
- sorting (research note, staging step 4).
270
+ - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
271
+ [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
272
+ (or `shaderMaterial({ transparent: true })`) builds the pipeline with
273
+ `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
274
+ behind opaques without occluding other translucents). The one inference:
275
+ a `shaderMaterial` with any `blend` but "none" is transparent unless told
276
+ `transparent: false` - every blended draw belongs after the opaques, and
277
+ back-to-front is harmless for add/multiply. The scene owns the
278
+ order: background, opaque meshes by `renderOrder` then add order,
279
+ transparent meshes by `renderOrder` then back-to-front by the CENTER of
280
+ the mesh's world bounds in view space (not the origin: off-origin geometry
281
+ sorts by where it is; not the nearest bounds point: a big translucent
282
+ ground plane would cover the small translucents on it) - one `setDrawOrder` from sync() whenever the list changed, a
283
+ renderOrder changed, or (with two or more transparent meshes) the camera
284
+ or a transparent mesh moved, and skipped when the resort lands on the
285
+ permutation already issued. Per-mesh sort only: one non-convex translucent
286
+ mesh still overlaps itself in vertex order, and two large interpenetrating
287
+ translucents can sort wrong (center distance, not per-pixel) - that is the
288
+ engine contract, no OIT. A `shaderMaterial({ transparent: true })`
289
+ fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
256
290
  - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
257
291
  always unit). There is exactly one rotation field: no `node.rotation`
258
292
  shadowing it, because a second field is a second thing to go stale (an
@@ -329,9 +363,36 @@ system.
329
363
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
330
364
  the shared channel removed). Scene scale honestly: hundreds to a
331
365
  few thousand objects, bounded by the interpreter, not the GPU.
366
+ - SCENE-WIDE uniforms go through that same shared channel via
367
+ `scene.setParams({ uTime })`, and this is the single highest-leverage
368
+ pattern in the library. It merges an app-owned name in beside
369
+ uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
370
+ zero coverage, neither side clobbers the other. One write per frame
371
+ however many meshes read it, with the motion itself in vertex shaders
372
+ off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
373
+ is O(meshes) per frame; reach for it only when the value genuinely
374
+ differs per mesh. (`scene.texture` IS the draw target id, so
375
+ `setTargetParams(scene.texture, ...)` is the same write - setParams is
376
+ the sanctioned spelling.)
377
+ - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
378
+ `setCamera`, params), so ONE scratch array reused every frame is safe -
379
+ allocating three arrays per node per frame is pure waste. The node's own
380
+ `position`/`quaternion`/`scale` are the live arrays: read them, do not
381
+ hand them out and do not mutate them (that write does not sync).
382
+ - `setTransform` early-outs on an unchanged value (rotation compared AFTER
383
+ euler conversion), so driving every node unconditionally from `onFrame`
384
+ costs only the compare for nodes that did not move. Compares are exact,
385
+ like `setVisible`.
386
+ - Per-generator conventions - orientation, UV mapping, which axis a solid
387
+ stands on, what a cap looks like - live on each generator's doc comment,
388
+ not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
389
+ lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
390
+ map inscribed in the unit square) but the doc comment is the source.
332
391
  - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
333
- list END. Irrelevant while everything is opaque + depth-tested; revisit
334
- when transparency lands.
392
+ list END and dirty the order, so the next sync() re-sorts and the mesh
393
+ keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
394
+ (setMaterial swaps `mesh.material` before the rebuild, so _detach must
395
+ not read the new material's flag).
335
396
  - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
336
397
  to the axis at x = 0) - it is a solid of revolution, NOT Three's open
337
398
  polyline shell. An "open" outline must be closed by the author;
@@ -342,7 +403,14 @@ system.
342
403
  compile twice - no dedupe by source value (deliberate; hidden
343
404
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
344
405
  one per look at app scope, share across meshes, `dispose()` when done
345
- for good.
406
+ for good. Looks that differ only in params/textures are ONE
407
+ `shaderMaterialClass` and many `instance()`s - the app-owned split, not
408
+ a cache. A class instance has no `dispose` of its own; disposing the
409
+ class invalidates every instance.
410
+ - A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
411
+ classes must have every variant reference every shared uniform it is
412
+ seeded with: a declared-but-unused per-entry name compiles out and
413
+ throws at add(). Open item: `okf/backlog/gpu-inactive-uniform-two-tier.md`.
346
414
  - The standard-set contract is checked TEXTUALLY at shaderMaterial()
347
415
  creation (uModel and uViewProj must appear in the vertex source) and
348
416
  strictly at add() for the per-entry names: a uModel or uNormal that is
@@ -380,9 +448,8 @@ system.
380
448
  Geometry instead.
381
449
  - The background covers the whole target with depth off, drawn first: it
382
450
  REPLACES the clearColor visually (the clear still runs; you just never
383
- see it), and a translucent mesh does not blend over it in-pass (blend
384
- is none|add today - the fade-over-backdrop look still needs the
385
- two-layer composition until blend factors land).
451
+ see it), and a `transparent: true` mesh blends over it in-pass since the
452
+ background is always entry zero.
386
453
  - The background pipeline/program are SCENE-OWNED (unlike shared
387
454
  material pipelines): setBackground(null), replacement, and dispose()
388
455
  destroy them. Do not hand the background's pipeline to anything else.
package/README.md CHANGED
@@ -62,9 +62,12 @@ bounding-box accurate in v1. A scene also takes a `background` - fragment
62
62
  GLSL drawn inside its own pass behind the meshes, replacing the stacked
63
63
  backdrop-texture pattern.
64
64
  Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
65
- shared `uViewProj`/`uCamPos`, each written once per change - plus your own
66
- uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
67
- imperatively via `setMeshParams` - and
65
+ shared `uViewProj`/`uCamPos`/`uCamRight`/`uCamUp`, each written once per
66
+ change - plus your own uniforms: scene-wide via `scene.setParams` (one write
67
+ however many meshes read it), or per mesh, declaratively via the `params`
68
+ prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
69
+ compiles one program and hands out `instance()` materials that differ only
70
+ in params/textures. And
68
71
  `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
69
72
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
70
73
  from plain template literals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.49",
3
+ "version": "0.0.50",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -17,6 +17,6 @@
17
17
  ],
18
18
  "peerDependencies": {
19
19
  "@solidjs/signals": "2.0.0-rc.0",
20
- "@solidrt/core": "0.0.49"
20
+ "@solidrt/core": "0.0.50"
21
21
  }
22
22
  }
package/src/bvh.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  // pays nothing. Storage is flat parallel arrays indexed by node id (no
13
13
  // per-node objects, no allocation at steady state past tree growth).
14
14
  //
15
- // Pure module by design: no engine imports, so the differential check rig
16
- // (checks/pick-check.ts) runs it under plain bun against a linear oracle.
15
+ // Pure module by design: no GUI imports, so the differential check rig
16
+ // (checks/pick-check.ts) runs it headless on flux against a linear oracle.
17
17
 
18
18
  /** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
19
19
  * re-inserts while moving, worse query pruning; 5% is the usual trade. */
@@ -17,6 +17,7 @@ import {
17
17
  setGeometry,
18
18
  setMaterial,
19
19
  setMeshParams,
20
+ setRenderOrder,
20
21
  setTransform,
21
22
  setVisible,
22
23
  } from "./scene.ts"
@@ -179,6 +180,8 @@ export type MeshProps = TransformProps & PointerEventProps & {
179
180
  * changing every frame prefer `ref` + setMeshParams from onFrame, the
180
181
  * same split as setTransform. */
181
182
  params?: ShaderParams
183
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
184
+ renderOrder?: number
182
185
  ref?: (mesh: MeshNode) => void
183
186
  }
184
187
 
@@ -203,6 +206,10 @@ export let Mesh: VoidComponent<MeshProps> = props => {
203
206
  if (p !== undefined) setMeshParams(mesh, p)
204
207
  },
205
208
  )
209
+ createEffect(
210
+ () => props.renderOrder,
211
+ o => setRenderOrder(mesh, o ?? 0),
212
+ )
206
213
  syncNode(mesh, props)
207
214
  untrack(() => props.ref)?.(mesh)
208
215
  onCleanup(() => remove(mesh))
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible, worldPosition } from "./scene.ts"
8
+ export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
9
9
  export type { CameraUpdate, Hit, Mesh as MeshNode, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
10
10
  export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
11
11
  export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
@@ -13,8 +13,8 @@ export { fillet, roundRect, shape, triangulate } from "./profile.ts"
13
13
  export type { Profile, ProfilePoint } from "./profile.ts"
14
14
  export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
15
15
  export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
16
- export { shaderMaterial, unlit } from "./material.ts"
17
- export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
16
+ export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
17
+ export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
18
18
  export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
19
19
  export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
20
20
  export { createOrbitCamera } from "./orbit.ts"
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,
@@ -55,6 +55,10 @@ export type Material = {
55
55
  * mesh whose geometry layout differs is rejected at add() - the strides
56
56
  * disagree, so a mismatch would render garbage, not just miss a channel. */
57
57
  layout?: VertexLayout
58
+ /** True when the pipeline blends over (blend "alpha", depthWrite off):
59
+ * the scene draws this material's meshes after every opaque one, sorted
60
+ * back-to-front by mesh origin, and re-sorts them when the camera moves. */
61
+ transparent?: boolean
58
62
  /** Present on materials that own their pipeline (shaderMaterial). */
59
63
  dispose?(): void
60
64
  }
@@ -96,23 +100,30 @@ const FRAGMENT_MAP_SRC = glsl`
96
100
  `
97
101
 
98
102
  let sharedVertex: ShaderStageId | undefined
99
- let pipelines: { color?: RenderPipelineId; map?: RenderPipelineId } = {}
103
+ let pipelines: Partial<Record<UnlitClass, RenderPipelineId>> = {}
100
104
 
101
- function pipelineFor(kind: "color" | "map"): RenderPipelineId {
102
- let existing = pipelines[kind]
105
+ // One pipeline per unlit CLASS: fragment kind x transparency, since blend
106
+ // state is pipeline state.
107
+ type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
108
+
109
+ function pipelineFor(cls: UnlitClass): RenderPipelineId {
110
+ let existing = pipelines[cls]
103
111
  if (existing !== undefined) return existing
104
112
  if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
105
- let fragment = compileShader("fragment", kind === "color" ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
113
+ let transparent = cls.endsWith("-transparent")
114
+ let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
106
115
  header: true,
107
116
  })
108
- let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + kind })
117
+ let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
109
118
  let pipeline = createRenderPipeline(program, {
110
119
  attributes: VERTEX_LAYOUTS.standard,
111
120
  depth: true,
121
+ depthWrite: transparent ? false : undefined,
122
+ blend: transparent ? "alpha" : undefined,
112
123
  cull: "back",
113
- label: "scene-unlit-" + kind,
124
+ label: "scene-unlit-" + cls,
114
125
  })
115
- pipelines[kind] = pipeline
126
+ pipelines[cls] = pipeline
116
127
  return pipeline
117
128
  }
118
129
 
@@ -121,6 +132,9 @@ export type UnlitOptions = {
121
132
  color?: [number, number, number] | [number, number, number, number]
122
133
  /** A texture id to sample (tinted by `color` when both are given). */
123
134
  map?: TextureId
135
+ /** Blend over what is behind (color alpha and map alpha both count).
136
+ * Without it an alpha below 1 still draws opaque. See Material.transparent. */
137
+ transparent?: boolean
124
138
  }
125
139
 
126
140
  /**
@@ -132,10 +146,16 @@ export function unlit(opts: UnlitOptions = {}): Material {
132
146
  let color = opts.color ?? [1, 1, 1]
133
147
  let a = color.length === 4 ? color[3] : 1
134
148
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
149
+ let transparent = opts.transparent === true
135
150
  if (opts.map !== undefined) {
136
- return { pipeline: () => pipelineFor("map"), params: { uColor }, textures: { uMap: opts.map } }
151
+ return {
152
+ pipeline: () => pipelineFor(transparent ? "map-transparent" : "map"),
153
+ params: { uColor },
154
+ textures: { uMap: opts.map },
155
+ transparent,
156
+ }
137
157
  }
138
- return { pipeline: () => pipelineFor("color"), params: { uColor } }
158
+ return { pipeline: () => pipelineFor(transparent ? "color-transparent" : "color"), params: { uColor }, transparent }
139
159
  }
140
160
 
141
161
  // Mirrors the engine's own preamble rule: a source carrying its own
@@ -181,7 +201,9 @@ export function backgroundPipeline(fragment: string, label: string): { pipeline:
181
201
  return { pipeline, program }
182
202
  }
183
203
 
184
- export type ShaderMaterialOptions = {
204
+ /** The class half of a shader material: sources and pipeline state, the
205
+ * things one compiled program fixes. */
206
+ export type ShaderMaterialClassOptions = {
185
207
  /**
186
208
  * Vertex stage GLSL. MUST declare and use `uniform mat4 uModel` (the
187
209
  * mesh's world matrix, written per entry whenever the mesh moves) and
@@ -204,11 +226,16 @@ export type ShaderMaterialOptions = {
204
226
  */
205
227
  vertex: string
206
228
  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". */
229
+ /** Blend over what is behind, with the scene sorting this material's
230
+ * meshes back-to-front after the opaque ones (see Material.transparent).
231
+ * Sets the pipeline defaults blend "alpha" and depthWrite false; the
232
+ * fragment must write premultiplied output (`vec4(rgb * a, a)`). Defaults
233
+ * to true whenever `blend` is set to anything but "none": every blended
234
+ * draw belongs after the opaques so it depth-tests against them, and
235
+ * back-to-front is harmless for the order-independent modes. */
236
+ transparent?: boolean
237
+ /** Pipeline state; defaults match unlit: depth: true, cull: "back",
238
+ * and for transparent materials blend "alpha", depthWrite: false. */
212
239
  depth?: boolean
213
240
  depthWrite?: boolean
214
241
  blend?: BlendMode
@@ -217,18 +244,41 @@ export type ShaderMaterialOptions = {
217
244
  label?: string
218
245
  }
219
246
 
247
+ /** The instance half of a shader material: uniform seeds and sampler
248
+ * bindings for one parameterisation of a class's program. */
249
+ export type ShaderMaterialInstanceOptions = {
250
+ /** Uniform seeds beyond the standard set; update per mesh later with
251
+ * setMeshParams. */
252
+ params?: ShaderParams
253
+ textures?: Record<string, TextureId>
254
+ }
255
+
256
+ export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
257
+
220
258
  /**
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.
259
+ * One program and pipeline, many parameterisations: the class/instance
260
+ * split unlit has internally, for your own GLSL. `instance()` returns a
261
+ * Material sharing the class's pipeline with its own params/textures - the
262
+ * class compiles once, and dispose() is on the class alone (instances hold
263
+ * nothing of their own).
230
264
  */
231
- export function shaderMaterial(opts: ShaderMaterialOptions): Material {
265
+ export type ShaderMaterialClass = {
266
+ instance(opts?: ShaderMaterialInstanceOptions): Material
267
+ /** Destroy the shared program and pipeline. Instances still in use draw
268
+ * nothing valid afterwards. */
269
+ dispose(): void
270
+ }
271
+
272
+ /**
273
+ * A material class from your own GLSL: sources without a `#version` line
274
+ * get the standard pipeline preamble (`fragColor`, `iResolution`). Two
275
+ * calls with identical sources compile two programs - there is no dedupe by
276
+ * source value (a hidden cache keyed by content is the anti-pattern the GPU
277
+ * layer avoids throughout); the class IS the app-owned split. Create one
278
+ * per program at app scope, `instance()` per look, and `dispose()` the class
279
+ * when the app is done with the look for good.
280
+ */
281
+ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMaterialClass {
232
282
  // The standard-set contract, checked where the mistake is made: a vertex
233
283
  // stage that never mentions the matrices cannot place meshes, and with
234
284
  // shared params skipping undeclared names the omission would otherwise
@@ -245,30 +295,34 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
245
295
  // Attributes live in the vertex stage only, so unlike the uNormal scan
246
296
  // there is nothing to look for in the fragment source.
247
297
  let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
298
+ let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
299
+ let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
300
+ let depth = opts.depth ?? true
301
+ let pipelineFor = (): RenderPipelineId => {
302
+ if (pipeline === undefined) {
303
+ let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
304
+ let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
305
+ program = linkProgram(vs, fs, { label: opts.label })
306
+ destroyShader(vs)
307
+ destroyShader(fs)
308
+ pipeline = createRenderPipeline(program, {
309
+ attributes: VERTEX_LAYOUTS[layout],
310
+ depth,
311
+ // depthWrite needs a depth buffer, so the transparent default
312
+ // only applies when there is one.
313
+ depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
314
+ blend: opts.blend ?? (transparent ? "alpha" : undefined),
315
+ cull: opts.cull ?? "back",
316
+ topology: opts.topology,
317
+ label: opts.label,
318
+ })
319
+ }
320
+ return pipeline
321
+ }
248
322
  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
323
+ instance(inst = {}) {
324
+ return { normalMatrix, layout, transparent, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
269
325
  },
270
- params: opts.params ?? {},
271
- textures: opts.textures,
272
326
  dispose() {
273
327
  if (pipeline !== undefined) {
274
328
  destroyRenderPipeline(pipeline)
@@ -281,3 +335,20 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
281
335
  },
282
336
  }
283
337
  }
338
+
339
+ /**
340
+ * A material from your own GLSL: the custom-look escape hatch, first-class
341
+ * next to unlit. A class with a single instance - `shaderMaterialClass()`
342
+ * is the form for one program with many parameterisations.
343
+ *
344
+ * The INSTANCE is the pipeline handle: two calls with identical sources
345
+ * compile two pipelines - there is no dedupe by source value. Create one
346
+ * per look at app scope, share it across meshes, and `dispose()` it if the
347
+ * app is done with the look for good.
348
+ */
349
+ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
350
+ let cls = shaderMaterialClass(opts)
351
+ let material = cls.instance(opts)
352
+ material.dispose = cls.dispose
353
+ return material
354
+ }
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
+ }
package/src/scene.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  // component boundary (components.tsx). A scene compiles to one draw
4
4
  // target: every mesh is one draw entry whose uModel (and, for materials
5
5
  // declaring it, uNormal) this module keeps in step with the tree, and the
6
- // camera is the target's SHARED uViewProj + uCamPos - one setTargetParams
7
- // per camera move, not one write per mesh. uCamPos rides unconditionally:
8
- // shared params tolerate zero coverage (stored and skipped until a
9
- // declaring material arrives), so no bookkeeping tracks who reads it.
6
+ // camera is the target's SHARED uViewProj + uCamPos + uCamRight/uCamUp -
7
+ // one setTargetParams per camera move, not one write per mesh. The
8
+ // non-matrix names ride unconditionally: shared params tolerate zero
9
+ // coverage (stored and skipped until a declaring material arrives), so no
10
+ // bookkeeping tracks who reads them. scene.setParams merges app-owned
11
+ // names into the same set.
10
12
  // Mutations batch to a microtask, so a burst of writes (a whole subtree
11
13
  // moved, many effects in one flush) syncs once.
12
14
  //
@@ -17,17 +19,18 @@
17
19
  // each write lands here, the microtask syncs the affected uModels, and the
18
20
  // flush renders once that frame.
19
21
 
20
- import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
22
+ import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
23
  import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
22
24
  import { getOwner, onCleanup } from "@solidrt/core"
23
25
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
24
26
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
25
27
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
26
- import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
28
+ import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
27
29
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
28
30
  import { geometryBounds, geometryBuffers } from "./geometry.ts"
29
31
  import type { Geometry } from "./geometry.ts"
30
32
  import { backgroundPipeline } from "./material.ts"
33
+ import { orderEntries } from "./order.ts"
31
34
  import type { Material } from "./material.ts"
32
35
  import { createBvh, rayBoxDistance } from "./bvh.ts"
33
36
 
@@ -51,6 +54,9 @@ let upScratch: Vec3 = [0, 0, 0]
51
54
  let pickInv = mat4()
52
55
  let pickOrigin: Vec4 = [0, 0, 0, 0]
53
56
  let pickDir: Vec3 = [0, 0, 0]
57
+ // setTransform's rotation compare happens AFTER conversion, so an euler and
58
+ // the quaternion it produces are the same write. Nothing outlives the call.
59
+ let rotScratch = quat()
54
60
 
55
61
  // The scene half a node needs to reach: attach/detach entries and schedule
56
62
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -62,6 +68,7 @@ type SceneHooks = {
62
68
  _attach(mesh: Mesh): void
63
69
  _detach(mesh: Mesh): void
64
70
  _setParams(mesh: Mesh, params: ShaderParams): void
71
+ _reorder(): void
65
72
  }
66
73
 
67
74
  export type SceneNode = {
@@ -97,7 +104,18 @@ export type Mesh = SceneNode & {
97
104
  kind: "mesh"
98
105
  geometry: Geometry
99
106
  material: Material
107
+ /** Explicit draw-order key (default 0), Three's name: lower draws first.
108
+ * Sorts within the opaque group and within the transparent group; the
109
+ * transparent group always follows the opaque one. Set with setRenderOrder. */
110
+ renderOrder: number
100
111
  _entry: DrawId | null
112
+ /** material.transparent as of the last attach - the entry's actual
113
+ * pipeline state, and what _detach counts against (setMaterial swaps
114
+ * mesh.material before the rebuild). */
115
+ _transparent: boolean
116
+ /** World-space center of the geometry bounds, kept by the sync walk
117
+ * beside the picking leaf: the transparent sort key. */
118
+ _center: Vec3
101
119
  _hidden: boolean
102
120
  _fresh: boolean
103
121
  _params: ShaderParams | null
@@ -184,6 +202,15 @@ export type Scene = {
184
202
  /** Partial camera update; absent keys keep their current value. */
185
203
  setCamera(update: CameraUpdate): void
186
204
  setSize(width: number, height: number): void
205
+ /**
206
+ * Scene-wide uniforms: merge app-owned names into the target's SHARED
207
+ * params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
208
+ * camera writes. One write per frame however many meshes read the name
209
+ * (a clock, a sun direction, fog) - the per-mesh channel is
210
+ * setMeshParams. Merge semantics, no unset; a material that does not
211
+ * declare a name simply skips it. Frame-rate-safe like setTransform.
212
+ */
213
+ setParams(params: ShaderParams): void
187
214
  /**
188
215
  * Set, replace, or remove (null) the scene's background: fragment GLSL
189
216
  * drawn as the FIRST entry of the scene's own pass - one target, no
@@ -279,7 +306,10 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
279
306
  let mesh = makeNode("mesh") as Mesh
280
307
  mesh.geometry = geometry
281
308
  mesh.material = material
309
+ mesh.renderOrder = 0
282
310
  mesh._entry = null
311
+ mesh._transparent = false
312
+ mesh._center = [0, 0, 0]
283
313
  mesh._hidden = false
284
314
  mesh._fresh = false
285
315
  mesh._params = null
@@ -340,33 +370,55 @@ export type TransformUpdate = {
340
370
  * Values are copied in; absent keys keep their current value. This is also
341
371
  * the frame-rate escape hatch: call it from onFrame on a node grabbed via
342
372
  * `ref`, bypassing signals entirely.
373
+ *
374
+ * A write that changes nothing schedules nothing, so driving every node
375
+ * unconditionally from onFrame costs only the compare for the nodes that
376
+ * did not move. Rotation is compared after conversion, so passing an euler
377
+ * equal to the node's current quaternion is also a no-op.
343
378
  */
344
379
  export function setTransform(node: SceneNode, update: TransformUpdate): void {
380
+ let r = update.rotation
381
+ let q = update.quaternion
382
+ if (r !== undefined && q !== undefined) {
383
+ throw new Error("Pass rotation or quaternion to setTransform, not both")
384
+ }
385
+ // A no-op write costs nothing: driving every node from onFrame is the
386
+ // intended shape, and most nodes did not move. Exact compares, like
387
+ // setVisible - a value that survives a float round trip unchanged is the
388
+ // same value, and an epsilon would need a scale-dependent one anyway.
389
+ let changed = false
345
390
  let p = update.position
346
- if (p) {
391
+ if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
347
392
  node.position[0] = p[0]
348
393
  node.position[1] = p[1]
349
394
  node.position[2] = p[2]
395
+ changed = true
350
396
  }
351
- let r = update.rotation
352
- let q = update.quaternion
353
- if (r !== undefined && q !== undefined) {
354
- throw new Error("Pass rotation or quaternion to setTransform, not both")
397
+ if (r !== undefined) quatFromEuler(rotScratch, r)
398
+ else if (q !== undefined) quatNormalize(rotScratch, q)
399
+ if (r !== undefined || q !== undefined) {
400
+ let n = node.quaternion
401
+ if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
402
+ n[0] = rotScratch[0]
403
+ n[1] = rotScratch[1]
404
+ n[2] = rotScratch[2]
405
+ n[3] = rotScratch[3]
406
+ changed = true
407
+ }
355
408
  }
356
- if (r !== undefined) quatFromEuler(node.quaternion, r)
357
- else if (q !== undefined) quatNormalize(node.quaternion, q)
358
409
  let s = update.scale
359
410
  if (s !== undefined) {
360
- if (typeof s === "number") {
361
- node.scale[0] = s
362
- node.scale[1] = s
363
- node.scale[2] = s
364
- } else {
365
- node.scale[0] = s[0]
366
- node.scale[1] = s[1]
367
- node.scale[2] = s[2]
411
+ let sx = typeof s === "number" ? s : s[0]
412
+ let sy = typeof s === "number" ? s : s[1]
413
+ let sz = typeof s === "number" ? s : s[2]
414
+ if (sx !== node.scale[0] || sy !== node.scale[1] || sz !== node.scale[2]) {
415
+ node.scale[0] = sx
416
+ node.scale[1] = sy
417
+ node.scale[2] = sz
418
+ changed = true
368
419
  }
369
420
  }
421
+ if (!changed) return
370
422
  node._localDirty = true
371
423
  node._scene?._schedule()
372
424
  }
@@ -482,8 +534,15 @@ export function setVisible(node: SceneNode, visible: boolean): void {
482
534
  node._scene?._schedule()
483
535
  }
484
536
 
485
- /** Swap a mesh's geometry: its draw entry is rebuilt (appended last -
486
- * order is irrelevant while every entry is opaque and depth-tested). */
537
+ /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
538
+ export function setRenderOrder(mesh: Mesh, order: number): void {
539
+ if (mesh.renderOrder === order) return
540
+ mesh.renderOrder = order
541
+ mesh._scene?._reorder()
542
+ }
543
+
544
+ /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
545
+ * the list, so the mesh keeps its place). */
487
546
  export function setGeometry(mesh: Mesh, geometry: Geometry): void {
488
547
  if (mesh.geometry === geometry) return
489
548
  mesh.geometry = geometry
@@ -545,11 +604,25 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
545
604
  let capture = new Map<number, Mesh>()
546
605
  let hover = new Map<number, Mesh>()
547
606
 
548
- // Live mesh entries in list (draw) order, so a background set after
549
- // meshes exist can insert BEFORE the first one. Mesh entries append;
550
- // rebuilds re-append; the background entry never joins this list.
551
- let entryOrder: DrawId[] = []
607
+ // Live meshes (those holding a draw entry) in add order; the background
608
+ // entry never joins this list. Draw order is derived from it by
609
+ // orderEntries (order.ts) whenever orderDirty. Camera moves and
610
+ // transparent-mesh moves only dirty the order when two or more transparent
611
+ // meshes exist - fewer cannot change relative order.
612
+ let meshes: Mesh[] = []
613
+ let transparentCount = 0
614
+ let orderDirty = false
615
+ // The order last handed to the engine: a resort that lands on the same
616
+ // permutation (the common case under a moving camera) issues nothing.
617
+ let lastOrder: DrawId[] = []
552
618
  let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
619
+ let sortEntries = () => {
620
+ orderDirty = false
621
+ let order = orderEntries(meshes, view, background?.entry)
622
+ if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
623
+ lastOrder = order
624
+ setDrawOrder(texture, order)
625
+ }
553
626
 
554
627
  // Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
555
628
  // the local box's center/extents carried through the absolute matrix (the
@@ -566,6 +639,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
566
639
  let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
567
640
  let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
568
641
  let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
642
+ mesh._center[0] = wx
643
+ mesh._center[1] = wy
644
+ mesh._center[2] = wz
569
645
  let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
570
646
  let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
571
647
  let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
@@ -610,7 +686,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
610
686
  // holds. Entries are untouched - uModel is camera-independent, and
611
687
  // uCamPos is stored even when no current material declares it.
612
688
  cameraPending = false
613
- setTargetParams(texture, { uViewProj: viewProj, uCamPos: eye })
689
+ // The camera basis rides along: the view matrix's first two rows are
690
+ // the camera's world-space right and up (no clip flip - that lives in
691
+ // the projection), so a billboard needs no reconstruction from uViewProj.
692
+ setTargetParams(texture, {
693
+ uViewProj: viewProj,
694
+ uCamPos: eye,
695
+ uCamRight: [view[0], view[4], view[8]],
696
+ uCamUp: [view[1], view[5], view[9]],
697
+ })
698
+ if (transparentCount > 1) orderDirty = true
614
699
  }
615
700
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
616
701
  let changed = parentChanged
@@ -632,6 +717,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
632
717
  mesh._hidden = !shown
633
718
  if (shown) mesh._fresh = true
634
719
  }
720
+ if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
635
721
  if (!mesh._hidden && (changed || mesh._fresh)) {
636
722
  if (mesh.material.normalMatrix) {
637
723
  setDrawParams(texture, mesh._entry, {
@@ -655,6 +741,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
655
741
  for (let c of node.children) walk(c, changed, shown)
656
742
  }
657
743
  walk(root, false, true)
744
+ if (orderDirty) sortEntries()
658
745
  }
659
746
 
660
747
  let hooks: SceneHooks = {
@@ -695,7 +782,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
695
782
  textures: mesh.material.textures,
696
783
  instanceCount: 0,
697
784
  })
698
- entryOrder.push(mesh._entry)
785
+ meshes.push(mesh)
786
+ mesh._transparent = mesh.material.transparent === true
787
+ if (mesh._transparent) transparentCount++
788
+ orderDirty = true
699
789
  mesh._hidden = true
700
790
  mesh._fresh = true
701
791
  this._schedule()
@@ -703,8 +793,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
703
793
  _detach(mesh) {
704
794
  if (mesh._entry !== null) {
705
795
  if (!disposed) removeDraw(texture, mesh._entry)
706
- let i = entryOrder.indexOf(mesh._entry)
707
- if (i >= 0) entryOrder.splice(i, 1)
796
+ let i = meshes.indexOf(mesh)
797
+ if (i >= 0) meshes.splice(i, 1)
798
+ if (mesh._transparent) transparentCount--
799
+ orderDirty = true
708
800
  }
709
801
  mesh._entry = null
710
802
  // The leaf goes with the entry: a geometry swap rebuilds the entry,
@@ -717,6 +809,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
717
809
  _setParams(mesh, params) {
718
810
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
719
811
  },
812
+ _reorder() {
813
+ orderDirty = true
814
+ this._schedule()
815
+ },
720
816
  }
721
817
 
722
818
  let root = makeNode("group")
@@ -864,6 +960,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
864
960
  cameraDirty = true
865
961
  hooks._schedule()
866
962
  },
963
+ setParams(params) {
964
+ if (!disposed) setTargetParams(texture, params)
965
+ },
867
966
  setBackground(source) {
868
967
  if (disposed) return
869
968
  if (background !== null) {
@@ -874,9 +973,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
874
973
  }
875
974
  if (source === null) return
876
975
  let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
877
- // First in list order: meshes append behind it forever (rebuilds
878
- // re-append too), so pinning happens once, here.
879
- let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: entryOrder[0] })
976
+ // First in list order: inserted before the first mesh entry, and every
977
+ // later sort keeps it there.
978
+ let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
880
979
  background = { entry, pipeline: built.pipeline, program: built.program }
881
980
  },
882
981
  project(point) {