粒子特效

This commit is contained in:
灌糖包子 2026-05-16 23:40:20 +08:00
parent 4533a754c9
commit 10b19a3311
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D
2 changed files with 204 additions and 35 deletions

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

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')}`
}