@driftengine/core 4.2.1 → 4.4.0

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.
Files changed (215) hide show
  1. package/CHANGELOG.json +272 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/backend/createRenderer.d.ts +13 -1
  12. package/dist/render/backend/createRenderer.js +6 -5
  13. package/dist/render/backend/webgl2/renderer.d.ts +166 -1
  14. package/dist/render/backend/webgl2/renderer.js +424 -23
  15. package/dist/render/backend/webgpu/buffers.js +46 -8
  16. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  17. package/dist/render/backend/webgpu/depthPass.js +75 -14
  18. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  19. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  20. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  21. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  22. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  23. package/dist/render/backend/webgpu/flatPass.js +12 -5
  24. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  25. package/dist/render/backend/webgpu/particlePass.js +1 -0
  26. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  27. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  28. package/dist/render/backend/webgpu/probeBake.js +97 -7
  29. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  30. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  31. package/dist/render/backend/webgpu/renderer.d.ts +176 -11
  32. package/dist/render/backend/webgpu/renderer.js +680 -250
  33. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  34. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  35. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  36. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  37. package/dist/render/backend/webgpu/skyPass.js +5 -1
  38. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  39. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  40. package/dist/render/bloomChain.d.ts +6 -0
  41. package/dist/render/bloomChain.js +8 -0
  42. package/dist/render/clusteredLights.d.ts +19 -11
  43. package/dist/render/clusteredLights.js +96 -45
  44. package/dist/render/cutoutCaster.d.ts +23 -0
  45. package/dist/render/cutoutCaster.js +20 -0
  46. package/dist/render/driftLight/bake.d.ts +34 -0
  47. package/dist/render/driftLight/bake.js +95 -0
  48. package/dist/render/driftLight/falloff.d.ts +16 -0
  49. package/dist/render/driftLight/falloff.js +23 -0
  50. package/dist/render/driftLight/layout.d.ts +59 -0
  51. package/dist/render/driftLight/layout.js +138 -0
  52. package/dist/render/driftLight/lightField.d.ts +108 -0
  53. package/dist/render/driftLight/lightField.js +179 -0
  54. package/dist/render/driftLight/uniforms.d.ts +28 -0
  55. package/dist/render/driftLight/uniforms.js +41 -0
  56. package/dist/render/driftLight/visibility.d.ts +18 -0
  57. package/dist/render/driftLight/visibility.js +47 -0
  58. package/dist/render/emptyTexture.d.ts +6 -0
  59. package/dist/render/emptyTexture.js +19 -0
  60. package/dist/render/exposurePass.d.ts +48 -0
  61. package/dist/render/exposurePass.js +172 -0
  62. package/dist/render/flameLight.d.ts +71 -0
  63. package/dist/render/flameLight.js +63 -0
  64. package/dist/render/gi/bounceLights.d.ts +34 -0
  65. package/dist/render/gi/bounceLights.js +66 -0
  66. package/dist/render/gi/fieldReach.d.ts +12 -0
  67. package/dist/render/gi/fieldReach.js +62 -0
  68. package/dist/render/gi/globalField.d.ts +8 -0
  69. package/dist/render/gi/globalField.js +31 -0
  70. package/dist/render/gi/probeTrace.d.ts +3 -1
  71. package/dist/render/gi/probeTrace.js +3 -1
  72. package/dist/render/gpuCapability.js +19 -4
  73. package/dist/render/lightBudget.d.ts +11 -0
  74. package/dist/render/lightBudget.js +11 -0
  75. package/dist/render/pointLightSelection.d.ts +16 -0
  76. package/dist/render/pointLightSelection.js +178 -83
  77. package/dist/render/probeGrid.d.ts +11 -1
  78. package/dist/render/probeGrid.js +5 -2
  79. package/dist/render/probeSweeps.d.ts +45 -0
  80. package/dist/render/probeSweeps.js +77 -0
  81. package/dist/render/reflectionProbe.d.ts +43 -1
  82. package/dist/render/reflectionProbe.js +17 -3
  83. package/dist/render/renderQuality.d.ts +3 -1
  84. package/dist/render/rendererHarness.d.ts +9 -0
  85. package/dist/render/rendererHarness.js +18 -3
  86. package/dist/render/sceneTarget.d.ts +20 -0
  87. package/dist/render/sceneTarget.js +62 -1
  88. package/dist/render/shaders/agx.d.ts +31 -0
  89. package/dist/render/shaders/agx.js +81 -0
  90. package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
  91. package/dist/render/shaders/ambientOcclusion.js +120 -63
  92. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  93. package/dist/render/shaders/cutoutCoverage.js +40 -0
  94. package/dist/render/shaders/depth.d.ts +25 -0
  95. package/dist/render/shaders/depth.js +69 -3
  96. package/dist/render/shaders/exposure.d.ts +52 -0
  97. package/dist/render/shaders/exposure.js +124 -0
  98. package/dist/render/shaders/filmLook.d.ts +47 -0
  99. package/dist/render/shaders/filmLook.js +98 -0
  100. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  101. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  102. package/dist/render/shaders/flat/driftLight.js +75 -0
  103. package/dist/render/shaders/flat/index.js +5 -0
  104. package/dist/render/shaders/flat/lobes.js +13 -0
  105. package/dist/render/shaders/flat/main.d.ts +1 -1
  106. package/dist/render/shaders/flat/main.js +68 -9
  107. package/dist/render/shaders/flat/preamble.js +23 -5
  108. package/dist/render/shaders/flat/probeGrid.js +46 -3
  109. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
  111. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  112. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  113. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  115. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  116. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  117. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  118. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  119. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  120. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  121. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  123. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  124. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  125. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  126. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  127. package/dist/render/shaders/globalMedium.js +4 -0
  128. package/dist/render/shaders/localExposure.d.ts +35 -0
  129. package/dist/render/shaders/localExposure.js +138 -0
  130. package/dist/render/shaders/prefilter.d.ts +1 -1
  131. package/dist/render/shaders/prefilter.js +6 -0
  132. package/dist/render/shaders/rush.js +33 -5
  133. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  134. package/dist/render/shaders/tangentFrame.js +15 -3
  135. package/dist/render/shaders/temporalResolve.js +39 -1
  136. package/dist/render/shadowCasters.d.ts +5 -3
  137. package/dist/render/shadowMap.js +13 -0
  138. package/dist/render/surfaceTexture.d.ts +5 -0
  139. package/package.json +6 -6
  140. package/scripts/gpuCompute.mjs +24 -3
  141. package/scripts/png.mjs +23 -12
  142. package/src/cinematic/cameraPath.ts +178 -0
  143. package/src/environment/daylight.ts +152 -0
  144. package/src/geometry/meshConcat.ts +100 -0
  145. package/src/index.ts +32 -0
  146. package/src/render/backend/createRenderer.ts +22 -6
  147. package/src/render/backend/webgl2/renderer.ts +462 -20
  148. package/src/render/backend/webgpu/buffers.ts +51 -8
  149. package/src/render/backend/webgpu/depthPass.ts +78 -10
  150. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  151. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  152. package/src/render/backend/webgpu/flatPass.ts +10 -2
  153. package/src/render/backend/webgpu/particlePass.ts +9 -0
  154. package/src/render/backend/webgpu/probeBake.ts +143 -9
  155. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  156. package/src/render/backend/webgpu/renderer.ts +807 -271
  157. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  158. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  159. package/src/render/backend/webgpu/skyPass.ts +5 -1
  160. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  161. package/src/render/bloomChain.ts +9 -0
  162. package/src/render/clusteredLights.ts +108 -63
  163. package/src/render/cutoutCaster.ts +44 -0
  164. package/src/render/driftLight/bake.ts +125 -0
  165. package/src/render/driftLight/falloff.ts +26 -0
  166. package/src/render/driftLight/layout.ts +177 -0
  167. package/src/render/driftLight/lightField.ts +227 -0
  168. package/src/render/driftLight/uniforms.ts +61 -0
  169. package/src/render/driftLight/visibility.ts +58 -0
  170. package/src/render/emptyTexture.ts +41 -0
  171. package/src/render/exposurePass.ts +186 -0
  172. package/src/render/flameLight.ts +135 -0
  173. package/src/render/gi/bounceLights.ts +68 -0
  174. package/src/render/gi/fieldReach.ts +74 -0
  175. package/src/render/gi/globalField.ts +39 -0
  176. package/src/render/gi/probeTrace.ts +3 -1
  177. package/src/render/gpuCapability.ts +18 -5
  178. package/src/render/lightBudget.ts +12 -0
  179. package/src/render/pointLightSelection.ts +191 -79
  180. package/src/render/probeGrid.ts +13 -2
  181. package/src/render/probeSweeps.ts +80 -0
  182. package/src/render/reflectionProbe.ts +54 -2
  183. package/src/render/renderQuality.ts +3 -1
  184. package/src/render/rendererHarness.ts +25 -3
  185. package/src/render/sceneTarget.ts +75 -1
  186. package/src/render/shaders/ambientOcclusion.ts +125 -63
  187. package/src/render/shaders/cutoutCoverage.ts +43 -0
  188. package/src/render/shaders/depth.ts +80 -3
  189. package/src/render/shaders/exposure.ts +128 -0
  190. package/src/render/shaders/filmLook.ts +111 -0
  191. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  192. package/src/render/shaders/flat/driftLight.ts +75 -0
  193. package/src/render/shaders/flat/index.ts +5 -0
  194. package/src/render/shaders/flat/lobes.ts +13 -0
  195. package/src/render/shaders/flat/main.ts +68 -9
  196. package/src/render/shaders/flat/preamble.ts +23 -5
  197. package/src/render/shaders/flat/probeGrid.ts +46 -3
  198. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
  199. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  200. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  201. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  202. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  203. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  204. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  205. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  206. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  207. package/src/render/shaders/globalMedium.ts +4 -0
  208. package/src/render/shaders/localExposure.ts +154 -0
  209. package/src/render/shaders/prefilter.ts +6 -0
  210. package/src/render/shaders/rush.ts +33 -5
  211. package/src/render/shaders/tangentFrame.ts +15 -3
  212. package/src/render/shaders/temporalResolve.ts +39 -1
  213. package/src/render/shadowCasters.ts +5 -3
  214. package/src/render/shadowMap.ts +14 -0
  215. package/src/render/surfaceTexture.ts +5 -0
package/CHANGELOG.json CHANGED
@@ -1,4 +1,276 @@
1
1
  [
2
+ {
3
+ "version": "4.4.0",
4
+ "date": "2026-09-28",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "`.drft` 1.18 carries a bought scene at a fifth of its size: quantised meshes, instanced copies, a texture cap and JPEG colour maps.",
9
+ "text": "**A bought 4K courtyard of five packs went from 1.67 GB to 355 MB, and from 702 MB to 231 MB gzipped**, with nothing on screen changed that a person could point at. The container gains two required chunks and the baker five flags.\n\n- **`MSHQ`** writes a mesh in the bytes its data needs, in place of its `MESH`: positions and UVs in sixteen bits of their own range, normals and tangents octahedral to within 5e-5 rad (an 8-bit normal map resolves 0.45°), any attribute every vertex agrees on as one value, and indices in sixteen bits where they reach. The baker writes it by default; `--no-quantise` writes floats for a reader before 1.18.\n- **`INST`**: an export often merges a thousand copies of one object into one mesh. `findRepeats` finds them by rigid registration, attribute for attribute, and the baker writes one copy and a matrix a copy. Ten thousand candles went from 873 MB to 2.9 MB and draw in three calls. `--no-instances` declines it.\n- **`--max-texture px`** halves an image until its longer side fits, re-encoded in its own codec: the same scene's textures went from 1.94 GB to 128 MB. **`--texture-codec jpeg`** then re-encodes the opaque colour maps, leaving normal and alpha maps lossless.\n- **`MATL` carries whether a material blends**, and `--blend-as-cutout` turns blended foliage into a cutout. A blended surface is never merged with an opaque one, so a decal no longer draws opaque.\n\nBoth chunks are required, so a reader before 1.18 refuses such a file by name rather than drawing one copy of ten thousand or reading sixteen-bit numbers as floats. A file carrying neither is byte for byte what 1.17 wrote."
10
+ },
11
+ {
12
+ "kind": "added",
13
+ "short": "A cutout casts the shape in its texture rather than its whole quad, into the sun's map and every point-light face, on both backends.",
14
+ "text": "**A leaf card cast the shadow of its card.** The depth pass had no alpha test and the caster sink carried no material, so every cutout cast its whole quad: a cypress threw a solid green slab, and a chain-link fence a wall.\n\nA `ShadowCasterSink` entry's material is now read once, by `cutoutOf`: an albedo map and a cutoff above zero make a cutout caster. It draws through a cutout variant of the depth program on both backends, with its cutoff in its own ring slot on WebGPU, per the rule that per-draw state is never one resource rewritten. The mesh path and the instanced path both do it, and so do the sun's layers and every point-light face, because they share the sink. A caster that offers no material casts exactly as before.\n\n**Measured** on `demo/dev/cutoutShadows.html` by `scripts/cutout-shadow-check.mjs`: a card with a lattice of holes casts **0.716** of a solid shadow against **0.717** predicted from the hole area, on both backends, on both paths, with a solid control in the same frame. Breaking either backend's path turns exactly the claims it owns red. The GPU-driven pipeline's depth pass still has no cutout, which `IMPROVEMENTS.md` records."
15
+ },
16
+ {
17
+ "kind": "added",
18
+ "short": "A model's lamps come with it: the glTF reader places `KHR_lights_punctual` lights, the baker writes them as `LITE`, and `DrftLoader.lights` hands them on.",
19
+ "text": "**The glTF reader now reads `KHR_lights_punctual`**, so a file requiring it is no longer told the reader lacks it. Each light comes out where its node's world matrix puts it, pointing down the node's −z, with the extension's own defaults where the file is silent, in glTF's units: candela for a point or a spot, lux for a directional light. What a candela is in a consumer's light units is that consumer's exposure decision, so nothing converts them.\n\nThe baker writes them as **`LITE`**, a code reserved since the container began and defined at 1.18: 64 bytes a light and each distinct name once, because a pack of ten thousand candles can name every one alike. It is optional, because a scene without its lamps is darker rather than wrong. `orientLights` turns them with the geometry when a bake stands a model up. `DrftLoader.lights` has them before the geometry lands, and `streamDrft` takes an `onLights` handler.\n\nA bought courtyard's 22 lanterns and 10,000 candles arrive placed by their author, and a scene lights them with `selectPointLights` like any other lights.\n\nThe same reader follows `EXT_texture_webp` and `EXT_texture_avif` to their image when there is no fallback `source`, and refuses `KHR_texture_basisu` by name. Before, such a texture baked untextured with only a warning. The loader names a codec it cannot decode rather than handing it to the browser as a JPEG."
20
+ },
21
+ {
22
+ "kind": "added",
23
+ "short": "`resolveDaylight` turns a palette keyed by sun elevation into light, sky and exposure; `CameraPath` moves an eye, a target and a field of view through time.",
24
+ "text": "**Two things every time-of-day scene wrote for itself.**\n\n`createDaylightPalette` takes keys: sun and moon colour, sky, hemispheric ambient, fog, shadow strength, emissive gain and a target exposure, at a scalar a caller chooses, usually the sun's elevation. `resolveDaylight` interpolates them into a caller-owned state, allocating nothing. `easeExposure` moves an exposure toward a target in time, `1 − exp(−rate·dt)`, so an eye adapts at the same speed at 60 Hz and at 144. `day-clock` runs on it, with no pixel changed on WebGPU.\n\n`createCameraPath` takes keys of an eye, a target and a field of view at times, and `sampleCameraPath` evaluates them through a Hermite with the keys' own times as knots, looped or not, into a caller-owned sample. A key two seconds from its neighbour and one twenty seconds away both arrive at their own pace."
25
+ },
26
+ {
27
+ "kind": "added",
28
+ "short": "A probe bake can read the grid it refills (`ProbeBakeOptions.bounce`), so the grid converges on light that has bounced more than once.",
29
+ "text": "**One sweep of a probe grid holds one bounce, and an arcade is lit mostly by the second and later.** The surfaces a probe saw were lit by the sun and by the open-sky hemispheric ambient, which is generous to a covered walk and wrong about it.\n\nWith `{ bounce: true }` a bake lights what it sees by the grid's last bake instead. Round-robin baking then converges by itself, about a bounce each time the grid is swept, and a scene holding one moment bakes the grid two or three times. It costs no pass and no memory. The faces are drawn into the probe's own cube and only the resolve writes the array, so no pass both samples and writes it, on either backend. Nothing changes until every probe has been baked once, because until then the grid is not readable.\n\n**What it gives up**: light that leaks through a wall into a probe is bounced again, so a leak grows with each sweep. A space with near-white walls converges slowly."
30
+ },
31
+ {
32
+ "kind": "added",
33
+ "short": "`createFlame` makes a flame's light and plume from one description, and `updateFlame` flickers it at the rate a flame that size does: 1.5/√D Hz.",
34
+ "text": "**A candle trembles and a bonfire breathes, and until now every light in this engine flickered at the same two rates.** A scene wired its plume, its light and a flicker amplitude by hand, and the selection's shared wobble made a candle and a brazier pulse together.\n\n`createFlame` takes where a fire burns from, how wide it is, a colour, a reach and a seed, and returns a `PointLightSource` and a `PlumePlacement`. The flame stands 2.5 widths tall and the light a third of the way up it, with a physical radius of half the width, so its shadows soften as a fire's do. `updateFlame` rewrites the light in place, allocating nothing. It flickers at the puffing rate of a buoyant flame, **1.5 / √D Hz** (Cetegen and Ahmed): 15 Hz for a candle's centimetre and 2.4 Hz for a 40 cm brazier. It sways up to 6 per cent of the flame's height on each axis, so a shadow it casts travels. `sway: 0` holds it still, for a light whose static shadow map a wandering light would read from off-centre. `flameFrequencyHz` is exported.\n\nThe light's own `flicker` is set to zero, or the selection would flicker it a second time."
35
+ },
36
+ {
37
+ "kind": "added",
38
+ "short": "`setBloom(scale, threshold)` moves the bloom threshold with the frame, so a scene whose exposure moves keeps blooming what it means to.",
39
+ "text": "**A bloom threshold is compared before exposure is applied**, so one fixed at construction means a different brightness on screen at every exposure. That is harmless while exposure stands still. A day whose exposure spans 2.5 to 14 had to choose: sunlit stone blooming at noon, or lamps never blooming at night.\n\n`setBloom` takes an optional threshold in the same scene units as `bloomThreshold`, held until moved. A caller whose exposure moves passes the brightness it wants on screen over the exposure. Omitted, it leaves the threshold where it was, so a caller that never passes one keeps the profile's. Both backends take it, and `drift/render` binds it as `bloomAbove(renderer, scale, threshold)`, both required. `MAX_CLUSTERED_LIGHTS` is exported too, so a consumer with clustered lights can size a light buffer to the table without restating 320."
40
+ },
41
+ {
42
+ "kind": "added",
43
+ "short": "`placeMesh` and `concatMeshes` move and join `MeshData` as typed arrays, keeping every attribute a member carries; `streamDrft` takes a `DrftStreamPacing`.",
44
+ "text": "**Two things a loader needs that `MeshBuilder` was standing in for.** `placeMesh(mesh, x, y, z, scale)` moves a mesh into a fit and copies its positions alone, sharing every other array, and returns the mesh itself at the identity. `concatMeshes(meshes)` joins meshes into one: it sizes each array once, fills an optional attribute a member lacks from the constant its backend would read for it, leaves out one no member has, and offsets the indices. A mesh joined alone is that mesh.\n\n`streamDrft` takes a third argument, `DrftStreamPacing`: how long it may work before it gives way for a task (`DEFAULT_STREAM_SLICE_MS`, six), with the clock and the yield as capabilities a host can replace. See the fixes below for why a stream has to give way at all."
45
+ },
46
+ {
47
+ "kind": "added",
48
+ "short": "`cameraCut()` tells the renderer the camera has cut, so the next frame's motion blur and temporal resolve start from the new view rather than smearing across the jump.",
49
+ "text": "**Everything temporal reprojects through the previous frame's view**: the motion blur measures its smear against it, and the temporal resolve and the reconstruction read their histories through it. A cut is a jump the renderer cannot tell from a fast camera, so a transport that seeks, a respawn or an edit smeared its first frame along the whole jump and blended in a picture of somewhere else. The caller knows it cut, and `cameraCut()`, called before the frame's `beginFrame`, is how it says so on both backends. The frame after it has no blur and no history, as the first frame of a session has none; nothing else is reset."
50
+ },
51
+ {
52
+ "kind": "added",
53
+ "short": "A scene's shape reaches traced indirect light: `bake --sdf` writes one field over the static geometry, and the stream, `DrftLoader.fields` and `addDistanceField` carry it.",
54
+ "text": "**Every piece of traced indirect light existed and none was joined.** `bakeObjectSdf` had no caller, `readDrft` read `SDFV` while the stream skipped it, and a scene loading through `DrftLoader` had no way to hand a field to `addDistanceField`. So `quality.indirectLight` could only be turned on by a rig of analytic boxes.\n\n- **`bake --sdf m`** bakes one distance field over the file's static geometry at `m` metres a voxel with `bakeSceneField`: every mesh drawn once whose material neither blends nor cuts out, so walls and floors and not foliage, cloth or a candle drawn ten thousand times.\n- **`SDFV_WHOLE_FILE`**, the ordinal `0xFFFFFFFF`, marks it as covering the file rather than one mesh, inside 1.18. No reader ever compared an ordinal with the mesh count, so an older one pairs it with nothing. **One merged field rather than one per mesh**, because a bought scene's walls come as a few meshes spanning all of it: per-mesh fields of 32 samples were 12.6 MB and 1.2 m voxels on the walls light bounces off; one field at 25 cm is 5.2 MB for a courtyard 36 m long.\n- **`streamDrft` takes `onFields`**, and **`DrftLoader.fields`** hands each field on where it stands: at the loader's fit, and once a copy for a mesh drawn many times.\n\nMeasured on that courtyard on WebGPU with the fixes below: a noon view's mean within a level of the rasterised grid's, and covered galleries the grid left near black lit, for 0.1 to 0.2 ms a frame at 2560 by 1440. WebGL2 keeps its baked grid, as the renderer has always said."
55
+ },
56
+ {
57
+ "kind": "added",
58
+ "short": "`setVignette` and `setFilmGrain` put a lens's corners and a print's grain on the frame, on both backends, and at 0 the frame is what it was byte for byte.",
59
+ "text": "**A picture that looks like it came off a camera has corners that fall away and a grain that moves.** Both live in the final composite, in the order their physics puts them.\n\n- **`setVignette(strength)`** darkens the corners the way a lens loses light off axis, `1 / (1 + k r^2)^2` with `r` measured to the corner on a circle, so a wide frame is not darker at its sides than at its top. It acts on scene light before the tone curve, so a bright corner rolls off through the shoulder as a photographed one does, where a vignette painted on the finished picture greys it. 0.5 is about 1.2 stops at the corner.\n- **`setFilmGrain(strength, seed)`** adds one value a pixel after the grade and the veil, weighted toward the midtones and faint at black and white, which also breaks up the bands a dark gradient makes in eight bits. **The seed is the caller's**, because the engine takes time from its caller: a new one each frame is grain that moves, and the same one is a still that is identical run to run. A temporal resolve cannot average it away, because it runs after the resolve.\n\nBoth are held until changed and need `screenEffects`, saying so once rather than doing nothing. At 0, the default, the nine published scenes are unchanged in every pixel on WebGPU."
60
+ },
61
+ {
62
+ "kind": "added",
63
+ "short": "`setAutoExposure(strength, dtSec)` adapts the exposure to the frame on both backends: metered on the GPU, eased over time, snapped by `cameraCut`.",
64
+ "text": "**Eye adaptation.** A fixed exposure is right for one view and wrong for the next: a courtyard floor in the shade of a 19 m wall is several stops under the sunlit walls above it, and an exposure set for one draws the other clipped or black. The finished scene is now metered each frame on the GPU with no readback, into a 32×32 grid of log luminance and one texel of its mean, and a held brightness follows it at a fixed rate, about 78% of the way in a second. The composite scales scene light toward middle grey by `strength` of the stops between them, at most six either way, before the lens and the curve.\n\n- `setOutputExposure` becomes a bias on top: a night kept darker than a day is a bias under one.\n- The time step is the caller's, as every clock here is; a held capture passes 0 and holds.\n- `cameraCut()` snaps it, so a new shot is metered rather than eased into.\n- 0 is off and the default, and at 0 nothing is metered or allocated. It needs `screenEffects` and `hdrScene`, and says so once otherwise.\n\nWhat it gives up is regional metering: every pixel counts the same."
65
+ },
66
+ {
67
+ "kind": "added",
68
+ "short": "A two-sided material is drawn from both faces on both backends: glTF's `doubleSided` is read, carried in `.drft` 1.18, culls nothing and casts from either face.",
69
+ "text": "**glTF's `doubleSided` was dropped on import and every pipeline culled back faces**, so a curtain seen from behind its arch was a hole, half a tree's leaf cards were not drawn, and a sheet whose front faced away from the sun cast nothing.\n\n- The glTF reader reads it; `MATL`'s flags word carries it as bit 1, inside 1.18, so the stride does not move and a file that never set it reads one-sided.\n- `DrftLoader` keeps two-sided parts apart when it merges, and `SurfaceMaterial.doubleSided` takes it to the draw.\n- WebGPU draws it with a pipeline that culls nothing, keyed `|2s`; WebGL2 turns culling off around the draw and hands it back. The shadow pass does the same on both.\n- A back face is lit as its front, the whole perturbed normal turned toward the eye after the normal map and the relief, which is glTF's rule. The face is decided from the normal against the view rather than from the rasteriser's facing flag, which would be a seventeenth fragment input where WebGPU allows sixteen."
70
+ },
71
+ {
72
+ "kind": "added",
73
+ "short": "`bake --normals-directx` turns a file's normal maps' green over on the way in, for maps written DirectX's way where glTF expects OpenGL's.",
74
+ "text": "**A DirectX normal map lights every groove from the wrong side**: a joint under a high sun reads as a dark line along its upper lip rather than shade under its lower one. Nothing in a file says which convention it wrote. The tell is a horizontal joint whose rows above read green over the middle, which glTF would put under it; a bought courtyard's stone maps read that way at forty joints across three maps. The flag inverts green on normal maps only, and re-encodes a map it would otherwise have carried as it came."
75
+ },
76
+ {
77
+ "kind": "added",
78
+ "short": "`DrftLoader`'s `surface` override can set `metallicScale`, so a consumer can declare a material dielectric whatever its map's metallic channel says.",
79
+ "text": "**A map's metallic channel can say metal where the surface is not.** A bought courtyard's stone, brick, plaster and wood maps carry up to 0.37 in it, on the clean stone rather than the grime; in shade each such patch swapped its diffuse light for a reflection of a dark gallery and read as a black blotch, and nothing in the maker's renders shows a metallic sheen on stone. `surface(material)` already let a consumer dress a surface by name with an opacity and a reflectivity; it now takes `metallicScale` too, which wins over the material's, joins the merge key, and at 0 says dielectric. `DrawSurfaceOverride` is the type."
80
+ },
81
+ {
82
+ "kind": "added",
83
+ "short": "`setLocalExposure(strength)` brings each region of the frame part of the way toward its brightness on both backends, so shade and sun read in one picture.",
84
+ "text": "**A tone curve maps one range, and a sunlit courtyard is two.** Measured in the probes that light a bought courtyard at noon, its shaded arcades hold a fiftieth of the light on its sunlit paving, which is what a canyon of grey stone is. Exposed for the paving the arcades went to black, exposed for the arcades the paving went to white, and no curve fixes both, because the same brightness has to mean shade in one place and sun in another.\n\nLocal exposure moves each region `strength` of the stops between it and eye adaptation's held brightness, at most three either way, before the lens and the curve. What a region's level is comes from a bilateral grid over the exposure meter's 32 × 32 tiles: for each tile and each of ten two-stop bands of log luminance, the sum and share of its taps in that band, and the tile's mean. The composite reads the grid at the pixel's place in the pixel's own band, so shade beside sun is lifted as shade rather than as the average of the two, blended 0.4 toward the tile's mean so a texture's own light and dark are not flattened into each other.\n\n- The held brightness is measured whether or not `setAutoExposure` is on; with it off the frame keeps the exposure it was given and only regions move.\n- 0 is off and the default, and at 0 nothing more is measured or allocated. Needs `screenEffects` and `hdrScene`, and says so once otherwise.\n\nWhat it gives up is some contrast between regions, which is the point, and a halo a tile wide where two regions in one band differ in brightness."
85
+ },
86
+ {
87
+ "kind": "added",
88
+ "short": "`ProbeGridOptions.crossfade` blends each sweep of probe bakes into the next, so a grid re-baked a probe a frame lights a scene without a pass of light each sweep.",
89
+ "text": "**A grid re-baked a probe a frame changed in steps.** Each probe held the light of its bake until its turn came round a sweep later and then jumped, and under a moving sun the jumps crossed the scene as a pass of light once a sweep: reported as a pass every second or two, which at sixty-four probes a frame at 60 Hz is one sweep. Measured on a gallery vault, frame-to-frame changes of 11 and 16 levels of 255 once a second.\n\nA crossfading grid keeps three sets of layers: the last whole sweep, the newest whole sweep, and the one being baked. The shading blends the first toward the second by how much of the third has been baked, so the light moves a sixty-fourth of a sweep a frame; the same vault then changed by 1 to 5 levels a frame, continuously. The set being written is never one the shading reads. Reflections read the newest whole sweep alone. `ProbeSweeps` holds the decision once for both backends.\n\nWhat it costs is three times the layers and a sweep of lag. It is off by default, and ignored where indirect light is traced, which writes the grid's layers itself."
90
+ },
91
+ {
92
+ "kind": "added",
93
+ "short": "`ProbeBakeOptions.faces` bakes a probe a few faces at a time, so a scene of millions of triangles spreads a bake over frames rather than dropping one.",
94
+ "text": "**A probe bake is six draws of the scene, and a bought courtyard of eleven million triangles made it the most expensive thing in a frame**: 6.4 ms of 14 on the frames it ran, which is a frame rate no display can hold steady.\n\n`faces: [first, count]` draws only those faces into the probe's capture, and the convolution into the grid layer, which reads all six, runs on the call that draws the sixth. Absent, a bake draws all six as before. Both backends take it: WebGL2 builds the probe's mip chain and WebGPU its prefiltered chain once, and neither marks the layer filled until the sixth face is drawn.\n\n**The caller finishes one probe before starting the next**, because the capture is one for the whole grid and faces of two probes interleaved make a cube of neither. What it gives up is a probe whose faces were drawn a few frames apart, under a light that moved a little between them."
95
+ },
96
+ {
97
+ "kind": "added",
98
+ "short": "`simplifyMesh` and `bake --simplify m` take out the triangles a shape does not need, with the surface moving at most `m` and a texture at most a texel.",
99
+ "text": "**A scene modelled for an offline renderer is mostly triangles smaller than a pixel.** `simplifyMesh(mesh, { maxError })` collapses edges by quadric error, the survivor keeping its own attributes, until the next collapse would move the surface further than `maxError`. It refuses a collapse that would turn a face over, fold one under, pinch a closed surface, open a seam where two vertices share a position, move a border off its line, or slide a texture: every sample the collapse absorbs has to land within `maxUvError` of where it was painted, a texel of a 1,024 map by default. A skinned or morphed mesh comes back unchanged.\n\n**`bake --simplify m`** runs it over every mesh written, after the copies are found, so a prototype is simplified once for all its placements, and after the outline, the colliders and the distance field, which are measured from what the author made.\n\n**Measured** on a bought courtyard at `--simplify 0.001`: the stone pack came down to 40% of its triangles, the ivy's leaves to 57%, the curtains to 60% and a candle's wax to 25%. A view down it at 3840 by 2160 went from 9.1 ms of GPU time to 8.0 at noon and from 30.5 to 27.5 at night, and a held capture of it differs in 2.6% of its pixels by more than 8 levels of 255, against 0.6% between two captures of one bake, along edges and in the shading of the curtains' folds."
100
+ },
101
+ {
102
+ "kind": "added",
103
+ "short": "`createRenderer` takes quality as a function of the backend it chose, so a WebGPU-only option is asked of WebGPU alone and a fallback prints no refusal.",
104
+ "text": "**An option only one backend has can now be asked of that backend alone.** Traced indirect light and reconstruction are WebGPU's, and WebGL2 refuses either in words when handed one: right for a consumer who asked for it there, and noise for one who wanted it wherever it runs, since every device that fell back printed a refusal of something nobody had asked it for. `createRenderer(canvas, (backend) => quality)` resolves the quality once the backend is chosen; a plain object works exactly as before. The type is `QualityForBackend`."
105
+ },
106
+ {
107
+ "kind": "added",
108
+ "short": "DriftLight: `createLightField` sums a scene's many fixed lights into an occluded volume, so every one lights past the few the frame shades exactly, on both backends and in the bounce.",
109
+ "text": "**Every fixed light lights, near or far.** A frame shades the lights `selectPointLights` chooses, nearest the camera, and nothing else, so a courtyard of two thousand candles left every wall past that choice dark until the camera walked up to it and its candles lit one by one.\n\n- **`renderer.createLightField(lights, { fields })`** sums a scene's fixed lights once into a sparse volume: bricks of four samples a side a third of a metre apart, each holding the light that arrives and the direction it mostly arrives from, laid out only where some light reaches. The scene's distance fields occlude it, with a penumbra from each light's `sourceRadius`, and a sample inside solid is left out of the filter rather than blended in. `field.bake(bricks)` does the number asked, so a scene paces it behind its loading screen; the engine reads no clock.\n- **The split is per pixel, and no light is counted twice or dropped.** `PointLightBuffer.complete` is new: the radius inside which every field light that reaches a point was chosen. The lit shader shades exactly inside it and reads the volume past it, crossfading over two metres; a field's lights carry a mark (`inLightField`) the selection turns into the sign of their weight, and their exact light is scaled by the share the volume does not take. `field.follow(buffer.complete, eye, dt)` shrinks the radius at once and grows it back slowly.\n- **It bounces.** A probe bake reads the whole field, rasterised or traced by DriftRay, so candlelight reaches the corners the candles cannot see.\n- **Measured**: bit-identical on the two backends on `demo/dev/driftlight.html`, a gallery of 726 candles with a partition whose positive control shows what the occlusion stops; the sum within 5% of exact shading on the same wall; 0.3 to 0.4 ms of GPU time at 1440p in a night courtyard; ten thousand candles lay out 3,774 bricks and bake in 2.2 s of one core. Two uniform vectors, because five took a part offering 256 down a rung of its light budget, and two samplers of the lit stage's sixteen.\n\n**What it gives up**: specular from summed lights, flicker past the choice, and occlusion by anything that moves. A light that moves stays exact and out of the field. The lights are summed as they stand when the field is made, and `field.scale` dims the sum."
110
+ },
111
+ {
112
+ "kind": "fixed",
113
+ "short": "DriftRay bounces every light, not only the sun: a probe's ray lights what it strikes with the frame's exact lights, shadowed by the distance field, so fires light corners they cannot see.",
114
+ "text": "**A courtyard at night bounced no firelight.** DriftRay shades what a probe's ray strikes itself, which is what lets the bounce follow a light that moves, but it shaded with the sun alone: every brazier and lantern lit the walls it faced and nothing past them, and the probes lit the rest of the courtyard with the sky.\n\n- **A hit is lit by the frame's exact lights**, the lit shader's lamp term line for line (falloff, cone and cosine, which `probeBake.test.ts` holds together), each one's shadow marched through the distance field and stopped a fixture's width short, the larger of 30 cm and two of the finest voxels, because a field that coarse draws a lantern round its own flame as solid.\n- **At most 32, the first the selection chose**, and a light a DriftLight field sums is left out, because the field already brings it to the bounce (`gi/bounceLights.ts`, decided once for any backend that traces).\n- **Measured** in the courtyard's arcade at 03:30 with the exposure held: the frame's mean went from 48.1 to 60.3 of 255 with the fixtures in the bounce, and no pixel darker by more than four levels on more than 0.1% of the frame. The bake went from 0.16 to 0.60 ms of GPU time a frame at 1440p for 26 lamps. `probe-bake-parity.mjs` agrees with its reference to 5.7e-5.\n\n**What it gives up**: a photometric profile and a cookie, which the bounce reads as a plain cone; an occluder within a fixture's width of a lamp; and lights past the first 32, which a probe far from the camera would want. The rasterised grid needed none of this: it draws the frame's lights into each face."
115
+ },
116
+ {
117
+ "kind": "fixed",
118
+ "short": "Choosing the nearest of many lights is linear: `selectPointLights` went from 3.2 ms to 0.36 ms for 320 of 10,000, and WebGL2's froxel binner from 47 ms to 17.",
119
+ "text": "**Both were fine for sixteen lights and neither was built for ten thousand.**\n\n`selectPointLights` kept a sorted list as the sources arrived, shifting fourteen fields a slot to make room. That is O(sources × slots): **3.2 ms a frame** to choose 320 of a courtyard's candles, most of it moving lights that were then displaced. It now partitions with a quickselect, linear on average, sorts only the winners, and packs each winner once: **0.36 ms**. Ties go to the earlier source, as they did, and the fade against the nearest light left out is unchanged. A test offers 10,000 lights in a scrambled order and checks slot for slot against distances derived by hand.\n\nThe CPU froxel binner WebGL2 runs every `bindMeshPass` recomputed a cluster's box, and all sixteen of a full cluster's member distances, on every insert into one. It assumed full clusters were rare. Three hundred candles within a few metres of the camera fill 2,152 of 3,456. It now takes each slice's depths once a binning and caches each member's distance when the member is placed: **47 ms to 17 ms**, with the table **byte-identical** on five configurations, so the GPU binner's conformance is untouched. A probe grid baked at night had been binning for a minute. A new test pins the overflow rule, which no test had."
120
+ },
121
+ {
122
+ "kind": "fixed",
123
+ "short": "A probe grid baked in one call no longer spends the frame's rings on WebGPU, and the sky and the scatter take a ring slot a draw; WebGL2's `dispose` frees everything.",
124
+ "text": "**Four WebGPU defects of one shape: per-draw state that was not per draw.**\n\n- **A probe bake gives its uniform-ring slots back after its own submit.** A grid of 64 probes baked in one call spent the frame's rings: thousands of draws against a ring of 4,096, and every probe past the ceiling was baked with its draws skipped. A courtyard's floor came back lit by half its walls.\n- **The sky is a ring slot a draw.** One buffer rewritten at each draw gave every sky in an encoder the last camera, so a probe's six faces all drew one direction's sky.\n- **The scatter's fragment block is a ring slot a draw too**, for the same reason: it held the camera, and \"settled per pass\" is not \"settled per encoder\".\n- **A particle batch drawn twice into one encoder is refused by name**, where it drew the second draw's particles twice.\n\n**WebGL2's `dispose` releases every GL object the renderer made.** The scene target, three sun maps, the mirror, the point shadows, four programs, two vertex arrays, a buffer and three textures were never released, so a page that swaps renderers leaked all of them each time. A test counts every kind at two profiles."
125
+ },
126
+ {
127
+ "kind": "fixed",
128
+ "short": "Importing a bought scene: the weld fits a five-million-triangle mesh, greyscale PNGs decode, `--up` turns tangents, and `writePartMaterial` copies every field.",
129
+ "text": "**Found by importing a bought 4K courtyard, and each one made it fail or look wrong.**\n\n- **The weld keys corners in an open-addressed table of typed arrays**, not a map of strings: a million corners cost 706 MB of heap, and a five-million-triangle mesh could not bake in the default heap. Ivy now bakes in 12.7 s at 1.1 GB, where it ran out at 5.9 GB. Its hash mixes the high bits into the low ones, which is what separates +0 from −0.\n- **The PNG reader the baker and the native host share reads greyscale and grey-with-alpha**, which the scene used for 50 of 135 maps; the host widens through the one `rgbaOf` rather than a copy of it.\n- **Standing an asset up turns its tangents with its normals.** The baker derives tangents before it orients, so a normal-mapped model baked with `--up` lit its normal maps from the wrong side.\n- **`writePartMaterial` fills a `SurfaceMaterial` from a loaded part, every field of it.** `SurfaceMaterial` defaults `occlusionStrength` to 1, and a glTF material with no occlusion map carries 0 over an ORM map whose red channel may be anything: zero, in the scene measured. A hand copy that left out the strength drew the whole model black.\n- **A loader whose parts are all instanced swaps its coarse outline out**, rather than leaving it on screen under the finished model."
130
+ },
131
+ {
132
+ "kind": "fixed",
133
+ "short": "A heavy streamed load takes half as long and holds the frame a fraction as long: 15 s to about 8 s for a five-pack courtyard, and no mesh is copied twice.",
134
+ "text": "**The same five packs from a local server loaded in 15 s and ran in stretches of up to a second with no frame drawn.** Profiled, none of it was the network, which delivered at 800 MB/s. It was work on the main thread that nothing bounded:\n\n- **The loader copied every mesh twice through a `MeshBuilder`**, once to move it into its fit and once more to merge it, through arrays that grow: about 7 s of the load. It now places a part with `placeMesh` and merges a group with `concatMeshes`, each array written once.\n- **A stream decoded every chunk inside one task.** Once a body is buffered each `read` resolves as a microtask, so a whole file decoded before a frame could draw. `streamDrft` now gives way after a slice.\n- **A uniform ring re-sent everything it held at every flush**, and a probe bake flushes once a face: 2.3 GB of uniform uploads in a two-second grid bake, every byte already on the device. A ring now sends from the lowest byte written since its last flush.\n- **The WebGPU interleave** walked components inside vertices with the index recomputed from both, 1.5 s; a loop per width does it in about a third. **The mesh validator** read `data.indices` inside its scan, a megamorphic load the compiler cannot hoist, 0.8 s. **The octahedral decode** normalised with `Math.hypot`, nearly three times a square root for the same float32 on every one of 263 million codes sampled.\n\nMeasured to the courtyard drawn: 15 s to between 7.4 and 8.2 s on WebGPU across runs, and 9.5 s on WebGL2. The nine published scenes are unchanged in every pixel on WebGPU."
135
+ },
136
+ {
137
+ "kind": "fixed",
138
+ "short": "A loaded part keeps the tangents its file carried, and a vertex frame that collapses falls back to the derived one rather than shading NaN.",
139
+ "text": "**Every loaded part lost its tangents, alone and merged**, because the `MeshBuilder` it passed through carries none, so a file that baked a frame for its normal maps drew with the one derived from screen derivatives. They reach the renderer now, which found the second defect at once.\n\n**A tangent parallel to its normal, or two that nearly cancel across a triangle at a mirrored seam, leaves nothing after Gram-Schmidt, and a normalised nothing is NaN.** One such pixel in one probe face was a whole courtyard drawn black: a probe convolves its faces into every direction and a bounce carries that into every other probe. `tangentFrame` now uses the derived frame wherever the vertex frame has no length left, on both backends."
140
+ },
141
+ {
142
+ "kind": "fixed",
143
+ "short": "`DrftLoader` says `ready` once every image is on the GPU, as `DrftLoadProgress` documents, rather than once the parts are merged.",
144
+ "text": "**`ready` is the signal to act on, and it fired with images still decoding.** `imagesDone` and `fraction` were corrected for exactly this window and `ready` was not, so a consumer gating on it judged surfaces that were still untextured. One baked its light probes at that moment and held the hour: stone without its colour map bounced into every probe, and the frame came out overexposed on the runs where the decodes lost the race and correct on the runs where they won. Every image counts when it decodes and when it fails, so a bad one cannot hold `ready` back for ever."
145
+ },
146
+ {
147
+ "kind": "fixed",
148
+ "short": "WebGPU refuses a mesh with a short attribute, as WebGL2 always has, instead of drawing zeroes for the tail.",
149
+ "text": "**WebGL2's mesh has run `validateMeshData` since the validator existed, and the WebGPU upload never called it.** A uv array one vertex short was an error on one backend and a stretched texture on the other, which is a decision taken twice. Both now refuse it in the same words."
150
+ },
151
+ {
152
+ "kind": "fixed",
153
+ "short": "`--texture-codec jpeg-all` encodes a normal map with every colour sample, so a tangent-space direction is never averaged across four pixels.",
154
+ "text": "**A normal map's colour is a direction**, and chroma subsampling averages its x and y across a 2×2 block, which bends a relief at every edge. MozJPEG happened to keep every sample at the quality the baker asks for; the baker now asks for it explicitly, and a test reads the sampling factors out of the encoded file."
155
+ },
156
+ {
157
+ "kind": "fixed",
158
+ "short": "A froxel holds 76 lights rather than 16, so a candlelit interior no longer draws its clustered lighting as hard-edged rectangles on both backends.",
159
+ "text": "**Screen-aligned rectangles on every vault, a shade darker than their neighbours, reported on both backends.** Measured, a courtyard of candles, lanterns and braziers asked 461 of its 3,456 froxels for more than sixteen lights and one for fifty. A full froxel keeps its nearest and drops the rest, and the froxel beside it keeps a different set, so the step between two froxels drew as a rectangle with a hard edge.\n\nA froxel's run is twenty texels now, 76 lights, the next cap the table's shape allows above what was measured. The table goes from 292 KB to 1.1 MB. The light loop is bounded by the cap, and the fixed arm still leaves at its own count and at `MAX_LIGHTS`, so a consumer that does not cluster iterates what it did and indexes nothing past its uniform arrays. Both binners agree bit for bit at the new cap (`scripts/cluster-check.mjs`), and the nine published scenes are unchanged in every pixel on WebGPU.\n\n**What it costs is shading**: the densest froxels now shade up to fifty lights where they shaded sixteen, and the candlelit view that showed it went from 4.8 to 7.7 ms of GPU time on WebGL2. That is the price of the lights being there; a consumer that wants it back asks for fewer lights, which now fades them rather than tiling them."
160
+ },
161
+ {
162
+ "kind": "fixed",
163
+ "short": "With reconstruction on, an overlay drawn after `endFrame` draws on WebGPU again: its depth was sized to the render size rather than the drawing buffer.",
164
+ "text": "**The overlay is on the far side of the resolve**, so its depth has to be the drawing buffer's. It was sized from the scene's depth, which a reconstruction draws small, and the device refused a depth of 985 by 468 beside a swap image of 1280 by 608. Every overlay command buffer came back invalid, so a load screen, a label or any interface drawn after `endFrame` vanished whenever reconstruction was on. Found by a scene turning reconstruction on for the first time; the overlay now takes the canvas's size, which is what it always was without reconstruction."
165
+ },
166
+ {
167
+ "kind": "fixed",
168
+ "short": "Traced indirect light owns the diffuse level of the probes it has traced, so a rasterised re-bake no longer sweeps light across a scene a probe at a time.",
169
+ "text": "**Two writers of one level is a flicker.** A scene re-rasterised a probe a frame for its reflections while the trace refreshed the same grid five probes a frame; each raster bake overwrote a layer's irradiance with its own answer and the trace pulled it back over the next frames, so light swept down a courtyard a probe at a time, for ever, with the clock paused. Measured on the held frame: region-scale flicker of 1.11 levels, peaks of 6.3, against 0.17 with the trace alone. `ProbeBaker.traced(layer)` says which layers are the trace's, and a rasterised bake of one refreshes only the roughness chain a reflection reads. WebGL2 has no trace and is unchanged."
170
+ },
171
+ {
172
+ "kind": "fixed",
173
+ "short": "A baked distance field signs open air correctly for a mesh built of sheets: inside is decided by walking the grid, not by crossing parity along one axis.",
174
+ "text": "**Parity is exact for a closed solid and wrong for a sheet**, and a bought scene is sheets: single-sided walls, rooms open to the sky, a floor with nothing under it. One sheet a ray along `x` passed flipped every point after it, so a courtyard's field read its open air as solid in bands by altitude, +2.6 at 4 m and -2.7 from 5 m to 9 m, and a closed ball behind a sheet came back empty. Light traced through the banded half found black.\n\n`bake/surfaceDepth.ts` walks from the grid's faces: a step between neighbours crosses no surface when their distances add to more than the step, so everything reachable is outside, a region bordering it across the surface is inside, a region bordering only that is sealed air, and so on. Exact where it moves; a gap narrower than about a voxel reads as sealed. Every field baked before this should be baked again."
175
+ },
176
+ {
177
+ "kind": "fixed",
178
+ "short": "Ambient occlusion is horizon-based (GTAO): no halo round a thing standing in front of a wall, and a flat floor seen at a grazing angle is open.",
179
+ "text": "**The estimate counted anything within the radius as a blocker, however thin**, so cloth hanging a hand in front of a column darkened the column, which read as a halo round every curtain. It now walks two lines a pixel both ways and keeps the horizon along each, with the sky between them weighted by the cosine to the normal in closed form (Jimenez et al. 2016); a sample fades out of the horizon toward the radius, and the horizon sinks back past a thin thing in front. Measured on the same frame, mean darkening fell from 19% to 8% and the share of pixels darkened by a fifth from 32% to 11%.\n\nThe slice directions are even in pixels, not in UV: even in UV they crowd toward the horizontal on a wide frame, and every flat floor and ceiling came back at 0.82 open. Two published scenes use occlusion, and change."
180
+ },
181
+ {
182
+ "kind": "fixed",
183
+ "short": "A cutout keeps its coverage down the mip chain: its alpha test credits a quarter more per level sampled, so a tree seen from a distance keeps its leaves.",
184
+ "text": "**Averaging alpha thins anything thinner than a texel**: a needle one texel wide is 0.25 two levels down and gone against a cutoff of 0.5, so a cypress stood in full leaf at 2000 pixels across and bare at 1280. The mesh pass and the cutout shadow pass both test alpha times one plus a quarter per mip level, Ben Golus's figure for foliage, from the texture coordinate's derivatives. Only the test sees it: what a translucent draw blends with is the texture's own alpha, and a material with no cutoff is untouched. What it gives up is exactness, which a coverage-preserving mip chain built at upload would have."
185
+ },
186
+ {
187
+ "kind": "fixed",
188
+ "short": "The glTF reader gives a metal coloured by an image no white highlight, so a dark decal whose file omits its factors no longer draws white in sun.",
189
+ "text": "**A bought courtyard's dirt decal drew every wall it covered white under a low sun, with the grime left dark between.** Its material names a base-colour image and neither a metallic nor a roughness factor, so glTF reads it as metallic 1 and roughness 1: a rough metal whose reflectance is its own dark colour. With no metallic-roughness map the factor had nowhere to go but the mesh's `specular` lane, which is this engine's dielectric highlight and is white, so a surface that should have been grime took a full-strength white highlight from the sun.\n\nA metal's highlight is its own colour, and where that colour is an image it varies per texel and a vertex lane cannot say it. The reader now keeps the lane at the dielectric's zero there. A metal whose colour is a constant keeps the highlight it had, and a metal with a metallic-roughness map was already read through the map. What it gives up is the sun's glint on a textured metal with no map. A model baked before this carries the old lane and wants baking again."
190
+ },
191
+ {
192
+ "kind": "fixed",
193
+ "short": "A surface turned from the sun stays in its roof's shadow whatever its normal map says: a shaded wall no longer takes the sun through the vault above it as white specks.",
194
+ "text": "**Two faults, one picture.** The directional shadow's filter gave up at a surface's own terminator, where no contact-safe bias can hold a grazing plane still, and returned fully lit there. Under a high sun that band is every vertical wall turned from its bearing, so a gallery's back wall and its columns' shaded faces were lit through the vault over them. And the band was chosen from the mapped normal, so the texels a normal map leaned toward the sun were lit on a surface the sun could not reach: a field of white specks over stone in shade, and a white rim on every doorframe.\n\nThe band now takes a coarse test, one tap at the receiver with a metre of tolerance, which cannot see a contact shadow and does see a roof. The band, and the sun itself, are decided by the surface's own normal before any map tilts it: a map says which way a texel faces within its surface, not which side of the surface the sun is on. The sun is taken away over the last two hundredths of the surface's own n·l, where an unmapped surface's Lambert term is already under 0.02, and the sun's shadow as the emissive share reads it is left alone on a face turned from the light, which is unlit rather than shadowed. What it gives up is the bump-lit relief of a surface barely facing the sun.\n\n**Three published scenes change, each toward what it says it is.** The gilded chamber, \"an interior lit by four fires and one hole\", had its walls, columns and stair risers lit by a steep sun through its ceiling, through their relief and the old band: its mean brightness goes from 38.6 to 33.2 of 255, the same on both backends. The collapse scene's stair side and channel walls, and the day clock's columns on their shaded side, lose the sun their relief had let in: 26,204 and 20,719 pixels of 921,600, a mean of 12 and 4 levels."
195
+ },
196
+ {
197
+ "kind": "fixed",
198
+ "short": "The temporal resolve reads its history through Catmull-Rom rather than one bilinear fetch, so a still camera settles on a sharp picture rather than a soft one.",
199
+ "text": "**The reprojection lands between texels nearly everywhere, and a bilinear fetch there is a small blur.** Applied to a picture that is itself last frame's blend it compounds, so a still camera settled on a picture visibly softer than one frame of it: measured on a courtyard's stone as the mean of a Laplacian, the resolved frame kept 0.70 of the fine detail of the same frame without it. Read through Catmull-Rom it keeps 0.75. Five bilinear fetches arranged as a Catmull-Rom filter pass a ramp through exactly and keep an edge an edge; the neighbourhood clip after it takes back what its negative lobes overshoot. Both backends, from the one GLSL source."
200
+ },
201
+ {
202
+ "kind": "fixed",
203
+ "short": "A surface outside a probe grid and facing away from it takes the open sky's light rather than the grid's edge, so a building's outer wall is not black at noon.",
204
+ "text": "**Outside its box a grid clamps to its edge probes**, which is right for a floor below the lowest layer or a vault above the highest, facing back into the grid, and wrong for a face turned away from it: a building's outer wall read its edge probe's light in the direction of its own normal, which from inside the courtyard is the shaded back of the same wall, and drew black under a noon sun. Such a surface now takes the hemispheric sky as it leaves the box facing out, from one metre past it to two and a half, judged by its own normal rather than a mapped one."
205
+ },
206
+ {
207
+ "kind": "fixed",
208
+ "short": "A point light's shadow is not looked up where the light has all but faded: a lantern-lit night frame at 1440p went from 26 ms of GPU time to 19.",
209
+ "text": "**A lamp's shadow was filtered at every pixel its froxel listed it for**, a blocker tap and twelve filter taps a light, even where the light had fallen off to nothing and the shadow was blended away with it. The shadow is mixed toward none by twice the falloff, so under a weight of 0.03 it changes at most three per cent of a light that has already all but faded; with the inverse-square falloff, that is past about eight metres from it. Both the static and the live lookup now skip it there, on both backends, as `POINT_SHADOW_MIN_WEIGHT`.\n\n**Measured** on a courtyard lit at night by lanterns and candles, at 2560 by 1440: the point-shadow lookups were 14 ms of a 26 ms frame of GPU time, and the frame went to 19. What it gives up is the last three per cent of a distant light's shadow, which the blend was already taking away; what would make it wrong is a falloff that stays strong far out."
210
+ },
211
+ {
212
+ "kind": "fixed",
213
+ "short": "DriftRay, the traced indirect light, carries the light a path tracer says it should: a sunlit room read a third of it, and a courtyard's far end was lit as open sky.",
214
+ "text": "**DriftRay is this engine's ray tracing**, rays marched through declared distance fields in GPU compute rather than on the dedicated ray-tracing units WebGPU does not expose, and four defects kept it off by default in the one scene built to show it. Found by holding it to `scripts/bounce-reference.mjs`, a path tracer of `demo/dev/bounce.html`'s room that shares nothing with the engine but its lighting convention, where before it was only compared with the rasterised grid, which is itself short on that page.\n\n- **The bake divided the sun by pi where the frame does not.** The surface shader lights a wall `albedo * (ambient + directionalColor * cosine)`, so every sunlit wall a probe struck came back a third as bright as it is drawn. The far wall now reads **187.7 of red against a reference of 194.6 to 218.0**, where it read 66.0.\n- **The world field reached 16 m from the camera**, and a probe beyond it was traced against nothing and lit as open sky from every side: a courtyard's far end washed milky. The field now has as many cascades as reach the whole probe grid from anywhere inside it, up to six, and a probe it still does not reach keeps the light it has (`gi/fieldReach.ts`).\n- **An escaping ray read the environment's ambient**, a grade's fill five times dimmer than a dusk horizon and blue where it was amber. It now reads the sky the frame drew, the sky shader's gradient and sunset bands transcribed line for line, and the ambient only where no sky was drawn.\n- **Each refresh was written as it landed**: 256 rays over a set that turns every frame put two refreshes of a probe a few per cent apart, a shimmer about nine times a second. A refresh now keeps four fifths of what the probe held, once the grid has been traced whole (`PROBE_HISTORY`), at the price of following a changed light over about half a second at 120 frames.\n\nGreen and blue still read an eighth over the reference, which the field's 0.17 m cells account for: a hit takes its cell's winning albedo, and half a cell along the red wall's joins comes back white. It costs 0.1 to 0.2 ms a frame, measured on a bought courtyard at 2560 by 1440."
215
+ },
216
+ {
217
+ "kind": "fixed",
218
+ "short": "A still scene lights a still picture under DriftRay: a traced probe casts the same directions at every refresh, so a paused courtyard no longer shimmers in its shade.",
219
+ "text": "**A still scene lights a still picture under DriftRay.** Each refresh of a traced probe cast its 256 rays along a direction set turned a little further every frame, so two refreshes of one probe were two estimates a few per cent apart, and a probe comes round several times a second: a paused courtyard shimmered softly in the shade its probes light. The set is held fixed now. Across a burst of frames of a still view, 77% of the pixels moved by more than three levels; 4.8% do now, and those are the flames.\n\nWhat it costs is a fixed quadrature error in place of a moving one, about 2.5 levels on the bounce page’s far wall, and the blend between refreshes now smooths a light that moves rather than noise that does not."
220
+ },
221
+ {
222
+ "kind": "fixed",
223
+ "short": "A shadow layer no pass has drawn reads as lit on every WebGL2 driver, so a scene no longer loses its sun under ANGLE’s GL backend, which Chrome on Linux runs by default.",
224
+ "text": "**A scene no longer loses its sun on WebGL2 under ANGLE’s GL backend**, which is what Chrome on Linux runs by default. A shadow layer the scene never draws — the second depth layer of a profile that asks for two, or the movers’ layer in a world with none — was sampled as whatever the driver made the texture. WebGL zeroes it, and a zero in a map compared with `LEQUAL` is an occluder at the light, so every surface was in shade. ANGLE on Vulkan hands the same texture back reading 1, which is lit, and every capture here had run on that: 0 and 255 for the same never-written texel on one card.\n\n`ShadowMap` now clears itself to the far plane when it is made. And the peel is sampled only where a pass has filled it since the first layer opened, which is the rule the WebGPU backend already held, so the two backends make one decision and a frame that does not peel no longer pays a fetch per filter tap for it."
225
+ },
226
+ {
227
+ "kind": "fixed",
228
+ "short": "Clustered lights, the probes’ diffuse level and film grain do their integer work at 32 bits on WebGL2, so a driver that runs mediump at 16 bits lights a scene again.",
229
+ "text": "**WebGL2 does its integer work at thirty-two bits on a driver that takes mediump at its word.** GLSL ES gives a fragment stage’s `int` and `uint` a default precision of mediump, and `precision highp float` says nothing about either. Mesa computes mediump in sixteen bits, and so does ANGLE’s GL backend on Linux, as do phones that run mediump at half width. Three passes did thirty-two-bit work under that default:\n\n- **The clustered lights** decode each record with `uintBitsToFloat` from `uvec4` texels, so every clustered light lit nothing: a courtyard of braziers at night had no firelight on a single stone.\n- **The probe prefilter’s** Hammersley radical inverse came back 0 for every sample, so a probe’s diffuse level was a blurred look along its normal rather than a cosine integral. A bounce bake compounded it, and the grid read 20 to 50% brighter than the same bake on Vulkan.\n- **The film grain’s** hash was sixteen bits wide.\n\nEach now declares `precision highp int`, and a test reads every fragment stage the WebGL2 renderer builds and refuses one doing bit arithmetic without it. ANGLE’s Vulkan backend computed all three at thirty-two bits regardless, so the published scenes there are identical inside the frame."
230
+ },
231
+ {
232
+ "kind": "fixed",
233
+ "short": "`describeGpu` never throws on a host that has a page and no DOM, such as the native host: it asks the adapter for the part instead of taking the caller down.",
234
+ "text": "**`describeGpu` keeps the promise its header makes, that nothing in it throws.** It made its WebGL2 canvas outside the guard it gave the context, and a host can have a `document` and still refuse to make one: the native host installs a page so a game finds what it expects, and its `createElement` throws, because there is no DOM behind it. A scene that chose its lighting tier from the part therefore never reached its first frame on the second host. The canvas is made inside the guard now, so such a host is simply one without WebGL2, and the adapter names the part — on the native host, Dawn’s."
235
+ },
236
+ {
237
+ "kind": "fixed",
238
+ "short": "The native host presents after a frame’s microtasks have run, as a browser does, so a HUD or a loading screen drawn after `endFrame` reaches the window.",
239
+ "text": "**The native host presents a frame after its microtasks have run, as a browser does, so what a game draws after `endFrame` reaches the window.** The WebGPU renderer submits anything drawn after `endFrame` on a microtask, since there is no second `endFrame` to hang it on, and a browser presents only after the task’s microtask checkpoint. The host presented as soon as the frame returned, so the window’s blit went to the device first and every overlay — a HUD, a loading screen — landed in the canvas texture after it. A capture reads that texture back and saw it; the window never showed it. Measured on a courtyard’s loading screen: the frame handed to the window had no pixel above 20 of 255, and holds the name, the percentage and the bar now.\n\nThe loop is `runFrames` in its own module, taking its steps as functions, so the order is held by a test without a display."
240
+ },
241
+ {
242
+ "kind": "added",
243
+ "short": "`MAX_TEXT_CELLS` says how many lit cells one pixel-font text object draws, so a caller laying out a runtime string can break it before the engine cuts it.",
244
+ "text": "**`MAX_TEXT_CELLS` is exported: how many lit cells one pixel-font text object draws before the rest of its string is cut.** The cut is silent by design, since a clipped message beats a frame that does not render, and until now a caller could not see it coming: a readout that grew past 900 cells lost its end mid-glyph. A caller breaking a runtime string into lines, with `countCells` beside it, now has the number to break at."
245
+ },
246
+ {
247
+ "kind": "added",
248
+ "short": "A native host frame may answer with a promise, which the loop waits for without presenting or counting it, so a window between two scenes no longer spins.",
249
+ "text": "**A frame on the native host may answer with a promise, and the loop waits for it.** A window between two scenes has nothing to draw until the next one has mounted, and the loop spun through empty frames meanwhile — tens of thousands a second on a window with nothing to pace it, a core burnt for every switch, and a run asked for a number of frames spent them all before the new scene had drawn once. A frame that waits closes its error scope first, presents nothing and is not counted as drawn. A frame that answers nothing is drawn as before."
250
+ },
251
+ {
252
+ "kind": "added",
253
+ "short": "`HostOptions.presentMode` chooses how the native host puts frames on the screen: `fifo` by default, or `mailbox` to keep the compositor's pacing out of a measurement.",
254
+ "text": "**`HostOptions.presentMode` chooses how the native host puts a frame on the screen**: `fifo`, the default, `fifoRelaxed`, `mailbox` or `immediate`, passed to the binding’s swap chain. It is an option because the pacing is the compositor’s rather than the engine’s. The binding renders into X11 windows only, so a Wayland session runs the host through XWayland, and what `fifo` waits for there differs by window: on one session a hidden window held 58 frames a second where a visible one drew a light scene at 250. A caller measuring the engine wants that wait out of the way, and `mailbox` removes it at the cost of drawing frames the display never shows."
255
+ },
256
+ {
257
+ "kind": "fixed",
258
+ "short": "A probe bake spread over frames no longer rebuilds every bind group or allocates its targets each call on WebGPU: a courtyard with the sun moving went from 63 to 171 fps natively.",
259
+ "text": "**A probe bake that runs every frame no longer rebuilds and reallocates as though it ran once.** Since a bake can be spread a few faces a frame and a crossfading grid re-bakes as the light moves, `bakeProbe` is a per-frame path, and on WebGPU it still behaved as a one-off. It rebuilt the flat bind group on entering and on leaving, which emptied the whole cache, so every material built its group again inside the bake and again after it; and it created and destroyed a depth and a multisampled colour texture per call, a view per face, and for each probe it completed a view and a bind group per level and face of its blur and convolution. A bounce bake binds exactly what the frame binds, so the groups are now rebuilt only when the environment they hold changes, and the targets are made once and kept.\n\nMeasured on the native host, where every object costs most: a courtyard re-baking its grid as the sun moved drew 63 frames a second with 28% of its CPU in `createBindGroup`; it draws 171 now, against 187 with the hour held. The published scenes are pixel-identical."
260
+ }
261
+ ]
262
+ },
263
+ {
264
+ "version": "4.3.0",
265
+ "date": "2026-09-21",
266
+ "entries": [
267
+ {
268
+ "kind": "added",
269
+ "short": "RendererApi.snapTextCellSize, so a caller can draw a bitmap glyph on whole device pixels and measure the size it will actually get.",
270
+ "text": "**The snap 4.1.4 imposed and 4.2.0 made opt-in, now reachable from where text is actually laid out.**\n\n`deviceSnappedCellSize` has been exported since 4.2.0, but it takes the ratio as two widths and the second of them is the renderer's own drawing buffer. `RendererApi` carries `cssWidth` and `cssHeight` and nothing about device pixels, and a game's text layout is a pure function over CSS pixels — so a consumer that wanted whole-pixel strokes had to thread a canvas down through its whole interface layer to reach a number the renderer was already holding. Every consumer on this engine sizes text that way, so the export alone was not adoptable.\n\n```js\nconst cell = renderer.snapTextCellSize(available / cells, viewportWidth);\nconst width = textWidthPx(label, cell);\nconst x = (boxWidth - width) / 2;\nrenderer.drawText(text, viewportWidth, viewportHeight, x, y, { ...style, cellSize: cell }, now);\n```\n\n`viewportWidth` is the caller's own and it already passes it to `drawText`. Snap once on the line the cell is computed, and the width, the centring, the right edge and the row pitch all follow from the one number — the measurement and the picture cannot disagree, which is the property the imposed version could never have.\n\nThe decision stays in `textLayout.ts` with only the binding per backend: the cell drawn is the largest whole number of device pixels not larger than the one asked for, downward so a line fitted to a box can only leave a gap rather than overflow it, and a cell already whole comes back untouched. **What it gives up** is that a caller who does not ask still gets strokes of two widths on a fractional cell, which is the trade 4.2.0 chose deliberately.\n\nPinned on the shared surface by a test rather than left to the `Omit` derivation, for the reason `api.test.ts` records about the resolution governor: a public member is otherwise on the surface by accident of not being in the omit list, which is a poor way to make a promise."
271
+ }
272
+ ]
273
+ },
2
274
  {
3
275
  "version": "4.2.1",
4
276
  "date": "2026-09-21",
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The runtime everything needs: renderer, loop, camera, input, geometry. Collision and dynamics live in [`@driftengine/physics`](../physics/README.md), which this package depends on and re-exports whole, so reaching for them through this barrel works as it always did.
4
4
 
5
- **Cost: 721.2 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
5
+ **Cost: 789.3 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
6
6
  3% — the number is a fact about the build rather than a claim in a document.
7
7
 
8
8
  Part of [DriftEngine](../../README.md). See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md)
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A camera move through world space: an eye, a point it looks at and a field of view, keyed in time.
3
+ *
4
+ * **Not the cinematic player's shots, which frame a subject.** A shot there is placed relative to
5
+ * something that moves, and its keyframes are linear on purpose because a rig damps them. A walk
6
+ * through a building has no subject: it is a route and a gaze, both in world coordinates, and it
7
+ * has to arrive at each key without a kink or the motion reads as a hand-held stumble.
8
+ *
9
+ * **A cubic Hermite through every key, its tangent the key's neighbours' difference over their
10
+ * time apart**, which is Catmull-Rom with the keys' own times as knots. A key is passed exactly, the
11
+ * velocity through it is continuous, and an evenly timed straight run is exactly a constant
12
+ * velocity. What it gives up is continuity of acceleration — the curvature can jump at a key — and
13
+ * what would make that wrong is a move slow and wide enough for the jump to be seen as a change of
14
+ * pull. The answer then is keys further apart, not another curve.
15
+ *
16
+ * The eye and the target are interpolated separately, so a pan and a dolly are written as what they
17
+ * are. Allocation-free after construction: `sampleCameraPath` fills a caller-owned sample.
18
+ */
19
+ import type { Vec3 } from '../math/color.ts';
20
+ export interface CameraPathKey {
21
+ readonly atSec: number;
22
+ readonly eye: Readonly<Vec3>;
23
+ readonly target: Readonly<Vec3>;
24
+ readonly fovDeg: number;
25
+ }
26
+ export interface CameraPathOptions {
27
+ /**
28
+ * Whether the move wraps back through its first key. A looped path needs `periodSec`: when the
29
+ * first key comes round again, which must be later than the last key.
30
+ */
31
+ readonly loop: boolean;
32
+ readonly periodSec?: number;
33
+ }
34
+ export interface CameraPath {
35
+ readonly loop: boolean;
36
+ /** For a loop, the period; for an open path, the time of the last key. */
37
+ readonly durationSec: number;
38
+ readonly count: number;
39
+ readonly times: Float64Array;
40
+ readonly values: Float64Array;
41
+ /** Per key and channel, units a second. */
42
+ readonly tangents: Float64Array;
43
+ }
44
+ export interface CameraPathSample {
45
+ eye: Vec3;
46
+ target: Vec3;
47
+ fovDeg: number;
48
+ }
49
+ export declare function createCameraPathSample(): CameraPathSample;
50
+ /** Check the keys and work out every tangent once. Throws at construction, never in a frame. */
51
+ export declare function createCameraPath(keys: readonly CameraPathKey[], options: CameraPathOptions): CameraPath;
52
+ /** The camera at `timeSec`. An open path holds its ends; a looped one wraps. */
53
+ export declare function sampleCameraPath(path: CameraPath, timeSec: number, out: CameraPathSample): CameraPathSample;
@@ -0,0 +1,118 @@
1
+ /** Seven channels a key: eye, target, field of view. */
2
+ const CHANNELS = 7;
3
+ export function createCameraPathSample() {
4
+ return { eye: [0, 0, 0], target: [0, 0, 0], fovDeg: 50 };
5
+ }
6
+ /** Check the keys and work out every tangent once. Throws at construction, never in a frame. */
7
+ export function createCameraPath(keys, options) {
8
+ const count = keys.length;
9
+ if (count < 2)
10
+ throw new Error('a camera path needs at least two keys to move between');
11
+ const times = new Float64Array(count);
12
+ const values = new Float64Array(count * CHANNELS);
13
+ for (let i = 0; i < count; i++) {
14
+ const key = keys[i];
15
+ if (i > 0 && !(key.atSec > times[i - 1])) {
16
+ throw new Error(`camera path key ${i} at ${key.atSec}s must be later than the one before`);
17
+ }
18
+ times[i] = key.atSec;
19
+ values.set([...key.eye, ...key.target, key.fovDeg], i * CHANNELS);
20
+ }
21
+ const first = times[0];
22
+ const last = times[count - 1];
23
+ let durationSec = last;
24
+ if (options.loop) {
25
+ const period = options.periodSec ?? 0;
26
+ if (!(first + period > last)) {
27
+ throw new Error(`a looped camera path's period ${period}s must end after its last key`);
28
+ }
29
+ durationSec = period;
30
+ }
31
+ const tangents = new Float64Array(count * CHANNELS);
32
+ for (let i = 0; i < count; i++) {
33
+ let before = i - 1;
34
+ let after = i + 1;
35
+ let tBefore = before >= 0 ? times[before] : 0;
36
+ let tAfter = after < count ? times[after] : 0;
37
+ if (options.loop) {
38
+ if (before < 0) {
39
+ before = count - 1;
40
+ tBefore = times[before] - durationSec;
41
+ }
42
+ if (after >= count) {
43
+ after = 0;
44
+ tAfter = times[0] + durationSec;
45
+ }
46
+ }
47
+ else {
48
+ /* An open end has one neighbour, and its tangent is the straight line to it. */
49
+ if (before < 0) {
50
+ before = i;
51
+ tBefore = times[i];
52
+ }
53
+ if (after >= count) {
54
+ after = i;
55
+ tAfter = times[i];
56
+ }
57
+ }
58
+ const span = tAfter - tBefore;
59
+ for (let c = 0; c < CHANNELS; c++) {
60
+ const delta = values[after * CHANNELS + c] - values[before * CHANNELS + c];
61
+ tangents[i * CHANNELS + c] = delta / span;
62
+ }
63
+ }
64
+ return { loop: options.loop, durationSec, count, times, values, tangents };
65
+ }
66
+ /** The camera at `timeSec`. An open path holds its ends; a looped one wraps. */
67
+ export function sampleCameraPath(path, timeSec, out) {
68
+ const { times, values, tangents, count } = path;
69
+ const first = times[0];
70
+ let t = timeSec;
71
+ if (path.loop) {
72
+ t = first + ((((timeSec - first) % path.durationSec) + path.durationSec) % path.durationSec);
73
+ }
74
+ else if (t <= first) {
75
+ return write(out, values, 0);
76
+ }
77
+ else if (t >= times[count - 1]) {
78
+ return write(out, values, count - 1);
79
+ }
80
+ let i = 0;
81
+ while (i < count - 1 && times[i + 1] <= t)
82
+ i++;
83
+ const next = i + 1 < count ? i + 1 : 0;
84
+ const t0 = times[i];
85
+ const t1 = next === 0 ? first + path.durationSec : times[next];
86
+ const h = t1 - t0;
87
+ const u = (t - t0) / h;
88
+ const u2 = u * u;
89
+ const u3 = u2 * u;
90
+ const h00 = 2 * u3 - 3 * u2 + 1;
91
+ const h10 = u3 - 2 * u2 + u;
92
+ const h01 = -2 * u3 + 3 * u2;
93
+ const h11 = u3 - u2;
94
+ for (let c = 0; c < CHANNELS; c++) {
95
+ const v = h00 * values[i * CHANNELS + c] +
96
+ h10 * h * tangents[i * CHANNELS + c] +
97
+ h01 * values[next * CHANNELS + c] +
98
+ h11 * h * tangents[next * CHANNELS + c];
99
+ if (c < 3)
100
+ out.eye[c] = v;
101
+ else if (c < 6)
102
+ out.target[c - 3] = v;
103
+ else
104
+ out.fovDeg = v;
105
+ }
106
+ return out;
107
+ }
108
+ function write(out, values, key) {
109
+ const at = key * CHANNELS;
110
+ out.eye[0] = values[at];
111
+ out.eye[1] = values[at + 1];
112
+ out.eye[2] = values[at + 2];
113
+ out.target[0] = values[at + 3];
114
+ out.target[1] = values[at + 4];
115
+ out.target[2] = values[at + 5];
116
+ out.fovDeg = values[at + 6];
117
+ return out;
118
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A day's lighting as a palette keyed by one number, and an exposure that follows it in time.
3
+ *
4
+ * **Keyed by any scalar a caller chooses.** A courtyard keys it by the sun's elevation in degrees,
5
+ * because what the light does there is a function of how high the sun stands; a scene that lerps
6
+ * between a night and a day by a day factor keys it at 0 and 1 and gets exactly the blend it had.
7
+ * The palette is data — colours, a density, a strength, an exposure — so which hours exist and what
8
+ * they look like stay the scene's, and the mechanism is the engine's.
9
+ *
10
+ * **Linear between keys, and in stops for exposure.** Colours and densities blend linearly, which
11
+ * is what every hand-written day cycle in this repository did; exposure blends in log2, because two
12
+ * keys a stop apart should pass through half a stop and not through the arithmetic mean, which sits
13
+ * nearer the brighter key. What that gives up is smoothness at a key: the blend has a corner there.
14
+ * What would make it wrong is a palette so sparse that the corner shows as a change of pace in the
15
+ * sky, and the answer then is more keys rather than a curve.
16
+ *
17
+ * Nothing here allocates after construction: `resolveDaylight` writes into a caller-owned state.
18
+ */
19
+ import type { Vec3 } from '../math/color.ts';
20
+ /** The lighting at one point of the palette's scalar. Every colour is linear and may exceed one. */
21
+ export interface DaylightKey {
22
+ /** Where on the palette's scalar this key sits: a sun elevation, a day factor, an hour. */
23
+ readonly at: number;
24
+ /** The sun's radiance at the surface it lights, which is the directional light while it is up. */
25
+ readonly sunColor: Readonly<Vec3>;
26
+ /** The moon's, for whichever part of the palette the moon lights. */
27
+ readonly moonColor: Readonly<Vec3>;
28
+ readonly skyTop: Readonly<Vec3>;
29
+ readonly skyHorizon: Readonly<Vec3>;
30
+ readonly skyDeep: Readonly<Vec3>;
31
+ /** The hemisphere's sky half. */
32
+ readonly ambient: Readonly<Vec3>;
33
+ /** The hemisphere's ground half: what the floor throws back up. */
34
+ readonly ambientGround: Readonly<Vec3>;
35
+ readonly fogColor: Readonly<Vec3>;
36
+ readonly fogDensity: number;
37
+ /** How dark a directional shadow is, 0 to 1. */
38
+ readonly shadowStrength: number;
39
+ /** The emissive master gain: how strongly lamps and flames read against this light. */
40
+ readonly emissiveGain: number;
41
+ /** The exposure a viewer's eye settles on here. Positive; blended in stops. */
42
+ readonly exposure: number;
43
+ }
44
+ /** Keys in ascending order of `at`, validated once. Build with `createDaylightPalette`. */
45
+ export interface DaylightPalette {
46
+ readonly keys: readonly DaylightKey[];
47
+ }
48
+ /** One moment of the palette, written in place by `resolveDaylight`. */
49
+ export interface DaylightState {
50
+ sunColor: Vec3;
51
+ moonColor: Vec3;
52
+ skyTop: Vec3;
53
+ skyHorizon: Vec3;
54
+ skyDeep: Vec3;
55
+ ambient: Vec3;
56
+ ambientGround: Vec3;
57
+ fogColor: Vec3;
58
+ fogDensity: number;
59
+ shadowStrength: number;
60
+ emissiveGain: number;
61
+ exposure: number;
62
+ }
63
+ /** Sort and check the keys. Throws at construction, so a bad palette never reaches a frame. */
64
+ export declare function createDaylightPalette(keys: readonly DaylightKey[]): DaylightPalette;
65
+ export declare function createDaylightState(): DaylightState;
66
+ /** The palette at `at`, into `out`. Past either end the nearest key holds. */
67
+ export declare function resolveDaylight(at: number, palette: DaylightPalette, out: DaylightState): DaylightState;
68
+ /**
69
+ * `current` moved toward `target` over `dtSec`, closing half the distance in stops every
70
+ * `halfLifeSec`.
71
+ *
72
+ * **In time, not per frame**, so a 144 Hz display adapts exactly as fast as a 60 Hz one: the
73
+ * remaining distance after any number of steps is `2^(−elapsed / halfLife)` of what it was, however
74
+ * the elapsed time was cut up. A half-life of zero is a cut to the target. Both exposures must be
75
+ * positive, which is what an exposure is.
76
+ */
77
+ export declare function easeExposure(current: number, target: number, dtSec: number, halfLifeSec: number): number;