渲染不是把一张图片直接贴到屏幕上,而是把场景数据逐层转换成几何、片元和像素,最终计算出帧缓冲中每个位置应该保存什么颜色。
我追问的链
- 游戏中的一帧画面到底是什么?
Sprite、模型和 UI 最终怎样变成屏幕上的颜色?- GPU 为什么主要处理三角形,而不是直接认识"角色"或"按钮"?
- 像素和片元是不是一回事?
- 分辨率提高,为什么有些渲染成本会快速上升,有些却不会?
- RGB 和 Alpha 在内存里怎样保存?
- Alpha 有数值,为什么还需要透明混合?
- 图片放大为什么会模糊,斜线为什么会有锯齿,渐变为什么会有色带?
- 这些现象在完整渲染管线中分别发生在哪一层?
1. 渲染最终只回答一个问题
无论场景是 2D 还是 3D,渲染最终都在回答:
屏幕上的每个像素,最后应该是什么颜色?
输入可能非常丰富:
1 | 物体的形状和位置 |
输出却很朴素:一张二维图像。
1 | 宽度 × 高度 × 每个像素的颜色 |
以 1920 × 1080 为例,一帧包含:
1 | 1920 × 1080 = 2,073,600 个最终像素 |
如果画面以每秒 60 帧更新,每秒就要产生约 1.24 亿个最终像素。真实过程中,同一个屏幕位置还可能被背景、角色、粒子和半透明 UI 反复计算,所以实际处理量可能远高于最终像素数。
这揭示了第一个重要区别:
最终只显示一个颜色,不代表这个位置只计算了一次。
2. 一帧画面不是一步画出来的
渲染需要解决三个基本问题。
2.1 物体在哪里
程序需要依次回答:
1 | 顶点在物体自己的局部空间哪里? |
这条链会引出后面的向量、坐标系、矩阵、相机和投影。
2.2 它覆盖了屏幕的哪些位置
GPU 通常不直接理解"一个人物"或"一个按钮",而是处理更基础的几何数据:
1 | 顶点 |
把三角形转换成屏幕上的候选像素位置,叫作光栅化(Rasterization)。
2.3 每个位置应该是什么颜色
候选位置的颜色可能来自:
1 | 纹理颜色 |
计算候选颜色的程序通常叫片元着色器(Fragment Shader),在一些体系中也叫 Pixel Shader。
3. 先记住这条最小渲染管线
先忽略大量分支,一帧画面可以压缩成:
1 | 场景数据 |
这条链是后续所有内容的地图。以后遇到渲染问题,先不要急着记 API,而要问:
1 | 问题发生在哪一层? |
4. GPU 为什么偏爱三角形
GPU 不直接认识这些业务概念:
1 | 角色 |
它们最终通常都要拆成三角形。三角形适合作为基础图元,是因为:
- 三个不共线的点一定确定一个平面。
- 任意复杂表面都能近似拆成三角形。
- 三角形内部的位置、颜色和纹理坐标容易插值。
- GPU 可以并行处理大量结构一致的三角形。
一个矩形图片通常只需要四个顶点和两个三角形:
1 | 左上 ─────── 右上 |
所以"显示一张图片"更准确的描述是:
1 | 创建两个三角形 |
图片是颜色数据,三角形才决定它覆盖屏幕的哪里。
5. 像素与片元不是一回事
像素
像素可以理解为:
二维图像中一个可寻址的颜色采样位置。
例如一张 3 × 2 图片包含六个颜色位置:
1 | P00 P10 P20 |
图片中的像素是离散颜色数据;显示器上的物理像素则是实际的发光单元。"像素是一个小方块"只是方便观察时使用的模型,并不是渲染数学中的完整定义。
片元
片元是光栅化阶段为屏幕位置产生的一份候选记录。它可能包含:
1 | 屏幕坐标 |
片元不一定成为最终像素。例如它可能:
- 因为在别的物体后面而深度测试失败。
- 被模板测试过滤。
- 在 Shader 中被丢弃。
- 与已有颜色进行透明混合。
- 写入以后又被后画的物体覆盖。
因此更准确的关系是:
1 | 三角形 |
一句话区分:
像素是图像或缓冲区中的位置;片元是渲染过程中准备影响这个位置的候选数据。
6. 分辨率为什么会影响性能
分辨率描述二维缓冲区中有多少个像素:
1 | 宽度 × 高度 |
常见分辨率的像素数量:
1 | 1920 × 1080 = 2,073,600 |
4K 的宽和高大约分别是 1080p 的两倍,像素总数却接近四倍:
1 | 宽度 × 2,且高度 × 2 |
因此,提高分辨率通常会显著增加这些工作:
- 光栅化产生和处理片元。
- 片元着色器计算。
- 屏幕后处理。
- 帧缓冲读写。
- 半透明产生的重复覆盖。
- 显存带宽消耗。
但它不一定让这些工作也变成四倍:
- 游戏逻辑。
- 物理计算。
- 动画骨骼计算。
- Draw Call 数量。
- 同一批几何的顶点数量。
所以"降低分辨率后帧率有没有提高"本身也是一个诊断信号:
1 | 明显提高 |
这只是方向判断,不能代替完整测量。
7. GPU 先写帧缓冲,不是直接点亮屏幕
程序通常不会直接逐个操作显示器的物理像素。GPU 会先把结果写进一块二维内存区域:
帧缓冲(Framebuffer)。
其中最直观的是颜色缓冲:
1 | Color Buffer |
它可以先被理解为一张与渲染分辨率相同的图片:
1 | colorBuffer[x][y] = 这个位置当前保存的颜色 |
一套帧缓冲还可能包含:
1 | Depth Buffer 深度缓冲 |
为了不把"只画了一半"的画面显示出来,通常会让显示和绘制使用不同缓冲:
1 | Front Buffer:当前正在显示 |
下一帧准备好后再交换它们:
1 | 显示 A,绘制 B |
这就是双缓冲最基本的思想。
8. RGB 如何表示颜色
常见的数字图像用 RGB 三个通道表示颜色:
1 | R:Red |
在每通道 8 bit 的格式中,每个通道有 0~255 共 256 个取值:
1 | 红色 (255, 0, 0) |
加上 Alpha 通道后,常见的 RGBA8888 格式为:
1 | R:8 bit |
一张未经压缩的 1024 × 1024 RGBA8888 纹理,基础级别的数据量约为:
1 | 1024 × 1024 × 4 bytes = 4 MB |
这只是理解量级的起点。实际占用还可能涉及 Mipmap、GPU 格式、行对齐、CPU 侧副本和临时缓冲等因素。
如果有一套完整 Mipmap,所有较小层级加起来通常还会增加约三分之一的基础纹理数据量:
1 | 4 MB × 4/3 ≈ 5.33 MB |
9. Shader 为什么喜欢使用 0~1
Shader 中常把颜色通道归一化为浮点数:
1 | 0 → 0.0 |
例如橙色可以表示为:
1 | vec4(1.0, 0.502, 0.0, 1.0) |
归一化以后,颜色运算更直接。比如把亮度降为原来的一半:
1 | color.rgb *= 0.5; |
或者让纹理颜色乘上顶点颜色:
1 | finalColor = textureColor * vertexColor; |
假设:
1 | 纹理颜色 = (1.0, 0.8, 0.5, 1.0) |
相乘后:
1 | 最终颜色 = (0.5, 0.8, 0.5, 1.0) |
红色通道被减半,其他通道保持不变。
10. Alpha 是数据,混合才是行为
RGBA 中的 A 是 Alpha。入门时可以先把它理解为:
1 | A = 0.0:完全透明 |
但更重要的是:
Alpha 只是一个数值;它不会自动让物体透明。
GPU 还需要开启混合,并指定怎样组合当前片元与颜色缓冲中已有的颜色。
普通透明混合可以先用这个直观公式理解:
1 | 结果颜色 |
假设:
1 | 前景颜色 = 红色 (1, 0, 0) |
那么:
1 | 结果 |
结果是偏蓝的紫色。
透明混合通常依赖绘制顺序。先画红色再画蓝色,与先画蓝色再画红色,结果可能不同。预乘 Alpha、透明排序、黑边和白边问题,会在后面的混合章节继续展开。
11. 图片放大为什么会模糊
假设一张纹理只有 100 × 100 个像素,却要覆盖 400 × 400 个屏幕像素。纹理没有为每个屏幕位置准备一个独立颜色,GPU 必须根据已有采样推测中间值。
最近邻采样
直接选择距离采样位置最近的纹理像素:
1 | 优点:保留硬边缘 |
它常用于需要保持像素格的美术风格。
双线性采样
读取采样位置周围的四个纹理像素,再按距离插值:
1 | 优点:颜色过渡平滑 |
因此图片模糊可能来自多个不同环节:
- 原始纹理分辨率不足。
- 显示尺寸远大于纹理尺寸。
- 使用线性过滤。
- 元素落在非整数像素位置。
- 图片经过多级缩放。
- 中间 Render Texture 的分辨率过低。
"模糊"只是最终现象,必须继续追到具体采样链才能确定原因。
12. 锯齿是连续几何遇到离散采样
几何边缘在数学上可以连续变化,屏幕却只有离散采样位置。当一条斜线经过像素网格时,有限采样无法完整表达连续边界,就会出现台阶:
1 | ████ |
这类现象叫走样(Aliasing)。
更准确的抗锯齿思路不是简单把边缘变模糊,而是估计:
这个像素有多少比例被几何覆盖?
如果某个像素只有 25% 被白色三角形覆盖,可以让它更接近:
1 | 25% 白色 + 75% 背景色 |
常见方案包括:
- MSAA:主要增加几何覆盖测试的样本。
- SSAA:用更高分辨率完成更多渲染工作后再缩小。
- FXAA:根据最终图像的边缘特征进行后处理。
- TAA:利用当前帧和历史帧样本积累结果。
它们并不是同一种算法的不同名字,而是在不同位置、用不同成本弥补采样不足。
13. 色带是有限颜色精度露出了台阶
8 bit 通道只能保存 256 个离散值:
1 | 0, 1, 2, 3, ... 253, 254, 255 |
如果一个渐变变化得很慢,许多连续颜色只能落到同一个离散值,原本平滑的变化就会显出一层层边界:
1 | 连续渐变 |
常见改善方向包括:
- 使用更高精度的颜色格式。
- 加入少量噪声进行抖动(Dithering)。
- 避免在低精度中间结果上反复处理。
- 正确处理颜色空间。
颜色空间会影响"数值的一半是不是视觉亮度的一半",需要单独展开,暂时不与颜色位数混在一起。
14. 把常见现象放回管线
现在可以把一些现象先定位到大致层级:
| 现象 | 优先追问的方向 |
|---|---|
| 图片模糊 | 纹理分辨率、缩放、采样过滤、中间缓冲分辨率 |
| 斜边锯齿 | 几何覆盖采样与抗锯齿策略 |
| 渐变色带 | 颜色精度、量化、颜色空间与抖动 |
| 半透明颜色异常 | Alpha 数据、混合公式、绘制顺序 |
| 提高分辨率后掉帧 | 片元工作量、后处理、Overdraw、显存带宽 |
| 降低分辨率仍没有变快 | CPU 提交、Draw Call、顶点或其他瓶颈 |
这张表不是最终诊断,只是在训练一种方法:
先把现象放回数据经过的层级,再去检查这一层的输入、状态和输出。
逻辑闭环 / 锚点
第一章可以收束成一条完整链:
1 | 场景中的业务对象 |
其中几个不能混淆的锚点:
- 图片不是几何:纹理提供颜色,三角形决定覆盖范围。
- 片元不是像素:片元是候选数据,像素是缓冲区中的结果位置。
- Alpha 不是混合:Alpha 是数据,混合状态才决定怎样组合颜色。
- 分辨率不是所有性能:它主要放大像素、片元和带宽相关成本。
- 显示一个颜色不等于只计算一次:重复覆盖会产生额外工作。
下一篇要继续追的是:
三个顶点为什么能覆盖一片像素?GPU 又怎样在三角形内部得到每个片元的 UV、颜色和其他属性?
这会把我们带到顶点、三角形、网格、绕序、重心坐标与插值。
关联
- 和 分层 + 封装 是同一个母题:渲染把场景、几何、片元和像素拆成不同表示层,让每层只解决一种变换。
- 和 JavaScript 运行时 的边界思维相同:业务代码、引擎、图形 API、驱动和 GPU 是不同层,不能因为上层只调用一个接口,就认为底层只发生了一步。
来源:与 Codex 的对话,2026-07。