用Three.js创建一个排版运动轨迹效果的教程

344 阅读11分钟

当涉及到创建先进的图形效果时,帧缓冲器是WebGL的一个关键功能,如景深、绽放、电影颗粒或各种类型的抗锯齿,并且已经在Codrops上进行了深入介绍。它们允许我们对我们的场景进行 "后处理",在渲染后对它们应用不同的效果。但它们究竟是如何工作的呢?

默认情况下,WebGL(以及Three.js和所有其他建立在它之上的库)会渲染到默认的帧缓冲区,也就是设备屏幕。如果你以前使用过Three.js或其他WebGL框架,你就会知道,你用正确的几何形状和材料创建网格,渲染它,然后就可以在屏幕上看到它。

然而,作为开发者,我们可以在默认的帧缓冲区之外创建新的帧缓冲区,并明确指示WebGL对其进行渲染。通过这样做,我们将场景渲染到显卡内存中的图像缓冲区而不是设备屏幕上。之后,我们可以像对待普通纹理一样对待这些图像缓冲区,并在最终渲染到设备屏幕前应用过滤器和效果。

这里有一段视频,详细介绍了《_合金装备5:幻痛》_中的后期处理和效果,真正把这个想法带入了现实。注意它是如何从实际的游戏画面开始渲染到默认的帧缓冲区(设备屏幕),然后分解出每个帧缓冲区的样子。所有这些帧缓冲器在每一帧上都被合成在一起,其结果就是你在玩游戏时看到的最终画面。

所以,随着理论的发展,让我们通过渲染到帧缓冲区来创建一个很酷的排版运动轨迹效果吧

我们的骨架应用程序

让我们使用threejs ,将一些2D文本渲染到默认的帧缓冲区,即设备屏幕。这里是我们的模板。

const LABEL_TEXT = 'ABC'

const clock = new THREE.Clock()
const scene = new THREE.Scene()

// Create a threejs renderer:
// 1. Size it correctly
// 2. Set default background color
// 3. Append it to the page
const renderer = new THREE.WebGLRenderer()
renderer.setClearColor(0x222222)
renderer.setClearAlpha(0)
renderer.setSize(innerWidth, innerHeight)
renderer.setPixelRatio(devicePixelRatio || 1)
document.body.appendChild(renderer.domElement)

// Create an orthographic camera that covers the entire screen
// 1. Position it correctly in the positive Z dimension
// 2. Orient it towards the scene center
const orthoCamera = new THREE.OrthographicCamera(
  -innerWidth / 2,
  innerWidth / 2,
  innerHeight / 2,
  -innerHeight / 2,
  0.1,
  10,
)
orthoCamera.position.set(0, 0, 1)
orthoCamera.lookAt(new THREE.Vector3(0, 0, 0))

// Create a plane geometry that spawns either the entire
// viewport height or width depending on which one is bigger
const labelMeshSize = innerWidth > innerHeight ? innerHeight : innerWidth
const labelGeometry = new THREE.PlaneBufferGeometry(
  labelMeshSize,
  labelMeshSize
)

// Programmaticaly create a texture that will hold the text
let labelTextureCanvas
{
  // Canvas and corresponding context2d to be used for
  // drawing the text
  labelTextureCanvas = document.createElement('canvas')
  const labelTextureCtx = labelTextureCanvas.getContext('2d')

  // Dynamic texture size based on the device capabilities
  const textureSize = Math.min(renderer.capabilities.maxTextureSize, 2048)
  const relativeFontSize = 20
  // Size our text canvas
  labelTextureCanvas.width = textureSize
  labelTextureCanvas.height = textureSize
  labelTextureCtx.textAlign = 'center'
  labelTextureCtx.textBaseline = 'middle'

  // Dynamic font size based on the texture size
  // (based on the device capabilities)
  labelTextureCtx.font = `${relativeFontSize}px Helvetica`
  const textWidth = labelTextureCtx.measureText(LABEL_TEXT).width
  const widthDelta = labelTextureCanvas.width / textWidth
  const fontSize = relativeFontSize * widthDelta
  labelTextureCtx.font = `${fontSize}px Helvetica`
  labelTextureCtx.fillStyle = 'white'
  labelTextureCtx.fillText(LABEL_TEXT, labelTextureCanvas.width / 2, labelTextureCanvas.height / 2)
}
// Create a material with our programmaticaly created text
// texture as input
const labelMaterial = new THREE.MeshBasicMaterial({
  map: new THREE.CanvasTexture(labelTextureCanvas),
  transparent: true,
})

// Create a plane mesh, add it to the scene
const labelMesh = new THREE.Mesh(labelGeometry, labelMaterial)
scene.add(labelMesh)

// Start out animation render loop
renderer.setAnimationLoop(onAnimLoop)

function onAnimLoop() {
  // On each new frame, render the scene to the default framebuffer 
  // (device screen)
  renderer.render(scene, orthoCamera)
}

这段代码简单地初始化了一个threejs 场景,添加了一个带有文本纹理的2D平面,并将其渲染到默认的帧缓冲区(设备屏幕)。如果我们在项目中包含threejs ,我们会得到这个结果。

同样,我们没有明确指定,所以我们正在渲染到默认的帧缓冲区(设备屏幕)。

现在我们已经成功地将我们的场景渲染到设备屏幕上了,让我们添加一个帧缓冲区(THEEE.WebGLRenderTarget)并将其渲染到显卡内存中的纹理上。

渲染到帧缓冲区

让我们在初始化我们的应用程序时,先创建一个新的帧缓冲区。

const clock = new THREE.Clock()
const scene = new THREE.Scene()

// Create a new framebuffer we will use to render to
// the video card memory
const renderBufferA = new THREE.WebGLRenderTarget(
  innerWidth * devicePixelRatio,
  innerHeight * devicePixelRatio
)

// ... rest of application

现在我们已经创建了它,我们必须明确指示threejs ,向它而不是默认的帧缓冲区,即设备屏幕进行渲染。我们将在我们的程序动画循环中这样做。

function onAnimLoop() {
  // Explicitly set renderBufferA as the framebuffer to render to
  renderer.setRenderTarget(renderBufferA)
  // On each new frame, render the scene to renderBufferA
  renderer.render(scene, orthoCamera)
}

正如你所看到的,我们得到的是一个空的屏幕,但我们的程序没有错误--那么发生了什么?好吧,我们不再向设备屏幕渲染,而是向另一个帧缓冲区渲染!我们的场景被渲染到了另一个帧缓冲区。我们的场景被渲染到显卡内存中的一个纹理上,所以这就是我们看到空屏幕的原因。

为了将这个生成的包含我们场景的纹理显示到默认的帧缓冲区(设备屏幕),我们需要创建另一个2D平面,它将覆盖我们应用程序的整个屏幕,并将纹理作为材质输入传递给它。

首先,我们将创建一个全屏的2D平面,它将横跨整个设备屏幕。

// ... rest of initialisation step

// Create a second scene that will hold our fullscreen plane
const postFXScene = new THREE.Scene()

// Create a plane geometry that covers the entire screen
const postFXGeometry = new THREE.PlaneBufferGeometry(innerWidth, innerHeight)

// Create a plane material that expects a sampler texture input
// We will pass our generated framebuffer texture to it
const postFXMaterial = new THREE.ShaderMaterial({
  uniforms: {
    sampler: { value: null },
  },
  // vertex shader will be in charge of positioning our plane correctly
  vertexShader: `
      varying vec2 v_uv;

      void main () {
        // Set the correct position of each plane vertex
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

        // Pass in the correct UVs to the fragment shader
        v_uv = uv;
      }
    `,
  fragmentShader: `
      // Declare our texture input as a "sampler" variable
      uniform sampler2D sampler;

      // Consume the correct UVs from the vertex shader to use
      // when displaying the generated texture
      varying vec2 v_uv;

      void main () {
        // Sample the correct color from the generated texture
        vec4 inputColor = texture2D(sampler, v_uv);
        // Set the correct color of each pixel that makes up the plane
        gl_FragColor = inputColor;
      }
    `
})
const postFXMesh = new THREE.Mesh(postFXGeometry, postFXMaterial)
postFXScene.add(postFXMesh)

// ... animation loop code here, same as before

正如你所看到的,我们正在创建一个新的场景,它将容纳我们的全屏平面。创建之后,我们需要增强我们的动画循环,将上一步生成的纹理渲染到我们屏幕上的全屏平面上。

function onAnimLoop() {
  // Explicitly set renderBufferA as the framebuffer to render to
  renderer.setRenderTarget(renderBufferA)

  // On each new frame, render the scene to renderBufferA
  renderer.render(scene, orthoCamera)
  
  // 👇
  // Set the device screen as the framebuffer to render to
  // In WebGL, framebuffer "null" corresponds to the default 
  // framebuffer!
  renderer.setRenderTarget(null)

  // 👇
  // Assign the generated texture to the sampler variable used
  // in the postFXMesh that covers the device screen
  postFXMesh.material.uniforms.sampler.value = renderBufferA.texture

  // 👇
  // Render the postFX mesh to the default framebuffer
  renderer.render(postFXScene, orthoCamera)
}

包括这些片段后,我们可以看到我们的场景再次在屏幕上渲染出来了。

让我们回顾一下在每个渲染循环中在我们的屏幕上产生这个图像所需的必要步骤:

  1. 创建renderTargetA framebuffer,这将使我们能够在用户设备的视频内存中渲染到一个单独的纹理。
  2. 创建我们的 "ABC "平面网格
  3. 将 "ABC "平面网格渲染到renderTargetA ,而不是设备屏幕上。
  4. 创建一个单独的全屏平面网格,期望将纹理作为其材质的输入。
  5. 使用渲染 "ABC "网格时生成的纹理,将全屏网格渲染回默认的帧缓冲区(设备屏幕)。renderTargetA

通过使用两个帧缓冲器来实现持久化效果

如果我们像现在这样简单地将帧缓冲器显示在设备屏幕上,那么我们对帧缓冲器的使用就不多了。现在我们已经准备好了我们的设置,让我们实际做一些很酷的后期处理。

首先,我们要创建另一个帧缓冲区--renderTargetB ,并确保它和renderTargetAlet 变量,而不是consts。这是因为我们要在每次渲染结束时交换它们,这样我们就可以实现帧缓冲区的乒乓效应。

WebGl中的 "乒乓 "是一种交替使用帧缓冲区作为输入或输出的技术。这是一个巧妙的技巧,允许通用的GPU计算,并用于高斯模糊等效果,为了模糊我们的场景,我们需要:

  1. 使用2D平面将其渲染到framebuffer A ,并通过片段着色器应用水平模糊。
  2. 将步骤1中水平模糊的图像渲染到framebuffer B ,并通过片段着色器应用垂直模糊。
  3. 交换framebuffer Aframebuffer B
  4. 继续重复步骤1到3,逐步应用模糊,直到达到所需的高斯模糊半径。

这里有一个小图表,说明了实现乒乓的步骤。

因此,考虑到这一点,我们将使用我们创建的postFXMesh ,将renderTargetA 的内容渲染到renderTargetB ,并通过片段着色器应用一些特殊效果。

让我们通过创建我们的renderTargetB 来开始吧。

let renderBufferA = new THREE.WebGLRenderTarget(
  // ...
)
// Create a second framebuffer
let renderBufferB = new THREE.WebGLRenderTarget(
  innerWidth * devicePixelRatio,
  innerHeight * devicePixelRatio
)

接下来,让我们增强我们的动画循环,以真正做到乒乓技术。

function onAnimLoop() {
  // 👇
  // Do not clear the contents of the canvas on each render
  // In order to achieve our ping-pong effect, we must draw
  // the new frame on top of the previous one!
  renderer.autoClearColor = false

  // 👇
  // Explicitly set renderBufferA as the framebuffer to render to
  renderer.setRenderTarget(renderBufferA)

  // 👇
  // Render the postFXScene to renderBufferA.
  // This will contain our ping-pong accumulated texture
  renderer.render(postFXScene, orthoCamera)

  // 👇
  // Render the original scene containing ABC again on top
  renderer.render(scene, orthoCamera)
  
  // Same as before
  // ...
  // ...
  
  // 👇
  // Ping-pong our framebuffers by swapping them
  // at the end of each frame render
  const temp = renderBufferA
  renderBufferA = renderBufferB
  renderBufferB = temp
}

如果我们用这些更新的片段再次渲染我们的场景,我们将看不到任何视觉上的差异,即使我们事实上是在两个帧缓冲器之间交替进行渲染。这是因为,就像现在这样,我们没有在我们的postFXMesh 的片段着色器中应用任何特殊效果。

让我们像这样改变我们的片段着色器。

// Sample the correct color from the generated texture
// 👇
// Notice how we now apply a slight 0.005 offset to our UVs when
// looking up the correct texture color

vec4 inputColor = texture2D(sampler, v_uv + vec2(0.005));
// Set the correct color of each pixel that makes up the plane
// 👇
// We fade out the color from the previous step to 97.5% of
// whatever it was before
gl_FragColor = vec4(inputColor * 0.975);

让我们分解一下我们更新的例子的一帧渲染:

  1. 我们将renderTargetB 结果渲染到renderTargetA
  2. 我们将我们的 "ABC "文本渲染到renderTargetA ,将其合成在步骤1中的renderTargetB 结果之上(我们在新的渲染中不清除画布的内容,因为我们设置了renderer.autoClearColor = false )。
  3. 我们将生成的renderTargetA 纹理传递给postFXMesh ,在查找纹理颜色时对其UV应用一个小的偏移vec2(0.002) ,并通过将结果乘以 ,使其淡化一些。0.975
  4. 我们将postFXMesh 渲染到设备屏幕上
  5. 我们将renderTargetArenderTargetB 进行交换(ping-ponging)。

对于每一个新的框架渲染,我们将重复步骤1到5。这样一来,我们之前渲染的目标帧缓冲区将被用作当前渲染的输入,以此类推。在上一个演示中,你可以清楚地看到这种效果--注意到随着乒乓运动的进行,越来越多的偏移量被应用到UV上,越来越多的不透明度被淡化。

应用单数噪音和鼠标交互

现在我们已经实现并可以看到乒乓技术在正常工作,我们可以发挥创意,对其进行扩展。

而不是像以前那样简单地在我们的片段着色器中添加一个偏移。

vec4 inputColor = texture2D(sampler, v_uv + vec2(0.005));

让我们实际使用单数噪音来获得更有趣的视觉效果。我们还将使用我们的鼠标位置来控制方向。

这是我们更新的片段着色器。

// Pass in elapsed time since start of our program
uniform float time;

// Pass in normalised mouse position
// (-1 to 1 horizontally and vertically)
uniform vec2 mousePos;

// <Insert snoise function definition from the link above here>

// Calculate different offsets for x and y by using the UVs
// and different time offsets to the snoise method
float a = snoise(vec3(v_uv * 1.0, time * 0.1)) * 0.0032;
float b = snoise(vec3(v_uv * 1.0, time * 0.1 + 100.0)) * 0.0032;

// Add the snoise offset multiplied by the normalised mouse position
// to the UVs
vec4 inputColor = texture2D(sampler, v_uv + vec2(a, b) + mousePos * 0.005);

我们还需要指定mousePostime 作为我们的postFXMesh 材质着色器的输入。

const postFXMaterial = new THREE.ShaderMaterial({
  uniforms: {
    sampler: { value: null },
    time: { value: 0 },
    mousePos: { value: new THREE.Vector2(0, 0) }
  },
  // ...
})

最后,让我们确保在我们的页面上附加一个mousemove 事件监听器,并将更新的归一化鼠标坐标从Javascript传递给我们的GLSL碎片着色器。

// ... initialisation step

// Attach mousemove event listener
document.addEventListener('mousemove', onMouseMove)

function onMouseMove (e) {
  // Normalise horizontal mouse pos from -1 to 1
  const x = (e.pageX / innerWidth) * 2 - 1

  // Normalise vertical mouse pos from -1 to 1
  const y = (1 - e.pageY / innerHeight) * 2 - 1

  // Pass normalised mouse coordinates to fragment shader
  postFXMesh.material.uniforms.mousePos.value.set(x, y)
}

// ... animation loop

有了这些变化,这就是我们的最终结果。请确保在它周围徘徊(你可能需要等待一会儿才能加载所有内容)。

结论

帧缓冲器是WebGL中的一个强大工具,它允许我们通过后期处理大大增强我们的场景,实现各种很酷的效果。正如我们所看到的,有些技术需要一个以上的帧缓冲器,而作为开发者,我们可以根据需要混合和匹配它们,以实现我们所需要的视觉效果。

我鼓励你对所提供的例子进行实验,尝试渲染更多的元素,在每个renderTargetArenderTargetB 交换之间交替使用 "ABC "文本颜色,以实现不同的颜色混合,等等。