@glyx-dev/three Stable
Declarative 3D scene composition for Glyx. Renders into a native <Canvas3D> surface using a custom wgpu/Vello 3D pipeline with Phong lighting — pure React hooks + context under the hood, no custom reconciler.
Install
npm install @glyx-dev/threeQuick start
<Mesh> takes geometry as a string ('box' | 'sphere' | 'plane') and color directly — there are no separate <BoxGeometry>/<MeshStandardMaterial> components.
import { Canvas3D } from '@glyx-dev/react'
import { Scene, PerspectiveCamera, Mesh, AmbientLight, DirectionalLight } from '@glyx-dev/three'
import { useRef } from 'react'
function ThreeDemo() {
const c3dRef = useRef(null)
return (
<Canvas3D ref={c3dRef} style={{ width: 600, height: 400 }}>
<Scene canvasRef={c3dRef} background={[0.05, 0.05, 0.08, 1]}>
<PerspectiveCamera fov={60} position={[0, 1, 4]} />
<AmbientLight intensity={0.3} />
<DirectionalLight direction={[-0.5, -1, -0.5]} intensity={1.0} />
<Mesh
geometry="box"
color={[0.55, 0.36, 0.96, 1]}
position={[0, 0, 0]}
rotation={[0.5, 0.8, 0]}
/>
</Scene>
</Canvas3D>
)
}<Scene> needs the same ref passed to <Canvas3D ref={c3dRef}> as its canvasRef prop — that's how it reaches updateScene()/loadGltf().
Animated scene
Drive rotation from a plain setInterval (or your own RAF-equivalent loop) and pass the value straight into rotation:
import { Canvas3D } from '@glyx-dev/react'
import { Scene, PerspectiveCamera, Mesh, DirectionalLight } from '@glyx-dev/three'
import { useRef, useState, useEffect } from 'react'
function SpinningBall() {
const c3dRef = useRef(null)
const [rot, setRot] = useState(0)
useEffect(() => {
const id = setInterval(() => setRot(r => r + 0.02), 16)
return () => clearInterval(id)
}, [])
return (
<Canvas3D ref={c3dRef} style={{ width: 400, height: 300 }}>
<Scene canvasRef={c3dRef} background={[0.05, 0.05, 0.08, 1]}>
<PerspectiveCamera fov={60} position={[0, 0, 3]} />
<DirectionalLight direction={[-0.3, -1, -0.5]} intensity={1} />
<Mesh geometry="sphere" color={[0.53, 0.53, 1, 1]} rotation={[0, rot, 0]} />
</Scene>
</Canvas3D>
)
}Grouping with <Group>
<Group> composes a shared parent transform for nested <Mesh>/<Model>/<Group> children — purely a JS-side authoring convenience. The renderer never sees groups, only each mesh's final composed world matrix, so nesting costs nothing on the Rust side.
<Group position={[2, 0, 0]} rotation={[0, armAngle, 0]}>
<Mesh geometry="box" color={[0.9, 0.5, 0.2, 1]} position={[1, 0, 0]} />
<Group position={[2, 0, 0]}>
<Mesh geometry="sphere" color={[0.3, 0.8, 0.4, 1]} scale={0.5} />
</Group>
</Group>Loading GLTF models
import { Canvas3D } from '@glyx-dev/react'
import { Scene, PerspectiveCamera, AmbientLight, Model, DirectionalLight } from '@glyx-dev/three'
import { useRef } from 'react'
function ModelViewer({ path }: { path: string }) {
const c3dRef = useRef(null)
return (
<Canvas3D ref={c3dRef} style={{ width: 800, height: 600 }}>
<Scene canvasRef={c3dRef} background={[0.1, 0.1, 0.18, 1]}>
<PerspectiveCamera fov={45} position={[0, 1, 5]} />
<AmbientLight intensity={0.5} />
<DirectionalLight direction={[-0.5, -1, -0.5]} intensity={1.2} />
<Model src={path} scale={[1, 1, 1]} position={[0, 0, 0]} />
</Scene>
</Canvas3D>
)
}<Model> preloads its GLTF on mount (and on every src change) via loadGltf(). Before the load completes, the first frame renders as a fallback box.
GLTF animation playback
<Model> plays keyframe or skeletal (CPU-skinned) GLTF animation via animationClip/animationTime. JS owns the clock — the same "JS drives state, Rust is a dumb renderer" model used everywhere else in Glyx — and sends the current sample point on every render; Rust does the skinning.
function AnimatedCharacter({ path }: { path: string }) {
const c3dRef = useRef(null)
const [time, setTime] = useState(0)
useEffect(() => {
let raf: number
const start = performance.now()
const tick = () => {
setTime((performance.now() - start) / 1000)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [])
return (
<Canvas3D ref={c3dRef} style={{ width: 640, height: 480 }}>
<Scene canvasRef={c3dRef}>
<PerspectiveCamera position={[0, 1.4, 4]} target={[0, 1, 0]} />
<DirectionalLight direction={[-0.4, -1, -0.4]} intensity={1.1} />
<Model src={path} animationClip="walk" animationTime={time} />
</Scene>
</Canvas3D>
)
}Animation playback is keyed by model path, not mesh instance — two <Model>s pointing at the same GLTF file share one animation pose per frame (the last-applied {clip, time} that frame wins). Omit both animationClip and animationTime to render the model's static bind pose.
Raycasting / picking
Click-to-select isn't a @glyx-dev/three component — it's a method on the Canvas3D context ref, since it needs a screen-space point rather than a JSX prop:
const c3dRef = useRef(null)
async function handleClick(e: { x: number; y: number }) {
const hit = await c3dRef.current?.raycast(e.x, e.y, width, height)
if (hit) console.log('hit mesh', hit.meshIndex, 'at', hit.point)
}Built-in primitives (box/sphere/plane) get exact analytic intersection. GLTF models are tested against their bounding box (AABB), not individual triangles — good enough for "click to select a model."
Components
<Scene>
Root 3D scene container. Must wrap all other @glyx-dev/three components and be a direct child of <Canvas3D>.
| Prop | Type | Default | Description |
|---|---|---|---|
canvasRef | React.RefObject | — | Same ref passed to <Canvas3D ref> |
background | [r, g, b, a] | transparent | Clear color |
<PerspectiveCamera>
Only one per <Scene> is effective (last registered wins).
| Prop | Type | Default | Description |
|---|---|---|---|
position | [x, y, z] | [0, 1, 5] | Camera world position |
target | [x, y, z] | [0, 0, 0] | Look-at target |
up | [x, y, z] | [0, 1, 0] | Up vector |
fov | number | 60 | Vertical field of view (degrees) |
near | number | 0.1 | Near clip plane |
far | number | 1000 | Far clip plane |
<Group>
| Prop | Type | Description |
|---|---|---|
position | [x, y, z] | Local translation |
rotation | [rx, ry, rz] | Radians, Euler XYZ |
scale | [sx, sy, sz] | number | Local scale |
transform | number[16] | Raw column-major matrix — overrides position/rotation/scale |
<Mesh>
| Prop | Type | Default | Description |
|---|---|---|---|
geometry | 'box' | 'sphere' | 'plane' | 'box' | Unit-sized primitive — use scale to resize |
color | [r, g, b, a] | [1, 1, 1, 1] | Material color |
position | [x, y, z] | [0, 0, 0] | World position (composed with any ancestor <Group>) |
rotation | [rx, ry, rz] | [0, 0, 0] | Radians, Euler XYZ |
scale | [sx, sy, sz] | number | [1, 1, 1] | Scale per axis |
transform | number[16] | — | Raw column-major matrix — overrides position/rotation/scale |
<Model>
| Prop | Type | Description |
|---|---|---|
src | string | Absolute or relative path to .glb/.gltf file |
color | [r, g, b, a] | Tint multiplied against the model's textures/material colors |
position / rotation / scale / transform | — | Same semantics as <Mesh> |
animationClip | string | Clip name to sample — omit for the static bind pose |
animationTime | number | Seconds into the clip — required alongside animationClip |
Lights
Up to 8 dynamic lights per scene.
| Component | Props |
|---|---|
<AmbientLight> | color (default [1,1,1]), intensity (default 0.3) |
<DirectionalLight> | direction (default [-0.5,-1,-0.5]), color, intensity (default 1.0) |
<PointLight> | position (default [0,2,0]), color, intensity, range (default 0 = no falloff) |
<SpotLight> | position (default [0,3,0]), direction (default [0,-1,0]), color, intensity, range, innerDeg (default 15), outerDeg (default 25) |
@glyx-dev/three renders into a native GPU surface using Glyx's wgpu pipeline — not Three.js or WebGL. The API is Three.js-inspired but uses a custom renderer, and doesn't (yet) have separate geometry/material component wrappers — <Mesh> takes both directly.