粒子特效
This commit is contained in:
parent
4533a754c9
commit
10b19a3311
@ -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
src/views/api/aplayer/visualizer.ts
Normal file
195
src/views/api/aplayer/visualizer.ts
Normal 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')}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user