Three.jsで煙を制作

「WebGL Smoke」を見て試してみたくなったので、Three.jsで煙を制作しました。BlenderとThree.jsはもともとWebVRコンテンツを制作したくて始めたので、練習のためにBlenderで制作した金床と合わせてみました。
Blenderで金床と日本刀を制作
モデリングとUV展開、スカルプティングの練習のために、チュートリアル動画を見て金床を制作しました。
金床だけだと絵として弱いと思ったので日本刀も制作し、「Blender2.8でglTFを出力」でやったようにglTFに出力しました。
Three.jsで煙を制作
「WebGL Smoke」を参考に煙を制作しました。背景を透過させたテクスチャ画像を平面にはり、X、Y、Z座標にランダムに配置して、Z軸方向に回転させることによって煙のリアリティを表現しています。
平面画像を使用しているためシンプルに実装できる反面、カメラの角度を変えると平面だということがばれてしまうので、正面以外カメラの角度を変更することはできません。
//Smoke
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;
smokeParticles.push(particle);
scene.add(particle);
}
});
function rendering(){
const delta = clock.getDelta();
let num = smokeParticles.length;
//平面をZ軸方向に回転
while(num--){
smokeParticles[num].rotation.z += (delta * 0.03);
}
requestAnimationFrame(rendering);
renderer.render(scene,camera);
}
● 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;
scene.add(plane);
● Three.jsでパーティクルアニメーションを制作
BufferGeometryを使用して、パーティクルアニメーションを制作します。バッファーオブジェクトを使用すると、頂点データをGPU上で動作させることができるので、CPUの負荷を下げながら、大量の頂点データをスムーズにアニメーションすることができます。
背景を透過させたテクスチャ画像を用意し、BufferGeometryの属性に位置情報を設定、速度情報を使用してアニメーションさせます。
//Snow
particleNum = 200;
//BufferGeometry
const snowGeometry = new THREE.BufferGeometry();
const particlePositions = new Float32Array(particleNum * 3);
const particleVelocity = [];
//テクスチャの設定
const snowTextureLoader = new THREE.TextureLoader();
const snowTexture = textureLoader.load('./img/texture/snow.png',function(texture){
//パーティクルの位置と速度を設定
for(let i = 0; i < 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
});
//パーティクルの生成
snowParticles = new THREE.Points(snowGeometry,snowMaterial);
snowParticles.particleVelocity = particleVelocity;
scene.add(snowParticles);
});
function rendering(){
const delta = clock.getDelta();
//パーティクルをアニメーション
if(snowParticles){
const particlePositions = snowParticles.geometry.attributes.position.array;
const particleVelocity = snowParticles.particleVelocity;
for(let i = 0; i < particleNum; i++){
particlePositions[i * 3 + 1] += particleVelocity[i].y;
if(particlePositions[i * 3 + 1] > 10){
particlePositions[i * 3 + 1] = -3;
}
}
snowParticles.geometry.attributes.position.needsUpdate = true;
}
requestAnimationFrame(rendering);
renderer.render(scene,camera);
}
● Three.jsでglTFを読み込み
Blenderで出力したglTFをThree.jsで読み込みます。
//glTFの読み込み
const gltfLoader = new GLTFLoader().setPath('./data/');
gltfLoader.load('anvil.glb',function(gltf){
const obj = gltf.scene;
obj.traverse(function(child) {
if(child.isMesh){
child.castShadow = true;
}
});
scene.add(obj);
obj.position.set(0.25,-1.5,0);
});
//読み込んだシーンが暗いので、明るくする
renderer.outputEncoding = THREE.GammaEncoding;
● script.js
必要なライブラリを読み込みます。
<script src="js/TweenMax.min.js"></script> <script src="js/lib/perlin.js"></script> <script src="js/script.js" type="module"></script>
完成したscript.jsになります。
//===============================================================
// Import Library
//===============================================================
import * as THREE from './lib/three_jsm/three.module.js';
import { GLTFLoader } from './lib/three_jsm/GLTFLoader.js';
//===============================================================
// Main
//===============================================================
window.addEventListener('load',function(){
init();
});
let scene,camera,renderer;
let clock = new THREE.Clock();
let smokeParticles = [];
let particleNum;
let snowParticles;
function init(){
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(60,window.innerWidth/window.innerHeight,0.1,5000);
camera.position.set(0,0,5);
scene.add(camera);
renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.setClearColor(new THREE.Color(0x000000));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.physicallyCorrectLights = true;
const container = document.querySelector('#canvas_vr');
container.appendChild(renderer.domElement);
window.addEventListener('resize',function(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
},false);
setLoading();
}
function setLoading(){
TweenMax.to('.loader',0.1,{opacity:1});
const gltfLoader = new GLTFLoader().setPath('./data/');
gltfLoader.load('anvil.glb',function(gltf){
const obj = gltf.scene;
obj.traverse(function(child){
if(child.isMesh){
child.castShadow = true;
}
} );
scene.add(obj);
obj.position.set(0.25,-1.5,0);
obj.scale.x = obj.scale.y = obj.scale.z = 1.5;
TweenMax.to('#loader_wrapper',1,{
opacity:0,
delay:1,
onComplete: function(){
document.getElementById('loader_wrapper').style.display ='none';
}
});
threeWorld();
setLight();
rendering();
} );
}
function threeWorld(){
scene.fog = new THREE.Fog(0xFFFFFF, 3,11);
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;
scene.add(plane);
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;
}
smokeParticles.push(particle);
scene.add(particle);
}
});
particleNum = 200;
const snowGeometry = new THREE.BufferGeometry();
const particlePositions = new Float32Array(particleNum * 3);
const particleVelocity = [];
const snowTextureLoader = new THREE.TextureLoader();
const snowTexture = textureLoader.load('./img/texture/snow.png',function(texture){
for(let i = 0; i < 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.setAttribute('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
});
snowParticles = new THREE.Points(snowGeometry,snowMaterial);
snowParticles.particleVelocity = particleVelocity;
scene.add(snowParticles);
});
renderer.outputEncoding = THREE.GammaEncoding;
}
function setLight(){
const ambientLight = new THREE.AmbientLight(0xFFFFFF,1);
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;
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]);
scene.add(pointLight);
}
}
function rendering(){
const delta = clock.getDelta();
let num = smokeParticles.length;
while(num--){
if(num != 0){
smokeParticles[num].rotation.z += (delta * 0.03);
}
}
if(snowParticles){
const particlePositions = snowParticles.geometry.attributes.position.array;
const particleVelocity = snowParticles.particleVelocity;
for(let i = 0; i < particleNum; i++){
particlePositions[i * 3 + 1] += particleVelocity[i].y;
if(particlePositions[i * 3 + 1] > 7){
particlePositions[i * 3 + 1] = -3;
}
}
snowParticles.geometry.attributes.position.needsUpdate = true;
}
requestAnimationFrame(rendering);
renderer.render(scene,camera);
}
ライティングとアニメーションの調整をして完成したデモになります。Three.jsを試したかったのでパソコンとスマホで見ることができるようにしました。

