Shadertoy 教程 Part 15 - 通道(Channels),纹理( Textures), 以及缓冲( Buffers )

Note: This series blog was translated from Nathan Vaughn's Shaders Language Tutorial and has been authorized by the author. If reprinted or reposted, please be sure to mark the original link and description in the key position of the article after obtaining the author’s consent as well as the translator's. If the article is helpful to you, click this Donation link to buy the author a cup of coffee.
说明:该系列博文翻译自Nathan Vaughn着色器语言教程。文章已经获得作者翻译授权,如有转载请务必在取得作者译者同意之后在文章的重点位置标明原文链接以及说明。如果你觉得文章对你有帮助,点击此打赏链接请作者喝一杯咖啡。

朋友们,你们好!欢迎来到Shadertoy系列的第15篇教程。本篇文章将会讨论如何在Shadertoy中使用通道(channels)和缓存(buffers),用纹理创建多个通道的着色器。

通道(Channels)

Shadertoy使用了一种叫做通道的概念来获取不同类型的数据。在页面的底部,有四个黑色的盒子选项:iChannel0,iChannel1,iChannel2,和iChannel3.

点击任意一个通道,就会弹出一个弹框。从中我们可以选择各种各样的交互元素,(textures)纹理,(Cubemaps)立方体贴图,(Volumes)音量,(Videos)视频以及(Music)音乐。

在(其他)“Misc”标签栏,可以选择交互的元素有:键盘,摄像头,麦克风,或者从SoundCloud上播放音乐。缓冲(buffers),A,B,C,D 可以创建多通道的着色器。可以将他们看作是着色器管线通道中的额外着色程序。“CubeMap A”是一个特殊的着色器程序,它可以让你创建自己的天空盒。并将其传递给缓冲或者你的image程序中,下一篇教程中我们会重点讨论它。

下一个标签栏,可以找到三页可用的2D纹理图。把2D纹理看作是从图片中抽取的像素。在本文编写的过程中,还只能用自带提供的纹理,而非自己导入的。但是仍然有很多方式绕过这道限制。



CubeMaps标签栏,包含了可供选择的多个立方体贴图。在下一篇教程中我们会提到它们。立方体贴图技术经常被使用在Unity这种3D游戏的渲染引擎中,让你感觉你身处在一个被包围的世界。

音量标签栏包含了3D的纹理。典型的2D纹理使用UV座标访问x轴(u)和y轴(v)上的数据。你可以把3D纹理想像成可以从其中抽取像素的立方体,像是从三维空间中拉取数据。

视频(Videos)标签栏包含了随时间变化的2D纹理。这也就是说,可以在Shadertoy画布上播放视频。在Shadertoy上使用视频是为了让开发者体验一些后期的特效或者图片的特效,这些特效依赖前面一帧返回的数据。Britney Spears 以及 Claude Van Damme视频是体验这种特效的很好的方式。

最后,是音乐(Music)标签栏让我们可以体验多种音乐效果。如果你在一个通道中选择了一首音乐,那么当有人访问你的着色器片段的时候,音乐会自动播放出来。

使用纹理

在Shadertoy中使用纹理是非常简单的一件事情。点击打开一个新的着色器,将代码替换如下:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)

  vec4 col = texture(iChannel0, uv);

  fragColor = vec4(col); // Output to screen
}

然后点击iChannel0,弹窗出现时,选择纹理标签栏。我们选择“Abstract 1”纹理,先看一下弹出的菜单栏的一些细节。

上图展示了这片纹理的分辨率是1024*1024像素,意味着它更适合于正方形的画布。它包含三个通道(红,绿,蓝)使用unit8类型的,无符号整数字节。

我们点击>“Abstarct1”加载纹理到iChannel0中去。然后,运行着色器程序。你就会看到一幅完整的纹理出现在Shadertoy画布上。

我们接下来分析一下这段着色器程序:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)

  vec4 col = texture(iChannel0, uv);

  fragColor = vec4(col); // Output to screen
}

UV座标被设置到了0到1之间,分别是x轴和y轴。记住,起始点为(0,0),开始于左下角的位置。使用iChannel0和uv座标,纹理函数从纹理中获取纹素。

一块纹素表示的是纹理上某个座标的指定的值。对于2D纹理例如图片,纹素表示一个像素值。我们对2D纹理进行采样,让UV座标沿着图片定位到0到1之间。同时也可以使用UVmap将纹理映射到整个画布上去。

对于3D纹理来说,你可以想象纹素就是3D座标中的某个像素值。3D纹理一般被使用到处理噪声生产的情景,或者光线步进算法。一般情况下它很少见。

你也许会好奇什么是iChannel0,为什么将它作为一个参数传递一个参数传递给texture函数。这是因为Shadertoy在为你自动处理许多的事情。采样器就是将纹理单位进行绑定的方式。采样器的类型会根据你使用的通道中加载的资源类型变化而变化。在这个例子当中,我们在iChannel0中上传的是2D纹理,因此,iChannel0将会是一个sampler2d的类型。你可以在OpenGl维基百科页面了解这些采样器的类型。假设你想要创建一个方法,这个方法传递一个特殊的通道。你可以通过下面的代码来实现。

  vec3 get2DTexture( sampler2D sam, vec2 uv ) {
  return texture(sam, uv).rgb;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)

  vec3 col = vec3(0.);
  
  col = get2DTexture(iChannel0, uv);
  col += get2DTexture(iChannel1, uv);

  fragColor = vec4(col,1.0); // Output to screen
}

点击iChannel1,选择Abstract3 纹理,运行代码,你就可以看到两个图合并在一起了。

get2DTexture方法创建接收一个sampler2D类型的参数。在通道中使用2d纹理,Shadertoy自动会给你返回sampler2D类的数据。如果你想在Shadertoy的画布上播放视频,你可以遵循2D纹理的同样的规则。只需要为iChannel0选择视频,那么就可以看到视频自动播放了。

通道设置

那么,现在让我们看看这些通道可以为我们做些什么。首先,把下面的代码复制到你的着色器中:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)

  vec4 col = texture(iChannel0, uv);

  fragColor = vec4(col); // Output to screen
}

然后,使用一个新的纹理。点击iChannel0,切换到“纹理”标签栏,翻到第2页,可以看见一个“Nyancat”的纹理。

“Nyancat”纹理是一个256*32的图片,拥有4个颜色通道。点击这个纹理,那么它就会在iChannel0中出现。

运行代码,你就可以看到图片出现了,不过是有点儿模糊。

要修复这种模糊,点击右下角的设置按钮即可。

点击之后会弹出一个设置菜单:Filter,Wrap,以及VFlip。

Filter选项让我们调整修改算法类型为过滤纹理算法。纹理的维度不一定总是和Shadertoy画布保持一致,所以filter通常被用来对纹理进行采样。默认,Filter选项被设置为mipmap。点击下拉菜单,选择nearest使用nearest-eighbor 差值算法。这种算法在保持像素化时十分有用。

选择filter为nearest,就可以看到一张清晰的纹理图片了。

纹理看起来有些方正,我们通过给它进行缩放0.25个单位来修复这个问题。

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)
  
  uv.x *= 0.25;

  vec4 col = texture(iChannel0, uv);

  fragColor = vec4(col); // Output to screen
}

运行上面的代码,我们的纹理图片看起来就是正常的了。

使用VFlip选项可以将纹理图片进行上下翻转。取消复选框的勾选,设置纹理翻转选项。

返回并且检查VFlip选项,返回一张普通的图片。然后我们通过移动uv.x以及使用iTime可以让纹理旋转起来。

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)
  
  uv.x *= 0.25;
  
  uv.x -= iTime * 0.05;

  vec4 col = texture(iChannel0, uv);

  fragColor = vec4(col); // Output to screen
}

默认情况下,Wrap模式是重复的。这意味当UV座标超出了0到1的范围,纹理会重新再0到1之间进行采样。uv.x越来越小,它最终会为0,但是样本会足够聪明找到一个合适的值。如果你不想要重复的效果,你可以设置wrap模式修改为clamp。

如果重设置时间到0,然后你就可以看到UV座标从0到1.纹理图像就消失不见了。

纹理图片为我们提供了一个alpha的通道,我们就可以轻易地设置背景的透明度了。请确保时间被设置到了初始状态,然后运行上面的代码。

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // Normalized pixel coordinates (from 0 to 1)
  
  vec4 col = vec4(0.75);
  
  uv.x *= 0.25;
  uv.x -= iTime * 0.05;

  vec4 texCol = texture(iChannel0, uv);
  
  col = mix(col, texCol, texCol.a);

  fragColor = vec4(col); // Output to screen
}

这样我们就给纹理设置了一个透明色的背景了。

请注意大多数纹理只有三个颜色通道。有些纹理则只有一个通道,像是Bayer纹理,只有一个红色的通道用来存储数据,但其他的三个通道则不会。这导致你只能看到红颜色。有些纹理被用来使用做噪声或者用来创建其他类型的图形。你甚至可以使用纹理来存储信息从而来改变地形结构。纹理对象的用处真的是很多。

缓冲

Shadertoy 提供了对缓冲的支持。你可以在完全不同的着色器中运行不同的缓冲。每一个着色器将会输出最终的一个fragColor,这个值可以被用作其他的着色器中去,最后传递到mainImage函数中输出颜色结果。缓冲分四种:BufferA, BufferB, BufferC,BufferD。每一种缓冲都可以保存四个通道值。要访问缓冲,我们使用其中的一个。现在就来练练手吧。在你的编辑器的顶部,可以看到一个image栏标签。image标签就是是我们的主着色器程序。要添加一个buffer,只需要简单地点击加号即可。

点击下拉的菜单,选择一个common,声音,以及BufferA, BufferB, BufferC,BufferD和CubeMapA等若干个选项。

Common的选项被用来在不同的着色器(所有的缓冲,包括其他的着色器声音以及CubemapA)中分享代码。声音选项让我们创建一个声音着色器。CubeMapA选项让我们生成一个立方体贴图。本文中,我们试试每个缓冲着色器,这些着色器都是普通简单的着色器,返回一个类型为vec4 的着色器片段(红,绿,蓝,透明);选择一个BufferA. 你就可以看到下面的模板的代码了。

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

看起来代码只是简单的返回蓝色。我们返回到image标签栏中去。点击iChannel0,切换到其他(Misc)标签栏。选择BufferA。你现在就可以使用在IChannel0中使用BufferA了。在image着色器中,粘贴下面的代码:

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

  vec3 col = texture(iChannel0, uv).rgb;
  
  col += vec3(1, 0, 0);

  // Output to screen
  fragColor = vec4(col, 1.0);
}

运行上面的代码,就可以看到整个画布变成了紫色。这是因为我们从BufferA中提取了蓝颜色值, 传递到了Image着色器中去,然后又给蓝色添加一个红色,结果就是我们在屏幕上看到的紫色。本质上,缓冲为我们提供了更大的发挥空间。你可以将整个着色器的功能在BufferA中实现,然后将结果传递到更多的Buffer里面去。最后传递结果给主着色器作为最终的输出。把它们想象为一个管线通道,颜色可以通过这个管道一直传递下去。这就是为什么使用了缓冲或者添加了多个着色器的程序也被称之为多通道着色器。

使用键盘

你可能见过一些着色器可以使用键盘来控制场景。我写过一个着色器,展示如何移动物体,通过键盘以及缓冲,存储每个按键的值,看过这个着色器,你就能看到我们是如何使用多个通道的。在BufferA中你可以看到如下的代码:

  // Numbers are based on JavaScript key codes: https://keycode.info/
const int KEY_LEFT  = 37;
const int KEY_UP    = 38;
const int KEY_RIGHT = 39;
const int KEY_DOWN  = 40;

vec2 handleKeyboard(vec2 offset) {
    float velocity = 1. / 100.; // This will cause offset to change by 0.01 each time an arrow key is pressed
    
    // texelFetch(iChannel1, ivec2(KEY, 0), 0).x will return a value of one if key is pressed, zero if not pressed
    vec2 left = texelFetch(iChannel1, ivec2(KEY_LEFT, 0), 0).x * vec2(-1, 0);
    vec2 up = texelFetch(iChannel1, ivec2(KEY_UP,0), 0).x * vec2(0, 1);
    vec2 right = texelFetch(iChannel1, ivec2(KEY_RIGHT, 0), 0).x * vec2(1, 0);
    vec2 down = texelFetch(iChannel1, ivec2(KEY_DOWN, 0), 0).x * vec2(0, -1);
    
    offset += (left + up + right + down) * velocity;

    return offset;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // Return the offset value from the last frame (zero if it's first frame)
    vec2 offset = texelFetch( iChannel0, ivec2(0, 0), 0).xy;
    
    // Pass in the offset of the last frame and return a new offset based on keyboard input
    offset = handleKeyboard(offset);

    // Store offset in the XY values of every pixel value and pass this data to the "Image" shader and the next frame of Buffer A
    fragColor = vec4(offset, 0, 0);
}

在Image着色器中,你可以看到如下的代码:

  float sdfCircle(vec2 uv, float r, vec2 offset) {
    float x = uv.x - offset.x;
    float y = uv.y - offset.y;
    
    float d = length(vec2(x, y)) - r;
    
    return step(0., -d);
}

vec3 drawScene(vec2 uv) {
    vec3 col = vec3(0);
    
    // Fetch the offset from the XY part of the pixel values returned by Buffer A
    vec2 offset = texelFetch( iChannel0, ivec2(0,0), 0 ).xy;
    
    float blueCircle = sdfCircle(uv, 0.1, offset);
    
    col = mix(col, vec3(0, 0, 1), blueCircle);
    
    return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 uv = fragCoord/iResolution.xy; // <0, 1>
    uv -= 0.5; // <-0.5,0.5>
    uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

    vec3 col = drawScene(uv);

    // Output to screen
    fragColor = vec4(col,1.0);
}
绘制一个圆,使用键盘移动它。其实就是我们通过按键的值,应用到圆的偏移值上去。
![](https://img2020.cnblogs.com/blog/612959/202112/612959-20211228105633122-440919328.png)

如果你仔细观察BufferA,会发现我在iChannel0中调用了自身。这是怎么回事呢?当你在BufferA着色器使用自身时,会获取fragColor的前一帧的值。这里并非递归,GLSL代码中不允许使用递归,而只能使用迭代。但这不能妨碍我们逐帧地使用帧缓冲。
texelFeth函数的作用是在纹理中查找单个纹素。但键盘并不算一个真正的纹理。所以它是如何工作的呢?Shadertoy本质上把所有的信息以纹理的形式进行存储,这样我们就有权限通过访问纹理从而获取任何的信息了。texelFeth检查一个键盘是否被按下,决定是回退0或者前进1,再乘以一个veclocity的值来调整位移座标。位移值将会被传递到下一帧里面去。最后它会被传递到主着色器中去。如果场景运行的速率是60帧,即一帧的切换速度是六十分之一秒,那么在多个着色器之间,取最后一个帧的BufferA值,然后将像素绘制到画布上面去。这个循环将会每秒60次地循环执行下去。其他的交互元素,例如麦克风也是通过这样的方式被访问的。你可以去阅读Inigo Quilez创建的示例,这些例子都是关于如何在Shadertoy中使用各种交互元素。

总结

纹理在计算机图形学以及游戏开发中是一个很重要的概念。GLSL以及其他的一些语言为访问纹理数据提供了特定的函数。Shadertoy也为我们提供了很多便捷的方法,让我们快速地访问可以交互的元素。你可以利用纹理储存颜色值,或者其他完全不同的数据类型例如高度,位移,深度或者其他的任何你想要的东西。查看下面的资源学习使用更多的可交互元素。

引用资源

Khronos: Data Type (GLSL)
Khronos: texture
Khronos: texelFetch
Khronos: Sampler (GLSL)
2D Movement with Keyboard
Input - Keyboard
Input - Microphone
Input - Mouse
Input - Sound
Input - SoundCloud
Input - Time
Input - TimeDelta
Input - 3D Texture
Example - mainCubemap
Cheap Cubemap

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章