视差长图 H5 实践

需求场景

主人公行走路过几个城市,沿途展示城市风貌与特色建筑,随着其前进周边的场景物体开始出现动画,若主人公后退则周围场景进行倒序消失的动画,具体表现与正序或倒序播放一段影片类似。除了出现动画外,场景物体由于层级景深不同,运动速度不同,即产生视差效果。

参考 Demo

  1. http://news.163.com/college/special/qingzang_railway/
  2. http://news.163.com/special/fdh5_tolerance/

视差

这里来解释下上文所说的视差:

视差效果,原本是一个天文学术语,当我们观察星空时,离我们远的星星移动速度较慢,离我们近的星星移动速度则较快。当我们坐在车上向车窗外 看时,也会有这样的感觉,远处的群山似乎没有在动,而近处的稻田却在飞速掠过。许多游戏中都使用视差效果来增加场景的立体感。说的简单点就是网页内的元素在滚动屏幕时发生的位置的变化,然而各个不同的元素位置变化的速度不同,导致网页内的元素有层次错落的错觉,这和我们人体的眼球效果很像。我看到多家产品商用视差滚动效果来展示产品,从不同的空间角度和用户体验,起到了非常不错的效果。
via 视差滚动(Parallax Scrolling)效果的原理和实现

实现视差即为不同的层级产生不同的运动速度,由远及近运动速度越快。

技术选型

最初向左一个类似 Demo 的效果,鉴于 Demo 中使用了 Pixi.js 与 GreenSock 可参考,所以…我也选用了同样的框架(什么 Canvas、WebGL 性能好,不存在的!)

渲染引擎 Pixi.js

Pixi.js 是一款超快开源 HTML5 2D 渲染引擎,它能够自动侦测使用 WebGL 或者 Canvas。

Pixi.js 学习资料

官网:https://pixijs.io

中文教程

动画引擎 GSAP

GSAP(GreenSock Animation Platform)在 Flash 时代就久负盛名的动画库,步入 HTML5 时代其也与时俱进支持了 DOM 与 JavaScript 动画,号称 20x 快于 jQuery 并支持 GPU 加速。最后 GSAP 与 Pixi.js 也支持的很好。

GSAP 有 4 款动画工具:

  • TweenLite:GSAP 核心库,借助其很方便实现补间动画。
  • TweenMax:TweenMax 继承 TweenLite,相较前者添加了新功能以及插件,可以理解为增强版的 TweenLite。
  • TimelineLite:是一个时间轴工具,可以理解为放置补间动画的容器,可管理多个补间动画序列。
  • TimelineMax:同上 TimelineMax 为 TimelineLite 的增强版。

GSAP 资料

官网:https://greensock.com/
贝塞尔曲线工具:https://greensock.com/customease

技术实现

确定好技术框架,那么久撸起袖子开始干吧,这里列举了一些技术实现上的要点。

地铁遮罩

需求中是有地铁穿梭的动画效果,我们可以使用遮罩来实现:定义好一个区域,让列车在其中运动,超出区域的部分需要被遮盖住。实现这样的需求我们使用 mask 遮罩,先画一个矩形,改矩形尺寸为目标精灵可视区域,将目标精灵的 mask 指向 最后让精灵做正常位移即可。

let mask = new PIXI.Graphics().drawRect(0 ,0, maskWidth, maskHeight);
let train = new PIXI.Sprite(new PIXI.Texture.fromImage('train.png'));
app.stage.addChild(train);
train.mask = mask;

资源加载 Loader

由于静态资源很多且移动端网速较慢,为了确保良好的体验,需做资源预加载。Pixi.js 提供了加载静态资源的 loader,可以很方便的将资源添加到加载队列中、监控加载进度以及提供了加载完成后的回调。

let loader = new PIXI.loaders.Loader();
loader.add(key, url);
loader.load();
loader.onProgress.add((d) => {
    // 在此处理加载进度
    console.log(`正在加载 ${d.progress.toFixed(2)}%`);
});
loader.onComplete.add(() => {
    // 在这里处理资源加载完毕回调
});

如要加载音频资源,切勿在微信端内使用 Pixi.js 的 loader,会导致资源无法成功加载且不抛出异常。而且这种使用方式加载音频资源是错误的,请使用 pixi-sound 插件替代。

材质包

如果小体积资源数量过多,可以将图片制作成材质包(Texture Pack)来加载,这样能减少资源请求数目,从而打到提升加载速度的目的,其实就是雪碧图技术的升级版本。材质包中包含雪碧图以及记录精灵图片位置信息的 json 文件。通过 TexturePacker 这款软件我们能很方便的制作出材质包。

使用 Webpack 打包

不可避免的在使用 JavaScript 加载图片,优于 Webpack 会对图片作处理,开发环境与线上环境静态资源的路径有改变,所以需使用 require 获取图片资源地址。

let loader = new PIXI.loaders.Loader();
images.forEach((image) => {
    loader.add(image.name, require(`asset/imagse/${image.url}`));
});

对于材质包我们需要特殊处理下:默认情况下 Pixi.js 加载材质包只需提供 json 文件即可,其会读取 json 文件中定义的图片地址并加载解析,但还是因 Webpack 打包问题,线上环境静态资源路径会有变化,所以还得修改 json 中定义的雪碧图路径,显然这不够最佳实践。我们可先加载雪碧图,然后再去加载 json 解析。

// load images 

// parse texture pack
loader.load((loader, resources) => {
    spritesJsonList.forEach(item => {
        let json = require(`assets/sprites/${item}.json`);
        const sheet = new PIXI.Spritesheet(resources[item].texture.baseTexture, json);
        sheet.parse(() => {
            // done!
        });
    });
});

层级控制

控制 Sprite 的层级关系有三种方式:

  1. 与插入 Canvas 元素的顺序相关,越后插入层级越高
  2. zOrder 控制
  3. addChildAt(child, index)

各个场景的物体,根据其定义的顺序依次插入画布。使用处理 isNotBehand 字段处理元素与主角之间的层级关系,这里我处理的不够细致,场景其他元素大多依赖调整元素在定义时的顺序,用方式 3 因该可做到精确控制。

// 筛选出在主人公后面的 sprites 先插入主场景
renderObjectList.forEach((item) => {
    if (item.isNotBehind) {
        beforeMainRoleSet.push(sprite);
    } else {
        mainContainer.addChild(sprite);
    }
});

// 筛选出在主人公后面的 sprites 先插入主场景
mainContainer.addChild(mainRoleSprite);

// 将主人公前的 sprites 插入场景
beforeMainRoleSet.forEach((item) => {
    mainContainer.addChild(sprite);
});

动画流程控制

以上完成了 Sprite 的资源加载与布局,那么接下来就需要控制场景中的物体运动了。

动画开始暂停

Pixi.js 中 PIXI.ticker.Ticker 类提供了控制渲染循环的更新回调接口,也提供了控制场景暂停或者恢复渲染方法。在 add() 方法中高阶函数中通过设置 Sprite 的位置实现其位移运动,play()stop() 方法可实现动画的播放与暂停。

let app = new PIXI.Application({
    // some config
});

let speed = 1;
app.ticker.add(() => {
    // 控制位置实现位移
    sprite.x += speed;
});

// 暂停动画
app.ticker.stop();

// 开始动画
app.ticker.play();

不过本次需求要求主人公始终保持行走的动作,stop() 方法暂停了整个场景的渲染,会导致其动作停止,所以只用 canPlay 这个变量来控制物体位移,因主人公行走动画是利用 AnimationSprite 实现,有单独的控制方式,故不受其影响。

let app = new PIXI.Application({
    if (!canPlay) {
        return false;
    }
    // animation control
});

动画类型

现在考虑就是动画怎么动了,根据动画的效果,我把动画分为 3 种类型,用不同的方式去实现,分别是:

  1. 位移:在本项目中为物体的 x 轴的位移运动。
  2. 补间:一些非水平位移的动画,比如海豚🐬跳跃,高楼大厦拔地而起,这里是借助 GSAP 实现的,具体实现方式下文细说
  3. 序列帧:如一些动作比较复杂无法同过物体变化,如主人公的行走、海鸟飞翔、主人公自拍动作等
场景物体动画开始结束控制

先抛开场景物体具体怎么动不谈,我们先来考虑下它们的动画啥时候开始,啥时候结束。我的定义非常简单,动画开始就是物体从视口出现,动画结束就是从视口消失。

这样我们的碰撞检测函数就 OK 了,接下来物体的动画开始结束需依赖其判定:

function testHit(sprite, viewPort) {
    return Math.abs((sprite.x + sprite.width / 2) - (viewPort.width / 2)) < ((sprite.width + viewPort.width) / 2)
}

这里继续拓展下,既然已经知道了物体运动距离,那么从中计算出当前物体处于整个动画周期的进度就很简单了:如果物体大小小于视口距离,那么运动距离为视口距离的一半,如果大于即为物体的宽度的一半(处于效果的优化这里都取一半)。最后用物体进入视口的具体位移除以上述求得距离即为我们要求得的动画进度。

 let width = Math.max(sprite.width / 2, viewPort.width / 2);
 let percent = (viewPort.width - sprite.x) / width;

位移

所谓人物行走,实际上是人物保持不动只是环境物体在运动,从而欺骗人眼达到人物向前行走的效果。至于视差,即控制目标物体运动速度为正常速度的倍率,大于 1 为快速,小于 1 的为慢速。正反序播放依赖 direction, 诺正向运动其为 1,反之为 -1

if (isHit) {
    let add = -speed * sprite.speed_rate.x
    sprite.x += add * direction;
}

补间

补间动画依赖 GSAP 实现,准确的说是 TweenMax,首先我封装了一些常用动画,并在渲染元素列中指定相应的动画。

let DURATION_TIME = 1;

const AnimationFn = {
    slideInUp(item) {
        return TweenMax.fromTo(item, DURATION_TIME, {y: item.y + item.height}, {y: item.y});
    },
    slideInDown(item) {
        return TweenMax.fromTo(item, DURATION_TIME, {y: -item.y}, {y: item.y, ease: 'Bounce.easeOut'}, {});
    },
    fadeIn(item) {
        return TweenMax.fromTo(item, DURATION_TIME, {alpha: 0,}, {alpha: 1});
    },
    fadeInUp(item) {
        return TweenMax.fromTo(item, DURATION_TIME, {y: item.y + item.height, alpha: 0,}, {y: item.y, alpha: 1});
    },
    lakeFadeIn(item) {
        return TweenMax.fromTo(item, DURATION_TIME, {alpha: 0}, {alpha: 1, delay: DURATION_TIME / 2});
    }
};

let renderObjects = [
    {
        name: 'xm_lake',
        attr: {
            x: 2252 + 3201,
            y: 879,
        },
        playFn: AnimationFn.lakeFadeIn
    },
    // ...
]

在初始化将元数据定义的动画初始化:

 let playFnWrap = function (fn, item, direction) {
    let that = this;
    if (!that.greenSock) {
        that.greenSock = fn.call(that, item, direction).progress(0);
    }
    item.visible = true;
    return this.greenSock;
};

renderObjects.forEach((item, index) => {
    // sprite = new PIXI.Sprite(resource);
    // other init operation ...
    if (item && item.playFn) {
        playFnWrap.call(item, item.playFn, sprite, direction);
    }
});

上文我们说到了如何获取元素运动的百分比,这里就用上了,TweenMax 中 progress() 方法可将当前动画运动到指定的进度,这样就把补间动画转化为按照运动进度播放的动画了。因为算的是百分比,正序倒序播放也顺便解决了。

info.greenSock.progress(percent);

序列帧动画

Pixi.js 提供了 AnimatedSprite 很方便的就能实现序列帧动画,设置序动画的播放暂停,指定播放到第几帧,不过不支持倒序播放,有点遗憾,所以在回退运动中,序列帧动画也是正向播放的。

let framesArray = [];
for (let i = 0; i < 6; i++) {
    let texture = PIXI.utils.TextureCache[`shot_people_${i+1}.png`];
    framesArray.push(texture);
}
let shootPeople = new PIXI.extras.AnimatedSprite(framesArray);
shootPeople.animationSpeed = .1;
shootPeople.play();

性能优化

这个项目最大的问题就是场景太长,元素太多,场景中的 Sprite 多达 270 多个,如若算上 Mask,那得继续翻倍,所以性能问题还是很棘手的,我这里采取了一些措施,虽然最后的结果不够理想,但也是努力过后的结果了🤣。

资源优化

  1. 压缩尺寸到之前 1/2,明显改善卡顿
  2. 拆分长图与大图成小块,图的长宽最好为 2 的整数次幂。

资源加载速度优化

  1. 压缩资源:270 多张图最终控制在 4MB 之内。
  2. 借助 CDN,实现域名发散突破浏览器下载资源数限制,但是 Canvas 或者 WebGL 不能够绘制跨域图片,所以还需要修改 CDN 服务器响应头部。
  3. 使用雪碧图,也就是上文的材质包。

渲染模式

Pixi.js 可使用 WebGL 或者 canvas 模式渲染,经过测试,移动端 WebGL 性能要高于 canvas 模式,最终采用前者。除此之外,还开启了一些性能选项,以及调低了渲染效果以求最佳性能,不过没有具体验证过,只是按照文档设置了一番:

 let app = new PIXI.Application({
    antialias: false,
    forceCanvas: false,
    roundPixels: false,
    forceFXAA: false,
    powerPreference: 'high-performance',
    view: document.getElementById('stage')
});

我对 WebGL 不是很熟悉,所以这次使用还是有些冒进的,这里初略整理了些资料,希望以后学习下:

出视口元素优化

运动出视口的 sprite 的 visible 设置为 fasle, Pixi.js 的文档中提到 visible = false 的 sprite 是不会被渲染的,这样我们只渲染处于视口中的元素,这里优化的不到位,如果超出视口的元素被销毁因该效果更好,不过需求有回放的操作,如果往回走,销毁的 sprite 还需被重新创建,有些复杂,所以没有进一步优化。

Bugs

选用 WebGL 是因为在 Android 机型上,WebGL 下性能要优于 Canvas。但 WebGL 的兼容性可没 Canvas 好,当时还挺担心在 Android 下的兼容性问题,不过还好,在 iOS 上倒是有一些预料不到的兼容性问题。

钉钉客户端崩溃

抖音地图项目在钉钉端内 iOS 端偶发奔溃,与钉钉的同学沟通反馈得知 UIWebView 自身不太稳定,需在 URL 中添加 dd_func_wk=true 启用 WKWebView,情况会有改善。

iOS 客户端崩溃

iOS 端内页面打开时 App 切换到后台,导致 App 崩溃,错误日志提示gpus_ReturnNotPermittedKillClient

原因:WebGL 底层 其实调用的是 OpenGL,iOS 不允许 App 在被 pause 后还在调用 OpenGL,进行绘制。

解决:需要在 APP 切换后台的时候停止页面 WebGL 的渲染,需客户端与页面通信提醒,或者监听 Page Visibility,在页面被隐藏的时候暂停页面绘制。或者在 iOS 下强制 Pixi.js 使用 Canvas 模式渲染, forceCanvas: true

https://developer.apple.com/library/archive/qa/qa1766/_index.html

华为 P20 下卡顿

首先华为手机对 WebGL 支持不佳,另外那台机器本省也有问题,运行 Native App 也卡,所以目前不好确定具体问题原因,但遇到华为机器需要重点注意下。

https://club.huawei.com/thread-14773610-1-1.html

长图大图无法渲染

移动端 Pixi.js 使用 WebGL 模式下渲染,长图大图无法展示。如用 Canvas 模式则无该问题,但性能较差。最终将大图分割为小图且宽高控制在 2048px 内可解决该问题。

iOS 开始前进时页面卡顿

在移动端一次性 load 多个音频会导致游戏页面的卡顿,load 完成后页面卡顿,需分批次 load 或者使用 Sound Sprite。最好方案是通过产品设计,引导用户产生 UI 操作,提前加载音频。iOS 由于一个 Audio 会开启一个线程,本次音频较多有 10 个,一次性 laod 会导致页面卡顿。

小结

本项目还有很多需要改进的点,希望下次改进。最后奉上最终成果(请在移动端打开):https://cache.amap.com/activity/2018DouyinMap/index.html 请多多指教。

发表评论

电子邮件地址不会被公开。 必填项已用*标注