Three.jsで煙を制作
「WebGL Smoke」を見て試したくなったので、Three.jsで煙を制作しました。BlenderとThree.jsはもともとWebVRコンテンツを制作したくて始めたので、練習のために制作した金床を使用して、試しにThree.jsとBlenderでスケッチをしてみました。
Blenderで金床と日本刀を制作
モデリングとUV展開、スカルプティングの練習のために、チュートリアル動画を見て金床を制作しました。
金床だけだと絵として弱いと思ったので日本刀も制作し、「Blender2.8でglTFを出力」でやったようにglTFに出力しました。

Three.jsで煙を制作


「WebGL Smoke」を参考に煙を制作しました。背景を透過させたテクスチャ画像を平面にはり、X、Y、Z座標にランダムに配置して、Z軸方向に回転させることによって煙のリアリティを表現しています。
平面画像を使用しいるためシンプルに実装できる反面、カメラの角度を変えると平面だということがばれてしまうので、正面以外カメラの角度を変更することはできません。

const _this = this; //Smoke this.smokeParticles = []; //平面の形状を生成 const smokeGeometry = new THREE.PlaneGeometry(30,30); //テクスチャの設定 const textureLoader = new THREE.TextureLoader(); const smokeTexture = textureLoader.load('img/texture/smoke.png',function(texture){ //マテリアルの設定 const smokeMaterial = new THREE.MeshLambertMaterial({ color: 0xFFFFFF, map: texture, transparent: true, depthTest: false }); //平面をX、Y、Z軸方向にランダムに配置 for(let i = 0; i < 7; i++){ const particle = new THREE.Mesh(smokeGeometry,smokeMaterial); particle.position.x = Math.random() * 30 - 15; particle.position.y = Math.random() * 1 - 8; particle.position.z = Math.random() * 6 - 10; particle.rotation.z = Math.random() * 360; _this.smokeParticles.push(particle); _this.scene.add(particle); } }); onTick(){ const delta = this.clock.getDelta(); let num = this.smokeParticles.length; //平面をZ軸方向に回転 while(num--){ this.smokeParticles[num].rotation.z += (delta * 0.03); } }
● Three.jsで床を制作


凸凹した床を制作します。ノイズを使用したいため、perlin.jsを使用します。
平面のセグメントを細かくして、頂点のZ座標をノイズでランダムな値にすることで、凸凹感を表現しています。
//Floor const planeGeometry = new THREE.PlaneGeometry(40,12,840,256); //平面の頂点のZ座標をランダムな値に for(let i = 0; i < planeGeometry.vertices.length; i++){ const vertex = planeGeometry.vertices[i]; vertex.z = noise.simplex2(vertex.x/1,vertex.y/1)*0.05; } const planeMaterial = new THREE.MeshStandardMaterial({ color: 0xFFFFFF, roughness : 0.5, wireframe : true }); const plane = new THREE.Mesh(planeGeometry,planeMaterial); plane.position.set(0,-1.5,0); plane.rotation.x = Math.PI / 2; this.scene.add(plane);
● Three.jsでパーティクルアニメーションを制作


BufferGeometryを使用して、パーティクルアニメーションを制作します。バッファーオブジェクトを使用すると、頂点データをGPU上で動作させることができるので、CPUの負荷を下げながら、大量の頂点データをスムーズにアニメーションすることができます。
背景を透過させたテクスチャ画像を用意し、BufferGeometryの属性に位置情報を設定、速度情報を使用してアニメーションさせます。
const _this = this; //Snow this.particleNum = 200; //BufferGeometry const snowGeometry = new THREE.BufferGeometry(); const particlePositions = new Float32Array(_this.particleNum * 3); const particleVelocity = []; //テクスチャの設定 const snowTextureLoader = new THREE.TextureLoader(); const snowTexture = textureLoader.load('img/texture/snow.png',function(texture){ //パーティクルの位置と速度を設定 for(let i = 0; i < _this.particleNum; i++){ particlePositions[i * 3] = Math.random() * 20 - 10; particlePositions[i * 3 + 1] = Math.random() * 10 - 3; particlePositions[i * 3 + 2] = Math.random() * 8 - 4; particleVelocity[i] = new THREE.Vector3(); particleVelocity[i].y = Math.random() * 0.05 + 0.005; particleVelocity[i].multiplyScalar(0.2 / Math.sqrt(3.0)); } //BufferGeometryの属性の設定 snowGeometry.addAttribute('position', new THREE.BufferAttribute(particlePositions, 3).setDynamic(true)); //マテリアルの設定 const snowMaterial = new THREE.PointsMaterial({ color:0xFFFFFF, map:snowTexture, size:1, blending:THREE.AdditiveBlending, transparent: true, opacity:1, depthTest: false }); //パーティクルの生成 _this.snowParticles = new THREE.Points(snowGeometry,snowMaterial); _this.snowParticles.particleVelocity = particleVelocity; _this.scene.add(_this.snowParticles); }); onTick(){ const delta = this.clock.getDelta(); //パーティクルをアニメーション if(this.snowParticles){ const particlePositions = this.snowParticles.geometry.attributes.position.array; const particleVelocity = this.snowParticles.particleVelocity; for(let i = 0; i < this.particleNum; i++){ particlePositions[i * 3 + 1] += particleVelocity[i].y; if(particlePositions[i * 3 + 1] > 10){ particlePositions[i * 3 + 1] = -3; } } this.snowParticles.geometry.attributes.position.needsUpdate = true; } }
● Three.jsでglTFを読み込み
今まではES5を使用していましたが、「Three.jsで海を制作」からECMAScript 2015(ES6)を使用し始めました。ES6ではThree.jsでglTFを読み込むさいES5から少し書き方が変わります。

const _this = this; //glTFの読み込み const loader = new GLTFLoader().setPath( './data/' ); loader.load('anvil.glb', function(gltf){ const obj = gltf.scene; obj.traverse(function(child) { if(child.isMesh){ child.castShadow = true; } }); _this.scene.add(obj); obj.position.set(0.25,-1.5,0); }); //読み込んだシーンが暗いので、明るくする this.renderer.gammaOutput = true;
● script.js
必要なライブラリを読み込みます。
完成したscript.jsになります。
//=============================================================== // Import Library //=============================================================== import * as THREE from './lib/three_jsm/three.module.js'; import { GLTFLoader } from './lib/three_jsm/GLTFLoader.js'; //=============================================================== // BasicView //=============================================================== class BasicView{ constructor(){ this.init(); } init(){ this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(60,window.innerWidth/window.innerHeight,0.1,10000); this.camera.position.set(0,0,5); this.scene.add(this.camera); this.renderer = new THREE.WebGLRenderer({antialias:true}); this.renderer.setPixelRatio(window.devicePixelRatio); this.renderer.setSize(window.innerWidth,window.innerHeight); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.addEventListener('touchmove', function(e) {e.preventDefault();}, {passive: false}); const container = document.querySelector('#canvas_wrapper'); container.appendChild(this.renderer.domElement); //For window Resize const _this = this; window.addEventListener('resize',function(){ _this.camera.aspect = window.innerWidth/window.innerHeight; _this.camera.updateProjectionMatrix(); _this.renderer.setSize(window.innerWidth,window.innerHeight); },false); } startRendering(){ requestAnimationFrame(this.startRendering.bind(this)); this.render(); this.onTick(); } render(){ this.renderer.render(this.scene,this.camera); } onTick(){ } } //=============================================================== // ThreeWorld extend BasicView //=============================================================== class ThreeWorld extends BasicView{ constructor(){ super(); this.setLoading(); } setLoading(){ const _this = this; const nameArray = ['anvil']; const manifestArray = []; let path; TweenMax.to(".loader",0.1,{opacity:1}); for(let i = 0; i < nameArray.length; i++){ const name = nameArray[i]; path = './data/'+name+'.glb'; manifestArray.push({id:name,src:path}); } const loadQueue = new createjs.LoadQueue(); loadQueue.on('progress',function(e){ const progress = e.progress; }); const imageArray = []; const textureArray = []; loadQueue.on('complete',function(){ TweenMax.to("#loader_wrapper" , 1 , {opacity:0}); _this.initThreeWorld(); _this.initLight(); _this.startRendering(); }); loadQueue.loadManifest(manifestArray); } initThreeWorld(){ const _this = this; this.scene.fog = new THREE.Fog(0xFFFFFF, 3,11); this.clock = new THREE.Clock(); //Floor const planeGeometry = new THREE.PlaneGeometry(40,12,840,256); for(let i = 0; i < planeGeometry.vertices.length; i++){ const vertex = planeGeometry.vertices[i]; vertex.z = noise.simplex2(vertex.x/1,vertex.y/1)*0.05; } const planeMaterial = new THREE.MeshStandardMaterial({ color: 0x000000, roughness : 0.5, side:THREE.DoubleSide, }); const plane = new THREE.Mesh(planeGeometry,planeMaterial); plane.position.set(0,-1.5,0); plane.rotation.x = Math.PI / 2; plane.receiveShadow = true; this.scene.add(plane); //glTF const loader = new GLTFLoader().setPath( './data/' ); loader.load('anvil.glb', function(gltf){ const obj = gltf.scene; obj.traverse(function(child){ if (child.isMesh){ child.castShadow = true; } }); _this.scene.add(obj); obj.position.set(0.25,-1.5,0); obj.scale.x = obj.scale.y = obj.scale.z = 1.5; } ); //Smoke this.smokeParticles = []; const smokeGeometry = new THREE.PlaneGeometry(30,30); const textureLoader = new THREE.TextureLoader(); const smokeTexture = textureLoader.load('./img/texture/smoke.png',function(texture){ const smokeMaterial = new THREE.MeshLambertMaterial({ color: 0xFFFFFF, map: texture, transparent: true, }); for(let i = 0; i < 20; i++){ const particle = new THREE.Mesh(smokeGeometry,smokeMaterial); if(i==0){ particle.position.x = 0; particle.position.y = -8.6; particle.position.z = 3; particle.rotation.z = 30; }else{ particle.position.x = Math.random() * 30 - 15; particle.position.y = Math.random() * 1 - 8; particle.position.z = Math.random() * 6 - 10; particle.rotation.z = Math.random() * 360; } _this.smokeParticles.push(particle); _this.scene.add(particle); } }); //Snow this.particleNum = 200; const snowGeometry = new THREE.BufferGeometry(); const particlePositions = new Float32Array(_this.particleNum * 3); const particleVelocity = []; const snowTextureLoader = new THREE.TextureLoader(); const snowTexture = textureLoader.load('./img/texture/snow.png',function(texture){ for(let i = 0; i < _this.particleNum; i++){ particlePositions[i * 3] = Math.random() * 20 - 10; particlePositions[i * 3 + 1] = Math.random() * 7 - 3; particlePositions[i * 3 + 2] = Math.random() * 8 - 4; particleVelocity[i] = new THREE.Vector3(); particleVelocity[i].y = Math.random() * 0.015 + 0.005; particleVelocity[i].multiplyScalar(0.2 / Math.sqrt(3.0)); } snowGeometry.addAttribute('position', new THREE.BufferAttribute(particlePositions, 3).setDynamic(true)); const snowMaterial = new THREE.PointsMaterial({ color:0xFFFFFF, map:snowTexture, size:0.8, blending:THREE.AdditiveBlending, transparent: true, opacity:0.3, depthTest: false }); _this.snowParticles = new THREE.Points(snowGeometry,snowMaterial); _this.snowParticles.particleVelocity = particleVelocity; _this.scene.add(_this.snowParticles); }); this.renderer.gammaOutput = true; } initLight(){ const ambientLight = new THREE.AmbientLight(0xFFFFFF,1); this.scene.add(ambientLight); const spotLight = new THREE.SpotLight(0x999999, 1, 13, Math.PI / 4, 10, 1); spotLight.position.set(0,8,-5); spotLight.castShadow = true; this.scene.add(spotLight); const positionArr = [ [-0.1,-0.4,1.4,5], [0,0.9,2,10], ]; for(let i = 0; i < positionArr.length; i++){ const pointLight = new THREE.PointLight(0xFFFFFF, positionArr[i][4], 2, 1); pointLight.position.set( positionArr[i][0], positionArr[i][1], positionArr[i][2]); this.scene.add(pointLight); } } onTick(){ const delta = this.clock.getDelta(); let num = this.smokeParticles.length; //Smoke while(num--){ if(num != 0){ this.smokeParticles[num].rotation.z += (delta * 0.03); } } //Snow if(this.snowParticles){ const particlePositions = this.snowParticles.geometry.attributes.position.array; const particleVelocity = this.snowParticles.particleVelocity; for(let i = 0; i < this.particleNum; i++){ particlePositions[i * 3 + 1] += particleVelocity[i].y; if(particlePositions[i * 3 + 1] > 7){ particlePositions[i * 3 + 1] = -3; } } this.snowParticles.geometry.attributes.position.needsUpdate = true; } } } //=============================================================== // Window load //=============================================================== window.addEventListener("load", function () { const threeWorld = new ThreeWorld(); });
ライティングとアニメーションの調整をして完成したデモになります。Three.jsを試したかったのでパソコンとスマホで見ることができるようにしました。
関連記事