WAMR3D is a 3D engine whose core is Rust compiled to WebAssembly, with its own WebGPU renderer and a native WebGL 2 fallback. You write the same code you would write for three.js, and the scene graph, culling, draw ordering and shader generation run in Wasm.

Version 0.9.5 is out. Most of it is about the GPU: how fast a frame reaches it, and how close the picture is to three.js. Let me show you the pictures first.

Reflections that match three.js

Open this page: wamr3d.org/parity/visual/?case=cube_environment

Two MeshStandardMaterial spheres in a cube-map environment: a polished metal ball (roughness 0.05) and a rough dielectric (roughness 0.5). The page draws the scene live with WAMR3D in your browser, next to a stored render from three.js's own WebGPU renderer, and shows the pixel difference between them.

That page is not a demo made for this post. It is one of the 39 visual test cases the engine has to pass before a release. Each case allows a mean difference of at most 1.5 out of 255 per channel. Add &backend=webgl to the URL to see the same check on the WebGL 2 renderer.

The scene code is ordinary three.js code:

scene.background = cubeMap; scene.environment = cubeMap; for ( const [ roughness, metalness, x ] of [ [ 0.05, 1, - 1 ], [ 0.5, 0, 1 ] ] ) { const ball = new THREE.Mesh( new THREE.SphereGeometry( 0.8, 48, 24 ), new THREE.MeshStandardMaterial( { color: 0xffffff, roughness, metalness } ) ); ball.position.x = x; scene.add( ball ); } 

The environment is prefiltered on the GPU for each roughness level, as three.js does it, and up to eight environment maps can be used in one frame.

Shadows: directional, spot and point

Basic, PCF and VSM shadow maps are supported, up to 16 per frame. You turn them on the way you know:

renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; const bulb = new THREE.PointLight( 0xffffff, 30, 0, 2 ); bulb.position.set( 0, 1.2, 0 ); bulb.castShadow = true; bulb.shadow.mapSize.set( 512, 512 ); box.castShadow = true; floor.receiveShadow = true; 

Physical materials and tone mapping

Clearcoat, sheen and a coloured specular layer on MeshPhysicalMaterial, along with iridescence, anisotropy and transmission with dispersion. There are also six tone mappers, each one its own case: ACES, AgX, Neutral, Reinhard, Cineon and linear.

Every material gets shaders built only for the features it uses. await renderer.compileAsync( scene, camera ) builds them before the first frame, so the page does not freeze while shaders compile.

What's new in 0.9.5: getting draws to the GPU faster

The scene was already prepared in WebAssembly. What still cost time was the JavaScript that hands each draw to the GPU, and this release rewrote it on both backends:

  • No per-draw binding of object data. Each mesh's matrices and flags (now 128 bytes, down from 256) are read by index in the shader. On WebGPU the index comes from firstInstance, with the data in one storage buffer. On WebGL 2 it comes from gl_InstanceID over 128-object uniform blocks, without any extension.
  • Automatic instancing. Consecutive draws with the same geometry, material and pipeline are issued as one instanced draw, even when you created them as separate Mesh objects.
  • Grouping where order cannot matter. Opaque meshes that write depth produce the same image in any order. WAMR3D groups them by material and geometry, closest first within each group. Anything order-dependent (blending, no depth write, stencil, custom shaders) keeps strict depth order.
  • Shared geometry buffers on WebGPU and one vertex array per geometry on WebGL 2, so changing meshes no longer rebinds every buffer.
  • Faster picking. A quick bounding-sphere test rejects objects before their geometry is read. 100 rays against 1,000 boxes went from 7.34 to 5.5 ms, now slightly faster than three.js (6.2–6.8 ms).
  • Faster skeletal animation. Skeleton.update reads every bone's world matrix in one call. With 200 characters of 24 bones blending two clips, a frame went from 4.52 to 3.77 ms (three.js 4.81–4.96 ms).
  • Two new visual tests (draw_batches, geometry_pool) that fail if batched or pooled draws read the wrong data, plus an MIT LICENSE file at the repository root.

Measured on the same machine, in the same session, against 0.9.4 (CPU time to submit a frame of separate meshes at 1280×720):

0.9.4 0.9.5
10,000 meshes, WebGPU 14.0–15.7 ms 4.1–4.4 ms
10,000 meshes, WebGL 2 24.6–25.3 ms 4.0 ms
50,000 meshes, WebGPU 70–72 ms (11 fps) 23–24 ms (29–31 fps)
50,000 meshes, WebGL 2 127 ms (7.2 fps) 24–26 ms (28–29 fps)

These rows were measured on a 0.9.4 build that also lacked the shared buffers and vertex arrays, so they show the whole release, not one change. Along the way the new tests found an old shading bug: under an orthographic camera, lit materials used a per-pixel view direction instead of the camera's. It is fixed on both backends.

Against three.js

From the full benchmark run of 0.9.5 (i9-10900X, RTX 2070 SUPER, Chrome 157, single runs, medians):

Scene WAMR3D three.js
50,000 separate animated meshes, 1280×720, WebGPU 37.8 fps 2.1 fps
50,000 separate meshes, 3840×2160, WebGL 2 against WebGL 37.6 fps 7.0 fps
100,000 instanced bodies, 3840×2160, WebGPU 41.3 fps 11.2 fps
10,004 objects, 3840×2160, CPU draw submission (WebGPU) 3.6 ms 78.6 ms
Same scene, first frame (WebGPU) 133 ms 652 ms

Where it is not ahead: at 2,000 bodies every engine hits the 60 fps display cap, and with 50,000 instanced bodies through the three.js-style object API, three.js on WebGL is a little faster (21.2 fps against 17.4 fps on WebGL 2). The raw logs are in the repository, and the benchmarks page shows every configuration.

You can run the comparison yourself in the showcase: up to 100,000 tumbling bodies, eight rendering paths, and a button that measures them one after another.

Try it

npm install wamr3d 
- import * as THREE from 'three'; + import * as THREE from 'wamr3d/compat'; + await THREE.init(); - const renderer = new THREE.WebGLRenderer( { antialias: true } ); + const renderer = new THREE.WebGPURenderer( { antialias: true } ); + await renderer.init(); 

WebGPURenderer picks WebGPU when the browser has it and falls back to WebGL 2 when it doesn't. What is not covered yet: TSL node materials are not ported, GLSL ShaderMaterial draws only on WebGL 2, and browser checks have run in Chromium-based browsers only. The status page lists the rest.

Issues and pull requests are welcome. If a scene of yours renders differently from three.js, that is a bug, and a screenshot of both is the best report.