Rendering

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 Mesh to a Scene, or a ShadedGeometry component 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_geometry runs 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 RenderExtension against one of six FramePhases. 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.

RequirementValue
Adapter featureindirect-first-instance - indirect draw is used throughout
Adapter featurefloat32-blendable - order-independent transparency
Adapter limitmaxStorageBuffersPerShaderStage >= 10, checked against the adapter before the device is requested
Device limitmaxColorAttachmentBytesPerSample >= 32 - the G-buffer is wide
Taken when offeredtimestamp-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:

SituationThrown
navigator.gpu is absent - WebGPU disabled or unsupporteda plain Error
canvas.getContext("webgpu") returned nulla plain Error
No adapter, or an adapter below the floorShadeDeviceFailure
adapter.requestDevice() rejectedwhatever 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:

ReasonMeaning
WebGPUUnavailableno navigator.gpu at all
AdapterUnavailableWebGPU is present but would not hand over an adapter
BelowFlooran adapter that misses a required feature or limit; detail names it
DeviceRequestFailedthe adapter was acceptable but the device request failed
DeviceLosta 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.

MemberWhat 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.
rendererThe 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_resolutionThe 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:

SignalFires
preRendercamera matrices are final, the frame has not been submitted
postRenderthe frame has been submitted - submitted, not finished; the GPU is still working
contextLostthe device was lost, carrying a ShadeDeviceFailure
contextRestorednever
contextFailedthere 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.

FrameStart - view only, no recordsrasterize visibility buffer and G-bufferAfterGBuffer - GBufferTextures, ViewTexturesGTAO, froxel lighting, deferred resolve, volumetricsAfterLighting - SceneColortransparencyAfterTransparency - whole scene, HDR, internal resolutionTAA or NSS upscale, DoF, motion blur, sharpen, bloomBeforePresent - HDR, output resolutiontonemapOverlay - PresentTarget, display space
PhaseRecords carriedWhat belongs here
FrameStartnoneper-frame GPU compute later phases depend on - a custom cull, a procedural buffer build
AfterGBufferGBufferTextures, ViewTexturessurface work. The one phase where the G-buffer may be published into - terrain splatting, decals
AfterLightingthe above, plus SceneColoranything transparent surfaces should blend over - water
AfterTransparencysamethe default. Drawing into the scene: particles, trails, paths, outlines, fog of war, gizmos
BeforePresentsamefull-screen effects needing the finished image as input, still HDR, at output resolution
OverlayPresentTarget onlyinterface 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>.

SystemModuleConstructorDraws
MeshSystemMeshSystem.js(graphics, scene, load)model instances for SGMesh entities, loaded through load(url). Calls set_scene
ShadedGeometrySystemShadedGeometrySystem.js(graphics, scene)one Mesh node per ShadedGeometry entity
LightSystemLightSystem.js(graphics, scene)the scene’s lights and shadow fitting. Calls set_scene
CameraSystemCameraSystem.js(graphics)nothing - drives the render camera from Camera + Transform64
TerrainSystemTerrainSystem.js(graphics, scene, assetManager)terrain tiles, plus a splat pass at AfterGBuffer
WaterSystemWaterSystem.js(graphics)the water surface, at AfterLighting
DecalSystemDecalSystem.js(graphics, assets)projected decals into the G-buffer, at AfterGBuffer, after terrain
HighlightOutlineSystemHighlightOutlineSystem.js(graphics)draws the outlines, at AfterTransparency; fed by SGMeshHighlightSystem (Highlight + SGMesh) and ShadedGeometryHighlightSystem (Highlight + ShadedGeometry)
ParticleEmitterSystemParticleEmitterSystem.js(graphics, assets)particle billboards, at AfterTransparency
GPUParticleEmitterSystemGPUParticleEmitterSystem.js(graphics, scene, assets)loads and publishes the sprite atlas at FrameStart for Shade’s GPU particle nodes
Trail3DSystemTrail3DSystem.js(graphics)trail tubes, at AfterTransparency
PathDisplaySystemPathDisplaySystem.js(graphics)tubes along PathDisplay + Path entities, at AfterTransparency
FogOfWarSystemFogOfWarSystem.js(graphics)the fog-of-war overlay, at AfterTransparency, after the systems above
DebugDrawSystemDebugDrawSystem.js(graphics)whatever Gizmo recorded this frame, at AfterTransparency, last
ParticipatingMediaSystemParticipatingMediaSystem.js(graphics, scene)volumetric media. Calls set_scene
VolumetricLightMapSystemVolumetricLightMapSystem.js(graphics, scene)nothing - uploads a baked Brick4 lightmap into the scene’s context
AnimationSystemAnimationSystem.js(graphics, meshes)nothing - plays clips on Animation + SGMesh entities
AnimationGraphSystemAnimationGraphSystem.js(graphics, meshes)nothing - evaluates AnimationGraphController
PickingSystemPickingSystem.js(meshes)nothing - async, batched ray queries against Pickable entities’ bounds
TooltipComponentSystemTooltipComponentSystem.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 pixelRatioRender 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 layersCPU visibility died with GPU culling. There is nothing to assign a mesh to and nothing to toggle per camera.
No graphics.sceneEntities reach the scene through the mesh system, not through the facade.
No material managerThere 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 backendOne target, WebGPU. There is no seam where another would go.
No degradation tiersOne 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 DracoKHR_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 impostorsMeshlet 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 Light component, 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 the VolumetricLightMap component.
  • 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 why Trail2D renders 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.