粒子特效

This commit is contained in:
2026-05-16 23:40:20 +08:00
parent 4533a754c9
commit 10b19a3311
2 changed files with 204 additions and 35 deletions
+9 -35
View File
@@ -124,6 +124,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
import { MusicPlayerItem, StatType } from '@/model/api/music'
import { parseLrc, adaptingThemeColor } from './utils'
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
import Hls from 'hls.js'
const props = defineProps<{
@@ -214,6 +215,9 @@ let animationId: number | null = null
let dataArray: Uint8Array<ArrayBuffer> | null = null
let visualizerConnected = false
// 粒子系统
const particles: Particle[] = []
function initVisualizer(audio: HTMLAudioElement) {
if (visualizerConnected) return
audioCtx = new AudioContext()
@@ -239,38 +243,13 @@ function drawVisualizer() {
analyser.getByteFrequencyData(dataArray)
ctx.clearRect(0, 0, width, height)
const { lowEnergy, highEnergy } = drawCircularSpectrum({ ctx, width, height, dataArray })
const centerX = width / 2
const centerY = height / 2
// 封面占 disc 的 52% + 6px border, 频谱从封面外边缘开始
const discRadius = Math.min(width, height) * 0.30
const barCount = dataArray.length
const maxBarLength = Math.min(width, height) * 0.18
for (let i = 0; i < barCount; i++) {
const value = dataArray[i]
const barLength = (value / 255) * maxBarLength
if (barLength < 1) continue
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
const startX = centerX + Math.cos(angle) * (discRadius + 2)
const startY = centerY + Math.sin(angle) * (discRadius + 2)
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
const ratio = i / barCount
const r = Math.floor(100 + ratio * 155)
const g = Math.floor(180 - ratio * 100)
const b = Math.floor(230 + ratio * 25)
const alpha = 0.4 + (value / 255) * 0.6
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
ctx.lineCap = 'round'
ctx.stroke()
}
spawnParticles(particles, centerX, centerY, discRadius, lowEnergy, highEnergy)
updateAndDrawParticles(ctx, particles)
animationId = requestAnimationFrame(drawVisualizer)
}
@@ -286,6 +265,7 @@ function stopVisualizer() {
cancelAnimationFrame(animationId)
animationId = null
}
particles.length = 0
}
function destroyVisualizer() {
@@ -371,12 +351,6 @@ function onVolumeInput(e: Event) {
}
}
function formatTime(sec: number) {
if (isNaN(sec)) return '00:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
function initAudio() {
const media = audioEl.value
+195
View File
@@ -0,0 +1,195 @@
/**
* 音频可视化 - 粒子系统与圆形频谱绘制
*
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
*/
/** 单个粒子的状态描述 */
export interface Particle {
x: number // 当前 x 坐标
y: number // 当前 y 坐标
vx: number // x 方向速度
vy: number // y 方向速度
life: number // 已存活帧数
maxLife: number // 最大存活帧数
size: number // 粒子半径
r: number // 颜色 R 分量
g: number // 颜色 G 分量
b: number // 颜色 B 分量
}
/** 粒子数量上限, 防止性能问题 */
const MAX_PARTICLES = 200
/**
* 在唱片边缘生成新粒子
*
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
* 颜色在蓝紫色调范围内随机偏移。
*
* @param particles - 粒子数组(会被直接修改)
* @param centerX - 唱片圆心 x 坐标
* @param centerY - 唱片圆心 y 坐标
* @param discRadius - 唱片半径(像素)
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
*/
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
const count = Math.floor(energy * 4)
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2
const speed = 0.5 + energy * 2 + highEnergy * 1.5
const hue = Math.random()
particles.push({
x: centerX + Math.cos(angle) * (discRadius + 4),
y: centerY + Math.sin(angle) * (discRadius + 4),
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
life: 1,
maxLife: 40 + Math.random() * 40,
size: 1.5 + energy * 2 + Math.random() * 1.5,
r: Math.floor(100 + hue * 155),
g: Math.floor(180 - hue * 100 + highEnergy * 60),
b: Math.floor(220 + hue * 35),
})
}
}
/**
* 更新粒子物理状态并绘制到 Canvas
*
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
*
* @param ctx - Canvas 2D 渲染上下文
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
*/
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.x += p.vx
p.y += p.vy
p.vx *= 0.98
p.vy *= 0.98
p.life += 1
const progress = p.life / p.maxLife
if (progress >= 1) {
particles.splice(i, 1)
continue
}
const alpha = 1 - progress
const size = p.size * (1 - progress * 0.5)
ctx.beginPath()
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
ctx.fill()
// 发光效果: 在粒子外围画一个更大的半透明圆
if (size > 1.5) {
ctx.beginPath()
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
ctx.fill()
}
}
}
/** drawCircularSpectrum 的入参 */
export interface SpectrumParams {
ctx: CanvasRenderingContext2D
width: number // Canvas 宽度
height: number // Canvas 高度
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
}
/** 频率能量分析结果 */
export interface EnergyResult {
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
}
/**
* 从频率数据中计算低频和高频的平均能量
*
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
* 用于驱动粒子系统的生成参数。
*
* @param dataArray - FFT 频率数据(0~255)
* @returns 低频和高频的归一化能量值
*/
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
const barCount = dataArray.length
let lowSum = 0, highSum = 0
const lowEnd = Math.floor(barCount * 0.25)
const highStart = Math.floor(barCount * 0.6)
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
return {
lowEnergy: lowSum / (lowEnd * 255),
highEnergy: highSum / ((barCount - highStart) * 255),
}
}
/**
* 绘制圆形音频频谱
*
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
*
* @param params - 包含 Canvas 上下文、尺寸和频率数据
* @returns 低频/高频能量分析结果
*/
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
const { ctx, width, height, dataArray } = params
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
const barCount = dataArray.length
const maxBarLength = Math.min(width, height) * 0.18
for (let i = 0; i < barCount; i++) {
const value = dataArray[i]
const barLength = (value / 255) * maxBarLength
if (barLength < 1) continue
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
const startX = centerX + Math.cos(angle) * (discRadius + 2)
const startY = centerY + Math.sin(angle) * (discRadius + 2)
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
const ratio = i / barCount
const r = Math.floor(100 + ratio * 155)
const g = Math.floor(180 - ratio * 100)
const b = Math.floor(230 + ratio * 25)
const alpha = 0.4 + (value / 255) * 0.6
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
ctx.lineCap = 'round'
ctx.stroke()
}
return computeFrequencyEnergy(dataArray)
}
/**
* 将秒数格式化为 MM:SS 格式
*
* @param sec - 秒数
* @returns 格式化后的时间字符串, 如 "03:25"
*/
export function formatTime(sec: number) {
if (isNaN(sec)) return '00:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}