├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.png │ │ └── logo.svg ├── babel.config.js ├── tsconfig.json ├── .gitignore ├── docs │ ├── sidebars.js │ ├── api.md │ └── three-example.md ├── README.md ├── package.json ├── src │ └── pages │ │ └── index.md └── docusaurus.config.js ├── client ├── public │ └── favicon.ico ├── lib │ ├── 3d-view-controls.d.ts │ └── camera.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── triangle.tsx │ ├── index.tsx │ └── lidar.tsx ├── next.config.js ├── .gitignore ├── tsconfig.json ├── package.json ├── dis-lib │ ├── util.ts │ ├── worker.js │ ├── LocalBackend.ts │ ├── MagicCanvas.tsx │ └── RemoteBackend.ts ├── README.md ├── renderer-loader.js └── renderers │ ├── box.render.ts │ ├── triangle.render.ts │ └── lidar.render.ts └── README.md /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/lib/3d-view-controls.d.ts: -------------------------------------------------------------------------------- 1 | declare module '3d-view-controls'; 2 | -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/magic-canvas/HEAD/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /client/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app' 2 | 3 | export default function App({ Component, pageProps }: AppProps) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Note: this contains only the client-side code for MagicCanvas demos, provided as a reference. In this form, only the local renderer will run. The server-side is not included in this.** 2 | 3 | # MagicCanvas Tech Preview 4 | 5 | This repo contains documentation and code samples of MagicCanvas. 6 | 7 | `/client` contains the code, `/docs` is the documentation that appears at [canvas.stream](https://canvas.stream). 8 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | 7 | webpack: ( 8 | config 9 | ) => { 10 | config.module.rules.push({ 11 | test: /\.render.[tj]s$/, 12 | use: [ 13 | { 14 | loader: path.resolve(__dirname, 'renderer-loader.js'), 15 | }, 16 | ], 17 | }) 18 | 19 | return config 20 | }, 21 | } 22 | 23 | module.exports = nextConfig 24 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .rollup.cache 38 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /client/lib/camera.ts: -------------------------------------------------------------------------------- 1 | import createCameraControls from '3d-view-controls' 2 | 3 | const CENTER = [1249.5, 1249.5, 800] 4 | const EYE = [CENTER[0] - 2000, CENTER[1] - 2000, 2000] 5 | 6 | export default function createCamera(canvas: HTMLElement) { 7 | const camera = createCameraControls(canvas, { 8 | eye: EYE, 9 | center: CENTER 10 | }) as any 11 | const tick = camera.tick 12 | camera.tick = function wrappedTick() { 13 | const result = tick.apply(camera) 14 | camera.up = [0, 0, 1] 15 | return result 16 | } 17 | const matrix = new Float32Array(16) 18 | camera.getMatrix = function() { 19 | matrix.set(camera.matrix) 20 | return matrix 21 | } 22 | return camera 23 | } 24 | -------------------------------------------------------------------------------- /docs/docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | The sidebars can be generated from the filesystem, or explicitly defined here. 7 | Create as many sidebars as you want. 8 | */ 9 | 10 | // @ts-check 11 | 12 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 13 | const sidebars = { 14 | // But you can create a sidebar manually 15 | docsSidebar: [ 16 | { 17 | type: 'doc', 18 | label: 'An Example', 19 | id: 'three-example' 20 | }, 21 | { 22 | type: 'doc', 23 | label: 'API', 24 | id: 'api' 25 | } 26 | ], 27 | }; 28 | 29 | module.exports = sidebars; 30 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "rm -rf ./out ./.next && next build && next export", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "gl-matrix": "^3.4.3", 13 | "next": "13.1.1" 14 | }, 15 | "devDependencies": { 16 | "@rollup/plugin-node-resolve": "^15.0.1", 17 | "@rollup/plugin-typescript": "^11.0.0", 18 | "@types/gl-matrix": "^3.2.0", 19 | "@types/node": "18.11.18", 20 | "@types/react": "18.0.26", 21 | "@types/three": "^0.148.0", 22 | "3d-view-controls": "^2.2.2", 23 | "eslint": "8.31.0", 24 | "eslint-config-next": "13.1.1", 25 | "rollup": "^3.9.1", 26 | "three": "^0.148.0", 27 | "typescript": "4.9.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/dis-lib/util.ts: -------------------------------------------------------------------------------- 1 | /** Represents a value that can change over time but has no initial value. 2 | * If get() is called before set(), it will return a promise that resolves when set() is called. 3 | * After set() is called, get() will return the last value set immediately. 4 | */ 5 | export class PromiseBox { 6 | private promise: Promise 7 | private resolve!: (value: T) => void 8 | private value: T | null = null 9 | 10 | constructor() { 11 | this.promise = new Promise((resolve) => { 12 | this.resolve = resolve 13 | }) 14 | } 15 | 16 | set(value: T) { 17 | this.value = value 18 | this.resolve(value) 19 | } 20 | 21 | async get() { 22 | if (this.value !== null) { 23 | return this.value 24 | } else { 25 | return this.promise 26 | } 27 | } 28 | 29 | getImmediate() { 30 | return this.value 31 | } 32 | } -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | **Note: this contains only the client-side code for MagicCanvas demos, provided as a reference. In this form, only the local renderer will run. The server-side is not included in this.** 2 | 3 | Structure: 4 | - `dis-lib/` is “Magic Canvas” shared library, which will eventually be extracted as the official Magic Canvas client-side code. 5 | - `lib/` contains helper functions used by pages/renderers. 6 | - `pages/` contains pages that nextjs turns into routes. Currently, pages are 1:1 with renderers, but there’s nothing to stop one renderer from being used by multiple pages. 7 | - `renderers/` contains modules that can be imported as renderers. These should have a filename ending with `.render.js` or `.render.ts`, which tells the loader to process them and not to include them in the main bundle. 8 | - `renderer-loader.js` is the custom loader we use for processing renderers. 9 | 10 | Running: 11 | 12 | npm i 13 | 14 | npm run dev 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ npm install 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ npm start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ npm run build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true npm run deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= npm run deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /client/pages/triangle.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { MagicCanvas } from '../dis-lib/MagicCanvas' 3 | import renderer from '../renderers/triangle.render' 4 | 5 | export default function App() { 6 | const [renderRemote, setRenderRemote] = useState(false) 7 | 8 | return ( 9 |
10 |
11 | 18 | 19 |
20 |
21 | 27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /client/renderer-loader.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const rollup = require('rollup'); 3 | const pluginTypescript = require('@rollup/plugin-typescript'); 4 | const pluginResolve = require('@rollup/plugin-node-resolve'); 5 | 6 | async function build(filename) { 7 | const plugins = [pluginResolve()] 8 | if (filename.endsWith('.ts')) { 9 | plugins.push(0, pluginTypescript()) 10 | } 11 | 12 | const bundle = await rollup.rollup({ 13 | input: filename, 14 | plugins, 15 | }); 16 | 17 | const result = await bundle.generate({ 18 | format: 'esm', 19 | }) 20 | 21 | return result.output[0].code 22 | } 23 | 24 | module.exports = function (source) { 25 | const filename = path.basename(this.resourcePath).replace(/\.render\.[tj]s$/, '.render.js') 26 | 27 | const callback = this.async() 28 | 29 | build(this.resourcePath).then((source) => { 30 | this.emitFile(`static/${filename}`, source, null, { type: 'asset' }) 31 | 32 | callback(null, `module.exports = "/_next/static/${filename}"`) 33 | }).catch((err) => { 34 | callback(err, null) 35 | }) 36 | } 37 | 38 | module.exports.raw = true 39 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magic-canvas-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "2.2.0", 19 | "@docusaurus/preset-classic": "2.2.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "clsx": "^1.2.1", 22 | "prism-react-renderer": "^1.3.5", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "2.2.0", 28 | "@tsconfig/docusaurus": "^1.0.5", 29 | "typescript": "^4.7.4" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "engines": { 44 | "node": ">=16.14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/renderers/box.render.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Mesh, PerspectiveCamera, Scene } from 'three'; 3 | 4 | export default function createRenderer(context: WebGLRenderingContext) { 5 | const renderer = new THREE.WebGLRenderer({ context, canvas: context.canvas }); 6 | renderer.setClearColor(0x000000, 1); 7 | 8 | const scene = new Scene(); 9 | const camera = new PerspectiveCamera(75, 1.0, 0.1, 1000); 10 | 11 | const geometry = new THREE.BoxGeometry(2, 2, 2); 12 | const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 }); 13 | const cube = new Mesh(geometry, material); 14 | 15 | const light = new THREE.PointLight(0xffffff, 1, 100); 16 | light.position.set(0, 0, 9); 17 | scene.add(light); 18 | scene.add(cube); 19 | 20 | camera.position.z = 5; 21 | 22 | return function render(renderProps: { x: number, y: number }, state: {xRotation: number, yRotation: number}) { 23 | if (state.xRotation === undefined) { 24 | state.xRotation = 0; 25 | } 26 | if (state.yRotation === undefined) { 27 | state.yRotation = 0; 28 | } 29 | 30 | state.xRotation += 0.01; 31 | state.yRotation += 0.01; 32 | 33 | cube.rotation.x = state.xRotation; 34 | cube.rotation.y = state.yRotation; 35 | 36 | light.position.x = 10 * renderProps.x; 37 | light.position.y = 10 * renderProps.y; 38 | 39 | renderer.render(scene, camera); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react' 2 | import { MagicCanvas } from '../dis-lib/MagicCanvas' 3 | import renderer from '../renderers/box.render' 4 | 5 | interface StateType { 6 | x: number 7 | y: number 8 | } 9 | 10 | export default function App() { 11 | const [renderRemote, setRenderRemote] = useState(false) 12 | const [lightPosition, setLightPosition] = useState({ x: 0, y: 0 }) 13 | 14 | const updateValues = useCallback((e: React.MouseEvent) => { 15 | const { clientX, clientY } = e 16 | const { left, top, width, height } = e.currentTarget.getBoundingClientRect() 17 | const x = ((clientX - left) / width) * 2 - 1 18 | const y = -((clientY - top) / height) * 2 + 1 19 | setLightPosition({ x, y }) 20 | }, []) 21 | 22 | return ( 23 |
24 |
25 | 32 |
33 |
34 | 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /client/dis-lib/worker.js: -------------------------------------------------------------------------------- 1 | const workerState = { 2 | renderProps: {}, 3 | renderState: {}, 4 | rafHandle: null, 5 | } 6 | 7 | async function init({ rendererUrl, canvas }) { 8 | // Webpack will use its own import() shim unless we use a magic 9 | // incantation. See https://webpack.js.org/api/module-methods/#magic-comments 10 | const module = await import(/* webpackIgnore: true */ rendererUrl) 11 | const createRenderer = module.default 12 | 13 | const context = canvas.getContext('webgl') 14 | const render = await createRenderer(context) 15 | 16 | function loop() { 17 | render(workerState.renderProps, workerState.renderState) 18 | cancelAnimationFrame(workerState.rafHandle) 19 | workerState.rafHandle = requestAnimationFrame(loop) 20 | } 21 | cancelAnimationFrame(workerState.rafHandle) 22 | workerState.rafHandle = requestAnimationFrame(loop) 23 | } 24 | 25 | // NOTE: we want to adhere to a rule where this onmessage handler must not be async. 26 | // This ensures that processing one part of a message (e.g. the init) will not block 27 | // processing another part of the message (e.g. the state update) 28 | onmessage = (msg) => { 29 | if (msg.data.init !== undefined) { 30 | const { rendererUrl, canvas } = msg.data.init 31 | init({ rendererUrl, canvas }) 32 | } 33 | if (msg.data.renderState !== undefined) { 34 | workerState.renderState = msg.data.renderState 35 | } 36 | if (msg.data.renderProps !== undefined) { 37 | workerState.renderProps = msg.data.renderProps 38 | } 39 | if (msg.data.requestState === true) { 40 | postMessage({renderState: workerState.renderState}) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/renderers/triangle.render.ts: -------------------------------------------------------------------------------- 1 | const VERTEX_SHADER = ` 2 | uniform float time; 3 | attribute vec2 position; 4 | 5 | 6 | void main() { 7 | gl_Position = vec4(position.x, position.y, 0.5, 1.0); 8 | } 9 | `; 10 | 11 | const FRAG_SHADER = ` 12 | precision mediump float; 13 | 14 | void main() { 15 | gl_FragColor = vec4(0.3, 0.7, 0.4, 1.0); 16 | } 17 | `; 18 | 19 | export default function createRenderer(gl: WebGLRenderingContext) { 20 | gl.clearColor(.05, .23, .3, 1); 21 | 22 | const triangleMesh = [ 23 | -0.5, -.66, 24 | 0.5, -.66, 25 | 0, 0.5, 26 | ]; 27 | 28 | const triangleBuffer = gl.createBuffer(); 29 | gl.bindBuffer(gl.ARRAY_BUFFER, triangleBuffer); 30 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangleMesh), gl.STATIC_DRAW); 31 | 32 | const program = createProgram(gl, VERTEX_SHADER, FRAG_SHADER); 33 | const attrPosition = gl.getAttribLocation(program, "position"); 34 | gl.enableVertexAttribArray(attrPosition); 35 | gl.useProgram(program); 36 | 37 | 38 | return function render(renderState: {}) { 39 | // draw to the whole canvas and clear to the background color 40 | gl.clear(gl.COLOR_BUFFER_BIT); 41 | 42 | gl.bindBuffer(gl.ARRAY_BUFFER, triangleBuffer); 43 | gl.vertexAttribPointer(attrPosition, 2, gl.FLOAT, false, 4 * 2, 0); 44 | gl.drawArrays(gl.TRIANGLES, 0, 3); 45 | 46 | gl.flush(); 47 | } 48 | } 49 | 50 | function createShader(gl: WebGLRenderingContext, source: string, type: number) { 51 | var shader = gl.createShader(type)!; 52 | gl.shaderSource(shader, source); 53 | gl.compileShader(shader); 54 | 55 | // report any errors from compiling the shader 56 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 57 | console.log("error compiling " + (type == gl.VERTEX_SHADER ? "vertex" : "fragment") + " shader: " + gl.getShaderInfoLog(shader)); 58 | } 59 | 60 | return shader; 61 | } 62 | 63 | function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) { 64 | var program = gl.createProgram()!; 65 | var vertexShader = createShader(gl, vertexSource, gl.VERTEX_SHADER); 66 | var fragmentShader = createShader(gl, fragmentSource, gl.FRAGMENT_SHADER); 67 | gl.attachShader(program, vertexShader); 68 | gl.attachShader(program, fragmentShader); 69 | gl.linkProgram(program); 70 | return program; 71 | } 72 | -------------------------------------------------------------------------------- /client/pages/lidar.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | import { MagicCanvas } from '../dis-lib/MagicCanvas' 3 | import createCamera from '../lib/camera' 4 | 5 | import renderer from '../renderers/lidar.render' 6 | 7 | /** How frequently to update the camera matrix. */ 8 | const TICK_INTERVAL_MS = 1000 / 30; 9 | 10 | export default function App() { 11 | const [renderRemote, setRenderRemote] = useState(false) 12 | const [viewMatrix, setViewMatrix] = useState(new Array(16).fill(0)) 13 | const cameraRef = useRef | null>(null) 14 | 15 | const refCallback = useCallback((node: HTMLElement | null) => { 16 | if (node !== null) { 17 | cameraRef.current = createCamera(node) 18 | } 19 | }, []) 20 | 21 | useEffect(() => { 22 | let timeout = setTimeout(function updateState() { 23 | timeout = setTimeout(updateState, TICK_INTERVAL_MS) 24 | if (cameraRef.current === null) return 25 | cameraRef.current.tick() 26 | const cameraMatrix = cameraRef.current.getMatrix() 27 | if (!areArraysEqual(cameraMatrix, viewMatrix)) { 28 | setViewMatrix(Array.from(cameraMatrix)) 29 | } 30 | }, TICK_INTERVAL_MS) 31 | 32 | return () => { 33 | clearTimeout(timeout) 34 | } 35 | }, []) 36 | 37 | return ( 38 |
39 |
40 | 47 |
48 |
49 | 56 |
57 |
58 | ) 59 | } 60 | 61 | function areArraysEqual(arr1: ArrayLike, arr2: ArrayLike): boolean { 62 | if (arr1.length !== arr2.length) return false 63 | for (let i = 0; i < arr1.length; i++) { 64 | if (arr1[i] !== arr2[i]) return false 65 | } 66 | return true 67 | } 68 | -------------------------------------------------------------------------------- /docs/docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ### MagicCanvas React Component 4 | 5 | The `` React component takes the following props: 6 | 7 | - `width: number` - the width of the canvas in pixels (required) 8 | - `height: number` - the height of the canvas in pixels (required) 9 | - `rendererUrl: string` - the URL for the bundled Renderer JS (see Webpack Loader below for more info) (required) 10 | - `renderProps: Record` - an object with values to be passed to the Renderer's `render()` function on each frame (optional) 11 | - `initialRenderState: Record` - an object with values to initialize the renderer's internal state (optional) 12 | - `remote: boolean` - a boolean (`false` by default) that indicates whether the canvas contents should be rendered remotely and streamed to the client (optional) 13 | 14 | 15 | ### The Renderer 16 | 17 | The Renderer is the part of your application's code that takes a GL context and renders to it. A Renderer must have a `createRenderer(context)` function as a default export that takes a WebGL1 Rendering Context. (WebGL2 is not currently supported, but WebGPU support is coming soon!) This `createRenderer(context)` function should return a `render(props)` function that will be called on every frame with the values passed to the MagicCanvas component's `renderProps` prop. 18 | 19 | For example: 20 | 21 | ```js 22 | export default function createRenderer(context) { 23 | // do some setup with the context 24 | return function render(renderProps) { 25 | // do some rendering with the context + renderProps 26 | } 27 | } 28 | ``` 29 | 30 | Note: the Renderer is not tied to a specific WebGL framework (e.g. THREE.js). Using a WebGL framework should work in most cases, though, as most frameworks support taking an existing WebGL context as an argument. You can also write a Renderer without any WebGL framework if you'd prefer to write raw WebGL code. 31 | 32 | When rendering locally, the Renderer is run in a WebWorker. When rendering remotely, the Renderer is run in NodeJS. For this reason, Renderer code can't rely on browser-only APIs or DOM elements. This also means that `context.canvas` may not point to an actual canvas and instead be a thin shim. Values like `width` and `height` which are often accessed from the canvas element should be accessed with `context.drawingBufferWidth` and `context.drawingBufferHeight`. 33 | 34 | 35 | ### Webpack Loader 36 | 37 | MagicCanvas currently provides a Webpack loader for producing a JS bundle using the `.render.js` (or `.render.ts`) file as an entrypoint, which it saves to the `static` directory. At build time, the loader replaces imports of the `.render.js` file with a URL to the bundled JS. This URL should be passed to the MagicCanvas as the `rendererUrl` prop. For example: 38 | 39 | ```jsx 40 | // the Webpack Loader transforms this import into a URL that points to the bundled render.js file 41 | import myDemoUrl from './my-demo.render 42 | 43 | // ... 44 | 45 | 49 | ``` 50 | 51 | Adding the loader to a NextJS config, for example: 52 | 53 | ```js 54 | const magicCanvasLoader = require('magic-canvas-loader') 55 | module.exports = { 56 | webpack: (config) => { 57 | config.module.rules.push({ 58 | test: /\.render.[tj]s$/, 59 | use: [{ loader: magicCanvasLoader }] 60 | }) 61 | return config 62 | } 63 | } 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/src/pages/index.md: -------------------------------------------------------------------------------- 1 | # MagicCanvas 2 | 3 | **MagicCanvas is a React component for pixel-streaming WebGL.** 4 | 5 | Apps that integrate `MagicCanvas` can swap between client-side WebGL rendering and server-side rendering at the click of a button. This allows applications to scale to more powerful cloud hardware when rendering complex scenes or loading large datasets. 6 | 7 | To the end user, the transition between local and remote is barely perceptible. It’s magic. 8 | 9 | ## Demo 10 | 11 | 12 | 13 | ## React example 14 | 15 | An application needs to do two things to use MagicCanvas: 16 | - Implement a *renderer*. This is a self-contained JavaScript bundle that exports a function matching a certain signature. 17 | - Create an instance of the `` component, and pass the renderer into it. 18 | 19 | Here’s an example of how to use MagicCanvas in your codebase: 20 | 21 | ```jsx 22 | import React from 'react' 23 | import { MagicCanvas } from 'react-magic-canvas' 24 | import boxDemoUrl from './box.render' 25 | 26 | export default function App() { 27 | const lightPosition = { x: 500, y: 750 } 28 | return ( 29 | 36 | ) 37 | } 38 | ``` 39 | 40 | Then, in `box.render.js`, we need to export a `createRenderer` function which takes a `WebGLRenderingContext` and returns a `render` function to be called on each frame. Notice that the `render()` function gets the value of the `renderProps` prop passed to it: 41 | 42 | ```js 43 | import * as THREE from 'three' 44 | 45 | export default function createRenderer(context) { 46 | const renderer = new THREE.WebGLRenderer({ 47 | context, 48 | canvas: context.canvas 49 | }) 50 | renderer.setClearColor(0x000000 /* black */, 1) 51 | 52 | const scene = new THREE.Scene() 53 | const geometry = new THREE.BoxGeometry(2, 2, 2) 54 | const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 }) 55 | const cube = new THREE.Mesh(geometry, material) 56 | const light = new THREE.PointLight(0xffffff, 1, 100) 57 | const camera = new THREE.PerspectiveCamera(75, 1.0, 0.1, 1000) 58 | camera.position.z = 5 59 | 60 | scene.add(light) 61 | scene.add(cube) 62 | 63 | return function render(lightPosition) { 64 | light.position.x = lightPosition.x 65 | light.position.y = lightPosition.y 66 | 67 | cube.rotation.x += 0.01 68 | cube.rotation.y += 0.01 69 | 70 | renderer.render(scene, camera) 71 | } 72 | } 73 | ``` 74 | 75 | The above code will render the scene in a NodeJS backend and stream the result to the client, while streaming changes to the `lightPosition` state in the client back to the Renderer over WebSockets. 76 | 77 | ## Future features 78 | 79 | - WebGPU support! 80 | - Support canvas resizing 81 | - Support compositing locally-rendered content with remotely-rendered content 82 | - Better shims for browser-dependent code 83 | - Broader support for JavaScript bundlers and build tools - not just Webpack and Webpack-compatible frameworks (like NextJS) 84 | - Better serialization format for `renderProps` values (other than JSON, which doesn't serialize/deserialize Dates or TypedArrays very well, e.g.) 85 | 86 | 87 | ## Learn more 88 | 89 | - [Walk through a ThreeJS example](/docs/three-example) 90 | - [See the API docs](/docs/api) 91 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 5 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'MagicCanvas', 10 | tagline: 'Render your WebGL in the cloud.', 11 | url: 'https://canvas.stream', 12 | baseUrl: '/', 13 | onBrokenLinks: 'throw', 14 | onBrokenMarkdownLinks: 'warn', 15 | favicon: 'img/favicon.png', 16 | 17 | // GitHub pages deployment config. 18 | // If you aren't using GitHub pages, you don't need these. 19 | organizationName: 'drifting-in-space', // Usually your GitHub org/user name. 20 | projectName: 'magic-canvas', // Usually your repo name. 21 | 22 | // Even if you don't use internalization, you can use this field to set useful 23 | // metadata like html lang. For example, if your site is Chinese, you may want 24 | // to replace "en" with "zh-Hans". 25 | i18n: { 26 | defaultLocale: 'en', 27 | locales: ['en'], 28 | }, 29 | 30 | presets: [ 31 | [ 32 | 'classic', 33 | /** @type {import('@docusaurus/preset-classic').Options} */ 34 | { 35 | docs: { 36 | sidebarPath: false // require.resolve('./docs/sidebars.js'), 37 | }, 38 | blog: false 39 | }, 40 | ], 41 | ], 42 | 43 | themeConfig: 44 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 45 | ({ 46 | navbar: { 47 | title: 'MagicCanvas', 48 | logo: { 49 | alt: 'Drifting in Space Logo', 50 | src: 'img/logo.svg', 51 | }, 52 | items: [ 53 | { 54 | type: 'doc', 55 | docId: 'three-example', 56 | position: 'left', 57 | label: 'An Example', 58 | }, 59 | { 60 | type: 'doc', 61 | docId: 'api', 62 | position: 'left', 63 | label: 'API', 64 | }, 65 | { 66 | href: 'https://github.com/drifting-in-space/magic-canvas', 67 | label: 'GitHub', 68 | position: 'right', 69 | }, 70 | ], 71 | }, 72 | footer: { 73 | style: 'dark', 74 | links: [ 75 | { 76 | title: 'Community', 77 | items: [ 78 | { 79 | label: 'Discord', 80 | href: 'https://discord.gg/N5sEpsuhh9', 81 | }, 82 | { 83 | label: 'Twitter', 84 | href: 'https://twitter.com/drifting_corp', 85 | }, 86 | ], 87 | }, 88 | { 89 | title: 'More', 90 | items: [ 91 | { 92 | label: 'Drifting in Space', 93 | href: 'https://driftingin.space', 94 | }, 95 | { 96 | label: 'Jamsocket', 97 | href: 'https://jamsocket.com', 98 | }, 99 | { 100 | label: 'GitHub', 101 | href: 'https://github.com/drifting-in-space/magic-canvas', 102 | }, 103 | ], 104 | }, 105 | ], 106 | copyright: `Copyright © ${new Date().getFullYear()} Drifting in Space.`, 107 | }, 108 | prism: { 109 | theme: lightCodeTheme, 110 | darkTheme: darkCodeTheme, 111 | }, 112 | scripts: [{src: 'https://plausible.io/js/script.js', defer: true, 'data-domain': 'canvas.stream'}], 113 | }), 114 | }; 115 | 116 | module.exports = config; 117 | -------------------------------------------------------------------------------- /client/dis-lib/LocalBackend.ts: -------------------------------------------------------------------------------- 1 | import { RenderBackend, RenderProps, RenderState } from "./MagicCanvas" 2 | import { PromiseBox } from "./util" 3 | 4 | /** 5 | * Expected type of messages sent to worker.js via postMessage. 6 | * 7 | * Messages may have one or more of the top-level fields. 8 | * */ 9 | interface MessageToWorker { 10 | /** Initialization message. 11 | * The worker doesn't render anything until it receives this message. */ 12 | init?: { 13 | /** URL of the renderer to load. */ 14 | rendererUrl: string 15 | 16 | /** OffscreenCanvas to draw to. */ 17 | canvas: OffscreenCanvas 18 | } 19 | 20 | /** Initial render state object. */ 21 | renderState?: RenderState 22 | 23 | /** Properties to send to the worker. */ 24 | renderProps?: RenderProps 25 | 26 | /** Request the current render state. */ 27 | requestState?: true 28 | } 29 | 30 | /** Expected type of messages received from worker.js via onmessage. */ 31 | interface MessageFromWorker { 32 | /** Render state object. Expected after sending a message with requestState. */ 33 | renderState?: RenderState 34 | } 35 | 36 | /** Wraps a Web Worker and the renderer running in it, providing a function-based 37 | * interface on top of postMessage/onmessage. */ 38 | class RenderWorker { 39 | /** Promise used to return state during a getRenderState call. */ 40 | private statePromise: PromiseBox | null = null 41 | 42 | /** Underlying Web Worker. */ 43 | private worker: Worker 44 | 45 | constructor() { 46 | this.worker = new Worker(new URL("./worker.js", import.meta.url)) 47 | this.worker.onmessage = (e: { data: MessageFromWorker }) => { 48 | if (e.data.renderState !== undefined) { 49 | if (this.statePromise !== null) { 50 | this.statePromise.set(e.data.renderState) 51 | } 52 | } 53 | } 54 | } 55 | 56 | /** Send a message to the worker. Type-aware wrapper to worker.postMessage. */ 57 | private send(msg: MessageToWorker, options?: StructuredSerializeOptions) { 58 | this.worker.postMessage(msg, options) 59 | } 60 | 61 | /** Set the render props. */ 62 | setProps(props: RenderProps) { 63 | this.send({ renderProps: props }) 64 | } 65 | 66 | setState(state: RenderState) { 67 | this.send({ renderState: state }) 68 | } 69 | 70 | /** Initialize the worker. */ 71 | init(rendererUrl: string, canvas: OffscreenCanvas, renderProps: RenderProps, renderState: RenderState) { 72 | this.send({ init: { rendererUrl, canvas }, renderState, renderProps }, { transfer: [canvas] }) 73 | } 74 | 75 | /** Get the current render state. 76 | * This sets up a promise to receive the state, and sends a message to 77 | * the worker requesting the state. 78 | */ 79 | getRenderState(): Promise { 80 | this.statePromise = new PromiseBox() 81 | this.send({ requestState: true }) 82 | return this.statePromise.get() 83 | } 84 | 85 | /** Terminate the worker. */ 86 | destroy() { 87 | this.worker.terminate() 88 | } 89 | } 90 | 91 | export class LocalBackend implements RenderBackend { 92 | container: PromiseBox = new PromiseBox() 93 | worker: RenderWorker 94 | canvas: HTMLCanvasElement | null = null 95 | 96 | constructor(private renderUrl: string, private renderProps: RenderState = {}, private initialRenderState: RenderState = {}) { 97 | this.worker = new RenderWorker() 98 | this.initWorker() 99 | } 100 | 101 | private async initWorker() { 102 | const canvas = await this.getCanvas() 103 | this.worker.init(this.renderUrl, canvas, this.renderProps, this.initialRenderState) 104 | } 105 | 106 | /** Return a new canvas element. If we already have a canvase element, delete it, to ensure that its context hasn't already been captured. */ 107 | private async getCanvas(): Promise { 108 | const container = await this.container.get() 109 | if (this.canvas !== null) { 110 | this.canvas.remove() 111 | } 112 | const canvas = document.createElement("canvas") 113 | 114 | canvas.height = container.clientHeight 115 | canvas.width = container.clientWidth 116 | 117 | // canvas.style.transition = 'all 150ms linear' 118 | // canvas.style.opacity = '1' 119 | canvas.style.border = '1px solid #ddd' 120 | canvas.style.width = `${canvas.width}px` 121 | canvas.style.height = `${canvas.height}px` 122 | canvas.style.pointerEvents = 'initial' 123 | 124 | this.canvas = canvas 125 | container.appendChild(canvas) 126 | const offscreenCanvas = canvas.transferControlToOffscreen() 127 | return offscreenCanvas 128 | } 129 | 130 | /** Set the container. No-op if the container is already set. */ 131 | setContainer(container: HTMLElement) { 132 | this.container.set(container) 133 | } 134 | 135 | setRenderProps(props: RenderProps) { 136 | this.worker.setProps(props) 137 | } 138 | 139 | setRenderState(state: RenderState) { 140 | this.worker.setState(state) 141 | } 142 | 143 | getRenderState(): Promise { 144 | return this.worker.getRenderState() 145 | } 146 | 147 | destroy() { 148 | this.worker.destroy() 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /docs/docs/three-example.md: -------------------------------------------------------------------------------- 1 | # An Example using THREE.js 2 | 3 | Let's walk through a simple THREE.js demo using MagicCanvas. To start, our demo will just render a rotating cube. 4 | 5 | ### The Renderer file 6 | 7 | When using MagicCanvas, we need to write our WebGL-rendering code in a separate file with 8 | a `.render.js` (or `.render.ts`) extension. The Renderer file must export a `createRenderer()` 9 | function which takes a WebGL1 rendering context and should return a `render()` function to be called on each frame. 10 | 11 | In `box.render.js`: 12 | 13 | ```js 14 | import * as THREE from 'three' 15 | 16 | export default function createRenderer(context) { 17 | const renderer = new THREE.WebGLRenderer({ context, canvas: context.canvas }) 18 | renderer.setClearColor(0x000000, 1) 19 | 20 | const scene = new THREE.Scene() 21 | const geometry = new THREE.BoxGeometry(2, 2, 2) 22 | const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 }) 23 | const cube = new THREE.Mesh(geometry, material) 24 | const camera = new THREE.PerspectiveCamera(75, 1.0, 0.1, 1000) 25 | camera.position.z = 5 26 | 27 | scene.add(cube) 28 | 29 | return function render() { 30 | cube.rotation.x += 0.01 31 | cube.rotation.y += 0.01 32 | renderer.render(scene, camera) 33 | } 34 | } 35 | ``` 36 | 37 | When writing a Renderer, you'll want to remember that **Renderer code may be run in a WebWorker or in a non-browser context on the server.** 38 | 39 | This means we can't access DOM elements or rely on values you'd normally have available in a browser (e.g. `window.innerWidth`). This also means that `context.canvas` may be a Dummy Canvas that doesn't have the methods or properties you'd find on a real canvas element. (Here, we are passing `context.canvas` to the `new THREE.WebGLRenderer()` call to prevent ThreeJS from trying to create a new canvas.) To get width and height, we need to rely on WebGLRenderingContext values (e.g. `context.drawingBufferWidth`). 40 | 41 | WebGL and `fetch()` are available in both contexts. 42 | 43 | ### The MagicCanvas component 44 | 45 | Now that we have our Renderer written, let's use a `MagicCanvas` React Component to render our ThreeJS demo in a local WebWorker. 46 | 47 | ```jsx 48 | import React from 'react' 49 | import { MagicCanvas } from 'react-magic-canvas' 50 | import boxDemoUrl from './box.render' 51 | 52 | export default function App() { 53 | return ( 54 | 59 | ) 60 | } 61 | ``` 62 | 63 | In the simplest case, we just need to import our Renderer: 64 | 65 | ```js 66 | import boxDemoUrl from './box.render' 67 | ``` 68 | 69 | And here, we need to rely on the MagicCanvas Webpack Loader. This looks for files with a `.render.js` (or `.render.ts`) extensions and builds them into stand-alone JS modules. So the value we get out of our `import boxDemoUrl from './box.render'` statement is *actually* a URL. All `MagicCanvas` really requires of us is to provide it a `width`, `height`, and `rendererUrl`. 70 | 71 | If you’re not using Next.js or Webpack, you can use another bundler to create the renderer bundle. The important thing is that the renderer is bundled independent of your other application code, because whether it is running locally or remotely, it runs in isolation from the rest of your application. 72 | 73 | This should give us a rotating cube, rendered in a WebWorker, off the main thread. 74 | 75 | ### Remote rendering 76 | 77 | The most compelling feature of MagicCanvas, though, is its ability to render in the backend and stream the result as a video to the frontend. Let's turn that on by simply passing a `remote={true}` prop to the `MagicCanvas` component: 78 | 79 | ```jsx 80 | 86 | ``` 87 | 88 | This will connect to the server over WebSocket, upload the renderer, establish a WebRTC connection, and synchronize state between the client and server. 89 | 90 | ### Render props 91 | 92 | What if we want to render our demo based on some props passed into the MagicCanvas? Let's augment our demo by adding a light that moves around the scene with our mouse. 93 | 94 | In `box.renderer.js`, before the render function, let's create a light and add it to the scene: 95 | 96 | ```js 97 | const light = new THREE.PointLight(0xffffff, 1, 100) 98 | light.position.set(0, 0, 9) 99 | scene.add(light) 100 | ``` 101 | 102 | Then, let's modify the render function to take the light position as an argument and set it on the ThreeJS light: 103 | 104 | ```js 105 | return function render(lightPosition) { 106 | light.position.x = 10 * lightPosition.x 107 | light.position.y = 10 * lightPosition.y 108 | // the remainder of the function snipped for brevity 109 | } 110 | ``` 111 | 112 | Now, back in our React code, we'll hold our light position in the React Component's state, and pass it to the `MagicCanvas` component: 113 | 114 | ```jsx 115 | const [lightPosition, setLightPosition] = useState({ x: 0, y: 0 }) 116 | 117 | // then, further down: 118 | 119 | 126 | ``` 127 | 128 | Finally, let's set the light position based on the mouse position by wrapping the `MagicCanvas` in a `
` and adding an `onMouseMove` event handler to it: 129 | 130 | ```jsx 131 | const onMouseMove = useCallback(({ clientX, clientY, currentTarget }) => { 132 | const { left, top, width, height } = currentTarget.getBoundingClientRect() 133 | setLightPosition({ 134 | x: ((clientX - left) / width) * 2 - 1, 135 | y: ((clientY - top) / height) * -2 + 1 136 | }) 137 | }, []) 138 | 139 | // ... 140 | 141 |
142 | 143 |
144 | ``` 145 | 146 | That's it! Now we should have a MagicCanvas component that passes updates to its `renderProps` prop along to the Renderer on each frame. 147 | 148 | When rendering locally (with `remote={false}`), these state updates are sent as messages to the WebWorker where the Renderer is running. When rendering remotely (with `remote={true}`), the state updates are sent over WebSockets to the Renderer running in the backend. 149 | -------------------------------------------------------------------------------- /client/dis-lib/MagicCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef } from "react" 2 | import { LocalBackend } from "./LocalBackend" 3 | import { RemoteBackend } from "./RemoteBackend" 4 | 5 | // Types 6 | 7 | export type RenderProps = Record 8 | export type RenderState = Record 9 | 10 | /** Backends generically represent the methods we can use to render: 11 | * local or remote. 12 | */ 13 | export interface RenderBackend { 14 | /** Set the container to render into. */ 15 | setContainer: (container: HTMLElement) => void 16 | 17 | /** Set the props passed into the renderer on every frame. */ 18 | setRenderProps: (props: RenderProps) => void 19 | 20 | setRenderState: (state: RenderState) => void 21 | 22 | /** Get the current render state. */ 23 | getRenderState: () => Promise 24 | 25 | /** Clean up. */ 26 | destroy: () => void 27 | } 28 | 29 | class MCanvas { 30 | remote: boolean = false 31 | container: HTMLElement | null = null 32 | localContainer: HTMLElement | null = null 33 | remoteContainer: HTMLElement | null = null 34 | 35 | localBackend: RenderBackend 36 | remoteBackend: RenderBackend 37 | 38 | constructor(renderUrl: string, renderProps: RenderProps = {}, initialRenderState: RenderState = {}) { 39 | this.localBackend = new LocalBackend(renderUrl, renderProps, initialRenderState) 40 | this.remoteBackend = new RemoteBackend(renderUrl, renderProps, initialRenderState) 41 | } 42 | 43 | setContainer(container: HTMLElement) { 44 | const innerContainer = document.createElement('div') 45 | innerContainer.classList.add('magiccanvas-root') 46 | innerContainer.style.position = 'relative' 47 | innerContainer.style.height = `${container.offsetHeight}px` 48 | innerContainer.style.width = `${container.offsetWidth}px` 49 | container.appendChild(innerContainer) 50 | 51 | const localContainer = document.createElement('div') 52 | localContainer.classList.add('magiccanvas-local') 53 | localContainer.style.position = 'absolute' 54 | localContainer.style.top = '0' 55 | localContainer.style.left = '0' 56 | localContainer.style.bottom = '0' 57 | localContainer.style.right = '0' 58 | localContainer.style.transition = 'all 150ms linear' 59 | localContainer.style.opacity = this.remote ? '0' : '1' 60 | container.appendChild(localContainer) 61 | 62 | const remoteContainer = document.createElement('div') 63 | remoteContainer.classList.add('magiccanvas-remote') 64 | remoteContainer.style.position = 'absolute' 65 | remoteContainer.style.top = '0' 66 | remoteContainer.style.left = '0' 67 | remoteContainer.style.bottom = '0' 68 | remoteContainer.style.right = '0' 69 | remoteContainer.style.transition = 'all 150ms linear' 70 | remoteContainer.style.opacity = this.remote ? '1' : '0' 71 | container.appendChild(remoteContainer) 72 | 73 | this.container = innerContainer 74 | this.localContainer = localContainer 75 | this.remoteContainer = remoteContainer 76 | 77 | this.localBackend.setContainer(localContainer) 78 | this.remoteBackend.setContainer(remoteContainer) 79 | } 80 | 81 | destroy() { 82 | this.localBackend.destroy() 83 | this.remoteBackend.destroy() 84 | } 85 | 86 | async setRemote(remote: boolean) { 87 | if (this.remote === remote) { 88 | return 89 | } 90 | 91 | this.remote = remote 92 | 93 | if (this.remote) { 94 | console.log('getting local state') 95 | let renderState = await this.localBackend.getRenderState() 96 | console.log('got local state', renderState) 97 | this.remoteBackend.setRenderState(renderState) 98 | } else { 99 | console.log('getting remote state') 100 | let renderState = await this.remoteBackend.getRenderState() 101 | console.log('got remote state', renderState) 102 | this.localBackend.setRenderState(renderState) 103 | } 104 | 105 | if (this.localContainer !== null && this.remoteContainer !== null) { 106 | this.localContainer.style.opacity = this.remote ? '0' : '1' 107 | this.remoteContainer.style.opacity = this.remote ? '1' : '0' 108 | } 109 | } 110 | 111 | setRenderProps(props: RenderProps) { 112 | this.localBackend.setRenderProps(props) 113 | this.remoteBackend.setRenderProps(props) 114 | } 115 | } 116 | 117 | type MagicCanvasProps = { 118 | /** Height of the canvas. */ 119 | height: number 120 | 121 | /** Width of the canvas. */ 122 | width: number 123 | 124 | /** Props passed into the renderer. */ 125 | renderProps?: RenderProps, 126 | 127 | /** Initial state passed to the renderer. 128 | * Changes to this after initialization are ignored, 129 | * because renderer is stateful. */ 130 | initialRenderState?: RenderState, 131 | 132 | /** The URL of the JavaScript file to be loaded as the renderer. 133 | * Note: changes to this after initial construction currently have no effect. 134 | */ 135 | rendererUrl: string 136 | 137 | /** Whether to run the renderer remotely. */ 138 | remote?: boolean 139 | } 140 | 141 | /** Hook to create a MCanvas instance. */ 142 | function useMagicCanvas(rendererUrl: string, renderProps: RenderProps = {}, renderState: RenderState = {}): MCanvas { 143 | const mcRef = useRef(null) 144 | 145 | if (typeof window === 'undefined') { 146 | // If we are in SSR, return a dummy object. 147 | return {} as MCanvas 148 | } 149 | 150 | if (mcRef.current === null) { 151 | mcRef.current = new MCanvas(rendererUrl, renderProps, renderState) 152 | } 153 | 154 | return mcRef.current 155 | } 156 | 157 | export function MagicCanvas(props: MagicCanvasProps): React.ReactElement { 158 | const mcRef = useMagicCanvas(props.rendererUrl, props.renderProps, props.initialRenderState) 159 | 160 | useEffect(() => { 161 | mcRef.setRenderProps(props.renderProps || {}) 162 | }, [props.renderProps]) 163 | 164 | useEffect(() => { 165 | mcRef.setRemote(props.remote || false) 166 | }, [props.remote]) 167 | 168 | const setContainer = useCallback((container: HTMLElement | null) => { 169 | if (container === null) { 170 | mcRef.destroy() 171 | } else { 172 | mcRef.setContainer(container) 173 | } 174 | }, []) 175 | 176 | return
184 | } 185 | -------------------------------------------------------------------------------- /client/dis-lib/RemoteBackend.ts: -------------------------------------------------------------------------------- 1 | import { RenderBackend, RenderProps, RenderState } from "./MagicCanvas"; 2 | import { PromiseBox } from "./util"; 3 | 4 | /** Copy of IncomingMessage from the handler; todo: make this an import from a common package. */ 5 | interface MessageToRemote { 6 | /** Update the local render props. */ 7 | renderProps?: RenderProps 8 | 9 | /** Register the active websocket connection as a belonging to the client or streamer. */ 10 | register?: 'client' 11 | 12 | init?: { 13 | /** Renderer JavaScript module as a string. */ 14 | renderer: string 15 | } 16 | 17 | /** Initial render state. */ 18 | renderState?: RenderState 19 | 20 | toStreamer?: { 21 | /** Send the RTC SDP to the other party. */ 22 | rtc?: RTCSessionDescriptionInit 23 | 24 | /** Send the ICE candidate to the other party. */ 25 | ice?: { candidate: string } 26 | } 27 | 28 | requestState?: boolean 29 | } 30 | 31 | interface MessageFromRemote { 32 | renderState?: RenderState 33 | 34 | rtc?: RTCSessionDescriptionInit 35 | } 36 | 37 | /** Represents a WebSocket connection to a remote handler. */ 38 | class HandlerConnection { 39 | ws: WebSocket 40 | private _remoteSdp: PromiseBox 41 | private _ready: PromiseBox 42 | private statePromise: PromiseBox | null = null 43 | 44 | constructor(url: string) { 45 | this._remoteSdp = new PromiseBox() 46 | this._ready = new PromiseBox() 47 | this.ws = new WebSocket(url) 48 | 49 | this.ws.onopen = () => { 50 | console.log("Connected to handler.") 51 | this._ready.set() 52 | } 53 | 54 | this.ws.onmessage = (e) => { 55 | const msg = JSON.parse(e.data) 56 | this.dispatch(msg) 57 | } 58 | 59 | this.ws.onerror = (e) => { 60 | console.log('error', e) 61 | } 62 | } 63 | 64 | async init(rendererJavascript: string, initialRenderProps: RenderProps, initialRenderState: RenderState) { 65 | await this._ready.get() 66 | this.send({ 67 | register: "client", 68 | init: { 69 | renderer: rendererJavascript, 70 | }, 71 | renderState: initialRenderState, 72 | renderProps: initialRenderProps 73 | }) 74 | } 75 | 76 | updateProps(props: RenderProps) { 77 | this.send({ renderProps: props }) 78 | } 79 | 80 | updateState(state: RenderState) { 81 | this.send({ renderState: state }) 82 | } 83 | 84 | remoteSdp(): Promise { 85 | return this._remoteSdp.get() 86 | } 87 | 88 | sendSdp(sdp: RTCSessionDescriptionInit) { 89 | this.send({ toStreamer: { rtc: sdp } }) 90 | } 91 | 92 | destroy() { 93 | this.ws.close() 94 | } 95 | 96 | private dispatch(msg: MessageFromRemote) { 97 | console.log('Got message', msg) 98 | if (msg.rtc !== undefined) { 99 | this._remoteSdp.set(msg.rtc) 100 | } 101 | if (msg.renderState !== undefined) { 102 | if (this.statePromise !== null) { 103 | this.statePromise.set(msg.renderState) 104 | } 105 | } 106 | } 107 | 108 | private send(msg: MessageToRemote) { 109 | if (this.ws.readyState === WebSocket.OPEN) { 110 | this.ws.send(JSON.stringify(msg)) 111 | } 112 | } 113 | 114 | getRenderState(): Promise { 115 | this.statePromise = new PromiseBox() 116 | this.send({ requestState: true }) 117 | return this.statePromise.get() 118 | } 119 | 120 | ready(): Promise { 121 | return this._ready.get() 122 | } 123 | } 124 | 125 | class RTCConnection { 126 | conn: RTCPeerConnection 127 | iceGatheringComplete: PromiseBox = new PromiseBox() 128 | 129 | constructor( 130 | private handlerConnection: HandlerConnection, 131 | private rendererUrl: string, 132 | private initialRenderProps: RenderProps, 133 | private initialRenderState: RenderState, 134 | private videoEl: HTMLVideoElement 135 | ) { 136 | 137 | // for the client, this URL will end up being a jamsocket backend 138 | this.conn = new RTCPeerConnection({ 139 | bundlePolicy: "max-bundle", 140 | iceTransportPolicy: "relay", 141 | iceServers: [ 142 | { 143 | urls: "stun:relay.metered.ca:80", 144 | }, 145 | { 146 | urls: "turn:relay.metered.ca:80", 147 | username: "c7f21a3a3a5f693e365dbc55", 148 | credential: "FyMQ1pmIIaUiFeCQ", 149 | }, 150 | { 151 | urls: "turn:relay.metered.ca:443", 152 | username: "c7f21a3a3a5f693e365dbc55", 153 | credential: "FyMQ1pmIIaUiFeCQ", 154 | }, 155 | { 156 | urls: "turn:relay.metered.ca:443?transport=tcp", 157 | username: "c7f21a3a3a5f693e365dbc55", 158 | credential: "FyMQ1pmIIaUiFeCQ", 159 | }, 160 | ], 161 | }); 162 | 163 | this.conn.onicecandidate = (e) => { 164 | console.log('Candidate', e) 165 | console.log('status', this.conn?.iceGatheringState) 166 | } 167 | 168 | this.conn.ontrack = (t) => { 169 | console.log('Got track') 170 | this.videoEl.srcObject = t.streams[0] 171 | console.log('paused', this.videoEl.paused) 172 | } 173 | 174 | this.conn.onicegatheringstatechange = () => { 175 | console.log("Ice gathering state", this.conn.iceGatheringState) 176 | if (this.conn.iceGatheringState === "complete") { 177 | this.iceGatheringComplete.set() 178 | } 179 | } 180 | } 181 | 182 | async connect() { 183 | console.log("Fetching renderer") 184 | let rendererJavascript = await fetch(this.rendererUrl).then(r => r.text()) 185 | 186 | console.log("Calling init on handler") 187 | this.handlerConnection.init(rendererJavascript, this.initialRenderProps, this.initialRenderState) 188 | 189 | console.log('Waiting for server SDP') 190 | const sdp = await this.handlerConnection.remoteSdp() 191 | 192 | console.log('Gathering ICE candidates.') 193 | await this.conn.setRemoteDescription(sdp) 194 | 195 | console.log('Creating an answer.') 196 | const answer = await this.conn.createAnswer() 197 | 198 | console.log('Setting local description.') 199 | await this.conn.setLocalDescription(answer) 200 | await this.iceGatheringComplete.get() 201 | 202 | if (this.conn.localDescription === null) { 203 | throw new Error('conn.localDescription should not be null') 204 | } 205 | 206 | console.log('Sending client description.', this.conn.localDescription) 207 | this.handlerConnection.sendSdp(this.conn.localDescription) 208 | } 209 | } 210 | 211 | export class RemoteBackend implements RenderBackend { 212 | container: PromiseBox = new PromiseBox() 213 | connection: HandlerConnection 214 | rtcConnection: RTCConnection | null = null 215 | videoElement: HTMLVideoElement | null = null 216 | 217 | getWsUrl(): string { 218 | const url = new URL(window.location.href) 219 | if (url.protocol === 'https:') { 220 | url.protocol = 'wss:' 221 | } else { 222 | url.protocol = 'ws:' 223 | } 224 | 225 | url.pathname = '/ws' 226 | url.port = '8080' 227 | 228 | return url.href 229 | } 230 | 231 | constructor(private renderUrl: string, private renderProps: RenderState = {}, private initialRenderState: RenderState = {}) { 232 | this.connection = new HandlerConnection(this.getWsUrl()) 233 | this.initConnection() 234 | } 235 | 236 | private async initConnection() { 237 | await this.connection.init(this.renderUrl, this.renderProps, this.initialRenderState) 238 | this.rtcConnection = new RTCConnection(this.connection, this.renderUrl, this.renderProps, this.initialRenderState, await this.getVideoElement()) 239 | await this.rtcConnection.connect() 240 | } 241 | 242 | /** If we already have a video element, return it; otherwise, create one (waiting for the container if necessary) */ 243 | private async getVideoElement(): Promise { 244 | if (this.videoElement !== null) { 245 | return this.videoElement 246 | } 247 | 248 | const container = await this.container.get() 249 | const video = document.createElement("video") 250 | 251 | video.height = container.clientHeight 252 | video.width = container.clientWidth 253 | 254 | video.autoplay = true 255 | video.muted = true 256 | video.playsInline = true 257 | // video.style.width = "100%" 258 | // video.style.height = "100%" 259 | 260 | video.style.transition = 'all 150ms linear' 261 | video.style.border = '1px solid #ddd' 262 | video.style.width = '100%' 263 | video.style.height = '100%' 264 | video.style.opacity = '1' 265 | video.style.pointerEvents = 'initial' 266 | 267 | container.appendChild(video) 268 | this.videoElement = video 269 | return video 270 | } 271 | 272 | /** Set the container. No-op if the container is already set. */ 273 | setContainer(container: HTMLElement) { 274 | this.container.set(container) 275 | } 276 | 277 | setRenderProps(props: RenderProps) { 278 | this.connection.updateProps(props) 279 | } 280 | 281 | setRenderState(state: RenderState) { 282 | this.connection.updateState(state) 283 | } 284 | 285 | getRenderState(): Promise { 286 | return this.connection.getRenderState() 287 | } 288 | 289 | destroy() { 290 | this.connection.destroy() 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /client/renderers/lidar.render.ts: -------------------------------------------------------------------------------- 1 | import { mat4 } from 'gl-matrix' 2 | 3 | export default async function createRenderer(gl: WebGLRenderingContext) { 4 | const ext = gl.getExtension('OES_texture_float') 5 | if (!ext) { 6 | throw new Error('Unable to get OES_texture_float extension') 7 | } 8 | 9 | // const config = { dataset: '987210.bin', fadeHeightOffsetRange: [350, 1000] } 10 | // const config = { dataset: 'midtown-sampled-sm.bin', fadeHeightOffsetRange: [450, 1200] } 11 | const config = { dataset: 'midtown-sampled-md.bin', fadeHeightOffsetRange: [450, 1200] } 12 | // const config = { dataset: 'midtown-sampled-lg.bin', fadeHeightOffsetRange: [450, 1200] } 13 | // const config = { dataset: 'midtown-sampled-xl.bin', fadeHeightOffsetRange: [450, 1200] } 14 | // const config = { dataset: 'manhattan-sampled-sm.bin', fadeHeightOffsetRange: [900, 2400] } 15 | // const config = { dataset: 'manhattan-sampled-md.bin', fadeHeightOffsetRange: [900, 2400] } 16 | // const config = { dataset: 'manhattan-sampled-lg.bin', fadeHeightOffsetRange: [900, 2400] } 17 | 18 | 19 | const result = await getLidarStreamer(gl, `https://nyc-lidar-demo.s3.amazonaws.com/${config.dataset}`) 20 | 21 | const { getCurrentPointCount, offset, buffer, batchIds, animationTextureSize, animationTexture } = result 22 | 23 | const minZ = offset[2] 24 | 25 | const vs = ` 26 | precision highp float; 27 | 28 | attribute vec3 position; 29 | attribute float intensity; 30 | attribute float batchId; 31 | uniform mat4 projection; 32 | uniform mat4 view; 33 | uniform float fadeHeightStart; 34 | uniform float fadeHeightEnd; 35 | uniform sampler2D animationStartTexture; 36 | uniform float textureSize; 37 | uniform float time; 38 | varying vec4 color; 39 | 40 | #define C1 vec3(0.22745, 0.06667, 0.10980) 41 | #define C2 vec3(0.34118, 0.28627, 0.31765) 42 | #define C3 vec3(0.51373, 0.59608, 0.55686) 43 | #define C4 vec3(0.73725, 0.87059, 0.64706) 44 | #define C5 vec3(0.90196, 0.97647, 0.73725) 45 | 46 | vec3 getColorFromPalette(float t) { 47 | if (t < 0.25) return mix(C1, C2, smoothstep(0.0, 0.25, t)); 48 | if (t < 0.5) return mix(C2, C3, smoothstep(0.25, 0.5, t)); 49 | if (t < 0.75) return mix(C3, C4, smoothstep(0.5, 0.75, t)); 50 | return mix(C4, C5, smoothstep(0.75, 1.0, t)); 51 | } 52 | 53 | void main() { 54 | vec3 p = position; 55 | float colorPow = 2.0; 56 | float colorOffset = 0.5; 57 | float t = intensity; 58 | float texIdx = floor(batchId / 4.0); 59 | int texComponent = int(mod(batchId, 4.0)); 60 | vec2 texCoord = vec2( 61 | mod(texIdx, textureSize) / (textureSize - 1.0), 62 | floor(texIdx / textureSize) / (textureSize - 1.0) 63 | ); 64 | 65 | vec4 animationDataPx = texture2D(animationStartTexture, texCoord); 66 | 67 | // an annoying limitation of GLSL 1 (WebGL1) is that you cannot index into a vector 68 | // with a variable - only a constant 69 | float animationStart; 70 | if (texComponent == 0) { 71 | animationStart = animationDataPx.x; 72 | } else if (texComponent == 1) { 73 | animationStart = animationDataPx.y; 74 | } else if (texComponent == 2) { 75 | animationStart = animationDataPx.z; 76 | } else { 77 | animationStart = animationDataPx.w; 78 | } 79 | 80 | float animationDurationMs = 3000.0; 81 | float animationT = clamp((time - animationStart) / animationDurationMs, 0.0, 1.0); 82 | // apply easing 83 | animationT = 1.0 - pow(1.0 - animationT, 4.0); 84 | // if animationStart is 0.0, then zero out animationT 85 | animationT *= float(bool(animationStart)); 86 | // have the points animate up into position slightly 87 | p.z -= 50.0 * (1.0 - animationT); 88 | 89 | vec3 c = getColorFromPalette(pow(t + colorOffset, colorPow)); 90 | // points that are closer to the ground should be darker 91 | float colorMult = 0.05 + smoothstep(fadeHeightEnd, fadeHeightStart, p.z); 92 | c *= colorMult; 93 | color = vec4(c, animationT); 94 | 95 | // get the position of the point with respect to the camera 96 | vec4 translatedPosition = view * vec4(p, 1); 97 | float distToCamera = length(translatedPosition); 98 | float sizeT = 1.0 - pow(smoothstep(20.0, 2200.0, distToCamera), 0.5); 99 | float size = mix(1.0, 7.0, sizeT); 100 | float hide = step(fadeHeightEnd + 1.0, p.z) * (animationT * 0.5 + 0.5); 101 | gl_PointSize = size * hide; 102 | gl_Position = projection * translatedPosition * hide; 103 | } 104 | ` 105 | 106 | const fs = ` 107 | precision highp float; 108 | varying vec4 color; 109 | void main() { 110 | gl_FragColor = color; 111 | } 112 | ` 113 | 114 | gl.clearColor(0.11, 0.12, 0.13, 1) 115 | // this isn't perfectly correct since we have blending turned on, but once all the data is 116 | // loaded, there are no transparent pixels anymore, and the performance win from depth testing 117 | // is too good to turn off 118 | gl.enable(gl.DEPTH_TEST) 119 | gl.enable(gl.BLEND) 120 | gl.blendEquation(gl.FUNC_ADD) 121 | gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE) 122 | 123 | const program = linkProgram(gl, vs, fs) 124 | gl.useProgram(program) 125 | 126 | const batchIdsBuffer = gl.createBuffer() 127 | gl.bindBuffer(gl.ARRAY_BUFFER, batchIdsBuffer) 128 | gl.bufferData(gl.ARRAY_BUFFER, batchIds, gl.STATIC_DRAW) 129 | const batchIdsAttributeLocation = gl.getAttribLocation(program, 'batchId') 130 | gl.enableVertexAttribArray(batchIdsAttributeLocation) 131 | gl.vertexAttribPointer(batchIdsAttributeLocation, 1, gl.UNSIGNED_BYTE, false, 1, 0) 132 | 133 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer) 134 | const positionAttributeLocation = gl.getAttribLocation(program, 'position') 135 | gl.enableVertexAttribArray(positionAttributeLocation) 136 | gl.vertexAttribPointer(positionAttributeLocation, 3, gl.UNSIGNED_SHORT, false, 8, 0) 137 | 138 | const intensityAttributeLocation = gl.getAttribLocation(program, 'intensity') 139 | gl.enableVertexAttribArray(intensityAttributeLocation) 140 | gl.vertexAttribPointer(intensityAttributeLocation, 1, gl.UNSIGNED_SHORT, true, 8, 6) 141 | 142 | const viewUniform = gl.getUniformLocation(program, 'view') 143 | const projectionUniform = gl.getUniformLocation(program, 'projection') 144 | const fadeHeightStartUniform = gl.getUniformLocation(program, 'fadeHeightStart') 145 | const fadeHeighEndUniform = gl.getUniformLocation(program, 'fadeHeightEnd') 146 | const textureSizeUniform = gl.getUniformLocation(program, 'textureSize') 147 | const timeUniform = gl.getUniformLocation(program, 'time') 148 | const animationStartTextureUniform = gl.getUniformLocation(program, 'animationStartTexture') 149 | 150 | return function render(renderState: { matrix: number[] }) { 151 | const { matrix } = renderState 152 | const time = Math.floor(performance.now()) 153 | 154 | gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT) 155 | // TODO: pass in width and height here to allow for dynamic resizing 156 | const width = gl.drawingBufferWidth 157 | const height = gl.drawingBufferHeight 158 | gl.viewport(0, 0, width, height) 159 | 160 | const projection = mat4.perspective(new Float32Array(16), Math.PI / 4, width / height, 1, 1000000) 161 | 162 | gl.bindTexture(gl.TEXTURE_2D, animationTexture) 163 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) 164 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) 165 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) 166 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) 167 | 168 | gl.uniformMatrix4fv(viewUniform, false, matrix) 169 | gl.uniformMatrix4fv(projectionUniform, false, projection) 170 | // TODO: have these controlled by UI sliders? 171 | gl.uniform1f(fadeHeightStartUniform, minZ + config.fadeHeightOffsetRange[1]) 172 | gl.uniform1f(fadeHeighEndUniform, minZ + config.fadeHeightOffsetRange[0]) 173 | gl.uniform1f(textureSizeUniform, animationTextureSize) 174 | gl.uniform1f(timeUniform, time) 175 | gl.uniform1i(animationStartTextureUniform, 0) 176 | 177 | gl.drawArrays(gl.POINTS, 0, getCurrentPointCount()) 178 | } 179 | } 180 | 181 | async function getLidarStreamer(gl: WebGLRenderingContext, url: string) { 182 | const startTime = performance.now() 183 | const response = await fetch(url) 184 | 185 | if (!response.body) { 186 | throw new Error('Unable to fetch lidar data. No response.body.') 187 | } 188 | 189 | const littleEndian = isLittleEndian() 190 | 191 | /* 192 | Binary Data format: 193 | pointCount - uint32 194 | xOffset, yOffset, zOffset - int32s 195 | pt1 xDelta, yDelta, zDelta - uint16s 196 | pt1 intensity - uint16 197 | pt2... 198 | */ 199 | const reader = response.body.getReader() 200 | 201 | const result = await reader.read() 202 | if (result.done || !result.value) throw new Error('Unable to fetch lidar data. Stream completed before any data was received.') 203 | const dataview = new DataView(result.value.buffer) 204 | const pointCount = dataview.getUint32(0, littleEndian) 205 | const offset = [ 206 | dataview.getInt32(4, littleEndian), 207 | dataview.getInt32(8, littleEndian), 208 | dataview.getInt32(12, littleEndian) 209 | ] 210 | 211 | console.log({ pointCount, offset }) 212 | 213 | const pointSizeInBytes = 4 * 2 // each point has 4 uint16 values 214 | const lidarData = new Uint8Array(pointCount * pointSizeInBytes) 215 | const initialData = new Uint8Array(result.value.buffer, 16) 216 | lidarData.set(initialData) 217 | 218 | let i = initialData.length 219 | let currentPointCount = Math.floor(i / pointSizeInBytes) 220 | 221 | const buffer = gl.createBuffer() 222 | if (!buffer) throw new Error('Could not create WebGL buffer') 223 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer) 224 | gl.bufferData(gl.ARRAY_BUFFER, lidarData, gl.DYNAMIC_DRAW) 225 | 226 | const textureSize = 8 // needs to be 8x8 texture in order to have fewer than 256 pointers (so we can use uint8s for pointers) 227 | const texturePxCount = textureSize * textureSize 228 | const batchCount = 4 * texturePxCount // 4 slots per pixel 229 | const pointBatchSize = Math.ceil(pointCount / batchCount) 230 | const animationData = new Float32Array(texturePxCount * 4) 231 | const batchIds = new Uint8Array(pointCount) 232 | for (let j = 0; j < batchIds.length; j++) { 233 | const batchId = Math.floor(j / pointBatchSize) 234 | batchIds[j] = batchId 235 | } 236 | 237 | const animationTexture = gl.createTexture() 238 | gl.bindTexture(gl.TEXTURE_2D, animationTexture) 239 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textureSize, textureSize, 0, gl.RGBA, gl.FLOAT, animationData) 240 | 241 | setTimeout(async function loadChunk() { 242 | let chunks = 1 243 | while (true) { 244 | const result = await reader.read() 245 | if (result.done) { 246 | console.log(`finished loading data in ${chunks} chunks. time(ms):`, performance.now() - startTime) 247 | return 248 | } 249 | chunks += 1 250 | // this should always have a value, but this check will satisfy typescript 251 | if (result.value) { 252 | const prevCompletedBatches = Math.floor(currentPointCount / pointBatchSize) 253 | gl.bufferSubData(gl.ARRAY_BUFFER, i, result.value) 254 | i += result.value.length 255 | currentPointCount = Math.floor(i / pointSizeInBytes) 256 | const curCompletedBatches = Math.floor(currentPointCount / pointBatchSize) 257 | const curTime = performance.now() 258 | for (let k = prevCompletedBatches; k < curCompletedBatches; k++) { 259 | animationData[k] = curTime 260 | } 261 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textureSize, textureSize, 0, gl.RGBA, gl.FLOAT, animationData) 262 | } 263 | } 264 | }, 0) 265 | 266 | return { 267 | offset, 268 | pointCount, 269 | getCurrentPointCount: () => currentPointCount, 270 | buffer, 271 | animationTextureSize: textureSize, 272 | batchIds, 273 | animationTexture 274 | } 275 | } 276 | 277 | function isLittleEndian () { 278 | const buffer = new ArrayBuffer(2) 279 | new DataView(buffer).setInt16(0, 256, true /* littleEndian */) 280 | // Int16Array uses the platform's endianness. 281 | return new Int16Array(buffer)[0] === 256 282 | } 283 | 284 | function createShader(gl: WebGLRenderingContext, type: number, source: string) { 285 | const shader = gl.createShader(type) 286 | 287 | if (!shader) { 288 | throw new Error('Could not create shader.') 289 | } 290 | 291 | gl.shaderSource(shader, source) 292 | gl.compileShader(shader) 293 | const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS) 294 | if (!success) { 295 | const err = gl.getShaderInfoLog(shader) 296 | throw new Error(`Shader error: ${err}`) 297 | } 298 | 299 | return shader 300 | } 301 | 302 | function linkProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) { 303 | const program = gl.createProgram() 304 | if (!program) { 305 | throw new Error('Could not create program') 306 | } 307 | 308 | const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource) 309 | const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource) 310 | 311 | gl.attachShader(program, vertexShader) 312 | gl.attachShader(program, fragmentShader) 313 | gl.linkProgram(program) 314 | 315 | const success = gl.getProgramParameter(program, gl.LINK_STATUS) 316 | if (!success) { 317 | const err = gl.getProgramInfoLog(program) 318 | throw new Error(`Link error: ${err}`) 319 | } 320 | 321 | return program 322 | } 323 | --------------------------------------------------------------------------------