feat: visualizer overhaul (#1575)

This commit is contained in:
Phan An 2022-11-06 18:09:06 +01:00 committed by GitHub
parent 4854e56fdb
commit 5e283ef539
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 902 additions and 102 deletions

View file

@ -31,6 +31,7 @@
"select": "^1.1.2", "select": "^1.1.2",
"sketch-js": "^1.1.3", "sketch-js": "^1.1.3",
"slugify": "^1.0.2", "slugify": "^1.0.2",
"three": "^0.146.0",
"vue": "^3.2.32", "vue": "^3.2.32",
"vue-global-events": "^2.1.1", "vue-global-events": "^2.1.1",
"youtube-player": "^3.0.4" "youtube-player": "^3.0.4"
@ -49,6 +50,7 @@
"@types/lodash": "^4.14.150", "@types/lodash": "^4.14.150",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/pusher-js": "^4.2.2", "@types/pusher-js": "^4.2.2",
"@types/three": "^0.144.0",
"@types/youtube-player": "^5.5.2", "@types/youtube-player": "^5.5.2",
"@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^4.11.1", "@typescript-eslint/parser": "^4.11.1",

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
Volume: this.stub('Volume') Volume: this.stub('Volume')
}, },
provide: { provide: {
[CurrentSongKey]: factory<Song>('song', { [<symbol>CurrentSongKey]: factory<Song>('song', {
playback_state: 'Playing' playback_state: 'Playing'
}) })
} }

View file

@ -1,17 +1,15 @@
<template> <template>
<div class="extra-controls" data-testid="other-controls"> <div class="extra-controls" data-testid="other-controls">
<div class="wrapper"> <div class="wrapper">
<button <a
v-if="song?.playback_state === 'Playing'"
v-koel-tooltip.top v-koel-tooltip.top
class="visualizer-btn" class="visualizer-btn"
data-testid="toggle-visualizer-btn" data-testid="toggle-visualizer-btn"
title="Toggle the visualizer" href="/#/visualizer"
type="button" title="Show the visualizer"
@click.prevent="toggleVisualizer"
> >
<icon :icon="faBolt"/> <icon :icon="faBolt"/>
</button> </a>
<button <button
v-if="useEqualizer" v-if="useEqualizer"
@ -41,7 +39,6 @@ import Volume from '@/components/ui/Volume.vue'
const song = requireInjection(CurrentSongKey, ref(null)) const song = requireInjection(CurrentSongKey, ref(null))
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER') const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
const toggleVisualizer = () => eventBus.emit('TOGGLE_VISUALIZER')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -2,7 +2,7 @@
exports[`renders 1`] = ` exports[`renders 1`] = `
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81=""> <div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
<div class="wrapper" data-v-8bf5fe81=""><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button> <div class="wrapper" data-v-8bf5fe81=""><a class="visualizer-btn" data-testid="toggle-visualizer-btn" href="/#/visualizer" title="Show the visualizer" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></a>
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81=""> <!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
</div> </div>
</div> </div>

View file

@ -2,7 +2,6 @@ import { ref } from 'vue'
import { waitFor } from '@testing-library/vue' import { waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { albumStore, preferenceStore } from '@/stores' import { albumStore, preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols' import { CurrentSongKey } from '@/symbols'
@ -10,17 +9,17 @@ import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
import MainContent from './MainContent.vue' import MainContent from './MainContent.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private renderComponent (hasCurrentSong = false) { private renderComponent () {
return this.render(MainContent, { return this.render(MainContent, {
global: { global: {
provide: { provide: {
[CurrentSongKey]: ref(factory<Song>('song')) [<symbol>CurrentSongKey]: ref(factory<Song>('song'))
}, },
stubs: { stubs: {
AlbumArtOverlay, AlbumArtOverlay,
HomeScreen: this.stub(), // so that home overview requests are not made HomeScreen: this.stub(), // so that home overview requests are not made
Visualizer: this.stub('visualizer'), Visualizer: this.stub('visualizer')
}, }
} }
}) })
} }
@ -41,15 +40,5 @@ new class extends UnitTestCase {
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull()) await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
}) })
it('toggles visualizer', async () => {
const { getByTestId, queryByTestId } = this.renderComponent()
eventBus.emit('TOGGLE_VISUALIZER')
await waitFor(() => getByTestId('visualizer'))
eventBus.emit('TOGGLE_VISUALIZER')
await waitFor(() => expect(queryByTestId('visualizer')).toBeNull())
})
} }
} }

View file

@ -5,7 +5,7 @@
lists), so we use v-show. lists), so we use v-show.
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice. For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
--> -->
<Visualizer v-if="showingVisualizer"/> <VisualizerScreen v-if="screen === 'Visualizer'"/>
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/> <AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/>
<HomeScreen v-show="screen === 'Home'"/> <HomeScreen v-show="screen === 'Home'"/>
@ -34,7 +34,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue' import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus, requireInjection } from '@/utils' import { requireInjection } from '@/utils'
import { preferenceStore } from '@/stores' import { preferenceStore } from '@/stores'
import { useThirdPartyServices } from '@/composables' import { useThirdPartyServices } from '@/composables'
import { CurrentSongKey, RouterKey } from '@/symbols' import { CurrentSongKey, RouterKey } from '@/symbols'
@ -61,7 +61,7 @@ const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/Pr
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue')) const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/SearchSongResultsScreen.vue')) const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/SearchSongResultsScreen.vue'))
const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/NotFoundScreen.vue')) const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/NotFoundScreen.vue'))
const Visualizer = defineAsyncComponent(() => import('@/components/ui/Visualizer.vue')) const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens/VisualizerScreen.vue'))
const { useYouTube } = useThirdPartyServices() const { useYouTube } = useThirdPartyServices()
@ -69,13 +69,10 @@ const router = requireInjection(RouterKey)
const currentSong = requireInjection(CurrentSongKey, ref(null)) const currentSong = requireInjection(CurrentSongKey, ref(null))
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay') const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
const showingVisualizer = ref(false)
const screen = ref<ScreenName>('Home') const screen = ref<ScreenName>('Home')
router.onRouteChanged(route => (screen.value = route.screen)) router.onRouteChanged(route => (screen.value = route.screen))
eventBus.on('TOGGLE_VISUALIZER', () => (showingVisualizer.value = !showingVisualizer.value))
onMounted(() => router.resolve()) onMounted(() => router.resolve())
</script> </script>

View file

@ -0,0 +1,148 @@
<template>
<section id="vizContainer" :class="{ fullscreen: isFullscreen }" @dblclick="toggleFullscreen">
<div class="artifacts">
<div class="credits" v-if="selectedVisualizer">
<h3>{{ selectedVisualizer.name }}</h3>
<p class="text-secondary" v-if="selectedVisualizer.credits">
by {{ selectedVisualizer.credits.author }}
<a :href="selectedVisualizer.credits.url" target="_blank">
<icon :icon="faUpRightFromSquare"/>
</a>
</p>
</div>
<select v-model="selectedId">
<option disabled value="-1">Pick a visualizer</option>
<option v-for="v in visualizers" :key="v.id" :value="v.id">{{ v.name }}</option>
</select>
</div>
<div ref="el" class="viz"/>
</section>
</template>
<script lang="ts" setup>
import { faUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { logger } from '@/utils'
import { preferenceStore as preferences, visualizerStore } from '@/stores'
const visualizers = visualizerStore.all
let destroyVisualizer: () => void
const el = ref<HTMLElement>()
const selectedId = ref<Visualizer['id']>()
const isFullscreen = ref(false)
const render = async (viz: Visualizer) => {
if (!el.value) {
await nextTick()
await render(viz)
}
freeUp()
try {
destroyVisualizer = await viz.init(el.value!)
} catch (e) {
// in e.g., DOM testing, the call will fail due to the lack of proper API support
logger.warn('Failed to initialize visualizer', e)
}
}
const selectedVisualizer = ref<Visualizer>()
watch(selectedId, id => {
preferences.visualizer = id
selectedVisualizer.value = visualizerStore.getVisualizerById(id || 'default')!
render(selectedVisualizer.value)
})
const toggleFullscreen = () => {
isFullscreen.value ? document.exitFullscreen() : el.value?.requestFullscreen()
isFullscreen.value = !isFullscreen.value
}
onMounted(() => {
selectedId.value = preferences.visualizer || 'default'
if (!visualizerStore.getVisualizerById(selectedId.value)) {
selectedId.value = 'default'
}
})
const freeUp = () => {
destroyVisualizer?.()
el.value && (el.value.innerHTML = '')
}
onBeforeUnmount(() => freeUp())
</script>
<style lang="scss">
#vizContainer {
.viz {
height: 100%;
width: 100%;
positioN: absolute;
z-index: 0;
canvas {
transition: opacity 0.3s;
height: 100%;
width: 100%;
}
}
.artifacts {
position: absolute;
z-index: 1;
height: 100%;
width: 100%;
top: 0;
left: 0;
opacity: 0;
padding: 24px;
transition: opacity 0.3s ease-in-out;
}
&:hover {
.artifacts {
opacity: 1;
}
}
.credits {
padding: 14px 28px 14px 14px;
background: rgba(0, 0, 0, .5);
width: fit-content;
position: absolute;
bottom: 24px;
h3 {
font-size: 1.2rem;
margin-bottom: .3rem;
}
a {
margin-left: .5rem;
display: inline-block;
vertical-align: middle;
}
}
select {
position: absolute;
bottom: 24px;
right: 24px;
}
&.fullscreen {
// :fullscreen pseudo support is kind of buggy, so we use a class instead.
background: var(--color-bg-primary);
.close {
opacity: 0 !important;
}
}
}
</style>

View file

@ -1,63 +0,0 @@
<template>
<div
id="vizContainer"
ref="el"
:class="{ fullscreen: isFullscreen }"
data-testid="visualizer"
@dblclick="toggleFullscreen"
>
<CloseModalBtn class="close" @click="hide"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import initVisualizer from '@/utils/visualizer'
import { eventBus, logger } from '@/utils'
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
const el = ref<HTMLElement>()
const isFullscreen = ref(false)
const toggleFullscreen = () => {
isFullscreen.value ? document.exitFullscreen() : el.value?.requestFullscreen()
isFullscreen.value = !isFullscreen.value
}
const hide = () => eventBus.emit('TOGGLE_VISUALIZER')
onMounted(() => {
try {
initVisualizer(el.value!)
} catch (e) {
logger.warn('Failed to initialize visualizer', e)
// in e.g., DOM testing, the call will fail due to the lack of proper API support
}
})
</script>
<style lang="scss" scoped>
#vizContainer {
position: relative;
&.fullscreen {
// :fullscreen pseudo support is kind of buggy, so we use a class instead.
background: var(--color-bg-primary);
.close {
opacity: 0 !important;
}
}
.close {
opacity: 0;
}
&:hover {
.close {
opacity: 1;
}
}
}
</style>

View file

@ -6,7 +6,6 @@ export type EventName =
| 'FOCUS_SEARCH_FIELD' | 'FOCUS_SEARCH_FIELD'
| 'PLAY_YOUTUBE_VIDEO' | 'PLAY_YOUTUBE_VIDEO'
| 'INIT_EQUALIZER' | 'INIT_EQUALIZER'
| 'TOGGLE_VISUALIZER'
| 'SEARCH_KEYWORDS_CHANGED' | 'SEARCH_KEYWORDS_CHANGED'
| 'SONG_CONTEXT_MENU_REQUESTED' | 'SONG_CONTEXT_MENU_REQUESTED'

View file

@ -3,3 +3,4 @@ export * from './acceptedMediaTypes'
export * from './genres' export * from './genres'
export * from './routes' export * from './routes'
export * from './audio' export * from './audio'
export * from './visualizers'

View file

@ -90,6 +90,10 @@ export const routes: Route[] = [
path: '/genres/(?<name>\.+)', path: '/genres/(?<name>\.+)',
screen: 'Genre' screen: 'Genre'
}, },
{
path: '/visualizer',
screen: 'Visualizer'
},
{ {
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
screen: 'Queue', screen: 'Queue',

View file

@ -0,0 +1,38 @@
export const visualizers: Visualizer[] = [
{
id: 'default',
name: 'Color Pills',
init: async (container) => (await import('@/visualizers/default')).init(container),
credits: {
author: 'Justin Windle (@soulwire)',
url: 'https://codepen.io/soulwire/pen/Dscga'
}
},
{
id: 'plane-mesh',
name: 'Plane Mesh',
init: async (container) => (await import('@/visualizers/plane-mesh')).init(container),
credits: {
author: 'Steven Marelly (@l1ve4code)',
url: 'https://github.com/l1ve4code/3d-music-visualizer'
}
},
{
id: 'waveform',
name: 'Waveform',
init: async (container) => (await import('@/visualizers/waveform')).init(container),
credits: {
author: 'Suboptimal Engineer (@SuboptimalEng)',
url: 'https://github.com/SuboptimalEng/gamedex/tree/main/audio-visualizer'
}
},
{
id: 'fluid-cube',
name: 'Fluid Cube',
init: async (container) => (await import('@/visualizers/fluid-cube')).init(container),
credits: {
author: 'Radik (@H2xDev)',
url: 'https://codepen.io/H2xDev/pen/rRRGbv'
}
}
]

View file

@ -15,6 +15,7 @@ export const audioService = {
source: null as unknown as MediaElementAudioSourceNode, source: null as unknown as MediaElementAudioSourceNode,
element: null as unknown as HTMLMediaElement, element: null as unknown as HTMLMediaElement,
preampGainNode: null as unknown as GainNode, preampGainNode: null as unknown as GainNode,
analyzer: null as unknown as AnalyserNode,
bands: [] as Band[], bands: [] as Band[],
@ -24,7 +25,10 @@ export const audioService = {
this.context = new AudioContext() this.context = new AudioContext()
this.preampGainNode = this.context.createGain() this.preampGainNode = this.context.createGain()
this.source = this.context.createMediaElementSource(this.element) this.source = this.context.createMediaElementSource(this.element)
this.source.connect(this.preampGainNode) this.analyzer = this.context.createAnalyser()
this.source.connect(this.analyzer)
this.analyzer.connect(this.preampGainNode)
const config = equalizerStore.getConfig() const config = equalizerStore.getConfig()

View file

@ -15,3 +15,4 @@ export * from './songStore'
export * from './themeStore' export * from './themeStore'
export * from './userStore' export * from './userStore'
export * from './genreStore' export * from './genreStore'
export * from './visualizerStore'

View file

@ -14,6 +14,7 @@ interface Preferences extends Record<string, any> {
showAlbumArtOverlay: boolean showAlbumArtOverlay: boolean
lyricsZoomLevel: number | null lyricsZoomLevel: number | null
theme?: Theme['id'] | null theme?: Theme['id'] | null
visualizer?: Visualizer['id'] | null
} }
const preferenceStore = { const preferenceStore = {
@ -37,7 +38,8 @@ const preferenceStore = {
supportBarNoBugging: false, supportBarNoBugging: false,
showAlbumArtOverlay: true, showAlbumArtOverlay: true,
lyricsZoomLevel: 1, lyricsZoomLevel: 1,
theme: null theme: null,
visualizer: 'default'
}), }),
init (user: User): void { init (user: User): void {

View file

@ -0,0 +1,12 @@
import { visualizers } from '@/config'
export const visualizerStore = {
get all () {
return visualizers
},
getVisualizerById (id: string) {
return visualizers.find(visualizer => visualizer.id === id)
}
}

View file

@ -389,3 +389,13 @@ type Genre = {
} }
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube' type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
type Visualizer = {
init: (container: HTMLElement) => Promise<Closure>
id: string
name: string
credits?: {
author: string
url: string
}
}

View file

@ -1,9 +1,7 @@
import Sketch from 'sketch-js' import Sketch from 'sketch-js'
import { audioService } from '@/services' import { audioService } from '@/services'
import { random, sample } from 'lodash' import { random, sample } from 'lodash'
import { noop } from '@/utils'
// Audio visualization originally created by Justin Windle (@soulwire)
// as seen on https://codepen.io/soulwire/pen/Dscga
const NUM_PARTICLES = 128 const NUM_PARTICLES = 128
const NUM_BANDS = 128 const NUM_BANDS = 128
@ -48,13 +46,12 @@ class AudioAnalyser {
this.audio = audioService.element this.audio = audioService.element
this.source = audioService.source this.source = audioService.source
this.analyser = audioService.context.createAnalyser() this.analyser = audioService.analyzer
this.analyser.smoothingTimeConstant = this.smoothing this.analyser.smoothingTimeConstant = this.smoothing
this.analyser.fftSize = this.bandCount * 2 this.analyser.fftSize = this.bandCount * 2
this.bands = new Uint8Array(this.analyser.frequencyBinCount) this.bands = new Uint8Array(this.analyser.frequencyBinCount)
this.source.connect(this.analyser)
this.update() this.update()
} }
@ -149,7 +146,7 @@ class Particle {
} }
} }
export default (container: HTMLElement) => { export const init = (container: HTMLElement) => {
const particles: Particle[] = [] const particles: Particle[] = []
Sketch.create({ Sketch.create({
@ -181,4 +178,6 @@ export default (container: HTMLElement) => {
}) })
} }
}) })
return noop
} }

View file

@ -0,0 +1,116 @@
import { audioService } from '@/services'
import { logger } from '@/utils'
import shaders from './shaders'
export const init = async (container: HTMLElement) => {
const gl = document.createElement('canvas').getContext('webgl')!
const postctx = container.appendChild(document.createElement('canvas')).getContext('2d')!
const postprocess = postctx.canvas
const canvas = gl.canvas
const cubeSize = 15
const analyzer = audioService.analyzer
analyzer.smoothingTimeConstant = 0.2
analyzer.fftSize = 128
let spectrumData = new Uint8Array(analyzer.frequencyBinCount)
const compileShader = (type, source) => {
const shader = gl.createShader(type)!
gl.shaderSource(shader, source)
gl.compileShader(shader)
const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS)
if (status) return shader
logger.error('shader compile error', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
}
const createProgram = function (vertexShader, fragmentShader) {
const program = gl.createProgram()!
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
const status = gl.getProgramParameter(program, gl.LINK_STATUS)
if (status) return program
logger.error('program link error', gl.getProgramInfoLog(program))
gl.deleteProgram(program)
}
const vertexShader = compileShader(gl.VERTEX_SHADER, shaders.vertex(cubeSize))
const fragmentShader = compileShader(gl.FRAGMENT_SHADER, shaders.fragment())
const program = createProgram(vertexShader, fragmentShader)!
const aPosition = gl.getAttribLocation(program, 'a_pos')
const uResolution = gl.getUniformLocation(program, 'u_res')
const uFrame = gl.getUniformLocation(program, 'u_frame')
const uSpectrumValue = gl.getUniformLocation(program, 'u_spectrumValue')
const vertices: number[] = []
const vertexBuffer = gl.createBuffer()
let frame = 0
const render = () => {
frame++
analyzer.getByteFrequencyData(spectrumData)
// Transfer spectrum data to shader program
gl.uniform1iv(uSpectrumValue, spectrumData)
if (postprocess.width !== postprocess.offsetWidth || postprocess.height !== postprocess.offsetHeight) {
postprocess.width = postprocess.offsetWidth
postprocess.height = postprocess.offsetHeight
canvas.width = postprocess.width
canvas.height = postprocess.height
gl.uniform2fv(uResolution, [canvas.width, canvas.height])
gl.viewport(0, 0, canvas.width, canvas.height)
}
gl.uniform1f(uFrame, frame)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.POINTS, 0, vertices.length / 3)
// Make Bloom
postctx.globalAlpha = 1
postctx.drawImage(canvas, 0, 0)
postctx.filter = 'blur(4px)'
postctx.globalCompositeOperation = 'screen'
postctx.drawImage(canvas, 0, 0)
postctx.globalCompositeOperation = 'source-over'
postctx.filter = 'blur(0)'
requestAnimationFrame(render)
}
gl.clearColor(0, 0, 0, 1)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.useProgram(program)
gl.uniform2fv(uResolution, new Float32Array([canvas.width, canvas.height]))
for (let i = 0; i < cubeSize ** 3; i++) {
let x = (i % cubeSize)
let y = Math.floor(i / cubeSize) % cubeSize
let z = Math.floor(i / cubeSize ** 2)
x -= cubeSize / 2 - 0.5
y -= cubeSize / 2 - 0.5
z -= cubeSize / 2 - 0.5
vertices.push(x)
vertices.push(y)
vertices.push(z)
}
gl.enableVertexAttribArray(aPosition)
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
render()
return () => gl?.getExtension('WEBGL_lose_context')?.loseContext()
}

View file

@ -0,0 +1,110 @@
export default {
vertex: (cubeSize: number) => `
attribute vec3 a_pos;
uniform vec2 u_res;
uniform float u_frame;
uniform int u_spectrumValue[128];
varying float v_frame;
varying vec3 vv_pos;
void main () {
v_frame = u_frame;
float pi = 3.141592653589793;
float rad = u_frame / 2.0 / 180.0 * pi;
int spectrumIndex = 12 + int(mod(a_pos.x + ${Math.floor(cubeSize / 2)}.0, ${cubeSize}.0) + mod(a_pos.y + ${Math.floor(cubeSize / 2)}.0, ${cubeSize ** 2}.0) + (a_pos.z + ${Math.floor(cubeSize / 2)}.0) / ${cubeSize ** 2}.0);
float value = float(u_spectrumValue[spectrumIndex]) / 100.0;
vec3 v_pos = a_pos;
vec3 t = vec3(1, 1, 1);
vv_pos = v_pos;
float dist = abs(${Math.floor(cubeSize / 2)}.0 - sqrt(vv_pos.x * vv_pos.x + vv_pos.y * vv_pos.y + vv_pos.z * vv_pos.z));
t.x = v_pos.x * cos(rad) + v_pos.z * sin(rad);
t.y = v_pos.y;
t.z = - v_pos.x * sin(rad) + v_pos.z * cos(rad);
v_pos = t;
t.x = v_pos.x * cos(rad) - v_pos.y * sin(rad);
t.y = v_pos.x * sin(rad) + v_pos.y * cos(rad);
t.z = v_pos.z;
v_pos = t;
t.x = v_pos.x;
t.y = v_pos.y * cos(rad) - v_pos.z * sin(rad);
t.z = v_pos.y * sin(rad) + v_pos.z * cos(rad);
v_pos = t;
v_pos.z -= 20.0;
// Make reaction on spectrum
v_pos.z += value * dist;
v_pos.y += value / 100.0;
v_pos.x += sin(u_frame / 30.0 + v_pos.y / 4.0) * 1.2;
v_pos.y += cos(u_frame / 20.0 + v_pos.z / 5.0) * 1.0;
v_pos.x /= v_pos.z;
v_pos.y /= v_pos.z;
v_pos.x /= u_res.x / u_res.y;
gl_Position = vec4(v_pos.xy, 0.0, 1.0);
gl_PointSize = dist;
}`,
fragment: () => `
precision mediump float;
uniform vec4 u_color;
varying float v_frame;
varying vec3 vv_pos;
float hue2rgb(float f1, float f2, float hue) {
if (hue < 0.0)
hue += 1.0;
else if (hue > 1.0)
hue -= 1.0;
float res;
if ((6.0 * hue) < 1.0)
res = f1 + (f2 - f1) * 6.0 * hue;
else if ((2.0 * hue) < 1.0)
res = f2;
else if ((3.0 * hue) < 2.0)
res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0;
else
res = f1;
return res;
}
vec3 hsl2rgb(vec3 hsl) {
vec3 rgb;
if (hsl.y == 0.0) {
rgb = vec3(hsl.z); // Luminance
} else {
float f2;
if (hsl.z < 0.5)
f2 = hsl.z * (1.0 + hsl.y);
else
f2 = hsl.z + hsl.y - hsl.y * hsl.z;
float f1 = 2.0 * hsl.z - f2;
rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0));
rgb.g = hue2rgb(f1, f2, hsl.x);
rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0));
}
return rgb;
}
vec3 hsl2rgb(float h, float s, float l) {
return hsl2rgb(vec3(h, s, l));
}
void main () {
float dist = sqrt(vv_pos.x * vv_pos.x + vv_pos.y * vv_pos.y + vv_pos.z * vv_pos.z);
float i_frame = mod(v_frame + dist * 20.0, 360.0);
gl_FragColor = vec4(hsl2rgb((i_frame) / 360.0, 1.0, .5), 1.0);
}`
}

View file

@ -0,0 +1,121 @@
import * as THREE from 'three'
import shaders from './shaders'
import planeMeshParameters from './planeMeshParameters'
import { audioService } from '@/services'
export const init = (container: HTMLElement) => {
const uniforms = {
u_time: {
type: 'f',
value: 2.0
},
u_amplitude: {
type: 'f',
value: 4.0
},
u_data_arr: {
type: 'float[64]',
value: new Uint8Array()
}
}
const analyser = audioService.analyzer
analyser.fftSize = 1024
const dataArray = new Uint8Array(analyser.frequencyBinCount)
const width = container.clientWidth
const height = container.clientHeight
const scene = new THREE.Scene()
const ambientLight = new THREE.AmbientLight(0xaaaaaa)
ambientLight.castShadow = false
const spotLight = new THREE.SpotLight(0xffffff)
spotLight.intensity = 0.9
spotLight.position.set(-10, 40, 20)
spotLight.castShadow = true
const camera = new THREE.PerspectiveCamera(
85,
width / height,
1,
1000
)
camera.position.z = 80
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
renderer.setClearAlpha(0)
container.appendChild(renderer.domElement)
const planeGeometry = new THREE.PlaneGeometry(64, 64, 64, 64)
const planeMaterial = new THREE.ShaderMaterial({
uniforms,
vertexShader: shaders.vertex,
fragmentShader: shaders.fragment,
wireframe: true
})
planeMeshParameters.forEach(item => {
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial)
if (item.rotation.x == undefined) {
planeMesh.rotation.y = item.rotation.y
} else {
planeMesh.rotation.x = item.rotation.x
}
planeMesh.scale.x = item.scale
planeMesh.scale.y = item.scale
planeMesh.scale.z = item.scale
planeMesh.position.x = item.position.x
planeMesh.position.y = item.position.y
planeMesh.position.z = item.position.z
scene.add(planeMesh)
})
scene.add(ambientLight)
scene.add(spotLight)
const render = () => {
analyser.getByteFrequencyData(dataArray)
uniforms.u_data_arr.value = dataArray
camera.rotation.z += 0.001
renderer.render(scene, camera)
}
const animate = () => {
requestAnimationFrame(animate)
render()
}
const windowResizeHandler = () => {
const width = container.clientWidth
const height = container.clientHeight
renderer.setSize(width, height)
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.domElement.width = width
renderer.domElement.height = height
}
const wheelHandler = event => {
const val = camera.position.z + event.deltaY / 100
camera.position.z = Math.min(Math.max(val, 0), 256)
}
window.addEventListener('resize', windowResizeHandler)
document.addEventListener('wheel', wheelHandler)
animate()
return () => {
renderer.domElement.remove()
renderer.dispose()
window.removeEventListener('resize', windowResizeHandler)
document.removeEventListener('wheel', wheelHandler)
}
}

View file

@ -0,0 +1,92 @@
const planeMeshParameters = [
{
rotation: {
x: -Math.PI / 2
},
scale: 2,
position: {
x: 0,
y: 40,
z: 10
}
},
{
rotation: {
x: Math.PI / 2
},
scale: 2,
position: {
x: 0,
y: -40,
z: 10
}
},
{
rotation: {
y: Math.PI / 2
},
scale: 2,
position: {
x: 40,
y: 0,
z: 10
}
},
{
rotation: {
y: -Math.PI / 2
},
scale: 2,
position: {
x: -40,
y: 0,
z: 10
}
},
{
rotation: {
x: -Math.PI / 2
},
scale: 2,
position: {
x: 0,
y: 40,
z: -118
}
},
{
rotation: {
x: Math.PI / 2
},
scale: 2,
position: {
x: 0,
y: -40,
z: -118
}
},
{
rotation: {
y: Math.PI / 2
},
scale: 2,
position: {
x: 40,
y: 0,
z: -118
}
},
{
rotation: {
y: -Math.PI / 2
},
scale: 2,
position: {
x: -40,
y: 0,
z: -118
}
}
]
export default planeMeshParameters

View file

@ -0,0 +1,36 @@
const shaders = {
vertex: `
varying float x;
varying float y;
varying float z;
varying vec3 vUv;
uniform float u_time;
uniform float u_amplitude;
uniform float[64] u_data_arr;
void main() {
vUv = position;
x = abs(position.x);
y = abs(position.y);
float floor_x = round(x);
float floor_y = round(y);
float x_multiplier = (64.0 - x) / 4.0;
float y_multiplier = (64.0 - y) / 4.0;
z = sin(u_data_arr[int(floor_x)] / 40.0 + u_data_arr[int(floor_y)] / 40.0) * u_amplitude;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, z, 1.0);
}
`,
fragment: `
varying float x;
varying float y;
varying float z;
varying vec3 vUv;
uniform float u_time;
uniform float[64] u_data_arr;
void main() {
// gl_FragColor = vec4((u_data_arr[32])/205.0, 0, (u_data_arr[8])/205.0, 1.0);
gl_FragColor = vec4((64.0 - abs(x)) / 32.0, (32.0 - abs(y)) / 32.0, (abs(x + y) / 2.0) / 32.0, 1.0);
}
`
}
export default shaders

View file

@ -0,0 +1,55 @@
import * as THREE from 'three'
import SceneInit from './lib/SceneInit'
import shaders from './lib/shaders'
import { audioService } from '@/services'
export const init = (container: HTMLElement) => {
const sceneInit = new SceneInit(container)
const analyser = audioService.analyzer
analyser.fftSize = 512
let dataArray = new Uint8Array(analyser.frequencyBinCount)
const uniforms = {
u_time: {
type: 'f',
value: 1.0
},
u_amplitude: {
type: 'f',
value: 3.0
},
u_data_arr: {
type: 'float[64]',
value: dataArray
}
}
const planeGeometry = new THREE.PlaneGeometry(64, 64, 64, 64)
const planeCustomMaterial = new THREE.ShaderMaterial({
uniforms,
vertexShader: shaders.vertex,
fragmentShader: shaders.fragment,
wireframe: true
})
const planeMesh = new THREE.Mesh(planeGeometry, planeCustomMaterial)
planeMesh.rotation.x = -Math.PI / 2 + Math.PI / 4
planeMesh.scale.x = 2
planeMesh.scale.y = 2
planeMesh.scale.z = 2
planeMesh.position.y = 8
sceneInit.scene.add(planeMesh)
const getByteFrequency = () => {
analyser.getByteFrequencyData(dataArray)
requestAnimationFrame(getByteFrequency)
}
getByteFrequency()
sceneInit.animate()
return () => sceneInit.destroy()
}

View file

@ -0,0 +1,83 @@
import * as THREE from 'three'
export default class SceneInit {
private readonly fov: number
private container: HTMLElement
private readonly camera: THREE.PerspectiveCamera
private clock: THREE.Clock
public scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private uniforms: any
private readonly onWindowResize: () => void
private readonly onDocumentWheel: (e: WheelEvent) => void
constructor (container: HTMLElement, fov = 36) {
this.container = container
this.fov = fov
this.camera = new THREE.PerspectiveCamera(
this.fov,
this.container.clientWidth / this.container.clientHeight,
1,
1000
)
this.camera.position.z = 128
this.clock = new THREE.Clock()
this.scene = new THREE.Scene()
this.uniforms = {
u_time: { type: 'f', value: 1.0 },
colorB: { type: 'vec3', value: new THREE.Color(0xfff000) },
colorA: { type: 'vec3', value: new THREE.Color(0xffffff) }
}
this.renderer = new THREE.WebGLRenderer({
antialias: true
})
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
container.appendChild(this.renderer.domElement)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
ambientLight.castShadow = false
this.scene.add(ambientLight)
const spotLight = new THREE.SpotLight(0xffffff, 0.55)
spotLight.castShadow = true
spotLight.position.set(0, 80, 10)
this.scene.add(spotLight)
this.onWindowResize = () => {
this.camera.aspect = this.container.clientWidth / this.container.clientHeight
this.camera.updateProjectionMatrix()
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
}
this.onDocumentWheel = (event: WheelEvent) => {
const val = this.camera.position.z + event.deltaY / 100
this.camera.position.z = Math.min(Math.max(val, 84), 256)
}
window.addEventListener('resize', this.onWindowResize, false)
document.addEventListener('wheel', this.onDocumentWheel, false)
}
animate () {
requestAnimationFrame(this.animate.bind(this))
this.render()
}
render () {
this.uniforms.u_time.value += this.clock.getDelta()
this.renderer.render(this.scene, this.camera)
}
destroy () {
window.removeEventListener('resize', this.onWindowResize, false)
document.removeEventListener('wheel', this.onDocumentWheel, false)
this.renderer.domElement.remove()
this.renderer.dispose()
}
}

View file

@ -0,0 +1,30 @@
export default {
vertex: `
varying float x;
varying float y;
varying float z;
varying vec3 vUv;
uniform float u_time;
uniform float u_amplitude;
uniform float[64] u_data_arr;
void main() {
vUv = position;
x = abs(position.x);
y = abs(position.y);
float floor_x = round(x);
float floor_y = round(y);
z = sin(u_data_arr[int(floor_x)] / 50.0 + u_data_arr[int(floor_y)] / 20.0) * u_amplitude * 2.0;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, z, 1.0);
}
`,
fragment: `
varying float x;
varying float y;
varying float z;
varying vec3 vUv;
uniform float u_time;
void main() {
gl_FragColor = vec4((32.0 - abs(x)) / 32.0, (32.0 - abs(y)) / 32.0, (abs(x + y) / 2.0) / 32.0, 1.0);
}
`
}

View file

@ -1291,6 +1291,18 @@
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef"
integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==
"@types/three@^0.144.0":
version "0.144.0"
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.144.0.tgz#a154f40122dbc3668c5424a5373f3965c6564557"
integrity sha512-psvEs6q5rLN50jUYZ3D4pZMfxTbdt3A243blt0my7/NcL6chaCZpHe2csbCtx0SOD9fI/XnF3wnVUAYZGqCSYg==
dependencies:
"@types/webxr" "*"
"@types/webxr@*":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.0.tgz#aae1cef3210d88fd4204f8c33385a0bbc4da07c9"
integrity sha512-IUMDPSXnYIbEO2IereEFcgcqfDREOgmbGqtrMpVPpACTU6pltYLwHgVkrnYv0XhWEcjio9sYEfIEzgn3c7nDqA==
"@types/yauzl@^2.9.1": "@types/yauzl@^2.9.1":
version "2.10.0" version "2.10.0"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
@ -6003,6 +6015,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
three@^0.146.0:
version "0.146.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.146.0.tgz#fd80f0d128ab4bb821a02191ae241e4e6326f17a"
integrity sha512-1lvNfLezN6OJ9NaFAhfX4sm5e9YCzHtaRgZ1+B4C+Hv6TibRMsuBAM5/wVKzxjpYIlMymvgsHEFrrigEfXnb2A==
throttleit@^1.0.0: throttleit@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"