Rendering overview
Shade is meep's renderer - GPU-driven, visibility-buffer deferred and WebGPU only - and this page maps what that means for game code, how the ECS binds to it, and what a frame contains.
meep renders through Shade, its own renderer. There is no WebGL path and no second backend. Shade is GPU-driven and visibility-buffer deferred, which changes what a game’s code is responsible for: you describe content, and the renderer decides what is drawn, in what order, and whether it is culled. This page is the map of that; the linked pages cover each piece in depth.
What Shade is
A GPU-driven, visibility-buffer deferred renderer on WebGPU. Geometry is clustered into meshlets ahead of time and lives GPU-resident in one large buffer. Culling runs on the GPU. The rasterizer writes a visibility buffer and a G-buffer rather than shading as it draws. Lights are binned into froxels and applied deferred. The result is temporally resolved (TAA, or the bundled NSS neural upscaler), tonemapped and presented.
Three consequences shape everything else:
- A mesh is a small GPU-resident record, not a draw call you own. You add a
Meshto aScene, or aShadedGeometrycomponent to an entity, and that is the whole of your involvement in submission. There is no render layer to assign, no visibility list to maintain, no CPU frustum cull to configure, and no draw order to influence. - Preparing geometry is a precompute.
meshlet_geometry_build_from_geometryruns once per shape, when content is created or loaded - never per frame. Content whose vertices are rewritten every frame (trails, paths, debug lines, CPU particles) goes down a separate dynamic-mesh path instead. - The frame is a closed sequence with declared injection points. You do not insert a pass wherever you like; you register a
RenderExtensionagainst one of sixFramePhases. See Render extensions.
WebGPU, and the device floor
Shade requires a WebGPU device that meets a fixed floor. There is no feature detection past it, no fallback, and no branch that produces a lesser frame - hardware below the floor is told clearly that it cannot run this.
| Requirement | Value |
|---|---|
| Adapter feature | indirect-first-instance - indirect draw is used throughout |
| Adapter feature | float32-blendable - order-independent transparency |
| Adapter limit | maxStorageBuffersPerShaderStage >= 10, checked against the adapter before the device is requested |
| Device limit | maxColorAttachmentBytesPerSample >= 32 - the G-buffer is wide |
| Taken when offered | timestamp-query, subgroups, texture-formats-tier1 - never required |
The adapter is requested with powerPreference: "high-performance". A fallback (software) adapter logs a warning and proceeds.
In practice there is one more requirement the floor cannot express: the engine’s shaders use the WGSL immediate_address_space extension, which shipped in Chrome 149/150. An older Chromium acquires a device that passes every check above and then fails to dispatch shaders.
How startup fails
Renderer.initialize() rejects, and not everything it rejects with is a ShadeDeviceFailure:
| Situation | Thrown |
|---|---|
navigator.gpu is absent - WebGPU disabled or unsupported | a plain Error |
canvas.getContext("webgpu") returned null | a plain Error |
| No adapter, or an adapter below the floor | ShadeDeviceFailure |
adapter.requestDevice() rejected | whatever the browser rejected with - the call is not wrapped |
ShadeDeviceFailure (src/shade/device/ShadeDeviceFailure.js, named export) extends Error. Its message is written to be shown to a person rather than logged; detail holds the specific missing feature or limit for the log; reason is a ShadeDeviceFailureReason:
| Reason | Meaning |
|---|---|
WebGPUUnavailable | no navigator.gpu at all |
AdapterUnavailable | WebGPU is present but would not hand over an adapter |
BelowFloor | an adapter that misses a required feature or limit; detail names it |
DeviceRequestFailed | the adapter was acceptable but the device request failed |
DeviceLost | a working device went away while running |
Two of those five reasons are enum members nothing constructs in 3.21.0. The no-WebGPU check is the first line of initialize() and predates the failure type, so it throws the plain Error above rather than a WebGPUUnavailable failure; and requestDevice is called without a try, so a refused device surfaces the browser’s own error rather than DeviceRequestFailed. Handle the plain-Error case too - a catch that only tests isShadeDeviceFailure misses the most common failure of all, an old browser.
Through the engine, this arrives as a signal. GraphicsEngine.start() raises on.contextFailed with the failure and then rethrows, so a caller that wants to handle it can and one that does not still fails loudly:
import { ShadeDeviceFailureReason } from "@woosh/meep-engine/src/shade/device/ShadeDeviceFailureReason.js";
engine.graphics.on.contextFailed.add(failure => {
if (failure.isShadeDeviceFailure !== true) {
// the no-WebGPU and no-canvas-context paths, which carry no `reason`
suggestBrowserUpgrade(failure.message);
return;
}
// failure.message is written for a player; failure.detail is for the log
showBlockingMessage(failure.message);
if (failure.reason === ShadeDeviceFailureReason.BelowFloor) {
reportUnsupportedHardware(failure.detail);
}
});
Engine subscribes to contextFailed itself: it pauses the simulation and adds GraphicsContextFailureView (src/view/graphics/GraphicsContextFailureView.js, named export) to the view stack, so an unhandled failure still puts the message on screen instead of burning cycles invisibly. Application state is left intact, which keeps a save-on-failure handler viable.
A lost device is not recovered
A device lost while running arrives on engine.graphics.on.contextLost, carrying a ShadeDeviceFailure with reason DeviceLost. Nothing renders after this. Every buffer, texture and pipeline lived on that device and went with it; the honest response is to say so and reload. on.contextRestored exists because the contract has it, but it never fires - a subscriber there is waiting for something that will not happen.
Engine tolerates the outage for two seconds and then pauses the simulation, on the assumption that a player who cannot see anything should not have the world run on without them. It does not install a failure view on this path, so telling the player is yours to do.
How the ECS binds to Shade
engine.graphics is a GraphicsEngine (src/engine/graphics3/GraphicsEngine.js, named export). Engine constructs it; you never do. It is deliberately a narrow facade rather than a renderer handle: the seam Engine needs in order to boot, plus a short list of things let through by name.
| Member | What it is |
|---|---|
set_scene(scene) | Draw this Shade Scene from now on. If the scene has no environment, make_default_environment() is installed, because Shade lights with indirect by default and a scene with no environment renders unlit. |
set_environment_map(texture) | Replace the scene’s image-based-lighting source. The texture must be octahedral; there is no prefilter step, and the assignment is safe before startup as well as after. It writes into whichever scene the facade currently holds, so call it after set_scene. There is deliberately no set_environment_texture - Shade has no skybox of its own. See Sky & environment. |
add_extension(ext) / remove_extension(ext) / extension_count(phase) | Register work into the frame. Extensions are held on the facade and re-applied after a restart, so registering does not require a device. |
scene_context(scene) | Shade’s per-scene GPU state and the managers hanging off it (the animation manager above all). null while there is no device; a restart builds a new context, so ids issued by the old one are stale. |
renderer | The escape hatch. Hands out the Renderer itself, null before start() and after stop(). Everything the members above are shaped to avoid is reachable through it - frame features, post-process settings, render_to_target. |
normalizeViewportPoint(input, result) | Viewport pixels (origin top-left) to clip space, -1..1, +Y up. |
viewportProjectionRay(x, y, source, direction) | A world-space ray from the eye through a clip-space point. x/y are clip space, which is what normalizeViewportPoint produces. This is the renderer’s whole contribution to gameplay picking. |
render_to_sampler(scene, camera, w, h) | Tooling-only GPU readback for thumbnails. Resizes the renderer for the call and puts it back. |
dynamic_resolution | The DynamicResolutionScaling instance, already wired to renderer.internal_resolution_scale. It targets 30 fps, so on hardware that clears the budget it never engages. |
Five signals sit on engine.graphics.on:
| Signal | Fires |
|---|---|
preRender | camera matrices are final, the frame has not been submitted |
postRender | the frame has been submitted - submitted, not finished; the GPU is still working |
contextLost | the device was lost, carrying a ShadeDeviceFailure |
contextRestored | never |
contextFailed | there will be no rendering, carrying a ShadeDeviceFailure |
The lifecycle is symmetric. GraphicsEngine starts from Engine.start() and stops from Engine.stop(), so start -> stop -> start returns a working renderer.
The scene, and the black-frame trap
There is one Shade Scene per engine and EngineHarness.shadeScene(engine) hands it out - built on first ask and cached against the engine. Every system that needs a scene should ask for it here rather than constructing its own, or entities end up in scenes nobody draws.
Only three systems call set_scene themselves: LightSystem, MeshSystem and ParticipatingMediaSystem. An app that registers ShadedGeometrySystem alone and never calls set_scene gets a black frame - the renderer is running, the meshes exist, and nothing has told the facade what to draw. There is no warning; the picture is simply empty.
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { LightSystem } from "@woosh/meep-engine/src/engine/graphics3/LightSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
const scene = EngineHarness.shadeScene(engine);
// LightSystem calls set_scene, so this wiring is complete
config.addSystem(new LightSystem(engine.graphics, scene));
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
// without a LightSystem / MeshSystem, say it yourself:
// engine.graphics.set_scene(scene);
}
});
The frame
Six phases, in the order they occur. Each names a moment where a specific set of frame records exists and is stable.
| Phase | Records carried | What belongs here |
|---|---|---|
FrameStart | none | per-frame GPU compute later phases depend on - a custom cull, a procedural buffer build |
AfterGBuffer | GBufferTextures, ViewTextures | surface work. The one phase where the G-buffer may be published into - terrain splatting, decals |
AfterLighting | the above, plus SceneColor | anything transparent surfaces should blend over - water |
AfterTransparency | same | the default. Drawing into the scene: particles, trails, paths, outlines, fog of war, gizmos |
BeforePresent | same | full-screen effects needing the finished image as input, still HDR, at output resolution |
Overlay | PresentTarget only | interface drawn onto the canvas in display space |
The upscale is a phase boundary and it is the one thing about the frame you cannot assume away. AfterGBuffer, AfterLighting and AfterTransparency run at the internal render resolution; BeforePresent and Overlay are on the far side of TAA/NSS at output resolution. With feature_taa_enabled === false there is no upscale at all and the two sides match. Never state a resolution as a constant - ask frame.resolution, which is derived from the live handle.
SceneColor is deliberately not carried at Overlay: the canvas is written there and a texture cannot be a render attachment and a sampled source in the same pass. Render extensions covers the mechanism - the records, ordering, and the extensions that ship.
The rendering systems
Rendering systems live in src/engine/graphics3/, are named exports, and take explicit collaborators rather than the engine. Most register a render extension at startup and remove it at shutdown. Several have deprecated <Name>3 aliases (MeshSystem3, LightSystem3, …); use the unsuffixed name.
Import as @woosh/meep-engine/src/engine/graphics3/<Module>.
| System | Module | Constructor | Draws |
|---|---|---|---|
MeshSystem | MeshSystem.js | (graphics, scene, load) | model instances for SGMesh entities, loaded through load(url). Calls set_scene |
ShadedGeometrySystem | ShadedGeometrySystem.js | (graphics, scene) | one Mesh node per ShadedGeometry entity |
LightSystem | LightSystem.js | (graphics, scene) | the scene’s lights and shadow fitting. Calls set_scene |
CameraSystem | CameraSystem.js | (graphics) | nothing - drives the render camera from Camera + Transform64 |
TerrainSystem | TerrainSystem.js | (graphics, scene, assetManager) | terrain tiles, plus a splat pass at AfterGBuffer |
WaterSystem | WaterSystem.js | (graphics) | the water surface, at AfterLighting |
DecalSystem | DecalSystem.js | (graphics, assets) | projected decals into the G-buffer, at AfterGBuffer, after terrain |
HighlightOutlineSystem | HighlightOutlineSystem.js | (graphics) | draws the outlines, at AfterTransparency; fed by SGMeshHighlightSystem (Highlight + SGMesh) and ShadedGeometryHighlightSystem (Highlight + ShadedGeometry) |
ParticleEmitterSystem | ParticleEmitterSystem.js | (graphics, assets) | particle billboards, at AfterTransparency |
GPUParticleEmitterSystem | GPUParticleEmitterSystem.js | (graphics, scene, assets) | loads and publishes the sprite atlas at FrameStart for Shade’s GPU particle nodes |
Trail3DSystem | Trail3DSystem.js | (graphics) | trail tubes, at AfterTransparency |
PathDisplaySystem | PathDisplaySystem.js | (graphics) | tubes along PathDisplay + Path entities, at AfterTransparency |
FogOfWarSystem | FogOfWarSystem.js | (graphics) | the fog-of-war overlay, at AfterTransparency, after the systems above |
DebugDrawSystem | DebugDrawSystem.js | (graphics) | whatever Gizmo recorded this frame, at AfterTransparency, last |
ParticipatingMediaSystem | ParticipatingMediaSystem.js | (graphics, scene) | volumetric media. Calls set_scene |
VolumetricLightMapSystem | VolumetricLightMapSystem.js | (graphics, scene) | nothing - uploads a baked Brick4 lightmap into the scene’s context |
AnimationSystem | AnimationSystem.js | (graphics, meshes) | nothing - plays clips on Animation + SGMesh entities |
AnimationGraphSystem | AnimationGraphSystem.js | (graphics, meshes) | nothing - evaluates AnimationGraphController |
PickingSystem | PickingSystem.js | (meshes) | nothing - async, batched ray queries against Pickable entities’ bounds |
TooltipComponentSystem | TooltipComponentSystem.js | ({ graphics, tooltips, pointer, localization, picking }) | nothing - DOM tooltips over picked entities |
Engine registers none of these; it only constructs GraphicsEngine. EngineHarness.buildBasics registers CameraSystem and LightSystem, plus TerrainSystem and WaterSystem when terrain is enabled - but never a mesh system, which is why a harness app that wants to draw models registers MeshSystem or ShadedGeometrySystem itself.
What is deliberately absent
No getRenderer() | The facade’s renderer getter is an escape hatch, not a pattern. |
No pixelRatio | Render scale is the renderer’s internal_resolution_scale: the frame is rendered smaller and upscaled by TAA, not stretched by the browser from a smaller canvas. |
| No layers | CPU visibility died with GPU culling. There is nothing to assign a mesh to and nothing to toggle per camera. |
No graphics.scene | Entities reach the scene through the mesh system, not through the facade. |
| No material manager | There is exactly one shipping PBR material class, StandardShadeMaterial. Nothing registers, compiles or caches materials on your behalf. Streaming its textures is opt-in per material - see Virtual texturing. |
| No WebGL, no second backend | One target, WebGPU. There is no seam where another would go. |
| No degradation tiers | One pipeline. Individual features switch off (feature_shadows_enabled, feature_taa_enabled, the resolution scale) but there is no low-quality build of the frame. |
| No Draco | KHR_texture_basisu (KTX2, ETC1S or UASTC) is read and transcoded and meshopt-compressed bufferViews are decoded; KHR_draco_mesh_compression is not - see Meshes & materials. |
| No LOD and no impostors | Meshlet culling is what stands in their place. |
Where to go next
The rest of this section, in reading order:
- Meshes & materials -
ShadedGeometry, geometry vs meshlet geometry,StandardShadeMaterial, textures. - Lights & shadows - the
Lightcomponent, photometric units, froxel binning, cascades. - Sky & environment - the one octahedral texture that is both the IBL source and the background, and volumetric fog.
- Global illumination -
indirect_lighting_mode, the Brick4 bake, and theVolumetricLightMapcomponent. - Effects - decals, outlines, camera shake, and the dynamic-mesh path.
- Particles & VFX - Particular’s CPU simulation and the one Shade billboard pass.
- Trails -
Trail3D, and whyTrail2Drenders nothing. - Virtual texturing -
material.vt_stack, streamed pages, and the residency loop. - Picking -
PickingSystem, the async batched ray query. - Render extensions - the frame records, phase ordering, and putting your own pass in the frame.
- Frame settings & post-processing - the
renderer.feature_*switches, upscaling, and the post-process sub-objects.
Terrain and water are rendering systems too, but they are documented with the rest of the world content: Terrain and Water & overlays.