Renderer · class
WebGL2Renderer
Explained in Hello world.
class WebGL2Renderer implements RendererApiimport { WebGL2Renderer } from '@driftengine/core';Constructor
newdeprecated
constructor(canvas: HTMLCanvasElement, qualityOptions?: RenderQualityOptions)Construct the WebGL2 renderer directly.
| Parameter | Type | Description |
|---|---|---|
canvas | HTMLCanvasElement | |
qualityOptions? | RenderQualityOptions |
Properties
| Name | Type | Description |
|---|---|---|
gpuTimerreadonly | GpuTimer | GPU time for the frame's three parts, when the driver will report it.MorePublic because the consumer is a diagnostic, not the renderer: the game brackets
its own frame with |
qualityreadonly | Readonly<RenderQuality> | Resolved once; a size/profile change recreates the renderer's resources. |
rendererNamereadonly | string | What UNMASKED_RENDERER_WEBGL calls this part, or '' when the browser withholds it.MorePublic because a bug report is worth more with it than without: every performance report this project has acted on was read GPU-string first. |
backendreadonly | RenderBackend | Which backend is drawing, from the renderer rather than from the address bar.More
Not the same question as |
capabilityClampedreadonly | boolean | Whether 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. |
reversedDepth | boolean | Whether this context really is drawing reversed. See RendererApi.reversedDepth.MoreSeparate from |
computeSupportedreadonly | boolean | Whether this backend can run a compute definition at all. It cannot.MoreA 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 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. |
displayRangereadonly | DisplayRange | The 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
| Name | Type | Description |
|---|---|---|
contextLostget | boolean | Whether the drawing context is currently gone.MoreEvery 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 — 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.
|
presentedFramesget | number | How many frames this renderer has actually presented, monotonic for its whole lifetime.MoreIncremented where |
cssWidthget | number | Viewport in CSS pixels rather than device pixels.MoreWhat 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. |
cssHeightget | number | |
distanceFieldMsget | number | null | Never measured here, because nothing is composed. See addDistanceField. |
indirectBakeMsget | number | null | Never traced here either, so there is no refresh to time. See addDistanceField. |
shadowMapSizeget | number | Side length of the depth map, so callers can size the light frustum. |
compressedFormatsget | readonly CompressedTextureFormat[] | The BC formats this device takes as blocks, each named with its colour space ('bc7-srgb').MoreAsk before choosing, because a phone usually answers none: a BC texture handed to
|
aspectget | number | |
sceneWidthget | number | The size the world is drawn at, which on this backend is always the drawing buffer.MoreHere 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 — |
sceneHeightget | number | |
resolutionScaleget | number | The density actually in force, which is where a governor has to start from.More
The effective density and not
|
shadedLightsget | number | How many point lights this renderer's shader shades at once.MoreRead it when sizing a |
shadedAreaLightsget | number | How many rectangular emitters this renderer's shader shades at once. |
sampledShadowLightsget | number | How many point-light shadows the shader samples at once. |
displayRangeReasonget | string | Why it is that range, in words, for a game choosing its look and for a bug report. |
frameBudgetget | FrameBudget | What the frame just drawn asked for. See budget.ts, and the note on budget above. |
firstFrameSettledget | boolean | Whether the driver has finished the first frame, not merely been asked for it.MoreFor 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): voidTold when the drawing context is lost. See attachContextLoss.
| Parameter | Type | Description |
|---|---|---|
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): voidTold when the drawing context comes back. See attachContextLoss.
| Parameter | Type | Description |
|---|---|---|
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): booleanWhether a mesh placed by this matrix is anywhere in the frame.
| Parameter | Type | Description |
|---|---|---|
bounds | Bounds | |
model | ReadonlyMat4 |
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): voidDeclare a box that things behind it may safely be hidden by.
| Parameter | Type | Description |
|---|---|---|
min | ArrayLike<number> | |
max | ArrayLike<number> | |
model | ReadonlyMat4 |
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>): voidDeclare a baked distance field that indirect light may be traced against.
| Parameter | Type | Description |
|---|---|---|
_field | FieldSource | |
_model | ReadonlyMat4 | |
_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(): voidForget the fields declared this frame. A no-op here, as addDistanceField is.
readDistanceFieldTimings
readDistanceFieldTimings(): voidNothing to read, for the same reason. A consumer may call it unconditionally.
occluded
occluded(bounds: Bounds, model: ReadonlyMat4): booleanWhether a mesh is entirely behind the occluders declared this frame.
| Parameter | Type | Description |
|---|---|---|
bounds | Bounds | |
model | ReadonlyMat4 |
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>): booleanWhether 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.
| Parameter | Type | Description |
|---|---|---|
min | ArrayLike<number> | |
max | ArrayLike<number> |
registerPass
registerPass(definition: PassDefinition): PassHandle| Parameter | Type | Description |
|---|---|---|
definition | PassDefinition |
drawPass
drawPass(handle: PassHandle): voidRun one, here, now.
| Parameter | Type | Description |
|---|---|---|
handle | PassHandle |
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): voidLet go of a pass. A handle kept past this draws nothing; see PassHandle.
| Parameter | Type | Description |
|---|---|---|
handle | PassHandle |
setSpotCookies
setSpotCookies(images: readonly TexImageSource[]): voidUpload the cookies a consumer loaded, and bind the atlas.
| Parameter | Type | Description |
|---|---|---|
images | readonly 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| Parameter | Type | Description |
|---|---|---|
profiles | readonly PhotometricProfile[] |
registerCompute
registerCompute(definition: ComputeDefinition): ComputeHandleRefuse, in words, and hand back the handle that names nothing.
| Parameter | Type | Description |
|---|---|---|
definition | ComputeDefinition |
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): voidNothing was registered, so there is nothing to dispatch. See registerCompute.
| Parameter | Type | Description |
|---|---|---|
_handle | ComputeHandle |
unregisterCompute
unregisterCompute(_handle: ComputeHandle): voidNothing was registered, so there is nothing to release. See registerCompute.
| Parameter | Type | Description |
|---|---|---|
_handle | ComputeHandle |
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| Parameter | Type | Description |
|---|---|---|
options? | { releaseContext?: boolean; } |
createMesh
createMesh(data: MeshData, options?: MeshOptions): Mesh| Parameter | Type | Description |
|---|---|---|
data | MeshData | |
options? | MeshOptions |
createMeshIncremental
createMeshIncremental(data: MeshData, options?: MeshOptions): IncrementalMeshGeometry in, handle out — but the geometry lands over as many frames as the caller gives it.
| Parameter | Type | Description |
|---|---|---|
data | MeshData | |
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): voidRewrite a mesh's positions, and its normals where the caller has them.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
positions | Float32Array | |
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): SurfaceTextureUpload an image the caller already has, for use as surface colour.
| Parameter | Type | Description |
|---|---|---|
source | SurfaceSource | |
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): SurfaceTextureUpload 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.
| Parameter | Type | Description |
|---|---|---|
sources | readonly SurfaceSource[] | |
options? | SurfaceTextureOptions |
createLightmap
createLightmap(page: LightmapPage): SurfaceTextureA baked lightmap page, as a lightmapModel material's modelMap. See RendererApi.
| Parameter | Type | Description |
|---|---|---|
page | LightmapPage |
createSceneCapture
createSceneCapture(width: number, height: number): SurfaceTextureA 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.
| Parameter | Type | Description |
|---|---|---|
width | number | |
height | number |
captureScene
captureScene(capture: SurfaceTexture, camera: Camera, clearColor: Vec3, draw: (camera: Camera) => void): booleanDraw 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.
| Parameter | Type | Description |
|---|---|---|
capture | SurfaceTexture | |
camera | Camera | |
clearColor | Vec3 | |
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): voidReplace a surface texture's pixels, keeping the GPU object and its sampler state.
| Parameter | Type | Description |
|---|---|---|
texture | SurfaceTexture | |
source | TexImageSource | 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| Parameter | Type | Description |
|---|---|---|
texture | SurfaceTexture |
prepareMesh
prepareMesh(mesh: Mesh, material: SurfaceMaterial | null, _options?: PrepareOptions): voidCompile, 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.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
material | SurfaceMaterial | 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): voidprepareMesh for an instanced batch: its own programs, and its model's for its variant.
| Parameter | Type | Description |
|---|---|---|
batch | InstancedBatch | |
material | SurfaceMaterial | null | |
_options? | PrepareOptions |
setSkinPalette
setSkinPalette(palette: Float32Array | null): voidChoose the joint palette the following drawMesh calls skin by, or null for none.
| Parameter | Type | Description |
|---|---|---|
palette | Float32Array | 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): GlClothBindingA mesh's cloth binding: which simulation triangle each of its vertices follows, where on it,
and by how much. See ClothBindingData.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
data | ClothBindingData |
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): GlClothParticlesOne character's particles, count of them, to be updated every frame.
| Parameter | Type | Description |
|---|---|---|
count | number |
updateClothParticles
updateClothParticles(particles: GlClothParticles, positions: Float32Array): voidThis frame's particles, three floats each, world space; last frame's are kept for motion. A simulation's output, as it is.
| Parameter | Type | Description |
|---|---|---|
particles | GlClothParticles | |
positions | Float32Array |
setCloth
setCloth(binding: GlClothBinding | null, particles?: GlClothParticles | null): voidPlace 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.
| Parameter | Type | Description |
|---|---|---|
binding | GlClothBinding | null | |
particles? | GlClothParticles | null |
disposeClothBinding
disposeClothBinding(binding: GlClothBinding): void| Parameter | Type | Description |
|---|---|---|
binding | GlClothBinding |
disposeClothParticles
disposeClothParticles(particles: GlClothParticles): void| Parameter | Type | Description |
|---|---|---|
particles | GlClothParticles |
setMorphWeights
setMorphWeights(weights: Float32Array | null): voidChoose the morph weights the following drawMesh calls deform by, or null for none.
| Parameter | Type | Description |
|---|---|---|
weights | Float32Array | 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| Parameter | Type | Description |
|---|---|---|
material | SurfaceMaterial | null |
setSurfaceTexturedeprecated
setSurfaceTexture(texture: SurfaceTexture | null, uScale?: number, vScale?: number, cutout?: number): void| Parameter | Type | Description |
|---|---|---|
texture | SurfaceTexture | null | |
uScale? | number | |
vScale? | number | |
cutout? | number |
setAmbientSH
setAmbientSH(coefficients: ArrayLike<number> | null): voidThe 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.
| Parameter | Type | Description |
|---|---|---|
coefficients | ArrayLike<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): voidHow much of the environment the following draws mirror, 0 to 1.
| Parameter | Type | Description |
|---|---|---|
amount | number |
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): voidThe 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.
| Parameter | Type | Description |
|---|---|---|
overlay | SurfaceOverlay | 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): voidHow bright the environment the surfaces drawn next reflect is. 1 is the default and identity.
| Parameter | Type | Description |
|---|---|---|
gain | number |
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): voidKept as a no-op that says so once, because it was a stand-in for a term that now exists.
| Parameter | Type | Description |
|---|---|---|
_amount | number |
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): voidA pass-level scale over the grain the geometry itself declared, 0 to 1.
| Parameter | Type | Description |
|---|---|---|
amount | number |
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): voidWhether the surfaces drawn next meet the medium: distance fog and the global medium's haze.
True is the default, and what bindMeshPass restores.
| Parameter | Type | Description |
|---|---|---|
enabled | boolean |
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): voidHow much of the surfaces drawn next survives a screen-door dither, for crossfading two levels of detail of one thing.
| Parameter | Type | Description |
|---|---|---|
amount | number |
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): voidWhether 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.
| Parameter | Type | Description |
|---|---|---|
on | boolean |
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): voidHow strong the microscopic relief on the surfaces drawn next is, and how coarse.
| Parameter | Type | Description |
|---|---|---|
amount | number | |
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): voidHow hard the bound surface texture's own luminance turns the shading normal, 0 for not at all.
| Parameter | Type | Description |
|---|---|---|
scale | number |
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): voidScale the emissive of the following draws, or reset to what the environment says.
| Parameter | Type | Description |
|---|---|---|
gain | number |
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): InstancedBatchAttach per-instance placement to a mesh already on the device.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
capacity | number | |
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): BoneAnimationHandleA 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.
| Parameter | Type | Description |
|---|---|---|
clip | BoneAnimationClip |
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): voidRelease a clip. A batch still playing it draws nothing, and casts nothing, after.
| Parameter | Type | Description |
|---|---|---|
animation | BoneAnimationHandle |
setAnimationTime
setAnimationTime(seconds: number): voidThe 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.
| Parameter | Type | Description |
|---|---|---|
seconds | number |
uploadInstanced
uploadInstanced(batch: InstancedBatch, data: MeshInstances): voidPush placement and colour. Only the live prefix.
| Parameter | Type | Description |
|---|---|---|
batch | InstancedBatch | |
data | MeshInstances |
drawInstanced
drawInstanced(batch: InstancedBatch, data: MeshInstances): voidDraw every live instance, opaque.
| Parameter | Type | Description |
|---|---|---|
batch | InstancedBatch | |
data | MeshInstances |
drawTranslucentInstanced
drawTranslucentInstanced(batch: InstancedBatch, data: MeshInstances, opacity: number, options?: TranslucentMeshOptions): voidDraw every live instance, blended. See drawTranslucentMesh for what the options mean.
| Parameter | Type | Description |
|---|---|---|
batch | InstancedBatch | |
data | MeshInstances | |
opacity | number | |
options? | TranslucentMeshOptions |
disposeInstanced
disposeInstanced(batch: InstancedBatch): voidRelease the placement. The mesh is the caller's and is not released.
| Parameter | Type | Description |
|---|---|---|
batch | InstancedBatch |
disposeMesh
disposeMesh(mesh: Mesh): void| Parameter | Type | Description |
|---|---|---|
mesh | Mesh |
disposePlumes
disposePlumes(plumes: PlumeRenderer): voidRelease a resource this renderer created, for every kind that needs the context back.
| Parameter | Type | Description |
|---|---|---|
plumes | PlumeRenderer |
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): voidRelease a wind streak lattice. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
streaks | WindStreakRenderer |
disposeFlock
disposeFlock(flock: FlockRenderer): voidRelease a flock. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
flock | FlockRenderer |
disposeScatter
disposeScatter(scatter: InstancedMesh): voidRelease an instanced batch made by createScatter. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
scatter | InstancedMesh |
disposeParticles
disposeParticles(particles: ParticleBatch): voidRelease a particle batch. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
particles | ParticleBatch |
disposeBolts
disposeBolts(bolts: BoltBatch): voidRelease a bolt batch. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
bolts | BoltBatch |
disposeLines
disposeLines(lines: LineBatch): voidRelease a line batch. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
lines | LineBatch |
disposeWater
disposeWater(water: WaterRenderer): voidRelease a water renderer. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
water | WaterRenderer |
disposeCaustics
disposeCaustics(caustics: CausticsRenderer): voidRelease a caustics renderer. See disposePlumes.
| Parameter | Type | Description |
|---|---|---|
caustics | CausticsRenderer |
createPlumes
createPlumes(plumes: readonly PlumePlacement[], options: PlumeOptions): PlumeRenderer| Parameter | Type | Description |
|---|---|---|
plumes | readonly PlumePlacement[] | |
options | PlumeOptions |
createWindStreaks
createWindStreaks(options?: WindStreakOptions): WindStreakRenderer| Parameter | Type | Description |
|---|---|---|
options? | WindStreakOptions |
drawWindStreaks
drawWindStreaks(streaks: WindStreakRenderer, camera: Camera, wind: WindField, timeSeconds: number, tint: Vec3, env: Environment): voidDraw after the opaque scene: streaks are blended and write no depth.
| Parameter | Type | Description |
|---|---|---|
streaks | WindStreakRenderer | |
camera | Camera | |
wind | WindField | |
timeSeconds | number | |
tint | Vec3 | |
env | Environment |
createText
createText(): TextRendererA 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): voidLay a string out. A no-op when it is the string already laid out.
| Parameter | Type | Description |
|---|---|---|
text | TextRenderer | |
content | string |
setPlate
setPlate(text: TextRenderer, widthCells: number, heightCells: number, bottomCell: number): voidA solid rectangle of cells, for a keycap or a backing plate. See TextLayout.setPlate.
| Parameter | Type | Description |
|---|---|---|
text | TextRenderer | |
widthCells | number | |
heightCells | number | |
bottomCell | number |
drawText
drawText(text: TextRenderer, viewportWidth: number, viewportHeight: number, originX: number, originY: number, style: TextStyle, timeSec: number): voidDraw a laid-out string over the scene.
| Parameter | Type | Description |
|---|---|---|
text | TextRenderer | |
viewportWidth | number | |
viewportHeight | number | |
originX | number | |
originY | number | |
style | TextStyle | |
timeSec | number |
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): numberThe cell size to draw a bitmap glyph at so every cell covers whole device pixels.
| Parameter | Type | Description |
|---|---|---|
cellSize | number | |
viewportWidth | number |
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): numberWidth of the string this handle currently holds, in pixels at a given cell size.
| Parameter | Type | Description |
|---|---|---|
text | TextRenderer | |
cellSize | number |
disposeText
disposeText(text: TextRenderer): void| Parameter | Type | Description |
|---|---|---|
text | TextRenderer |
createSdfText
createSdfText(): SdfTextRendererA 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): voidLay a string out against a font, and retain the atlas drawSdfText will sample.
| Parameter | Type | Description |
|---|---|---|
handle | SdfTextRenderer | |
font | SdfFont | |
atlas | SurfaceTexture | |
content | string | |
style | SdfTextStyle |
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| Parameter | Type | Description |
|---|---|---|
handle | SdfTextRenderer | |
model | Float32Array | |
color | Vec3 | |
opacity | number |
disposeSdfText
disposeSdfText(handle: SdfTextRenderer): voidRelease an SDF text label. Takes no context; see disposeText.
| Parameter | Type | Description |
|---|---|---|
handle | SdfTextRenderer |
createFlock
createFlock(count: number): FlockRenderer| Parameter | Type | Description |
|---|---|---|
count | number |
drawFlock
drawFlock(flock: FlockRenderer, camera: Camera, timeSeconds: number, params: FlockParams, tint: Vec3, windX?: number, windZ?: number): void| Parameter | Type | Description |
|---|---|---|
flock | FlockRenderer | |
camera | Camera | |
timeSeconds | number | |
params | FlockParams | |
tint | Vec3 | |
windX? | number | |
windZ? | number |
createScatter
createScatter(base: MeshData, data: InstanceData): InstancedMeshBuild 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).
| Parameter | Type | Description |
|---|---|---|
base | MeshData | |
data | InstanceData |
uploadScatter
uploadScatter(scatter: InstancedMesh, data: InstanceData): voidPush changed instances to the GPU.
| Parameter | Type | Description |
|---|---|---|
scatter | InstancedMesh | |
data | InstanceData |
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): ParticleBatchBuild a particle material: one program, one VAO, one draw call per pool.
| Parameter | Type | Description |
|---|---|---|
capacity | number | |
options | ParticleBatchOptions |
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): voidParticles 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.
| Parameter | Type | Description |
|---|---|---|
_batch | ParticleBatch | |
_particles | DeviceParticles | |
_camera | Camera | |
_env | Environment | |
_timeSeconds | number |
drawParticles
drawParticles(batch: ParticleBatch, data: ParticleInstances, camera: Camera, env: Environment, timeSeconds: number): voidDraw a live particle pool.
| Parameter | Type | Description |
|---|---|---|
batch | ParticleBatch | |
data | ParticleInstances | |
camera | Camera | |
env | Environment | |
timeSeconds | number |
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): BoltBatchBuild a batch for electrical arcs. capacity is in segments, not arcs.
| Parameter | Type | Description |
|---|---|---|
segmentCapacity | number | |
label? | string |
drawBolts
drawBolts(batch: BoltBatch, data: BoltSegments, camera: Camera, env: Environment, timeSeconds: number, core: Vec3, edge: Vec3, widthM: number, coreGain: number, minWidthPerMetre?: number): voidDraw a pool of arcs.
| Parameter | Type | Description |
|---|---|---|
batch | BoltBatch | |
data | BoltSegments | |
camera | Camera | |
env | Environment | |
timeSeconds | number | |
core | Vec3 | |
edge | Vec3 | |
widthM | number | |
coreGain | number | |
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): LineBatchA polyline with a real width, for anything that is a stroke rather than a surface.
| Parameter | Type | Description |
|---|---|---|
segmentCapacity | number | |
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): voidDraw a polyline.
| Parameter | Type | Description |
|---|---|---|
lines | LineBatch | |
data | LineSegments | |
model | ReadonlyMat4 | |
camera | Camera | |
env | Environment | |
color | Vec3 | |
widthM | number | |
opacity | number | |
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): voidDraw 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.
| Parameter | Type | Description |
|---|---|---|
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 | |
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): voidThe frame's wind, sampled once by the caller and handed down.
| Parameter | Type | Description |
|---|---|---|
windX | number | |
windZ | number | |
windGust | number | |
timeSeconds | number |
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| Parameter | Type | Description |
|---|---|---|
resolution? | number | |
nearExtent? | number | |
farHalfExtent? | number |
createCaustics
createCaustics(sheets: readonly CausticSheet[]): CausticsRenderer | nullSurfaces lit from below by the water under them.
| Parameter | Type | Description |
|---|---|---|
sheets | readonly 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| Parameter | Type | Description |
|---|---|---|
plumes | PlumeRenderer | |
camera | Camera | |
timeSeconds | number | |
env | Environment | |
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| Parameter | Type | Description |
|---|---|---|
water | WaterRenderer | |
camera | Camera | |
timeSeconds | number | |
settings | WaterBody | |
env | Environment | |
windX? | number | |
windZ? | number |
drawCaustics
drawCaustics(caustics: CausticsRenderer | null, camera: Camera, timeSeconds: number, env: Environment, windX?: number, windZ?: number, strength?: number): voidDraw after the opaque scene: this is light added to surfaces already shaded, from the water under them.
| Parameter | Type | Description |
|---|---|---|
caustics | CausticsRenderer | null | |
camera | Camera | |
timeSeconds | number | |
env | Environment | |
windX? | number | |
windZ? | number | |
strength? | number |
beginPlanarReflection
beginPlanarReflection(source: Camera, planeY: number, clearColor: Vec3): Camera | nullTwo 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.
| Parameter | Type | Description |
|---|---|---|
source | Camera | |
planeY | number | |
clearColor | Vec3 |
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(): voidresize
resize(): voidMatch 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): voidMove the drawing-buffer area cap, in pixels. Zero is uncapped.
| Parameter | Type | Description |
|---|---|---|
pixels | number |
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): voidMove the drawing-buffer density cap at runtime, without rebuilding anything.
| Parameter | Type | Description |
|---|---|---|
scale | number |
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): voidPin the drawing buffer to an exact pixel size, ignoring CSS and DPR.
| Parameter | Type | Description |
|---|---|---|
width | number | |
height | number |
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(): voidBack to following the CSS box. The next resize restores it.
beginShadowPass
beginShadowPass(lightViewProj: ReadonlyMat4, layer?: 'static' | 'static-peel' | 'dynamic'): voidRender 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.
| Parameter | Type | Description |
|---|---|---|
lightViewProj | ReadonlyMat4 | |
layer? | 'static' | 'static-peel' | 'dynamic' |
drawSceneCasters
drawSceneCasters(casters: ShadowCasters): voidDraw everything a caster enumeration contains into the open colour pass, with its materials.
| Parameter | Type | Description |
|---|---|---|
casters | ShadowCasters |
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| Parameter | Type | Description |
|---|---|---|
casters | ShadowCasters |
createStaticDraws
createStaticDraws(casters: ShadowCasters): StaticDrawsHandleDraws 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.
| Parameter | Type | Description |
|---|---|---|
casters | ShadowCasters |
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): voidDraw 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.
| Parameter | Type | Description |
|---|---|---|
draws | StaticDrawsHandle |
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): voidRelease a list. Its meshes, batches and materials are the caller's and are not released.
| Parameter | Type | Description |
|---|---|---|
draws | StaticDrawsHandle |
endShadowPass
endShadowPass(): voidprepareStaticPointShadows
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[]): voidSize the point-shadow pool for a world's lights. Call at load.
| Parameter | Type | Description |
|---|---|---|
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[] | |
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[]): voidPer 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.
| Parameter | Type | Description |
|---|---|---|
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[] | |
beginInset
beginInset(rect: InsetRect, clearColor: Vec3 | null): numberDraw into a rectangle of the canvas, on its own terms.
| Parameter | Type | Description |
|---|---|---|
rect | InsetRect | |
clearColor | Vec3 | 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): voidHand a rectangle of the frame to a 2D canvas, at its own pixel size.
| Parameter | Type | Description |
|---|---|---|
rect | InsetRect | |
target | HTMLCanvasElement |
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): voidFill a rectangle of the frame with one flat colour, blended.
| Parameter | Type | Description |
|---|---|---|
rect | InsetRect | |
color | Vec3 | |
alpha | number |
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(): voidGive the whole canvas back. Safe to call without a matching beginInset.
beginViewModel
beginViewModel(share?: number): voidDraw what follows as a view model, in the nearest sliver of depth, until endViewModel.
| Parameter | Type | Description |
|---|---|---|
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(): voidGive the whole depth range back. Safe to call without a matching beginViewModel.
setSpeedRush
setSpeedRush(strength: number): voidHow much speed blur the frame about to be drawn should resolve with, 0 to 1.
| Parameter | Type | Description |
|---|---|---|
strength | number |
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): voidHow much of the frame's camera motion blur to apply, 0 to 1. Scales cameraMotionBlur.
| Parameter | Type | Description |
|---|---|---|
scale | number | |
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(): voidThe 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): voidEye 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.
| Parameter | Type | Description |
|---|---|---|
strength | number | |
dtSec | number |
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): voidLocal 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.
| Parameter | Type | Description |
|---|---|---|
strength | number |
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): voidWhere 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.
| Parameter | Type | Description |
|---|---|---|
distance | number | |
radius | number |
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): voidWhere this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling to take.
| Parameter | Type | Description |
|---|---|---|
distance | number | |
range | number | |
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): voidHow much of the frame's bloom to apply, 0 to 1. Scales bloom, like
setCameraMotionBlur scales cameraMotionBlur.
| Parameter | Type | Description |
|---|---|---|
scale | number | |
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): voidHow thick the air is, this frame: a global participating medium filling the whole frustum.
| Parameter | Type | Description |
|---|---|---|
density | number | |
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): voidHow far this frame's scene is scaled into the tone curve. Replaces outputExposure.
| Parameter | Type | Description |
|---|---|---|
exposure | number |
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): voidThe 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.
| Parameter | Type | Description |
|---|---|---|
curve | FilmicCurve |
setDisplayLuminance
setDisplayLuminance(paperWhite: number, peak: number): voidHow 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.
| Parameter | Type | Description |
|---|---|---|
paperWhite | number | |
peak | number |
setFrameVeil
setFrameVeil(r: number, g: number, b: number, alpha: number): voidComposite a flat colour over the finished frame — for a cut dipping to white or to black.
| Parameter | Type | Description |
|---|---|---|
r | number | |
g | number | |
b | number | |
alpha | number |
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): voidThe colour grade this and every later frame applies, until it is set again.
| Parameter | Type | Description |
|---|---|---|
lut | ColourGradeLut | 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): voidHow 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.
| Parameter | Type | Description |
|---|---|---|
strength | number |
setChromaticAberration
setChromaticAberration(intensity: number, start?: number): voidThe 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.
| Parameter | Type | Description |
|---|---|---|
intensity | number | |
start? | number |
setFilmGrain
setFilmGrain(strength: number, seed: number): voidFilm 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.
| Parameter | Type | Description |
|---|---|---|
strength | number | |
seed | number |
drawDecal
drawDecal(projector: DecalProjector): voidMark whatever the depth buffer holds inside a projector's box.
| Parameter | Type | Description |
|---|---|---|
projector | DecalProjector |
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): voidReflect what the frame drew, in the surfaces inside a box.
| Parameter | Type | Description |
|---|---|---|
surface | ReflectiveSurface |
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| Parameter | Type | Description |
|---|---|---|
clearColor | Vec3 |
endFrame
endFrame(): voidPresent 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): voidBind the flat pass once per frame; then issue any number of drawMesh calls.
| Parameter | Type | Description |
|---|---|---|
camera | Camera | |
env | Environment |
createLightField
createLightField(lights: readonly LightFieldSource[], options?: Omit<LightFieldOptions, 'falloff'>): LightFieldSum 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.
| Parameter | Type | Description |
|---|---|---|
lights | readonly 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): WorldLightFieldHand 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.
| Parameter | Type | Description |
|---|---|---|
volume | DenseLightVolume | |
options? | WorldLightFieldOptions |
disposeLightField
disposeLightField(): voidLet 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): voidDraw world geometry.
| Parameter | Type | Description |
|---|---|---|
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 | |
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): voidDraw a mesh you can see through, in the same material as everything else by default.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
model | ReadonlyMat4 | |
opacity | number | |
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): booleanCapture the room into a cubemap, once, so reflective surfaces can mirror it.
| Parameter | Type | Description |
|---|---|---|
origin | Vec3 | |
clearColor | Vec3 | |
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): booleanDeclare where a grid's probes stand, and allocate the layers for them.
| Parameter | Type | Description |
|---|---|---|
options | ProbeGridOptions |
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): booleanBake one probe of the declared grid: six faces into the scratch cube, then one convolution.
| Parameter | Type | Description |
|---|---|---|
layer | number | |
clearColor | Vec3 | |
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): booleanBake every probe of the declared grid, in one call.
| Parameter | Type | Description |
|---|---|---|
clearColor | Vec3 | |
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): booleanLight the scene from an environment it did not photograph.
| Parameter | Type | Description |
|---|---|---|
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): booleanOne 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.
| Parameter | Type | Description |
|---|---|---|
layer | number | |
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): voidDraw a volume of light: a beam from a lamp, a shaft through a window, the cone under a street light.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
model | ReadonlyMat4 | |
camera | Camera | |
strength | number | |
length | number | |
spread | number | |
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.
lengthis 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 whateverlengthsays, 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.spreadhas 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.strengthis 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): voidDraw a thin wet film — an oil slick, a puddle, a wet patch — over the world.
| Parameter | Type | Description |
|---|---|---|
mesh | Mesh | |
camera | Camera | |
timeSeconds | number | |
env | Environment | |
sheen | number | |
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): voidScale one plume in a batch, 0 to hide it.
| Parameter | Type | Description |
|---|---|---|
plumes | PlumeRenderer | |
index | number | |
scale | number |
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): voidDraw last: the sky only fills pixels the world left untouched.
| Parameter | Type | Description |
|---|---|---|
camera | Camera | |
sky | SkyColors | |
env | Environment |
registerPickable
registerPickable(source: PickableSource, model: Float32Array): numberWhat is under a pixel, for a consumer that has to answer a click.
| Parameter | Type | Description |
|---|---|---|
source | PickableSource | |
model | Float32Array |
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| Parameter | Type | Description |
|---|---|---|
handle | number | |
model | Float32Array |
unregisterPickable
unregisterPickable(handle: number): void| Parameter | Type | Description |
|---|---|---|
handle | number |
pickAt
pickAt(camera: Camera, cssX: number, cssY: number): PickHit | null| Parameter | Type | Description |
|---|---|---|
camera | Camera | |
cssX | number | |
cssY | number |