diff --git a/src/components/themes/contour/fragment.glsl b/src/components/themes/contour/fragment.glsl new file mode 100644 index 0000000..2dbf667 --- /dev/null +++ b/src/components/themes/contour/fragment.glsl @@ -0,0 +1,123 @@ +#extension GL_OES_standard_derivatives : enable +#ifdef GL_ES +precision highp float; +#endif + +uniform float uTime; +uniform vec2 uResolution; + +// Zoom/scale of the noise pattern — higher values zoom in, revealing more detail +uniform float uScale; + +// Number of stripes/contour lines across the noise field +uniform float uScaleContour; + +// Line width: controls how much of the contour field is visible (0 = thin hairlines, 1 = wide bands) +uniform float uWidth; + +// Glow brightness: overall intensity of the bloom halo +uniform float uGlow; + +// Glow width: 0 = wide diffuse bloom, 1 = tight narrow halo (controls pow exponent) +uniform float uGlowWidth; + +// Max line band: caps the upper visible threshold so all lines have similar spatial width +// smaller = more uniform thin lines, larger = allows wide bands +uniform float uMaxLine; + +// First gradient color (RGB) +uniform vec3 uColor1; + +// Second gradient color (RGB) +uniform vec3 uColor2; + +const float M_PI = 3.14159265; +const int NUM_OCTAVES = 2; + +// Hash functions — map domain to pseudo-random [0, 1) +float hash11(float t) { + return fract(sin(t * 56789.0) * 56789.0); +} + +float hash21(vec2 uv) { + return hash11(hash11(uv[0]) + 2.0 * hash11(uv[1])); +} + +// Returns a unit-length gradient vector for a given grid cell +vec2 hashGradient2(vec2 uv) { + float t = hash21(uv); + return vec2(cos(2.0 * M_PI * t), sin(2.0 * M_PI * t)); +} + +// Bilinear mix helper +float mix2(float f00, float f10, float f01, float f11, vec2 uv) { + return mix(mix(f00, f10, uv[0]), mix(f01, f11, uv[0]), uv[1]); +} + +// Rotate a 2D vector by angle r (radians) +vec2 rotate2(vec2 uv, float r) { + mat2 R = mat2(cos(r), sin(r), -sin(r), cos(r)); + return R * uv; +} + +// Gradient (Perlin) noise with optional gradient rotation for animation +float gradientNoise(vec2 uv, float r) { + vec2 uvi = floor(uv); + vec2 uvf = uv - uvi; + vec2 g00 = rotate2(hashGradient2(uvi + vec2(0.0, 0.0)), r); + vec2 g10 = rotate2(hashGradient2(uvi + vec2(1.0, 0.0)), r); + vec2 g01 = rotate2(hashGradient2(uvi + vec2(0.0, 1.0)), r); + vec2 g11 = rotate2(hashGradient2(uvi + vec2(1.0, 1.0)), r); + float f00 = dot(g00, uvf - vec2(0.0, 0.0)); + float f10 = dot(g10, uvf - vec2(1.0, 0.0)); + float f01 = dot(g01, uvf - vec2(0.0, 1.0)); + float f11 = dot(g11, uvf - vec2(1.0, 1.0)); + float t = mix2(f00, f10, f01, f11, smoothstep(vec2(0.0), vec2(1.0), uvf)); + // Normalize: theoretical bounds are +-1/sqrt(2) ≈ +-0.7 + return (t / 0.7 + 1.0) * 0.5; +} + +// Fractional Brownian Motion — stacks octaves for richer noise detail +float noise(vec2 uv, float r) { + float result = 0.0; + for (int i = 0; i < NUM_OCTAVES; i++) { + float p = pow(2.0, float(i)); + result += gradientNoise(uv * p, r) / p; + } + // Normalize result back to [0, 1] + result /= (pow(2.0, float(NUM_OCTAVES)) - 1.0) / pow(2.0, float(NUM_OCTAVES - 1)); + return result; +} + +// Maps noise value to a periodic wave that creates the contour bands +float wave(float t) { + return 0.5 * (1.0 - cos(uScaleContour * M_PI * t)); +} + +void main() { + vec2 uv = uScale * gl_FragCoord.xy / uResolution.y; + + // Animate by rotating noise gradients over time (0.628 ≈ 0.1 * 2π) + float r = 0.628 * uTime; + + float noise_fac = noise(uv, r); + float contour_fac = wave(noise_fac); + vec3 color = mix(uColor1, uColor2, noise_fac); + + float lo = 1.0 - uWidth; + float hi = lo + uMaxLine; + float fw = fwidth(contour_fac); + + // Crisp anti-aliased line: only pixels in the [lo, hi] band are visible, + // bounding maximum spatial width so all lines appear roughly equal-sized + float line = smoothstep(lo - fw, lo + fw, contour_fac) + - smoothstep(hi - fw, hi + fw, contour_fac); + + // Glow: tent-shaped halo centered on the band with controlled falloff on both sides + float glowExponent = mix(1.0, 16.0, uGlowWidth); + float glow_t = clamp((contour_fac - lo + 0.1) / (uMaxLine + 0.2), 0.0, 1.0); + float glow_bell = 1.0 - abs(glow_t * 2.0 - 1.0); + float glow = uGlow * pow(glow_bell, glowExponent); + + gl_FragColor = vec4(color * max(line, glow), 1.0); +} diff --git a/src/components/themes/contour/index.tsx b/src/components/themes/contour/index.tsx new file mode 100644 index 0000000..7c78057 --- /dev/null +++ b/src/components/themes/contour/index.tsx @@ -0,0 +1,82 @@ +import { defineComponent, ref, computed, onMounted, onBeforeUnmount } from 'vue' +import GL from '@/utils/gl' +import { useThemeStore } from '@/store/theme' +import { hexToRgb } from '@/utils/color' +import fragmentShader from './fragment.glsl' +import vertexShader from './vertex.glsl' + +let render: GL + +export default defineComponent({ + name: 'ContourTheme', + setup() { + const themeStore = useThemeStore() + const canvasRef = ref(null) + + const animationSpeed = computed(() => themeStore.getThemeInput('animation-speed')?.value as number || 5) + const pxratio = computed(() => themeStore.getThemeInput('pxratio')?.value as number || 0.8) + const scale = computed(() => themeStore.getThemeInput('scale')?.value as number || 3.0) + const contour = computed(() => themeStore.getThemeInput('contour')?.value as number || 32.0) + const width = computed(() => themeStore.getThemeInput('width')?.value as number || 0.15) + const glow = computed(() => themeStore.getThemeInput('glow')?.value as number || 0.15) + const glowWidth = computed(() => themeStore.getThemeInput('glow-width')?.value as number || 0.5) + const maxLine = computed(() => themeStore.getThemeInput('max-line')?.value as number || 0.3) + const color1 = computed(() => themeStore.getThemeInput('color-active')?.value as string || '#FF00FF') + const color2 = computed(() => themeStore.getThemeInput('color-second')?.value as string || '#00FFFF') + + onMounted(() => { + render = new GL( + canvasRef.value!, + vertexShader, + fragmentShader, + window.innerWidth, + window.innerHeight, + { + extensions: ['OES_standard_derivatives'], + renderOptions: { externalTimeUse: true }, + renderHook() { + const gl = this as unknown as GL + + if (!gl.programInfo.uniforms.scale) { + gl.programInfo.uniforms.scale = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uScale') + gl.programInfo.uniforms.contour = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uScaleContour') + gl.programInfo.uniforms.width = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uWidth') + gl.programInfo.uniforms.glow = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uGlow') + gl.programInfo.uniforms.glowWidth = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uGlowWidth') + gl.programInfo.uniforms.maxLine = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uMaxLine') + gl.programInfo.uniforms.color1 = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uColor1') + gl.programInfo.uniforms.color2 = gl.ctx.getUniformLocation(gl.program as WebGLProgram, 'uColor2') + } + + gl.pxratio = pxratio.value + gl.time += animationSpeed.value / 500 + + const c1 = hexToRgb(color1.value) + const c2 = hexToRgb(color2.value) + + gl.ctx.uniform1f(gl.programInfo.uniforms.time, gl.time) + gl.ctx.uniform1f(gl.programInfo.uniforms.scale, scale.value) + gl.ctx.uniform1f(gl.programInfo.uniforms.contour, contour.value) + gl.ctx.uniform1f(gl.programInfo.uniforms.width, width.value) + gl.ctx.uniform1f(gl.programInfo.uniforms.glow, glow.value) + gl.ctx.uniform1f(gl.programInfo.uniforms.glowWidth, glowWidth.value) + gl.ctx.uniform1f(gl.programInfo.uniforms.maxLine, maxLine.value) + gl.ctx.uniform3f(gl.programInfo.uniforms.color1, c1[0] / 255, c1[1] / 255, c1[2] / 255) + gl.ctx.uniform3f(gl.programInfo.uniforms.color2, c2[0] / 255, c2[1] / 255, c2[2] / 255) + } + } + ) + render.running = true + }) + + onBeforeUnmount(() => { + render.running = false + }) + + return () => ( +
+ +
+ ) + } +}) diff --git a/src/components/themes/contour/vertex.glsl b/src/components/themes/contour/vertex.glsl new file mode 100644 index 0000000..9d68485 --- /dev/null +++ b/src/components/themes/contour/vertex.glsl @@ -0,0 +1,5 @@ +attribute vec2 aVertexPosition; + +void main() { + gl_Position = vec4(aVertexPosition, 0.0, 1.0); +} diff --git a/src/utils/gl.ts b/src/utils/gl.ts index bc5b5de..0831628 100644 --- a/src/utils/gl.ts +++ b/src/utils/gl.ts @@ -15,6 +15,7 @@ interface GlOption { renderHook: () => void; renderOptions?: GLRenderOptions; windowListener?: WindowListenerCallback[]; + extensions?: string[]; } export default class GL { @@ -82,6 +83,8 @@ export default class GL { this.ctx = ctx + options?.extensions?.forEach((ext) => ctx.getExtension(ext)) + // Create the shaders this.vertexShader = this.createShaderOfType(this.ctx, this.ctx.VERTEX_SHADER, vertexShaderSource) as WebGLShader this.fragmentShader = this.createShaderOfType(this.ctx, this.ctx.FRAGMENT_SHADER, fragmentShaderSource) as WebGLShader