@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-renderimports 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
rgba16floatuntil oneoutputTransformreaches thesurface.storage.rawendpoint;inspect()andobserve()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.
灯光最短入口
三条最短入口:
RectAreaLight:在现有Transform上声明单面矩形发光体,尺寸由width与height持有。SpotLight:用iesProfile和cookie绑定现有资产,用rollDeg共享方位角;缺席 handle 是乘法单位元。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 → MeshRenderer → draw 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.tsThe 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=0Direct 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.jsonThe 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
