我在奥运做绘座

image.png

鸟巢虐我千百遍,我爱鸟巢如初恋 回看2年前做奥运绘座的收获、成长,希望用一篇文章串联起跟绘座相关的全部。2019年5月底,从马蜂窝离职加入阿里。因为自己对图形、动画非常感兴趣加上之前在这一方向上的经验,能支持奥运赛事的大型场馆座位数字化也就是座位图对我来说非常有吸引力,在这里会遇到非常多的挑战,一定会有所成长、收获。加之这可是奥运啊~~这可是阿里啊~ 使命感、荣誉感让我非常憧憬

背景摘要

瑞士达沃斯,2017年1月,阿里巴巴集团正式成为国际奥委会(IOC)全球合作伙伴,成为“云服务”和“电子商务平台服务”的官方合作伙伴。

2018年10月,IOC、北京冬奥组委和阿里巴巴共同参与了奥运智慧票务项目立项会议,会上阿里巴巴正式承接了奥运智慧票务系的构建和服务工作。

我所在的团队就是奥运票务系统产研团队,我的主要职责是场馆座位图的开发。

这是22年冬奥闭幕式前夕,负责奥运会票务事务的国际奥委会电视转播和市场开发部部长提莫·鲁梅在接受媒体采访时盛赞北京冬奥智慧票务。

一张票要经历什么

我们以一个座位为视角,从生产、售卖到履约的生命周期来看:

座位生产:

座位生产阶段: 画座;北京13个场馆的座位图数字化。

  • 经过这个阶段,一个座位就有了物理上的属性:楼-区-排-座。可以通过全景图看到这个座位的位置。

场次规划阶段:

也就是票房规划,在这一阶段要在场次级别的座位图上设置票档、票价、锁座、排序、隔排隔座等

  • 场次规划完成,一个座位就有了价格信息,因为一个场馆会举行多个项目,每一个项目会有多个比赛,一个座位将会有不同的定价策略和售卖策略。

售卖阶段:

售卖阶段将会有不同的渠道对场次级别进行售卖:

1. 大客户: 为奥运赛事的合作伙伴分配座位,通常一个客户可能分配上百个座位。

2. Ticket Boxing: 奥运赛事期间线下面向公众的售卖渠道

3. web端: 赛事开始前公众的选票抽签

履约:

打印出一张票,票上标识出座位排座信息、价格信息,观赛人拿着这张票通过闸机紧凑观赛

我的角色

初期

架构设计 - 参考、借鉴,结合当前业务场景设计一套方便拓展、稳定、高效的一个渲染框架,最终取名ASeat

中期

全力支撑奥运2022冬奥 - 物理绘座、票房规划(场次座位图)、实时售卖

后期

赋能亚运、反哺大麦

提效 - 开发提效,运营提效

技术要做什么

渲染引擎设计

渲染引擎是重中之重,一切的业务都建立在渲染引擎基础之上。早在初期设计阶段和大麦的不断碰撞,以及参考seatIo、ticketMaster ,我们确立了渲染引擎的核心模块都有哪些,职责是什么:

image.png

类图关系

核心模块设计理念

  • 舞台 - Stage: Stage为Aseat-core对外暴露最顶层API。使用Aseat时先要new Stage({ options })。 Stage是一个scroller。 Stage座位Layer的容器,具备管理Layer的能力,CURD和event dispatch。 Stage做为Plugins挂载的容器。 Stage持有Gesture具备向下分发事件的能力。
  • 滚动缩放容器 - ScrollView: 提供了滚动、缩放的能力;采用css transform 作用到最外层stage 节点上,GPU滚动。 scrollView 提供了fitToViewport、fitBoxToViewport和坐标转换的能力。在处理事件是point 统一经由最上层convert后再向Layer分发。
  • 手势manager - Gesture: 提供了手势、事件能力,集成了hammer。Stage/Layer可控制分发规则,如Layers白名单、Layer的ignoreEvents。Layer还是items的事件proxy。
  • multiple Layer设计(纵向分层):

image.png

根据业务特点和定位,Aseat采用multiple layer的设计,如SVG底图、座位、选座的框/线等都个为单独的Layer。根据场景和操作频次multiple layer可减少没必要的渲染。 - Layer自己持有render方法,render由当前Layer使用了哪种renderEngine决定,如canvasLayer由canvasRender负责渲染那么就是时候用canvas context(‘2d’)负责渲染,当然canvasLayer不一定非要用cavnasRender负责渲染,可以替换成pixiRender。 由使用者决定何时render,render哪个Layer。 Layer座位Items的容器,具备Items的CURD、render、hitTest等能力。

  • Grid Layer设计 (横向切片):
![image.png](https://img.alicdn.com/tfs/TB1tRzcV4z1gK0jSZSgXXavwpXa-549-550.gif)

这里借鉴了大麦历史经验,抽象出canvasGridLayer,其中最核心的思想是切片,分而治之,解决了最大的问题:渲染性能。在面对动不动就需要绘制几万+座位的场景下,如果把这几万个座位放到一个Canvas中绘制,每一帧的绘制浏览器都在骂娘。把一个canvas,拆成多个grid每个grid即一个canvas,需要render的grid去render,不需要的grid就不用render。如下图所示:为方便演示,grid size 为100x100,开了chrome Paint flashing。可看到hover的Seat只有相关的grid 才发生了painting。

  • Item Item是一个元素。是整个系统里可绘制的最小单元,也是数据和样式的容器。 core中封装了常用的shape如: Circle、Arc、Line、Polygon、Rect、Path等等。每个shape根据自身形状特点重写基类Item的 updateBBox、applyMatrix方法。 image.png

如下伪代码实现一个自定义Item

    import { Item, Group } from "@alife/aseat-core"

    export default class Seat extends Group {
      ...
      public setAttrs(attrs: object): void {
        super.setAttrs(attrs);
        this._children.push(new Arc(attrs[0]));
        this._children.push(new Arc(attrs[1]));
        this._children.push(new Text(attrs[2]));
      }
      ...
    }

  • 事件流
>***以在GridLayer下点击一个座位为demo,事件的流转***
  • Plugin

    Plugin设计的初衷是拓展Aseat的能力,Plugin做到可插拔、可拓展、可替换,打造Plugin生态,让基于Aseat为底层的系统,有尽可能多的Plugin供开发者复用,通过搭积木的方式快速满足业务需求,避免重复造轮子。配套有cli 脚手架快速创建Plugin。奥运、大麦累计产出的Plugin 30+ ,如画座工具、选座工具、座位变形、鹰眼图等等。

    如下图一个鹰眼图Plugin的demo

      import EagleEye from "@alife/aseat-plugin-eagle-eye"
      import { Stage, SvgLayer } from "@alife/aseat-core";
      const stage = new Stage({...options});
      const svgLayer = new SvgLayer({...options})
    
      const eagleEye = new EagleEye({ 
        container: '#aseat-plugin-eagle-eye',
        svgLayer: svgLayer, 
        left: 20, 
        top: 20,
        scrollThrottle: 100,
        clickSwitch: true
      });
      stage.plug(eagleEye);
      eagleEye.invoke();
    

数据层设计

渲染层数据和服务端持久化数据是两套数据,我们要有一个数据代理来解决渲染层和持久化数据格式的握手衔接,我们命名为 Data-Proxy

业务抽象

image.png

座位生产:

image.png

通过画座工具,画满全场馆,通过React层UI 编排座位:定义排号座号规则,座位拖拽、变形。

票房规划:

这一阶段主要做的事就是给每个座位设置票档、Hold Code(禁止售卖、分配给某个大客户)、优先级分配(机选规则设置)

看图可知,这个阶段每天买对花花绿绿的屏幕

同时这个阶段也是将来运营阶段重点操作的步骤,因为策略可能会实时调整。

在这个业务背景下,要对代码、组件进行合理抽象

image.png

针对票房规划抽象出的组件级别构架图

遇到的挑战

计算性能瓶颈

先看一下让人头大的CPU负荷

什么原因导致的? 首先需要明确的是,这是一个典型的CPU密集计算场景,以鸟巢为例:8w+的座位,我们以看台纬度获取数据,数据到渲染对象Item的一个映射,也就是new Seat() 的过程中,一个看台平均以1000量级来看,就要执行1000次new Seat()的计算 + 渲染。

可以看到在这一个Task里主线程全部被跑满,全部是扁平的new Seat。

主线程跑满肯定是不行的,计算线程和UI线程互斥,计算线程跑满将会阻塞渲染线程,如果在此时进行UI交互将被阻塞。 其中new Seat的过程中有很多逻辑判断,计算渲染事需要的数据,此过程中会产生很多Hot Object,这一个过程我们也经历过多次优化,比如禁止解构对象,数组赋值不用循环,全部采用下标直接赋值,一些计算采用位运算等等,但是优化后效果不明显,还是避免不了计算量大了之后的问题。

对此还是采用策略级的手段来达成优化的目的

方案一:切片(空间换时间) 降纬计算任务,把「看台」纬度切成「排」纬度,由一个任务队列调度任务,降低每一个Task的计算负荷,代价是时间变长。

方案二:worker 异步计算,多worker 并行计算,主线程只负责渲染。 切片+队列的方式是解决了计算和渲染冲突的问题,但是毕竟是“时间换空间”,需要用户等的时间变长了,慢,还能不能进一步优化?web worker的作用,就是为了 javascript 创造多线程环境,将一些计算任务分配给woker后台运行,主线程继续运行,两者互不干扰。等到worker线程完成计算任务,再把结果返回给主线程。

如图:1000个座位的onMessage 序列化耗时 44.27ms。

该过程是主线程和worker线程通信的开销,但是主线程不再进行任何计算,所有计算已经在worker中处理好,主线程只需要挂载protoType,然后render

可以看到多个worker同时进行计算任务,还是由一个任务调度分配任务

渲染性能

回头看上面主线程的计算任务:

这是1000个座位创建时的耗时 (canvas 2d)

寻找瓶颈:

首先,因为canvas 2d 在浏览器端是以指令为单元向skia图形库发出渲染指令进行渲染的,调用的都是skia暴露出来的API,而这个指令还要在CPU进行更复杂的计算才交给GPU渲染,并且因为是CPU调用,可以理解为同步。换句话说,2d封装的指令更上层,上层即使做了cache之类的优化,也无法优化下层渲染行为。而webgl的渲染会更高效总结最主要的原因:gl 可以进行批处理操作,并且对图形指令的计算在GPU内完成,且GPU更擅长此类操作。

gl在绘制一个图形时,执行流程大概时这样的:

  • 编译着色器程序(webgl中只有顶点着色器与片段着色器)
  • cpu写入copy指令到特定内存区域,gpu读取后,将需要绘制的数据copy到自己的内存中,如顶点坐标、颜色等。
  • cpu发送绘制指令到特定内存区域,gpu将根据读取的数据此时会按一定设定,遍历已经copy到自己内存的顶点数据,并行处理,期间会进行
    1. 坐标变换
    2. 顶点着色器的执行
    3. 坐标的裁剪
    4. 片段着色器的执行(接近像素级别的遍历)
    5. 栅格化像素(将处理的结果转为屏幕像素数据输出) 这是一个流程较长、开销较大的过程。

gl 渲染方案

现在再回到我们绘座的场景,业务上每次操作是以看台为粒度大约1000个座位进行操作,每个座位由背景、边框、编号、角标四个元素构成,即每次绘制规模为4000个图形,而每个图形都非常简单。

现代浏览器使用gpu对canvas2d加速,调研4000次fill api, 将会触发4000次的calldraw, 开销都花在,内存copy的io、gpu的渲染管线调度,而不是在真正在渲染上。

由于特定的场景下,可以利用 ANGLE_instanced_arrays 来实现一次 calldraw,渲染多个元素。但是这个方法不是万能的,为什么可以同时渲染多个元素,是因为只能用一个shader,不能切换。不过在绘座的场景下,我们也有解法:用「纹理」来解决多种图形,利用全部可枚举的图形拼成一个雪碧图,在渲染过程中 根据雪碧图中的位置获取像素值即可。

但这里又引入了另一个限制:相同的形状只能绘制相同的颜色,但实际的绘座场景下会出现渲染的图形可能是彩色的图标,也有可能是形状相同,但颜色不同的图形,这个限制显然完全不符合需求。 如何满足用一个shader同时支持技能绘制彩色图标、又能设置图形的颜色?

这里我们使用了一个巧妙的办法,添加一个座位的颜色属性,我们将从纹理拾取出来的颜色与座位的色值做乘法(webgl中的颜色值范围都归一到[0,1]) resultColor = textureColor * seatColor [ r1, g1, b1, a1 ] * [ r2, g2, b2, a2 ] = [ r1r2, g1g2, b1b2, a1a2 ] 假设纹理中的颜色为1 ,乘后就得到座位的色值: resultColor= 1 * seatColor = seatColor 。 反之,假设座位的颜色为1, 得到纹理的色值: resultColor = (textColor * 1) = textColor。

这样如果想渲染一些定制的彩色图标,将座位的颜色设置为白色。 所以我们将代码做如下改动即可。

ASeat中的渲染流程

ASeat中创建一个新的renderer GLRender ,定义它的渲染流程为:

其中刚才提到的纹理雪碧图要在这个Render中预处理

对于Aseat这个场景下,纹理很简单,将各种shape绘制到AABB的boundingBox大小canvas中,所以纹理里全是矩形的Image,这样在shader转换顶点数据时将变的很简单。

创建纹理过程:

  • 在glRender中维护一个size足够大(2000 * 2000)的离屏canvas(textureCanvas),作为glrender的纹理,这是一张canvas雪碧图
  • 根据Item所对应的不同shape做快照(itemShapeImg),这里需要注意的是对于gl来讲fill 和stroke是两个纹理,所以要对Item精细的拆分,也就是说Item和纹理是一对多的关系。

  • 然后将 itemShapeImg 绘制到 textureCanvas 中,同时记录位置信息;将itemShapeImg的信息挂载到Item上。在render的时候直接获取纹理绘制。

渲染过程:

  • 计算Item所需要的纹理;因为Seat为Group元素,对应多个绘制指令,这里要拆分到具体每一个指令级别的Item。维护一个离屏 Texture canvas,随着渲染同步向这个canvas drawImage。

  • Item 坐标转换,grid position -> 单个canvas position

  • 同步 glElement 坐标、颜色信息

  • 调用 glRender()

特殊处理:

在grid canvas场景下,2d Context 和 canvas 是一一对应的关系,每个canvas有自己的2d context,但是在gl render的场景下,因为gl render 不允许也不能够创建多个上下文,所以这里还要特殊处理:多个canvas 共享一个glContext 通过drawImage 渲染到grid canvas中

gl render 结果