ranger-compiler 3.5.0 → 3.5.2

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 (338) hide show
  1. package/CHANGELOG.md +1160 -19
  2. package/LICENSE +3 -1
  3. package/README.md +124 -29
  4. package/dist/Lang.rgr +1188 -290
  5. package/dist/api.d.ts +2343 -879
  6. package/dist/api.js +79924 -56922
  7. package/dist/lib/JSON.rgr +102 -91
  8. package/dist/lib/Shell.rgr +4 -4
  9. package/dist/lib/apple/AppleToolchain.rgr +4 -4
  10. package/dist/lib/apple/README.md +5 -4
  11. package/dist/lib/apple/apple_test.rgr +1 -1
  12. package/dist/lib/core/README.md +1 -1
  13. package/dist/lib/evg/EVG.rgr +12 -0
  14. package/dist/lib/evg/EVGA11yFromTree.rgr +302 -0
  15. package/dist/lib/evg/EVGA11yTree.rgr +894 -0
  16. package/dist/lib/evg/EVGBox.rgr +267 -0
  17. package/dist/lib/evg/EVGBoxShorthandTest.rgr +220 -0
  18. package/dist/lib/evg/EVGCodepoint.rgr +316 -0
  19. package/dist/lib/evg/EVGColor.rgr +700 -0
  20. package/dist/lib/evg/EVGCommands.rgr +177 -0
  21. package/dist/lib/evg/EVGComponent.rgr +331 -0
  22. package/dist/lib/evg/EVGComponentTest.rgr +387 -0
  23. package/dist/lib/evg/EVGConnector.rgr +541 -0
  24. package/dist/lib/evg/EVGConnectorTest.rgr +306 -0
  25. package/dist/lib/evg/EVGDisplayList.rgr +4186 -0
  26. package/dist/lib/evg/EVGEasing.rgr +370 -0
  27. package/dist/lib/evg/EVGEffectTest.rgr +253 -0
  28. package/dist/lib/evg/EVGElement.rgr +4914 -0
  29. package/dist/lib/evg/EVGFixedTest.rgr +289 -0
  30. package/dist/lib/evg/EVGFlexRulesTest.rgr +317 -0
  31. package/dist/lib/evg/EVGFlexWrapTest.rgr +246 -0
  32. package/dist/lib/evg/EVGFling.rgr +244 -0
  33. package/dist/lib/evg/EVGFocus.rgr +400 -0
  34. package/dist/lib/evg/EVGFocusTest.rgr +399 -0
  35. package/dist/lib/evg/EVGGradient.rgr +322 -0
  36. package/dist/lib/evg/EVGGrapheme.rgr +190 -0
  37. package/dist/lib/evg/EVGGrid.rgr +975 -0
  38. package/dist/lib/evg/EVGHitTest.rgr +208 -0
  39. package/dist/lib/evg/EVGHoles.rgr +294 -0
  40. package/dist/lib/evg/EVGHostMeasurerTest.rgr +281 -0
  41. package/dist/lib/evg/EVGHostTextMeasurer.rgr +331 -0
  42. package/dist/lib/evg/EVGHostTree.rgr +834 -0
  43. package/dist/lib/evg/EVGHostTreeTest.rgr +447 -0
  44. package/dist/lib/evg/EVGImageDecode.rgr +117 -0
  45. package/dist/lib/evg/EVGImageMeasurer.rgr +86 -0
  46. package/dist/lib/evg/EVGInspect.rgr +883 -0
  47. package/dist/lib/evg/EVGInvalidateTest.rgr +432 -0
  48. package/dist/lib/evg/EVGJsonTest.rgr +535 -0
  49. package/dist/lib/evg/EVGLayout.rgr +4220 -0
  50. package/dist/lib/evg/EVGMeasure.rgr +1267 -0
  51. package/dist/lib/evg/EVGOverlayTest.rgr +626 -0
  52. package/dist/lib/evg/EVGPatch.rgr +1721 -0
  53. package/dist/lib/evg/EVGPatchTest.rgr +692 -0
  54. package/dist/lib/evg/EVGPopoverTest.rgr +395 -0
  55. package/dist/lib/evg/EVGReconcile.rgr +226 -0
  56. package/dist/lib/evg/EVGReconcileTest.rgr +421 -0
  57. package/dist/lib/evg/EVGReject.rgr +126 -0
  58. package/dist/lib/evg/EVGRelayoutTest.rgr +375 -0
  59. package/dist/lib/evg/EVGRtlLayoutTest.rgr +319 -0
  60. package/dist/lib/evg/EVGRuler.rgr +232 -0
  61. package/dist/lib/evg/EVGRulerTest.rgr +172 -0
  62. package/dist/lib/evg/EVGSelectChrome.rgr +178 -0
  63. package/dist/lib/evg/EVGStyleCacheTest.rgr +419 -0
  64. package/dist/lib/evg/EVGStyleSheet.rgr +2148 -0
  65. package/dist/lib/evg/EVGStyleStateTest.rgr +407 -0
  66. package/dist/lib/evg/EVGStyleVarTest.rgr +471 -0
  67. package/dist/lib/evg/EVGText.rgr +105 -0
  68. package/dist/lib/evg/EVGTextEngine.rgr +536 -0
  69. package/dist/lib/evg/EVGTextMeasurer.rgr +720 -0
  70. package/dist/lib/evg/EVGTimingTest.rgr +1417 -0
  71. package/dist/lib/evg/EVGToolbar.rgr +1369 -0
  72. package/dist/lib/evg/EVGTransition.rgr +728 -0
  73. package/dist/lib/evg/EVGTreeJson.rgr +721 -0
  74. package/dist/lib/evg/EVGUnit.rgr +554 -0
  75. package/dist/lib/evg/EVGViewportUnitTest.rgr +236 -0
  76. package/dist/lib/evg/EvgApp.rgr +189 -0
  77. package/dist/lib/evg/EvgBitmapTracer.rgr +4277 -0
  78. package/dist/lib/evg/EvgBitmapTracerTest.rgr +2119 -0
  79. package/dist/lib/evg/EvgHost.rgr +451 -0
  80. package/dist/lib/evg/EvgTest.rgr +91 -0
  81. package/dist/lib/evg/EvgTraceColor.rgr +236 -0
  82. package/dist/lib/evg/EvgTraceCurve.rgr +648 -0
  83. package/dist/lib/evg/EvgTraceFit.rgr +767 -0
  84. package/dist/lib/evg/EvgTracePath.rgr +435 -0
  85. package/dist/lib/evg/EvgTraceTypes.rgr +559 -0
  86. package/dist/lib/evg/EvgViewport.rgr +313 -0
  87. package/dist/lib/evg/FxDemoDoc.rgr +185 -0
  88. package/dist/lib/evg/HOSTS.md +227 -0
  89. package/dist/lib/evg/ISSUES.md +835 -0
  90. package/dist/lib/evg/PLAN_ACCESSIBILITY.md +650 -0
  91. package/dist/lib/evg/PLAN_CSS_LAYOUT_AND_FONTS.md +1124 -0
  92. package/dist/lib/evg/PLAN_EFFECTS.md +431 -0
  93. package/dist/lib/evg/PLAN_EVG.md +518 -0
  94. package/dist/lib/evg/PLAN_EVG_RENDERER.md +1674 -0
  95. package/dist/lib/evg/PLAN_INSPECTOR.md +788 -0
  96. package/dist/lib/evg/PLAN_LINKS_AND_FORMS.md +157 -0
  97. package/dist/lib/evg/PLAN_NATIVE_HOSTS.md +671 -0
  98. package/dist/lib/evg/PLAN_VECTOR_IR.md +664 -0
  99. package/dist/lib/evg/PLAN_VIEW_TRANSFORM.md +322 -0
  100. package/dist/lib/evg/PathBuilder.rgr +333 -0
  101. package/dist/lib/evg/README.md +1236 -0
  102. package/dist/lib/evg/SPEC.md +1252 -0
  103. package/dist/lib/evg/SVGPathParser.rgr +1199 -0
  104. package/dist/lib/evg/SvgParser.rgr +1736 -0
  105. package/dist/lib/evg/TODO_EVG_RENDERER.md +595 -0
  106. package/dist/lib/evg/VectorShapes.rgr +331 -0
  107. package/dist/lib/evg/VectorStroke.rgr +107 -0
  108. package/dist/lib/evg/VectorViewBox.rgr +380 -0
  109. package/dist/lib/evg/agent/README.md +290 -0
  110. package/dist/lib/evg/agent/evg_agent.rgr +899 -0
  111. package/dist/lib/evg/agent/fixtures/broken.evg.json +7 -0
  112. package/dist/lib/evg/agent/fixtures/card.evg.json +9 -0
  113. package/dist/lib/evg/agent/fixtures/connector.css +63 -0
  114. package/dist/lib/evg/agent/fixtures/connector.evg.json +14 -0
  115. package/dist/lib/evg/agent/fixtures/drawn.evg.json +129 -0
  116. package/dist/lib/evg/agent/fixtures/gradient.evg.json +4 -0
  117. package/dist/lib/evg/agent/fixtures/popover.css +99 -0
  118. package/dist/lib/evg/agent/fixtures/popover.evg.json +30 -0
  119. package/dist/lib/evg/agent/roundtrip.sh +74 -0
  120. package/dist/lib/evg/agent/smoke.sh +230 -0
  121. package/dist/lib/evg/android/README.md +97 -0
  122. package/dist/lib/evg/android/androidstubs/AndroidStubs.kt +175 -0
  123. package/dist/lib/evg/android/androidstubs/Annotation.kt +10 -0
  124. package/dist/lib/evg/android/androidstubs/App.kt +30 -0
  125. package/dist/lib/evg/android/androidstubs/Content.kt +41 -0
  126. package/dist/lib/evg/android/androidstubs/ContentRes.kt +12 -0
  127. package/dist/lib/evg/android/androidstubs/InputMethod.kt +46 -0
  128. package/dist/lib/evg/android/androidstubs/Net.kt +6 -0
  129. package/dist/lib/evg/android/androidstubs/Os.kt +30 -0
  130. package/dist/lib/evg/android/androidstubs/Util.kt +15 -0
  131. package/dist/lib/evg/android/androidstubs/View.kt +113 -0
  132. package/dist/lib/evg/android/androidstubs/Widget.kt +16 -0
  133. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidEvgSurface.kt +323 -0
  134. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidTextMeasurer.kt +56 -0
  135. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/RippleEffect.kt +262 -0
  136. package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtEvgSurface.kt +238 -0
  137. package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtTextMeasurer.kt +85 -0
  138. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgEngineThread.kt +115 -0
  139. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgPainter.kt +231 -0
  140. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgSurface.kt +117 -0
  141. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/RecordingSurface.kt +94 -0
  142. package/dist/lib/evg/apple/README.md +128 -0
  143. package/dist/lib/evg/apple/Sources/CoreGraphicsEvgSurface.swift +329 -0
  144. package/dist/lib/evg/apple/Sources/CoreTextMeasurer.swift +70 -0
  145. package/dist/lib/evg/apple/Sources/EvgEngineQueue.swift +105 -0
  146. package/dist/lib/evg/apple/Sources/EvgPainter.swift +225 -0
  147. package/dist/lib/evg/apple/Sources/EvgSurface.swift +163 -0
  148. package/dist/lib/evg/apple/Sources/RecordingSurface.swift +110 -0
  149. package/dist/lib/evg/bench/EvgLayoutBench.rgr +34 -0
  150. package/dist/lib/evg/bench/README.md +64 -0
  151. package/dist/lib/evg/bench/layout-bench.mjs +316 -0
  152. package/dist/lib/evg/bench/layout-cases.mjs +510 -0
  153. package/dist/lib/evg/bench/layout-conformance.mjs +254 -0
  154. package/dist/lib/evg/bin/.gitignore +17 -0
  155. package/dist/lib/evg/evg_test.rgr +313 -0
  156. package/dist/lib/evg/gl/README.md +198 -0
  157. package/dist/lib/evg/gl/a11y-paint-check.mjs +73 -0
  158. package/dist/lib/evg/gl/blur-check.mjs +399 -0
  159. package/dist/lib/evg/gl/boxmodel.json +1 -0
  160. package/dist/lib/evg/gl/demo.html +46 -0
  161. package/dist/lib/evg/gl/effect-presets.css +285 -0
  162. package/dist/lib/evg/gl/effect-presets.js +63 -0
  163. package/dist/lib/evg/gl/effect-shots.mjs +135 -0
  164. package/dist/lib/evg/gl/evg-a11y.js +566 -0
  165. package/dist/lib/evg/gl/evg-binary.js +160 -0
  166. package/dist/lib/evg/gl/evg-engine.js +296 -0
  167. package/dist/lib/evg/gl/evg-fx.js +237 -0
  168. package/dist/lib/evg/gl/evg-gestures.js +209 -0
  169. package/dist/lib/evg/gl/evg-list.js +167 -0
  170. package/dist/lib/evg/gl/evg-measure.js +186 -0
  171. package/dist/lib/evg/gl/evg-textinput.js +303 -0
  172. package/dist/lib/evg/gl/evg-view.js +144 -0
  173. package/dist/lib/evg/gl/evg-webgl.js +3942 -0
  174. package/dist/lib/evg/gl/fx-check.mjs +998 -0
  175. package/dist/lib/evg/gl/fx-demo.html +166 -0
  176. package/dist/lib/evg/gl/fx-demo.js +2 -0
  177. package/dist/lib/evg/gl/fx-serve.mjs +47 -0
  178. package/dist/lib/evg/gl/gestures-check.mjs +189 -0
  179. package/dist/lib/evg/gl/list-binary-check.mjs +152 -0
  180. package/dist/lib/evg/gl/measure-check.mjs +135 -0
  181. package/dist/lib/evg/gl/rotation-check.mjs +207 -0
  182. package/dist/lib/evg/gl/shift-check.mjs +113 -0
  183. package/dist/lib/evg/gl/stroke-check.mjs +168 -0
  184. package/dist/lib/evg/gl/text-snap-check.mjs +139 -0
  185. package/dist/lib/evg/gl/view-check.mjs +343 -0
  186. package/dist/lib/evg/gl/view-policy-check.mjs +184 -0
  187. package/dist/lib/evg/html/evg-dom.js +318 -0
  188. package/dist/lib/evg/html/evg-html.js +600 -0
  189. package/dist/lib/evg/inspect/README.md +415 -0
  190. package/dist/lib/evg/inspect/browser-smoke.mjs +115 -0
  191. package/dist/lib/evg/inspect/evg-inspect.js +947 -0
  192. package/dist/lib/evg/inspect/shots/css.png +0 -0
  193. package/dist/lib/evg/inspect/shots/dashboard.png +0 -0
  194. package/dist/lib/evg/inspect/shots/pptx-slide.png +0 -0
  195. package/dist/lib/evg/inspect/shots/state.png +0 -0
  196. package/dist/lib/evg/inspect/shots.mjs +226 -0
  197. package/dist/lib/evg/oracle/css-blur.json +576 -0
  198. package/dist/lib/evg/oracle/css-box.json +157 -0
  199. package/dist/lib/evg/oracle/css-timing.json +709 -0
  200. package/dist/lib/evg/oracle/css_blur_oracle.mjs +529 -0
  201. package/dist/lib/evg/oracle/css_box_oracle.mjs +106 -0
  202. package/dist/lib/evg/oracle/css_timing_oracle.mjs +389 -0
  203. package/dist/lib/evg/original/EVGColor.clj +310 -0
  204. package/dist/lib/evg/original/EVGColorContext.rgr +178 -0
  205. package/dist/lib/evg/original/SVGPath.rgr +627 -0
  206. package/dist/lib/evg/original/Vec2.crgr +103 -0
  207. package/dist/lib/evg/ranger.json +9 -0
  208. package/dist/lib/evg/showcase/README.md +246 -0
  209. package/dist/lib/evg/showcase/assets/emblem.svg +30 -0
  210. package/dist/lib/evg/showcase/assets/rosette.svg +22 -0
  211. package/dist/lib/evg/showcase/build.mjs +651 -0
  212. package/dist/lib/evg/showcase/pages/album.tsx +30 -0
  213. package/dist/lib/evg/showcase/pages/boxmodel.tsx +37 -0
  214. package/dist/lib/evg/showcase/pages/cards.tsx +41 -0
  215. package/dist/lib/evg/showcase/pages/chart_api.tsx +170 -0
  216. package/dist/lib/evg/showcase/pages/charts.tsx +219 -0
  217. package/dist/lib/evg/showcase/pages/drawing.tsx +160 -0
  218. package/dist/lib/evg/showcase/pages/emoji.tsx +91 -0
  219. package/dist/lib/evg/showcase/pages/flex.tsx +46 -0
  220. package/dist/lib/evg/showcase/pages/more.tsx +332 -0
  221. package/dist/lib/evg/showcase/pages/plots.tsx +262 -0
  222. package/dist/lib/evg/showcase/pages/svg.tsx +63 -0
  223. package/dist/lib/evg/showcase/pages/tables.tsx +209 -0
  224. package/dist/lib/evg/showcase/pages/typography.tsx +43 -0
  225. package/dist/lib/evg/showcase/pages/units.tsx +35 -0
  226. package/dist/lib/evg/showcase/pages/variants.tsx +233 -0
  227. package/dist/lib/evg/showcase/pages/vector.tsx +75 -0
  228. package/dist/lib/evg/showcase/pages/views.tsx +223 -0
  229. package/dist/lib/evg/showcase/tests/chart_api_smoke.mjs +339 -0
  230. package/dist/lib/evg/showcase/tests/gl_smoke.mjs +125 -0
  231. package/dist/lib/evg/showcase/themes/chart_api-default.css +15 -0
  232. package/dist/lib/evg/showcase/themes/charts-default.css +29 -0
  233. package/dist/lib/evg/showcase/themes/drawing-default.css +35 -0
  234. package/dist/lib/evg/showcase/themes/more-default.css +128 -0
  235. package/dist/lib/evg/showcase/themes/plots-default.css +18 -0
  236. package/dist/lib/evg/showcase/themes/showcase.css +508 -0
  237. package/dist/lib/evg/showcase/themes/tables-default.css +123 -0
  238. package/dist/lib/evg/showcase/themes/variants-default.css +15 -0
  239. package/dist/lib/evg/showcase/themes/views-default.css +15 -0
  240. package/dist/lib/evg/tools/bench_vs_potrace.mjs +290 -0
  241. package/dist/lib/evg/tools/evg_image_tool.rgr +488 -0
  242. package/dist/lib/evg/tools/evg_trace_bench.rgr +114 -0
  243. package/dist/lib/evg/tools/evg_trace_cli.rgr +662 -0
  244. package/dist/lib/evg/tools/evg_trace_cpp_bench.rgr +83 -0
  245. package/dist/lib/evg/tools/run_trace_bench.sh +74 -0
  246. package/dist/lib/evg/tools/run_trace_cli_smoke.sh +128 -0
  247. package/dist/lib/evg/web/responsive/EvgResponsiveCheck.rgr +271 -0
  248. package/dist/lib/evg/web/responsive/EvgResponsiveDemo.rgr +531 -0
  249. package/dist/lib/evg/web/responsive/README.md +97 -0
  250. package/dist/lib/evg/web/responsive/build.mjs +90 -0
  251. package/dist/lib/evg/web/responsive/dom-check.mjs +169 -0
  252. package/dist/lib/evg/web/responsive/index.html +177 -0
  253. package/dist/lib/evg/web/responsive/smoke.mjs +221 -0
  254. package/dist/lib/evg/web/tools/assets-client.mjs +95 -0
  255. package/dist/lib/evg/web/tools/boot-bench.mjs +189 -0
  256. package/dist/lib/evg/web/tools/inline-assets.mjs +119 -0
  257. package/dist/lib/evg/web/tools/minify.mjs +60 -0
  258. package/dist/lib/evg/web/tracer/build.mjs +87 -0
  259. package/dist/lib/evg/web/tracer/index.html +2805 -0
  260. package/dist/lib/evg/web/tracer/sample.png +0 -0
  261. package/dist/lib/evg/web/tracer/smoke.mjs +1162 -0
  262. package/dist/lib/evgr/Cargo.lock +21 -0
  263. package/dist/lib/evgr/Cargo.toml +19 -0
  264. package/dist/lib/evgr/README.md +140 -0
  265. package/dist/lib/evgr/bench/NativeBench.rgr +246 -0
  266. package/dist/lib/evgr/bench/compare.mjs +252 -0
  267. package/dist/lib/evgr/bench/speed.mjs +277 -0
  268. package/dist/lib/evgr/bin/.gitignore +3 -0
  269. package/dist/lib/evgr/src/bin/bench.rs +106 -0
  270. package/dist/lib/evgr/src/bin/smoke.rs +13 -0
  271. package/dist/lib/evgr/src/grid.rs +730 -0
  272. package/dist/lib/evgr/src/lib.rs +1003 -0
  273. package/dist/lib/evgr/src/style.rs +468 -0
  274. package/dist/lib/evgr/src/text.rs +83 -0
  275. package/dist/lib/image/BitReader.rgr +171 -0
  276. package/dist/lib/image/Buffer.rgr +173 -0
  277. package/dist/lib/image/DCT.rgr +283 -0
  278. package/dist/lib/image/Deflate.rgr +339 -0
  279. package/dist/lib/image/HuffmanDecoder.rgr +181 -0
  280. package/dist/lib/image/ImageBuffer.rgr +706 -0
  281. package/dist/lib/image/JPEGDecoder.rgr +808 -0
  282. package/dist/lib/image/PNGDecoder.rgr +544 -0
  283. package/dist/lib/image/PNGEncoder.rgr +388 -0
  284. package/dist/lib/image/PPMImage.rgr +224 -0
  285. package/dist/lib/image/ProgressiveJPEGDecoder.rgr +1154 -0
  286. package/dist/lib/image/README.md +33 -0
  287. package/dist/lib/image/RasterBuffer.rgr +294 -0
  288. package/dist/lib/image/VP8BoolDecoder.rgr +163 -0
  289. package/dist/lib/image/WebPDecoder.rgr +433 -0
  290. package/dist/lib/image/WebPLossless.rgr +1035 -0
  291. package/dist/lib/image/WebPLossy.rgr +1929 -0
  292. package/dist/lib/image/ranger.json +9 -0
  293. package/dist/lib/image/testdata/webp/anim_2f_24x16.webp +0 -0
  294. package/dist/lib/image/testdata/webp/fixtures.txt +33 -0
  295. package/dist/lib/image/testdata/webp/gen_fixtures.py +261 -0
  296. package/dist/lib/image/testdata/webp/ll_1x1.webp +0 -0
  297. package/dist/lib/image/testdata/webp/ll_alpha_29x21.webp +0 -0
  298. package/dist/lib/image/testdata/webp/ll_gradient_64x64.webp +0 -0
  299. package/dist/lib/image/testdata/webp/ll_meta_32x32.webp +0 -0
  300. package/dist/lib/image/testdata/webp/ll_noise_17x9.webp +0 -0
  301. package/dist/lib/image/testdata/webp/ll_pal11_23x11.webp +0 -0
  302. package/dist/lib/image/testdata/webp/ll_pal2_19x7.webp +0 -0
  303. package/dist/lib/image/testdata/webp/ll_pal40_16x16.webp +0 -0
  304. package/dist/lib/image/testdata/webp/ll_pal4_13x5.webp +0 -0
  305. package/dist/lib/image/testdata/webp/ll_photo_40x30.webp +0 -0
  306. package/dist/lib/image/testdata/webp/ll_predictors_64x32.webp +0 -0
  307. package/dist/lib/image/testdata/webp/ly_1x1.webp +0 -0
  308. package/dist/lib/image/testdata/webp/ly_alph_raw_f0_21x13.webp +0 -0
  309. package/dist/lib/image/testdata/webp/ly_alph_raw_f1_21x13.webp +0 -0
  310. package/dist/lib/image/testdata/webp/ly_alph_raw_f2_21x13.webp +0 -0
  311. package/dist/lib/image/testdata/webp/ly_alph_raw_f3_21x13.webp +0 -0
  312. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f0_21x13.webp +0 -0
  313. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f1_21x13.webp +0 -0
  314. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f2_21x13.webp +0 -0
  315. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f3_21x13.webp +0 -0
  316. package/dist/lib/image/testdata/webp/ly_alpha_33x17.webp +0 -0
  317. package/dist/lib/image/testdata/webp/ly_exif_15x11.webp +0 -0
  318. package/dist/lib/image/testdata/webp/ly_i16_96x80.webp +0 -0
  319. package/dist/lib/image/testdata/webp/ly_nofilter_24x20.webp +0 -0
  320. package/dist/lib/image/testdata/webp/ly_odd_17x9.webp +0 -0
  321. package/dist/lib/image/testdata/webp/ly_photo_48x40.webp +0 -0
  322. package/dist/lib/image/testdata/webp/ly_q100_20x18.webp +0 -0
  323. package/dist/lib/image/testdata/webp/ly_sharp_40x36.webp +0 -0
  324. package/dist/lib/image/testdata/webp/ly_simple_filter_33x31.webp +0 -0
  325. package/dist/lib/image/testdata/webp/ly_vpx_parts8_37x45.webp +0 -0
  326. package/dist/lib/image/testdata/webp/ly_vpx_skip_96x80.webp +0 -0
  327. package/dist/lib/image/tests/WebPDecodeTool.rgr +81 -0
  328. package/dist/lib/image/tests/WebPDecoderTest.rgr +1162 -0
  329. package/dist/lib/image/tests/run_webp_tests.sh +39 -0
  330. package/dist/lib/rust/RsJson.rgr +468 -0
  331. package/dist/lib/rust/RsPrelude.rgr +1663 -0
  332. package/dist/lib/shell_test.rgr +7 -7
  333. package/dist/lib/stdlib.rgr +21 -5
  334. package/dist/lib/zip/ranger.json +6 -0
  335. package/dist/rgrc.js +87318 -67610
  336. package/package.json +1010 -1040
  337. package/dist/README.md +0 -117
  338. package/dist/package.json +0 -47
@@ -0,0 +1,3942 @@
1
+ /**
2
+ * evg-webgl.js — draw an EVG display list with WebGL 2.
3
+ *
4
+ * Input is what evg_displaylist_tool emits: a page size and a flat list of
5
+ * commands in absolute pixels with colours already resolved. Nothing above
6
+ * this file knows about GL, and nothing in this file knows about JSX, flex,
7
+ * grid or fonts — that is the whole point of the seam.
8
+ *
9
+ * Drawing model
10
+ * -------------
11
+ * Every command is one quad. Rounded corners and borders are done in the
12
+ * fragment shader with a signed distance function, so a rounded rect costs the
13
+ * same two triangles as a square one and needs no tessellation:
14
+ *
15
+ * sdRoundedBox(p, halfSize, r) = length(max(|p| - halfSize + r, 0)) - r
16
+ *
17
+ * A fill keeps the inside; a border keeps a band of `thickness` inside the
18
+ * edge. Antialiasing is one fwidth() smoothstep, which is why the corners come
19
+ * out clean at any radius without multisampling.
20
+ *
21
+ * Paint order
22
+ * -----------
23
+ * The list is in paint order and must stay that way, so it is split into RUNS:
24
+ * consecutive quads that share the atlas are one instanced draw, and an image
25
+ * — which needs its own texture bound — breaks the run and is drawn on its
26
+ * own. Every run reads the same instance buffers, offset to its first
27
+ * instance, because WebGL 2 has no base-instance parameter.
28
+ *
29
+ * Text
30
+ * ----
31
+ * The list carries the run, the face and the size — the positions are EVG's,
32
+ * measured from the same TTF the PDF is set in. Only the glyph *images* come
33
+ * from the platform: here a 2D canvas rasterizes each run into an atlas, which
34
+ * is what a browser gives us for free. The SDL2 backend swaps that one piece
35
+ * for the engine's own RasterText and keeps everything else.
36
+ *
37
+ * Images
38
+ * ------
39
+ * `object-fit: cover` is the only fit EVG's raster and PDF targets implement,
40
+ * and it is done here the same way they do it — by cropping, not by squashing.
41
+ * The crop is a UV rectangle computed from the source and box aspect ratios,
42
+ * so the GPU samples the covered region directly and the quad stays two
43
+ * triangles. A radius on the element clips the photo through the same distance
44
+ * field the rectangles use.
45
+ *
46
+ * Vector paths
47
+ * ------------
48
+ * A path arrives already flattened into rings of points, in page coordinates —
49
+ * the display list does the parsing and the viewBox arithmetic, so this file
50
+ * carries no path parser. A FILL is drawn stencil-then-cover: each ring goes
51
+ * into the stencil buffer as a triangle fan, then one quad over the path's
52
+ * bounding box paints every pixel the rule says is inside. That handles holes
53
+ * and both fill rules without a triangulator, and it is why the context needs a
54
+ * stencil buffer. A STROKE is expanded to a quad per segment on the CPU, which
55
+ * needs no stencil at all.
56
+ *
57
+ * Clips are scissor rectangles, intersected as they nest. Not yet: stroke
58
+ * joins and caps are butt joints, which is invisible at the widths a chart uses.
59
+ */
60
+
61
+ const KIND = {
62
+ RECT: 0, BORDER: 1, IMAGE: 2, TEXT: 3, PUSH_CLIP: 4, POP_CLIP: 5,
63
+ PATH: 6, STROKE: 7,
64
+ };
65
+
66
+ // aShape.z — what the fragment shader should do with this instance.
67
+ // TEXT paints the atlas as a coverage mask in the run's own colour, which is
68
+ // right for a glyph and wrong for a colour emoji: the browser draws 😊 in its
69
+ // own colours, and its ALPHA is the whole opaque face, so masking it with the
70
+ // run's colour gives a solid disc in the text colour. COLORTEXT samples the
71
+ // atlas's own pixels instead. Which one a run gets is decided by looking at
72
+ // what the browser actually drew — see `atlasIsColored`.
73
+ // SHADOW is the same rounded box as SHAPE, drawn softly and BEFORE it: the
74
+ // quad is grown by the blur radius so the falloff has somewhere to go, and
75
+ // the shader measures the box back down from it.
76
+ const MODE = { SHAPE: 0, TEXT: 1, IMAGE: 2, COLORTEXT: 3, SHADOW: 4 };
77
+
78
+ const VERT = `#version 300 es
79
+ in vec2 aCorner; // unit quad, 0..1
80
+ in vec4 aRect; // x, y, w, h in page pixels
81
+ in vec4 aColor; // rgba, 0..1
82
+ in vec4 aColor2; // far gradient stop, rgba 0..1
83
+ in vec3 aShape; // radius (top-left), thickness (0 = fill), mode
84
+ in vec4 aRadii; // the four corners: TL, TR, BR, BL
85
+ in float aGrad; // 0 = flat, 1 = down the box, 2 = across it
86
+ in vec4 aUV; // u0,v0,u1,v1 — atlas slot, or the image's cover crop
87
+ in float aRot; // radians
88
+ in vec3 aOrigin; // pivot x, y, and 1 when the list named one
89
+ uniform vec2 uPage;
90
+ // How far the scroll layer this instance sits in has moved since its
91
+ // buffers were built. Zero for everything outside a layer, and for a frame
92
+ // drawn the moment it was built; a fling is this changing, and nothing else.
93
+ uniform vec2 uShift;
94
+ // THE CAMERA: (sx, sy, tx, ty). Scene coordinates times the scale plus the
95
+ // translate land on the page, and the identity (1, 1, 0, 0) is the list every
96
+ // consumer drew before a view existed. It is here rather than baked into the
97
+ // coordinates so that a frame already built can be drawn somewhere else — a
98
+ // pan without a rebuild. See PLAN_VIEW_TRANSFORM.md. (No backticks in this
99
+ // comment: the shader is a JS template literal and one would end it.)
100
+ //
101
+ // The scale is uniform in every caller: setView takes one number. A
102
+ // non-uniform one would stretch the box correctly and the corner radii and
103
+ // the border wrongly, since a circle under it is an ellipse and the distance
104
+ // field below measures a circle. Nothing asks for one; a rotating camera
105
+ // would want a mat3 here and nothing asks for that either.
106
+ uniform vec4 uView;
107
+ // Device pixels per page pixel (the canvas ratio); 0 turns the text snap off.
108
+ uniform float uSnap;
109
+ out vec4 vColor;
110
+ out vec4 vColor2;
111
+ out float vGrad;
112
+ out vec2 vT; // 0..1 along the box, for the gradient
113
+ out vec2 vLocal; // position within the rect, in pixels
114
+ out vec2 vHalf;
115
+ out vec4 vRadii;
116
+ out float vThickness;
117
+ out float vMode;
118
+ out vec2 vUV;
119
+ void main() {
120
+ // In SCENE units until the last line of this block: the rotation, the pivot
121
+ // and the shift are all properties of the picture, and the camera is applied
122
+ // to the result. Rotating after the scale would be the same thing only for a
123
+ // uniform one, and doing it before is one fewer thing to be careful about.
124
+ vec2 p = aRect.xy + uShift + aCorner * aRect.zw;
125
+ // A rotated element turns about its own centre, which is what the PDF matrix
126
+ // and the raster transform both do — an axis title on its side has to land in
127
+ // the same place in all three.
128
+ if (aRot != 0.0) {
129
+ // What the quad turns ABOUT. aOrigin.z is 1 when the display list named a
130
+ // point and 0 when it did not; without one the pivot is the quad's own
131
+ // centre, which is right for a lone sideways label and wrong for a whole
132
+ // element being turned. A box, its text and its children have to share one
133
+ // pivot or they come apart -- and a text quad is sized to its ink, not to
134
+ // the line box, so its own centre is nowhere near the element's.
135
+ //
136
+ // No backticks in here: this comment lives inside a JS template literal,
137
+ // and one would end the shader source mid-word.
138
+ vec2 c = (aOrigin.z > 0.5 ? aOrigin.xy : (aRect.xy + aRect.zw * 0.5)) + uShift;
139
+ float s = sin(aRot), co = cos(aRot);
140
+ vec2 d = p - c;
141
+ p = c + vec2(d.x * co - d.y * s, d.x * s + d.y * co);
142
+ }
143
+ p = p * uView.xy + uView.zw;
144
+ // A TEXT QUAD LANDS ON WHOLE DEVICE PIXELS. Its atlas slot is a whole number
145
+ // of texels, rasterised at this scale, and the sampler filters linearly: a
146
+ // quad whose corner sits a fraction of a pixel off the grid samples every
147
+ // glyph between two texels and comes out soft -- the half-pixel blur a run
148
+ // had next to the same words in the page's own DOM. Moving the whole quad
149
+ // by less than half a pixel puts each texel on one pixel. Rotated runs are
150
+ // left alone: a turned quad has no grid to land on.
151
+ if (uSnap > 0.0 && aRot == 0.0 && (aShape.z == 1.0 || aShape.z == 3.0)) {
152
+ vec2 tl = (aRect.xy + uShift) * uView.xy + uView.zw;
153
+ p += floor(tl * uSnap + 0.5) / uSnap - tl;
154
+ }
155
+ // Page space is y-down like every 2D layout engine; clip space is y-up.
156
+ vec2 ndc = vec2((p.x / uPage.x) * 2.0 - 1.0, 1.0 - (p.y / uPage.y) * 2.0);
157
+ gl_Position = vec4(ndc, 0.0, 1.0);
158
+ vColor = aColor;
159
+ vColor2 = aColor2;
160
+ vGrad = aGrad;
161
+ vT = aCorner;
162
+ // These four are what the fragment shader measures in PAGE pixels, so the
163
+ // camera applies to them as much as to the position: a box drawn at twice
164
+ // the scale has twice the corner radius and twice the border, or it is a
165
+ // different box rather than the same one nearer.
166
+ vHalf = aRect.zw * 0.5 * uView.xy;
167
+ vLocal = (aCorner - 0.5) * aRect.zw * uView.xy;
168
+ vRadii = aRadii * uView.x;
169
+ vThickness = aShape.y * uView.x;
170
+ vMode = aShape.z;
171
+ vUV = mix(aUV.xy, aUV.zw, aCorner);
172
+ }`;
173
+
174
+ const FRAG = `#version 300 es
175
+ precision highp float;
176
+ in vec4 vColor;
177
+ in vec4 vColor2;
178
+ in float vGrad;
179
+ in vec2 vT;
180
+ in vec2 vLocal;
181
+ in vec2 vHalf;
182
+ in vec4 vRadii;
183
+ in float vThickness;
184
+ in float vMode;
185
+ in vec2 vUV;
186
+ uniform sampler2D uAtlas;
187
+ uniform sampler2D uImage;
188
+ out vec4 outColor;
189
+
190
+ // Four corners, picked by the quadrant the fragment is in. The radii arrive
191
+ // as (TL, TR, BR, BL) -- the order border-radius writes them in -- and one
192
+ // choice per axis narrows them to the corner that matters here. No backticks
193
+ // in this comment: the whole shader is a JS template literal.
194
+ float sdRoundedBox(vec2 p, vec2 b, vec4 r) {
195
+ vec2 tb = (p.x > 0.0) ? vec2(r.y, r.z) : vec2(r.x, r.w); // right : left
196
+ float rr = (p.y > 0.0) ? tb.y : tb.x; // bottom : top
197
+ vec2 q = abs(p) - b + rr;
198
+ return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - rr;
199
+ }
200
+
201
+ // The rounded-box coverage of this fragment, 0..1.
202
+ //
203
+ // fwidth() is a screen-space derivative, and it jumps across the diagonal seam
204
+ // of the triangle strip on a very large quad — which drew a visible hairline
205
+ // across the page background. Clamping keeps the edge one pixel soft no matter
206
+ // how big the quad is.
207
+ float boxCoverage(out float d) {
208
+ // The clamp stays per corner: the display list already applies the CSS
209
+ // scale-down, so this only catches a caller that hands over a radius no box
210
+ // could hold.
211
+ float lim = min(vHalf.x, vHalf.y);
212
+ vec4 r = min(vRadii, vec4(lim));
213
+ d = sdRoundedBox(vLocal, vHalf, r);
214
+ float aa = clamp(fwidth(d), 0.35, 1.5);
215
+ return smoothstep(aa, -aa, d);
216
+ }
217
+
218
+ void main() {
219
+ if (vMode > 3.5) {
220
+ // A drop shadow. The quad was grown by the blur on every side, so the
221
+ // box it belongs to is that much smaller than the quad, and the same
222
+ // corner radii still describe it. The falloff spans one blur radius
223
+ // centred on the edge, which is what CSS means by a blur radius and what
224
+ // the software canvas draws.
225
+ //
226
+ // No backticks in this comment: the shader is a JS template literal.
227
+ float b = max(vThickness, 0.0);
228
+ vec2 hb = max(vHalf - vec2(b), vec2(0.01));
229
+ float lim = min(hb.x, hb.y);
230
+ vec4 rr = min(vRadii, vec4(lim));
231
+ float sd = sdRoundedBox(vLocal, hb, rr);
232
+ // A shadow with no blur is a hard offset copy, and smoothstep over a
233
+ // zero-wide band is undefined; one pixel of antialiasing stands in.
234
+ float soft = max(b * 0.5, clamp(fwidth(sd), 0.35, 1.5));
235
+ float sa = smoothstep(soft, -soft, sd);
236
+ if (sa <= 0.002) discard;
237
+ outColor = vec4(vColor.rgb, vColor.a * sa);
238
+ return;
239
+ }
240
+ if (vMode > 2.5) {
241
+ // Text the browser drew in colours of its own — a colour emoji. The atlas
242
+ // holds the finished pixels, so they are sampled rather than reduced to a
243
+ // coverage mask; only the run's opacity still applies.
244
+ vec4 glyph = texture(uAtlas, vUV);
245
+ if (glyph.a <= 0.001) discard;
246
+ // The LETTERS in such a run were drawn in the key ink, pure magenta — see
247
+ // \`reinkColored\` — and take the run's colour here; the emoji keeps its
248
+ // own. Judged as a ratio rather than a value: linear filtering mixes an
249
+ // edge pixel with transparent black, and (0.5, 0, 0.5) is still key ink.
250
+ float hi = max(glyph.r, glyph.b);
251
+ float key = hi > 0.02
252
+ ? smoothstep(0.6, 0.9, 1.0 - (glyph.g + abs(glyph.r - glyph.b)) / hi)
253
+ : 0.0;
254
+ outColor = vec4(mix(glyph.rgb, vColor.rgb, key), glyph.a * vColor.a);
255
+ return;
256
+ }
257
+ if (vMode > 1.5) {
258
+ // Image: the UV rectangle already carries the object-fit crop, so this is
259
+ // a plain sample. The radius still applies — a photo in a rounded box is
260
+ // clipped by the same distance field the box itself is drawn with.
261
+ // A crop window may reach past the bitmap — Figma stores one whenever the
262
+ // picture is zoomed out inside its frame. CLAMP_TO_EDGE would smear the
263
+ // edge pixel across that margin; the picture simply does not cover there.
264
+ if (vUV.x < -0.001 || vUV.x > 1.001 || vUV.y < -0.001 || vUV.y > 1.001) discard;
265
+ vec4 tex = texture(uImage, vUV);
266
+ float d;
267
+ float cov = boxCoverage(d);
268
+ if (cov <= 0.001) discard;
269
+ outColor = vec4(tex.rgb, tex.a * cov);
270
+ return;
271
+ }
272
+ if (vMode > 0.5) {
273
+ // The atlas holds coverage in the alpha channel; the colour is the run's.
274
+ float cov = texture(uAtlas, vUV).a;
275
+ if (cov <= 0.001) discard;
276
+ outColor = vec4(vColor.rgb, vColor.a * cov);
277
+ return;
278
+ }
279
+ float d;
280
+ float alpha = boxCoverage(d);
281
+ // A two-stop linear gradient, mixed the way the software canvas mixes it:
282
+ // straight down the box, or straight across when the fill said so.
283
+ vec4 base = vColor;
284
+ if (vGrad > 0.5) {
285
+ base = mix(vColor, vColor2, clamp(vGrad > 1.5 ? vT.x : vT.y, 0.0, 1.0));
286
+ }
287
+ if (vThickness > 0.0) {
288
+ // Keep a band just inside the edge.
289
+ float inner = -vThickness;
290
+ float aa = clamp(fwidth(d), 0.35, 1.5);
291
+ alpha = alpha * smoothstep(inner - aa, inner + aa, d);
292
+ }
293
+ if (alpha <= 0.001) discard;
294
+ outColor = vec4(base.rgb, base.a * alpha);
295
+ }`;
296
+
297
+ // Paths are drawn as plain triangles in page space with one colour per draw:
298
+ // a fill covers its own bounding box through the stencil, a stroke is the quads
299
+ // its segments expand to.
300
+ const PATH_VERT = `#version 300 es
301
+ in vec2 aPos;
302
+ uniform vec2 uPage;
303
+ uniform vec2 uShift;
304
+ uniform vec4 uView;
305
+ out vec2 vPage;
306
+ void main() {
307
+ // BEFORE the view transform. The gradient box the fragment shader mixes
308
+ // across arrives as a uniform in the display list's own coordinates, so
309
+ // the position compared against it has to be in those too — panned and
310
+ // zoomed, the ramp would otherwise slide across the shape.
311
+ vec2 q = aPos + uShift;
312
+ vPage = q;
313
+ vec2 p = q * uView.xy + uView.zw;
314
+ vec2 ndc = vec2((p.x / uPage.x) * 2.0 - 1.0, 1.0 - (p.y / uPage.y) * 2.0);
315
+ gl_Position = vec4(ndc, 0.0, 1.0);
316
+ }`;
317
+
318
+ // One colour per draw, or two. A vector filled with a gradient is not its own
319
+ // bounding box, so the ramp cannot ride on a quad the way a rectangle's does:
320
+ // the triangles are the shape and the box the stops run across comes in as a
321
+ // uniform. uGrad is 0 for flat, 1 for down the box and 2 for across it, which
322
+ // is what the display list's own `gd` means.
323
+ const PATH_FRAG = `#version 300 es
324
+ precision highp float;
325
+ uniform vec4 uColor;
326
+ uniform vec4 uColor2;
327
+ uniform float uGrad;
328
+ uniform vec4 uBox;
329
+ in vec2 vPage;
330
+ out vec4 outColor;
331
+ void main() {
332
+ if (uGrad < 0.5) { outColor = uColor; return; }
333
+ vec2 t = (vPage - uBox.xy) / max(uBox.zw, vec2(0.001));
334
+ outColor = mix(uColor, uColor2, clamp(uGrad > 1.5 ? t.x : t.y, 0.0, 1.0));
335
+ }`;
336
+
337
+ /** The rings of a path command, as flat [x,y,…] arrays. */
338
+ function ringsOf(c) {
339
+ const out = [];
340
+ const pts = c.pts || [];
341
+ const ends = c.ends || [];
342
+ let start = 0;
343
+ for (const end of ends) {
344
+ if (end - start >= 4) out.push(pts.slice(start, end));
345
+ start = end;
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // A fan of triangles filling a disc, for a round join or a round cap. Eight
351
+ // segments at a hairline and up to thirty-two at a thick stroke: the count
352
+ // is what makes a corner look round rather than cut, and past that it is
353
+ // vertices nobody can see.
354
+ function disc(tris, cx, cy, r) {
355
+ const steps = Math.max(8, Math.min(32, Math.ceil(r * 2)));
356
+ const step = (Math.PI * 2) / steps;
357
+ for (let i = 0; i < steps; i += 1) {
358
+ const a = i * step, b = a + step;
359
+ tris.push(cx, cy, cx + Math.cos(a) * r, cy + Math.sin(a) * r, cx + Math.cos(b) * r, cy + Math.sin(b) * r);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * A stroke's triangles: a quad per segment, and the corners and ends.
365
+ *
366
+ * The quads alone leave a WEDGE OF NOTHING at every corner — two rectangles
367
+ * meeting at an angle cover the inside of the turn twice and the outside not
368
+ * at all — so a thick polyline came out as a chain of separate bars. What
369
+ * goes in the wedge is the join:
370
+ *
371
+ * round a disc at the vertex, which is also what a round cap is
372
+ * bevel the triangle between the two outer corners
373
+ * miter the point where the two outer edges cross, cut back to a bevel
374
+ * past the limit so a near-reversal does not grow a spike
375
+ *
376
+ * Every one of them only ADDS ink to what was drawn before, so a backend or
377
+ * a file that says nothing about joins keeps the picture it had and gains
378
+ * the corners it was missing.
379
+ *
380
+ * A cap is only put on an OPEN ring: a closed one has a join there instead,
381
+ * and a round cap on a closed path is a blob on the seam.
382
+ */
383
+ /**
384
+ * The rings a dash pattern leaves of a polyline.
385
+ *
386
+ * `stroke-dasharray` is a list of lengths that alternate ON and OFF, and an
387
+ * ODD list runs twice: "4" is 4 on, 4 off, and "1 2 3" is a six-entry cycle.
388
+ * That rule is not a detail — a single-entry pattern is the commonest way a
389
+ * dashed line is written, and reading it as "4 on, nothing off" draws a
390
+ * solid line and looks like the feature is missing rather than wrong.
391
+ *
392
+ * The walk is per SEGMENT and keeps its place in the pattern across
393
+ * vertices, so a dash rounds a corner the way it does in a browser instead
394
+ * of restarting at every bend.
395
+ */
396
+ export function dashRings(rings, pattern, offset) {
397
+ const pat = pattern.filter((v) => v >= 0);
398
+ const cycle = pat.reduce((a, b) => a + b, 0);
399
+ if (!pat.length || cycle <= 0) return rings;
400
+ const full = pat.length % 2 === 1 ? pat.concat(pat) : pat;
401
+ const period = full.reduce((a, b) => a + b, 0);
402
+ const out = [];
403
+ for (const ring of rings) {
404
+ // Where in the pattern this ring starts, from `stroke-dashoffset`.
405
+ let at = ((offset || 0) % period + period) % period;
406
+ let idx = 0;
407
+ while (at >= full[idx]) { at -= full[idx]; idx = (idx + 1) % full.length; }
408
+ let on = idx % 2 === 0;
409
+ let left = full[idx] - at;
410
+ let current = on ? [ring[0], ring[1]] : null;
411
+ for (let i = 0; i + 3 < ring.length; i += 2) {
412
+ let x1 = ring[i], y1 = ring[i + 1];
413
+ const x2 = ring[i + 2], y2 = ring[i + 3];
414
+ let len = Math.hypot(x2 - x1, y2 - y1);
415
+ if (len < 1e-9) continue;
416
+ const ux = (x2 - x1) / len, uy = (y2 - y1) / len;
417
+ while (len > left) {
418
+ const cx = x1 + ux * left, cy = y1 + uy * left;
419
+ if (on) {
420
+ current.push(cx, cy);
421
+ if (current.length >= 4) out.push(current);
422
+ current = null;
423
+ } else {
424
+ current = [cx, cy];
425
+ }
426
+ x1 = cx; y1 = cy;
427
+ len -= left;
428
+ on = !on;
429
+ idx = (idx + 1) % full.length;
430
+ left = full[idx];
431
+ }
432
+ left -= len;
433
+ if (on) current.push(x2, y2);
434
+ }
435
+ if (on && current && current.length >= 4) out.push(current);
436
+ }
437
+ return out;
438
+ }
439
+
440
+ /** "6 4" or "6,4" as numbers; anything that is not one is dropped. */
441
+ export function parseDash(text) {
442
+ if (!text) return [];
443
+ return String(text)
444
+ .split(/[\s,]+/)
445
+ .map((t) => parseFloat(t))
446
+ .filter((v) => Number.isFinite(v) && v >= 0);
447
+ }
448
+
449
+ export function strokeTriangles(rings, width, cap, join, scale = 1) {
450
+ // A HAIRLINE IS THREE QUARTERS OF A PIXEL, and a pixel is a screen thing.
451
+ // The floor stops a thin line from disappearing into the gaps between
452
+ // samples, so it has to be measured where the line will be DRAWN: a list
453
+ // with a camera is in scene units, and a 1.5-unit stroke on a board at 0.17
454
+ // is a quarter of a pixel. Left as a floor on the list's own units it never
455
+ // applied there, and every line in a zoomed-out diagram came out a third as
456
+ // dark as the same diagram drawn without a camera \u2014 which is what the
457
+ // rangerflow page showed the first time the two were compared.
458
+ const s = scale || 1;
459
+ const half = Math.max(width, 0.75 / s) / 2;
460
+ const miterLimit = 4;
461
+ const tris = [];
462
+ for (const ring of rings) {
463
+ const n = ring.length;
464
+ if (n < 4) continue;
465
+ const closed = Math.abs(ring[0] - ring[n - 2]) < 1e-6 && Math.abs(ring[1] - ring[n - 1]) < 1e-6;
466
+ for (let i = 0; i + 3 < n; i += 2) {
467
+ const x1 = ring[i], y1 = ring[i + 1], x2 = ring[i + 2], y2 = ring[i + 3];
468
+ let dx = x2 - x1, dy = y2 - y1;
469
+ const len = Math.hypot(dx, dy);
470
+ if (len < 1e-6) continue;
471
+ const ux = dx / len, uy = dy / len;
472
+ // The normal, scaled to half the stroke width.
473
+ const nx = -uy * half, ny = ux * half;
474
+ // A square cap is the segment run on by half a width at the end that
475
+ // has no neighbour; a butt cap stops where the geometry stops.
476
+ let ax = x1, ay = y1, bx = x2, by = y2;
477
+ if (cap === 2 && !closed) {
478
+ if (i === 0) { ax -= ux * half; ay -= uy * half; }
479
+ if (i + 4 >= n) { bx += ux * half; by += uy * half; }
480
+ }
481
+ tris.push(
482
+ ax + nx, ay + ny, bx + nx, by + ny, bx - nx, by - ny,
483
+ ax + nx, ay + ny, bx - nx, by - ny, ax - nx, ay - ny,
484
+ );
485
+ // The corner between this segment and the next.
486
+ if (i + 5 < n) {
487
+ joinAt(tris, ring, i, half, join, miterLimit);
488
+ }
489
+ }
490
+ if (closed) {
491
+ // The seam is a corner like any other: the last segment meeting the
492
+ // first. Without this a closed path is drawn with one notch in it.
493
+ // A ring too short to have a seam — a point written twice — has no
494
+ // corner either, and must not fall through to the cap: a round cap
495
+ // there is two discs of ink where the file asked for nothing.
496
+ if (n >= 6) joinSeam(tris, ring, half, join, miterLimit);
497
+ } else if (cap === 1) {
498
+ disc(tris, ring[0], ring[1], half);
499
+ disc(tris, ring[n - 2], ring[n - 1], half);
500
+ }
501
+ }
502
+ return tris;
503
+ }
504
+
505
+ /** The corner at vertex i+2, between segments (i,i+1) and (i+2,i+3). */
506
+ function joinAt(tris, ring, i, half, join, miterLimit) {
507
+ cornerTris(tris, ring[i], ring[i + 1], ring[i + 2], ring[i + 3], ring[i + 4], ring[i + 5], half, join, miterLimit);
508
+ }
509
+
510
+ /** The corner where a closed ring's last segment meets its first. */
511
+ function joinSeam(tris, ring, half, join, miterLimit) {
512
+ const n = ring.length;
513
+ cornerTris(tris, ring[n - 4], ring[n - 3], ring[0], ring[1], ring[2], ring[3], half, join, miterLimit);
514
+ }
515
+
516
+ function cornerTris(tris, x0, y0, cx, cy, x2, y2, half, join, miterLimit) {
517
+ if (join === 1) {
518
+ disc(tris, cx, cy, half);
519
+ return;
520
+ }
521
+ const d1x = cx - x0, d1y = cy - y0;
522
+ const d2x = x2 - cx, d2y = y2 - cy;
523
+ const l1 = Math.hypot(d1x, d1y), l2 = Math.hypot(d2x, d2y);
524
+ if (l1 < 1e-6 || l2 < 1e-6) return;
525
+ const u1x = d1x / l1, u1y = d1y / l1;
526
+ const u2x = d2x / l2, u2y = d2y / l2;
527
+ // Which side the turn opens on: the wedge is on the outside of it.
528
+ const cross = u1x * u2y - u1y * u2x;
529
+ if (Math.abs(cross) < 1e-9) return;
530
+ const sign = cross > 0 ? -1 : 1;
531
+ const p1x = cx + -u1y * half * sign, p1y = cy + u1x * half * sign;
532
+ const p2x = cx + -u2y * half * sign, p2y = cy + u2x * half * sign;
533
+ // The bevel: the triangle between the two outer corners and the vertex.
534
+ tris.push(cx, cy, p1x, p1y, p2x, p2y);
535
+ if (join === 2) return;
536
+ // Miter: where the two offset edges cross. With unit outer normals that
537
+ // is `c + half * (n1+n2) / (1 + n1·n2)`, which needs no angle and no
538
+ // trigonometry. The denominator goes to zero as the turn approaches a
539
+ // reversal, and the limit is what stops the point growing into a spike
540
+ // as long as the page — past it the bevel already pushed is the corner.
541
+ const n1x = (p1x - cx) / half, n1y = (p1y - cy) / half;
542
+ const n2x = (p2x - cx) / half, n2y = (p2y - cy) / half;
543
+ const denom = 1 + (n1x * n2x + n1y * n2y);
544
+ if (denom < 1e-6) return;
545
+ const mx = cx + ((n1x + n2x) / denom) * half;
546
+ const my = cy + ((n1y + n2y) / denom) * half;
547
+ if (Math.hypot(mx - cx, my - cy) > miterLimit * half) return;
548
+ tris.push(p1x, p1y, mx, my, p2x, p2y);
549
+ }
550
+
551
+ function boundsOf(rings) {
552
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
553
+ for (const ring of rings) {
554
+ for (let i = 0; i + 1 < ring.length; i += 2) {
555
+ if (ring[i] < x0) x0 = ring[i];
556
+ if (ring[i] > x1) x1 = ring[i];
557
+ if (ring[i + 1] < y0) y0 = ring[i + 1];
558
+ if (ring[i + 1] > y1) y1 = ring[i + 1];
559
+ }
560
+ }
561
+ return [x0, y0, x1, y1];
562
+ }
563
+
564
+ function compile(gl, type, src) {
565
+ const s = gl.createShader(type);
566
+ gl.shaderSource(s, src);
567
+ gl.compileShader(s);
568
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
569
+ throw new Error("shader: " + gl.getShaderInfoLog(s));
570
+ }
571
+ return s;
572
+ }
573
+
574
+ /**
575
+ * Fetch and upload every distinct image the list refers to.
576
+ *
577
+ * Separate from rendering, and async, because a texture cannot be bound until
578
+ * its bytes have arrived — and the whole page would otherwise draw once with
579
+ * holes where the photos go. Call it, await it, then render.
580
+ *
581
+ * A `src` that will not load resolves to null rather than rejecting: one
582
+ * missing photo should leave a gap in the page, not an empty canvas.
583
+ */
584
+ export async function loadImages(doc, opts = {}) {
585
+ const base = opts.base || "";
586
+ const srcs = [...new Set(doc.list.cmds.filter((c) => c.k === KIND.IMAGE && c.src).map((c) => c.src))];
587
+ const out = new Map();
588
+ await Promise.all(
589
+ srcs.map(
590
+ (src) =>
591
+ new Promise((resolve) => {
592
+ const img = new Image();
593
+ img.crossOrigin = "anonymous";
594
+ img.onload = () => {
595
+ out.set(src, img);
596
+ resolve();
597
+ };
598
+ img.onerror = () => {
599
+ out.set(src, null);
600
+ resolve();
601
+ };
602
+ img.src = base + src;
603
+ })
604
+ )
605
+ );
606
+ return out;
607
+ }
608
+
609
+ /**
610
+ * Rasterize each text run into one atlas texture.
611
+ *
612
+ * A run per slot rather than a glyph per slot: EVG has already decided where
613
+ * each run sits and how wide it is, so the backend does not need to shape
614
+ * anything — it needs a picture of the run. That also keeps kerning exactly as
615
+ * EVG measured it, since the same string goes to the rasterizer whole.
616
+ */
617
+ /**
618
+ * What makes two text runs the same GLYPHS. Everything that changes the
619
+ * rasterized shape is in here and nothing else is — a run that moved, or
620
+ * changed colour, draws the same picture from the same slot.
621
+ *
622
+ * The atlas used to be keyed by the command OBJECT, which made it impossible
623
+ * to reuse between frames: a display list arrives as fresh JSON every time, so
624
+ * every object is new and every glyph was rasterized and uploaded again. Sixty
625
+ * times a second.
626
+ */
627
+
628
+ /**
629
+ * A run of display-list text, wrapped so the browser lays it out verbatim.
630
+ *
631
+ * `fillText` is not a glyph blitter: it runs the bidirectional algorithm over
632
+ * whatever it is given. That is right for logical text and wrong for ours —
633
+ * the display list is in VISUAL order already, reordered and shaped by
634
+ * OfficeText on the way in, so the browser reordered a second time and put
635
+ * every right-to-left line back into the order it was stored in. Letters
636
+ * joined correctly (shaped once) and words ran backwards (reordered twice),
637
+ * which is exactly how it was reported.
638
+ *
639
+ * The producer has to own the ordering because it is the only party that sees
640
+ * a LINE: a line that changes colour partway along becomes several text
641
+ * commands at computed x positions, and a browser asked to lay out each of
642
+ * them on its own would reorder each in isolation — the same bug one level
643
+ * down. So the fix is to stop the browser reordering: U+202D LEFT-TO-RIGHT
644
+ * OVERRIDE forces every character to level 0, and U+202C pops it.
645
+ *
646
+ * Applied to every run, not only the ones with Arabic in them. It is a no-op
647
+ * for Latin, it keeps measurement and rasterization on identical strings, and
648
+ * an unconditional rule is one a future backend cannot half-implement.
649
+ */
650
+ const LRO = "\u202D", PDF = "\u202C";
651
+ export const verbatim = (t) => LRO + t + PDF;
652
+
653
+ /** The families the browser may fall back to, after the one a command names.
654
+ *
655
+ * It matters because our own FontManager falls back per CODEPOINT across
656
+ * every loaded face — that is how an emoji, a geometric bullet or an Arabic
657
+ * letter gets drawn at all when the text face has no glyph for it — and every
658
+ * width in the layout was measured through that walk. A canvas that ends its
659
+ * font list at `sans-serif` answers those same codepoints from whatever the
660
+ * system has, so the glyphs appear (looking fine) at widths nobody measured.
661
+ *
662
+ * A page sets this to its own pool, in the order it loaded the faces, so the
663
+ * two walks agree. Default is what it always was.
664
+ */
665
+ let fallbackStack = "sans-serif";
666
+ let loadedFamilies = new Set();
667
+ export function setFontFallback(families) {
668
+ const list = (families || []).filter((f) => f && f.length);
669
+ // The same list, kept as a SET, answers a second question: is the family a
670
+ // command names one the page actually loaded? See `familyOf`.
671
+ loadedFamilies = new Set(list);
672
+ fallbackStack = list.map((f) => `"${f}"`).concat("sans-serif").join(", ");
673
+ }
674
+
675
+ /** One place that turns a TEXT command into a CSS font shorthand — it was
676
+ * written twice, once for measuring and once for rasterizing, and the two
677
+ * had to stay identical or a run was drawn at a size it was not measured at.
678
+ */
679
+ // The family less the `-Bold` suffix `effectiveFontFamily` writes for a bold
680
+ // element — a convention for the TTF measurers, and to a browser a family
681
+ // nobody has. The weight is given separately. `evg-measure.js` strips the
682
+ // same suffix, so the layout measured with the face this draws with.
683
+ //
684
+ // UNLESS the page loaded that exact family. A producer may name the FACE
685
+ // rather than a family plus a weight flag — it is the only spelling that
686
+ // survives bold-italic, since `Open Sans-Italic` plus a bold flag asks for
687
+ // `Open Sans-Italic-Bold` and gets nothing — and such a page registers
688
+ // `Open Sans-Bold` as a family of its own. Stripping the suffix there drew
689
+ // bold runs in the REGULAR face: about 7% narrow, so nothing looked bold and
690
+ // every run after one on the line sat a gap too far right, which is what a
691
+ // reader sees as a huge space in the middle of a sentence. The page's own
692
+ // pool, the one it hands `setFontFallback`, is what decides.
693
+ const familyOf = (c) => {
694
+ const f = c.font || "";
695
+ if (loadedFamilies.has(f)) return f;
696
+ return f.endsWith("-Bold") ? f.slice(0, -5) : f;
697
+ };
698
+
699
+ export function fontSpec(c, dpr) {
700
+ return `${c.italic ? "italic " : ""}${c.weight ? c.weight + " " : ""}${c.size * dpr}px "${familyOf(c)}", ${fallbackStack}`;
701
+ }
702
+
703
+ function runKey(c, dpr) {
704
+ return `${dpr}|${c.font || ""}|${c.size}|${c.weight || ""}|${c.italic ? 1 : 0}|${c.ls || 0}|${c.text}`;
705
+ }
706
+
707
+ // `letter-spacing`, set on the 2D context that measures the run and on the
708
+ // one that rasterizes it — the same value on both, or the slot is cut to a
709
+ // width the ink does not fit in. A canvas that does not know the property
710
+ // ignores the assignment and draws the run at the font's own spacing, which
711
+ // is where every run was before this and is the right thing to lose.
712
+ function applySpacing(ctx, c, dpr) {
713
+ const ls = c.ls || 0;
714
+ ctx.letterSpacing = ls ? `${ls * dpr}px` : "0px";
715
+ }
716
+
717
+ /** The runs an atlas holds: every distinct run, or — `onlyVisible` — only
718
+ * those that touch the page, when the whole list would not fit a texture. */
719
+ function atlasRuns(cmds, dpr, view, onlyVisible) {
720
+ const seen = new Set();
721
+ const runs = [];
722
+ for (const c of cmds) {
723
+ if (c.k !== KIND.TEXT || !c.text) continue;
724
+ if (onlyVisible && view) {
725
+ const w = c.w || 0, h = c.h || (c.size || 0) * 1.5;
726
+ if (c.x > view.w || c.y > view.h || c.x + w < 0 || c.y + h < 0) continue;
727
+ }
728
+ const key = runKey(c, dpr);
729
+ if (seen.has(key)) continue;
730
+ seen.add(key);
731
+ runs.push(c);
732
+ }
733
+ return runs;
734
+ }
735
+
736
+ const nextPow2 = (n) => { let p = 1; while (p < n) p *= 2; return p; };
737
+
738
+ // The empty border around a slot, so the linear filter never samples a
739
+ // neighbour's ink.
740
+ const PAD = 2;
741
+
742
+ /** One run, measured with the face it will be drawn in. */
743
+ function measureRun(ctx, c, dpr) {
744
+ ctx.font = fontSpec(c, dpr);
745
+ applySpacing(ctx, c, dpr);
746
+ const m = ctx.measureText(verbatim(c.text));
747
+ // Two different ascents, and the difference between them is the whole of
748
+ // where a run sits. `actualBoundingBox*` is the INK of these particular
749
+ // letters — "moon" has no ascender and no descender, "Ãg" has both — and
750
+ // is what the slot has to be big enough to hold. `fontBoundingBoxAscent`
751
+ // is the FACE's ascent, the same number for every string in the font, and
752
+ // is what EVG means: a TEXT command's y is the top of the line box, and
753
+ // the baseline sits one face-ascent below it. Placing the ink at y
754
+ // instead pushed every run up by the height of the empty space above its
755
+ // capitals — a couple of pixels for a caption, most of a line for a
756
+ // heading — which in a spreadsheet is text climbing out of its row.
757
+ // Rounded UP to a whole device pixel, so the baseline the run is
758
+ // rasterised on is a texel row and not a fraction of one: the draw below
759
+ // lines the quad up on the device grid, and a glyph whose baseline sat
760
+ // 0.4 texel into its row came out resampled across two rows however well
761
+ // the quad was placed. The quad is placed from this same number, so the
762
+ // baseline on the page does not move.
763
+ const asc = Math.ceil(m.actualBoundingBoxAscent || c.size * dpr * 0.8);
764
+ const desc = m.actualBoundingBoxDescent || c.size * dpr * 0.25;
765
+ const faceAsc = m.fontBoundingBoxAscent || (c.h ? c.h * dpr * 0.78 : c.size * dpr * 1.05);
766
+ // The face's DESCENT, needed for the half-leading — the line box holds the
767
+ // whole face, not just the part above the baseline.
768
+ const faceDesc = m.fontBoundingBoxDescent || c.size * dpr * 0.212;
769
+ return { c, w: Math.ceil(m.width) + PAD * 2, h: Math.ceil(asc + desc) + PAD * 2, asc, faceAsc, faceDesc };
770
+ }
771
+
772
+ /**
773
+ * The atlas is a SHELF: rows of slots, left to right, top to bottom, and a
774
+ * cursor at the end of the last row. A new run goes after the cursor or
775
+ * starts the next row. The cursor is KEPT between frames — that is what lets
776
+ * a frame add the runs it is the first to show behind the ones already there
777
+ * instead of laying every run out again.
778
+ */
779
+ function shelfOf(w, limit) {
780
+ return { w, limit, x: 0, y: 0, rowH: 0 };
781
+ }
782
+
783
+ /** Place one measured run on the shelf. False when it would pass the limit,
784
+ * and false for a run WIDER THAN THE SHELF: placed at x=0 and uploaded up to
785
+ * the texture's edge, such a run kept a slot whose u1 ran past 1.0, and
786
+ * CLAMP_TO_EDGE answered every sample beyond the edge with the last column
787
+ * of ink — a horizontal streak, in the text's colour, running on from where
788
+ * the glyphs stopped. Seen on the timeline's descriptions on a Retina Mac,
789
+ * whose face was wider than the 512px atlas the first frame's short runs had
790
+ * sized. Refusing here makes `appendRuns` hand back false, and the atlas is
791
+ * rebuilt at the width its widest run needs. */
792
+ function placeRun(shelf, m) {
793
+ if (m.w > shelf.w) return false;
794
+ if (shelf.x + m.w > shelf.w) { shelf.x = 0; shelf.y += shelf.rowH; shelf.rowH = 0; }
795
+ if (shelf.y + m.h > shelf.limit) return false;
796
+ m.x = shelf.x;
797
+ m.y = shelf.y;
798
+ shelf.x += m.w;
799
+ shelf.rowH = Math.max(shelf.rowH, m.h);
800
+ return true;
801
+ }
802
+
803
+ /** How tall a shelf's rows are so far, as a texture height. */
804
+ const shelfHeight = (shelf) => nextPow2(Math.max(64, shelf.y + shelf.rowH));
805
+
806
+ function slotOf(m, dpr, texW, texH, colored) {
807
+ return {
808
+ u0: m.x / texW, v0: m.y / texH,
809
+ u1: (m.x + m.w) / texW, v1: (m.y + m.h) / texH,
810
+ w: m.w / dpr, h: m.h / dpr, asc: m.asc / dpr, pad: PAD / dpr,
811
+ faceAsc: m.faceAsc / dpr, faceDesc: m.faceDesc / dpr,
812
+ colored,
813
+ };
814
+ }
815
+
816
+ function rasterRuns(c2, measured, dpr) {
817
+ c2.textBaseline = "alphabetic";
818
+ c2.fillStyle = "#fff";
819
+ for (const m of measured) {
820
+ c2.font = fontSpec(m.c, dpr);
821
+ applySpacing(c2, m.c, dpr);
822
+ c2.fillText(verbatim(m.c.text), m.x + PAD, m.y + PAD + m.asc);
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Draw a coloured run's cell again, its letters in the key ink.
828
+ *
829
+ * A run is one cell, so "🏀 Koripallon EM-kisat" is ONE picture: a sticker the
830
+ * browser painted in its own colours and letters it painted in the fill style,
831
+ * white. The cell reads as coloured, the shader samples its pixels as they are
832
+ * — and the letters come out white on a white box, which is how this was
833
+ * reported.
834
+ *
835
+ * The emoji ignores the fill style and the letters obey it, so painting the
836
+ * cell once more in a colour no text is — pure magenta — separates the two
837
+ * without knowing where one ends: whatever came out magenta is letters, and
838
+ * COLORTEXT gives those the run's colour.
839
+ */
840
+ const KEY_INK = "#f0f";
841
+
842
+ function reinkColored(c2, m, dpr) {
843
+ c2.clearRect(m.x, m.y, m.w, m.h);
844
+ c2.save();
845
+ c2.beginPath();
846
+ c2.rect(m.x, m.y, m.w, m.h);
847
+ c2.clip();
848
+ c2.textBaseline = "alphabetic";
849
+ c2.fillStyle = KEY_INK;
850
+ c2.font = fontSpec(m.c, dpr);
851
+ applySpacing(c2, m.c, dpr);
852
+ c2.fillText(verbatim(m.c.text), m.x + PAD, m.y + PAD + m.asc);
853
+ c2.restore();
854
+ }
855
+
856
+ /**
857
+ * Every run in the list, rasterized from scratch — what the first frame does,
858
+ * and what a frame does when the shelf is full.
859
+ *
860
+ * Packed in rows. A list with more text than one texture holds — a long feed
861
+ * of cards, most of it scrolled away — used to ask for a texture taller than
862
+ * the card allows and get nothing back, so every run drew as a solid box.
863
+ * Now the atlas widens up to the card's limit, then keeps only the runs that
864
+ * touch the page (the rest are not drawn anyway), and as a last resort drops
865
+ * what still does not fit.
866
+ */
867
+ function buildTextAtlas(cmds, dpr, view, maxTex) {
868
+ let runs = atlasRuns(cmds, dpr, view, false);
869
+ let culled = false;
870
+ const limit = Math.max(2048, maxTex || 2048);
871
+ const canvas = document.createElement("canvas");
872
+ // The atlas is READ BACK (`getImageData`, to find the ink a run actually
873
+ // drew), so it asks for a CPU-side bitmap up front. Without the flag
874
+ // Chromium keeps it on the GPU, warns "Multiple readback operations using
875
+ // getImageData are faster with the willReadFrequently attribute", and pays
876
+ // a GPU readback per call. The first getContext fixes the attributes, so
877
+ // it is said here and not at the later calls on the same canvas.
878
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
879
+ if (!runs.length) return { canvas: null, slots: new Map(), culled, shelf: shelfOf(Math.min(2048, limit), limit) };
880
+ const measure = (c) => measureRun(ctx, c, dpr);
881
+ // Every run on a shelf, however tall that makes it — the fits are judged
882
+ // after, against the limit.
883
+ const pack = (list, atlasW) => {
884
+ const shelf = shelfOf(atlasW, Infinity);
885
+ for (const m of list) {
886
+ // A run wider than the widest texture the card allows is CUT, and cut
887
+ // honestly: the slot's width is what will be rasterised, so the quad
888
+ // ends where the glyphs end instead of sampling past the texture's
889
+ // edge. `fullW` keeps the measured width across the repacks below.
890
+ if (m.fullW === undefined) m.fullW = m.w;
891
+ m.w = Math.min(m.fullW, atlasW);
892
+ placeRun(shelf, m);
893
+ }
894
+ shelf.limit = limit;
895
+ return shelf;
896
+ };
897
+ let measured = runs.map(measure);
898
+ let atlasW = Math.min(2048, nextPow2(Math.max(512, ...measured.map((m) => m.w))));
899
+ let shelf = pack(measured, atlasW);
900
+ while (shelfHeight(shelf) > limit && atlasW < limit) {
901
+ atlasW = Math.min(limit, atlasW * 2);
902
+ shelf = pack(measured, atlasW);
903
+ }
904
+ if (shelfHeight(shelf) > limit && view) {
905
+ runs = atlasRuns(cmds, dpr, view, true);
906
+ culled = true;
907
+ measured = runs.map(measure);
908
+ shelf = pack(measured, atlasW);
909
+ }
910
+ while (shelfHeight(shelf) > limit && measured.length > 1) {
911
+ measured = measured.slice(0, Math.max(1, Math.floor(measured.length / 2)));
912
+ culled = true;
913
+ shelf = pack(measured, atlasW);
914
+ }
915
+ canvas.width = atlasW;
916
+ canvas.height = Math.min(limit, shelfHeight(shelf));
917
+ const c2 = canvas.getContext("2d");
918
+ c2.clearRect(0, 0, canvas.width, canvas.height);
919
+ rasterRuns(c2, measured, dpr);
920
+ const slots = new Map();
921
+ for (const m of measured) {
922
+ const s = slotOf(m, dpr, canvas.width, canvas.height, false);
923
+ s._px = m.x; s._py = m.y; s._pw = m.w; s._ph = m.h;
924
+ s._m = m;
925
+ slots.set(runKey(m.c, dpr), s);
926
+ }
927
+ markColoredSlots(c2, slots, dpr);
928
+ return { canvas, slots, culled, shelf };
929
+ }
930
+
931
+ /** Whether a cell of pixels holds anything but white ink. Every other pixel
932
+ * each way — enough to catch a sticker, cheap enough not to matter. */
933
+ function inkIsColored(data, W, x0, y0, x1, y1) {
934
+ for (let y = y0; y < y1; y += 2) {
935
+ for (let x = x0; x < x1; x += 2) {
936
+ const i = (y * W + x) * 4;
937
+ if (data[i + 3] < 24) continue;
938
+ // White is what a masked glyph is. Anything meaningfully off it was
939
+ // painted by the browser and has to keep its own pixels.
940
+ if (data[i] < 232 || data[i + 1] < 232 || data[i + 2] < 232) return true;
941
+ }
942
+ }
943
+ return false;
944
+ }
945
+
946
+ /**
947
+ * Which atlas cells the browser drew in colours of its own.
948
+ *
949
+ * Everything here is drawn with `fillStyle = "#fff"`, so a glyph comes out
950
+ * white with the shape carried in the alpha — a coverage mask, which is what
951
+ * the text shader expects. A colour emoji ignores the fill style: the browser
952
+ * paints its own bitmap, and its alpha is the whole opaque sticker. Masked
953
+ * with the run's colour that is a solid blob — a red disc where a smiling
954
+ * face should be, which is exactly how this was reported.
955
+ *
956
+ * There is no reliable way to ask ahead of time whether a string will come out
957
+ * coloured: it depends on the codepoints, the font stack and the platform, and
958
+ * the emoji ranges alone get it wrong in both directions. So this reads back
959
+ * what was actually drawn. One `getImageData` over the whole atlas, on the
960
+ * rare frames that build one, and every fourth pixel of each cell — enough to
961
+ * catch a sticker, cheap enough not to matter.
962
+ */
963
+ export function markColoredSlots(c2, slots, dpr = 1) {
964
+ let img;
965
+ try {
966
+ img = c2.getImageData(0, 0, c2.canvas.width, c2.canvas.height);
967
+ } catch (_) {
968
+ return; // a tainted or zero-sized canvas: leave them all as masks
969
+ }
970
+ const data = img.data, W = img.width;
971
+ for (const s of slots.values()) {
972
+ const x0 = s._px | 0, y0 = s._py | 0;
973
+ const x1 = Math.min(W, x0 + Math.ceil(s._pw));
974
+ const y1 = Math.min(img.height, y0 + Math.ceil(s._ph));
975
+ s.colored = inkIsColored(data, W, x0, y0, x1, y1);
976
+ // The letters beside the sticker, in the key ink. Only where the caller
977
+ // left the measured run on the slot — a caller that did not keeps white
978
+ // letters, which is what it had before.
979
+ if (s.colored && s._m) reinkColored(c2, s._m, dpr);
980
+ delete s._px; delete s._py; delete s._pw; delete s._ph; delete s._m;
981
+ }
982
+ }
983
+
984
+ /**
985
+ * The UV rectangle that makes a source of `sw`×`sh` cover a box of `bw`×`bh`.
986
+ *
987
+ * This is `object-fit: cover`: fill the box and crop the overflow, centred,
988
+ * rather than distort the picture. Cropping in UV space means the GPU samples
989
+ * only what is shown, so the quad stays two triangles whatever the aspect
990
+ * mismatch.
991
+ */
992
+ function coverUV(sw, sh, bw, bh) {
993
+ if (!sw || !sh || !bw || !bh) return [0, 0, 1, 1];
994
+ const src = sw / sh, box = bw / bh;
995
+ if (src > box) {
996
+ const f = box / src; // source is wider: crop left and right
997
+ return [(1 - f) / 2, 0, 1 - (1 - f) / 2, 1];
998
+ }
999
+ const f = src / box; // source is taller: crop top and bottom
1000
+ return [0, (1 - f) / 2, 1, 1 - (1 - f) / 2];
1001
+ }
1002
+
1003
+ function makeTexture(gl, source) {
1004
+ const t = gl.createTexture();
1005
+ gl.bindTexture(gl.TEXTURE_2D, t);
1006
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
1007
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1008
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1009
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1010
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1011
+ return t;
1012
+ }
1013
+
1014
+ /**
1015
+ * The two programs, compiled once per context and kept.
1016
+ *
1017
+ * They used to be built on every call — two `compileShader`s, two
1018
+ * `linkProgram`s and the `getShaderParameter` / `getProgramParameter` that go
1019
+ * with them, sixty times a second. Those queries are SYNCHRONOUS: asking for
1020
+ * COMPILE_STATUS makes the CPU wait for the driver to finish the compile it
1021
+ * had every right to defer. In a Chrome profile of the code editor it was
1022
+ * 160 ms of a 440 ms window — 36% of the frame, spent recompiling two shaders
1023
+ * that had not changed since the page loaded.
1024
+ *
1025
+ * Keyed by the context, so a page with two canvases gets two sets and a
1026
+ * context that is thrown away takes its programs with it.
1027
+ */
1028
+ const PROGRAMS = new WeakMap();
1029
+ const ATLASES = new WeakMap();
1030
+ const IMAGE_TEXTURES = new WeakMap();
1031
+
1032
+ /**
1033
+ * Make room on a kept atlas: a taller bitmap with the old one drawn at its
1034
+ * top, a texture uploaded from it, and every slot's v rescaled to the new
1035
+ * height. Doubling, so it happens a handful of times in the life of a page.
1036
+ */
1037
+ function growAtlas(gl, have, texH) {
1038
+ const big = document.createElement("canvas");
1039
+ big.width = have.texW;
1040
+ big.height = texH;
1041
+ // Read back like the atlas it replaces — see `buildTextAtlas`.
1042
+ const ctx = big.getContext("2d", { willReadFrequently: true });
1043
+ ctx.clearRect(0, 0, big.width, big.height);
1044
+ ctx.drawImage(have.bitmap, 0, 0);
1045
+ const scale = have.texH / texH;
1046
+ for (const s of have.slots.values()) {
1047
+ s.v0 *= scale;
1048
+ s.v1 *= scale;
1049
+ }
1050
+ have.bitmap = big;
1051
+ have.ctx = ctx;
1052
+ have.texH = texH;
1053
+ gl.deleteTexture(have.tex);
1054
+ have.tex = makeTexture(gl, big);
1055
+ }
1056
+
1057
+ /**
1058
+ * The runs a frame shows for the first time, added behind the ones the atlas
1059
+ * already holds. Each new slot is rasterized, read back once — the read
1060
+ * serves both the colour check and the upload — and sent to the card as a
1061
+ * sub-image; nothing already on the atlas is touched. False when the shelf
1062
+ * cannot take them, which is the caller's cue to start a fresh one.
1063
+ */
1064
+ function appendRuns(gl, have, runs, dpr) {
1065
+ const measured = runs.map((c) => measureRun(have.ctx, c, dpr));
1066
+ const shelf = { ...have.shelf };
1067
+ for (const m of measured) {
1068
+ if (!placeRun(shelf, m)) return false;
1069
+ }
1070
+ const needH = Math.min(shelf.limit, shelfHeight(shelf));
1071
+ if (needH > have.texH) growAtlas(gl, have, needH);
1072
+ have.shelf = shelf;
1073
+ const c2 = have.ctx;
1074
+ rasterRuns(c2, measured, dpr);
1075
+ gl.bindTexture(gl.TEXTURE_2D, have.tex);
1076
+ for (const m of measured) {
1077
+ const w = Math.min(m.w, have.texW - m.x);
1078
+ const h = Math.min(m.h, have.texH - m.y);
1079
+ let colored = false;
1080
+ if (w > 0 && h > 0) {
1081
+ let img = null;
1082
+ try {
1083
+ img = c2.getImageData(m.x, m.y, w, h);
1084
+ } catch (_) {
1085
+ img = null; // a tainted canvas: the slot stays a mask, and is uploaded whole
1086
+ }
1087
+ if (img) {
1088
+ colored = inkIsColored(img.data, img.width, 0, 0, img.width, img.height);
1089
+ if (colored) {
1090
+ reinkColored(c2, m, dpr);
1091
+ img = c2.getImageData(m.x, m.y, w, h);
1092
+ }
1093
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, m.x, m.y, gl.RGBA, gl.UNSIGNED_BYTE, img);
1094
+ } else {
1095
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, have.bitmap);
1096
+ }
1097
+ }
1098
+ have.slots.set(runKey(m.c, dpr), slotOf(m, dpr, have.texW, have.texH, colored));
1099
+ }
1100
+ return true;
1101
+ }
1102
+
1103
+ /**
1104
+ * The text atlas for this context, ADDED TO as the page shows new text and
1105
+ * rebuilt only when it is full.
1106
+ *
1107
+ * It used to be keyed by every distinct run in the list, in order, and
1108
+ * rebuilt whole whenever that key changed. On a static page the key never
1109
+ * changed and the atlas was free; on a SCROLLING one it changed on every
1110
+ * frame, because the list is culled to the viewport and every scroll brings
1111
+ * a row in and takes one out — so every scroll frame rasterized every run on
1112
+ * the page onto a 2-D canvas, read the whole canvas back to find the colour
1113
+ * emoji, and uploaded it to the card. In a Chrome profile of a fling that
1114
+ * was a fifth of the frame, and it is what the fling stuttered on.
1115
+ *
1116
+ * Now a frame asks for the runs it holds that the atlas does not — usually
1117
+ * none, and on the frame a new row arrives, that row's — and those alone are
1118
+ * drawn, read back and uploaded. What it does NOT include is where the runs
1119
+ * are or what colour they are, because neither changes a glyph. Runs that
1120
+ * scroll away stay on the shelf, and are there when the page scrolls back;
1121
+ * when the shelf is full the atlas starts again from the runs on the screen.
1122
+ *
1123
+ * A list too big for one texture even alone falls back to the old scheme:
1124
+ * an atlas of the runs that touch the page, keyed by them and rebuilt when
1125
+ * they change. `culled` says which regime is in force.
1126
+ */
1127
+ function atlasFor(gl, cmds, dpr, view) {
1128
+ const have = ATLASES.get(gl);
1129
+ if (have && !have.culled) {
1130
+ const missing = [];
1131
+ const seen = new Set();
1132
+ for (const c of cmds) {
1133
+ if (c.k !== KIND.TEXT || !c.text) continue;
1134
+ const key = runKey(c, dpr);
1135
+ if (have.slots.has(key) || seen.has(key)) continue;
1136
+ seen.add(key);
1137
+ missing.push(c);
1138
+ }
1139
+ if (!missing.length) {
1140
+ have.rebuilt = false;
1141
+ have.added = 0;
1142
+ return have;
1143
+ }
1144
+ if (have.bitmap && appendRuns(gl, have, missing, dpr)) {
1145
+ have.rebuilt = false;
1146
+ have.added = missing.length;
1147
+ return have;
1148
+ }
1149
+ }
1150
+ const keyOf = () => atlasRuns(cmds, dpr, view, true).map((c) => runKey(c, dpr)).join("|");
1151
+ if (have && have.culled && have.key === keyOf()) {
1152
+ have.rebuilt = false;
1153
+ have.added = 0;
1154
+ return have;
1155
+ }
1156
+ if (have && have.tex) gl.deleteTexture(have.tex);
1157
+ const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE) || 2048;
1158
+ const { canvas, slots, culled, shelf } = buildTextAtlas(cmds, dpr, view, maxTex);
1159
+ const made = {
1160
+ key: culled ? keyOf() : null,
1161
+ culled,
1162
+ slots,
1163
+ shelf,
1164
+ bitmap: canvas,
1165
+ ctx: canvas ? canvas.getContext("2d") : null,
1166
+ texW: canvas ? canvas.width : 1,
1167
+ texH: canvas ? canvas.height : 1,
1168
+ // Reported in the stats: rebuilding the atlas means rasterising every run
1169
+ // on the page and uploading it, and doing that on a frame where nothing
1170
+ // about the text changed is the waste this cache exists to stop; adding
1171
+ // means the runs that were new. A test can watch them; a timing number
1172
+ // on a software GL driver cannot.
1173
+ rebuilt: true,
1174
+ added: slots.size,
1175
+ tex: canvas ? makeTexture(gl, canvas) : makeTexture(gl, new ImageData(1, 1)),
1176
+ };
1177
+ ATLASES.set(gl, made);
1178
+ return made;
1179
+ }
1180
+
1181
+ /**
1182
+ * One GL texture per image source, for the life of the context. Uploading the
1183
+ * same picture again every frame is pure cost — the code before this cache
1184
+ * both re-uploaded and leaked one texture per image per frame.
1185
+ *
1186
+ * The entry remembers WHICH image it was made from, and that is not a detail.
1187
+ * A src here is a name inside a document — `ppt/media/image1.png` — and a
1188
+ * second document names its own first picture exactly the same. Keying on the
1189
+ * name alone, the cache answered a newly opened file with the previous file's
1190
+ * textures: open one deck, open another, and slide 1 still showed the logo of
1191
+ * the deck before it. So a changed image object re-uploads, and the texture it
1192
+ * replaces is deleted rather than left on the card.
1193
+ */
1194
+ function textureCacheFor(gl, images) {
1195
+ let cache = IMAGE_TEXTURES.get(gl);
1196
+ if (!cache) {
1197
+ cache = new Map();
1198
+ IMAGE_TEXTURES.set(gl, cache);
1199
+ }
1200
+ let uploaded = 0;
1201
+ for (const [src, img] of images) {
1202
+ if (!img) continue;
1203
+ const have = cache.get(src);
1204
+ if (have && have.img === img) continue;
1205
+ if (have && have.tex) gl.deleteTexture(have.tex);
1206
+ cache.set(src, { tex: makeTexture(gl, img), w: img.naturalWidth, h: img.naturalHeight, img });
1207
+ uploaded += 1;
1208
+ }
1209
+ cache.uploaded = uploaded;
1210
+ return cache;
1211
+ }
1212
+
1213
+ /**
1214
+ * Forget every image texture on this context. A host that closes a document
1215
+ * does not have to call this — a changed image re-uploads on its own — but a
1216
+ * host that unloads one without opening another can hand the card's memory
1217
+ * back instead of holding it until the context dies.
1218
+ */
1219
+ export function dropImageTextures(gl) {
1220
+ const cache = IMAGE_TEXTURES.get(gl);
1221
+ if (!cache) return 0;
1222
+ let dropped = 0;
1223
+ for (const entry of cache.values()) {
1224
+ if (entry && entry.tex) {
1225
+ gl.deleteTexture(entry.tex);
1226
+ dropped += 1;
1227
+ }
1228
+ }
1229
+ cache.clear();
1230
+ return dropped;
1231
+ }
1232
+
1233
+ // ---------------------------------------------------------------------------
1234
+ // backdrop-filter: blur()
1235
+ // ---------------------------------------------------------------------------
1236
+ // What CSS does here is measured in `../oracle/css-blur.json`, and two of the
1237
+ // findings decide this whole implementation:
1238
+ //
1239
+ // THE KERNEL IS NOT A GAUSSIAN. `blur(r)` is the SVG filter spec's
1240
+ // three-box approximation with sigma = r — three box passes of width
1241
+ // d = floor(sigma * 3 * sqrt(2*pi) / 4 + 0.5). Fitted against a real browser
1242
+ // at three radii the three-box model is within half a luminance level
1243
+ // everywhere; a true Gaussian with the same sigma is out by up to 14. The
1244
+ // common belief is half right: the sigma is the radius, and the shape is not
1245
+ // a Gaussian.
1246
+ //
1247
+ // THE BACKDROP IS THE ELEMENT'S OWN REGION, EDGE-CLAMPED. Not the page
1248
+ // behind it: what a browser blurs is the rectangle under the element and
1249
+ // nothing outside it, with samples that fall off the edge taking the border
1250
+ // pixel's value.
1251
+ //
1252
+ // This one was got backwards first. A flat grey behind a pane comes out flat
1253
+ // to the border — no rim — and that was read as proof that the backdrop is
1254
+ // sampled past the edge. It is not proof of anything: a uniform field is
1255
+ // uniform under either rule. The case that decides is a feature that
1256
+ // straddles the border, and it is decisive: white page, black stripe
1257
+ // starting exactly at the pane's left edge, and the row across reads
1258
+ // 255 255 255 | 0 0 0 with NO ramp at the border at all — while the same
1259
+ // black/white boundary 100px further in, INSIDE the pane, gets the full
1260
+ // smooth ramp. Outside content does not enter. Edge samples clamp.
1261
+ //
1262
+ // So the copy is exactly the element's box, and CLAMP_TO_EDGE does the rest.
1263
+ // Growing the copy by the kernel's reach — which is what this did first —
1264
+ // bleeds the page into the pane's border and is visibly wrong over anything
1265
+ // that is not flat.
1266
+ //
1267
+ // Three passes across and three down, separably, ping-ponging between two
1268
+ // framebuffers: nine texture reads per pixel per axis at worst, instead of the
1269
+ // d*d of the direct form.
1270
+ const BLUR_VERT = `#version 300 es
1271
+ in vec2 aCorner;
1272
+ out vec2 vUV;
1273
+ void main() {
1274
+ vUV = aCorner;
1275
+ gl_Position = vec4(aCorner * 2.0 - 1.0, 0.0, 1.0);
1276
+ }`;
1277
+
1278
+ // One box pass along `uStep`, which is one texel across or one down. The box
1279
+ // is centred for an odd width; for an even one the spec offsets two of the
1280
+ // three passes by half a texel and widens the third, and `uOffset` carries
1281
+ // that — which is what keeps the result symmetric instead of creeping one way.
1282
+ const BLUR_FRAG = `#version 300 es
1283
+ precision highp float;
1284
+ in vec2 vUV;
1285
+ uniform sampler2D uSrc;
1286
+ uniform vec2 uStep;
1287
+ uniform float uWidth;
1288
+ uniform float uOffset;
1289
+ out vec4 outColor;
1290
+ void main() {
1291
+ // Named mid and not h-a-l-f: that is a reserved word in GLSL ES 3.00 and the
1292
+ // shader will not compile with it. And no backticks in this comment either —
1293
+ // it lives inside a template literal, and one would end the shader here.
1294
+ float mid = floor(uWidth * 0.5);
1295
+ vec4 acc = vec4(0.0);
1296
+ for (int i = 0; i < 129; i++) {
1297
+ if (float(i) >= uWidth) break;
1298
+ float k = float(i) - mid + uOffset;
1299
+ // No clamp of our own: the target is exactly the copied region, so the
1300
+ // sampler's own CLAMP_TO_EDGE is the measured rule — a sample that falls
1301
+ // off the element takes the border pixel's value.
1302
+ acc += texture(uSrc, vUV + uStep * k);
1303
+ }
1304
+ outColor = acc / uWidth;
1305
+ }`;
1306
+
1307
+ // The blurred region, drawn back over the page and clipped to the element's
1308
+ // rounded box. Three things a vertex needs, and they are three different
1309
+ // spaces, which is why this does not reuse BLUR_VERT: where the quad goes
1310
+ // (clip space), which part of the padded texture it reads (a sub-rect, since
1311
+ // the texture holds the box PLUS the kernel's reach), and where the fragment
1312
+ // sits inside the box (0..1, for the rounding).
1313
+ const BACKDROP_VERT = `#version 300 es
1314
+ in vec2 aPos;
1315
+ in vec2 aUV;
1316
+ in vec2 aCorner;
1317
+ out vec2 vUV;
1318
+ out vec2 vCorner;
1319
+ void main() {
1320
+ vUV = aUV;
1321
+ vCorner = aCorner;
1322
+ gl_Position = vec4(aPos, 0.0, 1.0);
1323
+ }`;
1324
+
1325
+ // The rounding is the same distance field the solid shapes use, so a blurred
1326
+ // pane and a plain one have the same corner.
1327
+ const BACKDROP_FRAG = `#version 300 es
1328
+ precision highp float;
1329
+ in vec2 vUV;
1330
+ in vec2 vCorner;
1331
+ uniform sampler2D uSrc;
1332
+ uniform vec2 uHalf;
1333
+ uniform float uRadius;
1334
+ out vec4 outColor;
1335
+ float sdRoundedBox(vec2 p, vec2 b, float r) {
1336
+ vec2 q = abs(p) - b + r;
1337
+ return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
1338
+ }
1339
+ void main() {
1340
+ vec2 local = (vCorner - 0.5) * uHalf * 2.0;
1341
+ float r = min(uRadius, min(uHalf.x, uHalf.y));
1342
+ float d = sdRoundedBox(local, uHalf, r);
1343
+ float aa = clamp(fwidth(d), 0.35, 1.5);
1344
+ float cov = smoothstep(aa, -aa, d);
1345
+ if (cov <= 0.001) discard;
1346
+ vec4 c = texture(uSrc, vUV);
1347
+ // Opaque where the box covers, and nothing outside it. The blurred backdrop
1348
+ // REPLACES what is under it rather than tinting it — the pixels it is made
1349
+ // of are those same pixels, softened.
1350
+ outColor = vec4(c.rgb, cov);
1351
+ }`;
1352
+
1353
+ /**
1354
+ * The box width the CSS filter spec asks for at a given sigma, and how the
1355
+ * three passes are offset. Straight out of the SVG spec's pseudo-code, which
1356
+ * the CSS filter spec adopts by reference — and matched against a browser at
1357
+ * three radii before it was believed.
1358
+ */
1359
+ export function boxesForSigma(sigma) {
1360
+ let d = Math.floor((sigma * 3 * Math.sqrt(2 * Math.PI)) / 4 + 0.5);
1361
+ if (d < 1) d = 1;
1362
+ return d % 2 === 1
1363
+ // Odd: three identical centred passes.
1364
+ ? [{ w: d, off: 0 }, { w: d, off: 0 }, { w: d, off: 0 }]
1365
+ // Even: two passes offset against each other by a texel, and a third one
1366
+ // texel wider. Without this the result creeps half a pixel per pass.
1367
+ : [{ w: d, off: 0 }, { w: d, off: 1 }, { w: d + 1, off: 0 }];
1368
+ }
1369
+
1370
+ /** How far a three-box blur of this sigma reaches, in pixels. */
1371
+ export function blurReach(sigma) {
1372
+ const b = boxesForSigma(sigma);
1373
+ return b.reduce((acc, p) => acc + Math.floor(p.w / 2) + 1, 0);
1374
+ }
1375
+
1376
+ // Two framebuffers and the textures behind them, kept per context and rebuilt
1377
+ // when the region's size changes. A dialog's scrim is the same size every
1378
+ // frame, so after the first one this allocates nothing.
1379
+ //
1380
+ // EXACTLY the region's size, and not "at least" it. Growing a shared target
1381
+ // and reusing it for a smaller region looks like the obvious saving and is a
1382
+ // trap: `copyTexImage2D` resizes the source texture to what was copied, while
1383
+ // the passes render across the whole target, so the first pass stretches the
1384
+ // image by target/region and the last one reads back only the region/target
1385
+ // fraction of it. The two cancel out along any axis where the picture happens
1386
+ // to be uniform — which is why a scene with a horizontal band through it saw
1387
+ // nothing wrong, twice.
1388
+ // ---------------------------------------------------------------------------
1389
+ // evg-surface-effect: ripple
1390
+ // ---------------------------------------------------------------------------
1391
+ //
1392
+ // A post-process over the finished surface: render the page to a texture, then
1393
+ // draw that texture across the screen with a fragment shader that bends the
1394
+ // sample position in a ring travelling out from where somebody touched it.
1395
+ //
1396
+ // The whole point is that the shader knows NOTHING about Ranger. It gets a
1397
+ // picture, a centre and an age. Everything on the page bends together — text,
1398
+ // card edges, the chart's own paths — because by the time this runs they are
1399
+ // all the same pixels, which is the most direct demonstration this gallery has
1400
+ // that a dashboard that looks like the DOM is not DOM pixels.
1401
+ //
1402
+ // This is an EVG EXTENSION and says so in its name: `evg-surface-effect` is
1403
+ // not a CSS property and nothing here should ever be measured against a
1404
+ // browser looking for a divergence, because there is nothing to diverge from.
1405
+ const RIPPLE_VERT = `#version 300 es
1406
+ in vec2 aPos;
1407
+ out vec2 vUV;
1408
+ void main() {
1409
+ vUV = aPos * 0.5 + 0.5;
1410
+ gl_Position = vec4(aPos, 0.0, 1.0);
1411
+ }`;
1412
+
1413
+ // The water, once. RIPPLE_FRAG below puts it over the whole surface; the
1414
+ // ripple PLUGIN further down runs the same function inside one element's
1415
+ // box. Shared as text rather than copied, because two copies of a wave
1416
+ // equation drift apart and nobody notices until they are lit differently.
1417
+ //
1418
+ // NO BACKTICKS ANYWHERE IN THE GLSL, comments included: every check that
1419
+ // draws through this painter inlines the file into a page as a script, and
1420
+ // a backtick inside a template literal ends it — which reads, three files
1421
+ // away, as the painter having a syntax error.
1422
+ const RIPPLE_CORE = `// THE RIPPLE ITSELF, as a function of one point in PAGE PIXELS, y DOWN.
1423
+ //
1424
+ // It is a function and not the body of main because there are two entry points
1425
+ // into it now: this program, which puts it over the whole surface from
1426
+ // list.effect the way it always has, and the ripple PLUGIN below, which runs
1427
+ // the same math inside one element's box from list.effects. One copy of the
1428
+ // water, two ways of asking for it.
1429
+ //
1430
+ // vUV comes off the fullscreen triangle as a GL texture coordinate, whose
1431
+ // origin is the BOTTOM left; a drop's x and y are page coordinates, whose
1432
+ // origin is the top left. Reading one as the other put every ring at uRes.y
1433
+ // minus where it was touched — a click near the top of the dashboard rippled
1434
+ // near the bottom of it. Everything below this line is in page space, and the
1435
+ // two places that hand a vector back to GL flip it back.
1436
+ vec4 rippleAt(vec2 p) {
1437
+
1438
+ // SUM the drops. This is the whole of the interference: two rings that
1439
+ // cross reinforce where their crests meet and cancel where a crest meets a
1440
+ // trough, and nothing here implements that — it is what adding waves does.
1441
+ //
1442
+ // The displacement is summed as a VECTOR, not as a scalar amplitude, so two
1443
+ // rings arriving from opposite sides push the surface in opposite
1444
+ // directions and the pixel between them stays where it was.
1445
+ vec2 push = vec2(0.0);
1446
+ float crest = 0.0;
1447
+ float energy = 0.0;
1448
+ for (int i = 0; i < MAX_DROPS; i++) {
1449
+ if (i >= uCount) break;
1450
+ vec3 drop = uDrops[i];
1451
+ vec2 delta = p - drop.xy;
1452
+ float d = length(delta);
1453
+ vec2 dir = delta / max(d, 0.001);
1454
+
1455
+ // The TRAIN. A drop on water does not make one ring, it makes several
1456
+ // from the same point a moment apart, each fainter than the one in front:
1457
+ // an expanding target rather than a circle. They cost no state — the
1458
+ // rings of one touch differ only in when they started, so the k-th is
1459
+ // simply this touch aged by k staggers, and one that has not started yet
1460
+ // has a negative age and is skipped.
1461
+ float amp = 1.0;
1462
+ for (int k = 0; k < MAX_RINGS; k++) {
1463
+ if (k >= uRings) break;
1464
+ float t = drop.z - float(k) * uStagger;
1465
+ if (t <= 0.0) break;
1466
+ float radius = t * uSpeed;
1467
+ // A Gaussian ring: the wave only exists near the front, so the rest of
1468
+ // the page is sampled exactly where it was drawn and stays sharp.
1469
+ float env = exp(-pow((d - radius) / uWidth, 2.0));
1470
+ float fade = env * exp(-t * uDecay) * amp;
1471
+ float wave = sin((d - radius) * 0.16) * fade;
1472
+ push += dir * wave;
1473
+ crest += wave;
1474
+ // The ENVELOPE, kept apart from the signal. It is the ring's amplitude
1475
+ // with the oscillation divided out, so unlike the wave itself it does
1476
+ // not pass through zero twice per wavelength.
1477
+ energy += fade;
1478
+ amp *= uFalloff;
1479
+ }
1480
+ }
1481
+
1482
+ float wave = crest;
1483
+ // Back into UV space to sample with: page y runs down and the texture's runs
1484
+ // up, so the displacement's y is negated on its way out.
1485
+ vec2 offset = vec2(push.x, -push.y) * uStrength / uRes;
1486
+
1487
+ // A whisper of chromatic aberration along the crest. The numbers are 1.08
1488
+ // and 0.92 rather than anything bolder for a reason: past about a tenth the
1489
+ // dashboard stops looking like water and starts looking like a filter.
1490
+ float r = texture(uSrc, vUV + offset * 1.08).r;
1491
+ float g = texture(uSrc, vUV + offset).g;
1492
+ float b = texture(uSrc, vUV + offset * 0.92).b;
1493
+ float a = texture(uSrc, vUV + offset).a;
1494
+
1495
+ // ---- THE SURFACE'S OWN NORMAL, AND A LIGHT ON IT ----------------------
1496
+ //
1497
+ // The wave sum is a HEIGHT FIELD: the rings added up. Its gradient is the
1498
+ // slope, and the slope is the normal — so nothing has to be computed or
1499
+ // stored to light this surface that the displacement did not already need.
1500
+ //
1501
+ // The gradient is taken in SCREEN SPACE with dFdx/dFdy rather than by
1502
+ // differentiating the sum by hand. Two reasons: the analytic derivative of
1503
+ // a Gaussian times a sine, summed over every ring of every drop, is a
1504
+ // second expression that has to be kept in step with the first one forever
1505
+ // — and this one is exact for whatever the first one happens to be. The
1506
+ // loops above branch only on uniforms, so every fragment in a quad takes
1507
+ // the same path and the derivative is well defined.
1508
+ //
1509
+ // dFdy differentiates against WINDOW y, which runs up, while the height
1510
+ // field and the light are both in page space, which runs down — so the y
1511
+ // component is negated. Get this wrong and the light silently moves to the
1512
+ // other side of the page: the glint still looks like a glint, on the wrong
1513
+ // edge of every wave.
1514
+ vec2 grad = vec2(dFdx(wave), -dFdy(wave)) * uBump;
1515
+ vec3 N = normalize(vec3(-grad, 1.0));
1516
+
1517
+ // Blinn-Phong. The eye looks straight down at a flat page, so V is +Z and
1518
+ // the half vector is the light plus that.
1519
+ vec3 L = normalize(uLight);
1520
+ vec3 V = vec3(0.0, 0.0, 1.0);
1521
+ vec3 H = normalize(L + V);
1522
+ float spec = pow(max(dot(N, H), 0.0), uGloss) * uShine;
1523
+
1524
+ // The specular lives on the FLANK of a wave and not on its top — that is
1525
+ // the whole difference from the ambient term beside it, and it is why the
1526
+ // glint slides along the ring as the ring travels rather than sitting on
1527
+ // it.
1528
+ //
1529
+ // It is faded out where there is no ring, so a page whose ripples have died
1530
+ // carries no shine. That fade is taken from the ENVELOPE and not from the
1531
+ // wave: the wave crosses zero twice per wavelength, and fading by it would
1532
+ // cut a dark seam straight through the middle of every glint.
1533
+ float alive = clamp(energy * 3.0, 0.0, 1.0);
1534
+
1535
+ vec3 col = vec3(r, g, b) + wave * uHi + spec * alive;
1536
+ return vec4(col, a);
1537
+ }
1538
+ `;
1539
+
1540
+ const RIPPLE_FRAG = `#version 300 es
1541
+ precision highp float;
1542
+ uniform sampler2D uSrc;
1543
+ uniform vec2 uRes; // page pixels
1544
+ // Up to this many touches at once. Eight is not a limit anybody reaches by
1545
+ // tapping; it is what a FINGER DRAGGED across the surface fills in a third of
1546
+ // a second, and the oldest is retired to make room.
1547
+ #define MAX_DROPS 8
1548
+ uniform vec3 uDrops[MAX_DROPS]; // x, y in page pixels; z is seconds since
1549
+ uniform int uCount;
1550
+ uniform float uSpeed; // px per second the ring travels
1551
+ uniform float uWidth; // the envelope's sigma, px
1552
+ uniform float uStrength; // displacement at the crest, px
1553
+ uniform float uDecay; // per second
1554
+ uniform float uHi; // how much the crest lightens
1555
+ // One touch sends a TRAIN of rings from the same point, a stagger apart.
1556
+ #define MAX_RINGS 5
1557
+ uniform int uRings;
1558
+ uniform float uStagger; // seconds between one wavefront and the next
1559
+ uniform float uFalloff; // what each ring behind the front is worth
1560
+ uniform float uShine; // how strong the glint is
1561
+ uniform float uGloss; // Blinn-Phong exponent: how tight it is
1562
+ uniform float uBump; // what turns the height field into a slope
1563
+ uniform vec3 uLight; // where the light is, not necessarily normalised
1564
+ in vec2 vUV;
1565
+ out vec4 outColor;
1566
+
1567
+ ` + RIPPLE_CORE + `
1568
+ void main() {
1569
+ outColor = rippleAt(vec2(vUV.x, 1.0 - vUV.y) * uRes);
1570
+ }`;
1571
+
1572
+ // One target, kept and resized only when the canvas is. Same shape as the blur
1573
+ // targets above and for the same reason: a frame that allocates a texture is a
1574
+ // frame that stutters.
1575
+ const RIPPLE_TARGET = new WeakMap();
1576
+
1577
+ function rippleTargetFor(gl, w, h, slot = 0) {
1578
+ // TWO of them now, and the second is only ever made by a page that stacks
1579
+ // effects: a filter reads the finished surface and writes another, so two
1580
+ // filters have to ping-pong between a pair of targets. One filter — which is
1581
+ // every page that has ever run this — makes one target and draws it to the
1582
+ // screen, exactly as before.
1583
+ let slots = RIPPLE_TARGET.get(gl);
1584
+ if (!slots) { slots = []; RIPPLE_TARGET.set(gl, slots); }
1585
+ let t = slots[slot];
1586
+ if (!t) { t = { w: 0, h: 0, tex: null, fbo: null, depth: null, complete: false }; slots[slot] = t; }
1587
+ if (t.w === w && t.h === h && t.tex) return t;
1588
+ if (t.tex) gl.deleteTexture(t.tex);
1589
+ if (t.fbo) gl.deleteFramebuffer(t.fbo);
1590
+ t.tex = gl.createTexture();
1591
+ gl.bindTexture(gl.TEXTURE_2D, t.tex);
1592
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1593
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1594
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1595
+ // A displaced sample near the edge must repeat the edge, not read black —
1596
+ // otherwise the ring draws a dark rim as it reaches the sides of the page.
1597
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1598
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1599
+ t.fbo = gl.createFramebuffer();
1600
+ gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo);
1601
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t.tex, 0);
1602
+ // A STENCIL BUFFER, and it is not optional. Path fills are stencil-then-
1603
+ // cover, so a target without one cannot fill a path — and the chart on the
1604
+ // page that wanted this effect is nothing but path fills. Leaving it off did
1605
+ // not draw a chart with no bars in it, which is what the missing-stencil
1606
+ // branch is written to do: it made every frame take half a SECOND, because
1607
+ // the driver fell off its fast path and back into software for a stencil
1608
+ // buffer that was not there. 7ms a frame became 540.
1609
+ if (t.depth) gl.deleteRenderbuffer(t.depth);
1610
+ t.depth = gl.createRenderbuffer();
1611
+ gl.bindRenderbuffer(gl.RENDERBUFFER, t.depth);
1612
+ gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, w, h);
1613
+ gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, t.depth);
1614
+ t.complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
1615
+ gl.bindRenderbuffer(gl.RENDERBUFFER, null);
1616
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1617
+ t.w = w; t.h = h;
1618
+ return t;
1619
+ }
1620
+
1621
+ const BLUR_TARGETS = new WeakMap();
1622
+
1623
+ function blurTargetsFor(gl, w, h) {
1624
+ let t = BLUR_TARGETS.get(gl);
1625
+ if (!t) {
1626
+ t = { w: 0, h: 0, tex: [null, null], fbo: [null, null], src: null };
1627
+ BLUR_TARGETS.set(gl, t);
1628
+ }
1629
+ if (t.w === w && t.h === h && t.src) return t;
1630
+ const nw = w, nh = h;
1631
+ if (t.src) gl.deleteTexture(t.src);
1632
+ const mk = () => {
1633
+ const tex = gl.createTexture();
1634
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1635
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, nw, nh, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1636
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1637
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1638
+ // CLAMP_TO_EDGE is not a detail: it is what makes a sample past the copied
1639
+ // region repeat its edge instead of reading black, which is the same thing
1640
+ // the browser does at the screen's own edge.
1641
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1642
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1643
+ return tex;
1644
+ };
1645
+ for (let i = 0; i < 2; i++) {
1646
+ if (t.tex[i]) gl.deleteTexture(t.tex[i]);
1647
+ if (t.fbo[i]) gl.deleteFramebuffer(t.fbo[i]);
1648
+ t.tex[i] = mk();
1649
+ t.fbo[i] = gl.createFramebuffer();
1650
+ gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo[i]);
1651
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t.tex[i], 0);
1652
+ }
1653
+ t.src = mk();
1654
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1655
+ t.w = nw; t.h = nh;
1656
+ return t;
1657
+ }
1658
+
1659
+ // ---------------------------------------------------------------------------
1660
+ // SURFACE EFFECT PLUGINS
1661
+ // ---------------------------------------------------------------------------
1662
+ //
1663
+ // `evg-surface-effect: ripple` was one effect, over the whole page, with its
1664
+ // parameters declared as fields on EVGElement and its drops pushed in by the
1665
+ // application's own `pointerdown`. Everything about it was hard-wired except
1666
+ // the numbers.
1667
+ //
1668
+ // This is the general form. An effect is a NAME the display list carries, a
1669
+ // BOX the layout worked out, a bag of PARAMETERS nothing in the engine reads,
1670
+ // and a list of EVENTS the host's driver collected — and what turns that into
1671
+ // pixels is a plugin registered here:
1672
+ //
1673
+ // registerSurfaceEffect({
1674
+ // name: "aurora",
1675
+ // layer: "source", // or "filter"
1676
+ // params: { speed: 1, hue: 200 }, // names and defaults
1677
+ // frag: "vec4 fxColor(vec2 p, vec2 local) { … }",
1678
+ // })
1679
+ //
1680
+ // TWO KINDS, and the difference is not decoration:
1681
+ //
1682
+ // a SOURCE writes pixels into the element's box, in paint order, at the
1683
+ // point the element's own background is drawn — so the element's children
1684
+ // are painted over it, which is what makes a starfield a background and not
1685
+ // a sticker over the text.
1686
+ //
1687
+ // a FILTER reads the finished surface and writes it back changed. The whole
1688
+ // frame goes into a texture first, and the box says which part of it the
1689
+ // plugin may touch — outside, the surface passes through untouched. The
1690
+ // ripple is one of these, which is why it can bend text it knows nothing
1691
+ // about: by the time it runs, the text is pixels.
1692
+ //
1693
+ // a BACKDROP is a filter that runs IN PAINT ORDER, over what is behind the
1694
+ // element and nothing that comes after it: the surface so far is copied at
1695
+ // the point the element paints, the plugin writes it back inside the box,
1696
+ // and then the element's own background, its border and its children are
1697
+ // drawn on top — crisp. That is what `backdrop-filter: blur()` does for one
1698
+ // hard-wired filter, and what the liquid glass needs: a pane that refracts
1699
+ // the page behind it and does NOT smear its own label.
1700
+ //
1701
+ // WHAT A PLUGIN WRITES is one function:
1702
+ //
1703
+ // vec4 fxColor(vec2 p, vec2 local)
1704
+ //
1705
+ // `p` is the point in PAGE pixels with y down — the same space the display
1706
+ // list, the box and the events are in, so nothing has to be converted by
1707
+ // hand. `local` is the same point as 0..1 across the box. A source returns
1708
+ // the colour to composite over what is there; a filter returns the finished
1709
+ // colour, and has `uSrc` to sample with `vUV`.
1710
+ //
1711
+ // AN INSTANCE THE HOST HAS SWITCHED OFF (`inst.off`) is skipped by all three,
1712
+ // at no cost: the run is passed over, the filter is not live, and nothing is
1713
+ // compiled or copied for it. That is the hook a page's own switch hangs on,
1714
+ // and the one `prefers-reduced-motion` would.
1715
+ //
1716
+ // AND WHAT IT GETS, without asking: `uBox` (x, y, w, h in page px), `uRadius`,
1717
+ // `uRes`, `uTime` in seconds, `uEvents[]` as (x, y, age-in-seconds) triples in
1718
+ // page pixels with `uEventCount` of them, and one `p_<name>` float per
1719
+ // declared parameter. The box and its rounded corners are applied for it — a
1720
+ // plugin that paints past its own box cannot, because the mask is in `main`
1721
+ // rather than in the plugin.
1722
+ //
1723
+ // WHY A SHADER AND NOT A CALLBACK. A plugin that ran JavaScript per frame
1724
+ // would be a plugin that could stall the frame. A plugin that is GLSL plus a
1725
+ // parameter list can only be slow the way every other shader is, and it is a
1726
+ // STRING — so an app can register one at runtime, and a WebGPU painter could
1727
+ // compile the same declaration into WGSL instead. Nothing in the display list
1728
+ // mentions either: it says `starfield` and a number called `density`, and
1729
+ // which API draws that is the painter's business.
1730
+ const FX_PLUGINS = new Map();
1731
+ // Bumped by every registration, so a program compiled from an older definition
1732
+ // is recompiled rather than quietly kept.
1733
+ let FX_EPOCH = 0;
1734
+
1735
+ /** A name a GLSL identifier can be made of — `evg-fx-star-size` is a legal
1736
+ * parameter and `p_star-size` is not a legal uniform. */
1737
+ const glslName = (k) => String(k).replace(/[^A-Za-z0-9_]/g, "_");
1738
+
1739
+ export function registerSurfaceEffect(def) {
1740
+ if (!def || !def.name) throw new Error("registerSurfaceEffect: no name");
1741
+ if (!def.frag) throw new Error("registerSurfaceEffect: no frag");
1742
+ const layer = (def.layer === "filter" || def.layer === "backdrop") ? def.layer : "source";
1743
+ FX_PLUGINS.set(def.name, { name: def.name, layer, params: def.params || {}, frag: def.frag });
1744
+ FX_EPOCH += 1;
1745
+ return def.name;
1746
+ }
1747
+
1748
+ /** The plugin registered under a name, or undefined. */
1749
+ export function surfaceEffect(name) { return FX_PLUGINS.get(name); }
1750
+
1751
+ /** Every effect this painter can draw, for a host that wants to say so. */
1752
+ export function surfaceEffectNames() { return [...FX_PLUGINS.keys()]; }
1753
+
1754
+ // The finished target, straight onto the screen. A frame that went offscreen
1755
+ // only because something in it had to read the surface has no filter to end
1756
+ // with, so it needs one pass that changes nothing.
1757
+ const FX_PRESENT_FRAG = `#version 300 es
1758
+ precision highp float;
1759
+ uniform sampler2D uSrc;
1760
+ in vec2 vUV;
1761
+ out vec4 outColor;
1762
+ void main() { outColor = texture(uSrc, vUV); }`;
1763
+
1764
+ const FX_VERT = `#version 300 es
1765
+ in vec2 aPos;
1766
+ out vec2 vUV;
1767
+ void main() {
1768
+ vUV = aPos * 0.5 + 0.5;
1769
+ gl_Position = vec4(aPos, 0.0, 1.0);
1770
+ }`;
1771
+
1772
+ // Everything a plugin gets whether it asks or not. MAX_EVENTS is 8 for the
1773
+ // same reason the ripple's MAX_DROPS is: eight is not a number anybody reaches
1774
+ // by tapping, and it is what a dragged finger fills in a third of a second.
1775
+ const FX_PREAMBLE = `precision highp float;
1776
+ uniform sampler2D uSrc;
1777
+ uniform vec2 uRes;
1778
+ uniform vec4 uBox;
1779
+ uniform float uRadius;
1780
+ uniform float uTime;
1781
+ #define MAX_EVENTS 8
1782
+ uniform vec3 uEvents[MAX_EVENTS];
1783
+ uniform int uEventCount;
1784
+ in vec2 vUV;
1785
+ out vec4 outColor;
1786
+
1787
+ // The point in PAGE pixels, y down — the space everything the plugin is given
1788
+ // is already in.
1789
+ vec2 fxPagePoint() { return vec2(vUV.x, 1.0 - vUV.y) * uRes; }
1790
+
1791
+ // THE BOX ITSELF, as a signed distance in page pixels: negative inside,
1792
+ // zero on the border, positive outside, and rounded corners for free.
1793
+ //
1794
+ // Exposed rather than kept for the mask below, because a plugin that wants to
1795
+ // look like a MATERIAL needs to know where the edge is and which way it faces.
1796
+ // The liquid glass is the whole reason: its refraction lives in a band inside
1797
+ // the rim and its normal is this function's gradient, so a pane is a lens
1798
+ // whatever shape the layout gave it and whatever radius the sheet asked for.
1799
+ float fxBoxDistance(vec2 p) {
1800
+ vec2 halfBox = uBox.zw * 0.5;
1801
+ vec2 centre = uBox.xy + halfBox;
1802
+ float r = min(uRadius, min(halfBox.x, halfBox.y));
1803
+ vec2 d = abs(p - centre) - (halfBox - vec2(r));
1804
+ return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
1805
+ }
1806
+
1807
+ // How much of this pixel is inside the box, corners included — the distance
1808
+ // above, softened over a pixel so a corner is not a staircase. Applied in
1809
+ // main, so no plugin can paint outside the element that declared it, which is
1810
+ // the whole difference between an effect on a card and an effect on a page.
1811
+ float fxBoxCoverage(vec2 p) {
1812
+ return 1.0 - smoothstep(-0.75, 0.75, fxBoxDistance(p));
1813
+ }
1814
+ `;
1815
+
1816
+ // A source composites over what is already there, with the painter's own
1817
+ // blend — so the colour it returns is NOT premultiplied, and the coverage goes
1818
+ // on the alpha.
1819
+ const FX_MAIN_SOURCE = `
1820
+ void main() {
1821
+ vec2 p = fxPagePoint();
1822
+ vec2 local = (p - uBox.xy) / max(uBox.zw, vec2(1.0));
1823
+ vec4 c = fxColor(p, local);
1824
+ outColor = vec4(c.rgb, c.a * fxBoxCoverage(p));
1825
+ }`;
1826
+
1827
+ // A filter and a backdrop REPLACE the surface, so blending is off and the
1828
+ // pixels outside the box have to be handed back exactly as they arrived. A mix
1829
+ // and not a branch: a branch here would make the screen-space derivative a
1830
+ // plugin like the ripple takes undefined along the box's own edge.
1831
+ const FX_MAIN_FILTER = `
1832
+ void main() {
1833
+ vec2 p = fxPagePoint();
1834
+ vec2 local = (p - uBox.xy) / max(uBox.zw, vec2(1.0));
1835
+ vec4 here = texture(uSrc, vUV);
1836
+ vec4 c = fxColor(p, local);
1837
+ outColor = mix(here, c, fxBoxCoverage(p));
1838
+ }`;
1839
+
1840
+ function fxFragSource(def) {
1841
+ const decls = Object.keys(def.params).map((k) => `uniform float p_${glslName(k)};`).join("\n");
1842
+ return "#version 300 es\n" + FX_PREAMBLE + decls + "\n" + def.frag +
1843
+ (def.layer === "source" ? FX_MAIN_SOURCE : FX_MAIN_FILTER);
1844
+ }
1845
+
1846
+ // THE SURFACE SO FAR, for a backdrop effect to read.
1847
+ //
1848
+ // One texture the size of the canvas, kept and reused: a backdrop pass copies
1849
+ // the framebuffer into it at the point the element paints and then samples it
1850
+ // across the whole screen, so what the plugin sees is exactly what a person
1851
+ // would see if the element were not there.
1852
+ //
1853
+ // `copyTexSubImage2D` and not `copyTexImage2D`, which is the trap the blur
1854
+ // section above documents: the latter RESIZES the texture to what was copied,
1855
+ // and a pass that then samples it as a full screen stretches the picture.
1856
+ // This one is allocated at the canvas's size once and written into.
1857
+ //
1858
+ // WHAT IS NEVER COPIED FROM IS THE CANVAS ITSELF. A WebGL2 context asked for
1859
+ // `antialias: true` — which is the default, and what every page in this
1860
+ // repository asks for — has a MULTISAMPLED default framebuffer, and copying
1861
+ // out of one is INVALID_OPERATION: the copy silently leaves the texture black,
1862
+ // the pass writes that black back over everything painted before it, and the
1863
+ // page loses its top half. Measured, on the demo page, after the checks
1864
+ // passed: they render with `preserveDrawingBuffer` set, which on this driver
1865
+ // hands out a single-sampled buffer and hides the whole thing.
1866
+ //
1867
+ // So a frame with a backdrop effect in it is rendered into the offscreen
1868
+ // target instead — the same one the filters use — and the copy comes from
1869
+ // there, where it is an ordinary single-sampled texture. `fxNeedsTarget` below
1870
+ // is that decision, and `drawPresent` is what puts the finished target back on
1871
+ // the screen when no filter pass is going to.
1872
+ const FX_COPY = new WeakMap();
1873
+
1874
+ function fxCopyOfSurface(gl, w, h) {
1875
+ let t = FX_COPY.get(gl);
1876
+ if (!t) { t = { w: 0, h: 0, tex: null }; FX_COPY.set(gl, t); }
1877
+ gl.activeTexture(gl.TEXTURE0);
1878
+ if (t.w !== w || t.h !== h || !t.tex) {
1879
+ if (t.tex) gl.deleteTexture(t.tex);
1880
+ t.tex = gl.createTexture();
1881
+ gl.bindTexture(gl.TEXTURE_2D, t.tex);
1882
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1883
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1884
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1885
+ // A refraction near the edge of the page samples past it; repeat the edge
1886
+ // rather than read black, or a pane at the margin gets a dark rim.
1887
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1888
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1889
+ t.w = w; t.h = h;
1890
+ } else {
1891
+ gl.bindTexture(gl.TEXTURE_2D, t.tex);
1892
+ }
1893
+ gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, w, h);
1894
+ return t.tex;
1895
+ }
1896
+
1897
+ // One program per plugin per context, compiled the first time that plugin is
1898
+ // actually drawn — a page that declares none compiles none.
1899
+ const FX_PROGRAMS = new WeakMap();
1900
+
1901
+ function fxProgramFor(gl, name) {
1902
+ let byName = FX_PROGRAMS.get(gl);
1903
+ if (!byName) { byName = new Map(); FX_PROGRAMS.set(gl, byName); }
1904
+ const found = byName.get(name);
1905
+ if (found !== undefined && (found === null || found.epoch === FX_EPOCH)) return found;
1906
+ const def = FX_PLUGINS.get(name);
1907
+ if (!def) { byName.set(name, null); return null; }
1908
+ let built = null;
1909
+ try {
1910
+ const prog = gl.createProgram();
1911
+ gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, FX_VERT));
1912
+ gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fxFragSource(def)));
1913
+ gl.linkProgram(prog);
1914
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
1915
+ throw new Error("link " + name + ": " + gl.getProgramInfoLog(prog));
1916
+ }
1917
+ const params = {};
1918
+ for (const k of Object.keys(def.params)) params[k] = gl.getUniformLocation(prog, "p_" + glslName(k));
1919
+ built = {
1920
+ epoch: FX_EPOCH, def, prog,
1921
+ posLoc: gl.getAttribLocation(prog, "aPos"),
1922
+ uSrc: gl.getUniformLocation(prog, "uSrc"),
1923
+ uRes: gl.getUniformLocation(prog, "uRes"),
1924
+ uBox: gl.getUniformLocation(prog, "uBox"),
1925
+ uRadius: gl.getUniformLocation(prog, "uRadius"),
1926
+ uTime: gl.getUniformLocation(prog, "uTime"),
1927
+ uEvents: gl.getUniformLocation(prog, "uEvents"),
1928
+ uEventCount: gl.getUniformLocation(prog, "uEventCount"),
1929
+ params,
1930
+ };
1931
+ } catch (e) {
1932
+ // A plugin that does not compile must not take the page down with it: the
1933
+ // frame draws without the effect and says so once, which is the same
1934
+ // choice the missing-stencil and incomplete-target branches make.
1935
+ console.warn("evg: surface effect '" + name + "' did not compile — " + (e && e.message ? e.message : e));
1936
+ built = null;
1937
+ }
1938
+ byName.set(name, built);
1939
+ return built;
1940
+ }
1941
+
1942
+ // ---------------------------------------------------------------------------
1943
+ // The built-in plugins
1944
+ // ---------------------------------------------------------------------------
1945
+
1946
+ // THE RIPPLE, inside one element's box.
1947
+ //
1948
+ // Not a second implementation: RIPPLE_CORE is the same text the whole-page
1949
+ // program above is built from, and the #defines below are the whole of the
1950
+ // adaptation — the plugin's generic parameters given the names that function
1951
+ // has always used for them. `evg-ripple-speed` still writes `speed`, because
1952
+ // the display list puts the ripple's own properties into the parameter bag.
1953
+ registerSurfaceEffect({
1954
+ name: "ripple",
1955
+ layer: "filter",
1956
+ params: {
1957
+ speed: 220, width: 28, strength: 7, decay: 1.8, highlight: 0.08,
1958
+ rings: 3, stagger: 0.09, falloff: 0.62,
1959
+ shine: 0.45, gloss: 120, bump: 70,
1960
+ lightX: -0.45, lightY: -0.65, lightZ: 0.62,
1961
+ },
1962
+ frag: `
1963
+ #define MAX_DROPS MAX_EVENTS
1964
+ #define MAX_RINGS 5
1965
+ #define uDrops uEvents
1966
+ #define uCount uEventCount
1967
+ #define uSpeed p_speed
1968
+ #define uWidth p_width
1969
+ #define uStrength p_strength
1970
+ #define uDecay p_decay
1971
+ #define uHi p_highlight
1972
+ #define uRings int(p_rings)
1973
+ #define uStagger p_stagger
1974
+ #define uFalloff p_falloff
1975
+ #define uShine p_shine
1976
+ #define uGloss p_gloss
1977
+ #define uBump p_bump
1978
+ #define uLight vec3(p_lightX, p_lightY, p_lightZ)
1979
+ ` + RIPPLE_CORE + `
1980
+ vec4 fxColor(vec2 p, vec2 local) { return rippleAt(p); }
1981
+ `,
1982
+ });
1983
+
1984
+ // THE STARFIELD: stars that shine and drift, and the dust they are in.
1985
+ //
1986
+ // A SOURCE, not a filter. It makes pixels rather than bending them, it is
1987
+ // drawn where the element's background is drawn, and everything the element
1988
+ // contains is painted over it — so `evg-surface-effect: starfield` on a
1989
+ // section gives that section a sky, with its own headline still on top of it
1990
+ // and its own rounded corners still cutting it.
1991
+ //
1992
+ // Nothing here is stored. Every star is a hash of the cell it lives in, so a
1993
+ // field of thousands costs no memory, no upload and nothing in the display
1994
+ // list: the picture is a function of (where, when), and the only state on the
1995
+ // page is the clock. That is also why it can drift forever without
1996
+ // accumulating anything to drift out of.
1997
+ //
1998
+ // THE PARAMETERS, all through `evg-fx-*`:
1999
+ // density how many stars — smaller cells, more of them
2000
+ // speed how fast the field drifts, px/s at the nearest layer
2001
+ // angle which way it drifts, degrees clockwise from east
2002
+ // twinkle 0 turns the shimmer off, 1 is lively
2003
+ // glow the halo around the brighter stars
2004
+ // nebula how much dust, 0 for a clean sky
2005
+ // hue/hue2 the two dust colours, degrees round the wheel
2006
+ // seed a different sky from the same numbers
2007
+ registerSurfaceEffect({
2008
+ name: "starfield",
2009
+ layer: "source",
2010
+ params: {
2011
+ density: 1, speed: 6, angle: 200, twinkle: 1, glow: 1,
2012
+ nebula: 0.6, hue: 225, hue2: 300, seed: 1,
2013
+ },
2014
+ frag: `
2015
+ float fxHash21(vec2 p) {
2016
+ p = fract(p * vec2(127.31, 311.7));
2017
+ p += dot(p, p + 34.53);
2018
+ return fract(p.x * p.y);
2019
+ }
2020
+ vec2 fxHash22(vec2 p) {
2021
+ return vec2(fxHash21(p), fxHash21(p + 19.19));
2022
+ }
2023
+ // Value noise, smoothed so the dust carries no grid.
2024
+ float fxNoise(vec2 p) {
2025
+ vec2 i = floor(p);
2026
+ vec2 f = fract(p);
2027
+ f = f * f * (3.0 - 2.0 * f);
2028
+ float a = fxHash21(i);
2029
+ float b = fxHash21(i + vec2(1.0, 0.0));
2030
+ float c = fxHash21(i + vec2(0.0, 1.0));
2031
+ float d = fxHash21(i + vec2(1.0, 1.0));
2032
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
2033
+ }
2034
+ float fxFbm(vec2 p) {
2035
+ float sum = 0.0;
2036
+ float amp = 0.5;
2037
+ for (int i = 0; i < 5; i++) {
2038
+ sum += amp * fxNoise(p);
2039
+ p = p * 2.03 + vec2(17.3, 9.1);
2040
+ amp *= 0.5;
2041
+ }
2042
+ return sum;
2043
+ }
2044
+ // A hue as a colour, saturation and value at 1. The dust is tinted by the
2045
+ // sheet and shaded by the noise, so this only has to answer which way round
2046
+ // the wheel.
2047
+ vec3 fxHueColor(float deg) {
2048
+ float h = fract(deg / 360.0) * 6.0;
2049
+ return clamp(abs(mod(h + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
2050
+ }
2051
+
2052
+ // One layer of stars. The field is a grid of cells with at most one star in
2053
+ // each, so a star is found by looking at the nine cells around the point
2054
+ // rather than by looping over a list — which is what makes the count free.
2055
+ //
2056
+ // Brightness is a hash raised to a power: most cells hold something faint, and
2057
+ // one in a few hundred holds the kind of star a person would point at.
2058
+ vec3 fxStarLayer(vec2 q, float cell, float seed, float twinkle, float glow) {
2059
+ vec2 g = q / cell;
2060
+ vec2 id = floor(g);
2061
+ vec2 f = fract(g);
2062
+ vec3 acc = vec3(0.0);
2063
+ for (int dy = -1; dy <= 1; dy++) {
2064
+ for (int dx = -1; dx <= 1; dx++) {
2065
+ vec2 o = vec2(float(dx), float(dy));
2066
+ vec2 cid = id + o + seed;
2067
+ float h = fxHash21(cid);
2068
+ // Two cells in three are empty sky.
2069
+ if (h < 0.62) continue;
2070
+ vec2 at = o + fxHash22(cid + 3.7);
2071
+ float d = length(f - at) * cell;
2072
+ float bright = pow(fxHash21(cid + 7.1), 3.5);
2073
+ // The shimmer is the atmosphere, so it is per star and out of phase.
2074
+ float ph = fxHash21(cid + 11.3) * 6.2831;
2075
+ bright *= 1.0 - twinkle * 0.45 * (0.5 + 0.5 * sin(uTime * (1.4 + 2.6 * fxHash21(cid + 2.2)) + ph));
2076
+ // The star, the halo around it, and the spikes on the handful bright
2077
+ // enough to earn them.
2078
+ //
2079
+ // A BRIGHT STAR IS ALSO A BIGGER ONE. Every star the same size is the
2080
+ // difference between a sky and a scatter plot: the eye reads size as
2081
+ // distance, and a field of identical dots reads as a texture. So the
2082
+ // core's radius and the halo's reach both grow with the brightness that
2083
+ // is already there, and the top few per cent become the ones a person
2084
+ // would point at.
2085
+ float rad = 0.85 + 2.2 * bright;
2086
+ float core = exp(-(d * d) / (rad * rad));
2087
+ // THE HALO HAS TO DIE INSIDE ONE CELL. Only the nine cells around this
2088
+ // point are looked at, so a glow that still has something left at the
2089
+ // edge of that neighbourhood is a glow that is CUT there — and the cut
2090
+ // is straight, and it is at the same place in every cell, so a dense
2091
+ // field comes out with a faint square grid across it. It is the sort of
2092
+ // thing that looks like a texture artefact and is really a loop bound.
2093
+ //
2094
+ // Tied to the cell rather than to the star: at a given density the
2095
+ // reach is whatever fits, and turning the glow up makes the bright ones
2096
+ // brighter rather than wider once it no longer can.
2097
+ float reach = min(3.0 * rad, cell * 0.42);
2098
+ float halo = glow * 0.30 * exp(-d / max(reach, 0.5));
2099
+ float spike = 0.0;
2100
+ if (bright > 0.55) {
2101
+ vec2 v = (f - at) * cell;
2102
+ float sp = exp(-abs(v.x) / (2.2 * rad)) * exp(-(v.y * v.y) / (0.9 * rad))
2103
+ + exp(-abs(v.y) / (2.2 * rad)) * exp(-(v.x * v.x) / (0.9 * rad));
2104
+ spike = sp * glow * 0.22 * (bright - 0.55) / 0.45;
2105
+ }
2106
+ // Cooler and warmer stars, mostly white.
2107
+ vec3 tint = mix(vec3(0.72, 0.82, 1.0), vec3(1.0, 0.93, 0.82), fxHash21(cid + 5.5));
2108
+ // AND FADED TO NOTHING BEFORE THE NEIGHBOURHOOD ENDS. Clamping the
2109
+ // halo's reach is not enough on its own: an exponential still has a few
2110
+ // per cent left where it is cut, the cut is at the same distance in
2111
+ // every cell, and a dense field comes out with a faint square grid
2112
+ // across it. This window is exactly zero by one cell, which is the
2113
+ // nearest a star that was NOT looked at can be, so every star's
2114
+ // contribution ends inside the nine cells that were.
2115
+ float win = 1.0 - smoothstep(cell * 0.6, cell * 1.0, d);
2116
+ acc += tint * bright * (core + halo + spike) * win;
2117
+ }
2118
+ }
2119
+ return acc;
2120
+ }
2121
+
2122
+ vec4 fxColor(vec2 p, vec2 local) {
2123
+ vec2 q = p - uBox.xy;
2124
+ float ang = radians(p_angle);
2125
+ vec2 dir = vec2(cos(ang), sin(ang));
2126
+ float seed = p_seed * 37.0;
2127
+ float cell = max(6.0, 34.0 / max(p_density, 0.05));
2128
+
2129
+ // THE DUST. Two fields at different scales and hues, the far one drifting
2130
+ // slower — the same parallax the stars have, for the same reason.
2131
+ vec3 dust = vec3(0.0);
2132
+ float dustA = 0.0;
2133
+ if (p_nebula > 0.001) {
2134
+ vec2 n1 = (q + dir * uTime * p_speed * 0.12) * 0.0042 + seed;
2135
+ vec2 n2 = (q + dir * uTime * p_speed * 0.07) * 0.0026 + seed + 13.7;
2136
+ float f1 = pow(fxFbm(n1), 2.2);
2137
+ float f2 = pow(fxFbm(n2), 3.0);
2138
+ dust = fxHueColor(p_hue) * f1 * 1.15 + fxHueColor(p_hue2) * f2 * 1.35;
2139
+ dustA = clamp((f1 * 0.9 + f2 * 1.1) * p_nebula, 0.0, 0.85);
2140
+ dust *= p_nebula;
2141
+ }
2142
+
2143
+ // THREE LAYERS, near to far: smaller cells drift faster, which is the only
2144
+ // thing that makes a flat field read as depth.
2145
+ vec3 stars = vec3(0.0);
2146
+ stars += fxStarLayer(q + dir * uTime * p_speed, cell, seed, p_twinkle, p_glow);
2147
+ stars += fxStarLayer(q + dir * uTime * p_speed * 0.55, cell * 2.1, seed + 51.0, p_twinkle, p_glow) * 0.85;
2148
+ stars += fxStarLayer(q + dir * uTime * p_speed * 0.28, cell * 3.7, seed + 97.0, p_twinkle * 0.6, p_glow * 1.4) * 0.7;
2149
+
2150
+ // A PRESS LANDS AS A FLASH, when the sheet asked for one. The events are the
2151
+ // same ones the ripple reads — a driver that feeds one feeds the other, and
2152
+ // an effect with no use for them simply never mentions uEvents.
2153
+ for (int i = 0; i < MAX_EVENTS; i++) {
2154
+ if (i >= uEventCount) break;
2155
+ vec3 e = uEvents[i];
2156
+ float d = length(p - e.xy);
2157
+ float t = max(e.z, 0.0);
2158
+ float ring = exp(-pow((d - t * 260.0) / 46.0, 2.0)) * exp(-t * 2.2);
2159
+ stars += vec3(0.75, 0.85, 1.0) * ring * 0.9;
2160
+ }
2161
+
2162
+ float starA = clamp(max(max(stars.r, stars.g), stars.b), 0.0, 1.0);
2163
+ float outA = clamp(dustA + starA, 0.0, 1.0);
2164
+ // Accumulated as light and handed back as colour: the painter blends with
2165
+ // SRC_ALPHA, so the sum is divided by the alpha it is about to be multiplied
2166
+ // by, which makes the composite additive without a second blend mode.
2167
+ vec3 lit = dust * dustA + stars;
2168
+ return vec4(lit / max(outA, 0.0001), outA);
2169
+ }
2170
+ `,
2171
+ });
2172
+ // LIQUID GLASS: a pane that bends the page behind it.
2173
+ //
2174
+ // A BACKDROP, so it reads the surface at the point its element paints and the
2175
+ // element's own tint, border and label are drawn over it afterwards — sharp.
2176
+ // A pane that smears its own caption is a pane that ran too late.
2177
+ //
2178
+ // WHAT MAKES GLASS LOOK LIKE GLASS is not the blur. Frosted glass has been one
2179
+ // CSS declaration for years — `backdrop-filter: blur(20px)`, which EVG already
2180
+ // does — and it reads as a fogged sheet, not as a solid object with a shape.
2181
+ // What is missing from it is REFRACTION: a real pane is thicker in the middle
2182
+ // than at its edge, so the edge is a curved surface, and light through a curve
2183
+ // bends. The page behind gets dragged toward the rim, compressed into a bright
2184
+ // band a few pixels wide, and split slightly into colour on the way. That band
2185
+ // is the whole illusion, and everything below is it:
2186
+ //
2187
+ // thickness how far in from the edge the curve reaches, px. This is the
2188
+ // pane's edge profile, not its blur.
2189
+ // strength how far the rim pulls what is behind it, px
2190
+ // power the profile of the bevel: 1 is a chamfer, 3 is a bulge that
2191
+ // only bends within a hair of the edge
2192
+ // disperse how far red and blue come apart in the band — a few hundredths
2193
+ // shine the specular arc where the rim faces the light
2194
+ // angle where the light is, degrees clockwise from east
2195
+ // tint a flat lift across the pane, for glass that is not water-clear
2196
+ //
2197
+ // AND THE SWEEP, which is what makes a pane look POLISHED rather than merely
2198
+ // transparent: a bar of light crossing it, flaring where it reaches the rim.
2199
+ // It is a light passing over a surface, so it is off by default — a pane in a
2200
+ // room with nothing moving has no streak on it — and a sheet turns it on:
2201
+ //
2202
+ // sweep how bright the bar is; 0 is no bar at all
2203
+ // sweep-angle which way it lies, degrees
2204
+ // sweep-width how wide it is, as a fraction of the crossing
2205
+ // sweep-speed passes per second; 0 holds it still at `sweep-at`
2206
+ // sweep-at where it sits when it is not moving, 0..1
2207
+ // sweep-edge how much brighter the rim is where the bar meets it
2208
+ // sweep-rim how much of the bar is kept to the rim: 0 crosses the pane
2209
+ // evenly, 1 shows only where the bevel is
2210
+ // sweep-duty what fraction of each cycle the pass takes; the rest of the
2211
+ // cycle the bar is off the pane entirely
2212
+ //
2213
+ // THOSE LAST TWO ARE WHAT MAKE IT A SHINE RATHER THAN A STRIPE. A bar of even
2214
+ // brightness parked across the middle of a pane is a line somebody drew on it:
2215
+ // real glass catches a moving light at its EDGES, where the surface is turned,
2216
+ // and the middle only flashes as the light goes by. So the bar is weighted
2217
+ // toward the rim, and it crosses in a fraction of its cycle and is gone for
2218
+ // the rest — a glint every few seconds, not a permanent diagonal.
2219
+ //
2220
+ // The dashes are the point: `evg-fx-sweep-speed` is a name this painter made
2221
+ // up, it reaches the shader as `p_sweep_speed`, and nothing between the
2222
+ // stylesheet and here had to learn either spelling.
2223
+ //
2224
+ // A MOVING sweep needs frames. `evg-effect-on: always` on the same element is
2225
+ // what tells the host's driver to keep asking for them — the same word a
2226
+ // starfield uses, for the same reason.
2227
+ //
2228
+ // THE SHAPE IS THE ELEMENT'S. The refraction follows `fxBoxDistance`, which is
2229
+ // the rounded box the layout and the sheet agreed on — so a pane is a lens at
2230
+ // whatever size the flex row gave it, with whatever `border-radius` it has,
2231
+ // and nothing here knows either number in advance.
2232
+ //
2233
+ // It composes with the rest of CSS rather than replacing it:
2234
+ //
2235
+ // .glass {
2236
+ // backdrop-filter: blur(14px); /* the frosting, already EVG's */
2237
+ // background-color: rgba(255,255,255,.08); /* the tint, an ordinary fill */
2238
+ // border-radius: 28px; /* the shape, once */
2239
+ // evg-surface-effect: liquid-glass; /* and the refraction */
2240
+ // }
2241
+ registerSurfaceEffect({
2242
+ name: "liquid-glass",
2243
+ layer: "backdrop",
2244
+ // The defaults are a pane about a finger thick. Measured by eye against a
2245
+ // page of stripes, which is the background that shows a bend: on a quiet one
2246
+ // half these numbers reads as a smudge, and twice them reads as a fishbowl.
2247
+ params: {
2248
+ thickness: 24, strength: 32, power: 2.2, disperse: 0.07,
2249
+ shine: 0.6, angle: -60, tint: 0.05,
2250
+ sweep: 0, "sweep-angle": -62, "sweep-width": 0.06,
2251
+ "sweep-speed": 0, "sweep-at": 0.5, "sweep-edge": 2.2,
2252
+ "sweep-rim": 0.55, "sweep-duty": 0.35,
2253
+ },
2254
+ frag: `
2255
+ vec4 fxColor(vec2 p, vec2 local) {
2256
+ float d = fxBoxDistance(p);
2257
+ // 1 at the very edge of the pane, 0 once the surface has flattened out.
2258
+ float t = clamp(1.0 + d / max(p_thickness, 0.5), 0.0, 1.0);
2259
+
2260
+ // WHICH WAY THE SURFACE FACES, as the gradient of the distance field. Taken
2261
+ // by sampling the field rather than with dFdx: the derivative of a rounded
2262
+ // box is exact everywhere except where it matters, along the corners, and
2263
+ // this costs four evaluations of an expression that is already cheap.
2264
+ float e = 1.0;
2265
+ vec2 g = vec2(fxBoxDistance(p + vec2(e, 0.0)) - fxBoxDistance(p - vec2(e, 0.0)),
2266
+ fxBoxDistance(p + vec2(0.0, e)) - fxBoxDistance(p - vec2(0.0, e)));
2267
+ vec2 n = g / max(length(g), 0.0001);
2268
+
2269
+ // The bend: nothing across the flat middle, everything in the band. Sampled
2270
+ // OUTWARD, which is what drags the page around the rim and compresses it
2271
+ // there — the single thing that makes a pane read as an object with an edge
2272
+ // rather than a window with a blur behind it.
2273
+ vec2 off = n * pow(t, max(p_power, 0.01)) * p_strength;
2274
+ // Page pixels to texture coordinates: the page counts y down, GL counts up.
2275
+ vec2 duv = vec2(off.x, -off.y) / uRes;
2276
+
2277
+ // A GLASS EDGE SPLITS COLOUR, because the bend depends on the wavelength.
2278
+ // Small: past about a tenth this stops being glass and becomes a fault in
2279
+ // the display — the same lesson the ripple learned about its own fringing.
2280
+ vec3 col;
2281
+ col.r = texture(uSrc, vUV + duv * (1.0 + p_disperse)).r;
2282
+ col.g = texture(uSrc, vUV + duv).g;
2283
+ col.b = texture(uSrc, vUV + duv * (1.0 - p_disperse)).b;
2284
+ float a = texture(uSrc, vUV + duv).a;
2285
+
2286
+ // THE HIGHLIGHT, inset from the very edge rather than on it. On the edge it
2287
+ // reads as a border somebody drew; a pixel or two in, it reads as a bevel
2288
+ // catching the light. The far side gets a fainter one, which is the light
2289
+ // that went through the pane and came back off the inside of the other rim.
2290
+ vec2 L = vec2(cos(radians(p_angle)), sin(radians(p_angle)));
2291
+ float facing = dot(n, L);
2292
+ float band = pow(t, 3.0) * (1.0 - smoothstep(0.88, 1.0, t));
2293
+ col += vec3(max(facing, 0.0) * band * p_shine
2294
+ + max(-facing, 0.0) * band * p_shine * 0.4);
2295
+
2296
+ // THE SWEEP. A bar of light lying across the pane at its own angle, either
2297
+ // parked or travelling.
2298
+ //
2299
+ // Measured ALONG the bar's direction and normalised by how far that
2300
+ // direction has to travel to cross this box, so one number puts the bar in
2301
+ // the same relative place on a wide card and a small round one — a sweep in
2302
+ // pixels would be off the end of the short one.
2303
+ if (p_sweep > 0.0) {
2304
+ vec2 sdir = vec2(cos(radians(p_sweep_angle)), sin(radians(p_sweep_angle)));
2305
+ vec2 centre = uBox.xy + uBox.zw * 0.5;
2306
+ float span = abs(uBox.z * sdir.x) + abs(uBox.w * sdir.y);
2307
+ float along = dot(p - centre, sdir) / max(span, 1.0) + 0.5;
2308
+ // WHERE THE BAR IS. Travelling from before one edge to past the other, so
2309
+ // it arrives and leaves rather than appearing in the middle — and it does
2310
+ // that in sweep-duty of the cycle, parked far off the pane for the rest.
2311
+ // A glint that is always crossing is a stripe with a slow animation on it.
2312
+ float at = p_sweep_at;
2313
+ if (p_sweep_speed > 0.0) {
2314
+ float duty = clamp(p_sweep_duty, 0.02, 1.0);
2315
+ float travel = fract(uTime * p_sweep_speed) / duty;
2316
+ at = travel <= 1.0 ? travel * 1.4 - 0.2 : 9.0;
2317
+ }
2318
+ float w = max(p_sweep_width, 0.004);
2319
+ float off2 = (along - at) / w;
2320
+ float bar = exp(-off2 * off2);
2321
+ // A thin white core inside a softer halo: one Gaussian alone reads as a
2322
+ // smudge, and the core is what makes it look like a reflection of
2323
+ // something with an edge.
2324
+ float core = exp(-off2 * off2 * 9.0);
2325
+ // KEPT TO THE RIM, by as much as the sheet asked for. t is 1 at the
2326
+ // pane's edge and 0 across its flat middle, so this is the difference
2327
+ // between a light catching a bevel and a line drawn on a window.
2328
+ float rimward = mix(1.0, t, clamp(p_sweep_rim, 0.0, 1.0));
2329
+ col += vec3(bar * 0.5 + core * 0.85) * p_sweep * rimward;
2330
+ // AND WHERE IT MEETS THE RIM. This is the bright segment along part of
2331
+ // the border in every photograph of a polished pane: the bevel is turned
2332
+ // towards the viewer there, so it returns far more of the bar than the
2333
+ // flat middle does. It is the rim band, lit by the bar rather than by the
2334
+ // ambient light.
2335
+ col += vec3(band * bar * p_sweep * p_sweep_edge);
2336
+ }
2337
+
2338
+ // And a flat lift, for glass that is not water-clear. The element's own
2339
+ // background-color is the other half of this and is painted after, so a
2340
+ // sheet can do it from either side.
2341
+ col += vec3(p_tint);
2342
+ return vec4(col, a);
2343
+ }
2344
+ `,
2345
+ });
2346
+ // PLASMA WAVE: ribbons of light crossing a dark ground.
2347
+ //
2348
+ // A SOURCE, and the quietest of the three: no pointer, no state, one clock. A
2349
+ // ribbon is a line whose height is a sum of two sines that do not share a
2350
+ // period, so it never repeats within a page; what is drawn is the DISTANCE to
2351
+ // that line, as a thin core inside a wide glow. Several of them at different
2352
+ // speeds and phases, and the crossings are where the picture comes from —
2353
+ // two ribbons that meet add, and the fold reads as depth that nothing here
2354
+ // computed.
2355
+ //
2356
+ // ribbons how many, 1..8
2357
+ // speed how fast they travel, and they travel at different rates
2358
+ // amp how far they swing from their lane, as a fraction of the box
2359
+ // freq how many crests fit across the box
2360
+ // width the core's half-width in pixels
2361
+ // glow how far the halo reaches past the core, px
2362
+ // sheet a much wider, much dimmer band under it, as a fraction of the
2363
+ // box's height: what makes a ribbon silk rather than a neon tube
2364
+ // hue/hue2 the two ends of the ramp the ribbons are coloured along
2365
+ // grain a dusting of particles along them; 0 is a clean ribbon
2366
+ registerSurfaceEffect({
2367
+ name: "plasma-wave",
2368
+ layer: "source",
2369
+ params: {
2370
+ ribbons: 5, speed: 0.35, amp: 0.22, freq: 1.7, width: 2.2,
2371
+ glow: 26, sheet: 0.16, hue: 225, hue2: 285, grain: 0.35, seed: 1,
2372
+ },
2373
+ frag: `
2374
+ float pwHash(vec2 p) {
2375
+ p = fract(p * vec2(127.31, 311.7));
2376
+ p += dot(p, p + 34.53);
2377
+ return fract(p.x * p.y);
2378
+ }
2379
+ vec3 pwHue(float deg) {
2380
+ float h = fract(deg / 360.0) * 6.0;
2381
+ return clamp(abs(mod(h + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
2382
+ }
2383
+
2384
+ vec4 fxColor(vec2 p, vec2 local) {
2385
+ vec2 q = p - uBox.xy;
2386
+ float w = max(uBox.z, 1.0);
2387
+ float h = max(uBox.w, 1.0);
2388
+ float x = q.x / w;
2389
+
2390
+ vec3 lit = vec3(0.0);
2391
+ float cover = 0.0;
2392
+ int n = int(clamp(p_ribbons, 1.0, 8.0));
2393
+ for (int i = 0; i < 8; i++) {
2394
+ if (i >= n) break;
2395
+ float fi = float(i);
2396
+ float seed = p_seed * 13.0 + fi * 7.31;
2397
+ // Each ribbon has its own lane, its own rate and its own phase, so they
2398
+ // drift apart and cross rather than travelling as a bundle.
2399
+ float lane = 0.5 + (pwHash(vec2(seed, 1.0)) - 0.5) * 0.55;
2400
+ float rate = 0.6 + pwHash(vec2(seed, 2.0)) * 1.1;
2401
+ float phase = pwHash(vec2(seed, 3.0)) * 6.2831;
2402
+ float f1 = p_freq * (0.8 + pwHash(vec2(seed, 4.0)) * 0.6);
2403
+ // TWO SINES THAT DO NOT SHARE A PERIOD. One alone is a wave somebody
2404
+ // drew; the second, at an irrational-ish multiple, is what stops the
2405
+ // shape repeating across the width of a page.
2406
+ float y = lane
2407
+ + p_amp * 0.5 * sin(x * 6.2831 * f1 + phase + uTime * p_speed * rate)
2408
+ + p_amp * 0.28 * sin(x * 6.2831 * f1 * 1.618 - phase * 1.7 - uTime * p_speed * rate * 0.7);
2409
+ float d = abs(q.y - y * h);
2410
+ float core = exp(-(d * d) / max(p_width * p_width, 0.01));
2411
+ float halo = exp(-d / max(p_glow, 1.0)) * 0.45;
2412
+ // AND A SHEET UNDER BOTH: a band far wider than the glow and far dimmer,
2413
+ // which is what makes a ribbon read as a fold of silk rather than as a
2414
+ // neon tube. sheet at 0 leaves the tube, which is also a look.
2415
+ halo += exp(-(d * d) / max(p_sheet * p_sheet * h * h, 0.0001)) * 0.16 * step(0.001, p_sheet);
2416
+ // Coloured along the ribbon rather than across it: the ramp runs with the
2417
+ // travel, so a crest carries one colour into the next one's.
2418
+ vec3 tint = mix(pwHue(p_hue), pwHue(p_hue2),
2419
+ clamp(0.5 + 0.5 * sin(x * 3.1 + fi * 1.3 + uTime * p_speed * 0.4), 0.0, 1.0));
2420
+ float amount = core + halo;
2421
+ lit += tint * amount;
2422
+ // The white middle of a bright ribbon, which is what a photograph of one
2423
+ // looks like: the colour lives in the glow and the core is overexposed.
2424
+ lit += vec3(core * 0.75);
2425
+ cover += amount;
2426
+ }
2427
+
2428
+ // A DUSTING ALONG THEM. Particles where the light already is, so they read
2429
+ // as motes caught in it rather than as noise laid over the top.
2430
+ if (p_grain > 0.0) {
2431
+ // A POINT INSIDE THE CELL, not the cell. The first version lit the whole
2432
+ // 7-pixel square a mote was drawn in, which on a dark ground is a grid of
2433
+ // faint squares across the picture — the particle equivalent of the
2434
+ // starfield's clipped halo, and just as easy to mistake for a texture.
2435
+ vec2 gq = q / 7.0;
2436
+ vec2 gid = floor(gq);
2437
+ vec2 gf = fract(gq);
2438
+ float g = pwHash(gid + p_seed * 3.0);
2439
+ if (g > 0.955) {
2440
+ vec2 at = vec2(pwHash(gid + 1.7), pwHash(gid + 4.3));
2441
+ float d = length((gf - at) * 7.0);
2442
+ float mote = exp(-d * d * 0.9) * pow(pwHash(gid + 9.1), 2.0);
2443
+ float tw = 0.55 + 0.45 * sin(uTime * 2.0 + g * 6.28);
2444
+ float near = clamp(cover * 1.6, 0.0, 1.0);
2445
+ lit += vec3(0.8, 0.85, 1.0) * mote * tw * p_grain * near;
2446
+ cover += mote * 0.4 * p_grain * near;
2447
+ }
2448
+ }
2449
+
2450
+ float a = clamp(cover, 0.0, 1.0);
2451
+ return vec4(lit / max(a, 0.0001), a);
2452
+ }
2453
+ `,
2454
+ });
2455
+
2456
+ // RAINDROP: water on the surface, with the page showing through it.
2457
+ //
2458
+ // A BACKDROP, because that is what a drop IS: a lens. It reads what is behind
2459
+ // the element and bends it through each drop — a drop over a photograph shows
2460
+ // the photograph, magnified and upside down at the edges, and a drop over a
2461
+ // flat dark card shows the card and its own highlight. A source effect could
2462
+ // draw the highlight and never the thing being seen through.
2463
+ //
2464
+ // Every drop is a hash of the cell it sits in, like the starfield's stars, so
2465
+ // a pane of five hundred of them costs no memory and no display list. They do
2466
+ // not fall: rain on a window mostly SITS, and the ones that run are a
2467
+ // different effect. What `speed` does here is drift the whole field slowly, so
2468
+ // a hero does not look like a photograph.
2469
+ //
2470
+ // density how many, by cell size
2471
+ // size how big each one is within its cell
2472
+ // refract how hard the lens bends what is behind it, px
2473
+ // shine the specular dot where the light lands on each drop
2474
+ // angle where that light is, degrees
2475
+ // rim the dark edge opposite the light, which is what gives a drop
2476
+ // its roundness on a flat colour
2477
+ // speed how fast the field drifts
2478
+ registerSurfaceEffect({
2479
+ name: "raindrop",
2480
+ layer: "backdrop",
2481
+ params: {
2482
+ density: 1, size: 0.5, refract: 14, shine: 0.85, angle: -55,
2483
+ rim: 0.55, speed: 0, seed: 1,
2484
+ },
2485
+ frag: `
2486
+ float rdHash(vec2 p) {
2487
+ p = fract(p * vec2(127.31, 311.7));
2488
+ p += dot(p, p + 34.53);
2489
+ return fract(p.x * p.y);
2490
+ }
2491
+ vec2 rdHash2(vec2 p) { return vec2(rdHash(p), rdHash(p + 19.19)); }
2492
+
2493
+ vec4 fxColor(vec2 p, vec2 local) {
2494
+ float cell = max(10.0, 46.0 / max(p_density, 0.05));
2495
+ vec2 drift = vec2(0.0, uTime * p_speed);
2496
+ vec2 g = (p - uBox.xy + drift) / cell;
2497
+ vec2 id = floor(g);
2498
+ vec2 f = fract(g);
2499
+
2500
+ // The nearest drop that covers this pixel. Drops do not overlap in this
2501
+ // field — one per cell, and the radius cannot reach the neighbour — so the
2502
+ // first one found is the answer and there is nothing to sort.
2503
+ float best = 1e9;
2504
+ vec2 bestDir = vec2(0.0);
2505
+ float bestR = 0.0;
2506
+ for (int dy = -1; dy <= 1; dy++) {
2507
+ for (int dx = -1; dx <= 1; dx++) {
2508
+ vec2 o = vec2(float(dx), float(dy));
2509
+ vec2 cid = id + o + p_seed * 5.0;
2510
+ float has = rdHash(cid);
2511
+ if (has < 0.45) continue;
2512
+ // Mostly small, a few large: the same power law the stars use, because
2513
+ // a field of identical circles reads as a pattern and not as rain.
2514
+ float r = cell * 0.5 * p_size * (0.22 + pow(rdHash(cid + 3.3), 2.2) * 0.95);
2515
+ vec2 at = o + 0.2 + rdHash2(cid + 7.7) * 0.6;
2516
+ vec2 v = (f - at) * cell;
2517
+ float d = length(v) - r;
2518
+ if (d < best) { best = d; bestDir = v / max(length(v), 0.0001); bestR = r; }
2519
+ }
2520
+ }
2521
+
2522
+ vec4 behind = texture(uSrc, vUV);
2523
+ if (best > 0.0 || bestR <= 0.0) {
2524
+ // Between the drops the surface is itself. A backdrop effect that tinted
2525
+ // the gaps would be a wet-looking rectangle rather than drops on one.
2526
+ return behind;
2527
+ }
2528
+
2529
+ // INSIDE A DROP. How far from its edge, 0 at the rim and 1 at the middle —
2530
+ // the drop's own dome, which is what both the lens and the light use.
2531
+ float t = clamp(-best / max(bestR, 0.01), 0.0, 1.0);
2532
+ // A sphere's normal, not a cone's: the bend is strongest at the rim and
2533
+ // nothing at all in the middle, so what is behind stays legible through the
2534
+ // centre and smears round the edge.
2535
+ float bend = (1.0 - t) * (1.0 - t) * p_refract;
2536
+ vec2 off = bestDir * bend;
2537
+ vec2 duv = vec2(off.x, -off.y) / uRes;
2538
+ vec3 col = vec3(
2539
+ texture(uSrc, vUV + duv * 1.03).r,
2540
+ texture(uSrc, vUV + duv).g,
2541
+ texture(uSrc, vUV + duv * 0.97).b);
2542
+
2543
+ // THE LIGHT ON IT, and this is the whole of what a drop looks like on a
2544
+ // dark surface: the lens above is looking at black, so without these three
2545
+ // terms a drop is invisible.
2546
+ //
2547
+ // A drop is not a lit sphere. Photograph one on a black pane and what you
2548
+ // see is a THIN BRIGHT CRESCENT just inside the rim on the side AWAY from
2549
+ // the lamp — light that entered the top of the dome, crossed it and left
2550
+ // through the far edge — and a small hard dot on the side towards it. The
2551
+ // body between them stays as dark as the surface. The first version lit the
2552
+ // whole half facing the lamp and came out as a field of white wedges.
2553
+ vec2 L = vec2(cos(radians(p_angle)), sin(radians(p_angle)));
2554
+ float facing = dot(bestDir, L);
2555
+ float edge = 1.0 - t;
2556
+ // A narrow band a tenth of the way in from the rim.
2557
+ float band = exp(-pow((t - 0.11) / 0.075, 2.0));
2558
+ float crescent = pow(max(-facing, 0.0), 2.2) * band * p_shine;
2559
+ // And the dot, further in and much tighter.
2560
+ float dot1 = exp(-pow((t - 0.58) / 0.14, 2.0));
2561
+ float hilite = pow(max(facing, 0.0), 14.0) * dot1 * p_shine * 0.8;
2562
+ // A dark line right at the rim, which is what separates a drop from the
2563
+ // surface it sits on when both are nearly black.
2564
+ col *= 1.0 - pow(edge, 7.0) * p_rim * 0.85;
2565
+ col += vec3(crescent + hilite);
2566
+ // And a hair of light across the dome, because water is not a hole.
2567
+ col += vec3(0.015 * t);
2568
+ return vec4(col, 1.0);
2569
+ }
2570
+ `,
2571
+ });
2572
+
2573
+ // AMBIENT LIGHT: a wash of colour with soft lights adrift in it.
2574
+ //
2575
+ // The quietest effect here and the one most likely to end up behind real
2576
+ // content: a two-colour gradient, a slow fold through it, and a handful of
2577
+ // out-of-focus discs that cross it. Nothing in it is sharp, which is the
2578
+ // point — it is a BACKGROUND, and a background with an edge in it competes
2579
+ // with the text on top.
2580
+ //
2581
+ // hue/hue2 the two ends of the wash
2582
+ // sat how far from grey either end is; a wash at full saturation is
2583
+ // a poster, and this goes behind text
2584
+ // tilt which way the wash runs, degrees
2585
+ // depth how far the wash goes from one end to the other
2586
+ // orbs how many lights, 0..10
2587
+ // size how big they are, as a fraction of the box
2588
+ // softness how far out of focus: 1 is a disc with a soft edge, 2 is a haze
2589
+ // speed how fast they drift
2590
+ // glow how bright they are
2591
+ registerSurfaceEffect({
2592
+ name: "ambient-light",
2593
+ layer: "source",
2594
+ params: {
2595
+ hue: 190, hue2: 268, sat: 0.5, tilt: 25, depth: 0.5,
2596
+ orbs: 7, size: 0.55, softness: 1.6, speed: 0.06, glow: 0.45, seed: 1,
2597
+ },
2598
+ frag: `
2599
+ float alHash(vec2 p) {
2600
+ p = fract(p * vec2(127.31, 311.7));
2601
+ p += dot(p, p + 34.53);
2602
+ return fract(p.x * p.y);
2603
+ }
2604
+ vec3 alHue(float deg) {
2605
+ float h = fract(deg / 360.0) * 6.0;
2606
+ return clamp(abs(mod(h + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
2607
+ }
2608
+
2609
+ vec4 fxColor(vec2 p, vec2 local) {
2610
+ vec2 q = (p - uBox.xy) / max(uBox.zw, vec2(1.0));
2611
+ float minSide = max(min(uBox.z, uBox.w), 1.0);
2612
+ float a = radians(p_tilt);
2613
+ vec2 dir = vec2(cos(a), sin(a));
2614
+ // The wash, with a slow fold through it so it is not a linear gradient a
2615
+ // stylesheet could have drawn.
2616
+ float along = dot(q - 0.5, dir) + 0.5;
2617
+ along += 0.12 * sin(q.x * 3.1 + uTime * p_speed * 1.7) * sin(q.y * 2.3 - uTime * p_speed * 1.1);
2618
+
2619
+ // MUTED ON PURPOSE. A hue at full saturation is a poster; this goes BEHIND
2620
+ // text, and the first version came out as a neon gradient that would eat
2621
+ // anything laid on it. sat mixes each end towards grey before either is
2622
+ // used, and depth is how bright the result is allowed to be.
2623
+ vec3 c1 = mix(vec3(0.5), alHue(p_hue), clamp(p_sat, 0.0, 1.0));
2624
+ vec3 c2 = mix(vec3(0.5), alHue(p_hue2), clamp(p_sat, 0.0, 1.0));
2625
+ vec3 wash = mix(c1, c2, clamp(along, 0.0, 1.0)) * p_depth;
2626
+
2627
+ // THE LIGHTS, in pixels rather than in the box's own units — a disc
2628
+ // measured in a stretched space comes out an ellipse, and the first version
2629
+ // squashed every one of them by the box's aspect.
2630
+ vec3 lit = vec3(0.0);
2631
+ int n = int(clamp(p_orbs, 0.0, 10.0));
2632
+ for (int i = 0; i < 10; i++) {
2633
+ if (i >= n) break;
2634
+ float fi = float(i);
2635
+ float s = p_seed * 17.0 + fi * 4.77;
2636
+ vec2 home = vec2(alHash(vec2(s, 1.0)), alHash(vec2(s, 2.0)));
2637
+ float rate = 0.4 + alHash(vec2(s, 3.0)) * 1.2;
2638
+ // A slow ellipse rather than a straight line: a light that crosses the
2639
+ // box and comes back is a light that never leaves it.
2640
+ vec2 at = home + 0.18 * vec2(
2641
+ sin(uTime * p_speed * rate + fi * 2.1),
2642
+ cos(uTime * p_speed * rate * 0.8 + fi * 1.3));
2643
+ float r = max(p_size * (0.35 + alHash(vec2(s, 4.0)) * 0.65) * 0.5 * minSide, 2.0);
2644
+ float dn = length((q - at) * uBox.zw) / r;
2645
+ float soft = max(p_softness, 0.2);
2646
+ // A BOKEH DISC, which is not a blurred dot: an out-of-focus highlight is
2647
+ // flat across its middle and brighter at its edge, because the lens maps
2648
+ // a point onto its own aperture. That ring is why these read as lights
2649
+ // out of focus rather than as smudges.
2650
+ float body = pow(clamp(1.0 - dn, 0.0, 1.0), soft);
2651
+ // The ring is a HINT, not the shape. At a half of the body it reads as a
2652
+ // donut — a gear, even, once two of them overlap — and the thing it is
2653
+ // meant to suggest is only that the light has an aperture behind it.
2654
+ float ring = exp(-pow((dn - 0.8) / (0.34 * soft), 2.0)) * 0.16;
2655
+ vec3 tint = mix(c2, c1, alHash(vec2(s, 5.0)));
2656
+ lit += (tint * 0.75 + vec3(0.25)) * (body * 0.8 + ring) * p_glow;
2657
+ }
2658
+
2659
+ vec3 col = wash + lit;
2660
+ float alpha = clamp(max(max(col.r, col.g), col.b), 0.0, 1.0);
2661
+ return vec4(col / max(alpha, 0.0001), alpha);
2662
+ }
2663
+ `,
2664
+ });
2665
+
2666
+ // THE SMOKE, as a wall of it rising through the box.
2667
+ //
2668
+ // A cloud is not a noise field with a threshold on it: what makes smoke read
2669
+ // as smoke is that it CURLS, and curls at every size at once. So the field is
2670
+ // DOMAIN WARPED — fbm evaluated at a point that two other fbms have already
2671
+ // moved — which is the cheapest way to get a turbulent flow out of a function
2672
+ // with no flow in it. One warp gives billows, two gives the fine tendrils that
2673
+ // come off their edges.
2674
+ //
2675
+ // The body is heavy at the bottom and thins upward, because the reference for
2676
+ // this is a stage full of it: dense along the floor, wisps reaching past the
2677
+ // halfway line, and black above.
2678
+ registerSurfaceEffect({
2679
+ name: "smoke",
2680
+ layer: "source",
2681
+ // The defaults are the stage: a bank of it along the floor, billowing to
2682
+ // about the middle and thinning to single tendrils above that. Arrived at
2683
+ // against the photograph this was written from, at 500 by 300.
2684
+ params: {
2685
+ density: 1.2, rise: 0.05, wind: 0, swirl: 2.6, scale: 190, detail: 5,
2686
+ height: 0.9, softness: 0.55, shade: 0.6, angle: -60,
2687
+ hue: 205, tint: 0.1, seed: 1,
2688
+ },
2689
+ frag: `
2690
+ float smHash21(vec2 p) {
2691
+ p = fract(p * vec2(127.31, 311.7));
2692
+ p += dot(p, p + 34.53);
2693
+ return fract(p.x * p.y);
2694
+ }
2695
+ float smNoise(vec2 p) {
2696
+ vec2 i = floor(p);
2697
+ vec2 f = fract(p);
2698
+ f = f * f * (3.0 - 2.0 * f);
2699
+ float a = smHash21(i);
2700
+ float b = smHash21(i + vec2(1.0, 0.0));
2701
+ float c = smHash21(i + vec2(0.0, 1.0));
2702
+ float d = smHash21(i + vec2(1.0, 1.0));
2703
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
2704
+ }
2705
+ // Octaves are a PARAMETER, not a constant: the tendrils are the last two and
2706
+ // they are also most of the cost, so a page that wants a quiet haze behind
2707
+ // text can have it for a third of the arithmetic.
2708
+ float smFbm(vec2 p) {
2709
+ float sum = 0.0;
2710
+ float amp = 0.5;
2711
+ int n = int(clamp(p_detail, 1.0, 6.0));
2712
+ for (int i = 0; i < 6; i++) {
2713
+ if (i >= n) break;
2714
+ sum += amp * smNoise(p);
2715
+ p = p * 2.07 + vec2(17.3, 9.1);
2716
+ amp *= 0.5;
2717
+ }
2718
+ return sum;
2719
+ }
2720
+
2721
+ // A hue as a colour, for the cast over the grey. Smoke is not coloured, it is
2722
+ // lit by something that is: a stage gel, a street lamp, a screen.
2723
+ vec3 smHue(float deg) {
2724
+ float h = fract(deg / 360.0) * 6.0;
2725
+ return clamp(abs(mod(h + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
2726
+ }
2727
+
2728
+ // The warped field at a point, in the box's own noise space. The two warps are
2729
+ // out of step with each other on purpose — a warp that moved with its own
2730
+ // source would breathe rather than roll.
2731
+ float smField(vec2 q, float t) {
2732
+ vec2 s = vec2(p_seed * 13.7, p_seed * 7.3);
2733
+ vec2 w1 = vec2(smFbm(q + s), smFbm(q + s + vec2(5.2, 1.3)));
2734
+ vec2 w2 = vec2(
2735
+ smFbm(q + 2.0 * w1 + s + vec2(1.7, 9.2) - vec2(0.0, t * 0.6)),
2736
+ smFbm(q + 2.0 * w1 + s + vec2(8.3, 2.8)));
2737
+ return smFbm(q + p_swirl * w2);
2738
+ }
2739
+
2740
+ vec4 fxColor(vec2 p, vec2 local) {
2741
+ float t = uTime * p_rise;
2742
+ vec2 q = (p - uBox.xy) / max(p_scale, 8.0);
2743
+ q.x += uTime * p_wind * 0.02;
2744
+ // Rising, and the whole field a little slower than the warp above, so the
2745
+ // billows travel through the smoke rather than with it.
2746
+ q.y -= t;
2747
+
2748
+ float f = smField(q, t);
2749
+
2750
+ // HOW HIGH IT REACHES. What rises is not the smoke but the BAR it has to
2751
+ // clear: at the floor almost any of the field shows, and by the top of the
2752
+ // body only the strongest part of it does, which is what leaves single
2753
+ // tendrils standing in the black instead of a fog that fades out evenly.
2754
+ float up = clamp(1.0 - local.y, 0.0, 1.0);
2755
+ float thr = 0.3 + 0.78 * (up / max(p_height, 0.03));
2756
+ float d = f * max(p_density, 0.0);
2757
+
2758
+ float soft = max(p_softness, 0.02);
2759
+ float a = smoothstep(thr - soft * 0.5, thr + soft * 0.5, d);
2760
+ // And thin where the field is thin, so an edge is smoke running out rather
2761
+ // than a cut-out of it.
2762
+ a *= 0.55 + 0.45 * smoothstep(0.2, 0.9, d);
2763
+ if (a <= 0.002) return vec4(0.0);
2764
+
2765
+ // LIT FROM ONE SIDE, by comparing the field with itself a step toward the
2766
+ // light: where the smoke is thinning in that direction the step is lower and
2767
+ // the pixel is a lit face; where it is thickening the step is higher and the
2768
+ // pixel is in shadow. That is a gradient, and a gradient of a field this
2769
+ // turbulent is what gives a cloud its volume.
2770
+ float sh = 1.0;
2771
+ if (p_shade > 0.0) {
2772
+ vec2 dir = vec2(cos(radians(p_angle)), sin(radians(p_angle)));
2773
+ float g = smField(q + dir * 0.22, t) - f;
2774
+ sh = clamp(1.0 - g * 5.0 * p_shade, 0.45, 1.3);
2775
+ }
2776
+
2777
+ // Smoke is not white: it is grey, and it is bright only where it is thick
2778
+ // and lit. A plume that reaches 1.0 everywhere reads as paper.
2779
+ vec3 grey = vec3(0.84, 0.86, 0.89);
2780
+ vec3 tinted = mix(grey, smHue(p_hue), clamp(p_tint, 0.0, 1.0));
2781
+ vec3 col = tinted * sh * (0.3 + 0.45 * min(d, 1.0));
2782
+ return vec4(clamp(col, 0.0, 1.0), clamp(a, 0.0, 1.0));
2783
+ }
2784
+ `,
2785
+ });
2786
+
2787
+ function programsFor(gl) {
2788
+ const found = PROGRAMS.get(gl);
2789
+ if (found) return found;
2790
+ const prog = gl.createProgram();
2791
+ gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VERT));
2792
+ gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FRAG));
2793
+ gl.linkProgram(prog);
2794
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
2795
+ throw new Error("link: " + gl.getProgramInfoLog(prog));
2796
+ }
2797
+ const blurProg = gl.createProgram();
2798
+ gl.attachShader(blurProg, compile(gl, gl.VERTEX_SHADER, BLUR_VERT));
2799
+ gl.attachShader(blurProg, compile(gl, gl.FRAGMENT_SHADER, BLUR_FRAG));
2800
+ gl.linkProgram(blurProg);
2801
+ if (!gl.getProgramParameter(blurProg, gl.LINK_STATUS)) {
2802
+ throw new Error("link blur: " + gl.getProgramInfoLog(blurProg));
2803
+ }
2804
+ const presentProg = gl.createProgram();
2805
+ gl.attachShader(presentProg, compile(gl, gl.VERTEX_SHADER, FX_VERT));
2806
+ gl.attachShader(presentProg, compile(gl, gl.FRAGMENT_SHADER, FX_PRESENT_FRAG));
2807
+ gl.linkProgram(presentProg);
2808
+ if (!gl.getProgramParameter(presentProg, gl.LINK_STATUS)) {
2809
+ throw new Error("link present: " + gl.getProgramInfoLog(presentProg));
2810
+ }
2811
+ const rippleProg = gl.createProgram();
2812
+ gl.attachShader(rippleProg, compile(gl, gl.VERTEX_SHADER, RIPPLE_VERT));
2813
+ gl.attachShader(rippleProg, compile(gl, gl.FRAGMENT_SHADER, RIPPLE_FRAG));
2814
+ gl.linkProgram(rippleProg);
2815
+ if (!gl.getProgramParameter(rippleProg, gl.LINK_STATUS)) {
2816
+ throw new Error("link ripple: " + gl.getProgramInfoLog(rippleProg));
2817
+ }
2818
+
2819
+ const backdropProg = gl.createProgram();
2820
+ gl.attachShader(backdropProg, compile(gl, gl.VERTEX_SHADER, BACKDROP_VERT));
2821
+ gl.attachShader(backdropProg, compile(gl, gl.FRAGMENT_SHADER, BACKDROP_FRAG));
2822
+ gl.linkProgram(backdropProg);
2823
+ if (!gl.getProgramParameter(backdropProg, gl.LINK_STATUS)) {
2824
+ throw new Error("link backdrop: " + gl.getProgramInfoLog(backdropProg));
2825
+ }
2826
+ const pathProg = gl.createProgram();
2827
+ gl.attachShader(pathProg, compile(gl, gl.VERTEX_SHADER, PATH_VERT));
2828
+ gl.attachShader(pathProg, compile(gl, gl.FRAGMENT_SHADER, PATH_FRAG));
2829
+ gl.linkProgram(pathProg);
2830
+ if (!gl.getProgramParameter(pathProg, gl.LINK_STATUS)) {
2831
+ throw new Error("link path: " + gl.getProgramInfoLog(pathProg));
2832
+ }
2833
+ // The locations belong to the program, so they are cached with it rather
2834
+ // than looked up per frame.
2835
+ const made = {
2836
+ prog,
2837
+ pathProg,
2838
+ cornerLoc: gl.getAttribLocation(prog, "aCorner"),
2839
+ uPage: gl.getUniformLocation(prog, "uPage"),
2840
+ uShift: gl.getUniformLocation(prog, "uShift"),
2841
+ uView: gl.getUniformLocation(prog, "uView"),
2842
+ uSnap: gl.getUniformLocation(prog, "uSnap"),
2843
+ uAtlas: gl.getUniformLocation(prog, "uAtlas"),
2844
+ uImage: gl.getUniformLocation(prog, "uImage"),
2845
+ pathPosLoc: gl.getAttribLocation(pathProg, "aPos"),
2846
+ pathPageLoc: gl.getUniformLocation(pathProg, "uPage"),
2847
+ pathShiftLoc: gl.getUniformLocation(pathProg, "uShift"),
2848
+ pathViewLoc: gl.getUniformLocation(pathProg, "uView"),
2849
+ pathColorLoc: gl.getUniformLocation(pathProg, "uColor"),
2850
+ pathColor2Loc: gl.getUniformLocation(pathProg, "uColor2"),
2851
+ pathGradLoc: gl.getUniformLocation(pathProg, "uGrad"),
2852
+ pathBoxLoc: gl.getUniformLocation(pathProg, "uBox"),
2853
+ blurProg,
2854
+ blurCornerLoc: gl.getAttribLocation(blurProg, "aCorner"),
2855
+ blurSrc: gl.getUniformLocation(blurProg, "uSrc"),
2856
+ blurStep: gl.getUniformLocation(blurProg, "uStep"),
2857
+ blurWidth: gl.getUniformLocation(blurProg, "uWidth"),
2858
+ blurOffset: gl.getUniformLocation(blurProg, "uOffset"),
2859
+ presentProg,
2860
+ presentPosLoc: gl.getAttribLocation(presentProg, "aPos"),
2861
+ presentSrc: gl.getUniformLocation(presentProg, "uSrc"),
2862
+ rippleProg,
2863
+ ripplePosLoc: gl.getAttribLocation(rippleProg, "aPos"),
2864
+ rippleSrc: gl.getUniformLocation(rippleProg, "uSrc"),
2865
+ rippleRes: gl.getUniformLocation(rippleProg, "uRes"),
2866
+ rippleDrops: gl.getUniformLocation(rippleProg, "uDrops"),
2867
+ rippleCount: gl.getUniformLocation(rippleProg, "uCount"),
2868
+ rippleSpeed: gl.getUniformLocation(rippleProg, "uSpeed"),
2869
+ rippleWidth: gl.getUniformLocation(rippleProg, "uWidth"),
2870
+ rippleStrength: gl.getUniformLocation(rippleProg, "uStrength"),
2871
+ rippleDecay: gl.getUniformLocation(rippleProg, "uDecay"),
2872
+ rippleHi: gl.getUniformLocation(rippleProg, "uHi"),
2873
+ rippleRings: gl.getUniformLocation(rippleProg, "uRings"),
2874
+ rippleStagger: gl.getUniformLocation(rippleProg, "uStagger"),
2875
+ rippleFalloff: gl.getUniformLocation(rippleProg, "uFalloff"),
2876
+ rippleShine: gl.getUniformLocation(rippleProg, "uShine"),
2877
+ rippleGloss: gl.getUniformLocation(rippleProg, "uGloss"),
2878
+ rippleBump: gl.getUniformLocation(rippleProg, "uBump"),
2879
+ rippleLight: gl.getUniformLocation(rippleProg, "uLight"),
2880
+ backdropProg,
2881
+ backdropPosLoc: gl.getAttribLocation(backdropProg, "aPos"),
2882
+ backdropUVLoc: gl.getAttribLocation(backdropProg, "aUV"),
2883
+ backdropCornerLoc: gl.getAttribLocation(backdropProg, "aCorner"),
2884
+ backdropSrc: gl.getUniformLocation(backdropProg, "uSrc"),
2885
+ backdropHalf: gl.getUniformLocation(backdropProg, "uHalf"),
2886
+ backdropRadius: gl.getUniformLocation(backdropProg, "uRadius"),
2887
+ };
2888
+ PROGRAMS.set(gl, made);
2889
+ return made;
2890
+ }
2891
+
2892
+ /**
2893
+ * A FRAME, BUILT AND KEPT.
2894
+ *
2895
+ * Everything the painter makes from a display list before it can draw it —
2896
+ * the instance arrays, the buffers on the card, the runs, the paths'
2897
+ * geometry — is made here once and kept on the object this returns. `draw`
2898
+ * puts it on the screen, as many times as asked; `dispose` gives the card
2899
+ * its memory back.
2900
+ *
2901
+ * What that is for is a SCROLL. The app keeps its list across a fling and
2902
+ * moves the layer inside it (`EVGDisplayList.refreshLayers`), so the frame
2903
+ * built from that list is still the right frame, moved: `draw` takes the
2904
+ * layers' current shifts, subtracts what was built in, and hands the
2905
+ * difference to the shaders as a uniform, per run. A scroll frame is then
2906
+ * the uniform, the scissors and the draw calls — no arrays, no upload —
2907
+ * which is what the browser's own compositor does with a layer, and why a
2908
+ * page scrolled this way stops stuttering where one rebuilt every frame
2909
+ * did not.
2910
+ *
2911
+ * And what it is ALSO for is a PAN. A list may carry a camera (`view`, see
2912
+ * PLAN_VIEW_TRANSFORM.md), and `draw` takes one too: the same frame drawn at
2913
+ * a different view is the same picture somewhere else, for the cost of a
2914
+ * uniform. A zoom is not as free — the glyph atlas was rasterised at a size
2915
+ * and the curves were flattened for one — so a host that moves the scale far
2916
+ * builds again; `viewBand` below says how far is far.
2917
+ *
2918
+ * `renderDisplayList` below is build, draw once, dispose: what a page that
2919
+ * does not keep its list wants, and what every caller had before.
2920
+ */
2921
+ export function prepareDisplayList(gl, doc, opts = {}) {
2922
+ return buildFrame(gl, doc, opts);
2923
+ }
2924
+
2925
+ /** The camera as the shaders want it: (sx, sy, tx, ty). */
2926
+ export function viewVec(v) {
2927
+ if (!v) return [1, 1, 0, 0];
2928
+ if (Array.isArray(v)) return v.length >= 4 ? v : [v[2] ?? 1, v[2] ?? 1, v[0] ?? 0, v[1] ?? 0];
2929
+ const s = v.scale === undefined || v.scale === null ? 1 : v.scale;
2930
+ return [s, s, v.x || 0, v.y || 0];
2931
+ }
2932
+
2933
+ /**
2934
+ * How far a frame built at one view may be drawn at another.
2935
+ *
2936
+ * A PAN is exact: nothing in a built frame depends on where it is. A ZOOM is
2937
+ * not — the atlas is a rasterisation at a size and a curve was flattened for
2938
+ * one — so past a band the frame has to be built again. √2 either way is
2939
+ * about three notches of a pinch, and a viewer that would rather have sharp
2940
+ * text than frames passes a tighter one.
2941
+ */
2942
+ export const VIEW_BAND = Math.SQRT2;
2943
+
2944
+ /** Can a frame built at `built` be drawn at `now` without building again? */
2945
+ export function viewWithinBand(built, now, band = VIEW_BAND) {
2946
+ const a = viewVec(built)[0] || 1;
2947
+ const b = viewVec(now)[0] || 1;
2948
+ const r = b / a;
2949
+ return r <= band && r >= 1 / band;
2950
+ }
2951
+
2952
+ export function renderDisplayList(gl, doc, opts = {}) {
2953
+ const frame = buildFrame(gl, doc, opts);
2954
+ const stats = frame.draw(opts.shifts);
2955
+ frame.dispose();
2956
+ return stats;
2957
+ }
2958
+
2959
+ function buildFrame(gl, doc, opts = {}) {
2960
+ const dpr = opts.dpr || 1;
2961
+ const images = opts.images || new Map();
2962
+ const cmds = doc.list.cmds;
2963
+ // The camera the LIST carried, if it carried one. A list with no view is in
2964
+ // page coordinates already, which is every list written before this existed
2965
+ // and every export that has no camera to speak of.
2966
+ const viewOfDoc = doc.view || (doc.list && doc.list.view) || null;
2967
+
2968
+ const built = programsFor(gl);
2969
+ const prog = built.prog;
2970
+ gl.useProgram(prog);
2971
+
2972
+ // The atlas, kept until the glyphs in it change. Building it means laying
2973
+ // out every run on a 2-D canvas and uploading the result — and the runs are
2974
+ // the same from one frame to the next almost always, because a frame that
2975
+ // differs by a moved shape has not changed a single letter.
2976
+ // THE ATLAS IS RASTERISED AT THE SIZE THE RUNS WILL BE DRAWN AT, which is
2977
+ // the page's device ratio times the scale the frame is built at. A list
2978
+ // with a camera is in scene units, so a 12-unit run on a board at 10% is
2979
+ // 1.2 device pixels: rasterising it at 12 and letting the card shrink it
2980
+ // costs a hundred times the atlas and comes out soft, which is what the
2981
+ // figma board did the first time it was drawn this way. Everything the
2982
+ // slot reports is divided by the same number, so the quad stays in scene
2983
+ // units and the shader's scale puts the ink back at 1:1.
2984
+ // The scale this frame is BUILT at. Everything that has to be measured in
2985
+ // pixels while the list is in scene units divides by it: the atlas below,
2986
+ // and the hairline floor under a stroke.
2987
+ const viewScaleOfFrame = viewVec(viewOfDoc)[0] || 1;
2988
+ const textDpr = dpr * viewScaleOfFrame;
2989
+ const { tex: atlas, slots, rebuilt: atlasRebuilt, added: atlasAdded } = atlasFor(gl, cmds, textDpr, { w: doc.width, h: doc.height });
2990
+
2991
+ // One texture per distinct source, uploaded ONCE — not once per frame. This
2992
+ // used to make a new GL texture for every picture on every frame and never
2993
+ // delete any of them: a decode and an upload per frame, and a leak.
2994
+ const textures = textureCacheFor(gl, images);
2995
+ const texturesUploaded = textures.uploaded | 0;
2996
+
2997
+ // Instances in paint order, plus the runs that must be drawn separately.
2998
+ // A run is a stretch of instances that share a texture binding; an image
2999
+ // ends the run before it and forms one of its own.
3000
+ const rects = [], colors = [], colors2 = [], grads = [], shapes = [], uvs = [], rots = [], radii = [];
3001
+ // `rc` is on a command only when its four corners differ; a box with one
3002
+ // radius still carries a single `r`, so nothing that reads this list had to
3003
+ // change to keep working.
3004
+ const pushRadii = (c) => {
3005
+ if (c.rc) radii.push(c.rc[0], c.rc[1], c.rc[2], c.rc[3]);
3006
+ else { const r = c.r || 0; radii.push(r, r, r, r); }
3007
+ };
3008
+ // Three floats per command, not two: a pivot at (0, 0) is a legal place to
3009
+ // turn about, so "is there one" cannot be read off the coordinates.
3010
+ const origins = [];
3011
+ const runs = [];
3012
+ // THE EFFECT INSTANCES this list carries, by name. Commands refer to one by
3013
+ // `efx`, which is the element's own id: a name survives the display list's
3014
+ // fragment cache, where an index into an array rebuilt this frame would not.
3015
+ const fxById = new Map();
3016
+ for (const e of (doc.list.effects || [])) fxById.set(e.id, e);
3017
+ let missingImages = 0, drawnImages = 0;
3018
+ // The clip in force, as a rectangle or null. A clip is a scissor here, and a
3019
+ // scissor is pipeline state rather than per-instance data, so a change of
3020
+ // clip has to END the run being gathered — everything in a run is drawn by
3021
+ // one call and one call has one scissor.
3022
+ //
3023
+ // This used to be skipped outright, with a note promising a scissor stack.
3024
+ // What that cost was not subtle: a label wider than its cell was drawn in
3025
+ // full, across whatever its neighbour had in it and over the row numbers,
3026
+ // because the clip that said where to stop was thrown away between the
3027
+ // display list and the screen.
3028
+ // The clip is the LIST of rectangles in force, each with the layer it was
3029
+ // written in, and it is intersected when it is applied rather than here:
3030
+ // a clip written inside a scroll layer moves with the layer's content,
3031
+ // the layer's own clip does not, and which rectangle wins between them
3032
+ // depends on how far the content has moved by the time the frame is
3033
+ // drawn — which a kept frame does not know yet.
3034
+ const clipStack = [];
3035
+ let clip = null;
3036
+ // The scroll layer the commands being gathered belong to: 0 outside any,
3037
+ // else the id the list put on the clip that opened it.
3038
+ const layerStack = [];
3039
+ let layer = 0;
3040
+ const pushRun = (start, count, tex, ownLayer) => {
3041
+ if (count > 0) runs.push({ kind: "quads", start, count, tex, clip, layer: ownLayer ?? layer });
3042
+ };
3043
+ // A COMMAND THAT SAYS WHICH LAYER IT IS IN. Layers are otherwise read off
3044
+ // the clip nesting, which is right for everything drawn inside a scroller —
3045
+ // and the focus ring is drawn LAST and outside every clip, so that a button
3046
+ // in a panel is not half-ringed by the panel's edge. Outside every clip is
3047
+ // outside every layer, so a kept frame moved by `uShift` moved the whole
3048
+ // page and left the ring where it was. The list now marks the ring with the
3049
+ // layer its element scrolls in (`EVGDisplayList.ringAround`) and this is
3050
+ // what reads it.
3051
+ let ownLayer = null;
3052
+ const flushOwn = () => { if (ownLayer !== null) { flush(); ownLayer = null; } };
3053
+ let runStart = 0;
3054
+ const flush = () => {
3055
+ const n = rects.length / 4;
3056
+ pushRun(runStart, n - runStart, null, ownLayer);
3057
+ runStart = n;
3058
+ };
3059
+ // Path geometry is not instanced quads, so a path ends the run before it and
3060
+ // becomes a run of its own — which is what keeps the paint order intact.
3061
+ const pushPath = (op) => { flush(); runs.push(Object.assign(op, { clip, layer })); };
3062
+
3063
+ for (const c of cmds) {
3064
+ // A command of its own layer starts its own run and ends it after — see
3065
+ // `ownLayer` above. Only the ring uses this; everything else takes the
3066
+ // layer of the clip it is inside.
3067
+ if (c.k !== KIND.PUSH_CLIP && c.k !== KIND.POP_CLIP) {
3068
+ const mine = c.layer > 0 ? c.layer : null;
3069
+ if (mine !== ownLayer) { flush(); ownLayer = mine; }
3070
+ }
3071
+ // A backdrop blur reads the framebuffer as it stands, so it has to happen
3072
+ // between the runs before it and the runs after it — the same reason a
3073
+ // path breaks the batch. The command then goes on to emit its own fill
3074
+ // below, which composites over the blur exactly as the browser does.
3075
+ if (c.k === KIND.RECT && c.bb > 0) {
3076
+ flush();
3077
+ runs.push({ kind: "backdrop", cmd: c, clip, layer });
3078
+ }
3079
+ // A BACKDROP EFFECT, for the same reason and at the same point: it reads
3080
+ // what is behind the element, so it has to run before the element's own
3081
+ // background — and everything the element is and contains is then painted
3082
+ // over it, unrefracted. `backdrop-filter: blur()` on the same box runs
3083
+ // first, which is what makes frosted-then-refracted one declaration each.
3084
+ if (c.k === KIND.RECT && c.efx) {
3085
+ const inst = fxById.get(c.efx);
3086
+ const plug = inst && FX_PLUGINS.get(inst.kind);
3087
+ if (plug && plug.layer === "backdrop") {
3088
+ flush();
3089
+ runs.push({ kind: "fx", inst, backdrop: true, clip, layer });
3090
+ }
3091
+ }
3092
+ if (c.k === KIND.PUSH_CLIP) {
3093
+ flush();
3094
+ clipStack.push(clip);
3095
+ layerStack.push(layer);
3096
+ clip = (clip || []).concat([{ x: c.x, y: c.y, w: c.w, h: c.h, layer }]);
3097
+ if (c.layer > 0) layer = c.layer;
3098
+ continue;
3099
+ }
3100
+ if (c.k === KIND.POP_CLIP) {
3101
+ flush();
3102
+ clip = clipStack.length ? clipStack.pop() : null;
3103
+ layer = layerStack.length ? layerStack.pop() : 0;
3104
+ continue;
3105
+ }
3106
+ if (c.k === KIND.PATH || c.k === KIND.STROKE) {
3107
+ const rings = ringsOf(c);
3108
+ if (!rings.length) continue;
3109
+ const col = c.c || [0, 0, 0, 1];
3110
+ const rgba = [col[0] / 255, col[1] / 255, col[2] / 255, col[3]];
3111
+ if (c.k === KIND.STROKE) {
3112
+ // Dashes are geometry, not a paint mode: the pattern cuts the
3113
+ // rings and what is left is stroked exactly as a solid line is,
3114
+ // caps and all — which is what puts a round end on each dash.
3115
+ const pat = parseDash(c.dash);
3116
+ const dashed = pat.length ? dashRings(rings, pat, c.dashoff || 0) : rings;
3117
+ const tris = strokeTriangles(dashed, c.t || 1, c.cap | 0, c.join | 0, viewScaleOfFrame);
3118
+ if (tris.length) pushPath({ kind: "tris", verts: new Float32Array(tris), color: rgba });
3119
+ } else {
3120
+ // A vector filled with a gradient keeps BOTH now: the rings say what
3121
+ // shape it is, `c2` and `gd` how it is shaded. Flattened to one
3122
+ // colour it kept the shape and lost the ramp, which was the right
3123
+ // half to keep and only half of it.
3124
+ const far = c.c2 || col;
3125
+ pushPath({ kind: "fill", rings: rings.map((r) => new Float32Array(r)),
3126
+ bounds: boundsOf(rings), evenOdd: !!c.eo, color: rgba,
3127
+ grad: c.c2 ? (c.gd === 1 ? 2 : 1) : 0,
3128
+ color2: [far[0] / 255, far[1] / 255, far[2] / 255, far[3]] });
3129
+ }
3130
+ continue;
3131
+ }
3132
+ if (c.k === KIND.IMAGE) {
3133
+ const t = c.src && textures.get(c.src);
3134
+ if (!t) { missingImages += 1; continue; }
3135
+ // Everything queued so far has to be drawn BEFORE this photo, or the
3136
+ // page paints out of order.
3137
+ flush();
3138
+ // A crop is a UV rectangle the command brought with it, in the
3139
+ // bitmap's own 0..1 space; cover is the one computed here when it did
3140
+ // not. Either way the quad is the box and only the sampling changes,
3141
+ // so a cropped photo costs nothing extra.
3142
+ const uv = c.cu ? c.cu : coverUV(t.w, t.h, c.w, c.h);
3143
+ // Mirrored is the same quad read the other way round: aUV is
3144
+ // (u0,v0,u1,v1) and the fragment mixes between them, so swapping the
3145
+ // ends flips the picture without touching the geometry.
3146
+ const u0 = c.fx ? uv[2] : uv[0];
3147
+ const u1 = c.fx ? uv[0] : uv[2];
3148
+ const v0 = c.fy ? uv[3] : uv[1];
3149
+ const v1 = c.fy ? uv[1] : uv[3];
3150
+ rects.push(c.x, c.y, c.w, c.h);
3151
+ uvs.push(u0, v0, u1, v1);
3152
+ shapes.push(c.r || 0, 0, MODE.IMAGE);
3153
+ pushRadii(c);
3154
+ rots.push(((c.rot || 0) * Math.PI) / 180);
3155
+ origins.push(c.rox || 0, c.roy || 0, c.rox === undefined ? 0 : 1);
3156
+ colors.push(1, 1, 1, 1);
3157
+ colors2.push(1, 1, 1, 1);
3158
+ grads.push(0);
3159
+ pushRun(runStart, 1, t.tex);
3160
+ runStart = rects.length / 4;
3161
+ drawnImages += 1;
3162
+ continue;
3163
+ }
3164
+ if (c.k === KIND.TEXT) {
3165
+ const s = slots.get(runKey(c, textDpr));
3166
+ if (!s) continue;
3167
+ // EVG's y is the top of the LINE BOX and c.h is its height, so the
3168
+ // baseline is the CSS half-leading below the top plus one face ascent:
3169
+ //
3170
+ // baseline = y + (lineBox - (faceAsc + faceDesc)) / 2 + faceAsc
3171
+ //
3172
+ // The half-leading term was missing, and it is missing in only one
3173
+ // direction: a line box is normally TALLER than the face it holds, so
3174
+ // every run was drawn that much high — 0.54px for 13px text at
3175
+ // line-height 1.2, and most of a pixel at reading sizes. It is computed
3176
+ // here rather than in the layout on purpose: this is where the real face
3177
+ // metrics are, and the layout only ever has an estimate of them.
3178
+ //
3179
+ // Inside the slot the baseline is pad + ink ascent down from the top.
3180
+ // Line the two up. This is the same rule the software canvas draws by,
3181
+ // which is what makes a page look the same whichever backend drew it.
3182
+ // Everything in the slot is stored in CSS pixels, and so is c.h.
3183
+ const halfLeading = c.h ? (c.h - (s.faceAsc + s.faceDesc)) / 2 : 0;
3184
+ rects.push(c.x - s.pad, c.y + halfLeading + s.faceAsc - (s.pad + s.asc), s.w, s.h);
3185
+ uvs.push(s.u0, s.v0, s.u1, s.v1);
3186
+ shapes.push(0, 0, s.colored ? MODE.COLORTEXT : MODE.TEXT);
3187
+ radii.push(0, 0, 0, 0);
3188
+ } else {
3189
+ // The drop shadow goes in FIRST, as its own instance: the box is drawn
3190
+ // over it in the same run, in array order, which is the order the
3191
+ // painter draws. A board of FigJam stickies is 154 of these, and
3192
+ // without them every note sits flat on the page.
3193
+ if (c.sh && c.k === KIND.RECT) {
3194
+ const b = Math.max(c.sh.blur || 0, 0);
3195
+ const sc = c.sh.c || [0, 0, 0, 0.35];
3196
+ rects.push(c.x + (c.sh.x || 0) - b, c.y + (c.sh.y || 0) - b, c.w + 2 * b, c.h + 2 * b);
3197
+ uvs.push(0, 0, 0, 0);
3198
+ shapes.push(c.r || 0, b, MODE.SHADOW);
3199
+ pushRadii(c);
3200
+ // The shadow turns with the box it belongs to, about the same pivot.
3201
+ rots.push(((c.rot || 0) * Math.PI) / 180);
3202
+ origins.push(c.rox || 0, c.roy || 0, c.rox === undefined ? 0 : 1);
3203
+ colors.push(sc[0] / 255, sc[1] / 255, sc[2] / 255, sc[3]);
3204
+ colors2.push(sc[0] / 255, sc[1] / 255, sc[2] / 255, sc[3]);
3205
+ grads.push(0);
3206
+ }
3207
+ rects.push(c.x, c.y, c.w, c.h);
3208
+ uvs.push(0, 0, 0, 0);
3209
+ shapes.push(c.r || 0, c.k === KIND.BORDER ? (c.t || 1) : 0, MODE.SHAPE);
3210
+ pushRadii(c);
3211
+ }
3212
+ rots.push(((c.rot || 0) * Math.PI) / 180);
3213
+ origins.push(c.rox || 0, c.roy || 0, c.rox === undefined ? 0 : 1);
3214
+ const col = c.c || [0, 0, 0, 1];
3215
+ colors.push(col[0] / 255, col[1] / 255, col[2] / 255, col[3]);
3216
+ // `gd` is the display list's gradient direction: 1 means across the box,
3217
+ // anything else means down it. A command with no `c2` is flat, and the
3218
+ // second stop is then the first, so the shader's mix is a no-op.
3219
+ const col2 = c.c2 || col;
3220
+ colors2.push(col2[0] / 255, col2[1] / 255, col2[2] / 255, col2[3]);
3221
+ grads.push(c.c2 ? (c.gd === 1 ? 2 : 1) : 0);
3222
+ // A SOURCE EFFECT, drawn here — after the rectangle it rides and before
3223
+ // whatever the element contains, which is what makes it a background and
3224
+ // not a sticker. `flush` closes the batch WITH this rectangle in it, so the
3225
+ // element's own background is under the effect and its children are over
3226
+ // it.
3227
+ //
3228
+ // A filter is not drawn here: it reads the finished surface, so it cannot
3229
+ // run until there is one. Those are collected below instead.
3230
+ if (c.k === KIND.RECT && c.efx) {
3231
+ const inst = fxById.get(c.efx);
3232
+ const plug = inst && FX_PLUGINS.get(inst.kind);
3233
+ if (plug && plug.layer === "source") {
3234
+ flush();
3235
+ runs.push({ kind: "fx", inst, clip, layer });
3236
+ }
3237
+ }
3238
+ }
3239
+ flush();
3240
+ const count = rects.length / 4;
3241
+ // The filters, in the order the document declares them. Whether any of them
3242
+ // has anything to do this frame is a question for `draw`: the events on an
3243
+ // instance change without the list being rebuilt, which is the whole point
3244
+ // of keeping the clock out of the tree.
3245
+ const fxFilters = (doc.list.effects || []).filter((e) => {
3246
+ const plug = FX_PLUGINS.get(e.kind);
3247
+ return !!plug && plug.layer === "filter";
3248
+ });
3249
+ // The backdrop runs this frame holds. Their instances decide, at draw time,
3250
+ // whether the frame has to be rendered offscreen at all — an effect switched
3251
+ // off reads nothing, so it costs nothing, not even the indirection.
3252
+ const fxBackdropRuns = runs.filter((r) => r.kind === "fx" && r.backdrop);
3253
+
3254
+ const quad = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
3255
+ const vao = gl.createVertexArray();
3256
+ gl.bindVertexArray(vao);
3257
+
3258
+ // The two VAOs a blurred backdrop draws through. Built here rather than
3259
+ // inside `drawBackdrop` so a page with one dialog on it and a page with ten
3260
+ // allocate the same amount: nothing, per frame.
3261
+ //
3262
+ // The blur passes draw a full-target quad in clip space and read the same
3263
+ // corners as UVs, so one attribute does both. The final draw needs its own
3264
+ // UVs — it puts the BOX back, out of a texture that holds the box plus the
3265
+ // kernel's reach — so it is a separate, streamed buffer.
3266
+ const blurVao = gl.createVertexArray();
3267
+ gl.bindVertexArray(blurVao);
3268
+ const blurQuadBuf = gl.createBuffer();
3269
+ gl.bindBuffer(gl.ARRAY_BUFFER, blurQuadBuf);
3270
+ gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
3271
+ gl.enableVertexAttribArray(built.blurCornerLoc);
3272
+ gl.vertexAttribPointer(built.blurCornerLoc, 2, gl.FLOAT, false, 0, 0);
3273
+
3274
+ // ONE TRIANGLE COVERING THE SCREEN, for every effect pass this frame draws:
3275
+ // three vertices instead of six, no seam down a diagonal for `fwidth` to trip
3276
+ // over, and made once rather than per pass — the version that made a buffer
3277
+ // inside the post-pass allocated one on every rippling frame.
3278
+ const fxQuadBuf = gl.createBuffer();
3279
+ gl.bindBuffer(gl.ARRAY_BUFFER, fxQuadBuf);
3280
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
3281
+
3282
+ const backdropVao = gl.createVertexArray();
3283
+ gl.bindVertexArray(backdropVao);
3284
+ const backdropBuf = gl.createBuffer();
3285
+ gl.bindBuffer(gl.ARRAY_BUFFER, backdropBuf);
3286
+ // x, y in clip space, then u, v into the padded texture, then the 0..1
3287
+ // corner the rounding is measured from — six floats a vertex.
3288
+ gl.enableVertexAttribArray(built.backdropPosLoc);
3289
+ gl.vertexAttribPointer(built.backdropPosLoc, 2, gl.FLOAT, false, 24, 0);
3290
+ gl.enableVertexAttribArray(built.backdropUVLoc);
3291
+ gl.vertexAttribPointer(built.backdropUVLoc, 2, gl.FLOAT, false, 24, 8);
3292
+ gl.enableVertexAttribArray(built.backdropCornerLoc);
3293
+ gl.vertexAttribPointer(built.backdropCornerLoc, 2, gl.FLOAT, false, 24, 16);
3294
+
3295
+ gl.bindVertexArray(vao);
3296
+
3297
+ // Attribute locations and buffers are set up once; a run re-points the
3298
+ // per-instance attributes at its own first instance, which is how the base
3299
+ // instance WebGL 2 does not have is emulated.
3300
+ const cornerBuf = gl.createBuffer();
3301
+ gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuf);
3302
+ gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
3303
+ const cornerLoc = built.cornerLoc;
3304
+ gl.enableVertexAttribArray(cornerLoc);
3305
+ gl.vertexAttribPointer(cornerLoc, 2, gl.FLOAT, false, 0, 0);
3306
+ gl.vertexAttribDivisor(cornerLoc, 0);
3307
+
3308
+ const instanced = [
3309
+ { name: "aRect", data: rects, size: 4 },
3310
+ { name: "aColor", data: colors, size: 4 },
3311
+ { name: "aColor2", data: colors2, size: 4 },
3312
+ { name: "aGrad", data: grads, size: 1 },
3313
+ { name: "aShape", data: shapes, size: 3 },
3314
+ { name: "aRadii", data: radii, size: 4 },
3315
+ { name: "aUV", data: uvs, size: 4 },
3316
+ { name: "aRot", data: rots, size: 1 },
3317
+ { name: "aOrigin", data: origins, size: 3 },
3318
+ ];
3319
+ for (const a of instanced) {
3320
+ a.buf = gl.createBuffer();
3321
+ gl.bindBuffer(gl.ARRAY_BUFFER, a.buf);
3322
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(a.data), gl.STATIC_DRAW);
3323
+ a.loc = gl.getAttribLocation(prog, a.name);
3324
+ if (a.loc < 0) continue;
3325
+ gl.enableVertexAttribArray(a.loc);
3326
+ gl.vertexAttribDivisor(a.loc, 1);
3327
+ }
3328
+ const pointAt = (first) => {
3329
+ for (const a of instanced) {
3330
+ if (a.loc < 0) continue;
3331
+ gl.bindBuffer(gl.ARRAY_BUFFER, a.buf);
3332
+ gl.vertexAttribPointer(a.loc, a.size, gl.FLOAT, false, 0, first * a.size * 4);
3333
+ }
3334
+ };
3335
+
3336
+ // The path pipeline: its own program (compiled with the other one, above),
3337
+ // its own buffer, one draw per path.
3338
+ const pathProg = built.pathProg;
3339
+ const pathVao = gl.createVertexArray();
3340
+ gl.bindVertexArray(pathVao);
3341
+ const pathBuf = gl.createBuffer();
3342
+ gl.bindBuffer(gl.ARRAY_BUFFER, pathBuf);
3343
+ const pathPosLoc = built.pathPosLoc;
3344
+ gl.enableVertexAttribArray(pathPosLoc);
3345
+ gl.vertexAttribPointer(pathPosLoc, 2, gl.FLOAT, false, 0, 0);
3346
+ const pathPageLoc = built.pathPageLoc;
3347
+ const pathColorLoc = built.pathColorLoc;
3348
+ gl.bindVertexArray(vao);
3349
+
3350
+ // What the layers had moved by when this frame was built. Those moves are
3351
+ // in the coordinates above already; `draw` applies only what came after.
3352
+ const baseShifts = (doc.list.shifts || []).map((s) => [s[0], s[1]]);
3353
+
3354
+ const frame = {
3355
+ doc,
3356
+ dispose() {
3357
+ for (const a of instanced) gl.deleteBuffer(a.buf);
3358
+ gl.deleteBuffer(cornerBuf);
3359
+ gl.deleteBuffer(blurQuadBuf);
3360
+ gl.deleteBuffer(fxQuadBuf);
3361
+ gl.deleteBuffer(backdropBuf);
3362
+ gl.deleteBuffer(pathBuf);
3363
+ gl.deleteVertexArray(vao);
3364
+ gl.deleteVertexArray(blurVao);
3365
+ gl.deleteVertexArray(backdropVao);
3366
+ gl.deleteVertexArray(pathVao);
3367
+ },
3368
+ };
3369
+
3370
+ // What the build made, reported by the first draw and not again: a frame
3371
+ // drawn a second time added nothing to the atlas and uploaded nothing.
3372
+ let fresh = true;
3373
+ // The camera this frame was BUILT at — the one the list carried. A draw
3374
+ // that names no view of its own uses it, which is every caller that had no
3375
+ // camera and every frame drawn the moment it was built.
3376
+ frame.view = viewOfDoc;
3377
+ // `shiftsNow` first, not the view, because every host that exists already
3378
+ // calls `draw(shifts)` and a second argument does not move the first.
3379
+ //
3380
+ // `opts.clear === false` draws ON TOP of what is already there instead of
3381
+ // starting from an empty canvas. A page with two frames — a document built
3382
+ // once and a caret built every blink — needs the second one to keep the
3383
+ // first: the clear below is unconditional otherwise, so the caret frame
3384
+ // erased the document and the canvas came out with a caret on it and nothing
3385
+ // else.
3386
+ frame.draw = (shiftsNow, viewNow, drawOpts) => {
3387
+ const clearFirst = !(drawOpts && drawOpts.clear === false);
3388
+ const madeNow = fresh;
3389
+ fresh = false;
3390
+ // The camera in force for THIS draw, as the shaders take it.
3391
+ const view = viewVec(viewNow === undefined || viewNow === null ? viewOfDoc : viewNow);
3392
+ const vsx = view[0], vsy = view[1], vtx = view[2], vty = view[3];
3393
+ // The move a layer has made since this frame was built: what the shaders
3394
+ // add. A frame drawn as it was built, or a run outside any layer, adds 0.
3395
+ const shiftOf = (l) => {
3396
+ if (!l) return ZERO_SHIFT;
3397
+ const now = (shiftsNow && shiftsNow[l - 1]) || baseShifts[l - 1] || ZERO_SHIFT;
3398
+ const base = baseShifts[l - 1] || ZERO_SHIFT;
3399
+ return [now[0] - base[0], now[1] - base[1]];
3400
+ };
3401
+ let curShift = ZERO_SHIFT;
3402
+
3403
+ gl.useProgram(prog);
3404
+ gl.bindVertexArray(vao);
3405
+ gl.uniform2f(built.uPage, doc.width, doc.height);
3406
+ gl.uniform2f(built.uShift, 0, 0);
3407
+ gl.uniform4f(built.uView, vsx, vsy, vtx, vty);
3408
+ gl.uniform1f(built.uSnap, dpr);
3409
+ gl.uniform1i(built.uAtlas, 0);
3410
+ gl.uniform1i(built.uImage, 1);
3411
+
3412
+ // A LIVE SURFACE EFFECT redirects the whole frame into a texture first. It
3413
+ // is live only while it is still moving: an effect declared in the sheet but
3414
+ // with no touch behind it (`t` below zero) costs a comparison and nothing
3415
+ // else, which is what lets a page carry the declaration all the time.
3416
+ const fx = doc.list.effect;
3417
+ // Live while ANY drop still has something left in it. The application
3418
+ // retires them, so in practice this is "are there any"; the decay test
3419
+ // stays as the renderer's own guard against being asked to draw nothing.
3420
+ const rippling = !!fx && fx.kind === "ripple" && fx.drops && fx.drops.length > 0 &&
3421
+ fx.drops.some((d) => Math.exp(-d[2] * fx.decay) > 0.004);
3422
+ // AND THE ELEMENT-SCOPED FILTERS with something to do. An instance is live
3423
+ // when it has events in flight or when the sheet said `always` — a filter
3424
+ // declared and idle costs the test below and nothing else, which is what
3425
+ // lets a page carry the declaration all the time.
3426
+ const fxLive = (inst) => !inst.off && ((inst.events && inst.events.length > 0) ||
3427
+ /\balways\b/.test(inst.on || ""));
3428
+ const fxPasses = fxFilters.filter(fxLive);
3429
+ // A BACKDROP READS THE SURFACE MID-FRAME, and the one surface it must never
3430
+ // read is the canvas — see `fxCopyOfSurface`. So a frame with a live one in
3431
+ // it goes offscreen too, and is presented at the end.
3432
+ const fxBackdrops = fxBackdropRuns.some((r) => !r.inst.off);
3433
+ // Every pass reads the finished surface, so the frame is drawn into a
3434
+ // texture first — once, however many passes there are.
3435
+ let target = (rippling || fxPasses.length > 0 || fxBackdrops)
3436
+ ? rippleTargetFor(gl, gl.canvas.width, gl.canvas.height)
3437
+ : null;
3438
+ // An incomplete target draws the page without the effect rather than
3439
+ // drawing nothing, which is the same choice the missing-stencil branch
3440
+ // makes about path fills: say what could not be done, do the rest.
3441
+ if (target && !target.complete) target = null;
3442
+ if (target) gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
3443
+
3444
+ // THE ATLAS IS BOUND HERE, AFTER THE TARGET, AND THE ORDER IS THE WHOLE
3445
+ // POINT. Creating the target's texture leaves it bound to the active unit,
3446
+ // which is this one — so binding the atlas first meant the frame drew with
3447
+ // the render target itself in the sampler the glyphs and images read. A
3448
+ // texture sampled while it is attached to the framebuffer being drawn into
3449
+ // is a feedback loop, and the spec says the result is undefined: this
3450
+ // driver dropped every textured draw. The page came out with its paths on
3451
+ // it and NOTHING else — no card, no letter — which reads as "the effect
3452
+ // broke the renderer" and is really one line of state in the wrong order.
3453
+ gl.activeTexture(gl.TEXTURE0);
3454
+ gl.bindTexture(gl.TEXTURE_2D, atlas);
3455
+
3456
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
3457
+ gl.disable(gl.DEPTH_TEST);
3458
+ gl.enable(gl.BLEND);
3459
+ gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
3460
+ if (clearFirst) {
3461
+ gl.clearColor(0, 0, 0, 0);
3462
+ gl.clear(gl.COLOR_BUFFER_BIT);
3463
+ }
3464
+
3465
+ const hasStencil = gl.getContextAttributes().stencil === true;
3466
+ let paths = 0, skippedFills = 0;
3467
+
3468
+ // The two stops and the box they run across, or nothing. Set on every path
3469
+ // draw, not only the gradient ones: the uniform is program state and a
3470
+ // flat path after a shaded one would otherwise keep the ramp.
3471
+ const setPathPaint = (op, color) => {
3472
+ gl.uniform4f(pathColorLoc, color[0], color[1], color[2], color[3]);
3473
+ if (op && op.grad) {
3474
+ const c2 = op.color2 || color;
3475
+ const [x0, y0, x1, y1] = op.bounds || [0, 0, 1, 1];
3476
+ gl.uniform4f(built.pathColor2Loc, c2[0], c2[1], c2[2], c2[3]);
3477
+ gl.uniform4f(built.pathBoxLoc, x0, y0, x1 - x0, y1 - y0);
3478
+ gl.uniform1f(built.pathGradLoc, op.grad);
3479
+ } else {
3480
+ gl.uniform1f(built.pathGradLoc, 0);
3481
+ }
3482
+ };
3483
+
3484
+ const drawTris = (verts, color, op) => {
3485
+ gl.bindVertexArray(pathVao);
3486
+ gl.useProgram(pathProg);
3487
+ gl.uniform2f(pathPageLoc, doc.width, doc.height);
3488
+ gl.uniform2f(built.pathShiftLoc, curShift[0], curShift[1]);
3489
+ gl.uniform4f(built.pathViewLoc, vsx, vsy, vtx, vty);
3490
+ setPathPaint(op, color);
3491
+ gl.bindBuffer(gl.ARRAY_BUFFER, pathBuf);
3492
+ gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STREAM_DRAW);
3493
+ gl.drawArrays(gl.TRIANGLES, 0, verts.length / 2);
3494
+ };
3495
+
3496
+ /**
3497
+ * Stencil-then-cover. Every ring goes into the stencil as a fan; the rule
3498
+ * decides which counts survive; then one quad over the bounding box paints
3499
+ * them and zeroes the stencil on its way out, so no clear is needed between
3500
+ * paths.
3501
+ */
3502
+ const drawFill = (op) => {
3503
+ if (!hasStencil) { skippedFills += 1; return; }
3504
+ gl.bindVertexArray(pathVao);
3505
+ gl.useProgram(pathProg);
3506
+ gl.uniform2f(pathPageLoc, doc.width, doc.height);
3507
+ // THE LAYER'S SHIFT, which `drawTris` has always set and this did not.
3508
+ // A kept frame is moved by this uniform rather than rebuilt, so a filled
3509
+ // path went on being drawn where it was when the frame was built while
3510
+ // the strokes and the boxes around it moved: on the diary's charts the
3511
+ // area under the curve lagged the curve by a whole scroll. It applies to
3512
+ // both draws below — the rings into the stencil and the quad that covers
3513
+ // them — because it is one uniform on `pathProg`.
3514
+ gl.uniform2f(built.pathShiftLoc, curShift[0], curShift[1]);
3515
+ gl.uniform4f(built.pathViewLoc, vsx, vsy, vtx, vty);
3516
+ gl.bindBuffer(gl.ARRAY_BUFFER, pathBuf);
3517
+
3518
+ gl.enable(gl.STENCIL_TEST);
3519
+ gl.colorMask(false, false, false, false);
3520
+ gl.stencilFunc(gl.ALWAYS, 0, 0xff);
3521
+ if (op.evenOdd) {
3522
+ gl.stencilMask(0x01);
3523
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.INVERT);
3524
+ } else {
3525
+ gl.stencilMask(0xff);
3526
+ // A ring's winding decides the sign, which is what "non-zero" counts.
3527
+ gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.KEEP, gl.INCR_WRAP);
3528
+ gl.stencilOpSeparate(gl.BACK, gl.KEEP, gl.KEEP, gl.DECR_WRAP);
3529
+ }
3530
+ for (const ring of op.rings) {
3531
+ gl.bufferData(gl.ARRAY_BUFFER, ring, gl.STREAM_DRAW);
3532
+ gl.drawArrays(gl.TRIANGLE_FAN, 0, ring.length / 2);
3533
+ }
3534
+
3535
+ gl.colorMask(true, true, true, true);
3536
+ gl.stencilFunc(gl.NOTEQUAL, 0, op.evenOdd ? 0x01 : 0xff);
3537
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.ZERO);
3538
+ setPathPaint(op, op.color);
3539
+ const [x0, y0, x1, y1] = op.bounds;
3540
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
3541
+ x0, y0, x1, y0, x1, y1,
3542
+ x0, y0, x1, y1, x0, y1,
3543
+ ]), gl.STREAM_DRAW);
3544
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
3545
+
3546
+ gl.stencilMask(0xff);
3547
+ gl.disable(gl.STENCIL_TEST);
3548
+ };
3549
+
3550
+ // A clip rectangle in page coordinates becomes a scissor box in framebuffer
3551
+ // pixels, which are scaled by the device ratio and measured from the BOTTOM.
3552
+ const sxScale = gl.canvas.width / doc.width;
3553
+ const syScale = gl.canvas.height / doc.height;
3554
+ let scissorOn = false;
3555
+ const applyClip = (list) => {
3556
+ if (!list) {
3557
+ if (scissorOn) { gl.disable(gl.SCISSOR_TEST); scissorOn = false; }
3558
+ return;
3559
+ }
3560
+ if (!scissorOn) { gl.enable(gl.SCISSOR_TEST); scissorOn = true; }
3561
+ let r = null;
3562
+ for (const c of list) {
3563
+ const sh = shiftOf(c.layer);
3564
+ const b = { x: c.x + sh[0], y: c.y + sh[1], w: c.w, h: c.h };
3565
+ if (!r) { r = b; continue; }
3566
+ const x0 = Math.max(r.x, b.x), y0 = Math.max(r.y, b.y);
3567
+ const x1 = Math.min(r.x + r.w, b.x + b.w), y1 = Math.min(r.y + r.h, b.y + b.h);
3568
+ r = { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
3569
+ }
3570
+ // Through the camera and then into framebuffer pixels. A clip is scene
3571
+ // geometry like everything else in the list, and a scissor is not a thing
3572
+ // the shader can apply — so this is the one place the view is multiplied
3573
+ // by hand rather than by the card.
3574
+ const px = r.x * vsx + vtx;
3575
+ const py = r.y * vsy + vty;
3576
+ const pw = r.w * vsx;
3577
+ const ph = r.h * vsy;
3578
+ const x = Math.round(px * sxScale);
3579
+ const y = Math.round((doc.height - (py + ph)) * syScale);
3580
+ const w = Math.max(0, Math.round(pw * sxScale));
3581
+ const h = Math.max(0, Math.round(ph * syScale));
3582
+ gl.scissor(x, y, w, h);
3583
+ };
3584
+
3585
+ // One blurred backdrop: copy what is behind, soften it, put it back.
3586
+ //
3587
+ // The copy is the element's box GROWN by the kernel's reach, because the
3588
+ // browser samples past the edge — see the note on BLUR_FRAG. Only the last
3589
+ // step clips to the box, and it does so with the same rounded distance field
3590
+ // the solid shapes use.
3591
+ let backdrops = 0;
3592
+ const drawBackdrop = (c) => {
3593
+ // The softening grows with the picture: a blur that stayed the same
3594
+ // number of pixels while the box doubled is a different blur.
3595
+ const sigma = c.bb * dpr * vsx;
3596
+ // The element's box in framebuffer pixels, measured from the bottom the
3597
+ // way GL counts — through the camera first, as the scissor is.
3598
+ const bx = Math.round(((c.x + curShift[0]) * vsx + vtx) * sxScale);
3599
+ const by = Math.round((doc.height - ((c.y + curShift[1] + c.h) * vsy + vty)) * syScale);
3600
+ const bw = Math.max(1, Math.round(c.w * vsx * sxScale));
3601
+ const bh = Math.max(1, Math.round(c.h * vsy * syScale));
3602
+ // EXACTLY the box, and no padding. What a browser blurs is the rectangle
3603
+ // under the element with its edges clamped, not the page around it — see
3604
+ // the note on BLUR_FRAG, where getting this wrong is the mistake a flat
3605
+ // backdrop cannot show you.
3606
+ const rx = Math.max(0, Math.min(bx, gl.canvas.width - 1));
3607
+ const ry = Math.max(0, Math.min(by, gl.canvas.height - 1));
3608
+ const rw = Math.min(gl.canvas.width - rx, bw);
3609
+ const rh = Math.min(gl.canvas.height - ry, bh);
3610
+ if (rw <= 0 || rh <= 0) return;
3611
+
3612
+ const t = blurTargetsFor(gl, rw, rh);
3613
+ // Straight off the framebuffer being drawn into. This is the one read
3614
+ // that has to happen mid-frame, and it is why a blur breaks the batch.
3615
+ gl.bindTexture(gl.TEXTURE_2D, t.src);
3616
+ gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, rx, ry, rw, rh, 0);
3617
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3618
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3619
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3620
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3621
+
3622
+ const wasScissor = gl.isEnabled(gl.SCISSOR_TEST);
3623
+ gl.disable(gl.SCISSOR_TEST);
3624
+ gl.disable(gl.BLEND);
3625
+ gl.useProgram(built.blurProg);
3626
+ gl.bindVertexArray(blurVao);
3627
+ gl.uniform1i(built.blurSrc, 0);
3628
+ gl.activeTexture(gl.TEXTURE0);
3629
+
3630
+ // Six passes, three each way, ping-ponging between the two targets. The
3631
+ // texture the region was copied into is only the first pass's input.
3632
+ const boxes = boxesForSigma(sigma);
3633
+ let srcTex = t.src, dst = 0;
3634
+ for (const axis of [[1 / t.w, 0], [0, 1 / t.h]]) {
3635
+ for (const box of boxes) {
3636
+ gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo[dst]);
3637
+ gl.viewport(0, 0, t.w, t.h);
3638
+ gl.bindTexture(gl.TEXTURE_2D, srcTex);
3639
+ gl.uniform2f(built.blurStep, axis[0], axis[1]);
3640
+ gl.uniform1f(built.blurWidth, box.w);
3641
+ gl.uniform1f(built.blurOffset, box.off);
3642
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
3643
+ srcTex = t.tex[dst];
3644
+ dst = 1 - dst;
3645
+ }
3646
+ }
3647
+
3648
+ // And back onto the page, clipped to the element's rounded box. The quad
3649
+ // is the BOX, not the grown region, so the padding's only job was to keep
3650
+ // the edges honest.
3651
+ //
3652
+ // Back onto THE FRAME'S OWN TARGET, which is the screen only when no
3653
+ // surface effect is running: a dialog on a rippling page would otherwise
3654
+ // put its softened backdrop straight on the canvas, under everything the
3655
+ // post-pass is about to draw over it.
3656
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null);
3657
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
3658
+ gl.enable(gl.BLEND);
3659
+ gl.useProgram(built.backdropProg);
3660
+ gl.bindVertexArray(backdropVao);
3661
+ // The quad covers the box, the copy IS the box, and the target is exactly
3662
+ // the copy — so the UVs are the whole texture.
3663
+ const u0 = 0, v0 = 0, u1 = 1, v1 = 1;
3664
+ const x0 = (bx / gl.canvas.width) * 2 - 1, y0 = (by / gl.canvas.height) * 2 - 1;
3665
+ const x1 = ((bx + bw) / gl.canvas.width) * 2 - 1;
3666
+ const y1 = ((by + bh) / gl.canvas.height) * 2 - 1;
3667
+ gl.bindBuffer(gl.ARRAY_BUFFER, backdropBuf);
3668
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
3669
+ x0, y0, u0, v0, 0, 0,
3670
+ x1, y0, u1, v0, 1, 0,
3671
+ x0, y1, u0, v1, 0, 1,
3672
+ x1, y1, u1, v1, 1, 1,
3673
+ ]), gl.STREAM_DRAW);
3674
+ gl.bindTexture(gl.TEXTURE_2D, srcTex);
3675
+ gl.uniform1i(built.backdropSrc, 0);
3676
+ gl.uniform2f(built.backdropHalf, bw / 2, bh / 2);
3677
+ gl.uniform1f(built.backdropRadius, (c.r || 0) * sxScale);
3678
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
3679
+
3680
+ // Put the state back the way the batch expects to find it. TEXTURE0 is the
3681
+ // glyph atlas for the whole frame and this has just been using it for its
3682
+ // own textures — leave it bound to the blur and every letter drawn after a
3683
+ // dialog samples that instead of the atlas, comes back with alpha 1
3684
+ // everywhere, and renders as a solid black rectangle the size of the word.
3685
+ // Which is exactly what the first dialog screenshot showed, in every run
3686
+ // on the page.
3687
+ gl.activeTexture(gl.TEXTURE0);
3688
+ gl.bindTexture(gl.TEXTURE_2D, atlas);
3689
+ if (wasScissor) gl.enable(gl.SCISSOR_TEST);
3690
+ backdrops += 1;
3691
+ };
3692
+
3693
+ // ONE EFFECT, drawn through its plugin.
3694
+ //
3695
+ // `srcTex` is the finished surface a filter reads, and null for a source —
3696
+ // which has nothing to read, because it is being drawn in the middle of the
3697
+ // frame that would become it. `shift` is what the scroll layer the effect
3698
+ // sits in has moved by since this frame was built.
3699
+ //
3700
+ // THE BOX AND THE EVENTS ARE MOVED INTO THE SPACE THE FRAGMENT COMPUTES IN.
3701
+ // A fragment's page point comes off the fullscreen triangle as vUV * uRes,
3702
+ // which is the page AS THE CAMERA LEFT IT; the box and the events are in
3703
+ // page coordinates as the layout and the pointer left them. On every page
3704
+ // without a camera — which is every page that is not a canvas — the two are
3705
+ // the same and this is the identity.
3706
+ let fxDrawn = 0;
3707
+ const fxEvents = new Float32Array(24);
3708
+ // `shift` DEFAULTS TO THE LAYER'S, which is what a source effect drawn in the
3709
+ // middle of the run loop wants: it is painted where its element is now, and
3710
+ // its element moves with the scroll like everything else in that layer. The
3711
+ // post-pass below passes ZERO_SHIFT instead, because by then the shift is in
3712
+ // the texture already and applying it again would move the effect twice.
3713
+ const drawFxPass = (inst, srcTex, shift = curShift) => {
3714
+ const plug = fxProgramFor(gl, inst.kind);
3715
+ if (!plug) return false;
3716
+ const box = inst.box || [0, 0, doc.width, doc.height];
3717
+ gl.useProgram(plug.prog);
3718
+ gl.bindVertexArray(null);
3719
+ gl.bindBuffer(gl.ARRAY_BUFFER, fxQuadBuf);
3720
+ gl.enableVertexAttribArray(plug.posLoc);
3721
+ gl.vertexAttribPointer(plug.posLoc, 2, gl.FLOAT, false, 0, 0);
3722
+ gl.uniform2f(plug.uRes, doc.width, doc.height);
3723
+ gl.uniform4f(plug.uBox,
3724
+ (box[0] + shift[0]) * vsx + vtx, (box[1] + shift[1]) * vsy + vty,
3725
+ box[2] * vsx, box[3] * vsy);
3726
+ if (plug.uRadius) gl.uniform1f(plug.uRadius, (inst.r || 0) * vsx);
3727
+ if (plug.uTime) gl.uniform1f(plug.uTime, inst.time || 0);
3728
+ if (plug.uEvents) {
3729
+ const list = inst.events || [];
3730
+ const n = Math.min(8, list.length);
3731
+ for (let i = 0; i < n; i++) {
3732
+ const e = list[i];
3733
+ fxEvents[i * 3] = (e[0] + shift[0]) * vsx + vtx;
3734
+ fxEvents[i * 3 + 1] = (e[1] + shift[1]) * vsy + vty;
3735
+ fxEvents[i * 3 + 2] = e[2];
3736
+ }
3737
+ // A slot past the count is never read, but leaving last frame's numbers
3738
+ // in it is the kind of thing that shows up as one stuck ring on a driver
3739
+ // that unrolls the loop.
3740
+ for (let i = n; i < 8; i++) { fxEvents[i * 3] = 0; fxEvents[i * 3 + 1] = 0; fxEvents[i * 3 + 2] = -1; }
3741
+ gl.uniform3fv(plug.uEvents, fxEvents);
3742
+ gl.uniform1i(plug.uEventCount, n);
3743
+ }
3744
+ // WHAT THE SHEET WROTE, AND THE PLUGIN'S DEFAULT WHERE IT WROTE NOTHING.
3745
+ // The default is the plugin's own, so an effect can gain a parameter
3746
+ // without every document that uses it having to learn about it.
3747
+ const wrote = inst.p || {};
3748
+ for (const k in plug.params) {
3749
+ const loc = plug.params[k];
3750
+ if (!loc) continue;
3751
+ const v = wrote[k];
3752
+ gl.uniform1f(loc, typeof v === "number" ? v : plug.def.params[k]);
3753
+ }
3754
+ if (plug.uSrc && srcTex) {
3755
+ gl.activeTexture(gl.TEXTURE0);
3756
+ gl.bindTexture(gl.TEXTURE_2D, srcTex);
3757
+ gl.uniform1i(plug.uSrc, 0);
3758
+ }
3759
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
3760
+ fxDrawn += 1;
3761
+ // TEXTURE0 is the glyph atlas for the rest of the frame — the same trap
3762
+ // `drawBackdrop` documents above, and the same one line that avoids it.
3763
+ gl.activeTexture(gl.TEXTURE0);
3764
+ gl.bindTexture(gl.TEXTURE_2D, atlas);
3765
+ return true;
3766
+ };
3767
+
3768
+ for (const run of runs) {
3769
+ curShift = shiftOf(run.layer);
3770
+ applyClip(run.clip);
3771
+ if (run.kind === "backdrop") { drawBackdrop(run.cmd); continue; }
3772
+ // A SOURCE EFFECT, at the element's own background, under everything the
3773
+ // element contains — and a BACKDROP, which reads the surface as it stands
3774
+ // at that same point and writes it back refracted. The copy is the whole
3775
+ // canvas and the pass covers the whole canvas, so what is outside the box
3776
+ // is written back exactly as it arrived; blending is off, because the pass
3777
+ // REPLACES those pixels rather than adding to them.
3778
+ if (run.kind === "fx") {
3779
+ // TURNED OFF BY THE HOST, and therefore free: no program, no copy, no
3780
+ // pass. A page with a switch for its effects — or one honouring reduced
3781
+ // motion, or a battery saver — sets this on the instance rather than
3782
+ // rebuilding the frame without it, which would mean uploading every
3783
+ // buffer again to stop drawing one shader.
3784
+ if (run.inst.off) continue;
3785
+ if (run.backdrop) {
3786
+ // No target means the frame is going straight to the canvas, which is
3787
+ // the one surface a backdrop may not copy. Draw the page without the
3788
+ // effect rather than draw it wrong: same choice as an incomplete
3789
+ // target and a plugin that would not compile.
3790
+ if (!target) continue;
3791
+ const behind = fxCopyOfSurface(gl, gl.canvas.width, gl.canvas.height);
3792
+ gl.disable(gl.BLEND);
3793
+ drawFxPass(run.inst, behind);
3794
+ gl.enable(gl.BLEND);
3795
+ } else {
3796
+ drawFxPass(run.inst, null);
3797
+ }
3798
+ continue;
3799
+ }
3800
+ if (run.kind === "fill") { drawFill(run); paths += 1; continue; }
3801
+ if (run.kind === "tris") { drawTris(run.verts, run.color, run); paths += 1; continue; }
3802
+ gl.useProgram(prog);
3803
+ gl.bindVertexArray(vao);
3804
+ gl.uniform2f(built.uShift, curShift[0], curShift[1]);
3805
+ if (run.tex) {
3806
+ gl.activeTexture(gl.TEXTURE1);
3807
+ gl.bindTexture(gl.TEXTURE_2D, run.tex);
3808
+ }
3809
+ pointAt(run.start);
3810
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, run.count);
3811
+ }
3812
+ applyClip(null);
3813
+
3814
+ // THE SECOND PASS, and the third if the page asked for one. Everything above
3815
+ // drew into a texture; each filter reads that texture and writes another,
3816
+ // and the last one writes the screen.
3817
+ //
3818
+ // No blending anywhere in here: the texture already holds the finished
3819
+ // surface and compositing it again would darken its own edges against
3820
+ // itself. No scissor either — a filter's region is its box, which the
3821
+ // plugin's own mask applies, and a clip left over from the last run would
3822
+ // cut it.
3823
+ if (target) {
3824
+ // The whole-surface effect first, if there is one, then the element-scoped
3825
+ // filters in the order the document declares them. `null` stands for the
3826
+ // old one: it has its own program, its own uniforms and no box, and it
3827
+ // stays that way so that every page and every gate written against it
3828
+ // draws exactly what it drew before.
3829
+ let passes = [];
3830
+ if (rippling) passes.push(null);
3831
+ for (const inst of fxPasses) passes.push(inst);
3832
+ // A frame that went offscreen only so a backdrop could read it has no
3833
+ // filter to finish with: `undefined` is that pass, and it copies the
3834
+ // target to the screen unchanged.
3835
+ if (passes.length === 0) passes.push(undefined);
3836
+
3837
+ // Ping-pong needs a second target. If it cannot be had, draw the LAST pass
3838
+ // only rather than nothing: an effect missing is a picture, an incomplete
3839
+ // framebuffer is a black page.
3840
+ let spare = passes.length > 1
3841
+ ? rippleTargetFor(gl, gl.canvas.width, gl.canvas.height, 1)
3842
+ : null;
3843
+ if (spare && !spare.complete) { spare = null; passes = passes.slice(-1); }
3844
+
3845
+ const drawLegacyRipple = (srcTex) => {
3846
+ gl.useProgram(built.rippleProg);
3847
+ gl.bindBuffer(gl.ARRAY_BUFFER, fxQuadBuf);
3848
+ gl.enableVertexAttribArray(built.ripplePosLoc);
3849
+ gl.vertexAttribPointer(built.ripplePosLoc, 2, gl.FLOAT, false, 0, 0);
3850
+ gl.activeTexture(gl.TEXTURE0);
3851
+ gl.bindTexture(gl.TEXTURE_2D, srcTex);
3852
+ gl.uniform1i(built.rippleSrc, 0);
3853
+ // In PAGE pixels, which is the space the drop was recorded in — the
3854
+ // texture is in device pixels and the shader never has to know.
3855
+ gl.uniform2f(built.rippleRes, doc.width, doc.height);
3856
+ // Flattened to (x, y, age) triples, oldest first — the order is the
3857
+ // application's and the shader does not care, because addition does not.
3858
+ const drops = new Float32Array(24);
3859
+ const n = Math.min(8, fx.drops.length);
3860
+ for (let i = 0; i < n; i++) {
3861
+ drops[i * 3] = fx.drops[i][0];
3862
+ drops[i * 3 + 1] = fx.drops[i][1];
3863
+ drops[i * 3 + 2] = fx.drops[i][2];
3864
+ }
3865
+ gl.uniform3fv(built.rippleDrops, drops);
3866
+ gl.uniform1i(built.rippleCount, n);
3867
+ gl.uniform1f(built.rippleSpeed, fx.speed);
3868
+ gl.uniform1f(built.rippleWidth, fx.width);
3869
+ gl.uniform1f(built.rippleStrength, fx.strength);
3870
+ gl.uniform1f(built.rippleDecay, fx.decay);
3871
+ gl.uniform1f(built.rippleHi, fx.highlight);
3872
+ gl.uniform1i(built.rippleRings, Math.max(1, Math.min(5, Math.round(fx.rings || 1))));
3873
+ gl.uniform1f(built.rippleStagger, fx.stagger || 0);
3874
+ gl.uniform1f(built.rippleFalloff, fx.falloff || 0);
3875
+ gl.uniform1f(built.rippleShine, fx.shine || 0);
3876
+ // Never zero: pow(x, 0) is 1 everywhere and the whole page turns white.
3877
+ gl.uniform1f(built.rippleGloss, Math.max(1, fx.gloss || 1));
3878
+ gl.uniform1f(built.rippleBump, fx.bump || 0);
3879
+ const L = fx.light || [0, 0, 1];
3880
+ gl.uniform3f(built.rippleLight, L[0], L[1], L[2]);
3881
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
3882
+ };
3883
+
3884
+ gl.bindVertexArray(null);
3885
+ gl.disable(gl.BLEND);
3886
+ gl.disable(gl.SCISSOR_TEST);
3887
+ let srcTex = target.tex;
3888
+ for (let i = 0; i < passes.length; i++) {
3889
+ const dest = i === passes.length - 1 ? null : (srcTex === spare.tex ? target : spare);
3890
+ gl.bindFramebuffer(gl.FRAMEBUFFER, dest ? dest.fbo : null);
3891
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
3892
+ gl.clearColor(0, 0, 0, 0);
3893
+ gl.clear(gl.COLOR_BUFFER_BIT);
3894
+ // Drawn with no camera and no shift: the surface in the texture is
3895
+ // already where the camera put it.
3896
+ if (passes[i] === null) drawLegacyRipple(srcTex);
3897
+ else if (passes[i] === undefined) {
3898
+ gl.useProgram(built.presentProg);
3899
+ gl.bindBuffer(gl.ARRAY_BUFFER, fxQuadBuf);
3900
+ gl.enableVertexAttribArray(built.presentPosLoc);
3901
+ gl.vertexAttribPointer(built.presentPosLoc, 2, gl.FLOAT, false, 0, 0);
3902
+ gl.activeTexture(gl.TEXTURE0);
3903
+ gl.bindTexture(gl.TEXTURE_2D, srcTex);
3904
+ gl.uniform1i(built.presentSrc, 0);
3905
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
3906
+ } else drawFxPass(passes[i], srcTex, ZERO_SHIFT);
3907
+ srcTex = dest ? dest.tex : null;
3908
+ }
3909
+ gl.enable(gl.BLEND);
3910
+ }
3911
+
3912
+ return {
3913
+ drawn: count, textRuns: slots.size, images: drawnImages, missingImages,
3914
+ runs: runs.length, paths,
3915
+ // What this frame had to make rather than reuse. Both should be 0 on a
3916
+ // frame that draws what the last one drew.
3917
+ atlasRebuilt: (madeNow && atlasRebuilt) ? 1 : 0, texturesUploaded: madeNow ? texturesUploaded : 0,
3918
+ // The runs this frame was the first to show, rasterized and uploaded on
3919
+ // their own. A scroll frame that brought no new row in reports 0.
3920
+ atlasAdded: madeNow ? atlasAdded : 0,
3921
+ // Whether this draw built its buffers or drew the ones it had.
3922
+ frameBuilt: madeNow ? 1 : 0,
3923
+ // A context without a stencil buffer cannot fill a path; say so rather than
3924
+ // drawing a chart with no bars in it.
3925
+ skippedFills,
3926
+ // How many backdrops were softened. Each one is a framebuffer read and six
3927
+ // passes, so this is the number to look at when a frame with a dialog on
3928
+ // it costs more than one without.
3929
+ backdrops,
3930
+ // Whether this frame went through a surface effect. 0 on every frame of
3931
+ // every page that is not rippling, which is nearly all of them.
3932
+ rippled: target ? 1 : 0,
3933
+ // How many element-scoped effect passes this frame drew — sources in paint
3934
+ // order and filters over the finished surface, together. 0 on a page that
3935
+ // declares none, which is still nearly all of them.
3936
+ fxDrawn,
3937
+ };
3938
+ };
3939
+ return frame;
3940
+ }
3941
+
3942
+ const ZERO_SHIFT = [0, 0];