├── .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 | [](https://www.npmjs.com/package/phenomenon)
5 | [](https://travis-ci.org/vaneenige/phenomenon)
6 | [](https://unpkg.com/phenomenon)
7 | [](https://github.com/vaneenige/phenomenon/blob/master/LICENSE)
8 | [](https://github.com/vaneenige/phenomenon/blob/master/package.json)
9 | [](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 `