@hozo/three
v0.2.0
Published
Projects Three.js scenes into portable Hozo Canvas scenes.
Downloads
296
Maintainers
Readme
@hozo/three
Projects a documented subset of an ordinary Three.js scene into a portable
@hozo/canvas scene. It is useful for flat 3D diagrams, data visualisations,
and a deterministic fallback where a GPU renderer is unavailable.
import { projectThreeScene } from '@hozo/three'
import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera, Scene } from 'three'
const scene = new Scene()
scene.add(new Mesh(new BoxGeometry(), new MeshBasicMaterial({ color: '#2563eb' })))
const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100)
camera.position.z = 4
const projected = projectThreeScene(scene, camera, { width: 320, height: 180 })For React, the same boundary is available as a universal Canvas component:
import { ThreeCanvas } from '@hozo/three'
<ThreeCanvas
decorative
scene={scene}
camera={camera}
width={320}
height={180}
/>width and height are optional. When either is omitted, the Web renderer
families follow a ResizeObserver, while the portable surface uses the same
measurement on Web and onLayout on Native. The server-rendered fallback is
300 by 150, and onResize reports the resolved layout size and pixel ratio.
Give the surface a responsive CSS or Native layout size, for example
style={{ width: '100%', height: '100%' }}; without one it keeps the stable
300-by-150 fallback.
Perspective cameras follow the resolved aspect ratio by default. Use
cameraResize="manual" when the application owns its projection. Orthographic
cameras are always application-owned because resizing their frustum has no
single correct policy.
Rendering is demand-driven by default. Change the revision prop or call
invalidate() through a ref after imperative scene mutations. Animated scenes
can opt into frameloop="always" and mutate their Three objects in onFrame.
GPU surfaces render those continuous frames directly rather than scheduling a
React update. If an onFrame mutation changes semantic structure such as an
object's name, visibility, or layer, also change revision when that semantic
change occurs so its keyboard and screen-reader controls are rebuilt.
Classic WebGL renderer
Web applications that need Three.js lighting, shader materials, depth, or the
established WebGLRenderer ecosystem can select the classic renderer family
explicitly:
import { ThreeCanvas } from '@hozo/three/webgl'
<ThreeCanvas
accessibilityLabel="Interactive product model"
scene={scene}
camera={camera}
width={640}
height={360}
/>This entry point owns renderer creation, sizing, drawing, and disposal while
sharing the portable surface's demand/continuous frame-loop contract. It is
currently Web-only; importing it on React Native fails explicitly because Hozo
does not yet provide a Native WebGL context host. It never silently falls back
to the portable renderer. The modern WebGPURenderer family uses a
separate @hozo/three/webgpu entry point.
onObjectPress raycasts the classic WebGL scene and preserves the source
Object3D and its intersections. Named interactive meshes, lines, points, and
sprites also receive real off-screen buttons, so the same object can be reached
with a pointer, keyboard, or screen reader. getAccessibilityLabel can supply
those names without mutating object.name.
Modern WebGPU renderer family
New Web applications can select Three.js's modern renderer family explicitly:
import { ThreeCanvas } from '@hozo/three/webgpu'
<ThreeCanvas
accessibilityLabel="Interactive product model"
scene={scene}
camera={camera}
width={640}
height={360}
/>Hozo loads three/webgpu lazily and awaits WebGPURenderer.init() before the
first draw. Three.js chooses WebGPU when available and otherwise uses its own
modern WebGL 2 backend; pass rendererOptions={{ forceWebGL: true }} to force
that backend for testing. This is distinct from the classic
@hozo/three/webgl renderer and shares its DOM interaction and accessibility
contract rather than its material or post-processing extension points.
Pass onObjectPress to receive the projected Three object and its raycast
intersections. Set object.name, or provide getAccessibilityLabel, to expose
one keyboard and screen-reader control per object even when it projects into
many triangles.
onObjectActiveChange reports the same object-level state for mouse or pen
hover, keyboard focus, and touch hold. Moving across triangle boundaries inside
one object does not emit a false leave and re-entry.
React Three Fiber authoring
Applications that author scenes with React Three Fiber can opt into a separate entry point. R3F continues to own its reconciler, renderer, frame loop, and pointer-event system; Hozo supplies the responsive container and the same label, fallback, or decorative accessibility choice as its direct surfaces.
import { ThreeCanvas } from '@hozo/three/r3f'
<ThreeCanvas
accessibilityLabel="Rotating product preview"
style={{ width: '100%', height: 360 }}
camera={{ position: [0, 2, 5] }}
>
<ambientLight intensity={0.5} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
</ThreeCanvas>@react-three/fiber is an optional peer and is retained only by this entry
point. Object-level keyboard activation uses an explicit list rather than
R3F's private instance metadata:
const product = useRef<THREE.Mesh>(null)
<ThreeCanvas
accessibilityLabel="Product preview"
accessibleObjects={[
{
id: 'product',
label: 'Inspect product',
object: product,
onPress: () => openInspector(),
},
]}
>
<mesh ref={product} onClick={() => openInspector()}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
</ThreeCanvas>The native control handles keyboard and screen-reader activation. The mesh keeps
its ordinary R3F pointer handler, so Hozo does not synthesize or double-dispatch
pointer events. A registered object with href becomes a real anchor instead,
preserving modifier-click and application-router integration.
Hidden until focus reaches it. The controls sit off-screen while nothing is
focused — a canvas should not carry a row of buttons on every scene — and the
strip is revealed as soon as focus enters it, in the user agent's own
Canvas/CanvasText colours. It was clipped in both states until
#689, which meant a sighted
keyboard user tabbed onto a control they could not see anywhere: measured at 1×1
with clip: rect(0, 0, 0, 0) around a 136×24 button that had Chrome's focus ring
on it, clipped away with everything else. WCAG 2.4.7, and invisible to every
other check here — axe reads the accessibility tree, where the control was
correct.
controlClassName dresses each control for applications that want their own
look; the package's two colours are the two a user agent guarantees contrast
between, and nothing more.
R3F with WebGPU
R3F also accepts an asynchronous renderer factory. Keep the factory stable and initialize Three's modern renderer before returning it:
import type { R3FRendererFactoryProps } from '@hozo/three/r3f'
const createWebGPURenderer = async ({ canvas }: R3FRendererFactoryProps) => {
const { WebGPURenderer } = await import('three/webgpu')
const renderer = new WebGPURenderer({
canvas: canvas as HTMLCanvasElement,
antialias: true,
})
await renderer.init()
return renderer
}
<ThreeCanvas accessibilityLabel="Product preview" gl={createWebGPURenderer}>
{/* ordinary R3F scene */}
</ThreeCanvas>The dynamic import keeps Three's modern renderer out of applications that use the default R3F WebGL path. Hozo passes the factory through; R3F owns awaiting, configuration, frame scheduling, error propagation, and disposal. Runtime GPU availability still belongs to the browser and should be covered by an application-level fallback or error boundary.
Experimental R3F Native host
Expo prebuild applications can opt into the Native GPU host explicitly:
import { ThreeCanvas } from '@hozo/three/r3f-native'
<ThreeCanvas
accessibilityLabel="Rotating product preview"
style={{ flex: 1 }}
camera={{ position: [0, 2, 5] }}
>
<ambientLight intensity={0.5} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
</ThreeCanvas>Install @react-three/fiber and three, then use npx expo install expo-gl
expo-asset expo-file-system so the GL host matches the Expo SDK. This entry is
experimental and separate from @hozo/three/r3f: R3F Native owns its Expo GL
context, frame loop, and touch raycasting, while Hozo supplies the same
label/fallback/decorative choice and explicit semantic object controls.
Keep one Three instance in Metro: R3F Native patches the CJS TextureLoader
for Expo image uploads, while ESM imports can otherwise select a second Three
build with a DOM-only image loader. Resolve bare three requests to
require.resolve('three') for both application and addon imports, as in the
probe's Metro configuration.
The measured support claim is Expo prebuild on Android and iOS. Android has release evidence for touch, TalkBack, Activity resume, frame sampling, and teardown. The iOS Simulator has release evidence for initialization, background/resume, frame sampling, and teardown; trusted pointer and VoiceOver operation still need device evidence. Expo Go, bare React Native setup, and forced driver/OS context loss are not yet claimed.
Portable projection details
The result is a retained CanvasScene, with world transforms and camera
projection already baked into its paths. Indexed and non-indexed triangle
BufferGeometry, perspective and orthographic cameras, clipping, face sides,
and flat MeshBasicMaterial colours are supported. Normal alpha transparency
maps to Canvas opacity and paints after opaque primitives. ArrayCamera
projects each child camera into its declared bottom-left viewport. Line,
LineSegments, and LineLoop with LineBasicMaterial are projected
with their declared colour and width. Solid and dashed lines interpolate RGB
vertex colours through portable Canvas gradients, including clipped endpoints.
Opaque RGB lines and MeshBasicMaterial wireframe edges approximate linear-RGB
and perspective-correct interpolation with adaptive encoded colour stops. Each
edge still emits one primitive, capped at 32 stops; short projected edges have
smaller budgets. Set lineColorInterpolation="endpoints" on ThreeCanvas, or
pass the same option to projectThreeScene, to keep the cheaper two-stop path
for large animated scenes. Uniform colours use two stops. Transparency, fog,
normal-derived gradients and non-default Three colour-management configurations
retain their existing approximation. The cache holds at most 256 gradient
conditions; many unique animated depths can miss it on every frame.
Solid meshes likewise preserve RGB vertex attributes and interpolate colours
across clipped triangles. RGBA attributes also preserve per-vertex alpha when
the material opts into transparency; opaque RGBA and alpha-tested gradients
remain diagnostic because Canvas source-over would change WebGL blend
semantics. The portable mesh interpolation is screen-space; perspective-correct
interpolation remains a GPU-backend concern.
Primary MeshBasicMaterial.map colour textures also project when they use
SRGBColorSpace, clamp, repeat, or mirrored wrapping on either axis, a URL/URI or Native
asset source, and complete two-component UVs. Texture transforms and flipY
are preserved, including UVs created by clipping. Sampling is affine in screen
space and has no mipmaps. Mesh, point, and sprite material colours multiply the
map's linear RGB while preserving its alpha. Web caches up to eight processed
colours per decoded image (fewer for large images); non-white tints require same-origin or CORS-enabled
images, and continuously animated tints should use a GPU renderer instead.
Native composes Skia's decode, colour matrix, and encode filters. Vertex-colour/fog modulation,
and per-pixel alpha tests stay diagnostic instead of being silently
approximated.
Solid Scene.background colours and constrained 2D colour textures fill the
portable viewport without creating an interactive object. Cube/equirectangular
environment backgrounds remain diagnostic. Ordinary 2D texture backgrounds
support linear-RGB intensity; backgroundBlurriness is a no-op, matching Three.js.
Fog and FogExp2 blend supported meshes, wireframes, lines, points, and
sprites in the same working colour space as Three.js, while respecting each
material's fog opt-out. Constant-depth shapes and individual points match
the shader formula; varying-depth portable gradients evaluate fog at their
vertices or endpoints rather than per fragment.
Uniform alphaTest is preserved for the untextured subset by omitting a whole
primitive when its material opacity is below the threshold. Per-vertex alpha,
alpha hashing, MSAA alpha-to-coverage, and dithering are diagnosed rather than
silently flattened into ordinary opacity.
LineDashedMaterial uses Three's
lineDistance, dash size, gap size, and scale to emit portable segments before
projection. PointsMaterial projects untextured points as circles and the
constrained colour-map subset as screen-aligned point-sprite quads, including
perspective size attenuation. Per-point RGB colours remain available for the
untextured path. Wireframe MeshBasicMaterial is
projected through the same line pipeline, including uniform or per-vertex RGB
colour and wireframeLinewidth. Default world-space material clipping planes cut meshes
and lines and discard clipped points. clipIntersection preserves the union of
material half-spaces for meshes, lines, points, and sprites.
Mesh material arrays
and BufferGeometry groups preserve each group's material and intersect with
the geometry's draw range just as they do in Three.js. Scene.overrideMaterial
replaces supported render-list materials while preserving source visibility and
allowOverride. LOD selects its active
level from the camera distance. SpriteMaterial billboards preserve their
centre, rotation, scale, and size attenuation for solid colours and the same
constrained affine colour-map subset as meshes.
InstancedMesh applies each instance transform, colour, and morph weight.
BatchedMesh projects its geometry ranges, sparse instance IDs, visibility,
transforms, and optional instance colours through public Three.js APIs. Position
morph targets are evaluated for meshes, points, and lines, including relative
morph geometry. SkinnedMesh uses Three's public CPU vertex evaluation so morphs,
bind matrices, bone weights, and the current skeleton pose remain aligned.
This is deliberately not a software WebGL implementation. General GPU texture sampling, lighting, custom blending, shaders, post-processing, and XR need a GPU backend. Unsupported inputs are omitted and returned as diagnostics rather than rendered misleadingly.
MeshNormalMaterial is the first non-basic portable mesh material: smooth and
flat view-space normals become Canvas vertex colours for ordinary and instanced
meshes, while wireframes become normal-coloured Canvas gradients. Each instance
uses its own normal matrix, and ordinary meshes blend absolute or relative
position and normal morph targets before projection. Skinned meshes apply those
morphs before the weighted bone and bind-matrix transforms used by Three.js.
Texture-perturbed normals and instanced morph textures remain diagnostic rather
than receiving an inaccurate approximation.
The version-pinned Three.js coverage report separates
atomic portable capabilities from class-level Three.js surface classification;
both link every claim back to executable tests. Every current portable surface
category now has an independently owned capability inventory, so the report can
publish an overall portable ceiling without presenting it as real-scene success.
The separate real-scene report runs six
version-pinned archetypes and publishes the same observations as
machine-readable JSON. Portable results are
measured deterministically; environment-dependent renderer families remain
explicitly not-run in that checked-in report rather than being inferred from
their API surface.
GPU renderer conformance is environment-dependent and is therefore measured on
main, weekly, and on demand rather than folded into the portable percentage.
Run pnpm --filter @hozo/three test:gpu to produce JSON and Markdown reports
for classic WebGL, the modern renderer's automatic backend choice, and its
forced WebGL 2 backend. A missing native WebGPU backend is recorded as
unavailable; classic WebGL and modern forced-WebGL 2 remain required to draw
and expose working semantic controls. Every available renderer mode also
executes the same six real-scene fixtures and requires useful draws, semantic
controls, and activation for every scene. This makes forced WebGL 2 the stable
Modern-family baseline while native WebGPU remains separately observable.
The product-viewer glTF fixture also decodes an embedded 4x4 PNG through the
browser's TextureLoader and advances its AnimationMixer using the surface's
onFrame callback. The browser probe waits for real frames, records the rotation
change, and requires a GPU texture allocation. The Expo GL probe exercises the
same image and clip through R3F Native's image loader and useFrame; Android and
iOS results are emitted by the Native GPU workflow. The deterministic Node report
uses pinned pixels and a sampled pose. External image URLs, compressed textures,
and larger glTF assets remain separate integration cases.
The weekly/on-demand Native workflow executes those same fixtures through the
portable ThreeCanvas entry on React Native Android and the Skia Canvas host.
It uploads screenshots, accessibility trees, and three-native-corpus.json:
the four portable archetypes must draw, expose named Android controls, and
activate; the ordinary glTF/PBR fixture must stop with the documented
UNSUPPORTED_MATERIAL diagnostic. This is Native-host evidence, not a claim
that the separately tracked Native GPU backend is available.
Part of Hozo, a Rust-powered universal UI compiler and accessibility-first layer for React Native. Most applications install @hozo/core and one build integration; see Getting started. Source and issues: github.com/iray-tno/hozo.
