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

@@ -114,6 +114,7 @@ export const CATEGORIES = [
'Liquid Chrome',
'Grid Distortion',
'Galaxy',
'Light Rays',
]
}
];

View File

@@ -100,6 +100,7 @@ const backgrounds = {
'ripple-grid': () => import('../demo/Backgrounds/RippleGridDemo.vue'),
'galaxy': () => import('../demo/Backgrounds/GalaxyDemo.vue'),
'faulty-terminal': () => import('../demo/Backgrounds/FaultyTerminalDemo.vue'),
'light-rays': () => import('../demo/Backgrounds/LightRaysDemo.vue'),
};
export const componentMap = {

View File

@@ -0,0 +1,26 @@
import code from '@content/Backgrounds/LightRays/LightRays.vue?raw';
import { createCodeObject } from '@/types/code';
export const lightRays = createCodeObject(code, 'Backgrounds/LightRays', {
installation: `npm install ogl`,
usage: `<template>
<div class="w-full h-[600px] relative">
<LightRays
rays-origin="top-center"
rays-color="#00ffff"
:rays-speed="1.5"
:light-spread="0.8"
:ray-length="1.2"
:follow-mouse="true"
:mouse-influence="0.1"
:noise-amount="0.1"
:distortion="0.05"
class-name="custom-rays"
/>
</div>
</template>
<script setup lang="ts">
import LightRays from "./LightRays.vue";
</script>`
});

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>

View File

@@ -0,0 +1,243 @@
<template>
<TabbedLayout>
<template #preview>
<div class="h-[600px] overflow-hidden demo-container relative">
<LightRays
:rays-origin="raysOrigin"
:rays-color="raysColor"
:rays-speed="raysSpeed"
:light-spread="lightSpread"
:ray-length="rayLength"
:pulsating="pulsating"
:fade-distance="fadeDistance"
:saturation="saturation"
:follow-mouse="true"
:mouse-influence="mouseInfluence"
:noise-amount="noiseAmount"
:distortion="distortion"
class="w-full h-full"
/>
<BackgroundContent pill-text="New Background" headline="May these lights guide you on your path" />
</div>
<Customize>
<PreviewColor
title="Rays Color"
v-model="raysColor"
/>
<PreviewSelect
title="Rays Origin"
v-model="raysOrigin"
:options="raysOriginOptions"
/>
<PreviewSlider
title="Rays Speed"
v-model="raysSpeed"
:min="0.1"
:max="3"
:step="0.1"
/>
<PreviewSlider
title="Light Spread"
v-model="lightSpread"
:min="0.1"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Ray Length"
v-model="rayLength"
:min="0.5"
:max="3"
:step="0.1"
/>
<PreviewSlider
title="Fade Distance"
v-model="fadeDistance"
:min="0.5"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Saturation"
v-model="saturation"
:min="0"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Mouse Influence"
v-model="mouseInfluence"
:min="0"
:max="1"
:step="0.1"
/>
<PreviewSlider
title="Noise Amount"
v-model="noiseAmount"
:min="0"
:max="0.5"
:step="0.01"
/>
<PreviewSlider
title="Distortion"
v-model="distortion"
:min="0"
:max="1"
:step="0.1"
/>
<PreviewSwitch title="Pulsating" v-model="pulsating" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="lightRays" />
</template>
<template #cli>
<CliInstallation :command="lightRays.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PropTable from '@/components/common/PropTable.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import PreviewSelect from '@/components/common/PreviewSelect.vue';
import PreviewColor from '@/components/common/PreviewColor.vue';
import BackgroundContent from '@/components/common/BackgroundContent.vue';
import LightRays, { type RaysOrigin } from '../../content/Backgrounds/LightRays/LightRays.vue';
import { lightRays } from '@/constants/code/Backgrounds/lightRaysCode';
const raysOrigin = ref<RaysOrigin>('top-center');
const raysColor = ref('#ffffff');
const raysSpeed = ref(1);
const lightSpread = ref(0.5);
const rayLength = ref(1.0);
const pulsating = ref(false);
const fadeDistance = ref(1.0);
const saturation = ref(1.0);
const mouseInfluence = ref(0.5);
const noiseAmount = ref(0.0);
const distortion = ref(0.0);
const raysOriginOptions = [
{ value: 'top-center', label: 'Top' },
{ value: 'right', label: 'Right' },
{ value: 'left', label: 'Left' },
{ value: 'bottom-center', label: 'Bottom' },
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' }
];
const propData = [
{
name: 'raysOrigin',
type: 'RaysOrigin',
default: '"top-center"',
description:
"Origin position of the light rays. Options: 'top-center', 'top-left', 'top-right', 'right', 'left', 'bottom-center', 'bottom-right', 'bottom-left'"
},
{
name: 'raysColor',
type: 'string',
default: '"#ffffff"',
description: 'Color of the light rays in hex format'
},
{
name: 'raysSpeed',
type: 'number',
default: '1',
description: 'Animation speed of the rays'
},
{
name: 'lightSpread',
type: 'number',
default: '0.5',
description: 'How wide the light rays spread. Lower values = tighter rays, higher values = wider spread'
},
{
name: 'rayLength',
type: 'number',
default: '1.0',
description: 'Maximum length/reach of the rays'
},
{
name: 'pulsating',
type: 'boolean',
default: 'false',
description: 'Enable pulsing animation effect'
},
{
name: 'fadeDistance',
type: 'number',
default: '1.0',
description: 'How far rays fade out from origin'
},
{
name: 'saturation',
type: 'number',
default: '1.0',
description: 'Color saturation level (0-1)'
},
{
name: 'followMouse',
type: 'boolean',
default: 'false',
description: 'Make rays rotate towards the mouse cursor'
},
{
name: 'mouseInfluence',
type: 'number',
default: '0.5',
description: 'How much mouse affects rays (0-1)'
},
{
name: 'noiseAmount',
type: 'number',
default: '0.0',
description: 'Add noise/grain to rays (0-1)'
},
{
name: 'distortion',
type: 'number',
default: '0.0',
description: 'Apply wave distortion to rays'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes to apply to the container'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>