Three.js案例
Three.js + Shader + 音乐可视化

音乐可视化

今天读到了一篇关于 Three.js + Shader + 音乐可视化 的文章,介绍了如何利用 辉光特效(Bloom)噪声函数(Noise) 来让一个球体随音乐节奏跳动。整体代码逻辑并不复杂,核心在于 Shader 的编写,尤其是 noise 函数的应用。

阅读后收获颇多,于是自己尝试替换了其中的 noise 函数,效果仍然不错。为了方便大家理解,我对原代码进行了简化,去除了非核心部分,只保留了主要实现逻辑。

无论是阅读原文还是我的简化版文章,应该都可以学到一些东西。

原文:https://waelyasmina.net/articles/how-to-create-a-3d-audio-visualizer-using-three-js/ (opens in a new tab)

效果如下

一、整体功能概述

  1. 使用 WebGLRenderer 创建渲染器并配置抗锯齿效果。
  2. 创建 场景相机轨道控制器 以及 后期处理通道(辉光特效 + 合成输出)
  3. 使用 自定义着色器(ShaderMaterial),并通过加载外部 .glsl 文件来编写顶点、片元着色器。
  4. 利用 AudioAudioAnalyser 等 Three.js 内置音频模块,实现音频可视化,将音频频率数据传递给着色器进行动画变形。
  5. 为了在用户交互(点击)后才播放音频,示例中添加了对 document 的点击事件监听。

二、项目结构和主要依赖

1. 依赖安装

  • Three.js:核心库(需要导入三维场景、相机、渲染器、后期处理、音频模块等)。
  • OrbitControls:辅助控制器,用来在场景中交互旋转、缩放、移动相机视角。
  • EffectComposerRenderPassUnrealBloomPassOutputPass:Three.js 提供的后期处理模块,用于在场景渲染结果上叠加特效(这里主要演示发光效果 Bloom)。
  • FileLoaderAudioLoader:Three.js 中用来加载外部文件的类。

2. 目录组织示例

project

├── src
│   ├── main.js          // 这里放置本文示例中的主要逻辑代码
│   ├── shaders
│   │   ├── vertex.glsl  // 顶点着色器
│   │   └── fragment.glsl // 片元着色器
│   └── Beats.mp3        // 音频资源

└── package.json

三、核心代码解析

1. 初始化 Three.js 渲染器、场景、相机

// 创建WebGL渲染器,启用抗锯齿效果
const renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置渲染器尺寸为窗口大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器的DOM元素添加到页面body中
document.body.appendChild(renderer.domElement);
 
// 创建场景对象,用于存放所有3D对象
const scene = new THREE.Scene();
 
// 创建透视相机,参数分别是:视场角度、宽高比、近裁剪面、远裁剪面
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
 
// 轨道控制器,用于鼠标交互
const controls = new OrbitControls(camera, renderer.domElement);
controls.update();
  • renderer:使用 new THREE.WebGLRenderer 初始化,antialias: true 代表开启抗锯齿以减少锯齿现象。
  • scene:一个场景容器,所有的 3D 模型、灯光等都会添加到其中。
  • camera:使用透视投影的相机,设置 FOV(视场角) 为 45°。0.11000 代表相机的近截面和远截面距离。
  • OrbitControls:允许通过拖拽、滚轮等方式平移、旋转、缩放视图。

2. 后期处理:辉光通道、效果合成器

// 创建渲染通道,用于后期处理
const renderScene = new RenderPass(scene, camera);
 
// 创建辉光通道,为场景添加发光效果
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight));
// 设置辉光阈值,只有亮度超过此值的像素才会发光
bloomPass.threshold = 0.5;
// 设置辉光强度
bloomPass.strength = 0.5;
// 设置辉光半径
bloomPass.radius = 0.4;
 
// 创建效果合成器,用于组合多个后期处理效果
const bloomComposer = new EffectComposer(renderer);
// 添加渲染通道
bloomComposer.addPass(renderScene);
// 添加辉光通道
bloomComposer.addPass(bloomPass);
 
// 创建输出通道,用于最终渲染结果的输出
const outputPass = new OutputPass();
bloomComposer.addPass(outputPass);
  1. RenderPass:后期处理前,必须先把场景渲染到一个中间缓冲中。
  2. UnrealBloomPass:实现 Bloom 效果的通道,可通过 thresholdstrengthradius 来调节发光效果。
  3. EffectComposer:用于组合多个后期处理 Pass,先渲染然后添加辉光,再把结果通过 OutputPass 输出到屏幕。

3. 相机设置

// 设置相机位置
camera.position.set(0, -2, 14);
// 设置相机朝向场景中心
camera.lookAt(0, 0, 0);
  • 将相机在 Z 轴正方向往后移一些,并微调 Y 轴,得到一个合适的俯视/正视角度。
  • lookAt(0, 0, 0) 使相机视线指向场景中心。

4. 着色器加载与创建 ShaderMaterial

我们的自定义着色器分为 顶点着色器片元着色器,源码放在外部 .glsl 文件中。

4.1 定义 uniforms

const uniforms = {
  u_time: { type: "f", value: 0.0 }, // 时间变量
  u_frequency: { type: "f", value: 0.0 }, // 音频频率
  u_red: { type: "f", value: 0.3 }, // 颜色 R 分量
  u_green: { type: "f", value: 1.0 }, // 颜色 G 分量
  u_blue: { type: "f", value: 0.6 }, // 颜色 B 分量
};
  • u_time:让物体或波动产生动画。
  • u_frequency:用来从音频分析器获得的频率值,驱动几何体变形。
  • u_red / u_green / u_blue:可用来在片元着色器中控制输出颜色。

4.2 加载着色器文件

function loadShader(path) {
  return new Promise((resolve, reject) => {
    const loader = new FileLoader();
    loader.load(
      path,
      (data) => {
        resolve(data);
      },
      undefined,
      (err) => {
        console.error(`加载着色器文件 ${path} 失败:`, err);
        reject(err);
      }
    );
  });
}
  • 使用 FileLoader 异步加载 .glsl 文件。
  • 返回一个 Promise,以便我们在后面可以通过 await 同步获取结果。

4.3 创建并应用 ShaderMaterial

async function initShaders() {
  try {
    // 加载顶点和片元着色器
    const vertexShader = await loadShader("/shaders/vertex.glsl");
    const fragmentShader = await loadShader("/shaders/fragment.glsl");
 
    // 创建着色器材质
    const mat = new THREE.ShaderMaterial({
      uniforms,
      vertexShader,
      fragmentShader,
    });
 
    // 创建二十面体几何体,用于雷达效果
    const geo = new THREE.IcosahedronGeometry(4, 30);
    // 创建网格并添加到场景
    const mesh = new THREE.Mesh(geo, mat);
    scene.add(mesh);
 
    // 设置为线框模式
    mesh.material.wireframe = true;
 
    // 启动动画循环
    animate();
  } catch (error) {
    console.error("初始化着色器失败:", error);
  }
}
  1. 这里的 new THREE.IcosahedronGeometry(4, 30) 生成了一个半径为 4 的二十面体,并把细分等级设置比较高(30),使其在变形时更柔和、更细腻。
  2. wireframe = true:给网格一个线框模式,呈现科技感或科幻风格。
  3. animate():初始化完成后启动动画循环。

5. 着色器代码剖析

5.1 顶点着色器(vertex.glsl

这个顶点着色器的作用是:

  • 创建一个随时间变化的动态表面
  • 表面会产生波浪般的起伏效果
  • 起伏的强度受频率参数控制
  • 整体效果像是一个动态的雷达波或水面波纹
uniform float u_time;
uniform float u_frequency;
 
// 简化的噪声函数辅助方法
float random(vec2 st){
  return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
 
// 使用分形叠加
// p 是三维空间中的一个点, rep 控制噪声的重复周期
float improvedNoise(vec3 p,vec3 rep){
  // 创建基础噪声
  float result=0.;
  float amplitude=1.;
  float frequency=1.;
  float maxValue=0.;
 
  // 分形布朗运动 - 叠加不同频率的噪声
  for(int i=0;i<3;i++){
    vec2 grid=floor(mod(p.xy*frequency,rep.xy));
    vec2 f=fract(p.xy*frequency);
 
    // 四个角的随机值
    float a=random(grid);
    float b=random(grid+vec2(1.,0.));
    float c=random(grid+vec2(0.,1.));
    float d=random(grid+vec2(1.,1.));
 
    // 平滑插值 - 使用更平滑的曲线
    vec2 u=f*f*f*(f*(f*6.-15.)+10.);// 更平滑的Perlin插值曲线
 
    // 混合四个角的值
    float noise=mix(
      mix(a,b,u.x),
      mix(c,d,u.x),
      u.y
    );
 
    // 添加旋转变化,使噪声在z轴上更有变化
    float zAngle=p.z*frequency/rep.z;
    float zFactor=.5+.5*sin(zAngle*6.2831853);
 
    // 累加噪声
    result+=noise*amplitude*zFactor;
    maxValue+=amplitude;
 
    // 调整下一次迭代的频率和振幅
    amplitude*=.5;
    frequency*=2.;
  }
 
  // 归一化结果
  return result/maxValue*2.2;
}
 
void main(){
  // 计算噪声值,加入时间因素使其随时间变化
  float noise = 3. * improvedNoise(position + vec3(u_time*.5, u_time*.3, u_time*.7), vec3(10.));
  // 计算位移量,由频率和噪声共同决定
  float displacement = (u_frequency / 30.) * (noise / 10.);
  // 沿着顶点法线方向应用位移,创建起伏效果
  vec3 newPosition = position + normal * displacement;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.);
}
  • positionnormal:Three.js 自动注入,分别是顶点坐标和顶点法线。
  • u_time:时间用来推动噪声,产生持续变化的动画。
  • u_frequency:音频频率值决定顶点位移程度。
  • improvedNoise:模拟类似 Perlin 的噪声,带有分形叠加效果,能得到更复杂的波动形变。

5.2 片元着色器(fragment.glsl

uniform float u_red;
uniform float u_blue;
uniform float u_green;
 
void main(){
  gl_FragColor = vec4(u_red, u_green, u_blue, 1.);
}
  • 简单地把像素颜色设置成 u_red, u_green, u_blue 的组合值,可根据需求灵活调整。
  • 如果想让颜色也随时间或频率变化,可以在这里加入更多逻辑或噪声计算。

6. 音频模块与可视化

// 创建音频监听器
const listener = new THREE.AudioListener();
camera.add(listener);
 
// 创建音频对象
const sound = new THREE.Audio(listener);
 
// 创建音频加载器
const audioLoader = new THREE.AudioLoader();
 
// 加载音频文件
let audioLoaded = false;
let audioBuffer = null;
audioLoader.load("/Beats.mp3", function (buffer) {
  console.log("音频加载完成");
  audioBuffer = buffer;
  audioLoaded = true;
});
 
// 创建音频分析器,用于获取音频频率数据
const analyser = new THREE.AudioAnalyser(sound, 32);
  1. AudioListener:放在相机上,模拟现实中的“收听者位置”,也可做 3D 音效等。
  2. Audio:将该 Listener 传给音频对象后,就能进行播放、暂停、音量设置等操作。
  3. AudioLoader:加载本地或线上音频文件(.mp3、.wav 等)。
  4. AudioAnalyser:提供 getAverageFrequency() 等方法,用来实时获取当前音频的频谱信息。

6.1 在用户点击后播放音频

为了满足现代浏览器策略,需要用户交互后才能播放声音:

// 用户点击页面后,若音频已加载且未播放,则开始播放
document.addEventListener("click", function () {
  if (audioLoaded && !sound.isPlaying) {
    tryPlayAudio();
  }
});
 
function tryPlayAudio() {
  if (!audioLoaded) return;
  try {
    sound.setBuffer(audioBuffer);
    sound.setLoop(true);
    sound.play();
    console.log("音频开始播放");
  } catch (error) {
    console.error("音频播放失败:", error);
  }
}

7. 动画循环

const clock = new THREE.Clock();
 
function animate() {
  // 更新时间
  uniforms.u_time.value = clock.getElapsedTime();
  // 更新频率变量
  uniforms.u_frequency.value = analyser.getAverageFrequency();
 
  // 使用效果合成器渲染场景(含后期处理)
  bloomComposer.render();
  // 请求下一帧
  requestAnimationFrame(animate);
}
  • clock.getElapsedTime():获取从场景开始渲染到现在的总时长(秒)。
  • analyser.getAverageFrequency():返回当前音频的频率值,会随音乐波动。
  • 每帧中都更新着色器的 u_timeu_frequency,就能使顶点产生“随音乐跳动”或波动的动态变形。

8. 自适应窗口尺寸

window.addEventListener("resize", function () {
  // 更新相机宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
 
  // 更新渲染器尺寸
  renderer.setSize(window.innerWidth, window.innerHeight);
 
  // 更新效果合成器尺寸
  bloomComposer.setSize(window.innerWidth, window.innerHeight);
});
  • 监听 resize 事件,每次改变窗口大小时都要同时更新相机和渲染器、后期处理器的尺寸。
  • 如果不做这个步骤,改变窗口后画面会扭曲或出现空白区域。

代码

https://github.com/calmound/threejs-demo/tree/main/ballnoise (opens in a new tab)