如何在OpenGL中直接使用屏幕空间坐标?

7

我在https://learnopengl.com/上找到的指南帮助我成功在窗口上渲染了一个三角形,但整个过程对我来说似乎过于复杂。我在互联网上搜索了很多类似的问题,但它们都似乎已经过时了,并没有给出令人满意的答案。

我阅读了上面提到的网站的Hello Triangle指南,其中有一张图解释了图形管线,这让我理解了为什么一个看似简单的任务(在屏幕上绘制一个三角形)需要那么多步骤。

我还阅读了同一网站的Coordinate Systems指南,它告诉我OpenGL使用的是“奇怪(对我来说)的坐标”(NDC),以及它为什么使用它。

(这是上述指南中的图片,我认为它有助于描述我的问题。)


问题是:我能直接使用最终的SCREEN SPACE坐标吗?

我想做一些2D渲染(没有z轴),并且屏幕尺寸已知(固定),因此我看不出为什么应该使用归一化坐标系而不是绑定到我的屏幕的特殊坐标系。

例如:在320x240的屏幕上,(0,0)表示左上角的像素,(319,239)表示右下角的像素。它不需要完全按照我描述的那样,每个整数坐标=屏幕上对应的像素。

我知道可以为自己设置这样一个坐标系统,但坐标将被转换到各个方向,最终又回到屏幕空间 - 这就是我一开始拥有的。所有这些似乎都是白费力气,而且当坐标被转换时,它们是否会引入精度损失?


来自Coordinate Systems指南的引用(上面的图片):

  1. 在坐标处于视图空间之后,我们想要将它们投影到剪辑坐标中。 剪辑坐标在-1.0和1.0范围内进行处理,并确定哪些顶点最终出现在屏幕上。

因此,在考虑一个1024x768屏幕的情况下,我将剪辑坐标定义为(0,0)到(1024,678),其中:

(0,0)--------(1,0)--------(2,0)  
  |            |            |    
  |   First    |            |    
  |   Pixel    |            |    
  |            |            |    
(0,1)--------(1,1)--------(2,1)        . . .
  |            |            |    
  |            |            |    
  |            |            |    
  |            |            |    
(0,2)--------(1,2)--------(2,2)  

              .
              .
              .
                                          (1022,766)---(1023,766)---(1024,766)
                                               |            |            |
                                               |            |            |
                                               |            |            |
                                               |            |            |
                                          (1022,767)---(1023,767)---(1024,767)
                                               |            |            |
                                               |            |   Last     |
                                               |            |   Pixel    |
                                               |            |            |
                                          (1022,768)---(1023,768)---(1024,768)

假设我想在Pixel(11,11)处放置一张图片,因此该位置的剪辑坐标为Clip(11.5,11.5),然后将该坐标处理为-1.0和1.0范围:

11.5f * 2 / 1024 - 1.0f = -0.977539063f // x
11.5f * 2 /  768 - 1.0f = -0.970052063f // y

我有一个NDC(-0.977539063f,-0.970052063f)

  1. 最后,我们将裁剪坐标转换为屏幕坐标,这个过程称为视口变换,它将坐标从-1.0和1.0转换为由glViewport定义的坐标范围。 然后将结果坐标发送到光栅化器以将其转换为片段。

因此,将NDC坐标转换回屏幕坐标:

(-0.977539063f + 1.0f) * 1024 / 2 = 11.5f        // exact
(-0.970052063f + 1.0f) *  768 / 2 = 11.5000076f  // error

由于1024是2的幂次方,因此x轴非常准确,但由于768不是,所以y轴有些偏差。误差非常小,但并不完全是11.5f,所以我猜原始图片可能会进行某种混合处理,而不是1:1的表示。


为避免上述提到的舍入误差,我做了如下操作:

首先,我将视口大小设置为比窗口大的大小,并使宽度和高度成为2的幂次方:

GL.Viewport(0, 240 - 256, 512, 256); // Window Size is 320x240

然后我设置了顶点的坐标,如下:

float[] vertices = {
    //  x       y
      0.5f,   0.5f, 0.0f, // top-left
    319.5f,   0.5f, 0.0f, // top-right
    319.5f, 239.5f, 0.0f, // bottom-right
      0.5f, 239.5f, 0.0f, // bottom-left
};

我手动在顶点着色器中进行转换:

#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x * 2 / 512 - 1.0, 0.0 - (aPos.y * 2 / 256 - 1.0), 0.0, 1.0);
}

最后我画了一个四边形,结果如下: render result 这似乎产生了正确的结果(四边形大小为320x240),但我想知道是否有必要这样做。
以下是我的问题:
  1. 我的方法有什么缺点?
  2. 是否有更好的方法来实现我所做的事情?

似乎以线框模式渲染时隐藏了问题。我试图将纹理应用到我的四边形上(实际上我切换为两个三角形),在不同的GPU上得到了不同的结果,并且其中没有一个看起来正确:
左:Intel HD4000 | 右:Nvidia GT635M(optimus) result_no_texture
我将GL.ClearColor设置为白色并禁用了纹理。
虽然两个结果都填充了窗口客户区域(320x240),但Intel给了我一个位于左上角的大小为319x239的正方形,而Nvidia则给了我一个位于左下角的大小为319x239的正方形。
如果打开纹理,它看起来像这样: result_textured 纹理如下: texture (我将其垂直翻转,以便在代码中更容易加载)
顶点如下:
float[] vertices_with_texture = {
    //  x       y           texture x     texture y
      0.5f,   0.5f,        0.5f / 512, 511.5f / 512, // top-left
    319.5f,   0.5f,      319.5f / 512, 511.5f / 512, // top-right
    319.5f, 239.5f,      319.5f / 512, 271.5f / 512, // bottom-right ( 511.5f - height 240 = 271.5f)
      0.5f, 239.5f,        0.5f / 512, 271.5f / 512, // bottom-left
};

现在我完全卡住了。

我认为我将四边形的边缘放在精确的像素中心(.5),并且我也在精确的像素中心(.5)处采样纹理,但两张图片给我两个不同的结果,而且都不正确(放大后可以看到图像中心略微模糊,没有明显的棋盘格纹)

我错过了什么吗?


我想我现在知道该怎么做了,我已经发布了解决方案作为答案,并将这个问题留在这里供参考。


@Rabbid76 我正在使用OpenTK 3.1.0的C#版本,基本上我是按照示例并边学边将代码改写成C#的。这种差异真的很重要吗? - RadarNyan
1
“但我想知道是否有必要做所有这些。”当然,避免舍入误差为0.0000076f是不必要的。那绝对是多余的。这在结果上没有任何区别。使用浮点数总会导致微小的误差。像素是一个整体单位。当规范化设备空间坐标被转换为窗口坐标时,这个误差是完全无关紧要的。你迷失在微不足道的细节中了。 - Rabbid76
@Rabbid76 你好,能否看一下我更新后的问题?你会看到中间有模糊区域,这就是为什么我非常关注任何错误并试图避免它的原因。 - RadarNyan
@Rabbid76 你说得对,总会有舍入误差。只确保起点和终点正确是不够的,任何中间值都可能使我失误。最终,我不得不将所有坐标预先适配为2的幂,以完全避免这个问题。 - RadarNyan
我还是无法理解为什么你是世界上唯一一个不能接受四舍五入误差的人。这些微小的误差是不相关的。 - Rabbid76
3个回答

4

好的……我想我终于把一切都按照我期望的方式工作了起来。问题在于我忘记了像素有大小——现在它显然得如此明显,以至于我不明白为什么我错过了这个。

在本答案中,我将把剪辑空间坐标称为Clip(x,y),其中x/y范围从0到屏幕宽度/高度。例如,在320x240的屏幕上,剪辑空间从Clip(0,0)Clip(320,240)


错误1:

当尝试绘制一个10像素的正方形时,我将其绘制从Clip(0.5,0.5)Clip(9.5,9.5)

尽管这些是正方形的开始像素结束像素像素中心的坐标,但实际空间该正方形占用的位置不是从其起始像素结束像素的像素中心开始的。

相反,该正方形实际占据的空间是从开始像素左上角结束像素右下角。因此,我应该使用的正确坐标是Clip(0,0) - Clip(10, 10)


错误2:

由于我弄错了正方形的大小,我也从错误的位置映射纹理了。既然现在我已经固定了正方形的大小,那么我将相应地调整纹理的坐标。

但是,我找到了一个更好的解决方案:矩形纹理 ,其中我引用了以下内容:

当使用矩形采样器时,所有纹理查找函数都会自动使用非规范化的纹理坐标。这意味着纹理坐标的值跨越了纹理中的(0..W,0..H),其中(W,H)指其以图元为单位的尺寸,而不是(0..1,0..1)。

这对于2D渲染非常方便,首先我不需要进行任何坐标转换,并且额外的奖励是我不需要再垂直翻转纹理了。

我尝试了一下,它的表现就像我期望的那样,但我遇到了一个新问题:当纹理没有放置在精确的像素网格上时,边缘会出现颜色渗透。


解决渗透问题:

如果我为每个正方形使用不同的纹理,则可以通过使采样器将纹理之外的所有内容都夹在TextureWrapMode.ClampToEdge中来避免此问题。

然而,我正在使用纹理集合,也称为“精灵表”。我在互联网上搜索了一些解决方案,例如:

  1. 手动为每个精灵添加填充,基本上为错误留出安全空间。

    这很直接,但我真的不喜欢它,因为我将失去紧密打包纹理的能力,并且这使得计算纹理坐标更加复杂,此外,这只是浪费空间。

  2. 对于GL_TEXTURE_MIN_FILTER使用GL_NEAREST并将坐标偏移0.5/0.375

    这非常容易编码,对于像素艺术而言效果很好 - 我不想用线性过滤使它们模糊。但是我也想保持显示图片并平稳移动而不是跳动像素的能力,因此我需要能够使用GL_LINEAR

一个解决方案:手动夹紧纹理坐标。

这基本上与TextureWrapMode.ClampToEdge相同,但是针对每个精灵而不仅仅是整个精灵表的边缘。我按如下方式编写片段着色器(仅用于概念证明,我肯定需要改进它):

#version 330 core
out vec4 FragColor;
in vec2 TexCoord;

uniform sampler2DRect texture1;

void main()
{
    vec2 mTexCoord;
    mTexCoord.x = TexCoord.x <= 0.5 ? 0.5 : TexCoord.x >= 319.5 ? 319.5 : TexCoord.x;
    mTexCoord.y = TexCoord.y <= 0.5 ? 0.5 : TexCoord.y >= 239.5 ? 239.5 : TexCoord.y;
    
    FragColor = texture(texture1, mTexCoord);
}

在这种情况下我使用的"sprite"是320x240,占据了我的整个屏幕。

由于矩形纹理使用非规范化坐标,编写代码非常容易。它运作良好,我就收手了。

另一种解决方案(尚未测试):使用数组纹理代替纹理图集

这个想法很简单,只需设置TextureWrapMode.ClampToEdge,并让采样器发挥作用。我还没有深入研究它,但从概念上看似乎能够工作。不过我真的很喜欢矩形纹理的坐标工作方式,如果可能的话,我想保留它。


舍入误差

当我尝试在屏幕上动画我的正方形时,得到了一个非常奇怪的结果:(注意左侧数值为X.5时正方形的左下角)

Intel HD4000

这仅在我的iGPU(Intel HD4000)上发生,而不是dGPU(通过优化的Nvidia GT635M)。这是因为我在片段着色器中将所有坐标调整到像素中心(.5)导致的。

#version 330 core
out vec4 FragColor;
in vec2 TexCoord;

uniform sampler2DRect texture1;

void main()
{
    vec2 mTexCoord;

    // Clamp to spirte
    mTexCoord.x = clamp(TexCoord.x, 0.5, 319.5);
    mTexCoord.y = clamp(TexCoord.y, 0.5, 239.5);

    // Snap to pixel
    mTexCoord.xy = trunc(mTexCoord.xy) + 0.5;
    
    FragColor = texture(texture1, mTexCoord);
}

我的最佳猜测是,当将坐标转换为NDC(并返回屏幕坐标)时,iGPU和dGPU会以不同的方式舍入。
使用大小为2的幂次方的四边形和纹理可以避免这个问题。还有一些解决方法,例如在截断之前向mTexCoord.xy添加少量(我的笔记本电脑上的0.0001就足够了)。
更新:解决方案
好吧,经过一夜好眠,我提出了一个相对简单的解决方案。
1. 处理图片时,无需更改任何内容(让线性滤波器发挥其作用) 由于总会存在舍入误差,所以我基本上在这一点上放弃并接受它。人眼根本看不出来。
2. 当尝试将纹理中的像素适配到屏幕上的像素网格中时,除了在片段着色器中捕捉纹理坐标(如上所示)之外,我还必须在顶点着色器中预先移动纹理坐标。
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 TexCoord;
uniform mat4 projection;

void main()
{
    TexCoord = aTexCoord + mod(aPos , 1);
    gl_Position = projection * vec4(aPos.xy, 0.0, 1.0);
}

这个想法很简单:当方块放置在Clip(10.5,10.5)时,也将纹理坐标移动到Pixel(0.5,0.5)。当然,这意味着结束坐标会移出精灵区域Pixel(320.5,320.5),但是在片段着色器中使用clamp进行修复,所以不必担心。任何介于两者之间的坐标(例如Pixel(0.1,0.1))也会被片段着色器捕捉到Pixel(0.5,0.5),从而创建一个像素对像素的结果。

这使我在我的iGPU(英特尔)和dGPU(Nvidia)之间获得了一致的结果:

snap_to_pixel_grid

位于Clip(10.5,10.5)处,请注意左下角的伪像已经消失。


总结一下:

  1. Clip Space坐标设置为屏幕的1:1比例。

  2. 计算精灵大小时记住像素有大小。

  3. 在纹理上使用正确的坐标,并修复精灵边缘的伪像。

  4. 尝试将纹理像素捕捉到屏幕像素时,除了纹理坐标外,还要特别注意顶点坐标。


3

我可以直接使用最终的屏幕空间坐标吗?

不行,你需要对坐标进行转换。可以在CPU或着色器(GPU)上进行转换。

如果您想要使用窗口坐标,则必须设置正交投影矩阵,将坐标从x:[-1.0,1.0],y:[-1.0,1.0](规范化设备空间)转换为您的窗口坐标x:[0,320],y:[240,0]。

例如,可以使用 glm::ortho 进行转换。

glm::mat4 projection = glm::orhto(0, 320.0f, 240.0f, 0, -1.0f, 1.0f);

例如,OpenTK的Matrix4.CreateOrthographic方法

OpenTK.Matrix4 projection = 
    OpenTK.Matrix4.CreateOrthographic(0, 320.0f, 240.0f, 0, -1.0f, 1.0f);

在顶点着色器中,顶点坐标必须乘以投影矩阵。
in vec3 vertex;

uniform mat4 projection;

void main()
{
    gl_Position = projection * vec4(vertex.xyz, 1.0);
}

为了完整起见,传统OpenGLglOrtho
(不要使用旧的和已弃用的传统OpenGL功能)


glOrtho(0.0, 320.0, 240.0, 0.0, -1.0, 1.0); 


@ RadarNyan 没关系,错误很小,在栅格化期间它将有效地舍入为整数像素坐标。但使用浮点数意味着您可以执行屏幕对象的平滑移动等操作。 - Andrea
@Rabbid76,我没看到它在哪里进行了四舍五入,(xw,yw)转换后仍然是浮点数,我有什么遗漏吗? - RadarNyan
@Rabbid76 如果它们是整数,那么如何在像素之间进行平滑动画呢?我更加困惑了。这里太短了,给我一分钟更新问题。 - RadarNyan
@RadarNyan 你不能在半个像素上进行绘制,只能在一个像素上进行或不进行绘制。但是有多重采样,其中代表一个像素的缓冲区由多个采样组成。屏幕上的像素得到采样颜色的插值颜色。但请注意,仅凭提问是不可能完全掌握这些内容的。可能需要阅读整个OpenGL规范和一些好书。 - Rabbid76
显示剩余3条评论

0

正如其他人所提到的,您需要一个正交投影矩阵。 您可以按照以下指南自己实现它: http://learnwebgl.brown37.net/08_projections/projections_ortho.html 或者您正在使用的框架可能已经有了。

如果您将右/左/上/下值设置为与屏幕分辨率匹配,那么坐标x,y(z无关紧要,您可以使用4x4矩阵的2D向量)的点将在屏幕上变成相同的x,y。

如果您通过将此投影矩阵乘以平移矩阵(先平移再投影)来移动视图,则可以像相机一样移动视图。 然后,您将此矩阵传递给着色器,并将顶点的位置乘以它。


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