OpenGL笔记:Gamma校正

1,151 阅读6分钟

前言

写这篇文章是因为在跟着learnOpenGL学习的过程中觉得,在Gamma校正这一节很多东西没有完全理解,就花了一些时间查资料自我总结,最后算是有点心得,用来做一个汇总。

什么是Gamma校正

你以为的亮度

想象一下,你用一台相机拍了一张照片,照片以RGB形式存在你的SD卡里,然后你可以连接其他设备,在电脑或者手机上面查看这张图片,很好,跟你拍摄的一致。 看到这里你是否认为这个过程非常简单,只需要把相机采集到的RGB值直接交给显示器显示就可以了?

其实并非如此,有两个可能有违常识的问题:

  1. 图像通常不会以它真实的RGB值进行存储,同样大多数显示器不会直接根据输入的图像原本的RGB值来显示对应通道的亮度
  2. 由于人眼对暗部变化感知更明显,所以需要更多的空间来存储暗部

人眼对于亮度的感知

物理上的亮度是根据单位面积光子的数量定义的,1-100的物理亮度就是严格的线性递增;而人眼并不是这样,人眼通过“对比”来感受亮度的变化,0-1的变化能被人很明显地感受到,但是100-101的变化就不行了,所以说人眼感知的亮度和物理亮度必然是不对等的(根据韦伯定律),它们的关系大概可以拟合为下面这样,幂函数的幂一般在1.8-2.5之间,图中为2.2。

image.png

如此一来,如果我们直接将图片RGB以物理亮度保存,那么大概物理亮度的0.2就代表了心理上0.5的亮度,这样一来暗部只能占0-0.2这一小范围,如果图片按照每通道8bit方式存储,那么暗部只能占到大概50个阶,这样会导致偏暗的图像丢失很多细节。为了有效利用有限的空间,存储心理上的亮度值是更好的做法,这样能让亮部暗部各占一半;由于物理亮度和视觉亮度是幂函数关系,我们将幂记为编码Gamma,以此对采集到的物理亮度进行编码。

显示器的Gamma值

前面说到为了有效利用空间,我们存储心理上的亮度值,但是显示器可不是人,它只认识严格的物理量,所以我们将图片交由显示器显示时,需要再次将亮度值转换为物理亮度,转换用的幂值记为解码Gamma

有趣的事情来了,最早的CRT显示器的输入电压和显示亮度的关系也不是线性的,他们的关系大概像下面这样,这个函数的幂大概在2.0-3.0之间。

image.png

可以发现,两个函数曲线是近似对称的,那就可以约定一个相同的幂值来统合编码和解码Gamma,这个值就是常见的2.2,存储编码时使用1/2.2作为幂,然后直接输入给显示器(显示器内部以2.2为幂解码),就可以达到比较理想的效果。虽然现代液晶显示器已经没有了早期CRT显示器的问题,但是为了兼容,一般也会设有2.2左右的默认Gamma值。

image.png

现在你应该知道一张图片从拍摄到在电脑显示具体有哪些过程了

image.png

OpenGL中的Gamma校正

OpenGL中为什么需要Gamma校正

经过前面内容大家应该能知道,我们日常中使用的大部分图片(比如sRGB标准)都是经过Gamma编码的,存储的是经过非线性映射的RGB值,那么就存在一个问题,如果我们将这种图片用作纹理贴图,然后需要对他们进行一些比如光照计算、模糊等处理的时候,如果我们直接用存储的RGB值来计算,就会出现错误的效果,因为做这些计算时我们依据的是真实物理量,但是存在图片中的RGB并不是

所以我们需要先将图片中的经过编码后的RGB值先解码真实物理值,然后根据需要对它们做计算处理,一切完成以后再将结果数据重新编码。

例子对比

learnOpenGL中用来对比的例子可能还是会让你有点怀疑,下面两张图片,凭什么说gamma校正之后的那张才是正确的呢,也许有的人觉得左边的更好!

image.png

那我们不妨换一个对照组,我这里用下面这张两色图片做一个均值模糊,你觉得处理后它应该是什么样子?

mix.png

让我们来编写代码试一试,完整代码在这里

BlurGamma.cpp

void BlurGamma::init() {
    // 隐藏鼠标光标
    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
	
    float vertices[] = {
        -1.0f, -1.0f,  0.0f, 0.0f,
        1.0f, -1.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,
        -1.0f, 1.0f,   0.0f, 1.0f
    };

    // 默认缓冲的四边形
    float rectVertices[] = {
        -1.0f,  1.0f,  0.0f, 1.0f,
        -1.0f, -1.0f,  0.0f, 0.0f,
         1.0f, -1.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
    };
    screenShader = Shader(FileSystem::getPath("shaders/advancedLighting/blurGamma/screen.vs").c_str(), FileSystem::getPath("shaders/advancedLighting/blurGamma/screen.fs").c_str());
    objectShader = Shader(FileSystem::getPath("shaders/advancedLighting/blurGamma/object.vs").c_str(), FileSystem::getPath("shaders/advancedLighting/blurGamma/object.fs").c_str());
    normalTexture = loadTexture(FileSystem::getPath("assets/texture/mix.png").c_str(), GL_CLAMP_TO_EDGE);
    // gamma解码后的
    gammaTexture = loadTexture(FileSystem::getPath("assets/texture/mix.png").c_str(), GL_CLAMP_TO_EDGE, true);

    // 反向
    // normalTexture = loadTexture(FileSystem::getPath("assets/texture/mix_reverse.png").c_str(), GL_CLAMP_TO_EDGE);
    // gammaTexture = loadTexture(FileSystem::getPath("assets/texture/mix_reverse.png").c_str(), GL_CLAMP_TO_EDGE, true);

    glGenVertexArrays(1, &containerVAO);
    glGenBuffers(1, &containerVBO);

    glBindVertexArray(containerVAO);
    glBindBuffer(GL_ARRAY_BUFFER, containerVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), 0);
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)(2 * sizeof(float)));
    glBindVertexArray(0);

    screenShader.use();
    screenShader.setInt("screenTexture", 0);
}

void BlurGamma::onCreate() {
	init();
}


void BlurGamma::onProcessInput(float deltaTime) {
    super::onProcessInput(deltaTime);
    if (glfwGetKey(window, GLFW_KEY_SPACE) == GLFW_PRESS) {
    if (!pressed) {
            pressed = true;
            gamma = !gamma;
        }
    }

    if (glfwGetKey(window, GLFW_KEY_SPACE) == GLFW_RELEASE) {
        pressed = false;
    }
}

void BlurGamma::onRender() {
    glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    screenShader.use();
    screenShader.setBool("gamma", gamma);
    glBindVertexArray(containerVAO);
    // 直接把绘制好的纹理绑定到默认缓冲
    glBindTexture(GL_TEXTURE_2D, gamma ? gammaTexture : normalTexture);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

void BlurGamma::onDestroy() {
}
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
    TexCoords = aTexCoords;
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); 
}  
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;
uniform bool gamma;

uniform sampler2D screenTexture;


void main()
{
    const float offset = 1 / 600.0;
    // 用50*50的核做均值模糊
    const int col = 50;
    const int size = col * col;
    vec3 color = vec3(0.0);
    for(int i = 0; i < col; i++) {
        for(int j = 0; j < col; j++) {
            // 偏移采样
            vec3 peice = texture(screenTexture, vec2(TexCoords.s + (i-col/2)*offset, TexCoords.t - (j-col/2)*offset)).rgb;
            color += (peice/vec3(size));
        }
    }
    if(gamma) {
       color = pow(color, vec3(1.0/2.2));    
    }
    FragColor = vec4(color, 1.0);
}

很简单,我们只是用了一个50*50(如果你的显卡很差,不妨把这个值调低)的核对这张图片做了一次均值模糊,不校正/校正的对比如下

未进行Gamma校正 image.png

进行了Gamma校正 image.png

差别很明显,正常来讲经过模糊以后,红绿两色分隔线那个部分应该是均匀过度的,但是第一张图却表现出明显的灰黑色,这是不正确的。

也许你可能会说第二张图左边部分的过度也比较突兀,其实这是因为我们的均匀模糊做的比较简单,相当于只是用由左到右做了一次横向的扫描,所以这是正常的,你可以把两个颜色倒过来试试。

至此相信你已经理解了Gamma校正的问题。