Threejs 海天一色效果学习
Threejs中海天一色的效果是比较壮观好玩的,本文我们就学习一下如何一步步实现该效果,同时也能深入理解着色器中的Uniforms参数的作用和归一化的原理及效果。
环境准备
首先我们搭建Threejs的基本环境,我们将初始化的元素都封装到一个类中;在使用时,直接初始化类即可:
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
类,构建水平面物体:
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,因此我们改变这个值就可以控制水面波纹的渲染效果了。
// three/examples/jsm/objects/Water
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()); //37.416573867739416
// 将向量归一化处理
p1.normalize();
console.log(p1.length()); // 1
因此,回到太阳位置的设置,我们传到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属性中有很多参数,但对每个参数的用法却并不清楚;本节我们就看实际看下每个参数的实际效果。
太阳位置调试
我们在创建物体之后,先来添加太阳位置的调试看下效果:
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控制水面波纹的扭曲程度,数值越大,波纹越扭曲。
{
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里给天空单独创建一个折叠的菜单:
{
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没有,让人很费解,一开始并不了解其中的原理;不过通过更深层的学习查资料,加上不断的尝试,最终透彻理解。
如果觉得写得还不错,敬请关注我的掘金主页。更多文章请访问谢小飞的博客
推荐阅读
-
svg、canvas 波浪效果和 svg 路径学习记录
-
NeurIPS 2022 | 最强斗地主AI!网易互娱AI Lab提出基于完美信息蒸馏的方法-完美信息蒸馏(PTIE) 在斗地主游戏中,非完美信息的引入主要是由于三位玩家均不能看到别人的手牌,对于任意一位玩家而言,仅可知道其余两位玩家当前手牌的并集,而难于精准判断每位玩家当前手牌。完美信息蒸馏的思路是针对这种非完美问题,构建一个第三方角色,该角色可以看到三位玩家的手牌,该角色在不告知每位玩家完美信息的情况下通过信息蒸馏的方式引导玩家打出当前情况下合理的出牌。 以强化学习常用的 Actor-Critic 算法为例,PTIE 在 Actor-Critic 算法的应用中可以利用 Critic 的 Value 输出作为蒸馏手段来提升 Actor 的表现。具体而言即在训练中 Critic 的输入为完美信息(包含所有玩家的手牌信息),Actor 的输入为非完美信息(仅包含自己手牌信息),此种情况下 Critic 给予的 Value 值包含了完美信息,可以更好地帮助 Actor 学习到更好的策略。 从更新公式上来看,正常的 Actor-Critic 算法 Actor 更新的方式如下: 在 PTIE 模式下,对于每个非完美信息状态 h,我们可以在 Critic 中构建对应的完美信息状态 D(h),并用 Critic 的输出来更新 Actor 的策略梯度,从而达到完美信息蒸馏的效果。 PTIE 框架的整体结构如下图所示: 无论是训练还是执行过程中智能体都不会直接使用完美信息,在训练中通过蒸馏将完美信息用于提升策略,从而帮助智能体达到一个更高的强度。 PTIE 的另一种蒸馏方式是将完美信息奖励引入到奖励值函数的训练中,PerfectDou 提出了基于阵营设计的完美信息奖励 node reward,以引导智能体学习到斗地主游戏中的合作策略,其定义如下: 如上所示,完美信息部分 代表 t 时刻地主手牌最少几步可以出完,在斗地主游戏中可以近似理解为是距游戏获胜的距离, 代表 t 时刻地主阵营和农民阵营距游戏获胜的距离之差, 为调节系数。通过此种奖励设计,在训练时既可以一定程度地引入各玩家的手牌信息(出完的步数需要知道具体手牌才能计算),同时也鼓励农民以阵营的角度做出决策,提升农民的合作性。 特征构建: PerfectDou 针对牌类游戏的特点主要构建了两部分特征:牌局状态特征和动作特征。其中牌局状态特征主要包括当前玩家手牌牌型特征、当前玩家打出的卡牌牌型特征、玩家角色、玩家手牌数目等常用特征,动作特征主要用于刻画当前状态下玩家的所有可能出牌,包括了每种出牌动作的牌型特征、动作的卡牌数目、是否为最大动作等特征。 牌型特征为 12 * 15 的矩阵,如下图所示: 该矩阵前 4 行代表对应每种卡牌的张数,5-12 行代表该种卡牌的种类和对应位置。 网络结构和动作空间设计 针对斗地主游戏出牌组合数较多的问题,PerfectDou 基于 RLCard 的工作上对动作空间进行了简化,对占比最大的两个出牌牌型:飞机带翅膀和四带二进行了动作压缩,将整体动作空间由 27472 种缩减到 621 种。 PerfectDou 策略网络结构如下图所示: 策略网络结构同样分为两部分:状态特征部分和动作特征部分。 在状态特征部分,LSTM 网络用于提取玩家的历史行为特征,当前牌局状态特征和提取后的行为特征会再通过多层的 MLP 网络输出当前的状态信息 embedding。 在动作特征部分,每个可行动作同样会经过多层 MLP 网络进行编码,编码后的动作特征会与其对应的状态信息 embedding 经过一层 MLP 网络计算两者间的相似度,并经由 softmax 函数输出对应的动作概率。 实验结果
-
Threejs 海天一色效果学习
-
threejs-实战打造科技风智慧城市效果-持续更新中-创建场景 35 threejs-实战打造科技风智慧城市效果-持续更新中-创建场景 34
-
Threejs/Webgl智慧城市部分效果实现
-
[29]知识蒸馏(knowledge distillation)测试和利用可学习参数辅助知识蒸馏训练学生模型--这里主要是看了知识蒸馏测试的效果,其中包含温度设置带来的变化;以及可学习参数的设置,设置了一个通过调整两个参数来拟合直线的小实验!最后,知识蒸馏对学生网络的影响与可学习参数对知识蒸馏过程的影响进行了对比测试。
-
理解GPT-2:论文解析 - 无监督的多任务学习者,展现语言模型的强大效果
-
科技新突破 | Google在《Cell》杂志发表论文:光学显微镜加AI深度学习,创造荧光显微镜效果
-
UE4学习笔记(三):打造炫酷刀光拖尾效果 - 第三部分
-
简单易懂版 - 什么是粒子群算法(PSO)?" - PSO 是这样工作的: 想象一群小鸟寻找食物,它们会互相学习、竞争并跟随最优秀的伙伴。这就是模仿群体智慧(Swarm Intelligence,SI)的粒子群优化算法,由 Eberhart 博士和 Kennedy 博士创造,属于多智能体优化系统(MAOS)的一员。 - 数学背后的逻辑: - 每只“鸟”(粒子)依据邻居过去的发现来飞得更好: 1. 受到激励的好位置(Pbest) 2. 与附近伙伴的成绩对比 3. 阿婆姨领先者的模仿 - 模型简化来说,每个粒子像 D 维空间的理想点,按特定速度飞行,速度随自身经验和同伴表现实时调整。我们用 Xi 表示 D 个粒子的集合,其中 Pi 存储过最佳位置,Pg 是群体中最优的位置,Vi 是粒子的速度。 - 更新规则: - **速度更新**:有点像梯度下降法中的导数概念,但因鸟群数量大,能有效跳出局部最优区域,引导群体朝全局最优方向前进。 - **位置更新**:在固定的时间内,新移动的距离就是 Vi(即速度向量在单位时间内的累积效果)。 - 参数简述:粒子群算法涉及多个参数,如粒子数量、学习因子(影响对过去经验的重视程度)、加速常数(控制探索与利用之间的平衡),这些参数的选择会影响算法的实际性能和收敛速度。