Skip to content

71. 实战:3D 音乐播放器

Published:

前面学了音频相关的 API,这节我们来做一个实战:3D 版音乐播放器。

你可能每天都在用音乐播放器:

image.png

学了 Three.js,我们可以做一个 3D 版的。

首先,播放、暂停、进度条都是 3D 的按钮,用 RayCaster 来实现点击、进度调整。

然后用 AudioAnalyser 做一个频谱可视化,这次我们做环状的,一共 21 圈,高度随音乐起伏,有那种喷泉的感觉。

最后做 3D 的歌词轮播,纵深方向来做歌词的列表,播放的时候把前面的歌词逐渐往后移动。

这样,一个 3D 的音频播放器就完成了。

思路理清了,接下来来写代码吧:

npx create-vite 3d-music-player

image.png

进入项目,安装依赖:

npm install
npm install --save three
npm install --save-dev @types/three

改下 src/main.js

import './style.css';
import * as THREE from 'three';
import {
    OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import player from './player';

const scene = new THREE.Scene();
scene.add(player);

const directionLight = new THREE.DirectionalLight(0xffffff, 2);
directionLight.position.set(500, 400, 300);
scene.add(directionLight);

const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);

const width = window.innerWidth;
const height = window.innerHeight;

const helper = new THREE.AxesHelper(500);
scene.add(helper);

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 10000);
camera.position.set(500, 600, 800);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({
  antialias: true
});
renderer.setSize(width, height)

function render() {
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

render();

document.body.append(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

创建 Scene、Light、Camera、Renderer。

改下 style.css

body {
  margin: 0;
}

然后创建 player.js

import * as THREE from 'three';

const player = new THREE.Group();

function createBtn() {
    const geometry = new THREE.BoxGeometry(100, 80, 100);
    const material = new THREE.MeshPhysicalMaterial({
        color: 'white',
        roughness: 0.3
    });
    const btn = new THREE.Mesh(geometry, material);
    return btn;
}

const playBtn = createBtn();
player.add(playBtn);

export default player;

画一个播放按钮。

先跑下:

npm run dev

image.png

image.png

上面有一个播放的图标。

image.png

我们可以用 canvas 来画这种图案,作为纹理。

之前我们在 canvas 那节画过一个播放按钮,拿过来作为纹理:

image.png

image.png

function createCanvas() {
    const dpr = window.devicePixelRatio;
    const canvas = document.createElement("canvas");
    const w = canvas.width = 100 * dpr;
    const h = canvas.height = 100 * dpr;

    const c = canvas.getContext('2d');
    c.translate(w / 2, h / 2);

    c.arc(0, 0, 40 * dpr, 0, Math.PI * 2);
    c.fillStyle = "orange";
    c.fill();

    c.beginPath();
    c.moveTo(-10 * dpr, -20 * dpr);
    c.lineTo(-10 * dpr, 20 * dpr);
    c.lineTo(20 * dpr, 0);
    c.closePath();
    c.fillStyle = "white";
    c.fill();
    return canvas;
}

function createBtn() {
    const texture = new THREE.CanvasTexture(createCanvas());
    const geometry = new THREE.BoxGeometry(100, 80, 100);
    const material = new THREE.MeshPhysicalMaterial({
        // color: 'white',
        map: texture,
        roughness: 0.3
    });
    const btn = new THREE.Mesh(geometry, material);
    return btn;
}

2025-04-15 19.22.53.gif

我们只想一个面展示这个播放图标,所以在上面加一个 PlaneGeometry

image.png

const g = new THREE.PlaneGeometry(100, 100);
const m = new THREE.MeshPhysicalMaterial({
    color: 'white',
    map: texture,
    roughness: 0.3
});
const plane = new THREE.Mesh(g, m);
plane.rotateX(-Math.PI / 2);
plane.position.y = 40;
btn.add(plane);

position.y 设置为高度 80 的一半。

2025-04-15 19.27.48.gif

这样因为平面和立方体的上表面重合了,会有深度冲突问题,也就是闪烁,所以我们把 position.y 改为 41,让它们错开。

而且背景也让它透明:

image.png

这样就好了:

2025-04-15 19.30.14.gif

然后再做一个暂停按钮:

image.png

function createCanvas2() {
    const dpr = window.devicePixelRatio;
    const canvas = document.createElement("canvas");
    const w = canvas.width = 100 * dpr;
    const h = canvas.height = 100 * dpr;

    const c = canvas.getContext('2d');
    c.translate(w / 2, h / 2);

    c.arc(0, 0, 40 * dpr, 0, Math.PI * 2);
    c.fillStyle = "orange";
    c.fill();

    c.beginPath();
    c.moveTo(-10 * dpr, -20 * dpr);
    c.lineTo(-10 * dpr, 20 * dpr);
    c.moveTo(10 * dpr, -20 * dpr);
    c.lineTo(10 * dpr, 20 * dpr);
    c.closePath();
    c.lineWidth = 10;
    c.lineCap = 'round';
    c.strokeStyle = "white";
    c.stroke();
    return canvas;
}

const pauseBtn = createBtn();
const texture = new THREE.CanvasTexture(createCanvas2());
pauseBtn.children[0].material.map = texture;
pauseBtn.position.x = 200;
player.add(pauseBtn);

调用之前的 createBtn 方法创建一个按钮,替换下它的 PlaneGeometry 的材质的颜色贴图。

这里 canvas 改成画两条线,设置 lineWith 为 10 和 lineCap 为圆角。

image.png

我们给这两个 btn 加一个 name:

image.png

image.png

playBtn.name = 'playBtn';
pauseBtn.name = 'pauseBtn';

并且设置下 target 属性,这样点到上面的平面也可以通过 target 拿到按钮对象:

image.png

plane.target = btn;
btn.target = btn;

然后加一下点击的处理:

image.png

用 RayCaster 来判断点击事件,点中的物体拿到 target 属性。

让按钮 scale 0.6,有个按下的感觉,高度还剩 0.6,那 positon.y 就要向下移动 0.4 的高度。

renderer.domElement.addEventListener('click', (e) => {
  const y = -((e.offsetY / height) * 2 - 1);
  const x = (e.offsetX / width) * 2 - 1;

  const rayCaster = new THREE.Raycaster();
  rayCaster.setFromCamera(new THREE.Vector2(x, y), camera);

  const intersections = rayCaster.intersectObjects(player.children);

  if(intersections.length) {
    const obj = intersections[0].object.target;
    if(obj) {
      obj.scale.y = 0.6;
      obj.position.y = -80 * 0.4
    }
  }
});

看下效果:

2025-04-15 19.51.08.gif

然后我们加载一下音乐

这个音频下载下来:

image.png

放到 public 目录下:

image.png

代码里加载一下:

image.png

const listener = new THREE.AudioListener();
const audio = new THREE.Audio( listener );

const loader = new THREE.AudioLoader();
loader.load('./superman.mp3', function ( buffer ) {
  audio.setBuffer( buffer );
  audio.autoplay = false;
});

设置 autoplay 为 false,不然会自动播放。

然后点击按钮的时候,让另一个按钮弹起:

image.png

image.png

const playerBtn = player.getObjectByName('playBtn');
const pauseBtn = player.getObjectByName('pauseBtn');
if(obj) {
  if(obj.name === 'playBtn') {
    obj.scale.y = 0.6;
    obj.position.y = -80 * 0.4;

    pauseBtn.scale.y = 1;
    pauseBtn.position.y = 0;
  } else if(obj.name === 'pauseBtn') {
    obj.scale.y = 0.6;
    obj.position.y = -80 * 0.4;

    playerBtn.scale.y = 1;
    playerBtn.position.y = 0;
  }
}

2025-04-15 20.00.51.gif

就是录音机的那种感觉。

然后让音乐也跟着播放和暂停:

image.png

player.play();
player.pause();

试下效果:

2025-04-15 20.04.02.gif

jaudio

音乐的暂停和播放就做好了。

这时候我发现按钮位置不太对:

2025-04-15 20.11.56.gif

按下高度为 0.6 的话,整体少了 0.4。

但之前中心点在按钮中间,中心点只需要下移一半就好了:

image.png

2025-04-15 20.14.39.gif

这样位置就对了。

案例代码上传了小册仓库

总结

这节我们开始做音乐播放器的实战。

我们用 BoxGeometry 画了 2 个按钮,上面的 PlaneGeometry 用 canvas 画了播放、暂停的纹理。

用 RayCaster 处理了点击事件,点击的时候修改对应按钮的 scale 和 position.y 做按下的效果,并且让另一个按钮弹起。

之后加上 Audio 加载了音乐,点击按钮的时候让音乐播放暂停。

下一节我们继续来加上环形柱状音乐频谱的可视化。

评论