用Three.js做个中秋轮播过渡特效

2,128 阅读5分钟

介绍

在中秋即将来临之际,本期将给大家介绍如何用three.js来完成2D图像的渲染和简单的shader效果编写以及动画的控制处理,来实现一个关于中秋题材的轮播图的动画过渡效果,希望大家会喜欢~

演示

正文

基础结构

import * as THREE from "three";

class Sketch {
  constructor({ background = "#000000", el = document.body }) {
    this.width = window.innerWidth;
    this.height = window.innerHeight;
    
    this.aspect = this.width / this.height;

    let frustumSize = 1;
    this.camera = new THREE.OrthographicCamera(
      frustumSize / -2,
      frustumSize / 2,
      frustumSize / 2,
      frustumSize / -2,
      -1000,
      1000
    );
    this.camera.aspect = this.aspect;

    this.scene = new THREE.Scene();
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setSize(this.width, this.height);
    this.renderer.setClearColor(background, 1.0);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));

    el.appendChild(this.renderer.domElement);
    this.isPlaying = false;

    this.init();
    return this;
  }
  async init() {
    this.addMesh();
    this.initReset();
    this.play();
  }
  initReset() {
    this.handleReset = this.reset.bind(this);
    window.addEventListener("resize", this.handleReset);
  }
  reset() {
    this.width = window.innerWidth;
    this.height = window.innerHeight;
    this.aspect = this.width / this.height;
    this.camera.aspect = this.aspect;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.width, this.height);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
  }
  destroy() {
    this.isPlaying = false;
    window.removeEventListener("resize", this.handleReset);
  }
  addMesh() {
    this.geometry = new THREE.PlaneGeometry();
    this.material = new THREE.ShaderMaterial({
       fragmentShader: `
        varying vec2 vUv;
        varying vec3 vNormal;

        uniform float time;

        void main(){
          gl_FragColor=vec4(vUv,0.,1.);
        }
      `,
      vertexShader: `
        varying vec2 vUv;
        void main() {
           vUv = uv;
           gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
      	}
	  `,
      uniforms: {}
      side: THREE.DoubleSide,
      transparent: true,
      depthTest: false,
      depthWrite: false,
      wireframe: false,
    });

    this.mesh = new THREE.Mesh(this.geometry, this.material);
    this.scene.add(this.mesh);
  }
  play() {
    if (this.isPlaying) return;
    this.isPlaying = true;
    this.render();
  }
  stop() {
    this.isPlaying = false;
  }
  render() {
    if (!this.isPlaying) return;
    window.requestAnimationFrame(() => {
      this.update();
      this.render();
    });
  }
  update() {
    const { scene, camera } = this;
    this.renderer.render(scene, camera);
  }
}

因为要渲染全屏的2D图,所以不需要添加灯光,主要讲解的也是怎么创建一个正交投影投影相机和一个平面。

我们先来介绍一下正交投影相机:

THREE.OrthographicCamera( left, right, top, bottom, near, far )
参数含义
left渲染空间的左边界
right渲染空间的右边界
top渲染空间的上边界
bottom渲染空间的下边界
near从距离相机多远的位置开始渲染,一般情况会设置一个很小的值。
far距离相机多远的位置截止渲染,如果设置的值偏小,会有部分场景看不到。

或许,我们平时大部分都是使用透视投影相机,因为它更接近于现实,但这次我们是使用的是正交投影相机,希望把2D图片绘制于一个平面,并且这个平面是全屏的且该平面相对于相机的距离对渲染的结果是没有影响的。通常这种相机多是用于2D游戏中,例如《模拟城市4》。

相机示意图.jpeg

接下来,我们会创建一个平面,但你会发现这个平面的材质使用了 ShaderMaterial ,因为 ShaderMaterial ,通过它可以创建自己的着色器程序,定义材质和物体如何显示。

// vertex
varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}

这里是顶点着色器,主要做这些的目的是把 uv 赋值给 vUv ,传递给下面的片元着色器使用。

// fragment
varying vec2 vUv;
varying vec3 vNormal;

uniform float time;

void main(){
    gl_FragColor=vec4(vUv,0.,1.);
}

这里先简单写一些片元着色器的内容,如果不写的话,默认是一整屏的红色,所以用了刚才顶点着色器传过来的 vUv ,用它的值来创建一张简单的uv色值纹理。

图1.png

渲染图片

我是用vite构建的项目,图片放在代码的路径下,所以我用vite之前读取该图片文件夹下的所有图片内容。

const images = import.meta.globEager(`./assets/imgs/*.jpg`);
const imgs = Object.values(images).map((mod) => mod.default);

接下来,就是按顺序加载这些图片了,这里写了一个简单的加载函数。

loader(imgs) {
    return new Promise((resolve, reject) => {
        let textureloader = new THREE.TextureLoader();
        let len = imgs.length;
        let textures = new Array(len);
        imgs.forEach((img, index) => {
            textureloader.load(img, (texture) => {
                textures[index] = texture;
                if (textures.filter((t) => t).length === len) {
                    resolve(textures);
                }
            });
        });
    });
}

在初始化的时候使用它,我们会得到textures数组,待会儿渲染的图片信息就存在里面。

async init() {
    this.textures = await this.loader(imgs);
    this.addMesh();
    this.initReset();
    this.play();
}

接下来,让我们把这些图片信息都放到 ShaderMaterial 中:

this.material = new THREE.ShaderMaterial({
      fragmentShader: `
            varying vec2 vUv;
            varying vec2 vPosition;

            uniform float progress;
            uniform vec4 resolution;
            uniform sampler2D t1;
            uniform sampler2D t2;

            void main(){
              vec2 newUV = vec2(vUv-vec2(.5)) * resolution.zw + vec2(.5);
              vec4 tt1=texture2D(t1,newUV);
              vec4 tt2=texture2D(t2,newUV);
              vec4 final=mix(tt1,tt2,progress);
              gl_FragColor=final;
            }
      `,
      vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
          }
		`,
      uniforms: {
        progress: {
          type: "f",
          value: 0,
        },
        resolution: {
          value: new THREE.Vector4(),
        },
        t1: {
          type: "t",
          value: this.textures[0],
        },
        t2: {
          type: "t",
          value: this.textures[1],
        },
      },
      side: THREE.DoubleSide,
      transparent: true,
      depthTest: false,
      depthWrite: false,
      wireframe: false,
    });

这里通过 uniforms 可以传入到片元着色器中很多信息,比如 progress 当前进度,resolution 图片分辨率, t1,t2 则为当前图片纹理和下一张图片纹理,fragmentShader 中用了内置mix混合函数可以将,当前图片和下一张图片根据当前进度数值去控制,范围是0到1。

当然还有一点,我们要根据图片尺寸放到屏幕中央,所以要编写一个函数来设置一下比例。

setTextureSize() {
    if(!this.material) return;
    this.imageAspect = 1280 / 1920;
    let a1, a2;
    if (this.height / this.width > this.imageAspect) {
        a1 = (this.width / this.height) * this.imageAspect;
        a2 = 1;
    } else {
        a1 = 1;
        a2 = this.height / this.width / this.imageAspect;
    }
    this.material.uniforms.resolution.value.x = this.width;
    this.material.uniforms.resolution.value.y = this.height;
    this.material.uniforms.resolution.value.z = a1;
    this.material.uniforms.resolution.value.w = a2;
}

过渡a.gif

自动过渡

刚才已经可以从第一张图片手动切换到第二张了,但是是用了最基础的混合,所以效果并不达到想要的效果。

现在,修改一下片元着色的内容。

varying vec2 vUv;
varying vec2 vPosition;

uniform float progress;
uniform vec4 resolution;
uniform sampler2D t1;
uniform sampler2D t2;

void main(){
  vec2 newUV=vec2(vUv-vec2(.5))*resolution.zw+vec2(.5);
  vec4 tt1=texture2D(t1,newUV);
  vec4 tt2=texture2D(t2,newUV);
  float dist=distance(tt1,tt2)*.5;
  float pr=step(dist,progress);
  vec4 final=mix(mix(tt1,tt2,pr),tt2,pr);
  gl_FragColor=final;
}

这里把根据过渡像素的颜色做了简单的差值和混合,接下来就是见证奇迹的时刻。

过渡b.gif

但是我们希望做的是自动过渡而不是手动控制,那么就要写一个函数,让 progress 进度自动从0到1,然后再同步替换到当前图片和下张图片,循环往复。

当然为了方便控制过渡的进度,我还引入了 gsap.js 来控制:

import { gsap } from "gsap";
class Sketch {
  constructor({ background = "#000000", el = document.body }) {
    // ...
      
    this.activeIndex = 0;
    this.progress = 0;
    this.isComplete = true;

    this.init();
    return this;
  }
  async init() {
    this.textures = await this.loader(imgs);
    this.addMesh();
    this.initReset();
    this.play();
    this.change();
  }
  change() {
    if (!this.material) return;
    if (!this.isComplete) return;
    this.isComplete = false;
    this.material.uniforms.t1.value = this.textures[this.activeIndex++];
    this.activeIndex %= this.textures.length;
    this.material.uniforms.t2.value = this.textures[this.activeIndex];
    gsap.fromTo(
      this,
      {
        progress: 0,
      },
      {
        duration: 2.1,
        delay: 1.2,
        progress: 1,
        onComplete: () => {
          this.isComplete = true;
          this.change();
        },
      }
    );
  }
  update() {
    const { scene, camera } = this;
    this.time += 0.01;
      
    if (this.material) {
      this.material.uniforms.progress.value = this.progress;
    }
    this.renderer.render(scene, camera);
  }
}

这里主要看新加的 change 方法,gsap.js 可以很轻松的控制 progress 过渡周期和停留时间。

写到这里就打工搞成了,一个简单的中秋轮播图过渡效果就完成了。

演示图.gif

结语

本篇算是比较基础的带小伙伴们了解three.js如何渲染二维场景和shader效果编写,希望大家会喜欢,也希望各位也发挥想象力实现更加惊艳的效果,如果感觉喜欢或者受用,记得一键三连哟~

最后,预祝大家中秋快乐,阖家团圆,幸福美满,或许,如今的行业不景气,但是只要心怀希望,静待花开月圆也是一种美好的境界。