Merge pull request #26 from Utkarsh-Singhal-26/feat/grid-distortion

Added <GridDistortion  /> background
This commit is contained in:
David
2025-07-17 09:47:33 +03:00
committed by GitHub
5 changed files with 373 additions and 1 deletions

View File

@@ -90,7 +90,8 @@ export const CATEGORIES = [
'Threads', 'Threads',
'Grid Motion', 'Grid Motion',
'Orb', 'Orb',
'Ballpit' 'Ballpit',
'Grid Distortion'
] ]
} }
]; ];

View File

@@ -77,6 +77,7 @@ const backgrounds = {
'balatro': () => import('../demo/Backgrounds/BalatroDemo.vue'), 'balatro': () => import('../demo/Backgrounds/BalatroDemo.vue'),
'orb': () => import('../demo/Backgrounds/OrbDemo.vue'), 'orb': () => import('../demo/Backgrounds/OrbDemo.vue'),
'ballpit': () => import('../demo/Backgrounds/BallpitDemo.vue'), 'ballpit': () => import('../demo/Backgrounds/BallpitDemo.vue'),
'grid-distortion': () => import('../demo/Backgrounds/GridDistortionDemo.vue'),
}; };
export const componentMap = { export const componentMap = {

View File

@@ -0,0 +1,24 @@
import code from '@content/Backgrounds/GridDistortion/GridDistortion.vue?raw';
import type { CodeObject } from '../../../types/code';
export const gridDistortion: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/GridDistortion`,
installation: `npm i three`,
usage: `<template>
<div class="relative w-full h-[600px]">
<GridDistortion
imageSrc="https://picsum.photos/1920/1080?grayscale"
:grid="10"
:mouse="0.1"
:strength="0.15
:relaxation="0.9"
className="custom-class"
/>
</div>
</template>
<script setup lang="ts">
import GridDistortion from "./GridDistortion.vue";
</script>`,
code
};

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import * as THREE from 'three';
import { onMounted, onUnmounted, ref, watch } from 'vue';
interface GridDistortionProps {
grid?: number;
mouse?: number;
strength?: number;
relaxation?: number;
imageSrc: string;
className?: string;
}
const props = withDefaults(defineProps<GridDistortionProps>(), {
grid: 15,
mouse: 0.1,
strength: 0.15,
relaxation: 0.9,
className: ''
});
const vertexShader = `
uniform float time;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform sampler2D uDataTexture;
uniform sampler2D uTexture;
uniform vec4 resolution;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
vec4 offset = texture2D(uDataTexture, vUv);
gl_FragColor = texture2D(uTexture, uv - 0.02 * offset.rg);
}
`;
const containerRef = ref<HTMLDivElement | null>(null);
const imageAspectRef = ref(1);
const cameraRef = ref<THREE.OrthographicCamera | null>(null);
const initialDataRef = ref<Float32Array | null>(null);
let cleanupAnimation: () => void = () => {};
const setupAnimation = () => {
const container = containerRef.value;
if (!container) return;
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const camera = new THREE.OrthographicCamera(0, 0, 0, 0, -1000, 1000);
camera.position.z = 2;
cameraRef.value = camera;
const uniforms = {
time: { value: 0 },
resolution: { value: new THREE.Vector4() },
uTexture: { value: null as THREE.Texture | null },
uDataTexture: { value: null as THREE.DataTexture | null }
};
const textureLoader = new THREE.TextureLoader();
textureLoader.load(props.imageSrc, texture => {
texture.minFilter = THREE.LinearFilter;
imageAspectRef.value = texture.image.width / texture.image.height;
uniforms.uTexture.value = texture;
handleResize();
});
const size = props.grid;
const data = new Float32Array(4 * size * size);
for (let i = 0; i < size * size; i++) {
data[i * 4] = Math.random() * 255 - 125;
data[i * 4 + 1] = Math.random() * 255 - 125;
}
initialDataRef.value = new Float32Array(data);
const dataTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.FloatType);
dataTexture.needsUpdate = true;
uniforms.uDataTexture.value = dataTexture;
const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
uniforms,
vertexShader,
fragmentShader
});
const geometry = new THREE.PlaneGeometry(1, 1, size - 1, size - 1);
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
const handleResize = () => {
const width = container.offsetWidth;
const height = container.offsetHeight;
const containerAspect = width / height;
const imageAspect = imageAspectRef.value;
renderer.setSize(width, height);
const scale = Math.max(containerAspect / imageAspect, 1);
plane.scale.set(imageAspect * scale, scale, 1);
const frustumHeight = 1;
const frustumWidth = frustumHeight * containerAspect;
camera.left = -frustumWidth / 2;
camera.right = frustumWidth / 2;
camera.top = frustumHeight / 2;
camera.bottom = -frustumHeight / 2;
camera.updateProjectionMatrix();
uniforms.resolution.value.set(width, height, 1, 1);
};
const mouseState = {
x: 0,
y: 0,
prevX: 0,
prevY: 0,
vX: 0,
vY: 0
};
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
mouseState.vX = x - mouseState.prevX;
mouseState.vY = y - mouseState.prevY;
Object.assign(mouseState, { x, y, prevX: x, prevY: y });
};
const handleMouseLeave = () => {
dataTexture.needsUpdate = true;
Object.assign(mouseState, {
x: 0,
y: 0,
prevX: 0,
prevY: 0,
vX: 0,
vY: 0
});
};
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('resize', handleResize);
handleResize();
const animate = () => {
requestAnimationFrame(animate);
uniforms.time.value += 0.05;
const data = dataTexture.image.data as Float32Array;
for (let i = 0; i < size * size; i++) {
data[i * 4] *= props.relaxation;
data[i * 4 + 1] *= props.relaxation;
}
const gridMouseX = size * mouseState.x;
const gridMouseY = size * mouseState.y;
const maxDist = size * props.mouse;
for (let i = 0; i < size; i++) {
for (let j = 0; j < size; j++) {
const distSq = Math.pow(gridMouseX - i, 2) + Math.pow(gridMouseY - j, 2);
if (distSq < maxDist * maxDist) {
const index = 4 * (i + size * j);
const power = Math.min(maxDist / Math.sqrt(distSq), 10);
data[index] += props.strength * 100 * mouseState.vX * power;
data[index + 1] -= props.strength * 100 * mouseState.vY * power;
}
}
}
dataTexture.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
cleanupAnimation = () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('resize', handleResize);
renderer.dispose();
geometry.dispose();
material.dispose();
dataTexture.dispose();
if (uniforms.uTexture.value) uniforms.uTexture.value.dispose();
};
};
onMounted(() => {
cleanupAnimation();
setupAnimation();
});
onUnmounted(() => {
const container = containerRef.value;
if (container) {
container.innerHTML = '';
}
cleanupAnimation();
});
watch(
() => props,
() => {
cleanupAnimation();
if (containerRef.value) {
setupAnimation();
}
},
{ immediate: true }
);
</script>
<template>
<div ref="containerRef" :class="[props.className, 'w-full h-full overflow-hidden']" />
</template>

View File

@@ -0,0 +1,111 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative p-0 h-[600px] overflow-hidden demo-container" ref="containerRef">
<GridDistortion
:key="key"
imageSrc="https://picsum.photos/1920/1080?grayscale"
:grid="grid"
:mouse="mouse"
:strength="0.15"
:relaxation="0.9"
className="grid-distortion"
/>
<p class="absolute font-black text-8xl text-center pointer-events-none select-none mix-blend-difference">
Distortion.
</p>
</div>
<Customize>
<PreviewSlider title="Grid Size" :min="6" :max="200" :step="1" v-model="grid" />
<PreviewSlider title="Mouse Size" :min="0.1" :max="0.5" :step="0.01" v-model="mouse" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['three']" />
</template>
<template #code>
<CodeExample :code-object="gridDistortion" />
</template>
<template #cli>
<CliInstallation :command="gridDistortion.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { useForceRerender } from '@/composables/useForceRerender';
import { ref, watch } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { gridDistortion } from '../../constants/code/Backgrounds/gridDistortionCode';
import GridDistortion from '../../content/Backgrounds/GridDistortion/GridDistortion.vue';
const { rerenderKey: key, forceRerender } = useForceRerender();
const grid = ref(10);
const mouse = ref(0.25);
const containerRef = ref<HTMLDivElement | null>(null);
watch(
() => [grid, mouse],
() => {
forceRerender();
},
{ deep: true }
);
const propData = [
{
name: 'imgageSrc',
type: 'string',
default: '',
description: 'The image you want to render inside the container.'
},
{
name: 'grid',
type: 'number',
default: '15',
description: 'The number of cells present in the distortion grid'
},
{
name: 'mouse',
type: 'number',
default: '0.1',
description: 'The size of the distortion effect that follows the cursor.'
},
{
name: 'relaxation',
type: 'number',
default: '0.9',
description: 'The speed at which grid cells return to their initial state.'
},
{
name: 'strength',
type: 'number',
default: '0.15',
description: 'The overall strength of the distortion effect.'
},
{
name: 'className',
type: 'string',
default: '',
description: 'Any custom class(es) you want to apply to the container.'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>