稀土掘金技术社区

three.js 模拟真实海洋(超详细教程,炫酷海洋)

很长一段时间没有在掘金发布新的文章了,开始觉得自己发的文章要么不能讲透彻,要么太简单没有必要。

今天用 three.js 模拟渲染海洋,我将点燃大海!!!我将带领读者们一步一步实现最终效果,源码。

超级详细!!!每一步超级详细!!!保证看完即会!!!

实现功能

  1. 波涛起伏的浪
  2. 海面的船只的高度应该适应海浪的高度
  3. 海面的船只应该会发生正确的几何变换(旋转,位移)

那么!开始吧!

1. 初始化 three 项目。

1.1 初始化:相机:camera,渲染器:renderer,场景:scene

        import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

let renderer, camera, scene, controls, clock,lineHelper

// 初始化场景基础元素(渲染器,相机,场景,控制器等等)
{
// 渲染器初始化
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

// 相机初始化
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(0, 10, 20);

// 窗口自适应
function resize() {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize, false);

// 场景
scene = new THREE.Scene();

// 控制器
controls = new OrbitControls(camera, renderer.domElement);

clock = new THREE.Clock();

}

function render() {
requestAnimationFrame(render);

const elapsedTime = clock.getElapsedTime()

controls.update();
renderer.render(scene, camera);
}

1.2 添加基础三维对象,添加一个Box模拟海面的小船,添加一条方向为(0,1,0)的线,模拟小船方向

        // 将三维对象加入场景
{
// 添加平行光
const light = new THREE.DirectionalLight(0xffffff, 0.5);
light.position.set(0, 10, 20)
scene.add(light);

// 添加平行光2
const light2 = new THREE.DirectionalLight(0xffffff, 0.1);
light2.position.set(-5, 5, -5)
scene.add(light2);

// 添加环境光
const light3 = new THREE.AmbientLight(0xffffff, 0.2)
scene.add(light3)

// 添加模拟小船
box = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshLambertMaterial());
scene.add(box)

// 添加法线辅助器
const helperGeometry = new THREE.BufferGeometry()
helperGeometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 5, 0]), 3))
const lineHelper = new THREE.LineSegments(helperGeometry, new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false }))
scene.add(lineHelper)

}

到此为止,页面效果如下,

Image

2. 创建海平面

第一步,创建基础平面,顶点数量设置为 100 * 100 便于后续修改顶点位置

        // 创建海平面
let material
{
material = new THREE.ShaderMaterial({wireFrame:true});

const geometry = new THREE.PlaneGeometry(100, 100, 500, 500);
geometry.rotateX(-Math.PI / 2);

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
}

Image

第二步,给海平面优化一下。

使用 函数,模拟海面起伏,当然,你也可以用

根据公式 可以得到,任意坐标下,海平面的高度

Image

给平面添加贴图,美化一下Image

起伏太大了,修改一下公式为 ,非常好看

Image

海面不会动?在着色器添加时间参数 uTime ,控制海面起伏以及纹理位移,海平面最终代码

        const SCALE = 5 // 控制海面起伏程度

const vertexShader = `
#define SCALE ${SCALE}.0
#include <common>
#include <logdepthbuf_pars_vertex>

varying vec2 vUv;

uniform float uTime;

float calculateSurface(float x, float z) {
float y = 0.0;

// 多个三角函数的叠加,增加随机性
y += (sin(x * 1.0 / SCALE + uTime * 1.0) + sin(x * 2.3 / SCALE + uTime * 1.5) + sin(x * 3.3 / SCALE + uTime * 0.4)) / 3.0;
y += (sin(z * 0.2 / SCALE + uTime * 1.8) + sin(z * 1.8 / SCALE + uTime * 1.8) + sin(z * 2.8 / SCALE + uTime * 0.8)) / 3.0;
return y;
}

void main() {
vUv = uv;
vec3 pos = position;

pos.y += calculateSurface(pos.x, pos.z);

gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);

#include <logdepthbuf_vertex>
}
`;

const fragmentShader = `
#include <common>
#include <logdepthbuf_pars_fragment>
varying vec2 vUv;

uniform sampler2D uMap;
uniform float uTime;
uniform vec3 uColor;

void main() {
#include <logdepthbuf_fragment>

vec2 uv = vUv * 10.0 + vec2(uTime * -0.05);

// uv
uv.y += 0.01 * (sin(uv.x * 3.5 + uTime * 0.35) + sin(uv.x * 4.8 + uTime * 1.05) + sin(uv.x * 7.3 + uTime * 0.45)) / 3.0;
uv.x += 0.12 * (sin(uv.y * 4.0 + uTime * 0.5) + sin(uv.y * 6.8 + uTime * 0.75) + sin(uv.y * 11.3 + uTime * 0.2)) / 3.0;
uv.y += 0.12 * (sin(uv.x * 4.2 + uTime * 0.64) + sin(uv.x * 6.3 + uTime * 1.65) + sin(uv.x * 8.2 + uTime * 0.45)) / 3.0;

// 纹理采样
vec4 tex1 = texture2D(uMap, uv * 1.0);
vec4 tex2 = texture2D(uMap, uv * 1.0 + vec2(0.2));

vec3 blue = uColor;

gl_FragColor = vec4(blue + vec3(tex1.a * 0.9 - tex2.a * 0.02), 1.0);
}

`;
const texture = new THREE.TextureLoader().load('./textures/water.png');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

const uniforms = {
uMap: { value: texture },
uTime: { value: 0 },
uColor: { value: new THREE.Color('#0051da') },
depthTest: true,
depthWrite: true,
};

material = new THREE.ShaderMaterial({
uniforms: uniforms,
vertexShader: vertexShader,
fragmentShader: fragmentShader,
side: THREE.DoubleSide,
wireframe: true
});

const geometry = new THREE.PlaneGeometry(100, 100, 500, 500);
geometry.rotateX(-Math.PI / 2);

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

在动画帧函数中更新时间参数

            material.uniforms.uTime.value = clock.getElapsedTime();

Image

3. 根据海平面的起伏,更新小船的高度

想必大家已经知道如何实现这个功能了,根据海平面的生成函数,计算小船的高度。

const position = box.position

const { x, z } = position
const { sin, cos, atan } = Math
position.y = (sin(x * 1.0 / SCALE + elapsedTime * 1.0) + sin(x * 2.3 / SCALE + elapsedTime * 1.5) + sin(x * 3.3 / SCALE + elapsedTime * 0.4)) / 3.0;
position.y += (sin(z * 0.2 / SCALE + elapsedTime * 1.8) + sin(z * 1.8 / SCALE + elapsedTime * 1.8) + sin(z * 2.8 / SCALE + elapsedTime * 0.8)) / 3.0;

效果,小船已经可以随着海面起伏更新高度

Image

4. 根据海面起伏的角度,更新小船的旋转信息,以及添加小船的加速度。

应该可以理解吧。不会画图,在二维斜面上,小船会倾斜,并且获得斜面的切线的速度,叠加到小船本身的速度上。

Image

在三维中,需要计算在某个坐标处的,海平面的切面,以及切面的面法线。

// 首先我们写下 dx 和 dz的求导公式,该求导公式由上述 的两个变量 x 和 z 分别进行求导得到。

function dx(x, t) {
const cos = Math.cos
return 1 / 3 * (cos(x / SCALE + t) / SCALE + cos(2.3 * x / SCALE + 1.5 * t) * 2.3 / SCALE + cos(3.3 * x / SCALE + 0.4 * t) * 3.3 / SCALE)
}

function dz(z, t) {
const cos = Math.cos
return 1 / 3 * (cos(0.2 * z / SCALE + 1.8 * t) * 0.2 / SCALE + cos(1.8 * z / SCALE + 1.8 * t) * 1.8 / SCALE + cos(2.8 * z / SCALE + 0.8 * t) * 2.8 / SCALE)
}

// 根据对应导数函数,求出x分量的斜率和z分量的斜率

// 求出 斜率kx 和斜率kz
const kx = dx(x, elapsedTime)
const kz = dz(z, elapsedTime)

// 根据斜率写出切面的面法线,如下图,橙色代表法线。蓝色代表斜率

Image
const n = new THREE.Vector3(-kx, 1, -kz).normalize();

// 计算旋转轴,以及旋转角度

Image

计算旋转轴,旋转轴可以根据向量的叉乘计算得出,

const axes = new THREE.Vector3().crossVectors(n, new THREE.Vector3(kx, 1, kz)).normalize()

计算旋转角度

function getAngleBetweenVectors(v1, v2, dotThreshold = 0.00005) {
let angle = 0;
const dot = v1.dot(v2);

if (dot > 1 - dotThreshold) {
angle = 0;
} else if (dot < dotThreshold - 1) {
angle = Math.PI;
} else {
angle = Math.acos(dot);
}
return angle;
}

const angle = getAngleBetweenVectors(new THREE.Vector3(0, 1, 0), n)

执行旋转操作

box.rotation.x = 0
box.rotation.y = 0
box.rotation.z = 0
box.rotateOnAxis(axes, -angle)

计算小船的加速度

// 小船基础速度
const speed = new THREE.Vector3(0,0,0)

// 机选小船加速度的方向
const dir = new THREE.Vector3().crossVectors(n, axes).normalize().divideScalar(100)

// 小船速度叠加了由于海平面倾斜带来的速度最终的速度
const newSpeed = speed.add(dir)

计算小船最终的位置

const endPosition = box.position.clone().addScaledVector(newSpeed, 1)

let y = (sin(x * 1.0 / SCALE + elapsedTime * 1.0) + sin(x * 2.3 / SCALE + elapsedTime * 1.5) + sin(x * 3.3 / SCALE + elapsedTime * 0.4)) / 3.0;
y += (sin(z * 0.2 / SCALE + elapsedTime * 1.8) + sin(z * 1.8 / SCALE + elapsedTime * 1.8) + sin(z * 2.8 / SCALE + elapsedTime * 0.8)) / 3.0;
const truePosition = new THREE.Vector3(endPosition.x, y, endPosition.z)

box.position.copy(truePosition)

最终效果

Image