🚧 Glyx is pre-release software. APIs may change before v1.0. Get started →
Documentation
Packages
@glyx-dev/three

@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/three

Quick 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>.

PropTypeDefaultDescription
canvasRefReact.RefObjectSame ref passed to <Canvas3D ref>
background[r, g, b, a]transparentClear color

<PerspectiveCamera>

Only one per <Scene> is effective (last registered wins).

PropTypeDefaultDescription
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
fovnumber60Vertical field of view (degrees)
nearnumber0.1Near clip plane
farnumber1000Far clip plane

<Group>

PropTypeDescription
position[x, y, z]Local translation
rotation[rx, ry, rz]Radians, Euler XYZ
scale[sx, sy, sz] | numberLocal scale
transformnumber[16]Raw column-major matrix — overrides position/rotation/scale

<Mesh>

PropTypeDefaultDescription
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
transformnumber[16]Raw column-major matrix — overrides position/rotation/scale

<Model>

PropTypeDescription
srcstringAbsolute or relative path to .glb/.gltf file
color[r, g, b, a]Tint multiplied against the model's textures/material colors
position / rotation / scale / transformSame semantics as <Mesh>
animationClipstringClip name to sample — omit for the static bind pose
animationTimenumberSeconds into the clip — required alongside animationClip

Lights

Up to 8 dynamic lights per scene.

ComponentProps
<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.