Module: Frame-Graph Geometry Renderer
Package paths:
packages/babylon-lite/src/frame-graph/,packages/babylon-lite/src/material/standard/standard-geometry-output-shader.ts
Purpose
The geometry renderer task renders a list of scene meshes into a bundle of multi-render-target (MRT) geometry textures — view-space normals, world-space positions, reflectivity, albedo, normalized view depth, linear velocity, and so on. These textures are consumed by downstream tasks (screen-space ambient occlusion, post-process effects, screen-space reflections, motion blur, etc.).
The shape and texture types mirror Babylon.js'
MaterialHelperGeometryRendering.GeometryTextureDescriptions. Babylon Lite
exposes a strict subset, biased toward minimal bundle size:
ALBEDO_SQRTexcluded — onlyALBEDOis exposed.VELOCITY(screen-space vec2) excluded — onlyLINEAR_VELOCITY(world-space vec3) is exposed.IRRADIANCE_LEGACYexcluded — onlyIRRADIANCEis exposed.COLORexcluded — callers wire a separate scene color RT through the task's optionaltargetTexturecolor attachment (matches BJSPREPASS_COLOR_INDEX).
A companion createCopyToTextureTask() exists so callers can blit a generated
geometry attachment into a viewport on the swapchain (or any other RT) for
inspection / impostor strips / post-process inputs.
Public API Surface (types, functions, constants — full signatures)
export const enum GeometryTextureType { IRRADIANCE = 0, WORLD_POSITION = 1, LOCAL_POSITION = 2, REFLECTIVITY = 3, VIEW_DEPTH = 4, NORMALIZED_VIEW_DEPTH = 5, SCREENSPACE_DEPTH = 6, VIEW_NORMAL = 7, WORLD_NORMAL = 8, ALBEDO = 9, LINEAR_VELOCITY = 10, MESH_BLEND_TAG = 11,}
export type GeometryClearValue = GPUColor;
export interface GeometryTextureDescription { readonly name: string; readonly defaultFormat: GPUTextureFormat; readonly clearValue: GeometryClearValue;}
export const GEOMETRY_TEXTURE_DESCRIPTIONS: readonly GeometryTextureDescription[];
export interface GeometryRendererTextureDescription { readonly type: GeometryTextureType; readonly format?: GPUTextureFormat;}
export interface GeometryRendererTaskConfig { name?: string; meshes?: readonly Mesh[]; camera?: Camera | null; size?: "canvas" | { width: number; height: number }; samples?: 1 | 4; depthTexture?: RenderTarget | null; readonly textureDescriptions: readonly GeometryRendererTextureDescription[]; reverseCulling?: boolean;}
export interface GeometryRendererTask extends Task { readonly name: string; readonly outputTarget: RenderTargetMrt; readonly geometryIrradianceTexture: RenderTarget | null; readonly geometryWorldPositionTexture: RenderTarget | null; readonly geometryLocalPositionTexture: RenderTarget | null; readonly geometryReflectivityTexture: RenderTarget | null; readonly geometryViewDepthTexture: RenderTarget | null; readonly geometryNormalizedViewDepthTexture: RenderTarget | null; readonly geometryScreenspaceDepthTexture: RenderTarget | null; readonly geometryViewNormalTexture: RenderTarget | null; readonly geometryWorldNormalTexture: RenderTarget | null; readonly geometryAlbedoTexture: RenderTarget | null; readonly geometryLinearVelocityTexture: RenderTarget | null; readonly geometryMeshBlendTagTexture: RenderTarget | null; excludeFromVelocity(mesh: Mesh): void; includeInVelocity(mesh: Mesh): void;}
export function createGeometryRendererTask(scene: SceneContext, config: GeometryRendererTaskConfig): GeometryRendererTask;
export interface CopyToTextureTaskConfig { name?: string; sourceTexture: RenderTarget; targetTexture?: RenderTarget; ownsTargetTexture?: boolean; viewport?: NormalizedViewport | null; lodLevel?: number; resolveTexture?: RenderTarget;}
export interface CopyToTextureTask extends Task { readonly name: string; sourceTexture: RenderTarget; targetTexture: RenderTarget | undefined; ownsTargetTexture: boolean; resolveTexture: RenderTarget | undefined; viewport: NormalizedViewport | null | undefined; lodLevel: number; readonly outputTexture: RenderTarget;}
export function createCopyToTextureTask(scene: SceneContext, config: CopyToTextureTaskConfig): CopyToTextureTask;Usage
const gbuffer = createGeometryRendererTask(scene, { samples: engine.msaaSamples as 1 | 4, textureDescriptions: [ { type: GeometryTextureType.NORMALIZED_VIEW_DEPTH }, { type: GeometryTextureType.VIEW_NORMAL }, { type: GeometryTextureType.WORLD_POSITION }, { type: GeometryTextureType.REFLECTIVITY }, { type: GeometryTextureType.ALBEDO }, ],});addTask(scene, gbuffer);
// Blit the depth attachment into a 256×144 strip at the top of the swapchain.addTask( scene, createCopyToTextureTask(scene, { sourceTexture: gbuffer.geometryNormalizedViewDepthTexture!, targetTexture: outputTarget, viewport: { x: 0, y: 0, width: 0.2, height: 0.2 }, }));The task accepts up to 8 attachments (the WebGPU max). Each
GeometryRendererTextureDescription.format defaults to the entry in
GEOMETRY_TEXTURE_DESCRIPTIONS[type].defaultFormat and can be overridden per
attachment.
MESH_BLEND_TAG is the exception to the general format override rule: it is
always single-sample r8uint, clears to unsigned integer zero, and rejects any
other format or nonzero clear. Standard, PBR, and Node geometry views emit a
typed u32 FragmentOutput member for its MRT slot while all other slots
remain vec4<f32>. Integer targets omit blend state. The uploaded tag is the
validated source-mesh tag, so source-backed clones/regular instances and thin
instances use their source draw's tag.
createMeshBlendingPostProcessTask installs this opt-in output support. The
geometry task may be created first, but the post-process task must exist before
the frame graph preloads its tasks.
Alpha-blended meshes participate by default, matching Babylon.js geometry
rendering. Callers that need an opaque-only pass provide a filtered meshes
array. Alpha-tested materials remain supported because their existing material
shader discards rejected fragments before any geometry output is written.
When more than two HDR (rgba16float) attachments are stacked the request can
exceed WebGPU's default maxColorAttachmentBytesPerSample cap of 32 bytes.
Callers raise that cap through EngineOptions.requiredLimits:
const engine = await createEngine(canvas, { requiredLimits: { maxColorAttachmentBytesPerSample: 64 },});Design
Two-phase task: record() plus execute()
All allocations, shader compilations, pipeline creation, bind-group creation,
draw-list construction, and viewport math live in record(). execute() only:
- updates per-frame UBO contents (scene UBO from the task's camera, per-mesh world matrix, previous world matrix for velocity, geometry params), and
- dispatches the prebuilt draw list against the prebuilt MRT render-pass descriptor.
The frame graph rebuilds and re-records when scene inputs change, so callers
never have to worry about stale caches. The same record()-vs-execute()
split is used by RenderPassTask, CopyToTextureTask, and the post-process
tasks.
Resource ownership and failed rebuilds
Every geometry binding owns a MeshRebuildResources lifetime sink. The task creates it before
calling the Standard/PBR/Node geometry rebuilder and stamps it onto the returned renderable
before binding. Builders require that owner and register releases before later fallible work.
The bound list publishes only after every rebuild and bind succeeds; a failure synchronously
releases all candidate sinks, including the failing entry, without changing the previous draw
list or any entry it carries. A sync builds views in its own copy of the task's view cache, which
replaces the cache only when the list publishes, so a failed sync also leaves the cache as the last
publish left it; the views it created are dropped with that copy (their variants' buffers went
with the released sinks).
Replaced or disposed live entries retire their detached lifetime batches behind submitted GPU work. Shared Standard/Node view resources are retained by each renderable and destroyed only when the last owner releases; an old callback cannot evict a replacement cache entry. There is no implicit view lease or scene auxiliary-disposer map.
Shader modules are not generation-owned. Standard and PBR geometry resolve them through a private
per-family memo keyed weakly by device and by the exact composed WGSL, so a re-record, a
renderable-version rebuild, a republished forward PBR context, and materials with identical
composition reuse one module per code string instead of compiling it again. Each memo entry counts
the renderables drawing with it: a renderable acquires its vertex and fragment entries when built
and releases them in its idempotent per-mesh lifetime packet, so a candidate generation, built
before the old one retires, still hits, and an entry leaves the memo with its last holder (a
superseded plugin or material variant, a removed mesh, a rolled-back candidate, a disposed task).
A renderable records only the acquisitions that succeeded, and a release never compiles or creates
an entry, so a candidate whose vertex or fragment compile throws rolls back exactly what it holds
and still destroys its mesh UBO.
A GPUShaderModule has no destroy(), so dropping an entry never invalidates a module that older
pipelines still reference. Composition, BGLs, pipeline layouts, and pipelines stay per view
resource. A device replaced by device-lost recovery compiles its own modules. Node geometry
already shares modules through its code-keyed pipeline cache.
Incremental re-sync
record() and every scene._renderableVersion change re-sync the bound list, but only the
entries a mutation touched are rebuilt, like the forward RenderTask resync. Each Standard or
PBR entry remembers the forward renderable the scene tracked for its mesh when it was built
(scene._renderables for Standard, the group's tracked output for PBR). While the mesh still
draws the same source material through that same forward renderable, the entry is carried as
is: same renderable, binding, update state (including velocity history) and lifetime sink.
Everything an entry captures either forces a forward rebuild when it changes (material
features and textures, mesh capabilities and vertex layout, the PBR context and light/shadow
request), is constant for the task, or is read live each frame (world, light selection,
material UBO version, vertex/index buffers, thin-instance counts). A resize or frame-graph
rebuild therefore rebuilds nothing, and a Standard material swap, material rebuild or new mesh
rebuilds only its meshes. PBR follows its forward pass: a per-mesh forward rebuild (a material
swap through the group's rebuild closure, markMeshRenderableDirty) rebuilds only that entry,
but a full PBR group rebuild (rebuildMaterial on a PBR material, a PBR mesh whose group must
widen or be built at runtime, rebuildScenePbrPipelines) replaces every PBR forward renderable
and publishes a new PBR context, so every PBR geometry entry is rebuilt and every PBR view
composes a variant set for the new context; Standard entries are still carried. The PBR
forward-generation gate (isPbrForwardBuildCurrent) runs first, so a PBR mesh whose forward
rebuild is pending is dropped, never carried. Only superseded entries are retired.
Standard/PBR views, with the variants compiled on them, persist across syncs while the source
keeps its _renderFeatures object; the view map is pruned to the drawn materials at each
publish. A new Standard mesh of an already drawn material compiles nothing. A PBR view keeps
one variant set per forward PBR context it composed against, weakly keyed by the context: the
context supplies the composer, scene features and shadow layout, while each entry binds env and
shadow textures from its own mesh's context, so a variant is never shared across contexts.
Several contexts can be live for one view: a full PBR group rebuild publishes a new scene
context, and a PBR mesh runtime-built into a group that was built after it was requested
(several meshes of a never-built PBR group built in one drain before the scene's first build
completes) keeps its own context until the next full rebuild. Meshes on different contexts
never evict each other's set, so a later rebuild of any of them compiles nothing. A new PBR
mesh compiles nothing when its context already has a set on the view; otherwise it composes
its own variant, as its forward build did. A batch that first binds K meshes on K per-mesh
contexts therefore composes K variants, not one per material. Node entries and views are
rebuilt on every sync, because a Node geometry resource snapshots the material's uniforms
once; a Node view is stamped with a per-sync token that holds no view, so a published Node view
keeps no earlier Node material or view alive. Off-scene meshes of an explicit list have no forward renderable and are rebuilt on every
sync too. A sync that sees a different device, camera or config.reverseCulling than the last
publish carries nothing and reuses no view, so a camera or culling change, or a device-loss
recovery (whose record() also recreates the task's own buffers), rebuilds the whole pass as
before. The camera is stamped as two elements, both compared by identity: the override as a view
captures it (config.camera ?? null, so an omitted and a null override are the same choice) and
the camera the pass draws with (config.camera, else scene.camera). Both matter under a
floating origin. The override decides what an entry packs its world against and whose version
re-packs it: the override itself, or whatever scene.camera is at each pack (it also decides
whether a PBR entry receives shadows). Without it, moving one camera between scene.camera and
config.camera would keep the effective camera and carry entries that then follow the wrong
origin once scene.camera changes. The effective camera catches scene.camera replaced for a
task without an override: its entries re-pack only when the version they compare moves, and the
new camera's version can equal the old one. A sync only sees the camera of that moment, so under
a floating origin execute() clears the stamp once a frame draws with another camera than the
stamped one: scene.camera can then pass through other cameras and return to the stamped one
with the version an entry compared last, leaving it packed against another camera's origin, and
the next sync carries nothing. Clearing instead of rebuilding keeps a camera switch between
frames free of pass rebuilds. If a sync against a changed configuration fails and the previous
configuration is restored, the next sync carries the published entries and views, none of the
failed sync's views.
The task's _removeMesh hook evicts every matching bound entry and queues its retirement
immediately, including when rendering is stopped. Its weak exclusion set still rejects removed
off-scene inputs and allows a mesh to rejoin after it is added back to the scene.
The task creates group 0 during recording, not once in the factory and again during recording. Immutable color clear/load/store state is initialized with the attachments; per-record work only refreshes changing views, dimensions, and depth attachments.
Bundle isolation
createGeometryRendererTask() owns its own scene UBO, scene bind group,
material-view cache, and pipeline cache. It does not route through
getOrCreateStandardBindings() or getOrCreateStandardPipeline(). Existing
scenes that never import geometry-renderer-task.js pay zero bytes.
Likewise, geometry-types.ts and the Standard geometry-output shader composer
(standard-geometry-output-shader.ts) are loaded only by the geometry task.
MRT engine plumbing — a dedicated RenderTargetMrt module
A geometry task's outputTarget is built with colorFormats set from the
textureDescriptions array. The MRT-specific render-target API lives in its
own module (engine/render-target-mrt.ts), separate from the single-attachment
RenderTarget (engine/render-target.ts) used by every existing scene. This
split keeps non-geometry scenes from paying for MRT helpers:
RenderTargetMrtownscolorFormats: readonly GPUTextureFormat[](1..8),_colorTextures: GPUTexture[],_colorViews: GPUTextureView[], plus_resolveColorTextures/_resolveColorViews(MSAA → resolve) whensampleCount > 1.- Single-attachment
RenderTargetkeeps its original_colorTexture/_colorViewshape — unchanged frommasterfor binary-format compatibility. createRenderTargetMrt/buildRenderTargetMrt/disposeRenderTargetMrt/getSampledColorTexture/getSampledColorVieware exported from the MRT module and lazy-loaded only by the geometry renderer.
Each per-type accessor on GeometryRendererTask (e.g.
geometryViewNormalTexture) returns a wrapper single-attachment RenderTarget
whose _colorTexture / _colorView are aliased to the underlying MRT
attachment via getSampledColorTexture / getSampledColorView. The wrapper
sets _eager: true so buildRenderTarget becomes a no-op (the MRT owns the
real GPU texture). This lets downstream tasks (createCopyToTextureTask,
post-process, etc.) consume a single geometry attachment as if it were an
ordinary single-attachment RT — they never need to know MRT exists.
Standard material geometry view
For each unique source Material among the caster meshes, the task creates a
MaterialView whose _renderFeatures flips GEOMETRY_OUTPUT on and clears
MATERIAL_ALPHA_BLEND. The view shares the source material's bindings
(textures, samplers, UBO data) and only swaps the compiled shader pipeline.
The MaterialView indirection (prototype-inheritance wrapper, see
material/material-view.ts) means the source material's diffuseTexture,
bumpTexture, opacityTexture, etc., flow through unchanged — every
property lookup falls through to the source. The geometry task reuses the
same texture handles and sampler objects the regular Standard pipeline uses.
The view's compiled fragment WGSL is produced by post-processing the
output of composeStandardShader (the regular Standard composer):
- The standard composer emits the full fragment body, including bump
perturbation (
normalW = perturbNormal(...)via the AC slot), opacity- texture alpha modulation (alpha *= textureSample(oT, oS, ...)), alpha-cutout discard, instancing, thin-instance colour, and the lighting loop. None of this is re-implemented for the geometry pass. composeStandardGeometryShader(instandard-geometry-output-shader.ts) then string-patches the composed fragment WGSL:- rewrites the entry-point return type from
-> @location(0) vec4<f32>to-> FragmentOutput, - prepends a
struct FragmentOutput { @location(0) f0: vec4<f32>, ... };declaration sized to the requested attachment count, and - replaces
return color;with MRT writes computed from already-in-scope variables (normalW,baseColor,alpha,input.vp,scene.view,gp.cameraNearFar).
- rewrites the entry-point return type from
- When any requested attachment needs
cameraNearFar/previousViewProjection(NORMALIZED_VIEW_DEPTH, LINEAR_VELOCITY), a small~geometry-paramsShaderFragment is appended to the fragment list to contribute thegpUBO binding. The fragment id starts with~so it sorts after everystd-*ext in the composer's alphabetical topo-sort order, placing the binding last in the layout.
The result is that every enabled Standard material and mesh feature flows through: morph targets, skeletal skinning, bump perturbation, opacity-texture alpha modulation, alpha-cutout, UV scale/offset transforms, RGBA vertex colour modulation, instancing, and thin-instance colour all use the same fragments as the main scene render. The Standard geometry path computes the same effective material feature mask from the source material plus the current mesh feature bits, then mirrors the regular bind-group and draw-buffer ordering.
The optional Standard skeleton / vertex-color / UV-offset paths remain behind the published Standard mesh-feature enablers. A geometry task does not statically import those chunks. Skeletal previous-bone state is a separate dynamic chunk loaded only when the skeleton enabler, a matching Standard mesh, and a velocity attachment are all present.
Standard geometry variant and cache identity
view._geometry is keyed by every shader-relevant input:
meshFeaturessceneFeatures (including fog presence)attachment list (owned by the view)emitColor (owned by the view)meshFeatures includes morph, skeleton, 8-bone skeleton, vertex color, UV2,
thin instances, and instance color. The resource stored under that key owns
the composed shader, mesh BGL, and its per-render-target pipeline map; its
shader modules come from the per-device memo keyed by exact WGSL (see
"Resource ownership and failed rebuilds"), so equal code shares one module.
The pipeline map remains keyed by the complete MRT target signature. A fog
and non-fog scene sharing one device cannot reuse the same Standard geometry
color shader when targetTexture requests lit color.
Standard geometry binding and draw order
Group-1 resources and vertex buffers follow the composed shader exactly:
- mesh UBO (
world, light selection, and velocity fields when requested), - morph storage buffers when morphing is active,
- material UBO,
- diffuse texture/sampler,
- UV transform UBO,
- sorted Standard extension bindings (including the skeleton bone texture),
- geometry params UBO and previous-bone texture when velocity requires them.
Vertex buffers are position, normal, UV/UV2, sorted enabled extension buffers
(vertex color and skeleton joints/weights), then thin-instance buffers.
Interleaved offsets come from MeshGPU._vbLayout, matching the regular
Standard draw path.
The per-scene fog fragment and STD_SCENE_FOG bit participate in Standard
geometry composition and cache identity. Without a real-color target the
resulting lit color and fog blend are dead code for the WGSL compiler; with
targetTexture, the same fragment produces the forward fogged color.
The per-attachment writeGeometryInfo gate (wg = select(0.0, 1.0, alpha > 0.4))
matches BJS default.fragment.fx PREPASS: combined with per-attachment
ALPHA_COMBINE blend state, low-opacity samples preserve the destination
(background) while high-opacity samples overwrite it. The MRT pipeline
toggles per-attachment alpha blend + depth-write-off when the source
material has alpha < 1 or HAS_OPACITY_TEXTURE.
Vertex color is vec4<f32>. Its RGB multiplies baseColor; its alpha
multiplies the running Standard alpha before both alpha-test discard and the
geometry write gate. Omitted Standard uvOffset always contributes [0, 0]
in both forward and geometry UV UBOs, including when UV-offset support was
enabled globally for a different material.
The REFLECTIVITY attachment re-samples sT/sS (the specular texture +
sampler) to recover the glossiness alpha channel that the std-specular
fragment drops when writing into specularColor.rgb.
Velocity attachment
When LINEAR_VELOCITY is requested, the task tracks per-mesh world matrices
across frames for its existing material paths. excludeFromVelocity(mesh)
and includeInVelocity(mesh) let callers opt single meshes out of velocity
tracking (e.g., for known-static instances or sky / hud meshes).
For Standard meshes the renderable extends MeshUniforms with previousWorld
and a velocity-enabled scalar. Current clip position uses the fully deformed
out.vp; previous clip position uses the prior local world snapshot and
deformation state. Skeletal velocity lazily creates two rgba32float bone
textures and two matching bind groups per renderable. Each update samples the
texture written on the prior frame while queue.writeTexture refreshes the
idle texture for the next frame, then swaps roles. Forward-only skeletons and
non-skeletal geometry scenes never load this implementation.
The first frame initializes previous world and both bone textures from current
state and keeps the velocity gate disabled, producing zero motion.
excludeFromVelocity() clears the per-mesh UBO gate while local previous state
continues advancing, so re-inclusion does not require a bind-group rebuild or
produce a stale-frame spike.
Depth attachment
The task allocates its own depth32float depth texture when
config.depthTexture is omitted. Pass an external RenderTarget to share an
existing depth attachment with another task (e.g., the main scene render
task). The depth format of the geometry pipelines is taken from the actual
attached depth-stencil texture.
Camera
config.camera overrides the per-pass camera. When omitted, the task reads
scene.camera at execute() time. Both view and view-projection matrices are
written into the task's own scene UBO via writePassSceneUBO().
CopyToTextureTask
Used in Scene 145's impostor strip to display geometry attachments. Two
execution paths chosen in record():
-
Fast path —
GPUCommandEncoder.copyTextureToTexture. Eligible when there is no viewport, source and target are single-sampled and share a format, the source mip dimensions match the target attachment's dimensions, the target is not the swapchain, and the source/target textures were created withCOPY_SRC/COPY_DST. Frame-graph render targets carry both copy usages by default; external/eager textures without the required usage fall back to the blit path. Texture-backed color wrappers retain_colorSubresourceso encoder-copy destinations use the attachment's selectedmipLeveland layer (origin.z) rather than the whole allocation's default mip/layer. Targets without this metadata retain the ordinary mip-zero, layer-zero destination. A source wrapper likewise supplies the physical source mip andorigin.z. Its view exposes exactly one mip:lodLevelis view-relative and clamps to zero, so the copy uses its selected-mip dimensions without another shift. Ordinary sources retain their existing source-LOD selection. If the selected source dimensions differ from the target dimensions, the blit path samples the wrapper's selected view instead. -
Blit path — full-screen triangle samples the source. MSAA sources resolve per-sample with
textureLoad. Lod level is applied viatextureSampleLevel. The Y axis is flipped when the source and target have differentflipYorientations.
When ownsTargetTexture is true, the task rebuilds its target during every
frame-graph build, disposes a previously owned target when targetTexture
changes, and disposes the current target with the task. sourceTexture cannot
be the engine scRT, whose GPU texture changes every frame. Resolve-only and
blit-plus-resolve modes use resolveTexture for hardware MSAA resolve.
The color attachment's loadOp is "load" when a viewport is set (so
pixels outside the viewport are preserved — required for impostor strips
that overlay a scene render) and "clear" otherwise.
Scene 145
lab/src/lite/scene145.ts is the primary parity / integration test for the
geometry renderer task. It loads the public Hill Valley .babylon scene,
renders it through an MSAA swapchain RenderTask, runs a six-attachment
GeometryRendererTask over the same meshes, and uses six
CopyToTextureTasks to display the geometry attachments in a strip across
the top of the canvas:
NORMALIZED_VIEW_DEPTHVIEW_NORMALWORLD_NORMALWORLD_POSITIONREFLECTIVITYALBEDO
Six attachments at the chosen formats sum to 34 bytes/sample (with WebGPU
alignment, just over the default cap of 32), so the scene calls
createEngine(canvas, { requiredLimits: { maxColorAttachmentBytesPerSample: 64 } }).
The BJS reference scene ports
Playground #ARI9J5#6 (minus the
FrameGraphGUITask overlays, which are out of scope for Lite). Parity passes
at MAD ≈ 0.002 against the BJS golden — the MaterialView post-processing
approach reuses the standard fragment body verbatim, so the impostor strip
matches BJS pixel-for-pixel (no lossy material-constants approximation).
Test Specification
- Minimal Standard composition excludes fog helper/blend WGSL.
- Explicit Standard fog context includes helper/blend WGSL.
- Fog/non-fog Standard bindings and geometry color resources remain separate on one device.
- Missing
uvOffsetwrites zeros in forward and geometry UBO data. - Standard geometry composition with skeleton includes the shared skinning helper, bone bindings, joints/weights layouts, deformed world position/normal, and previous-bone velocity state.
- Standard geometry composition with vertex color modulates albedo and alpha before discard/write masking and binds the color buffer.
- Morph/skeleton/vertex-color bits participate in the geometry resource key.
- Re-recording the task, a renderable-version rebuild, and a forward PBR rebuild that republishes an equivalent context compile no new Standard or PBR geometry shader modules; materials that compose identical WGSL share one module pair; a task writing a different attachment compiles only its new fragment module and shares the identical vertex module; and a replaced device compiles its own.
- Retired PBR plugin variants and Standard material variants leave the memo while the live variant stays shared; a disposed task releases only the modules no other task draws with, and its last holder empties the entry.
- A candidate whose vertex or fragment module fails to compile releases only the modules it acquired, creates no entry, takes no count from a stage a live generation holds, and destroys its mesh UBO; a retry compiles the missing stages once.
- A re-sync keeps the entries of meshes whose forward renderable is unchanged (new mesh,
re-record, unrelated swap) and rebuilds only the touched ones, including when driven by the
scene's own producers (material setter,
markMeshRenderableDirty,rebuildMaterial,addToScene); a new mesh of a drawn material reuses the cached view and variant; a shared Standard variant's material UBO survives deferred retirements until its last owner releases it; a failed replacement leaves every carried entry intact; a failed sync, incremental or against a changed camera or culling direction, leaves the view cache as published, so a restored configuration rebuilds a refreshed entry on its own view; Node entries rebuild on every sync, and a Node material swapped over several syncs leaves no earlier material or view reachable from the published views. - Under a floating origin, a tracked mesh is packed against the camera the pass draws with: after
scene.camerais replaced (with an equal world-matrix version) for a task without an override, after its camera becomesconfig.cameraandscene.camerathen moves on, afterconfig.camerais removed while it is the scene camera andscene.camerathen moves on, and on the record after frames drawn with other scene cameras end on the stamped one again. - A PBR geometry variant composed against a superseded forward PBR context is not reused.
- A PBR view keeps one variant set per live forward context: after meshes on a scene context and on per-mesh runtime-build contexts are bound (one variant per context), per-mesh and batch refreshes of any of them compile nothing.
Future extensions
- PBR support — mirror
standard-geometry-output-shader.tswith apbr-geometry-output-shader.tsthat post-processes the PBR composer's fragment output (rewriting the entry return andreturn finalColor;into MRT writes). - Gaussian splatting + sprites — render Gaussian splat meshes and sprite
batches into the same MRT attachments. The flag plumbing already exists
(
GEOMETRY_OUTPUTfor Standard); the work is per-renderer pipeline composition. - Previous morph weights for velocity — current Standard/PBR morph positions are applied to depth/normals/current clip. A future shared previous-weight buffer can extend motion vectors for independently animated morph weights in the same way Standard skeleton velocity retains prior bone matrices.
Related
28-frame-graph.md—Task/ single-attachmentRenderTargetAPI.29-post-process.md— sibling frame-graph task family.08-standard-material.md— Standard material features and bindings.engine/render-target-mrt.ts— MRT-only render-target module, lazy-loaded bygeometry-renderer-task.tsso existing scenes keep paying nothing for the multi-attachment API.