@solidrt/3d 0.0.46
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 +114 -0
- package/README.md +42 -0
- package/examples/README.md +9 -0
- package/examples/scene-basic.tsx +53 -0
- package/package.json +21 -0
- package/src/components.tsx +148 -0
- package/src/geometry.ts +227 -0
- package/src/index.ts +19 -0
- package/src/material.ts +209 -0
- package/src/math.ts +179 -0
- package/src/orbit.ts +164 -0
- package/src/scene.ts +403 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @solidrt/3d - agent notes
|
|
2
|
+
|
|
3
|
+
A retained 3D scene graph above `@solidrt/core/gpu`. Meshes, materials and
|
|
4
|
+
a camera compile to ONE depth-buffered draw target (`createDrawTarget` +
|
|
5
|
+
one `addDraw` entry per mesh); the scene's output is an ordinary texture
|
|
6
|
+
id composited as a `<texture>` leaf, so it takes layout, transforms,
|
|
7
|
+
blendMode and pointer events like any element. Design rationale:
|
|
8
|
+
`okf/research/scene-graph-3d.md` in the repo.
|
|
9
|
+
|
|
10
|
+
## The model
|
|
11
|
+
|
|
12
|
+
- Two layers. The imperative core is Solid-free: `createScene`,
|
|
13
|
+
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
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
|
|
18
|
+
face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
|
|
19
|
+
core over context and renders nothing itself.
|
|
20
|
+
- Rendering is the runtime's. The target is `render: "auto"`: it
|
|
21
|
+
re-renders when entries change, so a STATIC scene costs zero passes and
|
|
22
|
+
the library registers no frame loop. Continuous animation is the app's
|
|
23
|
+
own `onFrame` writing a signal (declarative) or `setTransform` on a
|
|
24
|
+
`ref`-grabbed node (the frame-rate escape hatch - signals carry
|
|
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.
|
|
29
|
+
- Materials dedupe hard: one program + one pipeline per material CLASS
|
|
30
|
+
(unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
|
|
31
|
+
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
32
|
+
|
|
33
|
+
## Components
|
|
34
|
+
|
|
35
|
+
| Component | Props |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)` |
|
|
38
|
+
| `Group` | `position?`, `rotation?` (Euler radians, x-y-z order), `scale?` (number = uniform), `visible?`, `ref?(node)` |
|
|
39
|
+
| `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
|
|
40
|
+
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
41
|
+
|
|
42
|
+
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
43
|
+
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
44
|
+
zoomSpeed? })` - drag-to-rotate, wheel-to-zoom, optional auto-orbit. Spread
|
|
45
|
+
`orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
|
|
46
|
+
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
|
|
49
|
+
`pose()`/`set()` (also the debug-command shape). It drives position and
|
|
50
|
+
target only; fov/near/far stay on scene.setCamera. In a component tree,
|
|
51
|
+
reach the scene via `<Scene ref>` or useScene().
|
|
52
|
+
|
|
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).
|
|
57
|
+
Materials:
|
|
58
|
+
|
|
59
|
+
- `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
|
|
60
|
+
internally.
|
|
61
|
+
- `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
|
|
62
|
+
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
|
|
70
|
+
`setMeshParams(mesh, { name: value })` (validated names; values persist
|
|
71
|
+
across entry rebuilds; frame-rate-safe like setTransform).
|
|
72
|
+
|
|
73
|
+
## Traps
|
|
74
|
+
|
|
75
|
+
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
76
|
+
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
77
|
+
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
78
|
+
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
79
|
+
and show mesh interiors.
|
|
80
|
+
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
81
|
+
cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
|
|
82
|
+
written on unhide.
|
|
83
|
+
- Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
|
|
84
|
+
translucent color overwrites. Transparency waits on blend factors +
|
|
85
|
+
sorting (research note, staging step 4).
|
|
86
|
+
- Rotation is Euler radians applied x, then y, then z. No quaternions in
|
|
87
|
+
v1.
|
|
88
|
+
- Transforms have ONE write path: `setTransform`/`setVisible` (or the
|
|
89
|
+
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
|
|
93
|
+
few thousand objects, bounded by the interpreter, not the GPU.
|
|
94
|
+
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
95
|
+
list END. Irrelevant while everything is opaque + depth-tested; revisit
|
|
96
|
+
when transparency lands.
|
|
97
|
+
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
98
|
+
context).
|
|
99
|
+
- A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
|
|
100
|
+
compile twice - no dedupe by source value (deliberate; hidden
|
|
101
|
+
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
102
|
+
one per look at app scope, share across meshes, `dispose()` when done
|
|
103
|
+
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @solidrt/3d
|
|
2
|
+
|
|
3
|
+
A retained 3D scene graph for SolidRT: meshes, materials and a camera,
|
|
4
|
+
declared as Solid components, rendered by the runtime into an ordinary
|
|
5
|
+
texture in your UI tree.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { createSignal, onFrame, render } from "@solidrt/core"
|
|
9
|
+
import { box, Mesh, PerspectiveCamera, Scene, unlit } from "@solidrt/3d"
|
|
10
|
+
|
|
11
|
+
function App() {
|
|
12
|
+
let [spin, setSpin] = createSignal(0)
|
|
13
|
+
onFrame(tick => setSpin(tick / 2000))
|
|
14
|
+
return (
|
|
15
|
+
<window>
|
|
16
|
+
<Scene width={720} height={720}>
|
|
17
|
+
<PerspectiveCamera position={[0, 1.5, 3]} lookAt={[0, 0, 0]} />
|
|
18
|
+
<Mesh geometry={box()} material={unlit({ color: [0.9, 0.3, 0.3] })} rotation={[0, spin(), 0]} />
|
|
19
|
+
</Scene>
|
|
20
|
+
</window>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
render(() => <App />)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The scene compiles to one depth-buffered GPU draw target: one draw entry
|
|
27
|
+
per mesh, one shared pipeline per material class, cross-mesh occlusion
|
|
28
|
+
from the shared depth buffer. A static scene costs zero GPU passes - the
|
|
29
|
+
runtime re-renders the target only when something changes - and a moved
|
|
30
|
+
mesh costs one uniform write.
|
|
31
|
+
|
|
32
|
+
There is also an imperative layer underneath (`createScene`, `createMesh`,
|
|
33
|
+
`setTransform`, ...) usable without components, plus a small math module
|
|
34
|
+
(`@solidrt/3d/math`: column-major mat4, perspective, lookAt).
|
|
35
|
+
|
|
36
|
+
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
|
|
40
|
+
staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
|
|
41
|
+
usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
|
|
42
|
+
[examples/](examples/).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @solidrt/3d examples
|
|
2
|
+
|
|
3
|
+
One concept per file; run with `bunx srt run <file>` from an app that
|
|
4
|
+
depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
5
|
+
|
|
6
|
+
- `scene-basic.tsx` - the whole v1 surface: a `<Scene>` composited as a
|
|
7
|
+
texture leaf, `<PerspectiveCamera>`, a ground plane, a spinning
|
|
8
|
+
`<Group>` of unlit meshes with real depth-buffer occlusion, geometry
|
|
9
|
+
and pipeline sharing, and the one-signal onFrame drive.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A minimal scene: unlit meshes with real cross-mesh occlusion (one shared
|
|
2
|
+
// depth buffer), a spinning group, and a fixed camera. One onFrame loop
|
|
3
|
+
// drives one signal; the library keeps every draw entry's uModel (and the
|
|
4
|
+
// scene target's shared uViewProj) in step.
|
|
5
|
+
// The sphere orbits with the group, crossing behind and in front of the
|
|
6
|
+
// tall box - that alternation is the depth buffer at work, not draw order.
|
|
7
|
+
//
|
|
8
|
+
// NOTE a registered onFrame keeps the client presenting every vsync -
|
|
9
|
+
// right for a continuously animating scene, wrong for a static one (a
|
|
10
|
+
// static scene costs zero passes; drop the onFrame and it idles).
|
|
11
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
12
|
+
import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
13
|
+
|
|
14
|
+
const SIZE = 720
|
|
15
|
+
|
|
16
|
+
function App() {
|
|
17
|
+
let [spin, setSpin] = createSignal(0)
|
|
18
|
+
onFrame(tick => setSpin(tick / 2000))
|
|
19
|
+
|
|
20
|
+
// Geometries and materials are plain values, shared freely: both boxes
|
|
21
|
+
// use one cube geometry (one vertex/index buffer pair on the GPU), and
|
|
22
|
+
// every mesh here shares the one unlit-color pipeline.
|
|
23
|
+
let cube = box()
|
|
24
|
+
let floor = plane(6, 6, "floor")
|
|
25
|
+
let ball = sphere(0.35)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<window>
|
|
29
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
30
|
+
<Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="scene-basic">
|
|
31
|
+
<PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
|
|
32
|
+
<Mesh
|
|
33
|
+
geometry={floor}
|
|
34
|
+
material={unlit({ color: [0.16, 0.17, 0.22] })}
|
|
35
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
36
|
+
/>
|
|
37
|
+
<Group rotation={[0, spin(), 0]}>
|
|
38
|
+
<Mesh geometry={cube} material={unlit({ color: [0.85, 0.3, 0.3] })} position={[0, 0.5, 0]} />
|
|
39
|
+
<Mesh
|
|
40
|
+
geometry={cube}
|
|
41
|
+
material={unlit({ color: [0.9, 0.8, 0.35] })}
|
|
42
|
+
position={[-1.1, 0.7, 0]}
|
|
43
|
+
scale={[0.5, 1.4, 0.5]}
|
|
44
|
+
/>
|
|
45
|
+
<Mesh geometry={ball} material={unlit({ color: [0.35, 0.65, 0.9] })} position={[1.1, 0.35, 0]} />
|
|
46
|
+
</Group>
|
|
47
|
+
</Scene>
|
|
48
|
+
</view>
|
|
49
|
+
</window>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
render(() => <App />)
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solidrt/3d",
|
|
3
|
+
"version": "0.0.46",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Antoine van Wel",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./math": "./src/math.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/",
|
|
14
|
+
"examples/",
|
|
15
|
+
"AGENTS.md"
|
|
16
|
+
],
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@solidjs/signals": "2.0.0-beta.31",
|
|
19
|
+
"@solidrt/core": "0.0.46"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
+
// scene (scene.ts) - no new intrinsic elements, no renderer changes. Props
|
|
3
|
+
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
4
|
+
// write into the retained nodes and the runtime's dirty flush renders.
|
|
5
|
+
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
6
|
+
// node with `ref` and call setTransform from onFrame - signals carry
|
|
7
|
+
// structure and slow state, per-frame motion goes straight to the scene.
|
|
8
|
+
|
|
9
|
+
import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
10
|
+
import type { ParentComponent, VoidComponent } from "@solidrt/core"
|
|
11
|
+
import {
|
|
12
|
+
add,
|
|
13
|
+
createGroup,
|
|
14
|
+
createMesh,
|
|
15
|
+
createScene,
|
|
16
|
+
remove,
|
|
17
|
+
setGeometry,
|
|
18
|
+
setMaterial,
|
|
19
|
+
setTransform,
|
|
20
|
+
setVisible,
|
|
21
|
+
} from "./scene.ts"
|
|
22
|
+
import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode } from "./scene.ts"
|
|
23
|
+
import type { Geometry } from "./geometry.ts"
|
|
24
|
+
import type { Material } from "./material.ts"
|
|
25
|
+
import type { Vec3 } from "./math.ts"
|
|
26
|
+
|
|
27
|
+
type SceneCtx = { scene: SceneHandle; parent: SceneNode }
|
|
28
|
+
let SceneContext = createContext<SceneCtx>()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The enclosing scene and parent node - the imperative escape hatch inside
|
|
32
|
+
* a component subtree (throws outside a `<Scene>`).
|
|
33
|
+
*/
|
|
34
|
+
export function useScene(): SceneCtx {
|
|
35
|
+
return useContext(SceneContext)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TransformProps = {
|
|
39
|
+
position?: Vec3
|
|
40
|
+
/** Euler radians, applied x then y then z. */
|
|
41
|
+
rotation?: Vec3
|
|
42
|
+
scale?: Vec3 | number
|
|
43
|
+
visible?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syncNode(node: SceneNode, props: TransformProps): void {
|
|
47
|
+
createEffect(
|
|
48
|
+
() => [props.position, props.rotation, props.scale, props.visible] as const,
|
|
49
|
+
([position, rotation, scale, visible]) => {
|
|
50
|
+
setTransform(node, { position, rotation, scale })
|
|
51
|
+
setVisible(node, visible !== false)
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SceneProps = {
|
|
57
|
+
width: number
|
|
58
|
+
height: number
|
|
59
|
+
clearColor?: [number, number, number, number]
|
|
60
|
+
label?: string
|
|
61
|
+
ref?: (scene: SceneHandle) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Owns a draw target and composites it as an ordinary `<texture>` leaf, so
|
|
66
|
+
* 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.
|
|
69
|
+
*/
|
|
70
|
+
export let Scene: ParentComponent<SceneProps> = props => {
|
|
71
|
+
let scene = untrack(() =>
|
|
72
|
+
createScene(props.width, props.height, { clearColor: props.clearColor, label: props.label }),
|
|
73
|
+
)
|
|
74
|
+
createEffect(
|
|
75
|
+
() => [props.width, props.height] as const,
|
|
76
|
+
([w, h]) => scene.setSize(w, h),
|
|
77
|
+
)
|
|
78
|
+
untrack(() => props.ref)?.(scene)
|
|
79
|
+
return (
|
|
80
|
+
<SceneContext value={{ scene, parent: scene.root }}>
|
|
81
|
+
<texture src={scene.texture} width={props.width} height={props.height} />
|
|
82
|
+
{props.children}
|
|
83
|
+
</SceneContext>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A transform node: children inherit its position/rotation/scale. */
|
|
88
|
+
export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) => void }> = props => {
|
|
89
|
+
let ctx = useContext(SceneContext)
|
|
90
|
+
let node = createGroup()
|
|
91
|
+
add(ctx.parent, node)
|
|
92
|
+
syncNode(node, props)
|
|
93
|
+
untrack(() => props.ref)?.(node)
|
|
94
|
+
onCleanup(() => remove(node))
|
|
95
|
+
return <SceneContext value={{ scene: ctx.scene, parent: node }}>{props.children}</SceneContext>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type MeshProps = TransformProps & {
|
|
99
|
+
geometry: Geometry
|
|
100
|
+
material: Material
|
|
101
|
+
ref?: (mesh: MeshNode) => void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One draw entry: geometry drawn with a material at a transform. */
|
|
105
|
+
export let Mesh: VoidComponent<MeshProps> = props => {
|
|
106
|
+
let ctx = useContext(SceneContext)
|
|
107
|
+
let mesh = untrack(() => createMesh(props.geometry, props.material))
|
|
108
|
+
add(ctx.parent, mesh)
|
|
109
|
+
createEffect(
|
|
110
|
+
() => props.geometry,
|
|
111
|
+
g => setGeometry(mesh, g),
|
|
112
|
+
{ defer: true },
|
|
113
|
+
)
|
|
114
|
+
createEffect(
|
|
115
|
+
() => props.material,
|
|
116
|
+
m => setMaterial(mesh, m),
|
|
117
|
+
{ defer: true },
|
|
118
|
+
)
|
|
119
|
+
syncNode(mesh, props)
|
|
120
|
+
untrack(() => props.ref)?.(mesh)
|
|
121
|
+
onCleanup(() => remove(mesh))
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type PerspectiveCameraProps = {
|
|
126
|
+
/** Vertical field of view in DEGREES (default 60). */
|
|
127
|
+
fov?: number
|
|
128
|
+
near?: number
|
|
129
|
+
far?: number
|
|
130
|
+
position?: Vec3
|
|
131
|
+
lookAt?: Vec3
|
|
132
|
+
up?: Vec3
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Drives the scene's camera from props (the scene has a default camera, so
|
|
137
|
+
* this component is optional). The camera is scene state, not a tree node:
|
|
138
|
+
* to orbit it, update `position`/`lookAt`.
|
|
139
|
+
*/
|
|
140
|
+
export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
|
|
141
|
+
let ctx = useContext(SceneContext)
|
|
142
|
+
createEffect(
|
|
143
|
+
() => [props.fov, props.near, props.far, props.position, props.lookAt, props.up] as const,
|
|
144
|
+
([fov, near, far, position, lookAt, up]) =>
|
|
145
|
+
ctx.scene.setCamera({ fov, near, far, position, target: lookAt, up }),
|
|
146
|
+
)
|
|
147
|
+
return null
|
|
148
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Geometry: interleaved vertex data in the one layout every scene material
|
|
2
|
+
// shares - position vec3, normal vec3, uv vec2 (8 floats per vertex) - plus
|
|
3
|
+
// uint16 indices. Winding is counter-clockwise seen from outside in the
|
|
4
|
+
// y-up world, which the standard camera rig (perspective() with its baked
|
|
5
|
+
// y flip) presents as the engine's displayed-CCW front faces: every
|
|
6
|
+
// generator here culls correctly with cull: "back". Normals ride along
|
|
7
|
+
// unused by the unlit materials so the layout is ready for lights without
|
|
8
|
+
// a geometry change (inactive attributes are skipped but keep the stride).
|
|
9
|
+
//
|
|
10
|
+
// GPU buffers are created lazily on first use and shared by every mesh and
|
|
11
|
+
// scene drawing the geometry. They are app-lifetime by design - one
|
|
12
|
+
// geometry commonly outlives the component that first drew it, so
|
|
13
|
+
// owner-scoped auto-free would free a buffer other scenes still draw from.
|
|
14
|
+
// disposeGeometry frees them when an app is done with a geometry for good.
|
|
15
|
+
|
|
16
|
+
import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
|
|
17
|
+
import type { BufferId, VertexAttribute } from "@solidrt/core/gpu"
|
|
18
|
+
import { add, cross, normalize, sub } from "./math.ts"
|
|
19
|
+
import type { Vec3 } from "./math.ts"
|
|
20
|
+
|
|
21
|
+
export const VERTEX_LAYOUT: VertexAttribute[] = [
|
|
22
|
+
{ name: "aPos", format: "vec3" },
|
|
23
|
+
{ name: "aNormal", format: "vec3" },
|
|
24
|
+
{ name: "aUV", format: "vec2" },
|
|
25
|
+
]
|
|
26
|
+
export const FLOATS_PER_VERTEX = 8
|
|
27
|
+
|
|
28
|
+
export type Geometry = {
|
|
29
|
+
/** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex. */
|
|
30
|
+
vertices: Float32Array
|
|
31
|
+
indices: Uint16Array
|
|
32
|
+
/** Debug name for the lazily-created GPU buffers. */
|
|
33
|
+
label?: string
|
|
34
|
+
_buffer?: BufferId
|
|
35
|
+
_index?: BufferId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The geometry's GPU buffers, created on first use and cached on it. */
|
|
39
|
+
export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index: BufferId } {
|
|
40
|
+
let buffer = geometry._buffer
|
|
41
|
+
let index = geometry._index
|
|
42
|
+
if (buffer === undefined || index === undefined) {
|
|
43
|
+
buffer = createBuffer(geometry.vertices, {
|
|
44
|
+
autoFree: false,
|
|
45
|
+
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
46
|
+
})
|
|
47
|
+
index = createBuffer(geometry.indices, {
|
|
48
|
+
autoFree: false,
|
|
49
|
+
label: geometry.label ? geometry.label + "-indices" : undefined,
|
|
50
|
+
})
|
|
51
|
+
geometry._buffer = buffer
|
|
52
|
+
geometry._index = index
|
|
53
|
+
}
|
|
54
|
+
return { buffer, index }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Free the geometry's GPU buffers. Draw entries created from them hold
|
|
59
|
+
* their own reference, so destruction order is safe; the geometry can be
|
|
60
|
+
* used again afterwards (fresh buffers are created on next use).
|
|
61
|
+
*/
|
|
62
|
+
export function disposeGeometry(geometry: Geometry): void {
|
|
63
|
+
if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
|
|
64
|
+
if (geometry._index !== undefined) destroyBuffer(geometry._index)
|
|
65
|
+
geometry._buffer = undefined
|
|
66
|
+
geometry._index = undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
|
|
70
|
+
export function box(width = 1, height = 1, depth = 1, label?: string): Geometry {
|
|
71
|
+
let x = width / 2
|
|
72
|
+
let y = height / 2
|
|
73
|
+
let z = depth / 2
|
|
74
|
+
let verts: number[] = []
|
|
75
|
+
let indices: number[] = []
|
|
76
|
+
type P = [number, number, number]
|
|
77
|
+
// Corners a (bottom-left) through d (top-left), CCW seen from outside.
|
|
78
|
+
let quad = (a: P, b: P, c: P, d: P, n: P) => {
|
|
79
|
+
let base = verts.length / FLOATS_PER_VERTEX
|
|
80
|
+
let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
|
|
81
|
+
let corners = [a, b, c, d]
|
|
82
|
+
for (let i = 0; i < 4; i++) {
|
|
83
|
+
let p = corners[i]!
|
|
84
|
+
let t = uv[i]!
|
|
85
|
+
verts.push(p[0], p[1], p[2], n[0], n[1], n[2], t[0]!, t[1]!)
|
|
86
|
+
}
|
|
87
|
+
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
|
88
|
+
}
|
|
89
|
+
quad([-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z], [0, 0, 1]) // front
|
|
90
|
+
quad([x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z], [0, 0, -1]) // back
|
|
91
|
+
quad([x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z], [1, 0, 0]) // right
|
|
92
|
+
quad([-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z], [-1, 0, 0]) // left
|
|
93
|
+
quad([-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z], [0, 1, 0]) // top
|
|
94
|
+
quad([-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z], [0, -1, 0]) // bottom
|
|
95
|
+
return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A rectangle in the XY plane facing +z, centered on the origin. For a
|
|
100
|
+
* ground plane, rotate it flat: `rotation={[-Math.PI / 2, 0, 0]}`.
|
|
101
|
+
*/
|
|
102
|
+
export function plane(width = 1, height = 1, label?: string): Geometry {
|
|
103
|
+
let x = width / 2
|
|
104
|
+
let y = height / 2
|
|
105
|
+
// prettier-ignore
|
|
106
|
+
let vertices = new Float32Array([
|
|
107
|
+
-x, -y, 0, 0, 0, 1, 0, 1,
|
|
108
|
+
x, -y, 0, 0, 0, 1, 1, 1,
|
|
109
|
+
x, y, 0, 0, 0, 1, 1, 0,
|
|
110
|
+
-x, y, 0, 0, 0, 1, 0, 0,
|
|
111
|
+
])
|
|
112
|
+
return { vertices, indices: new Uint16Array([0, 1, 2, 0, 2, 3]), label }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A (p,q) torus knot swept into a tube, centered on the origin and standing
|
|
117
|
+
* y-up: the knot's disc lies in the XZ plane with the weave running
|
|
118
|
+
* vertically - the orientation a y-up world with XZ floors wants (the
|
|
119
|
+
* standard-vocabulary divergence: Three's equivalent stands on z). The
|
|
120
|
+
* tube's (tubularSegments x radialSegments) grid stores each vertex once;
|
|
121
|
+
* the seam row/column duplicate the first with u/v = 1 (distinct texture
|
|
122
|
+
* coordinates, so genuinely distinct vertices). UVs: u 0..1 along the knot,
|
|
123
|
+
* v 0..1 around the tube.
|
|
124
|
+
*/
|
|
125
|
+
export function torusKnot(
|
|
126
|
+
radius = 1,
|
|
127
|
+
tube = 0.4,
|
|
128
|
+
tubularSegments = 64,
|
|
129
|
+
radialSegments = 8,
|
|
130
|
+
p = 2,
|
|
131
|
+
q = 3,
|
|
132
|
+
label?: string,
|
|
133
|
+
): Geometry {
|
|
134
|
+
// A point on the knot curve at parameter t (0..2*PI*p).
|
|
135
|
+
let point = (t: number): Vec3 => {
|
|
136
|
+
let qp = (q / p) * t
|
|
137
|
+
let r = radius * (2 + Math.cos(qp)) * 0.5
|
|
138
|
+
return [r * Math.cos(t), radius * Math.sin(qp) * 0.5, r * Math.sin(t)]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let rows = tubularSegments + 1
|
|
142
|
+
let cols = radialSegments + 1
|
|
143
|
+
let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
|
|
144
|
+
let at = 0
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < rows; i++) {
|
|
147
|
+
let t = (i / tubularSegments) * Math.PI * 2 * p
|
|
148
|
+
// A stable frame along the curve: tangent from a finite difference, and
|
|
149
|
+
// a normal biased away from the axis (P1 + P2), which is well-defined
|
|
150
|
+
// everywhere on a torus knot and needs no parallel transport.
|
|
151
|
+
let p1 = point(t)
|
|
152
|
+
let p2 = point(t + 0.01)
|
|
153
|
+
let tangent = sub(p2, p1)
|
|
154
|
+
let bitangent = normalize(cross(tangent, add(p2, p1)))
|
|
155
|
+
let normal = normalize(cross(bitangent, tangent))
|
|
156
|
+
|
|
157
|
+
for (let j = 0; j < cols; j++) {
|
|
158
|
+
let v = (j / radialSegments) * Math.PI * 2
|
|
159
|
+
let cv = -Math.cos(v) * tube
|
|
160
|
+
let sv = Math.sin(v) * tube
|
|
161
|
+
let x = p1[0] + cv * normal[0] + sv * bitangent[0]
|
|
162
|
+
let y = p1[1] + cv * normal[1] + sv * bitangent[1]
|
|
163
|
+
let z = p1[2] + cv * normal[2] + sv * bitangent[2]
|
|
164
|
+
let n = normalize([x - p1[0], y - p1[1], z - p1[2]])
|
|
165
|
+
vertices[at] = x
|
|
166
|
+
vertices[at + 1] = y
|
|
167
|
+
vertices[at + 2] = z
|
|
168
|
+
vertices[at + 3] = n[0]
|
|
169
|
+
vertices[at + 4] = n[1]
|
|
170
|
+
vertices[at + 5] = n[2]
|
|
171
|
+
vertices[at + 6] = i / tubularSegments
|
|
172
|
+
vertices[at + 7] = j / radialSegments
|
|
173
|
+
at += FLOATS_PER_VERTEX
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let indices = new Uint16Array(tubularSegments * radialSegments * 6)
|
|
178
|
+
let n = 0
|
|
179
|
+
for (let i = 0; i < tubularSegments; i++) {
|
|
180
|
+
for (let j = 0; j < radialSegments; j++) {
|
|
181
|
+
let a = i * cols + j
|
|
182
|
+
let b = (i + 1) * cols + j
|
|
183
|
+
let c = (i + 1) * cols + j + 1
|
|
184
|
+
let d = i * cols + j + 1
|
|
185
|
+
indices[n++] = a
|
|
186
|
+
indices[n++] = b
|
|
187
|
+
indices[n++] = c
|
|
188
|
+
indices[n++] = a
|
|
189
|
+
indices[n++] = c
|
|
190
|
+
indices[n++] = d
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { vertices, indices, label }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A UV sphere centered on the origin (poles on the y axis). */
|
|
198
|
+
export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, label?: string): Geometry {
|
|
199
|
+
let verts: number[] = []
|
|
200
|
+
let indices: number[] = []
|
|
201
|
+
for (let iy = 0; iy <= heightSegments; iy++) {
|
|
202
|
+
let v = iy / heightSegments
|
|
203
|
+
let theta = v * Math.PI
|
|
204
|
+
let sinT = Math.sin(theta)
|
|
205
|
+
let cosT = Math.cos(theta)
|
|
206
|
+
for (let ix = 0; ix <= widthSegments; ix++) {
|
|
207
|
+
let u = ix / widthSegments
|
|
208
|
+
let phi = u * Math.PI * 2
|
|
209
|
+
let nx = -Math.cos(phi) * sinT
|
|
210
|
+
let ny = cosT
|
|
211
|
+
let nz = Math.sin(phi) * sinT
|
|
212
|
+
verts.push(radius * nx, radius * ny, radius * nz, nx, ny, nz, u, v)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
let cols = widthSegments + 1
|
|
216
|
+
for (let iy = 0; iy < heightSegments; iy++) {
|
|
217
|
+
for (let ix = 0; ix < widthSegments; ix++) {
|
|
218
|
+
let a = iy * cols + ix + 1
|
|
219
|
+
let b = iy * cols + ix
|
|
220
|
+
let c = (iy + 1) * cols + ix
|
|
221
|
+
let d = (iy + 1) * cols + ix + 1
|
|
222
|
+
if (iy !== 0) indices.push(a, b, d)
|
|
223
|
+
if (iy !== heightSegments - 1) indices.push(b, c, d)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
|
|
227
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @solidrt/3d - a retained 3D scene graph above @solidrt/core/gpu.
|
|
2
|
+
// Meshes, materials, and a camera compile to one depth-buffered draw
|
|
3
|
+
// target; the output is an ordinary texture id in the UI tree. Two layers:
|
|
4
|
+
// the imperative core (createScene/createMesh/setTransform - usable
|
|
5
|
+
// without Solid components) and the component face (Scene/Mesh/Group/
|
|
6
|
+
// PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
|
|
7
|
+
|
|
8
|
+
export { add, createGroup, createMesh, createScene, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible } from "./scene.ts"
|
|
9
|
+
export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
|
|
10
|
+
export { box, disposeGeometry, plane, sphere, torusKnot, FLOATS_PER_VERTEX, VERTEX_LAYOUT } from "./geometry.ts"
|
|
11
|
+
export type { Geometry } from "./geometry.ts"
|
|
12
|
+
export { shaderMaterial, unlit } from "./material.ts"
|
|
13
|
+
export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
|
|
14
|
+
export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
15
|
+
export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
|
|
16
|
+
export { createOrbitCamera } from "./orbit.ts"
|
|
17
|
+
export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
|
|
18
|
+
export { compose, copy, identity, lookAt, mat4, multiply, perspective } from "./math.ts"
|
|
19
|
+
export type { Mat4, Vec3 } from "./math.ts"
|