A port of three.js core and its
WebGPURenderer to Rust on wgpu, graded by
Three's own end-to-end pixel comparison: each ported example renders headless
and is diffed against the reference screenshot Three ships for that example,
using Three's unmodified test/e2e/image.js comparator.
Two things distinguish it from other three.js-shaped Rust crates (including
the similarly named threers): the shaders are not hand-written but generated
from Three's node graph (TSL) by a port of Three's NodeBuilder, the way
WebGPURenderer does it, so Three's own WGSL dumps are the reference; and
correctness is judged by Three's own examples and reference screenshots, not
by scenes written for the port.
Status: 100 of 215 gradeable three.js WebGPU pages graded (16 N.A.). Early,
working, incomplete. Three ships 231 webgpu_* pages; 16 cannot grade against
a reference frame (XR sessions, editor UIs, benchmarks, video and DOM layout:
the page table in docs/parity.md says which and why),
a few more are ported but ignored because Three fails its own reference on
the grading machine (the same table lists them), and the webgpu_* examples
in the gallery below pass the grader. The rest (and Three's 600-odd examples
overall) have not been attempted. The API follows Three's object model but is
not stable. Vulkan on Linux is the only backend that has been run.
docs/parity.md judges every three.js export, row by row,
as present, partial or absent in the port, with the test or graded example
that verifies each present row.
One paragraph per area, describing what the code holds today and the larger
gaps. docs/parity.md is the row-level truth: every
three.js export, its verdict and the test or graded example that checks it.
Math and core. The math library whole: vectors, Matrix2/3/4,
Quaternion, Euler, Color with ColorManagement, Box2/Box3,
Sphere, Plane, Ray, Line3, Triangle, Frustum, Spherical,
Cylindrical, SphericalHarmonics3 and the interpolants, each with its
QUnit port. MathUtils has the interpolation and power-of-two helpers but
not the random, UUID or normalize ones, and there is no
BezierInterpolant. The Object3D scene graph with layers, typed scene
events and render hooks; BufferGeometry with groups, draw ranges, morph
and instanced attributes; typed BufferAttributes (every array type three
uses, normalized) and interleaved buffers; Raycaster and Timer. All
the cameras (perspective, orthographic, array, cube, stereo), Scene, Fog
and FogExp2. The objects: Mesh, Sprite, Points, Line,
LineSegments, InstancedMesh, BatchedMesh, SkinnedMesh with
Skeleton and Bone, Group and ClippingGroup; not LOD. The core
helpers (axes, arrow, grid, polar grid, box, Box3, plane, camera,
skeleton, and the directional, hemisphere, point and spot light helpers).
Geometries. Every core geometry except EdgesGeometry and
WireframeGeometry: box, plane, sphere, circle, ring, cylinder, cone,
torus, torus knot, capsule, lathe, the polyhedra, shape, extrude and tube,
plus the TeapotGeometry and RoundedBoxGeometry addons, bit-exact
against samples generated from three. The curve family (Curve,
CurvePath, lines, Béziers, ellipses and arcs, splines,
CatmullRomCurve3), Path / Shape / ShapePath and ShapeUtils with
Earcut, with their QUnit ports and a 1e-6 oracle against three's own
output.
Node system (TSL). A NodeBuilder that generates both WGSL stages and
the bind group layouts from a node graph, following three's nodes/ and
renderers/webgpu/nodes/, and most of the three/tsl exports, with the
generated WGSL gated against three's own dumps; deliberate divergences are
listed in docs/nodes.md. It covers the material and
lighting flow, morphing, skinning and batching, MRT, velocity, clipping,
PMREM, the reflector, screen reads, compute (storage buffers and textures,
atomics, barriers, workgroup memory, subgroups, indirect dispatch), the
MaterialX noise library, wgslFn / code and user-defined nodes through
Node::Custom. The post-processing nodes of examples/jsm/tsl/display
(bloom, GTAO, SSAO, SSR, SSGI, SSS, TRAA, TAAU, FSR1, SMAA, FXAA, depth of
field, outline, OIT, the stereo passes, the denoisers and the rest) live in
nodes::display. Missing: reference() / materialReference() by name,
custom tone mapping, and JSON node loading.
Materials. Basic, Lambert, Phong, Standard, Physical (clearcoat, sheen,
anisotropy, transmission and volume, iridescence, specular and ior,
diffuse roughness), Toon, Normal, Points, Sprite, LineBasic and the fat
Line2NodeMaterial (no dashes), as node materials with their node slots.
Bump and normal maps, environment maps, blending modes and BlendMode,
alpha test and alpha hash, wireframe, stencil state, backdrop nodes, fog
and tone mapping. Absent: ShadowMaterial (the shadow pass uses an
internal override material, not it), depth, distance, matcap, dashed-line
and SSS materials, dispersion, and the light, displacement and specular
maps. A field the port does not read is reported, not silently dropped
(docs/api.md §7).
Lights and shadows. Ambient, hemisphere, point, spot (no projected
map), directional and LightProbe (nine spherical-harmonic
coefficients, irradiance only), and a user LightingModel. Planar and cube
shadow maps with three's filters: basic, PCF on the Vogel disk (PCFSoft
resolves to it, as in three's WebGPU renderer) and VSM, plus shadow
opacity, skinned casters and custom shadowNode / filterNode.
RectAreaLight, IESSpotLight and ProjectorLight are absent.
Renderer. One Renderer on wgpu: Vulkan natively, the browser's
WebGPU in the web shell. Render lists, instancing, morph targets, skinning
and batching, render targets (MSAA, MRT, mip levels, red formats),
RenderPipeline, DirectRenderPipeline, PassNode post-processing and
QuadMesh, PmremGenerator, CubeCamera / CubeRenderTarget, mipmaps,
line topology, viewport / scissor / clearDepth / autoClear, occlusion
queries, copyTextureToTexture and pixel and storage-buffer readback.
Compute with indirect dispatch and indirect draws, storage textures
(2D and 3D) and storage buffers. The texture family: Texture,
DataTexture, Data3DTexture, CubeTexture, DepthTexture and
compressed 2D and array textures uploaded from KTX2 (compressed cube and 3D
textures parse but do not upload). Velocity as an MRT output, with three's
previous-frame matrices, under temporal anti-aliasing and motion blur;
weighted blended order-independent transparency on per-attachment blend
modes; a PassNode that draws at a fraction of the canvas and swaps each
draw's material (retroPass); clipping planes through ClippingGroup
(union and intersection, hardware clip_distances where the device has
them, alpha-to-coverage edges) and per-material stencil state. Not ported:
BundleGroup, compileAsync, copyFramebufferToTexture, storage array
textures, and cache eviction.
Addons. src/addons/ holds the three/addons/… tier that the graded
examples import: controls (OrbitControls, FirstPersonControls,
FlyControls, TransformControls, each graded against the JS class
itself), lines (fat lines: LineSegmentsGeometry, LineGeometry,
LineSegments2, Line2), text_geometry, curve_modifier_gpu (Flow),
lights (LightProbeGenerator), helpers (LightProbeHelper), objects
(SkyMesh, WaterMesh, Water2Mesh), camera_utils, improved_noise,
simplex_noise, raymarching (RaymarchingBox), textures
(FlakesTexture) and geometry_utils (hilbert3D only). RoomEnvironment
is in environments, SortUtils' radix sort in utils, and the
tsl/display effect nodes in nodes::display (above). They live in the
root crate for the harness reason given under Addons.
Loaders. glTF/GLB: every accessor type, skins, animations,
KHR_mesh_quantization, KHR_draco_mesh_compression,
EXT_meshopt_compression, KHR_texture_basisu, KHR_texture_transform,
EXT_texture_webp, and the KHR_materials_* extensions (specular, ior,
clearcoat, sheen, transmission, volume, anisotropy, iridescence, diffuse
roughness, emissive strength, unlit); not cameras, KHR_lights_punctual,
cubic-spline animation (linearised) or AVIF. Textures from PNG, JPEG, GIF
and WebP (an AVIF image is refused with an error), cube textures, KTX2,
HdrLoader, HdrCubeTextureLoader, UltraHdrLoader,
BufferGeometryLoader, ObjLoader (faces, no MTL), the LUT loaders
(.cube, .3dl, image) and typeface.json fonts (FontLoader). Every
loader is synchronous; there is no LoadingManager, Cache or
ObjectLoader.
Animation. Keyframe tracks of every value type, clips, PropertyMixer,
PropertyBinding (position, quaternion, scale and morph influences),
AnimationAction, AnimationMixer (its loop / finished events are a
MixerEvent queue drained with take_events()), AnimationObjectGroup and
AnimationUtils. Bézier tracks have no
interpolant.
Workspace crate. sdf-text: signed-distance-field text rendering with
a BatchedText object (ttf-parser outlines, analytic rasteriser), and the
SDF text examples with their gates. Its d3_treemap_labels example lays
its treemap out with d3-hierarchy,
a port of d3-hierarchy 3.1.2 that began in this repository.
Our own rendered frames, one per graded example. Each thumbnail opens the example running in your browser on WebGPU (all of them); the caption links the ported source. See docs/gallery.md.
Every one of these also runs in a browser, on the browser's own WebGPU, from
the same init(): the graded frame first, then the page's own animation loop,
with drag to orbit, wheel to dolly and right-drag to pan. See
web/README.md
for how to build and serve the shell, and issue #128
for where that is going. The table's browser column is CI's web-gate job:
the same example in headless Chrome on software WebGPU, graded against the
same screenshots at the same threshold (see
Grading in the browser).
| Three example | different pixels (of 100000) | steady frame (ms) | draw calls | triangles | browser |
|---|---|---|---|---|---|
| webgpu_depth_texture | 0 | 11.0 | 43 | 671746 | yes |
| webgpu_instance_mesh | 60 (Three itself scores 60 against the same JPEG) | 9.3 | 2 | 967001 | yes |
| webgpu_materials_basic | 0 | 16.1 | 118 | 113345 | yes |
| webgpu_rtt | 1 | 2.3 | 3 | 14 | yes |
| webgpu_lights_phong | 31 | 4.3 | 5 | 62001 | yes |
| webgpu_morphtargets | 0 | 2.8 | 2 | 12289 | yes |
| webgpu_shadowmap | 7 | 7.7 | 19 | 39399 | yes |
| webgpu_lights_physical | 4 | 4.2 | 11 | 4267 | yes |
| webgpu_postprocessing_masking | 18 | 1.0 | 3 | 1037 | yes |
| webgpu_tsl_galaxy | 40 | 5.3 | 2 | 40001 | yes |
| webgpu_skinning | 6 | 3.0 | 3 | 30091 | yes |
| webgpu_mesh_batch | 0 | 2.5 | 454 | 44341 | yes |
| webgpu_postprocessing_radial_blur | 7 | 3.9 | 2 | 401 | yes |
| webgpu_materials | 44 | 7.3 | 19 | 350065 | yes |
| webgpu_postprocessing_ssaa | 0 | 11.9 | 17 | 2119689 | yes |
| webgpu_pmrem_cubemap | 31 | 8.2 | 32 | 243905 | yes |
| webgpu_postprocessing_bloom_selective | 1 | 16.5 | 63 | 256013 | yes |
| webgpu_compute_points | 4 (see below) | 10.7 | 2 | 1 + 300000 points | yes |
| webgpu_lines_fat | 0 | 3.8 | 6 | 11191 | yes |
| webgpu_lines_fat_raycasting | 57 | 1.7 | 2 | 901 | yes |
| webgpu_pmrem_test | 0 | 6.1 | 35 | 67457 | yes |
| webgpu_postprocessing_difference | 13 | 1.5 | 2 | 13 | yes |
| webgpu_postprocessing_direct | 21 | 4.9 | 93 | 4192 | yes |
| webgpu_furnace_test | 0 | 7.1 | 122 | 116161 | yes |
| webgpu_postprocessing_anamorphic | 2 | 8.1 | 16 | 398798 | yes |
| webgpu_pmrem_scene | 0 | 3.8 | 9 | 58433 | yes |
| webgpu_postprocessing_bloom | 0 | 8.6 | 19 | 52085 | yes |
| webgpu_materials_envmaps | 0 | 2.1 | 3 | 7105 | yes |
| webgpu_materials_cubemap_mipmaps | 1 | 2.6 | 3 | 65025 | yes |
| webgpu_postprocessing_bloom_emissive | 0 | 5.1 | 15 | 17449 | yes |
| webgpu_instance_uniform | 13 | 6.0 | 14 | 247105 | yes |
| webgpu_tsl_interoperability | 0 | 2.2 | 2 | 4 | yes |
| webgpu_pmrem_equirectangular | 0 | 5.9 | 32 | 243905 | yes |
| webgpu_postprocessing_ca | 0 | 3.4 | 23 | 5658 (+ 42 lines) | yes |
| webgpu_loader_gltf | 22 | 1.9 | 3 | 17437 | yes |
| webgpu_mrt | 10 | 2.8 | 3 | 17437 | yes |
| webgpu_custom_fog_background | 7 | 2.0 | 2 | 15453 | yes |
| webgpu_loader_gltf_sheen | 28 | 3.1 | 6 | 41921 | yes |
| webgpu_deferred | 0 | 2.8 | 25 | 26378 | yes |
| webgpu_loader_gltf_anisotropy | 94 | 3.2 | 5 | 12188 | yes |
| webgpu_loader_gltf_transmission | 6 | 2.9 | 6 | 26433 | yes |
| webgpu_materials_texture_manualmipmap | 81 | 2.5 | 11 | 19 | yes |
| webgpu_tsl_vfx_flames | 31 | 5.4 | 5 | 9 | yes |
| webgpu_procedural_texture | 0 | 3.2 | 5 | 6 | yes |
| webgpu_postprocessing_sobel | 0 | 2.8 | 3 | 91218 | yes |
| webgpu_postprocessing_transition | 0 | 1.4 | 2 | 40001 | yes |
| webgpu_tsl_raging_sea | 12 | 10.5 | 2 | 131073 | yes |
| webgpu_tsl_angular_slicing | 4 | 3.3 | 9 | 45397 | yes |
| webgpu_textures_2d-array_compressed | 0 | 6.1 | 1 | 2 | yes |
| webgpu_compute_texture | 0 | 1.2 | 2 | 3 | yes |
| webgpu_volume_perlin | 23 | 5.2 | 2 | 13 | yes |
| webgpu_shadowmap_vsm | 0 | 4.5 | 22 | 10419 | yes |
| webgpu_shadowmap_pointlight | 63 (Three itself scores 63 against the same JPEG) | 5.0 | 20 | 7421 | yes |
| webgpu_struct_drawindirect | 0 (see below) | 1.3 | 2 | 100001 (drawn indirect) | yes |
| webgpu_particles | 0 (see below) | 5.8 | 4 | 6001 (+ 82 lines; the fire's 2000 drawn indirect) | yes |
| webgpu_clearcoat | 4 | 2.7 | 6 | 17857 | yes |
| webgpu_modifier_curve | 3 (Three itself scores 3 against the same JPEG) | 1.3 | 7 | 34753 (+ 50 lines) | yes |
| webgpu_camera_array | 20 | 4.0 | 73 | 4808 | yes |
| webgpu_sprites | 0 | 13.8 | 201 | 401 | yes |
| webgpu_instance_sprites | 32 | 1.0 | 2 | 20001 | yes |
| webgpu_materials_toon | 0 | 58.9 | 438 | 421985 | yes |
| webgpu_occlusion | 0 | 1.9 | 3 | 963 | yes |
| webgpu_fog_height | 0 | 2.8 | 3 | 3185 | yes |
| webgpu_shadowmap_opacity | 9 | 6.0 | 6 | 408644 | yes |
| webgpu_multiple_rendertargets | 22 | 2.2 | 2 | 8193 | yes |
| webgpu_multiple_rendertargets_readback | 22 | 2.4 | 3 | 8194 | yes |
| webgpu_texturegrad | 0 | 2.2 | 4 | 6 | yes |
| webgpu_texturegather | 16 | 1.8 | 4 | 6 | yes |
| webgpu_postprocessing_fxaa | 56 (Three itself scores 56 against the same JPEG) | 4.0 | 3 | 402 | yes |
| webgpu_lights_selective | 9 (Three itself scores 9 against the same JPEG) | 4.6 | 5 | 62001 | yes |
| webgpu_loader_gltf_compressed | 4 | 6.1 | 3 | 332259 | yes |
| webgpu_equirectangular | 0 | 3.6 | 2 | 1985 | yes |
| webgpu_skinning_points | 44 | 7.5 | 2 | 32681 (16340 point quads) | yes |
| webgpu_instance_points | 16 | 21.5 | 6 | 3011 (256 point quads, drawn twice) | yes |
| webgpu_textures_partialupdate | 0 | 9.3 | 2 | 3 | yes |
| webgpu_layers | 76 (Three itself scores 76 against the same JPEG) | 3.5 | 5 | 16985 | yes |
| webgpu_loader_gltf_diffuse_roughness | 0 | 8.6 | 26 | 75713 | yes |
| webgpu_cubemap_mix | 1 | 6.4 | 3 | 17437 | yes |
| webgpu_mirror | 22 | 6.4 | 40 | 6269 | yes |
| webgpu_tsl_halftone | 93 (Three itself scores 93 against the same JPEG) | 3.1 | 4 | 44363 | yes |
| webgpu_tsl_earth | 0 | 2.6 | 3 | 16129 | yes |
| webgpu_lightprobe | 2 | 1.3 | 4 | 6913 | yes |
| webgpu_lightprobe_cubecamera | 0 | 1.6 | 3 | 2945 | yes |
| webgpu_sky | 0 | 5.8 | 3 | 3981 | yes |
| webgpu_backdrop | 23 (Three itself scores 23 against the same JPEG) | 6.1 | 11 | 37771 | yes |
| webgpu_postprocessing_motion_blur | 5 | 6.2 | 9 | 100553 | yes |
| webgpu_display_stereo | 0 | 4.6 | 5 | 963969 | yes |
| webgpu_ocean | 0 | 4.5 | 19 | 75 | yes |
| webgpu_postprocessing_godrays | 3 | 12.1 | 54 | 9234 | yes |
| webgpu_postprocessing_lensflare | 0 | 3.3 | 29 | 55534 | yes |
| webgpu_postprocessing_dof_basic | 5 | 7.1 | 42 | 33694 | yes |
| webgpu_postprocessing_ssr | 4 | 10.2 | 17 | 22888 | yes |
| webgpu_postprocessing_3dlut | 0 | 1.9 | 4 | 5705 | yes |
| webgpu_postprocessing_outline | 15 (Three itself scores 15 against the same JPEG) | 4.1 | 46 | 96243 | yes |
| webgpu_oit | 0 | 2.2 | 12 | 12045 | yes |
| webgpu_clipping | 17 | 4.5 | 5 | 11403 | yes |
| webgpu_clipping_stencil | 0 | 6.0 | 9 | 132007 | yes |
| webgpu_loader_gltf_iridescence | 0 | 5.2 | 5 | 20851 | yes |
| webgpu_materials_transmission | 16 | 6.3 | 4 | 9921 | yes |
| webgpu_portal | 77 | 24.9 | 9 | 53085 (and 147336 wireframe lines) | yes |
webgpu_compute_points is graded like the rest and its 4 pixels mean less
than the rest: its frame is black apart from a 2x2 block at the centre, so
Three's comparator would pass it at 0.0% even if the compute passes never ran.
That rung is gated on the WGSL its kernels compile to and on reading the
storage buffers back — docs/rung12-progress.md says why and what the tests
assert.
webgpu_struct_drawindirect and webgpu_particles are the same kind of
green. three's page draws before it dispatches the kernels that fill the
indirect buffer, so the graded frame of the first is the clear colour; at the
grader's pinned time every sprite of the second has zero opacity, so its frame
is the grid. Both are gated on their shaders against three's own dump
(tests/nodes_compute_indirect_wgsl.rs) and on what the GPU draws a frame or
a moment later (tests/renderer_compute_indirect.rs); the notes say what each
asserts. The triangle counts are the CPU-side counts renderer.info() records
for an indirect draw, as three's does, not what the GPU was told to draw.
Some pages are ported but cannot be graded. Either three's own e2e harness
skips them, or three.js itself misses its own reference screenshot on this
machine, so a matching port would miss it too. Twelve pages that three
misses here are only #[ignore]d rungs in tests/e2e/main.rs
(docs/parity.md counts them, with retro, taau and fsr1, as fifteen). The pages
below are in the native viewer, not in the gallery or the browser shell:
| example | why it has no grade |
|---|---|
webgpu_postprocessing_traa · notes |
in three's e2e exception list (test/e2e/puppeteer.js, "Black screen"). It is gated instead on its resolve shader against three's dump and on tests/traa_frames.rs |
webgpu_water · notes |
in three's e2e exception list (test/e2e/puppeteer.js, "1 min"). It is gated instead on the water's shaders against three's dumps (tests/nodes_water_wgsl.rs) and on tests/water2_frames.rs |
webgpu_postprocessing_sss · notes |
in the same exception list. It is gated instead on its SSS shader against three's dump and on tests/sss_frames.rs |
webgpu_postprocessing_ssgi · notes |
in three's e2e exception list (test/e2e/puppeteer.js, "Black screen"). It is gated instead on its SSGI, composite and TRAA shaders against three's dump and on tests/ssgi_frames.rs |
webgpu_postprocessing_ao · notes |
in the same exception list. It is gated instead on its GTAO shader against three's dump and on tests/gtao_frames.rs |
webgpu_postprocessing_dof · notes |
in three's e2e exception list (test/e2e/puppeteer.js, "Black screen"). It is gated instead on each of DepthOfFieldNode's seven distinct quad shaders against three's dump (dof_* in tests/nodes_display_wgsl.rs) |
webgpu_postprocessing_retro · notes |
three.js scores 1.5% against its own reference here, over the 0.1% limit, and the port scores the same 1503 pixels. It is gated instead on its two post-processing shaders against three's dump and on tests/retro_frames.rs |
webgpu_postprocessing_ssr_denoise · notes |
in three's e2e exception list (test/e2e/puppeteer.js, "Need more time to render"). It is gated instead on its scene material, SSR, denoise, grading and sharpen shaders against three's dump and on tests/ssr_denoise_frames.rs |
webgpu_upscaling_taau · notes |
three.js scores 0.5% against its own reference here (540 pixels), over the 0.1% limit, and the port scores 539. It is gated instead on its seed and resolve shaders against three's dump and on tests/taau_frames.rs |
webgpu_upscaling_fsr1 · notes |
three.js scores 0.7% against its own reference here, over the 0.1% limit, and the port scores 698 pixels to three's 703. It is gated instead on its EASU and RCAS shaders against three's dump and on tests/fsr1_frames.rs |
Measured on Intel Iris Xe, Mesa 25.3.6, Fedora 43, against three.js 5f610f5 (past r186, for the cube PMREM of 2f80402; the pin becomes the r187 tag once upstream tags it). Other GPUs and drivers will land somewhere else on the pass threshold; the threshold is Three's own (0.1% of pixels).
The steady frame is the whole cost of a frame once the first has built and
uploaded everything — the example's animate() through to the GPU finishing
it, at 800x500, mean of 30 frames after a 10-frame warm-up in a release build
(viewer <example> --headless --frames 40, below). The same machine caveat
applies. The e2e grader renders three more frames after the graded one and
fails a rung whose steady frame is over a ceiling set well above these
(STEADY_FRAME_CEILING in tests/e2e/main.rs), so a per-frame cost the single
graded frame cannot see fails the ladder.
The last two columns are renderer.info() for the whole graded frame — draw
calls and the triangles they drew, instancing multiplied in (webgpu_rtt's 14
is a cube and two full-screen quads; webgpu_instance_mesh's million is one
instanced draw). Unlike the time, these are exact: the same grader asserts
that frames two and three of every rung compile, build and upload nothing,
as an equality, which is what catches a regression that re-uploads a live
geometry every frame while staying well under a time ceiling. Info has the
rest of the counts (pipelines, geometries, attribute buffers, textures, and
the resident totals).
(For how to contribute, what is in scope, and the gates a change has to pass,
see CONTRIBUTING.md.)
Requires a Rust toolchain (1.90 or newer) and a Vulkan driver. wgpu comes
from crates.io, pinned to 30.0.1 in Cargo.toml; nothing else is unusual.
wgpu types are part of the public API (a caller-owned Device and Queue,
external textures), so an application that uses wgpu directly must use the
same wgpu major version as three-rs, or it builds two copies that cannot
exchange values. Each three-rs minor release tracks one wgpu major: 0.1, 0.2 and
0.3 are on wgpu 30. Moving to a new wgpu major is a breaking change, so it
comes in the next minor release (0.3 to 0.4), never in a patch release.
cargo build --release
cargo test -p sdf-text --lib # sdf-text unit tests; no GPU
cargo test -p three-rs --lib # three-rs unit tests; no GPUThe rest of cargo test --workspace needs a GPU (the renderer tests and the
SDF text gates) and, for the e2e grader, the three.js checkout described
next. The e2e tests serialise themselves on the one GPU; no --test-threads
flag is needed for them, but sdf-text's three GPU gates still want
-- --test-threads=1.
cargo run --release --bin viewer -- --list
cargo run --release --bin viewer -- webgpu_lights_physical
cargo run --release --bin viewer -- 8 # the same, by key
cargo run --release --bin viewer -- shadowmap --headless --frames 40Opens the named example in a window (winit, tested on Wayland). Every graded
example is there, and so are the ungraded webgpu_postprocessing_traa, webgpu_water,
webgpu_postprocessing_sss, webgpu_postprocessing_ssgi,
webgpu_postprocessing_ao,
webgpu_postprocessing_dof, webgpu_postprocessing_ssr_denoise,
webgpu_postprocessing_smaa, webgpu_postprocessing_pixel,
webgpu_postprocessing_retro, webgpu_upscaling_taau and
webgpu_upscaling_fsr1. The last five are ported but their e2e tests are #[ignore]d, because three itself fails
their references on this machine; their
smaa,
pixel,
retro
taau
and fsr1
notes have the numbers. Each one animates, orbits, dollies and pans through
its own animate(), resize() and OrbitControls — the viewer drives the
example, it does not restate it. --list prints the examples with their keys,
and a key stands in for the name on the command line; in the window, [ and
] step to the previous and next example, because most of them do not fit in
the 36 single keys a keyboard has. The window prints one line a second with
the frame rate and the steady-state render time (mean and max over the last
60 frames, after a 10-frame warm-up):
webgpu_lights_phong — 1000x625 — 59.9 fps — render mean 1.61 ms max 1.79 ms (last 60 frames, after 10 warm-up)
--headless --frames N renders N frames with no window and reports the same
numbers for the whole frame, CPU and GPU, which is how the table above was
measured; add --screenshot out.png to keep the last frame. It also writes
target/e2e/<example>/strip.png: three more frames tiled left to right at
1:1, each with its draw calls and its build counts in the gutter under it, so
the count ladder sits beside the time one. The e2e harness writes the same
strip per rung (steady-strip.png) and one for the geometry-mutation case.
three.js keeps its controls, loaders and helpers out of core, in
examples/jsm/ — exported as three/addons/*, importing from three like any
user, and without core's stability promise. addons/ is the same tier here:
workspace crates that depend on three-rs and are not ports of anything in
three.js' src/.
three_rs::addons::controls::OrbitControls is the exception to that rule, and
it is in the root crate rather than a workspace one: most of the graded pages
create an OrbitControls, and an example pulled in by #[path] cannot reach a
crate that depends on three-rs. It is a port of
examples/jsm/controls/OrbitControls.js — the same state, the same defaults,
the same update() — but input-agnostic: it takes pointer_down,
pointer_move, pointer_up, wheel and key as plain values, so the same
controls serve a winit window, a DOM canvas and a test. It is graded the way
everything else here is, against the original: tools/orbit_controls_reference.mjs
runs the JS class under node over twelve scripted scenarios and prints the
camera it ends with, and tests/addons_orbit_controls.rs replays the same
scenarios and asserts every number to 1e-9. FirstPersonControls,
FlyControls and TransformControls (the translate/rotate/scale gizmo) sit
beside it in the same module and are graded the same way;
docs/controls.md describes all four.
addons/controls/ is a different addon: three-rs-controls, a MapControls in the spirit of
three.js' addon of that name, over a Ground — a sphere whose north pole is the
world origin, so R = 1e7 is a plane and a small R a planet, with no separate
case. The orbit target is a point on the ground, the camera never rolls —
up is the ground normal — and every field is damped with smooth_damp, a port
of camera-controls' smoothDamp.
cargo run --release -p three-rs-controls --bin heli is the demo, with its key
map on screen: left-drag grabs the ground,
right-drag (or ctrl-drag) orbits, the wheel zooms to the pointer,
the arrows pan, [ ] curl the ground and P flattens it, Home
resets, Tab is the overview (click a pane to drop onto it), Esc quits.
cargo run --release -p three-rs-controls --bin heli -- \
--headless addons/controls/shots/heli-sphere-high.png \
--radius 300 --pose 0,0,800,0,20 [--overview] [--size 1600x1000] [--no-legend]The examples load their textures and models from Three's own examples/
directory, and the grader reads Three's reference screenshots and runs its
comparator under node. So they need a three.js checkout:
git clone https://github.com/mrdoob/three.js ~/src/vendor/three.js
git -C ~/src/vendor/three.js checkout 5f610f5 # the r187 tag, once upstream tags it
(cd ~/src/vendor/three.js && npm ci)
export THREE_JS_DIR=~/src/vendor/three.js # this is the default location
cargo test --test e2e -- --nocaptureEach e2e test writes actual.png, the reference, and a diff strip under
target/e2e/<example>/. The grader is unmodified; rung0/grader-flags.patch
is only needed to run Three's own Chrome-based e2e suite on Linux with a
real Vulkan adapter, which is how the reference numbers were calibrated
(rung0/RUNG0.md). docs/grader.md says what the grader reproduces and
what a pass means.
Two more checkouts are optional:
| env var | default | needed by |
|---|---|---|
D3_GALLERY_DIR |
~/src/vendor/d3-gallery |
sdf-text's d3_treemap_labels example (its flare.json) |
D3_THREE_DIR |
~/src/projects/d3-three/main |
the image comparison in sdf-text's tests/d3_treemap_labels.rs (its examples/screenshots/d3_treemap.jpg) |
Tests that need a checkout that is missing fail on the open with the path
they looked for. The one exception is D3_THREE_DIR: d3-three is not public
yet, so without it that test prints the path it looked for and skips only the
image comparison.
src/ the three-rs crate, mirroring three.js's src/ tree
math core cameras geometries lights loaders materials objects textures animation
nodes/ TSL nodes and the WGSL NodeBuilder
renderer/ the wgpu backend: pipelines, bindings, passes, shadows, present
bin/viewer.rs
examples/ one file per ported Three example, also compiled into tests/e2e
tests/ Three's QUnit tests ported per module, plus the e2e harness
sdf-text/ workspace crate: SDF text and BatchedText, with its examples and gates
addons/ workspace crates in the role of three.js' examples/jsm: not ports
controls/ three-rs-controls — the map camera, its ground, and the heli demo
docs/ design notes per subsystem and per-example progress logs
rung0/ how the grader was calibrated
tools/ dump-webgpu.mjs, the rung dump hook (see docs/dumping.md)
ARCHITECTURE.md is the map: one frame through the code, and which note
explains each part. Of the notes, docs/nodes.md and docs/scene-graph.md
are the two to read first: how the node system maps onto Three's, and how the
Rc<RefCell<Object3D>> scene graph replaces Three's prototype tree.
docs/dumping.md says how a rung captures the WGSL and GPU descriptors it
ports against.
Example by example. Each "rung" takes one Three example, dumps the WGSL Three
generates for it (tools/dump-webgpu.mjs; see docs/dumping.md), ports whatever the example needs until the generated WGSL
matches and the grader passes, then merges. The port was directed and largely
written by Claude agents with the pixel diff as the ground truth; the
per-rung progress notes in docs/ record what each rung found, including the
places where Three's own output was reproduced bug-for-bug and the places
where it deliberately was not.
MIT, in LICENSE. The portions translated from three.js are under three.js'
own MIT licence, in LICENSE-three.js.

































































































