【实战】用 WebGL 创建一个在线画廊
// 每日前端夜话 第498篇
// 正文共:6000 字
// 预计阅读时间:15 分钟
❝目录
❞
创建 OGL 3D 环境
解释 `App` 类的设置
创建可重用的几何实例
用 Webpack 导入图像
设置 `Media` 类
添加无限滚动逻辑
加入圆周旋转
捕捉到最接近的项目
编写着色器
用MSDF字体在WebGL中包含文本
引入背景块
在本文中,我们将基于 WebGL 与 OGL[1] 来实现一个无限循环画廊。
本文中所用到的大多数套路也可以用在其他 WebGL 库中,例如 Three.js[2] 或 Babylon.js[3] 中,但是需要一些小小的调整。
创建 OGL 3D 环境
首先要确保你正确设置了创建 3D 环境所需的所有渲染逻辑。
通常我们需要:一台照相机,一个场景和一个渲染器,它将把所有内容输出到一个 canvas
元素中。然后在 requestAnimationFrame
循环中用相机在渲染器中渲染场景。以下是原始代码段:
import { Renderer, Camera, Transform } from 'ogl'
export default class App {
constructor () {
this.createRenderer()
this.createCamera()
this.createScene()
this.onResize()
this.update()
this.addEventListeners()
}
createRenderer () {
this.renderer = new Renderer()
this.gl = this.renderer.gl
this.gl.clearColor(0.79607843137, 0.79215686274, 0.74117647058, 1)
document.body.appendChild(this.gl.canvas)
}
createCamera () {
this.camera = new Camera(this.gl)
this.camera.fov = 45
this.camera.position.z = 20
}
createScene () {
this.scene = new Transform()
}
/**
* Events.
*/
onTouchDown (event) {
}
onTouchMove (event) {
}
onTouchUp (event) {
}
onWheel (event) {
}
/**
* Resize.
*/
onResize () {
this.screen = {
height: window.innerHeight,
width: window.innerWidth
}
this.renderer.setSize(this.screen.width, this.screen.height)
this.camera.perspective({
aspect: this.gl.canvas.width / this.gl.canvas.height
})
const fov = this.camera.fov * (Math.PI / 180)
const height = 2 * Math.tan(fov / 2) * this.camera.position.z
const width = height * this.camera.aspect
this.viewport = {
height,
width
}
}
/**
* Update.
*/
update () {
this.renderer.render({
scene: this.scene,
camera: this.camera
})
window.requestAnimationFrame(this.update.bind(this))
}
/**
* Listeners.
*/
addEventListeners () {
window.addEventListener('resize', this.onResize.bind(this))
window.addEventListener('mousewheel', this.onWheel.bind(this))
window.addEventListener('wheel', this.onWheel.bind(this))
window.addEventListener('mousedown', this.onTouchDown.bind(this))
window.addEventListener('mousemove', this.onTouchMove.bind(this))
window.addEventListener('mouseup', this.onTouchUp.bind(this))
window.addEventListener('touchstart', this.onTouchDown.bind(this))
window.addEventListener('touchmove', this.onTouchMove.bind(this))
window.addEventListener('touchend', this.onTouchUp.bind(this))
}
}
new App()
解释 App
类的设置
在 createRenderer
方法中,通过调用 this.gl.clearColor
来初始化有着固定颜色背景的渲染器。然后将 GL 上下文(this.renderer.gl`)引用存储在 `this.gl` 变量中,并将 `<canvas>
(this.gl.canvas
)元素附加到 document.body
中。
在 createCamera
方法中,我们要创建一个 new Camera()
实例并设置其一些属性:fov
和它的 z
位置。FOV是摄像机的视野,我们通过它来看到最终的画面。z
是相机在 z 轴上的位置。
在 createScene
方法中使用的是 Transform
类,它是一个新场景的表示,包含所有表示 WebGL 环境中图像的平面。
onResize
方法是初始化设置中最重要的部分,负责三件事:
-
确保我们能够始终用正确的视口大小调整 <canvas>
元素的大小。 -
更新 this.camera
透视图,以划分视口的width
和height
。 -
将变量值 this.viewport
存储在变量this.viewport
中,这个值表示将通过使用摄像机的fov
将像素转换为 3D 环境尺寸。
使用 camera.fov
在 3D 环境尺寸下转换像素的方法在众多的 WebGL 实现中非常常用。基本上它的工作是确保能够执行以下操作:this.mesh.scale.x = this.viewport.width;
这会使我们的网格适合整个屏幕宽度,其表现为 width: 100%
,不过是在 3D 空间中。
最后在更新中,我们设置了 requestAnimationFrame
循环,并确保能够持续渲染场景。
另外代码中还包含了 wheel
、touchstart
、touchmove
、touchend
、mousedown
、mousemove
和 mouseup
事件,它们用于处理用户与我们程序的交互。
创建可重用的几何实例
不管你用的是哪种 WebGL 库,总是要通过重复使用相同的几何图形引用来保持较低的内存使用量,这是一种很好的做法。为了表示所有图像,我们将使用平面几何图形,所以要创建一个新方法并将新几何图形存储在 this.planeGeometry
变量中。
import { Renderer, Camera, Transform, Plane } from 'ogl'
createGeometry () {
this.planeGeometry = new Plane(this.gl, {
heightSegments: 50,
widthSegments: 100
})
}
在这些值中之所以包含 heightSegments
和 widthSegments
,是因为能够通过它们操纵顶点,以使 Plane
的行为像空气中的纸一样。
用 Webpack 导入图像
接下来就要将图像导入我们的程序了。在这里我们使用 Webpack,需要获取图像的操作只需要简单的使用 import
就够了:
import Image1 from 'images/1.jpg'
import Image2 from 'images/2.jpg'
import Image3 from 'images/3.jpg'
import Image4 from 'images/4.jpg'
import Image5 from 'images/5.jpg'
import Image6 from 'images/6.jpg'
import Image7 from 'images/7.jpg'
import Image8 from 'images/8.jpg'
import Image9 from 'images/9.jpg'
import Image10 from 'images/10.jpg'
import Image11 from 'images/11.jpg'
import Image12 from 'images/12.jpg'
现在创建要在轮播滑块中使用的图像数组,并在 createMedia
方法中调用上面的变量。用 .map
创建 Media
类的新实例(new Media()
),它将用来表示画廊程序中每个图片。
createMedias () {
this.mediasImages = [
{ image: Image1, text: 'New Synagogue' },
{ image: Image2, text: 'Paro Taktsang' },
{ image: Image3, text: 'Petra' },
{ image: Image4, text: 'Gooderham Building' },
{ image: Image5, text: 'Catherine Palace' },
{ image: Image6, text: 'Sheikh Zayed Mosque' },
{ image: Image7, text: 'Madonna Corona' },
{ image: Image8, text: 'Plaza de Espana' },
{ image: Image9, text: 'Saint Martin' },
{ image: Image10, text: 'Tugela Falls' },
{ image: Image11, text: 'Sintra-Cascais' },
{ image: Image12, text: 'The Prophet\'s Mosque' },
{ image: Image1, text: 'New Synagogue' },
{ image: Image2, text: 'Paro Taktsang' },
{ image: Image3, text: 'Petra' },
{ image: Image4, text: 'Gooderham Building' },
{ image: Image5, text: 'Catherine Palace' },
{ image: Image6, text: 'Sheikh Zayed Mosque' },
{ image: Image7, text: 'Madonna Corona' },
{ image: Image8, text: 'Plaza de Espana' },
{ image: Image9, text: 'Saint Martin' },
{ image: Image10, text: 'Tugela Falls' },
{ image: Image11, text: 'Sintra-Cascais' },
{ image: Image12, text: 'The Prophet\'s Mosque' },
]
this.medias = this.mediasImages.map(({ image, text }, index) => {
const media = new Media({
geometry: this.planeGeometry,
gl: this.gl,
image,
index,
length: this.mediasImages.length,
scene: this.scene,
screen: this.screen,
text,
viewport: this.viewport
})
return media
})
}
你可能注意到了,我们把一堆参数传递给了 Media
类,在下一小节讲到设置类时,会解释为什么需要这样。另外还将复制图片数量,以免在非常宽的屏幕上无限循环时出现图片不足的问题。
在 this.medias
数组的 onResize
和 update
方法中包括一些特定的调用,因为我们希望图像能够响应:
onResize () {
if (this.medias) {
this.medias.forEach(media => media.onResize({
screen: this.screen,
viewport: this.viewport
}))
}
}
并在 requestAnimationFrame
内部执行一些实时操作:
update () {
this.medias.forEach(media => media.update(this.scroll, this.direction))
}
设置 Media
类
Media
类中用 OGL 中的 Mesh
、 Program
和 Texture
类来创建 3D 平面并赋予纹理,在例子中,这个平面会成为我们的图像。
在构造函数中存储所需的所有变量,这些变量是从 index.js
的 new Media()
初始化时传递的:
export default class {
constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
this.geometry = geometry
this.gl = gl
this.image = image
this.index = index
this.length = length
this.scene = scene
this.screen = screen
this.text = text
this.viewport = viewport
this.createShader()
this.createMesh()
this.onResize()
}
}
解释一下其中的参数, geometry
是要应用于 Mesh
类的几何图形。this.gl
是 GL 上下文,用于在类中继续进行 WebGL 操作。this.image
是图像的 URL。this.index
和 this.length
都将用于进行网格的位置计算。this.scene
是要将网格附加到的组。this.screen
和 this.viewport
是视口和环境的大小。
接下来用 createShader
方法创建要应用于 Mesh
的着色器,在 OGL 着色器中是通过 Program
创建的:
createShader () {
const texture = new Texture(this.gl, {
generateMipmaps: false
})
this.program = new Program(this.gl, {
fragment,
vertex,
uniforms: {
tMap: { value: texture },
uPlaneSizes: { value: [0, 0] },
uImageSizes: { value: [0, 0] },
uViewportSizes: { value: [this.viewport.width, this.viewport.height] }
},
transparent: true
})
const image = new Image()
image.src = this.image
image.onload = _ => {
texture.image = image
this.program.uniforms.uImageSizes.value = [image.naturalWidth, image.naturalHeight]
}
}
在上面的代码段中,创建了一个 new Texture()
实例,并把 generateMipmaps
设置为 false
,以便保留图像的质量。然后创建一个 new Program()
实例,该实例代表由 fragment
和 vertex
组成的着色器,并带有一些用于操纵它的 uniforms
。
代码中将创建了一个 new Image()
实例,用于在 texture.image
之前预加载图像。并且还要更新 this.program.uniforms.uImageSizes.value
,它用于保留图像的长宽比。
现在创建片段和顶点着色器,先创建两个新文件:fragment.glsl
和 vertex.glsl
:
precision highp float;
uniform vec2 uImageSizes;
uniform vec2 uPlaneSizes;
uniform sampler2D tMap;
varying vec2 vUv;
void main() {
vec2 ratio = vec2(
min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
);
vec2 uv = vec2(
vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
);
gl_FragColor.rgb = texture2D(tMap, uv).rgb;
gl_FragColor.a = 1.0;
}
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 p = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
并用 Webpack
在 Media.js
开头中导入它们:
import fragment from './fragment.glsl'
import vertex from './vertex.glsl'
之后在 createMesh
方法中创建 new Mesh()
实例,将几何图形和着色器合并在一起。
createMesh () {
this.plane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program
})
this.plane.setParent(this.scene)
}
把 Mesh
实例存储在 this.plane
变量中,以便在 onResize
和 update
方法中重用,然后作为 this.scene
组的子代附加。
现在屏幕上出现了带有图像的简单正方形:
接着实现 onResize
方法,确保我们能够渲染矩形:
onResize ({ screen, viewport } = {}) {
if (screen) {
this.screen = screen
}
if (viewport) {
this.viewport = viewport
this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]
}
this.scale = this.screen.height / 1500
this.plane.scale.y = this.viewport.height * (900 * this.scale) / this.screen.height
this.plane.scale.x = this.viewport.width * (700 * this.scale) / this.screen.width
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]
}
scale.y
和 scale.x
调用负责正确缩放元素,根据缩放比例将先前的正方形转换为 700×900 大小的矩形。
uViewportSizes
和 uPlaneSizes
统一值更新可以使图像正确显示。这就为了使图片具有 background-size: cover;
行为。
现在我们需要在 x 轴上放置所有矩形,确保它们之间有一个很小的间隙。用 this.plane.scale.x
, this.padding
和 this.index
变量来进行移动它们所需的计算:
this.padding = 2
this.width = this.plane.scale.x + this.padding
this.widthTotal = this.width * this.length
this.x = this.width * this.index
在 update
方法中将 this.plane.position
设置为以下变量:
update () {
this.plane.position.x = this.x
}
现在已经设置好了 Media
的所有初始代码,其结果如下图所示:
添加无限滚动逻辑
现在添加滚动逻辑,所以当用户滚动浏览你的页面时,会有一个无限旋转的画廊。在 index.js
中添加一下代码。
首先在构造函数中包含一个名为 this.scroll
的新对象,其中包含我们将要进行平滑滚动的所有变量:
this.scroll = {
ease: 0.05,
current: 0,
target: 0,
last: 0
}
下面添加触摸和滚轮事件,以便用户与画布交互时他将能够移动东西:
onTouchDown (event) {
this.isDown = true
this.scroll.position = this.scroll.current
this.start = event.touches ? event.touches[0].clientX : event.clientX
}
onTouchMove (event) {
if (!this.isDown) return
const x = event.touches ? event.touches[0].clientX : event.clientX
const distance = (this.start - x) * 0.01
this.scroll.target = this.scroll.position + distance
}
onTouchUp (event) {
this.isDown = false
}
然后在 onWheel
事件中包含 NormalizeWheel
库,这样当用户滚动时,在所有浏览器上能得到有相同的值:
onWheel (event) {
const normalized = NormalizeWheel(event)
const speed = normalized.pixelY
this.scroll.target += speed * 0.005
}
在带有 requestAnimationFrame
的 update
方法中,我们将使用 this.scroll.target对this.scroll.current
进行平滑处理,然后将其传递给所有 media:
update () {
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
if (this.medias) {
this.medias.forEach(media => media.update(this.scroll))
}
this.scroll.last = this.scroll.current
window.requestAnimationFrame(this.update.bind(this))
}
现在我们只是更新 Media
文件,用当前滚动值将 Mesh
移到新的滚动位置:
update (scroll) {
this.plane.position.x = this.x - scroll.current * 0.1
}
下面是目前的成果:
现在它还不能无限滚动,要实现这一点还需要添加一些代码。第一步是将滚动的方向包含在来自 index.js
的 update
方法中:
update () {
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
if (this.scroll.current > this.scroll.last) {
this.direction = 'right'
} else {
this.direction = 'left'
}
if (this.medias) {
this.medias.forEach(media => media.update(this.scroll, this.direction))
}
this.scroll.last = this.scroll.current
}
在 Media
类的造函数中包含一个名为 this.extra
的变量,并对它进行一些操作,当元素位于屏幕外部时求出图库的总宽度。
constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
this.extra = 0
}
update (scroll) {
this.plane.position.x = this.x - scroll.current * 0.1 - this.extra
const planeOffset = this.plane.scale.x / 2
const viewportOffset = this.viewport.width
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset
this.isAfter = this.plane.position.x - planeOffset > viewportOffset
if (direction === 'right' && this.isBefore) {
this.extra -= this.widthTotal
this.isBefore = false
this.isAfter = false
}
if (direction === 'left' && this.isAfter) {
this.extra += this.widthTotal
this.isBefore = false
this.isAfter = false
}
}
现在可以无限滚动了。
加入圆周旋转
首先让它根据位置平滑旋转。map
方法是一种基于另一个特定范围提供值的方法,例如 map(0.5, 0, 1, -500, 500);
将返回 0
,因为它是在 -500
和 500
之间的中间位置。一般来说第一个参数控制 min2
和 max2
的输出:
export function map (num, min1, max1, min2, max2, round = false) {
const num1 = (num - min1) / (max1 - min1)
const num2 = (num1 * (max2 - min2)) + min2
if (round) return Math.round(num2)
return num2
}
让我们通过在 Media
类中添加以下类似的代码来观察它的作用:
this.plane.rotation.z = map(this.plane.position.x, -this.widthTotal, this.widthTotal, Math.PI, -Math.PI)
这是目前的结果。你可以看到旋转根据平面位置而变化:
接下来要让它看起来像圆形。只需要用 Math.cos
给 this.plane.position.x/this.widthTotal
做一个简单的计算即可:
this.plane.position.y = Math.cos((this.plane.position.x / this.widthTotal) * Math.PI) * 75 - 75
只需根据位置在环境空间中将其移动 75
即可,结果如下所示:
捕捉到最接近的项目
现在添加在用户停止滚动时简单地捕捉到最近的项目。创建一个名为 onCheck
的方法,该方法将在用户释放滚动时进行一些计算:
onCheck () {
const { width } = this.medias[0]
const itemIndex = Math.round(Math.abs(this.scroll.target) / width)
const item = width * itemIndex
if (this.scroll.target < 0) {
this.scroll.target = -item
} else {
this.scroll.target = item
}
}
item
变量的结果始终是图库中元素之一的中心,这会将用户锁定到相应的位置。
对于滚动事件,还需要一个去抖动的版本 onCheckDebounce
,可以通过导入 lodash/debounce
将其添加到构造函数中:
import debounce from 'lodash/debounce'
constructor ({ camera, color, gl, renderer, scene, screen, url, viewport }) {
this.onCheckDebounce = debounce(this.onCheck, 200)
}
onWheel (event) {
this.onCheckDebounce()
}
现在画廊总是能够被捕捉到正确的条目:
编写着色器
最后是最有意思的部分,通过滚动速度和使网格的顶点变形来稍微增强着色器。
第一步是在 Media
类的 this.program
声明中包括两个新的 uniform:uSpeed
和 uTime
。
this.program = new Program(this.gl, {
fragment,
vertex,
uniforms: {
tMap: { value: texture },
uPlaneSizes: { value: [0, 0] },
uImageSizes: { value: [0, 0] },
uViewportSizes: { value: [this.viewport.width, this.viewport.height] },
uSpeed: { value: 0 },
uTime: { value: 0 }
},
transparent: true
})
现在编写一些着色器代码,使图像弯曲和变形。在你的 vertex.glsl
文件中,应该添加新的 uniform :uniform float uTime
和 uniform float uSpeed
:
uniform float uTime;
uniform float uSpeed;
然后在着色器的 void main()
内部,可以用这两个值以及在 p
中存储的 position
变量来操纵 z
轴上的顶点。可以用 sin
和 cos
像平面一样弯曲我们的顶点,添加下面的代码:
p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5);
同样不要忘记在 Media
的 update()
方法中包含 uTime
增量:
this.program.uniforms.uTime.value += 0.04
下面是产生的纸张效果动画:
用MSDF字体在WebGL中包含文本
现在把文本用 WebGL 显示出来,首先用 msdf-bmfont
来生成文件,安装 npm
依赖项并运行以下命令:
msdf-bmfont -f json -m 1024,1024 -d 4 --pot --smart-size freight.otf
运行之后,在当前目录中会有一个 .png
和 .json
文件,这些是将在 OGL 中的 MSDF 实现中使用的文件。
创建一个名为 Title
的新文件,在其中创建 class
并在着色器和文件中使用 import
:
import AutoBind from 'auto-bind'
import { Color, Geometry, Mesh, Program, Text, Texture } from 'ogl'
import fragment from 'shaders/text-fragment.glsl'
import vertex from 'shaders/text-vertex.glsl'
import font from 'fonts/freight.json'
import src from 'fonts/freight.png'
export default class {
constructor ({ gl, plane, renderer, text }) {
AutoBind(this)
this.gl = gl
this.plane = plane
this.renderer = renderer
this.text = text
this.createShader()
this.createMesh()
}
}
现在开始在 createShader()
方法中设置 MSDF 实现代码。首先创建一个新的 Texture()
实例,并加载存储在 src
中的 fonts/freight.png
:
createShader () {
const texture = new Texture(this.gl, { generateMipmaps: false })
const textureImage = new Image()
textureImage.src = src
textureImage.onload = _ => texture.image = textureImage
}
然后设置用于渲染 MSDF 文本的片段着色器,因为可以在 WebGL 2.0 中优化 MSDF,所以使用 OGL 中的 this.renderer.isWebgl2
来检查是否支持,并基于它声明不同的着色器,我们将使用 vertex300
,fragment300
,vertex100
和 fragment100
:
createShader () {
const vertex100 = `${vertex}`
const fragment100 = `
#extension GL_OES_standard_derivatives : enable
precision highp float;
${fragment}
`
const vertex300 = `#version 300 es
#define attribute in
#define varying out
${vertex}
`
const fragment300 = `#version 300 es
precision highp float;
#define varying in
#define texture2D texture
#define gl_FragColor FragColor
out vec4 FragColor;
${fragment}
`
let fragmentShader = fragment100
let vertexShader = vertex100
if (this.renderer.isWebgl2) {
fragmentShader = fragment300
vertexShader = vertex300
}
this.program = new Program(this.gl, {
cullFace: null,
depthTest: false,
depthWrite: false,
transparent: true,
fragment: fragmentShader,
vertex: vertexShader,
uniforms: {
uColor: { value: new Color('#545050') },
tMap: { value: texture }
}
})
}
你可能已经注意到,我们在 fragment
和 vertex
之前添加了基于渲染器 WebG L版本的不同设置,接着创建了text-fragment.glsl
和 text-vertex.glsl
文件:
uniform vec3 uColor;
uniform sampler2D tMap;
varying vec2 vUv;
void main() {
vec3 color = texture2D(tMap, vUv).rgb;
float signed = max(min(color.r, color.g), min(max(color.r, color.g), color.b)) - 0.5;
float d = fwidth(signed);
float alpha = smoothstep(-d, d, signed);
if (alpha < 0.02) discard;
gl_FragColor = vec4(uColor, alpha);
}
attribute vec2 uv;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
最后在 createMesh()
方法中创建 MSDF 字体实现的几何,使用OGL的 new Text()
实例,然后将由此生成的缓冲区应用于 new Text()
实例:
createMesh () {
const text = new Text({
align: 'center',
font,
letterSpacing: -0.05,
size: 0.08,
text: this.text,
wordSpacing: 0,
})
const geometry = new Geometry(this.gl, {
position: { size: 3, data: text.buffers.position },
uv: { size: 2, data: text.buffers.uv },
id: { size: 1, data: text.buffers.id },
index: { data: text.buffers.index }
})
geometry.computeBoundingBox()
this.mesh = new Mesh(this.gl, { geometry, program: this.program })
this.mesh.position.y = -this.plane.scale.y * 0.5 - 0.085
this.mesh.setParent(this.plane)
}
接下来在 Media
类中应用新的标题,创建一个名为 createTilte()
的新方法,并在 constructor
中调用:
constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
this.createTitle()
}
createTitle () {
this.title = new Title({
gl: this.gl,
plane: this.plane,
renderer: this.renderer,
text: this.text,
})
}
将输出以下结果:
就这个程序而言,我们还实现了一个 new Number()
类,负责显示用户正在查看的当前索引。你可以检查它在源代码中的实现方式,但是它基本上与 Title
类的实现相同,唯一的区别是它加载了不同的字体样式:
引入背景块
最后还需要在后台实现一些将在 x 和 y 轴上移动的块,以增强其深度效果:
为了达到这种效果,需要创建一个新的 Background
类,并在其内部通过更改 scale
来在一个带有随机大小和位置的 new Mesh()
中初始化一些 new Plane()
几何形状。
import { Color, Mesh, Plane, Program } from 'ogl'
import fragment from 'shaders/background-fragment.glsl'
import vertex from 'shaders/background-vertex.glsl'
import { random } from 'utils/math'
export default class {
constructor ({ gl, scene, viewport }) {
this.gl = gl
this.scene = scene
this.viewport = viewport
const geometry = new Plane(this.gl)
const program = new Program(this.gl, {
vertex,
fragment,
uniforms: {
uColor: { value: new Color('#c4c3b6') }
},
transparent: true
})
this.meshes = []
for (let i = 0; i < 50; i++) {
let mesh = new Mesh(this.gl, {
geometry,
program,
})
const scale = random(0.75, 1)
mesh.scale.x = 1.6 * scale
mesh.scale.y = 0.9 * scale
mesh.speed = random(0.75, 1)
mesh.xExtra = 0
mesh.x = mesh.position.x = random(-this.viewport.width * 0.5, this.viewport.width * 0.5)
mesh.y = mesh.position.y = random(-this.viewport.height * 0.5, this.viewport.height * 0.5)
this.meshes.push(mesh)
this.scene.addChild(mesh)
}
}
}
然后只需要对它们应用无限滚动逻辑,并遵循与 Media
类中相同的方向进行验证:
update (scroll, direction) {
this.meshes.forEach(mesh => {
mesh.position.x = mesh.x - scroll.current * mesh.speed - mesh.xExtra
const viewportOffset = this.viewport.width * 0.5
const widthTotal = this.viewport.width + mesh.scale.x
mesh.isBefore = mesh.position.x < -viewportOffset
mesh.isAfter = mesh.position.x > viewportOffset
if (direction === 'right' && mesh.isBefore) {
mesh.xExtra -= widthTotal
mesh.isBefore = false
mesh.isAfter = false
}
if (direction === 'left' && mesh.isAfter) {
mesh.xExtra += widthTotal
mesh.isBefore = false
mesh.isAfter = false
}
mesh.position.y += 0.05 * mesh.speed
if (mesh.position.y > this.viewport.height * 0.5 + mesh.scale.y) {
mesh.position.y -= this.viewport.height + mesh.scale.y
}
})
}
就这么简单,现在我们的代码终于完成了。
Reference
OGL: https://github.com/oframe/ogl
[2]Three.js: https://threejs.org/
[3]Babylon.js: https://www.babylonjs.com/