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.
- package/CHANGELOG.md +1160 -19
- package/LICENSE +3 -1
- package/README.md +124 -29
- package/dist/Lang.rgr +1188 -290
- package/dist/api.d.ts +2343 -879
- package/dist/api.js +79924 -56922
- package/dist/lib/JSON.rgr +102 -91
- package/dist/lib/Shell.rgr +4 -4
- package/dist/lib/apple/AppleToolchain.rgr +4 -4
- package/dist/lib/apple/README.md +5 -4
- package/dist/lib/apple/apple_test.rgr +1 -1
- package/dist/lib/core/README.md +1 -1
- package/dist/lib/evg/EVG.rgr +12 -0
- package/dist/lib/evg/EVGA11yFromTree.rgr +302 -0
- package/dist/lib/evg/EVGA11yTree.rgr +894 -0
- package/dist/lib/evg/EVGBox.rgr +267 -0
- package/dist/lib/evg/EVGBoxShorthandTest.rgr +220 -0
- package/dist/lib/evg/EVGCodepoint.rgr +316 -0
- package/dist/lib/evg/EVGColor.rgr +700 -0
- package/dist/lib/evg/EVGCommands.rgr +177 -0
- package/dist/lib/evg/EVGComponent.rgr +331 -0
- package/dist/lib/evg/EVGComponentTest.rgr +387 -0
- package/dist/lib/evg/EVGConnector.rgr +541 -0
- package/dist/lib/evg/EVGConnectorTest.rgr +306 -0
- package/dist/lib/evg/EVGDisplayList.rgr +4186 -0
- package/dist/lib/evg/EVGEasing.rgr +370 -0
- package/dist/lib/evg/EVGEffectTest.rgr +253 -0
- package/dist/lib/evg/EVGElement.rgr +4914 -0
- package/dist/lib/evg/EVGFixedTest.rgr +289 -0
- package/dist/lib/evg/EVGFlexRulesTest.rgr +317 -0
- package/dist/lib/evg/EVGFlexWrapTest.rgr +246 -0
- package/dist/lib/evg/EVGFling.rgr +244 -0
- package/dist/lib/evg/EVGFocus.rgr +400 -0
- package/dist/lib/evg/EVGFocusTest.rgr +399 -0
- package/dist/lib/evg/EVGGradient.rgr +322 -0
- package/dist/lib/evg/EVGGrapheme.rgr +190 -0
- package/dist/lib/evg/EVGGrid.rgr +975 -0
- package/dist/lib/evg/EVGHitTest.rgr +208 -0
- package/dist/lib/evg/EVGHoles.rgr +294 -0
- package/dist/lib/evg/EVGHostMeasurerTest.rgr +281 -0
- package/dist/lib/evg/EVGHostTextMeasurer.rgr +331 -0
- package/dist/lib/evg/EVGHostTree.rgr +834 -0
- package/dist/lib/evg/EVGHostTreeTest.rgr +447 -0
- package/dist/lib/evg/EVGImageDecode.rgr +117 -0
- package/dist/lib/evg/EVGImageMeasurer.rgr +86 -0
- package/dist/lib/evg/EVGInspect.rgr +883 -0
- package/dist/lib/evg/EVGInvalidateTest.rgr +432 -0
- package/dist/lib/evg/EVGJsonTest.rgr +535 -0
- package/dist/lib/evg/EVGLayout.rgr +4220 -0
- package/dist/lib/evg/EVGMeasure.rgr +1267 -0
- package/dist/lib/evg/EVGOverlayTest.rgr +626 -0
- package/dist/lib/evg/EVGPatch.rgr +1721 -0
- package/dist/lib/evg/EVGPatchTest.rgr +692 -0
- package/dist/lib/evg/EVGPopoverTest.rgr +395 -0
- package/dist/lib/evg/EVGReconcile.rgr +226 -0
- package/dist/lib/evg/EVGReconcileTest.rgr +421 -0
- package/dist/lib/evg/EVGReject.rgr +126 -0
- package/dist/lib/evg/EVGRelayoutTest.rgr +375 -0
- package/dist/lib/evg/EVGRtlLayoutTest.rgr +319 -0
- package/dist/lib/evg/EVGRuler.rgr +232 -0
- package/dist/lib/evg/EVGRulerTest.rgr +172 -0
- package/dist/lib/evg/EVGSelectChrome.rgr +178 -0
- package/dist/lib/evg/EVGStyleCacheTest.rgr +419 -0
- package/dist/lib/evg/EVGStyleSheet.rgr +2148 -0
- package/dist/lib/evg/EVGStyleStateTest.rgr +407 -0
- package/dist/lib/evg/EVGStyleVarTest.rgr +471 -0
- package/dist/lib/evg/EVGText.rgr +105 -0
- package/dist/lib/evg/EVGTextEngine.rgr +536 -0
- package/dist/lib/evg/EVGTextMeasurer.rgr +720 -0
- package/dist/lib/evg/EVGTimingTest.rgr +1417 -0
- package/dist/lib/evg/EVGToolbar.rgr +1369 -0
- package/dist/lib/evg/EVGTransition.rgr +728 -0
- package/dist/lib/evg/EVGTreeJson.rgr +721 -0
- package/dist/lib/evg/EVGUnit.rgr +554 -0
- package/dist/lib/evg/EVGViewportUnitTest.rgr +236 -0
- package/dist/lib/evg/EvgApp.rgr +189 -0
- package/dist/lib/evg/EvgBitmapTracer.rgr +4277 -0
- package/dist/lib/evg/EvgBitmapTracerTest.rgr +2119 -0
- package/dist/lib/evg/EvgHost.rgr +451 -0
- package/dist/lib/evg/EvgTest.rgr +91 -0
- package/dist/lib/evg/EvgTraceColor.rgr +236 -0
- package/dist/lib/evg/EvgTraceCurve.rgr +648 -0
- package/dist/lib/evg/EvgTraceFit.rgr +767 -0
- package/dist/lib/evg/EvgTracePath.rgr +435 -0
- package/dist/lib/evg/EvgTraceTypes.rgr +559 -0
- package/dist/lib/evg/EvgViewport.rgr +313 -0
- package/dist/lib/evg/FxDemoDoc.rgr +185 -0
- package/dist/lib/evg/HOSTS.md +227 -0
- package/dist/lib/evg/ISSUES.md +835 -0
- package/dist/lib/evg/PLAN_ACCESSIBILITY.md +650 -0
- package/dist/lib/evg/PLAN_CSS_LAYOUT_AND_FONTS.md +1124 -0
- package/dist/lib/evg/PLAN_EFFECTS.md +431 -0
- package/dist/lib/evg/PLAN_EVG.md +518 -0
- package/dist/lib/evg/PLAN_EVG_RENDERER.md +1674 -0
- package/dist/lib/evg/PLAN_INSPECTOR.md +788 -0
- package/dist/lib/evg/PLAN_LINKS_AND_FORMS.md +157 -0
- package/dist/lib/evg/PLAN_NATIVE_HOSTS.md +671 -0
- package/dist/lib/evg/PLAN_VECTOR_IR.md +664 -0
- package/dist/lib/evg/PLAN_VIEW_TRANSFORM.md +322 -0
- package/dist/lib/evg/PathBuilder.rgr +333 -0
- package/dist/lib/evg/README.md +1236 -0
- package/dist/lib/evg/SPEC.md +1252 -0
- package/dist/lib/evg/SVGPathParser.rgr +1199 -0
- package/dist/lib/evg/SvgParser.rgr +1736 -0
- package/dist/lib/evg/TODO_EVG_RENDERER.md +595 -0
- package/dist/lib/evg/VectorShapes.rgr +331 -0
- package/dist/lib/evg/VectorStroke.rgr +107 -0
- package/dist/lib/evg/VectorViewBox.rgr +380 -0
- package/dist/lib/evg/agent/README.md +290 -0
- package/dist/lib/evg/agent/evg_agent.rgr +899 -0
- package/dist/lib/evg/agent/fixtures/broken.evg.json +7 -0
- package/dist/lib/evg/agent/fixtures/card.evg.json +9 -0
- package/dist/lib/evg/agent/fixtures/connector.css +63 -0
- package/dist/lib/evg/agent/fixtures/connector.evg.json +14 -0
- package/dist/lib/evg/agent/fixtures/drawn.evg.json +129 -0
- package/dist/lib/evg/agent/fixtures/gradient.evg.json +4 -0
- package/dist/lib/evg/agent/fixtures/popover.css +99 -0
- package/dist/lib/evg/agent/fixtures/popover.evg.json +30 -0
- package/dist/lib/evg/agent/roundtrip.sh +74 -0
- package/dist/lib/evg/agent/smoke.sh +230 -0
- package/dist/lib/evg/android/README.md +97 -0
- package/dist/lib/evg/android/androidstubs/AndroidStubs.kt +175 -0
- package/dist/lib/evg/android/androidstubs/Annotation.kt +10 -0
- package/dist/lib/evg/android/androidstubs/App.kt +30 -0
- package/dist/lib/evg/android/androidstubs/Content.kt +41 -0
- package/dist/lib/evg/android/androidstubs/ContentRes.kt +12 -0
- package/dist/lib/evg/android/androidstubs/InputMethod.kt +46 -0
- package/dist/lib/evg/android/androidstubs/Net.kt +6 -0
- package/dist/lib/evg/android/androidstubs/Os.kt +30 -0
- package/dist/lib/evg/android/androidstubs/Util.kt +15 -0
- package/dist/lib/evg/android/androidstubs/View.kt +113 -0
- package/dist/lib/evg/android/androidstubs/Widget.kt +16 -0
- package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidEvgSurface.kt +323 -0
- package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidTextMeasurer.kt +56 -0
- package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/RippleEffect.kt +262 -0
- package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtEvgSurface.kt +238 -0
- package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtTextMeasurer.kt +85 -0
- package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgEngineThread.kt +115 -0
- package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgPainter.kt +231 -0
- package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgSurface.kt +117 -0
- package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/RecordingSurface.kt +94 -0
- package/dist/lib/evg/apple/README.md +128 -0
- package/dist/lib/evg/apple/Sources/CoreGraphicsEvgSurface.swift +329 -0
- package/dist/lib/evg/apple/Sources/CoreTextMeasurer.swift +70 -0
- package/dist/lib/evg/apple/Sources/EvgEngineQueue.swift +105 -0
- package/dist/lib/evg/apple/Sources/EvgPainter.swift +225 -0
- package/dist/lib/evg/apple/Sources/EvgSurface.swift +163 -0
- package/dist/lib/evg/apple/Sources/RecordingSurface.swift +110 -0
- package/dist/lib/evg/bench/EvgLayoutBench.rgr +34 -0
- package/dist/lib/evg/bench/README.md +64 -0
- package/dist/lib/evg/bench/layout-bench.mjs +316 -0
- package/dist/lib/evg/bench/layout-cases.mjs +510 -0
- package/dist/lib/evg/bench/layout-conformance.mjs +254 -0
- package/dist/lib/evg/bin/.gitignore +17 -0
- package/dist/lib/evg/evg_test.rgr +313 -0
- package/dist/lib/evg/gl/README.md +198 -0
- package/dist/lib/evg/gl/a11y-paint-check.mjs +73 -0
- package/dist/lib/evg/gl/blur-check.mjs +399 -0
- package/dist/lib/evg/gl/boxmodel.json +1 -0
- package/dist/lib/evg/gl/demo.html +46 -0
- package/dist/lib/evg/gl/effect-presets.css +285 -0
- package/dist/lib/evg/gl/effect-presets.js +63 -0
- package/dist/lib/evg/gl/effect-shots.mjs +135 -0
- package/dist/lib/evg/gl/evg-a11y.js +566 -0
- package/dist/lib/evg/gl/evg-binary.js +160 -0
- package/dist/lib/evg/gl/evg-engine.js +296 -0
- package/dist/lib/evg/gl/evg-fx.js +237 -0
- package/dist/lib/evg/gl/evg-gestures.js +209 -0
- package/dist/lib/evg/gl/evg-list.js +167 -0
- package/dist/lib/evg/gl/evg-measure.js +186 -0
- package/dist/lib/evg/gl/evg-textinput.js +303 -0
- package/dist/lib/evg/gl/evg-view.js +144 -0
- package/dist/lib/evg/gl/evg-webgl.js +3942 -0
- package/dist/lib/evg/gl/fx-check.mjs +998 -0
- package/dist/lib/evg/gl/fx-demo.html +166 -0
- package/dist/lib/evg/gl/fx-demo.js +2 -0
- package/dist/lib/evg/gl/fx-serve.mjs +47 -0
- package/dist/lib/evg/gl/gestures-check.mjs +189 -0
- package/dist/lib/evg/gl/list-binary-check.mjs +152 -0
- package/dist/lib/evg/gl/measure-check.mjs +135 -0
- package/dist/lib/evg/gl/rotation-check.mjs +207 -0
- package/dist/lib/evg/gl/shift-check.mjs +113 -0
- package/dist/lib/evg/gl/stroke-check.mjs +168 -0
- package/dist/lib/evg/gl/text-snap-check.mjs +139 -0
- package/dist/lib/evg/gl/view-check.mjs +343 -0
- package/dist/lib/evg/gl/view-policy-check.mjs +184 -0
- package/dist/lib/evg/html/evg-dom.js +318 -0
- package/dist/lib/evg/html/evg-html.js +600 -0
- package/dist/lib/evg/inspect/README.md +415 -0
- package/dist/lib/evg/inspect/browser-smoke.mjs +115 -0
- package/dist/lib/evg/inspect/evg-inspect.js +947 -0
- package/dist/lib/evg/inspect/shots/css.png +0 -0
- package/dist/lib/evg/inspect/shots/dashboard.png +0 -0
- package/dist/lib/evg/inspect/shots/pptx-slide.png +0 -0
- package/dist/lib/evg/inspect/shots/state.png +0 -0
- package/dist/lib/evg/inspect/shots.mjs +226 -0
- package/dist/lib/evg/oracle/css-blur.json +576 -0
- package/dist/lib/evg/oracle/css-box.json +157 -0
- package/dist/lib/evg/oracle/css-timing.json +709 -0
- package/dist/lib/evg/oracle/css_blur_oracle.mjs +529 -0
- package/dist/lib/evg/oracle/css_box_oracle.mjs +106 -0
- package/dist/lib/evg/oracle/css_timing_oracle.mjs +389 -0
- package/dist/lib/evg/original/EVGColor.clj +310 -0
- package/dist/lib/evg/original/EVGColorContext.rgr +178 -0
- package/dist/lib/evg/original/SVGPath.rgr +627 -0
- package/dist/lib/evg/original/Vec2.crgr +103 -0
- package/dist/lib/evg/ranger.json +9 -0
- package/dist/lib/evg/showcase/README.md +246 -0
- package/dist/lib/evg/showcase/assets/emblem.svg +30 -0
- package/dist/lib/evg/showcase/assets/rosette.svg +22 -0
- package/dist/lib/evg/showcase/build.mjs +651 -0
- package/dist/lib/evg/showcase/pages/album.tsx +30 -0
- package/dist/lib/evg/showcase/pages/boxmodel.tsx +37 -0
- package/dist/lib/evg/showcase/pages/cards.tsx +41 -0
- package/dist/lib/evg/showcase/pages/chart_api.tsx +170 -0
- package/dist/lib/evg/showcase/pages/charts.tsx +219 -0
- package/dist/lib/evg/showcase/pages/drawing.tsx +160 -0
- package/dist/lib/evg/showcase/pages/emoji.tsx +91 -0
- package/dist/lib/evg/showcase/pages/flex.tsx +46 -0
- package/dist/lib/evg/showcase/pages/more.tsx +332 -0
- package/dist/lib/evg/showcase/pages/plots.tsx +262 -0
- package/dist/lib/evg/showcase/pages/svg.tsx +63 -0
- package/dist/lib/evg/showcase/pages/tables.tsx +209 -0
- package/dist/lib/evg/showcase/pages/typography.tsx +43 -0
- package/dist/lib/evg/showcase/pages/units.tsx +35 -0
- package/dist/lib/evg/showcase/pages/variants.tsx +233 -0
- package/dist/lib/evg/showcase/pages/vector.tsx +75 -0
- package/dist/lib/evg/showcase/pages/views.tsx +223 -0
- package/dist/lib/evg/showcase/tests/chart_api_smoke.mjs +339 -0
- package/dist/lib/evg/showcase/tests/gl_smoke.mjs +125 -0
- package/dist/lib/evg/showcase/themes/chart_api-default.css +15 -0
- package/dist/lib/evg/showcase/themes/charts-default.css +29 -0
- package/dist/lib/evg/showcase/themes/drawing-default.css +35 -0
- package/dist/lib/evg/showcase/themes/more-default.css +128 -0
- package/dist/lib/evg/showcase/themes/plots-default.css +18 -0
- package/dist/lib/evg/showcase/themes/showcase.css +508 -0
- package/dist/lib/evg/showcase/themes/tables-default.css +123 -0
- package/dist/lib/evg/showcase/themes/variants-default.css +15 -0
- package/dist/lib/evg/showcase/themes/views-default.css +15 -0
- package/dist/lib/evg/tools/bench_vs_potrace.mjs +290 -0
- package/dist/lib/evg/tools/evg_image_tool.rgr +488 -0
- package/dist/lib/evg/tools/evg_trace_bench.rgr +114 -0
- package/dist/lib/evg/tools/evg_trace_cli.rgr +662 -0
- package/dist/lib/evg/tools/evg_trace_cpp_bench.rgr +83 -0
- package/dist/lib/evg/tools/run_trace_bench.sh +74 -0
- package/dist/lib/evg/tools/run_trace_cli_smoke.sh +128 -0
- package/dist/lib/evg/web/responsive/EvgResponsiveCheck.rgr +271 -0
- package/dist/lib/evg/web/responsive/EvgResponsiveDemo.rgr +531 -0
- package/dist/lib/evg/web/responsive/README.md +97 -0
- package/dist/lib/evg/web/responsive/build.mjs +90 -0
- package/dist/lib/evg/web/responsive/dom-check.mjs +169 -0
- package/dist/lib/evg/web/responsive/index.html +177 -0
- package/dist/lib/evg/web/responsive/smoke.mjs +221 -0
- package/dist/lib/evg/web/tools/assets-client.mjs +95 -0
- package/dist/lib/evg/web/tools/boot-bench.mjs +189 -0
- package/dist/lib/evg/web/tools/inline-assets.mjs +119 -0
- package/dist/lib/evg/web/tools/minify.mjs +60 -0
- package/dist/lib/evg/web/tracer/build.mjs +87 -0
- package/dist/lib/evg/web/tracer/index.html +2805 -0
- package/dist/lib/evg/web/tracer/sample.png +0 -0
- package/dist/lib/evg/web/tracer/smoke.mjs +1162 -0
- package/dist/lib/evgr/Cargo.lock +21 -0
- package/dist/lib/evgr/Cargo.toml +19 -0
- package/dist/lib/evgr/README.md +140 -0
- package/dist/lib/evgr/bench/NativeBench.rgr +246 -0
- package/dist/lib/evgr/bench/compare.mjs +252 -0
- package/dist/lib/evgr/bench/speed.mjs +277 -0
- package/dist/lib/evgr/bin/.gitignore +3 -0
- package/dist/lib/evgr/src/bin/bench.rs +106 -0
- package/dist/lib/evgr/src/bin/smoke.rs +13 -0
- package/dist/lib/evgr/src/grid.rs +730 -0
- package/dist/lib/evgr/src/lib.rs +1003 -0
- package/dist/lib/evgr/src/style.rs +468 -0
- package/dist/lib/evgr/src/text.rs +83 -0
- package/dist/lib/image/BitReader.rgr +171 -0
- package/dist/lib/image/Buffer.rgr +173 -0
- package/dist/lib/image/DCT.rgr +283 -0
- package/dist/lib/image/Deflate.rgr +339 -0
- package/dist/lib/image/HuffmanDecoder.rgr +181 -0
- package/dist/lib/image/ImageBuffer.rgr +706 -0
- package/dist/lib/image/JPEGDecoder.rgr +808 -0
- package/dist/lib/image/PNGDecoder.rgr +544 -0
- package/dist/lib/image/PNGEncoder.rgr +388 -0
- package/dist/lib/image/PPMImage.rgr +224 -0
- package/dist/lib/image/ProgressiveJPEGDecoder.rgr +1154 -0
- package/dist/lib/image/README.md +33 -0
- package/dist/lib/image/RasterBuffer.rgr +294 -0
- package/dist/lib/image/VP8BoolDecoder.rgr +163 -0
- package/dist/lib/image/WebPDecoder.rgr +433 -0
- package/dist/lib/image/WebPLossless.rgr +1035 -0
- package/dist/lib/image/WebPLossy.rgr +1929 -0
- package/dist/lib/image/ranger.json +9 -0
- package/dist/lib/image/testdata/webp/anim_2f_24x16.webp +0 -0
- package/dist/lib/image/testdata/webp/fixtures.txt +33 -0
- package/dist/lib/image/testdata/webp/gen_fixtures.py +261 -0
- package/dist/lib/image/testdata/webp/ll_1x1.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_alpha_29x21.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_gradient_64x64.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_meta_32x32.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_noise_17x9.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_pal11_23x11.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_pal2_19x7.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_pal40_16x16.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_pal4_13x5.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_photo_40x30.webp +0 -0
- package/dist/lib/image/testdata/webp/ll_predictors_64x32.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_1x1.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_raw_f0_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_raw_f1_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_raw_f2_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_raw_f3_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_vp8l_f0_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_vp8l_f1_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_vp8l_f2_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alph_vp8l_f3_21x13.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_alpha_33x17.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_exif_15x11.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_i16_96x80.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_nofilter_24x20.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_odd_17x9.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_photo_48x40.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_q100_20x18.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_sharp_40x36.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_simple_filter_33x31.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_vpx_parts8_37x45.webp +0 -0
- package/dist/lib/image/testdata/webp/ly_vpx_skip_96x80.webp +0 -0
- package/dist/lib/image/tests/WebPDecodeTool.rgr +81 -0
- package/dist/lib/image/tests/WebPDecoderTest.rgr +1162 -0
- package/dist/lib/image/tests/run_webp_tests.sh +39 -0
- package/dist/lib/rust/RsJson.rgr +468 -0
- package/dist/lib/rust/RsPrelude.rgr +1663 -0
- package/dist/lib/shell_test.rgr +7 -7
- package/dist/lib/stdlib.rgr +21 -5
- package/dist/lib/zip/ranger.json +6 -0
- package/dist/rgrc.js +87318 -67610
- package/package.json +1010 -1040
- package/dist/README.md +0 -117
- 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];
|