├── .gitattributes ├── examples ├── css.d.ts ├── rubber_duck.png ├── maplibre.css ├── maplibre_basic.css ├── vortex.css ├── maplibre_basic.html ├── maplibre_basic_sprites.html ├── vortex.html ├── vortex_sprites.html ├── vortex.ts ├── maplibre.html ├── vortex-sprites.ts ├── maplibre_basic.ts ├── maplibre_basic-sprites.ts ├── wave.svg ├── maplibre.ts ├── vortex-base.ts ├── wms.ts └── options.ts ├── .gitignore ├── tests ├── assets │ ├── glsl.d.ts │ ├── example.vert.glsl │ └── example.frag.glsl ├── utils.ts ├── shader.test.ts └── shader-program.test.ts ├── src ├── shaders │ ├── is_missing_velocity.glsl │ ├── placeholder.frag.glsl │ ├── texture.vert.glsl │ ├── render.frag.glsl │ ├── texture.frag.glsl │ ├── final.vert.glsl │ ├── final.frag.glsl │ ├── render.vert.glsl │ └── particle.vert.glsl ├── render │ ├── index.ts │ ├── texture.ts │ ├── final.ts │ ├── particles.ts │ └── propagator.ts ├── index.ts ├── utils │ ├── shader.ts │ ├── geometry.ts │ ├── speedcurve.ts │ ├── textures.ts │ ├── colormap.ts │ ├── wms.ts │ └── shader-program.ts ├── layer.ts └── visualiser.ts ├── .prettierrc ├── .github └── workflows │ ├── build-lint.yml │ ├── test.yml │ ├── npm-publish.yml │ └── github-pages.yml ├── vitest.config.ts ├── eslint.config.js ├── vite-examples.config.ts ├── LICENSE ├── tsconfig.json ├── vite-library.config.ts ├── index.html ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /examples/css.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | dist/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /tests/assets/glsl.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.glsl?raw' { 2 | const value: string 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /examples/rubber_duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/webgl-streamline-visualizer/main/examples/rubber_duck.png -------------------------------------------------------------------------------- /src/shaders/is_missing_velocity.glsl: -------------------------------------------------------------------------------- 1 | bool is_missing_velocity(vec4 raw) { 2 | // There is no velocity if r = g = 255. 3 | return raw.r == 1.0 && raw.g == 1.0; 4 | } 5 | -------------------------------------------------------------------------------- /src/shaders/placeholder.frag.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | out vec4 color; 5 | 6 | void main() { 7 | color = vec4(0.0, 0.0, 0.0, 0.0); 8 | } 9 | -------------------------------------------------------------------------------- /src/render/index.ts: -------------------------------------------------------------------------------- 1 | export { ParticlePropagator } from './propagator' 2 | export { ParticleRenderer } from './particles' 3 | export { TextureRenderer } from './texture' 4 | export { FinalRenderer, StreamlineStyle } from './final' 5 | -------------------------------------------------------------------------------- /src/shaders/texture.vert.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | in vec4 a_position; 5 | in vec2 a_tex_coord; 6 | 7 | out vec2 v_tex_coord; 8 | 9 | void main() { 10 | v_tex_coord = a_tex_coord; 11 | gl_Position = a_position; 12 | } 13 | -------------------------------------------------------------------------------- /src/shaders/render.frag.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | uniform sampler2D u_particle_texture; 5 | 6 | in vec2 v_tex_coord; 7 | 8 | out vec4 color; 9 | 10 | void main() { 11 | color = texture(u_particle_texture, v_tex_coord); 12 | } 13 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | export function createWebGl2Context(): WebGL2RenderingContext { 2 | const canvas = new OffscreenCanvas(100, 100) 3 | const gl = canvas.getContext('webgl2') 4 | if (!gl) { 5 | throw new Error('Failed to create WebGL2 context.') 6 | } 7 | return gl 8 | } 9 | -------------------------------------------------------------------------------- /tests/assets/example.vert.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | 3 | uniform float u_factor; 4 | 5 | in vec4 a_position; 6 | in vec2 a_tex_coord; 7 | 8 | out vec2 v_tex_coord; 9 | 10 | void main() { 11 | v_tex_coord = u_factor * a_tex_coord; 12 | gl_Position = a_position; 13 | } 14 | -------------------------------------------------------------------------------- /examples/maplibre.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: sans-serif; 4 | font-size: 16px; 5 | } 6 | 7 | main { 8 | width: 800px; 9 | margin: 40px auto; 10 | display: flex; 11 | flex-direction: column; 12 | row-gap: 20px; 13 | } 14 | 15 | div#container { 16 | width: 800px; 17 | height: 600px; 18 | } -------------------------------------------------------------------------------- /examples/maplibre_basic.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: sans-serif; 4 | font-size: 16px; 5 | } 6 | 7 | main { 8 | width: 800px; 9 | margin: 40px auto; 10 | display: flex; 11 | flex-direction: column; 12 | row-gap: 20px; 13 | } 14 | 15 | div#container { 16 | width: 800px; 17 | height: 600px; 18 | } -------------------------------------------------------------------------------- /src/shaders/texture.frag.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | uniform sampler2D u_texture; 5 | uniform float u_fade_amount; 6 | 7 | in vec2 v_tex_coord; 8 | 9 | out vec4 color; 10 | 11 | void main() { 12 | color = texture(u_texture, v_tex_coord); 13 | color.a = max(color.a - u_fade_amount, 0.0); 14 | } 15 | -------------------------------------------------------------------------------- /tests/assets/example.frag.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | 3 | precision highp float; 4 | 5 | uniform sampler2D u_texture; 6 | uniform float u_fade_amount; 7 | 8 | in vec2 v_tex_coord; 9 | 10 | out vec4 color; 11 | 12 | void main() { 13 | color = texture(u_texture, v_tex_coord); 14 | color.a = max(color.a - u_fade_amount, 0.0); 15 | } 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "quoteProps": "as-needed", 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "vueIndentScriptAndStyle": false, 12 | "endOfLine": "lf", 13 | "singleAttributePerLine": true 14 | } -------------------------------------------------------------------------------- /examples/vortex.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: sans-serif; 4 | font-size: 16px; 5 | } 6 | 7 | main { 8 | width: 800px; 9 | margin: 40px auto; 10 | } 11 | 12 | canvas { 13 | width: 800px; 14 | height: 600px; 15 | } 16 | 17 | div.container { 18 | width: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | row-gap: 10px; 23 | } -------------------------------------------------------------------------------- /examples/maplibre_basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Basic MapLibre demo 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/maplibre_basic_sprites.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Basic MapLibre demo 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/build-lint.yml: -------------------------------------------------------------------------------- 1 | name: Build and lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - uses: actions/setup-node@v6 17 | with: 18 | node-version: 22 19 | cache: 'npm' 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run lint 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { WMSStreamlineLayer, type WMSStreamlineLayerOptions } from './layer' 2 | export { StreamlineStyle } from './render' 3 | export { 4 | StreamlineVisualiser, 5 | TrailParticleShape, 6 | type TrailParticleOptions, 7 | type StreamlineVisualiserOptions 8 | } from './visualiser' 9 | export { 10 | fetchWMSAvailableTimesAndElevations, 11 | fetchWMSColormap, 12 | fetchWMSVelocityField 13 | } from './utils/wms' 14 | export { type BoundingBoxScaling } from './render/final' 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - uses: actions/setup-node@v6 17 | with: 18 | node-version: 22 19 | cache: 'npm' 20 | - run: npm ci 21 | - run: npx playwright install chromium 22 | - run: npm run test-once 23 | -------------------------------------------------------------------------------- /examples/vortex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vortex demo 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/vortex_sprites.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vortex with sprites demo 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vitest/config' 3 | import { playwright} from '@vitest/browser-playwright' 4 | 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | '@': resolve(__dirname, 'src') 9 | } 10 | }, 11 | test: { 12 | browser: { 13 | enabled: true, 14 | screenshotFailures: false, 15 | provider: playwright(), 16 | instances: [ { browser: 'chromium' }], 17 | headless: true, 18 | api: 5174 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | permissions: 4 | id-token: write # Required for OIDC 5 | contents: read 6 | 7 | on: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - uses: actions/setup-node@v6 17 | with: 18 | node-version: 22 19 | - name: Update npm # 11.5.0 required for OIDC authentication 20 | run: npm install -g npm@latest 21 | - run: npm ci 22 | - run: npm run build 23 | - run: npm publish 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import { defineConfig, globalIgnores } from 'eslint/config' 3 | import globals from 'globals' 4 | import tseslint from 'typescript-eslint' 5 | import eslintConfigPrettier from 'eslint-config-prettier/flat' 6 | 7 | export default defineConfig( 8 | globalIgnores(['coverage/', 'dist/', 'doc/']), 9 | eslint.configs.recommended, 10 | tseslint.configs.recommended, 11 | eslintConfigPrettier, 12 | { 13 | languageOptions: { 14 | globals: globals.browser, 15 | parserOptions: { 16 | projectService: { allowDefaultProject: ['*.js'] }, 17 | }, 18 | }, 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /src/shaders/final.vert.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | uniform vec2 u_bbox_scale; 5 | uniform vec2 u_bbox_offset; 6 | 7 | in vec4 a_position; 8 | in vec2 a_tex_coord; 9 | 10 | out vec2 v_tex_coord; 11 | out vec2 v_flipped_tex_coord; 12 | 13 | void main() { 14 | v_tex_coord = a_tex_coord; 15 | // Vertically flipped texture coordinate for velocity field data. 16 | v_flipped_tex_coord = vec2( 17 | a_tex_coord.x, 18 | 1.0 - a_tex_coord.y 19 | ); 20 | 21 | gl_Position = a_position; 22 | 23 | // Scale bounding box. 24 | gl_Position.xy = gl_Position.xy * u_bbox_scale + u_bbox_offset; 25 | } 26 | -------------------------------------------------------------------------------- /examples/vortex.ts: -------------------------------------------------------------------------------- 1 | import './vortex.css' 2 | 3 | import { initialiseControl, initialiseVisualiser } from './vortex-base' 4 | import { StreamlineStyle, type StreamlineVisualiserOptions } from '@/index' 5 | 6 | const numParticles = 10000 7 | const options: StreamlineVisualiserOptions = { 8 | style: StreamlineStyle.LightParticlesOnMagnitude, 9 | particleSize: 3, 10 | speedFactor: 0.4, 11 | fadeAmountPerSecond: 3, 12 | maxDisplacement: 1, 13 | maxAge: 2.0 14 | } 15 | 16 | initialiseVisualiser(numParticles, options) 17 | .then(visualiser => { 18 | initialiseControl(visualiser) 19 | }) 20 | .catch(error => console.error(`Failed to initialise visualiser: ${error}`)) 21 | -------------------------------------------------------------------------------- /examples/maplibre.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | MapLibre demo 10 | 11 | 12 | 13 |
14 | 15 |
16 | 20 |
21 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/shader.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import { VertexShader, FragmentShader } from '@/utils/shader' 4 | 5 | import { createWebGl2Context } from './utils' 6 | 7 | test('tests shader constructor', () => { 8 | const gl = createWebGl2Context() 9 | 10 | const vertexShader = new VertexShader(gl, 'void main() {}') 11 | expect(vertexShader).toBeDefined() 12 | expect(vertexShader.shader).toBeDefined() 13 | 14 | const fragmentShader = new FragmentShader(gl, 'void main() {}') 15 | expect(fragmentShader).toBeDefined() 16 | expect(fragmentShader.shader).toBeDefined() 17 | 18 | // Compiling should run without errors (errors are checked at link time). 19 | vertexShader.compile() 20 | fragmentShader.compile() 21 | }) 22 | -------------------------------------------------------------------------------- /examples/vortex-sprites.ts: -------------------------------------------------------------------------------- 1 | import './vortex.css' 2 | import spriteUrl from './wave.svg' 3 | 4 | import { initialiseControl, initialiseVisualiser } from './vortex-base' 5 | import { StreamlineStyle, type StreamlineVisualiserOptions } from '@/index' 6 | 7 | const numParticles = 500 8 | const options: StreamlineVisualiserOptions = { 9 | style: StreamlineStyle.LightParticlesOnMagnitude, 10 | particleSize: 24, 11 | speedFactor: 0.4, 12 | fadeAmountPerSecond: 3, 13 | maxDisplacement: 1, 14 | maxAge: 2, 15 | growthRate: 1, 16 | spriteUrl: new URL(spriteUrl, window.location.origin) 17 | } 18 | 19 | initialiseVisualiser(numParticles, options) 20 | .then(visualiser => { 21 | initialiseControl(visualiser) 22 | }) 23 | .catch(error => console.error(`Failed to initialise visualiser: ${error}`)) 24 | -------------------------------------------------------------------------------- /vite-examples.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import viteGlslPlugin from 'vite-plugin-glsl' 4 | 5 | function resolveRelativePath(relative: string): string { 6 | return resolve(__dirname, relative) 7 | } 8 | 9 | export default defineConfig({ 10 | base: '/webgl-streamline-visualizer/', // Base path for GitHub Pages 11 | plugins: [ 12 | viteGlslPlugin({ 13 | include: ['**/*.frag.glsl', '**/*.vert.glsl'], 14 | minify: true 15 | }) 16 | ], 17 | build: { 18 | rollupOptions: { 19 | input: { 20 | index: resolveRelativePath('index.html'), 21 | vortex: resolveRelativePath('examples/vortex.html'), 22 | vortex_sprite: resolveRelativePath('examples/vortex_sprites.html'), 23 | maplibre: resolveRelativePath('examples/maplibre.html') 24 | } 25 | } 26 | }, 27 | resolve: { 28 | alias: { 29 | '@': resolveRelativePath('./src') 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /examples/maplibre_basic.ts: -------------------------------------------------------------------------------- 1 | import './maplibre_basic.css' 2 | import 'maplibre-gl/dist/maplibre-gl.css' 3 | 4 | import { Map } from 'maplibre-gl' 5 | import { 6 | StreamlineStyle, 7 | WMSStreamlineLayer, 8 | type WMSStreamlineLayerOptions 9 | } from '@/index' 10 | 11 | const defaultCentre: [number, number] = [0, 0] 12 | const defaultZoom = 0.2 13 | const style = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' 14 | const map = new Map({ 15 | container: 'container', 16 | style, 17 | center: defaultCentre, 18 | zoom: defaultZoom 19 | }) 20 | 21 | await map.once('load') 22 | 23 | const options: WMSStreamlineLayerOptions = { 24 | baseUrl: 'https://example.com/FewsWebServices/wms', 25 | layer: 'layer_name', 26 | streamlineStyle: StreamlineStyle.LightParticlesOnMagnitude, 27 | numParticles: 12000, 28 | particleSize: 4, 29 | speedFactor: 0.1, 30 | fadeAmountPerSecond: 3, 31 | speedExponent: 0.7 32 | } 33 | 34 | const layer = new WMSStreamlineLayer('streamlines', options) 35 | map.addLayer(layer) 36 | 37 | await layer.initialise() 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Deltares 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "isolatedModules": true, 17 | "moduleDetection": "force", 18 | "noEmit": true, 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "allowUnreachableCode": false, 23 | "allowUnusedLabels": false, 24 | "noFallthroughCasesInSwitch": true, 25 | "noImplicitOverride": true, 26 | "noImplicitReturns": true, 27 | "noPropertyAccessFromIndexSignature": true, 28 | "noUncheckedIndexedAccess": true, 29 | "noUncheckedSideEffectImports": true, 30 | "noUnusedLocals": true, 31 | "noUnusedParameters": true, 32 | "verbatimModuleSyntax": true, 33 | 34 | "types": ["vite-plugin-glsl/ext", "vite/client"] 35 | }, 36 | "include": ["examples", "src", "tests", "*.config.ts"] 37 | } 38 | -------------------------------------------------------------------------------- /examples/maplibre_basic-sprites.ts: -------------------------------------------------------------------------------- 1 | import './maplibre_basic.css' 2 | import 'maplibre-gl/dist/maplibre-gl.css' 3 | import spriteUrl from './wave.svg' 4 | 5 | import { Map } from 'maplibre-gl' 6 | import { 7 | StreamlineStyle, 8 | WMSStreamlineLayer, 9 | type WMSStreamlineLayerOptions 10 | } from '@/index' 11 | 12 | const defaultCentre: [number, number] = [0, 0] 13 | const defaultZoom = 0.2 14 | const style = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' 15 | const map = new Map({ 16 | container: 'container', 17 | style, 18 | center: defaultCentre, 19 | zoom: defaultZoom 20 | }) 21 | 22 | await map.once('load') 23 | 24 | const options: WMSStreamlineLayerOptions = { 25 | baseUrl: 26 | 'https://rwsos-dataservices-ont.avi.deltares.nl/durban/FewsWebServices/wms', 27 | layer: 'swan_hs', 28 | streamlineStyle: StreamlineStyle.LightParticlesOnMagnitude, 29 | numParticles: 500, 30 | particleSize: 24, 31 | speedFactor: 0.05, 32 | fadeAmountPerSecond: 2, 33 | speedExponent: 0.7, 34 | growthRate: 1, 35 | maxAge: 2, 36 | spriteUrl: new URL(spriteUrl, window.location.origin) 37 | } 38 | 39 | const layer = new WMSStreamlineLayer('streamlines', options) 40 | map.addLayer(layer) 41 | 42 | await layer.initialise() 43 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main, examples-gh-pages] 6 | # enables manual deployment from the Actions tab 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment 15 | concurrency: 16 | group: 'pages' 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | # Single deploy job since we're just deploying 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v5 29 | - name: Set up Node 30 | uses: actions/setup-node@v6 31 | with: 32 | node-version: lts/* 33 | cache: 'npm' 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Build 37 | run: npm run build-examples 38 | - name: Setup Pages 39 | uses: actions/configure-pages@v5 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v4 42 | with: 43 | # Upload dist folder 44 | path: './dist' 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /vite-library.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import viteGlslPlugin from 'vite-plugin-glsl' 4 | import rollupPluginTypescript from '@rollup/plugin-typescript' 5 | 6 | function resolveRelativePath(relative: string): string { 7 | return resolve(__dirname, relative) 8 | } 9 | 10 | export default defineConfig({ 11 | plugins: [ 12 | viteGlslPlugin({ 13 | include: ['**/*.frag.glsl', '**/*.vert.glsl'], 14 | minify: true 15 | }) 16 | ], 17 | build: { 18 | lib: { 19 | entry: resolveRelativePath('src/index.ts'), 20 | // Only build ES module, this library is only relevant for use in the 21 | // browser. 22 | formats: ['es'], 23 | name: 'webgl-streamline-visualizer', 24 | fileName: 'webgl-streamline-visualizer' 25 | }, 26 | rollupOptions: { 27 | // Do not bundle MapLibre; applications using this streamlines library as a 28 | // map layer should already have it anyway. 29 | external: ['maplibre-gl'], 30 | plugins: [ 31 | rollupPluginTypescript({ 32 | allowImportingTsExtensions: false, 33 | declaration: true, 34 | declarationDir: resolveRelativePath('dist'), 35 | rootDir: resolveRelativePath('src') 36 | }) 37 | ] 38 | } 39 | }, 40 | resolve: { 41 | alias: { 42 | '@': resolveRelativePath('./src') 43 | } 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /src/utils/shader.ts: -------------------------------------------------------------------------------- 1 | type ShaderType = 2 | | WebGL2RenderingContext['VERTEX_SHADER'] 3 | | WebGL2RenderingContext['FRAGMENT_SHADER'] 4 | 5 | class Shader { 6 | readonly gl: WebGL2RenderingContext 7 | readonly shader: WebGLShader 8 | 9 | private hasCompileAttempt: boolean 10 | 11 | constructor(gl: WebGL2RenderingContext, type: ShaderType, source: string) { 12 | const shader = gl.createShader(type) 13 | if (!shader) { 14 | throw new Error('Failed to create WebGL shader.') 15 | } 16 | gl.shaderSource(shader, source) 17 | 18 | this.gl = gl 19 | this.shader = shader 20 | this.hasCompileAttempt = false 21 | } 22 | 23 | destruct(): void { 24 | this.gl.deleteShader(this.shader) 25 | } 26 | 27 | compile(): void { 28 | // Do not try to compile more than once. 29 | if (this.hasCompileAttempt) return 30 | 31 | this.gl.compileShader(this.shader) 32 | this.hasCompileAttempt = true 33 | // Do not check errors here, as checking the compile status blocks the main 34 | // thread until compilation is complete. Instead, the compile status (and 35 | // any possible errors) will be checked when this shader is linked into a 36 | // shader program. 37 | } 38 | } 39 | 40 | export class VertexShader extends Shader { 41 | constructor(gl: WebGL2RenderingContext, source: string) { 42 | super(gl, gl.VERTEX_SHADER, source) 43 | } 44 | } 45 | 46 | export class FragmentShader extends Shader { 47 | constructor(gl: WebGL2RenderingContext, source: string) { 48 | super(gl, gl.FRAGMENT_SHADER, source) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/geometry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ShaderProgram, 3 | bindAttribute, 4 | createAndFillStaticBuffer 5 | } from './shader-program' 6 | 7 | export function createRectangleVertexArray( 8 | program: ShaderProgram, 9 | xMin: number, 10 | xMax: number, 11 | yMin: number, 12 | yMax: number, 13 | doFlipV: boolean, 14 | positionAttribute: string, 15 | vertexCoordAttribute: string 16 | ): [WebGLBuffer, WebGLBuffer, WebGLVertexArrayObject] { 17 | const gl = program.gl 18 | const vertexArray = gl.createVertexArray() 19 | if (vertexArray === null) { 20 | throw new Error('Failed to create vertex array.') 21 | } 22 | gl.bindVertexArray(vertexArray) 23 | 24 | const positions = [xMax, yMax, xMin, yMax, xMax, yMin, xMin, yMin] 25 | // Texture coordinate v can either run in the same direction as the clip space 26 | // y-axis, or in the opposite direction. 27 | const texCoords = doFlipV 28 | ? [1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0] // flipped V 29 | : [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0] // unflipped V 30 | 31 | const positionBuffer = createAndFillStaticBuffer( 32 | gl, 33 | new Float32Array(positions) 34 | ) 35 | const texCoordBuffer = createAndFillStaticBuffer( 36 | gl, 37 | new Float32Array(texCoords) 38 | ) 39 | 40 | bindAttribute( 41 | gl, 42 | positionBuffer, 43 | program.getAttributeLocation(positionAttribute), 44 | 2 45 | ) 46 | bindAttribute( 47 | gl, 48 | texCoordBuffer, 49 | program.getAttributeLocation(vertexCoordAttribute), 50 | 2 51 | ) 52 | 53 | gl.bindVertexArray(null) 54 | 55 | return [positionBuffer, texCoordBuffer, vertexArray] 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/speedcurve.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Speed curve of the form factor * speed ^ exponent. 3 | */ 4 | export class SpeedCurve { 5 | private _exponent: number 6 | private _factor: number 7 | private _baseFactor: number 8 | 9 | constructor(exponent: number, factor: number, baseFactor: number) { 10 | this._exponent = exponent 11 | this._factor = factor 12 | this._baseFactor = baseFactor 13 | } 14 | 15 | /** Exponent of the curve. */ 16 | get exponent(): number { 17 | return this._exponent 18 | } 19 | 20 | /** Factor applied after exponentiation. */ 21 | get factor(): number { 22 | return this._factor 23 | } 24 | 25 | /** Base speed factor 26 | * 27 | * This is the multiplication factor that would be applied if the exponent 28 | * would be 1. 29 | */ 30 | get baseFactor(): number { 31 | return this._baseFactor 32 | } 33 | /** 34 | * Returns a speed curve from an exponent and a speed. 35 | * 36 | * The specified speed is used to compute the factor of the curve. At this 37 | * speed, the transformed speed is equal to the original speed. 38 | * 39 | * @param exponent exponent applied to the speed. 40 | * @param factor factor applied to the transformed speed. 41 | * @param speed speed where the transformation does not change the speed 42 | */ 43 | static fromExponentFactorAndSpeed( 44 | exponent: number, 45 | factor: number, 46 | speed: number 47 | ): SpeedCurve { 48 | const transformedSpeed = Math.pow(speed, exponent) 49 | const finalFactor = (factor * speed) / transformedSpeed 50 | return new SpeedCurve(exponent, finalFactor, factor) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | WebGL Streamline Visualizer Examples 10 | 45 | 46 | 47 | 48 |
49 |

WebGL Streamline Visualizer Examples

50 |

51 | Explore the following examples to see how the WebGL Streamline 52 | Visualizer can be used: 53 |

54 | 68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@deltares/webgl-streamline-visualizer", 3 | "description": "Particle-based flow field visualiser based on WebGL", 4 | "license": "MIT", 5 | "private": false, 6 | "version": "4.5.0", 7 | "type": "module", 8 | "files": [ 9 | "dist" 10 | ], 11 | "module": "./dist/webgl-streamline-visualizer.js", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "import": "./dist/webgl-streamline-visualizer.js" 16 | } 17 | }, 18 | "homepage": "https://deltares.github.io/webgl-streamline-visualizer/", 19 | "scripts": { 20 | "dev": "vite --config vite-examples.config.ts", 21 | "build": "tsc && vite build --config vite-library.config.ts", 22 | "build-examples": "tsc && vite build --config vite-examples.config.ts", 23 | "lint": "eslint \"./src/**/*.ts\" \"./examples/**/*.ts\"", 24 | "preview": "vite preview", 25 | "test": "vitest", 26 | "test-once": "vitest run" 27 | }, 28 | "devDependencies": { 29 | "@deltares/fews-wms-requests": "^4.0.1", 30 | "@eslint/js": "^9.39.1", 31 | "@rollup/plugin-typescript": "^12.3.0", 32 | "@types/lodash-es": "^4.17.12", 33 | "@types/node": "^22.14.1", 34 | "@vitest/browser": "^4.0.8", 35 | "@vitest/browser-playwright": "^4.0.8", 36 | "eslint": "^9.39.1", 37 | "eslint-config-prettier": "^10.1.8", 38 | "eslint-plugin-prettier": "^5.5.4", 39 | "playwright": "^1.56.1", 40 | "typescript": "^5.8.3", 41 | "typescript-eslint": "^8.46.4", 42 | "vite": "^7.2.2", 43 | "vite-plugin-glsl": "^1.5.4", 44 | "vitest": "^4.0.8" 45 | }, 46 | "dependencies": { 47 | "geotiff": "^2.1.3", 48 | "lodash-es": "^4.17.21", 49 | "maplibre-gl": "^5.12.0", 50 | "tslib": "^2.8.1" 51 | }, 52 | "volta": { 53 | "node": "22.14.0" 54 | }, 55 | "repository": { 56 | "type": "git", 57 | "url": "git+https://github.com/Deltares/webgl-streamline-visualizer.git" 58 | }, 59 | "bugs": { 60 | "url": "https://github.com/Deltares/webgl-streamline-visualizer/issues" 61 | }, 62 | "publishConfig": { 63 | "access": "public", 64 | "registry": "https://registry.npmjs.org" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 36 | 38 | 41 | 45 | 49 | 50 | 60 | 61 | 65 | 71 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /examples/maplibre.ts: -------------------------------------------------------------------------------- 1 | import './maplibre.css' 2 | import 'maplibre-gl/dist/maplibre-gl.css' 3 | 4 | import { Map } from 'maplibre-gl' 5 | 6 | import type { WMSStreamlineLayerOptions } from '@/layer' 7 | import { 8 | StreamlineStyle, 9 | WMSStreamlineLayer, 10 | type StreamlineVisualiserOptions 11 | } from '@/index' 12 | 13 | import { VisualiserOptionsControl } from './options' 14 | import './wms.ts' 15 | import type { FewsWmsOptionsControl } from './wms.ts' 16 | 17 | async function createMap(): Promise { 18 | const defaultCentre: [number, number] = [0, 0] 19 | const defaultZoom = 0.2 20 | const style = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' 21 | const map = new Map({ 22 | container: 'container', 23 | style, 24 | center: defaultCentre, 25 | zoom: defaultZoom 26 | }) 27 | 28 | await map.once('load') 29 | return map 30 | } 31 | 32 | function createStreamlinesLayer(): WMSStreamlineLayer { 33 | const options: WMSStreamlineLayerOptions = { 34 | baseUrl: '', 35 | layer: '', 36 | streamlineStyle: StreamlineStyle.LightParticlesOnMagnitude, 37 | numParticles: 12000, 38 | particleSize: 4, 39 | speedFactor: 0.1, 40 | fadeAmountPerSecond: 3, 41 | speedExponent: 0.7 42 | } 43 | 44 | const layer = new WMSStreamlineLayer('streamlines', options) 45 | // Add event listener to initialise the layer once it has been added to the 46 | // map. 47 | layer.once('add', () => { 48 | if (!layer.visualiser) { 49 | throw new Error('Streamline visualiser was not initialised.') 50 | } 51 | 52 | const layerControl = document.getElementById( 53 | 'wms-control' 54 | ) as FewsWmsOptionsControl 55 | layerControl.attachLayer(layer) 56 | 57 | // Initialise visualiser options control. 58 | const optionsControl = document.getElementById( 59 | 'options-control' 60 | ) as VisualiserOptionsControl 61 | optionsControl.attachVisualiser(layer.visualiser) 62 | 63 | layerControl.onLayerChange( 64 | ( 65 | numParticles?: number, 66 | options?: Partial 67 | ) => { 68 | optionsControl 69 | .setOptions(numParticles, options) 70 | .catch(error => 71 | console.error(`Failed to update options control: ${error}`) 72 | ) 73 | } 74 | ) 75 | }) 76 | return layer 77 | } 78 | 79 | function initialise(): void { 80 | const layer = createStreamlinesLayer() 81 | createMap() 82 | .then(map => map.addLayer(layer)) 83 | .catch(error => 84 | console.error(`Failed to create map: ${(error as Error).toString()}`) 85 | ) 86 | } 87 | 88 | initialise() 89 | -------------------------------------------------------------------------------- /src/utils/textures.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a new texture. 3 | * 4 | * This requires that the data are unsigned 8-bit integers for each channel. Based on the size of 5 | * the data, we determine whether they are RGB or RGBA; other formats are not supported and will 6 | * raise an error. 7 | * 8 | * Values are clamped to the edges at both S- and T-boundaries, and interpolation for minification 9 | * and magnification is done linearly. 10 | * 11 | * @param uniform uniform to bind the texture to. 12 | * @param data data to assign to the texture, must be unsigned 8-bit integers. 13 | * @param width width of the texture. 14 | * @param height height of the texture. 15 | * @returns initialised and bound texture. 16 | */ 17 | export function createTexture( 18 | gl: WebGL2RenderingContext, 19 | filter: number, 20 | data: Uint8Array | Uint8ClampedArray | ImageBitmap, 21 | width: number, 22 | height: number 23 | ): WebGLTexture { 24 | const texture = gl.createTexture() 25 | if (texture === null) { 26 | throw new Error('Failed to create texture.') 27 | } 28 | 29 | // Set texture properties and initialise data. 30 | gl.bindTexture(gl.TEXTURE_2D, texture) 31 | 32 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) 33 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) 34 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter) 35 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter) 36 | 37 | if (ArrayBuffer.isView(data)) { 38 | const numPixels = width * height 39 | let format: number 40 | let internalFormat: number 41 | if (3 * numPixels === data.length) { 42 | format = gl.RGB 43 | internalFormat = gl.RGB8 44 | } else if (4 * numPixels === data.length) { 45 | format = gl.RGBA 46 | internalFormat = gl.RGBA8 47 | } else { 48 | throw new Error('Only RGB or RGBA textures are supported.') 49 | } 50 | 51 | // Our data are specified as a typed array. 52 | gl.texStorage2D(gl.TEXTURE_2D, 1, internalFormat, width, height) 53 | gl.texSubImage2D( 54 | gl.TEXTURE_2D, 55 | 0, // level 56 | 0, // x-offset 57 | 0, // y-offset 58 | width, 59 | height, 60 | format, 61 | gl.UNSIGNED_BYTE, 62 | data 63 | ) 64 | } else { 65 | // Our data are specified as an ImageBitmap. 66 | gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, width, height) 67 | gl.texSubImage2D( 68 | gl.TEXTURE_2D, 69 | 0, // level 70 | 0, // x-offset 71 | 0, // y-offset 72 | gl.RGBA, 73 | gl.UNSIGNED_BYTE, 74 | data 75 | ) 76 | } 77 | 78 | gl.bindTexture(gl.TEXTURE_2D, null) 79 | 80 | return texture 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebGL streamline visualizer 2 | 3 | A library for visualizing streamlines with moving particles, powered by WebGL. 4 | The visualizer can be run with velocity fields from any source, but has a 5 | MapLibre map layer that fetches WMS data from a FEWS WMS service. 6 | 7 | This library uses WebGL to propagate particles along the streamlines of a 8 | velocity field and visualize their tracks. Both the propagation and the 9 | visualization is powered by the user's GPU, allowing the visualization to run 10 | with thousands of particles at high FPS, even on mobile phones. 11 | 12 | ## Usage with MapLibre and FEWS WMS 13 | 14 | This library has been primarily developed for use with 15 | [MapLibre](https://maplibre.org/) and [FEWS Web Mapping 16 | Service](https://publicwiki.deltares.nl/display/FEWSDOC/FEWS+Web+Mapping+Service+with+time+support%3A+WMS-T). 17 | 18 | It can be added to an existing MapLibre map with: 19 | 20 | ```typescript 21 | // Create new animated streamlines layer based on options. 22 | const layer = new WMSStreamlineLayer('streamlines', options) 23 | 24 | // Add the layer to the MapLibre map. 25 | map.addLayer(layer) 26 | 27 | // Initialise the streamlines by a.o. fetching a velocity field. 28 | await layer.initialise() 29 | ``` 30 | 31 | Refer to [`examples/maplibre_basic.ts`](examples/maplibre_basic.ts) for the full 32 | example. 33 | 34 | ## Examples 35 | 36 | A hosted version of some of the examples can be found on the [GitHub Pages](https://deltares.github.io/webgl-streamline-visualizer/). 37 | 38 | ## Standalone usage 39 | 40 | The core of the WebGL streamline visualizer can also be used standalone, for 41 | example for integrating with your map library or velocity field source of 42 | choice. 43 | 44 | Refer to [`examples/vortex.ts`](examples/vortex.ts) for a full example that uses 45 | the visualizer without a map library and generates velocity data with 46 | TypeScript function. 47 | 48 | ## For developers 49 | 50 | Install dependencies and initialize Playwright: 51 | 52 | ```bash 53 | npm install 54 | npx playwright install 55 | ``` 56 | 57 | Run a development server for the demo pages, listening for changes in the 58 | source: 59 | 60 | ```bash 61 | npm run dev 62 | ``` 63 | 64 | Run the linter on the library and examples: 65 | 66 | ```bash 67 | npm run lint 68 | ``` 69 | 70 | Run a production build of the library: 71 | 72 | ```bash 73 | npm run build 74 | ``` 75 | 76 | To build and view the examples run (note you may have to adapt base to '/' in [the examples config](vite-examples.config.ts) ) 77 | 78 | ```bash 79 | npm run build:examples 80 | npm run preview 81 | ``` 82 | 83 | Run all tests, listening for change in the source: 84 | 85 | ```bash 86 | npm run test 87 | ``` 88 | -------------------------------------------------------------------------------- /src/shaders/final.frag.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | uniform int u_style; 5 | 6 | uniform sampler2D u_particle_texture; 7 | uniform sampler2D u_colormap_texture; 8 | uniform sampler2D u_velocity_texture; 9 | 10 | uniform float u_colormap_start; 11 | uniform float u_colormap_end; 12 | 13 | uniform vec2 u_scale; 14 | uniform vec2 u_offset; 15 | 16 | in vec2 v_tex_coord; 17 | in vec2 v_flipped_tex_coord; 18 | 19 | out vec4 color; 20 | 21 | #include is_missing_velocity; 22 | 23 | float get_speed(vec2 pos) { 24 | vec4 velocity_raw = texture(u_velocity_texture, pos); 25 | 26 | // Set missing velocities to zero. 27 | if (is_missing_velocity(velocity_raw)) { 28 | return 0.0; 29 | } 30 | 31 | // Compute velocity in physical units. 32 | vec2 velocity = velocity_raw.rg * u_scale + u_offset; 33 | return length(velocity); 34 | } 35 | 36 | void main() { 37 | // Get the speed at the current point (in physical units), we need flipped 38 | // texture coordinates because the velocity texture was loaded from an 39 | // image and therefore flipped vertically. 40 | float speed = get_speed(v_flipped_tex_coord); 41 | 42 | if (speed > 0.0) { 43 | // Find the coordinate into the colormap texture for this speed. 44 | vec2 colormap_coords = vec2(0.0, 0.0); 45 | colormap_coords.s = clamp( 46 | (speed - u_colormap_start) / (u_colormap_end - u_colormap_start), 47 | 0.0, 1.0 48 | ); 49 | // Interpolate the colormap texture for this value. 50 | lowp vec4 magnitude_color = texture(u_colormap_texture, colormap_coords); 51 | 52 | // Interpolate the particle texture at this point. 53 | lowp vec4 particle_color = texture(u_particle_texture, v_tex_coord); 54 | 55 | // Blend the velocity magnitude texture with the particle texture. 56 | if (u_style == 0) { 57 | // Render light particles on velocity magnitude. 58 | color = mix( 59 | magnitude_color, 60 | vec4(1.0, 1.0, 1.0, 1.0), 61 | particle_color.a 62 | ); 63 | } else if (u_style == 1) { 64 | // Render dark particles on velocity magnitude. 65 | float factor = 1.0 - particle_color.a; 66 | color = magnitude_color; 67 | color.rgb *= factor * 0.8 + 0.2; 68 | } else if (u_style == 2) { 69 | // Render particles coloured by velocity magnitude on transparent 70 | // background. 71 | color = magnitude_color * particle_color.a; 72 | color.a = particle_color.a; 73 | } else if (u_style == 3) { 74 | // Render coloured particles on velocity magnitude. 75 | color = magnitude_color; 76 | color.rgb = mix( 77 | magnitude_color.rgb, 78 | particle_color.rgb, 79 | particle_color.a 80 | ); 81 | } else { 82 | // Invalid style, just render transparent pixels. 83 | color = vec4(0.0, 0.0, 0.0, 0.0); 84 | } 85 | } else { 86 | color = vec4(0.0, 0.0, 0.0, 0.0); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/render/texture.ts: -------------------------------------------------------------------------------- 1 | import { createRectangleVertexArray } from '../utils/geometry' 2 | import { ShaderProgram, bindTexture } from '../utils/shader-program' 3 | 4 | export class TextureRenderer { 5 | private program: ShaderProgram 6 | private positionBuffer: WebGLBuffer | null 7 | private texCoordBuffer: WebGLBuffer | null 8 | private vertexArray: WebGLVertexArrayObject | null 9 | private previousFramebuffer: WebGLFramebuffer 10 | private currentFramebuffer: WebGLFramebuffer 11 | 12 | constructor(program: ShaderProgram) { 13 | this.program = program 14 | 15 | this.positionBuffer = null 16 | this.texCoordBuffer = null 17 | this.vertexArray = null 18 | this.previousFramebuffer = this.program.gl.createFramebuffer() 19 | this.currentFramebuffer = this.program.gl.createFramebuffer() 20 | } 21 | 22 | initialise(): void { 23 | // We do not flip the vertical texture coordinate because we are only 24 | // rendering textures originating from a framebuffer, which does _not_ have 25 | // a flipped y-axis compared to clip space. 26 | const doFlipV = false 27 | const [positionBuffer, texCoordBuffer, vertexArray] = 28 | createRectangleVertexArray( 29 | this.program, 30 | -1.0, 31 | 1.0, 32 | -1.0, 33 | 1.0, 34 | doFlipV, 35 | 'a_position', 36 | 'a_tex_coord' 37 | ) 38 | this.positionBuffer = positionBuffer 39 | this.texCoordBuffer = texCoordBuffer 40 | this.vertexArray = vertexArray 41 | } 42 | 43 | destruct(): void { 44 | const gl = this.program.gl 45 | gl.deleteBuffer(this.positionBuffer) 46 | gl.deleteBuffer(this.texCoordBuffer) 47 | gl.deleteVertexArray(this.vertexArray) 48 | gl.deleteFramebuffer(this.currentFramebuffer) 49 | this.program.destruct() 50 | } 51 | 52 | resetParticleTextures( 53 | previousParticleTexture: WebGLTexture, 54 | currentParticleTexture: WebGLTexture 55 | ): void { 56 | this.setupFramebuffer(this.previousFramebuffer, previousParticleTexture) 57 | this.setupFramebuffer(this.currentFramebuffer, currentParticleTexture) 58 | } 59 | 60 | render(inputTexture: WebGLTexture, fadeAmount: number): void { 61 | const gl = this.program.gl 62 | this.program.use() 63 | 64 | gl.bindVertexArray(this.vertexArray) 65 | bindTexture(this.program, 'u_texture', 0, inputTexture) 66 | gl.uniform1f(this.program.getUniformLocation('u_fade_amount'), fadeAmount) 67 | 68 | gl.bindFramebuffer(gl.FRAMEBUFFER, this.currentFramebuffer) 69 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) 70 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) 71 | 72 | gl.bindVertexArray(null) 73 | } 74 | 75 | swapBuffers(): void { 76 | const temp = this.previousFramebuffer 77 | this.previousFramebuffer = this.currentFramebuffer 78 | this.currentFramebuffer = temp 79 | } 80 | 81 | private setupFramebuffer( 82 | framebuffer: WebGLFramebuffer, 83 | texture: WebGLTexture 84 | ): void { 85 | const gl = this.program.gl 86 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer) 87 | gl.framebufferTexture2D( 88 | gl.FRAMEBUFFER, 89 | gl.COLOR_ATTACHMENT0, 90 | gl.TEXTURE_2D, 91 | texture, 92 | 0 93 | ) 94 | gl.clearColor(0.0, 0.0, 0.0, 0.0) 95 | gl.disable(gl.BLEND) 96 | gl.bindFramebuffer(gl.FRAMEBUFFER, null) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/shaders/render.vert.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | in vec2 a_position; 5 | in vec2 a_tex_coord; 6 | 7 | uniform int u_width; 8 | uniform highp sampler2D u_particle_data_texture; 9 | uniform highp sampler2D u_particle_age_texture; 10 | 11 | uniform float u_particle_size; 12 | uniform float u_aspect_ratio; 13 | 14 | uniform lowp int u_do_rotate_particles; 15 | 16 | uniform vec2 u_bbox_scale; 17 | uniform vec2 u_bbox_offset; 18 | 19 | uniform float u_max_age; 20 | uniform float u_growth_rate; 21 | 22 | out vec2 v_tex_coord; 23 | 24 | float compute_particle_size(float age) { 25 | const float shrink_time = 0.1f; 26 | // Grow up to a maximum of the particle size, until the last 100 ms, in 27 | // which we shrink down to 0 again to make vanishing particles seem more 28 | // natural. 29 | float shrink_start_age = u_max_age - shrink_time; 30 | if(age > shrink_start_age) { 31 | float start_size = min(shrink_start_age * u_growth_rate * u_particle_size, u_particle_size); 32 | float shrink_factor = 1.0f - (age - shrink_start_age) / shrink_time; 33 | return start_size * shrink_factor; 34 | } else { 35 | float unconstrained_size = age * u_growth_rate * u_particle_size; 36 | return min(unconstrained_size, u_particle_size); 37 | } 38 | } 39 | 40 | void main() { 41 | // Obtain particle position from texture. 42 | ivec2 texture_indices = ivec2(gl_InstanceID / u_width, gl_InstanceID % u_width); 43 | const int mipmap_level = 0; 44 | vec4 particle_data = texelFetch(u_particle_data_texture, texture_indices, mipmap_level); 45 | float particle_age = texelFetch(u_particle_age_texture, texture_indices, mipmap_level).r; 46 | 47 | vec2 particle_position = particle_data.xy; 48 | vec2 particle_velocity = particle_data.zw; 49 | 50 | if(particle_velocity.x == 0.0f && particle_velocity.y == 0.0f) { 51 | // If the velocity is exactly zero, we are in a position where no 52 | // velocity has been defined. Return the same position for all vertices, 53 | // resulting in degenerate triangles that will not be rendered. 54 | gl_Position = vec4(0.0f, 0.0f, 0.0f, 1.0f); 55 | return; 56 | } 57 | 58 | vec2 position = a_position; 59 | if(u_do_rotate_particles == 1) { 60 | // Rotate the quad according to the velocity direction. We take the 61 | // velocity direction as the y-axis of a local coordinate system, and 62 | // a vector perpendicular to that as the x-axis. This gives two basis 63 | // vectors: 64 | // 65 | // e1 = (v, -u) e2 = (u, v) 66 | // 67 | // Hence, a transformation matrix from world coordinate to this local 68 | // coordinate system is: 69 | // 70 | // [ v u ] 71 | // [-u v ] 72 | // 73 | vec2 direction = normalize(particle_velocity); 74 | mat2 transformation = mat2(vec2(-direction.y, direction.x), direction); 75 | position = transformation * position; 76 | } 77 | 78 | // Scale quad and correct for aspect ratio. 79 | position *= compute_particle_size(particle_age); 80 | position.y *= u_aspect_ratio; 81 | 82 | gl_Position = vec4(position + particle_position, 0.0f, 1.0f); 83 | 84 | // Scale bounding box. 85 | gl_Position.xy = gl_Position.xy * u_bbox_scale + u_bbox_offset; 86 | 87 | v_tex_coord = a_tex_coord; 88 | } 89 | -------------------------------------------------------------------------------- /tests/shader-program.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { createWebGl2Context } from './utils' 3 | import { FragmentShader, VertexShader } from '@/utils/shader' 4 | import { ShaderProgram } from '@/utils/shader-program' 5 | 6 | import exampleVertexShaderSource from './assets/example.vert.glsl?raw' 7 | import exampleFragmentShaderSource from './assets/example.frag.glsl?raw' 8 | 9 | test('creates and links a new shader program', async () => { 10 | const gl = createWebGl2Context() 11 | 12 | const vertexShader = new VertexShader(gl, 'void main() {}') 13 | const fragmentShader = new FragmentShader(gl, 'void main() {}') 14 | 15 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 16 | expect(program).toBeDefined() 17 | 18 | await program.link() 19 | }) 20 | 21 | test('creates and links a new shader program with vertex shader error', async () => { 22 | const gl = createWebGl2Context() 23 | 24 | // Syntax error in vertex shader. 25 | const vertexShader = new VertexShader(gl, 'void main) {}') 26 | const fragmentShader = new FragmentShader(gl, 'void main() {}') 27 | 28 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 29 | 30 | await expect(async () => await program.link()).rejects.toThrow() 31 | }) 32 | 33 | test('creates and links a new shader program with fragment shader error', async () => { 34 | const gl = createWebGl2Context() 35 | 36 | const vertexShader = new VertexShader(gl, 'void main) {}') 37 | // Syntax error in fragment shader. 38 | const fragmentShader = new FragmentShader(gl, 'void main) {}') 39 | 40 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 41 | 42 | await expect(async () => await program.link()).rejects.toThrow() 43 | }) 44 | 45 | test('gets attribute locations', async () => { 46 | const gl = createWebGl2Context() 47 | 48 | const vertexShader = new VertexShader(gl, exampleVertexShaderSource) 49 | const fragmentShader = new FragmentShader(gl, exampleFragmentShaderSource) 50 | 51 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 52 | await program.link() 53 | 54 | const position = program.getAttributeLocation('a_position') 55 | const textureCoord = program.getAttributeLocation('a_tex_coord') 56 | 57 | expect(position).toBeDefined() 58 | expect(textureCoord).toBeDefined() 59 | 60 | expect(() => program.getAttributeLocation('non_existent')).toThrow() 61 | }) 62 | 63 | test('gets uniform locations', async () => { 64 | const gl = createWebGl2Context() 65 | 66 | const vertexShader = new VertexShader(gl, exampleVertexShaderSource) 67 | const fragmentShader = new FragmentShader(gl, exampleFragmentShaderSource) 68 | 69 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 70 | await program.link() 71 | 72 | // Vertex shader uniform. 73 | const factor = program.getUniformLocation('u_factor') 74 | // Fragment shader uniforms. 75 | const texture = program.getUniformLocation('u_texture') 76 | const fadeAmount = program.getUniformLocation('u_fade_amount') 77 | 78 | expect(factor).toBeDefined() 79 | expect(texture).toBeDefined() 80 | expect(fadeAmount).toBeDefined() 81 | 82 | expect(() => program.getUniformLocation('non_existent')).toThrow() 83 | }) 84 | 85 | test('uses a shader program', async () => { 86 | const gl = createWebGl2Context() 87 | 88 | const vertexShader = new VertexShader(gl, 'void main() {}') 89 | const fragmentShader = new FragmentShader(gl, 'void main() {}') 90 | 91 | const program = new ShaderProgram(gl, vertexShader, fragmentShader) 92 | 93 | // Trying to use an unlinked programn should throw. 94 | expect(() => program.use()).toThrow() 95 | 96 | await program.link() 97 | 98 | // It has been linked now, so this should not throw. 99 | program.use() 100 | }) 101 | -------------------------------------------------------------------------------- /src/shaders/particle.vert.glsl: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | precision highp float; 3 | 4 | uniform sampler2D u_velocity_texture; 5 | 6 | uniform float u_speed_factor; 7 | uniform float u_speed_exponent; 8 | 9 | uniform float u_aspect_ratio; 10 | uniform vec2 u_scale_in; 11 | uniform vec2 u_offset_in; 12 | 13 | uniform float u_dt; 14 | 15 | uniform float u_max_age; 16 | 17 | in vec4 a_particle_data; 18 | in float a_particle_age; 19 | 20 | out vec4 v_new_particle_data; 21 | out float v_new_particle_age; 22 | 23 | #include is_missing_velocity; 24 | 25 | // From: https://stackoverflow.com/questions/4200224/random-noise-functions-for-glsl 26 | float gold_noise(vec2 pos, float seed){ 27 | const float phi = 1.61803398874989484820459; 28 | return fract(tan(distance(pos * phi, pos) * seed) * pos.x); 29 | } 30 | 31 | vec2 random_position() { 32 | vec2 pos = a_particle_data.xy; 33 | float x = gold_noise(pos, -123.456) * 2.0 - 1.0; 34 | float y = gold_noise(pos, 789.012) * 2.0 - 1.0; 35 | return vec2(x, y); 36 | } 37 | 38 | vec2 get_clip_space_velocity(vec2 pos) { 39 | // Position is in clip space, but should be transformed to texture 40 | // coordinates. Because the velocity texture was loaded from an image, it is 41 | // vertically flipped, so the v texture coordinate is inverted. 42 | vec2 pos_texture = vec2( 43 | 0.5 + 0.5 * pos.x, 44 | 0.5 - 0.5 * pos.y 45 | ); 46 | vec4 velocity_raw = texture(u_velocity_texture, pos_texture); 47 | 48 | // Set missing velocities to zero. 49 | if (is_missing_velocity(velocity_raw)) { 50 | return vec2(0.0, 0.0); 51 | } 52 | 53 | // Compute velocity in physical coordinates. 54 | vec2 velocity = velocity_raw.rg * u_scale_in + u_offset_in; 55 | 56 | if (u_speed_exponent == 0.0) { 57 | // For a speed exponent of exactly 0, only use the velocity direction 58 | // and ignore its magnitude. 59 | velocity = normalize(velocity) * u_speed_factor; 60 | } else { 61 | // Apply speed exponent to "compress" the speed---for exponents smaller 62 | // than 1, higher speeds will be closer together. 63 | float speed_compressed = pow(length(velocity), u_speed_exponent); 64 | 65 | // Scale the speed by the speed factor so it is appropriately scaled for 66 | // particles moving in clip space. 67 | speed_compressed *= u_speed_factor; 68 | // Finally, compute the velocity based on the compressed speed. 69 | velocity = normalize(velocity) * speed_compressed; 70 | } 71 | 72 | // Correct the x-velocity for the aspect ratio of the canvas. 73 | velocity.x *= u_aspect_ratio; 74 | 75 | return velocity; 76 | } 77 | 78 | void main() { 79 | vec2 pos = a_particle_data.xy; 80 | vec2 velocity = a_particle_data.zw; 81 | 82 | vec2 new_position; 83 | float new_age; 84 | if (a_particle_age > u_max_age) { 85 | // Particles that are too old will be reset to a random position, and be 86 | // reset to an age of 0. 87 | new_position = random_position(); 88 | new_age = 0.0; 89 | } else if (velocity.x == 0.0 && velocity.y == 0.0) { 90 | // Particles in regions without velocity will be reset to a random 91 | // position. They are given a random age, because when suddenly zooming 92 | // in strongly, many particles may be regenerated at once. If we give 93 | // all these particle the same age, they will also all die at the same 94 | // time. 95 | new_position = random_position(); 96 | new_age = gold_noise(pos, 987.65) * u_max_age; 97 | } else if (pos.x < -1.0 || pos.x > 1.0 || pos.y < -1.0 || pos.y > 1.0) { 98 | // Also generate new positions and reset age to 0 if our particle leaves 99 | // clip space. 100 | new_position = random_position(); 101 | new_age = 0.0; 102 | } else { 103 | new_position = pos + velocity * u_dt; 104 | new_age = a_particle_age + u_dt; 105 | } 106 | 107 | vec2 new_velocity = get_clip_space_velocity(new_position); 108 | 109 | v_new_particle_data = vec4(new_position, new_velocity); 110 | v_new_particle_age = new_age; 111 | } 112 | -------------------------------------------------------------------------------- /examples/vortex-base.ts: -------------------------------------------------------------------------------- 1 | import { Colormap } from '@/utils/colormap' 2 | import { VelocityImage } from '@/utils/wms' 3 | import { 4 | StreamlineVisualiser, 5 | type StreamlineVisualiserOptions 6 | } from '@/visualiser' 7 | 8 | import './options' 9 | import type { VisualiserOptionsControl } from './options' 10 | 11 | function createColormap(): Colormap { 12 | const values = [0, 0.25, 0.5, 0.75, 1] 13 | const colors = [ 14 | { r: 254, g: 235, b: 226 }, 15 | { r: 251, g: 180, b: 185 }, 16 | { r: 247, g: 104, b: 161 }, 17 | { r: 197, g: 27, b: 138 }, 18 | { r: 122, g: 1, b: 119 } 19 | ] 20 | return new Colormap(values, colors) 21 | } 22 | 23 | function createVelocityImage(width: number, height: number): VelocityImage { 24 | // Velocities are in the range [-1, 1], output should be in the range [0, 1]. 25 | // 26 | // u = c * scale + offset 27 | // c = (u - offset) / scale 28 | // 29 | // (-1 - offset) / scale = 0 => -1 - offset = 0 => offset = -1 30 | // ( 1 - offset) / scale = 1 => 2 / scale = 1 => scale = 2 31 | const uScale = 2 32 | const vScale = 2 33 | const uOffset = -1 34 | const vOffset = -1 35 | 36 | const data: number[] = [] 37 | for (let row = 0; row < height; row++) { 38 | // Images are always specified with flipped y-coordinate. 39 | const y = (1.0 - row / height) * Math.PI 40 | for (let col = 0; col < width; col++) { 41 | const x = (col / width) * Math.PI 42 | 43 | // (Undamped) Taylor-Green vortex, velocities in the range [-1, 1]. 44 | const u = Math.sin(x) * Math.cos(y) 45 | const v = -Math.cos(x) * Math.sin(y) 46 | 47 | // Translate to the range [0, 255] and round; this is the inverse 48 | // operation from how we translate pixel values to velocities. 49 | const uScaled = ((u - uOffset) / uScale) * 255 50 | const vScaled = ((v - vOffset) / vScale) * 255 51 | 52 | // Set R and G channels and leave B channel zero, then append to the data 53 | // array. Strides from smallest to largest: RGB, columns, rows. 54 | data.push(uScaled, vScaled, 0) 55 | } 56 | } 57 | 58 | const dataBuffer = new Uint8ClampedArray(data) 59 | return new VelocityImage( 60 | dataBuffer, 61 | width, 62 | height, 63 | uOffset, 64 | vOffset, 65 | uScale, 66 | vScale 67 | ) 68 | } 69 | 70 | export async function initialiseVisualiser( 71 | numParticles: number, 72 | options: StreamlineVisualiserOptions 73 | ): Promise { 74 | // Get the canvas and make sure its contents are rendered at the same 75 | // resolution as its size. 76 | const canvas = document.getElementById('canvas') as HTMLCanvasElement 77 | const width = canvas.clientWidth 78 | const height = canvas.clientHeight 79 | canvas.width = width 80 | canvas.height = height 81 | 82 | // Initialise WebGL2 context. 83 | const gl = canvas.getContext('webgl2') 84 | if (!gl) { 85 | throw new Error('Could not create WebGL2 rendering context.') 86 | } 87 | 88 | // Create visualiser. 89 | const visualiser = new StreamlineVisualiser( 90 | gl, 91 | width, 92 | height, 93 | numParticles, 94 | options 95 | ) 96 | 97 | // Create and set demo colormap and velocity image. 98 | const colormap = createColormap() 99 | const velocityImage = createVelocityImage(width, height) 100 | 101 | await visualiser.initialise(colormap) 102 | visualiser.setVelocityImage(velocityImage, true) 103 | 104 | // Enable rendering mode in the visualiser. 105 | visualiser.start() 106 | 107 | // Render a new frame every frame, taking into account the time between 108 | // subsequent frames. 109 | let previousFrameTime: number | null = null 110 | const renderFrame = (now: number) => { 111 | const dt = previousFrameTime ? (now - previousFrameTime) / 1000 : 1 / 60 112 | previousFrameTime = now 113 | 114 | visualiser.renderFrame(dt) 115 | 116 | window.requestAnimationFrame(renderFrame) 117 | } 118 | window.requestAnimationFrame(renderFrame) 119 | 120 | return visualiser 121 | } 122 | 123 | export function initialiseControl(visualiser: StreamlineVisualiser): void { 124 | const control = document.getElementById( 125 | 'options-control' 126 | ) as VisualiserOptionsControl 127 | 128 | control.attachVisualiser(visualiser) 129 | } 130 | -------------------------------------------------------------------------------- /src/utils/colormap.ts: -------------------------------------------------------------------------------- 1 | import { createTexture } from './textures' 2 | 3 | /** 4 | * An RGB color. 5 | */ 6 | export class Color { 7 | r: number 8 | g: number 9 | b: number 10 | 11 | constructor(r: number, g: number, b: number) { 12 | this.r = r 13 | this.g = g 14 | this.b = b 15 | } 16 | 17 | /** 18 | * Parses a color from a hexadecimal color string. 19 | * @param hex hexadecimal color string. 20 | * @returns color parsed from the hexadecimal color string. 21 | */ 22 | static fromHex(hex: string): Color { 23 | const r = parseInt(hex.substring(1, 3), 16) 24 | const g = parseInt(hex.substring(3, 5), 16) 25 | const b = parseInt(hex.substring(5, 7), 16) 26 | return new Color(r, g, b) 27 | } 28 | } 29 | 30 | /** 31 | * A colormap. 32 | * 33 | * Its values may be non-uniformly spaced. 34 | */ 35 | export class Colormap { 36 | private values: number[] 37 | private colors: Color[] 38 | 39 | constructor(values: number[], colors: Color[]) { 40 | if (values.length !== colors.length) { 41 | throw new Error( 42 | 'Number of colormap values should be the same as the number of colors.' 43 | ) 44 | } 45 | this.values = values 46 | this.colors = colors 47 | } 48 | 49 | /** Number of points in the colormap. */ 50 | get num(): number { 51 | return this.values.length 52 | } 53 | 54 | /** Start value of the colormap. */ 55 | get start(): number { 56 | if (this.values.length === 0) { 57 | throw new Error('Colormap contains no values.') 58 | } 59 | return this.values[0]! 60 | } 61 | 62 | /** End value of the colormap. */ 63 | get end(): number { 64 | if (this.values.length === 0) { 65 | throw new Error('Colormap contains no values.') 66 | } 67 | return this.values[this.values.length - 1]! 68 | } 69 | 70 | /** Range of the colormap (i.e. difference between start and end). */ 71 | get range(): number { 72 | return this.end - this.start 73 | } 74 | 75 | /** 76 | * Creates a 1D texture from this colormap. 77 | * 78 | * The colormap as obtained from the GetLegendGraphic FEWS WMS endpoint may be non-uniformly 79 | * spaced. This function linearly interpolates this non-uniformly spaced colormap to a uniformly 80 | * spaced texture, from the colormap's start to its end. 81 | * 82 | * @param numPoints number of points in the texture. 83 | * @returns Colour map as a WebGL texture (note: not RGBA). 84 | */ 85 | toTexture(gl: WebGL2RenderingContext, numPoints: number): WebGLTexture { 86 | const colormapTexture = createTexture( 87 | gl, 88 | gl.LINEAR, 89 | this.to1DRGBTextureData(numPoints), 90 | numPoints, 91 | 1 92 | ) 93 | return colormapTexture 94 | } 95 | 96 | private to1DRGBTextureData(numPoints: number): Uint8Array { 97 | if (this.colors.length === 0 || this.values.length === 0) { 98 | return new Uint8Array() 99 | } 100 | 101 | // Uniform step size between start and end for the texture data. 102 | const step = this.range / (numPoints - 1) 103 | 104 | const data = new Uint8Array(3 * numPoints) 105 | for (let i = 0; i < numPoints; i++) { 106 | let color: Color 107 | if (i == 0) { 108 | color = this.colors[0]! 109 | } else if (i == numPoints - 1) { 110 | color = this.colors[this.num - 1]! 111 | } else { 112 | const value = this.start + i * step 113 | const indexNext = this.values.findIndex(entry => entry > value) 114 | const indexPrev = indexNext - 1 115 | 116 | const distPrev = Math.abs(value - this.values[indexPrev]!) 117 | const distNext = Math.abs(value - this.values[indexNext]!) 118 | // Linearly interpolate the colours for this point. 119 | const weightPrev = distNext / (distPrev + distNext) 120 | const weightNext = distPrev / (distPrev + distNext) 121 | 122 | const colorPrev = this.colors[indexPrev]! 123 | const colorNext = this.colors[indexNext]! 124 | color = new Color( 125 | colorPrev.r * weightPrev + colorNext.r * weightNext, 126 | colorPrev.g * weightPrev + colorNext.g * weightNext, 127 | colorPrev.b * weightPrev + colorNext.b * weightNext 128 | ) 129 | } 130 | 131 | // Set the RGB values for this point, smallest stride is RGB. 132 | const index = 3 * i 133 | data[index] = color.r 134 | data[index + 1] = color.g 135 | data[index + 2] = color.b 136 | } 137 | 138 | return data 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/render/final.ts: -------------------------------------------------------------------------------- 1 | import { Colormap } from '../utils/colormap' 2 | import { createRectangleVertexArray } from '../utils/geometry' 3 | import { ShaderProgram, bindTexture } from '../utils/shader-program' 4 | import { VelocityImage } from '../utils/wms' 5 | 6 | export interface BoundingBoxScaling { 7 | scaleX: number 8 | scaleY: number 9 | offsetX: number 10 | offsetY: number 11 | } 12 | 13 | export enum StreamlineStyle { 14 | LightParticlesOnMagnitude = 0, 15 | DarkParticlesOnMagnitude = 1, 16 | MagnitudeColoredParticles = 2, 17 | ColoredParticles = 3 18 | } 19 | 20 | export class FinalRenderer { 21 | private static readonly NUM_SEGMENTS_COLORMAP = 64 22 | 23 | public style: StreamlineStyle 24 | 25 | private program: ShaderProgram 26 | private positionBuffer: WebGLBuffer | null 27 | private texCoordBuffer: WebGLBuffer | null 28 | private vertexArray: WebGLVertexArrayObject | null 29 | private velocityImage: VelocityImage | null 30 | private colormap: Colormap 31 | private colormapTexture: WebGLTexture | null 32 | private velocityTexture: WebGLTexture | null 33 | 34 | constructor( 35 | program: ShaderProgram, 36 | style: StreamlineStyle, 37 | colormap: Colormap 38 | ) { 39 | this.program = program 40 | this.style = style 41 | this.positionBuffer = null 42 | this.texCoordBuffer = null 43 | this.vertexArray = null 44 | this.velocityImage = null 45 | this.colormap = colormap 46 | this.colormapTexture = null 47 | this.velocityTexture = null 48 | } 49 | 50 | initialise(): void { 51 | const gl = this.program.gl 52 | // In the final renderer, we need both a flipped and an unflipped vertical 53 | // texture coordinate. We need the flipped coordinate to obtain the velocity 54 | // field (which is loaded as an image), and the unflipped to coordinate to 55 | // render the particle texture (which is unflipped because it was rendered 56 | // in a framebuffer). 57 | const doFlipV = false 58 | const [positionBuffer, texCoordBuffer, vertexArray] = 59 | createRectangleVertexArray( 60 | this.program, 61 | -1.0, 62 | 1.0, 63 | -1.0, 64 | 1.0, 65 | doFlipV, 66 | 'a_position', 67 | 'a_tex_coord' 68 | ) 69 | this.positionBuffer = positionBuffer 70 | this.texCoordBuffer = texCoordBuffer 71 | this.vertexArray = vertexArray 72 | 73 | this.colormapTexture = this.colormap.toTexture( 74 | gl, 75 | FinalRenderer.NUM_SEGMENTS_COLORMAP 76 | ) 77 | } 78 | 79 | destruct(): void { 80 | const gl = this.program.gl 81 | gl.deleteBuffer(this.positionBuffer) 82 | gl.deleteBuffer(this.texCoordBuffer) 83 | gl.deleteVertexArray(this.vertexArray) 84 | gl.deleteTexture(this.colormapTexture) 85 | gl.deleteTexture(this.velocityTexture) 86 | this.program.destruct() 87 | } 88 | 89 | render(particleTexture: WebGLTexture, scaling: BoundingBoxScaling): void { 90 | const gl = this.program.gl 91 | this.program.use() 92 | 93 | gl.bindVertexArray(this.vertexArray) 94 | 95 | this.bindUniforms(scaling) 96 | this.bindTextures(particleTexture) 97 | 98 | // Make sure no framebuffer is bound so we render to the canvas. 99 | gl.bindFramebuffer(gl.FRAMEBUFFER, null) 100 | 101 | // Make sure that we blend with any previous renders on the frame buffer 102 | // appropriately. 103 | gl.enable(gl.BLEND) 104 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) 105 | 106 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) 107 | } 108 | 109 | setVelocityImage(velocityImage: VelocityImage) { 110 | this.velocityImage = velocityImage 111 | this.velocityTexture = velocityImage.toTexture(this.program.gl, false) 112 | } 113 | 114 | setColorMap(colormap: Colormap) { 115 | this.colormap = colormap 116 | this.colormapTexture = this.colormap.toTexture( 117 | this.program.gl, 118 | FinalRenderer.NUM_SEGMENTS_COLORMAP 119 | ) 120 | } 121 | 122 | private bindUniforms(scaling: BoundingBoxScaling): void { 123 | if (!this.velocityImage) { 124 | throw new Error( 125 | 'Velocity image is not defined, no velocity image was set?' 126 | ) 127 | } 128 | const gl = this.program.gl 129 | 130 | // Scaling parameters for the bounding box. 131 | gl.uniform2f( 132 | this.program.getUniformLocation('u_bbox_scale'), 133 | scaling.scaleX, 134 | scaling.scaleY 135 | ) 136 | gl.uniform2f( 137 | this.program.getUniformLocation('u_bbox_offset'), 138 | scaling.offsetX, 139 | scaling.offsetY 140 | ) 141 | 142 | // Uniform to set the render style, its values correspond to the values 143 | // of the StreamlineStyle enum. 144 | gl.uniform1i(this.program.getUniformLocation('u_style'), this.style) 145 | 146 | // Uniforms for the start and end of the colormap. 147 | gl.uniform1f( 148 | this.program.getUniformLocation('u_colormap_start'), 149 | this.colormap.start 150 | ) 151 | gl.uniform1f( 152 | this.program.getUniformLocation('u_colormap_end'), 153 | this.colormap.end 154 | ) 155 | 156 | // Uniforms for correctly scaling the velocity. 157 | gl.uniform2f( 158 | this.program.getUniformLocation('u_scale'), 159 | this.velocityImage.uScale, 160 | this.velocityImage.vScale 161 | ) 162 | gl.uniform2f( 163 | this.program.getUniformLocation('u_offset'), 164 | this.velocityImage.uOffset, 165 | this.velocityImage.vOffset 166 | ) 167 | } 168 | 169 | private bindTextures(particleTexture: WebGLTexture): void { 170 | if (this.colormapTexture === null || this.velocityTexture === null) { 171 | throw new Error('Textures have not been initialised.') 172 | } 173 | bindTexture(this.program, 'u_particle_texture', 0, particleTexture) 174 | bindTexture(this.program, 'u_colormap_texture', 1, this.colormapTexture) 175 | bindTexture(this.program, 'u_velocity_texture', 2, this.velocityTexture) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/utils/wms.ts: -------------------------------------------------------------------------------- 1 | import type { GetCapabilitiesResponse } from '@deltares/fews-wms-requests' 2 | import * as GeoTIFF from 'geotiff' 3 | 4 | import { Color, Colormap } from './colormap' 5 | import { createTexture } from './textures' 6 | 7 | export type TransformRequestFunction = (request: Request) => Promise 8 | 9 | export class VelocityImage { 10 | constructor( 11 | private data: Uint8Array | Uint8ClampedArray, 12 | readonly width: number, 13 | readonly height: number, 14 | readonly uOffset: number, 15 | readonly vOffset: number, 16 | readonly uScale: number, 17 | readonly vScale: number 18 | ) {} 19 | 20 | maxVelocity(): [number, number] { 21 | const computeU = (r: number) => r * this.uScale + this.uOffset 22 | const computeV = (g: number) => g * this.vScale + this.vOffset 23 | 24 | return [ 25 | Math.max(computeU(0), computeU(1)), 26 | Math.max(computeV(0), computeV(1)) 27 | ] 28 | } 29 | 30 | toTexture(gl: WebGL2RenderingContext, interpolate: boolean): WebGLTexture { 31 | return createTexture( 32 | gl, 33 | interpolate ? gl.LINEAR : gl.NEAREST, 34 | this.data, 35 | this.width, 36 | this.height 37 | ) 38 | } 39 | } 40 | 41 | /** 42 | * Fetches a colormap for a WMS layer from the FEWS web services. 43 | * 44 | * @param baseUrl base URL of the FEWS WMS service. 45 | * @param layer layer to obtain the legend for. 46 | * @returns Colormap fetched from the FEWS WMS service. 47 | */ 48 | export async function fetchWMSColormap( 49 | baseUrl: string, 50 | layer: string, 51 | colorScaleRange?: [number, number], 52 | signal?: AbortSignal, 53 | transformRequest?: TransformRequestFunction 54 | ): Promise { 55 | const url = new URL(baseUrl) 56 | url.searchParams.append('request', 'GetLegendGraphic') 57 | url.searchParams.append('format', 'application/json') 58 | url.searchParams.append('version', '1.3') 59 | url.searchParams.append('layers', layer) 60 | if (colorScaleRange) { 61 | url.searchParams.append('colorScaleRange', `${colorScaleRange.join(',')}`) 62 | } 63 | 64 | const request = new Request(url) 65 | const transformedRequest = (await transformRequest?.(request)) ?? request 66 | const response = await fetch(new Request(transformedRequest, { signal })) 67 | const data = (await response.json()) as { 68 | legend: { lowerValue: number; color: string }[] 69 | } 70 | 71 | return new Colormap( 72 | data.legend.map(entry => entry.lowerValue), 73 | data.legend.map(entry => Color.fromHex(entry.color)) 74 | ) 75 | } 76 | 77 | export async function fetchWMSAvailableTimesAndElevations( 78 | baseUrl: string, 79 | layerName: string, 80 | signal?: AbortSignal, 81 | transformRequest?: TransformRequestFunction 82 | ): Promise<{ times: string[]; elevationBounds: [number, number] | null }> { 83 | const url = new URL(baseUrl) 84 | url.searchParams.append('request', 'GetCapabilities') 85 | url.searchParams.append('format', 'application/json') 86 | url.searchParams.append('version', '1.3') 87 | url.searchParams.append('layers', layerName) 88 | 89 | const request = new Request(url) 90 | const transformedRequest = (await transformRequest?.(request)) ?? request 91 | const response = await fetch(new Request(transformedRequest, { signal })) 92 | const capabilities = (await response.json()) as GetCapabilitiesResponse 93 | 94 | const layer = capabilities.layers?.[0] 95 | if (!layer) { 96 | throw new Error('WMS GetCapabilities response contains no layers.') 97 | } 98 | if (!layer.times) { 99 | throw new Error('WMS GetCapabilities response contains no times.') 100 | } 101 | 102 | const lowerElevation = layer.elevation?.lowerValue 103 | const upperElevation = layer.elevation?.upperValue 104 | const elevationBounds = ( 105 | lowerElevation !== undefined && upperElevation !== undefined 106 | ? [+lowerElevation, +upperElevation] 107 | : null 108 | ) as [number, number] | null 109 | 110 | return { 111 | times: layer.times, 112 | elevationBounds: elevationBounds 113 | } 114 | } 115 | 116 | export interface FewsGeoTiffMetadata { 117 | BitsPerSample?: number[] 118 | ImageWidth?: number 119 | ImageLength?: number 120 | ModelTiepoint?: [number, number] 121 | ModelPixelScale?: [number, number] 122 | } 123 | 124 | export async function fetchWMSVelocityField( 125 | baseUrl: string, 126 | layer: string, 127 | time: string, 128 | boundingBox: [number, number, number, number], 129 | width: number, 130 | height: number, 131 | style?: string, 132 | useDisplayUnits?: boolean, 133 | useLastValue?: boolean, 134 | elevation?: number, 135 | signal?: AbortSignal, 136 | transformRequest?: TransformRequestFunction 137 | ): Promise { 138 | const url = new URL(baseUrl) 139 | url.searchParams.append('request', 'GetMap') 140 | url.searchParams.append('version', '1.3') 141 | url.searchParams.append('layers', layer) 142 | url.searchParams.append('crs', 'EPSG:3857') 143 | url.searchParams.append('time', time) 144 | url.searchParams.append('width', width.toString()) 145 | url.searchParams.append('height', height.toString()) 146 | url.searchParams.append('bbox', `${boundingBox.join(',')}`) 147 | url.searchParams.append('format', 'image/tiff') 148 | url.searchParams.append('convertVectortoRG', 'true') 149 | if (style) { 150 | url.searchParams.append('styles', style) 151 | } 152 | if (useLastValue !== undefined) { 153 | url.searchParams.append('useLastValue', useLastValue ? 'true' : 'false') 154 | } 155 | if (useDisplayUnits !== undefined) { 156 | url.searchParams.append( 157 | 'useDisplayUnits', 158 | useDisplayUnits ? 'true' : 'false' 159 | ) 160 | } 161 | if (elevation) { 162 | url.searchParams.append('elevation', `${elevation}`) 163 | } 164 | 165 | return fetchGeoTiffVelocityField(url, signal, transformRequest) 166 | } 167 | 168 | export async function fetchGeoTiffVelocityField( 169 | url: URL, 170 | signal?: AbortSignal, 171 | transformRequest?: TransformRequestFunction 172 | ): Promise { 173 | const request = new Request(url) 174 | const transformedRequest = (await transformRequest?.(request)) ?? request 175 | const response = await fetch(new Request(transformedRequest, { signal })) 176 | const arrayBuffer = await response.arrayBuffer() 177 | 178 | const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer, signal) 179 | const image = await tiff.getImage() 180 | const fileDirectory = image.getFileDirectory() as FewsGeoTiffMetadata 181 | 182 | const expectedProperties: (keyof FewsGeoTiffMetadata)[] = [ 183 | 'BitsPerSample', 184 | 'ImageWidth', 185 | 'ImageLength', 186 | 'ModelTiepoint', 187 | 'ModelPixelScale' 188 | ] 189 | const hasExpectedMetadata = expectedProperties.every( 190 | property => property in fileDirectory 191 | ) 192 | if (!hasExpectedMetadata) { 193 | const propertiesString = expectedProperties 194 | .map(property => `"${property}"`) 195 | .join(', ') 196 | throw new Error( 197 | `GeoTIFF metadata does not contain all expected properties; need the following properties: ${propertiesString}` 198 | ) 199 | } 200 | 201 | // Assume we have 8-bit data per channel. 202 | const isAllChannels8Bit = fileDirectory.BitsPerSample!.every( 203 | (numBits: number) => numBits === 8 204 | ) 205 | if (!isAllChannels8Bit) { 206 | throw new Error( 207 | 'Fetched GeoTIFF does not have the expected 8 bits bitdepth per channel.' 208 | ) 209 | } 210 | 211 | // Get image data, it should always have unsigned 8-bit integers for each 212 | // channel. For some mysterious reason, the GeoTIFF types say that this 213 | // function produces a Int8Array, while in reality it produces a Uint8Array. 214 | const dataUntyped = (await image.readRasters({ interleave: true })) as unknown 215 | const data = dataUntyped as Uint8Array 216 | 217 | // Get offsets and scales for the image. We multiply the scales by 255, since 218 | // 255 of an unsigned 8-bit integer corresponds to a texture value of 1.0 in 219 | // WebGL. 220 | const receivedWidth = fileDirectory.ImageWidth! 221 | const receivedHeight = fileDirectory.ImageLength! 222 | const uOffset = fileDirectory.ModelTiepoint![0] 223 | const uScale = fileDirectory.ModelPixelScale![0] * 255 224 | const vOffset = fileDirectory.ModelTiepoint![1] 225 | const vScale = fileDirectory.ModelPixelScale![1] * 255 226 | 227 | return new VelocityImage( 228 | data, 229 | receivedWidth, 230 | receivedHeight, 231 | uOffset, 232 | vOffset, 233 | uScale, 234 | vScale 235 | ) 236 | } 237 | -------------------------------------------------------------------------------- /src/utils/shader-program.ts: -------------------------------------------------------------------------------- 1 | import type { FragmentShader, VertexShader } from './shader' 2 | 3 | /** 4 | * A WebGL2 shader program. 5 | * 6 | * This also contains the locations of all its active attributes and uniforms. 7 | */ 8 | export class ShaderProgram { 9 | readonly gl: WebGL2RenderingContext 10 | readonly program: WebGLProgram 11 | 12 | private vertexShader: VertexShader 13 | private fragmentShader: FragmentShader 14 | private isLinked: boolean 15 | private attributes: Map 16 | private uniforms: Map 17 | 18 | constructor( 19 | gl: WebGL2RenderingContext, 20 | vertexShader: VertexShader, 21 | fragmentShader: FragmentShader, 22 | transformFeedbackVaryings?: string[] 23 | ) { 24 | // Create WebGL shader program and attach shaders. 25 | const program = gl.createProgram() 26 | if (program === null) { 27 | throw new Error('Failed to create shader program.') 28 | } 29 | gl.attachShader(program, vertexShader.shader) 30 | gl.attachShader(program, fragmentShader.shader) 31 | 32 | // Optionally bind transform feedback varyings. 33 | if (transformFeedbackVaryings) { 34 | gl.transformFeedbackVaryings( 35 | program, 36 | transformFeedbackVaryings, 37 | gl.SEPARATE_ATTRIBS 38 | ) 39 | } 40 | 41 | this.gl = gl 42 | this.vertexShader = vertexShader 43 | this.fragmentShader = fragmentShader 44 | this.program = program 45 | this.isLinked = false 46 | this.attributes = new Map() 47 | this.uniforms = new Map() 48 | } 49 | 50 | destruct(): void { 51 | this.gl.deleteProgram(this.program) 52 | } 53 | 54 | async link(): Promise { 55 | // Do not try to link more than once. 56 | if (this.isLinked) return 57 | 58 | // Make sure our shaders are compiled. 59 | this.vertexShader.compile() 60 | this.fragmentShader.compile() 61 | 62 | // Request program to link in a background thread, then wait for it to 63 | // complete asynchronously. 64 | this.gl.linkProgram(this.program) 65 | await this.waitForLinking() 66 | 67 | this.checkLinkStatus() 68 | 69 | this.updateActiveAttributes() 70 | this.updateActiveUniforms() 71 | 72 | this.isLinked = true 73 | } 74 | 75 | use(): void { 76 | if (!this.isLinked) { 77 | throw new Error('Link shader program before using it.') 78 | } 79 | this.gl.useProgram(this.program) 80 | } 81 | 82 | getAttributeLocation(name: string): number { 83 | if (!this.isLinked) { 84 | throw new Error('Link shader program before getting attribute locations.') 85 | } 86 | const location = this.attributes.get(name) 87 | if (location === undefined) 88 | throw new Error(`No attribute "${name}" exists.`) 89 | return location 90 | } 91 | 92 | getUniformLocation(name: string): WebGLUniformLocation { 93 | if (!this.isLinked) { 94 | throw new Error('Link shader program before getting uniform locations.') 95 | } 96 | const location = this.uniforms.get(name) 97 | if (location === undefined) throw new Error(`No uniform "${name}" exists.`) 98 | return location 99 | } 100 | 101 | private async waitForLinking(): Promise { 102 | // If available, use the KHR_parallel_shader_compile extension to have a 103 | // non-blocking call for checking the link status. 104 | const ext = this.gl.getExtension('KHR_parallel_shader_compile') 105 | 106 | // If not available, just wait for linking synchronously by checking the 107 | // link status. 108 | if (!ext) { 109 | // Getting the link status will block until linking has been completed. 110 | this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS) 111 | return 112 | } 113 | 114 | // Poll linking status every so often, allow the main thread to continue in 115 | // the mean time. 116 | const pollInterval = 20 117 | return new Promise(resolve => { 118 | const isComplete = () => { 119 | const isLinked = this.gl.getProgramParameter( 120 | this.program, 121 | ext.COMPLETION_STATUS_KHR 122 | ) as boolean 123 | if (isLinked) { 124 | resolve() 125 | } else { 126 | setTimeout(isComplete, pollInterval) 127 | } 128 | } 129 | isComplete() 130 | }) 131 | } 132 | 133 | private checkLinkStatus(): void { 134 | const gl = this.gl 135 | // Always check link status, even when not in debug mode. 136 | const isSuccess = this.gl.getProgramParameter( 137 | this.program, 138 | gl.LINK_STATUS 139 | ) as boolean 140 | if (!isSuccess) { 141 | // Check whether the shaders have been successfully compiled. We do not 142 | // check for errors when compiling shaders, because we want to avoid 143 | // blocking the main thread. 144 | const checkShaderStatus = ( 145 | shader: VertexShader | FragmentShader, 146 | name: string 147 | ) => { 148 | const inner = shader.shader 149 | const isSuccess = gl.getShaderParameter( 150 | inner, 151 | gl.COMPILE_STATUS 152 | ) as boolean 153 | if (!isSuccess) { 154 | const message = gl.getShaderInfoLog(inner) 155 | throw new Error(`Failed to compile ${name} shader: ${message}`) 156 | } 157 | } 158 | checkShaderStatus(this.vertexShader, 'vertex') 159 | checkShaderStatus(this.fragmentShader, 'fragment') 160 | 161 | const linkMessage = gl.getProgramInfoLog(this.program) 162 | throw new Error(`Failed to link program: ${linkMessage}`) 163 | } 164 | } 165 | 166 | private updateActiveAttributes(): void { 167 | const gl = this.gl 168 | const program = this.program 169 | 170 | // Add all active attributes for this program to a map. 171 | const numAttributes = gl.getProgramParameter( 172 | program, 173 | gl.ACTIVE_ATTRIBUTES 174 | ) as number 175 | for (let i = 0; i < numAttributes; i++) { 176 | const attribute = gl.getActiveAttrib(program, i) 177 | if (attribute === null) continue 178 | 179 | const location = gl.getAttribLocation(program, attribute.name) 180 | this.attributes.set(attribute.name, location) 181 | } 182 | } 183 | 184 | private updateActiveUniforms(): void { 185 | const gl = this.gl 186 | const program = this.program 187 | 188 | // Add all active uniforms for this program to a map. 189 | const numUniforms = gl.getProgramParameter( 190 | program, 191 | gl.ACTIVE_UNIFORMS 192 | ) as number 193 | for (let i = 0; i < numUniforms; i++) { 194 | const uniform = gl.getActiveUniform(program, i) 195 | if (uniform === null) continue 196 | 197 | const uniformLocation = gl.getUniformLocation(program, uniform.name) 198 | if (uniformLocation === null) continue 199 | this.uniforms.set(uniform.name, uniformLocation) 200 | } 201 | } 202 | } 203 | 204 | /** 205 | * Creates and fills a WebGL2 buffer. 206 | * 207 | * @param gl WebGL2 rendering context. 208 | * @param data values to fill the buffer with. 209 | * @returns filled WebGL2 buffer. 210 | */ 211 | export function createAndFillStaticBuffer( 212 | gl: WebGL2RenderingContext, 213 | data: AllowSharedBufferSource 214 | ): WebGLBuffer { 215 | const buffer = gl.createBuffer() 216 | if (buffer === null) { 217 | throw new Error('Failed to create buffer.') 218 | } 219 | 220 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer) 221 | gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW) 222 | gl.bindBuffer(gl.ARRAY_BUFFER, null) 223 | 224 | return buffer 225 | } 226 | 227 | /** 228 | * Binds an N-dimensional (floating-point) buffer to an attribute. 229 | * 230 | * @param gl WebGL2 rendering context. 231 | * @param buffer buffer to bind to the attribute. 232 | * @param attribute index of the attribute to bind. 233 | * @param numComponents number of components of the attribute (e.g. 2 for a vec2) 234 | */ 235 | export function bindAttribute( 236 | gl: WebGL2RenderingContext, 237 | buffer: WebGLBuffer, 238 | attribute: number, 239 | numComponents: number 240 | ) { 241 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer) 242 | gl.enableVertexAttribArray(attribute) 243 | const type = gl.FLOAT 244 | const doNormalise = false 245 | // Use the default stride, i.e. assume that data of each component follow each other directly, 246 | // without any padding. 247 | const stride = 0 248 | const offset = 0 249 | gl.vertexAttribPointer( 250 | attribute, 251 | numComponents, 252 | type, 253 | doNormalise, 254 | stride, 255 | offset 256 | ) 257 | gl.bindBuffer(gl.ARRAY_BUFFER, null) 258 | } 259 | 260 | /** 261 | * Binds a texture to a texture unit and uniform. 262 | * 263 | * @param program Shader program. 264 | * @param uniform Name of the uniform to bind to. 265 | * @param unit Texture unit to use. 266 | * @param texture Texture to bind. 267 | */ 268 | export function bindTexture( 269 | program: ShaderProgram, 270 | uniform: string, 271 | unit: number, 272 | texture: WebGLTexture 273 | ): void { 274 | const gl = program.gl 275 | gl.activeTexture(gl.TEXTURE0 + unit) 276 | gl.bindTexture(gl.TEXTURE_2D, texture) 277 | gl.uniform1i(program.getUniformLocation(uniform), unit) 278 | } 279 | -------------------------------------------------------------------------------- /src/render/particles.ts: -------------------------------------------------------------------------------- 1 | import { createRectangleVertexArray } from '../utils/geometry' 2 | import { ShaderProgram, bindTexture } from '../utils/shader-program' 3 | import type { BoundingBoxScaling } from './final' 4 | import type { ParticleBuffers } from './propagator' 5 | 6 | export class ParticleRenderer { 7 | public particleSize: number 8 | public maxAge: number 9 | public growthRate: number 10 | 11 | private program: ShaderProgram 12 | private width: number 13 | private height: number 14 | private numParticles: number 15 | private particleTexture: WebGLTexture 16 | private particleDataTexture: WebGLTexture | null 17 | private particleAgeTexture: WebGLTexture | null 18 | private positionBuffer: WebGLBuffer | null 19 | private texCoordBuffer: WebGLBuffer | null 20 | private vertexArray: WebGLVertexArrayObject | null 21 | private widthParticleDataTexture: number 22 | private heightParticleDataTexture: number 23 | private isSpriteRenderer: boolean 24 | private doRotateParticles: boolean 25 | 26 | constructor( 27 | program: ShaderProgram, 28 | width: number, 29 | height: number, 30 | numParticles: number, 31 | particleSize: number, 32 | particleTexture: WebGLTexture, 33 | widthParticleDataTexture: number, 34 | heightParticleDataTexture: number, 35 | isSpriteRenderer: boolean, 36 | maxAge: number, 37 | growthRate: number, 38 | doRotateParticles: boolean 39 | ) { 40 | this.program = program 41 | 42 | this.width = width 43 | this.height = height 44 | this.numParticles = numParticles 45 | this.particleSize = particleSize 46 | this.particleTexture = particleTexture 47 | this.particleDataTexture = null 48 | this.particleAgeTexture = null 49 | this.widthParticleDataTexture = widthParticleDataTexture 50 | this.heightParticleDataTexture = heightParticleDataTexture 51 | 52 | this.maxAge = maxAge 53 | this.growthRate = growthRate 54 | 55 | this.positionBuffer = null 56 | this.texCoordBuffer = null 57 | this.vertexArray = null 58 | 59 | this.isSpriteRenderer = isSpriteRenderer 60 | this.doRotateParticles = doRotateParticles 61 | } 62 | 63 | initialise(): void { 64 | // We need to flip the vertical texture coordinate since we are using a 65 | // texture loaded from an image, which is vertically flipped w.r.t. clip 66 | // space coordinates. 67 | const doFlipV = true 68 | const [positionBuffer, texCoordBuffer, vertexArray] = 69 | createRectangleVertexArray( 70 | this.program, 71 | -0.5, 72 | 0.5, 73 | -0.5, 74 | 0.5, 75 | doFlipV, 76 | 'a_position', 77 | 'a_tex_coord' 78 | ) 79 | this.positionBuffer = positionBuffer 80 | this.texCoordBuffer = texCoordBuffer 81 | this.vertexArray = vertexArray 82 | this.resetParticleDataTextures() 83 | } 84 | 85 | destruct(doDeleteSharedResources: boolean = false): void { 86 | const gl = this.program.gl 87 | gl.deleteBuffer(this.positionBuffer) 88 | gl.deleteBuffer(this.texCoordBuffer) 89 | gl.deleteVertexArray(this.vertexArray) 90 | if (doDeleteSharedResources) { 91 | gl.deleteTexture(this.particleTexture) 92 | gl.deleteTexture(this.particleDataTexture) 93 | gl.deleteTexture(this.particleAgeTexture) 94 | this.program.destruct() 95 | } 96 | } 97 | 98 | setDimensions(width: number, height: number): void { 99 | this.width = width 100 | this.height = height 101 | } 102 | 103 | setNumParticles( 104 | numParticles: number, 105 | widthParticlePositionTexture: number, 106 | heightParticlePositionTexture: number 107 | ): void { 108 | this.numParticles = numParticles 109 | this.widthParticleDataTexture = widthParticlePositionTexture 110 | this.heightParticleDataTexture = heightParticlePositionTexture 111 | this.resetParticleDataTextures() 112 | } 113 | 114 | setMaxAge(maxAge: number): void { 115 | this.maxAge = maxAge 116 | } 117 | 118 | setParticleTexture(texture: WebGLTexture): void { 119 | this.particleTexture = texture 120 | } 121 | 122 | setDoRotateParticles(doRotateParticles: boolean): void { 123 | this.doRotateParticles = doRotateParticles 124 | } 125 | 126 | render(particleBuffers: ParticleBuffers, scaling?: BoundingBoxScaling): void { 127 | if (!this.particleDataTexture || !this.particleAgeTexture) { 128 | throw new Error( 129 | 'No particle data textures defined, particle renderer was not initialised?' 130 | ) 131 | } 132 | if (this.isSpriteRenderer && scaling === undefined) { 133 | throw new Error( 134 | 'Must specify bounding box scaling when rendering sprites.' 135 | ) 136 | } 137 | 138 | const gl = this.program.gl 139 | this.program.use() 140 | 141 | gl.enable(gl.BLEND) 142 | if (!this.isSpriteRenderer) { 143 | // We keep the current state of the frame buffer and render the particles on 144 | // top of it, ignoring alpha for this blending as it has already been taken 145 | // care of in the texture render. 146 | gl.blendEquationSeparate(gl.FUNC_ADD, gl.MAX) 147 | gl.blendFunc(gl.ONE, gl.ONE) 148 | } else { 149 | gl.blendEquation(gl.FUNC_ADD) 150 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) 151 | } 152 | 153 | gl.bindVertexArray(this.vertexArray) 154 | 155 | // Update particle data (i.e. position and velocity) and particle age data 156 | // texture for use in the particle renderer. 157 | this.updateParticleDataTextureFromBuffer( 158 | particleBuffers.data, 159 | this.particleDataTexture, 160 | 4 161 | ) 162 | this.updateParticleDataTextureFromBuffer( 163 | particleBuffers.age, 164 | this.particleAgeTexture, 165 | 1 166 | ) 167 | 168 | bindTexture(this.program, 'u_particle_texture', 0, this.particleTexture) 169 | bindTexture( 170 | this.program, 171 | 'u_particle_data_texture', 172 | 1, 173 | this.particleDataTexture 174 | ) 175 | bindTexture( 176 | this.program, 177 | 'u_particle_age_texture', 178 | 2, 179 | this.particleAgeTexture 180 | ) 181 | this.bindUniforms(scaling) 182 | 183 | gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this.numParticles) 184 | 185 | gl.bindVertexArray(null) 186 | gl.disable(gl.BLEND) 187 | } 188 | 189 | private resetParticleDataTextures(): void { 190 | const gl = this.program.gl 191 | if (this.particleDataTexture) gl.deleteTexture(this.particleDataTexture) 192 | if (this.particleAgeTexture) gl.deleteTexture(this.particleAgeTexture) 193 | 194 | this.particleDataTexture = this.createParticleDataTexture(4) 195 | this.particleAgeTexture = this.createParticleDataTexture(1) 196 | } 197 | 198 | private createParticleDataTexture(numComponents: 1 | 4): WebGLTexture { 199 | const gl = this.program.gl 200 | 201 | const texture = gl.createTexture() 202 | if (texture === null) { 203 | throw new Error('Failed to create texture.') 204 | } 205 | 206 | gl.bindTexture(gl.TEXTURE_2D, texture) 207 | 208 | // Allocate storage for the texture. 209 | gl.texStorage2D( 210 | gl.TEXTURE_2D, 211 | 1, 212 | numComponents === 1 ? gl.R32F : gl.RGBA32F, 213 | this.widthParticleDataTexture, 214 | this.heightParticleDataTexture 215 | ) 216 | 217 | // We use a 32-bit floating point texture for the particles that we do not 218 | // want to (and cannot) interpolate, so use nearest neighbour filtering. 219 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) 220 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) 221 | 222 | gl.bindTexture(gl.TEXTURE_2D, null) 223 | 224 | return texture 225 | } 226 | 227 | private updateParticleDataTextureFromBuffer( 228 | particleBuffer: WebGLBuffer, 229 | dataTexture: WebGLTexture, 230 | numComponents: 1 | 4 231 | ): void { 232 | if (!this.particleDataTexture) { 233 | throw new Error( 234 | 'No particle position texture defined, particle renderer was not initialised?' 235 | ) 236 | } 237 | const gl = this.program.gl 238 | gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, particleBuffer) 239 | gl.bindTexture(gl.TEXTURE_2D, dataTexture) 240 | 241 | gl.texSubImage2D( 242 | gl.TEXTURE_2D, 243 | 0, // level 244 | 0, // x-offset 245 | 0, // y-offset 246 | this.widthParticleDataTexture, 247 | this.heightParticleDataTexture, 248 | numComponents === 1 ? gl.RED : gl.RGBA, 249 | gl.FLOAT, 250 | 0 // offset into the pixel unpack buffer 251 | ) 252 | 253 | gl.bindTexture(gl.TEXTURE_2D, null) 254 | gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null) 255 | } 256 | 257 | private bindUniforms(scaling?: BoundingBoxScaling): void { 258 | const gl = this.program.gl 259 | 260 | // Properties of the texture used to render the particle sprites; rescale 261 | // particle size to clip coordinates. Also take into account display 262 | // scaling so particles are not tiny on UHD-screens... 263 | const scalingFactor = window.devicePixelRatio ?? 1 264 | const particleSizeClipCoords = 265 | (this.particleSize * scalingFactor) / this.width 266 | gl.uniform1f( 267 | this.program.getUniformLocation('u_particle_size'), 268 | particleSizeClipCoords 269 | ) 270 | gl.uniform1f( 271 | this.program.getUniformLocation('u_aspect_ratio'), 272 | this.width / this.height 273 | ) 274 | gl.uniform1i( 275 | this.program.getUniformLocation('u_do_rotate_particles'), 276 | this.doRotateParticles ? 1 : 0 277 | ) 278 | 279 | // Width of the data texture to retrieve the particle positions in the 280 | // vertex shader. 281 | gl.uniform1i( 282 | this.program.getUniformLocation('u_width'), 283 | this.widthParticleDataTexture 284 | ) 285 | 286 | // Scaling parameters for the bounding box. 287 | gl.uniform2f( 288 | this.program.getUniformLocation('u_bbox_scale'), 289 | scaling?.scaleX ?? 1.0, 290 | scaling?.scaleY ?? 1.0 291 | ) 292 | gl.uniform2f( 293 | this.program.getUniformLocation('u_bbox_offset'), 294 | scaling?.offsetX ?? 0.0, 295 | scaling?.offsetY ?? 0.0 296 | ) 297 | 298 | gl.uniform1f(this.program.getUniformLocation('u_max_age'), this.maxAge) 299 | gl.uniform1f( 300 | this.program.getUniformLocation('u_growth_rate'), 301 | this.growthRate 302 | ) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/render/propagator.ts: -------------------------------------------------------------------------------- 1 | import { SpeedCurve } from '../utils/speedcurve' 2 | import { 3 | ShaderProgram, 4 | bindAttribute, 5 | bindTexture 6 | } from '../utils/shader-program' 7 | import { VelocityImage } from '../utils/wms' 8 | 9 | export class ParticleBuffers { 10 | private gl: WebGL2RenderingContext 11 | readonly data: WebGLBuffer 12 | readonly age: WebGLBuffer 13 | 14 | constructor(gl: WebGL2RenderingContext, numParticlesAllocate: number) { 15 | this.gl = gl 16 | this.data = ParticleBuffers.createBuffer(gl, 4, numParticlesAllocate) 17 | this.age = ParticleBuffers.createBuffer(gl, 1, numParticlesAllocate) 18 | } 19 | 20 | destroy(): void { 21 | this.gl.deleteBuffer(this.data) 22 | this.gl.deleteBuffer(this.age) 23 | } 24 | 25 | initialise( 26 | initialCoordinates: Float32Array, 27 | initialAges: Float32Array 28 | ): void { 29 | const gl = this.gl 30 | 31 | gl.bindBuffer(gl.ARRAY_BUFFER, this.data) 32 | gl.bufferSubData(gl.ARRAY_BUFFER, 0, initialCoordinates) 33 | 34 | gl.bindBuffer(gl.ARRAY_BUFFER, this.age) 35 | gl.bufferSubData(gl.ARRAY_BUFFER, 0, initialAges) 36 | 37 | gl.bindBuffer(gl.ARRAY_BUFFER, null) 38 | } 39 | 40 | resetAges(newAges: Float32Array): void { 41 | const gl = this.gl 42 | gl.bindBuffer(gl.ARRAY_BUFFER, this.age) 43 | gl.bufferSubData(gl.ARRAY_BUFFER, 0, newAges) 44 | gl.bindBuffer(gl.ARRAY_BUFFER, null) 45 | } 46 | 47 | private static createBuffer( 48 | gl: WebGL2RenderingContext, 49 | numFloatsPerParticle: number, 50 | numParticlesAllocate: number 51 | ): WebGLBuffer { 52 | const buffer = gl.createBuffer() 53 | const numBytesBuffer = 4 * numFloatsPerParticle * numParticlesAllocate 54 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer) 55 | gl.bufferData(gl.ARRAY_BUFFER, numBytesBuffer, gl.STATIC_DRAW) 56 | gl.bindBuffer(gl.ARRAY_BUFFER, null) 57 | return buffer 58 | } 59 | } 60 | 61 | export class ParticlePropagator { 62 | private program: ShaderProgram 63 | private width: number 64 | private height: number 65 | private numParticles: number 66 | private numParticlesAllocate: number 67 | private maxAge: number 68 | private speedCurve: SpeedCurve 69 | private inputBuffers: ParticleBuffers | null 70 | private outputBuffers: ParticleBuffers | null 71 | private transformFeedback: WebGLTransformFeedback | null 72 | private velocityImage: VelocityImage | null 73 | private velocityTexture: WebGLTexture | null 74 | 75 | constructor( 76 | program: ShaderProgram, 77 | width: number, 78 | height: number, 79 | numParticles: number, 80 | numParticlesAllocate: number, 81 | maxAge: number, 82 | speedCurve: SpeedCurve 83 | ) { 84 | this.program = program 85 | this.width = width 86 | this.height = height 87 | 88 | this.numParticles = numParticles 89 | this.numParticlesAllocate = numParticlesAllocate 90 | this.maxAge = maxAge 91 | 92 | this.velocityImage = null 93 | this.velocityTexture = null 94 | this.speedCurve = speedCurve 95 | 96 | this.inputBuffers = null 97 | this.outputBuffers = null 98 | this.transformFeedback = null 99 | } 100 | 101 | get buffers(): ParticleBuffers { 102 | if (!this.outputBuffers) { 103 | throw new Error( 104 | 'No output buffer defined, particle renderer was not initialised?' 105 | ) 106 | } 107 | return this.outputBuffers 108 | } 109 | 110 | initialise(): void { 111 | const gl = this.program.gl 112 | 113 | this.resetBuffers() 114 | this.transformFeedback = gl.createTransformFeedback() 115 | } 116 | 117 | destruct(): void { 118 | const gl = this.program.gl 119 | if (this.inputBuffers) this.inputBuffers.destroy() 120 | if (this.outputBuffers) this.outputBuffers.destroy() 121 | 122 | gl.deleteTransformFeedback(this.transformFeedback) 123 | gl.deleteTexture(this.velocityTexture) 124 | this.program.destruct() 125 | } 126 | 127 | setDimensions(width: number, height: number): void { 128 | this.width = width 129 | this.height = height 130 | } 131 | 132 | setVelocityImage(velocityImage: VelocityImage): void { 133 | this.velocityImage = velocityImage 134 | this.velocityTexture = velocityImage.toTexture(this.program.gl, false) 135 | } 136 | 137 | setNumParticles(numParticles: number, numParticlesAllocate: number): void { 138 | this.numParticles = numParticles 139 | this.numParticlesAllocate = numParticlesAllocate 140 | 141 | this.resetBuffers() 142 | } 143 | 144 | setMaxAge(maxAge: number): void { 145 | this.maxAge = maxAge 146 | 147 | // Reset all particle ages to spread all over the new age range. 148 | this.resetBuffers() 149 | } 150 | 151 | setSpeedCurve(speedCurve: SpeedCurve): void { 152 | this.speedCurve = speedCurve 153 | } 154 | 155 | resetBuffers(): void { 156 | if (this.inputBuffers) this.inputBuffers.destroy() 157 | if (this.outputBuffers) this.outputBuffers.destroy() 158 | 159 | const gl = this.program.gl 160 | this.inputBuffers = new ParticleBuffers(gl, this.numParticlesAllocate) 161 | this.outputBuffers = new ParticleBuffers(gl, this.numParticlesAllocate) 162 | 163 | // Initialise input buffer with random particle positions and ages. 164 | const initialCoordinates = this.generateInitialParticleData() 165 | const initialAges = this.generateInitialParticleAges() 166 | this.inputBuffers.initialise(initialCoordinates, initialAges) 167 | 168 | // Since we swap the buffers immediately in the update function, swap them 169 | // here too. 170 | this.swapBuffers() 171 | } 172 | 173 | resetAges(): void { 174 | const initialAges = this.generateInitialParticleAges() 175 | // Set the new ages on the output buffer, since we swap the buffers at the 176 | // start of rendering. 177 | this.outputBuffers?.resetAges(initialAges) 178 | } 179 | 180 | update(dt: number): void { 181 | const gl = this.program.gl 182 | this.program.use() 183 | 184 | if (!this.inputBuffers || !this.outputBuffers) { 185 | throw new Error( 186 | 'Input buffer and/or output buffer is not defined, particle renderer was not initialised?' 187 | ) 188 | } 189 | if (!this.velocityTexture) { 190 | throw new Error( 191 | 'Velocity texture is not defined, no velocity image was set?' 192 | ) 193 | } 194 | 195 | // We need to swap the buffers before we do the update, since we use the 196 | // output buffer in later rendering steps, so it should not have been 197 | // swapped out. 198 | this.swapBuffers() 199 | 200 | bindAttribute( 201 | gl, 202 | this.inputBuffers.data, 203 | this.program.getAttributeLocation('a_particle_data'), 204 | 4 205 | ) 206 | bindAttribute( 207 | gl, 208 | this.inputBuffers.age, 209 | this.program.getAttributeLocation('a_particle_age'), 210 | 1 211 | ) 212 | bindTexture(this.program, 'u_velocity_texture', 0, this.velocityTexture) 213 | this.bindUniforms(dt) 214 | 215 | // Bind transform feedback and buffer so we can write the updated positions 216 | // of the particles from the vertex shader to the output buffer. 217 | gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, this.transformFeedback) 218 | gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this.outputBuffers.data) 219 | gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, this.outputBuffers.age) 220 | 221 | // Do not run the fragment shader; we do the particle updates in the vertex 222 | // shader only. 223 | gl.enable(gl.RASTERIZER_DISCARD) 224 | 225 | gl.beginTransformFeedback(gl.POINTS) 226 | gl.drawArrays(gl.POINTS, 0, this.numParticles) 227 | gl.endTransformFeedback() 228 | 229 | // Re-enable the fragment shader and unbind the transform feedback. 230 | gl.disable(gl.RASTERIZER_DISCARD) 231 | gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null) 232 | } 233 | 234 | private bindUniforms(dt: number): void { 235 | if (!this.velocityImage) { 236 | throw new Error( 237 | 'Velocity image is not defined, no velocity image was set?' 238 | ) 239 | } 240 | const gl = this.program.gl 241 | 242 | // Time step such that the propagation is proportional to the time spent on 243 | // this frame. 244 | gl.uniform1f(this.program.getUniformLocation('u_dt'), dt) 245 | 246 | // Uniforms for correctly scaling the velocity. 247 | gl.uniform1f( 248 | this.program.getUniformLocation('u_aspect_ratio'), 249 | this.height / this.width 250 | ) 251 | gl.uniform2f( 252 | this.program.getUniformLocation('u_scale_in'), 253 | this.velocityImage.uScale, 254 | this.velocityImage.vScale 255 | ) 256 | gl.uniform2f( 257 | this.program.getUniformLocation('u_offset_in'), 258 | this.velocityImage.uOffset, 259 | this.velocityImage.vOffset 260 | ) 261 | gl.uniform1f( 262 | this.program.getUniformLocation('u_speed_factor'), 263 | this.speedCurve.exponent === 0 264 | ? this.speedCurve.baseFactor 265 | : this.speedCurve.factor 266 | ) 267 | gl.uniform1f( 268 | this.program.getUniformLocation('u_speed_exponent'), 269 | this.speedCurve.exponent 270 | ) 271 | 272 | // Select a range of particle indices to eliminate and replace by newly 273 | // generated positions. 274 | gl.uniform1f(this.program.getUniformLocation('u_max_age'), this.maxAge) 275 | } 276 | 277 | private swapBuffers(): void { 278 | const temp = this.inputBuffers 279 | this.inputBuffers = this.outputBuffers 280 | this.outputBuffers = temp 281 | } 282 | 283 | private generateInitialParticleData(): Float32Array { 284 | const data = new Float32Array(this.numParticles * 4) 285 | for (let i = 0; i < this.numParticles; i++) { 286 | const [x, y] = ParticlePropagator.randomClipCoords() 287 | const index = 4 * i 288 | data[index] = x 289 | data[index + 1] = y 290 | // Initialise velocity at almost, but not quite zero. If we initialise at 291 | // exactly 0, the shader logic will interpret this as "undefined speed". 292 | data[index + 2] = 1e-6 293 | data[index + 3] = 1e-6 294 | } 295 | return data 296 | } 297 | 298 | private generateInitialParticleAges(): Float32Array { 299 | const ages = new Float32Array(this.numParticles) 300 | for (let i = 0; i < this.numParticles; i++) { 301 | // Generate random ages such that not all particles die at the same time. 302 | ages[i] = Math.random() * this.maxAge 303 | } 304 | return ages 305 | } 306 | 307 | private static randomClipCoords(): [number, number] { 308 | const randomClipCoord = () => Math.random() * 2 - 1 309 | return [randomClipCoord(), randomClipCoord()] 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /examples/wms.ts: -------------------------------------------------------------------------------- 1 | import type { WMSStreamlineLayer } from '@/layer' 2 | import { StreamlineStyle } from '@/render' 3 | import type { StreamlineVisualiserOptions } from '@/visualiser' 4 | import type { 5 | GetCapabilitiesResponse, 6 | Layer as FewsWmsLayer 7 | } from '@deltares/fews-wms-requests' 8 | 9 | import waveCrestUrl from './wave.svg' 10 | 11 | interface WmsStyle { 12 | id: string 13 | title: string 14 | } 15 | 16 | type FewsAnimatedVectorSettings = FewsWmsLayer['animatedVectors'] 17 | interface Layer { 18 | id: string 19 | title: string 20 | times: Date[] 21 | styles: WmsStyle[] 22 | elevationBounds: [number, number] | null 23 | defaultSettings: FewsAnimatedVectorSettings 24 | } 25 | 26 | type LayerChangeCallback = ( 27 | numParticles?: number, 28 | options?: Partial 29 | ) => void 30 | 31 | export class FewsWmsOptionsControl extends HTMLElement { 32 | static readonly URL_STORAGE_KEY = 'maplibre-demo-base-url' 33 | 34 | private layer: WMSStreamlineLayer | null 35 | private availableLayers: Layer[] 36 | private layerChangeCallback: LayerChangeCallback | null 37 | 38 | private container: HTMLDivElement 39 | private controlContainer: HTMLDivElement 40 | 41 | private baseUrlInput: HTMLInputElement 42 | private layerSelect: HTMLSelectElement 43 | private styleSelect: HTMLSelectElement 44 | private timeSelect: HTMLSelectElement 45 | private elevationInput: HTMLInputElement 46 | 47 | constructor() { 48 | super() 49 | 50 | this.layer = null 51 | this.availableLayers = [] 52 | this.layerChangeCallback = null 53 | 54 | this.container = document.createElement('div') 55 | this.container.style.display = 'flex' 56 | this.container.style.flexDirection = 'column' 57 | this.container.style.rowGap = '10px' 58 | 59 | const [baseUrlLabel, baseUrlInput] = this.createBaseUrlControl() 60 | this.baseUrlInput = baseUrlInput 61 | 62 | this.container.appendChild(baseUrlLabel) 63 | 64 | this.controlContainer = document.createElement('div') 65 | this.controlContainer.style.width = '100%' 66 | this.controlContainer.style.display = 'flex' 67 | this.controlContainer.style.columnGap = '10px' 68 | 69 | this.layerSelect = this.createSelectControl('Select WMS layer') 70 | this.layerSelect.addEventListener('input', () => this.selectLayer()) 71 | 72 | this.styleSelect = this.createSelectControl('Select style') 73 | this.styleSelect.addEventListener('input', () => this.selectStyle()) 74 | 75 | this.timeSelect = this.createSelectControl('Select time') 76 | this.timeSelect.addEventListener('input', () => this.selectTime()) 77 | 78 | const [elevationLabel, elevationInput] = this.createElevationInput() 79 | this.elevationInput = elevationInput 80 | this.elevationInput.addEventListener('input', () => this.selectElevation()) 81 | 82 | this.controlContainer.appendChild(this.layerSelect) 83 | this.controlContainer.appendChild(this.styleSelect) 84 | this.controlContainer.appendChild(this.timeSelect) 85 | this.controlContainer.appendChild(elevationLabel) 86 | 87 | this.container.appendChild(this.controlContainer) 88 | } 89 | 90 | connectedCallback(): void { 91 | const shadow = this.attachShadow({ mode: 'open' }) 92 | shadow.appendChild(this.container) 93 | } 94 | 95 | attachLayer(layer: WMSStreamlineLayer): void { 96 | this.layer = layer 97 | 98 | // If we have a URL in the localStorage, use it. 99 | const baseUrl = localStorage.getItem(FewsWmsOptionsControl.URL_STORAGE_KEY) 100 | if (baseUrl) { 101 | this.baseUrlInput.value = baseUrl 102 | this.setBaseUrl(baseUrl).catch(error => 103 | console.error( 104 | `Failed to set stored base URL: ${(error as Error).toString()}` 105 | ) 106 | ) 107 | } 108 | } 109 | 110 | onLayerChange(callback: LayerChangeCallback): void { 111 | this.layerChangeCallback = callback 112 | } 113 | 114 | private createSelectControl(placeholder: string): HTMLSelectElement { 115 | const el = document.createElement('select') 116 | el.disabled = true 117 | el.setAttribute('data-placeholder', placeholder) 118 | 119 | const placeholderOption = document.createElement('option') 120 | placeholderOption.textContent = placeholder 121 | 122 | el.appendChild(placeholderOption) 123 | return el 124 | } 125 | 126 | private createElevationInput(): [HTMLLabelElement, HTMLInputElement] { 127 | const el = document.createElement('label') 128 | 129 | const input = document.createElement('input') 130 | input.type = 'number' 131 | input.step = '100' 132 | input.disabled = true 133 | 134 | el.appendChild(input) 135 | return [el, input] 136 | } 137 | 138 | private createBaseUrlControl(): [HTMLLabelElement, HTMLInputElement] { 139 | const el = document.createElement('label') 140 | el.style.display = 'flex' 141 | el.style.flexDirection = 'column' 142 | el.textContent = 143 | 'FEWS WMS service URL (e.g. https://example.com/FewsWebServices/wms):' 144 | 145 | const input = document.createElement('input') 146 | input.type = 'text' 147 | input.addEventListener('input', () => { 148 | this.setBaseUrl(input.value).catch(() => 149 | console.error('Error when setting base URL.') 150 | ) 151 | }) 152 | 153 | el.appendChild(input) 154 | return [el, input] 155 | } 156 | 157 | private async setBaseUrl(baseUrl: string): Promise { 158 | // Try a version request to see whether this is a valid WMS url. 159 | try { 160 | const url = new URL(baseUrl) 161 | url.searchParams.append('request', 'GetVersion') 162 | await fetch(url) 163 | 164 | // We have a working WMS URL, so fetch times, elevation and styles. 165 | await this.fetchCapabilities(baseUrl) 166 | this.createWmsLayerOptions() 167 | 168 | // Store the URL in the localStorage. 169 | localStorage.setItem( 170 | FewsWmsOptionsControl.URL_STORAGE_KEY, 171 | baseUrl.toString() 172 | ) 173 | } catch { 174 | this.disableControls() 175 | return 176 | } 177 | } 178 | 179 | private async fetchCapabilities(baseUrl: string): Promise { 180 | const url = new URL(baseUrl) 181 | url.searchParams.append('request', 'GetCapabilities') 182 | url.searchParams.append('format', 'application/json') 183 | 184 | const response = await fetch(url) 185 | const data = (await response.json()) as GetCapabilitiesResponse 186 | 187 | // Find layers configured to use animated vectors. 188 | const usableLayers = data.layers.filter( 189 | layer => layer.animatedVectors !== undefined 190 | ) 191 | this.availableLayers = usableLayers.map(layer => { 192 | const times = layer.times?.map(time => new Date(time)) ?? [] 193 | const styles: WmsStyle[] = 194 | layer.styles?.map(style => ({ 195 | id: style.name!, // FIXME: why can "name" be undefined? 196 | title: style.title 197 | })) ?? [] 198 | let elevationBounds: [number, number] | null = null 199 | if (layer.elevation?.lowerValue && layer.elevation.upperValue) { 200 | elevationBounds = [ 201 | layer.elevation.lowerValue, 202 | layer.elevation.upperValue 203 | ] 204 | } 205 | return { 206 | id: layer.name, 207 | title: layer.title, 208 | times, 209 | styles, 210 | elevationBounds, 211 | defaultSettings: layer.animatedVectors! 212 | } 213 | }) 214 | } 215 | 216 | private createWmsLayerOptions(): void { 217 | // Remove all previous options. 218 | this.layerSelect.innerHTML = '' 219 | 220 | // Add an option for each layer. 221 | this.availableLayers.forEach(layer => { 222 | const option = document.createElement('option') 223 | option.textContent = layer.title 224 | option.value = layer.id 225 | 226 | this.layerSelect.appendChild(option) 227 | }) 228 | 229 | // Enable the control. 230 | this.layerSelect.disabled = false 231 | 232 | // Call selectLayer for the currently selected first option. 233 | this.selectLayer() 234 | } 235 | 236 | private selectLayer(): void { 237 | if (!this.layer) throw new Error('No attached layer.') 238 | 239 | const layer = this.availableLayers.find( 240 | layer => layer.id === this.layerSelect.value 241 | ) 242 | if (!layer) { 243 | throw new Error(`Invalid selected layer ID "${this.layerSelect.value}".`) 244 | } 245 | 246 | // Recreate style options. 247 | this.styleSelect.innerHTML = '' 248 | layer.styles.forEach(style => { 249 | const option = document.createElement('option') 250 | option.textContent = style.title 251 | option.value = style.id 252 | 253 | this.styleSelect.appendChild(option) 254 | }) 255 | this.styleSelect.disabled = layer.styles.length < 2 256 | 257 | // Recreate time options. 258 | this.timeSelect.innerHTML = '' 259 | layer.times.forEach(time => { 260 | const option = document.createElement('option') 261 | option.textContent = time.toLocaleString() 262 | option.value = time.getTime().toString() 263 | 264 | this.timeSelect.appendChild(option) 265 | }) 266 | this.timeSelect.disabled = layer.times.length < 2 267 | 268 | // Set elevation input bounds (or disabled it altogether). 269 | if (layer.elevationBounds) { 270 | this.elevationInput.disabled = false 271 | this.elevationInput.min = layer.elevationBounds[0].toString() 272 | this.elevationInput.max = layer.elevationBounds[1].toString() 273 | // Always set it to the highest value. 274 | this.elevationInput.value = layer.elevationBounds[1].toString() 275 | } else { 276 | this.elevationInput.value = '' 277 | this.elevationInput.disabled = true 278 | } 279 | 280 | // Initialise layer. 281 | this.layer 282 | .setWmsLayer(this.baseUrlInput.value, this.layerSelect.value) 283 | .then(() => { 284 | if (this.layerChangeCallback) { 285 | const fewsOptions = layer.defaultSettings 286 | const isWaveCrest = fewsOptions?.particleType === 'wave-crest' 287 | const defaultNumParticles = 1000 288 | const defaultParticleSize = isWaveCrest ? 12 : 3 289 | const defaultSpeedFactor = isWaveCrest ? 0.02 : 0.2 290 | const defaultFadeAmountPerSecond = isWaveCrest ? 3 : 0.1 291 | const defaultSpeedExponent = 1 292 | const defaultMaxAge = isWaveCrest ? 10 : 2 293 | const defaultGrowthRate = isWaveCrest ? 1 : undefined 294 | const options: Partial = { 295 | style: fewsOptions?.coloredParticles 296 | ? StreamlineStyle.MagnitudeColoredParticles 297 | : StreamlineStyle.ColoredParticles, 298 | particleSize: fewsOptions?.particleSize ?? defaultParticleSize, 299 | speedFactor: fewsOptions?.speedFactor ?? defaultSpeedFactor, 300 | fadeAmountPerSecond: 301 | fewsOptions?.fadeAmount ?? defaultFadeAmountPerSecond, 302 | speedExponent: fewsOptions?.speedExponent ?? defaultSpeedExponent, 303 | particleColor: fewsOptions?.particleColor 304 | ? `#${fewsOptions?.particleColor}` 305 | : undefined, 306 | maxAge: fewsOptions?.maximumParticleAge ?? defaultMaxAge, 307 | spriteUrl: 308 | fewsOptions?.particleType === 'wave-crest' 309 | ? new URL(waveCrestUrl) 310 | : undefined, 311 | growthRate: defaultGrowthRate 312 | } 313 | const numParticles = 314 | fewsOptions?.numberOfParticles ?? defaultNumParticles 315 | 316 | try { 317 | this.layerChangeCallback(numParticles, options) 318 | } catch (error) { 319 | console.error( 320 | `Layer change callback failed: ${(error as Error).toString()}` 321 | ) 322 | } 323 | } 324 | }) 325 | .catch(error => 326 | console.error( 327 | `Failed to initialise streamlines layer: ${(error as Error).toString()}` 328 | ) 329 | ) 330 | } 331 | 332 | private selectStyle(): void { 333 | if (!this.layer) throw new Error('No attached layer.') 334 | 335 | this.layer 336 | .setStyle(this.styleSelect.value) 337 | .catch(error => 338 | console.error(`Failed to set WMS style: ${(error as Error).toString()}`) 339 | ) 340 | } 341 | 342 | private selectTime(): void { 343 | if (!this.layer) throw new Error('No attached layer.') 344 | 345 | const date = new Date(+this.timeSelect.value) 346 | this.layer 347 | .setTime(date) 348 | .catch(error => 349 | console.error(`Failed to set WMS time: ${(error as Error).toString()}`) 350 | ) 351 | } 352 | 353 | private selectElevation(): void { 354 | if (!this.layer) throw new Error('No attached layer.') 355 | 356 | const elevation = parseFloat(this.elevationInput.value) 357 | if (isNaN(elevation)) return 358 | 359 | this.layer 360 | .setElevation(elevation) 361 | .catch(error => 362 | console.error( 363 | `Failed to set WMS elevation: ${(error as Error).toString()}` 364 | ) 365 | ) 366 | } 367 | 368 | private disableControls(): void { 369 | const restorePlaceholder = (el: HTMLSelectElement) => { 370 | el.innerHTML = `` 371 | } 372 | restorePlaceholder(this.layerSelect) 373 | restorePlaceholder(this.styleSelect) 374 | restorePlaceholder(this.timeSelect) 375 | 376 | this.layerSelect.disabled = true 377 | this.styleSelect.disabled = true 378 | this.timeSelect.disabled = true 379 | 380 | this.elevationInput.value = '' 381 | this.elevationInput.disabled = true 382 | } 383 | } 384 | 385 | customElements.define('wms-options-control', FewsWmsOptionsControl) 386 | -------------------------------------------------------------------------------- /examples/options.ts: -------------------------------------------------------------------------------- 1 | import { StreamlineStyle } from '@/render' 2 | import { 3 | determineDoRotateParticles, 4 | TrailParticleShape, 5 | type StreamlineVisualiser, 6 | type StreamlineVisualiserOptions, 7 | type TrailParticleOptions 8 | } from '@/visualiser' 9 | 10 | import waveCrestUrl from './wave.svg' 11 | 12 | export class VisualiserOptionsControl extends HTMLElement { 13 | private container: HTMLDivElement 14 | private visualiser: StreamlineVisualiser | null = null 15 | private onNumParticleChangeCallbacks: ((numParticles: number) => void)[] = [] 16 | private onOptionsChangeCallbacks: (( 17 | options: Partial 18 | ) => void)[] = [] 19 | 20 | constructor() { 21 | super() 22 | this.container = document.createElement('div') 23 | this.container.style.display = 'flex' 24 | this.container.style.flexDirection = 'column' 25 | this.container.style.rowGap = '10px' 26 | } 27 | 28 | connectedCallback(): void { 29 | const shadow = this.attachShadow({ mode: 'open' }) 30 | shadow.appendChild(this.container) 31 | } 32 | 33 | attachVisualiser(visualiser: StreamlineVisualiser): void { 34 | this.visualiser = visualiser 35 | 36 | this.initialiseControls() 37 | } 38 | 39 | async setOptions( 40 | numParticles?: number, 41 | options?: Partial 42 | ): Promise { 43 | if (!this.visualiser) return 44 | if (numParticles) { 45 | this.visualiser.setNumParticles(numParticles) 46 | this.onNumParticleChangeCallbacks.forEach(callback => 47 | callback(numParticles) 48 | ) 49 | } 50 | if (options) { 51 | await this.visualiser.updateOptions(options) 52 | this.onOptionsChangeCallbacks.forEach(callback => callback(options)) 53 | } 54 | } 55 | 56 | private initialiseControls() { 57 | const styleSelect = this.createStreamlineStyleSelectControl() 58 | const numParticlesControl = this.createNumParticlesControl() 59 | const particleSizeControl = this.createNumericOptionsControl( 60 | 'Particle size [pixels]', 61 | 'particleSize', 62 | 1 63 | ) 64 | const speedFactorControl = this.createNumericOptionsControl( 65 | 'Speed factor', 66 | 'speedFactor', 67 | 0.1 68 | ) 69 | const fadeAmountControl = this.createNumericOptionsControl( 70 | 'Fade amount per second', 71 | 'fadeAmountPerSecond', 72 | 1 73 | ) 74 | const maximumDisplacementControl = this.createNumericOptionsControl( 75 | 'Maximum displacement [pixels]', 76 | 'maxDisplacement', 77 | 1 78 | ) 79 | const maxAgeControl = this.createNumericOptionsControl( 80 | 'Maximum particle age [s]', 81 | 'maxAge', 82 | 0.1, 83 | 1 84 | ) 85 | const speedExponentControl = this.createNumericOptionsControl( 86 | 'Speed exponent', 87 | 'speedExponent', 88 | 0.1, 89 | 1 90 | ) 91 | const growthRateControl = this.createNumericOptionsControl( 92 | 'Growth rate [particle sizes/s]', 93 | 'growthRate', 94 | 1, 95 | 5 96 | ) 97 | const aspectRatioControl = this.createTrailParticleAspectRatioControl() 98 | const doRotateParticlesControl = this.createTrailParticlesDoRotateControl() 99 | const trailParticleShapeControl = this.createTrailParticleShapeControl( 100 | aspectRatioControl, 101 | doRotateParticlesControl 102 | ) 103 | 104 | this.container.appendChild(styleSelect) 105 | this.container.appendChild(numParticlesControl) 106 | this.container.appendChild(particleSizeControl) 107 | this.container.appendChild(speedFactorControl) 108 | this.container.appendChild(fadeAmountControl) 109 | this.container.appendChild(maximumDisplacementControl) 110 | this.container.appendChild(maxAgeControl) 111 | this.container.appendChild(speedExponentControl) 112 | this.container.appendChild(growthRateControl) 113 | this.container.appendChild(trailParticleShapeControl) 114 | this.container.appendChild(doRotateParticlesControl) 115 | this.container.appendChild(aspectRatioControl) 116 | 117 | // Optionally show a checkbox allow wave crests to be turned on and off. 118 | const allowWaveCrest = this.getAttribute('allow-wave-crest') !== null 119 | if (allowWaveCrest) { 120 | const waveCrestControl = this.createWaveCrestControl(false) 121 | this.container.appendChild(waveCrestControl) 122 | } 123 | } 124 | 125 | private createStreamlineStyleSelectControl(): HTMLSelectElement { 126 | if (!this.visualiser) throw new Error('No attached visualiser.') 127 | 128 | const select = document.createElement('select') 129 | const options = [ 130 | { 131 | title: 'Light particles on velocity magnitude', 132 | value: StreamlineStyle.LightParticlesOnMagnitude 133 | }, 134 | { 135 | title: 'Dark particles on velocity magnitude', 136 | value: StreamlineStyle.DarkParticlesOnMagnitude 137 | }, 138 | { 139 | title: 'Colored particles on velocity magnitude', 140 | value: StreamlineStyle.ColoredParticles 141 | }, 142 | { 143 | title: 'Magnitude colored particles', 144 | value: StreamlineStyle.MagnitudeColoredParticles 145 | } 146 | ] 147 | options.forEach(option => { 148 | const el = document.createElement('option') 149 | el.value = option.value.toString() 150 | el.textContent = option.title 151 | 152 | select.appendChild(el) 153 | }) 154 | 155 | select.value = this.visualiser.options.style.toString() 156 | select.addEventListener('input', () => { 157 | if (!this.visualiser) return 158 | const style = +select.value 159 | this.visualiser 160 | .updateOptions({ style }) 161 | .catch(error => 162 | console.error(`Failed to update visualiser options: ${error}`) 163 | ) 164 | }) 165 | 166 | this.onOptionsChangeCallbacks.push(options => { 167 | if (options.style) { 168 | select.value = options.style.toString() 169 | } 170 | }) 171 | 172 | return select 173 | } 174 | 175 | private createTrailParticleShapeControl( 176 | aspectRatioControl: HTMLLabelElement, 177 | doRotateParticlesControl: HTMLLabelElement 178 | ): HTMLSelectElement { 179 | if (!this.visualiser) throw new Error('No attached visualiser.') 180 | 181 | const select = document.createElement('select') 182 | const options = [ 183 | { 184 | title: 'Circle', 185 | value: TrailParticleShape.Circle 186 | }, 187 | { 188 | title: 'Rectangle', 189 | value: TrailParticleShape.Rectangle 190 | } 191 | ] 192 | options.forEach(option => { 193 | const el = document.createElement('option') 194 | el.value = option.value.toString() 195 | el.textContent = option.title 196 | 197 | select.appendChild(el) 198 | }) 199 | 200 | select.value = 201 | this.visualiser.options.trailParticleOptions?.shape ?? 202 | TrailParticleShape.Circle 203 | select.addEventListener('input', () => { 204 | if (!this.visualiser) return 205 | const shape = select.value as TrailParticleShape 206 | const aspectRatio = 207 | shape === TrailParticleShape.Circle 208 | ? undefined 209 | : this.visualiser.options.trailParticleOptions?.aspectRatio 210 | const doRotate = shape !== TrailParticleShape.Circle 211 | const trailParticleOptions: TrailParticleOptions = { 212 | aspectRatio, 213 | shape, 214 | doRotate 215 | } 216 | this.visualiser 217 | .updateOptions({ trailParticleOptions }) 218 | .catch(error => 219 | console.error(`Failed to update visualiser options: ${error}`) 220 | ) 221 | 222 | // Update "do rotate" checkbox. 223 | const input = doRotateParticlesControl.querySelector( 224 | 'input' 225 | ) as HTMLInputElement 226 | input.checked = determineDoRotateParticles(this.visualiser.options) 227 | 228 | // Hide aspect ratio control if we use circles. 229 | if (shape === TrailParticleShape.Circle) { 230 | aspectRatioControl.style.display = 'none' 231 | } else { 232 | aspectRatioControl.style.display = 'flex' 233 | } 234 | }) 235 | 236 | this.onOptionsChangeCallbacks.push(options => { 237 | if (options.trailParticleOptions?.shape) { 238 | select.value = options.trailParticleOptions?.shape 239 | } 240 | }) 241 | 242 | return select 243 | } 244 | 245 | private createTrailParticleAspectRatioControl(): HTMLLabelElement { 246 | if (!this.visualiser) throw new Error('No attached visualiser.') 247 | 248 | const setOption = (value: number) => { 249 | if (!this.visualiser) return 250 | const trailParticleOptions: TrailParticleOptions = { 251 | shape: 252 | this.visualiser.options.trailParticleOptions?.shape ?? 253 | TrailParticleShape.Circle, 254 | aspectRatio: value, 255 | doRotate: determineDoRotateParticles(this.visualiser.options) 256 | } 257 | this.visualiser 258 | .updateOptions({ trailParticleOptions }) 259 | .catch(error => 260 | console.error(`Failed to update visualiser options: ${error}`) 261 | ) 262 | } 263 | const [labelElement, inputElement] = this.createNumericInput( 264 | 'Trail particle aspect ratio', 265 | this.visualiser.options.trailParticleOptions?.aspectRatio ?? 1, 266 | 0.1, 267 | setOption 268 | ) 269 | 270 | this.onOptionsChangeCallbacks.push(options => { 271 | const aspectRatio = options.trailParticleOptions?.aspectRatio ?? 1 272 | inputElement.value = aspectRatio.toString() 273 | }) 274 | 275 | // Hide the control if we use circles. 276 | const shape = 277 | this.visualiser.options.trailParticleOptions?.shape ?? 278 | TrailParticleShape.Circle 279 | if (shape === TrailParticleShape.Circle) { 280 | labelElement.style.display = 'none' 281 | } 282 | 283 | return labelElement 284 | } 285 | 286 | private createTrailParticlesDoRotateControl(): HTMLLabelElement { 287 | if (!this.visualiser) throw new Error('No attached visualiser.') 288 | 289 | const el = document.createElement('label') 290 | el.textContent = 'Rotate particles with velocity field?' 291 | el.style.display = 'flex' 292 | el.style.justifyContent = 'space-between' 293 | el.style.columnGap = '10px' 294 | 295 | const input = document.createElement('input') 296 | input.type = 'checkbox' 297 | input.checked = determineDoRotateParticles(this.visualiser.options) 298 | 299 | el.appendChild(input) 300 | 301 | input.addEventListener('input', () => { 302 | if (!this.visualiser) return 303 | const trailParticleOptions: TrailParticleOptions = { 304 | shape: 305 | this.visualiser.options.trailParticleOptions?.shape ?? 306 | TrailParticleShape.Circle, 307 | aspectRatio: this.visualiser.options.trailParticleOptions?.aspectRatio, 308 | doRotate: input.checked 309 | } 310 | this.visualiser 311 | .updateOptions({ trailParticleOptions }) 312 | .catch(error => 313 | console.error(`Failed to update visualiser options: ${error}`) 314 | ) 315 | }) 316 | 317 | this.onOptionsChangeCallbacks.push(options => { 318 | input.checked = determineDoRotateParticles(options) 319 | }) 320 | 321 | return el 322 | } 323 | 324 | private createNumParticlesControl(): HTMLLabelElement { 325 | if (!this.visualiser) throw new Error('No attached visualiser.') 326 | 327 | const setNumParticles = (numParticles: number) => { 328 | if (!this.visualiser) return 329 | this.visualiser.setNumParticles(numParticles) 330 | } 331 | const [labelElement, inputElement] = this.createNumericInput( 332 | 'Number of particles', 333 | this.visualiser.numParticles, 334 | 1000, 335 | setNumParticles 336 | ) 337 | 338 | this.onNumParticleChangeCallbacks.push(numParticles => { 339 | inputElement.value = numParticles.toString() 340 | }) 341 | 342 | return labelElement 343 | } 344 | 345 | private createNumericOptionsControl( 346 | label: string, 347 | key: keyof Omit< 348 | StreamlineVisualiserOptions, 349 | 'style' | 'particleColor' | 'spriteUrl' | 'trailParticleOptions' 350 | >, 351 | step: number, 352 | defaultValue: number = 0 353 | ): HTMLLabelElement { 354 | if (!this.visualiser) throw new Error('No attached visualiser.') 355 | 356 | const setOption = (value: number) => { 357 | if (!this.visualiser) return 358 | this.visualiser 359 | .updateOptions({ [key]: value }) 360 | .catch(error => 361 | console.error(`Failed to update visualiser options: ${error}`) 362 | ) 363 | } 364 | const [labelElement, inputElement] = this.createNumericInput( 365 | label, 366 | this.visualiser.options[key] ?? defaultValue, 367 | step, 368 | setOption 369 | ) 370 | 371 | this.onOptionsChangeCallbacks.push(options => { 372 | const option = options[key] 373 | if (option !== undefined) { 374 | inputElement.value = options[key]!.toString() 375 | } 376 | }) 377 | 378 | return labelElement 379 | } 380 | 381 | private createWaveCrestControl(initialValue: boolean): HTMLLabelElement { 382 | if (!this.visualiser) throw new Error('No attached visualiser.') 383 | 384 | const el = document.createElement('label') 385 | el.textContent = 'Use wave crest particles' 386 | el.style.display = 'flex' 387 | el.style.justifyContent = 'space-between' 388 | el.style.columnGap = '10px' 389 | 390 | const input = document.createElement('input') 391 | input.type = 'checkbox' 392 | input.checked = initialValue 393 | 394 | input.addEventListener('input', () => { 395 | if (!this.visualiser) return 396 | this.visualiser 397 | .updateOptions({ 398 | spriteUrl: input.checked ? new URL(waveCrestUrl) : undefined 399 | }) 400 | .catch(error => 401 | console.error(`Failed to update visualiser options: ${error}`) 402 | ) 403 | }) 404 | 405 | this.onOptionsChangeCallbacks.push(options => { 406 | const useWaveCrests = options.spriteUrl !== undefined 407 | input.checked = useWaveCrests 408 | }) 409 | 410 | el.appendChild(input) 411 | return el 412 | } 413 | 414 | private createNumericInput( 415 | label: string, 416 | initialValue: number, 417 | step: number, 418 | callback: (value: number) => void 419 | ): [HTMLLabelElement, HTMLInputElement] { 420 | const el = document.createElement('label') 421 | el.textContent = label 422 | el.style.display = 'flex' 423 | el.style.justifyContent = 'space-between' 424 | el.style.columnGap = '10px' 425 | 426 | const input = document.createElement('input') 427 | input.type = 'number' 428 | input.step = step.toString() 429 | 430 | input.value = initialValue.toString() 431 | input.addEventListener('input', () => 432 | this.setNumberIfValid(input.value, callback) 433 | ) 434 | 435 | el.appendChild(input) 436 | return [el, input] 437 | } 438 | 439 | private setNumberIfValid( 440 | value: string, 441 | callback: (value: number) => void 442 | ): void { 443 | const numericValue = parseFloat(value) 444 | if (isNaN(numericValue)) return 445 | callback(numericValue) 446 | } 447 | } 448 | 449 | customElements.define('visualiser-options-control', VisualiserOptionsControl) 450 | -------------------------------------------------------------------------------- /src/layer.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash-es' 2 | import { 3 | type CustomLayerInterface, 4 | LngLat, 5 | LngLatBounds, 6 | type Map, 7 | MercatorCoordinate 8 | } from 'maplibre-gl' 9 | import { 10 | type BoundingBoxScaling, 11 | type StreamlineVisualiserOptions, 12 | type TrailParticleOptions, 13 | StreamlineStyle, 14 | StreamlineVisualiser, 15 | fetchWMSAvailableTimesAndElevations, 16 | fetchWMSColormap, 17 | fetchWMSVelocityField 18 | } from '.' 19 | import type { TransformRequestFunction } from '@/utils/wms' 20 | 21 | export interface WMSStreamlineLayerOptions { 22 | baseUrl: string 23 | layer: string 24 | style?: string 25 | useDisplayUnits?: boolean 26 | useLastValue?: boolean 27 | streamlineStyle: StreamlineStyle 28 | numParticles: number 29 | particleSize: number 30 | speedFactor: number 31 | fadeAmountPerSecond: number 32 | downsampleFactorWMS?: number 33 | maxAge?: number 34 | growthRate?: number 35 | speedExponent?: number 36 | particleColor?: string 37 | spriteUrl?: URL 38 | trailParticleOptions?: TrailParticleOptions 39 | transformRequest?: TransformRequestFunction 40 | } 41 | 42 | function convertMapBoundsToEpsg3857BoundingBox( 43 | bounds: LngLatBounds 44 | ): [number, number, number, number] { 45 | // Converts weird normalised EPSG:3857 to actual EPSG:3857. 46 | const toMercator = (coords: LngLat): [number, number] => { 47 | // TODO: get magic number from Maplibre somehow; mercator 48 | const mercatorWidth = 2 * 20037508.34 49 | const mercNorm = MercatorCoordinate.fromLngLat(coords) 50 | const x = (mercNorm.x - 0.5) * mercatorWidth 51 | const y = (0.5 - mercNorm.y) * mercatorWidth 52 | return [x, y] 53 | } 54 | const [xSW, ySW] = toMercator(bounds.getSouthWest()) 55 | const [xNE, yNE] = toMercator(bounds.getNorthEast()) 56 | 57 | return [xSW, ySW, xNE, yNE] 58 | } 59 | 60 | export class WMSStreamlineLayer implements CustomLayerInterface { 61 | private static readonly MAX_PARTICLE_DISPLACEMENT = 1 62 | 63 | public readonly renderingMode = '2d' 64 | public readonly type = 'custom' 65 | 66 | private _id: string 67 | 68 | private map: Map | null 69 | private gl: WebGL2RenderingContext | null 70 | 71 | private options: WMSStreamlineLayerOptions 72 | private _visualiser: StreamlineVisualiser | null 73 | private previousFrameTime: DOMHighResTimeStamp | null 74 | 75 | private boundingBoxWMS: [number, number, number, number] | null 76 | 77 | private times: string[] 78 | private elevationBounds: [number, number] | null 79 | 80 | private timeIndex: number 81 | private elevation: number | null 82 | private colorScaleRange: [number, number] | null 83 | 84 | private isInitialised: boolean 85 | private abortController: AbortController 86 | 87 | private onLayerAdd: (() => void) | null 88 | private onStartLoading: (() => void) | null 89 | private onEndLoading: (() => void) | null 90 | // Pause rendering during map resizes; rendering will be continued by the 91 | // newly fetched velocity field. 92 | private onResizeStart = () => this._visualiser?.stop() 93 | // Map moveend events are fired during resize animations, so we debounce the 94 | // callback to prevent too many velocity field updates from happening. 95 | private debouncedOnMapMoveEnd = debounce(() => this.onMapMoveEnd(), 100) 96 | private onMapMoveStart = () => this.debouncedOnMapMoveEnd.cancel() 97 | 98 | constructor(id: string, options: WMSStreamlineLayerOptions) { 99 | this._id = id 100 | 101 | this.map = null 102 | this.gl = null 103 | 104 | this.options = options 105 | this._visualiser = null 106 | this.previousFrameTime = null 107 | 108 | this.boundingBoxWMS = null 109 | 110 | this.times = [] 111 | this.elevationBounds = null 112 | 113 | this.timeIndex = 0 114 | this.elevation = null 115 | this.colorScaleRange = null 116 | 117 | this.isInitialised = false 118 | this.abortController = new AbortController() 119 | 120 | this.onLayerAdd = null 121 | this.onStartLoading = null 122 | this.onEndLoading = null 123 | } 124 | 125 | get id(): string { 126 | return this._id 127 | } 128 | 129 | get visualiser(): StreamlineVisualiser | null { 130 | return this._visualiser 131 | } 132 | 133 | private get signal(): AbortSignal { 134 | return this.abortController.signal 135 | } 136 | 137 | private get time(): string { 138 | if (this.times.length === 0) { 139 | throw new Error('No available times.') 140 | } 141 | const time = this.times[this.timeIndex] 142 | if (!time) { 143 | throw new Error( 144 | `Requested time index ${this.timeIndex} out of range; only ${this.times.length} times available.` 145 | ) 146 | } 147 | return time 148 | } 149 | 150 | private get size(): [number, number] { 151 | if (!this.gl) throw new Error('Not initialised.') 152 | const width = this.gl.drawingBufferWidth 153 | const height = this.gl.drawingBufferHeight 154 | return [width, height] 155 | } 156 | 157 | onAdd(map: Map, gl: WebGL2RenderingContext) { 158 | this.map = map 159 | this.gl = gl 160 | 161 | this._visualiser = this.createVisualiser(gl, this.options) 162 | 163 | this.times = [] 164 | this.elevationBounds = null 165 | 166 | this.timeIndex = 0 167 | this.elevation = null 168 | this.colorScaleRange = null 169 | 170 | if (this.onLayerAdd) { 171 | this.onLayerAdd() 172 | this.onLayerAdd = null 173 | } 174 | } 175 | 176 | onRemove(): void { 177 | // Abort any ongoing updates to the layer. This prevents map event listeners 178 | // from being set after the layer has been removed from the map. 179 | this.abortController.abort() 180 | this.map 181 | ?.off('resize', this.onResizeStart) 182 | .off('movestart', this.onMapMoveStart) 183 | .off('moveend', this.debouncedOnMapMoveEnd) 184 | this._visualiser?.destruct() 185 | this._visualiser = null 186 | this.previousFrameTime = null 187 | } 188 | 189 | render(): void { 190 | if (!this.map || !this.boundingBoxWMS || !this._visualiser) { 191 | return 192 | } 193 | 194 | const [xSWCur, ySWCur, xNECur, yNECur] = 195 | convertMapBoundsToEpsg3857BoundingBox(this.map.getBounds()) 196 | const [xSWWMS, ySWWMS, xNEWMS, yNEWMS] = this.boundingBoxWMS 197 | 198 | // Compute offset and scale of the new bounding box compared to the old one. 199 | // This is used to determine where to render the streamline visualisation in 200 | // clip coordinates. 201 | const widthWMS = xNEWMS - xSWWMS 202 | const widthCur = xNECur - xSWCur 203 | const heightWMS = yNEWMS - ySWWMS 204 | const heightCur = yNECur - ySWCur 205 | 206 | // Compute the offset based on the centre of the bounding box. 207 | const xCentreCur = 0.5 * (xSWCur + xNECur) 208 | const yCentreCur = 0.5 * (ySWCur + yNECur) 209 | const xCentreWMS = 0.5 * (xSWWMS + xNEWMS) 210 | const yCentreWMS = 0.5 * (ySWWMS + yNEWMS) 211 | 212 | const scaling: BoundingBoxScaling = { 213 | scaleX: widthWMS / widthCur, 214 | scaleY: heightWMS / heightCur, 215 | offsetX: (-2 * (xCentreCur - xCentreWMS)) / widthCur, 216 | offsetY: (-2 * (yCentreCur - yCentreWMS)) / heightCur 217 | } 218 | this._visualiser?.setScaling(scaling) 219 | 220 | // Determine time elapsed between this frame and the previous frame. 221 | const now = performance.now() 222 | const dt = this.previousFrameTime 223 | ? (now - this.previousFrameTime) / 1000 224 | : 1 / 60 225 | this.previousFrameTime = now 226 | 227 | // Render the streamline visualisation. 228 | this._visualiser?.renderFrame(dt) 229 | 230 | // Request a new frame from Maplibre, apparently (surprising API...). 231 | this.map.triggerRepaint() 232 | } 233 | 234 | once(_: 'add', callback: () => void): void { 235 | this.onLayerAdd = callback 236 | } 237 | 238 | on(event: 'start-loading' | 'end-loading', callback: () => void): void { 239 | if (event === 'start-loading') { 240 | this.onStartLoading = callback 241 | } else if (event === 'end-loading') { 242 | this.onEndLoading = callback 243 | } 244 | } 245 | 246 | async waitForInitialisation(signal?: AbortSignal): Promise { 247 | return new Promise(resolve => { 248 | const checkInitialisation = () => { 249 | if (this.isInitialised) return resolve(true) 250 | // The layer may have been removed; fetches have been aborted so we will 251 | // never be initialised. 252 | if (this.signal.aborted) return resolve(false) 253 | // If we have an abort signal, waiting for initialisation may have been 254 | // aborted. 255 | if (signal?.aborted) return resolve(false) 256 | 257 | // If the layer is not yet initialised or aborted, wait a bit and check 258 | // again. 259 | window.setTimeout(checkInitialisation, 50) 260 | } 261 | checkInitialisation() 262 | }) 263 | } 264 | 265 | async initialise( 266 | time?: Date, 267 | elevation?: number, 268 | colorScaleRange?: [number, number] 269 | ): Promise { 270 | if (!this._visualiser || !this.map) throw new Error('Not added to a map.') 271 | 272 | // Fetch colormap and use it to initialise the visualiser. 273 | const colormap = await fetchWMSColormap( 274 | this.options.baseUrl, 275 | this.options.layer, 276 | colorScaleRange, 277 | this.signal, 278 | this.options.transformRequest 279 | ) 280 | 281 | // Fetch available WMS times and elevations. 282 | const response = await fetchWMSAvailableTimesAndElevations( 283 | this.options.baseUrl, 284 | this.options.layer, 285 | this.signal, 286 | this.options.transformRequest 287 | ) 288 | 289 | this.times = response.times 290 | this.elevationBounds = response.elevationBounds 291 | 292 | this.timeIndex = time ? this.findTimeIndex(time) : 0 293 | this.elevation = elevation ?? null 294 | this.colorScaleRange = colorScaleRange ?? null 295 | 296 | // Initialise and fetch first velocity field; this will also enable 297 | // rendering. 298 | await this._visualiser.initialise(colormap) 299 | await this.updateVelocityField(true) 300 | 301 | // Register event listeners for map changes. This will also be called when 302 | // the map is resized. Make sure we do not add the listener if we have 303 | // already aborted any requests because the layer is being removed. 304 | if (this.signal.aborted) return 305 | this.map.on('resize', this.onResizeStart) 306 | this.map.on('movestart', this.onMapMoveStart) 307 | this.map.on('moveend', this.debouncedOnMapMoveEnd) 308 | 309 | this.isInitialised = true 310 | 311 | // Request a repaint to ensure we see the velocity field. 312 | this.map.triggerRepaint() 313 | } 314 | 315 | async setWmsLayer(baseUrl: string, layer: string): Promise { 316 | this.options.baseUrl = baseUrl 317 | this.options.layer = layer 318 | await this.initialise() 319 | } 320 | 321 | async setStyle(style: string): Promise { 322 | this.options.style = style 323 | await this.updateVelocityField(false) 324 | } 325 | 326 | async setTime(time: Date): Promise { 327 | await this.setTimeIndex(this.findTimeIndex(time)) 328 | } 329 | 330 | async setTimeIndex(index: number): Promise { 331 | // No change, do not update. 332 | if (index === this.timeIndex) return 333 | 334 | if (index < 0 || index > this.times.length - 1) { 335 | throw new Error('Invalid time index.') 336 | } 337 | this.timeIndex = index 338 | // The velocity field update is abortable. 339 | await this.updateVelocityField(true) 340 | } 341 | 342 | async setElevation(elevation: number | null): Promise { 343 | // No change, do not update. 344 | if (elevation === this.elevation) return 345 | 346 | if (elevation === null) { 347 | this.elevation = null 348 | } else { 349 | if ( 350 | this.elevationBounds === null || 351 | elevation < this.elevationBounds[0] || 352 | elevation > this.elevationBounds[1] 353 | ) { 354 | throw new Error('Invalid elevation.') 355 | } 356 | this.elevation = elevation 357 | } 358 | // The velocity field update is abortable. 359 | await this.updateVelocityField(true) 360 | } 361 | 362 | async setColorScaleRange( 363 | colorScaleRange: [number, number] | null 364 | ): Promise { 365 | // No change, do not update. 366 | if (colorScaleRange === null && this.colorScaleRange === null) return 367 | if ( 368 | colorScaleRange !== null && 369 | this.colorScaleRange !== null && 370 | colorScaleRange[0] === this.colorScaleRange[0] && 371 | colorScaleRange[1] === this.colorScaleRange[1] 372 | ) { 373 | return 374 | } 375 | 376 | this.colorScaleRange = colorScaleRange 377 | 378 | // Update colormap and velocity field for new color scale range. 379 | const colormap = await fetchWMSColormap( 380 | this.options.baseUrl, 381 | this.options.layer, 382 | colorScaleRange ?? undefined, 383 | this.signal, 384 | this.options.transformRequest 385 | ) 386 | this._visualiser?.setColorMap(colormap) 387 | 388 | // Note that we do not need a velocity update, since the TIFF response from 389 | // the WMS server does not depend on the color scale range. 390 | } 391 | 392 | setNumParticles(numParticles: number): void { 393 | this._visualiser?.setNumParticles(numParticles) 394 | } 395 | 396 | async setVisualiserOptions( 397 | options: Partial 398 | ): Promise { 399 | await this._visualiser?.updateOptions(options) 400 | } 401 | 402 | async setDisplayUnits(useDisplayUnits: boolean | undefined): Promise { 403 | // No change, do not update. 404 | if (useDisplayUnits === this.options.useDisplayUnits) return 405 | 406 | this.options.useDisplayUnits = useDisplayUnits 407 | 408 | await this.updateVelocityField(false) 409 | } 410 | 411 | async setUseLastValue(useLastValue: boolean): Promise { 412 | // No change, do not update. 413 | if (useLastValue === this.options.useLastValue) return 414 | 415 | this.options.useLastValue = useLastValue 416 | 417 | await this.updateVelocityField(false) 418 | } 419 | 420 | private createVisualiser( 421 | gl: WebGL2RenderingContext, 422 | options: WMSStreamlineLayerOptions 423 | ): StreamlineVisualiser { 424 | if (!this.map) throw new Error('Not initialised.') 425 | 426 | const visualiserOptions = 427 | WMSStreamlineLayer.getVisualiserOptionsFromLayerOptions(options) 428 | const [width, height] = this.size 429 | return new StreamlineVisualiser( 430 | gl, 431 | width, 432 | height, 433 | this.options.numParticles, 434 | visualiserOptions 435 | ) 436 | } 437 | 438 | private onMapMoveEnd(): void { 439 | const doResetParticles = true 440 | this.updateVelocityField(doResetParticles).catch(() => 441 | console.error('Failed to update velocity field.') 442 | ) 443 | } 444 | 445 | private async updateVelocityField(doResetParticles: boolean): Promise { 446 | if (!this.map) throw new Error('Not added to a map') 447 | if (this.map.isMoving()) { 448 | // Will be called again when the map stops moving. 449 | return 450 | } 451 | 452 | if (this.onStartLoading) this.onStartLoading() 453 | 454 | // Update the canvas size and dimensions for the visualiser. This is no-op 455 | // if the size has not changed. 456 | const [width, height] = this.size 457 | this._visualiser?.setDimensions(width, height) 458 | // Restart animation after setting the dimensions, so we can still show 459 | // some animation after resizing the canvas, with the old velocity field. 460 | this._visualiser?.start() 461 | 462 | // Make sure to get the bounds before we start the long wait for the WMS 463 | // layer, since the user may have moved the map while this fetch is 464 | // happening; this would cause the newly fetched WMS image to be placed at 465 | // the wrong coordinates. 466 | // The FEWS Web Mapping Service cannot handle bounding boxes larger than a 467 | // single earth, so we restrict ourselves to just the one earth. We also 468 | // need to reduce the width of the requested image by the appropriate 469 | // amount, as this size is not respected by FEWS WMS if the aspect ratio is 470 | // not OK. 471 | let factorWidth = 1 472 | let bounds = this.map.getBounds() 473 | const range = bounds.getEast() - bounds.getWest() 474 | if (range > 360) { 475 | factorWidth = 360 / range 476 | bounds = new LngLatBounds( 477 | new LngLat(0, bounds.getSouth()), 478 | new LngLat(360, bounds.getNorth()) 479 | ) 480 | } 481 | const boundingBox = convertMapBoundsToEpsg3857BoundingBox(bounds) 482 | 483 | const downsampleDimension = (length: number) => { 484 | const divisor = this.options.downsampleFactorWMS ?? 1 485 | return Math.round(length / divisor) 486 | } 487 | const widthWMS = downsampleDimension(factorWidth * width) 488 | const heightWMS = downsampleDimension(height) 489 | 490 | try { 491 | const velocityImage = await fetchWMSVelocityField( 492 | this.options.baseUrl, 493 | this.options.layer, 494 | this.time, 495 | boundingBox, 496 | widthWMS, 497 | heightWMS, 498 | this.options.style, 499 | this.options.useDisplayUnits, 500 | this.options.useLastValue, 501 | this.elevation ?? undefined, 502 | this.signal, 503 | this.options.transformRequest 504 | ) 505 | this._visualiser?.setVelocityImage(velocityImage, doResetParticles) 506 | } catch (error) { 507 | // No error message is necessary if the promise gets rejected due to an 508 | // abort. 509 | if (!this.signal.aborted) { 510 | const errorString = (error as Error).toString() 511 | console.error( 512 | `Failed to fetch WMS velocity field, or received empty image: ${errorString}.` 513 | ) 514 | } 515 | this._visualiser?.stop() 516 | this.boundingBoxWMS = null 517 | return 518 | } 519 | 520 | this._visualiser?.start() 521 | this.boundingBoxWMS = boundingBox 522 | 523 | // Request a repaint from Maplibre so we (re)start the animation. 524 | this.map.triggerRepaint() 525 | 526 | if (this.onEndLoading) this.onEndLoading() 527 | } 528 | 529 | private findTimeIndex(time: Date): number { 530 | // Find the closest date to the requested date. 531 | const timestamps = this.times.map(cur => new Date(cur).getTime()) 532 | const timestamp = time.getTime() 533 | const diffs = timestamps.map(cur => Math.abs(timestamp - cur)) 534 | const minDiff = Math.min(...diffs) 535 | return diffs.findIndex(diff => diff == minDiff) ?? 0 536 | } 537 | 538 | private static getVisualiserOptionsFromLayerOptions( 539 | options: WMSStreamlineLayerOptions 540 | ): StreamlineVisualiserOptions { 541 | return { 542 | style: options.streamlineStyle, 543 | particleSize: options.particleSize, 544 | speedFactor: options.speedFactor, 545 | fadeAmountPerSecond: options.fadeAmountPerSecond, 546 | maxDisplacement: WMSStreamlineLayer.MAX_PARTICLE_DISPLACEMENT, 547 | maxAge: options.maxAge ?? 1.0, 548 | growthRate: options.growthRate, 549 | speedExponent: options.speedExponent, 550 | particleColor: options.particleColor, 551 | spriteUrl: options.spriteUrl, 552 | trailParticleOptions: options.trailParticleOptions 553 | } 554 | } 555 | } 556 | -------------------------------------------------------------------------------- /src/visualiser.ts: -------------------------------------------------------------------------------- 1 | import particleVertexShaderSource from './shaders/particle.vert.glsl' 2 | import renderVertexShaderSource from './shaders/render.vert.glsl' 3 | import textureVertexShaderSource from './shaders/texture.vert.glsl' 4 | import finalVertexShaderSource from './shaders/final.vert.glsl' 5 | 6 | import placeholderFragmentShaderSource from './shaders/placeholder.frag.glsl' 7 | import renderFragmentShaderSource from './shaders/render.frag.glsl' 8 | import textureFragmentShaderSource from './shaders/texture.frag.glsl' 9 | import finalFragmentShaderSource from './shaders/final.frag.glsl' 10 | 11 | import { ShaderProgram } from './utils/shader-program' 12 | import { SpeedCurve } from './utils/speedcurve' 13 | import { createTexture } from './utils/textures' 14 | import { 15 | StreamlineStyle, 16 | FinalRenderer, 17 | ParticlePropagator, 18 | ParticleRenderer, 19 | TextureRenderer 20 | } from './render' 21 | import { VelocityImage } from './utils/wms' 22 | import { Colormap } from './utils/colormap' 23 | import type { BoundingBoxScaling } from './render/final' 24 | import { FragmentShader, VertexShader } from './utils/shader' 25 | 26 | export enum TrailParticleShape { 27 | Circle = 'circle', 28 | Rectangle = 'rectangle' 29 | } 30 | 31 | export interface TrailParticleOptions { 32 | shape: TrailParticleShape 33 | aspectRatio?: number 34 | doRotate?: boolean 35 | } 36 | 37 | export interface StreamlineVisualiserOptions { 38 | style: StreamlineStyle 39 | particleSize: number 40 | speedFactor: number 41 | fadeAmountPerSecond: number 42 | maxDisplacement: number 43 | maxAge: number 44 | growthRate?: number 45 | speedExponent?: number 46 | particleColor?: string 47 | spriteUrl?: URL 48 | trailParticleOptions?: TrailParticleOptions 49 | } 50 | 51 | export function determineDoRotateParticles( 52 | options: Partial 53 | ): boolean { 54 | const configuredDoRotate = options.trailParticleOptions?.doRotate 55 | if (configuredDoRotate !== undefined) { 56 | return configuredDoRotate 57 | } 58 | const shape = options.trailParticleOptions?.shape ?? TrailParticleShape.Circle 59 | return shape !== TrailParticleShape.Circle 60 | } 61 | 62 | export class StreamlineVisualiser { 63 | private readonly MAX_NUM_SUBSTEPS = 32 64 | private readonly DEFAULT_GROWTH_RATE = 5 65 | 66 | private gl: WebGL2RenderingContext 67 | private width: number 68 | private height: number 69 | private isRendering: boolean 70 | private _numParticles: number 71 | private programRenderParticles: ShaderProgram | null 72 | private _options: StreamlineVisualiserOptions 73 | 74 | private textureRenderer: TextureRenderer | null 75 | private particlePropagator: ParticlePropagator | null 76 | private particleRenderer: ParticleRenderer | null 77 | private finalRenderer: FinalRenderer | null 78 | private spriteRenderer: ParticleRenderer | null 79 | 80 | private scaling: BoundingBoxScaling 81 | private previousParticleTexture: WebGLTexture | null 82 | private currentParticleTexture: WebGLTexture | null 83 | private velocityImage: VelocityImage | null 84 | private colorMap: Colormap | null 85 | private dtMin: number 86 | 87 | constructor( 88 | gl: WebGL2RenderingContext, 89 | width: number, 90 | height: number, 91 | numParticles: number, 92 | options: StreamlineVisualiserOptions 93 | ) { 94 | this.gl = gl 95 | this.width = width 96 | this.height = height 97 | this.isRendering = false 98 | this._numParticles = numParticles 99 | this.programRenderParticles = null 100 | this._options = { ...options } 101 | 102 | this.textureRenderer = null 103 | this.particlePropagator = null 104 | this.particleRenderer = null 105 | this.finalRenderer = null 106 | this.spriteRenderer = null 107 | 108 | this.scaling = { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 } 109 | this.previousParticleTexture = null 110 | this.currentParticleTexture = null 111 | this.velocityImage = null 112 | this.colorMap = null 113 | this.dtMin = 0 114 | } 115 | 116 | // Compute optimal particle data texture width/height; there are limits to 117 | // the size of each dimensions of a texture, so to support an acceptable 118 | // number of particles, we need to store them in a 2D texture, instead of 119 | // a simple 1D texture. 120 | private get widthParticleDataTexture(): number { 121 | return Math.ceil(Math.sqrt(this._numParticles)) 122 | } 123 | private get heightParticleDataTexture(): number { 124 | return this.widthParticleDataTexture 125 | } 126 | 127 | private get numParticlesAllocate(): number { 128 | return this.widthParticleDataTexture * this.heightParticleDataTexture 129 | } 130 | 131 | private get particleTextureSize(): number { 132 | // Final particle size in pixels depends on the display scaling, so take 133 | // this into account to create the particle texture. 134 | const scalingFactor = window.devicePixelRatio ?? 1 135 | return Math.ceil(2 * scalingFactor * this.options.particleSize) 136 | } 137 | 138 | get isInitialised(): boolean { 139 | return ( 140 | this.particlePropagator !== null && 141 | this.particleRenderer !== null && 142 | this.finalRenderer !== null 143 | ) 144 | } 145 | 146 | get options(): StreamlineVisualiserOptions { 147 | return { ...this._options } 148 | } 149 | 150 | get numParticles(): number { 151 | return this._numParticles 152 | } 153 | 154 | async initialise(colormap: Colormap): Promise { 155 | this.colorMap = colormap 156 | 157 | const [ 158 | programUpdateParticles, 159 | programRenderParticles, 160 | programRenderTexture, 161 | programRenderFinal 162 | ] = await this.compileShaderPrograms() 163 | 164 | // Store the particle renderer program for when we want to create a sprite 165 | // renderer when the options change. 166 | this.programRenderParticles = programRenderParticles 167 | 168 | // Create a texture to use as the particle sprite. 169 | const particleTexture = this.createParticleTexture() 170 | 171 | // Compute speed curve based on the colormap and options. 172 | const speedCurve = StreamlineVisualiser.computeSpeedCurve( 173 | colormap, 174 | this._options 175 | ) 176 | 177 | // Create and the renderers for the different stages of the visualisation. 178 | this.textureRenderer = new TextureRenderer(programRenderTexture) 179 | this.particlePropagator = new ParticlePropagator( 180 | programUpdateParticles, 181 | this.width, 182 | this.height, 183 | this._numParticles, 184 | this.numParticlesAllocate, 185 | this._options.maxAge, 186 | speedCurve 187 | ) 188 | 189 | this.particleRenderer = new ParticleRenderer( 190 | programRenderParticles, 191 | this.width, 192 | this.height, 193 | this._numParticles, 194 | this._options.particleSize, 195 | particleTexture, 196 | this.widthParticleDataTexture, 197 | this.heightParticleDataTexture, 198 | false, 199 | this._options.maxAge, 200 | this._options.growthRate ?? this.DEFAULT_GROWTH_RATE, 201 | determineDoRotateParticles(this._options) 202 | ) 203 | this.finalRenderer = new FinalRenderer( 204 | programRenderFinal, 205 | this._options.style, 206 | colormap 207 | ) 208 | 209 | this.textureRenderer.initialise() 210 | this.particlePropagator.initialise() 211 | this.particleRenderer.initialise() 212 | this.finalRenderer.initialise() 213 | 214 | // If we have a sprite URL, also create a sprite renderer to draw it over 215 | // the particle trail at the end of rendering every frame. 216 | if (this.options.spriteUrl) { 217 | const spriteTexture = await this.createSpriteTexture() 218 | this.spriteRenderer = new ParticleRenderer( 219 | programRenderParticles, 220 | this.width, 221 | this.height, 222 | this._numParticles, 223 | this._options.particleSize, 224 | spriteTexture, 225 | this.widthParticleDataTexture, 226 | this.heightParticleDataTexture, 227 | true, 228 | this._options.maxAge, 229 | this._options.growthRate ?? this.DEFAULT_GROWTH_RATE, 230 | true 231 | ) 232 | this.spriteRenderer.initialise() 233 | } 234 | 235 | // Create textures to render the particle trails into, and associate them 236 | // with the texture renderer's frame buffer. 237 | this.previousParticleTexture = this.createZeroTexture() 238 | this.currentParticleTexture = this.createZeroTexture() 239 | this.textureRenderer.resetParticleTextures( 240 | this.previousParticleTexture, 241 | this.currentParticleTexture 242 | ) 243 | } 244 | 245 | start(): void { 246 | if (!this.isInitialised) { 247 | throw new Error('Cannot start rendering for uninitialised visualiser.') 248 | } 249 | this.isRendering = true 250 | } 251 | 252 | stop(): void { 253 | this.isRendering = false 254 | } 255 | 256 | destruct(): void { 257 | if (this.textureRenderer) this.textureRenderer.destruct() 258 | if (this.particlePropagator) this.particlePropagator.destruct() 259 | if (this.particleRenderer) this.particleRenderer.destruct() 260 | if (this.finalRenderer) this.finalRenderer.destruct() 261 | this.gl.deleteTexture(this.previousParticleTexture) 262 | this.gl.deleteTexture(this.currentParticleTexture) 263 | } 264 | 265 | setScaling(scaling: BoundingBoxScaling): void { 266 | this.scaling = scaling 267 | } 268 | 269 | setDimensions(width: number, height: number): void { 270 | if (!this.particlePropagator || !this.particleRenderer) { 271 | throw new Error('Cannot set dimensions for uninitialised visualiser.') 272 | } 273 | if (this.width === width && this.height === height) return 274 | 275 | this.width = width 276 | this.height = height 277 | 278 | this.previousParticleTexture = this.createZeroTexture() 279 | this.currentParticleTexture = this.createZeroTexture() 280 | 281 | this.particlePropagator.setDimensions(width, height) 282 | this.particleRenderer.setDimensions(width, height) 283 | if (this.spriteRenderer) { 284 | this.spriteRenderer.setDimensions(width, height) 285 | } 286 | 287 | if (this.velocityImage) { 288 | // We need to recompute the time step because our pixel size has changed. 289 | this.dtMin = this.computeMinimumTimeStep() 290 | } 291 | } 292 | 293 | setNumParticles(numParticles: number): void { 294 | if (!this.particlePropagator || !this.particleRenderer) { 295 | throw new Error( 296 | 'Cannot set number of particles for uninitialised visualiser.' 297 | ) 298 | } 299 | if (this._numParticles === numParticles) return 300 | 301 | this.resetParticles() 302 | 303 | this._numParticles = numParticles 304 | 305 | this.particlePropagator.setNumParticles( 306 | this._numParticles, 307 | this.numParticlesAllocate 308 | ) 309 | this.particleRenderer.setNumParticles( 310 | this._numParticles, 311 | this.widthParticleDataTexture, 312 | this.heightParticleDataTexture 313 | ) 314 | if (this.spriteRenderer) { 315 | this.spriteRenderer.setNumParticles( 316 | this._numParticles, 317 | this.widthParticleDataTexture, 318 | this.heightParticleDataTexture 319 | ) 320 | } 321 | } 322 | 323 | setColorMap(colorMap: Colormap): void { 324 | if (!this.finalRenderer || !this.particlePropagator) { 325 | throw new Error('Cannot set colormap for uninitialised visualiser.') 326 | } 327 | this.colorMap = colorMap 328 | this.finalRenderer.setColorMap(this.colorMap) 329 | 330 | // Update the speed curve from the new colormap. 331 | const curve = StreamlineVisualiser.computeSpeedCurve( 332 | colorMap, 333 | this._options 334 | ) 335 | this.particlePropagator.setSpeedCurve(curve) 336 | } 337 | 338 | setVelocityImage( 339 | velocityImage: VelocityImage, 340 | doResetParticles: boolean 341 | ): void { 342 | if (doResetParticles) this.resetParticles() 343 | this.updateVelocityImage(velocityImage) 344 | } 345 | 346 | async updateOptions(options: Partial) { 347 | if ( 348 | !this.colorMap || 349 | !this.particlePropagator || 350 | !this.particleRenderer || 351 | !this.finalRenderer 352 | ) { 353 | throw new Error('Cannot update options for an uninitialised visualiser.') 354 | } 355 | this._options = { ...this._options, ...options } 356 | 357 | if (this.spriteRenderer === null && this._options.spriteUrl !== undefined) { 358 | // Create new sprite renderer. 359 | if (!this.programRenderParticles) { 360 | throw new Error('Shaders were not compiled before changing options.') 361 | } 362 | 363 | const spriteTexture = await this.createSpriteTexture() 364 | this.spriteRenderer = new ParticleRenderer( 365 | this.programRenderParticles, 366 | this.width, 367 | this.height, 368 | this._numParticles, 369 | this._options.particleSize, 370 | spriteTexture, 371 | this.widthParticleDataTexture, 372 | this.heightParticleDataTexture, 373 | true, 374 | this._options.maxAge, 375 | this._options.growthRate ?? this.DEFAULT_GROWTH_RATE, 376 | true 377 | ) 378 | this.spriteRenderer.initialise() 379 | } else if ( 380 | this.spriteRenderer !== null && 381 | this._options.spriteUrl === undefined 382 | ) { 383 | // Remove now-unused sprite renderer. 384 | this.spriteRenderer.destruct(false) 385 | this.spriteRenderer = null 386 | } 387 | 388 | if (this.velocityImage) { 389 | // Use the old minimum time step to compute the new one based on the change 390 | // in maximum displacement. 391 | this.dtMin = this.computeMinimumTimeStep() 392 | } 393 | 394 | this.particlePropagator.setMaxAge(this._options.maxAge) 395 | this.particleRenderer.setMaxAge(this._options.maxAge) 396 | if (this.spriteRenderer) { 397 | this.spriteRenderer.setMaxAge(this._options.maxAge) 398 | } 399 | 400 | const curve = StreamlineVisualiser.computeSpeedCurve( 401 | this.colorMap, 402 | this._options 403 | ) 404 | this.particlePropagator.setSpeedCurve(curve) 405 | 406 | const growthRate = this._options.growthRate ?? this.DEFAULT_GROWTH_RATE 407 | this.particleRenderer.particleSize = this._options.particleSize 408 | this.particleRenderer.growthRate = growthRate 409 | 410 | if (this.spriteRenderer) { 411 | this.spriteRenderer.particleSize = this._options.particleSize 412 | this.spriteRenderer.growthRate = growthRate 413 | } 414 | 415 | this.finalRenderer.style = this._options.style 416 | 417 | const particleTexture = this.createParticleTexture() 418 | this.particleRenderer.setParticleTexture(particleTexture) 419 | 420 | const doRotateParticles = determineDoRotateParticles(this._options) 421 | this.particleRenderer.setDoRotateParticles(doRotateParticles) 422 | } 423 | 424 | renderFrame(dt: number) { 425 | // Return immediately if we are not rendering. 426 | if (!this.isRendering) return 427 | if ( 428 | !this.textureRenderer || 429 | !this.particlePropagator || 430 | !this.particleRenderer || 431 | !this.finalRenderer || 432 | !this.previousParticleTexture || 433 | !this.currentParticleTexture 434 | ) { 435 | throw new Error( 436 | 'Visualiser was not initialised before attempting to render frame.' 437 | ) 438 | } 439 | 440 | // Large time steps (which may occur e.g. when the window loses focus) will 441 | // result in undesirable synchronisation of the particle ages: all particles 442 | // will die and be reborn simultaneously because they suddenly all exceed 443 | // their maximum age. If we have a time step larger than 10% of the maximum 444 | // age, regenerate particles. 445 | if (dt > 0.1 * this._options.maxAge) { 446 | this.particlePropagator.resetAges() 447 | } 448 | 449 | // Check whether we need to do any substepping. 450 | const needSubstepping = dt > this.dtMin 451 | // Never do more than a certain number of substeps. 452 | const numSubSteps = needSubstepping 453 | ? Math.min(Math.floor(dt / this.dtMin), this.MAX_NUM_SUBSTEPS) 454 | : 1 455 | const dtSub = needSubstepping ? dt / numSubSteps : dt 456 | for (let i = 0; i < numSubSteps; i++) { 457 | // Render the previous particle frame (i.e. a frame with only the 458 | // particles, not velocity magnitude colours) to a texture, fading it by 459 | // an amount proportional to the current time step. 460 | let fadeAmount = this._options.fadeAmountPerSecond * dtSub 461 | // We render the alpha channel with 8-bit precision, so we cannot 462 | // represent amounts below 1/255. If our fade amount is below this number, 463 | // randomly fade the texture by 1/255, with a probability proportional to 464 | // the desired fade amount. 465 | const fadeAmountMin = 1 / 255 466 | if (fadeAmount < fadeAmountMin) { 467 | const fadeProbability = fadeAmount / fadeAmountMin 468 | fadeAmount = Math.random() < fadeProbability ? fadeAmountMin : 0 469 | } 470 | this.textureRenderer.render(this.previousParticleTexture, fadeAmount) 471 | 472 | // Update the particle positions into an output buffer. 473 | this.particlePropagator.update(dtSub) 474 | 475 | // Use the updated particle position to render sprites at those locations. 476 | // These particles are rendered on top of the previous particle frame to 477 | // produce the fading "comet trails". 478 | this.particleRenderer.render(this.particlePropagator.buffers) 479 | 480 | // Do not swap at the last time step as we need the latest particle 481 | // texture for the final render. 482 | if (i < numSubSteps - 1) { 483 | this.swapParticleTextures() 484 | } 485 | } 486 | 487 | // Finally, render the velocity magnitude with the particles (and trails) 488 | // blended with it. 489 | this.finalRenderer.render(this.currentParticleTexture, this.scaling) 490 | 491 | if (this.spriteRenderer) { 492 | // Render the sprite in the final position, on top of everything. 493 | this.spriteRenderer.render(this.particlePropagator.buffers, this.scaling) 494 | } 495 | 496 | // Swap previous and current particle texture. 497 | this.swapParticleTextures() 498 | } 499 | 500 | private async compileShaderPrograms(): Promise< 501 | [ShaderProgram, ShaderProgram, ShaderProgram, ShaderProgram] 502 | > { 503 | // Create vertex shaders. 504 | const particleVertexShader = new VertexShader( 505 | this.gl, 506 | particleVertexShaderSource 507 | ) 508 | const renderVertexShader = new VertexShader( 509 | this.gl, 510 | renderVertexShaderSource 511 | ) 512 | const textureVertexShader = new VertexShader( 513 | this.gl, 514 | textureVertexShaderSource 515 | ) 516 | const finalVertexShader = new VertexShader(this.gl, finalVertexShaderSource) 517 | 518 | // Create fragment shaders. 519 | const placeholderFragmentShader = new FragmentShader( 520 | this.gl, 521 | placeholderFragmentShaderSource 522 | ) 523 | const renderFragmentShader = new FragmentShader( 524 | this.gl, 525 | renderFragmentShaderSource 526 | ) 527 | const textureFragmentShader = new FragmentShader( 528 | this.gl, 529 | textureFragmentShaderSource 530 | ) 531 | const finalFragmentShader = new FragmentShader( 532 | this.gl, 533 | finalFragmentShaderSource 534 | ) 535 | 536 | // Create shader programs. 537 | const programUpdateParticles = new ShaderProgram( 538 | this.gl, 539 | particleVertexShader, 540 | placeholderFragmentShader, 541 | ['v_new_particle_data', 'v_new_particle_age'] 542 | ) 543 | const programRenderParticles = new ShaderProgram( 544 | this.gl, 545 | renderVertexShader, 546 | renderFragmentShader 547 | ) 548 | const programRenderTexture = new ShaderProgram( 549 | this.gl, 550 | textureVertexShader, 551 | textureFragmentShader 552 | ) 553 | const programRenderFinal = new ShaderProgram( 554 | this.gl, 555 | finalVertexShader, 556 | finalFragmentShader 557 | ) 558 | 559 | // Wait until all shader programs have been linked. 560 | await Promise.all( 561 | [ 562 | programUpdateParticles, 563 | programRenderParticles, 564 | programRenderTexture, 565 | programRenderFinal 566 | ].map(program => program.link()) 567 | ) 568 | 569 | return [ 570 | programUpdateParticles, 571 | programRenderParticles, 572 | programRenderTexture, 573 | programRenderFinal 574 | ] 575 | } 576 | 577 | private computeMinimumTimeStep(): number { 578 | if (!this.velocityImage) { 579 | throw new Error( 580 | 'Cannot compute minimum time step if velocity image was not set.' 581 | ) 582 | } 583 | // Convert maximum displacement from pixels to clip coordinates in x- and 584 | // y-direction. Note that clip coordinates run from -1 to 1, hence the 585 | // factor 2. 586 | const maxDisplacementX = (this._options.maxDisplacement / this.width) * 2 587 | const maxDisplacementY = (this._options.maxDisplacement / this.height) * 2 588 | 589 | // Convert the maximum velocity from physical units to clip coordinates, 590 | // similar to how it is done in the particle propagator shader. 591 | let [maxU, maxV] = this.velocityImage.maxVelocity() 592 | maxU *= (this.height / this.width) * this._options.speedFactor 593 | maxV *= this._options.speedFactor 594 | 595 | // Compute time step such that the maximum velocity results in the maximum 596 | // acceptable displacement. 597 | const dtMinU = maxDisplacementX / maxU 598 | const dtMinV = maxDisplacementY / maxV 599 | return Math.min(dtMinU, dtMinV) 600 | } 601 | 602 | private createParticleTexture(): WebGLTexture { 603 | const width = this.particleTextureSize 604 | const height = this.particleTextureSize 605 | const canvas = new OffscreenCanvas(width, height) 606 | const context = canvas.getContext('2d') 607 | if (context === null) { 608 | throw new Error('Could not initialise 2D offscreen canvas.') 609 | } 610 | 611 | const shape = 612 | this._options.trailParticleOptions?.shape ?? TrailParticleShape.Circle 613 | const aspectRatio = this._options.trailParticleOptions?.aspectRatio ?? 1 614 | const particleColor = this._options.particleColor ?? 'black' 615 | if (shape === TrailParticleShape.Circle) { 616 | if (aspectRatio !== 1) { 617 | console.warn( 618 | 'Specifying an aspect ratio is not supported circle-shaped trail particles.' 619 | ) 620 | } 621 | 622 | const radius = 0.5 * this.particleTextureSize 623 | const x = radius 624 | const y = radius 625 | 626 | context.beginPath() 627 | context.arc(x, y, radius, 0, 2 * Math.PI, false) 628 | context.fillStyle = particleColor 629 | context.fill() 630 | } else { 631 | const relativeWidth = aspectRatio >= 1 ? 1 : aspectRatio 632 | const relativeHeight = aspectRatio <= 1 ? 1 : 1 / aspectRatio 633 | const width = relativeWidth * this.particleTextureSize 634 | const height = relativeHeight * this.particleTextureSize 635 | 636 | // Put the particle in the centre of the texture. 637 | const x = 0.5 * (this.particleTextureSize - width) 638 | const y = 0.5 * (this.particleTextureSize - height) 639 | 640 | context.fillStyle = particleColor 641 | context.fillRect(x, y, width, height) 642 | } 643 | 644 | const data = context.getImageData(0, 0, width, height).data 645 | return createTexture(this.gl, this.gl.LINEAR, data, width, height) 646 | } 647 | 648 | private async createSpriteTexture(): Promise { 649 | if (!this.options.spriteUrl) { 650 | throw new Error( 651 | 'Cannot create sprite texture if no sprite URL has been specified.' 652 | ) 653 | } 654 | 655 | const sprite = new Image() 656 | sprite.src = this.options.spriteUrl.toString() 657 | await sprite.decode() 658 | 659 | const width = this.particleTextureSize 660 | const height = this.particleTextureSize 661 | 662 | // Note: sprite images will always be squeezed into a square. 663 | const bitmap = await createImageBitmap(sprite, { 664 | resizeWidth: width, 665 | resizeHeight: height, 666 | resizeQuality: 'high' 667 | }) 668 | 669 | return createTexture(this.gl, this.gl.LINEAR, bitmap, width, height) 670 | } 671 | 672 | private createZeroTexture(): WebGLTexture { 673 | // Create texture initialised to zeros. 674 | const zeros = new Uint8Array(this.width * this.height * 4) 675 | return createTexture( 676 | this.gl, 677 | this.gl.LINEAR, 678 | zeros, 679 | this.width, 680 | this.height 681 | ) 682 | } 683 | 684 | private swapParticleTextures(): void { 685 | const temp = this.previousParticleTexture 686 | this.previousParticleTexture = this.currentParticleTexture 687 | this.currentParticleTexture = temp 688 | // Also swap frame buffers in the texture renderer, as those render into the 689 | // swapped textures. 690 | this.textureRenderer?.swapBuffers() 691 | } 692 | 693 | private resetParticles(): void { 694 | // Reset particle positions and ages. 695 | this.particlePropagator?.resetBuffers() 696 | // Reset rendered particle textures. 697 | this.previousParticleTexture = this.createZeroTexture() 698 | this.currentParticleTexture = this.createZeroTexture() 699 | this.textureRenderer?.resetParticleTextures( 700 | this.previousParticleTexture, 701 | this.currentParticleTexture 702 | ) 703 | } 704 | 705 | private updateVelocityImage(velocityImage: VelocityImage): void { 706 | if (!this.particlePropagator || !this.finalRenderer) { 707 | throw new Error('Cannot set velocity image for uninitialised visualiser.') 708 | } 709 | this.velocityImage = velocityImage 710 | this.particlePropagator.setVelocityImage(velocityImage) 711 | this.finalRenderer.setVelocityImage(velocityImage) 712 | this.dtMin = this.computeMinimumTimeStep() 713 | } 714 | 715 | private static computeSpeedCurve( 716 | colormap: Colormap, 717 | options: StreamlineVisualiserOptions 718 | ): SpeedCurve { 719 | return SpeedCurve.fromExponentFactorAndSpeed( 720 | options.speedExponent ?? 1.0, 721 | options.speedFactor, 722 | colormap.end 723 | ) 724 | } 725 | } 726 | --------------------------------------------------------------------------------