OpenGL如何进行透视正确的线性插值?

43
如果在线性插值在OpenGL管道的光栅化阶段发生,并且顶点已经转换为屏幕空间,那么用于透视正确插值的深度信息来自哪里?
有人可以详细描述OpenGL如何从屏幕空间基元转换为具有正确插值值的片段吗?

你可能会发现这个例子很有启发性。 - gman
2个回答

81
顶点着色器的输出是一个四维向量,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)函数来计算其重心坐标,在窗口坐标中,该函数是一个线性函数。这为我们提供了片段在2D三角形投影上的重心坐标。

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

作为一个程序相关的事情(参见[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

实现

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

struct Renderbuffer { int w, h, ys; void *data; };
struct Vert { vec4 position, texcoord, color; };
struct Varying { vec4 texcoord, color; };

void vertex_shader(const Vert &in, vec4 &gl_Position, Varying &OUT) {
    OUT.texcoord = in.texcoord;
    OUT.color = in.color;
    gl_Position = vec4(in.position.x, in.position.y, -2*in.position.z - 2*in.position.w, -in.position.z);
}

void fragment_shader(vec4 &gl_FragCoord, const Varying &IN, vec4 &OUT) {
    OUT = IN.color;
    vec2 wrapped = IN.texcoord.xy - floor(IN.texcoord.xy);
    bool brighter = (wrapped[0] < 0.5) != (wrapped[1] < 0.5);
    if(!brighter)
        OUT.rgb *= 0.5f;
}

// render output unit/render operations pipeline
void rop(Renderbuffer &buf, int x, int y, const vec4 &c) {
    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 box2 &viewport, const Vert *verts) {
    auto area = [](const vec2 &p0, const vec2 &p1, const vec2 &p2) { return cross(p1 - p0, p2 - p0); };
    auto interpolate = [](const auto a[3], auto p, const vec3 &coord) { return coord.x*a[0].*p + coord.y*a[1].*p + coord.z*a[2].*p; };

    Varying perVertex[3];
    vec4 gl_Position[3];

    box2 aabb = { viewport.hi, viewport.lo };
    for(int i = 0; i < 3; ++i) {
        vertex_shader(verts[i], gl_Position[i], perVertex[i]);

        // convert to normalized device coordinates
        gl_Position[i].w = 1/gl_Position[i].w;
        gl_Position[i].xyz *= gl_Position[i].w;

        // convert to window coordinates
        gl_Position[i].xy = mix(viewport.lo, viewport.hi, 0.5f*(gl_Position[i].xy + 1.0f));
        aabb = join(aabb, gl_Position[i].xy);
    }

    const float denom = 1/area(gl_Position[0].xy, gl_Position[1].xy, gl_Position[2].xy);

    // loop over all pixels in the rectangle bounding the triangle
    const ibox2 iaabb = lround(aabb);
    for(int y = iaabb.lo.y; y < iaabb.hi.y; ++y)
    for(int x = iaabb.lo.x; x < iaabb.hi.x; ++x)
    {
        vec4 gl_FragCoord;
        gl_FragCoord.xy = vec2(x, y) + 0.5f;

        // fragment barycentric coordinates in window coordinates
        const vec3 barycentric = denom*vec3(
            area(gl_FragCoord.xy, gl_Position[1].xy, gl_Position[2].xy),
            area(gl_Position[0].xy, gl_FragCoord.xy, gl_Position[2].xy),
            area(gl_Position[0].xy, gl_Position[1].xy, gl_FragCoord.xy)
        );

        // discard fragment outside the triangle. this doesn't handle edges correctly.
        if(barycentric.x < 0 || barycentric.y < 0 || barycentric.z < 0)
            continue;

        // interpolate inverse depth linearly
        gl_FragCoord.z = interpolate(gl_Position, &vec4::z, barycentric);
        gl_FragCoord.w = interpolate(gl_Position, &vec4::w, barycentric);

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

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

        // interpolate attributes
        Varying varying = {
            interpolate(perVertex, &Varying::texcoord, perspective),
            interpolate(perVertex, &Varying::color, perspective),
        };

        vec4 color;
        fragment_shader(gl_FragCoord, varying, color);
        rop(color_attachment, x, y, color);
    }
}

int main(int argc, char *argv[]) {
    Renderbuffer buffer = { 512, 512, 512*4 };
    buffer.data = calloc(buffer.ys, buffer.h);

    // VAO 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 } },
    };

    box2 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 Varying { vec4 texcoord; vec4 color; } OUT;
void main() {
    OUT.texcoord = texcoord;
    OUT.color = color;
    gl_Position = vec4(position.x, position.y, -2*position.z - 2*position.w, -position.z);
}

片段着色器:

#version 450 core
layout(location = 0) in Varying { 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.x < 0.5) != (wrapped.y < 0.5);
    if(!brighter)
        OUT.rgb *= 0.5;
}

结果

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

差异是由于不同的精度和舍入模式引起的。
为了比较,这里有一个不透视正确的例子(在上面的代码中使用而不是进行插值):


1
谢谢!这正是我所期望的答案!但我仍然有些困难。以下哪一点是不正确的?
  1. 正确插值片段属性要求透视除法尚未完成,因为需要有意义的w值。
  2. 片段(直接对应像素)直到视口变换后才能生成。
  3. 视口变换应用于标准化设备坐标。
  4. 标准化设备坐标是通过在剪辑坐标上执行透视除法获得的。
- AIGuy110
啊,所以顶点的剪裁空间坐标在透视除法后被保存并检索出来?这很有道理。谢谢 :)。 - AIGuy110
@user1003620:这里GL的作用是:整个剪裁空间坐标没有被存储,但是剪裁空间的w坐标被存储了。实际上,gl_FragCoord.w将包含(每个片段线性插值的)1/w坐标,这是透视校正的副产品,也可以在着色器中非常有用。 - derhass
在标题为“如何计算正确的透视插值?”下,w是否应该等于-z而不是-1/z?当w = -z时似乎更有意义,因为剪辑空间中的点(x,y,*,-z)在齐次化后将变为(u = x / -z,v = y / -z),这与您在关于w的句子之前提到的(u,v)是一致的。 - legends2k
使用该算法时,相机后面的物体仍然可见(但奇怪地变形)。可能出了什么问题? - neoexpert
显示剩余5条评论

26

您将在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表示点的重心坐标(a,b,c>= 0,a + b + c = 1),f_i表示顶点 i 处的属性值,w_i表示顶点 i 的剪辑空间w坐标。请注意,重心坐标仅针对三角形的窗口空间坐标的2D投影计算(因此z被忽略)。

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

在典型情况下,投影矩阵的最后一行为(0,0,-1,0),因此它会进行转换,以便w_clip = -z_eye,这就是ybungalowbill所使用的方法。但是,由于w实际上是我们将要除以的值(这是整个变换过程中唯一的非线性步骤),因此这将适用于任何投影轴。它还适用于正交投影的平凡情况,其中w始终为1(或至少是常数)。

  1. 请注意以下几点,以实现高效的实现。每个顶点可以预先计算倒数1/w_i并保存下来(在以下内容中称为q_i),无需对每个片段重新计算。而且它是完全免费的,因为我们在进入NDC空间时无论如何都要除以w,所以我们可以节省该值。GL规范从未描述某个特定功能在内部如何实现,但屏幕空间坐标将可在glFragCoord.xyz中访问,并且保证gl_FragCoord.w会给出(线性插值的)1/w 剪辑空间坐标,这一点非常显然。每个片段的1_w值实际上就是上述公式的分母。

  2. 公式中的a / w_ab / 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次额外除法

每个片段。


这个答案非常有帮助,比被采纳的那个容易实现得多。在第一个公式中,您应该写成 w_c 而不是 w*c。 此外,对于任何其他寻找此公式的人,可以在您提供的规范的第427页上找到它。 关于重心坐标系的公式可以在http://en.wikipedia.org/wiki/Barycentric_coordinate_system找到,而您只需要使用 x、y 值来计算它们。 - Chris Pushbullet
@christopherhesse:感谢您的反馈。我稍微更新了答案。现在公式是正确的,我还提到重心坐标必须基于三角形的二维投影进行计算。我还纠正了很多错别字,并使语言更加清晰。 - derhass
1
通过阅读这篇优秀的文章,您可以更好地理解这个方程式是如何形成的:https://www.scratchapixel.com/lessons/3d-basic-rendering/rasterization-practical-implementation/visibility-problem-depth-buffer-depth-interpolation - Petrakeas
1
如果出于某种原因您想要在传统的顶点和片段着色器中完成此操作,可以使用现有的插值。只需在顶点着色器中将属性与 1/w 相乘即可。将 1/w 与顶点属性一起发送以进行插值。在片段着色器中,通过插值后的 1/w 将属性除以它。请确保对要手动更正的属性和 1/w 属性使用 noperspective 关键字。 - Selmar

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接