@driftengine/core 4.12.0 → 4.13.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.
Files changed (207) hide show
  1. package/CHANGELOG.json +77 -0
  2. package/README.md +18 -9
  3. package/dist/geometry/meshBuilder.d.ts +10 -0
  4. package/dist/geometry/meshBuilder.js +11 -2
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +3 -2
  7. package/dist/math/color.d.ts +25 -0
  8. package/dist/math/color.js +37 -0
  9. package/dist/render/backend/webgl2/renderer.d.ts +17 -4
  10. package/dist/render/backend/webgl2/renderer.js +82 -47
  11. package/dist/render/backend/webgl2/skinScatter.d.ts +3 -1
  12. package/dist/render/backend/webgl2/skinScatter.js +5 -1
  13. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  14. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  15. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  16. package/dist/render/backend/webgpu/flockPass.d.ts +1 -1
  17. package/dist/render/backend/webgpu/giFieldPass.d.ts +3 -1
  18. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +10 -1
  19. package/dist/render/backend/webgpu/gpuDrivenPass.js +28 -11
  20. package/dist/render/backend/webgpu/panelPass.d.ts +1 -1
  21. package/dist/render/backend/webgpu/postPass.d.ts +2 -2
  22. package/dist/render/backend/webgpu/renderer.d.ts +19 -8
  23. package/dist/render/backend/webgpu/renderer.js +94 -38
  24. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  25. package/dist/render/backend/webgpu/skinScatterPass.d.ts +5 -1
  26. package/dist/render/backend/webgpu/skinScatterPass.js +9 -3
  27. package/dist/render/backend/webgpu/skyPass.d.ts +11 -1
  28. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  29. package/dist/render/backend/webgpu/windStreakPass.d.ts +1 -1
  30. package/dist/render/causticsRenderer.d.ts +3 -1
  31. package/dist/render/causticsRenderer.js +5 -1
  32. package/dist/render/decalPass.d.ts +3 -1
  33. package/dist/render/decalPass.js +4 -1
  34. package/dist/render/flockRenderer.d.ts +1 -1
  35. package/dist/render/flockRenderer.js +4 -1
  36. package/dist/render/globalMediumPass.d.ts +3 -1
  37. package/dist/render/globalMediumPass.js +5 -1
  38. package/dist/render/gradeColor.d.ts +21 -0
  39. package/dist/render/gradeColor.js +64 -0
  40. package/dist/render/lightMatrix.d.ts +13 -0
  41. package/dist/render/lightMatrix.js +17 -0
  42. package/dist/render/passGrade.d.ts +13 -0
  43. package/dist/render/passGrade.js +47 -0
  44. package/dist/render/plumeRenderer.d.ts +1 -1
  45. package/dist/render/plumeRenderer.js +4 -1
  46. package/dist/render/renderQuality.d.ts +57 -18
  47. package/dist/render/renderQuality.js +3 -3
  48. package/dist/render/sceneTarget.d.ts +2 -0
  49. package/dist/render/sceneTarget.js +1 -0
  50. package/dist/render/sdfTextRenderer.d.ts +1 -1
  51. package/dist/render/sdfTextRenderer.js +1 -1
  52. package/dist/render/shaders/arcane.d.ts +1 -1
  53. package/dist/render/shaders/arcane.js +5 -1
  54. package/dist/render/shaders/bolt.d.ts +1 -1
  55. package/dist/render/shaders/bolt.js +4 -1
  56. package/dist/render/shaders/caustics.d.ts +1 -1
  57. package/dist/render/shaders/caustics.js +4 -1
  58. package/dist/render/shaders/decalProject.js +12 -2
  59. package/dist/render/shaders/film.d.ts +1 -1
  60. package/dist/render/shaders/film.js +22 -9
  61. package/dist/render/shaders/fire.d.ts +1 -1
  62. package/dist/render/shaders/fire.js +4 -1
  63. package/dist/render/shaders/flat/layerLooks.js +11 -19
  64. package/dist/render/shaders/flat/layered.js +33 -18
  65. package/dist/render/shaders/flock.d.ts +1 -1
  66. package/dist/render/shaders/flock.js +4 -1
  67. package/dist/render/shaders/generated/arcane.wgsl.d.ts +11 -1
  68. package/dist/render/shaders/generated/arcane.wgsl.js +11 -1
  69. package/dist/render/shaders/generated/bolt.wgsl.d.ts +12 -2
  70. package/dist/render/shaders/generated/bolt.wgsl.js +12 -2
  71. package/dist/render/shaders/generated/caustics.wgsl.d.ts +12 -2
  72. package/dist/render/shaders/generated/caustics.wgsl.js +12 -2
  73. package/dist/render/shaders/generated/decalProject.wgsl.d.ts +6 -1
  74. package/dist/render/shaders/generated/decalProject.wgsl.js +6 -1
  75. package/dist/render/shaders/generated/film.wgsl.d.ts +12 -2
  76. package/dist/render/shaders/generated/film.wgsl.js +12 -2
  77. package/dist/render/shaders/generated/fire.wgsl.d.ts +11 -1
  78. package/dist/render/shaders/generated/fire.wgsl.js +11 -1
  79. package/dist/render/shaders/generated/flat.wgsl.js +23 -23
  80. package/dist/render/shaders/generated/flock.wgsl.d.ts +12 -2
  81. package/dist/render/shaders/generated/flock.wgsl.js +12 -2
  82. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +12 -2
  83. package/dist/render/shaders/generated/globalMedium.wgsl.js +12 -2
  84. package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +36 -16
  85. package/dist/render/shaders/generated/lightVolume.wgsl.js +39 -19
  86. package/dist/render/shaders/generated/panel.wgsl.d.ts +12 -2
  87. package/dist/render/shaders/generated/panel.wgsl.js +12 -2
  88. package/dist/render/shaders/generated/rush.wgsl.d.ts +31 -26
  89. package/dist/render/shaders/generated/rush.wgsl.js +31 -26
  90. package/dist/render/shaders/generated/scatter.wgsl.d.ts +12 -2
  91. package/dist/render/shaders/generated/scatter.wgsl.js +12 -2
  92. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +11 -1
  93. package/dist/render/shaders/generated/skinBlur.wgsl.js +11 -1
  94. package/dist/render/shaders/generated/sky.wgsl.d.ts +12 -2
  95. package/dist/render/shaders/generated/sky.wgsl.js +12 -2
  96. package/dist/render/shaders/generated/smoke.wgsl.d.ts +11 -1
  97. package/dist/render/shaders/generated/smoke.wgsl.js +11 -1
  98. package/dist/render/shaders/generated/text.wgsl.d.ts +11 -1
  99. package/dist/render/shaders/generated/text.wgsl.js +11 -1
  100. package/dist/render/shaders/generated/water.wgsl.d.ts +12 -2
  101. package/dist/render/shaders/generated/water.wgsl.js +12 -2
  102. package/dist/render/shaders/generated/windStreaks.wgsl.d.ts +12 -2
  103. package/dist/render/shaders/generated/windStreaks.wgsl.js +12 -2
  104. package/dist/render/shaders/globalMedium.js +14 -1
  105. package/dist/render/shaders/gpudriven/present.wgsl.d.ts +15 -6
  106. package/dist/render/shaders/gpudriven/present.wgsl.js +71 -8
  107. package/dist/render/shaders/lightVolume.js +4 -1
  108. package/dist/render/shaders/line.d.ts +1 -1
  109. package/dist/render/shaders/outputTransform.d.ts +8 -2
  110. package/dist/render/shaders/outputTransform.js +18 -11
  111. package/dist/render/shaders/panel.d.ts +1 -1
  112. package/dist/render/shaders/panel.js +5 -1
  113. package/dist/render/shaders/particle.d.ts +3 -3
  114. package/dist/render/shaders/rush.js +8 -0
  115. package/dist/render/shaders/scatter.d.ts +1 -1
  116. package/dist/render/shaders/scatter.js +4 -1
  117. package/dist/render/shaders/sdfText.d.ts +1 -1
  118. package/dist/render/shaders/skinBlur.js +5 -1
  119. package/dist/render/shaders/sky.d.ts +1 -1
  120. package/dist/render/shaders/sky.js +5 -1
  121. package/dist/render/shaders/smoke.d.ts +1 -1
  122. package/dist/render/shaders/smoke.js +4 -1
  123. package/dist/render/shaders/text.d.ts +1 -1
  124. package/dist/render/shaders/text.js +4 -1
  125. package/dist/render/shaders/water.d.ts +1 -1
  126. package/dist/render/shaders/water.js +12 -2
  127. package/dist/render/shaders/windStreaks.d.ts +1 -1
  128. package/dist/render/shaders/windStreaks.js +4 -1
  129. package/dist/render/surfaceLayerLooks.d.ts +16 -4
  130. package/dist/render/surfaceLayerLooks.js +33 -14
  131. package/dist/render/surfaceLayers.d.ts +2 -2
  132. package/dist/render/textRenderer.d.ts +3 -1
  133. package/dist/render/textRenderer.js +7 -1
  134. package/dist/render/vertexDefaults.d.ts +1 -1
  135. package/dist/render/vertexDefaults.js +1 -1
  136. package/dist/render/waterRenderer.d.ts +1 -1
  137. package/dist/render/waterRenderer.js +4 -1
  138. package/dist/render/windStreakRenderer.d.ts +3 -1
  139. package/dist/render/windStreakRenderer.js +5 -1
  140. package/package.json +6 -6
  141. package/src/geometry/meshBuilder.ts +21 -2
  142. package/src/index.ts +11 -2
  143. package/src/math/color.ts +39 -0
  144. package/src/render/backend/webgl2/renderer.ts +134 -44
  145. package/src/render/backend/webgl2/skinScatter.ts +5 -0
  146. package/src/render/backend/webgpu/giFieldPass.ts +3 -1
  147. package/src/render/backend/webgpu/gpuDrivenPass.ts +29 -13
  148. package/src/render/backend/webgpu/renderer.ts +104 -37
  149. package/src/render/backend/webgpu/skinScatterPass.ts +17 -1
  150. package/src/render/causticsRenderer.ts +5 -0
  151. package/src/render/decalPass.ts +3 -0
  152. package/src/render/flockRenderer.ts +5 -0
  153. package/src/render/globalMediumPass.ts +10 -1
  154. package/src/render/gradeColor.ts +90 -0
  155. package/src/render/lightMatrix.ts +32 -0
  156. package/src/render/passGrade.ts +58 -0
  157. package/src/render/plumeRenderer.ts +5 -0
  158. package/src/render/renderQuality.ts +60 -21
  159. package/src/render/sceneTarget.ts +3 -0
  160. package/src/render/sdfTextRenderer.ts +1 -1
  161. package/src/render/shaders/arcane.ts +5 -1
  162. package/src/render/shaders/bolt.ts +4 -1
  163. package/src/render/shaders/caustics.ts +4 -1
  164. package/src/render/shaders/decalProject.ts +12 -2
  165. package/src/render/shaders/film.ts +22 -9
  166. package/src/render/shaders/fire.ts +4 -1
  167. package/src/render/shaders/flat/layerLooks.ts +11 -19
  168. package/src/render/shaders/flat/layered.ts +33 -18
  169. package/src/render/shaders/flock.ts +4 -1
  170. package/src/render/shaders/generated/arcane.wgsl.ts +11 -1
  171. package/src/render/shaders/generated/bolt.wgsl.ts +12 -2
  172. package/src/render/shaders/generated/caustics.wgsl.ts +12 -2
  173. package/src/render/shaders/generated/decalProject.wgsl.ts +6 -1
  174. package/src/render/shaders/generated/film.wgsl.ts +12 -2
  175. package/src/render/shaders/generated/fire.wgsl.ts +11 -1
  176. package/src/render/shaders/generated/flat.wgsl.ts +23 -23
  177. package/src/render/shaders/generated/flock.wgsl.ts +12 -2
  178. package/src/render/shaders/generated/globalMedium.wgsl.ts +12 -2
  179. package/src/render/shaders/generated/lightVolume.wgsl.ts +39 -19
  180. package/src/render/shaders/generated/panel.wgsl.ts +12 -2
  181. package/src/render/shaders/generated/rush.wgsl.ts +31 -26
  182. package/src/render/shaders/generated/scatter.wgsl.ts +12 -2
  183. package/src/render/shaders/generated/skinBlur.wgsl.ts +11 -1
  184. package/src/render/shaders/generated/sky.wgsl.ts +12 -2
  185. package/src/render/shaders/generated/smoke.wgsl.ts +11 -1
  186. package/src/render/shaders/generated/text.wgsl.ts +11 -1
  187. package/src/render/shaders/generated/water.wgsl.ts +12 -2
  188. package/src/render/shaders/generated/windStreaks.wgsl.ts +12 -2
  189. package/src/render/shaders/globalMedium.ts +14 -1
  190. package/src/render/shaders/gpudriven/present.wgsl.ts +72 -8
  191. package/src/render/shaders/lightVolume.ts +4 -1
  192. package/src/render/shaders/outputTransform.ts +19 -11
  193. package/src/render/shaders/panel.ts +5 -1
  194. package/src/render/shaders/rush.ts +8 -0
  195. package/src/render/shaders/scatter.ts +4 -1
  196. package/src/render/shaders/skinBlur.ts +5 -1
  197. package/src/render/shaders/sky.ts +5 -1
  198. package/src/render/shaders/smoke.ts +4 -1
  199. package/src/render/shaders/text.ts +4 -1
  200. package/src/render/shaders/water.ts +12 -2
  201. package/src/render/shaders/windStreaks.ts +4 -1
  202. package/src/render/surfaceLayerLooks.ts +49 -18
  203. package/src/render/surfaceLayers.ts +2 -2
  204. package/src/render/textRenderer.ts +6 -0
  205. package/src/render/vertexDefaults.ts +1 -1
  206. package/src/render/waterRenderer.ts +5 -0
  207. package/src/render/windStreakRenderer.ts +5 -0
package/CHANGELOG.json CHANGED
@@ -1,4 +1,81 @@
1
1
  [
2
+ {
3
+ "version": "4.13.1",
4
+ "date": "2026-10-11",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "A sprite slot can take one painted rectangle instead of its whole image, so an atlas painted a picture at a time stops sending all of itself: `SpritePass.updateTexture`.",
9
+ "text": "`SpritePass.updateTexture(slot, source, region)` copies one rectangle of an image into the slot's texture at the same place and keeps the rest of it. `setTexture` sends the whole image and, on WebGPU, makes the texture and its bind group again, so a 2D layer painted into atlases sent every texel of an atlas for each picture painted into it: a consumer measured about 100 ms for a 5120-texel side, on a frame whose new pictures covered about 6 of the atlas's 26 million texels, and a typical later frame changes a timer's digits. Now an upload grows with what was painted rather than with the atlas. The slot keeps the options it was made with; a slot that is empty, or was made from an image of another size, takes the whole image as `setTexture` would; the rectangle is clipped to the image; and a slot with a mip chain has the chain rebuilt. On WebGL2 it is `texSubImage2D` with the unpack state put back after, since every other upload on the context reads it. **Paint such an atlas into a 2D context made with `willReadFrequently: true`**: on WebGPU a canvas drawn on the GPU is transferred whole before any part of it is copied, measured at 66 ms for one 256 by 64 rectangle of a 5120-texel atlas against 0.4 ms from a canvas kept in memory. WebGL2 copies a rectangle quickly from either. `SpriteRegion` is the rectangle's type."
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "version": "4.13.0",
15
+ "date": "2026-10-10",
16
+ "entries": [
17
+ {
18
+ "kind": "changed",
19
+ "short": "The frame is encoded for the screen by default, every pass and the clear colour alike: `outputTransform` is `'srgb'`, so a model reads as it does elsewhere.",
20
+ "text": "`outputTransform` defaults to `'srgb'`. The light the renderer computes is linear, and a texture's colours are decoded to linear when they are read, so a frame written without encoding them back, which `'none'`, the default until now, did, reads every midtone far too dark: a textured model drawn from the manual with no quality options came out darker than the same file in any other engine, and the cause looks like lighting, so that is where people looked. **Every pass now grades where nothing after it will, and the clear colour with them**, so a colour handed to the renderer means one thing wherever it lands: the sky, water, scatter, the plumes, flocks, wind streaks, bolts, caustics, light volumes and film grade themselves, as the lit pass, particles, lines and world text already did; the decal multiply, the global medium, the skin blur's add and the resolve's occlusion work in the terms of a pixel already graded; and the gpu-driven pipeline's blit grades instead of warning. Until now, without `hdrScene`, those passes drew their colours as given beside a graded world, so a sky never met the fog fading into it. `'srgb'` is the display encoding with no tone curve; `'aces'` with an `outputExposure` still rolls highlights off. A world graded under `'none'` says so and draws exactly as it did, bit for bit, and a frame with `hdrScene` is graded once at the resolve exactly as before. What changes for a world on another curve without `hdrScene` is that its sky and water are graded with it, and its interface is encoded for the screen, as the next entry says."
21
+ },
22
+ {
23
+ "kind": "added",
24
+ "short": "Colours picked by eye are stated as such: `srgbColor(r, g, b)`, `MeshBuilder.build({ colorSpace: 'srgb' })` and `decodeSrgbInPlace`, each decoding to the linear values the renderer draws.",
25
+ "text": "A colour picked by eye, on a screen or in a paint program, is a display value, and the renderer draws linear ones, so typed in as it looks it comes out too light. Three ways say which it is. `srgbColor(r, g, b)` decodes one colour through the sRGB curve, so a surface lit at 1, a sky or a clear colour comes out as the colour picked. `MeshBuildOptions.colorSpace: 'srgb'` decodes every vertex colour and emissive colour of a built mesh once, at build, as a texture uploaded as sRGB is decoded at upload, so a world built from hand-picked colours changes one line. `decodeSrgbInPlace` decodes an array of them, leaving a negative sentinel alone, for a mesh written by hand. `srgbToLinear` decodes one channel, for a colour that arrives a number at a time, as a packed hex token does. A light's intensity is an amount rather than a look and stays as it is. The examples state their colours this way, and the starter, which a new project begins from, shows the default."
26
+ },
27
+ {
28
+ "kind": "changed",
29
+ "short": "Text, panels and the 2D layer are encoded for the screen and never tone mapped, so an interface comes out as picked under any transform and at any exposure.",
30
+ "text": "Screen text, `fillPanel` and `@driftengine/ui2d`'s 2D layer take the screen encode and never the curve or the exposure: `interfaceGrade` says which code that is, for a contributed pass that draws an interface. A caption or a panel is not light, and its colour was picked for the screen, so under `'aces'` a menu's white text came out grey, its dark panel crushed to black, and the whole interface brightened and darkened as the scene's exposure eased. Now every interface colour stated through `srgbColor`, or as a theme's hex token, comes out exactly as picked under every transform. Inside an `hdrScene` frame an interface is part of the picture the resolve tone maps, as it was; drawn after `endFrame`, it is not. Text drawn in the world at a model matrix is part of the world and keeps the curve."
31
+ },
32
+ {
33
+ "kind": "fixed",
34
+ "short": "A pass drawn after `endFrame` grades itself under an `hdrScene` composite too, where it drew its colours as display values beside a graded frame.",
35
+ "text": "With `hdrScene` the resolve grades the frame and every pass before it draws linear light. A pass drawn after `endFrame` lands on the canvas the resolve has already written, so nothing after it grades it, and it used to draw ungraded there: an interface drawn after the present, which is where the manual puts one to keep it out of the screen-space chain, was the one surface on screen reading its colours as display values. The decision is now one function both backends read, and past the present it answers the frame's own grade, composite or not."
36
+ },
37
+ {
38
+ "kind": "changed",
39
+ "short": "A colour picked for the screen is decoded where an interface takes one: ui2d's hex tokens, and a script's `ui.tint` and `sprites.tinted`.",
40
+ "text": "`unpackRgba` and `themeRgba` decode a packed `0xRRGGBBAA` token from the sRGB a hex colour is written in to the linear light a node holds, keeping its alpha as coverage. Read as linear, `0x336699` came out as `0x7caacb` under the new default, every panel a washed-out version of the one picked. DriftScript's `ui.tint` and `sprites.tinted` take a colour picked for the screen in the same terms and decode it, since a script has no `srgbColor` to say so, so a white sprite draws exactly the tint named. A renderer asked for `outputTransform: 'none'` draws these darker than picked, as it does every colour."
41
+ },
42
+ {
43
+ "kind": "changed",
44
+ "short": "A splat capture's colour is a display value, decoded after its view-dependent band, so a trained capture draws as it does in the tool that made it.",
45
+ "text": "A Gaussian capture is trained against photographs, so its colour terms reproduce their pixels drawn as they are. `SplatSource.colors` was documented as linear and drawn so, which under the new `'srgb'` default is a washed-out cloud. The splat shader now decodes a splat's colour after adding its l=1 band, which training added in the same space, and the eight bits a colour is packed into are spent where the eye sees them. What it gives up is the blend: splats compose in linear light rather than in the display values training composed them in, which moves a translucent edge a little. `@driftengine/capture` fits in linear light against decoded frames and now hands its colours over encoded, its l=1 band carried through the encode's slope at each colour, so a fitted capture draws as fitted."
46
+ },
47
+ {
48
+ "kind": "changed",
49
+ "short": "Sun shadows no longer dissolve past six metres or below 18° of sun: `directionalShadowMaxDistance` defaults to 1,000 and `directionalShadowMaxSlope` to 60.",
50
+ "text": "`directionalShadowMaxDistance` defaults to 1,000 metres and `directionalShadowMaxSlope` to 60. The old 6 and 3 were a tuning for short casters: any sun shadow longer than six metres along the ground dissolved, so a building's walls and arcades cast nothing on its floor under a high sun and the light read as random, and every sun shadow faded below about 18° of elevation, which is an evening without shadows. A shadow now reaches as far as its map holds, whose edge already fades it, and holds until the sun is about a degree above the horizon. The reach is a shadow's own length along the ground and not a distance from the camera, which its name suggests, and both options now say what their default gives up and what would make it wrong. A scene whose low sun streaks lowers the slope; one tuned around the old values sets them. The medium's shafts and a surface's shadows, which disagreed past six metres, now agree."
51
+ },
52
+ {
53
+ "kind": "added",
54
+ "short": "`DrftLoader` draws, casts and prepares its model: `draw()`, `casters` and `prepare()` apply every rule a container needs, and `PartDraws` does it for a chosen list.",
55
+ "text": "`DrftLoader.draw()` draws this frame's parts into the open mesh pass by every rule a baked container needs, each of which fails as a picture rather than an error when it is missed: a part's whole material, so foliage keeps its alpha test and a one-sided card its faces; its copies through one instanced draw, where a part drawn as a plain mesh shows one arch of a repeated row; a blended part translucent whatever its opacity and writing no depth, since its alpha is in its texture, where drawn opaque a grime decal is a black patch; a blended batch through the translucent instanced path; glass as glass, its options kept per pane so a frame allocates nothing; and each part's own reflectivity, with nothing left set for the caller's draws after it. `loader.casters` is a ready `ShadowCasters`: every part but a decal, which would shadow its own surface, each with its material so a cutout casts its shape, glass as glass and copies as copies. `await loader.prepare()` compiles every pipeline the parts draw with, off the frame, translucent where they will be drawn so. `PartDraws` is the same for any list of parts, several loaders' or a filtered one. The manual's chapter on importing models and the models example draw through them."
56
+ },
57
+ {
58
+ "kind": "added",
59
+ "short": "`computeLightMatrixForBounds` covers a box with the sun's map whole, and `DrftLoader.shadowFit` fits the map to a model as placed, from the bounds its file declares.",
60
+ "text": "`computeLightMatrixForBounds(lightDir, min, max, mapSize, out)` places a directional light's map around a box of known size: the sphere around the box, at its centre, widened by the half texel the centre's snap may move it, so a corner on the sphere stays inside. A focus and a radius chosen by eye around a guessed centre left part of a model outside the map, where it cast nothing. `DrftLoader.shadowFit(lightDir, mapSize, out)` does it for the model as fitted and returns the depth span for `shadowDepthSpan`, or null until `HEAD` has said how large the model is. What both give up is resolution on a large box, whose texels are spread over all of it; a world that follows a player keeps `computeLightMatrix` around the viewer."
61
+ },
62
+ {
63
+ "kind": "added",
64
+ "short": "`meshOcclusion` darkens the colour and the ambient light at once, each at its own strength: `colorStrength` beside `into: 'ambient'`, past 1 where a material asks.",
65
+ "text": "`SurfaceMeshOcclusion.colorStrength` is the colour's own strength: the blended colour times `max(0, 1 − colorStrength × (1 − occlusion))`, which up to 1 is the `mix` a strength always gave and past it darkens faster than the occlusion does. Named beside `into: 'ambient'`, one occlusion darkens the ambient light at `strength` and the colour at `colorStrength`, which is how a material that takes its ambient occlusion from a map and darkens its colour by the same map, at up to twice that, is drawn; with a single target it drew one of the two. Absent, it is `strength` where the colour is the target and 0 where the ambient light is, so every material written for 4.12.0 draws as it did. The two floats that carried a strength and a target now carry the two strengths."
66
+ },
67
+ {
68
+ "kind": "fixed",
69
+ "short": "A summed layer blend reflects the surface's own specular once where its layers name none, rather than its own times the sum of the layers' shares.",
70
+ "text": "Under `blend: 'sum'` a layer's share is its weight, and the shares add past 1 where the weights do, which is what a summed blend is for in colour. The specular took the same sum: a layer that named none gave the surface's own times its share, so a surface painted to weights adding to 2.5 or 3 reflected two and a half to three times its own specular, measured as a sandy floor 2.5% brighter across a frame than with its own. The layers that name no specular now give the surface's own at most once, whatever their shares add to, and named speculars still add as their colours do. A blend laid over, whose shares never pass 1, draws exactly as before, as does any layered material while no material has asked for looks."
71
+ },
72
+ {
73
+ "kind": "fixed",
74
+ "short": "A reflection probe stays ungraded when its bake's callback binds the mesh pass, so the frame no longer grades what it reads from the probe a second time.",
75
+ "text": "A probe stores radiance, so its bake holds the output transform at 0 while it draws the faces. `bindMeshPass`, which every bake's callback calls first, set the frame's grade again, so under any forward grade each face was graded as it was drawn and the frame graded what it read back from the cube a second time: reflections and probe ambient lifted and flattened. Every pass, the mesh pass among them, now asks one gate, which answers the frame's grade except in a probe's face or a capture. The same gate keeps particles, lines, text and contributed passes ungraded inside a bake."
76
+ }
77
+ ]
78
+ },
2
79
  {
3
80
  "version": "4.12.0",
4
81
  "date": "2026-10-10",
package/README.md CHANGED
@@ -6,7 +6,7 @@ WebGL2 everywhere else, behind one API, and carries the fixed-step loop, the ren
6
6
  input, the scene graph, geometry, text and the whole of `@driftengine/physics`, which it re-exports.
7
7
  Everything else is a separate package a game adds when it reaches for it.
8
8
 
9
- **Cost: 910.1 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
9
+ **Cost: 920.9 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
10
10
  which fails the build if the figure drifts by more than 3%.
11
11
 
12
12
  The manual is at **[driftengine.dev/docs](https://driftengine.dev/docs)**, with an example running
@@ -35,6 +35,7 @@ import {
35
35
  SceneNode,
36
36
  createEnvironment,
37
37
  createRenderer,
38
+ srgbColor,
38
39
  startLoop,
39
40
  } from '@driftengine/core';
40
41
  import { bindModule } from '@driftengine/script';
@@ -60,15 +61,23 @@ if (readout !== null) readout.textContent = `${backend}: ${reason}`;
60
61
  renderer.resize();
61
62
  addEventListener('resize', () => renderer.resize());
62
63
 
63
- /** Light, air and ground bounce. Chosen once, because these are allocation-sized decisions. */
64
+ /**
65
+ * Light, air and ground bounce. Chosen once, because these are allocation-sized decisions.
66
+ *
67
+ * Every colour here and below was picked by eye, so it goes through `srgbColor`: the renderer
68
+ * lights in linear values and encodes them for the screen at the end, and a colour typed as it
69
+ * looks would come out too light. The clear colour in the loop is one of them.
70
+ */
71
+ const CLEAR = srgbColor(0.05, 0.06, 0.09);
72
+
64
73
  const ENV = createEnvironment({
65
74
  directionalDir: [0.4, 0.7, 0.35],
66
- directionalColor: [1, 0.96, 0.88],
67
- ambient: [0.2, 0.22, 0.28],
68
- ambientGround: [0.08, 0.08, 0.1],
75
+ directionalColor: srgbColor(1, 0.96, 0.88),
76
+ ambient: srgbColor(0.2, 0.22, 0.28),
77
+ ambientGround: srgbColor(0.08, 0.08, 0.1),
69
78
  emissiveGain: 0,
70
79
  nightFactor: 0,
71
- fogColor: [0.16, 0.18, 0.24],
80
+ fogColor: srgbColor(0.16, 0.18, 0.24),
72
81
  fogDensity: 0.004,
73
82
  fogHeightFalloff: 0.03,
74
83
  fogBaseY: 0,
@@ -80,11 +89,11 @@ const ENV = createEnvironment({
80
89
  * is two units on a side.
81
90
  */
82
91
  const shapes = new MeshBuilder();
83
- shapes.addBox([0, 1, 0], [1, 1, 1], [0.85, 0.45, 0.25]);
92
+ shapes.addBox([0, 1, 0], [1, 1, 1], srgbColor(0.85, 0.45, 0.25));
84
93
  const cube = renderer.createMesh(shapes.build());
85
94
 
86
95
  const slab = new MeshBuilder();
87
- slab.addBox([0, -0.25, 0], [12, 0.25, 12], [0.3, 0.32, 0.36]);
96
+ slab.addBox([0, -0.25, 0], [12, 0.25, 12], srgbColor(0.3, 0.32, 0.36));
88
97
  const ground = renderer.createMesh(slab.build());
89
98
 
90
99
  const spinner = new SceneNode();
@@ -136,7 +145,7 @@ startLoop({
136
145
 
137
146
  camera.updateMatrices(canvas.height > 0 ? canvas.width / canvas.height : 1);
138
147
 
139
- renderer.beginFrame([0.05, 0.06, 0.09]);
148
+ renderer.beginFrame(CLEAR);
140
149
  renderer.bindMeshPass(camera, ENV);
141
150
  renderer.drawMesh(ground, stillness.worldMatrix);
142
151
  renderer.drawMesh(cube, spinner.worldMatrix);
@@ -10,6 +10,16 @@ export interface MeshBuildOptions {
10
10
  * sample them. On, for a mesh that will be drawn with a `SurfaceTexture`.
11
11
  */
12
12
  planarUvs?: boolean;
13
+ /**
14
+ * The space the builder's colours were given in. `'linear'`, the default, is the renderer's own:
15
+ * light as a surface reflects it. `'srgb'` is a colour picked by eye, on a screen or in a paint
16
+ * program, and decodes every vertex's colour and emissive colour to linear here, once — as a
17
+ * texture of colours uploaded as sRGB is decoded — so a world built from hand-picked colours draws
18
+ * them as picked under the default `outputTransform`, where the same numbers taken as linear come
19
+ * out pale. What it gives up: nothing at the colour picked; a colour meant as light rather than as
20
+ * a look wants a linear number of its own.
21
+ */
22
+ colorSpace?: 'linear' | 'srgb';
13
23
  }
14
24
  /**
15
25
  * CPU-side accumulator that merges axis-aligned boxes into one static mesh —
@@ -1,4 +1,5 @@
1
1
  import { GrowableF32, GrowableU32 } from './growable.js';
2
+ import { decodeSrgbInPlace } from '../math/color.js';
2
3
  /**
3
4
  * The emissive colour of a vertex that has not been given one: negative, which no real
4
5
  * colour can be, so "inherit the surface's albedo" is representable without a second
@@ -914,14 +915,22 @@ export class MeshBuilder {
914
915
  /* Same promise as `specular`: a mesh nothing bound uploads no skinning buffers, and
915
916
  `MeshData` requires both arrays or neither. */
916
917
  const skinned = this.anyJoint;
918
+ /* Colours picked by eye, decoded once here: see `MeshBuildOptions.colorSpace`. */
919
+ const picked = options.colorSpace === 'srgb';
917
920
  return {
918
921
  positions: this.positions.toTyped(),
919
922
  normals: this.normals.toTyped(),
920
- colors: this.colors.toTyped(),
923
+ colors: picked ? decodeSrgbInPlace(this.colors.toTyped()) : this.colors.toTyped(),
921
924
  emissive: this.emissive.toTyped(),
922
925
  ...(shines ? { specular: this.specular.toTyped() } : {}),
923
926
  ...(textured ? { relief: this.relief.toTyped() } : {}),
924
- ...(tinted ? { emissiveColor: this.emissiveColor.toTyped() } : {}),
927
+ ...(tinted
928
+ ? {
929
+ emissiveColor: picked
930
+ ? decodeSrgbInPlace(this.emissiveColor.toTyped())
931
+ : this.emissiveColor.toTyped(),
932
+ }
933
+ : {}),
925
934
  ...(rough ? { roughness: this.roughness.toTyped() } : {}),
926
935
  ...(mineral ? { grain: this.grain.toTyped() } : {}),
927
936
  ...(skinned ? { joints: this.expandedJoints(), weights: this.expandedWeights() } : {}),
package/dist/index.d.ts CHANGED
@@ -38,7 +38,7 @@ export type { CelestialSite, CelestialState } from './environment/celestialClock
38
38
  export { createDaylightPalette, createDaylightState, easeExposure, resolveDaylight, } from './environment/daylight.ts';
39
39
  export type { DaylightKey, DaylightPalette, DaylightState } from './environment/daylight.ts';
40
40
  export { TAU, DEG_TO_RAD, clamp, lerp, smoothstep, damp, dampTracking, angleDelta, dampAngle, wrapAngle, lerpAngle, } from './math/scalar.ts';
41
- export { hslToRgb, mixColor, mixColorInto, scaleColor } from './math/color.ts';
41
+ export { decodeSrgbInPlace, hslToRgb, mixColor, mixColorInto, scaleColor, srgbColor, srgbToLinear, } from './math/color.ts';
42
42
  export type { Vec3 } from './math/color.ts';
43
43
  export { Camera } from './render/camera.ts';
44
44
  export { ThirdPersonCamera } from './render/thirdPersonCamera.ts';
@@ -250,7 +250,7 @@ export type { LightGrid } from './render/lightGrid.ts';
250
250
  export type { PointLightBuffer, PointLightSource } from './render/pointLightSelection.ts';
251
251
  export { createFlame, flameFrequencyHz, updateFlame } from './render/flameLight.ts';
252
252
  export type { Flame, FlameOptions } from './render/flameLight.ts';
253
- export { computeLightMatrix } from './render/lightMatrix.ts';
253
+ export { computeLightMatrix, computeLightMatrixForBounds } from './render/lightMatrix.ts';
254
254
  export { PlumeRenderer } from './render/plumeRenderer.ts';
255
255
  export { createEnvironment } from './render/backend/webgl2/renderer.ts';
256
256
  export type { InsetRect } from './render/backend/webgl2/renderer.ts';
@@ -264,6 +264,7 @@ export type { LoadTask, LoadSummary } from './core/loadTracker.ts';
264
264
  export { buildShellFill, shellSkin, shellFillMatrix, DEFAULT_SHELL_PROFILE, } from './geometry/shellFill.ts';
265
265
  export type { ShellStation, ShellFillOptions } from './geometry/shellFill.ts';
266
266
  export { OUTPUT_TRANSFORM_GLSL } from './render/shaders/outputTransform.ts';
267
+ export { interfaceGrade } from './render/passGrade.ts';
267
268
  export { PLUME_VERT, FIRE_FRAG } from './render/shaders/fire.ts';
268
269
  export { ARCANE_FRAG } from './render/shaders/arcane.ts';
269
270
  export { SMOKE_FRAG } from './render/shaders/smoke.ts';
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ export { MessageQueue } from './core/messageQueue.js';
35
35
  export { celestialStateAt, createCelestialState, moonIllumination, } from './environment/celestialClock.js';
36
36
  export { createDaylightPalette, createDaylightState, easeExposure, resolveDaylight, } from './environment/daylight.js';
37
37
  export { TAU, DEG_TO_RAD, clamp, lerp, smoothstep, damp, dampTracking, angleDelta, dampAngle, wrapAngle, lerpAngle, } from './math/scalar.js';
38
- export { hslToRgb, mixColor, mixColorInto, scaleColor } from './math/color.js';
38
+ export { decodeSrgbInPlace, hslToRgb, mixColor, mixColorInto, scaleColor, srgbColor, srgbToLinear, } from './math/color.js';
39
39
  export { Camera } from './render/camera.js';
40
40
  export { ThirdPersonCamera } from './render/thirdPersonCamera.js';
41
41
  export { CinematicCamera } from './render/cinematicCamera.js';
@@ -305,7 +305,7 @@ export { describeGpu } from './render/gpuCapability.js';
305
305
  export { DEFAULT_POINT_LIGHT_VIEW_RANGE, createPointLightBuffer, selectGridLights, selectPointLights, } from './render/pointLightSelection.js';
306
306
  export { LIGHT_GRID_MAX_CELLS, createLightGrid, gatherLights } from './render/lightGrid.js';
307
307
  export { createFlame, flameFrequencyHz, updateFlame } from './render/flameLight.js';
308
- export { computeLightMatrix } from './render/lightMatrix.js';
308
+ export { computeLightMatrix, computeLightMatrixForBounds } from './render/lightMatrix.js';
309
309
  export { PlumeRenderer } from './render/plumeRenderer.js';
310
310
  export { createEnvironment } from './render/backend/webgl2/renderer.js';
311
311
  export { TrampleField, TRAMPLE_SLOTS } from './render/trample.js';
@@ -337,6 +337,7 @@ export { buildShellFill, shellSkin, shellFillMatrix, DEFAULT_SHELL_PROFILE, } fr
337
337
  * this line the only way for it to comply was to copy, which is what the rule forbids.
338
338
  */
339
339
  export { OUTPUT_TRANSFORM_GLSL } from './render/shaders/outputTransform.js';
340
+ export { interfaceGrade } from './render/passGrade.js';
340
341
  export { PLUME_VERT, FIRE_FRAG } from './render/shaders/fire.js';
341
342
  export { ARCANE_FRAG } from './render/shaders/arcane.js';
342
343
  export { SMOKE_FRAG } from './render/shaders/smoke.js';
@@ -6,3 +6,28 @@ export declare function mixColor(a: Vec3, b: Vec3, t: number): Vec3;
6
6
  /** In-place `mixColor` for per-frame use — allocating here would be a hot-path bug. */
7
7
  export declare function mixColorInto(out: Vec3, a: Vec3, b: Vec3, t: number): void;
8
8
  export declare function scaleColor(color: Vec3, factor: number): Vec3;
9
+ /**
10
+ * A colour picked by eye — on a screen, in a paint program, as a CSS colour — as the linear value
11
+ * the renderer lights, blends, fogs and grades: each channel through the sRGB curve, which the
12
+ * default `outputTransform`, `'srgb'`, applies the other way at the end, so a colour drawn as it is,
13
+ * a sky, a clear or a surface lit at exactly 1, comes out as the colour picked.
14
+ *
15
+ * **Why a colour needs it**: everything the renderer draws is linear and encoded at the end, so a
16
+ * colour typed as it looks comes out too light — a brown picked as `(0.55, 0.3, 0.15)` draws as
17
+ * tan. **What it gives up**: nothing at the colour picked. **What would make it wrong** is a number
18
+ * that is an amount rather than a look: a light's intensity, a multiplier, a strength, which are
19
+ * linear already. Allocates, so it is for building a world rather than for a frame.
20
+ */
21
+ export declare function srgbColor(r: number, g: number, b: number): Vec3;
22
+ /**
23
+ * Every value of `values` decoded from sRGB in place, but a negative one, which is a sentinel and
24
+ * not a colour (`MeshBuilder`'s emissive colour left to the albedo). For whole arrays of colours
25
+ * picked by eye; see `MeshBuildOptions.colorSpace`.
26
+ */
27
+ export declare function decodeSrgbInPlace(values: Float32Array): Float32Array;
28
+ /**
29
+ * One channel, 0 to 1, through the sRGB decode: the straight segment below 0.04045. For a colour
30
+ * that arrives as numbers per channel rather than as a triple, a packed hex token being the usual
31
+ * one; allocates nothing.
32
+ */
33
+ export declare function srgbToLinear(value: number): number;
@@ -33,3 +33,40 @@ export function mixColorInto(out, a, b, t) {
33
33
  export function scaleColor(color, factor) {
34
34
  return [color[0] * factor, color[1] * factor, color[2] * factor];
35
35
  }
36
+ /**
37
+ * A colour picked by eye — on a screen, in a paint program, as a CSS colour — as the linear value
38
+ * the renderer lights, blends, fogs and grades: each channel through the sRGB curve, which the
39
+ * default `outputTransform`, `'srgb'`, applies the other way at the end, so a colour drawn as it is,
40
+ * a sky, a clear or a surface lit at exactly 1, comes out as the colour picked.
41
+ *
42
+ * **Why a colour needs it**: everything the renderer draws is linear and encoded at the end, so a
43
+ * colour typed as it looks comes out too light — a brown picked as `(0.55, 0.3, 0.15)` draws as
44
+ * tan. **What it gives up**: nothing at the colour picked. **What would make it wrong** is a number
45
+ * that is an amount rather than a look: a light's intensity, a multiplier, a strength, which are
46
+ * linear already. Allocates, so it is for building a world rather than for a frame.
47
+ */
48
+ export function srgbColor(r, g, b) {
49
+ return [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)];
50
+ }
51
+ /**
52
+ * Every value of `values` decoded from sRGB in place, but a negative one, which is a sentinel and
53
+ * not a colour (`MeshBuilder`'s emissive colour left to the albedo). For whole arrays of colours
54
+ * picked by eye; see `MeshBuildOptions.colorSpace`.
55
+ */
56
+ export function decodeSrgbInPlace(values) {
57
+ for (let i = 0; i < values.length; i++) {
58
+ const value = values[i];
59
+ if (value >= 0)
60
+ values[i] = srgbToLinear(value);
61
+ }
62
+ return values;
63
+ }
64
+ /**
65
+ * One channel, 0 to 1, through the sRGB decode: the straight segment below 0.04045. For a colour
66
+ * that arrives as numbers per channel rather than as a triple, a packed hex token being the usual
67
+ * one; allocates nothing.
68
+ */
69
+ export function srgbToLinear(value) {
70
+ // determinism: build-time — a colour for drawing, which no tick reads
71
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
72
+ }
@@ -1304,6 +1304,8 @@ export declare class WebGL2Renderer implements RendererApi {
1304
1304
  private warnedOverlayBlocks;
1305
1305
  /** Whether a probe is being baked, so the passes that would recurse into one can tell. */
1306
1306
  private probePassActive;
1307
+ /** `clearOf`'s scratch: a clear colour as the open target takes it. */
1308
+ private readonly gradedClear;
1307
1309
  /**
1308
1310
  * Whether a scene capture is being drawn (`captureScene`): an off-screen pass like a bake, so the
1309
1311
  * frame's own view, its refraction copy and skin's spread are left alone, but lit as the frame is.
@@ -2561,7 +2563,9 @@ export declare class WebGL2Renderer implements RendererApi {
2561
2563
  * real draw call touches the unit in the first place.
2562
2564
  */
2563
2565
  /**
2564
- * The output transform a forward pass should apply, and the exposure it should apply it at.
2566
+ * The grade a pass applies to what it draws into the target open now, and the exposure it
2567
+ * applies it at. The decision is `passGrade.ts`'s, for both backends; this only gathers the
2568
+ * state it is taken from.
2565
2569
  *
2566
2570
  * **Only where that pass is genuinely the last one to touch the frame.** With a scene target
2567
2571
  * the resolve grades, so a pass that graded as well would be applying a curve to its own
@@ -2569,15 +2573,24 @@ export declare class WebGL2Renderer implements RendererApi {
2569
2573
  * `screenEffects` off there is no resolve at all and every pass writes straight to the canvas,
2570
2574
  * so each one has to grade itself or the frame comes out half converted: linear values in an
2571
2575
  * eight-bit buffer read as display values, which is a saturated, clipped version of the colour
2572
- * the caller asked for rather than a slightly different one.
2576
+ * the caller asked for rather than a slightly different one. A probe's face or a capture is
2577
+ * never graded, and past the present every pass grades itself, composite or not.
2573
2578
  *
2574
2579
  * One pair of accessors rather than the expression written out at each site, because that is
2575
2580
  * how the mesh pass and the particle pass came to disagree in the first place: the mesh pass
2576
2581
  * gated, and the particle pass uploaded the transform unconditionally to a stage that did not
2577
2582
  * yet declare it.
2578
2583
  */
2579
- private gradeCode;
2580
- private gradeExposure;
2584
+ private passGrade;
2585
+ private passGradeExposure;
2586
+ /** Whether the frame's resolve applies the transform, so the passes before it draw linear. */
2587
+ private resolveGrades;
2588
+ /**
2589
+ * A clear colour as the target open now takes it: graded as a pass would grade it, so a colour
2590
+ * handed to the renderer is linear wherever it lands — the clear too, which the resolve grades
2591
+ * with everything else under `hdrScene`. See `gradeColor.ts`. Into one scratch, read at once.
2592
+ */
2593
+ private clearOf;
2581
2594
  drawSdfText(handle: SdfTextRenderer, model: Float32Array, color: Vec3, opacity: number): void;
2582
2595
  /** Release an SDF text label. Takes no context; see `disposeText`. */
2583
2596
  disposeSdfText(handle: SdfTextRenderer): void;