2计算机图形学原理及实践:基础方法(第3版)

光栅图形

像素级操作

光栅化反走样颜色

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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。选择哪个取决于决策参数——即理想直线与两个候选像素中点的相对位置。

cpp
// 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)是最经典的多边形填充方法:

cpp
// 扫描线多边形填充算法
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)

超采样是最直观的反走样方法:以高于显示分辨率的分辨率渲染场景,然后将结果降采样到目标分辨率。

cpp
// 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 个采样点)

光栅化阶段对每个采样点独立进行覆盖测试

片元着色器只执行一次(如果像素被覆盖至少一个采样点)

最终颜色通过对所有采样点取平均得到

cpp
// 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)。
glsl
// 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(红绿蓝)是加色模型,基于人眼视网膜上三种锥体细胞对红、绿、蓝光的敏感性。所有颜色都可以用三种原色的不同强度组合来表示。

cpp
// 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(色相、饱和度、亮度)是更符合人类直觉的颜色模型。在图形学中常用于颜色选择器和颜色调整。

cpp
// 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 空间显示。

glsl
// 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);
// 写入帧缓冲时自动从线性空间转换到 sRGB

2.4 深度缓冲

2.4.1 Z-Buffer 算法

深度缓冲(Z-Buffer)是解决可见性问题的标准方法。对每个像素,维护一个深度值,当新片元的深度小于已存储的深度时,更新颜色和深度值。

cpp
// 软件深度缓冲实现
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
cpp
// 透视投影的深度映射公式
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 混合方程

当渲染透明或半透明物体时,需要将新片元的颜色与帧缓冲中已有的颜色进行混合。标准混合方程为:

最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子
cpp
// 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);
// 要求源颜色已经预乘了 Alpha

2.5.2 排序问题

Alpha 混合不满足交换律和结合律,因此渲染顺序很重要。正确的做法是从后往前(Back-to-Front)渲染透明物体。

cpp
// 透明物体的排序渲染
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 软件光栅化渲染器

以下实现一个完整的软件光栅化渲染器,展示光栅化的核心流程:

cpp
#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 反走样效果对比

以下代码展示不同反走样方法的实现和效果差异:

cpp
// 反走样方法对比
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 个子采样点的深度和颜色,最终取平均输出。与无抗锯齿的结果进行对比。


2/8光栅图形
0%