├── example ├── snowden.png ├── server.js └── client.js ├── .gitignore ├── .npmignore ├── LICENSE.md ├── package.json ├── index.js └── README.md /example/snowden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/three-png-stream/HEAD/example/snowden.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | example/*.png 7 | !example/snowden.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md 11 | snowden.png -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015 Jam3 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-png-stream", 3 | "version": "1.0.3", 4 | "description": "streams ThreeJS render target pixel data", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "dependencies": { 13 | "gl-pixel-stream": "^1.2.0", 14 | "object-assign": "^4.0.1", 15 | "png-stream": "^1.0.5" 16 | }, 17 | "devDependencies": { 18 | "query-string": "^4.3.4", 19 | "babel-preset-es2015": "^6.24.1", 20 | "babelify": "^7.3.0", 21 | "budo": "^10.0.3", 22 | "envify": "^4.0.0", 23 | "get-ports": "^1.0.3", 24 | "ora": "^1.2.0", 25 | "pretty-bytes": "^4.0.2", 26 | "pretty-ms": "^2.1.0", 27 | "progress-stream": "^2.0.0", 28 | "snowden": "^1.0.1", 29 | "three": "^0.71.1", 30 | "three-simplicial-complex": "^69.0.6", 31 | "websocket-stream": "^5.0.0" 32 | }, 33 | "scripts": { 34 | "start": "node example/server.js example/client.js" 35 | }, 36 | "keywords": [ 37 | "fbo", 38 | "export", 39 | "save", 40 | "image", 41 | "threejs", 42 | "three", 43 | "three.js", 44 | "pixel", 45 | "pixels", 46 | "rgb", 47 | "rgba", 48 | "stream" 49 | ], 50 | "repository": { 51 | "type": "git", 52 | "url": "git://github.com/Jam3/three-png-stream.git" 53 | }, 54 | "homepage": "https://github.com/Jam3/three-png-stream", 55 | "bugs": { 56 | "url": "https://github.com/Jam3/three-png-stream/issues" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var PNGEncoder = require('png-stream/encoder') 2 | var glPixelStream = require('gl-pixel-stream') 3 | var assign = require('object-assign') 4 | 5 | var versionError = 'Could not find __webglFramebuffer on the target.\n' + 6 | 'Ensure you are using r69-r71 or r74+ of ThreeJS, and that you have\n' + 7 | 'already rendered to your WebGLRenderTarget like so:\n' + 8 | ' renderer.render(scene, camera, target);' 9 | 10 | module.exports = threePixelStream 11 | function threePixelStream (renderer, target, opt) { 12 | if (typeof THREE === 'undefined') throw new Error('THREE is not defined in global scope') 13 | if (!renderer || typeof renderer.getContext !== 'function') { 14 | throw new TypeError('Must specify a ThreeJS WebGLRenderer.') 15 | } 16 | 17 | var gl = renderer.getContext() 18 | if (!target) { 19 | throw new TypeError('Must specify WebGLRenderTarget,\npopulated with the contents for export.') 20 | } 21 | 22 | opt = opt || {} 23 | var format = opt.format 24 | if (!format && target.texture && target.texture.format) { 25 | format = target.texture.format 26 | } else if (!format) { 27 | format = target.format 28 | } 29 | 30 | var glFormat = getGLFormat(gl, format) 31 | var shape = [ target.width, target.height ] 32 | 33 | var framebuffer = target.__webglFramebuffer 34 | if (!framebuffer) { 35 | if (!renderer.properties) { 36 | throw new Error(versionError) 37 | } 38 | var props = renderer.properties.get(target) 39 | if (!props) throw new Error(versionError) 40 | framebuffer = props.__webglFramebuffer 41 | } 42 | 43 | opt = assign({ 44 | flipY: true 45 | }, opt, { 46 | format: glFormat 47 | }) 48 | 49 | var encoder = new PNGEncoder(shape[0], shape[1], { 50 | colorSpace: getColorSpace(gl, glFormat) 51 | }) 52 | 53 | var stream = glPixelStream(gl, framebuffer, shape, opt) 54 | stream.pipe(encoder) 55 | stream.on('error', function (err) { 56 | encoder.emit('error', err) 57 | }) 58 | return encoder 59 | } 60 | 61 | function getGLFormat (gl, format) { 62 | switch (format) { 63 | case THREE.RGBFormat: return gl.RGB 64 | case THREE.RGBAFormat: return gl.RGBA 65 | case THREE.LuminanceFormat: return gl.LUMINANCE 66 | case THREE.LuminanceAlphaFormat: return gl.LUMINANCE_ALPHA 67 | case THREE.AlphaFormat: return gl.ALPHA 68 | default: throw new TypeError('unsupported format ' + format) 69 | } 70 | } 71 | 72 | function getColorSpace (gl, format) { 73 | switch (format) { 74 | case gl.RGBA: return 'rgba' 75 | case gl.RGB: return 'rgb' 76 | case gl.LUMINANCE_ALPHA: return 'graya' 77 | case gl.LUMINANCE: 78 | case gl.ALPHA: 79 | return 'gray' 80 | default: 81 | throw new TypeError('unsupported format option ' + format) 82 | } 83 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-png-stream 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | Streams a PNG encoded pixels from a ThreeJS `WebGLRenderTarget`. This is done in chunks of `gl.readPixels`, using [gl-pixel-stream](https://github.com/Jam3/gl-pixel-stream), and works with render targets upwards of 10000x10000 pixels in Chrome (or more, depending on your GPU). 6 | 7 | The following transparent PNG image was generated with ThreeJS on the client side using the [example/](./example) code. See [Running from Source](#running-from-source) for details. 8 | 9 | 10 | 11 | > *Note:* This only works on Three r69-71 and 74+. 12 | 13 | ## Install 14 | 15 | ```sh 16 | npm install three-png-stream --save 17 | ``` 18 | 19 | ## Example 20 | 21 | ```js 22 | var pngStream = require('three-png-stream') 23 | 24 | // this will decide the output image size 25 | var target = new THREE.WebGLRenderTarget(512, 512) 26 | 27 | // draw your scene into the target 28 | renderer.render(scene, camera, target) 29 | 30 | // now you can write it to a new PNG file 31 | var output = fs.createWriteStream('image.png') 32 | pngStream(renderer, target) 33 | .pipe(output) 34 | ``` 35 | 36 | ## Usage 37 | 38 | [![NPM](https://nodei.co/npm/three-png-stream.png)](https://www.npmjs.com/package/three-png-stream) 39 | 40 | #### `stream = pngStream(renderer, target, [opt])` 41 | 42 | Creates a new `stream` which reads pixel data from `target` in chunks, writing PNG encoded data. 43 | 44 | - `renderer` is the WebGLRenderer of ThreeJS 45 | - `target` is the WebGLRenderTarget; you must render to it first! 46 | - `opt` are some optional settings: 47 | - `chunkSize` number of rows of pixels to read per chunk, default 128 48 | - `flipY` whether to flip the output on the Y axis, default `true` 49 | - `format` a THREE texture format to use, defaults to the format in `target` 50 | - `stride` the number of channels per pixel, guessed from the format (default 4) 51 | - `onProgress` the progress function for `gl-pixel-stream`, which has an event parameter with `current`, `total` and `bounds` for the current readPixel boudns 52 | 53 | ## Running From Source 54 | 55 | Clone and install: 56 | 57 | ```sh 58 | git clone https://github.com/Jam3/three-png-stream.git 59 | cd three-png-stream 60 | npm install 61 | ``` 62 | 63 | Now run the following: 64 | 65 | ```sh 66 | npm run start 67 | ``` 68 | 69 | And open the development server at [http://localhost:9966/](http://localhost:9966/). Once the model appears, click anywhere to save a new `snowden.png` to the example folder. You can also change the `outputWidth` and `outputHeight`, the max size is generally GPU-dependent. This is best used in Chrome. 70 | 71 | ## License 72 | 73 | MIT, see [LICENSE.md](http://github.com/Jam3/three-png-stream/blob/master/LICENSE.md) for details. 74 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | const budo = require('budo') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const URL = require('url') 5 | const wsStream = require('websocket-stream') 6 | const getPorts = require('get-ports') 7 | const http = require('http') 8 | const qs = require('query-string') 9 | const createEnvify = require('envify/custom') 10 | const progressStream = require('progress-stream') 11 | const cliSpinner = require('ora') 12 | const prettyBytes = require('pretty-bytes') 13 | const prettyMS = require('pretty-ms') 14 | 15 | const noop = () => {} 16 | 17 | // We use a separate server for the websocket stuff 18 | // since it does not seem compatible alongside 'ws' module, 19 | // which is used by budo's LiveReload client 20 | const DEFAULT_BUDO_PORT = 9966 21 | const DEFAULT_IMAGE_PORT = 33049 22 | 23 | getPorts([ DEFAULT_BUDO_PORT, DEFAULT_IMAGE_PORT ], (err, ports) => { 24 | if (err) throw err 25 | const devPort = ports[0] 26 | const imgPort = ports[1] 27 | devServer(devPort, imgPort) 28 | imageServer(imgPort) 29 | }) 30 | 31 | function imageServer (port) { 32 | const server = http.createServer((req, res) => { 33 | res.writeHead(200, 'ok') 34 | res.end('Hello! This is the image server for WebGL file saving.') 35 | }); 36 | server.listen(port, () => { 37 | console.log(`Image server: http://localhost:${port}/`); 38 | }); 39 | 40 | wsStream.createServer({ 41 | perMessageDeflate: false, 42 | server: server 43 | }, function(stream, req) { 44 | const search = URL.parse(req.url).search; 45 | const query = qs.parse(search) 46 | handleImageStream(stream, query.file) 47 | }) 48 | } 49 | 50 | function handleImageStream (imageStream, fileName = 'test.png', cb) { 51 | cb = cb || noop 52 | const timeStart = Date.now() 53 | const file = path.resolve(__dirname, fileName) 54 | const fileRelative = path.relative(process.cwd(), file) 55 | const spinner = cliSpinner('Receiving canvas...').start() 56 | 57 | const writeStream = fs.createWriteStream(file) 58 | writeStream.once('error', err => { 59 | spinner.fail('ERROR saving PNG to: ' + fileRelative) 60 | console.error(err) 61 | cb(err) 62 | cb = noop 63 | }) 64 | writeStream.on('close', function () { 65 | const end = Date.now() 66 | spinner.succeed('Saved PNG canvas to: ' + fileRelative + ' in ' + prettyMS(end - timeStart)) 67 | cb(null) 68 | cb = noop 69 | }) 70 | 71 | const progress = progressStream({ 72 | time: 100 73 | }).on('progress', (ev) => { 74 | spinner.text = `Transferred: ${prettyBytes(ev.transferred)}` 75 | }); 76 | imageStream 77 | .pipe(progress) 78 | .pipe(writeStream) 79 | } 80 | 81 | function devServer (port, imagePort) { 82 | budo.cli(process.argv.slice(2), { 83 | live: true, 84 | port: port, 85 | portfind: false, 86 | dir: path.resolve(__dirname, 'app'), 87 | browserify: { 88 | transform: [ 89 | [ 'babelify', { presets: 'es2015' } ], 90 | createEnvify({ 91 | IMAGE_SERVER_PORT: imagePort 92 | }) 93 | ] 94 | }, 95 | serve: 'bundle.js' 96 | }) 97 | } -------------------------------------------------------------------------------- /example/client.js: -------------------------------------------------------------------------------- 1 | global.THREE = require('three') 2 | 3 | const assign = require('object-assign') 4 | const qs = require('query-string') 5 | const pngStream = require('../') 6 | const createComplex = require('three-simplicial-complex')(THREE) 7 | const snowden = require('snowden') 8 | const wsStream = require('websocket-stream') 9 | const noop = () => {} 10 | 11 | // Our WebGL renderer with alpha and device-scaled 12 | const renderer = new THREE.WebGLRenderer({ 13 | alpha: true, 14 | antialias: true 15 | }) 16 | 17 | // 3D camera 18 | const camera = new THREE.PerspectiveCamera(60, 1, 0.01, 1000) 19 | camera.position.set(0, 0, -9) 20 | camera.lookAt(new THREE.Vector3(0, 0, 0)) 21 | 22 | // our Snowden scene 23 | const scene = createScene() 24 | 25 | // output dimensions 26 | const gl = renderer.getContext() 27 | const maxSize = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) 28 | 29 | const outputWidth = 2048 30 | const outputHeight = 2048 31 | 32 | console.log('Max RenderBuffer Size:', maxSize) 33 | console.log('Output: %dx%d', outputWidth, outputHeight) 34 | 35 | // output framebuffer 36 | const target = new THREE.WebGLRenderTarget(outputWidth, outputHeight) 37 | target.generateMipmaps = false 38 | target.minFilter = THREE.LinearFilter 39 | target.format = THREE.RGBAFormat 40 | 41 | assign(document.body.style, { 42 | margin: '0', 43 | cursor: 'pointer', 44 | overflow: 'hidden' 45 | }) 46 | 47 | document.body.appendChild(renderer.domElement) 48 | 49 | resize() 50 | window.addEventListener('resize', resize) 51 | 52 | var loader = document.createElement('div') 53 | assign(loader.style, { 54 | width: '100%', 55 | height: '100%', 56 | background: 'rgba(0, 0, 0, 0.5)', 57 | position: 'absolute', 58 | left: '0', 59 | color: 'white', 60 | padding: '10px', 61 | font: '14px Helvetica, sans-serif', 62 | color: 'white', 63 | top: '0', 64 | display: 'none' 65 | }) 66 | document.body.appendChild(loader) 67 | 68 | // trigger a save on space key 69 | var saving = false 70 | renderer.domElement.addEventListener('click', ev => { 71 | if (saving) return 72 | loader.innerText = 'Saving...' 73 | loader.style.display = 'block' 74 | saving = true 75 | ev.preventDefault() 76 | save(() => { 77 | loader.style.display = 'none' 78 | saving = false 79 | }) 80 | }) 81 | 82 | function createScene () { 83 | const scene = new THREE.Scene() 84 | const geo = createComplex(snowden) 85 | geo.computeFaceNormals() 86 | 87 | const mat = new THREE.MeshLambertMaterial({ color: 0xffffff }) 88 | const mesh = new THREE.Mesh(geo, mat) 89 | mesh.position.y = 0.7 90 | mesh.rotation.y = -Math.PI + 0.1 91 | scene.add(mesh) 92 | 93 | const light = new THREE.HemisphereLight(0xe3c586, 0xcb3ac2, 1) 94 | scene.add(light) 95 | return scene 96 | } 97 | 98 | function resize () { 99 | const width = window.innerWidth 100 | const height = window.innerHeight 101 | camera.aspect = width / height 102 | camera.updateProjectionMatrix() 103 | renderer.setPixelRatio(window.devicePixelRatio) 104 | renderer.setSize(width, height) 105 | renderer.render(scene, camera) 106 | } 107 | 108 | function save (cb = noop) { 109 | // draw scene into render target 110 | camera.aspect = outputWidth / outputHeight 111 | camera.updateProjectionMatrix() 112 | console.log('Rendering...') 113 | renderer.render(scene, camera, target, true) 114 | gl.finish() 115 | renderer.setRenderTarget(null) 116 | 117 | resize() 118 | console.log('Saving PNG...') 119 | 120 | // pipe output into websocket 121 | const imageStream = pngStream(renderer, target, { 122 | chunkSize: 1024, 123 | onProgress: (ev) => { 124 | const { current, total } = ev 125 | loader.innerText = `Writing chunk ${current} of ${total}` 126 | console.log(loader.innerText) 127 | } 128 | }) 129 | 130 | const url = getURL({ 131 | file: 'snowden.png' 132 | }) 133 | const stream = wsStream(url) 134 | stream.on('error', err => { 135 | console.error(err) 136 | loader.innerText = err.message 137 | cb() 138 | cb = noop 139 | }).on('finish', () => { 140 | cb() 141 | cb = noop 142 | }) 143 | imageStream.on('error', err => console.error(err)) 144 | imageStream.pipe(stream) 145 | } 146 | 147 | function getURL (opt = {}) { 148 | const protocol = document.location.protocol 149 | const hostname = document.location.hostname 150 | const port = process.env.IMAGE_SERVER_PORT 151 | const host = hostname + ':' + port 152 | const isSSL = /^https:/i.test(protocol) 153 | const wsProtocol = isSSL ? 'wss://' : 'ws://' 154 | return wsProtocol + host + '/save?' + qs.stringify(opt) 155 | } 156 | --------------------------------------------------------------------------------