🌅 Shader 进阶 - 光线追踪与环境映射的极致渲染
1. 🌟 传统渲染 vs 光线追踪
1.1 两种根本不同的渲染思路
在前面所有文章中,我们使用的是光栅化渲染 (Rasterization):
光栅化流程(投影式):
顶点 → 三角形 → 光栅化 → 像素💡 类比理解:光栅化就像用投影仪投幻灯片
- GPU 把 3D 模型的每个三角形"投影"到 2D 屏幕上
- 就像手电筒照在墙上,物体产生影子
- 快速但有局限:很难模拟真实的反射、折射和阴影
底层本质:光栅化是几何驱动的,遍历场景中的所有三角形,逐个判断它们覆盖了哪些像素。
但还有另一种完全不同的思路 - 光线追踪 (Ray Tracing):
光线追踪流程(物理模拟式):
相机 → 发射光线 → 碰撞检测 → 着色💡 类比理解:光线追踪模拟人眼看世界的过程
- 你的眼睛看向某个方向,接收到该方向反射回来的光
- 光线追踪反向模拟:从摄像机发射光线,看它击中了什么
- 就像用激光笔一点一点扫描场景,记录每个点的颜色
底层本质:光线追踪是像素驱动的,对屏幕上的每个像素发射一条光线,找出这条光线击中了什么物体。
1.2 两种方法的优劣对比
| 特性 | 光栅化 | 光线追踪 |
|---|---|---|
| 原理 | 投影三角形到屏幕 | 模拟真实光路 |
| 性能 | ⚡⚡⚡ 极快 | 🐢 慢 (除非硬件RT) |
| 反射/折射 | ❌ 需要技巧 (环境贴图) | ✅ 天然支持 |
| 软阴影 | ❌ 困难 | ✅ 简单 |
| 全局光照 | ❌ 需要烘焙 | ✅ 自然产生 |
| 复杂几何 | ✅ 三角形数量决定 | ✅ SDF可表示任意形状 |
💡 为什么光栅化更快?
硬件层面:现代 GPU 的核心架构就是为光栅化设计的:
- 专用的光栅化单元 (Rasterizer),硬件级加速
- 深度测试 (Z-Buffer) 自动处理遮挡关系
- 纹理缓存 (Texture Cache) 优化内存访问
算法层面:光栅化的计算复杂度是 O(三角形数量),每个三角形独立处理。
光线追踪为什么慢?
- 需要大量射线-物体求交计算 (Ray-Object Intersection)
- 每条光线可能与多个物体相交,需要找到最近的交点
- 复杂度是 O(光线数量 × 物体数量)
- 现代硬件 RT Core (如 NVIDIA RTX) 通过 BVH 加速结构优化这一过程
1.3 GPU上的挑战与解决方案
挑战 1:加速结构难以在 Shader 中实现
传统光线追踪依赖 BVH (Bounding Volume Hierarchy) 加速结构:
BVH 原理:
场景物体 → 层层包裹成树形结构 → 快速剔除不相交的分支
示例:
根节点(整个场景包围盒)
╱ ╲
左子树 右子树
(左半场景) (右半场景)
╱ ╲ ╱ ╲
物体A 物体B 物体C 物体D
查询过程:光线先测试根节点,再递归测试子节点,大幅减少测试次数💡 BVH 的困境:
- 需要动态内存访问(指针、数组索引跳转)
- Shader 中难以实现高效的树形遍历
- 传统 WebGL/GLSL 不支持递归或复杂数据结构
解决方案:Ray Marching + SDF
核心思想:用 SDF (Signed Distance Field) 替代三角形网格!
💡 什么是 SDF?
SDF 是一个数学函数,输入一个 3D 点,返回该点到最近表面的有向距离:
- 正数 = 点在物体外部,值表示"到表面的距离"
- 负数 = 点在物体内部,值表示"到表面的距离"
- 零 = 点在表面上
例如,球体的 SDF:
distance = length(point) - radius
Ray Marching的优势:
- ✅ 不需要复杂的数据结构(只需要一个函数)
- ✅ 可以表示数学定义的曲面(球、环面、分形等)
- ✅ 非常适合GPU并行计算(每个像素独立计算)
- ✅ 代码简洁,容易理解
- ✅ 支持 CSG 布尔运算(并集、交集、差集)
2. 🚶 Ray Marching - GPU上的光线追踪
2.1 核心思想:步进前进
传统光线追踪:直接求光线与物体的精确交点(需要复杂的数学求解)
Ray Marching:沿着光线逐步行进,每次前进"安全距离",直到接近表面
摄像机 SDF球体
● ●
│╲
│ ╲ 光线
│ ╲
│ ●───→ 步进1 (安全距离d1=3.0)
│ ╲
│ ●──→ 步进2 (安全距离d2=1.5)
│ ╲
│ ●─→ 步进3 (d3=0.001,命中!)💡 类比理解:想象你在浓雾中摸索前进
场景设定:你在一个漆黑的房间,手里有一个"距离探测器",它能告诉你:"前方最近的墙壁距离你 X 米"
前进策略:
- 探测器显示"前方最近墙壁 3 米" → 你可以安全地向前走 3 米(不会撞到任何东西)
- 到达新位置,再次探测:"前方最近墙壁 1.5 米" → 再走 1.5 米
- 继续探测:"前方最近墙壁 0.001 米" → 你几乎贴在墙上了,停止!
关键洞察:SDF 就是这个"距离探测器",它保证每次前进的距离是"安全"的(不会穿过物体)
Ray Marching 就是让光线这样"摸索"前进!
为什么这个方法有效?
数学保证:SDF 返回的是"到最近表面的距离",所以:
如果 SDF(当前点) = 5.0
→ 意味着:以当前点为球心,半径 5.0 的球体内部,没有任何物体表面
→ 结论:沿光线前进 5.0 单位,绝对不会穿过物体这就是"安全步进"(Sphere Tracing) 的数学基础!
2.2 算法步骤详解
完整流程:
初始化光线
- 起点
origin:摄像机位置 - 方向
direction:从摄像机指向屏幕像素的归一化向量 - 累计距离
t = 0.0
- 起点
开始循环步进 (最多 MAX_STEPS 次,例如 100 次)
计算当前位置
当前点 = origin + t × direction查询 SDF:"当前点到最近表面有多远?"
d = sceneSDF(当前点)判断三种情况
情况 A:命中表面 (
d < EPSILON,例如d < 0.001)- EPSILON 是"足够接近"的阈值
- 返回
t(光线走过的总距离) - 跳出循环,开始着色
情况 B:超出渲染范围 (
t > MAX_DISTANCE,例如t > 100.0)- 说明光线射向无限远,场景中没有物体
- 返回
-1或特殊值表示"未命中" - 跳出循环
情况 C:继续前进
- 更新累计距离:
t += d(关键!这是"安全步进") - 回到步骤 3
着色:如果命中,用
t计算交点坐标,进行光照计算
💡 为什么是"安全"步进?
SDF 的定义保证了一个重要性质:
如果 SDF(P) = d → 则以 P 为中心、半径为 d 的球体内,没有任何表面 → 沿任意方向前进 d 单位,都不会"穿过"物体这就是为什么可以大胆地
t += d,而不用担心跳过物体表面!这个技术也被称为 Sphere Tracing(球体追踪),因为每次步进都是在一个"安全球"内前进。
2.3 数学表达
光线的参数方程:
P(t) = origin + t × direction
其中:
- origin: 光线起点(摄像机位置),例如 (0, 1, -3)
- direction: 光线方向(归一化向量),例如 (0, 0, 1) 表示正前方
- t: 参数(沿光线的距离),范围 [0, MAX_DISTANCE]
- P(t): 光线上距离起点 t 单位的点💡 参数方程的直观理解:
- 当
t = 0时,P(0) = origin(在摄像机处)- 当
t = 1时,P(1) = origin + direction(沿光线前进 1 单位)- 当
t = 10时,P(10) = origin + 10 * direction(沿光线前进 10 单位)就像在一条直线上行走,
t就是"已走过的距离"
Ray Marching 伪代码:
算法 RayMarch(origin, direction):
t = 0.0 // 初始化:当前沿光线的累计距离
for i = 0 to MAX_STEPS: // 最多步进 MAX_STEPS 次(如 100 次)
point = origin + t * direction // 计算当前位置(光线上的点)
dist = SDF(point) // 查询:当前点到最近表面的距离
// ===== 命中检测 =====
if dist < EPSILON: // 如果足够接近表面(EPSILON = 0.001)
return t // 命中!返回距离
// ===== 安全步进 =====
t += dist // 向前移动 dist 单位(保证不会穿过物体)
// ===== 超出范围检测 =====
if t > MAX_DISTANCE: // 如果累计距离超出渲染范围(MAX_DISTANCE = 100.0)
return -1 // 未命中(光线射向无限远)
return -1 // 步数用完,仍未命中(可能场景太复杂,或参数不合适)💡 关键参数的底层原理:
MAX_STEPS (100):最多步进次数
- 作用:防止无限循环,保证 Shader 能在有限时间内完成
- 太小(如 20):复杂场景会遗漏细节,远处物体可能渲染不出来
- 太大(如 500):性能严重下降,每帧计算时间大增
- 推荐值:简单场景 50-100,复杂场景 100-200
EPSILON (0.001):命中判定阈值
- 作用:定义"足够接近表面"的距离
- 太小(如 0.00001):浮点精度误差导致永远无法"命中",出现条纹伪影
- 太大(如 0.1):提前终止,表面凹凸不准确,细节丢失
- 推荐值:0.001(千分之一单位)是经验最佳值
- 场景相关:如果场景单位是"米",0.001 = 1 毫米,足够精确
MAX_DISTANCE (100.0):最大渲染距离
- 作用:定义"可见范围",超出此距离认为"没有物体"
- 太小(如 10.0):远景物体被裁剪,看不见
- 太大(如 1000.0):浪费步进次数在空旷区域
- 推荐值:根据场景大小设置,室内 10-50,室外 100-500
2.4 基础实现
💡 代码目标:渲染一个漂浮的球体和地面,理解Ray Marching的完整流程。
#ifdef GL_ES
precision highp float; // 高精度(Ray Marching需要精确计算)
#endif
uniform vec2 u_resolution; // 屏幕分辨率
uniform float u_time; // 时间(用于动画)
// ========== 常量配置 ==========
const int MAX_STEPS = 100; // 最大步进次数
const float MAX_DIST = 100.0; // 最大渲染距离
const float EPSILON = 0.001; // 命中阈值
// ========== SDF函数库 ==========
// 🔵 SDF: 球体(最简单的 SDF 示例)
float sdSphere(vec3 p, float radius) {
// 返回点 p 到球面的有向距离
//
// 数学原理:
// - 球心在原点,半径 r
// - 点 p 到原点的距离 = length(p)
// - SDF = length(p) - r
//
// 示例:
// - 如果 length(p) = 5.0,radius = 3.0 → SDF = 2.0(点在球外 2 单位)
// - 如果 length(p) = 3.0,radius = 3.0 → SDF = 0.0(点在球面上)
// - 如果 length(p) = 1.0,radius = 3.0 → SDF = -2.0(点在球内 2 单位)
//
// 返回值:
// - 正数 = 在球外,值=到表面的距离
// - 零 = 在球面上
// - 负数 = 在球内,绝对值=到表面的距离
return length(p) - radius;
}
// 🔷 SDF: 盒子(立方体,相对复杂)
float sdBox(vec3 p, vec3 b) {
// p: 点的位置(世界坐标)
// b: 盒子的半尺寸(宽/高/深的一半)
// 例如 b = (1, 2, 3) 表示盒子尺寸是 2×4×6
//
// 原理:
// 1. abs(p) - b:计算点到盒子表面的向量
// 2. max(q, 0.0):仅保留"超出盒子"的部分(点在盒子外)
// 3. length(...):计算外部距离
// 4. min(max(q.x, ...), 0.0):处理内部点的距离(点在盒子内)
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
// ========== 场景定义 ==========
// 🌍 场景SDF(定义整个场景中所有物体)
float sceneSDF(vec3 p) {
// 这个函数是整个 Ray Marching 的核心!
// 它告诉我们:给定一个点 p,到最近物体表面的距离是多少
// ========== 物体 1:漂浮的球体 ==========
// 球心在 (0, 1, 0),半径 1.0
// 技巧:通过 "p - center" 实现球体的位移
float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
// 等价于:把整个空间向相反方向移动,使球心在原点
// ========== 物体 2:无限地面 ==========
// 地面是 y=0 的平面
// 技巧:平面的 SDF 就是点到平面的垂直距离
// - 如果 p.y = 5.0 → 点在平面上方 5 单位
// - 如果 p.y = 0.0 → 点在平面上
// - 如果 p.y = -2.0 → 点在平面下方 2 单位
float ground = p.y;
// ========== CSG 布尔运算:并集 (Union) ==========
// min(A, B):取两个距离的最小值
// 含义:"到最近物体的距离"
// - 如果球更近 → 返回到球的距离
// - 如果地面更近 → 返回到地面的距离
return min(sphere, ground);
// 💡 其他 CSG 运算:
// - 交集 (Intersection):max(A, B)
// - 差集 (Subtraction):max(A, -B)
// - 平滑并集:smin(A, B, k) = 使用平滑最小值函数
}
// ========== Ray Marching核心算法 ==========
// 🚶 Ray Marching 主函数
// 输入:
// ro (Ray Origin):光线起点(摄像机位置)
// rd (Ray Direction):光线方向(归一化向量)
// 返回:
// 正数:光线击中物体,返回距离起点的距离 t
// 负数:光线未击中任何物体(射向无限远或步数用完)
float rayMarch(vec3 ro, vec3 rd) {
float t = 0.0; // 沿光线的累计距离(初始在起点)
// ========== 循环步进 ==========
for(int i = 0; i < MAX_STEPS; i++) {
// ===== 步骤 1:计算当前位置 =====
vec3 p = ro + t * rd;
// p 是光线上距离起点 t 单位的点
// ===== 步骤 2:查询 SDF =====
float dist = sceneSDF(p);
// dist 是当前点到最近物体表面的距离
// ===== 步骤 3:命中检测 =====
if(dist < EPSILON) {
// 当前点非常接近表面(距离 < 0.001)
// 认为已经"命中"物体
return t; // 返回累计距离
}
// ===== 步骤 4:安全步进 =====
t += dist;
// 关键!向前移动 dist 单位(SDF 保证不会穿过物体)
// ===== 步骤 5:超出范围检测 =====
if(t > MAX_DIST) {
// 累计距离超出最大渲染范围
// 说明光线射向无限远,场景中没有物体
break; // 退出循环
}
}
// 如果循环结束都没有命中,返回 -1 表示"未命中"
return -1.0;
// 💡 未命中的两种情况:
// 1. t > MAX_DIST:光线射向无限远(天空)
// 2. 循环次数用完:场景太复杂,或参数设置不合理
}
// ========== 主渲染函数 ==========
void main() {
// ========== 步骤1:计算屏幕UV坐标 ==========
//
// 目标:把像素坐标转换为归一化设备坐标 (NDC)
//
// gl_FragCoord.xy:当前像素的屏幕坐标
// 例如:分辨率 800×600,左下角 (0, 0),右上角 (800, 600)
//
// 计算步骤:
// 1. gl_FragCoord.xy - 0.5 * u_resolution.xy
// → 将原点移到屏幕中心
// 例如:800×600 → 中心点从 (400, 300) 变为 (0, 0)
//
// 2. 除以 u_resolution.y(注意:是 y,不是 x!)
// → 归一化,并保持长宽比不变
// 例如:800×600 → uv 范围变为 [-0.67, 0.67]×[-0.5, 0.5]
//
// 💡 为什么除以 y 而不是 x?
// - 这样可以保证垂直方向固定为 [-0.5, 0.5]
// - 水平方向根据宽高比自动调整
// - 避免画面拉伸变形
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
// ========== 步骤2:设置摄像机 ==========
// 📍 相机位置 (Ray Origin)
vec3 ro = vec3(0.0, 1.0, -3.0);
// (0, 1, -3) 表示:
// - x=0:水平居中
// - y=1:高度 1 单位(与球心同高)
// - z=-3:在球前方 3 单位(OpenGL 右手坐标系,-z 是"向前")
// 🎯 光线方向 (Ray Direction)
vec3 rd = normalize(vec3(uv, 1.0));
// 解释:
// - vec3(uv, 1.0) = (uv.x, uv.y, 1.0)
// - uv.x, uv.y 决定横纵方向(对应屏幕上的像素位置)
// - 1.0 是"焦距"(z 方向的距离)
//
// 💡 焦距的作用:
// - 焦距越大(如 2.0),视野越窄(类似"望远镜")
// - 焦距越小(如 0.5),视野越宽(类似"广角镜头")
// - 1.0 是常见的默认值
//
// 💡 为什么要 normalize?
// - 方向向量必须是单位长度(长度=1)
// - 确保所有光线以相同的"速度"前进
// ========== 步骤3:执行Ray Marching ==========
float t = rayMarch(ro, rd);
// t > 0:光线击中物体,t 是距离
// t < 0:光线未击中物体
// ========== 步骤4:着色 ==========
vec3 color = vec3(0.0);
if(t > 0.0) {
// ===== 情况 A:命中物体 =====
color = vec3(0.5, 0.7, 1.0); // 浅蓝色
// 当前只是简单地给一个固定颜色
// 后续会添加光照计算、法线、阴影等
} else {
// ===== 情况 B:背景(天空)=====
color = vec3(0.1, 0.1, 0.2); // 深蓝色
// 模拟夜空的颜色
}
gl_FragColor = vec4(color, 1.0);
// 输出最终颜色(RGBA 格式,A=1.0 表示不透明)
}💡 代码关键点:
- sceneSDF:定义场景中的所有物体,返回"到最近表面的距离"
- rayMarch:沿光线步进,直到命中或超出范围
- uv坐标:
(gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y保证了画面不会拉伸变形
效果:渲染出一个浅蓝色球体漂浮在地面上!
⚠️ 常见问题:
- 什么都看不见:检查摄像机位置
ro是否合理,确保在物体前方- 球体变形:确认UV计算使用了
u_resolution.y而非u_resolution.x- 性能很差:降低
MAX_STEPS到50,或增大EPSILON到0.01
3. 💡 Ray Marching 光照
3.1 计算法线 - SDF的魔法
SDF的强大之处:法线可以通过梯度自动计算!
💡 类比理解:想象一座山的地形图
场景设定:你站在山坡上,手里有一个"高度计",可以测量任何位置的海拔高度
如何找到"上坡最陡的方向"?
- 向东走一小步,测量高度变化 → ΔH_x
- 向北走一小步,测量高度变化 → ΔH_y
- 向上走一小步,测量高度变化 → ΔH_z
- 把这三个变化率组合成向量
(ΔH_x, ΔH_y, ΔH_z),这就是"最陡上坡方向"对于 SDF:
- 山的"高度"类比为"到表面的距离"
- 法线就是"距离变化最快的方向"
- 这个方向垂直于表面(因为 SDF 在表面上的值是 0,梯度指向距离增加最快的方向)
数学原理:
法线 = ∇SDF = (∂SDF/∂x, ∂SDF/∂y, ∂SDF/∂z)
↑
梯度(Gradient)
表示"函数变化最快的方向和速率"
理论公式(微积分定义):
∂SDF/∂x = lim(ε→0) [SDF(p + εx) - SDF(p)] / ε
数值近似(中心差分法,更精确):
N.x ≈ [SDF(p + εx) - SDF(p - εx)] / (2ε)
N.y ≈ [SDF(p + εy) - SDF(p - εy)] / (2ε)
N.z ≈ [SDF(p + εz) - SDF(p - εz)] / (2ε)
其中:
- ε (epsilon) 是微小偏移量(如 0.001)
- εx = (ε, 0, 0),εy = (0, ε, 0),εz = (0, 0, ε)
- 中心差分法:同时向前和向后采样,精度更高(O(ε²) vs O(ε))💡 为什么要归一化?
梯度的两个属性:
- 方向:指向 SDF 值增加最快的方向(即"离表面最快"的方向)
- 长度:表示 SDF 变化的速率
法线只需要方向:
- 光照计算中,我们只关心"表面朝哪个方向"
- 不关心"距离变化有多快"
- 所以要归一化到单位长度(
length(N) = 1.0)示例:
- 梯度
(3, 4, 0),长度 = 5- 归一化后
(0.6, 0.8, 0),长度 = 1- 方向不变,但长度标准化为 1
🔧 GLSL实现
// 🧭 计算法线(使用梯度近似)
// 输入:p - 表面上的点(Ray Marching 的命中点)
// 返回:归一化的法线向量(单位长度)
vec3 getNormal(vec3 p) {
float eps = 0.001; // 微小偏移量(epsilon)
vec2 h = vec2(eps, 0.0); // 巧妙的技巧:用 swizzle 构建偏移向量
// ========== 计算三个方向的梯度(中心差分法) ==========
return normalize(vec3(
sceneSDF(p + h.xyy) - sceneSDF(p - h.xyy), // x 方向的梯度
sceneSDF(p + h.yxy) - sceneSDF(p - h.yxy), // y 方向的梯度
sceneSDF(p + h.yyx) - sceneSDF(p - h.yyx) // z 方向的梯度
));
// 💡 Swizzle 技巧解析:
// h = (eps, 0.0)
// h.xyy = (h.x, h.y, h.y) = (eps, 0, 0) → x 轴偏移
// h.yxy = (h.y, h.x, h.y) = (0, eps, 0) → y 轴偏移
// h.yyx = (h.y, h.y, h.x) = (0, 0, eps) → z 轴偏移
//
// 💡 中心差分法:
// 对于 x 方向:
// - 向右采样:SDF(p + (eps, 0, 0))
// - 向左采样:SDF(p - (eps, 0, 0))
// - 梯度 ≈ (右 - 左) / (2*eps) → 但我们省略了除以 2*eps,因为最后会 normalize
//
// 💡 为什么不除以 2*eps?
// - normalize 会把向量归一化到单位长度
// - 梯度的"长度"会被丢弃,只保留"方向"
// - 所以可以省略常数因子,提高效率
}💡 参数调优指南:
eps(微小偏移量)的选择:
太小(如 0.00001):
- 问题:浮点精度误差放大,导致法线抖动
- 现象:表面出现奇怪的条纹、噪点
- 原因:
SDF(p+ε)和SDF(p-ε)的差异接近浮点误差范围太大(如 0.1):
- 问题:数值近似不准确,法线粗糙
- 现象:表面细节丢失,凹凸不明显
- 原因:偏移量太大,违反了"微分"的"无限小"假设
推荐值:
- 0.001(默认):适合大多数场景
- 0.0001 - 0.01:根据场景尺度调整
- 规则:eps ≈ 场景尺度的 0.001 倍
- 例如:场景单位是"米",eps=0.001 表示 1 毫米
⚠️ 性能注意:
- 这个函数会调用
sceneSDF6 次(每个轴正负各一次)- 是 Ray Marching 渲染中的主要性能瓶颈之一
- 优化方法:
- 使用四面体法(Tetrahedron method):只需 4 次采样(精度稍低)
- 仅在需要光照的地方计算法线
- 简化 sceneSDF 函数的复杂度
3.2 添加光照 - Lambert漫反射
💡 目标:让球体根据光源位置产生明暗变化,看起来更真实。
uniform vec3 u_lightPos; // 光源位置(世界空间)
void main() {
// ========== 步骤1:设置光线 ==========
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 ro = vec3(0.0, 1.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.0));
// ========== 步骤2:Ray Marching ==========
float t = rayMarch(ro, rd);
vec3 color = vec3(0.0);
if(t > 0.0) {
// ========== 步骤3:计算光照 ==========
// 📍 命中点的世界坐标
vec3 p = ro + t * rd;
// 🧭 计算表面法线
vec3 N = getNormal(p);
// 💡 光照方向(从表面指向光源)
vec3 L = normalize(u_lightPos - p);
// ☀️ Lambert漫反射:光照强度 = cos(入射角)
float diff = max(dot(N, L), 0.0);
// dot(N, L) = cos(θ),θ是入射角
// max(..., 0.0) 确保背面不会变负
// 🎨 物体的基础颜色
vec3 baseColor = vec3(0.5, 0.7, 1.0);
// 🌍 环境光(模拟间接光照)
float ambient = 0.2; // 20%的基础亮度
// 最终颜色 = 基础色 × (环境光 + 漫反射)
color = baseColor * (ambient + diff * 0.8);
// ↑ ↑
// 最低亮度 直接光照
} else {
// 🌌 背景(天空)
color = vec3(0.1, 0.1, 0.2);
}
gl_FragColor = vec4(color, 1.0);
}💡 光照计算解释:
- N·L:点积等于
cos(入射角),角度越小(越正对光源),值越大- ambient:环境光确保背面不会全黑(模拟天空光等间接光)
- 权重分配:
0.2环境 + 0.8漫反射可根据需要调整
效果:球体会根据光源位置产生明暗渐变!
3.3 软阴影 - Ray Marching的优势
Ray Marching 可以轻松实现软阴影 (Soft Shadows)!
💡 类比理解:硬阴影 vs 软阴影
硬阴影(Hard Shadow):
- 光源:点光源(无限小,如激光笔)
- 特点:阴影边界清晰,非黑即白
- 现实例子:强烈的阳光、手电筒
- 判定:光线要么完全被遮挡(全黑),要么完全未遮挡(全亮)
软阴影(Soft Shadow):
- 光源:面光源(有体积,如窗户、灯管)
- 特点:阴影边界模糊,有渐变的半影区域
- 现实例子:阴天的光线、室内柔和的灯光
- 半影(Penumbra):阴影边缘的灰色过渡区域
Ray Marching 的优势:
- 可以模拟"光线接近遮挡物但未完全遮挡"的情况
- 通过"接近程度"计算半影强度
- 自然产生软阴影效果!
底层原理:
传统方法:从表面点向光源发射光线,判断是否被遮挡
- 被遮挡 → 完全黑暗(硬阴影)
- 未遮挡 → 完全明亮
Ray Marching 的软阴影方法:
- 发射光线的过程中,记录"最接近物体的距离"
- 如果光线接近但未命中物体 → 产生半影
- 距离越近 → 阴影越重
- 距离越远 → 阴影越轻
示例场景:
光源 ●
╲
╲ ← 光线
╲
●──●──● ← Ray Marching 步进点
╲ ↑
╲ │ 很接近遮挡物(产生半影)
╲ │
遮挡物 ▓▓▓
↓
表面点 ● ← 接收阴影的点
关键洞察:
- 步进点 2 很接近遮挡物,虽然没有命中,但"差点被遮挡"
- 这种"接近程度"决定了阴影的强度🔧 软阴影实现
// 🌑 软阴影计算
// 输入:
// ro (Ray Origin):光线起点(表面点,稍微偏离表面以避免自遮挡)
// rd (Ray Direction):光线方向(从表面点指向光源,归一化)
// mint:起始距离(通常 0.01,避免检测到自己)
// maxt:最大检测距离(通常设为光源距离)
// k:软阴影锐度参数(8.0=很软,32.0=中等,128.0=接近硬阴影)
// 返回:
// 阴影因子 [0.0, 1.0],0.0=完全黑暗,1.0=完全明亮
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
float res = 1.0; // 初始假设:完全无阴影(最亮状态)
float t = mint; // 从起始距离开始步进
// ========== Ray Marching 检测遮挡 ==========
for(int i = 0; i < 50; i++) {
// 计算当前位置
vec3 p = ro + rd * t;
// 查询 SDF:当前点到最近物体的距离
float h = sceneSDF(p);
// ===== 情况 A:完全遮挡(硬阴影)=====
if(h < 0.001) {
return 0.0; // 光线被完全遮挡,返回全黑
}
// ===== 情况 B:接近物体(软阴影核心)=====
// 🌫️ 半影计算公式:res = min(res, k * h / t)
//
// 直觉解释:
// - h:当前点到物体的距离(越小 = 越接近遮挡物)
// - t:光线已行进的距离(越大 = 越远离起点)
// - h / t:接近度比例(越小 = 越危险,接近被遮挡)
// - k * h / t:缩放后的阴影强度
//
// 为什么除以 t?
// - 在光线起点附近,即使 h 很小,也不应该产生很重的阴影
// - 在远离起点时,相同的 h 值应该产生更重的阴影
// - t 越大,h/t 越小,阴影越重(符合透视原理)
//
// k 的作用:
// - k 越小(如 8.0):阴影越柔和,半影区域越大
// - k 越大(如 128.0):阴影越锐利,接近硬阴影
res = min(res, k * h / t);
// ↑ ↑ ↑ ↑
// 保留 锐度 距离 累计
// 最暗值
// ===== 安全步进 =====
t += h; // 向前移动 h 单位
// ===== 超出检测范围 =====
if(t > maxt) break; // 已检测到光源位置,退出
}
// 返回最终阴影因子(在循环中可能被多次 min 更新)
return res;
// 💡 返回值含义:
// - 1.0:光线完全未遮挡,无阴影
// - 0.5:半影区域,50% 明亮
// - 0.0:完全遮挡,全黑
}
void main() {
// ... (之前的代码)
if(t > 0.0) {
// ========== 步骤1:基础光照 ==========
vec3 p = ro + t * rd;
vec3 N = getNormal(p);
vec3 L = normalize(u_lightPos - p);
// 💡 漫反射
float diff = max(dot(N, L), 0.0);
// ========== 步骤2:计算软阴影 ==========
// 从命中点向光源发射光线,检查是否被遮挡
float shadow = softShadow(
p + N * 0.01, // 起点:表面点 + 微小偏移(防止自遮挡)
L, // 方向:指向光源
0.01, // mint:最小距离
5.0, // maxt:最大检测距离
8.0 // k:软阴影锐度
);
// ========== 步骤3:应用阴影 ==========
diff *= shadow; // 漫反射强度 × 阴影因子
vec3 baseColor = vec3(0.5, 0.7, 1.0);
float ambient = 0.2;
color = baseColor * (ambient + diff * 0.8);
}
gl_FragColor = vec4(color, 1.0);
}💡 参数调优指南:
参数 推荐值 效果 k = 8.0 软阴影 边界模糊,真实感强 k = 32.0 中等 半软半硬 k = 128.0 硬阴影 边界清晰 mint = 0.01 小偏移 防止表面自己遮挡自己 maxt = 5.0 场景大小 与光源距离相关
⚠️ 注意事项:
- p + N * 0.01:起点必须稍微偏离表面,否则会检测到自己!
- 循环次数:50次通常够用,复杂场景可增加到100
- 性能消耗:每个像素会多执行一次Ray Marching,性能开销较大
效果:地面上会出现球体的软阴影,边界自然过渡!
4. 🔮 环境贴图 (Environment Mapping)
4.1 什么是环境贴图?
核心思想:用2D图像存储周围环境的光照信息
真实场景 (360°全景) → 映射到 2D 纹理 → 用于反射/折射/环境光💡 类比理解:天文馆的穹顶
场景设定:你进入一个天文馆,抬头看到巨大的穹顶上投影着星空
环境贴图的原理类似:
- 穹顶 = 环境贴图
- 你站在中心 = 摄像机/物体位置
- 抬头看任意方向 = 查询某个方向的环境颜色
- 穹顶上的图像 = 360° 全景照片
应用场景:
- 反射:金属球表面反射周围环境(就像照镜子)
- 折射:玻璃球内部看到的扭曲环境
- 环境光照:物体被周围环境照亮(IBL)
- 背景天空盒:渲染远景天空
为什么需要环境贴图?
传统方法:场景中放置多个光源 → 计算每个光源的贡献
- 问题:真实世界的光照来自四面八方(天空、墙壁反射等)
- 解决:用环境贴图"存储"整个环境的光照信息,一次采样即可
两种主要格式:
球形贴图 (Spherical / Equirectangular):
- 像世界地图,把球面展开成矩形
- 一张图片存储完整 360° 信息
- 优点:简单,易于制作
- 缺点:极点失真严重(北极/南极被拉伸)
立方体贴图 (Cubemap):
- 像骰子,用 6 个面包裹场景
- 6 张正方形图片拼成立方体
- 优点:失真小,GPU 硬件优化
- 缺点:文件数量多,制作稍复杂
4.2 球形环境贴图
4.2.1 原理:从3D方向到2D坐标
目标:将 3D 方向向量 (x, y, z) 映射到 2D 纹理坐标 (u, v)
完整流程:
3D 笛卡尔坐标 (x, y, z) → 球坐标 (θ, φ) → UV 坐标 (u, v)步骤 1:计算球坐标(Spherical Coordinates)
θ (theta) = atan(z, x) // 方位角(水平角):范围 [-π, π]
φ (phi) = asin(y) // 仰角(垂直角):范围 [-π/2, π/2]θ (方位角):在 XZ 平面上的旋转角度
- 正 X 轴方向:θ = 0
- 正 Z 轴方向:θ = π/2
- 负 X 轴方向:θ = π 或 -π
- 负 Z 轴方向:θ = -π/2
φ (仰角):离 XZ 平面的高度角
- 正 Y 轴(天顶):φ = π/2
- XZ 平面(地平线):φ = 0
- 负 Y 轴(天底):φ = -π/2
步骤 2:归一化到 [0, 1] 范围
u = (θ + π) / (2π) // 水平方向:[-π, π] → [0, 1]
v = (φ + π/2) / π // 垂直方向:[-π/2, π/2] → [0, 1]💡 类比理解:地球仪 → 世界地图
场景设定:你要把一个地球仪的表面"展开"成平面地图
球坐标 = 经纬度:
θ (theta) = 经度:
- 0° = 本初子午线(英国格林威治)
- 正值 = 东经(亚洲)
- 负值 = 西经(美洲)
- 范围:-180° ~ +180°(-π ~ π 弧度)
φ (phi) = 纬度:
- 0° = 赤道
- 正值 = 北纬
- 负值 = 南纬
- 范围:-90° ~ +90°(-π/2 ~ π/2 弧度)
映射规则:
- 北极点
(0, 1, 0)→ φ = π/2 → v = 1.0(图片顶部)- 南极点
(0, -1, 0)→ φ = -π/2 → v = 0.0(图片底部)- 赤道任意点 → φ = 0 → v = 0.5(图片中间)
失真问题:
- 极点附近严重拉伸(就像世界地图的格陵兰岛被拉得很大)
- 赤道附近失真最小
可视化:
北极 (0, 1, 0)
↑
│
──────●────── 赤道面
╱│╲
╱ │ ╲
↙ ↓ ↘
南极 (0, -1, 0)
展开成矩形(Equirectangular):
┌─────────────────┐ ← 北极 (v=1.0)
│ 全景图像 │
│ │ ← 赤道 (v=0.5)
│ │
└─────────────────┘ ← 南极 (v=0.0)
↑ ↑
u=0.0 (-π) u=1.0 (+π)4.2.2 GLSL实现
uniform sampler2D u_envMap; // 球形环境贴图(Equirectangular格式)
const float PI = 3.14159265359;
// 🌐 将3D方向转换为球形UV坐标
vec2 directionToSphericalUV(vec3 dir) {
// 输入:归一化的3D方向向量
// 输出:[0, 1] 范围的UV坐标
// ========== 步骤1:计算球坐标 ==========
float theta = atan(dir.z, dir.x); // 水平角(方位角):[-π, π]
// atan(y, x) 返回点(x,y)的角度
float phi = asin(dir.y); // 垂直角(仰角):[-π/2, π/2]
// asin(y) 返回 y 对应的角度
// ========== 步骤2:归一化到[0, 1] ==========
vec2 uv;
uv.x = (theta + PI) / (2.0 * PI); // [-π, π] → [0, 1]
uv.y = (phi + PI * 0.5) / PI; // [-π/2, π/2] → [0, 1]
return uv;
}
// 🎨 采样环境贴图
vec3 sampleEnvironment(vec3 direction) {
// 确保方向归一化
vec2 uv = directionToSphericalUV(normalize(direction));
// 从环境贴图采样颜色
return texture2D(u_envMap, uv).rgb;
}
void main() {
// ========== 步骤1:设置光线 ==========
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 ro = vec3(0.0, 1.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.0));
// ========== 步骤2:Ray Marching ==========
float t = rayMarch(ro, rd);
vec3 color;
if(t > 0.0) {
// 命中物体(后续会添加反射效果)
color = vec3(0.5);
} else {
// 🌌 背景 = 从环境贴图采样
// 光线方向rd就是"看向"的方向
color = sampleEnvironment(rd);
}
gl_FragColor = vec4(color, 1.0);
}💡 代码关键点:
- atan(z, x):返回
(x, z)点在XZ平面的角度,用于水平旋转- asin(y):返回
y对应的仰角,用于垂直方向- 背景采样:未命中物体时,直接用光线方向采样环境贴图
效果:背景会显示全景图像,像站在一个巨大的球体内部!
4.3 立方体贴图 (Cubemap)
4.3.1 原理:六面包裹
用6个面的正方形纹理组成一个立方体:
┌───┐
│ Y+│ (顶部/上)
┌───┼───┼───┬───┐
│X- │Z+ │X+ │Z- │ (左/前/右/后)
└───┼───┼───┴───┘
│ Y-│ (底部/下)
└───┘
采样时:根据方向向量,GPU自动选择对应的面💡 类比理解:
- 想象你站在一个盒子中心,六个面都贴着图片
- 球形贴图像"地球仪展开",立方体贴图像"盒子六面"
- 优势:边缘失真更小,GPU硬件加速更好
方向选择规则:
if |x| > |y| && |x| > |z|:
选择 X+ 或 X- 面
else if |y| > |z|:
选择 Y+ 或 Y- 面
else:
选择 Z+ 或 Z- 面4.3.2 Three.js中使用 Cubemap
// ========== JavaScript 加载 Cubemap ==========
const loader = new THREE.CubeTextureLoader();
// 加载6张图片(顺序固定!)
const envMap = loader.load([
'px.jpg', // +X (右侧)
'nx.jpg', // -X (左侧)
'py.jpg', // +Y (顶部)
'ny.jpg', // -Y (底部)
'pz.jpg', // +Z (前方)
'nz.jpg' // -Z (后方)
]);
// ========== 创建Shader材质 ==========
const material = new THREE.ShaderMaterial({
uniforms: {
u_envMap: { value: envMap } // 传递给Shader
},
fragmentShader: shaderCode
});💡 图片命名规范:
px= Positive X(右)nx= Negative X(左)py= Positive Y(上)ny= Negative Y(下)pz= Positive Z(前)nz= Negative Z(后)
GLSL中采样:
uniform samplerCube u_envMap; // Cubemap类型(不是sampler2D!)
void main() {
vec3 direction = ...; // 采样方向(3D向量)
// 📦 直接用3D方向采样(GPU自动选择对应的面)
vec3 envColor = textureCube(u_envMap, direction).rgb;
// ↑ ↑
// Cubemap采样函数 3D方向向量
gl_FragColor = vec4(envColor, 1.0);
}💡 Cubemap vs 球形贴图对比:
特性 球形贴图 立方体贴图 文件格式 1张图片 6张图片 采样方式 texture2D(map, uv)textureCube(map, dir)失真程度 极点严重失真 边缘少量失真 GPU优化 一般 硬件加速 使用场景 简单背景 高质量反射/IBL
优势:
- ✅ 更少的失真(相比球形贴图)
- ✅ GPU硬件优化(专门的Cubemap采样单元)
- ✅ 边缘过滤更好(三线性插值)
- ✅ 更适合实时渲染
🪞 反射 (Reflection)
📐 原理:镜面反射的数学基础
物理现象:光线击中表面后弹开,就像球撞墙反弹
关键要素:
- 入射光线 I:从光源射向表面的方向
- 法线 N:表面的垂直方向(单位向量)
- 反射光线 R:反弹后的方向
反射定律:
- 入射角 = 反射角
- 入射光、反射光、法线在同一平面
可视化:
N (法线,垂直表面)
↑
│
I ╱ ╲ R (反射光线)
╱ ╲
╱ ╲
╱ ↘
━━━━━━━━━━━━━━ 表面
入射角 = 反射角在 Shader 中的特殊情况:
- 我们通常使用视线方向 V(从表面指向摄像机)
- 要计算反射光线 R,需要取
-V(入射方向)
N (法线)
↑
│
V ╱ ╲ R (反射)
╱ ╲
╱ ↘
摄像机 表面
V: 从表面指向摄像机(我们看向表面的方向)
R: 反射方向(表面"看向"环境的方向)数学公式:
R = reflect(I, N)
= I - 2 * dot(I, N) * N公式推导直觉:
dot(I, N):入射光线在法线方向的投影2 * dot(I, N) * N:沿法线方向的"反弹量"(双倍,因为要从入射翻转到反射)I - ...:原方向减去反弹量,得到反射方向
示例:
假设:
I = (1, -1, 0) // 向右下方入射
N = (0, 1, 0) // 法线向上
计算:
dot(I, N) = 1*0 + (-1)*1 + 0*0 = -1
2 * dot(I, N) * N = 2 * (-1) * (0, 1, 0) = (0, -2, 0)
R = I - (0, -2, 0) = (1, -1, 0) - (0, -2, 0) = (1, 1, 0) // 向右上方反射
结果:入射角 = 45°,反射角 = 45°,符合反射定律!🔧 镜面反射实现
uniform samplerCube u_envMap;
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 ro = vec3(0.0, 1.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.0));
float t = rayMarch(ro, rd);
vec3 color;
if(t > 0.0) {
vec3 p = ro + t * rd;
vec3 N = getNormal(p);
// 🪞 计算反射方向
vec3 V = -rd; // 视线方向(指向摄像机)
vec3 R = reflect(-V, N); // 反射方向
// 🎨 采样环境贴图
color = textureCube(u_envMap, R).rgb;
// 💡 可选:添加菲涅尔效果
float fresnel = pow(1.0 - max(dot(V, N), 0.0), 5.0);
vec3 baseColor = vec3(0.2, 0.3, 0.5);
color = mix(baseColor, color, fresnel);
} else {
color = textureCube(u_envMap, rd).rgb;
}
gl_FragColor = vec4(color, 1.0);
}💎 折射 (Refraction)
📐 斯涅尔定律 (Snell's Law):光线穿越介质的规律
物理现象:光线从一种介质进入另一种介质时,传播方向发生改变
💡 日常例子:
- 筷子插入水中,看起来"折断"了
- 游泳池的底部看起来比实际浅
- 透过玻璃杯看东西会变形
核心公式:
n₁ × sin(θ₁) = n₂ × sin(θ₂)
其中:
- n₁:入射介质的折射率(光在该介质中的传播速度相关)
- n₂:折射介质的折射率
- θ₁:入射角(入射光与法线的夹角)
- θ₂:折射角(折射光与法线的夹角)常见材质的折射率:
- 真空:n = 1.0(光速最快)
- 空气:n ≈ 1.000293(近似为 1.0)
- 水:n ≈ 1.33
- 玻璃:n ≈ 1.5(普通玻璃)
- 钻石:n ≈ 2.42(非常高,产生强烈折射)
- 蓝宝石:n ≈ 1.77
可视化:
空气 (n₁ = 1.0,光速快)
──────────────────────── 界面(如玻璃表面)
玻璃 (n₂ = 1.5,光速慢)
↓ 入射光 (I)
│ θ₁ = 45°
────●──── 法线 N
│╲ θ₂ ≈ 28°(变小!)
│ ╲
↓ ↘ 折射光 (T)
关键现象:
- n₂ > n₁(进入更"密"的介质)→ 光线"弯向"法线(θ₂ < θ₁)
- n₂ < n₁(进入更"稀"的介质)→ 光线"远离"法线(θ₂ > θ₁)物理直觉:
💡 类比理解:汽车从高速公路驶入泥地
场景设定:
- 高速公路 = 空气(n = 1.0,光速快)
- 泥地 = 玻璃(n = 1.5,光速慢)
- 汽车斜着进入泥地
现象:
- 右侧轮子先进入泥地,速度变慢
- 左侧轮子还在高速公路上,速度较快
- 导致汽车"转向"(向右偏)
对应到光线:
- 光波的"波前"斜着进入玻璃
- 一侧先减速,另一侧还在高速
- 导致光线方向改变(折射)
特殊情况:
垂直入射(θ₁ = 0°):
- sin(0°) = 0 → sin(θ₂) = 0 → θ₂ = 0°
- 光线不改变方向,直穿而过
全反射(Total Internal Reflection):
- 当 n₁ > n₂(从密介质到稀介质,如玻璃→空气)
- 且入射角超过临界角时,光线无法折射,全部反射
- 临界角:sin(θ_c) = n₂ / n₁
- 例子:光纤、钻石的闪光效果
🔧 实现折射
uniform samplerCube u_envMap;
uniform float u_ior; // Index of Refraction (折射率)
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 ro = vec3(0.0, 1.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.0));
float t = rayMarch(ro, rd);
vec3 color;
if(t > 0.0) {
vec3 p = ro + t * rd;
vec3 N = getNormal(p);
vec3 V = -rd;
// 💎 计算折射方向
float eta = 1.0 / u_ior; // 空气→玻璃,使用倒数
vec3 T = refract(-V, N, eta); // GLSL内置函数
// 🎨 采样环境贴图
color = textureCube(u_envMap, T).rgb;
// ✨ 组合反射和折射 (Fresnel混合)
vec3 R = reflect(-V, N);
vec3 reflectColor = textureCube(u_envMap, R).rgb;
vec3 refractColor = textureCube(u_envMap, T).rgb;
float fresnel = pow(1.0 - max(dot(V, N), 0.0), 5.0);
color = mix(refractColor, reflectColor, fresnel);
} else {
color = textureCube(u_envMap, rd).rgb;
}
gl_FragColor = vec4(color, 1.0);
}🌈 色散 (Chromatic Dispersion):棱镜彩虹效果
物理现象:白光通过棱镜分解成彩虹色
💡 为什么会色散?
核心原因:不同波长的光在同一材质中的折射率不同
- 红光(波长长,约 700 nm):折射率最小,偏折最少
- 绿光(波长中,约 550 nm):折射率中等
- 蓝光(波长短,约 450 nm):折射率最大,偏折最多
日常例子:
- 三棱镜分解阳光成七色彩虹
- 钻石边缘的"火彩"(彩色闪光)
- CD 光盘表面的彩虹色
- 水滴形成的彩虹
数学模型:
对于大多数材质,折射率 n 与波长 λ 的关系(科西方程简化):
n(λ) = A + B/λ²
波长越短(蓝光)→ n 越大 → 折射角越大
波长越长(红光)→ n 越小 → 折射角越小示例值(对于玻璃):
- 红光(λ = 700 nm):n ≈ 1.50
- 绿光(λ = 550 nm):n ≈ 1.52
- 蓝光(λ = 450 nm):n ≈ 1.54
差异虽小(仅 0.04),但足以产生可见的色散效果!
GLSL 实现:
// 🌈 模拟棱镜色散效果
void main() {
// ... (之前的代码:Ray Marching 等)
if(t > 0.0) {
vec3 p = ro + t * rd;
vec3 N = getNormal(p);
vec3 V = -rd; // 视线方向(从表面指向摄像机)
// ========== 关键技巧:RGB 三个通道用不同折射率 ==========
//
// 💡 为什么是倒数?
// - GLSL 的 refract 函数参数是 η = n₁ / n₂
// - 从空气(n₁=1.0)进入玻璃(n₂)
// - 所以 η = 1.0 / n₂
float etaR = 1.0 / 1.50; // 红光通道(折射率 1.50)
float etaG = 1.0 / 1.52; // 绿光通道(折射率 1.52)
float etaB = 1.0 / 1.54; // 蓝光通道(折射率 1.54)
// ========== 分别计算三个颜色通道的折射方向 ==========
vec3 TR = refract(-V, N, etaR); // 红光折射方向(偏折最少)
vec3 TG = refract(-V, N, etaG); // 绿光折射方向(中等偏折)
vec3 TB = refract(-V, N, etaB); // 蓝光折射方向(偏折最多)
// ========== 从环境贴图分别采样三个方向 ==========
// 关键:每个颜色通道看向不同的方向,产生分离效果
float r = textureCube(u_envMap, TR).r; // 只取红色通道
float g = textureCube(u_envMap, TG).g; // 只取绿色通道
float b = textureCube(u_envMap, TB).b; // 只取蓝色通道
// ========== 重新组合成最终颜色 ==========
color = vec3(r, g, b);
// 结果:RGB 三个通道来自不同方向,产生色散效果
}
gl_FragColor = vec4(color, 1.0);
}💡 效果说明:
玻璃球边缘会出现彩虹色条纹:
- 正面看(入射角小):色散不明显
- 侧面看(入射角大):色散明显,出现彩色条纹
- 类似真实的钻石"火彩"效果
调节色散强度:
增强色散:拉大折射率差异
glslfloat etaR = 1.0 / 1.48; float etaG = 1.0 / 1.52; float etaB = 1.0 / 1.56; // 差异更大减弱色散:缩小折射率差异
glslfloat etaR = 1.0 / 1.50; float etaG = 1.0 / 1.50; float etaB = 1.0 / 1.51; // 差异很小
🌅 IBL (Image-Based Lighting) - 基于图像的光照
🎯 原理:让整个环境成为光源
传统光照 vs IBL 对比:
| 传统光照 | IBL (基于图像的光照) |
|---|---|
| 使用点光源/方向光源 | 整个环境贴图都是光源 |
| 光源数量有限(3-10个) | 无数个方向的光照(360°) |
| 环境光用常量(如0.2) | 环境光来自实际环境图像 |
| 反射用简单技巧 | 反射用真实环境信息 |
💡 类比理解:博物馆的照明
传统光照:
- 房间里放几盏聚光灯(点光源)
- 光照来自固定方向
- 阴影部分很暗
IBL:
- 房间四周都是发光的墙壁(环境贴图)
- 光照来自四面八方
- 即使阴影区域也有柔和的光照(间接光)
- 更真实、更柔和
IBL 的核心思想:
不再用几个点光源,而是把环境贴图的每一个像素都当作一个小光源!
传统光照:
光源1 ● 光源2 ● 光源3 ●
↓ ↓ ↓
物体 ⬤
IBL:
╭─────────────╮
│ 环境贴图 │ ← 每个像素都发光
│ ●●●●●●●●●● │
╰──────⬤──────╯
物体实现步骤:
漫反射 IBL (Diffuse IBL):
- 问题:物体表面接收来自四面八方的光照
- 方法:对环境贴图进行卷积(Convolution),预计算"辐照度贴图"
- 结果:存储每个方向的平均光照强度
镜面反射 IBL (Specular IBL):
- 问题:粗糙表面反射模糊,光滑表面反射清晰
- 方法:基于粗糙度预过滤环境贴图(生成 Mipmap 链)
- 结果:粗糙度越大,采样越模糊的环境贴图
为什么需要预计算?
直接计算 IBL 需要对环境贴图的所有像素进行采样和积分,性能消耗巨大!
// 理论上的漫反射 IBL(伪代码)
vec3 irradiance = vec3(0.0);
for(每个环境贴图像素 i) {
vec3 lightDir = 像素i的方向;
vec3 lightColor = 像素i的颜色;
float cosTheta = max(dot(N, lightDir), 0.0);
irradiance += lightColor * cosTheta;
}
irradiance /= 像素总数;
// 问题:如果环境贴图是 2048×1024,就要采样 2,097,152 次!解决方案:预先计算好,存储到"辐照度贴图"中,运行时直接采样!
🔧 简化漫反射IBL
uniform samplerCube u_envMap;
uniform samplerCube u_irradianceMap; // 预计算的辐照度贴图
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 ro = vec3(0.0, 1.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.0));
float t = rayMarch(ro, rd);
vec3 color;
if(t > 0.0) {
vec3 p = ro + t * rd;
vec3 N = getNormal(p);
vec3 V = -rd;
// 🌍 漫反射IBL (用法线方向采样)
vec3 irradiance = textureCube(u_irradianceMap, N).rgb;
vec3 diffuse = irradiance * vec3(0.5, 0.7, 1.0); // 基础颜色
// ✨ 镜面反射IBL (用反射方向采样)
vec3 R = reflect(-V, N);
vec3 specular = textureCube(u_envMap, R).rgb;
// 🎨 Fresnel混合
float fresnel = pow(1.0 - max(dot(V, N), 0.0), 5.0);
color = mix(diffuse, specular, fresnel);
} else {
color = textureCube(u_envMap, rd).rgb;
}
gl_FragColor = vec4(color, 1.0);
}🎨 综合实战: 玻璃球 Ray Marching
完整场景
💡 目标:综合应用所有学到的技术
- Ray Marching 渲染球体和地面
- 环境贴图作为背景
- 玻璃材质(反射 + 折射 + Fresnel)
- 地面漫反射光照
- 色调映射和 Gamma 校正
#ifdef GL_ES
precision highp float; // 使用高精度(Ray Marching 需要精确计算)
#endif
// ========== Uniforms ==========
uniform vec2 u_resolution; // 屏幕分辨率
uniform float u_time; // 时间(用于摄像机旋转动画)
uniform samplerCube u_envMap; // 环境贴图(立方体贴图)
// ========== 常量配置 ==========
const int MAX_STEPS = 100; // Ray Marching 最大步进次数
const float MAX_DIST = 100.0; // 最大渲染距离
const float EPSILON = 0.001; // 命中判定阈值
const float PI = 3.14159265359; // 圆周率(用于角度计算)
// ============ SDF 函数 ============
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
// ============ 场景组合 ============
float sceneSDF(vec3 p) {
// 🔮 玻璃球 (ID=1)
float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
// 📦 地面 (ID=2)
float ground = p.y;
return min(sphere, ground);
}
// 获取材质ID
float getMaterialID(vec3 p) {
float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
float ground = p.y;
return (sphere < ground) ? 1.0 : 2.0; // 1=玻璃, 2=地面
}
// ============ Ray Marching ============
float rayMarch(vec3 ro, vec3 rd) {
float t = 0.0;
for(int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + t * rd;
float dist = sceneSDF(p);
if(dist < EPSILON) return t;
t += dist;
if(t > MAX_DIST) break;
}
return -1.0;
}
// ============ 法线计算 ============
vec3 getNormal(vec3 p) {
vec2 h = vec2(0.001, 0.0);
return normalize(vec3(
sceneSDF(p + h.xyy) - sceneSDF(p - h.xyy),
sceneSDF(p + h.yxy) - sceneSDF(p - h.yxy),
sceneSDF(p + h.yyx) - sceneSDF(p - h.yyx)
));
}
// ============ 主渲染 ============
void main() {
// ========== 步骤 1:计算屏幕 UV 坐标 ==========
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
// ========== 步骤 2:设置旋转摄像机 ==========
// 📹 摄像机绕场景中心旋转(实现环绕动画)
float angle = u_time * 0.2; // 旋转角度随时间变化(0.2 = 旋转速度)
vec3 ro = vec3(sin(angle) * 4.0, 2.0, cos(angle) * 4.0);
// 摄像机位置:
// - x = sin(angle) * 4.0:水平圆周运动
// - y = 2.0:固定高度
// - z = cos(angle) * 4.0:水平圆周运动
// - 半径 = 4.0,摄像机围绕原点转圈
vec3 target = vec3(0.0, 1.0, 0.0); // 看向球心位置
// ========== 构建摄像机坐标系 ==========
// 💡 为什么需要构建坐标系?
// - 我们需要知道摄像机的"前、右、上"三个方向
// - 才能根据屏幕 UV 计算每个像素的光线方向
vec3 forward = normalize(target - ro); // 前向:指向目标
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
// 右向:世界上方向 × 前向 = 右向(右手坐标系)
vec3 up = cross(forward, right); // 上向:前向 × 右向 = 上向
// ========== 计算光线方向 ==========
vec3 rd = normalize(uv.x * right + uv.y * up + 1.5 * forward);
// 组合三个方向:
// - uv.x * right:水平偏移
// - uv.y * up:垂直偏移
// - 1.5 * forward:焦距(控制视野角度)
// ========== 步骤 3:执行 Ray Marching ==========
float t = rayMarch(ro, rd);
vec3 color;
if(t > 0.0) {
// ===== 命中物体:计算着色 =====
vec3 p = ro + t * rd; // 命中点的世界坐标
vec3 N = getNormal(p); // 表面法线
vec3 V = -rd; // 视线方向(从表面指向摄像机)
float matID = getMaterialID(p); // 材质 ID(1=玻璃,2=地面)
if(matID < 1.5) {
// ========== 材质 1:玻璃 ==========
// 计算反射方向
vec3 R = reflect(-V, N); // -V 是入射方向
// 计算折射方向(空气 n=1.0 → 玻璃 n=1.5)
vec3 T = refract(-V, N, 1.0 / 1.5);
// 从环境贴图采样反射和折射颜色
vec3 reflectColor = textureCube(u_envMap, R).rgb; // 反射看到的环境
vec3 refractColor = textureCube(u_envMap, T).rgb; // 折射看到的环境
// ========== Fresnel 效果 ==========
// 观察角度决定反射/折射的比例
// - 正面看(V·N 接近 1):主要是折射(透明)
// - 侧面看(V·N 接近 0):主要是反射(像镜子)
float fresnel = pow(1.0 - max(dot(V, N), 0.0), 5.0);
fresnel = fresnel * 0.9 + 0.1; // 映射到 [0.1, 1.0],确保最少 10% 反射
// 混合反射和折射
color = mix(refractColor, reflectColor, fresnel);
// fresnel = 0.1 → 90% 折射 + 10% 反射
// fresnel = 1.0 → 0% 折射 + 100% 反射
} else {
// ========== 材质 2:地面(简单漫反射)==========
// 固定方向光源
vec3 L = normalize(vec3(1.0, 1.0, -0.5)); // 从右上方照射
// Lambert 漫反射
float diff = max(dot(N, L), 0.0);
// 地面基础颜色(灰蓝色)
vec3 baseColor = vec3(0.3, 0.3, 0.4);
// 最终颜色 = 基础色 × (环境光 + 漫反射)
color = baseColor * (0.3 + diff * 0.7);
}
} else {
// ===== 未命中物体:显示背景 =====
// 🌌 直接用光线方向采样环境贴图(天空盒)
color = textureCube(u_envMap, rd).rgb;
}
// ========== 后期处理 ==========
// 📸 色调映射(Tone Mapping)
// Reinhard 色调映射:压缩 HDR 到 LDR
// 避免过曝(颜色值 > 1.0)
color = color / (color + vec3(1.0));
// 🎨 Gamma 校正
// 显示器是非线性的,需要校正到 Gamma 2.2
// pow(color, 1/2.2) = 从线性空间转到 sRGB 空间
color = pow(color, vec3(1.0 / 2.2));
gl_FragColor = vec4(color, 1.0);
}🎓 系列总结
🎉 恭喜! 你已经完成了整个 Shader 进阶系列!
✅ 完整知识体系回顾
第一篇: Shader基础
- GPU并行计算原理
- uniform / attribute / varying 数据流
- Swizzling语法糖
- 绘制三角形的多种方法
第二篇: 随机与噪声
- 伪随机函数
- Value Noise, Perlin Noise, Simplex Noise
- Worley Noise (细胞噪声)
第三篇: 分形与程序化
- 分形布朗运动 (fBm)
- 域扭曲技术
- 曼德布洛特集、谢尔宾斯基三角形
第四篇: 纹理与图像处理
- UV坐标系统
- 卷积核原理
- 各种滤镜 (模糊、锐化、边缘检测)
第五篇: 高级模拟技术
- 乒乓缓冲
- Conway生命游戏
- 水波模拟、反应扩散
第六篇: 光照与材质
- Phong / Blinn-Phong 光照模型
- 法线贴图、视差贴图
- PBR基础
第七篇 (本篇): 光线追踪与环境
- Ray Marching 算法
- 软阴影实现
- 环境贴图 (球形/立方体)
- 反射、折射、IBL
🚀 进阶方向建议
性能优化
- 实例化渲染
- LOD (Level of Detail)
- 遮挡剔除
高级PBR
- 完整的IBL管线
- 多重散射近似
- 各向异性材质
体积渲染
- 体积云
- 体积光 (God Rays)
- 烟雾模拟
后期处理
- Bloom (辉光)
- DOF (景深)
- Motion Blur
计算着色器
- GPU粒子系统
- 流体模拟
- 布料模拟
📚 进阶资源推荐
书籍:
- 《Real-Time Rendering》(实时渲染圣经)
- 《GPU Gems》系列
- 《Physically Based Rendering》(PBRT)
在线资源:
- Shadertoy - 世界顶级Shader作品
- Inigo Quilez的博客 - SDF大师的技术文章
- LearnOpenGL - 系统的OpenGL教程
- The Book of Shaders - 艺术化的Shader入门
社区:
🎯 最终挑战
尝试组合所有技术,创造一个完整场景:
项目: "梦幻水晶球"
- 使用 Ray Marching 渲染玻璃球
- 球内用 fBm 生成3D云雾
- 添加 Worley Noise 创造气泡
- 用 IBL 实现真实光照
- 反应扩散系统生成表面花纹
- 后期处理添加 Bloom 和色差
预期时间: 2-3天 难度: ⭐⭐⭐⭐⭐
💬 结语
Shader 编程是数学、物理和艺术的完美结合。你已经掌握了从基础到高级的完整工具链,剩下的就是无限的创造力!
记住:
- 🎨 实验至上: 最好的学习方式是动手尝试
- 📖 阅读源码: Shadertoy上有无数优秀作品可以学习
- 🤝 分享交流: 加入社区,与全球开发者交流
祝你在 Shader 的旅程中创造出惊艳世界的作品! ✨🚀
完