Shader 从入门到精通 - GPU 的艺术语言
1. 什么是 Shader?为什么要学它?
在传统的 Web 开发中,我们习惯了用 JavaScript 控制 CPU 来完成计算任务。但当涉及到图形渲染时,CPU 就像一个聪明但孤军奋战的将军,而 GPU 则是拥有成千上万士兵的军团。
Shader 本质上是运行在 GPU 上的"微型程序"。想象一下:
- CPU 一次处理一个任务,像单线程流水线
- GPU 可以同时处理成千上万个像素,每个像素都在并行计算
这就是为什么 Shader 能够创造出那些令人惊叹的视觉效果:实时的粒子系统、流体模拟、光线追踪、后期特效等等。
1.1 Shader 的运行机制
用 GLSL(OpenGL Shading Language)编写的 Shader 代码会经历这样的生命周期:
GLSL源代码 → 编译器 → GPU汇编指令 → GPU执行每一帧(通常是 60fps),GPU 都会执行这些指令:
- 顶点着色器(Vertex Shader):处理每个顶点的位置、变换
- 片元着色器(Fragment Shader):为每个像素计算颜色
举个例子:一个 1920×1080 的屏幕有 207万个像素。传统 JS 逐个处理需要几秒,而 GPU 能在 16.6ms 内(60fps)全部计算完成!这就是并行计算的威力。
2. Shader 如何获取数据?深入理解数据流
在 Shader 的世界里,数据的流向 是理解整个渲染管线的关键。就像水管系统,不同类型的"水管"承载着不同的数据。
2.1 四种数据来源详解
| 数据类型 💡 | 声明方式 | 作用范围 | 数据特性 | 典型应用 |
|---|---|---|---|---|
| uniform 🌐 | uniform float u_time; | 全局共享 | ✅ CPU→GPU 单向传递 ✅ 所有顶点/像素共用同一个值 ❌ 在单次绘制中不可变 | 时间、鼠标位置、贴图、变换矩阵、光源参数 |
| attribute / in 📍 | attribute vec3 position; (GLSL 1.0)in vec3 position; (GLSL 3.0) | 仅顶点着色器 | ✅ 来自 VBO(顶点缓冲) ✅ 每个顶点值不同 ⚡ GPU 逐顶点读取 | 顶点位置、法线、UV坐标、顶点颜色、骨骼权重 |
| varying / out 🌈 | varying vec2 vUv; (旧版)out vec2 vUv; (新版) | 顶点→片元传递 | ✅ 顶点着色器输出 ✅ GPU 自动插值 ✅ 片元着色器接收 | UV插值、法线插值、颜色渐变、深度传递 |
| 内置变量 ⚙️ | 无需声明 | GPU 自动提供 | ✅ 系统级变量 ✅ 只读(大部分) ✅ 自动更新 | gl_Position, gl_FragCoord, gl_FragColor |
2.2 深度解析:uniform - 全局的"广播电台"
uniform 就像一个广播电台,CPU 发出一个信号,所有 GPU 单元都接收到相同的值。
使用场景举例:
// ⏰ 时间动画
uniform float u_time;
void main() {
float wave = sin(u_time); // 所有像素同步使用这个时间值
}
// 🖱️ 鼠标交互
uniform vec2 u_mouse;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
float dist = distance(st, u_mouse); // 计算到鼠标的距离
}
// 🎨 纹理采样
uniform sampler2D u_texture;
void main() {
vec4 texColor = texture2D(u_texture, vUv);
}为什么叫 uniform? 因为它对所有顶点/像素是 统一(uniform) 的!
2.3 深度解析:attribute - 每个顶点的"身份证"
attribute 为每个顶点提供独特的数据,就像每个人的身份证号都不同。
数据流动过程:
JavaScript 创建 VBO → 上传顶点数据 → GPU 逐顶点读取 attribute实际例子:
// JavaScript 端
const positions = new Float32Array([
-1.0, -1.0, 0.0, // 顶点1
1.0, -1.0, 0.0, // 顶点2
0.0, 1.0, 0.0 // 顶点3
]);// Shader 端
attribute vec3 position; // GPU 会为每个顶点自动填充不同的 position 值
void main() {
gl_Position = vec4(position, 1.0);
// 顶点1 接收到: (-1.0, -1.0, 0.0)
// 顶点2 接收到: ( 1.0, -1.0, 0.0)
// 顶点3 接收到: ( 0.0, 1.0, 0.0)
}2.4 深度解析:varying - 自动的"魔法插值器"
varying 是 GPU 最神奇的功能之一!它能在三角形内部为每个像素自动插值。
工作原理图解:
顶点着色器 光栅化(插值) 片元着色器
┌─────────┐ ┌─────────┐
│ 顶点A: 红色│────┐ │ 像素1: ? │
└─────────┘ │ └─────────┘
├──→ GPU自动插值 ────→ ┌─────────┐
┌─────────┐ │ │ 像素2: ? │
│ 顶点B: 蓝色│────┘ └─────────┘
└─────────┘插值实例:
// ===== 顶点着色器 =====
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv; // 输出 UV
void main() {
vUv = uv; // 传递给片元着色器
gl_Position = vec4(position, 1.0);
}
// ===== 片元着色器 =====
varying vec2 vUv; // 接收插值后的 UV
void main() {
// 假设三角形三个顶点的 UV 是:
// 顶点A: (0, 0) 顶点B: (1, 0) 顶点C: (0.5, 1)
// GPU 会自动为三角形内每个像素插值出平滑的 UV 值
gl_FragColor = vec4(vUv, 0.0, 1.0); // 显示 UV 渐变
}为什么需要插值?
- ✅ 平滑的颜色渐变
- ✅ 正确的纹理映射
- ✅ 逐像素光照计算
- ✅ 深度信息传递
3. 内置变量 - GPU 的"系统级API"
GPU 为我们提供了一些开箱即用的内置变量,它们就像操作系统提供的系统调用,让我们能够获取关键的渲染信息。
3.1 gl_FragCoord - 像素的"GPS定位"
gl_FragCoord 是片元着色器中最常用的内置变量,它告诉当前像素在屏幕上的精确位置。
结构解析: vec4(x, y, z, w)
| 分量 | 含义 | 取值范围 | 用途 |
|---|---|---|---|
.x | 屏幕横坐标(像素) | 0 ~ 屏幕宽度 | 水平位置计算 |
.y | 屏幕纵坐标(像素) | 0 ~ 屏幕高度 | 垂直位置计算 |
.z | 深度值 | 0.0 ~ 1.0 | 深度测试、后期特效 |
.w | 齐次坐标 w 分量 | 透视除法后的值 | 透视校正(少用) |
🔍 坐标系陷阱警告!
这是很多初学者困惑的点:
Canvas 2D 坐标系 (DOM) WebGL/Shader 坐标系
┌─────────────────→ x y
│ (0,0) ↑
↓ │
y ───────────────→ x
原点: 左上角 原点: 左下角 (0,0)
(跟数学坐标系一致!)这是为什么?因为 OpenGL 遵循数学惯例(y 轴向上),而 DOM 遵循屏幕扫描惯例(y 轴向下)。
3.2 场景1:归一化坐标 - 让代码适配所有屏幕
问题: 不同设备屏幕尺寸不同(手机 375px,桌面 1920px),如何让 Shader 效果在所有设备上看起来一致?
解决方案:归一化!
uniform vec2 u_resolution; // 从 JS 传入,例如 vec2(1920.0, 1080.0)
void main() {
// 📐 归一化:将像素坐标转换为 0.0~1.0 范围
vec2 st = gl_FragCoord.xy / u_resolution;
// 现在 st 的范围是:
// 左下角: (0.0, 0.0)
// 右上角: (1.0, 1.0)
// 屏幕中心: (0.5, 0.5)
// ⚠️ 如果你想要 (0,0) 在左上角(习惯 DOM 坐标):
st.y = 1.0 - st.y;
// 🎨 现在可以用归一化坐标做各种效果了
float circle = distance(st, vec2(0.5)); // 到中心的距离
}为什么归一化这么重要?
- ✅ 与屏幕分辨率无关
- ✅ 便于数学计算(0~1 比 0~1920 更直观)
- ✅ 方便复用算法和效果
3.3 场景2:基于位置的渐变效果
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 🌈 方案1:水平渐变(从左到右)
vec3 color = vec3(st.x); // 左侧黑(0.0),右侧白(1.0)
// 🌈 方案2:垂直渐变
color = vec3(st.y);
// 🌈 方案3:对角渐变
color = vec3(st.x * st.y);
// 🌈 方案4:双色混合
vec3 colorA = vec3(0.149, 0.141, 0.912); // 蓝色
vec3 colorB = vec3(1.000, 0.833, 0.224); // 黄色
color = mix(colorA, colorB, st.x); // 从左到右,蓝→黄
gl_FragColor = vec4(color, 1.0);
}实际效果:
st.x:左边全黑,右边全白,中间平滑过渡st.y:下边全黑,上边全白st.x * st.y:左下角最黑,右上角最白
3.4 场景3:后期处理 - 屏幕空间特效
在游戏和视频特效中,后期处理(Post-processing)是必不可少的。
实例:简单的模糊效果
uniform sampler2D u_texture; // 原始渲染结果
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 📏 计算像素大小(用于偏移采样)
vec2 pixelSize = 1.0 / u_resolution;
// 🔍 9点采样模糊(Box Blur)
vec4 color = vec4(0.0);
for(float y = -1.0; y <= 1.0; y += 1.0) {
for(float x = -1.0; x <= 1.0; x += 1.0) {
vec2 offset = vec2(x, y) * pixelSize;
color += texture2D(u_texture, st + offset);
}
}
color /= 9.0; // 平均
gl_FragColor = color;
}3.5 场景4:像素化效果(Pixelation)
uniform vec2 u_resolution;
uniform float u_pixelSize; // 控制像素块大小,例如 8.0
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 🎮 像素化算法:向下取整 → 再还原
vec2 pixelated = floor(st * u_resolution / u_pixelSize) * u_pixelSize / u_resolution;
// 现在 pixelated 是"阶梯状"的坐标
vec3 color = somePattern(pixelated); // 用像素化坐标绘图
gl_FragColor = vec4(color, 1.0);
}3.6 其他重要内置变量
| 变量 | 所在着色器 | 类型 | 作用 |
|---|---|---|---|
gl_Position | 顶点着色器(输出) | vec4 | 必须赋值! 顶点的裁剪空间坐标 |
gl_FragColor | 片元着色器(输出) | vec4 | 必须赋值! 像素的最终颜色(RGBA) |
gl_PointSize | 顶点着色器(输出) | float | 点精灵(GL_POINTS)的大小 |
gl_FrontFacing | 片元着色器(输入) | bool | 是否是正面(用于双面材质) |
示例:双面材质
void main() {
vec3 color;
if(gl_FrontFacing) {
color = vec3(1.0, 0.0, 0.0); // 正面:红色
} else {
color = vec3(0.0, 0.0, 1.0); // 背面:蓝色
}
gl_FragColor = vec4(color, 1.0);
}4. 实战:搭建你的第一个 Shader 开发环境
学习 Shader 最好的方式就是立即动手写代码!这里我推荐使用 glslCanvas —— 一个轻量级的 Shader 练习工具。
4.1 方案一:在线快速体验(推荐新手)
你可以直接使用这些在线编辑器,无需安装:
- 🔥 ShaderToy - 最流行的 Shader 社区
- 🎨 The Book of Shaders Editor
- ⚡ glslEditor
4.2 方案二:本地开发环境(推荐进阶)
4.2.1 步骤1:安装 glslCanvas
# NPM 安装
npm install glslCanvas
# 或者使用 CDN(更简单)
# 直接在 HTML 中引入即可4.2.2 步骤2:创建 HTML 文件
创建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎨 我的第一个 Shader</title>
<!-- 引入 glslCanvas 库 -->
<script type="text/javascript"
src="https://rawgit.com/patriciogonzalezvivo/glslCanvas/master/dist/GlslCanvas.js">
</script>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #1a1a2e;
}
canvas {
border: 2px solid #16213e;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
</style>
</head>
<body>
<!--
glslCanvas 的魔法配置:
- class="glslCanvas" 必须保留
- data-fragment-url 指定 shader 文件路径
- data-textures 可选,用于加载纹理图片
-->
<canvas class="glslCanvas"
data-fragment-url="shader.frag"
width="800"
height="600">
</canvas>
</body>
</html>4.2.3 步骤3:创建你的第一个 Shader
创建 shader.frag 文件:
// ========================================
// 🎯 Shader 基础设置
// ========================================
#ifdef GL_ES
precision mediump float; // 设置浮点精度(移动设备需要)
#endif
// ========================================
// 📥 Uniform 输入(glslCanvas 自动提供)
// ========================================
uniform float u_time; // 程序运行时间(秒)
uniform vec2 u_resolution; // 画布分辨率(像素)
uniform vec2 u_mouse; // 鼠标位置(归一化 0~1)
// ========================================
// 🎨 主函数 - 为每个像素计算颜色
// ========================================
void main() {
// 1️⃣ 归一化坐标 (0.0 ~ 1.0)
vec2 st = gl_FragCoord.xy / u_resolution;
// 2️⃣ 定义两个颜色
vec3 coolBlue = vec3(0.2, 0.5, 0.9); // RGB: 冷蓝色
vec3 hotPink = vec3(0.9, 0.2, 0.5); // RGB: 热粉色
// 3️⃣ 创建时间动画:sin 波在 -1~1 之间振荡
// abs() 将其映射到 0~1 范围
float wave = abs(sin(u_time * 0.5));
// 4️⃣ 在两个颜色之间插值
// wave=0 时是 coolBlue,wave=1 时是 hotPink
vec3 animatedColor = mix(coolBlue, hotPink, wave);
// 5️⃣ 添加位置渐变效果
// 左下角会更暗,右上角会更亮
animatedColor *= vec3(st.x, st.y, 1.0);
// 6️⃣ 输出最终颜色(RGBA)
gl_FragColor = vec4(animatedColor, 1.0);
}4.2.4 步骤4:运行!
# 使用任意静态服务器
npx http-server .
# 或者 Python
python -m http.server 8000
# 或者 VSCode 的 Live Server 插件打开浏览器访问 http://localhost:8000,你会看到:
- ✨ 颜色在蓝色和粉色之间呼吸式渐变
- 🎨 左下角暗,右上角亮的空间渐变
- 🔄 实时动画,永不停歇
4.3 进阶:添加鼠标交互
在 shader.frag 中加入鼠标效果:
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 🖱️ 计算当前像素到鼠标的距离
float distToMouse = distance(st, u_mouse);
// 🎯 距离越近,颜色越亮
float brightness = 1.0 - smoothstep(0.0, 0.5, distToMouse);
// 🌈 创建鼠标周围的光晕效果
vec3 color = vec3(brightness) * vec3(0.5, 0.8, 1.0);
gl_FragColor = vec4(color, 1.0);
}现在移动鼠标,你会看到一个跟随鼠标的蓝色光晕!
4.4 glslCanvas 自动提供的 Uniform
| Uniform | 类型 | 说明 | 示例用途 |
|---|---|---|---|
u_time | float | 程序运行秒数 | 动画、波动效果 |
u_resolution | vec2 | 画布尺寸(宽,高) | 归一化坐标 |
u_mouse | vec2 | 鼠标位置(归一化) | 交互效果 |
u_date | vec4 | 当前日期时间 | 时钟、日历效果 |
u_delta | float | 帧间隔时间 | 物理模拟 |
4.5 小练习
尝试修改代码实现这些效果:
- 让颜色变化速度加快(提示:修改
u_time * 0.5的系数) - 改变颜色方案(试试不同的 RGB 值)
- 创建一个从中心向外的径向渐变(提示:使用
distance(st, vec2(0.5))) - 让动画反向播放(提示:
1.0 - wave)
5. Swizzling - GLSL 的优雅"魔法语法"
在传统编程语言中,访问向量分量通常需要用索引:v[0], v[1], v[2]。但 GLSL 提供了一种极其优雅的语法糖 —— Swizzling(混洗)。
5.1 什么是 Swizzling?
Swizzling 允许你用 点号 + 字母组合 来:
- ✅ 访问向量的分量
- ✅ 重新排列分量顺序
- ✅ 复制分量
- ✅ 创建不同维度的向量
5.2 三套等价的命名规则
GLSL 为向量分量提供了三套命名,它们完全等价,只是语义不同:
| 命名规则 | 用途 | 分量 | 示例 |
|---|---|---|---|
| xyzw 🎯 | 位置/方向(空间坐标) | x, y, z, w | position.xyz |
| rgba 🎨 | 颜色 | r, g, b, a | color.rgb |
| stpq 🗺️ | 纹理坐标 | s, t, p, q | uv.st |
它们指向同一块数据! 只是为了代码可读性而存在。
vec4 v = vec4(1.0, 2.0, 3.0, 4.0);
// 这三个完全等价!
float a = v.x; // 1.0
float b = v.r; // 1.0(同一个值)
float c = v.s; // 1.0(同一个值)5.3 基础用法:单分量访问
vec4 v = vec4(1.0, 2.0, 3.0, 4.0);
// 访问单个分量
float x = v.x; // 1.0
float y = v.y; // 2.0
float z = v.z; // 3.0
float w = v.w; // 4.0
// 颜色语义
vec4 color = vec4(0.5, 0.8, 1.0, 1.0);
float red = color.r; // 0.5
float green = color.g; // 0.8
float blue = color.b; // 1.0
float alpha = color.a; // 1.05.4 进阶用法:提取多个分量
vec4 v = vec4(1.0, 2.0, 3.0, 4.0);
// 提取 2D 向量
vec2 xy = v.xy; // (1.0, 2.0)
vec2 zw = v.zw; // (3.0, 4.0)
// 提取 3D 向量
vec3 xyz = v.xyz; // (1.0, 2.0, 3.0)
vec3 yzw = v.yzw; // (2.0, 3.0, 4.0)
// 混合命名(不推荐!但语法允许)
vec3 rgb = color.rgb; // ✅ 清晰的语义
// vec3 bad = color.xyz; // ❌ 语法正确但语义混乱5.5 黑魔法:任意重排和复制
这才是 Swizzling 的真正威力!
vec4 v = vec4(1.0, 2.0, 3.0, 4.0);
// 🔄 反转顺序
vec3 reversed = v.zyx; // (3.0, 2.0, 1.0)
vec4 fullReverse = v.wzyx; // (4.0, 3.0, 2.0, 1.0)
// 🔁 重新排列
vec4 shuffled = v.yxzw; // (2.0, 1.0, 3.0, 4.0)
vec2 swapped = v.yx; // (2.0, 1.0)
// 📋 复制分量
vec2 xx = v.xx; // (1.0, 1.0) - 复制 x
vec3 xxx = v.xxx; // (1.0, 1.0, 1.0)
vec4 yyyy = v.yyyy; // (2.0, 2.0, 2.0, 2.0)
// 🎲 任意组合
vec3 crazy = v.xzx; // (1.0, 3.0, 1.0)
vec4 wild = v.xyxy; // (1.0, 2.0, 1.0, 2.0)5.6 实际应用场景
5.6.1 场景1:颜色通道操作
vec4 color = vec4(0.8, 0.3, 0.5, 1.0);
// 🔴 提取 RGB,丢弃 Alpha
vec3 rgb = color.rgb;
// 🔄 交换红蓝通道(BGR 效果)
vec3 bgr = color.bgr;
// 🎭 只保留红色通道
vec3 redOnly = vec3(color.r, 0.0, 0.0); // 传统写法
vec3 redOnly = color.rrr * vec3(1.0, 0.0, 0.0); // Swizzle 写法
// 💡 灰度化(简单版)
float gray = (color.r + color.g + color.b) / 3.0;
vec3 grayscale = vec3(gray); // 或者 vec3(gray, gray, gray)5.6.2 场景2:坐标变换
vec3 position = vec3(1.0, 2.0, 3.0);
// 📏 只要 XY 平面(丢弃 Z)
vec2 flatPosition = position.xy;
// 🔄 交换 XY(旋转 90° 效果)
vec2 rotated = position.yx;
// 📐 从 2D 升级到 3D(添加 Z=0)
vec2 pos2D = vec2(5.0, 10.0);
vec3 pos3D = vec3(pos2D, 0.0); // 传统写法
vec3 pos3D = vec3(pos2D.xy, 0.0); // Swizzle 写法5.6.3 场景3:UV 操作
vec2 uv = vec2(0.3, 0.7);
// 🔄 翻转 UV(镜像效果)
vec2 flippedU = vec2(1.0 - uv.x, uv.y); // 水平翻转
vec2 flippedV = vec2(uv.x, 1.0 - uv.y); // 垂直翻转
// 或者用 Swizzle 简化
vec2 flipped = vec2(1.0) - uv; // 完全翻转
vec2 flipped = vec2(1.0) - uv.yx; // 翻转 + 旋转5.6.4 场景4:高效的数据重组
// 🎨 从纹理采样后重组颜色
vec4 texColor = texture2D(u_texture, uv);
// 传统写法:交换红蓝通道
vec4 swapped;
swapped.r = texColor.b;
swapped.g = texColor.g;
swapped.b = texColor.r;
swapped.a = texColor.a;
// Swizzle 写法:一行搞定!
vec4 swapped = texColor.bgra;
// 🎭 创建 Alpha 遮罩
float mask = texColor.a;
vec3 masked = texColor.rgb * mask;5.7 注意事项
// ❌ 不能混用不同的命名规则
vec2 bad = v.xr; // 错误!不能混用 xyzw 和 rgba
// ❌ 不能用于赋值的左侧(某些情况)
v.xy = vec2(1.0); // ✅ 可以
v.xx = vec2(1.0); // ❌ 错误!不能赋值给重复分量
// ✅ 正确的赋值
v.x = 1.0;
v.xy = vec2(1.0, 2.0);
v.xyz = vec3(1.0, 2.0, 3.0);5.8 性能优势
Swizzling 不会产生运行时开销!它是编译时的语法糖,编译器会直接优化成寄存器操作。
// 这两种写法性能完全一样!
vec3 a = vec3(v.x, v.y, v.z); // 传统
vec3 b = v.xyz; // Swizzle(更简洁)6. GLSL 核心函数库 - 你的数学工具箱
Shader 编程的本质是数学运算。GLSL 为我们提供了丰富的内置函数,掌握它们是写出高质量 Shader 的关键。
6.1 Step 与 Smoothstep - 阶跃与平滑过渡
这两个函数是 Shader 中最常用的函数,用于创建硬边缘和软边缘效果。
6.1.1 step(edge, x) - 硬边缘阶跃函数
功能: 创建一个突变的"开关"效果。
数学定义:
step(edge, x) = {
0.0 if x < edge
1.0 if x >= edge
}可视化效果:
1.0 ┤ ┌─────────
│ │
0.5 ┤ │
│ │
0.0 ┤─────────┘
└─────────┬─────────> x
edge实例1:创建一个圆形
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
st -= 0.5; // 移到中心
// 计算到中心的距离
float dist = length(st);
// 🎯 使用 step 创建硬边缘圆
float circle = step(dist, 0.3);
// dist < 0.3 → 1.0 (白色,圆内)
// dist >= 0.3 → 0.0 (黑色,圆外)
vec3 color = vec3(circle);
gl_FragColor = vec4(color, 1.0);
}实例2:创建条纹图案
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 🎨 使用 mod 创建循环,step 创建阶跃
float pattern = mod(st.x * 10.0, 1.0); // 0~1 循环10次
float stripes = step(0.5, pattern); // 0.5 处切换
vec3 color = vec3(stripes); // 黑白条纹
gl_FragColor = vec4(color, 1.0);
}向量版本的 step:
// step 可以对向量的每个分量单独操作!
vec2 edge = vec2(0.1, 0.2);
vec2 val = vec2(0.05, 0.3);
vec2 result = step(edge, val); // (0.0, 1.0)
// 等价于:
result.x = step(0.1, 0.05); // 0.0
result.y = step(0.2, 0.3); // 1.06.1.2 smoothstep(edge0, edge1, x) - 平滑过渡(抗锯齿神器)
功能: 创建平滑的 S 曲线过渡,天生具有抗锯齿效果。
数学定义: 使用 Hermite 插值(三次多项式)
t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
smoothstep = t * t * (3.0 - 2.0 * t)可视化效果:
1.0 ┤ ╭─────
│ ╱
0.5 ┤ ╱
│ ╱
0.0 ┤─────╯
└─────┬──┬────────> x
edge0 edge1对比 step 和 smoothstep:
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
st -= 0.5;
float dist = length(st);
// ❌ 硬边缘 - 有明显锯齿
float hardCircle = step(dist, 0.3);
// ✅ 软边缘 - 平滑抗锯齿
float softCircle = smoothstep(0.31, 0.29, dist);
// ↑ ↑
// 外边界 内边界
// 注意:edge0 > edge1 会反转效果(外黑内白)
vec3 color = vec3(softCircle);
gl_FragColor = vec4(color, 1.0);
}实例3:平滑矩形(带圆角效果)
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
vec3 color = vec3(0.0);
// 📐 定义矩形的边界范围
float smoothness = 0.02; // 控制边缘柔和程度(越小越锐利)
// 左下角边界(0.1 ~ 0.2 范围内平滑过渡)
vec2 bl = vec2(
smoothstep(0.1, 0.1 + smoothness, st.x),
smoothstep(0.1, 0.1 + smoothness, st.y)
);
// 右上角边界(0.9 ~ 0.8 范围内平滑过渡)
vec2 tr = vec2(
smoothstep(0.1, 0.1 + smoothness, 1.0 - st.x),
smoothstep(0.1, 0.1 + smoothness, 1.0 - st.y)
);
// 🎨 四个边界相乘 = 矩形内部
float rect = (bl.x * bl.y) * (tr.x * tr.y);
color = vec3(rect);
gl_FragColor = vec4(color, 1.0);
}实例4:渐变进度条
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
// 📊 进度条:随时间从 0 到 1
float progress = abs(sin(u_time * 0.5));
// 🎨 平滑的进度条效果
float bar = smoothstep(progress - 0.01, progress + 0.01, st.x);
// 双色效果
vec3 colorA = vec3(0.2, 0.8, 0.3); // 绿色(已完成)
vec3 colorB = vec3(0.1, 0.1, 0.1); // 灰色(未完成)
vec3 color = mix(colorA, colorB, bar);
gl_FragColor = vec4(color, 1.0);
}6.2 何时用 step,何时用 smoothstep?
| 特性 | step | smoothstep |
|---|---|---|
| 边缘 | 硬边缘(像素级锐利) | 软边缘(抗锯齿) |
| 性能 | ⚡ 极快(1条指令) | 🐢 稍慢(多条指令) |
| 用途 | 像素艺术、硬边图案、遮罩 | 真实感图形、UI、抗锯齿 |
| 锯齿 | ❌ 有明显锯齿 | ✅ 无锯齿 |
| 控制 | 单一阈值 | 过渡范围可调 |
💡 经验法则:
- 追求性能或像素风格 → 用
step - 追求视觉质量 → 用
smoothstep - 大部分情况下,smoothstep 的视觉效果远超其微小的性能代价
6.3 常用数学函数速查表
6.3.1 基础数学运算
| 函数 | 说明 | 公式/用途 | 实例 |
|---|---|---|---|
pow(a, b) | 幂运算 | a^b | pow(2.0, 3.0) = 8.0常用于衰减曲线、光照计算 |
sqrt(a) | 平方根 | √a | sqrt(16.0) = 4.0计算距离时常用 |
abs(x) | 绝对值 | |x| | abs(-5.0) = 5.0创建对称图案 |
sign(x) | 符号函数 | -1/0/+1 | sign(-3.5) = -1.0判断方向 |
6.3.2 取整与取模
| 函数 | 说明 | 行为 | 实例 |
|---|---|---|---|
floor(x) | 向下取整 | ⌊x⌋ | floor(3.7) = 3.0floor(-1.2) = -2.0 |
ceil(x) | 向上取整 | ⌈x⌉ | ceil(3.2) = 4.0ceil(-1.8) = -1.0 |
fract(x) | 小数部分 | x - floor(x) | fract(3.7) = 0.7常用于循环重复图案 |
mod(x, y) | 取模(余数) | x % y | mod(5.5, 2.0) = 1.5创建周期性效果 |
💡 fract 和 mod 的黄金组合:
// 🎨 创建重复的棋盘格图案
void main() {
// 这意味着整个画布被分成 10×10 的格子;st.x 的整数部分表示该像素在哪一列(0 到 9),st.y 的整数部分表示在哪一行(0 到 9)。
vec2 st = gl_FragCoord.xy / u_resolution * 10.0; // 放大10倍
// fract 让坐标在 0~1 范围内循环, 相当于"每个格子里的局部坐标", 通常会用 grid 来做子元素绘制。
vec2 grid = fract(st);
// 关键:相邻格子在行或列上相差 1,因此它们的行列和会相差 1(奇偶性翻转),所以相邻格子的和在偶/奇之间交替。
float pattern = step(0.5, mod(floor(st.x) + floor(st.y), 2.0));
gl_FragColor = vec4(vec3(pattern), 1.0);
}6.3.3 范围控制
| 函数 | 说明 | 用途 | 实例 |
|---|---|---|---|
clamp(x, min, max) | 限制范围 | 把 x 约束在 [min, max] | clamp(5.0, 0.0, 1.0) = 1.0防止数值溢出 |
min(x, y) | 取最小值 | x < y ? x : y | min(3.0, 7.0) = 3.0SDF 合并操作 |
max(x, y) | 取最大值 | x > y ? x : y | max(3.0, 7.0) = 7.0SDF 交集操作 |
6.3.4 插值与混合
| 函数 | 说明 | 公式 | 用途 |
|---|---|---|---|
mix(x, y, a) | 线性插值 | x×(1-a) + y×a | 颜色混合、动画过渡 |
mix() 深度解析:
// 🌈 颜色混合
vec3 red = vec3(1.0, 0.0, 0.0);
vec3 blue = vec3(0.0, 0.0, 1.0);
mix(red, blue, 0.0); // 纯红色
mix(red, blue, 0.5); // 紫色(中间色)
mix(red, blue, 1.0); // 纯蓝色
// ⏰ 动画示例
vec3 color = mix(red, blue, abs(sin(u_time))); // 红蓝呼吸6.3.5 向量运算(空间几何)
| 函数 | 说明 | 公式 | 典型应用 |
|---|---|---|---|
length(v) | 向量长度 | √(x²+y²+z²) | 计算距离、创建圆形 |
distance(a, b) | 两点距离 | length(b - a) | 光晕效果、粒子系统 |
normalize(v) | 单位化向量 | v / length(v) | 方向计算、法线归一化 |
dot(a, b) | 点积 | a·b = |a||b|cosθ | 角度计算、投影、光照 |
cross(a, b) | 叉积(3D) | a × b ⊥ (a, b) | 计算法线、判断方向 |
📐 dot 点积的5大应用:
// 1️⃣ 计算角度(通过余弦值)
vec3 a = normalize(vec3(1.0, 0.0, 0.0));
vec3 b = normalize(vec3(0.707, 0.707, 0.0));
float cosTheta = dot(a, b); // ≈ 0.707(45度)
float angle = acos(cosTheta); // ≈ π/4
// 2️⃣ 投影(把向量 b 投影到 a 上)
float projLength = dot(b, normalize(a));
vec3 projection = normalize(a) * projLength;
// 3️⃣ 判断前后(用于光照)
vec3 normal = vec3(0.0, 0.0, 1.0);
vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
float NdotL = dot(normal, lightDir); // > 0 朝向光源
// 4️⃣ 快速计算距离平方(避免 sqrt)
vec2 v = vec2(3.0, 4.0);
float distSq = dot(v, v); // 25.0(比 length(v)^2 更快)
// 5️⃣ 反射光线方向(镜面反射基础)
vec3 reflected = reflect(lightDir, normal);
// 内部实现:lightDir - 2.0 * dot(lightDir, normal) * normal🧭 cross 叉积的3大应用:
// 1️⃣ 计算三角形法线(最常用!)
vec3 edge1 = B - A;
vec3 edge2 = C - A;
vec3 normal = normalize(cross(edge1, edge2));
// 2️⃣ 判断左右方向(2D判断):在三维空间中,两个向量的叉积是一个新的向量,这个向量垂直于由原两个向量构成的平面。 这个新向量的方向可以用右手定则判断
vec2 a = vec2(1.0, 0.0);
vec2 b = vec2(0.0, 1.0);
float z = cross(vec3(a, 0.0), vec3(b, 0.0)).z;
// z > 0: b 在 a 左侧
// z < 0: b 在 a 右侧
// 3️⃣ 构建正交基(切线空间)
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 tangent = normalize(cross(normal, up));
vec3 bitangent = cross(normal, tangent);
// 4️⃣ 计算平行四边形(三角形)面积
float area = length(cross(edge1, edge2));6.3.6 光学函数(光照计算专用)
| 函数 | 说明 | 公式 | 应用场景 |
|---|---|---|---|
reflect(I, N) | 反射 | I - 2.0×dot(N,I)×N | 镜面反射、环境映射 |
refract(I, N, eta) | 折射 | 斯涅尔定律实现 | 玻璃、水面效果 |
faceforward(N, I, Nref) | 法线翻转 | 确保法线朝向视线 | 双面材质 |
🌟 反射实例:镜面球
// 计算环境反射
vec3 viewDir = normalize(cameraPos - worldPos);
vec3 normal = normalize(surfaceNormal);
vec3 reflectDir = reflect(-viewDir, normal);
// 采样环境贴图
vec3 envColor = textureCube(u_envMap, reflectDir).rgb;
7. 实战案例:在 Shader 中绘制三角形
在 Shader 中画三角形是一个思维转变的过程。传统编程是"画"图形(调用 drawTriangle 函数),而 Shader 是逐像素判断:"这个像素在三角形内吗?"
7.1 核心思路拆解
传统编程思维 Shader 思维
───────────── ──────────────
1. 定义三角形顶点 1. 建立坐标系
2. 调用绘图API 2. 定义三角形顶点
3. GPU自动填充 3. 对每个像素判断:在三角形内?
4. 在内→白色;在外→黑色7.2 步骤1:建立坐标系
不同的坐标系适合不同的场景。我们需要先把像素坐标转换成数学坐标。
// ====== 方案 A:标准化坐标 (0~1) ======
vec2 st = gl_FragCoord.xy / u_resolution.xy;
// 范围:(0,0) 左下角 → (1,1) 右上角
// 优点:简单直观
// 缺点:宽屏会拉伸(非正方形屏幕变形)
// ====== 方案 B:中心对称坐标 (-1~1) ======
vec2 st = gl_FragCoord.xy / u_resolution.xy; // 先归一化
st = st * 2.0 - 1.0; // 再映射到 -1~1
// 范围:(-1,-1) 左下角 → (1,1) 右上角,中心点 (0,0)
// 优点:以原点为中心,方便旋转和缩放
// 缺点:仍然有拉伸问题
// ====== 方案 C:宽高比校正(推荐) ======
vec2 st = gl_FragCoord.xy / u_resolution.xy;
st = st * 2.0 - 1.0;
float aspect = u_resolution.x / u_resolution.y; // 例如 1920/1080 ≈ 1.78
st.x *= aspect; // X 轴拉伸,保持正方形不变形
// 范围:x ∈ [-aspect, aspect],y ∈ [-1, 1]
// 优点:无变形,圆是圆,正方形是正方形
// 这是专业 Shader 的标准做法!
// ====== 方案 D:自定义缩放 ======
vec2 pos = st * 0.5; // 整体缩小到原来的 0.5 倍
// 灵活调整可视范围可视化对比:
方案A (0~1) 方案B (-1~1) 方案C (宽高比校正)
┌─────────┐ ┌─────────┐ ┌──────────────┐
│(0,1) (1,1)│ │(-1,1) (1,1)│ │(-1.78,1) (1.78,1)│
│ ● │ 拉伸 │ ● │ 拉伸 │ ● │ 正常
│(0,0) (1,0)│ → │(-1,-1)(1,-1)│ → │(-1.78,-1)(1.78,-1)│
└─────────┘ └─────────┘ └──────────────┘
宽屏变形 宽屏变形 无变形 ✅7.3 步骤2:定义三角形 - 如何量化大小?
在传统绘图中,你可能直接指定像素坐标。但在 Shader 中,我们需要用数学方法定义三角形。
7.3.1 方法 A:直接指定顶点坐标(自由三角形)
适用场景: 不规则三角形、艺术图案
// 💡 直接定义三个顶点
vec2 v1 = vec2(-0.5, -0.3); // 左下
vec2 v2 = vec2( 0.5, -0.3); // 右下
vec2 v3 = vec2( 0.0, 0.5); // 顶部
// ✅ 优点:灵活,可以画任意形状
// ❌ 缺点:不方便缩放、旋转、平移改进:参数化定义
// 🎯 通过底边中心点和尺寸参数化
vec2 center = vec2(0.0, 0.0);
float width = 0.8; // 底边宽度
float height = 0.6; // 高度
vec2 v1 = center + vec2(-width/2.0, -height/2.0);
vec2 v2 = center + vec2( width/2.0, -height/2.0);
vec2 v3 = center + vec2( 0.0, height/2.0);
// ✅ 现在可以轻松调整大小和位置了!7.3.2 方法 B:外接圆半径(等边三角形标准方法)
适用场景: 等边三角形、规则图案、极坐标设计
数学原理:
等边三角形的三个顶点在圆上均匀分布
角度间隔 = 360° / 3 = 120° = 2π/3
顶点1:角度 90° (指向上)
顶点2:角度 210° (左下)
顶点3:角度 330° (右下)#define PI 3.14159265359
#define TWO_PI 6.28318530718
uniform float u_time;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
st = st * 2.0 - 1.0;
float aspect = u_resolution.x / u_resolution.y;
st.x *= aspect;
// 📐 定义外接圆半径(控制三角形大小)
float radius = 0.4;
// 🔄 可选:添加旋转动画
float rotation = u_time * 0.5; // 旋转角度
// 🎯 计算三个顶点(极坐标转直角坐标)
vec2 v1 = radius * vec2(
cos(rotation + PI * 0.5), // 90°
sin(rotation + PI * 0.5)
);
vec2 v2 = radius * vec2(
cos(rotation + PI * 0.5 + TWO_PI/3.0), // 210°
sin(rotation + PI * 0.5 + TWO_PI/3.0)
);
vec2 v3 = radius * vec2(
cos(rotation + PI * 0.5 + TWO_PI*2.0/3.0), // 330°
sin(rotation + PI * 0.5 + TWO_PI*2.0/3.0)
);
// ... 后续判断逻辑
}优化版:用循环生成 N 边形
// 🎨 通用正多边形生成器
vec2 getPolygonVertex(int n, int index, float radius, float rotation) {
float angle = rotation + TWO_PI * float(index) / float(n);
return radius * vec2(cos(angle), sin(angle));
}
// 使用示例
vec2 v1 = getPolygonVertex(3, 0, 0.4, u_time); // 三角形
vec2 v2 = getPolygonVertex(3, 1, 0.4, u_time);
vec2 v3 = getPolygonVertex(3, 2, 0.4, u_time);
// 改成 getPolygonVertex(6, ...) 就是六边形!7.3.3 方法 C:内接圆半径(精确控制间距)
适用场景: 平铺(tiling)、蜂巢图案
数学关系:
等边三角形:
内接圆半径 r = 外接圆半径 R / 2
边长 a = r * √3// 通过内接圆半径定义
float inRadius = 0.2; // 内接圆半径
float outRadius = inRadius * 2.0; // 换算成外接圆半径
// 然后用方法 B 的代码即可7.3.4 三种方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接坐标 | 灵活自由 | 难以变换 | 不规则三角形、固定图案 |
| 外接圆半径 | 易旋转缩放 代码规整 | 只适合等边 | 规则图案、动画、N边形 |
| 内接圆半径 | 精确控制密度 | 需换算 | 平铺图案、蜂巢结构 |
7.4 步骤3:判断点是否在三角形内 - 核心算法
现在有了三角形的三个顶点,如何判断当前像素是否在三角形内部?这里有多种数学方法。
7.4.1 方法 A:叉积法(最直观)⭐
核心原理: 如果点在三角形内,则它必定在三条边的同一侧。
数学背景:
2D 叉积的 Z 分量表示:
- 正值:点在边的左侧
- 负值:点在边的右侧
- 零:点在边上
如果点在所有三条边的同一侧 → 点在三角形内可视化图解:
v3
/\
/ \
/ ●P \ ← P在三角形内
/______\
v1 v2
边1(v1→v2): P在左侧 ✓
边2(v2→v3): P在左侧 ✓
边3(v3→v1): P在左侧 ✓
结论:P在三角形内!完整实现:
// 🧮 2D叉积辅助函数
float cross2D(vec2 a, vec2 b) {
return a.x * b.y - a.y * b.x;
}
// 🔺 判断点是否在三角形内(叉积法)
bool isInsideTriangle_Cross(vec2 p, vec2 v1, vec2 v2, vec2 v3) {
// 计算三条边的叉积
float c1 = cross2D(v2 - v1, p - v1); // 边 v1→v2
float c2 = cross2D(v3 - v2, p - v2); // 边 v2→v3
float c3 = cross2D(v1 - v3, p - v3); // 边 v3→v1
// 检查符号是否一致(都>0 或都<0)
return (c1 >= 0.0 && c2 >= 0.0 && c3 >= 0.0) ||
(c1 <= 0.0 && c2 <= 0.0 && c3 <= 0.0);
}
// 🎨 在 main 中使用
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
vec2 v1 = vec2(-0.5, -0.3);
vec2 v2 = vec2( 0.5, -0.3);
vec2 v3 = vec2( 0.0, 0.5);
// 判断当前像素是否在三角形内
bool inside = isInsideTriangle_Cross(st, v1, v2, v3);
// 输出颜色
vec3 color = inside ? vec3(1.0, 0.5, 0.2) : vec3(0.1);
gl_FragColor = vec4(color, 1.0);
}优化版:返回浮点值(便于插值)
float triangleMask(vec2 p, vec2 v1, vec2 v2, vec2 v3) {
float c1 = cross2D(v2 - v1, p - v1);
float c2 = cross2D(v3 - v2, p - v2);
float c3 = cross2D(v1 - v3, p - v3);
// 所有叉积同号 → 1.0,否则 → 0.0
float sameSign = step(0.0, c1) * step(0.0, c2) * step(0.0, c3) +
step(c1, 0.0) * step(c2, 0.0) * step(c3, 0.0);
return clamp(sameSign, 0.0, 1.0);
}7.4.2 方法 B:面积法(精确但慢)
核心原理: 如果点 P 在三角形 ABC 内,则:
面积(PAB) + 面积(PBC) + 面积(PCA) = 面积(ABC)float triangleArea(vec2 a, vec2 b, vec2 c) {
return abs(cross2D(b - a, c - a)) * 0.5;
}
bool isInsideTriangle_Area(vec2 p, vec2 v1, vec2 v2, vec2 v3) {
float areaTotal = triangleArea(v1, v2, v3);
float area1 = triangleArea(p, v1, v2);
float area2 = triangleArea(p, v2, v3);
float area3 = triangleArea(p, v3, v1);
float epsilon = 0.0001; // 处理浮点误差
return abs((area1 + area2 + area3) - areaTotal) < epsilon;
}性能对比:
- ⚡ 叉积法:3次叉积 + 简单比较
- 🐢 面积法:4次叉积 + 面积求和 + 浮点误差处理
7.4.3 方法 C:重心坐标法(高级,用于插值)
核心原理: 将点 P 表示为三角形顶点的加权和:
P = α·v1 + β·v2 + γ·v3
其中 α + β + γ = 1
若 α,β,γ 都 ≥ 0 → P在三角形内应用场景: 不仅判断内外,还能得到插值权重(用于纹理映射、颜色渐变)
vec3 barycentric(vec2 p, vec2 v1, vec2 v2, vec2 v3) {
vec2 v0 = v2 - v1;
vec2 v1_offset = v3 - v1;
vec2 v2_offset = p - v1;
float d00 = dot(v0, v0);
float d01 = dot(v0, v1_offset);
float d11 = dot(v1_offset, v1_offset);
float d20 = dot(v2_offset, v0);
float d21 = dot(v2_offset, v1_offset);
float denom = d00 * d11 - d01 * d01;
float beta = (d11 * d20 - d01 * d21) / denom;
float gamma = (d00 * d21 - d01 * d20) / denom;
float alpha = 1.0 - beta - gamma;
return vec3(alpha, beta, gamma);
}
// 使用
vec3 weights = barycentric(st, v1, v2, v3);
bool inside = all(greaterThanEqual(weights, vec3(0.0)));
// 🎨 高级用法:基于重心坐标的颜色插值
vec3 color1 = vec3(1.0, 0.0, 0.0); // 顶点1:红色
vec3 color2 = vec3(0.0, 1.0, 0.0); // 顶点2:绿色
vec3 color3 = vec3(0.0, 0.0, 1.0); // 顶点3:蓝色
vec3 interpolatedColor = weights.x * color1 +
weights.y * color2 +
weights.z * color3;7.4.4 方法 D:极坐标法(高级,用于正多边形)
适用场景: 绘制任意正 N 边形(三角形、五角星、六边形等)
核心技巧: 利用极坐标的角度对称性
#define TWO_PI 6.28318530718
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
st.x *= u_resolution.x / u_resolution.y;
st = st * 2.0 - 1.0;
int N = 3; // 边数:3=三角形, 5=五边形, 6=六边形
float rotation = u_time * 0.3; // 旋转动画
// 🧭 转换到极坐标
float angle = atan(st.y, st.x); // 角度 [-π, π]
if (angle < 0.0) angle += TWO_PI; // 映射到 [0, 2π]
angle += rotation; // 添加旋转
float dist = length(st); // 距离
// 🔄 角度折叠:将360°分成N份,每份内部对称
float sectorAngle = TWO_PI / float(N);
float foldedAngle = mod(angle + sectorAngle * 0.5, sectorAngle) - sectorAngle * 0.5;
// 📏 修正距离(投影到边上)
float correctedDist = dist * cos(foldedAngle);
// 🎨 生成图形(smoothstep 创建平滑边缘)
float size = 0.5; // 控制大小
float edge = 0.02; // 边缘宽度
float shape = 1.0 - smoothstep(size - edge, size + edge, correctedDist);
gl_FragColor = vec4(vec3(shape), 1.0);
}优势:
- ✅ 一套代码支持任意正多边形
- ✅ 天然支持旋转动画
- ✅ 代码简洁
7.5 三种判断方法对比总结
| 方法 | 性能 | 精度 | 额外功能 | 推荐场景 |
|---|---|---|---|---|
| 叉积法 | ⚡⚡⚡ 快 | ✅ 高 | 无 | 日常使用首选 |
| 面积法 | ⚡⚡ 中 | ✅ 高 | 无 | 理论学习 |
| 重心坐标 | ⚡ 慢 | ✅ 高 | 插值权重 | 纹理映射、颜色渐变 |
| 极坐标法 | ⚡⚡ 中 | ⚠️ 近似 | 正多边形通用 | 规则图案、艺术效果 |
💡 实际开发建议:
- 🎯 简单判断 → 叉积法
- 🎨 需要插值 → 重心坐标法
- 🔺 正多边形 → 极坐标法
- 🎮 复杂形状 → SDF 距离场(下一节)
8. SDF (Signed Distance Field) - 现代 Shader 绘图的革命
如果说前面的方法是"判断点是否在图形内",那么 SDF 则是计算点到图形的距离。这个思维转变带来了惊人的能力。
8.1 什么是 SDF?
Signed Distance Field (有符号距离场) 是一个函数 d(p),它返回空间中任意点 p 到形状表面的带符号距离:
d(p) < 0 → 点在形状内部(距离为负)
d(p) = 0 → 点在形状表面
d(p) > 0 → 点在形状外部(距离为正)
|d(p)| = 到表面的最短距离可视化示例:
圆形 SDF 的距离值分布:
-0.3 -0.2 -0.1 0.0 +0.1 +0.2
┌────────────────────────────┐
│ ●●●●●●●●●●●●●●●●●● │
│ ●● ●● │
│ ● -0.2 ● │
│● -0.1 0.0 +0.1 ● │
│● 圆心 ● │
│ ● ● │
│ ●● ●● │
│ ●●●●●●●●●●●●●●●●● │
└────────────────────────────┘8.2 SDF 的三大优势
8.2.1 天然的抗锯齿边缘
// 传统方法:硬边缘
float circle = step(distance(p, center), radius); // ❌ 锯齿明显
// SDF 方法:平滑边缘
float d = length(p - center) - radius; // 计算距离
float circle = smoothstep(0.01, -0.01, d); // ✅ 完美抗锯齿!8.2.2 轻松创建轮廓/发光效果
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
void main() {
vec2 st = (gl_FragCoord.xy / u_resolution * 2.0 - 1.0);
st.x *= u_resolution.x / u_resolution.y;
float d = sdCircle(st, 0.3);
// 🎨 多种视觉效果:
// 1️⃣ 填充(内部白色,外部黑色)
float fill = step(0.0, -d);
// 2️⃣ 轮廓线(只显示边缘)
float outline = smoothstep(0.01, 0.0, abs(d) - 0.02);
// 3️⃣ 光晕效果(距离越近越亮)
float glow = 0.02 / abs(d);
// 4️⃣ 渐变填充(基于距离)
float gradient = 1.0 - clamp(abs(d) * 2.0, 0.0, 1.0);
vec3 color = vec3(gradient); // 选择一种效果
gl_FragColor = vec4(color, 1.0);
}8.2.3 布尔运算(组合多个形状)
这是 SDF 最强大的功能!
// 🔧 基础 SDF 函数库
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sdTriangle(vec2 p, vec2 a, vec2 b, vec2 c) {
vec2 e0 = b - a, e1 = c - b, e2 = a - c;
vec2 v0 = p - a, v1 = p - b, v2 = p - c;
vec2 pq0 = v0 - e0*clamp(dot(v0,e0)/dot(e0,e0), 0.0, 1.0);
vec2 pq1 = v1 - e1*clamp(dot(v1,e1)/dot(e1,e1), 0.0, 1.0);
vec2 pq2 = v2 - e2*clamp(dot(v2,e2)/dot(e2,e2), 0.0, 1.0);
float s = sign(e0.x*e2.y - e0.y*e2.x);
vec2 d = min(min(vec2(dot(pq0,pq0), s*(v0.x*e0.y-v0.y*e0.x)),
vec2(dot(pq1,pq1), s*(v1.x*e1.y-v1.y*e1.x))),
vec2(dot(pq2,pq2), s*(v2.x*e2.y-v2.y*e2.x)));
return -sqrt(d.x)*sign(d.y);
}
// 🎭 布尔运算操作符
// 并集(Union):两个形状合并
float opUnion(float d1, float d2) {
return min(d1, d2);
}
// 交集(Intersection):两个形状重叠部分
float opIntersection(float d1, float d2) {
return max(d1, d2);
}
// 差集(Subtraction):从 d1 中减去 d2
float opSubtraction(float d1, float d2) {
return max(d1, -d2);
}
// 🌟 平滑并集(Smooth Union)
float opSmoothUnion(float d1, float d2, float k) {
float h = clamp(0.5 + 0.5*(d2-d1)/k, 0.0, 1.0);
return mix(d2, d1, h) - k*h*(1.0-h);
}
// 📐 实际应用示例
void main() {
vec2 st = (gl_FragCoord.xy / u_resolution * 2.0 - 1.0);
st.x *= u_resolution.x / u_resolution.y;
// 🔵 圆形
float circle1 = sdCircle(st - vec2(-0.3, 0.0), 0.2);
float circle2 = sdCircle(st - vec2( 0.3, 0.0), 0.2);
// 🟦 矩形
float box = sdBox(st, vec2(0.3, 0.15));
// 🎨 组合操作
float shape;
// 示例1:两个圆的并集
shape = opUnion(circle1, circle2);
// 示例2:矩形减去两个圆(挖洞效果)
// shape = opSubtraction(box, opUnion(circle1, circle2));
// 示例3:平滑融合(液体效果)
// shape = opSmoothUnion(circle1, circle2, 0.1);
// 渲染
float mask = smoothstep(0.01, -0.01, shape);
vec3 color = vec3(mask);
gl_FragColor = vec4(color, 1.0);
}8.3 常用 2D SDF 形状库
| 形状 | SDF 函数 | 参数说明 |
|---|---|---|
| 圆形 | length(p) - r | r = 半径 |
| 矩形 | 见上方 sdBox | b = 半尺寸 (宽/2, 高/2) |
| 线段 | 点到线段的距离 | a, b = 端点 |
| 五角星 | 极坐标 + 角度折叠 | n = 边数, r = 半径 |
完整资源: Inigo Quilez 的 SDF 函数库 (业界标准)
8.4 SDF 的高级应用
8.4.1 Ray Marching(光线追踪)
SDF 是 Ray Marching 的核心!通过距离信息,可以高效地在 3D 场景中追踪光线。
8.4.2 文字渲染
使用 SDF 纹理可以实现无损缩放的文字(如 Valve 的 SDF 字体技术)。
8.4.3 粒子系统
每个粒子用 SDF 表示,可以实现流体般的融合效果(Metaball)。
8.5 SDF vs 传统方法对比
| 特性 | 传统判断法 | SDF 方法 |
|---|---|---|
| 边缘质量 | ❌ 锯齿 | ✅ 天然抗锯齿 |
| 轮廓/描边 | ❌ 难实现 | ✅ 轻松 |
| 形状组合 | ❌ 复杂 | ✅ 简单(min/max) |
| 性能 | ⚡⚡⚡ 极快 | ⚡⚡ 稍慢(涉及distance计算) |
| 应用场景 | 简单图形 | 复杂场景、艺术效果、3D |
结论: SDF 是现代 Shader 艺术和程序化建模的基石!
9. 2D 矩阵变换 - 操控空间的艺术
在传统编程中,我们移动物体(object.x += 10)。但在 Shader 中,思维需要完全逆转:
传统思维:移动物体
Shader思维:移动整个坐标系9.1 核心概念:坐标空间变换
想象一张写满数字的透明纸:
- 传统方法:移动纸上的数字
- Shader方法:数字不动,移动整张纸!
vec2 st = gl_FragCoord.xy / u_resolution.xy; // 每个像素知道自己的位置
// 我们的所有变换都是在"扭曲空间"
// 图形本身的定义不变,变的是坐标系为什么这样做?
- GPU 是并行的,每个像素独立计算
- 无法"通知"其他像素"我们要一起移动"
- 只能让每个像素重新计算自己在新坐标系下的位置
9.2 平移(Translate)- 最简单的变换
平移就是让坐标系整体移动。
9.2.1 数学原理
新坐标 = 旧坐标 + 位移向量
st' = st + offset⚠️ 注意逆向思维:
坐标系向右移 → 图形看起来向左移
坐标系向上移 → 图形看起来向下移9.2.2 基础示例:固定位移
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution;
st = st * 2.0 - 1.0;
// 🎯 平移:把原点从 (0,0) 移到 (0.3, 0.2)
st -= vec2(0.3, 0.2); // 注意是 -= (减法)
// 现在在新坐标系下画圆(圆心在原点)
float circle = length(st);
circle = step(circle, 0.2);
// 结果:圆显示在屏幕的 (0.3, 0.2) 位置!
gl_FragColor = vec4(vec3(circle), 1.0);
}为什么是 -= 而不是 +=?
目标:让圆向右移 0.3
逻辑:圆的定义是"在原点"
解决:让坐标系向左移 -0.3(st -= 0.3)
结果:圆相对屏幕向右了!
类比:你站在传送带上向左走,相对地面你在向右移动9.2.3 动画示例:圆周运动
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🎡 计算圆周运动的位移
float radius = 0.5; // 轨道半径
vec2 offset = vec2(
cos(u_time) * radius, // X: -0.5 ~ +0.5
sin(u_time) * radius // Y: -0.5 ~ +0.5
);
// 让坐标系跟着圆周运动
st -= offset;
// 画一个固定在原点的圆
float circle = smoothstep(0.11, 0.10, length(st));
// 🎨 添加拖尾效果(可选)
float trail = 0.02 / length(st);
vec3 color = vec3(circle) + vec3(0.1, 0.3, 0.5) * trail;
gl_FragColor = vec4(color, 1.0);
}9.2.4 多物体平移
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🟢 圆1:左侧
vec2 st1 = st - vec2(-0.4, 0.0);
float circle1 = smoothstep(0.11, 0.10, length(st1));
// 🔵 圆2:右侧
vec2 st2 = st - vec2(0.4, 0.0);
float circle2 = smoothstep(0.11, 0.10, length(st2));
// 🟣 圆3:上方(动画)
vec2 st3 = st - vec2(0.0, 0.4 * abs(sin(u_time)));
float circle3 = smoothstep(0.11, 0.10, length(st3));
vec3 color = vec3(0.0);
color += vec3(0.0, 1.0, 0.0) * circle1; // 绿色
color += vec3(0.0, 0.5, 1.0) * circle2; // 蓝色
color += vec3(1.0, 0.0, 1.0) * circle3; // 紫色
gl_FragColor = vec4(color, 1.0);
}9.3 旋转(Rotation)- 绕原点打转
旋转是最有趣也最容易出错的变换。
9.3.1 数学原理:2D 旋转矩阵
二维旋转矩阵(逆时针旋转 θ 角度):
| cos(θ) -sin(θ) | | x | | x' |
| sin(θ) cos(θ) | × | y | = | y' |
展开后:
x' = x * cos(θ) - y * sin(θ)
y' = x * sin(θ) + y * cos(θ)推导来源: 极坐标旋转公式
9.3.2 GLSL 实现
// 🔧 2D 旋转矩阵函数
mat2 rotate2d(float angle) {
float c = cos(angle);
float s = sin(angle);
return mat2(
c, s, // 第一列
-s, c // 第二列
);
// ⚠️ 注意:GLSL 的矩阵是列主序(column-major)
}
// 使用
vec2 rotated = rotate2d(PI * 0.5) * st; // 旋转 90°🎯 注意:矩阵乘法顺序
st = rotate2d(angle) * st; // ✅ 正确:矩阵在前
st = st * rotate2d(angle); // ❌ 错误:结果不对!9.3.3 完整示例:旋转矩形
#define PI 3.14159265359
uniform float u_time;
uniform vec2 u_resolution;
mat2 rotate2d(float angle) {
float c = cos(angle);
float s = sin(angle);
return mat2(c, s, -s, c);
}
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🔄 旋转坐标系
st = rotate2d(u_time) * st;
// 📐 画一个矩形(SDF 方法)
vec2 size = vec2(0.3, 0.1);
vec2 d = abs(st) - size;
float rect = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
float mask = smoothstep(0.01, -0.01, rect);
vec3 color = vec3(mask);
gl_FragColor = vec4(color, 1.0);
}9.3.4 关键陷阱:旋转中心
默认旋转中心是原点 (0,0)!如果你想绕其他点旋转:
// 🎯 绕屏幕中心 (0.5, 0.5) 旋转的完整流程
vec2 st = gl_FragCoord.xy / u_resolution; // (0,0) ~ (1,1)
// ❌ 错误:直接旋转(绕左下角转)
// st = rotate2d(u_time) * st;
// ✅ 正确:三步走
st -= 0.5; // 1. 平移:中心移到原点
st = rotate2d(u_time) * st; // 2. 旋转:绕原点转
st += 0.5; // 3. 还原:移回原位记忆口诀:移-转-还
- 移到原点
- 转动
- 还原位置
9.3.5 进阶:组合旋转 + 位移
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🎡 先让图形绕屏幕中心公转
st = rotate2d(u_time * 0.5) * st;
// 🌍 再让图形在自己位置自转
vec2 localSt = st - vec2(0.5, 0.0); // 偏移到公转轨道
localSt = rotate2d(u_time * 2.0) * localSt; // 自转(更快)
// 画一个小矩形
float shape = step(abs(localSt.x), 0.1) * step(abs(localSt.y), 0.05);
gl_FragColor = vec4(vec3(shape), 1.0);
}9.4 缩放(Scale)- 拉伸与压缩
缩放改变坐标系的"单位长度"。
9.4.1 数学原理
缩放矩阵:
| sx 0 | | x | | x' |
| 0 sy | × | y | = | y' |
结果:
x' = x * sx
y' = y * sy
sx > 1: X 方向拉伸 | sx < 1: X 方向压缩
sy > 1: Y 方向拉伸 | sy < 1: Y 方向压缩9.4.2 GLSL 实现
mat2 scale2d(vec2 s) {
return mat2(
s.x, 0.0,
0.0, s.y
);
}
// 或者直接用乘法(更简洁)
st *= vec2(2.0, 0.5); // X放大2倍,Y缩小0.5倍9.4.3 完整示例:呼吸效果
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🫁 呼吸式缩放(范围:0.5 ~ 1.5)
float scale = 1.0 + 0.5 * sin(u_time);
// 缩放坐标系(图形看起来缩小了)
st /= scale; // 等价于 st *= (1.0 / scale)
// 画圆
float circle = smoothstep(0.31, 0.30, length(st));
gl_FragColor = vec4(vec3(circle), 1.0);
}⚠️ 注意:缩放的逆向性
st *= 2.0; // 坐标系拉伸 2倍 → 图形看起来缩小到原来的 0.5 倍
st /= 2.0; // 坐标系缩小 0.5倍 → 图形看起来放大到原来的 2 倍9.4.4 非均匀缩放:创造变形效果
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🎨 动态椭圆效果
float scaleX = 1.0 + 0.5 * sin(u_time);
float scaleY = 1.0 + 0.5 * cos(u_time);
st.x /= scaleX;
st.y /= scaleY;
float circle = smoothstep(0.31, 0.30, length(st));
gl_FragColor = vec4(vec3(circle), 1.0);
// 圆会在椭圆和反向椭圆之间变化!
}9.5 组合变换:TRS 顺序
在实际应用中,我们经常需要同时使用平移、旋转、缩放。
9.5.1 变换顺序很重要!
// ❌ 错误顺序(结果出乎意料)
st -= offset; // 平移
st = rotate2d(angle) * st; // 旋转
st /= scale; // 缩放
// ✅ 标准顺序:TRS(Translate → Rotate → Scale)
// 实际代码中是 **逆序** 执行!
st /= scale; // 3. 最后缩放
st = rotate2d(angle) * st; // 2. 然后旋转
st -= offset; // 1. 先平移为什么逆序? 因为我们在变换坐标系,不是物体。想象逆向播放录像带。
9.5.2 完整TRS示例
#define PI 3.14159265359
uniform float u_time;
uniform vec2 u_resolution;
mat2 rotate2d(float a) {
float c = cos(a), s = sin(a);
return mat2(c, s, -s, c);
}
void main() {
vec2 st = gl_FragCoord.xy / u_resolution * 2.0 - 1.0;
st.x *= u_resolution.x / u_resolution.y;
// 🎯 TRS 变换(逆序执行)
// 3️⃣ Scale:呼吸式缩放
float scale = 1.0 + 0.3 * sin(u_time);
st /= scale;
// 2️⃣ Rotate:自转
st = rotate2d(u_time) * st;
// 1️⃣ Translate:绕圈公转
vec2 offset = vec2(cos(u_time * 0.5), sin(u_time * 0.5)) * 0.5;
st -= offset;
// 画一个矩形
vec2 size = vec2(0.2, 0.1);
float rect = step(abs(st.x), size.x) * step(abs(st.y), size.y);
vec3 color = vec3(rect);
gl_FragColor = vec4(color, 1.0);
}10. 总结与下一步
恭喜你完成了 Shader 的入门学习!你现在掌握了:
✅ 基础概念
- GPU 并行计算原理
- 数据流:uniform / attribute / varying
- 内置变量的使用
✅ 核心技能
- GLSL 函数库(step / smoothstep / dot / cross 等)
- Swizzling 语法糖
- 坐标系变换思维
✅ 实战技巧
- 多种绘制三角形的方法
- SDF 距离场的强大应用
- 2D 矩阵变换(平移/旋转/缩放)
10.1 进阶方向
- 3D Shader - 学习顶点着色器、光照模型、法线贴图
- 后期处理 - 模糊、辉光、色差、景深
- 粒子系统 - GPU 粒子、流体模拟
- 程序化纹理 - Perlin Noise、Worley Noise
- Ray Marching - 3D SDF、体积渲染
10.2 推荐资源
- The Book of Shaders - 最经典的 Shader 教程
- Shadertoy - 全球最大的 Shader 社区
- Inigo Quilez 的博客 - SDF 之父的技术文章
- GPU Gems - NVIDIA 官方技术书籍
继续探索 Shader 的无限可能吧!✨