Threejs海天一色效果学习

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

环境准备

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

javascript 复制代码
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钩子函数中执行:

vue 复制代码
<template>
  <div id="webgl-output"></div>
</template>
<script setup>
import { onMounted } from "vue";
import Ocean from "./index";
onMounted(() => {
  new Ocean();
});
</script>

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

javascript 复制代码
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函数,解除监听事件:

javascript 复制代码
let ocean;
onMounted(() => {
  ocean = new Ocean();
});
onBeforeUnmount(() => {
  ocean && ocean.beforeDestroy();
});

添加物体

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

添加海水

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

cmd 复制代码
$ cp three.js/examples/textures/waternormals.jpg demos/public/textures/

所谓的法线贴图(Normal Map)是一种纹理映射技术,用于在渲染过程中模拟物体表面的细节和几何形状。它通过使用RGB颜色值来存储每个像素点的法线方向信息。
法线贴图也广泛应用于游戏开发、动画制作、虚拟现实等领域,以提供更逼真和优化的视觉体验。

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

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

javascript 复制代码
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参数如下,其他属性的含义后面会进行调试:

typescript 复制代码
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属性来让纹理动起来:

javascript 复制代码
{
  render() {
    this.water.material.uniforms["time"].value += 1.0 / 60;
  }
}

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

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

javascript 复制代码
// three/examples/jsm/objects/Water
const material = new THREE.ShaderMaterial({
  uniforms: {
    // 省略其他参数
    'time': { value: 0.0 },
  },
  vertexShader: '着色器代码',// 顶点着色器
  fragmentShader: '着色器代码',// 片元着色器
});

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

天空

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

javascript 复制代码
  initMeshes() {
    // 太阳初始化的位置
    this.sun = new Vector3(-80, 5, -100);
    this.water.material.uniforms["sunDirection"].value.copy(this.sun);
  }

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

javascript 复制代码
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:

javascript 复制代码
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,并返回自身。

javascript 复制代码
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中,我们可以看到一个多面体在不停的上下浮沉,外面的材质被海水和天空所浸染,就像生活在大城市的我们一样,沾染着世俗气息,随波逐流。。。。

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

javascript 复制代码
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轴不断的旋转:

javascript 复制代码
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可以根据当前场景和光照计算出辐射度环境贴图,并将其缓存在内存中,方便后续使用

javascript 复制代码
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属性中有很多参数,但对每个参数的用法却并不清楚;本节我们就看实际看下每个参数的实际效果。

太阳位置调试

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

javascript 复制代码
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函数中,我们再次调用,给小球表面进行重新渲染:

javascript 复制代码
class Index {
  updateSunPosition() {
    // 其他代码...
    if (this.renderTarget) {
      this.renderTarget.dispose();
    }
    this.renderTarget = this.pmremGenerator.fromScene(this.scene);
    this.scene.environment = this.renderTarget.texture;
  }
}

海水调试

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

javascript 复制代码
{
  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控制水面波纹的扭曲程度,数值越大,波纹越扭曲。

javascript 复制代码
{
  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里给天空单独创建一个折叠的菜单:

javascript 复制代码
{
  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没有,让人很费解,一开始并不了解其中的原理;不过通过更深层的学习查资料,加上不断的尝试,最终透彻理解。

如果觉得写得还不错,敬请关注我的掘金主页。更多文章请访问谢小飞的博客

相关推荐
下雪天的夏风12 分钟前
TS - tsconfig.json 和 tsconfig.node.json 的关系,如何在TS 中使用 JS 不报错
前端·javascript·typescript
diygwcom24 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
Hello-Mr.Wang41 分钟前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七4 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
(⊙o⊙)~哦6 小时前
JavaScript substring() 方法
前端
无心使然云中漫步7 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript
Bug缔造者7 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_7 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js
麒麟而非淇淋8 小时前
AJAX 入门 day1
前端·javascript·ajax