@driftengine/core 3.61.0 → 3.61.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.json +3516 -0
- package/package.json +4 -3
package/CHANGELOG.json
ADDED
|
@@ -0,0 +1,3516 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "3.61.1",
|
|
4
|
+
"date": "2026-09-13",
|
|
5
|
+
"entries": [
|
|
6
|
+
{
|
|
7
|
+
"kind": "fixed",
|
|
8
|
+
"short": "Three packages shipped without files a consumer needs; 3.61.0 should not be used.",
|
|
9
|
+
"text": "**`files` dropped four directories and two data files on the way to the registry.** `@driftengine/package` shipped without `bin/`, `assets/`, `android/` and `ios/` \u2014 so the `drift-package` command its own `bin` field declares was not in the tarball and the package could not run at all. `@driftengine/script` shipped without `capabilities.json`, which the DriftScript Vite plugin resolves through `@driftengine/script/capabilities.json`, so a consumer compiling `.drs` failed at config load. `@driftengine/core` now carries `CHANGELOG.json`, which a consumer imports to render engine release notes.\\n\\nNo engine code changed. The manifests did: the `files` array was rewritten when these packages were prepared for publication and it lost entries that had been there all along. Use 3.61.1."
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "3.61.0",
|
|
15
|
+
"date": "2026-09-12",
|
|
16
|
+
"entries": [
|
|
17
|
+
{
|
|
18
|
+
"kind": "fixed",
|
|
19
|
+
"short": "The lit shader is built to fit the fragment uniform grid the device reports, so a phone that could not link it at all now draws, with point shadows still compiled in.",
|
|
20
|
+
"text": "**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 \u2014 so `uniform float uLightRadius[16]` costs the same sixteen rows a `vec3` array of sixteen does. At the full budget that is **440 rows**. An Adreno 740 offers **256**, and WebGL2 guarantees any conforming implementation only **224**.\n\nOn that part the program did not link, the constructor threw, and an application that awaited `createRenderer` got no renderer, no reason and no frame \u2014 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.\n\nThe budget is a build-time size now. The renderer counts the shader it is about to compile \u2014 from the source, not from a constant somebody measured once \u2014 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.\n\n`maxLights` and `maxAreaLights` are the ceiling for a consumer who wants to choose, and `renderer.shadedLights` reports what was resolved. Size a `PointLightBuffer` from 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."
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "3.60.2",
|
|
26
|
+
"date": "2026-09-10",
|
|
27
|
+
"entries": [
|
|
28
|
+
{
|
|
29
|
+
"kind": "fixed",
|
|
30
|
+
"short": "A setting passed as undefined no longer deletes the default it was meant to fall back to. It turned a camera arm to NaN on its first step, and nothing in the type system could see it.",
|
|
31
|
+
"text": "**`{ ...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 explicitly `undefined` \u2014 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 is `NaN`.\n\nMeasured on the boom: a timing of `{ retractLambda: undefined }` takes the arm's fraction from 0.96 to `NaN` on 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 writes `undefined` into a field that holds a number.\n\n**Nothing in this repository could see it.** The flag that separates \u201cabsent\u201d from \u201cpresent and undefined\u201d is `exactOptionalPropertyTypes`, and this tree does not run it \u2014 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.\n\nThe merge is a function now rather than a spread, and it keeps a falsy value that somebody chose: zero, an empty string and `false` are values, not absence. One implementation, for the reason the boom's own header gives about the arm it smooths \u2014 a third caller must not be able to get this wrong again.\n\nHeld 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."
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "3.60.1",
|
|
37
|
+
"date": "2026-09-08",
|
|
38
|
+
"entries": [
|
|
39
|
+
{
|
|
40
|
+
"kind": "fixed",
|
|
41
|
+
"short": "A lockstep session no longer halts when a packet repeats a tick the world already played. Sessions carrying more redundancy than their rewind window is deep were ending on links that lost nothing.",
|
|
42
|
+
"text": "**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 \u201cnever seen\u201d, and a link that lost nothing ends the match.\n\nReported 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.\n\nThe 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.\n\n**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 `confirmed` looks 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.\n\nNothing 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."
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "3.60.0",
|
|
48
|
+
"date": "2026-09-05",
|
|
49
|
+
"entries": [
|
|
50
|
+
{
|
|
51
|
+
"kind": "added",
|
|
52
|
+
"short": "A character controller can be handed a command already resolved in three dimensions, instead of two axes flattened onto the ground under the body. A body that walks on walls turned late until now.",
|
|
53
|
+
"text": "**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.\n\nIt 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.\n\n`ControllerInput.projectMove: false` steers 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.\n\nHeld by five cases, one of which fails when the branch is made unreachable."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"kind": "added",
|
|
57
|
+
"short": "The third-person camera can be set up for a rig that is not several metres across: how fast its arm moves, what world it collides against, and whether it damps the roll it is handed.",
|
|
58
|
+
"text": "**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.\n\n`boomTiming` is those four numbers, and `Infinity` is the immediate answer with no special case to find: damping is `lerp(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.\n\n**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 `BoomObstruction` is 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.\n\n**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: false` takes it as given on the tick it is given.\n\nHeld 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."
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"version": "3.59.0",
|
|
64
|
+
"date": "2026-09-05",
|
|
65
|
+
"entries": [
|
|
66
|
+
{
|
|
67
|
+
"kind": "added",
|
|
68
|
+
"short": "The third-person camera can be told which way its subject faces. Until now the only way to frame a shot from behind was to guess an axis the rig never reported.",
|
|
69
|
+
"text": "**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 \u2014 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.\n\nWhat 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 \u2014 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.\n\n`setBoomForward(x, y, z)` orthogonalises against the current up exactly as the carried one is, and every later `setBoomUp` re-orthogonalises it the same way \u2014 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.**\n\nA 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 `setBoomUp` is deliberate: that method must produce some forward because the up is what changed, and here the forward is the argument \u2014 answering with a direction the caller did not ask for is the failure this exists to fix.\n\nThe 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."
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"version": "3.58.0",
|
|
75
|
+
"date": "2026-09-05",
|
|
76
|
+
"entries": [
|
|
77
|
+
{
|
|
78
|
+
"kind": "fixed",
|
|
79
|
+
"short": "Two-bone IK now solves a rig whose hierarchy carries a scale. It used to return the right length in the wrong direction and report success.",
|
|
80
|
+
"text": "**A rotation read out of a scaled matrix is a different rotation, not the same one at a different length.** `quat.fromMat3` recovers a rotation from the matrix trace by way of `sqrt(trace + 1)`; scaling the matrix scales the trace while that `+ 1` stays 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.\n\nThe 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.\n\nThe 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.\n\nThe 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 \u2014 44% of its body step spent grinding out an answer a triangle already has."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"kind": "added",
|
|
84
|
+
"short": "A character controller can be steered in any direction, not only in the world's horizontal plane. `ControllerInput` takes an optional third component.",
|
|
85
|
+
"text": "**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 \u2014 the input plane collapses onto a line.\n\nThe 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 \u2014 and every consumer with a non-vertical up would have done the same.\n\nOptional, 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."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"kind": "fixed",
|
|
89
|
+
"short": "The third-person camera aims in the same frame its arm is built in. On any surface that was not a floor it put the eye in the right place and looked somewhere else.",
|
|
90
|
+
"text": "**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 \u2212Z \u2014 a right angle apart. What it rendered was the scene beside the subject, with the subject nowhere in it and no error anywhere.\n\nThe 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.\n\n**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 \u2014 a hundred-millionth of a pixel across a 1080-line frame. Every existing consumer is in that case.\n\nThe 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 \u2014 and got the sign backwards first, which renders as a plausible picture of the wrong thing."
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"version": "3.57.2",
|
|
96
|
+
"date": "2026-09-05",
|
|
97
|
+
"entries": [
|
|
98
|
+
{
|
|
99
|
+
"kind": "fixed",
|
|
100
|
+
"short": "Two-bone IK puts a limb on its target in one call. It used to land short whenever the pole hint sat off the plane the limb was bending in, and reach the target only on a second call.",
|
|
101
|
+
"text": "**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.\n\nThe 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** \u2014 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.\n\nOff 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**.\n\nThe 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."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"kind": "changed",
|
|
105
|
+
"short": "A fixed step now says it may run more than once for the same tick, and what that means for anything it plays, sends or spawns.",
|
|
106
|
+
"text": "**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 \u2014 and everything it emitted the first time is emitted again. Snapshots put a world back; they cannot un-play a sound.\n\nReported 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.\n\nThe 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 \u2014 keep effects out of the fixed step, or gate them on a small table of what a tick already emitted \u2014 and name the watermark such a table is cleared behind.\n\nAlso 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."
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"version": "3.57.1",
|
|
112
|
+
"date": "2026-09-05",
|
|
113
|
+
"entries": [
|
|
114
|
+
{
|
|
115
|
+
"kind": "fixed",
|
|
116
|
+
"short": "A world asked for more physics workers than a machine can afford now says so. It used to report the number it settled on as the number you asked for, with nothing to explain the difference.",
|
|
117
|
+
"text": "**`PhysicsWorld` keeps 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 that `parallelism` then reported `requested: 1, running: 1, reason: ''` \u2014 every field agreeing with every other, and none of them saying three quarters of the request had been dropped.\n\nIts own type documents `requested` as \"workers the consumer asked for\" and `reason` as \"empty when `running` matches `requested`\", 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 the `workers` option took*, and on the most common shortfall there is it had nothing to say.\n\n`requested` is 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.\n\n**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 \u2014 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."
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"version": "3.57.0",
|
|
123
|
+
"date": "2026-09-05",
|
|
124
|
+
"entries": [
|
|
125
|
+
{
|
|
126
|
+
"kind": "added",
|
|
127
|
+
"short": "A global participating medium: `setGlobalMedium` fills the frustum with air the sun can light, and `globalMediumSteps` decides what that may cost. Off by default, and off costs nothing.",
|
|
128
|
+
"text": "**Volumetrics were a body inside a hull and nothing else.** `drawLightVolume` marches a cone you place, so a shaft through a window works and fog filling a room does not; `atmosphere.ts` fades 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.\n\n`setGlobalMedium(density, albedo, anisotropy, maxDistance)` is the per-frame dial and `globalMediumSteps` the construction-time ceiling \u2014 the split `bloom` and `setBloom` already draw. **At `density: 0` no 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. `globalMediumSteps` is 0 by default and a part `isWeakGpuFamily` names is clamped to 16, because a march is the only ceiling in `RenderQuality` that is a per-pixel loop.\n\nOne GLSL source for both backends. The march writes scattered light in rgb and transmittance in alpha, so the composite is `(ONE, SRC_ALPHA)` \u2014 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.\n\n**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.\n\n`scripts/medium-check.mjs` measures six claims on both backends, each written against the deletion that breaks it, and the six deletions were run. Extinction is checked against what `exp(-sigma d)` predicts rather than against a threshold: **1.41 measured against 1.41 predicted** on both backends."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"kind": "added",
|
|
132
|
+
"short": "`readSplatSog` reads the `.sog` container newer capture tools emit: a ZIP of lossless WebP images and a manifest.",
|
|
133
|
+
"text": "**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 `.ply` and it documents no on-disk structure. What capture tools write is the PlayCanvas container, version 2, and that is what this reads.\n\n**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 `browserWebpDecoder` is the ordinary implementation \u2014 the arrangement `KeyValueStore` and `BrowserStore` already have. `unbundleSog` reads the ZIP through its central directory, which is the only place the sizes are: `splat-transform` writes streaming, so every local header carries a size of zero.\n\nChecked against a bundle `@playcanvas/splat-transform` produced 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."
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"kind": "added",
|
|
137
|
+
"short": "`createImageTexelDecoder` reads an image's texels back exactly. Every other browser route premultiplies, which corrupts an image used as a lookup.",
|
|
138
|
+
"text": "**A 2D canvas stores colour premultiplied and WebCodecs decodes a WebP with alpha to a premultiplied `BGRA` frame**, so both return every RGB value through `round(round(c * a / 255) * 255 / a)`. For a picture that is invisible. For an image used as a lookup it is a corruption: a `.sog` capture 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.\n\nMeasured 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.mjs` is what found it, which is the argument for a check that runs the decoder a consumer actually calls.\n\nIt costs one pipeline stall per image, which Chrome logs and which is paid at load rather than per frame."
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"kind": "added",
|
|
142
|
+
"short": "`drift/render` gains `medium`, its seventh per-frame dial and still none of the fifty `RenderQuality` fields.",
|
|
143
|
+
"text": "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. `medium` takes every argument the engine defaults, for the reason `focus` gives about its own `scale`: 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."
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"kind": "changed",
|
|
147
|
+
"short": "The LTC fitter fits against polygon integrals now, which is what it is judged on. The shipped term's 71.8% becomes 8.8% on the placement the bound is set on.",
|
|
148
|
+
"text": "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.\n\n**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 \u2014 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.\n\n**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 \u2014 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.\n\nWhat 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.\n\n**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.m00` where the fits are keyed `k00`, 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 \u2014 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."
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"version": "3.56.2",
|
|
154
|
+
"date": "2026-09-05",
|
|
155
|
+
"entries": [
|
|
156
|
+
{
|
|
157
|
+
"kind": "fixed",
|
|
158
|
+
"short": "Importing `PhysicsWorld` shipped a 12 KB gzipped island worker into bundles that could never fetch it. The pool factory moved behind its own specifier.",
|
|
159
|
+
"text": "**A bundler emits a worker before it decides the code is unreachable.** Vite's `vite:worker-import-meta-url` rewrites `new Worker(new URL(...))` at *transform* time, so the barrel re-exporting `createIslandPool` put the island worker in the module graph, and the chunk was written to disk whether or not anything could reach it.\n\nReported by a consumer and reproduced on a second the same day: **37,913 bytes raw, 12,259 gzipped**, in builds where `WorkerPoolExecutor`, `StagedExecutor`, `createIslandPool` and `SolveStage` were absent from every other chunk and nothing under `dist/` named the worker. Tree-shaking had done its half and the asset was already there.\n\n**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 `PhysicsWorld` reach 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.\n\n`createIslandPool` and `WorkerPoolExecutor` are at `@driftengine/physics/src/workers.ts` now. 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.\n\n**Every gate in this repository was green while it shipped**, including the one that asks whether a worker compiles. `worker-entry.test.mjs` now 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."
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"version": "3.56.1",
|
|
165
|
+
"date": "2026-09-05",
|
|
166
|
+
"entries": [
|
|
167
|
+
{
|
|
168
|
+
"kind": "fixed",
|
|
169
|
+
"short": "`npm run build` failed on the new XR package, which shipped without a build config. A gate now checks every package has one.",
|
|
170
|
+
"text": "**Every gate in the documented list was green and `npm run build` was not.** `@driftengine/xr` shipped without a `tsconfig.build.json`, so the Node build answered `TS5058` on it while `typecheck`, `test:scripts` and 4,394 tests all passed. The build is deliberately not in that list, because a consumer that bundles resolves `main` to `src/index.ts` and needs no build at all, and running eighteen of them on every change would buy nothing most days.\n\nWhat the absence costs is cheap to catch without paying that: a missing file is not a compilation error. `packages.test.mjs` checks every package has the config, beside the check that every package has a README. Whether a build *succeeds* is still `npm run cleanroom`'s question."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"kind": "fixed",
|
|
174
|
+
"short": "The island worker pool's sentinel never fired when the pool shipped, because the class was named around it, so the README went on listing the pool as missing.",
|
|
175
|
+
"text": "**This is the failure the AI bridges' own release notes described, committed one release earlier in the same repository.** `docs/CAPABILITIES.md` carried `worker-pool class WorkerExecutor|workerIslandPool`, and 3.54.0 shipped the class as `WorkerPoolExecutor`. `class WorkerExecutor` does not match `class 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.\n\nThe 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."
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"kind": "changed",
|
|
179
|
+
"short": "README's gap list is rewritten. Every roadmap track is complete, so what is left is two unscheduled rendering ideas and two things written but never run.",
|
|
180
|
+
"text": "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.\n\nWhat 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 \u2014 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.\n\nAnd 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."
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"version": "3.56.0",
|
|
186
|
+
"date": "2026-09-05",
|
|
187
|
+
"entries": [
|
|
188
|
+
{
|
|
189
|
+
"kind": "added",
|
|
190
|
+
"short": "WebXR: sessions over both backends, stereo, controller input and hand joints, as `@driftengine/xr`. A game that never enters a session carries none of it.",
|
|
191
|
+
"text": "**Track I, the last one, and the only one that started from nothing.** No `requestSession`, no `XRSession` and no stereo path existed anywhere in this repository before it.\n\n`enterXr` takes a session, builds a layer, asks for the best reference space it can get, and hands back a frame source. `local-floor` first and `local` second, because `local` puts the origin where the headset started and stands a player's feet wherever their head happened to be.\n\n**Both layer paths are built.** `XRGPUBinding` with a projection layer where a runtime has one, `XRWebGLLayer` otherwise, 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.\n\n**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.\n\nControllers 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."
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"kind": "added",
|
|
195
|
+
"short": "`Camera.adoptView` takes a view and a projection computed elsewhere, because an XR eye is an off-axis frustum and `mat4.perspective` cannot produce one.",
|
|
196
|
+
"text": "**The camera had to learn to be supplied rather than configured, and it had already written down why.** `updateMatrices` derives the view from yaw and pitch and the projection from `mat4.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, which `perspective` cannot produce.*\n\nA 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.\n\nSo `adoptView` copies both, recomputes the combined and inverse matrices so frustum culling, picking and the sky keep working unchanged, and **decomposes `position` and `forward` back out of the view**. That last part is not a convenience: a consumer reading `camera.position` to 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`, `pitch` and `roll` are deliberately left alone, because a supplied rotation may be one no Euler triple describes without a convention the class does not own.\n\n`startLoop` gained an optional `frameSource`, so a session drives frames on the headset's clock through its own `requestAnimationFrame`, and the frame reaches `render` as a fourth argument typed `unknown`: core has no business naming an `XRFrame`, and an implementation written before the parameter existed still satisfies the interface."
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"kind": "added",
|
|
200
|
+
"short": "`drift/xr` binds twelve capabilities and was the last surface the linker refused. Every module the language specifies is now described by this host.",
|
|
201
|
+
"text": "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.\n\n**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 `@deterministic` system reading either would take the other branch on replay and a stored run would stop meaning anything. `drift/ui` settled the same question for a pointer and the reasoning transfers unchanged.\n\n**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.\n\nNumbers answer `f32` rather than `float`, because the language refuses a width-polymorphic return with no `float` parameter to fix it against, and every position this engine stores is a `Float32Array`.\n\nTwo 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."
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"kind": "changed",
|
|
205
|
+
"short": "The XR verification is partial and says so. No `XRFrame` has ever been produced against this code, because a machine with no headset cannot make a context XR compatible.",
|
|
206
|
+
"text": "**Measured before any of it was designed**, in Chrome 151 on Linux with no headset: `navigator.xr` is present, `isSessionSupported('inline')` is true, `requestSession('inline')` succeeds, `requestReferenceSpace('viewer')` succeeds, and then `gl.makeXRCompatible()` throws `InvalidStateError`. 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.\n\n`npm run check:xr` asserts the thirteen things that machine does reach: detection, the specification's classes, a real inline session requested and ended with a reference space granted, `XRGPUBinding` present 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.\n\nEverything 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.\n\n**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."
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"version": "3.55.1",
|
|
212
|
+
"date": "2026-09-05",
|
|
213
|
+
"entries": [
|
|
214
|
+
{
|
|
215
|
+
"kind": "fixed",
|
|
216
|
+
"short": "The island worker entry had a top-level await, which broke every consumer's production build even when they never started a pool. Vite compiles a detected worker as an IIFE.",
|
|
217
|
+
"text": "**A consumer could not build at all, and every engine gate was green.** `workerPool.ts` builds its worker with `new 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 default `worker.format` is `iife`, and an IIFE cannot carry a top-level await:\n\n [vite:worker-import-meta-url] Module format \"iife\" does not support top-level await\n\nThe 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:scripts` and `check:pool` were all green over it, because `check:pool` drives a **dev server** and a dev server serves module workers. The first thing to see it was a consumer's `vite build`.\n\nThe Node branch attaches its listener in a microtask now. Nothing is missed: a Node `MessagePort` queues messages until `on('message')` attaches, so a `postMessage` issued before it resolves is delivered when it does.\n\n**`scripts/worker-entry.test.mjs` is 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 string `await` would have passed a worker that broke an IIFE some other way. Entries are found by reading `new 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."
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"version": "3.55.0",
|
|
223
|
+
"date": "2026-09-05",
|
|
224
|
+
"entries": [
|
|
225
|
+
{
|
|
226
|
+
"kind": "added",
|
|
227
|
+
"short": "`navigationBridge` gives an agent a `navigate@1` tool whose guard runs the search, so a destination stranded between a model proposing it and the agent acting on it is discarded.",
|
|
228
|
+
"text": "**The first tool in this package whose `admits` says something.** A buffered intent is a proposal authored against one snapshot and executed later \u2014 the README calls it \"a proposal, never a decision\" \u2014 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.\n\n**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.ts` says a guard is \"the consumer's sentence, written against the consumer's world\", and that stays true: `graphOf`, `positionOf` and `pathOf` are theirs and everything above them is the bridge's.\n\n**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: `take` guards the buffered intent, `applyCommand` guards it again on the way in \u2014 which `apply.ts` argues for at length, because those are two different moments and a snapshot is never authority \u2014 and `execute` runs 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.\n\n`reachableBy` asks the same question without acting, which is what a policy scoring a destination needs and what `route` could not give it."
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"kind": "added",
|
|
232
|
+
"short": "`AuthoritativeAgent` takes an agent's model decisions on one peer and replays them everywhere else. A participant never reaches a provider, and a rewind issues nothing.",
|
|
233
|
+
"text": "**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.\n\n**The design got smaller by reading what was already here.** Its spec proposed replicating a `u16` intent id on Track J's input path, since `inputLog.ts` is fixed-width per participant per tick and refuses variable-length payloads in writing. But `CommandLog` already records what a model decided and when it crossed, and `ReplaySession` already 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.\n\nWhat is left is the part that is genuinely about networking. Which side decides \u2014 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.\n\nThe rollback rule is Track J's own argument applied here. `network.write` stays outside `DETERMINISTIC_EFFECTS` because of the rewind loop rather than the send, since a `@deterministic` function is precisely the kind that gets re-run. An inference call is not idempotent either, and unlike a send it is billed."
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"kind": "added",
|
|
237
|
+
"short": "Four `drift/ai` capabilities reach both bridges from a script: `reachable`, `navigate`, `path` and `deciding`. No language release, and none duplicates `drift/navigation`.",
|
|
238
|
+
"text": "`drift/ai` was 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.\n\n**`drift/navigation` binds twelve and every one takes a `NavPath` or a `NavGraph`**, so what a script could not do was aim any of them at an `Agent`, which is a session keyed by an id the consumer registered rather than an entity. `path` is the join: one capability makes all twelve work on an agent instead of any being bound a second time. It is not a second `navigation.path`, which takes an ECS entity \u2014 same output, different door.\n\n**Every effect is inherited from a decision already made.** `reachable` is `navigation.read` and is the guard as a question, which matters because `route` computes *and writes* and a script scoring a destination had to route and undo. `navigate` is `navigation.write` and 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. `deciding` is `network.read` on the narrowing `drift/network.authority` already uses, that a role cannot vary with packet timing.\n\n**`ai.navigate` runs on every peer and that does not contradict `deciding`.** 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.\n\n**Nothing hands a script the tools or arguments a model chose.** `intentId` is bound and safe because the floor put it there and a replay recomputes it; a capability returning the model's action would let a `@deterministic` system branch on a provider's answer and take the other branch on replay. A host test asserts that absence."
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"kind": "changed",
|
|
242
|
+
"short": "Both bridges were refused in writing for reasons that stopped being true on 2026-09-03. The sentinels stayed armed and correct while the prose above them went stale.",
|
|
243
|
+
"text": "`CAPABILITIES.md` said 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 \u2014 the same failure `drift/render` carried for five releases.\n\nThe exports are named `navigationBridge`, `AI_NETWORK_AUTHORITY` and `replicateDecision` **deliberately, 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.\n\n`npm run check:ai-bridges` is 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.\n\n**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 RUN` for it rather than skipping quietly, and no document says it passed."
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"version": "3.54.0",
|
|
249
|
+
"date": "2026-09-05",
|
|
250
|
+
"entries": [
|
|
251
|
+
{
|
|
252
|
+
"kind": "added",
|
|
253
|
+
"short": "Islands solve on worker threads when a consumer asks: `new PhysicsWorld({ workers: 4, pool: createIslandPool })`. Measured 1.39x at eight workers, and serial with a reason wherever it cannot start.",
|
|
254
|
+
"text": "**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 `ShuffledExecutor` has been asserting that property since. What kept a pool out was never the engine.\n\nIslands are claimed from an `Atomics.add` cursor, which balances load and costs nothing in determinism precisely because the islands are disjoint. Workers park on `Atomics.wait`. The caller claims from the same cursor and then joins, blocking where it may and spinning where it may not, decided by trying `Atomics.wait` and catching rather than by sniffing for `window` \u2014 so what is left to wait for is the imbalance and not the solve.\n\n**`pool` is passed in rather than imported by `PhysicsWorld`, for a measured reason.** A world that reached `createIslandPool` on 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.\n\n**Read `parallelism` instead 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: 0` with correct serial physics is the design working. A world with a pool must be `dispose`d, which is a new method, because its workers hold the staging buffer and every body's state with it.\n\n**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.ts` predicted 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, and `total island cost / largest island cost`, which is why a hundred jointed boxes gain nothing at any worker count.\n\n**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 like `physics.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."
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"kind": "changed",
|
|
258
|
+
"short": "`Cross-Origin-Embedder-Policy: credentialless` grants the isolation a pool needs without breaking cross-origin resources, measured in Chrome 151. The documented price was higher than the real one.",
|
|
259
|
+
"text": "**The row's own blocker was priced from memory and is cheaper than it said.** `executor.ts` has recorded since 2026-08-27 that a pool costs `require-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.\n\n`credentialless` grants the same cross-origin isolation and lets those resources load, sent without credentials. `npm run check:pool` measures 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.\n\nThe 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.wait` is 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."
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"kind": "added",
|
|
263
|
+
"short": "`DisjointExecutor` fails when two islands write the same body. It needs no threads and no timing, and it is the only test here that would notice the pool's central assumption breaking.",
|
|
264
|
+
"text": "**The premise a pool cannot survive losing, turned into something that fails.** Every write inside the island solve is guarded by `invMass > 0` or walks `bodyOrder`, which `island.ts` fills 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.\n\nOne 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 `-0` over `0` is a write.\n\nIts 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."
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"kind": "fixed",
|
|
268
|
+
"short": "`npm run test:scripts` ran none of the package tests more than one directory deep. Eleven already-written, already-passing tests had never run under it.",
|
|
269
|
+
"text": "The script passed `packages/*/src/**/*.test.mjs` unquoted, and POSIX `sh` expands `**` as `*`, so only files exactly one directory below `src` were ever reached. `packages/ai/src/package.test.mjs` and `packages/package/src/cli.test.mjs` had never run under this command. Quoted, Node expands the pattern itself: 197 tests to 214, all green.\n\nAlso re-measured `physics-only`, which was 2.7% above its floor on `main` before this branch and had never tripped the 3% gate \u2014 the failure the floors file's own header describes, hit for the second time on the same entry."
|
|
270
|
+
}
|
|
271
|
+
]
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"version": "3.53.0",
|
|
275
|
+
"date": "2026-09-04",
|
|
276
|
+
"entries": [
|
|
277
|
+
{
|
|
278
|
+
"kind": "added",
|
|
279
|
+
"short": "A script drives six per-frame presentation dials through `drift/render`: bloom, exposure, motion blur, speed blur, focus and the frame veil. The linker had refused the module by name since 1.0.",
|
|
280
|
+
"text": "**`drift/render` is 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 while `host.test.ts` had already been corrected to say the surface waited on a row of its own.\n\n**It binds the dials and none of the profile.** `RenderQuality` has 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.\n\n**Nothing reads a dial back, and that is the same argument.** Each is clamped against the ceiling \u2014 `setBloom` does nothing at all when the profile's `bloom` is 0, because the chain is never built \u2014 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, for `network.read` and a confirmed-input watermark.\n\n**The effect is `scene.write` and it needed no language release.** That effect is outside `DETERMINISTIC_EFFECTS` because 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 `@deterministic` system may not dim the screen, which is the property rather than a limitation.\n\n`speedBlur` is the one capability whose name is not the engine's own: the method is `setSpeedRush`, and a rush is a thing that happens in somebody's game rather than a description of an image operation."
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"kind": "changed",
|
|
284
|
+
"short": "All twenty-nine checks were audited by deleting the term each measures. Fifty-seven claims across eighteen scripts could not see their own subject removed.",
|
|
285
|
+
"text": "**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.\n\n**Two classes of blind claim came out of it, neither visible from reading the source.** A *parity* claim \u2014 \"the backends agree\" \u2014 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 \u2014 \"glows evenly\", \"rises without stepping back\", \"the frame is identical pixel for pixel\" \u2014 passes when the shape is absent, because nothing is perfectly even and nothing steps back if nothing steps.\n\n**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 \u2014 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.\n\nNine scripts were already sound, and their claims caught the mutation on the term they name. One asserts nothing and is renamed rather than repaired.\n\n**`scripts/claimAudit.sh` runs the chain and `scripts/claimCensus.mjs` counts 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.\n\n**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 \u2014 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."
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"kind": "changed",
|
|
289
|
+
"short": "The depth precision diagnostic is a survey now. It reports measurements and makes no claim, which is what it always did and not what its old name suggested.",
|
|
290
|
+
"text": "It reports what `EXT_clip_control` and `depth32float` this 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."
|
|
291
|
+
}
|
|
292
|
+
]
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"version": "3.52.0",
|
|
296
|
+
"date": "2026-09-04",
|
|
297
|
+
"entries": [
|
|
298
|
+
{
|
|
299
|
+
"kind": "fixed",
|
|
300
|
+
"short": "An area light's highlight is the lobe integrated over the rectangle now. A smooth surface facing one reflected almost nothing before, and a rough surface head-on was several times too bright.",
|
|
301
|
+
"text": "**The specular half of an area light was losing the reflection rather than blurring it.** It handed the closest point on the rectangle to `sphereLobe` and 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.\n\nIt 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.\n\n**A linearly transformed cosine with an analytic matrix rather than a fitted one.** `quadCoverage` says 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.\n\n**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%.\n\n**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.\n\n**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."
|
|
302
|
+
}
|
|
303
|
+
]
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"version": "3.51.2",
|
|
307
|
+
"date": "2026-09-04",
|
|
308
|
+
"entries": [
|
|
309
|
+
{
|
|
310
|
+
"kind": "fixed",
|
|
311
|
+
"short": "Drawing a recorded scene into a mirror or a probe binds each material once for the run of draws that share it, so a busy frame stops losing geometry to the material budget.",
|
|
312
|
+
"text": "**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 `drawSceneCasters` bound one per entry.\n\nOn 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 \u2014 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 `useProgram` and a whole material write for every flat program.\n\nBoth 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.\n\n**The standing material is forgotten at the start of every `drawSceneCasters` call**, because what the pass around it last set is not the sink's to assume \u2014 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.\n\nThe consumer that reported it had written a deduping sink of its own over the public verbs to get this, and can delete it."
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"version": "3.51.1",
|
|
318
|
+
"date": "2026-09-04",
|
|
319
|
+
"entries": [
|
|
320
|
+
{
|
|
321
|
+
"kind": "fixed",
|
|
322
|
+
"short": "3.51.0 closed every package. Adding exports stops every undeclared path resolving, so a consumer reaching a deep path lost it; the packages that were open are open again.",
|
|
323
|
+
"text": "**`exports` does 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 in `exports` stops resolving, the workspace symlink keeps resolving it locally, and only a consumer's build fails.\n\nA consumer broke on three paths it had always used \u2014 `core/package.json`, `core/CHANGELOG.json`, and a module under `core/src/` \u2014 with `TS2307` and a failed `vite build`.\n\nEvery package that declared no `exports` before 3.51.0 now carries `\"./*\": \"./*\"`, which is exactly what the absence of the field used to give. `chemistry` and `script` declared `exports` already and were closed already, so they keep the surface they had. `scripts/packages.test.mjs` asserts it, because the failure is invisible from here.\n\n`scripts/cleanroom.mjs` skips pattern keys now: it was treating `\"./*\"` as an entry point and reporting fifteen failures for specifiers nobody can import."
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"kind": "changed",
|
|
327
|
+
"short": "A project that typechecks against the engine's own source needs allowImportingTsExtensions enabled in its tsconfig. It is legal beside noEmit, which a typecheck-only config already sets.",
|
|
328
|
+
"text": "**Porting note, and 3.51.0 should have carried it.** Every relative import inside these packages names `.ts` since 3.51.0. A consumer that resolves `@driftengine/*` to `src/index.ts` \u2014 which is what `main` and the `default` condition give a bundler \u2014 has the compiler follow into that source, and `tsc` refuses a `.ts` specifier unless the flag is set: `TS5097`.\n\nAdd it to the tsconfig that typechecks:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true } }\n```\n\nIt is legal beside `noEmit` or `emitDeclarationOnly`, 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.\n\nNothing about the runtime changes: a bundler resolves and strips the same source it always did.\n\n**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: `allowImportingTsExtensions` is legal beside `noEmit`, and legal without it only when `rewriteRelativeImportExtensions` is set too. A build script running `tsc` over a single module of this source to produce JavaScript is not a typecheck and cannot set `noEmit`, so it needs both:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true, \"rewriteRelativeImportExtensions\": true } }\n```\n\nThat is the same pair `npm run build` uses, and it rewrites each specifier as it emits. The failure without it is the same `TS5097`, from a command nobody thinks of as a typecheck."
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"version": "3.51.0",
|
|
334
|
+
"date": "2026-09-04",
|
|
335
|
+
"entries": [
|
|
336
|
+
{
|
|
337
|
+
"kind": "added",
|
|
338
|
+
"short": "These packages load from Node without a bundler. npm run build emits JavaScript, exports sends Node to it, and a bundler still reads src so an edit stays live in a consumer.",
|
|
339
|
+
"text": "**Node could not load this engine at all, and the reason was never the specifiers.** The packages ship TypeScript, `main` points at `src/index.ts`, and a bundler resolves it \u2014 but Node refuses to strip types for any file under `node_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.\n\n**`npm run build` emits `dist` with `tsc` and `rewriteRelativeImportExtensions`** \u2014 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.\n\n**`exports` carries both readers at once, and that is measured rather than assumed.** `node` resolves `dist`, `default` resolves `src`: 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.\n\n**A private `drift-source` condition keeps this repository reading its own source.** Packages import each other by name, and vitest, `tsx` and `tsc` all run in Node \u2014 so without it the suite would test a `dist` nobody 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 needs `customConditions` in the root tsconfig, which is one decision written in two files.\n\nEvery relative import inside the packages now names `.ts`, which finishes a migration rather than starting one: `chemistry`, `ai` and `entities` already did and nothing else did. Nothing is published \u2014 these packages stay private, and what changed is that a `file:` consumer can load them from Node."
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"kind": "fixed",
|
|
343
|
+
"short": "Every package tarball shipped zero built files, because dist is in .gitignore and npm honours it. Nothing inside the workspace could have seen it.",
|
|
344
|
+
"text": "**The build worked, the tests passed, and the tarball contained no JavaScript.** `dist/` is in `.gitignore`, npm honours a `.gitignore` when a package declares no `files` and no `.npmignore`, and every package declared neither. `npm pack` on `@driftengine/drft` produced 42 source files and zero built ones.\n\nThis is the defect `npm run cleanroom` exists 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 no `node_modules` segment, so neither Node's refusal to strip types nor a missing `dist` ever fires locally. The clean room packs every package, extracts it into a real `node_modules` outside the checkout, and imports **25 entry points under plain `node`** \u2014 the only arrangement in which the answer means anything.\n\nEach package declares `files` now. `driftscript` learned the same lesson by publishing, where five of seven consumption paths failed while all 892 of its tests passed."
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"version": "3.50.0",
|
|
350
|
+
"date": "2026-09-03",
|
|
351
|
+
"entries": [
|
|
352
|
+
{
|
|
353
|
+
"kind": "added",
|
|
354
|
+
"short": "Convex decomposition, offline: decomposeConvex turns a triangle mesh into hulls, BodyDesc.shapes makes them one rigid body, and COLL carries them out of a bake.",
|
|
355
|
+
"text": "**A concave thing could not move.** `addBody` refused a triangle mesh on anything but a static body and told the caller to use convex decomposition into hulls, *\"which the runtime already accepts\"* \u2014 and `BodySet.shape` held one shape per body, so a decomposed body had nowhere to go. The sentence was true of the kinematic sweep, which has taken `Body.parts` all along, and false of the dynamics it was written in. This row is the three pieces that make it true.\n\n**`decomposeConvex` is 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 \u2014 inside `hullShape`'s cap of 64.\n\n**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 \u2014 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.\n\n**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 \u2014 and a crate, a wedge, a rock and a wheel are all that case.\n\n**`BodyDesc.shapes` is where the hulls go.** A part carries no offset and no rotation, because a `ConvexShape` is already a point cloud in the body's frame. Mass is `combineMassProperties`: 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 to `MAX_BODY_PARTS` so 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.\n\n**`COLL` is 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/drft` depends on nothing. `drft bake --collider hull[:N] | box | none` writes it, and prefers a vehicle folder's `collider.kn5` over the model's own geometry.\n\nMeasured 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-only` rises 4,847 to 5,146 bytes; `physics-only` holds."
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"kind": "fixed",
|
|
359
|
+
"short": "The voxel fill walked out through a model's own base at four resolutions of eight, because a face lying exactly on a cell boundary was marked into neither of the two cells it touches.",
|
|
360
|
+
"text": "**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 \u2014 correct at four of them and the bare shell at the other four, with nothing in the shape to say which.\n\nA 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.\n\nThe 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.\n\n**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."
|
|
361
|
+
}
|
|
362
|
+
]
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"version": "3.49.0",
|
|
366
|
+
"date": "2026-09-03",
|
|
367
|
+
"entries": [
|
|
368
|
+
{
|
|
369
|
+
"kind": "added",
|
|
370
|
+
"short": "Environment probes stand on a grid. ProbeGrid and nearestProbes place them; the probe binding became an octahedral array carrying reflection and ambient, so a grid costs no texture unit.",
|
|
371
|
+
"text": "**One environment used to light everything equally, and now a surface takes the light of where it stands.** `ProbeGrid` places probes on a lattice and `nearestProbes` returns 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.\n\n**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 \u2014 GLSL ES cannot index a sampler array with a non-constant expression, so a grid of cubes needs a `samplerCube` each and a branch chain over them. The probe's binding became a `TEXTURE_2D_ARRAY` of 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.\n\n**That retired the spherical-harmonic path outright.** `irradianceSh.ts` and the WebGPU readback are gone, with the synchronous `readPixels` in 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 \u2014 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.\n\n**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 is `LINEAR` and 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.\n\n**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.\n\nProbes cost layers rather than units, capped at 64 \u2014 683 KB a layer, 43.7 MB for the lot, against the point-shadow array's 92.3 MB. `ProbeBakeOptions.irradiance` keeps its meaning and narrows from per bake to per grid. **21,495 gzipped bytes on `core-only`, 3.33%**, of which the generated WGSL is 16,502."
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"kind": "fixed",
|
|
375
|
+
"short": "The octahedral shader chunk had been compiled only into permutations with point shadows since it was written, because an earlier chunk opens a conditional a later one closes.",
|
|
376
|
+
"text": "**Its own comment said it was outside every `#if` and it was not.** `LOBES_GLSL` opens a conditional that `DIRECTIONALSHADOW_GLSL` closes, so every chunk between them is inside it \u2014 and `octEncode` and `octDecode` sat there, compiled into the eight permutations with point shadows and absent from the other eight.\n\nNothing 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.\n\nFound by counting `#if` against `#endif` per chunk rather than by reading them, which is the only way this class of fault is visible at all."
|
|
377
|
+
}
|
|
378
|
+
]
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"version": "3.48.1",
|
|
382
|
+
"date": "2026-09-03",
|
|
383
|
+
"entries": [
|
|
384
|
+
{
|
|
385
|
+
"kind": "fixed",
|
|
386
|
+
"short": "SceneCasterMaterial and SceneCasters are exported from the barrel, so a consumer implementing ShadowCasterSink can name the material its methods are obliged to accept.",
|
|
387
|
+
"text": "**One export, and it was missing for two releases.** 3.46.0 widened `ShadowCasterSink.mesh` and `.skinnedMesh` to take a `SceneCasterMaterial` so a caster enumeration could feed a colour pass, declared that type in `render/shadowCasters.ts`, exported it *from that module*, and did not add it to `src/index.ts` \u2014 where `ShadowCasterSink` and `ShadowCasters` beside it both were. The barrel is the entire public surface, which `scripts/docs-api.mjs` states in as many words, so a consumer implementing the interface could accept the material and had no way to name it.\n\nThe 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 \u2014 *Module `\"@driftengine/core\"` has no exported member `SceneCasterMaterial`*. Their workaround was `Parameters<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.\n\n**`SceneCasters` goes with it, for the same reason rather than a different one.** That alias exists to carry the contract `drawSceneCasters` documents 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.\n\n**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: `instanced` is 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.\n\nGuarded now rather than noticed again. `render/shadowCasters.test.ts` implements a sink using only names the barrel publishes, and the assertion is that the file compiles \u2014 a type that is not exported is a `tsc` error and nothing at all at run time, so `npm run typecheck` is the instrument. It reproduces the reported message on the exact line the report quoted when either export is taken back out."
|
|
388
|
+
}
|
|
389
|
+
]
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"version": "3.48.0",
|
|
393
|
+
"date": "2026-09-03",
|
|
394
|
+
"entries": [
|
|
395
|
+
{
|
|
396
|
+
"kind": "added",
|
|
397
|
+
"short": "Track J: a transport seam, identity-preserving snapshots, one rewind core under both lockstep and an authoritative host, and the two script surfaces. @driftengine/network, 2.3 KB standalone.",
|
|
398
|
+
"text": "**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. `RewindLoop` is that, and `LockstepSession`, `AuthorityHost` and `PredictingClient` sit on it.\n\n**A rewind snapshot is not a save file, and the difference is identity.** `serializeWorld` carries 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. `WorldSnapshot` preserves handles exactly, generations included, and costs 34,816 bytes for a thousand entities over two components.\n\n**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.\n\n**Three transports and three roles.** `Transport` is a caller's, per `AGENTS.md`; a loopback whose latency, jitter, loss, reordering and duplication all come from a seed, a WebSocket, and a WebRTC data channel configured `ordered: false` with `maxRetransmits: 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.\n\n**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.\n\n**`Sim` is 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, in `scripts/exactness-cross.mjs` and in the browser check, because a feature nothing turns on is written rather than ported."
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"kind": "fixed",
|
|
402
|
+
"short": "The determinism gate scanned one package while three documents called it engine-wide. Widened to thirteen roots, it found twenty unexempted calls and two defects.",
|
|
403
|
+
"text": "**`scripts/determinism.mjs` was written for Track B and walked `packages/physics/src` and 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, while `CAPABILITIES.md`, `AGENTS.md` and the physics README all described the determinism contract as a property of the engine.\n\n**Two of the twenty were defects rather than untidiness.** `packages/script/src/bindings/terrain.ts` returned a slope angle from `Math.acos` to a script, and `drift/terrain` ships under `physics.read`, which is inside the language's `DETERMINISTIC_EFFECTS` \u2014 so a `@deterministic` system 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. And `packages/terrain/src/heightfield.ts` computed a surface normal with `Math.hypot`, breaking a rule physics documents at five separate sites, in a query a consumer may call every tick.\n\n**The scanner could not see the exponentiation operator**, which is `Math.pow` wearing punctuation, and `joints.ts` used 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 to `packages/script` made false twice over.\n\n**`math/exact.ts` supplies four transcendentals built from arithmetic and `sqrt` alone** \u2014 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* [-\u03c0, \u03c0], where no range reduction is involved, and golden bit patterns now pin them."
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
"kind": "changed",
|
|
407
|
+
"short": "startLoop numbers its ticks and hands the number to simulate, mulberry32 gains a savable position, and every size floor was re-measured after all twenty-two were found stale.",
|
|
408
|
+
"text": "**The loop knew the fixed-step count and kept it**, so anything needing one \u2014 a recording, a replay, anything networked \u2014 kept a second counter beside `simulate` and trusted the two to stay in step. `simulate(dt, tick)` takes it as an additive second argument and `LoopOptions.startTick` is 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.\n\n**`savableMulberry32` exposes the position `mulberry32` closes over**, as one integer, so it stores beside a tick in a snapshot. Its sequence is asserted draw-for-draw against `mulberry32` over six seeds, because `AGENTS.md` makes that sequence a contract about every stored ghost and replay.\n\n**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/terrain` said 0.8 KB and measures 1.4 \u2014 a figure `ROADMAP.md` has had right since the day terrain shipped. The drift was attributed by A/B rather than apportioned: `core-only` was unchanged by this track, `core-and-audio` is byte-identical raw with and without `math/exact.ts` exported from core's barrel, and `exactAcos` accounts for 435 of `core-and-script`'s 3,194."
|
|
409
|
+
}
|
|
410
|
+
]
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"version": "3.47.0",
|
|
414
|
+
"date": "2026-09-03",
|
|
415
|
+
"entries": [
|
|
416
|
+
{
|
|
417
|
+
"kind": "added",
|
|
418
|
+
"short": "CharacterController takes its up as a vector, settable per tick and defaulting to world up, so a body can stand on a wall or a ceiling. Gravity keeps a direction of its own.",
|
|
419
|
+
"text": "**The controller could not represent a body standing on a wall. Not badly, at all: there was no orientation to give it.** `velY` was the only representation of vertical velocity, `gravity` was a scalar applied to it, `slopeCos` was measured against world Y, `stepHeight` was a height only because up was fixed, and `GROUNDED`, `AIRBORNE` and `SLIDING` were decided by a downward probe. Nothing in the file was wrong. All of it assumed one axis.\n\n**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.\n\n**`setUp` takes 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 what `velY` used to be, exposed as `speedAlongUp`, 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.\n\n**Gravity keeps a direction of its own, because a support normal and a fall direction are different questions.** It follows up until `setGravityDirection` is 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.\n\n**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 \u2014 with the rotation it rests 0.95 m clear of the face, which is `halfHeight + radius` exactly as on a floor, and without it 0.35 m, which is a radius.\n\n**What a `GroundProbe` cannot 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. `standOnSurface` is 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.\n\nWhat 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 \u2014 a parameter that means something in only one game does not belong here."
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"kind": "added",
|
|
423
|
+
"short": "ThirdPersonCamera builds its arm in a basis rather than swinging it about world Y, so a rig can orbit a banked surface. Yaw and pitch become angles inside the frame.",
|
|
424
|
+
"text": "**The rig could already look banked and could not orbit a banked surface.** The arm was a spherical boom about world Y \u2014 `-sin(yaw) cos(pitch)`, `-sin(pitch)`, `cos(yaw) cos(pitch)` \u2014 so a subject on a ceiling got an arm that still swung about world up and the rig placed the eye through the ceiling. `groundClear` then cut the arm where the eye would go under the deck, which is the same assumption a second time.\n\n**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.\n\n**`setBoomUp` takes 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, and `Boom`, 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.\n\n**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 \u2014 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.\n\n**`groundClear` is 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.\n\nIts 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."
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"version": "3.46.0",
|
|
430
|
+
"date": "2026-09-03",
|
|
431
|
+
"entries": [
|
|
432
|
+
{
|
|
433
|
+
"kind": "fixed",
|
|
434
|
+
"short": "A WebGPU material change costs one bind group the first time that combination is seen and nothing after. The cache was keyed on the albedo while every group in it held four maps.",
|
|
435
|
+
"text": "**Every material change rebuilt the flat bind group and threw the whole cache away.** `setMaterial` called `rebuildFlatBindGroup` once 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 **four `createBindGroup` calls**, and the cache that exists to make the second frame free was destroyed on every one of them.\n\n**The mismatch was stated in the file and was the reason for the clearing.** `albedoBindGroups` was *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.\n\n**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 `createBindGroup` calls 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.\n\n**The key was the defect, not the caching.** The cache is keyed on all four maps now and `setMaterial` clears 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 \u2014 the shadow cubemaps, the probe fence, the cookie atlas and the IES rows \u2014 and none of them is a frame path.\n\n**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."
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
"kind": "fixed",
|
|
439
|
+
"short": "damp holds its target still, so a chase camera settled a different distance behind at every frame rate and juddered when frames were uneven. dampTracking is exact for a target that is moving.",
|
|
440
|
+
"text": "**`damp(a, b, lambda, dt)` is `lerp(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 assumes `b` is constant across the interval. A third-person camera's target is never constant: it is a vehicle. `ThirdPersonCamera` smoothed its position with it, so every consumer's chase camera had this.\n\n**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 to `v dt (1-k)/k` for `k = 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.\n\n**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.\n\n**`dampTracking(a, b, velocity, lambda, dt)` solves the same equation for a target arriving at `b` having travelled at `v`.** Substituting `e = a - b` gives `e\u2032 = -lambda e - v`, whose solution is `e(t) = (e0 + v/lambda) exp(-lambda t) - v/lambda` \u2014 so the settled gap is `v/lambda` at every rate and there is nothing left for jitter to move. Zero velocity returns `damp` exactly, and a lambda of zero returns `a` rather than dividing by it, which is also the limit of the arithmetic.\n\n**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 of `delta/dt` across a step is a spike this would then aim `v/lambda` beyond."
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"kind": "added",
|
|
444
|
+
"short": "A caster enumeration may carry each draw's material, and drawSceneCasters replays one into the open colour pass, so a second viewpoint costs a replay rather than the whole draw path again.",
|
|
445
|
+
"text": "**The only draw-replay the engine had carried no material, so a second pass meant running the whole draw again.** `ShadowCasterSink` is exactly the mechanism \u2014 a handle, a matrix and a skinning palette \u2014 and carrying only that is right for a depth pass, where a material cannot change a depth. `beginPlanarReflection` hands back a `Camera` and 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.\n\n**What that costs was measured in a consumer**, whose `sim` phase \u2014 the people, the vehicles, the props and the smoke \u2014 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 a `mirror` row rather than hidden precisely because it is the most expensive thing in the frame.\n\n**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.\n\n**The material rides the sink now**, optional for the reason `instanced` already 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. `drawSceneCasters` is the colour counterpart of `drawShadowCasters` on 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.\n\n**Scatter is declined rather than drawn, and the absence is meant to be in the picture.** A scatter batch\u2019s 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."
|
|
446
|
+
}
|
|
447
|
+
]
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
"version": "3.45.0",
|
|
451
|
+
"date": "2026-09-03",
|
|
452
|
+
"entries": [
|
|
453
|
+
{
|
|
454
|
+
"kind": "added",
|
|
455
|
+
"short": "Track K is complete: an inspector, a scene tree and play-in-editor, as @driftengine/editor at 10.3 KB standalone. The stop condition held for an hour and then somebody asked for the rest.",
|
|
456
|
+
"text": "**`ROADMAP.md`'s risk table has said since it was written that if the editor threatened the\nprogramme the answer was to stop at debug draw and gizmos and defer the rest.** The gizmo shipped in\n3.44.0, the condition held, and the census recorded the deferral. Then the rest was asked for, which\nis the only reason a stop condition is ever lifted, and this is it.\n\n**The trigger never fired.** The track did not exceed its budget. What the estimate was wrong about\nis the size of the thing: an editor is a multi-year speciality when it is a *product* \u2014 a window, a\ndocking layout, a property drawer per type, an undo stack, an asset browser \u2014 and this is the model\nunderneath one. A tree flatten, a schema walk, a snapshot round trip and a `UiNode` builder.\n\n**A package, not part of core, and the reason is a boundary worth keeping.** It imports\n`@driftengine/entities`, which imports no other engine package deliberately; having core reach it to\nserve an editor would invert that for every game that never opens one. **10.3 KB gzipped standalone**,\nand standalone is a measurement rather than a convenience: `Gizmo` is the only value it imports from\ncore, so a bundle of it is 31.8 KB raw against core's 2.79 MB, with `createRenderer` and every shader\nstring absent from the output.\n\n**`SceneTree` is a flattened, collapsible, named view**, and the names live in the tree rather than on\n`SceneNode`. A string on every node would be paid by every game, in every serialised scene and every\nnode allocation, to serve an editor most of them never open. Reparenting *answers* rather than\nthrows, and the refusal itself is `attachChild`'s \u2014 checked rather than re-implemented, because it\nalready rejects a self-parent and any ancestor.\n\n**`Inspector` is reflection and needs no code per component.** `ComponentType.schema.fields` already\ncarries `{ id, name, type }` and `World.read`/`write` take a field by name, so it inspects a component\na `.drs` file declared and this package has never heard of. **The field's kind comes from the declared\ntype, because a read cannot answer it**: `bool` arrives as 0 or 1, an enum discriminant as an integer,\nan `Entity` as a number \u2014 every one of them is a number by the time you have it, and only the\ndeclaration says whether to draw a checkbox, a stepper or a menu. An option is spelled `option:f32`,\na prefix rather than the `?` suffix that is the obvious guess, because a field type is a key a\nmigration compares for equality.\n\n**A transform is written through `setPosition` and `markMoved`, never into the array.** Writing\n`node.position` directly leaves `worldMatrix` describing where the node used to be \u2014 no error, a\nplausible matrix, geometry in the wrong place until something else happens to move it.\n\n**Play-in-editor's hard part was not pause.** `LoopHooks.shouldSimulate` already existed so a paused\ngame keeps rendering, which is exactly what an editor wants; `step()` is a one-shot flag that hook\nconsumes, so a step is one tick rather than a duration that advances a variable number of them.\n\n**It was that `deserializeWorld` creates entities rather than replacing them.** A load appends, so a\nstop that only loads leaves the world holding both what was authored and what play produced \u2014 and the\nduplication grows every time somebody presses stop. **Measured at 4 live entities against 2** with the\nclear removed. So `stop()` clears first, walking the same `store.dense` per type that\n`serializeWorld` walks to decide what is live.\n\n**And every entity handle changes across a stop**, because the restored entities are new ones. A\nselection held as a handle is stale the moment play ends \u2014 pointing at nothing, or at whatever reused\nthe slot, which is worse because it looks like it worked. A selection is re-found by its *index in the\nsnapshot*, the only identity that survives, and an entity that play created has no index and the\nselection is emptied rather than moved to a stranger.\n\n**Panels are `@driftengine/ui2d` nodes and not pixels**, which is the shape `DebugLines` has against\n`drawLines`, one level up. Rows are rebuilt in place and carry a name of `row:<index>`, so the node\n`routeUiPointer` hands back parses straight to the row. **Nothing marks the selected row and `UiNode`\ngrows no field for it**: `tree.rowOf(tree.selected)` is the index, and how a selected row looks is a\ndecision about a consumer's palette \u2014 the same trade this package made when it kept node names out of\n`SceneNode`.\n\n**Its check found a hole in itself.** Every assertion was verified by breaking what it covers, and\none of those runs changed the page rather than confirming it: replacing `setPosition` with a raw array\nwrite left the check green, because the edit was applied during setup, before any world matrix had\nbeen computed, so the first `updateWorld` picked up a raw write as readily as a proper one. The edit\nnow lands after a frame has drawn and the same mutation fails at 669 pixels against 669.\n`inspector.test.ts` had it covered all along, because it calls `updateWorld` before writing \u2014 which\nis why a check with a control and a unit test with a precondition are not the same instrument.\n\nMeasured on both backends, identical to the pixel: three rows at 11,016 px and two at 7,344 when a\nbranch is collapsed; a click selecting row 2 and lighting 3,672 px where the control lights none; an\ninspector edit moving the rightmost cube from 669 to 849; and a stop restoring 2 live entities and a\nvalue of 10 where play left 2 and 999."
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"kind": "added",
|
|
460
|
+
"short": "drift/editor grows to forty-three capabilities over two opaque types: twenty for the tree, the inspector and the play state, beside the twenty-three that already drove the gizmo.",
|
|
461
|
+
"text": "**A second opaque type.** `Gizmo` was the transform tool; `Editor` is the session \u2014 what is\nselected, the tree of what exists, and whether the world is playing. Both reach a script through\n`uses`, the way a `Terrain` and a `NavGraph` do, so the module needs nothing from the host and stays\nregistered unconditionally.\n\n**A name is unique across the module and not per type**, which two opaque types make easy to forget: a\nmodule is one namespace and one implementation map, so `mode` cannot mean the gizmo's mode to a\n`Gizmo` and the session's to an `Editor`. It is `gizmoMode` and `mode`, and TypeScript caught the\ncollision as a duplicate object key rather than one of them silently winning at run time.\n\n**Every row and field reader answers a defined value for an index that does not exist**, because the\nlanguage has no optional to answer with and a frame loop is the worst place to learn that \u2014 the same\nargument `drift/ui` makes for addressing nodes by name.\n\n**And nothing here edits a field by name**, which is the same shape as the gizmo not picking: an\ninspector row is addressed by its index in a list the host built this frame, and a script wanting to\nwrite `Health.current` on a selection would be doing entity work `drift/ecs` already does properly,\nwith a component handle and a checked field."
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"kind": "changed",
|
|
465
|
+
"short": "A version bump is forty-eight places, not forty-three. The sixteenth package moved it, and four of the five new places are ranges rather than manifests.",
|
|
466
|
+
"text": "**The seventh time this number has moved, and the third time a peer dependency rather than a package\nwas the reason.** `@driftengine/editor` is one manifest and *four* ranges: three peers of its own \u2014\ncore, entities and ui2d \u2014 and `@driftengine/script`'s peer on it. So a release is now **seventeen\nmanifests, thirty ranges and the lockfile: forty-eight places.**\n\nCounted with the snippet in `AGENTS.md` rather than read off the previous sentence, which is the only\nreason it is right \u2014 that paragraph has been wrong six times while saying so. The floor in\n`scripts/version.test.mjs` is raised in the same commit, because a floor passes at the true number and\nat every stale one below it."
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"version": "3.44.0",
|
|
472
|
+
"date": "2026-09-03",
|
|
473
|
+
"entries": [
|
|
474
|
+
{
|
|
475
|
+
"kind": "added",
|
|
476
|
+
"short": "A transform gizmo, which stops Track K where its own risk table said to stop: translate, rotate and scale, thirteen handles, 4.53 KB over core and nothing to a game that never imports one.",
|
|
477
|
+
"text": "**Track K stops here, and that is a different thing from running out.** `ROADMAP.md`'s risk table has\nsaid since it was written that if the editor threatens the programme the answer is to *\"stop at debug\ndraw and gizmos, which are independently valuable, and defer the rest\"*. Debug draw shipped on\n2026-08-28. This is the other half, and it is the first row in this repository closed by a stop\ncondition written in advance rather than by an estimate running out.\n\n**`Gizmo` is a generator and not a pass**, which is the argument `DebugLines` makes and which holds\nhere with more force: it answers what a ray is over and what a drag does to a transform, fills five\nline buffers, and `drawLines` \u2014 on both backends since the polyline batch landed \u2014 draws them. Five\nand not one because `drawLines` takes one colour a call: three axes, one neutral, and one for\nwhatever the pointer is on.\n\n**Translate, rotate and scale; thirteen handles; world or local \u2014 except scale, which is always\nlocal.** A non-uniform scale along a world axis is not representable in a translation-rotation-scale\ntransform, so a gizmo offering it would shear the object and call it scaling.\n\n**Every drag answers from its anchor rather than accumulating**, so fifty frames of dragging back and\nforth return the target to where it started, exactly. **And every degenerate ray keeps the previous\nvalue rather than producing one**: a ray parallel to the axis it is dragging, a ray edge-on to a\nring, a grab at a ring's own centre. Each of those has a closest point that is either undefined or\nenormous, and a caller that took the number would teleport whatever it was holding at the moment the\npointer crossed the plane. `rayPlane`, `rayClosestOnLine` and `raySphere` are new in `math/intersect`\nand each refuses its own degenerate case at the source.\n\n**The basis is frozen at the grab.** Recomputing it from a rotation the drag is writing feeds the\nanswer into its own input, and what that looks like is a local-space ring accelerating away from the\npointer. A pair of tests says so the only way that can be said: the same drag on the same pixels in\neach space, against a target already turned a quarter, where the two answers differ. Against an\nunrotated target they would be identical, which is why that test starts turned.\n\n**The angle unwrap is real and the orientation cannot show it.** `atan2` answers in the half-open\nturn, so a drag crossing the seam reads as a jump of nearly a full one; the total is unwrapped and\naccumulated. But `setAxisAngle` is periodic in a full turn and a quaternion double-covers the\nrotations, so an accumulated angle wrong by a turn produces the same pose negated, which is the same\nrotation. What the unwrap buys is the *number* \u2014 a readout that says 270 rather than -90, and a\ncaller that wants to snap or clamp \u2014 so `dragAngle` exposes it and the tests assert it there.\n\n**4.53 KB gzipped over core, and nothing at all to a consumer that never imports one**, which is\nmeasured rather than assumed: `core-only` bundles byte-identical against this branch and against the\ncommit before it, and the bundle contains no gizmo symbol. It imports `createRenderer` and a bundler\nshakes the rest of the barrel out, which is what makes an editor tool affordable to ship in core.\n\n**Its check has a control on every claim.** A white quad that shrinks by four as the camera pulls\nback while the gizmo holds its size to within four pixels; a pointer on the background that lights\nnothing; the same drag begun off the handle that moves nothing and does not start. A test that\nmeasured only the gizmo across two distances would pass a build that ignores distance, and one that\ndraws nothing at all.\n\n**What it does not do is pick**, and that is the shape rather than a gap: a pick needs a ray, a ray\nneeds a camera and a pointer, and both belong to the application. `registerPickable` settled the same\nquestion for meshes and this follows it \u2014 the engine says what, and enter, leave, capture and the\ndifference between a click and a drag stay with the consumer that already has that logic."
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"kind": "added",
|
|
481
|
+
"short": "drift/editor is bound, under the editor effect the language had already named. Twenty-two capabilities: what the pointer is over, what the drag has done, and which kind of tool it is.",
|
|
482
|
+
"text": "**`drift/editor` was one of six specified surfaces nothing implemented, and it is five now.** A script\ndrives the tool: which of the three modes it is, which axes the handles point along, what the pointer\nis over by name, how far a rotation has turned, and the transform the drag has produced so it can be\nwritten onto an entity.\n\n**`editor` is the effect, and unusually the language had already named it.** Every other track that\narrived at this point found the vocabulary short: terrain ships under `physics.read` and says so in\nits binding, and navigation waited on a DriftScript release. `Effect` has carried `editor` since it\nwas written, outside the deterministic set, which is exactly right \u2014 a gizmo holds a person's pointer\npart way through moving something, so a `@deterministic` system reading one would replay differently.\nIt is the effect `@editor` was designed to pair with.\n\n**Three capabilities where an argument would have done.** `translateMode`, `rotateMode` and\n`scaleMode` rather than one taking a mode. The language has no enum, so the argument would be a\nstring or a number, and an unrecognised one leaves two options: change nothing, which is the silent\nno-op `host.ts` rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the\ninvalid call impossible to write, and the checker refuses it instead of a runtime branch. The\n*readers* answer strings, because a reader is total and has no invalid value to report.\n\n**And nothing here picks**, for the reason the gizmo itself does not: the host runs the pointer."
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
"kind": "changed",
|
|
486
|
+
"short": "PickableSet is on the barrel, the class and not only its types. Picking shipped in 3.19.0 and a consumer could describe a set without being able to build one.",
|
|
487
|
+
"text": "**Every renderer has held a `PickableSet` since picking shipped**, and `registerPickable`,\n`updatePickable`, `unregisterPickable` and `pickAt` have all been on `RendererApi` \u2014 so picking\nagainst what a renderer draws has been reachable the whole time. What was not exported is the class,\nso a consumer wanting a *second* set, over gizmo handles or over anything a renderer never draws, had\n`PickHit` and `PickableSource` to describe one and no way to construct it.\n\n**Two documents said picking did not ship at all.** The register of unpriced work had the gizmo row\nas *\"picking, a handle to drag, a transform gizmo\"*, and `docs/CAPABILITIES.md` said \"no handle to\ndrag, no picking, no gizmo\" in one row while listing \"ray picking\" as shipped two rows above it \u2014 a\nmap contradicting itself for eighteen days. Both are corrected.\n\n**What that cost was the ranking rather than the estimate.** A row priced at three pieces of work\nsits differently from one priced at two, and this one was passed over twice. The check is one grep,\nand it is the same check the `drift/math` row needed and did not get: that one was filed as a missing\nlanguage module when `std/math` had thirteen functions the whole time."
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
"kind": "fixed",
|
|
491
|
+
"short": "The core size floor was 505 bytes stale, so every KB-over-core figure in nine READMEs and the root table read 0.5 KB high. Measured against main rather than derived from the floor.",
|
|
492
|
+
"text": "**The floors drifted again, nine days after refraction found them 15.4 KB behind.** This time it was\n505 bytes, which is 0.078% and which the 3% tolerance passes without a word \u2014 the failure mode\n`scripts/size-floors.mjs` warns about in its own header: **a floor passes at the true number and at\nevery stale one below it.**\n\n**The difference is that it was looked for rather than stumbled on.** A new capability re-measures the\nfloors whether or not the gate asks, and the A/B that followed is what separates a correction from a\nguess: `core-only` against this branch is 645,522 bytes and against the commit before it 645,523. One\nbyte apart, in the direction that says the new code costs a consumer of core nothing \u2014 confirmed\ndirectly by the bundle, which contains no gizmo symbol.\n\n**So every `KB gzipped over core` figure read half a kilobyte high**, in eight package READMEs and\nagain in the root table, each under a sentence claiming to be a fact about the build. `IMPROVEMENTS.md`\nhad carried the sweep as its own commit for a while; it lands here because this is the commit that\nmoved the floor."
|
|
493
|
+
}
|
|
494
|
+
]
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
"version": "3.43.0",
|
|
498
|
+
"date": "2026-09-03",
|
|
499
|
+
"entries": [
|
|
500
|
+
{
|
|
501
|
+
"kind": "added",
|
|
502
|
+
"short": "Refraction, which completes Track D: a colour snapshot sampled along the surface normal, absorbed by Beer-Lambert over a path length that grows at grazing angles. 9.05 KB over core.",
|
|
503
|
+
"text": "**Track D is complete.** Glass bends and tints what is behind it: a copy of the frame's colour,\nlatched at the first refracting draw, sampled at an offset along the surface normal, and absorbed by\nBeer-Lambert over a path length that grows at grazing angles.\n\n**9.05 KB gzipped over core, against the 19.8 KB it had been priced at.** That estimate came off the\nbranch-in-`flatFrag` figure Track D's earlier rows measured, and it was right about where the cost\nlives and wrong about how much: a uniform and a branch that reads one sampler is not the size of the\nbranch the figure came from. The fragment stage stays at sixteen permutations and the vertex stage at\nfive. A frame that refracts nothing takes no copy, breaks no pass and binds an empty texel.\n\n**The snapshot is latched rather than declared**, because there is no translucent-set boundary to\nhang it on: `drawTranslucentMesh` draws immediately and the queue exists so order-independent\ntransparency can replay the set. So the first refracting draw takes the copy and the rest of the\nframe reuses it. Glass does not refract other glass, which is right; opaque geometry drawn after the\nfirst pane is missing from what that pane shows, which is the trap `snapshotDepth` already documents.\n\n**Neither backend can read what it is writing, and they answer differently.** WebGL2 blits, which\nresolves a multisampled source and therefore works in exactly the configuration where\n`colorAttachment` returns null. WebGPU ends the pass, copies the resolved `post.sceneColor` and\nreopens with `loadOp: 'load'` \u2014 a fourth instance of a boundary this backend already runs for the\nmirror, the light volume's depth snapshot and text.\n\n**The absorption is stated as what survives one metre** rather than as a coefficient: `pow(tint, d)`\nis `exp(-absorption * d)`, so clear glass is `(1, 1, 1)` with no special case and no `log(0)` at the\nAPI. **The path length divides by `dot(N, V)`**, which is the whole difference between this and a\ntint \u2014 a slab seen edge-on is more glass than the same slab face-on, so a glass edge goes green while\nits middle stays clear. Thickness rides the per-vertex channel's `.w` lane, which 3.42.0 declared\nreserved; it cost a varying and no attribute, where a fifteenth attribute would have spent one of the\ntwo vertex locations left.\n\n**Texture unit 14 of the two that were free.** One remains, and the next sampler has to fold into an\nexisting binding the way the point-shadow cubemaps became one array, rather than take the last one.\n\n**Its check found five defects before it went green, and only two were in the shader.** The scene was\nwrong twice: a flat pane facing the camera has a normal of (0,0,1) and correctly displaces nothing,\nand a rippled one displaces differently at every x, which smears rather than moves. The shader used\none scalar as both the offset and the blend, so a plausible 0.025 offset also meant a 2.5% blend and\nglass drew as flat paint. **Three were on WebGPU and every one silent**: `post.sceneColor` had no\n`COPY_SRC`; the pass is opened lazily, so there was none to end at the moment a refracting draw\nasked; and the flat bind group is built at construction and cached, so it held the one-texel white\nstand-in for the snapshot forever and every pane came out pure white \u2014 255,255,255 against WebGL2's\n108.9, with the non-refracting variants agreeing to a tenth on both.\n\nMeasured on both backends, agreeing to the digit: the pattern behind a pane shifts 11 px at one\nstrength and 23 at double; a tinted pane reads 9.7,88.4,18.6 against a clear 108.9; an edge-on pane\nabsorbs to 32.03 where face-on holds 38.55; the thickness lane separates 85.21 from 39.44.\n\n**What it gives up**: refraction replaces the surface's own shading rather than mixing with it, so\nglass carries no specular of its own. By that line the shaded colour has the lighting, the fog and\nthe tone curve folded into it and there is no highlight left to keep. A caller wanting a glint draws\na second, non-refracting pass over the same geometry.\n\n**Also**: the size floors were 15.4 KB behind reality and are swept. About 6.1 KB of that is 3.42.0's\nper-vertex channel, which stayed inside the 3% tolerance and so moved no floor and raised no gate."
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
"kind": "changed",
|
|
507
|
+
"short": "A celestial site can name its own clock: longitudeDeg and utcOffsetHours make the timestamp a plain UTC instant, so the host machine's time zone stops choosing the hour and the moon phase.",
|
|
508
|
+
"text": "**There was no way to tell `celestialStateAt` which hour to put the sun at.** It read the hour off\nthe timestamp with `Date#getHours` \u2014 the *host machine's* wall clock \u2014 so one argument and one\ninstant, with `dayOfYear` pinned, put solar noon at 12:00 under `TZ=UTC`, 10:00 in Rome, 16:00 in New\nYork and 03:00 in Tokyo. The only way to name an hour was to build a timestamp whose host offset\ncancelled the one being added.\n\n**That trick steadies the sun and cannot reach the moon.** `moonPhase` comes from the timestamp's\nabsolute value rather than from its hour, so a caller handing this function a different instant on\nevery machine \u2014 which is exactly what the workaround does \u2014 got a different phase on every machine.\n\n**`CelestialSite.longitudeDeg` and `utcOffsetHours` are the way to say it.** Naming either makes the\ntimestamp a plain UTC instant and the engine derives the site's own hour from it: the offset naming\nits calendar day, the meridian placing its sun, up to half an hour off the centre of its zone. That\nlast correction is the one this module's header had always warned about and handed back to the\ncaller, and it is the engine's now. A longitude alone implies the offset of its own meridian, so a\nsite giving one and not the other still describes a consistent clock.\n\n**A site naming neither, and a caller passing no site, keeps `getHours` exactly.** That is the model\nevery world here is tuned against, so the defect is *kept* in that form rather than quietly removed,\nand a test asserts the four numbers above so nobody makes it host-independent and moves every shadow\nin every consumer at once.\n\n**The equation of time stays the caller's**, which is not an oversight: it is a property of the\nplanet's orbit rather than of the place, and a world with an obliquity of its own has an equation of\ntime of its own.\n\nFound by a player in Italy watching the sun set before six."
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
"kind": "added",
|
|
512
|
+
"short": "The last four engine rows from an in-repo consumer's gap report: nearest filtering, a stick a caller need not supply, an additive stroke, and a component field that can hold an enum.",
|
|
513
|
+
"text": "**`demo/voxelSandbox/GAPS.md` has no open engine rows left.** Four closed here, on top of the three\n3.42.0 answered with the per-vertex channel. What remains in that file is the port's own work.\n\n**A surface texture can be asked for nearest magnification.** `filter` on `SurfaceTextureOptions`,\ndefaulting to `linear`. Linear magnification turned an authored pixel-art tile into a smear as the\ncamera approached, and the consumer's workarounds \u2014 composing the atlas with `imageSmoothingEnabled`\noff, keeping tiles at twice the resolution they wanted \u2014 could not reach it, because the smoothing\nhappens in the sampler after everything a caller controls. Minification still blends between mip\nlevels: the option is about magnification, and taking the nearest level too would trade a smear for\na visible pop as the camera pulls back. WebGPU refuses `maxAnisotropy` above 1 unless every filter is\n`linear`, so a nearest sampler gets 1 there and an ignored request on WebGL2 \u2014 that file had no test\nat all, and a refused sampler is a bind group that never builds and a frame that draws nothing.\n\n**`TouchControls` takes its stick nodes as options, or not at all.** They were positional and read as\ndependencies, so a consumer forbidden the page concluded the class needed two `HTMLElement`s and\nbuilt two `div`s it never appended anywhere. They are an output channel: only ever assigned `hidden`\nand `style`, never measured, with every touch coming from `input.target`. `new TouchControls(input)`\nis now a working scheme with no visible stick, and the positional form keeps working. Which form was\ncalled is decided by arity rather than by inspecting the second argument \u2014 `instanceof HTMLElement`\nneeds a DOM constructor this package is checked without, and duck-typing on `nodeType` reads a\nstand-in element as an options object, which the file's own harness supplies.\n\n**A stroke can say it is light.** `drawLines` takes a blend, so a glowing outline is one additive\npass rather than two alpha-blended ones at different softness faking a halo \u2014 which tinted toward the\nglow colour against a bright surface instead of adding to it. It is a blend and not a bolt mode,\nwhich `AGENTS.md` forbids: what separates a line from a bolt is the *path*, a bolt jitters because it\nis lightning, and an additive line still follows the path it was given, keeps its clean edge and is\nstill fogged. Blending is pipeline state on WebGPU, so the additive arm is a second pipeline under\nits own cache key.\n\n**A component field can hold a fieldless enum**, which is where a state machine's state lives. The\ndiscriminant takes an integer column, because that is what it always was. This was read as a language\ngap by two documents and is not one: DriftScript has `enum`, and `match` over one refuses to compile\nuntil every variant is handled \u2014 its own corpus advertises exactly that against a Lua version which\nwould silently do nothing for a state nobody wrote a branch for. What refused was this engine's\ncomponent store. So the corpus keeps its `Alertness` in a `data` record, which is not what a system\niterates, and the consumer shipped `mood: i32` with three numbers written out in a comment.\n\n`Int32Array` and not a byte, and the width is the one decision the report did not make: a byte would\nhold every enum anybody is likely to write, and `defineComponent` is handed `enum:Mood` \u2014 a name,\nwith the variant list belonging to the module that declared it. It cannot count them, and guessing\ntoo small fails silently, so it spends three bytes it will usually not need."
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
"kind": "fixed",
|
|
517
|
+
"short": "The per-vertex channel's fourth lane defaulted to 0 while carrying thickness, so a refracting mesh with no channel array absorbed nothing. A multiplier's identity is one.",
|
|
518
|
+
"text": "**A defect introduced in this release, found before it shipped, and worth recording because of how it\nhid.** 3.42.0 declared the channel's fourth lane *reserved and unread* and gave it an absent value of\n0, which was right for a lane nothing reads. Refraction then made it the thickness multiplier and\nleft the 0 in place.\n\nThe consequence is not subtle: the path length is `thickness * lane / dot(N, V)`, so a lane of zero\nmakes it zero, `pow(tint, 0)` is one, and **a pane naming a tint and a thickness bent the scene behind\nit and took no colour out of it at all**. Every lane in that tuple is a multiplier or an amount, and\nthe identity for a multiplier is one.\n\n**It survived the check because every subject the check draws supplies a channel array**, so the\nabsent value was never the thing being measured \u2014 the page had eight variants and all eight carried\none. There is a ninth now that carries none, and against the old default it reads the clear pane's\ncolour exactly: 108.9 grey where it should read 9.7,88.4,18.6.\n\nThat is the second defect this lane has produced in two releases. The other was a duplicate attribute\nlocation on every instanced WebGPU pipeline, found by a consumer bisecting a scrambled frame. Both\nhave the same shape: a value that was correct for a lane nobody reads, left standing when something\nstarted reading it."
|
|
519
|
+
}
|
|
520
|
+
]
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
"version": "3.42.0",
|
|
524
|
+
"date": "2026-09-03",
|
|
525
|
+
"entries": [
|
|
526
|
+
{
|
|
527
|
+
"kind": "added",
|
|
528
|
+
"short": "A four-lane per-vertex channel on `MeshData`: sway in the vertex stage from the shared wind, a sky factor the directional term alone consumes, and per-vertex alpha. `drift/scene` reads the wind.",
|
|
529
|
+
"text": "**Three gaps a consumer filed separately turn out to be one attribute.** They asked for leaf sway,\nfor a sky factor that scales the directional term without touching ambient, and for per-vertex\nalpha. Every one of them is the same sentence: a float a vertex the flat shader reads somewhere\n`colors` cannot reach. `MeshData.channel` is one optional `vec4` carrying all three, with a fourth\nlane declared and unread so the next question costs no location.\n\n**Locations were the scarce resource, and no list had said so.** WebGL2 guarantees sixteen vertex\nattribute locations, eleven were already spent, and an instanced draw spends all sixteen. So the\nconsumer's smallest change of one float could not be had once, let alone three times. One `vec4`\ncosts the location a `float` would. **An instanced draw still cannot carry it** and `InstancedMesh`\nthrows rather than reading the absent constant and drawing leaves that never move, which is the\nsilent no-op the two-backends rule forbids. The escape hatch stays documented and untaken.\n\n**Absent is `(0, 1, 1, 0)` through the disabled-attribute mechanism `grain` and `relief` use**, so a\nmesh that says nothing about this costs no buffer, no upload and no per-vertex fetch, and is\nidentical to the digest: `f0092361` on both backends. Two of those four defaults are ones and the\nwrong value there is not a subtle shading difference, it is every mesh in the engine losing the sun\nor vanishing.\n\n**Sway reads the wind rather than a clock of its own.** `setWind` samples the field once a frame and\nboth backends bind it to the mesh and depth programs, so a canopy and its own shadow bend together\nand a leaf leans with the grass beneath it. The conversion is `resolveScatterDeform`, shared with\nthe scatter batch. The lane scales the bend once where `scatter.ts` squares its own, because that\nfalloff is derived from height and this one is authored.\n\n**`skyDirect` multiplies `sunShade` and not `direct`**, because the sun's specular lobe reads\n`sunShade` on its own: scaling the diffuse alone leaves an enclosed face with no sunlight and a\nhighlight anyway. Ambient is untouched, which is the whole defect. A sky factor folded into `colors`\nmultiplies the albedo, so it scales ambient and sun together and darkens a cave face twice.\n\n**Per-vertex alpha did not need `colors` widened to four floats**, which is how it had been costed.\nIt is a factor on `uOpacity * coverage`, not a component of the albedo, so it rides a lane and no\nmesh producer, container field or baked asset moved.\n\n**5.8 KB gzipped over core, and no new permutation on either stage.** The row had been deferred as\npriced by the permutation count; that is the fragment stage's economics, where sixteen permutations\ncost 283.4 KB gzipped. The vertex stage is five at 5.2 KB.\n\n**Checked on the card, both backends agreeing to the digit.** `scripts/vertex-channel-check.mjs`\nruns ten assertions with every control in the scene: sway moves the quad 57.343 px one way and\n57.105 the other while a `sway = 0` control holds at 0.000 across the same two clocks, and the sky\nlane leaves 38.00 luminance where folding the factor into the vertex colour leaves 0.00 and full sun\ngives 176.00. Each claim was broken and confirmed red. The sky mutation is worth recording: scaling\nalbedo instead of `sunShade`, which is exactly the defect being fixed, still passes an assertion\nthat only checks the face got darker.\n\n**It found a real WebGPU defect on its first run there.** GLSL links varyings by name, so the two\nstages could list them in different orders and WebGL2 stayed pixel-perfect; the generated path\nassigns locations by declaration order and handed WebGPU a `vec4` where it wanted a `float`. No\npipeline was created, so the backend drew nothing at all, with the reason in a device error line and\nnothing wrong in the frame to look at.\n\n**`drift/scene` gains the wind**: `windDirectionX`, `windDirectionZ`, `windSpeed` and `windGust`,\nfour `f32` reads under `scene.read` and deterministic. A `Wind` handle is a sampled state and not a\nprofile, which is what lets a `@deterministic` system read it, and `wind.ts` builds its signal from\ninteger harmonics so a replay watched an hour later sees the gust the original run saw."
|
|
530
|
+
}
|
|
531
|
+
]
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"version": "3.41.0",
|
|
535
|
+
"date": "2026-09-03",
|
|
536
|
+
"entries": [
|
|
537
|
+
{
|
|
538
|
+
"kind": "added",
|
|
539
|
+
"short": "Track F: the 2D layer as `@driftengine/ui2d`. Batched sprites, sheets, tilemaps culled to the view, and a retained interface tree with layout, focus and input routing. `drift/2d` and `drift/ui` bound.",
|
|
540
|
+
"text": "**Track F ships, as `@driftengine/ui2d`, and it draws through `registerPass` rather than through\na verb on the renderer.** That is what lets it be a package at all \u2014 `pass.ts` in core names this\npackage by name as one of the three the contributed-pass seam exists for \u2014 and it means a\nconsumer that never draws a sprite carries no sampler, no pipeline and no generated WGSL for one.\n**8.7 KB gzipped on top of core**, which places it on Track D's own price table: a uniform and a\nbranch in `flatFrag` cost 19.8 KB across sixteen fragment permutations, a shader of its own cost\n4 KB for drawn decals and 7.5 KB for screen-space reflection, and a package of pure arithmetic\ncost 1.4 KB for terrain. The alternative a consumer actually asked for \u2014 `drawPanelTexture` beside\n`fillPanel`, filed in `demo/voxelSandbox/GAPS.md` \u2014 is the 19.8 KB row.\n\n**Two coordinate systems, and they count y opposite ways.** `screenToNdc` is CSS pixels from the\ntop-left, the convention `InsetRect` and `fillPanel` already use; `worldToNdc` is a 2D world\nthrough a `Camera2D`, where y counts up, because a caller placing a platform above a floor should\nnot have to subtract. One shared convention would make every caller wrong half the time.\n\n**No depth is written and none is tested: the order sprites were submitted in is the layering**,\nwhich is the decision gate 1.2 already took about pass ordering. So the batch keeps *runs* rather\nthan grouping by texture \u2014 consecutive sprites on one slot are one draw, a slot change starts a\nrun, and going back to the first slot starts a third rather than rejoining, because rejoining\nwould reorder the picture. `AGENTS.md` asks for exactly that shape: the number to hold down is\nmaterial changes, not draws. The batch does not grow; past its capacity `drawSprite` counts into\n`dropped` rather than allocating under a frame.\n\n**`drawTilemap` costs the view rather than the map.** The visible span is arithmetic on four\nnumbers, measured at **25 tiles drawn out of a million-cell map**.\n\n**The retained tree is the other half and it touches no GPU at all.** `layoutUiTree` is two passes\nover an object graph \u2014 measure bottom-up, place top-down \u2014 and no third, because a `fit` size that\ndepended on the space it was given would need a rule for when to stop. Three size cases (`fit`,\n`grow`, a number) rather than percentages or flex weights, since a percentage of a `fit` parent is\na cycle. It allocates nothing: every number lands in a `UiRect` the node already owns, which is\nwhy a node is an object with fields rather than a description that gets resolved into one.\n\n**`uiHitTest` searches last-drawn first, so the hit test agrees with the picture**, and a node that\nis not `interactive` does not block what is behind it \u2014 a backdrop that swallowed clicks makes\nevery button under a plate dead, which no screenshot shows. `routeUiPointer` activates on a press\nand a release **on the same node**, so somebody who pressed the wrong button can slide off it.\nFocus order is tree order rather than a declared index, and a node hidden since it was focused\ndoes not take the keyboard with it. The pass fills a **white slot of its own**, from four bytes\nrather than a canvas, so a solid rectangle needs no sheet.\n\n**Two checks on the card, on both backends, and one of them found a real defect on its first\nrun.** `scripts/sprite-check.mjs` is 22 assertions over sheets that are two texels by two with\nfour different colours, so a sprite's four quadrants say which corner is which \u2014 an assertion a\nmirror fails and a bounding box cannot, since a mirror preserves every count and every box. The\ntwo sheets arrive as a `<canvas>` and an `ImageBitmap`, because WebGL2 ignores\n`UNPACK_FLIP_Y_WEBGL` for one and honours it for the other. A sprite lands on its CSS rectangle to\n**0.00 px** on every edge and the two backends agree byte for byte in both submission orders.\n`scripts/ui-check.mjs` is 17 more: the page publishes the boxes the layout computed *and* the\nbounding boxes of the colours they were drawn in, and asserts the two agree \u2014 a bar sized by its\nown contents lands at 530, 660, 220 by 60 to **0.0 px**, byte-identical in all three pointer\nstates, with the pointer visible as a colour rather than read back from the router.\n\n**The defect: a contributed pass inherits whatever cull state the last scene draw left on**, and a\nscreen-space quad's winding is not the scene's \u2014 so WebGL2 drew *nothing at all* while WebGPU,\nwhose pipeline states its own cull mode, was pixel-perfect. No error on either side, no validation\nmessage, no warning. Every backend difference this seam has is that shape: one API carries state\nbetween draws and the other does not.\n\n**`drift/ui` is bound** \u2014 eighteen capabilities over a tree addressed by the names its nodes were\nbuilt with, because the language has no optional to answer a missing node with inside a frame\nloop. Reading a laid-out box is `scene.read` and inside the fixed step; `hovered` and `pressed`\nare `input.read` and outside it, so a `@deterministic` system cannot ask where the pointer is \u2014\nthey are fields on the tree and read like any other, so `scene.read` would type-check and would\nbe a lie about what they carry.\n\n**`drift/2d` is bound with it, and getting there took a language release and one day.** A\nmodule's namespace was the last segment of its path -- `split('/').pop()`, in three places in\nthe compiler, with no alias -- so a call would have been `2d.sprite(...)`, and `2d` is a number\nfollowed by an identifier the lexer refuses before the checker sees it. Five spellings were\ncompiled against the real registry and every one failed: the bare name, the namespace, `import\nas`, `import *`, a backquoted segment. `ui.layout` and `terrain.heightAt` compiled in the same\nharness, so the failure was the name and nothing else.\n\n**So the binding was written, wired, found uncallable and withdrawn rather than shipped.**\nRegistering it would have traded `DS0301`, which says nothing here implements it and is\nactionable, for `DS0003` inside a script author's own call -- a capability nobody can spell is\nthe silent no-op this repository forbids, wearing a feature's clothes. It was recorded with a\nsentinel and filed against the language, and **DriftScript 1.11.0 answered it at the import**:\n`import { sprite } from \"drift/2d\" as sprites`, with `DS0139` refusing a namespace that cannot\nbe written and saying the line to write. The fix is at the import rather than at the derivation,\nbecause a rule that turned a bad segment into a good identifier would be the language picking a\nname on the author's behalf.\n\nThe engine was already on 1.11.0 by the time this landed, so what moves here is the binding and\nthe sentinel with it. The round trip took one day. Twelve capabilities over three opaque types -- a batch, a sheet and a tilemap -- with a\ndraw as `scene.write` and a read of a sheet or a cell as `scene.read`, so a `@deterministic`\nsystem may ask what is in a cell and may not draw it."
|
|
541
|
+
}
|
|
542
|
+
]
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
"version": "3.40.0",
|
|
546
|
+
"date": "2026-09-03",
|
|
547
|
+
"entries": [
|
|
548
|
+
{
|
|
549
|
+
"kind": "added",
|
|
550
|
+
"short": "`.fbx` returns skins and clips, and indexed geometry: a bought character arrives with its skeleton, its weights and its take instead of as a bag of triangles five times too large.",
|
|
551
|
+
"text": "**`.fbx` returns a rig, which is the one bar that makes a character format useful.** `readModel` documented `skins` and `clips` as \"where the format carries them. glTF does; nothing else here does\", and FBX is how characters are *sold* \u2014 it is the only thing Mixamo exports \u2014 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.\n\n**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, and `Transform`, where the *mesh* was; the engine's `inverseBind` takes a vertex from model space into the joint's bind space, so it is the inverse of the first times the second. `TransformLink` alone is right exactly while the mesh sits at the origin with no rotation \u2014 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.\n\n**A bone names its cluster and its parent bone, and only one of those is hierarchy.** The transform walk took the first `OO` parent it found, which is whichever the exporter wrote first, and a cluster is not a model \u2014 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.\n\n**And the geometry stopped arriving five times over.** The reader wrote `indices[i] = i` across 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 calling `readModel` directly carried all of it. Deduplicating on the layer *offsets* is the cheap answer and shares almost nothing, because an exporter writing `ByPolygonVertex` `Direct` gives 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 \u2014 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.\n\n**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.\n\n**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 \u2014 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-default `RotationOrder` is the one part of the chain still refused by name.\n\n**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 named `rp_nathan_animated_003_walking_spine_01\\u0000\\u0001Model`, and the take `Take 001\\u0000\\u0001AnimStack`. Nothing fails on a name: `Joint.name` is 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 \u2014 which is the argument for real bytes in one line.\n\n**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 the `Take` records of pre-7000 files. Materials and textures are unchanged.\n\n**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.ts` writes the binary records instead, in the shape Autodesk's exporters emit them, which also answers the questions a test wants to ask \u2014 a cluster whose weights do not sum to one, a bone whose only connection is to its cluster, a curve with one key \u2014 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 `.drft` 1.11 and read back it is the same asset, and the engine's own `Skeleton` accepts 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."
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
"kind": "fixed",
|
|
555
|
+
"short": "The FBX reader's \u00abno geometry found\u00bb refusal now says which of the three shapes of nothing it met, instead of ending the trail where it starts.",
|
|
556
|
+
"text": "**\u00abfbx: no geometry found\u00bb 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 `Geometry` records at all, carries records that are not polygon meshes \u2014 a blend-shape target, a patch, a subdivision cage \u2014 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.\n\n**And the two exports it was met on are not a reader fault.** `_ue4.fbx` and `_u3d.fbx`, which ship beside a bought character for two particular engines, carry 95 and 88 `LimbNode`s with their curves and **no `Geometry` records at all**: they are animation-only files and the refusal was correct the whole time. It simply could not say so, and \u00abno geometry found\u00bb 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."
|
|
557
|
+
}
|
|
558
|
+
]
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
"version": "3.39.0",
|
|
562
|
+
"date": "2026-09-03",
|
|
563
|
+
"entries": [
|
|
564
|
+
{
|
|
565
|
+
"kind": "fixed",
|
|
566
|
+
"short": "WebGL2 ran order-independent transparency under multisampling, which WebGPU refuses: its two passes tested against a depth texture the frame was never drawn into. Both backends now refuse, once.",
|
|
567
|
+
"text": "**Order-independent transparency excluded multisampling on one backend of two.** WebGPU decided the frame's `oitActive` as `orderIndependent && screenEffects && samples === 1` and said why; WebGL2 decided it as `orderIndependent && sceneTarget !== null` and never asked the sample count. One boolean expression against another, found by reading them rather than by a capture.\n\n**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: `ensureSize` attaches `msaaDepth`, a renderbuffer, and the sampleable copy is written only by the depth blit at the end of `resolve` \u2014 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; `sampleCount` was already public two accessors above, so the guard needed no new API.\n\n**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: true` beside `sceneSamples: 4` \u2014 which is what a graphics screen's antialiasing switch produces \u2014 got weighted blending on one backend and sorted blending on the other, with nothing said on either.\n\n**Both backends now refuse it, once per renderer, in one string.** `OIT_MULTISAMPLE_REFUSAL` lives in `orderIndependent.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 \u2014 `compositeWantsDepth` was 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.\n\n**Pinned by a test on each backend, neither meaningful alone.** WebGL2 gained a `renderer.test.ts` \u2014 the first test in this repository to construct that renderer for anything but its budget \u2014 which submits one translucent pane and counts `drawElements`: 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 without `EXT_color_buffer_float` the effect refuses one step earlier and both profiles look alike for a reason neither test is about."
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
"kind": "added",
|
|
571
|
+
"short": "`.drft` 1.11 carries a skin's per-vertex half: `ATTR_JOINTS` and `ATTR_WEIGHTS`, the pair the format validated for four minor versions and wrote neither of.",
|
|
572
|
+
"text": "**`.drft` validated a skinned mesh and wrote half of it.** `MeshData` has declared `joints` and `weights` since skinning shipped and `validateMeshData` checks both \u2014 four floats a vertex, and neither present without the other \u2014 while `buildMesh` listed the optional arrays it had bits for and there were no bits for these. The `SKIN` chunk 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.\n\n**The write succeeded in silence, which is what made it survive four minor versions.** A baker handed `writeDrft` a correctly-validated skinned mesh, got no error and no warning, and produced a file nothing could skin \u2014 and `Mesh.isSkinned` reads `data.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 `.drft` would have brought \u2014 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.\n\n**Every other layer was already right**, which is why this is two bits and not a subsystem: the glTF reader reads `JOINTS_0` and `WEIGHTS_0` and 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`) and `ATTR_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 \u2014 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.\n\n**The test that should have caught it was right and its fixture was not.** `drft.test.ts` enumerates the keys of the mesh it builds and reports every loss at once \u2014 the method `relief` bought after a named-attribute list missed it \u2014 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. A `v1-11` compatibility fixture is checked in beside the others, and \u00a74.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."
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
"kind": "note",
|
|
576
|
+
"short": "A release is forty places, not thirty-six. The count and its floors were left behind by 3.38.0's new package, for the sixth time.",
|
|
577
|
+
"text": "**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` \u2014 one manifest and *three* internal ranges \u2014 and raised neither the paragraph in `AGENTS.md` nor the floors in `scripts/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."
|
|
578
|
+
}
|
|
579
|
+
]
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"version": "3.38.0",
|
|
583
|
+
"date": "2026-09-02",
|
|
584
|
+
"entries": [
|
|
585
|
+
{
|
|
586
|
+
"kind": "added",
|
|
587
|
+
"short": "Heightfield terrain: a query that answers the surface that is drawn, patches that meet without a hole, blended materials, a collider with no triangles in it, and `drift/terrain` bound.",
|
|
588
|
+
"text": "**`@driftengine/terrain` imports no renderer.** A `Terrain` is a heightfield and `heightfieldPatch` hands back `MeshData`, which goes to `createMesh` like any other geometry \u2014 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.\n\n**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 \u2014 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 `heightAt` reads the triangle, `heightfieldPatch` emits that same triangulation, and a test asserts every vertex of a patch against the query.\n\n`normalAt` is 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 \u2014 a crack that is not there.\n\n**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 \u2014 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. `neighbours` moves 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.\n\n**Measured on an AMD card at 1280x720, both backends.** An unmatched seam lets 15,401 pixels of the world through \u2014 counted per column below that column's own horizon, so sky is never mistaken for a hole \u2014 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.\n\n**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 \u2014 the same error the coarse patch carries over its whole area, arriving one cell early.\n\n**Several materials blend across one field, as vertex colour.** `TerrainMaterials` takes a weight map and `heightfieldPatch` bakes the blend into the vertices \u2014 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 in `flatFrag`, 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 \u2014 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.\n\n**It collides, through the mesh the picture is made of.** `meshShape` takes exactly the two arrays a patch hands back, so the geometry drawn is the geometry collided with and nothing is authored twice \u2014 asserted rather than claimed: a ray cast against that static body lands where `heightAt` says the ground is, at forty points off the lattice.\n\n**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 \u2014 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.\n\n**`drift/terrain` is bound**, which is one fewer module the linker refuses: `heightAt`, three normal components, `slopeAt`, `covers` and the two extents, all deterministic reads, so a `@deterministic` system may ask where the ground is. They are declared under `physics.read`, and that is a description rather than a borrowing \u2014 the terrain *is* the collision surface, which this package's tests assert \u2014 but the reason is that **the language names no `terrain.*` effect at all**. `drift/behavior` was given its two ahead of any provider precisely so a track would not need a language release first; terrain was missed. `docs/IMPROVEMENTS.md` carries the two lines that would fix it.\n\n**And it collides as a heightfield, not only as a mesh.** `heightfieldShape` in `@driftengine/physics` indexes the cell under a body straight from its x and z and carries the heights it was handed and no geometry at all \u2014 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.\n\n**It is not a second narrow phase, and that is the decision the row turns on.** `meshContact.ts` still owns every contact rule \u2014 many manifolds rather than one, one-sided triangles, and the interior-edge filter that stops a box stumbling on a flat seam \u2014 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.\n\n**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.\n\n**The triangulation rule now exists in two packages, and is guarded rather than trusted.** `@driftengine/physics` imports no other engine package, so it cannot see a `Terrain` \u2014 but a `Terrain` *is* a `Heightfield` structurally, which is how `heightfieldShape(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 arrangement `glDepthFunc` has with `DEPTH_COMPARE`, for the same reason: a boundary that forbids sharing the code does not forbid sharing a test."
|
|
589
|
+
}
|
|
590
|
+
]
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
"version": "3.37.1",
|
|
594
|
+
"date": "2026-09-02",
|
|
595
|
+
"entries": [
|
|
596
|
+
{
|
|
597
|
+
"kind": "note",
|
|
598
|
+
"short": "Refraction and screen-space GI go on the census: one scheduled with its design and its price, one refused in writing, and both with sentinels so the documents cannot go quietly wrong.",
|
|
599
|
+
"text": "**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.md` and `CAPABILITIES.md` prose and had no sentinel and no README bullet \u2014 and a sentinel is what makes a gap closeable here: `docs.test.mjs` asserts 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.\n\n**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 `flatFrag` and sampled at an offset along the surface normal \u2014 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.md` carries the design and the numbers.\n\n**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 \u2014 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."
|
|
600
|
+
}
|
|
601
|
+
]
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
"version": "3.37.0",
|
|
605
|
+
"date": "2026-09-02",
|
|
606
|
+
"entries": [
|
|
607
|
+
{
|
|
608
|
+
"kind": "added",
|
|
609
|
+
"short": "Screen-space reflection on both backends: a reflected ray marched against the frame's own depth, in a region a caller declares, for what a planar mirror cannot reach.",
|
|
610
|
+
"text": "**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 \u2014 and it costs a second pass over the scene.\n\n`ReflectiveSurface` and `renderer.drawReflection` march 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 \u2014 the part of a reflection a viewer reads first.\n\n**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 \u2014 the shape `DecalProjector` already has.\n\n**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 \u2014 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.\n\n**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 \u2014 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.\n\n**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 \u2014 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 \u2014 and a crossing further behind than `thicknessM` is refused, without which a ray sliding onto something in the foreground pastes it into the reflection.\n\n**7.5 KB gzipped on every core entry point.** It needs `screenEffects` and 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."
|
|
611
|
+
}
|
|
612
|
+
]
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
"version": "3.36.0",
|
|
616
|
+
"date": "2026-09-02",
|
|
617
|
+
"entries": [
|
|
618
|
+
{
|
|
619
|
+
"kind": "added",
|
|
620
|
+
"short": "Drawn decals on both backends: a projector evaluated against the frame's own depth, so a mark follows a surface that deforms or streams in after it was placed.",
|
|
621
|
+
"text": "**`projectDecal` clips 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.\n\n`DecalProjector` and `renderer.drawDecal` keep 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 \u2014 so cloth, a heightfield being rewritten, a skinned mesh or an instanced crowd is marked on the frame it is drawn.\n\n**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.\n\n**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 \u2014 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 what `scripts/decal-check.mjs` is set against.\n\n**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.\n\n**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 \u2014 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 \u2014 a beam, a particle \u2014 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."
|
|
622
|
+
}
|
|
623
|
+
]
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
"version": "3.35.0",
|
|
627
|
+
"date": "2026-09-02",
|
|
628
|
+
"entries": [
|
|
629
|
+
{
|
|
630
|
+
"kind": "added",
|
|
631
|
+
"short": "Order-independent transparency on both backends, off by default: two panes of glass stop depending on which was submitted first, including where they intersect and no sort can help.",
|
|
632
|
+
"text": "**Sorted alpha blending is order-dependent by construction** \u2014 `over` does not commute \u2014 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.\n\n`renderQuality.orderIndependent` replaces 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.\n\n**Measured on an AMD card at 1280x720, both backends.** Two intersecting panes drawn in both orders give digests `2a644bb9` and `bb4cb7b1` with the effect off, and `f8913765` both times with it on \u2014 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 how `scripts/oit-check.mjs` was shown to have teeth.\n\n**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 `flatFrag` permutation \u2014 about 247 KB gzipped, paid by every consumer whether or not they enable this. The weighting is a uniform branch instead, which cost `flat.wgsl.ts` 18 KB, and the geometry is submitted twice on the frames that asked for the effect.\n\nNeeds 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_float` it refuses and says so once. Multisampling is excluded."
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
"version": "3.34.0",
|
|
638
|
+
"date": "2026-09-02",
|
|
639
|
+
"entries": [
|
|
640
|
+
{
|
|
641
|
+
"kind": "added",
|
|
642
|
+
"short": "Temporal antialiasing on both backends, off by default: the projection is jittered a fraction of a pixel each frame and blended into where the last was, so an edge stops crawling as the camera moves.",
|
|
643
|
+
"text": "**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 \u2014 the crawl seen on every slow pan. `renderQuality.temporalAa` jitters 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.\n\n**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.\n\n**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% \u2014 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.mjs` was shown to have teeth.\n\n**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.\n\n**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.html` drives it, `?taa=0` against `?taa=1`."
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
"kind": "fixed",
|
|
647
|
+
"short": "The previous frame's view-projection is kept whenever an effect reprojects, not only while camera motion blur is on, so one running alone no longer reads a matrix nothing has written.",
|
|
648
|
+
"text": "The reprojection matrix was built and `previousViewProj` written only while `cameraMotionBlur` was above zero. Any other effect reprojecting through it \u2014 the temporal resolve is the first \u2014 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."
|
|
649
|
+
}
|
|
650
|
+
]
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
"version": "3.33.0",
|
|
654
|
+
"date": "2026-09-02",
|
|
655
|
+
"entries": [
|
|
656
|
+
{
|
|
657
|
+
"kind": "fixed",
|
|
658
|
+
"short": "glTF's archived specular-glossiness model imports. Such a file bound none of its textures and painted flat; its diffuse maps bind and the specular/glossiness pair solves into a metalness.",
|
|
659
|
+
"text": "**`KHR_materials_pbrSpecularGlossiness` was not read at all**, and a material using it carries **no `pbrMetallicRoughness` object** \u2014 its colour map is `diffuseTexture`. The reader therefore found no base colour map, returned `albedo: -1` for 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 \u2014 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.\n\n**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 \u2014 diffuse black, colour entirely in the specular \u2014 arrive as chrome rather than as a black mirror.\n\n**A `specularGlossinessTexture` is 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 \u2014 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** \u2014 once per material rather than once per primitive, which on that car is four warnings instead of a hundred and forty-four.\n\n**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 `metallicRoughnessTexture` in the core block, which is a better answer than this conversion can compute.\n\n**And it reaches a `.glb`.** The workaround this replaces rewrote the `.gltf` JSON before the reader saw it, so a `.glb` \u2014 whose JSON is a length-prefixed chunk inside the file \u2014 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."
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
"kind": "fixed",
|
|
663
|
+
"short": "The baker leaves out images no material samples and renumbers the ordinals that stay. A bake carried its source's whole texture list before: 20 textures on a car that reached none of them.",
|
|
664
|
+
"text": "**Every image the source declared was embedded, whether or not anything could sample it.** `MATL` carries four texture ordinals and they index `TEXS`, so compacting that list without rewriting them repaints the model \u2014 which is exactly why `readModel` leaves a reference that resolved to nothing in place, and why `embedTextures` writes 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.\n\nIt 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 \u2014 the stage that turned 3.6 MB of DDS into 11.6 MB of RGBA on a shipped car.\n\n**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.\n\n**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)`.\n\n**The cost is stated rather than hidden.** An image no material samples is no longer in the asset, so `TextureSet.get` throws 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` \u00a73 had promised this behaviour \u2014 *only images a material actually reaches are embedded* \u2014 for longer than it had been true."
|
|
665
|
+
}
|
|
666
|
+
]
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
"version": "3.32.0",
|
|
670
|
+
"date": "2026-09-02",
|
|
671
|
+
"entries": [
|
|
672
|
+
{
|
|
673
|
+
"kind": "fixed",
|
|
674
|
+
"short": "The WebGPU draw and material rings grow to fit the scene. A frame that ran out loses geometry once and is whole from the next, instead of for as long as the scene stays that size.",
|
|
675
|
+
"text": "**Past `MAX_DRAWS_PER_FRAME` or `MAX_MATERIALS_PER_FRAME` the backend skipped the work, and went on skipping it every frame.** A consumer whose world simply got bigger lost a stretch of it permanently \u2014 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.\n\nBoth 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 176` and the third reports nothing dropped**, drawing all 1,200.\n\n**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. At `beginFrame` everything outstanding has been submitted and nothing has been written into the staging that discarding it would lose.\n\n**Growing replaces the `GPUBuffer`, so every bind group holding it is rebuilt** \u2014 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.\n\n**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.\n\nThe other eleven ceilings are unchanged. They are feature counts \u2014 sixteen bodies of water, eight flocks \u2014 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 `frameBudget` like everything else."
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
"kind": "added",
|
|
679
|
+
"short": "renderer.frameBudget reports what a frame asked for against every ceiling the backend imposes, so a check can fail on it instead of a console warning nobody reads.",
|
|
680
|
+
"text": "**A per-frame ceiling firing was unobservable, and that cost a consumer weeks.** The WebGPU backend holds thirteen of them \u2014 draws, material changes, shadow draws, water bodies, light volumes, three kinds of effect batch, overlays \u2014 and past any one it skips the work and writes a single `console.warn` for 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.\n\n`renderer.frameBudget` is that number. Each line carries what was **asked for** \u2014 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.dropped` is 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.\n\n**Counted in `UniformRing` rather 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: `materialSlotForDraw` reuses an open slot without allocating, so the material line counts material *changes* and not draws.\n\n**WebGL2 reports the same lines with a `null` ceiling, 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 \u2014 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."
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
"kind": "fixed",
|
|
684
|
+
"short": "The WebGPU material-ceiling warning said draws would reuse the last material. They are skipped, and always have been. The doc comment that raised the ring from 256 said the same wrong thing.",
|
|
685
|
+
"text": "**`WebGPU: more than 1024 materials in a frame; the rest reuse the last` was wrong in the commit that wrote it.** `materialSlotForDraw` returns 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 \u2014 it has never described the code.\n\n**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.\n\n**And the wrong failure was the stated argument for the ring's size.** The comment at `MAX_MATERIALS_PER_FRAME` justified 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."
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
"kind": "fixed",
|
|
689
|
+
"short": "Two WebGPU tests named for the exhausted material ring stopped reaching it when that ring grew, and kept passing. They drive to the ceiling the renderer reports now.",
|
|
690
|
+
"text": "**`restores uLightingEnabled/uFogEnabled even when the material ring is exhausted` and its sibling both wrote `const materialRingCapacity = 256` and issued 257 draws.** When the ring became 1,024 they went on passing without ever reaching the null return they are named for \u2014 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.\n\nThey read the ceiling off `frameBudget` now, 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."
|
|
691
|
+
}
|
|
692
|
+
]
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
"version": "3.31.0",
|
|
696
|
+
"date": "2026-09-02",
|
|
697
|
+
"entries": [
|
|
698
|
+
{
|
|
699
|
+
"kind": "added",
|
|
700
|
+
"short": "Spring secondary motion in @driftengine/animation: hair, a coat, an antenna or a chain, sampled as a pure function of the time you ask about, so it scrubs backwards.",
|
|
701
|
+
"text": "**`sampleSpring` and `sampleSpringChain` answer *where is it at time t*, for any `t`, in any order.** Nothing accumulates between calls, nothing reads a clock, and the same `t` gives the same three floats whether the caller reached it by playing forwards or by dragging a playhead back \u2014 which is `sampleClip`'s contract and the reason this sits beside it rather than in `@driftengine/physics`.\n\n**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^(-\u03b6\u03c9(t-\u03c4))`, so evaluating at `t` is: start at rest a settle time earlier, march forward, return. `springSettleSec` is that lookback, derived rather than dialled.\n\n**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 \u2014 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.\n\n**A chain is not a list of springs, and `springChainSettleSec` is 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 in `t` beside the exponential and the lookback grows with depth. Sampling links independently would also cost `steps^depth` anchor calls, so the whole chain marches on one grid \u2014 `steps \u00d7 depth`, same answer.\n\n**`maxOffsetM` is 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."
|
|
702
|
+
}
|
|
703
|
+
]
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"version": "3.30.2",
|
|
707
|
+
"date": "2026-09-02",
|
|
708
|
+
"entries": [
|
|
709
|
+
{
|
|
710
|
+
"kind": "added",
|
|
711
|
+
"short": "The DDS reader takes a DX10 header and BC2 (DXT3). A consumer carrying a header rewrite of its own because this reader would not take the file can delete it.",
|
|
712
|
+
"text": "**A `DX10` header 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_SRGB` is bit for bit the BC3 announced as `DXT5` \u2014 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\u00b2 chain, which is 148 + 5,592,432 against 128 + 5,592,432.\n\n**BC2 (DXT3) decodes too**: eight bytes of explicit four-bit alpha, one nibble a texel, scaled by replication so `0xF` arrives as 255 and not 240.\n\n**Measured on the six vehicle bundles it was reported from: 31 surfaces of 766 that returned a 1x1 white pixel now decode** \u2014 19 at BC3 behind a `DX10` header, 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.\n\n**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.\n\n**If you carry a header rewrite of your own, it can go.** `blockDdsFromDx10` in `confine`'s `scripts/dds.mjs` exists only because this reader would not take the file."
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
"kind": "added",
|
|
716
|
+
"short": "An uncompressed DDS decodes: 32- and 24-bit RGB in any channel order, and 8- and 16-bit luminance, with or without alpha.",
|
|
717
|
+
"text": "**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.\n\n**One mask walk and no per-format branch**, because the masks *are* the format \u2014 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.\n\n**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 `0x1F` is 255 and not 248 \u2014 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 assumes `width * bytes` walks diagonally through a padded surface and returns the right picture progressively more sheared down the frame.\n\n**Measured across those bundles: all 766 DDS surfaces now decode and none is refused**, against 477 and 289 before this release."
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"kind": "fixed",
|
|
721
|
+
"short": "A BC3 block whose colour endpoints are ordered low-to-high no longer decodes its fourth index to transparent black. BC2 and BC3 never take BC1's punch-through mode.",
|
|
722
|
+
"text": "**`decodeColourBlock` was 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 with `c0 <= c1` therefore decoded its fourth index to transparent black instead of to the interpolant two thirds of the way between them.\n\nInvisible 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 \u2014 it says the same thing about the same bytes \u2014 and it has a test that fails without the fix."
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
"kind": "changed",
|
|
726
|
+
"short": "A .kn5 material's txMaps is no longer bound as a glTF ORM map. Every Assetto Corsa car's paint was rendering fully matte; it now keeps the roughness its own ksSpecularEXP states.",
|
|
727
|
+
"text": "**glTF's ORM is occlusion in R, roughness in G and metallic in B. AC's `txMaps` is not that packing** \u2014 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.\n\n**The half of that which needed no reference** is that `roughness` is derived from `ksSpecularEXP` two 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.\n\nNot binding it is not a claim about what `txMaps` means; 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.\n\n**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.md` carries the measurements and what would justify reading the map properly \u2014 a shader reference for `ksPerPixelMultiMap` settling the curve between AC's sharpness and this engine's roughness."
|
|
728
|
+
}
|
|
729
|
+
]
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
"version": "3.30.1",
|
|
733
|
+
"date": "2026-09-02",
|
|
734
|
+
"entries": [
|
|
735
|
+
{
|
|
736
|
+
"kind": "fixed",
|
|
737
|
+
"short": "Bolts, flocks, wind streaks and caustics each hold a uniform slot per batch on WebGPU, so a frame drawing two of any of them no longer gives both the second one's colour, width, time and placement.",
|
|
738
|
+
"text": "**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 \u2014 and one of each is correct, which is why it never showed. Water had the same defect and lost three bodies of four.\n\nEach now takes a slot from a `UniformRing` and 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.\n\n**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:batches` is the instrument.\n\n**The flock said more than the tint**: both flocks drew at the same centre, so the shared slot was carrying placement too \u2014 the blue count stayed at exactly one flock's instead of doubling.\n\n**A scene drawing one batch each is unchanged.** `storm-sea` on WebGPU \u2014 a flock, arcs and rain, one batch each \u2014 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."
|
|
739
|
+
}
|
|
740
|
+
]
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
"version": "3.30.0",
|
|
744
|
+
"date": "2026-09-02",
|
|
745
|
+
"entries": [
|
|
746
|
+
{
|
|
747
|
+
"kind": "added",
|
|
748
|
+
"short": "drawKeyOf and resolveDrawGrouping expose the loader's own draw-call grouping, so a consumer counting the draws a bake will cost calls the function the loader calls.",
|
|
749
|
+
"text": "**`DrftLoader` merges 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 from `DrftMaterial` by hand \u2014 its own comment said \"`DrftLoader.uploadOne`'s own key, field for field\", which is exactly what it was.\n\nIt went one field short the day `cutout` joined 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 and `resolveDrawGrouping` returns the ten fields behind it, both from `@driftengine/assets`. `uploadOne` calls them, so the inline key is gone rather than duplicated and the two cannot drift.\n\nThe 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."
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
"kind": "changed",
|
|
753
|
+
"short": "The baker writes a decoded DDS back as PNG instead of embedding it uncompressed, which had taken 21 textures of one shipped car from 3.6 MB of source to 11.6 MB of RGBA.",
|
|
754
|
+
"text": "**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`** \u2014 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.\n\n`encodePng` now 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.\n\n**The repository's own PNG reader is the wrong oracle for half of it, so a browser was used for that half.** `decodePng` never reads a CRC, so an encoder writing zeros there would round trip through it perfectly and be refused by every browser at `createImageBitmap`. Loaded into headless Chrome, drawn and read back: 0 of 12,288 samples differ.\n\n**The baker had no test at all**, which is how this stood for three minor versions. It has one now, and it reads `TEXS` by the layout `FORMAT.md` states rather than with this repository's reader, since a reader and a writer sharing a mistake agree with each other."
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
"kind": "changed",
|
|
758
|
+
"short": "readModel and gltfToMeshes take deriveTangents, and the baker declines it so the frame is derived after the weld instead of before it. The default is unchanged.",
|
|
759
|
+
"text": "**`generateTangents` accumulates a frame per index, so a vertex shared by several triangles comes out smooth \u2014 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, and `weldMesh` cannot 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.\n\n`readModel` and `gltfToMeshes` take `deriveTangents`, **on by default**, and `deriveTangentsFor` is exported beside `weldMesh`. The baker passes false and derives afterwards, on merged topology \u2014 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.\n\n**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 \u2014 u from x, v from y \u2014 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."
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
"kind": "note",
|
|
763
|
+
"short": "Two light volumes in one frame are the sum of each drawn alone, to 0 of 844,800 pixels on both backends. No engine code changed; nothing had ever drawn a second cone.",
|
|
764
|
+
"text": "**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 \u2014 `drawBolts`, `drawFlock`, `drawWindStreaks` and `drawCaustics` each write one shared buffer with `queue.writeBuffer` while the frame's encoder is submitted afterwards. Water had exactly this and lost three bodies of four. `drawLightVolume` is 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.\n\n`demo/dev/volume.html` gained `?cones=`, `?only=`, `?phase=` and `?warm=`, and `npm run check:cones` reads 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.\n\nThe 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."
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
"version": "3.29.1",
|
|
770
|
+
"date": "2026-09-01",
|
|
771
|
+
"entries": [
|
|
772
|
+
{
|
|
773
|
+
"kind": "fixed",
|
|
774
|
+
"short": "ShadowCasterSink.instanced is optional, so a consumer implementing that interface still compiles. 3.29.0 added it as required and broke every existing implementation.",
|
|
775
|
+
"text": "**3.29.0 added `instanced` to `ShadowCasterSink` as a required member, which is a breaking change and should not have shipped in a minor.**\n\nThat 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 \u2014 found immediately in a consumer, where three test doubles went red against a feature that game does not use and has no batches for.\n\nIt 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.\n\n**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\u2019s own implementations of an interface it exports."
|
|
776
|
+
}
|
|
777
|
+
]
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
"version": "3.29.0",
|
|
781
|
+
"date": "2026-09-01",
|
|
782
|
+
"entries": [
|
|
783
|
+
{
|
|
784
|
+
"kind": "added",
|
|
785
|
+
"short": "A mesh already on the device can be drawn many times over with a placement and a colour per instance, in one draw call and one material slot, on both backends and casting one shadow each.",
|
|
786
|
+
"text": "**Thirty copies of one model are one draw call \u2014 and, the half that matters more, one slot of the frame\u2019s material ring.** `createInstanced` attaches per-instance placement and colour to a mesh already uploaded; `uploadInstanced` pushes the live prefix; `drawInstanced` and `drawTranslucentInstanced` submit the batch; `disposeInstanced` releases it. `MeshInstances` carries a column-major matrix and an RGB tint per instance, and `createMeshInstances` allocates both arrays once so a frame that re-uploads a moving batch allocates nothing.\n\n**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 \u2014 the geometry an instanced draw repeats is usually a loaded model, and a second copy of it is the cost this exists to avoid \u2014 and carries a full transform, because a vehicle pitches and rolls on its suspension and a yaw cannot say so.\n\n**Shadows came with it.** `ShadowCasterSink` grows an `instanced` verb 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 \u2014 which reads as a lighting fault rather than as a pass nobody wrote.\n\n**Three refusals, each loud rather than a silent degrade.** A skinned mesh cannot be instanced: eleven of WebGL2\u2019s 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 \u2014 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\u2019s own vertex array.\n\n**What would make the layout wrong** is a twelfth base attribute, which cannot coexist with instancing at all.\n\nDriven 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 \u2014 changing nothing else \u2014 produced a frame identical in **0 of 704,000 pixels**."
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
"kind": "changed",
|
|
790
|
+
"short": "The engine\u2019s stated draw-call budget of under 100 is replaced by measured bands. It had been wrong for as long as anyone was using the engine.",
|
|
791
|
+
"text": "`AGENTS.md` had said `< 100 draw calls` since before the first production consumer existed. That consumer measures **744 draws in its worst world frame** and has since its world existed \u2014 so the budget was not a target anybody held to, it was a number a reader would either design around needlessly or, correctly, ignore.\n\nReplaced by bands: roughly **1,000\u20132,000** for a high-refresh target, **2,000\u201310,000** for ordinary 60 fps, and far past that where the draws are instanced. `MAX_DRAWS_PER_FRAME` is 4,096 and is a ring size rather than an opinion, sized so a consumer inside those bands never meets it.\n\n**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. `drawInstanced` is the answer where the same mesh repeats."
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"kind": "added",
|
|
795
|
+
"short": "A blended draw can carry a per-draw tint, so a model faded in with a runtime colour keeps that colour while it fades instead of arriving grey.",
|
|
796
|
+
"text": "`TranslucentMeshOptions.tint` is the per-draw colour multiplier `drawMesh` has always taken as its fourth argument, now available to the blended path as well.\n\n**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` \u2014 which wrote no tint \u2014 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.\n\nIn 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."
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"version": "3.28.1",
|
|
802
|
+
"date": "2026-09-01",
|
|
803
|
+
"entries": [
|
|
804
|
+
{
|
|
805
|
+
"kind": "changed",
|
|
806
|
+
"short": "The per-frame draw and material ceilings go to 4096 and 1024. Neither was a hardware limit, and raising a ring costs allocation once rather than bandwidth every frame.",
|
|
807
|
+
"text": "**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_FRAME` goes from 1024 to **4096** and `MAX_MATERIALS_PER_FRAME` from 256 to **1024**.\n\nThe measurement is the whole argument. `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two hundred whatever the capacity says \u2014 **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.\n\nA 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.\n\nThe 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.\n\n**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."
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
"kind": "fixed",
|
|
811
|
+
"short": "A frame recording more graph nodes than the replay scratch held dropped the tail of its passes silently. The scratch now grows with the arena, as two comments already claimed it did.",
|
|
812
|
+
"text": "**A frame heavy in graph nodes replayed only the first 1024 of them, and said nothing.**\n\nThe 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_FRAME` and never grown, and `keptNodes` stops at the end of the array it is given. So the tail of that frame's passes was replayed by nobody \u2014 no warning, no error, and a plausible picture missing whatever those passes drew.\n\nBoth `keptNodes` and 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.\n\nIt was reachable well below the draw ceiling, because a node is recorded **per verb** rather than per draw \u2014 overlays, scatter, plumes and text each record one. The bound in `keptNodes` stays, 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."
|
|
813
|
+
}
|
|
814
|
+
]
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
"version": "3.28.0",
|
|
818
|
+
"date": "2026-09-01",
|
|
819
|
+
"entries": [
|
|
820
|
+
{
|
|
821
|
+
"kind": "added",
|
|
822
|
+
"short": "A blended draw can decline to write depth, so a set of translucent surfaces blends through itself instead of the first one drawn hiding the rest.",
|
|
823
|
+
"text": "**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 \u2014 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.\n\n**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.\n\n**`{ 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.html` shows all three states \u2014 the defect, the half fix, and both together \u2014 because this is a failure a photograph shows and no number does."
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
"kind": "added",
|
|
827
|
+
"short": "A blended draw can declare a depth layer, so a decal coplanar with what it decorates stops fighting it. On WebGPU the layer had been accepted and ignored outright.",
|
|
828
|
+
"text": "**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. `drawMesh` has had `depthLayer` since 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 \u2014 which is most of what an imported interior's decal layer is.\n\n**And on WebGPU the parameter was accepted and dropped.** `drawMesh` there took a `depthLayer` and 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.\n\n**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 \u2014 given the opposite the two terms fight, the slope wins at a grazing angle, and the overlay disappears behind what it decorates.\n\n**Gated rather than argued: every published scene is 0 of 921,600 pixels on both backends** against the release before it."
|
|
829
|
+
}
|
|
830
|
+
]
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
"version": "3.27.0",
|
|
834
|
+
"date": "2026-09-01",
|
|
835
|
+
"entries": [
|
|
836
|
+
{
|
|
837
|
+
"kind": "fixed",
|
|
838
|
+
"short": "A weld silently dropped a mesh's rig, tangents, relief and morph targets, and merged corners that differed only in them. A skinned model came back unskinned, drawing at its bind pose.",
|
|
839
|
+
"text": "**The weld knew about five of the format's optional attributes and there are ten.** `MeshData` gained a tangent frame and relief on 21 August, then a rig and morph deltas on the 25th; `weldMesh` was written on the 21st and was told about none of them. Both halves of it were lists written out by hand \u2014 the key that decides whether two corners are the same vertex, and the copy that writes the survivors \u2014 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.\n\n**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.\n\n**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.\n\n**One table drives both halves now, and it is checked against `MeshData` at 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.\n\nWhat 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."
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
"kind": "changed",
|
|
843
|
+
"short": "An importer derives a tangent frame only where a material declares a normal map. Deriving one for every mesh with a texture coordinate cost 11 MB of a 62.7 MB bake that nothing could sample.",
|
|
844
|
+
"text": "**A tangent frame exists to sample a normal map, and this engine reads one in a single place** \u2014 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.\n\n**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.\n\n**A `TANGENT` the 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 calls `generateTangents` itself, which is exported for it.\n\n**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."
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
"kind": "fixed",
|
|
848
|
+
"short": "A relief array that is zero everywhere is no longer stored. Absent already means a perfectly smooth surface.",
|
|
849
|
+
"text": "`dropDefaultAttributes` drops an all-zero `relief` array now, alongside the specular, roughness, grain and emissive-colour arrays it already dropped. Relief landed on `MeshData` on 21 August and this function was not told, so a producer filling it with zeroes spent four bytes a vertex saying \"no relief\" \u2014 which is the one thing an absent attribute already says. Same omission as the weld's above, same day, one line."
|
|
850
|
+
}
|
|
851
|
+
]
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
"version": "3.26.0",
|
|
855
|
+
"date": "2026-09-01",
|
|
856
|
+
"entries": [
|
|
857
|
+
{
|
|
858
|
+
"kind": "fixed",
|
|
859
|
+
"short": "A bought car model imported mirrored. The reader believed its format's declared handedness and turned every left-hand-drive vehicle into a right-hand-drive one.",
|
|
860
|
+
"text": "**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 \u2014 the tool that writes it targets a left-handed graphics API, which is a good reason and not evidence \u2014 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.\n\n**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.\n\n**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 `+z` and 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` \u2014 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.\n\nSo the reader reports the frame it measured and mirrors nothing. `convertHandedness` stays in `orient.ts` for a format that genuinely is left-handed, and none of the formats read here is one."
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
"kind": "fixed",
|
|
864
|
+
"short": "A mirrored asset's hierarchy is mirrored too. Only a node's translation was, so a graph with rotations placed parts a metre from their own geometry.",
|
|
865
|
+
"text": "**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 \u2014 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.\n\n`mirrorNodes` is the pair to `convertHandedness`, exactly as `orientNodes` is the pair to `orientMeshes`, and it is exported beside it. The rotation is conjugated rather than negated \u2014 reflecting a rotation gives `M R M`, which as a quaternion is `(x, -y, -z, w)` \u2014 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."
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
"kind": "fixed",
|
|
869
|
+
"short": "Nine surfaces of a hundred and two arrived invisible on an imported car. An alpha test was being read as an opacity, so every material that asked for no test came out at zero.",
|
|
870
|
+
"text": "**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** \u2014 four tyres, four rims and the side windows \u2014 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.\n\n**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 \u2014 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.\n\nSo 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."
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
"kind": "added",
|
|
874
|
+
"short": "Container 1.10 carries a material's cutout, so a masked surface keeps the holes its image gives it instead of arriving as a solid rectangle.",
|
|
875
|
+
"text": "**A cutout is a test and an opacity is not, and the container had only the second.** `SurfaceMaterial.cutout` has been in the renderer since decals landed, defined as the alpha below which a fragment is discarded, and nothing could reach it: `MATL` had no such field and a part carried no such number, so a surface whose *shape* lives in its alpha channel \u2014 a grille, a vent, foliage, a fence \u2014 arrived as the rectangle its geometry actually is.\n\n**glTF states it exactly and it was being dropped.** `alphaMode: 'MASK'` with `alphaCutoff`, 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.\n\n**Additive behind the stride `MATL` already declares.** The entry grows from 72 bytes to 76 and an older file's shorter entry opens with the field defaulted to 0 \u2014 discard nothing, which is what every file written before it meant. `DrftPart` carries it beside `opacity`, 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."
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
"kind": "fixed",
|
|
879
|
+
"short": "localiseNodes is exported from @driftengine/assets, which the handbook has told people to import for two releases. Every import the docs print is now checked against the barrel.",
|
|
880
|
+
"text": "**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 \u2014 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.\n\nIt is exported, with `Localised` beside it. **And the class of defect is closed rather than the instance**: `scripts/docs.test.mjs` now reads every `import { \u2026 } from '@driftengine/\u2026'` 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."
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
"kind": "added",
|
|
884
|
+
"short": "DrftLoader.load takes { fit: 'none' } for a model an importer has already placed, instead of being handed the numbers it would have computed.",
|
|
885
|
+
"text": "**There was no way to decline the fit, and the obvious substitute is a trap.** `load` scales the larger of a model's footprint and height to a size, centres it in X and Z and stands it on `baseY`, 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.\n\nA 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_INTEGER` is 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.\n\n`{ fit: 'none' }` is scale 1 and no offset, and the docstring names the trap it replaces."
|
|
886
|
+
}
|
|
887
|
+
]
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
"version": "3.25.0",
|
|
891
|
+
"date": "2026-08-31",
|
|
892
|
+
"entries": [
|
|
893
|
+
{
|
|
894
|
+
"kind": "added",
|
|
895
|
+
"short": "A model's node hierarchy survives the import. Parts arrive named and placed, so a consumer can find a part by name and move it, instead of receiving one welded mesh.",
|
|
896
|
+
"text": "**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.nodes` is where a reader reports the graph instead, and the baker writes it as `NODE`, a chunk the container has declared since its first version and nothing ever filled.\n\n**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.\n\n**`localiseNodes` is 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.\n\n**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.\n\n**`orientNodes` closes 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."
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
"kind": "added",
|
|
900
|
+
"short": "Block-compressed textures decode in the baker. BC1, BC3 and BC5 become RGBA, so a model whose maps are block-compressed bakes fully textured instead of half-painted.",
|
|
901
|
+
"text": "**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.\n\n`dds.ts` decodes 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.\n\n**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.\n\nAnything else is refused by name with its identifier, which is worth more than a surface half-decoded into something plausible."
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
"kind": "added",
|
|
905
|
+
"short": "Container 1.9 adds LODF, a discrete level of detail carried as a complete nested asset with its own meshes, materials and hierarchy.",
|
|
906
|
+
"text": "**A hand-authored level of detail is a whole model, and the container had nowhere to put one.** `LODM` holds 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 into `LODM` would merge its parts into one mesh and discard every material it has.\n\n**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.\n\n**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.\n\n**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-levels` exists."
|
|
907
|
+
}
|
|
908
|
+
]
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
"version": "3.24.0",
|
|
912
|
+
"date": "2026-08-31",
|
|
913
|
+
"entries": [
|
|
914
|
+
{
|
|
915
|
+
"kind": "added",
|
|
916
|
+
"short": "A mesh can be uploaded over several frames. createMeshIncremental hands back a usable handle at once and an iterator that moves the geometry a chunk at a time, under whatever budget a caller gives it.",
|
|
917
|
+
"text": "**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 dozen `createMesh` calls 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 was `createMesh` alone, with disposal and bookkeeping never reaching 2.\n\n**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. `createMeshIncremental` is 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 under `StepBudget` and the square lands over as many frames as the budget allows.\n\n**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.\n\n**Nothing half-uploaded is drawn.** `MeshHandle.complete` says whether the geometry has landed, and `drawMesh`, `drawTranslucentMesh` and `drawLightVolume` skip 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.\n\n**A lost device or context ends the upload**, leaving `complete` false. 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.\n\n**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 `writeBuffer` under a loaded queue, since staging back-pressure would not show on an idle GPU with headroom."
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
"kind": "added",
|
|
921
|
+
"short": "A frame budget for work that can stop. StepBudget spends a few milliseconds advancing resumable jobs and bounds a call at the larger of the budget and one uninterrupted stop, then names the worst.",
|
|
922
|
+
"text": "**A budget for work that can stop, holding the clock and the arithmetic while the caller holds the work.** `StepBudget.spend(ms, next)` calls `next` once per indivisible unit until the budget is gone; `next` does one unit and returns a label naming what it did, or null when there is nothing left.\n\n**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. `worst` and `worstIn` are what make that actionable, because a budget being blown is useless information without the name of the unit that blew it.\n\n**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.\n\n**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.\n\n**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."
|
|
923
|
+
}
|
|
924
|
+
]
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
"version": "3.23.0",
|
|
928
|
+
"date": "2026-08-30",
|
|
929
|
+
"entries": [
|
|
930
|
+
{
|
|
931
|
+
"kind": "fixed",
|
|
932
|
+
"short": "A ragdoll's joints are anchored at the bone ends they share, not through the middle of the bone. A knee that drifted 23 cm open on a half-metre chain now holds to under half a millimetre.",
|
|
933
|
+
"text": "**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.\n\nAnchored 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.\n\n**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.\n\n**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.\n\n**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. `drive` looked 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.\n\nBoth 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."
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
"kind": "added",
|
|
937
|
+
"short": "Two bodies can be told never to collide, whatever their layers say. A ragdoll uses it to keep each bone off the one it hangs from while it still collides with the rest of the body.",
|
|
938
|
+
"text": "**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, with `allowPair` and `pairIgnored` beside it.\n\n**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.\n\n**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: false` is there for a crowd far enough away that nobody can see, and the default is the correct behaviour.\n\n**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.\n\n**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."
|
|
939
|
+
}
|
|
940
|
+
]
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
"version": "3.22.0",
|
|
944
|
+
"date": "2026-08-30",
|
|
945
|
+
"entries": [
|
|
946
|
+
{
|
|
947
|
+
"kind": "added",
|
|
948
|
+
"short": "Reversed-Z depth with a float buffer, on both backends. Precision becomes roughly uniform with distance, so a near plane can come in and decals stop needing offsets nobody chose.",
|
|
949
|
+
"text": "**Depth runs the other way, and `depthConvention.ts` is the one place that says so.** A conventional buffer spends its precision hyperbolically, about `z\u00b2 / (near \u00b7 2^bits)` at distance `z`, 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.\n\n**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.\n\n**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.\n\n**A game reads what the context granted, never the engine's wish.** WebGL2 needs `EXT_clip_control` and a context without it keeps the conventional sense, so `reversedDepth` on the created renderer is the fact to key a near plane on.\n\n**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.\n\n**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."
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
"kind": "fixed",
|
|
953
|
+
"short": "Two depth defects found by converting: the sky wrote the near plane once reversed, covering the world, and the camera blur recovered clip z with the wrong inverse.",
|
|
954
|
+
"text": "**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 \u2014 so it painted over the entire world, measured at **743,669 changed pixels of 921,600** in one frame. It now writes `glslFarDepth()`. And `rush.ts` recovered clip z from the depth texture as `depth * 2 - 1`, whose reversed inverse is `1 - 2 * depth`; four shaders carried that expression by hand and now share `glslSceneDepthToNdc`, with `DEPTH_CLIP_CORRECTION` negating in step so the matrix and the shader cannot drift. The renderer's own reprojection test is what caught it."
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
"kind": "added",
|
|
958
|
+
"short": "Asking a real GPU what depth precision it can offer on both backends: whether clip control is there decides how much a reversed-Z buffer can win on WebGL2.",
|
|
959
|
+
"text": "**`scripts/depth-survey.mjs` (`depth-check.mjs` until 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 exposes `EXT_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_control` offered, `DEPTH_COMPONENT32F` and `depth32float` both available."
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
"kind": "added",
|
|
963
|
+
"short": "A game can ask which way depth runs. The renderer reports what the context granted, and the constants a near plane and a decal offset depend on are exported.",
|
|
964
|
+
"text": "**A consumer can ask which way depth runs.** `CreatedRenderer.reversedDepth` reports what the context *granted*, reported the way `rendererName` is and read off the renderer in the same one place, because on WebGL2 it depends on `EXT_clip_control` and 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_SIGN` and `conventionalDepth` are exported for what the engine asks; the two agree everywhere except a WebGL2 context without that extension.\n\n**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 \u2014 it had to choose for the worse case and gained nothing from the better one. A conventional buffer resolves about `z\u00b2 / (near \u00b7 2^bits)`, which is what makes the near plane the only control there is."
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
"kind": "changed",
|
|
968
|
+
"short": "`MeshBuilder.build()` is a slice instead of ten conversions: 41.9 ms to 1.6 ms for 110,628 triangles. A streamer's batch cap no longer has to be a number tuned to one laptop.",
|
|
969
|
+
"text": "**`MeshBuilder.build()` went from 41.9 ms to 1.6 ms for 110,628 triangles**, measured on this machine. It accumulated into ten plain `number[]` and turned each into a typed array in one uninterruptible call at the end \u2014 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.\n\nThe 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 `build` asked \u2014 specular, emissive colour, roughness, grain, relief \u2014 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:mesh` reports both halves.\n\n**`MeshBuilder.triangleCount` and `vertexCountSoFar`** answer 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."
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
"kind": "changed",
|
|
973
|
+
"short": "World text is one box per horizontal run of lit cells rather than one per cell: 1.52x fewer triangles, and pixel-identical on both backends across every published scene.",
|
|
974
|
+
"text": "**`buildTextMesh` merges horizontal runs of lit cells into one box each.** It put a box \u2014 twelve triangles \u2014 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 \u2014 and their world is 1,195,296 triangles of lettering out of 2,662,324, forty-five per cent.\n\n**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.\n\n**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. `forEachRun` is exported beside `forEachCell` for anything else that walks a glyph."
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"kind": "fixed",
|
|
978
|
+
"short": "Ambient occlusion's two far-plane guards asked whether depth had reached 1.0, which is the near plane once depth is reversed.",
|
|
979
|
+
"text": "**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 \u2014 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.\n\n**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 \u2014 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.\n\n**`REVERSED_DEPTH` remains `false`**, and every published scene is pixel-identical on both backends at zero tolerance with it off."
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
"kind": "fixed",
|
|
983
|
+
"short": "Three WebGL2 shadow defects that only appeared once depth was reversed: a double-corrected lookup matrix, point-shadow faces losing half their range, and lamp bakes running the frame's own compare.",
|
|
984
|
+
"text": "**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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."
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
"kind": "added",
|
|
988
|
+
"short": "boundsOfBox builds bounds for a region a game names itself, so a streamed world can ask the renderer whether a square is on camera instead of testing every mesh standing on it.",
|
|
989
|
+
"text": "**`visible(bounds, model)` already answered this and nothing could build the question.** Measuring a vertex array was the only way to make a `Bounds`, 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.\n\n**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 `cullDraws` saves 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.\n\n**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.\n\n`MeshHandle.bounds` is what to pass for a mesh, and the rendering notes now say so, which they did not."
|
|
990
|
+
}
|
|
991
|
+
]
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"version": "3.21.0",
|
|
995
|
+
"date": "2026-08-30",
|
|
996
|
+
"entries": [
|
|
997
|
+
{
|
|
998
|
+
"kind": "added",
|
|
999
|
+
"short": "`heightSurface` takes a list of height fields as well as one, so a road can pass under a road. Each layer says `NaN` where it is not there and the asker's own height picks the floor.",
|
|
1000
|
+
"text": "**`heightSurface` takes a list of height fields, so a road can pass under a road.** One function of `x` and `z` has 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 \u2014 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 rule `CompositeSurface` and `RibbonSurface` already use between their own passes, and layers compose through the first of those rather than through a second copy of it.\n\n**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 \u2014 two surfaces and a `CompositeSurface` answered it, and the comment on `heightSurface.sample` said so \u2014 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 real `CharacterController` standing 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."
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
"kind": "changed",
|
|
1004
|
+
"short": "`HeightField` is exported, and `heightSurface`'s header now states that a non-finite height means no ground in that column.",
|
|
1005
|
+
"text": "**`HeightField` is exported and the absence contract is public.** `heightSurface`'s header now says that a `heightAt` returning 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 on `sample` that used to send a reader to `CompositeSurface` now names the list form instead, and says why the old answer, though true, was the wrong shape."
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
"kind": "added",
|
|
1009
|
+
"short": "`colliderSurface` makes a `ColliderSet` answer as ground, so a solid holds a body up. A slab whose lid is at 5.5 m used to let a character fall to -84 m, exactly as no slab does.",
|
|
1010
|
+
"text": "**`colliderSurface(set, { minY, maxY })` makes a `ColliderSet` answer as a `GroundSurface`, 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, and `CharacterController` senses ground through `PhysicsWorld` bodies and through a `GroundSurface`, which a collider set was neither \u2014 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.\n\n**A sloped hull supports at its slope, not at its bounding box.** The lid is solved against the solid's own face planes \u2014 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 \u2014 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.\n\n**It is a `GroundSurface` and 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 through `CompositeSurface` or a list of height fields. `minY` and `maxY` are 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.\n\nRequested 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."
|
|
1011
|
+
}
|
|
1012
|
+
]
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
"version": "3.20.0",
|
|
1016
|
+
"date": "2026-08-29",
|
|
1017
|
+
"entries": [
|
|
1018
|
+
{
|
|
1019
|
+
"kind": "added",
|
|
1020
|
+
"text": "**A `ColliderSet` can 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** \u2014 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 by `npm 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 single `Float32Array.set` and 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.",
|
|
1021
|
+
"short": "A `ColliderSet` takes and drops groups after it is built. A region crossing costs 0.090 ms against about 41 ms to rebuild, and the one-set-per-region workaround's 7.5x on every query goes away."
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
"kind": "changed",
|
|
1025
|
+
"text": "**`ColliderSet.count` is the live count and `capacity` is the slot count, with `liveAt(index)` between them.** They are equal on a set that has never had `remove` called on it, which is every set built before this release, so nothing that walks `data` today changes. After a removal the live slots are sparse within `capacity`, and a walk of `0..count` reads the wrong ones \u2014 walk `0..capacity` and skip `!liveAt(i)`. A reference to `data` is now valid only until the next `add`, 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 group `remove` refuses**, so a set meant to stream is constructed empty. `physics-only` grows 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.",
|
|
1026
|
+
"short": "`count` is the live count and `capacity` the slot count, equal until the first `remove`; after one, walk `0..capacity` and skip `!liveAt(i)`. A set meant to stream is constructed empty."
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
"kind": "changed",
|
|
1030
|
+
"text": "**`fingerprintColliders` reads liveness, and every hash it has ever produced is unchanged.** It hashed the whole of `data`, 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 \u2014 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.",
|
|
1031
|
+
"short": "`fingerprintColliders` walks live slots and every hash it ever produced is unchanged. On a mutated set it answers \"the same set with the same history\", not \"the same world\"."
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
"kind": "added",
|
|
1035
|
+
"text": "**`drift/physics` can change a collider set as well as read one.** `beginColliderGroup`, `addColliderBox` and `endColliderGroup` put a batch down and hand back the handle `removeColliderGroup` drops it with, under `physics.write`; `colliderCapacity` and `colliderBytes` join 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.",
|
|
1036
|
+
"short": "`drift/physics` gains a collider group builder under `physics.write`, plus `colliderCapacity` and `colliderBytes`. `endColliderGroup` returns the handle, not `begin`."
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"kind": "added",
|
|
1040
|
+
"text": "**`drift-package` takes `--out=<dir>`, so a build can stage outside the project.** The output was `join(cwd, 'out', target)` in four places and nothing moved it. That is not tidiness: Node's `fs.cp` refuses to copy a thing into itself and decides it by comparing `dev` and `ino` up the destination's ancestors, and a consumer on a Windows VM with the project on a drive mapped to a Linux folder measured `drift.package.json` and `out/` both answering `dev=66313 ino=-112686486700016` \u2014 one identity for a file and a directory \u2014 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-out` gives `C:\\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 is `steam`, not the build path, so a flag threaded through `build` alone would have gone on writing depot scripts beside an output directory that had moved.",
|
|
1041
|
+
"short": "`drift-package` takes `--out=<dir>`, for a project on a filesystem that answers one `dev` and `ino` for both a file and a directory, making every staging copy refuse itself."
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
"kind": "fixed",
|
|
1045
|
+
"short": "`drift-package` launched `tsx` through `npx`, which on Windows is `npx.cmd` and does not launch at all: it exited 1 printing nothing, because the reason sits in `result.error`.",
|
|
1046
|
+
"text": "**`drift-package` could not start on Windows, and said nothing about it.** Its executable ran `spawnSync('npx', ['tsx', \u2026])`. Over there `npx` is `npx.cmd`, and a `.cmd` is not something `CreateProcess` knows how to launch: `spawnSync` fails with `ENOENT`, leaves `status` at `null`, and a bare `status ?? 1` exits 1 **with nothing printed at all**, because the reason is in `result.error` and 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 launches `tsx` with the Node already running \u2014 no shell, no `.cmd`, no npx resolution \u2014 reading `tsx`'s own declared `bin` so a version that moves its entry point cannot break this silently, and it reports `result.error` and `result.signal` separately from an exit code. **`tsx` is 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."
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
"kind": "fixed",
|
|
1050
|
+
"short": "A desktop build proceeded with no Electron binary, because the version is read from a manifest that survives a postinstall which never finished, and failed several minutes later.",
|
|
1051
|
+
"text": "**A desktop build proceeded with no Electron runtime and failed several minutes later saying something else.** `installedElectronVersion()` resolves `electron/package.json` and reads its `version`; that file is in the npm tarball and the binary is not, since a postinstall downloads it into `dist` and writes `path.txt` beside 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 of `43.4.1` against no `dist`, with `npm config get ignore-scripts` answering `false` \u2014 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. `build` now checks for both `dist` and `path.txt` and **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 names `npm install-scripts approve electron`."
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
"kind": "added",
|
|
1055
|
+
"short": "`drift-package init` writes the two files a project cannot avoid owning: a Windows entry point and a launcher for a vendored engine. Everything else moved into the packager.",
|
|
1056
|
+
"text": "**`drift-package init` writes the two files a project has to own, and nothing else.** A Windows build needs a `.ps1` because 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 no `node_modules`. The generated PowerShell routes every native command through a step that checks `$LASTEXITCODE` \u2014 `$ErrorActionPreference = 'Stop'` governs cmdlets and not `npm`, 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, and `out/` in the checkout is empty. Both files refuse to be overwritten without `--force`. The shipped `build-windows.ps1` is deleted rather than repaired, because two scripts that do the same thing drift and only one of them is the one anybody runs."
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
"kind": "added",
|
|
1060
|
+
"short": "`doctor` refuses a filesystem that gives a file and a directory one identity, which is what makes `fs.cp` reject the first staging copy ten minutes into a build.",
|
|
1061
|
+
"text": "**`doctor` refuses a filesystem that cannot tell a file from a directory.** `fs.cp` will not copy a thing into itself and decides that by comparing `dev` and `ino` up the destination's ancestors, so a network share that invents a file index makes it reject a copy that is perfectly legitimate \u2014 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, with `bigint: true` because 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."
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
"kind": "fixed",
|
|
1065
|
+
"short": "`ELECTRON_RUN_AS_NODE` was removed for `run` alone, leaving `build` inheriting it. Every subcommand is protected now.",
|
|
1066
|
+
"text": "**`ELECTRON_RUN_AS_NODE` is removed for every subcommand, not just `run`.** Any terminal that is itself an Electron application \u2014 an editor's built-in one is the ordinary case \u2014 exports it to everything it spawns, and inherited it turns Electron into plain Node: the build dies on `Cannot find module 'electron'` under a Node banner, which looks nothing like its cause. It was deleted inside `run`'s own environment and nowhere else, which left `build` inheriting it \u2014 and `build` is the one that spends twenty minutes before anything can go wrong."
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
"kind": "fixed",
|
|
1070
|
+
"short": "The packager's README said the generated Android keystore is kept beside the artifact. It is in `~/.cache`, and it is the application's permanent identity.",
|
|
1071
|
+
"text": "**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 \u2014 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 `~/.cache` is 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; and `apksigner verify --print-certs` before handing a build to somebody who already has one installed, because the phone's refusal says nothing about signing."
|
|
1072
|
+
}
|
|
1073
|
+
]
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
"version": "3.19.1",
|
|
1077
|
+
"date": "2026-08-29",
|
|
1078
|
+
"entries": [
|
|
1079
|
+
{
|
|
1080
|
+
"kind": "fixed",
|
|
1081
|
+
"text": "**The scene target's resolve gives face culling back, so the world is no longer drawn double-sided on WebGL2.** `SceneTarget.resolve` switches off depth, blend and culling to draw its one triangle over the canvas, and put back only the first two. It runs at `endFrame`, so from the *second frame onwards* every `drawMesh` in every scene ran with `CULL_FACE` disabled \u2014 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 sets `cullMode: '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 \u2014 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. `showroom` moves 255 pixels of 504,000 and moves *closer* to WebGPU. `night street` moves 2,282 because its planar reflection sets `cullFace(FRONT)`, which had been doing nothing while culling was off \u2014 the wet road now carries the window reflections it should always have had. `sceneTarget.test.ts` asserts the restore rather than the disable, so a pass that borrows the state again has to give it back.",
|
|
1082
|
+
"short": "`SceneTarget.resolve` left `CULL_FACE` disabled, so from the second frame on every scene was drawn double-sided on WebGL2 while WebGPU culled as documented."
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
"kind": "fixed",
|
|
1086
|
+
"text": "**The WebGPU mesh pass uploads the camera medium, which it never did.** `bindMeshPass` set `uFogEnabled` and 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 \u2014 plumes, film, lines, water \u2014 and WebGL2's `writeMeshPassState` calls `bindAtmosphere` for exactly this reason, with a comment beside it saying an unwritten uniform is zero. `uFogNear` and `uFogFar` at 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 for `fogMode: 'linear'`, and none of the published ones do.",
|
|
1087
|
+
"short": "WebGPU's `bindMeshPass` set `uFogEnabled` without ever uploading the fog and underwater fields behind it, which saturates a linear ramp at any distance."
|
|
1088
|
+
}
|
|
1089
|
+
]
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
"version": "3.19.0",
|
|
1093
|
+
"date": "2026-08-28",
|
|
1094
|
+
"entries": [
|
|
1095
|
+
{
|
|
1096
|
+
"kind": "added",
|
|
1097
|
+
"text": "**A rectangular area light casts a shadow, and its penumbra is the shape of the emitter.** An `AreaLightSource` used to illuminate without occluding, which `AGENTS.md` calls 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 \u2014 its centre \u2014 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, reusing `PointShadowMap`, `PointShadowImage`, the staleness rule, the bake ordering and the arrival ramp unchanged on both backends; and `areaShadow()` 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 on `demo/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 \u2014 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:** `castsShadow` defaults **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 \u2014 8.39 MB \u2014 and a six-face bake. `shadowRange` is 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 rides `RenderQuality.pointShadows`**, and that is a texture-unit wall rather than a preference \u2014 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 \u2014 a panel a metre across, half a metre above its caster \u2014 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.",
|
|
1098
|
+
"short": "A rectangular area light casts a shadow whose penumbra is the emitter's shape: a strip blurs along its length and stays tight across it. Off unless a rectangle asks."
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
"kind": "added",
|
|
1102
|
+
"text": "**The packager can be told where its own `assets/`, `android/` and `ios/` are, and it checks that what it found is really them.** Four call sites climbed out of `src/` on their own \u2014 `../assets` twice, `../android`, `../ios` \u2014 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 copies `packages/<name>/src/**` to `<name>/**`, and the flattening drops exactly the level those paths climbed to. `resourceRoot`, `resourceDir` and `resourceFile` in `@driftengine/package` answer all four from one place, `--resources=<dir>` sets it on `doctor`, `run` and `build`, 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 called `assets` \u2014 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`, which `main/splash.ts` loads; `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.",
|
|
1103
|
+
"short": "The packager takes `--resources=<dir>` for its own assets, android and ios directories, and refuses one that is correctly named but is somebody else's package."
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
"kind": "fixed",
|
|
1107
|
+
"text": "**Asking whether a window can be resized no longer resizes it.** `DisplayControl` offered `setSize` and no way to ask whether it would do anything, so a consumer wanting to know had to call it and watch \u2014 which on a full-screen window is a visible change to the thing being asked about. `canSetSize()` answers without performing one, following `pad.canRumble`'s precedent: a capability query belongs beside the capability. `BrowserDisplay` answers false, and the native path forwards to one decider in `main/ipc.ts` that 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.",
|
|
1108
|
+
"short": "`DisplayControl.canSetSize()` answers whether a resize would work without performing one, which on a full-screen window used to be a visible change."
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
"kind": "changed",
|
|
1112
|
+
"text": "**Every `core-*` bundle floor moves, and `@driftengine/core` is 584.1 KB gzipped against 553.0.** Two things arrived at once and the split is on record in `scripts/size-floors.mjs`. **7,343 bytes of it was drift already inside the gate's 3% tolerance** \u2014 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, and `areaShadow()` is compiled into the eight fragment permutations that carry point shadows. Gzip recovers most of it \u2014 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.",
|
|
1113
|
+
"short": "`@driftengine/core` is 584.1 KB gzipped, up from 553.0: 24,549 bytes of area-light shadow, mostly generated WGSL, and 7,343 of drift the 3% tolerance had been hiding."
|
|
1114
|
+
}
|
|
1115
|
+
]
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
"version": "3.18.0",
|
|
1119
|
+
"date": "2026-08-28",
|
|
1120
|
+
"entries": [
|
|
1121
|
+
{
|
|
1122
|
+
"kind": "fixed",
|
|
1123
|
+
"text": "**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 \u2014 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 \u2014 a declaration error throws again next tick and is caught again, which costs a throw a tick and keeps a transient failure recoverable \u2014 and `runSchedule` no longer throws, which is what `AGENTS.md` has 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.",
|
|
1124
|
+
"short": "A system that throws is caught, reported once by name, and the systems scheduled after it still run."
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
"kind": "changed",
|
|
1128
|
+
"text": "**A ragdoll's joint limits are per joint.** `RagdollOptions.swingCos` and `twistSin` were 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 \u2014 so a consumer chose which of the two to get wrong. Reported from outside, where the choice was loose (a 78\u00b0 cone and 74\u00b0 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\u00b0 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 way `parents` is**, 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\u00b0 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 \u2014 a parent array and world matrices do not say which way a knee folds.",
|
|
1129
|
+
"short": "`RagdollOptions` takes a limit per joint, so a knee can be tight while a shoulder stays loose."
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
"kind": "changed",
|
|
1133
|
+
"text": "**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<\u2026>`, so a component appearing in a query and nowhere else was invisible to it \u2014 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: `DS0291` called the declaration the runtime demanded unused, and a module written on that advice compiled clean and threw once a tick. `DS0288` refuses the omission now, at compile time. A consumer that compiles `.drs` moves its own pin in the same step, which is the fourth copy of this number and the rule `AGENTS.md` records.",
|
|
1134
|
+
"short": "Takes DriftScript 1.9.0: a component named in a query is one the compiler now counts as read."
|
|
1135
|
+
}
|
|
1136
|
+
]
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
"version": "3.17.0",
|
|
1140
|
+
"date": "2026-08-28",
|
|
1141
|
+
"entries": [
|
|
1142
|
+
{
|
|
1143
|
+
"kind": "added",
|
|
1144
|
+
"text": "**A node of a blend tree may be sampled on a clock of its own.** `BlendTree.evaluate` walked every node with one `timeSec`, so a set whose members must be sampled on *different* clocks could not be written as one tree \u2014 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 fact `rootMotion` exists 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. A `clip` node now names the parameter its own time comes from, and a node that names none takes the time `evaluate` was 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 \u2014 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 a `blendPoses` per overlay, which gives up the declared graph, the scratch poses claimed at construction, and a state machine's crossfades on top.",
|
|
1145
|
+
"short": "A `clip` node in a blend tree can name its own clock, so one set holds a time-driven idle and a distance-driven gait."
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
"kind": "changed",
|
|
1149
|
+
"text": "**`drift/animation` drives a blend tree and no longer offers to build one.** `blendTree` took a `Skeleton` and handed it to the engine constructor's *root node* parameter behind an `as never`; called, it threw `Cannot read properties of undefined (reading 'length')`, because a tree asked a skeleton for its children. Nothing had ever run it \u2014 the two tests over the capability asserted that the compiler required its third argument, which it did \u2014 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. `blendSet` and `blendAt` replace 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 through `uses`, which is the seam DriftScript 1.8.1 opened. `blendSet` is also how a script reaches a node's clock.",
|
|
1150
|
+
"short": "`drift/animation` gains `blendSet` and `blendAt` for a tree a host hands it, and drops a `blendTree` that could not work."
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
"version": "3.16.0",
|
|
1156
|
+
"date": "2026-08-28",
|
|
1157
|
+
"entries": [
|
|
1158
|
+
{
|
|
1159
|
+
"kind": "changed",
|
|
1160
|
+
"text": "**A body of water is bounded by a rotated rectangle, not a square.** `WaterBody.bounds` was a centre and one half-extent, so the only shape a bounded body could take was a square \u2014 and the square that contains a 3 by 21 metre channel floods eighteen metres of ground either side of it. `halfX` and `halfZ` are the body's own axes now, and `forwardX` and `forwardZ` say which way those axes point: a direction and not an angle, matching `MeshBuilder.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. `halfM` stays 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 through `resolveWater` and 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.",
|
|
1161
|
+
"short": "`WaterBody.bounds` takes per-axis extents and a direction, so a channel is water and the fields beside it are not."
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
"kind": "fixed",
|
|
1165
|
+
"text": "**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 \u2014 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 a `UniformRing` now 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.html` is the page that draws four.",
|
|
1166
|
+
"short": "Several bodies of water in one frame no longer all draw with the last body's extents, level and colours on WebGPU."
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
"kind": "changed",
|
|
1170
|
+
"text": "**A rain field's fall speed and shutter time move while it is running.** `RainFieldOptions.speedMps` and `streakSec` were read once into private readonly fields, so a weather state that changes under a player could not change the rain. `RainField.speedMps` and `RainField.streakSec` are 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 \u2014 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.",
|
|
1171
|
+
"short": "`RainField.speedMps` and `streakSec` are settable, so drizzle, rain and hail differ in how fast they fall."
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
"kind": "fixed",
|
|
1175
|
+
"text": "**A ragdoll's root accessors answer the bone head on all three axes.** `rootX`, `rootY` and `rootZ` are 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* \u2014 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 read `bodies.posY` and `bodies.posZ` plainly; the third reached the same centre through a helper named `boneHeadX`, 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 \u2014 which reads exactly like a pose that never arrived, and cost the reporter two weeks of looking for one.",
|
|
1176
|
+
"short": "`Ragdoll.rootY` and `rootZ` return the bone head like `rootX` claims to, instead of the body's centre."
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
"kind": "added",
|
|
1180
|
+
"text": "**`demo/dev/water.html`, the instrument for a body of water.** Four bodies in one frame \u2014 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 shape `bounds` can 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.",
|
|
1181
|
+
"short": "`demo/dev/water.html` draws four bodies of water in one frame, one of them turned, on either backend."
|
|
1182
|
+
}
|
|
1183
|
+
]
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
"version": "3.15.0",
|
|
1187
|
+
"date": "2026-08-28",
|
|
1188
|
+
"entries": [
|
|
1189
|
+
{
|
|
1190
|
+
"kind": "changed",
|
|
1191
|
+
"text": "**Breaking, and the whole of it: `GroundProbe` spells a normal the way `SurfaceHit` does.** `nx`, `ny` and `nz` are now `normalX`, `normalY` and `normalZ`. The interface's documented purpose was that a `GroundSurface` from `@driftengine/core` satisfies 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.",
|
|
1192
|
+
"short": "`GroundProbe` renames nx/ny/nz to normalX/normalY/normalZ, matching `SurfaceHit`."
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
"kind": "fixed",
|
|
1196
|
+
"text": "**A ground probe is a floor now, and was a sensor before.** `senseGround` read the surface and set `GROUNDED`, 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, reported `GROUNDED` for 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. `standOnSurface` undoes 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.",
|
|
1197
|
+
"short": "A character stands on an analytic ground surface instead of sinking through it."
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
"kind": "added",
|
|
1201
|
+
"text": "**One floor for the character and the car alike.** `Vehicle` takes the same `ground` probe `CharacterController` does, consulted only where a wheel's ray found no body, and fills the same `RayHit` a body would have, so no grip code learns that a surface exists. `heightSurface(heightAt)` turns any `(x, z) => height` into a `GroundSurface`: 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 a `NaN` position 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.",
|
|
1202
|
+
"short": "`heightSurface` turns a height function into a ground surface, and the vehicle consults one too."
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
"kind": "added",
|
|
1206
|
+
"text": "**`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, a `RibbonSurface` with a stretch of `holes`, 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.",
|
|
1207
|
+
"short": "`coveredAbove` names the \"is there a roof over me\" query, and a hole in the roof is a hole."
|
|
1208
|
+
},
|
|
1209
|
+
{
|
|
1210
|
+
"kind": "added",
|
|
1211
|
+
"text": "**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. `RainField` keeps 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, and `coveredAbove` is the other half.",
|
|
1212
|
+
"short": "`RainField` draws falling rain as line streaks that land on the ground and stop under a roof."
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
"kind": "added",
|
|
1216
|
+
"text": "**Debug draw, as the generator the line batch was missing.** `drawLines` has 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. `DebugLines` fills a `LineSegments` with a mesh's vertex normals and with every collider a `PhysicsWorld` holds, 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** \u2014 a segment past capacity is counted in `dropped`, 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.",
|
|
1217
|
+
"short": "`DebugLines` draws a mesh's normals and a world's colliders through the existing line batch."
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
"kind": "added",
|
|
1221
|
+
"text": "**Quads that face where you meant, and a box that lies along a line.** `addQuad` takes its normal from `(b \u2212 a) \u00d7 (d \u2212 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. `addGroundQuad` takes the corners in any order and faces up; `addWallQuad` faces 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. `addOrientedBox` is the other half: `addBox` is 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 through `addWallQuad` against its own centre, which makes the outward normals one argument instead of six windings.",
|
|
1222
|
+
"short": "`addGroundQuad`, `addWallQuad` and `addOrientedBox` take the trap out of `addQuad`."
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
"kind": "added",
|
|
1226
|
+
"text": "**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.",
|
|
1227
|
+
"short": "`projectDecal` clips the receiving surface to a projector box, so a mark follows every curve."
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
"kind": "added",
|
|
1231
|
+
"text": "**`drift/navigation` has 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. `buildNavGraph` refuses an edge cheaper than its own span, which is what makes the straight-line heuristic admissible and the first route the cheapest one. `NavSearch` allocates nothing per query and breaks ties on the node index, so a route does not depend on the order the edges were declared in. `NavPath` answers 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.read` is in `DETERMINISTIC_EFFECTS` and `navigation.write` is not, so a `@deterministic` system may steer along a route and may not compute one.",
|
|
1232
|
+
"short": "`drift/navigation` is provided: a graph, an allocation-free A* over it, and a path follower."
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
"kind": "added",
|
|
1236
|
+
"text": "**`drift/behavior` has 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. `buildBehaviorTree` resolves 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.",
|
|
1237
|
+
"short": "`drift/behavior` is provided: a tree that suspends a preempted branch instead of resetting it."
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
"kind": "added",
|
|
1241
|
+
"text": "**`ActionMap.axis(action, 'x' | 'y')`, and `rawAxisX`/`rawAxisY` in `drift/input`.** `vector` treats 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 \u221a2 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 was `Math.hypot` three 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. `axisX` and `axisY` are unchanged, because correcting them in place would make every scripted character quietly faster on a diagonal.",
|
|
1242
|
+
"short": "`ActionMap.axis` reads one axis without normalising it against the other."
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
"kind": "added",
|
|
1246
|
+
"text": "**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. `medium` on 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 in `lightVolumeDraw.ts` and 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 by `demo/dev/volume.html?fog=`.",
|
|
1247
|
+
"short": "`drawLightVolume` takes a `medium`, so a beam fades as the air clears."
|
|
1248
|
+
}
|
|
1249
|
+
]
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
"version": "3.14.0",
|
|
1253
|
+
"date": "2026-08-27",
|
|
1254
|
+
"entries": [
|
|
1255
|
+
{
|
|
1256
|
+
"kind": "fixed",
|
|
1257
|
+
"text": "**A rolling body's contact anchor turned with it, which sank a rolling sphere 28 millimetres into the floor and braked it \u2014 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 \u2014 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.curvedA` and `curvedB` carry 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 from `fingerprintBodies`, 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.",
|
|
1258
|
+
"short": "A rolling body's contact anchor turned with it and sank a sphere 28 mm into the floor. Stored physics fingerprints and replays must be re-taken."
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
"kind": "added",
|
|
1262
|
+
"text": "**A cylinder primitive, `cylinderShape(radius, halfHeight)`, and it is a second kind of rounding rather than a second kind of shape.** `ConvexShape.radius` grows 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. `sideRadius` grows the cloud by a **disc** perpendicular to the segment through its first two points instead \u2014 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 through `hullShape`, 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, and `prismRoll.test.ts` measures both side by side. A curved side enumerates no separating axis, so contacts build their axis list against the *other* shape \u2014 its faces, its edges crossed with the cylinder's axis, and two directions per vertex \u2014 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.",
|
|
1263
|
+
"short": "`cylinderShape` ships. A wheel that bobbed 16 mm as a thirty-two-sided prism bobs under 2 mm as a cylinder."
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
"kind": "added",
|
|
1267
|
+
"text": "**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 a `DynamicTree` over them \u2014 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**: `addBody` refuses a mesh on anything but a static body and `colliderFromShape` refuses 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 \u2014 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 \u2014 a box slid at 6 m/s across sixteen quads keeps **80% of its speed with the filter and 1% without it**.",
|
|
1268
|
+
"short": "`meshShape` ships: a static triangle mesh with an interior-edge filter, worth 80% of a sliding box's speed against 1%."
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
"kind": "added",
|
|
1272
|
+
"text": "**Cloth pushes what it lands on, and stops passing through itself.** Both are off by default, so no existing world moves until it asks. `coupling` is 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 \u2014 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; turning `coupling` down is the answer. `particleMass` is in kilograms and is deliberately separate from `invMass`, which is a relative weight the compliance is tuned against. `selfDistance` is in metres and turns on self-collision: a spatial hash rebuilt each tick with one bucket a particle, a 3\u00d73\u00d73 cell scan, and a symmetric push apart split by inverse mass. **Particles a link joins are exempt**, which is not an optimisation \u2014 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.",
|
|
1273
|
+
"short": "Cloth gains two-way coupling and self-collision, both off by default."
|
|
1274
|
+
},
|
|
1275
|
+
{
|
|
1276
|
+
"kind": "added",
|
|
1277
|
+
"text": "**Depth of field.** `depthOfField` is the construction-time ceiling \u2014 how far a defocused point may spread, as a fraction of the frame's height \u2014 and `setDepthOfField(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 of `gl_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 \u2014 sharp inside `range` of the plane, full blur one more `range` beyond it \u2014 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**: `depthOfField` alone leaves the focus at its default of zero metres, which is behind the camera, so every pixel comes back at full blur.",
|
|
1278
|
+
"short": "Depth of field: a construction-time ceiling and a per-frame focus distance, both in metres. Set the plane where you set the camera."
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
"kind": "added",
|
|
1282
|
+
"text": "**Occlusion culling, and it is arithmetic on the CPU rather than a GPU feature.** `occlusionCulling` is the buffer's width in texels, `addOccluder(min, max, model)` declares a box that things behind it may safely be hidden by, and `occluded(bounds, model)` answers; `cullDraws` consults 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.",
|
|
1283
|
+
"short": "Occlusion culling against declared occluders, on the CPU: 60 of 76 draws removed with no pixel changed."
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
"kind": "added",
|
|
1287
|
+
"text": "**A contributed pass may bring its own attachments.** `PassDefinition.prepare` is a sibling of `draw` rather than a widening of it: `draw` is handed an open `GPURenderPassEncoder` and a render pass cannot be nested, so the WebGPU arm of `PrepareContext` carries a `GPUCommandEncoder` instead. It runs at one fixed point in `beginFrame` \u2014 after the frame's encoder exists and before anything opens the frame's render pass \u2014 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: `prepare` is called with the default framebuffer bound and must leave it bound, which is the promise `draw` already 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. `createPassAttachment` builds the target from the `PassDevice` the pass was handed at `init` \u2014 colour and optional depth, `rgba8` or `rgba16float`, released by the pass's own `dispose` \u2014 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 reason `reads` already gives.",
|
|
1288
|
+
"short": "A contributed pass can own a target: fill it in `prepare` before the frame's pass opens, sample it in `draw`."
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
"kind": "added",
|
|
1292
|
+
"text": "**View-dependent colour for Gaussian splats, at degree 1.** `readSplatPly` keeps the l=1 spherical-harmonic band \u2014 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 \u2014 and `packSplats` writes it as one extra `RGBA32UI` texel 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 \u2014 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 the `SPLT` block's own header, which the container format had already designed for this.",
|
|
1293
|
+
"short": "Gaussian splats gain view-dependent colour at degree 1, one extra texel a splat. A capture without harmonics is unchanged."
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
"kind": "added",
|
|
1297
|
+
"text": "**A latitude and a season for the celestial clock.** `celestialStateAt` takes an optional `CelestialSite`, 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 \u2014 so the poles get their midnight sun and their polar night, and `obliquityDeg` is 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 \u2014 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.",
|
|
1298
|
+
"short": "The sky clock takes a latitude and a season. A caller that names no site is unchanged."
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
"kind": "changed",
|
|
1302
|
+
"text": "**`@driftengine/physics` grows 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 \u2014 one triangle written into a scratch `ConvexShape` and handed to `collideShapes`, 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.",
|
|
1303
|
+
"short": "`@driftengine/physics` grows to 39.5 KB gzipped, for the cylinder, the mesh collider and the two cloth rows."
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
"kind": "changed",
|
|
1307
|
+
"text": "**The language pin moves to `driftscript` 1.5.0**, which gives `std/math` and `std/time` a float width they do not fix. The language grew a `float` type meaning *`f32` or `f64`, the same one throughout the call* \u2014 the width taken from the first argument that has one \u2014 so `math.clamp(v, 0, 1)` now works whether `v` is single or double precision, where before it was single only and nothing converted between the two. `f32.nearest(v)` and `f64.nearest(v)` are the conversions that were missing; a float previously had none at all, which `DS0232` said in so many words. **Nothing in `drift/*` changed shape**: every capability this engine declares is written `f32` and 1.5.0 accepts that unchanged, so a `.drs` file that compiled before compiles now, to the same JavaScript. Sixteen signatures moved in `packages/script/capabilities.json` and 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 moved `std/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 \u2014 and the language's own note to hosts is to implement a `float` capability in double without rounding, while rounding stays right for a signature left written `f32`. Every one of ours is. What would make that wrong is widening one of them to `float` and leaving the rounding in place, which is written beside the helper that does it. The upgrade was found by one red assertion in `host.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.\n\n**If you compile `.drs` files, move your own `driftscript` pin 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 answers `DS0237`, \u201c`float` is not a type this host registered\u201d, on every file. Nothing warns you, because npm installs both copies happily and because a `.drs` file is compiled by your bundler and not by `tsc` \u2014 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.",
|
|
1308
|
+
"short": "The language pin moves to driftscript 1.5.0: `std/math` works at either float width. No `drift/*` capability changed shape."
|
|
1309
|
+
}
|
|
1310
|
+
]
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
"version": "3.13.1",
|
|
1314
|
+
"date": "2026-08-27",
|
|
1315
|
+
"entries": [
|
|
1316
|
+
{
|
|
1317
|
+
"kind": "fixed",
|
|
1318
|
+
"text": "**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.writeTexture` does 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: `texSubImage2D` is 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=1` was 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.",
|
|
1319
|
+
"short": "On WebGPU, two skinned characters in one frame both took the last palette set. Each takes a slot of its own now, casters included."
|
|
1320
|
+
}
|
|
1321
|
+
]
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
"version": "3.13.0",
|
|
1325
|
+
"date": "2026-08-27",
|
|
1326
|
+
"entries": [
|
|
1327
|
+
{
|
|
1328
|
+
"kind": "changed",
|
|
1329
|
+
"text": "**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 with `packages/driftscript-language`, and the editor client is a marketplace listing rather than a directory. Nothing in `@driftengine/*` changed shape: the bindings in `@driftengine/script` export what they always did, and a game that never wrote a `.drs` file 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.",
|
|
1330
|
+
"short": "DriftScript ships from the registry now. A consumer depending on it by path replaces that line with the published version."
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
"kind": "added",
|
|
1334
|
+
"text": "**Root motion, in `@driftengine/animation`.** `sampleRootMotion` reports 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.",
|
|
1335
|
+
"short": "A clip's root translation is reported per step, so locomotion moves the character rather than the pose."
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
"kind": "added",
|
|
1339
|
+
"text": "**Controller rumble, and the refusal is half of it.** `GamepadView.canRumble` answers whether this pad has motors this browser can drive, and `rumble(durationMs, strong, weak)` and `stopRumble()` 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.",
|
|
1340
|
+
"short": "A pad's motors take a duration and two magnitudes, and say so honestly when the platform cannot drive them."
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
"kind": "added",
|
|
1344
|
+
"text": "**An asynchronous save backend, behind the seam that already answers now.** `KeyValueStore` is 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. So `RemoteSaveStore` hydrates 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.",
|
|
1345
|
+
"short": "A remote save store rides the synchronous seam: one hydrate, coalesced writes, backoff, and a status a game can show."
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
"kind": "added",
|
|
1349
|
+
"text": "**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.",
|
|
1350
|
+
"short": "A seventh joint type with per-axis freedom, limits and motors, and no existing joint's numbers move."
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
"kind": "added",
|
|
1354
|
+
"text": "**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.",
|
|
1355
|
+
"short": "Friction can be a cone rather than a box, off by default, because turning it on moves every stacking result."
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
"kind": "added",
|
|
1359
|
+
"text": "**Colour grading, applied where the tone curve already is.** `setColourGrade(lut, strength)` takes an `RGBA8` cube 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 `.cube` is display-referred. It needs `screenEffects` and 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.",
|
|
1360
|
+
"short": "A lookup table grades the frame after the tone curve, on both backends, and says so when there is no composite to apply it in."
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
"kind": "added",
|
|
1364
|
+
"text": "**A spot light projects a cookie.** `setSpotCookies` takes 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.",
|
|
1365
|
+
"short": "A spot light can project a tinted mask, filling its outer cone so one image works at any angle."
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
"kind": "added",
|
|
1369
|
+
"text": "**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 `lightIesProfiles` and an asymmetric one also takes `lightIesAxes`, 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.",
|
|
1370
|
+
"short": "A photometric profile that is not axially symmetric keeps every horizontal plane, given an axis to measure azimuth from."
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
"kind": "added",
|
|
1374
|
+
"text": "**Ambisonics, decoded through six virtual speakers.** `createAmbisonicSoundfield` takes 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.",
|
|
1375
|
+
"short": "A B-format recording decodes through six virtual speakers, so a whole scene costs six convolutions rather than six per sound."
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
"kind": "added",
|
|
1379
|
+
"text": "**A reverb zone can belong to a source rather than to the listener.** `SpatialSource.attachZone` costs 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.",
|
|
1380
|
+
"short": "A source can carry the room it is in, so a sound heard from outside a space still arrives with that space on it."
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
"kind": "added",
|
|
1384
|
+
"text": "**Condensed tar, so a smouldering fire's smoke is pale.** `tar` was 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.",
|
|
1385
|
+
"short": "A smouldering fire makes pale smoke, because tar condenses into an aerosol the model produces."
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
"kind": "fixed",
|
|
1389
|
+
"text": "**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 `sweep` accumulates 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** \u2014 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.",
|
|
1390
|
+
"short": "The gas field walked its chunks in allocation order, so two runs could diverge. It walks in key order now."
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
"kind": "fixed",
|
|
1394
|
+
"text": "**The contact solver's tangent pass took a block it never read.** `solveTangent` declared 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 without `noUnusedParameters`.",
|
|
1395
|
+
"short": "A parameter the contact solver never read is gone, so the function's dependencies are what its signature says."
|
|
1396
|
+
}
|
|
1397
|
+
]
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
"version": "3.12.0",
|
|
1401
|
+
"date": "2026-08-27",
|
|
1402
|
+
"entries": [
|
|
1403
|
+
{
|
|
1404
|
+
"kind": "added",
|
|
1405
|
+
"text": "**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 so `exp` never runs on a tick; one sparse atmosphere carrying oxygen in and smoke away; radiation with the 1/r\u00b2 that makes distance mean something; and ignition as five criteria rather than a flag. **18.1 KB gzipped, importing no other engine package** \u2014 nothing in core depends on it, so a game with no fire in it pays nothing.",
|
|
1406
|
+
"short": "Chemistry: substances, enthalpy, reactions, an atmosphere and fire, in a package that imports nothing."
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
"kind": "added",
|
|
1410
|
+
"text": "**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 \u22122,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 \u00b0C 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.",
|
|
1411
|
+
"short": "Seven substance libraries, every number sourced, and behaviour nobody wrote a rule for."
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
"kind": "added",
|
|
1415
|
+
"text": "**`drift/chemistry`, the thirteenth `drift/*` module**: fifty-one capabilities over a read surface, a write surface and an event buffer of twenty-one kinds, three opaque types, and `chemistry.write` **inside the determinism boundary** \u2014 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`, and `degC` is 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 in `K`, because the refused spelling compiles, runs, and produces a number 273.15 too large. `@substance` and `@reaction` are annotations on a `data` record rather than language forms.",
|
|
1416
|
+
"short": "`drift/chemistry` with fifty-one capabilities, eleven unit suffixes, and a refusal for the one that is an offset scale."
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
"kind": "added",
|
|
1420
|
+
"text": "**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 \u2248 0.235\u00b7Q^(2/5) \u2212 1.02\u00b7D`, 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 \u2014 plus three audio scalars, and the package never touches `@driftengine/audio`. Every target is described structurally by what it must have, so core's `ParticleInstances` satisfies it exactly and neither package knows the other exists. **Emissive in this engine is gated on `nightFactor`**, so embers written here look right at dusk and dead at noon; the call says so.",
|
|
1421
|
+
"short": "Six mappings from physical quantities to pixels, through arrays this package never names the type of."
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
"kind": "added",
|
|
1425
|
+
"text": "**A `SUBS` chunk at container 1.8**, pairing material ordinals with substance ids, written by the baker from a glTF material's `extras.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** \u2014 a `pine_bark_02` silently matching `pine` is a material with the wrong ignition temperature that nobody would ever notice.",
|
|
1426
|
+
"short": "`SUBS` at 1.8: an artist labels a material and it burns like that material."
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
"kind": "added",
|
|
1430
|
+
"text": "**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 \u2014 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, `Near` is exact, `Far` drifts 21% and `Distant` 43%, 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. And `fingerprintChemistry` is sixteen hexadecimal digits over compositions, enthalpies, the field's elements and the substance registry, order-insensitive over parcels because a handle *is* spawn order.",
|
|
1431
|
+
"short": "Tiers that conserve exactly, sleeping that costs a bounded scan, and a fingerprint that makes determinism an assertion."
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
"kind": "fixed",
|
|
1435
|
+
"text": "**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 \u2014 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 `fingerprintChemistry` on its first run, which is what it is for, and fixed at the source: the field is a constant `cp` at 298.15 and the ambient is now built with the same array a cell temperature divides by.",
|
|
1436
|
+
"short": "A cell's temperature depended on whether its chunk had been realised yet."
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
"kind": "fixed",
|
|
1440
|
+
"text": "**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/dT` and a pair may not exchange more heat than would equalise it \u2014 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 in `demo/dev/chemistry.html`.",
|
|
1441
|
+
"short": "Conduction let a thin, conductive object oscillate rather than settle; a pair is now clamped where it would be equal."
|
|
1442
|
+
},
|
|
1443
|
+
{
|
|
1444
|
+
"kind": "fixed",
|
|
1445
|
+
"text": "**Five more `Math.hypot` calls arrived under a promise of machine-independent results**, in the new swept step and stall escape \u2014 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 pins `sqrt` to IEEE-754 and leaves `hypot` to 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.",
|
|
1446
|
+
"short": "Five calls the specification does not pin down arrived in the new character physics."
|
|
1447
|
+
}
|
|
1448
|
+
]
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
"version": "3.11.0",
|
|
1452
|
+
"date": "2026-08-26",
|
|
1453
|
+
"entries": [
|
|
1454
|
+
{
|
|
1455
|
+
"kind": "added",
|
|
1456
|
+
"text": "**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/core` re-exports every name, so nothing a consumer imports has changed, and `drift/physics` carries the whole surface to DriftScript rather than the two read-only functions it held before.",
|
|
1457
|
+
"short": "Rigid body physics: solver, joints, queries, sensors, a character controller, ragdolls, vehicles and cloth, in a package that imports nothing."
|
|
1458
|
+
},
|
|
1459
|
+
{
|
|
1460
|
+
"kind": "added",
|
|
1461
|
+
"text": "**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.",
|
|
1462
|
+
"short": "Soft constraints, so raising the substep count buys accuracy without changing how anything behaves."
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
"kind": "added",
|
|
1466
|
+
"text": "**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 on `sin` and `atan`. The last of those turned out better than what it replaced, since a curve is edited by moving a point.",
|
|
1467
|
+
"short": "The tick uses only arithmetic every JavaScript engine rounds identically, and a gate refuses the rest."
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
"kind": "fixed",
|
|
1471
|
+
"text": "**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. Ten `Math.hypot` calls sat under that promise, and ECMAScript does not specify `hypot` precisely. 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.",
|
|
1472
|
+
"short": "Ten calls the specification does not pin down sat under a header promising identical results on every machine."
|
|
1473
|
+
}
|
|
1474
|
+
]
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
"version": "3.10.0",
|
|
1478
|
+
"date": "2026-08-26",
|
|
1479
|
+
"entries": [
|
|
1480
|
+
{
|
|
1481
|
+
"kind": "added",
|
|
1482
|
+
"text": "**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/core` re-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 a `createRenderer` bundle is the dynamics being built on top of it, which the size gate measures rather than the documentation promising.",
|
|
1483
|
+
"short": "Collision is now `@driftengine/physics`, importing no other engine package, so a simulation can run with no renderer. Core re-exports every name."
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
"kind": "added",
|
|
1487
|
+
"text": "**A shape carries its faces now, and can be weighed.** `ConvexShape` gained 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. `shapeMassProperties` is exact for a hull, by tetrahedral decomposition over those loops, and takes the closed form for a sphere. `capsuleShape` names 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.",
|
|
1488
|
+
"short": "`ConvexShape` now carries face planes and ordered loops, `shapeMassProperties` weighs a hull exactly, and `capsuleShape` is a named constructor."
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
"kind": "fixed",
|
|
1492
|
+
"text": "**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. Ten `Math.hypot` calls sat under that promise, and ECMAScript declines to specify `hypot` precisely, 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 now `Math.sqrt` of a sum of squares, which IEEE-754 requires correctly rounded, and `scripts/determinism.test.mjs` fails 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.",
|
|
1493
|
+
"short": "Ten `Math.hypot` calls sat under a header promising machine-independent results. Replaced with exactly-rounded arithmetic, and gated so the next cannot slip in."
|
|
1494
|
+
}
|
|
1495
|
+
]
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
"version": "3.9.0",
|
|
1499
|
+
"date": "2026-08-26",
|
|
1500
|
+
"entries": [
|
|
1501
|
+
{
|
|
1502
|
+
"kind": "added",
|
|
1503
|
+
"text": "**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 \u2014 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 \u2014 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 \u2014 never ones the model wrote \u2014 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 as `preemptedRequests`, because a number nobody reports is a number nobody tunes. Backpressure is `MessageQueue`. Accepted commands and preemptions are recorded beside `TickTrace`, 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 as `drift/ai`, with `@aiTool` and `@aiContext` generating schemas from signatures the checker already validated.",
|
|
1504
|
+
"short": "Agents that are never idle: a deterministic floor underneath, and the next decision already in flight."
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
"kind": "added",
|
|
1508
|
+
"text": "**DriftScript annotations take arguments.** `@aiTool(description: \"\u2026\")` and `@aiContext(description: \"\u2026\")` 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.",
|
|
1509
|
+
"short": "DriftScript annotations can carry arguments."
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
"kind": "note",
|
|
1513
|
+
"text": "**Two AI bridges are refused in writing rather than mocked.** There is no AI navigation bridge, because nothing pathfinds and `drift/navigation` waits on no track at all; and no AI network authority, because that gates on networking. Each carries a sentinel in `docs/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.",
|
|
1514
|
+
"short": "No AI navigation bridge and no network authority, refused in writing with sentinels."
|
|
1515
|
+
}
|
|
1516
|
+
]
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
"version": "3.8.0",
|
|
1520
|
+
"date": "2026-08-26",
|
|
1521
|
+
"entries": [
|
|
1522
|
+
{
|
|
1523
|
+
"kind": "added",
|
|
1524
|
+
"text": "**Entities are language forms in DriftScript, not library calls.** `component` declares a component's fields and the host builds a store from them, so a save survives a rename \u2014 field ids carry the `.drs` file that declared them. `component X from host { \u2026 }` goes the other way and asserts a shape the host already registered, refused at bind naming the field and both types. `entity` names a set of required components and turns its own `var` fields into a component. `system` declares a body the schedule runs, at a rate that becomes a stride over fixed steps \u2014 `update at 1Hz` is 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. `prefab` is a description a host instantiates, so its values must be constants. And `for 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** \u2014 twenty thousand entities, two components, one implementation per process.",
|
|
1525
|
+
"short": "DriftScript gains `component`, `entity`, `system`, `query` and `prefab` as language forms."
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
"kind": "added",
|
|
1529
|
+
"text": "**A system's `reads` and `writes` are 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 \u2014 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 not `await`: 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.",
|
|
1530
|
+
"short": "A system's reads and writes are inferred, and an undeclared write is a compile error."
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
"kind": "added",
|
|
1534
|
+
"text": "**`@editor(\u2026)` is checked against the field it annotates, and `mode` finally 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; an `assetType` on 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 \u2014 the first thing `CompileOptions.mode` has ever been read for, and deliberately a difference in payload rather than in behaviour.",
|
|
1535
|
+
"short": "`@editor` metadata is checked against its field and stripped from production builds."
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
"kind": "fixed",
|
|
1539
|
+
"text": "**A hot-patched module kept running against capability namespaces that were `undefined`.** `patchModule` re-ran a module's `__runtime` hook and never its `__bind` hook, so the first capability call after any edit threw \u2014 for as long as `__bind` has 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.",
|
|
1540
|
+
"short": "A hot-patched module keeps its host bindings instead of losing them on every edit."
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
"kind": "fixed",
|
|
1544
|
+
"text": "**Effect inference silently skipped three kinds of statement.** The walk that decides what `@pure` and `@deterministic` mean switched over seven statement kinds with no default, so a call inside one it did not name was invisible \u2014 `emit Ping { at: time.wallClock() }` and `spawn 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, so `f32?` and `String?` hashed identically: a module changing one to the other produced the same interface hash and its dependents did not recompile.",
|
|
1545
|
+
"short": "Effect inference now walks every statement kind, and an option names its inner type."
|
|
1546
|
+
}
|
|
1547
|
+
]
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
"version": "3.7.0",
|
|
1551
|
+
"date": "2026-08-26",
|
|
1552
|
+
"entries": [
|
|
1553
|
+
{
|
|
1554
|
+
"kind": "added",
|
|
1555
|
+
"text": "**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 \u2014 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 explicit `after` edges topologically sorted, and reports which adjacent systems could run together without running them that way. `everyTicks` strides 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 \u2014 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** \u2014 a consumer writes the ones its world needs \u2014 and costs 632 bytes gzipped with no engine at all.",
|
|
1556
|
+
"short": "Entities, components, queries, systems, prefabs and scenes, in a package that imports no other."
|
|
1557
|
+
},
|
|
1558
|
+
{
|
|
1559
|
+
"kind": "added",
|
|
1560
|
+
"text": "**DriftScript reaches entities and prefabs.** `drift/ecs` and `drift/prefab` are 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 \u2014 iterating one needs a language form that does not exist \u2014 so a script walks a component by `count` and `at`, counting downwards because removing swaps the last entity into a position already passed. `drift/navigation` and `drift/network` are still refused in writing.",
|
|
1561
|
+
"short": "Scripts can create entities, read and write components, and spawn prefabs."
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
"kind": "changed",
|
|
1565
|
+
"text": "**`ecs.write` is inside the determinism boundary.** Entity state is the simulation, so a `@deterministic` function may write it \u2014 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.write` stays 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.",
|
|
1566
|
+
"short": "A `@deterministic` function may write entity state, which is the simulation."
|
|
1567
|
+
}
|
|
1568
|
+
]
|
|
1569
|
+
},
|
|
1570
|
+
{
|
|
1571
|
+
"version": "3.6.0",
|
|
1572
|
+
"date": "2026-08-25",
|
|
1573
|
+
"entries": [
|
|
1574
|
+
{
|
|
1575
|
+
"kind": "added",
|
|
1576
|
+
"text": "**Characters that deform: `@driftengine/animation` is 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, `bake` writes them, and `.drft` carries them as `SKIN`, `ANIM` and `MORP` \u2014 alongside `NODE`, 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.",
|
|
1577
|
+
"short": "Skeletal animation ships: skinning on both backends, clips, blending, blend trees, a state machine, two-bone IK, morph targets and retargeting, imported from glTF and carried by `.drft`."
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
"kind": "added",
|
|
1581
|
+
"text": "**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.",
|
|
1582
|
+
"short": "Skinning and morph targets are vertex-shader permutations, so a mesh without a rig carries none of their instructions. They cost 1,118 and 1,674 gzipped bytes against 246,925 for a fragment flag."
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
"kind": "added",
|
|
1586
|
+
"text": "**Container format 1.7.** `SKIN` carries a skeleton, `ANIM` a clip, `MORP` a mesh's morph deltas, and `NODE` the asset's own hierarchy \u2014 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.",
|
|
1587
|
+
"short": "Container 1.7: `SKIN`, `ANIM` and `MORP` join `NODE`, all optional, so older readers open the file as the geometry it also holds."
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
"kind": "fixed",
|
|
1591
|
+
"text": "**`validateMeshData` never 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 \u2014 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.",
|
|
1592
|
+
"short": "`validateMeshData` now checks the tangent attribute, which was unguarded for three releases."
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
"kind": "fixed",
|
|
1596
|
+
"text": "**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 \u2014 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.",
|
|
1597
|
+
"short": "Blend trees and state machines take a bind pose, so a rotation-only clip no longer loses a skeleton's joint offsets when it blends."
|
|
1598
|
+
}
|
|
1599
|
+
]
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
"version": "3.5.0",
|
|
1603
|
+
"date": "2026-08-25",
|
|
1604
|
+
"entries": [
|
|
1605
|
+
{
|
|
1606
|
+
"kind": "added",
|
|
1607
|
+
"text": "**Captured places: `@driftengine/splats` draws Gaussian splat captures, and `.drft` streams 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: `.ply` and `.splat` readers, 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 through `registerPass`, so a project that never imports the package carries none of it \u2014 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 and `over` is not commutative, so the order *is* the picture and it changes whenever the view turns. `SplatSorter` runs the sort in a worker built from a `Blob` \u2014 no bundler configuration, no second file \u2014 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 a `Blob` worker it falls back to the main thread and says so once. **`splatBudget` caps what a weak device is asked to draw, and it selects by screen-space size rather than by truncating the order** \u2014 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. `defaultSplatBudget` reads 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 \u2014 a discarded draw call is cheap and a linear pass over a million splats is not. **And `.drft` 1.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 \u2014 sorted by Morton code, then walked bit-reversed \u2014 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.",
|
|
1608
|
+
"short": "Gaussian splat captures draw into your scene on both backends, sorted off the frame in a worker and budgeted by screen-space size. `.drft` streams them coarse first."
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
"kind": "fixed",
|
|
1612
|
+
"text": "**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 \u2014 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: `texSubImage2D` raises `INVALID_OPERATION` and **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.",
|
|
1613
|
+
"short": "A streaming splat capture lost up to a thousand splats on WebGL2 at full density: the last partial row of an upload was skipped there and padded on WebGPU."
|
|
1614
|
+
}
|
|
1615
|
+
]
|
|
1616
|
+
},
|
|
1617
|
+
{
|
|
1618
|
+
"version": "3.4.0",
|
|
1619
|
+
"date": "2026-08-25",
|
|
1620
|
+
"entries": [
|
|
1621
|
+
{
|
|
1622
|
+
"kind": "fixed",
|
|
1623
|
+
"text": "**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 \u2014 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**: `envLod` floored 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* \u2014 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.environmentPrefilter` switches 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.",
|
|
1624
|
+
"short": "Metals read as metal again: the GGX prefilter is opt-in through RenderQuality.environmentPrefilter and off by default, so an existing scene renders as it did before 3.2.0."
|
|
1625
|
+
},
|
|
1626
|
+
{
|
|
1627
|
+
"kind": "added",
|
|
1628
|
+
"text": "**`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.",
|
|
1629
|
+
"short": "RenderQuality.environmentPrefilter turns the GGX-prefiltered environment on, with the split-sum weight and the level selection that belongs to it."
|
|
1630
|
+
}
|
|
1631
|
+
]
|
|
1632
|
+
},
|
|
1633
|
+
{
|
|
1634
|
+
"version": "3.3.0",
|
|
1635
|
+
"date": "2026-08-25",
|
|
1636
|
+
"entries": [
|
|
1637
|
+
{
|
|
1638
|
+
"kind": "fixed",
|
|
1639
|
+
"text": "**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 to `1 - 0.55 x roughness`, and for a fully metallic texel \u2014 where the Fresnel base is 1 \u2014 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** \u2014 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.",
|
|
1640
|
+
"short": "Metals reflect their surroundings fully again: the environment BRDF was losing up to 55% of a metal's reflection at high roughness by ignoring multiple scattering."
|
|
1641
|
+
},
|
|
1642
|
+
{
|
|
1643
|
+
"kind": "fixed",
|
|
1644
|
+
"text": "**A light is offered a shadow map for whatever might cast, not for wherever the camera is.** `selectPointLights` took 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 \u2014 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.",
|
|
1645
|
+
"short": "A point light is offered a shadow map for where a caster stands rather than where the camera is, so a third-person subject keeps the shadow of the light beside it."
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
"kind": "added",
|
|
1649
|
+
"text": "**A registered pass is told the frame's depth format.** `PassDevice` carried 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 \u2014 so a contributed pass that wants to be occluded by the world had to guess it. It rides along now, on the same argument.",
|
|
1650
|
+
"short": "PassDevice carries the frame's depth format, so a pass contributed by a package can depth-test against the world instead of guessing the attachment."
|
|
1651
|
+
}
|
|
1652
|
+
]
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
"version": "3.2.0",
|
|
1656
|
+
"date": "2026-08-25",
|
|
1657
|
+
"entries": [
|
|
1658
|
+
{
|
|
1659
|
+
"kind": "fixed",
|
|
1660
|
+
"text": "**A moving caster is shadowed by a point light again \u2014 and had never been.** `PointShadowSystem.advance` walked its own pool and never reached the live pair, which belongs to `LivePointShadowSet`, 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 \u2014 the octahedral layer held the caster at 189 texels of 16,384 in one position and 57 in another, `hasBaked` was 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 why `demo/dev/pointshadow.html` now exists.",
|
|
1661
|
+
"short": "A moving caster is shadowed by a point light again, and had never been: the live shadow pair's arrival ramp was never advanced, so its weight was always zero."
|
|
1662
|
+
},
|
|
1663
|
+
{
|
|
1664
|
+
"kind": "fixed",
|
|
1665
|
+
"text": "**Calling `updatePointShadows` before `prepareStaticPointShadows` now says so.** That second call builds the octahedral array and is a separate one a consumer makes once; forgetting it was completely silent \u2014 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.",
|
|
1666
|
+
"short": "Calling updatePointShadows before prepareStaticPointShadows warns instead of silently baking into nothing and casting no shadows at all."
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
"kind": "added",
|
|
1670
|
+
"text": "**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.environmentPrefilterSamples` sets 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**: `envBrdfApprox` is the split sum's analytic second half, so how much of an environment a surface returns is an integral rather than `fresnel * (1 - roughness)`, and it needs no lookup texture.",
|
|
1671
|
+
"short": "A GGX-prefiltered environment chain with the split sum's analytic BRDF term beside it, so a reflection's blur and its brightness are both integrals."
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
"kind": "added",
|
|
1675
|
+
"text": "**An environment can be loaded rather than only photographed.** `readRadianceHdr` reads Radiance RGBE \u2014 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 \u2014 and `renderer.setEnvironmentImage` takes 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.",
|
|
1676
|
+
"short": "An environment can be loaded from a Radiance .hdr rather than only photographed from the scene, joining the same prefilter and projection a bake uses."
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
"kind": "added",
|
|
1680
|
+
"text": "**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 \u22121 and \u22122, so the cone term returns its upper bound outright for every direction on the sphere and a point light's arithmetic is unchanged \u2014 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 \u2014 comfortable for a lamp, coarse for a narrow stage spotlight.",
|
|
1681
|
+
"short": "Spot lights, built as a point light with a direction and a cone that collapses exactly, so every existing scene renders unchanged."
|
|
1682
|
+
},
|
|
1683
|
+
{
|
|
1684
|
+
"kind": "added",
|
|
1685
|
+
"text": "**IES photometry, so a light throws what a real fixture throws.** `readIesProfile` reads 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. `packIesAtlas` puts 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 \u2014 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.",
|
|
1686
|
+
"short": "IES photometric profiles: a light is shaped by a real fixture's measured distribution, packed one row per profile into a single texture."
|
|
1687
|
+
},
|
|
1688
|
+
{
|
|
1689
|
+
"kind": "added",
|
|
1690
|
+
"text": "**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 \u2014 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.",
|
|
1691
|
+
"short": "Rectangular area lights: the exact analytic polygon integral for the diffuse half and a representative point for the specular. They cast no shadow yet."
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
"kind": "changed",
|
|
1695
|
+
"text": "**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 \u2014 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 \u2014 so a term full of inverse cosines is far dearer than one full of branches. `scripts/size-gate.test.mjs` carries the table.",
|
|
1696
|
+
"short": "Track C is complete. Area lights are a runtime branch rather than a permutation, reversed on a size measurement as clustered lighting was."
|
|
1697
|
+
}
|
|
1698
|
+
]
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
"version": "3.1.0",
|
|
1702
|
+
"date": "2026-08-25",
|
|
1703
|
+
"entries": [
|
|
1704
|
+
{
|
|
1705
|
+
"kind": "changed",
|
|
1706
|
+
"text": "**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=0` turns 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 \u2014 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 \u2014 a bake legitimately wants the scene's pipelines after the frame is presented, which the first fix had assumed nothing did.",
|
|
1707
|
+
"short": "The WebGPU frame graph is on by default. Seven published scenes are pixel-identical against it turned off, and two defects that only recording could expose are fixed."
|
|
1708
|
+
}
|
|
1709
|
+
]
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
"version": "3.0.0",
|
|
1713
|
+
"date": "2026-08-24",
|
|
1714
|
+
"entries": [
|
|
1715
|
+
{
|
|
1716
|
+
"kind": "changed",
|
|
1717
|
+
"text": "**The mix is a tree, and `AudioGraph` no 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 \u2014 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 on `AudioGraph` is 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.md` maps 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 \u2014 which is the audio equivalent of the zero-pixel rule the renderer is gated on, and it is now a standing gate at `scripts/audio-baseline.mjs`.",
|
|
1718
|
+
"short": "The mix is a tree of buses with inserts, solo, mute and snapshots. AudioGraph keeps the transport and its mixing methods are removed; PORTING.md maps every one."
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
"kind": "added",
|
|
1722
|
+
"text": "**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 `warp` for 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** \u2014 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 from `PannerNode`: 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.",
|
|
1723
|
+
"short": "An HRTF listener, occlusion, reverb zones and doppler. Measured on a real browser rather than asserted, and the whole layer costs nothing to an app that never places a sound."
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
"kind": "fixed",
|
|
1727
|
+
"text": "**A music fade landed at the start of an exported clip instead of where it was asked for.** `AudioGraph.fadeMusic` scheduled on `context.currentTime` rather than on the graph's own clock. Live the two agree, so nothing was ever wrong in the game; offline they do not, because an `OfflineAudioContext` holds `currentTime` at zero for the whole time a timeline is being described \u2014 so in a rendered clip every fade landed at instant zero however late `at()` 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 is `duck(1, seconds)` and the level to return to stops being something the caller has to have remembered across a settings change.",
|
|
1728
|
+
"short": "A music fade landed at instant zero in an exported clip instead of where it was scheduled. Live playback was never affected; rendered clips were, and had been all along."
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
},
|
|
1732
|
+
{
|
|
1733
|
+
"version": "2.13.0",
|
|
1734
|
+
"date": "2026-08-24",
|
|
1735
|
+
"entries": [
|
|
1736
|
+
{
|
|
1737
|
+
"kind": "added",
|
|
1738
|
+
"text": "**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`, `pressed` and `consumePress`, so there is one shape to learn rather than two, and `consumePress` claims 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** \u2014 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. `rebind` **reports 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 through `KeyValueStore`, 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.",
|
|
1739
|
+
"short": "Action maps and rebinding complete Track H: one name answers for any device, and stored bindings are a diff from the defaults so a later release can still add an action."
|
|
1740
|
+
},
|
|
1741
|
+
{
|
|
1742
|
+
"kind": "added",
|
|
1743
|
+
"text": "**The keyboard gained the two verbs a pad already had.** `isDown` is a level and `onKeyDown` is a callback, and neither is an edge a simulation tick can claim, so an action bound to a key had nothing uniform to read. `keyPressed` and `consumeKeyPress` are 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 find `keyPressed` never clearing. What the gamepad switch still turns off is the expensive half, the `getGamepads` call 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: `isDown` and `onKeyDown` behave exactly as before.",
|
|
1744
|
+
"short": "keyPressed and consumeKeyPress give the keyboard the same edge a pad has, latched at the poll, so one action can read either device without special-casing."
|
|
1745
|
+
}
|
|
1746
|
+
]
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
"version": "2.12.0",
|
|
1750
|
+
"date": "2026-08-24",
|
|
1751
|
+
"entries": [
|
|
1752
|
+
{
|
|
1753
|
+
"kind": "added",
|
|
1754
|
+
"text": "**Gamepads, controller identity and input hints**, and a controller needs no wiring at all: `InputSource` polls 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.identity` answers 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. `lastDevice` flips 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, and `onDeviceChange` fires 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: `pressed` is readable by anything and `consumePress` claims it for exactly one caller. `getGamepads` allocates 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, answering `mapping: '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: false` hands 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.",
|
|
1755
|
+
"short": "Gamepads work with no wiring: positions rather than vendor lettering, controller identity for prompts, and a hint that flips on use so an idle pad cannot steal them."
|
|
1756
|
+
},
|
|
1757
|
+
{
|
|
1758
|
+
"kind": "fixed",
|
|
1759
|
+
"text": "**Neither renderer released a registered pass or compute definition when it was disposed.** `unregisterPass` and `unregisterCompute` have 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 buffer `init` built 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 in `device.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.",
|
|
1760
|
+
"short": "A disposed renderer now releases every registered pass and compute definition instead of leaking its pipeline, bind group and buffers."
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
"kind": "changed",
|
|
1764
|
+
"text": "**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 of `render/` 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 is `WebGL2Renderer` now, for the same reason: `Renderer` and `WebGPURenderer` named one backend and left the other as the default. `Renderer` stays 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 through `createRenderer` and holds the result as `RendererApi`, which is what made this free to do. A pure move, verified by an unchanged test count rather than by inspection.",
|
|
1765
|
+
"short": "Both backends now sit under render/backend/ beside the surface they implement, and the WebGL2 class is named WebGL2Renderer. Renderer stays exported as an alias."
|
|
1766
|
+
}
|
|
1767
|
+
]
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
"version": "2.11.0",
|
|
1771
|
+
"date": "2026-08-24",
|
|
1772
|
+
"entries": [
|
|
1773
|
+
{
|
|
1774
|
+
"kind": "added",
|
|
1775
|
+
"text": "**Two primitives for racing a recording against a live run.** `TickTrace` keeps 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. Its `tickAtOrBefore` is the method worth reading \u2014 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. `fingerprintColliders` is 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** \u2014 whether half a millimetre matters is the caller's judgement, and a hash that rounded would take that judgement from every caller at once.",
|
|
1776
|
+
"short": "TickTrace keeps what a fixed-step simulation produced so a replay becomes a lookup, and fingerprintColliders says which geometry it ran against."
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
"kind": "changed",
|
|
1780
|
+
"text": "**The capability map's Platform row now names what backs its claim.** It has said \u201creplay-safe simulation path\u201d 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 \u2014 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.md` now 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.",
|
|
1781
|
+
"short": "The Platform row names TickTrace and fingerprintColliders, and the roadmap says why this is not Track J."
|
|
1782
|
+
}
|
|
1783
|
+
]
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
"version": "2.10.0",
|
|
1787
|
+
"date": "2026-08-24",
|
|
1788
|
+
"entries": [
|
|
1789
|
+
{
|
|
1790
|
+
"kind": "added",
|
|
1791
|
+
"text": "Every plan the packaging work set out with is finished. **Steam** moved into the process that can hold it: `steamworks.js` is 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 \u2014 a game gets cloud saves by naming an app id, with `host.store` unchanged and the main process deciding whether the bytes land in a file or in the cloud, and achievements arrive as `host.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 own `node_modules` and unpacks it from the asar archive, because a `.node` binary inside one fails to load. `drift-package steam` writes the two files `steamcmd` uploads with, nothing set live. **`?report=1`** draws what a device says about itself over the running game \u2014 renderer string, WebGPU, secure context, platform, WebView build \u2014 on every platform, because a pinned desktop Chromium cannot see the class of failure the mobile tier exists to catch.",
|
|
1792
|
+
"short": "Steam runs in the main process where it can, cloud saves need no game change, and ?report=1 draws what a device says about itself over the running game."
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
"kind": "added",
|
|
1796
|
+
"text": "**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 `.app` that leaves is refused outright by Apple Silicon's loader. `rcodesign` puts an ad-hoc signature on instead, pinned and fetched by `bootstrap` into 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 reads `process.platform === \"darwin\"`. It produced a 404 MB archive with zero symlink entries against 353 MB on disk, silently, so the build asks for `dir` and archives with `zip -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 is `hdiutil`, 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.",
|
|
1797
|
+
"short": "A Mac build is cross-built from Linux, ad-hoc signed by rcodesign and archived with zip -y; there is no .dmg, because that needs a Mac."
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
"kind": "fixed",
|
|
1801
|
+
"text": "**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 inside `127.0.0.0/8` or of `localhost` **without reference to the scheme** \u2014 which is what Capacitor has shipped as `capacitor://localhost` for years, its own documentation recommending the hostname stay `localhost` because it allows Web APIs that would otherwise require a secure context. So `drift://localhost/` is one and `drift://app/` is not, and the two differ by six characters. The familiar write-up about `_registerURLSchemeAsSecure` answers a different question \u2014 an https page fetching a custom-scheme *subresource*, not a document loaded from one \u2014 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.",
|
|
1802
|
+
"short": "The iOS host serves from drift://localhost, which is a secure context because of the host; no loopback server and no private API are needed."
|
|
1803
|
+
}
|
|
1804
|
+
]
|
|
1805
|
+
},
|
|
1806
|
+
{
|
|
1807
|
+
"version": "2.9.0",
|
|
1808
|
+
"date": "2026-08-24",
|
|
1809
|
+
"entries": [
|
|
1810
|
+
{
|
|
1811
|
+
"kind": "added",
|
|
1812
|
+
"text": "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 \u2014 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 \u2014 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 \u2014 whether WebKit calls a custom scheme a secure context \u2014 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 behind `PlatformServices`, `steamworks.js` an optional peer dependency imported through a variable so a game that never ships there never downloads a store SDK, and `drift-package steam` writes the depot scripts with nothing set live. `bootstrap` now fetches XcodeGen too, and `--project` points the whole command at another directory so a game keeps its manifest and none of the build tooling.",
|
|
1813
|
+
"short": "Android ships a measured APK, iOS is written, and Steam binds to the seams that already fitted; one command still fetches every toolchain."
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
"kind": "changed",
|
|
1817
|
+
"text": "`Lifecycle` gains `canQuit`, 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 \u2014 and a game that instead branches on whether it is in a shell has hard-coded an assumption that stops being true. It is `false` in a tab and on iOS, `true` in a desktop shell and on Android.",
|
|
1818
|
+
"short": "Lifecycle.canQuit says whether an exit button belongs on screen, instead of a game guessing from the platform it thinks it is on."
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
"kind": "fixed",
|
|
1822
|
+
"text": "**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=Vulkan` yields WebGPU and encodes canvas frames as all-zero YUV under Wayland \u2014 a green clip \u2014 with nothing reporting it, since `VideoEncoder.error` never fires and the muxer finalises a file of the expected size. `ForceEnableWebGpuInterop` composites 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 reports `amd 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.js` absolutely, so served from a subdirectory every stylesheet and script resolved to nothing \u2014 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.",
|
|
1823
|
+
"short": "WebGPU and clip export both work on Linux with the right flag, and an Android build no longer loads its HTML and none of its code."
|
|
1824
|
+
}
|
|
1825
|
+
]
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
"version": "2.8.0",
|
|
1829
|
+
"date": "2026-08-24",
|
|
1830
|
+
"entries": [
|
|
1831
|
+
{
|
|
1832
|
+
"kind": "added",
|
|
1833
|
+
"text": "A game is packaged into an installable application, and the first two platforms work end to end. `@driftengine/package` is a seventh workspace package and a `drift-package` command: `bootstrap` fetches whatever a target needs, `doctor` says whether a project and a machine are ready and how a build would be signed, `run` starts the real shell from source, `build` produces artifacts, and `verify` greps 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 a `drift://` origin rather than `file://` so modules, workers and `fetch` work at all; one audited bridge crosses `contextIsolation`; 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 \u2014 which macOS needs, because Apple Silicon's loader refuses an unsigned binary outright \u2014 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.",
|
|
1834
|
+
"short": "A game becomes an installable Linux, Windows, macOS or Android application through the new drift-package command, which also fetches its own toolchain."
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
"kind": "changed",
|
|
1838
|
+
"text": "The host seam is what a video settings screen is built on. `DisplayControl` gains `mode`, `setMode`, `size`, `setSize` and `displays`, so a game's own menu can offer window mode, a resolution and a monitor list and get real answers \u2014 a shell reports its displays with their refresh rates, and a browser answers `false` from 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 changes `RenderQuality.resolutionScale` instead. `ScreenPresentation` is new beside it and is what a phone has instead of a window \u2014 orientation and its lock, safe-area insets, and the wake lock \u2014 each of which can be refused, and iOS refuses an orientation lock outright. Measured while packaging: `displayFrequency` is 0 on a Wayland session, so `refreshHz` is null there, which is the honest answer rather than a plausible 60 something would divide by.",
|
|
1839
|
+
"short": "Window mode, resolution, displays and refresh rate are now capabilities a game's own settings menu drives, with orientation and safe areas for phones."
|
|
1840
|
+
},
|
|
1841
|
+
{
|
|
1842
|
+
"kind": "fixed",
|
|
1843
|
+
"text": "An inset that hangs off the edge of the canvas no longer throws on WebGPU. `setScissorRect` is `[EnforceRange] unsigned long` and a viewport must lie inside the attachment at both ends, so a negative origin threw \u2014 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.",
|
|
1844
|
+
"short": "A partly off-screen inset threw on WebGPU and lost the rest of the frame; it is now clamped, and one entirely off screen draws nothing."
|
|
1845
|
+
}
|
|
1846
|
+
]
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
"version": "2.7.0",
|
|
1850
|
+
"date": "2026-08-24",
|
|
1851
|
+
"entries": [
|
|
1852
|
+
{
|
|
1853
|
+
"kind": "added",
|
|
1854
|
+
"text": "The capabilities a native shell implements, as interfaces the engine takes rather than imports. `DisplayControl` is fullscreen and refresh rate, `Lifecycle` is focus and the end of the process, `FileDialogs` is choosing and writing a file, and `PlatformServices` is a store's achievements and presence. They follow `KeyValueStore` exactly, which is the point: the engine declares what it needs, a host supplies it, and nothing under `src/` names a shell, a webview or a packager. `BrowserDisplay`, `BrowserLifecycle` and `BrowserFileDialogs` ship as the defaults a consumer in a browser wants, and each answers honestly where a browser cannot answer at all: `refreshHz` is `null` rather than 60, because a plausible number is one something will divide by, and `requestQuit` warns rather than doing nothing quietly, because a browser cannot close its own tab and a silent no-op reads as a working call. `PlatformServices` deliberately 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.",
|
|
1855
|
+
"short": "Display, lifecycle, file dialogs and store services are now capabilities a host supplies, with browser defaults; nothing in the engine names a shell."
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
"kind": "changed",
|
|
1859
|
+
"text": "A WebGPU device is accepted by drawing with it rather than by asking whether it exists. `probeDevice` compiles 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 in `reason`, on the same path as a browser with no `navigator.gpu`. Feature detection could not have caught what this catches: through the whole iOS black screen `navigator.gpu` was 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 class `uniformStride.test.ts` already catches at test time by reading the committed files. A consumer certifying on a platform nobody has tested passes the real set through `CreateRendererOptions.probeShaders` and takes the cost, which is the trade a black screen makes worth taking. `demo/dev/backend-probe.html` and `scripts/probe-check.mjs` drive it and re-measure both figures on any machine.",
|
|
1860
|
+
"short": "A WebGPU device must compile and draw before it is used, and a refusal falls back to WebGL2 with the device's own words; compiling the shipped shaders is opt-in."
|
|
1861
|
+
}
|
|
1862
|
+
]
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
"version": "2.6.0",
|
|
1866
|
+
"date": "2026-08-23",
|
|
1867
|
+
"entries": [
|
|
1868
|
+
{
|
|
1869
|
+
"kind": "changed",
|
|
1870
|
+
"text": "WebGPU is the backend `createRenderer` picks, and WebGL2 is the fallback beneath it. It was the other way round, and the reason had expired: `preferWebGpu` defaulted to `false` with 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 passed `true` explicitly. So the conservative default was overridden by every consumer that exists and true for none of them \u2014 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 \u2014 high-contrast edge density, with neither backend the better one \u2014 and nothing regressed: `collapse` and `gilded-chamber` improved 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: false` declines it and restores the old boot exactly. The fallback is unchanged and still silent, on the same three counts, and `?backend=webgl2` still 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 \u2014 which is most of why the fallback exists.",
|
|
1871
|
+
"short": "WebGPU is now the default backend and WebGL2 the fallback; a consumer that wants the old boot passes preferWebGpu: false."
|
|
1872
|
+
}
|
|
1873
|
+
]
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
"version": "2.5.3",
|
|
1877
|
+
"date": "2026-08-23",
|
|
1878
|
+
"entries": [
|
|
1879
|
+
{
|
|
1880
|
+
"kind": "fixed",
|
|
1881
|
+
"text": "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.md` carries the measurement of what the onset curve does and does not show there.",
|
|
1882
|
+
"short": "A gap between two detected kicks may span several beats, so a track with kicks missing is no longer counted at half its tempo; the worst error measured falls from half to 0.81%."
|
|
1883
|
+
}
|
|
1884
|
+
]
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
"version": "2.5.2",
|
|
1888
|
+
"date": "2026-08-23",
|
|
1889
|
+
"entries": [
|
|
1890
|
+
{
|
|
1891
|
+
"kind": "fixed",
|
|
1892
|
+
"text": "Standing on something means standing over it. `contactSupport` asks 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 \u2014 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 \u2014 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. **`faceNormals` holds 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 \u2014 the first cut of this fix did exactly that and the ledge survived it. Both ends of each normal are bounded now.",
|
|
1893
|
+
"short": "A body is only standing on something when it is over the thing itself, rather than anywhere inside its bounding box."
|
|
1894
|
+
},
|
|
1895
|
+
{
|
|
1896
|
+
"kind": "fixed",
|
|
1897
|
+
"text": "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 \u2014 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 \u2014 a body pinched between two walls then hangs in clear air, which is the failure this exists to end rather than one to introduce.",
|
|
1898
|
+
"short": "A body inside a solid can come out of it and can no longer walk through it."
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
"kind": "added",
|
|
1902
|
+
"text": "`ejectFromSolid` pushes 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 \u2014 a deck's hull sits a give below the surface a character rides \u2014 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.",
|
|
1903
|
+
"short": "A body that has ended up inside solid geometry can be pushed back out of it."
|
|
1904
|
+
},
|
|
1905
|
+
{
|
|
1906
|
+
"kind": "fixed",
|
|
1907
|
+
"text": "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 \u2014 the body's own column has to be over the thing holding it \u2014 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.",
|
|
1908
|
+
"short": "A contact that refuses to be called ground now says which way is off it."
|
|
1909
|
+
}
|
|
1910
|
+
]
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
"version": "2.5.1",
|
|
1914
|
+
"date": "2026-08-23",
|
|
1915
|
+
"entries": [
|
|
1916
|
+
{
|
|
1917
|
+
"kind": "fixed",
|
|
1918
|
+
"text": "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 \u2014 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 \u2014 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.",
|
|
1919
|
+
"short": "Contact shading no longer leaves black speckle and a dotted fringe along every edge it draws."
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
"kind": "fixed",
|
|
1923
|
+
"text": "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.",
|
|
1924
|
+
"short": "The two backends now draw the same contact shading at the same size, instead of one quietly drawing it at half."
|
|
1925
|
+
},
|
|
1926
|
+
{
|
|
1927
|
+
"kind": "fixed",
|
|
1928
|
+
"text": "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 \u2014 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.",
|
|
1929
|
+
"short": "Three separate faults could each leave a lamp lighting a shadow with no caster under it, and the loudest was ground cover recorded through the wrong face of the light."
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
"kind": "fixed",
|
|
1933
|
+
"text": "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 \u2014 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 \u2014 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.",
|
|
1934
|
+
"short": "A low sun no longer draws straight diagonal stripes across the surfaces near a shadow."
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
"kind": "fixed",
|
|
1938
|
+
"text": "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. `resolveAtmosphere` also 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.",
|
|
1939
|
+
"short": "Going underwater on WebGPU now looks like being underwater, instead of leaving the sky exactly as it was."
|
|
1940
|
+
}
|
|
1941
|
+
]
|
|
1942
|
+
},
|
|
1943
|
+
{
|
|
1944
|
+
"version": "2.5.0",
|
|
1945
|
+
"date": "2026-08-23",
|
|
1946
|
+
"entries": [
|
|
1947
|
+
{
|
|
1948
|
+
"kind": "added",
|
|
1949
|
+
"text": "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 \u2014 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 `max` so 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. `setEnvironmentAmbient` is 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 \u2014 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.",
|
|
1950
|
+
"short": "A room now lights what stands in it through real irradiance rather than a blurred sample of itself, and the dial that stood in for it is retired."
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
"kind": "fixed",
|
|
1954
|
+
"text": "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.",
|
|
1955
|
+
"short": "Shadows on WebGPU sit off the surface they fall on, which is the other half of the winding fix that shipped in 2.4.1."
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
"kind": "fixed",
|
|
1959
|
+
"text": "`heldClock.mjs` reaches consumers. It sat at the engine's own `scripts/` rather than inside the package, so `@driftengine/core/scripts/heldClock.mjs` resolved 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, and `AGENTS.md` now says the check is to import it the way a consumer does. `scripts/shadow-readback.mjs` arrives 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.",
|
|
1960
|
+
"short": "A consuming project's capture harness could not start at all, because a module it imports by package path was not in the package."
|
|
1961
|
+
}
|
|
1962
|
+
]
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
"version": "2.4.2",
|
|
1966
|
+
"date": "2026-08-23",
|
|
1967
|
+
"entries": [
|
|
1968
|
+
{
|
|
1969
|
+
"kind": "fixed",
|
|
1970
|
+
"text": "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 \u2014 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 \u2014 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.",
|
|
1971
|
+
"short": "A frame can carry thirty-two shafts of light on WebGPU rather than sixteen, which is what one world was quietly losing eight of."
|
|
1972
|
+
}
|
|
1973
|
+
]
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
"version": "2.4.1",
|
|
1977
|
+
"date": "2026-08-23",
|
|
1978
|
+
"entries": [
|
|
1979
|
+
{
|
|
1980
|
+
"kind": "fixed",
|
|
1981
|
+
"text": "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 \u2014 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.",
|
|
1982
|
+
"short": "Shadows on WebGPU stop letting light through anything hollow. The depth pass was culling the faces it exists to record, so a vault or an arcade cast almost nothing."
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
"kind": "fixed",
|
|
1986
|
+
"text": "A pointer-lock request carries the options it was built with. `askForPointerLock` composes `{ 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 \u2014 the motion is discarded before any browser sees it, so no engine can reach it.",
|
|
1987
|
+
"short": "Pointer lock really asks for unadjusted movement now, so a camera on Chromium is no longer filtered through the system pointer curve. Firefox is unchanged."
|
|
1988
|
+
}
|
|
1989
|
+
]
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
"version": "2.4.0",
|
|
1993
|
+
"date": "2026-08-23",
|
|
1994
|
+
"entries": [
|
|
1995
|
+
{
|
|
1996
|
+
"kind": "fixed",
|
|
1997
|
+
"text": "`createPointLightBuffer` takes 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 \u2014 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 is `MAX_POINT_LIGHTS` exactly, 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.",
|
|
1998
|
+
"short": "The light buffer can be as wide as clustered lighting can carry, which the helper every consumer uses could not do before."
|
|
1999
|
+
}
|
|
2000
|
+
]
|
|
2001
|
+
},
|
|
2002
|
+
{
|
|
2003
|
+
"version": "2.3.0",
|
|
2004
|
+
"date": "2026-08-23",
|
|
2005
|
+
"entries": [
|
|
2006
|
+
{
|
|
2007
|
+
"kind": "added",
|
|
2008
|
+
"text": "Emissive maps, on both backends, which completes the material core. An image says where a surface glows and in what colour, `emissiveScale` is the per-channel factor beside `roughnessScale` and `metallicScale`, 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 whose `emissive` attribute 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.",
|
|
2009
|
+
"short": "A surface can carry an image saying where it glows. Sampled as a colour, and it scales the emission a mesh already has rather than inventing one."
|
|
2010
|
+
},
|
|
2011
|
+
{
|
|
2012
|
+
"kind": "fixed",
|
|
2013
|
+
"text": "`DrftLoader` passes on the emissive texture index, which is the fourth and last of the four `MATL` has 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.",
|
|
2014
|
+
"short": "An imported model's emissive image reaches the renderer, so a model that describes its own glow now glows."
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
"kind": "fixed",
|
|
2018
|
+
"text": "The texture-unit budget counts what the lit pass actually binds. `UNITS_WITH_EVERYTHING` was 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.",
|
|
2019
|
+
"short": "The texture-unit budget is derived rather than counted, after a literal missed two of the units the lit pass binds."
|
|
2020
|
+
}
|
|
2021
|
+
]
|
|
2022
|
+
},
|
|
2023
|
+
{
|
|
2024
|
+
"version": "2.2.0",
|
|
2025
|
+
"date": "2026-08-23",
|
|
2026
|
+
"entries": [
|
|
2027
|
+
{
|
|
2028
|
+
"kind": "added",
|
|
2029
|
+
"text": "A compute seam on WebGPU. `registerCompute` takes a definition, `dispatchCompute` runs it and `unregisterCompute` releases it, mirroring the pass seam over the same generational handles, and `ComputeDevice` hands over the raw device the way `PassDevice` does. 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, so `renderer.computeSupported` answers false there and a registration is refused in words, once per definition, rather than returning a handle that would silently do nothing.",
|
|
2030
|
+
"short": "A package can dispatch compute on WebGPU. WebGL2 says it cannot, in words, instead of accepting work it would never run."
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
"kind": "added",
|
|
2034
|
+
"text": "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 single `RGBA32UI` texture 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.",
|
|
2035
|
+
"short": "A scene can carry 320 point lights instead of sixteen, lit per froxel. Off by default, and identical to the frame before it when off."
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
"kind": "changed",
|
|
2039
|
+
"text": "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.",
|
|
2040
|
+
"short": "Shader features gate on a uniform unless a permutation genuinely pays for itself, after a fifth flag was measured at 49% of the bundle."
|
|
2041
|
+
}
|
|
2042
|
+
]
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
"version": "2.1.0",
|
|
2046
|
+
"date": "2026-08-23",
|
|
2047
|
+
"entries": [
|
|
2048
|
+
{
|
|
2049
|
+
"kind": "fixed",
|
|
2050
|
+
"text": "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 `Float32Array` that 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 in `invViewProjection` for 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.",
|
|
2051
|
+
"short": "A camera aimed straight up or down keeps a usable view, so a reflection probe's top and bottom faces hold the room instead of a stale copy of a side."
|
|
2052
|
+
},
|
|
2053
|
+
{
|
|
2054
|
+
"kind": "fixed",
|
|
2055
|
+
"text": "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 `generateMipmap` and 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.",
|
|
2056
|
+
"short": "On WebGPU a polished surface reflects the room again. The blur that gives roughness its levels was reading uniforms that never reached the GPU, so every level held one flat colour."
|
|
2057
|
+
},
|
|
2058
|
+
{
|
|
2059
|
+
"kind": "fixed",
|
|
2060
|
+
"text": "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 `beginFrame` opens 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. `beginFrame` clears 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.",
|
|
2061
|
+
"short": "A planar reflection asked for before the frame it belongs to now says so, instead of quietly drawing no mirror on one backend and a full one on the other."
|
|
2062
|
+
},
|
|
2063
|
+
{
|
|
2064
|
+
"kind": "fixed",
|
|
2065
|
+
"text": "A mesh larger than the device's buffer limit is refused with both sizes rather than taking the frame down with it. `maxBufferSize` defaults 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.",
|
|
2066
|
+
"short": "Large imported models load on WebGPU: the device asks for the buffer size its adapter offers, and a mesh too large for the hardware is refused with both sizes in words."
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
"kind": "added",
|
|
2070
|
+
"text": "`environmentReflections` turns reflections off, beside `reflectionProbeSize` which says how good they are, in the shape `water` and `waterReflections` already 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, and `bakeReflectionProbe` answers false so a caller may issue it unconditionally.",
|
|
2071
|
+
"short": "`environmentReflections` turns reflections off in one field. Surfaces keep a sky and ground approximation rather than going flat, and it is on by default."
|
|
2072
|
+
},
|
|
2073
|
+
{
|
|
2074
|
+
"kind": "fixed",
|
|
2075
|
+
"text": "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.",
|
|
2076
|
+
"short": "Metal surfaces reflect their surroundings properly: highlights survive, bright rooms stay bright in the reflection, and lamps no longer wash a polished surface flat."
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
"kind": "fixed",
|
|
2080
|
+
"text": "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; `imageTypeFor` and `isRawCodec` are exported and tested because the fall-through was the defect.",
|
|
2081
|
+
"short": "An imported model keeps the reflectivity its file states, and a texture stored without compression decodes instead of failing."
|
|
2082
|
+
},
|
|
2083
|
+
{
|
|
2084
|
+
"kind": "changed",
|
|
2085
|
+
"text": "The four modules a consumer's visual gate imports live in the core package, where the specifier `@driftengine/core/scripts/*.mjs` resolves. `browser.mjs`, `cdp.mjs`, `png.mjs` and `frames.mjs` were 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.",
|
|
2086
|
+
"short": "The capture harness modules a consumer imports resolve again, so a project's visual gate runs without a local copy of them."
|
|
2087
|
+
}
|
|
2088
|
+
]
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
"version": "2.0.0",
|
|
2092
|
+
"date": "2026-08-21",
|
|
2093
|
+
"entries": [
|
|
2094
|
+
{
|
|
2095
|
+
"kind": "changed",
|
|
2096
|
+
"text": "The engine is five packages rather than one, and core's barrel no longer re-exports four of them. `@driftengine/drft` is the container format and declares no runtime dependency at all, so a `.drft` file can be read by something that never draws; `@driftengine/assets` is the model readers and the streaming loader; `@driftengine/audio` is the graph, the synthesis and the rhythm analysis; `@driftengine/media` is clip encoding, and it takes `mp4-muxer` with it, which drops core's runtime dependencies to `gl-matrix` and 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.md` lists 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. `MeshData` and `validateMeshData` moved into the format package, which is what lets it stand alone \u2014 every import the format code took from the renderer was one of those two \u2014 but core re-exports both, so an application taking them from the renderer is unaffected",
|
|
2097
|
+
"short": "The engine is five packages, and you pay only for the ones you import. Every package publishes its own measured size."
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
"kind": "changed",
|
|
2101
|
+
"text": "`CHANGELOG.json` moved to `packages/core/CHANGELOG.json`. By package specifier \u2014 `@driftengine/core/CHANGELOG.json` \u2014 nothing changed; a consumer reading it by file path has one line to fix",
|
|
2102
|
+
"short": "The changelog lives inside the core package, unchanged by specifier and moved by path."
|
|
2103
|
+
}
|
|
2104
|
+
]
|
|
2105
|
+
},
|
|
2106
|
+
{
|
|
2107
|
+
"version": "1.4.2",
|
|
2108
|
+
"date": "2026-08-18",
|
|
2109
|
+
"entries": [
|
|
2110
|
+
{
|
|
2111
|
+
"kind": "fixed",
|
|
2112
|
+
"text": "WebGPU compiles its pipelines before the first frame instead of inside it. `createRenderPipeline` returns 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 \u2014 `submit` returns 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. `PipelineCache` gained `getAsync`, built on `createRenderPipelineAsync`, and `ready()`, 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 where `createRenderPipelineAsync` is missing",
|
|
2113
|
+
"short": "WebGPU compiles pipelines before the first frame rather than inside it, where a phone spent 5.2 seconds with its GPU queue stalled and nothing on any CPU timer to show for it."
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
"kind": "changed",
|
|
2117
|
+
"text": "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",
|
|
2118
|
+
"short": "A mesh builds a pipeline for the target it is actually drawn on, halving the compiles for any scene that never draws overlay meshes."
|
|
2119
|
+
},
|
|
2120
|
+
{
|
|
2121
|
+
"kind": "fixed",
|
|
2122
|
+
"text": "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: `createShaderModule` parses and validates the WGSL, and a descriptor is built on every `PipelineCache` miss. 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 a `WeakMap` on 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",
|
|
2123
|
+
"short": "WebGPU compiles each shader source once per device rather than once per pipeline that names it."
|
|
2124
|
+
},
|
|
2125
|
+
{
|
|
2126
|
+
"kind": "changed",
|
|
2127
|
+
"text": "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.facing` takes `'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",
|
|
2128
|
+
"short": "A particle faces the camera by default, so no blade is ever edge-on and no additive sprite draws a bright line through itself."
|
|
2129
|
+
},
|
|
2130
|
+
{
|
|
2131
|
+
"kind": "changed",
|
|
2132
|
+
"text": "`SurfaceTexture`'s `RENDER_ATTACHMENT` usage is documented as what `copyExternalImageToTexture` requires 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 on `levels > 1` \u2014 which Dawn rejects outright for every unmipped texture. Stated properly so the next reader does not have to break a build to find out",
|
|
2133
|
+
"short": "The note on a surface texture's usage flags names what actually requires them, which is the upload rather than the mip chain."
|
|
2134
|
+
}
|
|
2135
|
+
]
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
"version": "1.4.1",
|
|
2139
|
+
"date": "2026-08-17",
|
|
2140
|
+
"entries": [
|
|
2141
|
+
{
|
|
2142
|
+
"kind": "added",
|
|
2143
|
+
"text": "`drawFilm` can 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, because `buildFilmPatch` already fades its coverage to zero at the rim and the alpha was not what the eye was reading. `FilmOptions` takes `roughness` and `roughnessCyclesPerMetre`, and the cycle count is the same quantity `setSurfaceRelief` takes, 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",
|
|
2144
|
+
"short": "`drawFilm` takes a roughness, so a wet road is broken up by the aggregate under it instead of meeting the dry surface at a mirror rim."
|
|
2145
|
+
},
|
|
2146
|
+
{
|
|
2147
|
+
"kind": "added",
|
|
2148
|
+
"text": "`KickDetector.level` is 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 phonk 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. `energy` keeps its meaning and its arithmetic, and a production consumer reads only `pulse`, so nothing existing moves",
|
|
2149
|
+
"short": "`KickDetector.level` exposes the whitened low end, which keeps its dynamics where `energy` saturates on a loud master."
|
|
2150
|
+
},
|
|
2151
|
+
{
|
|
2152
|
+
"kind": "fixed",
|
|
2153
|
+
"text": "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. `getByteFrequencyData` maps the spectrum from `minDecibels` to `maxDecibels` onto 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",
|
|
2154
|
+
"short": "The live kick detector no longer reads a clipped decibel scale as if it were amplitude, which is what let it hit on things that are not kicks."
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
"kind": "fixed",
|
|
2158
|
+
"text": "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 - mask` offline against `rms * 0.6 + kickBand * 0.6 - mask` live. 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",
|
|
2159
|
+
"short": "Both analysers now share one definition of a kick, instead of two copies that had quietly drifted apart in their whitening and their timing."
|
|
2160
|
+
},
|
|
2161
|
+
{
|
|
2162
|
+
"kind": "fixed",
|
|
2163
|
+
"text": "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 `fwidth` on 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 rule `shadowFactor` in the same file already records",
|
|
2164
|
+
"short": "Procedural relief fades where a pixel covers more than a bump, so a road at a grazing angle reads as aggregate rather than as noise."
|
|
2165
|
+
}
|
|
2166
|
+
]
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
"version": "1.4.0",
|
|
2170
|
+
"date": "2026-08-17",
|
|
2171
|
+
"entries": [
|
|
2172
|
+
{
|
|
2173
|
+
"kind": "added",
|
|
2174
|
+
"text": "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 thing `meshBasicMaterial`, 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",
|
|
2175
|
+
"short": "`drawTranslucentMesh` takes `{ lit, fog }`, so a translucent draw can read exactly its own colour."
|
|
2176
|
+
},
|
|
2177
|
+
{
|
|
2178
|
+
"kind": "added",
|
|
2179
|
+
"text": "`'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 with `PointsMaterial`, needed both at once. `ParticleBatchOptions.fog` rides 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",
|
|
2180
|
+
"short": "`'mote'`, an unlit alpha-blended particle, with fog as an explicit choice rather than a fixture."
|
|
2181
|
+
},
|
|
2182
|
+
{
|
|
2183
|
+
"kind": "added",
|
|
2184
|
+
"text": "`renderer.setSurfaceTextureRelief(scale)` reads relief off the surface texture a draw already binds, as its own luminance taken for a height field. `setSurfaceRelief` invents 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 beside `setSurfaceRelief`, `setSurfaceGrain` and `setSurfaceReflectivity`, reset by `bindMeshPass`, scaled exactly as three.js's `bumpScale` is so a scene porting one carries the number across rather than refitting it by eye, and gated on the same flag `setSurfaceTexture(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 to `map` and `bumpMap` together 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",
|
|
2185
|
+
"short": "`setSurfaceTextureRelief` turns a surface texture's own luminance into relief on the shading normal."
|
|
2186
|
+
},
|
|
2187
|
+
{
|
|
2188
|
+
"kind": "fixed",
|
|
2189
|
+
"text": "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",
|
|
2190
|
+
"short": "On WebGPU, two particle batches of one material no longer overwrite each other's settings."
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
"kind": "fixed",
|
|
2194
|
+
"text": "A renderer running without the composite grades every pass, not only the mesh. `flat.ts` was the one shader that applied `outputTransform` itself, so a consumer with `screenEffects` off 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. `#ff8d72` reached 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, `hdrScene` on, 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: `outputTransform` defaults to `none` and 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",
|
|
2195
|
+
"short": "Without a composite, particles, SDF text and lines are graded like the world instead of written raw."
|
|
2196
|
+
}
|
|
2197
|
+
]
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
"version": "1.3.0",
|
|
2201
|
+
"date": "2026-08-17",
|
|
2202
|
+
"entries": [
|
|
2203
|
+
{
|
|
2204
|
+
"kind": "added",
|
|
2205
|
+
"text": "Polylines: `createLines`, `drawLines` and `disposeLines` on 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 clamps `lineWidth` to 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. `lineBatch` expands each segment into a camera-facing quad through `segmentQuads.ts`, the same corner table `boltBatch` already 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",
|
|
2206
|
+
"short": "Polylines: `createLines`/`drawLines`/`disposeLines` draw a real-width stroke in one call, fogged and antialiased."
|
|
2207
|
+
},
|
|
2208
|
+
{
|
|
2209
|
+
"kind": "added",
|
|
2210
|
+
"text": "A font can carry pre-shaped runs: a glyph-table key longer than one character, baked by `scripts/sdf-font.ts` from 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 through `measureText` and `fillText`. `SdfFont.runs` holds 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",
|
|
2211
|
+
"short": "A font can carry pre-shaped runs, so a script like Arabic that joins its letters draws as a whole word."
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
"kind": "added",
|
|
2215
|
+
"text": "`scripts/sdf-font.ts` takes `--font` more 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",
|
|
2216
|
+
"short": "The atlas generator takes more than one font file, as a fallback stack."
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
"kind": "fixed",
|
|
2220
|
+
"text": "The SDF font and text style surface is exported from the package entry point. `parseSdfFont`, `SdfFont`, `SdfGlyph`, `SdfTextLayout`, `DEFAULT_SDF_TEXT_STYLE` and `SdfTextStyle` lived under `src/render/` and nowhere in `src/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 what `demo/dev/sdf-text.ts` had been doing since it was written. The fix is the export list alone; nothing about the types or the verbs themselves changed",
|
|
2221
|
+
"short": "The SDF font and text style surface is exported from the package entry point."
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
"kind": "fixed",
|
|
2225
|
+
"text": "`claimPlaybackSession` and `audioContextConstructor` are exported from the package entry point, for a consumer whose audio is one decoded file through its own chain rather than `AudioGraph`, 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.create` already 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; `AudioGraph` still calls both itself, so behaviour is unchanged for an existing consumer",
|
|
2226
|
+
"short": "`claimPlaybackSession` and `audioContextConstructor` are exported, for audio built outside `AudioGraph` to survive an iPhone's ringer switch."
|
|
2227
|
+
}
|
|
2228
|
+
]
|
|
2229
|
+
},
|
|
2230
|
+
{
|
|
2231
|
+
"version": "1.2.0",
|
|
2232
|
+
"date": "2026-08-16",
|
|
2233
|
+
"entries": [
|
|
2234
|
+
{
|
|
2235
|
+
"kind": "added",
|
|
2236
|
+
"text": "Optional SDF text, built from a font file rather than the engine's own five by seven pixel glyphs. `npm run sdf-font` is 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, `parseSdfFont` reads the document back into an `SdfFont`, and `SdfTextLayout` turns a string and a style into vertex data. `createSdfText`, `setSdfText`, `drawSdfText` and `disposeSdfText` are the four verbs on both backends; `setSdfText` takes 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 calls `createSdfText` triggers 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 convention `atlasLeft`/`atlasRight` already 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 twelve `anchorX`/`anchorY` combinations 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",
|
|
2237
|
+
"short": "Optional SDF text from a font file: opt-in, so a consumer that never calls it fetches no atlas."
|
|
2238
|
+
},
|
|
2239
|
+
{
|
|
2240
|
+
"kind": "added",
|
|
2241
|
+
"text": "Ray picking: `registerPickable`, `updatePickable`, `unregisterPickable` and `pickAt` on both backends, answering what is under a pixel without dispatching an event for it. `PickableSet` is backend-agnostic, the same split `math/intersect.ts` already 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. `pickAt` reads a CSS pixel through `camera.rayThrough`, added alongside `camera.project` in 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*localDirection` uses the same inverse for the origin and the direction, so `t` does 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",
|
|
2242
|
+
"short": "Ray picking (registerPickable/updatePickable/unregisterPickable/pickAt) answers what is under a pixel."
|
|
2243
|
+
},
|
|
2244
|
+
{
|
|
2245
|
+
"kind": "added",
|
|
2246
|
+
"text": "`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 to `style.left`/`style.top` with no sign flip of its own. Ships beside `camera.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",
|
|
2247
|
+
"short": "`camera.project` turns a world point into CSS pixels, for attaching DOM to a place in the world."
|
|
2248
|
+
},
|
|
2249
|
+
{
|
|
2250
|
+
"kind": "added",
|
|
2251
|
+
"text": "`Renderer.presentedFrames`, a monotonic count of frames a renderer has actually presented, requested by a consumer (`ENGINE-NOTES.md` \u00a71) 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 at `swapView`'s `getCurrentTexture` acquisition, and WebGL2, which has no swap chain to acquire, counts at the one point `endFrame` is guaranteed to reach only when the frame it opened is what the browser is about to show. A frame that dies between `beginFrame` and `endFrame`, or a surface lost mid-frame, does not advance it. `ExportTarget` and `FrameRecorder` consume it internally through an optional callback and a pure `isDuplicateFrame` comparison that is unit-testable without a DOM; a caller that never wires the callback sees no change in behaviour at all",
|
|
2252
|
+
"short": "`presentedFrames` counts frames actually presented, so an export can spot a repeat."
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
"kind": "added",
|
|
2256
|
+
"text": "`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 under `aces` blooms and tints rather than clipping, which is why this could not simply reuse `setOutputExposure`. 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 every `endFrame`, unlike `setSpeedRush` and `setCameraMotionBlur` beside 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",
|
|
2257
|
+
"short": "`setFrameVeil` composites a flat colour over the finished frame, for a cut's dip to white or black."
|
|
2258
|
+
}
|
|
2259
|
+
]
|
|
2260
|
+
},
|
|
2261
|
+
{
|
|
2262
|
+
"version": "1.1.2",
|
|
2263
|
+
"date": "2026-08-16",
|
|
2264
|
+
"entries": [
|
|
2265
|
+
{
|
|
2266
|
+
"kind": "changed",
|
|
2267
|
+
"text": "`discardResolvedAttachments` and `deferFramePass` default 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 with `npm 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=0` and `?defer=0` still 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. `gpuTiming` stays 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",
|
|
2268
|
+
"short": "The two tiler bandwidth changes are on again; they were never the black screen."
|
|
2269
|
+
}
|
|
2270
|
+
]
|
|
2271
|
+
},
|
|
2272
|
+
{
|
|
2273
|
+
"version": "1.1.1",
|
|
2274
|
+
"date": "2026-08-16",
|
|
2275
|
+
"entries": [
|
|
2276
|
+
{
|
|
2277
|
+
"kind": "fixed",
|
|
2278
|
+
"text": "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>` and `array<i32, 10>`, which stride by four. Dawn accepts that: all sixteen `flat` permutations 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 answered `arrays in the uniform address space must have a stride multiple of 16 bytes, but has a stride of 4 bytes`. `flat` is the mesh shader and no permutation of it survived, so every geometry pipeline in the frame was invalid, `setPipeline` invalidated 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 read `webgpu \u00b7 60 fps \u00b7 13 draws` over it, because from the API's point of view nothing had failed. `water` and `particle` carried the same arrays. The cause is that WGSL removed `@stride`, so naga cannot express SPIR-V's `ArrayStride 16` and drops it, and `scripts/wgsl/layout.mjs` had 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 and `blockLayout` computes 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",
|
|
2279
|
+
"short": "iOS renders again: uniform arrays stride by sixteen, as WGSL requires."
|
|
2280
|
+
}
|
|
2281
|
+
]
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
"version": "1.1.0",
|
|
2285
|
+
"date": "2026-08-16",
|
|
2286
|
+
"entries": [
|
|
2287
|
+
{
|
|
2288
|
+
"kind": "added",
|
|
2289
|
+
"text": "The WebGPU backend can say what a frame cost. `gpuTimer` there was a stub reporting `available: false` forever, so every GPU figure this project has ever quoted came from WebGL2 \u2014 the backend a phone does not use \u2014 and the consumer's own rule that a GPU verdict needs `gpuSamples > 0` could never be satisfied on the one that ships. `GpuTimestamps` implements the same `FrameTimer` interface `GpuTimer` does, 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 \u2014 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 a `requiredFeatures` naming 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",
|
|
2290
|
+
"short": "The WebGPU backend measures its own frames, through timestamp queries."
|
|
2291
|
+
},
|
|
2292
|
+
{
|
|
2293
|
+
"kind": "fixed",
|
|
2294
|
+
"text": "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 no `invalidateFramebuffer` existed anywhere, so every multisampled colour target was resolved and then also written out in full \u2014 and `post.sceneColorMsaa`, `reflection.colorMsaa` and `probe.colorMsaa` are created with RENDER_ATTACHMENT and no TEXTURE_BINDING, so no shader can read one. `reflection.depth` and `overlay.depth` were 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 with `loadOp: '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 \u2014 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 at `store`, and removing them is the next thing worth doing",
|
|
2295
|
+
"short": "Multisample attachments are discarded after resolve instead of written to memory."
|
|
2296
|
+
},
|
|
2297
|
+
{
|
|
2298
|
+
"kind": "fixed",
|
|
2299
|
+
"text": "The live point-shadow cubemaps are budgeted like every other bake. `pointShadowFacesPerFrame` was 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 \u2014 measured as `pointShadow.face` six 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 \u2014 the caster moved, that is what makes it live \u2014 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.cube` fell from six a frame to two and the frame from twelve passes to eight. Both backends, because the scheduler is shared",
|
|
2300
|
+
"short": "Live point-shadow cubemaps no longer re-bake in full every frame."
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
"kind": "added",
|
|
2304
|
+
"text": "`RenderQuality.liveShadowFacesPerFrame`, the live half of `pointShadowFacesPerFrame`. 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=`",
|
|
2305
|
+
"short": "`liveShadowFacesPerFrame` caps the per-frame live cubemap bake."
|
|
2306
|
+
},
|
|
2307
|
+
{
|
|
2308
|
+
"kind": "fixed",
|
|
2309
|
+
"text": "The frame's own attachment is opened once, instead of being closed and read back for every mirror. `beginFrame` opened the render pass eagerly, so a scene drawing a planar reflection had to end it, submit, and reopen it with `loadOp: 'load'` \u2014 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. `ensurePass` opens 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. `frameNeedsClear` keeps the distinction that matters: a scene that had already drawn before opening a mirror still gets a `load`, and one that opened the mirror first gets its single `clear` late. 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, with `load:post.sceneColorMsaa` and `load:flat.depth` each halved and one fewer multisample store. An empty frame still clears, because `endFrame` opens the pass rather than returning early on a null one \u2014 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",
|
|
2310
|
+
"short": "The frame's attachment is opened once rather than reopened per mirror."
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
"kind": "added",
|
|
2314
|
+
"text": "`RenderQuality.discardResolvedAttachments` and `RenderQuality.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 in `beginFrame`. 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 \u2014 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=0` and 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 \u2014 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 is `invalidateFramebuffer` after the resolve blit",
|
|
2315
|
+
"short": "Two switches to turn off the tiler-only bandwidth changes from the address bar."
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
"kind": "fixed",
|
|
2319
|
+
"text": "The capability clamp fires on WebGPU. It could not before: `WebGPURenderer.rendererName` was the literal string `WebGPU`, so `isWeakGpuFamily` had nothing to match, `capabilityClamped` was a hardcoded `false`, and a consumer passing `capabilityClamp: true` got silence \u2014 on the backend that had replaced the one where that clamp rescued an Adreno 619 from 156 ms a frame. `selectBackend` now builds a name from `adapter.info` and hands it back on `BackendChoice.rendererName`, and the renderer applies the same two pixel terms `renderer.ts` applies, from the same table",
|
|
2320
|
+
"short": "The capability clamp works on WebGPU, instead of reporting that it never fired."
|
|
2321
|
+
},
|
|
2322
|
+
{
|
|
2323
|
+
"kind": "changed",
|
|
2324
|
+
"text": "`isWeakGpuFamily` recognises WebGPU's architecture buckets as well as WebGL2's part numbers, and the difference between them is worth knowing. `UNMASKED_RENDERER_WEBGL` gives `ANGLE (Qualcomm, Adreno (TM) 619, OpenGL ES 3.2)`; `GPUAdapterInfo` deliberately does not, reporting a vendor and an architecture bucket to reduce fingerprinting entropy \u2014 measured here, an RX 9070 XT says `vendor: \"amd\", architecture: \"rdna-4\"` and nothing else at all. So `adreno-5xx`, `adreno-6xx` and `mali-g[35]x` are matched, and **`adreno-7xx` deliberately 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",
|
|
2325
|
+
"short": "The weak-GPU table understands WebGPU's coarse architecture buckets."
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
"kind": "fixed",
|
|
2329
|
+
"text": "A frame that fails no longer blanks the screen. `getCurrentTexture` is an acquisition rather than a read \u2014 whatever is in the texture it returns is what the browser presents at the end of the task, drawn into or not \u2014 and `beginFrame` took 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 between `beginFrame` and `endFrame`. 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 \u2014 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 because `configure` sizes the swap chain from it",
|
|
2330
|
+
"short": "A frame that fails leaves the last good one on screen instead of presenting black."
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
"kind": "added",
|
|
2334
|
+
"text": "`demoQualityFor` and `readDemoDeviceHints`, so a demo host can scale a scene to the device without taking anything out of it. Every scene mounted at `full` on every device, and `full` is the engine's defaults plus `sceneSamples: 4` \u2014 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 the `lean` budget and it is the wrong one for a page with no settings screen**: `lean` switches off directional shadows, water reflections and `screenEffects`, 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 \u2014 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 \u2014 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",
|
|
2335
|
+
"short": "A demo host can scale a scene to the device without removing any of its passes."
|
|
2336
|
+
},
|
|
2337
|
+
{
|
|
2338
|
+
"kind": "changed",
|
|
2339
|
+
"text": "`discardResolvedAttachments` and `deferFramePass` now default to **false**, and the swap chain is acquired eagerly again unless `deferFramePass` asks otherwise. All three consumers came back completely black on an iPhone 16 Pro under Chrome \u2014 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 \u2014 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 WebGL2 `invalidateFramebuffer` (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 \u2014 `?discard=1`, `?defer=1` \u2014 so whichever brings the black back names the cause",
|
|
2340
|
+
"short": "The two tiler-only changes default off after an iOS black screen; both bisectable."
|
|
2341
|
+
},
|
|
2342
|
+
{
|
|
2343
|
+
"kind": "fixed",
|
|
2344
|
+
"text": "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.lost` and 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 to `onContextLost` had no way to tell that apart from a rendering bug \u2014 and two of the three here were not listening. `beginFrame` now says so once, naming the recovery, and `DemoHandle.lost` lets a demo host see it without reaching past the handle. WebGPU cannot restore a device, only replace one: `createRenderer` on the same canvas acquires a fresh adapter and device and reconfigures the context, so it has always been the recovery \u2014 nothing was calling it",
|
|
2345
|
+
"short": "A lost GPU device says so instead of drawing a silent black frame."
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
"kind": "fixed",
|
|
2349
|
+
"text": "Nothing may end the planar reflection's pass while the mirror is being drawn. `takeVolumeDepth` and `openTextPass` both 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 \u2014 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 what `storeOp: 'discard'` promises, so the fault appears on a phone and nowhere else. `drawFilm` already guarded this boundary with `!reflectionPassActive` and the asymmetry is what let it survive. A volume inside the mirror now draws unclamped, which is right on its own terms \u2014 `resolvedDepthView` holds the frame's depth, and clamping a mirrored beam against it would stop it at geometry that is not in front of it",
|
|
2350
|
+
"short": "The mirror's pass can no longer be ended by a light volume or by text."
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
"kind": "changed",
|
|
2354
|
+
"text": "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 a `timestampWrites` block to *every render pass in the frame* \u2014 twelve of them \u2014 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. The `timestamp-query` feature 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=1` in the harness and in both consuming apps",
|
|
2355
|
+
"short": "GPU timing is off by default; a profiler must not be able to blank a frame."
|
|
2356
|
+
}
|
|
2357
|
+
]
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
"version": "1.0.2",
|
|
2361
|
+
"date": "2026-08-16",
|
|
2362
|
+
"entries": [
|
|
2363
|
+
{
|
|
2364
|
+
"kind": "fixed",
|
|
2365
|
+
"text": "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.",
|
|
2366
|
+
"short": "Clips encode at 1440 and 4K. Both codec strings were level 4.0, whose 8192 macroblocks stop at 1080, so any larger frame was refused before configure was reached."
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
"kind": "added",
|
|
2370
|
+
"text": "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.",
|
|
2371
|
+
"short": "clipEncodingSupported asks the encoder about a clip size before a consumer offers it, and framesReachEncoder catches a browser that encodes empty frames while reporting success."
|
|
2372
|
+
}
|
|
2373
|
+
]
|
|
2374
|
+
},
|
|
2375
|
+
{
|
|
2376
|
+
"version": "1.0.1",
|
|
2377
|
+
"date": "2026-08-15",
|
|
2378
|
+
"entries": [
|
|
2379
|
+
{
|
|
2380
|
+
"kind": "fixed",
|
|
2381
|
+
"text": "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.",
|
|
2382
|
+
"short": "A translucent draw at full opacity blends again on WebGPU, so a texture whose coverage is its own alpha stops arriving as a solid slab."
|
|
2383
|
+
}
|
|
2384
|
+
]
|
|
2385
|
+
},
|
|
2386
|
+
{
|
|
2387
|
+
"version": "1.0.0",
|
|
2388
|
+
"date": "2026-08-14",
|
|
2389
|
+
"entries": [
|
|
2390
|
+
{
|
|
2391
|
+
"kind": "added",
|
|
2392
|
+
"text": "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.",
|
|
2393
|
+
"short": "A WebGPU backend behind the same surface as WebGL2, chosen by createRenderer and reported rather than assumed."
|
|
2394
|
+
},
|
|
2395
|
+
{
|
|
2396
|
+
"kind": "changed",
|
|
2397
|
+
"text": "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.",
|
|
2398
|
+
"short": "Text, surface textures, plumes and particles are handles now. Stop naming the concrete classes first, or the errors read as something else."
|
|
2399
|
+
},
|
|
2400
|
+
{
|
|
2401
|
+
"kind": "changed",
|
|
2402
|
+
"text": "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.",
|
|
2403
|
+
"short": "Plumes and particles name a material rather than carrying GLSL, because a shader string cannot cross to a second backend."
|
|
2404
|
+
},
|
|
2405
|
+
{
|
|
2406
|
+
"kind": "added",
|
|
2407
|
+
"text": "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.",
|
|
2408
|
+
"short": "An arcane plume material, tinted, for auras and anything else that is light rather than fire."
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
"kind": "fixed",
|
|
2412
|
+
"text": "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 \u2014 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.",
|
|
2413
|
+
"short": "A canvas-sourced texture is no longer uploaded upside down, so canvases and bitmaps agree. Anything cancelling the old flip must stop."
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
"kind": "fixed",
|
|
2417
|
+
"text": "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 \u2014 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.",
|
|
2418
|
+
"short": "A wet film no longer costs the whole planar reflection on WebGPU, where holding a texture counts even when nothing reads it."
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
"kind": "fixed",
|
|
2422
|
+
"text": "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.",
|
|
2423
|
+
"short": "CausticsHandle and SurfaceTextureHandle are exported, and particle reuse takes a handle rather than the WebGL2 class."
|
|
2424
|
+
}
|
|
2425
|
+
]
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
"version": "0.35.0",
|
|
2429
|
+
"date": "2026-08-12",
|
|
2430
|
+
"entries": [
|
|
2431
|
+
{
|
|
2432
|
+
"kind": "changed",
|
|
2433
|
+
"text": "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.",
|
|
2434
|
+
"short": "A volume of light is now integrated along the view ray rather than drawn on flat panes, which is what removes the asterisk a beam showed when looked at end-on."
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
"kind": "added",
|
|
2438
|
+
"text": "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.",
|
|
2439
|
+
"short": "Dust and a sun shadow for volumes of light, plus a march sample count on the render quality options. Both terms default to off."
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
"kind": "fixed",
|
|
2443
|
+
"text": "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.",
|
|
2444
|
+
"short": "A volume of light watched from the side no longer breaks into blobs, and the march's dither no longer rattles the smooth part of the integral."
|
|
2445
|
+
}
|
|
2446
|
+
]
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
"version": "0.34.0",
|
|
2450
|
+
"date": "2026-08-12",
|
|
2451
|
+
"entries": [
|
|
2452
|
+
{
|
|
2453
|
+
"kind": "fixed",
|
|
2454
|
+
"text": "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.",
|
|
2455
|
+
"short": "A sun below the horizon no longer reads as a sun at the horizon, so a night sky stops carrying a sunset the world never asked for."
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2458
|
+
"kind": "changed",
|
|
2459
|
+
"text": "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.",
|
|
2460
|
+
"short": "The sunset band no longer mirrors itself below the horizon, where it tinted the deep sky and every downward probe face warm."
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
"kind": "added",
|
|
2464
|
+
"text": "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.",
|
|
2465
|
+
"short": "A builder for the panes a light volume is drawn on, taking the same length and aperture the draw call takes, so the geometry and the falloff cannot disagree."
|
|
2466
|
+
}
|
|
2467
|
+
]
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
"version": "0.33.0",
|
|
2471
|
+
"date": "2026-08-12",
|
|
2472
|
+
"entries": [
|
|
2473
|
+
{
|
|
2474
|
+
"kind": "added",
|
|
2475
|
+
"text": "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.",
|
|
2476
|
+
"short": "A generated sphere can carry an authored image: the primitive keeps the surface coordinates it already walks instead of discarding them."
|
|
2477
|
+
},
|
|
2478
|
+
{
|
|
2479
|
+
"kind": "added",
|
|
2480
|
+
"text": "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.",
|
|
2481
|
+
"short": "Emission that only shows where the sun does not reach, for lamps on the dark side of a turning body. Compiled in only when asked for."
|
|
2482
|
+
}
|
|
2483
|
+
]
|
|
2484
|
+
},
|
|
2485
|
+
{
|
|
2486
|
+
"version": "0.32.0",
|
|
2487
|
+
"date": "2026-08-12",
|
|
2488
|
+
"entries": [
|
|
2489
|
+
{
|
|
2490
|
+
"kind": "added",
|
|
2491
|
+
"text": "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.",
|
|
2492
|
+
"short": "The capture tools can freeze and drive a page they were not built into, wait in drawn frames rather than milliseconds, and walk any list of addresses."
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
"kind": "fixed",
|
|
2496
|
+
"text": "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.",
|
|
2497
|
+
"short": "A load reports an image as done when it reaches the graphics card, not when its bytes arrive, so a progress bar stops finishing before the picture does."
|
|
2498
|
+
},
|
|
2499
|
+
{
|
|
2500
|
+
"kind": "fixed",
|
|
2501
|
+
"text": "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.",
|
|
2502
|
+
"short": "Two uniforms that had been uploaded every frame to locations that no longer existed are gone, with no change to any pixel."
|
|
2503
|
+
}
|
|
2504
|
+
]
|
|
2505
|
+
},
|
|
2506
|
+
{
|
|
2507
|
+
"version": "0.31.0",
|
|
2508
|
+
"date": "2026-08-11",
|
|
2509
|
+
"entries": [
|
|
2510
|
+
{
|
|
2511
|
+
"kind": "added",
|
|
2512
|
+
"text": "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.",
|
|
2513
|
+
"short": "The tools for photographing and comparing frames ship with the engine, including the check that refuses to measure on a software renderer."
|
|
2514
|
+
},
|
|
2515
|
+
{
|
|
2516
|
+
"kind": "fixed",
|
|
2517
|
+
"text": "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.",
|
|
2518
|
+
"short": "Bloom decides brightness from a colour\u2019s largest channel rather than its luminance, so a saturated light blooms instead of counting as dim."
|
|
2519
|
+
}
|
|
2520
|
+
]
|
|
2521
|
+
},
|
|
2522
|
+
{
|
|
2523
|
+
"version": "0.30.0",
|
|
2524
|
+
"date": "2026-08-11",
|
|
2525
|
+
"entries": [
|
|
2526
|
+
{
|
|
2527
|
+
"kind": "added",
|
|
2528
|
+
"text": "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.",
|
|
2529
|
+
"short": "Bright things can be wider than the pixels they cover: one pass that spreads whatever sits above a threshold, off unless asked for, with the threshold read in the scene's own units."
|
|
2530
|
+
}
|
|
2531
|
+
]
|
|
2532
|
+
},
|
|
2533
|
+
{
|
|
2534
|
+
"version": "0.29.0",
|
|
2535
|
+
"date": "2026-08-11",
|
|
2536
|
+
"entries": [
|
|
2537
|
+
{
|
|
2538
|
+
"kind": "added",
|
|
2539
|
+
"text": "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.",
|
|
2540
|
+
"short": "A scene can keep its real brightness to the end of the frame and be graded there, which is what anything reacting to brightness needs first."
|
|
2541
|
+
},
|
|
2542
|
+
{
|
|
2543
|
+
"kind": "changed",
|
|
2544
|
+
"text": "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.",
|
|
2545
|
+
"short": "Keeping the range is off unless asked for: it is a real change to bright pixels, and with it off a published scene is unchanged."
|
|
2546
|
+
}
|
|
2547
|
+
]
|
|
2548
|
+
},
|
|
2549
|
+
{
|
|
2550
|
+
"version": "0.28.0",
|
|
2551
|
+
"date": "2026-08-11",
|
|
2552
|
+
"entries": [
|
|
2553
|
+
{
|
|
2554
|
+
"kind": "fixed",
|
|
2555
|
+
"text": "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.",
|
|
2556
|
+
"short": "A surface's microscopic relief survives a round trip through the container. The format had no place for it and dropped it in silence."
|
|
2557
|
+
},
|
|
2558
|
+
{
|
|
2559
|
+
"kind": "changed",
|
|
2560
|
+
"text": "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.",
|
|
2561
|
+
"short": "The tone curve setting names the symptom, because nobody searches for a transfer curve they do not know exists."
|
|
2562
|
+
}
|
|
2563
|
+
]
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
"version": "0.27.0",
|
|
2567
|
+
"date": "2026-08-11",
|
|
2568
|
+
"entries": [
|
|
2569
|
+
{
|
|
2570
|
+
"kind": "fixed",
|
|
2571
|
+
"text": "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.",
|
|
2572
|
+
"short": "A light's physical size reaches the shading again. Two guards disagreed about what an absent array is, and the feature was silently off."
|
|
2573
|
+
},
|
|
2574
|
+
{
|
|
2575
|
+
"kind": "changed",
|
|
2576
|
+
"text": "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.",
|
|
2577
|
+
"short": "Five things a caller could only learn by reading the source now say so: quad facing, open tubes, unlit instances, what a reflection re-enters, and radius constraints."
|
|
2578
|
+
}
|
|
2579
|
+
]
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
"version": "0.26.0",
|
|
2583
|
+
"date": "2026-08-11",
|
|
2584
|
+
"entries": [
|
|
2585
|
+
{
|
|
2586
|
+
"kind": "added",
|
|
2587
|
+
"text": "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.",
|
|
2588
|
+
"short": "A generated solid can have its radius and its colour asked for per point, so one primitive covers a boulder, a fracture and a banded planet."
|
|
2589
|
+
},
|
|
2590
|
+
{
|
|
2591
|
+
"kind": "changed",
|
|
2592
|
+
"text": "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.",
|
|
2593
|
+
"short": "The new solid shades from the surface rather than the radius, so a bulge lights as a bulge and a step gives a real edge."
|
|
2594
|
+
},
|
|
2595
|
+
{
|
|
2596
|
+
"kind": "changed",
|
|
2597
|
+
"text": "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.",
|
|
2598
|
+
"short": "The night factor, and the arc segments a bolt batch draws, now say what they are rather than what they were named for."
|
|
2599
|
+
}
|
|
2600
|
+
]
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
"version": "0.25.0",
|
|
2604
|
+
"date": "2026-08-11",
|
|
2605
|
+
"entries": [
|
|
2606
|
+
{
|
|
2607
|
+
"kind": "fixed",
|
|
2608
|
+
"text": "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.",
|
|
2609
|
+
"short": "A model arriving over the network no longer takes whole frames to arrive in: one clock bounds the per-frame stream work, where a count of parts could not."
|
|
2610
|
+
},
|
|
2611
|
+
{
|
|
2612
|
+
"kind": "added",
|
|
2613
|
+
"text": "`uploadMsPerFrame` says 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. `uploadsPerFrame` stays, 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.",
|
|
2614
|
+
"short": "`uploadMsPerFrame` bounds how long one update may spend starting stream work. `uploadsPerFrame` stays as a ceiling for a model of many tiny parts."
|
|
2615
|
+
}
|
|
2616
|
+
]
|
|
2617
|
+
},
|
|
2618
|
+
{
|
|
2619
|
+
"version": "0.24.0",
|
|
2620
|
+
"date": "2026-08-11",
|
|
2621
|
+
"entries": [
|
|
2622
|
+
{
|
|
2623
|
+
"kind": "fixed",
|
|
2624
|
+
"text": "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.",
|
|
2625
|
+
"short": "Textures on an imported OBJ are no longer upside down. Anything compensating for it outside the engine has to stop in this version."
|
|
2626
|
+
},
|
|
2627
|
+
{
|
|
2628
|
+
"kind": "changed",
|
|
2629
|
+
"text": "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.",
|
|
2630
|
+
"short": "A material library an OBJ names and does not have is now a warning, so a missing file cannot read as a model without textures."
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
"kind": "added",
|
|
2634
|
+
"text": "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.",
|
|
2635
|
+
"short": "The light selection reports how many sources were offered as well as how many were bound, so a scene over budget is one glance away."
|
|
2636
|
+
}
|
|
2637
|
+
]
|
|
2638
|
+
},
|
|
2639
|
+
{
|
|
2640
|
+
"version": "0.23.0",
|
|
2641
|
+
"date": "2026-08-11",
|
|
2642
|
+
"entries": [
|
|
2643
|
+
{
|
|
2644
|
+
"kind": "added",
|
|
2645
|
+
"text": "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.",
|
|
2646
|
+
"short": "Surfaces can carry microscopic relief, so a road reads as asphalt rather than as a grey plane."
|
|
2647
|
+
},
|
|
2648
|
+
{
|
|
2649
|
+
"kind": "changed",
|
|
2650
|
+
"text": "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.",
|
|
2651
|
+
"short": "A surface's roughness grows with its relief, so adding texture cannot reintroduce the sparkle two releases removed."
|
|
2652
|
+
}
|
|
2653
|
+
]
|
|
2654
|
+
},
|
|
2655
|
+
{
|
|
2656
|
+
"version": "0.22.0",
|
|
2657
|
+
"date": "2026-08-11",
|
|
2658
|
+
"entries": [
|
|
2659
|
+
{
|
|
2660
|
+
"kind": "added",
|
|
2661
|
+
"text": "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.",
|
|
2662
|
+
"short": "A mirrored pass can be rendered without water, and a wet film can show it, so a road can reflect what stands over it."
|
|
2663
|
+
},
|
|
2664
|
+
{
|
|
2665
|
+
"kind": "changed",
|
|
2666
|
+
"text": "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.",
|
|
2667
|
+
"short": "The film's mirror is new and its weighting is unproven, and the option says so with the measurement."
|
|
2668
|
+
}
|
|
2669
|
+
]
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
"version": "0.21.0",
|
|
2673
|
+
"date": "2026-08-11",
|
|
2674
|
+
"entries": [
|
|
2675
|
+
{
|
|
2676
|
+
"kind": "added",
|
|
2677
|
+
"text": "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.",
|
|
2678
|
+
"short": "Plumes, wind streaks, flocks, scattered instances, particles, bolts, water and caustics can all be released through the renderer."
|
|
2679
|
+
},
|
|
2680
|
+
{
|
|
2681
|
+
"kind": "changed",
|
|
2682
|
+
"text": "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.",
|
|
2683
|
+
"short": "The planar reflection, the film's sheen and the arc pool now state the constraints their names hide."
|
|
2684
|
+
}
|
|
2685
|
+
]
|
|
2686
|
+
},
|
|
2687
|
+
{
|
|
2688
|
+
"version": "0.20.0",
|
|
2689
|
+
"date": "2026-08-11",
|
|
2690
|
+
"entries": [
|
|
2691
|
+
{
|
|
2692
|
+
"kind": "fixed",
|
|
2693
|
+
"text": "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.",
|
|
2694
|
+
"short": "Dark glossy surfaces no longer show single bright pixels along their creases and gaps at night."
|
|
2695
|
+
},
|
|
2696
|
+
{
|
|
2697
|
+
"kind": "changed",
|
|
2698
|
+
"text": "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.",
|
|
2699
|
+
"short": "A light's physical size now widens its highlight, not only its shadow, so a close lamp reflects as broadly as it really is."
|
|
2700
|
+
}
|
|
2701
|
+
]
|
|
2702
|
+
},
|
|
2703
|
+
{
|
|
2704
|
+
"version": "0.19.0",
|
|
2705
|
+
"date": "2026-08-10",
|
|
2706
|
+
"entries": [
|
|
2707
|
+
{
|
|
2708
|
+
"kind": "changed",
|
|
2709
|
+
"text": "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.",
|
|
2710
|
+
"short": "A model that carries a coarse version now opens on it, and a bake writes one, rather than both waiting to be asked."
|
|
2711
|
+
},
|
|
2712
|
+
{
|
|
2713
|
+
"kind": "added",
|
|
2714
|
+
"text": "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.",
|
|
2715
|
+
"short": "A coarse version that would cost more than the model it stands for is dropped, and the tool says so with the numbers."
|
|
2716
|
+
},
|
|
2717
|
+
{
|
|
2718
|
+
"kind": "changed",
|
|
2719
|
+
"text": "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.",
|
|
2720
|
+
"short": "The default resolution for a coarse version is one exported number, so a bake and a conversion of the same model agree."
|
|
2721
|
+
},
|
|
2722
|
+
{
|
|
2723
|
+
"kind": "added",
|
|
2724
|
+
"text": "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.",
|
|
2725
|
+
"short": "A load can ask for textures to clamp rather than repeat, which is what stops bright pixels tracing the seams of a textured part."
|
|
2726
|
+
},
|
|
2727
|
+
{
|
|
2728
|
+
"kind": "added",
|
|
2729
|
+
"text": "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.",
|
|
2730
|
+
"short": "Reading a zip in a browser is reachable from the public surface rather than rewritten by each consumer."
|
|
2731
|
+
},
|
|
2732
|
+
{
|
|
2733
|
+
"kind": "added",
|
|
2734
|
+
"text": "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.",
|
|
2735
|
+
"short": "The frame meter exposes the rate it displays, so a caller does not keep a second average beside it."
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
"kind": "changed",
|
|
2739
|
+
"text": "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.",
|
|
2740
|
+
"short": "Three settings with a closed set of accepted values now say so in their types, so a wrong one is caught before it runs."
|
|
2741
|
+
},
|
|
2742
|
+
{
|
|
2743
|
+
"kind": "changed",
|
|
2744
|
+
"text": "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.",
|
|
2745
|
+
"short": "The bake tool warns when a model comes out below its own origin, and --lod no longer has to be the last argument."
|
|
2746
|
+
}
|
|
2747
|
+
]
|
|
2748
|
+
},
|
|
2749
|
+
{
|
|
2750
|
+
"version": "0.18.0",
|
|
2751
|
+
"date": "2026-08-10",
|
|
2752
|
+
"entries": [
|
|
2753
|
+
{
|
|
2754
|
+
"kind": "changed",
|
|
2755
|
+
"text": "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.",
|
|
2756
|
+
"short": "A coarse version of a model is now the surface of the space it occupies, which stops the ridges and spikes the old one grew through the bodywork."
|
|
2757
|
+
},
|
|
2758
|
+
{
|
|
2759
|
+
"kind": "added",
|
|
2760
|
+
"text": "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.",
|
|
2761
|
+
"short": "A small version of each image can land on its surfaces before the full one, so a textured surface stops being flat colour sooner."
|
|
2762
|
+
},
|
|
2763
|
+
{
|
|
2764
|
+
"kind": "added",
|
|
2765
|
+
"text": "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.",
|
|
2766
|
+
"short": "Exposure into the tone curve can be set per frame, so a world with a bright outside and a dark inside can be exposed for both."
|
|
2767
|
+
}
|
|
2768
|
+
]
|
|
2769
|
+
},
|
|
2770
|
+
{
|
|
2771
|
+
"version": "0.17.0",
|
|
2772
|
+
"date": "2026-08-10",
|
|
2773
|
+
"entries": [
|
|
2774
|
+
{
|
|
2775
|
+
"kind": "added",
|
|
2776
|
+
"text": "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.",
|
|
2777
|
+
"short": "A file can carry a coarse level of a whole model, so a load opens on a recognisable shape rather than an empty stage. Asked for at both ends."
|
|
2778
|
+
},
|
|
2779
|
+
{
|
|
2780
|
+
"kind": "added",
|
|
2781
|
+
"text": "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.",
|
|
2782
|
+
"short": "A finished load can be put back to any point of itself, so the stages can be examined at leisure rather than caught as they pass."
|
|
2783
|
+
}
|
|
2784
|
+
]
|
|
2785
|
+
},
|
|
2786
|
+
{
|
|
2787
|
+
"version": "0.16.0",
|
|
2788
|
+
"date": "2026-08-10",
|
|
2789
|
+
"entries": [
|
|
2790
|
+
{
|
|
2791
|
+
"kind": "added",
|
|
2792
|
+
"text": "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.",
|
|
2793
|
+
"short": "A model can be read straight from glTF, OBJ, FBX, USD, STL or 3MF in the browser, off the main thread."
|
|
2794
|
+
},
|
|
2795
|
+
{
|
|
2796
|
+
"kind": "added",
|
|
2797
|
+
"text": "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.",
|
|
2798
|
+
"short": "One place decides which reader a file needs, shared by the offline tool and the browser."
|
|
2799
|
+
},
|
|
2800
|
+
{
|
|
2801
|
+
"kind": "added",
|
|
2802
|
+
"text": "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.",
|
|
2803
|
+
"short": "A way to close the inside of a model that is only a surface, so an opening no longer shows through it."
|
|
2804
|
+
},
|
|
2805
|
+
{
|
|
2806
|
+
"kind": "fixed",
|
|
2807
|
+
"text": "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.",
|
|
2808
|
+
"short": "A model that was never uploaded was being reported as a corrupt file rather than a missing one."
|
|
2809
|
+
}
|
|
2810
|
+
]
|
|
2811
|
+
},
|
|
2812
|
+
{
|
|
2813
|
+
"version": "0.15.0",
|
|
2814
|
+
"date": "2026-08-10",
|
|
2815
|
+
"entries": [
|
|
2816
|
+
{
|
|
2817
|
+
"kind": "added",
|
|
2818
|
+
"text": "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.",
|
|
2819
|
+
"short": "A model can be loaded progressively, so it builds up on screen instead of appearing all at once."
|
|
2820
|
+
},
|
|
2821
|
+
{
|
|
2822
|
+
"kind": "added",
|
|
2823
|
+
"text": "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.",
|
|
2824
|
+
"short": "One object does a whole model load: streamed, uploaded within a frame budget, faded in, then merged."
|
|
2825
|
+
},
|
|
2826
|
+
{
|
|
2827
|
+
"kind": "added",
|
|
2828
|
+
"text": "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.",
|
|
2829
|
+
"short": "A way to track everything an app is waiting for as one weighted number, with the wording left to the caller."
|
|
2830
|
+
},
|
|
2831
|
+
{
|
|
2832
|
+
"kind": "fixed",
|
|
2833
|
+
"text": "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.",
|
|
2834
|
+
"short": "The first bytes of a file were being copied one at a time, which paused the start of every load."
|
|
2835
|
+
}
|
|
2836
|
+
]
|
|
2837
|
+
},
|
|
2838
|
+
{
|
|
2839
|
+
"version": "0.14.0",
|
|
2840
|
+
"date": "2026-08-10",
|
|
2841
|
+
"entries": [
|
|
2842
|
+
{
|
|
2843
|
+
"kind": "added",
|
|
2844
|
+
"text": "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.",
|
|
2845
|
+
"short": "Light in the air is drawn as light: a beam only ever adds, and nothing it crosses gets darker."
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
"kind": "added",
|
|
2849
|
+
"text": "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.",
|
|
2850
|
+
"short": "A reflection probe, so a reflective surface can mirror the room it stands in rather than an approximation."
|
|
2851
|
+
},
|
|
2852
|
+
{
|
|
2853
|
+
"kind": "changed",
|
|
2854
|
+
"text": "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.",
|
|
2855
|
+
"short": "Camera motion blur is a per-frame dial as well as a setting, so a game can bring it in only when fast."
|
|
2856
|
+
},
|
|
2857
|
+
{
|
|
2858
|
+
"kind": "fixed",
|
|
2859
|
+
"text": "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.",
|
|
2860
|
+
"short": "Point-light shadows were being compared at eight bits of depth precision rather than twenty-four."
|
|
2861
|
+
},
|
|
2862
|
+
{
|
|
2863
|
+
"kind": "changed",
|
|
2864
|
+
"text": "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.",
|
|
2865
|
+
"short": "The engine's own tools are typechecked, and every build bakes a real model and reads it back."
|
|
2866
|
+
}
|
|
2867
|
+
]
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
"version": "0.13.0",
|
|
2871
|
+
"date": "2026-08-10",
|
|
2872
|
+
"entries": [
|
|
2873
|
+
{
|
|
2874
|
+
"kind": "added",
|
|
2875
|
+
"text": "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.",
|
|
2876
|
+
"short": "Ambient occlusion, off by default: contact shading where surfaces meet, measured from the frame's own depth."
|
|
2877
|
+
},
|
|
2878
|
+
{
|
|
2879
|
+
"kind": "fixed",
|
|
2880
|
+
"text": "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.",
|
|
2881
|
+
"short": "A mesh drawn after another pass could be drawn by that pass's shader, and the error it raised made other checks misfire."
|
|
2882
|
+
},
|
|
2883
|
+
{
|
|
2884
|
+
"kind": "fixed",
|
|
2885
|
+
"text": "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.",
|
|
2886
|
+
"short": "Depth in a screen-space pass was arriving with eight bits rather than twenty-four, which had quietly degraded camera motion blur."
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
"kind": "changed",
|
|
2890
|
+
"text": "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.",
|
|
2891
|
+
"short": "The uniform reporter now also names a write aimed at a program that is not the one currently bound."
|
|
2892
|
+
}
|
|
2893
|
+
]
|
|
2894
|
+
},
|
|
2895
|
+
{
|
|
2896
|
+
"version": "0.12.0",
|
|
2897
|
+
"date": "2026-08-10",
|
|
2898
|
+
"entries": [
|
|
2899
|
+
{
|
|
2900
|
+
"kind": "added",
|
|
2901
|
+
"text": "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.",
|
|
2902
|
+
"short": "The engine imports models, from seven formats, each labelled with how far it is trusted."
|
|
2903
|
+
},
|
|
2904
|
+
{
|
|
2905
|
+
"kind": "added",
|
|
2906
|
+
"text": "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.",
|
|
2907
|
+
"short": "A baked model container that loads without parsing, and opens in every future reader."
|
|
2908
|
+
},
|
|
2909
|
+
{
|
|
2910
|
+
"kind": "added",
|
|
2911
|
+
"text": "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.",
|
|
2912
|
+
"short": "Textures are embedded in the model file and addressed by name rather than by index."
|
|
2913
|
+
},
|
|
2914
|
+
{
|
|
2915
|
+
"kind": "added",
|
|
2916
|
+
"text": "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.",
|
|
2917
|
+
"short": "Every reader carries the same materials, and two formats of one asset bake to the same result."
|
|
2918
|
+
},
|
|
2919
|
+
{
|
|
2920
|
+
"kind": "added",
|
|
2921
|
+
"text": "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.",
|
|
2922
|
+
"short": "Grain is a property a surface states, instead of being guessed from its roughness."
|
|
2923
|
+
},
|
|
2924
|
+
{
|
|
2925
|
+
"kind": "changed",
|
|
2926
|
+
"text": "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.",
|
|
2927
|
+
"short": "Grain reads as a surface being uneven rather than as marble veining."
|
|
2928
|
+
},
|
|
2929
|
+
{
|
|
2930
|
+
"kind": "added",
|
|
2931
|
+
"text": "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.",
|
|
2932
|
+
"short": "Anti-aliasing is available again with screen effects on, as an opt-in quality option."
|
|
2933
|
+
},
|
|
2934
|
+
{
|
|
2935
|
+
"kind": "fixed",
|
|
2936
|
+
"text": "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.",
|
|
2937
|
+
"short": "One bird in every flock had the innermost orbit, lowest height and slowest beat, and now matches the rest."
|
|
2938
|
+
},
|
|
2939
|
+
{
|
|
2940
|
+
"kind": "fixed",
|
|
2941
|
+
"text": "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.",
|
|
2942
|
+
"short": "A flock's scale is documented as what it is, which is half the wingspan."
|
|
2943
|
+
}
|
|
2944
|
+
]
|
|
2945
|
+
},
|
|
2946
|
+
{
|
|
2947
|
+
"version": "0.11.2",
|
|
2948
|
+
"date": "2026-08-09",
|
|
2949
|
+
"entries": [
|
|
2950
|
+
{
|
|
2951
|
+
"kind": "fixed",
|
|
2952
|
+
"text": "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 \u2014 the standard lets a driver read past the end as zeroes or refuse the draw outright, and both are correct \u2014 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.",
|
|
2953
|
+
"short": "Meshes with incomplete attributes are caught when built, instead of vanishing on some GPUs."
|
|
2954
|
+
},
|
|
2955
|
+
{
|
|
2956
|
+
"kind": "added",
|
|
2957
|
+
"text": "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.",
|
|
2958
|
+
"short": "Strict mode reports uniform writes that silently go nowhere."
|
|
2959
|
+
},
|
|
2960
|
+
{
|
|
2961
|
+
"kind": "fixed",
|
|
2962
|
+
"text": "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.",
|
|
2963
|
+
"short": "Uniform arrays resolve whichever way the driver names them."
|
|
2964
|
+
},
|
|
2965
|
+
{
|
|
2966
|
+
"kind": "fixed",
|
|
2967
|
+
"text": "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 \u2014 which is all of Apple's.",
|
|
2968
|
+
"short": "Point-light shadows stay on where the texture units genuinely fit."
|
|
2969
|
+
},
|
|
2970
|
+
{
|
|
2971
|
+
"kind": "fixed",
|
|
2972
|
+
"text": "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.",
|
|
2973
|
+
"short": "A mesh's constant attributes belong to the mesh, not to whatever was built last."
|
|
2974
|
+
}
|
|
2975
|
+
]
|
|
2976
|
+
},
|
|
2977
|
+
{
|
|
2978
|
+
"version": "0.11.1",
|
|
2979
|
+
"date": "2026-08-09",
|
|
2980
|
+
"entries": [
|
|
2981
|
+
{
|
|
2982
|
+
"kind": "added",
|
|
2983
|
+
"text": "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.",
|
|
2984
|
+
"short": "The engine now carries an MIT licence and a contributor guide."
|
|
2985
|
+
},
|
|
2986
|
+
{
|
|
2987
|
+
"kind": "changed",
|
|
2988
|
+
"text": "Comments and documentation throughout the engine have been rewritten to stand on their own. They were dense with the history of one game \u2014 its proper nouns, the names of the people who filed each report, and the words they used at the time \u2014 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.",
|
|
2989
|
+
"short": "Comments and documentation are now written for any reader, not just one project."
|
|
2990
|
+
}
|
|
2991
|
+
]
|
|
2992
|
+
},
|
|
2993
|
+
{
|
|
2994
|
+
"version": "0.11.0",
|
|
2995
|
+
"date": "2026-08-09",
|
|
2996
|
+
"entries": [
|
|
2997
|
+
{
|
|
2998
|
+
"kind": "added",
|
|
2999
|
+
"text": "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 \u2014 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.",
|
|
3000
|
+
"short": "Surfaces can take their colour from a picture, cut their own silhouette from it, and swap it later."
|
|
3001
|
+
},
|
|
3002
|
+
{
|
|
3003
|
+
"kind": "added",
|
|
3004
|
+
"text": "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.",
|
|
3005
|
+
"short": "Colours leave the renderer the way a screen expects them, with a choice of curve and an exposure."
|
|
3006
|
+
},
|
|
3007
|
+
{
|
|
3008
|
+
"kind": "added",
|
|
3009
|
+
"text": "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.",
|
|
3010
|
+
"short": "A lamp can fade with distance the way physics says, or the gentler way a large soft source does."
|
|
3011
|
+
},
|
|
3012
|
+
{
|
|
3013
|
+
"kind": "added",
|
|
3014
|
+
"text": "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 \u2014 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.",
|
|
3015
|
+
"short": "A surface can glow in a colour it is not made of, and a single draw can pulse its glow."
|
|
3016
|
+
},
|
|
3017
|
+
{
|
|
3018
|
+
"kind": "added",
|
|
3019
|
+
"text": "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 \u2014 the difference between a lamp landing as a dot and smearing down the length of a corridor.",
|
|
3020
|
+
"short": "Polished floors catch the lamps above them, and how rough a surface is decides how wide the highlight is."
|
|
3021
|
+
},
|
|
3022
|
+
{
|
|
3023
|
+
"kind": "added",
|
|
3024
|
+
"text": "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 \u2014 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.",
|
|
3025
|
+
"short": "Water is lit by the lamps around it, and can be as disturbed as it likes with no wind to explain it."
|
|
3026
|
+
},
|
|
3027
|
+
{
|
|
3028
|
+
"kind": "added",
|
|
3029
|
+
"text": "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.",
|
|
3030
|
+
"short": "Light thrown by water lands on a pool floor, not only on the underside of what covers it."
|
|
3031
|
+
},
|
|
3032
|
+
{
|
|
3033
|
+
"kind": "added",
|
|
3034
|
+
"text": "Creatures can be built out of capsules and spheres rather than boxes. Everything was a box, so anything alive read as furniture \u2014 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.",
|
|
3035
|
+
"short": "Creatures can be built from capsules and spheres, and a flat panel no longer has to be a whole box."
|
|
3036
|
+
},
|
|
3037
|
+
{
|
|
3038
|
+
"kind": "added",
|
|
3039
|
+
"text": "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.",
|
|
3040
|
+
"short": "Anything flat can face the viewer \u2014 upright for a standing figure, fully for a spark."
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
"kind": "added",
|
|
3044
|
+
"text": "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.",
|
|
3045
|
+
"short": "A pattern drawn on a surface knows which way is up, on every wall."
|
|
3046
|
+
},
|
|
3047
|
+
{
|
|
3048
|
+
"kind": "fixed",
|
|
3049
|
+
"text": "A room lit only by its own lamps is no longer nearly black. Half of the soft fill light \u2014 the half that reaches anything facing downward \u2014 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.",
|
|
3050
|
+
"short": "A room lit only by its own lamps is no longer nearly black, from two settings that were never applied."
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
"kind": "fixed",
|
|
3054
|
+
"text": "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 \u2014 a first run that renders a black screen and looks like a much deeper problem than it is.",
|
|
3055
|
+
"short": "A world that never tints anything is no longer rendered black."
|
|
3056
|
+
},
|
|
3057
|
+
{
|
|
3058
|
+
"kind": "fixed",
|
|
3059
|
+
"text": "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.",
|
|
3060
|
+
"short": "Something drawn see-through keeps the shape of its own image, instead of hazing its whole rectangle."
|
|
3061
|
+
},
|
|
3062
|
+
{
|
|
3063
|
+
"kind": "fixed",
|
|
3064
|
+
"text": "A renderer whose drawing surface has been taken away now stops instead of failing. A browser can reclaim it at any time \u2014 most often when a machine has too many pages open \u2014 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.",
|
|
3065
|
+
"short": "A renderer whose drawing surface was taken away stops cleanly, instead of raising an error per call."
|
|
3066
|
+
},
|
|
3067
|
+
{
|
|
3068
|
+
"kind": "changed",
|
|
3069
|
+
"text": "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.",
|
|
3070
|
+
"short": "A game with shadows turned off no longer compiles the machinery for them."
|
|
3071
|
+
},
|
|
3072
|
+
{
|
|
3073
|
+
"kind": "fixed",
|
|
3074
|
+
"text": "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 \u2014 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.",
|
|
3075
|
+
"short": "A highlight's width and its strength stopped interfering with each other."
|
|
3076
|
+
}
|
|
3077
|
+
]
|
|
3078
|
+
},
|
|
3079
|
+
{
|
|
3080
|
+
"version": "0.10.1",
|
|
3081
|
+
"date": "2026-08-08",
|
|
3082
|
+
"entries": [
|
|
3083
|
+
{
|
|
3084
|
+
"kind": "fixed",
|
|
3085
|
+
"text": "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.",
|
|
3086
|
+
"short": "A mouse capture the browser refuses is ignored, rather than surfacing as an unexpected error."
|
|
3087
|
+
},
|
|
3088
|
+
{
|
|
3089
|
+
"kind": "added",
|
|
3090
|
+
"text": "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.",
|
|
3091
|
+
"short": "A frame is told its real duration as well as the capped one, so a measurement is not reading the cap."
|
|
3092
|
+
}
|
|
3093
|
+
]
|
|
3094
|
+
},
|
|
3095
|
+
{
|
|
3096
|
+
"version": "0.10.0",
|
|
3097
|
+
"date": "2026-08-08",
|
|
3098
|
+
"entries": [
|
|
3099
|
+
{
|
|
3100
|
+
"kind": "added",
|
|
3101
|
+
"text": "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.",
|
|
3102
|
+
"short": "A rectangle of the frame can be filled with one flat blended colour, for an overlay to be read against."
|
|
3103
|
+
},
|
|
3104
|
+
{
|
|
3105
|
+
"kind": "changed",
|
|
3106
|
+
"text": "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.",
|
|
3107
|
+
"short": "An inset can keep the picture behind it, so an object drawn into it sits over the scene."
|
|
3108
|
+
}
|
|
3109
|
+
]
|
|
3110
|
+
},
|
|
3111
|
+
{
|
|
3112
|
+
"version": "0.9.0",
|
|
3113
|
+
"date": "2026-08-08",
|
|
3114
|
+
"entries": [
|
|
3115
|
+
{
|
|
3116
|
+
"kind": "added",
|
|
3117
|
+
"text": "Surfaces can carry a specular highlight. Until now everything in a world was lit the same way \u2014 a flat colour with the sun's angle on it and nothing else \u2014 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.",
|
|
3118
|
+
"short": "Surfaces can ask for a specular highlight, so a cut face flashes as it turns while plain ones do not."
|
|
3119
|
+
},
|
|
3120
|
+
{
|
|
3121
|
+
"kind": "added",
|
|
3122
|
+
"text": "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.",
|
|
3123
|
+
"short": "Procedural grain on those surfaces, computed from world position rather than from an image."
|
|
3124
|
+
},
|
|
3125
|
+
{
|
|
3126
|
+
"kind": "added",
|
|
3127
|
+
"text": "A shader for arcane auras, and a colour for effects to take. The volumetric effect system could draw fire and smoke \u2014 things that rise \u2014 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.",
|
|
3128
|
+
"short": "A shader for arcane auras that surround an object rather than rising off it, and a tint for effects."
|
|
3129
|
+
},
|
|
3130
|
+
{
|
|
3131
|
+
"kind": "fixed",
|
|
3132
|
+
"text": "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.",
|
|
3133
|
+
"short": "A raised kerb along a path can be stood on, instead of feet sinking into trim they are standing on."
|
|
3134
|
+
}
|
|
3135
|
+
]
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
"version": "0.8.0",
|
|
3139
|
+
"date": "2026-08-07",
|
|
3140
|
+
"entries": [
|
|
3141
|
+
{
|
|
3142
|
+
"kind": "added",
|
|
3143
|
+
"text": "`Renderer.onContextRestored` tells a consumer that a lost drawing context has come back. `preventDefault` on 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 sharing `onContextLost`, 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 on `Renderer` is created in the constructor and `readonly` after, which is the same reason a quality change is construction-time, so drawing again needs a reload and the consumer owns that decision",
|
|
3144
|
+
"short": "`Renderer.onContextRestored` tells a consumer that a lost drawing context has come back."
|
|
3145
|
+
},
|
|
3146
|
+
{
|
|
3147
|
+
"kind": "added",
|
|
3148
|
+
"text": "`ResolutionGovernor` holds 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, and `null` comes 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 \u2014 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",
|
|
3149
|
+
"short": "`ResolutionGovernor` holds a frame budget by moving the drawing-buffer scale. Deliberately hard to trigger."
|
|
3150
|
+
},
|
|
3151
|
+
{
|
|
3152
|
+
"kind": "added",
|
|
3153
|
+
"text": "`Renderer.applyResolutionScale` moves 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.rendererName` and `Renderer.capabilityClamped` are exposed alongside it, so a bug report says which part this is and whether it was clamped rather than leaving either to be inferred",
|
|
3154
|
+
"short": "`Renderer.applyResolutionScale` moves the density cap at runtime without rebuilding anything."
|
|
3155
|
+
},
|
|
3156
|
+
{
|
|
3157
|
+
"kind": "changed",
|
|
3158
|
+
"text": "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 \u2014 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: false` turns it off entirely, and a consumer passes that to mean \"this person chose these numbers themselves\" \u2014 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",
|
|
3159
|
+
"short": "A GPU family known to struggle opens at pixel terms it can hold. A hint, never authority."
|
|
3160
|
+
}
|
|
3161
|
+
],
|
|
3162
|
+
"reports": [
|
|
3163
|
+
"NFD6QQ"
|
|
3164
|
+
]
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
"version": "0.7.1",
|
|
3168
|
+
"date": "2026-08-07",
|
|
3169
|
+
"entries": [
|
|
3170
|
+
{
|
|
3171
|
+
"kind": "fixed",
|
|
3172
|
+
"text": "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-way `if (i == uPointShadowIndex[k])` chain, which is the shape GLSL ES 3.00 forces without a dynamically-indexable sampler array, and `texture()`'s implicit derivatives are only well-defined under uniform control flow \u2014 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=0` moved 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 now `textureLod(..., 0.0)`, which is output-identical by construction rather than by eye, because those cubemaps carry one storage level and `NEAREST` on 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",
|
|
3173
|
+
"short": "A `texture()` call in a branch the compiler could not prove uniform was being flattened, costing 13 ms a frame."
|
|
3174
|
+
}
|
|
3175
|
+
],
|
|
3176
|
+
"reports": [
|
|
3177
|
+
"R77XPL",
|
|
3178
|
+
"DZTF9F-b"
|
|
3179
|
+
]
|
|
3180
|
+
},
|
|
3181
|
+
{
|
|
3182
|
+
"version": "0.7.0",
|
|
3183
|
+
"date": "2026-08-07",
|
|
3184
|
+
"entries": [
|
|
3185
|
+
{
|
|
3186
|
+
"kind": "added",
|
|
3187
|
+
"text": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path \u2014 the primitive a curve needs, where `addCylinder` is 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.",
|
|
3188
|
+
"short": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path \u2014 the primitive a curve needs."
|
|
3189
|
+
},
|
|
3190
|
+
{
|
|
3191
|
+
"kind": "added",
|
|
3192
|
+
"text": "`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.",
|
|
3193
|
+
"short": "`metalBuffer`: a struck metal ring built from inharmonic partials over a short noise transient."
|
|
3194
|
+
},
|
|
3195
|
+
{
|
|
3196
|
+
"kind": "added",
|
|
3197
|
+
"text": "`AudioGraph.slamLowEnd` boosts 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 \u2014 the lift is a high-pass, so a slam taken after it would be weakest exactly where it is most wanted.",
|
|
3198
|
+
"short": "`AudioGraph.slamLowEnd` boosts the bass and slams a low-pass shut for a fraction of a second, then lets both back."
|
|
3199
|
+
},
|
|
3200
|
+
{
|
|
3201
|
+
"kind": "fixed",
|
|
3202
|
+
"text": "Filtered noise is actually filtered. Every noise source in the engine ran through a single pole, which is 6 dB per octave \u2014 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.",
|
|
3203
|
+
"short": "Filtered noise is actually filtered. Every noise source ran through a single pole, which is 6 dB per octave."
|
|
3204
|
+
},
|
|
3205
|
+
{
|
|
3206
|
+
"kind": "changed",
|
|
3207
|
+
"text": "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."
|
|
3208
|
+
}
|
|
3209
|
+
]
|
|
3210
|
+
},
|
|
3211
|
+
{
|
|
3212
|
+
"version": "0.6.0",
|
|
3213
|
+
"date": "2026-08-06",
|
|
3214
|
+
"entries": [
|
|
3215
|
+
{
|
|
3216
|
+
"kind": "added",
|
|
3217
|
+
"text": "One water, configurable, so a sea and a basin are the same component rather than a component and a trick. `WaterSettings` becomes `WaterBody`, and every body gets the waves, the Fresnel, the specular, the fog and the planar reflection; what differs between them is numbers. `density` is 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. `visibility` scales the whole surface away to nothing, `waveScale` says how built-up its sea is against the open one, and `bounds` makes 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.",
|
|
3218
|
+
"short": "One water, configurable, so a sea and a basin are the same component rather than a component and a trick."
|
|
3219
|
+
},
|
|
3220
|
+
{
|
|
3221
|
+
"kind": "added",
|
|
3222
|
+
"text": "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.mirror` blends Fresnel toward one \u2014 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.",
|
|
3223
|
+
"short": "A water body may declare itself a mirror, blending Fresnel toward one \u2014 a lie about optics, honest about what a pool is for."
|
|
3224
|
+
},
|
|
3225
|
+
{
|
|
3226
|
+
"kind": "added",
|
|
3227
|
+
"text": "Text as world geometry. `buildTextMesh` puts 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.addOrientedMesh` merges 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 \u2014 which renders with no GL error and no pixels.",
|
|
3228
|
+
"short": "Text as world geometry: `buildTextMesh` puts a string in the world as boxes, with its own cell height for shallow angles."
|
|
3229
|
+
},
|
|
3230
|
+
{
|
|
3231
|
+
"kind": "added",
|
|
3232
|
+
"text": "`drawTranslucentMesh`, a mesh you can see through, drawn in the flat material with a `uOpacity` uniform. 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. `bindMeshPass` sets 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.",
|
|
3233
|
+
"short": "`drawTranslucentMesh`: a mesh you can see through, drawn in the flat material with a `uOpacity` uniform. It writes depth."
|
|
3234
|
+
},
|
|
3235
|
+
{
|
|
3236
|
+
"kind": "changed",
|
|
3237
|
+
"text": "The canvas stops claiming to be see-through. The WebGL context asked for an alpha channel it has never used: `beginFrame` clears with an alpha of 1 and source-alpha blending leaves the destination at 1, so every pixel handed to the browser was already opaque \u2014 but the default is `alpha: 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.",
|
|
3238
|
+
"short": "The canvas stops claiming to be see-through. It asked for an alpha channel it has never used."
|
|
3239
|
+
},
|
|
3240
|
+
{
|
|
3241
|
+
"kind": "added",
|
|
3242
|
+
"text": "`maxDrawingBufferPixels`, a cap on a frame's area to go beside the cap on its density. `maxDevicePixelRatio` answers \"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 pure `drawingBuffer.ts` so 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.",
|
|
3243
|
+
"short": "`maxDrawingBufferPixels`, a cap on a frame's area to go beside the cap on its density."
|
|
3244
|
+
},
|
|
3245
|
+
{
|
|
3246
|
+
"kind": "fixed",
|
|
3247
|
+
"text": "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` \u2014 every tap past the 90 ms resolve was discarded, which on a phone is most of them. `isTapRelease` decides 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.",
|
|
3248
|
+
"short": "The tap that the hold had already resolved is no longer eaten."
|
|
3249
|
+
},
|
|
3250
|
+
{
|
|
3251
|
+
"kind": "changed",
|
|
3252
|
+
"text": "A `Spline` measures 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 \u2014 and `sampleAt` evaluates 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, `buildRibbon` is 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.",
|
|
3253
|
+
"short": "A `Spline` measures its centripetal knots once per segment instead of once per evaluation."
|
|
3254
|
+
}
|
|
3255
|
+
]
|
|
3256
|
+
},
|
|
3257
|
+
{
|
|
3258
|
+
"version": "0.5.1",
|
|
3259
|
+
"date": "2026-08-06",
|
|
3260
|
+
"entries": [
|
|
3261
|
+
{
|
|
3262
|
+
"kind": "fixed",
|
|
3263
|
+
"text": "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 \u2014 it used to render six passes over the static world for every lamp in the day, most of them never looked at.",
|
|
3264
|
+
"short": "Point-light shadow maps are borrowed from a pool of twelve instead of one per world light, kept forever."
|
|
3265
|
+
},
|
|
3266
|
+
{
|
|
3267
|
+
"kind": "fixed",
|
|
3268
|
+
"text": "A light may drift `pointShadowRebakeDistance` from 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.",
|
|
3269
|
+
"short": "A light may drift a little from where its shadow was baked before the map is stale; an exact compare re-baked every frame."
|
|
3270
|
+
},
|
|
3271
|
+
{
|
|
3272
|
+
"kind": "fixed",
|
|
3273
|
+
"text": "Multisampling is requested only when something can use it. With `screenEffects` on, 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 \u2014 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.",
|
|
3274
|
+
"short": "Multisampling is requested only when something can use it \u2014 with screen effects on, nothing can."
|
|
3275
|
+
}
|
|
3276
|
+
]
|
|
3277
|
+
},
|
|
3278
|
+
{
|
|
3279
|
+
"version": "0.5.0",
|
|
3280
|
+
"date": "2026-08-04",
|
|
3281
|
+
"entries": [
|
|
3282
|
+
{
|
|
3283
|
+
"kind": "added",
|
|
3284
|
+
"text": "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 \u2014 a rig asking for a 7 m arm held 9.2 m \u2014 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.",
|
|
3285
|
+
"short": "A critically damped spring that rides a moving target, so a follower does not have to fall behind before it can travel."
|
|
3286
|
+
},
|
|
3287
|
+
{
|
|
3288
|
+
"kind": "added",
|
|
3289
|
+
"text": "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.",
|
|
3290
|
+
"short": "A shared camera arm: one boom with a speed limit, quick to shorten and slow to extend."
|
|
3291
|
+
},
|
|
3292
|
+
{
|
|
3293
|
+
"kind": "added",
|
|
3294
|
+
"text": "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.",
|
|
3295
|
+
"short": "Instanced geometry can cast shadows. One caster sink takes a rigid mesh or an instanced batch."
|
|
3296
|
+
},
|
|
3297
|
+
{
|
|
3298
|
+
"kind": "added",
|
|
3299
|
+
"text": "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.",
|
|
3300
|
+
"short": "Fog with a real extinction law and a medium that has a height, in place of a fixed-function curve."
|
|
3301
|
+
},
|
|
3302
|
+
{
|
|
3303
|
+
"kind": "added",
|
|
3304
|
+
"text": "A mix can be rendered rather than heard. `AudioGraph` takes 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. `AmbientLoop` takes the same clock \u2014 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.",
|
|
3305
|
+
"short": "A mix can be rendered rather than heard: `AudioGraph` takes the context it runs on and a clock saying where work lands."
|
|
3306
|
+
},
|
|
3307
|
+
{
|
|
3308
|
+
"kind": "added",
|
|
3309
|
+
"text": "`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.",
|
|
3310
|
+
"short": "`MixLevels`: one authority for the music and effects levels, given at construction rather than scheduled."
|
|
3311
|
+
},
|
|
3312
|
+
{
|
|
3313
|
+
"kind": "added",
|
|
3314
|
+
"text": "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 \u2014 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.",
|
|
3315
|
+
"short": "Clip encoding on a timeline the caller chooses. Frames go in with their own index and come out stamped where they belong."
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
"kind": "added",
|
|
3319
|
+
"text": "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.",
|
|
3320
|
+
"short": "A wet film material: a blended pass whose per-vertex coverage fades to nothing at the rim."
|
|
3321
|
+
},
|
|
3322
|
+
{
|
|
3323
|
+
"kind": "added",
|
|
3324
|
+
"text": "A trample field \u2014 eight recent presses that decay, applied in the vertex shader from a single uniform upload a frame \u2014 so instanced foliage can be pressed down and recover without re-uploading its instance buffer.",
|
|
3325
|
+
"short": "A trample field \u2014 eight recent presses that decay, applied in the vertex shader from one uniform upload a frame."
|
|
3326
|
+
},
|
|
3327
|
+
{
|
|
3328
|
+
"kind": "added",
|
|
3329
|
+
"text": "A pool for short-lived particles, for the effects a batch uploaded once cannot express: anything that sprays, bursts or dissipates."
|
|
3330
|
+
},
|
|
3331
|
+
{
|
|
3332
|
+
"kind": "added",
|
|
3333
|
+
"text": "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 \u2014 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.",
|
|
3334
|
+
"short": "Box surfaces, composable with the ribbon surface, so a route can be made of pads as well as swept ribbon."
|
|
3335
|
+
},
|
|
3336
|
+
{
|
|
3337
|
+
"kind": "added",
|
|
3338
|
+
"text": "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`, a `beginInset` that takes a `DOMRect` and does the device-pixel and axis-flip arithmetic itself, and `copyRegionTo` for putting the result in front of a page's backdrop filter rather than behind it.",
|
|
3339
|
+
"short": "Four things a small self-contained lit scene drawn into an element's rectangle needed."
|
|
3340
|
+
},
|
|
3341
|
+
{
|
|
3342
|
+
"kind": "changed",
|
|
3343
|
+
"text": "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 \u2014 crisp for two metres, readable for four, gone by eight, which is what a metre-wide flame does.",
|
|
3344
|
+
"short": "Point shadows behave like light: filter taps sit on a disk perpendicular to the ray, rotated per fragment."
|
|
3345
|
+
},
|
|
3346
|
+
{
|
|
3347
|
+
"kind": "changed",
|
|
3348
|
+
"text": "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.",
|
|
3349
|
+
"short": "The device-pixel-ratio ceiling is 2. At 1.75 nothing rendered at native resolution, because every retina panel reports 2."
|
|
3350
|
+
},
|
|
3351
|
+
{
|
|
3352
|
+
"kind": "fixed",
|
|
3353
|
+
"text": "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 \u2014 absent.",
|
|
3354
|
+
"short": "Exactly one node reaches the audio destination. Send returns were connected straight to it as well."
|
|
3355
|
+
},
|
|
3356
|
+
{
|
|
3357
|
+
"kind": "fixed",
|
|
3358
|
+
"text": "A suspended audio context is not a failure. The graph awaited a resume and treated the rejection \u2014 which is exactly what a browser that has not seen a tap gives you \u2014 as proof the device could not play sound, returning nothing and staying silent for the rest of the session.",
|
|
3359
|
+
"short": "A suspended audio context is not a failure. The graph treated a browser's refusal to start sound as one."
|
|
3360
|
+
},
|
|
3361
|
+
{
|
|
3362
|
+
"kind": "fixed",
|
|
3363
|
+
"text": "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 \u2212256 the other. Ranges are normalised where they are consumed, because a caller describing space has said nothing about winding.",
|
|
3364
|
+
"short": "A band built from a descending lateral range came out inside-out, and an inside-out slab is invisible under back-face culling."
|
|
3365
|
+
},
|
|
3366
|
+
{
|
|
3367
|
+
"kind": "fixed",
|
|
3368
|
+
"text": "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.",
|
|
3369
|
+
"short": "Array uniforms resolve under their bare name as well as the `name[0]` WebGL reports."
|
|
3370
|
+
},
|
|
3371
|
+
{
|
|
3372
|
+
"kind": "fixed",
|
|
3373
|
+
"text": "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."
|
|
3374
|
+
},
|
|
3375
|
+
{
|
|
3376
|
+
"kind": "fixed",
|
|
3377
|
+
"text": "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.",
|
|
3378
|
+
"short": "Moving instances have to be uploaded. Scatter batches are placed once and deliberately not re-uploaded per frame."
|
|
3379
|
+
}
|
|
3380
|
+
]
|
|
3381
|
+
},
|
|
3382
|
+
{
|
|
3383
|
+
"version": "0.4.0",
|
|
3384
|
+
"date": "2026-08-02",
|
|
3385
|
+
"entries": [
|
|
3386
|
+
{
|
|
3387
|
+
"kind": "added",
|
|
3388
|
+
"text": "An offline beat map with bassline whitening, so a track's kicks are known before a frame of it plays."
|
|
3389
|
+
},
|
|
3390
|
+
{
|
|
3391
|
+
"kind": "added",
|
|
3392
|
+
"text": "A camera that can be cut: framed shots held and switched between, rather than interpolated through the world."
|
|
3393
|
+
},
|
|
3394
|
+
{
|
|
3395
|
+
"kind": "added",
|
|
3396
|
+
"text": "Frame capture to a video file where the browser supports it, and nothing at all where it does not."
|
|
3397
|
+
},
|
|
3398
|
+
{
|
|
3399
|
+
"kind": "added",
|
|
3400
|
+
"text": "Text as real geometry \u2014 a pixel font extruded into the scene, lit, fogged and occluded like everything else in it."
|
|
3401
|
+
},
|
|
3402
|
+
{
|
|
3403
|
+
"kind": "changed",
|
|
3404
|
+
"text": "The audio registry reads a manifest instead of fixed names, so a library of any size resolves without a code change."
|
|
3405
|
+
}
|
|
3406
|
+
]
|
|
3407
|
+
},
|
|
3408
|
+
{
|
|
3409
|
+
"version": "0.3.0",
|
|
3410
|
+
"date": "2026-08-02",
|
|
3411
|
+
"entries": [
|
|
3412
|
+
{
|
|
3413
|
+
"kind": "added",
|
|
3414
|
+
"text": "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; `PreferenceStore` validates field by field and `OnceSet` holds one-time flags.",
|
|
3415
|
+
"short": "Persistence is a seam: the caller supplies a `KeyValueStore`, so a save can live in a browser, a server, a shell or nowhere."
|
|
3416
|
+
},
|
|
3417
|
+
{
|
|
3418
|
+
"kind": "added",
|
|
3419
|
+
"text": "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.",
|
|
3420
|
+
"short": "The audio graph: music and effects stages, a speed-driven master low-pass, reverb and delay sends, positional gain and pan."
|
|
3421
|
+
},
|
|
3422
|
+
{
|
|
3423
|
+
"kind": "added",
|
|
3424
|
+
"text": "`MessageQueue` \u2014 one message at a time, most important first, deduped, bounded and interruptible."
|
|
3425
|
+
},
|
|
3426
|
+
{
|
|
3427
|
+
"kind": "added",
|
|
3428
|
+
"text": "A pausable fixed-timestep loop that drains banked simulation time rather than replaying it on resume."
|
|
3429
|
+
},
|
|
3430
|
+
{
|
|
3431
|
+
"kind": "changed",
|
|
3432
|
+
"text": "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.",
|
|
3433
|
+
"short": "Generic mobile controls, multi-subscriber device capture, a collision-safe third-person camera, and point-light selection."
|
|
3434
|
+
},
|
|
3435
|
+
{
|
|
3436
|
+
"kind": "fixed",
|
|
3437
|
+
"text": "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.",
|
|
3438
|
+
"short": "The message queue reordered its own backlog every frame, because peeking took the best candidate and pushed it back when it lost."
|
|
3439
|
+
},
|
|
3440
|
+
{
|
|
3441
|
+
"kind": "fixed",
|
|
3442
|
+
"text": "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."
|
|
3443
|
+
}
|
|
3444
|
+
]
|
|
3445
|
+
},
|
|
3446
|
+
{
|
|
3447
|
+
"version": "0.2.0",
|
|
3448
|
+
"date": "2026-08-02",
|
|
3449
|
+
"entries": [
|
|
3450
|
+
{
|
|
3451
|
+
"kind": "added",
|
|
3452
|
+
"text": "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."
|
|
3453
|
+
},
|
|
3454
|
+
{
|
|
3455
|
+
"kind": "added",
|
|
3456
|
+
"text": "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.",
|
|
3457
|
+
"short": "Water as a wave simulation: a Gerstner surface reflecting the whole scene, with Fresnel, foam masking and angle-dependent transparency."
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
"kind": "added",
|
|
3461
|
+
"text": "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."
|
|
3462
|
+
},
|
|
3463
|
+
{
|
|
3464
|
+
"kind": "added",
|
|
3465
|
+
"text": "Instanced meshes and a flock renderer, both driven by the shared wind, and one plume renderer that fire and smoke are two instances of."
|
|
3466
|
+
},
|
|
3467
|
+
{
|
|
3468
|
+
"kind": "added",
|
|
3469
|
+
"text": "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."
|
|
3470
|
+
},
|
|
3471
|
+
{
|
|
3472
|
+
"kind": "changed",
|
|
3473
|
+
"text": "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.",
|
|
3474
|
+
"short": "Shadows: one map per light baked once, static maps that persist at any distance, and a live crossfading map for movers."
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
"kind": "changed",
|
|
3478
|
+
"text": "`RenderQualityOptions` is the single construction-time entrypoint for pixel ratio, shadow allocation, water and reflection resolution, filtering, the underwater atmosphere and plume detail."
|
|
3479
|
+
},
|
|
3480
|
+
{
|
|
3481
|
+
"kind": "changed",
|
|
3482
|
+
"text": "Collision broadphase is a spatial hash, flat in cost from forty colliders to eight thousand."
|
|
3483
|
+
}
|
|
3484
|
+
]
|
|
3485
|
+
},
|
|
3486
|
+
{
|
|
3487
|
+
"version": "0.1.0",
|
|
3488
|
+
"date": "2026-08-02",
|
|
3489
|
+
"entries": [
|
|
3490
|
+
{
|
|
3491
|
+
"kind": "added",
|
|
3492
|
+
"text": "A WebGL2 flat-shaded renderer with merged static geometry, a procedural sky and a directional shadow map, and no texture files anywhere in it."
|
|
3493
|
+
},
|
|
3494
|
+
{
|
|
3495
|
+
"kind": "added",
|
|
3496
|
+
"text": "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."
|
|
3497
|
+
},
|
|
3498
|
+
{
|
|
3499
|
+
"kind": "added",
|
|
3500
|
+
"text": "Collision primitives: AABB sweep, axis-separated movement, segment hits and a collider set."
|
|
3501
|
+
},
|
|
3502
|
+
{
|
|
3503
|
+
"kind": "added",
|
|
3504
|
+
"text": "A fixed-timestep loop whose rendering interpolates and never mutates simulation state."
|
|
3505
|
+
},
|
|
3506
|
+
{
|
|
3507
|
+
"kind": "added",
|
|
3508
|
+
"text": "Input as devices and generic mechanisms \u2014 keyboard, pointer, a configurable virtual stick and a tap/hold/swipe recogniser \u2014 none of which name a gameplay verb."
|
|
3509
|
+
},
|
|
3510
|
+
{
|
|
3511
|
+
"kind": "added",
|
|
3512
|
+
"text": "Seeded RNG, angle and colour maths, and a mesh builder that merges a world into a single draw call."
|
|
3513
|
+
}
|
|
3514
|
+
]
|
|
3515
|
+
}
|
|
3516
|
+
]
|