├── .npmrc ├── .gitignore ├── .editorconfig ├── .travis.yml ├── demo ├── index.html └── src │ ├── utils.js │ ├── style.css │ └── index.js ├── LICENSE ├── package.json ├── test └── index.test.js ├── README.md └── src └── index.ts /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | package-lock.json 5 | coverage 6 | .rts* 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | dist: trusty 5 | sudo: false 6 | addons: 7 | chrome: stable 8 | cache: 9 | npm: true 10 | directories: 11 | - node_modules 12 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/src/utils.js: -------------------------------------------------------------------------------- 1 | export function rotateX(matrix, angle) { 2 | const m = matrix; 3 | const c = Math.cos(angle); 4 | const s = Math.sin(angle); 5 | const mv1 = m[1]; 6 | const mv5 = m[5]; 7 | const mv9 = m[9]; 8 | 9 | m[1] = m[1] * c - m[2] * s; 10 | m[5] = m[5] * c - m[6] * s; 11 | m[9] = m[9] * c - m[10] * s; 12 | 13 | m[2] = m[2] * c + mv1 * s; 14 | m[6] = m[6] * c + mv5 * s; 15 | m[10] = m[10] * c + mv9 * s; 16 | } 17 | 18 | export function rotateY(matrix, angle) { 19 | const m = matrix; 20 | const c = Math.cos(angle); 21 | const s = Math.sin(angle); 22 | const mv0 = m[0]; 23 | const mv4 = m[4]; 24 | const mv8 = m[8]; 25 | 26 | m[0] = c * m[0] + s * m[2]; 27 | m[4] = c * m[4] + s * m[6]; 28 | m[8] = c * m[8] + s * m[10]; 29 | 30 | m[2] = c * m[2] - s * mv0; 31 | m[6] = c * m[6] - s * mv4; 32 | m[10] = c * m[10] - s * mv8; 33 | } 34 | 35 | export function getRandom(value) { 36 | const floor = -value; 37 | return floor + Math.random() * value * 2; 38 | } 39 | 40 | export function rgbToHsl(rgb) { 41 | return rgb.map(c => c / 255); 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Colin van Eenige 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 | -------------------------------------------------------------------------------- /demo/src/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | width: 100%; 6 | height: 100%; 7 | 8 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 9 | letter-spacing: 0; 10 | font-style: normal; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | canvas { 17 | position: fixed; 18 | width: 100%; 19 | height: 100%; 20 | image-rendering: pixelated; 21 | } 22 | 23 | .controls { 24 | position: fixed; 25 | margin: 16px; 26 | } 27 | 28 | button { 29 | -webkit-appearance: none; 30 | display: inline-block; 31 | border: 2px solid rgb(83, 109, 254);; 32 | font-size: 14px; 33 | font-weight: 500; 34 | color: rgb(83, 109, 254); 35 | text-align: center; 36 | text-decoration: none; 37 | text-transform: uppercase; 38 | border-radius: 0; 39 | cursor: pointer; 40 | outline: none; 41 | padding: 8px 20px; 42 | margin-right: 10px; 43 | } 44 | 45 | button:hover, button:focus { 46 | color: white; 47 | background-color: rgb(83, 109, 254); 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phenomenon", 3 | "version": "1.6.0", 4 | "description": "A fast 2kB low-level WebGL API.", 5 | "source": "src/index.ts", 6 | "main": "dist/phenomenon.mjs", 7 | "unpkg": "dist/phenomenon.umd.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "start": "http-server demo --silent & $npm_execpath run watch", 11 | "watch": "microbundle watch --format umd --entry demo/src/index.js --output demo/dist/bundle.js", 12 | "build": "microbundle --name Phenomenon --format es,umd --sourcemap false", 13 | "test": "npm run build && karmatic", 14 | "prepare": "$npm_execpath run test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/vaneenige/phenomenon.git" 19 | }, 20 | "license": "MIT", 21 | "author": { 22 | "name": "Colin van Eenige", 23 | "email": "cvaneenige@gmail.com", 24 | "url": "https://use-the-platform.com" 25 | }, 26 | "files": [ 27 | "src", 28 | "dist" 29 | ], 30 | "keywords": [ 31 | "webgl", 32 | "particles", 33 | "shaders" 34 | ], 35 | "prettier": { 36 | "printWidth": 100, 37 | "singleQuote": true, 38 | "trailingComma": "es5" 39 | }, 40 | "devDependencies": { 41 | "http-server": "^0.11.1", 42 | "karmatic": "^1.3.1", 43 | "microbundle": "^0.11.0", 44 | "webpack": "^4.31.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | // Import module from source 2 | import Phenomenon from '../../dist/phenomenon'; 3 | 4 | // Import optional utils 5 | import { getRandom, rgbToHsl, rotateY } from './utils'; 6 | 7 | // Material colors in HSL 8 | const colors = [[255, 108, 0], [83, 109, 254], [29, 233, 182], [253, 216, 53]].map(color => 9 | rgbToHsl(color) 10 | ); 11 | 12 | // Boolean to toggle dynamic attributes 13 | const dynamicAttributes = true; 14 | 15 | // Update value for every frame 16 | const step = 0.01; 17 | 18 | // Multiplier of the canvas resolution 19 | const devicePixelRatio = 1; 20 | 21 | // Create the renderer 22 | const phenomenon = new Phenomenon({ 23 | settings: { 24 | devicePixelRatio, 25 | position: { x: 0, y: 0, z: 3 }, 26 | onRender: r => { 27 | rotateY(r.uniforms.uModelMatrix.value, step * 2); 28 | }, 29 | }, 30 | }); 31 | 32 | let count = 0; 33 | 34 | function addInstance() { 35 | count += 1; 36 | 37 | // The amount of particles that will be created 38 | const multiplier = 4000; 39 | 40 | // Percentage of how long every particle will move 41 | const duration = 0.6; 42 | 43 | // Base start position (center of the cube) 44 | const start = { 45 | x: getRandom(1), 46 | y: getRandom(1), 47 | z: getRandom(1), 48 | }; 49 | 50 | // Base end position (center of the cube) 51 | const end = { 52 | x: getRandom(1), 53 | y: getRandom(1), 54 | z: getRandom(1), 55 | }; 56 | 57 | // Every attribute must have: 58 | // - Name (used in the shader) 59 | // - Data (returns data for every particle) 60 | // - Size (amount of variables in the data) 61 | const attributes = [ 62 | { 63 | name: 'aPositionStart', 64 | data: () => [start.x + getRandom(0.1), start.y + getRandom(0.1), start.z + getRandom(0.1)], 65 | size: 3, 66 | }, 67 | { 68 | name: 'aPositionEnd', 69 | data: () => [end.x + getRandom(0.1), end.y + getRandom(0.1), end.z + getRandom(0.1)], 70 | size: 3, 71 | }, 72 | { 73 | name: 'aColor', 74 | data: () => colors[count % 4], 75 | size: 3, 76 | }, 77 | { 78 | name: 'aOffset', 79 | data: i => [i * ((1 - duration) / (multiplier - 1))], 80 | size: 1, 81 | }, 82 | ]; 83 | 84 | // Every uniform must have: 85 | // - Key (used in the shader) 86 | // - Type (what kind of value) 87 | // - Value (based on the type) 88 | const uniforms = { 89 | uProgress: { 90 | type: 'float', 91 | value: 0.0, 92 | }, 93 | }; 94 | 95 | // Vertex shader used to calculate the position 96 | const vertex = ` 97 | attribute vec3 aPositionStart; 98 | attribute vec3 aPositionEnd; 99 | attribute vec3 aPosition; 100 | attribute vec3 aColor; 101 | attribute float aOffset; 102 | 103 | uniform float uProgress; 104 | uniform mat4 uProjectionMatrix; 105 | uniform mat4 uModelMatrix; 106 | uniform mat4 uViewMatrix; 107 | 108 | varying vec3 vColor; 109 | 110 | float easeInOutQuint(float t){ 111 | return t < 0.5 ? 16.0 * t * t * t * t * t : 1.0 + 16.0 * (--t) * t * t * t * t; 112 | } 113 | 114 | void main(){ 115 | float tProgress = easeInOutQuint(min(1.0, max(0.0, (uProgress - aOffset)) / ${duration})); 116 | vec3 newPosition = mix(aPositionStart, aPositionEnd, tProgress); 117 | gl_Position = uProjectionMatrix * uModelMatrix * uViewMatrix * vec4(newPosition + aPosition, 1.0); 118 | gl_PointSize = ${devicePixelRatio.toFixed(1)}; 119 | vColor = aColor; 120 | } 121 | `; 122 | 123 | // Fragment shader to draw the colored pixels to the canvas 124 | const fragment = ` 125 | precision mediump float; 126 | 127 | varying vec3 vColor; 128 | 129 | void main(){ 130 | gl_FragColor = vec4(vColor, 1.0); 131 | } 132 | `; 133 | 134 | // Boolean to switch transition direction 135 | let forward = true; 136 | 137 | // Add an instance to the renderer 138 | phenomenon.add(count, { 139 | attributes, 140 | multiplier, 141 | vertex, 142 | fragment, 143 | uniforms, 144 | onRender: r => { 145 | const { uProgress } = r.uniforms; 146 | uProgress.value += forward ? step : -step; 147 | 148 | if (uProgress.value >= 1) { 149 | if (dynamicAttributes) { 150 | const newEnd = { 151 | x: getRandom(1), 152 | y: getRandom(1), 153 | z: getRandom(1), 154 | }; 155 | r.prepareBuffer({ 156 | name: 'aPositionStart', 157 | data: r.attributes[1].data, 158 | size: 3, 159 | }); 160 | r.prepareAttribute({ 161 | name: 'aPositionEnd', 162 | data: () => [ 163 | newEnd.x + getRandom(0.1), 164 | newEnd.y + getRandom(0.1), 165 | newEnd.z + getRandom(0.1), 166 | ], 167 | size: 3, 168 | }); 169 | uProgress.value = 0; 170 | } else { 171 | forward = false; 172 | } 173 | } else if (uProgress.value <= 0) forward = true; 174 | }, 175 | }); 176 | } 177 | 178 | for (let i = 0; i < 10; i += 1) { 179 | addInstance(); 180 | } 181 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | /* global expect */ 2 | 3 | import Phenomenon from '../dist/phenomenon'; 4 | 5 | let phenomenon; 6 | 7 | describe('phenomenon', () => { 8 | beforeEach(() => { 9 | const canvas = document.querySelector('canvas'); 10 | if (canvas !== null) canvas.outerHTML = ''; 11 | document.body.appendChild(document.createElement('canvas')); 12 | if (typeof phenomenon !== 'undefined') phenomenon.destroy(); 13 | phenomenon = new Phenomenon(); 14 | }); 15 | 16 | describe('renderer', () => { 17 | describe('constructor()', () => { 18 | it('should have a valid WebGL context after creation', () => { 19 | expect(phenomenon.gl.constructor.name).toBe('WebGLRenderingContext'); 20 | }); 21 | 22 | it('should override default values based on parameters', () => { 23 | phenomenon = new Phenomenon({ 24 | context: { alpha: false }, 25 | settings: { devicePixelRatio: 2 }, 26 | }); 27 | expect(phenomenon.gl.getContextAttributes().alpha).toBe(false); 28 | expect(phenomenon.devicePixelRatio).toBe(2); 29 | }); 30 | }); 31 | 32 | describe('resize()', () => { 33 | it('should adjust the width and height of the canvas', () => { 34 | phenomenon = new Phenomenon({ 35 | settings: { devicePixelRatio: 2 }, 36 | }); 37 | expect(phenomenon.canvas.width).toBe(600); 38 | expect(phenomenon.canvas.height).toBe(300); 39 | }); 40 | 41 | it('should have projection, view and model uniforms', () => { 42 | const { uProjectionMatrix, uViewMatrix, uModelMatrix } = phenomenon.uniforms; 43 | expect(uProjectionMatrix).toBeDefined(); 44 | expect(uViewMatrix).toBeDefined(); 45 | expect(uModelMatrix).toBeDefined(); 46 | }); 47 | }); 48 | 49 | describe('toggle()', () => { 50 | it('should update its state if its provided as a parameter', () => { 51 | phenomenon.toggle(false); 52 | expect(phenomenon.shouldRender).toBe(false); 53 | }); 54 | 55 | it('should toggle the shouldRender boolean without parameters', () => { 56 | phenomenon.toggle(); 57 | expect(phenomenon.shouldRender).toBe(false); 58 | phenomenon.toggle(); 59 | expect(phenomenon.shouldRender).toBe(true); 60 | }); 61 | }); 62 | 63 | describe('render()', () => { 64 | it('should call the render hooks if provided', done => { 65 | phenomenon = new Phenomenon({ 66 | settings: { 67 | onRender: () => { 68 | done(); 69 | }, 70 | }, 71 | }); 72 | }); 73 | }); 74 | 75 | describe('add()', () => { 76 | it('should add a new instance by its key', done => { 77 | phenomenon.add('instance'); 78 | expect(phenomenon.instances.size).toBe(1); 79 | expect(phenomenon.instances.get('instance')).toBeDefined(); 80 | done(); 81 | }); 82 | 83 | it('should create a deep clone of renderer uniforms', () => { 84 | const instance = phenomenon.add('instance'); 85 | expect(instance.uniforms.uModelMatrix === phenomenon.uniforms.uModelMatrix).toBe(false); 86 | }); 87 | 88 | it('should return the instance after creation', () => { 89 | const instance = phenomenon.add('instance'); 90 | expect(instance.constructor.name).toBe('e'); 91 | }); 92 | }); 93 | 94 | describe('remove()', () => { 95 | it('should remove an instance by its key', done => { 96 | phenomenon.add('instance'); 97 | phenomenon.remove('instance'); 98 | expect(phenomenon.instances.size).toBe(0); 99 | done(); 100 | }); 101 | }); 102 | 103 | describe('destroy()', () => { 104 | it('should remove all instances', () => { 105 | phenomenon.add('instance'); 106 | phenomenon.destroy(); 107 | expect(phenomenon.instances.size).toBe(0); 108 | expect(phenomenon.shouldRender).toBe(false); 109 | }); 110 | 111 | it('should stop requesting animation frames', () => { 112 | phenomenon.destroy(); 113 | expect(phenomenon.shouldRender).toBe(false); 114 | }); 115 | }); 116 | }); 117 | 118 | describe('instance', () => { 119 | const vertex = ` 120 | void main(){ 121 | gl_Position = vec4(0.0, 0.0, 0.0, 0.0); 122 | } 123 | `; 124 | const fragment = ` 125 | void main(){ 126 | gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0); 127 | } 128 | `; 129 | 130 | describe('constructor()', () => { 131 | it('should override default values based on parameters', () => { 132 | const settings = { multiplier: 1000 }; 133 | const instance = phenomenon.add('instance', settings); 134 | expect(instance.multiplier).toBe(1000); 135 | }); 136 | }); 137 | 138 | describe('compileShader()', () => { 139 | it('should compile vertex and fragment shaders', () => { 140 | const instance = phenomenon.add('instance'); 141 | const vertexShader = instance.compileShader(35633, vertex); 142 | const fragmentShader = instance.compileShader(35632, fragment); 143 | expect(instance.gl.isShader(vertexShader)).toBe(true); 144 | expect(instance.gl.isShader(fragmentShader)).toBe(true); 145 | }); 146 | }); 147 | 148 | describe('prepareProgram()', () => { 149 | it('should create a valid shader program', () => { 150 | const instance = phenomenon.add('instance', { vertex, fragment }); 151 | const { gl, program } = instance; 152 | expect(gl.getProgramParameter(program, gl.LINK_STATUS)).toBe(true); 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Phenomenon 3 | 4 | [![npm version](https://img.shields.io/npm/v/phenomenon.svg)](https://www.npmjs.com/package/phenomenon) 5 | [![travis build](https://img.shields.io/travis/vaneenige/phenomenon.svg)](https://travis-ci.org/vaneenige/phenomenon) 6 | [![gzip size](http://img.badgesize.io/https://unpkg.com/phenomenon/dist/phenomenon.umd.js?compression=gzip)](https://unpkg.com/phenomenon) 7 | [![license](https://img.shields.io/npm/l/phenomenon.svg)](https://github.com/vaneenige/phenomenon/blob/master/LICENSE) 8 | [![dependencies](https://img.shields.io/badge/dependencies-none-ff69b4.svg)](https://github.com/vaneenige/phenomenon/blob/master/package.json) 9 | [![TypeScript](https://img.shields.io/static/v1.svg?label=&message=TypeScript&color=294E80)](https://www.typescriptlang.org/) 10 | 11 | Phenomenon is a very small, low-level WebGL library that provides the essentials to deliver a high performance experience. Its core functionality is built around the idea of moving *millions of particles* around using the power of the GPU. 12 | 13 | #### Features: 14 | 15 | - Small in size, no dependencies 16 | - GPU based for high performance 17 | - Low-level & highly configurable 18 | - Helper functions with options 19 | - Add & destroy instances dynamically 20 | - Dynamic attribute switching 21 | 22 | *Want to see some magic right away? Have a look here!* 23 | 24 | ## Install 25 | 26 | ``` 27 | $ npm install --save phenomenon 28 | ``` 29 | 30 | ## Usage 31 | 32 | ```js 33 | // Import the library 34 | import Phenomenon from 'phenomenon'; 35 | 36 | // Create a renderer 37 | const phenomenon = new Phenomenon(options); 38 | 39 | // Add an instance 40 | phenomenon.add("particles", options); 41 | ``` 42 | 43 | > For a better understanding of how to use the library, read along or have a look at the demo! 44 | 45 | ## API 46 | 47 | ### Phenomenon(options) 48 | 49 | Returns an instance of Phenomenon. 50 | 51 | > Throughout this documentation we'll refer to an instance of this as `renderer`. 52 | 53 | #### options.canvas 54 | Type: `HTMLElement`
55 | Default: `document.querySelector('canvas')`
56 | 57 | The element where the scene, with all of its instances, will be rendered to. The provided element has to be `` otherwise it won't work. 58 | 59 | #### options.context 60 | Type: `Object`
61 | Default: `{}`
62 | 63 | Overrides that are used when getting the WebGL context from the canvas. The library overrides two settings by default. 64 | 65 | | Name | Default | Description | 66 | | --------- | --------| --------------------------------------------------------------------------------------------------------------------------------------- | 67 | | Alpha | `false` | Setting this property to `true` will result in the canvas having a transparent background. By default clearColor is used instead. | 68 | | Antialias | `false` | Setting this property to `true` will make the edges sharper, but could negatively impact performance. See for yourself if it's worth it! | 69 | 70 | > Read more about all the possible overrides on MDN. 71 | 72 | #### options.contextType 73 | Type: `String`
74 | Default: `webgl` 75 | 76 | The context identifier defining the drawing context associated to the canvas. For WebGL 2.0 use `webgl2`. 77 | 78 | #### options.settings 79 | Type: `Object`
80 | Default: `{}`
81 | 82 | Overrides that can be used to alter the behaviour of the experience. 83 | 84 | | Name | Value | Default | Description | 85 | | ---------------------------------------- | ---------- | --------------- | --------------------------------------------------------------------------- | 86 | | devicePixelRatio | `number` | `1` | The resolution multiplier by which the scene is rendered relative to the canvas' resolution. Use `window.devicePixelRatio` for the highest possible quality, `1` for the best performance. | 87 | | clearColor | `array` | `[1,1,1,1]` | The color in `rgba` that is used as the background of the scene. | 88 | | clip | `array` | `[0.001, 100]` | The near and far clip plane in 3D space. | 89 | | position | `number` | `{x:0,y:0,z:2}` | The distance in 3D space between the center of the scene and the camera. | 90 | | shouldRender | `boolean` | `true` | A boolean indicating whether the scene should start rendering automatically. | 91 | | uniforms | `object` | `{}` | Shared values between all instances that can be updated at any given moment. By default this feature is used to render all the instances with the same `uProjectionMatrix`, `uModelMatrix` and `uViewMatrix`. It's also useful for moving everything around with the same progress value; `uProgress`. | 92 | | onSetup(gl) | `function` | `undefined` | A setup hook that is called before first render which can be used for gl context changes. | 93 | | onRender(renderer) | `function` | `undefined` | A render hook that is invoked after every rendered frame. Use this to update `renderer.uniforms`. | 94 | | debug | `boolean` | `false` | Whether or not the console should log shader compilation warnings. | 95 | 96 | 97 | ### .resize() 98 | 99 | Update all values that are based on the dimensions of the canvas to make it look good on all screen sizes. 100 | 101 | ### .toggle(shouldRender) 102 | 103 | Toggle the rendering state of the scene. When shouldRender is false `requestAnimationFrame` is disabled so no resources are used. 104 | 105 | #### shouldRender 106 | Type: `Boolean`
107 | Default: `undefined`
108 | 109 | An optional boolean to set the rendering state to a specific value. Leaving this value empty will result in a regular boolean switch. 110 | 111 | ### .add(key, settings) 112 | 113 | This function is used to add instances to the renderer. These instances can be as *simple* or *complex* as you'd like them to be. There's no limit to how many of these you can add. Make sure they all have a different key! 114 | 115 | #### key 116 | Type: `String`
117 | Default: `undefined`
118 | 119 | Every instance should have a unique name. This name can also be used to destroy the instance specifically later. 120 | 121 | #### settings 122 | Type: `Object`
123 | Default: `{}`
124 | 125 | An object containing overrides for parameters that are used when getting the WebGL context from the canvas. 126 | 127 | | Name | Value | Default | Description | 128 | | ----------- | ---------- | ------------ | --------------------------------------------------------------------------- | 129 | | attributes | `array` | `[]` | Values used in the program that are stored once, directly on the GPU. | 130 | | uniforms | `object` | `{}` | Values used in the program that can be updated on the fly. | 131 | | vertex | `string` | - | The vertex shader is used to position the geometry in 3D space. | 132 | | fragment | `string` | - | The fragment shader is used to provide the geometry with color or texture. | 133 | | multiplier | `number` | `1` | The amount of duplicates that will be created for the same instance. | 134 | | mode | `number` | `0` | The way the instance will be rendered. Particles = 0, triangles = 4. | 135 | | geometry | `object` | `{}` | Vertices (and optional normals) of a model. | 136 | | modifiers | `object` | `{}` | Modifiers to alter the attributes data on initialize. | 137 | | onRender | `function` | `undefined` | A render hook that is invoked after every rendered frame. | 138 | 139 | > Note: Less instances with a higher multiplier will be faster than more instances with a lower multiplier! 140 | 141 | ### .remove(key) 142 | 143 | Remove an instance from the scene (and from memory) by its key. 144 | 145 | ### .destroy() 146 | 147 | Remove all instances and the renderer itself. The canvas element will remain in the DOM. 148 | 149 | ### .prepareAttribute(attribute) 150 | 151 | Dynamically override an attribute with the same logic that is used during initial creation of the instance. The function requires an object with a name, size and data attribute. 152 | 153 | > Note: The calculation of the data function is done on the CPU. Be sure to check for dropped frames with a lot of particles. 154 | 155 | Attributes can also be switched. In the demo this is used to continue with a new start position identical to the end position. This can be achieved with `.prepareBuffer(attribute)` in which the data function is replaced with the final array. 156 | 157 | ## Examples 158 | 159 | 1. Particles 160 | 2. Types 161 | 3. Transition 162 | 4. Easing 163 | 5. Shapes 164 | 6. Instances 165 | 7. Movement 166 | 8. Particle cube 167 | 9. Dynamic intances 168 | 169 | ## Contribute 170 | 171 | Are you excited about this library and have interesting ideas on how to improve it? Please tell me or contribute directly! 🙌 172 | 173 | ``` 174 | npm install > npm start > http://localhost:8080 175 | ``` 176 | 177 | ## License 178 | 179 | MIT © Colin van Eenige 180 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | interface AttributeProps { 2 | name: string; 3 | size: number; 4 | data?: any; 5 | } 6 | 7 | interface UniformProps { 8 | type: string; 9 | value: Array; 10 | location?: WebGLUniformLocation; 11 | } 12 | 13 | interface GeometryProps { 14 | vertices?: Array>; 15 | normal?: Array>; 16 | } 17 | 18 | interface BufferProps { 19 | location: number; 20 | buffer: WebGLBuffer; 21 | size: number; 22 | } 23 | 24 | interface InstanceProps { 25 | attributes?: Array; 26 | vertex?: string; 27 | fragment?: string; 28 | geometry?: GeometryProps; 29 | mode?: number; 30 | modifiers?: object; 31 | multiplier?: number; 32 | uniforms: { 33 | [key: string]: UniformProps; 34 | }; 35 | } 36 | 37 | interface RendererProps { 38 | canvas?: HTMLCanvasElement; 39 | context?: object; 40 | contextType?: string; 41 | settings?: object; 42 | debug?: boolean; 43 | } 44 | 45 | const positionMap = ['x', 'y', 'z']; 46 | 47 | /** 48 | * Class representing an instance. 49 | */ 50 | class Instance { 51 | public gl: WebGLRenderingContext; 52 | public vertex: string; 53 | public fragment: string; 54 | public program: WebGLProgram; 55 | public uniforms: { 56 | [key: string]: UniformProps; 57 | }; 58 | public geometry: GeometryProps; 59 | public attributes: Array; 60 | public attributeKeys: Array; 61 | public multiplier: number; 62 | public modifiers: Array; 63 | public buffers: Array; 64 | public uniformMap: object; 65 | public mode: number; 66 | public onRender?: Function; 67 | 68 | /** 69 | * Create an instance. 70 | */ 71 | constructor(props: InstanceProps) { 72 | // Assign default parameters 73 | Object.assign(this, { 74 | uniforms: {}, 75 | geometry: { vertices: [{ x: 0, y: 0, z: 0 }] }, 76 | mode: 0, 77 | modifiers: {}, 78 | attributes: [], 79 | multiplier: 1, 80 | buffers: [], 81 | }); 82 | 83 | // Assign optional parameters 84 | Object.assign(this, props); 85 | 86 | // Prepare all required pieces 87 | this.prepareProgram(); 88 | this.prepareUniforms(); 89 | this.prepareAttributes(); 90 | } 91 | 92 | /** 93 | * Compile a shader. 94 | */ 95 | compileShader(type: number, source: string) { 96 | const shader = this.gl.createShader(type); 97 | this.gl.shaderSource(shader, source); 98 | this.gl.compileShader(shader); 99 | return shader; 100 | } 101 | 102 | /** 103 | * Create a program. 104 | */ 105 | prepareProgram() { 106 | const { gl, vertex, fragment } = this; 107 | 108 | // Create a new shader program 109 | const program = gl.createProgram(); 110 | 111 | // Attach the vertex shader 112 | gl.attachShader(program, this.compileShader(35633, vertex)); 113 | 114 | // Attach the fragment shader 115 | gl.attachShader(program, this.compileShader(35632, fragment)); 116 | 117 | // Link the program 118 | gl.linkProgram(program); 119 | 120 | // Use the program 121 | gl.useProgram(program); 122 | 123 | // Assign it to the instance 124 | this.program = program; 125 | } 126 | 127 | /** 128 | * Create uniforms. 129 | */ 130 | prepareUniforms() { 131 | const keys = Object.keys(this.uniforms); 132 | for (let i = 0; i < keys.length; i += 1) { 133 | const location = this.gl.getUniformLocation(this.program, keys[i]); 134 | this.uniforms[keys[i]].location = location; 135 | } 136 | } 137 | 138 | /** 139 | * Create buffer attributes. 140 | */ 141 | prepareAttributes() { 142 | if (typeof this.geometry.vertices !== 'undefined') { 143 | this.attributes.push({ 144 | name: 'aPosition', 145 | size: 3, 146 | }); 147 | } 148 | if (typeof this.geometry.normal !== 'undefined') { 149 | this.attributes.push({ 150 | name: 'aNormal', 151 | size: 3, 152 | }); 153 | } 154 | this.attributeKeys = []; 155 | // Convert all attributes to be useable in the shader 156 | for (let i = 0; i < this.attributes.length; i += 1) { 157 | this.attributeKeys.push(this.attributes[i].name); 158 | this.prepareAttribute(this.attributes[i]); 159 | } 160 | } 161 | 162 | /** 163 | * Prepare a single attribute. 164 | */ 165 | prepareAttribute(attribute: AttributeProps) { 166 | const { geometry, multiplier } = this; 167 | const { vertices, normal } = geometry; 168 | // Create an array for the attribute to store data 169 | const attributeBufferData = new Float32Array(multiplier * vertices.length * attribute.size); 170 | // Repeat the process for the provided multiplier 171 | for (let j = 0; j < multiplier; j += 1) { 172 | // Set data used as default or the attribute modifier 173 | const data = attribute.data && attribute.data(j, multiplier); 174 | // Calculate the offset for the right place in the array 175 | let offset = j * vertices.length * attribute.size; 176 | // Loop over vertices length 177 | for (let k = 0; k < vertices.length; k += 1) { 178 | // Loop over attribute size 179 | for (let l = 0; l < attribute.size; l += 1) { 180 | // Check if a modifier is provided 181 | const modifier = this.modifiers[attribute.name]; 182 | if (typeof modifier !== 'undefined') { 183 | // Handle attribute modifier 184 | attributeBufferData[offset] = modifier(data, k, l, this); 185 | } else if (attribute.name === 'aPosition') { 186 | // Handle position values 187 | attributeBufferData[offset] = vertices[k][positionMap[l]]; 188 | } else if (attribute.name === 'aNormal') { 189 | // Handle normal values 190 | attributeBufferData[offset] = normal[k][positionMap[l]]; 191 | } else { 192 | // Handle other attributes 193 | attributeBufferData[offset] = data[l]; 194 | } 195 | offset += 1; 196 | } 197 | } 198 | } 199 | this.attributes[this.attributeKeys.indexOf(attribute.name)].data = attributeBufferData; 200 | this.prepareBuffer(this.attributes[this.attributeKeys.indexOf(attribute.name)]); 201 | } 202 | 203 | /** 204 | * Create a buffer with an attribute. 205 | */ 206 | prepareBuffer(attribute: AttributeProps) { 207 | const { data, name, size } = attribute; 208 | 209 | const buffer = this.gl.createBuffer(); 210 | this.gl.bindBuffer(34962, buffer); 211 | this.gl.bufferData(34962, data, 35044); 212 | 213 | const location = this.gl.getAttribLocation(this.program, name); 214 | this.gl.enableVertexAttribArray(location); 215 | this.gl.vertexAttribPointer(location, size, 5126, false, 0, 0); 216 | 217 | this.buffers[this.attributeKeys.indexOf(attribute.name)] = { buffer, location, size }; 218 | } 219 | 220 | /** 221 | * Render the instance. 222 | */ 223 | render(renderUniforms: object) { 224 | const { uniforms, multiplier, gl } = this; 225 | 226 | // Use the program of the instance 227 | gl.useProgram(this.program); 228 | 229 | // Bind the buffers for the instance 230 | for (let i = 0; i < this.buffers.length; i += 1) { 231 | const { location, buffer, size } = this.buffers[i]; 232 | gl.enableVertexAttribArray(location); 233 | gl.bindBuffer(34962, buffer); 234 | gl.vertexAttribPointer(location, size, 5126, false, 0, 0); 235 | } 236 | 237 | // Update the shared uniforms from the renderer 238 | Object.keys(renderUniforms).forEach(key => { 239 | uniforms[key].value = renderUniforms[key].value; 240 | }); 241 | 242 | // Map the uniforms to the context 243 | Object.keys(uniforms).forEach(key => { 244 | const { type, location, value } = uniforms[key]; 245 | this.uniformMap[type](location, value); 246 | }); 247 | 248 | // Draw the magic to the screen 249 | gl.drawArrays(this.mode, 0, multiplier * this.geometry.vertices.length); 250 | 251 | // Hook for uniform updates 252 | if (this.onRender) this.onRender(this); 253 | } 254 | 255 | /** 256 | * Destroy the instance. 257 | */ 258 | destroy() { 259 | for (let i = 0; i < this.buffers.length; i += 1) { 260 | this.gl.deleteBuffer(this.buffers[i].buffer); 261 | } 262 | this.gl.deleteProgram(this.program); 263 | this.gl = null; 264 | } 265 | } 266 | 267 | /** 268 | * Class representing a Renderer. 269 | */ 270 | class Renderer { 271 | public clearColor: Array; 272 | public onRender: Function; 273 | public onSetup: Function; 274 | public uniformMap: object; 275 | public gl: WebGLRenderingContext; 276 | public canvas: HTMLCanvasElement; 277 | public devicePixelRatio: number; 278 | public clip: Array; 279 | public instances: Map; 280 | public position: { 281 | x: number; 282 | y: number; 283 | z: number; 284 | }; 285 | public uniforms: { 286 | [key: string]: UniformProps; 287 | }; 288 | public shouldRender: boolean; 289 | public debug: boolean; 290 | 291 | /** 292 | * Create a renderer. 293 | */ 294 | constructor(props: RendererProps) { 295 | const { 296 | canvas = document.querySelector('canvas'), 297 | context = {}, 298 | contextType = 'experimental-webgl', 299 | settings = {}, 300 | debug = false, 301 | } = props || {}; 302 | 303 | // Get context with optional parameters 304 | const gl = canvas.getContext( 305 | contextType, 306 | Object.assign( 307 | { 308 | alpha: false, 309 | antialias: false, 310 | }, 311 | context 312 | ) 313 | ); 314 | 315 | // Assign program parameters 316 | Object.assign(this, { 317 | gl, 318 | canvas, 319 | uniforms: {}, 320 | instances: new Map(), 321 | shouldRender: true, 322 | }); 323 | 324 | // Assign default parameters 325 | Object.assign(this, { 326 | devicePixelRatio: 1, 327 | clearColor: [1, 1, 1, 1], 328 | position: { x: 0, y: 0, z: 2 }, 329 | clip: [0.001, 100], 330 | debug, 331 | }); 332 | 333 | // Assign optional parameters 334 | Object.assign(this, settings); 335 | 336 | // Create uniform mapping object 337 | this.uniformMap = { 338 | float: (loc, val) => gl.uniform1f(loc, val), 339 | vec2: (loc, val) => gl.uniform2fv(loc, val), 340 | vec3: (loc, val) => gl.uniform3fv(loc, val), 341 | vec4: (loc, val) => gl.uniform4fv(loc, val), 342 | mat2: (loc, val) => gl.uniformMatrix2fv(loc, false, val), 343 | mat3: (loc, val) => gl.uniformMatrix3fv(loc, false, val), 344 | mat4: (loc, val) => gl.uniformMatrix4fv(loc, false, val), 345 | }; 346 | 347 | // Enable depth 348 | gl.enable(gl.DEPTH_TEST); 349 | gl.depthFunc(gl.LEQUAL); 350 | 351 | // Set clear values 352 | if (gl.getContextAttributes().alpha === false) { 353 | // @ts-ignore 354 | gl.clearColor(...this.clearColor); 355 | gl.clearDepth(1.0); 356 | } 357 | 358 | // Hook for gl context changes before first render 359 | if (this.onSetup) this.onSetup(gl); 360 | 361 | // Handle resize events 362 | window.addEventListener('resize', () => this.resize()); 363 | 364 | // Start the renderer 365 | this.resize(); 366 | this.render(); 367 | } 368 | 369 | /** 370 | * Handle resize events. 371 | */ 372 | resize() { 373 | const { gl, canvas, devicePixelRatio, position } = this; 374 | 375 | canvas.width = canvas.clientWidth * devicePixelRatio; 376 | canvas.height = canvas.clientHeight * devicePixelRatio; 377 | 378 | const bufferWidth = gl.drawingBufferWidth; 379 | const bufferHeight = gl.drawingBufferHeight; 380 | const ratio = bufferWidth / bufferHeight; 381 | 382 | gl.viewport(0, 0, bufferWidth, bufferHeight); 383 | 384 | const angle = Math.tan(45 * 0.5 * (Math.PI / 180)); 385 | 386 | // prettier-ignore 387 | const projectionMatrix = [ 388 | 0.5 / angle, 0, 0, 0, 389 | 0, 0.5 * (ratio / angle), 0, 0, 390 | 0, 0, -(this.clip[1] + this.clip[0]) / (this.clip[1] - this.clip[0]), -1, 0, 0, 391 | -2 * this.clip[1] * (this.clip[0] / (this.clip[1] - this.clip[0])), 0, 392 | ]; 393 | 394 | // prettier-ignore 395 | const viewMatrix = [ 396 | 1, 0, 0, 0, 397 | 0, 1, 0, 0, 398 | 0, 0, 1, 0, 399 | 0, 0, 0, 1, 400 | ]; 401 | 402 | // prettier-ignore 403 | const modelMatrix = [ 404 | 1, 0, 0, 0, 405 | 0, 1, 0, 0, 406 | 0, 0, 1, 0, 407 | position.x, position.y, (ratio < 1 ? 1 : ratio) * -position.z, 1, 408 | ]; 409 | 410 | this.uniforms.uProjectionMatrix = { 411 | type: 'mat4', 412 | value: projectionMatrix, 413 | }; 414 | 415 | this.uniforms.uViewMatrix = { 416 | type: 'mat4', 417 | value: viewMatrix, 418 | }; 419 | 420 | this.uniforms.uModelMatrix = { 421 | type: 'mat4', 422 | value: modelMatrix, 423 | }; 424 | } 425 | 426 | /** 427 | * Toggle the active state of the renderer. 428 | */ 429 | toggle(shouldRender: boolean) { 430 | if (shouldRender === this.shouldRender) return; 431 | this.shouldRender = typeof shouldRender !== 'undefined' ? shouldRender : !this.shouldRender; 432 | if (this.shouldRender) this.render(); 433 | } 434 | 435 | /** 436 | * Render the total scene. 437 | */ 438 | render() { 439 | this.gl.clear(16640); 440 | 441 | this.instances.forEach(instance => { 442 | instance.render(this.uniforms); 443 | }); 444 | 445 | if (this.onRender) this.onRender(this); 446 | 447 | if (this.shouldRender) requestAnimationFrame(() => this.render()); 448 | } 449 | 450 | /** 451 | * Add an instance to the renderer. 452 | */ 453 | add(key: string, settings: InstanceProps) { 454 | if (typeof settings === 'undefined') { 455 | settings = { uniforms: {} }; 456 | } 457 | 458 | if (typeof settings.uniforms === 'undefined') { 459 | settings.uniforms = {}; 460 | } 461 | 462 | Object.assign(settings.uniforms, JSON.parse(JSON.stringify(this.uniforms))); 463 | 464 | Object.assign(settings, { 465 | gl: this.gl, 466 | uniformMap: this.uniformMap, 467 | }); 468 | 469 | const instance = new Instance(settings); 470 | this.instances.set(key, instance); 471 | 472 | if (this.debug) { 473 | // debug vertex shader 474 | const vertexDebug = this.gl.createShader(this.gl.VERTEX_SHADER); 475 | this.gl.shaderSource(vertexDebug, settings.vertex); 476 | this.gl.compileShader(vertexDebug); 477 | if (!this.gl.getShaderParameter(vertexDebug, this.gl.COMPILE_STATUS)) { 478 | console.error(this.gl.getShaderInfoLog(vertexDebug)); 479 | } 480 | 481 | // debug fragment shader 482 | const fragmentDebug = this.gl.createShader(this.gl.FRAGMENT_SHADER); 483 | this.gl.shaderSource(fragmentDebug, settings.fragment); 484 | this.gl.compileShader(fragmentDebug); 485 | if (!this.gl.getShaderParameter(fragmentDebug, this.gl.COMPILE_STATUS)) { 486 | console.error(this.gl.getShaderInfoLog(fragmentDebug)); 487 | } 488 | } 489 | 490 | return instance; 491 | } 492 | 493 | /** 494 | * Remove an instance from the renderer. 495 | */ 496 | remove(key: string) { 497 | const instance = this.instances.get(key); 498 | if (typeof instance === 'undefined') return; 499 | instance.destroy(); 500 | this.instances.delete(key); 501 | } 502 | 503 | /** 504 | * Destroy the renderer and its instances. 505 | */ 506 | destroy() { 507 | this.instances.forEach((instance, key) => { 508 | instance.destroy(); 509 | this.instances.delete(key); 510 | }); 511 | this.toggle(false); 512 | } 513 | } 514 | 515 | export default Renderer; 516 | --------------------------------------------------------------------------------