前言
最近手上有个项目,要翻新一个旧的角色关系图页面,需要优化展示角色关系,凸显人物及其关联关系。下面让我们看看设计师给出的效果。
初次看到这个时,其实心理状态是这样的。
冷静下来,分析一波,也不是很难。
下面,一步步解析下是如何实现这样的一个页面的。
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)
其他动画效果都大同小异,在这里不一一叙述了。
最后,看下整体效果~
欢迎大家长按识别二维码或点击链接体验线上成品:
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 呈现模式分析。
黄色——表示处理任务的时间,也可以说是CPU等待GPU完成任务的时间,线条越高表示 GPU 做的事情越多
红色——表示执行任务的时间,线条越高表示需要绘制的视图更多
蓝色——表示绘制视图列表所需要的时间,线条越高说明当前视图比较复杂或者无效需要重绘,影响帧率
绿色——包括测量、布局、动画、输入事件、主线程任务等等
绿色水平线——表示16ms线,每个柱形高度都小于它是最理想的
从整体趋势可以看出两款手机渲染均显示稳定(图形没有明显突变波动)。但低端机上整体性能略差。
红色和黄色部分任务都是由 CPU,GPU 负责处理,即使超出 16ms 线也不完全意味着页面卡顿。上图可以看出除了手机配置差异外,绘制的视图还是存在略多的问题,后期可选择合并渲染对象,减少渲染对象数量来优化。蓝色和绿色部分则是我们需要重点关注的,低端机的较差表现依旧可以认为是元素布局层级多,配置低效率差的原因。
另外,由于页面始终存在动画效果,总是不停歇的在渲染,长时间的高频渲染是部分机型长时间运行发热的根本原因。可以从闲时停止渲染着手优化,这里不再扩展。
5. 总结
经历了如此复杂、细节满满的页面。学习到了很多没有接触过的知识。同时,也有几点感悟:
- 再复杂的事物,也都可以化繁为简,一一解决。
- 细节、细节、还是细节。
相关参考:
thebookofshaders.com/13/?lan=ch
patriciogonzalezvivo.github.io/glslEditor/