【免费下载链接】thebookofshaders
Step-by-step guide through the abstract and complex universe of Fragment Shaders.
导读
本章是 The Book of Shaders(09/README-vi.md)中「算法绘图(Algorithmic Drawing)」部分的收官章节,核心主题是如何在片段着色器中用极小的计算代价生成可无限平铺(tile)的图案。由于着色器对每个像素独立执行,无论画面中重复多少次同一形状,总计算量都保持不变,这让平铺图案成为片段着色器最擅长的场景之一。读完本文,你将掌握三大核心技法:用fract()把坐标空间切成重复网格、用矩阵变换在单个格子内平移/旋转/缩放形状、以及利用奇偶行/列的判断构造砖墙错位与 Truchet 瓷砖等经典图案,并最终学会设计属于自己的平铺规则。
平铺的底层动机:逐像素执行 = 免费复制
理解本章一切技巧的前提,是片段着色器的执行模型:程序为屏幕上的每一个像素独立运行一遍,与屏幕上画了多少个形状无关。因此,在u_resolution为 1920×1080 的窗口里,一帧固定执行约 200 万个片段的main();无论你在里面绘制 1 个圆还是 1000 个圆,这个数字都不会变。
这正是 The Book of Shaders 反复强调的设计思路:让坐标空间本身去“复制”形状,而不是在代码里显式地绘制多个副本。本章的策略与前几章一脉相承——先在归一化坐标[0.0, 1.0]区间内画好一个“母版”形状,然后通过坐标变换把它平铺成网格。开篇引用的 Nina Warmerdam《The IMPRINT Project(2013)》就是这种思路的艺术化表达(对应示例 09/dots5.frag,其核心是两个不同缩放级别网格上circlePattern()的叠加):
第一步:用 fract() 把空间切成重复网格
什么是 fract()
文档首先回顾了 fract() 函数:它返回一个数的小数部分,本质上是除以 1 的取模运算,即fract(x)等价于mod(x, 1.0)。换句话说,fract()只保留浮点小数点之后的数字。
由于我们常用的归一化坐标变量st本身就在0.0 ~ 1.0之间,直接对它调用fract()是毫无意义的:
void main(){ vec2 st = gl_FragCoord.xy/u_resolution; vec3 color = vec3(0.0); st = fract(st); color = vec3(st,0.0); gl_FragColor = vec4(color,1.0); }放大坐标空间,激活 fract() 的平铺能力
关键在于:先把坐标空间整体放大,再取小数部分。假设把归一化坐标放大 3 倍,那么同一份代码会得到 3 段首尾相接、互不重叠的0~1线性渐变:第一段落在0-1,第二段落在1-2,第三段落在2-3。每一段在fract()作用下都会被“折回”到[0, 1]。
09/grid-making.frag 第 21~22 行正是这一招的精髓:
st *= 3.0; // 把空间放大 3 倍 st = fract(st); // 折回到 1.0 以内放大前后对照:st * 3的取值会溢出 1,而fract(st * 3)永远落在[0, 1]。以 x 轴为例,st = 0.10时st*3 = 0.30、fract = 0.30;st = 0.50时st*3 = 1.50、fract = 0.50;st = 0.80时st*3 = 2.40、fract = 0.40。可以看到每个st*3超过 1 的区间,fract()都把它折回了归一化范围(这一数值推演记录在仓库的 09/notes.md 中,作者 nicolas 详细列出了三组 block 的数值对照表)。
于是我们得到了一个3×3 的网格,每个格子都是[0, 1]的迷你坐标系。把 09/grid-making.frag 第 27 行取消注释,就会在每个迷你空间里绘制一个圆(circle()函数基于smoothstep实现,见第 10~15 行)。由于 x、y 两轴按同一比例放大,格子的宽高比不变,形状不会变形。特别注意:整个过程完全没有使用循环。
配套练习
文档给出了三个加深理解的练习:
- 改变放大倍数:尝试把
3.0换成浮点数,或对 x、y 两轴使用不同的倍数(如st *= vec2(3.0, 5.0)),观察网格密度与格子宽高比的变化。 - 封装成可复用函数:把
st *= zoom; st = fract(st);这一平铺技巧提炼为独立的工具函数,例如与 09/checks.frag 第 32~35 行中的tile()函数一致的形式:vec2 tile(vec2 _st, float _zoom){ _st *= _zoom; return fract(_st); } - 识别行列,绘制井字棋:把空间分成 3 行 3 列,想办法得知当前像素位于哪一行、哪一列,据此切换各格子内绘制的形状(圈、叉、空),组合成一局井字棋对弈。这为后面 Truchet 瓷砖的奇偶判断埋下伏笔。
在图案内部应用矩阵变换:checks.frag 与菱形瓷砖
既然每个格子都是归一化坐标系的缩小版,就可以对它施加此前学过的矩阵变换——平移、旋转、缩放——实现“每个格子内部再变换”。
09/checks.frag 演示了完整的组合流程:
// 把空间分成 4 格 st = tile(st,4.); // 用矩阵把空间旋转 45 度 st = rotate2D(st,PI*0.25); // 绘制一个方块 color = vec3(box(st,vec2(0.7),0.01));其中rotate2D()(第 24~30 行)实现了经典的 2D 旋转矩阵:先把坐标平移到以中心为原点(_st -= 0.5),乘以mat2(cos(_angle), -sin(_angle), sin(_angle), cos(_angle)),再平移回原点;box()(第 37~43 行)用两个smoothstep做带抗锯齿的方块边界。旋转发生在每个独立格子的局部坐标系内,因此整幅画面呈现出棋盘格上旋转方块的效果。
进阶练习
- 让图案动起来:思考让这个图案运动的趣味方式,例如分别对颜色、形状、运动方式做动画,创作 3 个不同的动画版本(
u_time是仓库中所有.frag示例统一提供的 uniform)。 - 组合多个形状:把多个基础形状叠加组合成更复杂的图案,示例 09/diamondtiles.frag 即是通过
offset()偏移函数 +rotate2D旋转 + 两层box()相减(第 74 行)构成菱形瓷砖:
[](https://link.gitcode.com/i/dd5ff4f23f797b4e86124e6d45b68d52)- 多层图案叠加:把不同层级的平铺叠加起来,例如复刻苏格兰 Tartan 格子呢的经典织纹(下方为 Kavalenkava 创作的 Tartan 风格矢量图案)。Tartan 的本质就是多组不同间距、不同颜色的横竖条纹按固定节拍交错,天然适合“多层平铺 + 分层着色”的 shader 方案。
错位图案:用奇偶行判断砌一堵砖墙
问题:如何错开每一行
假设要模拟一堵砖墙——观察真实墙面会发现,每隔一行的砖在 x 方向错开了半块砖。如何实现?
第一步是判断当前像素所在的行是偶数还是奇数,因为只有奇数行需要平移。判断工具是mod():对行号取mod(x, 2.0),余数小于1.0说明是偶数行,否则是奇数行。文档给出的核心公式(可用simpleFunction交互式验证):
y = mod(x,2.0); // y = mod(x,2.0) < 1.0 ? 0. : 1. ; // y = step(1.0,mod(x,2.0));三种写法殊途同归。第二种用三元运算符判断余数是否小于1.0;第三种改用内置的step()函数——更快。为什么?虽然很难确定每块显卡如何编译优化代码,但可以稳妥地假设:内置函数通常比手写表达式更快,而step()是硬件级优化过的,条件分支语句在 GPU 上则非常慢(需要强制求值并合并分支)。因此文档给出的黄金法则是:能用内置函数,就绝不写条件判断。09/notes.md 进一步指出,三元运算符本质上是if的伪装,会强制分支求值;替代方案y = floor(mod(x, 2.0))只需加载一个内存值,甚至可以用float(int(mod(x, 2.0)))类型转换去掉floor(把 float 强转 int 会丢弃小数部分,效果等同于 floor,注意要再转回 float)。
brickTile():奇数行平移半格
09/bricks.frag 第 10~17 行的brickTile()函数完整展现了这一技巧:
vec2 brickTile(vec2 _st, float _zoom){ _st *= _zoom; // 错位在这里发生 _st.x += step(1., mod(_st.y,2.0)) * 0.5; return fract(_st); }第 14 行是灵魂:step(1., mod(_st.y, 2.0))对 y 行号奇偶检测——偶数行该表达式结果为0.0,乘以0.5偏移后仍为0.0,砖块不动;奇数行结果为1.0,乘以0.5后把 x 轴平移半格,砖块错开。main()中以brickTile(st, 5.0)调用,再用box()绘制砖形。
文档还提示了两处可尝试的代码:
- 第 32 行:取消注释
st /= vec2(2.15,0.65)/1.5;,会按“现代公制砖”的尺寸比例(215mm × 102.5mm × 65mm,见代码注释引用的砖块尺寸标准)拉伸坐标轴宽高比,得到更真实的砖形。 - 第 40 行:取消注释
color = vec3(st,0.0);,可以看到每个格子内的坐标系被映射成红色(x)和绿色(y),直观理解局部坐标。
动态化练习
- 按时间移动偏移:让错位量随
u_time变化,砖墙会“呼吸”般地滑动。 - 偶数行左移、奇数行右移:把
step(1., mod(_st.y, 2.0)) * 0.5中的偏移方向按行号取反。 - 改为列错位:把奇偶检测从
_st.y换到_st.x,就能实现竖排错位。 - x、y 双向偏移:结合两个轴的错位,得到类似 09/marching_dots.frag 的效果——它的
movingTiles()函数(第 13~30 行)用fract(time) > 0.5在「先移动 x 再移动 y」两个相位间切换,并依据fract(_st.y * 0.5)/fract(_st.x * 0.5)的奇偶性决定每个格子的移动方向,形成整片圆点阵列交错行进的有趣动画。
Truchet 瓷砖:用奇偶索引旋转出无限设计
单元素、四姿态
在学会判断格子位于偶数/奇数行、偶数列/奇数列之后,就可以根据格子位置复用同一个设计元素。Truchet 瓷砖正是这样的经典案例:单个设计元素有 4 种呈现方式(每旋转 90° 一种),通过在不同格子间切换姿态,能构造出无穷无尽的复杂设计:
rotateTilePattern() 剖析
09/truchet.frag 第 25~60 行的rotateTilePattern()是核心,文档要求重点研读。它把每个格子再细分为 2×2 的 4 个子格,并为每个子格分配旋转角度:
vec2 rotateTilePattern(vec2 _st){ // 把坐标系放大 2x2 _st *= 2.0; // 根据位置给每个格子一个索引号 float index = 0.0; index += step(1., mod(_st.x,2.0)); index += step(1., mod(_st.y,2.0))*2.0; // | // 2 | 3 // | //-------------- // | // 0 | 1 // | // 让每个格子回到 0.0 - 1.0 _st = fract(_st); // 根据索引旋转每个格子 if(index == 1.0){ _st = rotate2D(_st,PI*0.5); // 格子 1 旋转 90 度 } else if(index == 2.0){ _st = rotate2D(_st,PI*-0.5); // 格子 2 旋转 -90 度 } else if(index == 3.0){ _st = rotate2D(_st,PI); // 格子 3 旋转 180 度 } return _st; }索引的计算方式非常巧妙:step(1., mod(_st.x, 2.0))对 x 的奇偶性给出 0/1,step(1., mod(_st.y, 2.0)) * 2.0对 y 的奇偶性给出 0/2,两者相加得到 0~3 的唯一编号(见代码注释中的布局图)。主函数里先用tile(st, 3.0)平铺,再调用rotateTilePattern(),最后用step(st.x, st.y)画出黑白三角形:
void main (void) { vec2 st = gl_FragCoord.xy/u_resolution.xy; st = tile(st,3.0); st = rotateTilePattern(st); // 组合出更有趣的效果 // st = tile(st,2.0); // st = rotate2D(st,-PI*u_time*0.25); // st = rotateTilePattern(st*2.); // st = rotate2D(st,PI*u_time*0.25); gl_FragColor = vec4(vec3(step(st.x,st.y)),1.0); }练习清单
- 组合设计:注释、取消注释、复制第 69~72 行(
tile()、rotate2D()、rotateTilePattern()的嵌套组合),观察旋转与缩放叠加后产生的新图案。 - 替换元素:把黑白三角形替换成其他元素,比如两个半圆、旋转过的方块、直线等(仓库中 09/rotatedtiles.frag 就用三角形元素 + 时间旋转做了变体)。
- 按位置旋转:编写按格子自身位置决定旋转角度的图案。
- 按位置改属性:让颜色、大小、线条粗细等其他属性也随格子位置变化。
- 跳出图案:把本章原理应用到非图案领域——文档举的例子是易经卦象。仓库中的 09/iching-01.frag 就是完整实现:
hex()函数(第 44~76 行)把 0~63 的整数N逐位拆成 6 位二进制(step(1.0, mod(remain, 2.0))),映射到 6 爻的阴阳,再在st *= 10.0的网格上逐格绘制,最终以ipos.x + ipos.y + u_time*5.0作为卦序号,生成随时间流动的六十四卦阵列——这正是「奇偶判断 + 格子索引」思想的绝佳延伸。
设计自己的规则
制作程序化图案本质上是一场寻找最小可复用元素的思维练习。人类很早就用网格与图案装饰织物、地板和器物边框:从古希腊的 Meander 回纹,到中国的格纹门窗设计,重复与变化的张力始终吸引着想象力。阿拉伯几何图案、非洲绚丽的织物纹样……装饰艺术的整个宇宙都可以成为 shader 图案的灵感来源。
仓库中还有更多值得研读的变体:09/mirrortiles.frag 展示了mirrorTile()镜像平铺(奇数行翻转 y 轴,配合正弦波形成连绵波浪);09/dots5.frag 以两层不同缩放的网格叠加圆环并随时间微移,是平铺 + 色彩混合的综合示例。这些文件都位于 09/ 目录,可直接对照阅读。
本章同时标志着「算法绘图」部分的收官。在接下来的章节里,我们将向 shader 注入熵(entropy)——随机数与噪声——让程序从“可预测的秩序”走向“独一无二、不可预知”的生成式设计。到那时,本章习得的网格、索引与奇偶判断仍将是组织随机性的基本骨架。
【免费下载链接】thebookofshaders
Step-by-step guide through the abstract and complex universe of Fragment Shaders.
相关推荐
Eino 生态组件实战:如何快速接入 OpenAI、Claude 大模型与评估器工具?
Eino 生态组件实战:如何快速接入 OpenAI、Claude 大模型与评估器工具? Eino( 'aino' ,谐音 "I know")是 CloudWeG
The Book of Shaders 第 6 章:用 GLSL 向量与 mix() 掌控色彩
The Book of Shaders 第 6 章:用 GLSL 向量与 mix 掌控色彩 导读 本文以 The Book of Shaders 第 6 章(仓
The Book of Shaders 第 5 章精读:用 GLSL 造型函数掌控 0.0–1.0 区间
The Book of Shaders 第 5 章精读:用 GLSL 造型函数掌控 0.0–1.0 区间 《The Book of Shaders》(着色器之书
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考