|
|
@@ -1,131 +1,193 @@
|
|
|
-import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
|
|
|
+import { SpectrumRenderer, SpectrumRenderOptions, rgbaFromHex } from './SpectrumRenderer'
|
|
|
|
|
|
/**
|
|
|
* 水波纹频谱渲染器
|
|
|
- * 海浪式抛物线水波,中心浪峰更高,两侧自然回落
|
|
|
+ * 参考经典水波纹实现,使用海洋色系的三层填充波浪并结合 FFT 频段做实时起伏
|
|
|
+ * 高频越强,海浪越急、越碎、越有冲击感
|
|
|
*/
|
|
|
export class RippleWaveSpectrumRenderer implements SpectrumRenderer {
|
|
|
private phase: number = 0
|
|
|
|
|
|
render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
|
|
|
- width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
|
|
|
+ width: number, height: number, _brandColor: string, options?: SpectrumRenderOptions): void {
|
|
|
if (bands.length === 0) {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height)
|
|
|
|
|
|
- const palette = buildSpectrumPalette(brandColor)
|
|
|
const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
|
|
|
- const layerCount: number = Math.max(4, Math.floor(7 * complexityScale))
|
|
|
- const segmentCount: number = Math.max(44, Math.floor(92 * complexityScale))
|
|
|
- const horizonY: number = height * 0.58
|
|
|
- const waveHeight: number = height * (0.075 + energy * 0.13)
|
|
|
- const layerGap: number = height * 0.05
|
|
|
- const coolColor: string = hslToHex(palette.coolHue, 0.84, 0.66)
|
|
|
- const primaryColor: string = hslToHex(palette.primaryHue, 0.78, 0.50)
|
|
|
- const deepColor: string = hslToHex(palette.complementaryHue, 0.52, 0.26)
|
|
|
-
|
|
|
- this.phase += 0.055 + energy * 0.05
|
|
|
+ const segmentCount: number = Math.max(60, Math.floor(124 * complexityScale))
|
|
|
+ const centerY: number = height * 0.60
|
|
|
+ const trebleEnergy: number = this.getBandRangeAverage(bands, 0.62, 1.0)
|
|
|
+ const midEnergy: number = this.getBandRangeAverage(bands, 0.30, 0.62)
|
|
|
+ const waveAggression: number = this.clamp(energy * 0.34 + midEnergy * 0.28 + trebleEnergy * 1.08, 0, 1)
|
|
|
+ const trebleSurge: number = this.clamp(trebleEnergy * 1.18 + energy * 0.20, 0, 1)
|
|
|
+
|
|
|
+ const aquaGreen: string = '#27D7A1'
|
|
|
+ const lightBlue: string = '#7FD8FF'
|
|
|
+ const cyanBlue: string = '#3FB7FF'
|
|
|
+ const seaBlue: string = '#1C75DB'
|
|
|
+ const deepOcean: string = '#0F3F8E'
|
|
|
+
|
|
|
+ this.phase += 0.042 + waveAggression * 0.082 + trebleSurge * 0.052
|
|
|
if (this.phase > Math.PI * 2) {
|
|
|
this.phase -= Math.PI * 2
|
|
|
}
|
|
|
|
|
|
- const skyGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, height)
|
|
|
- skyGradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
|
|
|
- skyGradient.addColorStop(0.62, rgbaFromHex(coolColor, 0.03 + energy * 0.02))
|
|
|
- skyGradient.addColorStop(1, rgbaFromHex(primaryColor, 0.08 + energy * 0.05))
|
|
|
- ctx.fillStyle = skyGradient
|
|
|
- ctx.beginPath()
|
|
|
- ctx.rect(0, 0, width, height)
|
|
|
- ctx.fill()
|
|
|
+ const backgroundGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, height)
|
|
|
+ backgroundGradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
|
|
|
+ backgroundGradient.addColorStop(0.60, rgbaFromHex(lightBlue, 0.03 + waveAggression * 0.02))
|
|
|
+ backgroundGradient.addColorStop(0.82, rgbaFromHex(cyanBlue, 0.05 + waveAggression * 0.04))
|
|
|
+ backgroundGradient.addColorStop(1, rgbaFromHex(deepOcean, 0.09 + waveAggression * 0.05))
|
|
|
+ ctx.fillStyle = backgroundGradient
|
|
|
+ ctx.fillRect(0, 0, width, height)
|
|
|
+
|
|
|
+ this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
|
|
|
+ baseY: centerY + height * 0.10,
|
|
|
+ amplitude: height * (0.070 + waveAggression * 0.060 + trebleSurge * 0.022),
|
|
|
+ bandAmplitude: height * (0.060 + waveAggression * 0.070 + trebleSurge * 0.022),
|
|
|
+ mainFrequency: 0.010,
|
|
|
+ detailFrequency: 0.021,
|
|
|
+ chopFrequency: 0.040,
|
|
|
+ detailScale: 0.24,
|
|
|
+ chopScale: 0.16,
|
|
|
+ phaseShift: this.phase,
|
|
|
+ trebleEnergy,
|
|
|
+ surgeEnergy: trebleSurge,
|
|
|
+ bandOffset: 0.00,
|
|
|
+ fillColor: rgbaFromHex(seaBlue, 0.46)
|
|
|
+ })
|
|
|
+
|
|
|
+ this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
|
|
|
+ baseY: centerY + height * 0.01,
|
|
|
+ amplitude: height * (0.078 + waveAggression * 0.072 + trebleSurge * 0.028),
|
|
|
+ bandAmplitude: height * (0.066 + waveAggression * 0.076 + trebleSurge * 0.026),
|
|
|
+ mainFrequency: 0.018,
|
|
|
+ detailFrequency: 0.033,
|
|
|
+ chopFrequency: 0.062,
|
|
|
+ detailScale: 0.22,
|
|
|
+ chopScale: 0.20,
|
|
|
+ phaseShift: this.phase * 1.22 + 0.9,
|
|
|
+ trebleEnergy,
|
|
|
+ surgeEnergy: trebleSurge,
|
|
|
+ bandOffset: 0.08,
|
|
|
+ fillColor: rgbaFromHex(lightBlue, 0.36)
|
|
|
+ })
|
|
|
+
|
|
|
+ this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
|
|
|
+ baseY: centerY - height * 0.10,
|
|
|
+ amplitude: height * (0.145 + waveAggression * 0.132 + trebleSurge * 0.056),
|
|
|
+ bandAmplitude: height * (0.124 + waveAggression * 0.128 + trebleSurge * 0.054),
|
|
|
+ mainFrequency: 0.014,
|
|
|
+ detailFrequency: 0.028,
|
|
|
+ chopFrequency: 0.086,
|
|
|
+ detailScale: 0.30,
|
|
|
+ chopScale: 0.34,
|
|
|
+ phaseShift: this.phase * 0.92 + 1.7,
|
|
|
+ trebleEnergy,
|
|
|
+ surgeEnergy: trebleSurge,
|
|
|
+ bandOffset: 0.15,
|
|
|
+ fillColor: rgbaFromHex(aquaGreen, 0.54)
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- for (let layer = 0; layer < layerCount; layer++) {
|
|
|
- const layerProgress: number = layer / Math.max(1, layerCount - 1)
|
|
|
- const baseY: number = horizonY + layer * layerGap
|
|
|
- const localHeight: number = waveHeight * (1 - layerProgress * 0.5)
|
|
|
- const strokeAlpha: number = 0.22 + (1 - layerProgress) * 0.24 + energy * 0.10
|
|
|
- const fillAlpha: number = 0.05 + (1 - layerProgress) * 0.07 + energy * 0.04
|
|
|
- const hue: number = (palette.coolHue + layer * 8) % 360
|
|
|
- const lineWidth: number = 1.2 + (1 - layerProgress) * 1.6
|
|
|
- const phaseShift: number = this.phase * (1.0 + layer * 0.08) + layer * 0.65
|
|
|
-
|
|
|
- ctx.beginPath()
|
|
|
- for (let i = 0; i <= segmentCount; i++) {
|
|
|
- const progressX: number = i / segmentCount
|
|
|
- const x: number = progressX * width
|
|
|
- const normalizedX: number = progressX * 2 - 1
|
|
|
- const parabolaEnvelope: number = Math.max(0, 1 - normalizedX * normalizedX)
|
|
|
-
|
|
|
- const bandIndexFloat: number = progressX * (bands.length - 1)
|
|
|
- const bandIndex: number = Math.min(bands.length - 1, Math.max(0, Math.floor(bandIndexFloat + layer)))
|
|
|
- const bandValue: number = Math.pow(bands[bandIndex], 0.9)
|
|
|
-
|
|
|
- const mainSwell: number = Math.sin(progressX * Math.PI * (2.0 + layer * 0.10) + phaseShift)
|
|
|
- const detailSwell: number = Math.sin(progressX * Math.PI * (4.8 + layer * 0.22) - phaseShift * 0.82)
|
|
|
- const crestShape: number = parabolaEnvelope * (0.52 + bandValue * 0.88)
|
|
|
- const swellOffset: number = mainSwell * localHeight * 0.18 + detailSwell * localHeight * 0.06
|
|
|
- const lift: number = crestShape * localHeight
|
|
|
- const y: number = baseY - lift + swellOffset
|
|
|
-
|
|
|
- if (i === 0) {
|
|
|
- ctx.moveTo(x, y)
|
|
|
- } else {
|
|
|
- ctx.lineTo(x, y)
|
|
|
- }
|
|
|
- }
|
|
|
+ reset(): void {
|
|
|
+ this.phase = 0
|
|
|
+ }
|
|
|
|
|
|
- const fillGradient: CanvasGradient = ctx.createLinearGradient(0, baseY - localHeight * 1.1, 0, height)
|
|
|
- fillGradient.addColorStop(0, rgbaFromHex(hslToHex(hue, 0.84, 0.64), fillAlpha))
|
|
|
- fillGradient.addColorStop(0.55, rgbaFromHex(primaryColor, fillAlpha * 0.65))
|
|
|
- fillGradient.addColorStop(1, rgbaFromHex(deepColor, 0.02))
|
|
|
- ctx.lineTo(width, height)
|
|
|
- ctx.lineTo(0, height)
|
|
|
- ctx.closePath()
|
|
|
- ctx.fillStyle = fillGradient
|
|
|
- ctx.fill()
|
|
|
-
|
|
|
- ctx.beginPath()
|
|
|
- for (let i = 0; i <= segmentCount; i++) {
|
|
|
- const progressX: number = i / segmentCount
|
|
|
- const x: number = progressX * width
|
|
|
- const normalizedX: number = progressX * 2 - 1
|
|
|
- const parabolaEnvelope: number = Math.max(0, 1 - normalizedX * normalizedX)
|
|
|
-
|
|
|
- const bandIndexFloat: number = progressX * (bands.length - 1)
|
|
|
- const bandIndex: number = Math.min(bands.length - 1, Math.max(0, Math.floor(bandIndexFloat + layer)))
|
|
|
- const bandValue: number = Math.pow(bands[bandIndex], 0.9)
|
|
|
-
|
|
|
- const mainSwell: number = Math.sin(progressX * Math.PI * (2.0 + layer * 0.10) + phaseShift)
|
|
|
- const detailSwell: number = Math.sin(progressX * Math.PI * (4.8 + layer * 0.22) - phaseShift * 0.82)
|
|
|
- const crestShape: number = parabolaEnvelope * (0.52 + bandValue * 0.88)
|
|
|
- const swellOffset: number = mainSwell * localHeight * 0.18 + detailSwell * localHeight * 0.06
|
|
|
- const lift: number = crestShape * localHeight
|
|
|
- const y: number = baseY - lift + swellOffset
|
|
|
-
|
|
|
- if (i === 0) {
|
|
|
- ctx.moveTo(x, y)
|
|
|
- } else {
|
|
|
- ctx.lineTo(x, y)
|
|
|
- }
|
|
|
+ private fillWaveLayer(ctx: CanvasRenderingContext2D, bands: number[], width: number, height: number,
|
|
|
+ segmentCount: number, config: RippleWaveLayerConfig): void {
|
|
|
+ ctx.beginPath()
|
|
|
+ for (let i = 0; i <= segmentCount; i++) {
|
|
|
+ const progress: number = i / segmentCount
|
|
|
+ const x: number = progress * width
|
|
|
+ const bandValue: number = this.sampleBands(bands, progress + config.bandOffset)
|
|
|
+ const prevBand: number = this.sampleBands(bands, progress + config.bandOffset - 0.025)
|
|
|
+ const nextBand: number = this.sampleBands(bands, progress + config.bandOffset + 0.025)
|
|
|
+ const averageBand: number = (prevBand + bandValue + nextBand) / 3
|
|
|
+ const fftPeak: number = Math.max(0, bandValue * 2 - prevBand - nextBand)
|
|
|
+
|
|
|
+ const mainWave: number = Math.sin(x * config.mainFrequency + config.phaseShift)
|
|
|
+ const detailWave: number = Math.sin(x * config.detailFrequency + config.phaseShift * 1.18 + progress * Math.PI * 2)
|
|
|
+ const chopWave: number = Math.sin(x * config.chopFrequency + config.phaseShift * 1.46 + bandValue * 5.2)
|
|
|
+ const waveOffset: number = mainWave * config.amplitude +
|
|
|
+ detailWave * config.amplitude * config.detailScale * (0.48 + bandValue * 0.62 + config.surgeEnergy * 0.22) +
|
|
|
+ chopWave * config.amplitude * config.chopScale *
|
|
|
+ (0.24 + config.trebleEnergy * 1.22 + config.surgeEnergy * 0.72 + fftPeak * 0.70)
|
|
|
+ const fftOffset: number = (averageBand - 0.28) * config.bandAmplitude +
|
|
|
+ fftPeak * config.bandAmplitude * (0.72 + config.trebleEnergy * 1.18 + config.surgeEnergy * 0.60)
|
|
|
+ const y: number = this.clamp(config.baseY + waveOffset - fftOffset, 0, height)
|
|
|
+
|
|
|
+ if (i === 0) {
|
|
|
+ ctx.moveTo(x, y)
|
|
|
+ } else {
|
|
|
+ ctx.lineTo(x, y)
|
|
|
}
|
|
|
- ctx.strokeStyle = rgbaFromHex(hslToHex(hue, 0.88, 0.70), strokeAlpha)
|
|
|
- ctx.lineWidth = lineWidth
|
|
|
- ctx.stroke()
|
|
|
}
|
|
|
-
|
|
|
- const shimmerY: number = horizonY - waveHeight * (0.25 + energy * 0.15)
|
|
|
- const shimmerGradient: CanvasGradient = ctx.createLinearGradient(0, shimmerY - 8, 0, shimmerY + 18)
|
|
|
- shimmerGradient.addColorStop(0, 'rgba(255,255,255,0)')
|
|
|
- shimmerGradient.addColorStop(0.5, rgbaFromHex(coolColor, 0.08 + energy * 0.05))
|
|
|
- shimmerGradient.addColorStop(1, 'rgba(255,255,255,0)')
|
|
|
- ctx.fillStyle = shimmerGradient
|
|
|
- ctx.beginPath()
|
|
|
- ctx.rect(0, shimmerY - 10, width, 28)
|
|
|
+ ctx.lineTo(width, height)
|
|
|
+ ctx.lineTo(0, height)
|
|
|
+ ctx.closePath()
|
|
|
+ ctx.fillStyle = config.fillColor
|
|
|
ctx.fill()
|
|
|
}
|
|
|
|
|
|
- reset(): void {
|
|
|
- this.phase = 0
|
|
|
+ private sampleBands(bands: number[], progress: number): number {
|
|
|
+ if (bands.length === 1) {
|
|
|
+ return this.normalizeBand(bands[0])
|
|
|
+ }
|
|
|
+ const safeProgress: number = this.clamp(progress, 0, 1)
|
|
|
+ const position: number = safeProgress * (bands.length - 1)
|
|
|
+ const leftIndex: number = Math.floor(position)
|
|
|
+ const rightIndex: number = Math.min(bands.length - 1, leftIndex + 1)
|
|
|
+ const mix: number = position - leftIndex
|
|
|
+ const leftValue: number = this.normalizeBand(bands[leftIndex])
|
|
|
+ const rightValue: number = this.normalizeBand(bands[rightIndex])
|
|
|
+ return leftValue * (1 - mix) + rightValue * mix
|
|
|
}
|
|
|
+
|
|
|
+ private normalizeBand(value: number): number {
|
|
|
+ return Math.pow(this.clamp(value, 0, 1), 0.82)
|
|
|
+ }
|
|
|
+
|
|
|
+ private getBandRangeAverage(bands: number[], startProgress: number, endProgress: number): number {
|
|
|
+ if (bands.length === 0) {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ const startIndex: number = Math.floor(this.clamp(startProgress, 0, 1) * (bands.length - 1))
|
|
|
+ const endIndex: number = Math.max(startIndex, Math.floor(this.clamp(endProgress, 0, 1) * (bands.length - 1)))
|
|
|
+ let sum: number = 0
|
|
|
+ let count: number = 0
|
|
|
+ for (let i = startIndex; i <= endIndex; i++) {
|
|
|
+ sum += this.normalizeBand(bands[i])
|
|
|
+ count++
|
|
|
+ }
|
|
|
+ return count > 0 ? sum / count : 0
|
|
|
+ }
|
|
|
+
|
|
|
+ private clamp(value: number, min: number, max: number): number {
|
|
|
+ if (value < min) {
|
|
|
+ return min
|
|
|
+ }
|
|
|
+ if (value > max) {
|
|
|
+ return max
|
|
|
+ }
|
|
|
+ return value
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+interface RippleWaveLayerConfig {
|
|
|
+ baseY: number
|
|
|
+ amplitude: number
|
|
|
+ bandAmplitude: number
|
|
|
+ mainFrequency: number
|
|
|
+ detailFrequency: number
|
|
|
+ chopFrequency: number
|
|
|
+ detailScale: number
|
|
|
+ chopScale: number
|
|
|
+ phaseShift: number
|
|
|
+ trebleEnergy: number
|
|
|
+ surgeEnergy: number
|
|
|
+ bandOffset: number
|
|
|
+ fillColor: string
|
|
|
}
|