Every public call, option and setting, with what it does, what it takes, what it returns and what it throws. Look a call up in the index below, or search this page for its name. The README says what the engine is and how it’s built; this page is how to use it.
import { Winding, Camera, Camera2D } from 'winding-engine';
These hold everywhere, so one learned is one learned for good.
scene.addX(options) makes one and returns its
Node. scene.setX(node, changes) changes any of the options addX took, leaving the
rest. scene.xOf(node) returns a copy of its options, or null if the node has none. It goes
with its node: scene.remove(node) or node.destroy(). The
kinds: sprites, text, shapes, paths, tilemaps, emitters, decals, lights and reflection probes.position and parent are options of every
addX.[x, y] or (x, y) wherever a 3D call takes three: z is 0 for a
position or direction, and 1 for a scale.pivot is the point of a thing placed at its node, measured from its top-left: [0, 0] is
the top-left, [1, 1] the bottom-right, [0.5, 0.5] the centre. The same in 3D and 2D, for
sprites, text, shapes, tilemaps and the 2D camera.rotate() does.[r, g, b, a], 0 to 1 — [r, g, b] for a light. In a 3D view they’re linear
light, as lighting works, so a colour past 1 glows through bloom. In a 2D view they’re sRGB, as
CSS and image editors work, so rgb(128, 128, 128) is [128 / 255, 128 / 255, 128 / 255, 1]
and lands on screen as 128. The same sprite’s color means linear light when a 3D camera draws
it and sRGB when a 2D camera does.init(), and the engine grows
what it needs as you add to a scene.Winding · Renderer settings · Debug lines · Scene · Node · Camera · Animation · Lights · Particles · Gaussian splats · Decals · Reflection probes · Sprites · Text · Shapes · Paths · Tilemaps · Camera2D · The 2D view · OrbitController · StatsOverlay · Benchmark · Environment and HDR · Colour helpers · Math · Renamed in 1.0
Every call, setting and entry, A to Z by its name.
A aabb · scene.add() · scene.addDecal() · scene.addEmitter() · scene.addLight() · scene.addPath() · scene.addProbe() · scene.addShape() · scene.addSplats() · scene.addSprite() · scene.addText() · scene.addTilemap() · scene.advance() · node.alive · camera.ambient · camera.angle · node.animation · node.animations · engine.renderer.post.antialias · engine.renderer.ao · engine.debug.axes()
B camera.background · new Benchmark() · scene.bounds() · engine.debug.box() · scene.burst()
C Camera properties · new Camera() · new Camera2D() · engine.captureProbes() · node.children() · scene.childrenOf() · engine.debug.circle() · engine.clock · colorFromBytes() · colorFromHex() · Winding.create() · scene.createNode() · engine.createScene() · engine.createTarget()
D engine.debug · scene.decalOf() · engine.debug.depthTest · engine.destroy() · node.destroy() · overlay.destroy() · orbit.detach() · engine.renderer.dof · orbit.dragged · engine.renderer.drawSkybox (renamed)
E scene.emitterOf() · engine.environment · scene.environment · new Environment() · engine.renderer.exposure
F engine.renderer.post.filterRadius · engine.renderer.fog · camera.follow() · Benchmark.format() · engine.fps · scene.frame() · orbit.frameBounds() · camera.frameBounds()
G engine.gpu · engine.grading · engine.renderer.post.grading
H HUD
I engine.invalidate() · camera.is2D
L engine.renderer.lightDistance · engine.renderer.post.levels · scene.lightOf() · engine.debug.line() · linearToSrgb() · lit · engine.load() · engine.loadEnvironment() · engine.loadFont() · engine.loadLUT() · engine.loadSplats() · engine.loadTexture()
M mat4
O engine.renderer.oit · engine.onDemand · new OrbitController() · camera.orthographicHalfHeight()
P Painter's order · parseHDR() · scene.particlesActive · scene.pathOf() · scene.pick() · scene.pick() · camera.pivot · engine.gpu.pixelRatio · camera.pixelSnap · node.play() · camera.position · scene.probeOf()
Q quat
R scene.raycast() · camera.rayFromScreen() · engine.gpu.readPixels() · scene.remove() · engine.renderer · engine.renderFrame() · benchmark.report() · engine.renderer.post.requestedLevels (renamed) · engine.renderer.resolution · engine.rhi (renamed) · camera.rotation (renamed) · engine.run() · benchmark.run()
S new Scene() · camera.screenToWorld() · node.setAngle() · node.setAxisAngle() · scene.setDecal() · node.setDirection() · scene.setEmitter() · node.setEuler() · scene.setLight() · node.setParent() · scene.setPath() · node.setPosition() · scene.setProbe() · node.setRotation() · node.setScale() · scene.setShape() · scene.setSprite() · scene.setText() · scene.setTile() · scene.setTilemap() · scene.setTiles() · engine.renderer.shadowDistance · engine.renderer.shadows · scene.shapeOf() · engine.skippedFrames · engine.renderer.skybox · engine.debug.sphere() · scene.splatsOf() · scene.spriteOf() · spriteSheet() · sRGB colours · srgbToLinear() · benchmark.start() · engine.stats · new StatsOverlay() · engine.stop() · node.stop() · engine.renderer.post.strength · orbit.syncFromCamera()
T scene.textOf() · engine.renderer.post.threshold · scene.tileAt() · scene.tilemapOf()
U engine.unload() · scene.update() · camera.update() · camera.update() · orbit.update() · overlay.update()
V vec3 · camera.view
W node.weights · camera.width · node.worldPosition() · camera.worldToScreen()
Winding.create(canvas, options) → Promise<Winding>Creates an engine on a <canvas>: requests the WebGPU device, compiles the renderer’s pipelines, and bakes the default environment. The canvas is sized, configured and watched for resizes for you.
| Option | Default | Meaning |
|---|---|---|
label |
'winding' |
Label of the GPU device, shown in WebGPU error messages. |
powerPreference |
'high-performance' |
Passed to requestAdapter: 'high-performance' or 'low-power'. |
onDeviceLost |
null |
(detail) => {} when the GPU goes away. detail is { reason, message, recoverable, action: 'reload' }. Without it the loss is logged to the console. The engine destroys itself either way. |
onError |
null |
(error) => {} for uncaptured WebGPU errors. Without it they go to console.error. |
exposure |
1 |
Starting value of renderer.exposure. |
antialias |
true |
FXAA after the tonemap. Same as post.antialias; see post.antialias. |
grading |
null |
Starting colour grading; see engine.grading. |
post |
{} |
Bloom and post settings: threshold, knee, filterRadius, strength, levels, antialias, grading. See Renderer settings. Values here win over the top-level antialias and grading. |
shadows |
{} |
Shadow maps. See the table below; some fields can change later through renderer.shadows. |
shadowDistance |
null |
Starting value of renderer.shadowDistance. |
lightDistance |
null |
Starting value of renderer.lightDistance. |
ao |
false |
Ambient occlusion: true, or { radius } in world units. Starting value of renderer.ao. |
oit |
false |
Starting value of renderer.oit. |
fog |
null |
Starting value of renderer.fog. |
dof |
null |
Starting value of renderer.dof. |
environment |
{} |
Settings for the default Environment (size, irradianceSize, prefilterMips, sky, map, label). Settings only, not an Environment instance. |
maxDraws |
4096 |
Starting capacity of the draw lists. They grow as needed. |
gpuTiming |
false |
Time every pass on the GPU. Read results from engine.renderer.gpuTiming, and switch at any time with engine.renderer.gpuTiming.enabled. Does nothing on a device without timestamp-query. |
workerCount |
min(cores - 1, 7) |
Transform workers. Workers only run when the page is cross-origin isolated (COOP + COEP); otherwise work runs on the main thread and this is ignored. 0 runs inline. |
onDemand |
true |
Starting value of engine.onDemand. Pass false to draw every frame. |
shadows options:
| Option | Default | Meaning |
|---|---|---|
size |
2048 |
Texels on a side of each directional shadow cascade. |
cascades |
4 |
Cascades per shadow-casting directional light, 1 to 4. |
lambda |
0.7 |
How cascades split the range: 0 uniform, 1 logarithmic. |
casterExtent |
4 |
How far behind each cascade casters are still caught, as a multiple of the cascade’s radius. |
normalBias |
1.5 |
Offset along the surface normal at lookup, in texels. Raise it for shadow acne. |
depthBiasSlope |
-2 |
Slope-scaled depth bias while drawing the map. Fixed at creation. |
depthBiasConstant |
-1 |
Constant depth bias while drawing the map. Fixed at creation. |
localSize |
512 |
Texels on a side of each point or spot shadow view. A point light uses six. |
Returns: the engine.
Throws: if options.environment is an Environment instance; 'WebGPU is not available…' when navigator.gpu is missing; 'No WebGPU adapter…'; 'Could not get a webgpu context from the canvas.'; when the GPU allows fewer storage buffers per stage than the renderer reads; RangeError when shadows.cascades is outside 1 to 4, shadows.size is past the device limit, or shadows.localSize is outside 5 to the device limit; RangeError when an environment size is past the device limit.
import { Winding, Camera } from 'winding-engine';
const engine = await Winding.create(canvas, {
ao: true,
shadows: { size: 4096 },
onDeviceLost: () => location.reload(),
});
Notes: a shadows.shadowDistance is ignored; use the top-level shadowDistance.
engine.createScene(options) → SceneCreates an empty scene lit by an environment. Every scene you draw comes from here.
| Option | Default | Meaning |
|---|---|---|
environment |
engine.environment |
The Environment that lights the scene and draws its background, such as one from engine.loadEnvironment. Several scenes can share one. |
capacity |
4096 |
Starting node capacity (a Scene option). |
renderableCapacity |
capacity |
Starting capacity for drawn meshes (a Scene option). |
lightCapacity |
256 |
Starting light capacity (a Scene option). |
Returns: a Scene with scene.environment set.
Throws: 'createScene: this engine was destroyed'; 'createScene: that Environment belongs to another engine'.
const studio = await engine.loadEnvironment('studio.hdr');
const scene = engine.createScene({ environment: studio });
engine.load(source, options) → Promise<Model>Loads a .glb or .gltf and returns a model that scene.add() takes. All the slow work (download, decode, upload, pipeline compiles) finishes before it resolves, so adding the model never stalls a frame.
source is a URL string, an ArrayBuffer or a Uint8Array.
| Option | Default | Meaning |
|---|---|---|
retainGeometry |
false |
Keep positions and indices (and skin weights and morph deltas) on the CPU, so scene.raycast hits triangles instead of bounding boxes. Costs about 12 bytes a vertex plus 4 an index for as long as the model lives. |
baseURL |
the URL of source |
What relative buffer and image URLs in a .gltf resolve against. Bytes without a baseURL cannot fetch anything. |
fetch |
globalThis.fetch |
Replaces fetch for every download: source when it is a URL, and the buffers and images the file names. Use it to refuse, rewrite or restrict URLs, above all from files you did not write. Every loader that downloads takes this option. |
Returns: a model object: { nodes, roots, meshes, materials, materialIds, animations, skins, lights, cameras, textures, source, engine }. Pass the whole object to scene.add().
Throws: 'load: <url> returned <status>' for a failed download; 'load: this engine was destroyed' if the engine is destroyed before or during the load; errors from the glTF parser (a malformed file, or one past the device’s buffer limit). A failed load frees everything it had made.
const helmet = await engine.load('helmet.glb', { retainGeometry: true });
scene.add(helmet);
Notes: every call allocates, the same file included. Free a model you no longer need with engine.unload. An image larger than the device’s biggest texture is left out, with a warning in the console, and its material uses its factor alone. A relative baseURL is taken against the page, as a relative link in it would be.
engine.unload(asset) → voidFrees the GPU memory of anything a load call returned: a model from load, a texture from loadTexture, a font from loadFont, a LUT from loadLUT, an environment from loadEnvironment, splats from loadSplats, or a target from createTarget. Calling it twice does nothing.
Throws: 'unload: mesh "<name>" is still in a scene; remove it first' for a model any scene still draws; 'unload: this asset was loaded by another engine'; 'unload: this is not something load, loadTexture, loadFont, loadLUT, loadEnvironment, loadSplats or createTarget returned'.
scene.remove(helmetNode);
engine.unload(helmet);
Notes: only models are checked for use. A texture, font, LUT, environment or splats is freed at once, so stop using it first (remove its sprites, text or splat nodes, clear engine.grading, move scenes to another environment).
engine.loadTexture(source, options) → Promise<Texture>Loads an image as a mipmapped GPU texture, for sprites (scene.addSprite({ texture })).
source is a URL string, a Blob, an ImageBitmap, or anything createImageBitmap takes.
| Option | Default | Meaning |
|---|---|---|
srgb |
true |
The image holds colour. Pass false for data such as masks. |
pixelated |
false |
For pixel art: enlarged, each texel stays a hard square, like CSS image-rendering: pixelated. |
label |
'texture' |
GPU label, for error messages. |
fetch |
globalThis.fetch |
Replaces fetch for downloading a URL source. |
Returns: { texture, view, width, height, pixelated }.
Throws: 'loadTexture: <url> returned <status>'; 'loadTexture: this engine was destroyed'; decode errors from createImageBitmap.
const pin = await engine.loadTexture('pin.png', { pixelated: true });
scene.addSprite({ texture: pin, position: [0, 1, 0] });
Notes: the pixels are used as authored, with no colour conversion or premultiplied alpha. Free with engine.unload.
engine.loadFont(css) → Promise<Font>Makes a font for scene.addText from any CSS font the page can use, rasterised as a distance field at the pixel size it names. Waits for a web font to load first.
Returns: a Font.
Throws: 'loadFont: the font needs a size in pixels, like '64px sans-serif'; got '<css>'' when css has no px size; 'loadFont: this engine was destroyed'.
const font = await engine.loadFont('64px Inter');
scene.addText({ font, text: 'Gate 3', size: 0.4 });
Notes: pick a size near the one the text is mostly seen at. It stays sharp above it and down to about an eighth of it. Free with engine.unload.
engine.loadLUT(source, options) → Promise<LUT>Loads a 3D colour lookup table from an Adobe .cube file, for engine.grading.
source is a URL, or the file’s text. Text containing LUT_3D_SIZE is parsed directly; anything else is fetched.
| Option | Default | Meaning |
|---|---|---|
fetch |
globalThis.fetch |
Replaces fetch for downloading source. |
Returns: { size, data, domainMin, domainMax, texture, view }.
Throws: 'loadLUT: <url> returned <status>'; 'loadLUT: this engine was destroyed'; 'parseCube: a 1D LUT; only 3D LUTs are supported'; 'parseCube: LUT_3D_SIZE must be 2 or more…'; 'parseCube: a size N LUT has N³ entries, and this has …'; 'parseCube: cannot read the line …'; 'parseCube: DOMAIN_MAX must be above DOMAIN_MIN'.
engine.grading = { lut: await engine.loadLUT('film.cube') };
Notes: free with engine.unload after taking it out of engine.grading.
engine.loadEnvironment(source, options) → Promise<Environment>Loads a Radiance .hdr panorama and bakes it into an Environment: ambient light, reflections and background. Give it to engine.createScene.
source is a URL, an ArrayBuffer or a Uint8Array.
| Option | Default | Meaning |
|---|---|---|
fetch |
globalThis.fetch |
Replaces fetch for downloading source. |
size |
the power of two at or below map width / 4 | Edge of the baked cube, in texels. A power of two. Smaller costs less memory and gives a softer background; the lighting is the same. |
irradianceSize |
32 |
Edge of the diffuse-light cube. A power of two. |
prefilterMips |
6 |
Roughness levels of the reflection cube, capped by the cube’s mip count. |
label |
'env' |
GPU label prefix. |
Returns: an Environment.
Throws: 'loadEnvironment: <url> returned <status>'; 'loadEnvironment: this engine was destroyed'; the parseHDR errors (including a map past the device’s texture limit); the Environment errors.
const studio = await engine.loadEnvironment('studio.hdr', { size: 256 });
const scene = engine.createScene({ environment: studio });
engine.loadSplats(source, { fetch }) → Promise<Splats>Loads a Gaussian splat capture for scene.addSplats. It reads a .ply as 3D Gaussian Splatting training writes it (binary, with f_dc, opacity, scale and rot properties), or a .splat (32 bytes a splat). Colour comes from the constant spherical-harmonic term; the higher terms are skipped, so colour does not change with the view.
source is a URL, an ArrayBuffer or a Uint8Array. fetch replaces fetch for downloading source.
Returns: { count, min, max }: how many splats, and the corners of the box around their centres, in the capture’s units. Free it with engine.unload once no scene draws it.
Throws: 'loadSplats: <url> returned <status>'; 'splats: a .ply in '<format>' format; only binary_little_endian is read'; 'splats: this .ply is not a splat capture: no <properties>'; 'splats: the .ply is cut short: …'; 'splats: not a .ply, and <n> bytes is not a whole number of 32-byte .splat records'; 'splats: splat <i> has a position that is not a number'; 'splats: a compressed .ply (from SuperSplat) is not read; …'; 'splats: this is a zip -- a .sog, perhaps -- which is not read; …'; 'splats: this is gzipped -- a .spz, perhaps -- which is not read; …'; 'splats: <n> splats need <bytes> bytes in one buffer, past this device's <limit>; …'.
const room = await engine.loadSplats('room.ply');
Notes: the environment is yours. Free it with engine.unload once no scene uses it.
engine.run(scene, camera, { update, frame, hud }) → voidStarts the frame loop on requestAnimationFrame, drawing scene through camera: a Camera for 3D, or a Camera2D for a 2D view. Simulation runs at a fixed rate and drawing at the display’s rate.
| Option | Default | Meaning |
|---|---|---|
update |
none | (dt, elapsed) => {} at a fixed 60 Hz (dt is engine.clock.fixedDt), zero or more times per drawn frame. Put simulation here. |
frame |
none | (alpha, clock) => {} once per frame, before drawing. alpha in [0, 1) is how far this frame sits between the last two simulation steps; interpolate with it. |
hud |
null |
{ scene, camera }: a 2D scene drawn over every frame. camera must be a Camera2D; a plain one is used if left out. See HUD. |
Throws: 'run: this engine was destroyed'; 'run: takes (scene, camera, { update, frame, hud }), the scene first, from createScene()'; 'run: already running; call stop() first'; 'run: hud needs { scene, camera }, the scene from createScene()'; 'run: the hud's camera must be a Camera2D'; 'run: overlay is now hud, with the same { scene, camera }'.
const orbit = new OrbitController(camera, canvas);
engine.run(scene, camera, {
update: (dt) => { x += speed * dt; ship.setPosition(x, 0, 0); },
frame: (alpha, clock) => orbit.update(clock.realDelta),
hud: { scene: hud },
});
Notes: animations and particles advance on real time each frame, before update. Without update, engine.clock.elapsed does not advance. With engine.onDemand on, a frame where nothing changed is skipped. If the canvas leaves the document or the device is lost, the loop destroys the engine.
engine.stop() → voidStops the loop that engine.run started. Call run again to restart it. Safe to call when not running.
engine.stop();
engine.invalidate() → voidMakes the next frame of engine.run draw even if nothing seems to have changed. Only matters while engine.onDemand is on.
paint(canvasTexture); // your own GPU writes: run cannot see them
engine.invalidate();
Notes: run already notices changes to the scene (assigning scene.environment included), the camera, the canvas size, debug lines and every setting under Renderer settings, the live shadows fields included. Call this for changes it cannot see, such as drawing into a texture a sprite shows.
engine.renderFrame(scene, camera, { hud, target }) → voidDraws one frame now. Use it when you own the loop instead of calling engine.run, or to draw into a target.
| Option | Default | Meaning |
|---|---|---|
hud |
null |
{ scene, camera }, as run takes it. |
target |
null |
A target from engine.createTarget to draw into, in place of the canvas. |
Throws: 'renderFrame: this engine was destroyed'; "renderFrame: target must be one this engine's createTarget made"; 'renderFrame: that target was unloaded'; 'Renderer: the scene has no environment'; the hud errors listed under run, named renderFrame; setting errors such as a bad renderer.fog, renderer.dof or engine.grading.
function tick() {
engine.renderFrame(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
Notes: it does not move the scene on, skip idle frames, or update engine.fps. Call scene.advance(dt) yourself.
engine.createTarget({ size, pixelated, label }) → Promise<Target>A texture to draw a scene into, with renderFrame’s target. A sprite shows it as it would a loaded image: a minimap, a screen in a room, a split screen, or pixel art drawn small and shown large. A 2D or a 3D scene can be drawn into it.
| Option | Default | Meaning |
|---|---|---|
size |
required | [width, height] in the target’s own pixels. |
pixelated |
false |
Shown enlarged with hard edges, as loadTexture’s is. |
label |
'target' |
GPU label, for error messages. |
Returns: { texture, view, width, height, pixelated }, which a sprite takes as its texture.
Throws: 'createTarget: size must be [width, height], whole pixels from 1 to N, …'; 'createTarget: this engine was destroyed'.
const map = await engine.createTarget({ size: [160, 160] });
hud.addSprite({ texture: map, pivot: [0, 0], position: [16, 16] });
// whenever the map should change -- every frame, or when something on it moves:
engine.renderFrame(world, overhead, { target: map });
Notes:
run draw, even with onDemand on, since what shows the target has changed.engine.unload.engine.captureProbes(scene, probeNodes) → Promise<void>Renders each reflection probe’s view of the scene (six faces) and prefilters it, so nearby surfaces reflect it. Captures all of the scene’s probes, or only the probe nodes you pass.
Throws: 'captureProbes: this engine was destroyed'; 'captureProbes: this node is not a reflection probe'; 'captureProbes: the scene has no environment'.
const hall = scene.addProbe({ position: [0, 2, 0], size: [10, 4, 16] });
await engine.captureProbes(scene);
// later, after the hall's contents change:
await engine.captureProbes(scene, [hall]);
Notes: this costs six scene renders per probe, so do it at load time or after what a probe sees has changed, not every frame. A probe that moves shows nothing until captured again.
engine.destroy() → voidStops the loop and frees everything the engine owns: workers, renderer, its default environment and the GPU device. Calling it again does nothing.
engine.destroy();
Notes: afterwards every method that uses the GPU throws '<method>: this engine was destroyed'. Environments from engine.loadEnvironment die with the device. The engine also destroys itself when the device is lost or the canvas leaves the document.
engine.grading → object | nullColour grading, read and set at any time. The same object as post.grading. Setting undefined or null turns it off.
| Field | Default | Meaning |
|---|---|---|
whiteBalance |
off | Colour temperature in kelvin that comes out white, 1667 to 25000. 3200 undoes tungsten orange; 10000 undoes overcast blue. |
contrast |
1 |
Contrast about middle grey, in stops. Must be above 0. |
saturation |
1 |
0 is grey, 1 unchanged. Must be 0 or more. |
lut |
none | A LUT from engine.loadLUT, applied after the tonemap. |
Throws (on the next frame): 'whiteBalance: a colour temperature from 1667 K to 25000 K, got …'; 'grading: contrast must be positive, got …'; 'grading: saturation must be 0 or more, got …'.
engine.grading = { whiteBalance: 5000, contrast: 1.1, saturation: 0.9 };
engine.grading = null;
Notes: grading is part of the 3D post chain. A 2D view (Camera2D) does not use it.
engine.stats → objectCounts from the last frame drawn. Read only. The first five fields start at 0; the others appear once a frame of that kind has set them.
| Field | Set by | Meaning |
|---|---|---|
renderables |
3D | Meshes in the scene. |
draws |
3D | Indirect draw batches (one per mesh part and material). |
recomposed |
3D, 2D | Transforms recomputed this frame. |
transparent |
3D | Blended objects this frame. |
transparentDraws |
3D | Draw calls for blended objects. |
shadowViews |
3D | Point and spot shadow views. |
shadowViewsDrawn |
3D | Of those, how many were redrawn (the rest were cached). |
cascadesDrawn |
3D | Directional shadow cascades redrawn. |
sprites2D |
2D | Sprites and glyphs in the 2D view. |
sprites2DWritten |
2D | Of those, how many were re-uploaded. |
tiles2DWritten |
2D | Tile-map tiles re-uploaded. |
emitters |
2D | Particle emitters drawn in the 2D view. |
hudSprites |
HUD | Sprites and glyphs in the HUD. |
hudSpritesWritten |
HUD | Of those, how many were re-uploaded. |
splats |
3D | Splats in the clouds drawn, before culling. |
console.log(`${engine.stats.draws} draws, ${engine.stats.renderables} meshes`);
Notes: CPU time per phase is in engine.renderer.timing (transforms, upload, graph, encode, total, in milliseconds). There is no visible-object count: culling runs on the GPU and is never read back.
engine.debug → DebugLinesLines drawn for one frame, for debugging. See Debug lines.
engine.debug.axes([0, 0, 0], 1);
engine.fps → numberFrames per second of the engine.run loop, updated every half second. 0 until run has gone half a second. Read only.
Notes: skipped idle frames count, so this is the loop’s rate, not the number of frames drawn.
engine.skippedFrames → numberHow many frames run skipped because nothing had changed. Read it to check that a still scene is idle.
engine.onDemand → booleanDefault true (from the onDemand option). When on, run skips a frame that would draw exactly what the last one drew, so a still scene costs the GPU nothing. Set false to draw every frame. See engine.invalidate.
engine.onDemand = false;
engine.clock → ClockThe fixed-step clock run drives.
| Field | Meaning |
|---|---|
fixedDt |
Simulation step in seconds, 1 / 60. Writable: change it to change the update rate. |
elapsed |
Simulated seconds so far. Only advances when run has an update. |
realDelta |
Real seconds since the last frame, capped at 0.25. |
alpha |
How far between the last two steps this frame is, in [0, 1). |
frame |
Frames begun. |
engine.run(scene, camera, { frame: (alpha, clock) => orbit.update(clock.realDelta) });
engine.renderer → RendererThe renderer. Its live settings are listed under Renderer settings.
engine.environment → EnvironmentThe default environment, made from the environment option of Winding.create. Scenes use it unless given another. Freed by engine.destroy.
engine.gpu → DeviceThe GPU layer. engine.gpu.device is the raw GPUDevice; engine.gpu.width and engine.gpu.height are the canvas size in pixels.
engine.gpu.pixelRatio → numberCanvas pixels per CSS pixel: 2 on most phones, 1.5 at 150% Windows scaling. Measured from the canvas, so it is exact after rounding. Falls back to devicePixelRatio while the canvas has no CSS width. Read only.
const x = event.offsetX * engine.gpu.pixelRatio;
engine.gpu.readPixels({ x, y, width, height }) → Promise<Uint8Array>The canvas’s pixels as RGBA bytes, row by row from the top-left: the whole canvas, or the region given in canvas pixels. Given only x and y, the region runs to the canvas’s far edges. For screenshots and tests.
engine.renderFrame(scene, camera);
const pixels = await engine.gpu.readPixels();
const at = (x, y) => pixels.subarray((y * engine.gpu.width + x) * 4, (y * engine.gpu.width + x) * 4 + 4);
Notes: call it straight after renderFrame, with nothing awaited between. The browser shows a frame and hands the canvas a fresh, blank one as soon as the page waits for anything, so a read after an await returns zeros. For the same reason, read once a frame and index into the result, rather than reading pixel by pixel.
Plain fields on engine.renderer and engine.renderer.post. Set them at any time; the next frame uses them, and engine.run notices the change. They apply to 3D views. A 2D view (Camera2D) draws without them.
engine.renderer.exposure → numberDefault 1 (the exposure option). Multiplies the scene’s linear colour before the tonemap. 2 is one stop brighter.
engine.renderer.exposure = 0.5;
engine.renderer.resolution → numberDefault 1. The share of the canvas’s width and height the 3D view is drawn at, from 0.5 to 1. NVIDIA Image Scaling then brings it up to the canvas size and sharpens it. At 0.75 the scene shades 44% fewer pixels, so on a GPU-bound scene the frame is cheaper. The scaler itself costs about 2 ms at 1920x1080 on integrated graphics (Intel Iris Xe), and less on a discrete GPU.
Throws (on the next frame): 'resolution must be from 0.5 to 1, …'.
engine.renderer.resolution = 0.75;
Notes: only frames drawn to the canvas are scaled. A target keeps the size it was made at. The HUD and the stats overlay are drawn at the canvas’s full resolution, after scaling, so text stays crisp. Debug lines are drawn into the 3D view, so they are scaled with it. Sprites sized in pixels keep their size on screen. The first time a lower resolution is set, the scaler builds in the background, and frames are stretched without sharpening until it is ready, as with ao.
engine.renderer.fog → object | nullDefault null (no fog). Exponential height fog. Its colour is not chosen: it scatters the environment’s light and the directional lights’ light, times albedo.
| Field | Default | Meaning |
|---|---|---|
visibility |
required | Metres at which a dark object fades to 2% contrast, measured at height. Must be above 0. |
height |
0 |
Height where the density matches visibility. |
scaleHeight |
uniform | Metres over which the density falls by a factor of e going up. Leave out for fog that is the same at every height. |
albedo |
[1, 1, 1] |
Share of light the fog scatters rather than absorbs, per channel. Three numbers, 0 or more. |
Throws (on the next frame): 'fog: visibility must be a positive number of metres…'; 'fog: height must be a finite number…'; 'fog: scaleHeight must be positive, or left out for uniform fog…'; 'fog: albedo must be three non-negative numbers…'.
engine.renderer.fog = { visibility: 200, scaleHeight: 20 };
Notes: shadows inside the fog are not modelled.
engine.renderer.dof → object | nullDefault null (off). Depth of field from a real lens: the focal length comes from the camera’s fovY on a sensor of sensorHeight.
| Field | Default | Meaning |
|---|---|---|
focusDistance |
required | Distance in focus, in world units (metres). Must be past the lens’s focal length. |
fStop |
required | Aperture. Lower is blurrier. Must be above 0. |
sensorHeight |
0.024 |
Sensor height in metres (full frame). |
Throws (on the next frame): 'dof: focusDistance must be positive…'; 'dof: fStop must be positive…'; 'dof: sensorHeight must be positive…'; 'dof: focusDistance must be past the lens's focal length…'.
engine.renderer.dof = { focusDistance: 3, fStop: 1.8 };
Notes: skipped for an orthographic camera.
engine.renderer.ao → { radius } | nullDefault null (off; the ao option). Ambient occlusion. radius is how far occlusion reaches, in world units. A null radius means a thirty-second of the scene’s bounding radius, worked out each frame. Assign { radius } to turn it on and null to turn it off.
engine.renderer.ao = { radius: 0.5 };
engine.renderer.ao.radius = 1;
engine.renderer.ao = null;
Throws (on the next frame): 'ao: radius must be positive, or null to fit the scene, …'.
Notes: the first time it is turned on, its pipelines build in the background. Frames draw without it until they are ready, a fraction of a second. After that, switching costs nothing. The same holds for oit and post.antialias.
engine.renderer.oit → booleanDefault false (the oit option). Weighted-blended order-independent transparency for blended materials, in place of back-to-front sorting. Good for smoke and foliage; sorting stays exact for separate glass objects.
engine.renderer.oit = true;
Notes: builds in the background the first time, like ao.
engine.renderer.shadows → ShadowMapsThe shadow maps. Three of the shadows options can change at any time as fields here: lambda, casterExtent and normalBias.
engine.renderer.shadows.normalBias = 2.5; // less acne
Notes: size, cascades, localSize and the two depth biases are fixed at creation. The textures and pipelines are built with them.
engine.renderer.skybox → booleanDefault true. Whether the environment is drawn as the background. Turning it off shows black behind the scene and changes nothing about lighting.
engine.renderer.skybox = false;
engine.renderer.shadowDistance → number | nullDefault null. How far from the camera directional shadows reach, in world units. null fits the whole scene: the distance to its farthest point, rounded up to a power of two so shadows stay stable. Set a number to spend the shadow map’s resolution on a shorter range.
engine.renderer.shadowDistance = 40;
engine.renderer.lightDistance → number | nullDefault null. How far along the view point and spot lights are resolved, in world units. null uses the scene’s farthest depth from the camera each frame. Set a number to pin it.
engine.renderer.lightDistance = 100;
engine.renderer.post.threshold → numberDefault 1.2. Linear brightness (brightest channel) where bloom starts. Only light brighter than white blooms at the default.
engine.renderer.post.knee → numberDefault 0.6. Width of the soft ramp around threshold, as a fraction of it, so bloom fades in instead of popping on.
engine.renderer.post.filterRadius → numberDefault 1. Blur radius of each bloom upsample, in texels. Larger is a wider, softer halo.
engine.renderer.post.strength → numberDefault 0.06. How much of the image is bloom, 0 to 1 (clamped). Bloom is mixed in, not added, so raising it moves light into the halo without brightening the image.
engine.renderer.post.strength = 0.15;
engine.renderer.post.levels → numberDefault 5 (the post.levels option). How many times bloom halves the image, 1 to 6. More is a wider halo. Values outside that are clamped each frame, and a frame uses fewer if the canvas is too small.
engine.renderer.post.levelsDrawn is the count the last frame drew. Read only.
const engine = await Winding.create(canvas, { post: { levels: 6 } });
engine.renderer.post.levels = 3;
engine.renderer.post.antialias → booleanDefault true (the antialias option). FXAA after the tonemap. Off writes the tonemapped image straight to the screen.
engine.renderer.post.antialias = false;
Notes: an engine created with antialias: false builds FXAA the first time it is turned on. Frames draw without it until it is ready, as with ao.
engine.renderer.post.grading → object | nullDefault null. The colour grading object. Same as engine.grading, which is the usual way to set it.
engine.debug draws lines for exactly one frame. Call it every frame you want them seen; nothing needs removing. Every call returns engine.debug, so calls chain.
In 3D, lines are drawn after the tonemap, straight onto the screen, with no exposure, bloom or antialiasing. Colours are linear, like every 3D colour, but clamped to 0–1 here (they cannot glow). The scene’s depth hides them behind geometry unless depthTest is off.
Through a Camera2D they are drawn over the 2D view with no depth, and colours are sRGB, like CSS. Points can be [x, y].
Colours are [r, g, b]; a fourth value is ignored. The default is white. Lines are one pixel wide.
engine.debug.line(from, to, color) → DebugLinesA segment from from to to. Points are [x, y, z], or [x, y] at z = 0.
engine.debug.line([0, 0, 0], [0, 2, 0], [1, 0, 0]);
engine.debug.box(min, max, color) → DebugLinesWith [x, y, z] corners, the twelve edges of an axis-aligned box. With [x, y] corners, a rectangle’s four edges.
engine.debug.box([-1, 0, -1], [1, 2, 1], [1, 1, 0]);
engine.debug.box([10, 10], [110, 60], [1, 0.5, 0]); // 2D rectangle
engine.debug.sphere(center, radius, color) → DebugLinesThree circles, one around each axis, at center ([x, y, z], or [x, y] at z = 0).
engine.debug.sphere([0, 1, 0], 0.5, [0, 1, 1]);
engine.debug.circle(center, radius, color) → DebugLinesA circle in the x-y plane (a 2D view’s plane) around center, [x, y] or [x, y, z].
engine.debug.circle([160, 120], 24, [0, 1, 0]);
engine.debug.axes(origin, size) → DebugLinesThe world axes at origin ([x, y, z], or [x, y] at z = 0): x red, y green, z blue, each size long (default 1).
engine.debug.axes([0, 0, 0], 0.5);
engine.debug.depthTest → booleanDefault true. Whether scene geometry hides lines behind it. Set false to draw them on top. Has no effect in a 2D view.
engine.debug.depthTest = false;
A scene holds everything in the world: models, lights, emitters, decals, probes, and the 2D kinds. Everything in it is a Node. Every kind follows one pattern: scene.addX(options) returns a Node, scene.setX(node, changes) changes some options, scene.xOf(node) returns a copy of the options or null, and scene.remove(node) or node.destroy() removes it.
new Scene(options) → SceneMakes an empty scene with no lights. You normally get one from engine.createScene(options), which passes these options through and sets scene.environment.
| Option | Default | Meaning |
|---|---|---|
capacity |
4096 |
Nodes to make room for up front. |
renderableCapacity |
capacity |
Mesh primitives to make room for up front. |
lightCapacity |
256 |
Point and spot lights to make room for up front. |
All three are starting sizes. Each store grows when it fills.
Throws: if the node count ever needs more than 2^24 slots (HandleAllocator: ... exceeds the 24-bit index space).
const scene = engine.createScene({ capacity: 20000 });
scene.environmentThe Environment that gives the scene its ambient light, reflections and background. engine.createScene sets it: to the engine’s own environment, or to the one you pass as createScene({ environment }). A scene with no lights is lit by its environment alone.
scene.createNode(options) → NodeMakes an empty node. Use it to group things you move together, or as a mount point for a camera or light.
| Option | Default | Meaning |
|---|---|---|
parent |
null |
Node to attach it to. null puts it at the scene root. |
Returns: the new Node, at the origin of its parent’s space.
Throws: 'createNode: parent was removed'; 'createNode: parent is a node of another scene'. Every addX that takes a parent checks it the same way, named for itself.
const mount = scene.createNode({ parent: car });
mount.setPosition(0, 2, 6);
scene.node(entity) → NodeWraps an entity handle (a number, as in node.entity) in a Node. It does not check that the entity is alive; check node.alive.
scene.childrenOf(node) → Node[]The node’s direct children, oldest first. Returns [] for a node that is no longer alive. Same as node.children().
scene.add(asset, options) → NodePuts a loaded glTF model into the scene. asset is what engine.load returned. Synchronous: all the slow work already happened in engine.load. Add the same asset as many times as you like; each copy is independent.
| Option | Default | Meaning |
|---|---|---|
parent |
null |
Node to attach the model to. |
Returns: the model’s root Node. A file with several root nodes gets one extra wrapper node, so you always get one Node to move the whole model by. Only this Node can play the model’s clips (see node.play).
Throws:
Scene.add: this asset was unloaded; load it again, if the asset was passed to engine.unload.Nothing is left in the scene when it throws.
const robot = scene.add(await engine.load('robot.glb'));
robot.setPosition(0, 0, -3);
Notes: lights in the file become lights in the scene (see Lights); a directional one casts shadows by default. Cameras in the file become Camera objects that already follow their nodes, pushed onto scene.cameras in the order added.
scene.remove(node)Removes a node and everything under it: meshes, lights, sprites, emitters, decals, probes, text, and every child. Does nothing for a node that is already gone.
scene.remove(robot);
Notes: a camera imported with the model is dropped from scene.cameras. A camera you made that follows a removed node stops following and stays where it was. Same as node.destroy().
scene.update() → numberRecomputes world matrices from the positions, rotations and scales you set. engine.run calls it every frame; call it yourself only if you need world positions (for example node.worldPosition) before the next frame.
Returns: how many transforms were recomputed.
scene.advance(dt) → voidMoves the scene on by dt seconds: every playing clip, every sprite animation, and every emitter’s clock (the GPU catches the particles up on the next frame). engine.run calls it once per frame with the real elapsed time, for the scene and its HUD. Call it yourself only when you drive frames with renderFrame. A negative or NaN dt moves nothing.
scene.advance(clock.realDelta);
engine.renderFrame(scene, camera);
scene.bounds(outMin, outMax) → booleanWrites the world-space box around every mesh in the scene into outMin and outMax (each a 3-element array). Brings transforms up to date first.
Returns: true, or false if the scene has no meshes, in which case outMin and outMax are left alone.
const min = vec3Create(), max = vec3Create();
if (scene.bounds(min, max)) console.log(min, max);
Notes: only meshes from scene.add count. Sprites, text, particles and lights do not.
scene.frame(camera, options) → booleanMoves a camera so the whole scene fills the view, keeping the direction it already looks from. It is scene.bounds followed by camera.frameBounds.
| Option | Default | Meaning |
|---|---|---|
margin |
1 |
Multiplies the fitting distance. 1 fits exactly; 1.2 leaves more room. |
aspect |
camera.aspect |
Width over height of the view. |
Returns: true, or false if the scene has no meshes, having moved nothing.
scene.frame(camera, { aspect: canvas.clientWidth / canvas.clientHeight });
Notes: camera.aspect is 1 until the camera’s first update, so pass aspect when you frame during setup. With an OrbitController, frame through the controller’s frameBounds instead; it owns the camera’s position.
scene.raycast(origin, direction, options) → { node, renderable, distance } | nullFinds the nearest mesh a ray hits. Brings transforms and bounds up to date first, so the answer is right even if nothing has rendered since the last move.
| Option | Default | Meaning |
|---|---|---|
maxDistance |
Infinity |
Ignore hits this far or further. |
origin and direction are world-space 3-element arrays. Normalize direction, or distance comes back scaled by its length.
Returns: { node, renderable, distance } for the nearest hit, or null. node is the Node that carries the mesh. renderable is an index into the scene’s mesh list and changes when anything is removed, so do not keep it.
Throws: if origin or direction has a non-finite component (raycast origin: non-finite at ...).
const hit = scene.raycast([0, 5, 0], [0, -1, 0], { maxDistance: 20 });
if (hit) console.log('ground at', 5 - hit.distance);
Notes: the test is exact, triangle by triangle and as skinned or morphed, only for models loaded with engine.load(url, { retainGeometry: true }). Other models are hit at their bounding box. Only meshes are hit; for sprites and 2D things use scene.pick with a Camera2D. A mesh drawn in levels of detail is hit by its finest level: a ray has no distance to choose one by.
scene.pick(camera, x, y, width, height, options) → { node, renderable, distance } | nullFinds the nearest mesh under a point on the canvas. x, y are CSS pixels from the canvas’s top-left; width, height are the canvas’s CSS size. That is what a pointer event and getBoundingClientRect() give you. With a 3D Camera it builds the ray with camera.rayFromScreen and calls scene.raycast; options are raycast’s.
Returns: as scene.raycast.
Throws: if width or height is not positive, or the ray is not finite.
canvas.addEventListener('click', (e) => {
const r = canvas.getBoundingClientRect();
const hit = scene.pick(camera, e.clientX - r.left, e.clientY - r.top, r.width, r.height);
if (hit) hit.node.setScale(1.2);
});
Notes: with a Camera2D it returns what the 2D view shows there instead, topmost first: { node, point }, plus tile: [column, row] for a tilemap, or null. See scene.pick with a Camera2D.
A Node is a handle to one thing in a scene. It stores nothing itself: node.scene is its scene and node.entity its entity handle. Two Node objects can refer to the same thing, so compare a.entity === b.entity, not a === b.
Transforms are set through methods, never by writing to properties. Positions, rotations and scales are in the parent’s space. The setters return the node, so they chain:
lamp.setPosition(2, 3, 0).setDirection(0, -1, -0.5);
The transform setters throw on a non-finite value (setPosition: non-finite in (…)), before writing anything, so a caught throw leaves the node where it was.
node.alive → booleanfalse once the node has been removed. A removed node’s methods must not be used.
node.setPosition(x, y, z = 0) → NodePlaces the node in its parent’s space. z defaults to 0, so setPosition(x, y) places a 2D node.
node.setAngle(radians) → NodeTurns the node about Z, in the screen’s plane: the rotation for 2D. With a Camera2D, whose y points down, a positive angle turns clockwise, as CSS rotate() does. Replaces the node’s whole rotation.
node.setScale(x, y, z) → NodeOne number scales every axis. Two numbers set x and y and leave z at 1, for a 2D node: setScale(-1, 1) mirrors it. Three are used as given.
tree.setScale(2); // 2, 2, 2
sprite.setScale(-1, 1); // -1, 1, 1
box.setScale(1, 2, 0.5);
node.setRotation(q) → NodeSets the rotation from a quaternion [x, y, z, w]. For angles, use setAxisAngle, setEuler or setDirection.
node.setDirection(x, y, z = 0) → NodeTurns the node so its -Z points along (x, y, z), keeping +Y as upright as it can. That is the way a spot light shines, a directional light’s light travels, and a followed camera looks. The vector need not be unit length. setDirection(x, y) aims across a 2D view. Pointing straight up or down has no upright answer; it then takes the shortest turn from -Z.
key.setDirection(-0.4, -0.7, -0.3);
node.setAxisAngle(axis, radians) → NodeSets the rotation to radians about axis.
Throws: if axis is not unit length (quatSetAxisAngle: axis must be normalized).
decal.setAxisAngle([1, 0, 0], -Math.PI / 2);
node.setEuler(yaw, pitch, roll = 0) → NodeSets the rotation from angles in radians, applied in YXZ order: yaw about Y, pitch about X, roll about Z. The angles are not stored.
node.setParent(node) → NodeAttaches the node to another. null moves it to the scene root. Its position, rotation and scale are kept as numbers, now in the new parent’s space, so it moves in the world if the parents differ.
Throws: setParent: would create a cycle in the transform hierarchy, if the new parent is the node itself or below it; setParent: parent was removed; setParent: parent is a node of another scene.
node.worldPosition(out) → outWrites the node’s world position into out (a 3-element array) and returns it.
const p = node.worldPosition(vec3Create());
Notes: the value is as of the last scene.update(). Straight after setPosition it returns the old position until the scene updates.
node.children() → Node[]The node’s direct children, oldest first; [] if the node is gone. Same as scene.childrenOf(node).
node.destroy()Removes the node and everything under it. Same as scene.remove(node). Returns nothing.
new Camera(options) → CameraA 3D camera, perspective by default. It looks from position toward target. engine.run calls its update each frame.
| Option | Default | Meaning |
|---|---|---|
fovY |
Math.PI / 3 |
Vertical field of view, radians. The horizontal one follows from the aspect. |
near |
0.1 |
Nearest distance drawn. Precision is near-uniform, so this can be small without z-fighting. |
orthographic |
false |
Parallel projection: size on screen does not change with distance. |
far |
1000 |
Where an orthographic view ends. Used only when orthographic is true. |
A perspective camera has no far plane: it draws to infinity, and there is no draw distance to set. Only an orthographic camera has far.
An orthographic camera shows a height of 2 * distance * tan(fovY / 2), where distance is from position to target: what a perspective camera would see at the target. Move the camera closer or further to zoom.
const camera = new Camera({ fovY: 0.8 });
camera.position.set([0, 2, 6]);
camera.target.set([0, 1, 0]);
| Property | Default | Meaning |
|---|---|---|
position |
[0, 0, 5] |
Where the camera is. A Float32Array; write into it. |
target |
[0, 0, 0] |
The point it looks at. |
up |
[0, 1, 0] |
Which way is up. Need not be exactly perpendicular to the view. |
fovY |
from options | Vertical field of view, radians. |
near |
from options | Nearest distance drawn. |
orthographic |
from options | Switch at any time; a camera switched to orthographic gets far = 1000 if it had none. |
far |
from options | Orthographic only. |
aspect |
1 |
Read only: the aspect from the last update. |
following |
null |
Read only: the node set by follow. |
view, projection, viewProjection, inverseProjection |
Read only: matrices from the last update. |
camera.follow(node) → CameraMakes the camera ride a node: every update takes its position and aim from the node’s world transform. The node’s -Z is the view direction and its +Y is up; scale is ignored. null stops following and leaves the camera where it was.
const mount = scene.createNode({ parent: car });
mount.setPosition(0, 2, 6);
camera.follow(mount); // a chase camera
Notes: while following, position, target and up are overwritten every update, and an OrbitController on this camera stands aside. To hand control back, call follow(null) then controller.syncFromCamera(). The distance from position to target is kept, which sets an orthographic camera’s view height. If the node is removed, the camera stops following and stays put.
camera.frameBounds(min, max, options) → CameraMoves the camera so a world-space box fills the view, keeping the direction it already looks from. It fits the box’s bounding sphere, so the fit does not change as you orbit.
| Option | Default | Meaning |
|---|---|---|
margin |
1 |
Multiplies the fitting distance. 1 fits the sphere exactly, which already leaves some air around the box. |
aspect |
camera.aspect |
Width over height. camera.aspect is 1 before the first update, so pass it during setup. |
Throws: if min or max has a non-finite component.
camera.frameBounds([-1, 0, -1], [1, 2, 1], { aspect: 16 / 9, margin: 1.1 });
Notes: an orthographic camera’s far is pushed out if the box would not fit. See also scene.frame.
camera.orthographicHalfHeight() → numberHalf the world height an orthographic camera shows: distance * tan(fovY / 2), with distance from position to target.
camera.update(aspect) → CameraRecomputes the matrices from position, target, up, fovY and the followed node. aspect is the view’s width over height. engine.run calls it every frame; call it yourself only when you drive frames yourself.
camera.rayFromScreen(x, y, width, height, outOrigin, outDirection) → outDirectionThe world-space ray through a point on the canvas. x, y are CSS pixels from the canvas’s top-left; width, height the canvas’s CSS size, not its backing-store size. Writes the ray into outOrigin and outDirection (3-element arrays) and returns outDirection.
A perspective ray starts at the camera’s position and its direction is unit length. An orthographic ray starts on the camera’s plane under the point and points straight down the view.
Throws: if width or height is not positive.
const origin = vec3Create(), dir = vec3Create();
camera.rayFromScreen(x, y, rect.width, rect.height, origin, dir);
Notes: the aspect is width / height, so the ray is right before the first frame and straight after a resize. scene.pick does this and the raycast in one call.
Models with clips play them through the Node scene.add returned. engine.run advances them every frame.
node.play(nameOrIndex, options) → NodeStarts a clip, by name or by index into node.animations. Without a fade, the new clip replaces what the layer was playing. The pose changes on the next scene.advance.
| Option | Default | Meaning |
|---|---|---|
loop |
true |
Repeat. A clip that does not loop stops on its last frame. |
speed |
1 |
Playback rate. Negative plays backwards; a clip that does not loop then stops at its start. |
time |
0 |
Where to start, in seconds. |
fade |
0 |
Seconds to cross-fade from what the layer is playing. On the base layer with nothing playing, the clip starts at full weight. On another layer it fades the layer in. |
layer |
'base' |
Which layer to play on. Make other layers first with node.animation.layer(name). |
weight |
1 |
The clip’s weight within its layer, when fully in. |
join |
false |
Join the clips already playing on the layer instead of replacing them. Move weights with node.animation.setWeight. |
sync |
false |
Share one clock with the layer’s other synced clips, measured in cycles, so clips of different lengths stay in step. A synced clip loops, and one joining a group starts at the group’s place, ignoring time. |
Returns: the node. Does nothing if there is no such clip, or the node has no player.
Throws: AnimationPlayer: no layer named "..."; make it with layer() first, for an unknown layer. A RangeError if weight is negative or not finite.
const model = scene.add(asset);
model.play('Walk');
model.play('Run', { fade: 0.3 }); // cross-fade over 0.3 s
node.stop(options) → NodeStops every layer, or one.
| Option | Default | Meaning |
|---|---|---|
layer |
every layer | The layer to stop. |
fade |
0 |
Seconds to fade out. Without a fade the pose stays where it is. With one, a layer above the base hands the nodes back to the layers beneath. |
Throws: for an unknown layer.
model.stop({ layer: 'upper', fade: 0.2 });
node.animation → AnimationPlayer | nullThe model instance’s player, or null (not the returned root of a model, or no clips). Use it for layers, weights and root motion.
| Member | Meaning |
|---|---|
names |
Clip names. |
play(nameOrIndex, options) |
As node.play, but returns false if there is no such clip. |
stop(options) |
As node.stop. |
layer(name, { mask, weight, additive }) |
Makes a layer, or changes one; layers apply in the order made. mask: a node name or list of names, each with everything under it; null clears it. weight scales the layer. additive: true adds each clip’s change from its first keyframe instead of covering the pose beneath. Throws for an additive base layer, a negative weight, or a mask name no node has. |
setWeight(nameOrIndex, weight, { layer = 'base', fade = 0 }) |
Moves a playing clip’s weight, at once or over fade seconds. Returns false if the clip is not playing on that layer. Weight 0 keeps it playing, silent. |
rootMotion({ node, vertical = false, apply = true }) |
Moves the instance by the travel of one node (default: the highest node below the instance that a clip translates), and holds that node in place. vertical: true includes height. apply: false only measures, into motion. rootMotion(null) turns it off. Throws if it cannot choose the node; name it with node. |
motion |
{ position, yaw }: what root motion moved the instance by in the last advance. |
clip, time, speed, loop, finished |
The base layer’s newest clip. time, speed and loop can be set. finished is true once a clip that does not loop has reached its end. |
const player = model.animation;
player.layer('upper', { mask: 'Spine' });
model.play('Wave', { layer: 'upper', fade: 0.2 });
node.animations → string[]The clip names this instance can play; [] if it has none.
node.weights → Float32Array | nullThe morph target weights of the mesh on this node, or null if it has none. A live array: write to it directly.
face.weights[0] = 1; // full smile
Notes: the weights belong to the node that carries the mesh, which is often a child of the model’s root, not the root itself. A clip that animates the weights overwrites what you write.
A light is a node. Its position and aim come from its node’s transform, so it moves, parents and animates like anything else. Change its colour, brightness, reach and cone with scene.setLight. A new scene has no lights; its environment lights it until you add one.
scene.addLight(options) → NodeAdds a point, spot or directional light.
| Option | Default | Meaning |
|---|---|---|
type |
'point' ('spot' with a direction) |
'point', 'spot' or 'directional'. |
position |
[0, 0, 0] |
In the parent’s space. [x, y] places it in a 2D view. A directional light’s position does not matter. |
direction |
null |
Aims the node’s -Z this way: where a spot shines and where a directional light’s light travels. [x, y] aims across a 2D view. After this the node’s rotation carries the aim. |
color |
[1, 1, 1] |
Linear RGB, each 0 or more. |
intensity |
1 |
Multiplies the colour. 0 or more. |
radius |
10 |
Where a point or spot light fades to exactly zero. Above 0. Not used by a directional light. |
innerAngle |
0.2 |
Spot only: radians from the axis where the cone starts to fade. |
outerAngle |
0.5 |
Spot only: radians from the axis where the cone reaches zero. 0 ≤ innerAngle ≤ outerAngle ≤ π/2; equal angles give a hard edge. |
parent |
null |
Node to attach it to. The light then follows and aims with its parent. |
castShadow |
true for directional, false otherwise |
Whether it casts shadows. |
Returns: the light’s Node.
Throws: 'addLight: type must be point, spot or directional, got …'; 'addLight: color must be 3 finite numbers, 0 or more, got …'; 'addLight: intensity must be 0 or more, got …'; 'addLight: radius must be positive, got …'; 'addLight: angles need 0 <= innerAngle <= outerAngle <= PI/2, got …'.
const lamp = scene.addLight({ position: [0, 3, 0], color: [1, 0.7, 0.4], intensity: 20, radius: 8 });
const sun = scene.addLight({ type: 'directional', direction: [-0.4, -0.8, -0.4], intensity: 3 });
const torch = scene.addLight({ direction: [0, 0, -1], parent: hand }); // a spot that aims where the hand aims
Notes:
direction shines along -Z. Give it one, or turn its node.direction without type makes a spot. For a directional light, say type: 'directional'.outerAngle is wider than 45 degrees. A point light gets six, one per cube face. A point or spot light draws no shadow maps while its radius sphere is off screen. Maps are redrawn only when something within the light’s reach moves.2D: through a Camera2D, point and spot lights also light every sprite, shape, path, text or tilemap made with lit: true, fading to nothing at radius (in the view’s units). Give position: [x, y] and, for a spot, direction: [x, y]. Directional lights and shadows do not apply in 2D. Where no light reaches, the camera’s ambient lights it.
scene.addLight({ position: [120, 80], radius: 90, color: [1, 0.7, 0.4], intensity: 1.5 });
scene.addLight({ position: [40, 60], direction: [1, 0], radius: 200, outerAngle: 0.4 });
scene.setLight(node, changes)Changes what a light is, without moving it: the same names addLight takes. Only the fields given change. Move or aim it through its node.
| Change | Applies to |
|---|---|
color, intensity, castShadow |
every type |
radius |
point, spot |
innerAngle, outerAngle |
spot |
Throws: 'setLight: this node is not a light'; 'setLight: <type, position, direction or parent> can't change here; …'; 'setLight: a <type> light has no <field>' for a field the table does not list for its type; the value errors of addLight, named setLight. A spot’s angles are checked together, so changing one alone must still fit the other.
scene.setLight(lamp, { intensity: 30, castShadow: true });
lamp.setPosition(2, 3, 0); // moving is the node's job
Notes: a light cannot change type; remove it and add another.
scene.lightOf(node) → object | nullThe light’s settings, or null if the node is not a light. A copy: change it through setLight.
Returns: { type, color, intensity, castShadow }, plus radius for a point or spot, plus innerAngle and outerAngle for a spot. Position and direction are not included; they are the node’s.
if (scene.lightOf(lamp).castShadow) console.log('casts');
Emitters are simulated on the GPU. Once born, a particle lives in world space, so a moving emitter leaves a trail. engine.run advances them.
scene.addEmitter(options) → NodeAdds a particle emitter as a node. Particles leave along its direction, turned by the node’s rotation, and are drawn as quads facing the camera.
| Option | Default | Meaning |
|---|---|---|
rate |
0 |
Particles per second. 0 for bursts only (see scene.burst). |
lifetime |
required | Seconds. One number, or [min, max] for each particle to pick from. Above 0. |
size |
required | Width at birth, in world units. One number. |
sizeEnd |
size |
Width at death. |
speed |
0 |
Speed at birth. One number, or [min, max]. |
direction |
[0, 1, 0] |
In the node’s space. [x, y] in a 2D view. Must not be zero. |
spread |
0 |
Radians off direction a particle may leave at: 0 is a line, Math.PI every way. |
radius |
0 |
Particles are born anywhere in this sphere around the node. 0 is a point. |
acceleration |
[0, 0, 0] |
World space, units per second squared: gravity, if you want it. [x, y] in a 2D view. |
drag |
0 |
Share of speed lost per second, as a rate. |
color |
[1, 1, 1, 1] |
At birth. Linear in 3D, and may exceed 1 to glow. sRGB in a 2D view. |
colorEnd |
color |
At death. |
texture |
null |
From engine.loadTexture. Without one, a soft round dot. |
blend |
'additive' |
'additive' (needs no draw order) or 'alpha', whose particles are sorted far to near on the GPU every frame in 3D. |
layer |
0 |
2D only: its place in the painter’s order with sprites. |
position |
[0, 0, 0] |
In the parent’s space. |
parent |
null |
Node to attach it to. |
Returns: the emitter’s Node.
Throws (all start addEmitter:): lifetime or size missing; size not a single number; size or sizeEnd negative; lifetime not positive; speed negative, or a range with min above max; rate, radius or drag negative or not finite; direction zero or the wrong length; spread outside 0 to pi; color, colorEnd or acceleration the wrong length or not finite; texture not from engine.loadTexture; blend not 'additive' or 'alpha'; layer not finite.
const sparks = scene.addEmitter({
rate: 200, lifetime: [0.4, 0.8], size: 0.05, sizeEnd: 0, speed: [2, 4], spread: 0.4,
acceleration: [0, -9.81, 0], color: [4, 2, 0.5, 1],
});
scene.setEmitter(node, changes)Changes an emitter’s options: the same names addEmitter takes, checked the same way. Particles already alive live on.
Throws: setEmitter: this node has no emitter, or as addEmitter for a bad value.
scene.setEmitter(sparks, { rate: 0 }); // stop emitting; the last sparks finish
scene.emitterOf(node) → object | nullThe emitter’s options as addEmitter took them, or null. A copy: change it through setEmitter.
scene.burst(node, count)Emits count particles at once, on the next frame.
Throws: burst: this node has no emitter; burst: count must be a whole number, got ... for a negative or fractional count.
const puff = scene.addEmitter({ lifetime: 1, size: 0.2, speed: [1, 2], spread: Math.PI });
scene.burst(puff, 50);
scene.particlesActive → booleanWhether a particle may still be alive, or one is about to be born.
A capture made by 3D Gaussian Splatting: up to millions of soft, coloured ellipsoids fitted to photographs. They are sorted back to front on the GPU whenever the camera, the cloud or the canvas has moved, and blended over the scene. Load one with engine.loadSplats.
scene.addSplats({ splats, position, parent }) → NodeAdds a capture as a node. The node’s position, rotation and scale place it, and one capture can be added any number of times. position defaults to [0, 0, 0].
Splats are lit by nothing: they show the colour the capture saw, decoded from sRGB, fogged by renderer.fog by the distance to each one, and tonemapped with the rest of the frame. Geometry in front hides them. They write no depth, so they hide nothing and cast no shadows. They are drawn straight after opaque geometry, so sprites, particles and blended surfaces in front of a capture show over it; blended or transmissive surfaces behind one show over it too. Depth of field works from depth, so it blurs splats as whatever geometry is behind them, or as far away where there is none. They count in scene.bounds and scene.frame by the box around their centres. They are not picked or raycast.
Throws: 'addSplats: splats must be what engine.loadSplats returned'; 'addSplats: these splats were unloaded'. A frame drawing splats unloaded since they were added throws 'addSplats: these splats were unloaded; remove the node first'.
const room = await engine.loadSplats('room.ply');
// Captures usually come with y down, as the photographs were: turn them upright.
scene.addSplats({ splats: room }).setAxisAngle([1, 0, 0], Math.PI);
Notes: two captures are each sorted on their own and drawn farther one first, so where two overlap they do not interleave. On integrated graphics (Intel Iris Xe, a million splats, 1280x720) the draw costs about 10 ms for a capture seen whole, and more up close, where splats fill the screen; renderer.resolution cuts that part. The sort costs about 2 ms, and nothing while the view is still.
scene.splatsOf(node) → { splats } | nullThe capture a node draws, or null.
scene.addDecal(options) → NodeProjects a texture onto whatever surfaces lie inside a box, along the box’s -Z: a scorch mark, a poster, a puddle. It changes the surface’s base colour before lighting, so it is lit, shadowed and fogged with the surface. Surfaces facing away from it are not painted. Later decals paint over earlier ones.
| Option | Default | Meaning |
|---|---|---|
texture |
required | From engine.loadTexture. Its alpha is how much it covers. |
size |
required | The box: [width, height, depth], width and height across the image and depth along the projection, in the node’s units. Scaled by the node. |
color |
[1, 1, 1, 1] |
Multiplies the texture. Its alpha scales the cover. |
position |
[0, 0, 0] |
In the parent’s space. |
parent |
null |
Node to attach it to. |
Returns: the decal’s Node.
Throws: addDecal: texture must be one engine.loadTexture returned; addDecal: size must be [width, height, depth], all positive, ...; addDecal: color must be 4 finite numbers, ....
const scorch = scene.addDecal({ texture: burn, size: [2, 2, 0.5] });
scorch.setPosition(0, 0.01, 0).setAxisAngle([1, 0, 0], -Math.PI / 2); // project downward
scene.setDecal(node, changes)Changes a decal’s texture, size or color, checked as in addDecal.
Throws: setDecal: this node has no decal, or as addDecal for a bad value.
scene.decalOf(node) → { texture, size, color } | nullThe decal’s settings, or null. Change them through setDecal.
A probe makes the surfaces inside a box reflect the scene as seen from the probe, instead of the sky. Nothing shows until it is captured with engine.captureProbes.
scene.addProbe(options) → NodeAdds a probe as a node. Its box is centred on the node and stays square to the world: turning or scaling the node does not turn or scale it. Moving the node moves the box, and the probe then shows nothing until it is captured again.
| Option | Default | Meaning |
|---|---|---|
size |
required | The box, [width, height, depth] in world units. |
fade |
0 |
How far in from the box’s faces the probe fades in. 0 is a hard edge; more hides the seam between neighbouring probes. |
position |
[0, 0, 0] |
In the parent’s space. |
parent |
null |
Node to attach it to. |
Returns: the probe’s Node.
Throws: addProbe: a probe is a node now -- give its box as size, ... if given the old min/max; addProbe: size must be [width, height, depth], all positive, ...; addProbe: fade must be 0 or more, ...; addProbe: blend is now fade, … for the old name.
const hall = scene.addProbe({ position: [0, 2, 0], size: [10, 4, 16] });
await engine.captureProbes(scene);
Notes: where boxes nest, the smaller one wins. Capturing renders the scene six times per probe, so do it once the room is loaded, and again when it changes.
scene.setProbe(node, changes)Changes a probe’s size or fade. The probe shows nothing until it is captured again.
Throws: setProbe: this node is not a reflection probe, or as addProbe for a bad value.
scene.probeOf(node) → { size, fade, captured } | nullThe probe’s settings and whether it is captured now, or null if the node is not a probe.
scene.addSprite(options) → NodeAdds a textured quad as a node. It moves, parents and is removed like any other node. Both 3D views and 2D views (Camera2D) draw it.
| Option | Default | Meaning |
|---|---|---|
texture |
required | A texture from engine.loadTexture. |
position |
[0, 0, 0] |
Where the node goes. [x, y] places it in 2D. |
parent |
null |
A node to hang it off. |
size |
see Notes | [width, height]. 3D: world units, or pixels with pixels: true. 2D: CSS pixels at zoom 1. |
pixels |
false |
3D only. size is in screen pixels, so the sprite stays one size on screen. The node’s scale is then ignored. |
color |
[1, 1, 1, 1] |
Multiplies the texture. 3D: linear, may exceed 1 to glow. 2D: sRGB 0..1, as in CSS. |
rect |
[0, 0, 1, 1] |
[u0, v0, u1, v1]: the part of the texture to show. Past 0..1 the image repeats: [0, 0, 4, 1] shows it four times across, for a scrolling background or a parallax strip. Ignored while animation is set. |
pivot |
[0.5, 0.5] |
The point of the image placed at the node, 0..1 from its top-left. [0.5, 1] is its bottom middle, for something standing on the ground. |
angle |
0 |
Radians. In 2D it turns the quad clockwise, on top of the node’s angle. In 3D it turns the quad in its own plane (counter-clockwise as seen, since y points up). |
facing |
'camera' |
3D only. 'camera' turns to face the camera every way. 'upright' turns about Y only, for trees and people. 'plane' does not turn: the quad lies in the node’s own x-y plane, like a sign. |
blend |
'alpha' |
'alpha', 'additive' (adds light), 'multiply' (darkens: shadows, tints), 'screen' (lightens: glows), or 'cutout' (each pixel drawn fully or not at all, by cutoff). |
cutoff |
0.5 |
For 'cutout': pixels with alpha below this are dropped. 0..1. |
layer |
0 |
2D only. Higher layers draw over lower ones. See painter’s order. |
animation |
null |
{ frames, fps = 12, loop = true }. frames is a list of rects, as spriteSheet makes. A non-looping animation stops on its last frame. |
lit |
false |
2D only. Lit by the scene’s lights and the camera’s ambient. See lighting. |
Returns: the new Node.
Throws:
addSprite: texture must be one engine.loadTexture returned if texture is missing or has no view or size.addSprite: size must be 2 finite numbers / size must be positive.addSprite: color must be 4 finite numbers, and the same for rect (4) and pivot (2).addSprite: facing is 'camera', 'upright' or 'plane' for any other facing.addSprite: blend is 'alpha', 'additive', 'multiply', 'screen' or 'cutout' for any other blend.addSprite: cutoff must be between 0 and 1.addSprite: angle must be a finite number, and the same for layer.addSprite: rotation is now angle, … for the old name.addSprite: animation.frames must be a list of rects if frames is not a non-empty array; each frame must be 4 finite numbers.addSprite: animation.fps must be positive.import { spriteSheet } from 'winding-engine';
const hero = await engine.loadTexture('hero.png', { pixelated: true });
const player = scene.addSprite({
texture: hero,
animation: { frames: spriteSheet({ columns: 8 }), fps: 10 },
pivot: [0.5, 1],
position: [160, 120],
});
Notes:
| 3D view | 2D view | |
|---|---|---|
| Quad | Faces the camera, per facing. |
Flat on screen. facing and pixels are ignored. |
| Default size | 1 unit wide at its frame’s aspect, or the frame’s size in pixels with pixels: true. The frame is its first animation frame, or its rect. |
The current frame’s own size in texels. |
| Units | World units, or pixels with pixels: true. |
A unit is a CSS pixel. |
| Colour | Linear light. Unlit, fogged, and may glow through bloom. | sRGB, blended as a browser blends a page. |
| Order | Cutouts first, then additive, then alpha, multiply and screen sorted back to front. layer is ignored. |
By layer, then in the order added. |
| Scale | Scaled by the node (unless pixels). |
Scaled by the node. A negative x scale (node.setScale(-1, 1)) mirrors it. |
engine.run advances animations. If you call engine.renderFrame yourself, call scene.advance(dt) each frame.pixelSnap) is unchanged. A 'cutout' keeps its hard edge.scene.remove(node) or node.destroy().scene.setSprite(node, changes)Changes a sprite’s options. Takes the same names as scene.addSprite; options you leave out keep their values.
Returns: nothing.
Throws: setSprite: this node has no sprite, or any error addSprite throws for the merged options, named setSprite:.
scene.setSprite(player, { animation: { frames: spriteSheet({ columns: 8, first: 8, count: 8 }), fps: 10 } });
Notes:
animation (including null) restarts from its first frame.size, a new texture gets a new default size. A size you gave is kept.scene.spriteOf(node) → object | nullA sprite’s current options, or null if the node has no sprite.
Returns: a copy with texture, size, color, rect, pivot, angle, facing, blend, cutoff, pixels, layer, lit and animation ({ frames, fps, loop } or null), plus frame (the frame shown) and time (seconds the animation has run).
const { frame } = scene.spriteOf(player);
Notes: a copy all the way down: changing it changes nothing. Change the sprite through scene.setSprite.
spriteSheet({ columns, rows, count, first }) → number[][]The frames of a sprite sheet laid out in a grid, as rects in reading order: left to right, then top to bottom. Use it for animation.frames, or pick one as a sprite’s rect. Exported from 'winding-engine'.
| Option | Default | Meaning |
|---|---|---|
columns |
required | Frames across. A whole number above zero. |
rows |
1 |
Frames down. A whole number above zero. |
count |
columns * rows |
How many frames. Stops short of a last row that is not full. |
first |
0 |
Frames to skip from the start. |
Returns: an array of [u0, v0, u1, v1], one per frame.
Throws:
spriteSheet: columns and rows must be whole numbers above zero.spriteSheet: frames A to B are not all in a C x R sheet if first or count is not a whole number, count is below 1, first is negative, or first + count is past the last frame.const run = spriteSheet({ columns: 6, rows: 4, first: 6, count: 6 }); // the second row
scene.addSprite({ texture: hero, rect: run[0] });
scene.addText(options) → NodeAdds a string as a node. Each glyph is a quad drawn from a distance field, so text stays sharp at any size. Both 3D and 2D views draw it.
| Option | Default | Meaning |
|---|---|---|
font |
required | A font from engine.loadFont. |
position |
[0, 0, 0] |
Where the node goes. [x, y] places it in 2D. |
parent |
null |
A node to hang it off. |
text |
'' |
The string. Turned into a string with String(). \n starts a new line. |
size |
required | The font’s em. 3D: world units, or pixels with pixels: true. 2D: CSS pixels at zoom 1. |
color |
[1, 1, 1, 1] |
3D: linear, may exceed 1 to glow. 2D: sRGB 0..1. |
align |
'left' |
'left', 'center' or 'right': each line within the block. |
pivot |
[0.5, 0.5] |
The point of the block placed at the node, 0..1. [0, 0] is the block’s top-left. |
lineHeight |
the font’s | Distance between baselines, in ems. The font’s ascent plus descent by default. |
width |
Infinity |
Lines wrap between words to fit it, in the same units as size. The block is then this wide, so align and pivot work within it. A word wider than it overflows. |
facing |
'camera' |
3D only. 'camera', 'upright' or 'plane', as for sprites. |
pixels |
false |
3D only. size is in screen pixels, and the node’s scale is ignored. |
layer |
0 |
2D only. See painter’s order. |
lit |
false |
2D only. See lighting. |
blend |
'alpha' |
How it meets what is under it: 'alpha', 'additive' (adds light), 'multiply' (darkens; white changes nothing) or 'screen' (lightens; black changes nothing). |
stroke |
[0, 0, 0, 1] |
The outline’s colour. |
strokeWidth |
0 |
The outline’s width, in the units of size, drawn outside the letters so they keep their weight. At most about a ninth of an em for a 64px font (see Notes). 0 means no outline. |
Returns: the new Node.
Throws:
addText: anchor is now pivot, and [0, 0] is the block's top-left, as a sprite's is if anchor is passed at all.addText: width must be positive.addText: font must be one engine.loadFont returned.addText: size must be positive (also for a missing or non-finite size).addText: color must be 4 finite numbers.addText: facing is 'camera', 'upright' or 'plane'.addText: pivot must be [x, y].addText: layer must be a finite number.addText: align is 'left', 'center' or 'right' for any other align.addText: blend is 'alpha', 'additive', 'multiply' or 'screen'.addText: stroke must be 4 finite numbers.addText: strokeWidth must be 0 to N for this font at this size, … past what the font holds.const font = await engine.loadFont('600 32px system-ui, sans-serif');
scene.addText({ font, text: 'Gate 3', size: 0.4, parent: gate }); // 3D, world units
hud.addText({ font, text: 'HULL', size: 11, pivot: [0, 0], position: [22, 19] }); // 2D, CSS pixels
Notes:
lit.\n, and between any two Chinese or Japanese characters—but not before a closing mark such as 。 or after an opening one, as a browser breaks them—and between the words of Thai, Lao, Khmer and Burmese, which are written without spaces.align is not reversed. Latin ligatures are not formed.strokeWidth is at most 7/64 of an em for a font loaded at 64px, so 2.2 pixels for 20px text. Load the font larger for a wider outline. An outline is drawn outside the letters, where a shape’s is inside its edge: inside, it would eat thin strokes.anchor was renamed to pivot. The old name throws rather than being ignored.scene.setText(node, changes)Changes a text’s options, such as its string. Takes the same names as scene.addText; options you leave out keep their values.
Returns: nothing.
Throws: setText: this node has no text, or any error addText throws for the merged options, named setText:.
scene.setText(score, { text: `Score ${points}` });
Notes: every call lays the text out again and rebuilds the 2D draw list. Fine for a score; avoid it on many texts every frame.
scene.textOf(node) → object | nullA text’s current state, or null if the node has no text.
Returns: the options as addText and setText last took them — font, text, size, pivot, width and the rest — as a copy.
const { align, pivot } = scene.textOf(label);
Notes: change it through scene.setText.
scene.addShape(options) → NodeAdds a rectangle or an ellipse as a node, for a 2D view. It is computed per pixel from its distance to the edge, so it is round and smooth at any size. A 3D camera does not draw it.
| Option | Default | Meaning |
|---|---|---|
position |
[0, 0, 0] |
Where the node goes. [x, y] is enough. |
parent |
null |
A node to hang it off. |
shape |
'rect' |
'rect' or 'ellipse'. A circle is an ellipse as wide as tall. |
size |
required | [width, height] in units (CSS pixels at zoom 1). |
radius |
0 |
A rect’s corner radius. Drawn no larger than half the shorter side, so half the height makes a capsule; the radius asked for is kept, so a shape that shrinks and grows back gets its corner back. |
color |
[1, 1, 1, 1] |
The fill, sRGB 0..1. Alpha 0 for an outline alone. |
stroke |
[0, 0, 0, 1] |
The outline’s colour. Drawn inside the edge, like a CSS border. |
strokeWidth |
0 |
The outline’s width. 0 means no outline. |
pivot |
[0.5, 0.5] |
The point of the shape placed at the node. [0, 0] is its top-left. |
layer |
0 |
See painter’s order. |
blend |
'alpha' |
How it meets what is under it: 'alpha', 'additive' (adds light), 'multiply' (darkens; white changes nothing) or 'screen' (lightens; black changes nothing). |
lit |
false |
See lighting. |
Returns: the new Node.
Throws:
addShape: shape is 'rect' or 'ellipse'.addShape: size must be 2 finite numbers / size must be positive.addShape: radius must be 0 or more, and the same for strokeWidth.addShape: color must be 4 finite numbers, and the same for stroke (4) and pivot (2).addShape: layer must be a finite number.addShape: blend is 'alpha' or 'additive'.scene.addShape({ size: [120, 12], radius: 6, color: [0.9, 0.2, 0.2, 1] });
scene.addShape({ shape: 'ellipse', size: [30, 30], color: [0, 0, 0, 0], stroke: [1, 1, 1, 1], strokeWidth: 2 });
Notes: placed, turned (node.setAngle) and scaled by its node. Under a non-uniform scale, the corner radius and outline scale by the smaller axis.
scene.setShape(node, changes)Changes a shape’s options. Takes the same names as scene.addShape; options you leave out keep their values.
Returns: nothing.
Throws: setShape: this node has no shape, or any error addShape throws for the merged options, named setShape:.
scene.setShape(health, { size: [138 * hp, 10] });
scene.shapeOf(node) → object | nullA shape’s current options, or null if the node has no shape.
Returns: a copy with shape, size, radius (as asked), color, stroke, strokeWidth, pivot, layer, blend and lit.
const [width] = scene.shapeOf(health).size;
Notes: change it through scene.setShape.
scene.addPath(options) → NodeAdds a polygon to fill, a line to stroke, or both, as a node, for a 2D view. Computed per pixel from the distance to its segments, so it is smooth at any size. A 3D camera does not draw it.
| Option | Default | Meaning |
|---|---|---|
position |
[0, 0, 0] |
Where the node goes. [x, y] is enough. |
parent |
null |
A node to hang it off. |
points |
required | A list of [x, y], in order, in the node’s units. At least 2. |
closed |
true |
Whether the last point joins the first. Only a closed path fills. A crossing or concave outline fills by the nonzero rule, as a canvas does. |
color |
[1, 1, 1, 1] |
The fill, sRGB 0..1. Alpha 0 for a line alone. |
stroke |
[0, 0, 0, 1] |
The line’s colour. Centred on the path, with round joins and ends. |
strokeWidth |
0 |
The line’s width. 0 means no line. |
layer |
0 |
See painter’s order. |
blend |
'alpha' |
How it meets what is under it: 'alpha', 'additive' (adds light), 'multiply' (darkens; white changes nothing) or 'screen' (lightens; black changes nothing). |
lit |
false |
See lighting. |
Returns: the new Node.
Throws:
addPath: points must be a list of [x, y] if points is not an array.addPath: point N must be [x, y] for a point that is not 2 finite numbers.addPath: a path needs at least 2 points.addPath: color must be 4 finite numbers, and the same for stroke.addPath: strokeWidth must be 0 or more.addPath: layer must be a finite number.addPath: blend is 'alpha', 'additive', 'multiply' or 'screen'.scene.addPath({ points: [[0, 0], [60, 20], [0, 40]], color: [1, 0.8, 0, 1] }); // a triangle
scene.addPath({ points: route, closed: false, color: [0, 0, 0, 0], stroke: [1, 1, 1, 1], strokeWidth: 3 });
Notes:
pivot. The points are measured from the node.strokeWidth: 0 draws nothing.scene.setPath(node, changes)Changes a path’s options. Takes the same names as scene.addPath. The points are kept unless you pass points.
Returns: nothing.
Throws: setPath: this node has no path, or any error addPath throws for the merged options, named setPath:.
scene.setPath(trail, { points: history });
Notes: new points rebuild the 2D draw list. A colour change does not.
scene.pathOf(node) → object | nullA path’s current options, or null if the node has no path.
Returns: the options as addPath and setPath took them, as a copy: points as a list of [x, y], closed, color, stroke, strokeWidth, layer, blend and lit.
const { points } = scene.pathOf(trail); // [[x, y], ...]
Notes: what it returns can be passed straight back to scene.setPath.
scene.addTilemap(options) → NodeAdds a grid of tiles from one tileset image, as a node, for a 2D view. It draws as one quad whatever its size, and changing a tile uploads that tile alone. A 3D camera does not draw it.
| Option | Default | Meaning |
|---|---|---|
position |
[0, 0, 0] |
Where the node goes. With the default pivot, the map’s top-left. |
parent |
null |
A node to hang it off. |
tileset |
required | A texture from engine.loadTexture: tiles in a grid, read left to right, then top to bottom. |
tileSize |
required | [width, height] of a tile, in the tileset’s texels, and in units on screen (before the node’s scale). Whole numbers. |
columns |
required | The map’s width in tiles. A whole number above zero. |
rows |
required | The map’s height in tiles. A whole number above zero. |
tiles |
all empty | columns * rows ids, row by row from the top-left. See Notes. |
margin |
0 |
Texels around the tileset’s grid, as Tiled names it. A whole number. |
spacing |
0 |
Texels between the tileset’s tiles, as Tiled names it. A whole number. |
layer |
0 |
See painter’s order. |
color |
[1, 1, 1, 1] |
Multiplies every tile, sRGB 0..1. |
pivot |
[0, 0] |
The point of the map placed at the node. [0, 0] is its top-left. |
blend |
'alpha' |
How it meets what is under it: 'alpha', 'additive' (adds light), 'multiply' (darkens; white changes nothing) or 'screen' (lightens; black changes nothing). |
lit |
false |
See lighting. |
Returns: the new Node.
Throws:
addTilemap: tileset must be one engine.loadTexture returned.addTilemap: margin and spacing must be whole texels, 0 or more.addTilemap: tileSize must be whole texels inside the W x H tileset if a side is not a whole number above zero, or a tile plus two margins is larger than the tileset.addTilemap: columns and rows must be whole numbers above zero.addTilemap: tiles holds N ids; a C x R map needs M if tiles is the wrong length.addTilemap: tile N is not in the tileset, which holds T (ids start at 1; 0 is empty) for an id that is not a whole 32-bit number, or whose tile (flip bits aside) is past the tileset’s last.addTilemap: tile N flips no tile; an empty tile is 0 for flip bits on tile 0.addTilemap: layer must be a finite number.addTilemap: color must be 4 finite numbers, and the same for pivot (2).addTilemap: a C x R map is past this device's N tiles a side if columns or rows is larger than the GPU’s largest texture side.const tiles = await engine.loadTexture('tiles.png', { pixelated: true });
const map = scene.addTilemap({ tileset: tiles, tileSize: [16, 16], columns: 100, rows: 40, tiles: level });
scene.setTile(map, 12, 3, 0); // break a block
Notes:
0 is empty, 1 is the tileset’s first tile, 2 the next, and so on.firstgid - 1 from the id and keep the flip bits.margin and spacing.scene.setTilemap(node, changes)Changes a tilemap’s options. Takes the same names as scene.addTilemap. Its tiles are kept unless you pass tiles, and you must pass them if columns or rows changes.
Returns: nothing.
Throws: setTilemap: this node has no tilemap, or any error addTilemap throws for the merged options, named setTilemap:. Changing the size without new tiles throws the tiles holds N ids error.
scene.setTilemap(map, { color: [0.6, 0.6, 0.8, 1] });
Notes: every call uploads the whole map again. To change tiles, use scene.setTile or scene.setTiles.
scene.tilemapOf(node) → object | nullA tilemap’s current options, or null if the node has no tilemap.
Returns: a copy with tileset, tileSize, margin, spacing, columns, rows, tiles (a Uint32Array), layer, color, pivot and lit.
const { columns, rows } = scene.tilemapOf(map);
Notes: tiles is a copy. Read single tiles with scene.tileAt and change them with scene.setTile.
scene.setTile(node, x, y, id)Sets the tile at column x, row y. id follows the same rules as addTilemap’s tiles, flip bits included.
Returns: nothing.
Throws: setTile: this node has no tilemap; setTile: (x, y) is not on the C x R map if x or y is not a whole number on the map; setTile: tile N is not in the tileset… as for addTilemap. A refused call writes nothing.
scene.setTile(map, 12, 3, 5 | 0x80000000); // tile 5, flipped horizontally
scene.setTiles(node, x, y, width, tiles)Sets a block of tiles. tiles holds rows of width ids; the block’s top-left is at column x, row y.
Returns: nothing.
Throws: setTiles: this node has no tilemap; setTiles: a W-wide block of N at (x, y) is not inside the C x R map if x, y or width is not a whole number, width is not above zero, tiles.length is not a multiple of width, or the block runs off the map; setTiles: tile N is not in the tileset… as for addTilemap. A refused call writes nothing.
scene.setTiles(map, 10, 5, 3, [1, 1, 1,
2, 2, 2]);
scene.tileAt(node, x, y) → numberThe id at column x, row y, flip bits included. Fractional x and y are floored.
Returns: the id, or 0 for an empty tile or a position off the map.
Throws: tileAt: this node has no tilemap.
const id = scene.tileAt(map, 12, 3) & 0x1fffffff; // without the flip bits
A camera for 2D. One unit is one CSS pixel, y points down, and the origin is the top-left, as in an HTML canvas and CSS. A scene viewed through one is drawn by the 2D view.
new Camera2D(options)Creates a 2D camera. Pass it to engine.run in place of a 3D camera, or as a HUD’s camera.
| Option | Default | Meaning |
|---|---|---|
position |
[0, 0] |
The world point placed at pivot of the view. |
pivot |
[0, 0] |
Where in the view position sits: [0, 0] top-left, [1, 1] bottom-right. [0.5, 0.5] centres position, for a camera that follows something. |
zoom |
1 |
Screen pixels per world unit. 2 draws everything twice as large. |
angle |
0 |
Radians. Turns the view clockwise about its centre. |
background |
[0, 0, 0, 1] |
What the view is cleared to, sRGB 0..1. |
pixelSnap |
false |
For pixel art: puts every item’s corner on a whole screen pixel, and makes a unit a whole number of screen pixels. See camera.pixelSnap. |
ambient |
[0, 0, 0] |
The light on lit things where no light reaches, linear. See lighting. |
Throws: Camera2D: anchor is now pivot -- the point of the view placed at position, as a sprite's pivot is if anchor is passed.
import { Camera2D } from 'winding-engine';
const camera = new Camera2D({ pivot: [0.5, 0.5], zoom: 3, pixelSnap: true });
const at = new Float32Array(3);
engine.run(scene, camera, { frame() { player.worldPosition(at); camera.position.set([at[0], at[1]]); } });
Notes: every option can be changed later through the property of the same name. The renderer reads them each frame.
camera.position → Float32Array(2)The world point at pivot of the view. Write into it to scroll: camera.position[0] += 4.
camera.pivot → Float32Array(2)Where in the view position sits, 0..1 from its top-left.
camera.zoom → numberScreen pixels per world unit.
camera.angle → numberRadians, turning the view clockwise about its centre. The world on screen turns the other way.
camera.background → Float32Array(4)The clear colour, sRGB 0..1. Ignored when the camera draws a HUD, which is drawn over the frame without clearing.
camera.pixelSnap → booleanFor pixel art. Puts the view’s offset and each item’s corner on whole screen pixels, so a texel never lands between them and nothing flickers as it moves. And makes a unit a whole number of screen pixels: zoom × the pixel ratio, rounded (at least 1). On a 1.5× screen, zoom 1 draws a texel as 2 screen pixels, not as 1 and 2 by turns. For an unrotated view.
Notes: this is the one exception to “a unit is a CSS pixel”: at a fractional pixel ratio, a snapped view is drawn a little larger or smaller than the CSS size. screenToWorld and pick follow it.
camera.ambient → Float32Array(3)Light on lit things where no light reaches, linear. Black shows lit things only where lights reach.
camera.view, camera.projection, camera.viewProjection → Float32Array(16)Read-only matrices, recomputed by camera.update. view maps world to canvas pixels, projection maps canvas pixels to clip space, and viewProjection is the two combined.
camera.width, camera.height, camera.pixelRatio → numberThe canvas size in its own (device) pixels, and device pixels per CSS pixel, as of the last update. They start at 1.
camera.is2D → trueAlways true. The renderer and scene.pick use it to take the 2D path.
camera.update(aspect, width, height, pixelRatio) → Camera2DRecomputes the matrices for a canvas width × height of its own pixels, pixelRatio of them to a CSS pixel. The renderer calls it every frame, so you rarely need to. aspect is ignored; it is there so the call matches a 3D camera’s.
| Argument | Default | Meaning |
|---|---|---|
aspect |
— | Ignored. |
width |
camera.width |
Canvas width in its own pixels. |
height |
camera.height |
Canvas height in its own pixels. |
pixelRatio |
camera.pixelRatio |
Canvas pixels per CSS pixel. |
Returns: the camera.
camera.update(0, canvas.width, canvas.height, devicePixelRatio);
camera.screenToWorld(x, y, out) → Float32Array(2)Where canvas pixel (x, y) is in the world, as of the last update.
| Argument | Default | Meaning |
|---|---|---|
x, y |
required | A point in the canvas’s own pixels (device pixels, not CSS pixels). |
out |
a new Float32Array(2) |
Where to write the result. |
Returns: out.
const r = canvas.getBoundingClientRect();
const world = camera.screenToWorld((e.clientX - r.left) * camera.width / r.width,
(e.clientY - r.top) * camera.height / r.height);
Notes: a pointer event gives CSS pixels. Scale them as above. scene.pick does this for you.
camera.worldToScreen(x, y, out) → Float32Array(2)Where world point (x, y) lands on the canvas, in its own pixels, as of the last update.
| Argument | Default | Meaning |
|---|---|---|
x, y |
required | A world point. |
out |
a new Float32Array(2) |
Where to write the result. |
Returns: out.
const [sx, sy] = camera.worldToScreen(200, 120); // canvas pixels
What a Camera2D draws: sprites, text, shapes, paths, tilemaps and particle emitters. There is no depth and none of the 3D passes (no shadows, fog, bloom or tonemapping).
Everything draws by layer, lowest first. Within a layer, things draw in the order they were added. setX keeps a thing’s place; to bring something to the front, give it a higher layer.
scene.addTilemap({ tileset, tileSize: [16, 16], columns: 40, rows: 20, tiles, layer: 0 });
scene.addSprite({ texture: hero, layer: 1 });
Notes: kinds do not matter. A shape on layer 2 draws over a tilemap on layer 1, whichever was added first. Emitters (scene.addEmitter) take a place in the same order by their layer.
A 2D view composites in sRGB, as a browser composites a page. Colours you give (color, stroke, background) are sRGB 0..1, like CSS, and an opaque colour lands on screen exactly as authored. Half-transparent edges blend as they do in an image editor.
scene.addShape({ size: [40, 40], color: [1, 0.5, 0, 1] }); // CSS rgb(255, 128, 0)
Notes: 3D colours are linear light. The same numbers look different in a 3D view and a 2D view. A colour texture loaded with the default srgb: true shows as it looks in an image viewer.
lit and ambientBy default nothing in a 2D view is lit: the colour is the colour. Anything with lit: true is instead multiplied by the camera’s ambient plus every point and spot light in the scene (scene.addLight).
const camera = new Camera2D({ ambient: [0.08, 0.08, 0.12] });
scene.addTilemap({ tileset, tileSize: [16, 16], columns, rows, tiles, lit: true });
scene.addLight({ position: [200, 120], color: [1, 0.8, 0.5], intensity: 2, radius: 160 });
Notes:
radius is in world units (CSS pixels), where its light fades to nothing.direction: [x, y]. A spot aimed straight out of the screen lights nothing.engine.run(scene, camera, { hud: { scene, camera } }) draws a second, 2D scene over every frame (engine.run). It is drawn after tonemapping, bloom and antialiasing, so its colours land exactly and its text stays crisp. It works over a 3D view or a 2D one.
| Field | Default | Meaning |
|---|---|---|
scene |
required | A scene from engine.createScene(). |
camera |
a plain Camera2D |
A Camera2D to view it through. |
Throws: run: hud needs { scene, camera }, the scene from createScene(); run: the hud's camera must be a Camera2D.
const hud = engine.createScene();
const hudCamera = new Camera2D();
const health = hud.addShape({ size: [138, 10], radius: 5, color: [0.85, 0.47, 0.34, 1], pivot: [0, 0], position: [22, 38] });
engine.run(scene, camera, { hud: { scene: hud, camera: hudCamera } });
Notes:
background is ignored.run advances the HUD scene with the main one.engine.renderFrame(scene, camera, { hud }) takes the same object.scene.pick(camera2D, x, y, width, height) → { node, point, tile? } | nullThe topmost thing a 2D view shows under a point on the canvas. Use it for clicks and hovers.
| Argument | Meaning |
|---|---|
camera2D |
The Camera2D the scene is drawn through. |
x, y |
CSS pixels from the canvas’s top-left. |
width, height |
The canvas’s CSS size. |
Returns: { node, point }, where point is the world point [x, y]. For a tilemap it also has tile: [column, row]. null if nothing is hit.
canvas.addEventListener('pointerdown', (e) => {
const r = canvas.getBoundingClientRect();
const hit = scene.pick(camera, e.clientX - r.left, e.clientY - r.top, r.width, r.height);
if (hit?.tile) scene.setTile(hit.node, hit.tile[0], hit.tile[1], 0);
});
What counts as a hit, per kind, tested topmost first (the reverse of painter’s order):
| Kind | Hit where |
|---|---|
| Sprite | Anywhere in its quad, transparent pixels included. |
| Text | Anywhere in its block. |
| Shape | Inside its outline (its rounded rect or ellipse), even with a clear fill. |
| Path | Inside a closed path by the nonzero rule, even with a clear fill, or within half strokeWidth of its line. |
| Tilemap | On a tile that is not empty (id not 0). |
Notes:
camera.update yourself.scene.pick casts a ray instead; see scene.pick.new OrbitController(camera, element, options) → OrbitControllerMouse and touch control for a 3D Camera: drag to orbit, wheel to zoom, right-drag or shift-drag to pan. It listens on element and only changes the camera, so it works with or without engine.run. It sets the element’s CSS touch-action to none, so a touch browser gives it the finger instead of scrolling the page, and puts it back on detach.
| Option | Default | Meaning |
|---|---|---|
distance |
6 |
Distance from the target. |
yaw |
0 |
Angle around the vertical axis, radians. 0 looks from +Z. |
pitch |
0.3 |
Angle above the horizontal, radians. Kept just short of straight up or down, however it is set. |
target |
[0, 0, 0] |
Point the camera orbits and looks at. |
minDistance |
0.1 |
Closest zoom. |
maxDistance |
1000 |
Farthest zoom. |
rotateSpeed |
0.005 |
Radians per pixel dragged. |
zoomSpeed |
0.0015 |
Zoom per wheel unit (exponential, so it feels the same near and far). |
panSpeed |
0.002 |
Pan per pixel, scaled by distance. |
damping |
12 |
How fast the camera eases to where it is going. Higher is snappier. |
Each option is also a writable field of the same name. yaw, pitch, distance and target are where the camera is now; desired ({ distance, yaw, pitch, target }) is where it is easing to. To move the camera from code, write desired.
const orbit = new OrbitController(camera, canvas, { distance: 4, target: [0, 1, 0] });
orbit.desired.yaw += Math.PI / 2; // eases a quarter turn
Notes: the camera is placed immediately. The controller owns the camera’s position: anything that moves the camera directly is overwritten on the next update, unless you call syncFromCamera. It does nothing while the camera follows a node.
orbit.update(dt) → OrbitControllerEases the camera toward desired and writes its position and target. Call once per frame with the frame’s time in seconds. 0 snaps straight there. If you never call it, input still works but snaps instead of easing.
engine.run(scene, camera, { frame: (alpha, clock) => orbit.update(clock.realDelta) });
orbit.syncFromCamera() → OrbitControllerAdopts the camera’s current position and target, instead of overwriting them. Call it after moving the camera yourself (a cutscene, a saved view, camera.frameBounds).
camera.position.set([4, 3, 4]);
orbit.syncFromCamera();
Notes: takes effect at once, with no easing. A pose past the pitch or distance limits is clamped to the nearest one the controller can hold.
orbit.frameBounds(min, max, { margin }) → OrbitControllerPoints at the centre of an axis-aligned box ([x, y, z] corners) and backs off until it fits the view. margin defaults to 1, an exact fit; larger leaves more room. Snaps at once.
orbit.frameBounds([-1, 0, -1], [1, 2, 1], { margin: 1.2 });
orbit.dragged → booleantrue when the last press moved more than 3 pixels, so it was a drag, not a click. Read it in a click handler to ignore the click that ends an orbit.
canvas.addEventListener('click', (event) => {
if (orbit.dragged) return;
pick(event);
});
orbit.detach() → voidRemoves the controller’s event listeners from element. The camera stays where it is.
new StatsOverlay(engine, options) → StatsOverlayA small fixed box in the page’s bottom-left corner showing fps, canvas size, object and draw counts, pipeline count, render graph passes, and CPU time per phase.
| Option | Default | Meaning |
|---|---|---|
interval |
0.5 |
Seconds between refreshes. |
parent |
document.body |
Element the box is added to. |
The box is overlay.element, a <div> you can restyle.
const stats = new StatsOverlay(engine);
engine.run(scene, camera, { frame: (alpha, clock) => stats.update(clock.realDelta) });
Notes: pipelines should stop climbing once loading is done. If it keeps rising, shaders are compiling mid-frame.
overlay.update(dt) → voidAdds dt seconds and refreshes the text once interval has passed. Call once per frame.
overlay.destroy() → voidRemoves the box from the page.
Opt-in timing. It lives in its own module, never imported by the engine, and until it attaches the renderer’s profiling hook costs a null check.
import { Benchmark } from 'winding-engine/bench.js';
new Benchmark(engine)A benchmark for one engine. It records every CPU phase of a frame, every GPU pass where the device has timestamp queries, and the wall time.
benchmark.run(scene, camera, options) → Promise<report>Renders frames frames, one at a time, and reports on them. It waits for the GPU after every
frame, which is what makes the wall time mean something; it measures a frame, not the throughput of
a pipelined loop.
| Option | Default | Meaning |
|---|---|---|
frames |
300 |
frames to record |
warmup |
30 |
frames rendered first and not recorded: the first frames compile pipelines and grow buffers |
update |
null |
update(i), run untimed before each frame, to move the camera or animate the scene |
Returns: a report, as benchmark.report() describes.
const report = await new Benchmark(engine).run(scene, camera, { frames: 300 });
console.log(Benchmark.format(report));
benchmark.start() and benchmark.stop()Record every frame the engine renders, however it’s driven — your own loop or engine.run –
between the two. Starting again while running starts the recording over. stop() puts GPU timing
back as it was before the first start().
benchmark.report() → reportEverything recorded so far, summarised, as plain data safe to JSON.stringify:
{ frames,
cpu: [{ name, mean, median, p95, max, share }], // in frame order; they sum to the frame
gpu: [{ name, mean, share }] | null, // null without timestamp queries
wall: { mean, median, p95, max } | null } // null unless from run()
Times are milliseconds. share is the fraction of the frame the phase or pass took.
Benchmark.format(report) → stringA report as a table to print, ending with what the frame is bound by: CPU, GPU, or waiting for the display.
new Environment(gpu, options) → EnvironmentA baked lighting environment: diffuse ambient light, reflections, and the background. Baked once, from a procedural sky or from an equirectangular HDR map. Most code gets one from engine.loadEnvironment or uses engine.environment; construct one directly for a custom sky.
| Option | Default | Meaning |
|---|---|---|
size |
128; with a map, the power of two at or below map width / 4 |
Edge of the sky cube and reflection cube, in texels. A power of two. |
irradianceSize |
32 |
Edge of the diffuse-light cube. A power of two. |
prefilterMips |
6 |
Roughness levels for reflections, capped by the cube’s mip count. |
label |
'env' |
GPU label prefix. |
sky |
the default sky | Procedural sky settings, merged over the defaults below. Ignored when map is given. |
map |
null |
An equirectangular panorama, { width, height, data } with data as linear RGB floats, as parseHDR returns. Its centre faces +X and its top row is straight up. |
sky fields (all colours linear):
| Field | Default | Meaning |
|---|---|---|
ground |
[0.10, 0.09, 0.08] |
Colour below the horizon. |
horizon |
[0.62, 0.66, 0.74] |
Colour at the horizon. |
zenith |
[0.16, 0.30, 0.60] |
Colour straight up. |
sun |
[0.35, 0.55, 0.45] |
Direction toward the sun disc (normalised for you). |
sunColor |
[1.0, 0.93, 0.80] |
Colour of the disc and its glow. |
sunIntensity |
60 |
Brightness of the disc. 0 removes it. |
glow |
0.5 |
Brightness of the halo around the disc. 0 removes it. |
Properties: size (cube edge), prefilterMips, sky (the merged settings). Method: destroy().
Throws: 'Environment: map needs width, height and width * height * 3 floats of RGB'; RangeError 'Environment: a WxH map is past this device's N'; RangeError 'Environment: size N is past this device's M'.
import { Environment } from 'winding-engine';
const dusk = new Environment(engine.gpu, {
sky: { zenith: [0.05, 0.08, 0.2], sun: [0.9, 0.1, 0], sunIntensity: 30 },
});
const scene = engine.createScene({ environment: dusk });
Notes: the sky lights the scene only through ambient light and reflections; its sun disc casts no shadow. For sunlight and shadows, add a directional light shining along the opposite of sun. Settings are read once at bake time; changing sky afterwards does nothing. An environment belongs to the engine whose gpu made it. Free it with engine.unload once no scene uses it.
parseHDR(bytes, { maxDimension }) → { width, height, data }Decodes a Radiance .hdr (RGBE) file to linear RGB floats, top row first. engine.loadEnvironment calls it for you; use it directly to inspect or edit a map before building an Environment.
bytes is the file, as a Uint8Array or an ArrayBuffer.
| Option | Default | Meaning |
|---|---|---|
maxDimension |
Infinity |
Refuse a map wider or taller than this before decoding it. Pass engine.gpu.limits.maxTextureDimension2D. |
Returns: { width, height, data }, with data a Float32Array of 3 floats a pixel. Any EXPOSURE= header is divided out.
Throws: 'hdr: not a Radiance file…'; 'hdr: the header never ends'; 'hdr: FORMAT=… is not supported; only 32-bit_rle_rgbe is'; 'hdr: EXPOSURE=… is not a positive number'; 'hdr: only -Y h +X w and +Y h +X w orientations are supported'; 'hdr: a WxH image has no pixels'; 'hdr: a WxH map is past this device's N'; 'hdr: the pixel data ends early'; 'hdr: a run is longer than its scanline'; 'hdr: a repeat with no pixel before it'.
const bytes = new Uint8Array(await (await fetch('sky.hdr')).arrayBuffer());
const map = parseHDR(bytes, { maxDimension: engine.gpu.limits.maxTextureDimension2D });
const sky = new Environment(engine.gpu, { map, size: 256 });
3D colours in Winding are linear light. Colour pickers, hex codes and CSS values are sRGB. These convert sRGB to linear for 3D. 2D colours (anything seen through a Camera2D) are already sRGB and need no conversion. Alpha is never converted.
A light’s colour may exceed 1: convert the colour, then multiply by the brightness you want.
srgbToLinear(c) → numberOne channel, sRGB (0–1) to linear.
srgbToLinear(0.5); // 0.214
linearToSrgb(c) → numberOne channel, linear back to sRGB (0–1).
linearToSrgb(0.214); // 0.5
colorFromHex(hex) → [r, g, b, a]A hex colour as linear RGBA. Takes 3, 4, 6 or 8 digits, with or without #. Alpha is 1 when not given.
Throws: 'colorFromHex: "<hex>" is not a 3, 4, 6 or 8 digit hex colour', for a number as well: 0xff0000 is written '#ff0000'.
colorFromHex('#e03a2f'); // [0.745, 0.042, 0.028, 1]
colorFromHex('#e03a2f80'); // alpha 0.502
colorFromBytes(r, g, b, a) → [r, g, b, a]0–255 sRGB channels, as a colour picker gives them, as linear RGBA. a defaults to 255.
const sun = colorFromBytes(255, 240, 220).slice(0, 3).map((c) => c * 3);
Notes: returns a plain array, so it survives JSON.stringify.
Exported from 'winding-engine'. Plain functions over typed arrays. Every function that produces a vector, quaternion or matrix writes into out first and returns it. Passing the same array as out and an input is safe. Only the *Create functions allocate.
A vec3 is a Float32Array(3) (any array of 3 numbers works as input).
| Function | What it does |
|---|---|
vec3Create(x = 0, y = 0, z = 0) |
A new Float32Array(3). Allocates. |
vec3Set(out, x, y, z) |
Sets out to x, y, z. |
vec3Copy(out, a) |
Copies a into out. |
vec3Add(out, a, b) |
a + b. |
vec3Sub(out, a, b) |
a - b. |
vec3Mul(out, a, b) |
Component-wise a * b. |
vec3Scale(out, a, s) |
a * s. |
vec3ScaleAndAdd(out, a, b, s) |
a + b * s. |
vec3Negate(out, a) |
-a. |
vec3Dot(a, b) |
The dot product. Returns a number. |
vec3Cross(out, a, b) |
The cross product a × b. |
vec3LengthSq(a) |
Squared length. Cheaper than vec3Length for comparisons. |
hypot3(x, y, z) |
sqrt(x² + y² + z²): a faster Math.hypot for three numbers. |
vec3Length(a) |
Length. |
vec3DistanceSq(a, b) |
Squared distance between two points. |
vec3Normalize(out, a) |
Unit length. A zero vector gives zero, not NaN. |
vec3Lerp(out, a, b, t) |
Linear blend: a at 0, b at 1. |
vec3Min(out, a, b) |
Component-wise minimum. |
vec3Max(out, a, b) |
Component-wise maximum. |
vec3TransformMat4(out, a, m) |
Transforms a point (w = 1), with perspective divide. |
vec3TransformMat4Dir(out, a, m) |
Transforms a direction (w = 0): no translation. |
vec3TransformQuat(out, a, q) |
Rotates by a quaternion. |
A quaternion is a Float32Array(4), [x, y, z, w].
| Function | What it does |
|---|---|
quatCreate() |
A new identity quaternion. Allocates. |
quatIdentity(out) |
Sets out to the identity. |
quatCopy(out, a) |
Copies a into out. |
quatSetAxisAngle(out, axis, rad) |
A turn of rad radians about a unit axis, right-hand rule. |
quatMultiply(out, a, b) |
a * b: b applies first, then a. |
quatDot(a, b) |
The 4D dot product. Returns a number. |
quatConjugate(out, a) |
The conjugate: the inverse, for a unit quaternion. |
quatNormalize(out, a) |
Unit length. A zero quaternion gives the identity. |
quatFromEuler(out, yaw, pitch, roll) |
From radians, YXZ order: roll about Z, then pitch about X, then yaw about Y. There is no inverse. |
quatFromMat4(out, m, mOff = 0) |
The rotation of a matrix’s upper 3×3, which must have no scale. |
quatSlerp(out, a, b, t) |
Spherical blend at constant angular speed, the short way round. |
quatFromTo(out, from, to) |
The shortest turn taking unit vector from onto unit vector to. |
quatLookAlong(out, direction, up = [0, 1, 0]) |
Points -Z along direction with +Y as upright as it can be. direction need not be unit length. |
A matrix is a column-major Float32Array(16). The optional *Off arguments address a matrix or vector inside a larger array.
| Function | What it does |
|---|---|
mat4Create() |
A new identity matrix. Allocates. |
mat4Identity(out) |
Sets out to the identity. |
mat4Copy(out, a, outOff = 0, aOff = 0) |
Copies a into out. |
mat4GetTranslation(out, m) |
Writes m’s translation into vec3 out. |
mat4Multiply(out, a, b, outOff = 0, aOff = 0, bOff = 0) |
a * b: applied to a vector, b happens first. |
mat4MultiplyAffine(out, a, b, outOff = 0, aOff = 0, bOff = 0) |
a * b for matrices whose bottom row is 0, 0, 0, 1 (translation, rotation, scale). Faster; wrong for projections. |
mat4FromQuatPosScale(out, q, pos, scale, outOff = 0, qOff = 0, posOff = 0, scaleOff = 0) |
Builds translation × rotation × scale. |
mat4Invert(out, a) |
The inverse. Returns null, leaving out untouched, if the determinant is exactly zero. |
mat4LookAt(out, eye, center, up) |
A right-handed view matrix looking from eye toward center, down its -Z. |
mat4NormalMatrix(out, m, outOff = 0, mOff = 0) |
The inverse transpose of the upper 3×3, written as a WGSL mat3x3 (3 columns padded to 4 floats). Returns false for a singular matrix, else true. |
mat4Decompose(outPos, outRot, outScale, m, mOff = 0) |
Splits into translation, rotation and scale. Shear is lost. Returns false, leaving the outputs untouched, if an axis has zero scale. |
mat4OrthographicReverseZ(out, left, right, bottom, top, near, far) |
An orthographic projection for WebGPU, reverse-Z: near maps to depth 1, far to 0. |
mat4PerspectiveReverseZInfinite(out, fovYRadians, aspect, near) |
A perspective projection for WebGPU, reverse-Z with no far plane. Use with depthCompare: 'greater' and a depth clear of 0. |
A box is a pair of vec3s, min and max. An empty box has min at +Infinity and max at -Infinity.
| Function | What it does |
|---|---|
aabbTransform(outMin, outMax, min, max, m, mOff = 0, outOff = 0, inOff = 0) |
The smallest axis-aligned box holding the box transformed by m. An empty box stays empty. Returns nothing. |
aabbBoundingSphere(outCenter, min, max) |
Writes the box’s centre into outCenter and returns the radius of the sphere around it. |
aabbUnion(min, max, otherMin, otherMax) |
Grows min/max in place to hold the other box. Returns nothing. |
aabbSetEmpty(min, max) |
Makes the box empty. Returns nothing. |
aabbRayDistance(min, max, origin, direction, boundsOff = 0) |
Distance along a ray to where it enters the box: 0 if the origin is inside, -1 for a miss. Does not check the ray is finite. |
rayTriangleDistance(origin, direction, positions, a, b, c) |
Distance along a ray to a triangle, from either side, or -1 for a miss. a, b, c index flat xyz positions, so pass index * 3. The distance is in units of direction’s length. |
Names that changed in 1.0, for code written against 0.x. A renamed option, and each of the four properties with an entry here, throws saying what it is called now; a renamed method is simply gone, and calling it says it is not a function.
| Was | Is |
|---|---|
engine.run({ scene, camera, update, frame, overlay }) |
engine.run(scene, camera, { update, frame, hud }) |
overlay (in run and renderFrame) |
hud |
engine.stats.overlay2D, overlay2DWritten |
hudSprites, hudSpritesWritten |
a sprite’s rotation |
angle |
addReflectionProbe, setReflectionProbe, reflectionProbeOf, engine.captureReflectionProbes |
addProbe, setProbe, probeOf, engine.captureProbes |
a probe’s min and max; its blend |
position and size; fade |
play({ add }) |
play({ join }) |
scene.advanceAnimations(dt), scene.advanceParticles(dt) |
scene.advance(dt) |
node.setRotationAxisAngle, node.setRotationEuler, node.getWorldPosition |
setAxisAngle, setEuler, worldPosition |
node.setLight(changes) |
scene.setLight(node, changes) |
scene.playerFor(node) |
node.animation |
text’s anchor; Camera2D’s anchor |
pivot, from the top-left |
an emitter’s size: [birth, death] |
size and sizeEnd |
engine.rhiNow engine.gpu. Reading or assigning engine.rhi throws 'engine.rhi is now engine.gpu'.
camera.rotation (Camera2D)Now camera.angle. Reading or assigning it throws.
engine.renderer.drawSkyboxNow engine.renderer.skybox. Reading or assigning it throws.
engine.renderer.post.requestedLevelsNow engine.renderer.post.levels; what the last frame drew is post.levelsDrawn. Reading or assigning it throws.