当前位置: 首页 > news >正文

LearnOpenGL——OIT

教程地址:简介 - LearnOpenGL CN


简介

  • 原文链接:LearnOpenGL - Introduction

前言

在混合(Blending)章节中,我们介绍了颜色混合的主题。混合是在3D场景中实现透明表面的方法。简而言之,透明度涉及到在计算机图形学中绘制半透明或完全透明的对象(如玻璃)的问题。这个概念在混合章节中已经得到了适当的解释,因此如果你对这个话题不熟悉,最好先阅读它。

在这篇文章中,我们将进一步探讨这个话题,因为在 3D 环境中实现这种效果涉及许多技术。

首先,我们将讨论图形库/硬件的限制以及它们带来的困难,以及为什么透明度是一个如此棘手的问题。之后,我们将介绍并简要回顾过去二十年中与当前硬件相关的一些较为知名的透明技术,这些技术已被发明并使用。最后,我们将重点解释并实现其中一种技术,这将成为本文下一部分的内容。

注意,本文的目标是介绍比混合章节中使用的透明度技术性能显著更好的技术。否则,就没有真正令人信服的理由来扩展这个话题。

图形库/硬件限制

本文之所以存在,你之所以在阅读它,是因为当前技术无法直接绘制透明表面。许多人希望绘制透明表面就像在他们的图形API中打开一个开关那么简单,但那只是一个童话故事。至于这是图形库还是显卡的限制,这一点尚有争议。

正如混合章节中所解释的,这个问题的根源在于深度测试和颜色混合的结合。在片段着色器阶段,没有像深度缓冲区那样的缓冲区来处理透明像素,以告诉图形库哪些像素是完全可见或半可见的。其中一个原因可能是,没有一种有效的方法能将透明像素的信息存储在这样一个缓冲区中,而这个缓冲区需要为屏幕上的每个坐标保存无限数量的像素。由于每个透明像素都可能暴露其下方(深度靠后)的像素,因此需要一种方法来存储所有屏幕坐标的所有像素的不同层次。

这种限制让我们不得不思考如何克服这个问题。由于图形库和硬件都无法提供帮助,这一切都必须由开发者使用手头的工具来完成。我们将探讨两种在这个主题中突出的方法:一种是 有序透明 OT(Ordered Transparency),另一种是 无序透明 OIT(Order-Independent Transparency)

有序透明

克服这一问题最便捷的解决方案是:根据相机位置,将透明物体按从远到近或从近到远的顺序排序后再绘制。通过这种方式,深度测试将不会影响以下像素的最终渲染结果: 这些像素可能因绘制顺序靠后/靠前,但实际上位于更远/更近的物体的上方或下方。尽管这种方法对CPU的开销很大,但在许多我们可能玩过的早期游戏中都使用了这种方法。

例如,下面的示例图像展示了混合渲染顺序的重要性。图像的上半部分显示了无序 alpha 混合的错误结果,而下半部分正确地对几何体进行了排序。可见,如果没有正确的深度排序,骨骼结构的可见度会显著降低。此图像来自 ATI Mecha Demo:

image.png

到目前为止,我们已经明白,为了克服当前技术在绘制透明对象时的限制,我们需要对透明对象进行排序,以便它们在屏幕上正确显示。排序会降低应用程序的性能,由于大多数3D应用程序是实时运行的,每帧执行排序性能影响会更加明显。

因此,我们将探索无序透明——OIT技术的世界,寻找一种更适合我们目的且与我们的渲染管线兼容的技术,这样我们就不需要在绘制之前对对象进行排序。

无序透明

无序透明(Order-Independent Transparency,简称OIT)是一种不需要我们按照顺序绘制透明对象的技术。乍一看,这将为我们节省用于排序对象的CPU周期,但与此同时,OIT技术也有其优缺点。

OIT技术的目标是消除在绘制时对透明对象进行排序的需求。根据具体技术的不同,其中一些技术必须在后期对片段进行排序以获得准确的结果,但这是在所有绘制调用(draw call)完成后进行的;而另一些技术则不需要排序,但结果是近似的。

历史

为了克服渲染透明表面的限制而发明的一些更高级的技术,明确使用了一种缓冲区(例如链表或如 [x][y][z] 这样的3D数组),该缓冲区可以保存多层像素信息,并能在GPU上对像素进行排序,通常利用GPU的并行处理能力,而非CPU。

A-Buffer 是一种诞生于1984年的计算机图形学技术。它通过软件光栅化器(如专为抗锯齿设计的 REYES 架构)为每个像素存储片段数据列表(包括微多边形信息),最初是为抗锯齿设计的,但也可用于透明度。

与此同时,一些硬件能够通过执行硬件计算来辅助完成这项任务,这是开发者最方便的方式,可以开箱即用地实现透明度。

[SEGA Dreamcast](SEGA Dreamcast 是少数几个具有自动每个像素透明度排序功能的游戏机之一,该功能由其硬件实现。) 是少数几个具有自动对每个像素透明度排序功能的游戏机之一,该功能由其硬件实现。

通常,OIT 技术分为 精确(Exact)近似(Approximate) 两大类。精确方法会产生具有准确透明度的更好的图像,适用于所有场景,而近似方法虽然图像看起来不错,但在复杂场景中缺乏准确性。

精确 OIT

这些技术能够精确计算最终颜色,为此必须对所有片段进行排序。对于深度复杂度高的场景,排序会成为瓶颈。

排序阶段的一个问题是局部内存占用有限,在这种情况下,与GPU的吞吐量和操作延迟隐藏相关的单指令多线程属性是一个关键因素。尽管如此,反向内存分配(Backwards Memory Allocation,BMA) 可以按深度复杂度对像素进行分组并分批排序,从而在高深度复杂度场景下提升低深度复杂度像素的占用率及性能。据报道,这可使 OIT 整体性能最多可提升3倍。

排序阶段在着色器中需要相对大量的临时内存,通常会保守地分配到最大值,这会影响内存占用率和性能。

排序通常在局部数组中进行,但通过利用 GPU 的内存层次结构并在寄存器中进行排序,可以进一步提高性能,类似于外部归并排序,尤其是在与 BMA 结合使用时。

近似 OIT

近似 OIT 技术放宽了精确渲染的约束,以获得更快的计算结果。通过不必存储所有片段或仅部分排序几何体,可以获得更高的性能。一些技术还对片段数据进行压缩或减少。这些技术包括:

  • 随机透明(Stochastic Transparency):以更高的分辨率绘制完全不透明的图像,但丢弃一些片段。然后通过降采样来实现透明效果。
  • 自适应透明(Adaptive Transparency):一种两阶段技术,第一阶段构建一个可见性函数,该函数在运行时进行压缩(这种压缩避免了对片段进行完全排序),第二阶段使用这些数据合成无序片段。Intel 的像素同步技术避免了存储所有片段的需要,从而消除了许多其他 OIT 技术中无限内存需求的问题。

技术

以下是工业界中常用的一些 OIT 技术:

  • 深度剥离(Depth Peeling):2001 年提出,描述了一种硬件加速的OIT技术,通过深度缓冲区在每次渲染过程中剥离一层像素。由于早期图形硬件的限制,需多次渲染场景几何体。
  • 双重深度剥离(Dual Depth Peeling):2008年提出,优化了深度剥离的性能,但仍存在多次渲染的限制。
  • 加权混合(Weighted, Blended):于2013年发布,利用加权函数和两个缓冲区——一个用于像素颜色,一个用于像素可见性阈值——来进行最终合成。能在复杂场景中生成质量不错的近似图像。

实现

在 3D 应用程序中实现 OIT 的常用方法是进行多遍渲染。实现 OIT 技术至少需要三遍渲染,因此,为了做到这一点,你需要对 OpenGL 中的帧缓冲区有较好的理解。一旦你熟悉了帧缓冲区,一切问题就转化为了你试图实现的技术的复杂性是什么。

简单来说,涉及的三遍渲染如下:

  • 第一遍:绘制所有不透明对象,也就是任何不允许光线穿过其几何体的对象。
  • 第二遍:绘制所有半透明对象。需要丢弃 alpha 值的对象可以在第一遍中渲染。
  • 第三遍:将前两遍生成的图像进行合成,并将该图像绘制到你的后缓冲区(backbuffer)上。

在所有不同渲染管线中实现 OIT 技术,这个例程几乎是相同的。

在本文的下一部分,我们将实现 加权混合 OIT(Weighted, Blended OIT),这是过去十年中视频游戏行业中使用的最简单且具有较高性能的 OIT 技术之一。

进阶阅读

  • SEGA Dreamcast 硬件:Dreamcast 是少数几个实现了硬件级顺序无关透明度的游戏机之一。
  • OIT:一系列具有出色性能并能在近似方法下产生良好结果的技巧。
  • 加权混合 OIT:实现方面最简单的 OIT 技术之一,同时能为复杂场景生成接受度较高的图像。

Article by: Mahan Heshmati Moghaddam
Contact: e-mail(原作者的哈,不是博主)


加权混合

  • 原文链接:LearnOpenGL - Weighted Blended

前言

加权混合(Weighted, Blended) 是一种近似无序依赖的透明度技术,于 2013 年由 NVIDIA 的 Morgan McGuire 和 Louis Bavoil 在计算机图形技术杂志上发表,旨在解决当时游戏平台上广泛存在的透明度问题。

他们的方法通过改变合成算子使其与顺序无关,从而避免存储和排序图元或片段的成本,这样就可以实现一种纯粹的流式处理方式。

大多数游戏都有针对特定场景的临时方法来绕过透明表面渲染的限制。这些方法包括有限排序、仅加法混合以及硬编码的渲染和合成顺序。这些方法大多在游戏进行到某个阶段时会失效并产生视觉伪像。一种不可行的替代方案是深度剥离(Depth Peeling),它可以生成高质量的图像,但在理论和实践上对于多层场景来说都太慢。

透明渲染有许多渐进(asymptotically)快速的解决方案,例如使用可编程混合的有限 A-Buffer 近似(例如 Marco Salvi 的工作),随机透明度(Eric Enderton 等人的工作)以及光线跟踪。这些方法中有一个或多个可能会在未来占据主导地位,但在五六年前的游戏平台上,包括PC DX11/GL4 GPU、支持OpenGL ES 3.0 GPU的移动设备以及上一代主机如PlayStation 4,这些方法都不实用。

在数学分析中,渐进分析(Asymptotic Analysis),也称为渐进法(asymptotics),是一种描述极限行为的方法。

下图是使用该技术渲染的汽车引擎的透明CAD视图。

image.png

理论

该技术通过带有颜色的表面渲染非折射、单色透射效果 ,无需排序或依赖新硬件特性。事实上,只要支持每通道超过8位混合渲染目标的GPU,就可以用一个简单的着色器实现它。

在支持多渲染目标(MRT)和浮点纹理 的GPU上,其性能优于传统排序透明技术,且能避免粒子系统的排序伪像和闪烁问题。与4层深的RGBA8 K-buffer相比,其带宽消耗更低,并允许将低分辨率粒子与全分辨率表面(如玻璃)混合渲染。

在混合分辨率场景中,峰值内存消耗仍由高分辨率渲染目标决定,但带宽成本会根据低分辨率表面的占比而降低。

加权混合方法的基本思想是精确计算透明表面对背景的覆盖率,但仅近似计算透明表面本身向相机散射的光线。该算法对透明表层间的遮挡因子施加了一种启发式规则——遮挡因子随物体与摄像机的距离增加而增大

启发式技术(Heuristic)指一种解决问题或自我探索的实用方法,虽不保证最优、完美或完全合理,但足以实现短期目标或近似解。在本例中,启发式规则体现为加权函数 。

在所有透明表面渲染完成后,会执行一次全屏归一化和合成阶段,以减少因启发式规则对真实遮挡关系近似不足导致的误差。

下图是使用该技术渲染的玻璃棋子。注意,棋子本身未折射任何光线。

image.png

若需深入理解加权函数的技术细节,请参考原始论文第 5-7 页(论文链接见本文末)。加权混合 OIT 技术自提出以来,已通过多种方法改进并实现。

局限性

该技术的主要局限性在于,加权启发式规则必须针对预期的深度范围和透明表面的不透明度进行调整。

该技术已在 OpenGL 的 G3D 创新引擎和 DirectX 的虚幻引擎中实现,用于实时演示及论文中的效果呈现。Dan Bagnell 和 Patrick Cozzi 在他们的开源Cesium 引擎中用 WebGL 实现了它(详见他们的相关技术博客)。

通过上述实践,研究者总结出一组效果良好的加权函数(详见期刊论文)。论文还讨论了如何识别并修复因权重函数调整不当而导致的瑕疵。

此外,目前尚未找到在延迟渲染器中合理实现该技术的方案。由于延迟渲染中像素会互相覆盖,从而导致前几层信息丢失,因此无法正确累积(accumulate)用于光照阶段的颜色值。

一种可行的解决方案是:沿用前向渲染的透明渲染阶段逻辑,将其直接整合到延迟渲染管线中。这相当于从前向渲染中“借用”透明处理流程,并嵌入到延迟渲染架构里。

实现

该技术的实现非常直接,且着色器的修改非常简单。如果熟悉OpenGL中帧缓冲区(Framebuffer)的工作原理,你几乎已经完成了一半的工作。

唯一需注意的是 :必须使用OpenGL 4.0及以上版本才能支持多渲染目标混合(例如使用 glBlendFunci 函数)。在论文中,作者还讨论了针对不支持多目标渲染或混合的图形库的替代实现方案。

初始化 GLFW 时别忘了更改你的 OpenGL 版本,同样也要更改着色器中的 GLSL 版本。

概述

在透明表面渲染阶段,按常规方式对表面进行着色,但需输出到两个渲染目标。第一个渲染目标(accum ——累积值)需至少为 RGBA16F 精度,第二个渲染目标(revealage ——可见性)需至少为 R8 精度。初始化时,将第一个渲染目标清空为 vec4(0),第二个渲染目标清空为 1(可通过片段着色器或 glClearBuffer + glClear 实现)。

随后,以任意顺序渲染表面到这两个渲染目标,并在片段着色器末尾添加以下代码,同时使用指定的混合模式:

// 第一个渲染目标:用于累积预乘颜色值
layout (location = 0) out vec4 accum;

// 第二个渲染目标:用于存储像素的可见性(revealage)值
layout (location = 1) out float reveal;

...

// 输出线性颜色(非伽马编码!),且为非预乘格式
vec4 color = ... // 常规着色代码

// 插入你选择的加权函数。
// 基于颜色的因子避免薄雾状云边缘颜色污染,
// 基于深度的因子优先处理更近的表面
float weight =
    max(min(1.0, max(max(color.r, color.g), color.b) * color.a), color.a) *
    clamp(0.03 / (1e-5 + pow(z / 200, 4.0)), 1e-2, 3e3);

// 混合模式:GL_ONE, GL_ONE
// 切换为预乘Alpha并应用权重
accum = vec4(color.rgb * color.a, color.a) * weight;

// 混合模式:GL_ZERO, GL_ONE_MINUS_SRC_ALPHA
reveal = color.a;

在所有表面渲染完成后,使用全屏渲染将结果进行合成:

// 绑定第一个渲染目标到此纹理单元
layout (binding = 0) uniform sampler2D rt0;

// 绑定第二个渲染目标到此纹理单元
layout (binding = 1) uniform sampler2D rt1;

// 着色器输出
out vec4 color;

// 采样像素信息
vec4 accum = texelFetch(rt0, int2(gl_FragCoord.xy), 0);
float reveal = texelFetch(rt1, int2(gl_FragCoord.xy), 0).r;

// 混合模式:GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA
color = vec4(accum.rgb / max(accum.a, 1e-5), reveal);

使用下表作为渲染目标的参考:

Render TargetFormatClearSrc BlendDst BlendWrite (“Src”)
accumRGBA16F(0,0,0,0)ONEONE(r*a, g*a, b*a, a) * w
revealageR8(1,0,0,0)ZEROONE_MINUS_SRC_COLORa

总共需要三个渲染过程来完成下面的最终结果:

image.png

详情

实现之初,我们需要为不透明和透明表面设置一个四边形。红色四边形将是不透明的,而绿色和蓝色四边形将是透明的。由于我们也将使用相同的四边形作为屏幕四边形,这里我们定义了 UV 值,以便进行纹理映射。

float quadVertices[] = {
    // 位置              // UV坐标
    -1.0f, -1.0f, 0.0f,  0.0f, 0.0f,
     1.0f, -1.0f, 0.0f,  1.0f, 0.0f,
     1.0f,  1.0f, 0.0f,  1.0f, 1.0f,

     1.0f,  1.0f, 0.0f,  1.0f, 1.0f,
    -1.0f,  1.0f, 0.0f,  0.0f, 1.0f,
    -1.0f, -1.0f, 0.0f,  0.0f, 0.0f
};

// 四边形VAO配置
unsigned int quadVAO, quadVBO;
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glBindVertexArray(0);

接下来,我们将为不透明和透明通道创建两个帧缓冲区。

  1. 不透明通道 FBO :包含颜色缓冲(存储颜色)和深度缓冲。
  2. 透明通道 FBO :包含两个颜色缓冲(分别存储颜色累积值和像素可见性阈值)。

透明 FBO 需共享不透明 FBO 的深度纹理,以便透明物体渲染时进行深度测试。

// 创建帧缓冲区
unsigned int opaqueFBO, transparentFBO;
glGenFramebuffers(1, &opaqueFBO);
glGenFramebuffers(1, &transparentFBO);

// 配置不透明FBO的附件
unsigned int opaqueTexture;
glGenTextures(1, &opaqueTexture);
glBindTexture(GL_TEXTURE_2D, opaqueTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_HALF_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

unsigned int depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT,
             0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// 配置透明FBO的附件
unsigned int accumTexture;
glGenTextures(1, &accumTexture);
glBindTexture(GL_TEXTURE_2D, accumTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_HALF_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

unsigned int revealTexture;
glGenTextures(1, &revealTexture);
glBindTexture(GL_TEXTURE_2D, revealTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, SCR_WIDTH, SCR_HEIGHT, 0, GL_RED, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// 不要忘记明确告诉OpenGL你的透明帧缓冲区有两个绘制缓冲区
const GLenum transparentDrawBuffers[] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, transparentDrawBuffers);

为了便于理解,在本文中我们创建了两个独立的 FBO,实际开发中可优化为:

  • 省略不透明 FBO,直接使用后缓冲区(backbuffer)。
  • 创建单一 FBO 并附加4个纹理(不透明颜色、累积颜色、可见性、深度),通过切换渲染目标实现多通道渲染。

在渲染之前,为你的四边形设置一些模型矩阵。你可以随意设置 Z 轴,因为这是一个无序技术,物体离相机较近或较远都不会造成问题。

glm::mat4 redModelMat = calculate_model_matrix(glm::vec3(0.0f, 0.0f, 0.0f)); // 红色四边形(不透明)
glm::mat4 greenModelMat = calculate_model_matrix(glm::vec3(0.0f, 0.0f, 1.0f)); // 绿色四边形(透明)
glm::mat4 blueModelMat = calculate_model_matrix(glm::vec3(0.0f, 0.0f, 2.0f)); // 蓝色四边形(透明)

此时,我们需要进行不透明物体的渲染阶段。配置渲染状态并绑定不透明帧缓冲区:

// 配置渲染状态
glEnable(GL_DEPTH_TEST);      // 启用深度测试
glDepthFunc(GL_LESS);         // 深度函数:通过更近的片段
glDepthMask(GL_TRUE);         // 允许写入深度缓冲区
glDisable(GL_BLEND);          // 禁用混合
glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清屏颜色

// 绑定不透明FBO并清空缓冲区
glBindFramebuffer(GL_FRAMEBUFFER, opaqueFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

由于渲染管线会在后续步骤中更改这些状态,我们必须每帧都重置深度函数和深度掩码。

现在,使用不透明着色器绘制不透明对象。你可以在此阶段和下一阶段都绘制带有 alpha 裁剪的对象。不透明着色器是一个简单的着色器,仅变换顶点并使用提供的颜色绘制网格:

// 使用不透明着色器
solidShader.use();

// 绘制红色四边形
solidShader.setMat4("mvp", vp * redModelMat);
solidShader.setVec3("color", glm::vec3(1.0f, 0.0f, 0.0f));
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);// 绘制6个顶点(2个三角形)

到目前为止一切顺利。对于透明物体渲染阶段,与不透明阶段类似,配置渲染状态以混合到以下渲染目标,然后绑定透明帧缓冲区并将其两个颜色缓冲区清为 vec4(0.0f)vec4(1.0)

// 配置渲染状态
glDepthMask(GL_FALSE);        // 禁止写入深度缓冲区(避免覆盖不透明阶段的深度值)
glEnable(GL_BLEND);           // 启用混合
glBlendFunci(0, GL_ONE, GL_ONE); // accum(累积)缓冲区混合模式:加法
glBlendFunci(1, GL_ZERO, GL_ONE_MINUS_SRC_COLOR); // revealage(可见性)缓冲区混合模式
glBlendEquation(GL_FUNC_ADD); // 混合方程:加法

// 绑定透明FBO并清空两个颜色缓冲区
glBindFramebuffer(GL_FRAMEBUFFER, transparentFBO);
glClearBufferfv(GL_COLOR, 0, &zeroFillerVec[0]); // accum初始化为vec4(0)
glClearBufferfv(GL_COLOR, 1, &oneFillerVec[0]);  // reveal初始化为1.0

然后,使用你喜欢的 alpha 值绘制透明表面:

// 使用透明着色器
transparentShader.use();

// 绘制绿色四边形
transparentShader.setMat4("mvp", vp * greenModelMat);
transparentShader.setVec4("color", glm::vec4(0.0f, 1.0f, 0.0f, 0.5f));
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);

// 绘制蓝色四边形
transparentShader.setMat4("mvp", vp * blueModelMat);
transparentShader.setVec4("color", glm::vec4(0.0f, 0.0f, 1.0f, 0.5f));
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);

透明着色器完成了大约一半的工作。它的主要工作为收集像素信息以供合成阶段使用:

layout (location = 0) out vec4 accum;  // 输出到累积缓冲区
layout (location = 1) out float reveal; // 输出到可见性缓冲区

uniform vec4 color; // 传入的材质颜色

void main() {
    // 加权函数:结合透明度和深度
    float weight = clamp(
        pow(min(1.0, color.a * 10.0) + 0.01, 3.0) * 1e8 * 
        pow(1.0 - gl_FragCoord.z * 0.9, 3.0), 
        1e-2, 3e3
    );

    // 存储像素颜色累积
    accum = vec4(color.rgb * color.a, color.a) * weight;
    // 存储像素可见性阈值
    reveal = color.a;
}

注意,我们直接使用传递给着色器的颜色作为最终片段颜色。通常,如果你在光照着色器中,你应该使用光照的最终结果存储到累积和可见性渲染目标中。

现在一切都已经渲染完毕,我们需要合成这两张图像,以便得到最终结果。

合成是许多技术中常用的方法,使用覆盖整个屏幕的后处理四边形。将其想象为在 Photoshop 或 Gimp 等照片编辑软件中合并两个图层。

在 OpenGL 中,我们可以通过颜色混合功能实现这一点:

// 设置渲染状态
glDepthFunc(GL_ALWAYS);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

// 绑定不透明FBO作为合成目标
glBindFramebuffer(GL_FRAMEBUFFER, opaqueFBO);

// 使用合成着色器
compositeShader.use();

// 绘制屏幕四边形
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, accumTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, revealTexture);
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);

合成着色器是完成另一半工作的地方。我们的工作基本上是将两个图层合并,一个是不透明对象图像,另一个是透明对象图像。累积缓冲区告诉我们颜色信息,可见性缓冲区决定下方像素的可见性:

// 着色器输出
layout (location = 0) out vec4 frag;
// 颜色累积缓冲区
layout (binding = 0) uniform sampler2D accum;
// 可见性阈值缓冲区
layout (binding = 1) uniform sampler2D reveal;

// 极小值
const float EPSILON = 0.00001f;

// 精确计算浮点数的相等性
bool isApproximatelyEqual(float a, float b)
{
    return abs(a - b) <= (abs(a) < abs(b) ? abs(b) : abs(a)) * EPSILON;
}

// 获取三个值中的最大值
float max3(vec3 v)
{
    return max(max(v.x, v.y), v.z);
}

void main()
{
    // 片段坐标
    ivec2 coords = ivec2(gl_FragCoord.xy);

    // 片段可见性
    float revealage = texelFetch(reveal, coords, 0).r;

    // 如果没有透明片段,则节省混合和颜色纹理获取成本
    if (isApproximatelyEqual(revealage, 1.0f))
        discard;

    // 片段颜色
    vec4 accumulation = texelFetch(accum, coords, 0);

    // 抑制溢出
    if (isinf(max3(abs(accumulation.rgb))))
        accumulation.rgb = vec3(accumulation.a);

    // 防止浮点精度错误
    vec3 average_color = accumulation.rgb / max(accumulation.a, EPSILON);

    // 混合像素
    frag = vec4(average_color, 1.0f - revealage);
}

注意,我们使用了一些辅助函数,如 isApproximatelyEqualmax3,以帮助我们精确计算浮点数。由于当前一代处理器在浮点数计算上的不精确性,我们需要使用一个极小的值(称为 epsilon)来比较值,以避免下溢或溢出。

此外,我们不需要中间帧缓冲区来进行合成。我们可以使用不透明帧缓冲区作为基础帧缓冲区并在其上绘制,因为它已经包含了不透明通道的信息。而且,我们声明所有深度测试都应通过,因为我们希望在不透明图像上绘制。

最后,将合成的图像(即不透明纹理附件,因为你在最后一遍中在其上渲染了透明图像)绘制到后缓冲区并观察结果:

// 设置渲染状态
glDisable(GL_DEPTH);
glDepthMask(GL_TRUE); // 启用深度写入,以免 glClear 忽略清除深度缓冲区
glDisable(GL_BLEND);

// 绑定后缓冲区
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

// 使用屏幕着色器
screenShader.use();

// 绘制最终屏幕四边形
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, opaqueTexture);
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);

屏幕着色器只是一个简单的后处理着色器,用于绘制全屏四边形。

在常规渲染管线中,你还会在渲染到后缓冲区之前,在中间后处理帧缓冲区中应用伽马校正、色调映射等。但要确保在渲染不透明和透明表面时,以及在合成之前不应用这些处理,因为此透明技术需要原始颜色值来计算透明像素。

现在,你可以调整对象的 Z 轴,以观察无序透明的效果。尝试将透明对象放置在不透明对象后面,或完全打乱顺序。

image.png

在上图中,绿色四边形位于红色四边形后方,但绿色四边形在红色四边形之后才被渲染。如果移动相机从后面观察绿色四边形,你将不会看到任何伪像。

如前所述,该技术的一个局限性是,对于深度或 alpha 复杂度较高的场景,我们需要调整加权函数以获得正确的结果。幸运的是,论文中提供了一些经过测试的权重函数,你可以参考并测试它们以适应你的环境。

另请查看下面链接中的彩色透射透明(colored transmission transparency)技术,这是该技术的改进版本。

您可以在此处找到此演示的源代码。(博主源码:OIT - GitCode)


ke lin 在讨论区分享了实例化 + OIT 渲染数十万窗口的成果(令人惊叹),原作者指出了一个值得注意的地方:

Some objects have opaque and transparent surfaces mixed as one so you have to render them separately. All the other major renderers do it the same way too.(有些物体表面既有不透明的也有透明的部分,所以你必须分别渲染它们。)

建议大家去原文讨论区查看。

本篇教程也并没有对加权混合 OIT 的原理进行深入讲解,如原文所述:若需深入理解加权函数的技术细节,请参考原始论文第 5-7 页


进阶阅读

  • Weighted, Blended paper:发表于《计算机图形学杂志》的原始论文,简要回顾了透明渲染技术的发展历史及该技术的诞生背景。对于希望深入理解的读者,此论文为必读材料。
  • Weighted, Blended introduction:Casual Effects 是 Morgan McGuire 的个人博客。这篇帖子介绍了他们的技术,并深入探讨了更多细节,绝对值得一读。此外,还有他们实现技术的现场视频,你绝对不想错过。
  • Weighted, Blended for implementors:Morgan McGuire实现该技术的另一篇博客文章。
  • Weighted, Blended and colored transmission:探讨如何将加权混合技术扩展到彩色透射(Colored Transmission)场景,例如彩色玻璃的渲染。
  • A live implementation of the technique:Cesium引擎提供的WebGL实时演示工具,允许用户在浏览器中直接测试不同加权函数的效果。

Article by: Mahan Heshmati Moghaddam
Contact: e-mail(原作者的哈,不是博主)

相关文章:

  • QT6(12)3.3.1 Qt元对象系统概述:QObject 类与 QMetaObject 类,类型转换 qobject_cast<T>()。
  • 医疗机构中核心业务相关的IT设备全面解析
  • UI自动化基础(1)
  • 文件中魔数
  • Docker与VNC的使用
  • Spring MVC 数据绑定教程
  • nginx配置oss代理
  • [环境配置] 2. 依赖库安装
  • Linux-CentOS-7—— 配置yum源(网络yum源 + 本地yum源)
  • RabbitMQ安装与使用教程(含Spring Boot整合)
  • HTTP Form v.s. Flask-WTF Form v.s. Bootstrap Form
  • Ollama
  • 项目实战--路由权限
  • OpenCV 图形API(20)用于执行标量与矩阵之间的逐元素减法操作函数subRC()
  • Dify的基本功能介绍与界面初识
  • 当实体类中的属性名和表中的字段名不一样 ,怎么办
  • Comfyui 一键下载模型(多线程)
  • COMSOL固体力学接触
  • LLM面试题七
  • 2024年RAG大赛
  • 网站百度商桥/南宁企业官网seo
  • 石家庄网站建设找哪家/西安网络推广外包公司
  • 中国最大的做网站的公司/软文营销代理
  • 开网站建设工作是如何/广州网站优化公司
  • 公司做网站发生的费用分录/国外浏览器搜索引擎入口
  • 深圳网站设计九曲/qq推广引流网站