├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── demo.frag ├── demo.js ├── index.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | image.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gl-pixel-stream 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | Streams chunks of `gl.readPixels` from the specified FrameBuffer Object. This is primarily useful for exporting WebGL scenes and textures to high resolution images (i.e. print-ready). 6 | 7 | Before calling this method, ensure your FBO is populated with the content you wish to export. On each chunk, this will bind the given FBO, set the viewport, read the new pixels, and then unbind all FBOs. 8 | 9 | The following image was generated with the [demo.js](./demo.js) in this module. This approach can render upwards of 10000x10000 images on a late 2013 MacBookPro. 10 | 11 | ![earth](http://i.imgur.com/ee6nE6i.png) 12 | 13 | [(download full 3200x1800 image)](https://www.dropbox.com/s/crojjnh5in2bgsi/gl-pixel-stream.png?dl=0) 14 | 15 | ## Install 16 | 17 | ```sh 18 | npm install gl-pixel-stream --save 19 | ``` 20 | 21 | ## Example 22 | 23 | A simple example with [gl-fbo](https://github.com/stackgl/gl-fbo) might look like this: 24 | 25 | ```js 26 | var pixelStream = require('gl-pixel-stream') 27 | 28 | // bind your FBO 29 | fbo.bind() 30 | 31 | // draw your scene to it 32 | drawScene() 33 | 34 | // get a pixel stream 35 | var stream = pixelStream(gl, fbo.handle, fbo.shape) 36 | 37 | // pipe it out somewhere 38 | stream.pipe(output) 39 | ``` 40 | 41 | A more practical example involves streaming through [png-stream/encoder](https://github.com/devongovett/png-stream) to a write stream. See [demo.js](./demo.js) for an example of this, which uses Electron (through [hihat](https://github.com/Jam3/hihat)) to merge the WebGL and Node.js APIs. 42 | 43 | See [Running From Source](#running-from-source) for details. 44 | 45 | ## Usage 46 | 47 | [![NPM](https://nodei.co/npm/gl-pixel-stream.png)](https://www.npmjs.com/package/gl-pixel-stream) 48 | 49 | #### `stream = glPixelStream(gl, fboHandle, shape, [opts])` 50 | 51 | Creates a new stream which streams the data from `gl.readPixels`, reading from the given FrameBuffer. It is assumed to already be populated with your scene/texture. 52 | 53 | The stream emits a `Buffer` containing the uint8 pixels, default RGBA. 54 | 55 | - `gl` (required) the WebGL context 56 | - `fboHandle` (required) the handle for the WebGLFramebuffer instance 57 | - `shape` (required) an Array, the `[width, height]` of the output 58 | - `opts` (optional) additional settings 59 | 60 | The additional settings can be: 61 | 62 | - `chunkSize` (Number) the number of rows to fetch from the GPU in a single call to `gl.readPixels`, defaults to 128 63 | - `flipY` (Boolean) whether to flip the output image on the Y axis (default false) 64 | - `format` a WebGL format like `gl.RGBA` or `gl.RGB` for reading, default `gl.RGBA` 65 | - `stride` (Number) the number of channels in a pixel, guessed from the specified `format`, or defaults to `4` 66 | - `onProgress` (Function) a function that has an `event` parameter with `current` and `total` chunk count, as well as `bounds` array with `[ x, y, width, height ]` from readPixels 67 | 68 | ## Running from Source 69 | 70 | Clone and install: 71 | 72 | ```sh 73 | git clone https://github.com/Jam3/gl-pixel-stream.git 74 | cd gl-pixel-stream 75 | npm install 76 | ``` 77 | 78 | To run the tests: 79 | 80 | ```sh 81 | npm run test 82 | ``` 83 | 84 | To run the demo in "production" mode (no DevTools window). This will output an `image.png` in the current folder. 85 | 86 | ```sh 87 | npm run start 88 | ``` 89 | 90 | To run the demo in "development" mode. This opens a DevTools window and reloads the bundle on `demo.js` file-save. 91 | 92 | ```sh 93 | npm run dev 94 | ``` 95 | 96 | The output `image.png` should look like this, and be the size specified in the `demo.js` file: 97 | 98 | ![earth](http://i.imgur.com/ee6nE6i.png) 99 | 100 | ## License 101 | 102 | MIT, see [LICENSE.md](http://github.com/Jam3/gl-pixel-stream/blob/master/LICENSE.md) for details. 103 | -------------------------------------------------------------------------------- /demo.frag: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | uniform float iGlobalTime; 4 | uniform vec2 iResolution; 5 | 6 | #pragma glslify: planet = require('glsl-earth') 7 | 8 | void main() { 9 | vec2 uv = gl_FragCoord.xy / iResolution.xy; 10 | 11 | //% of screen 12 | float size = 0.75; 13 | 14 | //create our planet 15 | gl_FragColor.rgb = planet(uv, iResolution.xy, size); 16 | gl_FragColor.a = 1.0; 17 | } -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | 3 | var pixelStream = require('./') 4 | var PNGEncoder = require('png-stream/encoder') 5 | var createFBO = require('gl-fbo') 6 | var createGL = require('webgl-context') 7 | var createShader = require('gl-shader') 8 | 9 | var triangle = require('a-big-triangle') 10 | var glslify = require('glslify') 11 | var vert = glslify('glsl-shader-basic/vert.glsl') 12 | var frag = glslify(__dirname + '/demo.frag') 13 | 14 | render() 15 | 16 | function render () { 17 | var shape = [640, 360] 18 | var gl = createGL() 19 | var fbo = createFBO(gl, shape) 20 | var shader = createShader(gl, vert, frag) 21 | 22 | // use DevTools for timing info 23 | console.time('render') 24 | 25 | // render scene into frame buffer 26 | fbo.bind() 27 | renderScene() 28 | 29 | // create a write stream for the file 30 | var output = fs.createWriteStream('image.png') 31 | 32 | // create a PNG encoder stream 33 | var encoder = new PNGEncoder(shape[0], shape[1], { 34 | colorSpace: 'rgba' 35 | }) 36 | 37 | // create a readPixels stream for the FBO 38 | // flip image on Y axis due to FBO coordinates 39 | var pixels = pixelStream(gl, fbo.handle, fbo.shape, { 40 | flipY: true 41 | }) 42 | 43 | // send pixels to encoder, then to file 44 | pixels.pipe(encoder) 45 | encoder.pipe(output) 46 | 47 | // when file writing is finished 48 | output.on('close', function () { 49 | console.log('Saved %dx%d buffer to image.png', shape[0], shape[1]) 50 | console.timeEnd('render') 51 | 52 | // quit the dev tools window when not in dev mode 53 | if (process.env.NODE_ENV === 'production') { 54 | window.close() 55 | } 56 | }) 57 | 58 | // can be any type of render function 59 | function renderScene () { 60 | gl.clearColor(0, 0, 0, 1) 61 | gl.clear(gl.COLOR_BUFFER_BIT) 62 | gl.viewport(0, 0, shape[0], shape[1]) 63 | shader.bind() 64 | shader.uniforms.iResolution = shape 65 | shader.uniforms.iGlobalTime = 0 66 | triangle(gl) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Readable = require('readable-stream').Readable 2 | var DEFAULT_CHUNK_SIZE = 128 3 | 4 | module.exports = glPixelStream 5 | function glPixelStream (gl, fboHandle, size, opt) { 6 | if (!gl) { 7 | throw new TypeError('must specify gl context') 8 | } 9 | if (typeof fboHandle === 'undefined') { 10 | throw new TypeError('must specify a FrameBufferObject handle') 11 | } 12 | if (!Array.isArray(size)) { 13 | throw new TypeError('must specify a [width, height] size') 14 | } 15 | 16 | opt = opt || {} 17 | 18 | var width = Math.floor(size[0]) 19 | var height = Math.floor(size[1]) 20 | var flipY = opt.flipY 21 | var format = opt.format || gl.RGBA 22 | var stride = typeof opt.stride === 'number' 23 | ? opt.stride : guessStride(gl, format) 24 | var chunkSize = typeof opt.chunkSize === 'number' 25 | ? opt.chunkSize : DEFAULT_CHUNK_SIZE 26 | var onProgress = opt.onProgress 27 | 28 | // clamp chunk size 29 | chunkSize = Math.min(Math.floor(chunkSize), height) 30 | 31 | var totalChunks = Math.ceil(height / chunkSize) 32 | var currentChunk = 0 33 | var stream = new Readable() 34 | stream._read = read 35 | return stream 36 | 37 | function read () { 38 | if (currentChunk > totalChunks - 1) { 39 | return process.nextTick(function () { 40 | stream.push(null) 41 | }) 42 | } 43 | 44 | gl.bindFramebuffer(gl.FRAMEBUFFER, fboHandle) 45 | var status = gl.checkFramebufferStatus(gl.FRAMEBUFFER) 46 | if (status !== gl.FRAMEBUFFER_COMPLETE) { 47 | var self = this 48 | return process.nextTick(function () { 49 | self.emit('error', new Error('Framebuffer not complete, cannot gl.readPixels')) 50 | }) 51 | } 52 | 53 | var yOffset = chunkSize * currentChunk 54 | var dataHeight = Math.min(chunkSize, height - yOffset) 55 | if (flipY) { 56 | yOffset = height - yOffset - dataHeight 57 | } 58 | 59 | var outBuffer = new Buffer(width * dataHeight * stride) 60 | gl.viewport(0, 0, width, height) 61 | gl.readPixels(0, yOffset, width, dataHeight, format, gl.UNSIGNED_BYTE, outBuffer) 62 | gl.bindFramebuffer(gl.FRAMEBUFFER, null) 63 | 64 | var rowBuffer = outBuffer 65 | if (flipY) { 66 | flipVertically(outBuffer, width, dataHeight, stride) 67 | } 68 | currentChunk++ 69 | if (typeof onProgress === 'function') { 70 | onProgress({ 71 | bounds: [ 0, yOffset, width, dataHeight ], 72 | current: currentChunk, 73 | total: totalChunks 74 | }) 75 | } 76 | stream.push(rowBuffer) 77 | } 78 | } 79 | 80 | function flipVertically (pixels, width, height, stride) { 81 | var rowLength = width * stride 82 | var temp = Buffer.allocUnsafe(rowLength) 83 | var halfRows = Math.floor(height / 2) 84 | for (var rowIndex = 0; rowIndex < halfRows; rowIndex++) { 85 | var otherRowIndex = height - rowIndex - 1; 86 | 87 | var curRowStart = rowLength * rowIndex; 88 | var curRowEnd = curRowStart + rowLength; 89 | var otherRowStart = rowLength * otherRowIndex; 90 | var otherRowEnd = otherRowStart + rowLength; 91 | 92 | // copy current row into temp 93 | pixels.copy(temp, 0, curRowStart, curRowEnd) 94 | // now copy other row into current row 95 | pixels.copy(pixels, curRowStart, otherRowStart, otherRowEnd) 96 | // and now copy temp back to other slot 97 | temp.copy(pixels, otherRowStart, 0, rowLength) 98 | } 99 | } 100 | 101 | function guessStride (gl, format) { 102 | switch (format) { 103 | case gl.RGB: 104 | return 3 105 | case gl.LUMINANCE_ALPHA: 106 | return 2 107 | case gl.ALPHA: 108 | case gl.LUMINANCE: 109 | return 1 110 | default: 111 | return 4 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gl-pixel-stream", 3 | "version": "1.2.0", 4 | "description": "streaming gl.readPixels from an FBO", 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 | "readable-stream": "^2.0.3" 14 | }, 15 | "devDependencies": { 16 | "a-big-triangle": "^1.0.2", 17 | "concat-stream": "^1.5.1", 18 | "faucet": "0.0.1", 19 | "gl-fbo": "^2.0.5", 20 | "gl-shader": "^4.0.5", 21 | "glsl-earth": "^1.0.2", 22 | "glsl-film-grain": "^1.0.2", 23 | "glsl-shader-basic": "^1.0.0", 24 | "glslify": "^2.3.1", 25 | "png-stream": "^1.0.4", 26 | "tape": "^4.2.2", 27 | "webgl-context": "^2.2.0" 28 | }, 29 | "scripts": { 30 | "dev": "hihat demo.js --node --browser-field -- -t glslify", 31 | "start": "NODE_ENV=production hihat demo.js --exec --node --browser-field -- -t glslify", 32 | "test": "hihat test.js --exec --timeout=1000 | faucet" 33 | }, 34 | "keywords": [ 35 | "streaming", 36 | "fbo", 37 | "stackgl", 38 | "readPixels", 39 | "gl", 40 | "webgl", 41 | "glsl", 42 | "save", 43 | "export", 44 | "write", 45 | "render", 46 | "png", 47 | "image", 48 | "framebuffer" 49 | ], 50 | "repository": { 51 | "type": "git", 52 | "url": "git://github.com/Jam3/gl-pixel-stream.git" 53 | }, 54 | "homepage": "https://github.com/Jam3/gl-pixel-stream", 55 | "bugs": { 56 | "url": "https://github.com/Jam3/gl-pixel-stream/issues" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var pixelStream = require('./') 2 | var test = require('tape') 3 | var FBO = require('gl-fbo') 4 | var getGL = require('webgl-context') 5 | var concat = require('concat-stream') 6 | 7 | test('streaming gl.readPixels from an FBO', run(new Buffer([ 8 | 0, 255, 0, 255, 9 | 0, 255, 0, 255, 10 | 0, 0, 0, 255, 11 | 0, 0, 0, 255 12 | ]))) 13 | 14 | test('streaming gl.readPixels from an FBO', run(new Buffer([ 15 | 0, 0, 0, 255, 16 | 0, 0, 0, 255, 17 | 0, 255, 0, 255, 18 | 0, 255, 0, 255 19 | ]), { flipY: true })) 20 | 21 | function run (expected, opts) { 22 | return function (t) { 23 | t.plan(1) 24 | var gl = getGL() 25 | 26 | var width = 2 27 | var height = 2 28 | var shape = [width, height] 29 | 30 | var fbo = FBO(gl, shape) 31 | 32 | fbo.bind() 33 | gl.viewport(0, 0, width, height) 34 | // all black 35 | gl.clearColor(0, 0, 0, 1) 36 | gl.clear(gl.COLOR_BUFFER_BIT) 37 | // green 38 | gl.clearColor(0, 1, 0, 1) 39 | gl.enable(gl.SCISSOR_TEST) 40 | gl.scissor(0, 0, width, height/2) 41 | gl.clear(gl.COLOR_BUFFER_BIT) 42 | gl.disable(gl.SCISSOR_TEST) 43 | 44 | pixelStream(gl, fbo.handle, shape, opts) 45 | .pipe(concat(function (body) { 46 | t.deepEqual(body, expected, 'matches pixels') 47 | })) 48 | } 49 | } 50 | --------------------------------------------------------------------------------