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 three flavors, and nothing above the <glarea> tells them apart: DRI3 on Linux (GBM/EGL), Apple-DRI on macOS/XQuartz (CGL, and x11-dri ≥ 0.5.0), and CGL into a CALayer on react-x11's native macOS backend, where frames render into IOSurface-backed framebuffers and the WindowServer composites them — no X server anywhere. All three want the x11-dri addon and a local session; none survives a network connection.

There is no indirect rung on the Cocoa backend, because indirect is GLX and there is no X connection to encode into. glPolicy: 'indirect' throws there saying so, and 'auto' — the default on that backend — resolves to direct. So a scene that runs at all on macOS-native runs with shaders and post-processing available.

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 the canvas takes the pointer only once something listens. cursor="pointer" on a hovered object sets the cursor over the surface. onPointerMissed on the canvas catches presses that hit nothing.

The pointer reaches the scene through the tree. Core dispatches the pointer over a <glarea> at the <glarea> itself, on both backends (react-x11#545), and <Canvas> feeds the scene from its surface's own onMouseDown/onMouseMove/onMouseUp/onMouseLeave. So a press on a canvas reaches the scene and then bubbles on to the canvas's ancestors like any other, and the wheel, which the scene has no events for, goes straight to them. A press the scene listens for holds the pointer until its release, so a drag that leaves the canvas still reaches the scene.

On a core whose <glarea> node has no forwardsPointer, the canvas listens on the surface's X window instead, and X then delivers the presses and the wheel over the canvas to that window: the scene hears them, and the tree around the canvas does not. The Cocoa backend's surface is a layer with no events of its own, so on such a core a scene there hears nothing.

The cursor goes where the surface can wear it. On X11 the surface is an X window, and a hovered object's cursor goes onto it: X shows it over the surface at once, and the tree's cursor again once it comes off. The Cocoa backend's surface is a layer with no cursor of its own, so there the object's cursor becomes the <glarea>'s cursor style, over the canvas's own as a child's is over its parent's, and core puts it on the window as it does any node's. That is a commit each time the cursor changes, and it arrives a motion late (see Honest edges).

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).
  • On the Cocoa backend the cursor follows the scene one motion late. Core picks the window's cursor as the pointer moves, before the scene hears where it went, so an object's cursor goes on with the motion after the one that reached the object, and comes off with the motion after the one that left it. A pointer that comes to rest just as it reaches an object keeps the old cursor until it moves again.

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.