Releases
189 releases of DriftEngine, newest first.
4.12.0 · 2026-10-10
- added
@driftengine/createis the new packagenpm create @driftengine@latest my-gameruns. It starts a Vite and TypeScript project from thestarterexample, a lit cube turning at a rate a DriftScript rule sets, or with-- --template first-gamefrom the complete 3D game, and both are generated from the typechecked examples, so a new project starts as code the compiler and the examples page have already run. It asks nothing and installs nothing, because the caller is as likely to be a coding agent as a person and an agent cannot answer a prompt. A project carries anAGENTS.mdnaming its commands, the rules the compiler cannot check and what done means there; thedriftengineskill in.agents/skills/and.claude/skills/, the folders Codex, Cursor and Claude Code read skills from; andnpm run check, which typechecks, runs the tests in Node and looks at the game.-- --skilladds the skill to a project that already exists. The engine is pinned at the command's own version and DriftScript exactly at the engine's pin. Vitest is 5 rather than the 4.1 this repository runs: Vitest 4.1 cannot be installed into a project with no lockfile by npm 10.9, the npm Node 22 ships, whose resolver stops on its peer list.npm run cleanroomnow starts both templates from the packed tarballs and typechecks, tests and builds them, and with--lookphotographs them; both pass. - added
@driftengine/core/scripts/look.mjsis the engine's capture instruments as one verdict, for whoever cannot see the screen.look({ url })opens the page once with?backend=webgpuand once with?backend=webgl2, declines the boot badge, and writes what a player sees on each, the canvas alone beside it, andlook.jsonwith every console line. It says which backend actually drew, read from the context the page's canvas asked for rather than from the address, and how much of the canvas changed between two frames. It fails on a console error or a warning, on a canvas that is 99.5% one colour, measured with the page's own interface hidden so a readout over an empty canvas does not pass, and on a backend asked for and not used. With no hardware GPU it refuses rather than trust a software rasteriser. Against the starter it reported both backends drawing; against a page clearing its canvas under a text readout it reported nothing drew on either, and against a page with no canvas that no renderer started. A started project runs it asnpm run look, and-- --headedopens a real window where a headless browser cannot reach the GPU. - added
skills/driftengine/is an agent skill in the Agent Skills format: when the engine fits and when it does not, how to start and check a project, the shape of a program, the rules the compiler cannot check, the traps that look like engine faults, and where to read further. Its code is regions of the typechecked examples, rewritten from them bynpm run create:templates, and its three references, every manual page, every example and every package, are generated from the repository with addresses at the installed version's tag, so a page added to the manual reaches the skill on the next run. A test fails when any of it is stale, when its frontmatter breaks the format, and when it names a type, function or option the engine's source does not declare. The manual gains a chapter, Working with a coding agent, and the installation page starts fromnpm create @driftengine. - changed
browser.mjsknew only Chrome's Linux and macOS paths and stopped a browser by signalling its process group, which Windows does not have, so neither the capture tools norlookcould run there.browserCandidates(platform, env)lists Chrome, Chromium and Edge where each system installs them: under both Program Files and the per-user folder on Windows, where Edge is always present, and under/Applicationsand~/Applicationson macOS. On Windows the browser's whole tree is stopped withtaskkill /T.lookuses each system's own graphics path through ANGLE: Vulkan on Linux, Metal on macOS, Direct3D 11 on Windows. Run on Linux only so far; on Windows and macOS the first run is a measurement still owed, andlookprints the GPU it found on its second line. - added
A layered material read layer i from arrays of its own, so a texture worn by many materials in many tints was baked once per material with the tint in its pixels: in the case reported, about 1 GB of arrays for 140 MB of textures.
arrayLayerspicks which layer of the albedo, normal and ORM arrays each layer reads, so materials share one array of each kind;extrasAtsays where a material's own maps beyond its layers start, a mask and a mesh normal among them, which just past the layers no longer is once arrays are shared.looksgives each layer atint, linear and allowed above 1, theroughnessandmetalnessranges its ORM's channels are spread over, anormalStrengthits normal map's slopes are scaled by, and aspecular, blended as the colours are, that every highlight, reflection and area light reads in place of the vertices'; and a layer's repeat may be a pair, across and down apart. They are a lit switch of their own,LAYER_LOOKS, fifteen fragment uniform vectors on WebGL2, off until a material names one, and a layered material that names none draws as before. Ondemo/dev/layerLooks.htmllayers picked from shared arrays draw the same picture as the same layers in arrays of their own, white tinted draws the same as the coloured texture, a strength of 0 draws the same as a flat map while 3 bands the surface harder than 1, roughness spread over 0 to 0.1 against 0.9 to 1 moved 13.9% of the frame, a layer's specular of 0 took the highlight off, and a repeat of[4, 4]drew the same as4while[4, 1]stretched the layer, identically on both backends.SurfaceLayersandSurfaceLayerLookare exported for consumers that build them. - added
A cliff blended from layers repeating dozens of times across it lost the shading of its own large shape, which only a map at the mesh's coordinates holds.
meshOcclusionreads the red of the ORM array's layer after the material's mask and added mask, at the mesh's coordinates asmeshNormalreads its normal, spreads it over arange, and darkens the surface bymix(1, occlusion, strength). A number is the strength and darkens the blended colour, and so every light on it;{ into: 'ambient' }darkens the ambient light alone, sky, ground and probe, as an ambient occlusion does, leaving the sun and the lamps as they were. Absent or 0, nothing is read. Ondemo/dev/layerLooks.htmla wall under an occlusion ramp darkened from black at its left edge to untouched at its right, 35.9% of the frame against the same wall without it; lit only by its ambient, the two targets drew the same picture, and with a sun on it the ambient target kept the sun's share; a range of 0.5 to 1 left the edge half dark. The same on both backends.
4.11.1 · 2026-10-10
- added
layerscovers more of the ways a layered surface is blended.mask: 'orm'reads the mask from the ORM array's layer past the material's layers, which leaves the model's slot to a lightmap's page, so a lightmapped surface blends its layers;mask: 'vertex'takes the weights from the vertex colour, which then tints nothing.blend: 'sum'mixes the base toward each layer times its weight, by the weights' sum held to 1, besides laying each over the ones before it. Under aprojectionthe layers are placed by the world, each at its own repeats a metre, andaddMaskadds a mask placed the same way to one layer's weight.facingweighs one layer by how much the surface faces up, andmeshNormallays a normal map read at the mesh's own coordinates under the layers' blended normal. A triplanar projection lays layers on the horizontal plane, and says so once. Ondemo/dev/layerFamilies.htmla mask carried by the ORM array draws exactly as the same mask as a map, keeps its layers under a lightmap's page, and every mode draws the same on both backends. - added
transmissionColor, linear, is the colour the light from behind a thin surface (diffuseTransmission) takes through it, in place of the surface's own colour: a printed banner glowing a flat red behind its print rather than lit through in the print's colours. Absent, the surface's colour, as before. It rides the glass tint's colour lanes, which no opaque material reads, and a pane drawn between two draws of the material puts it back. Ondemo/dev/thinLight.html?color=0,0.4,1a red banner lit from behind went from 163, 24, 19 to 16, 74, 171 under the sun with no red let through, the same to the pixel on both backends. - added
Ambient occlusion is whole up to
distancemetres from the eye and goneradiusmetres past it, held until changed; a negative distance, the default, fades nothing. Far off, the depth buffer's steps are metres apart and the estimate shaded them in bands across a sky dome or a mountain range; past the fade those pixels are left open and skip the estimate's walk. Bound in DriftScript asdrift/render'socclusionFade. Ondemo/dev/aoFade.htmla ridge 3 km off went from 66,267 banded pixels to none, while the feet of the near boxes darkened exactly as before, on both backends. - added
A lightmapped batch carries its page regions in the lane
MeshInstances.alphaswould take, so its instances could not fade. Their opacity now travels as whole steps added to the region's U offset, 64 of them, the screen door's own resolution, and the vertex stage takes them back off. An opaque instance's region is written to the bit as before; a fading one's offset keeps 16 bits of fraction, a sixteenth of a texel on a 4,096 page. Ondemo/dev/lightmap.html?mode=tiles&fade=0.5the faded tiles keep 50.0% of their pixels, each lit exactly as unfaded, and nothing else changes, on both backends. - added
prepareMesh(mesh, material)andprepareInstanced(batch, material)compile, off the main thread, every pipeline a draw of that mesh or batch in that material takes that making it did not: a two-sided, cut-out or surface-model variant, a skin's halves under the screen-space blur, and the material's lit switches;{ translucent: true }adds the blended ones.ready()resolves once they have. UnderpipelineCompile: 'skip'a draw whose pipeline is new is left out until it lands, so a figure brought on screen came without its skin and hair for a few frames; prepared, it is drawn whole in its first frame. On WebGPU a prepared key is the one the draw asks for, worked out by the same function; on WebGL2 a surface model's programs are what a draw can wait for, and are what it prepares. - added
The badge left three seconds after the first frame, twenty at most, so a game whose first screen loads for longer was seen loading.
holdSplashkeeps it up until the promise settles, either way, as well as until the first frame; frames run behind it while the game loads, since a load may need them, and are held once it has settled for what is left of the minimum.capMsis the game's ceiling in place of the engine's twenty seconds, the longest of several holds, and the call answers whether there was a badge to hold. Ondemo/dev/splashHold.htmla load settling at 6.2 s took the badge down at 6.7 s with 361 frames run under it, a rejected load the same, and an 8-second cap at 8.4 s.
4.11.0 · 2026-10-10
- added
A surface overlay's rim takes
mode: 'blend': the base colour is mixed toward the rim colour by the edge's weight, raised to its contrast and scaled by its alpha, before lighting, so the edge is lit, shadowed and fogged as the rest of the surface is rather than glowing over it, and a colour channel weighted above 1 is held at 1.'add', the default, is the rim as it was. Ondemo/dev/surfaceOverlay.html?rim=blendan edge out of the light moved by −1.9 in luminance where adding moved it by +41, and the two backends draw the same picture. - added
With
setDitherOpacity(true)the draws that follow keep a pixel only where the opacity it carries (a mesh's own, orMeshInstances.alphasfor an instance of a batch) lies above an 8x8 ordered threshold, the patternsetDitherFadeuses, so an opaque instance fades in or out over a few frames instead of popping, still writes depth and needs no sorting; two instances crossfading cover each pixel once. Ondemo/dev/hlodFade.html?opacity=the share of pixels kept matches the opacity to 1/64 on both backends. What it gives up: a screen door shows as a pattern up close, which the temporal resolve smooths where it runs. - added
createSkinnedClothSet(renderer, setups)solves several garments together: on WebGPU one pass a step, each constraint batch of every garment merged by colour into one dispatch, where each garment had its own encoder, pass and submit. Garments are named by their index, insetPose(g, …),setWind(g, …),reset(g)andparticles[g]forsetCloth, andstep(dt)runs them all;createSkinnedClothis a set of one, and a backend without compute runs the set on the CPU. Garments in a set sharesubsteps,iterationsandmaxSteps, and are refused by the first they disagree on.SkinnedClothParameters.maxStepscaps the whole steps oneadvanceruns and drops the time past it, so a slow frame no longer hands the next one more steps; unset, it is unbounded as before. Ondemo/dev/skinnedCloth.html?bench=1&garments=15, fifteen garments step in 0.793 ms as one set against 8.903 ms as fifteen solvers, and a set agrees with separate solvers within the spread of a nudged control. - added
A lit pipeline first needed inside a frame, for a material's surface model or a lit switch met mid-scene, was compiled where it was asked for, which holds the GPU process for the compile, reported at 130 to 400 ms on a phone. With
pipelineCompile: 'skip'the compile starts off the main thread and the draw is left out until it lands;'wait', the default, is as before.ready()waits for every compile started either way. On WebGL2 a model program compiles throughKHR_parallel_shader_compilewhere the context offers it and waits where it does not. Ondemo/dev/pipelineCompile.html, eight new variants at once held a WebGPU frame 700 to 3,700 ms waiting and 17 ms skipping, the draws landing 21 to 26 frames later; through ANGLE on OpenGL the longest WebGL2 frame went from 820–1,120 ms to 630–700 ms, the driver finishing its own compile at the program's first draw. What it gives up is the draw, for the frames its compile takes. - added
projection: { kind: 'planar' | 'triplanar', scale, sharpness? }reads a material's maps at a point's place in the world rather than at the mesh's texture coordinates:'planar'across the two horizontal axes,'triplanar'on three planes blended by how squarely the surface faces each, with the normal map turned into the world by the whiteout blend, so no tangent frame is needed. A lit switch, compiled in the first time a material asks. Ondemo/dev/worldUv.htmla triplanar cube matches the same map read through each face's own coordinates and tangents, on both backends. What it gives up: a projection belongs to the world, so a mesh that moves slides under it; triplanar reads each map three times; a texture is mirrored on a plane's far side; and a cutout material's shadow is still cut by the mesh's coordinates. - added
A frame's
beginShadowPass(matrix, 'dynamic')drawn with a matrix other than the environment'slightViewProjis now read through that matrix: the static layer keeps one square over the whole of what stands still, and the moving layer a tight one around what moves, at its own texel size, filter and depth span. The lit stage, the global medium and light volumes all read it, behind a lit switch that turns on the first frame a dynamic pass's matrix differs. Ondemo/dev/sunLayers.html?moving=tighta moving caster's shadow core went from 144 to 202 pixels and its penumbra from 886 to 739, and everything outside the tight square was unchanged to the pixel, on both backends. What it gives up: a receiver outside the static layer's square takes no sun shadow at all, so the tight square belongs inside the wide one. - added
diffuseTransmission, 0 to 1, brings the light falling on a surface's far side (the sun, lamps, area lights and DriftLight) to the eye through it, coloured by the surface's own colour; a metal lets none through, and 0, the default, is every material as it was. A lamp's shadow is read from the side the lamp is on, so a thin surface does not stand in its own shadow. Ondemo/dev/thinLight.htmla sheet lit only from behind went from 16 to 163 in luminance under the sun and to 104 under a lamp, with no light behind it unchanged, on both backends. What it gives up: a thin surface rather than a volume, so a thick one lets as much through as a sheet; the light behind is not blurred; and glass lets light through by its own rule. - added
layers: { mask, repeats, emissiveLayer? }lays up to four layers over a base by an RGBA mask read at the mesh's coordinates, red laying layer 1 through alpha laying layer 4, each over every one before it. Layeriis layeriof the material's albedo, normal and ORM arrays, read at the coordinates times its own repeat, and the tangent-space normals are blended before the frame turns them into the world;emissiveLayermakes the emissive map glow only where that layer shows. A lit switch, compiled in the first time a material asks, and the mask is read wheremodelMapgoes, so it takes no texture binding of its own. Ondemo/dev/layers.htmlthree layers at three repeats draw the same on both backends, and a material without layers draws as before. What it gives up: every layer is read at every pixel whatever its share; a material carries a mask or a surface model's map, not both. - changed
Every attribute of a mesh travelled as 32-bit floats and every index as 32 bits. Indices are 16 bits now wherever a mesh has 65,536 vertices or fewer; normals and tangents travel as 16-bit signed fixed point, colours and skin weights inside [0, 1] as unsigned; and a colour or an emissive every vertex shares is a constant rather than a stream. Both backends decide it the same way, from the data, when a mesh is made, and positions and texture coordinates stay floats. Measured on WebGPU across the eight published scenes, mesh vertex and index buffers went from 210 to 150 MB: the voxel sandbox's vertices from 38.4 to 28.5 MB and its indices from 4.8 to 2.4, the showroom's from 113.8 to 78.2 and from 34.9 to 25.2. Eight of the nine scenes are the same to the pixel on WebGPU, and one moves 12 pixels by 2 of 255 on average, which is normals at 1/32767. What it gives up: texture coordinates stay floats because sixteen bits move a pixel on an atlas tile's edge onto the next tile, measured as 29 pixels in the voxel sandbox; a dynamic mesh's normals stay floats; and an attribute outside its range stays a float rather than being clamped.
- fixed
Where a mesh carries no tangents, the frame its normal map is read in is derived from screen-space derivatives, and its handedness was taken from the texture coordinates' determinant alone, which cannot see a mapping mirrored about the normal: such a surface had its bumps lit from the side opposite the light. The handedness is the surface's orientation on screen against its normal now, which is the determinant's sign wherever a mapping is not mirrored. On
demo/dev/worldUv.htmla mirrored ground read 74.8 derived against 110.8 through its tangents, and 110.8 both ways after; every published scene is unchanged to the pixel. - fixed
The WebGPU lit vertex stage projected a receiver into the sun's map through the matrix of the frame's last shadow pass. Where every pass shared one matrix nothing showed; where a dynamic pass was drawn with a tighter one, the static layer was read through it and its shadows landed in the wrong place. It projects through
env.lightViewProjnow, as WebGL2 always did. - fixed
A pipeline whose
createRenderPipelineAsyncrejected stayed among the compilesready()waits on, so a boot awaiting it never resolved; it is taken out, and built where it is next asked for so the device says why. Andready()now waits for a lit switch's rebuild, so the first frame after it has every pipeline with the feature that turned the switch on, where a capture taken then photographed the frames before. - fixed
A program for a surface model first made between
bindMeshPassand the draws after it was handed the pass's state with the dither fade cleared, so the draws through it ignoredsetDitherFadeuntil the next pass. WebGL2 only: WebGPU carries the fade in each draw's block.
4.10.0 · 2026-10-07
- added
renderer.createStaticDraws(casters)takes the enumerationdrawSceneCasterstakes — rigid meshes and instanced batches, each with its material — once, and keeps it;drawStaticDraws(list)draws it into the open mesh pass under whatever camera, lights and fogbindMeshPassset, the frame's, a mirror's or a capture's, anddisposeStaticDrawsreleases it. On WebGPU a list is recorded into a render bundle for each view and replayed with one call, and recorded again only when something it was recorded against changes; WebGL2 has no bundles and replays the entries every frame, drawing the same picture. A skinned mesh or a scatter batch is refused by name, since both change every frame, and a list draws whole in every view, so a world split into lists by region culls by list. Ondemo/dev/staticDraws.html, 412 draws cost the main thread 0.650 ms a frame drawn one by one and 0.127 ms as a list — 1.646 and 0.202 with a scene capture beside the frame — and the two pictures are the same to the pixel on both backends. - added
renderer.createBoneAnimation(clip)takes a clip as a turn and a place for each bone at each of its frames, andInstancedOptions.animationplays it on every instance of a batch: each vertex follows the one bone its second coordinates' u names, at the instance's own moment of the clip.MeshInstances.clocksholds a phase and a rate an instance, andsetAnimationTime(seconds)the clock they share, set once a frame before the shadows. Every instance moves at the frame's rate and out of step with the next, in the colour pass and in every shadow, with nothing done on the processor once the clip is made;disposeBoneAnimationreleases it. Both backends run the same arithmetic: ondemo/dev/boneCrowd.htmltwenty-five instances played by the vertex stage and posed on the processor by that arithmetic differ by 0 pixels on WebGPU and 2 on WebGL2, shadows included. What it gives up: a vertex follows one bone; an animated batch is culled whole, never instance by instance; and under reconstruction or the temporal resolve a crowd's own movement is not in the frame's motion. - added
createSurfaceTexturetakes ETC2 (etc2-rgb8,etc2-rgb8a1,etc2-rgba8), EAC (eac-r11,eac-rg11) and every LDR ASTC block size from 4x4 to 12x12, each with its sRGB twin where it has one, beside the BC formats it already took. WebGPU asks fortexture-compression-etc2andtexture-compression-astcwherever the adapter offers them, WebGL2 readsWEBGL_compressed_texture_etcandWEBGL_compressed_texture_astc, andcompressedFormatslists what the device samples — so a phone's textures go up at a byte a texel or less, where RGBA takes four.updateSurfaceTexturetakes blocks too: they replace an image behind the handle every draw already holds, read in the colour space the texture was made in and refused where the device does not take them, while an image in place of blocks is still refused.levelBytesgives the bytes a level of any block format holds. Ondemo/dev/phoneTextures.htmlan ETC2 chain, an EAC normal map and an ASTC block draw exactly the texels they decode to, on both backends. - added
A
DrftLoadergivenbcWorkerre-encodes each BC texture as ETC2 or EAC where the device samples those and not BC, which is a phone. The texture is shown decoded first, exactly as before, and its chain then replaces it behind the handle it was drawn with, from a second worker of the same factory, one texture at a time:etc2-rgb8, oretc2-rgba8where the decoded alpha is less than opaque,eac-r11for BC4 andeac-rg11for BC5, every level averaged in light where the slot reads colour. Nothing arrives later for it, and the texture ends at half a byte a texel, or a byte with alpha, where it held four; the encode costs about 0.7 s for a 2048² image on a desktop processor, on a core of its own. Without a worker nothing is encoded.readKtx2reads an uncompressed KTX2 file of ASTC, ETC2, EAC or BC as the blocks it carries, refusing a supercompressed file, a cube or an array by name, andencodeEtc2andencodeEtc2Chainencode pixels a caller holds. Ondemo/dev/phoneLoad.htmlthe loader's swapped texture and the same chain uploaded directly draw the same picture on both backends. - added
A
SurfaceMaterialmay statereflectivity, 0 to 1, andenvironmentGain, held non-negative: the numberssetSurfaceReflectivityandsetEnvironmentGainset for every draw, stated by the material for its own. A draw under a material that states one wears it whatever the setters say, and a material stating neither leaves its draws to the setters, as every material did before; each falls back on its own. They are what lets a static list carry a stage whose batches each mirror their own share of a baked reflection at their own brightness, since a list keeps each entry's material and none of the renderer's state between draws. On both backends. - changed
The lit stage's uniforms on WebGPU are three blocks where they were one: the pass's, written once a pass; the material's, kept in a store by its contents and bound by offset, so a material a frame has drawn before costs no upload; and, for the vertex stage, the camera, light matrix and wind, so a draw's own slot no longer changes when the camera does. A frame that draws what the one before it drew uploads no material bytes, where the dev pages measured 14 to 29 KB a frame, and every published scene draws the same to the pixel on both backends. The cutout dither's frame moved into the pass's block with it, since a number that changes every frame made every cutout material new every frame. The frame's statistics still count material changes, as WebGL2's do.
- changed
On WebGPU a skin palette slot keeps the largest texture it has needed rather than replacing it whenever a rig of another joint count takes the slot, and a skinned or morphed draw's bind group is kept per slot and per material rather than built again; WebGL2's palette only grows as well. A frame whose skinned meshes and characters are culled differently from the frame before no longer creates and destroys textures and bind groups to draw them.
- changed
On WebGPU, rewriting a mesh's positions while DriftTR keeps its previous positions for motion copied the old ones in a command buffer submitted on the spot, one submit a rewrite. The copy is recorded into the frame's own work now, so a mesh rewritten every frame costs its upload and nothing more, and every submit the renderer makes goes through one place.
- fixed
A refracting draw bent the frame behind it by the world x and y of its normal, which on a quad facing the camera is the quad's lean against the world's axes: one offset across the whole quad, a lens, with a normal map's tilt a small change on top. It bends by the normal in view space now, the map's tilt included, so a distortion card shimmers by its map and a pane bends by how it leans toward the eye wherever the eye stands, the same on both backends.
4.9.0 · 2026-10-07
- added
A renderer built with
screenSpaceReflectionsreflects the frame in every opaque lit surface by that surface's own material, whereReflectiveSurfacereflects it by a box's. The lit stage leaves, per pixel, how much of the environment it showed and the tint a reflection lands with; the screen-space trace gains a material mode that marches every such pixel against the frame's depth and swaps the probe's share for what it finds. The swap is exact because the lit stage's blend is a mix: the frame holds the environment times the tint times the weight, and a found reflection belongs there with the same tint and weight, so nothing else in the pixel moves. A rougher surface shows a softer reflection, blurred by its roughness, and pastmaxRoughnessit keeps the probes' reflection alone. One floor mesh with one ORM map therefore mirrors the figures standing on its glossy panels and barely shows them on its matte ones. What a surface reflects is what the lit stage already reflected: a metal by its metalness, a dielectric wheresetSurfaceReflectivityor its material asks.truetakesDEFAULT_FRAME_REFLECTIONS; aFrameReflectionssets the ceiling, the march and the blur. WebGPU writes the two maps by replaying the opaque lit draws without their lamps, since a second attachment on the frame's pass would bind every pipeline in it; WebGL2 writes them beside the colour, enabled around the opaque lit draws alone and behind a lit switch, so a renderer without the option carries nothing for it. It needsscreenEffects,hdrSceneand one sample, and says so once where it has none. Ondemo/dev/frameReflections.htmlthe trace moves 30,138 floor pixels and none above them, a ceiling below every stripe matches the frame without the trace to the pixel, and the two backends differ by 41 pixels. - added
renderer.setSurfaceOverlay(overlay)lays something over the surfaces of the draws that follow, beyond their materials, so one character's whole set of materials changes together while another's does not; per draw, reset bybindMeshPass. Arimglows along the silhouette: an edge(1 - N·V)^falloffleaning toward normals that face up, raised to a contrast, times a noise laid in screen space and scrolled on the environment'ssurfaceTime, a slow pulse and a mask in the mesh's uv, added to emission and not gated on the night. Adissolvecuts the surface away where a noise in its uv falls under a threshold, with a glowing band at the edge and a colour laid through the same noise. Awrinkleblends a second normal map into the surface's by six region weights read through two masks. Every image is a region of one atlas (OverlayRegion), read in the slot the frame's refraction copy takes, which an opaque surface never reads; a draw that refracts keeps its refraction and reads none of the overlay's images, and says so once. A lit switch, off until an overlay is set. WebGL2 carries the overlay's fifteen uniform vectors where its device has room and refuses it in words where it does not, as on a 256-vector part at the eight-light budget, and reads a compressed atlas as none. Ondemo/dev/surfaceOverlay.htmlevery control moves only the object it names, and the two backends agree to the pixel. - added
outputTransform: 'filmic'is a film curve in the ACEScg working space: a straight segment through mid grey meeting a soft toe below and a shoulder above, shaped by a slope, a toe, a shoulder and two clips, with the ACES reference's glow and red modifier around it.setFilmicCurvesets the numbers and a renderer starts withDEFAULT_FILMIC_CURVE; a curve that runs backwards or divides by zero is clamped and said once rather than refused. 0.18 of scene light comes out at 0.18, where'aces'gives about 0.11: 118 of 255 on both backends, and every one of sixteen grey and colour patches agrees with a consumer's own transcription of the reference. It is the composite's, so it needsscreenEffectsandhdrScene; without them every pass grades with'aces'and the renderer says so once. Bound to DriftScript asdrift/render.filmic. - added
highDynamicRange: trueasks for a canvas whose highlights run past paper white as far as the display can show. It is had where the renderer is on WebGPU, the frame keeps its light past white (screenEffectsandhdrScene) and the display reports a high range: the canvas is configuredrgba16floatwith extended tone mapping and read back to confirm it.renderer.displayRangesays which range the frame goes out in,'high'or'standard', anddisplayRangeReasonsays why; WebGL2 draws into an eight-bit canvas and always answers'standard'.setDisplayLuminance(paperWhite, peak)gives the ratio highlights may reach: paper white stays where it was and so does everything below it, the filmic shoulder rolls off toward the peak, and a colour grade colours a highlight without clipping it back to white. On a standard display it changes nothing. A host without a browser display query answers it withcreateRenderer'shighDynamicRangeDisplay. - added
Camera motion blur now reads each pixel's motion from the reconstruction's motion target, so a mover with a previous placement or pose smears along its own path while the stage standing still behind it does not, and
setCameraMotionBlurtakes a maximum as a share of the frame. Ondemo/dev/objectBlur.htmla box crossing a still camera's view smears 8 pixels, 1 with the blur off, 5 capped. It is WebGPU's under DriftTR alone, since that is where per-object motion is kept; WebGL2, and WebGPU without reconstruction, blur by the camera's motion alone and say so once when given a mover. - added
setChromaticAberration(intensity, start)is a lens fringe in the composite: the three channels sampled apart toward the frame's edges, scaled by their wavelengths, from a start radius outward, so the middle of the frame is untouched. Held until changed; 0 is none. Ondemo/dev/toneCurve.html?fringe=it moves 13,480 pixels, none inside the start, and the two backends agree. It needsscreenEffects, and says so once. - added
Atmosphere.fogStartholds the exponential height fog off for its first metres: the medium begins where the ray has travelled that far, integrated along the ray's own path, so a stage reads clear up close and hazes with distance as a fog start does in a scene authored elsewhere. 0, or absent, is the fog as before. Ondemo/dev/fogStart.htmlthe measured columns match values worked by hand on both backends, and the GPU-driven pipeline's fog takes it too. - added
A pass that declares
reads: ['depthSnapshot']is handed the frame's depth assceneDepth— in thePrepareContexton WebGPU, anr32floattexture, and in thePassContexton WebGL2 — and one that declaresreads: ['colorSnapshot']the colour with everything drawn before it assceneColor, copied afresh at each such pass's draw. A distortion samples the scene behind it with it; a full-screen pass drawn last reads the whole frame and writes over it in scene light, before the tone curve. Under DriftTR the pass runs after the upscale over a copy of the reconstructed frame. Ondemo/dev/framePass.htmla pass inverting the frame reads the same values on both backends, with multisampling too. - changed
spawnBcWorkerand the physics island pool'screateIslandPoolwere reached by a path into a package'ssrc, which a published package'sexportsdoes not have to allow and a strict resolver refuses. Each now has a subpath of its own in its package'sexports,@driftengine/assets/bcWorkersand@driftengine/physics/workers, built todistlike the barrels, and the messages that name them say so. The barrels still do not reach them, which is what keeps a worker out of a game that never names one. - fixed
Three defects at the boundary a capture or a probe bake opens in the middle of a frame. On WebGPU,
captureSceneandbakeProbesaved the open pass before flushing the frame graph, which opens the frame's pass, so a capture after any draw restored the wrong pass and the frame came out black. On both backends, the latch that spreads skin once a frame was set before the check that refuses a mirror, a probe or a capture, so a blended draw inside a capture switched the frame's skin spread off. And the temporal history stepped its jitter each time the camera was bound rather than once a frame, so a frame that bound it twice drew two frames' jitter. Each has a test, andscripts/capture-check.mjscaptures at the start of a frame, among its draws and at its end, against controls taken the same way. - fixed
The check that decides whether a device can build the environment probe's permutation compared its limits against seven sampled textures and four samplers, typed when the probe was added. The widest lit variant had since grown to sixteen and twelve with the material maps, glass, the fixture atlas and DriftLight, so a device under those would have been admitted to a permutation it cannot bind. The counts now come from the generated bindings, a shared sampler counted once.
4.8.7 · 2026-10-06
- changed
createLightmapstored a page as two half-float layers, 16 bytes a texel, so a stage of 49 pages and 21.8 million texels took 348 MB on the device. The irradiance is now one rgb9e5 word a texel, three 9-bit mantissas over a shared 5-bit exponent in the layout ofEXT_texture_shared_exponentand WebGPU'srgb9e5ufloat, beside the direction's four bytes, in tworgba8layers that the lit stage decodes and filters itself: 8 bytes a texel, 174 MB for the same stage.LightmapPage.irradiancetakes aUint32Arrayof packed words as a bake hands them over, uploaded where they lie with no float array in between, as well as floats, which are packed once at upload. What it gives up is precision in a texel's smaller channels, which share the largest one's exponent, and anything past 65,408; light has neither to lose. Ondemo/dev/lightmap.htmla page of one value is still 0 pixels from the same ambient raised bysetAmbientSH, and the room is within one level of 4.8.6's on both backends. - changed
DrftLoaderdecoded a BC texture the device cannot take as blocks in a worker it built withnew Worker(new URL('./bcWorker.ts', import.meta.url))inside the package's barrel, and a bundler writes that worker out at transform time, before tree-shaking, so every game importing anything from@driftengine/assetscarried the BC decoder's chunk from 4.8.4 on, whether or not it loaded a BC texture. The gate that asserts no barrel names a worker bundled each barrel against the built packages and skipped one that would not bundle, so it saw this one only when another test'snpm packhad built them first, which is how it passed three releases. The factory is nowspawnBcWorkerat@driftengine/assets/src/bcWorkers.ts, as the physics island pool is, and a loader takes it asbcWorker; without it BC textures decode on the main thread and the loader says so once, naming the option. The gate bundles from source now. Every scene and page in this repository that loads a model names the worker. - added
A point or spot light may name its own falloff:
(1 - (d/R)^2)^nwithRits radius andnthe exponent, and no inverse square, which is how lighting tools commonly light a light that does not use inverse-square falloff. A rig authored that way keeps its shape: a fill meant to light a whole hall across a hundred metres does, where the frame's falloff had it gone a few metres out. 0, or absent, is the frame's falloff as before. The selection carries it inPointLightBuffer.falloffExponentsand the environment inlightFalloffExponents; the fixed arm reads it in the third lane of the cone uniform, which a two-float array already paid a whole row for, so the eight-light build spends no uniform row on it, and the froxel table's record grows to six texels. Water, DriftLight's volume and DriftRay's bounce fall off by it too, so a light lands the same way on every surface it reaches. Ondemo/dev/clustered.htmlone light drawn by the fixed arm and by the table is 0 pixels apart on both backends, and an exponent of 8 moves 42,123 pixels against none. - added
A rectangle may carry barn doors, a flap hinged at each edge standing
barnDoorAngledegrees from its normal andbarnDoorLengthmetres long, 20 cm when absent, the usual default for a rectangle light. At 90 they fold flat and hide nothing; smaller narrows the light to the opening they leave. Each fragment shades the part of the rectangle no door's tip hides, one axis at a time, so the form factor and the integrated highlight both come from that smaller rectangle, in the fixed arm, which reads the doors in the size uniform's two spare lanes, and in the froxel table's record. Each door is taken as endless along its hinge, so where two meet the corner they hide is square rather than mitred, and a shadowed rectangle's map is still baked from its whole face. Measured from straight above a 1.2 by 0.5 m panel 1.2 m over a floor with 20 degree, 30 cm doors: nothing changes within the 1.04 by 0.69 m box the door tips leave whole, nothing is lit past 4.94 and 2.32 m where they hide all of it, the fixed and clustered arms are 0 pixels apart, and doors at 90 degrees are 0 pixels from none, on both backends. - added
The engine's highlight is a look control: it peaks at the specular attribute times one however rough the surface, so strength and width are set apart. A material with
physicalSpecular: truetakes GGX's own term instead,pi * D * Vis * F * N.Lwith Smith's height-correlated masking, as this engine's skin and eye already did. Its peak is1 / (4 alpha^2)of the light head-on, so a polished surface's highlight is several times the look's and a rough one's lower and wider; the specular attribute is read as the reflectance at normal incidence,0.08 * Specular, and Fresnel brightens every surface toward grazing rather than a metal alone. Lamps, widened by their size as before, and the sun take it, on the standard, lightmap and anisotropic models; a rectangle's highlight was already the lobe integrated over it with that F0. The code is a lit switch, compiled into the lit programs the first time a material asks, so a scene that never does carries none of it. Measured with a lamp and an eye mirrored about a floor at roughness 0.5, where the term works out by hand to 4.4376: the physical highlight at 1/4.4377 of the light reads the look's middle pixel to within a level on both backends, from a lamp and from the sun, and the anisotropic model's physical lobe at strength 0 is the standard one to within a level everywhere. - added
A screen-space reflection's
strengthwas one share over its box, so a floor reflected as much underfoot as toward the horizon and a consumer re-matched the share to its camera's angle each frame. Withfresnel: truethe strength is the surface's reflectance head-on and each pixel returnsF0 * A + Bof what its ray finds, the split-sum environment BRDF at its own view and the box'sroughness: Karis's fit, now one GLSL text that the lit stage and the reflection pass both include. The pass reads no material, so the reflectance and roughness are the box's, and nothing occludes the reflection where the lit stage's ambient occlusion would. Off by default, the one share as before. Measured ondemo/dev/ssr.html: at a reflectance of 1 and roughness 0 it is 0 pixels from a strength of 1, as the fit says, and at a pixel whose view works out by hand to N.V 0.344, reflectance 0.04 matches the constant strength the fit gives there, 0.153, to within a level on both backends. - added
Four floats an instance,
[scaleU, scaleV, offsetU, offsetV], applied to the mesh's texture coordinates before the material's own scale and offset, in the lit, depth and motion stages alike, so a cutout instance's shadow is cut by its own cell. They ride the bottom row of the instance's matrix, which a placement made of a turn, a scale and a move leaves at[0, 0, 0, 1]and which every stage drawing an instance now rebuilds as that, so they cost no attribute and no byte; an instanced placement must be affine, as every placement of a solid object is. Absent, every instance wears the whole texture, and a culled copy carries the cells into a target that has the array. Ondemo/dev/instanceCells.htmleight cards each on their own cell of a strip, in one instanced draw, are 0 pixels from eight single draws with the cell in their materials, opaque and blended, on both backends; the published scenes and the instanced shadow, cull and opacity pages are 0 pixels from 4.8.6 on WebGPU. - added
createSceneCapture(width, height)makes a texture the scene can be drawn into, andcaptureScene(capture, camera, clearColor, draw)draws it: the callback is handed the camera, its matrices shaped to the capture, and submits the world exactly as it would to the screen. The texture then goes wherever an image goes, as a material'semissivefor a screen in the world showing what another camera sees, a monitor or a security feed. It holds radiance, as a reflection probe does: the output transform and exposure are held off for the pass, at the world's colour format and sample count, half floats underhdrScene, so the frame grades the screen once with everything around it. It is the probe bake's arrangement and the mesh pass alone: the sky, water and particles are in it only if the callback draws them, nothing after the world runs on it, it has no mip chain, and the frame's camera, jitter, motion, refraction copy and skin spread are left alone while it draws. WebGL2 draws a capture's rows flipped, as it does a probe face's, so it reads as an image does. A second camera's view shown on a quad filling the frame is 0 pixels from that view drawn directly, single-sampled on both backends and at four samples on WebGPU, where WebGL2's multisampled capture is WebGPU's to the pixel; and a frame with a capture in it is 0 pixels from the frame without one outside the screen. - added
A simulation can run entirely on the device: a compute shader of the consumer's own, registered with
registerCompute, writes its particles into a buffer of its own, anddrawDeviceParticles(batch, { buffer, count }, camera, env, time)draws the pool's material, blend, flipbook, fades and fog from that buffer in place of the one the pool uploads into. Each particle isDEVICE_PARTICLE_FLOATS, sixteen floats in aParticleInstances' order, and the buffer needsVERTEXusage besideSTORAGE; a slot of half-width 0 draws nothing, so a fixed-size buffer can hold fewer live particles than it has room for, and a count past what the buffer holds is cut to it. The particles are not sorted. WebGL2 has no compute stage, so there it says so once and draws nothing. Ondemo/dev/spriteParticles.htmlsixteen sprites written by a compute shader are 0 pixels from the same sixteen uploaded, soft edges included. - fixed
skinModel'sradiuswas documented as how far light travels beneath the surface before it leaves, and was handed to Burley's profile as its distanceditself. That profile carries light 2.5don average and the blur's taps reach 9d, so light travelled two and a half times as far as the model said, andskinScattering: 'screen-space'spread a face at arm's length over sixty pixels: a nose, lips and brows washed into one blur. The profile is now atradius / 2.5in both paths, the screen-space blur and the lit stage's pre-integrated fit, transmission and penumbra, so the two still agree. Measured first rather than assumed: on a step of light at 0.75 m and 800 by 600, the blur matched the profile's own masses at the old distance exactly, edges at the predicted taps, so the kernel was right and the distance was not; it now reaches 26 pixels where it reached 65, on both backends. A skin authored against the old meaning and looking right keeps its look with itsradiusraised by 2.5. - fixed
A brushed surface takes its direction from the tangent frame, and a mesh carrying neither tangents nor coordinates gives a frame of zeros, which normalised to a NaN and drew the whole surface black. It takes a direction in the surface's plane instead, across the world's up, so the highlight is stretched somewhere. Measured on a floor with no coordinates: the anisotropic model at strength 0 draws the standard highlight to within a level on both backends, where it drew nothing.
- fixed
A multisampled target shades a pixel on a quad's edge at its centre, which can lie outside the quad. There an arc's side coordinate ran past one, the filament's
acrosswent negative, andpowof a negative number is NaN on both backends: one dark speck, which bloom then spread into a white disc the size of its blur around every arc.acrossis clamped before its powers. Found by a scene drawing short arcs at four samples. - note
What every lit pipeline now compiles was measured on RADV with a phone's viewport, against 4.8.6: the fixed-arm lit pixel shader is 5,778 instructions where it was 5,618 on WebGPU and 5,171 where it was 4,989 on WebGL2, both still 96 registers; the clustered one is 6,831 where it was 6,507 and 6,313 where it was 5,934, still 120 and 108. Registers set how many fragments a phone's GPU keeps in flight, and they did not move. The physical highlight is a lit switch and costs nothing until a material asks for it. Core's gzipped size is 828.1 KB, 29.0 KB over 4.8.6, almost all of it the generated WGSL, whose sixteen lit permutations each carry the new light loop.
4.8.6 · 2026-10-05
- added
createLightmap(page)uploads a baked page, linear irradiance as three floats a texel and the direction it arrives from as a first-order spherical harmonic in four bytes, as two half-float layers, and returns a texture that a material made withlightmapModel({ region })takes as itsmodelMap. A surface addsalbedo * (1 - metal) * irradiance * max(0, dot(d.xyz, n) + d.w)to its diffuse, a directional lightmap read, withnthe shading normal and the page read at the mesh's second coordinates,MeshData.lightmapUvs, scaled and offset by the region. An instanced batch carries a region an instance inMeshInstances.lightmapRegions, applied before the material's, so instances of one mesh sit in different parts of one page. The lightmap is a shading model, a pipeline constant of its own, so nothing that does not use one pays for it, and every published scene moves 0 pixels on WebGPU. What it takes is spent elsewhere: the coordinates ride the grain and relief attributes, stored below zero so that the same mesh drawn without its page has neither, and an instance's region rides its tint and opacity. A mesh naming lightmap coordinates beside grain or relief that is not zero is refused, and a batch and a material that disagree about regions are said once on the console. Checked on a room baked on the CPU with a box's shadow in it (demo/dev/lightmap.html): a page of one value adds exactly what the same ambient raised bysetAmbientSHadds, 0 pixels apart on both backends, sixteen instanced tiles are within one level of the same tiles drawn one at a time, and the two backends are within one level of each other. The GPU-driven pipeline reads no page. - added
setProbeLayerImage(layer, image, options)issetEnvironmentImagefor one layer of the gridsetProbeGriddeclared: the same equirectangular image of linear floats, the same projection and the same prefilter into the layer's reflection and irradiance, after which the layer counts as baked. A stage's reflection captures from another engine can then be fitted to the lattice and drawn without baking them here, where until now only the bake could fill a layer. The other layers keep what they held. Checked on both backends by a grid of one layer handed the image the environment is handed, which draws 0 pixels from the environment. - added
setAmbientSH(coefficients)takes the 27 numbers of a second-order spherical harmonic, nine coefficients of radiance in three channels, and lights the draws that follow by its cosine-convolved irradiance in place of the frame's ambient and the probe grid's, untilnullor the nextbindMeshPassgives the frame's back. A character moving through a baked irradiance volume samples it where it stands each frame and sets it around its own draws, a per-object volumetric lightmap. It is material state, so on WebGPU a change takes a material slot. The lit shader had no uniform row to spare on a 256-row phone, so scalar uniforms were packed into vectors they now share, and the eight-light build fits the 256 rows it fitted before. Checked on both backends: the frame's own sky-and-ground gradient handed back as coefficients draws 0 pixels from the frame without them. - added
Each point or spot light carries a channel mask,
Environment.lightChannelsbesidelightColors(andPointLightSet.lightChannelswhere a consumer fills the set itself), and each material one,SurfaceMaterial.lightChannels, both whole masks from 1 to 255 and 1 by default, so a scene that names none is lit exactly as it was. A light shades a surface only where the two share a bit: a game lights its characters with key and rim lights that the stage never sees. The fixed loop and the froxel table both honour it on both backends, the mask riding a spare lane of a light's profile axis in the one and the light's record in the other. The sun, the sky, the probes, area lights, DriftLight and the GPU-driven pipeline light every surface whatever its mask. - added
setBloom(scale, threshold, response)takes aBloomResponse:ramp, the scene units over which a colour comes in from none of it at the threshold to all of it,saturate((L - threshold) / 2)where the old subtraction stays the default; andtints, a colour for each of the six levels from the finest halo to a twentieth of the frame, white by default and not clamped, so a tint carries a strength. One band over the colour less a low threshold washed a stage authored with a threshold of 0.1 white. Both backends weigh each level by a blend constant as it is added back, so the response costs no pass and no texture, and a frame that names none draws what it drew: the published scenes move 0 pixels on WebGPU.BLOOM_LEVELSis exported. - added
createMeshInstancesallocatesalphas, one opacity an instance and 1 by default, anddrawTranslucentInstancedmultiplies each into the batch's own, where the per-vertex alpha lane is multiplied in; an opaque draw ignores it. The instance's tint attribute became four floats wide to carry it, in the float the instance stride already padded, so an instance costs what it did. Checked by eight panes at eight opacities drawn as one instanced draw and as eight translucent draws: 0 pixels apart on both backends. - added
createParticlestakes the'sprite'material with atexture, drawn unlit and multiplied by each particle's colour, and the options a game's effect materials carry:cellswith a per-particleframesindex for a flipbook,blendCellsto blend toward the next cell by the fraction, per-particleheightsfor a card that is not square,softDepthto fade where a sprite meets the opaque scene (it reads the frame's depth, which needsscreenEffects, and says once when there is none),cameraFadeto fade it in front of the eye, andcameraOffsetto move a particle toward the eye so a spark born inside a body is drawn in front of it. The facing'velocity'stands a particle along its travel across the view.sortdraws a blended batch farthest first, by a stable merge sort into storage allocated with the batch, so nothing is allocated per frame. Both backends draw it from one packing of the sprite stream. - added
Rectangles past the fixed
maxAreaLightsare binned into the froxel table beside the point lights and shaded by the same function the fixed loop uses, integrated specular and all, so one rectangle drawn either way is 0 pixels apart on both backends. They take no shadow, and are windowed to zero atAreaLightSource.range, an attenuation radius in metres, which a rectangle that names none derives from where its light on its own axis falls to a thousandth of a scene unit. A stage with 163 rectangle lights had been drawing them as spots. Without the table, rectangles past the fixed ones are said once on the console rather than dropped quietly. - changed
A spot was binned into every froxel its bounding sphere touched, so a 10 degree spot reaching tens of metres filled a large share of the frustum, and a rig made mostly of narrow spots was reported costing about 0.035 ms a light at 1600x900. Both binners now test each froxel's bounding sphere against the cone exactly, the apex included, and the CPU and GPU tables still agree byte for byte:
scripts/cluster-check.mjscompares them, and now also asserts that a cone culled something. The picture does not change, because a froxel the cone misses received nothing from it. - changed
Refraction offset the frame behind a surface along its geometric normal, so a heat haze or a hit spark's distortion sphere wearing a noise normal map bent as a smooth lens. It takes the shading normal now, the one the lit block shades by before a back face is turned, and an unlit draw, which shades none, reads its normal map through the same function. A surface with no normal map bends exactly as before: the refraction page's clear pane and every published scene are 0 pixels from 4.8.5 on WebGPU, and lit and unlit draws of one mapped pane are 0 pixels apart on both backends.
- fixed
The WebGPU joint-palette ring was capped at 128 palettes a frame and declined every skinned draw past it, and a rig drawn in five passes (the main pass, a reflection and three shadow layers) uploaded the same matrices five times, so three dozen idling extras beside two characters filled it in one frame and the consumer stood its crowd in the bind pose. The ring holds as many palettes as a frame holds draws,
RenderQuality.drawsPerFrame, and a palette set again with the same numbers is the slot it already has, compared by value, since a caller may pose every rig into one scratch array. A slot still allocates its texture only when a frame first reaches it. WebGL2 never had the limit. - fixed
Under
skinScattering: 'screen-space'the diffuse half carried the coloured diffuse into the blur, so every mark painted in a skin's albedo, a brow, a freckle or a lip line, was spread into the skin around it as a ghost. The half now shades a white surface, which is the light the blur should spread, and a third half writes the skin's colour into a target of its own, depth-tested for equality as the diffuse half is, by which the blur's second pass multiplies what it spread. A skin of one colour draws as it did. It costs a colour target at the frame's size, and its multisampled twin where the frame is multisampled, on both backends. - fixed
Opening a pass bound the blank material group while still holding the previous pass's normal, ORM, emissive and model maps, and
setMaterialskips the lookup when the maps it is given are the ones held. So a pass whose first material was the last of the pass before drew with the stand-ins from the second frame on while its flags said the maps were bound: a metal read its roughness off a blank texel, a mirror blurred, and a lit side took the sun as a dielectric. Any scene of one material had it. A pass now forgets every map it opens on, as WebGL2 always did, and the two backends agree again.
4.8.5 · 2026-10-05
- fixed
The shader comment stripper a consumer's build runs,
shaderCommentStripperfrom@driftengine/core/dist/build/shaderComments.js, found GLSL template literals with a pattern that paired the escaped backticks quoting an identifier inside a comment. A//comment quoting one was cut halfway and the rest of its line reached the compiler as code, so from 4.4.0, when such a comment arrived in the lit shader's shadow code, every lit shader a consumer built with the plugin failed to compile on WebGL2 at every light budget, while WebGPU, which never meets the GLSL, hid it. Literals are read with their escapes now, and comments of both kinds in one pass, so a/*inside a line comment opens nothing. A strip that changes how many literals a module opens and closes is refused, and the check that a GLSL marker survived reads the code rather than the prose, which had refused a correct strip of the lit shader's own three modules. The plugin's corpus test read the top of one shaders directory, 47 of the 130 modules the plugin reaches and not the lit shader's; it reads every package's shaders now and asserts that no backtick is left in the GLSL. A production game rebuilt with the fix compiles every program on WebGL2 and ships 62 KB less gzipped JavaScript, 1,319 KB to 1,257, because the old pattern's mispairing had been leaving much of the commentary in.
4.8.4 · 2026-10-05
- added
SurfaceMaterial.modeltakes a frozen descriptor fromanisotropicModel,hairModel,skinModeloreyeModel, andmodelMapan image of that model's own channels, read at the albedo's coordinates. Each model is a pipeline constant of its own, off in every pipeline but the ones a material asks for and compiled the first time a draw does, so the standard lit shader is the one it was: every published scene moves 0 pixels on WebGPU, and the lit shader's instruction count and registers hold on RADV.anisotropicModelis GGX stretched along the mesh's tangent by a strength and turned by a rotation, afterKHR_materials_anisotropy, and is the standard highlight exactly at strength 0; the environment is reflected about a normal bent toward the stretch, so a brushed disc shows a streak of sky. Hair, skin and the eye ignore the per-vertexspecular: their highlights are physical, set by their own index of refraction. The models belong to the forward path; aGpuDrivenMaterialnames none. The manual's materials chapter says what each map channel means. - added
hairModel({ shift, scatter, backlit })treats each strand as a rough dielectric cylinder along the tangent, running from root to tip, after Marschner et al. in the real-time form Karis gave: the reflection off the surface, white and shifted toward the root by twice the cuticle's tilt; the light through the strand, which is the glow of a backlit head and is nearly nothing with the light in front; and the light reflected once inside, the coloured second highlight. Each is a normalised longitudinal Gaussian times an azimuthal fit, so a lamp's size widens every lobe without brightening it, and a wrapped scatter term carries the light that has passed through many strands, deepening toward the fibre's hue in a penumbra. The sky and DriftLight reach it through that scatter, and the environment through the reflected lobe at the strand's own Fresnel.shiftis the tilt in radians, 3° by default; a card whose tangents run tip to root takes it negative. Held by tests to the three lobes' tilted peaks, reciprocity, a white strand returning what it receives to within the fit, and finite answers where the geometry degenerates. - added
skinModel({ scatterColor, radius, transmission, profile })gives each colour its own scatter distance,radiustimes its share ofscatterColor, and on a curved surface lights a point by its neighbours' angle to the light as well as its own: Burley's diffusion profile averaged around a ring of the surface's curvature, after Penner, evaluated as a closed-form fit rather than looked up, so it costs no texture. The fit splits the average into the cosine's first harmonic and a smoothed absolute value carrying the rest, is exactly Lambert on a flat surface, and stays within 0.0105 of the integral done numerically wherever it is used. The curvature is read from the screen or from the model map; light from behind a thin part comes through it, and a shadow's penumbra lets each colour in as far as it travels. The highlight is two normalised GGX lobes at skin's Fresnel. Measured at a person's scale: a lamp behind a six-millimetre fin moves it red by 99 levels and blue by 8, and the standard model by nothing. - added
A renderer quality option. Each skin draw writes its specular to the frame and its diffuse to a half-float target of its own — drawn a second time to do it — which is spread along each screen axis by Burley's profile integrated across a line, every tap weighted by the profile's mass over its cell in closed form, refusing taps off the skin's depth and counting part-covered ones for their part, and added back before the frame's first blended draw. Under it the per-pixel fit steps back to Lambert's and the blur does the scattering, so light crosses a shadow's edge and a nostril's rim on screen; light through thin parts stays with the fit. The split adds back to the whole: with a blur narrower than half a pixel the two pictures differ only at silhouettes, on both backends, multisampled or not, and under a pane drawn over the skin. It needs the composite (
screenEffects) and, on WebGL2,EXT_color_buffer_float; without either, skin is pre-integrated and the renderer says so once. What it costs: every skin draw twice, two half-float targets the size of the frame and a third under multisampling, and two full-screen passes that leave at once where no skin was drawn.profilepicks one of eight scatter distances the blur uses. - added
eyeModel({ irisRadius, irisDepth, ior, corneaRoughness, axis, joint })refracts the eye's ray at the cornea and moves the iris's texture coordinate by how far that ray crosses the iris plane before any map is read, so colour, relief and the model's channels move as one. Measured on a flat cornea at 30°: the iris is seen across 1.21 mm of a 3 mm depth, where parallax alone would put it at 1.73, both as predicted. The iris is lit by the surface normal mirrored about the eye's axis, so light pools on the side away from it, the sclera by its own, and the cornea's highlight — normalised GGX atcorneaRoughness, the Fresnel of index 1.336 — lies over both.axisandjointsay which way the eye looks and which bone it turns with; the renderer turns the axis per draw. - added
A dithered cutout keeps a pixel by the share of it the texture covers rather than by a line through it. The material asks for the smooth edge and the frame decides how: under a temporal resolve (TAA or DriftTR) the threshold moves every frame and the resolve averages the pattern into coverage; multisampled, the share goes to the GPU as alpha-to-coverage; with neither, the test is hard, since a dither nothing averages is grain. Shadows and coloured glass shadows dither with a pattern that holds still, and a translucent draw tests hard whatever the material says.
'hard'stays the default, and every cutout before this draws as it did. Measured on both backends: grain under TAA 11.7, DriftTR 13.1, and the hard test's edge unchanged to the pixel. - added
MeshData.joints2andweights2add a second four influences besidejointsandweights. glTF'sJOINTS_1andWEIGHTS_1are read, and FBX skins with more than four bones a vertex keep eight, sorted heaviest first and normalised across all eight..drft1.24 writes them after every array a 1.23 reader reads; a 1.23 reader refuses such a mesh rather than drawing it with its second four read as indices. A skinned draw with them takes the eight-influence vertex stage on both backends — lit, shadow-casting and DriftTR's motion — under a pipeline constant inside the existing skinned variant, so a four-influence mesh draws exactly as before and the generated shaders do not double. - added
createSurfaceTexturetakes aCompressedTextureSource— format, size and the stored mip chain — and uploads it as it is whererenderer.compressedFormatshas the format: WebGPU asks fortexture-compression-bcwherever the adapter offers it, and WebGL2 reads the four extensions BC is spread across. Core ships no decoder and refuses by name a source the device cannot take;uploadsCompressedis the question to ask first. The baker keeps a DDS as BC blocks in.drft1.24'sCODEC_BCpayload (--decode-ddskeeps PNG for an older reader), and@driftengine/assetsuploads them as blocks or decodes level 0 in a module worker — on the main thread where no worker can be had — so every device gets the image. A two-channel BC5 normal map keeps two channels and the lit stage rebuilds the third, which also repairs every BC5 map baked to PNG before this release with its blue at zero. The decoders are checked against an independent one: BC7 to the byte across every partition. - added
SkinnedClothin@driftengine/physicsis XPBD over particles that carry up to eight bone influences: distance, dihedral bending and tether constraints in graph-colour batches, a max distance, a backstop and a frontstop measured from where skinning puts each particle, spheres and tapered capsules on joints, inertia that lets a free particle keep a share of the character's motion, and a teleport that resets, settles and blends back in — whole fixed steps, drawn onalpha, withsetWindtaking the frame's sample of the scene's one wind.createSkinnedCloth(renderer, setup)solves it as WebGPU compute, its particles never leaving the device, and asSkinnedClothunder WebGL2, one schedule over both. A skinned mesh bound to it withcreateClothBindingandsetClothis placed by the cloth in its vertex stage — lit, shadow and DriftTR's motion alike — so a garment uploads nothing a frame. On an RX 9070 XT, 4,500 particles and 40,000 bound vertices take 0.65 ms a frame on the device against 20 ms on the CPU.examples/garment/builds a cape by hand; the handbook maps a cooked garment onto the two arrays. Self-collision is accepted in a set-up and not simulated. - changed
A dynamic mesh kept a CPU copy of its interleaved vertex buffer, patched the positions and normals into it and sent all of it, reported at 2.8 MB a frame for one character's garments. Positions and normals are buffers of their own now, an update is one write of each, and no copy is kept; last frame's positions, for DriftTR's motion, are copied on the device ahead of the write. The picture is identical to the pixel, plain and under DriftTR.
- fixed
The lit vertex stage handed the fragment its tangent in the mesh's own space and the tangent frame crossed it with the world-space normal, so any mesh with stored tangents that was not drawn unturned lit its normal map from a direction that turned with it. The tangent is now turned as the normal is: by the joints, the model or instance matrix, and a cloth. Measured on a mapped panel turned a quarter: 80.4 rms from the derived reference before, 14.8 now — what it is unturned — on both backends. Every published scene is unchanged on WebGPU.
- fixed
Feeding a lit program compiled mid-frame its pass state also reset the renderer's current material, so the draws after it — until the next
setMaterial— drew with no maps. The pass's material is reset where the pass begins now, and a program compiled mid-frame is handed the material that is current. - fixed
A texture the baker found by searching a bundle went through a path that knew PNG, JPEG and WEBP and nothing else, so a DDS there was refused as an image and baked white. Both paths share one now, and a DDS is kept as BC blocks or decoded like any other.
4.8.3 · 2026-10-04
- fixed
Occlusion is applied in the composite from the depth the opaque world leaves, after every translucent draw has landed, and a particle or a pane writes no depth, so a puff of smoke in front of a wall was darkened by the corner behind it: reported as walls and figures behind a cloud showing through it as dark outlines, and nothing at all with occlusion off. The scene's alpha now carries how much of each pixel is still the opaque surface. The frame clears it to 1, an opaque draw writes 1, and every blended draw states what it does to it: what covers (glass, smoke, water, lines, text, panels, sprites, splats) multiplies it by what it lets through, what transmits (the global medium, the order-independent composite) by its own transmittance, and what adds light or is a layer of the surface (caustics, light volumes, bolts, a wet film, decals, reflections) leaves it alone. The composite darkens that share and no more, and the temporal resolve and the reconstruction carry it through. Measured on both backends, plain, with temporal antialiasing and with DriftTR, against a darkened join: a pane at 0.85 opacity keeps 0.15 of the darkening behind it and a cluster of smoke none, while the open join and every frame with occlusion off are unchanged to the pixel. What it gives up is light added in front of an occluded corner, which is darkened with the surface it lands on as before, and the GPU-driven pipeline's own blended surfaces, which still take the occlusion behind them because its blit's alpha is the mask that says where it drew. A pass of your own states its blending with
SCENE_ALPHA_COVERS,SCENE_ALPHA_TRANSMITSorSCENE_ALPHA_KEEPSon WebGPU, andblendCovering,blendTransmittingorblendKeepingon WebGL2. - fixed
ParticleInstances.sizesand a pool'ssizeStartandsizeEndwere documented as metres across, and the quad spans twice the size, a corner at minus and plus one on each axis, so a caller who sized by the documentation drew every particle twice as wide as meant. The drawing is unchanged, because every effect already tuned against it would have halved; the type, the pool's options and the manual now say half-width. - fixed
A rig often puts one joint exactly on another, a collar on the chest joint or a helper on the hips, and a bone shorter than
minLengthgets no body. What hung below such a bone was jointed to nothing, because a bone was jointed to the bone ending at its parent joint and that joint had none: in a limp fall the spine above a collar flew 3.9 m off the hips. And it collided with every bone it met at that point, because the shared-end rule compared parent joints by index, so capsules out of one point separated whichever way rounding said and a reaction depended on which way the character faced. A short bone is now transparent: each joint resolves to the nearest one above it that a body ends at, for jointing and for collision alike, and where a short bone lies between, the joint is anchored where the two bones meet at rest, so a still doll does not move on its first tick. - fixed
writePoseturned a joint by its first bone below and placed every child by the rig's offset from that turn, so at a joint with several bones below it, hips over a spine and two thighs, a chest over a neck and two clavicles, every branch but the first was swung by the first one's rotation and drawn off its own body. Reported from a game as a foot 0.24 m and an arm 0.17 m from their bodies after a fall; a test fall measured a thigh 97.5 cm out. Each joint is now placed along its own bone as that bone's body holds it, at the rig's length and in the parent's frame, which for the first branch is the rig's offset exactly. Lengths never stretch. A joint with no bone of its own keeps the rig's offset. - added
Opaque draws could not opt out of the medium:
drawTranslucentMeshhadfog: falseand the shader a per-draw switch, and nothing reached it fordrawMesh. A ported game darkening its floor with distance had to darken its figures with it, or leave the far floor 18% too bright.setSurfaceFog(enabled)is a surface dial likesetSurfaceGrain: material state for the draws that follow, read by instanced and skinned draws too, and restored bybindMeshPass. A translucent draw's ownfogis measured against it and puts it back. Verified on both backends with two identical far pillars, one fogged and one not, and a pane between them asking for fog under the dial. - added
A fourth output transform.
'srgb'clips each channel at 1, so an overbright colour shifts hue on its way to white:(1.6, 0.4, 0.2)clips to(1, 0.4, 0.2), pinker and wider.'shoulder'leaves everything below 0.8 alone and eases the brightest channel toward 1, as0.8 + 0.2e / (e + 0.2)of an excesse, scaling the other two with it, so the same colour becomes(0.96, 0.24, 0.12). It is the curve a ported game's own resolve applies, and it has none of ACES's toe or desaturation. One definition shared by the forward passes and the resolve, measured on both backends with the composite on and off:(250, 134, 97)against sRGB's(255, 170, 124). - added
SurfaceMaterialhad a UV scale and no offset, so a flipbook could not choose its cell through the material and a game built one quad mesh per cell, 106 meshes for four strips.uOffsetandvOffsetare added after the scale,uv * scale + offset, for every map of the material, and they reach the cutout depth and shadow passes, so a card's shadow has the holes of the cell its colour shows. Both backends, mesh and instanced draws. - added
The transport could only go back to the top, so music held to a frame counter, which has to resume where that counter is after a skip, ran its own source into the music bus outside every stem's lock.
restart(fromSec)stops the stems and starts them all from that point at one instant, wrapped by each stem's length, and returns how long until it is heard.restart()is unchanged. - changed
Every slot had to carry a synthesised stand-in, which is right for a footstep and wrong for a score that has to be the file or nothing, so such a score was decoded outside the registry.
SoundSource.synthis optional now. A slot without one is required: when none of its candidates loads it is listed inunbuilt, andloadstill settles every other slot before rejecting with an error naming each missing required slot and the files it tried. A slot with a synth behaves as before. - fixed
WebSocketTransportandWebRtcTransportdiscarded any message that arrived as a string, without counting it indropped, on the ground that a payload through UTF-8 loses its high bytes. That is true of binary data sent as text and not of a peer that speaks text: a text frame is lossless UTF-8 by the protocol, and a client against a server sending text received nothing at all. Text now arrives as exactly the bytes the sender wrote, anything else unreadable is counted, and both transports share the one rule.
4.8.2 · 2026-10-03
- fixed
ecs.count(world, "Hunger")reaches a schedule that refuses a component the system did not declare, and the compiler never counted the name: the declaration the host demanded was called unused (DS0291), and leaving it out compiled clean and was refused once per tick. DriftScript 1.17.0 lets a capability say which parameter names a component and how, anddrift/ecsmarkshas,read,count,at,findNearest,queryandviewas reads andattach,detachandwriteas writes, so a string literal there counts exactly ase.Hungerdoes.withoutis not marked, as an exclusion never looks inside the component. driftscript 1.17.0, pinned in four manifests: the editor carries the pin too, which is now said where the others are. Reported from a game. - fixed
From 4.5.0 every lit pipeline compiled in the coloured-glass shadow lookups, and since then the measured-profile and cookie reads, the effects-table load, DriftLight's volume reads and the clustered arm of the light loop, whether a scene used any of them or not. Each sat behind a uniform branch, which keeps its code and its registers; the clustered arm also ran a view transform, two logarithms and a texel fetch on every lit pixel. On a desktop GPU none of this showed; on a phone the point-lit scenes fell to 10 to 20 fps while the sea and the voxel sandbox held, which is what pointed at the lit pass. On the phone that reported it, 4.8.2 runs them smoothly where 4.8.1 crawled. Each is now a pipeline constant the device compiles away, off until it is used and then rebuilt on, once: glass when a shadow pass is first offered a pane, fixtures when a profile or cookie is loaded, effects when a material carries a table, DriftLight when a volume is set; clustering follows
clusteredLights. On WebGPU the rebuilt set swaps in together when it has compiled, so no frame mixes the two; on WebGL2 it is a synchronous recompile at the nextbeginFrame. Measured with RADV's own statistics, the lit shader went from 10,134 instructions and 144 registers to 4,641 and 96 on WebGL2, and from 10,544 and 120 to 5,276 and 96 on WebGPU, below 4.4.2's 5,335 and 5,859. Every published scene is pixel-identical to 4.8.1 on both backends, and so are the pages that turn each feature on: glass under the sun, a lamp and a rectangle, a profile, an effects table, a DriftLight volume and clustered lighting. What it costs is the frames between a feature's first use and its rebuild, which draw without it. - fixed
With the graph on, a verb drawn after
endFramerecords rather than opening the overlay pass, and only an opening queued the microtask that submits it before the browser presents.fillPanelanddrawPassrecorded and queued nothing, so their work waited for the nextbeginFrame, whose replay drew onto a canvas texture already presented: "Destroyed texture used in a submit", and no interface. Recording after the present now queues the submission as opening it does. Reported from a game drawing its HUD over the finished frame, which had turned the graph off to get it back. - fixed
PassDevicesays what the frame is, the scene target at the scene's samples, and a pass drawn afterendFramelands on the canvas at one sample, so a pipeline built from the device was refused there. The WebGPUPassContextnow carriesformat,depthFormatandsamplesfor the pass the draw lands in, read from the same cache a mesh drawn there uses, and@driftengine/ui2d's sprite pass keeps a pipeline per target and chooses at the draw. A game had registered its HUD's sprite pass for the canvas by hand to get round it. Code that builds aPassContextitself, which is test code, now supplies the three. - fixed
writePoseturned joint j by the body standing on the bone that ends at j, and a skinned limb follows the joint it starts from: the forearm is weighted to the elbow. So a limb was drawn in line with the bone before it, 0.47 m from its body at the hands in a doll synced and never stepped, as reported from a game. Each joint is now turned by its first bone below, and a joint with none below it rides the bone it hangs off;drivesteers the same bone the write reads, andsynccarries a bone's twist about its own length, which a wrist with a thumb beside the fingers needs. Translations are each parent's own frame, where they were world offsets that only agreed with a rig unrotated at rest. A root's rotation is written in the frame of the node placed atrootX,rootYandrootZ. The chains the tests were built from were straight and unrotated, which is why none of this showed; an arm with real rotations now stands beside them. - fixed
The wanted velocity was clamped as a vector and then approached one world axis at a time, each by the whole step, so a diagonal from rest had 2.83 m/s three ticks in where a straight run had 2.0, and stopped √2 times as fast. It now moves toward the wanted velocity by at most the step in length, and lands on it exactly. What it gives up is that each axis no longer reaches its target on its own schedule: turning from one direction to another now curves rather than squaring the corner.
- added
DigitalAction.mouseButtonsand themousebinding device, saved and loaded with the rest, andInputSource.mouseDown,mousePressedandconsumeMousePressper button, with the keyboard's edge rules: a press is pressed over the source's target, released anywhere, and a lost focus or a cancelled pointer releases every button.MouseButtonisleft,middle,right,backorforward. A binding narrowed as keyboard-or-else-gamepad no longer compiles, since there is a third device; the input example and the gamepad page are corrected. A game readmousedownitself beside the actions it bound everything else through. - added
What is drawn between the two lands in the nearest share of the depth range, 1% by default (
VIEW_MODEL_DEPTH_SHARE): the viewport's depth bounds on WebGPU andgl.depthRangeon WebGL2, at whichever end the context's convention puts near. The world's depth is not cleared, so ambient occlusion, depth of field and fog still see the world behind the weapon, which clearing it inside an inset did not.endFramecloses a view model left open. Its pixels take no motion of their own under temporal reconstruction, so a view model is drawn with multisampling. A game had squeezed the depth through a camera of its own for want of this. - fixed
The drawn streams are compacted live particles and only
updaterebuilt them, so a burst emitted after the frame's update, a muzzle flash or a hit's sparks, appeared a frame late, and a pool updated only while it had something live never showed its first particle.emitnow writes the particle into both streams at once, into the entry of the particle it replaces where the ring is full, and onto the end otherwise. A game calledupdate(0)before every draw to see its own effects. - fixed
The overlay gives each panel a dark background and a title row
PANEL_TITLE_HEIGHTpixels tall, so the tools example no longer lays a backdrop under each site; colours with alpha reach the painter as#rrggbbaa, which its contract always allowed. The console copies its scroll to the node the overlay draws and takes the window from the height its panel is given, so it shows the newest lines, a wheel moves them and a click picks the row under the pointer; the overlay takes a wheel over a panel whether or not the panel made a command of it, so the page no longer scrolls underneath. Ctrl+Shift+Z redoes with the capital a browser reports for it. Anf32field reads as the shortest decimal that stores as the same float,0.55rather than0.550000011920929. ALockstepSessionis handed toobserveSessionandsessionReadoutas it is, through a publicrewindDepth, and the package README edits throughsetFieldCommandwhere its example did not compile. - changed
A store fills every field its schema declares and never looked at the other keys, so a misspelt field was dropped and the field it meant took its default; one consumer's had been dropped since the component was written. An add now refuses one by name and lists the fields there are, before anything is stored, as
writeand a component's declared defaults already did, anddefinePrefabrefuses one when the prefab is defined rather than at its first spawn. A scene read back is migrated onto the current schema first, so a renamed field in a save still loads. - fixed
UiNode.layerwas sorted bylayerOrderand read by nothing that draws or points: a dropdown raised over the panel its control sits in was drawn under a later sibling and the pointer reached the sibling through it.drawUiTreenow draws layer by layer, lowest first and tree order within one, each with the clips it would have had, anduiHitTestsearches from the highest layer down. A tree with no raised node is drawn and searched in one walk, as before. - fixed
AudioGraph.captureStreamanswered a live track from a suspended context, which never carries a sample, and a recorder given one stalled on it: a recording started from a script click kept 19 frames of 120 and no sound. It answersnulluntil the context runs, and the tap after. - fixed
The WebGPU renderer's comment said it returned the inset's height in device pixels, which both backends stopped doing; and with no pass to draw into it returned 0, a projection of infinities in the caller's camera. It answers the aspect of the box asked for there.
- added
Every map on such a mesh reads one texel, so an emissive map made nothing glow and the frame was identical with and without it. The first such draw of each mesh says so, on both backends. A one-colour ORM map is not counted: read at one texel it is the constant roughness and metalness it was bound for.
- fixed
Its parameter was a type whose one member is optional, which TypeScript treats as weak, and the DOM types do not declare
makeXRCompatibleonWebGL2RenderingContext, so the context every game holds did not compile until it was cast. It takes any object and asks for the method.XrRuntime, whatHostServices.xrtakes, is exported from@driftengine/script.
4.8.1 · 2026-10-03
- fixed
Two defects in the narrow phase, which together let a standing 1.8 m capsule walk through a beam from 1.4 to 1.7 m. A capsule against a box was measured from the two balls at the ends of its segment only, so a slab across its middle, which neither ball reaches, was not there at all; the closest point inside the segment is now found wherever the ends are not the answer, which costs nothing for a capsule standing on a floor or lying on a face. And
shapecastadvanced by the first contact's separation, which for a capsule is its lower end, so a box that only the upper end would meet was never reached. It now advances by the nearest contact.overlapand the solver's contacts gain the interior point too, so a capsule body lying across a rail rests on it. - fixed
The forward part of a step is what is left of the tick's move when the body meets the step, and that can be millimetres. With a 0.32 m radius and a 0.35 m step height, a 0.25 m rise climbed and a 0.28 m one did not: the foot came down on the step's edge, a contact too steep to stand on, slid back off, and never tried again. A step that lands on an edge now carries the body over it, so the foot comes down on the top, which moves it up to one radius further in that tick than it was going to.
shapecastalso closes on a surface it approaches at a shallow angle, where its fixed number of advances ran out first and it reported nothing there: it now advances by how fast the two close along the normal that separates them, not by the whole travel.
4.8.0 · 2026-10-03
- changed
Twelve enums are declared by the engine for DriftScript 1.15.0's host enums:
TreeStatus,MatterPhase,ChemistryEvent,ContactKind,SaveStatus,GizmoMode,GizmoSpace,GizmoHandle,EditorMode,FieldKind,HandandHandJoint.behavior.tick,stepandstatus,chemistry.phaseandeventKind,physics.contactKind,persistence.saveStatusand the editor'sgizmoMode,space,hovered,modeandfieldKindanswer a variant where they answered a number or a string, anddrift/xrtakes aHandand aHandJointwhere it took"right"and"index-finger-tip". A script that compared one of these against a number or a string no longer compiles, which is the point: those comparisons were checked by nothing, and a misspelt joint read zero. Each variant list is tested against the engine constants it translates.contactKind,eventKindandfieldKindrefuse an index past what exists, where they answered a stale entry or nothing. - fixed
driftscriptmoves to 1.16.0. A loop read only an optional field's value column and an assignment put the option object into it, which storedNaNmarked present; through a handle the read answered a bare number. The compiler now reads and writes the presence column beside the value, anddrift/ecs'sreadanswersundefinedfor an absent optional field where it answered zero, which the compiler turns intonone. The entities example's frogs keep their target as anEntity?, where they kept a flag beside a plainEntitybecause of this. - fixed
No peer can publish an input for the ticks before its first tick plus the input delay, so every peer guessed them and nothing ever corrected the guess. A guess repeats the newest input the log holds, and for a peer's own participant that was the input it had just submitted for a later tick, which the other peer cannot know. So a key held when the match began split the two worlds, and the first fingerprint halted the session.
LockstepSessionsettles those ticks as neutral for every participant on its firstsubmit. - fixed
SocketLike.sendandDataChannelLike.sendtake a view of anArrayBuffer, which is what a browser'sWebSocketandRTCDataChannelaccept, and both transports copy a message on shared memory off it before sending, since a browser refuses to send one. - added
runSchedule(world, schedule, tick, report). Without a reporter a failing system is still skipped and logged once per system per world, as before. With one, nothing is logged and the reporter decides: count failures, show them in an overlay, send them to telemetry, or throw to make a failure fatal while developing, which stops the schedule at that system. - changed
From DriftScript 1.16.0. Access was inferred from a module's own functions, so a helper imported from another file touched nothing as far as a system was concerned: a declaration of what it wrote drew
DS0291, and leaving it out drew noDS0288and left the write out of the metadata the engine builds its schedule from. And from 1.15.0,==compares what two values hold, so an enum variant still equals itself after a hot reload; it compared objects by identity, and a rule writtenif round.phase != Phase.Playingreturned forever after the first save. - added
DriftScript in a game says what belongs in a script and what stays in TypeScript, Setting up scripts covers the build, binding, services and hot reload, Patterns names the shapes the examples share, What a script can reach lists every module, function and enum from the capability file, and Determinism, testing and shipping covers
@deterministic, testing a script in Node and what reaches the bundle. The starter and the first game keep their rules in.drsfiles, the starter carries the Vite config and declaration a copied project needs and builds without a warning, and the first game's round is tested in Node. Networking and rollback is built on the new netplay example: two peers in one page over an impaired loopback, with the link, the redundancy and the input delay as live switches.
4.7.4 · 2026-10-02
- fixed
driftscriptmoves to 1.14.0, whose compiler now carries the value each component field was declared with (seed: u32 = 7). Before, the value was parsed and dropped, so every entity started at zero and nothing said so. A component type hasdefaults, anddefineComponent(schema, defaults)refuses one that names no field or does not fit its column. The store fills an omitted field from them, soworld.addwith some of the fields, a prefab that leaves one out and a script'secs.instantiateall start at the declared values; anullgiven for an optional field is still its absence.deserializeWorldgives a field the saved scene does not have its declared value, so a save loads across an added field with what the component says. DriftScript 1.14.0 also type-checks a component's defaults, which nothing did before, and refuses one that is computed (DS0275). - added
XR covers asking a browser what it can present, entering a session, and reading the head, the controllers and a tracked hand, from TypeScript and from
drift/xr, through the simulated runtime the package tests against. It says plainly that the renderer cannot draw into a headset yet, and the xr README's sample, which calledrenderer.setViewportandrenderer.draw, now stops where the renderer does. Saves and preferences covers the synchronous store and why it is synchronous, preferences checked field by field,RemoteSaveStoreover a backend that answers later,serializeWorldanddeserializeWorldwith a save read back across an added field, anddrift/persistence, around a garden bed that saves to a server the page can make slow or take offline. - fixed
The vehicle spawned overlapping a cone and came to rest on it with two wheels in the air; it starts on the ramp's line. A readout is sized to the frame it is in, so the manual's 768 by 432 frames no longer cut lines off, and it can keep clear of a panel. Chemistry stepped a quarter second sixty times a second and drew 3 fps once smoke filled the air; it steps a sixteenth, about four times the clock, at 60 fps, and
stage.runtakes loop options so a slow frame catches up two ticks at most. DriftLight baked 64 bricks a frame and ran at 1 to 8 fps for its first ten seconds on a CPU three times slower; it bakes for four milliseconds a frame and stays above 18. Under that throttling 44 of the 46 examples hold 57 to 60 fps, and chemistry 16. - changed
Unused imports, constants, functions, variables and parameters are gone, products by zero are written as the terms that remain, and optional chains in tests stay optional to the end. Inside the native host, the window, the document and the canvas take their
on<type>attributes from a base class that declares them, in place of an interface merged into each class, and fdlibm's constants are spelled with the digits a double holds, each parsing to the same value.
4.7.3 · 2026-10-02
- fixed
The lit pass decides a clustered light is out of reach on the first texel of its record, before reading the rest, and skips the photometric lookup when no profile is loaded. A plain point light's emitter size and weight share one word as two halves, so it is read in two texels of five; a light with a shadow slot, a cone, a profile or a cookie reads the other three, and only in a frame that has one. The uniform arm rounds size and weight the same way, so the two arms still draw one picture, and scenes of plain lights are byte-identical to 4.7.2 on both backends. At 3840 by 2160 with 320 lamps the main pass went from 17.5 to 12.0 ms at radius 8, from 26.2 to 17.2 at radius 12 and from 34.8 to 25.1 at radius 24, on WebGPU; building the clusters was 0.15 ms of the first.
demo/dev/clusterStress.htmlis the workload andscripts/cluster-stress.mjstimes every pass of it. - fixed
Both renderers built the clustered light set from five arrays and left out the direction, the cone, the profile, its axis and the cookie, so the record and the shader were right and the fixture never arrived: every spot lit the scene as a bare bulb. On a page holding one spot with a profile and a cookie the two arms differed by 357,706 pixels, and now differ by none on either backend.
fillClusterLightSetis the one assembly both use. A light given no emitter size now has none on both arms, where the clustered arm read the light's radius in its place and widened every highlight it laid on a specular surface.
4.7.2 · 2026-10-02
- fixed
driftscriptmoves to 1.13.1. Its Vite plugin named each.drsmodule by the absolute path the bundler handed it, and the compiler writes that name into the module it emits and into every field id a hot patch matches on, so a shipped bundle carried the directory of the machine that built it, and two checkouts built different output from the same source. A module is now named by its path from the project root, which is the same on every machine, and a hot edit still patches the running module in place. A game changes nothing; a message that prints a module's name prints the shorter one.
4.7.1 · 2026-10-02
- added
The manual lives in
docs/manual/, one Markdown file a chapter andmanual.jsonfor their order: Getting started, from installation to a complete 3D game and a 2D one; Concepts; Rendering; Worlds; Simulation; Content, for models, the.drftcontainer, sound and the command-line tools; and Interface, for input, cameras, sprites and menus. Every code block is a region of a file underexamples/, which the typecheck compiles andnpm run manual:synccopies in, so a sample cannot drift from code that runs. Forty-six examples run on WebGPU and WebGL2, their switches change the running scene, and where a scene has behaviour it lives in a.drsfile that reloads in place.docs/manual/uncovered.jsonlists what has no chapter yet and can only shrink, andundocumented.jsonthe exports with no comment, which can only shrink too.@driftengine/core's README is a package page: what the engine is, a quickstart a test keeps equal toexamples/starter, its systems linked to their chapters, and the other packages. - added
drift/audioturns the mix by name.duck,fadeandrecallmove a bus or a snapshot the host made, and answerfalsefor a name nothing has;pulsereads the kick detector the host runs,0without one.MixConsole.recallreturns whether the snapshot existed.drift/inputreads a touch screen through aTouchhandle besideActions:touchX,touchY,touchHeld,touchTapandtouchSlide, so a game whose controls are a script can be played on a phone.drift/chemistrygainswettable, so rain poured over every parcel passes over a nail instead of stopping at it. - fixed
voxeliseWalkablesees walls. It sampled surfaces at cell centres, which never meet a vertical face, so a building on the ground read as a roof over open floor and a route went through it; a face too steep to stand on is now a solid in every cell it crosses, and erosion judges a neighbour at the span's own height. Regions meet edge to edge: each outline was simplified on its own, so a shared border came out as two lines that overlap nowhere and a portal vanished; a room with a pillar at the deviation the README recommends had no route between opposite corners. Border points are held across regions and each stretch is simplified once.VoxeliseSettings.maxSteptakes the climbbuildRegionstakes. - fixed
A picture keeps its top up in a 2D world. Every sprite and tile drawn through
worldToNdccame out upside down, because the frame's top row was read at the sprite's corner, which in a world is its bottom; the vertex stage reads the affine's orientation now, andscripts/sprite-check.mjsdraws the same rectangles in both spaces and asserts the frames match byte for byte.clipis drawn and hit tested:drawUiTreecuts every quad under a clipping node to what it leaves, an image's frame in proportion, and gives the content sink the visible rectangle;uiHitTestfinds nothing under a clipping node outside its box. A scrolled list's rows drew wherever they had scrolled to, and took clicks there. - fixed
drift/prefabcould not be called, since its one function is namedspawn, a keyword; a script makes an entity from a prefab withecs.instantiatenow, and the module is refused by name.ecs.count,ecs.atandecs.alivework inside a system, where they threw and the schedule skipped the system every tick. Splats are occluded by the geometry in front of them on both backends: WebGPU compared depth one way under reversed depth, and a contributed pass on WebGL2 was handed the identity in place of the depth remap. A ragdoll built from a rig whose root has no bone holds together: bones hanging from that root were jointed to nothing, so a humanoid fell as a torso and two loose legs. Pale smoke is drawn, because droplets now count toward how thick smoke is.
4.7.0 · 2026-10-01
- added
A
.blendfile is read directly, with nothing installed.readModelandnpm run baketake Blender's own format from 2.79 to 5.x, plain, gzip or zstd, because the reader learns every struct from the file'sDNA1catalogue rather than from a list of Blender releases. It reads meshes in all three layouts Blender has stored them in (the legacy structs, 3.5's named attributes and 5.0's attribute storage), their normals by Blender's own rule — sharp edges and faces, auto smooth, custom split normals — with UVs, colour attributes and shape keys; Principled materials wired the way the glTF importer wires them, with their packed pictures; point, spot and sun lights, blackbody colour included; cameras; parents, collection instances and render visibility; and keyed object animation, layered actions and NLA strips included. It builds the same glTF the rest of the pipeline reads, so a.blendand a.glbreach the baker as the same thing, and the fixtures inscripts/fixtures/blend/hold it to what Blender's own exporter writes for the same file. What only Blender can evaluate is refused by name — a rig, a constraint, a modifier past the smoothing ones — asBlendNeedsBlender, never as half a scene; the baker then drives the user's own Blender through its bundled glTF exporter,--via-blenderasks for that outright and--directrefuses it, and every import says which route it took. - added
How many material changes a WebGPU frame holds is a quality option,
materialChangesPerFrame. A frame spends a slot of a uniform ring each time it changes material and skips every draw past the last slot, which is geometry missing rather than geometry drawn wrong — and 1,024 was a constant. A bought city of eleven hundred materials spends about nine hundred on a street, and a planar mirror of that street spends as many again, so its reflection came and went as the camera turned. The default stays 1,024; a scene that measures past it asks for more at construction and pays seven to thirteen kilobytes a slot, andframeBudget's materials line reports the ceiling it got. WebGL2 keeps no ring and reads the option without effect. - fixed
@driftengine/core/scripts/png.mjsreads every non-interlaced PNG. It refused palettes (colour type 3), greys below eight bits and sixteen-bit images, and the baker and the native host read model pictures through it: measured on one bought city, 219 of its 2,447 pictures, every material wearing one baked with no colour map and drew white. Rows are now un-filtered as packed bytes and then widened to eight-bit samples — a palette to RGB, or RGBA where it carriestRNS; a grey or RGBtRNSkey to an alpha of nought at that value; sixteen bits to the high byte; a grey below eight bits scaled to the full byte. An eight-bit image with no key still takes the path that copies nothing, so a screenshot decodes as it did. Interlacing is still refused by name. - fixed
An untextured glTF metal that states no
metallicFactormirrors. The specification's default for the factor is 1, and the reader took the material's reflectivity from the factor alone with a default of 0, so every such material — an exporter writes none when the value is the default — drew as a matte surface. The reflectivity of a material with no base colour texture now follows its metallic value, defaulted as the specification defaults it; a textured or vertex-coloured one is unchanged.
4.6.1 · 2026-10-01
- fixed
A mesh with no triangles draws nothing, in every pass, on both backends. A game's world can hold an empty mesh, for a level with none of that kind of geometry, and every pass it reached issued a draw of zero indices: WebGPU answers each with a warning, five on one game's first screen (the frame, its reflection, the sun's static and peeled maps and a lamp's shadow faces), and WebGL2 counted draws that drew nothing.
GpuMesh.indexCountandMesh.indexCountcarry the rule, and every place a mesh enters a pass skips an empty one beside its other checks: meshes, translucent meshes, instanced batches, light volumes, shadow casters, glass depth and the motion pass. A game that already skipped its empty meshes itself can keep doing so.
4.6.0 · 2026-10-01
- added
Container 1.23 carries a world built from a kit. An assembly is painted copies of kit pieces rather than its own vertices, a region (
REGN) names the assemblies, instance groups, occluders and collision of one square of the world, and a world's finest level is paged:writeDrftwrites the regions nearest a starting point first, andtexturesFirstputs the pictures ahead of the geometry so every region can wear them as it lands.buildKit,buildAssembly,expandAssemblyandplanRegionsare the format's halves, and a mesh's sway, sky and opacity channel now survives a bake.DrftLoaderhands a streamed world over a region at a time, never merged into its parts, asLoadedRegions with their levels, collision, navigation and entities;onMeshtakes the meshes no region holds andonImageeach picture as it decodes.StaticRegionscollides a scene's scenery a region at a time.HlodSetpicks each region's level of detail by its geometric error on screen, with a band no still eye can cross, andsetDitherFadecrossfades two levels with a screen-door dither on both backends.Measured on a two-kilometre city of 484 regions and 18.8 million triangles at its finest: the 14 regions within 150 m of the start are whole after 13 MB of a 130 MB file.
- added
createInstanced(mesh, capacity, { cull: true })tests the whole batch against the view and the declared box occluders (occludedBox) on the CPU, then keeps each instance only where its sphere meets the view: as compute into an indirect draw on WebGPU, on the CPU on WebGL2. A scene may hold as many batches of one mesh as it has regions.A batch too small to repay that is drawn whole (
cullsInstances, 16,384 indices). Chrome's GPU process validates each indirect draw at about 8 µs, so a per-instance cull of a few lamps cost more than drawing them: on a city street, 633 indirect draws a frame became 108 and the frame went from 19.5 to 10.8 ms, pixel-identical. Shadow, motion and blended draws still see every instance. - added
createSurfaceTextureArray(sources, { effects }): every surface texture is an array, and a mesh names which layer each face wears, so one merged mesh wearing many images is one draw. Each layer may carry aSurfaceLayerEffect: rooms behind its windows (interior mapping from a room layer in the same array), windows lit by night (Environment.litWindows, withlateWindowsthe share that stays lit latest, and the light itself carried per building on the vertices so a style shared by forty facades still lights each its own way), wear (dust, grime, streaks), animation (scroll, pulse, flicker, flipbook frames and a fade with distance, on the caller'ssurfaceTimeso a held clock holds it) and rain (Environment.wetness, withdryfor a covered surface).The effects are a texel row per layer (
packSurfaceEffects,SURFACE_EFFECT_TEXELS), the same on both backends. - added
bakeDenseFieldsums a whole world's fixed lights offline into one dense volume, sample for sample what the bricks hold, carried in the container asLVOLin runs of dark, solid and lit samples.WorldLightField(createWorldLightField) takes it whole and lights every surface past the exact lights on both backends, so a street three blocks away is lit by its own lamps without spending a shadow or a slot on them.createLightGridandselectGridLightsfind the frame's exact lights through a grid, choosing exactly what the full scan chose field for field, so a city of seven thousand lamps picks its nearest without visiting them all. A tie for a shadow map now goes to the lamp listed first rather than to the way its distance rounded. - added
Solids in the barrel:
solidBox,solidCylinder,solidSphere,solidTorus,solidLathe,solidExtrude,solidSweepand the rest, each closed and wound outward, withtransformSolidkeeping normals true under any scale,mergeSolids,smoothSolidNormalsandsolidToMesh.Booleans:
solidUnion,solidSubtractandsolidIntersectthrough a BSP that splits only what the other operand reaches, andsolidBoxBooleanexact on axis-aligned boxes, which is most of what a building is cut from. - added
TranslucentMeshOptions.additive: the surface's colour times its opacity is added and nothing is darkened, as a glow or a pool of light under a lamp is. Order does not matter to a sum, so an additive draw is never held for the order-independent pass. Added light fades in the medium (uFogEnabledat 2): mixed toward the fog's colour and then added, a glow put the haze into the frame twice, and every lamp's light cone drew by day as a grey solid, greyer the thicker the air.TranslucentMeshOptions.reconstructed: a translucent surface that moves with what it lies on, such as a glow on a wall or a pane in a window, is drawn into the picture the reconstruction resolves rather than after it, so it is resolved with the edge it lies against. A street's glows went from 34,757 pixels changing between still frames to 2,999 (2,470 with the reconstruction off). - fixed
DriftTR keeps the temporal resolve's anti-flicker, over its own period. Each output pixel keeps a record of its gathered luma, and once two periods running repeat while still its box widens by its own spread and it keeps more history; the width follows the period (
flickerRule), since eighteen phases wear a spread down further between catches than eight. A still pixel whose depth has been seen to swing is not a disocclusion: a held peak of how far last frame's depth missed widens its depth tolerance, so a rail finer than a texel no longer drops its history every period. The camera's motion goes through one reprojection matrix multiplied in double precision: the single-precision round trip through a world position read a few hundredths of a pixel of motion on a still camera, crept every still history and kept every pixel from counting as still. A still city view: 1,156 pixels changing between frames to 355 (184 native).Glows and panes after the upscale are tested against each render texel's surface carried to the pixel's centre, not the nearest of four, which let a glow along an edge through on one frame in two. A reconstruction blooms the resolved picture, not the jittered render, whose sub-texel bright points pulsed their halos. The composite reads ambient occlusion where the frame's jitter put it, under the temporal resolve and the reconstruction alike. An interface drawn after the present is not jittered and opens no temporal frame: every consumer drawing its HUD after
endFramehad half the temporal resolve's jitter sequence and no anti-flicker at all. - changed
Each shared item of the generated WGSL is stored once and renumbered per function, where every permutation carried its own copy: the lit-shader helpers cost once, and core's WebGPU shader source is 185 KB smaller. Nothing about what a device compiles changes.
- fixed
- The pixel font's strokes are one cell wide wherever the line is drawn, and its brackets are whole; text under a perspective tilt divides about each cell's centre.
- An inset opened after the present on WebGPU draws, as it did on WebGL2.
- A material setter reaches every WebGL2 flat program, so an instanced batch wears what its caller set rather than the defaults.
- A material with no maps leaves an array stand-in on every WebGL2 surface unit, and sprites borrow the last unit of the pool, not one the lit pass binds.
- A character stands on a triangle mesh's kerb and walks on, and a round body near a mesh edge reports its real gap.
- A raindrop passing the eye is not drawn (
RainFieldOptions.clearM, a metre by default): a streak is a world-space width, so a drop 30 cm from the camera drew fourteen pixels thick and as long as the frame. It keeps falling, as a sheltered drop does; pass 0 to draw drops in macro.
4.5.0 · 2026-09-29
- added
A reconstruction can follow what moves, when the draw says what it is. DriftTR reprojects each pixel by where its surface was last frame, and it knew that only for a draw handed last frame's model matrix. A consumer that passed none, which was every consumer measured, got the camera's motion for everything: right for the world, wrong for anything that moved, so a mover trailed a ghost and a doubled edge.
createMover()makes an identity for one moving object, anddrawMeshtakes it where it took last frame's matrix. The renderer keeps that object's last model and, for a skinned draw, its last joint palette, so a bending limb reprojects as well as a sliding body. A matrix works exactly as it did. A mover drawn twice in one frame is two objects wearing one identity: the second draw is warned about once and records no motion.Measured on
demo/dev/ghost.htmlagainst a native four-sample frame, trail pixels without and with a mover: a slide 6,602 → 1,150, a spin 8,599 → 3,012, a skinned limb 11,648 → 1,518. A cut is still the consumer's to say: callrenderer.cameraCut()where the view jumps, or the first frames after it blend the old view in. Only a reconstruction reads any of this, and reconstruction is WebGPU's. - added
TranslucentMeshOptions.glass:{ transmission, frost, tint }. A pane shows what is behind it by how much it lets through less what Schlick's Fresnel reflects at the view angle, keeps its own lighting and highlight, blurs what it shows by reading the level of the frame copy's mip chain its frost picks, and glows with every light behind it (the sun, lamps, rectangles and DriftLight) in proportion to its frost. It is two-sided and symmetric: seen from behind it passes and glows with the light beyond it as it does from in front. A glass caster writes no depth, so a lantern's own light leaves the lantern.A model says so three ways.
.drft1.19 carries the three fields; the glTF reader mapsKHR_materials_transmission,KHR_materials_diffuse_transmissionandKHR_materials_volume, rough glass arriving as frosted glass; andDrftLoaderOptions.surfacecan declare a file's opaque pane glass, which lands onDrftPart.glass. Both backends agree to a level on a bought courtyard's lantern at night, its panes from 95 to 246. - added
Coloured shadows, for every light that casts one, on both backends. Light through a pane takes
transmission × (1 − F) × tint: the sun's static and moving maps, pooled and live lamps, and rectangles. Each map gains a glass depth layer, the nearest pane, and an RGBA8 tint, every pane on the ray multiplied together with its clarity. A receiver is tinted only behind a pane; its outline comes from depth-tested taps and its colour from taps spread by frost and read at the tint's matching mip level, unmixed from the ground around the pane, so a frosted patch is softer and passes the light a clear one does. Measured ondemo/dev/glassShadow.html, both backends within 0.4 levels: clear sun patches 149/56 against 149.1/55.6 derived by hand, a lamp's 47.2/30.0 against 47.1/29.8; frost moves the light it passes by 0.1 to 1.4%.RenderQualityOptions.glassShadowsis'full','half'(the tints at half size) or'off'. Nothing is allocated until a glass caster reaches a map; then, at'full', 78 MB for the sun's two maps and 9.8 MB a lamp layer, roughly halved at'half'.'off'casts nothing through glass and compiles the lookup out of the lit shader, which a world that never casts through glass should say: a lookup that finds no glass still costs the lit pass its registers, 0.27 ms on WebGPU and 0.42 ms on WebGL2 at 720p on a bought courtyard at night. Where glass does cast, that courtyard's lit pass costs 2.3 ms more on either backend, its lamps being enclosed in frosted glass.What it gives up: a pane is thin, so a solid of glass under-darkens and bends light once; DriftLight's summed far field and the probes' bounce are not tinted; frost spreads colour only inside the opaque frame's shadow; and a second copy of a pane's triangles is a second pane, so two-sided glass is declared with
doubleSided. The core bundle grows 91.6 KB gzipped, nearly all of it the lit shaders' generated WGSL. - added
GpuDrivenMaterial.glasstakes the same{ transmission, frost, tint }. A glass material is drawn in the renderer's blended half with the forward renderer's see-through, frost, Fresnel and sun glow, reading a mip chain of the opaque colour, andGpuDrivenShadowOptions.glassgives its sun a third light-space cut for glass, drawn into a glass layer and a tint as the forward sun's is. Its arithmetic is checked on the device against the TypeScript reference byscripts/gpu-parity.mjs, and its clear patches measure the forward renderer's 149/56. Its glass shadow ignores cutouts, as its opaque shadow does. - changed
While reconstructing, every blended draw lands after the upscale, at output resolution. A translucent pane, a caption, a spark or a plume has no one depth a reprojection can trust, so drawn into the reconstructed picture it trailed and softened with whatever was behind it. Blended meshes, particles, plumes, bolts, lines, film, caustics and world text now draw in a late pass on the output-size frame, unjittered and depth-tested against the upscaled depth; refraction and order-independent panes read the reconstructed picture and land in it. On the ghost page a moving translucent pane's trail went from 5,751 pixels to 20, and particles' from 2,464 to 0.
Light volumes stay reconstructed: their march reads render-size depth and is among the most expensive passes in a frame, so they keep the render size. Without a reconstruction nothing changes, and every published scene draws identically, to the pixel on WebGPU.
- fixed
Two kinds of motion had no way to say where they had been. An instanced batch carried one placement per instance and no previous one, and a mesh rewritten with
updateMeshmoved its vertices under a draw that looked still, so both were reprojected by the camera's motion alone and ghosted under reconstruction whatever the consumer passed. Each now keeps last frame's state beside this frame's, slot for slot and vertex for vertex, and needs nothing from the caller: the batch and the mesh are their own identity. An instanced crowd's trail on the ghost page went from 8,961 pixels to 1,340. - fixed
Three WebGL2 defects, each reached first by glass. The copy of the frame that refraction reads rebound the scene's framebuffer on its way out rather than the caller's, so a pane drawn into a reflection probe's face attached the probe's next face to the scene and the whole frame went flat grey. WebGL2 also took that copy inside a probe bake at all, which WebGPU has always refused: the copy is of the frame on screen, not of the face. And an instanced batch never turned culling off for a double-sided material, so its far faces were missing on WebGL2 alone; 28 of one bought courtyard's 53 double-sided materials are instanced.
- note
DrftPart.glassis required,nullfor a part that is not glass, so a consumer that constructs aDrftPartitself, a test stub for instance, addsglass: nullor no longer typechecks. Parts the loader makes carry it already.And a reconstruction is still softer than native on geometry that never moves. A mover fixes motion, not that: on the ghost page a still edge at a reconstruction of 1.5 measures about 8 in edge error against a native frame's 3, which is the reconstruction's own resolution rather than anything a consumer passes.
4.4.2 · 2026-09-28
- fixed
The disocclusion test compared two different surfaces at every edge. The reconstruction reprojects each output pixel by the nearest of its nine samples, the usual dilation, and then asked whether last frame held the same surface by reading last frame's depth under the output pixel rather than where the dilated sample had stood. At a silhouette those are the object and what is behind it, so the far side of every edge, every surface turned away from the eye and every part thinner than a sample dropped its history on a still frame: a paused picture flashed white along its outlines and read softer than a single frame of it.
The test now searches last frame's three by three around where the dilated sample stood, allows for how steeply the surface recedes, and weights the normal test by how planar the surface really is, so a thin rod no longer fails it. Measured on a consumer reconstructing at 1.5: a paused frame went from 3,269 flickering pixels to 338, and a still panel from up to 1,787 to 7, with no added ghosting in motion. The cost is 0.065 ms of GPU time at 2280 by 1291.
- fixed
A still pixel's neighbourhood now widens by its own proven flicker. A thread finer than a pixel is caught by the jitter in some phases and missed in others, and in a phase that misses it the nine taps round the pixel miss it too: the box collapses onto the background, the settled history is clipped away, and a paused frame flashed at every fine edge. A still camera over a still scene samples every pixel the same way in every period of the jitter, so each pixel now keeps a small record of its own samples, and once two periods running have repeated, its box widens by the spread it has shown and it keeps more of its history. A surface that moves, a flame, or anything arriving breaks the repetition, and a sample beyond what the pixel has shown disqualifies the frame it lands in.
Measured on a scene of fine ironwork and fabric: a held frame went from 839 flickering pixels to 3 on WebGPU and from 1,802 to 14 on WebGL2, and the device check's edge figures are unchanged. It costs two half-float record targets beside the history. A moving camera is resolved exactly as before, and so is WebGL2 without float colour targets.
- fixed
A re-bake spread across frames was sampled half done. Faces land in the layer the shader reads as they are drawn, and the origin they were drawn from is published only with the last one, so a map re-baked two faces a frame was read from its old origin with part of it drawn from the new one. A cube face is a 90 degree frustum, so the wrong part was a straight-edged quadrilateral of shadow on the ground under the lamp, and with the face budget spent elsewhere it could hold for over a second.
A map that already holds an image is now re-baked whole or not at all, and a light being shaded may take one whole cube a frame outside
pointShadowFacesPerFrame, the allowance a wandering light already had. A map with no image yet still fills a few faces a frame, since nothing samples it until it is complete. What it costs is six faces in the frame a shaded light goes stale, rather than two in each of three. - fixed
The near plane moved with the flame, and the fixture under it did not.
createFlameputs the light a third of the way up the flame, 0.83 of its width above what it burns from, with a near plane of one width, and sways it by 0.15 of a width on each axis. At the top of the sway the cut sat 7 mm under a 40 cm brazier's coals and at the bottom 13 cm, so the bowl beneath went in and out of the shadow map depending on where the flame stood when the map was last drawn, and the floor under the brazier took a dark disc on some re-bakes and none on others.The default
shadowNearnow adds the sway, so it clears the same margin under the fire wherever the flame has wandered. A fixture reaching further below the flame than a sixth of its width sets its ownshadowNearfrom its own depth.
4.4.1 · 2026-09-28
- fixed
The blur refused every neighbour of a surface seen edge on, because it compared each tap's depth with the centre's: a floor forty metres out changes its depth by four percent a row, twice the tolerance, so the pass down the frame kept the centre alone and the estimate's rotation pattern stayed on screen as four-pixel stripes across every ramp and far floor. A tap is now compared with the depth this pixel's own plane predicts there, which is exact because 1/z is affine across a plane, so it is refused only for leaving the surface. Measured on a game's ramp at night, the row-to-row ripple fell from 0.028 to 0.0017, and silhouettes are unchanged.
And the estimate is stored at half scale. On a plane turned from the eye one of a pixel's two lines sees more than the whole sky and the other less, and only the average over every turn is exactly one; an eight-bit target clipped each turn above one before the blur could average it, so an open plane seen at 80° came back 3.6% shaded. Scenes with occlusion on change where they have surfaces seen edge on.
- fixed
updatePointShadowspublished each shadow at the wrong place after a light that declines to cast. The shader reads a light's shadow at the light's own place in the shaded list; the lights that cast were gathered into a shorter list first, and each shadow was published at its place in that one. So a light withcastsShadow: falsemoved every casting light after it one place down: the first lamp's shadow landed on the declining light and each lamp after it wore the next one's. A light riding a camera or a car is exactly the light that declines, and it is nearly always shaded first. The place is now kept, and left empty.A consumer that worked around this by handing over only the lights before the first one that declines can hand over the whole count. That cap is what switched lamps' shadows on and off as a car moved, because which lamps rank ahead of its headlights changes with every metre.
- fixed
@driftengine/packagehad shipped the packager's local Gradle caches and a build report since at least 4.2.0. Itsfilesnamesandroidwhole, and the ignore rules for that directory's build output live in the repository's.gitignore, which npm does not read for a package. Nineteen files, none of them the engine's and none carrying a path or a name from the machine that built them, checked; they are excluded now, with the rest of what an Android build writes.And 4.4.0 shipped two modules whose sources had been deleted,
core'srender/shaders/agx.jsandtexture'stensor/reuse.js, because the build wrote intodistand never emptied it. It empties it first now, andnpm run cleanroomrefuses a tarball holding any file that is neither tracked nor built from a source that exists.
4.4.0 · 2026-09-28
- added
A bought 4K courtyard of five packs went from 1.67 GB to 355 MB, and from 702 MB to 231 MB gzipped, with nothing on screen changed that a person could point at. The container gains two required chunks and the baker five flags.
MSHQwrites a mesh in the bytes its data needs, in place of itsMESH: positions and UVs in sixteen bits of their own range, normals and tangents octahedral to within 5e-5 rad (an 8-bit normal map resolves 0.45°), any attribute every vertex agrees on as one value, and indices in sixteen bits where they reach. The baker writes it by default;--no-quantisewrites floats for a reader before 1.18.INST: an export often merges a thousand copies of one object into one mesh.findRepeatsfinds them by rigid registration, attribute for attribute, and the baker writes one copy and a matrix a copy. Ten thousand candles went from 873 MB to 2.9 MB and draw in three calls.--no-instancesdeclines it.--max-texture pxhalves an image until its longer side fits, re-encoded in its own codec: the same scene's textures went from 1.94 GB to 128 MB.--texture-codec jpegthen re-encodes the opaque colour maps, leaving normal and alpha maps lossless.MATLcarries whether a material blends, and--blend-as-cutoutturns blended foliage into a cutout. A blended surface is never merged with an opaque one, so a decal no longer draws opaque.
Both chunks are required, so a reader before 1.18 refuses such a file by name rather than drawing one copy of ten thousand or reading sixteen-bit numbers as floats. A file carrying neither is byte for byte what 1.17 wrote.
- added
A leaf card cast the shadow of its card. The depth pass had no alpha test and the caster sink carried no material, so every cutout cast its whole quad: a cypress threw a solid green slab, and a chain-link fence a wall.
A
ShadowCasterSinkentry's material is now read once, bycutoutOf: an albedo map and a cutoff above zero make a cutout caster. It draws through a cutout variant of the depth program on both backends, with its cutoff in its own ring slot on WebGPU, per the rule that per-draw state is never one resource rewritten. The mesh path and the instanced path both do it, and so do the sun's layers and every point-light face, because they share the sink. A caster that offers no material casts exactly as before.Measured on
demo/dev/cutoutShadows.htmlbyscripts/cutout-shadow-check.mjs: a card with a lattice of holes casts 0.716 of a solid shadow against 0.717 predicted from the hole area, on both backends, on both paths, with a solid control in the same frame. Breaking either backend's path turns exactly the claims it owns red. The GPU-driven pipeline's depth pass still has no cutout, whichIMPROVEMENTS.mdrecords. - added
The glTF reader now reads
KHR_lights_punctual, so a file requiring it is no longer told the reader lacks it. Each light comes out where its node's world matrix puts it, pointing down the node's −z, with the extension's own defaults where the file is silent, in glTF's units: candela for a point or a spot, lux for a directional light. What a candela is in a consumer's light units is that consumer's exposure decision, so nothing converts them.The baker writes them as
LITE, a code reserved since the container began and defined at 1.18: 64 bytes a light and each distinct name once, because a pack of ten thousand candles can name every one alike. It is optional, because a scene without its lamps is darker rather than wrong.orientLightsturns them with the geometry when a bake stands a model up.DrftLoader.lightshas them before the geometry lands, andstreamDrfttakes anonLightshandler.A bought courtyard's 22 lanterns and 10,000 candles arrive placed by their author, and a scene lights them with
selectPointLightslike any other lights.The same reader follows
EXT_texture_webpandEXT_texture_avifto their image when there is no fallbacksource, and refusesKHR_texture_basisuby name. Before, such a texture baked untextured with only a warning. The loader names a codec it cannot decode rather than handing it to the browser as a JPEG. - added
Two things every time-of-day scene wrote for itself.
createDaylightPalettetakes keys: sun and moon colour, sky, hemispheric ambient, fog, shadow strength, emissive gain and a target exposure, at a scalar a caller chooses, usually the sun's elevation.resolveDaylightinterpolates them into a caller-owned state, allocating nothing.easeExposuremoves an exposure toward a target in time,1 − exp(−rate·dt), so an eye adapts at the same speed at 60 Hz and at 144.day-clockruns on it, with no pixel changed on WebGPU.createCameraPathtakes keys of an eye, a target and a field of view at times, andsampleCameraPathevaluates them through a Hermite with the keys' own times as knots, looped or not, into a caller-owned sample. A key two seconds from its neighbour and one twenty seconds away both arrive at their own pace. - added
One sweep of a probe grid holds one bounce, and an arcade is lit mostly by the second and later. The surfaces a probe saw were lit by the sun and by the open-sky hemispheric ambient, which is generous to a covered walk and wrong about it.
With
{ bounce: true }a bake lights what it sees by the grid's last bake instead. Round-robin baking then converges by itself, about a bounce each time the grid is swept, and a scene holding one moment bakes the grid two or three times. It costs no pass and no memory. The faces are drawn into the probe's own cube and only the resolve writes the array, so no pass both samples and writes it, on either backend. Nothing changes until every probe has been baked once, because until then the grid is not readable.What it gives up: light that leaks through a wall into a probe is bounced again, so a leak grows with each sweep. A space with near-white walls converges slowly.
- added
A candle trembles and a bonfire breathes, and until now every light in this engine flickered at the same two rates. A scene wired its plume, its light and a flicker amplitude by hand, and the selection's shared wobble made a candle and a brazier pulse together.
createFlametakes where a fire burns from, how wide it is, a colour, a reach and a seed, and returns aPointLightSourceand aPlumePlacement. The flame stands 2.5 widths tall and the light a third of the way up it, with a physical radius of half the width, so its shadows soften as a fire's do.updateFlamerewrites the light in place, allocating nothing. It flickers at the puffing rate of a buoyant flame, 1.5 / √D Hz (Cetegen and Ahmed): 15 Hz for a candle's centimetre and 2.4 Hz for a 40 cm brazier. It sways up to 6 per cent of the flame's height on each axis, so a shadow it casts travels.sway: 0holds it still, for a light whose static shadow map a wandering light would read from off-centre.flameFrequencyHzis exported.The light's own
flickeris set to zero, or the selection would flicker it a second time. - added
A bloom threshold is compared before exposure is applied, so one fixed at construction means a different brightness on screen at every exposure. That is harmless while exposure stands still. A day whose exposure spans 2.5 to 14 had to choose: sunlit stone blooming at noon, or lamps never blooming at night.
setBloomtakes an optional threshold in the same scene units asbloomThreshold, held until moved. A caller whose exposure moves passes the brightness it wants on screen over the exposure. Omitted, it leaves the threshold where it was, so a caller that never passes one keeps the profile's. Both backends take it, anddrift/renderbinds it asbloomAbove(renderer, scale, threshold), both required.MAX_CLUSTERED_LIGHTSis exported too, so a consumer with clustered lights can size a light buffer to the table without restating 320. - added
Two things a loader needs that
MeshBuilderwas standing in for.placeMesh(mesh, x, y, z, scale)moves a mesh into a fit and copies its positions alone, sharing every other array, and returns the mesh itself at the identity.concatMeshes(meshes)joins meshes into one: it sizes each array once, fills an optional attribute a member lacks from the constant its backend would read for it, leaves out one no member has, and offsets the indices. A mesh joined alone is that mesh.streamDrfttakes a third argument,DrftStreamPacing: how long it may work before it gives way for a task (DEFAULT_STREAM_SLICE_MS, six), with the clock and the yield as capabilities a host can replace. See the fixes below for why a stream has to give way at all. - added
Everything temporal reprojects through the previous frame's view: the motion blur measures its smear against it, and the temporal resolve and the reconstruction read their histories through it. A cut is a jump the renderer cannot tell from a fast camera, so a transport that seeks, a respawn or an edit smeared its first frame along the whole jump and blended in a picture of somewhere else. The caller knows it cut, and
cameraCut(), called before the frame'sbeginFrame, is how it says so on both backends. The frame after it has no blur and no history, as the first frame of a session has none; nothing else is reset. - added
Every piece of traced indirect light existed and none was joined.
bakeObjectSdfhad no caller,readDrftreadSDFVwhile the stream skipped it, and a scene loading throughDrftLoaderhad no way to hand a field toaddDistanceField. Soquality.indirectLightcould only be turned on by a rig of analytic boxes.bake --sdf mbakes one distance field over the file's static geometry atmmetres a voxel withbakeSceneField: every mesh drawn once whose material neither blends nor cuts out, so walls and floors and not foliage, cloth or a candle drawn ten thousand times.SDFV_WHOLE_FILE, the ordinal0xFFFFFFFF, marks it as covering the file rather than one mesh, inside 1.18. No reader ever compared an ordinal with the mesh count, so an older one pairs it with nothing. One merged field rather than one per mesh, because a bought scene's walls come as a few meshes spanning all of it: per-mesh fields of 32 samples were 12.6 MB and 1.2 m voxels on the walls light bounces off; one field at 25 cm is 5.2 MB for a courtyard 36 m long.streamDrfttakesonFields, andDrftLoader.fieldshands each field on where it stands: at the loader's fit, and once a copy for a mesh drawn many times.
Measured on that courtyard on WebGPU with the fixes below: a noon view's mean within a level of the rasterised grid's, and covered galleries the grid left near black lit, for 0.1 to 0.2 ms a frame at 2560 by 1440. WebGL2 keeps its baked grid, as the renderer has always said.
- added
A picture that looks like it came off a camera has corners that fall away and a grain that moves. Both live in the final composite, in the order their physics puts them.
setVignette(strength)darkens the corners the way a lens loses light off axis,1 / (1 + k r^2)^2withrmeasured to the corner on a circle, so a wide frame is not darker at its sides than at its top. It acts on scene light before the tone curve, so a bright corner rolls off through the shoulder as a photographed one does, where a vignette painted on the finished picture greys it. 0.5 is about 1.2 stops at the corner.setFilmGrain(strength, seed)adds one value a pixel after the grade and the veil, weighted toward the midtones and faint at black and white, which also breaks up the bands a dark gradient makes in eight bits. The seed is the caller's, because the engine takes time from its caller: a new one each frame is grain that moves, and the same one is a still that is identical run to run. A temporal resolve cannot average it away, because it runs after the resolve.
Both are held until changed and need
screenEffects, saying so once rather than doing nothing. At 0, the default, the nine published scenes are unchanged in every pixel on WebGPU. - added
Eye adaptation. A fixed exposure is right for one view and wrong for the next: a courtyard floor in the shade of a 19 m wall is several stops under the sunlit walls above it, and an exposure set for one draws the other clipped or black. The finished scene is now metered each frame on the GPU with no readback, into a 32×32 grid of log luminance and one texel of its mean, and a held brightness follows it at a fixed rate, about 78% of the way in a second. The composite scales scene light toward middle grey by
strengthof the stops between them, at most six either way, before the lens and the curve.setOutputExposurebecomes a bias on top: a night kept darker than a day is a bias under one.- The time step is the caller's, as every clock here is; a held capture passes 0 and holds.
cameraCut()snaps it, so a new shot is metered rather than eased into.- 0 is off and the default, and at 0 nothing is metered or allocated. It needs
screenEffectsandhdrScene, and says so once otherwise.
What it gives up is regional metering: every pixel counts the same.
- added
glTF's
doubleSidedwas dropped on import and every pipeline culled back faces, so a curtain seen from behind its arch was a hole, half a tree's leaf cards were not drawn, and a sheet whose front faced away from the sun cast nothing.- The glTF reader reads it;
MATL's flags word carries it as bit 1, inside 1.18, so the stride does not move and a file that never set it reads one-sided. DrftLoaderkeeps two-sided parts apart when it merges, andSurfaceMaterial.doubleSidedtakes it to the draw.- WebGPU draws it with a pipeline that culls nothing, keyed
|2s; WebGL2 turns culling off around the draw and hands it back. The shadow pass does the same on both. - A back face is lit as its front, the whole perturbed normal turned toward the eye after the normal map and the relief, which is glTF's rule. The face is decided from the normal against the view rather than from the rasteriser's facing flag, which would be a seventeenth fragment input where WebGPU allows sixteen.
- The glTF reader reads it;
- added
A DirectX normal map lights every groove from the wrong side: a joint under a high sun reads as a dark line along its upper lip rather than shade under its lower one. Nothing in a file says which convention it wrote. The tell is a horizontal joint whose rows above read green over the middle, which glTF would put under it; a bought courtyard's stone maps read that way at forty joints across three maps. The flag inverts green on normal maps only, and re-encodes a map it would otherwise have carried as it came.
- added
A map's metallic channel can say metal where the surface is not. A bought courtyard's stone, brick, plaster and wood maps carry up to 0.37 in it, on the clean stone rather than the grime; in shade each such patch swapped its diffuse light for a reflection of a dark gallery and read as a black blotch, and nothing in the maker's renders shows a metallic sheen on stone.
surface(material)already let a consumer dress a surface by name with an opacity and a reflectivity; it now takesmetallicScaletoo, which wins over the material's, joins the merge key, and at 0 says dielectric.DrawSurfaceOverrideis the type. - added
A tone curve maps one range, and a sunlit courtyard is two. Measured in the probes that light a bought courtyard at noon, its shaded arcades hold a fiftieth of the light on its sunlit paving, which is what a canyon of grey stone is. Exposed for the paving the arcades went to black, exposed for the arcades the paving went to white, and no curve fixes both, because the same brightness has to mean shade in one place and sun in another.
Local exposure moves each region
strengthof the stops between it and eye adaptation's held brightness, at most three either way, before the lens and the curve. What a region's level is comes from a bilateral grid over the exposure meter's 32 × 32 tiles: for each tile and each of ten two-stop bands of log luminance, the sum and share of its taps in that band, and the tile's mean. The composite reads the grid at the pixel's place in the pixel's own band, so shade beside sun is lifted as shade rather than as the average of the two, blended 0.4 toward the tile's mean so a texture's own light and dark are not flattened into each other.- The held brightness is measured whether or not
setAutoExposureis on; with it off the frame keeps the exposure it was given and only regions move. - 0 is off and the default, and at 0 nothing more is measured or allocated. Needs
screenEffectsandhdrScene, and says so once otherwise.
What it gives up is some contrast between regions, which is the point, and a halo a tile wide where two regions in one band differ in brightness.
- The held brightness is measured whether or not
- added
A grid re-baked a probe a frame changed in steps. Each probe held the light of its bake until its turn came round a sweep later and then jumped, and under a moving sun the jumps crossed the scene as a pass of light once a sweep: reported as a pass every second or two, which at sixty-four probes a frame at 60 Hz is one sweep. Measured on a gallery vault, frame-to-frame changes of 11 and 16 levels of 255 once a second.
A crossfading grid keeps three sets of layers: the last whole sweep, the newest whole sweep, and the one being baked. The shading blends the first toward the second by how much of the third has been baked, so the light moves a sixty-fourth of a sweep a frame; the same vault then changed by 1 to 5 levels a frame, continuously. The set being written is never one the shading reads. Reflections read the newest whole sweep alone.
ProbeSweepsholds the decision once for both backends.What it costs is three times the layers and a sweep of lag. It is off by default, and ignored where indirect light is traced, which writes the grid's layers itself.
- added
A probe bake is six draws of the scene, and a bought courtyard of eleven million triangles made it the most expensive thing in a frame: 6.4 ms of 14 on the frames it ran, which is a frame rate no display can hold steady.
faces: [first, count]draws only those faces into the probe's capture, and the convolution into the grid layer, which reads all six, runs on the call that draws the sixth. Absent, a bake draws all six as before. Both backends take it: WebGL2 builds the probe's mip chain and WebGPU its prefiltered chain once, and neither marks the layer filled until the sixth face is drawn.The caller finishes one probe before starting the next, because the capture is one for the whole grid and faces of two probes interleaved make a cube of neither. What it gives up is a probe whose faces were drawn a few frames apart, under a light that moved a little between them.
- added
A scene modelled for an offline renderer is mostly triangles smaller than a pixel.
simplifyMesh(mesh, { maxError })collapses edges by quadric error, the survivor keeping its own attributes, until the next collapse would move the surface further thanmaxError. It refuses a collapse that would turn a face over, fold one under, pinch a closed surface, open a seam where two vertices share a position, move a border off its line, or slide a texture: every sample the collapse absorbs has to land withinmaxUvErrorof where it was painted, a texel of a 1,024 map by default. A skinned or morphed mesh comes back unchanged.bake --simplify mruns it over every mesh written, after the copies are found, so a prototype is simplified once for all its placements, and after the outline, the colliders and the distance field, which are measured from what the author made.Measured on a bought courtyard at
--simplify 0.001: the stone pack came down to 40% of its triangles, the ivy's leaves to 57%, the curtains to 60% and a candle's wax to 25%. A view down it at 3840 by 2160 went from 9.1 ms of GPU time to 8.0 at noon and from 30.5 to 27.5 at night, and a held capture of it differs in 2.6% of its pixels by more than 8 levels of 255, against 0.6% between two captures of one bake, along edges and in the shading of the curtains' folds. - added
An option only one backend has can now be asked of that backend alone. Traced indirect light and reconstruction are WebGPU's, and WebGL2 refuses either in words when handed one: right for a consumer who asked for it there, and noise for one who wanted it wherever it runs, since every device that fell back printed a refusal of something nobody had asked it for.
createRenderer(canvas, (backend) => quality)resolves the quality once the backend is chosen; a plain object works exactly as before. The type isQualityForBackend. - added
Every fixed light lights, near or far. A frame shades the lights
selectPointLightschooses, nearest the camera, and nothing else, so a courtyard of two thousand candles left every wall past that choice dark until the camera walked up to it and its candles lit one by one.renderer.createLightField(lights, { fields })sums a scene's fixed lights once into a sparse volume: bricks of four samples a side a third of a metre apart, each holding the light that arrives and the direction it mostly arrives from, laid out only where some light reaches. The scene's distance fields occlude it, with a penumbra from each light'ssourceRadius, and a sample inside solid is left out of the filter rather than blended in.field.bake(bricks)does the number asked, so a scene paces it behind its loading screen; the engine reads no clock.- The split is per pixel, and no light is counted twice or dropped.
PointLightBuffer.completeis new: the radius inside which every field light that reaches a point was chosen. The lit shader shades exactly inside it and reads the volume past it, crossfading over two metres; a field's lights carry a mark (inLightField) the selection turns into the sign of their weight, and their exact light is scaled by the share the volume does not take.field.follow(buffer.complete, eye, dt)shrinks the radius at once and grows it back slowly. - It bounces. A probe bake reads the whole field, rasterised or traced by DriftRay, so candlelight reaches the corners the candles cannot see.
- Measured: bit-identical on the two backends on
demo/dev/driftlight.html, a gallery of 726 candles with a partition whose positive control shows what the occlusion stops; the sum within 5% of exact shading on the same wall; 0.3 to 0.4 ms of GPU time at 1440p in a night courtyard; ten thousand candles lay out 3,774 bricks and bake in 2.2 s of one core. Two uniform vectors, because five took a part offering 256 down a rung of its light budget, and two samplers of the lit stage's sixteen.
What it gives up: specular from summed lights, flicker past the choice, and occlusion by anything that moves. A light that moves stays exact and out of the field. The lights are summed as they stand when the field is made, and
field.scaledims the sum. - fixed
A courtyard at night bounced no firelight. DriftRay shades what a probe's ray strikes itself, which is what lets the bounce follow a light that moves, but it shaded with the sun alone: every brazier and lantern lit the walls it faced and nothing past them, and the probes lit the rest of the courtyard with the sky.
- A hit is lit by the frame's exact lights, the lit shader's lamp term line for line (falloff, cone and cosine, which
probeBake.test.tsholds together), each one's shadow marched through the distance field and stopped a fixture's width short, the larger of 30 cm and two of the finest voxels, because a field that coarse draws a lantern round its own flame as solid. - At most 32, the first the selection chose, and a light a DriftLight field sums is left out, because the field already brings it to the bounce (
gi/bounceLights.ts, decided once for any backend that traces). - Measured in the courtyard's arcade at 03:30 with the exposure held: the frame's mean went from 48.1 to 60.3 of 255 with the fixtures in the bounce, and no pixel darker by more than four levels on more than 0.1% of the frame. The bake went from 0.16 to 0.60 ms of GPU time a frame at 1440p for 26 lamps.
probe-bake-parity.mjsagrees with its reference to 5.7e-5.
What it gives up: a photometric profile and a cookie, which the bounce reads as a plain cone; an occluder within a fixture's width of a lamp; and lights past the first 32, which a probe far from the camera would want. The rasterised grid needed none of this: it draws the frame's lights into each face.
- A hit is lit by the frame's exact lights, the lit shader's lamp term line for line (falloff, cone and cosine, which
- fixed
Both were fine for sixteen lights and neither was built for ten thousand.
selectPointLightskept a sorted list as the sources arrived, shifting fourteen fields a slot to make room. That is O(sources × slots): 3.2 ms a frame to choose 320 of a courtyard's candles, most of it moving lights that were then displaced. It now partitions with a quickselect, linear on average, sorts only the winners, and packs each winner once: 0.36 ms. Ties go to the earlier source, as they did, and the fade against the nearest light left out is unchanged. A test offers 10,000 lights in a scrambled order and checks slot for slot against distances derived by hand.The CPU froxel binner WebGL2 runs every
bindMeshPassrecomputed a cluster's box, and all sixteen of a full cluster's member distances, on every insert into one. It assumed full clusters were rare. Three hundred candles within a few metres of the camera fill 2,152 of 3,456. It now takes each slice's depths once a binning and caches each member's distance when the member is placed: 47 ms to 17 ms, with the table byte-identical on five configurations, so the GPU binner's conformance is untouched. A probe grid baked at night had been binning for a minute. A new test pins the overflow rule, which no test had. - fixed
Four WebGPU defects of one shape: per-draw state that was not per draw.
- A probe bake gives its uniform-ring slots back after its own submit. A grid of 64 probes baked in one call spent the frame's rings: thousands of draws against a ring of 4,096, and every probe past the ceiling was baked with its draws skipped. A courtyard's floor came back lit by half its walls.
- The sky is a ring slot a draw. One buffer rewritten at each draw gave every sky in an encoder the last camera, so a probe's six faces all drew one direction's sky.
- The scatter's fragment block is a ring slot a draw too, for the same reason: it held the camera, and "settled per pass" is not "settled per encoder".
- A particle batch drawn twice into one encoder is refused by name, where it drew the second draw's particles twice.
WebGL2's
disposereleases every GL object the renderer made. The scene target, three sun maps, the mirror, the point shadows, four programs, two vertex arrays, a buffer and three textures were never released, so a page that swaps renderers leaked all of them each time. A test counts every kind at two profiles. - fixed
Found by importing a bought 4K courtyard, and each one made it fail or look wrong.
- The weld keys corners in an open-addressed table of typed arrays, not a map of strings: a million corners cost 706 MB of heap, and a five-million-triangle mesh could not bake in the default heap. Ivy now bakes in 12.7 s at 1.1 GB, where it ran out at 5.9 GB. Its hash mixes the high bits into the low ones, which is what separates +0 from −0.
- The PNG reader the baker and the native host share reads greyscale and grey-with-alpha, which the scene used for 50 of 135 maps; the host widens through the one
rgbaOfrather than a copy of it. - Standing an asset up turns its tangents with its normals. The baker derives tangents before it orients, so a normal-mapped model baked with
--uplit its normal maps from the wrong side. writePartMaterialfills aSurfaceMaterialfrom a loaded part, every field of it.SurfaceMaterialdefaultsocclusionStrengthto 1, and a glTF material with no occlusion map carries 0 over an ORM map whose red channel may be anything: zero, in the scene measured. A hand copy that left out the strength drew the whole model black.- A loader whose parts are all instanced swaps its coarse outline out, rather than leaving it on screen under the finished model.
- fixed
The same five packs from a local server loaded in 15 s and ran in stretches of up to a second with no frame drawn. Profiled, none of it was the network, which delivered at 800 MB/s. It was work on the main thread that nothing bounded:
- The loader copied every mesh twice through a
MeshBuilder, once to move it into its fit and once more to merge it, through arrays that grow: about 7 s of the load. It now places a part withplaceMeshand merges a group withconcatMeshes, each array written once. - A stream decoded every chunk inside one task. Once a body is buffered each
readresolves as a microtask, so a whole file decoded before a frame could draw.streamDrftnow gives way after a slice. - A uniform ring re-sent everything it held at every flush, and a probe bake flushes once a face: 2.3 GB of uniform uploads in a two-second grid bake, every byte already on the device. A ring now sends from the lowest byte written since its last flush.
- The WebGPU interleave walked components inside vertices with the index recomputed from both, 1.5 s; a loop per width does it in about a third. The mesh validator read
data.indicesinside its scan, a megamorphic load the compiler cannot hoist, 0.8 s. The octahedral decode normalised withMath.hypot, nearly three times a square root for the same float32 on every one of 263 million codes sampled.
Measured to the courtyard drawn: 15 s to between 7.4 and 8.2 s on WebGPU across runs, and 9.5 s on WebGL2. The nine published scenes are unchanged in every pixel on WebGPU.
- The loader copied every mesh twice through a
- fixed
Every loaded part lost its tangents, alone and merged, because the
MeshBuilderit passed through carries none, so a file that baked a frame for its normal maps drew with the one derived from screen derivatives. They reach the renderer now, which found the second defect at once.A tangent parallel to its normal, or two that nearly cancel across a triangle at a mirrored seam, leaves nothing after Gram-Schmidt, and a normalised nothing is NaN. One such pixel in one probe face was a whole courtyard drawn black: a probe convolves its faces into every direction and a bounce carries that into every other probe.
tangentFramenow uses the derived frame wherever the vertex frame has no length left, on both backends. - fixed
readyis the signal to act on, and it fired with images still decoding.imagesDoneandfractionwere corrected for exactly this window andreadywas not, so a consumer gating on it judged surfaces that were still untextured. One baked its light probes at that moment and held the hour: stone without its colour map bounced into every probe, and the frame came out overexposed on the runs where the decodes lost the race and correct on the runs where they won. Every image counts when it decodes and when it fails, so a bad one cannot holdreadyback for ever. - fixed
WebGL2's mesh has run
validateMeshDatasince the validator existed, and the WebGPU upload never called it. A uv array one vertex short was an error on one backend and a stretched texture on the other, which is a decision taken twice. Both now refuse it in the same words. - fixed
A normal map's colour is a direction, and chroma subsampling averages its x and y across a 2×2 block, which bends a relief at every edge. MozJPEG happened to keep every sample at the quality the baker asks for; the baker now asks for it explicitly, and a test reads the sampling factors out of the encoded file.
- fixed
Screen-aligned rectangles on every vault, a shade darker than their neighbours, reported on both backends. Measured, a courtyard of candles, lanterns and braziers asked 461 of its 3,456 froxels for more than sixteen lights and one for fifty. A full froxel keeps its nearest and drops the rest, and the froxel beside it keeps a different set, so the step between two froxels drew as a rectangle with a hard edge.
A froxel's run is twenty texels now, 76 lights, the next cap the table's shape allows above what was measured. The table goes from 292 KB to 1.1 MB. The light loop is bounded by the cap, and the fixed arm still leaves at its own count and at
MAX_LIGHTS, so a consumer that does not cluster iterates what it did and indexes nothing past its uniform arrays. Both binners agree bit for bit at the new cap (scripts/cluster-check.mjs), and the nine published scenes are unchanged in every pixel on WebGPU.What it costs is shading: the densest froxels now shade up to fifty lights where they shaded sixteen, and the candlelit view that showed it went from 4.8 to 7.7 ms of GPU time on WebGL2. That is the price of the lights being there; a consumer that wants it back asks for fewer lights, which now fades them rather than tiling them.
- fixed
The overlay is on the far side of the resolve, so its depth has to be the drawing buffer's. It was sized from the scene's depth, which a reconstruction draws small, and the device refused a depth of 985 by 468 beside a swap image of 1280 by 608. Every overlay command buffer came back invalid, so a load screen, a label or any interface drawn after
endFramevanished whenever reconstruction was on. Found by a scene turning reconstruction on for the first time; the overlay now takes the canvas's size, which is what it always was without reconstruction. - fixed
Two writers of one level is a flicker. A scene re-rasterised a probe a frame for its reflections while the trace refreshed the same grid five probes a frame; each raster bake overwrote a layer's irradiance with its own answer and the trace pulled it back over the next frames, so light swept down a courtyard a probe at a time, for ever, with the clock paused. Measured on the held frame: region-scale flicker of 1.11 levels, peaks of 6.3, against 0.17 with the trace alone.
ProbeBaker.traced(layer)says which layers are the trace's, and a rasterised bake of one refreshes only the roughness chain a reflection reads. WebGL2 has no trace and is unchanged. - fixed
Parity is exact for a closed solid and wrong for a sheet, and a bought scene is sheets: single-sided walls, rooms open to the sky, a floor with nothing under it. One sheet a ray along
xpassed flipped every point after it, so a courtyard's field read its open air as solid in bands by altitude, +2.6 at 4 m and -2.7 from 5 m to 9 m, and a closed ball behind a sheet came back empty. Light traced through the banded half found black.bake/surfaceDepth.tswalks from the grid's faces: a step between neighbours crosses no surface when their distances add to more than the step, so everything reachable is outside, a region bordering it across the surface is inside, a region bordering only that is sealed air, and so on. Exact where it moves; a gap narrower than about a voxel reads as sealed. Every field baked before this should be baked again. - fixed
The estimate counted anything within the radius as a blocker, however thin, so cloth hanging a hand in front of a column darkened the column, which read as a halo round every curtain. It now walks two lines a pixel both ways and keeps the horizon along each, with the sky between them weighted by the cosine to the normal in closed form (Jimenez et al. 2016); a sample fades out of the horizon toward the radius, and the horizon sinks back past a thin thing in front. Measured on the same frame, mean darkening fell from 19% to 8% and the share of pixels darkened by a fifth from 32% to 11%.
The slice directions are even in pixels, not in UV: even in UV they crowd toward the horizontal on a wide frame, and every flat floor and ceiling came back at 0.82 open. Two published scenes use occlusion, and change.
- fixed
Averaging alpha thins anything thinner than a texel: a needle one texel wide is 0.25 two levels down and gone against a cutoff of 0.5, so a cypress stood in full leaf at 2000 pixels across and bare at 1280. The mesh pass and the cutout shadow pass both test alpha times one plus a quarter per mip level, Ben Golus's figure for foliage, from the texture coordinate's derivatives. Only the test sees it: what a translucent draw blends with is the texture's own alpha, and a material with no cutoff is untouched. What it gives up is exactness, which a coverage-preserving mip chain built at upload would have.
- fixed
A bought courtyard's dirt decal drew every wall it covered white under a low sun, with the grime left dark between. Its material names a base-colour image and neither a metallic nor a roughness factor, so glTF reads it as metallic 1 and roughness 1: a rough metal whose reflectance is its own dark colour. With no metallic-roughness map the factor had nowhere to go but the mesh's
specularlane, which is this engine's dielectric highlight and is white, so a surface that should have been grime took a full-strength white highlight from the sun.A metal's highlight is its own colour, and where that colour is an image it varies per texel and a vertex lane cannot say it. The reader now keeps the lane at the dielectric's zero there. A metal whose colour is a constant keeps the highlight it had, and a metal with a metallic-roughness map was already read through the map. What it gives up is the sun's glint on a textured metal with no map. A model baked before this carries the old lane and wants baking again.
- fixed
Two faults, one picture. The directional shadow's filter gave up at a surface's own terminator, where no contact-safe bias can hold a grazing plane still, and returned fully lit there. Under a high sun that band is every vertical wall turned from its bearing, so a gallery's back wall and its columns' shaded faces were lit through the vault over them. And the band was chosen from the mapped normal, so the texels a normal map leaned toward the sun were lit on a surface the sun could not reach: a field of white specks over stone in shade, and a white rim on every doorframe.
The band now takes a coarse test, one tap at the receiver with a metre of tolerance, which cannot see a contact shadow and does see a roof. The band, and the sun itself, are decided by the surface's own normal before any map tilts it: a map says which way a texel faces within its surface, not which side of the surface the sun is on. The sun is taken away over the last two hundredths of the surface's own n·l, where an unmapped surface's Lambert term is already under 0.02, and the sun's shadow as the emissive share reads it is left alone on a face turned from the light, which is unlit rather than shadowed. What it gives up is the bump-lit relief of a surface barely facing the sun.
Three published scenes change, each toward what it says it is. The gilded chamber, "an interior lit by four fires and one hole", had its walls, columns and stair risers lit by a steep sun through its ceiling, through their relief and the old band: its mean brightness goes from 38.6 to 33.2 of 255, the same on both backends. The collapse scene's stair side and channel walls, and the day clock's columns on their shaded side, lose the sun their relief had let in: 26,204 and 20,719 pixels of 921,600, a mean of 12 and 4 levels.
- fixed
The reprojection lands between texels nearly everywhere, and a bilinear fetch there is a small blur. Applied to a picture that is itself last frame's blend it compounds, so a still camera settled on a picture visibly softer than one frame of it: measured on a courtyard's stone as the mean of a Laplacian, the resolved frame kept 0.70 of the fine detail of the same frame without it. Read through Catmull-Rom it keeps 0.75. Five bilinear fetches arranged as a Catmull-Rom filter pass a ramp through exactly and keep an edge an edge; the neighbourhood clip after it takes back what its negative lobes overshoot. Both backends, from the one GLSL source.
- fixed
Outside its box a grid clamps to its edge probes, which is right for a floor below the lowest layer or a vault above the highest, facing back into the grid, and wrong for a face turned away from it: a building's outer wall read its edge probe's light in the direction of its own normal, which from inside the courtyard is the shaded back of the same wall, and drew black under a noon sun. Such a surface now takes the hemispheric sky as it leaves the box facing out, from one metre past it to two and a half, judged by its own normal rather than a mapped one.
- fixed
A lamp's shadow was filtered at every pixel its froxel listed it for, a blocker tap and twelve filter taps a light, even where the light had fallen off to nothing and the shadow was blended away with it. The shadow is mixed toward none by twice the falloff, so under a weight of 0.03 it changes at most three per cent of a light that has already all but faded; with the inverse-square falloff, that is past about eight metres from it. Both the static and the live lookup now skip it there, on both backends, as
POINT_SHADOW_MIN_WEIGHT.Measured on a courtyard lit at night by lanterns and candles, at 2560 by 1440: the point-shadow lookups were 14 ms of a 26 ms frame of GPU time, and the frame went to 19. What it gives up is the last three per cent of a distant light's shadow, which the blend was already taking away; what would make it wrong is a falloff that stays strong far out.
- fixed
DriftRay is this engine's ray tracing, rays marched through declared distance fields in GPU compute rather than on the dedicated ray-tracing units WebGPU does not expose, and four defects kept it off by default in the one scene built to show it. Found by holding it to
scripts/bounce-reference.mjs, a path tracer ofdemo/dev/bounce.html's room that shares nothing with the engine but its lighting convention, where before it was only compared with the rasterised grid, which is itself short on that page.- The bake divided the sun by pi where the frame does not. The surface shader lights a wall
albedo * (ambient + directionalColor * cosine), so every sunlit wall a probe struck came back a third as bright as it is drawn. The far wall now reads 187.7 of red against a reference of 194.6 to 218.0, where it read 66.0. - The world field reached 16 m from the camera, and a probe beyond it was traced against nothing and lit as open sky from every side: a courtyard's far end washed milky. The field now has as many cascades as reach the whole probe grid from anywhere inside it, up to six, and a probe it still does not reach keeps the light it has (
gi/fieldReach.ts). - An escaping ray read the environment's ambient, a grade's fill five times dimmer than a dusk horizon and blue where it was amber. It now reads the sky the frame drew, the sky shader's gradient and sunset bands transcribed line for line, and the ambient only where no sky was drawn.
- Each refresh was written as it landed: 256 rays over a set that turns every frame put two refreshes of a probe a few per cent apart, a shimmer about nine times a second. A refresh now keeps four fifths of what the probe held, once the grid has been traced whole (
PROBE_HISTORY), at the price of following a changed light over about half a second at 120 frames.
Green and blue still read an eighth over the reference, which the field's 0.17 m cells account for: a hit takes its cell's winning albedo, and half a cell along the red wall's joins comes back white. It costs 0.1 to 0.2 ms a frame, measured on a bought courtyard at 2560 by 1440.
- The bake divided the sun by pi where the frame does not. The surface shader lights a wall
- fixed
A still scene lights a still picture under DriftRay. Each refresh of a traced probe cast its 256 rays along a direction set turned a little further every frame, so two refreshes of one probe were two estimates a few per cent apart, and a probe comes round several times a second: a paused courtyard shimmered softly in the shade its probes light. The set is held fixed now. Across a burst of frames of a still view, 77% of the pixels moved by more than three levels; 4.8% do now, and those are the flames.
What it costs is a fixed quadrature error in place of a moving one, about 2.5 levels on the bounce page’s far wall, and the blend between refreshes now smooths a light that moves rather than noise that does not.
- fixed
A scene no longer loses its sun on WebGL2 under ANGLE’s GL backend, which is what Chrome on Linux runs by default. A shadow layer the scene never draws — the second depth layer of a profile that asks for two, or the movers’ layer in a world with none — was sampled as whatever the driver made the texture. WebGL zeroes it, and a zero in a map compared with
LEQUALis an occluder at the light, so every surface was in shade. ANGLE on Vulkan hands the same texture back reading 1, which is lit, and every capture here had run on that: 0 and 255 for the same never-written texel on one card.ShadowMapnow clears itself to the far plane when it is made. And the peel is sampled only where a pass has filled it since the first layer opened, which is the rule the WebGPU backend already held, so the two backends make one decision and a frame that does not peel no longer pays a fetch per filter tap for it. - fixed
WebGL2 does its integer work at thirty-two bits on a driver that takes mediump at its word. GLSL ES gives a fragment stage’s
intanduinta default precision of mediump, andprecision highp floatsays nothing about either. Mesa computes mediump in sixteen bits, and so does ANGLE’s GL backend on Linux, as do phones that run mediump at half width. Three passes did thirty-two-bit work under that default:- The clustered lights decode each record with
uintBitsToFloatfromuvec4texels, so every clustered light lit nothing: a courtyard of braziers at night had no firelight on a single stone. - The probe prefilter’s Hammersley radical inverse came back 0 for every sample, so a probe’s diffuse level was a blurred look along its normal rather than a cosine integral. A bounce bake compounded it, and the grid read 20 to 50% brighter than the same bake on Vulkan.
- The film grain’s hash was sixteen bits wide.
Each now declares
precision highp int, and a test reads every fragment stage the WebGL2 renderer builds and refuses one doing bit arithmetic without it. ANGLE’s Vulkan backend computed all three at thirty-two bits regardless, so the published scenes there are identical inside the frame. - The clustered lights decode each record with
- fixed
describeGpukeeps the promise its header makes, that nothing in it throws. It made its WebGL2 canvas outside the guard it gave the context, and a host can have adocumentand still refuse to make one: the native host installs a page so a game finds what it expects, and itscreateElementthrows, because there is no DOM behind it. A scene that chose its lighting tier from the part therefore never reached its first frame on the second host. The canvas is made inside the guard now, so such a host is simply one without WebGL2, and the adapter names the part — on the native host, Dawn’s. - fixed
The native host presents a frame after its microtasks have run, as a browser does, so what a game draws after
endFramereaches the window. The WebGPU renderer submits anything drawn afterendFrameon a microtask, since there is no secondendFrameto hang it on, and a browser presents only after the task’s microtask checkpoint. The host presented as soon as the frame returned, so the window’s blit went to the device first and every overlay — a HUD, a loading screen — landed in the canvas texture after it. A capture reads that texture back and saw it; the window never showed it. Measured on a courtyard’s loading screen: the frame handed to the window had no pixel above 20 of 255, and holds the name, the percentage and the bar now.The loop is
runFramesin its own module, taking its steps as functions, so the order is held by a test without a display. - added
MAX_TEXT_CELLSis exported: how many lit cells one pixel-font text object draws before the rest of its string is cut. The cut is silent by design, since a clipped message beats a frame that does not render, and until now a caller could not see it coming: a readout that grew past 900 cells lost its end mid-glyph. A caller breaking a runtime string into lines, withcountCellsbeside it, now has the number to break at. - added
A frame on the native host may answer with a promise, and the loop waits for it. A window between two scenes has nothing to draw until the next one has mounted, and the loop spun through empty frames meanwhile — tens of thousands a second on a window with nothing to pace it, a core burnt for every switch, and a run asked for a number of frames spent them all before the new scene had drawn once. A frame that waits closes its error scope first, presents nothing and is not counted as drawn. A frame that answers nothing is drawn as before.
- added
HostOptions.presentModechooses how the native host puts a frame on the screen:fifo, the default,fifoRelaxed,mailboxorimmediate, passed to the binding’s swap chain. It is an option because the pacing is the compositor’s rather than the engine’s. The binding renders into X11 windows only, so a Wayland session runs the host through XWayland, and whatfifowaits for there differs by window: on one session a hidden window held 58 frames a second where a visible one drew a light scene at 250. A caller measuring the engine wants that wait out of the way, andmailboxremoves it at the cost of drawing frames the display never shows. - fixed
A probe bake that runs every frame no longer rebuilds and reallocates as though it ran once. Since a bake can be spread a few faces a frame and a crossfading grid re-bakes as the light moves,
bakeProbeis a per-frame path, and on WebGPU it still behaved as a one-off. It rebuilt the flat bind group on entering and on leaving, which emptied the whole cache, so every material built its group again inside the bake and again after it; and it created and destroyed a depth and a multisampled colour texture per call, a view per face, and for each probe it completed a view and a bind group per level and face of its blur and convolution. A bounce bake binds exactly what the frame binds, so the groups are now rebuilt only when the environment they hold changes, and the targets are made once and kept.Measured on the native host, where every object costs most: a courtyard re-baking its grid as the sun moved drew 63 frames a second with 28% of its CPU in
createBindGroup; it draws 171 now, against 187 with the hour held. The published scenes are pixel-identical.
4.3.0 · 2026-09-21
- added
The snap 4.1.4 imposed and 4.2.0 made opt-in, now reachable from where text is actually laid out.
deviceSnappedCellSizehas been exported since 4.2.0, but it takes the ratio as two widths and the second of them is the renderer's own drawing buffer.RendererApicarriescssWidthandcssHeightand nothing about device pixels, and a game's text layout is a pure function over CSS pixels — so a consumer that wanted whole-pixel strokes had to thread a canvas down through its whole interface layer to reach a number the renderer was already holding. Every consumer on this engine sizes text that way, so the export alone was not adoptable.const cell = renderer.snapTextCellSize(available / cells, viewportWidth); const width = textWidthPx(label, cell); const x = (boxWidth - width) / 2; renderer.drawText(text, viewportWidth, viewportHeight, x, y, { ...style, cellSize: cell }, now);viewportWidthis the caller's own and it already passes it todrawText. Snap once on the line the cell is computed, and the width, the centring, the right edge and the row pitch all follow from the one number — the measurement and the picture cannot disagree, which is the property the imposed version could never have.The decision stays in
textLayout.tswith only the binding per backend: the cell drawn is the largest whole number of device pixels not larger than the one asked for, downward so a line fitted to a box can only leave a gap rather than overflow it, and a cell already whole comes back untouched. What it gives up is that a caller who does not ask still gets strokes of two widths on a fractional cell, which is the trade 4.2.0 chose deliberately.Pinned on the shared surface by a test rather than left to the
Omitderivation, for the reasonapi.test.tsrecords about the resolution governor: a public member is otherwise on the surface by accident of not being in the omit list, which is a poor way to make a promise.
4.2.1 · 2026-09-21
- fixed
Reported from continuous integration, intermittently, as "the workers did not take the shared buffer, so island solving stayed on this thread." The world stays correct when that happens, because the pool falls back to a serial solve, so what it costs is the capability rather than the picture. That is why it survived: nothing looked wrong, physics was just quietly single-threaded.
createIslandPoolspawns its workers and returns with no readiness handshake, so the firstreloadof a pool is also paying for the worker entry to load: a module graph, resolved and type-stripped, once per worker. It was measured against the deadline meant for a running worker that has stopped answering, and those are different questions.Measured on an idle twenty-four core machine with a warm module cache: the first adoption of each pool takes 45 to 77 ms and every later one 0.3 to 0.8 ms. So the 250 ms was already spending up to a third of itself on startup under the best conditions available, and a cold two-core machine goes over it.
The first adoption now has its own five second budget, because a boot that slow is still a working pool while the alternative is serial physics for the life of the world. The deadline that means "a running worker stopped answering" is untouched. The wait also parks on the acknowledgement rather than spinning for it, which a budget that long makes load-bearing rather than tidy: burning a core for five seconds would slow the very startup being waited for, and the worker has always answered with
Atomics.notifyon that slot.Two earlier attempts blamed core contention and are recorded in the test file beside this one, because neither reproduced anything: a hot spin does not starve a worker on a preemptive scheduler, which is what made a startup cost look like a scheduling one twice running. This one is proven by reproduction: forcing the start deadline below the measured boot reproduces both failing cases and the identical message, and restoring it turns them green.
A consumer on a phone or a modest laptop is the one this was costing, which is where a worker pool is worth the most.
4.2.0 · 2026-09-21
- fixed
Reported as text that "is not centered anymore and maybe tinier", in every consumer at once, correct in a full-size landscape browser and wrong in mobile portrait.
4.1.4 made the draw path snap the cell down to whole device pixels, so a 5x7 face could not draw strokes of two different widths. That defect is real and the snap is the right cure for it, but imposing it was wrong.
textWidthPxis the only measurement a consumer has, it takes no viewport and therefore cannot know the device ratio, so every consumer went on laying out against the cell it asked for while the engine drew a smaller one.Everything done with a measured width broke together: a centred line, a right-aligned column, a line fitted to a box. Measured on a 23-character string, a cell of 4.68 at ratio 1 draws 46.6 px narrower than measured; a whole cell of 3 on a 1.25 display draws 41.1 px narrower and 20% smaller than asked. The release note that shipped it called this "at most half a device pixel per cell off centre", which was wrong by two orders of magnitude.
It looked fine on a desktop because the snap is a no-op there. A whole cell at a whole device ratio is returned untouched, which is exactly the case a full-size landscape browser produces. A consumer that divides an available width by a cell count, which is what a responsive layout does, gets a fraction and pays the whole gap. That is why it was reported as a mobile-portrait fault rather than a text fault.
Both backends now draw the cell they are handed. The origin snap stays: it rounds to the nearest device pixel, shifts a line by at most half of one and breaks no layout.
What this gives up is that a fractional cell draws strokes of two widths again for a caller that does not opt in, which is the defect 4.1.4 was written to fix. It is available rather than imposed now, and the entry below is how.
- added
The cell size to draw at so that every cell of a bitmap glyph covers whole device pixels, which the engine used to apply on the caller's behalf and no longer does.
It was not exported at all before, which is why no consumer could have compensated for the snap being imposed: the decision was reachable only from inside the renderer. Snap once, at the point the cell is computed, then use that one number to measure, to lay out and to draw. The measurement and the picture then agree by construction, which is the property the imposed version could not have.
const cell = deviceSnappedCellSize(available / cells, viewportWidth, canvas.width); const width = textWidthPx(label, cell); renderer.drawText(text, viewportWidth, viewportHeight, (boxWidth - width) / 2, y, { ...style, cellSize: cell }, now);The cell drawn is the largest whole number of device pixels not larger than the one asked for. Down rather than to nearest, because a caller that fitted a line to a box measured it first and rounding up would draw a line wider than the box; rounding down can only leave a gap. A cell already whole is returned untouched, so nothing moves where there was nothing to fix.
4.1.5 · 2026-09-21
- changed
Reported on a brazier: the runner's shadow followed the fire and the brazier's own did not. One light, two shadows, visibly disagreeing about where it was.
pointShadowRebakeDistancewas 0.4 m, chosen to clear a flame's whole wander with room to spare, so a flame never re-baked at all. Its shadow of the static world therefore stood still, while the live map under the same light, which is re-rendered every frame because it holds the movers, swung with the flame. A millimetre instead, which is as near to "whenever it moved" as makes sense. A centimetre was tried and left a visible vibration: a light wandering on a curve moves less than that in a frame near the turning points of its travel, so the shadow ran smoothly through the fast part and stepped through the slow part. What the tolerance is still for is not re-baking on arithmetic noise. A light that does not move never goes stale at either value, so the whole cost falls on the lights that need it.And one such light finishes its cube inside a single frame, the same allowance a light with no image at all already had.
planBakepins the origin for the whole of a resumed bake, so six faces dribbled out at two a frame publish a new origin only once every third frame, and the shader shoots from that origin: the shadow stepped rather than travelled, at about 20 Hz against a flame that wanders roughly once a second. Reported as motion that was "really fast, not smooth, snappy, and unrealistic".Bounded to one light, and it has to be. Eight braziers taking six passes each is the 48 passes and 89 ms
pointShadowFacesPerFrameexists to prevent. The one served is the light being shaded that keeps going stale, which is the one somebody is standing next to; the rest dribble under the ordinary budget. It is spent after the cold group, so a light arriving in range still gets its first image ahead of a flame refining one it already has.What makes this affordable now and not before is that the 174-of-296 draw calls the old tolerance was sized against predates both the face budget that caps the whole static bake and the scheduler that puts a chronically stale map behind every cold and settling one. What would make it wrong is a caster set heavy enough that six passes over it does not fit the frame, where a stepped shadow is the better trade and
pointShadowRebakeDistanceshould go back up.gpuTimingis how to tell which, and it is a number rather than an opinion.And the staleness run is hysteresis rather than a reset, for the same reported vibration and the other half of its cause. A light is not stale on every frame near those turning points, and a run that reset to zero lost the whole-cube allowance there and dropped back to two faces. The run climbs to twice its threshold and decays one frame at a time, so a light that has earned the allowance keeps it through three quiet frames and a light that genuinely settles still stops re-baking rather than holding a budget for ever.
- fixed
Reported on a brazier at night: a hard square under the fire, flashing in time with the flame while the camera stood still, present on one page load and absent on the next. Both backends.
matchesSourcelets a light wanderpointShadowRebakeDistancefrom the point its image was rendered at, because re-baking six faces of the static world on every frame of a flicker is what made a brazier the most expensive object in a scene. The shader then built both its sample direction and the distance it compares from the light's live position, so the ray and the picture disagreed by up to the whole tolerance, every frame, in a pattern driven by the flicker.The argument recorded for the tolerance was that "the shadow of static geometry metres away barely moves when the emitter shifts by centimetres". That is true of where a shadow lands and false of the two things the filter actually asks.
distis compared against the stored distance and moves one for one with the drift, against a bias of a few centimetres; and the blocker search reads a single texel, so a fraction of the drift moves it across an occluder's silhouette and swapsoccluderDistancebetween the caster and nothing at all. With a fire's 0.45 m source radius that swings the shadow's strength between two thirds and none, so the whole shape switches rather than its edge shifting. That is why it read as random: what you see is the flame's phase against wherever the map happened to be baked.The bake origin is published per light and the filter shoots from it, so the ray is the ray the picture was drawn along and the drift costs nothing.
It rides in
wof the far plane's row rather than in an array of its own. A default-blockfloat[N]spends a whole uniform vector per element and uses one of its four components, so the far plane was already paying for three floats it threw away. Declared as a separatevec3array this cost sixteen rows on each of the two sets, and the budget ladder answers a 256-vector device by halvingMAX_LIGHTS: measured at eight lights becoming four. Folded in, the fix is free, at 440 vectors before and after.What it gives up is that a shadow stops tracking its light within the tolerance, so a wandering flame's shadow of the static world now stands still. That is the trade the tolerance was always making, now made honestly instead of by reading a picture from a point it cannot answer for. A consumer that wants the shadow to travel lowers
pointShadowRebakeDistanceand pays for the re-bakes. - fixed
Reported as shadows "faded out but NOT shadow outlines, generating long lines around the world", and on a brazier as a bare square outline lying on the ground with no shadow inside it.
The point and area filters decided how far the penumbra had swallowed a shadow once, from a single centre tap, and multiplied the whole filtered result by it. That is a blocker search of one sample standing in for twelve.
Where the taps agree it is right, and the umbra is unchanged to the bit: inside one, every tap finds the same occluder, so a per-tap fade is arithmetically the shared one. It is wrong exactly where they disagree, which is the silhouette. There the centre ray misses the caster, so the search reports no occluder at all, the spread is zero, the fade switches off, and the taps that do land on the caster then draw at full strength. What that paints is a hard one-texel line around a shadow whose interior has correctly faded to nothing.
It is the same square the bake-origin fix above removed the fill of, one layer down: that fix stopped the picture being read from the wrong place, and this one stops the boundary being drawn unfaded.
tapStrengthtakes the distance that tap itself read. Two multiplies and a divide per occluded tap, against a tap that has already paid for a texture fetch. - fixed
Reported from Firefox on Linux with a Radeon R9 200 Series: most consumers drew a single flat colour with only the interface over it. Chromium selects the extension on the same machine, which is why every capture taken here was green.
REVERSED_DEPTHputs the far plane at 0 and comparesgreater, which WebGL2 can only do withEXT_clip_control. Without it the backend already fell back to the conventional convention at runtime, but two things went on being decided at import time and could not follow it.DEPTH_CLEARis a module constant folded fromREVERSED_DEPTH, so a context running conventional depth cleared to 0, which is the near plane there: every fragment failed the test and the world never reached the frame. AndSKY_VERTfroze the same constant into its shader source, so the sky wrote the near plane too.depthClearFor(reversed)andskyVertFor(reversed)answer per context, and the backend asks them rather than reading a constant. The decision stays backend-neutral and only the binding is per-backend.
4.1.4 · 2026-09-21
- fixed
Reported as grainy, stippled shadows in two unrelated games on this engine, at a desktop profile with the full twelve taps. The approved repair was a rotation tile and a resolve, and underneath it was a second cause nobody had measured.
PCF_OFFSETSdeclared twelve taps and asked eight questions. It was a cube's eight corners and six faces, carried asvec3from the era when a tap offset a direction by a 3D vector. Both filters have read only.xysince the map became octahedral, and flattening a cube's corners onto a plane makes four pairs coincide: four taps were answered twice and the closest pair sat at a distance of zero. The eight distinct places were all on the rim, at radius 1 or 1.414, with nothing in between — so a coverage estimate moved in steps of two twelfths as the penumbra swept a doubled pair across it, which is the size of the speckle.A golden-angle disk of twelve distinct places replaces it, the construction
ambientOcclusion.tsalready uses, with the radii dealt round rather than taken in spiral order:shadowFilterTapsis 4, 8 or 12 and the loop stops early, so in spiral order a low profile would quietly have received the four innermost taps and a third of the width it asked for. The outermost tap is 1.384 against the old 1.414, so the filter's reach does not move.The turn and the resolve are one mechanism. Two tap sets half a golden angle apart, chosen by the fragment's column parity, folded together after both lamp loops. The resolve cannot sit beside the taps: that loop continues on falloff and on facing and reads its trip count from a froxel, so neighbouring pixels can be shading different lights, and a derivative there is the 2026-08-07 rule's own case. After the loops the control flow has reconverged. Each pixel is shaded by the pair's twenty-four places and pays for twelve.
Measured on the mover's penumbra, mean absolute difference to the right and down neighbour: 4.563 to 2.327 on WebGPU and 4.595 to 2.372 on WebGL2, against a floor of 0.805 with the shadow off. The area-light path is included, because it uses the same taps and stippled the same way. Cost is nothing measurable: five capture pairs on
night-courtput the before-and-after difference at most 0.08 ms against a round-to-round spread of 0.53.The banding the turn was added to cure has not returned, checked on the control it was reported from: with the turn deleted the post's long shadow terraces, and the shipped build is smooth with no straight edge.
- fixed
Reported as pixelated text across three unrelated surfaces — a game's world label, a wordmark, and this repository's own voxel sandbox. It is not about missing pixels, which is why a hole count found none: a 5x7 face draws every stroke exactly one cell wide, so when a cell is a fractional number of device pixels the rasteriser gives one column of cells four pixels and the next three, and a face whose whole legibility is uniform strokes comes apart.
Two doors reach a fractional cell, which is why it arrived as three unrelated faults. A caller can ask for one outright — a consumer sizing a detail line at 0.52 of a headline of 9 asks for 4.68, which needs no unusual display at all. Or a caller can ask for a whole cell that the ratio makes fractional, by laying out in CSS pixels at a device ratio of 1.25. A pixel budget that scales the drawing buffer by a square root and floors it is a third route, and a player can reach it from a settings screen.
The tell is that cell 3.75 carries more ink than cell 4.00 — more lit pixels from a smaller cell, which is impossible if the face were merely small.
deviceSnappedCellSizedraws the largest whole number of device pixels not larger than the one asked for, decided in shared code with only the binding per backend. Down rather than to nearest, because a caller that fitted a line to a box measured it first: rounding up draws wider than the box, rounding down can only leave a gap.And the grid has a phase as well as a pitch, which the first half of this fix left alone and the reporter caught: a whole four-pixel cell starting at 10.4 puts every boundary at four tenths and splits the strokes again.
deviceSnappedOriginsnaps that too, to nearest rather than down, because a cell is a size and an origin is a position.What it costs:
textWidthis not snapped, because it is handed no viewport and so cannot know the ratio, which makes a measured width an upper bound and a centred line sit up to half a device pixel per cell off centre. And a label animating its position sub-pixel now steps by a device pixel rather than sliding. SDF text does not come through this path. - fixed
4.1.3 was written from a log that named
gpu-driven blend, and it wrapped that pipeline alone. The four other render pipelines and the eight compute pipelines beside it kept the shape that caused the report, and the next device reported what was left of it: the transparent pass correctly skipping itself, the overlay still drawing, the frame counter still counting, and the world black — which is what an invalid opaque pipeline looks like once the blended one has stopped taking the command buffer with it.The whole of
buildPipelinesis built inside one pair of error scopes now, with the blend pipeline's own pair nested inside. The two verdicts stay separate: a device that refuses only the transparent pipeline still draws everything opaque, and a device that refuses anything else turns the pass off rather than encoding against a handle the driver has already rejected. Nothing this pass encodes runs until the scopes come back clean.And a scene can say why it is black.
refusedBecausereports the refusal in the device's own words, andDemoHandle.refusedcarries it to a host — the same shape as thelostflag beside it, one cause along. A pipeline refusal arrives asynchronously, long aftercreateRendererhas decided the backend can run the pass at all, so there is no earlier place to throw and nothing for a host to catch.The fixture had to be corrected before the test meant anything. It stubbed
popErrorScopewithoutpushErrorScope, which is a device that reports errors nobody asked it to collect. No device is that, and the guard correctly took such a device at its word and never ran — a green test over an unexercised guard. - fixed
bake/hlod.tsbuilds the proxies and impostorsdocs/CAPABILITIES.mdlists as shipping. It is tested, it is sized, it carries the licence, and until now nothing outside its own test imported it — so a consumer following this engine's own rule that the barrel is the contract could read the capability map, believe the row, and find no way to call it.The assets barrel already carries that paragraph twice, for the cluster bakers and for the object SDF. What is new is that a demo boundary test caught those two and nothing caught this one, because no scene tries to bake an impostor. A module reached only by its own test is not shipped, and no gate here asks.
Found by censusing what three consumers could adopt and what they could not.
- fixed
Reported from a Galaxy S23 Ultra and then an Adreno 740, and 4.1.2 caused it. Both refuse
gpu-driven blendwithVK_ERROR_UNKNOWNand nothing else, and both drew the whole city and showed black: the page's own readout said 43 fps, 1,087 clusters drawn and 27,167 in the sun's map while the screen was empty.The blend pass is what clears the two weighted-transparency targets — accumulation to zero and reveal to one, meaning nothing has covered this pixel yet. 4.1.2 skipped that pass where the device refused its pipeline, which was right, and took the clear with it, which was not. The resolve was left running against targets nothing had written, and a reveal of zero is fully covered, over every pixel, so it composited the opaque image away.
The comment beside that clear had already predicted it, in as many words: cleared to zero "the resolve would show no scene anywhere the pass ran, which is a black frame that looks like the blend working". It was describing the clear value, and it turned out to describe the missing clear too.
The resolve now skips on the same verdict as the draw. There is nothing to composite when nothing was rasterised, and the opaque image is already in place before the resolve would load it.
What is still not fixed is why those drivers refuse the pipeline. Its targets are
rgba16floatandr8unormunderlayout: auto, and diagnosingVK_ERROR_UNKNOWNneeds the part that produces it. This is the containment working as intended: a refusal costs the glass it is about.
4.1.3 · 2026-09-20
- fixed
Reported as a car whose interior, grille, mirrors and lamps came through as hard black and white shards, where the published build of the same game was correct. 4.0.0 changed how a
.drftdecodes its images, from premultiplied to straight alpha.The argument for straight alpha was measured and is still true.
createImageBitmappremultiplies unless told not to, the upload into a straight-alpha texture divides back out, and on a PNG holding every colour at every alpha, 98,463 of 196,608 channel values came back changed. None did withpremultiplyAlpha: none. Every cutout and emblem was losing the colour its author padded past its edge.What it did not measure is content. An imported material routinely carries arbitrary bytes under its fully transparent texels, and premultiplying is what kept them out of the frame. Straight alpha let them through, and an opaque draw discards nothing, so the shader takes the colour whatever the alpha beside it says.
Isolated in the showroom on held frames, which is the part worth keeping. A model with 41 images drew clean and two with 64 and 65 drew shards, on both backends, with the old container and the new alike, fully streamed. The count only decides how likely a model is to carry a masked texture at all. Restoring the one option drew the car correctly.
So the faithful choice per texel was the wrong choice per model.
colorSpaceConversionis unchanged and stays off. - fixed
ksAlphaRefof zero means the shader's own threshold, not discard nothing. The.kn5reader read it literally, and the first car to set the flag set it on eleven materials with a reference of zero on every one:int_net,int_stitching,grille_a,hood_labels. A threshold of zero discards nothing, so a grille's holes and a seat's stitching were drawn solid.A material that says it is alpha tested has already said something is meant to go, so a reference of zero cannot be read literally without contradicting the flag beside it. Half, because that is the convention those shaders were authored against, and because these masks are two valued.
The comment this replaces said the path was carried on the format's word rather than on evidence. This is the evidence arriving.
- fixed
4.1.2 had this the wrong way round, and the reporter proved it on the next run. That version built the pipeline, bound a group against its layout, encoded the pass, and disowned the handle when the error scope came back. A scope comes back on a microtask, after all three. The log carried the new warning and then every error it was meant to prevent:
GetBindGroupLayouton an invalid pipeline, a bind group against an invalid layout, aSetPipelinewith it, and a refused submit.Nothing touches the handle until the device has answered now. The bindings are held as a closure rather than built, and run once, either where the verdict has already landed or when it does. What that costs is no transparency for the frames before the answer, which nobody sees.
A device offering no error scopes is taken at its word, because refusing transparency forever on hardware that never said no would be the worse failure of the two.
4.1.2 · 2026-09-20
- fixed
Reported from a Galaxy S23 Ultra, on the published site.
CreateGraphicsPipelines failed with VK_ERROR_UNKNOWNforgpu-driven blend, and then four more errors that were all the same one: an invalid pipeline makes an invalid bind group, which makes an invalid encoder, which makes an invalid command buffer. The city drew nothing, on a device that could have drawn all of it but the glass.createRenderPipelinedoes not throw where a driver refuses it. It hands back an invalid handle and reports asynchronously, so there is nothing to catch at the call and the code after it carried on: a bind group built from the invalid layout, a pass set to the invalid pipeline, a submit of the invalid buffer. Twoas GPURenderPipelinecasts are where that assumption lived.So the pipeline is built inside error scopes and disowned where one comes back. Both scopes, validation and internal, because a shader a driver cannot compile is reported as one on some backends and the other elsewhere, and this is exactly the case where guessing costs the whole frame. The transparent pass then skips itself and everything opaque still draws. The warning is said once, in the words the device used.
The check that went with it had to be rewritten before it meant anything. The first version asserted that no transparent draw was encoded, and it could not have failed: the fixture carries nothing transparent, so that draw is absent either way and it passed over an unfixed pass. What is observable is the warning and the frame, so that is what it asserts, and perturbing the disown turns it red.
What is not fixed is why that driver refuses the pipeline. Its targets are
rgba16floatandr8unormunderlayout: auto, and diagnosingVK_ERROR_UNKNOWNneeds the part that produces it. This is the containment: a refusal costs what it is about.
4.1.1 · 2026-09-20
- added
A panel needs its world in the shape the panel reads, and two of those shapes are the same in every game that has the thing behind them.
createToolsOverlayput the panels on screen in 4.1.0 and left every consumer to write the same two bridges, which is the drift this repository opens by describing, one layer in.entitiesInspectable(world, types)turns an entity world into anInspectableWorld. The inspector addresses a component by name, because a name is what a row carries; a world addresses it by the type object, because that is what indexes its stores. Bridging them is seven forwarding methods and a lookup. A name with no type behind it does nothing rather than guessing, so a row built against a stale schema cannot write a field into a component the world does not have and report success, which is a silent loss of an edit somebody just made and watched apply.createSessionRecorder,observeSessionandsessionReadoutturn a lockstep session into aNetworkReadout. The watching is the half worth writing once:session.desyncis latched rather than an event, so it keeps answering with the same disagreement on every frame until the next one arrives, and a recorder that appended what it read would turn one divergence into sixty a second and bury the tick somebody is looking for under copies of itself.snapshotBytesis the caller's, becauseRewindLoopis generic over the state it snapshots and cannot know the size of one; a caller passing zero is saying it has not measured.Both are typed structurally rather than imported, the arrangement
@driftengine/drftalready has with the DTEX types@driftengine/texturesatisfies. So this package gains no dependency, the release is the same ninety five places it was, and a consumer whose state is not an entity world at all can still satisfy the shape.5,391 bytes gzipped, from 5,105, and 286 of that is these two. Named in the size fixture in the same commit, which is the whole point of the paragraph the last release added to that file.
- fixed
Reported from a Galaxy S23 Ultra, on the published site: "A city at dusk" was a blank frame. The scene asked for 152,314,560 bytes as one storage binding and the device binds 134,217,728. That is 128 MiB, the WebGPU default, and it is what most handhelds offer against the several gigabytes a desktop adapter does.
Nothing was misconfigured.
select.tsalready asks the adapter for its ceiling onmaxStorageBufferBindingSizerather than taking the default, so there was nothing left to raise: the city was simply larger than the part could hold. The refusal was correct, it named the number, and it reached a console rather than a person.The scene sizes itself to the device now.
reachForCeilingsteps down a ladder of reaches until the vertex buffer fits inside four fifths of what the adapter reports, leaving room for the pipeline's other storage bindings. An S23 Ultra gets a reach of 450 metres at 71% of its limit; a desktop adapter keeps the published 900. A smaller reach asked for with?reach=is a decision and is never raised to fill the room available.The budget could not have fixed this. The scene ignored the one it was handed, and honouring it would not have helped: a demos page passes
fullto every device and only a?budget=in the address says otherwise, so a phone never asked for less. Sizing against the limit the device reports needs no coordination with whoever mounts the scene.VERTEX_FLOATSis exported for it. AStreamCapacityis counts and what a device refuses is bytes, so whether a capacity fits isvertices * VERTEX_FLOATS * 4against the binding ceiling. Without it a consumer either hard-codes the stride or finds out by being refused, which is what this scene did on every phone.
4.1.0 · 2026-09-20
- added
4.0.0 shipped the panels and nothing to mount them, which is the gap this closes.
@driftengine/toolsexported an inspector, a console, a profiler and a network panel, and each of those builds aUiNodetree and stops there. What turned a tree into something a person could look at, the geometry and the event routing and the painting, lived in the editor application, and that workspace is private and ships to nobody. So a consumer wanting an in-game inspector had to write that half again, and six of them writing it six times is the driftAGENTS.mdopens by describing.A column rather than a dock. The editor dock splits, drags and persists because somebody arranges an editor and then keeps that arrangement. Nobody arranges a debug overlay: they press a key, read a number and press it again. So the panels stack down one edge in the order they are given, and what a dock would have bought is not bought.
The painter is supplied rather than reached for, and it is four calls: a rectangle, a line of text, a clip and its close. A host with a 2D context writes four lines, a host drawing through
@driftengine/ui2dwrites them over a sprite batch, and a host with no screen at all writes them into an array, which is what the tests in this package do. That is the platform rule applied to drawing, and it is why nothing here needs a graphics device and the whole of it can be asserted.While it is closed it costs a boolean.
framereturns before it builds anything androuterefuses every event except the one that opens it, so a game that never presses the key pays nothing for carrying it.createGpuPassTimingsandrecordGpuSamplecame with it, because the engine fills no timings for anybody:PassTimingsis a container andRendererApi.gpuTimeris a source, and joining the two is the same twenty lines in every consumer, over slots that areGPU_SLOTSand never the game's.recordGpuSampleresets before it writes, and the first draft did not:recordPassSampleaccumulates on purpose, since a shadow pass runs per cascade, and a whole resolved frame added to what was already there leaves the rows climbing forever and reads as a leak in whatever the game last changed.The package is 5,105 bytes gzipped, from 3,395. The size fixture named the four panels and the undo stack and nothing else, so for one commit the overlay was tree-shaken straight out of the measurement and the gate stayed green over a package that had grown by half. A size fixture measures what it imports, which is the same trap as a scope nobody re-reads.
PanelBindingandbindPanelmoved here from the editor, because neither had an editor-specific noun in it and both hosts needed the same thing. Two implementations of one decision drift, so the editor re-exports them now.
4.0.0 · 2026-09-20
- changed
This is a major version for what it adds, not for what it breaks. Five packages are new —
capture,native-host,nav,textureandtools— alongside aneditorapplication workspace, bringing the tree to twenty-three packages. Across every package barrel, 769 public symbols were added and none were removed, so a game built against 3.63.0 compiles against this unchanged.What is in it, in one paragraph. A GPU-driven pipeline behind one option, and a frame graph under the renderer. Temporal reconstruction, and indirect light that falls back through a bounded world-space field rather than guessing off the edge of the screen. Worlds larger than single precision can address, streamed by where the camera is about to look, with terrain that decides its own detail and a navigation mesh across it. Textures as compiled programs over a latent rather than images, with residency predicted instead of reacted to. An editor that opens, scrubs the simulation backwards, and lets a scene be edited while it runs. The engine on a native window with no browser in the process. And a video of a room becoming a scene with colliders, materials and proposed entities, on the player's own device.
What a consumer owes the upgrade is the bump itself. A release moves
versionin twenty-five manifests, the sixty-nine internal@driftengine/*ranges that pin the packages to each other, andpackage-lock.json— ninety-five places. That count was ninety-three in this repository's own notes until it was counted rather than read, immediately before this release, and answered two more.Nothing here moves the
driftscriptpin, which is a published package on a version line this repository does not own. - added
npm run editorand it is there. A menu bar, a viewport, docked panel slots, selection by picking, delete, undo, redo, select-all and a command palette. Every edit is aCommandon a stack, so a panel cannot touch the world except through something the stack can put back.app.tsis the general shell andshell.tsis the product, which is a deviation from the design and the right one: assembling a product into the reusable shell would make it depend on a selection model. The dock is a binary tree of splits with one panel per region, andpanels/docked.tsis the single seam that closes aPanel<W, V>'s generics and owns the title strip.The gizmo is the editor's arithmetic rather than core's, and the reason is a conditioning number. Core translates by the closest point between the ray and the axis line, which is right and refuses only within
1e-6of parallel; between that cutoff and a comfortable angle the answer divides by the squared sine, so two degrees off the axis multiplies a pixel of mouse noise by 820. A drag here is computed against a plane containing the axis, chosen per drag as the one whose normal most faces the camera: the two candidates are orthogonal, so the conditioning is never worse than √2 where a fixed choice reaches a billion four times a turn. An axis pointing at the camera has no good plane at all, so the drag is refused rather than invented. Commands carry absolute positions rather than deltas, which is what makes the merge contract's trap harmless: applying an absorbed command twice writes the same position twice, where a delta would move the object twice as far as the pointer and read as a sensitivity bug.What it does not do is in
editor/README.mdrather than left to be found: there are no tabs and no splitter dragging, and nothing paints the gizmo's arms. - added
Press play, press stop, and the scene is exactly as it was — the oldest defect an editor has.
editor/src/pie/session.tscaptures the world before the first step and restores it on stop, checked by a digest of the whole world rather than a spot check of a few fields. Advancing and stepping are different verbs: a loop callsadvancePieevery frame and it does nothing while paused, and a person presses step and gets exactly that many frames and a session still paused. The world arrives throughSnapshotter, the engine's own seam, so nothing here knows what a world is.Scrub the simulation backwards to any recorded frame, and forward again unchanged. A keyframe every _n_ frames plus the input log between reconstructs any frame exactly, because the simulation is deterministic. The assertion that matters is the second one: scrub back, play forward, and compare against a run that never scrubbed, frame for frame. A scrubber that perturbs the simulation is worse than none, because it produces a version of the bug that is not the bug. A frame the ring has dropped is reported as gone and never as the nearest one.
Change something at frame four hundred and the rest happens differently.
editDuringPlay.tsrecords the edit and re-simulates from its frame rather than applying it on the spot, which is one path instead of two and the only arrangement under which scrubbing away and back keeps the change. The edit is the sameCommandan inspector emits at rest, so it is undoable and there is no second way to write the world. Editor edits stay out of the input log, because an editor command is not something a peer sends.And when two runs stop agreeing, the frame and then the component.
divergence.tsbinary-searches the per-frame fingerprints and@driftengine/toolscompares the per-component hashes at that frame. Three things are reported as themselves rather than as a number meaning something else: agreement is not frame zero, no overlap is not agreement, and a whole-world difference no recorded component accounts for is said plainly.What this cost is the shortest honest summary of the determinism work. A timeline, a slider, and the rule that an edit during play goes into the log. Snapshots, rewind, input logs, deterministic replay and fingerprints all shipped already, for the netcode.
- added
A game can ship the editor's panels, and a game that does not pays nothing.
@driftengine/toolsis the inspector, the console, the profiler and the network panel, with the command stack that makes their edits undoable, at 3,395 bytes gzipped.The line is what a panel needs, not what it shows. A scene tree and an asset browser want a project, and a shipped game has none, so those stayed in the editor.
Panel.routereturns aCommandor nothing, so an in-game inspector edit goes onto an undo stack without the game arranging anything.Every other package's size floor is unchanged by this one existing, which is the argument rather than a claim about tree-shaking.
- added
One editor over three node vocabularies, in
editor/src/graph/. Nodes and links with a cycle refused by name and by node; every edit a command, including a node drag, which merges into one undo entry; a canvas that pans and zooms about the pointer.The hit test is handed no view at all.
buildGraphGeometryturns graph space into screen space once, into arrays, and both the draw and the hit test read those arrays — because the commonest defect in a graph editor is a hit test that disagrees with the draw about the pan or the zoom, and the only way to prevent it for good is to leave no second place that could apply a transform. Automatic arrangement reads the graph and never the current positions, so tidying is not path-dependent.A material graph's compile target is a
DTEXdecode program, not a shader — the fact a reader most needs and least expects. A texture was already defined as a small interpreted decode program, so a material graph is a visual editor for exactly that structure: a graph-authored material and a baked one are indistinguishable downstream, and the graph editor is a debugger for baked materials as a side effect. A node the vocabulary cannot express grows the decode vocabulary, which costs bytes linearly because an operation is data.A particle graph compiles to the
ParticlePoolOptionsthe engine already takes, plus the emission the pool has never owned, so a compiled graph is two records rather than a third representation to keep in step. Randomness is a seed and never the global generator. A curve is two numbers, because two is whatParticlePoolinterpolates between, so a preview agrees with the frame. Capacity is derived from rate × life rather than typed in.A behaviour graph emits DriftScript source, and DriftScript's own compiler is what judges it. The graph gets no semantics, no effect analysis and no hot reload of its own, which is what makes a graph and hand-written code the same artefact and lets a project mix them. A graph can write a component it never declared, and the real compiler refuses it with DS0288 in the words a person hand-writing that file would get.
Each vocabulary ships one honest refusal.
separateis in the material palette and deliberately does not compile, because splitting a colour into channels is the first node somebody reaches for that this genuinely cannot do;turbulenceis the particle vocabulary's. - added
DriftScript is edited against the language server that already exists, not a second language implementation.
editor/src/panels/script.tsis a text view, a diagnostic gutter and a completion popup; every rule about what the language means stays with the compiler, which is the same line the behaviour-graph editor holds and for the same reason.It works with no client at all — a headless build has none and a test has none, so it is a text view before it is anything else.
The server's view of the buffer lags it by a message, so a diagnostic arrives describing text already deleted every time somebody types quickly. Ranges are clamped, and one entirely past the end collapses to an empty mark at the end rather than disappearing, because the server has something to say and dropping it loses the only sign that it did. Nothing awaits the server: a change notification goes out and the buffer is already updated, and a completion reply arriving after a dismissal is dropped rather than reopening a popup over text somebody went back to typing.
- added
A clip becomes a surface, on the device, with no service.
@driftengine/capturetakes aFrameSource— a video element in a browser, ffmpeg on the native host — chooses frames, runs Depth Anything 3 Small for poses, depth, intrinsics and a confidence together, fuses the views into a truncated signed-distance volume, marches it and decimates the result. ThencollisionMeshmakes it something aCharacterControllerstands on, a navigation mesh bakes from the same surface,delightseparates what the surface reflects from the light it was photographed under, andsegmentGeometryandproposeEntitiescut it into regions and say what each might be.The same stages run in both hosts with nothing in the package changed between them. Measured 2026-09-20 on an RX 9070 XT over six frames of a 1,401-frame 1080p handheld clip: 7.2 s in the browser and 9.05 s on the native host, of which 3.6 s and 5.5 s are loading the weights. A room came back as 33,060 triangles over 1.08 × 0.70 × 1.08 m, with one navigation polygon and eight entity proposals, in a 1.09 MB file.
Its maturity is stated rather than implied, stage by stage, because a reconstruction that looks finished and is not costs a consumer more than one that says so. Geometry reconstructs what a clip saw from where the clip saw it and nothing more. Colliders hold where the capture has a surface, and the capture does not have one everywhere: dropping a controller from a metre above over a three-by-three grid, three of nine landed on it and six fell through, which is the reconstruction being patchy rather than the colliders failing. Delighting recovers colour and says how much it trusts it, and on ordinary footage that is not much — a mean confidence of 0.276 on a wooden table and 0.249 on a room. It cannot remove a cast shadow, because a shadowed point is shadowed from every camera and agreement is not evidence of albedo. Entity proposals arrive unlabelled and undecided: a person accepts or rejects each one in the editor, and the file records decisions rather than guesses.
Three limits ship as limits. Six frames is the ceiling on this device and ten is refused with an invalid pipeline layout. The metric scale is not verified: no capture in this record contained an object of known size, so the metres are the model's claim rather than a measurement. And delighting is the most expensive stage by thirty times — 44 s over 16,670 triangles and 179 s over 33,060 — because it ray-casts each vertex against every triangle with no acceleration structure. Colour is per vertex, so its detail ceiling is the mesh rather than the footage.
Each model definition is held to the upstream's own code on a seeded miniature, rather than to a picture that looks about right: Depth Anything 3 agrees to 3 parts per million, Depth Anything V2 to 7.8e-7, MobileSAM to 3.1e-6, SAM 2.1 to 7.4e-6, and OWLv2 to 4.1e-6 with its box prior held bit for bit. An unread weight is refused, so a branch this engine sets aside is set aside by name.
The package floor is 66,373 gzipped bytes, built up a stage at a time so the bill is legible: the largest single step is the Gaussian fit at 5,336, and the assembly step's 7,683 is
@driftengine/drft's whole writer rather than this package's code.No weights are distributed with this engine.
tools/capture-weights/fetches each model at the revision its manifest pins, verifies it and converts it; a game that ships one ships it under that model's own licence, andCREDITS.mdandNOTICEcarry the attribution. The runtime never fetches. - added
The engine on a native window and a native WebGPU device, with no browser between them.
@driftengine/native-hostsupplies the page a game expects around its canvas — a window, a GPU, events, gamepads, audio — and no engine code changed to make it work. A game's whole contract is one export: the manifest'snative.entrynames a module exportingmount(canvas).All nine published scenes come back 0 of 776,960 pixels against Chrome, checked by
npm run native:gate. Measured on an RX 9070 XT: 730 ms median from spawn to first frame — 275 ms to reach the game'smount, 355 to build a WebGPU renderer and 85 to its first frame — in a 61.3 MB archive. Of that 355 ms the adapter and the device are 30; the rest is pipelines the binding has no cache for, which Chrome builds warm in 75 ms, and that gap is recorded as an absent capability rather than smoothed over.This is what the platform rule was written for. Nothing under
src/may call a platform API directly when a consumer might want a different one, and until a second host existed that rule was held by review. Every platform touch this host had to answer was a capability the caller supplies or a page global, never a call buried in the engine. What each host can do that the other cannot is recorded both ways indocs/CAPABILITIES.md§2a, the host's side guarded by apresentblock so a capability cannot quietly go.Four defects in it were found by a person at the machine and by no gate, which is worth publishing because the shape recurs. A first key press was reported as an auto-repeat: the code read
(event.repeat ?? 0) !== 0, which is right for the number the platform's types declare and inverted for the boolean it actually gives, so held keys were discarded while tapped keys still worked. Two automated checks confirmed it working, because a replayed key carries the number0, which coerces correctly. Two platform handles were closed twice, each ending the process. A keyboard's power-key endpoint enumerated as a gamepad and took the first pad slot; a device with no axes and no hats is not offered as one now.And the editor on this host painted its interface after
endFrame, which survives on WebGL2 and vanishes on WebGPU: 0 pixels above black after, 1,747,114 before, with one line moved. It was invisible to every capture, because a readback reads the canvas texture and a texture that is never presented still reads back perfectly.Windows and macOS are not built yet; the Linux target is.
- added
A material in the container as a decode program over a latent, rather than as pictures.
@driftengine/textureis the interpreter anddrft/src/dtex.tsis the chunk: the latent grid, the channel specs, the graph's nodes, a small network's shape and weights, and a tile table with a content hash per tile so two identical tiles share one payload.The chunk carries no semantics.
@driftengine/drfthas no dependencies and does not know what an operation means, so the types are plain arrays that@driftengine/texture's satisfy structurally — the arrangementTerrainalready has withHeightfieldacross the physics boundary. It validates containment, not meaning: a tile whose bytes run past the payload and a result register no node writes are refused, because those hand a reader arbitrary memory or an uninitialised value. Those guards are unreachable through the writer, so the tests corrupt a written chunk to reach them.No per-chunk version, which is where it departs from its design: the file's version covers the format and a second one is a second thing to keep in step. It is additive, so an older reader skips it by its length and loses only the material it could not have decoded.
And a texture cannot move the simulation, asserted rather than left true by construction. A run sampling an animated
DTEXevery tick fingerprints identically to a run sampling nothing, and the same simulation run twice at different wall-clock times fingerprints identically, becausetis the caller's, from the simulation's clock. A run that reads a wall clock instead is asserted to diverge, without which the first two pass for a decoder that reads a clock nobody noticed. - added
Residency is decided by looking at where the camera _will_ be. The state of the art is reactive by construction — the most widely deployed implementation's own documentation says streaming is reactive by nature, because nothing can know a tile is needed until a frame has already needed it. That is true of an engine that cannot run its simulation forward and put it back. This one does it every frame, for the netcode.
predictViewssaves, advances _n_ deterministic steps with no rendering, samples which tiles those views want, and restores. No feedback buffer and no readback, so it works for passes that could never write feedback. What those views want istilesForView's answer: each instance inside a predicted view names the level its nearest point asks for, the finer one within an eighth of a level of a boundary, and every coarser one a sampler falls back to, most important first — so the answer at any budget is a prefix of the answer with none.Measured on a scripted flight down a corridor of real latents, 452 tiles through a 256-page cache and never more than 102 in a frame: 296 late samples predicted against 1,294 reactive, and the 296 are exactly the three frames before the first fetch could possibly have landed.
Mispredicting degrades rather than fails, and that is asserted rather than claimed. A camera reversing every three frames is what prediction cannot help with, and the cost is a wasted fetch and a late tile, which is what a reactive engine does on every frame. Measured on that flight: 102 late samples predicted against 755 reactive. The floor of this mechanism is everyone else's ceiling.
A tile that will not arrive stops being waited for. A fetch resolving with nothing leaves the tile requested and its in-flight slot occupied forever if nobody handles it, and a handful of those stops streaming entirely with no error anywhere. A missing tile is remembered as missing, a rejection is treated identically, bytes larger than a page are refused rather than truncated, and a fetch landing after its tile was evicted is dropped.
And the decode can move from per-sample to residency time, which is the fallback the design named before the risk could arrive: decode once when a tile becomes resident and sample it as an ordinary texture after. That loses procedural and per-sample parameterisation for the affected materials and keeps the compression, the joint channels and the streaming.
- added
A scorch mark is in the input log, so a replay burns the same wall. Runtime-mutable textures normally break replay: the marks are not part of the simulation and nothing records them, so a recording plays back clean walls and nothing fails at the time.
overlay/sparse.tsallocates only the tiles a mark touched and tracks written per texel and per channel;overlay/journal.tsrecords every write beside the input. Rolling back is rebuilding from the journal, because a write is not invertible — two marks on one texel leave no record of what was underneath. The journal is truncated on rollback, or a replay draws both what happened and what was undone. - added
@driftengine/navships, at 8,401 bytes gzipped, and it reverses a refusal that named its own trigger.core/src/nav/navGraph.tsstates that it is a graph rather than a mesh, that this is a decision rather than a first step, and that a mesh is the row that is still open if a world's walkable space is genuinely a region. A streamed open world is that world.Geometry becomes a walkable voxel field — two layers under an overhang, which is why it is voxels and not a heightfield — then a watershed partition, contours checked for self-intersection rather than assumed simple, and convex polygons whose adjacency is symmetric. A one-directional build makes paths work in one direction only and nothing says so.
One A* in the repository: the query builds a
NavGraphand hands it to core's existingNavSearch, then funnels, so open ground is a straight line and not a walk along polygon centres. The graph is not replaced; it stays right for roads, corridors and docking lanes.Two limitations ship with a test pinning each. A region with a free-standing pillar loses the inner loop, so an agent walks through the pillar; and the funnel gives the shortest path through the corridor A* chose, 10.75 against an ideal 9.81 on one wall.
Steering is still refused, which is the line the reversal did not cross: a path is a function of geometry and an engine can own one, but how a character moves along it is a game's feel.
- added
The simulation never rebases; rendering does.
core/src/world/holds a sparse cell grid, a render origin that follows the camera in whole cells, and the rule that keeps them apart: simulation coordinates stay absolute and double, because a rebase would change floating-point results and therefore the replay fingerprint — only in sessions that crossed an origin boundary, which is a divergence nobody reproduces on demand.The origin moves in whole cells, since a continuously moving one re-quantises every vertex every frame and the world shimmers: measured over a 220-tick walk at the far end of the world, it moves 75 times rather than 220. A round trip through render space costs an ulp of a number under five hundred and does not grow with distance, where the same coordinate put into single precision is wrong by exactly one metre at 2²⁴–2²⁵, because single precision counts in twos there.
Cells stream by where the camera will look, not only by where it will be.
cellsInFrustumwalks the box round a predicted view's corners in double precision and keeps a cell unless it lies wholly beyond one plane: 33,554 km out, it tells a sliver a tenth of a metre inside the view from one a tenth outside, which planes held in single precision keep both of.Freezing is a simulation decision and unloading is a memory one, and
world/freeze.tskeeps them apart on purpose. A cell outside the simulated radius freezes whether or not its contents are resident, and only a frozen cell may be unloaded, so streaming can change where the bytes live and never what the simulation computes. The alternative — unloading causing freezing — makes the simulation depend on how much memory a machine had, which is a divergence that appears on one player's computer and nowhere else. A frozen cell still contributes to the fingerprint, from a digest taken when it froze, because a run that agrees only once everything has thawed is a run that disagreed for a while. What it costs is stated rather than discovered: an agent in an unloaded cell does not walk anywhere, does not age and does not finish what it was doing. - added
Which squares of the field to draw, at what detail, as one decision per patch.
terrain/src/clipmap.tstakes a camera and a patch index and answers, reading the frame and nothing else — which is what lets it move into a compute pass beside the cluster cut rather than be rewritten there.Each level's block snaps to an even patch index, which is the whole of why the levels nest: an even multiple of a level's patch span is a multiple of the next level's, so a finer block's edge always falls on a coarser patch boundary. The hole a finer level leaves is not fixed at the centre, which looks wrong and is right: levels re-centre at different moments, so culling by coverage rather than by a central hole is what removes the need for the L-shaped stitching strip the usual implementation carries. Asserted by counting: 65,536 field cells drawn, exactly, at every one of thirty-three camera positions across a coarse patch — an area equal to the footprint with no cell counted twice is no overlap and no hole, together.
A heightfield is a
DTEXlayer, and collision reads the decoded numbers rather than the source ones. Terrain inherits the format's residency, streaming and determinism instead of growing an image path of its own. The design had the requirement backwards: it asked that decoded heights match the source within the format's tolerance, which they do and which is not enough — a renderer reading the layer while a query reads the source differ by exactly that tolerance, everywhere, permanently, and a character floating by a fraction of a millimetre on every surface in the world is a defect nobody attributes. SoterrainFromHeightLayerbuilds theTerrainfrom the decoded samples and everything reads that one object.Splat weights decode renormalised, since four weights rounded to eight bits sum to between 0.994 and 1.006, and a shader that trusts the sum shades a whole hillside wrong, smoothly enough to read as lighting.
- added
A distant block of a city is one coarse mesh, and a distant tree is its own picture.
assets/src/bake/hlod.tsbuilds both offline. The proxy is not a new mesh merger —coarseLevel.tsalready emits the boundary of an occupancy grid rather than decimating a surface, which is what stops a merge growing triangles through a car's bodywork — so what is new is the grouping and the level schedule.A proxy can be refused, and that is the honest half. A grid-based merge cannot promise fewer triangles than its input, because its output scales with surface area rather than with triangle count, so the half-budget is checked and a group that is already cheap gets
nullinstead of something larger than what it replaced.The impostor's hard part is its edges. Colour is dilated past the silhouette and every tile carries a two-texel gutter, because a transparent texel left at the clear colour is what makes a distant tree glow at its atlas seams. Measured on a tree at eight directions a side: 1.0000 silhouette agreement on a baked direction and 0.9083 between two, since the blend carries no parallax correction and the fix costs the square of the direction count.
- added
DriftTR, the analytic tier, ships on WebGPU and is off by default.
quality.reconstructiontakes a ratio from 1.3 to 2.0, and anything outside it is off. The world draws at the output size divided by that ratio, jittered along a Halton sequence of eight phases for every output pixel a render pixel covers, and a compute resolve accumulates it into the output: history read bicubically where the surface was, clipped to its neighbourhood's variance box in YCoCg, refused where depth, motion or normals say it is another surface, and sharpened inside the range of its neighbours.Camera motion comes from the frame's depth; a draw that states its previous transform or skin palette is drawn again into a motion target. A contributed pass is handed the frame's jitter through
PrepareContext.jitter, and the GPU-driven pipeline and splats draw with it.Measured on the gilded chamber at 1280 by 607, three runs each: 3.08 to 3.15 ms of GPU off, 2.62 to 2.69 at 1.3, 2.42 to 2.49 at 1.5 and 2.16 at 2.
recon-parity.mjsholds the resolve's WGSL to its reference over seven frames,ghost-check.mjsmeasures a moving object under a still camera andpan-check.mjsa moving camera over a still world, and a replay fingerprints the same with it on at every ratio as with it off.A multisampled frame is not reconstructed, and says so once. The learned tier and frame generation are not in this release and are recorded as absent.
- added
The GPU-driven pipeline is opt-in, WebGPU only, and refuses rather than falls back.
createRenderer({ pipeline: 'gpu-driven' })on a backend without indirect draws throws with the reason in words, checked against the backend that will _draw_ rather than the one that was asked for — three of the paths into that decision arrive having already fallen back from WebGPU.Instance culling, a level cut, a depth reduction, cluster culling, a near-plane refusal, compaction into one draw, a visibility buffer, material binning, surface reconstruction, the lit expression, the shadow lookup, the gradient a compute invocation has to assemble for itself, the octahedral probe mapping, the DriftTexture decode interpreter and the surface frame a textured pixel is read with, and the network evaluator in single and half precision.
Every pass is written twice — once in TypeScript as the reference and once in WGSL — and
scripts/gpu-parity.mjsruns twenty checks requiring the two to agree on a real device. Until 2026-09-17 that was one short: the instance cull was checked on the device and never dispatched by the pass, so every cluster of a mesh wholly outside the view was cut and tested one by one. It is a stage of the frame now, and the eighteen scenes are 0 of 921,600 pixels against the build before it.Its scene streams, and keeps no copy of what it uploads. The pass first built its scene in a method whose constructor said the geometry does not move — and a voxel world streams chunks as a player walks and remeshes one on every block they break. Vertices and indices now go through a
GeometrySinkstraight into the device's buffers when a mesh is placed, because holding them as well doubled what a world costs and, at the voxel sandbox's radius 32, the copy was anArrayBufferChrome will not allocate. There is one path and not two: the static case is a scene filled once and never emptied, asserted to pack byte for byte the way the old builder did, because two paths would mean the draft rigs exercise the path no published scene uses, which is how a control stops being a control.Timings are a range rather than a number, because the device clock is quantised. Four captures of each rig land on multiples of about 0.065 ms and nothing between them, so a single frame's figure is a bucket and quoting one is quoting the low end of a distribution.
- added
Indirect light, as three levels that fall back to each other and never off the end.
ROADMAP.mdrefused screen-space global illumination because its error is unbounded: a ray that leaves the frame has no answer, and every technique that ships one anyway invents one from whatever happened to be on screen. This does not reverse that refusal. The screen is an accelerator with a world-space distance field behind it, whose error is bounded by its own resolution, and a probe volume behind _that_, which is never wrong and only ever coarse.traceIndirecttakes a ray and aGiResourcesand answers from the best level that can;GI_SOURCE_SCREEN,GI_SOURCE_FIELDandGI_SOURCE_PROBESsay which one did, so a caller can see the fallback rather than infer it. A scene that wants indirect light and declared no grid is given one that fits it.It is CPU-side, and reached by nobody who does not ask. There is no WGSL, no pass and no renderer option yet, so no scene renders differently for these existing — measured rather than asserted: the eight published scenes are 0 of 921,600 pixels against the build before them, and
core-onlyis byte-identical with these exported and without them. It is exported anyway, because a capability with no route out of its package is one the first consumer finds by failing a boundary test. - added
The frame's pure half.
core/src/render/frame/holds the recording arena, the resource table, the scheduler and the replay, and imports neither backend — which is what lets the scheduling be proven by assertion rather than by a picture.A node declares what it touches, by bit where a mask has bits and by identifier when it has run out of them, and a transient resource knows when it is born and when nothing needs it again.
FrameResourceandPassTimingsare the public surface; the rest stays underrender/, because nothing outside it may reach in yet and saying so is cheaper than discovering a consumer who did. - added
packages/core/scripts/gpuCompute.mjsruns WGSL on a real device and reads the buffers back, which is what lets the GPU-driven pipeline's arithmetic have a test at all. Before it, the WGSL half had nothing but a generator check that confirms a committed file matches its source — which says nothing about whether the arithmetic is right.It serves its own page from a loopback server, because
navigator.gpuis not exposed onabout:blankandhttp://localhostis a secure context. There is no dev server to start first.scripts/gpu-parity.mjsruns the passes over generated input and requires agreement: 256 spheres with 149 culled, 256 clusters with 58 selected, and a 37×21 depth pyramid reduced to 18×10 — an odd size on purpose, because the extra row and column are what a reduction gets wrong. Both answers must occur in each case, or the agreement is between two constants. The check was audited by breaking the WGSL five ways — the reversed-Z minimum, the conservative direction of the cull, the second half of the cut rule, the plane normalisation and the odd-row fold — and it caught all five. - note
This project treats an absence as a deliverable, and
docs/CAPABILITIES.md§2 names a sentinel symbol per absent row — the symbol that would exist if the work had landed — so the suite goes red on the day one arrives rather than the documentation going quietly stale.DriftCapture's three. There is no texture atlas: colour is one value per vertex, so the detail ceiling is the mesh rather than the footage. There is no delighting prior, so a grey object on a grey floor has no chromaticity to tell shading from paint. And there is no metric anchor: no clip in this record contained an object of known size, so the scale is the model's claim rather than a measurement.
Reconstruction ships its analytic tier only. The learned tier and frame generation inside the frame are not here.
Screen-space global illumination stays refused in writing, for the reason given when it was first refused: its error is unbounded. What ships instead falls back through a bounded world-space field to a probe volume.
A proof suite was planned for this release and deferred out of it, deliberately, so that what is in 4.0.0 is what has been run rather than what was scheduled.
Two things in this release have been used by one person and no more: the editor, which nobody who did not write it has opened, and the native host on Windows and macOS, which is not built at all. The Linux target is.
3.63.0 · 2026-09-14
- added
mipmaponSpriteTextureOptions, false by default. A sprite sheet is usually pixel art, where a chain is memory nothing samples and a blur nobody asked for, so nothing changes for a sheet that does not ask. A glyph page is the sheet where that default is wrong, and the comment onfiltersaid as much without offering a way out: it assumed a sheet that is not pixel art "is normally drawn near its authored size, where the two filters differ by very little".A consumer bakes one page per weight at 96 px and draws body copy at 11 to 13, which is about a 7x minification.
linearreads four texels out of a footprint covering dozens, so atcrossbar two texels tall lands on roughly a quarter of a pixel and survives or not depending on where the sample falls. A player reported it asStep-In UppercutreadingSlep-In Uppercul— and because the sample point moves with the glyph's position, the same letter survived in one word and not the next, which made the line look unevenly spaced as well as misread.Neither workaround was worth having: baking nearer the drawn size blurs the headings, because one atlas cannot serve the 8x range between body copy and a display size, and a second atlas for small text doubles the pages and the uploads and moves a sampling decision into the game where it has to be re-tuned whenever a size changes.
Measured rather than asserted.
demo/dev/glyphMip.tsdraws one glyph twelve times at deliberately different subpixel offsets, through a plain sampler and through a chain, because the reported symptom is not missing ink but inconsistent ink — a build that samples well by luck passes an ink threshold and fails this. The copies' ink has a standard deviation of 0.0130 plain and 0.0020 mipmapped: 6.6x less disagreement, and the two backends land on the same number to four decimal places.scripts/glyph-mip-check.mjsis that measurement as a gate, and it reddens with four failures when the chain is disabled.With
filter: 'linear'this is trilinear; withfilter: 'nearest'the levels are still blended, because that is minification andfilteris about magnification, which is the splitSurfaceTexturealready makes. Padding cells against bleed stays the caller's problem, since how much depends on how far down the sheet is ever sampled.WebGPU has no
generateMipmap, sogenerateMipChainandmipLevelCountare public now: the per-level blit thatSurfaceTexturehas always carried is a free function rather than a private method, andui2dcalls it. Writing the blit a second time there would have been a second shader to keep in step, and the copy that quietly disagrees about the colour space is the one that ships. It is the first runtime importui2dtakes from the engine and it was measured before it was taken: bundled alone, 11,777 against 11,163 gzipped bytes, so 614 for the blit and its shader.
3.62.0 · 2026-09-13
- added
The Android manifest template declares one activity and no permissions, and that default is right: a permission is visible in a store listing, and a packager that granted the network to every game it ever built would be asking on behalf of games that never use it. The difficulty was that a consumer had no way to depart from it. The template is copied wholesale, none of the eight
-Pdrift*properties Gradle takes reaches the manifest, there is nomanifestPlaceholdersand no source-set overlay, and--resourcesmoves the desktop shell and the iOS spec along with the Gradle project — so changing one line of one manifest meant vendoring all three.So an online game shipped an APK whose process is not permitted to open a socket. Every connection failed, the game reported that it could not connect, and the relay logged nothing at all because no packet left the phone: the same silence at both ends, indistinguishable from a wrong address. Measured on the built artifact,
aapt2 dump permissionsprinted the package name and nothing else."android": { "permissions": ["INTERNET"], "cleartextTraffic": false }"INTERNET"and"android.permission.INTERNET"are both accepted; a name that would break out of the XML attribute it is written into is refused at parse time. The elements are written into the copied Gradle project before Gradle runs, which is where the icon is already written, so nothing in the engine's own tree is touched by a consumer's build.doctorprints what was asked for beside the signing mode, and saysnone — the APK cannot open a socketwhen nothing was.cleartextTrafficis the second half, and it is the one only the engine could answer. The game is served fromhttps://appassets.androidplatform.net, which is a secure context, and a WebView defaults toMIXED_CONTENT_NEVER_ALLOW— stricter than a browser tab, where the same insecure WebSocket connects with a deprecation warning rather than being refused, measured on Chrome 151 against a LAN address. Setting it true writesandroid:usesCleartextTraffic="true"and is whatMainActivityreads back throughNetworkSecurityPolicyto allow mixed content in the WebView. One switch drives both, so the platform and the renderer cannot disagree about it, and a network security config a consumer adds later is picked up without the Java learning about it. A relay on a LAN cannot hold a certificate, which is the case it exists for; a public relay should bewss://and this should stay false.Verified on real APKs with
aapt2: unchanged, the dump is the package name alone; asking, it carries both permissions and the cleartext flag. Held by twenty-nine cases, and seven mutations each redden at least one.
3.61.4 · 2026-09-13
- fixed
dist/is gitignored, so a tarball carries whatever the publishing machine last built, and nothing hooked the build to the publish or compared the two. 3.61.3 went to the registry withsrc/render/backend/webgpu/insetPass.tscarrying the inset depth fix anddist/render/backend/webgpu/insetPass.jscarrying the bug it fixed.main,typesand the defaultexportscondition all resolve todist, so an ordinary consumer installed the defect under a version number that claimed to have cured it, and only a consumer reading thedrift-sourcecondition got the fix.A published version is immutable, so the answer is this release rather than a correction of that one. 3.61.3 should not be used: for the inset fix, take 3.61.4.
Invisible from inside the workspace, which is what it has in common with the missing files 3.61.2 fixed. A path dependency resolves through a symlink into the tree, where
distis whatever the last local build left behind; only an install from the registry can show either defect, and by then it is published. That is the whole argument for gating both at the tarball.Every package now carries a
prepackthat builds it, so the tarball is built the same way whoever publishes it invokes npm, andpackages.test.mjsasserts every package declares one.prepackrather thanprepublishOnlybecause npm runs it fornpm packtoo, so the tarball a gate inspects is the tarball a consumer installs. Held by staling a builtdiston purpose and packing: the file inside the tarball comes out rebuilt.
3.61.3 · 2026-09-13
- fixed
beginInsetclears its rectangle with a drawn quad, because WebGPU clears whole attachments and cannot confine one to a scissor. That quad is drawn withdepthCompare: 'always'and depth writes on, so whatever clip-space z it carries is stamped across the entire inset. It carried1.0, under a comment calling that the far plane.That is true of a conventional depth buffer, and this engine reverses depth:
REVERSED_DEPTHis on, the far plane is 0, and the compare isgreater. So the quad wrote the nearest possible value into the box, and every mesh drawn betweenbeginInsetandendInsetfailed the depth test against it. The inset came out holding its clear colour and nothing else, on WebGPU only, with nothing logged by either the API or the engine.It is the same mistake
glslFarDepthwas written for after the sky made it: a full-screen triangle atz = wis the far plane conventionally and the near plane once reversed. The sky painted over the world, which is loud. An inset paints over nothing, which is a black box on a menu and reads as a draw that never happened.What kept it alive is a stale note in
AGENTS.md, which said overlays drawn afterendFramevanish on WebGPU. They do not:openPassopens an overlay pass against the presented swap view with its own depth, so a consumer can draw its interface after the present and escape the screen-space chain. Measured ondemo/dev/overlay.ts, text and an inset both land under?after=1on both backends. The note gave the empty box a sanctioned explanation, so everybody who looked at it, including the demo written to investigate it, stopped there. That entry is corrected and replaced with the trap this actually was.Held by a pair of cases that read the z out of the shader the pipeline compiles and compare it against the convention rather than against a literal, so the one way this comes back, flipping
REVERSED_DEPTHwithout the quad following, is the way they fail.
3.61.2 · 2026-09-13
- fixed
packages/core/scripts/had never been published, thoughAGENTS.mddocuments importing@driftengine/core/scripts/browser.mjsand a consumer does exactly that in eight files. It ships now, along with thescripts/ofpackage,physicsandscript.\n\nThe gate matters more than the fix.packages.test.mjsnow asserts that every file a package tracks reaches its tarball, excluding tests, snapshots and build configs. None of this was visible from inside the workspace: a path dependency resolves through a symlink into the tree, where every file exists whateverfilessays, so the defect could only appear once somebody installed from the registry. Three packages shipped broken in 3.61.0 for exactly that reason.
3.61.1 · 2026-09-13
- fixed
filesdropped four directories and two data files on the way to the registry.@driftengine/packageshipped withoutbin/,assets/,android/andios/— so thedrift-packagecommand its ownbinfield declares was not in the tarball and the package could not run at all.@driftengine/scriptshipped withoutcapabilities.json, which the DriftScript Vite plugin resolves through@driftengine/script/capabilities.json, so a consumer compiling.drsfailed at config load.@driftengine/corenow carriesCHANGELOG.json, which a consumer imports to render engine release notes.\n\nNo engine code changed. The manifests did: thefilesarray was rewritten when these packages were prepared for publication and it lost entries that had been there all along. Use 3.61.1.
3.61.0 · 2026-09-12
- fixed
A shader that will not link is not a slower frame, it is no frame at all. The lit fragment stage declares twenty uniform arrays sized by the point-light budget and fourteen by the area-light one, and GLSL ES gives an array a whole row of the uniform grid per element whatever its base type — so
uniform float uLightRadius[16]costs the same sixteen rows avec3array of sixteen does. At the full budget that is 440 rows. An Adreno 740 offers 256, and WebGL2 guarantees any conforming implementation only 224.On that part the program did not link, the constructor threw, and an application that awaited
createRenderergot no renderer, no reason and no frame — the page's background colour for as long as a player was willing to look at it, with the boot badge covering the first second of it. The only lever there was is switching point shadows off, and it is not enough to be a fix: it pays a whole feature on every part under 440, and the lit path without point shadows is still 248, so it could not reach a conforming device at all.The budget is a build-time size now. The renderer counts the shader it is about to compile — from the source, not from a constant somebody measured once — against what the device reports, and builds at the largest budget that fits: 8 lights and 2 rectangles is 252 rows with point shadows compiled in, and 4 and 1 is 158, which is inside what every conforming device guarantees. A refused link steps down a rung and tries again rather than propagating, and a shader that will not link at any budget throws a sentence carrying the device's own numbers instead of a bare driver string.
maxLightsandmaxAreaLightsare the ceiling for a consumer who wants to choose, andrenderer.shadedLightsreports what was resolved. Size aPointLightBufferfrom that number: the selection evicts the weakest when the buffer is full, so a wider buffer leaves an arbitrary subset lit rather than the nearest ones. A wider upload is otherwise harmless, measured on ANGLE. WebGPU is unaffected and reports the full budget: its lit block is about 7 KB against a guaranteed 64 KB binding size, and there is no per-stage uniform-vector ceiling to be short of.
3.60.2 · 2026-09-10
- fixed
{ ...DEFAULTS, ...given }is the obvious spelling and it destroys the default it was written to preserve. A spread copies every own key, including one whose value is explicitlyundefined— so a caller writing{ retractLambda: settings.lambda }, where that value may be absent, does not fall back. It overwrites the default with nothing, and what comes out of the arithmetic downstream isNaN.Measured on the boom: a timing of
{ retractLambda: undefined }takes the arm's fraction from 0.96 toNaNon the first step, which is a camera that never recovers. The same shape was in the resolution governor's limits and in a prefab's component overrides, where it writesundefinedinto a field that holds a number.Nothing in this repository could see it. The flag that separates “absent” from “present and undefined” is
exactOptionalPropertyTypes, and this tree does not run it — a consumer that does reported the diagnostics, forty of them across this source, and every one reads as harmless because the receiver almost always uses??. The three that merge with a spread are the ones that are not.The merge is a function now rather than a spread, and it keeps a falsy value that somebody chose: zero, an empty string and
falseare values, not absence. One implementation, for the reason the boom's own header gives about the arm it smooths — a third caller must not be able to get this wrong again.Held by seven cases. Two fail when the function goes back to a spread, one when it treats falsy as absent, and one when the prefab override does.
3.60.1 · 2026-09-08
- fixed
Three windows move independently and a session lives inside all of them. Redundancy says how far back every packet reaches, the rewind loop's depth says how far back a correction can reach, and the session forgets inputs below the oldest tick that correction window covers, every tick. The guard that skips a repeated input asked the log whether the tick was already confirmed, and the log can only answer for the window it still holds. So on any session whose redundancy reaches further back than its rewind depth, the oldest word of a packet names a tick retention has just dropped, the answer comes back “never seen”, and a link that lost nothing ends the match.
Reported from outside on the eleventh frame of every match, with no loss, no jitter and 60 ms of one-way delay. Reproduced here on the same tick the report names.
The judgement now rests on what this world has applied rather than on what the log can still say. A tick at or below that mark had every participant's real input and was stepped with it, so a second copy cannot change the world and cannot be evidence of a divergence. Above it nothing changes, halt included, and there the halt is right: an input this peer never had and can no longer apply really is fatal.
That mark is kept rather than asked for, because the obvious version of this fix has a hole in it. Reading the session's live
confirmedlooks equivalent and is not: when a peer goes quiet for longer than the rewind window is deep, retention walks past the last tick everybody had an input for and the live figure falls to nothing, while the world built from those inputs is still the world. The peer's first packet back then carries exactly the repeat this fix is about. Held by three cases over a hand-driven transport, one of which fails against the live figure and passes against the kept one.Nothing about the numbers has to be arranged: redundancy is chosen for the link and the rewind depth for the game, and the README now says so instead of leaving a consumer to find it by having a session halt on a perfect connection.
3.60.0 · 2026-09-05
- added
Splitting a command into an along-up part and a tangent part is right for a stick, and only for a stick. Two axes of input have to become three of movement somehow, and the plane the body is standing on is the sensible place to put them: it is what makes a walk up a ramp a walk rather than a jump, and it is why the split has never been optional.
It is the wrong arithmetic for a caller that has already done the resolving. A body that walks on walls computes its own direction in three dimensions, against a support frame the controller cannot see, and the along-up component of that direction is thrown away before the acceleration curve ever runs. Reported from outside on a body crossing an inside corner: 8.5 degrees of the turn arrive in the first tick where the whole command was already correct, and a body with nothing under it holds a stale tangent for 21 ticks of 90. Raising the acceleration does not fix it, which is the tell that it is not a rate problem: the reporter measured it unchanged at fifteen, sixty, two hundred and forty and two thousand times the acceleration, because the vector being approached is wrong, not the speed of approach.
ControllerInput.projectMove: falsesteers the whole velocity toward the whole command. Everything else stays: the speed clamp, the separate acceleration and deceleration curves,airControl, and gravity, which is added after the steering in both paths and so is untouched by either. The default is the projected path, unchanged for every existing caller and asserted as such, by a test that gives a body a command straight up and requires the result to equal what the same body does when given no command at all.Held by five cases, one of which fails when the branch is made unreachable.
- added
The boom's timings are stated in metres a second, which is the unit a crane is specified in and the wrong one for an arm shorter than a hand. The ceilings exist so a boom asked to give up its full length cannot lunge; on a 12 metre arm they bind and that is the whole point. On a 170 mm arm neither ever binds, only the damping acts, and the trade the ceilings were chosen for is not the trade being made. Easing into a wall costs a big rig a frame or two of looking through a post, which reads as a lens artefact. Reported from outside on a subject 46 mm across: a lip at 80 mm along a 170 mm arm left the eye inside masonry for 78 ticks of 120, worst 71 mm past it, which on a subject that size renders the inside of the wall. A black screen with the game running perfectly behind it.
boomTimingis those four numbers, andInfinityis the immediate answer with no special case to find: damping islerp(a, b, 1 - exp(-lambda dt)), so an infinite lambda returns the target exactly and an infinite ceiling leaves the clamp unbounded. A rig that must come out of a wall on the tick it touches one asks for that on retraction and keeps the easing on the way back out, which is the half that stops the picture pumping. Reproduced here at 48 ticks and 71.3 mm on the defaults, and zero with the immediate timings.Second, the rig collided against a type its caller did not have. A world built on the physics package exposes no collider set, so using the rig at all meant maintaining a second copy of the world for the camera alone. A
BoomObstructionis a fourth constructor argument, defaulted, taking a segment and a radius and answering in metres or with nothing. It replaces the collider set rather than joining it: a caller with a world query has the whole world behind it, and a set of boxes beside that would be a second, partial answer to one question.Third, the roll was damped twice. A caller with a comfort setting damps its own roll and applies the same value to a first-person view, where nothing damps it. Damped again by the rig, the setting means one thing in one view and something else in the other.
dampRoll: falsetakes it as given on the tick it is given.Held by seven cases. Four fail when the injected query is ignored, one when the timings are ignored, one when extension is made as eager as retraction, and one when the roll is damped regardless.
3.59.0 · 2026-09-05
- added
The boom's up became a parameter, then its aim followed that up, and the forward was still carried. Carrying is the right default and stays the default: it is what makes a body walking from a floor onto a wall onto a ceiling continuous, and deriving forward from a fixed reference would put a pole wherever up lined up with that reference — the arm swinging through a half turn as a subject crossed it, on a rig whose whole job is that the view does not snap.
What carrying cannot say is "behind this". A yaw of pi puts the eye opposite the carried forward, which on the first frame of a run is world Z re-projected: an axis the caller never chose and could not read. Reported from outside as a subject spawning face-on to a wall and the camera sitting beside it, which reads as a shot framed at random — and as a caller unable to work out how far to roll for the support surface to sit at the bottom of the picture, because that answer depends on where the arm is and the arm's azimuth was measured from an axis nothing reported.
setBoomForward(x, y, z)orthogonalises against the current up exactly as the carried one is, and every latersetBoomUpre-orthogonalises it the same way — a heading is a direction in the world and the frame it lands in is the surface's. A caller that never calls it carries its forward exactly as before.A zero-length vector, or one parallel to the up, keeps the frame it had rather than falling back to a world axis. That asymmetry with
setBoomUpis deliberate: that method must produce some forward because the up is what changed, and here the forward is the argument — answering with a direction the caller did not ask for is the failure this exists to fix.The frame's forward is readable now for the same reason: both setters orthogonalise, so what the rig holds is rarely the vector it was handed, and a caller computing a roll needs the one the arm is actually built from.
3.58.0 · 2026-09-05
- fixed
A rotation read out of a scaled matrix is a different rotation, not the same one at a different length.
quat.fromMat3recovers a rotation from the matrix trace by way ofsqrt(trace + 1); scaling the matrix scales the trace while that+ 1stays put, so normalising the result afterwards only fixes the length of something already pointing the wrong way. Measured against the maths library alone, a known rotation scaled and read back: 13.98 degrees out at scale 0.5, 61.59 at 0.007132, 22.89 at 140.2. Nothing but exactly 1 is close, and neither direction is safe.The solver's two steps differed in one way. The bend transforms an axis through the parent frame and normalises it, so a uniform scale divides out and it was always correct. The aim read quaternions straight out of that frame. Split that way the failure says exactly where it is: reported from outside as a chain coming out the right length to a part in ten million and pointing 108.8 degrees wrong, on an animal 46 mm long whose model is authored in metres.
The parent frame's columns are normalised now, at the one place both steps read it. A hierarchy at unit scale divides by 1 and gets precisely what it got before, which is why no test here saw this: a rig authored at the scale it is played at has determinant 1, where the recovery is exact.
The consumer that reported it was running twenty-four passes of its own cyclic descent per limb instead, measured at 4.41 ms a tick against 2.45 ms for the closed form — 44% of its body step spent grinding out an answer a triangle already has.
- added
Two components span the tangent plane only while up is world Y. Giving the controller an arbitrary up left its input two-dimensional in a fixed world plane, so a body that could stand on a wall could not be steered along one: on a wall whose normal is +Z, up the wall is +Y and no pair of the old two describes it — the input plane collapses onto a line.
The reporting consumer had given up on the feel layer entirely to get that axis back, writing the velocity components directly each tick with zero acceleration and deceleration so the controller would leave them alone. That keeps the sweep, the step-over and the contact resolution, and throws away the acceleration curves, the air control and the speed clamp — and every consumer with a non-vertical up would have done the same.
Optional, so no caller changes and nothing that implements the interface breaks. Absent means zero, and zero is arithmetically what a caller got before this existed: the clamp, the projection and the approach are the same three lines with a third component in them.
- fixed
A half-migration rather than a defect in either half. The arm moved into the boom's basis when that basis was added; the aim stayed on the world axes, and the two agree only while up is world Y. With up at +X an arm built at yaw 0 lies along the frame's forward while a camera at yaw 0 looks down world −Z — a right angle apart. What it rendered was the scene beside the subject, with the subject nowhere in it and no error anywhere.
The aim is derived from the arm now, so the signature is unchanged and nothing outside has to learn the convention. The decomposition is the analytic inverse of what the camera builds from its three angles: the arm fixes yaw and pitch, and the roll is the angle from the basis those two would build onto the up the arm was built against, with the caller's damped roll added on top.
A rig that never sets a boom up reads back the three numbers it was handed, to within 3e-17 radians on yaw and pitch and 8e-13 on roll — a hundred-millionth of a pixel across a 1080-line frame. Every existing consumer is in that case.
The consumer that reported it was recovering the arm from where the rig had put the eye and inverting the rig's own boom formula from outside it, against a sign convention nothing stated — and got the sign backwards first, which renders as a plausible picture of the wrong thing.
3.57.2 · 2026-09-05
- fixed
A closed-form solver has nothing to converge, so a chain that reached its target on the second call and not the first was the symptom of an angle applied about the wrong axis.
The mid joint is bent by the difference between the interior angle it has and the one the triangle wants, and that produces the angle asked for only when the rotation happens in the plane the angle is measured in. The axis used was the bone crossed with the pole hint, which is perpendicular to that plane exactly when the pole lies in it — so the wrong axis was right by accident for every pole that happened to sit in the limb's own plane, and wrong for every one that did not. A knee's pole points where a character faces; the plane its leg is bending in is wherever the animation left it, and the two coincide by luck.
Off the plane, the bend turned the bone out of the triangle instead, the chain came out short, and the aim step then placed the tip on the right ray at the wrong distance. Reported from outside against a crouching leg. Held now by a fixed rig with a 0.46 m thigh, a 0.44 m shin and a pole off the plane: 0.028 short of its target before, under a micrometre after.
The axis is the chain's own plane normal now. The pole still chooses the bend for a chain that is straight, which is the one case with no plane and the only case it was ever needed for.
- changed
Rewind owns state, and an effect is not state. Correcting a tick restores the world and steps forward again, so a step runs once when it is first predicted and again after every correction reaching back past it — and everything it emitted the first time is emitted again. Snapshots put a world back; they cannot un-play a sound.
Reported from outside as a doubled impact on a corrected frame. Nothing in the rewind surface warned about it, which is the gap: replaying a tick is the whole mechanism, and a step that is not re-runnable cannot be part of one.
The engine does not deduplicate, and the note says why: knowing what counts as the same emission means knowing what an event is, which is a game's vocabulary and not an engine's. What it does now is name the hazard, point at the two answers — keep effects out of the fixed step, or gate them on a small table of what a tick already emitted — and name the watermark such a table is cleared behind.
Also documented: fixed-rate capture paces to whatever rate is asked for. Every sentence about it named 60, which reads as a limit and is not one; capturing at 60 and discarding every other frame pays a full render per discarded frame for a rate that was available.
3.57.1 · 2026-09-05
- fixed
PhysicsWorldkeeps one core back for the thread that steps the world, so a four-worker request on a two-core machine grants one. That is right. What was wrong is thatparallelismthen reportedrequested: 1, running: 1, reason: ''— every field agreeing with every other, and none of them saying three quarters of the request had been dropped.Its own type documents
requestedas "workers the consumer asked for" andreasonas "empty whenrunningmatchesrequested", and the code met neither. This is the silent clamp that whole surface exists to prevent: its note says to read it *rather than assuming theworkersoption took*, and on the most common shortfall there is it had nothing to say.requestedis now what was asked for, and a clamped pool carries a sentence naming both numbers and the machine's own core count. A pool that could not start at all keeps its own better sentence.Found by continuous integration rather than by a local run, which is the other half of it. A build character has two cores and the machine this was written on has twenty-four, so the clamp never bit here — and the tests that asserted the documented meaning had been red on every push since the pool shipped. They pin the core count now, so they measure the pool rather than the character they happen to be on, and two new cases pin it low to hold the clamp itself.
3.57.0 · 2026-09-05
- added
Volumetrics were a body inside a hull and nothing else.
drawLightVolumemarches a cone you place, so a shaft through a window works and fog filling a room does not;atmosphere.tsfades a surface toward a colour by distance, which dims a wall behind a doorway and cannot put the doorway's shape on the floor. Distance fog asks how much of a surface survives the journey. This asks what the journey adds, which is every point along it the sun can see.setGlobalMedium(density, albedo, anisotropy, maxDistance)is the per-frame dial andglobalMediumStepsthe construction-time ceiling — the splitbloomandsetBloomalready draw. Atdensity: 0no target is allocated, no program is compiled and no composite is drawn, so a game that never asks for weather renders the frame it rendered before this existed.globalMediumStepsis 0 by default and a partisWeakGpuFamilynames is clamped to 16, because a march is the only ceiling inRenderQualitythat is a per-pixel loop.One GLSL source for both backends. The march writes scattered light in rgb and transmittance in alpha, so the composite is
(ONE, SRC_ALPHA)— the transfer equation for a segment written as a blend state, which means the pass never samples the colour it is modifying. Half resolution behind a depth-aware upsample, because a homogeneous medium is smooth everywhere except at a silhouette and that is the one place the upsample is careful.Samples are spread as distance squared along the ray, and evenly spaced ones were measured before they were changed: a 200 m ray at 32 steps puts six metres between samples and the shadow dither then moves each by three, which photographed as coarse salt and pepper over the brightest part of the frame. Everything a march has to resolve is near.
scripts/medium-check.mjsmeasures six claims on both backends, each written against the deletion that breaks it, and the six deletions were run. Extinction is checked against whatexp(-sigma d)predicts rather than against a threshold: 1.41 measured against 1.41 predicted on both backends. - added
Two formats share the name, and the distinction is the first thing to know. The self-organising-Gaussians paper is a technique for sorting Gaussians onto a grid so image compression can carry them; its released scenes decompress to
.plyand it documents no on-disk structure. What capture tools write is the PlayCanvas container, version 2, and that is what this reads.The WebP decoder is a parameter rather than a dependency. Node has none and this package will not vendor a thousand lines of VP8L to pretend otherwise, so the contract is declared and
browserWebpDecoderis the ordinary implementation — the arrangementKeyValueStoreandBrowserStorealready have.unbundleSogreads the ZIP through its central directory, which is the only place the sizes are:splat-transformwrites streaming, so every local header carries a size of zero.Checked against a bundle
@playcanvas/splat-transformproduced from sixty-four Gaussians chosen in this repository and decoded by Pillow, so nothing in the loop checks itself. Every field lands inside the quantiser's own resolution: position 1.1e-4 m over a four-metre cloud, opacity within one eight-bit step. - added
A 2D canvas stores colour premultiplied and WebCodecs decodes a WebP with alpha to a premultiplied
BGRAframe, so both return every RGB value throughround(round(c * a / 255) * 255 / a). For a picture that is invisible. For an image used as a lookup it is a corruption: a.sogcapture keeps three codebook indices in RGB beside an opacity in alpha, so a premultiplying read lands the index several entries away wherever a Gaussian is transparent and the colours come back tied to the capture's own transparency.Measured on a lossless WebP whose values are known: 3 of 255 out through either route, with every deviation predicted by that expression, against 0 through a texture upload with the unpack flag off.
scripts/sog-check.mjsis what found it, which is the argument for a check that runs the decoder a consumer actually calls.It costs one pipeline stall per image, which Chrome logs and which is paid at load rather than per frame.
- added
How thick the air is is a fact about the frame, so it is a dial; how expensive a march may be is a fact about the device, so it stays out.
mediumtakes every argument the engine defaults, for the reasonfocusgives about its ownscale: a script author is further from the cost than a TypeScript caller, and a defaulted albedo is a number somebody has to go and look up before they can tell what their fog will look like. - changed
Nothing in the engine reads this yet and the row is still open, but four attempts had concluded the fit was not worth having and that conclusion is wrong.
The objective was the blocker. The search fitted a transformed cosine to the lobe over a hemisphere and was judged on how well it integrates a rectangle, so it chose matrices that won the first and lost the second — with the analytic answer offered as a seed at every entry and rejected on score. Fitting against the polygon integrals directly is affordable because a reference does not depend on the candidate: the brute force runs once per entry, and every evaluation afterwards is six closed-form form factors.
And the comparison was wrong. A relative error against a reference of 0.000204 is not a bound, and the analytic term's headline 85.7% is exactly that cell — where the rectangle covers two hundredths of one per cent of the lobe. Every figure is now reported twice, over the grid and over the cells carrying at least a twentieth of it.
What is left is the storage: read back through a degree-5 polynomial the good table is worse than doing nothing, and degrees 6 to 10 are worse still. These are data that can only be shipped, which is what the row said at the start, and a 32x32 table of five values is 562 gzipped bytes at 8 bits.
Two tooling fixes came with it and are recorded here rather than as their own entry, because neither changes anything a consumer can observe. Writing the fitted table out read
fits.m00where the fits are keyedk00, so that flag threw for as long as it existed and nobody had run it. And a fitted table can now be read back instead of refitted, so a polynomial degree is a sweep of seconds rather than a four-minute refit each — which is why the third attempt's conclusion that a degree-5 surface cannot carry these terms had never been tested against a degree-7 one.
3.56.2 · 2026-09-05
- fixed
A bundler emits a worker before it decides the code is unreachable. Vite's
vite:worker-import-meta-urlrewritesnew Worker(new URL(...))at transform time, so the barrel re-exportingcreateIslandPoolput the island worker in the module graph, and the chunk was written to disk whether or not anything could reach it.Reported by a consumer and reproduced on a second the same day: 37,913 bytes raw, 12,259 gzipped, in builds where
WorkerPoolExecutor,StagedExecutor,createIslandPoolandSolveStagewere absent from every other chunk and nothing underdist/named the worker. Tree-shaking had done its half and the asset was already there.This is the same decision 3.54.0 made with half of it missed. That release moved the factory to the call site rather than have
PhysicsWorldreach it, to avoid "2,629 bytes gzipped into every consumer's bundle, including everyone who never asks for a worker". The half that was missed weighs 4.7 times the half that was caught, and landed on exactly the consumer that reasoning was written to protect.createIslandPoolandWorkerPoolExecutorare at@driftengine/physics/src/workers.tsnow. A caller already had to name the factory for 3.54.0's own reason, so the cost is the specifier and nothing else. The types stay on the barrel, because a type import emits nothing.Every gate in this repository was green while it shipped, including the one that asks whether a worker compiles.
worker-entry.test.mjsnow bundles each package barrel and asserts none of them reaches a worker construction, which is the question a consumer's bundler was asking. Verified by putting the re-export back and watching it go red, and by rebuilding a real consumer and watching the chunk disappear.
3.56.1 · 2026-09-05
- fixed
Every gate in the documented list was green and
npm run buildwas not.@driftengine/xrshipped without atsconfig.build.json, so the Node build answeredTS5058on it whiletypecheck,test:scriptsand 4,394 tests all passed. The build is deliberately not in that list, because a consumer that bundles resolvesmaintosrc/index.tsand needs no build at all, and running eighteen of them on every change would buy nothing most days.What the absence costs is cheap to catch without paying that: a missing file is not a compilation error.
packages.test.mjschecks every package has the config, beside the check that every package has a README. Whether a build succeeds is stillnpm run cleanroom's question. - fixed
This is the failure the AI bridges' own release notes described, committed one release earlier in the same repository.
docs/CAPABILITIES.mdcarriedworker-pool class WorkerExecutor|workerIslandPool, and 3.54.0 shipped the class asWorkerPoolExecutor.class WorkerExecutordoes not matchclass WorkerPoolExecutor, so the documentation gate stayed quiet, the row was never rewritten, and README's gap list has advertised a hole the engine filled ever since.The sentinel is removed and the gap list is rewritten. The lesson is the one already written down: name the export to match the pattern deliberately, so the guard fires and forces the edit, because a name chosen around a sentinel lands the capability and leaves the guard silent.
- changed
The section listed a worker pool that shipped in 3.54.0, a fitted LTC table that came off the list on 2026-09-04 after four attempts, and two empty headings where WebXR used to be.
What it says now is what is true. Two rendering rows are genuinely absent and neither is scheduled: screen-space GI, and the fitted LTC table, which is priced with the measurement that stopped it — the analytic matrix scores 19.5% against the fitted table's 75.2% on the placement the feature exists for, because the search fits a lobe over a hemisphere while the term integrates a polygon.
And two things are written and have never been run: WebXR against a headset, and an iOS build on a Mac. Those are named in prose rather than listed as gaps, because the section holds gaps only and the code exists. Neither carries a sentinel, since a sentinel guards a symbol that would exist if the work had landed and both symbols are already here.
3.56.0 · 2026-09-05
- added
Track I, the last one, and the only one that started from nothing. No
requestSession, noXRSessionand no stereo path existed anywhere in this repository before it.enterXrtakes a session, builds a layer, asks for the best reference space it can get, and hands back a frame source.local-floorfirst andlocalsecond, becauselocalputs the origin where the headset started and stands a player's feet wherever their head happened to be.Both layer paths are built.
XRGPUBindingwith a projection layer where a runtime has one,XRWebGLLayerotherwise, chosen from what the session offers rather than from a preference. A WebGPU layer that fails to build falls through to WebGL2 with a sentence saying so, because a game running on WebGPU in a browser whose WebXR is WebGL-only should enter a session rather than refuse.A hand joint that stops being tracked keeps its last pose. A tracker loses joints constantly as fingers occlude each other from the cameras, and zeroing one collapses that finger onto the wrist for a frame and snaps it back on the next. The flag says the pose is stale; whether to hold, fade or hide is a game's decision and this package refuses to make it.
Controllers are read by the gamepad profile's names rather than indexed at every call site, and a device without a given button reads zero rather than reporting its neighbour's.
- added
The camera had to learn to be supplied rather than configured, and it had already written down why.
updateMatricesderives the view from yaw and pitch and the projection frommat4.perspective, and a note beside it records the corollary for an entirely unrelated reason: *only a centred crop of a symmetric frustum is itself a symmetric frustum; an off-centre one is sheared, whichperspectivecannot produce.*A headset's eye projection is exactly that shear, off-axis by the distance from the pupil to the display's centre, and its view comes from a pose no pair of Euler angles was asked to describe.
So
adoptViewcopies both, recomputes the combined and inverse matrices so frustum culling, picking and the sky keep working unchanged, and decomposespositionandforwardback out of the view. That last part is not a convenience: a consumer readingcamera.positionto place a listener would otherwise get whatever the last flat frame left there, which is a defect that presents as an audio bug rather than a camera one.yaw,pitchandrollare deliberately left alone, because a supplied rotation may be one no Euler triple describes without a convention the class does not own.startLoopgained an optionalframeSource, so a session drives frames on the headset's clock through its ownrequestAnimationFrame, and the frame reachesrenderas a fourth argument typedunknown: core has no business naming anXRFrame, and an implementation written before the parameter existed still satisfies the interface. - added
Whether a session is presenting, where the head is, what each hand's trigger and grip are doing, where a named joint is, and whether a hand is pinching.
None of them is deterministic, and that is the property rather than a limitation. A head moves because somebody moved it and a controller reports where a camera saw it, so a
@deterministicsystem reading either would take the other branch on replay and a stored run would stop meaning anything.drift/uisettled the same question for a pointer and the reasoning transfers unchanged.None of them is a matrix. An eye's projection is a fact about somebody's optics and its view is a fact about where their head is in a room the script has never seen; handing either over would be
drift/render's refused dial read in different clothes. Drawing the two eyes is the host's, because the host owns the renderer.Numbers answer
f32rather thanfloat, because the language refuses a width-polymorphic return with nofloatparameter to fix it against, and every position this engine stores is aFloat32Array.Two gates had floors requiring at least one refusal so the list could not silently drain. The list has legitimately drained, so both were repaired rather than satisfied: they compare the two lists in both directions now, and the extraction behind them is watched producing a value from a sample, so an empty answer means an empty document rather than a broken reader.
- changed
Measured before any of it was designed, in Chrome 151 on Linux with no headset:
navigator.xris present,isSessionSupported('inline')is true,requestSession('inline')succeeds,requestReferenceSpace('viewer')succeeds, and thengl.makeXRCompatible()throwsInvalidStateError. Without that call there is no base layer, and without a base layer a session delivers no frame at all. Every promise along the way was kept and nothing could be drawn.npm run check:xrasserts the thirteen things that machine does reach: detection, the specification's classes, a real inline session requested and ended with a reference space granted,XRGPUBindingpresent under--enable-experimental-web-platform-features, and every refusal path. Those refusals are the common case rather than a consolation prize: an XR button is pressed on machines without headsets far more often than on machines with them, and one that throws, hangs or says nothing is the defect a consumer's users actually meet. It caught one such defect, which no unit test saw: the support probe reported a mode message ahead of a compatibility one, so a machine whose context had been offered and refused was told only an inline session was available.Everything past the first frame is exercised against a synthetic runtime, which stands in for a browser API rather than for an engine capability. It cannot prove a real runtime accepts what the engine hands back.
Unmeasured, and carried as an open row rather than implied away: a real immersive session, stereo on hardware, controller input from a device, hand tracking, device performance, and the compositor's reprojection.
3.55.1 · 2026-09-05
- fixed
A consumer could not build at all, and every engine gate was green.
workerPool.tsbuilds its worker withnew Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' }), which Vite, webpack and Parcel detect statically and compile as a worker whether or not the game ever starts a pool. Vite's defaultworker.formatisiife, and an IIFE cannot carry a top-level await:[vite:worker-import-meta-url] Module format "iife" does not support top-level await
The entry had
await import('node:worker_threads')at the top level so one file could serve both a browser worker and Node.typecheck, the suite,test:scriptsandcheck:poolwere all green over it, becausecheck:pooldrives a dev server and a dev server serves module workers. The first thing to see it was a consumer'svite build.The Node branch attaches its listener in a microtask now. Nothing is missed: a Node
MessagePortqueues messages untilon('message')attaches, so apostMessageissued before it resolves is delivered when it does.scripts/worker-entry.test.mjsis the gate, and it asserts the constraint rather than a proxy for it: every file this engine constructs as a worker is compiled with esbuild at--format=iife, which is the same question a consumer's bundler asks. A test forbidding the stringawaitwould have passed a worker that broke an IIFE some other way. Entries are found by readingnew Worker(new URL(...))calls with comments stripped, so a second worker added later is covered without anyone remembering to list it, and the scan asserts it found at least one.
3.55.0 · 2026-09-05
- added
The first tool in this package whose
admitssays something. A buffered intent is a proposal authored against one snapshot and executed later — the README calls it "a proposal, never a decision" — and until now the strongest guard a consumer could write was "the entity still exists". A bridge that dropped, a door that closed or a region that streamed out makes a destination unreachable in between, and navigation is the first subsystem here that can say so.A factory over a three-function adapter, because the engine owns the graph, the A* and the follower while the consumer owns what an agent is.
tools/registry.tssays a guard is "the consumer's sentence, written against the consumer's world", and that stays true:graphOf,positionOfandpathOfare theirs and everything above them is the bridge's.It costs three searches for an intent that is accepted and applied, and the third was a correction found by wiring the demo rather than by thinking:
takeguards the buffered intent,applyCommandguards it again on the way in — whichapply.tsargues for at length, because those are two different moments and a snapshot is never authority — andexecuteruns a third. Caching a route between any two of them would key it on nothing stable, since the world changing between them is the entire reason the guard exists.reachableByasks the same question without acting, which is what a policy scoring a destination needs and whatroutecould not give it. - added
A model is not a function of the simulation: it is slow, variable and metered, so two peers running the same agent loop over the same world do not agree. A decision is taken on the authority and reaches a participant as a fact it does not re-take.
The design got smaller by reading what was already here. Its spec proposed replicating a
u16intent id on Track J's input path, sinceinputLog.tsis fixed-width per participant per tick and refuses variable-length payloads in writing. ButCommandLogalready records what a model decided and when it crossed, andReplaySessionalready reads that log, recomputes floor intents and calls no provider, ever. So a participant is a replay whose log arrives over a network instead of from a recording, and a rewind is a replay of the same log: one mechanism, two reasons for reaching it, and no second implementation.What is left is the part that is genuinely about networking. Which side decides — asked every tick through a function, because authority can move and a wrapper that cached the answer would keep deciding after it stopped being allowed to. Getting decisions onto the wire, read back out of the log so what crosses is exactly what a replay would see. And reporting a decision that lands for a tick already run, without acting on it: performing a rewind from inside an agent would be an AI package deciding when a whole simulation goes backwards.
The rollback rule is Track J's own argument applied here.
network.writestays outsideDETERMINISTIC_EFFECTSbecause of the rewind loop rather than the send, since a@deterministicfunction is precisely the kind that gets re-run. An inference call is not idempotent either, and unlike a send it is billed. - added
drift/aiwas already specified at DriftScript 1.13.0 and already bound with five capabilities. The language specifies modules and the host describes capabilities, so this is a binding and a regenerated snapshot.drift/navigationbinds twelve and every one takes aNavPathor aNavGraph, so what a script could not do was aim any of them at anAgent, which is a session keyed by an id the consumer registered rather than an entity.pathis the join: one capability makes all twelve work on an agent instead of any being bound a second time. It is not a secondnavigation.path, which takes an ECS entity — same output, different door.Every effect is inherited from a decision already made.
reachableisnavigation.readand is the guard as a question, which matters becauseroutecomputes and writes and a script scoring a destination had to route and undo.navigateisnavigation.writeand deterministic on the argument 1.12.0 made when it admitted that effect: a route is a function of the graph and two endpoints, and ties break on the node index.decidingisnetwork.readon the narrowingdrift/network.authorityalready uses, that a role cannot vary with packet timing.ai.navigateruns on every peer and that does not contradictdeciding. The authority rule is about model-authored intents; a script's own command is a function of the simulation, so every peer computes the same route and it replicates itself by being re-run.Nothing hands a script the tools or arguments a model chose.
intentIdis bound and safe because the floor put it there and a replay recomputes it; a capability returning the model's action would let a@deterministicsystem branch on a provider's answer and take the other branch on replay. A host test asserts that absence. - changed
CAPABILITIES.mdsaid there was no navigation bridge because "nothing pathfinds" and no network authority because "Track J is not built". Navigation shipped on 2026-08-28 and Track J on 2026-09-03. The sentinels underneath were still armed and still correct, so the mechanism worked and the document around it did not — the same failuredrift/rendercarried for five releases.The exports are named
navigationBridge,AI_NETWORK_AUTHORITYandreplicateDecisiondeliberately, to match the sentinel patterns. A bridge named around them would have landed the capability and left both guards quiet, which is worse than stale prose: the guard built to catch exactly this would have been stepped around rather than removed. Both sentinels are deleted in the commit that rewrites the row.npm run check:ai-bridgesis the exit test: twelve assertions over a page running an authority, a participant, a graph cut mid-run and a rewind. The first two establish that decisions were taken and moved the world, because every other claim is satisfied by a run in which no agent asked for anything.One claim is written and has not been run: the same page against a real remote provider, which needs a proxy URL rather than a key since this package holds no credential. The check prints
NOT RUNfor it rather than skipping quietly, and no document says it passed.
3.54.0 · 2026-09-05
- added
A worker pool behind the island executor's seam, which closes Track B. No two islands share a body, so solving them in any order or all at once gives the same bits; the seam has been there since islands landed and
ShuffledExecutorhas been asserting that property since. What kept a pool out was never the engine.Islands are claimed from an
Atomics.addcursor, which balances load and costs nothing in determinism precisely because the islands are disjoint. Workers park onAtomics.wait. The caller claims from the same cursor and then joins, blocking where it may and spinning where it may not, decided by tryingAtomics.waitand catching rather than by sniffing forwindow— so what is left to wait for is the imbalance and not the solve.poolis passed in rather than imported byPhysicsWorld, for a measured reason. A world that reachedcreateIslandPoolon its own put 2,629 bytes gzipped into every consumer's bundle, including everyone who never asks for a worker. Naming it at the call site costs one import line and keeps the package's promise that you pay only for what you import.Read
parallelisminstead of assuming the option took. Every path that does not start a pool sets a whole sentence saying why: the page is not cross-origin isolated, the bundler could not build the worker entry, no pool was passed.running: 0with correct serial physics is the design working. A world with a pool must bedisposed, which is a new method, because its workers hold the staging buffer and every body's state with it.Measured on a 24-core desktop, 256 islands of ten boxes: 1.05x, 1.22x, 1.26x and 1.39x at one, two, four and eight workers.
executor.tspredicted 2x to 4x from Amdahl and the solve share; the staging copy accounts for 1.7% of the gap and nothing has measured the rest, so nothing claims a cause. Two bounds hold and both belong to the scene: Amdahl's, andtotal island cost / largest island cost, which is why a hundred jointed boxes gain nothing at any worker count.No
drift/*binding, as a decision. A pool needs headers on the consumer's document, so whether one can exist is settled before any script runs. A capability likephysics.workers(4)would work on an isolated page and silently do nothing everywhere else, which is the blind surface the 3.53.0 audit removed fifty-seven of. - changed
The row's own blocker was priced from memory and is cheaper than it said.
executor.tshas recorded since 2026-08-27 that a pool costsrequire-corp, under which every cross-origin resource that does not send CORP stops loading anywhere on a consumer's site: fonts, CDN images, embedded video, analytics.credentiallessgrants the same cross-origin isolation and lets those resources load, sent without credentials.npm run check:poolmeasures both against a real page in Chrome 151: each grants isolation, each runs four workers, each is bit-identical to the serial runner for 120 ticks. Safari is untested and the documentation says so and nothing more.The check asserts engagement before agreement, because a pool that failed to start falls back to serial and agrees with serial on every bit of every tick. It also asserts that
Atomics.waitis still refused on the main thread, which is the fact the join's design rests on: written the other way round, the day a browser permits it would pass unnoticed. - added
The premise a pool cannot survive losing, turned into something that fails. Every write inside the island solve is guarded by
invMass > 0or walksbodyOrder, whichisland.tsfills with dynamic bodies alone, so a static floor under twenty towers is read by all of them and written by none. That was an argument about four guards and nothing checked it.One unguarded write to a static body would break the pool and nothing else: serial physics stays correct, every existing test stays green, and the pool produces a state that depends on which worker got there first, on some ticks, on some machines. The character snapshots the thirteen body lanes before each island and compares bits after, reporting the first body two islands both wrote. Bits and not values, because
-0over0is a write.Its own test suite includes a solver that writes one body from two islands and asserts the overlap is reported, because an absence proves nothing until the instrument has been seen to move.
- fixed
The script passed
packages/*/src/**/*.test.mjsunquoted, and POSIXshexpands**as*, so only files exactly one directory belowsrcwere ever reached.packages/ai/src/package.test.mjsandpackages/package/src/cli.test.mjshad never run under this command. Quoted, Node expands the pattern itself: 197 tests to 214, all green.Also re-measured
physics-only, which was 2.7% above its floor onmainbefore this branch and had never tripped the 3% gate — the failure the floors file's own header describes, hit for the second time on the same entry.
3.53.0 · 2026-09-04
- added
drift/renderis bound, and it had been free to bind for five releases. The linker's refusal named Track D; Track D completed on 2026-09-03 with refraction, and nothing re-read the mapping when it closed. The capability map still named the track whilehost.test.tshad already been corrected to say the surface waited on a row of its own.It binds the dials and none of the profile.
RenderQualityhas forty-eight fields and no script can reach one, on the ceiling-and-dial split the engine already argues for depth of field: a ceiling says how far a defocused point may spread and therefore what the pass may cost, and the dial says where this frame's lens is focused and how much of that ceiling it takes. A profile is chosen once from what a device can afford and is clamped against what the adapter reports, so a script writing to one would be overruling a decision made about hardware it cannot see, and two machines running the same script would draw different pictures for a reason the script did not choose.Nothing reads a dial back, and that is the same argument. Each is clamped against the ceiling —
setBloomdoes nothing at all when the profile'sbloomis 0, because the chain is never built — so a read would answer a fact about the machine wearing the costume of a fact about the frame. The language wrote this once already, fornetwork.readand a confirmed-input watermark.The effect is
scene.writeand it needed no language release. That effect is outsideDETERMINISTIC_EFFECTSbecause a node is what draws and moving one is a change to the view; the renderers' own comments call these a renderer parameter rather than a scene one, describing how the finished image is presented and not anything in the world. So a@deterministicsystem may not dim the screen, which is the property rather than a limitation.speedBluris the one capability whose name is not the engine's own: the method issetSpeedRush, and a rush is a thing that happens in somebody's game rather than a description of an image operation. - changed
A green check is not evidence that it can go red. Twice on 2026-09-04 one was measuring nothing: an area-light capture would have passed with the whole specular term deleted, and the claim written to replace it passed with the term multiplied by 0.02. This is the generalisation, run against every script that asserts.
Two classes of blind claim came out of it, neither visible from reading the source. A parity claim — "the backends agree" — passes when both agree on nothing, since a difference of zero is within any tolerance; three scripts wrote that case out explicitly, giving a spread of zero for two blank frames. A shared regression is the one failure a cross-backend claim is uniquely able to catch, and it was the one it could not see. A shape claim — "glows evenly", "rises without stepping back", "the frame is identical pixel for pixel" — passes when the shape is absent, because nothing is perfectly even and nothing steps back if nothing steps.
Seven scripts already had the guard written down and inert. One says above four claims that if the two cones were drawn identically then every comparison further down would pass while measuring nothing, and then compares anyway. One says its cross-backend pair agree on a count of nothing trivially. One carries the sentence that it is the scene that is order-independent rather than an empty frame, over a claim nothing rested on — and seven of its nine claims survived, which is the exact failure another script's header records as having shipped once. In every case the repair is to make the premise a value and let the claims rest on it.
Nine scripts were already sound, and their claims caught the mutation on the term they name. One asserts nothing and is renamed rather than repaired.
scripts/claimAudit.shruns the chain andscripts/claimCensus.mjscounts the shapes. The harness restarts the dev server for all three runs, which is what makes the rest possible rather than a precaution: a vite server that has been up across an edit serves a stale transform, and the identical mutation read twelve-of-twelve green on one and five-of-twelve on a fresh one. Three other ways a run can be meaningless are recorded in its header, each found by making the mistake: a mutation aimed at a term the check does not measure, a mutation that replaces the output rather than removing it, and a regeneration that leaves one backend whole.One script needed a different repair from every other. The ORM check's roughness claim compared a column against itself, and the comment three lines above it already said why that cannot work: the column still shows a smooth gradient, because a lit sphere always does. It reads the difference from the same column drawn without a map now, which is the quantity the map is responsible for — a swing of 130.9 with the map in, and exactly zero without it, because the two frames are the same frame. Its rough-metal claim passed because a non-metal is not black either, and now requires the map to have changed that cell first. Neither was missing a premise; both were reading the wrong quantity.
- changed
It reports what
EXT_clip_controlanddepth32floatthis adapter offers, which is a fact about the machine rather than a claim about the engine, so there is nothing for it to hold to account. It has no exit code, no throw and no assertion, and it is the only one of the twenty-nine that cannot fail. The header now says so.
3.52.0 · 2026-09-04
- fixed
The specular half of an area light was losing the reflection rather than blurring it. It handed the closest point on the rectangle to
sphereLobeand scaled the result by the rectangle's diffuse form factor. Measured against a brute-force integral: on polished metal facing a softbox it returned 0.000233 where the integral is 0.9207, and head-on on a rough surface it was 3.7 times too bright. Worst 287% on both of the placements measured.It reads 40.1% now where the rectangle is what the surface reflects, 79.0% for one straight overhead, and 0.0% to 2.9% across the whole smooth range the old term lost. The capability map has always said this half was the approximate one; what it had not said is by how much, because nobody had put it beside the integral.
A linearly transformed cosine with an analytic matrix rather than a fitted one.
quadCoveragesays what fraction of the specular lobe the rectangle covers, by taking the ordinary polygon form factor in the space where the lobe is a clamped cosine: an orthonormal frame on the lobe's dominant direction, scaled across it by the lobe's own width. The environment BRDF says how much the surface returns. So there is still no fitted table, no polynomial and no texture unit, and both pieces were already in the shader.Both numbers inside it were measured rather than chosen. Framing on the mirror ray instead of the lobe dominant direction is worth 213% against 42%. Fitting one isotropic scale per grid entry lands at twice the roughness squared below about a quarter, and 1.15 times it at one. And the multiple-scattering compensation is deliberately absent: it returns the multiply-scattered share, every direct lobe here is single-scattering, and adding it measured 211% against 40.1%.
This changes the picture of every scene with an area light, which is the thing a default is normally not allowed to do. It is a defect fix rather than a feature: the old term was not a look somebody chose. A polished floor under a softbox reflects it now, and a rough surface directly under one is dimmer and correct.
What is left in it is the anisotropy an isotropic scale cannot carry, where a real lobe stretches along the view at grazing angles. That is what a fitted matrix would add and is the 40.1% above. The attempt to fit one stopped in the same release and its own note says where a third would start.
3.51.2 · 2026-09-04
- fixed
*A caster sink's material is optional, and omitting it meant no material rather than unchanged.* So a run of entries sharing one material had no shorter spelling and
drawSceneCastersbound one per entry.On WebGPU that is a cliff rather than a slope. A bind dirties the material slot, so a bind per entry spends a slot per draw out of the 1,024 a frame holds — and past the ring the draws asking for the rest are skipped rather than mispainted, which is a stretch of the world simply not drawn. A consumer measured 82 material binds a model, because it binds per surface and draws two to four meshes under each, and a reflection replaying the frame shares the ring with the pass that recorded it: eight models on screen took the two together over the ceiling. On WebGL2 the same bind is a
useProgramand a whole material write for every flat program.Both sinks hold the material they bound and skip the bind when the next entry asks for the same one. Reference identity rather than a deep compare: a caster list holds the material objects its draws bound, so a run from one surface is literally one object, and two structurally equal materials from different surfaces cost what they cost today.
The standing material is forgotten at the start of every
drawSceneCasterscall, because what the pass around it last set is not the sink's to assume — a mirror draws its own water between replays. Carrying it across would paint the next replay with whatever the frame set in between, which is a wrong picture rather than a slow one, and both backends have a test that fails on exactly that.The consumer that reported it had written a deduping sink of its own over the public verbs to get this, and can delete it.
3.51.1 · 2026-09-04
- fixed
exportsdoes not only route requests, it closes a package, and 3.51.0's own design called the change "additive for anybody who is not Node". That was wrong in a direction nothing in this repository can see: a path not declared inexportsstops resolving, the workspace symlink keeps resolving it locally, and only a consumer's build fails.A consumer broke on three paths it had always used —
core/package.json,core/CHANGELOG.json, and a module undercore/src/— withTS2307and a failedvite build.Every package that declared no
exportsbefore 3.51.0 now carries"./*": "./*", which is exactly what the absence of the field used to give.chemistryandscriptdeclaredexportsalready and were closed already, so they keep the surface they had.scripts/packages.test.mjsasserts it, because the failure is invisible from here.scripts/cleanroom.mjsskips pattern keys now: it was treating"./*"as an entry point and reporting fifteen failures for specifiers nobody can import. - changed
Porting note, and 3.51.0 should have carried it. Every relative import inside these packages names
.tssince 3.51.0. A consumer that resolves@driftengine/*tosrc/index.ts— which is whatmainand thedefaultcondition give a bundler — has the compiler follow into that source, andtscrefuses a.tsspecifier unless the flag is set:TS5097.Add it to the tsconfig that typechecks:
{ "compilerOptions": { "allowImportingTsExtensions": true } }It is legal beside
noEmitoremitDeclarationOnly, which a typecheck-only config already sets. One consumer here needed it in three configs and a fourth already had it, which is why 3.51.0's own consumer check passed on one repository and not the other.Nothing about the runtime changes: a bundler resolves and strips the same source it always did.
*A compile that emits from this source needs one flag more*, and this note did not say so until a second consumer path found it:
allowImportingTsExtensionsis legal besidenoEmit, and legal without it only whenrewriteRelativeImportExtensionsis set too. A build script runningtscover a single module of this source to produce JavaScript is not a typecheck and cannot setnoEmit, so it needs both:{ "compilerOptions": { "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true } }That is the same pair
npm run builduses, and it rewrites each specifier as it emits. The failure without it is the sameTS5097, from a command nobody thinks of as a typecheck.
3.51.0 · 2026-09-04
- added
Node could not load this engine at all, and the reason was never the specifiers. The packages ship TypeScript,
mainpoints atsrc/index.ts, and a bundler resolves it — but Node refuses to strip types for any file undernode_modules, categorically:ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, fired on the package entry before a single relative import is reached. So adding extensions would have fixed nothing on its own, and a consumer whose importer or measurement script needed a package from Node ran esbuild first and maintained a resolver mapping every package to its barrel. One consumer had five scripts over a shared resolver whose own comment recorded that the package list had drifted out of step with their bundler config for long enough that nobody could say when.npm run buildemitsdistwithtscandrewriteRelativeImportExtensions— a type strip with no code generation anywhere in it, so the shipped JavaScript is the source with its types removed, line for line. Nothing is bundled, minified or reordered, and no source map ships because there is nothing a map could explain that the file does not.exportscarries both readers at once, and that is measured rather than assumed.noderesolvesdist,defaultresolvessrc: Node takes the build, a browser bundler takes the source. So an edit in this repository is still live in a consumer the moment it is saved, which is the property that made the package split affordable in the first place.A private
drift-sourcecondition keeps this repository reading its own source. Packages import each other by name, and vitest,tsxandtscall run in Node — so without it the suite would test adistnobody had built. Measured before it existed: a test importing a sibling failed with "Failed to resolve entry for package @driftengine/entities". It is first in every conditions object and needscustomConditionsin the root tsconfig, which is one decision written in two files.Every relative import inside the packages now names
.ts, which finishes a migration rather than starting one:chemistry,aiandentitiesalready did and nothing else did. Nothing is published — these packages stay private, and what changed is that afile:consumer can load them from Node. - fixed
The build worked, the tests passed, and the tarball contained no JavaScript.
dist/is in.gitignore, npm honours a.gitignorewhen a package declares nofilesand no.npmignore, and every package declared neither.npm packon@driftengine/drftproduced 42 source files and zero built ones.This is the defect
npm run cleanroomexists for, and it found it on its first successful run. Nothing inside the workspace can see this class of fault: a dependency resolves through a symlink whose real path has nonode_modulessegment, so neither Node's refusal to strip types nor a missingdistever fires locally. The clean room packs every package, extracts it into a realnode_modulesoutside the checkout, and imports 25 entry points under plainnode— the only arrangement in which the answer means anything.Each package declares
filesnow.driftscriptlearned the same lesson by publishing, where five of seven consumption paths failed while all 892 of its tests passed.
3.50.0 · 2026-09-03
- added
A concave thing could not move.
addBodyrefused a triangle mesh on anything but a static body and told the caller to use convex decomposition into hulls, "which the runtime already accepts" — andBodySet.shapeheld one shape per body, so a decomposed body had nowhere to go. The sentence was true of the kinematic sweep, which has takenBody.partsall along, and false of the dynamics it was written in. This row is the three pieces that make it true.decomposeConvexis offline and works on a grid rather than on the triangles. A collision mesh is a triangle soup: routinely not watertight, routinely self-intersecting, routinely missing a face nobody would see. Every method that reasons over the source has to decide what the inside of such a thing is and gets it wrong differently for each mesh; a flood fill from outside answers it once, the same way, for any input, at a cost set by the resolution rather than by the triangle count. It claims maximal boxes of solid cells, merges them, and returns at most 32 hulls of at most 50 points — insidehullShape's cap of 64.The merge is measured two ways, and that is the design. Reaching the part budget takes thousands of merges, ordered by how empty a merged bounding box would be, which is six integer compares. Going below it takes a handful, and each asks the real question: how much of the merged hull is not solid. A box cannot tell a convex solid from a cavity, and three attempts to make it were each wrong differently — a fixed threshold left a convex prism as eight parts, because a cylinder's box is 21.5% empty however it is cut; scoring the change against the parents' sum gave negative costs that always pass and collapsed a cup to one hull at 403% bloat; anchoring against the emptier parent let each merge license the next and reached 57%. A real hull costs 1.09 ms at fifty points, and it is affordable at that count because a region is always a union of boxes, so its support set is exact from its seeds' corners.
Greedy merging cannot finish a slanted convex solid either. A tetrahedron came back as sixteen parts at 20.3% bloat: every individual merge across a voxel staircase leaves a notch, while merging all of them gives the tetrahedron back exactly. The whole solid is tested for convexity once, before anything is merged, for the cost of one hull — and a crate, a wedge, a rock and a wheel are all that case.
BodyDesc.shapesis where the hulls go. A part carries no offset and no rotation, because aConvexShapeis already a point cloud in the body's frame. Mass iscombineMassProperties: volumes added, the centre volume-weighted, each tensor moved by the parallel axis theorem. Contacts are one manifold per touching part, in a set sized toMAX_BODY_PARTSso a compound against a single convex shape can never overflow, with the part pair folded into every feature id. A compound against a static mesh shares the budget between the parts that find candidates, never below one each.COLLis defined at container 1.12. The FourCC was claimed in v1 and had no payload, no reader and no writer for the whole of it, because nobody had decided whether a baked hull is triangle soup or a set of convex hulls. It is a set of convex hulls, carried as points, since@driftengine/drftdepends on nothing.drft bake --collider hull[:N] | box | nonewrites it, and prefers a vehicle folder'scollider.kn5over the model's own geometry.Measured by
npm run check:decompose: a convex prism returns one part, an L-beam two at zero bloat, a cup 12 to 14 with its cavity open, a staircase 4 to 6. A torus is the limit at 27.0% summed and 16.2% occupied, and the budget is not what binds it.drft-onlyrises 4,847 to 5,146 bytes;physics-onlyholds. - fixed
A closed prism of volume 3.106 filled to 3.57, 0.56, 0.47, 3.37, 3.33, 3.31, 0.20 and 0.17 at resolutions 32 through 96 — correct at four of them and the bare shell at the other four, with nothing in the shape to say which.
A face lying exactly in a cell boundary plane is tangent to the cell boxes on both sides, so the plane test compares two quantities that are equal in exact arithmetic and differ by a rounding step in this one. When it fell the wrong way neither neighbour was marked, the flood fill walked straight through the gap, and the whole interior came back empty. The fans that close a prism are two such faces, and so is every floor, wall, lid and cap in every model.
The cell box is grown by one part in a million. That is the safe direction to be wrong in: this marking is conservative by design, so a cell marked that need not be costs a little accuracy at the surface and a cell missed that should be marked is a hole.
It was visible only because the check sweeps resolutions. A leak deflates the source volume and the hulls together, so every quality bound held at any single resolution while the fill was empty.
3.49.0 · 2026-09-03
- added
One environment used to light everything equally, and now a surface takes the light of where it stands.
ProbeGridplaces probes on a lattice andnearestProbesreturns the eight around a point with trilinear weights. A single baked probe is a grid of one, so there is one path through the array, the convolution and the shader instead of a grid path and a legacy path that drift.It costs no texture unit, and that is the whole design. Fifteen of WebGL2's guaranteed sixteen are spent and one is left, which is fewer than a grid of cubes could ever want — GLSL ES cannot index a sampler array with a non-constant expression, so a grid of cubes needs a
samplerCubeeach and a branch chain over them. The probe's binding became aTEXTURE_2D_ARRAYof octahedral maps instead, one layer a probe, which is the answer twelve point-shadow cubemaps already reached for. Levels 0 to 4 hold the GGX chain and level 5 holds a cosine convolution, so one sampler carries the reflection and the diffuse ambient, and unit 15 is still free.That retired the spherical-harmonic path outright.
irradianceSh.tsand the WebGPU readback are gone, with the synchronousreadPixelsin the bake, the nine coefficients, and the second gate uniform that existed only because one backend's ambient landed a frame or two after its reflection. The diffuse term is a fetch now. The risk in that fold is a single factor — irradiance is the integral of L cos, the cosine density is cos/pi, and the lit pass wants radiance, so the convolution is a plain mean and a stray pi would be a room 3.14 times too bright with every direction still in proportion. It is pinned against the closed form.A filtered octahedral map needs a gutter, which the shipped one never did. The point-shadow array is one storage level with
NEAREST, so no tap crosses the map's border; a prefiltered chain isLINEARand does. The border is a fold rather than an edge, and a gutter texel filled by that rule matches the texel it is folded against to 3.3e-16, against 4.6e-2 for a border without one.A grid of one reproduces the probe this engine already had, which was the row's own stop condition, written before the work because a probe grid changes the pixels of every published scene. Measured on one GPU against the previous release through
ibl-check.mjs: within about one level of 255 on every figure, with the no-probe control identical.Probes cost layers rather than units, capped at 64 — 683 KB a layer, 43.7 MB for the lot, against the point-shadow array's 92.3 MB.
ProbeBakeOptions.irradiancekeeps its meaning and narrows from per bake to per grid. 21,495 gzipped bytes oncore-only, 3.33%, of which the generated WGSL is 16,502. - fixed
Its own comment said it was outside every
#ifand it was not.LOBES_GLSLopens a conditional thatDIRECTIONALSHADOW_GLSLcloses, so every chunk between them is inside it — andoctEncodeandoctDecodesat there, compiled into the eight permutations with point shadows and absent from the other eight.Nothing noticed for as long as it has existed, because the only caller was
pointShadow, which is inside the same arm. A probe grid calls the octahedral helpers from an arm that has nothing to do with shadows, so glslang refused four permutations with "no matching overloaded function found" for a function whose definition was in the source.Found by counting
#ifagainst#endifper chunk rather than by reading them, which is the only way this class of fault is visible at all.
3.48.1 · 2026-09-03
- fixed
One export, and it was missing for two releases. 3.46.0 widened
ShadowCasterSink.meshand.skinnedMeshto take aSceneCasterMaterialso a caster enumeration could feed a colour pass, declared that type inrender/shadowCasters.ts, exported it from that module, and did not add it tosrc/index.ts— whereShadowCasterSinkandShadowCastersbeside it both were. The barrel is the entire public surface, whichscripts/docs-api.mjsstates in as many words, so a consumer implementing the interface could accept the material and had no way to name it.The report that found it did what this repository's rules prescribe rather than reasoning about it: wrote the import, ran the typechecker, and quoted what came back — *Module
"@driftengine/core"has no exported memberSceneCasterMaterial*. Their workaround wasParameters<ShadowCasterSink['mesh']>[2], which is exact by construction and cannot drift from whatever this engine widens the type to; it cost them a line and a paragraph explaining why it was not an import.SceneCastersgoes with it, for the same reason rather than a different one. That alias exists to carry the contractdrawSceneCastersdocuments itself by pointing at, and a doc comment referring a reader to a name the barrel does not publish is a reference into a private module. The report asked for one export and was right to; leaving the second would have been the next entry.Interfaces implemented outwards are the case where a missing export is a defect rather than an omission. This one's own comments say so twice:
instancedis optional because consumers' sinks and test doubles satisfy the type, and the material added in 3.46.0 is optional for the same reason. An interface a consumer is expected to implement is only as usable as the names it obliges them to write.Guarded now rather than noticed again.
render/shadowCasters.test.tsimplements a sink using only names the barrel publishes, and the assertion is that the file compiles — a type that is not exported is atscerror and nothing at all at run time, sonpm run typecheckis the instrument. It reproduces the reported message on the exact line the report quoted when either export is taken back out.
3.48.0 · 2026-09-03
- added
Prediction and rollback are the same operation, so there is one driver and not two. A lockstep peer that guessed a remote input and then learned the real one has to unwind the ticks it computed from the guess. A predicting client whose authority disagrees has to unwind the ticks it computed from a world that was wrong. Both are: put the state back to tick T, correct what was wrong about T, and step forward to where we were.
RewindLoopis that, andLockstepSession,AuthorityHostandPredictingClientsit on it.A rewind snapshot is not a save file, and the difference is identity.
serializeWorldcarries every component's schema so a load can migrate, keys values by stable field id so a renamed field still loads, and creates entities so a scene can load into a world that already holds things. Every one of those is right for a save and fatal for a rewind: an input recorded against entity 4,194,307 has to still mean that entity afterwards.WorldSnapshotpreserves handles exactly, generations included, and costs 34,816 bytes for a thousand entities over two components.A snapshot is taken before the step, not after. Slot T holds the world as tick T began, which is what a rewind to T wants, because T's input is about to be applied. Saving afterwards makes every rewind land one tick late, and the symptom is a world that drifts slowly rather than one that breaks.
Three transports and three roles.
Transportis a caller's, perAGENTS.md; a loopback whose latency, jitter, loss, reordering and duplication all come from a seed, a WebSocket, and a WebRTC data channel configuredordered: falsewithmaxRetransmits: 0, since either alone is half the fix. Relay, authority and participant are separate roles because a relay written in another language can carry for this engine and cannot run its simulation.Two things the tests forced into the design rather than the design anticipating them. A fingerprint is only comparable at a confirmed tick: the newest tick is speculative, each peer having predicted inputs the other already knows, and comparing live worlds halted a healthy session at tick 32. And an input message carries a run of consecutive ticks, because at 15% loss a single-input packet loses that input for good, and the control with one input per packet does not converge.
Simis opt-in 48.16 fixed point, with a real consumer. Its multiply splits both operands at the fixed point and sums four terms, which is exact wherever the result is representable; the test checks it against BigInt on products the naive form gets wrong. The conformance fixture is written in it and runs in the unit tests, inscripts/exactness-cross.mjsand in the browser check, because a feature nothing turns on is written rather than ported. - fixed
scripts/determinism.mjswas written for Track B and walkedpackages/physics/srcand nothing else. Physics complied visibly and chemistry adopted the same discipline by hand, with a reason written at each site. Nothing outside those two was ever checked, whileCAPABILITIES.md,AGENTS.mdand the physics README all described the determinism contract as a property of the engine.Two of the twenty were defects rather than untidiness.
packages/script/src/bindings/terrain.tsreturned a slope angle fromMath.acosto a script, anddrift/terrainships underphysics.read, which is inside the language'sDETERMINISTIC_EFFECTS— so a@deterministicsystem asking a hillside how steep it was received a number ECMAScript does not specify, and the annotation promising a reproducible replay was false for anything that branched on it. Andpackages/terrain/src/heightfield.tscomputed a surface normal withMath.hypot, breaking a rule physics documents at five separate sites, in a query a consumer may call every tick.The scanner could not see the exponentiation operator, which is
Math.powwearing punctuation, andjoints.tsused it for a squared length inside the collision kernel the gate exists to protect. It catches**now, and strips string literals as well as comments, which the old stripper documented as a known limitation and which widening the scope topackages/scriptmade false twice over.math/exact.tssupplies four transcendentals built from arithmetic andsqrtalone — within one ulp of the platform's and identical on any conforming engine. Three fdlibm coefficients were transcribed a repeated digit short; the accuracy check found them at nineteen ulps inside [-π, π], where no range reduction is involved, and golden bit patterns now pin them. - changed
The loop knew the fixed-step count and kept it, so anything needing one — a recording, a replay, anything networked — kept a second counter beside
simulateand trusted the two to stay in step.simulate(dt, tick)takes it as an additive second argument andLoopOptions.startTickis where the count begins, for a participant joining a session in progress. A pause holds the tick, which is the property the dropped accumulator already had, read from the other side.savableMulberry32exposes the positionmulberry32closes over, as one integer, so it stores beside a tick in a snapshot. Its sequence is asserted draw-for-draw againstmulberry32over six seeds, becauseAGENTS.mdmakes that sequence a contract about every stored ghost and replay.Every size floor was stale and none tripped the 3% gate. The drift ranged from 3 bytes to 3,194, and seven package READMEs plus seven root-table rows were quoting numbers that read low under a sentence claiming to be a fact about the build.
@driftengine/terrainsaid 0.8 KB and measures 1.4 — a figureROADMAP.mdhas had right since the day terrain shipped. The drift was attributed by A/B rather than apportioned:core-onlywas unchanged by this track,core-and-audiois byte-identical raw with and withoutmath/exact.tsexported from core's barrel, andexactAcosaccounts for 435 ofcore-and-script's 3,194.
3.47.0 · 2026-09-03
- added
The controller could not represent a body standing on a wall. Not badly, at all: there was no orientation to give it.
velYwas the only representation of vertical velocity,gravitywas a scalar applied to it,slopeCoswas measured against world Y,stepHeightwas a height only because up was fixed, andGROUNDED,AIRBORNEandSLIDINGwere decided by a downward probe. Nothing in the file was wrong. All of it assumed one axis.What that cost a consumer is a reimplementation rather than a workaround. Roughly nine hundred lines duplicating the sweep, the step-over, the slope test and the ground probe this file already has, and not sharing its contact code, so the two would drift. It is not one game's problem: it is every wall-crawler, every arbitrary-gravity level, and every walker on the inside of a rotating station.
setUptakes the direction, per tick, defaulting to world up so every existing consumer is unchanged. Velocity stays a world vector and the frame decides how it is read: the speed along up is whatvelYused to be, exposed asspeedAlongUp, and the rest stays tangent to it. Steering happens in the tangent plane, with the input read as a world direction flattened onto the surface, which at world up removes nothing and is exactly the arithmetic it replaced.slopeCos,stepHeight, the step-over and both ground probes measure in that frame. The three states are unchanged in meaning.Gravity keeps a direction of its own, because a support normal and a fall direction are different questions. It follows up until
setGravityDirectionis called, which is the default that leaves existing behaviour alone; a body clinging to a wall stands in the wall's frame and may still fall the way the world falls the moment it lets go.The capsule is rotated to stand along up, and that is the one part that cannot be left out. The pose quaternion was identity, which is right while up is world Y and wrong the instant it is not: a body on a wall whose capsule is still world-vertical is swept against the wall by its side rather than its foot. Its check is the measurement — with the rotation it rests 0.95 m clear of the face, which is
halfHeight + radiusexactly as on a floor, and without it 0.35 m, which is a radius.What a
GroundProbecannot do is stated rather than approximated. A probe answers a height for an (x, z), so it has already chosen which axis is vertical and there is no reading of it in a tilted frame.standOnSurfaceis skipped outside world up, and a body on a wall stands on a collider. Approximating it would hoist a body along the wrong axis, which is worse than not hoisting it.What stays out: adhesion, grip, contamination, pads and claws, and anything that knows what an animal is. Those are the caller's, and they are what the engine's own boundary rule refuses — a parameter that means something in only one game does not belong here.
- added
The rig could already look banked and could not orbit a banked surface. The arm was a spherical boom about world Y —
-sin(yaw) cos(pitch),-sin(pitch),cos(yaw) cos(pitch)— so a subject on a ceiling got an arm that still swung about world up and the rig placed the eye through the ceiling.groundClearthen cut the arm where the eye would go under the deck, which is the same assumption a second time.The rig had already anticipated this case and stopped one step short of it.
targetRoll's own comment reads that this rig knows nothing about banked surfaces, only that a camera can be tilted, and that roll is damped here so a subject snapping between surfaces cannot snap the view. Roll was carried; the arm was not.setBoomUptakes the direction and the rest of the frame follows it, defaulting to world axes so a rig that never asks gets the arm it always got. Yaw and pitch keep their meaning and become angles inside the frame, andBoom, the collision sweep, the damping and the field-of-view handling are untouched. That matters more than the arm: a consumer computing its own arm keeps the damping and loses the sweep, which is the hard part and the part that lets a camera enter under-table and baseboard space without clipping.Forward is carried from the frame it had rather than derived from a world axis. Deriving it from a fixed reference is one line shorter and puts a pole wherever up lines up with that reference, so the arm would swing through a half turn as a subject crossed it — on a rig whose whole purpose is that the view does not snap. Carrying it makes a body walking from a floor onto a wall and onto a ceiling continuous, and the world-axis fallbacks are reached only by a teleport, which a continuous rotation cannot produce.
groundClearis skipped outside world up rather than generalised. It reads a surface that answers a height for an (x, z), so "under the deck" and "behind the support plane" are the same sentence only while up is world up. In any other frame the arm is left to the collision sweep, which is frame-agnostic and is the instrument that actually keeps the eye out of geometry.Its check is a mirror, because that is the only assertion that cannot pass by accident: the pitch that lifts the eye 2.40 m above the subject in the default frame lowers it 2.40 m below in an inverted one, and the two agree to within a centimetre. Building the arm on world Y instead gives 2.40 m in both, which is the eye through the ceiling.
3.46.0 · 2026-09-03
- fixed
Every material change rebuilt the flat bind group and threw the whole cache away.
setMaterialcalledrebuildFlatBindGrouponce per changed map, up to three times in one call, and each of those cleared the cache; the albedo lookup on the next line then missed the cache it had just emptied and built a fourth. So a material carrying a different map from the one before it cost up to fourcreateBindGroupcalls, and the cache that exists to make the second frame free was destroyed on every one of them.The mismatch was stated in the file and was the reason for the clearing.
albedoBindGroupswas keyed by albedo while every group it held also carried the normal, ORM and emissive views current when it was built. Keyed on one thing and holding four, it could only be correct by being thrown away whenever any of the other three moved.What it cost was measured from outside, against a consumer's own models. The materials each car merges to were read out of the baked files and this backend's state machine replayed over them in draw order: 476
createBindGroupcalls a pass across seven models whose distinct signatures number 247. That game draws its vehicles twice, once for the frame and once for the water's mirror, so about 950 a frame in steady state where the correct number after the first frame is nought. It scales with the number of distinct models on screen rather than with the number of cars, because identical cars share their batches, which is exactly how it was reported from playing: the frame rate falling off when a second kind of car came into view.The key was the defect, not the caching. The cache is keyed on all four maps now and
setMaterialclears nothing. Nested maps rather than one map under a composite key, because a key built per lookup is an allocation on a path that runs per material change. The four invalidations that remain move something every group holds — the shadow cubemaps, the probe fence, the cookie atlas and the IES rows — and none of them is a frame path.And the count is on the frame budget now, as
bind groups, which is the one number a consumer could not get at and the reason this took a measurement of the models to find rather than a reading of the frame. No ceiling, because the honest figure is nought: every other line counts something rationed and this counts something cached, so any standing figure is a cache missing. WebGL2 declares the same line and never asks it, because it builds none. - fixed
damp(a, b, lambda, dt)islerp(a, b, 1 - exp(-lambda dt)), which is the right answer for a target standing still and a zero-order hold for one that is not. It assumesbis constant across the interval. A third-person camera's target is never constant: it is a vehicle.ThirdPersonCamerasmoothed its position with it, so every consumer's chase camera had this.Two costs fall out of it, and the error is a function of
dt, which is what makes it a frame-rate dependence rather than an offset somebody could tune out. The settled gap works out tov dt (1-k)/kfork = 1 - exp(-lambda dt), which shrinks as the frame time grows: at lambda 14 and 25 m/s that is 1.586 m at 60 Hz against 1.376 m at 28 Hz, so a machine that drops rate silently re-frames the shot. And under jitter the gap oscillates rather than settling, which at 90 km/h several times a second is what a consumer reported as the car snapping about inside the picture when the frame rate dipped.The subject itself was never the problem, and that is how the camera was identified rather than assumed. Driven through the engine's own interpolation harness at 25 m/s, the car holds its world speed to within a twentieth of a per cent at 60 Hz, at 28 Hz, and through jitter of plus or minus 20 ms; the camera's own speed varies by 5.38% under the last of those. The fixed step and the interpolation were doing exactly what they are for.
dampTracking(a, b, velocity, lambda, dt)solves the same equation for a target arriving atbhaving travelled atv. Substitutinge = a - bgivese′ = -lambda e - v, whose solution ise(t) = (e0 + v/lambda) exp(-lambda t) - v/lambda— so the settled gap isv/lambdaat every rate and there is nothing left for jitter to move. Zero velocity returnsdampexactly, and a lambda of zero returnsarather than dividing by it, which is also the limit of the arithmetic.It is
ThirdPersonCamera's default rather than an option, because a camera arm following something that stands still is the rare case. The velocity is differenced from the target rather than asked for, so no consumer signature moved: every caller already hands the target over each frame, and the difference is exact for a target moving smoothly. Roll and the field of view keep the hold, deliberately: both follow targets that step, and one frame ofdelta/dtacross a step is a spike this would then aimv/lambdabeyond. - added
The only draw-replay the engine had carried no material, so a second pass meant running the whole draw again.
ShadowCasterSinkis exactly the mechanism — a handle, a matrix and a skinning palette — and carrying only that is right for a depth pass, where a material cannot change a depth.beginPlanarReflectionhands back aCameraand nothing else, so a consumer wanting the same scene from a second viewpoint had one option, which was to call its own whole draw path a second time.What that costs was measured in a consumer, whose
simphase — the people, the vehicles, the props and the smoke — is 2.3 to 9.4 ms and is the largest single row in its frame meter. The water's mirror pays all of it again, and it is charged to amirrorrow rather than hidden precisely because it is the most expensive thing in the frame.The part worth recording is what was tried first. That mirror was drawn by replaying the caster list, because the list already existed and was already recorded. It came back with every car unpainted, because the list carries no material. So the game re-enters its own draw with a mirrored camera, which is correct and costs the phase twice.
The material rides the sink now, optional for the reason
instancedalready gives at length: this interface is implemented outwards, so a required parameter added in a minor stops every consumer's sink and every test double compiling. A sink that ignores it behaves exactly as it did, and a depth pass ignores it correctly.drawSceneCastersis the colour counterpart ofdrawShadowCasterson both backends, so one closure answers what is in this frame for the shadow cascade, every cubemap face, and a mirror or a probe face.Scatter is declined rather than drawn, and the absence is meant to be in the picture. A scatter batch’s colour draw needs the camera and the environment of the pass it is going into, and this enumeration records what a thing is rather than what the pass around it looks like. Handing the colour path a stale camera would put the grass of one pass into another, which is the plausible-picture failure the two-backends rule exists to forbid. So a replayed pass has no scatter in it and a caller wanting grass in a mirror draws it with the camera it already has.
3.45.0 · 2026-09-03
- added
ROADMAP.md's risk table has said since it was written that if the editor threatened the programme the answer was to stop at debug draw and gizmos and defer the rest. The gizmo shipped in 3.44.0, the condition held, and the census recorded the deferral. Then the rest was asked for, which is the only reason a stop condition is ever lifted, and this is it.The trigger never fired. The track did not exceed its budget. What the estimate was wrong about is the size of the thing: an editor is a multi-year speciality when it is a product — a window, a docking layout, a property drawer per type, an undo stack, an asset browser — and this is the model underneath one. A tree flatten, a schema walk, a snapshot round trip and a
UiNodebuilder.A package, not part of core, and the reason is a boundary worth keeping. It imports
@driftengine/entities, which imports no other engine package deliberately; having core reach it to serve an editor would invert that for every game that never opens one. 10.3 KB gzipped standalone, and standalone is a measurement rather than a convenience:Gizmois the only value it imports from core, so a bundle of it is 31.8 KB raw against core's 2.79 MB, withcreateRendererand every shader string absent from the output.SceneTreeis a flattened, collapsible, named view, and the names live in the tree rather than onSceneNode. A string on every node would be paid by every game, in every serialised scene and every node allocation, to serve an editor most of them never open. Reparenting answers rather than throws, and the refusal itself isattachChild's — checked rather than re-implemented, because it already rejects a self-parent and any ancestor.Inspectoris reflection and needs no code per component.ComponentType.schema.fieldsalready carries{ id, name, type }andWorld.read/writetake a field by name, so it inspects a component a.drsfile declared and this package has never heard of. The field's kind comes from the declared type, because a read cannot answer it:boolarrives as 0 or 1, an enum discriminant as an integer, anEntityas a number — every one of them is a number by the time you have it, and only the declaration says whether to draw a checkbox, a stepper or a menu. An option is spelledoption:f32, a prefix rather than the?suffix that is the obvious guess, because a field type is a key a migration compares for equality.A transform is written through
setPositionandmarkMoved, never into the array. Writingnode.positiondirectly leavesworldMatrixdescribing where the node used to be — no error, a plausible matrix, geometry in the wrong place until something else happens to move it.Play-in-editor's hard part was not pause.
LoopHooks.shouldSimulatealready existed so a paused game keeps rendering, which is exactly what an editor wants;step()is a one-shot flag that hook consumes, so a step is one tick rather than a duration that advances a variable number of them.It was that
deserializeWorldcreates entities rather than replacing them. A load appends, so a stop that only loads leaves the world holding both what was authored and what play produced — and the duplication grows every time somebody presses stop. Measured at 4 live entities against 2 with the clear removed. Sostop()clears first, walking the samestore.denseper type thatserializeWorldwalks to decide what is live.And every entity handle changes across a stop, because the restored entities are new ones. A selection held as a handle is stale the moment play ends — pointing at nothing, or at whatever reused the slot, which is worse because it looks like it worked. A selection is re-found by its *index in the snapshot*, the only identity that survives, and an entity that play created has no index and the selection is emptied rather than moved to a stranger.
Panels are
@driftengine/ui2dnodes and not pixels, which is the shapeDebugLineshas againstdrawLines, one level up. Rows are rebuilt in place and carry a name ofrow:<index>, so the noderouteUiPointerhands back parses straight to the row. Nothing marks the selected row andUiNodegrows no field for it:tree.rowOf(tree.selected)is the index, and how a selected row looks is a decision about a consumer's palette — the same trade this package made when it kept node names out ofSceneNode.Its check found a hole in itself. Every assertion was verified by breaking what it covers, and one of those runs changed the page rather than confirming it: replacing
setPositionwith a raw array write left the check green, because the edit was applied during setup, before any world matrix had been computed, so the firstupdateWorldpicked up a raw write as readily as a proper one. The edit now lands after a frame has drawn and the same mutation fails at 669 pixels against 669.inspector.test.tshad it covered all along, because it callsupdateWorldbefore writing — which is why a check with a control and a unit test with a precondition are not the same instrument.Measured on both backends, identical to the pixel: three rows at 11,016 px and two at 7,344 when a branch is collapsed; a click selecting row 2 and lighting 3,672 px where the control lights none; an inspector edit moving the rightmost cube from 669 to 849; and a stop restoring 2 live entities and a value of 10 where play left 2 and 999.
- added
A second opaque type.
Gizmowas the transform tool;Editoris the session — what is selected, the tree of what exists, and whether the world is playing. Both reach a script throughuses, the way aTerrainand aNavGraphdo, so the module needs nothing from the host and stays registered unconditionally.A name is unique across the module and not per type, which two opaque types make easy to forget: a module is one namespace and one implementation map, so
modecannot mean the gizmo's mode to aGizmoand the session's to anEditor. It isgizmoModeandmode, and TypeScript caught the collision as a duplicate object key rather than one of them silently winning at run time.Every row and field reader answers a defined value for an index that does not exist, because the language has no optional to answer with and a frame loop is the worst place to learn that — the same argument
drift/uimakes for addressing nodes by name.And nothing here edits a field by name, which is the same shape as the gizmo not picking: an inspector row is addressed by its index in a list the host built this frame, and a script wanting to write
Health.currenton a selection would be doing entity workdrift/ecsalready does properly, with a component handle and a checked field. - changed
The seventh time this number has moved, and the third time a peer dependency rather than a package was the reason.
@driftengine/editoris one manifest and four ranges: three peers of its own — core, entities and ui2d — and@driftengine/script's peer on it. So a release is now seventeen manifests, thirty ranges and the lockfile: forty-eight places.Counted with the snippet in
AGENTS.mdrather than read off the previous sentence, which is the only reason it is right — that paragraph has been wrong six times while saying so. The floor inscripts/version.test.mjsis raised in the same commit, because a floor passes at the true number and at every stale one below it.
3.44.0 · 2026-09-03
- added
Track K stops here, and that is a different thing from running out.
ROADMAP.md's risk table has said since it was written that if the editor threatens the programme the answer is to *"stop at debug draw and gizmos, which are independently valuable, and defer the rest"*. Debug draw shipped on 2026-08-28. This is the other half, and it is the first row in this repository closed by a stop condition written in advance rather than by an estimate running out.Gizmois a generator and not a pass, which is the argumentDebugLinesmakes and which holds here with more force: it answers what a ray is over and what a drag does to a transform, fills five line buffers, anddrawLines— on both backends since the polyline batch landed — draws them. Five and not one becausedrawLinestakes one colour a call: three axes, one neutral, and one for whatever the pointer is on.Translate, rotate and scale; thirteen handles; world or local — except scale, which is always local. A non-uniform scale along a world axis is not representable in a translation-rotation-scale transform, so a gizmo offering it would shear the object and call it scaling.
Every drag answers from its anchor rather than accumulating, so fifty frames of dragging back and forth return the target to where it started, exactly. And every degenerate ray keeps the previous value rather than producing one: a ray parallel to the axis it is dragging, a ray edge-on to a ring, a grab at a ring's own centre. Each of those has a closest point that is either undefined or enormous, and a caller that took the number would teleport whatever it was holding at the moment the pointer crossed the plane.
rayPlane,rayClosestOnLineandraySphereare new inmath/intersectand each refuses its own degenerate case at the source.The basis is frozen at the grab. Recomputing it from a rotation the drag is writing feeds the answer into its own input, and what that looks like is a local-space ring accelerating away from the pointer. A pair of tests says so the only way that can be said: the same drag on the same pixels in each space, against a target already turned a quarter, where the two answers differ. Against an unrotated target they would be identical, which is why that test starts turned.
The angle unwrap is real and the orientation cannot show it.
atan2answers in the half-open turn, so a drag crossing the seam reads as a jump of nearly a full one; the total is unwrapped and accumulated. ButsetAxisAngleis periodic in a full turn and a quaternion double-covers the rotations, so an accumulated angle wrong by a turn produces the same pose negated, which is the same rotation. What the unwrap buys is the number — a readout that says 270 rather than -90, and a caller that wants to snap or clamp — sodragAngleexposes it and the tests assert it there.4.53 KB gzipped over core, and nothing at all to a consumer that never imports one, which is measured rather than assumed:
core-onlybundles byte-identical against this branch and against the commit before it, and the bundle contains no gizmo symbol. It importscreateRendererand a bundler shakes the rest of the barrel out, which is what makes an editor tool affordable to ship in core.Its check has a control on every claim. A white quad that shrinks by four as the camera pulls back while the gizmo holds its size to within four pixels; a pointer on the background that lights nothing; the same drag begun off the handle that moves nothing and does not start. A test that measured only the gizmo across two distances would pass a build that ignores distance, and one that draws nothing at all.
What it does not do is pick, and that is the shape rather than a gap: a pick needs a ray, a ray needs a camera and a pointer, and both belong to the application.
registerPickablesettled the same question for meshes and this follows it — the engine says what, and enter, leave, capture and the difference between a click and a drag stay with the consumer that already has that logic. - added
drift/editorwas one of six specified surfaces nothing implemented, and it is five now. A script drives the tool: which of the three modes it is, which axes the handles point along, what the pointer is over by name, how far a rotation has turned, and the transform the drag has produced so it can be written onto an entity.editoris the effect, and unusually the language had already named it. Every other track that arrived at this point found the vocabulary short: terrain ships underphysics.readand says so in its binding, and navigation waited on a DriftScript release.Effecthas carriededitorsince it was written, outside the deterministic set, which is exactly right — a gizmo holds a person's pointer part way through moving something, so a@deterministicsystem reading one would replay differently. It is the effect@editorwas designed to pair with.Three capabilities where an argument would have done.
translateMode,rotateModeandscaleModerather than one taking a mode. The language has no enum, so the argument would be a string or a number, and an unrecognised one leaves two options: change nothing, which is the silent no-ophost.tsrules out, or throw inside a frame, whichAGENTS.mdrules out. Three names make the invalid call impossible to write, and the checker refuses it instead of a runtime branch. The readers answer strings, because a reader is total and has no invalid value to report.And nothing here picks, for the reason the gizmo itself does not: the host runs the pointer.
- changed
Every renderer has held a
PickableSetsince picking shipped, andregisterPickable,updatePickable,unregisterPickableandpickAthave all been onRendererApi— so picking against what a renderer draws has been reachable the whole time. What was not exported is the class, so a consumer wanting a second set, over gizmo handles or over anything a renderer never draws, hadPickHitandPickableSourceto describe one and no way to construct it.Two documents said picking did not ship at all. The register of unpriced work had the gizmo row as "picking, a handle to drag, a transform gizmo", and
docs/CAPABILITIES.mdsaid "no handle to drag, no picking, no gizmo" in one row while listing "ray picking" as shipped two rows above it — a map contradicting itself for eighteen days. Both are corrected.What that cost was the ranking rather than the estimate. A row priced at three pieces of work sits differently from one priced at two, and this one was passed over twice. The check is one grep, and it is the same check the
drift/mathrow needed and did not get: that one was filed as a missing language module whenstd/mathhad thirteen functions the whole time. - fixed
The floors drifted again, nine days after refraction found them 15.4 KB behind. This time it was 505 bytes, which is 0.078% and which the 3% tolerance passes without a word — the failure mode
scripts/size-floors.mjswarns about in its own header: a floor passes at the true number and at every stale one below it.The difference is that it was looked for rather than stumbled on. A new capability re-measures the floors whether or not the gate asks, and the A/B that followed is what separates a correction from a guess:
core-onlyagainst this branch is 645,522 bytes and against the commit before it 645,523. One byte apart, in the direction that says the new code costs a consumer of core nothing — confirmed directly by the bundle, which contains no gizmo symbol.So every
KB gzipped over corefigure read half a kilobyte high, in eight package READMEs and again in the root table, each under a sentence claiming to be a fact about the build.IMPROVEMENTS.mdhad carried the sweep as its own commit for a while; it lands here because this is the commit that moved the floor.
3.43.0 · 2026-09-03
- added
Track D is complete. Glass bends and tints what is behind it: a copy of the frame's colour, latched at the first refracting draw, sampled at an offset along the surface normal, and absorbed by Beer-Lambert over a path length that grows at grazing angles.
9.05 KB gzipped over core, against the 19.8 KB it had been priced at. That estimate came off the branch-in-
flatFragfigure Track D's earlier rows measured, and it was right about where the cost lives and wrong about how much: a uniform and a branch that reads one sampler is not the size of the branch the figure came from. The fragment stage stays at sixteen permutations and the vertex stage at five. A frame that refracts nothing takes no copy, breaks no pass and binds an empty texel.The snapshot is latched rather than declared, because there is no translucent-set boundary to hang it on:
drawTranslucentMeshdraws immediately and the queue exists so order-independent transparency can replay the set. So the first refracting draw takes the copy and the rest of the frame reuses it. Glass does not refract other glass, which is right; opaque geometry drawn after the first pane is missing from what that pane shows, which is the trapsnapshotDepthalready documents.Neither backend can read what it is writing, and they answer differently. WebGL2 blits, which resolves a multisampled source and therefore works in exactly the configuration where
colorAttachmentreturns null. WebGPU ends the pass, copies the resolvedpost.sceneColorand reopens withloadOp: 'load'— a fourth instance of a boundary this backend already runs for the mirror, the light volume's depth snapshot and text.The absorption is stated as what survives one metre rather than as a coefficient:
pow(tint, d)isexp(-absorption * d), so clear glass is(1, 1, 1)with no special case and nolog(0)at the API. The path length divides bydot(N, V), which is the whole difference between this and a tint — a slab seen edge-on is more glass than the same slab face-on, so a glass edge goes green while its middle stays clear. Thickness rides the per-vertex channel's.wlane, which 3.42.0 declared reserved; it cost a varying and no attribute, where a fifteenth attribute would have spent one of the two vertex locations left.Texture unit 14 of the two that were free. One remains, and the next sampler has to fold into an existing binding the way the point-shadow cubemaps became one array, rather than take the last one.
Its check found five defects before it went green, and only two were in the shader. The scene was wrong twice: a flat pane facing the camera has a normal of (0,0,1) and correctly displaces nothing, and a rippled one displaces differently at every x, which smears rather than moves. The shader used one scalar as both the offset and the blend, so a plausible 0.025 offset also meant a 2.5% blend and glass drew as flat paint. Three were on WebGPU and every one silent:
post.sceneColorhad noCOPY_SRC; the pass is opened lazily, so there was none to end at the moment a refracting draw asked; and the flat bind group is built at construction and cached, so it held the one-texel white stand-in for the snapshot forever and every pane came out pure white — 255,255,255 against WebGL2's 108.9, with the non-refracting variants agreeing to a tenth on both.Measured on both backends, agreeing to the digit: the pattern behind a pane shifts 11 px at one strength and 23 at double; a tinted pane reads 9.7,88.4,18.6 against a clear 108.9; an edge-on pane absorbs to 32.03 where face-on holds 38.55; the thickness lane separates 85.21 from 39.44.
What it gives up: refraction replaces the surface's own shading rather than mixing with it, so glass carries no specular of its own. By that line the shaded colour has the lighting, the fog and the tone curve folded into it and there is no highlight left to keep. A caller wanting a glint draws a second, non-refracting pass over the same geometry.
Also: the size floors were 15.4 KB behind reality and are swept. About 6.1 KB of that is 3.42.0's per-vertex channel, which stayed inside the 3% tolerance and so moved no floor and raised no gate.
- changed
There was no way to tell
celestialStateAtwhich hour to put the sun at. It read the hour off the timestamp withDate#getHours— the host machine's wall clock — so one argument and one instant, withdayOfYearpinned, put solar noon at 12:00 underTZ=UTC, 10:00 in Rome, 16:00 in New York and 03:00 in Tokyo. The only way to name an hour was to build a timestamp whose host offset cancelled the one being added.That trick steadies the sun and cannot reach the moon.
moonPhasecomes from the timestamp's absolute value rather than from its hour, so a caller handing this function a different instant on every machine — which is exactly what the workaround does — got a different phase on every machine.CelestialSite.longitudeDegandutcOffsetHoursare the way to say it. Naming either makes the timestamp a plain UTC instant and the engine derives the site's own hour from it: the offset naming its calendar day, the meridian placing its sun, up to half an hour off the centre of its zone. That last correction is the one this module's header had always warned about and handed back to the caller, and it is the engine's now. A longitude alone implies the offset of its own meridian, so a site giving one and not the other still describes a consistent clock.A site naming neither, and a caller passing no site, keeps
getHoursexactly. That is the model every world here is tuned against, so the defect is kept in that form rather than quietly removed, and a test asserts the four numbers above so nobody makes it host-independent and moves every shadow in every consumer at once.The equation of time stays the caller's, which is not an oversight: it is a property of the planet's orbit rather than of the place, and a world with an obliquity of its own has an equation of time of its own.
Found by a player in Italy watching the sun set before six.
- added
demo/voxelSandbox/GAPS.mdhas no open engine rows left. Four closed here, on top of the three 3.42.0 answered with the per-vertex channel. What remains in that file is the port's own work.A surface texture can be asked for nearest magnification.
filteronSurfaceTextureOptions, defaulting tolinear. Linear magnification turned an authored pixel-art tile into a smear as the camera approached, and the consumer's workarounds — composing the atlas withimageSmoothingEnabledoff, keeping tiles at twice the resolution they wanted — could not reach it, because the smoothing happens in the sampler after everything a caller controls. Minification still blends between mip levels: the option is about magnification, and taking the nearest level too would trade a smear for a visible pop as the camera pulls back. WebGPU refusesmaxAnisotropyabove 1 unless every filter islinear, so a nearest sampler gets 1 there and an ignored request on WebGL2 — that file had no test at all, and a refused sampler is a bind group that never builds and a frame that draws nothing.TouchControlstakes its stick nodes as options, or not at all. They were positional and read as dependencies, so a consumer forbidden the page concluded the class needed twoHTMLElements and built twodivs it never appended anywhere. They are an output channel: only ever assignedhiddenandstyle, never measured, with every touch coming frominput.target.new TouchControls(input)is now a working scheme with no visible stick, and the positional form keeps working. Which form was called is decided by arity rather than by inspecting the second argument —instanceof HTMLElementneeds a DOM constructor this package is checked without, and duck-typing onnodeTypereads a stand-in element as an options object, which the file's own harness supplies.A stroke can say it is light.
drawLinestakes a blend, so a glowing outline is one additive pass rather than two alpha-blended ones at different softness faking a halo — which tinted toward the glow colour against a bright surface instead of adding to it. It is a blend and not a bolt mode, whichAGENTS.mdforbids: what separates a line from a bolt is the path, a bolt jitters because it is lightning, and an additive line still follows the path it was given, keeps its clean edge and is still fogged. Blending is pipeline state on WebGPU, so the additive arm is a second pipeline under its own cache key.A component field can hold a fieldless enum, which is where a state machine's state lives. The discriminant takes an integer column, because that is what it always was. This was read as a language gap by two documents and is not one: DriftScript has
enum, andmatchover one refuses to compile until every variant is handled — its own corpus advertises exactly that against a Lua version which would silently do nothing for a state nobody wrote a branch for. What refused was this engine's component store. So the corpus keeps itsAlertnessin adatarecord, which is not what a system iterates, and the consumer shippedmood: i32with three numbers written out in a comment.Int32Arrayand not a byte, and the width is the one decision the report did not make: a byte would hold every enum anybody is likely to write, anddefineComponentis handedenum:Mood— a name, with the variant list belonging to the module that declared it. It cannot count them, and guessing too small fails silently, so it spends three bytes it will usually not need. - fixed
A defect introduced in this release, found before it shipped, and worth recording because of how it hid. 3.42.0 declared the channel's fourth lane reserved and unread and gave it an absent value of 0, which was right for a lane nothing reads. Refraction then made it the thickness multiplier and left the 0 in place.
The consequence is not subtle: the path length is
thickness * lane / dot(N, V), so a lane of zero makes it zero,pow(tint, 0)is one, and a pane naming a tint and a thickness bent the scene behind it and took no colour out of it at all. Every lane in that tuple is a multiplier or an amount, and the identity for a multiplier is one.It survived the check because every subject the check draws supplies a channel array, so the absent value was never the thing being measured — the page had eight variants and all eight carried one. There is a ninth now that carries none, and against the old default it reads the clear pane's colour exactly: 108.9 grey where it should read 9.7,88.4,18.6.
That is the second defect this lane has produced in two releases. The other was a duplicate attribute location on every instanced WebGPU pipeline, found by a consumer bisecting a scrambled frame. Both have the same shape: a value that was correct for a lane nobody reads, left standing when something started reading it.
3.42.0 · 2026-09-03
- added
Three gaps a consumer filed separately turn out to be one attribute. They asked for leaf sway, for a sky factor that scales the directional term without touching ambient, and for per-vertex alpha. Every one of them is the same sentence: a float a vertex the flat shader reads somewhere
colorscannot reach.MeshData.channelis one optionalvec4carrying all three, with a fourth lane declared and unread so the next question costs no location.Locations were the scarce resource, and no list had said so. WebGL2 guarantees sixteen vertex attribute locations, eleven were already spent, and an instanced draw spends all sixteen. So the consumer's smallest change of one float could not be had once, let alone three times. One
vec4costs the location afloatwould. An instanced draw still cannot carry it andInstancedMeshthrows rather than reading the absent constant and drawing leaves that never move, which is the silent no-op the two-backends rule forbids. The escape hatch stays documented and untaken.Absent is
(0, 1, 1, 0)through the disabled-attribute mechanismgrainandreliefuse, so a mesh that says nothing about this costs no buffer, no upload and no per-vertex fetch, and is identical to the digest:f0092361on both backends. Two of those four defaults are ones and the wrong value there is not a subtle shading difference, it is every mesh in the engine losing the sun or vanishing.Sway reads the wind rather than a clock of its own.
setWindsamples the field once a frame and both backends bind it to the mesh and depth programs, so a canopy and its own shadow bend together and a leaf leans with the grass beneath it. The conversion isresolveScatterDeform, shared with the scatter batch. The lane scales the bend once wherescatter.tssquares its own, because that falloff is derived from height and this one is authored.skyDirectmultipliessunShadeand notdirect, because the sun's specular lobe readssunShadeon its own: scaling the diffuse alone leaves an enclosed face with no sunlight and a highlight anyway. Ambient is untouched, which is the whole defect. A sky factor folded intocolorsmultiplies the albedo, so it scales ambient and sun together and darkens a cave face twice.Per-vertex alpha did not need
colorswidened to four floats, which is how it had been costed. It is a factor onuOpacity * coverage, not a component of the albedo, so it rides a lane and no mesh producer, container field or baked asset moved.5.8 KB gzipped over core, and no new permutation on either stage. The row had been deferred as priced by the permutation count; that is the fragment stage's economics, where sixteen permutations cost 283.4 KB gzipped. The vertex stage is five at 5.2 KB.
Checked on the card, both backends agreeing to the digit.
scripts/vertex-channel-check.mjsruns ten assertions with every control in the scene: sway moves the quad 57.343 px one way and 57.105 the other while asway = 0control holds at 0.000 across the same two clocks, and the sky lane leaves 38.00 luminance where folding the factor into the vertex colour leaves 0.00 and full sun gives 176.00. Each claim was broken and confirmed red. The sky mutation is worth recording: scaling albedo instead ofsunShade, which is exactly the defect being fixed, still passes an assertion that only checks the face got darker.It found a real WebGPU defect on its first run there. GLSL links varyings by name, so the two stages could list them in different orders and WebGL2 stayed pixel-perfect; the generated path assigns locations by declaration order and handed WebGPU a
vec4where it wanted afloat. No pipeline was created, so the backend drew nothing at all, with the reason in a device error line and nothing wrong in the frame to look at.drift/scenegains the wind:windDirectionX,windDirectionZ,windSpeedandwindGust, fourf32reads underscene.readand deterministic. AWindhandle is a sampled state and not a profile, which is what lets a@deterministicsystem read it, andwind.tsbuilds its signal from integer harmonics so a replay watched an hour later sees the gust the original run saw.
3.41.0 · 2026-09-03
- added
Track F ships, as
@driftengine/ui2d, and it draws throughregisterPassrather than through a verb on the renderer. That is what lets it be a package at all —pass.tsin core names this package by name as one of the three the contributed-pass seam exists for — and it means a consumer that never draws a sprite carries no sampler, no pipeline and no generated WGSL for one. 8.7 KB gzipped on top of core, which places it on Track D's own price table: a uniform and a branch inflatFragcost 19.8 KB across sixteen fragment permutations, a shader of its own cost 4 KB for drawn decals and 7.5 KB for screen-space reflection, and a package of pure arithmetic cost 1.4 KB for terrain. The alternative a consumer actually asked for —drawPanelTexturebesidefillPanel, filed indemo/voxelSandbox/GAPS.md— is the 19.8 KB row.Two coordinate systems, and they count y opposite ways.
screenToNdcis CSS pixels from the top-left, the conventionInsetRectandfillPanelalready use;worldToNdcis a 2D world through aCamera2D, where y counts up, because a caller placing a platform above a floor should not have to subtract. One shared convention would make every caller wrong half the time.No depth is written and none is tested: the order sprites were submitted in is the layering, which is the decision gate 1.2 already took about pass ordering. So the batch keeps runs rather than grouping by texture — consecutive sprites on one slot are one draw, a slot change starts a run, and going back to the first slot starts a third rather than rejoining, because rejoining would reorder the picture.
AGENTS.mdasks for exactly that shape: the number to hold down is material changes, not draws. The batch does not grow; past its capacitydrawSpritecounts intodroppedrather than allocating under a frame.drawTilemapcosts the view rather than the map. The visible span is arithmetic on four numbers, measured at 25 tiles drawn out of a million-cell map.The retained tree is the other half and it touches no GPU at all.
layoutUiTreeis two passes over an object graph — measure bottom-up, place top-down — and no third, because afitsize that depended on the space it was given would need a rule for when to stop. Three size cases (fit,grow, a number) rather than percentages or flex weights, since a percentage of afitparent is a cycle. It allocates nothing: every number lands in aUiRectthe node already owns, which is why a node is an object with fields rather than a description that gets resolved into one.uiHitTestsearches last-drawn first, so the hit test agrees with the picture, and a node that is notinteractivedoes not block what is behind it — a backdrop that swallowed clicks makes every button under a plate dead, which no screenshot shows.routeUiPointeractivates on a press and a release on the same node, so somebody who pressed the wrong button can slide off it. Focus order is tree order rather than a declared index, and a node hidden since it was focused does not take the keyboard with it. The pass fills a white slot of its own, from four bytes rather than a canvas, so a solid rectangle needs no sheet.Two checks on the card, on both backends, and one of them found a real defect on its first run.
scripts/sprite-check.mjsis 22 assertions over sheets that are two texels by two with four different colours, so a sprite's four quadrants say which corner is which — an assertion a mirror fails and a bounding box cannot, since a mirror preserves every count and every box. The two sheets arrive as a<canvas>and anImageBitmap, because WebGL2 ignoresUNPACK_FLIP_Y_WEBGLfor one and honours it for the other. A sprite lands on its CSS rectangle to 0.00 px on every edge and the two backends agree byte for byte in both submission orders.scripts/ui-check.mjsis 17 more: the page publishes the boxes the layout computed and the bounding boxes of the colours they were drawn in, and asserts the two agree — a bar sized by its own contents lands at 530, 660, 220 by 60 to 0.0 px, byte-identical in all three pointer states, with the pointer visible as a colour rather than read back from the router.The defect: a contributed pass inherits whatever cull state the last scene draw left on, and a screen-space quad's winding is not the scene's — so WebGL2 drew nothing at all while WebGPU, whose pipeline states its own cull mode, was pixel-perfect. No error on either side, no validation message, no warning. Every backend difference this seam has is that shape: one API carries state between draws and the other does not.
drift/uiis bound — eighteen capabilities over a tree addressed by the names its nodes were built with, because the language has no optional to answer a missing node with inside a frame loop. Reading a laid-out box isscene.readand inside the fixed step;hoveredandpressedareinput.readand outside it, so a@deterministicsystem cannot ask where the pointer is — they are fields on the tree and read like any other, soscene.readwould type-check and would be a lie about what they carry.drift/2dis bound with it, and getting there took a language release and one day. A module's namespace was the last segment of its path --split('/').pop(), in three places in the compiler, with no alias -- so a call would have been2d.sprite(...), and2dis a number followed by an identifier the lexer refuses before the checker sees it. Five spellings were compiled against the real registry and every one failed: the bare name, the namespace,import as,import *, a backquoted segment.ui.layoutandterrain.heightAtcompiled in the same harness, so the failure was the name and nothing else.So the binding was written, wired, found uncallable and withdrawn rather than shipped. Registering it would have traded
DS0301, which says nothing here implements it and is actionable, forDS0003inside a script author's own call -- a capability nobody can spell is the silent no-op this repository forbids, wearing a feature's clothes. It was recorded with a sentinel and filed against the language, and DriftScript 1.11.0 answered it at the import:import { sprite } from "drift/2d" as sprites, withDS0139refusing a namespace that cannot be written and saying the line to write. The fix is at the import rather than at the derivation, because a rule that turned a bad segment into a good identifier would be the language picking a name on the author's behalf.The engine was already on 1.11.0 by the time this landed, so what moves here is the binding and the sentinel with it. The round trip took one day. Twelve capabilities over three opaque types -- a batch, a sheet and a tilemap -- with a draw as
scene.writeand a read of a sheet or a cell asscene.read, so a@deterministicsystem may ask what is in a cell and may not draw it.
3.40.0 · 2026-09-03
- added
.fbxreturns a rig, which is the one bar that makes a character format useful.readModeldocumentedskinsandclipsas "where the format carries them. glTF does; nothing else here does", and FBX is how characters are sold — it is the only thing Mixamo exports — so the one format a bought character arrives in was the one a rig could not be read from. Measured on a Renderpeople figure: through this reader, a bag of triangles with no skeleton, no weights and no clip; the same file converted to glTF first, 88 joints and a 64-track take. A clip can be authored afterwards and a skin cannot be recovered from a mesh, which is why the skin is the half that mattered.The bind pose is two matrices and not one, and that is the trap a hand-made fixture cannot catch. A cluster carries
TransformLink, where the bone was when the mesh was bound, andTransform, where the mesh was; the engine'sinverseBindtakes a vertex from model space into the joint's bind space, so it is the inverse of the first times the second.TransformLinkalone is right exactly while the mesh sits at the origin with no rotation — which is what every hand-made rig does and no bought one does. The first test written here binds the mesh at the origin and cannot tell the two formulas apart; it is kept beside the one that binds it three metres off, and perturbing the reader turns the second red and leaves the first green.A bone names its cluster and its parent bone, and only one of those is hierarchy. The transform walk took the first
OOparent it found, which is whichever the exporter wrote first, and a cluster is not a model — so the walk to the world stopped there and silently returned identity, dropping every ancestor above that bone. A prop parented to a hand drew as though the hand were a root: right relative to the hand and in the wrong place in the scene, which reads as a rigging fault rather than as a connection read in the wrong order.And the geometry stopped arriving five times over. The reader wrote
indices[i] = iacross every corner it had fanned, so a bought character came back at 64,518 vertices where its own file describes 12,216. The baker welds, so a baked asset never showed it and anything callingreadModeldirectly carried all of it. Deduplicating on the layer offsets is the cheap answer and shares almost nothing, because an exporter writingByPolygonVertexDirectgives every corner its own offset into an array of repeated values: measured on a rigged car, 1,958,002 vertices from 1,958,016 corners — fourteen shared out of two million. Keyed on the values instead, bucketed by control point so the candidates for any corner are the handful already emitted for it, the same car is 388,362, and the three FBX files in this repository come down by 4.2 to 5.0 times. The read is also three and a half times faster, 827 ms against 2,952 ms, because the allocations went with the duplicates.Weights are normalised on the way in and four is the shader's number, both said once per mesh rather than once per vertex; a vertex held by more than four joints keeps its four largest and is renormalised, because dropping by order discards whichever the exporter happened to write last and on a shoulder that is as likely to be the dominant influence as not.
A rotation is the chain and not the curve. FBX composes a joint's local rotation as
PreRotation * R * inverse(PostRotation), and both outer terms are fixed per joint and written on the model rather than in the curve — so a reader taking the curve alone plays a take in which every joint carrying one is turned by a constant amount. Twenty-nine of the measured character's eighty-eight carry one. Every key still interpolates and the clip still runs, which is why it reads as a bad export rather than as an unread field. Both are composed; a non-defaultRotationOrderis the one part of the chain still refused by name.A binary FBX separates a name from its class with
\u0000\u0001, not with::. The ASCII form's separator is the one a reader tends to be written against, because it is the one the specification's examples show. Every joint of the measured character therefore came back namedrp_nathan_animated_003_walking_spine_01\u0000\u0001Model, and the takeTake 001\u0000\u0001AnimStack. Nothing fails on a name:Joint.nameis what retargeting matches on, so a rig whose every joint carries a class suffix matches nothing, one subsystem away from the reader that wrote it. Both of these were found by running the reader over the file it was written for, after the synthetic tests were green — which is the argument for real bytes in one line.What is refused by name rather than approximated: a non-default
RotationOrder, the second and later animation layers of a stack, morph channels driven by curves, and theTakerecords of pre-7000 files. Materials and textures are unchanged.The test files are written rather than checked in. The assets that would prove this on real bytes are bought characters: tens of megabytes, licensed, and not committable here.
fbxHarness.tswrites the binary records instead, in the shape Autodesk's exporters emit them, which also answers the questions a test wants to ask — a cluster whose weights do not sum to one, a bone whose only connection is to its cluster, a curve with one key — rather than only the ones one file happens to contain. Measured on the character it was written for, a 24.6 MB Renderpeople figure at version 7300: 12,216 vertices and 21,506 triangles, which is the same vertex count that file gives through a glTF conversion; 88 joints, named and sorted parents-first with none naming a parent after it; a 176-track take of 2.267 seconds; every joint index in range, no vertex left unweighted, and weights summing to one within 4.5e-8. Baked to.drft1.11 and read back it is the same asset, and the engine's ownSkeletonaccepts the rig and resolves an identity palette at the bind pose to 5.5e-5 across the whole 88-joint chain, which is float32 accumulation over 88 matrix inversions rather than an error. - fixed
«fbx: no geometry found» is three different faults and said only one thing. Two rig-specific exports of one bought character both failed with that line and the trail ended there: the message could not say whether the file carries no
Geometryrecords at all, carries records that are not polygon meshes — a blend-shape target, a patch, a subdivision cage — or carries ones that produced no polygons. Each is a different next step. The refusal now counts what it met and names which of the three it is, which is the difference between a report somebody can act on and one they can only forward.And the two exports it was met on are not a reader fault.
_ue4.fbxand_u3d.fbx, which ship beside a bought character for two particular engines, carry 95 and 88LimbNodes with their curves and noGeometryrecords at all: they are animation-only files and the refusal was correct the whole time. It simply could not say so, and «no geometry found» reads as a reader that cannot find something rather than as a file that does not contain one. The message now names that case specifically, and says the mesh is in the file next to them.
3.39.0 · 2026-09-03
- fixed
Order-independent transparency excluded multisampling on one backend of two. WebGPU decided the frame's
oitActiveasorderIndependent && screenEffects && samples === 1and said why; WebGL2 decided it asorderIndependent && sceneTarget !== nulland never asked the sample count. One boolean expression against another, found by reading them rather than by a capture.What the missing guard cost. Both order-independent passes attach
SceneTarget.depthAttachment()and depth-test against it without writing, and above one sample the frame is not drawn into that texture:ensureSizeattachesmsaaDepth, a renderbuffer, and the sampleable copy is written only by the depth blit at the end ofresolve— which itself runs only when ambient occlusion, motion blur, depth of field or the temporal resolve asked for depth. So a pane was rejected against the previous frame's depth, and with those four effects off, against a texture the frame had never written. The method immediately below it,colorAttachment(), guards the colour half of exactly this and gives exactly this reasoning;sampleCountwas already public two accessors above, so the guard needed no new API.Either behaviour is defensible and the disagreement is the defect. 3.34.0's own note says it in as many words: two backends compositing different pictures is what the parity rule exists to prevent. A consumer profile carrying
orderIndependent: truebesidesceneSamples: 4— which is what a graphics screen's antialiasing switch produces — got weighted blending on one backend and sorted blending on the other, with nothing said on either.Both backends now refuse it, once per renderer, in one string.
OIT_MULTISAMPLE_REFUSALlives inorderIndependent.ts, the backend-neutral module the capability already had, rather than as a literal in each renderer: the decal and reflection refusals beside it are duplicated literals whose halves have to be diffed by eye, and this engine has already paid for that arrangement once —compositeWantsDepthwas two copies of one expression, depth of field was added to one, and the effect ran against a discarded attachment. The WebGPU comment had claimed since the effect shipped that it "says so once", and for two releases nothing did.Pinned by a test on each backend, neither meaningful alone. WebGL2 gained a
renderer.test.ts— the first test in this repository to construct that renderer for anything but its budget — which submits one translucent pane and countsdrawElements: one means sorted and blended immediately, none means held for the two passes at the end of the frame. Multisampled must be one and single-sampled must be none, and before the fix the multisampled profile was none. The harness can now declare an extension present, because withoutEXT_color_buffer_floatthe effect refuses one step earlier and both profiles look alike for a reason neither test is about. - added
.drftvalidated a skinned mesh and wrote half of it.MeshDatahas declaredjointsandweightssince skinning shipped andvalidateMeshDatachecks both — four floats a vertex, and neither present without the other — whilebuildMeshlisted the optional arrays it had bits for and there were no bits for these. TheSKINchunk beside them was correct the whole time, so a file carried a skeleton, its bind pose and its clips, pointing at vertices that recorded no influence on any of them. The two halves of a skin disagreed about whether the format supported skinning.The write succeeded in silence, which is what made it survive four minor versions. A baker handed
writeDrfta correctly-validated skinned mesh, got no error and no warning, and produced a file nothing could skin — andMesh.isSkinnedreadsdata.joints, so what came back drew in bind pose for ever. A consumer wrote its own binary container for a single human body rather than use this: about seventy lines across a baker and a loader, and with them everything.drftwould have brought — the shard splitting a static host wants over 25 MiB, the coarse-first ordering that puts an outline on screen in the first few kilobytes,DrftLoader's fitting, and the streaming reader.Every other layer was already right, which is why this is two bits and not a subsystem: the glTF reader reads
JOINTS_0andWEIGHTS_0and remaps them onto its parents-first joint order, the weld's attribute table carries both at four floats each, and both backends upload them at attributes 11 and 12.ATTR_JOINTS(0x80) andATTR_WEIGHTS(0x100) are the next two free bits, appended last in the frozen order, so a 1.10 reader meets the seven arrays it knows in the seven places it expects them, stops before these, and draws the asset in bind pose — which is what a reader that has never heard of skinning should produce. Two bits rather than one because the frozen order is a property of arrays and these are two; they stay both-or-neither, refused on the way out and again on the way in.The test that should have caught it was right and its fixture was not.
drft.test.tsenumerates the keys of the mesh it builds and reports every loss at once — the methodreliefbought after a named-attribute list missed it — and the mesh it built carried no influences, so there was nothing to enumerate. It carries them now, which is what turns the enumeration back into a guarantee. Av1-11compatibility fixture is checked in beside the others, and §4.4's practice of one fixture per released minor is repaired for this version; 1.8 and 1.10 are still missing theirs, and forging them from a later writer would look like evidence and be none. - note
A version bump is forty places, not thirty-six, and the number had been stale for a whole release. 3.38.0 shipped
@driftengine/terrain— one manifest and three internal ranges — and raised neither the paragraph inAGENTS.mdnor the floors inscripts/version.test.mjs, so an entire version ran at fifteen manifests and twenty-four ranges while both said fourteen and twenty-one and everything stayed green. That is the sixth time this figure has drifted, and it is exactly the failure the rule predicts: a floor passes at the true number and at every stale one below it. Found by running the counting snippet rather than by reading the sentence above it, which is what that snippet is there for.
3.38.0 · 2026-09-02
- added
@driftengine/terrainimports no renderer. ATerrainis a heightfield andheightfieldPatchhands backMeshData, which goes tocreateMeshlike any other geometry — so terrain is drawn by the pass that already exists. That is why it is a package rather than part of core: a world with no terrain pays nothing, and one with terrain pays 1.4 KB gzipped for arithmetic.The query answers the surface that is drawn, and that is the whole design. A heightfield is stored as a lattice and drawn as triangles, and a bilinear patch through four corners is not the pair of triangles that spans them — they agree only at the corners and along their shared diagonal. Interpolating bilinearly because it is the obvious thing puts a character above the ground over half of every cell and below it over the other half, by up to a quarter of that cell's height range, everywhere, for ever, with no screenshot that shows it and no test that fails. So
heightAtreads the triangle,heightfieldPatchemits that same triangulation, and a test asserts every vertex of a patch against the query.normalAtis the other way round on purpose: a central difference over the samples, continuous across every cell boundary. A face normal jumps at every edge, which makes terrain faceted and makes two patches shade differently along the edge they share — a crack that is not there.The seam between two levels of detail is matched rather than hidden. Where a patch drawn at full detail meets one that skips every fourth sample, the fine edge has vertices the coarse edge does not, and those sit on the field while the coarse edge cuts the chord beneath them. The gap is a hole through to the sky. The usual remedy is a skirt — a vertical curtain dropped from every patch edge, the wrong colour, lit the wrong way, and paid for along every boundary in the world for ever.
neighboursmoves the fine edge's odd vertices onto the coarse edge's own straight segment instead, so the two are one polyline and there is nothing to fill.Measured on an AMD card at 1280x720, both backends. An unmatched seam lets 15,401 pixels of the world through — counted per column below that column's own horizon, so sky is never mistaken for a hole — and a matched one lets through exactly zero. The two backends agree on both figures and byte for byte on both frames, the geometry being built once on the CPU and handed to each.
What matching costs, stated because it is real: along a matched edge the drawn surface is the coarse chord rather than the field, so the query and the picture differ there by up to the sag of one coarse cell — the same error the coarse patch carries over its whole area, arriving one cell early.
Several materials blend across one field, as vertex colour.
TerrainMaterialstakes a weight map andheightfieldPatchbakes the blend into the vertices — the idiomatic answer here rather than a compromise, this engine's colour being vertex data. A splat map read by a shader would mean a texture bound on every terrain draw and a branch inflatFrag, which Track D measured at 19.8 KB gzipped generated into sixteen fragment permutations; this cost 144 bytes and nothing at runtime. What it gives up is sharpness: the blend is only as fine as the mesh, so a consumer wanting a road edge draws that patch at full detail — the dial the geometry already has. The weights are read bilinearly, which is the opposite of how a height is read and for the opposite reason: a weight is not drawn, so there is no triangulation to agree with, and reading it as triangles would put a crease along every cell diagonal for nothing.It collides, through the mesh the picture is made of.
meshShapetakes exactly the two arrays a patch hands back, so the geometry drawn is the geometry collided with and nothing is authored twice — asserted rather than claimed: a ray cast against that static body lands whereheightAtsays the ground is, at forty points off the lattice.That assertion found a real defect in the first half of this release. The mesh was split along one diagonal while the query read the other, so the two surfaces disagreed by up to a centimetre everywhere except along the anti-diagonal. Nothing caught it: every vertex lies on both triangulations, so per-vertex assertions passed either way — and every test field was of the form
f(x) + g(z), which is separable, and for a separable field both diagonals give the identical surface. The fields carry a cross term now, and a test reads the mesh's own triangle between the vertices.drift/terrainis bound, which is one fewer module the linker refuses:heightAt, three normal components,slopeAt,coversand the two extents, all deterministic reads, so a@deterministicsystem may ask where the ground is. They are declared underphysics.read, and that is a description rather than a borrowing — the terrain is the collision surface, which this package's tests assert — but the reason is that the language names noterrain.*effect at all.drift/behaviorwas given its two ahead of any provider precisely so a track would not need a language release first; terrain was missed.docs/IMPROVEMENTS.mdcarries the two lines that would fix it.And it collides as a heightfield, not only as a mesh.
heightfieldShapein@driftengine/physicsindexes the cell under a body straight from its x and z and carries the heights it was handed and no geometry at all — no vertex buffer, no index buffer, no tree. At a 129-square field a mesh collider's positions and indices alone are more than five times the bytes of the heights, before the tree over them is counted. The shape itself cost 949 bytes in the physics package.It is not a second narrow phase, and that is the decision the row turns on.
meshContact.tsstill owns every contact rule — many manifolds rather than one, one-sided triangles, and the interior-edge filter that stops a box stumbling on a flat seam — and a field changes only where triangles come from: an index range instead of a tree query, four samples instead of an index buffer. A second copy of that filter would have been the most expensive duplication in the package.The proof is differential rather than descriptive. Both colliders are built over one field and a box is walked across it, and the normals, counts and separations must be identical at every pose; splitting the field's cells the other way turns that red. The flat-floor case is asserted directly as well, because a heightfield collider that reproduced the mesh path's own bug would pass a comparison against it.
The triangulation rule now exists in two packages, and is guarded rather than trusted.
@driftengine/physicsimports no other engine package, so it cannot see aTerrain— but aTerrainis aHeightfieldstructurally, which is howheightfieldShape(terrain)type-checks with no import in either direction. Sixty rays are cast at both colliders and at the query, and all three must agree. That is the arrangementglDepthFunchas withDEPTH_COMPARE, for the same reason: a boundary that forbids sharing the code does not forbid sharing a test.
3.37.1 · 2026-09-02
- note
Track D's list of what it does not do had two entries that nothing could close. Refraction and screen-space global illumination were named as absent in
ROADMAP.mdandCAPABILITIES.mdprose and had no sentinel and no README bullet — and a sentinel is what makes a gap closeable here:docs.test.mjsasserts that each named symbol is still missing, so the suite goes red on the day one arrives, which is the day the document needs editing. A gap living only in prose goes quietly wrong instead, which is how this documentation set once fell a whole major version behind.Refraction is scheduled and priced. It cannot be a region pass like the drawn decal and the screen-space reflection that shipped beside it: those apply to whatever the depth buffer holds inside a box, and a refracting surface is translucent and therefore drawn after that. Offsetting the sample of a region containing glass reads the glass itself. What it needs is a snapshot of the colour taken before the translucent set, bound into
flatFragand sampled at an offset along the surface normal — a new sampler in the shader every draw already uses, which is the one place in this engine where a capability is priced by the permutation count rather than by its own size.docs/IMPROVEMENTS.mdcarries the design and the numbers.Screen-space global illumination is refused in writing, on the same terms DS-7 and AI-7 were. It is the one screen-space effect whose error is not bounded by a fade: a reflection that runs out of screen fades to nothing, and indirect light that runs out of screen changes the brightness of the room — so a viewer sees a scene that dims when they turn their head. The honest version wants temporal accumulation, a disocclusion test and a denoiser, which is three subsystems for one effect. It carries a sentinel too, so building it fails the documentation rather than passing unnoticed.
3.37.0 · 2026-09-02
- added
A planar reflection re-renders the world from a mirrored camera for one horizontal plane at one height. That is exactly right for a lake and gives nothing for a floor that undulates, a bonnet or a tilted pane — and it costs a second pass over the scene.
ReflectiveSurfaceandrenderer.drawReflectionmarch a reflected ray against the depth the frame has already drawn, sampling the finished picture where it lands. One scissored fullscreen pass per surface, the surface followed per pixel whatever shape it is, and exact where an object meets the floor — the part of a reflection a viewer reads first.Reflective is a region rather than a material, and that is a fact about a forward renderer. There is no G-buffer, so nothing on screen records which surface was polished; recording it would be a second colour attachment written by every draw path in the engine, which is the price the temporal row already declined for a velocity buffer. A caller declares a box and a facing rule instead — the shape
DecalProjectoralready has.Measured on an AMD card at 1280x720, both backends. Two identical blocks stand on one floor and a surface covers the half under one of them: 17,022 reflected pixels on WebGL2 and 17,008 on WebGPU under the declared half, and 0 under the other. The control is in the scene rather than in the check — the second block differs from the first in nothing but the region beneath it. Raising the blocks a metre moves their reflections down the screen, 524.4 to 615.9, which a tint or a decal would not do; building the trace with the other backend's Y constant leaves every per-backend assertion green and turns that one red, the reflection moving up instead.
The march is quadratic rather than evenly spaced, and the difference is the contact seam. Evenly spaced, a ten-metre reach in 24 steps leaves a dark band along every contact and 11,826 reflected pixels; spacing the samples by the square of their index gives 13,890 with the band closed — a first step of centimetres where it matters and a last one of nearly a metre out where a reflection is faint and about to leave the frame.
It can only reflect what is on screen, which is the honest limit of every screen-space method. A ray that leaves the frame, points back toward the eye, or runs its whole reach fades out rather than answering with the wrong pixel. A hit is a crossing and never a comparison — the ray must be in front of the scene at one sample and behind it at the next, which is what keeps a grazing ray off its own surface — and a crossing further behind than
thicknessMis refused, without which a ray sliding onto something in the foreground pastes it into the reflection.7.5 KB gzipped on every core entry point. It needs
screenEffectsand excludes multisampling, refusing in the same words on both backends: a multisampled attachment is not a texture until it has been resolved. Eight surfaces a frame is the cap.
3.36.0 · 2026-09-02
- added
projectDecalclips a mark once, against the mesh as it stood. That is exact, lit as the surface is lit and free every frame after the one that built it, and it is the wrong shape for a surface that then changes: a mark on something that deforms rides the old shape, and a mark on something that streams in later cannot be made at all, because there was nothing to clip.DecalProjectorandrenderer.drawDecalkeep the projector alive instead. The pass reads the depth the frame has already drawn, turns each pixel back into the world point it stands for, and marks it where that point is inside the box — so cloth, a heightfield being rewritten, a skinned mesh or an instanced crowd is marked on the frame it is drawn.It multiplies rather than covers, and that is the design rather than a shortcut. A forward renderer has no G-buffer, so by the time this runs the pixel is already lit and there is nowhere to write an albedo. Painting a flat colour over it would light a scorch mark by nothing at all and make it glow in an unlit corner; multiplying takes the receiver's lighting exactly, and on a diffuse surface it is identical to having marked the albedo before the light was applied. The cost is that a mark can darken and tint and can never brighten.
Measured on an AMD card at 1280x720, both backends. A floor is marked and then deformed under the mark through
updateMesh: a decal clipped from the flat floor goes from 94,736 px to 0, and the projected one from 77,522 to 79,914. The first half is the control — without it the second is a statement about a scene that never changed. The two backends agree to a tenth of a pixel on both the covered area and the centre of the mark, having reconstructed it through their own framebuffer conventions; building one with the other's constant moves that centre by eleven pixels, which is whatscripts/decal-check.mjsis set against.4 KB gzipped on every core entry point, against the 19.8 KB order-independent transparency cost in the release before it. The difference is the whole lesson: that one added a uniform and a branch to
flatFrag, which is generated into sixteen fragment permutations and five vertex ones, and this is a shader of its own, generated once.What it does not do. The mark is a procedural ellipse with a soft edge and a facing fade rather than a texture, since a per-decal image is a bind group per decal on WebGPU. It needs
screenEffects, having no depth to read without the off-screen target, and refuses in the same words on both backends — WebGPU never opens a composite for that profile, so its marks were being dropped in silence while WebGL2 explained itself, which is what the first run of the check caught. It knows only what the depth buffer knows, so anything drawn without writing depth — a beam, a particle — is in front of the mark and is darkened with it. Thirty-two marks a frame is the cap, above which it says so once and draws the first thirty-two.
3.35.0 · 2026-09-02
- added
Sorted alpha blending is order-dependent by construction —
overdoes not commute — so a translucent set is drawn back to front and anything the sort cannot separate wins arbitrarily. Where two panes intersect there is no back-to-front order at all: each is in front of the other over part of the screen, so sorting harder cannot help and the usual remedy is splitting the geometry to work around the renderer.renderQuality.orderIndependentreplaces the ordering with a weighted sum and a running transmittance. Both commute, so the frame stops depending on submission order. It is exact for a single layer, where there is no ordering to approximate, and an approximation for several, near layers counting for more than far ones.Measured on an AMD card at 1280x720, both backends. Two intersecting panes drawn in both orders give digests
2a644bb9andbb4cb7b1with the effect off, andf8913765both times with it on — byte-identical, and the same digest on WebGL2 and WebGPU, which reach it by different mechanics. Switching the effect off turns that assertion red on both, which is howscripts/oit-check.mjswas shown to have teeth.Two passes over the translucent set rather than one with two outputs. Writing both buffers from one pass needs a second fragment output and therefore another
flatFragpermutation — about 247 KB gzipped, paid by every consumer whether or not they enable this. The weighting is a uniform branch instead, which costflat.wgsl.ts18 KB, and the geometry is submitted twice on the frames that asked for the effect.Needs a float colour buffer: the accumulation is a sum whose weight reaches three thousand, so eight bits of it is white after one bright layer. Without
EXT_color_buffer_floatit refuses and says so once. Multisampling is excluded.
3.34.0 · 2026-09-02
- added
An edge past a certain angle is two colours and nothing between, and which one a pixel takes flips as the edge crosses its centre — the crawl seen on every slow pan.
renderQuality.temporalAajitters the projection a fraction of a pixel each frame and blends the result into where the last frame was, so eight sub-pixel samples land either side of the edge and put it where it actually is. What MSAA buys inside a frame this buys across them, for one texture and one fullscreen pass.The jitter goes on the matrix that draws and never on the one that reprojects. The history holds a resolved picture standing for the unjittered scene, so reprojecting through a jittered matrix would look every sample up half a pixel from where it is and cancel the accumulation. Shadow maps take their own matrices and are untouched, which is correct: jittering a shadow map moves the shadow rather than the sample.
Measured on an AMD card at 1280x720, both backends. An unresolved edge has zero pixels between the two colours it separates; a resolved one has 1,078 on WebGL2 and 1,101 on WebGPU, and the frame's mean brightness moves by 0.007% — antialiased and not displaced, which is what the centred jitter sequence buys. Turning the accumulation off with the jitter left on returns both backends to zero, which is how
scripts/temporal-aa-check.mjswas shown to have teeth.Off by default, and that is not timidity: jittering the projection moves every pixel of every frame by construction, so defaulting it on would move every published capture and every consumer's reference images with them.
The motion is the camera's. That is exact for a static world under a moving camera and wrong for a moving object under a still one. The neighbourhood clip rejects the ghost where the background differs and cannot where it does not; a velocity buffer is what would fix it, and it means a second colour attachment on the scene pass with every draw path writing into it.
demo/dev/taa.htmldrives it,?taa=0against?taa=1. - fixed
The reprojection matrix was built and
previousViewProjwritten only whilecameraMotionBlurwas above zero. Any other effect reprojecting through it — the temporal resolve is the first — would have read a matrix nothing had ever written, reprojecting every pixel through an identity and sampling the history at the wrong place for the life of the renderer. Both backends now keep it whenever anything asks.
3.33.0 · 2026-09-02
- fixed
KHR_materials_pbrSpecularGlossinesswas not read at all, and a material using it carries nopbrMetallicRoughnessobject — its colour map isdiffuseTexture. The reader therefore found no base colour map, returnedalbedo: -1for every surface, and the file's images were decoded, embedded in the bake, and sampled by nothing. Measured on a 1995 Fiat Punto GT: 20 textures loaded, 0 bound, 144 of 144 meshes reported as carrying no albedo map, and the whole car painted in one flat fallback colour — no badge, no grille, no glass tint, no tyre tread, on a model whose largest single texture is a 10 MB body map. It is Khronos' first PBR material model, archived in 2020, and still what an asset store serves for anything exported from a specular workflow.The specular colour and the glossiness are solved for jointly rather than renamed. They are not a metalness and a roughness under other names: a dielectric's reflectance is fixed at 4% and its colour lives in its diffuse, while a metal has no diffuse at all and its colour lives in its specular, so a given pair is produced by exactly one metalness between those ends. That is the extension's own Appendix B, conversion and base-colour derivation both, and the derivation is what makes chrome — diffuse black, colour entirely in the specular — arrive as chrome rather than as a black mirror.
A
specularGlossinessTextureis the half that cannot be carried, and it is left absent rather than guessed. It packs the specular colour in RGB and the glossiness in A; glTF's ORM packing wants roughness in G and metallic in B, so producing one means decoding and re-encoding every image to move a channel — a baker's job and not a reader's. Binding it unconverted would be worse than binding nothing, because the renderer would read a specular colour's green as a roughness. Such a surface draws as a dielectric at the engine's default roughness, which is the rule a metallic-roughness material with an ORM map already follows, and the reader names every material it applies to — once per material rather than once per primitive, which on that car is four warnings instead of a hundred and forty-four.A file carrying both blocks is read as the core one. The extension's text says the opposite, and that rule is written for a renderer that supports it completely; an exporter that wrote both did the conversion with the texels in hand and put a real
metallicRoughnessTexturein the core block, which is a better answer than this conversion can compute.And it reaches a
.glb. The workaround this replaces rewrote the.gltfJSON before the reader saw it, so a.glb— whose JSON is a length-prefixed chunk inside the file — got nothing from it. The choice is made on the parsed document, which is what both spellings become, so there is no second path to keep in step. - fixed
Every image the source declared was embedded, whether or not anything could sample it.
MATLcarries four texture ordinals and they indexTEXS, so compacting that list without rewriting them repaints the model — which is exactly whyreadModelleaves a reference that resolved to nothing in place, and whyembedTextureswrites a 1x1 placeholder instead of dropping an entry. Each of those was right on its own, neither could do the whole job, and so nothing did.It is one function now and it does both halves together: the entries nothing reaches go, the ordinals that stay are renumbered, and every nested level does the same for itself because a level is a whole asset. A dropped image is never opened, so a block-compressed texture nothing samples costs no decode and no PNG re-encode — the stage that turned 3.6 MB of DDS into 11.6 MB of RGBA on a shipped car.
Measured at a whole model on the case that prompted it: a car written in glTF's archived specular-glossiness model embedded 20 textures and reached none of them, the largest a 10 MB body map. That half is closed by the reader understanding the extension; what stays unreachable is the specular-glossiness map itself, whose channels no rearrangement fits into an ORM one, and that is what this leaves out.
The baker says what it left out, and what the model carried for it, because an image silently missing from a bake is not a saving:
1 image no material samples, not embedded: body_sg (8 KB the model carried).The cost is stated rather than hidden. An image no material samples is no longer in the asset, so
TextureSet.getthrows for its name the way it throws for any name the file does not have. A consumer that wants an unsampled image out of a container needs a way to say so; nothing does today, and the alternative is every asset carrying its source's whole texture folder.HANDBOOK§3 had promised this behaviour — only images a material actually reaches are embedded — for longer than it had been true.
3.32.0 · 2026-09-02
- fixed
Past
MAX_DRAWS_PER_FRAMEorMAX_MATERIALS_PER_FRAMEthe backend skipped the work, and went on skipping it every frame. A consumer whose world simply got bigger lost a stretch of it permanently — reported from a game as ground that appears and disappears as the camera moves. The ceiling was sized for a phone's memory, so the number could not be raised far enough to be safe; what was wrong was that it was fixed at all.Both rings now grow at the start of the frame after one that ran out, to the next power of two above what that frame asked for. Measured on a real WebGPU device against a scene of 1,200 material changes: the first frame reports
materials 1200/1024 dropped 176and the third reports nothing dropped, drawing all 1,200.At the start of a frame and nowhere else, which keeps
UniformRing.allocate's refusal to grow intact for the reason it gives: growing where a draw runs out would stall the frame to make room. AtbeginFrameeverything outstanding has been submitted and nothing has been written into the staging that discarding it would lose.Growing replaces the
GPUBuffer, so every bind group holding it is rebuilt — the cached albedo groups, the skinned twins and the blank one a pass starts from. The blank is rebuilt with the material maps forced to null rather than with whatever the last frame left set, or a group that means no maps at all would quietly carry one.Bounded at 64 MiB of staging a ring, past which it refuses and goes back to skipping and reporting: a runaway frame should degrade rather than crash a phone. For the flat vertex ring that is about forty-three thousand draws.
The other eleven ceilings are unchanged. They are feature counts — sixteen bodies of water, eight flocks — rather than measures of how much world is in frame, and a consumer reaching one has a question a bigger ring does not answer. They report through
frameBudgetlike everything else. - added
A per-frame ceiling firing was unobservable, and that cost a consumer weeks. The WebGPU backend holds thirteen of them — draws, material changes, shadow draws, water bodies, light volumes, three kinds of effect batch, overlays — and past any one it skips the work and writes a single
console.warnfor the lifetime of the renderer. That cannot be asserted on, cannot be read from a headless check, and is gone by the time anybody looks. One consumer's open report on ground that appeared and disappeared as the camera moved still listed, as the experiment nobody had run, ask the reporter for the console; it had written a draw counter and a per-material field of its own to guess at a worst case the renderer already knew exactly.renderer.frameBudgetis that number. Each line carries what was asked for — not what fit, which is the distinction that makes it actionable: a ring naturally reports 1,024 of 1,024 and says a frame is exactly full, where 1,267 of 1,024 says how much to cut.frameBudget.droppedis one boolean a consumer's own suite can fail on, and it stays correct when a ceiling is added that the check was written before.Counted in
UniformRingrather than at the thirteen call sites, because the ring is the only place that knows both halves. It also means the count matches what each ceiling actually governs:materialSlotForDrawreuses an open slot without allocating, so the material line counts material changes and not draws.WebGL2 reports the same lines with a
nullceiling, which is the point rather than a gap. It imposes no per-frame limit on anything, so a scene over a WebGPU ceiling draws in full there and loses geometry here, with nothing failing on either side — and most development happens on WebGL2, because it is the fallback that runs everywhere. A consumer can now compare its own count against the ceiling the other backend publishes, before a player finds it. - fixed
WebGPU: more than 1024 materials in a frame; the rest reuse the lastwas wrong in the commit that wrote it.materialSlotForDrawreturns null and the caller skips the draw; nothing has ever been drawn with another material's numbers. Both halves went in together, so this is not a message left behind by a later change — it has never described the code.It matters because the two failures send you to different places. Wrong texture is a material problem; missing geometry is a culling or budget problem. The consumer that reported this went looking at culling, which is where its weeks went, while the console line it was reading described a symptom the renderer cannot produce.
And the wrong failure was the stated argument for the ring's size. The comment at
MAX_MATERIALS_PER_FRAMEjustified raising it from 256 to 1,024 with reads as a stretch of the world losing its texture and has been reported twice from a consumer. The number may still be right; the reasoning recorded beside it was not. Both now say what the code does, and a test pins the behaviour rather than the wording so they cannot part again. - fixed
restores uLightingEnabled/uFogEnabled even when the material ring is exhaustedand its sibling both wroteconst materialRingCapacity = 256and issued 257 draws. When the ring became 1,024 they went on passing without ever reaching the null return they are named for — measured before the fix at 257 slots taken of 1,024. The restore they assert happens on the ordinary path too, which is why nothing failed.They read the ceiling off
frameBudgetnow, so a future raise cannot unhook them again. That is the same defect one layer down: the ceiling was unobservable from outside the renderer, and the tests could not see it either.
3.31.0 · 2026-09-02
- added
sampleSpringandsampleSpringChainanswer where is it at time t, for anyt, in any order. Nothing accumulates between calls, nothing reads a clock, and the sametgives the same three floats whether the caller reached it by playing forwards or by dragging a playhead back — which issampleClip's contract and the reason this sits beside it rather than in@driftengine/physics.A ragdoll is the obvious tool for this and is the wrong one, for a reason the consumer who asked wrote down before we did: a ragdoll integrates state, so a preview reading an audio clock and an export reading a frame index disagree, and two exports of one project differ. What makes a pure version possible is that a damped spring forgets. The anchor's influence decays as
e^(-ζω(t-τ)), so evaluating attis: start at rest a settle time earlier, march forward, return.springSettleSecis that lookback, derived rather than dialled.Each substep is solved in closed form, and that is a measurement rather than a preference. Stepping the equation with semi-implicit Euler missed a four-thousand-step reference march by 0.058 at thirty-two substeps per period, 0.029 at sixty-four and 0.0075 at two hundred and fifty-six — first order, and it needed about a thousand substeps a period to hold a hundredth, three thousand per joint per frame. Subtracting the moving anchor's own trail leaves an unforced oscillator, whose solution over a fixed step is a 2x2 built once per link: the error is second order, 4.0e-4 at thirty-two substeps, and the cost is ninety-odd steps of four multiplies.
A chain is not a list of springs, and
springChainSettleSecis where that shows. Each link hangs off where the one above it actually is, so a cascade holds a disturbance longer than any of its links: the composite carries a polynomial intbeside the exponential and the lookback grows with depth. Sampling links independently would also coststeps^depthanchor calls, so the whole chain marches on one grid —steps × depth, same answer.maxOffsetMis a limit and not a stiffness change, applied to the finished position rather than inside the march, because clamping a position the spring never reached back into its own velocity makes a different spring. Damping of exactly 0 is refused by name: an undamped spring rings forever, so no lookback is long enough and there is no honest answer to give.
3.30.2 · 2026-09-02
- added
A
DX10header names the format in a 20-byte extension rather than in the four characters at byte 84, and the blocks behind it are the same blocks this reader already decoded.DXGI_FORMAT_BC3_UNORM_SRGBis bit for bit the BC3 announced asDXT5— sRGB is a statement about how the values are read, not about how they are stored. The consumer who reported it counted the bytes: 5,592,580 against 5,592,560 for the classic spelling of the same 2048² chain, which is 148 + 5,592,432 against 128 + 5,592,432.BC2 (DXT3) decodes too: eight bytes of explicit four-bit alpha, one nibble a texel, scaled by replication so
0xFarrives as 255 and not 240.Measured on the six vehicle bundles it was reported from: 31 surfaces of 766 that returned a 1x1 white pixel now decode — 19 at BC3 behind a
DX10header, one at BC1, and 11 DXT3. The nineteen are one car's paint, its wheels, its lamps, its plate and seven interior maps, and they cost that consumer a lighting bug rather than a missing texture: white in an ORM map is roughness 1 and metallic 1, and a fully metallic surface in the flat shader has no diffuse and no sun term. The bodywork stopped being paint and became a blurred mirror of the field the car was parked in.What is still refused, and on purpose: a cube map, a texture array or a volume behind that header, because a classic header cannot state any of them and accepting one would hand the caller six faces claiming to be a single surface. Also BC4, signed BC5, BC6H and BC7, none of which this reader decodes.
If you carry a header rewrite of your own, it can go.
blockDdsFromDx10inconfine'sscripts/dds.mjsexists only because this reader would not take the file. - added
A pixel format that describes channels rather than naming a compression, which this reader refused for as long as it has existed. There is no block to decode, so it is a bit count, four masks and a copy.
One mask walk and no per-format branch, because the masks are the format — a reader with a case per layout is a reader with a case missing. The layouts one consumer's six vehicle bundles carry, surveyed rather than guessed: 32-bit BGRA and RGBA, 24-bit BGR, and 8- and 16-bit luminance with and without alpha.
Three things this gets right that are quiet when they are wrong, each with its own test. A narrow channel is scaled by replication, so five bits of
0x1Fis 255 and not 248 — otherwise a white surface comes back very slightly grey on every texel. A 24-bit surface has no alpha channel and comes back opaque rather than empty. And a declared row pitch is honoured, because a decoder that assumeswidth * byteswalks diagonally through a padded surface and returns the right picture progressively more sheared down the frame.Measured across those bundles: all 766 DDS surfaces now decode and none is refused, against 477 and 289 before this release.
- fixed
decodeColourBlockwas reached unconditionally, so BC3 inherited BC1's three-colour mode. BC2 and BC3 carry alpha in their own half of the block, which leaves no index over to mean transparent: their colour half is always the four-colour opaque mode, whatever the order of the endpoints. A BC3 block written withc0 <= c1therefore decoded its fourth index to transparent black instead of to the interpolant two thirds of the way between them.Invisible in every test this reader had, because they all order their endpoints the other way, and uncommon in real data because an encoder with no reason to write them low usually does not. Found by reading BC2's specification while adding it — it says the same thing about the same bytes — and it has a test that fails without the fix.
- changed
glTF's ORM is occlusion in R, roughness in G and metallic in B. AC's
txMapsis not that packing — two independent references give it as specular intensity in R, reflection and specular sharpness in G, and reflection intensity in B. Sharpness is the inverse of roughness, so a G pinned at 255, which is what the paint map of every bundle measured holds, is a file asking for the sharpest reflection available and was being read as the bluntest. Measured, G against each material's own roughness: the Hyundai i20 N 255 against 0.180, the Fiat Panda 255 against 0.140, the Giulia GTAm 255 against 0.196.The half of that which needed no reference is that
roughnessis derived fromksSpecularEXPtwo lines above the binding and is correct, and the shader replaces it with the map's G rather than scaling it. The reader computed the right number and then discarded it for a channel it had already described as unestablished.Not binding it is not a claim about what
txMapsmeans; it is declining to assert a packing that is not there. The reader now says so once per model rather than reading the texture past in silence.If you have tuned anything around AC paint coming out wrong, re-tune it. Every imported AC car's paint changes: it keeps the roughness its own material states instead of 1.0.
docs/IMPROVEMENTS.mdcarries the measurements and what would justify reading the map properly — a shader reference forksPerPixelMultiMapsettling the curve between AC's sharpness and this engine's roughness.
3.30.1 · 2026-09-02
- fixed
Four passes wrote their per-draw uniforms into one shared buffer and then recorded a draw.
queue.write*calls are ordered on the queue timeline and the frame's encoder is submitted after every one of them, so the last write reached every draw: two bolt pools, two flocks, two wind-street lattices or two caustic batches in one frame all came out wearing the second one's numbers. Each of these takes a handle per batch, so drawing several is what the API is for — and one of each is correct, which is why it never showed. Water had the same defect and lost three bodies of four.Each now takes a slot from a
UniformRingand binds it by dynamic offset, which is the fix water took on 2026-08-28. A batch past the ceiling of eight is told once and skipped, because a batch that cannot be addressed must not be drawn with another batch's numbers.Measured on
demo/dev/batches.html, which draws two of each tinted red and blue, with WebGL2 as the control since it sets uniforms and draws in one stream. The first batch's signature in the frame with both, before and after: bolts 0 then 4,492, caustics 0 then 21,088, the flock 0 then 1,699, wind streaks 0 then 1,003.npm run check:batchesis the instrument.The flock said more than the tint: both flocks drew at the same centre, so the shared slot was carrying placement too — the blue count stayed at exactly one flock's instead of doubling.
A scene drawing one batch each is unchanged.
storm-seaon WebGPU — a flock, arcs and rain, one batch each — before against after: 0 of 750,080 pixels differ across the frame the harness compares. Both captures were taken against a dev server started for the purpose: one left running across the edits serves a mixed module graph, and a first attempt at this measurement was taken from one that had a pass compiled with dynamic offsets against a renderer compiled without them, which drew a black frame and looked exactly like a regression.
3.30.0 · 2026-09-02
- added
DrftLoadermerges parts whose material would set the same GPU state, and until now the key that decided it was built inline. A consumer's baker reports how many draws each baked part will cost, and to do that it rebuilt that key fromDrftMaterialby hand — its own comment said "DrftLoader.uploadOne's own key, field for field", which is exactly what it was.It went one field short the day
cutoutjoined the key in 3.26.0, and a short key does not throw: it merges two surfaces the loader keeps apart and under-counts.drawKeyOf(material, override?)returns the key andresolveDrawGroupingreturns the ten fields behind it, both from@driftengine/assets.uploadOnecalls them, so the inline key is gone rather than duplicated and the two cannot drift.The test asserts one case per field rather than looping the ten names, because the defect being closed is a field going missing and a loop cannot fail for an eleventh that nobody added to it.
- changed
A block-compressed texture is decoded so a bought model's maps survive the bake, and the decoded surface was then embedded as
CODEC_RAW— on the argument that a bake runs offline and never in a frame. That is true of the time and not of the file, which a browser downloads. Measured on a shipped car's level of detail B: 21 DDS textures, 3.6 MB in the source, 11.6 MB of RGBA in a 22.8 MB container, 3.3x. A consumer wrote the missing encoder themselves and reported it as the difference between a model that needs sharding to clear a static host's per-file limit and one that does not.encodePngnow sits beside the decoder that was already there:node:zlib, one filter chosen per row by the sum of absolute residuals, about a hundred lines, Node-side and offline, so nothing a game links grows. Both ends are lossless, so the pixels are the source's.The repository's own PNG reader is the wrong oracle for half of it, so a browser was used for that half.
decodePngnever reads a CRC, so an encoder writing zeros there would round trip through it perfectly and be refused by every browser atcreateImageBitmap. Loaded into headless Chrome, drawn and read back: 0 of 12,288 samples differ.The baker had no test at all, which is how this stood for three minor versions. It has one now, and it reads
TEXSby the layoutFORMAT.mdstates rather than with this repository's reader, since a reader and a writer sharing a mistake agree with each other. - changed
generateTangentsaccumulates a frame per index, so a vertex shared by several triangles comes out smooth — which is what it was written to do. Run on a mesh as a great many files store one, a corner soup with one vertex per triangle corner, every corner is its own index and gets exactly one triangle's frame. No two corners at a point agree, andweldMeshcannot merge them, because two corners alike in position, normal and UV but opposite in the bitangent's sign are a mirrored UV shell and merging those lights one side of a model inside out. Nothing can tell the two cases apart from the data.readModelandgltfToMeshestakederiveTangents, on by default, andderiveTangentsForis exported besideweldMesh. The baker passes false and derives afterwards, on merged topology — which is a smaller mesh and a better frame, since sharing is exactly what the derivation averages over. A consumer reading a model straight into a renderer never welds and keeps today's behaviour, so this is an option arriving and not public behaviour leaving.Two figures in this repository disagreed about what it costs, and both describe a real case. The frame is normalised, so two triangles differ only where the direction u increases in differs. A separable unwrap — u from x, v from y — gives every triangle on a flat surface the same frame: seven vertices of 728,168 on a 253-mesh CAD export. A rotational unwrap turns the direction with position and costs almost the whole weld: 9,600 corners to 9,482 vertices against 1,681, with six different frames at one point at worst. End to end through the baker, a normal-mapped soup bakes to 169 vertices against 830.
- note
A claim in a comment became a measurement. Four WebGPU passes are on record handing every draw in a frame the last draw's uniforms —
drawBolts,drawFlock,drawWindStreaksanddrawCausticseach write one shared buffer withqueue.writeBufferwhile the frame's encoder is submitted afterwards. Water had exactly this and lost three bodies of four.drawLightVolumeis documented as taking its uniforms from a ring, and nothing had ever checked, because one cone is the arrangement in which a shared slot draws the right picture.demo/dev/volume.htmlgained?cones=,?only=,?phase=and?warm=, andnpm run check:conesreads the answers on both backends. Two volumes in one frame are the sum of each drawn alone, 0 of 844,800 pixels, nothing clamped. A pose swept into over twelve frames is the pose drawn cold, 0 of 844,800, in haze. So a consumer sweeping a beam through a scene can draw as many as the frame will take.The check was watched failing at 21,292 pixels with the last draw's strength and dust forced onto both draws, so it can see the defect it exists for. No engine code changed for this entry, which is the point of it being a note: the row is closed by evidence rather than by a fix.
3.29.1 · 2026-09-01
- fixed
3.29.0 added
instancedtoShadowCasterSinkas a required member, which is a breaking change and should not have shipped in a minor.That interface is implemented outwards: a consumer writes a sink, and its tests write doubles of one. Adding a member every existing implementation lacks stops all of them compiling — found immediately in a consumer, where three test doubles went red against a feature that game does not use and has no batches for.
It is optional in the type now and unchanged in spirit: the renderer always supplies it, so
sink.instanced?.(batch, data)never actually skips, and a consumer written before instanced draws existed has nothing to declare through it.The lesson is one this repository already writes down. Because consumers bundle this source directly, a change to the public barrel is live in every consumer the moment it is saved, and it has to be verified against a real one before it is committed. A typecheck here cannot see a consumer’s own implementations of an interface it exports.
3.29.0 · 2026-09-01
- added
Thirty copies of one model are one draw call — and, the half that matters more, one slot of the frame’s material ring.
createInstancedattaches per-instance placement and colour to a mesh already uploaded;uploadInstancedpushes the live prefix;drawInstancedanddrawTranslucentInstancedsubmit the batch;disposeInstancedreleases it.MeshInstancescarries a column-major matrix and an RGB tint per instance, andcreateMeshInstancesallocates both arrays once so a frame that re-uploads a moving batch allocates nothing.Distinct from
createScatter, and not merged with it. A scatter owns its own base geometry and carries a uniform scale, a yaw and a wind response, because it describes a plant. This attaches to a mesh the consumer already uploaded — the geometry an instanced draw repeats is usually a loaded model, and a second copy of it is the cost this exists to avoid — and carries a full transform, because a vehicle pitches and rolls on its suspension and a yaw cannot say so.Shadows came with it.
ShadowCasterSinkgrows aninstancedverb and the depth stage a matching variant. Without it a batch casts one shadow, from whichever matrix was last bound, with the other twenty-nine missing — which reads as a lighting fault rather than as a pass nobody wrote.Three refusals, each loud rather than a silent degrade. A skinned mesh cannot be instanced: eleven of WebGL2’s guaranteed sixteen attribute locations are the base mesh and the five that remain are exactly the matrix and the tint, so an instanced pipeline reclaims locations 11 and 12 from the joint indices and weights — free for an unskinned mesh, which carries both as constants, and impossible for a skinned one, which interleaves them. A morphed mesh cannot either, and that one compiles, which makes it worse: a morph weight is per draw, so every instance would wear one expression between them. And a mesh may have one batch, because WebGL2 binds the attributes to the mesh’s own vertex array.
What would make the layout wrong is a twelfth base attribute, which cannot coexist with instancing at all.
Driven on hardware on both backends by
demo/instancing.ts: two ranks of thirty blocks, same geometry and material, thirty draws against one, each block its own colour, both casting into one shadow pass through their own verb. Swapping which rank is instanced — changing nothing else — produced a frame identical in 0 of 704,000 pixels. - changed
AGENTS.mdhad said< 100 draw callssince before the first production consumer existed. That consumer measures 744 draws in its worst world frame and has since its world existed — so the budget was not a target anybody held to, it was a number a reader would either design around needlessly or, correctly, ignore.Replaced by bands: roughly 1,000–2,000 for a high-refresh target, 2,000–10,000 for ordinary 60 fps, and far past that where the draws are instanced.
MAX_DRAWS_PER_FRAMEis 4,096 and is a ring size rather than an opinion, sized so a consumer inside those bands never meets it.And the number to hold down is material changes rather than draws, because that is the ring whose overflow is silent: past it every subsequent draw reuses the last material set.
drawInstancedis the answer where the same mesh repeats. - added
TranslucentMeshOptions.tintis the per-draw colour multiplierdrawMeshhas always taken as its fourth argument, now available to the blended path as well.The two paths were not interchangeable for a caller that tints, and the gap only shows during a transition. A consumer fading a coloured model in makes every one of its surfaces translucent for the length of the fade, so every surface went through
drawTranslucentMesh— which wrote no tint — and the model arrived in its own vertex colour before snapping to the runtime one as the fade completed. Reported from a game whose cars arrive that way.In the options rather than as a fifth positional parameter, because it is one of the same set of per-draw decisions the rest of that interface holds. Scoped and handed back on both backends, so a tinted draw cannot leak its colour onto the next one.
3.28.1 · 2026-09-01
- changed
The two per-frame ceilings on the WebGPU backend were ring capacities sized against this repository's own demo scenes, and nobody had asked what raising them costs.
MAX_DRAWS_PER_FRAMEgoes from 1024 to 4096 andMAX_MATERIALS_PER_FRAMEfrom 256 to 1024.The measurement is the whole argument.
UniformRing.flushuploadsused * slotSize, so a frame that draws two hundred pays for two hundred whatever the capacity says — a bigger ring costs no per-frame bandwidth at all. What it costs is one-time allocation, and only for the parts that cannot grow: the command pool and the node arena already reallocate on demand, so of the five things the draw ceiling sizes, three were never ceilings.A draw slot is 1,540 bytes and a material slot 7,168 to 13,312, depending on which fragment permutation a profile builds. Both raised is 10 to 14 MB once, on a backend where a single loaded car body can be 36 MB.
The material ring is the one worth paying for, and for how it fails rather than how it performs: past it, every subsequent draw silently reuses the last material set, which reads as a stretch of the world losing its texture rather than as an error. It had been reported twice from consumers. A consumer rationing its own draws to stay under 256 materials can stop.
What would make this wrong is a scene switching material every draw. A thousand material changes is a thousand uniform copies, and that is a batching problem no ring size answers.
- fixed
A frame heavy in graph nodes replayed only the first 1024 of them, and said nothing.
The frame graph's node arena reallocates at twice its capacity whenever a frame records more nodes than it holds. The scratch array the replay reads through did not: it was allocated once at
MAX_DRAWS_PER_FRAMEand never grown, andkeptNodesstops at the end of the array it is given. So the tail of that frame's passes was replayed by nobody — no warning, no error, and a plausible picture missing whatever those passes drew.Both
keptNodesand the renderer's own field carried comments saying the array was sized with the arena and that this therefore could not happen. Neither was true, and the comments are why it survived: they read as a settled invariant rather than as an assumption.It was reachable well below the draw ceiling, because a node is recorded per verb rather than per draw — overlays, scatter, plumes and text each record one. The bound in
keptNodesstays, because it is what stops a write past the end of the array; what changed is that the caller now honours the contract that bound documents.
3.28.0 · 2026-09-01
- added
A blended draw writes depth, which is right for one surface and wrong for a set of them. That has been the rule since translucency landed and the reason is good: the sky is drawn last over everything the world left untouched, so a sign hung in the air that declined to write depth is painted straight over. What it costs is that depth written by a blended surface rejects the blended surfaces behind it — so a model whose interior is blended draws the first of each overlapping pair and discards the rest, and which one is first is whatever order the caller submitted in.
Reported by a consumer importing vehicles, on a car whose interior is 96 blended surfaces sitting inside its shell: it drew as interpenetrating shards with its dashboard showing through its bonnet, and every check that model had was green. A quarter of that file's materials declare a blend, and the format they come from states whether a surface blends and never by how much, so there is nothing in the file to draw a threshold at.
{ depthWrite: false }is the tool the docstring had been describing without providing. It leaves the depth buffer alone, so a set of blended surfaces blends through itself. The cost is stated where it is taken and is why this is an option and not the default: nothing in the depth buffer means nothing to sort by, so the caller owns the order, and a surface that writes no depth cannot stand against the sky.demo/dev/blended.htmlshows all three states — the defect, the half fix, and both together — because this is a failure a photograph shows and no number does. - added
A decal lying on the surface it decorates is coplanar with it, and coplanar surfaces are a coin toss taken per pixel: their interpolated depths are equal in exact arithmetic, so which one survives is decided by which way the rounding fell.
drawMeshhas haddepthLayersince that turned up in six places in one session. A blended draw had no way to say it, so the same marking over the same slab was ordered when it was opaque and a hatch of fighting pixels when it blended — which is most of what an imported interior's decal layer is.And on WebGPU the parameter was accepted and dropped.
drawMeshthere took adepthLayerand ignored it for as long as that backend has existed: a consumer declaring which of two fused surfaces should win got the answer on one backend and a coin toss on the other, with nothing failing and nothing recorded. It reaches a pipeline now, where the offset is baked in as a depth bias, and both backends read one function for what a layer is worth.The slope term went from zero to matching the constant, and a measurement is what changed it. The argument for zero is that a declared overlay is coplanar from every angle, so a constant nudge should be the whole of it. What that misses is that the offset is applied in depth, and how much depth a pixel spans grows without bound as a surface turns edge-on. Measured on a blended quad coplanar with its panel, counting the decal pixels that survive: WebGL2 clears it at layer 1 either way, and WebGPU keeps 1,178 of 1,237 at layer 1 with no slope and needs layer 2 to reach 1,237. With the slope both backends read 1,237 at layer 1. The sign is the constant's sign and not its opposite — given the opposite the two terms fight, the slope wins at a grazing angle, and the overlay disappears behind what it decorates.
Gated rather than argued: every published scene is 0 of 921,600 pixels on both backends against the release before it.
3.27.0 · 2026-09-01
- fixed
The weld knew about five of the format's optional attributes and there are ten.
MeshDatagained a tangent frame and relief on 21 August, then a rig and morph deltas on the 25th;weldMeshwas written on the 21st and was told about none of them. Both halves of it were lists written out by hand — the key that decides whether two corners are the same vertex, and the copy that writes the survivors — so for five releases a merged mesh came back without its joints, its weights, its tangents, its relief and its morph targets, and with corners merged across the bones they were weighted to.Nothing raised, which is why it lasted. An unskinned mesh is a valid mesh and it draws at its bind pose. A mesh with no tangent frame is a valid mesh and the lit pass falls back to screen-space derivatives. The only symptom is a model that has stopped moving, in a bake whose every printed number is correct.
It surfaced through a report that had the measurement right and the cause wrong, and both halves are worth recording. A consumer re-baked an unchanged model against a newer engine and found it 8.3 MB larger than the copy committed two days before, which read as a change between the two versions. The two versions produce identical bytes from identical input: what had moved was the source file. The earlier bake came from an export storing one vertex per triangle corner, where the weld merged 126 meshes of 253 and destroyed the tangent frame on every one of them; the later came from an already-shared export of the same model, where it merged three. The 8.3 MB was this defect stopping, not a regression starting.
One table drives both halves now, and it is checked against
MeshDataat compile time, so the day an eleventh attribute is added to the format this file stops compiling until the table lists it. A table that can be forgotten is what was already forgotten twice.What it costs is stated where it happens: a file carrying a tangent frame derived per corner welds less, because a corner soup gets one triangle's frame per corner and no two corners agree. The answer to that is not a looser key, which would merge a mirrored UV shell and light one side of a model inside out; it is the entry below, which stops deriving a frame nothing samples.
- changed
A tangent frame exists to sample a normal map, and this engine reads one in a single place — inside the lit pass's
if (uNormalStrength > 0.0). It was derived for every imported primitive carrying a texture coordinate, whatever its material declared, which is four floats a vertex that no draw can ever fetch.Measured on a 253-mesh CAD vehicle export whose eight materials declare no normal map at all: a bake of 62.7 MB becomes 51.7 MB, and the difference is exactly 689,033 vertices times 16 bytes, so the whole of it is that one buffer. It is not a frame cost: the buffer uploads once and the vertex stride is the same either way. It is bytes on the wire, and on a model already large enough to shard it is a whole extra request.
A
TANGENTthe file supplies is still read, whatever the material says. That is authored data and the file is entitled to be believed; only inventing a frame is gated. A consumer attaching a normal map to a material the source did not have one on callsgenerateTangentsitself, which is exported for it.What would make this wrong is a second reader of the frame that needs no map: anisotropic specular, hair, cloth. There is none today, and the day one lands it wants a frame derived after the weld, not this one back.
- fixed
dropDefaultAttributesdrops an all-zeroreliefarray now, alongside the specular, roughness, grain and emissive-colour arrays it already dropped. Relief landed onMeshDataon 21 August and this function was not told, so a producer filling it with zeroes spent four bytes a vertex saying "no relief" — which is the one thing an absent attribute already says. Same omission as the weld's above, same day, one line.
3.26.0 · 2026-09-01
- fixed
A car came in as its own mirror image, and nothing a bake measures could see it. The tier 2 vehicle reader declared its format left-handed — the tool that writes it targets a left-handed graphics API, which is a good reason and not evidence — and mirrored every model it read. A mirrored car has the same bounds, the same triangle count and the same consistently wound triangles as an unmirrored one, so the import passed every check it had while producing a car whose steering wheel was on the wrong side.
It was found by eye, by a consumer, on the second file this reader had ever met: a rear badge reading backwards, a number plate written backwards, and the driver sitting on the wrong side of the cabin.
What settles it is a picture rather than an argument. The badge mesh, rasterised straight out of the file with its numbers read as right-handed and the viewer standing behind the car, spells the model's name forwards; through the conversion the reader was applying, it spells it backwards. Every other asymmetry in the file agrees: up is
+y, the front bumper and headlights are at+zand the tail lamps at-z, and the steering wheel, the driver's seat, the door named for the left side, the left mirror and the driver's eye position in the model's own configuration data are all at+x— which is the car's left in a right-handed frame with that up and that forward. Read exactly as stored, the file is a left-hand-drive car.So the reader reports the frame it measured and mirrors nothing.
convertHandednessstays inorient.tsfor a format that genuinely is left-handed, and none of the formats read here is one. - fixed
A mirror has a hierarchy half and it did not exist. The one place that mirrored an asset negated each node's translation X inline and left its rotation alone, so a graph carrying rotations desynchronised from the geometry it places — measured on a real car at 1.385 m apart and on the other side of the model. Both halves stay individually well formed, which is the worst shape this can take: nothing downstream can detect it, and a consumer's only recourse is to stop using the graph, which is what happened.
mirrorNodesis the pair toconvertHandedness, exactly asorientNodesis the pair toorientMeshes, and it is exported beside it. The rotation is conjugated rather than negated — reflecting a rotation givesM R M, which as a quaternion is(x, -y, -z, w)— and every node is conjugated, not only the roots, which is the one place this differs from an up-axis turn: a rotation applied to a root already carries its descendants, while a reflection is a conjugation and conjugation distributes over the product, so leaving a child alone drops the mirror out of the composition. - fixed
That format's alpha-test reference is the value below which a fragment is discarded, and it was being read as the surface's opacity. It is 0 on any transparent-shader material that wants no alpha test at all, so those materials came out fully transparent and drew as nothing. Measured on a shipped car: nine meshes of a hundred and two — four tyres, four rims and the side windows — and seventeen of a hundred and seventy-two at full detail. It was reported twice from playing, first as wheels with no tyres and then as a photograph of bare rims.
The file states what blends, and a list of shader names was answering instead. Two bytes the material walk skipped as "the flags" are a blend mode and an alpha-test flag, and they are read now: across a car, its three levels of detail and its collider they take only the values the format defines, and they agree with each node's own transparency flag on 171 of 172 meshes. The shader-name list they replace was wrong about that car — all four of its tyres wear a shader the list called transparent, while the material itself says opaque and the node agrees. A name is a guess where a field is a fact, and the list survives only for the warning it can genuinely answer: a shader this reader has not mapped.
So a blended surface is one the file says is blended, its per-fragment alpha comes from its texture where it always did, and the alpha test goes to the field that means it.
- added
A cutout is a test and an opacity is not, and the container had only the second.
SurfaceMaterial.cutouthas been in the renderer since decals landed, defined as the alpha below which a fragment is discarded, and nothing could reach it:MATLhad no such field and a part carried no such number, so a surface whose shape lives in its alpha channel — a grille, a vent, foliage, a fence — arrived as the rectangle its geometry actually is.glTF states it exactly and it was being dropped.
alphaMode: 'MASK'withalphaCutoff, which the specification defaults to 0.5, is neither of the two modes this reader handled: read as an opacity it would be wrong in both directions, and ignored it fills in the mask. It becomes a cutout now, and so does a vehicle format's alpha-test reference, on the materials that say they are tested.Additive behind the stride
MATLalready declares. The entry grows from 72 bytes to 76 and an older file's shorter entry opens with the field defaulted to 0 — discard nothing, which is what every file written before it meant.DrftPartcarries it besideopacity, and the loader groups by it, because merging a masked surface into a solid one either punches holes in the solid or fills in the mask depending on which material wins. - fixed
The handbook printed an import that did not compile.
import { localiseNodes } from '@driftengine/assets'is what the vehicle chapter tells a consumer to write, and the barrel did not export it — so the one consumer who followed the handbook reached the module by sub-path, which is the exception this repository reserves for one library, and nothing anywhere said the documented form was wrong.It is exported, with
Localisedbeside it. And the class of defect is closed rather than the instance:scripts/docs.test.mjsnow reads everyimport { … } from '@driftengine/…'in the documentation and asserts the named package's barrel exports each binding. It found this one and nothing else. The porting guide is exempt by name, for the one reason that a porting guide's job is to print the import that stopped working beside the one that replaced it. - added
There was no way to decline the fit, and the obvious substitute is a trap.
loadscales the larger of a model's footprint and height to a size, centres it in X and Z and stands it onbaseY, which is what a showroom wants and none of what a game wants for a model its own importer has already put in metres at the origin. The only way to ask for nothing was to measure the model and hand back the numbers the loader would have computed.A consumer reached for the shortcut instead, and it does not fail in a way that looks like a mistake: a footprint of
Number.MAX_SAFE_INTEGERis not "do not scale", it is a scale of 2.3e15, and a car drawn nine quadrillion metres wide looks from inside exactly like a model that failed to load.{ fit: 'none' }is scale 1 and no offset, and the docstring names the trap it replaces.
3.25.0 · 2026-08-31
- added
Every reader here flattened a source's node graph into world space and threw it away. That is the right call for a prop, and it is the wrong call for anything with moving parts: a vehicle arrives as one welded mesh, and the wheel, the door and the steering column that the artist named are gone.
ModelImport.nodesis where a reader reports the graph instead, and the baker writes it asNODE, a chunk the container has declared since its first version and nothing ever filled.Geometry stays in world space, and that is what makes this additive. Rule 4 of the compatibility rules says a minor version never changes the meaning of an existing byte. Moving vertices into node-local space would change what every vertex in the format means while leaving the bytes identical, and it would defeat rule 2 as well, since a reader skipping an unknown chunk is promised a usable asset and a local-space model without its graph draws as a heap at the origin. So both are written. A reader that predates this opens the file and draws the model exactly as before; a reader that knows the chunk gets the graph beside it. No chunk is marked required and no bake mode was added.
localiseNodesis the arithmetic in between. It accumulates each node's world matrix, inverts it, and hands back the part expressed about its own origin, which is what lets a wheel turn about its hub instead of about the model's centre. Normals take the inverse transpose. Measured over a 44.6 MB vehicle: 221,077 vertices in one pass, worst round trip error 1.57e-7 m, no singular nodes. It is load-time work and belongs behind a frame budget for a large asset, never inside a frame.One rule for every reader, with no flag. glTF fills the same field from the graph it already walks to flatten, so a consumer does not have to know which format a model came from to ask what its parts are called. A flag would have been a thing to forget, and forgetting it produces an import that silently cannot be animated.
orientNodescloses the half that would have gone wrong quietly. Standing an asset up rotates its meshes, and a graph left unturned then claims every part is somewhere it is not, with both halves individually well formed and nothing downstream able to tell. The two turns happen together. - added
A model can carry images the pipeline could identify and not read. Image detection covers PNG, JPEG and WEBP, and a third of the textures on the vehicle this was built against are block-compressed: 34 of 81, and they are its paint, its interior and most of its normal maps. The model imported correctly shaped and visibly half-painted, with a warning per texture and nothing a consumer could do about it.
dds.tsdecodes BC1, BC3 and BC5 into RGBA, and the bake embeds the result. All 34 decode. The check that establishes it is right, and not merely quiet, is a flat normal map: it comes out at a mean of 129, 128, 255 against a true 128, 128, 255, which is a value no channel swap or endpoint mix-up could land on by accident.Baker work only. RGBA is larger than the source it came from and the bake re-encodes afterwards, so this runs once, offline, and nothing ships a block decoder into a running game. The endpoint cases are where a block codec goes wrong, so both modes of each are asserted: the three-colour mode whose fourth index is transparent, which is what foliage and grilles depend on, and the six-value alpha mode whose last two indices are pinned to 0 and 255.
Anything else is refused by name with its identifier, which is worth more than a surface half-decoded into something plausible.
- added
A hand-authored level of detail is a whole model, and the container had nowhere to put one.
LODMholds a single merged, material-less outline per level, built so a load can open on a silhouette, and that is what it is for. A source that ships three alternative models is a different thing: on the vehicle measured, the levels carry 102, 67 and 38 meshes with a material each. Folding one intoLODMwould merge its parts into one mesh and discard every material it has.A level is a complete nested asset, and nesting is what keeps it additive. Materials are parallel to meshes by ordinal and a node names a mesh by ordinal, so appending another level's meshes to the same arrays would leave an older reader drawing every level at once, on top of itself. A nested file is one chunk an older reader skips, after which it opens the asset as the full-detail model it also is.
Handed back as bytes and not parsed. A consumer picking a level by distance wants one of them, so parsing four to return three that will never be drawn is work nobody asked for. Pass the one you want back to the reader.
Each level carries its own textures, because it has to. A material addresses a texture by ordinal into its own asset's list, so a nested file pointing at another file's images is not a valid asset, and the reader refuses it. That cost is real and is the reason
--no-levelsexists.
3.24.0 · 2026-08-31
- added
A game streaming a world hitched, and the whole of every slow frame was
createMesh. A consumer streams its world in 500 m squares and gave the build a 4 ms frame budget, which it honoured exactly, for the half of the work it owned. The other half was the host making that square's GPU handles once the build finished, about two dozencreateMeshcalls in a single burst inside whichever frame that happened to be, outside every budget. Attributed in the browser over a drive into a town: a 66.5 ms frame of which 57.6 was the square arriving and 57.2 of that wascreateMeshalone, with disposal and bookkeeping never reaching 2.Spreading a square across its groups is not enough, which is why this is here and not in a game. One material group of the densest square is 248,928 of its 367,542 triangles, so a host that yields between handles still pays that group as one indivisible stop.
createMeshIncrementalis where the group itself becomes divisible: a handle that is usable at once, and an iterator that moves a quarter of a megabyte of geometry per call. Drive it underStepBudgetand the square lands over as many frames as the budget allows.The cost belongs to one backend and the fix is shaped to that. WebGPU interleaves every vertex on the CPU into one new array before it writes anything, which is why the same drive measured smooth on WebGL2 at a worst frame of 29.5 ms and hitched on WebGPU at 66.5. So WebGPU divides the interleave and the write together, in whole vertices; WebGL2, whose three tight buffers are the reason it was already fast, divides by attribute and grows no interleave. The chunk is measured rather than picked: 65,536 floats interleave in 0.14 ms on a desktop part against 2.2 ms for a megabyte, which is most of a 4 ms budget before the GPU half is counted and several times that on a phone.
Nothing half-uploaded is drawn.
MeshHandle.completesays whether the geometry has landed, anddrawMesh,drawTranslucentMeshanddrawLightVolumeskip a mesh that has not. Drawing what had arrived was the alternative and is worse: a mesh whose triangle count grows over several frames is a stranger artefact than a square that is briefly absent, and holding a region back until it is whole is the direction a streaming consumer already treats as safe. A consumer that never drives the iterator therefore sees nothing rather than a fan of triangles through the origin.A lost device or context ends the upload, leaving
completefalse. Done and not complete is an abandoned upload, and it is stated because a half-uploaded mesh from a dead context is the one thing a consumer cannot detect from the outside.What is measured and what is not. The mechanism and its attribution are measured beyond doubt: the upload was unbudgeted, it was the whole of every slow frame, and spreading it made the game smooth in play. The full one to two second hang the report opens with was not reproduced on the machine that investigated it, which reached 70 ms at worst on an RX 9070 XT. If the missing factor matters, the thing to instrument is
writeBufferunder a loaded queue, since staging back-pressure would not show on an idle GPU with headroom. - added
A budget for work that can stop, holding the clock and the arithmetic while the caller holds the work.
StepBudget.spend(ms, next)callsnextonce per indivisible unit until the budget is gone;nextdoes one unit and returns a label naming what it did, or null when there is nothing left.What it guarantees, in the honest words: a call costs the larger of the budget and one uninterrupted stop. Not the budget. A stop cannot be interrupted from outside, so the only way to bound a call by the budget alone would be to bound every unit, which is the caller's job.
worstandworstInare what make that actionable, because a budget being blown is useless information without the name of the unit that blew it.Three properties, each arrived at by a consumer getting it wrong first. Do not start what you cannot finish: keep an estimate of the worst single stop and require room for another before starting one, or a driver overshoots by a whole unit, measured at 30 Hz as 8.3 ms of budget plus an 8.6 ms stop against a frame of 16.7. But always do one, or a unit longer than the whole budget means nothing ever advances and a world that never loads is worse than a frame that runs long. And the estimate has to forget, or one pathological stop sets the price of every stop for ever: it decays one per cent a call, a half-life of sixty-nine calls. Five per cent was tried and is too fast, falling below the real cost inside a single crossing of a town, at which point the guard stops guarding.
Why a clock and not a count of units. A count bounds the cheap term and leaves the expensive one free. The model loader measured it: the parts of one car cost between 6 and 254 ms each, so three a frame was three of whatever they happened to be, and one frame reached 318 ms. A clock read after each unit is exact, needs no per-asset tuning, and is as right on a phone as on a workstation.
It touches no renderer and no device, which is what lets the property worth testing be a whole number: five units that each cost a whole budget need five calls, and a driver that ran them in one would report one. The consumer's own check stayed green through this entire defect because its host did no work, so the upload cost nothing. A stub cannot pass a counting assertion.
3.23.0 · 2026-08-30
- fixed
A knee is not the middle of a shin. The ragdoll builder created one cone-twist joint per bone and passed no anchors, so each joint took the documented default: the child's centre, expressed in the parent's frame. That default exists for a good reason and is right for what it was written for, which is stopping a fixed joint yanking its body onto the origin on the first tick. It is wrong for a limb.
Anchored at the child's centre, the shin is pinned by its middle and free to turn about that point inside its cone, so the knee end of the shin swings up to half a shin away from the knee end of the thigh. Every limb hangs off the body at a point that is not a joint, and the cone meant to limit a knee is measured about the wrong pivot into the bargain.
Measured. A consumer threw a body out of a door at 22, 50 and 90 km/h and watched the knee open to 56, 55 and 100 cm in flight, still 38, 28 and 72 cm open after it landed. Anchored at the bone ends the same three flights open to 6.9, 8.9 and 8.2 and settle to 2.8, 2.4 and 4.1, which is inside a capsule's own diameter. Reported from play as the body's junctions being messed together and everything vibrating, which is an accurate description of a knee a metre wide.
Why nothing caught it. Every body was still exactly one bone from its parent's centre either way, so any measurement between centres read as healthy while the limb hung off nothing. The test added here measures the two ends that share a joint, which is the only thing that sees it.
What changes for a doll you are already driving. The old default pinned each bone's centre to a point rigidly attached to its parent, which carries the child along with the parent's rotation and over-constrains the chain into something stiffer than an articulated one.
drivelooked stronger against that, and a driven ragdoll will now sit lower for the same weight, because it is working against a real lever. Raise the weight if you had tuned it against the old geometry.Both anchors resolve to the same world point at rest, because a bone's head is its parent's tail, so the first tick still moves nothing.
- added
A mask says what a body is; it cannot say who two bodies are to each other. The layer test is a property of each body on its own, so it expresses this kind does not meet that kind and cannot express these two in particular.
world.ignorePair(a, b)is the second question, withallowPairandpairIgnoredbeside it.A ragdoll is entirely the second question. Two capsules jointed end to end overlap near the joint by construction, because that is what having a radius means, so every parent and child in a doll and every pair of siblings starts interpenetrating. Left to resolve, that contact fights the joint holding them together. A consumer measured it holding settled joints 14.2 cm open, turning a body at 3.0 rad/s while it lay still on the road, and propping it 28 cm up on its own thighs. The builder now excludes every pair that shares a bone end, and that is not a setting.
Switching all of it off is the cheap answer and it is measurably worse. With nothing inside the doll colliding, the joint cones alone do not stop a limb folding through the torso, and the same consumer measured a foot reaching 15% of its resting distance from the chest, which is a foot inside the ribcage.
selfCollision: falseis there for a crowd far enough away that nobody can see, and the default is the correct behaviour.Why not layers. They are 32 bits shared by the whole world. Expressing a doll's adjacency with them takes a bit per bone, so the consumer who tried it spent most of the space on one body and could not have built a second.
A body index is a slot and not an identity, so exclusions are followed through a removal: removing a body moves the last one into its place, and an exclusion left alone would quietly stop the newcomer colliding with whatever the departed was jointed to. A world that never excludes a pair pays one integer compare per candidate.
3.22.0 · 2026-08-30
- added
Depth runs the other way, and
depthConvention.tsis the one place that says so. A conventional buffer spends its precision hyperbolically, aboutz² / (near · 2^bits)at distancez, so the near plane is the only control there is. A consumer measured what that costs them: a street plate's letters held 7.5 mm proud of a 6 mm plate, a sign 15 mm proud of its board, and a near plane settled at 0.25 for a first-person camera that wanted 0.05, because dropping it to 0.15 made every decal in the world blink. None of those offsets is a thickness anybody chose.What reversing buys. A float's exponent gives its resolution where the values are small, and putting far at zero makes the hyperbolic loss and the floating-point gain very nearly cancel, so precision becomes roughly uniform with distance.
On both backends, gated at zero tolerance across every published scene on a real GPU. Each backend against its own conventional output: WebGL2's worst frame 7,348 pixels of 921,600 and WebGPU's 11,832, most a few hundred or none, all of it seams resolving the other way. The two backends against each other come to 443,035 pixels on their worst scene reversed against 443,039 conventional, so the conversion costs four pixels of the ordinary disagreement between two independent implementations of an animated scene, in the direction of agreeing slightly better.
A game reads what the context granted, never the engine's wish. WebGL2 needs
EXT_clip_controland a context without it keeps the conventional sense, soreversedDepthon the created renderer is the fact to key a near plane on.This is the cure for a near-coincident seam and deliberately not for a coincident one. Two exactly coplanar surfaces have equal depth in exact arithmetic and no format separates equal numbers, which is what a polygon offset is for. What this fixes is the other case: surfaces a fraction of a millimetre apart, which a conventional buffer cannot tell apart past about forty metres and this one can.
Shadows keep the conventional sense. The directional cascades are orthographic, where depth is already linear and a float buffer gains nothing, so reversing them would mean flipping every comparison in the shadow path for no precision at all.
- fixed
Two defects the conversion exposed, both fixed and both invisible until depth was reversed. The sky is a full-screen triangle written straight into clip space at
z = 1.0, which is the far plane conventionally and the near plane once reversed — so it painted over the entire world, measured at 743,669 changed pixels of 921,600 in one frame. It now writesglslFarDepth(). Andrush.tsrecovered clip z from the depth texture asdepth * 2 - 1, whose reversed inverse is1 - 2 * depth; four shaders carried that expression by hand and now shareglslSceneDepthToNdc, withDEPTH_CLIP_CORRECTIONnegating in step so the matrix and the shader cannot drift. The renderer's own reprojection test is what caught it. - added
scripts/depth-survey.mjs(depth-check.mjsuntil 3.52.1) asks a real GPU what it can do, because one fact decides the shape of a reversed-Z conversion and nothing here knew it: whether WebGL2 exposesEXT_clip_control. WebGPU's clip space is already[0, 1]; WebGL2's is[-1, 1], and without clip control the flip folds into the projection but the float exponent lands its resolution mid-range instead of at the near plane. It also reports the depth formats each backend will render to, since a reversed buffer that is not float buys far less. Measured on an RX 9070 XT through ANGLE/Vulkan:EXT_clip_controloffered,DEPTH_COMPONENT32Fanddepth32floatboth available. - added
A consumer can ask which way depth runs.
CreatedRenderer.reversedDepthreports what the context granted, reported the wayrendererNameis and read off the renderer in the same one place, because on WebGL2 it depends onEXT_clip_controland is a property of the object that got built rather than of anything upstream.REVERSED_DEPTH,DEPTH_CLEAR,DEPTH_COMPARE,DEPTH_COMPARE_EQUAL,DEPTH_FORMAT,DEPTH_OFFSET_SIGNandconventionalDepthare exported for what the engine asks; the two agree everywhere except a WebGL2 context without that extension.Reported from outside as the half that was missing. The depth seam shipped whole and none of it was reachable through the barrel, so a game had no way to know whether it could put its near plane at 0.05 or had to leave it at 0.25, and no way to size the offset that keeps a decal off its surface — it had to choose for the worse case and gained nothing from the better one. A conventional buffer resolves about
z² / (near · 2^bits), which is what makes the near plane the only control there is. - changed
MeshBuilder.build()went from 41.9 ms to 1.6 ms for 110,628 triangles, measured on this machine. It accumulated into ten plainnumber[]and turned each into a typed array in one uninterruptible call at the end — two and a half frames at 16.7 ms, which is what made a consumer streaming a world cap a batch at a triangle count tuned to one laptop and overshoot it by whatever the last object happened to be.The builder now writes into growable typed arrays as geometry arrives, so the end is one memcpy rather than ten walks over boxed doubles. And the five "did anything differ from the default" questions
buildasked — specular, emissive colour, roughness, grain, relief — were each a scan of every vertex, about 1.5 million predicate calls for a mesh that size; a write knows whether it matters, so the list now answers in a field.npm run bench:meshreports both halves.MeshBuilder.triangleCountandvertexCountSoFaranswer how big a builder has become without building it, which is the other half of the same report: a caller batching by size can ask instead of tallying every geometry verb's triangle count on its own side. - changed
buildTextMeshmerges horizontal runs of lit cells into one box each. It put a box — twelve triangles — at every lit cell of a five-by-seven bitmap font, so a run of three adjacent cells was three boxes with four interior faces between them that nothing can ever see. Measured against a representative corpus of street names and house numbers: 13,541 lit cells become 8,895 boxes, 1.52x, which matches the 1.53x a consumer measured against their own 1,168 strings — and their world is 1,195,296 triangles of lettering out of 2,662,324, forty-five per cent.Horizontal only, and that is a measurement rather than a simplification. A greedy rectangle merge over two dimensions gives exactly the same number on that corpus: in a glyph five cells wide there is nothing vertical left to win, so the second pass would be code that never pays.
Invisible, and gated rather than argued. A run and the box that spans it have the same outer surface, so every published scene is pixel-identical on both backends at zero tolerance. A test asserts the stronger form: the merged geometry covers exactly the extent one box per cell would, which is what catches a centre or half-extent off by half a cell.
forEachRunis exported besideforEachCellfor anything else that walks a glyph. - fixed
Ambient occlusion skipped the background by asking whether depth had reached 1.0, which is the far plane conventionally and the near plane once depth is reversed — so under a reversed buffer it would have computed occlusion over the sky. Both guards now go through
glslIsFarDepth, which moves the sense and the bound together.It is a latent fix, not the one that unblocks the switch, and measuring that is what it bought. Flipping the guards moved the shot gate by zero pixels, and the scene that darkens worst runs no occlusion at all — so ambient occlusion is eliminated as the cause of the two scenes that still differ when reversed depth is turned on, by experiment rather than by argument. That is the third candidate eliminated; the constant's docstring carries all of them and what the next attempt should try instead.
REVERSED_DEPTHremainsfalse, and every published scene is pixel-identical on both backends at zero tolerance with it off. - fixed
None of the three looks like a depth bug, and that is why they took so long to find. A wrong shadow map does not draw broken. It draws dark, so a scene with any of these in it reads as a lighting regression.
The lit shader was handed a range-corrected light matrix. It projects with that matrix and then remaps the result from
[-1, 1]to[0, 1]itself, and the correction performs the same range change, so both applied put every receiver depth in[0.5, 1]against a map holding[0, 1]. Every comparison was biased and the cascade's far fade was dragged across the whole map. WebGPU had this right and now both do.Point-shadow face matrices were never range-corrected at all. Clip control is context state, so a face matrix still emitting OpenGL's
[-1, 1]loses everything below zero and half of every face is clipped away.The ordinary lamp's bake had no depth bracket. An area light's bake put the conventional compare and clear back for its own length; the point-light path, which is nearly every point shadow a scene has, ran under the frame's compare against a buffer cleared to the frame's far plane while writing depths from a matrix that is deliberately not reversed, so every fragment won or lost the wrong comparison. Both paths go through one bracket now, applied whichever way the frame runs, so there is no branch left to miss.
Found by comparing the two backends against each other and then neutralising one term of the lit expression at a time. Two independent implementations that agree conventionally and stop agreeing reversed say which side is wrong; a diff against a backend's own baseline never can.
- added
visible(bounds, model)already answered this and nothing could build the question. Measuring a vertex array was the only way to make aBounds, so a consumer that wanted to ask about a region had no vertices to hand it and had to fill the centre and radius by hand.What that costs, as a consumer measured it. With one mesh per material per square, a two-kilometre ring is 1,618 draws walked one at a time, where the world itself knows 68 squares it could have tested instead. The renderer's own
cullDrawssaves the GPU's share and nothing else: by the time it is asked, the caller has already walked the drawable, built its model matrix and switched material.The radius here is half the diagonal, which is the opposite of what the vertex form does and is right for the same reason. That one measures the furthest actual vertex, because a mesh's corners are usually empty; a named box has no vertices and its corners are exactly the points it promises to contain, so anything tighter would report a region as off screen while part of it is on.
MeshHandle.boundsis what to pass for a mesh, and the rendering notes now say so, which they did not.
3.21.0 · 2026-08-30
- added
heightSurfacetakes a list of height fields, so a road can pass under a road. One function ofxandzhas exactly one height per column and no arrangement of it holds both a flyover's deck and the carriageway beneath it; a list does, and which floor a query gets is decided by the height every caller already passes. Each layer answers a non-finite number where it is not there, so a deck is a floor exactly over its span and nothing at all beside it — that contract was previously stated only in a private comment inside the file, which is no use to somebody deciding how to model a crossing. Order in the list carries no meaning: the rule is nearness to the asker with a tie broken toward the floor below, which is the ruleCompositeSurfaceandRibbonSurfacealready use between their own passes, and layers compose through the first of those rather than through a second copy of it.Reported by a consumer with 276 km of road and six flyovers, who had built the deck into a single field, where the only way through a crossing is over it. What they were missing was reachable — two surfaces and a
CompositeSurfaceanswered it, and the comment onheightSurface.samplesaid so — but it was the wrong shape of answer to a question about a height field, and it read as "a height field cannot do this". Measured against a realCharacterControllerstanding under a span: one merged field leaves the body falling at -40.33 m, and two layers leave it grounded at 0.00 m with the deck 5.5 m overhead. - changed
HeightFieldis exported and the absence contract is public.heightSurface's header now says that aheightAtreturning a non-finite number means there is no ground in that column, which is the property a layered world is built out of and was previously visible only to somebody reading the function's private helper. The comment onsamplethat used to send a reader toCompositeSurfacenow names the list form instead, and says why the old answer, though true, was the wrong shape. - added
colliderSurface(set, { minY, maxY })makes aColliderSetanswer as aGroundSurface, so a solid holds a body up. A collider was solid to walk into and not to stand on: the kinematic sweep resolves a body against a set laterally, andCharacterControllersenses ground throughPhysicsWorldbodies and through aGroundSurface, which a collider set was neither — so nothing ever asked it what was under your feet. Measured: a character dropped over a slab whose lid is at 5.5 m fell to -84 m in three seconds, which is exactly what it does with no slab at all; through this it stands at 5.50 m and reports grounded.A sloped hull supports at its slope, not at its bounding box. The lid is solved against the solid's own face planes — for a convex solid the vertical line at a column leaves through the lowest upward-facing plane and enters through the highest downward-facing one — so a ramp holds a body along its face and hands back a normal that tilts with it. Answering the broad-phase box instead would stand a body on thin air over the low half of every ramp, which is the same mistake the segment query records having made.
It is a
GroundSurfaceand not a new seam, which is the point. Everything that stands on ground already takes one, so the character controller, the raycast vehicle, the camera boom and the rain field all read it with no change, and it composes with terrain throughCompositeSurfaceor a list of height fields.minYandmaxYare required rather than defaulted: the spatial hash walks every cell of the column, so an unbounded one is half a million cells and a frozen frame, and a consumer knows how tall their world is where the engine does not.Requested beside the layered ground above, by the same consumer, as the general answer of which a flyover is one case: a deck becomes a slab rather than a height, and the same set that stops a body walking into it now stops it falling through.
3.20.0 · 2026-08-29
- added
A
ColliderSetcan change after it is built, so a world whose solid geometry streams no longer has to build another one.add(boxes)returns a handle,remove(group)drops it, and the uniform grid is updated for that group and for no other collider in the set. Reported by a consumer moving from 2.4 km of survey sheets to a 9.3 x 4.9 km world off OpenStreetMap, who measured the two things today's API left them: rebuilding at every region crossing costs about 2.1 microseconds a box and is linear — 16.9 ms at their 6,076 colliders and 41.2 ms at the 15,000 they are moving to, two and a half frames, every few seconds while somebody is driving; and the alternative they shipped instead, one set per region with the nine around the player queried, measured 1.19 microseconds a query against 0.16 for a single set, a 7.5x multiplier on every collision query in the game plus a seam where an index means nothing without the set that answered it. Measured here bynpm run check:streaming, driving the real sweep across region boundaries at five frame rates: a crossing costs 0.090 ms and queries on the streamed set run at 1.25 to 1.31x a freshly built one's. The workaround can be deleted and an index goes back to being an index.absorb(other)folds one set into another by copying packed bounds with a singleFloat32Array.setand offsetting its buckets, with nothing rehashed; a churned source's holes come across as holes, because the constant offset is only valid while every source slot occupies a destination slot and copying them as live would report the bytes of colliders that were deleted.bytes()answers what a set costs in four numbers, which is the question that decides a region size and which a consumer counting its own bytes could not ask. - changed
ColliderSet.countis the live count andcapacityis the slot count, withliveAt(index)between them. They are equal on a set that has never hadremovecalled on it, which is every set built before this release, so nothing that walksdatatoday changes. After a removal the live slots are sparse withincapacity, and a walk of0..countreads the wrong ones — walk0..capacityand skip!liveAt(i). A reference todatais now valid only until the nextadd, which may reallocate;bounds(index, out)survives both that and a change of packing. The colliders a set is constructed with belong to a reserved groupremoverefuses, so a set meant to stream is constructed empty.physics-onlygrows 40,438 to 42,057 bytes gzipped, 4.0%, and none of it is tree-shakeable: they are methods on a class every consumer of the package constructs. - changed
fingerprintCollidersreads liveness, and every hash it has ever produced is unchanged. It hashed the whole ofdata, so spare capacity or a hole would have hashed slots that are not geometry and desynced a replay in the quietest way there is. It walks live slots in index order now, which for a constructor-built set is byte-for-byte the stream it always was — including the empty slots a sparse input array leaves, which stay live with zero bounds precisely so the string does not move. Two golden hashes pin it. On a set that has been mutated it answers a narrower question: "the same set with the same history", not "the same world". Replay is unaffected, since the same inputs drive the same path and so the same slots; what it costs is comparing two sessions that reached the same world by different routes. - added
drift/physicscan change a collider set as well as read one.beginColliderGroup,addColliderBoxandendColliderGroupput a batch down and hand back the handleremoveColliderGroupdrops it with, underphysics.write;colliderCapacityandcolliderBytesjoin the read surface. A builder rather than one call because a script has no list to hand across, and a per-box call returning a group would mint one group per box, which gives a streamer nothing to drop. - added
drift-packagetakes--out=<dir>, so a build can stage outside the project. The output wasjoin(cwd, 'out', target)in four places and nothing moved it. That is not tidiness: Node'sfs.cprefuses to copy a thing into itself and decides it by comparingdevandinoup the destination's ancestors, and a consumer on a Windows VM with the project on a drive mapped to a Linux folder measureddrift.package.jsonandout/both answeringdev=66313 ino=-112686486700016— one identity for a file and a directory — so the first staging copy of every build failed with a sentence about the one thing that was not happening. The layout under the root is unchanged, so--out=C:\drift-outgivesC:\drift-out\win-x64\stage. An absolute path is used as it stands; a relative one resolves against wherever the command was run, which is not necessarily--project=. The fourth call site issteam, not the build path, so a flag threaded throughbuildalone would have gone on writing depot scripts beside an output directory that had moved. - fixed
drift-packagecould not start on Windows, and said nothing about it. Its executable ranspawnSync('npx', ['tsx', …]). Over therenpxisnpx.cmd, and a.cmdis not somethingCreateProcessknows how to launch:spawnSyncfails withENOENT, leavesstatusatnull, and a barestatus ?? 1exits 1 with nothing printed at all, because the reason is inresult.errorand nothing read it. A consumer lost half a day to it and wrote a 209-line launcher of their own, which is the real measure of the defect. It now launchestsxwith the Node already running — no shell, no.cmd, no npx resolution — readingtsx's own declaredbinso a version that moves its entry point cannot break this silently, and it reportsresult.errorandresult.signalseparately from an exit code.tsxis now a declared dependency of@driftengine/package, which it always was in fact: its executable cannot run without it, and twelve workspace scripts were resolving it from the npx cache over the network. - fixed
A desktop build proceeded with no Electron runtime and failed several minutes later saying something else.
installedElectronVersion()resolveselectron/package.jsonand reads itsversion; that file is in the npm tarball and the binary is not, since a postinstall downloads it intodistand writespath.txtbeside it. So the version always resolved, the build always proceeded, and electron-builder found nothing to package. Measured in this repository before the fix: a version of43.4.1against nodist, withnpm config get ignore-scriptsansweringfalse— so this is not only npm refusing to run scripts, which is the case a consumer reported, but the plainer fact that a download can fail to finish for a proxy, a slow link, a sandbox or a CI image and nothing downstream is told.buildnow checks for bothdistandpath.txtand fetches the runtime rather than refusing: a consumer's own wrapper printed the command and stopped, and they watched somebody read that and re-run the script unchanged three times. If the fetch itself fails, the throw namesnpm install-scripts approve electron. - added
drift-package initwrites the two files a project has to own, and nothing else. A Windows build needs a.ps1because something must start Node on that machine and that something cannot be a Node program; a project needs a launcher because a vendored engine resolves through nonode_modules. The generated PowerShell routes every native command through a step that checks$LASTEXITCODE—$ErrorActionPreference = 'Stop'governs cmdlets and notnpm, and PowerShell 5.1, which is what a fresh Windows machine runs, has no$PSNativeCommandUseErrorActionPreference, so an unchecked failure arrives as a complaint about a missing output directory with the real error scrolled off. It relocates a build off a mapped or network drive and copies the artifacts back, which is the half that is easy to miss: without it the build succeeds, says so, lists its files, andout/in the checkout is empty. Both files refuse to be overwritten without--force. The shippedbuild-windows.ps1is deleted rather than repaired, because two scripts that do the same thing drift and only one of them is the one anybody runs. - added
doctorrefuses a filesystem that cannot tell a file from a directory.fs.cpwill not copy a thing into itself and decides that by comparingdevandinoup the destination's ancestors, so a network share that invents a file index makes it reject a copy that is perfectly legitimate — and what comes out is a sentence about copying a file into a subdirectory of itself, ten minutes into a build, about the thing furthest from the cause. It is checked before anything is read or downloaded, withbigint: truebecause a Windows file index is 64 bits and loses precision as a double exactly in the high half where those values live, which would make the check wrong in both directions on the one platform it exists for. The remedy it names is--out=<dir>first and a local copy second, which is what that flag bought: until it existed the only answer was to clone the whole project. - fixed
ELECTRON_RUN_AS_NODEis removed for every subcommand, not justrun. Any terminal that is itself an Electron application — an editor's built-in one is the ordinary case — exports it to everything it spawns, and inherited it turns Electron into plain Node: the build dies onCannot find module 'electron'under a Node banner, which looks nothing like its cause. It was deleted insiderun's own environment and nowhere else, which leftbuildinheriting it — andbuildis the one that spends twenty minutes before anything can go wrong. - fixed
The packager's README said the generated Android keystore is "kept beside the artifact". It is not: it is in
~/.cache/driftengine/toolchain/keystores/<app id>.jks, which the source has always said and the page did not. That matters because of what the file is — a device refuses an upgrade signed with a different key, reading it as a different application wearing the same name, and the only way out on the phone is to uninstall, which throws away the player's save. The page now carries the real path and the three things that follow: back it up, because~/.cacheis a directory whose entire purpose is being safe to delete; a second build machine mints a second identity silently, so a game built in two places needs an owned key; andapksigner verify --print-certsbefore handing a build to somebody who already has one installed, because the phone's refusal says nothing about signing.
3.19.1 · 2026-08-29
- fixed
The scene target's resolve gives face culling back, so the world is no longer drawn double-sided on WebGL2.
SceneTarget.resolveswitches off depth, blend and culling to draw its one triangle over the canvas, and put back only the first two. It runs atendFrame, so from the second frame onwards everydrawMeshin every scene ran withCULL_FACEdisabled — against a renderer that enables culling once at setup and documents it as on for the renderer's life because the world is solid. The two backends therefore disagreed about a rule only one of them was enforcing: WebGPU setscullMode: 'back'in pipeline state, which no pass can leak. It is invisible wherever geometry is wound correctly, which is every published scene here, and total in a world whose geometry is not — a voxel demo wound backwards looked perfect on WebGL2 and drew no ground at all on WebGPU, so the divergence read as a WebGPU defect for the length of that port and was this line. Measured across the harness at--delta=16: ten of thirteen scenes are pixel-identical either way.showroommoves 255 pixels of 504,000 and moves closer to WebGPU.night streetmoves 2,282 because its planar reflection setscullFace(FRONT), which had been doing nothing while culling was off — the wet road now carries the window reflections it should always have had.sceneTarget.test.tsasserts the restore rather than the disable, so a pass that borrows the state again has to give it back. - fixed
The WebGPU mesh pass uploads the camera medium, which it never did.
bindMeshPasssetuFogEnabledand never wrote the fog and underwater fields behind it, so the mesh pass read whatever the uniform block happened to hold. Every other pass in that backend resolves and writes the medium — plumes, film, lines, water — and WebGL2'swriteMeshPassStatecallsbindAtmospherefor exactly this reason, with a comment beside it saying an unwritten uniform is zero.uFogNearanduFogFarat zero collapse the linear span to 1e-4, so the ramp saturates at any distance past a tenth of a millimetre: the failure is total rather than subtle, every surface at full fog colour or black where the ramp inverts. It stayed hidden because it only shows on a scene that asks forfogMode: 'linear', and none of the published ones do.
3.19.0 · 2026-08-28
- added
A rectangular area light casts a shadow, and its penumbra is the shape of the emitter. An
AreaLightSourceused to illuminate without occluding, whichAGENTS.mdcalls a bug rather than a limitation: a lit panel over a table lit the floor through the table. The gap was recorded as needing a different projection, on the reasoning that the shadow pool is octahedral and centred on a point and a rectangle is not one. A rectangle does have a point — its centre — and what the emitter's extent is actually needed for is the filter rather than the bake. So the bake is a point light's bake from the rectangle's middle, reusingPointShadowMap,PointShadowImage, the staleness rule, the bake ordering and the arrival ramp unchanged on both backends; andareaShadow()projects the rectangle's two axes across the light ray and opens an ellipse the size of the emitter's outline instead of a disk the size of a radius. That is the difference a single radius cannot express: nine bulbs on a cable throw nine hard shadows that pile up darker where they cross, and one wide emitter throws one shadow, soft along its length and tight across it. Measured ondemo/dev/pointshadow.html?area=1, identically on both backends: a shadow edge 0.178 m wide along a three-metre strip against 0.044 m across it, and turning the strip a quarter turn moves the softening to the other axis — 0.300 m against 0.067 m. A still caster's ground reads 4.5 against 10.7 at its mirror through the emitter and a moving caster's 2.1 against 13.7, so the static layer and the live one both arrive. The two backends differ in 122 pixels of 1,689,600 past a delta of 8, and the two published point-lit scenes are 0 of 921,600 against the release before this one. Off unless a rectangle asks for it:castsShadowdefaults false here where it defaults true on a point light, because every scene that already declared a rectangle was authored against a light that passed through stone, and turning it on costs two octahedral layers — 8.39 MB — and a six-face bake.shadowRangeis required alongside it and is deliberately not defaulted: a rectangle has no radius to take a far plane from, and guessing one puts the shadow's edge in a straight line partway across the floor. Both refusals are warned by name, once. It ridesRenderQuality.pointShadows, and that is a texture-unit wall rather than a preference — the occlusion is read out of the same array texture a point light's is, WebGL2 guarantees sixteen units and the lit pass binds sixteen, so there is no seventeenth sampler to declare. What it approximates is visibility over the emitter: one map from the centre is right in the middle of the rectangle and increasingly wrong toward its edges. Measured on the case the improvement ledger named before the feature existed — a panel a metre across, half a metre above its caster — the shadow reads 0.231 of its control against 0.094 for the same panel 1.9 m up, so it softens by more than half rather than hardening or vanishing. - added
The packager can be told where its own
assets/,android/andios/are, and it checks that what it found is really them. Four call sites climbed out ofsrc/on their own —../assetstwice,../android,../ios— with no option, flag or variable that moved any of them, and the first runs on every desktop build. That is fine in a checkout of this repository and a wall for a consumer that vendors engine source rather than installing it: a vendor copiespackages/<name>/src/**to<name>/**, and the flattening drops exactly the level those paths climbed to.resourceRoot,resourceDirandresourceFilein@driftengine/packageanswer all four from one place,--resources=<dir>sets it ondoctor,runandbuild, and a relative path resolves against where the command was typed rather than against the module. Making it configurable made its absence quiet, and that is fixed in the same release. A vendored engine puts every package in one directory and one of them is@driftengine/assets, so the default root in that layout is a directory calledassets— an existence check passes on a package's TypeScript source, and staging then produces an artifact that launches with no splash screen and no error. Each directory is now identified by a file only it has:splash.html, whichmain/splash.tsloads;project.yml, which the iOS host rewrites;settings.gradle.kts, without which Gradle will not open the project. Every marker is a file the packager itself reads, so one cannot rot while the check keeps passing. The refusal names the coincidence rather than the rule, because to somebody hitting it the path and the name both look right. - fixed
Asking whether a window can be resized no longer resizes it.
DisplayControlofferedsetSizeand no way to ask whether it would do anything, so a consumer wanting to know had to call it and watch — which on a full-screen window is a visible change to the thing being asked about.canSetSize()answers without performing one, followingpad.canRumble's precedent: a capability query belongs beside the capability.BrowserDisplayanswers false, and the native path forwards to one decider inmain/ipc.tsthat both the query and the resize now read, so the two cannot disagree about when a resize is refused. The Android and iOS hosts answer it too. - changed
Every
core-*bundle floor moves, and@driftengine/coreis 584.1 KB gzipped against 553.0. Two things arrived at once and the split is on record inscripts/size-floors.mjs. 7,343 bytes of it was drift already inside the gate's 3% tolerance — the floor said 566,250 and the build before this release measured 573,593, so the gate stayed green while the number in the file stopped being the measured one. The other 24,549 bytes, 4.3%, is the area-light shadow, and nine tenths of that is generated WGSL rather than the decision: a shader ships twice in this engine, authored in GLSL and transpiled, andareaShadow()is compiled into the eight fragment permutations that carry point shadows. Gzip recovers most of it — 114,278 raw bytes of near-identical copies is what it is best at. Each package README now quotes what its own fixture measures rather than what it measured when its floor was last written, which is why the per-package deltas moved by up to 3.6 KB even though this change touches only core.
3.18.0 · 2026-08-28
- fixed
A system that throws no longer takes the rest of the tick with it. The throw left
runSchedule, so every system scheduled after a failing one never ran — once per tick, for as long as the game was up. Reported from outside, and the way it was found is the argument for containing it: a system queried a component it had not declared, which is refused by design; the system that stopped was three places later and was the one stepping a vehicle; the symptom was a car moving in jerks, and the only evidence was a browser console line no headless check reads. Each system's failure is caught now, reported once naming the system and the reason, and the schedule carries on. Nothing is disabled — a declaration error throws again next tick and is caught again, which costs a throw a tick and keeps a transient failure recoverable — andrunScheduleno longer throws, which is whatAGENTS.mdhas required of anything the frame loop calls since it was written. What it gives up is the stack reaching a debugger's uncaught-exception break; what would change that is a reporter this takes as a parameter rather than a rule chosen here for everybody. - changed
A ragdoll's joint limits are per joint.
RagdollOptions.swingCosandtwistSinwere one pair applied to every joint a rig has, and a knee is a hinge with no twist where a shoulder is nearly a ball — so a consumer chose which of the two to get wrong. Reported from outside, where the choice was loose (a 78° cone and 74° of twist at every joint, with a comment saying tight limits look like a wooden puppet) and what it produced was reported from play as a scrambled ball of things, limbs torso head mixed together: at 78° nothing stops a knee or an elbow folding a limb through the torso, so a body that lands rolls itself into a knot. Either option now takes an array as well as a number, indexed the wayparentsis, so the entry at the knee limits the knee. The default stands in past the array's end and wherever an entry is not a finite number, because the other reading of a missing entry is zero and zero is a 90° cone at exactly the joint nobody thought about. What this does not buy is a real hinge: a narrow cone with no twist still bends a little in every direction, and the axis a revolute joint would need is rig data the builder is never handed — a parent array and world matrices do not say which way a knee folds. - changed
Takes DriftScript 1.9.0, where a component a query narrows by is a component it reads. The language's access analysis walked a loop's body and never its own
query<…>, so a component appearing in a query and nowhere else was invisible to it — while this engine's runtime refuses a query unless every component in it is declared, because a schedule derived from declarations is wrong the moment a system touches more than it says. The two tools gave opposite instructions about the same line:DS0291called the declaration the runtime demanded unused, and a module written on that advice compiled clean and threw once a tick.DS0288refuses the omission now, at compile time. A consumer that compiles.drsmoves its own pin in the same step, which is the fourth copy of this number and the ruleAGENTS.mdrecords.
3.17.0 · 2026-08-28
- added
A node of a blend tree may be sampled on a clock of its own.
BlendTree.evaluatewalked every node with onetimeSec, so a set whose members must be sampled on different clocks could not be written as one tree — and that is the canonical locomotion blend space rather than an exotic case. A stride has to advance with distance travelled or the foot slides while the body passes over it, which is the factrootMotionexists for; an idle has to advance with time, because somebody standing still is still breathing. On one clock one of the two is wrong: share the distance and the idle freezes whenever nobody moves, share the time and the walk skates. Aclipnode now names the parameter its own time comes from, and a node that names none takes the timeevaluatewas given, so every tree written before this is unchanged. The value is a time on the clip's own axis in seconds, and what advances it is the caller's business — a stride in metres is divided by the metres a cycle covers and multiplied by the cycle's duration, because an engine that took metres here would be deciding what a character is doing. A name is a blend weight or a clock and never both, refused at construction: one number doing both jobs is a rig answering the wrong dial. Reported from outside, where the workaround was two clocks kept by hand and ablendPosesper overlay, which gives up the declared graph, the scratch poses claimed at construction, and a state machine's crossfades on top. - changed
drift/animationdrives a blend tree and no longer offers to build one.blendTreetook aSkeletonand handed it to the engine constructor's root node parameter behind anas never; called, it threwCannot read properties of undefined (reading 'length'), because a tree asked a skeleton for its children. Nothing had ever run it — the two tests over the capability asserted that the compiler required its third argument, which it did — so a capability nobody could use had been described, typed and documented. Building a graph needs a node value the language has no shape for, so the broken capability is gone rather than repaired in place: a compile-time refusal is earlier than a runtime one.blendSetandblendAtreplace it and are the whole script-side surface of a tree: a host builds one in TypeScript, where the skeleton and the clips already are, and hands it to a system throughuses, which is the seam DriftScript 1.8.1 opened.blendSetis also how a script reaches a node's clock.
3.16.0 · 2026-08-28
- changed
A body of water is bounded by a rotated rectangle, not a square.
WaterBody.boundswas a centre and one half-extent, so the only shape a bounded body could take was a square — and the square that contains a 3 by 21 metre channel floods eighteen metres of ground either side of it.halfXandhalfZare the body's own axes now, andforwardXandforwardZsay which way those axes point: a direction and not an angle, matchingMeshBuilder.addOrientedBox, because a consumer bounding a ditch, a gutter or a canal arrives already holding the direction its run takes. It is normalised on the way in, and a direction of no length is read as unturned instead of putting the sheet at NaN.halfMstays as the shorthand for a square and a per-axis extent overrides it on that axis alone, resolved in one place so two spellings cannot disagree. The extents and both rim fades are measured per axis throughresolveWaterand both backends, so a channel reaches its own bank instead of fading along its whole length; the unbounded ocean passes one number twice and is unchanged. What this cannot do is bend, so a canal that turns is one body per straight run, and the corner is where two sheets overlap. Reported from outside, where the workaround was a single unbounded body at the ditches' level with the world's whole ground raised above it. - fixed
A frame may draw more than one body of water on WebGPU. Every body's uniforms lived in one buffer rewritten before each draw, and a
queue.write*call does not interleave with the draws it sits between: writes are ordered on the queue timeline and the frame's encoder is submitted after all of them. So a ditch at its own level, a basin in a courtyard and the sea behind them all drew with whichever body was written last — its extents, its level, its colours, its direction. Measured on the new instrument at 387,837 pixels of a 921,600-pixel frame: three of four bodies gone, and the sea with them. Each body takes a slot of aUniformRingnow and binds it by dynamic offset, uploaded once a frame. WebGL2 never had this, because it sets uniforms and draws in one stream. Invisible for as long as nothing drew two, which is how the joint palette's copy of the same defect survived eight releases;demo/dev/water.htmlis the page that draws four. - changed
A rain field's fall speed and shutter time move while it is running.
RainFieldOptions.speedMpsandstreakSecwere read once into private readonly fields, so a weather state that changes under a player could not change the rain.RainField.speedMpsandRainField.streakSecare plain settable fields: nothing is sized by either and nothing caches a value derived from one, so a write between updates costs an assignment. Hail is the case that shows it — about 14 m/s against rain's 9, which is 0.233 m of fall a tick instead of 0.15 and a 0.7 m streak instead of 0.45, because the streak is the distance a drop covers while the shutter is open. Reported from outside by a consumer whose hailstorm had to be expressed as a wider, denser rain and read as heavy rain.demo/dev/lines.html?rainspeed=writes the value every frame, which is what asserts it is read live. - fixed
A ragdoll's root accessors answer the bone head on all three axes.
rootX,rootYandrootZare documented as the first bone's parent end, so a caller can move a character's node to where the physics put it, and they returned the body's centre — half a bone away, which for a 0.92 m root bone is 0.46 m of a character standing in the air. Two of them readbodies.posYandbodies.posZplainly; the third reached the same centre through a helper namedboneHeadX, so the name answered the question and the arithmetic never did. A capsule is built about its bone's middle and turned to put its own +y along the bone, so the head is the centre half a bone back along that axis, and the first bone's half length is taken in the build loop from the length the body was placed from. Reported from outside, where the symptom was a body drawn half a metre above the physics holding it — which reads exactly like a pose that never arrived, and cost the reporter two weeks of looking for one. - added
demo/dev/water.html, the instrument for a body of water. Four bodies in one frame — the unbounded sea, a square basin, a channel three metres across and forty-two long, and the same channel laid along a direction forty degrees off the axis. It exists because every shapeboundscan take was untestable from inside: the two published scenes that draw a bounded body draw a square each, and nothing anywhere drew two bodies at once. It reads no clock, so two runs photograph the same pixels, and the two backends differ on it by 56 pixels.
3.15.0 · 2026-08-28
- changed
Breaking, and the whole of it:
GroundProbespells a normal the waySurfaceHitdoes.nx,nyandnzare nownormalX,normalYandnormalZ. The interface's documented purpose was that aGroundSurfacefrom@driftengine/coresatisfies it, and that was never true: a surface wrote three fields nothing read and left three fields nothing wrote. TypeScript allowed the assignment because method parameters are bivariant, and a flat surface still behaved, because the scratch it is handed starts at(0, 1, 0). Only a banked one showed it, by handing the character the normal of whatever body it last touched. Nothing in three consumers implements this interface, so the rename is a compile error naming the field where a stale normal was a week. - fixed
A ground probe is a floor now, and was a sensor before.
senseGroundread the surface and setGROUNDED, and nothing put the feet back on it: the sweep resolves bodies, and a surface is not a body. A character over an analytic world sank at gravity, reportedGROUNDEDfor three ticks while the gap stayed inside the six-centimetre tolerance, then went airborne and fell for ever. Measured at 10.17 m under the floor after one second. Every test the seam had ran a single tick from feet already exactly on the surface, which is the one arrangement that cannot see it.standOnSurfaceundoes a descent this tick could have caused and nothing more, so a character in a cellar is not hoisted through the ceiling and a jump still leaves. - added
One floor for the character and the car alike.
Vehicletakes the samegroundprobeCharacterControllerdoes, consulted only where a wheel's ray found no body, and fills the sameRayHita body would have, so no grip code learns that a surface exists.heightSurface(heightAt)turns any(x, z) => heightinto aGroundSurface: a normal by central difference or an exact one you supply, bounds outside which there is no ground, and a non-finite height read as absence instead of passed on as aNaNposition two frames later. A consumer reported that there is no terrain height a controller can ask about, that the answer they wrote is a hundred lines, and that every consumer with a road writes it again. This is a seam and not terrain, which is still Track L: it renders nothing and stores nothing. - added
coveredAbove(surface, x, z, from, to): is the sky visible from here. The third question a surface can answer, and the only one that had no name. It is asked by more than weather: rain and snow that stop under a canopy, a puddle that forms only where the sky reaches, a sun shaft that should not appear indoors, an agent deciding whether to take shelter. A hole is a hole for free, which is the property worth having: the answer comes from whether the surface has a face in that column, so a gap between pads, aRibbonSurfacewith a stretch ofholes, and a bounded field past its own edge all let the sky through exactly where they look like they should, with nothing describing the opening twice. - added
Rain, as streaks the line batch already draws. A drop is a streak and not a sprite: rain photographs as the distance it moved while the shutter was open.
RainFieldkeeps a slab of drops around the viewer, spread through it on the first update so the first second is rain and not a descending curtain, and draws each from where it is to where it was a shutter-time ago. The wind angle comes free, because the streak runs along the velocity. It reads no clock and no random number: a drop's column is a hash of its index and how many times it has fallen, so a consumer evaluating instants out of order sees the same rain every time. Drops land on the ground and do not fall through roofs, neither of which the rain owns: a roof is the nearest floor under anything above it, andcoveredAboveis the other half. - added
Debug draw, as the generator the line batch was missing.
drawLineshas been on both backends since the polyline batch landed, with a world-space width and a pixel floor. What was missing was anything that turns what you want to see into segments, so a consumer who wanted a normals key wrote the geometry themselves or, far more often, did not.DebugLinesfills aLineSegmentswith a mesh's vertex normals and with every collider aPhysicsWorldholds, dispatched on what a shape is: a sphere is three great circles, a capsule a ball at each end, a cylinder two flat caps and a sharp rim, a polytope its face loops with each edge kept once, a concave mesh its triangle edges. It allocates nothing after construction and never throws — a segment past capacity is counted indropped, because a drawer that threw would take the frame down at the moment somebody was trying to see what was wrong. Its absence was measured outside: one consumer wrote geometry with downward normals four separate times, including every building in their world lit inside out, and found each by wondering why tarmac was black at midday. - added
Quads that face where you meant, and a box that lies along a line.
addQuadtakes its normal from(b − a) × (d − a), which is not the order a person walks around a rectangle, so the obvious winding for a horizontal quad lights it from below. That is documented, and documentation is not the fix: it cost five scenes in one application and then four bugs in one afternoon in another, one of which was every building in a village lit inside out.addGroundQuadtakes the corners in any order and faces up;addWallQuadfaces away from a point you name, which is a thing a caller knows without looking anything up. Both reverse corners instead of negating a normal, so the triangle winding still agrees with the face.addOrientedBoxis the other half:addBoxis world-axis only, so a hedge along a property line came out as a row of slabs each facing a different way. Its six faces go throughaddWallQuadagainst its own centre, which makes the outward normals one argument instead of six windings. - added
Decals, as the surface clipped and not a quad over it. A forward renderer has no G-buffer to write a decal cheaply into, which is the fact every design here starts from. What it can do is the older technique and the better-looking one: take the triangles the projector box covers, clip them to it, and lift them along their own normals. The mark is then made of the surface and follows every curve of it, which a projected quad cannot. The stand-in it replaces was reported from outside as strips of geometry lifted two millimetres above the road: a draw call per patch, for something that exists only to sit on top of something else, and flat. Clipping carries the normal through every crossing, and facing is decided by the triangle's own plane, so a projector deep enough to contain a floor and the ceiling above it marks the floor. Static: a decal on something that later deforms rides the old shape, and a decal drawn at render time is the row that is left.
- added
drift/navigationhas a provider. The linker has refused it by name since the language shipped, and the reason given was that nothing here paths. That was true and it was never a blocker: it was the row. A consumer costed the absence at twelve agents walking straight at whatever they are going to and wedging against a building on the way, about once every two minutes of play, and noted that they already extract a road graph. A graph and not a navigation mesh, because the consumers who ask for this arrive with a network already, and what that costs is stated where it is decided: an agent travels along edges, so open ground crossed by two nodes is crossed in one straight line.buildNavGraphrefuses an edge cheaper than its own span, which is what makes the straight-line heuristic admissible and the first route the cheapest one.NavSearchallocates nothing per query and breaks ties on the node index, so a route does not depend on the order the edges were declared in.NavPathanswers where to steer, a lookahead along the line and not the next node, and recovers progress from the agent's own position, so a shove costs progress and never correctness. Its reads are deterministic and its writes are not, which is the language's line:navigation.readis inDETERMINISTIC_EFFECTSandnavigation.writeis not, so a@deterministicsystem may steer along a route and may not compute one. - added
drift/behaviorhas a provider, and it survives being interrupted. A routine can be written today as a table and a loop, and a consumer's works. What it cannot do is be looked at, held, stepped, or interrupted without being lost, and the last is the one they named: an agent dropping what it is doing because it started raining, and picking it up again after. A selector that switches to a higher-priority branch suspends the one it displaced, leaving its cursors exactly where they were, so the errand resumes at the step it had reached. State is cleared only when a branch finishes, which is when starting again is what starting again means. Most implementations reset on abort; a test perturbs to that version and goes red.buildBehaviorTreeresolves every action and condition against your own tables, so a typo is refused when the tree is built and not the first time a villager reaches the market.doing(behavior, name)reads the tree's own answer, which stops a script keeping a second copy of what an agent is doing and watching the two drift. - added
ActionMap.axis(action, 'x' | 'y'), andrawAxisX/rawAxisYindrift/input.vectortreats an action's two axes as one direction and shortens a keyboard diagonal to the rim, which is right on foot and wrong at a wheel: throttle and steering are two controls, and dividing both by √2 means a driver holding forward and left can never reach full lock while accelerating. A consumer lost two weeks to it. The report was that the car did not turn and was slow, the whole vehicle model was rewritten looking for it, and the cause wasMath.hypotthree call frames away doing exactly what it was written to do; they shipped four duplicate digital actions bound to the same keys to get around it.axisXandaxisYare unchanged, because correcting them in place would make every scripted character quietly faster on a diagonal. - added
A light volume answers to the air it stands in. The shader's own header has said since it was written that a beam is more visible in fog, because the fog is what there is to light, and nothing implemented it: the same headlight cone was drawn in soup and on a clear night. Reported from outside as fog filtering the frame instead of changing what can be seen through it, which at night is the whole of the road.
mediumon the draw scales the volume by the haze at the volume's own height against the density it was authored for, so a car in a valley of fog seen from the ridge carries its own weather. A CPU multiplier inlightVolumeDraw.tsand not a uniform: the shader clamps strength to one, so a gain could only go downward, and the value is per draw. No shader changed, and both backends read the one function. Clear air returns zero and the existing guard skips the draw. Driven bydemo/dev/volume.html?fog=.
3.14.0 · 2026-08-27
- fixed
A rolling body's contact anchor turned with it, which sank a rolling sphere 28 millimetres into the floor and braked it — and every physics result moves now that it does not. The solver stores each contact anchor in its body's own frame and turns it forward through the substeps, which is exact for a face or a corner and wrong for anything round: the point where a rolling ball meets the floor stays at the bottom however far the ball has spun, and an anchor that turns anyway climbs its side and reports a gap that is not there. The solver then let that gap close, so the body sank until the penetration made up the difference. Measured before the fix: a sphere of radius 1 rolling at 6 rad/s sat 28 mm into the ground and stayed there, and in the solver's own baseline scene a sphere of radius 0.4 rested at 0.3916. The drift also acted as a rolling resistance nobody had asked for — the same ball now leaves a floor it used to stop on. A contact on a curved surface is held in world frame instead, where a constant offset from the centre is the right answer for the whole tick;
Manifold.curvedAandcurvedBcarry the decision, and a cylinder sets them per feature because its cap is flat and its side is not. The sphere rests at exactly 0.4000. What this costs a consumer: any stored fingerprint fromfingerprintBodies, and any replay compared against one, will diverge and must be re-taken. Nothing in the public surface changed shape, so code compiles and runs unchanged. It was found while building the cylinder primitive and was never about cylinders. - added
A cylinder primitive,
cylinderShape(radius, halfHeight), and it is a second kind of rounding rather than a second kind of shape.ConvexShape.radiusgrows a point cloud by a ball, which is what makes a sphere and a capsule and is exactly why it could never express a cylinder: a uniform radius rounds the rim along with the side, and the rim is what a wheel rolls on.sideRadiusgrows the cloud by a disc perpendicular to the segment through its first two points instead — two flat caps, a curved side, a sharp rim between them. The workaround it replaces had a floor this repository had already measured: an n-gon prism throughhullShape, capped at thirty-two sides because a hull takes 64 points, where a thirty-two-sided wheel of radius 1 rolling at 6 m/s bobs 16 mm that no side count can reduce. The same wheel as a cylinder bobs under 2 mm, inside the solver's own five-millimetre slop, andprismRoll.test.tsmeasures both side by side. A curved side enumerates no separating axis, so contacts build their axis list against the other shape — its faces, its edges crossed with the cylinder's axis, and two directions per vertex — and answer every one exactly from the cylinder's support; the touching feature is then a cap disc, a side line or a rim point, each clipped differently. Mass properties are the two textbook moments in closed form, bounds grow by the disc's extent down each axis rather than by the radius on all three, a ray is a quadratic and two discs rather than the march a capsule needs, and cloth pushes a particle out through the same four-region closest point. - added
Static concave mesh colliders,
meshShape(positions, indices). A level is a mesh and everything in it is a hull. The shape carries the triangles, their planes, an edge classification and aDynamicTreeover them — the tree this package already had, built once and never moved, so no second acceleration structure had to be written or tested. Static only, and the limit is enforced rather than documented:addBodyrefuses a mesh on anything but a static body andcolliderFromShaperefuses one outright, because a moving concave mesh needs its tree refitted every tick and a mass tensor a triangle soup does not have, and because a mesh handed to the kinematic sweep would arrive as its own bounding box and turn a hollow level into a solid brick. A moving concave body wants convex decomposition into hulls, which the runtime already accepts. Every triangle goes through the same narrow phase everything else does, as a three-point polytope written into one scratch shape — so a box, a sphere, a capsule, a cylinder and a hull are all exact against a mesh on the day it lands and none of them was written twice. A mesh pair produces several contact planes rather than one, because a body in the corner of a mesh room has two or three and reducing them to one picks a wall and lets the body through the others. Contacts are one-sided, because a level mesh is a surface and a two-sided triangle fires anything that has sunk below it back out the wrong way; rays are two-sided, because a sight line that saw through a wall from behind is a worse answer than one that did not. The interior-edge filter is what decides whether a mesh floor feels like a floor: without it a box sliding across two coplanar triangles catches on their seam, which is a character stumbling on a flat floor once a metre. Measured — a box slid at 6 m/s across sixteen quads keeps 80% of its speed with the filter and 1% without it. - added
Cloth pushes what it lands on, and stops passing through itself. Both are off by default, so no existing world moves until it asks.
couplingis a scale from 0 to 1: at anything above zero, a particle pushed out of a body hands the body back the momentum it lost, at the contact point, so a sheet landing on one end of a plank tips it — which an impulse through the centre of mass could not. It is an impulse exchange rather than a merged island, and the refusal it replaces was right about the price and wrong about the only way to pay it: putting particles into the rigid solver's constraint graph would merge islands, and merged islands are what the parallel executor is made of, so that stays refused. The exchange is explicit, so the body answers on the next tick and a very heavy sheet on a very light body rings; turningcouplingdown is the answer.particleMassis in kilograms and is deliberately separate frominvMass, which is a relative weight the compliance is tuned against.selfDistanceis in metres and turns on self-collision: a spatial hash rebuilt each tick with one bucket a particle, a 3×3×3 cell scan, and a symmetric push apart split by inverse mass. Particles a link joins are exempt, which is not an optimisation — a stretch link's rest length is the spacing, so a self-collision distance anywhere near it would have every neighbour fighting its own link and the sheet would inflate rather than drape. Measured on a sheet squeezed until it folds: with it off the closest unlinked pair comes to 58 mm inside a sheet whose own spacing is 100, and with it on the closest pair is at or above what was asked for at 60, 80 and 90. - added
Depth of field.
depthOfFieldis the construction-time ceiling — how far a defocused point may spread, as a fraction of the frame's height — andsetDepthOfField(distance, range, scale)is the per-frame dial: where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling it takes. Both distances are in metres, because a camera focuses on a subject rather than on a value ofgl_FragCoord.z. A circle of confusion from the depth buffer and an eight-tap disc, spliced into the composite rather than given a pass of its own: that pass already holds the resolved scene, its depth and a place in the chain where the picture is finished but not graded, so a second fullscreen pass would be a second read and a second write of the frame to add eight taps to the pixels that are out of focus. Off costs one comparison on a uniform. The ramp is linear rather than physical — sharp insiderangeof the plane, full blur one morerangebeyond it — because a real circle of confusion needs an aperture, a focal length and a sensor size, three numbers a game camera does not have and would have to invent. A tap contributes fully when it is behind the pixel it is blurring into and only as far as it is itself defocused when it is in front, which removes the halo a sharp subject wears against a blurred background; spreading a blurred foreground over a sharp background is something a gather cannot do, and that is said rather than approximated. The pair matters:depthOfFieldalone leaves the focus at its default of zero metres, which is behind the camera, so every pixel comes back at full blur. - added
Occlusion culling, and it is arithmetic on the CPU rather than a GPU feature.
occlusionCullingis the buffer's width in texels,addOccluder(min, max, model)declares a box that things behind it may safely be hidden by, andoccluded(bounds, model)answers;cullDrawsconsults it beside the frustum test. The two obvious implementations each fail a rule this engine already holds: a hardware occlusion query answers a frame late, needs a bounding-box draw per candidate, and is spelled differently on the two backends; a GPU depth pyramid over the frame's own depth has to be read back to cull on the CPU, which stalls, or tested in a compute shader, which WebGL2 does not have. Neither can be asserted without a GPU, and this engine's whole culling story is assertions over arithmetic. So the consumer declares its occluders and the buffer rasterises them, erodes by a texel, reduces into a max pyramid and answers a rectangle test. A small object never occludes anything: a crowd does not hide the crowd behind it, and what would reverse that is a scene whose occlusion is genuinely made of small things. Everything errs toward drawing, because a cull that is wrong the other way is a hole in the world: back faces, since a box hides what is behind its far surface; eroded by one texel, because a triangle sampled at texel centres over-covers by half a texel at every silhouette; and a maximum pyramid tested against the object's nearest point. Driven on both backends: seventy-two boxes behind a wall and four that must survive, 60 of 76 draws removed and zero pixels of 870,400 changed, with the two backends culling exactly the same objects. - added
A contributed pass may bring its own attachments.
PassDefinition.prepareis a sibling ofdrawrather than a widening of it:drawis handed an openGPURenderPassEncoderand a render pass cannot be nested, so the WebGPU arm ofPrepareContextcarries aGPUCommandEncoderinstead. It runs at one fixed point inbeginFrame— after the frame's encoder exists and before anything opens the frame's render pass — for every pass that declares it, in registration order. That is an order and not a dependency graph: gate 1.2's withdrawal of dependency ordering stands, and a pass that needs another's output registers after it. On WebGL2 the contract is a restore rather than an ordering:prepareis called with the default framebuffer bound and must leave it bound, which is the promisedrawalready makes about program, vertex array, blend, depth and viewport, one step stronger because a framebuffer left bound takes the whole frame rather than one draw.createPassAttachmentbuilds the target from thePassDevicethe pass was handed atinit— colour and optional depth,rgba8orrgba16float, released by the pass's owndispose— and it is single-sampled on purpose, because a multisampled one needs a resolve target beside it and a decision nobody has asked for. It costs nothing for a pass that does not declare it: both renderers count the passes that do and skip the step at zero. What a pass writes into the frame stays refused, for the reasonreadsalready gives. - added
View-dependent colour for Gaussian splats, at degree 1.
readSplatPlykeeps the l=1 spherical-harmonic band — transposed out of the file's channel-major order, which is the reordering a reimplementation gets wrong and which produces a capture whose sheen is real, wrong and in the wrong channel — andpackSplatswrites it as one extraRGBA32UItexel a splat: nine 8-bit coefficients and a per-splat scale in the fourth word. Bytes rather than halves because nine halves is eighteen bytes and would not fit a texel; a per-splat scale rather than a capture-wide one because it costs the sixteenth byte and gives every splat the whole range for its own coefficients. The direction is taken in the capture's own space, because the coefficients were trained in that frame and a world-space direction leaves the sheen behind when the capture turns. Degrees 2 and 3 are declined with the arithmetic on record: a splat's record is read six times a frame, so at the 400,000-splat mobile budget degree 1 is 115 MB a frame against 77, degree 2 is 192 and degree 3 is 307 — on a device already carrying 389 MB a frame of attachment traffic. Opt-in by construction: a capture without harmonics is eight words a splat and two texels exactly as before, and the shader's gate is a uniform, so it performs no third fetch. The record width travels through theSPLTblock's own header, which the container format had already designed for this. - added
A latitude and a season for the celestial clock.
celestialStateAttakes an optionalCelestialSite, and with one the sun's arc follows the place and the time of year rather than swinging through a fixed arc peaking at 66 degrees wherever the world was meant to be. A declination from the day of the year, an hour angle from the clock, and the two spherical formulas over them and the latitude — so the poles get their midnight sun and their polar night, andobliquityDegis a parameter because a stylised world may want no seasons at all or sharper ones. The moon then stands where its phase says, taking the phase as its elongation: a new moon shares the sun's place in the sky and a full one is half a turn from it. The model without a site pinned the moon exactly opposite the sun, which contradicted the phase reported beside it — a crescent that rose at midnight. A caller that names no site gets the same numbers bit for bit, asserted against hand-derived literals rather than against a second call: every world authored against this engine is tuned to that arc, and a model that silently moved the sun would move every shadow in every consumer at once. What it still gives up: the clock's hours are taken as local solar time, so a shadow measured against an almanac is out by the equation of time and by a place's offset from its time zone's centre; and the moon's own five-degree orbital inclination is ignored, worth up to five degrees of its altitude. Reported by a consumer who had worked the gap out for themselves and written a note beside their workaround. - changed
@driftengine/physicsgrows from 31.0 KB gzipped to 39.5, +27%, and it is three capabilities rather than a creep. The cylinder is 5.5 KB of it and is a second shape representation rather than a fourth constructor: a curved side enumerates no separating axis, so its contact module builds an axis list against the other shape and none of it shares an instruction with the polytope path. The mesh collider and the two cloth rows are the remaining 3.2 KB between them, and the mesh's own shape is the reuse the design was built around — one triangle written into a scratchConvexShapeand handed tocollideShapes, so the separating-axis test, the clipping, the round paths and the cylinder's own module are all exact against a mesh without a byte of new narrow phase. What would make it wrong is a consumer importing the package for a character controller and paying for a mesh collider they never build; the answer there is a second entry point rather than a smaller narrow phase. - changed
The language pin moves to
driftscript1.5.0, which givesstd/mathandstd/timea float width they do not fix. The language grew afloattype meaning *f32orf64, the same one throughout the call* — the width taken from the first argument that has one — somath.clamp(v, 0, 1)now works whethervis single or double precision, where before it was single only and nothing converted between the two.f32.nearest(v)andf64.nearest(v)are the conversions that were missing; a float previously had none at all, whichDS0232said in so many words. Nothing indrift/*changed shape: every capability this engine declares is writtenf32and 1.5.0 accepts that unchanged, so a.drsfile that compiled before compiles now, to the same JavaScript. Sixteen signatures moved inpackages/script/capabilities.jsonand all sixteen are the language's own standard library, which is the language server learning the new widths. The bindings go on rounding to single, and that is now a decision rather than an accident: 1.5.0 movedstd/math's rounding out of its implementations to the call site, because a library that rounded every result would destroy the precision of a double-precision call — and the language's own note to hosts is to implement afloatcapability in double without rounding, while rounding stays right for a signature left writtenf32. Every one of ours is. What would make that wrong is widening one of them tofloatand leaving the rounding in place, which is written beside the helper that does it. The upgrade was found by one red assertion inhost.test.ts, which is the pin's seam working: the language grew a type name and the test that lists them went red on install rather than a script author meeting it.If you compile
.drsfiles, move your owndriftscriptpin to 1.5.0 in the same step. This engine describes itself to the language, so your compiler reads a registry this engine filled; an older compiler is then handed capabilities it has no types for and answersDS0237, “floatis not a type this host registered”, on every file. Nothing warns you, because npm installs both copies happily and because a.drsfile is compiled by your bundler and not bytsc— a typecheck and a full test suite pass over it, and the build is where it lands. This cost two consumers of ours an afternoon between them.
3.13.1 · 2026-08-27
- fixed
Two skinned characters in one frame both took the pose of whichever was drawn last, on WebGPU. A rig carries its placement in its joint palette, not in its model matrix, so the symptom was not a bend gone wrong: every character in the frame stood in the pose, and at the position, of the last one drawn. The consumer that found it reads as a transparency fault, which is why it took a while to name. A game drawing an Echo of an earlier run and then the live character got the Echo with the character's palette, so a translucent second body sat exactly on top of the player. The cause is that
queue.writeTexturedoes not interleave with draw commands: uploads are ordered on the queue timeline and the frame's encoder is submitted after every one of them, so a single palette texture written between two draws hands both draws its final contents. Every palette a frame sets now takes a slot of its own, which is the treatment per-draw uniform blocks have had here since they became a ring. The same collapse applied to two skinned shadow casters in one bake, and is fixed with them. WebGL2 was never affected:texSubImage2Dis a command in the same stream as the draws around it, so an upload between two draws separates them there by construction. It survived from 3.6.0, where skinning shipped, because nothing drew two rigs in one frame: no published scene has a rig at all, and the dev page that has one drew a single character until?pair=1was added beside this fix. Measured there, two rigs held at different phases: WebGPU differed from WebGL2 by 22,608 pixels of 832,000 before, and by 0 after.
3.13.0 · 2026-08-27
- changed
DriftScript is taken from the registry rather than from
packages/, and a consumer taking it by path has one line to change. The language was extracted to its own repository and published, so the copy that lived here is gone along withpackages/driftscript-language, and the editor client is a marketplace listing rather than a directory. Nothing in@driftengine/*changed shape: the bindings in@driftengine/scriptexport what they always did, and a game that never wrote a.drsfile notices nothing. What breaks is a manifest line reading"driftscript": "file:.../driftengine/packages/driftscript", which now points at a path that does not exist; replace it with the published version. The engine's number is spent on@driftengine/*and this moved none of it, which is why a language that changed how it is hosted does not make this a major release. - added
Root motion, in
@driftengine/animation.sampleRootMotionreports what a clip's root track moved between two times, so the caller advances the character and the pose leaves the root where it is. It stays a pure function of the clip and the two times, with no clock of its own and nothing allocated per frame, which is what keeps a run replayable through an animation that drives it. - added
Controller rumble, and the refusal is half of it.
GamepadView.canRumbleanswers whether this pad has motors this browser can drive, andrumble(durationMs, strong, weak)andstopRumble()each answer whether the platform took the call rather than accepting it and doing nothing quietly, so a settings screen greys the control out instead of offering a slider a player's hardware ignores. The engine takes durations and magnitudes and nothing else: no pattern language and no named effect, each of which would be a decision about what a game does with the hardware. The actuator is re-read at every poll, because a pad unplugged and plugged back in is a different device behind the same slot. - added
An asynchronous save backend, behind the seam that already answers now.
KeyValueStoreis synchronous because preferences are read during boot, before the first frame, and an await there is a frame drawn on the wrong settings and then corrected. SoRemoteSaveStorehydrates a cache once and drains writes behind it rather than widening the interface into an asynchronous twin every consumer would have to learn. It coalesces, because a slider writes on every drag frame; it retries with a growing backoff, and a failed batch is merged back under anything written while it was away, so a resend cannot silently undo the drag; and it carries a status, because a game showing "saved" over a queue that has been failing for ten minutes is worse than a crash. - added
A configurable six-DOF joint, in
@driftengine/physics. Three linear and three angular degrees, each free, limited or locked, with the motors and break impulse the other six types already carry. Purely additive: every existing joint solves to the same numbers it did before, which is asserted rather than assumed. - added
An elliptical friction cone, as a world option defaulting to the behaviour you already have. Two tangent impulses clamped independently bound the pair to a square, so a box has about forty per cent more grip along the diagonals between the tangent axes than along them, and which headings are cheap is an artefact of how the basis was built rather than a fact about the world. Measured over eight headings at 4 m/s on a 0.8 surface: 1.064 m along the axes against 0.737 m along the diagonals under the box, and 1.059 to 1.073 m under the ellipse. It is off by default because every stacking result moves under it, and this engine does not silently invalidate a stored replay or a golden fingerprint. A world that says nothing is bit-identical to one that asks for the box.
- added
Colour grading, applied where the tone curve already is.
setColourGrade(lut, strength)takes anRGBA8cube of display values, the 32 or 33 lattice points a grading tool exports, and the composite samples it after the curve and before the frame veil, because a.cubeis display-referred. It needsscreenEffectsand says so on the console rather than doing nothing, since without a composite every pass grades itself and there is nowhere to apply a look once. - added
A spot light projects a cookie.
setSpotCookiestakes a consumer's images into an atlas and a light picks a tile, oriented by the same axes a photometric profile uses. It fills the outer cone, so one mask works in a narrow spot and a wide one, and it tints rather than scales, which is what a stained window needs. The engine ships no cookie: what a fixture throws is your art. - added
An asymmetric photometric profile keeps its horizontal planes. A street light or a wall washer is not axially symmetric, and until now the reader kept the first plane and flattened the rest. A light takes a row with
lightIesProfilesand an asymmetric one also takeslightIesAxes, because the azimuth reference cannot be derived from the aim: there is no continuous field of unit vectors tangent to a sphere, and every obvious construction is singular exactly where these fixtures point. - added
Ambisonics, decoded through six virtual speakers.
createAmbisonicSoundfieldtakes a four-channel first-order field in ACN order with SN3D normalisation, which is what a B-format recording carries and what every capture tool emits. It decodes over an octahedron, each speaker panned through the same head model a placed source uses, so a whole recorded scene costs six convolutions however much is in it. The decode sums to exactly one over the sphere, so a field is neither louder nor quieter for having been decoded, and a buffer that is not four channels is refused at construction rather than played silent over two thirds of the field. - added
A reverb zone can belong to a source rather than to the listener.
SpatialSource.attachZonecosts one gain node and a per-frame amount, because the return already carries its convolver, and it closes the case the listener model names as the one it cannot serve: a sound inside a cave heard from outside it now carries the cave's tail. The two-zone budget still binds and binds per source, since what it limits is how many convolvers are audible at once. - added
Condensed tar, so a smouldering fire's smoke is pale.
tarwas carried as a gas and nothing condensed it, which left the aerosol channel to a consumer to invent. It is a species and a condensation the network is told about now, so the difference between a flaming fire's black smoke and a smouldering one's pale smoke follows from the model rather than from something choosing between them. - fixed
The atmosphere walked its chunks in the order they were allocated, and that put a race into a deterministic simulation. Every full walk of the gas field went along the chunk list, which is history rather than geometry, and
sweepaccumulates each face into both sides' deltas with+=. Floating-point addition is not associative, so which chunk was visited first decided the order every contribution landed in. Measured: two logs eight metres apart, spawned in the other order, sixty ticks, 1,202 cells differing — a last bit, fed into rate laws that vary by twenty orders of magnitude across the temperature range of interest. Chunks are walked in key order now, which is a pure function of where a chunk is, and the sweep goes in rounds so a chunk created mid-sweep is still swept in the same pass. A chemistry run recorded before 3.13.0 may not replay bit-exactly, which is the cost of the fix and is worth it. - fixed
The contact solver's tangent pass took a block it never read.
solveTangentdeclared a constraint-block parameter and used none of it, which is a claim about what a function depends on. Found by a consumer's typecheck rather than by this repository's, which runs withoutnoUnusedParameters.
3.12.0 · 2026-08-27
- added
Track P is complete: chemistry, in
@driftengine/chemistry. Matter that is made of something, and behaves accordingly. Fifteen elements and sixty-eight species whose molar masses are derived from their formulas rather than stored beside them; substances whose composition, conductivity, porosity and ignition criteria are real published numbers; parcels whose state is enthalpy rather than temperature, so a phase change is a gated reaction and the plateau emerges from the solver; a depth stack of equal-mass shells, so a twig catches and a log does not; a reaction network checked against the element matrix at registration and costed by Hess's law, with rates tabulated soexpnever runs on a tick; one sparse atmosphere carrying oxygen in and smoke away; radiation with the 1/r² that makes distance mean something; and ignition as five criteria rather than a flag. 18.1 KB gzipped, importing no other engine package — nothing in core depends on it, so a game with no fire in it pays nothing. - added
Thirty-nine substances across seven optional entry points, because a substance is data and forty of them is a payload:
organic,food,fuel,polymer,mineral,metal,biological, each 628 to 1,165 bytes, so a consumer who wants a campfire imports one. Every number is sourced or back-derived from a measurement, and every derived quantity is checked against a second, independent one: calcination comes out at +179.2 kJ/mol against a published +178.3, magnesium at 24.75 MJ/kg, aerobic respiration at −2,802.7 against a quoted 2,803. Some of what falls out of that: a candle is wax melting, then boiling, then burning, with the word appearing nowhere; a steak cannot brown while it is wet, because Maillard needs 140 °C and a wet surface is pinned at 100; PVC self-extinguishes in air and PMMA does not, on one limiting oxygen index each; and a hay bale self-heats out of the mesophilic band and is picked up by the thermophiles above it. - added
drift/chemistry, the thirteenthdrift/*module: fifty-one capabilities over a read surface, a write surface and an event buffer of twenty-one kinds, three opaque types, andchemistry.writeinside the determinism boundary — the second effect ever admitted there, because a parcel's enthalpy is a simulated quantity integrated on the fixed step. Eleven new unit suffixes came with it,degC K kg g mol J kJ MJ W Pa kPa, anddegCis the only unit in the language that is an offset rather than a scale: the checker refuses it as an operand of+or-and says to write the difference inK, because the refused spelling compiles, runs, and produces a number 273.15 too large.@substanceand@reactionare annotations on adatarecord rather than language forms. - added
A presentation seam that names no core type. Six mappings, each a physical quantity rather than an art direction: smoke colour from the soot-to-aerosol ratio, flame height from Heskestad's
L ≈ 0.235·Q^(2/5) − 1.02·D, glow from Planck's law at the surface temperature, albedo lerped by char fraction, roughness from the free surface water, and a scale from the cube root of what is left of the volume — plus three audio scalars, and the package never touches@driftengine/audio. Every target is described structurally by what it must have, so core'sParticleInstancessatisfies it exactly and neither package knows the other exists. Emissive in this engine is gated onnightFactor, so embers written here look right at dusk and dead at noon; the call says so. - added
A
SUBSchunk at container 1.8, pairing material ordinals with substance ids, written by the baker from a glTF material'sextras.substance. An artist labels the oak in Blender and the log burns like oak, with no code in between. Additive, so a 1.7 reader opens a 1.8 file as the geometry it also holds. Matching is exact and unmatched materials are reported, never guessed — apine_bark_02silently matchingpineis a material with the wrong ignition temperature that nobody would ever notice. - added
Four level-of-detail tiers, and every one conserves exactly. Shells are equal-mass by construction, so folding four into one is a proportional redistribution and mass, every element and enthalpy come through a tier change unchanged — which is what makes one safe to apply to a burning object mid-burn. What a tier costs is measured rather than claimed: over five hundred seconds of fire,
Nearis exact,Fardrifts 21% andDistant43%, and the coarser tiers char more, because a lumped parcel has no cold core to hide behind. A cold stone sleeps, waking on heat, water, mass, a contact or a fire coming within range, through a coarse scan spread round-robin over four ticks. AndfingerprintChemistryis sixteen hexadecimal digits over compositions, enthalpies, the field's elements and the substance registry, order-insensitive over parcels because a handle is spawn order. - fixed
A gas cell reported a different temperature depending on whether some other object had happened to realise its chunk first. The unrealised path returned the authored ambient and the realised one returned it derived, and the two were 8e-5 K apart rather than an ulp — because the ambient enthalpy was built with a heat capacity at the ambient temperature and read back with one at the datum. That put an order dependence into convection and from there into whether a log ignites on tick N or N+1. Found by
fingerprintChemistryon its first run, which is what it is for, and fixed at the source: the field is a constantcpat 298.15 and the ambient is now built with the same array a cell temperature divides by. - fixed
A thin, highly conductive object beside a fire rang to 4,000 K and back to 100 with its energy conserved the whole way. Inward conduction capped its sub-steps and the note above the cap said a ring was the right failure, because nothing is lost. It is not: an ignition criterion reads a surface temperature and a glow is drawn from one, so an oscillation that loses no energy is still a fire starting in the wrong place. Each shell now carries its own
dh/dTand a pair may not exchange more heat than would equalise it — unconditionally stable, exactly conserving, and identical wherever the step was resolved anyway. The radiative and convective terms are bounded the same way, against the source and the gas. Found by putting a copper coin on a hearth indemo/dev/chemistry.html. - fixed
Five more
Math.hypotcalls arrived under a promise of machine-independent results, in the new swept step and stall escape — the same class of bug 3.11.0 fixed ten of in the collision kernel, which is why the gate that names them exists. ECMAScript pinssqrtto IEEE-754 and leaveshypotto the implementation, so a stored replay can diverge between two browsers. All five are exactly-rounded arithmetic now. A consumer holding byte-exact replays or ghosts recorded before 3.12.0 should re-record them: the results move in the last bits.
3.11.0 · 2026-08-26
- added
Track B is complete: rigid body physics, in
@driftengine/physics. The collision kernel moved out of core into a package that imports no other engine package, and the whole of the dynamics landed on top of it: a dynamic bounding-volume tree for the broadphase, contact manifolds in three exact cases, a substepped soft-constraint solver, islands with island-gated sleeping, six joint types with motors and one-sided limits, world raycast, shapecast and overlap, sensors and a 32-layer mask, a kinematic character controller, ragdolls, a raycast vehicle and XPBD cloth. Thirty kilobytes gzipped for all of it, because none of it is shader text.@driftengine/corere-exports every name, so nothing a consumer imports has changed, anddrift/physicscarries the whole surface to DriftScript rather than the two read-only functions it held before. - added
Constraints are soft, and that makes the substep count a quality dial rather than a behaviour change. Every contact and joint carries a stiffness in hertz and a damping ratio instead of a fraction of penetration per step, so raising the substeps buys accuracy without moving anything: a ten-box stack rests at the same height at four and at eight, and a dropped ball bounces to the same height at two and at eight. Contacts persist across ticks by a feature id rather than by proximity within a tolerance, which is what makes warm starting correct rather than order-dependent, and it is why a stack settles in a handful of iterations.
- added
The simulation is bit-identical on any JavaScript engine, and that is a construction rather than a promise. The tick uses only the operations ECMAScript specifies exactly, which is the four arithmetic operators and
Math.sqrt; every function the specification declines to pin down is refused by a lint gate over the whole package. That forces some of the design: joint limits compare cosines and quaternion components rather than angles, and a tyre's grip comes from a table you supply rather than from a formula built onsinandatan. The last of those turned out better than what it replaced, since a curve is edited by moving a point. - fixed
The collision kernel promised machine-independent results and did not deliver them.
hullShape's own header said the same input yields the same features in the same order on every machine, which is what a stored replay depends on. TenMath.hypotcalls sat under that promise, and ECMAScript does not specifyhypotprecisely. Two of them mattered: one normalises the plane normal that classifies points against a coplanarity threshold, so a single bit decided whether a plane became a face at all; the other normalises the directions compared for duplication, so a single bit decided how many separating axes existed and in what order. All ten are exactly-rounded arithmetic now, and nothing in the suite could have found it, which is why the gate exists.
3.10.0 · 2026-08-26
- added
Collision moves into
@driftengine/physics, which imports no other engine package. The convex shapes, the spatial hash over them, the segment queries and the swept kinematic sweep now live in a package of their own, and@driftengine/corere-exports every name, so nothing a consumer imports has changed. What it buys is a simulation that runs with no renderer anywhere in its module graph: in Node, in a worker, or on a server, which is what an authoritative host needs and what a dependency on core would have priced at core's whole gzipped weight. It is 5,832 bytes gzipped standalone, published as a size floor like every other package here. Core depends on this package rather than the other way round, and it is the one place in the engine where that arrow runs backwards. The cinematic and third-person cameras, the contact probe and the ribbon builder all need the sweep, so collision was never optional and is not being made so; what has to stay out of acreateRendererbundle is the dynamics being built on top of it, which the size gate measures rather than the documentation promising. - added
A shape carries its faces now, and can be weighed.
ConvexShapegained outward face planes and ordered vertex loops, beside the separating axes it already enumerated. The two are deliberately separate: a box has three separating directions and six faces, because a plane and its opposite are one axis and two faces. The axis list is what SAT wants, and the loops are what a contact manifold and an inertia tensor need.shapeMassPropertiesis exact for a hull, by tetrahedral decomposition over those loops, and takes the closed form for a sphere.capsuleShapenames what the representation already expressed, two points and a rounding radius, and stands along y so a character capsule needs no rotation applied to it. A cylinder is refused rather than quietly approximated, because the rounding radius grows around every feature and would round a rim as well as a side; build one as an n-gon prism and the facets are the honest cost. - fixed
The collision kernel's determinism claim was a comment, and it was false.
hullShape's header promised that the same input yields the same features in the same order on every machine, which is what a stored replay depends on. TenMath.hypotcalls sat under that promise, and ECMAScript declines to specifyhypotprecisely, so two engines may differ by one unit in the last place. Two of the ten were load-bearing. One normalises the plane normal that then classifies every point against a coplanarity threshold, so a single bit there decides whether a plane becomes a face at all. The other normalises the directions compared for duplication, so a single bit decides how many separating axes exist and in what order, and the separation test takes a minimum over that list. Every one of the ten is nowMath.sqrtof a sum of squares, which IEEE-754 requires correctly rounded, andscripts/determinism.test.mjsfails on any function ECMAScript leaves unspecified, so the next one cannot accumulate. All 2,926 tests pass unchanged, which is what a correction of that size should do.
3.9.0 · 2026-08-26
- added
Drift AI, in
@driftengine/ai, and an agent loop that does not wait. Live inference takes fifty milliseconds to five seconds or never, and an agent that waits for it stands visibly still — so there are two layers. A deterministic policy floor runs inside the simulation on the fixed clock, allocating nothing per tick and knowing nothing about providers, which is why an agent with none configured still behaves. On top of it, a one-slot buffer holds the next model-authored intent while the current one executes. The current slot is never empty: a provider that is slow, absent, or over budget costs quality, never motion. Exactly one request is in flight per agent, enforced by the shape of a four-state machine rather than by a counter — there is no edge on which a second could be issued. The continuation goes out when the current intent's remaining extent falls to the provider's measured p90 latency, so a fast provider is asked late against fresher context and a slow one early because it has to be; the tuning constant that would be wrong on every device does not exist. A buffered intent is a proposal rather than a decision, so guards the tools declared — never ones the model wrote — run again when it drains, and a failing guard discards it rather than deferring: a plan whose world is gone is a plan for a different world. Preemption clears the intent, the buffer and the request together and the floor covers on the same tick, with the thrown-away request reported aspreemptedRequests, because a number nobody reports is a number nobody tunes. Backpressure isMessageQueue. Accepted commands and preemptions are recorded besideTickTrace, so a run replays exactly with no provider called. Ships a deterministic test provider with programmable latency, a remote adapter that never sees a credential, and a local adapter accepted by running it. Bound to DriftScript asdrift/ai, with@aiTooland@aiContextgenerating schemas from signatures the checker already validated. - added
DriftScript annotations take arguments.
@aiTool(description: "…")and@aiContext(description: "…")carry the sentence that tells a model when to reach for something, and a tool without one is refused. String literals only, because an annotation is an assertion the compiler checks and an expression there would make it a computation the compiler has to run. - note
Two AI bridges are refused in writing rather than mocked. There is no AI navigation bridge, because nothing pathfinds and
drift/navigationwaits on no track at all; and no AI network authority, because that gates on networking. Each carries a sentinel indocs/CAPABILITIES.md, so the commit that builds either is red until the row is corrected. A seam with no implementation behind it is what the mock-provider reversal was written against.
3.8.0 · 2026-08-26
- added
Entities are language forms in DriftScript, not library calls.
componentdeclares a component's fields and the host builds a store from them, so a save survives a rename — field ids carry the.drsfile that declared them.component X from host { … }goes the other way and asserts a shape the host already registered, refused at bind naming the field and both types.entitynames a set of required components and turns its ownvarfields into a component.systemdeclares a body the schedule runs, at a rate that becomes a stride over fixed steps —update at 1Hzis sixty, and a rate that does not divide the fixed step exactly is refused naming the arithmetic, because rounding it is how a replay stops matching a recording.prefabis a description a host instantiates, so its values must be constants. Andfor e in query<Position, Velocity>().without<Frozen>()is the language's first loop over anything: it opens a pooled cursor and hoists a live view, so reading a field is a property load and an array index rather than a host call. Measured at 30.4 ns per entity against 28.7 for the same loop written by hand and 388 for a call per field — twenty thousand entities, two components, one implementation per process. - added
A system's
readsandwritesare inferred and the declaration is checked against them. The compiler propagates component access through the call graph the way it already propagates effects, so a write made inside a helper is a write the system declares — and a declaration that omits one is an error naming the system and the component, rather than the engine refusing the write three frames into a session. A declaration wider than the body is a warning instead, because widening deliberately is sometimes what an author means, and a system that declares neither clause is fully described by the inference. A query loop may notawait: its cursor comes from a pool and a suspension would hold one across a frame where the entity model already says the result is invalid. - added
@editor(…)is checked against the field it annotates, andmodefinally does something. A range on a field holding text describes nothing; a range in metres on a field defaulting to seconds moves the value by the wrong amount and nothing at runtime can tell, because units are erased by then; anassetTypeon a number has nothing to pick into it. None of that needs an editor to exist, which is why it lands before one does. The metadata rides the schema a module already emits and a production build strips it — the first thingCompileOptions.modehas ever been read for, and deliberately a difference in payload rather than in behaviour. - fixed
A hot-patched module kept running against capability namespaces that were
undefined.patchModulere-ran a module's__runtimehook and never its__bindhook, so the first capability call after any edit threw — for as long as__bindhas existed. Nothing caught it because the only module anything ever patched imports no capability at all. The runtime remembers which host a module was bound to and binds the new version to it. - fixed
Effect inference silently skipped three kinds of statement. The walk that decides what
@pureand@deterministicmean switched over seven statement kinds with no default, so a call inside one it did not name was invisible —emit Ping { at: time.wallClock() }andspawn worker(time.wallClock())both read the wall clock inside a function annotated as deterministic, and nothing reported anything. And an option's migration key named no inner type, sof32?andString?hashed identically: a module changing one to the other produced the same interface hash and its dependents did not recompile.
3.7.0 · 2026-08-26
- added
Entities, in
@driftengine/entities, and it imports no other engine package. An entity is one number carrying a 26-bit index and a 27-bit generation, so a handle to something destroyed is refused rather than silently addressing whatever took its slot — the conventional packed 32-bit handle splits 24 and 8, which makes a slot's 257th reuse hand back a handle identical to its first. Components are stored as a sparse set per type with a typed-array column per numeric field, so adding or removing one is constant time and moves nothing else. Queries walk the smallest set and allocate nothing, per call as well as per step. A system declares what it reads and writes and is held to it; the schedule orders by declaration with explicitafteredges topologically sorted, and reports which adjacent systems could run together without running them that way.everyTicksstrides the fixed step and is never a rate in seconds. Prefabs are values with no link back to their instances, and a scene addresses every value by a stable field id — so a scene saved before a component gained a field loads with the field at its default, and one whose field changed type is refused naming it rather than coerced. That migration is DriftScript's, unchanged: one description of a component, one thing that reconciles two versions of it. The package declares no component types at all — a consumer writes the ones its world needs — and costs 632 bytes gzipped with no engine at all. - added
DriftScript reaches entities and prefabs.
drift/ecsanddrift/prefabare bound, and two of the four surfaces the linker refused by name are gone with them. A component is addressed by a string the host resolves, and a name nothing registered is refused listing what the world has rather than reading zeroes and writing into nothing. There is no query capability yet — iterating one needs a language form that does not exist — so a script walks a component bycountandat, counting downwards because removing swaps the last entity into a position already passed.drift/navigationanddrift/networkare still refused in writing. - changed
ecs.writeis inside the determinism boundary. Entity state is the simulation, so a@deterministicfunction may write it — a movement system writing a position is the canonical deterministic operation, and a rule refusing it would refuse the thing the annotation exists to describe.scene.writestays outside for a reason that does not apply to entities: a scene node is what draws. The boundary had been written down twice, in the registry and in the effects checker, and adding the effect to one left the other refusing every script that used it; the checker imports it now.
3.6.0 · 2026-08-25
- added
Characters that deform:
@driftengine/animationis the whole of skeletal animation, and a rig now survives an import, a bake and a stream. A skeleton resolves a pose into a skinning palette; clips are sampled as a pure function of a time you supply; poses blend, layer additively, and run through blend trees and a state machine with crossfaded transitions; a two-bone IK solver puts a hand on a target; morph targets deform the bind pose; and retargeting plays one skeleton's clip on another by joint name. Both backends draw it, pixel for pixel. glTF's skins, animations and morph targets are read,bakewrites them, and.drftcarries them asSKIN,ANIMandMORP— alongsideNODE, which the format specified in v1 and nothing had ever written. Nothing in the sampling path reads a clock, so an animated character replays bit-identically from an intent stream. - added
Skinning costs 1,118 gzipped bytes and morph targets 1,674, because both are shader permutations of the vertex stage rather than branches every mesh pays for. The rule that a lit-pass feature must be a branch unless it earns a permutation is about the fragment corpus, where one more flag costs 246,925 bytes; the vertex shader had no permutation axis at all. So a mesh with no rig carries no skinning instructions rather than a branch it can never take, and the two flags together are about one percent of what a single fragment flag would have cost. The joint palette is a vertex data texture rather than a uniform array for the same kind of reason: a uniform array caps a rig at sixty joints on the WebGL2 floor, which is under a humanoid with fingers.
- added
Container format 1.7.
SKINcarries a skeleton,ANIMa clip,MORPa mesh's morph deltas, andNODEthe asset's own hierarchy — the last of which the format has specified since v1 with no writer and no reader until rigid TRS animation needed one. All four are optional, so a file written today opens in every reader this engine has shipped, as the geometry it also holds. - fixed
validateMeshDatanever checked the tangent attribute. Four floats a vertex, the widest optional attribute in the format, and the only one a short buffer could reach a driver through — for three releases. The hazard is the one that function's own documentation describes: a driver may read zeroes and may equally drop the draw, with no GL error either way, so a mesh renders perfectly for whoever built it and vanishes on hardware they do not own. - fixed
A blend tree or a state machine keeps the bind pose a rotation-only clip preserves. Sampling a clip leaves a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever the pose held — but blending interpolates every channel, so a tree whose scratch poses started at zero translation collapsed every joint onto its parent's origin the moment it blended anything. Both now take an optional bind pose. Supply it whenever your clips drive rotation alone, which is most of them.
3.5.0 · 2026-08-25
- added
Captured places:
@driftengine/splatsdraws Gaussian splat captures, and.drftstreams them coarse first. A capture is a real place photographed and optimised into a few hundred thousand elliptical blobs, and the whole of it now works:.plyand.splatreaders, the packing, a counting sort in a worker, and a pass that composes into your scene rather than instead of one. A wall in front of a capture occludes it and a beam drawn after it passes through, because depth is tested and never written. It reaches the frame throughregisterPass, so a project that never imports the package carries none of it — about twelve kilobytes gzipped over core, asserted by the size gate rather than claimed. The pass owns no vertex buffer and no vertex array on either backend: six vertices a splat come off the vertex id and everything else is an integer texture fetch, so the whole per-backend surface is a pipeline, two textures and a uniform block. Sorting is where the work is, because splats blend back to front andoveris not commutative, so the order is the picture and it changes whenever the view turns.SplatSorterruns the sort in a worker built from aBlob— no bundler configuration, no second file — holds at most one in flight and drops rather than queues, and re-sorts only once the view has turned about two and a half degrees, so a still camera sorts zero times a second. Where a strict content policy refuses aBlobworker it falls back to the main thread and says so once.splatBudgetcaps what a weak device is asked to draw, and it selects by screen-space size rather than by truncating the order — the order runs far to near, so a prefix of it keeps the backdrop and throws away everything nearest the camera, which is the largest thing on screen.defaultSplatBudgetreads the renderer's own name and lowers itself for the parts this engine has measured struggling; both numbers are reasoned estimates rather than handset measurements, and a consumer holding a real one should pass it. Each batch has its own transform, so two captures compose in one scene, and a capture out of frame is skipped before its sort rather than after — a discarded draw call is cheap and a linear pass over a million splats is not. And.drft1.5 carries a capture, in blocks laid out so any prefix of the file is the whole place. The container writes splats interleaved across the whole capture — sorted by Morton code, then walked bit-reversed — so the first block that lands is a complete sparse version of it rather than a finished corner. Measured on the engine's own page: 662 splats out of 24 KB of a 332 KB file, drawing the whole room, with the rest densifying it. The chunk is optional, so an older reader skips it in silence, and a file may now carry a capture and no geometry at all. What is still absent, and worth knowing: colour comes from the spherical-harmonic DC term alone, so a capture's speculars read painted on rather than shifting as you move. - fixed
A streaming capture drew up to a thousand splats short on WebGL2, at full density and never earlier. Splat blocks are pushed into the data texture a run of whole rows at a time, and a capture whose count is not a multiple of a row ends in a partial one — so the last block's source array was shorter than the rectangle it was handed to. WebGPU had a pad for that and WebGL2 did not:
texSubImage2DraisesINVALID_OPERATIONand skips the upload, so the final row kept the zeroes it was allocated with and the capture drew with its tail missing. Invisible to every test, because the arithmetic is correct for every block but the last and the missing splats are scattered across the whole capture rather than gathered anywhere a reader would look. Found by the thing that finds this class: the two backends are a control for each other, and the same page came back 59,585 pixels of 870,400 apart at full density and one pixel apart at one block. The row-covering guard is now one implementation both halves call, and the figure is zero.
3.4.0 · 2026-08-25
- fixed
Metals read as metal again, and the GGX prefilter is now something a scene opts into. The prefilter shipped in 3.2.0 wired straight into the lit pass, which changed the picture of every consumer that had a reflection probe — the one thing a render-quality option exists to prevent, since a feature's default has to reproduce today's picture. A box-filtered level is a smaller picture of the room; a prefiltered level is the room convolved with the lobe for the roughness that level stands for, so at the same material roughness it is far blurrier. That is the physically correct answer and it is not what existing content was authored for: it was reported as metals going opaque. Two things moved together and neither works alone. The level:
envLodfloored at a texel footprint, which is right for a box chain where a coarser level costs detail and nothing else, and against a lobe chain says this surface is rough — measured on a 512 probe with panels swinging 0.03 radians a pixel, an enforced roughness of 0.37 on every metal whatever its material map said. The weight: the split sum is the correct share of a prefiltered environment and over a box chain asserts an integral that was never performed, flattening the grazing-to-head-on contrast that reads as gloss from about 15 to 1 down to 5 to 1 at roughness 0.5.RenderQuality.environmentPrefilterswitches both halves together and is off by default, so the arithmetic is exactly what the engine carried before the lighting track. With it on, the footprint becomes a roughness combined in quadrature rather than a floor on a level. - added
RenderQuality.environmentPrefilter, off by default. On, the lit pass samples the GGX-prefiltered cube and weights it with the split sum; off, it samples the capture's box chain and uses the reflectance curve the engine carried before 3.2.0. It is one flag rather than two because the level selection and the weight both depend on which chain is bound and are wrong apart: a texel-footprint floor is right for a box chain, and a footprint expressed as a roughness and combined in quadrature is right for a lobe chain. A uniform rather than a shader permutation, which would have doubled the generated WGSL every consumer bundles as source.
3.3.0 · 2026-08-25
- fixed
A perfect reflector is no longer asked to absorb, and metals stop reading like paint.
envBrdfApprox, the split sum's analytic second half, integrates a single scattering event: a ray that strikes one microfacet, bounces off a second and leaves is energy the surface returned and the fit never counted. Its total comes to1 - 0.55 x roughness, and for a fully metallic texel — where the Fresnel base is 1 — that total is the whole reflection weight, so the shading asserted that a perfect mirror absorbs over half the light once it is roughened. Roughness scatters light; it does not absorb it. Measured against the weight this replaced: 22% of the reflection gone at roughness 0.4, 33% at 0.6, 44% at 0.8 and 55% at 1.0, which is a metal losing its room and reading flat and opaque. The repair is multiple-scattering compensation, and at a Fresnel base of 1 it returns exactly 1.0 at every roughness and every view angle — so a metal gets back precisely the weight it had before the split sum landed, and a dielectric gains the small amount it was always owed. Every published scene is 0 of 921,600 pixels against the build before it, on both backends. - fixed
A light is offered a shadow map for whatever might cast, not for wherever the camera is.
selectPointLightstook one reference point and used it for two decisions: ordering and culling the shaded list, which is a question about the camera, and ordering and culling the shadow pool, which is a question about whatever might cast. A third-person camera sits behind its subject, so a fire the subject was standing next to got no map at all whenever the camera was further from it than the fire's radius — and the scene stayed perfectly lit, which is why nothing distinguished it from a bake that never ran. It now takes a casting reference point beside the shading one, and the guard and the pool's rank both read it; reading two would fill the pool nearest-to-camera and then evict the very lamp the subject stands under. The default is the shading point and the arithmetic is unchanged, so a caller that says nothing selects bit-identically. - added
A registered pass is told the frame's depth format.
PassDevicecarried the colour format and the sample count because a render pipeline cannot be built without them and a package has no other way to learn what the frame is. The same is true of the depth format, and it was missing — so a contributed pass that wants to be occluded by the world had to guess it. It rides along now, on the same argument.
3.2.0 · 2026-08-25
- fixed
A moving caster is shadowed by a point light again — and had never been.
PointShadowSystem.advancewalked its own pool and never reached the live pair, which belongs toLivePointShadowSet, so that pair's arrival ramp never moved. The resolved weight is the crossfade weight times the image's presence, and the product was zero on every frame this engine has ever rendered: the shader multiplied a correct occlusion by nothing. Every other part of the path reported itself healthy, which is why it survived — the octahedral layer held the caster at 189 texels of 16,384 in one position and 57 in another,hasBakedwas true, the layer index and far plane were published correctly, and the crossfade weight was 1. Only presence was 0. A moving caster now measures 0.06 shadowed where it measured 1.00, at every position, identically on both backends. No published scene could have caught this, because every point-lit demo passes no moving casters, which is whydemo/dev/pointshadow.htmlnow exists. - fixed
Calling
updatePointShadowsbeforeprepareStaticPointShadowsnow says so. That second call builds the octahedral array and is a separate one a consumer makes once; forgetting it was completely silent — the update ran, lights were selected, the bake budget was spent every frame, and no light cast anything anywhere. The picture is indistinguishable from a world whose lamps do not cast. Both backends now warn once, naming the call to make, and return rather than baking into nothing. - added
A GGX prefilter, so a reflection is an integral rather than a box blur. Each level of the environment chain is convolved with the lobe for the roughness it stands for, importance-sampled, with each sample's source level chosen from its own solid angle so a wide lobe cannot turn a few bright texels into fireflies. It writes a second cube rather than rewriting the capture, which removes the feedback-loop question on WebGL2, stops a level being convolved from one already convolved, and leaves the spherical-harmonic projection reading the capture untouched. It costs one cube of memory and no texture unit.
RenderQuality.environmentPrefilterSamplessets the sample count, because a bake is once per scene and a phone and a workstation should not agree on it. Its partner shipped with it:envBrdfApproxis the split sum's analytic second half, so how much of an environment a surface returns is an integral rather thanfresnel * (1 - roughness), and it needs no lookup texture. - added
An environment can be loaded rather than only photographed.
readRadianceHdrreads Radiance RGBE — both scanline encodings, the mantissa at the centre of its bucket rather than the bottom, a zero exponent as exactly black, and every refusal naming what it found — andrenderer.setEnvironmentImagetakes the equirectangular image, resamples it onto six faces at the probe's own size, and runs the same convolution and the same projection a bake runs. Downstream of the cube it is a bake, so a loaded sky is not a second lighting path that can be wrong on its own. Opt-in exactly as SDF text is: a consumer that never loads an environment fetches nothing, ships no decoder, and the engine carries no image of its own. - added
Spot lights, as a point light with a direction rather than a second kind of light. A spot rides the same sixteen-wide record, the same selection, the same froxel table and the same octahedral shadow layer. A light that declares no cone carries cosines of −1 and −2, so the cone term returns its upper bound outright for every direction on the sphere and a point light's arithmetic is unchanged — every published scene is pixel-identical against the build before it. The inner edge is clamped so it can never pass the outer, and the direction is normalised rather than trusted, because a cone compares against a cosine and an un-normalised axis gives a cone of the wrong width. A spot's shadow takes an octahedral layer covering the whole sphere, so a 45 degree cone uses about 15% of it, roughly 0.38 of the linear resolution — comfortable for a lamp, coarse for a narrow stage spotlight.
- added
IES photometry, so a light throws what a real fixture throws.
readIesProfilereads IESNA LM-63 whitespace-delimited across line breaks, because every published file wraps its numeric lists and a line-oriented reader fails on all of them, and both scaling factors are applied because a file means their product.packIesAtlasputs any number of profiles into one texture, a row each, and every row spans the same 0 to 180 degree arc whatever the file measured — which is what lets a light carry only a row index. It applies to a point light as readily as to a spot, since a bare bulb has a measured distribution too. Asymmetric fixtures are flattened to their first horizontal plane, which is stated rather than hidden. - added
Rectangular area lights, with what is exact and what is approximate stated rather than blurred. The diffuse half is the closed-form cosine-weighted integral over the polygon, which is what a linearly transformed cosine reduces to at the identity — so that half is not approximated at all. The specular is a representative point on the rectangle handed to the existing sphere lobe, and it diverges from a fitted LTC at grazing angles where the real lobe stretches along the view. No fitted tables and no texture unit, so this is not LTC and is not described as it. The sign of the form factor is the sidedness, exact per fragment rather than a facing test about the rectangle's centre: a one-sided emitter takes its positive part, a two-sided one its magnitude. Four at once. They cast no shadow, knowingly: the shadow pool is octahedral and centred on a point and a rectangle is not one, which is recorded as a gap rather than left to be discovered.
- changed
Track C is complete, and two of its items were built differently from their design, each reversed on a measurement. Area lights are a branch on a count rather than a shader permutation, because the term costs 44,984 gzipped bytes compiled in against the 196,910 that doubling the permutation corpus costs — which every consumer pays whether or not they enable anything. That is the same reversal clustered lighting made. The three additions this release also measured what shared lit-pass code costs: 6,215 gzipped bytes for thirty lines, 10,517 for fifty and 44,984 for ninety, tracking generated operands rather than authored lines — so a term full of inverse cosines is far dearer than one full of branches.
scripts/size-gate.test.mjscarries the table.
3.1.0 · 2026-08-25
- changed
The WebGPU frame graph is on by default. Every drawing verb is recorded rather than executed, and the schedule opens the frame's pass and the mirror's, derives their load and store operations, and drops passes nothing reads. Turning it on changes nothing a consumer can see: all seven published scenes come back 0 of 921,600 pixels changed against
?graph=0, whole frame, with no device errors on either path, measured on an AMD RX 9070 XT.?graph=0turns it off in one reload, because a switch whose default has moved has to stay bisectable in both directions. WebGL2 has no verb-level graph and is untouched. Two defects had kept it off, and both were the same shape — a decision taken at one moment and used at another, which only recording can separate. A draw took the pipeline of whichever pass happened to be open rather than of the pass it would land in, which for a recorded draw are separated by a whole flush. And a reflection probe bake never replayed what its own callback recorded, so not one draw reached the cube: six with the graph off and zero with it on, meaning the probe stored its clear colour and a mirror reflected an empty room, with nothing anywhere reporting it. Both are fixed, both are held by tests that fail on the unfixed renderer for the right reason, and the fix for the first had its own stated assumption falsified by the second within the hour — a bake legitimately wants the scene's pipelines after the frame is presented, which the first fix had assumed nothing did.
3.0.0 · 2026-08-24
- changed
The mix is a tree, and
AudioGraphno longer holds it. Buses nest to any depth, each one a fader into an insert chain into a send tap into an output, and a send is taken after the fader so turning a bus down turns down what it sends. Solo silences siblings and leaves ancestors and descendants alone, because anything else silences a soloed bus through its own parent; mute survives a solo it is part of, since the two answer different questions and a solo that cleared somebody's mute would have overruled a decision it was never asked about. A bus created while somebody is soloing arrives already gated, because the gate is a property of the tree rather than an event that happened once. Snapshots capture levels, mutes and sends and deliberately not insert parameters — the master cutoff is written every frame from a game's own state, and a snapshot that captured it would fight that writer with the last write winning. Every mixing method onAudioGraphis removed rather than left forwarding, because a shim that works forever is a second answer to every question the console already answers and the two drift the first time one grows a clamp;PORTING.mdmaps all thirteen one for one, and every site is a compiler error naming the member. The transport is unchanged. The rewrite is asserted rather than claimed: an offline render of the old mix was frozen before a single source file moved, and the tree came back bit-for-bit identical to it on both channels — which is the audio equivalent of the zero-pixel rule the renderer is gated on, and it is now a standing gate atscripts/audio-baseline.mjs. - added
Sound placed in the world: an HRTF listener, occlusion, reverb zones and doppler. A listener carries position, facing and a velocity derived from successive positions rather than taken, with
warpfor a teleport so nothing derives twelve kilometres a second from a camera cut. Sources are panned with a head model instead of a stereo balance, which is what puts a sound behind somebody at all: measured on a real browser, a source three metres to the right is 4.15 times louder in the right channel and arrives 36 samples ahead of the left, 0.75 ms of interaural delay, and the mirrored source returns the two channels' figures swapped exactly. Occlusion is a curve the engine owns over a number the game owns — a game answers how blocked a path is through a probe closed over its own colliders, and the engine decides what that sounds like: a geometric sweep from 20 kHz to 500 Hz, twelve decibels down, smoothed in time so one rate means one thing at any frame rate. A fully occluded source is quieter and duller and never silent, because a sound that vanishes behind a wall teaches a player that walls delete things. A reverb zone is a bus, which is the payoff for the tree: a room is not a special case in the mixer, so it can be soloed and muted like anything else. At most two sound at once, the space being left and the space being entered. Doppler is computed rather than configured, because the specification removed it fromPannerNode: measured, a tone closing at 60 m/s comes back at 1246 Hz against 1212 derived and receding at 852 against 851. It is off per source unless asked for, since on a looping bed a listener walking past makes the bed's pitch wander. The whole spatial layer is standalone functions rather than methods, so a bundler drops it for an application that never places a sound: the mix tree costs a core bundle 1,441 bytes gzipped and the spatial layer another 1,479 that only its users pay. - fixed
A music fade landed at the start of an exported clip instead of where it was asked for.
AudioGraph.fadeMusicscheduled oncontext.currentTimerather than on the graph's own clock. Live the two agree, so nothing was ever wrong in the game; offline they do not, because anOfflineAudioContextholdscurrentTimeat zero for the whole time a timeline is being described — so in a rendered clip every fade landed at instant zero however lateat()said to put it, and a clip that should have ducked only at its end had been rendering ducked throughout. Measured on this repository's own reference render at the moment of the change: RMS 0.129 against 0.344, with every quarter of the render moved and the last one moved least. The replacement,MixBus.duck, schedules on the mix's clock like everything else and takes a multiplier over the fader rather than an absolute level, so coming back out of a fade isduck(1, seconds)and the level to return to stops being something the caller has to have remembered across a settings change.
2.13.0 · 2026-08-24
- added
Action maps and rebinding, which complete Track H. A game asks whether jump happened rather than which device said so, so the device branch stops being written by hand at every site that reads input and widened by every device added after. An action name is the consumer's own string and never an engine enumeration, because a game's verbs are a game's business. Digital actions answer the same three verbs a pad does,
down,pressedandconsumePress, so there is one shape to learn rather than two, andconsumePressclaims through the device itself rather than keeping a second register: two claim registers over one physical press is how a press gets acted on twice. Analog actions answer a direction instead of a state, and take the larger of stick and keys rather than the sum — a player holding a key while pushing the stick the same way is asking to go that way once, not at twice the speed, which is the bug a naive merge ships with. A keyboard diagonal is normalised so it is not faster than a straight line, and key directions follow the stick's own convention, where up is negative, rather than the engine silently disagreeing with the API it wraps.rebindreports the actions it displaced rather than deciding about them, since stealing a binding silently, warning, or refusing is a product decision; a game that wants uniqueness enforced reads the return and rebinds back. Persistence is a diff from the defaults, not a snapshot: a snapshot freezes the map at the version that saved it, so a game that later adds an action, or changes a default nobody had touched, would find every returning player still on the old set with no way to tell a deliberate choice from a stale record. It goes throughKeyValueStore, so bindings can live in a browser, a native shell, on a server or nowhere, and an unreadable record is the defaults reported once rather than a broken game. Rumble is the only input capability still absent, and it was never in the track. - added
The keyboard gained the two verbs a pad already had.
isDownis a level andonKeyDownis a callback, and neither is an edge a simulation tick can claim, so an action bound to a key had nothing uniform to read.keyPressedandconsumeKeyPressare latched at the poll and cleared at the next, exactly as a button's are. The poll now advances the whole input frame rather than only sampling pads, so it runs whether or not controllers are enabled: a consumer that switched them off would otherwise findkeyPressednever clearing. What the gamepad switch still turns off is the expensive half, thegetGamepadscall that allocates. The edges rotate between two sets rather than being cleared, because a key pressed during the frame that is about to end belongs to the next one, and neither set is allocated per frame. Nothing existing changed:isDownandonKeyDownbehave exactly as before.
2.12.0 · 2026-08-24
- added
Gamepads, controller identity and input hints, and a controller needs no wiring at all:
InputSourcepolls the Standard Gamepad on an animation frame of its own, so an application that linked nothing still gets a working pad. A consumer reads positions rather than anybody's lettering, because index 0 is A on an Xbox pad, Cross on a PlayStation pad and B on a Nintendo pad, which swaps the two physically. Any lettering the engine picked would therefore be a false statement about somebody's hardware.pad.identityanswers what the pad in the player's hands calls that position, across four families with a generic fallback and no per-device table: a device database rots silently and its failure is a wrong glyph a player sees and no test can. Glyph keys are identifiers a consumer maps to its own art; the engine ships none and never will.lastDeviceflips on use rather than presence, so a controller plugged in for something else does not take the prompts from a player still on the keyboard, andonDeviceChangefires only on a change so a prompt set is redrawn once instead of every frame. The mouse alone carries a movement threshold, because a keydown and a touchstart are intentional where a mousemove is not necessarily anything. Button edges are latched at the poll rather than derived at a read, so a slow frame that runs the fixed-step loop twice cannot turn one press into two:pressedis readable by anything andconsumePressclaims it for exactly one caller.getGamepadsallocates an array and the objects behind it, which is the browser's cost and cannot be pooled, so it is called exactly once a frame and every accessor is a lookup on numbers already copied out. A pad the browser will not vouch for is served rather than refused, reachable by raw index, answeringmapping: 'unknown', and saying so once per device rather than once per frame. Any source can be switched off, and a disabled gamepad stops the poll outright.autoPoll: falsehands the sampling point to a consumer that wants it pinned to its own fixed tick, which is what a recorded replay needs. Options arrive as a third constructor parameter, so every existing call site compiles untouched. Action maps and rebinding are still absent. - fixed
Neither renderer released a registered pass or compute definition when it was disposed.
unregisterPassandunregisterComputehave always released the one they removed, and nothing called either on teardown. The registries are private fields, and a field going out of scope is not a GPU object being destroyed, so an application that creates and destroys renderers, which is a page switching worlds, an editor reopening a viewport or a test suite, kept a pipeline, a bind group and every bufferinitbuilt for as long as the process lived. On WebGPU that included the cluster binner, registered at construction and never unregistered, so its table texture and light buffer leaked on every teardown with clustering enabled. The drain is one shared helper rather than four loops, and it bumps each slot's generation so a handle kept past teardown still answers nothing. The order is the load-bearing part on WebGPU: both registries are drained before the surface is disposed, because that call ends indevice.destroy()and a definition releasing a buffer against a destroyed device is how the fix would become the bug it replaced. The two backends guard differently on purpose, since deleting a lost GL context's objects is an error where a lost WebGPU device swallows the call. - changed
Both backends now sit under
render/backend/, beside the surface they implement, the selector and the acceptance probe. One of them used to sit at the root ofrender/while the other sat in a subdirectory that repeated its own name in its filename, so neither the layout nor the naming said that these are two implementations of one thing. The WebGL2 class isWebGL2Renderernow, for the same reason:RendererandWebGPURenderernamed one backend and left the other as the default.Rendererstays exported as an alias and is not deprecated, so nothing is required of a consumer. No application here binds either class, because every one reaches the engine throughcreateRendererand holds the result asRendererApi, which is what made this free to do. A pure move, verified by an unchanged test count rather than by inspection.
2.11.0 · 2026-08-24
- added
Two primitives for racing a recording against a live run.
TickTracekeeps what a fixed-step simulation produced, tick by tick, as a flat float array plus one byte of flags: an intent stream has no keyframes, so reaching tick 4,000 of a replay means running the four thousand before it, and doing that once up front turns a replay from a simulation into a lookup. ItstickAtOrBeforeis the method worth reading — given a channel that only rises it answers which tick crossed a value, which is how two runs of one course are compared at the same point on the course rather than at the same moment. Comparing by time drifts the instant somebody takes a wider line. It returns the last tick of a run of equal values, because a channel stalls whenever the thing it measures stops, and answering the head of a plateau would report the other party ahead by the whole length of the stall.fingerprintCollidersis the other half: a stable, order-independent hash of the static geometry a recording ran against, so a build that generates a world differently can refuse a stale replay instead of drawing a body moving through empty space. It reads the raw bytes of the bounds array rather than any decimal rendering, because a float's text is a platform question and its bit pattern is not, and it applies no tolerance at all — whether half a millimetre matters is the caller's judgement, and a hash that rounded would take that judgement from every caller at once. - changed
The capability map's Platform row now names what backs its claim. It has said “replay-safe simulation path” since the map was written, with no primitive behind the words. Worth recording that no sentinel forced the correction: the sentinel list has no replay row, so the only guard that went red was the test-file count — and fixing a count while leaving the prose stale would have been the worst of both, a green build over a map that lies. Track J is untouched and
ROADMAP.mdnow says so where somebody would look: these are the offline half, comparing a recorded run against a live one with no transport anywhere, and Track J is the live half. They share the determinism contract and nothing else.
2.10.0 · 2026-08-24
- added
Every plan the packaging work set out with is finished. Steam moved into the process that can hold it:
steamworks.jsis a native Node module and the renderer runs sandboxed with no Node, so the first arrangement could never have run. The SDK now lives in the main process and crosses the same audited bridge as every other capability — a game gets cloud saves by naming an app id, withhost.storeunchanged and the main process deciding whether the bytes land in a file or in the cloud, and achievements arrive ashost.services, which is null in a browser, null where no app is named, and null when the copy was launched outside Steam. The packager copies the SDK out of the game's ownnode_modulesand unpacks it from the asar archive, because a.nodebinary inside one fails to load.drift-package steamwrites the two filessteamcmduploads with, nothing set live.?report=1draws what a device says about itself over the running game — renderer string, WebGPU, secure context, platform, WebView build — on every platform, because a pinned desktop Chromium cannot see the class of failure the mobile tier exists to catch. - added
A Mac target is cross-built from Linux, which the packager used to refuse. Nothing in one is compiled, because Electron ships a prebuilt darwin runtime, so the artifact was never the obstacle: the signature was.
isSignAllowed()returns false off darwin, electron-builder logs one warning and packs on, and the unsigned.appthat leaves is refused outright by Apple Silicon's loader.rcodesignputs an ad-hoc signature on instead, pinned and fetched bybootstrapinto the toolchain cache, and a Developer ID in the environment is refused here rather than quietly downgraded to ad-hoc. Its own zip is unusable off a Mac and says so in its source: the archiver picks 7-Zip everywhere but darwin, guarded by a comment reading "7zip dereferences symlinks, corrupting .framework structure and breaking codesign", and the guard readsprocess.platform === "darwin". It produced a 404 MB archive with zero symlink entries against 353 MB on disk, silently, so the build asks fordirand archives withzip -y: 188 MB, 14 symlinks, layout intact. Everything the host cannot launch it checks instead: every nested bundle sealed, the Mach-O slice matching the target, and the archive carrying its symlinks. What Linux still cannot do is named rather than worked around: there is no.dmg, because that ishdiutil, and no proof it runs, because that needs the machine it runs on. A build made this way launches on an M1 MacBook Air, which is the check that matters, since an unsigned binary would have been stopped by the loader before it drew. - fixed
The iOS origin is
drift://localhost/, and the host is what makes it a secure context. This was the open question that gated the whole tier, and it is answered from the specification rather than from a device: a custom scheme is not trustworthy to WebKit on its own, but W3C's is origin potentially trustworthy returns trustworthy for a host inside127.0.0.0/8or oflocalhostwithout reference to the scheme — which is what Capacitor has shipped ascapacitor://localhostfor years, its own documentation recommending the hostname staylocalhostbecause it allows Web APIs that would otherwise require a secure context. Sodrift://localhost/is one anddrift://app/is not, and the two differ by six characters. The familiar write-up about_registerURLSchemeAsSecureanswers a different question — an https page fetching a custom-scheme subresource, not a document loaded from one — and its private selector is not needed here. There is no loopback server, which is what this was otherwise going to cost: a listening socket inside a game.
2.9.0 · 2026-08-24
- added
The packager reaches every platform it set out to, and an exit is something a game decides. Android ships a signed APK measured on a device: the game is served from the asset root an https origin gives it, the back gesture goes to the game before it closes anything — a registered quit handler, or the Escape key a pause menu is bound to, and only then two presses with a line on screen between them — and a download the WebView would have dropped is caught, read back from the page and written to Downloads through MediaStore with no permission asked for. iOS is written: one view controller holding a WKWebView, a scheme handler with the same containment rule the desktop shell has, and everything the engine reads synchronously injected as a literal before the page's first script, because that platform has no synchronous return from native code at all. It has not been compiled on a Mac, and the question that gates it — whether WebKit calls a custom scheme a secure context — ships beside it as a probe page that answers it in one look. Steam binds to the two seams that already fitted: cloud saves behind
KeyValueStore, achievements and presence behindPlatformServices,steamworks.jsan optional peer dependency imported through a variable so a game that never ships there never downloads a store SDK, anddrift-package steamwrites the depot scripts with nothing set live.bootstrapnow fetches XcodeGen too, and--projectpoints the whole command at another directory so a game keeps its manifest and none of the build tooling. - changed
LifecyclegainscanQuit, which is what an exit button should be drawn from. A browser cannot close its own tab and an iOS application does not exit itself, so a menu that always shows Exit has a button that does nothing on two of the platforms a game ships to — and a game that instead branches on whether it is in a shell has hard-coded an assumption that stops being true. It isfalsein a tab and on iOS,truein a desktop shell and on Android. - fixed
WebGPU on Linux no longer costs clip export, and the refusal that said it did is gone. The packager used to fail any Linux build asking for both, on a measurement that was true of the flags it was passing:
--use-angle=vulkan --enable-features=Vulkanyields WebGPU and encodes canvas frames as all-zero YUV under Wayland — a green clip — with nothing reporting it, sinceVideoEncoder.errornever fires and the muxer finalises a file of the expected size.ForceEnableWebGpuInteropcomposites WebGPU through GL rather than moving ANGLE onto Vulkan, and both survive: measured in a packaged build on an AMD RX 9070 XT, the adapter reportsamd rdna-4, a WebGPU draw reads back 255,0,255,255 and a vp8 round trip comes back 255,0,254 rather than green. And an Android build is served from the root its own paths point at: a web build references/assets/index-abc.jsabsolutely, so served from a subdirectory every stylesheet and script resolved to nothing — the APK installed, launched, and showed unstyled text with no error anywhere. Found on a phone; the build now refuses a bundle whose entry page points at files that will not be there.
2.8.0 · 2026-08-24
- added
A game is packaged into an installable application, and the first two platforms work end to end.
@driftengine/packageis a seventh workspace package and adrift-packagecommand:bootstrapfetches whatever a target needs,doctorsays whether a project and a machine are ready and how a build would be signed,runstarts the real shell from source,buildproduces artifacts, andverifygreps the built bundle for a string from your diff, because an artifact that was never rebuilt looks exactly like one that was. Desktop is a bundled Chromium: the game is served over adrift://origin rather thanfile://so modules, workers andfetchwork at all; one audited bridge crossescontextIsolation; there is no menu bar on Windows or Linux and a minimal one on macOS where Cmd+Q lives; the browser's own key bindings are refused in a shipped build; developer tools do not exist in one; and an external link opens in the person's own browser, with only http, https and mailto allowed to leave at all. Android is an APK around the system WebView, served from an https origin so it is a secure context, with the same bridge injected before the game's own scripts through a document-start script. Signing has three declared modes and every build prints which one it used: none, ad-hoc — which macOS needs, because Apple Silicon's loader refuses an unsigned binary outright — and real, from the environment. iOS is specified and not built, and Steam is not integrated yet. Measured on a development machine: an AppImage of a real game runs, WebGPU draws through it, saves persist, and a signed APK of the same build installs with developer mode on. - changed
The host seam is what a video settings screen is built on.
DisplayControlgainsmode,setMode,size,setSizeanddisplays, so a game's own menu can offer window mode, a resolution and a monitor list and get real answers — a shell reports its displays with their refresh rates, and a browser answersfalsefrom the calls it genuinely cannot perform rather than doing nothing quietly, which is what lets a settings screen grey a control instead of offering one that does not work. Two window modes and not three: Chromium never takes an exclusive fullscreen, so a separate borderless setting would be the same thing under another name, and a game that wants fewer pixels changesRenderQuality.resolutionScaleinstead.ScreenPresentationis new beside it and is what a phone has instead of a window — orientation and its lock, safe-area insets, and the wake lock — each of which can be refused, and iOS refuses an orientation lock outright. Measured while packaging:displayFrequencyis 0 on a Wayland session, sorefreshHzis null there, which is the honest answer rather than a plausible 60 something would divide by. - fixed
An inset that hangs off the edge of the canvas no longer throws on WebGPU.
setScissorRectis[EnforceRange] unsigned longand a viewport must lie inside the attachment at both ends, so a negative origin threw — inside the frame, which lost every draw recorded after the inset, on every frame of the animation. An interface panel sliding in from the left edge is exactly that for a few frames. WebGL2 takes the same numbers without complaint, which is why no gate had ever shown it: it was found by running a real game inside the new desktop shell, whose forwarded console printed the error. The rectangle is now clamped to the attachment, and one entirely outside draws nothing rather than clamping to a zero-width rectangle, which is itself invalid. The residual difference is recorded in the parity ledger: for the frames where a panel is half off the edge, WebGL2 slides it and WebGPU pins it to the edge.
2.7.0 · 2026-08-24
- added
The capabilities a native shell implements, as interfaces the engine takes rather than imports.
DisplayControlis fullscreen and refresh rate,Lifecycleis focus and the end of the process,FileDialogsis choosing and writing a file, andPlatformServicesis a store's achievements and presence. They followKeyValueStoreexactly, which is the point: the engine declares what it needs, a host supplies it, and nothing undersrc/names a shell, a webview or a packager.BrowserDisplay,BrowserLifecycleandBrowserFileDialogsship as the defaults a consumer in a browser wants, and each answers honestly where a browser cannot answer at all:refreshHzisnullrather than 60, because a plausible number is one something will divide by, andrequestQuitwarns rather than doing nothing quietly, because a browser cannot close its own tab and a silent no-op reads as a working call.PlatformServicesdeliberately has no default implementation: a consumer that never asks for an achievement must not acquire a store SDK, ship one to a browser, or explain one in a privacy policy. No shell implementation ships in this release; this is the seam one plugs into. - changed
A WebGPU device is accepted by drawing with it rather than by asking whether it exists.
probeDevicecompiles whatever shader sources it is handed, renders one known colour into a 1x1 target and reads the pixel back; a device that fails either check falls back to WebGL2 carrying the device's own words inreason, on the same path as a browser with nonavigator.gpu. Feature detection could not have caught what this catches: through the whole iOS black screennavigator.gpuwas present, an adapter was offered and a device was returned, and what failed was a shader compile nobody asked about. The readback half catches the other class, a device that validates everything and rasterises nothing, which is a black screen with a clean log. What it compiles by default is nothing, and that was measured rather than assumed: on an AMD Radeon RX 9070 XT the draw-and-read check costs 4.2 to 5.4 ms, while adding all 46 generated WGSL modules costs a further 58 to 62 ms of every boot to catch the classuniformStride.test.tsalready catches at test time by reading the committed files. A consumer certifying on a platform nobody has tested passes the real set throughCreateRendererOptions.probeShadersand takes the cost, which is the trade a black screen makes worth taking.demo/dev/backend-probe.htmlandscripts/probe-check.mjsdrive it and re-measure both figures on any machine.
2.6.0 · 2026-08-23
- changed
WebGPU is the backend
createRendererpicks, and WebGL2 is the fallback beneath it. It was the other way round, and the reason had expired:preferWebGpudefaulted tofalsewith a comment deferring the flip to a numbered task in an implementation plan, while the engine's own demo page, the game and the studio each passedtrueexplicitly. So the conservative default was overridden by every consumer that exists and true for none of them — a default outliving its reason, which is only visible to somebody reading the option and the call sites at the same time. Re-measured before it was touched, because the parity table on record was nine days and three fixes old: seven scenes, one unchanged build, both backends, at the delta threshold the ledger reads, on an AMD Radeon RX 9070 XT. Four scenes are inside the 0.5% gate and three are above it for the reason already attributed to them — high-contrast edge density, with neither backend the better one — and nothing regressed:collapseandgilded-chamberimproved by about a third. Nothing changes for a consumer already asking for WebGPU, which is all of them here. What changes is the consumer who asks for nothing, and it now gets an adapter request in a boot that had none;preferWebGpu: falsedeclines it and restores the old boot exactly. The fallback is unchanged and still silent, on the same three counts, and?backend=webgl2still forces it in every build. The console warning for a WebGPU request that was not honoured now fires only when a caller asked explicitly, because warning on the default would put a line in front of every player whose browser has no WebGPU at all — which is most of why the fallback exists.
2.5.3 · 2026-08-23
- fixed
A tempo is counted from the beats the analyser is sure of, and it was reading each gap between two of them as one beat. They are not one beat: the analyser reports only the hits it can place and stays quiet about the rest, so a gap is a whole number of beats, and a set of such gaps forms one cluster per number of beats skipped. The middle of that set falls at the top of one cluster or the foot of the next rather than on a beat, and the error grows with the share of beats missed instead of staying small. Measured on synthetic tracks, a fully detected 140 counted 136.4, and one reported at two kicks in three counted at exactly half its tempo at four of the six tempos tried. Half speed is the error a listener hears at once, and it is what a manual tempo correction is usually being reached for. The beat is now the gap that accounts for the most other gaps, and every gap folds down to a single beat before the median is taken. Scoring every observed gap is the part that matters, rather than picking a better fixed one: a single unrepresentative gap explains nothing but itself and loses, where a fixed choice cannot tell the two apart and put one 128 reading at 112. Across four miss patterns and six tempos the worst error is now 0.81%, where eight of those twenty-four readings had been out by half. One case is unchanged and no rule over this data can fix it: where no two reported kicks are adjacent, every gap spans two beats and the track is gap for gap identical to one played at half the speed, so it is still read at half. Reaching that state takes alternate kicks about 15 dB down, and above that bar the reading is already right;
docs/IMPROVEMENTS.mdcarries the measurement of what the onset curve does and does not show there.
2.5.2 · 2026-08-23
- fixed
Standing on something means standing over it.
contactSupportasks whether a body's own column is over the thing holding it up, and it used the obstacle's bounding box as a stand-in for its footprint — exact for an axis-aligned collider and enormously wrong for a diagonal one. A ribbon's deck is tiled with wedges whose top face is the drawn triangle, so this is not an exotic case, it is every deck in every world: a slab lying across the world axes has a box several times its own area, and every column inside that box was called standing. What it produced is a walkable ledge in every cut in a deck — a body landing at the mouth of a gap catches the last wedge with the edge of its shoulder, its centre out over the hole, and is told it is standing, so nothing slides it off and nothing lets it fall. For a convex volume the column hits it exactly when the point is inside the shape's shadow on the ground plane, which the faces with a horizontal normal bound; faces looking up or down bound nothing there and are skipped, which is what keeps an ordinary lid from being cut down to a sliver. The same call reports which side the column fell off, so a consumer refusing support has a direction to act on.faceNormalsholds one entry per direction rather than per face, and reading it as though each entry bounded one side left the far end and one flank of every deck unbounded — the first cut of this fix did exactly that and the ledge survived it. Both ends of each normal are bounded now. - fixed
A body already inside a solid may leave it, and may not travel through it. The sweep granted such a body its whole requested move, unconditionally: the comment called it letting a body out rather than trapping it, and the code let it go anywhere. A character whose body had ended up inside a column could walk straight on through and out the far side, because every move it asked for was granted in full — and the same grant is why such a body reads as perfectly healthy, since a move nothing clamps leaves the support flag at the value it starts with, so the body is told it is standing and every backstop a consumer has waits for a refusal that never comes. The overlap runs from entry to exit along the direction of travel, so the body leaves in one going forward and the other going back; travelling toward the nearer of the two is coming out and toward the further one is going through, and only the first is granted, whole, so nothing is ever sealed in. Horizontal travel only, because the rule reads as its own opposite on a falling body: a body overlapping the flank of a tall wall has its shortest exit upward, so descending would count as the long way round and the fall would be refused — a body pinched between two walls then hangs in clear air, which is the failure this exists to end rather than one to introduce.
- added
ejectFromSolidpushes a body out of geometry it has ended up inside, which is the one thing a swept move cannot do: both of its clamps test a face an overlapping body is already past, so nothing constrains it. Gated on the body's centre rather than on overlap, because overlap is ordinary — a deck's hull sits a give below the surface a character rides — while a centre inside is burial. It resolves every overlap with one push rather than one per collider, since a body between two columns has a shortest exit from each that points into the other, and it arms only where a collider holds more than a quarter of the body's width on both horizontal axes, because ejecting on a graze fights gravity. Hull colliders are skipped: a hull's box is its broad phase and says nothing about the convex volume inside it. - fixed
A downward contact reports the way off a rim it refused. Support is correctly withheld from a foot's corner on a sliver of a slab — the body's own column has to be over the thing holding it — but the contact normal on a downward clamp points straight up, because up genuinely is the way out of that clamp, so a consumer trying to slide off a perch finds no horizontal component to slide along. Nothing else can work it out either: which sideways ends the perch is a fact about the obstacle's footprint and only the sweep sees that. Without it a body could be held in clear air indefinitely, measured on a courtyard pillar as five seconds of held input and zero metres of descent.
2.5.1 · 2026-08-23
- fixed
Contact shading stops speckling and dotting along the edges it exists to draw. Three separate faults met on those pixels. The surface normal came from a hardware derivative, which is a difference across a 2x2 quad — and every silhouette in a frame puts two surfaces in one quad, so the difference is a secant from the near surface to the far one and the normal it yields is roughly perpendicular to the real one: occlusion saturates and the pixel clamps to black. A one-sided difference is substituted there, and only there, gated on a genuine discontinuity rather than on a preference, because taking the nearer side everywhere instead selects whichever half of a quantised flat surface happens to read zero and paints a horizontal band along every contour of constant depth. Second, the blur that makes the estimate usable ran over one period of the rotation tile, and its depth weight is designed to fall to zero across an edge — so a pixel beside a rail could keep a single tap, which is one rotation out of sixteen at the tile's own four-pixel period, drawn as a dotted line following the edge. It runs over two periods now, so a pixel hard against a silhouette still has four consecutive taps on its own side, which is every rotation exactly once. That halves what is left of the grain everywhere else as well.
- fixed
The occlusion estimate runs at the frame's own size on WebGPU, as it always has on WebGL2. It was half each way, undocumented, and it is not the free saving on this effect that it is on bloom: the estimate and its blur are one design, twelve taps turned by a rotation that repeats over a 4x4 pixel tile and a blur measured in the same pixels. At half size that tile is four frame pixels wide, so every structure the pass leaves behind arrives on screen at twice the size, through a bilinear upsample that softens the join without removing what is in it. The same setting on the same machine looked softer on one backend than on the other, and nothing said so. The cost is the cost the other backend has always paid, and a part that cannot afford it has the switch a profile already exposes.
- fixed
A lamp no longer casts a shadow with nothing making it. Three causes, and the loudest is WebGPU's alone: the depth pass's scatter batches wrote their uniforms into one shared buffer immediately before each draw, and a point light's bake records six cube faces on a single encoder with a single submit — so every batch in all six faces drew under the last face's light matrix, and five sixths of a field of ground cover went into the octahedral map at directions nothing occupies. Those batches take a ring slot each now, which is the fix the visible scatter pass has always had. Second, a bake spread across frames restarted whenever any of the light's parameters moved, and a flame that wanders to look alive moves every frame: with a budget of two faces the bake rendered the same two for ever while the other four kept an older origin, permanently, because the map stayed marked complete from the last bake that did finish. A bake in flight now owns the parameters it started with and finishes on them. Third, the live pair that holds moving casters published a map that had never been baked, and kept its image when a slot changed hands, where the static pool has forgotten and withheld one since the day that rule was written.
- fixed
Straight oblique bands stop appearing near shadows at a low sun. The directional filter follows the receiving plane's own depth gradient so that a grazing surface does not stripe itself, and that gradient is the solution of a 2x2 system whose determinant goes to zero exactly where the plane collapses toward a line in light space — a large deck under a low sun. The guard against that compared the determinant to a fixed 1e-8, where the quantity's own scale under an ordinary camera is around 1e-6: one percent of what it was guarding, so it never fired and the ratio blew up long before it. The guard is relative now, and the compensation it feeds is bounded as well, at nine texels' worth of depth — above every slope the receiver fade still draws a shadow on, and below the ones no bias can represent. Both halves matter: an unbounded gradient multiplied by a bounded offset is still unbounded, and what it drew was the shadow map's own texel grid, turned by the sun rather than by the camera, wherever the map held an occluder at all.
- fixed
The sky binds the medium the camera is standing in on WebGPU. Its uniform block declares an underwater colour and factor and the draw wrote neither, so both held the zero a staging array is born with for the life of the session and the shader's last line mixed by nothing: a camera under the sea drew the sky it would have seen from above it, sun, stars and clouds intact, while every other pass in the frame correctly went green. The comment that stood there called the atmosphere uniforms a later task's, which is how a missing bind reads as a scheduled one.
resolveAtmospherealso stops throwing on an environment whose underwater field is absent rather than null, which is a frame-loop path and may not throw at all.
2.5.0 · 2026-08-23
- added
Irradiance from spherical harmonics, on both backends. A baked reflection probe is projected onto nine coefficients and the lit pass evaluates them against the surface normal with no texture fetch at all — nine multiply-adds over a bare polynomial, with every constant folded in at bake time. What it replaces was a fetch three levels down a box-filtered cube, lifted by a
maxso a partial probe could not darken what stood in it, and gated by an option almost nobody set: a sample of a room rather than an integral over it.setEnvironmentAmbientis retired with it and is now a no-op that says so once; nothing has to change in a consumer, and it goes properly at the next major. Each backend reads its own cube its own way and hands the same six faces to the same projection — one synchronously inside the bake, the other through a sampling pass and a buffer copy that resolve a frame or two later, which a gate of its own makes a defined state rather than a difference in the picture. Measured on a grid of spheres between one emissive wall and one nearly black one: 25 of 25 lit from the bright side at a mean margin of 13.3 of 255 against a control of 1.0, identical on both backends, and zero pixels of movement on every published scene when the old term was deleted. - fixed
The WebGPU shadow pass gets the raster offset it never had, matching
gl.polygonOffset(1.1, 4)on the other backend. Its pipeline declared a format, a write and a compare and nothing else, so a shadow was compared against a depth with no separation from the surface receiving it. It had been measured at 2,178 pixels and filed as minor, and that measurement could not see what mattered: the pass was storing the far side of every caster, because its winding was inverted, and a caster's own thickness is an enormous accidental bias. 2.4.1 corrected the winding and removed the masking with it, so this is the other half of that fix rather than a new idea. The constant is not the same quantity on the two backends and the code says so: one counts multiples of a 24-bit buffer's smallest step, the other scales against a float. - fixed
heldClock.mjsreaches consumers. It sat at the engine's ownscripts/rather than inside the package, so@driftengine/core/scripts/heldClock.mjsresolved to nothing and a consuming project's entire visual gate threw on its first import. Nothing failed here, because this repository's own harness reached it by a relative path. A module a consumer imports by package path is part of the package, andAGENTS.mdnow says the check is to import it the way a consumer does.scripts/shadow-readback.mjsarrives beside it: it reads a directional shadow map off a live renderer on either backend, from any page, which is the one thing a screenshot cannot show.
2.4.2 · 2026-08-23
- fixed
WebGPU draws up to thirty-two volumes of light in a frame where it drew sixteen. The cap is the capacity of the uniform rings a volume's blocks come out of, and the other backend has no equivalent, because it uploads a volume's uniforms and draws it there and then — so past the cap a shaft was skipped on one backend and drawn on the other, which the console said and nothing else did. Sixteen turned out to sit inside the range worlds are actually built in rather than above it: across twenty-six worlds of an application using this engine, six ask for a volume at all, the largest asks for twenty-four, and the next two ask for fifteen. That largest world had been losing eight of its beams on every frame anybody photographed, and the picture said so once it was looked at — a lamp visibly lit with no shaft under it. Against the other backend it went from 34,827 differing pixels at mean delta 20.8 to 21,407 at 3.5. The cost is 12 KiB of uniform buffer, and the cap stays a cap: a scene asking for hundreds of shafts is still told rather than quietly allocated for.
2.4.1 · 2026-08-23
- fixed
The WebGPU shadow pass records the surfaces the light can actually see. Which faces a pipeline culls is decided in framebuffer coordinates, whose Y points down where clip space's points up, so a triangle wound counter-clockwise in clip space reaches the rasteriser clockwise — and every pass that gets presented is projected through a correction that negates Y and turns that back over. The directional shadow map is sampled rather than shown, so its correction deliberately leaves Y alone, and its pipeline was therefore discarding exactly the faces the pass exists to record: anything the light could see through went into the map as its far side, or not at all. It takes hollow geometry to show, which is why it survived a version and a half: a closed box stores the same nearest depth whichever faces are culled, and a vault, an arcade or a colonnade does not. Read back texel for texel against WebGL2 on a cathedral interior, 805,117 texels of 2,095,575 held depths more than two metres apart and 110,097 recorded nothing where the other backend recorded a surface; both are zero now, and the two maps agree to a mean of five centimetres. That world's frame went from 230,855 differing pixels at mean delta 30.6 to 144,482 at 5.5, and four of the engine's own scenes moved toward WebGL2 with none moving away.
- fixed
A pointer-lock request carries the options it was built with.
askForPointerLockcomposes{ unadjustedMovement: true }and handed it to a callback that took no parameters, so the option was dropped on the way out and every browser was asked for a plain lock. The feature had been inert since the commit that added it, with three passing tests above it that each supplied their own callback and so proved only that the helper offers the option rather than that anything receives it. For a player on a Chromium browser a camera now genuinely gets the device's own counts and loses the operating system's pointer acceleration curve, which is the whole reason the option is asked for. Firefox is unchanged and this does not fix it: 154 rejects the option outright, the fallback then asks plainly, and what arrives is the compositor's accelerated travel in whole screen pixels. That is measured, recorded at the helper's own definition, and visible in unrelated games from other engines in the same browser — the motion is discarded before any browser sees it, so no engine can reach it.
2.4.0 · 2026-08-23
- fixed
createPointLightBuffertakes a capacity, so clustered lighting can be reached through the helper every consumer already uses. Clustering shipped in 2.2.0 able to carry 320 lights, and this buffer was sized from the shader's uniform arrays at sixteen — so the froxel table would take as many as a caller could offer, and the one function anybody offers them through could not offer more than the fixed budget. A consumer that built the arrays itself could always fill it, which is how the engine's own demo page lights forty lamps, but no consumer does that. The default isMAX_POINT_LIGHTSexactly, so nothing that does not ask for a wider buffer changes. Found by trying to adopt clustering in an application rather than by reading the engine.
2.3.0 · 2026-08-23
- added
Emissive maps, on both backends, which completes the material core. An image says where a surface glows and in what colour,
emissiveScaleis the per-channel factor besideroughnessScaleandmetallicScale, and it is sampled sRGB rather than linear because it is a colour somebody chose rather than data. It modulates the surface's own emission rather than creating it, which is glTF's rule that emitted colour is the factor times the texture: a mesh whoseemissiveattribute is 0 stays dark however bright the image it binds. An import carries the factor for you, since the glTF reader puts it in vertex data already. With no map bound the term is multiplied by one, so every published scene is unchanged by zero pixels on both backends. Measured with a page that binds one: a mapped panel's luminance deviation is 30.8 against 0.0 for an unmapped one, and both backends returned the same figures. - fixed
DrftLoaderpasses on the emissive texture index, which is the fourth and last of the fourMATLhas carried since the container reached stride 72. The other three were bound already; this one had waited on a renderer that could take an emissive map at all, which is a different thing from the normal index being dropped by oversight for a whole minor version. An imported model that describes its own glow now glows. - fixed
The texture-unit budget counts what the lit pass actually binds.
UNITS_WITH_EVERYTHINGwas a literal that omitted the reflection probe for as long as the probe has existed, and then omitted the froxel table too, so the capability map said seven units used and nine free while the widest permutation was binding nine. It is derived from the highest unit now, because a literal cannot notice a unit being added. The four material maps are also contiguous again: emissive took the unit the budget had named for it, and the probe and the froxel table moved up one.
2.2.0 · 2026-08-23
- added
A compute seam on WebGPU.
registerComputetakes a definition,dispatchComputeruns it andunregisterComputereleases it, mirroring the pass seam over the same generational handles, andComputeDevicehands over the raw device the wayPassDevicedoes. A dispatch records into a command encoder of its own and is submitted where it is issued: a compute pass cannot be opened inside a render pass, and this backend opens the frame's render pass late and then keeps it open because closing and reopening it was measured at 92 MB a frame, so a dispatch that closed it would spend that back on every frame that computed. Submitting at the point of issue rather than deferring also keeps the ordering honest in a frame with a mirror in it, which submits three times before it ends. WebGL2 has no compute shaders and never will, sorenderer.computeSupportedanswers false there and a registration is refused in words, once per definition, rather than returning a handle that would silently do nothing. - added
Clustered lighting, on both backends, off by default behind
RenderQuality.clusteredLights. The view frustum is cut into a 16 by 9 by 24 grid of froxels with exponential depth slices, each froxel is told which lights touch it, and a fragment shades against its own froxel's list. A scene can carry 320 point lights where the fixed path shades against sixteen chosen for the whole world, and a fragment still iterates at most sixteen, so the shader's loop bound has not moved. The froxel table is a singleRGBA32UItexture that both backends read the same way: on WebGPU a compute shader fills it, and on WebGL2 the same decision is made on the CPU, measured at 0.411 ms a frame for sixteen lights and 2.589 ms for 320. Two implementations of one decision drift, so a script compares the tables the two produce on a real device rather than trusting them to agree; it found two defects in the CPU binner during development, both of which would have shipped as lights missing on WebGL2 alone. With the option off, every published scene is unchanged by zero pixels on both backends. - changed
A shader feature that lives inside the lit pass is no longer automatically a compile-time permutation. The generated WGSL ships every permutation as text, so each flag doubles it: building clustered lighting as a fifth flag took the generated file from 914 KB to 1,906 KB and cost 196,910 bytes gzipped, a 49% rise carried by every consumer including those who never enabled it. Gzip does not absorb it, because a permutation is larger than deflate's 32 KB window and near-identical copies do not dedupe. The same feature as a branch on a uniform costs 36,838 bytes. The rule now has a second half, recorded with its arithmetic: a feature becomes a permutation only when carrying it compiled-in would cost more than doubling the shader corpus, which is true of the shadow paths and was not true of this.
2.1.0 · 2026-08-23
- fixed
A camera aimed straight up or down keeps a view it can invert. The basis was built by handing a look-at a target one metre ahead, and a look-at crosses the view direction with world up, so it has nothing to work with when the two are parallel. What hid it is that the cosine of a quarter turn is 6.1e-17 rather than zero: the horizontal part of the direction survives only if it survives the addition, and in a
Float32Arraythat depends on where the camera stands, so the same aim is well conditioned at the origin and singular half a metre away. A singular view draws nothing rather than drawing something wrong, every vertex landing on one point, and the failed invert silently leaves the previous aim ininvViewProjectionfor the sky pass to read as its own. The reflection probe is the only thing here that aims at a pole, on two of its six faces, so every probe baked anywhere but the origin held one stale copy of a sideways face where its up and down should have been. The basis is derived from the angles now: right is(cos yaw, 0, sin yaw), which does not depend on pitch and is unit length at both poles, and the matrix is written from the same forward vector the shaders are given so the two cannot disagree. - fixed
On WebGPU, the reflection probe's blur read a uniform buffer that was never uploaded. Its ring is the only one outside the frame's plumbing and it was in neither the flush nor the reset, so the per-face basis was written into staging and stayed there. Every fragment read zeros, which makes the direction a texel stands for the normalize of a zero vector, and the whole chain came back as a single constant on all six faces of every level above zero while level zero was correct. A surface samples the level its roughness picks, so anything polished reflected one flat colour and read as matte paint. The other backend builds its chain with
generateMipmapand was unaffected, which is what made this look like a material fault rather than a missing upload. The ring is reset as well as flushed, because nothing else resets it and a second bake found it full and skipped the chain in silence, and it is sized from the cube's own level count rather than a round number. - fixed
On WebGPU, a planar reflection asked for outside a frame is refused in words instead of with a bare null. The pass records into the frame's command encoder, which does not exist until
beginFrameopens one, so a consumer that mirrored before the frame received null on every frame and read it as the documented "no target could be allocated". The other backend has no encoder and accepts the same call order, so the two disagreed in silence and a wet road lost every reflection it had on one of them.beginFrameclears the mirror's ready flag besides, so an encoder handed over early would not have helped. The order is the contract and a null that means "too early" now says so once. - fixed
A mesh larger than the device's buffer limit is refused with both sizes rather than taking the frame down with it.
maxBufferSizedefaults to 256 MiB where a desktop adapter offers 4 GiB, and a bought model reached 298,273,296 bytes for a single vertex buffer. What comes back past the limit is an invalid buffer, and every command encoder that binds it is invalidated too, so the console fills with repeats of "invalid due to a previous error" from the shadow pass and the frame while the one line naming the cause scrolls away and the model is simply absent. The device is now requested with the adapter's own buffer ceiling, as it already was for sampled textures and samplers, and a mesh that still will not fit names its own size and the device's. - added
environmentReflectionsturns reflections off, besidereflectionProbeSizewhich says how good they are, in the shapewaterandwaterReflectionsalready use. A metal has no diffuse term, so what it shows is its reflection, and the feature costs a cubemap fetch on every reflective pixel plus six submissions of the scene each time a caller bakes. Until now the only way to decline that was to know a probe of zero pixels is how absent is spelled, which is not something a settings screen should have to know. On by default, and with the size at its own default of zero it changes nothing for anybody. Off is a defined picture rather than an absence: the mesh shader keeps the sky-and-ground gradient it carried before probes existed, so a reflective surface still reads as reflective and still varies with the view, andbakeReflectionProbeanswers false so a caller may issue it unconditionally. - fixed
A metal keeps its reflection, its highlight and its range. Several defects compounded into one appearance: the WebGL2 probe was allocated eight bit whatever the scene asked for, so room light above white was clamped on the way into the cube while the other backend kept its range; a reflection was thinned a second time by one minus roughness on top of the mip chain that already is the roughness; the sun's highlight was added into the value the environment blend scales down, so on a metal it was multiplied by nothing; a lamp's diffuse was never weighted by metalness, laying a broad view-independent wash over every metal in every lit room; and the environment gain scaled what every surface reflects rather than only what a metal does, washing dark paint grey. Measured over one chest across gains of 1, 2.6, 8 and 24, the eight-bit cube saturated at 45.4 while a half-float one tracked the room to 141.4.
- fixed
An imported model keeps the reflectivity its own file states, and a texture stored without compression decodes. The loader wrote an override or zero and discarded what the container declared, which is the property deciding whether paint reads as paint. Separately, the baker writes a one-pixel stand-in wherever an image is missing so material ordinals keep meaning what they meant, and those four bytes were handed to a JPEG decoder that could never read them;
imageTypeForandisRawCodecare exported and tested because the fall-through was the defect. - changed
The four modules a consumer's visual gate imports live in the core package, where the specifier
@driftengine/core/scripts/*.mjsresolves.browser.mjs,cdp.mjs,png.mjsandframes.mjswere left at the workspace root when the engine became five packages, so every consumer's capture harness failed at its first import and their visual gates were dead. The root's own entry points are unchanged.
2.0.0 · 2026-08-21
- changed
The engine is five packages rather than one, and core's barrel no longer re-exports four of them.
@driftengine/drftis the container format and declares no runtime dependency at all, so a.drftfile can be read by something that never draws;@driftengine/assetsis the model readers and the streaming loader;@driftengine/audiois the graph, the synthesis and the rhythm analysis;@driftengine/mediais clip encoding, and it takesmp4-muxerwith it, which drops core's runtime dependencies togl-matrixand the format package. The break is the feature rather than a cost of it: a barrel that re-exports audio means importing core pulls audio in, and the split would buy nothing.PORTING.mdlists every symbol that moved and where it went, and every one of them is a compiler error naming the symbol, so the migration is a list rather than a search. What it is worth is measured rather than claimed, gzipped, by a gate that fails when a floor drifts more than three per cent: the format package alone is 2.7 KB, the audio graph adds 4.9 KB to a core bundle and the model readers 9.3 KB. Five packages and not the thirteen the design named, because the import graph was measured first and allows exactly these four extractions today: the rest of the tree is mutually entangled and stays in core.MeshDataandvalidateMeshDatamoved into the format package, which is what lets it stand alone — every import the format code took from the renderer was one of those two — but core re-exports both, so an application taking them from the renderer is unaffected - changed
CHANGELOG.jsonmoved topackages/core/CHANGELOG.json. By package specifier —@driftengine/core/CHANGELOG.json— nothing changed; a consumer reading it by file path has one line to fix
1.4.2 · 2026-08-18
- fixed
WebGPU compiles its pipelines before the first frame instead of inside it.
createRenderPipelinereturns before the driver has compiled anything: it defers the shader to the first draw that needs it. So a consumer that built its meshes and then rendered paid every compile inside one frame, with the whole GPU queue stalled behind it. Measured on a Galaxy S23 Ultra: a single frame of 5.2 seconds, seven submits in a row each reporting the same 5.2 seconds and all draining together, with no allocation, no long task and an idle main thread for the duration. Nothing on the JavaScript side could see it, which is why it survived so long —submitreturns immediately, so every timer wrapped around it read single digits, and the pipeline count and build time both looked trivial because creating a pipeline object is trivial.PipelineCachegainedgetAsync, built oncreateRenderPipelineAsync, andready(), which resolves when everything asked for so far has genuinely compiled;RendererApi.ready()exposes it on both backends and resolves immediately on WebGL2, which links at creation and has nothing outstanding. A consumer awaits it once before its first frame. Correctness does not depend on doing so: a draw that arrives before its pipeline is ready builds it synchronously, exactly as before, so waiting is a performance choice rather than a requirement. The async path falls back to the synchronous one wherecreateRenderPipelineAsyncis missing - changed
A mesh builds a pipeline for the target it is drawn on, not for both. A mesh can land on the frame's own target or on the pass reopened over the canvas after
endFrame, and which meshes those are is the consumer's business, so every mesh used to get a pipeline for each. Where the two disagree on sample count that is two compiles per mesh rather than one, for a target most consumers never draw to: a scene with no overlay meshes at all compiled 20 pipelines and used 10. Every one of them is translated and compiled fresh by Dawn on each page load, with no driver shader cache behind it, so the waste is paid by every visitor rather than amortised. The second target is built on demand now, and a consumer that does draw overlay meshes pays one compile at the first such draw instead of all of them up front. Where the two targets agree, which is the ordinary profile, nothing changes - fixed
WebGPU compiles each shader source once per device instead of once per pipeline that names it. A module was created inside every pipeline descriptor, which reads as free and is not:
createShaderModuleparses and validates the WGSL, and a descriptor is built on everyPipelineCachemiss. The flat shader is both the worst case and the common one, because a mesh gets a pipeline per distinct vertex layout, times a blended twin, times every target it can land on, and each of those parsed the largest source in the engine again. Modules are immutable once created and safe to share between pipelines, so this is a cache rather than a pool, keyed by the source text because the source is what decides what a module is and a label is a debugging string that must never change the object graph. Held in aWeakMapon the device, so a context loss builds a new device that starts empty and can never be handed a module belonging to the dead one - changed
A particle is a camera-facing quad now, and the world-fixed cross is an option rather than the only shape. The cross is two blades at right angles in world space, blade 0 spanning X and blade 1 spanning Z, so a camera looking down either axis sees that blade edge-on. An edge-on blade does not fade as it turns, it compresses, and a sprite squeezed into a one-pixel column spends its whole brightness in that column: alpha-blended smoke half hides it inside its own noise, and an additive spark draws it as a bright straight line through the middle of every particle, scaling and moving with the sprite. Fading the edge-on blade would fix that case and not the matching one, because both blades contain world Y and a camera looking straight down sees both edge-on, so a puff becomes a plus sign of two lines or nothing at all once they are faded. A camera-facing quad has no angle at which it thins, and it also holds a brightness the cross could not: a cross covers one sprite of area seen down an axis and about 1.4 from the diagonal, so a puff quietly pulses as the camera orbits it.
ParticleBatchOptions.facingtakes'cross'for the old geometry, which is still worth having where the camera stays near horizontal and the parallax of two blades reads as volume. The second blade collapses to zero area rather than being dropped, so one pair of vertex and index buffers serves both facings on both backends - changed
SurfaceTexture'sRENDER_ATTACHMENTusage is documented as whatcopyExternalImageToTexturerequires rather than as something the mip chain needs. It said the latter, which is true and is not the reason, and reading it that way leads directly to gating the flag onlevels > 1— which Dawn rejects outright for every unmipped texture. Stated properly so the next reader does not have to break a build to find out
1.4.1 · 2026-08-17
- added
drawFilmcan be rough, and take the aggregate under it. The pass had exactly one finish and it was mirror, which is right for standing water and wrong for what it is mostly used for: a wet road is water lying in aggregate, so the stone underneath scatters the reflection. With no way to say so, a film patch over a textured surface met it at a hard rim, mirror on one side and stone on the other, and that boundary was reported three times before it had a name. Feathering the patch never addressed it, becausebuildFilmPatchalready fades its coverage to zero at the rim and the alpha was not what the eye was reading.FilmOptionstakesroughnessandroughnessCyclesPerMetre, and the cycle count is the same quantitysetSurfaceRelieftakes, which is the point: given the same field at the same scale, the water is broken up by the aggregate the dry surface beside it shows, and the two stop reading as different materials. The reflection is displaced rather than blurred, because a rough surface scatters what it reflects instead of softening it, so a lamp becomes a streak rather than a soft disc, and the displacement is stretched along the view because a bump tilts the ray in the plane it is already travelling in. It is also the only affordable choice: the mirror has no mip chain, so an honest blur would cost taps where this costs none - added
KickDetector.levelis a low-end level a visual can actually follow.energy, the only one there was, is the raw sum of the two lowest bands with a gain on it: honest about what it says and useless on anything mastered loud, because it reaches its ceiling and stays there, so a consumer driving a light or a particle field from it draws a constant. Measured on a bass-heavy track through this detector, it read 1.000 in every frame sampled. The detector already computed the quantity that does move with the kick and threw it away after gating on it, which is the whitened signal: what is left once the bassline, the mud and the low mids are masked out. That is now readable, unscaled, because how far a consumer opens it up is a decision about its own picture rather than about the music.energykeeps its meaning and its arithmetic, and a production consumer reads onlypulse, so nothing existing moves - fixed
The live kick detector read a decibel scale into arithmetic tuned for linear amplitude, which is what let it hit on things that are not kicks.
getByteFrequencyDatamaps the spectrum fromminDecibelstomaxDecibelsonto 0 to 255, a window defaulting to -100 and -30 dB, and the detector treated what came back as a level. Two consequences, both live for as long as it has existed. Everything above -30 dBFS pinned at 255, which on a loud master is most of the low end for most of the track, so the bands it decided from were clipped flat. And a near-silent bin reads about 0.28 on that scale where it reads about 0.0001 as amplitude, so every ratio gate compared two numbers compressed into the same narrow band: a test like punch over bassline is then nearly always true, which is a detector with its safeguards switched off. It reads unclipped float decibels now and converts them back to amplitude, and a band is measured as its own root mean square by Parseval rather than as the mean of its bins, which is a different quantity that varies with the FFT size and sits far below every absolute floor the gates use. Scored against this engine's own offline analyser on a real track, it finds 10 of 12 kicks inside 120 ms, and the level driving a visual runs at a mean of 0.22 where it used to sit on its ceiling in every frame - fixed
The offline analyser and the live detector share one definition of a kick, in
kickCore.ts, rather than two copies of the claim that they agree. Both headers said the same bands, the same whitening and the same gates, because a replay whose cuts disagree with the lights in its own footage is the most confusing possible bug, and the copies had drifted in two ways that reading the constants would not reveal. The whitening was not the same expression:kickBand - maskoffline againstrms * 0.6 + kickBand * 0.6 - masklive. And every envelope was a per-step lerp applied at a 5 ms hop offline and once per rendered frame live, so the same constant was a 47 ms time constant in one and 158 ms in the other, and live it moved with the consumer's frame rate, which meant one track detected differently on a 60 Hz screen and a 144 Hz one. The shared core smooths in time rather than per step, with its rates derived from the hop the constants were tuned at, so the offline analyser is unchanged bit for bit and the live one is now on its terms. Two tests hold what the comments used to assert: the live detector finds the same kicks the offline analyser finds on one rendered track, and the core reaches the same state whether it is advanced at 60 or 240 steps a second - fixed
Procedural relief fades out where a pixel is wider than a bump. It was point-sampled at full amplitude at every distance, so at the 60 cycles a metre a road asks for, a carriageway seen at a grazing angle put tens of bumps inside one fragment and took a single gradient from somewhere among them: the surface read as salt-and-pepper noise rather than as aggregate, worst exactly where a street scene spends most of its pixels. The normal perturbation now fades over a footprint of half a noise cell to one and a half, measured with
fwidthon the world position. The roughness widening deliberately keeps the unfaded amount, because unresolved normal variance is still roughness and a road that faded to smooth at distance would go glossy instead of noisy. The derivative is taken above the varying-dependent branch, which is the ruleshadowFactorin the same file already records
1.4.0 · 2026-08-17
- added
A translucent draw can be unlit, unfogged, or both:
drawTranslucentMesh(mesh, model, opacity, { lit, fog }), with each defaulting to true so every draw written before this is unchanged. The verb's own doc had always said it was lit, fogged and shaded exactly as the world is, which is the right default for glass and water and the wrong one for a glow shell, a wash plate, a flat sign. three.js calls that second thingmeshBasicMaterial, a whole art direction can be built out of it, and there was no way to ask for it here. Implemented as uniform branches inside the shared mesh shader rather than a fifth pipeline permutation, and measured rather than assumed: neither backend shows a cost above harness noise, and no pipeline key changed. Lighting and fog are separate switches because they are separate questions, which the alpha-blended point sprite added in this same release then needed. Proved on a demo page that puts two identical plates side by side under a light that never reaches either face, so the lit one reads at ambient alone and the unlit one reads exactly its authored colour, plus three rows of chips receding into a background painted the fog colour: WebGL2 and WebGPU captures of it are pixel identical outside the strip of text that names the backend - added
'mote', a third particle material beside'spark'and'smoke': unlit, alpha blended, and a soft round point that is nothing but its own colour and its own alpha. Neither existing material could draw one.'spark'is unlit but additive, and its fragment stage folds all of its brightness into colour and always writes alpha 1, because additive blending multiplies by alpha and a real one there would square the energy.'smoke'has a real varying alpha and pays for it with lighting it cannot switch off. Dust caught in the air, drifting glints, the field of points three.js draws withPointsMaterial, needed both at once.ParticleBatchOptions.fogrides with it, defaulting to false and read only by this material: a mote is the one particle a scene legitimately wants both ways, since dust in a shaft of light recedes into haze like anything else out there while a field of them meant to read as flat, close atmosphere must not.'spark'and'smoke'take no such option and stay fogged, matching every particle drawn before this existed. Verified on real hardware with three overlapping clumps in front of a lit wall, where the additive spark blows out past the wall's colour and the two alpha-blended materials never do, plus two identical far clumps one option apart, where the fogged one dissolves into the background and the unfogged one does not. WebGL2 and WebGPU captures differ in zero of 960,000 pixels in the rendered region - added
renderer.setSurfaceTextureRelief(scale)reads relief off the surface texture a draw already binds, as its own luminance taken for a height field.setSurfaceReliefinvents structure from noise, which is right for asphalt and cast concrete because their structure has no particular arrangement; a rock, a bark, a hammered plate has one, its bumps are where the picture says they are, and no noise function can know where that is. This is pass state besidesetSurfaceRelief,setSurfaceGrainandsetSurfaceReflectivity, reset bybindMeshPass, scaled exactly as three.js'sbumpScaleis so a scene porting one carries the number across rather than refitting it by eye, and gated on the same flagsetSurfaceTexture(null)clears, so geometry with no image pays nothing. The colour image serves as the height field rather than a second sampler being added: that is what a consumer handing one photograph tomapandbumpMaptogether already does, and this fragment stage's widest permutation declares one sampler more than a typical adapter offers, so a second image sampler here is not the free addition it would be in a smaller shader. The tangent frame comes from screen-space derivatives rather than a vertex attribute, since mesh data carries no tangents and requiring them would mean rebuilding every consumer's geometry. Proved on a demo page of four panels at 0, 0.13, 1, and 1 with no texture bound, the last of which is indistinguishable from a plain lit plate. Nothing on the existing path moved: the engine's seven demo scenes, which between them cover textures, shadows, an environment probe, water, wind and scatter, were captured on WebGPU at a held frame before and after and differ in 0 of 921,600 pixels each - fixed
On WebGPU, two particle batches of one material drew with each other's settings. The uniform buffers and bind group were cached per material and shared by every batch naming it, which is invisible on WebGL2, where uniform writes and draws run in call order so a shared location always holds what the draw right after it wrote. WebGPU records every draw into one encoder and does not submit it until the frame ends, so every buffer write lands before any of those draws run, and the buffer holds only the last write by the time the GPU reaches the first of them. Any scene with two pools of one material was affected, and a production consumer is one: its tyre smoke, body impact dust and oil spray are three separate
'smoke'pools with three different erosion values, drawn every frame, so on WebGPU all three had been settling on whichever was recorded last. Fixed by giving every batch its own buffers and bind group, matching what its instance buffer already did; only the bind group layout stays shared per material, since a layout describes what a shader binds and never a value. Two regression tests hold the invariant that made the bug possible rather than the symptom it produced - fixed
A renderer running without the composite grades every pass, not only the mesh.
flat.tswas the one shader that appliedoutputTransformitself, so a consumer withscreenEffectsoff got a world that was tone mapped and converted beside particles, SDF text, lines and the sky that were not: those wrote linear values into an eight-bit buffer, which are read back as if they were already display values. That is not a subtle shift.#ff8d72reached the screen as(255, 68, 43), a clipped saturated red where the author asked for a warm salmon, against the(213, 125, 97)the grade gives. The engine's own comment at the mesh pass's upload site had described the state accurately for as long as it existed, which is how it survived: it read as a known limitation rather than as a fault. Found when a site port moved its sparkle field from additive and fogged, where it was too dim to show, to alpha blended and unfogged, where it was the brightest thing in the frame. The grade now lives in one shared chunk,shaders/outputTransform.ts, rather than privately inside the mesh shader, and the particle, SDF text and line stages include it and apply it at the same gate the mesh pass uses: only where that pass is genuinely the last one to touch the frame. No consumer moved, and the reason is worth stating precisely, because a looser one would be wrong. The gate is a composite that keeps range,hdrSceneon, where the resolve grades. One consumer's studio is exactly that and is untouched, which the engine's own seven demo scenes captured on WebGPU before and after at a held frame also show, 0 of 921,600 pixels differing on every one of them. The other is untouched for a different reason:outputTransformdefaults tononeand its shipped configuration never asks for one. What does change is a consumer that asks for a transform, has no composite keeping range, and draws one of these three passes, which is the case this was found in. Blending then happens in display space, which is what three.js does in every one of its own materials. The remaining forward passes, the sky, water, scatter, plumes and the pixel font, still grade only through a composite; they are named here rather than left for the next person to rediscover
1.3.0 · 2026-08-17
- added
Polylines:
createLines,drawLinesanddisposeLineson both backends, so a list of points becomes one stroke with a real width, drawn in a single call, fogged like the rest of the scene and antialiased at any distance. A wide line is not a portable primitive: WebGL2 clampslineWidthto one pixel on nearly every driver and WebGPU offers no line width control at all, so a stroke worth having has to be built from triangles rather than requested from the API.lineBatchexpands each segment into a camera-facing quad throughsegmentQuads.ts, the same corner tableboltBatchalready used for arcs; the two diverge exactly where the thing they draw diverges. An arc is light arriving, so it is additive and unlit; a line is a thing in the world, so it is blended and fogged with a clean edge. Verified on real hardware (radv, RX 9070 XT) with a five-case demo covering three widths of one shape, a line receding into fog, softness compared side by side, a line under a moving and scaling model matrix, and one rewritten every frame from an advancing sine: WebGL2 and WebGPU produce pixel-identical captures, both hold 60 fps and a 16.67 ms mean flat over fifty seconds, and neither backend's console logs a rejected draw. A handle holds one polyline's geometry for the frame it is drawn in. Drawing it more than once a frame is fine when the content does not change between calls, which is exactly what the demo's three widths of one shape do; changing what a handle holds and drawing it again inside the same frame is not fine, and the two backends disagree about it silently rather than loudly. WebGPU's queue write does not interleave with a pass's already-recorded draw commands, so every draw against a handle in a frame reads whichever write landed last by the time the frame submits, rather than the content that was current at each call; WebGL2 draws immediately on each call and happens to give the answer a caller expects. A consumer drawing several polylines whose geometry differs within one frame needs one handle each, not one handle reused - added
A font can carry pre-shaped runs: a glyph-table key longer than one character, baked by
scripts/sdf-font.tsfrom a whole string rather than measured letter by letter. The engine does no shaping of its own, so a script that joins or reorders its letters, such as Arabic, cannot be assembled correctly at draw time out of isolated glyphs; a run arrives already assembled, as one cell, shaped by the platform text stack the generator drives throughmeasureTextandfillText.SdfFont.runsholds the table, and layout takes the longest run matching at the pen before falling back to single glyphs, so a string containing a baked run draws as one quad instead of several. The demo added alongside this draws the shaped Arabic word beside the isolated letters 1.2.0 shipped, so the difference is something to look at rather than take on faith. A run is a decision taken once, at bake time, about a string somebody knew in advance: this is not a step toward the engine doing general text shaping, and is not meant to become one - added
scripts/sdf-font.tstakes--fontmore than once, as a fallback stack in the order given. A face that covers the Latin a project wants may cover none of its Arabic, and the generator used to accept exactly one file, so a project needing both scripts had to pick a single face that happened to carry both, or bake two atlases and switch between them by hand. The repeated flag becomes a CSS font stack the browser resolves per character, during measurement and rasterisation alike, so which face drew which glyph stays reproducible rather than left to whatever the operating system happens to substitute.--runs, a text file of whole strings to bake as pre-shaped runs, rides the same release, alongside the existing glyph list flag - fixed
The SDF font and text style surface is exported from the package entry point.
parseSdfFont,SdfFont,SdfGlyph,SdfTextLayout,DEFAULT_SDF_TEXT_STYLEandSdfTextStylelived undersrc/render/and nowhere insrc/index.ts, so drawing SDF text was reachable the moment 1.2.0 shipped and constructing a font to draw one was not: a consumer had to import two directories past the barrel to reach either, exactly whatdemo/dev/sdf-text.tshad been doing since it was written. The fix is the export list alone; nothing about the types or the verbs themselves changed - fixed
claimPlaybackSessionandaudioContextConstructorare exported from the package entry point, for a consumer whose audio is one decoded file through its own chain rather thanAudioGraph, the stem player they were written for. iOS puts Web Audio in the ambient session, which the hardware ringer switch silences, while an<audio>or<video>element gets the playback session, which the switch does not touch;AudioGraph.createalready claimed the playback session before constructing its context, and until now that ordering was reachable only through the class built for a game soundtrack. Copied into another repository instead, it would have been a second version of an iOS behaviour, and the wrong one would be whichever nobody was reading the next time WebKit moved. The ordering the two functions encode, claim the session before the context exists, is stated in their doc comments rather than left for a second implementation to get wrong;AudioGraphstill calls both itself, so behaviour is unchanged for an existing consumer
1.2.0 · 2026-08-16
- added
Optional SDF text, built from a font file rather than the engine's own five by seven pixel glyphs.
npm run sdf-fontis a dev-only generator: point it at a TTF or OTF and a glyph list and it rasterises a signed-distance atlas plus a metrics document,parseSdfFontreads the document back into anSdfFont, andSdfTextLayoutturns a string and a style into vertex data.createSdfText,setSdfText,drawSdfTextanddisposeSdfTextare the four verbs on both backends;setSdfTexttakes the atlas as a consumer-supplied texture handle, so the engine parses a document and lays out quads but never fetches an image itself. That is the promise this keeps: a consumer that never callscreateSdfTexttriggers no atlas request, so the engine's payload budget and the 5x7 pixel font's licence-free promise (see the AGENTS.md note added alongside this) hold exactly as they did before this shipped. Verified against a real GPU rather than assumed: two atlases (DejaVu Sans, under the Bitstream Vera licence, and Noto Sans Arabic, OFL 1.1) drove a demo scene on both backends, and the first pass through a real texture upload caught a genuine defect, glyphs sampled from the wrong atlas row, because the generator's y-up flip and the renderer's already-unflipped texture upload (shipped 2026-08-14) disagreed about the convention and nobody had compared them until now. Fixed by making the generator emit native texel coordinates, the same conventionatlasLeft/atlasRightalready used, so the whole pipeline has zero inversions in it rather than one waiting to be simplified back into a bug later. Antialiasing, anchor placement (all twelveanchorX/anchorYcombinations checked numerically against marker positions) and kerning were correct from the first pass, because none of them touch the atlas. Arabic renders its three letters correctly and in string order but unshaped and unjoined: this release lays out isolated glyphs and does not compose them into the joined forms a word takes, a stated gap rather than a silent one - added
Ray picking:
registerPickable,updatePickable,unregisterPickableandpickAton both backends, answering what is under a pixel without dispatching an event for it.PickableSetis backend-agnostic, the same splitmath/intersect.tsalready used for ray/AABB and now extends to ray/triangle: a broad-phase box per entry rejects nearly everything for six compares, and only a survivor pays for its triangles, so a scene of forty pickables does not walk every triangle of all of them on every pointer move. The box is recomputed when a model matrix changes rather than when a ray is cast, because a scene moves a handful of things a frame and casts one ray per pointer move, so the work belongs on the write.pickAtreads a CSS pixel throughcamera.rayThrough, added alongsidecamera.projectin this same release, and returns the nearest hit's handle, distance and world-space point, or null. Verified against a real GPU with two overlapping quads and one separate: hovering the overlap always resolves to the nearer quad, hovering either quad alone picks only that one, and a miss returns null, on both backends, identically. The returned distance is invariant under a scaled model matrix:localOrigin + t*localDirectionuses the same inverse for the origin and the direction, sotdoes not need the extra division an early draft of this work wrongly assumed it did, and would have doubled every scaled hit's distance had it shipped - added
camera.project(out, x, y, z, cssWidth, cssHeight)turns a world point into CSS pixels, for attaching a DOM element to a place in the world rather than reprojecting it by hand in every consumer that wants one. It returns false rather than a coordinate for a point behind the camera (w <= 1e-6), because a caller that mirrored the point across the screen instead would place a label exactly opposite where its subject actually is. Y is reported downward, matching CSS rather than NDC, so a caller hands the result straight tostyle.left/style.topwith no sign flip of its own. Ships besidecamera.rayThrough(origin, direction, cssX, cssY, cssWidth, cssHeight), the operation ray picking above is built on: an unprojected ray through a pixel. A round-trip test, project a point, cast a ray through the result, confirm the ray passes back through the original point, is what actually catches a y-flip, and is the one both methods were written to pass - added
Renderer.presentedFrames, a monotonic count of frames a renderer has actually presented, requested by a consumer (ENGINE-NOTES.md§1) so an offline export can tell a new picture from a repeat rather than composite a frame nobody drew, at the wrong timestamp, as a copy of the one before it. It increments where a frame is genuinely shown rather than where a method happens to be called: WebGPU counts atswapView'sgetCurrentTextureacquisition, and WebGL2, which has no swap chain to acquire, counts at the one pointendFrameis guaranteed to reach only when the frame it opened is what the browser is about to show. A frame that dies betweenbeginFrameandendFrame, or a surface lost mid-frame, does not advance it.ExportTargetandFrameRecorderconsume it internally through an optional callback and a pureisDuplicateFramecomparison that is unit-testable without a DOM; a caller that never wires the callback sees no change in behaviour at all - added
renderer.setFrameVeil(r, g, b, alpha)composites one flat colour over the finished frame, requested by a consumer for the dip to white or black across a cut, which it had been painting outside the engine, a DOM element over the preview canvas, a fillRect in the export composite, for lack of anywhere to ask the renderer for it directly. The veil composites after the tone map and before grain and vignette, so a dip takes the whole picture down with it instead of sitting on top of it like a sheet, and white and black land exact rather than as exposure asymptotes: driving exposure up underacesblooms and tints rather than clipping, which is why this could not simply reusesetOutputExposure. Bloom is resolved upstream of this pass, so a veil composited here cannot feed it, by construction rather than by a guard that could be removed later. Alpha 0 is the default and costs nothing beyond a single shader comparison that hands the pixel back unmixed, and the veil clears itself at the end of everyendFrame, unlikesetSpeedRushandsetCameraMotionBlurbeside it, because a forgotten veil is a frame stuck opaque, and that is a worse failure than a transition that has to ask again every frame
1.1.2 · 2026-08-16
- changed
discardResolvedAttachmentsanddeferFramePassdefault on again. They were turned off in 1.1.0 because all three consumers had gone black on an iPhone and these two were the only changes in the engine whose correctness an immediate-mode desktop GPU cannot express, which made them the reasonable suspects. They were not the cause, and turning them off never lifted the black screen: it was a uniform array striding by four bytes where WGSL requires sixteen, fixed in 1.1.1. What the retreat cost is measurable, on the gilded chamber at a phone viewport withnpm run frame-audit: 75.6 MB a frame against 49.7, which is 4.43 GB/s against 2.91 at 60 fps, and thirteen passes against twelve. Nearly all of it is the deferral, worth 24.4 MB on its own, because opening the frame's pass eagerly forces a load and a store of the attachment for every mirror the scene draws; the discard adds the last 1.5 MB and takes the frame's unreadable writes to zero. That is the whole mobile bandwidth result of 1.1.0, and with the defaults off it was being given up on exactly the devices it was measured for. Neither is verified on a tile-based GPU and that has not changed, so?discard=0and?defer=0still turn them off one at a time in all three consumers. What changed is that there is no longer an observed failure to attribute to them.gpuTimingstays off, for its own reason: it attaches a timestamp block to every render pass, which is not a cost to hand somebody who did not ask for a measurement
1.1.1 · 2026-08-16
- fixed
Every shader that draws geometry compiles on iOS, which none of them did. WGSL requires a sixteen-byte element stride for an array in the uniform address space, and the toolchain emitted
array<f32, 10>andarray<i32, 10>, which stride by four. Dawn accepts that: all sixteenflatpermutations compile with the validation scope clean and not one warning, measured on a real adapter. So it rendered on every desktop browser and on no iPhone, where WebKit enforces the rule and answeredarrays in the uniform address space must have a stride multiple of 16 bytes, but has a stride of 4 bytes.flatis the mesh shader and no permutation of it survived, so every geometry pipeline in the frame was invalid,setPipelineinvalidated the pass and the command buffer was dropped whole. The sky went with it. What a person saw was a completely black view on all three consumers, in both iOS browsers, that no graphics setting recovered, while the engine's own caption readwebgpu · 60 fps · 13 drawsover it, because from the API's point of view nothing had failed.waterandparticlecarried the same arrays. The cause is that WGSL removed@stride, so naga cannot express SPIR-V'sArrayStride 16and drops it, andscripts/wgsl/layout.mjshad then been changed to pack the CPU side to match, on a measurement of Dawn that was sound and one browser wide. The generator now widens a narrow uniform array to four components before it reaches a block and reads it back through a component, so naga emits a conformant stride by construction andblockLayoutcomputes the same sixteen without being told. The two sides cannot disagree again, because only one of them chooses. The GLSL is untouched, so the WebGL2 path keeps its scalar arrays rather than paying four times the fragment uniform vectors on the devices with the fewest. It shipped in 1.0.0 and nobody had opened the page on a phone
1.1.0 · 2026-08-16
- added
The WebGPU backend can say what a frame cost.
gpuTimerthere was a stub reportingavailable: falseforever, so every GPU figure this project has ever quoted came from WebGL2 — the backend a phone does not use — and the consumer's own rule that a GPU verdict needsgpuSamples > 0could never be satisfied on the one that ships.GpuTimestampsimplements the sameFrameTimerinterfaceGpuTimerdoes, so a consumer already calling beginFrame/begin/end/endFrame/poll starts getting numbers with nothing changed on its side. The one shape difference is forced by the API: WebGL2 brackets an arbitrary range of commands with TIME_ELAPSED_EXT and WebGPU cannot, so a timestamp pair rides each render pass descriptor and the readback sums the pass deltas per slot — which is why every per-frame pass now carries one, a pass without it being GPU time the total would silently omit. The feature is requested only where the adapter offers it, because arequiredFeaturesnaming something an adapter lacks rejects the device outright rather than clamping. Held against WebGL2 on the gilded chamber at 900x700 dpr 2, same machine: 6.72-7.04 ms against 6.15-6.51. Two unrelated timing mechanisms agreeing within 10% is what says the number is real - fixed
A resolved multisample attachment is discarded rather than written back to memory, on both backends. Not one
storeOp: 'discard'existed in the WebGPU backend and noinvalidateFramebufferexisted anywhere, so every multisampled colour target was resolved and then also written out in full — andpost.sceneColorMsaa,reflection.colorMsaaandprobe.colorMsaaare created with RENDER_ATTACHMENT and no TEXTURE_BINDING, so no shader can read one.reflection.depthandoverlay.depthwere stored on the same terms and are sampled by nothing. Measured on the consumer at a phone viewport (824x1830, four samples): 388.7 MB of attachment traffic a frame, of which five multisample stores at 23 MB each bought nothing. A desktop GPU absorbs that and a tile-based mobile GPU cannot, which is why it survived a release. Only a terminal pass may discard, and that cannot be settled by looking at the picture: the frame's own attachment is reopened withloadOp: 'load'for the mirror, the light volume and text, and after a discard those loads read undefined contents. With every attachment discarding, all seven scenes still came back inside the shot harness's own 130-pixel readout noise on this desktop part — which is the trap rather than permission, because an immediate-mode GPU has nowhere else to put the samples and a tiler really drops them. So the reopens are what hold the frame's attachment atstore, and removing them is the next thing worth doing - fixed
The live point-shadow cubemaps are budgeted like every other bake.
pointShadowFacesPerFramewas spent entirely on the static maps, and the loop over the live maps underneath it then asked for a whole cubemap, per map, every frame, with no budget and no staleness test — measured aspointShadow.facesix times a frame in the consumer and in three of the engine's own seven demos, in frames that were otherwise nine to fourteen passes. There are two live maps and the second wakes during an ownership handoff, which is exactly what running past a row of lamps is, so a courtyard paid twelve full passes over the dynamic casters every frame on top of everything else it draws. Unlike a static map there is nothing to test — the caster moved, that is what makes it live — so what was missing is a ceiling, and the faces now round-robin under one: a mover's shadow finishes over two or three frames instead of inside one. Measured on the night court at two faces a frame,pointShadow.cubefell from six a frame to two and the frame from twelve passes to eight. Both backends, because the scheduler is shared - added
RenderQuality.liveShadowFacesPerFrame, the live half ofpointShadowFacesPerFrame. It defaults to 12 rather than 6 and the difference is worth knowing: the budget is shared across the live maps rather than granted to each, so at 6 the first map takes the whole cubemap and the crossfade's second map never bakes at all, which is a mover's shadow vanishing at precisely the moment a character crosses between two lamps. 12 is two whole cubemaps, which is what the loop already took, so nothing an existing consumer draws moves. Lower it on a device short of bandwidth and a fast character's shadow trails slightly; the engine's own harness reaches it with?livefaces= - fixed
The frame's own attachment is opened once, instead of being closed and read back for every mirror.
beginFrameopened the render pass eagerly, so a scene drawing a planar reflection had to end it, submit, and reopen it withloadOp: 'load'— a full read of the attachment back into tile memory and a full write at the end of the resumed pass, once per mirror, and the consumer has two of them.ensurePassopens it on the first draw that wants it instead, which for such a scene is after the last mirror, so it is opened once and cleared once.frameNeedsClearkeeps the distinction that matters: a scene that had already drawn before opening a mirror still gets aload, and one that opened the mirror first gets its singleclearlate. Measured on the gilded chamber at 900x700 dpr 2 with four samples: attachment traffic from 436.5 MB a frame to 281.0, a 36% cut, withload:post.sceneColorMsaaandload:flat.deptheach halved and one fewer multisample store. An empty frame still clears, becauseendFrameopens the pass rather than returning early on a null one — otherwise a frame nobody drew into would present whatever the swap chain last held. Gated against a same-build floor of 125 pixels: worst scene 132, and the two mirror-and-volume scenes at zero - added
RenderQuality.discardResolvedAttachmentsandRenderQuality.deferFramePass, both true by default and both there to be turned off from a phone. They gate the two bandwidth changes in this engine that cannot be verified anywhere but on a tile-based GPU: throwing away a multisampled attachment once it has resolved, and opening the frame's pass on the first draw rather than inbeginFrame. On an immediate-mode desktop part a discard costs nothing and changes nothing, because the samples have nowhere else to be, so a screenshot gate cannot tell a correct one from a wrong one — and the failure either could cause is a whole frame that does not present rather than a pixel that is slightly off. A device that flashes black is bisected with?discard=0and then?defer=0, one reload each, and whichever stops it names the cause. Held on the gilded chamber at four samples: 281.0 MB a frame with both on, 358.8 with the discards off, 436.5 with the deferral off — the last being exactly the figure from before either landed, so the switch restores the old behaviour rather than approximating it. The same switch reaches WebGL2, where the discard isinvalidateFramebufferafter the resolve blit - fixed
The capability clamp fires on WebGPU. It could not before:
WebGPURenderer.rendererNamewas the literal stringWebGPU, soisWeakGpuFamilyhad nothing to match,capabilityClampedwas a hardcodedfalse, and a consumer passingcapabilityClamp: truegot silence — on the backend that had replaced the one where that clamp rescued an Adreno 619 from 156 ms a frame.selectBackendnow builds a name fromadapter.infoand hands it back onBackendChoice.rendererName, and the renderer applies the same two pixel termsrenderer.tsapplies, from the same table - changed
isWeakGpuFamilyrecognises WebGPU's architecture buckets as well as WebGL2's part numbers, and the difference between them is worth knowing.UNMASKED_RENDERER_WEBGLgivesANGLE (Qualcomm, Adreno (TM) 619, OpenGL ES 3.2);GPUAdapterInfodeliberately does not, reporting a vendor and an architecture bucket to reduce fingerprinting entropy — measured here, an RX 9070 XT saysvendor: "amd", architecture: "rdna-4"and nothing else at all. Soadreno-5xx,adreno-6xxandmali-g[35]xare matched, andadreno-7xxdeliberately is not: that bucket holds both the 710 measured at 112 ms a frame and the 740 in a current flagship, and softening every flagship to catch one mid-range part is the wrong trade when the governor corrects a wrong guess from measurement anyway. The bucket spellings are inferred from the specification rather than read off a device this project owns, which is why the consumer now prints the part and whether it was clamped in its boot log: one message from a real phone settles it - fixed
A frame that fails no longer blanks the screen.
getCurrentTextureis an acquisition rather than a read — whatever is in the texture it returns is what the browser presents at the end of the task, drawn into or not — andbeginFrametook it unconditionally. So every path that acquired and then failed to submit presented an untouched texture: a lost surface, a null encoder, a composite target that had gone, or an exception in the consumer's own frame code betweenbeginFrameandendFrame. That is a black frame, indistinguishable from a rendering fault while being nothing of the kind, and it was reported from a phone as intermittent flashing. The swap chain is taken on first use instead, which under the ordinary profile is the composite at the very end of the frame — so a frame that dies before then never takes it, the browser has nothing new to present, and the last good frame stays on screen. A dropped frame reads as a dropped frame. Sizing comes from the canvas, which cannot disagree becauseconfiguresizes the swap chain from it - added
demoQualityForandreadDemoDeviceHints, so a demo host can scale a scene to the device without taking anything out of it. Every scene mounted atfullon every device, andfullis the engine's defaults plussceneSamples: 4— four times the colour and depth bandwidth of a frame that was already the constraint on a handset, reported from a Galaxy S23 Ultra at 15 to 20 fps. The obvious answer was theleanbudget and it is the wrong one for a page with no settings screen:leanswitches off directional shadows, water reflections andscreenEffects, and with the composite gone so are bloom, occlusion, motion blur and the speed rush. A game can afford that because a player can put it back; a demos page would simply be showing a phone reader an engine with several of its passes missing. So nothing is switched off. What a handheld gets is dials — density 1.5, a 1.6 megapixel ceiling, one sample rather than four, half-scale reflections, 1024px directional maps at one depth layer, 256px cube faces, four filter taps — and every pass a desktop reader sees it still draws. Measured on the gilded chamber at a phone viewport: 8.09 MP and 809.6 MB a frame becomes 1.60 MP and 49.7 MB, a sixteenfold cut, with the reflection, the light volume, the shadows and the composite all still in the frame. Desktop is untouched, held at 96 pixels against a 101 pixel floor. The device is recognised by its panel and its input rather than by the GPU-name table, which is exactly what missed this one - changed
discardResolvedAttachmentsanddeferFramePassnow default to false, and the swap chain is acquired eagerly again unlessdeferFramePassasks otherwise. All three consumers came back completely black on an iPhone 16 Pro under Chrome — which on iOS is WKWebView, so WebKit's WebGPU implementation on an Apple tile-based GPU. These two are precisely the changes whose correctness an immediate-mode desktop GPU cannot express: a discarded multisample attachment costs nothing and changes nothing where the samples have nowhere else to be, and taking the swap chain late is indistinguishable from taking it early until a browser disagrees about when a frame is presented. Both passed every gate this repository has, which is the point rather than the excuse — it is the failure mode both were documented as carrying. A black screen outweighs any bandwidth figure, so they are off until a real device says otherwise. Everything else from this release is device-independent and unchanged: the live-cubemap budget, the capability clamp, the timestamp queries and the WebGL2invalidateFramebuffer(which is gated on multisampling and so never ran on a phone). Both switches are two-way from the address bar in all three consumers —?discard=1,?defer=1— so whichever brings the black back names the cause - fixed
A lost GPU device is no longer silent. This is the defect behind a black screen that survived reloads and setting changes on iOS, and it is worse than whatever caused the loss: every entry point on the WebGPU renderer guards on
surface.lostand returns, so a host's frame loop went on calling forty of them at full rate and produced a black canvas with nothing thrown, nothing logged and nothing to catch. A consumer not already listening toonContextLosthad no way to tell that apart from a rendering bug — and two of the three here were not listening.beginFramenow says so once, naming the recovery, andDemoHandle.lostlets a demo host see it without reaching past the handle. WebGPU cannot restore a device, only replace one:createRendereron the same canvas acquires a fresh adapter and device and reconfigures the context, so it has always been the recovery — nothing was calling it - fixed
Nothing may end the planar reflection's pass while the mirror is being drawn.
takeVolumeDepthandopenTextPassboth end whatever pass is current and reopen on the composite target, and neither checked whether the current pass was the mirror's. Called during a reflection they therefore ended it, threw away what the mirror had drawn, and sent everything drawn afterwards into the frame instead — an empty mirror and a frame with the mirror's geometry in it. On an immediate-mode GPU the first half is invisible, because a discarded attachment has nowhere else to be and keeps its contents anyway; on a tile-based GPU it is exactly whatstoreOp: 'discard'promises, so the fault appears on a phone and nowhere else.drawFilmalready guarded this boundary with!reflectionPassActiveand the asymmetry is what let it survive. A volume inside the mirror now draws unclamped, which is right on its own terms —resolvedDepthViewholds the frame's depth, and clamping a mirrored beam against it would stop it at geometry that is not in front of it - changed
GPU timing is opt-in through
RenderQuality.gpuTiming, and off by default. A diagnostic must never be able to break the thing it observes, and this one could. Enabling it attaches atimestampWritesblock to every render pass in the frame — twelve of them — so an implementation that disagrees about any part of it invalidates the whole command buffer, and an invalid command buffer draws nothing at all. It shipped on by default, unconditional and ungated, having only ever run against one WebGPU implementation. Thetimestamp-queryfeature is now requested only when a consumer asks for the timer, because a requested feature is a feature the device carries. Turn it on while measuring:?gputiming=1in the harness and in both consuming apps
1.0.2 · 2026-08-16
- fixed
Clips encode at 1440 and 4K. ClipEncoder wrote both of its codec strings by hand at level 4.0, and an H.264 level is a promise about frame size: 4.0 holds 8192 macroblocks, which is 1920x1080 with 32 to spare and nothing above it. So VideoEncoder.isConfigSupported refused 2560x1440, 2160x2160 and 3840x2160 outright, configure was never reached at those sizes, and a 4K export ended in "this browser cannot encode video at that size" on a card that encodes 4K perfectly well. The level is now derived from the frame rather than written down: the smallest one whose MaxFS can represent it, High profile first and constrained baseline as the fallback, both rungs at the same level so the fallback is a second chance rather than a second refusal. Measured on Chrome 149 and a Radeon RX 9070 XT across 9 sizes, 2 rates and 7 levels: every level at or above the one whose MaxFS holds the frame answers yes and every level below it answers no, in all 252 cells, with profile and frame rate moving nothing. The files were read back with ffprobe rather than assumed: 3840x2160 at 120 fps arrives as High, level 52, carrying pictures.
- added
Two capability calls beside offlineEncodingSupported, for the two questions a consumer has to answer before it offers somebody an export. clipEncodingSupported({ width, height, fps, bitrate }) says whether this browser will encode a clip that size, by walking the same ladder ClipEncoder.open configures from, so a panel greying out a control and a button starting an export ask one question instead of two copies of it. It is memoised per request, because the answer is a property of the machine: a consumer asking about nine sizes at two rates pays for eighteen probes once and nothing afterwards. framesReachEncoder() answers whether the frames arrive at all. It paints 64 by 64 of magenta, encodes one key frame, decodes it back and reads the colour, memoised for the session. That one exists because a browser can report success at every step and encode nothing: Chrome 149 with chrome://flags#enable-vulkan on, under Wayland, hands the encoder an empty VideoFrame built from a canvas while VideoEncoder.error never fires, encodeQueueSize drains, flush resolves and the muxer finalises. Reproduced from this repository's own capture harness: twelve frames of 1920x1080 encoded to 114,157 bytes with the flags off and 8,747 bytes with them on, and ffprobe read every frame of the second file back as all-zero YUV, which is solid green once a decoder applies the limited range. Only a positive reading of an empty frame refuses anything, so a probe that cannot answer costs nobody a clip.
1.0.1 · 2026-08-15
- fixed
A mesh handed to drawTranslucentMesh at an opacity of 1 blends again on WebGPU. The alpha a fragment leaves with is the draw's opacity multiplied by the bound texture's own, so a caller passing 1 is not saying the surface is solid, it is saying the shape lives in the image: a caption, a decal, a painted shadow, a glow. That backend picked its pipeline from the opacity alone, took the unblended one, and wrote every texel the alpha cutout had kept at full strength, so the soft edge of a blurred shadow arrived as a solid slab bounded by the cutout threshold. In the product it read as a hard black outline around every letter of a caption whose shadow was soft on WebGL2 in the same frame. Nothing among this engine's own scenes draws a textured quad that way, which is how eight of them agreed to within a fifth of a percent while the first consumer to try it did not. WebGL2 was right here from the start: it turns blending on when the call is made and off when it returns, without ever reading the number.
1.0.0 · 2026-08-14
- added
A second backend. The engine draws through WebGPU where a browser offers it and through WebGL2 everywhere else, behind one surface that both implement, and nothing an application already does changes. createRenderer is the way in: it asks for an adapter, which can only be done asynchronously, and hands back the renderer together with which backend actually drew and why. Report that rather than inferring it from the address bar, because a browser without WebGPU, a device request that failed and a misspelt query all fall back silently and draw a frame that looks entirely reasonable. Falling back is the normal case on a great deal of hardware and needs no handling; WebGL2 remains a first-class path rather than a legacy branch, and no player should ever be asked to set a browser flag. new Renderer keeps working untouched and is not deprecated, so an application whose renderer is built inside a constructor can adopt the rest of this release without rearranging its startup.
- changed
Text, surface textures, plumes and particles are opaque handles the renderer draws, rather than objects that draw themselves. Each of them used to be a class holding a WebGL2 context, which pinned every caller of one to a single backend however careful the rest of the code was; the engine's own showroom scene could not run on WebGPU for exactly this reason. The verbs move to the renderer and the types become handles, and the compiler finds every site. The trap worth knowing before starting is that a consumer holding the concrete Renderer class is still pinned by its own annotations, and the errors that produces read as a half-finished migration rather than as what they are: both consumers had far more annotated sites than they had errors, because an annotation only fails where a handle actually reaches it. Change the renderer's type first and most of the rest goes with it.
- changed
Plumes and particles take a named material instead of a fragment shader. A caller-supplied shader string is the one capability that cannot be honoured on both backends, because WebGPU has no runtime GLSL compiler and this package must not ship one, so naming the material lets each backend resolve what it can actually compile. There is deliberately no drop-in replacement for your own GLSL: say which effect you had rather than which shader. The per-batch label goes with it, since a batch is named by its material now. A plume is fire, smoke or arcane; a particle is smoke or spark.
- added
An arcane plume material: a radial counter-rotating swirl rather than a rising column, so it reads as a spell containing a thing instead of a fire underneath it. It is the only plume that reads tint, which is what lets an aura take its scene's own glow colour, and it is drawn additively for the same reason a flame is. It exists because the move to named materials would otherwise have taken an effect away from a consumer that was already drawing it.
- fixed
Textures uploaded from a canvas are no longer flipped vertically, so a canvas and an ImageBitmap finally arrive the same way up. The upload set the pixel store flip for every source, and WebGL ignores that flag for an ImageBitmap because a bitmap carries its own orientation — so the two source types the same method accepts arrived opposite to each other, and had done for as long as the method existed. Nothing caught it because every canvas-sourced texture here was symmetric under a vertical flip: eroded noise, a radial halo, a sleeve blurred down to twenty-four pixels and back. It was found by painting lettering, where a title and an artist came out upside down beside a sleeve that did not. Bitmaps are deliberately the ones left alone, since making them match instead would have turned over every painted surface on every loaded model. If you paint to a canvas and upload it, it is now the right way up, and any code mirroring its own canvas to cancel this has to stop.
- fixed
A planar reflection is no longer dropped in full on WebGPU when the scene draws a wet film. The film samples the mirror, and a scene that draws itself twice draws the film into the mirror as well; the pass already knew not to read a half-drawn reflection and switched the lookup off, but its bind group went on holding the texture, and WebGPU validates the binding rather than the read. The result was not a wrong reflection, it was no reflection: the whole command buffer was invalidated at the end of the pass, every frame, with nothing to show for it but a console line. The binding moves now as well as the flag. The same shape of fault was fixed for the environment probe before this release, and the rule is the same both times — turning a uniform off is not enough on its own, because a driver may fetch a sampler's descriptor before it evaluates the arithmetic that discards the result.
- fixed
Two handle types that could be received and not named. Anything created through the shared surface hands back a handle, and CausticsHandle and SurfaceTextureHandle were never exported, so a consumer could be given one by createCaustics or createSurfaceTexture and had no way to declare the field it kept it in. Particle program sharing had the same shape from the other side: reuse named the concrete WebGL2 batch class, so an application that had correctly moved off that class could not pass back the handle the engine had just given it. It takes a handle now, and the WebGPU path ignores it legitimately, because its pipelines are already shared by material and blend.
0.35.0 · 2026-08-12
- changed
Light in the air is integrated along the view ray instead of being drawn on flat sheets, and a mesh built for the old pass has to be rebuilt. The pass used to rasterise a few panes through the volume's axis and weight each fragment by how far from face-on its pane was turned, which stands in for the distance a line of sight travels through the air that pane represents. It works from the side and it cannot work down the barrel, because every pane contains the axis: a view lying near that axis lies nearly within all of them at once, each one collapses to a narrow bright wedge on screen, and their union reads as a six-armed asterisk rather than as the round cross-section a cone actually has. It was reported twice from outside, once as a beam whose shape never changed however far its strength and spread were swept and once as raw lines across the opening of a room, and no correction to the weight could have answered either, because the fault was that a handful of flat sheets is not a volume. The geometry now only decides which pixels run and every one of them walks its own line of sight, so the path length falls out of the walk. Two things ride that walk and are off unless asked for: dust, which breaks the body up with a noise field carried on an offset the caller supplies rather than a clock, so a frame rendered twice is the same frame; and a sun shadow, which reads the directional map the surfaces below are already shaded by, so a shaft carries the bars of the window it came through instead of being smooth above a barred floor. Callers of buildLightVolume are already correct and get a hull; anything that hand-rolled its own panes lights only the sliver of screen those panes cover.
- added
Dust and sun shadow on a volume of light, and a sample count to pay for them with. Dust is an amount, a cell size and a drift offset: a shaft of sun through a window is not smooth, because what is lit is the motes in it and those are uneven, and taking the drift as a position rather than a rate keeps the pass free of any clock and lets an application that renders instants out of order ask for the same one twice. Sun shadow is an amount and the environment the directional map lives on, evaluated at every step of the march, which is the difference between a beam and light that has been somewhere: a shaft crossing tracery arrives barred. Both default to nothing, because a torch beam wants neither. The sample count joins the construction-time render quality options at thirty-two, which is clean at the size a volume usually occupies; it is the only dial the pass has and it costs nothing in a frame that draws no volume.
- fixed
A volume of light no longer breaks into blobs when it is watched from the side, which is how a beam is almost always watched. The march was clipped only to the slab the volume occupies along its own axis, and side-on a ray crosses that slab in a single step, so the clip did nothing and the samples spread across a fallback span while the beam itself was a few metres thick. A ninety-metre beam got a handful of samples inside it and integrated empty air for the rest. It is now also clipped to the cylinder enclosing the volume's widest point, which is one quadratic, always bounded, and conservative, so no light can be clipped away by it. Two other faults in the same walk went with it: the per-pixel offset that stops a fixed start quantising the volume into shells was being applied to the sample positions, where it slides a smooth integral along its own gradient instead of averaging out, and it now moves only the shadow lookup, which is the one term with a hard edge in it; and that offset is an ordered pattern rather than a random one, so any four-by-four block of pixels covers the step evenly however a shadow edge falls across it.
0.34.0 · 2026-08-12
- fixed
Every sky with its sun below the horizon carried a sunset, whatever colour that sun was. The warm band low in the sky is driven by the sun's elevation, which is right, but it was driven by one edge that saturated a few degrees under the horizon and never came back down, so a sun pointing straight at the ground read as maximum sunset rather than as no sun at all. That direction is exactly what a night world writes to say there is nothing up there. The tint is also a fixed ember rather than the sun's own colour, so setting the sun black could not turn it off and nothing a caller had access to could. A consumer measured it on a black sun and found 87 percent more red in the sky than in the same frame with the sun pointing up, with the world's own blue-grey horizon arriving red-highest, an ordering no exposure curve can produce; four of their night worlds were never started because of it, and this engine's own storm at sea shipped a pink horizon under a black sun for eight versions. The term now has a second edge and fades out as the sun goes deep, ending near where astronomical twilight does. Dusk is barely touched, a low sun is untouched, and a sun below the horizon fades to nothing over the same range the real sky does.
- changed
The sunset band stops at the horizon instead of being mirrored below it. Its height was measured as a distance from the horizon in either direction, which put a second copy of the glow twenty-seven degrees down into the deep, where there is no sky to scatter anything. It showed in any world whose ground does not fill the lower half of the frame and in the downward face of every environment probe, where it tinted reflections warm. The glow now falls three times faster below the horizon than above it, which leaves every pixel at or above the horizon exactly as it was and keeps the horizon line itself unbroken; gating it the way the sun disc and the moon are gated would have cut the band's own core, since that gate is only partly open at the height where this term is brightest.
- added
A builder for the panes a volume of light is drawn on, given the same reach and aperture the draw call is given. Light in the air is drawn on a few flat panes crossing its axis, and the shader fades it to nothing at a length and an aperture it is told about; both of those are read in the geometry's own space, so a caller building the panes by hand states each number twice in two files and nothing checks that the copies agree. Both failures are silent. Panes narrower than the aperture leave the fade still climbing when the polygon ends, which draws a hard-edged wedge, and a length longer than the geometry puts the whole mesh inside the first fraction of its own falloff, which draws nothing at all with no error and no warning. A consumer outside this repository shipped both, in two worlds, after building a beam as a unit shape and placing it with a matrix that carried no scale. Building the volume from the same numbers the draw call receives removes the disagreement rather than documenting it, and the method itself now states all three traps, including that its strength is clamped at one, which is the first thing a caller raises when a beam looks faint and the one thing that cannot help.
0.33.0 · 2026-08-12
- added
A sphere the engine generates can now wear an image that was drawn rather than interpolated. The primitive that makes round things walks a pair of surface coordinates to place every one of its vertices and used to throw them away, so the only way to vary colour over a curved surface was to vary it per vertex, and the sharpest edge that can draw is one cell wide however good the callback is. That was reported twice as a planet looking soft and undefined, and both rounds of work went at the wrong half of it: measured on the picture, the median edge in the frame was four pixels and one cell of the mesh was four and a half, which agree closely enough to say the mesh is the limit and nothing done inside the callback could beat it. Keeping the coordinates costs nothing to produce and lets a coastline be one texel wide instead. They run around from zero and pole to pole, the last column repeats the first so a wrapped image meets itself cleanly, and the box projection that already existed is no substitute on anything round, being three projections meeting at seams with the poles smeared along an axis. Geometry that has nothing to map still carries nothing, exactly as before; a shape that does pays two floats a vertex whether or not an image is ever bound to it.
- added
Light that only shows where the sun does not reach, for the lamps on the dark side of anything. Where the lights are is a property of a surface, so it is authored into the surface; whether they should be visible is a property of where the sun is, which a turning body carries its surface past, and nothing multiplied the two together. A consumer built city lights on a rotating planet, photographed them and then deleted them, because with that world's lighting a patch bright enough to read at night arrived sixteen times too bright by day as a blot on a continent, and that ratio is fixed by the lighting rather than by the value chosen. It takes two settings rather than one and the reason is worth stating: how much is a number a frame supplies, and whether the term exists at all is decided when the renderer is built, because the term is not free when it is switched off. Its arithmetic names two values the ordinary emissive term also names, and giving those a second reader is enough to let a driver re-plan the work they share, which moved a hundred and nine pixels of seven hundred and fifty thousand on one scene with the amount at zero. So it is cut out of the shader entirely unless it is asked for, and a renderer that does not ask is identical to one built before it existed, on six of seven scenes measured at not one pixel.
0.32.0 · 2026-08-12
- added
The capture tools can now drive a page they were not built into. A clock can be injected before a page's own scripts run, so an application does not have to grow a switch that freezes it and then carry that switch into production; a wait can be measured in drawn frames rather than in milliseconds, which is what a picture actually needs, since a setting reaches a pixel on the frame after the one that set it and a fixed sleep is either dead time or a race depending on how quick the card is; and the command that walks a set of pages and compares two runs of them takes its own list of addresses and its own idea of when a page is ready. Two measurements shaped the clock and both are worth knowing for anything that loads asynchronously. Freezing from the moment the page opens left two runs of one unchanged build differing in twelve thousand pixels of nine hundred thousand, because the clock keeps counting while a worker and a sound are still being decoded and there is a different number of those moments every time; waiting for the application to exist first, and then counting drawn frames rather than turns of the queue, brought that to three hundred and sixty nine.
- fixed
A model's load now reports an image as done when it is on the graphics card rather than when its bytes have arrived. The decode that sits between those two moments is deliberately not waited for, so that one slow image never holds up the rest of the file, and the effect was that every number a caller could read said finished while the surfaces wearing those images were still drawing untextured. Measured by the consumer who reported it, on a real asset: one and three quarter seconds in which the fraction read one, every part was uploaded and every image was counted, and the object on screen was visibly wrong. It cost them more than a wrong photograph, because a loading bar driven off those counters reached the end and the model then changed underneath somebody who had been told it was finished. The cost of the correction is that the bar now pauses near the end while the last images decode, and that is the better failure: a bar that sits still is honest about being busy, and one that finishes early and then lets the thing it loaded change is not, because nobody can tell it is happening.
- fixed
Two uniforms that had been uploaded once a frame to locations that no longer existed are gone. Both were left behind by changes that were themselves correct: one fed a calculation that moved from the graphics card to the processor, and the other fed a piece of bird animation that was reverted once the shape being reported turned out to be a lighthouse beam rather than a bird. A write to a uniform a program does not have is legal, silent and does nothing at all, which is why they survived so long, and it is the same silence that once hid an entire lighting system uploading to nothing. Nothing about the picture changes and that is measured rather than assumed: the three published scenes that draw water or birds were held at the same frame before and after, and not one pixel of seven hundred and fifty thousand moved in any of them.
0.31.0 · 2026-08-11
- added
The tools this engine uses to look at its own pictures now ship with it, because a renderer cannot be judged from a unit test and every project using one runs into that on the first day it wants to know whether a change moved a pixel. There is a small client for the browser's own debugging protocol, a launcher that proves the browser is drawing with a real graphics card before anything is captured, a reader for the screenshots, and a comparison that reports what two frames differ by split into bands of brightness rather than flattened into one number. Above them sits a command that photographs a frozen frame of each scene, twice, and reports the difference. All of it has no dependencies and every piece can be used on its own against any page rather than only against this repository's own harness. The launcher's check is the part worth knowing about: asked for a headless browser in the ordinary way, this machine quietly hands back a software renderer, which produces pictures that look plausible, tell you nothing about what a player sees, and cook the processor while doing it.
- fixed
Bloom now decides how bright a colour is from its largest channel rather than from its luminance, which is what makes a coloured light bloom at all. Luminance weights green heavily and blue barely, so a saturated source counted as dim however far past white it really was: a pure blue driven to 1.4, which is off the top of what a screen can show in blue and is unmistakably a light rather than a surface, measured 0.101 by that reckoning and would have had to reach nearly fourteen before it crossed a threshold of one. This was found from outside, on two worlds built entirely out of coloured light, both of which bloomed by not one pixel with the effect switched on and the threshold meaning exactly what it says. The largest channel makes the threshold mean what a reader already takes it to mean, which is past white. It can only ever bloom more and never less, and the two measures agree exactly on any grey, so a scene lit in white is untouched by this and a scene lit in colour is repaired by it. The ratio is applied to all three channels rather than to each on its own, so a halo keeps the colour of whatever cast it instead of drifting toward the strongest channel.
0.30.0 · 2026-08-11
- added
Bright things can be wider than the pixels they cover. Until now an emissive surface was exactly as bright as its own pixels and not one pixel further, so anything meant to overwhelm the eye had to be imitated in geometry: a core shape, a second slightly larger one around it, and a crowd of additive particles standing in for a halo. This is that as one pass. Whatever sits above a threshold is kept, the frame is halved repeatedly, six times at any ordinary size and fewer on a small canvas, and the levels are added back through a soft filter on the way up, which is what gives a falloff that keeps going faintly outward rather than a disc with an edge. It is off unless asked for and the chain is not built at zero, so a scene that does not want it pays nothing and looks exactly as it did. Its strength has a per-frame dial beside the exposure one, because how much of it a moment wants is not a property of the world. The threshold is stated in the scene's own units rather than in screen brightness, which is why the option that keeps a scene's real range to the end of the frame had to land first: without that, the picture is squashed into what a screen can show before the composite sees it, a lamp and a white wall arrive as the same colour, and a threshold of one finds nothing at all. Asked for in that state, the renderer says so once rather than presenting a switched-on effect that cannot do anything. It composes with the tone curve instead of replacing it, so the curve still decides how a bright pixel rolls off while this decides how far it spreads. Lens dirt, anamorphic streaks and ghosting were asked against by name and are absent.
0.29.0 · 2026-08-11
- added
A scene can keep its real brightness all the way to the end of the frame, and be graded there rather than as it is drawn. Until now the world was squashed into the range a screen can show at the moment each surface was shaded, so everything after that point saw a picture in which a star at five times white and a sheet of white paper are the same colour. Nothing downstream could tell them apart, which is why there is no way to ask for the bright things to glow: the question cannot be asked of a buffer that has already thrown the answer away. Keeping the range is the thing that has to exist first. It also settles an inconsistency that predates it, because the pass that drew surfaces was the only one applying the grade, so the sky, the particles, the wet film and the water were composited ungraded beside a world that was; one grade at the end covers every pass by construction.
- changed
It is off unless asked for, because it is not neutral and the measurement says so plainly. Held on a published scene and compared against the release before it, the pixels the scene had already driven close to white changed about a fifth of the time by roughly three parts in two hundred and fifty five, while every other part of the picture moved by a fortieth of that, which is the rounding between eight bits and a half float. That is the change being right rather than the change being small: a blur or an occlusion term now works on the brightness a surface actually emitted instead of on a clipped copy of it. With the option off the same scene is identical apart from the numbers printed over it, which is the claim this kind of change has to be able to make.
0.28.0 · 2026-08-11
- fixed
A surface's microscopic relief survives being written to a file and read back. The container gained no place for it when the feature arrived two releases ago, so a mesh went out through the writer and came back with the right shape and its surface texture gone, which reads as a lighting problem rather than as a format quietly dropping an array. It takes the next free attribute bit and is written last, so a file carrying it still opens in every older reader as the same model without relief. Found from outside by a test that writes every optional attribute and reports all the losses at once rather than the first, which is now how the suite checks it: the test that missed this named each attribute, and naming them means a new one adds no line and fails nothing.
- changed
The tone curve setting names the symptom somebody actually has. Leaving it off is the right default and a curve that changed every existing output would be far worse, but nothing led from what a person sees to the thing that fixes it: without a curve, values above one clip flat instead of rolling off, so a scene with bright sources against dark surroundings reads as crushed and desaturated. That gets reported as dark, or flat, or not vivid, which sounds like a lighting problem, and it is where people look. A consumer built, tuned and shipped six worlds before anybody said the word. The symptom is now written where somebody searching for it will find it.
0.27.0 · 2026-08-11
- fixed
A light's physical size reaches the shading again. Two guards on adjacent lines disagreed about what an absent array means: one checked a length and the other checked whether the field was missing, and the factory that builds an environment supplies empty arrays, which are present and unusable. So the fallback never ran, an empty array reached a uniform that wants ten floats, the upload was refused on every frame that had a light in it, and the uniform kept the zeros it began with. Every light was then shaded as a mathematical point, which is a highlight narrower than a single pixel, which is the speckle on dark glossy paint that stating a light's size was added to remove. The feature was silently off for anyone who did not bind that one array by hand. Every array is now guarded the same way, an environment is built with arrays that are valid to upload, and a short one is said out loud once rather than refused every frame.
- changed
Five pieces of the surface now state what a caller has to know and could only find by reading the source. A flat quad takes its facing from its first and last edges rather than from consecutive corners, so one wound the obvious way round a horizontal face points at the floor; that had every ground in one application facing down for four scenes, and only showed when a fifth was lit by the sun rather than by lamps near the ground. A tube is open at both ends. The scattered instance material binds no lamps, which is a rule about what may be instanced rather than a note about cost, because choosing wrongly leaves a prop unlit rather than slow. The reflection probe and the mirrored pass re-enter the mesh pass and not the whole frame, so anything drawn after the world is missing from both. And a radius built from a set of constraints has to place them inside the base radius, or they remove nothing.
0.26.0 · 2026-08-11
- added
A smooth irregular solid, whose radius and colour are both asked for per point. Until now a generated surface could be curved or varied and not both: every generator took one colour for a whole call, and the one that could vary it computes a flat facet from its winding, so anything assembled from those reads as folded paper. A planet with bands needed an image, in an engine whose argument is not needing them, and a rock defeated four attempts in a row, each on a property of the primitive rather than the shape: boxes gave right angles, overlapping spheres gave creases and read as foam, a grid of flat plates gave a correct outline made of eighty facets, and a tube gave a water worn pebble because its cross section is always a circle. A constant radius is a sphere, gentle waves are a boulder, an abrupt step is a fracture, and a constant radius with a varying colour is the banded planet.
- changed
That solid takes its shading direction from the surface rather than from the radius, which is the part a simpler version gets wrong. Where the radius varies the surface no longer faces along it, so using the radius would light a boulder as though it were a sphere: a lumpy outline with shading that disagrees with it. Each point takes the slope of the surface measured through the same callback, so a smooth radius shades smoothly and an abrupt one gives a genuine hard edge. That is what lets one primitive be both a pebble and something broken.
- changed
Three pieces of documentation now say what they do rather than what they were named for. The night factor is the master switch for self illuminated geometry and not a clock: at zero nothing glows whatever a mesh declared, so a daylit scene containing a lit sign, a screen or a studio light has to set it to one and accept the name. It cost a consumer an evening and a white studio that shipped with grey rectangles where its lights should have been. The arc segments a bolt batch draws are plain arrays with a live count and can be filled from outside, which is the route for anything that must render the same plan twice and get the same pixels; the pool that advances is not the only way in. Neither is renamed, because a second name for one thing is worse than an inaccurate one.
0.25.0 · 2026-08-11
- fixed
A model arriving over the network no longer takes whole frames to arrive in. The loader spent a fixed number of parts per update, on the reasoning written into that option itself: that a part is a small buffer upload and that the allocations are what it costs. Measured on a 43 MB car through the demo page, that is the wrong way round. Creating the GPU buffer costs between half a millisecond and three; the work around it costs between six and two hundred and fifty, most of it the caller's own transform plus the two further walks over the same vertices the loader does itself. Counting parts bounded the cheap term and left the expensive one free, and the three kinds of work could stack into a single frame with nothing anywhere counting the total. One clock now governs all of them. On the same load the worst frame falls from 318 ms to 254 and the load finishes no later. That remaining 254 ms is one part on its own, which a budget can keep company away from but cannot divide.
- added
uploadMsPerFramesays how long one update may spend starting stream work. It defaults to six milliseconds, a minority of a 60 Hz frame and under the whole of a 120 Hz one, because a scene has to draw as well as load. Raise it in a tool that would rather have the model in front of it sooner than hold a rate, and lower it where the frame matters more than the wait. Zero is the slowest honest setting rather than a stall, because one piece a frame still begins: a load has to finish.uploadsPerFramestays, as a ceiling rather than the budget, for the opposite shape of asset where hundreds of tiny parts would otherwise all land in one frame.
0.24.0 · 2026-08-11
- fixed
Textures on an imported OBJ are no longer upside down. The format puts its texture origin at the bottom left and this engine uploads with the origin at the top left, so a reader has to flip one coordinate exactly once. Three of the four do and say so; the OBJ reader was the one that did not, so every textured model in that format arrived with its maps inverted while the same asset in another format was correct, which is what made it look like a fault in the model rather than in the reader. Anything already imported from an OBJ with textures changes appearance in this version, and anything that was compensating outside the engine has to stop in the same release. A test now pins the convention for every reader that has one, which is what was missing for it to have been caught here.
- changed
A material library named by an OBJ and not found beside it is now a warning rather than a passing remark. A consumer cannot otherwise tell a model that has no textures from a model whose textures could not be found, and the two want opposite handling: the first is painted by material name, the second is a missing file to go and look for. The engine is the only party that knows which happened, because it is the one that went looking. Reported after an hour spent treating a bundle packed without its material file as a model that simply had no maps.
- added
The light selection reports how many sources were offered, beside how many won a slot. A scene submitting sixty three lights against a budget of ten behaves exactly as documented and looks quietly wrong: the budget goes to whatever is nearest, which is usually static scenery, and the lights that were supposed to matter never appear. Nothing flickers and nothing warns, and the symptom a person reports is that the lighting is poor, which leads nobody to a budget. The ratio is the one figure that makes it obvious, and recovering it previously meant walking the array by hand in a console.
0.23.0 · 2026-08-11
- added
Surfaces can carry microscopic relief: structure far too small to model and far too large to ignore, which is what makes a road read as asphalt rather than as a grey plane. A mesh says how much relief each of its surfaces has and the pass says what kind, because how coarse the bumps are and how deep is a property of the material rather than of the geometry: the same slab is asphalt at sixty bumps to the metre and cast concrete at a hundred and fifty. It is the sibling of grain and not a replacement for it. Grain varies how much light a point takes, so it mottles a face that stays flat and washes out at the shallow angles where a road shows its texture most; this varies which way the point faces, so it catches a lamp on one side of every bump and shades on the other. Absent means none, so nothing built before it exists has changed.
- changed
A surface's roughness now grows with its relief, automatically. A shading direction that wanders cannot hold a highlight narrower than the wander, and a narrow one on a fast-varying direction is exactly the sub-pixel sparkle two releases were spent removing. The amount is known here rather than measured from the finished picture, which is what makes it safe: the same widening driven by a screen measurement was tried during that work and made the artefact three times worse. A surface with no relief is unaffected.
0.22.0 · 2026-08-11
- added
A mirrored pass can be rendered for a surface that is not water, and a wet film can show it. The mirror was a water feature by an accident of its wiring rather than by design: the target existed only when water and its reflections were both on, and only the water pass read it, so a road built as a mesh with wet patches over it could not reflect anything into itself however it asked. A quality option now allocates the target on its own, and a film takes how much of the mirrored scene to show, weighted by the viewing angle the way a real reflection is. The projection is the water pass's own, unchanged, because reproducing it outside is the piece most likely to be subtly wrong and least likely to be noticed. Off unless asked for, and a scene with water still gets its reflection exactly as before.
- changed
The film's mirror is new and its weighting is unproven, which is written into the option rather than left to be discovered. Measured on the reproduction scene at a held frame, turning it fully on changes forty six thousand pixels and moves no channel by more than twelve of two hundred and fifty five: present, and not yet reading as a mirror. The cause is believed to be the film's own coverage fading the mirrored term along with everything else. That is a number to tune rather than a mechanism to find, and where to tune it is named in the same place.
0.21.0 · 2026-08-11
- added
Every resource the renderer hands out can now be released through it. Meshes and surface textures already had a way; plumes, wind streaks, flocks, scattered instances, particles, bolts, water and caustics did not, and each one's own release method asks for the graphics context, which this package deliberately never exposes. The engine's own scenes could call them because they create the canvas and hold it; nobody else could. It cost nothing to a consumer that tears the whole renderer down, since letting the context go takes everything with it, and it leaked one compiled program per rebuild for anyone swapping scenery or weather without rebuilding the renderer. Each new method declines quietly after a lost context, for the same reason the mesh one does: the old context already took its objects, and asking the new one to release them reports an error for every object and buries whatever caused the loss.
- changed
Three pieces of the public surface now say what they are, after each was attempted from outside on the strength of its name. The planar reflection is a water feature rather than a renderer one: its target exists only when water and its reflections are both on, and only the water pass reads it, so a road built as a mesh cannot mirror the scene into itself however it asks. The wet film's sheen is iridescence and not wetness, so raising it makes a surface look oilier rather than wetter, and the range that reads as wet tarmac at night is written down instead of being found by bisection a third time. And the arc pool advances rather than evaluates, so it cannot be driven from a frame index the way the smoke can, which matters to anything rendering one plan twice and expecting the same picture.
0.20.0 · 2026-08-11
- fixed
Dark glossy surfaces no longer show single bright pixels tracing their creases, panel gaps and silhouettes under night lighting. Two things caused it and both are the same mistake: a highlight narrower than one pixel, sampled once. The environment reflection chose how blurred to read from the material's roughness alone, which says nothing about how much of the surrounding scene lands inside a pixel, so where a surface creases and the mirror direction swings between neighbouring fragments each of them read somewhere far from its neighbour at the sharpest detail available. And a light was treated as a mathematical point, so its reflection was a point too, smaller than a fragment and landing in some pixels and not the ones beside them. The reflection is now never read sharper than the pixel can resolve, and a light's own size widens its highlight. It looks like anti-aliasing being switched off and is not: multisampling resolves the edges of shapes and cannot touch a value that varies this fast inside one triangle, which is why raising it has never helped anybody who tried.
- changed
A light's stated size now shapes its highlight as well as its shadow. Every source carried a physical radius already and only the shadow pass was reading it, so a lamp threw a penumbra that widened correctly with distance while its reflection in a polished surface stayed infinitely small. A sphere of a given radius at a given distance covers a knowable part of the sky, and the highlight is widened to match, with the same amount of light spread across the wider shape rather than added to it. The visible difference is on surfaces near a source: a headlight sits closer to bodywork than anything else in a scene ever does, so its reflection is genuinely broad and now looks it. A source that states no size is unchanged.
0.19.0 · 2026-08-10
- changed
A file that carries a coarse version of a model now opens on it, and a bake writes one, without either being asked. Both were opt-in since the feature landed, for a reason that has stopped being true: the coarse version used to be built by merging nearby vertices, which can join points sitting on different surfaces of the model, so an asset could come back as ridges and spikes and somebody waiting for a car saw a crumpled white shape. A bad preview reads as a failed import where an empty stage reads as a load in progress, so refusing to guess was right. The coarse version is the surface of the space the model occupies now, which cannot invent geometry that was never there, and nothing carries one by accident: a file has one only because a bake wrote one. A loader that would rather hold the picture it already has says so with outline false, and a bake that should not carry one is told with --no-lod.
- added
A coarse version is refused when it would cost more than it saves. The grid it is built on is a fixed number of cells across whatever it is given, so its cost follows the shape of a model rather than the amount of detail in it: something simpler than the grid comes back as a shell with more triangles than the model it stands for, which inverts the whole point of having one. A four-triangle test model produced nine thousand eight hundred, and 308 KB of preview arriving ahead of a model measured in bytes. Anything not at least twice as cheap as what it describes is dropped, and the tool says so with both figures. The rule is one function shared by the bake and the browser's own converter, so a model taken through either path gets the same answer.
- changed
The resolution a coarse version is built at is one number the engine owns rather than a copy held by everything that asks for one. The tool printed one figure and the browser's converter passed none, so the same model taken through the two paths was described by grids of different sizes for no stated reason. DEFAULT_COARSE_CELLS is exported beside the builder, and a caller that wants a different grid still names one.
- added
A load can say how the images in a model behave past their own edges. They repeat, which is right for a map meant to tile and is a trap at the border of a UV island: a sample whose footprint crosses the edge wraps round to the far side of the image, and a small enough on-screen size averages across parts of the map that have nothing to do with each other. On a model whose untextured surfaces sit at an exporter's default grey, that shows up as single bright pixels tracing the seam of every textured part, which is invisible against a light background and obvious against dark paint. textureWrap clamp is the repair, and it is asked for rather than assumed because a model that genuinely tiles a map needs the old behaviour.
- added
Opening a bought archive no longer means rediscovering how. prepareZipInflate and browserInflateRaw sit beside the pair that already existed for FBX, so the pieces for reading a zip in a browser are all reachable: a zip stores raw deflate where FBX stores the wrapped kind, and the callback that expands each entry has to be primed in one pass and answered in the next, because the only decompressor a browser has is asynchronous. Both were written twice already, once inside this package and once by somebody outside it.
- added
The frame meter says what rate it is showing. It owns a panel and writes a rate into it, and a caller that wanted the same number for its own interface had to keep a second average beside the first. fps, meanMs and worstMs read what was last displayed, so they move at the panel's own interval rather than per frame, which is the figure a person is reading and the one worth comparing against a budget.
- changed
Three render quality settings whose accepted values are a closed set now say so in their types: the shadow filter budget, the number of static sun depth layers, and the water reflection filter. Each already refused anything else at construction with a message naming the allowed values, which is the right refusal arriving at the worst moment, because it survives a type check and lands as a blank page. The check stays for consumers without types. Nothing that was accepted before is refused now.
- changed
The bake tool says when a model has come out standing below its own origin, and names the flag that turns it over. A container's declared up axis can simply be wrong, and the tool believes what it is told, so a model can bake upside down with nothing in the output saying anything. It is a warning rather than a rotation, because every rule for measuring which way is up is wrong on something ordinary. The tool also stopped treating the token after --lod as a cell count when it is the next flag, which had quietly required --lod to be written last.
0.18.0 · 2026-08-10
- changed
The coarse version of a model that a load can open on is now built as the surface of the space the model occupies, rather than by merging nearby vertices. The difference is one a viewer sees rather than one only the code knows about: merging vertices can join three points that sit on different surfaces of the model, so a car whose seats and engine bay share space with its bodywork grew ridges and spikes straight through the paintwork, and no amount of smoothing fixes a triangle that should not be there. Marking every cell any surface passes through and drawing the boundary between the full ones and the empty ones cannot do that, because it is the outside of a solid. The inside of a hollow model is dropped as well, which halves the size and removes a second invisible shell that used to fight the first one for the depth buffer. On the model this was built against it is 150 KB of a 76 MB file and reads as a car under a dust cover.
- added
A small version of every image can be put on its surfaces before the full one arrives, so a tyre reads as tread about a second earlier on a real model instead of standing as flat colour while a four megapixel image decodes. The small version is made from the bytes already in the file, by asking the browser to resize the image while it decodes it, and the full one then replaces it in the same texture so nothing that was already drawing has to be told. It costs a second decode away from the main thread and nothing at all in the file. Off unless asked for, because it is a trade rather than an improvement.
- added
Exposure into the tone curve can now be set per frame rather than only when the renderer is built. One value is wrong twice in a world that has both a bright exterior and a dark interior in it: blown out in one and unreadable in the other. The construction-time value stays the grade a world is authored against, and a frame may say what it wants instead, so anything that never asks is exposed exactly as before. Moving smoothly between two of them is left to the caller, which is where the knowledge lives: a game knows it walked into a cave, where the renderer could only find out by measuring the picture a frame late.
0.17.0 · 2026-08-10
- added
A large model can put a whole object on screen before its parts arrive. A file may carry a coarse level of the entire model, laid out ahead of the geometry it stands for, so a plain fetch shows a recognisable shape within the first few hundred kilobytes and sharpens from there. On the model this was built against that is 140 KB of a 76 MB file, and the shape is up at six per cent of the load where the stage used to be empty. It has to be asked for at both ends: a file carries a level only if it was written with one, and a program that meets a file carrying one still says whether it wants it drawn. That is deliberate, because a coarse level is a decimation and how good it looks belongs to the model rather than to the engine, so a rough preview reads as a broken import where an empty stage reads as a load in progress. Older files open unchanged, and a file written with a level opens in an older reader as the model without it.
- added
A finished load can be put back to any point of itself. Where a program asks for it, the pieces a load builds are held instead of released at the end, and it can then show the state after any number of them: nothing at all, the coarse level on its own, the first forty parts, or the finished model. Nothing is fetched or uploaded a second time, so moving through the states costs almost nothing and can follow a control as fast as somebody drags it. Off by default, because holding the pieces costs a second copy of the geometry on the graphics card: that is a fair price for a tool or a page whose subject is the load itself, and the wrong one for a game shipping a level.
0.16.0 · 2026-08-10
- added
A model can now be read straight from the formats a studio already has, in the browser, without converting it first. Point a scene at a file and it opens: glTF in both its packagings, OBJ with its material file, STL, USD, 3MF, and FBX. The reading happens off the main thread, because on a real model it is seconds of work and doing it on the page is a freeze rather than a pause, and what comes back is fed through the same path a converted file takes, so what a viewer watches does not depend on which format it started as. Converting once is still the better thing to do with anything you mean to ship, since that is what buys the streaming and the zero-copy load, and this is for looking at a file without running a tool first.
- added
One place that decides which reader a file needs, used by the offline tool and by the browser alike. It had lived in the tool and was written against the filesystem, so a second caller wanting the same answer would have had to copy it and then keep the copy in step with every format added afterwards. Everything it might need is now something the caller hands it: reading a file, decompressing, and resolving a name that a model states relative to itself. Four of the seven formats need none of those, so the common case works anywhere with no configuration at all, and a format that does need one says which by name instead of failing obscurely.
- added
A way to close the inside of a model that is only a surface. A great many bought models are exterior shells: a car with no cabin, a building with no rooms. Drawn on their own they are correct, and the moment anything can be seen through an opening in them, a grille or a doorway or a window, you are looking through the object and out the far side. Two pieces do it, because neither can do the other's job: a generated solid that has no holes, which is what shows through an opening, and the model's own surface drawn just inside itself in one flat colour, which follows every curve exactly because it is every curve. What the subject looks like is stated by the caller, and the default is a plain block, because a shape guessed at is wrong in the one direction that shows.
- fixed
A model that was never uploaded is no longer reported as a corrupt one. Whether a request succeeded is not enough to tell those apart, and the difference is the ordinary case rather than an exotic one: a development server and most static hosts answer a path they do not recognise with the site's own page, under a success code. Those bytes reached the reader, the check on the first four bytes failed, and a scene told a visitor that the model was broken when nobody had ever put one there. Found on a published page, where the deployed site was answering correctly at the same moment and the local one was not.
0.15.0 · 2026-08-10
- added
A model can be loaded progressively, so it builds up on screen instead of appearing. The container was designed for this and the layout needed no change: a reader can now be fed bytes and it reports each piece as that piece finishes arriving, while the writer lays the file out in the order a viewer needs it. What that produces, in order, is the manifest, then the paint, then the model arriving part by part already correctly painted, then the images landing on it. Those stages are the file's own contents rather than a script over them. Reading a whole file at once is untouched and is still the right thing for a file on disk.
- added
One object that does the whole load: it streams the file, spends a bounded amount of the frame on uploading, fades each part in as it lands, and finishes by merging everything down to one draw per material. The bounded part is what makes the difference between a build and a stutter. Handing every piece to the graphics card the moment its bytes arrive puts an unlimited amount of work into a single frame, and on a two hundred piece model that is visible as the whole page hitching twice: once as the pieces start, once as the images and the merge land at the end. Three separate budgets, because a piece of geometry, a two thousand pixel image and a merge of a million vertices are not interchangeable amounts of work.
- added
A way to track everything an application is waiting for as one honest number. Every consumer writes this and most write it the same way wrong: a bar driven by whichever load happened to report last, or a fraction over a count of tasks that jumps in sevenths. This is weighted, so a large thing counts for more than a small one, and a task that turns out not to be needed is dropped from the total rather than completed, because counting an absent thing as finished makes a bar claim something arrived that never did. It reports names and numbers and no wording at all: what to call a stage depends entirely on who is reading it.
- fixed
Reading the first bytes of a file no longer copies them one at a time. The header and the table are held before the rest of the file has anywhere to go, and they were being accumulated a byte at a time into a plain list, while the first thing a network hands over is tens of kilobytes. It was measurable as a pause at the very start of a load, which is the worst place to have one, because nothing is on screen yet to explain it.
0.14.0 · 2026-08-10
- added
Light in the air is drawn as light. A beam from a lamp, a shaft through a window or the cone under a street light is now its own kind of thing rather than a surface that happens to glow, and the difference is that it only ever adds: nothing it crosses can come out darker than it was. Two earlier attempts to express one as a material failed in opposite directions, and both are worth knowing about if you reach for a material again. Blended, it moved whatever was behind it toward its own colour, so an unlit shaft darker than the sky it crossed swept a solid dark shape across the frame. Made additive but still fogged like a surface, ninety metres of it faded to nothing, because the fog is the very thing a beam is lighting. A caller supplies a few flat panes crossing the volume's axis and says how far and how wide it opens; the renderer shapes the falloff along it and away from it, so what shows is a soft core with no edge anywhere in the picture.
- added
Reflective surfaces can mirror the room they stand in. A reflection probe captures the scene from one point into a cubemap, once, at the moment a caller says the world is finished being built, and a surface with reflectivity turned up then shows the room rather than an approximation of a sky: a car body carrying the ceiling and the panels above it. Rougher surfaces read a blurrier version of the same capture, which is what separates satin from lacquer, and it costs one texture read per reflective pixel rather than anything per frame. It needs one more texture unit than the standard guarantees, so on hardware without a spare one it says so and reflective surfaces keep the approximation they always had.
- changed
Camera motion blur can be turned up and down per frame instead of only being switched on for good. The setting that decides whether the effect exists at all is still made once, and how much of it a given frame wants is now a separate dial beside the speed blur. This is worth having because a blur that never varies is not a speed cue: it reads as a filter over the whole game, it costs eight samples on every moving pixel to do that, and it was reported as boring by one player and switched off within a minute by another. A game can now bring it in only when a run is genuinely fast.
- fixed
Point-light shadows are compared over a distance the hardware can actually resolve. The twelve shadow cubemaps were being read at eight bits of precision rather than twenty-four, because a shader gives its texture samplers a low default precision whatever it says about its numbers. It matters more here than anywhere else it has been found, since the comparison converts a stored value back into a distance in metres through a division, which magnifies whatever error arrived with it and magnifies it most for the furthest-reaching lights. The speckling along the lit edge of a surface near a lamp resolves into a clean edge.
- changed
The tools that come with the engine are typechecked, and a build now bakes a real model. Until now the importer was outside the typechecked project, so a missing function in it could pass every check the engine had. It has its own configuration for that, deliberately separate, because the tools run in Node and the engine must never compile against those types: a consumer bundles this source for a browser. The end-to-end half matters more than the types did, and it is why a four-triangle model with two materials is now checked into the repository: a bake runs on every push, twice, with the two results compared, which covers writing a container and reading one back. Every real model this project has been pointed at is somebody else's download and is not redistributable, so before this a fresh clone had nothing at all to bake.
0.13.0 · 2026-08-10
- added
Ambient occlusion, as a quality option that defaults to off. It shades where surfaces meet, which is most of what makes a model stand in a room rather than float in front of it: under a wheel arch, along a panel gap, where a plinth meets a floor. Strength is a weight from 0 to 1, and the radius is in metres of the world rather than pixels of the screen, so a gap of two centimetres darkens by the same amount seen from across a room as from beside it. It is measured from the depth the frame already resolves, so it costs three passes over the image and no second drawing of the scene, and it comes to 0.18 ms at 1.46 megapixels. It needs the off-screen target that screen effects allocate, since without one there is no depth to read, and on a driver that will not resolve depth it turns itself off instead of shading from whatever was in memory.
- fixed
A mesh drawn after another pass could be drawn by that pass's shader. The main pass is bound once a frame and every other one interrupts it, so a call made after water, particles or a flock was writing to a program that had not been current since, and the draw that followed took the program it found. It also raised an error every frame, which mattered more than the geometry it spoiled: a pending GL error is not attached to the call that raised it, so the next capability check anywhere reads it as its own failure. One had already read it as a driver refusing to resolve depth and switched camera motion blur off for the session. Every call that writes the main pass's uniforms now binds it first.
- fixed
Depth read in a screen-space pass was arriving with eight bits of precision rather than twenty-four. GLSL gives a fragment shader's samplers a default precision of lowp whatever the file declares for its floats, so the frame's depth was flattened onto a couple of hundred planes. Camera motion blur has been reprojecting through that since it shipped, and it is why the first ambient occlusion could not work at all: the surface normal reconstructed from depth came out facing the camera everywhere, and what reached the screen was contour banding along the planes. Both passes now ask for the precision they need.
- changed
The optional uniform reporter now also names a write aimed at a program that is not the one currently bound, beside the missing uniform name it already reported. Both upload nothing at all, and only this one leaves an error behind for an unrelated check to misread later. It stays off by default, because a renderer that warns from inside a frame loop is its own problem, and it names each fault once rather than once per frame.
0.12.0 · 2026-08-10
- added
The engine imports models. Seven formats read directly: glTF 2.0 in both its packagings, Wavefront OBJ with its material companion, STL, USD as both the zipped and the plain text form, 3MF, and FBX. Each is labelled with how far it is trusted, and that label appears wherever the format is named rather than only in a document: the open specifications are held to a compatibility promise, FBX is a real attempt that is explicitly not a promise, and a format with no public specification is identified and refused with the export that does work named for you, instead of failing somewhere confusing. A folder can be handed over instead of a file, and the tool inventories what is inside, picks the best reader available and prints the choice it made.
- added
A baked container for models, with a compatibility promise. Geometry lands in the file at an alignment the GPU can take directly, so loading it costs bounds checks rather than parsing, and a whole level is one fetch that cannot half arrive. The promise is the part worth adopting it for: a file written today opens in every future reader, with no expiry and no migration step. A reader meeting a chunk it does not know skips it in silence if the file says it is optional, and refuses by name if the file says it is required, so an asset is never quietly half loaded.
- added
Textures travel inside the model file, and are addressed by name. An image is embedded exactly as it arrived rather than re-encoded, and only images a material actually reaches are carried. A consumer swapping one at runtime names it the way the source did, and an unknown name raises an error listing what the asset does have. That is deliberate rather than incidental: a numeric index that has gone stale still resolves, to the wrong surface, with nothing reported anywhere.
- added
Every reader carries the same material information, so which one a bundle happened to use is invisible afterwards. Colour, texture, transparency and how much of the surroundings a surface mirrors all survive the import, and the same asset in two formats bakes to the same result. That is measured rather than asserted: a character supplied as both OBJ and FBX gives 111,668 vertices and 154,543 triangles either way, and one model supplied in four formats gives the same 1,994,358 triangles in all four.
- added
Grain is a property a surface states rather than one the renderer infers from something else. It was read off how shiny a surface was, then from how rough it was, and both are proxies: painted plaster is rough and has no grain, polished granite is smooth and has a great deal of it, so neither can be derived from the other. A mesh now carries grain as its own value alongside the rest of its material, a scene sets it while it builds geometry, and a surface that never mentions grain has none.
- changed
Grain reads as a surface being uneven rather than as one with a pattern in it. The brightness swing was eighteen percent either side, which is enough to look like veining, and veining is what marble has and sandstone does not. It is six percent now, and measured on a flat lit floor the variation across a surface is 4.7 times smaller. The pattern also gained a second, much coarser scale, because a single frequency across a column, a floor tile and a cliff makes all three the same stone at three sizes.
- added
Anti-aliasing, as a quality option. The engine asked for multisampling only when screen effects were switched off, for a good and measured reason: with the scene drawn into an off-screen target, the only thing reaching the screen is one full-viewport triangle and there is nothing left to smooth. What never happened is anything replacing it, so the default configuration has had no anti-aliasing at all, which is what hard-stepped edges on an imported model were. The scene target can now be multisampled and resolved into the image the effects read, so edges are smoothed where they are actually drawn. Measured against a hard silhouette on a gradient sky, four samples spread an edge over 1.65 times as many pixels and cut the hard steps to under two thirds. A request larger than the hardware allows is clamped and said rather than silently ignored.
- fixed
One bird in every flock was permanently unlike the rest. The hash that gives each bird its own orbit has a fixed point at zero, so the first bird was handed the floor of all three of its ranges at once: the innermost orbit of the whole flock, the lowest height and the slowest wingbeat. Measured on a thirty-four bird flock, it orbited at 8.25 metres while every other bird spread between 8.34 and 15.72. It now sits inside its own flock like the rest.
- fixed
A flock's scale parameter documented itself as the wingspan and delivered twice that, because the bird outline it multiplies puts the wingtips at plus and minus one. A caller asking for a metre and a half of bird was given three metres of it. The parameter now says what it is.
0.11.2 · 2026-08-09
- fixed
A mesh whose optional attributes did not cover every vertex is now rejected when it is built, rather than drawn differently on different hardware. Each shape method filled those arrays by hand and they had drifted apart: some wrote a value twice per vertex, one wrote it five times, and merging another mesh did not carry roughness across at all. A short attribute buffer is not harmless — the standard lets a driver read past the end as zeroes or refuse the draw outright, and both are correct — so a scene could be flawless on one machine and simply absent on another, with no error raised anywhere. A courtyard lit by lanterns arrived on a phone as a fire burning in an empty void for exactly this reason.
- added
A strict mode that reports writes to uniforms a shader does not have. Such a write is legal and does nothing, so an effect can quietly cease to exist with no error, no warning and nothing connecting the wrong picture to its cause. Switched on, it names the program and the uniform once each. It found twelve in this engine on its first run.
- fixed
Uniform arrays are found under either spelling a driver may report them by. The standard permits both a bare name and one with a subscript, and implementations disagree; the engine assumed the subscript, so on any driver using the other convention every point-light upload went nowhere in silence.
- fixed
Point-light shadows are no longer switched off on hardware that can afford them. The budget fits a sixteen-unit device exactly, and requiring one unit spare disabled the feature on every GPU reporting that figure — which is all of Apple's.
- fixed
A mesh now applies its own constant attributes each time it is drawn. Those values are context state rather than part of the vertex array object, so setting them once when the mesh was built made its appearance depend on which mesh happened to be built last.
0.11.1 · 2026-08-09
- added
The engine now carries an MIT licence and a contributor guide. Neither changes a line of what it draws, and together they make it something a stranger can read, use and build on without asking first: the licence states the terms plainly, and the guide sets out how to get set up, what a good change looks like, which contracts must not move, and how much testing is enough.
- changed
Comments and documentation throughout the engine have been rewritten to stand on their own. They were dense with the history of one game — its proper nouns, the names of the people who filed each report, and the words they used at the time — which reads as a private notebook rather than as an engine's reference, and would be the wrong thing to hand to anyone else. The reasoning has been kept in full, because it is the reason these files are worth reading; only the parts that meant something to one project have been replaced by the general thing they were an instance of.
0.11.0 · 2026-08-09
- added
Surfaces can take their colour from a picture. Until now everything was coloured a corner at a time, which is right for a cliff face and hopeless for wallpaper, floor tile, a photograph or a hand print on a wall — so a game whose whole look is patterned surfaces had no way to say so. A picture can also cut its own silhouette out, so a shape drawn on a clear background arrives as that shape and not as a rectangle around it, and it can be replaced after the fact, which is how a portrait that is still downloading gets swapped in without rebuilding anything.
- added
Colours now leave the renderer the way a screen expects them. Every calculation inside is done in the units light actually adds up in, and those are not the units a display reads; the step that converts between the two was missing, so everything arrived a little wrong in a way no amount of adjusting the lights could fix. There is a choice of film-like curve, an exposure to sit it where a scene wants it, and the whole thing can be turned off for anything that would rather do its own.
- added
A lamp can fade with distance the way physics says or the way a room wants. The gentle falloff the engine had is right for a world of big soft sources and wrong for a strip light a few centimetres under a ceiling, which in life floods the panel around it and here left it dark. Both are available now and a world picks; neither is more correct than the other.
- added
A surface can glow in a colour it is not made of. Glow used to be a single dial that brightened whatever the surface already was, so a warm strip set into a pale panel came out pale and bright rather than warm — right about the brightness, wrong about the light. It can also be turned up and down for a single draw, which is what lets a doorway breathe or a ceiling pulse with music instead of sitting at one fixed brightness.
- added
Polished floors catch the lamps above them. A highlight answered only to the sun, so a room lit entirely by its own fixtures had none at all and a waxed floor under a strip light looked like matte board. Surfaces also carry how rough they are, which decides the width of a highlight — the difference between a lamp landing as a dot and smearing down the length of a corridor.
- added
Water is lit by the lamps around it, not only by the sky. The surface answered to a single distant source, which quietly assumes water lives outdoors — a flooded corridor, a cistern, an indoor pool and a harbour at night are all water lit entirely by lamps, and under those it had no shading and none of the glitter that is the strongest single sign that a surface is water rather than a coloured plane. A body of water can also be told how disturbed it is, rather than taking it from the weather, so water in a sealed room is not calm because there is no wind in the room.
- added
Light thrown by water now lands on a pool floor, not only on what covers the water. The effect was built for the underside of a bridge and refused everything below the surface, on the reasoning that there is nothing down there to light. That is only true when the water is a ceiling to you, and the floor of a pool is by far the more ordinary sight.
- added
Creatures can be built out of capsules and spheres rather than boxes. Everything was a box, so anything alive read as furniture — a fair description of what it looked like. Flat panels are cheaper to build too: a single face can be added on its own instead of a whole six-sided block, which is what a wall, a floor or a poster actually is.
- added
Anything flat can be made to face the viewer. A sign, a figure, a face hanging in the dark: two ways, one that turns only on the spot so a standing thing stays upright when you look down at it, and one that faces you on every axis for a spark or a puff of smoke. Picking the wrong one is the classic version of this mistake and both are now available to pick from.
- added
A pattern drawn on a surface knows which way is up. Two walls at right angles disagreed about it, so an oriented image appeared rotated a quarter turn depending on which wall it landed on.
- fixed
A room lit only by its own lamps is no longer nearly black. Half of the soft fill light — the half that reaches anything facing downward — was being left out entirely, and the film-like curve a scene asked for was reaching one part of the picture and not the rest. Both were silent: nothing failed, the room was simply dark, and no amount of turning the lights up could reach what was not being asked for.
- fixed
A world that never tints anything is no longer black. The tint every surface is multiplied by started at nothing rather than at white, so a game that never set one had every surface multiplied to zero — a first run that renders a black screen and looks like a much deeper problem than it is.
- fixed
Something drawn see-through keeps the shape of its own image. A stain, a poster, a sticker: the picture is clear around the mark and part-way clear within it, and only the strength of the whole draw was reaching the screen, so a hand print at three-quarter strength arrived as a three-quarter-strength rectangle with a hand print somewhere inside it.
- fixed
A renderer whose drawing surface has been taken away now stops instead of failing. A browser can reclaim it at any time — most often when a machine has too many pages open — and everything that touched it afterwards raised its own error, so one loss became a flood of them with the cause buried somewhere near the top. It stops drawing, it answers honestly when asked whether it still has one, and letting go of something after the surface is gone is no longer treated as a mistake.
- changed
A game that has turned shadows off no longer carries the machinery for them. The code for shadows was assembled whether or not anything asked for it, which costs every machine the compile and the slowest ones rather more than that.
- fixed
A highlight's width and its strength stopped interfering with each other. Roughness is meant to say how wide a highlight is and nothing else, and it was quietly setting how bright one was too — so a surface that had asked for a soft sheen before roughness existed came out as a blown white speck instead, fifty times too bright and less than half as wide. Anything that was tuned against the old behaviour keeps its look by saying how strong it wants its highlight, which is the setting that was always meant to carry it.
0.10.1 · 2026-08-08
- fixed
A mouse capture the browser refuses is no longer treated as a failure. Once you leave the capture with Escape the browser will not hand it back for about a second, which is exactly when the next click asks for it, and that refusal was being left for the page to trip over. A game watching for unexpected errors then reported one, about nothing.
- added
A frame is now told how long it really took, alongside the capped figure it already had. The cap is there so a stall cannot run the simulation or the animation forward by a minute, and for those it is the right number. It is the wrong one for anything measuring the machine, which could not tell a slow device from a browser that had stopped asking for frames at all.
0.10.0 · 2026-08-08
- added
A rectangle of the frame can be filled with one flat colour that blends with what is behind it. There were two things that nearly did this and neither was one: clearing a box to a colour is opaque by definition, and the text layer's backing plate is built from one lit cube per cell, which is right for a keycap and a grid of seams at panel size.
- changed
An inset can keep the picture behind it and clear only depth. An object drawn into a rectangle of the frame then sits over the scene rather than inside a box cut out of it, which is what a caller compositing over its own frame wants.
0.9.0 · 2026-08-08
- added
Surfaces can carry a specular highlight. Until now everything in a world was lit the same way — a flat colour with the sun's angle on it and nothing else — which is honest for stone and wrong for anything that is meant to catch the light. Geometry can now ask for a highlight per vertex, so a cut face flashes as it turns and a plain wall next to it does not. It is optional and off by default, which matters more than it sounds: a mesh that does not ask for one produces exactly the data it produced before, uploads nothing extra, and costs a single comparison per pixel.
- added
Procedural grain on those same surfaces, computed rather than drawn. There are no image textures anywhere in this engine and there are none here either: the variation comes from the position in the world, so it needs no memory, cannot be seen to repeat, and stands still while an object turns through it the way a real grain does. Only geometry that asked for a highlight pays for it.
- added
A shader for arcane auras, and a colour for effects to take. The volumetric effect system could draw fire and smoke — things that rise — and had no way to draw something that surrounds an object instead. The new one works outward from its centre in rings that turn against each other, which reads as contained energy rather than as a flame. Effects can also be tinted now, so one shader serves every colour instead of one shader per colour.
- fixed
A raised kerb along a path can be stood on. Trim down the edge of a surface was drawn a few centimetres proud and the physics knew nothing about it, so a player standing on one had their feet reported at the height of the road beside it and sank into the thing they were plainly on top of. The surface now takes the same two measurements the trim is drawn from, which is what stops the two from ever describing different worlds.
0.8.0 · 2026-08-07
- added
Renderer.onContextRestoredtells a consumer that a lost drawing context has come back.preventDefaulton the loss has always promised the browser that the page intends to restore, and nothing had ever listened for the restore, so every promise was broken. It gets its own sink rather than sharingonContextLost, because a fault reported at the moment the fault ended reads in a bug report as a device that lost its context twice. It makes the moment observable and rebuilds nothing: every resource field onRendereris created in the constructor andreadonlyafter, which is the same reason a quality change is construction-time, so drawing again needs a reload and the consumer owns that decision - added
ResolutionGovernorholds a frame budget by moving the drawing-buffer scale, and nothing else. Pure policy with no GL, no DOM and no clock, because every device it exists for is one no test machine has: frame times go in, a new scale comes out, andnullcomes out on the frames where nothing moved so an ordinary frame costs no GL or DOM work. It is deliberately hard to trigger, because the failure mode that matters is degrading a machine that was fine. A window is bad only when half its sixty frames ran over 40 ms, and the scale moves only after two bad windows in a row with no good one between them — so a garbage collection, a shader compile, a world build or another application grabbing the GPU cannot move it, while an Adreno 619 at 156.66 ms a frame has a share of 1.0 and clears two windows immediately. An earlier version took the worst frame in the window instead, which would have dropped a 120 fps machine a step on one hitch: the worst frame is the right rule for diagnosing a device and the wrong one for a control loop, which has to ignore what it cannot act on. Raising is slower still and waits behind four clean windows, because an oscillating resolution is more visible than a slightly low one. Unplayable frames are not evidence at all: a menu frame and a hidden-tab frame are not verdicts about the machine, and that false positive reached a player once already - added
Renderer.applyResolutionScalemoves the drawing-buffer density cap at runtime without rebuilding anything, which is what the governor drives. It is the one quality lever that can move mid-session:resize()re-reads the cap and both the scene target and the planar reflection are sized from the drawing buffer, so nothing here owns an allocation a new density invalidates.Renderer.rendererNameandRenderer.capabilityClampedare exposed alongside it, so a bug report says which part this is and whether it was clamped rather than leaving either to be inferred - changed
A GPU family known to struggle now opens at pixel terms it can hold, clamped at construction to a density of 1 and a reflection scale of 0.5. An Adreno 619 was opening at a density of 2.625 with a full-size reflection target and holding 156.66 ms a frame, because the consumer's default quality is a constant for every device. Only the pixel terms move: the shadow and water switches are what a preset means, and drawing a different scene than a settings screen describes is worse than drawing the right one at fewer pixels, while both clamped terms are ones a player can already reach themselves. It is a hint and never authority — the unknown renderer string is deliberately not treated as weak, because a device database is wrong about every GPU nobody has tested, and the governor corrects a wrong guess from measurement in both directions.
RenderQualityOptions.capabilityClamp: falseturns it off entirely, and a consumer passes that to mean "this person chose these numbers themselves" — a player who deliberately asks a weak part for the good profile is entitled to it and to the frame rate that comes with it, because a setting that silently does not apply is worse than a slow game
0.7.1 · 2026-08-07
- fixed
A
texture()call reached through a branch the compiler could not prove uniform was being flattened, costing 13 ms a frame.flat.ts's point-light loop samples its shadow cubemaps inside a ten-wayif (i == uPointShadowIndex[k])chain, which is the shape GLSL ES 3.00 forces without a dynamically-indexable sampler array, andtexture()'s implicit derivatives are only well-defined under uniform control flow — so a compiler unwilling to trust that branch is free to compute every arm and discard nine. The tell was a system getting slower when it was switched off:?shadows=0moved the unattributed part of the frame from 17.50 ms to 28.50 ms, reproduced three times, and removing work cannot cost 11 ms. Both calls are nowtextureLod(..., 0.0), which is output-identical by construction rather than by eye, because those cubemaps carry one storage level andNEARESTon both filters, so no mip exists for the two to disagree about. Measured on one rig with a pinned sky: 17.50 ms to 4.50 ms, wall clock 17.00 to 8.50, which is the measured vsync interval
0.7.0 · 2026-08-07
- added
MeshBuilder.addTubesweeps a round tube of varying radius along an arbitrary path — the primitive a curve needs, whereaddCylinderis straight and axis-aligned. A tube carries a normal per ring vertex, so the flat shader shades it as the round thing it is, where a curve chained out of boxes has hard corners and six distinct normals and reads as a staircase however finely it is stepped. The frame is parallel-transported rather than built from a fixed world up, because a world-up frame flips through 180 degrees where a path turns vertical and puts a visible seam at exactly the apex of an arch. - added
metalBuffer: a struck metal ring, built from inharmonic partials over a short noise transient, each partial dying faster than the one below it. Whole multiples would be a bell, and holding the top partials as long as the bottom is why a synthesised clang usually sounds like a synthesiser. - added
AudioGraph.slamLowEndboosts the score's bass and slams a low-pass shut for a fraction of a second, then lets both back. It is a parallel path rather than an in-line stage, so an idle graph is sample-identical to one without it, and it is tapped upstream of the airborne lift — the lift is a high-pass, so a slam taken after it would be weakest exactly where it is most wanted. - fixed
Filtered noise is actually filtered. Every noise source in the engine ran through a single pole, which is 6 dB per octave — three or four octaves above its own cutoff it has taken off barely twenty decibels, so what survives is still broadband, and lowering the cutoff could never have helped because the leak is the slope rather than the corner. Three poles in series is 18 dB per octave, and every existing caller gets it without changing any of its own numbers.
- changed
The drift scrape sits in the low mids where a hard wheel on stone actually lives, instead of in the top end where it read as hiss.
0.6.0 · 2026-08-06
- added
One water, configurable, so a sea and a basin are the same component rather than a component and a trick.
WaterSettingsbecomesWaterBody, and every body gets the waves, the Fresnel, the specular, the fog and the planar reflection; what differs between them is numbers.densityis how much of what lies beneath it the water hides looked straight down, so a sea hides its own floor, a fountain shows the tiles at the bottom of it, and zero is glass.visibilityscales the whole surface away to nothing,waveScalesays how built-up its sea is against the open one, andboundsmakes it a fixed square instead of the endless sheet. Two mechanisms carry it: the grid's origin moves to the CPU, so the ocean passes its own camera snapped to whole cells (which is what stops an endless sea sliding underfoot) while a bounded body passes the middle of the thing it fills; and a unit sheet in [-1, 1] is scaled per draw by the body's half-extent, so a fountain costs a uniform rather than a mesh and a hundred puddles cost the same as one. - added
A water body may declare itself a mirror. Reflection strength was the Fresnel term alone, which is right for an ocean seen across its own surface and means almost nothing standing over a basin looking down: the angle is steep, the term is a few percent, and a reflection nobody can see is the same as no reflection. A reflection in a fountain was visible and far too faint to read as one.
WaterBody.mirrorblends Fresnel toward one — a lie about optics and an honest one about what a small pool is for, which is looking into it to see something. The sea keeps zero and behaves exactly as before. - added
Text as world geometry.
buildTextMeshputs a string in the world as boxes, with a separate cell height for text read at a shallow angle, because a marking on a road is foreshortened along the direction it is read from.MeshBuilder.addOrientedMeshmerges a mesh under an orthonormal basis, which is what lets any of it follow a surface instead of the world's axes; it refuses a left-handed basis, since a mirror reverses winding and every face would then be culled — which renders with no GL error and no pixels. - added
drawTranslucentMesh, a mesh you can see through, drawn in the flat material with auOpacityuniform. It writes depth, unlike every other blended pass here, and the reason is the sky: drawn last as a full-screen triangle over every pixel the world left untouched, it paints straight over anything translucent that declined to write depth.bindMeshPasssets the uniform to 1 every frame, because a GL uniform starts at zero and a world drawn with an alpha of nothing is invisible the moment the canvas is composited. - changed
The canvas stops claiming to be see-through. The WebGL context asked for an alpha channel it has never used:
beginFrameclears with an alpha of 1 and source-alpha blending leaves the destination at 1, so every pixel handed to the browser was already opaque — but the default isalpha: true, and believing it, the compositor blends the whole drawing buffer over the page every frame and may not treat the layer as opaque. That is the fast path a fullscreen page is otherwise given, declined in exchange for a promise nothing was keeping. - added
maxDrawingBufferPixels, a cap on a frame's area to go beside the cap on its density.maxDevicePixelRatioanswers "is this panel high-DPI" and cannot answer "how much frame is this": with density held constant, a frame still costs whatever area the window was last dragged to, which makes window size an uncontrolled multiplier on a renderer that is roughly 90% fragment-bound. Vsync then charges for it in steps rather than proportionally, so a few percent of growth past a slot presents as half the frame rate. The default is 12 MP, above 4K and therefore invisible on almost every panel; 0 is uncapped. The whole sizing decision moved into a puredrawingBuffer.tsso the rule that matters is testable: an export lock is handed back exactly and no cap touches it, because a clip is the size the export asked for whatever anybody's frame rate is. - fixed
The tap that the hold had already resolved is no longer eaten. A caller ticks once a frame, so a still touch is always promoted to the held form before it is released, and the press was queued only from
pending— every tap past the 90 ms resolve was discarded, which on a phone is most of them.isTapReleasedecides on travel and duration instead, so a promoted touch released quickly is still a tap. The file's own tap test passed throughout because it never ticked between the start and the end of its tap, which is the one sequence the real loop never skips. With it: a held thumb can steer, since the hold used to be terminal; touches after the first are no longer dropped outright and may tap or flick; and the tap window is sized against a platformer's coyote time rather than being generous for its own sake. - changed
A
Splinemeasures its centripetal knots once per segment instead of once per evaluation. They cost three hypotenuses and three square roots and depend only on which segment you are in, never on where you are inside it — andsampleAtevaluates three times, once for the point and once either side for the central-difference tangent, so a single sample was paying for nine of each. The arc-length table is thousands of evaluations per curve,buildRibbonis thousands more, and the simulation samples the route every tick. The four control points a segment interpolates are hoisted the same way, into a point list carrying a reflected phantom at each end, which also takes the two end-case branches out of the inner loop. Measured through a consumer: building a world went from 99 ms to 86, and laying its route from 24 ms to 14. No number moved: the arithmetic is the same expression on the same inputs, and twenty-five days of colliders, meshes, lights, anchors, secrets, scatter and segment windows hash identically before and after, which is what the replay contract needs.
0.5.1 · 2026-08-06
- fixed
Point-light shadow maps are borrowed from a pool of twelve instead of owned one per world light, forever. A world with 50 lamps allocated roughly 327 MB of depth cubemaps so that the eight MAX_POINT_LIGHTS the shader can bind could be read; 42 of them were unreadable by construction. Measured 388 MB of texture memory down to 160 MB at 1080p. Nothing is lost visually, and that is by construction rather than by hope: a light outside the sampled set has no sampler bound to it and could not be read however long its map was kept. The cost is a six-face bake when a light re-enters the set, which is also why load no longer bakes anything — it used to render six passes over the static world for every lamp in the day, most of them never looked at.
- fixed
A light may drift
pointShadowRebakeDistancefrom where its shadow was baked before the map is stale. An exact position compare had made a flame the most expensive object in the scene: a brazier wanders a few centimetres every frame to look alive, and every frame that re-baked six cubemap faces of the entire static world. Measured at zero static re-bakes a frame afterwards. Drift accumulates from the baked origin rather than being forgiven each frame, so a light that genuinely travels still re-bakes, and callers wanting the old behaviour pass 0. - fixed
Multisampling is requested only when something can use it. With
screenEffectson, the scene lands in a single-sampled off-screen target and the only thing reaching the default framebuffer is one fullscreen triangle, which has no interior edges — so the multisampled backbuffer was allocated and resolved every frame for a provable no-op. Forcing it off moved gl.SAMPLES from 4 to 0 with screenshots that compare identical, and it stops competing for memory on parts where system RAM is the VRAM.
0.5.0 · 2026-08-04
- added
A critically damped spring that rides a moving target. A first-order lag has to accumulate error before it can travel at all, so anything following a 14 m/s subject sits a fixed distance behind it — a rig asking for a 7 m arm held 9.2 m — while a spring stepped against the target's own velocity keeps station. Shot placement is now a function separate from the rig that follows it, so a rig can evaluate the same shot at two poses in one frame and learn the velocity it should be riding.
- added
A shared camera arm: one boom with a speed limit and damping that is quick to shorten and slow to extend, so something crossing the line of sight cannot teleport a camera. Worst single-frame move 8.3 m before, 0.7 m after, and every rig with an arm now uses the same one rather than a third getting it wrong.
- added
Instanced geometry can cast shadows. A caster sink takes a rigid mesh or an instanced batch, and one enumeration serves the sun's cascade and all six faces of every cubemap; the depth program shares its wind displacement with the visible one, so a caster bent by a gust and the picture of it cannot come apart. There had previously been no route at all by which an instanced batch could reach a depth pass.
- added
Fog with a real extinction law and a medium that has a height. The old curve was fixed-function exponential-squared, whose optical depth grows with the square of distance and which saturated at 99.9% by 200 m; density now thins with altitude and is integrated along the ray, which is the whole difference between looking along a valley and looking down a mountain. It lives in one shared GLSL block that every fogging program injects.
- added
A mix can be rendered rather than heard.
AudioGraphtakes the context it runs on and a clock saying where scheduled work lands, so an offline render produces the same mix with the same time constants and no wall clock anywhere in the chain.AmbientLooptakes the same clock — it read the context's own time, which offline stands still for the entire render, so every bed would have played a whole file at whatever the last frame computed. - added
MixLevels: one authority for the music and effects levels, given at construction rather than scheduled. A rendered mix opens at the level the caller set instead of gliding down to it from unity over the first third of a second. - added
Clip encoding on a timeline the caller chooses. Frames go in with their own index and come out stamped where they belong rather than when they arrived, so a slow machine makes a slower export and not a worse file — worst deviation from the ideal 1/60 grid, 0.3 microseconds. A rendered score reads back as a beat pulse too, so a light flashing on the kick is exact, and identical on every export of the same material.
- added
A wet film material: a blended pass whose per-vertex coverage fades to nothing at the rim, on patch geometry built from a curve's own frame so a spill lies on a banked surface instead of floating above it. A hard edge reads as a sticker whatever fills it, because a real spill is thinnest where it ends.
- added
A trample field — eight recent presses that decay, applied in the vertex shader from a single uniform upload a frame — so instanced foliage can be pressed down and recover without re-uploading its instance buffer.
- added
A pool for short-lived particles, for the effects a batch uploaded once cannot express: anything that sprays, bursts or dissipates.
- added
Box surfaces, composable with the ribbon surface, so a route can be made of pads as well as swept ribbon and both answer one ground query — including which deck is meant, since the query now takes the asker's own height and stops handing back the upper deck of an overpass.
- added
Four things a small self-contained lit scene drawn into an element's rectangle needed, each of whose absence had already produced a bug: a factory for an
Environment,Camera.lookAt, abeginInsetthat takes aDOMRectand does the device-pixel and axis-flip arithmetic itself, andcopyRegionTofor putting the result in front of a page's backdrop filter rather than behind it. - changed
Point shadows behave like light. Filter taps sit on a disk perpendicular to the light ray and the disk is rotated per fragment, sample directions are kept inside their own cube face, the depth comparison gains tolerance across a face seam, and occlusion fades with distance from its caster — crisp for two metres, readable for four, gone by eight, which is what a metre-wide flame does.
- changed
The device-pixel-ratio ceiling is 2. At 1.75 nothing rendered at native resolution: every retina panel reports 2, so the drawing buffer held 87% of the screen's pixels and the browser stretched the rest.
- fixed
Exactly one node reaches the audio destination. Send returns were connected straight to it while a recording tapped the master, so every captured mix carried the dry signal and none of the reverb or delay: the tail measured 0.00000 at the tap before and 0.03876 after. Not attenuated — absent.
- fixed
A suspended audio context is not a failure. The graph awaited a resume and treated the rejection — which is exactly what a browser that has not seen a tap gives you — as proof the device could not play sound, returning nothing and staying silent for the rest of the session.
- fixed
A band built from a descending lateral range came out inside-out, and under back-face culling an inside-out slab is invisible from outside: signed volume +256 one way round and −256 the other. Ranges are normalised where they are consumed, because a caller describing space has said nothing about winding.
- fixed
Array uniforms resolve under their bare name as well as the
name[0]WebGL reports. A lookup that misses writes to a null location, which throws nothing, warns nothing and writes nothing, so a whole per-frame effect can be computed and discarded in silence. - fixed
A transport fades before it stops. Stopping a source lands wherever its waveform happens to be, and the step from there to zero is a click at the head of everything that restarts one.
- fixed
Moving instances have to be uploaded. Scatter batches are placed once and deliberately not re-uploaded per frame, so anything else sharing that path drew from the positions its instances held at creation and only the count animated.
0.4.0 · 2026-08-02
- added
An offline beat map with bassline whitening, so a track's kicks are known before a frame of it plays.
- added
A camera that can be cut: framed shots held and switched between, rather than interpolated through the world.
- added
Frame capture to a video file where the browser supports it, and nothing at all where it does not.
- added
Text as real geometry — a pixel font extruded into the scene, lit, fogged and occluded like everything else in it.
- changed
The audio registry reads a manifest instead of fixed names, so a library of any size resolves without a code change.
0.3.0 · 2026-08-02
- added
Persistence is a seam. The caller supplies a
KeyValueStore, so a save can live in the browser, on a server, in a native shell or nowhere;PreferenceStorevalidates field by field andOnceSetholds one-time flags. - added
The audio graph: independent music and effects stages, a speed-driven master low-pass, reverb and delay sends, positional gain and pan, and synthesised fire, sea, wind and storm beds that loop without a seam.
- added
MessageQueue— one message at a time, most important first, deduped, bounded and interruptible. - added
A pausable fixed-timestep loop that drains banked simulation time rather than replaying it on resume.
- changed
Generic mobile controls, multi-subscriber device capture, a collision-safe third-person camera, celestial sampling, point-light selection and the throttled frame profiler all moved down out of the game, which keeps only its mappings and policy.
- fixed
The message queue reordered its own backlog every frame: peeking took the best candidate and pushed it back when it lost, so two equally important events came out in whichever order the frame boundary fell.
- fixed
Browser storage that accepts a read probe and silently discards writes now degrades to memory, instead of reporting a healthy store that loses every save.
0.2.0 · 2026-08-02
- added
One
WindField, sampled once a frame and passed to everything that moves in air, with accumulated drift for anything the wind positions rather than displaces. - added
Water as a wave simulation: a Gerstner surface reflecting the complete scene, with physical Fresnel, foam masking and angle-dependent transparency that soften with the swell instead of reading as polished glass.
- added
An underwater medium shared by the mesh, sky, water, fire and smoke passes, so depth changes colour, scattering and what a distant plume looks like.
- added
Instanced meshes and a flock renderer, both driven by the shared wind, and one plume renderer that fire and smoke are two instances of.
- added
A procedural moon with a curved terminator, limb shading, texture-free maria, earthshine and a phase-scaled halo, which becomes the dominant directional light below the sun's horizon.
- changed
Shadows: one map per light baked once, static maps that persist at any distance, a live crossfading map for movers, two peeled static depth layers so overlapping casters darken instead of replacing each other, and one shared filter budget.
- changed
RenderQualityOptionsis the single construction-time entrypoint for pixel ratio, shadow allocation, water and reflection resolution, filtering, the underwater atmosphere and plume detail. - changed
Collision broadphase is a spatial hash, flat in cost from forty colliders to eight thousand.
0.1.0 · 2026-08-02
- added
A WebGL2 flat-shaded renderer with merged static geometry, a procedural sky and a directional shadow map, and no texture files anywhere in it.
- added
Point lights with baked depth-cubemap shadows, each light carrying a physical size so a bulb throws a crisp shadow and a flame a soft one.
- added
Collision primitives: AABB sweep, axis-separated movement, segment hits and a collider set.
- added
A fixed-timestep loop whose rendering interpolates and never mutates simulation state.
- added
Input as devices and generic mechanisms — keyboard, pointer, a configurable virtual stick and a tap/hold/swipe recogniser — none of which name a gameplay verb.
- added
Seeded RNG, angle and colour maths, and a mesh builder that merges a world into a single draw call.