Shader从入门到放弃 —— Shader编程简介及坐标系绘制

4,696 阅读8分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

本文是系列文章 “Shader从入门到放弃” 的开篇文章。为什么想要写这样的一个系列文章呢,原因有几点:

  1. 在闲暇之余回忆起自己年初定下的目标之一就是好好的学习Shader,但是最近工作一直都比较的忙碌,一直没有空余时间来学习Shader的相关知识,正好通过写文章的方式来强迫自己学习。
  2. 正好在某站上看到一个UP主的视频讲的比较好,我觉得将UP主讲的知识点自己消化一遍后再将其输出出来。
  3. 因为Shader真的可以画出很多炫酷的图形啊!正所谓程序员的三大浪漫,其中之一就是图形学了。

废话不多说了,马上进入正题。本文的Shader均以 GLSL 语言进行讲解。

Shader编程简介

什么是Shader

什么是Shader呢?在Wikipedia中是这样解释的:

In computer graphics, a shader is a computer program that calculates the appropriate levels of lightdarkness, and color during the rendering of a 3D scene - a process known as shading. Shaders have evolved to perform a variety of specialized functions in computer graphics special effects and video post-processing, as well as general-purpose computing on graphics processing units. —— wikipedia

"shader is a computer program",不得不说外国人说话先说主干还是不错的,很清晰的就点题了。shader是一段电脑程序,后面的定语从句 "that calculates the appropriate levels of light, darkness, and color during the rendering of a 3D scene" 说了这段程序用于在渲染3D场景时计算适当的光影、颜色。(仿佛回到了高中学英语的时候- -!)

当然shader程序的作用不仅仅限于渲染3D场景,还包括图形特效、视频后处理,以及在GPU中进行通用计算的能力。

在现代计算机的渲染管线中存在各种各样的shader:

  1. vertex shader 顶点着色器
  2. fragment shader 片元着色器
  3. compute shader 计算着色器
  4. geometry shader 几何着色器
  5. ……

我们可以看到Shader是分为很多种的。具体这些shader的作用都是什么,这里就不再一一介绍了。 在本系列文章中,主要介绍的是在 片元着色器(fragment shader) 中如何进行“创作”。

片元着色器(Fragment Shader)

片元着色器就是决定了屏幕上每个像素颜色的一段程序。你可以简单的理解为你在为屏幕上的每一个像素点编程,每个像素点就是一个执行单元,它会完整的执行你编写的这段程序。如果我们在CPU上模拟这段程序,它看起来应该是这样的:

for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
        // 此处执行你的Shader程序
        fragmentShader(x, y, ...args);
    }
}

此处的 fragmentShader 就是你编写的shader程序。理解这一点对于你学习shader是非常重要,一定要在头脑中时刻保持这个念头。

GLSL

在本文的开头提到,本文的shader程序都将使用 GLSL 来进行编写和讲解。现在我们来简单的介绍一下 GLSL

GLSL 全称名为:OpenGL Shading Language。它是一门用于OpenGL中的着色器语言。它与C语言的语法风格非常的类似。我们可以简单的感受下:

void main() {
    gl_FragColor = vec4(1.0);
}

所以,写过类C风格语言的程序员来编写shader程序时并不会在格式上遇到太多的困难。

由于文本主要专注于shader的编写,我并不想讲更多的关于shader与OpenGL之间的交互细节的问题。所以推荐大家在Shadertoy BETA上进行编写。又或者可以在vscode上装身上ShaderToy的插件,这样就可以在vscode上面直接编写shader程序了。

image.png

第一个Shader程序

现在,让我们进入实操部分。打开vscode, 创建一个文件"first-glsl.glsl"。键入以下内容:

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

接着,按 ctr/cmd + shift + P 输入 "show glsl preview",回车,就可以看到编辑器的右侧出现一个预览窗口。如果你看到一片橙色的海洋,那么恭喜你,完成了你的第一个shader程序。

shadertoy.png

简单的介绍下此程序的含义:

mainImage,它是 ShaderToy中的输出像素颜色的一个函数,其中的参数 fragColor 前面有一个out 表示它最终会作为函数的返回值,而 fragCoord 的前面有一个 in 则表示它是函数的输入值。像素的颜色最终就是由 fragColor 所决定的。

每一个像素点都会执行我们的这段程序,由于这段程序没有任何的逻辑可言,所以所有的像素点都被染成了橙色。

坐标系绘制

image.png

如上图所示,在ShaderToy的设定中,整个画布区域的左下角为(0, 0), 右上角为 (1, 1)。我们稍微修改一下我们的shader程序,用颜色将UV坐标展示出来(这里的UV轴相当于XY轴)。

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord.xy / iResolution.xy;
    fragColor = vec4(uv, 0.0, 1.0);
}

上面这段程序中,fragCoordmainImage 函数的输入值,表示的是每个像素点的坐标。

iResolution 是 ShaderToy中的内置变量,它表示了整个屏幕的分辨率。(ShaderToy的内置变量如下图所示)

image.png

所以,fragCoord.xy / iResolution.xy 将坐标进行了归一化。最后,我们将其作为输出颜色RG通道的值进行输出。

需要提醒的是在GLSL中,颜色值的表示并不是0 ~ 255, 它同样是归一化表示的,颜色值的范围同样是0~1。所以(1, 1, 1)表示的是白色。(1, 0, 0)为红色,(0, 1, 0) 为绿色,(1, 1, 0) 为黄色。

shadertoy.png

理解了我们的UV坐标过后,我们可以开始着手于绘制一个正交坐标系了。目前我们的原点还处于画面的左下角,我们想要将原点放置在我们的画面中心,并且让左右的边界的值处于 -1 ~ 1 的范围之间。所以,我们需要对当前的 uv 坐标进行坐标转换。

    vec2 uv = (fragCoord.xy / iResolution.xy - 0.5) * 2.0;

上面的式子应该不难理解,fragCoord.xy / iResolution.xy 的范围处于 0 ~ 1之间, 减去0.5过后,范围变为了 -0.5 ~ 0.5,再乘上2,就将 uv 坐标转换到了 -1 ~ 1 之间了。

我们先给所有的像素设置一个初始颜色,先暂且定为黑色吧(0, 0, 0);

    vec3 color = vec3(0.0);

如果当前像素的位置距离我们的中心的距离比较近的话,我们就将其颜色设置为白色。

if(abs(uv.x) < 0.01) {
    color = vec3(1.0);
}
fragColor = vec4(color, 1.0);

shadertoy.png

对于x轴的绘制,我们只需要将 uv.x 换成 uv.y

    if(abs(uv.x) < 0.01) {
        color = vec3(1.0);
    }
    if(abs(uv.y) < 0.01) {
        color = vec3(1.0);
    }

    fragColor = vec4(color, 1.0);

shadertoy2.png

嘿,一个基本的正交坐标系的雏形已经有了。让我们继续完善它吧。上面程序中出现了一个 0.01的魔法值,其实我们只是想控制直线的宽度。我们可以通过计算每个像素的尺寸来决定线宽

计算一个像素的宽度: vec2(1.0) / iResolution

vec2 onePixel = 1. * vec2(1.0) / iResolution.xy;
if(abs(uv.x) < onePixel.x) {
    color = vec3(1.0);
}
if(abs(uv.y) < onePixel.y) {
    color = vec3(1.0);
}

shadertoy3.png

这样我们就能比较方便的调节坐标系的线宽了。

坐标系绘制附加内容

接下来我们来讲一点附加的内容,这里可能会稍微的有一点费劲。我们现在还想在坐标系的各个象限中继续绘制一些格子。(如下所示)

shadertoy.png

首先,我们先来绘制格子。在shader中绘制格子其实是有套路的,也很简单。我们只需要对 uv 坐标乘上一个数,然后再取它的小数部分即可。

vec2 nuv = fract(uv * 3.0);
if(abs(nuv.x) < onePixel.x) {
    color = vec3(1.0);
}
if(abs(nuv.y) < onePixel.y) {
    color = vec3(1.0);
}
fragColor = vec4(color, 1.0);

But!这里会出现一些问题,如果屏幕的分辨率不够高的话,你会发现有的线莫名其妙的消失了。因为满足if条件时,这里的宽度已经小于物理像素的宽度了,所以我们的显示屏显示不出来。这里给出解决方法,至于为什么先不解释。

我们将 onePixel换成了 fwidth(nuv.x)。结果如下

vec2 nuv = fract(uv * 3.0);
if(abs(nuv.x) < fwidth(nuv.x)) {
    color = vec3(1.0);
}
if(abs(nuv.y) < fwidth(nuv.y)) {
    color = vec3(1.0);
}
fragColor = vec4(color, 1.0);

shadertoy.png

为了让x, y轴更显眼一些,我们给它换个颜色。

shadertoy.png

现在基本完成了,但是我们又发现了新的问题,当我们的画布的长宽比不是1:1时,我们绘制的格子它不是一个正方形,那么如何修改呢?此时,我们需要继续修改我们的uv坐标。

1668138771027-tuya.jpg 如上图所示,我们以较小的边为基准,将较大的边进行缩放。这样较大的那条边的范围从最左到最右则不是 -1 ~ 1

我们修改 uv 坐标如下:

vec2 uv = 2. * (fragCoord.xy - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);

最终代码及效果如下:

至此,我们已经全部完成了坐标系的绘制工作。

总结

总结一下:

  1. 今天讲述了关于Shader编程的基本概念,尤其是关于shader程序其实就是为屏幕上的每个像素点编写程序这一点需要读者们深刻的记住。
  2. 介绍了在ShaderToy/使用ShaderToy的vscode插件进行编程的方法
  3. 完成了我们绘制坐标系的shader程序,
    • 绘制了X、Y轴坐标
    • 为坐标系绘制了网格(对uv坐标乘上一个数并取小数部分)
    • 矫正了非 1:1宽高比画布带来的变形问题

大家下来一定要自己多加练习,一定会越来越得心应手的!