使用 PixiJS 和 WebGL 构建图像库

Avatar of Luis Goncalves
Luis Goncalves

DigitalOcean 为您的旅程各个阶段提供云产品。立即开始使用 $200 免费信用!

有时,我们需要超越 HTML、CSS 和 JavaScript 来创建所需的 UI,而是使用其他资源,如 SVG、WebGL、canvas 等。

例如,WebGL 可以创建最令人惊叹的效果,因为它是一个 JavaScript API,旨在渲染任何兼容网络浏览器中的交互式 2D 和 3D 图形,从而实现 GPU 加速的图像处理。

也就是说,使用 WebGL 可能非常复杂。因此,存在各种库可以使其相对容易,例如 PixiJSThree.jsBabylon.js,等等。我们将使用其中一个,PixiJS,创建一个受此 Dribbble 截图片段启发的随机图像库 Zhenya Rynzhuk 的 Dribbble 截图

这看起来很难,但实际上你不需要具备高级 WebGL 知识,甚至不需要 PixiJS 知识来学习,不过一些基本 Javascript (ES6) 知识会很有用。你甚至可能想从熟悉 WebGL 中使用的片段着色器基本概念开始,着色器手册 是一个不错的起点。

有了这些,让我们深入了解使用 PixiJS 创建这种 WebGL 效果!

初始设置

以下是我们开始需要的东西

  1. 将 PixiJS 库作为脚本添加到 HTML 中。
  2. 拥有一个 <canvas> 元素(或从 Javascript 动态添加),以渲染应用程序。
  3. 使用 new PIXI.Application(options) 初始化应用程序。

看,还没有什么太疯狂的。以下是我们可以用作样板的 JavaScript

// Get canvas view
const view = document.querySelector('.view')
let width, height, app

// Set dimensions
function initDimensions () {
  width = window.innerWidth
  height = window.innerHeight
}

// Init the PixiJS Application
function initApp () {
  // Create a PixiJS Application, using the view (canvas) provided
  app = new PIXI.Application({ view })
  // Resizes renderer view in CSS pixels to allow for resolutions other than 1
  app.renderer.autoDensity = true
  // Resize the view to match viewport dimensions
  app.renderer.resize(width, height)
}

// Init everything
function init () {
  initDimensions()
  initApp()
}

// Initial call
init()

执行此代码时,我们唯一看到的将是黑屏,以及如果我们打开控制台,则会出现类似这样的消息
PixiJS 5.0.2 - WebGL 2 - http://www.pixijs.com/.

我们已准备好开始使用 PixiJS 和 WebGL 在画布上绘图!

使用 WebGL 着色器创建网格背景

接下来,我们将创建一个包含网格的背景,这将使我们能够清晰地看到我们想要的扭曲效果。但首先,我们必须知道着色器是什么以及它是如何工作的。我之前推荐 着色器手册 作为学习它们的起点,这些概念将发挥作用。如果你还没有这样做,我强烈建议你回顾一下那些材料,然后才继续学习这里的内容。

我们将创建一个片段着色器,它会在屏幕上打印网格背景

// It is required to set the float precision for fragment shaders in OpenGL ES
// More info here: https://stackoverflow.com/a/28540641/4908989
#ifdef GL_ES
precision mediump float;
#endif

// This function returns 1 if `coord` correspond to a grid line, 0 otherwise
float isGridLine (vec2 coord) {
  vec2 pixelsPerGrid = vec2(50.0, 50.0);
  vec2 gridCoords = fract(coord / pixelsPerGrid);
  vec2 gridPixelCoords = gridCoords * pixelsPerGrid;
  vec2 gridLine = step(gridPixelCoords, vec2(1.0));
  float isGridLine = max(gridLine.x, gridLine.y);
  return isGridLine;
}

// Main function
void main () {
  // Coordinates for the current pixel
  vec2 coord = gl_FragCoord.xy;
  // Set `color` to black
  vec3 color = vec3(0.0);
  // If it is a grid line, change blue channel to 0.3
  color.b = isGridLine(coord) * 0.3;
  // Assing the final rgba color to `gl_FragColor`
  gl_FragColor = vec4(color, 1.0);
}

此代码来自 Shadertoy 上的一个 演示,Shadertoy 是着色器灵感和资源的绝佳来源。

为了使用此着色器,我们必须首先从它所在的文

// Loaded resources will be here
const resources = PIXI.Loader.shared.resources

// Load resources, then init the app
PIXI.Loader.shared.add([
  'shaders/backgroundFragment.glsl'
]).load(init)

现在,为了使我们的着色器在我们可以看到结果的地方起作用,我们将添加一个新元素(一个空的 Sprite)到舞台,我们将用它来定义一个过滤器。这是 PixiJS 让我们执行自定义着色器(如我们刚刚创建的着色器)的方式。

// Init the gridded background
function initBackground () {
  // Create a new empty Sprite and define its size
  background = new PIXI.Sprite()
  background.width = width
  background.height = height
  // Get the code for the fragment shader from the loaded resources
  const backgroundFragmentShader = resources['shaders/backgroundFragment.glsl'].data
  // Create a new Filter using the fragment shader
  // We don't need a custom vertex shader, so we set it as `undefined`
  const backgroundFilter = new PIXI.Filter(undefined, backgroundFragmentShader)
  // Assign the filter to the background Sprite
  background.filters = [backgroundFilter]
  // Add the background to the stage
  app.stage.addChild(background)
}

现在我们看到了带蓝色线条的网格背景。仔细观察,因为这些线条在深色背景色的衬托下有点模糊。

扭曲效果

我们的背景现在已经准备好了,那么让我们看看如何将所需的效果(立方体镜头扭曲)添加到整个舞台,包括背景和我们稍后添加的任何其他元素,如图像。为此,我们需要创建一个新的过滤器并将其添加到舞台。是的,我们也可以定义影响整个 PixiJS 舞台的过滤器!

这次,我们根据 这个很棒的 Shadertoy 演示 的代码来创建我们的着色器,它使用不同的可配置参数实现了扭曲效果。

#ifdef GL_ES
precision mediump float;
#endif

// Uniforms from Javascript
uniform vec2 uResolution;
uniform float uPointerDown;

// The texture is defined by PixiJS
varying vec2 vTextureCoord;
uniform sampler2D uSampler;

// Function used to get the distortion effect
vec2 computeUV (vec2 uv, float k, float kcube) {
  vec2 t = uv - 0.5;
  float r2 = t.x * t.x + t.y * t.y;
  float f = 0.0;
  if (kcube == 0.0) {
    f = 1.0 + r2 * k;
  } else {
    f = 1.0 + r2 * (k + kcube * sqrt(r2));
  }
  vec2 nUv = f * t + 0.5;
  nUv.y = 1.0 - nUv.y;
  return nUv;
}

void main () {
  // Normalized coordinates
  vec2 uv = gl_FragCoord.xy / uResolution.xy;

  // Settings for the effect
  // Multiplied by `uPointerDown`, a value between 0 and 1
  float k = -1.0 * uPointerDown;
  float kcube = 0.5 * uPointerDown;
  float offset = 0.02 * uPointerDown;
  
  // Get each channel's color using the texture provided by PixiJS
  // and the `computeUV` function
  float red = texture2D(uSampler, computeUV(uv, k + offset, kcube)).r;
  float green = texture2D(uSampler, computeUV(uv, k, kcube)).g;
  float blue = texture2D(uSampler, computeUV(uv, k - offset, kcube)).b;
  
  // Assing the final rgba color to `gl_FragColor`
  gl_FragColor = vec4(red, green, blue, 1.0);
}

这次我们使用了两个 uniform。uniform 是通过 JavaScript 传递给着色器的变量

  • uResolution:这是一个包含 {x: width, y: height} 的 JavaScript 对象。此 uniform 使我们能够在 [0, 1] 范围内规范化每个像素的坐标。
  • uPointerDown:这是一个在 [0, 1] 范围内的浮点数,它使我们能够动画化扭曲效果,使其强度按比例增加。

让我们看看我们必须添加到 JavaScript 中的代码才能看到我们新着色器引起的扭曲效果

// Target for pointer. If down, value is 1, else value is 0
// Here we set it to 1 to see the effect, but initially it will be 0
let pointerDownTarget = 1
let uniforms

// Set initial values for uniforms
function initUniforms () {
  uniforms = {
    uResolution: new PIXI.Point(width, height),
    uPointerDown: pointerDownTarget
  }
}

// Set the distortion filter for the entire stage
const stageFragmentShader = resources['shaders/stageFragment.glsl'].data
const stageFilter = new PIXI.Filter(undefined, stageFragmentShader, uniforms)
app.stage.filters = [stageFilter]

我们已经可以享受扭曲效果了!

此效果目前是静态的,所以它还不太有趣。接下来,我们将看看如何使效果能够动态地响应指针事件。

监听指针事件

PixiJS 使监听事件变得非常简单,即使是响应鼠标和触摸交互的多个事件。在这种情况下,我们希望我们的动画在桌面和移动设备上都能正常工作,因此我们必须监听与这两个平台相对应的事件。

PixiJs 提供了一个 interactive 属性,让我们可以做到这一点。我们将其应用于一个元素,并使用与 jQuery 类似的 API 开始监听事件

// Start listening events
function initEvents () {
  // Make stage interactive, so it can listen to events
  app.stage.interactive = true

  // Pointer & touch events are normalized into
  // the `pointer*` events for handling different events
  app.stage
    .on('pointerdown', onPointerDown)
    .on('pointerup', onPointerUp)
    .on('pointerupoutside', onPointerUp)
    .on('pointermove', onPointerMove)
}

从这里,我们将开始使用第三个 uniform (uPointerDiff),它将使我们能够使用拖放来浏览图像库。它的值将等于我们浏览画廊时场景的平移。以下是对应于每个事件处理函数的代码

// On pointer down, save coordinates and set pointerDownTarget
function onPointerDown (e) {
  console.log('down')
  const { x, y } = e.data.global
  pointerDownTarget = 1
  pointerStart.set(x, y)
  pointerDiffStart = uniforms.uPointerDiff.clone()
}

// On pointer up, set pointerDownTarget
function onPointerUp () {
  console.log('up')
  pointerDownTarget = 0
}

// On pointer move, calculate coordinates diff
function onPointerMove (e) {
  const { x, y } = e.data.global
  if (pointerDownTarget) {
    console.log('dragging')
    diffX = pointerDiffStart.x + (x - pointerStart.x)
    diffY = pointerDiffStart.y + (y - pointerStart.y)
  }
}

如果我们查看我们的工作,我们仍然看不到任何动画,但我们可以在控制台中看到我们已在每个事件处理函数中定义的消息是如何正确打印的。

现在让我们来实现动画!

动画化扭曲效果和拖放功能

我们使用 PixiJS(或任何基于画布的动画)启动动画的第一件事是动画循环。它通常包含一个连续调用的函数,使用 requestAnimationFrame,它在每次调用中渲染画布元素上的图形,从而产生所需的动画。

我们可以在 PixiJS 中实现自己的动画循环,或者我们可以使用库中包含的实用程序。在本例中,我们将使用 app.ticker 的 add 方法,它允许我们传递一个将在每一帧中执行的函数。在 init 函数的末尾,我们将添加以下内容

// Animation loop
// Code here will be executed on every animation frame
app.ticker.add(() => {
  // Multiply the values by a coefficient to get a smooth animation
  uniforms.uPointerDown += (pointerDownTarget - uniforms.uPointerDown) * 0.075
  uniforms.uPointerDiff.x += (diffX - uniforms.uPointerDiff.x) * 0.2
  uniforms.uPointerDiff.y += (diffY - uniforms.uPointerDiff.y) * 0.2
})

同时,在背景的 Filter 构造函数中,我们将传递舞台过滤器中的 uniform。这使我们能够通过对相应着色器进行以下微小修改来模拟背景的平移效果

uniform vec2 uPointerDiff;

void main () {
  // Coordinates minus the `uPointerDiff` value
  vec2 coord = gl_FragCoord.xy - uPointerDiff;

  // ... more code here ...
}

现在我们可以看到扭曲效果在起作用,包括网格背景的拖放功能。玩玩它!

随机生成砌体网格布局

为了使我们的 UI 更有趣,我们可以随机生成网格单元的大小和尺寸。也就是说,每个图像可以有不同的尺寸,从而创建一种砌体布局。

让我们使用 Unsplash Source,它将使我们能够从 Unsplash 获取随机图像并定义我们想要的尺寸。这将方便创建随机砌体布局的任务,因为图像可以具有我们想要的任何尺寸,因此可以预先生成布局。

为了实现这一点,我们将使用一个执行以下步骤的算法

  1. 我们将从一个矩形列表开始。
  2. 我们将选择列表中的第一个矩形,将其分成两个具有随机尺寸的矩形,只要两个矩形的尺寸都等于或大于建立的最小限制。我们将添加一个检查以确保这是可能的,如果可能,我们将这两个生成的矩形添加到列表中。
  3. 如果列表为空,我们将完成执行。如果列表不为空,我们将返回步骤二。

我认为在接下来的演示中,您将对该算法的工作原理有更深入的了解。使用按钮查看算法的运行方式:**下一步**将执行步骤二,**全部**将执行整个算法,**重置**将重置到步骤一。

绘制实心矩形

现在,我们可以正确地生成随机网格布局,我们将使用该算法生成的矩形列表在 PixiJS 应用程序中绘制实心矩形。这样,我们可以查看它是否有效,并在使用 Unsplash Source API 添加图像之前进行调整。

为了绘制这些矩形,我们将生成一个比视窗大五倍的随机网格布局,并将其放置在舞台的中心。这使我们能够自由地向画廊的任何方向移动。

// Variables and settings for grid
const gridSize = 50
const gridMin = 3
let gridColumnsCount, gridRowsCount, gridColumns, gridRows, grid
let widthRest, heightRest, centerX, centerY, rects

// Initialize the random grid layout
function initGrid () {
  // Getting columns
  gridColumnsCount = Math.ceil(width / gridSize)
  // Getting rows
  gridRowsCount = Math.ceil(height / gridSize)
  // Make the grid 5 times bigger than viewport
  gridColumns = gridColumnsCount * 5
  gridRows = gridRowsCount * 5
  // Create a new Grid instance with our settings
  grid = new Grid(gridSize, gridColumns, gridRows, gridMin)
  // Calculate the center position for the grid in the viewport
  widthRest = Math.ceil(gridColumnsCount * gridSize - width)
  heightRest = Math.ceil(gridRowsCount * gridSize - height)
  centerX = (gridColumns * gridSize / 2) - (gridColumnsCount * gridSize / 2)
  centerY = (gridRows * gridSize / 2) - (gridRowsCount * gridSize / 2)
  // Generate the list of rects
  rects = grid.generateRects()
}

到目前为止,我们已经生成了矩形列表。为了将它们添加到舞台上,创建一个容器会很方便,因为这样我们就可以将图像添加到同一个容器中,并在拖动画廊时便于移动。

在 PixiJS 中创建容器是这样的

let container

// Initialize a Container element for solid rectangles and images
function initContainer () {
  container = new PIXI.Container()
  app.stage.addChild(container)
}

现在我们可以将矩形添加到容器中,以便它们可以显示在屏幕上。

// Padding for rects and images
const imagePadding = 20

// Add solid rectangles and images
// So far, we will only add rectangles
function initRectsAndImages () {
  // Create a new Graphics element to draw solid rectangles
  const graphics = new PIXI.Graphics()
  // Select the color for rectangles
  graphics.beginFill(0xAA22CC)
  // Loop over each rect in the list
  rects.forEach(rect => {
    // Draw the rectangle
    graphics.drawRect(
      rect.x * gridSize,
      rect.y * gridSize,
      rect.w * gridSize - imagePadding,
      rect.h * gridSize - imagePadding
    )
  })
  // Ends the fill action
  graphics.endFill()
  // Add the graphics (with all drawn rects) to the container
  container.addChild(graphics)
}

请注意,我们在计算中添加了每个矩形的填充(imagePadding)。这样,图像之间就会有一些间距。

最后,在动画循环中,我们需要添加以下代码来正确定义容器的位置

// Set position for the container
container.x = uniforms.uPointerDiff.x - centerX
container.y = uniforms.uPointerDiff.y - centerY

现在我们将得到以下结果

但仍然有一些细节需要修复,例如定义拖放功能的限制。让我们将其添加到onPointerMove事件处理程序中,在那里我们将根据我们计算的网格大小有效地检查限制

diffX = diffX > 0 ? Math.min(diffX, centerX + imagePadding) : Math.max(diffX, -(centerX + widthRest))
diffY = diffY > 0 ? Math.min(diffY, centerY + imagePadding) : Math.max(diffY, -(centerY + heightRest))

另一个使事情更加精致的小细节是向网格背景添加偏移量。这将使蓝色网格线保持完好。我们只需将所需的偏移量(在本例中为imagePadding / 2)添加到背景着色器中,这样就可以了

// Coordinates minus the `uPointerDiff` value, and plus an offset
vec2 coord = gl_FragCoord.xy - uPointerDiff + vec2(10.0);

我们将得到随机网格布局的最终设计

从 Unsplash Source 添加图像

我们的布局已经准备好了,因此我们可以开始添加图像了。要在 PixiJS 中添加图像,我们需要一个Sprite,它将图像定义为其Texture。有多种方法可以做到这一点。在本例中,我们首先将为每个图像创建一个空的Sprite,并且只有当Sprite在视窗内时,我们才会加载图像,创建Texture并将其添加到Sprite中。听起来很多?我们将逐步完成。

为了创建空精灵,我们将修改initRectsAndImages函数。请注意注释以更好地理解

// For the list of images
let images = []

// Add solid rectangles and images
function initRectsAndImages () {
  // Create a new Graphics element to draw solid rectangles
  const graphics = new PIXI.Graphics()
  // Select the color for rectangles
  graphics.beginFill(0x000000)
  // Loop over each rect in the list
  rects.forEach(rect => {
    // Create a new Sprite element for each image
    const image = new PIXI.Sprite()
    // Set image's position and size
    image.x = rect.x * gridSize
    image.y = rect.y * gridSize
    image.width = rect.w * gridSize - imagePadding
    image.height = rect.h * gridSize - imagePadding
    // Set it's alpha to 0, so it is not visible initially
    image.alpha = 0
    // Add image to the list
    images.push(image)
    // Draw the rectangle
    graphics.drawRect(image.x, image.y, image.width, image.height)
  })
  // Ends the fill action
  graphics.endFill()
  // Add the graphics (with all drawn rects) to the container
  container.addChild(graphics)
  // Add all image's Sprites to the container
  images.forEach(image => {
    container.addChild(image)
  })
}

到目前为止,我们只有空的精灵。接下来,我们将创建一个函数,该函数负责下载图像并将其作为Texture分配给相应的Sprite。只有当Sprite在视窗内时才会调用此函数,以便只有在必要时才下载图像。

另一方面,如果在下载过程中拖动画廊并且Sprite不再在视窗内,则该请求可能会被中止,因为我们将使用AbortController有关 MDN 的更多信息)。这样,当我们拖动画廊时,我们将取消不必要的请求,优先考虑对应于始终在视窗内的精灵的请求。

让我们看看代码,以便更好地理解这些想法

// To store image's URL and avoid duplicates
let imagesUrls = {}

// Load texture for an image, giving its index
function loadTextureForImage (index) {
  // Get image Sprite
  const image = images[index]
  // Set the url to get a random image from Unsplash Source, given image dimensions
  const url = `https://source.unsplash.com/random/${image.width}x${image.height}`
  // Get the corresponding rect, to store more data needed (it is a normal Object)
  const rect = rects[index]
  // Create a new AbortController, to abort fetch if needed
  const { signal } = rect.controller = new AbortController()
  // Fetch the image
  fetch(url, { signal }).then(response => {
    // Get image URL, and if it was downloaded before, load another image
    // Otherwise, save image URL and set the texture
    const id = response.url.split('?')[0]
    if (imagesUrls[id]) {
      loadTextureForImage(index)
    } else {
      imagesUrls[id] = true
      image.texture = PIXI.Texture.from(response.url)
      rect.loaded = true
    }
  }).catch(() => {
    // Catch errors silently, for not showing the following error message if it is aborted:
    // AbortError: The operation was aborted.
  })
}

现在,我们需要为每个相应的Sprite与视窗相交的图像调用loadTextureForImage函数。此外,我们将取消不再需要的fetch请求,并且当矩形进入或离开视窗时,我们将添加alpha过渡。

// Check if rects intersects with the viewport
// and loads corresponding image
function checkRectsAndImages () {
  // Loop over rects
  rects.forEach((rect, index) => {
    // Get corresponding image
    const image = images[index]
    // Check if the rect intersects with the viewport
    if (rectIntersectsWithViewport(rect)) {
      // If rect just has been discovered
      // start loading image
      if (!rect.discovered) {
        rect.discovered = true
        loadTextureForImage(index)
      }
      // If image is loaded, increase alpha if possible
      if (rect.loaded && image.alpha < 1) {
        image.alpha += 0.01
      }
    } else { // The rect is not intersecting
      // If the rect was discovered before, but the
      // image is not loaded yet, abort the fetch
      if (rect.discovered && !rect.loaded) {
        rect.discovered = false
        rect.controller.abort()
      }
      // Decrease alpha if possible
      if (image.alpha > 0) {
        image.alpha -= 0.01
      }
    }
  })
}

验证矩形是否与视窗相交的函数如下

// Check if a rect intersects the viewport
function rectIntersectsWithViewport (rect) {
  return (
    rect.x * gridSize + container.x <= width &&
    0 <= (rect.x + rect.w) * gridSize + container.x &&
    rect.y * gridSize + container.y <= height &&
    0 <= (rect.y + rect.h) * gridSize + container.y
  )
}

最后,我们必须将checkRectsAndImages函数添加到动画循环中

// Animation loop
app.ticker.add(() => {
  // ... more code here ...

  // Check rects and load/cancel images as needded
  checkRectsAndImages()
})

我们的动画几乎准备好了!

处理视窗大小的变化

在初始化应用程序时,我们调整了渲染器的大小,使其占据整个视窗,但是如果视窗由于任何原因(例如,用户旋转其移动设备)改变了大小,我们应该重新调整尺寸并重新启动应用程序。

// On resize, reinit the app (clean and init)
// But first debounce the calls, so we don't call init too often
let resizeTimer
function onResize () {
  if (resizeTimer) clearTimeout(resizeTimer)
  resizeTimer = setTimeout(() => {
    clean()
    init()
  }, 200)
}
// Listen to resize event
window.addEventListener('resize', onResize)

clean函数将清除视窗尺寸改变之前正在执行的动画的任何残留物

// Clean the current Application
function clean () {
  // Stop the current animation
  app.ticker.stop()
  // Remove event listeners
  app.stage
    .off('pointerdown', onPointerDown)
    .off('pointerup', onPointerUp)
    .off('pointerupoutside', onPointerUp)
    .off('pointermove', onPointerMove)
  // Abort all fetch calls in progress
  rects.forEach(rect => {
    if (rect.discovered && !rect.loaded) {
      rect.controller.abort()
    }
  })
}

这样,无论视窗如何变化,我们的应用程序都将适当地响应视窗的尺寸。这将为我们提供工作的完整最终结果!

一些最后的思考

感谢您与我一起踏上这段旅程!我们经历了很多,但我们也学习了很多概念,并且得到了一款非常不错的 UI。您可以在GitHub 上查看代码,或者使用CodePen 上的演示进行体验。

如果您以前使用过 WebGL(无论是否使用其他库),我希望您能看到使用 PixiJS 的好处。它以一种非常好的方式抽象了与 WebGL 世界相关的复杂性,使我们能够专注于我们想要做什么,而不是使它起作用的技术细节。

底线是 PixiJS 使 WebGL 的世界更贴近前端开发人员,为他们打开了 HTML、CSS 和 JavaScript 之外的无限可能。