着色器开发新手指南

学习编写图形着色器就是学习利用 GPU 的强大功能,它的数千个内核全部并行运行。这是一种需要不同思维方式的编程,但其巨大的潜力值得开始时的投入。

几乎你看到的每一个现代图形模拟都以某种方式由为 GPU 编写的代码提供支持,从尖端 AAA 游戏中的逼真照明效果到 2D 后处理效果和流体模拟。

Minecraft 中的一个场景,在应用一些着色器前后。

1、本指南的目的

着色器编程有时会成为一种神秘的黑魔法,并且经常被误解。有很多代码示例向您展示如何创建令人难以置信的效果,但很少或根本没有提供任何解释。本指南旨在弥合这一差距。我将更多地关注编写和理解着色器代码的基础知识,这样你就可以轻松地调整、组合或从头开始编写自己的代码!

这是一个通用指南,因此你在这里学到的内容将适用于任何可以运行着色器的东西。

2、什么是着色器?

着色器只是在图形管道中运行并告诉计算机如何渲染每个像素的程序。这些程序被称为着色器,因为它们通常用于控制照明阴影 效果,但它们没有理由不能处理其他特殊效果。

着色器是用一种特殊的着色语言编写的。不用担心,你不必出去学习一门全新的语言;我们将使用 GLSL(OpenGL 着色语言),它是一种类似 C 的语言。对于不同的平台有很多着色语言,但由于它们都适用于在 GPU 上运行,所以它们都非常相似。

注意:本文专门介绍片段着色器。如果你对其他类型的着色器感到好奇,可以在 OpenGL Wiki 上阅读有关图形管道中各个阶段的信息。

3、让我们开始!

我们将在本教程中使用ShaderToy。这使你可以直接在浏览器中开始对着色器进行编程,而无需进行任何设置!ShaderToy使用 WebGL 进行渲染,因此你需要一个可以支持该功能的浏览器。 创建帐户是可选的,但对于保存代码很方便。

注意:在撰写本文时,ShaderToy 处于测试阶段。一些小的 UI/语法细节可能略有不同。

单击New Shader后,你应该会看到如下内容:

如果未登录,你的界面可能会略有不同。

底部的黑色小箭头是你单击以编译代码的地方。

4、发生了什么?

我将用一句话解释着色器是如何工作的。你准备好了吗?开始!

着色器的唯一目的是返回四个数字:rgba

这就是它曾经做过或能做的一切。你在面前看到的函数针对屏幕上的每个像素运行。它返回这四个颜色值,这成为像素的颜色。这就是所谓的像素着色器 (有时称为 片段着色器)。

考虑到这一点,让我们尝试将屏幕变为纯红色。rgba(红色、绿色、蓝色和“alpha”,定义透明度)值从01,所以我们需要做的就是 return r,g,b,a = 1,0,0,1。ShaderToy 期望最终的像素颜色存储在fragColor.

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    fragColor = vec4(1.0,0.0,0.0,1.0);
}

恭喜!这是你的第一个工作着色器!

挑战:你能把它改成纯灰色吗?

vec4只是一种数据类型,所以我们可以将颜色声明为变量,如下所示:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec4 solidRed = vec4(1.0,0.0,0.0,1.0);
    fragColor = solidRed;
}

不过,这并不是很令人兴奋。我们有能力在数十万像素上并行运行代码,并且我们将它们全部设置为相同的颜色。

让我们尝试在屏幕上渲染一个渐变。好吧,如果不知道我们正在影响的像素的一些事情,例如它在屏幕上的位置,我们将无能为力...

5、着色器输入

像素着色器传递一些变量 供你使用。对我们最有用的是fragCoord它保存像素的 x 和 y(以及 z,如果你在 3D 中工作)坐标。让我们尝试将屏幕左半边的所有像素变为黑色,将右半边的所有像素变为红色:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel
    vec4 solidRed = vec4(0,0.0,0.0,1.0);//This is actually black right now
    if(xy.x > 300.0){//Arbitrary number, we don't know how big our screen is!
        solidRed.r = 1.0;//Set its red component to 1.0
    }
    fragColor = solidRed;
}

注意:对于任何,你可以通过、和通过obj.xobj.yobj.zobj.w obj.robj.gobj.bobj.a 访问vec4其组件。它们是等价的;这只是一种方便的命名方式,以使你的代码更具可读性,以便其他人看到 obj.r 时,他们会理解obj代表一种颜色。

你看到上面的代码中的问题了吗?尝试单击预览窗口右下角的全屏按钮。

红色的屏幕比例会根据屏幕的大小而有所不同。为了确保屏幕的一半是红色的,我们需要知道屏幕有多大。屏幕大小不是像像素位置那样的内置变量,因为通常由构建应用程序的程序员来设置它。在这种情况下,设置屏幕大小的是 ShaderToy 开发人员。

如果某些东西不是内置变量,你可以将该信息从 CPU(您的主程序)发送到 GPU(你的着色器)。ShaderToy 为我们处理了这个问题。你可以在Shader Inputs选项卡中看到所有传递给着色器的变量。以这种方式从 CPU 传递到 GPU 的变量在 GLSL 中称为Uniform

让我们调整上面的代码以正确获取屏幕的中心。我们需要使用着色器输入iResolution

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel
    xy.x = xy.x / iResolution.x; //We divide the coordinates by the screen size
    xy.y = xy.y / iResolution.y;
    // Now x is 0 for the leftmost pixel, and 1 for the rightmost pixel
    vec4 solidRed = vec4(0,0.0,0.0,1.0); //This is actually black right now
    if(xy.x > 0.5){
        solidRed.r = 1.0; //Set its red component to 1.0
    }
    fragColor = solidRed;
}

如果这次尝试放大预览窗口,颜色仍应完美地将屏幕一分为二。

6、从分裂到渐变

把它变成一个渐变应该很容易。我们的颜色值从01,坐标也从01

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel
    xy.x = xy.x / iResolution.x; //We divide the coordinates by the screen size
    xy.y = xy.y / iResolution.y;
    // Now x is 0 for the leftmost pixel, and 1 for the rightmost pixel
    vec4 solidRed = vec4(0,0.0,0.0,1.0); //This is actually black right now
     solidRed.r = xy.x; //Set its red component to the normalized x value
    fragColor = solidRed;
}

瞧!

挑战:你能把它变成垂直渐变吗?对角线呢?具有多种颜色的渐变呢?

如果你玩得够多,你可以看出左上角有坐标(0,1)而不是 (0,0)。记住这一点很重要。

7、绘制图像

玩弄颜色很有趣,但如果我们想做一些令人印象深刻的事情,我们的着色器必须能够从图像中获取输入并改变它。通过这种方式,我们可以制作一个影响整个游戏屏幕的着色器(例如水下流体效果或颜色校正),或者根据输入以某些方式仅影响某些对象(例如逼真的照明系统)。

如果我们在普通平台上编程,需要将图像(或纹理)作为Uniform发送到 GPU,就像发送屏幕分辨率一样。ShaderToy 为我们解决了这个问题。底部有四个输入通道:

ShaderToy 的四个输入通道

单击iChannel0 并选择你喜欢的任何纹理(图像)。

完成后,你现在就有了一个正在传递给着色器的图像。但是,有一个问题:没有DrawImage()函数。请记住,像素着色器唯一能做的就是改变每个像素的颜色

那么如果我们只能返回一种颜色,我们如何在屏幕上绘制纹理呢?我们需要以某种方式将着色器所在的当前像素映射到纹理上的相应像素:

根据 (0,0) 在屏幕上的位置,你可能需要翻转 y 轴以正确映射纹理。在撰写本文时,ShaderToy 已更新,其原点位于左上方,因此无需翻转任何内容。

我们可以通过使用texture(textureData,coordinates)函数来做到这一点,该函数将纹理数据和(x, y)坐标对作为输入,并将这些坐标处的纹理颜色作为 a 返回vec4

可以以任何你喜欢的方式将坐标与屏幕匹配。你可以在屏幕的四分之一上绘制整个纹理(通过跳过像素,有效地缩小它)或只绘制纹理的一部分。

出于我们只想查看图像,因此我们将匹配像素 1:1:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 xy = fragCoord.xy / iResolution.xy;//Condensing this into one line
    vec4 texColor = texture(iChannel0,xy);//Get the pixel at xy from iChannel0
    fragColor = texColor;//Set the screen pixel to that color
}

这样,我们就有了第一张图片!

现在已经正确地从纹理中提取数据,可以随意操作它!你可以拉伸它并缩放它,或者玩弄它的颜色。

让我们尝试用渐变来修改它,类似于我们上面所做的:

texColor.b = xy.x;

恭喜,你刚刚制作了您的第一个后期处理效果!

挑战: 你能写一个将图像变成黑白的着色器吗?

请注意,即使它是静态图像,你眼前看到的也是实时发生的。可以通过将静态图像替换为视频来亲自查看:再次单击iChannel0输入并选择其中一个视频。

8、添加一些动作

到目前为止,我们所有的效果都是静态的。通过使用 ShaderToy 提供给我们的输入,我们可以做更多有趣的事情。iGlobalTime 是一个不断增加的变量;我们可以用它作为种子来制作周期性效果。让我们试着玩一下颜色:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 xy = fragCoord.xy / iResolution.xy; // Condensing this into one line
    vec4 texColor = texture(iChannel0,xy); // Get the pixel at xy from iChannel0
       texColor.r *= abs(sin(iGlobalTime));
    texColor.g *= abs(cos(iGlobalTime));
    texColor.b *= abs(sin(iGlobalTime) * cos(iGlobalTime));
    fragColor = texColor; // Set the screen pixel to that color
}

GLSL 中内置了正弦和余弦函数,以及许多其他有用的函数,例如获取向量的长度或两个向量之间的距离。颜色不应该是负数,所以我们确保使用abs函数获得绝对值。

挑战:你能制作一个着色器,将图像从黑白到全彩色来回改变吗?

9、结束语

虽然你可能习惯于单步执行代码并打印出所有内容的值以查看发生了什么,但在编写着色器时这是不可能的。你可能会发现一些特定于平台的调试工具,但通常最好的办法是将正在测试的值设置为你可以看到的图形。

这些只是使用着色器的基础知识,但熟悉这些基础知识将使您能够做更多事情。浏览 ShaderToy 上的效果,看看您是否可以理解或复制其中的一些效果!

我在本教程中没有提到的一件事是Vertex Shaders 它们仍然是用相同的语言编写的,除了它们在每个顶点而不是每个像素上运行,它们返回一个位置和一个颜色。顶点着色器通常负责将 3D 场景投影到屏幕上(大多数图形管道中内置的东西)。像素着色器负责我们看到的许多高级效果,这就是我们关注的原因。

最后的挑战:你能否编写一个着色器来移除 ShaderToy 上视频中的绿屏,并在第一个视频中添加另一个视频作为背景?

原文链接:A Beginner's Guide to Coding Graphics Shaders

BimAnt翻译整理,转载请标明出处