@hology/webgpu-renderer
v0.1.6
Published
WebGPU renderer for the Hology game engine
Downloads
1,038
Readme
@hology/webgpu-renderer
TypeScript-first WebGPU renderer scaffold aimed at teams migrating from an existing three.js codebase.
The current slice keeps three scene/content classes as the authoring surface and adapts them into a small WGSL-first renderer:
MeshSkinnedMeshInstancedMeshBatchedMeshBufferGeometryandBufferAttributeTexture/CanvasTexture/DataTextureMaterialvia a customWGSLShaderMaterial
What this version does
- Initializes a raw WebGPU device and canvas context.
- Traverses a three
Scene. - Skins
SkinnedMeshcontent in the WGSL vertex stage from cached GPU bone palettes; CPU work is limited to one palette update per changed skeleton plus conservative bone-envelope culling. - Extracts standard meshes, instanced meshes, and batched meshes into a shared per-frame instance buffer.
- Renders standard meshes, true instanced draws for
InstancedMesh, and batched draws forBatchedMeshwithout per-draw object-uniform uploads. - Runs the frame through an explicit render graph with pass dependencies, resource reads/writes, and ordered execution.
- Builds a Hi-Z depth pyramid with compute passes.
- Uses compute to cull indexed opaque draws against the Hi-Z pyramid and write indirect indexed draw arguments on the GPU.
- Temporally GPU-culls every instance in a
StaticDrawSet, compacts visible source-instance indices before the static depth prepass, and builds its indirect instance counts without per-frame CPU visibility uploads. - Renders directional-light and point-light shadow maps.
- Exposes a reusable
ComputeKernelabstraction for general-purpose compute work. - Exposes both
render()andrenderAsync()so graph passes can opt into asyncprepare()work before execution. - Exposes
compile()andcompileAsync()so scenes can prewarm geometry, textures, and material pipelines before the first visible frame. - Can capture per-pass render-graph timings, with CPU timings everywhere and GPU timestamp-query timings when the device supports them.
- Supports custom render-graph nodes, including fullscreen post-processing passes that can read the renderer's scene color target and write to a post-process target before presentation.
- Exposes a higher-level
FrameGraphBuilderso passes can be wired with named outputs and inputs instead of rawGPUTextureViewplumbing. - Lets you write custom WGSL shaders and pass uniforms/textures through a material that plays the role of a WebGPU-era
ShaderMaterial. - Supports sampled 2D, 2D-array, 3D, cube, compressed, and compressed-array texture uploads, including KTX2-friendly
CompressedTexture,CompressedTextureArray,CompressedCubeTexture,DataArrayTexture, andData3DTexturepaths. - Ships with a demo scene that proves the pipeline works with
Mesh,BufferAttribute,Texture,InstancedMesh,BatchedMesh, and dynamic shadows.
What this version does not do yet
- Built-in PBR or lighting materials.
- GPU-driven culling for transparent draws or non-indexed draws. The current visibility path targets indexed opaque draws first.
- GPU compaction into a single
multi_draw_indirectstyle stream. The renderer writes one indirect indexed argument record per extracted indexed draw and then submits them individually. - Full GPU-driven aggregation for mutable
BatchedMeshcontent. Dynamic batches still use the CPU extraction path; immutable batches can useStaticDrawSetfor per-instance GPU culling and compaction. - Cascaded directional shadows, clustered lighting, point-light arrays, shadow atlases, or any of the bigger AAA-scale scheduling systems yet.
- Storage textures, external textures, and the more specialized renderer-owned texture flows from three's node/WebGPU stack.
- Post-processing, temporal anti-aliasing/upscaling, terrain, particles, or material-template systems.
Install and run
npm install
npm run devUseful scripts:
npm run buildnpm run typechecknpm run demo:build
Public API
import {
FrameGraphBuilder,
Mesh,
Scene,
PerspectiveCamera,
BoxGeometry,
WGSLShaderMaterial,
WebGpuRenderer,
} from "@hology/webgpu-renderer";
const renderer = new WebGpuRenderer({ canvas });
await renderer.initialize();
const material = new WGSLShaderMaterial({
vertexShader: `...`,
fragmentShader: `...`,
uniforms: {
time: { type: "f32", value: 0 },
},
});
const mesh = new Mesh(new BoxGeometry(1, 1, 1), material);
const scene = new Scene();
scene.add(mesh);
const camera = new PerspectiveCamera(50, canvas.width / canvas.height, 0.1, 100);
camera.position.z = 4;
renderer.render(scene, camera);
await renderer.renderAsync(scene, camera);
renderer.compile(scene, camera);
await renderer.compileAsync(scene, camera);compile() is synchronous and assumes initialize() has already completed. compileAsync() will initialize on demand if needed and uses async pipeline compilation for WGSL materials.
For incremental migrations, a renderer-wide fallback can draw meshes whose source
materials have not been ported yet. The source materials remain untouched, and
native WGSLShaderMaterial instances still take precedence:
const renderer = new WebGpuRenderer({
canvas,
fallbackMaterial: diagnosticMaterial,
// Disable this for scenes whose callbacks require THREE.WebGLRenderer APIs.
invokeObjectRenderCallbacks: false,
});The fallback applies to regular, instanced, and batched mesh extraction, including
static draw sets. For imported THREE.SkinnedMesh objects, the renderer instead
uses its built-in skin-aware lit fallback automatically, so standard Three.js
materials and their skin attributes work without a WGSL material per submesh. Set
fallbackSkinnedMaterial when a project needs a custom GPU-skinned appearance.
Batched meshes from both the Three.js r169 _drawInfo layout and newer
_instanceInfo layout are supported.
Hi-Z occlusion culling is currently optional and defaults to off while the GPU visibility path is still settling:
const renderer = new WebGpuRenderer({
canvas,
hiZOcclusionCulling: false,
sampleCount: 1,
renderGraphProfiling: true,
});
renderer.setHiZOcclusionCullingEnabled(true);
renderer.setRenderGraphProfilingEnabled(true);Whether Hi-Z culling is actually active also depends on device support for WebGPU indirect draws with non-zero firstInstance:
renderer.getHiZOcclusionCullingEnabled(); // requested
renderer.getHiZOcclusionCullingActive(); // requested + supported on this device
renderer.getLastFrameProfile(); // latest CPU/GPU pass timings
renderer.getSampleCount(); // current MSAA sample countManually synchronized scene transforms
By default, render() updates the scene's world matrices before extraction, as
Three renderers normally do. Editors that already track transform dirtiness can
avoid that full traversal by synchronizing dirty subtrees and then disabling the
automatic renderer update:
scene.matrixWorldAutoUpdate = false;
function synchronizeTransform(object: THREE.Object3D): void {
// Recompose this object's local matrix when position/quaternion/scale changed.
if (object.matrixAutoUpdate) object.updateMatrix();
// Update its ancestors as needed, then propagate the new world transform to
// descendants. Call once for each highest dirty root, not for every child.
object.updateWorldMatrix(true, true);
}A camera parented into the scene is covered by its dirty subtree update. For an
unparented camera, either leave camera.matrixWorldAutoUpdate enabled or disable
it and call camera.updateMatrixWorld() after changing its transform. The same
manual synchronization rule applies to directional and spot-light targets whose
matrixWorldAutoUpdate has been disabled.
Static GPU Visibility
For immutable environment content, snapshot the authored Three objects once and remove
the source root from the dynamic scene. The static path supports indexed opaque
Mesh, InstancedMesh, and BatchedMesh content with WGSLShaderMaterial:
await renderer.initialize();
const staticEnvironment = renderer.createStaticDrawSet(environmentRoot, {
label: "environment",
});
scene.remove(environmentRoot);With Hi-Z active, the renderer keeps frozen instance data and per-instance bounds on the GPU. It first culls against the previous frame's depth pyramid, compacts the survivors, and uses those indirect commands for the static depth prepass. It then builds the current pyramid and retests only the initial rejects before forward rendering, which keeps camera motion and newly exposed geometry correct. CPU work does not scale with the static set's instance count.
The first frame after creation, resize, or toggling Hi-Z is deliberately frustum-only because there is no valid depth history yet. Static transforms, membership, geometry, materials, and visibility are frozen; dispose and recreate the set after changing any of those properties. Material uniforms and textures remain live.
MSAA is currently disabled by default. You can enable it with:
const renderer = new WebGpuRenderer({
canvas,
sampleCount: 4,
});or update it later with:
renderer.setSampleCount(4);This first slice applies MSAA to the built-in forward pass and resolves back into the normal scene color target, while the rest of the render graph remains single-sampled.
Meshes can also provide mesh.customDepthMaterial using WGSLShaderMaterial. In this slice, that override is used for directional and spot shadows so vertex-deformed casters can render correctly into the shadow map. The shared depth prepass intentionally skips those meshes for now to keep the prepass on its fast shared pipeline.
Optional renderer hooks:
renderer.setRenderGraphBuilder((graph, context) => { ... })renderer.setHiZOcclusionCullingEnabled(true | false)renderer.getHiZOcclusionCullingActive()renderer.setRenderGraphProfilingEnabled(true | false)renderer.getLastFrameProfile()renderer.createComputeKernel({ ... })
Inside setRenderGraphBuilder(...), the context can now:
- add the built-in shadow, depth, Hi-Z, visibility, forward, and present passes
- add a custom pass via
context.addCustomPass({ ... }) - choose whether the present pass samples from
sceneColororpostProcessColor
For a simpler authoring layer, use FrameGraphBuilder:
renderer.setRenderGraphBuilder((_graph, context) => {
const frameGraph = new FrameGraphBuilder(context);
const vignetteColor = frameGraph.createColorTarget("vignetteColor");
frameGraph.addStandardScenePasses();
frameGraph.addFullscreenPass({
name: "VignettePostPass",
inputs: {
sceneColor: frameGraph.sceneColor,
},
outputs: vignetteColor,
fragmentShader: `...`,
});
frameGraph.present(vignetteColor);
});This keeps the internal renderer explicit, but lets normal usage feel closer to named render targets flowing from one pass to the next.
Shader contract
The renderer prepends a small WGSL header before your shaders. Your WGSL code is expected to provide vsMain and fsMain.
For porting existing three.js ShaderMaterial code, see Migrating Three ShaderMaterial Shaders. For material shaders that write multiple render targets, see Multiple Render Targets. WGSLShaderMaterial accepts three-style uniform entries like { value }, infers common WGSL types, and moves texture uniforms into texture bindings automatically.
WGSLShaderMaterial also runs a small preprocessor before WebGPU compilation. You can use defines plus common GLSL-style directives such as #define, #ifdef, #ifndef, #if, #elif, #else, and #endif in WGSL shader strings:
const material = new WGSLShaderMaterial({
defines: {
USE_BATCH_PARAMS: true,
MAX_LIGHT_STEPS: 8,
},
vertexShader,
fragmentShader,
});The preprocessor is a migration helper, not native WGSL. It supports conditional compilation and simple object-like/function-like macro expansion, but it does not provide three's GLSL chunks or convert GLSL syntax to WGSL.
The renderer also provides a small compatibility define set by default:
HIGH_PRECISIONWEBGPU_RENDERERWGSL_SHADER_MATERIALUSE_BATCHINGUSE_COLORUSE_COLOR_ALPHAUSE_SHADOWMAPwhen compiling a shadow-receiving forward variantDOUBLE_SIDEDorFLIP_SIDEDfrommaterial.side- texture-name defines such as
USE_MAP,USE_NORMALMAP, andUSE_ALPHAMAPwhen matching texture bindings exist - light-count constants such as
NUM_DIR_LIGHTS,NUM_POINT_LIGHTS, andNUM_SPOT_LIGHTS
You can disable a default compatibility define per material by setting it to false in defines.
Available frame resources in every shader:
struct RendererCameraUniforms {
viewProj: mat4x4<f32>,
cameraPosition: vec4<f32>,
viewportAndTime: vec4<f32>,
view: mat4x4<f32>,
projection: mat4x4<f32>,
};Also available:
rendererElapsedTime()— thetimeSecondsargument passed torender(), in secondsrendererWorldPosition(localPosition, instanceIndex)rendererClipPosition(localPosition, instanceIndex)rendererWorldNormal(localNormal, instanceIndex)rendererObjectColor(instanceIndex)rendererBatchingId(instanceIndex)modelMatrix(instanceIndex)modelViewMatrix(instanceIndex)projectionMatrix()normalMatrix(instanceIndex)cameraPosition()rendererDirectionalLightDirection()rendererDirectionalLightColor()rendererPointLightPosition()rendererPointLightRange()rendererPointLightColor()rendererAmbientLight()rendererSampleDirectionalShadow(worldPosition)rendererSamplePointShadow(worldPosition)
If you define material uniforms, they are exposed as:
@group(1) @binding(0) var<uniform> materialUniforms: MaterialUniforms;For example, a uniform named time is read as materialUniforms.time in WGSL rather than as a global time variable.
For renderer-wide animation time, prefer rendererElapsedTime() over a material
time uniform. The renderer uploads it once per frame through the shared scene
or shadow-camera bind group, so it works for forward rendering, depth/shadow
passes, and compatible StaticDrawSet render bundles without per-material
uniform uploads or bundle rebuilds.
For BatchedMesh, rendererBatchingId(instanceIndex) returns the original three.js batched instance id from BatchedMesh.addInstance(...). This is the migration replacement for shaders that used three's batching id path to look up additional per-batch data from a custom texture or buffer.
If you define textures like { albedo: texture }, they are exposed with the correct WGSL texture type for the bound three.js texture. A regular 2D texture looks like:
@group(1) @binding(1) var albedoSampler: sampler;
@group(1) @binding(2) var albedoTexture: texture_2d<f32>;For array, cube, 3D, integer, or depth textures, the declaration changes automatically to the matching WGSL type such as texture_2d_array<f32>, texture_cube<f32>, or texture_3d<f32>.
Vertex attribute locations are fixed for this first slice:
@location(0)positionvec3<f32>@location(1)normalvec3<f32>@location(2)uvvec2<f32>@location(3)colorvec4<f32>@builtin(instance_index)instance index for shared instance-buffer lookups
Missing geometry attributes are auto-filled with defaults.
GPU skinning contract
SkinnedMesh no longer has a CPU-skinned fallback. Imported Three.js meshes use
the renderer's built-in skin-aware fallback material automatically. For custom
WGSL, the renderer sets USE_SKINNING from the draw object and supplies the two
GPU vertex streams. The material only needs the conditional shader path; no
editor-facing skinning setting is required:
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
#ifdef USE_SKINNING
@location(4) skinIndex: vec4<u32>,
@location(5) skinWeight: vec4<f32>,
#endif
@builtin(instance_index) instanceIndex: u32,
};
#ifdef USE_SKINNING
let skinnedPosition = rendererSkinPosition(input.position, input.skinIndex, input.skinWeight);
let skinnedNormal = rendererSkinNormal(input.normal, input.skinIndex, input.skinWeight);
#else
let skinnedPosition = input.position;
let skinnedNormal = input.normal;
#endif
output.position = rendererClipPosition(skinnedPosition, input.instanceIndex);
output.worldNormal = rendererWorldNormal(skinnedNormal, input.instanceIndex);The renderer shares one GPU palette per THREE.Skeleton, uploads it only when its bone world matrices or inverses change, and retains a per-mesh bind-matrix uniform. It also culls against a conservative envelope built from each bone's influenced vertices before a palette upload is needed.
Demo
The demo is wired directly to the source library and shows:
- a regular
Meshusing a customBufferAttributecolor stream - a procedural
SkinnedMeshexample with animated bones - a realistic 20-character X Bot walking crowd loaded from the supplied FBX mesh and animation
- a shared texture sampled from WGSL
- animated material uniforms
- an
InstancedMesh - a
BatchedMesh - a shadow-casting directional light
- a shadow-casting point light
- a render graph that includes depth, Hi-Z, visibility, and forward passes
- a live HUD that visualizes per-pass frame timings
- an example picker with both a baseline scene and a custom vignette post-processing example
- one example module per file under
demo/examples, so new demos can be added without growingdemo/main.ts
Structure
src/materials: WGSL material and uniform packingsrc/renderer: WebGPU renderer core, extraction, pass execution, and shader header generationsrc/rendergraph: frame graph primitivessrc/compute: reusable compute pipeline wrapperdemo/examples: one file per selectable example, plus shared demo scene helpersdemo/ui: small reusable demo UI helpers like the profiling HUDdemo: Vite demo app shell using the library exports directly
Next steps
Natural follow-ups from here:
- add a three bridge layer explicitly named and documented as the migration boundary
- compact visible draws into fewer GPU-driven submission streams and reduce CPU draw iteration further
- add cascaded directional shadows, light arrays, and shadow atlases
- add material templates and pipeline caches for larger shader families
- separate extraction, render graph nodes, and resource registries into dedicated modules
