Renderer · class

WebGL2Renderer

Explained in Hello world.

class WebGL2Renderer implements RendererApi
import { WebGL2Renderer } from '@driftengine/core';

Constructor

newdeprecated

constructor(canvas: HTMLCanvasElement, qualityOptions?: RenderQualityOptions)

Construct the WebGL2 renderer directly.

ParameterTypeDescription
canvasHTMLCanvasElement
qualityOptions?RenderQualityOptions

Properties

NameTypeDescription
gpuTimerreadonlyGpuTimerGPU time for the frame's three parts, when the driver will report it.
More

Public because the consumer is a diagnostic, not the renderer: the game brackets its own frame with beginFrame/endFrame and reads samples with poll.

qualityreadonlyReadonly<RenderQuality>Resolved once; a size/profile change recreates the renderer's resources.
rendererNamereadonlystringWhat UNMASKED_RENDERER_WEBGL calls this part, or '' when the browser withholds it.
More

Public because a bug report is worth more with it than without: every performance report this project has acted on was read GPU-string first.

backendreadonlyRenderBackendWhich backend is drawing, from the renderer rather than from the address bar.
More

createRenderer returns this too and says to report it and never infer it — the inference being a query string, which says what was asked for and is wrong in exactly the case that matters, a silent fallback. A consumer holding only a renderer could not ask before this existed, so it inferred or it said nothing.

Not the same question as rendererName, which is the GPU's own string and identifies the part rather than the API.

capabilityClampedreadonlybooleanWhether this part was recognised as one that cannot hold the profile it asked for, and had its pixel terms clamped at construction. Carried into bug snapshots, so a report says whether the clamp fired rather than leaving it to be inferred.
reversedDepthbooleanWhether this context really is drawing reversed. See RendererApi.reversedDepth.
More

Separate from REVERSED_DEPTH, which is what the engine wants: this is what it got. Every depth decision below reads this one, so a context without EXT_clip_control behaves exactly as it did before rather than half-reversed — and it is public because a consumer choosing a near plane needs the granted answer and not the wished-for one.

computeSupportedreadonlybooleanWhether this backend can run a compute definition at all. It cannot.
More

A value-typed member answered honestly, which the 2026-08-13 rule requires of every backend: a missing method names itself and a missing property is undefined, which in arithmetic is a picture rather than an error. FrameTimer.available is the same shape, and its comment names the alternative as the worst number available to invent.

What it gives up: a consumer must branch, and a package that wants a GPU sort has to carry a second implementation. What would make it wrong: nothing short of WebGL2 gaining compute shaders, which it will not — the specification has none and is closed.

displayRangereadonlyDisplayRangeThe range the frame goes out in: 'high' where the profile asked for highDynamicRange, the frame holds light past white and the display shows it — on WebGPU — and 'standard' otherwise. Always standard here: this backend draws into a canvas of eight bits a channel, which has nothing above white to show. Typed as the question rather than this backend's answer, because the shared surface is derived from this class.

Accessors

NameTypeDescription
contextLostgetbooleanWhether the drawing context is currently gone.
More

Every GL entry point below returns early while this is true, and that is a reliability fix rather than an optimisation. A lost context does not stop the caller's frame loop — requestAnimationFrame keeps firing, the game keeps calling beginFrame, and every object this renderer holds now belongs to a context that no longer exists. The browser answers each call with INVALID_OPERATION, so a single loss becomes an unbounded flood of "object does not belong to this context" at sixty frames a second, which buries the one message that mattered and makes the console useless for finding the cause.

It was reported as a wall of console errors after the picture stopped, with the trace running through the frame loop — the renderer drawing long after there was anything to draw to.

Public so a consumer can skip its own per-frame work too; the guard here only makes the engine's half harmless.

Asks the context, not only the event. webglcontextlost is dispatched asynchronously, so there is a window — however many calls the caller makes before the task queue drains — in which the context is already dead and the flag is still false. Every guard built on the flag alone fails open in that window, which is how a resize reached PlanarReflection.ensureSize and threw FRAMEBUFFER_UNSUPPORTED (0x8CDD) against a context that no longer existed.

isContextLost() is the authority and is cheap. The flag is kept because it is what onContextLost listeners fire from, and it holds across the gap until webglcontextrestored clears it.

presentedFramesgetnumberHow many frames this renderer has actually presented, monotonic for its whole lifetime.
More

Incremented where endFrame reaches its end, not where it is called. WebGL2 has no swap chain to acquire the way WebGPU's getCurrentTexture gives one — the canvas is the default framebuffer, and beginFrame clears it directly, so there is no separate "taken" instant to hang this on. endFrame's only early return is a lost context, so its far end, right beside framePresented, is this backend's honest equivalent: the one point every call is guaranteed to reach if and only if the frame it opened is what the browser is about to show. A consumer compositing an offline export reads this after endFrame and compares it with what it read last, which is how it tells a frame that drew something from one that repeated the canvas it already had — see ExportTarget.

cssWidthgetnumberViewport in CSS pixels rather than device pixels.
More

What screen-space overlays lay out against: sizing text in the drawing buffer makes it a third the size on a high-DPI phone, which is exactly the screen where it is hardest to read.

cssHeightgetnumber
distanceFieldMsgetnumber | nullNever measured here, because nothing is composed. See addDistanceField.
indirectBakeMsgetnumber | nullNever traced here either, so there is no refresh to time. See addDistanceField.
shadowMapSizegetnumberSide length of the depth map, so callers can size the light frustum.
compressedFormatsgetreadonly CompressedTextureFormat[]The BC formats this device takes as blocks, each named with its colour space ('bc7-srgb').
More

Ask before choosing, because a phone usually answers none: a BC texture handed to createSurfaceTexture where its format is not here is refused by name rather than decoded, since core ships no decoder. @driftengine/assets' loader asks this for every BC texture in a container and decodes at load where the answer is no. Read from the four extensions WebGL2 spreads BC across; see glCompressed.ts.

aspectgetnumber
sceneWidthgetnumberThe size the world is drawn at, which on this backend is always the drawing buffer.
More

Here so that a contributed pass has one question to ask on both backends. WebGPU can draw the world smaller than the canvas and let its composite enlarge it — quality.reconstruction — and a pass filling a target of its own has to follow that size or draw a picture the frame pass clips. This backend has no compute stage and no reconstruction, so the answer never moves; a pass that reads it is correct on both, and one that reads canvas.width is correct on one.

sceneHeightgetnumber
resolutionScalegetnumberThe density actually in force, which is where a governor has to start from.
More

applyResolutionScale could always be written and never read, and that was enough for the consumer that drives it from a stored preset. It is not enough for one that drives it from measurement: ResolutionGovernor is constructed around a starting scale and moves relative to it, so a caller with no way to ask has to guess.

The effective density and not maxDpr, and the difference is not academic. maxDpr is a ceiling: resize takes min(devicePixelRatio, maxDpr), so on an ordinary 1x display a ceiling of 2 is already doing nothing. A governor seeded from the ceiling there spends its first four decisions walking 2 down to 1 while the drawing buffer does not change by a single pixel, which is a machine stuttering for several seconds through a fix that is being applied to a number nobody is reading. Found exactly that way, at 20x throttle: three decisions, no change in buffer size.

capabilityClamp is the other half of the same point, and it argues the same direction: maxDevicePixelRatio is a request the clamp is entitled to have already lowered.

shadedLightsgetnumberHow many point lights this renderer's shader shades at once.
More

Read it when sizing a PointLightBuffer. selectPointLights fills a buffer to the buffer's own capacity and evicts the weakest when it is full, so the winners are the nearest ones but they are not in any order — which means a buffer wider than this leaves the shader shading an arbitrary subset rather than the nearest N. Sizing the buffer from this number is what makes a clamped budget a smaller scene instead of a flickering one. A wider upload is otherwise harmless: GL ignores array elements past the end of a uniform, measured on ANGLE.

shadedAreaLightsgetnumberHow many rectangular emitters this renderer's shader shades at once.
sampledShadowLightsgetnumberHow many point-light shadows the shader samples at once.
displayRangeReasongetstringWhy it is that range, in words, for a game choosing its look and for a bug report.
frameBudgetgetFrameBudgetWhat the frame just drawn asked for. See budget.ts, and the note on budget above.
firstFrameSettledgetbooleanWhether the driver has finished the first frame, not merely been asked for it.
More

For whatever is covering the load: a plate lifted on the frame being submitted uncovers the screen while the GPU process is still paying that frame's first-use bill, and the player watches the last of the load rather than being spared it. Poll it once a frame; it never goes back to false.

Non-blocking by construction — a zero timeout asks the driver what it has finished and takes the answer, rather than waiting for the one it wants.

Methods

onContextLost

onContextLost(listener: () => void): void

Told when the drawing context is lost. See attachContextLoss.

ParameterTypeDescription
listener() => void
More

A lost context is a black screen the game does not otherwise notice: nothing throws, the loop keeps running, and every draw call quietly does nothing.

onContextRestored

onContextRestored(listener: () => void): void

Told when the drawing context comes back. See attachContextLoss.

ParameterTypeDescription
listener() => void
More

This does not rebuild anything, and cannot: every resource field on this class is readonly and set in the constructor, which is the same reason a quality change is construction-time. A listener that wants to draw again has to take the page through a reload. It is here so the moment is visible in a trace rather than silent, and so the promise preventDefault makes has somebody on the other end of it.

visible

visible(bounds: Bounds, model: ReadonlyMat4): boolean

Whether a mesh placed by this matrix is anywhere in the frame.

ParameterTypeDescription
boundsBounds
modelReadonlyMat4
More

Valid between bindMeshPass and endFrame, because that is when a camera has been given. A consumer is better placed to use this than cullDraws is: it can skip the model matrix, the animation update and the material switch as well, none of which the renderer can avoid once drawMesh has been called.

addOccluder

addOccluder(min: ArrayLike<number>, max: ArrayLike<number>, model: ReadonlyMat4): void

Declare a box that things behind it may safely be hidden by.

ParameterTypeDescription
minArrayLike<number>
maxArrayLike<number>
modelReadonlyMat4
More

Between bindMeshPass and the first drawMesh, because the pass is what supplies the camera and the first test is what freezes the pyramid. A no-op when the profile asked for no occlusion buffer, so a consumer may call it unconditionally.

The box is in the model matrix's own space, and it is the box you may be hidden by rather than the one you occupy — an occluder larger than the solid it stands for culls things that are visible, which is a hole in the world. OcclusionBuffer carries the rest of the reasoning.

addDistanceField

addDistanceField(_field: FieldSource, _model: ReadonlyMat4, _albedo?: ArrayLike<number>): void

Declare a baked distance field that indirect light may be traced against.

ParameterTypeDescription
_fieldFieldSource
_modelReadonlyMat4
_albedo?ArrayLike<number>
More

Recorded by nothing on this backend, and that is the refusal rather than an oversight. Composing the world's field and marching probes through it is compute, and WebGL2 has no compute stage — INDIRECT_LIGHT_WEBGL2_REFUSAL says so once a frame-loop when quality.indirectLight is on. The method exists here because RendererApi is this class's shape, and a consumer that declares its fields unconditionally must not have to ask which backend it got: this is a no-op for the same reason addOccluder is one when the profile asked for no occlusion buffer.

The field is in the model matrix's own space and the matrix must carry uniform scale only. composeGlobalField gives the reason: a distance is not preserved by a non-uniform scale.

clearDistanceFields

clearDistanceFields(): void

Forget the fields declared this frame. A no-op here, as addDistanceField is.

readDistanceFieldTimings

readDistanceFieldTimings(): void

Nothing to read, for the same reason. A consumer may call it unconditionally.

occluded

occluded(bounds: Bounds, model: ReadonlyMat4): boolean

Whether a mesh is entirely behind the occluders declared this frame.

ParameterTypeDescription
boundsBounds
modelReadonlyMat4
More

False whenever anything is uncertain, including when no occluders were declared and when the profile asked for no buffer — so a consumer may call it unconditionally and a scene that declares nothing behaves exactly as it did before. Read it beside visible, which answers the other half of the same question.

occludedBox

occludedBox(min: ArrayLike<number>, max: ArrayLike<number>): boolean

Whether a world-space box is wholly behind the declared occluders — the tighter question for something that is a box, such as a region. False with no occlusion buffer. See occluded.

ParameterTypeDescription
minArrayLike<number>
maxArrayLike<number>

registerPass

registerPass(definition: PassDefinition): PassHandle
ParameterTypeDescription
definitionPassDefinition

drawPass

drawPass(handle: PassHandle): void

Run one, here, now.

ParameterTypeDescription
handlePassHandle
More

Immediate, because this backend draws immediately: the framebuffer the frame is going into is already bound and a callback is a pass. The WebGPU side records it instead and runs it at the flush, and the difference is invisible to the caller — which is the point of both. The caller's position in its own draw order is the only thing that decides where this geometry lands, exactly as it is for every built-in verb.

unregisterPass

unregisterPass(handle: PassHandle): void

Let go of a pass. A handle kept past this draws nothing; see PassHandle.

ParameterTypeDescription
handlePassHandle

setSpotCookies

setSpotCookies(images: readonly TexImageSource[]): void

Upload the cookies a consumer loaded, and bind the atlas.

ParameterTypeDescription
imagesreadonly TexImageSource[]
More

One row of square tiles, COOKIE_TILE a side, each image scaled into its tile by the driver. Called once per set rather than per frame, because a cookie is an asset: a fixture's mask does not change while the game runs.

Passing an empty list clears them and leaves the single white texel the atlas falls back to, which is the multiplicative identity — a scene that drops its cookies goes back to plain lights rather than going black.

The engine ships no cookie and never will, which is the same line the glyph keys draw: what a fixture throws is a consumer's art. zero texture files in AGENTS.md is a rule about what the engine carries, and a consumer's own image has always been allowed — it is what createSurfaceTexture and the material maps take.

setIesProfiles

setIesProfiles(profiles: readonly PhotometricProfile[]): void
ParameterTypeDescription
profilesreadonly PhotometricProfile[]

registerCompute

registerCompute(definition: ComputeDefinition): ComputeHandle

Refuse, in words, and hand back the handle that names nothing.

ParameterTypeDescription
definitionComputeDefinition
More

This is the defined-state half of the 2026-08-13 rule, whose other half forbids a silent no-op. There is nothing to degrade to here — a pass this backend cannot run can at least be an empty method that leaves a visible hole in the picture, and a compute stage has no picture to leave a hole in. So the degraded state is the refusal itself, spoken at the one moment a consumer can still act on it: registration, which is init time, where the house rule puts failures and never the frame loop, where it forbids them.

Zero rather than a live handle, because definitionAt already reads zero as naming nothing — handles start at 1 for exactly this reason — so the dispatchCompute that follows is a no-op which has already announced itself rather than one that never speaks.

console.error rather than a throw, for the reason createGpuSurface gives about the same choice: a consumer registering during boot should get a diagnostic it can act on, not an exception that takes the page down over a capability it can live without.

dispatchCompute

dispatchCompute(_handle: ComputeHandle): void

Nothing was registered, so there is nothing to dispatch. See registerCompute.

ParameterTypeDescription
_handleComputeHandle

unregisterCompute

unregisterCompute(_handle: ComputeHandle): void

Nothing was registered, so there is nothing to release. See registerCompute.

ParameterTypeDescription
_handleComputeHandle

ready

ready(): Promise<void>

Resolve once every program a draw has started compiling has finished, adopting each: what a loading screen awaits after drawing what its scene will use, where draws skip a compile. See RenderQuality.pipelineCompile; nothing is started anywhere else here, so otherwise it is now.

More

It waits without holding the thread, as the other backend's does: it asks the driver whether each is done, which does not wait, and yields between asks. A driver that never says done is asked the way that waits after READY_MAX_POLLS, so this cannot hang a loading screen.

dispose

dispose(options?: { releaseContext?: boolean; }): void
ParameterTypeDescription
options?{ releaseContext?: boolean; }

createMesh

createMesh(data: MeshData, options?: MeshOptions): Mesh
ParameterTypeDescription
dataMeshData
options?MeshOptions

createMeshIncremental

createMeshIncremental(data: MeshData, options?: MeshOptions): IncrementalMesh

Geometry in, handle out — but the geometry lands over as many frames as the caller gives it.

ParameterTypeDescription
dataMeshData
options?MeshOptions
More

See RendererApi.createMeshIncremental for what this is for and what a caller may do with a mesh that has not finished arriving. The iterator here adds the one thing mesh.ts cannot know about: a lost context ends the upload, because an upload that spans frames can straddle one and every buffer it was filling is gone. Done with complete still false is the signal that it was abandoned.

updateMesh

updateMesh(mesh: Mesh, positions: Float32Array, normals?: Float32Array): void

Rewrite a mesh's positions, and its normals where the caller has them.

ParameterTypeDescription
meshMesh
positionsFloat32Array
normals?Float32Array
More

The VAO is left alone: an attribute pointer describes where a buffer's data is, and the buffer is the same buffer. Rebinding here would cost a state change for nothing.

createSurfaceTexture

createSurfaceTexture(source: SurfaceSource, options?: SurfaceTextureOptions): SurfaceTexture

Upload an image the caller already has, for use as surface colour.

ParameterTypeDescription
sourceSurfaceSource
options?SurfaceTextureOptions
More

Takes a TexImageSource rather than a URL, and that is the whole boundary: the engine ships no image assets and fetches nothing, so where the pixels came from — a canvas drawn procedurally at runtime, a decoded bitmap, a video frame — stays the consumer's decision. What it gains is a GPU object with sane sampler state.

createSurfaceTextureArray

createSurfaceTextureArray(sources: readonly SurfaceSource[], options?: SurfaceTextureOptions): SurfaceTexture

Upload several images of one size as the layers of one array, for a merged mesh that names a layer per vertex (MeshData.layers). One material binding then covers every image, which is what makes a block wearing forty facades one draw. Every layer must be the same size, and the refusal names the first that is not.

ParameterTypeDescription
sourcesreadonly SurfaceSource[]
options?SurfaceTextureOptions

createLightmap

createLightmap(page: LightmapPage): SurfaceTexture

A baked lightmap page, as a lightmapModel material's modelMap. See RendererApi.

ParameterTypeDescription
pageLightmapPage

createSceneCapture

createSceneCapture(width: number, height: number): SurfaceTexture

A texture the scene can be drawn into, for a material to show. See RendererApi and sceneCapture.ts. Half floats where the frame keeps range, as a probe does.

ParameterTypeDescription
widthnumber
heightnumber

captureScene

captureScene(capture: SurfaceTexture, camera: Camera, clearColor: Vec3, draw: (camera: Camera) => void): boolean

Draw the scene into a capture from camera: draw is handed the camera, its matrices updated for the capture's shape, and submits the scene as it would to the screen. See RendererApi.

ParameterTypeDescription
captureSurfaceTexture
cameraCamera
clearColorVec3
draw(camera: Camera) => void
More

Radiance, as a probe bake stores it: the output transform and exposure are held off for the pass, so the frame grades a screen showing it once. No temporal jitter, which a capture has no resolve to undo. Returns whether it drew: false for a texture that is not a capture, or one the driver will not draw into.

updateSurfaceTexture

updateSurfaceTexture(texture: SurfaceTexture, source: TexImageSource | CompressedTextureSource): void

Replace a surface texture's pixels, keeping the GPU object and its sampler state.

ParameterTypeDescription
textureSurfaceTexture
sourceTexImageSource | CompressedTextureSource
More

Goes through the renderer because the GL context does not leave this directory, and SurfaceTexture.update needs one — without this a consumer could create a texture and dispose it but never change it, which is exactly the case an image that decodes after the world is built runs into. Re-uploading beats constructing a second texture: the binding a draw loop already holds stays valid, so the swap is a swap rather than a rebuild.

Not a hot path — it re-uploads the whole image and regenerates the mip chain.

disposeSurfaceTexture

disposeSurfaceTexture(texture: SurfaceTexture): void
ParameterTypeDescription
textureSurfaceTexture

prepareMesh

prepareMesh(mesh: Mesh, material: SurfaceMaterial | null, _options?: PrepareOptions): void

Compile, off the frame, every program a draw of mesh in material takes that making the mesh did not, so the first frame that draws it waits on none: the program of the material's shading model for each vertex variant the mesh can be drawn with (its rig, with a palette; its targets, with weights and without), a skin's halves under the screen-space blur, and the lit switches the material turns on. ready() resolves once they have.

ParameterTypeDescription
meshMesh
materialSurfaceMaterial | null
_options?PrepareOptions
More

For pipelineCompile: 'skip' above all, where a draw whose program is new is left out until it lands: a figure brought on screen whole rather than without its face and hair for the frames those take. Where draws wait, the programs compile here, at the call, rather than in a frame. Two-sided and cut-out surfaces are draw state on this backend and need nothing; on WebGPU they are pipelines of their own, prepared the same way.

Call it between frames: it sets material to read it and leaves no material set after, as setMaterial(null) does. options is the other backend's; blended draws share a program here.

prepareInstanced

prepareInstanced(batch: InstancedBatch, material: SurfaceMaterial | null, _options?: PrepareOptions): void

prepareMesh for an instanced batch: its own programs, and its model's for its variant.

ParameterTypeDescription
batchInstancedBatch
materialSurfaceMaterial | null
_options?PrepareOptions

setSkinPalette

setSkinPalette(palette: Float32Array | null): void

Choose the joint palette the following drawMesh calls skin by, or null for none.

ParameterTypeDescription
paletteFloat32Array | null
More

Null is what an unskinned draw needs and is the default, so a game that never animates never allocates a palette texture and never leaves one bound.

The palette is sixteen floats a joint, column-major, exactly as Skeleton.palette produces — this renderer never learns where it came from, which is the boundary that keeps the animation runtime out of core.

createClothBinding

createClothBinding(mesh: Mesh, data: ClothBindingData): GlClothBinding

A mesh's cloth binding: which simulation triangle each of its vertices follows, where on it, and by how much. See ClothBindingData.

ParameterTypeDescription
meshMesh
dataClothBindingData
More

Refused by name for a mesh with no rig — a binding blends a vertex between its skinning and the cloth — and for one that disagrees with the mesh. Compiles the programs that draw and cast it here, at creation, so no frame ever waits on a compile.

createClothParticles

createClothParticles(count: number): GlClothParticles

One character's particles, count of them, to be updated every frame.

ParameterTypeDescription
countnumber

updateClothParticles

updateClothParticles(particles: GlClothParticles, positions: Float32Array): void

This frame's particles, three floats each, world space; last frame's are kept for motion. A simulation's output, as it is.

ParameterTypeDescription
particlesGlClothParticles
positionsFloat32Array

setCloth

setCloth(binding: GlClothBinding | null, particles?: GlClothParticles | null): void

Place the following skinned draws by a cloth: binding the mesh's, particles the character's. Null for none, which is every draw until this is called. Both or neither, and the particles must be as many as the binding names.

ParameterTypeDescription
bindingGlClothBinding | null
particles?GlClothParticles | null

disposeClothBinding

disposeClothBinding(binding: GlClothBinding): void
ParameterTypeDescription
bindingGlClothBinding

disposeClothParticles

disposeClothParticles(particles: GlClothParticles): void
ParameterTypeDescription
particlesGlClothParticles

setMorphWeights

setMorphWeights(weights: Float32Array | null): void

Choose the morph weights the following drawMesh calls deform by, or null for none.

ParameterTypeDescription
weightsFloat32Array | null
More

One weight per target the mesh carries. The weights are per draw and the deltas are per mesh, which is the split that matters: two characters sharing one head mesh wear different expressions without a second copy of the geometry.

setMaterial

setMaterial(material: SurfaceMaterial | null): void
ParameterTypeDescription
materialSurfaceMaterial | null

setSurfaceTexturedeprecated

setSurfaceTexture(texture: SurfaceTexture | null, uScale?: number, vScale?: number, cutout?: number): void
ParameterTypeDescription
textureSurfaceTexture | null
uScale?number
vScale?number
cutout?number

setAmbientSH

setAmbientSH(coefficients: ArrayLike<number> | null): void

The ambient the following draws take, as nine spherical-harmonic coefficients of incoming radiance — red, green and blue of each, twenty-seven numbers — or null for the frame's own: the sky's gradient, or the probes'. See ambientHarmonics.ts for the basis and the axes.

ParameterTypeDescription
coefficientsArrayLike<number> | null
More

For a thing lit by where it stands: a consumer sampling a stage's baked volume of indirect light at a character every frame sets the result around that character's draws, and the character takes the light of its spot rather than the frame's. It replaces the diffuse ambient only — a glossy surface still reflects the probes — and it is pass state like the material, which bindMeshPass clears, so a mirror or a probe bake drawn after does not inherit it.

setSurfaceReflectivity

setSurfaceReflectivity(amount: number): void

How much of the environment the following draws mirror, 0 to 1.

ParameterTypeDescription
amountnumber
More

Pass state beside the grain, and the counterpart to it: grain is a surface being uneven, this is a surface being smooth enough to carry an image. Polished paint, glass, chrome and still water want it; plaster and stone want none.

Defaults to zero and bindMeshPass restores it, so a scene that never calls this looks exactly as it did.

setSurfaceOverlay

setSurfaceOverlay(overlay: SurfaceOverlay | null): void

The overlay the following draws wear — a rim of light, a dissolve, wrinkles by region — or null for none. Pass state, as the material is: bindMeshPass puts it back to none. See surfaceOverlay.ts for what it lays and shaders/flat/overlay.ts for how.

ParameterTypeDescription
overlaySurfaceOverlay | null
More

Refused, said once, where the lit stage has no room for it. Fifteen vectors more than the program this device fits: counted from the source as fitFlatProgram counts, and on a device whose limit is the 256 an Adreno 740 reports, the eight-light build at 254 has none. The draws then wear nothing, which is the picture a game without the feature draws. Its atlas is bound as a 2D copy of its first layer, in the refraction copy's unit (SurfaceTexture.flatLayer); a compressed atlas has no copy and is read as none, also said once.

setEnvironmentGain

setEnvironmentGain(gain: number): void

How bright the environment the surfaces drawn next reflect is. 1 is the default and identity.

ParameterTypeDescription
gainnumber
More

See uEnvironmentGain in the preamble for the whole argument. In one line: a metal's Fresnel base is its own albedo, so it takes no ambient and no direct diffuse and is its reflection, and when the only environment is a cube of nearby geometry that leaves it as dark as the room happens to be. This is the stand-in for the image-based lighting that would carry the sky and the sources instead, and it goes away when there is one.

Not clamped above: the case it exists for is a room that is too dim, and a value below 1 is as legitimate as one above. Held non-negative, because a negative environment is not a darker one.

setEnvironmentAmbient

setEnvironmentAmbient(_amount: number): void

Kept as a no-op that says so once, because it was a stand-in for a term that now exists.

ParameterTypeDescription
_amountnumber
More

It mixed the ambient toward a box-filtered level of the probe, three below the coarsest, lifted by a max so a partial probe could not darken a subject. Irradiance is nine projected spherical-harmonic coefficients now, evaluated per fragment against the normal, and it is on whenever a probe has been baked — with nothing to tune, because an approximation needed a dial for the ways it was wrong and a cosine integral does not have them.

Deprecated rather than removed, and that is a compatibility decision rather than a preference. It is on the public surface and consumers call it; deleting it today would be a breaking change across four repositories to save one method. It goes at the next major, where a coordinated release is happening anyway.

setSurfaceGrain

setSurfaceGrain(amount: number): void

A pass-level scale over the grain the geometry itself declared, 0 to 1.

ParameterTypeDescription
amountnumber
More

Which surfaces are mineral is now MeshData.grain, stated per vertex through MeshBuilder.setGrain — because it is a property of a material, and absent means none. This stays as the override for the case that has no answer to state: an imported model arrives with no grain information at all, and a caller drawing one decides for it.

Pass state, like setSurfaceTexture, because a material covers many draws. The default is 1 and bindMeshPass restores it, so it scales geometry that declared grain and leaves geometry that did not exactly as it is.

setSurfaceFog

setSurfaceFog(enabled: boolean): void

Whether the surfaces drawn next meet the medium: distance fog and the global medium's haze. True is the default, and what bindMeshPass restores.

ParameterTypeDescription
enabledboolean
More

The opaque counterpart of TranslucentMeshOptions.fog, which a translucent draw has had all along; the shader's per-draw switch was there, and nothing reached it for an ordinary mesh. What it is for is a medium that darkens one thing and not another — a floor receding into the distance under figures that must keep their full value however far from the camera they stand. Material state like setSurfaceGrain: instanced and skinned draws read it too, and a translucent draw's own fog is measured against it and puts it back.

What it gives up is the medium's honesty: a surface out of the fog is drawn as though the air between it and the eye were empty, so it should be a surface the scene means to stand apart, not one whose distance is the point.

setDitherFade

setDitherFade(amount: number): void

How much of the surfaces drawn next survives a screen-door dither, for crossfading two levels of detail of one thing.

ParameterTypeDescription
amountnumber
More

0 is off and is what bindMeshPass restores. A positive amount keeps that share of the pattern's cells, a negative one keeps the complement, so the level fading in drawn at t and the one fading out at -t cover every pixel exactly once — no hole, no pixel drawn twice. Material state like setSurfaceGrain; instanced draws read it too, and shadow casters, which are a pass of their own, do not. See uDither in flat/preamble.ts for what it costs.

setDitherOpacity

setDitherOpacity(on: boolean): void

Whether the draws that follow spend their own opacity on a screen door rather than on blending: an instance's MeshInstances.alphas, or a vertex's channel alpha, keeps that share of the pixels setDitherFade would and draws the rest not at all. So an opaque or cut-out batch fades one instance at a time, with no sorting and no blend, and an instance at 1 is drawn whole.

ParameterTypeDescription
onboolean
More

Off is what bindMeshPass restores. It shares the fade's pattern, so a crossfade at t and -t of an instance at opacity a covers that instance's share once, without holes. Material state like setDitherFade; instanced draws read it, and shadow casters do not.

What it gives up: a fade reads as grain while it lasts, which a temporal resolve smooths and a plain frame does not; a blended draw that asks spends its opacity here and blends none of it; and a lightmapped batch's opacities ride its page regions, so they fade in steps of a sixty-fourth, which is the pattern's own resolution (MeshInstances.lightmapRegions).

setSurfaceRelief

setSurfaceRelief(amount: number, cyclesPerMetre?: number): void

How strong the microscopic relief on the surfaces drawn next is, and how coarse.

ParameterTypeDescription
amountnumber
cyclesPerMetre?number
More

The material half of MeshData.relief. The geometry says which surfaces have texture; this says what texture, because the same amount over a different scale is a different material entirely. Asphalt is coarse and deep, roughly 60 bumps to the metre at full strength; cast concrete is finer; orange peel on paint is finer again and shallow. The geometry carrying all three is identical, which is exactly why the two halves are separate.

amount scales whatever each vertex declared, so 0 switches it off for a pass without rebuilding a mesh, the same way setSurfaceGrain does. cyclesPerMetre is how many bumps fit in a metre of world.

The roughness of a surface grows with its relief, automatically and not optionally: a normal that wanders cannot hold a highlight narrower than the wander, and a narrow one on a fast-varying normal is the sub-pixel sparkle 0.20.0 was spent removing. The amount is known here rather than measured off the screen, which is what makes it safe; see the specular speckle entry in docs/IMPROVEMENTS.md for the version that measured it instead and made things three times worse.

Defaults to off and bindMeshPass restores that, so a scene that never calls this is unchanged.

setSurfaceTextureRelief

setSurfaceTextureRelief(scale: number): void

How hard the bound surface texture's own luminance turns the shading normal, 0 for not at all.

ParameterTypeDescription
scalenumber
More

Relief a photograph can give, where setSurfaceRelief invents it. Noise is the right answer for asphalt and cast concrete, whose structure has no particular arrangement. A rock, a bark, a hammered plate has one: the bumps are where the picture says they are, and no noise function can know where that is. This reads the height off the image setSurfaceTexture already bound as colour, so a surface lights as the thing the photograph is of.

One image in both roles rather than a second sampler, which is a decision with a cost behind it: the fragment shader's widest permutation already declares more samplers than this project's adapter offers and is legal only because several of them are marked as shared. It is also the ordinary case rather than an unusual one, since a scene handing one image to three.js's map and bumpMap together is exactly what this replaces.

Scaled as three.js's bumpScale is, so a scene porting one carries the number over rather than refitting it by eye, and a negative value inverts the relief there and here alike. Deliberately not clamped to 0..1 the way setSurfaceRelief and setSurfaceGrain are: those scale something the geometry declared, and this is an amplitude against a luminance gradient with no natural ceiling.

Nothing happens without a texture bound, because the shader gates on the same flag setSurfaceTexture(null) clears. Defaults to off and bindMeshPass restores that, so a scene that never calls this is unchanged.

setEmissiveGain

setEmissiveGain(gain: number): void

Scale the emissive of the following draws, or reset to what the environment says.

ParameterTypeDescription
gainnumber
More

Pass state, like setSurfaceTexture, and for the same reason: emissive strength belongs to a material and a material covers many draws. bindMeshPass sets it from env.emissiveGain and this overrides it between draws.

It exists because emissive is per-vertex data, so anything that pulses — a threshold breathing, a ceiling answering a kick drum — could not be expressed at all without rebuilding geometry every frame. The light around such a thing could pulse and the glowing surface itself could not, which reads as a lamp brightening while its own bulb stays flat.

createInstanced

createInstanced(mesh: Mesh, capacity: number, options?: InstancedOptions): InstancedBatch

Attach per-instance placement to a mesh already on the device.

ParameterTypeDescription
meshMesh
capacitynumber
options?InstancedOptions
More

One batch per mesh: the attributes bind to the mesh's own vertex array, of which a mesh has one. Mesh.attachInstances is what refuses a second, and says why.

createBoneAnimation

createBoneAnimation(clip: BoneAnimationClip): BoneAnimationHandle

A bone animation an instanced batch can play (InstancedOptions.animation): per frame, a turn and a place for each bone, which every vertex of the batch's mesh follows — the bone its second coordinates' u names, times boneScale — at each instance's own moment of the clip. What a crowd is made of: every instance moves at the frame's rate on its own clock, in the colour pass and in every shadow, with nothing on the processor after this. See boneAnimation.ts for the clip's layout and the arithmetic, which both backends run.

ParameterTypeDescription
clipBoneAnimationClip
More

What it gives up against skinning is the blend: a vertex follows one bone. Refused, with what was wrong, for a clip whose numbers are not its size, or past 2,048 bones or frames.

disposeBoneAnimation

disposeBoneAnimation(animation: BoneAnimationHandle): void

Release a clip. A batch still playing it draws nothing, and casts nothing, after.

ParameterTypeDescription
animationBoneAnimationHandle

setAnimationTime

setAnimationTime(seconds: number): void

The clock a bone animation plays by this frame, in seconds on the caller's clock: an instance's moment is this times its rate plus its phase (MeshInstances.clocks). Set it before the shadows are drawn, once a frame, as the wind is set: a shadow drawn under one clock and its caster under another come apart at the frame's rate. 0 until set, which holds every crowd at the moment its phases name.

ParameterTypeDescription
secondsnumber

uploadInstanced

uploadInstanced(batch: InstancedBatch, data: MeshInstances): void

Push placement and colour. Only the live prefix.

ParameterTypeDescription
batchInstancedBatch
dataMeshInstances

drawInstanced

drawInstanced(batch: InstancedBatch, data: MeshInstances): void

Draw every live instance, opaque.

ParameterTypeDescription
batchInstancedBatch
dataMeshInstances

drawTranslucentInstanced

drawTranslucentInstanced(batch: InstancedBatch, data: MeshInstances, opacity: number, options?: TranslucentMeshOptions): void

Draw every live instance, blended. See drawTranslucentMesh for what the options mean.

ParameterTypeDescription
batchInstancedBatch
dataMeshInstances
opacitynumber
options?TranslucentMeshOptions

disposeInstanced

disposeInstanced(batch: InstancedBatch): void

Release the placement. The mesh is the caller's and is not released.

ParameterTypeDescription
batchInstancedBatch

disposeMesh

disposeMesh(mesh: Mesh): void
ParameterTypeDescription
meshMesh

disposePlumes

disposePlumes(plumes: PlumeRenderer): void

Release a resource this renderer created, for every kind that needs the context back.

ParameterTypeDescription
plumesPlumeRenderer
More

Reported from outside, and it was a real leak. disposeMesh and disposeSurfaceTexture existed and nothing else did, so eight other kinds of resource were handed out with no way to release them: each one's own dispose takes the WebGL2RenderingContext, which this package deliberately never exposes. The engine's own demos could call them because they hold their own gl from creating the canvas; nobody outside could. It costs nothing when a consumer tears the whole renderer down, since releasing the context takes the programs and buffers with it, and it costs a program per rebuild for anyone who swaps weather or scenery without rebuilding the renderer. That is the ordinary case for a scene that changes, and the consumer who reported it was leaking one per scenario switch and living with it.

Each guards on a lost context for the same reason disposeMesh does: the old context took its objects with it, and asking the new one to delete them is an INVALID_OPERATION apiece, which buries whatever caused the loss under a page of noise.

disposeWindStreaks

disposeWindStreaks(streaks: WindStreakRenderer): void

Release a wind streak lattice. See disposePlumes.

ParameterTypeDescription
streaksWindStreakRenderer

disposeFlock

disposeFlock(flock: FlockRenderer): void

Release a flock. See disposePlumes.

ParameterTypeDescription
flockFlockRenderer

disposeScatter

disposeScatter(scatter: InstancedMesh): void

Release an instanced batch made by createScatter. See disposePlumes.

ParameterTypeDescription
scatterInstancedMesh

disposeParticles

disposeParticles(particles: ParticleBatch): void

Release a particle batch. See disposePlumes.

ParameterTypeDescription
particlesParticleBatch

disposeBolts

disposeBolts(bolts: BoltBatch): void

Release a bolt batch. See disposePlumes.

ParameterTypeDescription
boltsBoltBatch

disposeLines

disposeLines(lines: LineBatch): void

Release a line batch. See disposePlumes.

ParameterTypeDescription
linesLineBatch

disposeWater

disposeWater(water: WaterRenderer): void

Release a water renderer. See disposePlumes.

ParameterTypeDescription
waterWaterRenderer

disposeCaustics

disposeCaustics(caustics: CausticsRenderer): void

Release a caustics renderer. See disposePlumes.

ParameterTypeDescription
causticsCausticsRenderer

createPlumes

createPlumes(plumes: readonly PlumePlacement[], options: PlumeOptions): PlumeRenderer
ParameterTypeDescription
plumesreadonly PlumePlacement[]
optionsPlumeOptions

createWindStreaks

createWindStreaks(options?: WindStreakOptions): WindStreakRenderer
ParameterTypeDescription
options?WindStreakOptions

drawWindStreaks

drawWindStreaks(streaks: WindStreakRenderer, camera: Camera, wind: WindField, timeSeconds: number, tint: Vec3, env: Environment): void

Draw after the opaque scene: streaks are blended and write no depth.

ParameterTypeDescription
streaksWindStreakRenderer
cameraCamera
windWindField
timeSecondsnumber
tintVec3
envEnvironment

createText

createText(): TextRenderer

A 3D text object: one string, drawn as instanced glyph cubes over the scene. Create one per message slot and reuse it — setText is a no-op when the string has not changed.

setText

setText(text: TextRenderer, content: string): void

Lay a string out. A no-op when it is the string already laid out.

ParameterTypeDescription
textTextRenderer
contentstring

setPlate

setPlate(text: TextRenderer, widthCells: number, heightCells: number, bottomCell: number): void

A solid rectangle of cells, for a keycap or a backing plate. See TextLayout.setPlate.

ParameterTypeDescription
textTextRenderer
widthCellsnumber
heightCellsnumber
bottomCellnumber

drawText

drawText(text: TextRenderer, viewportWidth: number, viewportHeight: number, originX: number, originY: number, style: TextStyle, timeSec: number): void

Draw a laid-out string over the scene.

ParameterTypeDescription
textTextRenderer
viewportWidthnumber
viewportHeightnumber
originXnumber
originYnumber
styleTextStyle
timeSecnumber
More

The viewport is the caller's rather than this renderer's: an overlay is positioned in whatever box the caller lays out in, and the game's own card is not always the canvas.

snapTextCellSize

snapTextCellSize(cellSize: number, viewportWidth: number): number

The cell size to draw a bitmap glyph at so every cell covers whole device pixels.

ParameterTypeDescription
cellSizenumber
viewportWidthnumber
More

The caller snaps, and then measures and draws with the one number. 4.1.4 applied this inside drawText instead, where textWidthPx could not see it — it takes no viewport and so cannot know the ratio — and every consumer went on laying out against the cell it asked for while the engine drew a smaller one. A centred line, a right-aligned column and a line fitted to a box broke together. Snapped here, the arithmetic and the picture cannot disagree.

It is on the renderer because only the renderer knows the second number. viewportWidth is the caller's, and it already passes it to drawText; the drawing buffer is not on the shared surface and a game's text layout is a pure function over CSS pixels, so reaching it meant threading a canvas through an interface layer to a value the renderer was holding.

The decision itself stays in textLayout.ts and only this binding is per backend, which is the 2026-08-13 rule: a cell already whole comes back untouched, and the snap is downward so a line fitted to a box can only leave a gap rather than overflow it.

textWidth

textWidth(text: TextRenderer, cellSize: number): number

Width of the string this handle currently holds, in pixels at a given cell size.

ParameterTypeDescription
textTextRenderer
cellSizenumber

disposeText

disposeText(text: TextRenderer): void
ParameterTypeDescription
textTextRenderer

createSdfText

createSdfText(): SdfTextRenderer

A text label from an SDF font. Opt-in: a consumer that never calls this pays nothing, which is what keeps pixelFont.ts's no-asset promise true for everybody else.

setSdfText

setSdfText(handle: SdfTextRenderer, font: SdfFont, atlas: SurfaceTexture, content: string, style: SdfTextStyle): void

Lay a string out against a font, and retain the atlas drawSdfText will sample.

ParameterTypeDescription
handleSdfTextRenderer
fontSdfFont
atlasSurfaceTexture
contentstring
styleSdfTextStyle
More

The engine fetches nothing: font was parsed from a metrics document the caller already had, and atlas is a SurfaceTexture the caller already uploaded. A no-op, and no re-upload, when neither the text nor the style moved since the last call.

drawSdfText

drawSdfText(handle: SdfTextRenderer, model: Float32Array, color: Vec3, opacity: number): void
ParameterTypeDescription
handleSdfTextRenderer
modelFloat32Array
colorVec3
opacitynumber

disposeSdfText

disposeSdfText(handle: SdfTextRenderer): void

Release an SDF text label. Takes no context; see disposeText.

ParameterTypeDescription
handleSdfTextRenderer

createFlock

createFlock(count: number): FlockRenderer
ParameterTypeDescription
countnumber

drawFlock

drawFlock(flock: FlockRenderer, camera: Camera, timeSeconds: number, params: FlockParams, tint: Vec3, windX?: number, windZ?: number): void
ParameterTypeDescription
flockFlockRenderer
cameraCamera
timeSecondsnumber
paramsFlockParams
tintVec3
windX?number
windZ?number

createScatter

createScatter(base: MeshData, data: InstanceData): InstancedMesh

Build a scatter batch and fill it. Takes the data rather than handing back something the caller has to upload, because uploading needs the GL context and that never leaves this directory (AGENTS.md).

ParameterTypeDescription
baseMeshData
dataInstanceData

uploadScatter

uploadScatter(scatter: InstancedMesh, data: InstanceData): void

Push changed instances to the GPU.

ParameterTypeDescription
scatterInstancedMesh
dataInstanceData
More

Only for batches that move. Foliage is placed once at generation and never touched again, so createScatter uploads it and drawScatter deliberately does not — a per-frame upload of every blade of grass in the world would be pure waste.

A live particle system is the other case, and it was silently broken: the drift plume's grains were drawn from the positions they happened to hold at boot, because nothing ever re-uploaded them. Only the count animated, so a drift threw a growing pile of stationary grit instead of a spray. Explicit rather than automatic, so the cost stays visible at the call site that needs it.

createParticles

createParticles(capacity: number, options: ParticleBatchOptions): ParticleBatch

Build a particle material: one program, one VAO, one draw call per pool.

ParameterTypeDescription
capacitynumber
optionsParticleBatchOptions
More

A material rather than a renderer, and the distinction is the reason this exists at all. Particles used to be drawn through drawScatter — the foliage material — which is opaque, lit like a leaf and has no opacity channel, so every effect in the game that emitted particles rendered as small solid cubes that turned black instead of fading. The fix is not a tuning pass on the emitters; it is a material that knows what a particle is.

drawDeviceParticles

drawDeviceParticles(_batch: ParticleBatch, _particles: DeviceParticles, _camera: Camera, _env: Environment, _timeSeconds: number): void

Particles a caller's own compute wrote: WebGPU's alone, since this backend has no compute stage to have written them (computeSupported). Said once, and nothing is drawn.

ParameterTypeDescription
_batchParticleBatch
_particlesDeviceParticles
_cameraCamera
_envEnvironment
_timeSecondsnumber

drawParticles

drawParticles(batch: ParticleBatch, data: ParticleInstances, camera: Camera, env: Environment, timeSeconds: number): void

Draw a live particle pool.

ParameterTypeDescription
batchParticleBatch
dataParticleInstances
cameraCamera
envEnvironment
timeSecondsnumber
More

Uploads and draws together, unlike the scatter path where the two are deliberately separate: a particle pool's data changes every frame without exception, so leaving the upload to the caller only creates the opportunity to forget it — which is exactly what happened to the skate sparks, drawn for a whole milestone from a buffer nothing had ever written.

createBolts

createBolts(segmentCapacity: number, label?: string): BoltBatch

Build a batch for electrical arcs. capacity is in segments, not arcs.

ParameterTypeDescription
segmentCapacitynumber
label?string

drawBolts

drawBolts(batch: BoltBatch, data: BoltSegments, camera: Camera, env: Environment, timeSeconds: number, core: Vec3, edge: Vec3, widthM: number, coreGain: number, minWidthPerMetre?: number): void

Draw a pool of arcs.

ParameterTypeDescription
batchBoltBatch
dataBoltSegments
cameraCamera
envEnvironment
timeSecondsnumber
coreVec3
edgeVec3
widthMnumber
coreGainnumber
minWidthPerMetre?number
More

widthM is the filament's own half-width and minWidthPerMetre the floor that keeps a distant arc above a pixel — under one, a bright thin line does not fade, it strobes as the rasteriser catches it on some frames and not others.

createLines

createLines(segmentCapacity: number, label?: string): LineBatch

A polyline with a real width, for anything that is a stroke rather than a surface.

ParameterTypeDescription
segmentCapacitynumber
label?string
More

Separate from drawBolts rather than a flag on it. The two share a vertex expansion and nothing else: an arc is additive, unlit, unfogged and jittered along its own path, a line is a flat fogged colour with a clean edge. A jitter: 0 argument meaning "actually draw a line" would be a noun lying about what it draws, which AGENTS.md rules out for good reasons.

There is no portable alternative. WebGL2 clamps lineWidth to one pixel on nearly every driver and WebGPU has no line width at all, so a wide line is a triangle everywhere or it is nowhere.

A handle holds one polyline's geometry for the frame it is drawn in. Drawing it more than once in a frame is fine — three widths of one shape is exactly that, and costs nothing beyond the re-upload — because the data underneath does not change between those calls. Changing what it holds and drawing it more than once in the same frame is not fine: on WebGPU, queue.writeBuffer does not interleave with a pass's already-recorded draw commands, so every draw against this handle in a frame reads whichever write landed last by the time the frame submits, not the content that was current when each call was made. WebGL2 draws immediately on each call and happens to give the answer a caller expects, so the two backends disagree silently rather than loudly. A caller drawing several polylines whose geometry differs within one frame needs one handle each, not one handle reused.

drawLines

drawLines(lines: LineBatch, data: LineSegments, model: ReadonlyMat4, camera: Camera, env: Environment, color: Vec3, widthM: number, opacity: number, softness?: number, minWidthPerMetre?: number, additive?: boolean): void

Draw a polyline.

ParameterTypeDescription
linesLineBatch
dataLineSegments
modelReadonlyMat4
cameraCamera
envEnvironment
colorVec3
widthMnumber
opacitynumber
softness?number
minWidthPerMetre?number
additive?boolean
More

widthM is the stroke's own half-width and minWidthPerMetre the floor that keeps a distant line above a pixel — under one, a thin line does not fade, it strobes as the rasteriser catches it on some frames and not others. softness widens the antialiased edge inward, as a fraction of the half-width; zero is a clean edge.

drawScatter

drawScatter(scatter: InstancedMesh, data: InstanceData, camera: Camera, env: Environment, windX: number, windZ: number, windGust: number, timeSeconds: number, /** * Recent presses, from a `TrampleField`, or null for foliage nothing walks * through. Four floats per slot: world position and how pressed. */ trample?: Float32Array | null): void

Draw a scatter batch. Wind arrives as a value rather than being sampled here: every system in the world must answer to the same gust, and a renderer that sampled its own would be a second wind by definition.

ParameterTypeDescription
scatterInstancedMesh
dataInstanceData
cameraCamera
envEnvironment
windXnumber
windZnumber
windGustnumber
timeSecondsnumber
/** * Recent presses
from a `TrampleField`
or null for foliage nothing walks * through. Four floats per slot: world position and how pressed. */ trample?: Float32Array | null
More

It binds no point lights, and that is a rule about what may be instanced rather than a performance note. A field of grass shaded by ten lamps is a cost nobody wanted, so this material answers to the sun and the ambient alone. The consequence is invisible from the call site and a consumer has to know it: anything a lamp in the scene is meant to reach has to be merged into a mesh instead. Choosing wrongly is not slow, it is unlit. Reported from outside, where it had become a rule written on a consumer's own wrapper.

setWind

setWind(windX: number, windZ: number, windGust: number, timeSeconds: number): void

The frame's wind, sampled once by the caller and handed down.

ParameterTypeDescription
windXnumber
windZnumber
windGustnumber
timeSecondsnumber
More

Every mesh carrying a per-vertex channel bends to this, in the colour pass and in the depth pass, so a canopy and its own shadow move together. A caller that never calls it gets a still world, which is exactly what every scene drew before the channel existed.

createWater

createWater(resolution?: number, nearExtent?: number, farHalfExtent?: number): WaterRenderer
ParameterTypeDescription
resolution?number
nearExtent?number
farHalfExtent?number

createCaustics

createCaustics(sheets: readonly CausticSheet[]): CausticsRenderer | null

Surfaces lit from below by the water under them.

ParameterTypeDescription
sheetsreadonly CausticSheet[]
More

Null when the profile has water off or there is nothing to light, so a world with no covered water pays no program compile, no buffer and no draw. The matching draw accepts null for the same reason: a caller should not need a branch to express "this world has none".

drawPlumes

drawPlumes(plumes: PlumeRenderer, camera: Camera, timeSeconds: number, env: Environment, windX?: number, windZ?: number, originX?: number, originY?: number, originZ?: number): void
ParameterTypeDescription
plumesPlumeRenderer
cameraCamera
timeSecondsnumber
envEnvironment
windX?number
windZ?number
originX?number
originY?number
originZ?number

drawWater

drawWater(water: WaterRenderer, camera: Camera, timeSeconds: number, settings: WaterBody, env: Environment, windX?: number, windZ?: number): void
ParameterTypeDescription
waterWaterRenderer
cameraCamera
timeSecondsnumber
settingsWaterBody
envEnvironment
windX?number
windZ?number

drawCaustics

drawCaustics(caustics: CausticsRenderer | null, camera: Camera, timeSeconds: number, env: Environment, windX?: number, windZ?: number, strength?: number): void

Draw after the opaque scene: this is light added to surfaces already shaded, from the water under them.

ParameterTypeDescription
causticsCausticsRenderer | null
cameraCamera
timeSecondsnumber
envEnvironment
windX?number
windZ?number
strength?number

beginPlanarReflection

beginPlanarReflection(source: Camera, planeY: number, clearColor: Vec3): Camera | null

Two things sample the target: water, and a wet film. It is allocated when water and waterReflections are both on, or when RenderQuality.planarReflections asks for it in a scene with no water, and drawFilm reads it through FilmOptions.reflectionStrength for a film on the plane it was rendered for. This said the pass was water-only after both of those had landed, which is how a stale limitation outlives its cause: it read as a decision.

ParameterTypeDescription
sourceCamera
planeYnumber
clearColorVec3
More

A surface on any other plane, or that is not a film, reflects a probe (bakeReflectionProbe), which is a room rather than a mirror.

endPlanarReflection

endPlanarReflection(): void

resize

resize(): void

Match the drawing buffer to CSS size × clamped DPR, under the pixel budget. Cheap when unchanged.

More

The budget is deliberately not applied to a locked buffer: a lock is a caller naming an exact size for a reason no quality setting knows about — a clip is 1080x1920 whatever anybody's frame rate is — and silently handing back a different one would write a file that is not the size it claims.

setMaxDrawingBufferPixels

setMaxDrawingBufferPixels(pixels: number): void

Move the drawing-buffer area cap, in pixels. Zero is uncapped.

ParameterTypeDescription
pixelsnumber
More

Live, unlike every other quality lever, and that is the feature rather than an inconsistency. The rest size an allocation at construction — shadow maps, reflection targets, the wave surface — so moving one means rebuilding every GPU resource the world hangs off. This one only changes how many pixels the next frame covers, and everything sized from the drawing buffer already re-fits itself when it changes. A player hunting their own frame rate can therefore move this and see the answer, which is the only way anybody finds out that pixels were what it cost them.

applyResolutionScale

applyResolutionScale(scale: number): void

Move the drawing-buffer density cap at runtime, without rebuilding anything.

ParameterTypeDescription
scalenumber
More

Live for the same reason setMaxDrawingBufferPixels is, and it is the other half of the same idea: resize() re-reads this cap, and the scene target and the planar reflection are both sized from the drawing buffer, so nothing here owns an allocation that a new density invalidates.

This is the one quality lever a governor can drive, which is exactly why the governor drives this and not the preset. A preset means shadow maps and wave surfaces sized at construction; changing one of those mid-session means rebuilding every GPU resource the world hangs off, and that is what a reload is for.

Cheap to call every few hundred frames and pointless to call every frame: it forces a resize(), which reallocates the scene target when the size actually changes.

lockDrawingBuffer

lockDrawingBuffer(width: number, height: number): void

Pin the drawing buffer to an exact pixel size, ignoring CSS and DPR.

ParameterTypeDescription
widthnumber
heightnumber
More

For recording a frame at a size that has nothing to do with the window: a clip is 1080x1920 whatever shape the browser is, and aspect follows the lock, so a camera given renderer.aspect composes for the export rather than for the screen. That is the difference between rendering at an aspect and cropping to one, and cropping throws away the half of the frame the shot was built around.

Not a quality option, deliberately. RenderQualityOptions describes how good the picture is for the whole session and is resolved before any GPU resource exists; this is a temporary, caller-driven override that must be released. The CSS box is untouched — a page that wants the preview letterboxed is styling, not rendering.

unlockDrawingBuffer

unlockDrawingBuffer(): void

Back to following the CSS box. The next resize restores it.

beginShadowPass

beginShadowPass(lightViewProj: ReadonlyMat4, layer?: 'static' | 'static-peel' | 'dynamic'): void

Render one directional shadow layer. Bracket drawShadowCasters calls between this and endShadowPass. Static and dynamic layers are sampled together; pass shadowStrength: 0 whenever the dominant source is not emitting, because a stale map must never be sampled.

ParameterTypeDescription
lightViewProjReadonlyMat4
layer?'static' | 'static-peel' | 'dynamic'

drawSceneCasters

drawSceneCasters(casters: ShadowCasters): void

Draw everything a caster enumeration contains into the open colour pass, with its materials.

ParameterTypeDescription
castersShadowCasters
More

The colour counterpart of drawShadowCasters, so one closure answers "what is in this frame" for the shadow cascade, every cubemap face, and a second viewpoint such as a mirror or a probe face. What it replaces is a consumer re-entering its whole draw path with a second camera.

drawShadowCasters

drawShadowCasters(casters: ShadowCasters): void
ParameterTypeDescription
castersShadowCasters

createStaticDraws

createStaticDraws(casters: ShadowCasters): StaticDrawsHandle

Draws that do not change between frames, enumerated once and kept: what a stage, a set or any world that stands still draws, recorded so a frame replays it rather than issuing it.

ParameterTypeDescription
castersShadowCasters
More

The same enumeration drawSceneCasters takes, called once, here: rigid meshes and instanced batches, each with its material. A skinned mesh or a scatter batch is refused by name, since both change every frame. What it captures is what the enumeration said at the time — the matrices and the instance counts are copied, so a caller's scratch is free to move on — while the meshes, batches, textures and materials stay the caller's: a list must be disposed, or made again, before anything it holds is, and made again when a material it holds is changed.

What it is for is the frame's processor time. WebGPU records the list into a render bundle per view and replays it with one call, where a draw issued a frame at a time costs its encoding every frame and again for every view that draws it. This backend has no bundles and replays the entries through the scene-caster path every frame, at the cost a frame drawing them itself pays; the picture is the same.

What a list gives up against drawing each frame is culling: every entry is drawn in every view, because a bundle cannot leave one out. A world split into lists by region culls by list.

drawStaticDraws

drawStaticDraws(draws: StaticDrawsHandle): void

Draw a list into the open mesh pass, under the camera, the lights and the fog bindMeshPass set — the frame's, a mirror's, a capture's. Opaque draws, through each entry's material.

ParameterTypeDescription
drawsStaticDrawsHandle
More

Its draws are rigid and wear no overlay, so a skin palette, morph weights, cloth or a surface overlay set before it is put back to none, as bindMeshPass puts an overlay back; and no material is left set after it, which is what setMaterial(null) leaves. The state a pass set — the grain, the relief, the fog, the grade — is the list's to draw with, as it is any draw's, and a list drawn under a different one is recorded again for it on WebGPU.

disposeStaticDraws

disposeStaticDraws(draws: StaticDrawsHandle): void

Release a list. Its meshes, batches and materials are the caller's and are not released.

ParameterTypeDescription
drawsStaticDrawsHandle

endShadowPass

endShadowPass(): void

prepareStaticPointShadows

prepareStaticPointShadows(lights: readonly ShadowLight[], /** * The rectangles this world will shade, so the ones that cast get layers of the same array. * * **A second parameter on this call rather than a call of its own, because there is one array * and `texStorage3D` sizes it once.** A separate `prepareAreaShadows` would either rebuild the * array — throwing away every baked lamp — or depend on being called first, and a consumer * getting that order wrong would see the rectangles cast and the lamps stop. Optional, so * every existing caller is unchanged and allocates exactly what it did before. * * The name says point and this takes area lights too. It stays, because what it prepares is * *the* shadow array and both kinds of light are layers in it — the alternative was a second * name for one thing. */ areaLights?: readonly AreaLightSource[]): void

Size the point-shadow pool for a world's lights. Call at load.

ParameterTypeDescription
lightsreadonly ShadowLight[]
/** * The rectangles this world will shade
so the ones that cast get layers of the same array. * * **A second parameter on this call rather than a call of its own
because there is one array * and `texStorage3D` sizes it once.** A separate `prepareAreaShadows` would either rebuild the * array — throwing away every baked lamp — or depend on being called first
and a consumer * getting that order wrong would see the rectangles cast and the lamps stop. Optional
so * every existing caller is unchanged and allocates exactly what it did before. * * The name says point and this takes area lights too. It stays
because what it prepares is * *the* shadow array and both kinds of light are layers in it — the alternative was a second * name for one thing. */ areaLights?: readonly AreaLightSource[]
More

It used to bake a map for every light here and keep them all forever, which is why it was called bakeStaticPointShadows. That allocated one cubemap per lamp — about 327 MB for 50 of them — so that the eight the shader can bind could be read. See POINT_SHADOW_POOL.

Nothing is baked now. Maps are borrowed from the pool and baked by updatePointShadows the first time a light is actually sampled, which is the only moment the result can be seen. Load is correspondingly faster: it no longer renders six passes over the static world for every lamp in the day, most of which were never looked at.

updatePointShadows

updatePointShadows(lights: readonly ShadowLight[], activeWorldIndices: Int32Array, activeCount: number, liveX: number, liveY: number, liveZ: number, frameDt: number, staticCasters: ShadowCasters, dynamicCasters: ShadowCasters, warmWorldIndices?: Int32Array, warmCount?: number, /** * The rectangles this frame shades, in the order `selectAreaLights` was given them. * * **The same list and the same order, which is what makes a slot mean one thing.** The lit * pass reads `uAreaShadow*[a]` at the slot it is shading, and those arrays are filled from this * list's position — so handing a different order here than to `selectAreaLights` puts one * fixture's shadow under another. Optional, so an existing caller is unchanged. * * Their bakes ride this call rather than one of their own because the budget, the casters and, * on the other backend, the encoder are all already here. See `AreaShadowSet.update`. */ areaLights?: readonly AreaLightSource[]): void

Per frame: bind the shading pass's exact light set, refresh any sampled map whose source moved, and rebuild the live-caster map nearest the supplied focus. A second live map exists only during its ownership crossfade. Fixed-light world maps remain untouched.

ParameterTypeDescription
lightsreadonly ShadowLight[]
activeWorldIndicesInt32Array
activeCountnumber
liveXnumber
liveYnumber
liveZnumber
frameDtnumber
staticCastersShadowCasters
dynamicCastersShadowCasters
warmWorldIndices?Int32Array
warmCount?number
/** * The rectangles this frame shades
in the order `selectAreaLights` was given them. * * **The same list and the same order
which is what makes a slot mean one thing.** The lit * pass reads `uAreaShadow*[a]` at the slot it is shading
and those arrays are filled from this * list's position — so handing a different order here than to `selectAreaLights` puts one * fixture's shadow under another. Optional
so an existing caller is unchanged. * * Their bakes ride this call rather than one of their own because the budget
the casters and
* on the other backend
the encoder are all already here. See `AreaShadowSet.update`. */ areaLights?: readonly AreaLightSource[]

beginInset

beginInset(rect: InsetRect, clearColor: Vec3 | null): number

Draw into a rectangle of the canvas, on its own terms.

ParameterTypeDescription
rectInsetRect
clearColorVec3 | null
More

For a portrait: a character preview standing in a box on a menu, lit and framed by whatever the caller wants, with nothing of the world in it. The scissor is what makes that true — the clear only touches the box, so the frame already drawn survives around it, and only the meshes the caller issues appear inside.

Kept as a pair of calls rather than a callback so the caller's draw sequence reads the same inside a box as outside one: beginInset, bind a pass, draw meshes, endInset. It returns the aspect of the rectangle it set up, for the camera that will fill it.

The rectangle is in CSS pixels with the DOM's top-left origin — a DOMRect, in other words, which is what a caller actually has. The first version took GL's own convention and left the caller to scale by the device pixel ratio and flip the y axis; that is arithmetic about this object's canvas, so it belongs here. A caller doing it has two chances to be wrong about somebody else's state, and on a high-DPI screen the mistake is a box in the wrong place at the wrong size.

clearColor: null keeps the picture and clears only depth, which is the difference between an object in the frame and an object in a box cut out of it. A backdrop is right for a preview inside a panel, where the box is furniture and a dark case is what makes a small object read. It is wrong the moment the same inset is used over a live frame: the caller gets a filled rectangle pasted onto the picture, whatever colour it picks, because any colour that is not the scene is a rectangle. Depth alone still puts the meshes in front of everything already drawn inside the box, which is what a caller compositing over its own frame wants.

copyRegionTo

copyRegionTo(rect: InsetRect, target: HTMLCanvasElement): void

Hand a rectangle of the frame to a 2D canvas, at its own pixel size.

ParameterTypeDescription
rectInsetRect
targetHTMLCanvasElement
More

For content that has to appear in front of the page rather than behind it. The menu's panels carry backdrop-filter: blur(6px), so anything drawn into the canvas underneath a panel — including an inset in a hole cut through one — is shown blurred, by design: the world behind a menu is meant to be out of focus. A character preview shown through that hole inherits the blur along with everything else.

A copy is what escapes it. The pixels become an ordinary image inside a DOM element, so the blur has nothing to do with them. It is one drawImage of a few tens of thousands of pixels, GPU-side, which is cheaper than any of the alternatives — a second WebGL context, or a mask cut out of the blurring layer and kept in step with the element it is hiding.

Must be called in the same task as the draw. A WebGL drawing buffer is only guaranteed readable until the page composites; afterwards this copies black. That is the same rule the still export learned the hard way.

fillPanel

fillPanel(rect: InsetRect, color: Vec3, alpha: number): void

Fill a rectangle of the frame with one flat colour, blended.

ParameterTypeDescription
rectInsetRect
colorVec3
alphanumber
More

The plain surface an overlay is read against. Neither of the two things that could nearly do this actually can: beginInset clears, so it is opaque by definition, and TextRenderer.setPlate builds its rectangle from one lit cube per cell — correct for a keycap and a grid of seams at panel size.

rect is in CSS pixels from the top-left of the canvas — cssWidth and cssHeight's space, which is what an overlay laying itself out already has. Deliberately not beginInset's viewport-relative DOMRect: that one takes what its callers hold, which is a measured DOM element, and this one takes what its callers hold, which is a rectangle they computed against the frame. The two spaces differ exactly when the canvas does not fill the window — a letterboxed export — so a single convention would be silently wrong on one side or the other in the case that matters most.

Depth-test off and depth writes off: this is furniture drawn over a finished frame, and it must neither be occluded by the scene nor occlude what is drawn after it.

endInset

endInset(): void

Give the whole canvas back. Safe to call without a matching beginInset.

beginViewModel

beginViewModel(share?: number): void

Draw what follows as a view model, in the nearest sliver of depth, until endViewModel.

ParameterTypeDescription
share?number
More

A first-person view model — arms and a held weapon — drawn with the frame's camera, or with one of its own through bindMeshPass, lands in front of anything past the near plane without the world's depth being cleared, so every pass that reads depth afterwards still sees the world. share is how much of the range it takes, 1% by default. See viewModel.ts for what it gives up, which includes temporal reconstruction: draw a view model with multisampling.

Not inside an inset: an inset has its own depth, cleared for it, and needs no squeeze.

endViewModel

endViewModel(): void

Give the whole depth range back. Safe to call without a matching beginViewModel.

setSpeedRush

setSpeedRush(strength: number): void

How much speed blur the frame about to be drawn should resolve with, 0 to 1.

ParameterTypeDescription
strengthnumber
More

Set before beginFrame, held until changed, and ignored entirely when screen effects are off. A renderer parameter rather than a scene one: it describes how the finished image is presented, not anything in the world, which is why nothing about the world has to know it exists.

setCameraMotionBlur

setCameraMotionBlur(scale: number, maxShare?: number): void

How much of the frame's camera motion blur to apply, 0 to 1. Scales cameraMotionBlur.

ParameterTypeDescription
scalenumber
maxShare?number
More

A ceiling and a dial, rather than one number. cameraMotionBlur is construction-time because it decides whether the pass exists at all; this decides how much of it a given frame wants, and a frame is exactly the granularity that matters. Blur that is always on is not a speed cue, it is a filter: a viewer stops reading it within seconds, and it costs eight taps on every moving pixel while doing so. Reported from play, twice, by two different people, one of whom turned the setting off inside a minute.

So a caller ramps it with whatever "fast" means in its world. 1 keeps what a scene written before this got, so nothing changes for anyone who does not call it.

Set before beginFrame and held until changed, like setSpeedRush, which it sits beside for the same reason: both describe how the finished image is presented rather than anything in the world.

maxShare is the longest smear, as a share of the frame (0.03 unless given, at most half): a fast turn softens the frame rather than erasing it. Each drawn object's own motion is followed only under a reconstruction on WebGPU, which draws the motion target it reads; here, and on WebGPU without one, the camera's motion alone smears the frame, and a draw that names where it was last frame is told so once.

cameraCut

cameraCut(): void

The camera has cut: this frame is a new shot, not the next moment of the last one.

More

Everything temporal reprojects through the previous frame's view: the motion blur measures its smear against it, and the temporal resolve reads its history through it. A cut is a jump the renderer cannot tell from a fast camera, so without this a transport that seeks, a respawn or an edit smears its first frame along the whole jump and blends in a picture of somewhere else. The caller knows it cut; this is how it says so, before the frame's beginFrame.

What it gives up is one frame of history: the frame after a cut has no blur and no temporal blend, as the first frame of a session has none. Nothing else is reset.

A reconstructing renderer depends on it: its history is the last shot's, and without this call a cut blends that shot into the first frames of the next. Nothing detects a cut on the caller's behalf, deliberately — a fast pan and a cut look alike from here.

setAutoExposure

setAutoExposure(strength: number, dtSec: number): void

Eye adaptation: how far the frame's exposure follows its own brightness, 0 to 1, and the seconds since the last frame. 0 is off and the default, and at 0 nothing is metered or allocated.

ParameterTypeDescription
strengthnumber
dtSecnumber
More

The finished scene is metered on the GPU each frame — the log-mean of its luminance — and a held brightness moves toward it at a fixed rate, about 78% of the way in a second; the composite then scales scene light toward middle grey by `strength` of the stops between them, at most six either way. So a shaded courtyard floor opens up the way a camera or an eye would, and `setOutputExposure` becomes a bias on top: a night kept darker than a day is a bias under one. The time is the caller's, because the engine takes time from its caller; a held capture passes 0 and holds. `cameraCut` snaps it, so a new shot is metered rather than eased into.

What it gives up is regional metering: every pixel counts the same. Needs `screenEffects` and `hdrScene`, and says so once rather than doing nothing. See `shaders/exposure.ts`.

setLocalExposure

setLocalExposure(strength: number): void

Local exposure: how far each region of the frame is brought toward the frame's own brightness, 0 to 1. 0 is off and the default, and at 0 nothing more is measured or allocated.

ParameterTypeDescription
strengthnumber
More

A tone curve maps one range, and a sunlit courtyard is two: its shaded arcades hold a fiftieth of the light on its sunlit paving, so exposed for either the other is black or white. This moves each region strength of the stops between it and the frame's held brightness, at most three, read from a bilateral grid over the exposure meter's tiles, so shade beside sun is lifted as shade rather than as the average of the two. The eye's held brightness is what regions move toward, so it is measured whether or not setAutoExposure is on; with adaptation off the frame as a whole keeps the exposure it was given. About 0.5 is a photograph's latitude, and 1 is a frame with no light and shade left in it.

What it gives up is contrast between regions, which is the point, and a halo a tile wide where two regions of one brightness band differ. Needs screenEffects and hdrScene, and says so once rather than doing nothing. See shaders/localExposure.ts.

setAmbientOcclusionFade

setAmbientOcclusionFade(distance: number, radius: number): void

Where ambient occlusion fades out with distance: whole up to distance metres from the eye and gone radius metres past it, so the far depth buffer's steps — a sky dome kilometres off, a mountain range — are not shaded in rings, and those pixels skip the occlusion's sampling.

ParameterTypeDescription
distancenumber
radiusnumber
More

A distance that is not a finite number at or above zero is no fade, which is the default, and Infinity reads as "never fades"; a radius below zero is a cut at distance. Set before endFrame and held until changed, like setDepthOfField. Nothing at all when ambientOcclusion is 0. See occlusionFade.ts for what it gives up.

setDepthOfField

setDepthOfField(distance: number, range: number, scale?: number): void

Where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling to take.

ParameterTypeDescription
distancenumber
rangenumber
scale?number
More

The ceiling-and-dial split setCameraMotionBlur argues for, with a third number that is neither. depthOfField is construction-time and decides how far a defocused point may spread, which is what the effect costs; scale decides how much of that this frame wants, so 0 turns it off for a moment without paying for it. distance and range are not a fraction of anything — they are where the camera is looking, in metres, and a caller changes them when the subject moves.

Racking focus is the caller's, and that is the same boundary setOutputExposure draws. A game knows what its camera is looking at; the renderer could only find out by reading a depth sample back a frame late. Easing distance toward a target is two lines wherever that state already lives, and the clock belongs to the caller in this engine.

range is clamped above zero because it is the divisor of the ramp: at zero, every pixel not exactly on the plane would be at full blur, which is a different effect and not one anybody asked for.

Set before beginFrame and held until changed, like setSpeedRush and setCameraMotionBlur beside it. Ignored entirely when depthOfField is 0.

setBloom

setBloom(scale: number, threshold?: number, response?: BloomResponse | null): void

How much of the frame's bloom to apply, 0 to 1. Scales bloom, like setCameraMotionBlur scales cameraMotionBlur.

ParameterTypeDescription
scalenumber
threshold?number
response?BloomResponse | null
More

A scale rather than a replacement, and the distinction is worth being explicit about because the setter beside this one — setOutputExposure — is the other kind. A grade has no natural full value to take a fraction of, so exposure replaces. A strength does: how much a world blooms is that world's look, decided once, and what a frame gets to say is how much of it this moment wants. So bloom: 0.4 with setBloom(0.5) is 0.2, and a caller that never calls this keeps exactly what it asked for at construction.

That is also what keeps a quality profile meaningful: a part that cannot afford the effect has it capped in one place rather than in every frame that sets a dial.

Set before beginFrame and held until changed. Ignored entirely when bloom is 0, since the chain is never built.

threshold moves bloomThreshold for this frame and every one after, in the same scene units. A threshold is compared before exposure is applied, so one fixed at construction means a different brightness on screen at every exposure. That is harmless while exposure stands still and wrong for a day whose exposure spans 2.5 to 14: a courtyard blooming at noon, or candles never blooming at night. A caller whose exposure moves passes what it wants on screen over the exposure. Omitted leaves it where it was, so a caller that never passes one keeps the profile's.

response is how a colour comes in past the threshold and what colour each octave of the halo takes — a ramp in place of the subtraction, and a tint a level — held until moved like the threshold; null goes back to the subtraction and white. See BloomResponse: it is what matches a stage built for another engine's bloom, whose tints are how a strength past 1 is asked for.

setGlobalMedium

setGlobalMedium(density: number, albedo?: number, anisotropy?: number, maxDistance?: number): void

How thick the air is, this frame: a global participating medium filling the whole frustum.

ParameterTypeDescription
densitynumber
albedo?number
anisotropy?number
maxDistance?number
More

The dial beside globalMediumSteps' ceiling, on the split every other effect here uses. The profile says what a march may cost, and this says what the weather is doing — so a game can walk into a fog bank without the settings screen changing under it, and a device that cannot afford a medium never draws one however thick the game says the air is.

density is extinction per metre and 0 is a real off: no target is allocated, no march runs and no composite is drawn, so a frame at 0 is identical to a build with the feature absent rather than merely close to it. Everything else is optional and holds until changed: albedo is how much of what the air takes out comes back as light, anisotropy is the Henyey-Greenstein g that makes haze glow toward a low sun, and maxDistance is where the search for light to scatter stops — not where the fog stops, which is what extinction says.

This is the air, not a beam. drawLightVolume is a shaft somebody placed inside a hull; this is everything between the camera and whatever the frame already drew, and it is the one of the two that can put the shape of a doorway on a floor.

setOutputExposure

setOutputExposure(exposure: number): void

How far this frame's scene is scaled into the tone curve. Replaces outputExposure.

ParameterTypeDescription
exposurenumber
More

The same ceiling-and-dial split as setCameraMotionBlur, for the same reason. outputExposure is construction-time and is a grade: it says how this world's light levels are meant to sit in the curve. What it cannot express is that one scene has both a lit showroom and a dark interior in it, which is where a single exposure is wrong twice — blown out in the room and unreadable inside. So a frame gets to say what it wants, and a caller that never says anything keeps the grade it asked for at construction.

Adaptation is the caller's, and that is a deliberate boundary rather than a shortcut. A game knows it walked into a cave; the renderer can only find out by measuring pixels a frame late. Easing toward a target is two lines wherever the state already lives, and the clock belongs to the caller in this engine — see the determinism rules. What the engine could add on top, and has not, is the measurement half: a mip of the scene target read as an average luminance and smoothed in a 1x1 ping-pong, so a scene that cannot say what it is looking at gets a number anyway. RENDERING.md §4 has that written down as what is left.

Ignored unless outputTransform is aces, because there is no curve to be exposed into otherwise. Clamped above zero: a zero exposure is a black frame, and a negative one is a black frame with a sign error in it.

setFilmicCurve

setFilmicCurve(curve: FilmicCurve): void

The filmic curve this and every later frame is graded through, until it is set again: a grading volume's five numbers, solved once here into the constants the resolve reads. Ignored unless outputTransform is filmic. A number out of range is clamped into it and said once, since a frame loop must not throw. See filmicCurve.ts.

ParameterTypeDescription
curveFilmicCurve

setDisplayLuminance

setDisplayLuminance(paperWhite: number, peak: number): void

How bright paper white and the display's peak are, in one unit, held until changed: their ratio is how far above white the highlights may run where displayRange is 'high', and nothing changes where it is standard, which is always here.

ParameterTypeDescription
paperWhitenumber
peaknumber

setFrameVeil

setFrameVeil(r: number, g: number, b: number, alpha: number): void

Composite a flat colour over the finished frame — for a cut dipping to white or to black.

ParameterTypeDescription
rnumber
gnumber
bnumber
alphanumber
More

alpha 0 leaves the picture untouched and is the default; 1 replaces it outright. Nothing else on this renderer can reach either end: setOutputExposure's own guard clamps above zero, so black is unreachable by design, and white is not an exposure at all under aces, which is built to roll off rather than clip — driving exposure up gives a bloomed, tinted, still-legible frame, which is a different effect and a nice one. A veil is the operation that gap was missing, not a knob left unturned on an existing one.

Composited after the tone map, before grain and vignette. That is a ruling, not a measurement, and it is written out here because a caller reading this is exactly who needs it.

After the tone map, because a transition has to hit exact black and exact white deterministically: setFrameVeil(0, 0, 0, 1) must be pure black and setFrameVeil(1, 1, 1, 1) pure white. Compositing before a curve built to roll off makes "full white" an asymptote no caller could solve for — the same trap setOutputExposure is already in, and this must not be that.

Before grain and vignette, because that is the complaint this replaces: painted on top of an already-finished frame, a dip to black flattens grain and a vignette instead of taking them down with it, which reads as a sheet laid over the picture rather than the picture going dark. Composited here, ahead of anything that still runs after the grade, a dip fades the whole image together.

This also answers the bloom question by construction rather than by choice: bloom is resolved upstream of this pass, from the pre-tonemap scene — see SceneTarget.resolve — so a veil composited here never feeds it. That is the right side of its own framing: a dip is a cut rather than a light, and a veil that bloomed would mean dipping to white lit the whole frame instead of covering it.

Cleared with the frame, unlike setSpeedRush and setCameraMotionBlur beside it: one call per frame, and a frame that does not call it draws with no veil at all, rather than inheriting whatever the last cut left behind. A transition that forgets to turn itself off is a worse failure than one that forgets to turn on, which is why this resets in endFrame where those two are held until changed.

Costs nothing at zero: the shader's withVeil is a single comparison that hands the pixel back unmixed, and nothing extra is allocated on the CPU side to reach it.

setColourGrade

setColourGrade(lut: ColourGradeLut | null, strength?: number): void

The colour grade this and every later frame applies, until it is set again.

ParameterTypeDescription
lutColourGradeLut | null
strength?number
More

A lookup table over display values, applied after the tone curve and before the veil — colourGrade.ts argues both placements and uGradeLut's own comment in rush.ts states them. Held rather than per-frame, so a consumer sets it once; the table is uploaded only when the object it was given changes, which makes calling this from a frame loop free.

It needs screenEffects, and says so rather than doing nothing. Without a composite every forward pass is the last thing to touch the frame and grades itself, so there is no single place a look could be applied — the same rule and the same words hdrScene already carries. A silent no-op here would be a consumer wondering why their grade does not apply, which is precisely the failure this engine's rules exist to prevent. Once per renderer, because a consumer setting a grade every frame would otherwise flood a console.

setVignette

setVignette(strength: number): void

How strongly the lens darkens the frame's corners, held until changed: 0 is none, 0.5 is about 1.2 stops at the corner. Applied to scene light before the tone curve, as a lens loses light; see filmLook.ts. Needs screenEffects, and says so once rather than doing nothing.

ParameterTypeDescription
strengthnumber

setChromaticAberration

setChromaticAberration(intensity: number, start?: number): void

The lens's colour fringe, held until changed: intensity is a percentage, 0 is none, and red and green are pulled toward the centre by their wavelength's distance from blue, growing from start — a share of the half-frame, 0 the centre — to the whole of it at the edge. Applied to scene light at the composite's first read. Needs screenEffects, and says so once rather than doing nothing. See shaders/fringe.ts.

ParameterTypeDescription
intensitynumber
start?number

setFilmGrain

setFilmGrain(strength: number, seed: number): void

Film grain, held until changed: an amplitude in display values (0 is none, 0.03 is a fine grain) and this frame's seed. The seed is the caller's, because the engine takes time from its caller: pass a new one each frame for grain that moves, and the same one for a still that is identical run to run. Applied last, after the grade and the veil; see filmLook.ts.

ParameterTypeDescription
strengthnumber
seednumber

drawDecal

drawDecal(projector: DecalProjector): void

Mark whatever the depth buffer holds inside a projector's box.

ParameterTypeDescription
projectorDecalProjector
More

The drawn half of decals, and projectDecal is the other. That one clips the receiving surface's triangles once and hands back a mesh, which is cheaper every frame and exact — and which cannot follow a surface that deforms or streams in afterwards. This decides the mark from the frame's own depth instead, so the receiver may be a skinned mesh, a heightfield being rewritten, an instanced crowd, or geometry that arrived after the mark was placed.

The mark multiplies. A forward renderer has no G-buffer, so the pixel is already lit by the time this runs: multiplying takes the receiver's lighting exactly, at the cost that a mark can darken and tint and never brighten. decalProjector.ts argues that at length.

Recorded here and drawn in endFrame, before the frame is composited, so the post chain sees the mark. Silently nothing where the profile has no screen effects, since there is then no depth to read: the same rule hdrScene and orderIndependent carry.

drawReflection

drawReflection(surface: ReflectiveSurface): void

Reflect what the frame drew, in the surfaces inside a box.

ParameterTypeDescription
surfaceReflectiveSurface
More

What a planar reflection cannot reach. beginPlanarReflection re-renders the world from a mirrored camera for one horizontal plane at one height, which is exactly right for a lake and gives nothing for a floor that undulates, a bonnet or a tilted pane — and it costs a second pass over the scene. This costs one scissored fullscreen pass per surface, follows the surface per pixel whatever shape it is, and is exact where an object meets the floor.

It can only reflect what is on screen. Anything behind the camera or hidden behind the reflecting surface is not in the colour buffer and cannot be recovered from it; the fades in screenSpaceReflection.ts are what keep that from being an obvious limit rather than a pretence that it is not one.

Recorded here and traced in endFrame, after the decals so a mark appears in the reflection and before the translucent resolve so a pane of glass is drawn over it. Needs screenEffects, and says so once where there is none.

beginFrame

beginFrame(clearColor: Vec3): void
ParameterTypeDescription
clearColorVec3

endFrame

endFrame(): void

Present the frame.

More

Called at the very end of a render pass, and always resolves to the canvas — a frame recorder reads the canvas to build a clip and a still is a canvas copy, so a scene left in a framebuffer would export black while the screen looked right. A no-op when screen effects are off, so a caller may call it unconditionally.

bindMeshPass

bindMeshPass(camera: Camera, env: Environment): void

Bind the flat pass once per frame; then issue any number of drawMesh calls.

ParameterTypeDescription
cameraCamera
envEnvironment

createLightField

createLightField(lights: readonly LightFieldSource[], options?: Omit<LightFieldOptions, 'falloff'>): LightField

Sum a scene's many fixed lights into a DriftLight field, which stands in for each of them wherever the frame's choice of exact lights does not reach. See driftLight/lightField.ts.

ParameterTypeDescription
lightsreadonly LightFieldSource[]
options?Omit<LightFieldOptions, 'falloff'>
More

Replaces any field made before. The scene then paces field.bake(bricks) and calls field.follow(buffer.complete, ...) each frame with the selection that chose its exact lights; the volume is summed into the frame once every brick has landed.

createWorldLightField

createWorldLightField(volume: DenseLightVolume, options?: WorldLightFieldOptions): WorldLightField

Hand a world's DriftLight volume to the renderer: every fixed light of a city, summed offline by bakeDenseField, standing in past the frame's exact choice. Replaces any field made before, of either kind; the scene calls field.follow(buffer.complete, ...) each frame as it would for createLightField, and marks the lights the volume summed inLightField so none is counted twice. See driftLight/worldLightField.ts.

ParameterTypeDescription
volumeDenseLightVolume
options?WorldLightFieldOptions

disposeLightField

disposeLightField(): void

Let go of the field and its volumes; the scene's lights are all exact again.

drawMesh

drawMesh(mesh: Mesh, model: ReadonlyMat4, depthLayer?: number, /** * Optional per-draw colour multiplier, or null for the mesh's own colours. * * For anything that has to take on a colour decided at runtime — a part matching the * surface underneath it, a highlight, a team tint — where the alternative is * rebuilding vertex data every frame. Reset to white after the call, so a caller that * passes one cannot leak it into the next draw. */ tint?: Vec3 | null, /** * Where the mesh was last frame — a `Mover` or a matrix — which this backend takes and ignores. * * Accepted rather than absent so that one scene draws through both renderers unchanged — the * parity rule this repository is built on. Reconstruction is WebGPU's, having no compute stage * here, so there is no motion target for this to be written into, and a mover passed here is * neither read nor advanced. */ previousModel?: ReadonlyMat4 | Mover | null): void

Draw world geometry.

ParameterTypeDescription
meshMesh
modelReadonlyMat4
depthLayer?number
/** * Optional per-draw colour multiplier
or null for the mesh's own colours. * * For anything that has to take on a colour decided at runtime — a part matching the * surface underneath it
a highlight
a team tint — where the alternative is * rebuilding vertex data every frame. Reset to white after the call
so a caller that * passes one cannot leak it into the next draw. */ tint?: Vec3 | null
/** * Where the mesh was last frame — a `Mover` or a matrix — which this backend takes and ignores. * * Accepted rather than absent so that one scene draws through both renderers unchanged — the * parity rule this repository is built on. Reconstruction is WebGPU's
having no compute stage * here
so there is no motion target for this to be written into
and a mover passed here is * neither read nor advanced. */ previousModel?: ReadonlyMat4 | Mover | null
More

depthLayer orders geometry that occupies the same surface as other geometry — a deck crossing another deck, a kerb fused into the slab it trims, a marking inlaid in a floor. A higher layer wins the depth test wherever two surfaces coincide. 0, the default, is the base world and takes no offset at all.

The engine cannot guess which of two fused surfaces should win, so it does not try: the consumer declares the order once, and the engine's promise is that the answer is then the same from every angle. That is the whole defect — not that the wrong surface wins, but that which one wins is not decided anywhere.

Without it, two coplanar surfaces are a coin toss taken per pixel. Their interpolated depths are equal in exact arithmetic, so the comparison is decided by which way the rounding fell in each triangle's interpolators, and that pattern changes with the view: a kerb reads as a full band from one angle and a hairline from another, and the seam between two fused slabs crawls with a hatch.

It turned up in six places in a single session, and the right call was to refuse the workaround: this belongs at engine level, and level design should not be bent around an engine bug. The workaround is real — consumers had been lifting markings a centimetre or two off the surfaces they belong to, one hand-tuned constant at a time, to dodge this.

Two measurements narrowed it to exactly this and are worth keeping, because both eliminate a plausible cure:

  • Not depth precision. With the near plane at 2.0 the buffer resolves seven microns at the distances involved and the artefact was unchanged. So the surfaces are not nearly-coincident, they are coincident — and no depth format separates equal depths. That is what took a reversed-Z conversion off the table.
  • Not the shadow pass. With ?shadows=0, unchanged.

POLYGON_OFFSET_FILL is the feature built for this. The slope term matters as much as the constant one: a band seen at a grazing angle covers many depth units across one pixel, and a fixed nudge that suffices head-on vanishes there.

drawTranslucentMesh

drawTranslucentMesh(mesh: Mesh, model: ReadonlyMat4, opacity: number, options?: TranslucentMeshOptions): void

Draw a mesh you can see through, in the same material as everything else by default.

ParameterTypeDescription
meshMesh
modelReadonlyMat4
opacitynumber
options?TranslucentMeshOptions
More

For geometry that is present without being solid — a sign hung in the air, a hologram, a marker over a course. Lit, fogged and shaded exactly as the world is, because it belongs to the world; the only difference is that the world carries on behind it.

options turns either of those off, and it is the same draw doing it rather than a second one. Geometry, blend state and depth behaviour are unchanged either way; only whether the fragment stage runs lighting and fog differs. See TranslucentMeshOptions for what each half means and why they are independent. A glow shell or a flat backdrop plate that needs to read exactly its own colour — three.js's meshBasicMaterial — is { lit: false, fog: false }; everything that called this before keeps drawing lit and fogged, since both default to true.

It writes depth, unlike every other blended pass here, and that is not an oversight. The sky is drawn last, as a full-screen triangle that fills every pixel the world left untouched — so anything translucent that declined to write depth is simply painted over wherever it stood against open air. The first version showed it plainly: a gate came out half visible, clipped away except for the part that overlapped the deck behind it. A sign hung in the air is exactly the case that stands against the sky.

The cost is the usual one: two translucent surfaces in this pass sort by depth instead of blending through each other, and which of an overlapping pair survives is the order they were submitted in. { depthWrite: false } is the tool for a caller with overlapping glass, and it was missing for as long as this paragraph named it without providing it — a consumer importing vehicles found it on a model whose interior is 96 blended surfaces inside a shell. { depthLayer } is the other half, for a blended surface coplanar with what it decorates, which no ordering can separate. What it buys is that the piece exists in the depth buffer like everything else in the world.

Back faces stay culled, so a closed shape blends once rather than twice, and a marker seen from below reads the same weight as one seen from above.

Call inside the flat pass, after the opaque draws. Everything it changes is handed straight back.

bakeReflectionProbe

bakeReflectionProbe(origin: Vec3, clearColor: Vec3, drawFace: (camera: Camera) => void, options?: ProbeBakeOptions): boolean

Capture the room into a cubemap, once, so reflective surfaces can mirror it.

ParameterTypeDescription
originVec3
clearColorVec3
drawFace(camera: Camera) => void
options?ProbeBakeOptions
More

drawFace is handed a camera already aimed and submits the scene exactly as it would to the screen: a bindMeshPass, the world's draws, the sky. It is called six times, and the whole thing is one bake rather than anything per frame — six submissions at the moment a caller says the room is finished, then a cubemap fetch per reflective pixel for ever after.

Called once the world exists and not before. A probe baked in a constructor captures a room that has not been built yet, which is a reflection of nothing that looks exactly like a reflection of something.

A no-op without reflectionProbeSize, and a no-op on a device with no spare texture unit, so a caller may issue it unconditionally. Returns whether the room was captured, for a caller that wants to say so.

What it must not contain: the passes that bind framebuffers of their own. A shadow bake or a planar reflection inside drawFace would unbind the probe's target halfway through a face. Shadow maps baked before the probe are fine and are the normal arrangement, since what the probe wants is the lit room.

setProbeGrid

setProbeGrid(options: ProbeGridOptions): boolean

Declare where a grid's probes stand, and allocate the layers for them.

ParameterTypeDescription
optionsProbeGridOptions
More

texStorage3D is immutable, so the layer count is fixed here and not grown later. A grid that already matches is left alone, which is what makes bakeReflectionProbe cheap to call every time a scene rebakes its one probe.

Answers false when no probe was allowed at all — no reflectionProbeSize, or a device with no spare texture unit — so a caller may issue it unconditionally.

bakeProbe

bakeProbe(layer: number, clearColor: Vec3, drawFace: (camera: Camera) => void, options?: ProbeBakeOptions): boolean

Bake one probe of the declared grid: six faces into the scratch cube, then one convolution.

ParameterTypeDescription
layernumber
clearColorVec3
drawFace(camera: Camera) => void
options?ProbeBakeOptions
More

One probe per call, and that is the budget question a grid actually raises. A bake is six passes plus a convolution per level, which prefilterEnvMap.ts says is affordable only because it happens once per scene; a grid multiplies it by the probe count. A consumer spreading thirty-two probes over thirty-two frames is doing what the point-shadow pool already does.

The grid is not sampled until every layer has been filled, so a scene paced this way keeps the gradient it had until the last probe lands rather than showing half a grid.

bakeProbeGrid

bakeProbeGrid(clearColor: Vec3, drawFace: (camera: Camera) => void, options?: ProbeBakeOptions): boolean

Bake every probe of the declared grid, in one call.

ParameterTypeDescription
clearColorVec3
drawFace(camera: Camera) => void
options?ProbeBakeOptions
More

The whole grid at once, for a scene that can afford a stall at load. bakeProbe is the same work paced by the caller, and a grid of any size is worth pacing.

setEnvironmentImage

setEnvironmentImage(image: { readonly width: number; readonly height: number; readonly data: Float32Array; }, options?: ProbeBakeOptions): boolean

Light the scene from an environment it did not photograph.

ParameterTypeDescription
image{ readonly width: number; readonly height: number; readonly data: Float32Array; }
options?ProbeBakeOptions
More

Downstream of the capture this is exactly a bake, which is the whole design: the faces are uploaded into the same cube a bake fills and the same convolution runs over them. A loaded sky is therefore not a second lighting path that can be wrong on its own.

A loaded environment is a grid of one, at the origin, because it is the same everywhere.

setProbeLayerImage

setProbeLayerImage(layer: number, image: { readonly width: number; readonly height: number; readonly data: Float32Array; }, options?: ProbeBakeOptions): boolean

One layer of the declared probe grid from an image, the way setEnvironmentImage fills a single probe: the same equirectangular image of linear RGB floats, projected onto the capture cube and prefiltered into that layer, the others left as they are. Returns false — and says why — where there is no probe, no grid, or no such layer.

ParameterTypeDescription
layernumber
image{ readonly width: number; readonly height: number; readonly data: Float32Array; }
options?ProbeBakeOptions
More

What it is for: a scene baked somewhere else. Another engine's reflection captures are images taken where each one stands; a consumer fits them to a lattice — for each point, the capture whose reach holds it — and hands each point its image here, where bakeProbe would have drawn this engine's own world into it.

options.irradiance decides, at grid scope as a bake's does, whether the grid also lights the scene's diffuse ambient. A grid that crossfades writes the set being swept, as a bake does.

drawLightVolume

drawLightVolume(mesh: Mesh, model: ReadonlyMat4, camera: Camera, strength: number, length: number, spread: number, options?: LightVolumeDrawOptions): void

Draw a volume of light: a beam from a lamp, a shaft through a window, the cone under a street light.

ParameterTypeDescription
meshMesh
modelReadonlyMat4
cameraCamera
strengthnumber
lengthnumber
spreadnumber
options?LightVolumeDrawOptions
More

Not a material, and that is the whole point of it being a separate call. Everything drawMesh does to a surface is wrong for light: it lights it, fogs it toward the medium's colour, and scales its emissive by how dark the world is. Light is what the fog is made of; a beam is more visible in mist, not less. This pass adds, and does nothing else.

Two attempts to express a beam as geometry in the flat pass failed, in opposite directions, and both are worth knowing before reaching for a material again. Alpha blended it subtracted: blending moves the background toward the surface's own colour, so an unlit wedge darker than the dusk behind it swept a solid dark shape across the frame, which was reported five times as a bird. Made additive but still fogged, ninety metres of it multiplied down to nothing at twenty times the emissive.

What the caller supplies is a closed hull, and the pass walks the view ray inside it. The geometry decides which pixels run and nothing else: every one of them integrates the air along its own line of sight, so the path length that makes a volume read as a volume comes out of the walk rather than being modelled.

It used to rasterise a few flat panes through the axis and weight each fragment by how far from face-on its pane was turned, which stands in for that path length and works from the side. It cannot work down the barrel: every pane contains the axis, so a view lying near that axis lies nearly within all of them at once, each collapses to a narrow bright wedge, and their union reads as a six-armed asterisk instead of a disc. Reported twice from outside — as a beam whose shape never changed however it was tuned, and as raw lines in the opening of a room — and no correction to the weight could have fixed either, because the fault was that a handful of flat sheets is not a volume.

Culling is off, because a volume seen from inside is still lit. Depth testing stays on, so a beam passes behind whatever stands in front of it; depth writing is off, because a volume of light occludes nothing.

The volume opens from its own origin along its local +Z, and length and spread say how far and how wide: spread is half-width over distance, so the tangent of the half-angle. The shader fades the light to nothing at that length and at that aperture, which is what lets a beam dissolve into the air rather than stop at a bright square, and it is why the hull can be a coarse frustum rather than anything shaped like light. Building the gradient into the geometry instead is a staircase, because MeshBuilder states one emissive per quad.

All three numbers describe geometry this call cannot see, and each one fails silently when it disagrees with the mesh. buildLightVolume in src/geometry/ builds the hull from the same length and spread passed here, which is the way to have none of this apply; the following is what a caller building its own has to get right, and every one of them has been paid for from outside this repository.

  • length is measured in the geometry's own units, not in world metres, because the shader compares it against the untransformed vertex position. A beam authored as a unit wedge and placed with an orthonormal basis is one unit long whatever length says, so a length of 7.4 puts the whole mesh inside the first 13% of its own falloff and the beam is invisible. Either the model matrix carries the scale, or the hull is built at world size. There is no error and no warning: the draw succeeds and nothing appears.
  • spread has to be at or under the geometry's own flare, since the across-axis fade reaches zero at this aperture and clamps there. Wider than the hull and the fade is still climbing where the hull ends, which cuts the volume instead of dissolving it. Narrower is safe and merely reads as a beam thinner than its own silhouette.
  • strength is clamped to 1. Raising it is the natural response to a beam that looks faint, and above 1 it does nothing at all — so a faint beam is one of the two faults above, or a colour and emissive that are too low in the mesh itself.

strength fades the whole thing, for a lamp coming up at dusk or dimming at dawn. At 0 it draws nothing at all rather than adding black, so a caller may keep the call in the frame.

drawFilm

drawFilm(mesh: Mesh, camera: Camera, timeSeconds: number, env: Environment, sheen: number, options?: FilmOptions): void

Draw a thin wet film — an oil slick, a puddle, a wet patch — over the world.

ParameterTypeDescription
meshMesh
cameraCamera
timeSecondsnumber
envEnvironment
sheennumber
options?FilmOptions
More

Its own pass because it is its own material: view-dependent iridescence and a dissolving edge, neither of which the flat shader can express. It runs after the world with depth writes off and blending on, so a slick lies on the surface it was built against instead of fighting it for the depth buffer.

The caller never sees a GL call: it hands over a mesh, the camera, the clock and how strong the sheen should be.

sheen is iridescence, not wetness, and the two are not the same dial. Raising it to make a road look wetter makes it look oilier: the surface bands green to magenta like fuel on water, because that is what the term models. A consumer found the usable range for wet tarmac at night by bisection twice, at 0.7 and again at 0.24, and landed on 0.15 to 0.18. Start there rather than repeating the search.

The mirror is a separate dial: FilmOptions.reflectionStrength shows the planar reflection a frame rendered for the film's plane, weighted by the viewing angle. See beginPlanarReflection for when that target exists.

setPlumeScale

setPlumeScale(plumes: PlumeRenderer, index: number, scale: number): void

Scale one plume in a batch, 0 to hide it.

ParameterTypeDescription
plumesPlumeRenderer
indexnumber
scalenumber
More

For a fire that is not always burning — an oil slick on its ignition cycle. The caller owns when; nothing here knows what a slick is.

drawSky

drawSky(camera: Camera, sky: SkyColors, env: Environment): void

Draw last: the sky only fills pixels the world left untouched.

ParameterTypeDescription
cameraCamera
skySkyColors
envEnvironment

registerPickable

registerPickable(source: PickableSource, model: Float32Array): number

What is under a pixel, for a consumer that has to answer a click.

ParameterTypeDescription
sourcePickableSource
modelFloat32Array
More

The engine says what, and nothing else: enter, leave, capture, bubbling and the difference between a click and a drag are the consumer's, which already has that logic for anything with a drag in it. An event system here would be a second one, disagreeing with the first.

updatePickable

updatePickable(handle: number, model: Float32Array): void
ParameterTypeDescription
handlenumber
modelFloat32Array

unregisterPickable

unregisterPickable(handle: number): void
ParameterTypeDescription
handlenumber

pickAt

pickAt(camera: Camera, cssX: number, cssY: number): PickHit | null
ParameterTypeDescription
cameraCamera
cssXnumber
cssYnumber