页面

从场景到像素:渲染就是逐层决定屏幕颜色

← 返回兔子洞总览 · rendering 主题地图

渲染不是把一张图片直接贴到屏幕上,而是把场景数据逐层转换成几何、片元和像素,最终计算出帧缓冲中每个位置应该保存什么颜色。

我追问的链

  • 游戏中的一帧画面到底是什么?
  • Sprite、模型和 UI 最终怎样变成屏幕上的颜色?
  • GPU 为什么主要处理三角形,而不是直接认识"角色"或"按钮"?
  • 像素和片元是不是一回事?
  • 分辨率提高,为什么有些渲染成本会快速上升,有些却不会?
  • RGB 和 Alpha 在内存里怎样保存?
  • Alpha 有数值,为什么还需要透明混合?
  • 图片放大为什么会模糊,斜线为什么会有锯齿,渐变为什么会有色带?
  • 这些现象在完整渲染管线中分别发生在哪一层?

1. 渲染最终只回答一个问题

无论场景是 2D 还是 3D,渲染最终都在回答:

屏幕上的每个像素,最后应该是什么颜色?

输入可能非常丰富:

1
2
3
4
5
6
物体的形状和位置
相机的位置和朝向
纹理和材质
灯光和阴影
透明与遮挡关系
特效和后处理

输出却很朴素:一张二维图像。

1
宽度 × 高度 × 每个像素的颜色

1920 × 1080 为例,一帧包含:

1
1920 × 1080 = 2,073,600 个最终像素

如果画面以每秒 60 帧更新,每秒就要产生约 1.24 亿个最终像素。真实过程中,同一个屏幕位置还可能被背景、角色、粒子和半透明 UI 反复计算,所以实际处理量可能远高于最终像素数。

这揭示了第一个重要区别:

最终只显示一个颜色,不代表这个位置只计算了一次。

2. 一帧画面不是一步画出来的

渲染需要解决三个基本问题。

2.1 物体在哪里

程序需要依次回答:

1
2
3
4
顶点在物体自己的局部空间哪里?
物体在世界中的哪里?
相机从哪里观察?
它最终投影到屏幕的哪里?

这条链会引出后面的向量、坐标系、矩阵、相机和投影。

2.2 它覆盖了屏幕的哪些位置

GPU 通常不直接理解"一个人物"或"一个按钮",而是处理更基础的几何数据:

1
2
3
4
5
顶点

三角形

三角形覆盖的屏幕位置

把三角形转换成屏幕上的候选像素位置,叫作光栅化(Rasterization)。

2.3 每个位置应该是什么颜色

候选位置的颜色可能来自:

1
2
3
4
5
6
7
纹理颜色
顶点颜色
材质参数
灯光
阴影
透明度
雾和后处理

计算候选颜色的程序通常叫片元着色器(Fragment Shader),在一些体系中也叫 Pixel Shader。

3. 先记住这条最小渲染管线

先忽略大量分支,一帧画面可以压缩成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
场景数据

CPU 整理顶点、纹理、材质和渲染状态

CPU 向 GPU 提交绘制命令

顶点着色器计算顶点位置

图元装配把顶点组成三角形

光栅化产生片元

片元着色器计算候选颜色

深度、模板、透明混合等测试与运算

结果写入帧缓冲

最终显示到屏幕

这条链是后续所有内容的地图。以后遇到渲染问题,先不要急着记 API,而要问:

1
2
3
问题发生在哪一层?
这一层拿到的输入是什么?
它产生的输出又交给了谁?

4. GPU 为什么偏爱三角形

GPU 不直接认识这些业务概念:

1
2
3
4
5
6
7
角色
按钮
圆形
立方体
Sprite
骨骼动画
UI 面板

它们最终通常都要拆成三角形。三角形适合作为基础图元,是因为:

  1. 三个不共线的点一定确定一个平面。
  2. 任意复杂表面都能近似拆成三角形。
  3. 三角形内部的位置、颜色和纹理坐标容易插值。
  4. GPU 可以并行处理大量结构一致的三角形。

一个矩形图片通常只需要四个顶点和两个三角形:

1
2
3
4
5
左上 ─────── 右上
│ \ │
│ \ │
│ \ │
左下 ─────── 右下

所以"显示一张图片"更准确的描述是:

1
2
3
4
5
6
7
8
9
创建两个三角形

为四个顶点设置纹理坐标

光栅化产生片元

根据纹理坐标从图片中取颜色

经过测试和混合后写入帧缓冲

图片是颜色数据,三角形才决定它覆盖屏幕的哪里。

5. 像素与片元不是一回事

像素

像素可以理解为:

二维图像中一个可寻址的颜色采样位置。

例如一张 3 × 2 图片包含六个颜色位置:

1
2
P00  P10  P20
P01 P11 P21

图片中的像素是离散颜色数据;显示器上的物理像素则是实际的发光单元。"像素是一个小方块"只是方便观察时使用的模型,并不是渲染数学中的完整定义。

片元

片元是光栅化阶段为屏幕位置产生的一份候选记录。它可能包含:

1
2
3
4
5
屏幕坐标
深度
插值后的纹理坐标
插值后的颜色
法线等其他属性

片元不一定成为最终像素。例如它可能:

  • 因为在别的物体后面而深度测试失败。
  • 被模板测试过滤。
  • 在 Shader 中被丢弃。
  • 与已有颜色进行透明混合。
  • 写入以后又被后画的物体覆盖。

因此更准确的关系是:

1
2
3
4
5
三角形
↓ 光栅化
产生片元
↓ 测试、着色、混合
影响帧缓冲中的像素

一句话区分:

像素是图像或缓冲区中的位置;片元是渲染过程中准备影响这个位置的候选数据。

6. 分辨率为什么会影响性能

分辨率描述二维缓冲区中有多少个像素:

1
宽度 × 高度

常见分辨率的像素数量:

1
2
3
1920 × 1080 = 2,073,600
2560 × 1440 = 3,686,400
3840 × 2160 = 8,294,400

4K 的宽和高大约分别是 1080p 的两倍,像素总数却接近四倍:

1
2
3
宽度 × 2,且高度 × 2

总像素数 × 4

因此,提高分辨率通常会显著增加这些工作:

  • 光栅化产生和处理片元。
  • 片元着色器计算。
  • 屏幕后处理。
  • 帧缓冲读写。
  • 半透明产生的重复覆盖。
  • 显存带宽消耗。

但它不一定让这些工作也变成四倍:

  • 游戏逻辑。
  • 物理计算。
  • 动画骨骼计算。
  • Draw Call 数量。
  • 同一批几何的顶点数量。

所以"降低分辨率后帧率有没有提高"本身也是一个诊断信号:

1
2
3
4
5
明显提高
→ 更可能受片元计算、像素填充或带宽限制

变化很小
→ 更可能受 CPU、提交、顶点或其他工作限制

这只是方向判断,不能代替完整测量。

7. GPU 先写帧缓冲,不是直接点亮屏幕

程序通常不会直接逐个操作显示器的物理像素。GPU 会先把结果写进一块二维内存区域:

帧缓冲(Framebuffer)。

其中最直观的是颜色缓冲:

1
Color Buffer

它可以先被理解为一张与渲染分辨率相同的图片:

1
colorBuffer[x][y] = 这个位置当前保存的颜色

一套帧缓冲还可能包含:

1
2
3
Depth Buffer      深度缓冲
Stencil Buffer 模板缓冲
Render Texture 可供后续渲染再次采样的离屏结果

为了不把"只画了一半"的画面显示出来,通常会让显示和绘制使用不同缓冲:

1
2
Front Buffer:当前正在显示
Back Buffer:GPU 正在绘制下一帧

下一帧准备好后再交换它们:

1
2
3
显示 A,绘制 B
↓ 交换
显示 B,绘制 A

这就是双缓冲最基本的思想。

8. RGB 如何表示颜色

常见的数字图像用 RGB 三个通道表示颜色:

1
2
3
R:Red
G:Green
B:Blue

在每通道 8 bit 的格式中,每个通道有 0~255 共 256 个取值:

1
2
3
4
5
6
红色  (255,   0,   0)
绿色 ( 0, 255, 0)
蓝色 ( 0, 0, 255)
白色 (255, 255, 255)
黑色 ( 0, 0, 0)
黄色 (255, 255, 0)

加上 Alpha 通道后,常见的 RGBA8888 格式为:

1
2
3
4
5
R:8 bit
G:8 bit
B:8 bit
A:8 bit
合计:32 bit,也就是每像素 4 bytes

一张未经压缩的 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
2
3
0   → 0.0
128 → 约 0.502
255 → 1.0

例如橙色可以表示为:

1
vec4(1.0, 0.502, 0.0, 1.0)

归一化以后,颜色运算更直接。比如把亮度降为原来的一半:

1
color.rgb *= 0.5;

或者让纹理颜色乘上顶点颜色:

1
finalColor = textureColor * vertexColor;

假设:

1
2
纹理颜色 = (1.0, 0.8, 0.5, 1.0)
顶点颜色 = (0.5, 1.0, 1.0, 1.0)

相乘后:

1
最终颜色 = (0.5, 0.8, 0.5, 1.0)

红色通道被减半,其他通道保持不变。

10. Alpha 是数据,混合才是行为

RGBA 中的 A 是 Alpha。入门时可以先把它理解为:

1
2
3
A = 0.0:完全透明
A = 0.5:半透明
A = 1.0:完全不透明

但更重要的是:

Alpha 只是一个数值;它不会自动让物体透明。

GPU 还需要开启混合,并指定怎样组合当前片元与颜色缓冲中已有的颜色。

普通透明混合可以先用这个直观公式理解:

1
2
3
结果颜色
= 前景颜色 × 前景 Alpha
+ 背景颜色 × (1 - 前景 Alpha)

假设:

1
2
3
前景颜色 = 红色 (1, 0, 0)
前景 Alpha = 0.25
背景颜色 = 蓝色 (0, 0, 1)

那么:

1
2
3
4
结果
= (1, 0, 0) × 0.25
+ (0, 0, 1) × 0.75
= (0.25, 0, 0.75)

结果是偏蓝的紫色。

透明混合通常依赖绘制顺序。先画红色再画蓝色,与先画蓝色再画红色,结果可能不同。预乘 Alpha、透明排序、黑边和白边问题,会在后面的混合章节继续展开。

11. 图片放大为什么会模糊

假设一张纹理只有 100 × 100 个像素,却要覆盖 400 × 400 个屏幕像素。纹理没有为每个屏幕位置准备一个独立颜色,GPU 必须根据已有采样推测中间值。

最近邻采样

直接选择距离采样位置最近的纹理像素:

1
2
优点:保留硬边缘
缺点:放大后出现明显像素块

它常用于需要保持像素格的美术风格。

双线性采样

读取采样位置周围的四个纹理像素,再按距离插值:

1
2
优点:颜色过渡平滑
缺点:放大后容易显得模糊

因此图片模糊可能来自多个不同环节:

  • 原始纹理分辨率不足。
  • 显示尺寸远大于纹理尺寸。
  • 使用线性过滤。
  • 元素落在非整数像素位置。
  • 图片经过多级缩放。
  • 中间 Render Texture 的分辨率过低。

"模糊"只是最终现象,必须继续追到具体采样链才能确定原因。

12. 锯齿是连续几何遇到离散采样

几何边缘在数学上可以连续变化,屏幕却只有离散采样位置。当一条斜线经过像素网格时,有限采样无法完整表达连续边界,就会出现台阶:

1
2
3
████
████
████

这类现象叫走样(Aliasing)。

更准确的抗锯齿思路不是简单把边缘变模糊,而是估计:

这个像素有多少比例被几何覆盖?

如果某个像素只有 25% 被白色三角形覆盖,可以让它更接近:

1
25% 白色 + 75% 背景色

常见方案包括:

  • MSAA:主要增加几何覆盖测试的样本。
  • SSAA:用更高分辨率完成更多渲染工作后再缩小。
  • FXAA:根据最终图像的边缘特征进行后处理。
  • TAA:利用当前帧和历史帧样本积累结果。

它们并不是同一种算法的不同名字,而是在不同位置、用不同成本弥补采样不足。

13. 色带是有限颜色精度露出了台阶

8 bit 通道只能保存 256 个离散值:

1
0, 1, 2, 3, ... 253, 254, 255

如果一个渐变变化得很慢,许多连续颜色只能落到同一个离散值,原本平滑的变化就会显出一层层边界:

1
2
3
4
5
连续渐变
↓ 有限精度量化
离散台阶
↓ 肉眼可见
色带 Banding

常见改善方向包括:

  • 使用更高精度的颜色格式。
  • 加入少量噪声进行抖动(Dithering)。
  • 避免在低精度中间结果上反复处理。
  • 正确处理颜色空间。

颜色空间会影响"数值的一半是不是视觉亮度的一半",需要单独展开,暂时不与颜色位数混在一起。

14. 把常见现象放回管线

现在可以把一些现象先定位到大致层级:

现象 优先追问的方向
图片模糊 纹理分辨率、缩放、采样过滤、中间缓冲分辨率
斜边锯齿 几何覆盖采样与抗锯齿策略
渐变色带 颜色精度、量化、颜色空间与抖动
半透明颜色异常 Alpha 数据、混合公式、绘制顺序
提高分辨率后掉帧 片元工作量、后处理、Overdraw、显存带宽
降低分辨率仍没有变快 CPU 提交、Draw Call、顶点或其他瓶颈

这张表不是最终诊断,只是在训练一种方法:

先把现象放回数据经过的层级,再去检查这一层的输入、状态和输出。

逻辑闭环 / 锚点

第一章可以收束成一条完整链:

1
2
3
4
5
6
7
8
9
场景中的业务对象
↓ 被表示成渲染数据
顶点和三角形
↓ 光栅化
片元
↓ 着色、测试、混合
帧缓冲中的离散颜色
↓ 交换并显示
屏幕画面

其中几个不能混淆的锚点:

  1. 图片不是几何:纹理提供颜色,三角形决定覆盖范围。
  2. 片元不是像素:片元是候选数据,像素是缓冲区中的结果位置。
  3. Alpha 不是混合:Alpha 是数据,混合状态才决定怎样组合颜色。
  4. 分辨率不是所有性能:它主要放大像素、片元和带宽相关成本。
  5. 显示一个颜色不等于只计算一次:重复覆盖会产生额外工作。

下一篇要继续追的是:

三个顶点为什么能覆盖一片像素?GPU 又怎样在三角形内部得到每个片元的 UV、颜色和其他属性?

这会把我们带到顶点、三角形、网格、绕序、重心坐标与插值。

关联

  • 分层 + 封装 是同一个母题:渲染把场景、几何、片元和像素拆成不同表示层,让每层只解决一种变换。
  • JavaScript 运行时 的边界思维相同:业务代码、引擎、图形 API、驱动和 GPU 是不同层,不能因为上层只调用一个接口,就认为底层只发生了一步。

来源:与 Codex 的对话,2026-07。