使用 CurtainsJS 创建 WebGL 效果

Avatar of Zach Saucier
Zach Saucier

DigitalOcean 为您的旅程每个阶段提供云产品。立即开始使用 200 美元的免费信用额度!

本文重点介绍将 WebGL 效果添加到已“完成”网页的<image><video> 元素中。虽然有一些关于此主题的有用资源(例如 这些 两个),但我希望通过将过程提炼成几个步骤来帮助简化此主题:

  • 像往常一样创建一个网页。
  • 使用 WebGL 渲染您想要添加 WebGL 效果的部分。
  • 创建(或找到)要使用的 WebGL 效果。
  • 添加事件监听器以将您的页面与 WebGL 效果连接起来。

具体来说,我们将重点关注普通网页和 WebGL 之间的连接。我们要做什么?如何创建一个带有交互式鼠标悬停的拖动图像滑块!

我们不会介绍滑块的核心功能,也不会深入研究 WebGL 或 GLSL 着色器的技术细节。但是,演示代码中有大量注释,以及指向外部资源的链接,如果您想了解更多信息。

我们使用最新版本的 WebGL(WebGL2)和 GLSL(GLSL 300),目前在 Safari 或 Internet Explorer 中不起作用。因此,请使用 Firefox 或 Chrome 查看演示。如果您打算在生产环境中使用我们所涵盖的任何内容,您应该加载 GLSL 100 和 300 版本的着色器,并且仅当curtains.renderer._isWebGL2 为真时才使用 GLSL 300 版本。我在上面的演示中对此进行了介绍。

首先,像往常一样创建一个网页

您知道,HTML 和 CSS 等等。在本例中,我们正在制作一个图像滑块,但这仅仅是为了演示。我们不会深入探讨如何制作滑块(Robin 有一个关于不错的帖子)。但这是我整理的内容

  1. 每个幻灯片等于页面的整个宽度。
  2. 拖动幻灯片后,滑块会继续朝着拖动方向滑动,并逐渐减速,形成动量。
  3. 动量会将滑块在终点位置捕捉到最近的幻灯片。
  4. 每个幻灯片都有一个退出动画,该动画在拖动开始时触发,还有一个进入动画,该动画在拖动停止时触发。
  5. 将鼠标悬停在滑块上时,会应用类似于此视频的悬停效果。

我非常喜欢GreenSock 动画平台(GSAP)。它对我们来说特别有用,因为它提供了一个拖动插件,一个启用拖动动量的插件,以及一个按行拆分文本的插件。如果您不习惯使用 GSAP 创建滑块,我建议您花一些时间熟悉上面演示中的代码。

再说一遍,这仅仅是为了演示,但我至少想稍微描述一下组件。这些是我们将在其中使 WebGL 同步的 DOM 元素。

接下来,使用 WebGL 渲染将包含 WebGL 效果的部分

现在我们需要在 WebGL 中渲染图像。为此,我们需要

  1. 将图像作为纹理加载到 GLSL 着色器中。
  2. 为图像创建一个 WebGL 平面,并将图像纹理正确地应用于平面。
  3. 将平面定位在 DOM 版本图像所在的位置,并将其正确缩放。

使用纯 WebGL,第三步尤其不平凡,因为我们需要跟踪想要移植到 WebGL 世界中的 DOM 元素的位置,同时在滚动和用户交互期间保持 DOM 和 WebGL 部分同步。

实际上,有一个库可以帮助我们轻松地完成所有这些工作:CurtainsJS!这是我发现的唯一一个可以轻松创建 DOM 图像和视频的 WebGL 版本,并在不提供太多其他功能的情况下同步它们的库(但我希望对此观点被证明是错误的,所以如果您知道其他做得很好的库,请留下评论)。

使用 Curtains,这是我们需要添加的所有 JavaScript 代码

// Create a new curtains instance
const curtains = new Curtains({ container: "canvas", autoRender: false });
// Use a single rAF for both GSAP and Curtains
function renderScene() {
  curtains.render();
}
gsap.ticker.add(renderScene);
// Params passed to the curtains instance
const params = {
  vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
  fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
  
 // Include any variables to update the WebGL state here
  uniforms: {
    // ...
  }
};
// Create a curtains plane for each slide
const planeElements = document.querySelectorAll(".slide");
planeElements.forEach((planeEl, i) => {
  const plane = curtains.addPlane(planeEl, params);
  // const plane = new Plane(curtains, planeEl, params); // v7 version
  // If our plane has been successfully created
  if(plane) {
    // onReady is called once our plane is ready and all its texture have been created
    plane.onReady(function() {
      // Add a "loaded" class to display the image container
      plane.htmlElement.closest(".slide").classList.add("loaded");
    });
  }
});

我们还需要更新我们的updateProgress 函数,以便它更新我们的 WebGL 平面。

function updateProgress() {
  // Update the actual slider
  animation.progress(wrapVal(this.x) / wrapWidth);
  
  // Update the WebGL slider planes
  planes.forEach(plane => plane.updatePosition());
}

我们还需要添加一个非常基本的顶点和片段着色器来显示我们正在加载的纹理。我们可以通过<script> 标签加载它们,就像我在演示中做的那样,或者通过使用反引号,就像我在最终演示中展示的那样。 

再说一遍,本文不会详细介绍这些 GLSL 着色器的技术方面。我建议阅读着色器之书Codrops 上的 WebGL 主题 作为起点。

如果您不太了解着色器,可以说顶点着色器定位平面,而片段着色器处理纹理的像素。还有三个我想指出的变量前缀

  • in是从数据缓冲区传入的。在顶点着色器中,它们来自 CPU(我们的程序)。在片段着色器中,它们来自顶点着色器。
  • uniform是从 CPU(我们的程序)传入的。
  • out是着色器的输出。在顶点着色器中,它们被传递到我们的片段着色器。在片段着色器中,它们被传递到帧缓冲区(绘制到屏幕的内容)。

在我们向项目中添加了所有这些内容后,我们有了与之前完全相同的内容,但是我们的滑块现在通过 WebGL 显示了!太棒了。

CurtainsJS 可以轻松地将图像和视频转换为 WebGL。至于将 WebGL 效果添加到文本,有几种不同的方法,但可能最常见的是将文本绘制到<canvas> 上,然后将其用作着色器中的纹理(例如12)。可以使用html2canvas(或类似方法)对大多数其他 HTML 进行操作,并将该画布用作着色器中的纹理;但是,这性能不太好。

创建(或找到)要使用的 WebGL 效果

现在我们可以添加 WebGL 效果,因为我们的滑块使用 WebGL 渲染。让我们分解一下我们灵感视频中看到的效果

  1. 图像颜色已反转。
  2. 鼠标位置周围有一个半径,显示正常颜色,并产生鱼眼效果。
  3. 鼠标周围的半径在鼠标悬停在滑块上时从 0 动画到 0,在鼠标不再悬停时从 0 动画回 0。
  4. 半径不会跳到鼠标位置,而是会随着时间的推移动画到该位置。
  5. 整个图像根据鼠标相对于图像中心的位置进行平移。

在创建 WebGL 效果时,请务必记住,着色器没有在帧之间存在的内存状态。它可以根据鼠标在给定时间的所在位置执行某些操作,但不能根据鼠标已经在何处自行执行某些操作。这就是为什么对于某些效果(例如,鼠标进入滑块后动画半径或随着时间的推移动画半径位置),我们应该使用一个 JavaScript 变量并将该值传递给滑块的每一帧。我们将在下一节中详细讨论此过程。

在将着色器修改为反转半径外部的颜色并在半径内部创建鱼眼效果后,我们将得到类似于下面演示的内容。再说一遍,本文的重点是关注 DOM 元素和 WebGL 之间的连接,所以我不会详细介绍着色器,但我确实在其中添加了注释。

但这还不太令人兴奋,因为半径没有响应我们的鼠标。我们将在下一节中对此进行介绍。

我还没有找到一个包含大量预制 WebGL 着色器的存储库,这些着色器可用于普通网站。有ShaderToyVertexShaderArt(它们有一些真正令人惊叹的着色器!),但它们都不针对适合大多数网站类型的效果。我真的很想看到有人创建一个 WebGL 着色器存储库,作为为日常网站工作的人的资源。如果您知道其中一个,请告诉我。

添加事件监听器以将您的页面与 WebGL 效果连接起来

现在我们可以为 WebGL 部分添加交互性了!我们需要将一些变量(uniform)传递给着色器,并在用户与我们的元素交互时影响这些变量。这一部分我会详细讲解,因为它是我们如何将 JavaScript 连接到着色器的核心。

首先,我们需要在着色器中声明一些 uniform。我们只需要在顶点着色器中使用鼠标位置。

// The un-transformed mouse position
uniform vec2 uMouse;

我们需要在片段着色器中声明半径和分辨率。

uniform float uRadius; // Radius of pixels to warp/invert
uniform vec2 uResolution; // Used in anti-aliasing

然后,让我们在传递给 Curtains 实例的参数中添加一些这些变量的值。我们已经在为 uResolution 做这件事了!我们需要指定着色器中变量的 name、它的 type,以及初始的 value

const params = {
  vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
  fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
  
  // The variables that we're going to be animating to update our WebGL state
  uniforms: {
    // For the cursor effects
    mouse: { 
      name: "uMouse", // The shader variable name
      type: "2f",     // The type for the variable - https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html
      value: mouse    // The initial value to use
    },
    radius: { 
      name: "uRadius",
      type: "1f",
      value: radius.val
    },
    
    // For the antialiasing
    resolution: { 
      name: "uResolution",
      type: "2f", 
      value: [innerWidth, innerHeight] 
    }
  },
};

现在,着色器 uniforms 已连接到我们的 JavaScript!此时,我们需要创建一些事件监听器和动画来影响传递给着色器的值。首先,让我们设置半径的动画和更新传递给着色器的值的函数。

const radius = { val: 0.1 };
const radiusAnim = gsap.from(radius, { 
  val: 0, 
  duration: 0.3, 
  paused: true,
  onUpdate: updateRadius
});
function updateRadius() {
  planes.forEach((plane, i) => {
    plane.uniforms.radius.value = radius.val;
  });
}

如果我们播放半径动画,那么着色器将在每次循环中使用新值。

我们还需要在鼠标悬停在滑块上时更新鼠标位置,无论是鼠标设备还是触摸屏。这里有很多代码,但你可以线性地逐行查看。慢慢来,理解发生了什么。

const mouse = new Vec2(0, 0);
function addMouseListeners() {
  if ("ontouchstart" in window) {
    wrapper.addEventListener("touchstart", updateMouse, false);
    wrapper.addEventListener("touchmove", updateMouse, false);
    wrapper.addEventListener("blur", mouseOut, false);
  } else {
    wrapper.addEventListener("mousemove", updateMouse, false);
    wrapper.addEventListener("mouseleave", mouseOut, false);
  }
}


// Update the stored mouse position along with WebGL "mouse"
function updateMouse(e) {
  radiusAnim.play();
  
  if (e.changedTouches && e.changedTouches.length) {
    e.x = e.changedTouches[0].pageX;
    e.y = e.changedTouches[0].pageY;
  }
  if (e.x === undefined) {
    e.x = e.pageX;
    e.y = e.pageY;
  }
  
  mouse.x = e.x;
  mouse.y = e.y;
  
  updateWebGLMouse();
}


// Updates the mouse position for all planes
function updateWebGLMouse(dur) {
  // update the planes mouse position uniforms
  planes.forEach((plane, i) => {
    const webglMousePos = plane.mouseToPlaneCoords(mouse);
    updatePlaneMouse(plane, webglMousePos, dur);
  });
}


// Updates the mouse position for the given plane
function updatePlaneMouse(plane, endPos = new Vec2(0, 0), dur = 0.1) {
  gsap.to(plane.uniforms.mouse.value, {
    x: endPos.x,
    y: endPos.y,
    duration: dur,
    overwrite: true,
  });
}


// When the mouse leaves the slider, animate the WebGL "mouse" to the center of slider
function mouseOut(e) {
  planes.forEach((plane, i) => updatePlaneMouse(plane, new Vec2(0, 0), 1) );
  
  radiusAnim.reverse();
}

我们还应该修改现有的 updateProgress 函数来保持 WebGL 鼠标同步。

// Update the slider along with the necessary WebGL variables
function updateProgress() {
  // Update the actual slider
  animation.progress(wrapVal(this.x) / wrapWidth);
  
  // Update the WebGL slider planes
  planes.forEach(plane => plane.updatePosition());
  
  // Update the WebGL "mouse"
  updateWebGLMouse(0);
}

现在,我们已经成功了!我们的滑块现在满足了我们所有的需求。

使用 GSAP 进行动画的另外两个好处是,它提供了对回调函数(如 onComplete)的访问,并且 GSAP 保持所有内容完美同步,无论刷新率如何(例如 这种情况)。

接下来的部分就由你来啦!

当然,这仅仅是冰山一角,我们现在可以用 WebGL 对滑块做更多的事情。例如,像湍流和位移这样的常见效果可以添加到 WebGL 中的图像。位移效果的核心概念是根据我们用作输入源的梯度光照图来移动像素。我们可以使用 这个纹理(我从 这个位移演示 中提取的,作者是 Jesper Landberg — 你应该关注他)作为我们的源,然后将其插入我们的着色器。

要了解更多关于创建这些纹理的信息,请查看 这篇文章这条推文 以及 这个工具。我不知道是否有现有的类似图像库,但如果你知道,请告诉我。

如果我们连接上面的纹理,并使位移强度随时间变化和拖动速度变化,那么它将创建一个很好的半随机但看起来自然的位移效果。

还需要注意的是,Curtains 也有自己的 React 版本,如果你喜欢的话。

目前就到这里。如果你使用从本文中学到的知识创建了一些东西,我很想看到它!在 Twitter 上与我联系。