刚升 Lv.2,送给佬友们《你家猫娘也能看懂的 Shader(着色器)教程》

Midnight_Brick 2026-07-15 14:43 1


一写就是 好几天 十好几天,实际上已经升 Lv2 一段时间了(



0. 引言


近期看到 L 站不少佬的帖子涉及「前端好不好看」「前端怎么才能好看」之类的的问题。作为老前端工程师,我想讲讲在专业前端领域常用的技术之一:Shader。




相信大家总是会看到一些 hero 区域带有独特和奇妙交互效果的 portfolio、landing 或个人网站,比如:https://galekto.com、https://k95.it/en、https://shadergradient.co/、以及本人的https://owen-tsai.github.io。




我的网站中,鼠标在页面上移动会产生墨水阴染的效果,墨水本身做不规则的噪声动画,且颜色在灰度范围内不断变化。





Galekto 的项目展示页面。鼠标移过会有反色墨水的印染效果,整个网站仿 CRT 旧式显示屏,带有扫描线和畸变,四个角有滤镜(filter)。





TRAE 落地页中,像素点阵呈现不规则噪声动画,且颜色在白-绿范围内不断变化。





Outsource Consultants 公司首页,鼠标移动时颗粒化像素点阵跟随鼠标移动产生墨水晕染效果,颜色在蓝色范围内不断变化。





除了这些例子之外,Awwwards 上还有很多网站。全球各地的评委每天都会评选SOTD(Site of the Day),获奖作品基本都致力于构建完整和沉浸式的数字体验。BTW 我在很多贴下面都评论过这个网站,建议各位前端同仁如有不知道的(不太可能)狠狠地收藏,每天打开看。


Anyway,上面提到的这些效果几乎都是借助 WebGL 实现的,而操作 WebGL 最简便的实现方式就是通过 three.js 或者 p5.js。我们的这个教程以 three.js 为例搭建示例项目。


倘若你要问我 three.js 能做到的最出众的网站效果是什么,我就要给你下面这个网站:





Bruno Simon 是一位英语口音相当浓厚的法国创作者,他的 portfolio 一直被视为 three.js 社区最富有风格的 digital experience。





罗马不是一日建成的。今日我们不会马上就做出这样出色的网站,而是会先探讨一个让很多人闻之色变的基础知识——Shader,了解 Shader 并且编写 Shader。




1. 正在编译着色器 …1%



经常玩游戏的佬友应该知道,近些年的游戏在开始前往往会有一个Compiling Shader / 编译着色器的过程。所谓的着色器就是 Shader。


Shader 是一段「相对较小」的程序,运行在你的显卡(GPU)上,计算你屏幕上正在显示的物体的每一个像素的光照、颜色、形状。换言之,Shader 决定了一个物体如何被渲染在屏幕上



[!Note] 为何以前的游戏没有编译着色器的过程?

老玩家们会注意到一件事,那就是老游戏通常没有单独的编译 Shader 的过程。这是因为老游戏的渲染管线相对固定,API(通常是DX9)进行了实时编译,但代价就是我们老玩家往往都有很高的容忍度,因为那个时候的游戏在新的怪物/区域/特效出现后都会有卡顿。但现代游戏 Shader 非常多且复杂,必须在游戏开始前提前编译。尤其是现代的设备(GPU)也是五花八门,在一块玻璃上玩游戏?我真的想都不敢想



这也是让很多人「谈 shader 色变」的原因之一。Shader 运行在 GPU 上,跟寻常的程序有些许不同,比如没有完备的及第三方库和生态、调试困难、涉及大量的数学知识(矩阵、向量和你大学学到的线性代数的一切)。不过好在,我们编写 shader 用到的语言是一种语法类似 C 的高级抽象 GLSL(OpenGL Shading Language),我们会在接下来的教学中学习基本的语法。


总之,Shader 或者叫着色器是 WebGL 和游戏领域绕不开的东西。


2. 着色器的分类


我们通常把着色器分为两类:Vertex Shader 和 Fragment Shader。两者都使用 GLSL 语言编写,但职责各有不同。


Vertex Shader 通常翻译为 「顶点着色器」


我们都知道,即便精致如顶级 3A 游戏中的 Arthur Morgan(来自荒野大镖客2:救赎)、V(来自赛博朋克 2077)、Trevor Philips(来自 GTA 5) 或者齐天小圣(来自黑神话:悟空),也都是由几何体经过拼、切、割、组合等「雕塑手法」建模而成的,因此,这些游戏物体都有成千上万个「顶点」。顶点着色器决定了最终渲染到屏幕上的每一个「顶点」的位置信息。



[!Note] 一个立方体有几个顶点?球体呢?

立方体通常被认为有 8 个顶点——这是假设构成立方体的 x,y,z 三个轴方向上都分别只有 1 条线段。事实上,我们有时会向物体中插入更多的顶点使其支持形变。我们会在后面详细讲到。

至于球体,计算机中没有完美的球形。考虑微分原理,无论球如何规则,其表面都是被微分成很多个「面」的多面体。理想情况下,一个完美的球的顶点趋向于正无穷个。



Vertex Shader 的典型应用场景就是随风摆动的草。只需要在 Vertex Shader 中随着风的方向、按照从 y 轴由下到上越来越大的位移来偏移草的顶点,就可以实现这一需求。类似地,**一波一波的海浪可以看作正弦函数,因此为一个长方体的上表面各顶点叠加正弦波函数就可以(近似)实现。


Fragment Shader 通常被翻译成 「片元着色器」 或者 「片段着色器」。Fragment Shader 决定了物体的某一个部分(即片段)是什么颜色。这也是今天的教程中主要讲解的 Shader



[!Note] Fun Fact

Fragment 是碎片的意思。在 Arma 等 Milsim 游戏中,投出手雷要对队友喊「Frag out」以示告警,Frag 代表破片手雷(Fragmentation Grenade)。

下面的教程中我可能也会用 Frag Shader 代表 Fragment Shader,会意就好。



综上所述:


Vertex Shader 决定了物体每一个顶点的位置。所以,在某时刻,Vertex Shader 对每个顶点均执行一次。Fragment Shader 决定了物体每一部分的颜色。所以,在某时刻,Fragment Shader 对每个部分(片元)均执行一次


故而,你的 GPU 每时每刻都在并发地执行高达千万甚至亿次的计算,它承担的任务不可谓不重,在我的 Alienware 机型中需要功率极高的风扇为其散热。可想而知,模型越精细,顶点就越多,从《Minecraft(我的世界)》到《007 First Light(007 初露锋芒)》,对 GPU 的要求自然也是不同的。


以我的 MacBook 为例,视网膜屏幕的分辨率为 2560 * 1664,假设每秒渲染 60 帧,则每秒需要进行 255590400(2亿5千万次)运算。



[!Note] Fun Fact

GPU 的强大计算能力也导致了它被用于很多其他的计算密集型场景,其中一些永远改变了人类的生活,另一些为中、外玩家们所共同唾弃。



3. 建立 demo 场景


在学习 Shader 之前,我们介绍了一些有关模型的常识。接下来,我们需要创建一个基于 three.js 的 demo 场景,好让我们继续学习 Shader 的编写。


由于我们的重点意在讲解 Shader,所以我不会涉及太多的前端知识,也不会用 react 或者 vue 或者 svelte,也不会让大家在本地搭建项目。我在 codepen 上创建了示例,大家只需要在线编码就可以实时预览结果。不用担心,我总是会给出代码并进行讲解,即便佬友们没有用过JS。而且 JS 的上手难度十分之低,这也是为什么前端快要灭绝了


Codepen 在线项目:


https://codepen.io/editor/Lem0/pen/019f49cd-b716-7d3b-a16d-bc3ee6c13798


打开链接后,屏幕上应当会显示一个白色的立方体,你可以拖拽改变视角并移动滚轮进行放大缩小等基础操作。


我们来简单看一眼代码。这部分 JS 代码几乎不会改动,所以我们只看一次(当然佬友们看到后面时还是可以滚动鼠标再回来看第二次)。



[!Tip] 注意

你不用试图理解每一行代码都做了什么——它们只是调用 three.js 的 API,在屏幕上渲染一个立方体而已。




  • L15~L18 创建了一个「场景」和一个「摄像机」。摄像机的位置为(0, 1, 3),这是一个空间中的点。摄像机位于这个点,照向场景中。这就是为什么我们可以「看到」白色立方体。

  • L23 创建了一个盒状几何体,其长宽高均为单位 1。这是一个单位立方体

  • L24 创建了一个「材质」,L25 将材质「赋予」到单位立方体上,形成了最终大家看到的立方体cube

  • L26 设置cube的在空间坐标系中的位置为坐标原点(0, 0, 0)。L27 设置其沿 y 轴方向旋转 45°。

  • L33~L37 初始化了 WebGL 渲染器,并为摄像机camera添加了控制操作,也就是鼠标拖拽和缩放。

  • L39~L42 是一个函数animate,该函数在 L43 渲染器实例renderersetAnimationLoop方法中被调用。该方法会无穷无尽地循环执行以更新画面。



[!Note] 什么是 FPS?什么是动态模糊?什么是卡顿?

在学校,我们被告知要避免无法结束的循环,但在游戏开发中则不是这样的——游戏始终运行在循环中。游戏每秒都会渲染几十次画面,每一个画面被称为一帧(frame)。一秒之内游戏可以渲染的帧数称为帧率(Frame/Second, FPS)。


电影的帧率通常为 24。经常打游戏的佬应该知道,主机游戏的帧率往往是 30,而 PC 游戏至少需要 60 才不会觉得卡顿。为什么电影的帧率只有 24 却还是不卡呢?


这是因为电影并非计算机生成的画面。假如你拍摄一张汽车飞驰而过的照片,汽车会拖着残影,这就是上一帧和下一帧之间的「过渡信息」。电影的画面也始终有两帧之间的过渡。但是计算机渲染的画面不存在这样的过渡,必须用更多的画面(帧)弥补这种视觉上缺少的信息。


至于很多游戏中的「动态模糊」功能,其实就是用残影去模拟这种过渡信息。主机游戏 30 FPS + 动态模糊之后画面也会感觉很流畅。但缺点就是容易加剧 3D 眩晕的症状。





在现在的画面中,我们会看到几个不自然的地方:



  • 立方体各个面都是完全一样的白色。

  • 完全无法分辨出棱的位置,因为棱也是完全的白色。


这是因为我们采用了一种临时材质(L.24 MeshBasicMaterial),不响应光照。关于 material 和光照等都不在我们学习的范围内,所以我们可以无视,只需要接受这种自然界中不存在的奇怪效果就可以了。


接下来我们需要MeshBasicMaterial改为ShaderMaterial,并移除ShaderMaterial构造函数的参数


修改后的代码 L.24 看上去应该是:


const material = new THREE.ShaderMaterial({})

此时,画面中的立方体会变为纯红色。


发生了什么?


在原先的模板中,我们使用了MeshBasicMaterial,这是 three.js 内置的一种材质,这个材质自带了 shader。但我们刚刚提到了,这个内置的 shader 十分简单,它不响应光照。我们把这个材质修改成了ShaderMaterialShaderMaterial材质允许我们传入我们自己编写的 Shader


现在,很显然,ShaderMaterial构造函数的参数为空,我们也没有传入任何的 Shader。那为什么立方体显示为红色呢?在我们没有传入 Shader 时,three.js 将会使用默认的 Shader。我们接下来就要实现这个默认的 Shader。




4. 佬友们的第一个 Shader


让我们打开shader.js,我已经为佬友们准备了两个常量vertexShaderfragmentShader,对应前面提到的顶点着色器的代码和片元着色器的代码。我们接下来需要写入代码。



three.js 中,shader 只能以字符串形式传入。如果佬友们在本地搭建一个前端项目,则可以引入对应的 loader 或插件来识别和处理.glsl格式的文件,并在独立的.glsl文件中编写 shader。



将下面的代码粘贴到shader.js中:


export const vertexShader = `
// vertex shader
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`

export const fragmentShader = `
// fragment shader
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`

然后,在index.js里,修改 L24 的ShaderMaterial构造函数,传入 vertex shader 和 frag shader:


// index.js
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader
})
// 注意,此处我们使用了 JS 中的简写语法。
// 当一个对象的键和值同名时,可以省略冒号和值。上面的代码等价于下面的:
// const material = new THREE.ShaderMaterial({
// vertexShader: vertexShader,
// fragmentShader: fragmentShader
// })


[!Danger] 小数

注意,在 shader 中,必须显式地指定浮点数的小数部分。即:你必须写作1.0,不能省略小数部分。



此时如果画面上的红色立方体还在,恭喜,这说明你没有出错。如果屏幕上不渲染任何画面,你应该检查控制台看看 three.js 是否报告了错误。常见的错误基本都来自于拼写。



[!tip] 控制台

如果你在本地建立了项目,我相信你必定会查看控制台。如果你使用了我分享的 codepen 链接,那么你可以在预览界面右上角点击地址栏(preview:/)右侧的 show console 按钮显示控制台。



可是,我们该如何判断我们的 shader 工作了呢?毕竟现在的画面看上去和刚刚 three.js 使用内置的 shader 渲染出的画面一模一样。


我们接下来将shader.jsfragmentShader里的代码作如下更改:


  gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0);

如果一切正常,现在红色的立方体会变成绿色。我们的 shader 正常工作了


刚才发生了什么?


我们在第 2 节说到,fragment shader 的作用是决定每一部分的颜色。就像佬友们已经想到的那样,gl_FragColor是最终决定立方体颜色的变量,而赋给它的vec4则表示了一个颜色。事实上,vec 即 vector(向量),vec4表示一个四维向量。在此处,向量的四个维度以 RGBA 表示法表示了一个颜色的红、绿、蓝、透明度四个通道。


一切都很简单吧!



[!note] 那么 vertex shader 呢?

我们不会在这个篇幅内详细讨论 vertex shader,但正如你猜到的那样,gl_Position是每个顶点在世界坐标系中的最终位置,由顶点本身的位置position经过一系列的矩阵运算得到。



5. UV是什么


在(真正地)编写 shader 代码之前,我们还需要了解一下必要的概念。


你和 L 站的佬友们凑在一起玩《龙与地下城》。你为了解除陷阱投掷了一枚 d6 的骰子 ^-^。显然,这枚骰子有 6 个面,就像刚才的立方体。


当你把这枚骰子展开,它的表面会形成一个平面。这个平面上的每一个坐标点,都对应了 3D 的骰子上的一个具体的位置。在不务正业的游戏领域,我们称这张展开的平面为这个骰子的UV 映射,这个平面上的点的坐标称为UV 坐标


假设有一个 3D 游戏角色模型,那么这个模型的 UV 展开大概是这样的:



可以看到,展开图上任何一个点,都对应了 3D 模型上的一个点。当我们一掷千金购买了一款角色皮肤时,美术们正在人皮展开图上作画(x




具体到我们的 demo 场景中,我们从代码的角度认识一下 UV 坐标系。让我们修改index.jsgeometry附近的代码,增加一行 debug 输出。


/**
* GEOMETRY
*/
const geometry = new THREE.BoxGeometry(1, 1, 1)
console.log(geometry.attributes.uv) // 新增

观察浏览器的控制台,控制台会输出:


{
"itemSize": 2,
"type": "Float32Array",
"array": [
0, 1, 1, 1, 0, 0, 1, 0,
0, 1, 1, 1, 0, 0, 1, 0,
0, 1, 1, 1, 0, 0, 1, 0,
0, 1, 1, 1, 0, 0, 1, 0,
0, 1, 1, 1, 0, 0, 1, 0,
0, 1, 1, 1, 0, 0, 1, 0
],
"normalized": false
}

我们看到,一个几何体的uv包含一个数组。就本例来说,几何体是一个立方体,共有 6 个面。在上面的数组中,每一行(是我为佬友们手动分的行捏)都对应了立方体一个面。在每个面上,都有(0, 1)、(1, 1)、(0, 0)、(1, 0) 四个 UV 坐标。



[!note] UV 坐标的范围

UV 坐标会被标准化到[0, 1],与3D 物体的实际尺寸无关。换句话说,UV 坐标 (0.5, 0.5) 就是UV坐标轴 x 方向的中心和 y 方向的中心构成的点。



综上,此立方体的 UV 数组恰好是 6 个面中每个面的 4 个顶点。那么,让我们假设UV 坐标为 (0.5, 0.5) 的点是白色,这意味着立方体 6 个面的中心点均为白色


再让我们发散一下:一个长宽为 1、高为 2 的长方体(const geometry = new THREE.BoxGeometry(1, 2, 1))的 UV 坐标数组是什么样呢?


请大家思考 7 秒钟。如果你对自己的答案足够自信,请不要再展开下面的答案。




答案

答案和立方体时完全一样。因为上文:



UV 坐标会被标准化到[0, 1]。






Anyway,为什么要介绍 UV 的概念呢?这是因为 UV 的值可以在 shader 中读取。


让我们修改shader.js中的vertexShader


// vertex shader
varying vec2 vUv; // 新增

void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vUv = uv; // 新增
}

在这段代码中,我们新增了一个varying vec2 vUv,并且在main函数的最后将uv赋值给vUv。显然,uv就像projectionMatrixmodelViewMatrixposition一样,都是 vertex shader 中直接可以读取的变量。我们先无视这些内置变量,只看我们刚刚认识到的uv


我们前面讲到,vertex shader 对物体的每一个顶点执行一次。所以,uv变量就是每一个顶点的 UV 坐标。我们定义了一个vec2 vUv,然后将顶点的 UV 坐标传递给了vUv。将坐标传递给vUv之后,我们没有任何操作,vertex shader 就已经结束了。


那么,vertex shader 最后赋值的意义是什么呢?这就要说到定义vec2 vUv时使用的描述符varying了。varying定义的变量可以被传递给 fragment shader,从而使原本只在 vertex shader 中可用的 UV 坐标在 fragment shader 中也可用


那么接下来,让我们修改fragment shader


// fragment shader
varying vec2 vUv; //新增

void main() {
gl_FragColor = vec4(0.0, vUv.x, 0.0, 1.0); // 修改
}

在 frag shader 中,我们通过varying vec2 vUv接受了来自 vertex shader 传递的vUv变量。然后,在表征颜色的四维向量中,我们将绿色通道的值从固定的1.0改为了vUv.x


这意味着,原本呈现全绿色的立方体,其每一个「片段」的颜色将会跟随当前片段所处位置的 UV 坐标的x轴的值发生改变。如果代码编写无误,我们将看到如下画面:



我们已经分析过,立方体的 UV 数组中包含 6 个面,每个面的都包含四个顶点的 UV 坐标。这首先意味着我们在六个面上都应当看到一样的额图案。其次,我们知道 UV 坐标系里,x轴方向从左到右,因此对每一个面的左边缘,都有vUv.x = 0,所以每一个面最左侧边缘的颜色是:vec4(0.0, 0.0, 0.0, 1.0),即纯黑色。


同理可证,对于每一个面的右边缘,其处于 UV 坐标系中x = 1的直线上,其vUv.x = 1。所以右边缘的颜色是vec4(0.0, 1.0, 0.0, 1.0),即纯绿色。


至此,我们已经学会了(?)编写 fragment shader。





[!tip] 挑战

在刚才的 shader 中,立方体每一个面的颜色从左到右沿 x 正方向从黑色过渡到绿色。如何实现让立方体的颜色沿 y 轴正方向从黑色过渡到绿色?如何实现从 x 和 y 轴两个方向上从黑色过渡到绿色?



6. 练手阶段!让我们实现各种图案!


在本节,我们将一起通过狠狠玩弄 fragment shader 实现一些特定的图案。为了方便起见,我们只看立方体的一个面


题目 1



观察图形,右上角即 (1, 1) 的位置为白色,左下角 (0, 0) 的位置为纯蓝色。

因此可以得到颜色表达式为vec4(vUv.x, vUv.y, 1.0, 1.0)


// fragment shader
varying vec2 vUv;

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


[!note] 注意

上面的代码使用了简写。vec4需要传入向量的四个维度,我们知道vUv是一个二维向量,本身就有两个维度,因此vec4(vUv.x, vUv.y, ...)也可以直接被写为vec4(vUv, ...)





题目 2



题目 2 是题目 1 的「取反」,其 UV 坐标 (0, 0) 的位置是纯白色,而 (1, 1) 的位置是纯蓝色。请佬友们思考一下应该如何实现。




题目 2 答案

每当需要「取反」时,考虑使用 1.0 减去坐标值。


// fragment shader
varying vec2 vUv;

void main() {
gl_FragColor = vec4(1.0 - vUv.x, 1.0 - vUv.y, 1.0, 1.0);
}




题目 3



题目 3 的图案沿 UV 坐标系 x 轴正方向从黑色过渡到白色。显然,我们只需要保证vec4的RGB 三个维度值全部为vUv.x就好了。


// fragment shader
varying vec2 vUv;

void main() {
gl_FragColor = vec4(vec3(vUv.x), 1.0);
}



题目 4



在题目 3 的基础上,本题颜色过渡在 x 轴上某一点之后突然加剧,一大半的全域全都是纯白色。请各位佬友先试着挑战一下。




题目 4 答案

// fragment shader
varying vec2 vUv;

void main() {
// 此处直接乘以一个比较大的系数 5.0,就会导致 vUv.x * 5.0 在
// x=0.2 的时候达到 1 即纯白色
gl_FragColor = vec4(vec3(vUv.x * 5.0), 1.0);
}




题目 5



本题和题目 3 十分相似,大概相当于题目 3 的图形在 UV 坐标 x 轴 [0, 1] 范围内重复了十次。已知灰度颜色中,RGB值相等且 从 0 到 1 的闭区间范围内取值。假设我们令strength = R = G = B,那么我们可以定义函数f(strength),并画出它的函数图像:



锯齿波。很典型的图像,相信佬友们一眼就能看出来这是取模函数f(x) = mod(x, 1)的图像。


显然,在 UV 坐标系的 x ∈ [0, 1] 范围内,mod(x, 1)与题目 3 的线性函数完全相同,不可能重复十次。所以我们只需要简单地将x乘以 10 就能达到正确答案:


// fragment shader
varying vec2 vUv;

void main() {
float strength = mod(vUv.x * 10.0, 1.0); // 令 float: strength 为 RGB 的值
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 6



在题目 5 的基础上,本题取消了渐变,改为白色矩形黑色矩形相间的图案。依然次用函数图像反推。已知白色代表strength = 1,黑色代表strength = 0,可以画出f(strength)



这是典型的方波函数。做嵌入式的佬友们应该知道这是常见的开关信号,仅有两个状态,即 0 和 1。从数学角度讲,通过傅里叶级数,方波可以拆分为无数个不同频率的正弦波叠加,但我们显然不能这样写代码。


聪明的佬友马上想到可以用个if-else或者三目表达式。不过,更好的方法是利用 glsl 内置的step(x, y)函数。该函数对于传入的y,若y > x返回 1,否则返回 0。



[!note] 你总是应该尽量使用内置函数,以获得更好的性能表现。





题目 7



本题中,目标图(最右侧)的图案是左边两张图的「叠加」。想要叠加两种图案,自然是使用加法,关键在于如何分别得到左边两张图的图案。请佬友们自行尝试。




题目 7 答案

// fragment shader
varying vec2 vUv;

void main() {
// 左边图 1 的图案,注意`step`函数的第一个参数为 0.8 左右,可呈现白色细线
float strength = step(0.8, mod(vUv.x * 10.0, 1.0));
// 在此基础上,累加中间图 2 的图案
strength += step(0.8, mod(vUv.y * 10.0, 1.0));
// 得到最终图案
gl_FragColor = vec4(vec3(strength), 1.0);
}




题目 8



这是一个比较有意思的图形,它在题目 7 最左侧图 1 的基础上「断开」成了虚线。


此题的代码自然也与题目 7 非常相近,唯一的挑战在于发现「题目 7 的图形如何变化可以得到本题的图形」。


我们仔细分析一下题目 7 的图形:我们知道白色部分颜色为(1.0, 1.0, 1.0, 1.0),即strength = 1.0;黑色部分颜色为(0.0, 0.0, 0.0, 0.0),即strength = 0.0


当题目 7 的前两图两者相叠加时,原来在图 1 UV 坐标系中为黑色的「片段」,有两种可能:



  • 黑色的片段被叠加黑色,此时strength = 0.0 + 0.0 = 0.0,仍为黑色。比如题目 7 图 3 中间小方格的部分;

  • 黑色的片段被叠加白色,此时strength = 0.0 + 1.0 = 1.0,为白色。比如图 1 中原本不存在横向白线,但是叠加之后出现了横向的白线。


同理,对于原来在图 1 UV 坐标系中为白色的「片段」,只有一种可能,无论白色叠加黑色还是白色,最终都是白色。


反观题目 8,如果由 题目 7 的图 1 变化而来,就意味着题目 7 的图 1 需要「减去」一部分白色。我们刚刚分析过,显然用加法无法实现,因为白色叠加黑色或白色都只能是白色。


那么答案就已经非常明白了。还是请佬友们挑战一下,先不要阅读答案。




题目 8 答案

// fragment shader
varying vec2 vUv;

void main() {
float strength = step(0.8, mod(vUv.x * 10.0, 1.0));
// 注意,使用减法
// 当白色的片段减去白色的片段时,最终的颜色 = 1.0 - 1.0 = 0.0,即黑色。
strength -= step(0.8, mod(vUv.y * 10.0, 1.0));
gl_FragColor = vec4(vec3(strength), 1.0);
}




题目 9



非常有意思。本题连线都没有了,只剩下一个个点(严谨地讲,是正方形)。如果老友们仔细观察的话,会发现本题中的点恰好是题目 7 目标图形中白线的交点。


还是请佬友们自行思考:如何在题目 7 的基础上,只保留图形的交点,去掉其他所有部分的白线?


如果你还是没有思路,不妨在看答案之前先看一下下面的思路:




题目 9 思路


  1. 交点意味着什么?当然是意味着无论是水平的白线还是垂直的白线,都会经过这一点

  2. 对于原本是白色的线条部分,要想去除则意味着需要把该片段的1.0变为0.0

  3. 有什么办法可以将1变为0





题目 9 答案

// fragment shader
varying vec2 vUv;

void main() {
float strength = step(0.8, mod(vUv.x * 10.0, 1.0));
// 注意,使用乘法
// 只有当处于交点时,1 * 1 = 1;在交点以外,必有 1 * 0 = 0
// 所以交点以外的部分将全部被变为黑色
strength *= step(0.8, mod(vUv.y * 10.0, 1.0));
gl_FragColor = vec4(vec3(strength), 1.0);
}




题目 10



这里就不让各位佬友思考了,因为本题涉及一个新函数。


观察图形,发现:左半边:vUv.x 0→0.5,颜色 0.5→0;右半边:vUv.x 0.5→1,颜色 0→0.5。这符合绝对值的特性。vUv.x = 0.5 作为对称中心。


// fragment shader
varying vec2 vUv;

void main() {
// 绝对值函数 abs()
float strength = abs(vUv.x - 0.5);
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 11




快变成 Companion Cube 了,V 社会不会起诉我



看到本题的图案有的佬友可能就觉得这应该是abs(vUv.x - 0.5)abs(vUv.y - 0.5)的叠加了。但其实不是,因为我们在题目 8 分析过,对于白色区域,叠加的结果只可能是白色,无法出现本图中四条边中间部分的黑色。


相比于叠加,这里其实需要用到最小值函数min。对于任何一个片段/像素,其strength变量的取值总是取abs(vUv.x - 0.5)abs(vUv.y - 0.5)中的最小值:


// fragment shader
varying vec2 vUv;

void main() {
float strength = min(abs(vUv.x - 0.5), abs(vUv.y - 0.5));
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 12



请佬友们试着自行解决此问题。




题目 12 答案

不难发现此题与上题相反,使用最大值函数max即可。


// fragment shader
varying vec2 vUv;

void main() {
float strength = max(abs(vUv.x - 0.5), abs(vUv.y - 0.5));
gl_FragColor = vec4(vec3(strength), 1.0);
}




题目 13



此图和题目 12 的目标图形十分类似,区别在于发生了颜色突变。回想第 6 题,颜色突变意味着是方波函数,此题必定使用step


知道这一点后,答案就很简单了,我直接给出:


// fragment shader
varying vec2 vUv;

void main() {
float strength = step(
0.2,
max(abs(vUv.x - 0.5), abs(vUv.y - 0.5))
);
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 14



本题与题目 3 十分相似,区别在于本题中,UV 坐标系的 x 轴每增加 0.1,strength的值都会增加 0.1,并在接下来的 0.1 区间内保持不变。


如果画出f(vUv.x)的函数曲线,我们会得到一条阶梯函数:



不难推导:f(vUv.x) = floor(vUv.x * 10) / 10


// fragment shader
varying vec2 vUv;

void main() {
float strength = floor(vUv.x * 10.0) / 10.0;
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 15



上一题中我们绘制的是floor(vUv.x * 10.0) / 10.0对应的图形,佬友们可以自行绘制出floor(vUv.y * 10.0) / 10.0的图形。然后思考一下,如何可以获得本题的目标图形呢?




题目 15 答案

// fragment shader
varying vec2 vUv;

void main() {
float strength = floor(vUv.x * 10.0) / 10.0;
strength *= floor(vUv.y * 10.0) / 10.0;
gl_FragColor = vec4(vec3(strength), 1.0);
}

为什么不用加法进行叠加?相信有的佬友应该这样试过。我们可以看到目标图形中,即便是最右上角的一个方格,其颜色也并不是纯白色(因为floor(vUv.x * 10.0)/10.0最大只能得到0.9),而使用加法的话,会导致右上大部分片段的颜色stength超过 1。





题目 16



图中包含一个黑色的圆形,圆心位于 UV 坐标系的(0, 0),半径为 1。


比较敏感的佬友们应该能推测出来,strength为二维向量 vUv 的长度。当vUv.x = 0 && vUv.y = 0时,二维向量 vUv 长度为 0,对应左下角黑色的点。随着vUv.xvUv.y增加,二维向量 vUv 的长度最大变为 1。


在 glsl 语言中,向量的长度使用length函数计算:


// fragment shader
varying vec2 vUv;

void main() {
float strength = length(vUv);
gl_FragColor = vec4(vec3(strength), 1.0);
}

或者,使用distance函数可以计算任意两个点之间的距离。注意,每个点也都是一个二维向量。


float strength = distance(vUv, vec2(0, 0));
// vUv 是简写写法,即 vec2(vUv.x, vUv.y)



题目 17



相比于题目 16,本题的目标图形将圆形的圆心移动到了 UV 坐标系中(0.5, 0.5)的位置。请佬友们自行思考一下,再看答案。




题目 17 答案

// fragment shader

varying vec2 vUv;


void main() {

float strength = distance(vUv, vec2(0.5, 0.5));

gl_FragColor = vec4(vec3(strength), 1.0);

}





题目 18



本题中,图形是一个在外围变化极为缓慢、但靠近中心后突然急剧变为白色的光点。这个图形依旧与图 17 类似,可经过变换得到。


首先观察本题图形,是一个白色的圆形。第 2 题说过,如果需要取反则考虑用 1 减去已有的值。我们先用 1.0 减去题 17 的图float strength = 1.0 - distance(vUv, vec2(0.5, 0.5)),得到下面的图形:



在此基础上,我们需要让颜色「急剧变化」。我们可以简单地用一个小数除以当前的图形。


// fragment shader
varying vec2 vUv;

void main() {
float strength = 1.0 - distance(vUv, vec2(0.5, 0.5));
gl_FragColor = vec4(vec3(strength), 1.0);
}



题目 19



首先,不妨先画一个白色实心圆形。



我们前面讲到,颜色突变意味着使用step函数,因此代码可以很容易地写出:


// fragment shader
varying vec2 vUv;

void main() {
float strength = 1.0 - step(0.3, distance(vUv, vec2(0.5, 0.5)));
gl_FragColor = vec4(vec3(strength), 1.0);
}

接下来,我们只需要将这个实心圆形「掏空」即可。很容易想到的方法是利用减法。在这个圆形内部再画一个小一点的圆形,然后使用减法「掏空」剩下圆环。代码如下:


// fragment shader
varying vec2 vUv;

void main() {
float strength = 1.0 - step(0.3, distance(vUv, vec2(0.5, 0.5)));
// 小一圈的圆形, r = 0.28
strength -= 1.0 - step(0.28, distance(vUv, vec2(0.5, 0.5)));
gl_FragColor = vec4(vec3(strength), 1.0);
}


[!note] 更多解法

聪明的佬友一定会想到还有别的解法,不妨挑战一下!





题目 20 - 终极挑战:函数可视化



目标图形与题目 3 相同,是线性渐变。设 RGB 颜色为f(vUv.x),则其函数为f(vUv.x) = vUv.x


与题目 3 的区别在于,不仅图案是颜色从黑到白的线性渐变,图上还叠加了f(vUv.x) = vUv.x的图像,以一条绿色的线绘制。


下面是代码和注释:


// fragment shader
varying vec2 vUv;

// 绘图函数
float plot(vec2 uv) {
// smoothstep 函数:
// 当 abs(uv.y - uv.x) 的值在 0~0.02 范围内时,将进行平滑过渡
return smoothstep(0.02, 0.0, abs(uv.y - uv.x));
}

void main() {
// 定义函数 fx = vUv.x
float fx = vUv.x;
// 定义用来画图的颜色,我这里使用纯绿色
vec3 pColor = vec3(0.0, 1.0, 0.0);
// 调用你的 plot 函数画图
float p = plot(vUv);

// 设置颜色
// p * pColor 将得到绿色的一次函数线条,此时是浮点数 p 与向量 pColor 进行乘法运算
vec3 color = p * pColor;
// 绿色线条叠加题目 3 的图形代码
color += (1.0 - p) * vec3(fx);
// 最终的颜色
gl_FragColor = vec4(color, 1.0);
}

觉得有余力的佬友们可以试着编写以下几个函数的可视化图形:


(设 x = vUv.x



  1. f(x) = x^5




  1. f(x) = step(0.5, x)




  1. f(x) = sin(x)



注意,你可能需要对plot函数进行修改,因为一次函数是特殊情况。各位佬友可以运用自己的推理力争分夺秒全力以赴,推断出plot中的画图的表达式。




7. 下期预告


预告一下,今天吃炒米粉:





相信各位佬友一定觉得很失望:这 20 个 fragment shader 离文章开头那些网站上的效果相去甚远。但相信我,本节所讲的内容是编写更复杂的 shader 所绕不开的基础知识。


这一系列已经太长了,长到编辑器有点卡顿(也可能是我开着开发环境、摸着鱼、还在玩 Dave the Diver 的最新 DLC 的缘故)。如果后面还有机会,我还会给大家分享后续的教程:



  • 本期教程为了专注于图形,没有涉及颜色的计算。我们也许应当好好聊一聊颜色,让对颜色毫不敏感的后端程序员(x)们掌握一点颜色的基本知识,然后在 shader 里使用颜色。

  • 各种形状。下一期教程将带来更多的图形和它们的实现方法,其目的在于启发佬友们的创造能力。我小的时候曾经画过会飞的车,家父看到后说「车怎么会飞呢」。虽然现如今车确实会飞了,但在漫长的岁月里我们都丧失了创造力,要成为一名合格的前端工程师,就必须把它找回来。

  • 矩阵运算。上帝啊,我非常讨厌线性代数,但要对图形进行形变就不得不讲。希望事态不至于发展到那一步。

  • Vertex Shader。是的,我们还没有开始 vertex shader。

  • 动画。引入时间,让 shader 成为跟随时间的函数。




有的佬友可能听说过 generative art,指的是利用计算机升序生成的令人叹为观止、精妙绝伦的艺术作品。他们的底层都是四则运算、各种函数和三角函数、旋转位移和重复所构成的。如果再叠加上颜色,我们将面对几乎无限的可能性。


所以,这些代码就是你的基本功;遵守和理解它非常重要。你将会一遍又一遍地回到 0.0 到 1.0 这个区间,你将会掌握融合与构建这些代码的艺术。


是的佬友们,页面是世界上最无聊的东西,前端多多少少需要一点艺术。


— TBC —

最新回复 (1)
  • 怎么是这样的? 07-16 00:07
    1

    太牛逼了佬友,我看到后期已经快懵了 ^-^

* 帖子来源Linux.do
返回