Merge pull request #63 from Gazoon007/feat/light-rays

Create <LightRays /> background
This commit is contained in:
David
2025-07-26 22:22:09 +03:00
committed by GitHub
5 changed files with 796 additions and 0 deletions

View File

@@ -0,0 +1,525 @@
<template>
<div
ref="containerRef"
:class="[
'w-full h-full relative pointer-events-none z-[3] overflow-hidden',
className
]"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, useTemplateRef, computed, nextTick } from 'vue';
import { Renderer, Program, Triangle, Mesh } from 'ogl';
export type RaysOrigin =
| 'top-center'
| 'top-left'
| 'top-right'
| 'right'
| 'left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left';
interface LightRaysProps {
raysOrigin?: RaysOrigin;
raysColor?: string;
raysSpeed?: number;
lightSpread?: number;
rayLength?: number;
pulsating?: boolean;
fadeDistance?: number;
saturation?: number;
followMouse?: boolean;
mouseInfluence?: number;
noiseAmount?: number;
distortion?: number;
className?: string;
}
interface MousePosition {
x: number;
y: number;
}
interface AnchorAndDirection {
anchor: [number, number];
dir: [number, number];
}
interface WebGLUniforms {
iTime: { value: number };
iResolution: { value: [number, number] };
rayPos: { value: [number, number] };
rayDir: { value: [number, number] };
raysColor: { value: [number, number, number] };
raysSpeed: { value: number };
lightSpread: { value: number };
rayLength: { value: number };
pulsating: { value: number };
fadeDistance: { value: number };
saturation: { value: number };
mousePos: { value: [number, number] };
mouseInfluence: { value: number };
noiseAmount: { value: number };
distortion: { value: number };
}
const props = withDefaults(defineProps<LightRaysProps>(), {
raysOrigin: 'top-center',
raysColor: '#ffffff',
raysSpeed: 1,
lightSpread: 1,
rayLength: 2,
pulsating: false,
fadeDistance: 1.0,
saturation: 1.0,
followMouse: true,
mouseInfluence: 0.1,
noiseAmount: 0.0,
distortion: 0.0,
className: ''
});
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
const uniformsRef = ref<WebGLUniforms | null>(null);
const rendererRef = ref<Renderer | null>(null);
const mouseRef = ref<MousePosition>({ x: 0.5, y: 0.5 });
const smoothMouseRef = ref<MousePosition>({ x: 0.5, y: 0.5 });
const animationIdRef = ref<number | null>(null);
const meshRef = ref<Mesh | null>(null);
const cleanupFunctionRef = ref<(() => void) | null>(null);
const isVisible = ref<boolean>(false);
const observerRef = ref<IntersectionObserver | null>(null);
const resizeTimeoutRef = ref<number | null>(null);
const rgbColor = computed<[number, number, number]>(() => hexToRgb(props.raysColor));
const pulsatingValue = computed<number>(() => props.pulsating ? 1.0 : 0.0);
const devicePixelRatio = computed<number>(() => Math.min(window.devicePixelRatio || 1, 2));
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m
? [
parseInt(m[1], 16) / 255,
parseInt(m[2], 16) / 255,
parseInt(m[3], 16) / 255,
]
: [1, 1, 1];
};
const getAnchorAndDir = (origin: RaysOrigin, w: number, h: number): AnchorAndDirection => {
const outside = 0.2;
switch (origin) {
case 'top-left':
return { anchor: [0, -outside * h], dir: [0, 1] };
case 'top-right':
return { anchor: [w, -outside * h], dir: [0, 1] };
case 'left':
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
case 'right':
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
case 'bottom-left':
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
case 'bottom-center':
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
case 'bottom-right':
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
default:
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
}
};
const debouncedUpdatePlacement = (() => {
let timeoutId: number | null = null;
return (updateFn: () => void): void => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
updateFn();
timeoutId = null;
}, 16);
};
})();
const vertexShader: string = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const fragmentShader: string = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
float baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
vec4 rays1 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * raysSpeed);
vec4 rays2 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * raysSpeed);
fragColor = rays1 * 0.5 + rays2 * 0.4;
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
float brightness = 1.0 - (coord.y / iResolution.y);
fragColor.x *= 0.1 + brightness * 0.8;
fragColor.y *= 0.3 + brightness * 0.6;
fragColor.z *= 0.5 + brightness * 0.5;
if (saturation != 1.0) {
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
}
fragColor.rgb *= raysColor;
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}`;
const initializeWebGL = async (): Promise<void> => {
if (!containerRef.value) return;
await nextTick();
if (!containerRef.value) return;
try {
const renderer = new Renderer({
dpr: devicePixelRatio.value,
alpha: true,
antialias: false,
powerPreference: 'high-performance'
});
rendererRef.value = renderer;
const gl = renderer.gl;
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
while (containerRef.value.firstChild) {
containerRef.value.removeChild(containerRef.value.firstChild);
}
containerRef.value.appendChild(gl.canvas);
const uniforms: WebGLUniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] },
rayPos: { value: [0, 0] },
rayDir: { value: [0, 1] },
raysColor: { value: rgbColor.value },
raysSpeed: { value: props.raysSpeed },
lightSpread: { value: props.lightSpread },
rayLength: { value: props.rayLength },
pulsating: { value: pulsatingValue.value },
fadeDistance: { value: props.fadeDistance },
saturation: { value: props.saturation },
mousePos: { value: [0.5, 0.5] },
mouseInfluence: { value: props.mouseInfluence },
noiseAmount: { value: props.noiseAmount },
distortion: { value: props.distortion },
};
uniformsRef.value = uniforms;
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms,
});
const mesh = new Mesh(gl, { geometry, program });
meshRef.value = mesh;
const updatePlacement = (): void => {
if (!containerRef.value || !renderer) return;
renderer.dpr = devicePixelRatio.value;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.value;
renderer.setSize(wCSS, hCSS);
const dpr = renderer.dpr;
const w = wCSS * dpr;
const h = hCSS * dpr;
uniforms.iResolution.value = [w, h];
const { anchor, dir } = getAnchorAndDir(props.raysOrigin, w, h);
uniforms.rayPos.value = anchor;
uniforms.rayDir.value = dir;
};
const loop = (t: number): void => {
if (!rendererRef.value || !uniformsRef.value || !meshRef.value || !isVisible.value) {
return;
}
uniforms.iTime.value = t * 0.001;
if (props.followMouse && props.mouseInfluence > 0.0) {
const smoothing = 0.92;
smoothMouseRef.value.x =
smoothMouseRef.value.x * smoothing +
mouseRef.value.x * (1 - smoothing);
smoothMouseRef.value.y =
smoothMouseRef.value.y * smoothing +
mouseRef.value.y * (1 - smoothing);
uniforms.mousePos.value = [
smoothMouseRef.value.x,
smoothMouseRef.value.y,
];
}
try {
renderer.render({ scene: mesh });
animationIdRef.value = requestAnimationFrame(loop);
} catch (error) {
console.warn('WebGL rendering error:', error);
return;
}
};
const handleResize = (): void => {
debouncedUpdatePlacement(updatePlacement);
};
window.addEventListener('resize', handleResize, { passive: true });
updatePlacement();
animationIdRef.value = requestAnimationFrame(loop);
cleanupFunctionRef.value = (): void => {
if (animationIdRef.value) {
cancelAnimationFrame(animationIdRef.value);
animationIdRef.value = null;
}
window.removeEventListener('resize', handleResize);
if (resizeTimeoutRef.value) {
clearTimeout(resizeTimeoutRef.value);
resizeTimeoutRef.value = null;
}
if (renderer) {
try {
const canvas = renderer.gl.canvas;
const loseContextExt =
renderer.gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
} catch (error) {
console.warn('Error during WebGL cleanup:', error);
}
}
rendererRef.value = null;
uniformsRef.value = null;
meshRef.value = null;
};
} catch (error) {
console.error('Failed to initialize WebGL:', error);
}
};
let mouseThrottleId: number | null = null;
const handleMouseMove = (e: MouseEvent): void => {
if (!containerRef.value || !rendererRef.value) return;
if (mouseThrottleId) return;
mouseThrottleId = requestAnimationFrame(() => {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseRef.value = { x, y };
mouseThrottleId = null;
});
};
onMounted((): void => {
if (!containerRef.value) return;
observerRef.value = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const entry = entries[0];
isVisible.value = entry.isIntersecting;
},
{
threshold: 0.1,
rootMargin: '50px'
}
);
observerRef.value.observe(containerRef.value);
});
watch(isVisible, (newVisible: boolean): void => {
if (newVisible && containerRef.value) {
if (cleanupFunctionRef.value) {
cleanupFunctionRef.value();
cleanupFunctionRef.value = null;
}
initializeWebGL();
} else if (!newVisible && cleanupFunctionRef.value) {
if (animationIdRef.value) {
cancelAnimationFrame(animationIdRef.value);
animationIdRef.value = null;
}
}
});
watch(
[
() => props.raysColor,
() => props.raysSpeed,
() => props.lightSpread,
() => props.raysOrigin,
() => props.rayLength,
() => props.pulsating,
() => props.fadeDistance,
() => props.saturation,
() => props.mouseInfluence,
() => props.noiseAmount,
() => props.distortion,
],
(): void => {
if (!uniformsRef.value || !containerRef.value || !rendererRef.value) return;
const u = uniformsRef.value;
const renderer = rendererRef.value;
u.raysColor.value = rgbColor.value;
u.raysSpeed.value = props.raysSpeed;
u.lightSpread.value = props.lightSpread;
u.rayLength.value = props.rayLength;
u.pulsating.value = pulsatingValue.value;
u.fadeDistance.value = props.fadeDistance;
u.saturation.value = props.saturation;
u.mouseInfluence.value = props.mouseInfluence;
u.noiseAmount.value = props.noiseAmount;
u.distortion.value = props.distortion;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.value;
const dpr = renderer.dpr;
const { anchor, dir } = getAnchorAndDir(props.raysOrigin, wCSS * dpr, hCSS * dpr);
u.rayPos.value = anchor;
u.rayDir.value = dir;
},
{ flush: 'post' }
);
watch(
() => props.followMouse,
(newFollowMouse: boolean): void => {
if (newFollowMouse) {
window.addEventListener('mousemove', handleMouseMove, { passive: true });
} else {
window.removeEventListener('mousemove', handleMouseMove);
if (mouseThrottleId) {
cancelAnimationFrame(mouseThrottleId);
mouseThrottleId = null;
}
}
},
{ immediate: true }
);
onUnmounted((): void => {
if (observerRef.value) {
observerRef.value.disconnect();
observerRef.value = null;
}
if (cleanupFunctionRef.value) {
cleanupFunctionRef.value();
cleanupFunctionRef.value = null;
}
if (mouseThrottleId) {
cancelAnimationFrame(mouseThrottleId);
mouseThrottleId = null;
}
window.removeEventListener('mousemove', handleMouseMove);
});
</script>