├── .gitignore ├── README.md ├── fire-texture-atlas.jpg ├── package.json └── tutorial.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebGL Particle Effect Billboarding Tutorial 2 | 3 | ## To read the tutorial 4 | 5 | http://www.chinedufn.com/webgl-particle-effect-billboard-tutorial/ 6 | 7 | ## To run locally 8 | 9 | ``` 10 | git clone https://github.com/chinedufn/webgl-particle-effect-tutorial 11 | cd webgl-particle-effect-tutorial 12 | npm install 13 | npm run start 14 | ``` 15 | 16 | ## License 17 | 18 | MIT 19 | -------------------------------------------------------------------------------- /fire-texture-atlas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinedufn/webgl-particle-effect-tutorial/1db068adb90ebe9289cbf00af2bf45d57474ce3e/fire-texture-atlas.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgl-particle-effect-tutorial", 3 | "version": "0.0.1", 4 | "description": "A tutorial for creating a WebGL fire particle effect", 5 | "main": "tutorial.js", 6 | "scripts": { 7 | "start": "budo tutorial.js --open --live", 8 | "test": "standard" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/chinedufn/webgl-particle-effect-tutorial.git" 13 | }, 14 | "keywords": [ 15 | "webgl", 16 | "particle", 17 | "effect", 18 | "fire", 19 | "billboard", 20 | "quad", 21 | "opengl", 22 | "flame", 23 | "tutorial" 24 | ], 25 | "author": "Chinedu Francis Nwafili ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/chinedufn/webgl-particle-effect-tutorial/issues" 29 | }, 30 | "homepage": "https://github.com/chinedufn/webgl-particle-effect-tutorial#readme", 31 | "dependencies": { 32 | "gl-mat4": "^1.1.4" 33 | }, 34 | "devDependencies": { 35 | "budo": "^10.0.4", 36 | "standard": "^10.0.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tutorial.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Section 1 - Getting our interface set up 3 | */ 4 | 5 | // gl-mat4 is a collection of different 4x4 matrix math operations 6 | // You can find it at -> https://github.com/stackgl/gl-mat4 7 | var glMat4 = require('gl-mat4') 8 | 9 | // We'll use this variable to enable or disable billboarding whenever we 10 | // click on a button 11 | var billboardingEnabled = true 12 | 13 | // We create a canvas that we'll render our particle effect onto 14 | var canvas = document.createElement('canvas') 15 | canvas.width = 500 16 | canvas.height = 500 17 | var mountLocation = document.getElementById('webgl-particle-effect-tutorial') || document.body 18 | 19 | // Make the button that lets us turn billboarding on and off when we click it 20 | var billboardButton = document.createElement('button') 21 | billboardButton.innerHTML = 'Click to disable billboarding' 22 | billboardButton.style.display = 'block' 23 | billboardButton.style.cursor = 'pointer' 24 | billboardButton.style.marginBottom = '3px' 25 | billboardButton.style.height = '40px' 26 | billboardButton.style.width = '160px' 27 | billboardButton.onclick = function () { 28 | billboardingEnabled = !billboardingEnabled 29 | billboardButton.innerHTML = (billboardingEnabled ? 'Click to disable billboarding' : 'Click to enable billboarding') 30 | } 31 | 32 | // Add our button and canvas into the page 33 | mountLocation.appendChild(billboardButton) 34 | mountLocation.appendChild(canvas) 35 | 36 | /** 37 | * Section 2 - Canvas mouse / touch movement controls 38 | */ 39 | var isDragging = false 40 | 41 | // Our rotation about the x and y axes of the world 42 | var xRotation = 0 43 | var yRotation = 0 44 | 45 | // The last x and y coordinate in the page that we moved 46 | // our mouse or finger. We use this to know much much you've 47 | // dragged the canvas 48 | var lastMouseX = 0 49 | var lastMouseY = 0 50 | 51 | // When you mouse down we begin dragging 52 | canvas.onmousedown = function (e) { 53 | isDragging = true 54 | lastMouseX = e.pageX 55 | lastMouseY = e.pageY 56 | } 57 | // As you move your mouse we adjust the x and y rotation of 58 | // our camera around the world x and y axes 59 | canvas.onmousemove = function (e) { 60 | if (isDragging) { 61 | xRotation += (e.pageY - lastMouseY) / 50 62 | yRotation -= (e.pageX - lastMouseX) / 50 63 | 64 | xRotation = Math.min(xRotation, Math.PI / 2.5) 65 | xRotation = Math.max(xRotation, -Math.PI / 2.5) 66 | 67 | lastMouseX = e.pageX 68 | lastMouseY = e.pageY 69 | } 70 | } 71 | // When you let go of your click we stop dragging the scene 72 | canvas.onmouseup = function (e) { 73 | isDragging = false 74 | } 75 | 76 | // As you drag your finger we move the camera 77 | canvas.addEventListener('touchstart', function (e) { 78 | lastMouseX = e.touches[0].clientX 79 | lastMouseY = e.touches[0].clientY 80 | }) 81 | canvas.addEventListener('touchmove', function (e) { 82 | e.preventDefault() 83 | xRotation += (e.touches[0].clientY - lastMouseY) / 50 84 | yRotation -= (e.touches[0].clientX - lastMouseX) / 50 85 | 86 | xRotation = Math.min(xRotation, Math.PI / 2.5) 87 | xRotation = Math.max(xRotation, -Math.PI / 2.5) 88 | 89 | lastMouseX = e.touches[0].clientX 90 | lastMouseY = e.touches[0].clientY 91 | }) 92 | 93 | /** 94 | * Section 3 - Setting up our shader 95 | */ 96 | 97 | // We get our canvas' WebGL context so that we can render onto it's 98 | // drawing buffer 99 | var gl = canvas.getContext('webgl') 100 | gl.clearColor(0.0, 0.0, 0.0, 1.0) 101 | gl.viewport(0, 0, 500, 500) 102 | 103 | // Let's create our particles' vertex shader. This is the meat of our 104 | // simulation. 105 | var vertexGLSL = ` 106 | // The current time in our simulation. A particle's 107 | // position is a function of the current time. 108 | uniform float uTime; 109 | 110 | // The location of the center of the fire 111 | uniform vec3 uFirePos; 112 | 113 | // The random amount of time that this particle should 114 | // live before re-starting it's motion. 115 | attribute float aLifetime; 116 | 117 | // The uv coordinates of this vertex 118 | attribute vec2 aTextureCoords; 119 | 120 | // How far this vertex is from the center of this invidual particle 121 | attribute vec2 aTriCorner; 122 | 123 | // How far this particle starts from the center of the entire flame 124 | attribute vec3 aCenterOffset; 125 | 126 | // The randomly generated velocity of the particle 127 | attribute vec3 aVelocity; 128 | 129 | // Our perspective and world view matrix 130 | uniform mat4 uPMatrix; 131 | uniform mat4 uViewMatrix; 132 | 133 | // Whether or not to make our particles face the camera. This 134 | // is used to illustrate the difference between billboarding and 135 | // not billboarding your particle quads. 136 | uniform bool uUseBillboarding; 137 | 138 | // We pass the lifetime and uv coordinates to our fragment shader 139 | varying float vLifetime; 140 | varying vec2 vTextureCoords; 141 | 142 | void main (void) { 143 | // Loop the particle through it's lifetime by using the modulus 144 | // of the current time and the lifetime 145 | float time = mod(uTime, aLifetime); 146 | 147 | // We start by positioning our particle at the fire's position. We then 148 | // multiply it's velocity by the amount of time elapsed to move it along 149 | // it's trajectory 150 | vec4 position = vec4(uFirePos + aCenterOffset + (time * aVelocity), 1.0); 151 | 152 | // Calculate a size for our particle. As it ages we make it smaller. I wrote 153 | // this before I really understood what I was doing so the it's a little 154 | // unclear.. but I don't want to tamper with it since I like the effect so 155 | // *shrug*. 156 | vLifetime = 1.3 - (time / aLifetime); 157 | vLifetime = clamp(vLifetime, 0.0, 1.0); 158 | float size = (vLifetime * vLifetime) * 0.05; 159 | 160 | // If we want to use billboarding we get the right and up world space vectors for 161 | // our camera and use that to align our vertex so our particle faces the camera 162 | if (uUseBillboarding) { 163 | vec3 cameraRight = vec3(uViewMatrix[0].x, uViewMatrix[1].x, uViewMatrix[2].x); 164 | vec3 cameraUp = vec3(uViewMatrix[0].y, uViewMatrix[1].y, uViewMatrix[2].y); 165 | 166 | position.xyz += (cameraRight * aTriCorner.x * size) + (cameraUp * aTriCorner.y * size); 167 | } else { 168 | // If billboarding is not enabled we align our vertex along the XY plane 169 | position.xy += aTriCorner.xy * size; 170 | } 171 | 172 | // Position our vertex in clip space 173 | gl_Position = uPMatrix * uViewMatrix * position; 174 | 175 | vTextureCoords = aTextureCoords; 176 | vLifetime = aLifetime; 177 | } 178 | ` 179 | 180 | var fragmentGLSL = ` 181 | precision mediump float; 182 | 183 | // Adjust the color of the fire 184 | uniform vec4 uColor; 185 | 186 | uniform float uTimeFrag; 187 | 188 | varying float vLifetime; 189 | varying vec2 vTextureCoords; 190 | 191 | uniform sampler2D fireAtlas; 192 | 193 | void main (void) { 194 | // So as I was learning I threw this in and I liked how it looked. 195 | // This doesn't make much sense since we aren't even calculating a life 196 | // percentage.. but I'll leave it. Sometimes you achieve effects that you like 197 | // by accident *shrug* 198 | float time = mod(uTimeFrag, vLifetime); 199 | float percentOfLife = time / vLifetime; 200 | percentOfLife = clamp(percentOfLife, 0.0, 1.0); 201 | 202 | // Ok so the first part of this fragment shader is bogus.. but let's move on. Here we 203 | // decide which of the 16 textures in our texture atlas to use based on how far along 204 | // in the particle's life we are. As it ages we move through the fire sprites in the 205 | // atlas. 206 | float offset = floor(16.0 * percentOfLife); 207 | float offsetX = floor(mod(offset, 4.0)) / 4.0; 208 | float offsetY = 0.75 - floor(offset / 4.0) / 4.0; 209 | 210 | // Set the frag color to the fragment in the sprite within our texture atlas 211 | vec4 texColor = texture2D(fireAtlas, vec2((vTextureCoords.x / 4.0) + offsetX, (vTextureCoords.y / 4.0) + offsetY)); 212 | gl_FragColor = uColor * texColor; 213 | 214 | // Fade away the particle as it ages 215 | gl_FragColor.a *= vLifetime; 216 | } 217 | ` 218 | 219 | // Initialize our vertex shader 220 | var vertexShader = gl.createShader(gl.VERTEX_SHADER) 221 | gl.shaderSource(vertexShader, vertexGLSL) 222 | gl.compileShader(vertexShader) 223 | 224 | var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) 225 | gl.shaderSource(fragmentShader, fragmentGLSL) 226 | gl.compileShader(fragmentShader) 227 | 228 | var shaderProgram = gl.createProgram() 229 | gl.attachShader(shaderProgram, vertexShader) 230 | gl.attachShader(shaderProgram, fragmentShader) 231 | gl.linkProgram(shaderProgram) 232 | gl.useProgram(shaderProgram) 233 | 234 | // Enable all of our vertex attributes 235 | var lifetimeAttrib = gl.getAttribLocation(shaderProgram, 'aLifetime') 236 | var texCoordAttrib = gl.getAttribLocation(shaderProgram, 'aTextureCoords') 237 | var triCornerAttrib = gl.getAttribLocation(shaderProgram, 'aTriCorner') 238 | var centerOffsetAttrib = gl.getAttribLocation(shaderProgram, 'aCenterOffset') 239 | var velocityAttrib = gl.getAttribLocation(shaderProgram, 'aVelocity') 240 | gl.enableVertexAttribArray(lifetimeAttrib) 241 | gl.enableVertexAttribArray(texCoordAttrib) 242 | gl.enableVertexAttribArray(triCornerAttrib) 243 | gl.enableVertexAttribArray(centerOffsetAttrib) 244 | gl.enableVertexAttribArray(velocityAttrib) 245 | 246 | // Get the location of all of our uniforms so that we can send data to the GPU 247 | var timeUni = gl.getUniformLocation(shaderProgram, 'uTime') 248 | var timeUniFrag = gl.getUniformLocation(shaderProgram, 'uTimeFrag') 249 | var firePosUni = gl.getUniformLocation(shaderProgram, 'uFirePos') 250 | var perspectiveUni = gl.getUniformLocation(shaderProgram, 'uPMatrix') 251 | var viewUni = gl.getUniformLocation(shaderProgram, 'uViewMatrix') 252 | var colorUni = gl.getUniformLocation(shaderProgram, 'uColor') 253 | var fireAtlasUni = gl.getUniformLocation(shaderProgram, 'uFireAtlas') 254 | var useBillboardUni = gl.getUniformLocation(shaderProgram, 'uUseBillboarding') 255 | 256 | /** 257 | * Section 4 - Setting up the data that we need to render our particles 258 | */ 259 | 260 | // Load our fire texture atlas 261 | var imageIsLoaded = false 262 | var fireTexture = gl.createTexture() 263 | var fireAtlas = new window.Image() 264 | fireAtlas.onload = function () { 265 | gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) 266 | gl.bindTexture(gl.TEXTURE_2D, fireTexture) 267 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, fireAtlas) 268 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) 269 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) 270 | imageIsLoaded = true 271 | } 272 | fireAtlas.src = 'fire-texture-atlas.jpg' 273 | 274 | // Initialize the data for all of our particles 275 | var numParticles = 1000 276 | var lifetimes = [] 277 | var triCorners = [] 278 | var texCoords = [] 279 | var vertexIndices = [] 280 | var centerOffsets = [] 281 | var velocities = [] 282 | 283 | var triCornersCycle = [ 284 | // Bottom left corner of the square 285 | -1.0, -1.0, 286 | // Bottom right corner of the square 287 | 1.0, -1.0, 288 | // Top right corner of the square 289 | 1.0, 1.0, 290 | // Top left corner of the square 291 | -1.0, 1.0 292 | ] 293 | var texCoordsCycle = [ 294 | // Bottom left corner of the texture 295 | 0, 0, 296 | // Bottom right corner of the texture 297 | 1, 0, 298 | // Top right corner of the texture 299 | 1, 1, 300 | // Top left corner of the texture 301 | 0, 1 302 | ] 303 | 304 | for (var i = 0; i < numParticles; i++) { 305 | // Particles live for up to 8 seconds 306 | var lifetime = 8 * Math.random() 307 | 308 | // Particles are placed within 0.25 units from the center of the flame 309 | var diameterAroundCenter = 0.5 310 | var halfDiameterAroundCenter = diameterAroundCenter / 2 311 | 312 | // We randomly choose the x displacement from the center 313 | var xStartOffset = diameterAroundCenter * Math.random() - halfDiameterAroundCenter 314 | xStartOffset /= 3 315 | 316 | // We randomly choose the y displacement from the center 317 | var yStartOffset = diameterAroundCenter * Math.random() - halfDiameterAroundCenter 318 | yStartOffset /= 10 319 | 320 | // We randomly choose the z displacement from the center 321 | var zStartOffset = diameterAroundCenter * Math.random() - halfDiameterAroundCenter 322 | zStartOffset /= 3 323 | 324 | // We randomly choose how fast the particle shoots up into the air 325 | var upVelocity = 0.1 * Math.random() 326 | 327 | // We randomly choose how much the particle drifts to the left or right 328 | var xSideVelocity = 0.02 * Math.random() 329 | if (xStartOffset > 0) { 330 | xSideVelocity *= -1 331 | } 332 | 333 | // We randomly choose how much the particle drifts to the front and back 334 | var zSideVelocity = 0.02 * Math.random() 335 | if (zStartOffset > 0) { 336 | zSideVelocity *= -1 337 | } 338 | 339 | // Push the data for the four corners of the particle quad 340 | for (var j = 0; j < 4; j++) { 341 | lifetimes.push(lifetime) 342 | 343 | triCorners.push(triCornersCycle[j * 2]) 344 | triCorners.push(triCornersCycle[j * 2 + 1]) 345 | 346 | texCoords.push(texCoordsCycle[j * 2]) 347 | texCoords.push(texCoordsCycle[j * 2 + 1]) 348 | centerOffsets.push(xStartOffset) 349 | // Particles that start farther from the fire's center start slightly 350 | // higher. This gives the bottom of the fire a slight curve 351 | centerOffsets.push(yStartOffset + Math.abs(xStartOffset / 2.0)) 352 | centerOffsets.push(zStartOffset) 353 | 354 | velocities.push(xSideVelocity) 355 | velocities.push(upVelocity) 356 | velocities.push(zSideVelocity) 357 | } 358 | 359 | // Push the 6 vertices that will form our quad 360 | // 3 for the first triangle and 3 for the second 361 | vertexIndices = vertexIndices.concat([ 362 | 0, 1, 2, 0, 2, 3 363 | ].map(function (num) { return num + 4 * i })) 364 | } 365 | 366 | // Push all of our particle attribute data to the GPU 367 | function createBuffer (bufferType, DataType, data) { 368 | var buffer = gl.createBuffer() 369 | gl.bindBuffer(gl[bufferType], buffer) 370 | gl.bufferData(gl[bufferType], new DataType(data), gl.STATIC_DRAW) 371 | return buffer 372 | } 373 | createBuffer('ARRAY_BUFFER', Float32Array, lifetimes) 374 | gl.vertexAttribPointer(lifetimeAttrib, 1, gl.FLOAT, false, 0, 0) 375 | 376 | createBuffer('ARRAY_BUFFER', Float32Array, texCoords) 377 | gl.vertexAttribPointer(texCoordAttrib, 2, gl.FLOAT, false, 0, 0) 378 | 379 | createBuffer('ARRAY_BUFFER', Float32Array, triCorners) 380 | gl.vertexAttribPointer(triCornerAttrib, 2, gl.FLOAT, false, 0, 0) 381 | 382 | createBuffer('ARRAY_BUFFER', Float32Array, centerOffsets) 383 | gl.vertexAttribPointer(centerOffsetAttrib, 3, gl.FLOAT, false, 0, 0) 384 | 385 | createBuffer('ARRAY_BUFFER', Float32Array, velocities) 386 | gl.vertexAttribPointer(velocityAttrib, 3, gl.FLOAT, false, 0, 0) 387 | 388 | createBuffer('ELEMENT_ARRAY_BUFFER', Uint16Array, vertexIndices) 389 | 390 | // We set OpenGL's blend function so that we don't see the black background 391 | // on our particle squares. Essentially, if there is anything behind the particle 392 | // we show whatever is behind it plus the color of the particle. 393 | // 394 | // If the color of the particle is black then black is (0, 0, 0) so we only show 395 | // whatever is behind it. 396 | // So this works because our texture has a black background. 397 | // There are many different blend functions that you can use, this one works for our 398 | // purposes. 399 | gl.enable(gl.BLEND) 400 | gl.blendFunc(gl.ONE, gl.ONE) 401 | 402 | // Push our fire texture atlas to the GPU 403 | gl.activeTexture(gl.TEXTURE0) 404 | gl.bindTexture(gl.TEXTURE_2D, fireTexture) 405 | gl.uniform1i(fireAtlasUni, 0) 406 | 407 | // Send our perspective matrix to the GPU 408 | gl.uniformMatrix4fv(perspectiveUni, false, glMat4.perspective([], Math.PI / 3, 1, 0.01, 1000)) 409 | 410 | /** 411 | * Section 5 - Creating our camera's view matrix 412 | */ 413 | 414 | function createCamera () { 415 | var camera = glMat4.create() 416 | 417 | // Start our camera off at a height of 0.25 and 1 unit 418 | // away from the origin 419 | glMat4.translate(camera, camera, [0, 0.25, 1]) 420 | 421 | // Rotate our camera around the y and x axis of the world 422 | // as the viewer clicks or drags their finger 423 | var xAxisRotation = glMat4.create() 424 | var yAxisRotation = glMat4.create() 425 | glMat4.rotateX(xAxisRotation, xAxisRotation, -xRotation) 426 | glMat4.rotateY(yAxisRotation, yAxisRotation, yRotation) 427 | glMat4.multiply(camera, xAxisRotation, camera) 428 | glMat4.multiply(camera, yAxisRotation, camera) 429 | 430 | // Make our camera look at the first red fire 431 | var cameraPos = [camera[12], camera[13], camera[14]] 432 | glMat4.lookAt(camera, cameraPos, redFirePos, [0, 1, 0]) 433 | 434 | return camera 435 | } 436 | 437 | /** 438 | * Section 6 - Drawing our particles 439 | */ 440 | var previousTime = new Date().getTime() 441 | 442 | // Start a bit into the simulation so that we skip the wall of fire 443 | // that forms at the beginning. To see what I mean set this value to 444 | // zero seconds, instead of the current 3 seconds 445 | var clockTime = 3 446 | 447 | // Our first flame's position is at the world origin and it is red 448 | var redFirePos = [0.0, 0.0, 0.0] 449 | var redFireColor = [0.8, 0.25, 0.25, 1.0] 450 | 451 | // Our second flame is 0.5 units to the right of the first flame 452 | // and is purple 453 | var purpFirePos = [0.5, 0.0, 0.0] 454 | var purpFireColor = [0.25, 0.25, 8.25, 1.0] 455 | 456 | function draw () { 457 | // Once the image is loaded we'll start drawing our particle effect 458 | if (imageIsLoaded) { 459 | // Clear our color buffer and depth buffer so that 460 | // nothing is left over in our drawing buffer now that we're 461 | // completely redrawing the entire canvas 462 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) 463 | 464 | // Get the current time and subtract it by the time that we 465 | // last drew to calculate the new number of seconds 466 | var currentTime = new Date().getTime() 467 | clockTime += (currentTime - previousTime) / 1000 468 | previousTime = currentTime 469 | 470 | // Pass the current time into our vertex and fragment shaders 471 | gl.uniform1f(timeUni, clockTime) 472 | gl.uniform1f(timeUniFrag, clockTime) 473 | 474 | // Pass our world view matrix into our vertex shader 475 | gl.uniformMatrix4fv(viewUni, false, createCamera()) 476 | 477 | // Set whether or not we will use billboarding for this draw call 478 | gl.uniform1i(useBillboardUni, billboardingEnabled) 479 | 480 | // We pass information specific to our first flame into our vertex shader 481 | // and then draw our first flame. 482 | gl.uniform3fv(firePosUni, redFirePos) 483 | gl.uniform4fv(colorUni, redFireColor) 484 | // What does numParticles * 6 mean? 485 | // For each particle there are two triangles drawn (to form the square) 486 | // The first triangle has 3 vertices and the second triangle has 3 vertices 487 | // making for a total of 6 vertices per particle. 488 | gl.drawElements(gl.TRIANGLES, numParticles * 6, gl.UNSIGNED_SHORT, 0) 489 | 490 | // We pass information specific to our second flame into our vertex shader 491 | // and then draw our second flame. 492 | gl.uniform3fv(firePosUni, purpFirePos) 493 | gl.uniform4fv(colorUni, purpFireColor) 494 | gl.drawElements(gl.TRIANGLES, numParticles * 6, gl.UNSIGNED_SHORT, 0) 495 | } 496 | 497 | // On the next animation frame we re-draw our particle effect 498 | window.requestAnimationFrame(draw) 499 | } 500 | draw() 501 | --------------------------------------------------------------------------------