惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
Security Latest
Security Latest
S
Secure Thoughts
H
Heimdal Security Blog
The Last Watchdog
The Last Watchdog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security Affairs
PCI Perspectives
PCI Perspectives
C
CERT Recently Published Vulnerability Notes
C
CXSECURITY Database RSS Feed - CXSecurity.com
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
有赞技术团队
有赞技术团队
爱范儿
爱范儿
腾讯CDC
P
Privacy & Cybersecurity Law Blog
量子位
T
Threat Research - Cisco Blogs
V
V2EX
S
Schneier on Security
P
Proofpoint News Feed
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
Forbes - Security
Forbes - Security
The Hacker News
The Hacker News
月光博客
月光博客
C
Cybersecurity and Infrastructure Security Agency CISA
Schneier on Security
Schneier on Security
L
LINUX DO - 最新话题
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
Visual Studio Blog
S
SegmentFault 最新的问题
G
GRAHAM CLULEY
人人都是产品经理
人人都是产品经理
Hacker News - Newest:
Hacker News - Newest: "LLM"
博客园 - 叶小钗
罗磊的独立博客
WordPress大学
WordPress大学
www.infosecurity-magazine.com
www.infosecurity-magazine.com
C
Cisco Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tenable Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
W
WeLiveSecurity
V
Vulnerabilities – Threatpost
Last Week in AI
Last Week in AI
Google DeepMind News
Google DeepMind News
博客园 - 聂微东
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻

云游君的小站

开源项目的「死与新生」 没有红包封面,但是 AI 春联…… 家·烟花·红包封面 云乐坊工作室的成立与缘起与有关的话 聊聊四元数与万向锁及其实际意义 从零开始写一个 Web Component - GitHub Corners 又拍云—年轻人的第一个 CDN 云空调,便携小空调 char-dust 一个图片转字符画的 npm 包与示例站点 nnrm - 一个极简的 npm/yarn registry 切换管理器 做猪呢,最重要的是开心 从 Vite 与 Vue 开始的 D3 数据可视化之旅 如何找到你朋友的社交账号 写写高斯模糊——从 CSS 模糊滤镜的白边说起 如何从 Valine 迁移至 Disqus ADV 游戏引擎计划 关于我打算当老师并报考教师资格证这件事 Strato - Hexo 主题「Yun」版本宣传 PV 我与小镇与家 el-bot & mirai-ts 制作笔记 第三方评论系统之我见 人生不是一场马拉松 👴就是二次元,故尝试证明。 教你如何从零开始搭建一个属于自己的网站 使用 CDN 加速你的 GitHub Pages 网站 使用 CCDIK 或 FABRIK 实现逆向动力学 【转】三维文件格式知多少 【译】30 分钟学会 LaTeX LaTeX 使用笔记 在 macOS 下配置 OpenGL 路漫漫其修远兮 CSS 陷阱 Blender 学习笔记 Everyday Auto Xuexiqiangguo 酷 Q 使用笔记 Deepin 使用笔记 塞翁失马焉知非福 hexo-theme-yun 制作笔记 写给可爱的学弟学妹们 北线无战事 让百度收录你的 GitHub Pages 博客 搭建自己的 Minecraft 服务器 导出「你的日记」与导入「一本日记」 从来如此 卷土重来未可知 恋爱证明记 微信与象棋 关于部落冲突的二三事儿(番外)-端午事变 Travis-CI 持续集成部署 Vue 中 canvas 的自适应 大家一起吃火锅吧 Parcel.js + Vue 搭建笔记 Vue 学习笔记 【动画电影】《声之形》观后感 VS Code 配置笔记 仓生 YunYouJun/give-me-money 永远相信美好的事情即将发生 鱼与熊掌 人生本该如此。 Discourse 新一代开源论坛 Hexo 静态博客搭建笔记 Coding WebIDE 云端开发实验室 npm & yarn 常用包与命令 像夏花一样绚烂 【番剧】《月色真美》(老夫的少女心啊) 临摹一只智障女神∠( ᐛ 」∠)_ 南京之旅-记10th计算机设计大赛 Git 学习笔记 Laravel 使用笔记 Wordpress 的实用插件与主题推荐 【定格动画】交换/EXchange 我与幻星的故事 来自某夏令营的回忆 水彩原创头像尝试 云游君表情包制作计划 小说连载初尝试-《阿庫娅的星球》 仓鼠 回声 【分享】网页背景效果插件 Canvas-Nest.js 【花开物语|MAD】幹物女(WeiWei)-Bonboru! 太空 关于部落冲突的二三事儿-2 关于部落冲突的二三事儿-1 关于部落冲突的二三事儿-序
旋转吧!徽章!
☁️ · 2021-09-05 · via 云游君的小站

如何实现一个徽章惯性旋转动画?

标题我只能说:懂得都懂。如何评价「懂的都懂」这句话?|知乎

前言 ​

距离上一次写技术文章过去了多久呢?大概已经屈指不可数了。 与此前相比,无论心境还是境遇也都变化了许多,而我本打算记录的实习生涯还未动笔,便已接近尾声。但愿还有机会一叙。

在家中慵懒地躺了数日,自觉不应继续如此,方翻了个身,决定将此文修整完,以略表奋发之意。 总之,也由衷地希望大家能从中学习到什么或是给我一些更好的改进建议。

那么,……,先旋转起来吧?

简介 ​

本篇文章原本来自于写「🐜 蚂蚁 🌲 森林 🐒 神奇动物」徽章动画时总结的技术分享,考虑到各种因素,已去除了敏感资源。 并编写了更为抽象(高内聚、低耦合)的代码作为示例。

这也是我离开腾讯后,前去蚂蚁实习做的第一个项目。

长啥样 ​

那么首先,我们来看看这个动画的效果。

进入 「蚂蚁森林」 左上角 - 点击发现神奇物种(继续点左上角)

那么我实现了什么?

  • 拖动 3D 徽章
  • 根据拖动速度展现的惯性动画
  • 停止时总是停留在正/反面
  • 进入时的晃动动画
  • 轻拍晃动效果 👏
  • 陀螺仪效果(晃动手机) 📱

尽管这是一个简单的效果,但也同样有着一些细节和需要注意的问题。 接下来将主要就我选取的实现方案和遇到的问题进行分享。

顺便放一个简单的示例(可以直接拖动哦~):

我的实现方案 ​

我们的首要任务是实现拖动徽章,松开手后的惯性动画。

而神奇动物中的徽章使用 tiny3d(蚂蚁开源的一个 3D 渲染库 tiny + tiny-plugin-three) 在 canvas 上渲染的 3D 模型。我们只需要按照帧率改变徽章的旋转角度使其形成动画即可。

你问我为什么不用 Three.js?懂得都懂。 当然 Three.js 较大,且业务可能存在很多定制需求,这也是一部分原因。

如何拖动徽章? ​

首先,我们至少要保证徽章是可拖动的。 那么我们只需要计算屏幕触摸点横向移动的差值(delta X),按比例赋值给徽章饶 Y 轴旋转的角度(欧拉角 y)。

So easy? 此时还基本无需考虑动画的问题。因为手指移动是连续的,旋转就是连续的。

typescript

const deltaX = curPos.x - this.lastPos.x
model.eulerAngles.y += deltaX

setInterval VS setTimeout VS requestAnimationFrame

当我们的滑动停止后,徽章仍继续转动,这意味着这应当是一个动画。 在实现动画之前,我们先来考虑一下动画的机制。

屏幕的渲染帧率通常是:60fps

但实际上这里还有个坑,iOS 省电模式下会降低帧率,导致不同情况下表现不一致。反而不如使用 setInterval 替代 requestAnimationFrame。 不过如果你业余写的小玩具用户量并没有达到蚂蚁森林的千万级别,也许还并不需要花心思考虑这些。更简洁才更优雅。

定时函数 setTimeout(callback, 16) (设置为每 16 ms 执行一次),便可以达到类似的效果。 但是我们还有更为优雅的方式,requestAnimationFrame

你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。 回调函数执行次数通常是每秒 60 次,与浏览器屏幕刷新次数相匹配。

分析动画 ​

接下来,我们对动画的状态进行一下分解。

  • 拖动徽章(此时无需考虑动画、但要避免拖动与惯性动画冲突)
  • 手离开屏幕,徽章旋转速度逐渐衰减(需要得知初速度)
  • 最终总是停留在正面或是反面(在平面上的不同区间赋予对应方向的加速度)

🤔,可能简短的文字难以描述清楚,我们来看一下具体的实现步骤。

详细步骤 ​

事实上,我们将其抽象理解为物理世界中的速度,最后再根据不同阶段计算得出的速度,去算出对应旋转的角度,在每帧渲染时赋值给徽章即可。

初速度 ​

惯性动画,意味着徽章本身结束动应当有一个初速度。

牛顿三大定律-第一定律:假若施加于某物体的外力为零,则该物体的运动速度不变(惯性定律)

好吧,其实和惯性定律关系不大。(我只是想放个定律在这里显得高大上一点。) 简而言之,我们可以在拖动结束时,即监听 touchEnd 时间时赋予徽章一个初速度。

而该初速度可通过手指在屏幕上横向滑动的距离与滑动时间的比值计算而得。

v=ΔxΔtv = \frac{\Delta x}{\Delta t}

Talk is cheap, show me your code.

typescript

// 创建动画实例,具体实现见后文
const inertiaAnimation = createInertiaAnimation({/* ... */})

// 滑动距离
const deltaX
  = e.changedTouches[0].clientX - this.prePos.x

const touchEndTime = new Date().valueOf()
// 滑动时间
const deltaTime = touchEndTime - touchStartTime

/**
 * 初始速度,每 ms 旋转速度(设顺时针为正数)
 * deltaX <= 0 为顺时针
 */
const speed = -deltaX / deltaTime

// 将速度赋予动画实例,播放动画
inertiaAnimation.playAnimation = true
inertiaAnimation.speed = speed
inertiaAnimation.run()

徽章旋转衰减 ​

根据有限的中学物理知识,我们可以有两种方案来模拟衰减。

  • 方案 1: 模拟物理重力,使用加速度来计算速度
  • 方案 2: 模拟摩擦力,采用摩擦系数一样的衰减系数

我决定采用 方案 2,其更简便、更符合逻辑。 譬如,当速度变为相反方向(徽章反着转)这时无需考虑数值的正反,只需乘以衰减系数即可。 此外,加速度模式在之后模拟水平方向上的重力会用到,可以避免两边的逻辑混杂在一起,难以编写。

在经过尝试后,我决定将每帧的衰减系数设定为 0.95。

u: 0.95

typescript

/**
 * 每一步动画
 * @param {number} timestamp
 */
function step(timestamp) {
  // ...
  speed *= u
  // ...
}

平面重力 ​

速度衰减,意味着徽章最后可能停止在任意角度。但这并不是我们想要的,我们希望它能总是保持正/反面。

旋转到一半停了

同样我们可以从物理角度进行思考,在水平面施加一个「重力」,使其总是保持正/反面。 这个重力(加速度)在徽章的左右区间正负是不一样的。

假设从徽章顶部看,顺时针方向为正 ,同时我们可以划分为四个区间。

随后从屏幕看向徽章:(徽章的左半部分,且处于靠近屏幕外的位置时,简称为 左半外。)

重力范围加速度方向数值
左半外加速度向屏幕内+
左半内加速度向屏幕外-
右半外加速度向屏幕内-
右半内加速度向屏幕外+

同时我们需要做一些细节的处理,比如旋转角度大于 180 的时候,负数的时候。 最后给不同旋转区间的角度时的徽章速度赋予不同的加速度。

经过尝试后,我决定将水平重力影响系数设置为 0.008。gravity: 0.008

typescript

// 一些乱七八糟的取余处理(因为会大于 180 嘛)
const remainder = getRotation() % 180
const positiveRemainder = remainder < 0 ? remainder + 180 : remainder

if (positiveRemainder >= 90 && positiveRemainder < 180) {
  speed -= gravity
}
else if (positiveRemainder > 0 && positiveRemainder < 90) {
  speed += gravity
}

如何停止 ​

速度配合水平面的重力加速度,并根据衰减系数逐渐衰减,数字尽管越来越小,但其仍旧是浮点数。

正所谓,「日取其半,万世不竭」。——《庄子》

因此我们需要明确徽章趋近于静止时的一个界限(可以被忽略的参数大小)。

  • toleratedSpeed: 0.001 可以被忽略的速度
  • toleratedAngle: 5 可以被忽略的角度

此外,拖拽时会与加速度动画、以及后续的陀螺仪动画产生冲突,我们需要通过状态来使得他们互相独立。

_this.playAnimation 是否允许播放动画(外部可修改,来决定是否允许播放动画) _this.isPlaying 记录状态,是否正在播放动画(仅用来记录内部是否正在播放的状态)

typescript

function step(timestamp) {
  // 继续播放动画的条件(需要避免和后续的陀螺仪/拖动等冲突)
  if (
    _this.playAnimation
    && (Math.abs(speed) > toleratedSpeed
      || (positiveRemainder > toleratedAngel
        && positiveRemainder < 180 - toleratedAngel))
  ) {
    // 记录是否正在播放动画
    _this.isPlaying = true

    // ...
    // 根据时间和速度,计算每一帧徽章应该旋转的角度
    const deltaRotation = speed * elapsed
    setRotation(deltaRotation)
    // ...
    // 继续播放动画
    window.requestAnimationFrame(step)
  }
  else {
    _this.isPlaying = false
  }
}

抽象函数 ​

到这里,功能便实现的差不多了,但我们仍旧可以对代码进一步进行优化。

  • 使用 TypeScript 编写(增强类型提示)
  • 抽象出可配置的参数
  • 函数内部默认提供默认参数

typescript

export interface InertiaAnimationOptions {
  /**
   * 初速度
   */
  speed?: number
  /**
   * 水平重力影响系数
   */
  gravity?: number
  /**
   * 衰减系数
   */
  u?: number
  /**
   * 最后可忽略的速度
   */
  toleratedSpeed?: 0.0005
  /**
   * 最后可忽略的角度
   */
  toleratedAngel?: 3
  /**
   * 获取旋转角度
   */
  getRotation?: () => number
  /**
   * 设置旋转角度
   */
  setRotation?: (deltaRotation: number) => void
}
  • 函数式而非 Class(有一定争议,但 react 后来的 hooks 和 vue 的 createXXX 很明显都更加青睐函数)
    • createInertiaAnimation

typescript

/**
 * 创建旋转惯性动画
 *
 * @param {*} options
 */
export function createInertiaAnimation(
  options: InertiaAnimationOptions
) {
  return {
    // ...
  }
}

可复用 ​

还有什么可以优化的点? 此处动画的应用场景是 3D 模型,但倘若是换为图片、DOM?

譬如 CSS 编写而成的立体徽章,CSS 原教旨主义 示例:https://explosions.yunyoujun.cn/badge-rotation/ 使用 TypeScript + VueUse + CSS + createInertiaAnimation 搭建,顺便给 Vususe 中 swipe 缺失的 touchcancel 事件提了个 PR。

我们还可以进一步解耦,允许用户(开发者)可以任意自定义旋转方式。 暴露两个可以控制 rotation 的方法。

  • setRotation: 设置旋转角度 transform: rotateY(${val}deg)
  • getRotation: 获取旋转角度 getComputedStyle

typescript

// 创建动画实例
const inertiaAnimation = createInertiaAnimation({
  speed: 0.2,
  getRotation() {
    return rotateY.value
  },
  setRotation(deltaRotation) {
    rotateY.value -= deltaRotation
  },
})

最后一公里 ​

事实上,交给我的徽章动画工作的确到这里就结束了。 但是真的结束了吗?我们还可以自己尝试继续加一点点优化,细节决定成败。

初始晃动效果 ​

用户打开徽章时,进行缓动,让用户知道它是可拖动的。此外这可以和此后的惯性动画共用一个实例,仅需赋予一个开始的初速度,即可快速实现。(也体现了此前优化封装的好处)

typescript

const inertiaAnimation = createInertiaAnimation()
// ...
game.run(options, () => {
  inertiaAnimation.playAnimation = true
  // 赋予一个微小的初速度,并播放动画
  inertiaAnimation.speed = 0.2
  inertiaAnimation.run()
})

轻拍晃动 ​

原本的初速度是根据移动距离和时间计算的,那么用户仅仅想拍一下徽章呢? 这时没有反馈是不大好的,且较小数值的移动距离和时间,容易出现边界计算的问题。 当移动距离和时间小于一定数值时触发,并判断为左侧还是右侧。(同样公用一个动画实例,仅需赋予一个初速度,即可快速实现。)

typescript

// 轻拍
const tapSpeed = 0.3
if (Math.abs(deltaX) < 5 && deltaTime < 200) {
  const pageWidth = document.documentElement.clientWidth
  speed = this.prePos.x > pageWidth / 2 ? -tapSpeed : tapSpeed
}

陀螺仪 ​

用户微微晃动手机,徽章总是面向用户,也是增强体验不错的方式。 但也存在一些小问题… 🐛

  • 与惯性动画冲突造成抖动
    • 通过各类状态判断,当用户在拖动、仍在播放惯性动画时,陀螺仪效果不生效(与拖动和惯性动画之间的冲突处理逻辑类似)
  • 陀螺仪因为用户初始拿的位置便具有数值(应当使用相对数值)
  • 超过 90 度会突变为负数 90 -> -89
  • 数值频率变化敏感
    • 通过相对计算,并进行缓动,过大变化时舍去
  • 网页 API deviceorientation iOS 兼容问题

typescript

window.addEventListener('deviceorientation', (event) => {
  // ...
  // gamma: 从左到右
  // eslint-disable-next-line prefer-const
  let deltaGamma = event.gamma - this.lastGamma
  // ...
}, true)

查询 DeviceOrientationEvent API | Can I use 其实可以发现 iOS 网页的这个事件有兼容问题。 平时只能自求多福了。(支付宝的话则可以调用其自身的原生 API)

总结 ​

至此,一个完整的徽章旋转动画,便算告一段落了。 后续,我们甚至还可以进一步将其封装为一个 npm 包 @explosions/badge-rotation,通过导入的方式来快速使用它。

我在 npm 上建立了一个 explosions 的 org,打算在这个 scope 下发些有趣的动效。 如果你也有些想法和兴趣,可以到这里来点 PR 或 Issue。

bash

npm install @explosions/badge-rotation

typescript

import { createInertiaAnimation } from '@explosions/badge-rotation'

const inertiaAnimation = createInertiaAnimation()

以上便是我在实现过程中的一些方案、问题与思考,从零开始去尝试如何实现是一件很有趣的事情,但想必也一定存在更优雅的方案和优化,所以有任何建议也欢迎给我留言。谢谢!

下次再有什么有趣的事情时候见!


Q.E.D.