先上效果。这些画面已经很难在工程中使用了。 画面所代表的意义需要自己寻找,在理解背后的技术之后,读者朋友也可以自己去探索出自己觉得有意思的画面。
第一个火焰的作者 Xor代码实在是 golfy
。我写了一个易读的版本, 可以访问 www.shadertoy.com/view/3XSXWV 查看。 在理解 Xor提出的高效湍流技术之后,我结合其他效果创作了其他 3 个视觉效果。
golfy 是一个shadertoy社区极客文化,他们以字母数量少为目标。比如上面的火焰只用了 340 个字母(没错,就是 340 个字母。)在不借助任何库的情况下就实现了上面的效果。 这个文化对新手不友好。 沉迷这种文化的人貌似大多都是个人 PC刚兴起的时候 20 岁,现在都 40,50 了。 也许是他们的浪漫吧
首先有三篇前置知识可以先了解
这里的实现有一些技巧是需要空间想象力,例如 raymarching技术。有一些不符合物理世界规律的,例如对光线进行扭曲。 作者本人水平实在有限,还在门口徘徊,尽力理解并讲解,如果读者对这个很有兴趣并还是不是很清楚。 欢迎加我微信(在公众号聊天可获取)讨论, 另外理解 Volumetric Rendering 与 IFS 对理解本篇中的技巧有帮助。 我在附录中也放了相关资料。
RayMarching光线步进
Raymarching 是一种在计算机图形学中用来渲染场景的技术,特别是在实时渲染和非实时渲染中的程序式图形和视觉特效的生成。与经典的光线投射(Raycasting)或光线追踪(Raytracing)相比,Raymarching 通过在场景中逐步“行进”来近似相交检测,并被广泛用于渲染体积材料(如烟雾、云)和复杂的几何形状(如分形)。
基本原理
Raymarching 的基本原理是:对于场景中的每一个像素,发出一条从相机位置出发并穿过像素的光线。然后,沿着这条光线,分步前进一定的距离,并检查光线上的点是否击中了场景中的任何对象。
这个“前进步数”可以固定或者动态调整。动态调整步数的一个常见方法是使用所谓的“有符号距离场”(Signed Distance Fields, SDFs)。这是一种可以快速确定点到场景中最近表面距离的技术,因此光线可以安全地多走这段距离,不会错过任何物体。
现实世界中我们的视线实际上去接受世界上物体反射给我们的光,我们的眼睛是一个接收器,视觉中对于距离和物体形体的判断需要依靠大脑中非常复杂的机制。 而在代码世界中,我们没有大脑那么强的能力,但是却又拥有了一些奇妙的其他能力。 代码的Ray可以做任何变换,例如加入一个sin函数就能让你的视线在空间中旋转, 而物体形体的判断是通过距离函数来确定。当你的视线到了某个物体的表面,视线距离物体表面的距离接近于0.
Camera
最核心的是Ray和Distance这两个抽象的概念。不过我们先用 3D常见的 camera来理解他。
在纯shader表示相机的方式与传统3D图形中的相机表示有所不同。相机通常是隐式定义的,最简单的camera就是cameraPosition(rayOrgin)和rayDirection两个变量
vec3 rayOrgin = vec3(0., 1., 0.); vec3 rayDirection = normalize(vec3(uv.x, uv.y, 1.));
这里可以思考一个有趣的问题,如果是按照传统的3D图形相机,通常有以下的结构
struct Camera { vec3 position; // 相机的位置 vec3 target; // 相机指向的目标点(在世界空间中) vec3 up; // 相机的上方向 float fov; // 相机的视野(Field of View) float aspectRatio; // 宽高比};
那么对应ray的配置落到camera的配置,应该值是什么呢?
position = (0, 1, 0)target = (0, 0, 1)aspectRatio = iResolution.x / iResolution.yup = (0, 1, 0)fov = 90
碰撞检测
确定射线后,接下去的问题是如何确定这些射线于3d object是否相交。以下图从P0点不断的往P4方向行进,每次探一探距离,如果P点于边缘点小于某个小值,则说明已经相交了。 下探的距离使用经验值,即上一个点离表面的最短距离。
#define MAX_STEPS 80;#define MAX_DIST 100;#define SURFACE_DIST 0.001;float RayMarch(vec3 ro, vec3 rd) { float distanceFromOrigin = 0.; for (int i=0; i<MAX_STEPS; i++){ vec3 p = ro + distanceFromOrigin*rd; float distanceFromSurface = GetDist(p); distanceFromOrigin += distanceFromSurface; if(distanceFromSurface<SURFACE_DIST || distanceFromOrigin>MAX_DIST) break; } return distanceFromOrigin;}
最简单的场景
创造一个世界, 只有有地平面和球两个物体,GetDist
函数应该是
#define MAX_STEPS 80#define MAX_DIST 100.#define SURFACE_DIST 0.001float GetDist(vec3 p) { vec4 sphere = vec4(0., 1., 6., 1.); float distanceSphere = length(p - sphere.xyz) - sphere.w; float distancePlane = p.y; return min(distanceSphere, distancePlane);}
最后讲RayMarching获取的值,变成颜色变获得以下的3D世界。 明显画面没有体积感。在传统素描技法中需要明暗对比,透视,纹理来表现体积感。接下去就是增加光照。
2407070450
基础光影
一个常用的简单光照模型是Phong光照模型,它由环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和高光(Specular Lighting)三个组成部分构成。
-
环境光照 (Ambient Lighting) 环境光照是一个全局光照,假设光线从每个方向均匀的照射到物体上,它提供了一个没有方向性的基础亮度,以模拟间接光的效果,避免完全处于黑暗中。
vec3 ambientColor = vec3(0.1, 0.1, 0.1); // 根据场景情况调整环境光亮度vec3 ambient = ambientColor * surfaceColor; // surfaceColor 是物体的颜色
-
漫反射光照 (Diffuse Lighting) 漫反射光照根据表面法线和光源方向的角度差来计算,角度差越小(也就是这两个方向越贴近)漫反射光照越强,这表现了光线如何照射到表面并向各个方向均匀散射。
vec3 lightDir = normalize(lightPos - fragPos); // 计算光线方向vec3 norm = normalize(normal); // 计算法线方向float diff = max(dot(norm, lightDir), 0.0); // 计算法线和光线的点乘,取最大值以避免负数vec3 diffuse = diff * lightColor * surfaceColor; // lightColor 是光源颜色
-
高光 (Specular Lighting) 高光表现的是光线照射到表面后,沿着一个反射方向产生的亮点效果。高光大小取决于观察方向和反射方向的接近程度,它通常在镜面反射方向附近最亮。
vec3 viewDir = normalize(viewPos - fragPos); // 观察方向vec3 reflectDir = reflect(-lightDir, norm); // 反射方向,传入的光线方向需要取反float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess); // shininess 是高光的反射程度vec3 specular = spec * lightColor;
法向量计算中更详细的数值优化方法查看IQ的文章: iquilezles.org/articles/no… , 计算normal的代码为
#define NORMAL_DELTA 0.01vec3 GetNormal(vec3 p) { vec2 e = vec2(NORMAL_DELTA, 0.); return normalize(vec3( GetDist(p+e. xyy) - GetDist(p-e.xyy), GetDist(p+e.yxy) - GetDist(p-e.yxy), GetDist(p+e.yyx) - GetDist(p-e.yyx) )); }
最后得到一个有体积感的空间物体啦
2505163028
不过我们将在这里停止,不在继续讨论光照模型。 因为本篇侧重技巧不需要物理模型的光照,而是用一种聪明的,伪装的,看起来像的方法。
并非有光才有色
边缘权重增强
我们在这里换一种着色的方式. 不再使用光照。 我们做一种特殊的权重方式。 我们在 raymarching的过程中,如果离球体表面越近,那么我们就增加一些颜色, 最后求平均。 核心代码如下所示
float ShaderSphere(vec3 ro, vec3 rd) { float rayDepth = 0.; float stepSize = 0.0; float colorWeight = 0.0; // 迭代 50 次 for (int i=0; i<50; i++){ vec3 p = ro + rayDepth*rd; float distanceFromSurface = GetDist(p); // 离表面越近,步子越小 stepSize = 0.01 + abs(distanceFromSurface)/7.; // 步子越小,颜色贡献越大 colorWeight += 1. / stepSize; rayDepth += stepSize; } return colorWeight;}
当射线与平面的法向量越接近,那么步子会走的更快。从而边缘能够获得更多的着色机会。下图为着色算法,当射线朝向球中心,和射线朝向球边缘的迭代图。 x轴为迭代次数,y轴为rayDepth。 可以看到在边缘处整体射线会停留更久,也就是会获得更多颜色。最后的变现就是边缘更亮
色盘
IQ在这篇文章中介绍了通过三角函数创造色盘的方法 iquilezles.org/articles/pa…
// cosine based palette, 4 vec3 paramsvec3 palette( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ){ return a + b*cos( 6.28318*(c*t+d) );}
为了获取自己喜欢的颜色,也可以到 dev.thi.ng/gradients/ 这个网站去调配颜色, 这里我随便需选了一些颜色
vec3 palette( in float t ){ vec3 a = vec3(0.902,0.914,0.086); vec3 b = vec3(0.529,0.494,0.808); vec3 c = vec3(1.000, 1.000, 1.000); vec3 d = vec3(0.000, 0.333, -2.362); return a + b*cos( 6.28318*(c*t+d) );}
当我们讲这个颜色放到权重函数中去,并且用时间做一下 offset. 看看效果会如何
vec3 gCol = palette(rayDepth - iTime ) * 1. ;color += gCol / stepSize;
2505165518
火焰原作者使用了简化版本,这里但背后的原理是一样,其方法为
vec3 gCol = sin(rayDepth / 3.0 + vec3(7.0, 2.0, 3.0) - iTime) + 1.1;color += gCol / stepSize;
最后效果有
三角函数 + FBM
Gyroid
**这里只做了解, 核心是要知道。 我们通过对坐标进行 三角函数操作, 可以得到一些一想不到的曲面... **。 如果想要详细了解关于Gyroid结构的艺术创作,一定要去看在文章尾部The art of code的讲解。 下图为 shadertoy另外一个大神FabriceNeyret2 创作的
Gyroid(双曲极小曲面) 是一种复杂的三维几何结构,属于数学中的 三重周期极小曲面(Triply Periodic Minimal Surfaces, TPMS)。它因其独特的拓扑结构和物理特性,在材料科学、生物学、3D打印、艺术设计等领域有广泛应用。其 ** 数学定义**
-
极小曲面:曲面的平均曲率为零,即在任意一点上,曲面的两个主曲率之和为零。这种特性使极小曲面具有最小能量状态(如肥皂膜的形状)。
-
三重周期性:Gyroid 在三个维度(x, y, z)上周期性重复,形成无限延展的蜂窝状结构。
-
无自交:曲面在三维空间中不会与自身相交,保持连续性和光滑性。
数学表达式(近似形式):(实际精确表达式更复杂,涉及超几何函数或隐式方程)
Gyroid 结构在自然界中广泛存在,例如:
-
细胞膜:某些细胞器(如线粒体)的膜系统呈现类似 Gyroid 的双层结构,用于高效物质交换。
-
昆虫翅膀:甲虫翅鞘的纳米级结构具有 Gyroid 模式,增强机械强度和光学性能。
-
病毒外壳:部分病毒的蛋白质外壳采用 Gyroid 几何排列,优化稳定性和组装效率。
FBM
当我们用这种曲面的手段进一步结合 FBM的模式。具体如下代码
p += cos(p.yzx * 1.0) / 1.0;p += cos(p.yzx * 2.0) / 2.0;p += cos(p.yzx * 4.0) / 4.0;p += cos(p.yzx * 8.0) / 8.0;
会发现出现了漂亮的曲面
到这里最终的形状不大可解释了, 不过不妨碍他很美。 这里我们只看到颜色的运动, 如果想要形状发生改变,我们试着在 三角函数里面加上一些时间分量看看。
vec3 offset = vec3(iTime, iTime * 2.0, 0.); p += cos((p.yzx + offset) * 1.0) / 1.0; p += cos((p.yzx + offset) * 2.0) / 2.0; p += cos((p.yzx + offset) * 4.0) / 4.0; p += cos((p.yzx + offset) * 8.0) / 8.0;
最后得到了 已经很美了,虽然不知道这是个什么玩意.... 至此所有核心的技巧已经讲解完毕
至此所有核心的技巧已经讲解完毕, 接下去就是发现美了
火焰
火焰代码如下。
// Fork of "3D Fire [340]" by Xor. https://shadertoy.com/view/3XXSWS// 2025-05-06 22:13:32void mainImage(out vec4 fragColor, vec2 fragCoord){ // Time for animation float t = iTime; // Raymarch loop iterator float i = 0.0; // Raymarched depth (rayDepth) float rayDepth = 0.0; // Raymarch step size and "Turbulence" frequency (stepSize) float stepSize; // Normalize screen coordinates vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // Camera setup vec3 rayOrigin = vec3(0.0, 0.0, 0.0); vec3 camPos = vec3(0.0, 0.0, 1.0); vec3 dir0 = normalize(-camPos); vec3 up = vec3(0.0, 1.0, 0.0); vec3 right = normalize(cross(dir0, up)); up = cross(right, dir0); // Recalculate up to ensure orthogonality // Calculate ray direction based on screen UV vec3 rd = normalize( dir0 + up * (uv.y + 1.0 / iResolution.y) + right * (uv.x + 1.0 / iResolution.x) ); // Raymarching loop with 50 iterations for (i = 0.0; i < 50.0; i++) { // Compute raymarch sample point vec3 hitPoint = rayOrigin + rayDepth * rd; // Animation: Flame moves backward with slight wobble hitPoint.z += 5.0 + cos(t); // Distortion: Rotate x/z plane based on y-coordinate float rotationAngle = hitPoint.y * 0.5; mat2 rotationMatrix = mat2( cos(rotationAngle), -sin(rotationAngle), sin(rotationAngle), cos(rotationAngle) ); hitPoint.xz *= rotationMatrix; // Flame shape: Expanding upward cone hitPoint.xz /= max(hitPoint.y * 0.1 + 1.0, 0.1); // Turbulence effect using fractal noise (fBm) float turbulenceFrequency = 2.0; for (int turbulenceIter = 0; turbulenceIter < 5; turbulenceIter++) { vec3 turbulenceOffset = cos( (hitPoint.yzx - vec3(t / 0.1, t, turbulenceFrequency)) * turbulenceFrequency ); hitPoint += turbulenceOffset / turbulenceFrequency; turbulenceFrequency /= 0.6; } // Calculate distance to flame surface (hollow cone) float coneRadius = length(hitPoint.xz); float coneDistance = abs(coneRadius + hitPoint.y * 0.3 - 0.5); // Compute step size for raymarching stepSize = 0.01 + coneDistance / 7.0; // Update ray depth rayDepth += stepSize; // Add color and glow effect based on depth vec4 flameColor = sin(rayDepth / 3.0 + vec4(7.0, 2.0, 3.0, 0.0)) + 1.1; fragColor += flameColor / stepSize; } // Tanh tonemapping fragColor = tanh(fragColor / 2000.0);}
资料
-
Interactive Graphics 25 - Volume Rendering www.youtube.com/watch?v=y4K…
-
How Big Budget AAA Games Render Clouds www.youtube.com/watch?v=Qj\…
-
RayMarching介绍 www.bilibili.com/video/BV1MV…
-
Volume Rendering www.scratchapixel.com/lessons/3d-…
-
Raymarching in Raymarching www.shadertoy.com/view/wlSGWy
-
Gyroid结构 www.youtube.com/watch?v=-ad…