# Rendering runtime

An Environment is an engine-neutral browser document, not a synonym for R3F,
Three.js, WebGPU, or even 3D. It may compose DOM, Canvas 2D, WebGL, WebGPU,
WASM, or any browser-capable engine such as Three/R3F, Babylon, PlayCanvas,
Phaser, Pixi, Unity Web, or Godot Web. Those choices stay behind the same
Environment host, lifecycle, identity, and security contracts.

The official Al-Amr R3F profile (`al-amr.r3f-webgpu@1`), recorded in
[ADR-0048](../decisions/ADR-0048-three-r185-webgpu-first-rendering.md), is one
optional authoring and rendering profile. It uses Three.js r185 with the
`WebGPURenderer`, created only inside `@al-amr/r3f`, and custom materials
written in TSL. The profile is WebGPU-first, never WebGPU-only: when WebGPU is
absent the same renderer runs on its real internal WebGL2 backend, and a device
is unsupported for this profile only when both backends fail.

Profile membership is local tooling metadata. A workspace package is a member
when its own package name is `@al-amr/r3f` or `@react-three/fiber`, or when it
declares either marker in any dependency section. The Registry and public
Environment schema do not infer an engine from that choice and expose no
`engine`, `renderer`, or `threeVersion` manifest field.

The conformance boundary follows the same split. Generic
`runEnvironmentConformance` validates the engine-neutral Environment contract.
An Environment that binds this profile additionally uses
`runR3fEnvironmentConformance`, which composes the generic checks with the R3F
gate-order and no-canvas-before-gate properties. Choosing this profile adds
those authoring checks; it does not change what an Environment is.

The PAP 0.1 bounded proof applies the generic suite to a packed DOM-only
Environment and then opens the identical artifact digest through the built
Desktop product-runtime host and web adapter. A system-Chrome execution can
prove the browser lifecycle seam when Electron WebContents is unavailable, but
the receipt must name that limitation and cannot stand in for Electron-specific
or R3F profile conformance.

The version cohort is pinned exactly — `three`/`@types/three` `0.185.1`,
`@react-three/fiber` `9.6.1`, `@react-three/drei` `10.7.7`, and
`react`/`react-dom` `19.2.7` — while public packages declare bounded peer
ranges. Every profile-member lockfile importer must resolve the exact profile
Three.js version. `quality/rendering-profile.json` and a renderer policy
guardrail script scope the cohort, factory-only renderer rule, raw-Canvas
check, and GLSL ban to those profile members while leaving generic
Environments unscanned.

## Renderer modes

Rendering in this profile goes through one shared factory in `@al-amr/r3f`, which
lazily loads `three/webgpu` and awaits `renderer.init()` before returning a
handle. Two permanent modes exist:

- `auto` (default) prefers WebGPU and treats the renderer's internal WebGL2
  backend as the only production fallback. WebGL1 is unsupported, and the
  classic `WebGLRenderer` no longer exists in world runtime code.
- `force-webgl2` pins the WebGL2 backend (`forceWebGL: true`) for fallback,
  testing, and user recovery.

A user override resolves before every boot: the `al-amr-rendering` query
parameter (`?al-amr-rendering=force-webgl2`) wins over the persisted
`al-amr:rendering-mode` localStorage value written after a successful
forced-compatibility recovery. Anything unrecognized is ignored, so the
WebGPU-first default always stands.

## The gate and canvas contract

No profile member constructs `new WebGPURenderer()` or passes private
`<Canvas gl={...}>` options. Hub, Gallery, Conference Hall, and the CLI
starter all compose the same two components from `@al-amr/r3f`:

- `EnvironmentRenderingGate` runs a fast, non-authoritative capability probe —
  secure context, WebGL2 context creation, and a bounded WebGPU adapter
  request — before its children, and therefore before presence, Plugins, or
  scene code, ever mount. The bounded adapter request is the first WebGPU
  attempt for that boot. If it cannot produce an adapter while WebGL2 is
  available, the requested mode remains `auto` but the first Canvas attempt
  starts with `forceWebGL: true` and reports `fallbackUsed: true`. This avoids
  repeating the same request without a time bound inside Three. The real
  verdict still comes from actually initializing the selected backend.
- `EnvironmentCanvas` is the single place that creates a world renderer. It
  reports backend selection, first frame, device loss, and failure back to the
  gate through the shared rendering context.

R3F 9.6 can re-enter its asynchronous `gl` factory while an earlier
`configure()` is still awaiting the renderer and `state.gl` is empty. The
shared Canvas coalesces every call for the same attempt, canvas element, and
renderer configuration into one Promise and one renderer generation. R3F
adoption through `onCreated` is the commit point for backend/renderer-ready
callbacks. Cleanup is keyed to the attempt generation, so a delayed passive
cleanup from an old Canvas cannot abort or dispose its replacement. When the
configuration really changes, the replacement waits for the superseded
initialization task to settle and dispose before it starts; device-loss and
first-frame callbacks are fenced to the exact renderer generation.

Initialization gets exactly one bounded retry: when the bounded WebGPU probe
produces an adapter, a later real failure in `auto` mode disposes the attempt
and retries once on a fresh canvas with `forceWebGL: true`. When the probe
cannot produce an adapter, that boot begins directly on the WebGL2 attempt
instead. When both backends fail, the gate renders the unsupported page. A
lost GPU device follows a bounded recovery sequence — one retry in the current
mode, then, in `auto`, one forced-WebGL2 attempt — before the device is declared
`device_lost_unrecovered`. Every attempt is cancellable:
superseded or unmounted attempts receive an abort signal checked around the
lazy module load and initialization, and each renderer is disposed exactly
once.

The Canvas owns an outer layout surface and resolves its logical size and DPR
before `WebGPURenderer.init()`, when Three allocates the default depth
attachment. R3F does not apply its measured Canvas size until its async `gl`
factory resolves, so initializing against the inner canvas's HTML default
would mix a stale depth texture with the first real color attachment. The
surface is measured again after initialization; if the physical dimensions
changed during startup, that renderer generation is disposed and recreated in
a bounded stabilization loop. Each Canvas attempt passes one real, memoized
perspective camera to every re-entrant R3F configure continuation; passing
camera options would let a stale async continuation replace the sized camera
with a new zero-aspect camera. A recovery attempt receives a fresh camera
generation. R3F adoption and the first post-render frame revalidate the
final backing size, camera aspect, and finite projection matrix, requiring a
subsequent corrected frame before readiness. The same post-render probe remains
active for the renderer generation and reconciles later size, backing-store,
DPR, or camera-projection drift within a bounded correction budget. A
stationary measurement that cannot converge is abandoned instead of rewriting
the backing store and blanking every frame; a new measurement gets a fresh
budget. The shared canvas pins R3F's frameloop to `"always"` and intentionally
exposes no `frameloop` prop: on-demand work calls `invalidate()` or `advance()`
from inside the canvas tree, where the store is live rather than an async
configuration snapshot.

Above that pin sits the sanctioned way to reduce render cost for worlds that
stay open all day: opt-in idle render cadence. An Environment creates an
`IdleCadenceController` outside the canvas and passes it through the `cadence`
prop; after the settled first frame plus a grace period, the canvas takes over
render submission and skips most ticks while the visitor is idle, waking
instantly on input, camera motion, surface changes, domain `wake()` calls, or
a held continuous lease. The loop itself keeps ticking — cadence gates the
render submission, never the frameloop, so the historical decision to abandon
demand-driven rendering still stands and is not contradicted by cadence. A
`RenderPipeline` owner on a cadence canvas registers through
`useCadenceRenderDelegate()` instead of a positive-priority `useFrame`, and
the quality-tier and GPU-timing probes measure only rendered, full-cadence
frames so idle spacing cannot distort tier decisions. Without the prop,
nothing mounts and submission behavior is unchanged.

Generated Three resources created with React memoization use
`useDisposableRenderResource`. It reference-counts shared resources and
defers final disposal across React StrictMode's development effect probe.
Effect-local resources may retain effect-local cleanup. Directly disposing a
memoized geometry or material from a normal effect cleanup is forbidden: the
StrictMode probe can reuse that JavaScript object after its WebGPU buffer has
already been destroyed.

Readiness is honest. An Environment reports ready only after the renderer is
adopted and the first frame completes in R3F's post-render phase, after
`gl.render()` rather than from a pre-render `useFrame` subscriber. The boot
overlay never covers the unsupported page. The Hub joins that validated canvas
frame with the current Active Zone's post-render signal before it reports
environment boot ready. Renderer retry or remount never destroys network
sessions: Multiplayer, SDK, presence, and session state live outside the Canvas
boundary and survive a renderer remount. Account authorization belongs to the
desktop Shell, outside the Environment view. A bundle never handles an OAuth
callback or tokens. When rendering turns out unsupported, the gate unmounts
Environment-owned providers while the host-controlled Shell session remains
outside that lifecycle.

## Support and the unsupported page

Support for this profile is capability-based, never a user-agent allowlist. A
device is supported when production runs in a secure context, the
`WebGPURenderer` actually initializes on WebGPU or WebGL2, and the first frame
renders without validation errors. Slowness is never unsupported; slow devices
drop to a lower quality tier instead. Other engines own their own support and
fallback policy.

When no real backend can start, the profile gate renders a DOM-only, RTL-aware
unsupported page with Persian and English guidance, retry and
forced-compatibility actions, and a copyable reason code. No Canvas, Plugin,
or presence mounts on that page. The stable machine-readable reason codes,
defined in `packages/r3f/src/rendering-types.ts`, are:

| Code                                | Meaning                                                                     |
| ----------------------------------- | --------------------------------------------------------------------------- |
| `insecure_context`                  | The page is not running in a secure (HTTPS) context.                        |
| `webgpu_init_failed`                | WebGPU initialization failed on this device.                                |
| `webgl2_unavailable`                | No WebGL2 context could be created.                                         |
| `webgl2_init_failed`                | The forced WebGL2 compatibility attempt also failed.                        |
| `hardware_acceleration_unavailable` | Neither backend is available; hardware acceleration is disabled or missing. |
| `device_lost_unrecovered`           | The GPU device was lost and bounded recovery did not succeed.               |
| `renderer_initialization_failed`    | The automatic and forced attempts both failed to initialize the renderer.   |
| `renderer_unknown`                  | The failure could not be classified.                                        |

The user-facing support policy, tested browser matrix, and recovery guidance
live in [Rendering support](/docs/product/rendering-support).

## Quality tiers

Backend (`webgpu` | `webgl2`), quality tier (`compatible` | `standard` |
`enhanced`), and feature availability are independent axes. Tier selection is
driven by sustained frame time with a short warm-up and hysteresis — never by
GPU model or VRAM, which are not hard gates. After warm-up the quality
controller evaluates the **trimmed mean** frame time of each window —
the window sorted, the fastest and slowest 5% discarded, the rest averaged —
and downgrades `standard` to `compatible` only after consecutive breaching
windows. The statistic is deliberately not a percentile: a vsync-locked
display reports only whole refresh intervals, so on 60 Hz every sample is
~16.7 ms or ~33.3 ms and the frame rate lives in the ratio between them,
which a percentile cannot read and an average can. Any bar configured here
must also stay clear of the 16.67 ms vsync floor, or it is unreachable rather
than strict.

A demoted device is promoted back to `standard` after a sustained run of
comfortable windows. Because promoting turns on the very load that defines the
heavier tier, three mechanisms keep that from oscillating: a cooldown after
every automatic change, a doubling backoff on any promotion that fails to
hold, and a `notifySceneChange` seam an Environment calls on a Zone swap so a
scene's build cost — shader compiles, texture uploads, terrain generation —
is never measured as its running cost.

An Environment that offers a quality setting routes it through
`setQualityPreference`, not `setQuality`: a concrete tier pins the controller
and parks both automatic lanes, and `"auto"` returns their authority. Writing
a tier while the lanes stay live is a fight rather than a preference — the
lane demotes, the Environment's settings sync writes the tier back, and the
world flips every cooldown.

The forced-compatibility recovery path starts directly in `compatible`. The
`enhanced` tier is plumbing
only: it is never auto-selected and is not advertised as production support
until its WebGPU-native features exist. A downgrade never changes the canvas
DPR mid-session: the backing store is resolved once from the initial tier when
the renderer session starts, so the drawing buffer is never reallocated (and
briefly blanked) by a tier change. Lower tiers reduce cost through scene
complexity — tessellation, instancing, shadows, and post-processing — not
through resolution.

## Diagnostics and telemetry

`readRendererStats` and `detectRendererBackend` provide backend-neutral
diagnostics over both backends (draw calls, triangles, geometry and texture
counts, and the active backend), and the Hub and Conference Hall end-to-end
bridges report the active backend through the same vocabulary. WebGPU's
animation loop resets per-frame counters before R3F calls `useFrame`;
diagnostics therefore sample through `useAfterRenderFrame`, after
`gl.render()`. Reading `info.render.calls` as a per-frame value is also
forbidden because it is cumulative on WebGPU; `readRendererStats` selects
`drawCalls`. Telemetry events are privacy-safe and limited to a closed
allowlist: event type
(`backend-selected`, `first-frame`, `init-failed`, `unsupported`,
`device-lost`, `recovered`), backend, fallback usage, quality tier,
init-duration bucket, reason code, and recovery outcome. GPU vendor and model,
unmasked renderer strings, VRAM, raw adapter information, raw driver errors,
and shader sources are never collected or displayed; the unsupported page
shows only sanitized reason codes.

## Aggregate GPU timing

Environments that need renderer GPU durations opt in with
`gpuTiming: "aggregate"` on `EnvironmentCanvas`, which the shared factory maps
to the renderer's timestamp tracking. The default is `off`, in which case no
timestamp query is ever allocated. After
initialization the backend itself disables tracking when the device lacks the
timestamp capability, so opting in on an unsupported device is safe and
simply yields no values.

Samples resolve without ever blocking the render loop: every 45 rendered
frames (about 750 ms at 60 fps)
the canvas runtime fires one asynchronous timestamp resolution for the render
and compute queues and delivers the result whenever the readback lands. Each
resolved sample reports whole-renderer durations —
`{ sampledFrameRenderMs, sampledFrameComputeMs, framesSinceLastSample }`
— the GPU milliseconds of a single frame, the last frame of the batch three
resolved, passed through unscaled. `framesSinceLastSample` is sampling
density and never a denominator; dividing the durations by it under-reports
GPU time by the whole cadence. These are renderer-wide timings, never
Plugin- or Avatar-scoped timings. On the internal WebGL2 backend values flow
when its timer extension exists; `EXT_disjoint_timer_query` accepts only one
ACTIVE query at a time, so nested render contexts are not expected to receive
independent queries there — a limitation we have reasoned about but not
measured. A queue that recorded no work (compute
on a render-only scene) reports zero; a resolution that fails mid-recovery
drops its sample instead of reporting fabricated numbers.

Consumers read the latest sample through the `onGpuTimingWindow` canvas
callback or the `useRendererGpuTiming()` hook, which returns `null` while the
option is off, the capability is absent, or no window has resolved yet; the
gate also resets the value on device loss, unsupported, and retry so stale
durations never survive a renderer generation. The resolver is disposed with
its renderer generation, leaving no pending readback after unmount or device
loss.

GPU timing is a local diagnostic, not telemetry: values are millisecond
durations only, they never leave the process unless the host Environment
forwards them, and they contain no GPU vendor, model, VRAM, or adapter
information. Because nothing is emitted as a telemetry event, the
`telemetryAllowlist` in `quality/rendering-profile.json` deliberately stays
unchanged.

## TSL-first materials

`WebGPURenderer` does not execute `ShaderMaterial`, `RawShaderMaterial`,
`onBeforeCompile` patches, or the legacy `EffectComposer` — including on its
internal WebGL2 backend. New custom materials are therefore written only with
TSL/NodeMaterial (`three/tsl`). The original production materials — the Hub
skyway particles, the Hub luminous gate veil, and the gradient sky — were
converted with visual parity in formulas, colors, blending, transparency,
`depthWrite`, and `depthTest`. A static policy check rejects new GLSL entry
points in profile-member world runtime code.
WebGPU-native redesign — compute shaders, storage buffers, GPU culling,
indirect draw, TSL post-processing — is deliberately deferred to a later
phase.

## Binary glTF assets

First-party worlds are procedural and the repository ships no binary 3D asset.
That stays a choice, not a limitation: for an Iran-hosted product it avoids a
CDN dependency, a transcoder download from a host that may be unreachable, and
a cold-cache asset stall. Third-party Environments and Plugins need a loading
path all the same, so [ADR-0079](../decisions/ADR-0079-shared-self-hosted-gltf-asset-loading.md)
places the Draco decoder (`draco_decoder.wasm`, `draco_wasm_wrapper.js`) and
the Basis Universal transcoder (`basis_transcoder.wasm`,
`basis_transcoder.js`) directly in `@al-amr/r3f/dist/decoders/` as static
assets. A sync script (`@al-amr/r3f/scripts/sync-decoders.mjs`) is wired into
`predev` and `prebuild` across every world package, copying these static bytes
into the consuming app's `public/decoders/` tree so they are served directly by
Vite at runtime. Third parties never fetch decoders from external CDNs, and
all four first-party worlds maintain zero network dependencies during asset
loading. `GLTFLoader`, `KTX2Loader`, `DRACOLoader` and `MeshoptDecoder` load through a
dynamic import on the first load only, exactly as the renderer factory loads
`three/webgpu`, so none of them reach the initial JavaScript graph. Every load
is bounded in time (20 s by default) and honours an `AbortSignal`, because
`WorkerPool` in the Three addon tree registers no error listener and holds no
rejection: a transcoder worker that dies leaves its promise unsettled forever.
Asset loading may fail; it may never suspend a boot.

A loader belongs to one renderer. `detectSupport` is valid only against an
initialized renderer, WebGPU reports its formats through `hasFeature`, and the
two backends transcode to different GPU formats — so the renderer object's own
identity is the cache generation. It changes on exactly the events that
invalidate a decoded texture, including the ones no gate counter tracks: the
bounded forced-WebGL2 retry, the surface stabilization loop, and device-loss
recovery. When it changes, the loader and its worker pool are disposed, that
renderer's cache entries are released, and the loader is rebuilt. Packs are
reference-counted through `useDisposableRenderResource`, so two Zones sharing a
URL download and transcode it once and the resources are freed when the last
consumer unmounts.

A failed load is an application error. `AssetLoadError` carries a stable
`reason` — `network`, `decode`, `timeout`, `aborted`, `renderer_unavailable` —
and reaches `RenderingErrorBoundary`; a model that did not arrive is never an
unsupported device, so the unsupported reason codes above are unchanged and the
unsupported page is never shown for it. Error text passes through the same
sanitizer as renderer errors and carries no internal path or device detail.

`scripts/check-performance-budget.mjs` measures a per-Environment 3D asset
lane beside the JavaScript budget: total glTF bytes, total texture bytes, and a
rejection of any texture in a 3D asset bundle that is neither KTX2 nor WebP.
The vendored decoders are outside both lanes — they are platform bytes, never
in the initial graph.

## Exceptions and exclusions

- The boot loader is a boot surface, not a world renderer, and it asks the
  graphics stack for nothing at all. It was markup, CSS and one inline script
  written into every Environment's document by `alAmrEnvironmentHandshake()`,
  so it painted in the document's first frame rather than after a bundle had
  arrived. ADR-0094 deleted it: the desktop client is the only host and it draws
  boot itself, in a view above the world's document. The property is unchanged —
  what paints first still asks the graphics stack for nothing.

  It replaced a WebGPU diorama that rendered in a Worker on an OffscreenCanvas
  and could only release its device once its choreography had played through —
  two devices alive for ~5.5 s on every warm reload, each stalling the other on
  exactly the hardware least able to afford either. The renderer exception
  ADR-0048 recorded for that engine is withdrawn with it: there is now exactly
  one graphics device on the page for the whole of a boot, and
  `docs/design/boot-experience.md` (R9) holds it there.

- WebGPU-native features and any Registry-level engine compatibility contract
  remain deferred, exactly as recorded in ADR-0048.
