深入理解 3D 火焰着色器:从 Shadertoy 到 Three.js 的完整实现解析

本文由 AI 生成,结合 GLSL 原理与 Three.js 实践,旨在帮助初学者逐行理解代码,而不是仅仅"照抄能跑"。我会用直观类比、数值例子、代码注释来拆解整个火焰效果。


一、前言:从 Shadertoy 到 Three.js

Shadertoy 上有很多绚丽的着色器,但它们常常让新手望而生畏:几十行数学公式,cos/sin 嵌套,光线行进(raymarching)循环一堆看不懂的变量。

其实这些代码是有逻辑脉络的:

  1. 定义相机 → 每个像素对应一条射线
  2. 沿射线逐步前进(raymarching)
  3. 在每个点做几何变换 + 噪声扰动 → 得到火焰体积感
  4. 根据深度和噪声算颜色 → 累积
  5. 最后做 tone mapping → 显示在屏幕上

这篇文章会带你走完整个过程,并给出 Vue + Three.js 的完整实现。


二、完整片元着色器代码(带注释)

ini 复制代码
// 火焰效果片元着色器
// 输入:时间 iTime(秒),画布分辨率 iResolution(x=宽,y=高,z备用)
uniform float iTime;
uniform vec3 iResolution;

void mainImage(out vec4 fragColor, vec2 fragCoord)
{
    float t = iTime;          // 动画时间
    float rayDepth = 0.0;     // 射线累计前进距离
    vec3 col = vec3(0.0);     // 颜色累积器

    // 将像素坐标转为 [-1,1] 的标准化坐标
    vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;

    // 相机设置
    vec3 rayOrigin = vec3(0.0, 0.0, 0.0);    // 相机位置
    vec3 camPos = vec3(0.0, 0.0, 1.0);       // 相机朝向参考点
    vec3 dir0 = normalize(-camPos);          // 主视线方向(-Z)
    vec3 up = vec3(0.0, 1.0, 0.0);           // 世界上方向
    vec3 right = normalize(cross(dir0, up)); // 相机右方向
    up = cross(right, dir0);                 // 重算正交上方向

    // 每个像素对应的射线方向
    vec3 rayDirection = normalize(
        dir0 + uv.x * right + uv.y * up
    );

    // 光线行进主循环
    for (float i = 0.0; i < 50.0; i++) {
        // 当前射线位置
        vec3 hitPoint = rayOrigin + rayDepth * rayDirection;

        // 火焰推远并随时间摆动
        hitPoint.z += 5.0 + cos(t);

        // 火焰随高度扭曲
        float rot = hitPoint.y * 0.5;
        mat2 rotMat = mat2(cos(rot), -sin(rot), sin(rot), cos(rot));
        hitPoint.xz *= rotMat;

        // 火焰锥体形状:上窄下宽
        hitPoint.xz /= max(hitPoint.y * 0.1 + 1.0, 0.1);

        // Turbulence:多层余弦扰动模拟噪声
        float freq = 2.0;
        for (int it = 0; it < 5; it++) {
            vec3 offset = cos((hitPoint.yzx - vec3(t/0.1, t, freq)) * freq);
            hitPoint += offset / freq;
            freq /= 0.6;
        }

        // 距离场:判断火焰边界
        float coneRadius = length(hitPoint.xz);
        float coneDist = abs(coneRadius + hitPoint.y * 0.3 - 0.5);

        // 自适应步长
        float stepSize = 0.01 + coneDist / 7.0;
        rayDepth += stepSize;

        // 累积颜色
        vec3 gCol = sin(rayDepth / 3.0 + vec3(7.0, 2.0, 3.0)) + 1.1;
        col += gCol / stepSize;
    }

    // Tone mapping
    col = tanh(col / 2000.0);
    fragColor = vec4(col, 1.0);
}

void main() {
    mainImage(gl_FragColor, gl_FragCoord.xy);
}

三、逐段原理解析

1. UV 转换

公式:uv = (2.0*fragCoord - iResolution.xy) / iResolution.y

作用:把屏幕像素映射到中心为 (0,0),范围约 [-1,1] 的坐标系。

举例:分辨率 800×600,中点 (400,300) → uv = (0,0)。


2. 射线方向

通过 dir0(相机主方向)、upright 三个正交向量,把 uv 映射到 3D 空间。

直观理解:每个像素就是你眼睛发出的一条射线。


3. Raymarch 循环

光线前进 50 步,每一步:

  1. 计算 hitPoint = rayOrigin + rayDepth * rayDirection
  2. 加上时间和 cos(t) 让火焰晃动
  3. 用旋转矩阵让火焰扭动
  4. 用锥体缩放实现上窄下宽
  5. 加 turbulence 扰动 → 看起来不规则
  6. 算到火焰边界的"距离" → 控制步长
  7. 按颜色函数累积颜色

类比:就像走进一个雾气团,每一步闻一口烟雾浓度,最后累加。


4. Turbulence(扰动)

使用 5 层 cos 叠加,模拟分形噪声:

  • 低频控制整体摆动
  • 高频增加细节
  • 层层叠加后,就像火焰的跳动纹理

5. 颜色生成

gCol = sin(rayDepth/3.0 + vec3(7,2,3)) + 1.1

  • 三个通道不同相位 → 颜色过渡(红→橙→黄)
  • +1.1 提升基底亮度
  • 除以步长 → 靠近边界亮度增强

6. Tone mapping

tanh(col/2000.0) 把颜色压到合理范围,避免过曝。


四、Vue + Three.js 集成示例

ini 复制代码
<script setup>
import * as THREE from "three";
import { onMounted, onBeforeUnmount, ref } from "vue";

const container = ref(null);
let renderer, scene, camera, mesh, material, rafId;

onMounted(() => {
  scene = new THREE.Scene();
  camera = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
  renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  container.value.appendChild(renderer.domElement);

  const uniforms = {
    iTime: { value: 0 },
    iResolution: { value: new THREE.Vector3(window.innerWidth, window.innerHeight, 1) }
  };

  material = new THREE.ShaderMaterial({
    uniforms,
    fragmentShader: fragmentSource, // 上面着色器代码
  });

  mesh = new THREE.Mesh(new THREE.PlaneGeometry(2,2), material);
  scene.add(mesh);

  function animate(time){
    uniforms.iTime.value = time * 0.001;
    renderer.render(scene, camera);
    rafId = requestAnimationFrame(animate);
  }
  animate();

  window.addEventListener("resize", () => {
    renderer.setSize(window.innerWidth, window.innerHeight);
    uniforms.iResolution.value.set(window.innerWidth, window.innerHeight, 1);
  });
});

onBeforeUnmount(() => cancelAnimationFrame(rafId));
</script>

<template>
  <div ref="container" style="width:100vw;height:100vh;"></div>
</template>

五、可调参数(实验一下!)

  • Raymarch 循环次数:20 vs 80 → 精度 vs 性能
  • Turbulence 层数:3 vs 7 → 平滑 vs 细节
  • hitPoint.z += 5.0 → 火焰远近
  • coneDist / 7.0 → 控制步长大小
  • tanh(col/2000.0) → 调整亮度范围

六、调试与常见问题

  • 黑屏 → 着色器语法错误,看控制台
  • 帧率低 → 减少循环步数,降分辨率
  • 颜色过曝 → 调整 tone mapping 参数
  • 没动画 → 检查 iTime 是否在更新

七、总结

火焰效果其实就是:

  1. 定义锥体几何(火焰形状)
  2. 用噪声扰动模拟跳动
  3. 用光线行进采样累积亮度
  4. 用颜色公式生成火焰色彩
  5. 最后映射成屏幕像素

一句话:每个像素是一条射线,在锥体火焰里采样累积颜色,得到动态火焰。

参考 欧阳大盆裁 文章而成


👉 如果你觉得还难,可以做一个"简化版练习":先只保留锥体形状(不加 turbulence、不加颜色函数),跑起来后再一步步加扰动、颜色、tone mapping,这样更直观。

相关推荐
光影少年2 小时前
vue打包优化方案都有哪些?
前端·javascript·vue.js
硅谷工具人3 小时前
vue3边学边做系列(3)-路由缓存接口封装
前端·缓存·前端框架·vue
β添砖java4 小时前
CSS网格布局
前端·css·html
木易 士心6 小时前
Ref 和 Reactive 响应式原理剖析与代码实现
前端·javascript·vue.js
程序员博博6 小时前
概率与决策 - 模拟程序让你在选择中取胜
前端
被巨款砸中6 小时前
一篇文章讲清Prompt、Agent、MCP、Function Calling
前端·vue.js·人工智能·web
sophie旭6 小时前
一道面试题,开始性能优化之旅(1)-- beforeFetch
前端·性能优化
Cache技术分享6 小时前
204. Java 异常 - Error 类:表示 Java 虚拟机中的严重错误
前端·后端