分形金字塔的 Ray Marching 实现

效果预览

一个由迭代函数系统(IFS)生成的三维分形结构,通过 Ray Marching 渲染出体积光 glow 效果。中心分形球体呈现紫蓝粉渐变的发光碎片结构,周围环绕深紫红色星空背景。相机围绕物体持续旋转,分形本身也在多轴上同时旋转,产生迷幻的动态视觉效果。

👉 点击查看《分形金字塔的》完整源码与效果演示

Shader 实现原理

1. 整体思路 --- Ray Marching + IFS 分形

传统多边形渲染需要显式定义几何体(顶点、面片),但分形具有无限细节 的特性,无法用最多数万个三角形表达。这里的解决方案是隐式曲面

  1. 定义一个距离场函数 map(p),返回空间中任意点 p 到分形表面的距离
  2. 从相机位置沿每条视线射出 ray,通过距离场逐步前进(Sphere Tracing)
  3. 每步积累发光颜色,距离场值越小积累越强,形成体积光 glow
  4. 远离分形的区域用星空背景填充

这不是表面渲染,而是体积渲染光线穿过发光介质时的能量积累。

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 上最常用的廉价随机数生成器。核心操作:

  1. dot(p, vec2(127.1, 311.7)) ------ 把二维坐标投影到一维
  2. sin(...) ------ 利用正弦的混沌特性放大微小差异
  3. * 43758.5453 ------ 进一步打散周期
  4. fract(...) ------ 取小数部分,归一化到 [0, 1)

127.1311.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% 的区域变成星星
相关推荐
谢小飞9 小时前
Three.js三球轮播沉浸式落地页开发
前端·three.js
贵州数擎科技有限公司1 天前
雨滴特效的 Three.js 实现
前端·three.js
:mnong2 天前
PlayCanvas 开源 WebGL/WebGPU 3D 创作平台分析
3d·开源·webgl
Strayer6 天前
在地图上实现管网拓扑批量移动、旋转与缩放(参考图片的实现方式)
gis·webgl·数据可视化
Strayer6 天前
WebGL 地图上做精准编辑?这套分层方案搞定管网拖拽 / 连接
gis·webgl
郝学胜-神的一滴6 天前
中级OpenGL教程 005:为球体&平面注入法线灵魂
c++·unity·图形渲染·three.js·opengl·unreal
我胖虎不答应!!7 天前
Three.js开发思想笔记
javascript·笔记·three.js
山海鲸可视化7 天前
数字孪生项目案例 | 物流园区可视化
webgl·可视化·数据可视化·数据表格·搜索框
图扑软件8 天前
50ms 级实时数字孪生|汽车先进制造车间工艺流程
3d·数据采集·webgl·数字孪生·可视化·opc ua·汽车制造