├── .gitignore ├── README.md ├── blender-to-obj.py ├── model.blend ├── monkey-hot-reload.gif ├── package.json ├── services ├── viewer.js └── watch-blender-file.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender -> WebGL Hot Reload Experiment | Part 1 2 | 3 | An experiment in hot reloading non-skinned models from Blender to a WebGL Scene 4 | 5 | [Read the Blog Post](http://chinedufn.com/blender-web-hot-reload-obj/) 6 | 7 | ![Monkey hot reload](monkey-hot-reload.gif) 8 | 9 | To run locally 10 | 11 | ```sh 12 | git clone https://github.com/chinedufn/blender-webgl-hot-reload-experiment 13 | cd blender-webgl-hot-reload-experiment 14 | npm install 15 | npm start 16 | ``` 17 | 18 | Whenever you save `model.blend` in Blender your browser should update with the new model 19 | -------------------------------------------------------------------------------- /blender-to-obj.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import sys 3 | 4 | argv = sys.argv 5 | # Get all args after `--` 6 | argv = argv[argv.index('--') + 1:] 7 | 8 | objFilePath = argv[0] 9 | 10 | bpy.ops.export_scene.obj( 11 | filepath=objFilePath, 12 | axis_forward="-Z", 13 | axis_up="Y", 14 | use_materials=False, 15 | use_triangles=True, 16 | use_edges=True, 17 | use_normals=True, 18 | use_mesh_modifiers=True, 19 | use_blen_objects=True 20 | ) 21 | -------------------------------------------------------------------------------- /model.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinedufn/blender-webgl-hot-reload-experiment/2f4e1563ece73a45b096e27b3cbc0b0957e4fcb2/model.blend -------------------------------------------------------------------------------- /monkey-hot-reload.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinedufn/blender-webgl-hot-reload-experiment/2f4e1563ece73a45b096e27b3cbc0b0957e4fcb2/monkey-hot-reload.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blender-webgl-hot-reload-experiment", 3 | "version": "0.0.1", 4 | "description": "An experiment in hot reloading OBJ models from Blender", 5 | "main": "watch-blender-file.js", 6 | "scripts": { 7 | "start:server": "node watch-blender-file.js", 8 | "start:client": "budo viewer.js --open --live", 9 | "start": "lil-pids services", 10 | "test": "standard" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/chinedufn/blender-webgl-hot-reload-experiment.git" 15 | }, 16 | "keywords": [ 17 | "blender", 18 | "webgl", 19 | "hot", 20 | "reload", 21 | "live", 22 | "wavefront", 23 | "obj" 24 | ], 25 | "author": "Chinedu Francis Nwafili ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/chinedufn/blender-webgl-hot-reload-experiment/issues" 29 | }, 30 | "homepage": "https://github.com/chinedufn/blender-webgl-hot-reload-experiment#readme", 31 | "devDependencies": { 32 | "budo": "^10.0.4", 33 | "lil-pids": "^2.6.0", 34 | "standard": "^10.0.3" 35 | }, 36 | "dependencies": { 37 | "chokidar": "^1.7.0", 38 | "cuid": "^1.3.8", 39 | "expand-vertex-data": "^1.1.2", 40 | "gl-mat4": "^1.1.4", 41 | "wavefront-obj-parser": "^2.0.0", 42 | "ws": "^3.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /services: -------------------------------------------------------------------------------- 1 | npm run start:server 2 | npm run start:client 3 | -------------------------------------------------------------------------------- /viewer.js: -------------------------------------------------------------------------------- 1 | var glMat4 = require('gl-mat4') 2 | var expandVertexData = require('expand-vertex-data') 3 | 4 | // Create a canvas to draw onto and add it into the page 5 | var canvas = document.createElement('canvas') 6 | canvas.width = 600 7 | canvas.height = 600 8 | document.body.append(canvas) 9 | 10 | // Add click controls to the canvas so that you can click and drag to move the camera 11 | var isDragging = false 12 | var xCameraRot = Math.PI / 3 13 | var yCameraRot = 0 14 | var lastX 15 | var lastY 16 | canvas.onmousedown = function (e) { 17 | isDragging = true 18 | lastX = e.pageX 19 | lastY = e.pageY 20 | } 21 | canvas.onmousemove = function (e) { 22 | if (isDragging) { 23 | xCameraRot += (e.pageY - lastY) / 60 24 | yCameraRot -= (e.pageX - lastX) / 60 25 | 26 | xCameraRot = Math.min(xCameraRot, Math.PI / 2.3) 27 | xCameraRot = Math.max(-0.5, xCameraRot) 28 | 29 | lastX = e.pageX 30 | lastY = e.pageY 31 | } 32 | } 33 | canvas.onmouseup = function () { 34 | isDragging = false 35 | } 36 | 37 | // Get a handle for WebGL context 38 | var gl = canvas.getContext('webgl') 39 | gl.clearColor(0.0, 0.0, 0.0, 1.0) 40 | gl.enable(gl.DEPTH_TEST) 41 | 42 | // Create a simple vertex shader to render our geometry 43 | var vertexGLSL = ` 44 | attribute vec3 aVertexPos; 45 | attribute vec3 aVertexNormal; 46 | uniform mat4 uMVMatrix; 47 | uniform mat4 uPMatrix; 48 | 49 | varying vec3 vNormal; 50 | varying vec3 vWorldSpacePos; 51 | 52 | void main (void) { 53 | gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPos, 1.0); 54 | 55 | vNormal = aVertexNormal; 56 | // World space is same as model space since model matrix is identity 57 | vWorldSpacePos = aVertexPos; 58 | } 59 | ` 60 | 61 | // Create a simple fragment shader with some lighting 62 | var fragmentGLSL = ` 63 | precision mediump float; 64 | 65 | uniform vec3 uLightPos; 66 | uniform vec3 uCameraPos; 67 | 68 | varying vec3 vNormal; 69 | varying vec3 vWorldSpacePos; 70 | 71 | void main (void) { 72 | vec3 ambient = vec3(0.24725, 0.1995, 0.0745); 73 | 74 | vec3 lightColor = vec3(1.0, 1.0, 1.0); 75 | 76 | vec3 normal = normalize(vNormal); 77 | vec3 lightDir = normalize(uLightPos - vWorldSpacePos); 78 | float diff = max(dot(normal, lightDir), 0.0); 79 | vec3 diffuse = diff * vec3(0.75164, 0.60648, 0.22648); 80 | 81 | float shininess = 0.4; 82 | vec3 viewDir = normalize(uCameraPos - vWorldSpacePos); 83 | vec3 reflectDir = reflect(-lightDir, normal); 84 | float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); 85 | vec3 specular = shininess * spec * vec3(0.628281, 0.555802, 0.366065); 86 | 87 | gl_FragColor = vec4(ambient + diffuse + specular, 1.0); 88 | } 89 | ` 90 | 91 | // Link our shader program 92 | var vertexShader = gl.createShader(gl.VERTEX_SHADER) 93 | gl.shaderSource(vertexShader, vertexGLSL) 94 | gl.compileShader(vertexShader) 95 | console.log(gl.getShaderInfoLog(vertexShader)) 96 | 97 | var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) 98 | gl.shaderSource(fragmentShader, fragmentGLSL) 99 | gl.compileShader(fragmentShader) 100 | console.log(gl.getShaderInfoLog(fragmentShader)) 101 | 102 | var shaderProgram = gl.createProgram() 103 | gl.attachShader(shaderProgram, vertexShader) 104 | gl.attachShader(shaderProgram, fragmentShader) 105 | gl.linkProgram(shaderProgram) 106 | gl.useProgram(shaderProgram) 107 | 108 | var vertexPosAttrib = gl.getAttribLocation(shaderProgram, 'aVertexPos') 109 | gl.enableVertexAttribArray(vertexPosAttrib) 110 | var vertexNormalAttrib = gl.getAttribLocation(shaderProgram, 'aVertexNormal') 111 | gl.enableVertexAttribArray(vertexNormalAttrib) 112 | 113 | // Create the buffers that will hold our vertex data when it loads 114 | var vertexPosBuffer = gl.createBuffer() 115 | var vertexNormalBuffer = gl.createBuffer() 116 | var vertexIndexBuffer = gl.createBuffer() 117 | 118 | // Get handles to our shader uniforms 119 | var mVMatrixUni = gl.getUniformLocation(shaderProgram, 'uMVMatrix') 120 | var pMatrixUni = gl.getUniformLocation(shaderProgram, 'uPMatrix') 121 | var lightPosUni = gl.getUniformLocation(shaderProgram, 'uLightPos') 122 | var cameraPosUni = gl.getUniformLocation(shaderProgram, 'uCameraPos') 123 | 124 | // Set up our perspective matrix 125 | gl.uniformMatrix4fv(pMatrixUni, false, glMat4.perspective([], Math.PI / 3, 1, 0.1, 100)) 126 | 127 | // Open up a websocket connection to our hot reload server. 128 | // Whenever our server sends us new vertex data we'll update our GPU buffers with the new data. 129 | // Then, next time we draw, this new vertex data will be used. This is the essence of hot-reloading 130 | // our 3D models 131 | var ws = new window.WebSocket('ws://127.0.0.1:8989') 132 | ws.onmessage = function (message) { 133 | var vertexData = JSON.parse(message.data) 134 | vertexData = expandVertexData(vertexData, {facesToTriangles: true}) 135 | 136 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexPosBuffer) 137 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData.positions), gl.STATIC_DRAW) 138 | gl.vertexAttribPointer(vertexPosAttrib, 3, gl.FLOAT, false, 0, 0) 139 | 140 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexNormalBuffer) 141 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData.normals), gl.STATIC_DRAW) 142 | gl.vertexAttribPointer(vertexNormalAttrib, 3, gl.FLOAT, false, 0, 0) 143 | 144 | gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vertexIndexBuffer) 145 | gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(vertexData.positionIndices), gl.STATIC_DRAW) 146 | 147 | // Keep track of how many indices we need to draw when we call drawElements 148 | numIndicesToDraw = vertexData.positionIndices.length 149 | } 150 | 151 | var numIndicesToDraw 152 | function draw () { 153 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) 154 | 155 | // Create our camera based on how much the user has dragged the canvas 156 | var camera = glMat4.create() 157 | var xCameraMatrix = glMat4.create() 158 | var yCameraMatrix = glMat4.create() 159 | glMat4.translate(camera, camera, [0, 0, 8]) 160 | glMat4.rotateX(xCameraMatrix, xCameraMatrix, -xCameraRot) 161 | glMat4.rotateY(yCameraMatrix, yCameraMatrix, yCameraRot) 162 | glMat4.multiply(camera, xCameraMatrix, camera) 163 | glMat4.multiply(camera, yCameraMatrix, camera) 164 | 165 | // We use the camera position uniform to calculate our specular lighting 166 | gl.uniform3fv(cameraPosUni, [camera[12], camera[13], camera[14]]) 167 | 168 | camera = glMat4.lookAt([], [camera[12], camera[13], camera[14]], [0, 0, 0], [0, 1, 0]) 169 | gl.uniformMatrix4fv(mVMatrixUni, false, camera) 170 | 171 | var worldSpaceLightPos = [-2, 5, 2] 172 | gl.uniform3fv(lightPosUni, worldSpaceLightPos) 173 | 174 | if (numIndicesToDraw) { 175 | gl.drawElements(gl.TRIANGLES, numIndicesToDraw, gl.UNSIGNED_SHORT, 0) 176 | } 177 | 178 | window.requestAnimationFrame(draw) 179 | } 180 | draw() 181 | -------------------------------------------------------------------------------- /watch-blender-file.js: -------------------------------------------------------------------------------- 1 | var chokidar = require('chokidar') 2 | var cp = require('child_process') 3 | var cuid = require('cuid') 4 | var fs = require('fs') 5 | 6 | // Keep track of connected clients so that we can send the vertex data to every connected browser tab 7 | var connectedClients = {} 8 | 9 | // Watch our blend file for changes 10 | chokidar.watch('./*.blend', {}) 11 | .on('change', function (blenderFilePath) { 12 | var modelName = blenderFilePath.split('.blend')[0] 13 | var wavefrontPath = modelName + '.obj' 14 | var jsonPath = modelName + '.json' 15 | 16 | // Use the blender CLI to export our .blend model as OBJ 17 | cp.exec( 18 | // Make sure that `blender` is in your PATH. 19 | // On mac you can try adding the following to your ~/.bash_profile: 20 | // # Blender CLI 21 | // export PATH="$PATH:/Applications/blender.app/Contents/MacOS" 22 | `blender ${blenderFilePath} --background --python blender-to-obj.py -- ${wavefrontPath}`, 23 | function (err, stdout, stderr) { 24 | if (err) { 25 | return console.error(`exec error: ${err}`) 26 | } 27 | // Write to stdout just for some quick debugging of our experiment 28 | console.log(`stdout: ${stdout}`) 29 | 30 | // Convert OBJ file into JSON using wavefront-obj-parser 31 | cp.exec( 32 | `cat ${wavefrontPath} | node ./node_modules/wavefront-obj-parser/bin/obj2json.js > ${jsonPath}`, 33 | function (err, stdout, stderr) { 34 | if (err) { throw err } 35 | 36 | // Send JSON file to connected clients 37 | fs.readFile(jsonPath, function (err, jsonModelFile) { 38 | if (err) { throw err } 39 | 40 | for (var clientId in connectedClients) { 41 | if (connectedClients[clientId].readyState === WebSocket.OPEN) { 42 | connectedClients[clientId].send( 43 | jsonModelFile.toString() 44 | ) 45 | } 46 | } 47 | }) 48 | } 49 | ) 50 | } 51 | ) 52 | }) 53 | 54 | var WebSocket = require('ws') 55 | var wsServer = new WebSocket.Server({port: 8989}) 56 | 57 | // Start WebSocket server and keep track of currently connected clients 58 | wsServer.on('connection', function (ws) { 59 | ws.clientId = cuid() 60 | connectedClients[ws.clientId] = ws 61 | 62 | ws.on('close', function () { 63 | delete connectedClients[ws.clientId] 64 | }) 65 | }) 66 | --------------------------------------------------------------------------------