效果展示
原效果技术解析
这个效果是 Threejs 作者 Mr.doob 发表在个人网站的,虽然没有找到开源的 repo,但在右键源代码中可以看到该效果是用 Threejs R39 版本实现的,目前这个库最新版本是 R152 ,API 已经经过大幅修改,大部分不能兼容了。本文就是使用最新版 Threejs 重写这个效果。
源代码在这里可以看到,就不贴出来了。
原理简单来说就是使用一组 Plane
贴云朵的贴图,在镜头方向纵向排布,然后让这组平面从远到近移动
这个效果有两个细节值得注意:
- 原效果使用了 8000 个相同的平面,从直觉上应该就感觉可以优化。
- 云朵贴图在镜头前不是突然消失的,而是雾化渐变消失,这样过渡效果最好。
细节 1
细节 1 作者使用了 Geometry 聚合,将多个 plane
合成一个,然后再加材质。GeometryUtils
这个类在 R152 版本叫 BufferGeometryUtils
// Threejs R39
var plane = new THREE.Mesh( new THREE.Plane( 64, 64 ) );
for ( i = 0; i < 8000; i++ ) {
plane.position.x = Math.random() * 1000 - 500;
plane.position.y = - Math.random() * Math.random() * 200 - 15;
plane.position.z = i;
plane.rotation.z = Math.random() * Math.PI;
plane.scale.x = plane.scale.y = Math.random() * Math.random() * 1.5 + 0.5;
GeometryUtils.merge( geometry, plane );
}
mesh = new THREE.Mesh( geometry, material );
scene.addObject( mesh );
细节 2
细节 2 作者使用的不是标准材质而是 ShaderMaterial,因为这样便于自定义控制消失时的过渡效果。我写到这里感觉(如果绕过 Shader 使用标准材质)理论上通过判断 plane 距离镜头的距离修改 Material.opacity
应该也可以实现个大概。
// Vertex Shader
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
// Fragment Shader
uniform sampler2D map;
uniform vec3 fogColor;
uniform float fogNear;
uniform float fogFar;
varying vec2 vUv;
void main() {
float depth = gl_FragCoord.z / gl_FragCoord.w;
float fogFactor = smoothstep( fogNear, fogFar, depth );
gl_FragColor = texture2D( map, vUv );
gl_FragColor.w *= pow( gl_FragCoord.z, 20.0 );
gl_FragColor = mix( gl_FragColor, vec4( fogColor, gl_FragColor.w ), fogFactor );
}
可以看到 Fragment Shader 中最后的着色部分使用了结合距离的计算,实现雾化。
细节 3
mesh = new THREE.Mesh( geometry, material );
scene.addObject( mesh );
mesh = new THREE.Mesh( geometry, material );
mesh.position.z = - 8000;
scene.addObject( mesh );
...
position = ( ( new Date().getTime() - start_time ) * 0.03 ) % 8000;
camera.position.z = - position + 8000;
这里添加了两组云朵,当时间足够长时,camera 位置归零时前面一组都在投影平面后面,第二组云朵还在镜头前,位置变动也不会引起观察者的察觉。
用 InstancedMesh 优化性能
在对细节 1 的实现中,现版本 Threejs 下有 InstancedMesh
这个类,相比于BufferGeometryUtils
聚合 Geometry,这个类直接聚合 Mesh。
官网介绍是这样写的:
一种具有实例化渲染支持的特殊版本的 Mesh。你可以使用 InstancedMesh 来渲染大量具有相同几何体与材质、但具有不同世界变换的物体。 使用 InstancedMesh 将帮助你减少 draw call 的数量,从而提升你应用程序的整体渲染性能。
我实现的代码如下:
let count = 8000 // 多少片云实例,同时也是 MergedMesh 的z轴宽度(0-count)
const size = 64 // 每片云的尺寸
const plane = new THREE.Mesh(new THREE.PlaneGeometry(size, size))
const MergedMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(size, size), material, count)
for (let i = 0; i < count; i++) {
plane.position.x = Math.random() * 1000 - 500;
plane.position.y = - Math.random() * Math.random() * 200 - 15;
plane.position.z = i;
plane.rotation.z = Math.random() * Math.PI;
plane.scale.x = plane.scale.y = Math.random() * Math.random() * 1.5 + 0.5;
plane.updateMatrix()
MergedMesh.setMatrixAt(i, plane.matrix)
}
MergedMesh.instanceMatrix.needsUpdate = true;
scene.add(MergedMesh)
以 plane
为实例,随机修改每个平面的位置大小缩放旋转,将该模板的 matrix
应用到 MergedMesh
类中每一个实例中去。
这里可以根本不使用 plane
,只用矩阵变换效果也完全一致,因为需要从模板中提取的信息也是变换信息。
let count = 8000 // 多少片云实例,同时也是 MergedMesh 的z轴宽度(0-count)
const size = 64 // 每片云的尺寸
const MergedMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(size, size), material, count)
for (let i = 0; i < count; i++) {
let T = new THREE.Matrix4().makeTranslation(Math.random() * 1000 - 500, -Math.random() * Math.random() * 200 - 15, i)
let R = new THREE.Matrix4().makeRotationZ(Math.random() * Math.PI)
const scale = Math.random() * Math.random() * 1.5 + 0.5
let S = new THREE.Matrix4().makeScale(scale, scale, 1)
let matrix = new THREE.Matrix4().multiply(T).multiply(R).multiply(S)
MergedMesh.setMatrixAt(i, matrix)
}
MergedMesh.instanceMatrix.needsUpdate = true;
scene.add(MergedMesh)
使用InstancedMesh和直接添加8000个平面的性能对比(Nvidia 750Ti):
这个方式实际显示效果只有一朵被加深 8000 次的云,并不是预期效果,具体原因看下个小结。
ShaderMaterial 对 InstancedMesh 不生效
上节提出的问题出现在哪呢?经过各种调试可以确定是材质的问题。
这里有个调试小技巧,排除材质调试时可以用这个法线贴图,每个面的颜色都不同并且不需要光照环境,方便我们看到每个面的颜色
new THREE.MeshNormalMaterial({ flatShading: true });
经过调试定位问题出现在 Vertex Shader 上,表现为单个物体的材质可以正常显示,但 InstancedMesh 上只能显示在相同位置
查谷歌在 SOF 上得到这样的回答:Three.js InstancedMesh: Shader is NOT changed after updating instance matrix - Stack Overflow
这个回答指向了 WebGLProgram – three.js docs
#ifdef USE_INSTANCING
// Note that modelViewMatrix is not set when rendering an instanced model,
// but can be calculated from viewMatrix * modelMatrix.
//
// Basic Usage:
// gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
attribute mat4 instanceMatrix;
#endif
顶点着色器通常是这样计算:
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
// 或者
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
但对于 instancedMesh,gl_Position
的计算过程和正常不一样,要单独乘以预定义的 instanceMatrix
修改后的 Vertex Shader 如下:
varying vec2 vUv;
void main() {
vUv = uv;
#ifdef USE_INSTANCING
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4( position, 1.0 );
#else
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
#endif
}
经过修改,对于单个物体还是 instancedMesh 着色器都可以正常渲染。
纹理滤镜
const texture = new THREE.TextureLoader().load('./Clouds/cloud10.png');
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipMapLinearFilter;
- magFilter : 它们定义了当被纹理化的像素映射到小于或者等于1纹理元素(texel)的区域时,将要使用的纹理放大函数
- minFilter : 它们定义了当被纹理化的像素映射到大于1纹理元素(texel)的区域时,将要使用的纹理缩小函数。
完成效果
index.vue (vue2)
<template>
<section class="wrapper">
<div id="mountedDOM"></div>
<div class="stat-container"></div>
<div class="gui-container" ref="GUIcontainer"></div>
</section>
</template>
<script>
import { run, dispose } from "./main";
export default {
mounted() {
run(document.getElementById("mountedDOM"));
},
beforeDestroy() {
dispose()
}
};
</script>
main.js (threejs R152)
/* eslint-disable */
import * as THREE from "three";
import { vertexShader, fragmentShader } from './shaders'
import Stats from "stats.js";
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
let scene, renderer, camera;
let windowHalfX = window.innerWidth / 2;
let windowHalfY = window.innerHeight / 2;
let mouseX = 0, mouseY = 0
const clock = new THREE.Clock()
const stats = new Stats();
const params = {
speed:1
}
export function run(DOM = null) {
init(DOM);
setGUI();
render();
}
function init(DOM) {
const [width, height] = [DOM.clientWidth, DOM.clientHeight];
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
DOM.appendChild(renderer.domElement);
document.getElementsByClassName('stat-container')[0].appendChild(stats.dom)
scene = new THREE.Scene();
scene.background = new THREE.Color(0x326696)
camera = new THREE.PerspectiveCamera(30, width / height, 1, 18000);
camera.position.z = 6000;
scene.add(camera);
const texture = new THREE.TextureLoader().load('./Clouds/cloud10.png');
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipMapLinearFilter;
const material = new THREE.ShaderMaterial({
uniforms: {
map: { value: texture },
fogColor: { value: new THREE.Color(0x4584b4) },
fogNear: { value: -100 },
fogFar: { value: 3000 },
},
vertexShader,
fragmentShader,
side: THREE.DoubleSide
});
material.depthWrite = false;
material.depthTest = false;
material.transparent = true;
// const length = 64
// const plane = new THREE.Mesh(new THREE.PlaneGeometry(length, length), material);
// for (let i = 0; i < 8000; i++) {
// let T = new THREE.Matrix4().makeTranslation(Math.random() * 1000 - 500, -Math.random() * Math.random() * 200 - 15, i)
// let R = new THREE.Matrix4().makeRotationZ(Math.random() * Math.PI)
// const scale = Math.random() * Math.random() * 1.5 + 0.5
// let S = new THREE.Matrix4().makeScale(scale, scale, 1)
// let matrix = new THREE.Matrix4().multiply(T).multiply(R).multiply(S)
// let cloneP = plane.clone()
// cloneP.applyMatrix4(matrix)
// scene.add(cloneP)
// }
let count = 8000 // 多少片云实例,同时也是 MergedMesh 的z轴宽度(0-count)
const size = 64 // 每片云的尺寸
const MergedMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(size, size), material, count)
for (let i = 0; i < count; i++) {
let T = new THREE.Matrix4().makeTranslation(Math.random() * 1000 - 500, -Math.random() * Math.random() * 200 - 15, i)
let R = new THREE.Matrix4().makeRotationZ(Math.random() * Math.PI)
const scale = Math.random() * Math.random() * 1.5 + 0.5
let S = new THREE.Matrix4().makeScale(scale, scale, 1)
let matrix = new THREE.Matrix4().multiply(T).multiply(R).multiply(S)
MergedMesh.setMatrixAt(i, matrix)
}
MergedMesh.instanceMatrix.needsUpdate = true;
scene.add(MergedMesh)
// 两片云交替,防止突兀切换
const MergedMeshCopy = MergedMesh.clone();
MergedMeshCopy.position.z = -count;
scene.add(MergedMeshCopy);
document.addEventListener('mousemove', onDocumentMouseMove, false);
window.addEventListener("resize", onResize);
}
function onDocumentMouseMove(event) {
mouseX = (event.clientX - windowHalfX) * 0.25;
mouseY = (event.clientY - windowHalfY) * 0.15;
}
function render() {
if (camera) {
let position = (clock.getElapsedTime() * 10) % 8000;
camera.position.x += (mouseX - (camera.position.x || 0)) * 0.01
camera.position.y += (-mouseY - (camera.position.y || 0)) * 0.01
camera.position.z = -position*params.speed + 6000;
}
if (!renderer) return
stats.update();
renderer.render(scene, camera)
requestAnimationFrame(render);
}
function setGUI() {
const gui = new GUI({
container: document.getElementsByClassName('gui-container')[0]
});
gui.add(params,'speed',0.1,10,0.1).name('flight speed')
}
function onResize() {
const [width, height] = [window.innerWidth, window.innerHeight];
windowHalfX = width / 2;
windowHalfY = height / 2;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
export function dispose() {
window.removeEventListener("resize", onResize);
scene.traverse((child) => {
if (child.material) {
child.material.dispose();
}
if (child.geometry) {
child.geometry.dispose();
}
child = null;
});
renderer.forceContextLoss();
renderer.renderLists.dispose()
renderer.dispose();
scene.clear();
scene = null;
camera = null;
renderer.domElement = null;
renderer = null;
}
参考链接
- 效果原作者 Mr.doob | Clouds
- 来源:Clouds by Mr.doob - Experiments with Google 这个页面还有很多有意思的效果