第2章 光栅图形(Raster Graphics)
一、导读
1.1 本章学习目标
光栅图形是计算机图形学中最基本的图像表示形式。我们日常看到的显示器、手机屏幕、电视屏幕,都是以光栅(像素阵列)的形式呈现图像的。本章将深入探讨如何将连续的几何图元(线段、多边形等)转换为离散的像素阵列——这个过程称为光栅化(Rasterization),它是现代实时图形渲染的核心技术之一。
本章的核心学习目标包括:
- 掌握光栅化的基本原理:理解从连续的几何表示到离散的像素表示的转换过程,以及这个过程中不可避免的精度损失。
- 实现经典的光栅化算法:包括 Bresenham 画线算法、扫描线多边形填充算法,理解这些算法如何仅用整数运算就实现高效的图元转换。
- 理解走样(Aliasing)的产生机制:走样是离散采样连续信号时产生的视觉瑕疵,包括锯齿、摩尔纹、闪烁等。理解其数学本质是掌握反走样技术的前提。
- 掌握主流反走样技术:从超采样抗锯齿(SSAA)到多重采样抗锯齿(MSAA),再到后处理抗锯齿(FXAA、TAA),理解各技术的原理、性能开销和适用场景。
- 深入理解颜色模型和颜色空间:RGB、HSV、CMYK 等颜色模型的设计动机,sRGB 与线性 RGB 的区别,HDR 颜色表示的意义。
- 理解帧缓冲的组织和操作:颜色缓冲、深度缓冲、模板缓冲的协同工作,以及混合(Blending)操作的原理。
1.2 为什么本章如此重要
光栅化是连接几何世界和像素世界的桥梁。无论三维场景多么复杂,最终都要通过光栅化转换为屏幕上的像素。理解光栅化的原理,对于以下方面至关重要:
首先,性能优化。光栅化算法的效率直接决定了渲染速度。Bresenham 算法之所以经典,是因为它完全避免了浮点运算和除法,仅用整数加减法就能画出完美的直线。在现代 GPU 中,光栅化单元仍然是固定功能硬件,其效率对整体性能影响巨大。
其次,图像质量。走样是光栅图形中最常见的视觉问题。一条斜线在像素网格上必然呈现锯齿状,一个远处的细小物体可能因为采样不足而闪烁或消失。理解走样的数学本质(信号采样与重建理论),才能正确选择和应用反走样技术。
最后,颜色准确性。现代渲染管线中,颜色计算的正确性直接影响最终图像质量。在 sRGB 空间中进行光照计算会导致物理上不正确的结果,这是很多初学者容易忽略的问题。理解颜色空间是做好渲染的前提。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么画一条直线需要专门的算法?不能简单地用 y = mx + b 逐点计算吗?Bresenham 算法是如何避免浮点运算的?
走样的数学本质是什么?它与信号处理中的采样定理有什么关系?为什么提高分辨率不能完全解决走样问题?
MSAA(多重采样抗锯齿)是如何在不显著增加着色器计算量的情况下实现抗锯齿的?它与传统的全分辨率超采样有什么区别?
为什么在 sRGB 颜色空间中进行光照插值会导致颜色变暗?什么是"伽马正确"的渲染流程?
深度缓冲(Z-Buffer)算法的精度分布有什么特点?为什么近裁剪面附近的深度精度远高于远处?这对场景设置有什么指导意义?
Alpha 混合的数学公式是什么?为什么混合顺序很重要?如何实现与顺序无关的透明渲染?
二、核心概念详解
2.1 光栅化的基本原理
光栅化(Rasterization)是将几何图元(点、线、三角形等)转换为离散像素的过程。这个过程本质上是一个采样问题:我们需要判断哪些像素被几何图元"覆盖",并为这些像素计算合适的属性值(颜色、深度等)。
2.1.1 像素与片元
在讨论光栅化之前,需要区分两个容易混淆的概念:
- 像素(Pixel):显示设备上的最小物理单元,是屏幕空间中的固定网格点。
- 片元(Fragment):光栅化阶段产生的数据集合,包含颜色、深度、纹理坐标等信息,是片元着色器的输入。一个像素可能对应多个片元(例如透明物体叠加),最终通过深度测试和混合决定像素的最终颜色。
2.1.2 Bresenham 画线算法
Bresenham 画线算法(1965)是计算机图形学中最经典的算法之一。它的目标是在整数坐标的像素网格上绘制一条直线,且仅使用整数加法和减法,避免昂贵的浮点运算和除法。
算法的核心思想是:假设直线斜率在 [0, 1] 之间(其他情况可通过对称变换转化),x 每递增 1,y 要么保持不变,要么递增 1。选择哪个取决于决策参数——即理想直线与两个候选像素中点的相对位置。
// Bresenham 画线算法(完整实现,处理所有方向)
void drawLine(int x0, int y0, int x1, int y1,
uint32_t* framebuffer, int fbWidth, int fbHeight,
uint32_t color) {
int dx = abs(x1 - x0);
int dy = abs(y1 - y0);
int sx = (x0 < x1) ? 1 : -1;
int sy = (y0 < y1) ? 1 : -1;
int err = dx - dy;
while (true) {
if (x0 >= 0 && x0 < fbWidth && y0 >= 0 && y0 < fbHeight) {
framebuffer[y0 * fbWidth + x0] = color;
}
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}算法分析:
- 时间复杂度:O(max(dx, dy)),每个像素最多一次循环迭代。
- 空间复杂度:O(1),仅使用几个整数变量。
- 关键优势:完全没有浮点运算、乘法和除法,只有整数加减和移位(
2 * err可以用左移实现),在早期硬件上性能优势巨大。
2.1.3 扫描线多边形填充
多边形填充是光栅化的另一个核心问题。扫描线算法(Scanline Algorithm)是最经典的多边形填充方法:
// 扫描线多边形填充算法
struct Edge {
float x, dx;
int yMin, yMax;
bool operator<(const Edge& other) const {
return x < other.x;
}
};
void fillPolygon(const std::vector<glm::vec2>& vertices,
uint32_t* framebuffer, int fbWidth, int fbHeight,
uint32_t color) {
int n = vertices.size();
std::vector<Edge> edges;
// 构建边表
for (int i = 0; i < n; i++) {
int j = (i + 1) % n;
int yMin = std::min((int)vertices[i].y, (int)vertices[j].y);
int yMax = std::max((int)vertices[i].y, (int)vertices[j].y);
if (yMin == yMax) continue; // 跳过水平边
Edge e;
e.yMin = yMin;
e.yMax = yMax;
e.x = vertices[i].y < vertices[j].y ? vertices[i].x : vertices[j].x;
e.dx = (vertices[j].x - vertices[i].x) /
(vertices[j].y - vertices[i].y);
edges.push_back(e);
}
// 对每条扫描线进行填充
for (int y = 0; y < fbHeight; y++) {
std::vector<float> intersections;
for (auto& e : edges) {
if (y >= e.yMin && y < e.yMax) {
intersections.push_back(e.x + e.dx * (y - e.yMin));
}
}
std::sort(intersections.begin(), intersections.end());
// 成对填充
for (size_t i = 0; i + 1 < intersections.size(); i += 2) {
int xStart = (int)std::ceil(intersections[i]);
int xEnd = (int)std::floor(intersections[i + 1]);
for (int x = xStart; x <= xEnd; x++) {
if (x >= 0 && x < fbWidth) {
framebuffer[y * fbWidth + x] = color;
}
}
}
}
}扫描线算法的核心思想是利用连贯性(Coherence):相邻扫描线的交点变化很小(x 的增量 dx 是常数),因此可以增量计算,避免重复求解。
2.2 走样与反走样
2.2.1 走样的数学本质
走样(Aliasing)的根本原因是采样频率不足。根据奈奎斯特-香农采样定理,要正确重建一个信号,采样频率必须至少是信号最高频率的两倍。当采样频率不足时,高频成分会"折叠"成低频成分,产生视觉瑕疵。
在光栅图形中,走样表现为:
- 空间走样:直线和多边形边缘的锯齿(Jaggies)
- 时间走样:动画中小物体的闪烁和消失
- 频率走样:纹理中的摩尔纹(Moiré Pattern)
走样示意(一条斜线在像素网格上的表现):
理想直线: 光栅化后:
/ ┌──┐
/ └──┘──┐
/ └──┐
└──┐
└──┘
可以看到明显的阶梯状锯齿2.2.2 超采样抗锯齿(SSAA)
超采样是最直观的反走样方法:以高于显示分辨率的分辨率渲染场景,然后将结果降采样到目标分辨率。
// 4x SSAA 的基本原理
// 渲染分辨率: 2x 宽, 2x 高
// 最终分辨率: 原始宽高
void ssaaDownsample(const uint32_t* highResBuffer,
int highResWidth, int highResHeight,
uint32_t* lowResBuffer,
int lowResWidth, int lowResHeight) {
int scale = highResWidth / lowResWidth;
for (int y = 0; y < lowResHeight; y++) {
for (int x = 0; x < lowResWidth; x++) {
uint32_t r = 0, g = 0, b = 0;
int count = 0;
for (int sy = 0; sy < scale; sy++) {
for (int sx = 0; sx < scale; sx++) {
int hx = x * scale + sx;
int hy = y * scale + sy;
uint32_t pixel = highResBuffer[hy * highResWidth + hx];
r += (pixel >> 16) & 0xFF;
g += (pixel >> 8) & 0xFF;
b += pixel & 0xFF;
count++;
}
}
r /= count; g /= count; b /= count;
lowResBuffer[y * lowResWidth + x] =
(0xFF << 24) | (r << 16) | (g << 8) | b;
}
}
}SSAA 的优点是效果最好,能处理所有类型的走样;缺点是性能开销巨大——4x SSAA 需要渲染 4 倍的像素量,片元着色器的计算量也增加 4 倍。
2.2.3 多重采样抗锯齿(MSAA)
MSAA 是现代 GPU 中最常用的硬件级抗锯齿方法。它的核心洞察是:走样主要发生在几何边缘,而不是着色细节。因此,MSAA 只在几何边缘处进行多重采样,着色计算仍然只执行一次。
MSAA 的工作原理:
每个像素内部维护多个采样点(如 4x MSAA 有 4 个采样点)
光栅化阶段对每个采样点独立进行覆盖测试
片元着色器只执行一次(如果像素被覆盖至少一个采样点)
最终颜色通过对所有采样点取平均得到
// OpenGL 中启用 MSAA
glfwWindowHint(GLFW_SAMPLES, 4); // 请求 4x MSAA
glEnable(GL_MULTISAMPLE);
// 创建多重采样帧缓冲
GLuint msFBO;
glGenFramebuffers(1, &msFBO);
glBindFramebuffer(GL_FRAMEBUFFER, msFBO);
// 多重采样颜色纹理
GLuint msColorTex;
glGenTextures(1, &msColorTex);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, msColorTex);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, 4,
GL_RGBA8, width, height, GL_TRUE);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D_MULTISAMPLE, msColorTex, 0);
// 多重采样深度缓冲
GLuint msDepthRBO;
glGenRenderbuffers(1, &msDepthRBO);
glBindRenderbuffer(GL_RENDERBUFFER, msDepthRBO);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4,
GL_DEPTH_COMPONENT24,
width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_RENDERBUFFER, msDepthRBO);MSAA 相比 SSAA 的优势在于:着色器计算量不增加(只执行一次),只有光栅化和最终解析的开销增加。对于着色复杂的场景,性能优势非常明显。
2.2.4 后处理抗锯齿
后处理抗锯齿在渲染完成后对最终图像进行处理,不需要修改渲染管线:
- FXAA(Fast Approximate AA):检测边缘方向的亮度梯度,沿边缘方向进行模糊。速度极快,但可能导致整体画面模糊。
- TAA(Temporal AA):利用时间维度的信息,将当前帧与历史帧混合。效果优秀,但可能产生鬼影(Ghosting)。
// FXAA 简化版核心逻辑
vec3 rgbNW = textureOffset(tex, texCoord, ivec2(-1, -1)).rgb;
vec3 rgbNE = textureOffset(tex, texCoord, ivec2( 1, -1)).rgb;
vec3 rgbSW = textureOffset(tex, texCoord, ivec2(-1, 1)).rgb;
vec3 rgbSE = textureOffset(tex, texCoord, ivec2( 1, 1)).rgb;
vec3 rgbM = texture(tex, texCoord).rgb;
vec3 luma = vec3(0.299, 0.587, 0.114);
float lumaNW = dot(rgbNW, luma);
float lumaNE = dot(rgbNE, luma);
float lumaSW = dot(rgbSW, luma);
float lumaSE = dot(rgbSE, luma);
float lumaM = dot(rgbM, luma);
float lumaMin = min(lumaM, min(min(lumaNW, lumaNE),
min(lumaSW, lumaSE)));
float lumaMax = max(lumaM, max(max(lumaNW, lumaNE),
max(lumaSW, lumaSE)));
// 边缘检测
vec2 dir;
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) *
(0.25 / FXAA_REDUCE_MUL), FXAA_REDUCE_MIN);
float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
dir = min(vec2(FXAA_SPAN_MAX),
max(vec2(-FXAA_SPAN_MAX), dir * rcpDirMin)) * texOffset;
vec3 rgbA = 0.5 * (
texture(tex, texCoord + dir * (1.0/3.0 - 0.5)).rgb +
texture(tex, texCoord + dir * (2.0/3.0 - 0.5)).rgb);
vec3 rgbB = rgbA * 0.5 + 0.25 * (
texture(tex, texCoord + dir * -0.5).rgb +
texture(tex, texCoord + dir * 0.5).rgb);
float lumaB = dot(rgbB, luma);
if (lumaB < lumaMin || lumaB > lumaMax) {
FragColor = vec4(rgbA, 1.0);
} else {
FragColor = vec4(rgbB, 1.0);
}2.3 颜色模型与颜色空间
2.3.1 RGB 颜色模型
RGB(红绿蓝)是加色模型,基于人眼视网膜上三种锥体细胞对红、绿、蓝光的敏感性。所有颜色都可以用三种原色的不同强度组合来表示。
// RGB 颜色混合
struct Color {
float r, g, b;
// 加法混合(光的叠加)
Color operator+(const Color& other) const {
return {r + other.r, g + other.g, b + other.b};
}
// 乘法混合(光照计算常用)
Color operator*(const Color& other) const {
return {r * other.r, g * other.g, b * other.b};
}
// 标量乘法
Color operator*(float s) const {
return {r * s, g * s, b * s};
}
// 限制到 [0, 1] 范围
Color clamp() const {
return {std::clamp(r, 0.0f, 1.0f),
std::clamp(g, 0.0f, 1.0f),
std::clamp(b, 0.0f, 1.0f)};
}
};2.3.2 HSV/HSL 颜色模型
HSV(色相、饱和度、明度)和 HSL(色相、饱和度、亮度)是更符合人类直觉的颜色模型。在图形学中常用于颜色选择器和颜色调整。
// RGB 到 HSV 的转换
struct HSV {
float h, s, v;
};
HSV rgbToHsv(float r, float g, float b) {
float maxVal = std::max({r, g, b});
float minVal = std::min({r, g, b});
float delta = maxVal - minVal;
HSV hsv;
hsv.v = maxVal;
hsv.s = (maxVal > 0.0f) ? delta / maxVal : 0.0f;
if (delta < 1e-6f) {
hsv.h = 0.0f;
} else if (maxVal == r) {
hsv.h = 60.0f * std::fmod((g - b) / delta, 6.0f);
} else if (maxVal == g) {
hsv.h = 60.0f * ((b - r) / delta + 2.0f);
} else {
hsv.h = 60.0f * ((r - g) / delta + 4.0f);
}
if (hsv.h < 0.0f) hsv.h += 360.0f;
return hsv;
}
// HSV 到 RGB 的转换
std::tuple<float, float, float> hsvToRgb(float h, float s, float v) {
float c = v * s;
float x = c * (1.0f - std::abs(std::fmod(h / 60.0f, 2.0f) - 1.0f));
float m = v - c;
float r, g, b;
if (h < 60.0f) { r = c; g = x; b = 0; }
else if (h < 120.0f) { r = x; g = c; b = 0; }
else if (h < 180.0f) { r = 0; g = c; b = x; }
else if (h < 240.0f) { r = 0; g = x; b = c; }
else if (h < 300.0f) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return {r + m, g + m, b + m};
}2.3.3 sRGB 与线性 RGB
sRGB 是标准 RGB 颜色空间,它对线性 RGB 值进行了一个非线性变换(近似 gamma 2.2),目的是更好地匹配人眼对亮度的感知特性(人眼对暗部变化更敏感)。
关键问题:如果在 sRGB 空间中进行光照计算(如插值、混合),结果在物理上是不正确的。正确做法是在线性空间中进行计算,最后转换到 sRGB 空间显示。
// sRGB 到线性空间的转换
vec3 srgbToLinear(vec3 srgb) {
return pow(srgb, vec3(2.2));
}
// 线性空间到 sRGB 的转换
vec3 linearToSrgb(vec3 linear) {
return pow(linear, vec3(1.0 / 2.2));
}
// 正确的渲染流程
// 1. 输入纹理通常是 sRGB 格式,OpenGL 自动在线性空间采样
// 2. 在线性空间中进行所有光照计算
// 3. 最终输出转换到 sRGB 空间
// 方法1: 使用 sRGB 纹理格式(推荐)
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, data);
// OpenGL 采样时自动转换到线性空间
// 方法2: 使用帧缓冲的 sRGB 转换
glEnable(GL_FRAMEBUFFER_SRGB);
// 写入帧缓冲时自动从线性空间转换到 sRGB2.4 深度缓冲
2.4.1 Z-Buffer 算法
深度缓冲(Z-Buffer)是解决可见性问题的标准方法。对每个像素,维护一个深度值,当新片元的深度小于已存储的深度时,更新颜色和深度值。
// 软件深度缓冲实现
class DepthBuffer {
public:
DepthBuffer(int width, int height)
: width(width), height(height),
colorBuffer(width * height, 0),
depthBuffer(width * height, 1.0f) {}
void clear() {
std::fill(colorBuffer.begin(), colorBuffer.end(), 0);
std::fill(depthBuffer.begin(), depthBuffer.end(), 1.0f);
}
bool writeFragment(int x, int y, float depth, uint32_t color) {
if (x < 0 || x >= width || y < 0 || y >= height) return false;
int idx = y * width + x;
if (depth < depthBuffer[idx]) {
depthBuffer[idx] = depth;
colorBuffer[idx] = color;
return true;
}
return false;
}
private:
int width, height;
std::vector<uint32_t> colorBuffer;
std::vector<float> depthBuffer;
};2.4.2 深度精度问题
深度缓冲的精度分布是不均匀的。在使用透视投影时,近裁剪面附近的深度精度远高于远处。这是因为透视投影将深度值映射到 [0, 1] 范围时使用了非线性变换:
深度缓冲精度分布(透视投影):
近裁剪面 ──────────────────────── 远裁剪面
████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|← 高精度 →|← ────── 低精度 ──────────→|
近处: 深度差异 0.001 可以区分
远处: 深度差异 0.1 可能无法区分 → Z-Fighting// 透视投影的深度映射公式
float linearDepthToNDC(float linearZ, float near, float far) {
// 透视投影后的 NDC 深度
return (far + near) / (far - near) +
(2.0f * far * near) / (far - near) / (-linearZ);
}
// 实践建议:
// 1. 近裁剪面尽量设远(不要设为 0.01 如果场景不需要那么近)
// 2. 远裁剪面尽量设近(不要设为 10000 如果场景不需要那么远)
// 3. 大范围场景考虑使用 Cascaded Shadow Maps 或对数深度缓冲
// 对数深度缓冲(Logarithmic Depth Buffer)
// 片元着色器中:
float logDepth = log2(linearDepth + 1.0) * log2FarToNearFactor;2.5 Alpha 混合
2.5.1 混合方程
当渲染透明或半透明物体时,需要将新片元的颜色与帧缓冲中已有的颜色进行混合。标准混合方程为:
最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子// OpenGL 混合设置
glEnable(GL_BLEND);
// 标准 Alpha 混合
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// 等价于: result = srcColor * srcAlpha + dstColor * (1 - srcAlpha)
// 加法混合(用于粒子效果、光晕)
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
// result = srcColor * srcAlpha + dstColor * 1.0
// 预乘 Alpha 混合
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
// 要求源颜色已经预乘了 Alpha2.5.2 排序问题
Alpha 混合不满足交换律和结合律,因此渲染顺序很重要。正确的做法是从后往前(Back-to-Front)渲染透明物体。
// 透明物体的排序渲染
void renderScene() {
// 1. 先渲染所有不透明物体(深度测试 + 深度写入)
glDepthMask(GL_TRUE);
glDisable(GL_BLEND);
for (auto& obj : opaqueObjects) {
obj.render();
}
// 2. 收集所有透明物体
std::vector<TransparentObject*> transparentObjects;
for (auto& obj : sceneObjects) {
if (obj.isTransparent()) {
transparentObjects.push_back(&obj);
}
}
// 3. 按距离排序(从远到近)
glm::vec3 cameraPos = camera.getPosition();
std::sort(transparentObjects.begin(), transparentObjects.end(),
[&cameraPos](const TransparentObject* a, const TransparentObject* b) {
float distA = glm::length(a->getPosition() - cameraPos);
float distB = glm::length(b->getPosition() - cameraPos);
return distA > distB; // 远的先渲染
});
// 4. 渲染透明物体(深度测试 + 无深度写入)
glDepthMask(GL_FALSE);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
for (auto* obj : transparentObjects) {
obj->render();
}
glDepthMask(GL_TRUE);
}三、实战案例
3.1 软件光栅化渲染器
以下实现一个完整的软件光栅化渲染器,展示光栅化的核心流程:
#include <vector>
#include <cmath>
#include <algorithm>
#include <cstdint>
struct Vec3 {
float x, y, z;
Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
Vec3 operator-(const Vec3& o) const { return {x-o.x, y-o.y, z-o.z}; }
Vec3 operator*(float s) const { return {x*s, y*s, z*s}; }
float dot(const Vec3& o) const { return x*o.x + y*o.y + z*o.z; }
Vec3 cross(const Vec3& o) const {
return {y*o.z - z*o.y, z*o.x - x*o.z, x*o.y - y*o.x};
}
};
struct Triangle {
Vec3 v0, v1, v2;
uint32_t color;
};
class SoftwareRenderer {
public:
SoftwareRenderer(int w, int h)
: width(w), height(h),
colorBuffer(w * h, 0xFF000000),
depthBuffer(w * h, 1.0f) {}
void clear() {
std::fill(colorBuffer.begin(), colorBuffer.end(), 0xFF000000);
std::fill(depthBuffer.begin(), depthBuffer.end(), 1.0f);
}
void rasterizeTriangle(const Triangle& tri) {
// 计算包围盒
float minX = std::max(0.0f, std::min({tri.v0.x, tri.v1.x, tri.v2.x}));
float maxX = std::min((float)(width-1),
std::max({tri.v0.x, tri.v1.x, tri.v2.x}));
float minY = std::max(0.0f, std::min({tri.v0.y, tri.v1.y, tri.v2.y}));
float maxY = std::min((float)(height-1),
std::max({tri.v0.y, tri.v1.y, tri.v2.y}));
// 遍历包围盒中的每个像素
for (int y = (int)minY; y <= (int)maxY; y++) {
for (int x = (int)minX; x <= (int)maxX; x++) {
Vec3 p = {(float)x + 0.5f, (float)y + 0.5f, 0.0f};
// 计算重心坐标
float area = edgeFunction(tri.v0, tri.v1, tri.v2);
if (std::abs(area) < 1e-6f) continue;
float w0 = edgeFunction(tri.v1, tri.v2, p) / area;
float w1 = edgeFunction(tri.v2, tri.v0, p) / area;
float w2 = edgeFunction(tri.v0, tri.v1, p) / area;
// 判断像素是否在三角形内
if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
// 插值深度
float depth = w0 * tri.v0.z + w1 * tri.v1.z + w2 * tri.v2.z;
int idx = y * width + x;
if (depth < depthBuffer[idx]) {
depthBuffer[idx] = depth;
colorBuffer[idx] = tri.color;
}
}
}
}
}
const std::vector<uint32_t>& getColorBuffer() const {
return colorBuffer;
}
private:
float edgeFunction(const Vec3& a, const Vec3& b, const Vec3& c) {
return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x);
}
int width, height;
std::vector<uint32_t> colorBuffer;
std::vector<float> depthBuffer;
};
// 使用示例
int main() {
SoftwareRenderer renderer(800, 600);
renderer.clear();
Triangle tri = {
{400.0f, 100.0f, 0.5f},
{200.0f, 500.0f, 0.5f},
{600.0f, 500.0f, 0.5f},
0xFFFF4444
};
renderer.rasterizeTriangle(tri);
return 0;
}3.2 反走样效果对比
以下代码展示不同反走样方法的实现和效果差异:
// 反走样方法对比
class AntiAliasingDemo {
public:
// 无抗锯齿(基础光栅化)
void renderNoAA(SoftwareRenderer& renderer, const Triangle& tri) {
renderer.rasterizeTriangle(tri);
}
// 2x2 超采样抗锯齿
void renderSSAA2x(const Triangle& tri, int width, int height,
std::vector<uint32_t>& output) {
int ssWidth = width * 2;
int ssHeight = height * 2;
SoftwareRenderer ssRenderer(ssWidth, ssHeight);
ssRenderer.clear();
// 放大三角形
Triangle ssTri = {
{tri.v0.x * 2, tri.v0.y * 2, tri.v0.z},
{tri.v1.x * 2, tri.v1.y * 2, tri.v1.z},
{tri.v2.x * 2, tri.v2.y * 2, tri.v2.z},
tri.color
};
ssRenderer.rasterizeTriangle(ssTri);
// 降采样
auto& ssBuffer = ssRenderer.getColorBuffer();
output.resize(width * height);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
uint32_t r = 0, g = 0, b = 0;
for (int sy = 0; sy < 2; sy++) {
for (int sx = 0; sx < 2; sx++) {
uint32_t pixel = ssBuffer[(y*2+sy) * ssWidth + (x*2+sx)];
r += (pixel >> 16) & 0xFF;
g += (pixel >> 8) & 0xFF;
b += pixel & 0xFF;
}
}
output[y * width + x] =
(0xFF << 24) | ((r/4) << 16) | ((g/4) << 8) | (b/4);
}
}
}
};四、本章小结
本章深入探讨了光栅图形的核心技术和理论基础:
光栅化是将连续几何图元转换为离散像素的过程。Bresenham 算法和扫描线算法是经典的高效光栅化算法,它们利用增量计算和整数运算实现最优性能。
走样是离散采样连续信号时不可避免的视觉瑕疵,其数学本质是采样频率不足导致的高频折叠。反走样技术的核心思想是增加采样率或对采样结果进行滤波。
MSAA 通过在几何边缘处多重采样、着色计算只执行一次的方式,在图像质量和性能之间取得了优秀的平衡。
颜色空间的正确使用至关重要。在线性空间中进行光照计算,最后转换到 sRGB 空间显示,是获得物理正确结果的基础。
深度缓冲是解决可见性问题的标准方法,但其精度分布不均匀,近处精度高、远处精度低,需要合理设置近远裁剪面。
Alpha 混合需要正确的渲染顺序(从后往前),这是实时渲染中透明物体渲染的核心挑战。
五、思考与练习
思考题
Bresenham 算法如何扩展到绘制任意斜率的直线?如何处理浮点坐标的直线(如经过变换后的线段)?
MSAA 对透明物体有什么局限性?为什么 MSAA 不能解决着色器内部的走样问题(如高频纹理)?
为什么深度缓冲使用 24 位或 32 位而不是 8 位?8 位深度缓冲会导致什么视觉问题?
在 sRGB 空间中进行双线性插值与在线性空间中插值,结果有什么不同?为什么?
什么是 Z-Fighting?它产生的原因是什么?有哪些解决方法?
编程练习
Bresenham 画圆:实现 Bresenham 画圆算法(Midpoint Circle Algorithm),利用八分对称性减少计算量。
渐变三角形光栅化:扩展软件光栅化器,为三角形的每个顶点指定不同颜色,使用重心坐标进行颜色插值,实现平滑着色。
MSAA 模拟器:实现一个简单的 4x MSAA 模拟器,每个像素维护 4 个子采样点的深度和颜色,最终取平均输出。与无抗锯齿的结果进行对比。