Threejs海水效果学习

  Threejs中海天一色的效果是比较壮观好玩的,本文我们就学习一下如何一步步实现该效果,同时也能深入理解着色器中的Uniforms参数的作用和归一化的原理及效果。

环境准备

  首先我们搭建Threejs的基本环境,我们将初始化的元素都封装到一个类中;在使用时,直接初始化类即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Index {
constructor() {
// 初始化场景
this.scene = new Scene();

// 初始化相机
this.camera = new PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
1,
20000
);
this.camera.position.copy(new Vector3(30, 30, 100));
this.camera.lookAt(new Vector3(0, 0, 0));

this.renderer = new WebGLRenderer({
//开启抗锯齿
antialias: true,
physicallyCorrectLights: true,
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio * 2);
document.getElementById("webgl-output").appendChild(renderer.domElement);

this.scene.add(new AxesHelper(10));

this.controls = new OrbitControls(this.camera, this.renderer, false);
this.render();
}
render() {
this.controls.update(this.clock.getDelta());

this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render.bind(this));
}
}

  代码比较多,这里主要就是搭建了场景、相机、渲染器、轨道控制器等基本的Threejs元素,实现一个Three画布中该有的元素;然后把我们的类放到页面中初始化;在vue中,我们可以放到onMounted钩子函数中执行:

1
2
3
4
5
6
7
8
9
10
<template>
<div id="webgl-output"></div>
</template>
<script setup>
import { onMounted } from "vue";
import Ocean from "./index";
onMounted(() => {
new Ocean();
});
</script>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  这个时候,我们只要一改变页面的宽高大小,我们的画布由于没有及时更新,就会出现空白的区域;我们在构造函数中绑定页面大小监听事件,重新更新renderer和相机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Index {
constructor() {
this._resizeFn = this.resizeFn.bind(this);
window.addEventListener("resize", this._resizeFn);
}
resizeFn() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
}
beforeDestroy() {
window.removeEventListener("resize", this._resizeFn);
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在vue中,页面unmount时调用beforeDestroy函数,解除监听事件:

1
2
3
4
5
6
7
let ocean;
onMounted(() => {
ocean = new Ocean();
});
onBeforeUnmount(() => {
ocean && ocean.beforeDestroy();
});

添加物体

  环境初步搭建好后,我们就可以向画布上添加物体了,这里我们具体来学习水Water、天空Sky等物体的使用方式。

添加海水

  这里我们需要从Three.js源码中获取一个法线贴图,拷贝到我们项目public目录:

1
$ cp three.js/examples/textures/waternormals.jpg demos/public/textures/

所谓的法线贴图(Normal Map)是一种纹理映射技术,用于在渲染过程中模拟物体表面的细节和几何形状。它通过使用RGB颜色值来存储每个像素点的法线方向信息。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

法线贴图也广泛应用于游戏开发、动画制作、虚拟现实等领域,以提供更逼真和优化的视觉体验。

  我们打开复制法线贴图看一下,是一张偏蓝紫色的图片;

法线贴图

  通过水面的法线贴图,我们就可以模拟水面的波纹效果以及太阳光的照射效果了;我们从threejs中引入Water类,构建水平面物体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Water } from "three/examples/jsm/objects/Water";
class Index {
initMeshes() {
this.water = new Water(new PlaneGeometry(10000, 10000), {
textureWidth: 512,
textureHeight: 512,
waterNormals: new TextureLoader().load(
"/textures/waternormals.jpg",
(texture) => {
texture.wrapS = texture.wrapT = RepeatWrapping;
}
),
waterColor: 0x0072ff,
});
// 翻转平面
this.water.rotation.x = -Math.PI / 2;
this.scene.add(this.water);
}
}

  这里我们Water构造函数接收两个参数,第一个是物体,我们直接使用一个较大的PlaneGeometry作为水平面;第二个参数是WaterOptions,其中主要的是waterNormals属性,就是我们的法线贴图,通过TextureLoader加载,加载完成后我们让它在S和T方向上都重复平铺开来;还有一个属性是waterColor,就是水面的基本颜色,我们选一个接近海水的蓝色即可。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  完整的WaterOptions参数如下,其他属性的含义后面会进行调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export interface WaterOptions {
textureWidth?: number;
textureHeight?: number;
clipBias?: number;
alpha?: number;
time?: number;
waterNormals?: Texture;
sunDirection?: Vector3;
sunColor?: ColorRepresentation;
waterColor?: ColorRepresentation;
eye?: Vector3;
distortionScale?: number;
side?: Side;
fog?: boolean;
}

  我们先打开页面看一下效果,海水的纹理也呈现出来了:

海水效果

  海水纹理加载后,我们就可以通过海水材质的uniforms属性来让纹理动起来:

1
2
3
4
5
{
render() {
this.water.material.uniforms["time"].value += 1.0 / 60;
}
}

波光粼粼的效果

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  那么这里的uniforms是什么?为什么我们更改了time的value就可以让波纹动起来呢?我们悬浮查看一下Water的材质,发现它是一个ShaderMaterial材质:

ShaderMaterial

  ShaderMaterial在定义顶点着色器和片元着色器之外,还会声明uniforms属性,可以给顶点着色器和片元着色器中的变量传值,达到不同的渲染效果,比如我们查看Water.js的源码就能看到它的uniforms属性里面有一个time参数,初始化的value是0.0,因此我们改变这个值就可以控制水面波纹的渲染效果了。

1
2
3
4
5
6
7
8
9
// three/examples/jsm/objects/Water
const material = new THREE.ShaderMaterial({
uniforms: {
// 省略其他参数
'time': { value: 0.0 },
},
vertexShader: '着色器代码',// 顶点着色器
fragmentShader: '着色器代码',// 片元着色器
});

  在后面天空的材质中我们会看到uniforms中更多参数的用法。

天空

  海水做完了,我们来实现天空中的效果;这里的太阳和天空是一体的,Three.js都集成到Sky类中,因此我们不需要去单独做一个太阳的物体,只要初始化一个太阳的位置,后续传入即可:

1
2
3
4
5
initMeshes() {
// 太阳初始化的位置
this.sun = new Vector3(-80, 5, -100);
this.water.material.uniforms["sunDirection"].value.copy(this.sun);
}

  这里Water的sunDirection是一个Vector3向量,我们使用copy函数将传入的太阳xyz位置进行赋值。我们在后面调试的时候,只要更改太阳的位置,就可以同时更改阳光在海水和天空的效果;

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Sky } from "three/examples/jsm/objects/Sky";
class Index {
initMeshes() {
this.sky = new Sky();
this.sky.scale.setScalar(10000);

this.sky.material.uniforms["sunPosition"].value.copy(this.sun);

this.sky.material.uniforms["turbidity"].value = 1;
this.sky.material.uniforms["rayleigh"].value = 1.5;
this.sky.material.uniforms["mieCoefficient"].value = 0.005;
this.sky.material.uniforms["mieDirectionalG"].value = 0.8;

this.scene.add(this.sky);
}
}

  这里实例化了一个天空,setScalar设置了一个放大的倍数,我们跟海平面设置成一样大小;uniforms中的sunPosition属性也是太阳的位置,我们传入sun位置即可;其他的一些属性也是调节天空的参数,我们在下面调试的时候会详细分析每个参数的意义。

  加上天空后,我们看一下页面的效果,现在整体的效果就更加的真实了,有种海上落日余晖的场景了。

天空效果

海水优化

  但是这里加了太阳之后,水面显示会有点泛白,是因为太阳的位置的向量长度太长;我们上面初始化太阳位置是一个Vector3三维向量,不过这并不表示太阳实际在天空中的真实位置,只是通过向量的角度方位来模拟太阳的位置;而向量是有长度的,向量越长,太阳光就越强烈,水面也就更白了。

  因此,这里需要介绍一下归一化的概念,归一化在机器学习中也有着广泛的应用,就是将所有的数据压缩到0到1之间的范围;Three.js中的归一化,其实就是将向量的xyz等比例缩放,将整个向量的长度缩放到长度为1。

更多向量的学习,可以参考这篇文章:向量方向(归一化.normalize)

  比如下面的向量p1,根据初中学的两点之间计算公式,它的长度是√(10*2+20*2+30*2),算出来长度大概是37多,而通过normalize函数之后,我们再去获取length,得到的就是单位1:

1
2
3
4
5
const p1 = new Vector3(10, 20, 30);
console.log(p1.length()); //37.416573867739416
// 将向量归一化处理
p1.normalize();
console.log(p1.length()); // 1

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  因此,回到太阳位置的设置,我们传到sunDirection.value中后,copy函数会将传入的sun位置复制到sunDirection向量,并返回自身向量;最后再调用一下normalize函数就可以将向量设置成单位向量了:

copy函数作用将所传入Vector3的x、y和z属性复制给这一Vector3,并返回自身。

1
2
3
4
5
this.sun = new Vector3(-80, 5, -100);
this.water.material
.uniforms["sunDirection"].value
.copy(this.sun)
.normalize();

我们通过查看Water.js源码中sunDirection初始值,发现是Vector3( 0.70707, 0.70707, 0 ),也是一个归一化的向量。

  再次看页面效果,我们发现海水也更加的柔和了,仿佛你女朋友看你的眼神一样的柔和:

海水效果

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

多面体

  在Three.js的Demo中,我们可以看到一个多面体在不停的上下浮沉,外面的材质被海水和天空所浸染,就像生活在大城市的我们一样,沾染着世俗气息,随波逐流。。。。

  我们首先构建一个二十面体的球形物体:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Index {
initMeshes() {
const geometry = new IcosahedronBufferGeometry(20, 1);
const material = new MeshStandardMaterial({
roughness: 0,
side: DoubleSide,
flatShading: true,
});

this.cube = new Mesh(geometry, material);
this.scene.add(this.cube);
}
}

  在渲染的时候,控制y轴方向做sin函数运动,同时绕着x轴和z轴不断的旋转:

1
2
3
4
5
6
7
8
class Index {
render() {
const now = Date.now();
this.cube.position.y = Math.sin(now * 0.001) * 20 + 5;
this.cube.rotation.x = now * 0.001 * 0.5;
this.cube.rotation.z = now * 0.001 * 0.5;
}
}

  这时,由于我们使用的MeshStandardMaterial材质,因此我们会看到一个灰色的,光滑的球体在水面上下漂浮。

  如果我们想让环境的光照射到圆球的表面,可以使用PMREMGenerator,它的全称叫预计算辐射度环境贴图(pre-computed radiance environment map,PMREM)生成器,PMREMGenerator可以根据当前场景和光照计算出辐射度环境贴图,并将其缓存在内存中,方便后续使用

1
2
3
4
5
6
7
8
9
10
11
import { PMREMGenerator } from "three";
class Index {
initMeshes() {
this.pmremGenerator = new PMREMGenerator(this.renderer);
if (this.renderTarget) {
this.renderTarget.dispose();
}
this.renderTarget = this.pmremGenerator.fromScene(this.scene);
this.scene.environment = this.renderTarget.texture;
}
}

  它的用法也很简单,构建一个类,然后调用fromScene从当前场景中生成辐射环境贴图,赋值给scene.environment

球体表面辐照效果

控制调试

  上面我们创建了蓝天、海水等物体,我们会看到材质的uniforms属性中有很多参数,但对每个参数的用法却并不清楚;本节我们就看实际看下每个参数的实际效果。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

太阳位置调试

  我们在创建物体之后,先来添加太阳位置的调试看下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import * as dat from "dat.gui";

class Index {
constructor() {
this.gui = new dat.GUI();
this.initMeshes();

this.enableGui();
}
enableGui() {
const folderSun = this.gui.addFolder("太阳位置");
folderSun
.add(this.sun, "x", -100, 100)
.onChange(this.updateSunPosition.bind(this));
folderSun
.add(this.sun, "y", -100, 100)
.onChange(this.updateSunPosition.bind(this));
folderSun
.add(this.sun, "z", -100, 100)
.onChange(this.updateSunPosition.bind(this));
}
// 更新太阳的位置
updateSunPosition() {
this.water.material.uniforms["sunDirection"].value
.copy(this.sun)
.normalize();
this.sky.material.uniforms["sunPosition"].value.copy(this.sun);
}
}

  通过addFolder单独创建一个单独展开的文件菜单,然后像里面添加对应的变量设置;由于这里我们已经有了全局的this.sun变量,它里面也有xyz属性,因此我们直接拿来用即可。更新参数后,我们需要同步更新water和sky的uniforms,因此这里我们抽离一个单独的函数updateSunPosition。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们改变太阳方位后,发现小球表面的光照辐射强度还是没有改变,这是因为我们在初始化的时候调用了pmremGenerator.fromScene生成了辐射环境贴图;因此在updateSunPosition函数中,我们再次调用,给小球表面进行重新渲染:

1
2
3
4
5
6
7
8
9
10
class Index {
updateSunPosition() {
// 其他代码...
if (this.renderTarget) {
this.renderTarget.dispose();
}
this.renderTarget = this.pmremGenerator.fromScene(this.scene);
this.scene.environment = this.renderTarget.texture;
}
}

海水调试

  我们还记得海水的WaterOptions参数中有很多的属性字段,我们看下不同属性的效果,首先是time属性,控制海水波浪起伏的速度,我们单独创建一个参数变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
enableGui() {
const folderWater = this.gui.addFolder("海水");
this.waterParams = {
speed: 1.0,
};

folderWater.add(this.waterParams, "speed", 0, 10).name("水流速度").step(0.1);
}
render() {
- this.water.material.uniforms["time"].value += 1.0 / 60;
+ this.water.material.uniforms["time"].value += this.waterParams.speed / 60;
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在render函数渲染的时候,将固定变量1.0替换成我们的参数变量,我们可以查看效果

  除了time,还有两个属性alpha和distortionScale可以加到我们的调试面板调试,alpha控制透明通道的值,色值越小越泛白;而distortionScale控制水面波纹的扭曲程度,数值越大,波纹越扭曲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
enableGui(){
this.waterParams = {
speed: 1.0,
alpha: 1.0,
distortionScale: 20,
};

folderWater.add(this.waterParams, "alpha", 0, 1)
.onChange((value) => {
this.water.material.uniforms["alpha"].value = value;
});
folderWater
.add(this.waterParams, "distortionScale", 0, 240, 0.1)
.name("扭曲比例")
.onChange((value) => {
this.water.material.uniforms["distortionScale"].value = value;
});
}
}

  我们将属性添加到gui中调试时默认展示属性的英文名称,比如这里的distortionScale,很多时候会不知道这个属性的作用;因此我们加个name函数给它一个中文的名称,在调试时更容易知道其作用。

  这里就不截图展示具体效果了,大家可以点击这里自己手动调试查看效果。

天空参数调试

  下面就来调试天空的参数,我们看下最重要的两个参数,turbidity浑浊度和rayleigh锐利值;还是和上面一个,我们在gui里给天空单独创建一个折叠的菜单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
enableGui() {
const folderSky = this.gui.addFolder("天空");
this.skyParams = {
turbidity: 1,
rayleigh: 1.5,
};
folderSky
.add(this.skyParams, "turbidity", 0, 100)
.name("浑浊度")
.onChange((value) => {
this.sky.material.uniforms["turbidity"].value = value;
});
folderSky
.add(this.skyParams, "rayleigh", 0, 100)
.name("锐利值")
.onChange((value) => {
this.sky.material.uniforms["rayleigh"].value = value;
});
}
}

  浑浊度turbidity大概的效果就是太阳被云层遮挡的光晕的浑浊程度,数值越小,太阳的轮廓就越清晰;锐利值rayleigh则更像是太阳被乌云遮住的感觉,数值越大越有日落西山的感觉。

  
浑浊度的效果

锐利值的效果

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  最终所有调试效果可以点击这里查看

总结

  学习Three.js很痛苦的一点就是很多时候不知道这里调用这个函数有什么用,还找不到资料解释,很多函数里面都会涉及到了数学或者图形学方面的知识,调试的不方便也极大的增加了我们学习的成本;同时网络上也充斥着各种版本的代码,质量也都参差不齐;比如笔者在学习water和sky的uniforms设置时,water后面调用了一个normalize函数,而sky没有,让人很费解,一开始并不了解其中的原理;不过通过更深层的学习查资料,加上不断的尝试,最终透彻理解了就有种恍然顿悟的感觉。

本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【海天一色】即可获取。


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。