1. 开始之前:渲染系统究竟在解决什么问题
很多同学对渲染系统的下意识理解,是"把场景里的模型画到屏幕上"。这个理解不算错,但容易把架构设计带偏。渲染系统的真实工作,是在一个极其苛刻的预算信封里,持续回答"这一帧里有什么东西需要画、以什么顺序画、用什么状态画、画完之后把结果交给谁"。
我见过不少项目起步时渲染代码很爽快:场景对象一个循环,push 到列表里,绑定材质,画完收工。真机性能也没那么差。麻烦出在内容和特性增长之后——灯光一多、阴影一开、半透明物体一乱、分辨率一上调,帧开销立刻失控。你说它是性能问题,其实背后是架构问题:渲染代码把"功能性"和"运行框架"揉在了一起,既没有一个稳定的调度结构,也没有明确的资源边界。
所以在这篇文章里,我想把渲染系统拆成几块来聊:线程与帧循环、场景可见性、材质与光照路径、资源生命周期,以及最后那种"排查问题很慢"的背后隐患。这套拆法不是教科书分类,而是从多年实际维护引擎、优化项目的经验里反推出来的——真正出状况的地方,几乎都在这些交界处。
1.1 渲染系统不只是"画模型"的代码集合
如果把渲染系统当成一个工厂,那它的输入是"游戏世界里所有可能有视觉表现的东西",输出是"GPU 最终绘制的那张图"。中间要被处理的环节包括:哪些物体在视锥内、有没有被遮挡、用什么材质和光照模型、shader 参数怎么传、纹理和缓冲区该不该常驻显存、不同渲染阶段之间怎么同步。
这些环节听起来是连续的,实际上分属不同子系统。可视性系统只听场景结构,不管画面长什么样;材质系统只管表面的数学定义,不知道物体在哪个位置;提交系统把结果变成 GPU 能执行的命令序列。它们之间不应该直接互相调用,而是通过稳定的数据结构传递。
如果架构把这些东西全部塞在同一个类里,最常见的结果是:想让一个特性生效,必须同时修改场景遍历、提交循环和资源管理。每加一个特性,状态分支就多几个,最终变成谁都不敢动的意大利面。这是渲染系统架构最需要警惕的陷阱。
1.2 渲染系统要长期回答的几类"问题"
你可以把渲染系统的职责整理成几类问题,每一类对应架构里的一个核心模块,彼此有逻辑上的先后顺序。我通常用这样一个表来帮团队对齐思路:
| 问题 | 对应模块 | 典型动作 |
|---|---|---|
| 这帧里哪些东西值得画? | 可见性与剔除系统 | 视锥剔除、遮挡查询、距离裁剪 |
| 它们的表面长什么样? | 材质与着色器系统 | 编译变体、参数绑定、采样器设置 |
| 光源如何影响它们? | 光照系统 | 光源剔除、阴影贴图、间接光序列 |
| 按什么顺序提交最稳定? | 渲染命令生成与排序 | MeshDrawCommand、排序键、渲染管线状态 |
| 数据放在哪里,怎么同步? | 资源系统 | 纹理/缓冲区生命周期、Barrier、上传与回读 |
| 这一帧花了多少钱? | 性能监控系统 | 帧时间分解、GPU 预算、自动降质 |
这张表不是归档文档,而是架构决策的产物。每一个新功能进来,先问它属于哪一类问题,再把改动落到对应模块。如果一个问题可以靠单独模块解决,就不要牵动主流程。
1.3 架构的边界应该划在哪
划边界的原则,我总结成一句话:渲染系统知道"怎么画",但尽量不知道"为什么要画"。它允许游戏逻辑告诉它"这个角色出现在这里了",但不允许游戏逻辑直接操作 GPU 资源句柄;它允许美术配置一套材质,但不允许美术系统直接往渲染线程里塞命令。
保持这种边界的主要手段是接口契约。游戏侧看到的是场景代理对象(通常叫 RenderScene 的入口),渲染侧看到的是紧凑的、面向硬件的中间结构。只在两者之间传送变化增量,不让两边共享同一个可变对象。这一点在后面讲场景数据时会再展开。
2. 线程模型与帧循环:让每一帧都踩在节拍上
从运行架构的角度看,渲染系统最核心的设计决策不是"用哪种管线",而是"谁在什么时候喂给 GPU 什么"。这一步如果没想清楚,后面再漂亮的渲染特性也会被同步问题拖垮。
2.1 一帧的 16.6ms 到底怎么分
以 60Hz 刷新率为例,每帧只有 16.6 毫秒预算。现代重度游戏里,画面越复杂,GPU 越接近满负荷。一个常见的帧时间分布可能是:游戏逻辑 4ms,物理与动画 3ms,渲染提交 5ms,GPU 执行 11ms。问题在于这些任务并不是"先逻辑后渲染"的简单排队关系,逻辑更新里随时可能产生新的渲染需求:角色移动、摄像机转向、灯光被触发、材质参数变化,全部要在这一帧里反映到画面上。
如果所有工作都压在主线程上,那一次大量物件刷新的高负载会把整帧都拖爆。所以引擎架构里几乎必然要拉出独立的渲染线程。渲染线程的目标很简单:从游戏线程消费"场景变化",生成一份 GPU 可以高效执行的命令清单。
需要强调一点,渲染线程不是"晚一点执行的游戏逻辑",它是专门负责提交工作的专职线程。游戏线程本身也会等待垂直同步,但等待是为了控制节奏,而不是包办渲染。
2.2 游戏线程与渲染线程之间的通信协议
两个线程之间传递的不是大块场景拷贝,而是"变化记录"。通常设计是一个无锁的环形命令队列,游戏线程往里写,渲染线程往出读。命令的类型包括:
- 更新某个物体实例的变换矩阵
- 修改某个材质参数
- 把一个物体从渲染场景中移除
- 切换摄像机与剔除外围数据
- 标记某帧的结束,等待垂直同步
我倾向于在整个引擎里统一使用这种"命令块"模式,而不是暴露对象方法调用。因为对象方法调用一旦跨线程,就必然引入锁竞争,而锁竞争一多,渲染线程的优先级就会被稀释。命令块是数据,不是执行逻辑,每一块都写清操作对象、操作类型和依赖,渲染线程消费时不需要回头询问游戏线程。
一个典型的帧循环伪代码长这样:
// 游戏线程 while (running) { input->poll(); gameplay->update(dt); player->transform = camera->computeViewMatrix(); scene->batchUpdatedTransforms(); // 生成一批渲染命令 renderQueue->push(FrameEnd); waitForVsync(); // 按目标帧率节拍 } // 渲染线程 while (running) { FrameCommand cmd = renderQueue->pop(); switch (cmd.type) { case UpdateTransform: renderScene->setTransform(cmd.handle, cmd.matrix); break; case FrameEnd: culling->run(); renderer->recordDrawCommands(); gpu->submit(commandBuffer); break; } }这个模型有一个明显的代价:渲染永远比逻辑晚一帧。但这在现代实时渲染里是可以接受的,因为绝大多数输入延迟来自整个渲染管线管道,而不是这一帧的逻辑-渲染差值。
2.3 帧延迟到底选多少:两缓冲还是三缓冲变化
"晚一帧"指的是逻辑帧领先渲染帧一帧。在一些需要快速响应的游戏类型里,团队会把逻辑帧与渲染帧拉开更多距离,以换取更稳定的帧时间。做法通常是引入"三缓冲"的概念:逻辑帧、渲染帧、GPU 帧各自使用独立的槽位,环环相扣。
| 方案 | 延迟 | 稳定性 | 适用场景 |
|---|---|---|---|
| 同步单缓冲 | 最低,但易卡顿 | 差 | 极小项目、编辑器简单预览 |
| 逻辑/渲染双缓冲 | 1-2 帧 | 较好 | 主流动作游戏、第三人称游戏 |
| 三缓冲 | 2-3 帧 | 稳定 | 高负载开放世界、大规模场景 |
这里有个实际经验:不要一开始就想把逻辑帧和渲染帧完全并行到零延迟。并行会让依赖关系非常复杂,每次加入新的渲染依赖,你都要重新推导"线程 A 是否看到了帧 N 之前的某状态"。先做严格的帧序号对齐,等稳定后再考虑局部跳跃。
2.4 刷新率不同步时的架构弹性
移动设备有 60Hz、90Hz、120Hz 甚至可变刷新率;主机平台可能是锁定的 30 或 60。渲染架构必须支持动态目标帧率,而不是写死"每帧一步"。
我的做法是把帧节拍拆成两层:逻辑节奏层用固定步长驱动游戏逻辑,渲染节奏层独立监听垂直同步。这样当屏幕刷新率从 60 跳到 120 时,渲染线程可以更频繁地提交新帧,而逻辑层的步长不需要改变。场景变化命令里带上时间戳,渲染层决定哪一帧消费哪些更新即可。
3. 从场景数据到 GPU 指令:剔除与合批是性能第一关
画面再美,如果每个物体都被当成待绘制对象送进 GPU,再强的显卡也撑不住。渲染架构里最重要的一道过滤器,是"场景数据如何变成一份精简的 GPU 指令集"。
3.1 RenderScene:渲染器自己的"内部数据库"
游戏侧的世界是一个复杂的场景图:物体有父子关系、逻辑组件、物理碰撞体、动画控制器。渲染系统如果直接跟着这套结构走,等于把游戏逻辑的负债全背到自己身上。因此渲染侧通常维护一个专门结构,我习惯叫它 RenderScene,它只关心渲染视角需要的数据。
RenderScene 是紧凑的、数组友好的:一个物体是一个实例索引,实例数据里只有变换矩阵、包围盒、LOD 状态、材质索引、可见性标记。实例不持有 GameThread 任何指针。它的更新完全由命令队列驱动,只有游戏侧声明某个组件变化了,渲染侧才会重写对应槽位。
这种"双份数据"看着浪费,实际上非常节省。因为渲染线程和游戏线程不需要在共享结构上加锁,只需要对命令队列做无锁同步。而且渲染侧的数据布局是面向 GPU 的 SOA 结构,光剔除本身就能跑 SIMD 优化,这比每次遍历一个对象数组要高效得多。
3.2 剔除体系:视锥、遮挡、距离的接力
把物体撤出绘制列表,往往比优化绘制本身更便宜。剔除是有层次、有顺序的:
| 阶段 | 目的 | 成本 | 精度 |
|---|---|---|---|
| 粗粒度空间划分 | 快速排除不可能相交的区块 | 很低 | 粗 |
| 视锥剔除 | 用包围体与观察锥体求交 | 低 | 中等 |
| 距离/屏幕大小剔除 | 决定是否需要切换 LOD 或完全放弃 | 极低 | 可控 |
| 遮挡剔除 | 判断是否被其余几何完全挡住 | 中 | 较高 |
| VS 级小物体剔除 | 用上一帧 GPU 数据回读辅助 | 中 | 精确 |
视锥剔除是第一步,把所有包围球或包围盒与视锥求交。一个简单的边界盒测试能过滤掉一半以上的场景物体。想在架构上做好视锥剔除,关键是提前维护空间索引,比如四叉树、八叉树、或网格哈希,而不是每帧线性扫描所有实例。
遮挡剔除更讲究。一种稳定方案是:用上一帧渲染出的深度信息作为"遮挡者"来源,生成低分辨率深度缓冲,然后用目标物体的包围盒去和这个深度缓冲做保守测试。这比每帧调用硬件遮挡查询更稳定,因为它不依赖 GPU 回读时间。缺点是需要处理上一帧结果滞后一帧的误差,通常用保守扩张边界来解决。
实战里最容易踩坑的是"剔除对象和实际绘制对象不一致"。比如合批后的网格,只剔除了其中一块实例,结果其它可见部分也被误删,画面产生明显闪烁。我的经验是剔除必须在"实例级"做,合批只能在"被剔除后的实例集合"上做,顺序不能反。
3.3 从 MeshDrawCommand 到合批策略
剔除结束后,我们得到一份"候选可见物体"清单。下一步是把这份清单变成 GPU 可以高效执行的绘制命令。不要把每个物体直接提交为单独 DrawCall,而是先生成 MeshDrawCommand,再排序合并。
struct MeshDrawCommand { uint32_t materialKey; // 材质排序键 uint32_t meshKey; // 网格排序键 uint32_t instanceOffset;// 实例偏移 uint32_t instanceCount; // 实例数量 uint8_t renderLayer; // 不透明/透明/特殊层 };排序键的设计决定了状态切换成本。我一般把材质 key 放在最高位,因为切换管线状态(换 shader、换贴图、换混合模式)开销最大;其次是网格缓冲切换,最后才是实例变换绑定。一个稳定的排序键能让每个渲染 Pass 在几百个 DrawCall 之间只切换几十次管线状态。
合并策略可以有多种:静态网格合批、动态实例化合批、以及自动识别的同材质合批。我的倾向是优先做"同材质实例化合批",因为它不会改变物体之间的遮挡关系,也方便剔除系统按实例独立剔除。静态网格合并(把多个物体拼成一个 mesh)表面上市节省了 DrawCall,但会卡住剔除精细度,除非那些物体在布局时就是一块不可分割的整体,否则不建议乱用。
4. 材质、光照与渲染路径:品质上限由架构决定
到了这一层,渲染架构要决定画面长什么样。材质、阳光、阴影、多光源,这些特性之间互相关联,很容易在模块设计上纠缠不清。
4.1 材质系统不是"一个类",而是"多层协议"
很多人开始做渲染时,会设计一个 Material 类,里面有颜色、贴图、粗糙度、金属度,然后 Shader 里根据这些参数写一堆分支。这样做在功能上没错,但扩展性很差。真实引擎里,材质系统更像一个多层协议:
- 数据层:材质资产,记录所有输入参数的数值与贴图来源;
- 编译层:根据表面模型生成对应 Shader,并解决变体选择;
- 绑定层:把数据解释成 GPU 可以绑定的描述符、常量缓冲、贴图列表。
关键点是"表面模型"和"材质实例"是分离的。表面模型决定数学性质,例如一种标准 PBR 模型、一种卡通光照模型、一种皮肤次表面模型。材质实例只提供参数。架构上如果让材质实例对象直接决定 Shader 代码,每个材质新功能都会变成对某个万能 Shader 的又一次堆砌。
4.2 着色器变体管理的成本真相
着色器变体是渲染系统里最容易被低估的成本黑洞。所谓变体,就是同一个 Shader 在不同编译宏组合下生成的版本。比如一个标准 PBR Shader,启用阴影贴图、启用视差贴图、启用细节法线、启用方向光、启用点光……每个组合都是一个独立编译单元。
变体数量的增长是组合爆炸,不是线性增长。100 个开关,理论上 2 的 100 次方种组合。当然实际不会全部使用,但项目跑到后期,上万变体并不罕见。后果是:构建时间变长、显存占用膨胀、运行时首次加载卡顿。
我的实操经验是建立变体白名单机制。美术想加一个新模型,必须先走"变体评审",只批准真正需要进包的组合;未覆盖的变体走流式加载,运行时命中后再补编译,同时记录日志。还有一个小技巧:所有变体在编译时输出一张"变体到管线状态"的映射表,渲染线程绑定时不需要 string 匹配,直接表查。
4.3 渲染路径:Forward、Deferred、分块式怎么选
渲染路径往往在引擎搭建早期就要定。三种主流方案各有明确优缺点,下面是我整理过的对比:
| 路径 | 光照开销 | 多光源扩展性 | 半透明支持 | 抗锯齿友好度 | 典型用途 |
|---|---|---|---|---|---|
| Forward | 逐物体叠加光源 | 较差 | 原生支持 | 支持 MSAA | 低光源数、移动端、少特效 |
| Deferred | 一次 GBuffer 生成、逐像素光照 | 优秀 | 需额外处理 | 不支持 MSAA | 室内多光源、主机端 |
| 分块式光照(Cluster/Tiled) | 光源裁剪精细 | 良好 | 较复杂 | 支持 | 大世界多光源、现代跨平台 |
Deferred 的灵活性在于把光照延迟到屏幕空间,光源数量对几何复杂度影响变小。但它的 GBuffer 写入和光照 Pass 都要读写大量带宽,在移动端或显存带宽受限场景非常吃亏。Forward 实现简单、MSAA 友好,但光源多时会逐物体重复计算。
分块式光照是我个人比较推荐的折中:在 Forward 或 Deferred 的基础上,把屏幕分块或按视锥体分簇,每个像素只计算它所在块接收到的一组光源。这让"每物体遍历全部光源"变成了"每个块遍历少量光源",性能来源非常直观。
4.4 阴影与光照数据的组织方式
阴影系统的架构难点不是"用 PCF 还是软阴影算法",而是谁来保证阴影贴图资源足够、谁来排队多个光源的阴影渲染。一个场景里可能有一个方向光级联 4 张贴图、3 个点光源 cubemap、多个局域光。如果每次光源开启都动态创建阴影贴图,资源管理系统会非常崩溃。
架构上我习惯采用"阴影贴图池"机制:预先分配固定数量的阴影图集,每个光源需要阴影时从池中申请一块区域。一份主方向光级联可以使用多张独立贴图,也可以打入一个 atlas。这种池化方案的好处是 GPU 资源管理可控,并且可以方便地按距离或重要性调整某一光源的阴影分辨率。
光照数据本身还要做密度管理。最实用的做法是维护一个紧凑的光源数组,剔除系统计算哪些光源影响当前可见区域,再把集合打包成结构化缓冲传给 GPU。不要把每个光源都设成全局 uniform,那样 GPU 的并行分支处理会丧失大块性能。
5. 资源生命周期与状态变换:现代图形 API 下的地基
渲染架构再上层设计,最终要落到 GPU 资源的出生、使用、销毁。这一部分平时不起眼,出问题就是灾难性的花屏、卡顿、崩溃。
5.1 谁拥有 Buffer 和 Texture 的"产权"
在游戏引擎里,GPU 资源不应该由业务代码直接创建和释放。因为游戏线程可能还在引用某个材质,渲染线程已经要使用它了,如果材质删除后底层纹理被立刻释放,渲染线程会在不可预期的位置读到野指针。
我的做法是:所有图形资源由资源管理器发放句柄,句柄本身包含索引和版本号。业务侧持有句柄,不持有指针;资源管理器内部维护引用计数和帧延迟销毁队列。资源真正销毁前,必须经过两个完整帧,确保 GPU 已经不再引用它。
可以这样理解:GPU 资源的"产权",永远归资源管理器;业务系统只有"使用权"。使用权可以随时释放,但产权方只在安全时刻清理物理内存。
5.2 Barrier、描述符与同步点:现代 API 的施工许可
低开销图形 API 的显著特征是"显式同步"。旧式 API 会在内部隐式处理很多资源状态切换,现代 API 要求开发者声明:这块纹理之前用作渲染目标,现在要作为采样输入,请先在这两个用途之间插入一个屏障(Barrier)。
如果不处理,GPU 端状态混乱,轻则性能下降,重则画面内容错误。Barrier 可以被类比为高速公路的施工段:必须等上一批车完全离开,才能允许下一批车进入,不然两拨车在同一个车道上撞车。
渲染架构里我通常封装一层"自动 Barrier 管理器"。每个渲染 Pass 声明资源用途(渲染目标、采样、存储读写、拷贝源),提交时管理器分析依赖图,自动在 Pass 边界插入需要的 Barrier。不建议把 Barrier 散落在各 Pass 代码里,因为一旦 Pass 顺序调整,散落的 Barrier 全部要跟着改,维护成本极高。
5.3 内存分配:不要直接频繁创建 GPU 资源
显存分配是最容易被性能分析遗漏的地方。每次创建纹理、缓冲区,底层驱动可能要跟操作系统申请内存、建立映射关系、做对齐。如果每帧执行成百上千次资源创建,哪怕每个资源只花几十微秒,累积起来也会拖垮帧预算。
成熟的引擎会做两件事:帧内临时资源池和环形上传缓冲。
帧内临时资源池可以复用同一块显存,同一帧内多次写入、多次使用,帧末统一回收。很多中间数据,比如动态阴影贴图、临时滤波纹理,根本不需要长期常驻。环形上传缓冲则用来把 CPU 侧数据批量拷入显存:要更新的变换矩阵、材质参数、顶点数据,统一写进一个上传 Ring Buffer,再在 GPU 侧按偏移访问,避免成百上千次小拷贝。
我的经验是,移动端尤其要注意"分配和上传"的握手。有些设备的 CPU 与 GPU 共享物理内存,统一内存架构下频繁分配反而可能触发大块内存锁定。最好从一开始就建立上传池机制,不要等到真机上发现问题才回头整改。
6. 调试与可达性:渲染架构的上限体现在排查问题的速度
架构好不好,不光是功能全不全、跑得快不快,最終要看"出了问题,团队能不能在一个小时之内定位原因"。渲染系统的调试是我见过最痛苦的调试类型,因为一个花屏可能有十种原因:变换矩阵错了、资源被提前回收、材质参数没绑定、Pipeline 状态不匹配、Barrier 缺失、或者仅仅是读回的数据没有同步。
6.1 从"花屏"开始的反推法
一旦画面异常,我的第一反应不是去翻渲染代码,而是先把异常归类:
- 如果是全屏闪烁/错乱的颜色带,优先怀疑资源状态与同步;
- 如果是个别物体透明或消失,优先检查剔除与绘制排序;
- 如果是大面积黑斑、噪点,优先检查材质参数与光照贴图;
- 如果是UV 搓揉、纹理乱拉,问题在顶点数据或描述符不匹配;
- 如果是画面整块撕裂或黑屏,第一看交换链和垂直同步。
这样的反推法能把排查范围缩小到几个模块,而不是全工程地毯式搜索。接下来动手做最小复现:只保留最简单的场景、最简单的材质,把问题场景几何体逐个移除,直到找到触发条件。这个动作比任何调试器都重要。
6.2 帧捕获与性能仪表盘的配合
现代的帧调试工具能抓取某一帧的所有渲染命令,逐条查看管线状态和资源内容。但前提是引擎本身要输出足够稳定的元信息。我在架构里会为每个渲染 Pass 命名,让帧捕获工具能显示"ShadowMapPass、GBufferPass、LightingPass、PostPass"这样的可读名字。没有命名的渲染命令,排错时根本不知道是哪个 Pass 的问题。
性能侧,我维护一块帧预算仪表盘面板,显示 GPU 总时间、每个 Pass 时间、DrawCall 数量、状态切换次数、上传与回读占带宽比例。当总时间超预算时,系统会自动弹出一个分级报告:哪类 Pass 增长最快、是不是阴影贴图变多了、有没有出现意外的大块拷贝。这一套配合可以在几分钟内锁定多数性能退化点。
6.3 LOD、动态分辨率与质量分级:稳定帧率的最后防线
渲染架构必须接受"最坏情况":可能是某个角落有密集粒子,可能是朝远处的大地图同时入射多个光源,无论如何 GPU 总会瞬间超载。设计上应该内置几道平滑的降级机制:
- LOD 切换:按屏幕尺寸和距离选择不同精度的网格;
- 动态分辨率:GPU 时间超阈值时,逐步降低渲染分辨率,再由放大技术恢复显示;
- 阴影分辨率降级:先从较远的级联开始降低尺寸,保留主光源附近的高质量阴影;
- 特效密度控制:减少粒子发射数量或关闭半透明后处理。
每道降级机制都是一个"策略决策点",由统一的帧预算控制器评估。控制器在每帧结束读取 GPU 时间,基于滑动窗口计算超载程度,再决定是否触发下一级降级。注意不要做剧烈的跳变,否则玩家会看到画面突然糊掉。阶梯式、每秒最多一两级降级,会更平滑。
6.4 跨平台后端抽象层:一次只做好一个后端
很多引擎都想一开始就支持所有平台,最后被跨平台抽象拖到崩溃。我的建议是先做一块真正扎实的主后端,再考虑抽象。
后端抽象层只暴露最必要的接口:创建资源、提交命令、同步、回读。不要试图隐藏平台差异,反而要在接口里显式保留平台的限制。比如某个平台的纹理格式不支持某种压缩,就应该允许渲染代码查询能力集,而不是假装所有纹理格式都一样。通向后端的接口应该让调用方明确知道"这里是平台相关知识,不要去碰它里面的细节",但数据结构和帧流程依然共享。
调试方面还有一个心得:跨平台 bug 往往不是引擎逻辑错了,而是平台后端行为不一致。因此抽象层要提供"平台的渲染信息转储",包括版本、能力、格式支持表、限制值。遇到问题,先对比两个平台的信息转储,常常一眼看出差异。
最后再分享一个运维向的建议。渲染系统架构迭代时,不要一口气同时改线程模型和资源模型。先保住一个稳定的后端,确认帧循环和可见性系统都可靠,再逐步推进材质和光照的扩展。配合前面说的帧仪表盘,每次改动都能用数据确认"没有把性能偷偷丢掉"。渲染系统的价值,就是在这些细节一个个叠加之后,依然能稳定地掌控每一帧的边界。