@solidrt/3d 0.0.46 → 0.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,9 +12,9 @@ blendMode and pointer events like any element. Design rationale:
12
12
  - Two layers. The imperative core is Solid-free: `createScene`,
13
13
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
14
  `setVisible` - plain objects with dirty flags, batched to a microtask,
15
- one `setDrawParams` (the uModel matrix) per changed mesh and ONE
16
- `setTargetParams` (the shared uViewProj) per camera change, however many
17
- meshes. The component
15
+ one `setDrawParams` (uModel, plus uNormal for materials declaring it)
16
+ per changed mesh and ONE `setTargetParams` (the shared uViewProj +
17
+ uCamPos) per camera change, however many meshes. The component
18
18
  face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
19
19
  core over context and renders nothing itself.
20
20
  - Rendering is the runtime's. The target is `render: "auto"`: it
@@ -23,9 +23,23 @@ blendMode and pointer events like any element. Design rationale:
23
23
  own `onFrame` writing a signal (declarative) or `setTransform` on a
24
24
  `ref`-grabbed node (the frame-rate escape hatch - signals carry
25
25
  structure, per-frame motion goes straight to the scene).
26
- - One vertex layout everywhere: `aPos` vec3 + `aNormal` vec3 + `aUV` vec2,
27
- uint16-indexed. Geometry GPU buffers are lazy, shared, and app-lifetime
28
- (owner-scoped free would break sharing); `disposeGeometry` frees them.
26
+ - Two named vertex layouts (`Geometry.layout`, absent = "standard"):
27
+ "standard" is `aPos` vec3 + `aNormal` vec3 + `aUV` vec2 - what every
28
+ generator emits - and "colored" appends `aColor` vec4, the per-vertex
29
+ data channel (a tint, baked AO, any four scalars; standard name, your
30
+ contents). Derive colored geometry with `withColors(geometry, fill)` -
31
+ fill is a flat 4-per-vertex array or a per-vertex callback receiving
32
+ `(index, pos, normal, uv)`. Geometry and material layouts must match
33
+ (layout is stride); a mismatched pair throws at add(). The whole layout
34
+ ships whether a material reads every attribute or not (inactive
35
+ attributes only keep the stride), so colored vertices cost 12 floats
36
+ regardless - keep data-light passes (a wireframe reading only aPos) on
37
+ standard geometry.
38
+ Indices are uint16 or uint32 - the `Geometry.indices` array type picks
39
+ the draw's index format, so hand-built geometry past 64k vertices just
40
+ uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
41
+ lazy, shared, and app-lifetime (owner-scoped free would break sharing);
42
+ `disposeGeometry` frees them.
29
43
  - Materials dedupe hard: one program + one pipeline per material CLASS
30
44
  (unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
31
45
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
@@ -34,42 +48,131 @@ blendMode and pointer events like any element. Design rationale:
34
48
 
35
49
  | Component | Props |
36
50
  | --- | --- |
37
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)` |
51
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)`, `output?(texture)` |
38
52
  | `Group` | `position?`, `rotation?` (Euler radians, x-y-z order), `scale?` (number = uniform), `visible?`, `ref?(node)` |
39
53
  | `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
40
54
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
41
55
 
56
+ Output composition: without `output`, `Scene` emits a minimal
57
+ `<texture width height>` leaf and nothing else is forwarded - anything
58
+ more goes through `output(texture)`, which renders in place of that leaf:
59
+ a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
60
+ post-effect chain (`createShaderTarget` sampling the id with a
61
+ covering-triangle pass; created in the callback it disposes with the
62
+ Scene). Return null for no leaf at all and compose `scene.texture`
63
+ elsewhere. Called once, untracked, inside the scene context. Scene
64
+ `width`/`height` are target pixels and the leaf's own width/height are
65
+ layout, so render and display size separate - render at 2x and display
66
+ smaller for supersampling.
67
+
42
68
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
43
69
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
44
- zoomSpeed? })` - drag-to-rotate, wheel-to-zoom, optional auto-orbit. Spread
70
+ zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
71
+ - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
72
+ auto-orbit. Input runs on core's `createTransform` recognizer, so drag and
73
+ pinch arbitrate in the app-wide gesture arena (a viewport inside a scroller
74
+ does not double-handle) and rotation starts after the recognizer's slop;
75
+ `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
76
+ scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
77
+ `viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
78
+ without it, it rotates like one finger; `clampTarget(target)` bounds where
79
+ a pan may put the pivot. Zoom aims
80
+ at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
81
+ / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
82
+ that point stays pinned under the pointer and the target slides toward it;
83
+ only the app can build that mapping, since fov, aspect and element placement
84
+ are app state. Pair it with `rotateAnchor({eye, target})`: called at gesture
85
+ start, its point is projected onto the view axis and re-seats the pivot
86
+ without moving the picture, so a drag after an anchored zoom orbits what the
87
+ camera looks at, not wherever the zoom left the target. Spread
45
88
  `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
46
89
  from your onFrame (no frame loop of its own), and use its return - true
47
- when the pose changed - to gate per-frame dependents like a `uCamPos`
48
- write. `orbiting()` is reactive (HUD-safe); the pose is plain state via
90
+ when the pose changed - to gate per-frame dependents like reprojecting
91
+ HUD overlays. `orbiting()` is reactive (HUD-safe); the pose is plain state via
49
92
  `pose()`/`set()` (also the debug-command shape). It drives position and
50
93
  target only; fov/near/far stay on scene.setCamera. In a component tree,
51
94
  reach the scene via `<Scene ref>` or useScene().
52
95
 
53
- Geometry: `box(w?, h?, d?)`, `plane(w?, h?)` (XY, faces +z - rotate
54
- `[-Math.PI/2, 0, 0]` for a floor), `sphere(radius?, wSeg?, hSeg?)`,
55
- `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)` (standing
56
- y-up, unlike Three's z-up).
96
+ Overlay projection: `scene.project(point)` maps a world point to scene
97
+ pixels (top-left origin, y down - the output texture's own space; `w` is
98
+ clip-space w, the camera-forward distance) and returns null for a point
99
+ at or behind the camera plane. It reflects a pending `setCamera`
100
+ immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
101
+ copies the view-projection matrix for batch work. Never rebuild the
102
+ camera matrices by hand for a HUD.
103
+
104
+ Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
105
+ `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
106
+ for a floor); `sphere(radius?, wSeg?, hSeg?)`;
107
+ `cylinder(rTop?, rBottom?, height?, radialSeg?)` (y axis, capped; unequal
108
+ radii taper it) and `cone(radius?, height?, radialSeg?)`;
109
+ `torus(radius?, tube?, radialSeg?, tubularSeg?)` (lying flat, hole on the
110
+ y axis) and `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)`
111
+ (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
112
+ `withColors(geometry, fill, label?)` derives a "colored"-layout copy of
113
+ any standard-layout geometry (generator or hand-built), adding the
114
+ `aColor` vec4 channel; the source is untouched.
115
+ `fillColors(vertices, fill, first?, count?)` is the in-place primitive
116
+ under it: writes the aColor slots of a colored-layout interleave you
117
+ already own (a merging builder's packed buffer), reading pos/normal/uv
118
+ from the buffer itself - so a packer that bakes transforms while writing
119
+ hands the baker world-space vertices. `fill` indexes relative to
120
+ `first`. It trusts the buffer's layout (no tag to check); withColors is
121
+ the checked path.
122
+
123
+ Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
124
+ closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
125
+ share an averaged normal - `fillet(points, radius, segs?)` and
126
+ `roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
127
+ Winding is normalized, so either authoring direction works.
128
+ `extrude(profile, depth?, bevel?, bevelSegs?)` sweeps along z, centered,
129
+ with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
130
+ start?)` revolves a CLOSED (x = radius, y = height) profile about the y
131
+ axis - watertight by construction, flat caps on partial sweeps;
132
+ `shape(profile)` fills one flat (facing +z, like circle);
133
+ `triangulate(points)` is the ear-clipping core (fan fallback, never drops
134
+ a cap), exported for custom flat work. These pick uint16/uint32 indices
135
+ by vertex count automatically.
136
+
57
137
  Materials:
58
138
 
59
139
  - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
60
140
  internally.
61
141
  - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
62
142
  depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
63
- custom-look escape hatch. The vertex stage MUST declare and use BOTH
64
- `uniform mat4 uModel` (the mesh's world matrix, per entry) and
65
- `uniform mat4 uViewProj` (the camera, shared target-level params) -
66
- transform with `uViewProj * uModel * vec4(aPos, 1.0)`; attributes come
67
- from the shared layout by name; sources without
68
- `#version` get the standard pipeline preamble. App-driven uniforms
69
- beyond uModel/uViewProj: seed via `params`, then write per mesh with
143
+ custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
144
+ MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
145
+ per entry) and `uniform mat4 uViewProj` (the camera, shared
146
+ target-level params) - transform with
147
+ `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
148
+ at shaderMaterial() creation. The rest is opt-in by declare-and-use:
149
+ `uniform vec3 uCamPos` (the camera's world position, shared and written
150
+ with uViewProj - the specular/fresnel view vector is
151
+ `normalize(uCamPos - worldPos)`) and `uniform mat4 uNormal` (the world
152
+ inverse-transpose, written beside uModel for this material's meshes;
153
+ take `mat3(uNormal)` - correct under non-uniform scale, where
154
+ mat3(uModel) bends normals off the surface). Attributes come from the
155
+ geometry's layout by name; a vertex stage reading `in vec4 aColor` opts
156
+ the material into the "colored" layout, and its meshes then need
157
+ `withColors()` geometry. Sources without `#version` get the standard
158
+ pipeline preamble. App-driven uniforms beyond the standard set: seed
159
+ via `params`, then write per mesh with
70
160
  `setMeshParams(mesh, { name: value })` (validated names; values persist
71
161
  across entry rebuilds; frame-rate-safe like setTransform).
72
162
 
163
+ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
164
+ into shaderMaterial sources with plain template literals - `LIT_VERTEX`
165
+ (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
166
+ varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
167
+ plus the colored layout's aColor forwarded raw as vColor - using it opts
168
+ the material into that layout) and the pure functions `HEMISPHERE`
169
+ (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
170
+ `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
171
+ (`fresnel(n, v, power)`). Lights, colors and exponents are arguments, so
172
+ nothing is pinned but the function names; future lit material classes
173
+ compose from these same constants - customizing never means leaving the
174
+ system.
175
+
73
176
  ## Traps
74
177
 
75
178
  - The y-down clip flip is baked into `perspective()`; scene code and
@@ -87,13 +190,18 @@ Materials:
87
190
  v1.
88
191
  - Transforms have ONE write path: `setTransform`/`setVisible` (or the
89
192
  props that call them). Mutating `node.position` directly does not sync.
90
- - A camera change is ONE `setTargetParams` write (uViewProj is target
91
- state), independent of mesh count - never reintroduce per-mesh camera
92
- writes. Scene scale honestly: hundreds to a
193
+ - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
194
+ target state), independent of mesh count - never reintroduce per-mesh
195
+ camera writes (uEye-style per-mesh params are exactly the O(scene) cost
196
+ the shared channel removed). Scene scale honestly: hundreds to a
93
197
  few thousand objects, bounded by the interpreter, not the GPU.
94
198
  - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
95
199
  list END. Irrelevant while everything is opaque + depth-tested; revisit
96
200
  when transparency lands.
201
+ - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
202
+ to the axis at x = 0) - it is a solid of revolution, NOT Three's open
203
+ polyline shell. An "open" outline must be closed by the author;
204
+ otherwise the shape is simply wrong, there is no open-profile mode.
97
205
  - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
98
206
  context).
99
207
  - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
@@ -101,14 +209,16 @@ Materials:
101
209
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
102
210
  one per look at app scope, share across meshes, `dispose()` when done
103
211
  for good.
104
- - A shaderMaterial vertex stage without `uniform mat4 uModel` (declared
105
- AND used) throws at mesh attach - the scene seeds uModel on every entry
106
- and the engine rejects unknown uniform names. One without `uViewProj`
107
- also throws at attach when it is the scene's ONLY material class: after
108
- the first camera sync, _attach re-issues the shared uViewProj (same
109
- value, one write) so the coverage error lands at add() instead of
110
- inside a later camera-sync microtask. Only the very first attach (no
111
- camera sync yet) reports it asynchronously, from the sync that attach
112
- schedules. With other declaring materials present it silently ignores
113
- the camera instead (partial coverage is legal). Declare and use both,
114
- always.
212
+ - The standard-set contract is checked TEXTUALLY at shaderMaterial()
213
+ creation (uModel and uViewProj must appear in the vertex source) and
214
+ strictly at add() for the per-entry names: a uModel or uNormal that is
215
+ declared but never USED compiles out, and the scene's entry seed then
216
+ throws at attach (the engine rejects unknown entry uniform names). The
217
+ shared names have no such backstop - a declared-but-unused uViewProj or
218
+ uCamPos is skipped silently (shared params tolerate zero coverage), so
219
+ the symptom is an untransformed or unlit render, not an error. Use what
220
+ you declare.
221
+ - The layout scan is textual the same way: any `aColor` token in the
222
+ vertex source - a comment counts - selects the "colored" layout, and
223
+ the material then rejects standard geometry at add(). Do not mention
224
+ aColor you do not read.
package/README.md CHANGED
@@ -4,6 +4,8 @@ A retained 3D scene graph for SolidRT: meshes, materials and a camera,
4
4
  declared as Solid components, rendered by the runtime into an ordinary
5
5
  texture in your UI tree.
6
6
 
7
+ _@solidrt/3d is experimental: expect more API churn here than in the rest of SolidRT._
8
+
7
9
  ```tsx
8
10
  import { createSignal, onFrame, render } from "@solidrt/core"
9
11
  import { box, Mesh, PerspectiveCamera, Scene, unlit } from "@solidrt/3d"
@@ -27,16 +29,31 @@ The scene compiles to one depth-buffered GPU draw target: one draw entry
27
29
  per mesh, one shared pipeline per material class, cross-mesh occlusion
28
30
  from the shared depth buffer. A static scene costs zero GPU passes - the
29
31
  runtime re-renders the target only when something changes - and a moved
30
- mesh costs one uniform write.
32
+ mesh costs one uniform write. By default `<Scene>` composites the target
33
+ as a plain `<texture>` leaf; the `output` prop receives the texture id
34
+ and replaces that leaf - place a `<d-texture>`, add paint or pointer
35
+ props, chain a post-effect shader target, or return null and composite
36
+ `scene.texture` yourself.
31
37
 
32
38
  There is also an imperative layer underneath (`createScene`, `createMesh`,
33
39
  `setTransform`, ...) usable without components, plus a small math module
34
- (`@solidrt/3d/math`: column-major mat4, perspective, lookAt).
40
+ (`@solidrt/3d/math`: column-major mat4, perspective, lookAt). For HUD
41
+ overlays, `scene.project(point)` maps a world point to scene pixels.
42
+ Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
43
+ shared `uViewProj`/`uCamPos`, each written once per change - and
44
+ `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
45
+ blinn, fresnel, a standard vertex stage) to compose your own lit looks
46
+ from plain template literals.
35
47
 
36
48
  v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
37
- GLSL as a first-class material), box/plane/sphere geometry, one
38
- perspective camera with an orbit control (`createOrbitCamera`: drag,
39
- zoom, auto-orbit). Lights, transparency, model loading and picking are
49
+ GLSL as a first-class material), geometry generators (box, plane, circle,
50
+ ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
51
+ solids (`extrude` with bevels, `lathe`, flat `shape`, with `fillet`/
52
+ `roundRect`/`triangulate` helpers), a per-vertex data channel
53
+ (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
54
+ to any geometry, for materials that read it), one perspective camera
55
+ with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit).
56
+ Lights, transparency, model loading and picking are
40
57
  staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
41
58
  usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
42
59
  [examples/](examples/).
@@ -0,0 +1,82 @@
1
+ // The `output` prop: Scene hands you its texture id and you compose the
2
+ // leaf yourself, in place of the built-in `<texture>`. Here a fragment
3
+ // pass samples the scene and adds chromatic aberration plus a vignette,
4
+ // and the scene renders at 2x display size - the post pass doubles as the
5
+ // downsample (the default linear sampler box-averages the 2x2 quad), so
6
+ // the same chain is also free supersampling. Sampled textures are live
7
+ // dependencies: the post pass re-renders exactly when the scene target
8
+ // does, and a static scene still costs zero passes.
9
+ //
10
+ // The same slot takes a `<d-texture>`, a leaf with blendMode/fit/pointer
11
+ // props, or `() => null` (headless - composite scene.texture elsewhere).
12
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
13
+ import { createShaderTexture } from "@solidrt/core/gpu"
14
+ import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
15
+
16
+ const SIZE = 720 // display pixels (the leaf)
17
+ const RENDER = SIZE * 2 // target pixels (the scene)
18
+
19
+ // vUV, iResolution, and fragColor come from the standard shader-texture
20
+ // preamble; uSource is bound via the `textures` option.
21
+ const POST = `
22
+ uniform sampler2D uSource;
23
+ void main() {
24
+ vec2 c = vUV - 0.5;
25
+ float r2 = dot(c, c);
26
+ // Chromatic aberration: red and blue sample at slightly shifted radii.
27
+ vec2 shift = c * r2 * 0.04;
28
+ vec3 col = vec3(
29
+ texture(uSource, vUV + shift).r,
30
+ texture(uSource, vUV).g,
31
+ texture(uSource, vUV - shift).b);
32
+ // Vignette.
33
+ col *= 1.0 - 1.1 * r2;
34
+ fragColor = vec4(col, 1.0);
35
+ }`
36
+
37
+ function App() {
38
+ let [spin, setSpin] = createSignal(0)
39
+ onFrame(tick => setSpin(tick / 2000))
40
+
41
+ let cube = box()
42
+ let floor = plane(6, 6, "floor")
43
+ let ball = sphere(0.35)
44
+
45
+ return (
46
+ <window>
47
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
48
+ <Scene
49
+ width={RENDER}
50
+ height={RENDER}
51
+ clearColor={[0.07, 0.07, 0.1, 1]}
52
+ label="scene-post"
53
+ output={tex => {
54
+ // Created inside the callback, the post target disposes with
55
+ // the Scene.
56
+ let post = createShaderTexture(POST, SIZE, SIZE, null, { textures: { uSource: tex } })
57
+ return <texture src={post} width={SIZE} height={SIZE} />
58
+ }}
59
+ >
60
+ <PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
61
+ <Mesh
62
+ geometry={floor}
63
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
64
+ rotation={[-Math.PI / 2, 0, 0]}
65
+ />
66
+ <Group rotation={[0, spin(), 0]}>
67
+ <Mesh geometry={cube} material={unlit({ color: [0.85, 0.3, 0.3] })} position={[0, 0.5, 0]} />
68
+ <Mesh
69
+ geometry={cube}
70
+ material={unlit({ color: [0.9, 0.8, 0.35] })}
71
+ position={[-1.1, 0.7, 0]}
72
+ scale={[0.5, 1.4, 0.5]}
73
+ />
74
+ <Mesh geometry={ball} material={unlit({ color: [0.35, 0.65, 0.9] })} position={[1.1, 0.35, 0]} />
75
+ </Group>
76
+ </Scene>
77
+ </view>
78
+ </window>
79
+ )
80
+ }
81
+
82
+ render(() => <App />)
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.46",
3
+ "version": "0.0.47",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
7
7
  "main": "src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./math": "./src/math.ts"
10
+ "./math": "./src/math.ts",
11
+ "./glsl": "./src/glsl.ts"
11
12
  },
12
13
  "files": [
13
14
  "src/",
@@ -16,6 +17,6 @@
16
17
  ],
17
18
  "peerDependencies": {
18
19
  "@solidjs/signals": "2.0.0-beta.31",
19
- "@solidrt/core": "0.0.46"
20
+ "@solidrt/core": "0.0.47"
20
21
  }
21
22
  }
@@ -7,7 +7,7 @@
7
7
  // structure and slow state, per-frame motion goes straight to the scene.
8
8
 
9
9
  import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
10
- import type { ParentComponent, VoidComponent } from "@solidrt/core"
10
+ import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
11
11
  import {
12
12
  add,
13
13
  createGroup,
@@ -54,18 +54,29 @@ function syncNode(node: SceneNode, props: TransformProps): void {
54
54
  }
55
55
 
56
56
  export type SceneProps = {
57
+ /** Target pixels. With `output`, the leaf's own width/height are layout,
58
+ * so render size and display size separate (supersampling). */
57
59
  width: number
58
60
  height: number
59
61
  clearColor?: [number, number, number, number]
60
62
  label?: string
61
63
  ref?: (scene: SceneHandle) => void
64
+ /**
65
+ * Compose the output yourself: called once (untracked) with the scene's
66
+ * texture id, and its return renders in place of the built-in `<texture>`
67
+ * leaf - a `<d-texture>`, a leaf carrying paint/pointer/layout props, or
68
+ * a post-effect chain (a shader target sampling the id; created in the
69
+ * callback it disposes with the Scene). Return null to render no leaf.
70
+ */
71
+ output?: (texture: TextureId) => Element
62
72
  }
63
73
 
64
74
  /**
65
75
  * Owns a draw target and composites it as an ordinary `<texture>` leaf, so
66
76
  * the output takes layout, transforms, blendMode, and pointer events like
67
- * any element. Children (Mesh/Group/PerspectiveCamera) render nothing
68
- * themselves - they populate the retained scene through context.
77
+ * any element - or hand `output` the texture id and compose it yourself.
78
+ * Children (Mesh/Group/PerspectiveCamera) render nothing themselves - they
79
+ * populate the retained scene through context.
69
80
  */
70
81
  export let Scene: ParentComponent<SceneProps> = props => {
71
82
  let scene = untrack(() =>
@@ -76,9 +87,14 @@ export let Scene: ParentComponent<SceneProps> = props => {
76
87
  ([w, h]) => scene.setSize(w, h),
77
88
  )
78
89
  untrack(() => props.ref)?.(scene)
90
+ let output = untrack(() => props.output)
79
91
  return (
80
92
  <SceneContext value={{ scene, parent: scene.root }}>
81
- <texture src={scene.texture} width={props.width} height={props.height} />
93
+ {output ? (
94
+ untrack(() => output(scene.texture))
95
+ ) : (
96
+ <texture src={scene.texture} width={props.width} height={props.height} />
97
+ )}
82
98
  {props.children}
83
99
  </SceneContext>
84
100
  )