雲間飛行3D实现

效果展示

原效果技术解析

这个效果是 Threejs 作者 Mr.doob 发表在个人网站的,虽然没有找到开源的 repo,但在右键源代码中可以看到该效果是用 Threejs R39 版本实现的,目前这个库最新版本是 R152 ,API 已经经过大幅修改,大部分不能兼容了。本文就是使用最新版 Threejs 重写这个效果。

源代码在这里可以看到,就不贴出来了。

原理简单来说就是使用一组 Plane 贴云朵的贴图,在镜头方向纵向排布,然后让这组平面从远到近移动

这个效果有两个细节值得注意:

  1. 原效果使用了 8000 个相同的平面,从直觉上应该就感觉可以优化。
  2. 云朵贴图在镜头前不是突然消失的,而是雾化渐变消失,这样过渡效果最好。

细节 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;

Textures – three.js docs

  • magFilter : 它们定义了当被纹理化的像素映射到小于或者等于1纹理元素(texel)的区域时,将要使用的纹理放大函数
  • minFilter : 它们定义了当被纹理化的像素映射到大于1纹理元素(texel)的区域时,将要使用的纹理缩小函数。

完成效果

Sirice-FEPlayground

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;
}

参考链接