2019年10月21日

Three.jsで海を制作

最近Blenderばかりで、ひさしぶりにThree.jsをさわりたくなったので、Three.jsのサンプルを参考に海を制作しました。

ECMAScript 2015(ES6)とThree.js

ブラウザの対応状況を考えて、今まではECMAScript 5 (ES5)を使用していましたが、IE11以外のブラウザはほぼ対応が完了していて、class構文とES Modulesを使用したかったので、これからはES6を使用していきたいと思います。ES6に関しては下記を参考にしてください。

Three.jsのES6に対応したライブラリデータは「Three.jsのフレームワークデータ」の中の「examples > jsm」に入っています。

● script.jsの読み込み

Three.js関連のライブラリはscript.jsからインポートするので、script.jsはtype="module"をつけて読み込みます。


● Three.jsのテンプレート

シーンやカメラ、レンダラーの設置を毎回するのは面倒なので、基本的な設定をしたBasciViewを継承したクラスThreeWorldを作成し、ThreeWorld内にJavascriptを書いていきます。

テンプレートは、確認のために座標軸とグリッドを表示しています。

//===============================================================
// Import Library
//===============================================================
import * as THREE from './lib/three_jsm/three.module.js';
import { OrbitControls } from './lib/three_jsm/OrbitControls.js';

//===============================================================
// BasicView
//===============================================================
class BasicView{
	constructor(){
		this.init();
	}

	init(){
		//シーン、カメラ、レンダラーを生成
		this.scene = new THREE.Scene();
		this.camera = new THREE.PerspectiveCamera(45,window.innerWidth/window.innerHeight,0.1,10000);
		this.camera.position.set(0.1,0,0);
		this.scene.add(this.camera);
		this.renderer = new THREE.WebGLRenderer({antialias:true});
		this.renderer.setPixelRatio(window.devicePixelRatio);
		this.renderer.setSize(window.innerWidth,window.innerHeight);

		//OrbitControls
		document.addEventListener('touchmove', function(e) {e.preventDefault();}, {passive: false});
		const orbitControls = new OrbitControls(this.camera);

		//canvasを作成
		const container = document.querySelector('#canvas_wrapper');
		container.appendChild(this.renderer.domElement);

		//ウィンドウのリサイズに対応
		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.initThreeWorld();
		this.initLight();
		this.startRendering();
	}
	initThreeWorld(){
		//座標軸の生成
		const axes = new THREE.AxesHelper(1000);
		axes.position.set(0,0,0);
		this.scene.add(axes);

		//グリッドの生成
		const grid = new THREE.GridHelper(100,100);
		this.scene.add(grid);
	}
	initLight(){
		//環境光
		const ambientLight = new THREE.AmbientLight(0xFFFFFF);
		this.scene.add(ambientLight);
	}
	onTick(){
	}
}

//===============================================================
// Window load
//===============================================================
window.addEventListener("load", function () {
   const threeWorld = new ThreeWorld();
});

Three.jsで海の制作

● 海の制作

海は「examples > jsm > objects」にあるWater.jsを使用して制作できます。Water.jsは「examples > textures > water > Water_1_M_Normal.jpg」にあるノーマルマップを読みこむ必要があります。
また、Water.js内のthree.module.jsのパスを合わせる必要があります。

import { Water } from './lib/three_jsm/Water.js';

//PlaneBufferGeometry
const waterGeometry = new THREE.PlaneBufferGeometry(1000,1000);

//Water
this.water = new Water(
	waterGeometry,
	{
		textureWidth: 512,
		textureHeight: 512,
		waterNormals: new THREE.TextureLoader().load( './img/water/Water_1_M_Normal.jpg', function ( texture ) {
			texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
		} ),
		alpha: 1.0,
		waterColor: 0x3e89ce,
		distortionScale: 3.7,
		fog: this.scene.fog !== undefined
	}
);

//シーンに追加
this.scene.add(this.water);
this.water.rotation.x = - Math.PI / 2;

onTick(){
	//アニメーション
	this.water.material.uniforms['time'].value += 1.0 / 60.0;
}

● 空の制作

Water.jsを使用して海を制作しましたが、海だけだと不自然なので空も制作します。

空は「examples > jsm > objects」にあるSky.jsを使用して制作できます。また、Sky.js内のthree.module.jsのパスを合わせる必要があります。

import { Sky } from './lib/three_jsm/Sky.js';

//Sky
const sky = new Sky();
sky.scale.setScalar(450000);
this.scene.add(sky);

//Skyの設定
const sky_uniforms = sky.material.uniforms;
sky_uniforms[ 'turbidity' ].value = 10;
sky_uniforms[ 'rayleigh' ].value = 2;
sky_uniforms[ 'luminance' ].value = 1;
sky_uniforms[ 'mieCoefficient' ].value = 0.005;
sky_uniforms[ 'mieDirectionalG' ].value = 0.8;

//Sun
const sunSphere = new THREE.Mesh(
	new THREE.SphereBufferGeometry( 200, 16, 8 ),
	new THREE.MeshBasicMaterial( { color: 0xFFFFFF } )
);
this.scene.add(sunSphere);

//Sunの設定
const sun_uniforms = sky.material.uniforms;
sun_uniforms[ "turbidity" ].value = 10;
sun_uniforms[ "rayleigh" ].value = 2;
sun_uniforms[ "mieCoefficient" ].value = 0.005;
sun_uniforms[ "mieDirectionalG" ].value = 0.8;
sun_uniforms[ "luminance" ].value = 1;

const theta = Math.PI * ( -0.01 );
const phi = 2 * Math.PI * ( -0.25 );
const distance = 400000;
sunSphere.position.x = distance * Math.cos( phi );
sunSphere.position.y = distance * Math.sin( phi ) * Math.sin( theta );
sunSphere.position.z = distance * Math.sin( phi ) * Math.cos( theta );
sunSphere.visible = true;
sun_uniforms[ "sunPosition" ].value.copy( sunSphere.position );

● 球体の制作

海と空を制作しましたが、せっかくなので泡のような球体も制作します。

まず「Learning 3D Graphics With Three.js | Dynamic Geometry」を参考に、頂点をアニメーションして泡のような柔らかさを表現します。そのさい、perlin.jsが必要なので読み込みます。


次に「Three.jsでキューブ環境マッピング」でもやりましたが、海のHDRI画像を使用してキューブ環境マッピングで泡の映り込みを表現します。最後に「materials / shaders / fresnel」を参考にフレネル反射をつけました。

import { FresnelShader } from './lib/three_jsm/FresnelShader.js';

//Bubble
const geometry = new THREE.SphereGeometry(1,36,24);

const urls = [
	'./img/bubble/posx.jpg','./img/bubble/negx.jpg',
	'./img/bubble/posy.jpg','./img/bubble/negy.jpg',
	'./img/bubble/posz.jpg','./img/bubble/negz.jpg',
];

const textureCube = new THREE.CubeTextureLoader().load( urls );
textureCube.format = THREE.RGBFormat;

const shader = FresnelShader;
const uniforms = THREE.UniformsUtils.clone( shader.uniforms );
uniforms[ "tCube" ].value = textureCube;
const material = new THREE.ShaderMaterial( {
	uniforms: uniforms,
	vertexShader: shader.vertexShader,
	fragmentShader: shader.fragmentShader,
	blending:THREE.MultiplyBlending,
} );

this.sphere = new THREE.Mesh(geometry,material);
this.scene.add(this.sphere);

onTick(){
	const time = performance.now() * 0.0005;
	const k = 1.5;

	for(let j = 0; j < this.sphere.geometry.vertices.length; j++){
		const p = this.sphere.geometry.vertices[j];
		p.normalize().multiplyScalar(3 + 0.15 * noise.perlin3(p.x * k + time, p.y * k, p.z * k));
	}
	this.sphere.geometry.computeVertexNormals();
	this.sphere.geometry.verticesNeedUpdate = true;
	this.sphere.geometry.normalsNeedUpdate = true;
}

● script.js

必要なライブラリを読み込みます。





完成したscript.jsになります。

//===============================================================
// Import Library
//===============================================================
import * as THREE from './lib/three_jsm/three.module.js';
import { OrbitControls } from './lib/three_jsm/OrbitControls.js';
import { Water } from './lib/three_jsm/Water.js';
import { Sky } from './lib/three_jsm/Sky.js';
import { FresnelShader } from './lib/three_jsm/FresnelShader.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,5,15);
		this.scene.add(this.camera);
		this.renderer = new THREE.WebGLRenderer({antialias:true});
		this.renderer.setPixelRatio(window.devicePixelRatio);
		this.renderer.setSize(window.innerWidth,window.innerHeight);

		//OrbitControls
		document.addEventListener('touchmove', function(e) {e.preventDefault();}, {passive: false});
		const orbitControls = new OrbitControls(this.camera);
		orbitControls.maxPolarAngle = Math.PI * 0.495;
		orbitControls.minDistance = 5.0;
		orbitControls.maxDistance = 25.0;

		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 = ['water_texture','posx','posy','posz','negx','negy','negz'];
		const manifestArray = [];
		let path;

		TweenMax.to(".loader",0.1,{opacity:1});

		for(let i = 0; i < nameArray.length; i++){
	        const name = nameArray[i];
	        if(i==0){
	        	path = './img/water/'+name+'.jpg';
	        }else{
	           	path = './img/bubble/'+name+'.jpg';
	        }
	        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(){

			for(let i = 0; i < nameArray.length; i++){
                const tempImage = loadQueue.getResult(nameArray[i]);
	            const tempTexture = new THREE.Texture(tempImage);
	            tempTexture.needsUpdate = true;
	            imageArray.push(tempImage);
	            textureArray.push(tempTexture);
            }

			TweenMax.to("#loader_wrapper" , 1 , {opacity:0});
			_this.initThreeWorld();
			_this.initLight();
			_this.startRendering();
		});

		loadQueue.loadManifest(manifestArray);
	}

	initThreeWorld(){
		//Ocean
		const waterGeometry = new THREE.PlaneBufferGeometry(1000,1000);
		this.water = new Water(
			waterGeometry,
			{
				textureWidth: 512,
				textureHeight: 512,
				waterNormals: new THREE.TextureLoader().load( './img/water/water_texture.jpg', function ( texture ) {
					texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
				} ),
				alpha: 0.6,
				waterColor: 0x3e89ce,
				distortionScale: 3.7,
				fog: this.scene.fog !== undefined
			}
		);
		this.water.rotation.x = - Math.PI / 2;
		this.water.position.y = -5;
		this.scene.add(this.water);

		//Sky
		const sky = new Sky();

		const sky_uniforms = sky.material.uniforms;
		sky_uniforms[ 'turbidity' ].value = 10;
		sky_uniforms[ 'rayleigh' ].value = 2;
		sky_uniforms[ 'luminance' ].value = 1;
		sky_uniforms[ 'mieCoefficient' ].value = 0.005;
		sky_uniforms[ 'mieDirectionalG' ].value = 0.8;
		sky.scale.setScalar(450000);
		this.scene.add(sky);

		//Sun
		const sunSphere = new THREE.Mesh(
			new THREE.SphereBufferGeometry( 200, 16, 8 ),
			new THREE.MeshBasicMaterial( { color: 0xFFFFFF } )
		);

		const sun_uniforms = sky.material.uniforms;
		sun_uniforms[ "turbidity" ].value = 10;
		sun_uniforms[ "rayleigh" ].value = 0.3;
		sun_uniforms[ "mieCoefficient" ].value = 0.005;
		sun_uniforms[ "mieDirectionalG" ].value = 0.8;
		sun_uniforms[ "luminance" ].value = 0.8;

		const theta = Math.PI * ( -0.2 );
		const phi = 2 * Math.PI * ( -0.25 );
		const distance = 4000;
		sunSphere.position.x = distance * Math.cos( phi );
		sunSphere.position.y = distance * Math.sin( phi ) * Math.sin( theta );
		sunSphere.position.z = distance * Math.sin( phi ) * Math.cos( theta );
		sunSphere.visible = true;
		sun_uniforms[ "sunPosition" ].value.copy( sunSphere.position );

		//Bubble
		const bubble_geometry = new THREE.SphereGeometry(1,36,24);

		const urls = [
			'./img/bubble/posx.jpg','./img/bubble/negx.jpg',
			'./img/bubble/posy.jpg','./img/bubble/negy.jpg',
			'./img/bubble/posz.jpg','./img/bubble/negz.jpg',
		];

		const textureCube = new THREE.CubeTextureLoader().load( urls );
		textureCube.format = THREE.RGBFormat;

		const shader = FresnelShader;
		const uniforms = THREE.UniformsUtils.clone( shader.uniforms );
		uniforms[ "tCube" ].value = textureCube;
		const bubble_material = new THREE.ShaderMaterial( {
			uniforms: uniforms,
			vertexShader: shader.vertexShader,
			fragmentShader: shader.fragmentShader,
			blending:THREE.MultiplyBlending,
		} );

		this.sphereArr = [];
		this.spherePosArr = [];
		for(let i = 0; i < 25; i++){
			const sphere = new THREE.Mesh(bubble_geometry,bubble_material);
			sphere.position.x = Math.random() * 50 - 25;
			sphere.position.y = Math.random() * 10 - 3;
			sphere.position.z = Math.random() * 50 - 25;
			sphere.rotation.y = Math.random() * Math.PI / 2;
			sphere.rotation.z = Math.random() * Math.PI / 2;
			sphere.scale.x = sphere.scale.y = sphere.scale.z = Math.random() * 1.5 + 0.5;
			this.scene.add(sphere);
			this.sphereArr.push(sphere);
			this.spherePosArr.push(sphere.position.y);
		}
	}

	initLight(){
		const ambientLight = new THREE.AmbientLight(0xFFFFFF);
		this.scene.add(ambientLight);
	}

	onTick(){
		const time = performance.now() * 0.0005;
		const time2 = performance.now() * 0.0003;
		let anim_time;

		const k = 1.5;

		for(let i=0; i < this.sphereArr.length; i++){
			let sphere = this.sphereArr[i];

			for(let j = 0; j < sphere.geometry.vertices.length; j++){
				const p = sphere.geometry.vertices[j];
				p.normalize().multiplyScalar(2 + 0.15 * noise.perlin3(p.x * k + time, p.y * k, p.z * k));
			}
			sphere.geometry.computeVertexNormals();
			sphere.geometry.verticesNeedUpdate = true;
			sphere.geometry.normalsNeedUpdate = true;

			if(i % 2 == 0){
				anim_time = time;
			}else{
				anim_time = time2;
			}
			sphere.position.y = Math.sin(anim_time) * 2.5 + this.spherePosArr[i];
		}

		this.water.material.uniforms['time'].value += 1.0 / 240.0;
	}
}

//===============================================================
// Window load
//===============================================================
window.addEventListener("load", function () {
   const threeWorld = new ThreeWorld();
});

完成したデモになります。Three.jsを試したかったのとHMDには負荷がかかりすぎると思ったのでパソコンとスマホで見ることができるようにしました。

  • このエントリーをはてなブックマークに追加

関連記事

前の記事へ

Three.jsでglTFアニメーション

次の記事へ

Three.jsで煙を制作