├── .editorconfig ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── demo ├── index.html ├── src │ ├── base.js │ ├── index.js │ ├── instance.js │ └── utils.js └── style.css ├── package.json └── src └── index.ts /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | package-lock.json 5 | .rts* 6 | three.min.js 7 | uot.umd.js 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # THREE.Phenomenon 3 | 4 | [![npm version](https://img.shields.io/npm/v/three.phenomenon.svg)](https://www.npmjs.com/package/three.phenomenon) 5 | [![gzip size](http://img.badgesize.io/https://unpkg.com/three.phenomenon/dist/three.phenomenon.mjs?compression=gzip)](https://unpkg.com/three.phenomenon) 6 | [![license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/vaneenige/three.phenomenon/blob/master/LICENSE) 7 | [![dependencies](https://img.shields.io/badge/dependencies-three.js-ff69b4.svg)](https://github.com/mrdoob/three.js/) 8 | [![TypeScript](https://img.shields.io/static/v1.svg?label=&message=TypeScript&color=294E80)](https://www.typescriptlang.org/) 9 | 10 | THREE.Phenomenon is a tiny wrapper around three.js built for high-performance WebGL experiences. 11 | 12 | With it's simple API a mesh can be created that contains multiple instances of a geometry combined with a material. With access to the vertex shader, attributes per instance and uniforms this mesh can be transformed in any way possible (and on the GPU). 13 | 14 | #### Features: 15 | - Below 1kb in size (gzip) 16 | - Custom instanced geometries 17 | - Attributes for every instance 18 | - Support for default materials 19 | - Compatible with three.js r104 20 | 21 | ## Install 22 | ``` 23 | $ npm install --save three.phenomenon 24 | ``` 25 | 26 | ## Usage 27 | ```js 28 | // Import the library 29 | import Phenomenon from 'three.phenomenon'; 30 | 31 | // Create an instance 32 | Phenomenon({ ... }); 33 | ``` 34 | 35 | > The wrapper is also available through THREE.Phenomenon. 36 | 37 | ## API 38 | ### Phenomenon(options) 39 | 40 | Returns an instance of Phenomenon. 41 | 42 | > The instance provides access to the mesh (with the compiled vertex and fragment shader) and uniforms. 43 | 44 | #### options.attributes 45 | Type: `Array`
46 | 47 | Values used in the program that are stored once, directly on the GPU. Every item in this array needs to have a: 48 | - `name` for referencing data in the vertex shader. 49 | - `data` function to create the data for each instance. 50 | - `size` so it's clear what comes back from the data. 51 | 52 | > The data function receives the index of the current instance and the total number of instances so calculations can be done based on these values. 53 | 54 | #### options.uniforms 55 | Type: `Object`
56 | 57 | Variables used in the program that can be adjusted on the fly. These are accessible through the instance variable and can be updated directly. 58 | 59 | #### options.vertex 60 | Type: `String`
61 | 62 | The vertex shader of the program which will calculate the position of every instance. This will automatically get merged with the shaders that's created based on the provided geometry. 63 | 64 | #### options.fragment 65 | Type: `Array`
66 | 67 | The fragment parameter is optional and can be used to modify specific parts of the provided material's fragment shader. For example: Give every instance a unique color or manually use its position for calculations. 68 | 69 | #### options.geometry 70 | Type: `THREE.Geometry`
71 | 72 | The geometry that will be multiplied. See Geometry for more information. 73 | 74 | #### options.material 75 | Type: `THREE.Material`
76 | 77 | The material that is used for the geometry. See Material for more information. 78 | 79 | #### options.multiplier 80 | Type: `Number`
81 | The amount of instances that will be created. 82 | 83 | #### options.castShadow 84 | Type: `Boolean`
85 | Should the mesh cast a shadow? 86 | 87 | ## Contribute 88 | Are you excited about this library and have interesting ideas on how to improve it? Please tell me or contribute directly! 89 | 90 | ``` 91 | npm install > npm start > http://localhost:8080 92 | ``` 93 | 94 | ## License 95 | MIT © Colin van Eenige 96 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | THREE.Phenomenon 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/src/base.js: -------------------------------------------------------------------------------- 1 | function base() { 2 | const renderer = new THREE.WebGLRenderer({ 3 | antialias: true, 4 | }); 5 | 6 | renderer.shadowMap.enabled = true; 7 | renderer.shadowMap.type = THREE.PCFSoftShadowMap; 8 | 9 | renderer.setClearColor(0x212121, 0); 10 | renderer.setSize(window.innerWidth, window.innerHeight); 11 | renderer.setPixelRatio(1); 12 | 13 | document.querySelector('body').appendChild(renderer.domElement); 14 | 15 | const scene = new THREE.Scene(); 16 | 17 | const camera = new THREE.PerspectiveCamera( 18 | 40, 19 | window.innerWidth / window.innerHeight, 20 | 0.1, 21 | 10000 22 | ); 23 | camera.position.set(0, 20 * 1, 35 * 1); 24 | camera.lookAt(scene.position); 25 | scene.add(camera); 26 | 27 | const ambientLight = new THREE.AmbientLight('#ffffff', 0.1); 28 | scene.add(ambientLight); 29 | 30 | const plane = new THREE.Mesh( 31 | new THREE.PlaneGeometry(1000, 1000), 32 | new THREE.MeshPhongMaterial({ 33 | emissive: '#F694C1', 34 | }) 35 | ); 36 | plane.receiveShadow = true; 37 | plane.position.y = -15; 38 | plane.rotation.x = Math.PI * -0.5; 39 | scene.add(plane); 40 | 41 | const light = new THREE.SpotLight(0xffffff, 2, 80, Math.PI * 0.25, 1, 2); 42 | light.position.set(0, 40, 0); 43 | light.castShadow = true; 44 | light.shadow.mapSize.width = 1024; 45 | light.shadow.mapSize.height = 1024; 46 | light.shadow.camera.near = 0.5; 47 | light.shadow.camera.far = 31; 48 | 49 | scene.add(light); 50 | 51 | return { renderer, scene, camera }; 52 | } 53 | 54 | export default base; 55 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import './../../dist/three.phenomenon'; 2 | 3 | import base from './base'; 4 | import instance from './instance'; 5 | 6 | const { renderer, scene, camera } = base(); 7 | const { mesh, uniforms } = instance(); 8 | 9 | scene.add(mesh); 10 | 11 | let progress = 0; 12 | 13 | uot( 14 | p => { 15 | progress = p; 16 | }, 17 | 3000, 18 | Infinity 19 | ); 20 | 21 | function animate() { 22 | requestAnimationFrame(animate); 23 | uniforms.uProgress.value = progress; 24 | renderer.render(scene, camera); 25 | } 26 | 27 | animate(); 28 | -------------------------------------------------------------------------------- /demo/src/instance.js: -------------------------------------------------------------------------------- 1 | import { getArrayWithNoise } from './utils'; 2 | 3 | function createInstance() { 4 | const duration = 0.7; 5 | 6 | const geometry = new THREE.TorusGeometry(2, 0.5, 32, 32); 7 | 8 | const multiplier = 100; 9 | 10 | const material = new THREE.MeshPhongMaterial({ 11 | color: '#ff6e40', 12 | emissive: '#ff6e40', 13 | flatShading: false, 14 | shininess: 100, 15 | }); 16 | 17 | const castShadow = true; 18 | 19 | const attributes = [ 20 | { 21 | name: 'aPositionStart', 22 | data: () => getArrayWithNoise([0, 0, 0], 20), 23 | size: 3, 24 | }, 25 | { 26 | name: 'aControlPointOne', 27 | data: () => getArrayWithNoise([0, 0, 0], 20), 28 | size: 3, 29 | }, 30 | { 31 | name: 'aControlPointTwo', 32 | data: () => getArrayWithNoise([0, 0, 0], 20), 33 | size: 3, 34 | }, 35 | { 36 | name: 'aPositionEnd', 37 | data: () => getArrayWithNoise([0, 0, 0], 20), 38 | size: 3, 39 | }, 40 | { 41 | name: 'aOffset', 42 | data: i => [i * ((1 - duration) / (multiplier - 1))], 43 | size: 1, 44 | }, 45 | { 46 | name: 'aColor', 47 | data: (i, total) => { 48 | const color = new THREE.Color(); 49 | color.setHSL( 50 | // (i % 2 === 0) ? 51 | // ((i / total) * 0.2) + THREE.Math.randFloat(0.6, 0.8) : 52 | i / total, 53 | 0.6, 54 | 0.7 55 | ); 56 | return [color.r, color.g, color.b]; 57 | }, 58 | size: 3, 59 | }, 60 | ]; 61 | 62 | const uniforms = { 63 | uProgress: { 64 | value: 0, 65 | }, 66 | }; 67 | 68 | const vertex = ` 69 | attribute vec3 aPositionStart; 70 | attribute vec3 aControlPointOne; 71 | attribute vec3 aControlPointTwo; 72 | attribute vec3 aPositionEnd; 73 | attribute vec3 aColor; 74 | attribute float aOffset; 75 | uniform float uProgress; 76 | 77 | varying vec3 vColor; 78 | 79 | float easeInOutSin(float t){ 80 | return (1.0 + sin(${Math.PI} * t - ${Math.PI} / 2.0)) / 2.0; 81 | } 82 | 83 | vec4 quatFromAxisAngle(vec3 axis, float angle) { 84 | float halfAngle = angle * 0.5; 85 | return vec4(axis.xyz * sin(halfAngle), cos(halfAngle)); 86 | } 87 | 88 | vec3 rotateVector(vec4 q, vec3 v) { 89 | return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); 90 | } 91 | 92 | vec3 bezier4(vec3 a, vec3 b, vec3 c, vec3 d, float t) { 93 | return mix(mix(mix(a, b, t), mix(b, c, t), t), mix(mix(b, c, t), mix(c, d, t), t), t); 94 | } 95 | 96 | void main(){ 97 | float tProgress = easeInOutSin(min(1.0, max(0.0, (uProgress - aOffset)) / ${duration})); 98 | vec4 quatX = quatFromAxisAngle(vec3(1.0, 0.0, 0.0), -5.0 * tProgress); 99 | vec4 quatY = quatFromAxisAngle(vec3(0.0, 0.0, 0.0), -5.0 * tProgress); 100 | vec3 basePosition = rotateVector(quatX, rotateVector(quatY, position)); 101 | vec3 newPosition = bezier4(aPositionStart, aControlPointOne, aControlPointTwo, aPositionEnd, tProgress); 102 | float scale = tProgress * 2.0 - 1.0; 103 | scale = 1.0 - scale * scale; 104 | basePosition *= scale; 105 | vNormal = rotateVector(quatX, vNormal); 106 | gl_Position = basePosition + newPosition; 107 | vColor = aColor; 108 | } 109 | `; 110 | 111 | const fragment = [ 112 | ['#define PHONG', 'varying vec3 vColor;'], 113 | ['vec4( diffuse, opacity )', 'vec4( vColor, opacity )'], 114 | ['vec3 totalEmissiveRadiance = emissive;', 'vec3 totalEmissiveRadiance = vColor;'], 115 | ]; 116 | 117 | const instance = new THREE.Phenomenon( 118 | geometry, 119 | material, 120 | multiplier, 121 | attributes, 122 | uniforms, 123 | vertex, 124 | castShadow, 125 | fragment 126 | ); 127 | 128 | return instance; 129 | } 130 | 131 | export default createInstance; 132 | -------------------------------------------------------------------------------- /demo/src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get a random value between positive and negative. 3 | * @param {number} value 4 | */ 5 | export function getRandomBetween(value) { 6 | const floor = -value; 7 | return floor + Math.random() * value * 2; 8 | } 9 | 10 | /** 11 | * Get a random value from an array. 12 | * @param {array} array 13 | */ 14 | export function getRandomFromArray(array) { 15 | console.log(array); 16 | return array[Math.floor(Math.random() * array.length)]; 17 | } 18 | 19 | /** 20 | * Get an array with noise added to values. 21 | * @param {array} array 22 | * @param {number} noise 23 | */ 24 | export function getArrayWithNoise(array, noise) { 25 | return array.map(item => item + getRandomBetween(noise)); 26 | } 27 | -------------------------------------------------------------------------------- /demo/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | canvas { 4 | margin: 0; 5 | width: 100%; 6 | height: 100%; 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | overflow: hidden; 11 | pointer-events: none; 12 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three.phenomenon", 3 | "version": "1.2.0", 4 | "description": "A tiny wrapper around three.js built for high-performance WebGL experiences.", 5 | "source": "src/index.ts", 6 | "main": "dist/three.phenomenon.mjs", 7 | "unpkg": "dist/three.phenomenon.umd.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "start": "npm run copy && http-server demo --silent & $npm_execpath run watch", 11 | "copy": "npm run copy:three && npm run copy:uot", 12 | "copy:three": "cp node_modules/three/build/three.min.js demo/three.min.js", 13 | "copy:uot": "cp node_modules/uot/dist/uot.umd.js demo/uot.umd.js", 14 | "watch": "microbundle watch --format umd --entry demo/src/index.js --output demo/dist/bundle.js", 15 | "build": "rm -rf dist && microbundle --name Phenomenon --format es,umd --sourcemap false", 16 | "prepare": "$npm_execpath run build" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/vaneenige/three.phenomenon.git" 21 | }, 22 | "author": { 23 | "name": "Colin van Eenige", 24 | "email": "cvaneenige@gmail.com", 25 | "url": "https://use-the-platform.com" 26 | }, 27 | "files": [ 28 | "src", 29 | "dist" 30 | ], 31 | "keywords": [ 32 | "webgl", 33 | "instances", 34 | "particles" 35 | ], 36 | "prettier": { 37 | "printWidth": 100, 38 | "singleQuote": true, 39 | "trailingComma": "es5" 40 | }, 41 | "devDependencies": { 42 | "http-server": "^0.11.1", 43 | "microbundle": "^0.11.0" 44 | }, 45 | "dependencies": { 46 | "three": "^0.104.0", 47 | "uot": "^1.3.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as three from 'three'; 2 | 3 | declare global { 4 | var THREE: typeof three; 5 | } 6 | 7 | interface Attribute { 8 | name: string; 9 | data: (i: number, total: number) => void; 10 | size: number; 11 | } 12 | 13 | class Geometry extends THREE.BufferGeometry { 14 | constructor(geometry: three.Geometry, multiplier: number, attributes: Array) { 15 | super(); 16 | 17 | // Assign settings to variables 18 | const { faces, vertices } = geometry; 19 | 20 | const vertexCount = vertices.length; 21 | const indexes = faces.length * 3; 22 | 23 | // Create array to put face coordinates in 24 | const bufferIndexes = []; 25 | for (let i = 0; i < faces.length; i += 1) { 26 | bufferIndexes.push(faces[i].a, faces[i].b, faces[i].c); 27 | } 28 | 29 | // Create array with length of multiplier times indexes 30 | const indexBuffer = new Uint32Array(multiplier * indexes); 31 | 32 | // Loop over the multiplier 33 | for (let i = 0; i < multiplier; i += 1) { 34 | // Loop over the indexes of the baseGeometry 35 | for (let j = 0; j < indexes; j += 1) { 36 | // Repeat over the indexes and add them to the buffer 37 | indexBuffer[i * indexes + j] = bufferIndexes[j] + i * vertexCount; 38 | } 39 | } 40 | 41 | // Set the index with the data 42 | this.setIndex(new THREE.BufferAttribute(indexBuffer, 1)); 43 | 44 | // Create a new attribute to store data in 45 | const attributeData = new Float32Array(multiplier * vertexCount * 3); 46 | 47 | // Value to hold position used in the loop for the array positions 48 | let offset = 0; 49 | // Loop over the multiplier 50 | for (let i = 0; i < multiplier; i += 1) { 51 | // Loop over the vertexCount of the baseGeometry 52 | for (let j = 0; j < vertexCount; j += 1, offset += 3) { 53 | // Repeat over the vertices and add them to the buffer 54 | const vertex = vertices[j]; 55 | attributeData[offset] = vertex.x; 56 | attributeData[offset + 1] = vertex.y; 57 | attributeData[offset + 2] = vertex.z; 58 | } 59 | } 60 | 61 | const attribute = new THREE.BufferAttribute(attributeData, 3); 62 | this.addAttribute('position', attribute); 63 | 64 | // Loop over the attributes 65 | for (let i = 0; i < attributes.length; i += 1) { 66 | // Create array with length of multiplier times vertexCount times attribute size 67 | const bufferArray = new Float32Array(multiplier * vertexCount * attributes[i].size); 68 | // Create a buffer attribute where the data will be stored in 69 | const bufferAttribute = new THREE.BufferAttribute(bufferArray, attributes[i].size); 70 | // Add the attribute by it's name 71 | this.addAttribute(attributes[i].name, bufferAttribute); 72 | // Loop over the multiplier 73 | for (let j = 0; j < multiplier; j += 1) { 74 | // Get data from the attribute function for every instance 75 | const data = attributes[i].data(j, multiplier); 76 | // Calculate offset based on vertexCount and attribute size 77 | offset = j * vertexCount * bufferAttribute.itemSize; 78 | // Loop over the vertexCount of the instance 79 | for (let k = 0; k < vertexCount; k += 1) { 80 | // Loop over the item size of the attribute 81 | for (let l = 0; l < bufferAttribute.itemSize; l += 1) { 82 | // Assign the buffer data to the right position 83 | bufferArray[offset] = data[l]; 84 | offset += 1; 85 | } 86 | } 87 | } 88 | } 89 | 90 | return this; 91 | } 92 | } 93 | 94 | class Phenomenon { 95 | constructor( 96 | geometry: three.Geometry, 97 | material: three.Material, 98 | multiplier: number, 99 | attributes: Array, 100 | uniforms: object, 101 | vertex: string, 102 | castShadow?: boolean, 103 | fragment?: Array> 104 | ) { 105 | // Create the custom geometry 106 | const customGeometry = new Geometry(geometry, multiplier, attributes); 107 | 108 | // Create a combined mesh 109 | const mesh = new THREE.Mesh(customGeometry, material); 110 | 111 | // Compute vertex normals 112 | mesh.geometry.computeVertexNormals(); 113 | 114 | // Set callback to modify our shaders 115 | material.onBeforeCompile = shader => { 116 | // @ts-ignore - Reference shader for debugging 117 | mesh.shader = shader; 118 | 119 | // Combine the uniforms 120 | Object.assign(shader.uniforms, uniforms); 121 | 122 | // Trim the provided vertex shader 123 | const vertexShader = vertex.replace(/(\r\n|\n|\r)/gm, ''); 124 | 125 | // Get shader attributes 126 | const attributes = vertexShader.match(/.+?(?=void)/)[0]; 127 | 128 | // Get shader main function 129 | const main = vertexShader.match(/main\(\){(.*?)}/)[1]; 130 | 131 | // Construct the final vertex shader 132 | shader.vertexShader = `${attributes} \n ${shader.vertexShader}`; 133 | shader.vertexShader = shader.vertexShader.replace( 134 | '#include ', 135 | main.replace('gl_Position =', 'vec3 transformed =') 136 | ); 137 | 138 | for (let i = 0; i < fragment.length; i += 1) { 139 | shader.fragmentShader = shader.fragmentShader.replace(fragment[i][0], fragment[i][1]); 140 | } 141 | 142 | // @ts-ignore - Hack to randomize function 143 | material.onBeforeCompile = `${material.onBeforeCompile 144 | .toString() 145 | .slice(0, -1)}/* ${Math.random()} */}`; 146 | 147 | if (castShadow) { 148 | // Create custom material for shadows 149 | const customMaterial = new THREE.ShaderMaterial({ 150 | vertexShader: shader.vertexShader, 151 | fragmentShader: THREE.ShaderLib.shadow.fragmentShader, 152 | uniforms, 153 | }); 154 | // Turn on shadows 155 | mesh.castShadow = true; 156 | // @ts-ignore - Set custom depth material 157 | mesh.customDepthMaterial = customMaterial; 158 | // @ts-ignore - Set custom distance material 159 | mesh.customDistanceMaterial = customMaterial; 160 | } 161 | }; 162 | 163 | return { mesh, uniforms }; 164 | } 165 | } 166 | 167 | // @ts-ignore - Make it available through THREE 168 | THREE.Phenomenon = Phenomenon; 169 | 170 | export default Phenomenon; 171 | --------------------------------------------------------------------------------