Skip to main content

Three

A react-three-fiber-shaped 3D scene graph, drawn through whichever OpenGL backend the connection has.

import { Canvas, useFrame } from '@react-x11/components/three';

function SpinningBox() {
const ref = useRef(null);
useFrame((state, delta) => {
ref.current.rotation.y += delta;
});
return (
<mesh ref={ref} position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}

<Canvas camera={{ position: [3, 3, 6], fov: 50 }} style={{ flexGrow: 1 }}>
<ambientLight intensity={0.4} />
<pointLight position={[5, 6, 6]} />
<SpinningBox />
</Canvas>;

If that looks like a react-three-fiber component, that is the design goal: the element names, the prop shapes (args, position, tuples, CSS or hex colours), attach, dashed paths like position-x, refs that hand out mutable objects, useFrame/useThree, pointer events on meshes, and extend() all follow r3f, so a component written for it ports with its structure intact. What differs is underneath: there is no three.js and no WebGL — the scene renders through react-x11's <glarea>, over one of two pipelines.

The two backends

indirect (GLX)direct (DRI3 / Apple-DRI)
how it drawsGL 1.x commands encoded into the X connectionOpenGL ES 2 on the GPU, frames as dma-bufs
reachesany server that allows indirect contexts, including over a networka local session with the x11-dri addon — either flavor
geometrycompiled into server-side display lists, oncevertex buffers on the GPU, once
lightingper vertex, 8 fixed-function light unitsper fragment (same 8-light cap, so scenes match)
shadersnone — the protocol encodes no shader objectsyes<shaderMaterial>, GLSL ES 1.00
post-processingnone — no framebuffer objects to encodeyes<effectComposer> and passes

The direct backend comes in two flavors, and nothing above the <glarea> tells them apart: DRI3 on Linux (GBM/EGL) and Apple-DRI on macOS/XQuartz (CGL, and x11-dri ≥ 0.5.0). Both want the addon and a local session — neither survives a network connection.

The scene graph is identical; the indirect feature set is a subset of the direct one. Ask for the best available at the root and the same JSX renders on both:

const root = await createRoot({ glPolicy: 'auto' });

'auto' uses direct rendering where it is available and indirect otherwise — which is usually what you want, since most modern desktops ship with indirect GLX off (Xorg ≥ 1.17, Xwayland), and those are exactly the machines where direct works. react-x11's default policy is 'indirect'; one run can be switched without touching code via NTK_GL_POLICY=auto.

The two direct-only element families throw at creation, naming the reason, rather than rendering a blank surface. A scene that would rather degrade branches first:

const shaders = useThree((s) => s.supportsShaders); // or useSupports('shaders')
{
shaders ? (
<shaderMaterial {...glsl} />
) : (
<meshPhongMaterial color="#e0533d" />
);
}

macOS differences

Apple-DRI renders the same scenes; a few properties of the platform show through:

  • GL resources are per-glarea. Apple-DRI holds a CGL context per window, where Linux DRI3 shares one EGL context per connection — never share GL objects across glareas.
  • No 32-bit ARGB visual. glx={{ ALPHA_SIZE: 1 }} fails on XQuartz.
  • Animations pace to a ~60fps fallback. XQuartz's RandR modes carry no timing data, so the frame clock cannot read the display's real rate.
  • 'auto' needs the local session. Over SSH there is no WindowServer session, so direct rendering is unavailable and 'auto' degrades to indirect GLX.

<Canvas>

propmeaning
stylelayout, as any drawn element takes it
camerapartial settings for the default camera: { position, fov, near, far, zoom, up, target }
orthographican orthographic default camera
frameloop'demand' (default) / 'always' / 'never' — see below
clearColorCSS colour or [r, g, b, a]; <color attach="background" /> wins over it
glxvisual spec, e.g. { DEPTH_SIZE: 24 }
fallbackelement or (error) => element, shown when the connection has no GL at all
onCreated(state) => void, once the context exists
onPointerMisseda press that hit no object
onDrawraw GL after the scene draws — branch on gl.backend

The name is Canvas, as in r3f — the lowercase <canvas> host element is core's 2D escape hatch and JSX keeps them apart.

frameloop differs from r3f in one deliberate way. r3f renders continuously by default; a desktop toolkit should not, so the default here is 'demand': render on commits, resizes, exposes and invalidate(). Subscribing to useFrame switches the surface to continuous rendering while mounted — which is why ported r3f components animate without asking — and prop-driven changes redraw on their own. An imperative mutation from an event handler (ref.current.position.x = 2) lands through the object's own change hooks too; useThree((s) => s.invalidate) is the explicit valve.

Elements

Objects: <group>, <mesh>, <points>, <line>, <lineSegments>, <lineLoop>, <instancedMesh instances={[…]}>, <primitive object={…}>.

Geometries (three.js args): <boxGeometry>, <planeGeometry>, <sphereGeometry>, <cylinderGeometry>, <coneGeometry>, <torusGeometry>, and <bufferGeometry position={…} normal={…} uv={…} index={…}> for explicit arrays. Changing args rebuilds the shape in place — refs stay valid, and the renderers re-upload once.

Materials: <meshBasicMaterial> (unlit), <meshLambertMaterial>, <meshPhongMaterial>, <meshStandardMaterial>, <pointsMaterial>, <lineBasicMaterial>, and — direct backend only — <shaderMaterial> / <rawShaderMaterial> with three.js's prelude declared for you and uniforms in the { name: { value } } shape (material.uniforms.uTime.value = t animates without a recompile).

Lights: <ambientLight>, <directionalLight>, <pointLight>, <spotLight> — eight-unit cap on both backends, <ambientLight> costs no unit.

Cameras: the default one, adjusted through the camera prop, or <perspectiveCamera makeDefault> / <orthographicCamera makeDefault>.

Post-processing (direct only): <effectComposer> holding <bloomPass>, <vignettePass>, <fxaaPass>, <shaderPass> — the scene renders to a texture and the passes run in tree order, last one to the window. There is no <renderPass>: the surface's own scene is always the input. examples/three-effects.tsx runs the whole stack with each pass's enabled on a switch.

extend({ MyThing }) teaches the reconciler new classes, r3f's way: <myThing args={[…]} />.

Hooks

useThree() — or useThree(selector) — reads the canvas state: { gl, backend, scene, camera, size, viewport, clock, supportsShaders, invalidate }. useFrame((state, delta) => …, priority?) runs on every frame of the enclosing canvas, before the scene draws; mutate what refs hold and the frame being drawn has it.

Events

onClick, onPointerDown/Up/Move/Over/Out on any object, raycast on the CPU against the same arrays the geometry was uploaded from — no GPU picking, no round trips. Only objects that (or whose ancestors) carry handlers are tested, and X pointer events are only selected on the window once something listens. cursor="pointer" on a hovered object sets the window cursor. onPointerMissed on the canvas catches presses that hit nothing.

TypeScript

The scene vocabulary is typed twice over, because react-x11 core still declares its own (narrower) 3D element types until its scene graph is removed:

  • Plain JSX works today with core's declarations for the shared names, plus this package's for the new ones (<primitive>, the cameras, <color>, <coneGeometry>, <meshStandardMaterial>).
  • For the full r3f-shaped typings — refs to the mutable classes, attach, dashed props — put the pragma at the top of a scene-heavy file:
/** @jsxImportSource @react-x11/components/three */

It is runtime-identical to the default JSX source; only the types change. When core's 3D vocabulary is gone, the default namespace picks these typings up and the pragma stops being necessary.

Honest edges

  • <meshStandardMaterial> is an approximation. roughness/metalness are mapped onto Blinn-Phong terms — identically on both backends — so tutorial scenes render sensibly, not physically.
  • The camera is position/target/up. camera.lookAt(…) works; writing to a camera's rotation does not (no quaternions anywhere in the fixed subset).
  • <instancedMesh> is declarative — an instances array instead of setMatrixAt. What it saves is the geometry; each instance still costs a transform and a draw, since neither backend does GPU instancing.
  • No loaders. useLoader, GLTF, textures-from-URL are three.js machinery; map takes an ntk Image or any { width, height, data } RGBA bytes.
  • Removed props keep their value rather than resetting to a default (write the value you want, or key the element).

Why this lives here, and how

Core's <glarea> owns everything that is renderer internals — the child X window on a GL visual, the context, the frame clock, the swap. This module is composition over that public element: a second react-reconciler renders the scene children into mutable objects (that is what makes args, attach and mutation-by-ref possible), and a per-backend renderer walks them from inside <glarea onDraw> — display lists over indirect GLX, buffers and generated GLSL over direct GL. The AGENTS.md "boundary runs through a feature" section is this exact worked example.