PIXIJs实战之角色关系图

3,362 阅读11分钟

前言

最近手上有个项目,要翻新一个旧的角色关系图页面,需要优化展示角色关系,凸显人物及其关联关系。下面让我们看看设计师给出的效果。

图片名称

初次看到这个时,其实心理状态是这样的。

冷静下来,分析一波,也不是很难。

下面,一步步解析下是如何实现这样的一个页面的。

1. 整体分析

首先,面对一个复杂的内容不能盲目下手。拆分开来看看整个页面包含哪些内容,决定下整体方向。

图片名称

从层级上划分,包含三个部分:背景层,角色关系层,角色信息层。

角色信息:没有过多的动效和重复渲染,直接用 Dom 元素去实现,方便简单。

角色关系层 & 背景层:单看云雾背景,第一感觉可以用 3D 引擎去实现整体背景,比如 THREEJS,但对于一个单页面 THREEJS 的体量未免过重,页面内容本身更符合 2D。所以,在技术选型上选择了 2D 渲染库-- PIXIJS。PIXIJS 在 2D 渲染速度方面是最快的,同时 PIXIJS 封装了非常丰富的API可以使用,方便对不同的渲染对象进行属性设置、修改。动效方面,可以搭配 TweenJS 设定要修改的属性,轻松实现各种动画效果。

对需求进行拆解及分析之后,选定了使用 PIXIJS 实现。梳理一下整体的实现思路:页面引入 PIXIJS, 创建应用(app)及舞台(stage),根据不同层级对展示内容进行拆分,分别实现得到每层的可视化对象,最终归集于舞台上渲染展示。具体见下图:

图片名称

2. 具体实现

2.1 创建舞台

应用(app):会自动选择使用 Canvas 或者是 WebGL 来渲染图形,这取决于浏览器的支持情况。

舞台(stage):所有要渲染的可视化对象都需要添加到舞台中才能被显示出来,舞台是可视化对象的根容器,也是整个树形渲染结构的最底层。具体如下:

const app = new PIXI.Application({
    width: windowWidth,
    height: windowHeight,
    antialias: true,
    resolution: devicePixelRatio,
    transparent: true,
})
app.renderer.autoDensity = true
app.renderer.resize(windowWidth, windowHeight)
document.getElementById('container').appendChild(app.view)
app.stage.sortableChildren = true

概念解释:

  • antialias:使字体和图形边缘更加平滑。
  • resolution:设置分辨率,决定了Canvas 的实际宽高(即 width/height )。
  • autoDensity:属性设置为 true,使 Canvas 视图的 CSS 尺寸自动调整为屏幕尺寸(即 canvas.style.width/height )。
  • sortableChildren:属性设置为 true,舞台内的子元素将按设置的 zIndex 值排序(默认的 zIndex 为0)。

*需要注意的是,resolution 越大 Canvas 需要绘制的区域就越大,这会拖慢整体加载及渲染速度。即使这样做可以保证足够的清晰度,但仍需开发人员自己衡量、优化。

2.2 创建背景

背景主要分为三层:云雾、星空以及地球。

图片名称

2.2.1 云雾

对于云雾背景,通过查找网络示例,使用着色器编写效果是最好的。

图为通过噪声模拟的云雾纹理

而 PIXIJS,正好也提供了一种特殊的着色器 Filter ,它将后期处理效果应用于输入纹理,并写入输出渲染目标。简单讲,我们可以完全自定义纹理效果并轻松的展示它。

首先来创建渲染目标及Filter:

const background = new PIXI.Sprite()
background.width = this.app.screen.width
background.height = this.app.screen.height
this.app.stage.addChild(background)

this.filter = new PIXI.Filter(null, fogFragment, {
  uResolution: {
    x: this.app.screen.width * devicePixelRatio,
    y: this.app.screen.height * devicePixelRatio,
  },
  uTime: 0,
})
background.filters = [this.filter]

创建好 Filter 后,很重要的一步是要传入更新时间,使其随着时间去渲染以实现云雾飘动的效果。

update() {
  this.filter.uniforms.uTime += 0.01
}

具体的着色器代码如下:

void main() {
  const vec3 c1 = vec3(0.110, 0.110, 0.137);
  const vec3 c2 = vec3(0.133, 0.149, 0.247);
        
  vec2 p = gl_FragCoord.xy * 8.0 / uResolution.xx;
  float q = fbm(p - uTime * 0.1);
  vec2 r = vec2(fbm(p + q + uTime * 0.4 - p.x - p.y), fbm(p + q - uTime * 0.7));
  vec3 c = mix(c1, c2, fbm(p + r));
  float grad = gl_FragCoord.y / uResolution.y;
  gl_FragColor = vec4(c * cos(1.4 * gl_FragCoord.y / uResolution.y), 1.0);
  gl_FragColor.xyz *= 0.8 + grad;
} 

基本原理是使用噪声模拟云雾纹理,加以两种颜色混合渲染,实现云雾飘动的效果。更多细节这里不在叙述。(大家有兴趣可以去了解下 shader,那是一片美丽的新大陆)。

此时我们的页面已经有了第一层。可以看到,效果还是很自然、丝滑的。

图片名称

2.2.2 星空

对于星空的实现思路稍微复杂点:多精灵复用+径向分布+相机运动。

首先是创建多精灵,很简单,循环创建即可。

// 循环创建精灵
for (let i = 0; i < this.starAmount; i++) {
  const star = {
    sprite: new PIXI.Sprite(starTexture),
    z: 0,
    x: 0,
    y: 0,
  }
  // 锚地设置居中
  star.sprite.anchor.set(0.5)
  this.randomizeStar(star, true)
  // 加入舞台内
  this.app.stage.addChild(star.sprite)
  // 保留对象
  this.stars.push(star)
}

创建好每个星星后,使用正余弦函数计算同一随机弧度得出它们的 x,y 轴位置,以确保没有与相机位置(即 0,0)重叠的对象。这就是所谓径向分布。

randomizeStar(star, initial) {
    star.z = initial
      ? Math.random() * 2000
      : this.cameraZ + Math.random() * 1000 + 2000

    // 随机分布在径向坐标上,确保没有与相机位置(即0,0)重叠的对象
    const deg = Math.random() * Math.PI * 2 // 0~2PI
    const distance = Math.random() * 60 + 1 // 距中心距离,越大越发散
    star.x = Math.cos(deg) * distance
    star.y = Math.sin(deg) * distance
}

接下来,要让星空动起来。

相机运动,简单讲就是让相机随时间匀速推进( z 轴坐标增加),并将星星的坐标由三维坐标投影至二维。同时,需要计算当前星星是否已经出镜(即 z 轴坐标小于相机坐标),出镜则重新初始化位置,以起到重复使用的作用。具体代码如下:

update(delta) {
    this.cameraZ += delta * 10 * this.baseSpeed
    for (let i = 0; i < this.starAmount; i++) {
      const star = this.stars[i]
      // 若z轴坐标小于相机,说明已出镜,重新计算位置
      if (star.z < this.cameraZ) {
        this.randomizeStar(star)
      }

      // 三维坐标投影至二维
      const z = star.z - this.cameraZ
      star.sprite.x =
        star.x * (this.fov / z) * this.app.renderer.screen.width +
        this.app.renderer.screen.width / 2
      star.sprite.y =
        star.y * (this.fov / z) * this.app.renderer.screen.width +
        this.app.renderer.screen.height / 2

      // 计算缩放
      const distanceScale = Math.max(0, (2000 - z) / 2000) * this.starBaseSize
      star.sprite.scale.x = distanceScale
      star.sprite.scale.y = distanceScale
    }
}

让我们看看实际效果,还是很有代入感的~

图片名称

*视频中星星数量为100,可根据实际使用进行调整。但要注意过多的渲染对象会增加渲染成本,拉低性能。

地球背景没有过多的动态效果,直接使用直接使用精灵贴图就好,这里不在叙述~

看一下整体效果。

图片名称

做到这里,是不是感觉已经成功一半了呢~

图片名称

2.3 创建角色及其关联关系

2.3.1 角色头像

对于角色,可以两个点切入:定位、绘制。

先从绘制入手,考虑到动效及交互,之后会更利于处理,头像合并绘制成一个对象,由四个部分组成:背景、头像图片、阴影遮罩及名称。

图片名称

考虑到存在头像图片裁剪、文字超长省略等问题。使用 Canvas 绘制更为灵活和便捷。通过 Canvas 绘制出不同的头像和名称,Canvas 转换成 Texture 再加载为精灵图更为灵活和便捷。最后,添加到背景金圈中,形成一个整体。

const canvas = document.createElement('canvas')
const texturePIXI.Texture.from(canvas)
const roleSprite = new PIXI.Sprite(texture)

其次是定位问题,根据设计师描述,所有的角色分为了主角、关联角色两部分,其中关联角色又分为内圈 & 中圈角色各 6 个、外圈角色至多 24 个。

图片名称
  • 主角:定位在舞台中央即可。

  • 内圈、中圈:不难看出,他们均匀的分布在圆周上,即每个角色之间的弧度为三分之一PI;

  • 外圈:外圈比较特殊,虽然是至多摆放24个,但一屏内实际能看到的内容最多只有12 个,分布在上下两个半圆弧上,所有我们只需要计算出第一个点的位置,其余的可以通过它依次推理出来。

下面简单画了一个示意图:

图片名称

通过 arctan 函数(反正切函数)计算出一个弧度值,这个弧度值即可以确定第一个角色的初始位置,同时它也代表前三个角色占位的总弧度,进而可以计算出固定的弧度间隔,依次计算出每个角色的弧度。

const h = this.screenHeight * 0.3
const w = this.screenWidth
const atan = Math.atan(w / 2 / h)

2.3.2 角色关系

图片名称

角色定位后,角色关系就很容易处理了。可以拆分为:连线和关系名称。

每个角色的圆心连线即关系连线,这一点很容易做到,只需要计算两两圆心间距在绘制线条即可。

const widthTarget =
Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)) -(this.ROLE_SIZE / 2) 

注:这里连线是需要穿入关联角色的中心,故只剪去了主角的半径。至于层级顺序,上文提到过的每个对象的 zIndex 属性可以轻松解决。

对于关系名称,同样使用 Canvas 绘制文字,再转换为 Texture 加载。这样做的好处在于我们可以在绘制的同时,通过 ctx.measureText 方法拿到文字的实际宽度,以结合线条长度计算出文字相对线条的居中位置。

relation.position.set(
  line.x + ((widthTarget - (v.effectiveWidth / 2) * operation) / 2) * Math.cos(rotation),
  line.y + ((widthTarget - (v.effectiveWidth / 2) * operation) / 2) *Math.sin(rotation),
)

细心的同学会发现这里多乘了一个operation,它是什么呢?

让我们看看去掉它的样子。

图片名称

左侧的文字倒置了。脖子快扭断了才能看,体验是不是极差~

operation 就是处理这种情况的,对弧度在第二、第三象限绘制的文字进行镜像倒置。设置文字 scale 为 -1,就是指将渲染对象的 x、y 轴缩放 -1 倍,即实现镜像倒置。

另外,设 operation 为 -1。文字被镜像倒置后,原本的位置计算也发生相应的变化。

这样,文字摆放正常的同时依旧是居中的。

if (rotation < -Math.PI / 2 && rotation > (-Math.PI / 2) * 3) {
  // 镜像倒转
  relation.scale.set(-0.5)
  operation = -1
}

3. 动画效果

当我们创建好舞台、背景以及处理好角色及其关联关系后,下一步就可以处理动画了。

角色的动画主要包括缩放、位移、旋转。使用 TweenJS 可以非常简单的实现每个对象的动画。

Tween.js,类似于 jQuery 的 animate 方法和 CSS3 动画。以平滑的方式修改元素的属性值。只需要告诉 TweenJS 你想修改什么值,以及动画结束时它的最终值是什么,动画花费多少时间等信息,Tween 引擎就可以计算从开始动画点到结束动画点之间值,就可以产生平滑的动画效果。另外还有很多中缓动函数,帮助创作出更完美的动画效果。

图片名称

3.1 动画实现

具体实现如同上文介绍的一样简单,下面是一个主角登场的动画,一看就懂:

const enterTween = new TWEEN.Tween(bg)
      .group(this.innerGroup)
      .to({ scale: { x: scaleTarget, y: scaleTarget }, rotation: 0 }, 300)

又例如外圈角色的转动动画,也只是简单的旋转,加以缓动函数辅助实现的,非常简单易懂。

const tween = new TWEEN.Tween(this.outer[i])
    .to({ rotation: ro }, du)
    .easing(TWEEN.Easing.Sinusoidal.InOut)
    .delay(5000 - du)

其他动画效果都大同小异,在这里不一一叙述了。

最后,看下整体效果~

图片名称

欢迎大家长按识别二维码或点击链接体验线上成品:

图片名称

h5.if.qidian.com/h5/relation…

4. 爬了一些小坑

4.1 适配问题

起初,全局所有对象的适配均是按照等比放大,但在例如 iPad Pro 等大屏设备上,角色头像显示的过大、内圈中圈角色偏移位置过大等等适配问题,整体美化度被大大折扣。

为解决这个问题,在舞台加载前,根据当前设备屏幕宽高和设计稿提供的宽高计算出一个基准值 Benchmark。

getBenchmark(w, h) {
      return (
        (innerWidth * h > innerHeight * w
          ? (innerHeight * w) / h
          : innerWidth) / 360
      )
}

后续的所有计算可以加入此系数,限制缩放、位置计算等使整体效果达到最优。

role.width = base * this.app.benchmark
role.height = base * this.app.benchmark
……
const scaleTarget = s[i] * this.app.benchmark
……
const offsetX =
  Math.cos(rotation) * 
  (this.app.benchmark * this.REFER_SHOW_SIZE) * coefficient
const offsetY =
  Math.sin(rotation) *
  (this.app.benchmark * this.REFER_SHOW_SIZE) * coefficient
……
const widthTarget =
  Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)) -  (this.ROLE_SIZE / 2) * this.app.benchmark

4.2 shader noise算法兼容问题

在测试阶段,发现了一些 iOS 机型存在云雾背景渲染出错问题。很明显,是 iOS Safari 浏览器版本兼容问题。

持着这样的猜测去测试机上逐句调试 Shader 代码,最终发现并非某个 Shader 内置计算方法不支持,而是在噪声计算中,设置的 43758.5453 系数在低版本 Safari 浏览器中不支持,改为 537.5453 即可修复问题。本质上该参数只影响由正弦函数生成的伪随机数密度,不影响整体效果的展示。

float rand(vec2 n) {
  return fract(cos(dot(n, vec2(12.9898, 4.1414))) * 437.5453);
}

4.3 外圈角色滚动不自然

最初的外圈转动实现,是修改 x,y 轴坐标。但如果把动画时间调长会发现动画很不自然。根本原因是同一时间内 x,y 轴的偏移量是不一样的。为了解决这个问题,重新制定外圈实现思路,通过计算弧度来进行定位、旋转弧度来实现动画效果。(具体见上文)

4.4 性能相关

在测试阶段,主要出现两点性能问题:

  • 低端机略显卡顿
  • 部分机型长时间运行发热的问题

下面找了两款配置差别较大的真机进行 GPU 呈现模式分析。

华为Mate30 和 小米5s

黄色——表示处理任务的时间,也可以说是CPU等待GPU完成任务的时间,线条越高表示 GPU 做的事情越多

红色——表示执行任务的时间,线条越高表示需要绘制的视图更多

蓝色——表示绘制视图列表所需要的时间,线条越高说明当前视图比较复杂或者无效需要重绘,影响帧率

绿色——包括测量、布局、动画、输入事件、主线程任务等等

绿色水平线——表示16ms线,每个柱形高度都小于它是最理想的

从整体趋势可以看出两款手机渲染均显示稳定(图形没有明显突变波动)。但低端机上整体性能略差。

红色和黄色部分任务都是由 CPU,GPU 负责处理,即使超出 16ms 线也不完全意味着页面卡顿。上图可以看出除了手机配置差异外,绘制的视图还是存在略多的问题,后期可选择合并渲染对象,减少渲染对象数量来优化。蓝色和绿色部分则是我们需要重点关注的,低端机的较差表现依旧可以认为是元素布局层级多,配置低效率差的原因。

另外,由于页面始终存在动画效果,总是不停歇的在渲染,长时间的高频渲染是部分机型长时间运行发热的根本原因。可以从闲时停止渲染着手优化,这里不再扩展。

5. 总结

经历了如此复杂、细节满满的页面。学习到了很多没有接触过的知识。同时,也有几点感悟:

  1. 再复杂的事物,也都可以化繁为简,一一解决。
  2. 细节、细节、还是细节。

相关参考:

pixijs.com/

github.com/tweenjs/twe…

thebookofshaders.com/13/?lan=ch

en.wikipedia.org/wiki/Fracti…

patriciogonzalezvivo.github.io/glslEditor/