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钩子函数中执行:
| <template> <div id="webgl-output"></div> </template> <script setup> import { onMounted } from "vue"; import Ocean from "./index"; onMounted(() => { new Ocean(); }); </script>
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
这个时候,我们只要一改变页面的宽高大小,我们的画布由于没有及时更新,就会出现空白的区域;我们在构造函数中绑定页面大小监听事件,重新更新renderer和相机:
| 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函数,解除监听事件:
| let ocean; onMounted(() => { ocean = new Ocean(); }); onBeforeUnmount(() => { ocean && ocean.beforeDestroy(); });
|
添加物体
环境初步搭建好后,我们就可以向画布上添加物体了,这里我们具体来学习水Water、天空Sky等物体的使用方式。
添加海水
这里我们需要从Three.js源码中获取一个法线贴图,拷贝到我们项目public目录:
| $ 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参数如下,其他属性的含义后面会进行调试:
| 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属性
来让纹理动起来:
| { render() { this.water.material.uniforms["time"].value += 1.0 / 60; } }
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
那么这里的uniforms是什么?为什么我们更改了time的value就可以让波纹动起来呢?我们悬浮查看一下Water的材质,发现它是一个ShaderMaterial
材质:
ShaderMaterial在定义顶点着色器和片元着色器之外,还会声明uniforms属性,可以给顶点着色器和片元着色器中的变量传值,达到不同的渲染效果,比如我们查看Water.js的源码就能看到它的uniforms属性里面有一个time
参数,初始化的value是0.0,因此我们改变这个值就可以控制水面波纹的渲染效果了。
| const material = new THREE.ShaderMaterial({ uniforms: { 'time': { value: 0.0 }, }, vertexShader: '着色器代码', fragmentShader: '着色器代码', });
|
在后面天空的材质中我们会看到uniforms中更多参数的用法。
天空
海水做完了,我们来实现天空中的效果;这里的太阳和天空是一体的,Three.js都集成到Sky类中,因此我们不需要去单独做一个太阳的物体,只要初始化一个太阳的位置,后续传入即可:
| initMeshes() { this.sun = new Vector3(-80, 5, -100); this.water.material.uniforms["sunDirection"].value.copy(this.sun); }
|
这里Water的sunDirection是一个Vector3向量,我们使用copy
函数将传入的太阳xyz位置进行赋值。我们在后面调试的时候,只要更改太阳的位置,就可以同时更改阳光在海水和天空的效果;
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| 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:
| const p1 = new Vector3(10, 20, 30); console.log(p1.length());
p1.normalize(); console.log(p1.length());
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
因此,回到太阳位置的设置,我们传到sunDirection.value中后,copy函数会将传入的sun位置复制到sunDirection向量,并返回自身向量;最后再调用一下normalize函数
就可以将向量设置成单位向量了:
copy函数作用将所传入Vector3的x、y和z属性复制给这一Vector3,并返回自身。
| 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中,我们可以看到一个多面体在不停的上下浮沉,外面的材质被海水和天空所浸染,就像生活在大城市的我们一样,沾染着世俗气息,随波逐流。。。。
我们首先构建一个二十面体的球形物体:
| 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轴不断的旋转:
| 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可以根据当前场景和光照计算出辐射度环境贴图,并将其缓存在内存中,方便后续使用
| 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函数中,我们再次调用,给小球表面进行重新渲染:
| class Index { updateSunPosition() { if (this.renderTarget) { this.renderTarget.dispose(); } this.renderTarget = this.pmremGenerator.fromScene(this.scene); this.scene.environment = this.renderTarget.texture; } }
|
海水调试
我们还记得海水的WaterOptions参数中有很多的属性字段,我们看下不同属性的效果,首先是time属性,控制海水波浪起伏的速度,我们单独创建一个参数变量:
| { 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没有,让人很费解,一开始并不了解其中的原理;不过通过更深层的学习查资料,加上不断的尝试,最终透彻理解了就有种恍然顿悟的感觉。
本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【海天一色】即可获取。