活动回顾 / T SALON

编辑内容

王宇谈自制渲染引擎:把图形管线收进一个客户端组件

王宇从曲线交互和动画的局部需求出发,回顾坐标变换、渲染管线与图形 API,再讲解如何用任务队列、渲染视图和绘制命令构建轻量引擎,兼顾性能、业务集成和按场景扩展。

王宇没有从“大而全的渲染引擎”讲起。他考虑的是一个更具体的问题:客户端的大部分界面由常规组件组成,只有某个局部需要高频绘制、曲线交互或特别的动画,怎样满足它,又不让整个应用为此改变架构?

这段分享来自上海“移动端技术实践”录播,稿件发布于 2022 年 3 月 13 日,实际活动日期未确认。视频以当时的 iOS 与 OpenGL ES 实现为例,这些接口选择不等于今天的新项目选型建议。

什么情况下,自己做一个引擎才有意义

王宇先承认 UIKit 等常规组件可以覆盖大部分交互。遇到特殊界面,也已有其他图形框架和现成引擎可用。自研的理由不能仅仅是“能够画出来”,而要对应现有方案难以合适满足的局部需求。

他给出的判断包含四点:自定义内容只是原生界面中的一部分;基础组件难以直接表达;交互对绘制速度有较高要求;承载的业务逻辑相对简单。以手势驱动曲线的场景为例,目标是让曲线随着输入变化,又能轻便地嵌入普通页面。引入完整 UI 引擎可能带来不成比例的代码量和接入工作。

他先把选择摆在一起:常规界面可以继续用 UIKit,特殊绘制也可考虑现成的 GUI 或图形方案。问题在于,若只为页面中的一个绘图区引入完整引擎,团队还要处理新框架怎样嵌回既有视图体系。反过来,如果只是画一条静态曲线,系统提供的绘图能力可能已经足够;需要手势响应和连续动画,才有必要进一步评估自己的绘制管线。四个条件须结合具体页面判断,而非给所有客户端页面套一层新引擎。

这仍然需要实际测量。分享中的性能动机来自特定实现,不能由此推导出“系统绘图全部依赖 CPU”或“自写 GPU 代码必然更快”。自研是否值得,取决于具体瓶颈、维护成本和集成代价。

第一层基础:物体怎样出现在画面里

为了让客户端开发者理解后面的抽象,王宇花了较多时间回顾图形知识。他先解释 Model、View、Projection:模型变换确定物体在世界中的位置,视图变换表达观察位置和方向,投影变换则决定怎样投到可显示的范围。

透视投影体现近大远小,正交投影适合另一类空间表达。王宇用球体和立方体的相对位置说明,同一组物体随着观察方式变化,可以得到不同画面。这些变换是完整成像流程的一部分,不能把矩阵相乘简单等同于已经完成所有屏幕映射。

他按物体坐标、世界坐标、观察坐标的顺序移动示意图:模型变换把立方体安放到世界中,视图变换改变从哪里看球与立方体,最后再根据投影方式得到画面。这个顺序使“物体变了位置”和“相机换了位置”成为两件不同的事,也解释了为什么后面要把场景、相机和渲染器分开抽象。

接下来是光栅化管线:输入顶点、颜色和纹理等数据,处理几何,再形成片段,计算片段输出并经过相应测试与混合,最后写入目标缓冲区。分享重点介绍顶点着色器和片段着色器两个可编程环节,帮助理解应用代码究竟能在哪些位置影响结果。

王宇把流程从顶点输入一直讲到图元装配、光栅化与片段处理。顶点着色器决定顶点怎样参与后续成像,片段着色器计算片段的颜色;纹理坐标与纹理数据则为取样提供位置和内容。他没有把 shader 当成“给图片加效果”的黑盒,而是先让听众知道绘制命令交给图形 API 后,数据按什么顺序流动。

第二层基础:把数据、程序和输出目标连接起来

以 OpenGL 风格的接口为例,一次绘制先需要上下文来承载相关状态,再准备顶点、纹理坐标、纹理等输入数据。着色器程序也有独立的创建、编译和链接过程;最后把合适的数据与程序关联起来,提交绘制。

他用 WebGL 风格的示意图解释这个过程:上下文持有绘制相关的状态;一条支路准备几何、颜色和纹理,另一条支路准备顶点与片段着色器并组合成程序。只有把两边连接起来、发出 draw call,目标缓冲区才会得到输出。后面的轻量引擎之所以需要 Surface 和 Command,正是为了把这些重复的准备工作和每次变化的绘制工作区分开。

这一过程输出的目标可以是供显示的缓冲区,也可以是后续处理需要的图像。把“准备数据”“准备程序”“执行绘制”“呈现结果”拆开,后续设计引擎接口时就有了对应的职责边界。

API 的抽象:场景里有什么,观察方式是什么

王宇借一个常见的 3D 绘制示例解释高层 API:先创建场景、相机和渲染器,再用几何数据与材质构成网格,把网格和光源放入场景。需要动画时,在连续更新中改变物体的位置或姿态,再把当前场景与相机交给渲染器。

示例具体到一个立方体:几何体描述形状,材质描述表面,二者组合成网格;场景中再加入环境光和其他光源。动画函数每次稍微改变立方体的姿态,再调用渲染器。这套对象关系帮助解释 API 如何替使用者收起底层 draw call,却也让王宇看到:自己的二维局部绘制并不需要整套光源、透视相机和 3D 场景对象。

这段示例的作用,是说明如何把底层图形操作整理成开发者能使用的对象。它不意味着每一个小型引擎都必须复制完整的 3D 类体系;后面他恰恰会根据自己的需求删减这些抽象。

自研目标由此收敛为三个:性能与体量符合局部需求,能够像普通视图一样接进现有项目,并为不同绘制场景留下扩展位置。没有明确目标,就无法判断新增代码究竟换来了什么。

每一帧,从更新到显示

从概念上看,一轮渲染需要更新观察方式、场景元素和相关状态,再绘制并呈现结果。实际实现中,输入和绘制未必适合放在同一处执行,因此王宇把 UI 侧生成的帧请求封装后交给队列,再由负责渲染的执行环节消费。

他先列出常见循环中的相机更新、场景元素更新、渲染和显示四步,然后改画自己的执行图。页面交互产生输入数据,由界面侧打包成一帧的请求;渲染侧取出请求,准备绘制命令、相关程序和数据,再执行并把结果交回视图。这样一来,业务动作与图形工作之间有清楚的交接点。

这里的队列解决的是两端速度不同、请求交接和同步的问题。它不是一项“放进去便不会丢帧”的保证;具体系统仍要决定队列长度、积压时怎样处理,以及显示节奏与任务完成时间怎样配合。视频中的“GPU 线程”也是应用侧负责提交渲染工作的线程角色,不能和 GPU 的硬件执行单元混为一谈。

消费请求后,引擎准备绘制数据与命令,组织所需程序,执行渲染,再把结果呈现到视图中。这条链路将输入组织、命令准备和实际绘制分开,使业务输入不必直接操纵每一个底层图形调用。

王宇用生产者与消费者解释线程之间的同步:请求先进入队列,消费侧按自己的节奏取出。队列能够缓冲一时的速度差,也让任务交接有固定位置;如果绘制长期慢于输入,队列仍会积压,不能把它当成性能本身。引擎要实现他提出的“高性能”目标,还得看帧耗时、内存、显示节奏和丢弃过期请求的策略。

Thread、Event Loop 和 Task Runner 分别负责什么

王宇专门解释了三个容易混在一起的概念:线程提供执行载体;事件循环持续接收和处理工作;Task Runner 则提供提交任务的接口。通过不同的任务执行入口,可以把逻辑和绘制组织到合适的执行环境中。

但轻量组件并不一定值得永久占有两条专用线程。以 iOS 为例,他尝试利用系统已有的主队列和串行队列组织工作,并提醒:队列、线程与 Run Loop 不是同一回事,不能假定换了执行方式后,原先依赖的界面提交时机仍会自动成立。

他的取舍来自使用频率:页面上的绘制区域并非一直活跃,若逻辑和渲染都各养一条常驻线程,就与“轻量”目标相冲突。于是他考虑由主队列处理界面侧逻辑,另用串行队列安排绘制任务,借助系统调度资源,而不是把线程数当作引擎接口的一部分暴露给业务方。

分享提到,他在这一过程中检查过渲染事务的提交,并处理了没有预期 Run Loop 行为时的更新问题。这是具体接入时需要验证的环节,而不是给所有异步绘制统一加一个调用就能解决。

他举出的坑是 Core Animation 事务:某些提交时机会跟 Run Loop 周期相关;把任务移到 GCD 队列后,不能继续假定它会以原来的时机提交到渲染服务。因此接入时要检查事务边界和显式提交需要。这个例子说明,任务能在另一条执行路径跑完,并不等于结果已经按预期显示。

用视图承载输出,用 Command 承载扩展

在 OpenGL ES 示例中,王宇继续解释 framebuffer、颜色与深度等 renderbuffer,以及如何把输出与 CAEAGLLayer 连接。需要准备和绑定相应存储,再在绘制后呈现结果。对业务层,他希望这些细节最终收在一个普通视图形态的组件里。

他从输出端倒推所需资源:先确定显示层,再建立 framebuffer,并把颜色 renderbuffer 挂到其上;若场景需要深度信息,再增加相应附件。分配存储、完成绘制、呈现缓冲区,各步都要对应同一个输出目标。业务开发者最终只拿到嵌在常规页面里的渲染视图,而不用在按钮或手势回调里手动绑定缓冲区。

由于目标主要是局部二维内容,不需要完整的光照和可移动相机体系,他把入口进一步缩减到渲染视图与命令队列。Surface 负责协调视图、底层显示层、上下文和输出目标,Command 则描述每次需要执行的绘制。

命令可以只执行一次绘制,也可以按时间采样形成动画;还可以把前一个命令的输出作为后一个命令的输入,组成滤镜处理链。每种命令再准备自己的 shader、顶点、纹理与其他数据。扩展点因此落在“新增一种绘制操作”,而不是每次都改动整个引擎循环。

他以马赛克滤镜解释链式命令:前一步先生成图像,下一步把这张图像作为输入,按自己的 shader 再输出结果。一次性绘制、随时间更新的动画和多步图像处理,因而可以共享 Surface 与队列机制,却把差异留在 Command 内。每条命令仍须交代所需的顶点、纹理坐标、纹理及程序;抽象没有消除图形数据,只是给它们安排了位置。

应用案例:从手势和配置走到绘制

最后的曲线案例面向儿童描画交互:用户操作小刷子沿路径画线,完成后呈现动态的绘制效果。引擎在这里服务于一个具体的产品体验,而不只是展示能够画出图形。

王宇把处理拆成手势采样、路径描述、客户端逻辑和渲染几个环节。输入路径先形成描述或配置,再由绘制实现消费这些数据。这样,产品交互和图形算法能够通过明确的数据形式连接。

现场展示的是儿童沿着笔画移动小刷子的体验。首先从手势取得采样点,识别沿哪条路径移动,再形成描述笔画的配置;客户端逻辑据此决定何时完成,而渲染命令负责把线条和动态效果画出来。王宇略过了具体笔画算法,没有给出可复现的插值或着色器代码;这部分的可借鉴之处在于输入、产品规则和绘制职责的拆分。

王宇也提到接入骨骼动画运行时,并开始说明图集等概念。录播在这一段结束,后续实现和原计划的问答不在这段视频中。

这场分享最值得保留的是从局部需求逐步推导抽象的过程。具体 API 则有历史边界:Apple 已从 iOS 12 起弃用 OpenGL ES,并建议使用 Metal。阅读旧实现可以帮助理解渲染系统的组织,但新项目仍应依据目标平台选择受支持的图形方案。Apple 官方说明

查看原视频与分段信息:移动端技术实践:上海场 →

主题
iOS图形学渲染引擎客户端