├── .eslintignore ├── .gitignore ├── .eslintrc ├── .prettierignore ├── screenshot.png ├── examples ├── images │ ├── uv.jpg │ ├── example-basic.png │ ├── charles-unsplash.jpg │ ├── example-3d-model.png │ ├── example-instancing.png │ ├── example-same-camera.png │ └── source.svg ├── lib │ ├── three-utils.js │ ├── loadTexture.js │ ├── loadEnvMap.js │ ├── AssetManager.js │ └── WebGLApp.js ├── basic.html ├── 3d-model.html ├── instancing.html ├── same-camera.html ├── basic.js ├── 3d-model.js ├── same-camera.js ├── instancing.js └── index.html ├── .babelrc ├── .prettierrc ├── .editorconfig ├── src ├── three-utils.js └── ProjectedMaterial.js ├── rollup.config.js ├── package.json ├── webpack.config.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-accurapp" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | .* 3 | node_modules/ 4 | build/ -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/screenshot.png -------------------------------------------------------------------------------- /examples/images/uv.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/uv.jpg -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["accurapp"], 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /examples/images/example-basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/example-basic.png -------------------------------------------------------------------------------- /examples/images/charles-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/charles-unsplash.jpg -------------------------------------------------------------------------------- /examples/images/example-3d-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/example-3d-model.png -------------------------------------------------------------------------------- /examples/images/example-instancing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/example-instancing.png -------------------------------------------------------------------------------- /examples/images/example-same-camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanuys/three-projected-material/master/examples/images/example-same-camera.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /examples/images/source.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/three-utils.js: -------------------------------------------------------------------------------- 1 | export function monkeyPatch(shader, { header = '', main = '', ...replaces }) { 2 | let patchedShader = shader 3 | 4 | Object.keys(replaces).forEach(key => { 5 | patchedShader = patchedShader.replace(key, replaces[key]) 6 | }) 7 | 8 | return patchedShader.replace( 9 | 'void main() {', 10 | ` 11 | ${header} 12 | void main() { 13 | ${main} 14 | ` 15 | ) 16 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: 'src/ProjectedMaterial.js', 3 | external: ['three'], 4 | output: [ 5 | { 6 | format: 'umd', 7 | globals: { 8 | three: 'THREE', 9 | }, 10 | name: 'projectedMaterial', 11 | exports: 'named', 12 | file: 'build/ProjectedMaterial.js', 13 | }, 14 | { 15 | format: 'esm', 16 | file: 'build/ProjectedMaterial.module.js', 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /examples/lib/three-utils.js: -------------------------------------------------------------------------------- 1 | // from https://discourse.threejs.org/t/functions-to-calculate-the-visible-width-height-at-a-given-z-depth-from-a-perspective-camera/269 2 | export function visibleHeightAtZDepth(depth, camera) { 3 | // compensate for cameras not positioned at z=0 4 | const cameraOffset = camera.position.z 5 | if (depth < cameraOffset) { 6 | depth -= cameraOffset 7 | } else { 8 | depth += cameraOffset 9 | } 10 | 11 | // vertical fov in radians 12 | const vFOV = (camera.fov * Math.PI) / 180 13 | 14 | // Math.abs to ensure the result is always positive 15 | return 2 * Math.tan(vFOV / 2) * Math.abs(depth) 16 | } 17 | 18 | export function visibleWidthAtZDepth(depth, camera) { 19 | const height = visibleHeightAtZDepth(depth, camera) 20 | return height * camera.aspect 21 | } 22 | -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Basic example - three-projected-material 7 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/3d-model.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 3D Model example - three-projected-material 7 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/instancing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Instancing example - three-projected-material 7 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/same-camera.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Same camera example - three-projected-material 7 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-projected-material", 3 | "description": "Materail which projects a texture onto an object", 4 | "version": "1.1.0", 5 | "main": "build/ProjectedMaterial.js", 6 | "module": "build/ProjectedMaterial.module.js", 7 | "repository": "git@github.com:marcofugaro/three-projected-material.git", 8 | "author": "Marco Fugaro ", 9 | "license": "MIT", 10 | "files": [ 11 | "build/" 12 | ], 13 | "scripts": { 14 | "build": "rollup -c", 15 | "start": "npx serve examples/", 16 | "build-examples": "NODE_ENV=production webpack", 17 | "predeploy": "yarn build-examples", 18 | "deploy": "gh-pages -d examples" 19 | }, 20 | "peerDependencies": { 21 | "three": "^0.111.0" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.6.4", 25 | "@babel/plugin-transform-runtime": "^7.7.6", 26 | "babel-loader": "^8.0.6", 27 | "babel-preset-accurapp": "^4.1.5", 28 | "canvas-sketch-util": "^1.10.0", 29 | "chalk": "^2.4.2", 30 | "controls-gui": "^1.2.2", 31 | "controls-state": "^1.1.1", 32 | "datauritoblob": "^1.0.0", 33 | "detect-gpu": "^1.1.2", 34 | "eslint-config-accurapp": "^4.2.8", 35 | "event-hooks-webpack-plugin": "^2.1.4", 36 | "gh-pages": "^2.1.1", 37 | "glslify-loader": "^2.0.0", 38 | "html-webpack-plugin": "^3.2.0", 39 | "image-promise": "^6.0.2", 40 | "lodash": "^4.17.15", 41 | "orbit-controls": "^1.2.4", 42 | "p-map": "^3.0.0", 43 | "raw-loader": "^3.1.0", 44 | "react-dev-utils": "^9.1.0", 45 | "rimraf": "^3.0.0", 46 | "rollup": "^1.27.11", 47 | "stats.js": "^0.17.0", 48 | "three": "0.111.0", 49 | "touches": "^1.2.2", 50 | "webpack": "^4.41.1", 51 | "webpack-cli": "^3.3.9" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/lib/loadTexture.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/marcofugaro/threejs-modern-app/blob/master/src/lib/loadTexture.js 2 | import * as THREE from 'three' 3 | import loadImage from 'image-promise' 4 | 5 | export default async function loadTexture(url, options) { 6 | const texture = new THREE.Texture() 7 | texture.name = url 8 | texture.encoding = options.encoding || THREE.LinearEncoding 9 | setTextureParams(url, texture, options) 10 | 11 | try { 12 | const image = await loadImage(url, { crossorigin: 'anonymous' }) 13 | 14 | texture.image = image 15 | texture.needsUpdate = true 16 | if (options.renderer) { 17 | // Force texture to be uploaded to GPU immediately, 18 | // this will avoid "jank" on first rendered frame 19 | options.renderer.initTexture(texture) 20 | } 21 | return texture 22 | } catch (err) { 23 | throw new Error(`Could not load texture ${url}`) 24 | } 25 | } 26 | 27 | function setTextureParams(url, texture, opt) { 28 | if (typeof opt.flipY === 'boolean') texture.flipY = opt.flipY 29 | if (typeof opt.mapping !== 'undefined') { 30 | texture.mapping = opt.mapping 31 | } 32 | if (typeof opt.format !== 'undefined') { 33 | texture.format = opt.format 34 | } else { 35 | // choose a nice default format 36 | const isJPEG = url.search(/\.(jpg|jpeg)$/) > 0 || url.search(/^data:image\/jpeg/) === 0 37 | texture.format = isJPEG ? THREE.RGBFormat : THREE.RGBAFormat 38 | } 39 | if (opt.repeat) texture.repeat.copy(opt.repeat) 40 | texture.wrapS = opt.wrapS || THREE.ClampToEdgeWrapping 41 | texture.wrapT = opt.wrapT || THREE.ClampToEdgeWrapping 42 | texture.minFilter = opt.minFilter || THREE.LinearMipMapLinearFilter 43 | texture.magFilter = opt.magFilter || THREE.LinearFilter 44 | texture.generateMipmaps = opt.generateMipmaps !== false 45 | } 46 | -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import ProjectedMaterial, { project } from '..' 3 | import WebGLApp from './lib/WebGLApp.js' 4 | import assets from './lib/AssetManager.js' 5 | 6 | // grab our canvas 7 | const canvas = document.querySelector('#app') 8 | 9 | // setup the WebGLRenderer 10 | const webgl = new WebGLApp({ 11 | canvas, 12 | // set the scene background color 13 | background: '#222', 14 | // show the fps counter from stats.js 15 | showFps: true, 16 | orbitControls: { distance: 4, phi: Math.PI / 2.5 }, 17 | }) 18 | 19 | // attach it to the window to inspect in the console 20 | window.webgl = webgl 21 | 22 | // hide canvas 23 | webgl.canvas.style.visibility = 'hidden' 24 | 25 | // preload the texture 26 | const textureKey = assets.queue({ 27 | url: 'images/uv.jpg', 28 | type: 'texture', 29 | }) 30 | 31 | // load any queued assets 32 | assets.load({ renderer: webgl.renderer }).then(() => { 33 | // show canvas 34 | webgl.canvas.style.visibility = '' 35 | 36 | // create a new camera from which to project 37 | const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 3) 38 | camera.position.set(-1, 1.2, 2) 39 | camera.lookAt(0, 0, 0) 40 | 41 | // add a camer frustum helper just for demostration purposes 42 | const helper = new THREE.CameraHelper(camera) 43 | webgl.scene.add(helper) 44 | 45 | // create the mesh with the projected material 46 | const geometry = new THREE.BoxGeometry(1, 1, 1) 47 | const material = new ProjectedMaterial({ 48 | camera, 49 | texture: assets.get(textureKey), 50 | color: '#37E140', 51 | }) 52 | const box = new THREE.Mesh(geometry, material) 53 | webgl.scene.add(box) 54 | 55 | // move the mesh any way you want! 56 | box.rotation.y = -Math.PI / 4 57 | 58 | // and when you're ready project the texture! 59 | project(box) 60 | 61 | // rotate for demo purposes 62 | webgl.onUpdate(() => { 63 | box.rotation.y -= 0.003 64 | }) 65 | 66 | // add lights 67 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.8) 68 | webgl.scene.add(ambientLight) 69 | 70 | // start animation loop 71 | webgl.start() 72 | webgl.draw() 73 | }) 74 | -------------------------------------------------------------------------------- /examples/3d-model.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils' 3 | import ProjectedMaterial, { project } from '..' 4 | import WebGLApp from './lib/WebGLApp.js' 5 | import assets from './lib/AssetManager.js' 6 | 7 | // extract all geometry from a gltf scene 8 | export function extractGeometry(gltf) { 9 | const geometries = [] 10 | gltf.traverse(child => { 11 | if (child.isMesh) { 12 | geometries.push(child.geometry) 13 | } 14 | }) 15 | 16 | return BufferGeometryUtils.mergeBufferGeometries(geometries) 17 | } 18 | 19 | // grab our canvas 20 | const canvas = document.querySelector('#app') 21 | 22 | // setup the WebGLRenderer 23 | const webgl = new WebGLApp({ 24 | canvas, 25 | // set the scene background color 26 | background: '#E6E6E6', 27 | // show the fps counter from stats.js 28 | showFps: true, 29 | orbitControls: { distance: 4 }, 30 | }) 31 | 32 | // attach it to the window to inspect in the console 33 | window.webgl = webgl 34 | 35 | // hide canvas 36 | webgl.canvas.style.visibility = 'hidden' 37 | 38 | // preload the texture 39 | const textureKey = assets.queue({ 40 | url: 'images/uv.jpg', 41 | type: 'texture', 42 | }) 43 | 44 | // preload the model 45 | const modelsKey = assets.queue({ 46 | url: 'models/suzanne.gltf', 47 | type: 'gltf', 48 | }) 49 | 50 | // load any queued assets 51 | assets.load({ renderer: webgl.renderer }).then(() => { 52 | // show canvas 53 | webgl.canvas.style.visibility = '' 54 | 55 | // create the mesh with the projected material 56 | const gltf = assets.get(modelsKey).scene.clone() 57 | const geometry = extractGeometry(gltf) 58 | const material = new ProjectedMaterial({ 59 | camera: webgl.camera, 60 | texture: assets.get(textureKey), 61 | color: '#cccccc', 62 | textureScale: 0.8, 63 | }) 64 | const mesh = new THREE.Mesh(geometry, material) 65 | webgl.scene.add(mesh) 66 | 67 | // move the mesh any way you want! 68 | // (in this case no translations/rotations) 69 | 70 | // and when you're ready project the texture! 71 | project(mesh) 72 | 73 | // rotate for demo purposes 74 | mesh.rotation.y = Math.PI / 3 75 | webgl.onUpdate(() => { 76 | mesh.rotation.y -= 0.003 77 | }) 78 | 79 | // add lights 80 | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6) 81 | directionalLight.position.set(0, 10, 10) 82 | webgl.scene.add(directionalLight) 83 | 84 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) 85 | webgl.scene.add(ambientLight) 86 | 87 | // start animation loop 88 | webgl.start() 89 | webgl.draw() 90 | }) 91 | -------------------------------------------------------------------------------- /examples/same-camera.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import ProjectedMaterial, { project } from '..' 3 | import { random } from 'lodash' 4 | import WebGLApp from './lib/WebGLApp.js' 5 | import assets from './lib/AssetManager.js' 6 | 7 | // grab our canvas 8 | const canvas = document.querySelector('#app') 9 | 10 | // setup the WebGLRenderer 11 | const webgl = new WebGLApp({ 12 | canvas, 13 | // set the scene background color 14 | background: '#E6E6E6', 15 | // show the fps counter from stats.js 16 | showFps: true, 17 | orbitControls: { distance: 4 }, 18 | }) 19 | 20 | // attach it to the window to inspect in the console 21 | window.webgl = webgl 22 | 23 | // hide canvas 24 | webgl.canvas.style.visibility = 'hidden' 25 | 26 | // preload the texture 27 | const textureKey = assets.queue({ 28 | url: 'images/charles-unsplash.jpg', 29 | type: 'texture', 30 | }) 31 | 32 | // load any queued assets 33 | assets.load({ renderer: webgl.renderer }).then(() => { 34 | // show canvas 35 | webgl.canvas.style.visibility = '' 36 | 37 | // breate a bunch of meshes 38 | const elements = new THREE.Group() 39 | const NUM_ELEMENTS = 50 40 | for (let i = 0; i < NUM_ELEMENTS; i++) { 41 | const geometry = new THREE.IcosahedronGeometry(random(0.1, 0.5)) 42 | const material = new ProjectedMaterial({ 43 | // use the scene camera itself 44 | camera: webgl.camera, 45 | texture: assets.get(textureKey), 46 | color: '#3149D5', 47 | textureScale: 0.8, 48 | }) 49 | const element = new THREE.Mesh(geometry, material) 50 | 51 | // move the meshes any way you want! 52 | if (i < NUM_ELEMENTS * 0.3) { 53 | element.position.x = random(-0.5, 0.5) 54 | element.position.y = random(-1.1, 0.5) 55 | element.position.z = random(-0.3, 0.3) 56 | element.scale.multiplyScalar(1.4) 57 | } else { 58 | element.position.x = random(-1, 1, true) 59 | element.position.y = random(-2, 2, true) 60 | element.position.z = random(-0.5, 0.5) 61 | } 62 | element.rotation.x = random(0, Math.PI * 2) 63 | element.rotation.y = random(0, Math.PI * 2) 64 | element.rotation.z = random(0, Math.PI * 2) 65 | 66 | // and when you're ready project the texture! 67 | project(element) 68 | 69 | elements.add(element) 70 | } 71 | 72 | webgl.scene.add(elements) 73 | 74 | elements.rotation.y = Math.PI / 2 75 | webgl.onUpdate(() => { 76 | elements.rotation.y -= 0.003 77 | }) 78 | 79 | // add lights 80 | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6) 81 | directionalLight.position.set(0, 10, 10) 82 | webgl.scene.add(directionalLight) 83 | 84 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) 85 | webgl.scene.add(ambientLight) 86 | 87 | // start animation loop 88 | webgl.start() 89 | webgl.draw() 90 | }) 91 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const TerserJsPlugin = require('terser-webpack-plugin') 3 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages') 4 | const EventHooksPlugin = require('event-hooks-webpack-plugin') 5 | const chalk = require('chalk') 6 | const _ = require('lodash') 7 | 8 | module.exports = { 9 | mode: 'production', 10 | entry: { 11 | basic: './examples/basic.js', 12 | 'same-camera': './examples/same-camera.js', 13 | instancing: './examples/instancing.js', 14 | '3d-model': './examples/3d-model.js', 15 | }, 16 | output: { 17 | path: path.resolve(__dirname, './examples'), 18 | filename: '[name].bundle.js', 19 | // change this if you're deploying on a subfolder 20 | publicPath: '', 21 | }, 22 | devtool: 'source-map', 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | loader: 'babel-loader', 29 | options: { 30 | cacheDirectory: true, 31 | }, 32 | }, 33 | { 34 | test: /\.(glsl|frag|vert)$/, 35 | use: ['raw-loader', 'glslify-loader'], 36 | }, 37 | ], 38 | }, 39 | // turn off performance hints 40 | performance: false, 41 | // turn off the default webpack bloat 42 | stats: false, 43 | 44 | plugins: [ 45 | // TODO use webpack's api when it will be implemented 46 | // https://github.com/webpack/webpack-dev-server/issues/1509 47 | new EventHooksPlugin({ 48 | // debounced because it gets called two times somehow 49 | beforeCompile: _.debounce(() => { 50 | console.log('⏳ Compiling...') 51 | }, 0), 52 | done(stats) { 53 | if (stats.hasErrors()) { 54 | const statsJson = stats.toJson({ all: false, warnings: true, errors: true }) 55 | const messages = formatWebpackMessages(statsJson) 56 | console.log(chalk.red('❌ Failed to compile.')) 57 | console.log() 58 | console.log(messages.errors[0]) 59 | } 60 | }, 61 | afterEmit() { 62 | console.log(chalk.green(`✅ Compiled successfully!`)) 63 | console.log() 64 | }, 65 | }), 66 | ], 67 | optimization: { 68 | // disable code splitting 69 | splitChunks: false, 70 | minimizer: [ 71 | new TerserJsPlugin({ 72 | terserOptions: { 73 | parse: { 74 | // we want uglify-js to parse ecma 8 code. However, we don't want it 75 | // to apply any minfication steps that turns valid ecma 5 code 76 | // into invalid ecma 5 code. This is why the 'compress' and 'output' 77 | // sections only apply transformations that are ecma 5 safe 78 | // https://github.com/facebook/create-react-app/pull/4234 79 | ecma: 8, 80 | }, 81 | compress: { 82 | ecma: 5, 83 | }, 84 | output: { 85 | ecma: 5, 86 | // Turned on because emoji and regex is not minified properly using default 87 | // https://github.com/facebook/create-react-app/issues/2488 88 | ascii_only: true, 89 | }, 90 | }, 91 | parallel: true, 92 | cache: true, 93 | sourceMap: true, 94 | }), 95 | ], 96 | }, 97 | } 98 | -------------------------------------------------------------------------------- /examples/lib/loadEnvMap.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/marcofugaro/threejs-modern-app/blob/master/src/lib/loadEnvMap.js 2 | import * as THREE from 'three' 3 | import clamp from 'lodash/clamp' 4 | import { HDRCubeTextureLoader } from 'three/examples/jsm/loaders/HDRCubeTextureLoader' 5 | import { PMREMGenerator } from 'three/examples/jsm/pmrem/PMREMGenerator' 6 | import { PMREMCubeUVPacker } from 'three/examples/jsm/pmrem/PMREMCubeUVPacker' 7 | import loadTexture from './loadTexture' 8 | 9 | export default async function loadEnvMap(url, options) { 10 | const renderer = options.renderer 11 | 12 | if (!renderer) { 13 | throw new Error(`PBR Map requires renderer to passed in the options for ${url}!`) 14 | } 15 | 16 | if (options.equirectangular) { 17 | const texture = await loadTexture(url, { renderer }) 18 | 19 | const cubeRenderTarget = new THREE.WebGLRenderTargetCube(1024, 1024).fromEquirectangularTexture( 20 | renderer, 21 | texture 22 | ) 23 | 24 | const cubeMapTexture = cubeRenderTarget.texture 25 | 26 | // renderTarget is used for the scene.background 27 | cubeMapTexture.renderTarget = cubeRenderTarget 28 | 29 | texture.dispose() // dispose original texture 30 | texture.image.data = null // remove Image reference 31 | 32 | return buildCubeMap(cubeMapTexture, options) 33 | } 34 | 35 | const basePath = url 36 | 37 | const isHDR = options.hdr 38 | const extension = isHDR ? '.hdr' : '.png' 39 | const urls = genCubeUrls(`${basePath.replace(/\/$/, '')}/`, extension) 40 | 41 | if (isHDR) { 42 | // load a float HDR texture 43 | return new Promise((resolve, reject) => { 44 | new HDRCubeTextureLoader().load( 45 | THREE.UnsignedByteType, 46 | urls, 47 | map => resolve(buildCubeMap(map, options)), 48 | null, 49 | () => reject(new Error(`Could not load PBR map: ${basePath}`)) 50 | ) 51 | }) 52 | } 53 | 54 | // load a RGBM encoded texture 55 | return new Promise((resolve, reject) => { 56 | new THREE.CubeTextureLoader().load( 57 | urls, 58 | cubeMap => { 59 | cubeMap.encoding = THREE.RGBM16Encoding 60 | resolve(buildCubeMap(cubeMap, options)) 61 | }, 62 | null, 63 | () => reject(new Error(`Could not load PBR map: ${basePath}`)) 64 | ) 65 | }) 66 | } 67 | 68 | function buildCubeMap(cubeMap, options) { 69 | if (options.pbr || typeof options.level === 'number') { 70 | // prefilter the environment map for irradiance 71 | const pmremGenerator = new PMREMGenerator(cubeMap) 72 | pmremGenerator.update(options.renderer) 73 | if (options.pbr) { 74 | const pmremCubeUVPacker = new PMREMCubeUVPacker(pmremGenerator.cubeLods) 75 | pmremCubeUVPacker.update(options.renderer) 76 | const target = pmremCubeUVPacker.CubeUVRenderTarget 77 | cubeMap = target.texture 78 | pmremCubeUVPacker.dispose() 79 | } else { 80 | const idx = clamp(Math.floor(options.level), 0, pmremGenerator.cubeLods.length) 81 | cubeMap = pmremGenerator.cubeLods[idx].texture 82 | } 83 | pmremGenerator.dispose() 84 | } 85 | if (options.mapping) cubeMap.mapping = options.mapping 86 | return cubeMap 87 | } 88 | 89 | function genCubeUrls(prefix, postfix) { 90 | return [ 91 | `${prefix}px${postfix}`, 92 | `${prefix}nx${postfix}`, 93 | `${prefix}py${postfix}`, 94 | `${prefix}ny${postfix}`, 95 | `${prefix}pz${postfix}`, 96 | `${prefix}nz${postfix}`, 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /examples/instancing.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import ProjectedMaterial, { allocateProjectionData, projectInstanceAt } from '..' 3 | import State from 'controls-state' 4 | import { random } from 'lodash' 5 | import WebGLApp from './lib/WebGLApp.js' 6 | import assets from './lib/AssetManager.js' 7 | import { visibleWidthAtZDepth, visibleHeightAtZDepth } from './lib/three-utils' 8 | 9 | // grab our canvas 10 | const canvas = document.querySelector('#app') 11 | 12 | // setup the WebGLRenderer 13 | const webgl = new WebGLApp({ 14 | canvas, 15 | // set the scene background color 16 | background: '#E6E6E6', 17 | // show the fps counter from stats.js 18 | showFps: true, 19 | orbitControls: { distance: 4 }, 20 | controls: { 21 | speed: State.Slider(0.3, { min: 0, max: 3, step: 0.01 }), 22 | }, 23 | }) 24 | 25 | // attach it to the window to inspect in the console 26 | window.webgl = webgl 27 | 28 | // hide canvas 29 | webgl.canvas.style.visibility = 'hidden' 30 | 31 | // preload the texture 32 | const textureKey = assets.queue({ 33 | url: 'images/charles-unsplash.jpg', 34 | type: 'texture', 35 | }) 36 | 37 | // load any queued assets 38 | assets.load({ renderer: webgl.renderer }).then(() => { 39 | // show canvas 40 | webgl.canvas.style.visibility = '' 41 | 42 | const width = visibleWidthAtZDepth(0, webgl.camera) 43 | const height = visibleHeightAtZDepth(0, webgl.camera) 44 | 45 | // create a bunch of instanced elements 46 | const NUM_ELEMENTS = 1000 47 | const dummy = new THREE.Object3D() 48 | 49 | const geometry = new THREE.TetrahedronBufferGeometry(0.4) 50 | const material = new ProjectedMaterial({ 51 | camera: webgl.camera, 52 | texture: assets.get(textureKey), 53 | color: '#cccccc', 54 | cover: true, 55 | instanced: true, 56 | }) 57 | 58 | // allocate the projection data 59 | allocateProjectionData(geometry, NUM_ELEMENTS) 60 | 61 | // create the instanced mesh 62 | const instancedMesh = new THREE.InstancedMesh(geometry, material, NUM_ELEMENTS) 63 | 64 | const initialPositions = [] 65 | const initialRotations = [] 66 | for (let i = 0; i < NUM_ELEMENTS; i++) { 67 | // position the element 68 | dummy.position.x = random(-width / 2, width / 2) 69 | dummy.position.y = random(-height / 2, height / 2) 70 | dummy.rotation.x = random(0, Math.PI * 2) 71 | dummy.rotation.y = random(0, Math.PI * 2) 72 | dummy.rotation.z = random(0, Math.PI * 2) 73 | dummy.updateMatrix() 74 | instancedMesh.setMatrixAt(i, dummy.matrix) 75 | 76 | // project the texture! 77 | dummy.updateMatrixWorld() 78 | projectInstanceAt(i, instancedMesh, dummy.matrixWorld) 79 | 80 | // rotate the element a bit 81 | // so they don't show the image initially 82 | dummy.rotateX(-Math.PI / 2) 83 | dummy.updateMatrix() 84 | instancedMesh.setMatrixAt(i, dummy.matrix) 85 | 86 | // save the initial position and rotation 87 | initialPositions.push(dummy.position.clone()) 88 | initialRotations.push(dummy.rotation.clone()) 89 | } 90 | 91 | webgl.scene.add(instancedMesh) 92 | 93 | // rotate the elements 94 | webgl.onUpdate((dt, time) => { 95 | for (let i = 0; i < NUM_ELEMENTS; i++) { 96 | dummy.position.copy(initialPositions[i]) 97 | dummy.rotation.copy(initialRotations[i]) 98 | 99 | dummy.rotateX(time * webgl.controls.speed) 100 | dummy.updateMatrix() 101 | instancedMesh.setMatrixAt(i, dummy.matrix) 102 | } 103 | instancedMesh.instanceMatrix.needsUpdate = true 104 | }) 105 | 106 | // add lights 107 | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6) 108 | directionalLight.position.set(0, 10, 10) 109 | webgl.scene.add(directionalLight) 110 | 111 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) 112 | webgl.scene.add(ambientLight) 113 | 114 | // start animation loop 115 | webgl.start() 116 | webgl.draw() 117 | }) 118 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | three-projected-material 7 | 11 | 12 | 13 | 14 | 127 | 128 | 129 |
130 |

three-projected-material

131 |

132 | Three.js Material which lets you do 133 | Texture Projection 136 | on a 3d Model. 137 |

138 |
139 |
140 |

Documentation

141 | Documentation on GitHub 142 |

Examples

143 | 144 |
145 |
146 | 147 |
148 | Basic 149 | (source) 154 |
155 |
156 | 157 |
158 | 159 |
160 | Same camera 161 | (source) 166 |
167 |
168 |
169 | 170 |
171 | Instancing 172 | (source) 177 |
178 |
179 |
180 | 181 |
182 | 3D Model 183 | (source) 188 |
189 |
190 |
191 |
192 | 193 | 194 | -------------------------------------------------------------------------------- /examples/lib/AssetManager.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/marcofugaro/threejs-modern-app/blob/master/src/lib/AssetManager.js 2 | import pMap from 'p-map' 3 | import prettyMs from 'pretty-ms' 4 | import loadImage from 'image-promise' 5 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 6 | import loadTexture from './loadTexture' 7 | import loadEnvMap from './loadEnvMap' 8 | 9 | class AssetManager { 10 | #queue = [] 11 | #cache = {} 12 | #onProgressListeners = [] 13 | #asyncConcurrency = 10 14 | #logs = [] 15 | 16 | addProgressListener(fn) { 17 | if (typeof fn !== 'function') { 18 | throw new TypeError('onProgress must be a function') 19 | } 20 | this.#onProgressListeners.push(fn) 21 | } 22 | 23 | // Add an asset to be queued, input: { url, type, ...options } 24 | queue({ url, type, ...options }) { 25 | if (!url) throw new TypeError('Must specify a URL or opt.url for AssetManager.queue()') 26 | if (!this._getQueued(url)) { 27 | this.#queue.push({ url, type: type || this._extractType(url), ...options }) 28 | } 29 | 30 | return url 31 | } 32 | 33 | _getQueued(url) { 34 | return this.#queue.find(item => item.url === url) 35 | } 36 | 37 | _extractType(url) { 38 | const ext = url.slice(url.lastIndexOf('.')) 39 | 40 | switch (true) { 41 | case /\.(gltf|glb)$/i.test(ext): 42 | return 'gltf' 43 | case /\.json$/i.test(ext): 44 | return 'json' 45 | case /\.svg$/i.test(ext): 46 | return 'svg' 47 | case /\.(jpe?g|png|gif|bmp|tga|tif)$/i.test(ext): 48 | return 'image' 49 | case /\.(wav|mp3)$/i.test(ext): 50 | return 'audio' 51 | case /\.(mp4|webm|ogg|ogv)$/i.test(ext): 52 | return 'video' 53 | default: 54 | throw new Error(`Could not load ${url}, unknown file extension!`) 55 | } 56 | } 57 | 58 | // Fetch a loaded asset by URL 59 | get = url => { 60 | if (!url) throw new TypeError('Must specify an URL for AssetManager.get()') 61 | if (!(url in this.#cache)) { 62 | throw new Error(`The asset ${url} is not in the loaded files.`) 63 | } 64 | 65 | return this.#cache[url] 66 | } 67 | 68 | // Loads a single asset 69 | async loadSingle({ renderer, ...item }) { 70 | // renderer is used to load textures and env maps, 71 | // but require it always since it is an extensible pattern 72 | if (!renderer) { 73 | throw new Error('You must provide a renderer to the loadSingle function.') 74 | } 75 | 76 | try { 77 | const itemLoadingStart = Date.now() 78 | 79 | this.#cache[item.url] = await this._loadItem({ renderer, ...item }) 80 | 81 | if (window.DEBUG) { 82 | console.log( 83 | `📦 Loaded single asset %c${item.url}%c in ${prettyMs(Date.now() - itemLoadingStart)}`, 84 | 'color:blue', 85 | 'color:black' 86 | ) 87 | } 88 | 89 | return item.url 90 | } catch (err) { 91 | delete this.#cache[item.url] 92 | console.error(`📦 Could not load ${item.url}:\n${err}`) 93 | } 94 | } 95 | 96 | // Loads all queued assets 97 | async load({ renderer }) { 98 | // renderer is used to load textures and env maps, 99 | // but require it always since it is an extensible pattern 100 | if (!renderer) { 101 | throw new Error('You must provide a renderer to the load function.') 102 | } 103 | 104 | const queue = this.#queue.slice() 105 | this.#queue.length = 0 // clear queue 106 | 107 | const total = queue.length 108 | if (total === 0) { 109 | // resolve first this functions and then call the progress listeners 110 | setTimeout(() => this.#onProgressListeners.forEach(fn => fn(1)), 0) 111 | return 112 | } 113 | 114 | const loadingStart = Date.now() 115 | 116 | await pMap( 117 | queue, 118 | async (item, i) => { 119 | try { 120 | const itemLoadingStart = Date.now() 121 | 122 | this.#cache[item.url] = await this._loadItem({ renderer, ...item }) 123 | 124 | if (window.DEBUG) { 125 | this.log( 126 | `Loaded %c${item.url}%c in ${prettyMs(Date.now() - itemLoadingStart)}`, 127 | 'color:blue', 128 | 'color:black' 129 | ) 130 | } 131 | } catch (err) { 132 | this.logError(`Skipping ${item.url} from asset loading:\n${err}`) 133 | } 134 | 135 | const percent = (i + 1) / total 136 | this.#onProgressListeners.forEach(fn => fn(percent)) 137 | }, 138 | { concurrency: this.#asyncConcurrency } 139 | ) 140 | 141 | if (window.DEBUG) { 142 | const errors = this.#logs.filter(log => log.type === 'error') 143 | 144 | this.groupLog( 145 | `📦 Assets loaded in ${prettyMs(Date.now() - loadingStart)} ⏱ ${ 146 | errors.length > 0 147 | ? `%c ⚠️ Skipped ${errors.length} asset${errors.length > 1 ? 's' : ''} ` 148 | : '' 149 | }`, 150 | errors.length > 0 ? 'color:white;background:red;' : '' 151 | ) 152 | } 153 | } 154 | 155 | // Loads a single asset on demand, returning from 156 | // cache if it exists otherwise adding it to the cache 157 | // after loading. 158 | async _loadItem({ url, type, renderer, ...options }) { 159 | if (url in this.#cache) { 160 | return this.#cache[url] 161 | } 162 | 163 | switch (type) { 164 | case 'gltf': 165 | return new Promise((resolve, reject) => { 166 | new GLTFLoader().load(url, resolve, null, err => 167 | reject(new Error(`Could not load GLTF asset ${url}:\n${err}`)) 168 | ) 169 | }) 170 | case 'json': 171 | return fetch(url).then(response => response.json()) 172 | case 'env-map': 173 | return loadEnvMap(url, { renderer, ...options }) 174 | case 'svg': 175 | case 'image': 176 | return loadImage(url, { crossorigin: 'anonymous' }) 177 | case 'texture': 178 | return loadTexture(url, { renderer, ...options }) 179 | case 'audio': 180 | // You might not want to load big audio files and 181 | // store them in memory, that might be inefficient. 182 | // Rather load them outside of the queue 183 | return fetch(url).then(response => response.arrayBuffer()) 184 | case 'video': 185 | // You might not want to load big video files and 186 | // store them in memory, that might be inefficient. 187 | // Rather load them outside of the queue 188 | return fetch(url).then(response => response.blob()) 189 | default: 190 | throw new Error(`Could not load ${url}, the type ${type} is unknown!`) 191 | } 192 | } 193 | 194 | log(...text) { 195 | this.#logs.push({ type: 'log', text }) 196 | } 197 | 198 | logError(...text) { 199 | this.#logs.push({ type: 'error', text }) 200 | } 201 | 202 | groupLog(...text) { 203 | console.groupCollapsed(...text) 204 | this.#logs.forEach(log => { 205 | console[log.type](...log.text) 206 | }) 207 | console.groupEnd() 208 | 209 | this.#logs.length = 0 // clear logs 210 | } 211 | } 212 | 213 | // asset manager is a singleton, you can require it from 214 | // different files and use the same instance. 215 | // A plain js object would have worked just fine, 216 | // fucking java patterns 217 | export default new AssetManager() 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-projected-material 2 | 3 | > Three.js Material which lets you do [Texture Projection](https://en.wikipedia.org/wiki/Projective_texture_mapping) on a 3d Model. 4 | 5 | [](https://marcofugaro.github.io/three-projected-material/) 6 | 7 | ### [EXAMPLES](https://marcofugaro.github.io/three-projected-material/) 8 | 9 | ## Installation 10 | 11 | After having installed Three.js, install it from npm with: 12 | 13 | ``` 14 | npm install three-projected-material 15 | ``` 16 | 17 | or 18 | 19 | ``` 20 | yarn add three-projected-material 21 | ``` 22 | 23 | You can also use it from the CDN, just make sure to put this after the Three.js script: 24 | 25 | ```html 26 | 27 | ``` 28 | 29 | ## Getting started 30 | 31 | You can import it like this 32 | 33 | ```js 34 | import ProjectedMaterial, { project } from 'three-projected-material' 35 | ``` 36 | 37 | or, if you're using CommonJS 38 | 39 | ```js 40 | const { default: ProjectedMaterial, project } = require('three-projected-material') 41 | ``` 42 | 43 | Instead, if you install it from the CDN, its exposed under `window.projectedMaterial`, and you use it like this 44 | 45 | ```js 46 | const { default: ProjectedMaterial, project } = window.projectedMaterial 47 | ``` 48 | 49 | Then, you can use it like this: 50 | 51 | ```js 52 | const geometry = new THREE.BoxGeometry(1, 1, 1) 53 | const material = new ProjectedMaterial({ 54 | camera, // the camera that acts as a projector 55 | texture, // the texture being projected 56 | color: '#cccccc', // the color of the object if it's not projected on 57 | textureScale: 0.8, // scale down the texture a bit 58 | cover: true, // enable background-size: cover behaviour, by default it's like background-size: contain 59 | }) 60 | const box = new THREE.Mesh(geometry, material) 61 | webgl.scene.add(box) 62 | 63 | // move the mesh any way you want! 64 | box.rotation.y = -Math.PI / 4 65 | 66 | // and when you're ready project the texture! 67 | project(box) 68 | ``` 69 | 70 | ProjectedMaterial also supports instanced objects via Three.js' [InstancedMesh](https://threejs.org/docs/index.html#api/en/objects/InstancedMesh), this is an example usage: 71 | 72 | ```js 73 | import ProjectedMaterial, { 74 | allocateProjectionData, 75 | projectInstanceAt, 76 | } from 'three-projected-material' 77 | 78 | const NUM_ELEMENTS = 1000 79 | const dummy = new THREE.Object3D() 80 | 81 | const geometry = new THREE.BoxBufferGeometry(1, 1, 1) 82 | const material = new ProjectedMaterial({ 83 | camera, 84 | texture, 85 | color: '#cccccc', 86 | instanced: true, 87 | }) 88 | 89 | // allocate the projection data 90 | allocateProjectionData(geometry, NUM_ELEMENTS) 91 | 92 | // create the instanced mesh 93 | const instancedMesh = new THREE.InstancedMesh(geometry, material, NUM_ELEMENTS) 94 | 95 | for (let i = 0; i < NUM_ELEMENTS; i++) { 96 | // position the element 97 | dummy.position.x = random(-width / 2, width / 2) 98 | dummy.position.y = random(-height / 2, height / 2) 99 | dummy.rotation.x = random(0, Math.PI * 2) 100 | dummy.rotation.y = random(0, Math.PI * 2) 101 | dummy.rotation.z = random(0, Math.PI * 2) 102 | dummy.updateMatrix() 103 | instancedMesh.setMatrixAt(i, dummy.matrix) 104 | 105 | // project the texture! 106 | dummy.updateMatrixWorld() 107 | projectInstanceAt(i, instancedMesh, dummy.matrixWorld) 108 | } 109 | 110 | webgl.scene.add(instancedMesh) 111 | ``` 112 | 113 | If you want to see the remaining code, and other usages, check out the [examples](https://marcofugaro.github.io/three-projected-material/). 114 | 115 | ## API 116 | 117 | ### new ProjectedMaterial({ camera, texture, ...others }) 118 | 119 | Create a new material to later use for a mesh. 120 | 121 | | Option | Default | Description | 122 | | -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 123 | | `camera` | | The [PerspectiveCamera](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera) the texture will be projected from. | 124 | | `texture` | | The [Texture](https://threejs.org/docs/#api/en/textures/Texture) being projected. | 125 | | `color` | `'#ffffff'` | The color the non-projected on parts of the object will have. | 126 | | `textureScale` | 1 | Make the texture bigger or smaller. | 127 | | `cover` | false | Wheter the texture should act like [`background-size: cover`](https://css-tricks.com/almanac/properties/b/background-size/) on the projector frustum. By default it works like [`background-size: contain`](https://css-tricks.com/almanac/properties/b/background-size/). | 128 | | `instanced` | false | Wether the material will be part of an [InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh). If this is true, [`allocateProjectionData()`](#allocateprojectiondatageometry-instancescount) and [`projectInstanceAt()`](#projectinstanceatindex-instancedmesh-matrixworld) must be used instead of [`project()`](#projectmesh). | 129 | | `opacity` | 1 | The opacity of the material, works like the [`Material.opacity`](https://threejs.org/docs/#api/en/materials/Material.opacity). | 130 | 131 | ### project(mesh) 132 | 133 | Project the texture from the camera on the mesh. 134 | 135 | | Option | Description | 136 | | ------ | ---------------------------------------------------- | 137 | | `mesh` | The mesh that has a `ProjectedMaterial` as material. | 138 | 139 | ### allocateProjectionData(geometry, instancesCount) 140 | 141 | Allocate the data that will be used when projecting on an [InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh). Use this on the geometry that will be used in pair with a `ProjectedMaterial` when initializing `InstancedMesh`. 142 | 143 | _**NOTE:** Don't forget to pass `instanced: true` to the projected material._ 144 | 145 | | Option | Description | 146 | | ---------------- | ----------------------------------------------------------------------------- | 147 | | `geometry` | The geometry that will be passed to the `InstancedMesh`. | 148 | | `instancesCount` | The number of instances, the same that will be passed to the `InstancedMesh`. | 149 | 150 | ### projectInstanceAt(index, instancedMesh, matrixWorld) 151 | 152 | Do the projection for an [InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh). Don't forget to call `updateMatrixWorld()` like you do before calling `InstancedMesh.setMatrixAt()`. 153 | 154 | ```js 155 | dummy.updateMatrixWorld() 156 | projectInstanceAt(i, instancedMesh, dummy.matrixWorld) 157 | ``` 158 | 159 | [Link to the full example](https://marcofugaro.github.io/three-projected-material/instancing). 160 | 161 | _**NOTE:** Don't forget to pass `instanced: true` to the projected material._ 162 | 163 | | Option | Description | 164 | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | 165 | | `index` | The index of the instanced element to project. | 166 | | `instancedMesh` | The [InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh) with a projected material. | 167 | | `matrixWorld` | The `matrixWorld` of the dummy you used to position the instanced mesh element. Be sure to call `.updateMatrixWorld()` beforehand. | 168 | 169 | ## TODO 170 | 171 | - different materials for the rest of the object 172 | - multiple projections onto an object? 173 | -------------------------------------------------------------------------------- /examples/lib/WebGLApp.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/marcofugaro/threejs-modern-app/blob/master/src/lib/WebGLApp.js 2 | import * as THREE from 'three' 3 | import createOrbitControls from 'orbit-controls' 4 | import createTouches from 'touches' 5 | import dataURIToBlob from 'datauritoblob' 6 | import Stats from 'stats.js' 7 | import State from 'controls-state' 8 | import wrapGUI from 'controls-gui' 9 | import { getGPUTier } from 'detect-gpu' 10 | import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer' 11 | import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass' 12 | 13 | export default class WebGLApp { 14 | #updateListeners = [] 15 | #tmpTarget = new THREE.Vector3() 16 | #rafID 17 | #lastTime 18 | 19 | constructor({ 20 | background = '#000', 21 | backgroundAlpha = 1, 22 | fov = 45, 23 | near = 0.01, 24 | far = 100, 25 | ...options 26 | } = {}) { 27 | this.renderer = new THREE.WebGLRenderer({ 28 | antialias: true, 29 | alpha: false, 30 | // enabled for saving screenshots of the canvas, 31 | // may wish to disable this for perf reasons 32 | preserveDrawingBuffer: true, 33 | failIfMajorPerformanceCaveat: true, 34 | ...options, 35 | }) 36 | 37 | this.renderer.sortObjects = false 38 | this.canvas = this.renderer.domElement 39 | 40 | this.renderer.setClearColor(background, backgroundAlpha) 41 | 42 | // clamp pixel ratio for performance 43 | this.maxPixelRatio = options.maxPixelRatio || 2 44 | // clamp delta to stepping anything too far forward 45 | this.maxDeltaTime = options.maxDeltaTime || 1 / 30 46 | 47 | // setup a basic camera 48 | this.camera = new THREE.PerspectiveCamera(fov, 1, near, far) 49 | // this.camera = new THREE.OrthographicCamera(-2, 2, 2, -2, near, far) 50 | 51 | this.scene = new THREE.Scene() 52 | 53 | this.gl = this.renderer.getContext() 54 | 55 | this.time = 0 56 | this.isRunning = false 57 | this.#lastTime = performance.now() 58 | this.#rafID = null 59 | 60 | // detect the gpu info 61 | const gpu = getGPUTier({ glContext: this.renderer.getContext() }) 62 | this.gpu = { 63 | name: gpu.type, 64 | tier: Number(gpu.tier.slice(-1)), 65 | isMobile: gpu.tier.toLowerCase().includes('mobile'), 66 | } 67 | 68 | // handle resize events 69 | window.addEventListener('resize', this.resize) 70 | window.addEventListener('orientationchange', this.resize) 71 | 72 | // force an initial resize event 73 | this.resize() 74 | 75 | // __________________________ADDONS__________________________ 76 | 77 | // really basic touch handler that propagates through the scene 78 | this.touchHandler = createTouches(this.canvas, { 79 | target: this.canvas, 80 | filtered: true, 81 | }) 82 | this.touchHandler.on('start', (ev, pos) => this.traverse('onPointerDown', ev, pos)) 83 | this.touchHandler.on('move', (ev, pos) => this.traverse('onPointerMove', ev, pos)) 84 | this.touchHandler.on('end', (ev, pos) => this.traverse('onPointerUp', ev, pos)) 85 | 86 | // expose a composer for postprocessing passes 87 | if (options.postprocessing) { 88 | this.composer = new EffectComposer(this.renderer) 89 | this.composer.addPass(new RenderPass(this.scene, this.camera)) 90 | } 91 | 92 | // set up a simple orbit controller 93 | if (options.orbitControls) { 94 | this.orbitControls = createOrbitControls({ 95 | element: this.canvas, 96 | parent: window, 97 | distance: 4, 98 | ...(options.orbitControls instanceof Object ? options.orbitControls : {}), 99 | }) 100 | 101 | // move the camera position accordingly to the orgitcontrols options 102 | this.camera.position.fromArray(this.orbitControls.position) 103 | this.camera.lookAt(new THREE.Vector3().fromArray(this.orbitControls.target)) 104 | } 105 | 106 | // Attach the Cannon physics engine 107 | if (options.world) this.world = options.world 108 | 109 | // Attach Tween.js 110 | if (options.tween) this.tween = options.tween 111 | 112 | // show the fps meter 113 | if (options.showFps) { 114 | this.stats = new Stats() 115 | this.stats.showPanel(0) 116 | document.body.appendChild(this.stats.dom) 117 | } 118 | 119 | // initialize the controls-state 120 | if (options.controls) { 121 | const controlsState = State(options.controls) 122 | this.controls = options.hideControls ? controlsState : wrapGUI(controlsState) 123 | } 124 | } 125 | 126 | resize = ({ 127 | width = window.innerWidth, 128 | height = window.innerHeight, 129 | pixelRatio = Math.min(this.maxPixelRatio, window.devicePixelRatio), 130 | } = {}) => { 131 | this.width = width 132 | this.height = height 133 | this.pixelRatio = pixelRatio 134 | 135 | // update pixel ratio if necessary 136 | if (this.renderer.getPixelRatio() !== pixelRatio) { 137 | this.renderer.setPixelRatio(pixelRatio) 138 | } 139 | 140 | // setup new size & update camera aspect if necessary 141 | this.renderer.setSize(width, height) 142 | if (this.camera.isPerspectiveCamera) { 143 | this.camera.aspect = width / height 144 | } 145 | this.camera.updateProjectionMatrix() 146 | 147 | // resize also the composer 148 | if (this.composer) { 149 | this.composer.setSize(pixelRatio * width, pixelRatio * height) 150 | } 151 | 152 | // recursively tell all child objects to resize 153 | this.scene.traverse(obj => { 154 | if (typeof obj.resize === 'function') { 155 | obj.resize({ 156 | width, 157 | height, 158 | pixelRatio, 159 | }) 160 | } 161 | }) 162 | 163 | // draw a frame to ensure the new size has been registered visually 164 | this.draw() 165 | return this 166 | } 167 | 168 | // convenience function to trigger a PNG download of the canvas 169 | saveScreenshot = ({ width = 2560, height = 1440, fileName = 'image.png' } = {}) => { 170 | // force a specific output size 171 | this.resize({ width, height, pixelRatio: 1 }) 172 | this.draw() 173 | 174 | const dataURI = this.canvas.toDataURL('image/png') 175 | 176 | // reset to default size 177 | this.resize() 178 | this.draw() 179 | 180 | // save 181 | saveDataURI(fileName, dataURI) 182 | } 183 | 184 | update = (dt, time) => { 185 | if (this.orbitControls) { 186 | this.orbitControls.update() 187 | 188 | // reposition to orbit controls 189 | this.camera.up.fromArray(this.orbitControls.up) 190 | this.camera.position.fromArray(this.orbitControls.position) 191 | this.#tmpTarget.fromArray(this.orbitControls.target) 192 | this.camera.lookAt(this.#tmpTarget) 193 | } 194 | 195 | // recursively tell all child objects to update 196 | this.scene.traverse(obj => { 197 | if (typeof obj.update === 'function') { 198 | obj.update(dt, time) 199 | } 200 | }) 201 | 202 | if (this.world) { 203 | // update the Cannon physics engine 204 | this.world.step(dt) 205 | 206 | // recursively tell all child bodies to update 207 | this.world.bodies.forEach(body => { 208 | if (typeof body.update === 'function') { 209 | body.update(dt, time) 210 | } 211 | }) 212 | } 213 | 214 | if (this.tween) { 215 | // update the Tween.js engine 216 | this.tween.update() 217 | } 218 | 219 | // call the update listeners 220 | this.#updateListeners.forEach(fn => fn(dt, time)) 221 | 222 | return this 223 | } 224 | 225 | onUpdate(fn) { 226 | this.#updateListeners.push(fn) 227 | } 228 | 229 | draw = () => { 230 | if (this.composer) { 231 | // make sure to always render the last pass 232 | this.composer.passes.forEach((pass, i, passes) => { 233 | const isLastElement = i === passes.length - 1 234 | 235 | if (isLastElement) { 236 | pass.renderToScreen = true 237 | } else { 238 | pass.renderToScreen = false 239 | } 240 | }) 241 | 242 | this.composer.render() 243 | } else { 244 | this.renderer.render(this.scene, this.camera) 245 | } 246 | return this 247 | } 248 | 249 | start = () => { 250 | if (this.#rafID !== null) return 251 | this.#rafID = window.requestAnimationFrame(this.animate) 252 | this.isRunning = true 253 | return this 254 | } 255 | 256 | stop = () => { 257 | if (this.#rafID === null) return 258 | window.cancelAnimationFrame(this.#rafID) 259 | this.#rafID = null 260 | this.isRunning = false 261 | return this 262 | } 263 | 264 | animate = () => { 265 | if (!this.isRunning) return 266 | window.requestAnimationFrame(this.animate) 267 | 268 | if (this.stats) this.stats.begin() 269 | 270 | const now = performance.now() 271 | const dt = Math.min(this.maxDeltaTime, (now - this.#lastTime) / 1000) 272 | this.time += dt 273 | this.#lastTime = now 274 | this.update(dt, this.time) 275 | this.draw() 276 | 277 | if (this.stats) this.stats.end() 278 | } 279 | 280 | traverse = (fn, ...args) => { 281 | this.scene.traverse(child => { 282 | if (typeof child[fn] === 'function') { 283 | child[fn].apply(child, args) 284 | } 285 | }) 286 | } 287 | } 288 | 289 | function saveDataURI(name, dataURI) { 290 | const blob = dataURIToBlob(dataURI) 291 | 292 | // force download 293 | const link = document.createElement('a') 294 | link.download = name 295 | link.href = window.URL.createObjectURL(blob) 296 | link.onclick = setTimeout(() => { 297 | window.URL.revokeObjectURL(blob) 298 | link.removeAttribute('href') 299 | }, 0) 300 | 301 | link.click() 302 | } 303 | -------------------------------------------------------------------------------- /src/ProjectedMaterial.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { monkeyPatch } from './three-utils' 3 | 4 | export default class ProjectedMaterial extends THREE.ShaderMaterial { 5 | constructor({ 6 | camera, 7 | texture, 8 | color = 0xffffff, 9 | textureScale = 1, 10 | instanced = false, 11 | cover = false, 12 | opacity = 1, 13 | ...options 14 | } = {}) { 15 | if (!texture || !texture.isTexture) { 16 | throw new Error('Invalid texture passed to the ProjectedMaterial') 17 | } 18 | 19 | if (!camera || !camera.isCamera) { 20 | throw new Error('Invalid camera passed to the ProjectedMaterial') 21 | } 22 | 23 | // make sure the camera matrices are updated 24 | camera.updateProjectionMatrix() 25 | camera.updateMatrixWorld() 26 | camera.updateWorldMatrix() 27 | 28 | // get the matrices from the camera so they're fixed in camera's original position 29 | const viewMatrixCamera = camera.matrixWorldInverse.clone() 30 | const projectionMatrixCamera = camera.projectionMatrix.clone() 31 | const modelMatrixCamera = camera.matrixWorld.clone() 32 | 33 | const projPosition = camera.position.clone() 34 | 35 | // scale to keep the image proportions and apply textureScale 36 | const [widthScaled, heightScaled] = computeScaledDimensions( 37 | texture, 38 | camera, 39 | textureScale, 40 | cover 41 | ) 42 | 43 | super({ 44 | ...options, 45 | lights: true, 46 | uniforms: { 47 | ...THREE.ShaderLib['lambert'].uniforms, 48 | baseColor: { value: new THREE.Color(color) }, 49 | texture: { value: texture }, 50 | viewMatrixCamera: { type: 'm4', value: viewMatrixCamera }, 51 | projectionMatrixCamera: { type: 'm4', value: projectionMatrixCamera }, 52 | modelMatrixCamera: { type: 'mat4', value: modelMatrixCamera }, 53 | // we will set this later when we will have positioned the object 54 | savedModelMatrix: { type: 'mat4', value: new THREE.Matrix4() }, 55 | projPosition: { type: 'v3', value: projPosition }, 56 | widthScaled: { value: widthScaled }, 57 | heightScaled: { value: heightScaled }, 58 | opacity: { value: opacity }, 59 | }, 60 | 61 | vertexShader: monkeyPatch(THREE.ShaderChunk['meshlambert_vert'], { 62 | header: [ 63 | instanced 64 | ? ` 65 | attribute vec4 savedModelMatrix0; 66 | attribute vec4 savedModelMatrix1; 67 | attribute vec4 savedModelMatrix2; 68 | attribute vec4 savedModelMatrix3; 69 | ` 70 | : ` 71 | uniform mat4 savedModelMatrix; 72 | `, 73 | ` 74 | uniform mat4 viewMatrixCamera; 75 | uniform mat4 projectionMatrixCamera; 76 | uniform mat4 modelMatrixCamera; 77 | 78 | varying vec4 vWorldPosition; 79 | varying vec3 vNormal; 80 | varying vec4 vTexCoords; 81 | `, 82 | ].join(''), 83 | main: [ 84 | instanced 85 | ? ` 86 | mat4 savedModelMatrix = mat4( 87 | savedModelMatrix0, 88 | savedModelMatrix1, 89 | savedModelMatrix2, 90 | savedModelMatrix3 91 | ); 92 | ` 93 | : '', 94 | ` 95 | vNormal = mat3(savedModelMatrix) * normal; 96 | vWorldPosition = savedModelMatrix * vec4(position, 1.0); 97 | vTexCoords = projectionMatrixCamera * viewMatrixCamera * vWorldPosition; 98 | `, 99 | ].join(''), 100 | }), 101 | 102 | fragmentShader: monkeyPatch(THREE.ShaderChunk['meshlambert_frag'], { 103 | header: ` 104 | uniform vec3 baseColor; 105 | uniform sampler2D texture; 106 | uniform vec3 projPosition; 107 | uniform float widthScaled; 108 | uniform float heightScaled; 109 | 110 | varying vec3 vNormal; 111 | varying vec4 vWorldPosition; 112 | varying vec4 vTexCoords; 113 | 114 | float map(float value, float min1, float max1, float min2, float max2) { 115 | return min2 + (value - min1) * (max2 - min2) / (max1 - min1); 116 | } 117 | `, 118 | 'vec4 diffuseColor = vec4( diffuse, opacity );': ` 119 | vec2 uv = (vTexCoords.xy / vTexCoords.w) * 0.5 + 0.5; 120 | 121 | // apply the corrected width and height 122 | uv.x = map(uv.x, 0.0, 1.0, 0.5 - widthScaled / 2.0, 0.5 + widthScaled / 2.0); 123 | uv.y = map(uv.y, 0.0, 1.0, 0.5 - heightScaled / 2.0, 0.5 + heightScaled / 2.0); 124 | 125 | vec4 color = texture2D(texture, uv); 126 | 127 | // this makes sure we don't sample out of the texture 128 | // TODO handle alpha 129 | bool inTexture = (max(uv.x, uv.y) <= 1.0 && min(uv.x, uv.y) >= 0.0); 130 | if (!inTexture) { 131 | color = vec4(baseColor, 1.0); 132 | } 133 | 134 | // this makes sure we don't render also the back of the object 135 | vec3 projectorDirection = normalize(projPosition - vWorldPosition.xyz); 136 | float dotProduct = dot(vNormal, projectorDirection); 137 | if (dotProduct < 0.0) { 138 | color = vec4(baseColor, 1.0); 139 | } 140 | 141 | // opacity from three.js 142 | color.a *= opacity; 143 | 144 | vec4 diffuseColor = color; 145 | `, 146 | }), 147 | }) 148 | 149 | // listen on resize if the camera used for the projection 150 | // is the same used to render. 151 | // do this on window resize because there is no way to 152 | // listen for the resize of the renderer 153 | // (or maybe do a requestanimationframe if the camera.aspect changes) 154 | window.addEventListener('resize', () => { 155 | this.uniforms.projectionMatrixCamera.value.copy(camera.projectionMatrix) 156 | 157 | const [widthScaledNew, heightScaledNew] = computeScaledDimensions( 158 | texture, 159 | camera, 160 | textureScale, 161 | cover 162 | ) 163 | this.uniforms.widthScaled.value = widthScaledNew 164 | this.uniforms.heightScaled.value = heightScaledNew 165 | }) 166 | 167 | this.isProjectedMaterial = true 168 | this.instanced = instanced 169 | } 170 | } 171 | 172 | // scale to keep the image proportions and apply textureScale 173 | function computeScaledDimensions(texture, camera, textureScale, cover) { 174 | const ratio = texture.image.naturalWidth / texture.image.naturalHeight 175 | const ratioCamera = camera.aspect 176 | const widthCamera = 1 177 | const heightCamera = widthCamera * (1 / ratioCamera) 178 | let widthScaled 179 | let heightScaled 180 | if (cover ? ratio > ratioCamera : ratio < ratioCamera) { 181 | const width = heightCamera * ratio 182 | widthScaled = 1 / ((width / widthCamera) * textureScale) 183 | heightScaled = 1 / textureScale 184 | } else { 185 | const height = widthCamera * (1 / ratio) 186 | heightScaled = 1 / ((height / heightCamera) * textureScale) 187 | widthScaled = 1 / textureScale 188 | } 189 | 190 | return [widthScaled, heightScaled] 191 | } 192 | 193 | export function project(mesh) { 194 | if (!mesh.material.isProjectedMaterial) { 195 | throw new Error(`The mesh material must be a ProjectedMaterial`) 196 | } 197 | 198 | // make sure the matrix is updated 199 | mesh.updateMatrixWorld() 200 | 201 | // we save the object model matrix so it's projected relative 202 | // to that position, like a snapshot 203 | mesh.material.uniforms.savedModelMatrix.value.copy(mesh.matrixWorld) 204 | } 205 | 206 | export function projectInstanceAt(index, instancedMesh, matrixWorld) { 207 | if (!instancedMesh.isInstancedMesh) { 208 | throw new Error(`The provided mesh is not an InstancedMesh`) 209 | } 210 | 211 | if (!instancedMesh.material.isProjectedMaterial) { 212 | throw new Error(`The InstancedMesh material must be a ProjectedMaterial`) 213 | } 214 | 215 | if ( 216 | !instancedMesh.geometry.attributes.savedModelMatrix0 || 217 | !instancedMesh.geometry.attributes.savedModelMatrix1 || 218 | !instancedMesh.geometry.attributes.savedModelMatrix2 || 219 | !instancedMesh.geometry.attributes.savedModelMatrix3 220 | ) { 221 | throw new Error( 222 | `No allocated data found on the geometry, please call 'allocateProjectionData(geometry)'` 223 | ) 224 | } 225 | 226 | if (!instancedMesh.material.instanced) { 227 | throw new Error(`Please pass 'instanced: true' to the ProjectedMaterial`) 228 | } 229 | 230 | instancedMesh.geometry.attributes.savedModelMatrix0.setXYZW( 231 | index, 232 | matrixWorld.elements[0], 233 | matrixWorld.elements[1], 234 | matrixWorld.elements[2], 235 | matrixWorld.elements[3] 236 | ) 237 | instancedMesh.geometry.attributes.savedModelMatrix1.setXYZW( 238 | index, 239 | matrixWorld.elements[4], 240 | matrixWorld.elements[5], 241 | matrixWorld.elements[6], 242 | matrixWorld.elements[7] 243 | ) 244 | instancedMesh.geometry.attributes.savedModelMatrix2.setXYZW( 245 | index, 246 | matrixWorld.elements[8], 247 | matrixWorld.elements[9], 248 | matrixWorld.elements[10], 249 | matrixWorld.elements[11] 250 | ) 251 | instancedMesh.geometry.attributes.savedModelMatrix3.setXYZW( 252 | index, 253 | matrixWorld.elements[12], 254 | matrixWorld.elements[13], 255 | matrixWorld.elements[14], 256 | matrixWorld.elements[15] 257 | ) 258 | } 259 | 260 | export function allocateProjectionData(geometry, instancesCount) { 261 | geometry.setAttribute( 262 | 'savedModelMatrix0', 263 | new THREE.InstancedBufferAttribute(new Float32Array(instancesCount * 4), 4) 264 | ) 265 | geometry.setAttribute( 266 | 'savedModelMatrix1', 267 | new THREE.InstancedBufferAttribute(new Float32Array(instancesCount * 4), 4) 268 | ) 269 | geometry.setAttribute( 270 | 'savedModelMatrix2', 271 | new THREE.InstancedBufferAttribute(new Float32Array(instancesCount * 4), 4) 272 | ) 273 | geometry.setAttribute( 274 | 'savedModelMatrix3', 275 | new THREE.InstancedBufferAttribute(new Float32Array(instancesCount * 4), 4) 276 | ) 277 | } 278 | --------------------------------------------------------------------------------