Three.js+React制作3D夢(mèng)中海島效果
背景
深居內(nèi)陸的人們,大概每個(gè)人都有過大海之夢(mèng)吧。夏日傍晚在沙灘漫步奔跑;或是在海上沖浪游泳;或是在海島游玩探險(xiǎn);亦或靜待日出日落……本文使用 React + Three.js 技術(shù)棧,實(shí)現(xiàn) 3D 海洋和島嶼,主要包含知識(shí)點(diǎn)包括:Tone Mapping、Water 類、Sky 類、Shader 著色、ShaderMaterial 著色器材質(zhì)、Raycaster 檢測(cè)遮擋以及 Three.js 的其他基礎(chǔ)知識(shí),讓我們?cè)谶@個(gè)夏天通過此頁(yè)面共赴大海之約。

效果

本頁(yè)面僅適配PC端,大屏訪問效果更佳。
在線預(yù)覽地址1:https://3d-eosin.vercel.app/#/ocean
在線預(yù)覽地址2:https://dragonir.github.io/3d/#/ocean
實(shí)現(xiàn)
素材準(zhǔn)備
開發(fā)之前,需要準(zhǔn)備頁(yè)面所需的素材,本文用到的海島素材是在 sketchfab.com 找的免費(fèi)模型。下載好素材之后,在 Blender 中打開,按自己的想法調(diào)整模型的顏色、材質(zhì)、大小比例、角度、位置等信息,刪減不需要的模塊、縮減面數(shù)以壓縮模型體積,最后刪除相機(jī)、光照、UV、動(dòng)畫等多余信息,只導(dǎo)出模型網(wǎng)格備用。

資源引入
首先,引入開發(fā)所需的必備資源,OrbitControls 用于鏡頭軌道控制;GLTFLoader 用于加載 gltf 格式模型;Water 是 Three.js 內(nèi)置的一個(gè)類,可以生成類似水的效果;Sky 可以生成天空效果;TWEEN 用來(lái)生成補(bǔ)間動(dòng)畫;Animations 是對(duì) TWEEN 控制鏡頭補(bǔ)間動(dòng)畫方法的封裝;waterTexture 、flamingoModel、islandModel 三者分別是水的法向貼圖、飛鳥模型、海島模型;vertexShader 和 fragmentShader 是用于生成彩虹的 Shader 著色器。
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { Water } from 'three/examples/jsm/objects/Water';
import { Sky } from 'three/examples/jsm/objects/Sky';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min";
import Animations from '@/assets/utils/animations';
import waterTexture from '@/containers/Ocean/images/waternormals.jpg';
import islandModel from '@/containers/Ocean/models/island.glb';
import flamingoModel from '@/containers/Ocean/models/flamingo.glb';
import vertexShader from '@/containers/Ocean/shaders/rainbow/vertex.glsl';
import fragmentShader from '@/containers/Ocean/shaders/rainbow/fragment.glsl';
頁(yè)面結(jié)構(gòu)
頁(yè)面主要由3部分構(gòu)成:canvas.webgl 用于渲染 WEBGL 場(chǎng)景;div.loading 用于模型加載完成前顯示加載進(jìn)度;div.point 用于添加交互點(diǎn),省略部分是其他幾個(gè)交互點(diǎn)信息。
render () {
return (
<div className='ocean'>
<canvas className='webgl'></canvas>
{this.state.loadingProcess === 100 ? '' : (
<div className='loading'>
<span className='progress'>{this.state.loadingProcess} %</span>
</div>
)}
<div className="point point-0">
<div className="label label-0">1</div>
<div className="text">燈塔:矗立在海岸的巖石之上,白色的塔身以及紅色的塔屋,在湛藍(lán)色的天空和深藍(lán)色大海的映襯下,顯得如此醒目和美麗。</div>
</div>
// ...
</div>
)
}
場(chǎng)景初始化
在這部分,先定義好需要的狀態(tài)值,loadingProcess 用于顯示頁(yè)面加載進(jìn)度。
state = {
loadingProcess: 0
}
定義一些全局變量和參數(shù),初始化場(chǎng)景、相機(jī)、鏡頭軌道控制器、燈光、頁(yè)面縮放監(jiān)聽等。
const clock = new THREE.Clock();
const raycaster = new THREE.Raycaster()
const sizes = {
width: window.innerWidth,
height: window.innerHeight
}
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('canvas.webgl'),
antialias: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(sizes.width, sizes.height);
// 設(shè)置渲染效果
renderer.toneMapping = THREE.ACESFilmicToneMapping;
// 創(chuàng)建場(chǎng)景
const scene = new THREE.Scene();
// 創(chuàng)建相機(jī)
const camera = new THREE.PerspectiveCamera(55, sizes.width / sizes.height, 1, 20000);
camera.position.set(0, 600, 1600);
// 添加鏡頭軌道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
controls.maxPolarAngle = 1.5;
controls.minDistance = 50;
controls.maxDistance = 1200;
// 添加環(huán)境光
const ambientLight = new THREE.AmbientLight(0xffffff, .8);
scene.add(ambientLight);
// 添加平行光
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.color.setHSL(.1, 1, .95);
dirLight.position.set(-1, 1.75, 1);
dirLight.position.multiplyScalar(30);
scene.add(dirLight);
// 頁(yè)面縮放監(jiān)聽并重新更新場(chǎng)景和相機(jī)
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
Tone Mapping
可以注意到,本文使用了 renderer.toneMapping = THREE.ACESFilmicToneMapping 來(lái)設(shè)置頁(yè)面渲染效果。目前 Three.js 中有以下幾種 Tone Mapping 值,它們定義了 WebGLRenderer 的 toneMapping 屬性,用于在近似標(biāo)準(zhǔn)計(jì)算機(jī)顯示器或移動(dòng)設(shè)備的低動(dòng)態(tài)范圍 LDR 屏幕上展示高動(dòng)態(tài)范圍 HDR 外觀。大家可以修改不同的值看看渲染效果有何不同。
THREE.NoToneMappingTHREE.LinearToneMappingTHREE.ReinhardToneMappingTHREE.CineonToneMappingTHREE.ACESFilmicToneMapping
海
使用 Three.js 自帶的 Water 類創(chuàng)建海洋,首先創(chuàng)建一個(gè)平面網(wǎng)格 waterGeometry,讓后將它傳遞給 Water,并配置相關(guān)屬性,最后將海洋添加到場(chǎng)景中。

const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
const water = new Water(waterGeometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load(waterTexture, texture => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
}),
sunDirection: new THREE.Vector3(),
sunColor: 0xffffff,
waterColor: 0x0072ff,
distortionScale: 4,
fog: scene.fog !== undefined
});
water.rotation.x = - Math.PI / 2;
scene.add(water);
Water 類
參數(shù)說(shuō)明:
textureWidth:畫布寬度textureHeight:畫布高度waterNormals:法向量貼圖sunDirection:陽(yáng)光方向sunColor:陽(yáng)光顏色waterColor:水顏色distortionScale:物體倒影分散度fog:霧alpha:透明度

天空
接著,使用 Three.js 自帶的天空類 Sky 創(chuàng)建天空,通過修改著色器參數(shù)設(shè)置天空樣式,然后創(chuàng)建太陽(yáng)并添加到場(chǎng)景中。
const sky = new Sky(); sky.scale.setScalar(10000); scene.add(sky); const skyUniforms = sky.material.uniforms; skyUniforms['turbidity'].value = 20; skyUniforms['rayleigh'].value = 2; skyUniforms['mieCoefficient'].value = 0.005; skyUniforms['mieDirectionalG'].value = 0.8; // 太陽(yáng) const sun = new THREE.Vector3(); const pmremGenerator = new THREE.PMREMGenerator(renderer); const phi = THREE.MathUtils.degToRad(88); const theta = THREE.MathUtils.degToRad(180); sun.setFromSphericalCoords(1, phi, theta); sky.material.uniforms['sunPosition'].value.copy(sun); water.material.uniforms['sunDirection'].value.copy(sun).normalize(); scene.environment = pmremGenerator.fromScene(sky).texture;
Sky 類
天空材質(zhì)著色器參數(shù)說(shuō)明:
turbidity渾濁度rayleigh視覺效果就是傍晚晚霞的紅光的深度luminance視覺效果整體提亮或變暗mieCoefficient散射系數(shù)mieDirectionalG定向散射值

虹
首先,創(chuàng)建具有彩虹漸變效果的著色器 Shader, 然后使用著色器材質(zhì) ShaderMaterial, 創(chuàng)建圓環(huán) THREE.TorusGeometry 并添加到場(chǎng)景中。
頂點(diǎn)著色器 vertex.glsl:
varying vec2 vUV;
varying vec3 vNormal;
void main () {
vUV = uv;
vNormal = vec3(normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
片段著色器 fragment.glsl:
varying vec2 vUV;
varying vec3 vNormal;
void main () {
vec4 c = vec4(abs(vNormal) + vec3(vUV, 0.0), 0.1); // 設(shè)置透明度為0.1
gl_FragColor = c;
}
彩虹漸變著色器效果:

const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
transparent: true,
uniforms: {},
vertexShader: vertexShader,
fragmentShader: fragmentShader
});
const geometry = new THREE.TorusGeometry(200, 10, 50, 100);
const torus = new THREE.Mesh(geometry, material);
torus.opacity = .1;
torus.position.set(0, -50, -400);
scene.add(torus);

Shader 著色器
WebGL 中記述了坐標(biāo)變換的機(jī)制就叫做著色器 Shader,著色器又有處理幾何圖形頂點(diǎn)的 頂點(diǎn)著色器 和處理像素的 片段著色器 兩種類型
準(zhǔn)備頂點(diǎn)著色器和片元著色器
著色器的添加有多種方法,最簡(jiǎn)單的方法就是把著色器記錄在 HTML 中。該方法利用HTML 的 script 標(biāo)簽來(lái)實(shí)現(xiàn),如:
頂點(diǎn)著色器:
<script id="vshader" type="x-shader/x-vertex"></script>
片段著色器:
<script id="fshader" type="x-shader/x-fragment"></script>
也可以像本文中一樣,直接使用單獨(dú)創(chuàng)建 glsl 格式文件引入。
著色器的三個(gè)變量與運(yùn)行方式
Uniforms:是所有頂點(diǎn)都具有相同的值的變量。 比如燈光,霧,和陰影貼圖就是被儲(chǔ)存在uniforms中的數(shù)據(jù)。uniforms可以通過頂點(diǎn)著色器和片元著色器來(lái)訪問。Attributes:是與每個(gè)頂點(diǎn)關(guān)聯(lián)的變量。例如,頂點(diǎn)位置,法線和頂點(diǎn)顏色都是存儲(chǔ)在attributes中的數(shù)據(jù)。attributes只可以在頂點(diǎn)著色器中訪問。Varyings:是從頂點(diǎn)著色器傳遞到片元著色器的變量。對(duì)于每一個(gè)片元,每一個(gè)varying的值將是相鄰頂點(diǎn)值的平滑插值。
頂點(diǎn)著色器 首先運(yùn)行,它接收 attributes, 計(jì)算每個(gè)單獨(dú)頂點(diǎn)的位置,并將其他數(shù)據(jù)varyings 傳遞給片段著色器。片段著色器 后運(yùn)行,它設(shè)置渲染到屏幕的每個(gè)單獨(dú)的片段的顏色。
ShaderMaterial 著色器材質(zhì)
Three.js 所謂的材質(zhì)對(duì)象 Material 本質(zhì)上就是著色器代碼和需要傳遞的 uniform 數(shù)據(jù)光源、顏色、矩陣。Three.js 提供可直接渲染著色器語(yǔ)法的材質(zhì) ShaderMaterial 和 RawShaderMaterial。
RawShaderMaterial: 和原生WebGL中一樣,頂點(diǎn)著色器、片元著色器代碼基本沒有任何區(qū)別,不過頂點(diǎn)數(shù)據(jù)和uniform數(shù)據(jù)可以通過Three.js的API快速傳遞,要比使用WebGL原生的API與著色器變量綁定要方便得多。ShaderMaterial:ShaderMaterial比RawShaderMaterial更方便些,著色器中的很多變量不用聲明,Three.js系統(tǒng)會(huì)自動(dòng)設(shè)置,比如頂點(diǎn)坐標(biāo)變量、投影矩陣、視圖矩陣等。
構(gòu)造函數(shù):
ShaderMaterial(parameters : Object)
parameters:可選,用于定義材質(zhì)外觀的對(duì)象,具有一個(gè)或多個(gè)屬性。
常用屬性:
attributes[Object]:接受如下形式的對(duì)象,{ attribute1: { value: []} } 指定要傳遞給頂點(diǎn)著色器代碼的 attributes;鍵為 attribute 修飾變量的名稱,值也是對(duì)象格式,如 { value: [] }, value 是固定名稱,因?yàn)?nbsp;attribute 相對(duì)于所有頂點(diǎn),所以應(yīng)該回傳一個(gè)數(shù)組格式。只有 bufferGeometry 類型的能使用該屬性。
.uniforms[Object]:如下形式的對(duì)象:{ uniform1: { value: 1.0 }, uniform2: { value: 2.0 }} 指定要傳遞給shader 代碼的 uniforms;鍵為 uniform 的名稱,值是如下形式:{ value: 1.0 } 這里 value 是 uniform 的值。名稱必須匹配著色器代碼中 uniform 的 name,和 GLSL 代碼中的定義一樣。 注意,uniforms 逐幀被刷新,所以更新 uniform 值將立即更新 GLSL 代碼中的相應(yīng)值。
.fragmentShader[String]:片元著色器的 GLSL 代碼,它也可以作為一個(gè)字符串直接傳遞或者通過 AJAX 加載。
.vertexShader[String]:頂點(diǎn)著色器的 GLSL 代碼,它也可以作為一個(gè)字符串直接傳遞或者通過 AJAX 加載。
島
接著,使用 GLTFLoader 加載島嶼模型并添加到場(chǎng)景中。加載之前可以使用 LoadingManager 來(lái)管理加載進(jìn)度。

const manager = new THREE.LoadingManager();
manager.onProgress = async(url, loaded, total) => {
if (Math.floor(loaded / total * 100) === 100) {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
Animations.animateCamera(camera, controls, { x: 0, y: 40, z: 140 }, { x: 0, y: 0, z: 0 }, 4000, () => {
this.setState({ sceneReady: true });
});
} else {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
}
};
const loader = new GLTFLoader(manager);
loader.load(islandModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
child.material.metalness = .4;
child.material.roughness = .6;
}
})
mesh.scene.position.set(0, -2, 0);
mesh.scene.scale.set(33, 33, 33);
scene.add(mesh.scene);
});

鳥
使用 GLTFLoader 加載島嶼模型添加到場(chǎng)景中,獲取模型自帶的動(dòng)畫幀并進(jìn)行播放,記得要在 requestAnimationFrame 中更新動(dòng)畫??梢允褂?nbsp;clone 方法在場(chǎng)景中添加多只飛鳥。鳥模型來(lái)源于 Three.js 官網(wǎng)。

loader.load(flamingoModel, gltf => {
const mesh = gltf.scene.children[0];
mesh.scale.set(.35, .35, .35);
mesh.position.set(-100, 80, -300);
mesh.rotation.y = - 1;
mesh.castShadow = true;
scene.add(mesh);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(gltf.animations[0]).setDuration(1.2).play();
this.mixers.push(mixer);
});

交互點(diǎn)
添加交互點(diǎn),鼠標(biāo) hover 懸浮時(shí)顯示提示語(yǔ),點(diǎn)擊交互點(diǎn)可以切換鏡頭角度,視角聚焦到交互點(diǎn)對(duì)應(yīng)的位置上。
const points = [
{
position: new THREE.Vector3(10, 46, 0),
element: document.querySelector('.point-0')
},
// ...
];
document.querySelectorAll('.point').forEach(item => {
item.addEventListener('click', event => {
let className = event.target.classList[event.target.classList.length - 1];
switch(className) {
case 'label-0':
Animations.animateCamera(camera, controls, { x: -15, y: 80, z: 60 }, { x: 0, y: 0, z: 0 }, 1600, () => {});
break;
// ...
}
}, false);
});

動(dòng)畫
在 requestAnimationFrame 中更新水、鏡頭軌道控制器、相機(jī)、TWEEN、交互點(diǎn)等動(dòng)畫。
const animate = () => {
requestAnimationFrame(animate);
water.material.uniforms['time'].value += 1.0 / 60.0;
controls && controls.update();
const delta = clock.getDelta();
this.mixers && this.mixers.forEach(item => {
item.update(delta);
});
const timer = Date.now() * 0.0005;
TWEEN && TWEEN.update();
camera && (camera.position.y += Math.sin(timer) * .05);
if (this.state.sceneReady) {
// 遍歷每個(gè)點(diǎn)
for (const point of points) {
// 獲取2D屏幕位置
const screenPosition = point.position.clone();
screenPosition.project(camera);
raycaster.setFromCamera(screenPosition, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length === 0) {
// 未找到相交點(diǎn),顯示
point.element.classList.add('visible');
} else {
// 找到相交點(diǎn)
// 獲取相交點(diǎn)的距離和點(diǎn)的距離
const intersectionDistance = intersects[0].distance;
const pointDistance = point.position.distanceTo(camera.position);
// 相交點(diǎn)距離比點(diǎn)距離近,隱藏;相交點(diǎn)距離比點(diǎn)距離遠(yuǎn),顯示
intersectionDistance < pointDistance ? point.element.classList.remove('visible') : point.element.classList.add('visible');
}
const translateX = screenPosition.x * sizes.width * 0.5;
const translateY = - screenPosition.y * sizes.height * 0.5;
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
}
}
renderer.render(scene, camera);
}
animate();
}
Raycaster 檢測(cè)遮擋
仔細(xì)觀察,在上述更新交互點(diǎn)動(dòng)畫的方法中,通過 raycaster 射線來(lái)檢查交互點(diǎn)是否被物體遮擋,如果被遮擋就隱藏交互點(diǎn),否則顯示交互點(diǎn),大家可以通過旋轉(zhuǎn)場(chǎng)景觀察到這一效果。

總結(jié)
本文包含的新知識(shí)點(diǎn)主要包括:
Tone MappingWater類Sky類Shader著色器ShaderMaterial著色器材質(zhì)Raycaster檢測(cè)遮擋
以上就是Three.js+React制作3D夢(mèng)中海島效果的詳細(xì)內(nèi)容,更多關(guān)于Three.js React 3D海島的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
深入理解JavaScript系列(10) JavaScript核心(晉級(jí)高手必讀篇)
本篇是ECMA-262-3 in detail系列的一個(gè)概述(本人后續(xù)會(huì)翻譯整理這些文章到本系列(第11-19章)。每個(gè)章節(jié)都有一個(gè)更詳細(xì)的內(nèi)容鏈接,你可以繼續(xù)讀一下每個(gè)章節(jié)對(duì)應(yīng)的詳細(xì)內(nèi)容鏈接進(jìn)行更深入的了解2012-01-01
JavaScript實(shí)現(xiàn)簡(jiǎn)單圖片輪播效果
這篇文章主要介紹了JavaScript實(shí)現(xiàn)簡(jiǎn)單圖片輪播效果,點(diǎn)擊下標(biāo)切換到該圖片上,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08
25個(gè)讓你眼前一亮的JavaScript代碼技巧分享
學(xué)習(xí)強(qiáng)大的JavaScript一行代碼,能夠節(jié)省你的時(shí)間和代碼量,所以本文為大家整理了25個(gè)JavaScript實(shí)用代碼技巧,感興趣的小伙伴可以了解一下2023-07-07
微信小程序?qū)崿F(xiàn)聯(lián)動(dòng)選擇器
這篇文章主要為大家詳細(xì)介紹了微信小程序?qū)崿F(xiàn)聯(lián)動(dòng)選擇器,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-02-02
Javascript表單驗(yàn)證要注意的事項(xiàng)
JavaScript 可用來(lái)在數(shù)據(jù)被送往服務(wù)器前對(duì) HTML 表單中的這些輸入數(shù)據(jù)進(jìn)行驗(yàn)證。被 JavaScript 驗(yàn)證的這些典型的表單數(shù)據(jù)有:用戶是否已填寫表單中的必填項(xiàng)目?用戶輸入的郵件地址是否合法?用戶是否已輸入合法的日期?用戶是否在數(shù)據(jù)域 (numeric field) 中輸入了文本?2014-09-09
uniapp開發(fā)App出現(xiàn)網(wǎng)絡(luò)異常的解決方法
這篇文章主要給大家介紹了uniapp開發(fā)App出現(xiàn)網(wǎng)絡(luò)異常的解決方案,文中有相關(guān)的解決方法和步驟,具有一定的參考價(jià)值,需要的朋友可以參考下2023-09-09
JS區(qū)分Object與Aarry的六種方法總結(jié)
下面小編就為大家?guī)?lái)一篇JS區(qū)分Object與Aarry的六種方法總結(jié)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來(lái)看看吧2017-02-02

