效果预览

一个由迭代函数系统(IFS)生成的三维分形结构,通过 Ray Marching 渲染出体积光 glow 效果。中心分形球体呈现紫蓝粉渐变的发光碎片结构,周围环绕深紫红色星空背景。相机围绕物体持续旋转,分形本身也在多轴上同时旋转,产生迷幻的动态视觉效果。
Shader 实现原理
1. 整体思路 --- Ray Marching + IFS 分形
传统多边形渲染需要显式定义几何体(顶点、面片),但分形具有无限细节 的特性,无法用最多数万个三角形表达。这里的解决方案是隐式曲面:
- 定义一个距离场函数
map(p),返回空间中任意点p到分形表面的距离 - 从相机位置沿每条视线射出 ray,通过距离场逐步前进(Sphere Tracing)
- 每步积累发光颜色,距离场值越小积累越强,形成体积光 glow
- 远离分形的区域用星空背景填充
这不是表面渲染,而是体积渲染光线穿过发光介质时的能量积累。
2. 调色板 --- 线性插值的色彩空间
glsl
vec3 palette(float d) {
return mix(vec3(0.2, 0.7, 0.9), vec3(1.0, 0.0, 1.0), d);
}
mix(a, b, t) 在 GLSL 中是线性插值 :a * (1-t) + b * t。
t = 0时输出vec3(0.2, 0.7, 0.9)------ 青色(高绿、高蓝)t = 1时输出vec3(1.0, 0.0, 1.0)------ 品红色(高红、高蓝)0 < t < 1时输出两者的线性过渡
这个调色板的选择刻意避开了完整的 RGB 色轮,只在青-品红 这条线上扫过,形成冷色调的科技感。参数 d = length(p) * 0.1 以到原点的距离为索引,越远越偏品红,越近越偏青。
3. 二维旋转矩阵
glsl
vec2 rotate(vec2 p, float a) {
float c = cos(a);
float s = sin(a);
return p * mat2(c, s, -s, c);
}
mat2(c, s, -s, c) 是标准的二维旋转矩阵。
数学上,逆时针旋转角度 a 的变换矩阵为:
scss
| cos(a) -sin(a) |
| sin(a) cos(a) |
GLSL 的 mat2 构造函数按列优先 存储:mat2(c, s, -s, c) 等价于:
r
| c -s |
| s c |
注意 p * mat2(...) 是行向量左乘矩阵 ,数学上等价于标准的列向量变换。rotate 函数被复用在多个地方:分形内部的自旋转、相机围绕物体的公转。
4. 分形距离场 --- Kaleidoscopic IFS
glsl
float map(vec3 p) {
for (int i = 0; i < 8; ++i) {
float t = iTime * 0.2;
p.xz = rotate(p.xz, t);
p.xy = rotate(p.xy, t * 1.89);
p.xz = abs(p.xz);
p.xz -= 0.5;
}
return dot(sign(p), p) / 5.0;
}
这是**Kaleidoscopic Iterated Function System(万花筒迭代函数系统)**的经典结构,每一轮迭代包含四个操作:
4.1 双轴旋转
glsl
p.xz = rotate(p.xz, t);
p.xy = rotate(p.xy, t * 1.89);
XZ 平面和 XY 平面分别绕不同角速度旋转。1.89 是一个无理数倍率 (接近 2 - 1/e),确保两轴旋转不会形成周期性锁定,产生准周期运动。
4.2 绝对值折叠 --- 对称性生成
glsl
p.xz = abs(p.xz);
abs() 将 XZ 平面的负半轴折叠到正半轴,这是镜像对称 操作。单次折叠产生 2 重对称,8 次迭代后形成 2^8 = 256 重对称碎片。
这个操作是分形自相似性的核心:每次折叠都把空间"掰"成更小的镜像副本,无限迭代下去会产生分形维度大于 2 的曲面。
4.3 平移 --- 缩放与位移
glsl
p.xz -= 0.5;
每次折叠后向内收缩 0.5 个单位。这个位移量直接控制分形的密度:
- 值越大 → 碎片越分散,内部更空旷
- 值越小 → 碎片越密集,趋向实心球体
0.5 是一个经验值,在视觉上形成"碎片环"结构 ------ 不是实心球,而是有镂空的发光环面。
4.4 距离场输出
glsl
return dot(sign(p), p) / 5.0;
sign(p) 提取 p 各分量的符号(-1 或 1),dot(sign(p), p) 等价于 |p.x| + |p.y| + |p.z| ------ 这是L1 范数(曼哈顿距离)。
除以 5.0 是缩放因子 ,把距离场的尺度压缩,使 ray marching 的步长更细密。如果没有这个缩放,分形会太大,相机在 z = -50 处只能看到局部。
5. Ray Marching --- 体积光积累
glsl
vec4 rm(vec3 ro, vec3 rd) {
float t = 0.0;
vec3 col = vec3(0.0);
float d;
for (float i = 0.0; i < 64.0; i++) {
vec3 p = ro + rd * t;
d = map(p) * 0.5;
if (d < 0.02) break;
if (d > 100.0) break;
col += palette(length(p) * 0.1) / (400.0 * d);
t += d;
}
return vec4(col, 1.0 / (d * 100.0));
}
5.1 Sphere Tracing 步进
t 是 ray 上已走过的距离,p = ro + rd * t 是当前采样点。d = map(p) * 0.5 是该点到分形表面的安全距离。
d < 0.02 时认为已经 hit 到表面,退出循环。0.02 是容差阈值:
- 太小 → 步数增加,可能穿透表面
- 太大 → 提前终止,丢失细节
d > 100.0 时认为 ray 已经远离分形,退出循环节省计算。
5.2 体积光积累 --- 反比发光模型
glsl
col += palette(length(p) * 0.1) / (400.0 * d);
这不是传统光照(光源 → 表面 → 相机),而是发光介质的体积吸收:
palette(length(p) * 0.1)------ 根据到原点的距离选取颜色1 / d------ 距离场越小(越接近分形表面),发光越强1 / 400.0------ 整体衰减系数,防止过曝
从物理角度,这近似于等离子体或星云的发射 :密度越高(距离场越小)的区域,光子发射越强。1/d 的衰减规律不是真实物理(真实物理是 1/d²),但在视觉上更讨喜,因为 1/d 的衰减更慢,能产生更柔和的光晕。
5.3 Alpha 通道的含义
glsl
return vec4(col, 1.0 / (d * 100.0));
Alpha 不是传统的不透明度,而是深度权重:
- hit 时
d ≈ 0.02,alpha ≈ 0.5 - 远离时
d > 100,alpha ≈ 0
这个值在 main 函数中用于区分前景(分形)和背景(星空)。
6. 星空背景 --- Hash 与闪烁
glsl
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
vec3 background(vec3 rd) {
vec3 bg = vec3(0.05, 0.015, 0.04);
float sky = max(rd.y, 0.0);
bg = mix(bg, vec3(0.08, 0.025, 0.07), sky * sky);
vec2 su = rd.xz / (abs(rd.y) + 0.5);
float s = hash(floor(su * 200.0));
float tw = sin(iTime * 2.0 + s * 50.0) * 0.5 + 0.5;
bg += vec3(0.55, 0.40, 0.50) * smoothstep(0.997, 1.0, s) * tw * 0.5;
return bg;
}
6.1 Hash 函数 --- 确定性伪随机
hash(vec2) 是 GPU 上最常用的廉价随机数生成器。核心操作:
dot(p, vec2(127.1, 311.7))------ 把二维坐标投影到一维sin(...)------ 利用正弦的混沌特性放大微小差异* 43758.5453------ 进一步打散周期fract(...)------ 取小数部分,归一化到[0, 1)
127.1 和 311.7 的选择原则是:与像素坐标的整数部分没有简单有理数关系 ,避免摩尔纹。43758.5453 同理。
6.2 球面坐标到平面的映射
glsl
vec2 su = rd.xz / (abs(rd.y) + 0.5);
rd 是单位方向向量(球面坐标)。直接把它当随机数输入会出问题:球面上均匀分布的方向在 xz 平面上的投影不均匀(两极密度高)。除以 abs(rd.y) + 0.5 是一种近似等面积投影,让星星在球面上分布更均匀。
6.3 闪烁 --- 相位偏移的正弦
glsl
float tw = sin(iTime * 2.0 + s * 50.0) * 0.5 + 0.5;
每颗星星的闪烁频率相同(iTime * 2.0),但相位不同 (s * 50.0)。这模拟了大气扰动造成的星光闪烁,每颗星的明暗周期相同但起始时刻随机。
6.4 阈值化 --- 从噪声到离散星星
glsl
smoothstep(0.997, 1.0, s)
hash 输出在 [0, 1) 均匀分布,smoothstep(0.997, 1.0, s) 把 > 0.997 的极少数值映射到 (0, 1],其余全部压为 0。这是一个硬阈值,把连续噪声变成稀疏的离散点 ------ 即星星。
概率上,每 200 * 200 = 40000 个 grid 点中约有 40000 * 0.003 = 120 颗可见星,密度适中。
7. 相机矩阵 --- Look-At 的简化版
glsl
vec3 ro = vec3(0.0, 0.0, -50.0);
ro.xz = rotate(ro.xz, iTime);
vec3 cf = normalize(-ro);
vec3 cs = normalize(cross(cf, vec3(0.0, 1.0, 0.0)));
vec3 cu = normalize(cross(cf, cs));
这里构建了相机坐标系的三个基向量:
ro(ray origin)--- 相机位置,初始在(0, 0, -50),绕 Y 轴旋转形成轨道运动cf(camera forward)--- 相机朝向,始终指向原点(-ro归一化)cs(camera side)--- 相机右方向,cross(forward, world_up)cu(camera up)--- 相机上方向,cross(forward, side)
这是Look-At 矩阵 的手动构建版本。cross 顺序很重要:cross(cf, vec3(0,1,0)) 产生右向量,cross(cf, cs) 产生修正后的上向量,确保三者正交。
glsl
vec3 uuv = ro + cf * 3.0 + uv.x * cs + uv.y * cu;
vec3 rd = normalize(uuv - ro);
cf * 3.0 把成像平面放在相机前方 3 个单位处。uv.x * cs + uv.y * cu 在成像平面上铺展像素。最终 rd 是从相机指向每个像素的方向向量。
8. 前景与背景合成
glsl
vec4 col = rm(ro, rd);
vec3 bg = background(rd);
float fg = length(col.rgb);
float bgMask = smoothstep(0.008, 0.0, fg);
col.rgb = mix(col.rgb, bg, bgMask);
合成策略基于亮度阈值:
fg = length(col.rgb)------ 分形输出的亮度smoothstep(0.008, 0.0, fg)------ 亮度低于0.008时完全显示背景,高于0.008时完全显示分形
0.008 这个阈值的选择依据:ray marching 在远离分形区域 64 步积累后的 rgb 长度约 0.005 ~ 0.01,刚好落在阈值边缘。这样分形球体的发光区域和星空背景有硬边过渡,不会互相渗透。
9. 性能分析
| 阶段 | 每像素开销 | 瓶颈 |
|---|---|---|
map() 距离场 |
8 次旋转 + 8 次折叠 + 8 次平移 | ALU(纯计算) |
| Ray Marching 循环 | 最多 64 次 map() 调用 |
分支发散(不同像素 hit/未 hit) |
| 体积光积累 | 1 次 palette() + 除法 + 加法 |
ALU |
| 背景星空 | 2 次 hash() + sin() |
ALU |
在现代桌面 GPU 上,这个 shader 在 1080p 下可以稳定 60fps。主要瓶颈是 map() 中的循环:每个像素最多执行 8 次 rotate(含 2 次 sin + 2 次 cos),64 步就是 512 次三角函数调用。GPU 的 sin/cos 是硬件指令但仍有明显延迟,这是限制分辨率的主要因素。
总结
这个特效展示了隐式分形 + 体积渲染的强大组合:
- 分形不是画出来的,是"折叠"出来的 ------ 8 轮旋转、镜像、平移,把空间拧成一个自相似的无穷结构
- 光照不是照出来的,是"积累"出来的 ------ 每步 ray marching 往颜色 buffer 里加一点点 glow,距离越近加得越猛
- 星空不是贴图,是"筛"出来的 ------ Hash 噪声经过阈值化,99.7% 的区域变黑,0.3% 的区域变成星星