35

如果线性插值发生在 OpenGL 管道的光栅化阶段,并且顶点已经转换到屏幕空间,那么用于透视正确插值的深度信息来自哪里?

任何人都可以详细描述OpenGL如何从屏幕空间基元到具有正确插值的片段?

4

2 回答 2

59

顶点着色器的输出是一个分量向量vec4 gl_Position。从核心 GL 4.4 规范的第 13.6 节坐标转换:

着色器执行的顶点结果的剪辑坐标gl_Position,它产生一个顶点坐标。

剪辑坐标的透视分割产生标准化的设备坐标,然后是视口变换(参见第 13.6.1 节)将这些坐标转换为窗口坐标

OpenGL将透视划分为

device.xyz = gl_Position.xyz / gl_Position.w

但随后将 保留1 / gl_Position.w为 的最后一个组成部分gl_FragCoord

gl_FragCoord.xyz = device.xyz scaled to viewport
gl_FragCoord.w = 1 / gl_Position.w

这种变换是双射的,因此不会丢失深度信息。事实上,正如我们在下面看到的,1 / gl_Position.w对于透视正确插值至关重要。


重心坐标简介

给定一个三角形(P0,P1,P2),可以通过顶点的线性组合参数化三角形内的所有点:

P(b0,b1,b2) = P0*b0 + P1*b1 + P2*b2

其中 b0 + b1 + b2 = 1 且 b0 ≥ 0,b1 ≥ 0,b2 ≥ 0。

给定三角形内的一个点 P,满足上述方程的系数 (b0, b1, b2) 称为该点的重心坐标。对于非退化三角形,它们是唯一的,可以计算为以下三角形面积的商:

b0(P) = area(P, P1, P2) / area(P0, P1, P2)
b1(P) = area(P0, P, P2) / area(P0, P1, P2)
b2(P) = area(P0, P1, P) / area(P0, P1, P2)

每个 bi 都可以被认为是“必须混合多少 Pi”。所以 b = (1,0,0), (0,1,0) 和 (0,0,1) 是三角形的顶点,(1/3, 1/3, 1/3) 是重心,等等。

给定三角形顶点上的属性 (f0, f1, f2),我们现在可以将其插值到内部:

f(P) = f0*b0(P) + f1*b1(P) + f2*b2(P)

这是 P 的线性函数,因此它是给定三角形上的唯一线性插值。数学也适用于 2D 或 3D。

透视正确插值

假设我们在屏幕上填充了一个投影的 2D 三角形。对于每个片段,我们都有它的窗口坐标。首先我们通过反转函数来计算它的重心坐标P(b0,b1,b2),它是窗口坐标中的线性函数。这为我们提供了二维三角形投影上片段的重心坐标。

属性的透视正确插值将在剪辑坐标(以及扩展的世界坐标)中线性变化。为此,我们需要获取片段在剪辑空间中的重心坐标。

碰巧(见[1][2]),片段的深度在窗口坐标中不是线性的,但深度倒数1/gl_Position.w)是。因此,当由深度倒数加权时,属性和剪辑空间重心坐标在窗口坐标中线性变化。

因此,我们通过以下方式计算透视校正重心:

     ( b0 / gl_Position[0].w, b1 / gl_Position[1].w, b2 / gl_Position[2].w )
B = -------------------------------------------------------------------------
      b0 / gl_Position[0].w + b1 / gl_Position[1].w + b2 / gl_Position[2].w

然后用它来插入顶点的属性。

注意: GL_NV_fragment_shader_barycentric 通过暴露设备线性重心坐标gl_BaryCoordNoPerspNV并通过 校正透视gl_BaryCoordNV

执行

这是一个 C++ 代码,它以类似于 OpenGL 的方式在 CPU 上对三角形进行光栅化和着色。我鼓励您将其与下面列出的着色器进行比较:

struct Renderbuffer {
    int w, h, ys;
    void *data;
};

struct Vert {
    vec4f position;
    vec4f texcoord;
    vec4f color;
};

struct Varying {
    vec4f texcoord;
    vec4f color;
};

void vertex_shader(const Vert &in, vec4f &gl_Position, Varying &out)
{
    out.texcoord = in.texcoord;
    out.color = in.color;
    gl_Position = { in.position[0], in.position[1], -2*in.position[2] - 2*in.position[3], -in.position[2] };
}

void fragment_shader(vec4f &gl_FragCoord, const Varying &in, vec4f &out)
{
    out = in.color;
    vec2f wrapped = vec2f(in.texcoord - floor(in.texcoord));
    bool brighter = (wrapped[0] < 0.5) != (wrapped[1] < 0.5);
    if(!brighter)
        (vec3f&)out = 0.5f*(vec3f&)out;
}

void store_color(Renderbuffer &buf, int x, int y, const vec4f &c)
{
    // can do alpha composition here
    uint8_t *p = (uint8_t*)buf.data + buf.ys*(buf.h - y - 1) + 4*x;
    p[0] = linear_to_srgb8(c[0]);
    p[1] = linear_to_srgb8(c[1]);
    p[2] = linear_to_srgb8(c[2]);
    p[3] = lround(c[3]*255);
}

void draw_triangle(Renderbuffer &color_attachment, const box2f &viewport, const Vert *verts)
{
    Varying perVertex[3];
    vec4f gl_Position[3];
    
    box2f aabbf = { viewport.hi, viewport.lo };
    for(int i = 0; i < 3; ++i)
    {
        // invoke the vertex shader
        vertex_shader(verts[i], gl_Position[i], perVertex[i]);

        // convert to device coordinates by perspective division
        gl_Position[i][3] = 1/gl_Position[i][3];
        gl_Position[i][0] *= gl_Position[i][3];
        gl_Position[i][1] *= gl_Position[i][3];
        gl_Position[i][2] *= gl_Position[i][3];
        
        // convert to window coordinates
        auto &pos2 = (vec2f&)gl_Position[i];
        pos2 = mix(viewport.lo, viewport.hi, 0.5f*(pos2 + vec2f(1)));
        aabbf = join(aabbf, (const vec2f&)gl_Position[i]);
    }

    // precompute the affine transform from fragment coordinates to barycentric coordinates
    const float denom = 1/((gl_Position[0][0] - gl_Position[2][0])*(gl_Position[1][1] - gl_Position[0][1]) - (gl_Position[0][0] - gl_Position[1][0])*(gl_Position[2][1] - gl_Position[0][1]));
    const vec3f barycentric_d0 = denom*vec3f( gl_Position[1][1] - gl_Position[2][1], gl_Position[2][1] - gl_Position[0][1], gl_Position[0][1] - gl_Position[1][1] );
    const vec3f barycentric_d1 = denom*vec3f( gl_Position[2][0] - gl_Position[1][0], gl_Position[0][0] - gl_Position[2][0], gl_Position[1][0] - gl_Position[0][0] );
    const vec3f barycentric_0 = denom*vec3f(
        gl_Position[1][0]*gl_Position[2][1] - gl_Position[2][0]*gl_Position[1][1],
        gl_Position[2][0]*gl_Position[0][1] - gl_Position[0][0]*gl_Position[2][1],
        gl_Position[0][0]*gl_Position[1][1] - gl_Position[1][0]*gl_Position[0][1]
    );

    // loop over all pixels in the rectangle bounding the triangle
    const box2i aabb = lround(aabbf);
    for(int y = aabb.lo[1]; y < aabb.hi[1]; ++y)
    for(int x = aabb.lo[0]; x < aabb.hi[0]; ++x)
    {
        vec4f gl_FragCoord;
        gl_FragCoord[0] = x + 0.5;
        gl_FragCoord[1] = y + 0.5;

        // fragment barycentric coordinates in window coordinates
        const vec3f barycentric = gl_FragCoord[0]*barycentric_d0 + gl_FragCoord[1]*barycentric_d1 + barycentric_0;

        // discard fragment outside the triangle. this doesn't handle edges correctly.
        if(barycentric[0] < 0 || barycentric[1] < 0 || barycentric[2] < 0)
            continue;
        
        // interpolate inverse depth linearly
        gl_FragCoord[2] = dot(barycentric, vec3f(gl_Position[0][2], gl_Position[1][2], gl_Position[2][2]));
        gl_FragCoord[3] = dot(barycentric, vec3f(gl_Position[0][3], gl_Position[1][3], gl_Position[2][3]));

        // clip fragments to the near/far planes (as if by GL_ZERO_TO_ONE)
        if(gl_FragCoord[2] < 0 || gl_FragCoord[2] > 1)
            continue;

        // convert to perspective correct (clip-space) barycentric
        const vec3f perspective = 1/gl_FragCoord[3]*barycentric*vec3f(gl_Position[0][3], gl_Position[1][3], gl_Position[2][3]);

        // interpolate the attributes using the perspective correct barycentric
        Varying varying;
        for(int i = 0; i < sizeof(Varying)/sizeof(float); ++i)
            ((float*)&varying)[i] = dot(perspective, vec3f(
                ((const float*)&perVertex[0])[i],
                ((const float*)&perVertex[1])[i],
                ((const float*)&perVertex[2])[i] 
            ));

        // invoke the fragment shader and store the result
        vec4f color;
        fragment_shader(gl_FragCoord, varying, color);
        store_color(color_attachment, x, y, color);
    }
}

int main()
{
    Renderbuffer buffer = { 512, 512, 512*4 };
    buffer.data = calloc(buffer.ys, buffer.h);

    // interleaved attributes buffer
    Vert verts[] = {
        { { -1, -1, -2, 1 }, { 0, 0, 0, 1 }, { 0, 0, 1, 1 } },
        { { 1, -1, -1, 1 }, { 10, 0, 0, 1 }, { 1, 0, 0, 1 } },
        { { 0, 1, -1, 1 }, { 0, 10, 0, 1 }, { 0, 1, 0, 1 } },
    };

    box2f viewport = { 0, 0, buffer.w, buffer.h };
    draw_triangle(buffer, viewport, verts);

    stbi_write_png("out.png", buffer.w, buffer.h, 4, buffer.data, buffer.ys);
}

OpenGL着色器

以下是用于生成参考图像的 OpenGL 着色器。

顶点着色器:

#version 450 core
layout(location = 0) in vec4 position;
layout(location = 1) in vec4 texcoord;
layout(location = 2) in vec4 color;

out gl_PerVertex {
    vec4 gl_Position;
};

layout(location = 0) out PerVertex {
    vec4 texcoord;
    vec4 color;
} OUT;

void main() {
    OUT.texcoord = texcoord;
    OUT.color = color;
    gl_Position = vec4(position[0], position[1], -2*position[2] - 2*position[3], -position[2]);
}

片段着色器:

#version 450 core
layout(location = 0) in PerVertex {
    vec4 texcoord;
    vec4 color;
} IN;
layout(location = 0) out vec4 OUT;

void main() {
    OUT = IN.color;
    vec2 wrapped = fract(IN.texcoord.xy);
    bool brighter = (wrapped[0] < 0.5) != (wrapped[1] < 0.5);
    if(!brighter)
        OUT.rgb *= 0.5;
}

结果

以下是由 C++(左)和 OpenGL(右)代码生成的几乎相同的图像:

差异是由不同的精度和舍入模式引起的。

为了比较,这是一个不正确的透视图(在上面的代码中使用barycentric而不是perspective插值):

于 2014-06-27T21:17:47.340 回答
22

您将在GL 规范中找到的公式(参见第 427 页;链接是当前的 4.4 规范,但一直都是这样),用于三角形中属性值的透视校正插值:

   a * f_a / w_a   +   b * f_b / w_b   +  c * f_c / w_c
f=-----------------------------------------------------
      a / w_a      +      b / w_b      +     c / w_c

其中表示我们正在为 ( )a,b,c插值的三角形中点的重心坐标、顶点 的属性值和顶点的剪辑空间坐标。请注意,仅针对三角形窗口空间坐标的 2D 投影计算重心坐标(因此忽略 z)。a,b,c >=0, a+b+c = 1f_iiw_iwi

这就是 ybungalowbill 在他的好答案中给出的公式归结为,在一般情况下,具有任意投影轴。实际上,投影矩阵的最后一行只定义了图像平面将与之正交的投影轴,剪辑空间w分量只是顶点坐标和该轴之间的点积。

在典型情况下,投影矩阵具有 (0,0,-1,0) 作为最后一行,因此它w_clip = -z_eye转换为 ,这就是 ybungalowbill 使用的。但是,由于w我们实际上将进行除法(这是整个变换链中唯一的非线性步骤),因此这适用于任何投影轴。它也适用于正交投影的琐碎情况,其中w始终为 1(或至少为常数)。

  1. 请注意一些事情以有效地实现这一点。1/w_i可以预先计算每个顶点的反转(让我们q_i在下面调用它们),不必对每个片段重新评估。w它是完全免费的,因为我们在进入 NDC 空间时无论如何都要除以,所以我们可以保存那个值。GL 规范从未描述如何在内部实现某个功能,但屏幕空间坐标可在 中访问glFragCoord.xyz,并gl_FragCoord.w保证给出(线性插值)1/w 剪辑空间坐标的事实在这里非常具有启发性。每个片段的1_w值实际上是上面给出的公式的分母。

  2. 因子a/w_a,b/w_bc/w_c分别在公式中使用了两次。对于任何属性值,这些也是恒定的,现在不管有多少属性要插值。因此,对于每个片段,您可以计算a'=q_a * ab'=q_b * bc'=q_c得到

      a' * f_a + b' * f_b + c' * f_c
    f=------------------------------
               a' + b' + c'
    

所以透视插值归结为

  • 3个额外的乘法,
  • 2个额外的补充,和
  • 1个附加部门

每个片段。

于 2014-06-29T13:42:32.450 回答