├── .gitattributes ├── .github └── workflows │ └── tag-release-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── TestWorker.js ├── WorkerLogger.js ├── biome.json ├── index.html ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── rahti ├── component.js ├── dom.js ├── globalState.js ├── idle.js └── state.js ├── test.js ├── testApp.js ├── testGraphics.js ├── testWebgl2.js ├── vite-plugin-rahti └── vite-plugin-rahti.js ├── vite.config.js └── webgl2 ├── animationFrame.js ├── buffer.js ├── camera.js ├── command.js ├── context.js ├── elements.js ├── instances.js ├── texture.js ├── uniformBlock.js └── webgl2.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/tag-release-publish.yml: -------------------------------------------------------------------------------- 1 | name: Tag, Release, & Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout your updated source code 13 | - uses: actions/checkout@v3 14 | 15 | # If the version has changed, create a new git tag for it. 16 | - name: Tag 17 | id: autotagger 18 | uses: butlerlogic/action-autotag@1.1.2 19 | env: 20 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 21 | 22 | # The remaining steps all depend on whether or not 23 | # a new tag was created. There is no need to release/publish 24 | # updates until the code base is in a releaseable state. 25 | 26 | # Create a github release 27 | # This will create a snapshot of the module, 28 | # available in the "Releases" section on Github. 29 | - name: Release 30 | id: create_release 31 | if: steps.autotagger.outputs.tagcreated == 'yes' 32 | uses: actions/create-release@v1.0.0 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | with: 36 | tag_name: ${{ steps.autotagger.outputs.tagname }} 37 | release_name: ${{ steps.autotagger.outputs.tagname }} 38 | 39 | - uses: actions/setup-node@v3 40 | with: 41 | node-version: '20.x' 42 | registry-url: 'https://registry.npmjs.org' 43 | - run: npm publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joni Korpi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rahti 2 | 3 | `npm install @vuoro/rahti` 4 | 5 | Write reactive JS components without JSX. Docs WIP. 6 | 7 | ```js 8 | import { Component } from "@vuoro/rahti"; 9 | import { State } from "@vuoro/rahti/state"; 10 | 11 | const Parent = new Proxy(function (hello) { 12 | const [time, setTime] = State(Date.now()); 13 | setTimeout(() => setTime(Date.now()), Math.random() * 2000); 14 | 15 | Child(hello, time); 16 | }, Component); 17 | 18 | const Child = new Proxy(function (hello, time) { 19 | console.log(hello, time); 20 | }, Component); 21 | 22 | Parent("Hello world"); 23 | ``` 24 | 25 | # Upcoming features 26 | 27 | - "Global", signal-like components to lessen the need for prop drilling 28 | - Better API for DOM elements 29 | 30 | # API & example 31 | 32 | ```js 33 | import { Component, cleanup, save, load } from "@vuoro/rahti"; 34 | import { getInstance, update, updateParent } from "@vuoro/rahti"; 35 | import { State } from "@vuoro/rahti/state"; 36 | import { createGlobalState } from "@vuoro/rahti/globalState"; 37 | import { Mount, html, svg } from "@vuoro/rahti/dom"; 38 | 39 | const App = new Proxy(function () { 40 | // You can call any component inside any other component. 41 | Child(); 42 | 43 | // You can create DOM elements using `html` and `svg`. 44 | // Object arguments turn into DOM attributes. 45 | // Text and number arguments turn into text nodes. 46 | html.p( 47 | {style: "color: red"}, 48 | "Hello world" 49 | ); 50 | svg.svg( 51 | svg.rect({width: "300" height: "300" fill: "red"}) 52 | ); 53 | 54 | // Events can be handled with `EventHandler`… 55 | html.button( 56 | {type: "button"}, 57 | EventHandler({type: "click", listener: console.log, {passive: true, once: true}}) 58 | ); 59 | 60 | // or with `EventListener`. 61 | EventListener( 62 | document.body, 63 | "click", 64 | (event) => console.log(event), 65 | {passive: true} 66 | ); 67 | 68 | // Finally, none of the above DOM components will actually appear on the page, 69 | // unless passed to `Mount`. The first argument is the mount target. 70 | const paragraph = html.p("Hello from document.body!"); 71 | Mount(document.body, paragraph) 72 | }, Component); 73 | 74 | App(); 75 | 76 | // Components can have `State`. 77 | // When a component's `State` changes, it re-runs. 78 | // If the component returns a different value than the last time it ran, 79 | // it'll tell its parent to re-run too. 80 | // Any other components that the parent then calls will also try to re-run, 81 | // but all components are _memoized_, 82 | // meaning they will only re-run if they receive different arguments from when they last ran, 83 | // otherwise they'll just return their previous return value. 84 | const StatefulApp = new Proxy(function () { 85 | const [timestamp, setTimestamp, getTimestamp] = State(performance.now()); 86 | requestAnimationFrame(setTimestamp); 87 | 88 | Mount(document.body, html.p(timestamp)) 89 | }, Component); 90 | 91 | // The setter function of a State accepts two arguments: 92 | // 1. the State's new value 93 | // 2. a boolean: `true` if it should update immediately, or `false` (the default) if later using `requestIdleCallback` 94 | setTimestamp(performance.now(), false); // updates on the next `requestIdleCallback` 95 | setTimestamp(performance.now(), true); // updates immediately 96 | 97 | // `createGlobalState` is a helper for sharing the same state between multiple components. 98 | // It returns a component that works like State, a setter function, and a getter function. 99 | const [ 100 | GlobalTimer, 101 | setGlobalTimestamp, 102 | getGlobalTimestamp 103 | ] = createGlobalState(performance.now()); 104 | 105 | const A = new Proxy(function () { 106 | const [timestamp, setGlobalTimestamp, getGlobalTimestamp] = GlobalTimer(performance.now()); 107 | console.log("from a", timestamp); 108 | }, Component); 109 | 110 | const B = new Proxy(function () { 111 | const [timestamp, setGlobalTimestamp, getGlobalTimestamp] = GlobalTimer(performance.now()); 112 | console.log("from b", timestamp); 113 | }, Component); 114 | 115 | requestAnimationFrame(setGlobalTimestamp); 116 | 117 | // The getter function lets you easily check or set the state outside components, 118 | // inside event handlers and such. 119 | setTimeout(() => console.log("from setTimeout", getGlobalTimestamp()), 1000); 120 | 121 | // You can also create custom state mechanisms with `getInstance`, `update` and `updateParent`. 122 | // (Check out state.js and globalState.js for how they use it.) 123 | // `update(instance)` causes the instance to re-run on the next `requestIdleCallback`. 124 | // `update(instance, true)` causes the instance to re-run immediately. 125 | // The above goes for `updateParent` too. 126 | const CustomStateTest = new Proxy(function () { 127 | const instance = getInstance(); 128 | console.log("ran at", performance.now()); 129 | setTimeout(() => update(instance), 1000); 130 | }, Component); 131 | 132 | // Components can have keys, which lets them be identified better between re-runs of their parents. 133 | // Define a `getKey` function as below. It gets passed the same arguments as the component. 134 | // Whatever `getKey` returns will be the key for that component instance. 135 | const Child = new Proxy(function (index) { 136 | console.log(index); 137 | }, {...Component, getKey: index => index}); 138 | 139 | // `save` & `load` are an additional way to persist data between component reruns. 140 | // Handy for avoiding creating new objects every time the component runs. 141 | // The data will be cleared if the component is destroyed. 142 | const SaveAndLoad = new Proxy(function () { 143 | const savedArray = load() || save([]); 144 | savedArray.push(Math.random()); 145 | }, Component); 146 | 147 | // Finally, components can have a `cleanup` callback. 148 | // It gets called before a component re-runs, and when it gets destroyed. 149 | const Cleanup = new Proxy(function () { 150 | const element = document.createElement("div"); 151 | cleanup(() => element.remove()); 152 | return element; 153 | }, Component); 154 | 155 | // Cleanups are called with some pieces of data you can use to perform complicated cleanup logic. 156 | // Be very mindful when using these, as it's easy to introduce bugs with them. 157 | // - 1st argument = the component instance object also returned by `getInstance`, which can be used for identification (but be keep in mind that after the component instance gets destroyed the object may be reused by new component instances) 158 | // - 2st argument = the last data the component has saved with `save`, if any 159 | // - 3nd argument = a boolean indicating whether the component is being destroyed (`true`) or just updating (`false`) 160 | const CleanupAdvanced = new Proxy(function () { 161 | const element = document.createElement("div"); 162 | console.log(getInstance); 163 | cleanup(cleanElement); 164 | return element; 165 | }, Component); 166 | 167 | function cleanElement(instance, savedData, isBeingDestroyed) { 168 | console.log(instance); 169 | element.remove(); 170 | } 171 | ``` 172 | 173 | ## Hot module reloading for Vite 174 | 175 | Rahti supports HMR in [Vite](https://vitejs.dev) when `vite-plugin-rahti` is loaded in `vite.config.js`: 176 | 177 | ```js 178 | import { rahtiPlugin } from "@vuoro/rahti/vite-plugin-rahti"; 179 | 180 | export default { 181 | plugins: [rahtiPlugin()] 182 | }; 183 | ``` 184 | 185 | HMR will work best in files that export nothing but Components. Otherwise Vite's HMR system seems to go into an infinite loop sometimes, and I don't know how to fix or even debug it. 186 | 187 | ```js 188 | // This should work perfectly 189 | export const SomeComponent = new Proxy(function() {}, Component); 190 | const somethingElse = "hello"; 191 | 192 | // This probably won't 193 | export const SomeComponent = new Proxy(function() {}, Component); 194 | export const somethingElse = "hello"; 195 | ``` 196 | 197 | ## WebGL 2 components 198 | 199 | Since I'm using this library to develop games, I'm also building a set of components for working with WebGL 2. They are experimental, and will not follow this repository's semantic versioning. 200 | 201 | ```js 202 | import * as WebGl2 from "@vuoro/rahti/webgl2"; 203 | ``` 204 | 205 | ## Inspirations 206 | 207 | - 208 | - 209 | - 210 | - -------------------------------------------------------------------------------- /TestWorker.js: -------------------------------------------------------------------------------- 1 | import { WorkerLogger } from "./WorkerLogger.js"; 2 | 3 | console.log("Hello from worker"); 4 | 5 | WorkerLogger(); 6 | -------------------------------------------------------------------------------- /WorkerLogger.js: -------------------------------------------------------------------------------- 1 | import { Component } from "./rahti/component.js"; 2 | 3 | export const WorkerLogger = new Proxy(function () { 4 | console.log("Hello from worker component"); 5 | }, Component); 6 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatter": { 3 | "indentStyle": "space", 4 | "lineWidth": 100, 5 | "formatWithErrors": true 6 | }, 7 | "linter": { 8 | "rules": { 9 | "complexity": { 10 | "useArrowFunction": "off" 11 | }, 12 | "correctness": { 13 | "noUnusedImports": "warn", 14 | "noUnusedVariables": "warn", 15 | "noUndeclaredVariables": "error" 16 | }, 17 | "style": { 18 | "noParameterAssign": "warn" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vuoro/rahti", 3 | "version": "12.0.5", 4 | "description": "Reactive JS components without JSX", 5 | "type": "module", 6 | "main": "./rahti/component.js", 7 | "exports": { 8 | ".": "./rahti/component.js", 9 | "./state": "./rahti/state.js", 10 | "./globalState": "./rahti/globalState.js", 11 | "./dom": "./rahti/dom.js", 12 | "./webgl2": "./webgl2/webgl2.js", 13 | "./vite-plugin-rahti": "./vite-plugin-rahti/vite-plugin-rahti.js" 14 | }, 15 | "scripts": { 16 | "start": "vite dev --open" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/vuoro/rahti.git" 21 | }, 22 | "keywords": ["reactive", "javascript", "rahti"], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/vuoro/rahti/issues" 26 | }, 27 | "homepage": "https://github.com/vuoro/rahti#readme", 28 | "devDependencies": { 29 | "vite": "^5.2.11", 30 | "@biomejs/biome": "^1.7.3" 31 | }, 32 | "prettier": { 33 | "printWidth": 100 34 | }, 35 | "packageManager": "pnpm@9.1.0", 36 | "dependencies": { 37 | "gl-mat4-esm": "^1.1.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | gl-mat4-esm: 12 | specifier: ^1.1.4 13 | version: 1.1.4 14 | devDependencies: 15 | '@biomejs/biome': 16 | specifier: ^1.7.3 17 | version: 1.7.3 18 | vite: 19 | specifier: ^5.2.11 20 | version: 5.2.11 21 | 22 | packages: 23 | 24 | '@biomejs/biome@1.7.3': 25 | resolution: {integrity: sha512-ogFQI+fpXftr+tiahA6bIXwZ7CSikygASdqMtH07J2cUzrpjyTMVc9Y97v23c7/tL1xCZhM+W9k4hYIBm7Q6cQ==} 26 | engines: {node: '>=14.21.3'} 27 | hasBin: true 28 | 29 | '@biomejs/cli-darwin-arm64@1.7.3': 30 | resolution: {integrity: sha512-eDvLQWmGRqrPIRY7AIrkPHkQ3visEItJKkPYSHCscSDdGvKzYjmBJwG1Gu8+QC5ed6R7eiU63LEC0APFBobmfQ==} 31 | engines: {node: '>=14.21.3'} 32 | cpu: [arm64] 33 | os: [darwin] 34 | 35 | '@biomejs/cli-darwin-x64@1.7.3': 36 | resolution: {integrity: sha512-JXCaIseKRER7dIURsVlAJacnm8SG5I0RpxZ4ya3dudASYUc68WGl4+FEN03ABY3KMIq7hcK1tzsJiWlmXyosZg==} 37 | engines: {node: '>=14.21.3'} 38 | cpu: [x64] 39 | os: [darwin] 40 | 41 | '@biomejs/cli-linux-arm64-musl@1.7.3': 42 | resolution: {integrity: sha512-c8AlO45PNFZ1BYcwaKzdt46kYbuP6xPGuGQ6h4j3XiEDpyseRRUy/h+6gxj07XovmyxKnSX9GSZ6nVbZvcVUAw==} 43 | engines: {node: '>=14.21.3'} 44 | cpu: [arm64] 45 | os: [linux] 46 | 47 | '@biomejs/cli-linux-arm64@1.7.3': 48 | resolution: {integrity: sha512-phNTBpo7joDFastnmZsFjYcDYobLTx4qR4oPvc9tJ486Bd1SfEVPHEvJdNJrMwUQK56T+TRClOQd/8X1nnjA9w==} 49 | engines: {node: '>=14.21.3'} 50 | cpu: [arm64] 51 | os: [linux] 52 | 53 | '@biomejs/cli-linux-x64-musl@1.7.3': 54 | resolution: {integrity: sha512-UdEHKtYGWEX3eDmVWvQeT+z05T9/Sdt2+F/7zmMOFQ7boANeX8pcO6EkJPK3wxMudrApsNEKT26rzqK6sZRTRA==} 55 | engines: {node: '>=14.21.3'} 56 | cpu: [x64] 57 | os: [linux] 58 | 59 | '@biomejs/cli-linux-x64@1.7.3': 60 | resolution: {integrity: sha512-vnedYcd5p4keT3iD48oSKjOIRPYcjSNNbd8MO1bKo9ajg3GwQXZLAH+0Cvlr+eMsO67/HddWmscSQwTFrC/uPA==} 61 | engines: {node: '>=14.21.3'} 62 | cpu: [x64] 63 | os: [linux] 64 | 65 | '@biomejs/cli-win32-arm64@1.7.3': 66 | resolution: {integrity: sha512-unNCDqUKjujYkkSxs7gFIfdasttbDC4+z0kYmcqzRk6yWVoQBL4dNLcCbdnJS+qvVDNdI9rHp2NwpQ0WAdla4Q==} 67 | engines: {node: '>=14.21.3'} 68 | cpu: [arm64] 69 | os: [win32] 70 | 71 | '@biomejs/cli-win32-x64@1.7.3': 72 | resolution: {integrity: sha512-ZmByhbrnmz/UUFYB622CECwhKIPjJLLPr5zr3edhu04LzbfcOrz16VYeNq5dpO1ADG70FORhAJkaIGdaVBG00w==} 73 | engines: {node: '>=14.21.3'} 74 | cpu: [x64] 75 | os: [win32] 76 | 77 | '@esbuild/aix-ppc64@0.20.2': 78 | resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} 79 | engines: {node: '>=12'} 80 | cpu: [ppc64] 81 | os: [aix] 82 | 83 | '@esbuild/android-arm64@0.20.2': 84 | resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} 85 | engines: {node: '>=12'} 86 | cpu: [arm64] 87 | os: [android] 88 | 89 | '@esbuild/android-arm@0.20.2': 90 | resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} 91 | engines: {node: '>=12'} 92 | cpu: [arm] 93 | os: [android] 94 | 95 | '@esbuild/android-x64@0.20.2': 96 | resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} 97 | engines: {node: '>=12'} 98 | cpu: [x64] 99 | os: [android] 100 | 101 | '@esbuild/darwin-arm64@0.20.2': 102 | resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} 103 | engines: {node: '>=12'} 104 | cpu: [arm64] 105 | os: [darwin] 106 | 107 | '@esbuild/darwin-x64@0.20.2': 108 | resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} 109 | engines: {node: '>=12'} 110 | cpu: [x64] 111 | os: [darwin] 112 | 113 | '@esbuild/freebsd-arm64@0.20.2': 114 | resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} 115 | engines: {node: '>=12'} 116 | cpu: [arm64] 117 | os: [freebsd] 118 | 119 | '@esbuild/freebsd-x64@0.20.2': 120 | resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} 121 | engines: {node: '>=12'} 122 | cpu: [x64] 123 | os: [freebsd] 124 | 125 | '@esbuild/linux-arm64@0.20.2': 126 | resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} 127 | engines: {node: '>=12'} 128 | cpu: [arm64] 129 | os: [linux] 130 | 131 | '@esbuild/linux-arm@0.20.2': 132 | resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} 133 | engines: {node: '>=12'} 134 | cpu: [arm] 135 | os: [linux] 136 | 137 | '@esbuild/linux-ia32@0.20.2': 138 | resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} 139 | engines: {node: '>=12'} 140 | cpu: [ia32] 141 | os: [linux] 142 | 143 | '@esbuild/linux-loong64@0.20.2': 144 | resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} 145 | engines: {node: '>=12'} 146 | cpu: [loong64] 147 | os: [linux] 148 | 149 | '@esbuild/linux-mips64el@0.20.2': 150 | resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} 151 | engines: {node: '>=12'} 152 | cpu: [mips64el] 153 | os: [linux] 154 | 155 | '@esbuild/linux-ppc64@0.20.2': 156 | resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} 157 | engines: {node: '>=12'} 158 | cpu: [ppc64] 159 | os: [linux] 160 | 161 | '@esbuild/linux-riscv64@0.20.2': 162 | resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} 163 | engines: {node: '>=12'} 164 | cpu: [riscv64] 165 | os: [linux] 166 | 167 | '@esbuild/linux-s390x@0.20.2': 168 | resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} 169 | engines: {node: '>=12'} 170 | cpu: [s390x] 171 | os: [linux] 172 | 173 | '@esbuild/linux-x64@0.20.2': 174 | resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} 175 | engines: {node: '>=12'} 176 | cpu: [x64] 177 | os: [linux] 178 | 179 | '@esbuild/netbsd-x64@0.20.2': 180 | resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} 181 | engines: {node: '>=12'} 182 | cpu: [x64] 183 | os: [netbsd] 184 | 185 | '@esbuild/openbsd-x64@0.20.2': 186 | resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} 187 | engines: {node: '>=12'} 188 | cpu: [x64] 189 | os: [openbsd] 190 | 191 | '@esbuild/sunos-x64@0.20.2': 192 | resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} 193 | engines: {node: '>=12'} 194 | cpu: [x64] 195 | os: [sunos] 196 | 197 | '@esbuild/win32-arm64@0.20.2': 198 | resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} 199 | engines: {node: '>=12'} 200 | cpu: [arm64] 201 | os: [win32] 202 | 203 | '@esbuild/win32-ia32@0.20.2': 204 | resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} 205 | engines: {node: '>=12'} 206 | cpu: [ia32] 207 | os: [win32] 208 | 209 | '@esbuild/win32-x64@0.20.2': 210 | resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} 211 | engines: {node: '>=12'} 212 | cpu: [x64] 213 | os: [win32] 214 | 215 | '@rollup/rollup-android-arm-eabi@4.17.2': 216 | resolution: {integrity: sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==} 217 | cpu: [arm] 218 | os: [android] 219 | 220 | '@rollup/rollup-android-arm64@4.17.2': 221 | resolution: {integrity: sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==} 222 | cpu: [arm64] 223 | os: [android] 224 | 225 | '@rollup/rollup-darwin-arm64@4.17.2': 226 | resolution: {integrity: sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==} 227 | cpu: [arm64] 228 | os: [darwin] 229 | 230 | '@rollup/rollup-darwin-x64@4.17.2': 231 | resolution: {integrity: sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==} 232 | cpu: [x64] 233 | os: [darwin] 234 | 235 | '@rollup/rollup-linux-arm-gnueabihf@4.17.2': 236 | resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} 237 | cpu: [arm] 238 | os: [linux] 239 | 240 | '@rollup/rollup-linux-arm-musleabihf@4.17.2': 241 | resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} 242 | cpu: [arm] 243 | os: [linux] 244 | 245 | '@rollup/rollup-linux-arm64-gnu@4.17.2': 246 | resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} 247 | cpu: [arm64] 248 | os: [linux] 249 | 250 | '@rollup/rollup-linux-arm64-musl@4.17.2': 251 | resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} 252 | cpu: [arm64] 253 | os: [linux] 254 | 255 | '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': 256 | resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} 257 | cpu: [ppc64] 258 | os: [linux] 259 | 260 | '@rollup/rollup-linux-riscv64-gnu@4.17.2': 261 | resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} 262 | cpu: [riscv64] 263 | os: [linux] 264 | 265 | '@rollup/rollup-linux-s390x-gnu@4.17.2': 266 | resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} 267 | cpu: [s390x] 268 | os: [linux] 269 | 270 | '@rollup/rollup-linux-x64-gnu@4.17.2': 271 | resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} 272 | cpu: [x64] 273 | os: [linux] 274 | 275 | '@rollup/rollup-linux-x64-musl@4.17.2': 276 | resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} 277 | cpu: [x64] 278 | os: [linux] 279 | 280 | '@rollup/rollup-win32-arm64-msvc@4.17.2': 281 | resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} 282 | cpu: [arm64] 283 | os: [win32] 284 | 285 | '@rollup/rollup-win32-ia32-msvc@4.17.2': 286 | resolution: {integrity: sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==} 287 | cpu: [ia32] 288 | os: [win32] 289 | 290 | '@rollup/rollup-win32-x64-msvc@4.17.2': 291 | resolution: {integrity: sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==} 292 | cpu: [x64] 293 | os: [win32] 294 | 295 | '@types/estree@1.0.5': 296 | resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} 297 | 298 | esbuild@0.20.2: 299 | resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} 300 | engines: {node: '>=12'} 301 | hasBin: true 302 | 303 | fsevents@2.3.3: 304 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 305 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 306 | os: [darwin] 307 | 308 | gl-mat4-esm@1.1.4: 309 | resolution: {integrity: sha512-Z6+GyXGZN7ZRiPcTkVovsXOnizhIdMhoJJ6zjpoxm9RJ/DfBOK3+vGSg3yIjW5Y0OjOKZQ+VyESVfH8SRaGE1w==} 310 | 311 | nanoid@3.3.7: 312 | resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 313 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 314 | hasBin: true 315 | 316 | picocolors@1.0.0: 317 | resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} 318 | 319 | postcss@8.4.38: 320 | resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} 321 | engines: {node: ^10 || ^12 || >=14} 322 | 323 | rollup@4.17.2: 324 | resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==} 325 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} 326 | hasBin: true 327 | 328 | source-map-js@1.2.0: 329 | resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} 330 | engines: {node: '>=0.10.0'} 331 | 332 | vite@5.2.11: 333 | resolution: {integrity: sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==} 334 | engines: {node: ^18.0.0 || >=20.0.0} 335 | hasBin: true 336 | peerDependencies: 337 | '@types/node': ^18.0.0 || >=20.0.0 338 | less: '*' 339 | lightningcss: ^1.21.0 340 | sass: '*' 341 | stylus: '*' 342 | sugarss: '*' 343 | terser: ^5.4.0 344 | peerDependenciesMeta: 345 | '@types/node': 346 | optional: true 347 | less: 348 | optional: true 349 | lightningcss: 350 | optional: true 351 | sass: 352 | optional: true 353 | stylus: 354 | optional: true 355 | sugarss: 356 | optional: true 357 | terser: 358 | optional: true 359 | 360 | snapshots: 361 | 362 | '@biomejs/biome@1.7.3': 363 | optionalDependencies: 364 | '@biomejs/cli-darwin-arm64': 1.7.3 365 | '@biomejs/cli-darwin-x64': 1.7.3 366 | '@biomejs/cli-linux-arm64': 1.7.3 367 | '@biomejs/cli-linux-arm64-musl': 1.7.3 368 | '@biomejs/cli-linux-x64': 1.7.3 369 | '@biomejs/cli-linux-x64-musl': 1.7.3 370 | '@biomejs/cli-win32-arm64': 1.7.3 371 | '@biomejs/cli-win32-x64': 1.7.3 372 | 373 | '@biomejs/cli-darwin-arm64@1.7.3': 374 | optional: true 375 | 376 | '@biomejs/cli-darwin-x64@1.7.3': 377 | optional: true 378 | 379 | '@biomejs/cli-linux-arm64-musl@1.7.3': 380 | optional: true 381 | 382 | '@biomejs/cli-linux-arm64@1.7.3': 383 | optional: true 384 | 385 | '@biomejs/cli-linux-x64-musl@1.7.3': 386 | optional: true 387 | 388 | '@biomejs/cli-linux-x64@1.7.3': 389 | optional: true 390 | 391 | '@biomejs/cli-win32-arm64@1.7.3': 392 | optional: true 393 | 394 | '@biomejs/cli-win32-x64@1.7.3': 395 | optional: true 396 | 397 | '@esbuild/aix-ppc64@0.20.2': 398 | optional: true 399 | 400 | '@esbuild/android-arm64@0.20.2': 401 | optional: true 402 | 403 | '@esbuild/android-arm@0.20.2': 404 | optional: true 405 | 406 | '@esbuild/android-x64@0.20.2': 407 | optional: true 408 | 409 | '@esbuild/darwin-arm64@0.20.2': 410 | optional: true 411 | 412 | '@esbuild/darwin-x64@0.20.2': 413 | optional: true 414 | 415 | '@esbuild/freebsd-arm64@0.20.2': 416 | optional: true 417 | 418 | '@esbuild/freebsd-x64@0.20.2': 419 | optional: true 420 | 421 | '@esbuild/linux-arm64@0.20.2': 422 | optional: true 423 | 424 | '@esbuild/linux-arm@0.20.2': 425 | optional: true 426 | 427 | '@esbuild/linux-ia32@0.20.2': 428 | optional: true 429 | 430 | '@esbuild/linux-loong64@0.20.2': 431 | optional: true 432 | 433 | '@esbuild/linux-mips64el@0.20.2': 434 | optional: true 435 | 436 | '@esbuild/linux-ppc64@0.20.2': 437 | optional: true 438 | 439 | '@esbuild/linux-riscv64@0.20.2': 440 | optional: true 441 | 442 | '@esbuild/linux-s390x@0.20.2': 443 | optional: true 444 | 445 | '@esbuild/linux-x64@0.20.2': 446 | optional: true 447 | 448 | '@esbuild/netbsd-x64@0.20.2': 449 | optional: true 450 | 451 | '@esbuild/openbsd-x64@0.20.2': 452 | optional: true 453 | 454 | '@esbuild/sunos-x64@0.20.2': 455 | optional: true 456 | 457 | '@esbuild/win32-arm64@0.20.2': 458 | optional: true 459 | 460 | '@esbuild/win32-ia32@0.20.2': 461 | optional: true 462 | 463 | '@esbuild/win32-x64@0.20.2': 464 | optional: true 465 | 466 | '@rollup/rollup-android-arm-eabi@4.17.2': 467 | optional: true 468 | 469 | '@rollup/rollup-android-arm64@4.17.2': 470 | optional: true 471 | 472 | '@rollup/rollup-darwin-arm64@4.17.2': 473 | optional: true 474 | 475 | '@rollup/rollup-darwin-x64@4.17.2': 476 | optional: true 477 | 478 | '@rollup/rollup-linux-arm-gnueabihf@4.17.2': 479 | optional: true 480 | 481 | '@rollup/rollup-linux-arm-musleabihf@4.17.2': 482 | optional: true 483 | 484 | '@rollup/rollup-linux-arm64-gnu@4.17.2': 485 | optional: true 486 | 487 | '@rollup/rollup-linux-arm64-musl@4.17.2': 488 | optional: true 489 | 490 | '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': 491 | optional: true 492 | 493 | '@rollup/rollup-linux-riscv64-gnu@4.17.2': 494 | optional: true 495 | 496 | '@rollup/rollup-linux-s390x-gnu@4.17.2': 497 | optional: true 498 | 499 | '@rollup/rollup-linux-x64-gnu@4.17.2': 500 | optional: true 501 | 502 | '@rollup/rollup-linux-x64-musl@4.17.2': 503 | optional: true 504 | 505 | '@rollup/rollup-win32-arm64-msvc@4.17.2': 506 | optional: true 507 | 508 | '@rollup/rollup-win32-ia32-msvc@4.17.2': 509 | optional: true 510 | 511 | '@rollup/rollup-win32-x64-msvc@4.17.2': 512 | optional: true 513 | 514 | '@types/estree@1.0.5': {} 515 | 516 | esbuild@0.20.2: 517 | optionalDependencies: 518 | '@esbuild/aix-ppc64': 0.20.2 519 | '@esbuild/android-arm': 0.20.2 520 | '@esbuild/android-arm64': 0.20.2 521 | '@esbuild/android-x64': 0.20.2 522 | '@esbuild/darwin-arm64': 0.20.2 523 | '@esbuild/darwin-x64': 0.20.2 524 | '@esbuild/freebsd-arm64': 0.20.2 525 | '@esbuild/freebsd-x64': 0.20.2 526 | '@esbuild/linux-arm': 0.20.2 527 | '@esbuild/linux-arm64': 0.20.2 528 | '@esbuild/linux-ia32': 0.20.2 529 | '@esbuild/linux-loong64': 0.20.2 530 | '@esbuild/linux-mips64el': 0.20.2 531 | '@esbuild/linux-ppc64': 0.20.2 532 | '@esbuild/linux-riscv64': 0.20.2 533 | '@esbuild/linux-s390x': 0.20.2 534 | '@esbuild/linux-x64': 0.20.2 535 | '@esbuild/netbsd-x64': 0.20.2 536 | '@esbuild/openbsd-x64': 0.20.2 537 | '@esbuild/sunos-x64': 0.20.2 538 | '@esbuild/win32-arm64': 0.20.2 539 | '@esbuild/win32-ia32': 0.20.2 540 | '@esbuild/win32-x64': 0.20.2 541 | 542 | fsevents@2.3.3: 543 | optional: true 544 | 545 | gl-mat4-esm@1.1.4: {} 546 | 547 | nanoid@3.3.7: {} 548 | 549 | picocolors@1.0.0: {} 550 | 551 | postcss@8.4.38: 552 | dependencies: 553 | nanoid: 3.3.7 554 | picocolors: 1.0.0 555 | source-map-js: 1.2.0 556 | 557 | rollup@4.17.2: 558 | dependencies: 559 | '@types/estree': 1.0.5 560 | optionalDependencies: 561 | '@rollup/rollup-android-arm-eabi': 4.17.2 562 | '@rollup/rollup-android-arm64': 4.17.2 563 | '@rollup/rollup-darwin-arm64': 4.17.2 564 | '@rollup/rollup-darwin-x64': 4.17.2 565 | '@rollup/rollup-linux-arm-gnueabihf': 4.17.2 566 | '@rollup/rollup-linux-arm-musleabihf': 4.17.2 567 | '@rollup/rollup-linux-arm64-gnu': 4.17.2 568 | '@rollup/rollup-linux-arm64-musl': 4.17.2 569 | '@rollup/rollup-linux-powerpc64le-gnu': 4.17.2 570 | '@rollup/rollup-linux-riscv64-gnu': 4.17.2 571 | '@rollup/rollup-linux-s390x-gnu': 4.17.2 572 | '@rollup/rollup-linux-x64-gnu': 4.17.2 573 | '@rollup/rollup-linux-x64-musl': 4.17.2 574 | '@rollup/rollup-win32-arm64-msvc': 4.17.2 575 | '@rollup/rollup-win32-ia32-msvc': 4.17.2 576 | '@rollup/rollup-win32-x64-msvc': 4.17.2 577 | fsevents: 2.3.3 578 | 579 | source-map-js@1.2.0: {} 580 | 581 | vite@5.2.11: 582 | dependencies: 583 | esbuild: 0.20.2 584 | postcss: 8.4.38 585 | rollup: 4.17.2 586 | optionalDependencies: 587 | fsevents: 2.3.3 588 | -------------------------------------------------------------------------------- /rahti/component.js: -------------------------------------------------------------------------------- 1 | import { requestIdleCallback } from "./idle.js"; 2 | 3 | const reportError = globalThis.reportError || console.error; 4 | 5 | export const Component = { 6 | getKey: undefined, 7 | apply: function (code, _, argumentsList) { 8 | const parent = getInstance(); 9 | const key = this.getKey?.apply(null, argumentsList); 10 | const instance = findInstance(code, parent, key) || createInstance(code, parent, key); 11 | 12 | if (parent !== topLevel) parent.currentIndex++; 13 | return run(instance, argumentsList); 14 | }, 15 | get: function (code, property) { 16 | if (property === "_rahtiCode") return code; 17 | return code[property]; 18 | }, 19 | }; 20 | 21 | class Instance { 22 | currentIndex = 0; 23 | needsUpdate = false; 24 | 25 | parent = null; 26 | lastValue = undefined; 27 | lastArguments = null; 28 | savedData = null; 29 | cleaner = null; 30 | 31 | key = null; 32 | children = []; 33 | code = null; 34 | } 35 | 36 | export const getInstance = () => { 37 | return stack.at(-1); 38 | }; 39 | export const save = (dataToSave) => { 40 | getInstance().savedData = dataToSave; 41 | return dataToSave; 42 | }; 43 | export const load = () => { 44 | return getInstance().savedData; 45 | }; 46 | export const cleanup = (cleaner) => { 47 | const instance = getInstance(); 48 | if (instance.cleaner) throw new Error("only 1 `cleanup()` allowed per component instance"); 49 | instance.cleaner = cleaner; 50 | }; 51 | 52 | const topLevel = new Instance(); 53 | const stack = [topLevel]; 54 | 55 | const instancePool = []; 56 | 57 | const createInstance = (code, parent, key) => { 58 | let instance; 59 | 60 | if (instancePool.length) { 61 | instance = instancePool.pop(); 62 | } else { 63 | instance = new Instance(); 64 | } 65 | 66 | // Get or create parent's children 67 | parent.children = parent.children || []; 68 | 69 | // Get parent's current index and save as a child using it 70 | const index = parent.currentIndex; 71 | parent.children.splice(index, 0, instance); 72 | 73 | // Save the parent, the key, and the Component 74 | instance.parent = parent; 75 | instance.key = key; 76 | instance.code = code; 77 | 78 | // Mark as needing an update 79 | instance.needsUpdate = true; 80 | 81 | // console.log("created at", parent.currentIndex); 82 | 83 | if (import.meta.hot) { 84 | // Add this instance into the HMR instance registry, 85 | // so it can be found when HMR gets new versions of its Component 86 | globalThis._rahtiHmrInstances?.get(code)?.add(instance); 87 | } 88 | 89 | return instance; 90 | }; 91 | 92 | const findInstance = (code, parent, key) => { 93 | // console.log("looking for", Component.name, "in", Components.get(parentId).name, "with key:", key); 94 | if (parent.children) { 95 | // Find the current child 96 | const currentIndex = parent.currentIndex; 97 | const currentChild = parent.children[currentIndex]; 98 | 99 | if (currentChild && currentChild.code === code && currentChild.key === key) { 100 | // The child looks like what we're looking for 101 | // console.log("found here"); 102 | return currentChild; 103 | } 104 | 105 | // Try to find the a matching child further on 106 | for (let index = currentIndex + 1; index < parent.children.length; index++) { 107 | const child = parent.children[index]; 108 | if (child.code === code && child.key === key) { 109 | // This one looks correct, so move it into its new place 110 | parent.children.splice(index, 1); 111 | parent.children.splice(currentIndex, 0, child); 112 | // console.log("found later"); 113 | return child; 114 | } 115 | } 116 | 117 | // console.log("did not find matching children"); 118 | } else { 119 | // console.log("there were no children for"); 120 | } 121 | }; 122 | 123 | const run = (instance, newArguments) => { 124 | // See if the instance should re-run 125 | let needsUpdate = instance.needsUpdate; 126 | 127 | if (!needsUpdate) { 128 | if (instance.lastArguments?.length !== newArguments.length) { 129 | // console.log("argument length changed", instance.lastArguments, newArguments); 130 | needsUpdate = true; 131 | } else { 132 | // Check arguments 133 | for (let index = 0; index < newArguments.length; index++) { 134 | const previousArgument = instance.lastArguments[index]; 135 | const newArgument = newArguments[index]; 136 | 137 | if (!Object.is(newArgument, previousArgument)) { 138 | // console.log("argument has changed", previousArgument, newArgument); 139 | needsUpdate = true; 140 | break; 141 | } 142 | } 143 | } 144 | } 145 | 146 | // Save this run's arguments for next time 147 | instance.lastArguments = newArguments; 148 | 149 | if (needsUpdate) { 150 | // Run the instance 151 | // console.log("+++ start of", instance.Component?.name); 152 | 153 | // Run the cleanup, if there is one 154 | runCleanup(instance, false); 155 | 156 | // Run the instance's Component 157 | stack.push(instance); 158 | instance.currentIndex = 0; 159 | let result; 160 | 161 | try { 162 | let code = instance.code; 163 | if (import.meta.hot) { 164 | // Use the latest HMR'd version of this component, if available 165 | code = globalThis._rahtiHmrComponentReplacements?.get(code) || code; 166 | } 167 | 168 | result = code.apply(instance, newArguments); 169 | 170 | // Save the new value 171 | instance.lastValue = result; 172 | 173 | // Mark as no longer needing update 174 | instance.needsUpdate = false; 175 | updateQueue.delete(instance); 176 | } catch (error) { 177 | // console.log("caught"); 178 | reportError(error); 179 | } finally { 180 | stack.pop(); 181 | 182 | // Destroy children that were not visited on this execution 183 | if (instance.children) { 184 | const nextIndex = instance.currentIndex; 185 | const { length } = instance.children; 186 | 187 | if (nextIndex < length) { 188 | // console.log("/// destroying leftover children in", Component.name, length - nextIndex); 189 | for (let index = nextIndex; index < length; index++) { 190 | destroy(instance.children[index]); 191 | } 192 | instance.children.splice(nextIndex); 193 | } 194 | } 195 | } 196 | 197 | // console.log("--- returning", result, "from", Component.name, instance); 198 | return result; 199 | } 200 | 201 | // Skip running and return the previous value 202 | // console.log("!!! skipping update for", instance.Component?.name); 203 | return instance.lastValue; 204 | }; 205 | 206 | const runCleanup = (instance, isBeingDestroyed = false) => { 207 | if (instance.cleaner) { 208 | instance.cleaner(instance.savedData, instance, isBeingDestroyed); 209 | instance.cleaner = null; 210 | } 211 | }; 212 | 213 | const destroy = async (instance) => { 214 | // console.log("destroying", Components.get(id).name); 215 | 216 | // If there's an ongoing run, wait for it 217 | if (instance.pendingPromise) { 218 | // console.log("??? waiting for", Components.get(id).name, "to finish before destroying"); 219 | await instance.pendingPromise; 220 | // console.log("??? continuing with destroying", Components.get(id).name); 221 | } 222 | 223 | // Run the cleanup, if there is any 224 | runCleanup(instance, true); 225 | 226 | // Destroy children 227 | if (instance.children) { 228 | for (const child of instance.children) { 229 | destroy(child); 230 | } 231 | } 232 | 233 | if (import.meta.hot) { 234 | // Remove this instance from the HMR instance registry 235 | globalThis._rahtiHmrInstances?.get(instance.code)?.delete(instance); 236 | } 237 | 238 | // Clean up instance 239 | instance.currentIndex = 0; 240 | instance.needsUpdate = false; 241 | 242 | instance.parent = null; 243 | instance.lastValue = undefined; 244 | instance.lastArguments = null; 245 | instance.savedData = null; 246 | instance.cleaner = null; 247 | 248 | instance.key = null; 249 | instance.children = null; 250 | instance.code = null; 251 | 252 | // Add to pool for reuse 253 | instancePool.push(instance); 254 | }; 255 | 256 | let updateQueueWillRun = false; 257 | let updateQueueIsRunningImmediately = false; 258 | const updateQueue = new Set(); 259 | 260 | export const update = (instance, immediately = false) => { 261 | if (getInstance() === instance) return; 262 | updateQueue.add(instance); 263 | 264 | if (immediately) updateQueueIsRunningImmediately = true; 265 | 266 | if (!updateQueueWillRun) { 267 | updateQueueWillRun = true; 268 | if (immediately) { 269 | runUpdateQueue(); 270 | } else { 271 | requestIdleCallback(runUpdateQueue); 272 | } 273 | } 274 | }; 275 | 276 | export const updateParent = (instance, immediately = false) => { 277 | if (instance.parent && instance.parent !== topLevel && getInstance() !== instance.parent) 278 | update(instance.parent, immediately); 279 | }; 280 | 281 | const runUpdateQueue = function (deadline) { 282 | for (const instance of updateQueue) { 283 | if (!updateQueueIsRunningImmediately && deadline?.timeRemaining() === 0) { 284 | return requestIdleCallback(runUpdateQueue); 285 | } 286 | 287 | updateQueue.delete(instance); 288 | runUpdate(instance); 289 | } 290 | 291 | updateQueueWillRun = false; 292 | updateQueueIsRunningImmediately = false; 293 | }; 294 | 295 | const runUpdate = function (instance) { 296 | if (instance.code === null) { 297 | // console.log("=== cancelling update because instance is gone"); 298 | return; 299 | } 300 | 301 | instance.needsUpdate = true; 302 | const lastValue = instance.lastValue; 303 | const newValue = run(instance, instance.lastArguments); 304 | 305 | if (newValue !== lastValue) { 306 | // console.log("escalating update to parent from", instance.code); 307 | updateParent(instance); 308 | } 309 | }; 310 | 311 | if (import.meta.hot) { 312 | // Save the updater, so the HMR code can use it to update instances as needed 313 | globalThis._rahtiUpdate = update; 314 | } 315 | -------------------------------------------------------------------------------- /rahti/dom.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, load, save } from "./component.js"; 2 | 3 | export const Mount = new Proxy(function (to, ...children) { 4 | if (children.length > 0) processChildren(children, to, 0); 5 | return to; 6 | }, Component); 7 | 8 | const getElement = function ({ components, isSvg }, tagName) { 9 | let component = components.get(tagName); 10 | if (component) return component; 11 | 12 | component = new Proxy(function (...children) { 13 | const element = Element(tagName, isSvg); 14 | if (children.length > 0) processChildren(children, element, 0); 15 | return element; 16 | }, Component); 17 | 18 | components.set(tagName, component); 19 | return component; 20 | }; 21 | 22 | export const html = new Proxy( 23 | { components: new Map(), isSvg: false }, 24 | { 25 | get: getElement, 26 | }, 27 | ); 28 | 29 | export const svg = new Proxy( 30 | { components: new Map(), isSvg: true }, 31 | { 32 | get: getElement, 33 | }, 34 | ); 35 | 36 | const Element = new Proxy(function (tagName, isSvg) { 37 | const element = isSvg 38 | ? document.createElementNS("http://www.w3.org/2000/svg", tagName) 39 | : document.createElement(tagName); 40 | save(element); 41 | cleanup(cleanNode); 42 | return element; 43 | }, Component); 44 | 45 | const processChildren = function (children, element, slotIndex) { 46 | let currentSlotIndex = slotIndex; 47 | 48 | for (let index = 0, { length } = children; index < length; index++) { 49 | const child = children[index]; 50 | 51 | if (child instanceof Node) { 52 | // it's already an element of some kind, so let's just mount it 53 | Slot(child, element, currentSlotIndex); 54 | currentSlotIndex++; 55 | } else if (child instanceof EventOfferer) { 56 | const { type, listener, options } = child; 57 | EventListener(element, type, listener, options); 58 | } else if (Array.isArray(child)) { 59 | // treat as a list of grandchildren 60 | currentSlotIndex = processChildren(child, element, currentSlotIndex); 61 | } else if (typeof child === "object") { 62 | // treat as attributes 63 | Attributes(element, child); 64 | } else { 65 | const type = typeof child; 66 | 67 | if (type === "string" || type === "number") { 68 | // treat as Text 69 | const textNode = TextNode(); 70 | textNode.nodeValue = child; 71 | Slot(textNode, element, currentSlotIndex); 72 | currentSlotIndex++; 73 | } 74 | } 75 | } 76 | 77 | return currentSlotIndex; 78 | }; 79 | 80 | const TextNode = new Proxy(function () { 81 | const node = new Text(); 82 | save(node); 83 | cleanup(cleanNode); 84 | return node; 85 | }, Component); 86 | 87 | const removedNodes = new Set(); 88 | 89 | function cleanNode(node) { 90 | node.remove(); 91 | removedNodes.add(node); 92 | } 93 | 94 | const Attributes = new Proxy(function (element, attributes) { 95 | const newAttributes = new Map(); 96 | const previousAttributes = load(); 97 | 98 | for (const key in attributes) { 99 | const value = attributes[key]; 100 | newAttributes.set(key, value); 101 | if (previousAttributes) previousAttributes.delete(key); 102 | 103 | if (key === "style") { 104 | // inline style 105 | element.style.cssText = value; 106 | } else { 107 | // attribute 108 | if (typeof value === "boolean") { 109 | if (value) { 110 | element.setAttribute(key, key); 111 | } else { 112 | element.removeAttribute(key); 113 | } 114 | } else { 115 | element.setAttribute(key, value); 116 | } 117 | } 118 | } 119 | 120 | // Remove unused previous attributes 121 | if (previousAttributes) { 122 | for (const [key] in previousAttributes) { 123 | if (key === "style") { 124 | element.style.cssText = ""; 125 | } else { 126 | element.removeAttribute(key); 127 | } 128 | } 129 | } 130 | if (newAttributes?.size) save(newAttributes); 131 | }, Component); 132 | 133 | let slotQueueWillRun = false; 134 | const slotChildren = new Map(); 135 | const slotIndexes = new Map(); 136 | 137 | const Slot = new Proxy(function (child, parent, index) { 138 | slotChildren.set(child, parent); 139 | slotIndexes.set(child, index); 140 | 141 | if (!slotQueueWillRun) { 142 | slotQueueWillRun = true; 143 | queueMicrotask(processSlotQueue); 144 | } 145 | }, Component); 146 | 147 | const processSlotQueue = () => { 148 | for (const [child, parent] of slotChildren) { 149 | const index = slotIndexes.get(child); 150 | 151 | if (removedNodes.has(child)) { 152 | removedNodes.delete(child); 153 | child.remove(); 154 | } else if (index > parent.childNodes.length) { 155 | parent.appendChild(child); 156 | } else { 157 | const childInTheWay = parent.childNodes.item(index); 158 | if (childInTheWay !== child) parent.insertBefore(child, childInTheWay); 159 | } 160 | 161 | slotChildren.delete(child); 162 | slotIndexes.delete(child); 163 | } 164 | 165 | slotQueueWillRun = false; 166 | }; 167 | 168 | export const EventListener = new Proxy(function (target, type, listener, options) { 169 | target.addEventListener(type, listener, options); 170 | save([target, type, listener, options]); 171 | cleanup(cleanEventListener); 172 | }, Component); 173 | 174 | function cleanEventListener([target, type, listener, options]) { 175 | target.removeEventListener(type, listener, options); 176 | } 177 | 178 | export const EventHandler = new Proxy(function (type, listener, options) { 179 | const offerer = new EventOfferer(); 180 | offerer.type = type; 181 | offerer.listener = listener; 182 | offerer.options = options; 183 | return offerer; 184 | }, Component); 185 | 186 | class EventOfferer { 187 | type = "click"; 188 | listener = null; 189 | options = null; 190 | } 191 | -------------------------------------------------------------------------------- /rahti/globalState.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, getInstance, updateParent } from "./component.js"; 2 | 3 | export const GlobalState = new Proxy((initialValue) => { 4 | const instances = new Set(); 5 | const state = [ 6 | initialValue, 7 | (newValue, immediately = false) => { 8 | state[0] = newValue; 9 | for (const instance of instances) { 10 | updateParent(instance, immediately); 11 | } 12 | }, 13 | () => state[0], 14 | ]; 15 | 16 | const State = new Proxy(function () { 17 | instances.add(getInstance()); 18 | cleanup(cleanState); 19 | return state; 20 | }, Component); 21 | 22 | function cleanState(_, instance) { 23 | instances.delete(instance); 24 | } 25 | 26 | return [State, state[1], state[2]]; 27 | }, Component); 28 | -------------------------------------------------------------------------------- /rahti/idle.js: -------------------------------------------------------------------------------- 1 | export let requestIdleCallback = globalThis.requestIdleCallback; 2 | export let cancelIdleCallback = globalThis.cancelIdleCallback; 3 | 4 | if (!requestIdleCallback) { 5 | const timeAllowance = 8; 6 | let startedAt = performance.now(); 7 | const fallbackDeadline = { 8 | timeRemaining: () => Math.max(0, timeAllowance - (performance.now() - startedAt)), 9 | didTimeout: false, 10 | }; 11 | const { timeRemaining } = fallbackDeadline; 12 | const fallbackSchedule = new Set(); 13 | let fallbackStep = null; 14 | 15 | requestIdleCallback = (callback) => { 16 | fallbackSchedule.add(callback); 17 | fallbackStep = fallbackStep || setTimeout(runFallbackSchedule); 18 | return fallbackStep; 19 | }; 20 | 21 | cancelIdleCallback = (id) => clearTimeout(id); 22 | 23 | const runFallbackSchedule = () => { 24 | startedAt = performance.now(); 25 | 26 | for (const item of fallbackSchedule) { 27 | fallbackSchedule.delete(item); 28 | item(fallbackDeadline); 29 | if (timeRemaining() <= 0) break; 30 | } 31 | 32 | fallbackStep = fallbackSchedule.size > 0 ? setTimeout(runFallbackSchedule) : null; 33 | }; 34 | } 35 | 36 | let currentResolve = null; 37 | const promiseResolveCatcher = (resolve) => (currentResolve = resolve); 38 | let currentIdle = null; 39 | 40 | const idleCallback = (deadline) => { 41 | currentIdle = null; 42 | currentResolve(deadline); 43 | }; 44 | 45 | export const idle = async () => { 46 | if (!currentIdle) { 47 | currentIdle = new Promise(promiseResolveCatcher); 48 | requestIdleCallback(idleCallback); 49 | } 50 | 51 | return currentIdle; 52 | }; 53 | -------------------------------------------------------------------------------- /rahti/state.js: -------------------------------------------------------------------------------- 1 | import { Component, getInstance, load, save, updateParent } from "./component.js"; 2 | 3 | export const State = new Proxy(function (initialValue) { 4 | const state = load(); 5 | if (state) return state; 6 | 7 | const instance = getInstance(); 8 | 9 | const newState = [ 10 | initialValue, 11 | (newValue, immediately = false) => { 12 | newState[0] = newValue; 13 | updateParent(instance, immediately); 14 | }, 15 | () => newState[0], 16 | ]; 17 | 18 | return save(newState); 19 | }, Component); 20 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import TestWorker from "./TestWorker.js?worker"; 2 | import { App } from "./testApp.js"; 3 | 4 | new TestWorker(); 5 | 6 | App("hello"); 7 | -------------------------------------------------------------------------------- /testApp.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup } from "./rahti/component.js"; 2 | import { EventHandler, EventListener, Mount, html, svg } from "./rahti/dom.js"; 3 | import { GlobalState } from "./rahti/globalState.js"; 4 | import { State } from "./rahti/state.js"; 5 | import { TestGraphics } from "./testGraphics.js"; 6 | import { Webgl2App } from "./testWebgl2.js"; 7 | 8 | const [GlobalTest, setGlobalTest, getGlobalTest] = GlobalState(0); 9 | 10 | const TestWrapper = new Proxy(function () { 11 | GlobalTest(); 12 | 13 | const [counter, setState] = State(0); 14 | const timer = setTimeout(setState, 1000, counter + 1); 15 | const interval = setInterval(() => setGlobalTest(getGlobalTest() + 1), 5000); 16 | cleanup(() => { 17 | // console.log("cleaning additional timer", timer); 18 | clearTimeout(timer); 19 | clearInterval(interval); 20 | }); 21 | 22 | const testComponents = []; 23 | const max = 20; 24 | 25 | for (let index = 0; index < (0.5 + Math.random() * 0.5) * max; index++) { 26 | try { 27 | if (Math.random() > 0.1) testComponents.push(TestItem(counter, testComponents.length)); 28 | } catch (error) { 29 | reportError(error); 30 | } 31 | } 32 | 33 | return [ 34 | html.p( 35 | { style: "color: red" }, 36 | `Something is wrong if: a) there's ever`, 37 | html.em(" consistently "), 38 | `over ${max} items here, b)`, 39 | html.em(" every "), 40 | "element is flashing red on updates.", 41 | ), 42 | html.style(` 43 | * { 44 | animation: enter 1000ms ease-out; 45 | outline: 2px solid transparent; 46 | } 47 | 48 | @keyframes enter { 49 | 0% { outline-color: rgba(255,0,0, 0.618) } 50 | 100% { outline-color: transparent; } 51 | } 52 | `), 53 | html.ol( 54 | { class: "lol" }, 55 | EventHandler({ type: "click", listener: console.log }), 56 | testComponents, 57 | ), 58 | ]; 59 | }, Component); 60 | 61 | const TestItem = new Proxy( 62 | function (counter, index) { 63 | const [local, setLocal] = State(0); 64 | const [global] = GlobalTest(); 65 | 66 | const timer = setTimeout(setLocal, 1000 + Math.random() * 1000, Math.random()); 67 | cleanup(() => clearTimeout(timer)); 68 | if (Math.random() < 0.01) throw new Error("intentional test error"); 69 | 70 | return html.li( 71 | `(${index + 1})`, 72 | html.input({ type: "checkbox", checked: local > 0.5 }), 73 | ` Parent: ${counter} / Global: ${global} / Local: ${local}`, 74 | EventHandler({ type: "click", listener: (...args) => console.log(...args), passive: true }), 75 | ); 76 | }, 77 | { ...Component, getKey: (_, index) => index }, 78 | ); 79 | 80 | export const App = new Proxy(function (hello) { 81 | console.log("========", hello, "world"); 82 | 83 | const canvas = html.canvas({ style: "width: 100%; height: 25vh" }); 84 | const gfx = TestGraphics(canvas); 85 | 86 | Mount( 87 | document.body, 88 | canvas, 89 | TestWrapper(), 90 | svg.svg( 91 | svg.rect({ fill: "cyan", stroke: "turquoise", width: "300", height: "150" }), 92 | svg.text({ x: 100, y: 100 }, "SVG"), 93 | ), 94 | ); 95 | 96 | Webgl2App(gfx); 97 | 98 | EventListener(document.body, "click", console.log, { passive: true, once: true }); 99 | }, Component); 100 | -------------------------------------------------------------------------------- /testGraphics.js: -------------------------------------------------------------------------------- 1 | import { Component } from "./rahti/component.js"; 2 | import { 3 | Attribute, 4 | Camera, 5 | Command, 6 | Context, 7 | Elements, 8 | Instances, 9 | Texture, 10 | UniformBlock, 11 | } from "./webgl2/webgl2.js"; 12 | 13 | export const TestGraphics = new Proxy(function (canvas) { 14 | const context = Context({ canvas, debug: true }); 15 | 16 | const shape = Attribute({ 17 | context, 18 | data: [ 19 | Float32Array.of(0, 0), 20 | Float32Array.of(1, 0), 21 | Float32Array.of(1, 1), 22 | Float32Array.of(0, 1), 23 | ], 24 | }); 25 | const shared = UniformBlock({ context, uniforms: { time: 0, lightColor: [0, 0, 0] } }); 26 | const smallTexture = Texture({ 27 | context, 28 | pixels: new Uint8Array(64 * 64 * 4).fill(128), 29 | anisotropicFiltering: 16, 30 | }); 31 | const [cameraController, camera] = Camera({ context, fov: 90 }); 32 | cameraController.target[0] = 0.1; 33 | cameraController.target[1] = 0.1; 34 | 35 | const triangleElements = Elements({ context, data: Int16Array.of(0, 1, 2) }); 36 | const quadElements = Elements({ context, data: Int16Array.of(0, 1, 2, 2, 3, 0) }); 37 | 38 | const QuadInstance = Instances({ 39 | context: context, 40 | attributes: { 41 | color: [1, 1, 1], 42 | offset: [0, 0], 43 | }, 44 | }); 45 | 46 | const drawTriangle = Command({ 47 | context: context, 48 | attributes: { shape }, 49 | textures: { smallTexture }, 50 | elements: triangleElements, 51 | vertex: ` 52 | out vec2 textureCoordinates; 53 | void main () { 54 | textureCoordinates = shape; 55 | gl_Position = vec4(shape, 0.0, 1.0); 56 | } 57 | `, 58 | fragment: ` 59 | in vec2 textureCoordinates; 60 | out vec4 fragment; 61 | 62 | float fDistance(float x) { 63 | return length(vec2(dFdx(x), dFdy(x))); 64 | } 65 | 66 | float aLine(float threshold, float value, float thickness) { 67 | return clamp(thickness - abs(threshold - value) / fDistance(value), 0.0, 1.0); 68 | } 69 | 70 | void main () { 71 | fragment = vec4(texture(smallTexture, textureCoordinates).rgb, 1.0); 72 | fragment.rgb *= 1.0 - aLine(0.5, length(textureCoordinates), 1.0); 73 | } 74 | `, 75 | }); 76 | 77 | const drawQuads = Command({ 78 | context: context, 79 | attributes: { shape }, 80 | uniformBlocks: { camera }, 81 | elements: quadElements, 82 | instances: QuadInstance, 83 | vertex: ` 84 | out vec3 colorOut; 85 | 86 | void main () { 87 | colorOut = color; 88 | gl_Position = projectionView * vec4(shape + offset, -offset.x, 1.0); 89 | } 90 | `, 91 | fragment: ` 92 | in vec3 colorOut; 93 | out vec4 fragment; 94 | 95 | void main () { 96 | fragment = vec4(colorOut, 1.0); 97 | } 98 | `, 99 | }); 100 | 101 | return { 102 | frame: context.frame, 103 | resize: context.resize, 104 | drawTriangle, 105 | drawQuads, 106 | clear: context.clear, 107 | QuadInstance, 108 | shared, 109 | cameraController, 110 | smallTexture, 111 | }; 112 | }, Component); 113 | -------------------------------------------------------------------------------- /testWebgl2.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, load, save, update } from "./rahti/component"; 2 | import { EventListener } from "./rahti/dom"; 3 | import { State } from "./rahti/state"; 4 | import { AnimationFrame } from "./webgl2/animationFrame"; 5 | 6 | export const Webgl2App = new Proxy(function ({ 7 | smallTexture, 8 | QuadInstance, 9 | cameraController, 10 | frame, 11 | clear, 12 | drawTriangle, 13 | drawQuads, 14 | }) { 15 | EventListener(document, "pointermove", ({ x, y }) => { 16 | cameraController.target[0] = -x * 0.001; 17 | cameraController.target[1] = y * 0.001; 18 | }); 19 | TriangleUpdater(smallTexture); 20 | 21 | Quads(QuadInstance); 22 | 23 | frame(() => { 24 | clear(); 25 | drawTriangle(); 26 | drawQuads(); 27 | }); 28 | }, Component); 29 | 30 | // export const lol = "lol"; 31 | 32 | const TriangleUpdater = new Proxy(function (smallTexture) { 33 | AnimationFrame(); 34 | smallTexture.update( 35 | Uint8Array.of(Math.random() * 255, Math.random() * 255, Math.random() * 255, 255), 36 | Math.random() * 64, 37 | Math.random() * 64, 38 | ); 39 | }, Component); 40 | 41 | const Quads = new Proxy(function (QuadInstance) { 42 | const [max, setMax] = State(100); 43 | save(setTimeout(setMax, Math.random() * 2000, 100 * (0.5 + Math.random() * 0.5))); 44 | cleanup(cleanTimer); 45 | 46 | // const max = 100 * (0.5 + Math.random() * 0.5); 47 | // AnimationFrame(); 48 | 49 | for (let index = 0; index < max; index++) { 50 | if (Math.random() < 0.01) continue; 51 | Quad(index, QuadInstance); 52 | } 53 | }, Component); 54 | 55 | const cleanTimer = (timer) => clearTimeout(timer); 56 | 57 | const Quad = new Proxy( 58 | function (index, QuadInstance) { 59 | const instance = QuadInstance(); 60 | instance.offset[0] = -index * 0.02; 61 | instance.offset[1] = -index * 0.02; 62 | instance.color[0] = Math.random(); 63 | instance.color[1] = Math.random(); 64 | instance.color[2] = Math.random(); 65 | 66 | QuadUpdater(instance); 67 | }, 68 | { ...Component, getKey: (index) => index }, 69 | ); 70 | 71 | const QuadUpdater = new Proxy(function (instance) { 72 | AnimationFrame(); 73 | instance.offset[0] += (Math.random() * 2 - 1) * 0.003; 74 | instance.offset[1] += (Math.random() * 2 - 1) * 0.003; 75 | }, Component); 76 | -------------------------------------------------------------------------------- /vite-plugin-rahti/vite-plugin-rahti.js: -------------------------------------------------------------------------------- 1 | export const rahtiPlugin = () => { 2 | let config; 3 | 4 | return { 5 | name: "add-rahti-hmr-handlers", 6 | apply: "serve", 7 | configResolved(resolvedConfig) { 8 | config = resolvedConfig; 9 | }, 10 | transform(src, id) { 11 | const path = id.split("?")[0]; 12 | 13 | if ( 14 | config.command === "build" || 15 | config.isProduction || 16 | id.includes("node_modules") || 17 | !( 18 | path.endsWith(".js") || 19 | path.endsWith(".jsx") || 20 | path.endsWith(".tx") || 21 | path.endsWith(".tsx") 22 | ) || 23 | !src.includes("Component") || 24 | !src.includes("new Proxy(") 25 | ) { 26 | return; 27 | } 28 | 29 | const code = src + getHmrCode(id); 30 | return { code }; 31 | }, 32 | }; 33 | }; 34 | 35 | const getHmrCode = (fileId) => { 36 | let hmrInjection = hmrCode.toString().split("\n"); 37 | hmrInjection = hmrInjection.slice(1, hmrInjection.length - 1).join("\n"); 38 | 39 | return `import * as thisModule from /* @vite-ignore */"${fileId}"; 40 | if (import.meta.hot) { 41 | // Rahti HMR handler 42 | const _rahtiFileName = "${fileId}"; 43 | ${hmrInjection} 44 | }`; 45 | }; 46 | 47 | const hmrCode = () => { 48 | // Create HMR registries, if they haven't been already 49 | globalThis._rahtiHmrOriginalModules = globalThis._rahtiHmrOriginalModules || new Map(); 50 | globalThis._rahtiHmrComponentReplacements = 51 | globalThis._rahtiHmrComponentReplacements || new Map(); 52 | globalThis._rahtiHmrComponentVersions = globalThis._rahtiHmrComponentVersions || new Map(); 53 | globalThis._rahtiHmrInstances = globalThis._rahtiHmrInstances || new Map(); 54 | 55 | // Save the original version of this module 56 | if (!globalThis._rahtiHmrOriginalModules.has(_rahtiFileName)) { 57 | // console.log("First glimpse of", _rahtiFileName, thisModule); 58 | globalThis._rahtiHmrOriginalModules.set(_rahtiFileName, thisModule); 59 | 60 | // Start registries for components in it 61 | 62 | for (const name in thisModule) { 63 | const code = thisModule[name]?._rahtiCode; 64 | if (code) { 65 | // console.log("Starting registries for", name); 66 | globalThis._rahtiHmrInstances.set(code, new Set()); 67 | globalThis._rahtiHmrComponentVersions.set(code, new Set([code])); 68 | } 69 | } 70 | } 71 | 72 | import.meta.hot.accept((newModule) => { 73 | if (!newModule) { 74 | return import.meta.hot.invalidate("No new module (syntax error?)"); 75 | } 76 | 77 | // Go through the new module 78 | const originalModule = globalThis._rahtiHmrOriginalModules.get(_rahtiFileName); 79 | let featuresChecked = 0; 80 | 81 | for (const name in newModule) { 82 | featuresChecked++; 83 | 84 | const originalCode = originalModule[name]?._rahtiCode; 85 | const previousCode = globalThis._rahtiHmrComponentReplacements.get(originalCode); 86 | const newCode = newModule[name]?._rahtiCode; 87 | 88 | if (!newCode || !originalCode) { 89 | // FIXME: I would import.meta.hot.invalidate here, 90 | // but the self-import seems to throw it into an infinite loop. 91 | return console.warn( 92 | `[vite-plugin-rahti] \`${name}\` does not seem to be a Component. HMR only works reliably if the module exports nothing but Components.`, 93 | ); 94 | } 95 | 96 | // console.log("HMR is updating", name, globalThis._rahtiHmrInstances.get(originalCode)); 97 | 98 | // Mark this as the replacement for the original version 99 | globalThis._rahtiHmrComponentReplacements.set(originalCode, newCode); 100 | // … and same for the previous version, if there is one 101 | if (previousCode) { 102 | globalThis._rahtiHmrComponentReplacements.set(previousCode, newCode); 103 | } 104 | 105 | // Keep track of Component versions 106 | const versions = globalThis._rahtiHmrComponentVersions.get(originalCode); 107 | let instancesUpdated = 0; 108 | 109 | // Tell instances using any of the now outdated versions to update 110 | for (const version of versions) { 111 | if (globalThis._rahtiHmrInstances.has(version)) { 112 | for (const instance of globalThis._rahtiHmrInstances.get(version)) { 113 | instancesUpdated++; 114 | if (globalThis._rahtiUpdate) globalThis._rahtiUpdate(instance); 115 | } 116 | } 117 | } 118 | 119 | versions.add(newCode); 120 | console.log(`[vite-plugin-rahti] hot updated: ${instancesUpdated} instances of ${name}`); 121 | } 122 | 123 | if (featuresChecked === 0) import.meta.hot.invalidate("No exports"); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { rahtiPlugin } from "./vite-plugin-rahti/vite-plugin-rahti.js"; 2 | 3 | export default { 4 | plugins: [rahtiPlugin()], 5 | }; 6 | -------------------------------------------------------------------------------- /webgl2/animationFrame.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, getInstance, updateParent } from "../rahti/component.js"; 2 | 3 | export const preRenderJobs = new Set(); 4 | export const renderJobs = new Set(); 5 | export const postRenderJobs = new Set(); 6 | let frameNumber = 0; 7 | let totalSubscribers = 0; 8 | let frame = null; 9 | 10 | const subscribers = new Set(); 11 | 12 | export const subscribeToAnimationFrame = (callback) => { 13 | if (!subscribers.has(callback)) { 14 | subscribers.add(callback); 15 | totalSubscribers++; 16 | frame = frame || requestAnimationFrame(runAnimationFrame); 17 | } 18 | }; 19 | 20 | export const unsubscribeFromAnimationFrame = (callback) => { 21 | if (subscribers.has(callback)) { 22 | subscribers.delete(callback); 23 | totalSubscribers--; 24 | } 25 | }; 26 | 27 | const componentSubscribers = new Set(); 28 | 29 | const componentProps = { timestamp: performance.now(), sinceLastFrame: 1, frameNumber: 0 }; 30 | const runComponents = (timestamp, sinceLastFrame, frameNumber) => { 31 | componentProps.timestamp = timestamp; 32 | componentProps.sinceLastFrame = sinceLastFrame; 33 | componentProps.frameNumber = frameNumber; 34 | 35 | for (const instance of componentSubscribers) { 36 | updateParent(instance, true); 37 | } 38 | }; 39 | 40 | export const AnimationFrame = new Proxy(function () { 41 | componentSubscribers.add(getInstance()); 42 | cleanup(cleanAnimationFrame); 43 | if (!subscribers.has(runComponents)) subscribeToAnimationFrame(runComponents); 44 | 45 | return componentProps; 46 | }, Component); 47 | 48 | function cleanAnimationFrame(_, instance) { 49 | componentSubscribers.delete(instance); 50 | if (componentSubscribers.size === 0) unsubscribeFromAnimationFrame(runComponents); 51 | } 52 | 53 | export const requestPreRenderJob = (job) => { 54 | preRenderJobs.add(job); 55 | frame = frame || requestAnimationFrame(runAnimationFrame); 56 | }; 57 | export const requestRenderJob = (job) => { 58 | renderJobs.add(job); 59 | frame = frame || requestAnimationFrame(runAnimationFrame); 60 | }; 61 | export const requestPostRenderJob = (job) => { 62 | postRenderJobs.add(job); 63 | frame = frame || requestAnimationFrame(runAnimationFrame); 64 | }; 65 | 66 | export const cancelPreRenderJob = (job) => preRenderJobs.delete(job); 67 | export const cancelRenderJob = (job) => renderJobs.delete(job); 68 | 69 | export const cancelJobsAndStopFrame = () => { 70 | if (frame) { 71 | cancelAnimationFrame(frame); 72 | frame = null; 73 | } 74 | 75 | preRenderJobs.clear(); 76 | renderJobs.clear(); 77 | postRenderJobs.clear(); 78 | }; 79 | 80 | let lastTime = performance.now(); 81 | 82 | const runAnimationFrame = () => { 83 | // Using performance.now() here because in Safari the timestamp 84 | // passed by RAF is currently not a DOMHighResTimeStamp. 85 | // I don't know why. 86 | const timestamp = performance.now(); 87 | const sinceLastFrame = Math.min(timestamp - lastTime, 100); 88 | lastTime = timestamp; 89 | 90 | for (const callback of subscribers) { 91 | callback(timestamp, sinceLastFrame, frameNumber); 92 | } 93 | 94 | for (const job of preRenderJobs) { 95 | preRenderJobs.delete(job); 96 | job(); 97 | } 98 | 99 | for (const job of renderJobs) { 100 | renderJobs.delete(job); 101 | job(timestamp, sinceLastFrame, frameNumber); 102 | } 103 | 104 | for (const job of postRenderJobs) { 105 | postRenderJobs.delete(job); 106 | job(); 107 | } 108 | 109 | frameNumber++; 110 | 111 | if (totalSubscribers !== 0) { 112 | frame = requestAnimationFrame(runAnimationFrame); 113 | } else { 114 | frame = null; 115 | } 116 | }; 117 | -------------------------------------------------------------------------------- /webgl2/buffer.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup } from "../rahti/component.js"; 2 | import { requestPreRenderJob } from "./animationFrame.js"; 3 | 4 | export const Buffer = new Proxy(function ({ 5 | context, 6 | data, 7 | binding = "ARRAY_BUFFER", 8 | usage = "STATIC_DRAW", 9 | types = dataToTypes(data[0]), 10 | }) { 11 | const { gl, setBuffer, requestRendering } = context; 12 | const BINDING = gl[binding]; 13 | 14 | const [bufferType, shaderType] = types; 15 | const Constructor = bufferTypeToConstructor(bufferType); 16 | 17 | const buffer = gl.createBuffer(); 18 | setBuffer(buffer, BINDING); 19 | 20 | const dimensions = data[0].length || 1; 21 | let allData = new Constructor(data.length * dimensions); 22 | 23 | for (let index = 0; index < data.length; index++) { 24 | const datum = data[index]; 25 | 26 | if (dimensions > 1) { 27 | allData.set(datum, index * dimensions); 28 | } else { 29 | allData[index] = datum; 30 | } 31 | } 32 | 33 | const { BYTES_PER_ELEMENT } = allData; 34 | const count = data.length; 35 | const USAGE = gl[usage]; 36 | 37 | const countSubscribers = new Set(); 38 | 39 | const bufferObject = { 40 | allData, 41 | buffer, 42 | bufferType, 43 | shaderType, 44 | Constructor, 45 | count, 46 | dimensions, 47 | countSubscribers, 48 | }; 49 | 50 | let firstDirty = Number.Infinity; 51 | let lastDirty = 0; 52 | let shouldSet = true; 53 | 54 | const set = (data = allData) => { 55 | if (dead) return; 56 | 57 | allData = data; 58 | bufferObject.allData = allData; 59 | bufferObject.count = allData.length / dimensions; 60 | 61 | for (const subscriber of countSubscribers) { 62 | subscriber(bufferObject.count); 63 | } 64 | 65 | shouldSet = true; 66 | requestPreRenderJob(commitUpdates); 67 | }; 68 | 69 | requestPreRenderJob(set); 70 | 71 | const update = function (data, offset) { 72 | if (dead) return; 73 | 74 | const length = data?.length; 75 | 76 | if (length) { 77 | allData.set(data, offset); 78 | } else { 79 | allData[offset] = data; 80 | } 81 | 82 | markAsNeedingUpdate(offset, offset + length); 83 | }; 84 | 85 | const markAsNeedingUpdate = function (from, to) { 86 | firstDirty = Math.min(from, firstDirty); 87 | lastDirty = Math.max(to, lastDirty); 88 | 89 | requestPreRenderJob(commitUpdates); 90 | }; 91 | 92 | const commitUpdates = function () { 93 | if (dead) return; 94 | 95 | setBuffer(buffer, BINDING); 96 | 97 | if (shouldSet) { 98 | // console.log("set", bufferObject.count); 99 | gl.bufferData(BINDING, allData, USAGE); 100 | shouldSet = false; 101 | } else { 102 | // console.log("update", allData.length, firstDirty, lastDirty); 103 | gl.bufferSubData( 104 | BINDING, 105 | firstDirty * BYTES_PER_ELEMENT, 106 | allData, 107 | firstDirty, 108 | lastDirty - firstDirty, 109 | ); 110 | } 111 | 112 | firstDirty = Number.Infinity; 113 | lastDirty = 0; 114 | 115 | requestRendering(); 116 | }; 117 | 118 | bufferObject.set = set; 119 | bufferObject.update = update; 120 | bufferObject.markAsNeedingUpdate = markAsNeedingUpdate; 121 | 122 | let dead = false; 123 | 124 | cleanup(() => { 125 | dead = true; 126 | gl.deleteBuffer(buffer); 127 | }); 128 | 129 | return bufferObject; 130 | }, Component); 131 | 132 | export const dataToTypes = (data) => { 133 | if (typeof data === "number") { 134 | return ["FLOAT", "float"]; 135 | } 136 | 137 | if (typeof data === "boolean") { 138 | return ["BYTE", "bool"]; 139 | } 140 | 141 | if (Array.isArray(data)) { 142 | return ["FLOAT", data.length > 4 ? `mat${Math.sqrt(data.length)}` : `vec${data.length}`]; 143 | } 144 | 145 | switch (data.constructor.name) { 146 | case "Float32Array": 147 | return ["FLOAT", data.length > 4 ? `mat${Math.sqrt(data.length)}` : `vec${data.length}`]; 148 | case "Int8Array": 149 | return ["BYTE", `ivec${data.length}`]; 150 | case "Uint8Array": 151 | case "Uint8ClampedArray": 152 | return ["UNSIGNED_BYTE", `uvec${data.length}`]; 153 | case "Int16Array": 154 | return ["SHORT", `ivec${data.length}`]; 155 | case "Uint16Array": 156 | return ["UNSIGNED_SHORT", `uvec${data.length}`]; 157 | default: 158 | throw new Error("Finding types failed"); 159 | } 160 | }; 161 | 162 | export const bufferTypeToConstructor = (type) => { 163 | switch (type) { 164 | case "BYTE": 165 | return Int8Array; 166 | case "UNSIGNED_BYTE": 167 | return Uint8Array; 168 | case "SHORT": 169 | return Int16Array; 170 | case "UNSIGNED_SHORT": 171 | return Uint16Array; 172 | default: 173 | return Float32Array; 174 | } 175 | }; 176 | -------------------------------------------------------------------------------- /webgl2/camera.js: -------------------------------------------------------------------------------- 1 | import { create, invert, lookAt, multiply, ortho, perspective } from "gl-mat4-esm"; 2 | import { Component, cleanup } from "../rahti/component.js"; 3 | import { requestPreRenderJob } from "./animationFrame.js"; 4 | import { UniformBlock } from "./uniformBlock.js"; 5 | 6 | export const defaultCameraIncludes = new Set([ 7 | // "projection", 8 | // "view", 9 | "projectionView", 10 | "inverseProjectionView", 11 | "cameraPosition", 12 | "cameraTarget", 13 | "cameraDirection", 14 | "cameraDistanceToTarget", 15 | // "cameraUp", 16 | // "cameraNear", 17 | // "cameraFar", 18 | // "cameraZoom", 19 | // "cameraFov", 20 | // "aspectRatio", 21 | // "pixelRatio", 22 | ]); 23 | 24 | export const Camera = new Proxy(function ({ 25 | context, 26 | fov = 60, 27 | near = 0.1, 28 | far = 1000, 29 | zoom = 1, 30 | position: inputPosition = [0, 0, 2], 31 | target: inputTarget = [0, 0, 0], 32 | up: inputUp = [0, 0, 1], 33 | include = defaultCameraIncludes, 34 | }) { 35 | const subscribers = new Set(); 36 | const subscribe = (callback) => subscribers.add(callback); 37 | const unsubscribe = (callback) => subscribers.delete(callback); 38 | 39 | let width = context?.gl?.drawingBufferWidth || 1; 40 | let height = context?.gl?.drawingBufferHeight || 1; 41 | let aspectRatio = width / height; 42 | let pixelRatio = globalThis.devicePixelRatio || 1; 43 | let projectionNeedsUpdate = true; 44 | 45 | const projection = create(); 46 | const view = create(); 47 | const projectionView = create(); 48 | const inverseProjectionView = create(); 49 | 50 | const position = Float32Array.from(inputPosition); 51 | const target = Float32Array.from(inputTarget); 52 | const up = Float32Array.from(inputUp); 53 | 54 | const direction = Float32Array.from([0, 0, -1]); 55 | let distance = 1; 56 | 57 | const calculateDirectionAndDistance = () => { 58 | direction[0] = target[0] - position[0]; 59 | direction[1] = target[1] - position[1]; 60 | direction[2] = target[2] - position[2]; 61 | 62 | const sum = 63 | direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]; 64 | distance = Math.sqrt(sum); 65 | 66 | direction[0] /= distance; 67 | direction[1] /= distance; 68 | direction[2] /= distance; 69 | }; 70 | 71 | const uniforms = {}; 72 | if (include.has("projection")) uniforms.projection = projection; 73 | if (include.has("view")) uniforms.view = view; 74 | if (include.has("projectionView")) uniforms.projectionView = projectionView; 75 | if (include.has("inverseProjectionView")) uniforms.inverseProjectionView = inverseProjectionView; 76 | if (include.has("cameraPosition")) uniforms.cameraPosition = position; 77 | if (include.has("cameraTarget")) uniforms.cameraTarget = target; 78 | if (include.has("cameraDirection")) uniforms.cameraDirection = direction; 79 | if (include.has("cameraDistanceToTarget")) uniforms.cameraDistanceToTarget = distance; 80 | if (include.has("cameraUp")) uniforms.cameraUp = up; 81 | if (include.has("cameraNear")) uniforms.cameraNear = near; 82 | if (include.has("cameraFar")) uniforms.cameraFar = far; 83 | if (include.has("cameraZoom")) uniforms.cameraZoom = zoom; 84 | if (include.has("cameraFov")) uniforms.cameraFov = fov; 85 | if (include.has("aspectRatio")) uniforms.aspectRatio = aspectRatio; 86 | if (include.has("pixelRatio")) uniforms.pixelRatio = pixelRatio; 87 | 88 | const block = UniformBlock({ context, uniforms }); 89 | 90 | const update = (key, value) => { 91 | if (include.has(key)) block.update(key, value); 92 | }; 93 | 94 | const updateProjection = () => { 95 | aspectRatio = width / height; 96 | 97 | if (fov) { 98 | const fovInRadians = (fov * Math.PI) / 180; 99 | perspective(projection, fovInRadians, aspectRatio, near, far); 100 | } else { 101 | const worldWidth = width > height ? width / height : 1; 102 | const worldHeight = width > height ? 1 : height / width; 103 | 104 | const left = -worldWidth / 2 / zoom; 105 | const right = worldWidth / 2 / zoom; 106 | const top = worldHeight / 2 / zoom; 107 | const bottom = -worldHeight / 2 / zoom; 108 | 109 | ortho(projection, left, right, bottom, top, near, far); 110 | } 111 | 112 | update("projection", projection); 113 | update("cameraNear", near); 114 | update("cameraFar", far); 115 | update("cameraZoom", zoom); 116 | update("cameraFov", fov); 117 | update("aspectRatio", aspectRatio); 118 | }; 119 | 120 | const updateView = () => { 121 | lookAt(view, position, target, up); 122 | 123 | update("cameraPosition", position); 124 | update("cameraTarget", target); 125 | update("cameraUp", up); 126 | 127 | calculateDirectionAndDistance(); 128 | update("cameraDirection", direction); 129 | update("cameraDistanceToTarget", distance); 130 | 131 | update("view", view); 132 | }; 133 | 134 | const updateCombined = () => { 135 | multiply(projectionView, projection, view); 136 | invert(inverseProjectionView, projectionView); 137 | 138 | update("projectionView", projectionView); 139 | update("inverseProjectionView", inverseProjectionView); 140 | }; 141 | 142 | const updateCamera = () => { 143 | if (projectionNeedsUpdate) updateProjection(); 144 | updateView(); 145 | updateCombined(); 146 | 147 | for (const subscriber of subscribers) { 148 | subscriber(camera); 149 | } 150 | }; 151 | 152 | const updateDevicePixelRatio = () => { 153 | update("pixelRatio", pixelRatio); 154 | }; 155 | 156 | const handleResize = (_, __, renderWidth, renderHeight, ratio) => { 157 | width = renderWidth; 158 | height = renderHeight; 159 | 160 | projectionNeedsUpdate = true; 161 | requestPreRenderJob(updateCamera); 162 | 163 | if (ratio !== pixelRatio) { 164 | pixelRatio = ratio; 165 | requestPreRenderJob(updateDevicePixelRatio); 166 | } 167 | }; 168 | 169 | context.subscribe(handleResize); 170 | cleanup(() => { 171 | context.unsubscribe(handleResize); 172 | }); 173 | 174 | const proxyHandler = { 175 | set: function (target, prop, newValue) { 176 | target[prop] = newValue; 177 | 178 | if (prop == "0" || prop == "1" || prop == "2") { 179 | requestPreRenderJob(updateCamera); 180 | } 181 | 182 | return true; 183 | }, 184 | get(target, prop) { 185 | return target[prop]; 186 | }, 187 | }; 188 | 189 | const camera = { 190 | position: new Proxy(position, proxyHandler), 191 | target: new Proxy(target, proxyHandler), 192 | up: new Proxy(up, proxyHandler), 193 | 194 | projection, 195 | view, 196 | projectionView, 197 | inverseProjectionView, 198 | direction, 199 | 200 | get width() { 201 | return width; 202 | }, 203 | get height() { 204 | return height; 205 | }, 206 | get aspectRatio() { 207 | return aspectRatio; 208 | }, 209 | get pixelRatio() { 210 | return aspectRatio; 211 | }, 212 | 213 | subscribe, 214 | unsubscribe, 215 | 216 | get fov() { 217 | return fov; 218 | }, 219 | get near() { 220 | return near; 221 | }, 222 | get far() { 223 | return far; 224 | }, 225 | get zoom() { 226 | return zoom; 227 | }, 228 | 229 | set fov(input) { 230 | if (input !== fov) { 231 | fov = input; 232 | projectionNeedsUpdate = true; 233 | requestPreRenderJob(updateCamera); 234 | } 235 | }, 236 | set near(input) { 237 | if (input !== near) { 238 | near = input; 239 | projectionNeedsUpdate = true; 240 | requestPreRenderJob(updateCamera); 241 | } 242 | }, 243 | set far(input) { 244 | if (input !== far) { 245 | far = input; 246 | projectionNeedsUpdate = true; 247 | requestPreRenderJob(updateCamera); 248 | } 249 | }, 250 | set zoom(input) { 251 | if (input !== zoom) { 252 | zoom = input; 253 | projectionNeedsUpdate = true; 254 | requestPreRenderJob(updateCamera); 255 | } 256 | }, 257 | }; 258 | 259 | requestPreRenderJob(updateCamera); 260 | 261 | return [camera, block]; 262 | }, Component); 263 | -------------------------------------------------------------------------------- /webgl2/command.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup } from "../rahti/component.js"; 2 | 3 | const blank = {}; 4 | 5 | export const Command = new Proxy(function ({ 6 | context, 7 | 8 | // Static 9 | attributes = blank, 10 | uniformBlocks = blank, 11 | textures = blank, 12 | instances, 13 | elements, 14 | 15 | vert, 16 | vertex = vert, 17 | frag, 18 | fragment = frag, 19 | 20 | shaderVersion = "#version 300 es", 21 | vertexPrecision = "", 22 | fragmentPrecision = "precision mediump float;", 23 | 24 | // Can be overridden at runtime 25 | mode = "TRIANGLES", 26 | depth = "LESS", 27 | cull = "BACK", 28 | blend = null, 29 | 30 | // Runtime-only overrides 31 | // count: overrideCount, 32 | // instanceCount: overrideInstanceCount, 33 | 34 | // Later 35 | // target, https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/framebufferTexture2D 36 | // attachments, https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_draw_buffers 37 | }) { 38 | const { gl, setBuffer, setProgram, setVao, setDepth, setCull, setBlend, debug } = context; 39 | if (!vertex || !fragment) throw new Error("missing vertex or fragment shader"); 40 | if (attributes === blank) throw new Error("missing at least one attribute"); 41 | 42 | const isInstanced = !!instances; 43 | const instanceAttributes = instances?._attributes; 44 | const instanceList = instances?._instancesToSlots; 45 | const usesElements = !!elements; 46 | const UNSIGNED_SHORT = gl.UNSIGNED_SHORT; 47 | 48 | // Shaders 49 | let attributeLines = ""; 50 | let textureLines = ""; 51 | let uniformBlockLines = ""; 52 | 53 | for (const key in attributes) { 54 | const { shaderType } = attributes[key]; 55 | attributeLines += `in ${shaderType} ${key};\n`; 56 | } 57 | 58 | if (isInstanced) { 59 | for (const [key, { shaderType }] of instanceAttributes) { 60 | attributeLines += `in ${shaderType} ${key};\n`; 61 | } 62 | } 63 | 64 | for (const key in textures) { 65 | const { shaderType } = textures[key]; 66 | textureLines += `uniform ${shaderType} ${key};\n`; 67 | } 68 | 69 | for (const key in uniformBlocks) { 70 | const { uniforms } = uniformBlocks[key]; 71 | uniformBlockLines += `layout(std140) uniform ${key} {\n`; 72 | for (const key in uniforms) { 73 | const { shaderType } = uniforms[key]; 74 | uniformBlockLines += ` highp ${shaderType} ${key};\n`; 75 | } 76 | uniformBlockLines += "};\n"; 77 | } 78 | 79 | const finalVertex = `${shaderVersion} 80 | ${vertexPrecision} 81 | ${attributeLines} 82 | ${textureLines} 83 | ${uniformBlockLines} 84 | ${vertex}`; 85 | const finalFragment = `${shaderVersion} 86 | ${fragmentPrecision} 87 | ${textureLines} 88 | ${uniformBlockLines} 89 | ${fragment}`; 90 | 91 | // Compile shaders 92 | const vertexShader = gl.createShader(gl.VERTEX_SHADER); 93 | const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); 94 | gl.shaderSource(vertexShader, finalVertex); 95 | gl.shaderSource(fragmentShader, finalFragment); 96 | 97 | gl.compileShader(vertexShader); 98 | gl.compileShader(fragmentShader); 99 | 100 | // Compile program 101 | const program = gl.createProgram(); 102 | gl.attachShader(program, vertexShader); 103 | gl.attachShader(program, fragmentShader); 104 | gl.linkProgram(program); 105 | 106 | // Log errors, but only in dev 107 | if (debug && !gl.getProgramParameter(program, gl.LINK_STATUS)) { 108 | const vertexLog = gl.getShaderInfoLog(vertexShader); 109 | const fragmentLog = gl.getShaderInfoLog(fragmentShader); 110 | if (vertexLog) logError(vertexLog, finalVertex); 111 | if (fragmentLog) logError(fragmentLog, finalFragment); 112 | throw new Error(gl.getProgramInfoLog(program)); 113 | } 114 | 115 | setProgram(program); 116 | 117 | // VAO for elements and attributes 118 | const vao = gl.createVertexArray(); 119 | setBuffer(); 120 | setVao(vao); 121 | 122 | // Elements 123 | if (usesElements) setBuffer(elements.buffer, gl.ELEMENT_ARRAY_BUFFER); 124 | 125 | // Attribute vertex count 126 | let count = Number.Infinity; 127 | const measureCount = () => { 128 | count = Number.Infinity; 129 | for (const key in attributes) { 130 | const attribute = attributes[key]; 131 | count = Math.min(count, attribute.count); 132 | } 133 | }; 134 | for (const key in attributes) { 135 | const attribute = attributes[key]; 136 | attribute.countSubscribers.add(measureCount); 137 | } 138 | measureCount(); 139 | 140 | let dead = false; 141 | 142 | // Rahti cleanup 143 | cleanup(() => { 144 | dead = true; 145 | 146 | gl.deleteShader(vertexShader); 147 | gl.deleteShader(fragmentShader); 148 | gl.deleteProgram(program); 149 | gl.deleteVertexArray(vao); 150 | 151 | for (const key in attributes) { 152 | const { countSubscribers } = attributes[key]; 153 | countSubscribers.delete(measureCount); 154 | } 155 | }); 156 | 157 | // Attributes 158 | for (let i = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES) - 1; i >= 0; i--) { 159 | const { name } = gl.getActiveAttrib(program, i); 160 | const location = gl.getAttribLocation(program, name); 161 | 162 | const instancedAttribute = instanceAttributes?.get(name); 163 | const attribute = attributes[name] || instancedAttribute; 164 | const isInstanced = !!instancedAttribute; 165 | 166 | if (location === -1 || !attribute) { 167 | console.warn(`Failed linking attribute ${name}`); 168 | continue; 169 | } 170 | 171 | if (isInstanced) { 172 | gl.vertexAttribDivisor(location, 1); 173 | } 174 | 175 | gl.enableVertexAttribArray(location); 176 | setBuffer(attribute.buffer); 177 | gl.vertexAttribPointer(location, attribute.dimensions, gl[attribute.bufferType], false, 0, 0); 178 | } 179 | 180 | // Turn off vao after bindings are done 181 | setVao(); 182 | 183 | // Uniform blocks 184 | for (let i = gl.getProgramParameter(program, gl.ACTIVE_UNIFORM_BLOCKS) - 1; i >= 0; i--) { 185 | const name = gl.getActiveUniformBlockName(program, i); 186 | const index = gl.getUniformBlockIndex(program, name); 187 | 188 | const uniformBlock = uniformBlocks[name]; 189 | 190 | if (index === -1 || uniformBlock.bindIndex === undefined) { 191 | console.warn(`Failed linking uniform block ${name}`); 192 | continue; 193 | } 194 | 195 | gl.uniformBlockBinding(program, index, uniformBlock.bindIndex); 196 | } 197 | 198 | // Textures 199 | const totalUniforms = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); 200 | const indexes = [...Array(totalUniforms).keys()]; 201 | const blockIndexes = gl.getActiveUniforms(program, indexes, gl.UNIFORM_BLOCK_INDEX); 202 | const indexesOfNonBlockUniforms = indexes.filter((index) => blockIndexes[index] === -1); 203 | 204 | for (const i of indexesOfNonBlockUniforms) { 205 | const { name } = gl.getActiveUniform(program, i); 206 | const location = gl.getUniformLocation(program, name); 207 | 208 | const texture = textures[name]; 209 | 210 | if (location === -1 || texture.index === undefined) { 211 | console.warn(`Failed linking texture ${name}`); 212 | continue; 213 | } 214 | 215 | gl.uniform1i(location, texture.index); 216 | } 217 | 218 | // Render 219 | let executeRender; 220 | 221 | if (isInstanced) { 222 | if (usesElements) { 223 | executeRender = (mode, count, instanceCount) => 224 | gl.drawElementsInstanced(mode, count, UNSIGNED_SHORT, 0, instanceCount); 225 | } else { 226 | executeRender = (mode, count, instanceCount) => 227 | gl.drawArraysInstanced(mode, 0, count, instanceCount); 228 | } 229 | } else { 230 | if (usesElements) { 231 | executeRender = (mode, count) => gl.drawElements(mode, count, UNSIGNED_SHORT, 0); 232 | } else { 233 | executeRender = (mode, count) => gl.drawArrays(mode, 0, count); 234 | } 235 | } 236 | 237 | const render = ( 238 | overrideMode = mode, 239 | overrideDepth = depth, 240 | overrideCull = cull, 241 | overrideBlend = blend, 242 | overrideCount = usesElements ? elements.count : count, 243 | overrideInstanceCount = instanceList?.size, 244 | ) => { 245 | if (dead) return; 246 | if (isInstanced && !overrideInstanceCount) return; 247 | 248 | setProgram(program); 249 | setVao(vao); 250 | setDepth(overrideDepth); 251 | setCull(overrideCull); 252 | if (overrideBlend) setBlend(...overrideBlend); 253 | executeRender(gl[overrideMode], overrideCount, overrideInstanceCount); 254 | setVao(); 255 | }; 256 | 257 | return render; 258 | }, Component); 259 | 260 | const logError = (log, shader) => { 261 | console.error(log); 262 | 263 | const position = log.match(/(\d+:\d+)/g)[0]; 264 | if (position) { 265 | const [, lineNumber] = position.split(":"); 266 | let lineIndex = 1; 267 | for (const line of shader.split("\n")) { 268 | if (Math.abs(lineIndex - lineNumber) < 5) { 269 | console[lineIndex === +lineNumber ? "warn" : "log"](`${lineIndex} ${line}`); 270 | } 271 | lineIndex++; 272 | } 273 | } 274 | }; 275 | -------------------------------------------------------------------------------- /webgl2/context.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, getInstance, update } from "../rahti/component.js"; 2 | import { EventListener } from "../rahti/dom.js"; 3 | import { cancelJobsAndStopFrame, requestRenderJob } from "./animationFrame.js"; 4 | 5 | const defaultAttributes = { 6 | antialias: false, 7 | alpha: true, 8 | powerPreference: "high-performance", 9 | }; 10 | 11 | const defaultHints = { 12 | FRAGMENT_SHADER_DERIVATIVE_HINT: "NICEST", 13 | }; 14 | 15 | export const Context = new Proxy(function ({ 16 | canvas, 17 | attributes: inputAttributes, 18 | hints: inputHints, 19 | clearColor = [0, 0, 0, 1], 20 | pixelRatio = 1, 21 | debug = false, 22 | drawingBufferColorSpace = "display-p3", 23 | unpackColorSpace = drawingBufferColorSpace, 24 | }) { 25 | if (!canvas || !(canvas instanceof Node)) throw new Error("Missing canvas"); 26 | 27 | const attributes = { ...defaultAttributes, ...inputAttributes }; 28 | 29 | const gl = canvas.getContext("webgl2", attributes); 30 | if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = drawingBufferColorSpace; 31 | if ("unpackColorSpace" in gl) gl.unpackColorSpace = unpackColorSpace; 32 | const textureIndexes = new Map(); 33 | 34 | // Hints 35 | const hints = { ...defaultHints, ...inputHints }; 36 | for (const target in hints) { 37 | const mode = hints[target]; 38 | gl.hint(gl[target], gl[mode]); 39 | } 40 | 41 | // Caches and setters 42 | let currentProgram = null; 43 | let currentVao = null; 44 | let currentBuffer = null; 45 | let currentTexture = null; 46 | let currentFramebuffer = null; 47 | let currentDepth = null; 48 | let currentCull = null; 49 | let currentBlend = null; 50 | 51 | const setProgram = (program) => { 52 | if (currentProgram !== program) { 53 | gl.useProgram(program); 54 | currentProgram = program; 55 | } 56 | }; 57 | const setVao = (vao = null) => { 58 | if (currentVao !== vao) { 59 | gl.bindVertexArray(vao); 60 | currentVao = vao; 61 | } 62 | }; 63 | const setBuffer = (buffer, type = gl.ARRAY_BUFFER) => { 64 | if (currentBuffer !== buffer) { 65 | gl.bindBuffer(type, buffer); 66 | currentBuffer = buffer; 67 | } 68 | }; 69 | const setDepth = (depth) => { 70 | if (currentDepth !== depth) { 71 | if (depth) { 72 | gl.enable(gl.DEPTH_TEST); 73 | gl.depthFunc(gl[depth]); 74 | } else { 75 | gl.disable(gl.DEPTH_TEST); 76 | } 77 | currentDepth = depth; 78 | } 79 | }; 80 | const setCull = (cull) => { 81 | if (currentCull !== cull) { 82 | if (cull) { 83 | gl.enable(gl.CULL_FACE); 84 | gl.cullFace(gl[cull]); 85 | } else { 86 | gl.disable(gl.CULL_FACE); 87 | } 88 | currentCull = cull; 89 | } 90 | }; 91 | 92 | const setBlend = (sourceFactor, destinationFactor) => { 93 | if (currentBlend !== sourceFactor + destinationFactor) { 94 | if (sourceFactor && destinationFactor) { 95 | gl.enable(gl.BLEND); 96 | gl.blendFunc(gl[sourceFactor], gl[destinationFactor]); 97 | } else { 98 | gl.disable(gl.BLEND); 99 | } 100 | currentBlend = sourceFactor + destinationFactor; 101 | } 102 | }; 103 | 104 | gl.enable(gl.DEPTH_TEST); 105 | gl.enable(gl.CULL_FACE); 106 | 107 | const setTexture = (texture, TARGET = gl.TEXTURE_2D) => { 108 | if (currentTexture !== texture) { 109 | if (!textureIndexes.has(texture)) { 110 | textureIndexes.set(texture, textureIndexes.size); 111 | } 112 | 113 | const index = textureIndexes.get(texture); 114 | gl.activeTexture(gl[`TEXTURE${index}`]); 115 | gl.bindTexture(TARGET, texture); 116 | } 117 | 118 | currentTexture = texture; 119 | }; 120 | 121 | const setFramebuffer = (framebuffer) => { 122 | if (currentFramebuffer !== framebuffer) { 123 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); 124 | currentFramebuffer = framebuffer; 125 | } 126 | }; 127 | 128 | // Clearing 129 | let lastDepth = 1; 130 | const setClear = (color = clearColor, depth = lastDepth) => { 131 | color.forEach((value, index) => { 132 | clearColor[index] = value; 133 | }); 134 | gl.clearColor(...clearColor); 135 | if (lastDepth.current !== depth) { 136 | gl.clearDepth(depth); 137 | lastDepth = depth; 138 | } 139 | }; 140 | setClear(); 141 | const clear = (value = 16640) => { 142 | gl.clear(value); 143 | }; 144 | 145 | let currentPixelRatio = pixelRatio; 146 | let width = canvas.offsetWidth; 147 | let height = canvas.offsetHeight; 148 | 149 | const resizeSubscribers = new Set(); 150 | const subscribe = (subscriber) => { 151 | resizeSubscribers.add(subscriber); 152 | subscriber(0, 0, width, height, pixelRatio); 153 | }; 154 | const unsubscribe = (subscriber) => resizeSubscribers.delete(subscriber); 155 | 156 | const resize = (pixelRatio = currentPixelRatio) => { 157 | if (dead) return; 158 | 159 | currentPixelRatio = pixelRatio; 160 | 161 | canvas.width = width * pixelRatio; 162 | canvas.height = height * pixelRatio; 163 | 164 | gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); 165 | 166 | for (const subscriber of resizeSubscribers) { 167 | subscriber(0, 0, width, height, pixelRatio); 168 | } 169 | 170 | requestRendering(); 171 | }; 172 | 173 | const observer = new ResizeObserver((entries) => { 174 | const entry = entries[0]; 175 | 176 | if (entry.devicePixelContentBoxSize) { 177 | width = entry.devicePixelContentBoxSize[0].inlineSize; 178 | height = entry.devicePixelContentBoxSize[0].blockSize; 179 | } else if (entry.contentBoxSize) { 180 | // Annoying fallback. Assumes window.devicePixelRatio includes browser zoom. 181 | // Currently that's how it works in Chrome, but not in Safari. 182 | // As a result current Safari will end up with the wrong size if browser zoom is in use. 183 | width = Math.round(entry.contentBoxSize[0].inlineSize * window.devicePixelRatio); 184 | height = Math.round(entry.contentBoxSize[0].blockSize * window.devicePixelRatio); 185 | } 186 | 187 | resize(); 188 | }); 189 | 190 | try { 191 | observer.observe(canvas, { box: "device-pixel-content-box" }); 192 | } catch { 193 | observer.observe(canvas); 194 | } 195 | 196 | const instance = getInstance(); 197 | // const contextLossTester = gl.getExtension("WEBGL_lose_context"); 198 | 199 | const handleLost = (event) => { 200 | // console.log("context lost"); 201 | dead = true; 202 | event.preventDefault(); 203 | cancelJobsAndStopFrame(); 204 | 205 | // setTimeout(() => contextLossTester.restoreContext(), 2000); 206 | }; 207 | const handleRestored = () => { 208 | // console.log("restoring context"); 209 | update(instance, true); 210 | }; 211 | 212 | EventListener(canvas, "webglcontextlost", handleLost); 213 | EventListener(canvas, "webglcontextrestored", handleRestored); 214 | 215 | // setTimeout(() => contextLossTester.loseContext(), 2000); 216 | 217 | cleanup(() => { 218 | dead = true; 219 | cancelJobsAndStopFrame(); 220 | observer.disconnect(); 221 | }); 222 | 223 | let renderFunction; 224 | let dead = false; 225 | 226 | const frame = (renderPass) => { 227 | renderFunction = renderPass; 228 | requestRendering(); 229 | }; 230 | const executeRender = (timestamp, sinceLastFrame, frameNumber) => { 231 | if (renderFunction && !dead) { 232 | try { 233 | renderFunction(timestamp, sinceLastFrame, frameNumber); 234 | } catch (error) { 235 | (globalThis.reportError || console.error)(error); 236 | } 237 | } 238 | }; 239 | 240 | const requestRendering = () => { 241 | requestRenderJob(executeRender); 242 | }; 243 | 244 | return { 245 | gl, 246 | setProgram, 247 | setVao, 248 | setBuffer, 249 | setDepth, 250 | setCull, 251 | setBlend, 252 | setTexture, 253 | textureIndexes, 254 | setFramebuffer, 255 | setClear, 256 | clear, 257 | subscribe, 258 | unsubscribe, 259 | resize, 260 | uniformBindIndexCounter: 0, 261 | frame, 262 | requestRendering, 263 | debug, 264 | }; 265 | }, Component); 266 | -------------------------------------------------------------------------------- /webgl2/elements.js: -------------------------------------------------------------------------------- 1 | import { Component } from "../rahti/component.js"; 2 | import { Buffer } from "./buffer.js"; 3 | 4 | export const Elements = new Proxy(function ({ context, data }) { 5 | return Buffer({ 6 | context, 7 | data, 8 | binding: "ELEMENT_ARRAY_BUFFER", 9 | types: ["UNSIGNED_SHORT", "int"], 10 | }); 11 | }, Component); 12 | -------------------------------------------------------------------------------- /webgl2/instances.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup, getInstance } from "../rahti/component.js"; 2 | import { requestPreRenderJob } from "./animationFrame.js"; 3 | import { Buffer } from "./buffer.js"; 4 | 5 | export const Instances = new Proxy(function ({ context, attributes: attributeMap }) { 6 | const attributes = new Map(); 7 | const defaultValues = new Map(); 8 | const attributeViews = new Map(); 9 | 10 | for (const key in attributeMap) { 11 | const value = attributeMap[key]; 12 | 13 | const data = [value]; 14 | const bufferObject = Buffer({ context, data, usage: "DYNAMIC_DRAW" }); 15 | const { Constructor } = bufferObject; 16 | 17 | attributes.set(key, bufferObject); 18 | defaultValues.set(key, value.length ? new Constructor(value) : new Constructor(data)); 19 | attributeViews.set(key, new Map()); 20 | } 21 | 22 | const additions = new Set(); 23 | const deletions = new Set(); 24 | 25 | const instancesToSlots = new Map(); 26 | const slotsToInstances = new Map(); 27 | 28 | const freeSlots = []; 29 | const changes = new Map(); 30 | const orphans = new Map(); 31 | 32 | const buildInstances = () => { 33 | if (dead) return; 34 | 35 | const oldSize = instancesToSlots.size; 36 | const newSize = oldSize + additions.size - deletions.size; 37 | 38 | // Mark deletions as free slots 39 | for (const instance of deletions) { 40 | const slot = instancesToSlots.get(instance); 41 | instancesToSlots.delete(instance); 42 | slotsToInstances.delete(slot); 43 | 44 | for (const [key] of attributes) { 45 | attributeViews.get(key).delete(instance); 46 | } 47 | 48 | if (slot < newSize) { 49 | freeSlots.push(slot); 50 | } 51 | } 52 | 53 | // Mark orphans 54 | for (let slot = newSize; slot < oldSize; slot++) { 55 | const instance = slotsToInstances.get(slot); 56 | 57 | for (const [key] of attributes) { 58 | attributeViews.get(key).delete(instance); 59 | } 60 | 61 | if (instance) { 62 | orphans.set(instance, slot); 63 | instancesToSlots.delete(instance); 64 | slotsToInstances.delete(slot); 65 | } 66 | } 67 | 68 | // Add new instances 69 | for (const instance of additions) { 70 | const slot = freeSlots.length ? freeSlots.pop() : instancesToSlots.size; 71 | instancesToSlots.set(instance, slot); 72 | slotsToInstances.set(slot, instance); 73 | changes.set(instance, slot); 74 | } 75 | 76 | // Move orphans into remaining slots 77 | for (const [instance] of orphans) { 78 | const slot = freeSlots.pop(); 79 | instancesToSlots.set(instance, slot); 80 | slotsToInstances.set(slot, instance); 81 | changes.set(instance, slot); 82 | } 83 | 84 | // Resize TypedArrays if needed 85 | for (const [key, { allData, Constructor, dimensions, set }] of attributes) { 86 | let newData = allData; 87 | const views = attributeViews.get(key); 88 | 89 | if (newSize !== oldSize) { 90 | if (newSize <= oldSize) { 91 | // slice old array 92 | newData = allData.subarray(0, newSize * dimensions); 93 | } else { 94 | // create new array 95 | newData = new Constructor(newSize * dimensions); 96 | newData.set(allData); 97 | } 98 | } 99 | 100 | // And fill in the changes 101 | for (const [instance, slot] of changes) { 102 | // Orphans use the value from their previous slot 103 | if (orphans.has(instance)) { 104 | const oldSlot = orphans.get(instance); 105 | 106 | if (dimensions === 1) { 107 | newData[slot] = allData[oldSlot]; 108 | } else { 109 | newData.set( 110 | allData.subarray(oldSlot * dimensions, oldSlot * dimensions + dimensions), 111 | slot * dimensions, 112 | ); 113 | } 114 | // Others use their already set value or the default value 115 | } else { 116 | const value = views.has(instance) ? views.get(instance) : defaultValues.get(key); 117 | newData.set(value, slot * dimensions); 118 | } 119 | 120 | // Delete now invalid views 121 | views.delete(instance); 122 | } 123 | 124 | // If a new array was created, views are all invalid 125 | if (newSize > oldSize) { 126 | views.clear(); 127 | } 128 | 129 | set(newData); 130 | } 131 | 132 | deletions.clear(); 133 | additions.clear(); 134 | orphans.clear(); 135 | changes.clear(); 136 | }; 137 | 138 | const InstanceComponent = new Proxy(function () { 139 | cleanup(cleanInstance); 140 | if (dead) return; 141 | 142 | const instance = getInstance(); 143 | deletions.delete(instance); 144 | additions.add(instance); 145 | requestPreRenderJob(buildInstances); 146 | 147 | return new Proxy( 148 | {}, 149 | { 150 | get: function (_, key) { 151 | // This poor man's updater proxy assumes the whole attribute will be updated 152 | // whenever it's accessed. 153 | const { allData, Constructor, dimensions, markAsNeedingUpdate } = attributes.get(key); 154 | const views = attributeViews.get(key); 155 | 156 | // Additions get a dummy view, which will be deleted later 157 | // Buffer will be updated on additions, so no need to mark as needing update 158 | if (additions.has(instance)) { 159 | if (views.has(instance)) return views.get(instance); 160 | 161 | const view = new Constructor(defaultValues.get(key)); 162 | views.set(instance, view); 163 | return view; 164 | } 165 | 166 | // Non-additions get a real view, generated on demand 167 | // Impacted buffer range will be marked as needing update 168 | const slot = instancesToSlots.get(instance); 169 | const index = slot * dimensions; 170 | markAsNeedingUpdate(index, index + dimensions); 171 | 172 | if (views.has(instance)) return views.get(instance); 173 | 174 | const view = allData.subarray(index, index + dimensions); 175 | views.set(instance, view); 176 | return view; 177 | }, 178 | }, 179 | ); 180 | }, Component); 181 | 182 | function cleanInstance(_, instance, isBeingDestroyed) { 183 | if (isBeingDestroyed) { 184 | if (additions.has(instance)) { 185 | // Deleted before ever getting added 186 | additions.delete(instance); 187 | } else { 188 | deletions.add(instance); 189 | requestPreRenderJob(buildInstances); 190 | } 191 | 192 | for (const [key] of attributes) { 193 | attributeViews.get(key).delete(instance); 194 | } 195 | } 196 | } 197 | 198 | let dead = false; 199 | 200 | cleanup(() => { 201 | dead = true; 202 | }); 203 | 204 | InstanceComponent._attributes = attributes; 205 | InstanceComponent._instancesToSlots = instancesToSlots; 206 | 207 | return InstanceComponent; 208 | }, Component); 209 | -------------------------------------------------------------------------------- /webgl2/texture.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup } from "../rahti/component.js"; 2 | import { requestPreRenderJob } from "./animationFrame.js"; 3 | 4 | const defaultParameters = { 5 | TEXTURE_MIN_FILTER: "LINEAR", 6 | TEXTURE_MAG_FILTER: "LINEAR", 7 | TEXTURE_WRAP_S: "REPEAT", 8 | TEXTURE_WRAP_T: "REPEAT", 9 | }; 10 | const defaultMipParameters = { 11 | ...defaultParameters, 12 | TEXTURE_MIN_FILTER: "NEAREST_MIPMAP_LINEAR", 13 | }; 14 | 15 | export const Texture = new Proxy(function ({ 16 | context, 17 | shaderType = "sampler2D", 18 | target = "TEXTURE_2D", 19 | storer = "texStorage2D", 20 | updater = "texSubImage2D", 21 | levels = 1, 22 | format = "RGBA", 23 | internalFormat = "RGBA8", 24 | type = "UNSIGNED_BYTE", 25 | pixels = null, 26 | width = 64, 27 | height = 64, 28 | mipmaps = false, 29 | flipY = false, 30 | premultiplyAlpha = false, 31 | unpackAlignment, 32 | parameters = mipmaps ? defaultMipParameters : defaultParameters, 33 | anisotropicFiltering = false, 34 | }) { 35 | const { gl, setTexture, requestRendering, textureIndexes } = context; 36 | 37 | const TARGET = gl[target]; 38 | const FORMAT = gl[format]; 39 | const INTERNAL_FORMAT = gl[internalFormat]; 40 | const TYPE = gl[type]; 41 | 42 | const texture = gl.createTexture(); 43 | setTexture(texture, TARGET); 44 | 45 | if (parameters) { 46 | for (const key in parameters) { 47 | gl.texParameteri(gl.TEXTURE_2D, gl[key], gl[parameters[key]]); 48 | } 49 | } 50 | 51 | if (anisotropicFiltering) { 52 | const anisotropicExtension = gl.getExtension("EXT_texture_filter_anisotropic"); 53 | 54 | if (anisotropicExtension) { 55 | const anisotropy = Math.min( 56 | anisotropicFiltering, 57 | gl.getParameter(anisotropicExtension.MAX_TEXTURE_MAX_ANISOTROPY_EXT), 58 | ); 59 | 60 | gl.texParameterf(gl.TEXTURE_2D, anisotropicExtension.TEXTURE_MAX_ANISOTROPY_EXT, anisotropy); 61 | } 62 | } 63 | 64 | if (flipY) gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY); 65 | if (premultiplyAlpha) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, premultiplyAlpha); 66 | if (unpackAlignment !== undefined) gl.pixelStorei(gl.UNPACK_ALIGNMENT, unpackAlignment); 67 | 68 | const hasMipmaps = typeof mipmaps === "string"; 69 | if (hasMipmaps) gl.hint(gl.GENERATE_MIPMAP_HINT, gl[mipmaps]); 70 | 71 | const generateMipmaps = () => { 72 | if (dead) return; 73 | setTexture(texture, TARGET); 74 | gl.generateMipmap(TARGET); 75 | }; 76 | 77 | const update = (data, x = 0, y = 0, width = 1, height = 1, level = 0) => { 78 | if (dead) return; 79 | setTexture(texture, TARGET); 80 | gl[updater](TARGET, level, x, y, width, height, FORMAT, TYPE, data); 81 | if (hasMipmaps) requestPreRenderJob(generateMipmaps); 82 | requestRendering(); 83 | }; 84 | 85 | let dead = false; 86 | 87 | cleanup(() => { 88 | dead = true; 89 | gl.deleteTexture(texture); 90 | }); 91 | 92 | gl[storer](TARGET, levels, INTERNAL_FORMAT, width, height); 93 | if (pixels) update(pixels, 0, 0, width, height); 94 | 95 | return { shaderType, update, index: textureIndexes.get(texture) }; 96 | }, Component); 97 | -------------------------------------------------------------------------------- /webgl2/uniformBlock.js: -------------------------------------------------------------------------------- 1 | import { Component, cleanup } from "../rahti/component.js"; 2 | import { cancelPreRenderJob, requestPreRenderJob } from "./animationFrame.js"; 3 | import { dataToTypes } from "./buffer.js"; 4 | 5 | export const UniformBlock = new Proxy(function ({ context, uniforms: uniformMap }) { 6 | const { gl, setBuffer, requestRendering } = context; 7 | 8 | const offsets = new Map(); 9 | const bindIndex = context.uniformBindIndexCounter++; 10 | 11 | const buffer = gl.createBuffer(); 12 | setBuffer(buffer, gl.UNIFORM_BUFFER); 13 | gl.bindBufferBase(gl.UNIFORM_BUFFER, bindIndex, buffer); 14 | 15 | let byteCounter = 0; 16 | let elementCounter = 0; 17 | 18 | const uniforms = {}; 19 | 20 | for (const key in uniformMap) { 21 | const value = uniformMap[key]; 22 | 23 | const [, shaderType] = dataToTypes(value); 24 | const elementCount = value.length || 1; 25 | 26 | // std140 alignment rules 27 | const [alignment, size] = 28 | elementCount === 1 ? [1, 1] : elementCount === 2 ? [2, 2] : [4, elementCount]; 29 | 30 | // std140 alignment padding 31 | // | a |...|...|...|b.x|b.y|b.z|b.w| c | d |...|...| 32 | const padding = (alignment - (elementCounter % alignment)) % alignment; 33 | elementCounter += padding; 34 | byteCounter += padding * 4; 35 | 36 | let data; 37 | if (Array.isArray(value) || ArrayBuffer.isView(value)) { 38 | data = value; 39 | } else { 40 | data = [value]; 41 | } 42 | 43 | const uniform = { 44 | shaderType, 45 | padding, 46 | size, 47 | byteOffset: byteCounter, 48 | elementOffset: elementCounter, 49 | data, 50 | }; 51 | 52 | uniforms[key] = uniform; 53 | offsets.set(key, uniform.elementOffset); 54 | 55 | elementCounter += size; 56 | byteCounter += size * 4; 57 | } 58 | 59 | const endPadding = (4 - (elementCounter % 4)) % 4; 60 | elementCounter += endPadding; 61 | 62 | const allData = new Float32Array(elementCounter); 63 | const { BYTES_PER_ELEMENT } = allData; 64 | 65 | for (const key in uniforms) { 66 | const { data, elementOffset } = uniforms[key]; 67 | allData.set(data, elementOffset); 68 | } 69 | 70 | gl.bufferData(gl.UNIFORM_BUFFER, allData, gl.DYNAMIC_DRAW); 71 | 72 | let firstDirty = Number.Infinity; 73 | let lastDirty = 0; 74 | 75 | const update = (key, data) => { 76 | if (dead) return; 77 | 78 | const length = data.length || 1; 79 | const offset = offsets.get(key); 80 | 81 | firstDirty = Math.min(offset, firstDirty); 82 | lastDirty = Math.max(offset + length, lastDirty); 83 | 84 | if (data.length) { 85 | allData.set(data, offset); 86 | } else { 87 | allData[offset] = data; 88 | } 89 | 90 | requestPreRenderJob(commitUpdate); 91 | }; 92 | 93 | const { UNIFORM_BUFFER } = gl; 94 | 95 | const commitUpdate = () => { 96 | if (dead) return; 97 | 98 | setBuffer(buffer, UNIFORM_BUFFER); 99 | gl.bufferSubData( 100 | UNIFORM_BUFFER, 101 | firstDirty * BYTES_PER_ELEMENT, 102 | allData, 103 | firstDirty, 104 | lastDirty - firstDirty, 105 | ); 106 | 107 | firstDirty = Number.Infinity; 108 | lastDirty = 0; 109 | 110 | requestRendering(); 111 | }; 112 | 113 | let dead = false; 114 | 115 | cleanup(() => { 116 | dead = true; 117 | cancelPreRenderJob(commitUpdate); 118 | gl.deleteBuffer(buffer); 119 | }); 120 | 121 | return { uniforms, update, bindIndex }; 122 | }, Component); 123 | -------------------------------------------------------------------------------- /webgl2/webgl2.js: -------------------------------------------------------------------------------- 1 | export * from "./context.js"; 2 | export * from "./animationFrame.js"; 3 | export * from "./command.js"; 4 | export { Buffer as Attribute } from "./buffer.js"; 5 | export * from "./instances.js"; 6 | export * from "./elements.js"; 7 | export * from "./uniformBlock.js"; 8 | export * from "./texture.js"; 9 | export * from "./camera.js"; 10 | --------------------------------------------------------------------------------