OpenGL OIT 之 Linked List 实现(上篇):原理、流程与缓冲区设计

0. 前言

在 OpenGL 渲染中,透明物体一直是一个棘手的问题。传统的 alpha 混合要求物体从远到近排序绘制,但实际场景中物体之间可能有穿插、包含关系,无法简单地按距离排序。更糟糕的是,随着视角旋转,物体的远近关系会动态变化,每帧都要重新排序------这在 CPU 侧代价高昂且容易出错。

OIT(Order-Independent Transparency,与顺序无关的透明度) 就是为了解决这个问题而生的。本文介绍 OIT 三种主流方案之一:基于逐像素链表的 OIT(Linked List OIT)


1. 什么是 OIT?为什么需要它?

1.1 传统透明渲染的困境

在标准渲染管线中,透明物体的绘制顺序直接决定了最终颜色:

复制代码
最终颜色 = 源颜色 * alpha + 目标颜色 * (1 - alpha)

这并不是一个交换律 运算,所以 A over B ≠ B over A。如果先画近处的再画远处的,结果会出错:

复制代码
错误顺序:近(含alpha) → 远(不透明) → 结果:近的透明物体遮挡了远的,但透明度计算错误
正确顺序:远(不透明) → 近(含alpha) → 结果:正确

CPU 侧排序的做法是:

  1. 收集所有透明物体
  2. 按距离排序
  3. 从远到近逐个绘制

但这有几个致命问题:

  • 物体间有穿插时无法排序
  • 视角旋转后排序变化,需要每帧重新排序
  • 排序本身有 O(n log n) 的开销

1.2 OIT 的核心思想

OIT 不要求 CPU 侧排序,而是将排序的责任交给 GPU。GPU 在渲染每个像素时,收集该像素上所有透明片段的颜色和深度,在 GPU 内部排序后再混合。

三种主流 OIT 方案:

方案 原理 优点 缺点
Depth Peeling 多次 Pass 逐层剥离 精确,兼容性好 Pass 数多,性能较差
Linked List 逐像素链表存储所有片段 一次 Pass 收集,精确 显存开销大,需要原子操作
Stochastic 随机采样近似混合 单 Pass,性能好 非精确,有噪点

本文聚焦于 Linked List 方案------它用一次 Pass 收集所有透明片段到逐像素链表中,然后在一次全屏 Pass 中排序并混合,是一种精确且优雅的方案。


2. Linked List OIT 整体架构

2.1 三 Pass 渲染流程

Linked List OIT 将渲染分为三个阶段:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                        Pass 1: 不透明物体                         │
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────────┐ │
│  │ 不透明物体    │ ──→ │ Blinn-Phong  │ ──→ │ opaqueFBO        │ │
│  │ (spot cow)   │     │ 光照计算     │     │ (color + depth)  │ │
│  └──────────────┘     └──────────────┘     └──────────────────┘ │
│  深度写入: ON   深度测试: LESS                                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Pass 2: 透明物体收集                           │
│                                                                 │
│  ┌──────────────┐     ┌──────────────────────────────────────┐  │
│  │ 透明物体 ×3  │ ──→ │ Fragment Shader 中:                  │  │
│  │ (RGB quads)  │     │ 1. atomicCounterIncrement 获取节点ID  │  │
│  └──────────────┘     │ 2. imageAtomicExchange 头插法入链表   │  │
│                      │ 3. 写入 linkedListBuffer[nodeID]      │  │
│                      └──────────────────────────────────────┘  │
│                            │          │                         │
│                            ▼          ▼                         │
│                   ┌────────────┐  ┌──────────────────┐         │
│                   │ SSBO 链表   │  │ oitRenderFBO     │         │
│                   │ (每像素一条) │  │ (color attachment)│        │
│                   └────────────┘  └──────────────────┘         │
│  深度写入: OFF  深度测试: ON (共享 opaque 的 depth)              │
│  作用: 让被不透明物体遮挡的透明片段被正确丢弃                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Pass 3: 合成输出                              │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Fullscreen Quad + compositeShader:                       │   │
│  │ 1. 遍历当前像素的链表                                      │   │
│  │ 2. 去重(相邻三角形边界同一深度)                           │   │
│  │ 3. 插入排序(从远到近)                                    │   │
│  │ 4. Back-to-Front over 混合                                │   │
│  └──────────────────────────────────────────────────────────┘   │
│                            │                                     │
│                            ▼                                     │
│                   ┌──────────────────┐                           │
│                   │ 默认帧缓冲 (屏幕) │                           │
│                   └──────────────────┘                           │
│  glMemoryBarrier: 确保 SSBO/Image/Atomic 写入完成后才读取         │
└─────────────────────────────────────────────────────────────────┘

2.2 逐像素链表的数据结构

每个像素(屏幕坐标 (x, y))对应一条单链表,链表中的每个节点存储:

复制代码
NodeType {
    vec4 color;   // 片段的 RGBA 颜色
    float depth;  // 片段的深度值(用于排序)
    uint  next;   // 链表中下一个节点的索引(0xFFFFFFFF 表示链表尾)
}
  • 头指针 :存储在 headPointers (Image Texture) 中,每个像素一个 uint32,指向该像素链表的第一个节点
  • 节点存储 :存储在 linkedListBuffer (SSBO) 中,所有像素共享一个大的节点池
  • 节点分配 :通过 atomicCounterIncrement 原子操作分配唯一的节点索引

示意图:

复制代码
像素 (300, 200) 的链表:

headPointers[300,200] = 5  ──→  Node[5]  ──→  Node[2]  ──→  Node[0]  ──→ END
                                   │              │              │
                                红色片段        绿色片段        蓝色片段
                                depth=0.3      depth=0.5      depth=0.7
                                next=2         next=0         next=0xFFFFFFFF

3. 四种特殊缓冲区详解

这是本项目的核心难点,涉及四个不常用的 OpenGL 缓冲区类型,这里逐一剖析。

3.1 Atomic Counter Buffer ------ 原子计数器

cpp 复制代码
// 创建
glGenBuffers(1, &atomicBuffer_);
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, atomicBuffer_);
glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), nullptr, GL_DYNAMIC_DRAW);

本质 :一个可以原子增加的 uint32 缓冲区。

绑定点GL_ATOMIC_COUNTER_BUFFER,binding = 0

作用 :为每个透明片段分配一个全局唯一 的节点索引。在 Fragment Shader 中通过 atomicCounterIncrement 原子地获取当前计数值并自增 1。

为什么需要原子操作? 因为多个像素的 Fragment Shader 在 GPU 上并行执行,如果使用普通变量,多个线程可能读到相同的值(竞态)。原子操作保证:读取 → 返回旧值 → 写入新值 这三个步骤不可分割。

每帧重置:在 Pass 2 开始前,必须将计数器归零:

cpp 复制代码
GLuint zero = 0;
glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 0, sizeof(GLuint), &zero);

Shader 侧使用

glsl 复制代码
layout(binding = 0, offset = 0) uniform atomic_uint nextNodeCounter;
// ...
uint nodeIndex = atomicCounterIncrement(nextNodeCounter);

3.2 SSBO (Shader Storage Buffer Object) ------ 链表存储

cpp 复制代码
GLint nodeSize = 5 * sizeof(GLfloat) + sizeof(GLuint);  // vec4 + float + uint
glGenBuffers(1, &linkedListBuffer_);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, linkedListBuffer_);
glBufferData(GL_SHADER_STORAGE_BUFFER, maxNodes_ * nodeSize, nullptr, GL_DYNAMIC_DRAW);

本质 :一块 GPU 可读写的大缓冲区,大小 = maxNodes * sizeof(NodeType)

绑定点GL_SHADER_STORAGE_BUFFER,binding = 0

与 UBO 的区别

  • UBO 大小有限(通常 16KB-64KB),只读,适合小量 uniform 数据
  • SSBO 大小可达 GB 级,可读写,适合大量结构化数据

节点容量估算

复制代码
maxNodes = width * height * 20 = 800 * 600 * 20 = 9,600,000 个节点
每个节点 = 24 bytes (vec4=16 + float=4 + uint=4)
总大小 ≈ 9,600,000 * 24 ≈ 230 MB

这是 Linked List OIT 的主要代价------显存开销大。

Shader 侧使用

glsl 复制代码
layout(binding = 0, std430) buffer linkedLists {
    NodeType nodes[];
};
// 写入: nodes[nodeIndex].color = color;
// 读取: NodeType node = nodes[idx];

3.3 Image Texture ------ 头指针纹理

cpp 复制代码
glGenTextures(1, &headPtrTexture_);
glBindTexture(GL_TEXTURE_2D, headPtrTexture_);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, width_, height_);
glBindImageTexture(0, headPtrTexture_, 0, GL_FALSE, 0, GL_READ_WRITE, GL_R32UI);

本质 :一个 R32UI 格式的 2D 纹理,每个像素存储一个 uint32 头指针。通过 glBindImageTexture 绑定,允许 Shader 中的 imageLoad / imageStore / imageAtomicExchange 直接读写。

与普通纹理的区别

普通纹理 (sampler) Image 纹理
读取方式 texture(sampler, uv) 带过滤 imageLoad(image, ivec2) 精确像素
写入方式 只读(通过 FBO 颜色附件) imageStore(image, ivec2, data) 直接写
原子操作 不支持 支持 imageAtomicExchange
用途 采样颜色 通用数据存储/计算

glBindImageTexture 参数解析

cpp 复制代码
glBindImageTexture(
    0,            // unit: Image Unit 索引,对应 shader 中 binding = 0
    texture,      // 纹理对象
    0,            // level: mipmap 层级
    GL_FALSE,     // layered: 是否分层
    0,            // layer: 分层索引
    GL_READ_WRITE,// access: 读写权限
    GL_R32UI      // format: 内部格式
);

关键操作:imageAtomicExchange

在 Fragment Shader 中,这是链表插入的核心操作:

glsl 复制代码
uint preHead = imageAtomicExchange(headPointers, ivec2(gl_FragCoord.xy), newNodeIndex);

这行代码原子地完成了两个操作:

  1. 读取 headPointers[x][y] 的旧值,赋给 preHead
  2. newNodeIndex 写入 headPointers[x][y]

然后设置 nodes[newNodeIndex].next = preHead,完成头插法

为什么需要原子操作? 两个透明片段可能同时覆盖同一个像素,如果不用原子操作,两个线程可能同时读取旧头指针,然后各自写入自己的索引,导致其中一个丢失。

3.4 PBO (Pixel Unpack Buffer) ------ 清空缓冲区

cpp 复制代码
std::vector<GLuint> headPtrClearBuf(width_ * height_, 0xffffffff);
glGenBuffers(1, &clearBuf_);
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, clearBuf_);
glBufferData(GL_PIXEL_UNPACK_BUFFER, headPtrClearBuf.size() * sizeof(GLuint),
             headPtrClearBuf.data(), GL_STATIC_COPY);

本质 :一个 PBO,存储了 width * height0xFFFFFFFF(即链表尾哨兵值)。

为什么需要它? 每帧 Pass 2 开始前,需要将所有像素的头指针重置为 0xFFFFFFFF(表示空链表)。直接使用 glTexSubImage2D 需要从 CPU 内存上传数据,而 PBO 允许异步 DMA 传输,数据已经在 GPU 内存中,比 CPU 上传快得多。

使用方式

cpp 复制代码
// 绑定 PBO 到 GL_PIXEL_UNPACK_BUFFER
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, clearBuf_);
// 绑定头指针纹理
glBindTexture(GL_TEXTURE_2D, headPtrTexture_);
// texSubImage 的最后一个参数为 nullptr 表示从当前绑定的 PBO 读取数据
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width_, height_,
                GL_RED_INTEGER, GL_UNSIGNED_INT, nullptr);

GL_PIXEL_UNPACK_BUFFER 绑定了 PBO 时,glTexSubImage2Ddata 参数(nullptr)表示从 PBO 的偏移量 0 处读取数据,而不是从 CPU 内存。


4. GL 状态设置与各 Pass 的作用

4.1 Pass 1: 不透明物体渲染

cpp 复制代码
glEnable(GL_DEPTH_TEST);   // 开启深度测试
glDepthFunc(GL_LESS);      // 深度值小于当前值的片段通过测试
glDepthMask(GL_TRUE);      // 允许写入深度缓冲
glDisable(GL_CULL_FACE);   // 关闭面剔除
状态 设置 作用
GL_DEPTH_TEST GL_TRUE 启用深度测试,丢弃被遮挡的片段
glDepthFunc GL_LESS 只有比已有深度更近的片段才通过,实现正确的遮挡关系
glDepthMask GL_TRUE 允许向深度缓冲写入,记录不透明物体的精确深度
GL_CULL_FACE 禁用 渲染双面,确保模型完整显示

输出opaqueTexture(颜色)+ opaqueDepthTexture(深度)

4.2 Pass 2: 透明物体收集

cpp 复制代码
glEnable(GL_DEPTH_TEST);   // 开启深度测试(丢弃被不透明物体完全遮挡的透明片段)
glDepthMask(GL_FALSE);     // 禁止写入深度缓冲(透明物体不遮挡彼此)
状态 设置 作用
GL_DEPTH_TEST GL_TRUE 利用不透明物体的深度,丢弃被不透明物体遮挡的透明片段
glDepthMask GL_FALSE 关键! 透明物体不写入深度缓冲。如果写入,先画的透明物体可能遮挡后画的透明物体,导致后画的片段被错误丢弃

注意 :Pass 2 的深度测试使用 Pass 1 写入的 opaqueDepthTexture(oitRenderFBO 的深度附件和 opaqueFBO 共享同一个深度纹理),但透明物体自身的深度不写入,确保所有未被遮挡的透明片段都能进入链表。

Shader 中的深度比较:Fragment Shader 还额外进行了一次采样比较:

glsl 复制代码
float depth = texture(texture_depth, uv).r;
if (gl_FragCoord.z > depth + 0.0001) {
    discard;
}

这是在 gl_FragCoord.z 基础上额外做的保护,确保被不透明物体遮挡的片段被丢弃(因为 glDepthMask(GL_FALSE) 意味着硬件的深度测试仍会执行,但 depth texture 是干净的,双重保险)。

4.3 Pass 3: 合成输出

cpp 复制代码
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT |
                GL_SHADER_STORAGE_BARRIER_BIT |
                GL_ATOMIC_COUNTER_BARRIER_BIT);

glEnable(GL_DEPTH_TEST);
glDepthMask(GL_TRUE);

glMemoryBarrier --- 这是 Pass 2 到 Pass 3 之间最关键的一步

GPU 是高度并行的,Pass 2 的 Fragment Shader 写入 SSBO 和 Image Texture 时,这些写入可能还在 GPU 的缓存中,尚未刷新到全局内存。glMemoryBarrier 强制所有之前的写入操作在下一次读取之前完成,否则 Pass 3 可能读到不完整或过时的数据。

三个 barrier bit 的含义:

Barrier Bit 保护的资源
GL_SHADER_IMAGE_ACCESS_BARRIER_BIT Image Texture 的读写(head pointers)
GL_SHADER_STORAGE_BARRIER_BIT SSBO 的读写(链表节点)
GL_ATOMIC_COUNTER_BARRIER_BIT 原子计数器的读写

5. 总结

本文覆盖了 Linked List OIT 的三大核心:

  1. 三 Pass 渲染流程:不透明物体 → 透明片段收集到链表 → 排序混合输出
  2. 四种特殊缓冲区:Atomic Counter(分配节点ID)、SSBO(存储链表)、Image Texture(存储头指针)、PBO(快速清空)
  3. GL 状态管理:深度测试与深度写入的开关控制各 Pass 的行为,Memory Barrier 保证 Pass 间的数据一致性

源码地址:GitHub 仓库

下篇将深入 Shader 代码实现,逐行解析 oitRender.fragcomposite.frag 的关键逻辑。

相关推荐
玖釉-1 天前
Vulkan Specialization Constants 详解:在“运行时配置”和“编译期优化”之间取得平衡
c++·windows·图形渲染
charlie1145141912 天前
通用GUI编程技术——图形渲染实战(四十五)——D3D12资源与堆管理:从上传到驻留
开发语言·3d·图形渲染·win32
玖釉-3 天前
Vulkan 中 Shader 的 vert、frag、mesh、comp 全面解析:作用、关系、特点与工程实践
开发语言·c++·windows·算法·图形渲染
玖釉-3 天前
Vulkan 示例解析:gltfscenerendering.cpp 如何渲染一个复杂 glTF 场景
c++·windows·图形渲染
玖釉-3 天前
Vulkan 示例解析:pipelines.cpp 如何在一个 Render Pass 中切换多条 Graphics Pipeline
c++·windows·算法·图形渲染
做cv的小昊3 天前
计算机图形学:【Games101】学习笔记06——几何(曲线和曲面、网格处理)、阴影图
c++·笔记·学习·游戏·图形渲染·几何学·光照贴图
郝学胜-神的一滴6 天前
[简化版 GAMES 101] 计算机图形学 11:频域·卷积·抗锯齿
c++·unity·图形渲染·opengl·three·unreal
RReality10 天前
【Unity Shader URP】水面效果 实战教程
unity·游戏引擎·图形渲染
郝学胜-神的一滴13 天前
[简化版 GAMES 101] 计算机图形学 10:反走样与深度缓冲核心解析
c++·unity·godot·图形渲染·three.js·unreal engine·opengl