Skip to content

🌅 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 米"

前进策略:

  1. 探测器显示"前方最近墙壁 3 米" → 你可以安全地向前走 3 米(不会撞到任何东西)
  2. 到达新位置,再次探测:"前方最近墙壁 1.5 米" → 再走 1.5 米
  3. 继续探测:"前方最近墙壁 0.001 米" → 你几乎贴在墙上了,停止!

关键洞察:SDF 就是这个"距离探测器",它保证每次前进的距离是"安全"的(不会穿过物体)

Ray Marching 就是让光线这样"摸索"前进!


为什么这个方法有效? ​

数学保证:SDF 返回的是"到最近表面的距离",所以:

如果 SDF(当前点) = 5.0
→ 意味着:以当前点为球心,半径 5.0 的球体内部,没有任何物体表面
→ 结论:沿光线前进 5.0 单位,绝对不会穿过物体

这就是"安全步进"(Sphere Tracing) 的数学基础!

2.2 算法步骤详解 ​

完整流程:

  1. 初始化光线

    • 起点 origin:摄像机位置
    • 方向 direction:从摄像机指向屏幕像素的归一化向量
    • 累计距离 t = 0.0
  2. 开始循环步进 (最多 MAX_STEPS 次,例如 100 次)

  3. 计算当前位置

    当前点 = origin + t × direction
  4. 查询 SDF:"当前点到最近表面有多远?"

    d = sceneSDF(当前点)
  5. 判断三种情况

    情况 A:命中表面 (d < EPSILON,例如 d < 0.001)

    • EPSILON 是"足够接近"的阈值
    • 返回 t(光线走过的总距离)
    • 跳出循环,开始着色

    情况 B:超出渲染范围 (t > MAX_DISTANCE,例如 t > 100.0)

    • 说明光线射向无限远,场景中没有物体
    • 返回 -1 或特殊值表示"未命中"
    • 跳出循环

    情况 C:继续前进

    • 更新累计距离:t += d (关键!这是"安全步进")
    • 回到步骤 3
  6. 着色:如果命中,用 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的完整流程。

glsl
#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的强大之处:法线可以通过梯度自动计算!

💡 类比理解:想象一座山的地形图

场景设定:你站在山坡上,手里有一个"高度计",可以测量任何位置的海拔高度

如何找到"上坡最陡的方向"?

  1. 向东走一小步,测量高度变化 → ΔH_x
  2. 向北走一小步,测量高度变化 → ΔH_y
  3. 向上走一小步,测量高度变化 → ΔH_z
  4. 把这三个变化率组合成向量 (Δ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实现 ​

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 毫米

⚠️ 性能注意:

  • 这个函数会调用 sceneSDF 6 次(每个轴正负各一次)
  • 是 Ray Marching 渲染中的主要性能瓶颈之一
  • 优化方法:
    1. 使用四面体法(Tetrahedron method):只需 4 次采样(精度稍低)
    2. 仅在需要光照的地方计算法线
    3. 简化 sceneSDF 函数的复杂度

3.2 添加光照 - Lambert漫反射 ​

💡 目标:让球体根据光源位置产生明暗变化,看起来更真实。

glsl
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 很接近遮挡物,虽然没有命中,但"差点被遮挡"
- 这种"接近程度"决定了阴影的强度

🔧 软阴影实现 ​

glsl
// 🌑 软阴影计算
// 输入:
//   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° 全景照片

应用场景:

  1. 反射:金属球表面反射周围环境(就像照镜子)
  2. 折射:玻璃球内部看到的扭曲环境
  3. 环境光照:物体被周围环境照亮(IBL)
  4. 背景天空盒:渲染远景天空

为什么需要环境贴图?

传统方法:场景中放置多个光源 → 计算每个光源的贡献

  • 问题:真实世界的光照来自四面八方(天空、墙壁反射等)
  • 解决:用环境贴图"存储"整个环境的光照信息,一次采样即可

两种主要格式:

  1. 球形贴图 (Spherical / Equirectangular):

    • 像世界地图,把球面展开成矩形
    • 一张图片存储完整 360° 信息
    • 优点:简单,易于制作
    • 缺点:极点失真严重(北极/南极被拉伸)
  2. 立方体贴图 (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实现 ​

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
// ========== 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中采样:

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:反弹后的方向

反射定律:

  1. 入射角 = 反射角
  2. 入射光、反射光、法线在同一平面

可视化:

      N (法线,垂直表面)
      ↑
      │
  I ╱   ╲ R (反射光线)
   ╱     ╲
  ╱       ╲
 ╱         ↘
━━━━━━━━━━━━━━  表面

入射角 = 反射角

在 Shader 中的特殊情况:

  • 我们通常使用视线方向 V(从表面指向摄像机)
  • 要计算反射光线 R,需要取 -V(入射方向)
      N (法线)
      ↑
      │
  V ╱   ╲ R (反射)
   ╱     ╲
  ╱       ↘
摄像机    表面

V: 从表面指向摄像机(我们看向表面的方向)
R: 反射方向(表面"看向"环境的方向)

数学公式:

glsl
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°,符合反射定律!

🔧 镜面反射实现 ​

glsl
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,光速慢)
  • 汽车斜着进入泥地

现象:

  • 右侧轮子先进入泥地,速度变慢
  • 左侧轮子还在高速公路上,速度较快
  • 导致汽车"转向"(向右偏)

对应到光线:

  • 光波的"波前"斜着进入玻璃
  • 一侧先减速,另一侧还在高速
  • 导致光线方向改变(折射)

特殊情况:

  1. 垂直入射(θ₁ = 0°):

    • sin(0°) = 0 → sin(θ₂) = 0 → θ₂ = 0°
    • 光线不改变方向,直穿而过
  2. 全反射(Total Internal Reflection):

    • 当 n₁ > n₂(从密介质到稀介质,如玻璃→空气)
    • 且入射角超过临界角时,光线无法折射,全部反射
    • 临界角:sin(θ_c) = n₂ / n₁
    • 例子:光纤、钻石的闪光效果

🔧 实现折射 ​

glsl
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 实现:

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);
}

💡 效果说明:

玻璃球边缘会出现彩虹色条纹:

  • 正面看(入射角小):色散不明显
  • 侧面看(入射角大):色散明显,出现彩色条纹
  • 类似真实的钻石"火彩"效果

调节色散强度:

  • 增强色散:拉大折射率差异

    glsl
    float etaR = 1.0 / 1.48;
    float etaG = 1.0 / 1.52;
    float etaB = 1.0 / 1.56;  // 差异更大
  • 减弱色散:缩小折射率差异

    glsl
    float 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:
   ╭─────────────╮
   │ 环境贴图     │  ← 每个像素都发光
   │ ●●●●●●●●●●  │
   ╰──────⬤──────╯
         物体

实现步骤:

  1. 漫反射 IBL (Diffuse IBL):

    • 问题:物体表面接收来自四面八方的光照
    • 方法:对环境贴图进行卷积(Convolution),预计算"辐照度贴图"
    • 结果:存储每个方向的平均光照强度
  2. 镜面反射 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 ​

glsl
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 校正
glsl
#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

🚀 进阶方向建议 ​

  1. 性能优化

    • 实例化渲染
    • LOD (Level of Detail)
    • 遮挡剔除
  2. 高级PBR

    • 完整的IBL管线
    • 多重散射近似
    • 各向异性材质
  3. 体积渲染

    • 体积云
    • 体积光 (God Rays)
    • 烟雾模拟
  4. 后期处理

    • Bloom (辉光)
    • DOF (景深)
    • Motion Blur
  5. 计算着色器

    • GPU粒子系统
    • 流体模拟
    • 布料模拟

📚 进阶资源推荐 ​

书籍:

  • 《Real-Time Rendering》(实时渲染圣经)
  • 《GPU Gems》系列
  • 《Physically Based Rendering》(PBRT)

在线资源:

社区:


🎯 最终挑战 ​

尝试组合所有技术,创造一个完整场景:

项目: "梦幻水晶球"

  1. 使用 Ray Marching 渲染玻璃球
  2. 球内用 fBm 生成3D云雾
  3. 添加 Worley Noise 创造气泡
  4. 用 IBL 实现真实光照
  5. 反应扩散系统生成表面花纹
  6. 后期处理添加 Bloom 和色差

预期时间: 2-3天 难度: ⭐⭐⭐⭐⭐


💬 结语 ​

Shader 编程是数学、物理和艺术的完美结合。你已经掌握了从基础到高级的完整工具链,剩下的就是无限的创造力!

记住:

  • 🎨 实验至上: 最好的学习方式是动手尝试
  • 📖 阅读源码: Shadertoy上有无数优秀作品可以学习
  • 🤝 分享交流: 加入社区,与全球开发者交流

祝你在 Shader 的旅程中创造出惊艳世界的作品! ✨🚀


完