npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@forgeax/engine-render

v0.1.36

Published

Canonical ECS render schemas and renderer boundary.

Readme

@forgeax/engine-render

[!NOTE] This package-owner README contains physical @forgeax/engine-render imports in internal ownership examples. Game-facing code should use the public facade @forgeax/engine/render; the Bloom consumer route is documented in hello-bloom.

[!IMPORTANT] The Standard pipeline keeps dark-gradient color in rgba16float until one outputTransform reaches the surface.storage.raw endpoint; inspect() and observe() expose stable graph/backend identity facts, while pixel metrics belong exclusively to the Engine-owned hello-fxaa fixture report.

Public frame contract

Every accepted FrameReceipt with a submitted camera context carries an immutable barrelDistortion mapping, including the exact identity mapping when the camera has no active distortion. The optional property is a transport and pre-first-frame shape: undefined means that no accepted submitted display context exists. It is fail-closed, never an identity guess; display consumers must wait for a new submitted frame. The mapping is tied to the submitted output extent, camera matrices, device generation, graph generation, and frame identity. Consumers must keep those facts together; a mapping from a newer World edit cannot be applied to an older picture. The renderer publishes the mapping only after queue submission, and a candidate that fails validation keeps the previous accepted picture and mapping. The public projection is deeply frozen and contains no device, graph, texture, or mutable resource handle.

renderer.inspect().barrelDistortion projects the same accepted context as effectiveMapping, extent, frameId, deviceGeneration, graphGeneration, and lastKnownGood. Before the first accepted frame, after surface retirement, or during device loss, effectiveMapping is unavailable; display consumers must wait for a new submitted frame. An invalid authoring value returns barrel-distortion-invalid-parameter with its field and bound. Unsupported float attachment capability is reported only for an active plan; an empty or zero-strength plan stays zero-work. A pending or failed disable keeps the old nonzero mapping with its picture, while a successful disable publishes identity and retires the old feature resources through the normal in-flight fence.

Fog provides distance fog with exponential height density through one finite-depth fullscreen pass before temporal resolve, Bloom and tone mapping. density, heightFalloff, linear color and maxOpacity are selected from the frame's resource owner. Disabled or zero-density fog allocates no fog target; there is no 3D texture, froxel grid or temporal history. The authored sky remains unchanged. FXAA, TAA and no-AA use single-sample depth; MSAA is rejected until a matching depth resolve is available. Translucent surfaces that do not write depth use the opaque background depth. Use VolumetricFog for spatial density and lighting/scattering effects; the meadow-style distance haze needs only Fog.

Local volumetric fog

Spawn one VolumetricFog per local medium in the rendered environment World. Each owner selects its own light, 3D density texture, world-space bounds, and optical coefficients. Up to MAX_VOLUMETRIC_FOG_OWNERS (8) owners share the renderer-owned inject, integrate, temporal, and composite passes. Overlapping media add coefficients before integration; removing one owner keeps the others. Removing the last owner retires the volume graph resources.

| Author field | Contract | |:--|:--| | density | Shared linear TextureAsset, viewDimension: '3d' | | sampling | ECS enum: VolumetricFogSamplingValue.noise (default animated atmospheric grain), or .density (local normalized bounds sample) | | boundsMin, boundsMax | Finite world coordinates, strictly increasing on each axis; not transformed by Transform | | extinction, albedo, emission | Nonnegative RGB; albedo additionally at most 1 | | anisotropy, maxDistance | Finite, respectively (-1, 1) and positive world units | | light, spotLight | Same-World selected light and optional Point/Spot pair, as in the existing single-volume contract |

Pure VolumetricFogAuthoring validation uses the string sampling labels, not the ECS numeric values. extractVolumetricFog() returns an available fogs collection and validates every member atomically. Overflow returns volume-owner-conflict with detail.ownerCount; invalid author parameters retain the existing structured error contract.

Read renderer.inspect().volumetricFog.ownerCount after a completed frame for the accepted collection, alongside status, pass count, memory, and generation. It is not an echo of a rejected candidate. Light projectors retain the shared renderer-selected projector contract; only its selected light samples the map.

[!IMPORTANT] This is local-medium integration against scene depth, not a claim of depth-aware fogging of every transparent or VFX feature. Those consumers require their own rendered integration evidence. Software GPU readback is correctness evidence, not hardware performance evidence.

灯光最短入口

三条最短入口:

  1. RectAreaLight:在现有 Transform 上声明单面矩形发光体,尺寸由 widthheight 持有。
  2. SpotLight:用 iesProfilecookie 绑定现有资产,用 rollDeg 共享方位角;缺席 handle 是乘法单位元。
  3. LightProbe:声明 27 个 irradiance 值和唯一的 radius,位置与朝向仍来自 Transform

这些是 authored/render 输入,不是 GPU slot、LTC、probe asset 或第二个 Renderer API。尺寸、半径和 rollDeg 在场景序列化中保留;资产 GUID 通过 Pack/Catalog 的 refs 往返,不能从 URL 或数组位置推断身份。

Extended-lighting consumer route

The three smallest public examples are RectAreaLight(width, height), SpotLight(iesProfile, cookie, rollDeg), and LightProbe(irradiance, radius). Probe diffuse uses A={admitted && d<r}, c=1-(d/r)^2, scaled finite qhat, alpha=q/Q, C=1-prod(1-c), and S=1-C; Sky is only S*E_sky(N). It never enters probe admission, Q, alpha, specular, or DirectLightSlot. recordByteLength=160 is the per-object ABI receipt.

Recovery is inspected as data: read code, expected, hint, and typed detail; keep the current LKG until the replacement generation is accepted. GPU, Browser, and Dawn evidence that cannot execute is not-run or unavailable, never a verified structural substitute.

The public frame vocabulary is intentionally small. Camera is the authoring owner for tone, exposure, color grading, antialiasing, bloom, and historyVersion; Atmosphere and Fog are independent ECS components. The renderer extracts these facts into an immutable FramePlan, records one frame, and returns a FrameReceipt. FrameReceipt is the only successful synchronous proof that the host submit reached the queue. Use Renderer.inspect() for detached lifecycle and capability facts, then observe(receipt, request) for receipt-bound evidence.

AI cold-start route

An AI consumer can start with the public sequence state -> inspect -> recover -> FrameReceipt. Renderer.state() is the closed lifecycle union: only alive admits a frame; device-lost permits one explicit recovery flight; recovering shares that flight; faulted requires a new Renderer; and disposed is terminal. inspect() is detached POD evidence for the current state, recovery attempt, and named output contract. After recover() succeeds, submit the current draw input and use the new FrameReceipt as the proof for that generation. An active device loss includes native destroyed notifications; only retired or unpublished candidate generations are isolated from active health. Pending dynamic geometry must be prepared again for the replacement generation before admission. Explicit preparation between recovery and draw may upload its new payload; the first-draw guard still rejects lazy cold uploads or pipeline builds inside rendering. Preparation does not make an old-generation receipt current. Recovery prepares the last successfully submitted workset; the next draw still consumes current World edits and admits new resources through normal residency. Fullscreen recovery warms only active declared post-process identities; graph-local resource aliases are resolved by the prepared feature and are not separate pipelines. Ordinary graph replacement retains the previous graph until submission settles, using the same retirement boundary for resize and post-process changes.

inspect().recovery is always present. Its phase is null outside an active attempt and otherwise follows quiesce, acquire-adapter, acquire-device, rehydrate, compile-graph, publish, and cleanup. The projection also reports the fromGeneration/candidateGeneration fence, monotonic attempt, lastOutcome, committed rehydratedRoots, and bounded staleLossEvents. These are detached facts; they never expose devices, graphs, textures, or mutable resource collections.

During device-lost or recovering, the App keeps its host heartbeat but does not advance the World or submit a frame. A failed Result is repaired from error.code, expected, hint, and typed detail: wait for the shared recovery flight, explicitly retry, repair the named owner, or create a new Renderer according to the closed error. Do not parse message. Target and history tokens retain logical identity, but their new-generation contents are uninitialized until a successful receipt; a neutral target or last-known-good fallback is not proof of real recovery. Public consumers never receive graph nodes, devices, history textures, or prepared handles.

The current-source manifest and schema identify source, build, backend, runner, and frameIdentity. Structural graph receipts, Browser/Dawn readback or PNG evidence, and historical oracle data are separate evidence classes. An unavailable backend is reported as unavailable.

Renderer-wide graph allocation inspection

renderer.inspect().renderGraphGenerationAllocation is the renderer-owned logical allocation receipt across active, volumetric candidate, retiring, and detached recovery graph generations:

const allocation = renderer.inspect().renderGraphGenerationAllocation;
if (allocation?.availability === 'complete') {
  console.log(allocation.liveBytes, allocation.pendingRetirementBytes, allocation.peakBytes);
  for (const entry of allocation.entries) {
    console.log(entry.generation, entry.roles, entry.retirement, entry.allocation);
  }
}

roles identifies active, candidate, and retiring ownership; retirement is active, pending, or failed. liveBytes and pendingRetirementBytes are current logical bytes, while peakBytes is the simultaneous logical high-water mark captured at compile, replacement, and retirement events. failedRetirementCount and failedRetirementBytes keep a destroy refusal, throw, or fence failure visible until its owner is repaired. availability is complete, partial, or unavailable, with unavailableGenerationCount explaining omitted graph facts. unit is engine-allocation-bytes and physicalResidency is always unknown: imported resources remain with their importing owner and logical byte size is not a VRAM measurement. After a device-loss recovery, inspect the replacement generation and retry the frame; a retained failed or pending row is recovery evidence.

RenderFeature: the producer seam (first-read index)

Material contract projection

Render consumes only the Pack-owned material publication projection. Its inputs are runtime bool/value data, composed module slots, and the closed compiler context; render never authors, cooks, writes DDC, or selects a fallback material. The projection preserves layoutIdentity, programIdentity, cookIdentity, and materialPublicationIdentity so a stale draw can be traced to the first producer divergence.

Single-layer medium Surface

MaterialAsset.surface selects the Engine template and one imported Surface implementation. A Standard Surface imports forgeax_material::surface_v1::{SurfaceInput, SurfaceData} and exports evaluate_surface(SurfaceInput) -> SurfaceData. A model: 'single-layer-medium' Surface instead imports forgeax_material::single_layer_medium_surface_v1::{SingleLayerMediumSurfaceInput, SingleLayerMediumSurfaceData} and exports that separate input/data ABI. The Surface returns normal, roughness, coverage, foam, absorption, scattering, IOR, and phase facts; Render performs the finite Beer-Lambert/single-scatter integral, one Fresnel allocation, and consumes renderer-owned paired color/depth frame facts. maxDistanceMeters is a finite authored upper bound in metres for the sky-miss case. A valid value is carried through the Surface ABI and Cook output, while invalid or absent input uses the conservative Engine bound; it never turns an unavailable depth producer into a synthetic sample. The Surface does not declare a stage, binding, attachment, BRDF, or vertex displacement.

import { definePack, definePackageId } from '@forgeax/engine/pack/source';
import { ok, type MaterialAsset } from '@forgeax/engine/types';

const packageId = definePackageId('01900000-0000-7000-8000-000000000001');

const material = {
  kind: 'material',
  surface: {
    model: 'single-layer-medium',
    module: 'game::water_surface_a',
    dynamicInput: {
      name: 'waterEvents',
      fields: [
        { name: 'position', type: 'vec3<f32>' },
        { name: 'time', type: 'f32' },
        { name: 'eventId', type: 'u32' },
      ],
      maxRecords: 64,
      maxDomains: 8,
      maxPageBytes: 2048,
      maxBindings: 1,
      maxEventsPerSample: 8,
    },
  },
  passes: [{ name: 'color', program: { module: 'forgeax::single-layer-medium' } }],
  parameters: [
    { name: 'coverage', type: 'f32' },
    { name: 'absorption', type: 'vec3' },
  ],
  values: { coverage: 0.85, absorption: [0.22, 0.07, 0.025] },
} satisfies MaterialAsset;

export default definePack({
  schemaVersion: '2.0.0',
  packageId,
  name: 'Water',
  build: () => ok({ 'material/water': material }),
});

The authored module reads that schema through the compiler-generated accessor; parameter names are fields on material, not free uniforms or string lookups:

#define_import_path game::water_surface_a
#import forgeax_material::parameters::{material}
#import forgeax_material::single_layer_medium_surface_v1::{SingleLayerMediumSurfaceInput, SingleLayerMediumSurfaceData}

fn evaluate_surface(input: SingleLayerMediumSurfaceInput) -> SingleLayerMediumSurfaceData {
  return SingleLayerMediumSurfaceData(
    normalize(input.geometricNormalWS),
    0.16,
    material.coverage,
    0.0,
    material.absorption,
    vec3<f32>(0.018, 0.04, 0.085),
    1.333,
    0.24,
    900.0,
  );
}

Cook validates this accessor against parameters, the Pack transport writes the cooked material through JSON while preserving its GUID, and runtime loads that GUID before allocating the MaterialAsset handle used by MeshRenderer. The executable LightProbe to ProbeBlend to Surface example shows the loadByGuid<MaterialAsset> → World → MeshRendererdraw half of the same route.

The generated dynamic page is a bounded, read-only storage record. Create one ReadonlyDynamicInputPage, write only changed records, then reserve an explicit { domain, recordStart, recordCount, instanceIndex, member } range for each draw. member is the stable public address { worldIdentity: world.identity, entityKey, drawItemIndex, instanceOrdinal }. Its contentRevision, bufferGeneration, and deviceGeneration are separate facts; stale ranges and consumption before an upload return a structured error. The page uses the existing instance bind group's free binding and never uses skinning's customDataStart. Publish the page to the existing Renderer owner before drawing: renderer.setSurfaceDynamicInput({ page, ranges, projectionRevision, frameTime }). Advance projectionRevision only when range membership or addresses change; record values and World time keep the current revision so stable frames reuse the retained validation and consumption projection. Range order is irrelevant: Render joins each range to the admitted candidate by member and rejects missing, duplicate, or non-admitted identities.

Linear HDR optical oracle

The physical oracle observes the real Pack → GUID → World → medium nearest/color path before exposure, tone mapping, and anti-aliasing. Arm the next successful submit with renderer.requestObservation(['linear-hdr']), keep its FrameReceipt, then call renderer.observe(receipt, { include: ['linear-hdr'] }). The returned bytes are padded-row rgba16float; frameId, device and graph generations, texture identity, and readback identity bind them to that exact completed frame. When no observation is requested, the renderer does not copy, map, or synchronously read this attachment.

[!IMPORTANT] Compare optical math in this linear attachment. A canvas screenshot is an exposed, tone-mapped, anti-aliased presentation artifact and cannot prove the Beer-Lambert, scatter, or Fresnel contract.

The permanent fixture loads authored water through the Preview Pack, freezes the camera, medium parameters, paired background, light, event time, and fixed ROI, then compares the observed half-floats with an independent double-precision screen-space reference. Its evidence also carries the actual nearest/color program and resource generations plus the completed submission receipt.

Author 4x coverage through the Camera component. ECS fields retain their numeric schema representation; ANTIALIAS_MSAA extracts to the public closed antialias: 'msaa' render fact:

import { ANTIALIAS_MSAA, Camera, orthographic } from '@forgeax/engine/render';

world.spawn({
  component: Camera,
  data: {
    ...orthographic({ left: -3, right: 3, bottom: -2, top: 2 }),
    antialias: ANTIALIAS_MSAA,
  },
});

The edge oracle independently projects the authored rod, wall, shoreline, and rough-water rectangles onto the canonical four sample positions. Fully covered control regions calibrate each authored linear-HDR endpoint. For opaque edges, the nearest depth sample selects its matching color before the full-coverage water pass. For partial water coverage, the final hardware resolve averages the independently predicted covered samples. The oracle rejects the opposite policy, checks a small moving-camera interval, repeats after an 800-by-450 backing resize, and keeps the physical maximum per-channel error at 0.05. Its pure four-sample counterexample separately proves why averaging color and then choosing the nearest depth loses the color/depth pair.

FORGEAX_SURFACE_LANE_PARITY=1 pnpm exec vitest run --project=dawn --retry=0 \
  --maxWorkers=1 --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts

FORGEAX_SURFACE_LANE_PARITY=1 pnpm exec vitest run \
  --config vitest.browser.config.ts --project=browser --retry=0 \
  --maxWorkers=1 --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts

FORGEAX_SURFACE_MSAA4X=1 FORGEAX_SURFACE_LANE_PARITY=1 \
  pnpm exec vitest run --project=dawn --retry=0 --maxWorkers=1 \
  --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts

VITE_FORGEAX_SURFACE_MSAA4X=1 FORGEAX_SURFACE_LANE_PARITY=1 \
  pnpm exec vitest run --project=browser --retry=0 --maxWorkers=1 \
  --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts

# Public App lifecycle: World-time water events plus a cooked GPU splash.
FORGEAX_SURFACE_APP_LIFECYCLE_ONLY=1 pnpm exec vitest run --project=dawn \
  --retry=0 --maxWorkers=1 --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts

VITE_FORGEAX_SURFACE_APP_LIFECYCLE_ONLY=1 pnpm exec vitest run --project=browser \
  --retry=0 --maxWorkers=1 --no-file-parallelism \
  packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts

The lifecycle gate enqueues two deduplicated impacts into one attached World. Its Update system takes one Time.elapsed snapshot, stamps both Surface records, and starts the matching ParticleEffectPlayer edges. The splash is the authored surface-water-splash.pack.json asset: the Pack transport resolves its GUID, loadVfxGpuEffect loads its cooked program, and createVfxRuntimeHost contributes the production renderer feature. The gate rejects an effect without a renderer; an intent-only program is not visible evidence.

Before each target submit, the gate arms the existing linear-hdr observation. It reads pre-frozen 16-by-16 water-A, water-B, and above-water splash ROIs from that completed receipt, and compares every pixel. The mask never depends on brightness, medians, or observed error. Evidence carries the frame, device, graph, texture, and readback identities. The active splash ROI must change and return to its baseline after the effect expires; the VFX host must also retire both players. rgba16float samples remain unclamped linear values, including values above 1, and use the same maximum per-channel error of 0.05 at every lifecycle stage. The oracle does not round them through an 8-bit domain.

A real wait while paused preserves World time, committed VFX state, receipt count, and the exact active completed image: paused reuses the active receipt and does not submit a zero-delta frame. resume() continues from retained World time without adding the paused host interval or replaying either splash. The later stepFrame(0.05) calls are explicit deterministic simulation steps used to reach camera coverage and expiry; they advance World time and therefore are not evidence that a paused App remains frozen. In Dawn only, a timer supplies the missing host scheduling primitive; the same App remains the sole owner of World update and Renderer draw.

LightProbe to ProbeBlend to Surface example

The following scene uses one authored water material for two entities. Each entity selects a different local LightProbe domain, while both consume the same bounded dynamic-input page. The renderer derives ProbeBlendRecord, slot, generation, and the direct or scene-index consumer lane from the attached World; application code does not build a second probe table.

drawProbeLitWater receives an already-constructed Runtime Renderer. Construct it at the Runtime boundary with await createRenderer(canvas), check the returned Result (if (!result.ok) throw result.error), and pass result.value to this helper. Runtime completes renderer initialization before returning that value, so the render helper does not own a second initialization promise.

import { Time, World } from '@forgeax/engine-ecs';
import { createPlaneGeometry } from '@forgeax/engine-geometry';
import {
  Camera,
  DirectionalLight,
  LightProbe,
  MeshFilter,
  MeshRenderer,
  ReadonlyDynamicInputPage,
  type Renderer,
} from '@forgeax/engine-render';
import { Transform } from '@forgeax/engine-scene';
import { type AssetRegistry } from '@forgeax/engine-assets-runtime';
import type { AssetGuid, MaterialAsset } from '@forgeax/engine-types';

export async function drawProbeLitWater(
  renderer: Renderer,
  assets: AssetRegistry,
  waterGuid: AssetGuid,
) {
  // The Pack producer cooks the authored MaterialAsset and its Surface module;
  // runtime consumers load only the GUID-addressed publication.
  const loaded = await assets.loadByGuid<MaterialAsset>(waterGuid);
  if (!loaded.ok) throw loaded.error;
  const water = loaded.value;
  const dynamicInput = water.surface?.dynamicInput;
  if (water.surface?.model !== 'single-layer-medium' || dynamicInput === undefined) {
    throw new Error('expected an authored single-layer-medium Surface');
  }

  const world = new World();
  const plane = createPlaneGeometry(1.8, 1.8).unwrap();
  const mesh = world.allocSharedRef('MeshAsset', plane);
  const material = world.allocSharedRef('MaterialAsset', water);
  const waterEntities = [-1.2, 1.2].map((x) =>
    world
      .spawn(
        { component: Transform, data: { pos: [x, 0, 0] } },
        { component: MeshFilter, data: { assetHandle: mesh } },
        { component: MeshRenderer, data: { materials: [material] } },
      )
      .unwrap(),
  );

  const red = new Float32Array(27);
  red[0] = 1.8;
  const blue = new Float32Array(27);
  blue[2] = 1.8;
  world.spawn(
    { component: Transform, data: { pos: [-1.2, 0, 0] } },
    { component: LightProbe, data: { irradiance: red, radius: 1 } },
  );
  world.spawn(
    { component: Transform, data: { pos: [1.2, 0, 0] } },
    { component: LightProbe, data: { irradiance: blue, radius: 1 } },
  );
  world.spawn(
    { component: Transform, data: { pos: [0, 0, 6] } },
    {
      component: Camera,
      data: { fov: Math.PI / 4, aspect: 16 / 9, near: 0.1, far: 20 },
    },
  );
  world.spawn({
    component: DirectionalLight,
    data: { direction: [0, 0, -1], color: [1, 1, 1], intensity: 1 },
  });

  const page = ReadonlyDynamicInputPage.create({
    sourceId: 'water-events',
    pageId: 1,
    schema: dynamicInput,
  }).unwrap();
  const eventLifetimeSeconds = 0.45;
  const events = [
    { position: [-1.2, 0, 0] as const, time: 0, eventId: 1 },
    { position: [1.2, 0, 0] as const, time: 0, eventId: 2 },
  ] as const;
  let publishedEventCount = 0;
  let projectionRevision = 1;
  let publishedDeviceGeneration = renderer.inspect().frame.deviceGeneration;
  page.reconfigureDevice(publishedDeviceGeneration).unwrap();

  const reserveFreshRanges = () =>
    waterEntities.map((entityKey, index) =>
      page
        .reserveRange({
          domain: index === 0 ? 'water-left' : 'water-right',
          recordStart: index,
          recordCount: 1,
          instanceIndex: index,
          member: {
            worldIdentity: world.identity,
            entityKey,
            drawItemIndex: 0,
            instanceOrdinal: 0,
          },
        })
        .unwrap(),
    );
  let ranges = reserveFreshRanges();
  let deviceLost = renderer.state() === 'device-lost';

  const publishDynamicInput = (now: number): void => {
    const activeEvents = events.filter(
      (event) => now >= event.time && now - event.time < eventLifetimeSeconds,
    );
    const recordsToRewrite = Math.max(publishedEventCount, activeEvents.length);
    for (let index = 0; index < recordsToRewrite; index += 1) {
      page
        .writeRecord(
          index,
          activeEvents[index] ?? { position: [0, 0, 0], time: -1_000, eventId: 0 },
        )
        .unwrap();
    }
    publishedEventCount = activeEvents.length;
    renderer.setSurfaceDynamicInput({ page, ranges, projectionRevision, frameTime: now });
  };

  const recoverAndRepublish = async (): Promise<number> => {
    if (renderer.state() !== 'device-lost') {
      throw new Error('water recovery requires the Renderer device-lost state');
    }
    const recovered = await renderer.recover();
    if (!recovered.ok) throw recovered.error;
    const nextDeviceGeneration = renderer.inspect().frame.deviceGeneration;
    if (nextDeviceGeneration <= publishedDeviceGeneration) {
      throw new Error('water recovery did not publish a new device generation');
    }
    const previousRanges = ranges;
    page.reconfigureDevice(nextDeviceGeneration).unwrap();
    ranges = reserveFreshRanges();
    if (
      ranges.some(
        (range, index) =>
          range === previousRanges[index] ||
          range.deviceGeneration !== nextDeviceGeneration ||
          range.bufferGeneration === previousRanges[index]?.bufferGeneration,
      )
    ) {
      throw new Error('water recovery reused a pre-recovery dynamic range');
    }
    publishedDeviceGeneration = nextDeviceGeneration;
    projectionRevision += 1;
    publishDynamicInput(world.getResource(Time).elapsed);
    deviceLost = false;
    return nextDeviceGeneration;
  };

  const unsubscribe = renderer.subscribe((event) => {
    if (event.kind === 'state-changed' && event.current === 'device-lost') {
      deviceLost = true;
    }
  });
  try {
    const attachment = renderer.attach(world);
    if (!attachment.ok) throw attachment.error;
    const attached = attachment.value;
    publishDynamicInput(world.getResource(Time).elapsed);
    world.update(1 / 60).unwrap();
    const submitAndObserve = async () => {
      // The Renderer keeps this logical World lease valid across recovery;
      // retry the same lease after the page and ranges are revalidated.
      const submitted = renderer.draw({
        leases: [attached],
        camera: { lease: attached },
        environment: { lease: attached },
      });
      if (!submitted.ok) throw submitted.error;
      const completed = await submitted.value.completed;
      if (!completed.ok) throw completed.error;
      const observed = await renderer.observe(submitted.value, { include: ['draws'] });
      if (!observed.ok) throw observed.error;
      return submitted.value;
    };

    let submitted: Awaited<ReturnType<typeof submitAndObserve>>;
    try {
      submitted = await submitAndObserve();
    } catch (cause) {
      // A first frame can fail before it yields a receipt. Recover only when
      // the public lifecycle reports the actual device-lost state.
      if (renderer.state() !== 'device-lost') throw cause;
      await recoverAndRepublish();
      submitted = await submitAndObserve();
    }
    if (deviceLost || renderer.state() === 'device-lost') {
      await recoverAndRepublish();
      submitted = await submitAndObserve();
    }
    const inspection = renderer.inspect();
    if (submitted.deviceGeneration !== publishedDeviceGeneration) {
      throw new Error('water draw receipt does not match the published device generation');
    }
    return {
      world,
      attached,
      page,
      receipt: submitted,
      submission: inspection.renderScene.submission,
      deviceGeneration: inspection.frame.deviceGeneration,
    };
  } finally {
    unsubscribe();
  }
}

The repository exercises this route through real Pack/GUID publication and a physical texture or compositor readback:

pnpm exec vitest run --project=dawn \
  packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts --retry=0
pnpm exec vitest run --config vitest.browser.config.ts --project=browser \
  packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts --retry=0

# Same material, scene, time, events, ProbeBlend domains, exposure, and ROI;
# cold direct -> GPU -> direct. The original 0.05 RGB oracle remains active.
FORGEAX_SURFACE_LANE_PARITY=1 FORGEAX_DAWN_LIGHTWEIGHT=1 \
  pnpm exec vitest run --project=dawn \
  packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts --retry=0

Direct medium draws use a dedicated group(3): ordinary instance transforms, the retained ProbeBlend record, dynamic page, per-member frame rows, shared frame time, and a producer-owned per-draw frame-base uniform. The vertex shader addresses frameBase + instance_index; neither GUID order nor a scene-index visible slot is used to infer that address. Missing member identity, page, generation, or frame-base publication fails the draw through the structured Render error path.

After a successful submission, renderer.inspect().renderScene.gpuDriven.surfaceArtifact exposes the selected direct/scene-index entries and artifact/resource generation. renderer.inspect().renderScene.submission separates requestedLane from the command-derived actualLane and publishes only nearest/color passes that encoded a real draw* or draw*Indirect command. When the current Standard frame carries the reflection fallback MRT, the same submission projection reports actualLane: 'direct' with actualLaneReason: 'reflection-fallback-mrt'. This is a pass-ownership constraint, not a device-capability result; the reason is absent again when a later frame resumes the ordinary GPU-driven lane. Direct rows contain the actual command range (firstInstance remains 0), the producer's separate surfaceFrameBase, and the member identities bound by that command. Every command reports the exact selected artifact's receiptIdentity/receiptGeneration; programEvidence: 'missing' means the recorder could not prove that producer identity. Indirect rows contain the actual indirect buffer identity and byte offset. status: 'completed' is published only after the existing queue completion fence resolves for the same device generation.

GPU member identity is opt-in because it requires readback. Call await renderer.observe(frameReceipt, { include: ['draws'] }) before reading the inspection row. Until that call, an indirect pass reports memberEvidence: 'indirect-readback-required' and omits members. The observe path reuses the selector telemetry copy, reads its actual visible rows, and then reports indirect-visible-readback. The readback carries its recording sequence, frame/device/resource/view identity, and per-pass indirect ranges; an older or reordered shared-buffer result is rejected even within one device generation. Nearest and color share the same visible member set because both consume the same submitted selector/indirect projection; each pass still has its own encoded command receipt. CPU candidate lists are never substituted for this GPU readback. The lane-parity gates pair these command facts with completed fixed-ROI pixels at the unchanged 0.05 RGB epsilon.

The public consumer sequence is draw -> observe -> inspect:

import type { SurfaceGpuIndirectParameters } from '@forgeax/engine/render';

const receipt = renderer.draw(frameInput);
if (!receipt.ok) throw receipt.error;
const observed = await renderer.observe(receipt.value, { include: ['draws'] });
if (!observed.ok) throw observed.error;
const inspection = renderer.inspect();
const completePasses = (inspection.renderScene.submission?.passes ?? []).filter(
  (pass) => pass.memberEvidence === 'indirect-visible-readback',
);
const indirectParameters: readonly SurfaceGpuIndirectParameters[] = completePasses.flatMap(
  (pass) => {
    if (pass.indirectParameters === undefined) throw new Error('complete GPU readback is missing');
    return pass.indirectParameters;
  },
);
void indirectParameters;

pass.indirectParameters appears only after the requested observation resolves and the renderer accepts a complete matching GPU readback. A pass before readback, with no complete identity match, reports memberEvidence: 'indirect-readback-required' and has no parameters. A valid readback whose bounded command sample is truncated reports memberEvidence: 'indirect-visible-readback-truncated' and also has no parameters; its saved and dropped counts remain diagnostic facts. GPU-byte truncation, a stale/reordered copy, or an identity mismatch is rejected and leaves the pass in indirect-readback-required. Only a complete valid copy reports memberEvidence: 'indirect-visible-readback' with parameters. These states are diagnostic facts and never proof when the required evidence is absent. Each record is bound to the same sequence, frameId, deviceGeneration, resourceGeneration, viewIdentity, and Surface pass as the published submission, plus the exact indirect indirectBufferIdentity and indirectOffset. For indexed commands, count, first, instanceCount, signed baseVertex, and firstInstance decode the five WebGPU indirect words. For non-indexed commands, count, first, instanceCount, and firstInstance decode the four words and baseVertex is zero by definition.

Each pass exposes totalCommandCount, bounded savedCommandCount, droppedCommandCount, and truncated. A truncated indirect row is diagnostic evidence, never a complete member set; a valid readback labels it indirect-visible-readback-truncated and omits indirectParameters. A direct row uses direct-command-members-truncated for the same bounded-sample limit.

The Render owner validates the cooked layout, source/page identity, generation, range bounds, and device storage limit. It begins a detached upload and commits the page revision only after every queue write succeeds; graph submission then consumes the validated ranges. The same input is carried through GPU preparation and the recovery candidate. In the scene-index lane, the renderer maps each GPU-written visible member's stable candidate row to a frame row and keeps each producer instanceIndex in the row; the atomic compact draw slot is never used as a dynamic-input address.

On a device with compute, storage-buffer, and indirect-drawing capabilities, GPU-driven preparation records the published Surface submission and binds the Surface page and frame records through the existing GPU-driven instance owner. The Standard Forward and Deferred graph owners allocate the two nearest-layer/color targets and share the transmission backdrop producer. The backdrop is the paired opaque color input. At 1x, both Standard paths copy the completed depth into an r32float sampled target. At 4x MSAA, Render does not use the hardware-averaged color resolve as the medium backdrop. A typed fullscreen producer examines all four depth samples, selects the nearest sample, and publishes both that depth and the color at the same sample index. The nearest-water pass uses 4x color and depth attachments and the identical paired rule before the color pass consumes it. Both resolved depth facts are r32float with an unfilterable-float/non-filtering binding, so the color pass never samples a depth attachment while writing scene depth. Inputs other than the admitted 1x or paired 4x forms fail with resource-descriptor-invalid; addSingleLayerMediumPasses never creates a synthetic depth value. This policy is a nearest-sample coverage resolve, not an average of already-resolved color. The color pass binds the copied depth, the preceding nearest-layer color/depth pair, and the shared backdrop through the existing material bind-group owner. GPU-driven recording reuses the existing Standard raster dispatch with a Surface-family filter; devices without the required capability use the existing CPU record lane and its structured admission reason. These tests prove the owner boundary and fail-closed behavior; Browser, Dawn, and physical GPU frame evidence remain separate gates. A missing Surface ABI, pass, resource, or prepared generation is a producer/preparation error. The public paired math and two example Surface modules live in src/examples/single-layer-medium-surface.ts.

The public route is one RenderFeature<FrameData> through the Standard Pipeline and the active RenderGraph pass. In the examples below, type FrameData is the producer-owned extracted value. A feature extracts one frame value, then its mandatory plan(data, context) declares named resources and passes; the host derives graph access, preparation, recording, and recovery from that plan. Register it at construction with createRenderer(canvas, { features: [feature] }). A feature never receives a device, queue, encoder, staging builder, or submit callback.

After a frame reaches queue submission, the optional onFrameSubmitted callback receives a bounded RenderFeatureSubmission projection. Its passes contain only work admitted after prepared-resource resolution, so producer diagnostics can distinguish submitted compute dispatches and graphics draws from a plan that was still pending. This is a receipt of admitted commands, not a GPU readback or proof that an indirect draw produced non-zero instances; producers that need those facts must use their existing readback or tape evidence.

BarrelDistortion is supplied by the ordinary ordered RenderFeature host. A missing or zero-strength component produces no allocation, upload, or pass; the feature still declares its shader for production validation. A positive component adds one fullscreen pass after linear-LDR LUT output and before FXAA and final encoding. There is no separate built-in registration path, so feature ordering and retirement use the same graph and in-flight lifetime rules as other post-process features.

RenderFeature: the producer seam (first-read index)

The public route is one RenderFeature<FrameData> through the active RenderGraph and Standard Pipeline. Here type FrameData is the producer-owned extracted value; plan(data, context) declares named resources and passes. Register it with createRenderer(canvas, { features: [feature] }). The renderer derives graph access, preparation, recording, and recovery; the feature never receives a device, queue, encoder, or submit callback. Each declaration becomes a RenderGraph pass inside the active frame submit boundary.

Material contract projection

Render consumes only the Pack-owned material publication projection. Its inputs are runtime bool/value data, composed module slots, and the closed compiler context; render never authors, cooks, writes DDC, or selects a fallback material. The projection preserves layoutIdentity, programIdentity, cookIdentity, and materialPublicationIdentity so a stale draw can be traced to the first producer divergence.

Directional shadow quality: author → inspect → recover

DirectionalLight is the only authoring entry for directional shadow quality. Set shadowFilter with the numeric constant for one of the five closed labels below; it is an ECS enum field, not a label string. Do not invent a numeric label or a compatibility alias. The default is pcf3. The two PCSS fields are read only when shadowFilter is pcssMedium or pcssHigh:

import { DirectionalLight, DirectionalShadowFilterValue } from '@forgeax/engine-render';

world.spawn({ component: DirectionalLight, data: {
  direction: [0.2, -0.98, 0],
  shadowFilter: DirectionalShadowFilterValue.pcssHigh,
  shadowAngularRadius: 0.00465,
  maxPenumbraTexels: 32,
} });

| Author field | Valid values / units | Default | Effective meaning | |:--|:--|:--|:--| | shadowFilter | pcf1, pcf3, pcf5, pcssMedium, pcssHigh | pcf3 | Requested directional filter profile | | shadowAngularRadius | Radians, finite range [0.0001, 0.05] | 0.00465 | PCSS light angular radius | | maxPenumbraTexels | Texels, finite integer range [1, 64] | 32 | PCSS penumbra ceiling |

The remaining CSM fields keep these public units and defaults: cascadeCount is an integer in [1, 4] (default 4), splitLambda is [0, 1] (default 0.75), cascadeBlend is [0, 0.5] (default 0.2), mapSize is a positive map resolution (default 2048), depthBias is a depth value (default 0.00001, subtracted from normalized receiver depth), normalBias offsets the surface receiver position along its world-space normal before projection (default 0.05), and shadowDistance is a positive world-unit distance in meters (default 200).

Normal offset and depth bias are independent: increasing the CSM depth span must not magnify the world-space normal offset. Both PCF and PCSS project the offset receiver; volumetric samples have no surface normal and use only depth bias. Surface receivers also get a per-cascade depth correction derived from the world size of a shadow texel, the filter footprint, and the receiver's depth slope along the light's two image axes. The existing normal offset counts toward this coverage; only the missing amount is added to depthBias, after conversion through that cascade's light-space depth span. PCSS derives its blocker-search and comparison coverage separately from their actual radii. This does not change either author field's units or add shadow texture taps. Cascade projections include their incoming blend band, and PCSS depth spans match the actual projection, including toward-light caster reach. The normal position offset follows the approach in Three.js shadowmap_vertex. The real-render regression is shadow-contact.browser.test.ts.

After draw, read the single renderer.inspect().directionalShadow projection. It is JSON-safe and bounded: requested is author intent, effective is the admitted profile, status is accepted | fallback | rejected, and fallbackReason explains webgl2-unsupported, rhi-null-structural, or candidate-failed. lastKnownGood identifies retained output and pixelEvidence distinguishes real pixel evidence from not-available. cascadeCount, mapSize, atlasBytes, writerPasses, blockerTaps, filterTapUpperBound, seamTapUpperBound, deviceGeneration, and graphGeneration are inspection facts, not additional author controls.

If author validation returns error.code === 'shadow-invalid-config', do not parse error.message. Read error.expected, error.hint, error.detail.field, error.detail.actual, error.detail.bound, and error.detail.reason; repair the named DirectionalLight field and retry the same request. For a rejected candidate, retain lastKnownGood from inspection, repair or rebuild the named producer, then call the existing renderer.recover() boundary and retry. WebGL2 reports its explicit PCF lane; RhiNull reports rhi-null-structural only, so neither is PCSS pixel evidence. Missing or not-run Browser/Dawn/PNG/timing evidence remains missing or not-run, never a pass. The authoritative labels and validation remain in directional-light.ts and light-helpers.ts; this section is an AI index path, not a second schema.

import {
  ANTIALIAS_TAA,
  Atmosphere,
  BLOOM_DISABLED,
  CAMERA_EXPOSURE_MODE_MANUAL,
  Camera,
  Fog,
  type FramePlan,
  type Renderer,
} from '@forgeax/engine-render';

void ANTIALIAS_TAA;
void Atmosphere;
void Camera;
void Fog;
declare const renderer: Renderer;
declare const plan: FramePlan;
void renderer.inspect();
void plan;

Environment selection is a closed none | image | atmosphere fact. Multiple environment owners or multiple fog owners return structured errors with a code-specific detail; they do not create a second registry or silently pick the first entity. Frame facts contain IDs, revisions, and POD values only, not textures, buffers, devices, or other live GPU objects.

An Atmosphere also requires exactly one DirectionalLight sun. A direct render extraction with no sun routes SunCardinalityError through the World-owned error boundary (console.error with the structured error) before extraction throws; that internal route is deliberately separate from app.onError. app.onError receives errors returned by the App frame loop and renderer event stream. Inspect error.code and error.detail.value, repair the owning World, then retry the same extraction or the next frame. For zero suns, spawn one DirectionalLight; for multiple suns, remove the extras so the Atmosphere has one owner. The renderer does not manufacture a fallback sun or retain a partially selected environment.

The Standard graph renders an Atmosphere source as a 128-by-128, six-face rgba16float sky cube and a background pass before scene geometry. The selected DirectionalLight supplies the sun direction, color, and illuminance; the sun angle is in radians, and zero radius disables the visible disc. Geometry covers the background through ordinary scene rendering, including transparent blending. The cube excludes the disc. An explicit Skylight without an equirect asset uses diffuse irradiance and roughness-prefiltered radiance from this same cube. Its color and intensity remain the lighting controls; no Skylight means no global ambient contribution. An explicit equirect keeps its image source. Use a neutral Skylight tint when comparing local captures against the sky.

The cube uses one bounded analytic Rayleigh/Mie daylight evaluator. Rayleigh and Mie controls affect their spectral scattering and extinction, while solar radiance scales linearly with the selected light. A 1.5 exponent shapes the Rayleigh color response before solar scaling; the Mie lobe remains additive. A smooth effective air-mass bound (four zenith columns) avoids a saturated neutral horizon. This is an explicit game-oriented clear-sky approximation, not the retired Perez fit or a full spherical multiple-scattering solution. The existing cache owns all work; background pixels still sample the cube, and the separate sun disc is unchanged.

Atmosphere controls and fixed-exposure comparisons

Atmosphere keeps the visible disc and the analytic sky response as separate controls. circumsolarStrength and circumsolarWidth affect only the Mie forward lobe used while producing the cached sky cube. The defaults preserve the baseline response; sunAngularRadius still controls the separate disc in the background pass and does not grow a halo.

| Field | Default | Valid range | Meaning | |:--|:--:|:--|:--| | circumsolarStrength | 1 | [0, 4] | Multiplier for the circumsolar Mie lobe | | circumsolarWidth | 1 | [0.25, 4] | Relative lobe width; larger values broaden it, smaller values narrow it | | sunAngularRadius | 0.004675 rad | [0, +∞) | Radius of the separate visible sun disc; 0 disables the disc |

For an angular or sunset sweep, hold the camera exposure in manual mode and disable Bloom so the measurement belongs to the Atmosphere response. Vary the two circumsolar fields while leaving sunAngularRadius unchanged:

world.set(atmosphere, Atmosphere, {
  circumsolarStrength: 1.5,
  circumsolarWidth: 2,
  sunAngularRadius: 0.004675,
}).unwrap();
world.set(camera, Camera, {
  exposureMode: CAMERA_EXPOSURE_MODE_MANUAL,
  exposure: 1,
  bloom: BLOOM_DISABLED,
}).unwrap();

The fixed exposure and Bloom setting are comparison setup, not additional Atmosphere state. Use the renderer's submitted frame or a real Browser/Dawn readback for visual claims; source values and a sky-cube upload do not prove a pixel response.

The cube has 786,432 texture payload bytes. Its 16-square irradiance cube and 64-square, five-mip prefilter add 274,176 bytes; parameters and vertices total 1,560 bytes, before backend allocation alignment. DeviceScope owns one cached set, imported into each graph. Resize and probe topology changes reuse it; disabling Atmosphere retains it until device-scope retirement. Device recovery allocates a fresh set. Its six faces and lighting products update together only when the selected environment signature changes, and become current only after successful frame submission. An unchanged source runs only the background pass. GPU pass observations name atmosphere-prepare, atmosphere-cube-0 through atmosphere-cube-5, and atmosphere-background, atmosphere-irradiance-* and atmosphere-prefilter-*; unavailable timestamps remain explicitly unavailable. The source lifecycle inspection and graph resource accounting describe different owners: a source revision is not a claim that its GPU cube was submitted.

The reusable Wave 1 recipe shows the public scene inputs and associated acceptance gates.

GPU pass timing: opt in, draw, observe, branch on status

When App owns the draw loop, renderer.subscribe delivers the exact receipt in each frame-submitted event. Pass that receipt to observe; do not construct one from the frame number. Start observation promptly because retention is bounded.

GPU pass timing is disabled by default. Opt in once on createRenderer, keep the returned FrameReceipt, and request facts only for that receipt. The pass duration is a bounded GPU fact; it is not frame latency.

import { createRenderer } from '@forgeax/engine-runtime';

const created = await createRenderer(canvas, { gpuPassTiming: {} });
if (!created.ok) throw created.error;
const renderer = created.value;
const attached = renderer.attach(world);
if (!attached.ok) throw attached.error;
const drawn = renderer.draw({
  leases: [attached.value],
  camera: { lease: attached.value },
  environment: { lease: attached.value },
});
if (!drawn.ok) throw drawn.error;

const observed = await renderer.observe(drawn.value, { include: ['timings'] });
if (!observed.ok) throw observed.error;
const timings = observed.value.timings;
if (timings === undefined) throw new Error('timings were not requested');
switch (timings.status) {
  case 'complete':
    console.log(timings.frame.passes);
    break;
  case 'partial':
    console.log(timings.reason.code, timings.frame.passes);
    break;
  case 'unavailable':
    console.log(timings.reason.code, timings.capability);
    break;
  case 'failed':
    console.log(timings.error.code, timings.latestKnownGood);
    break;
}

The bounded fact contract, recovery errors, and validator are the source-linked references for the four status branches and the fail-closed benchmark verdict. A timing error exposes the closed code union plus expected, hint, and detail; follow the producer-owned recovery action in the error before observing a later receipt. latestKnownGood is a separate reference and never changes the current status or completeness. Omitting timings from include returns receipt metadata without materializing timing facts.

Run the paired real-GPU benchmark with the Dawn host:

FORGEAX_GPU_PASS_TIMING_HOST_MODULE="$PWD/packages/render/bench/gpu-pass-timing/dawn-host.ts" \
  pnpm gpu-pass-timing:bench -- --output=/tmp/forgeax-gpu-pass-timing.json

The command exits 0 only for a complete accepted report and exits 2 for a blocked report. When an observation is partial, branch on timings.reason.code === 'timestamp-write-unavailable', retain the unmeasured pass and its structured detail.cause, then observe the next receipt after the producer-owned recovery action. Do not turn that pass into a zero-duration sample or accept the benchmark until all paired windows are complete.

An accepted report retains frameFacts: one complete on-path frame fact per paired group, including the frame identity, raw decimal ticks, each measured pass duration, and measuredPassNanoseconds. If a real run is blocked by partial observations, the blocked JSON keeps the observed representative facts under evidence.frameFacts so the measured values remain inspectable without weakening the verdict. If complete windows fail the paired overhead gate, evidence.windows retains the raw samples and evidence.pairedOverhead retains each group p95, group overhead, and reported median.

Measured entries expose measurementSource: raster and compute entries use pass-boundary; copy entries use copy-boundary-envelope, the interval between timing-only marker passes, and must not be read as exact copy duration.

Fog and point-shadow observations

Fog is a one-per-World environment input. Extract validates its parameters before publication; an invalid update keeps the renderer's last-known-good fog frame and exposes the structured failure through the existing inspection path. There is no app-local fog state.

VolumetricFog authoring, World time, and recovery

VolumetricFog is an authored, one-owner component backed by a linear 3D TextureAsset. The selected light must be a live same-World DirectionalLight, PointLight, or SpotLight; Point/Spot selection also requires the corresponding Transform. Validate the authoring POD before spawning the component, then let the existing RenderSystem extract it. The World Time resource is the only simulation clock: world.update(delta) advances it, and the volume parameter block receives that elapsed value.

import { Time, World } from '@forgeax/engine-ecs';
import {
  Atmosphere,
  BLOOM_DISABLED,
  CAMERA_EXPOSURE_MODE_MANUAL,
  Camera,
  DirectionalLight,
  VolumetricFog,
  extractVolumetricFog,
  perspective,
  resolveSelectedVolumetricLight,
  validateVolumetricFog,
  type VolumetricFogAuthoring,
  type Renderer,
} from '@forgeax/engine-render';
import { Transform } from '@forgeax/engine-scene';
import type { TextureAsset } from '@forgeax/engine-types';

const world = new World();
const densityAsset: TextureAsset = {
  kind: 'texture',
  shape: { viewDimension: '3d', extent: { width: 4, height: 4, depth: 4 } },
  format: 'r8unorm',
  colorSpace: 'linear',
  mips: { kind: 'none' },
  data: new Uint8Array(4 * 4 * 4).fill(32),
};
const density = world.allocSharedRef('TextureAsset', densityAsset);
// A production project normally obtains this TextureAsset from its existing
// Pack/Catalog load path, then allocates the same shared handle in this World.
// The inline payload keeps this authoring example executable and deterministic.
const camera = world.spawn(
  { component: Transform, data: { pos: [0, 0, 3] } },
  { component: Camera, data: {
    ...perspective({ fov: Math.PI / 4, aspect: 16 / 9 }),
    exposureMode: CAMERA_EXPOSURE_MODE_MANUAL,
    exposure: 1,
    bloom: BLOOM_DISABLED,
  } },
).unwrap();
const sun = world.spawn({
  component: DirectionalLight,
  data: { direction: [-0.4, -0.8, -0.3], castShadow: true },
}).unwrap();
const atmosphere = world.spawn({
  component: Atmosphere,
  data: { circumsolarStrength: 1, circumsolarWidth: 1, sunAngularRadius: 0.004675 },
}).unwrap();

const authored: VolumetricFogAuthoring = {
  light: sun,
  density: {
    guid: 'density-demo',
    generation: 1,
    shape: { viewDimension: '3d', extent: { width: 4, height: 4, depth: 4 } },
    format: 'r8unorm',
    colorSpace: 'linear',
  },
  bounds: { min: [-1, -1, -1], max: [1, 1, 1] },
  extinction: [0.2, 0.2, 0.2],
  albedo: [0.8, 0.8, 0.8],
  emission: [0, 0, 0],
  anisotropy: 0,
  maxDistance: 50,
};
const checked = validateVolumetricFog(authored);
if (!checked.ok) throw checked.error;
const extracted = extractVolumetricFog([checked.value]);
if (!extracted.ok) throw extracted.error;
const selected = resolveSelectedVolumetricLight(world, sun);
if (selected.status === 'unresolved') {
  throw new Error(`${selected.reason}: ${selected.hint}`);
}
world.spawn({
  component: VolumetricFog,
  data: {
    light: sun,
    density,
    boundsMin: authored.bounds.min,
    boundsMax: authored.bounds.max,
    extinction: authored.extinction,
    albedo: authored.albedo,
    emission: authored.emission,
    anisotropy: authored.anisotropy,
    maxDistance: authored.maxDistance,
  },
}).unwrap();

world.update(1 / 60).unwrap();
const elapsed = world.getResource(Time).elapsed; // the projected volume time
void camera;
void atmosphere;
void elapsed;

If validation fails, read its structured code, expected, hint, and typed detail. If light resolution is unresolved, read its reason, expected, actual, and hint; repair the owning authoring field, and extract again. Do not manufacture a light or a density fallback. After a submitted frame, renderer.inspect().volumetricFog reports status, resourceStage, the selected-light facts, and candidate/LKG generations. For a renderer device loss, wait for the host recovery boundary, call await renderer.recover(), then submit the same World again and inspect the replacement generation:

import type { World } from '@forgeax/engine-ecs';
import type { Renderer } from '@forgeax/engine-render';

// After the existing createRenderer(canvas) happy path returns a Renderer,
// call `await recoverAndDraw(renderer, world)` with the World above.
async function recoverAndDraw(renderer: Renderer, world: World): Promise<void> {
  const attached = renderer.attach(world);
  if (!attached.ok) throw attached.error;
  const lease = attached.value;
  if (renderer.state() === 'device-lost') {
    const recovered = await renderer.recover();
    if (!recovered.ok) throw recovered.error;
  }
  const retry = renderer.draw({
    leases: [lease],
    camera: { lease },
    environment: { lease },
  });
  if (!retry.ok) throw retry.error;
  const completed = await retry.value.completed;
  if (!completed.ok) throw completed.error;
  const volume = renderer.inspect().volumetricFog;
  console.log(volume?.status, volume?.resourceStage, volume?.generation);
}

The recovery call rebuilds renderer-owned resources; it is not a second volume clock or registry. Pausing the simulation means withholding world.update. Equal time and normal monotonic World ticks up to 100 ms retain eligible volume history. Clock rollback, a larger jump, or changed time availability invalidates it. Camera, light, medium, size and resource changes still invalidate history. Integration and shadow visibility use stable cell centers, so a reset does not expose render-frame random noise. The ray uses 96 base intervals; segments within four interval lengths of a selected punctual source use four midpoints to resolve its inverse-square peak and spot cone. This local refinement keeps the density expression and packed shadow resolution unchanged. A real GPU punctual-scattering oracle protects spatial accuracy alongside the continuous-frame browser check; homogeneous transmittance alone cannot detect light-band artifacts. History is clipped to the current neighborhood and its weight decays with elapsed World time (at most 0.875 per frame, about 87 ms half-life at 60 Hz or slower), limiting trails from animated density. CloudLayer is the renderer-owned procedural cloud route described below; it does not change the volumetric fog component or add a second clock.

CloudLayer authoring and derived transport

CloudLayer is a single World-authored component. Its schema keeps the seed, layer bounds, noise scale, coverage, density, wind and quality as serializable source facts. renderComponentsPlugin() registers it with the normal ECS component lease. Cache bytes, light-space shadow projections, per-view history and GPU handles remain Render-derived state and can be reconstructed from the validated source key.

Coverage is monotonic: zero clears the layer and increasing it admits more of its weather-shaped body. Detail erodes this body after altitude shaping; it cannot create detached density outside the body. Formation cache version 5 uses the same integer hash and density composition as both analytic WGSL paths, with center-aligned periodic interpolation. Layer thickness controls the physical height of two vertical body cells; scale controls four horizontal cells per world-space period. Choose comparable body dimensions for cumulus rather than compressing a broad cloud into a thin layer. The broad field owns 85% of body shaping; attenuated high-frequency octaves and bounded subtractive erosion preserve connected masses instead of dense detached fragments.

Install the producer through the existing RenderFeature seam when a scene uses clouds:

import { World } from '@forgeax/engine-ecs';
import { createRenderer } from '@forgeax/engine-runtime';
import {
  Camera,
  CloudLayer,
  CloudQualityValue,
  DirectionalLight,
  createCloudLayerFeature,
  perspective,
} from '@forgeax/engine-render';
import { Transform } from '@forgeax/engine-scene';

const canvas = document.querySelector('canvas');
if (!(canvas instanceof HTMLCanvasElement)) throw new Error('canvas is required');
const world = new World();
const camera = world.spawn(
  { component: Transform, data: { pos: [0, 140, 240] } },
  { component: Camera, data: { ...perspective({ fov: Math.PI / 4, aspect: 16 / 9 }) } },
).unwrap();
const sun = world.spawn({
  component: DirectionalLight,
  data: { direction: [-0.4, -0.8, -0.3], color: [1, 0.95, 0.9], intensity: 2 },
}).unwrap();
const cloud = world.spawn({
  component: CloudLayer,
  data: {
    seed: 1337,
    baseHeight: 120,
    thickness: 80,
    scale: 0.004,
    coverage: 0.48,
    density: 1,
    wind: [8, 0, 2],
    quality: CloudQualityValue.medium,
    shadowRange: 512,
  },
}).unwrap();
const created = await createRenderer(canvas, { features: [createCloudLayerFeature()] });
if (!created.ok) throw created.error;
const renderer = created.value;
const attached = renderer.attach(world);
if (!attached.ok) throw attached.error;
world.update(1 / 60).unwrap();
const drawn = renderer.draw({
  leases: [attached.value],
  camera: { lease: attached.value },
  environment: { lease: attached.value },
});
if (!drawn.ok) throw drawn.error;
console.log(camera, sun, cloud, renderer.inspect().cloudLayer);

world.set(cloud, CloudLayer, { wind: [10, 0, 2] }).unwrap();
const changed = renderer.draw({
  leases: [attached.value],
  camera: { lease: attached.value },
  environment: { lease: attached.value },
});
if (!changed.ok) {
  console.error(renderer.state(), renderer.inspect().cloudLayer, changed.error);
  throw changed.error;
}

If the capability report does not admit the cloud lane, disable that feature at assembly time and keep the rest of the renderer running:

const created = await createRenderer(canvas, {
  features: [createCloudLayerFeature({ enabled: false })],
});

The runtime createRenderer() result is returned after the renderer's internal initialization barrier has completed, so the public Renderer can attach and draw immediately after the Result check above. The initialization Promise