Creating Realistic Cigarette Smoke with Three.js Shaders
This article demonstrates a stepâbyâstep implementation of a realistic, animated cigarette smoke effect in Three.js using custom GLSL shaders. It covers texture mapping, UV handling, masking, remapping, edge fading, geometry twisting, and depthâwrite management for transparent materials.
Introduction\nThe following article demonstrates how to produce a realistic cigaretteâsmoke effect in WebGL using Three.js and GLSL. It assumes familiarity with Three.js but will briefly cover core shader concepts so that readers can understand the flow from geometry to animated translucency.\n\n---\n\n**1. Scene Setup**\n\nWe start with a simple plane that will act as the smoke volume. The plane is doubleâsided so that the smoke is visible from all angles.\n\nconst scene = new THREE.Scene();\nconst camera = new THREE.PerspectiveCamera(...);\nconst renderer = new THREE.WebGLRenderer();\n\n---\n\n**2. Vertex Shader Basics**\n\nThe vertex shaderâs sole job is to forward the fragmentâs UV coordinates to the fragment shader and compute the world position of each vertex.\n\nconst material = new THREE.ShaderMaterial({\n uniforms: { /* will be filled later */ },\n vertexShader: `\n varying vec2 vUv;\n void main() {\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n vUv = uv;\n }\n `,\n fragmentShader: `\n /* will be filled later */\n `,\n side: THREE.DoubleSide,\n wireframe: false\n});\n\n---\n\n**3. Fragment Shader Foundations**\n\nBefore we bring textures into play we establish a simple fragment shader that outputs full green. This helps verify that the geometry is rendering.\n\nfragmentShader: `\n void main() {\n gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0);\n #include \n #include \n }\n`;\n\n---\n\n**4. Importing a PerlinâNoise Texture**\n\nThe texture is a grayscale Perlinânoise texture that will drive the smoke variation. It is loaded asynchronously and supplied as a uniform.\n\nconst textureLoader = new THREE.TextureLoader();\nconst smokeTexture = textureLoader.load('textures/perlin-noise.png');\n\nmaterial.uniforms.uTexture = { type: 't', value: smokeTexture };\n\n---\n\n**5. Texture Sampling via UVs**\n\nUV coordinates are passed from vertex to fragment with a varying. To debug the mapping we output the UV as the fragment colour.\n\nvertexShader snippet already declares `varying vec2 vUv;`.\n\nfragmentShader debug:\n\n`const vec2 uv = vUv;\n gl_FragColor = vec4(uv, 0.0, 1.0);`\n\nSwitch the shader state to âUVâ in the UI to verify the gradient.\n\n---\n\n**6. Mapping the Noise Texture**\n\nThe core of the smoke effect is sampling the noise texture. Because the texture is singleâchannel (grayscale), we only need the red component.\n\nfragmentShader core:\n\n`float sample = texture(uTexture, vUv).r;`\n\n`gl_FragColor = vec4(sample, sample, sample, 1.0);`\n\n---\n\n**7. Custom Sample Size**\n\nTo control how much of the texture is used we expose `uTextureSampleWidth` and `uTextureSampleHeight` uniforms. These scale the UVs before sampling, effectively cropping the texture.\n\n`vec2 uvScaled = vUv * vec2(uTextureSampleWidth, uTextureSampleHeight);`\n\n`float sample = texture(uTexture, uvScaled).r;`\n\n---\n\n**8. Masking for Transparency**\n\nSmoke should be translucent. Instead of using the intensity as RGB, we output white colour and use the sample as the alpha channel.\n\n`gl_FragColor = vec4(1.0, 1.0, 1.0, sample);`\n\nSet `material.transparent = true;` to enable alpha blending.\n\n---\n\n**9. Animating the Texture Upwards**\n\nWe give the smoke a rising motion by repeating the texture vertically and shifting its Y coordinate over time. The texture is set to repeat.\n\n`smokeTexture.wrapT = THREE.RepeatWrapping;`\n\nUniforms `uTime` and `uSpeed` are added:\n\n`float y = vUv.y - uTime * uSpeed;`\n\n`float sample = texture(uTexture, vec2(vUv.x, y)).r;`\n\n---\n\n**10. Remapping Darkness**\n\nThe raw Perlin texture contains very few true black pixels, so the smoke looks solid. `smoothstep` remaps the sample value into a steeper 0â1 range.\n\n`sample = smoothstep(uRemapLow, uRemapHigh, sample);`\n\nTypical values: `uRemapLow = 0.3`, `uRemapHigh = 0.8`.\n\n---\n\n**11. Fading at the Edges**\n\nTo prevent the smoke from having hard edges we fade it out near the planeâs borders using `smoothstep` on the UVs.\n\n`float edgeFade = smoothstep(0.0, uEdgeX, vUv.x) *\n smoothstep(1.0, 1.0 - uEdgeX, vUv.x) *\n smoothstep(0.0, uEdgeY, vUv.y) *\n smoothstep(1.0, 1.0 - uEdgeY, vUv.y);`\n\n`sample *= edgeFade;`\n\n---\n\n**12. Twisting Geometry (Optional)**\n\nFor an additional layer of realism we twist the plane around the Y axis. The twist angle per vertex is derived from a vertical slice of the noise texture.\n\nUniforms: `uTwistSampleX`, `uTwistSampleHeight`, `uTwistStrength`, `uTwistSpeed`, `uTime`.\n\nVertex shader twist:\n\n`float sliceY = uv.y * uTwistSampleHeight;`\n`float twistVal = texture(uTexture, vec2(uTwistSampleX, sliceY)).r;`\n`float angle = twistVal * uTwistStrength;`\n`vec2 rotated = rotate2D(position.xz, angle);`\n`vec3 twisted = vec3(rotated.x, position.y, rotated.y);`\n`gl_Position = projectionMatrix * modelViewMatrix * vec4(twisted, 1.0);`\n\n`rotate2D` helper:\n\n`vec2 rotate2D(vec2 p, float a){\n float s = sin(a); float c = cos(a);\n return vec2(c*p.x - s*p.y, s*p.x + c*p.y);\n}`\n\n---\n\n**13. Depth Writing**\n\nOpaque objects normally write to the depth buffer. Because smoke is semiâtransparent we disable depth writing so that layers blend correctly.\n\n`material.depthWrite = false;`\n\n---\n\n**14. Putting It All Together**\n\nAt this point the shader has the following key uniforms:\n\n- `uTexture`\n- `uTextureSampleWidth`, `uTextureSampleHeight`\n- `uTime`, `uSpeed`\n- `uRemapLow`, `uRemapHigh`\n- `uEdgeX`, `uEdgeY`\n- `uTwistSampleX`, `uTwistSampleHeight`, `uTwistStrength`, `uTwistSpeed`\n\nThe animation loop updates `uTime`:\n\n``\nconst clock = new THREE.Clock();\nfunction animate(){\n requestAnimationFrame(animate);\n material.uniforms.uTime.value = clock.getElapsedTime();\n renderer.render(scene, camera);\n}\nanimate();\n``\n\n---\n\n**15. Conclusion**\n\nBy combining Perlin noise sampling, UV manipulation, remapping, edge fading, and optional geometry twisting, we obtain a realistic, animated cigarette smoke effect that can be reused as a building block for more complex particle systems or UI microâeffects. The shader demonstrates the power of keeping the heavy lifting on the GPU while exposing straightforward GLSL constructs for control through the Three.js material system.\n\n---\n\n**References**\n- https://threejs.org/docs/#api/en/renderers/WebGLRenderer\n- https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext\n- GLSL documentation on `smoothstep` and texture fetching