mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
637 lines
16 KiB
Vue
637 lines
16 KiB
Vue
<template>
|
|
<div ref="containerRef" :class="['w-full h-full overflow-hidden relative z-2', className]" v-bind="$attrs">
|
|
<canvas ref="canvasRef" class="block w-full h-full" />
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Renderer, Camera, Transform, Plane, Program, Mesh, Texture, type OGLRenderingContext } from 'ogl';
|
|
|
|
type GL = OGLRenderingContext;
|
|
type OGLProgram = Program;
|
|
type OGLMesh = Mesh;
|
|
type OGLTransform = Transform;
|
|
type OGLPlane = Plane;
|
|
|
|
interface ScreenSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ViewportSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ScrollState {
|
|
position?: number;
|
|
ease: number;
|
|
current: number;
|
|
target: number;
|
|
last: number;
|
|
}
|
|
|
|
interface AutoBindOptions {
|
|
include?: Array<string | RegExp>;
|
|
exclude?: Array<string | RegExp>;
|
|
}
|
|
|
|
interface MediaParams {
|
|
gl: GL;
|
|
geometry: OGLPlane;
|
|
scene: OGLTransform;
|
|
screen: ScreenSize;
|
|
viewport: ViewportSize;
|
|
image: string;
|
|
length: number;
|
|
index: number;
|
|
planeWidth: number;
|
|
planeHeight: number;
|
|
distortion: number;
|
|
}
|
|
|
|
interface CanvasParams {
|
|
container: HTMLElement;
|
|
canvas: HTMLCanvasElement;
|
|
items: string[];
|
|
planeWidth: number;
|
|
planeHeight: number;
|
|
distortion: number;
|
|
scrollEase: number;
|
|
cameraFov: number;
|
|
cameraZ: number;
|
|
}
|
|
|
|
const vertexShader = `
|
|
precision highp float;
|
|
|
|
attribute vec3 position;
|
|
attribute vec2 uv;
|
|
attribute vec3 normal;
|
|
|
|
uniform mat4 modelViewMatrix;
|
|
uniform mat4 projectionMatrix;
|
|
uniform mat3 normalMatrix;
|
|
|
|
uniform float uPosition;
|
|
uniform float uTime;
|
|
uniform float uSpeed;
|
|
uniform vec3 distortionAxis;
|
|
uniform vec3 rotationAxis;
|
|
uniform float uDistortion;
|
|
|
|
varying vec2 vUv;
|
|
varying vec3 vNormal;
|
|
|
|
float PI = 3.141592653589793238;
|
|
mat4 rotationMatrix(vec3 axis, float angle) {
|
|
axis = normalize(axis);
|
|
float s = sin(angle);
|
|
float c = cos(angle);
|
|
float oc = 1.0 - c;
|
|
|
|
return mat4(
|
|
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
|
|
oc * axis.x * axis.y + axis.z * s,oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
|
|
oc * axis.z * axis.x - axis.y * s,oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
|
|
0.0, 0.0, 0.0, 1.0
|
|
);
|
|
}
|
|
|
|
vec3 rotate(vec3 v, vec3 axis, float angle) {
|
|
mat4 m = rotationMatrix(axis, angle);
|
|
return (m * vec4(v, 1.0)).xyz;
|
|
}
|
|
|
|
float qinticInOut(float t) {
|
|
return t < 0.5
|
|
? 16.0 * pow(t, 5.0)
|
|
: -0.5 * abs(pow(2.0 * t - 2.0, 5.0)) + 1.0;
|
|
}
|
|
|
|
void main() {
|
|
vUv = uv;
|
|
|
|
float norm = 0.5;
|
|
vec3 newpos = position;
|
|
float offset = (dot(distortionAxis, position) + norm / 2.) / norm;
|
|
float localprogress = clamp(
|
|
(fract(uPosition * 5.0 * 0.01) - 0.01 * uDistortion * offset) / (1. - 0.01 * uDistortion),
|
|
0.,
|
|
2.
|
|
);
|
|
localprogress = qinticInOut(localprogress) * PI;
|
|
newpos = rotate(newpos, rotationAxis, localprogress);
|
|
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(newpos, 1.0);
|
|
}
|
|
`;
|
|
|
|
const fragmentShader = `
|
|
precision highp float;
|
|
|
|
uniform vec2 uImageSize;
|
|
uniform vec2 uPlaneSize;
|
|
uniform sampler2D tMap;
|
|
|
|
varying vec2 vUv;
|
|
|
|
void main() {
|
|
vec2 imageSize = uImageSize;
|
|
vec2 planeSize = uPlaneSize;
|
|
|
|
float imageAspect = imageSize.x / imageSize.y;
|
|
float planeAspect = planeSize.x / planeSize.y;
|
|
vec2 scale = vec2(1.0, 1.0);
|
|
|
|
if (planeAspect > imageAspect) {
|
|
scale.x = imageAspect / planeAspect;
|
|
} else {
|
|
scale.y = planeAspect / imageAspect;
|
|
}
|
|
|
|
vec2 uv = vUv * scale + (1.0 - scale) * 0.5;
|
|
|
|
gl_FragColor = texture2D(tMap, uv);
|
|
}
|
|
`;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
|
|
const getAllProperties = (object: object): Set<[object, string | symbol]> => {
|
|
const properties = new Set<[object, string | symbol]>();
|
|
let currentObject: object | null = object;
|
|
do {
|
|
for (const key of Reflect.ownKeys(currentObject)) {
|
|
properties.add([currentObject, key]);
|
|
}
|
|
} while ((currentObject = Reflect.getPrototypeOf(currentObject)) && currentObject !== Object.prototype);
|
|
return properties;
|
|
};
|
|
|
|
const filter = (key: string | symbol) => {
|
|
const match = (pattern: string | RegExp) =>
|
|
typeof pattern === 'string' ? key === pattern : (pattern as RegExp).test(key.toString());
|
|
|
|
if (include) return include.some(match);
|
|
if (exclude) return !exclude.some(match);
|
|
return true;
|
|
};
|
|
|
|
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
|
|
if (key === 'constructor' || !filter(key)) continue;
|
|
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
|
|
if (descriptor && typeof descriptor.value === 'function' && typeof key === 'string') {
|
|
self[key] = self[key].bind(self);
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
function lerp(p1: number, p2: number, t: number): number {
|
|
return p1 + (p2 - p1) * t;
|
|
}
|
|
|
|
function map(num: number, min1: number, max1: number, min2: number, max2: number, round = false): number {
|
|
const num1 = (num - min1) / (max1 - min1);
|
|
const num2 = num1 * (max2 - min2) + min2;
|
|
return round ? Math.round(num2) : num2;
|
|
}
|
|
|
|
class Media {
|
|
gl: GL;
|
|
geometry: OGLPlane;
|
|
scene: OGLTransform;
|
|
screen: ScreenSize;
|
|
viewport: ViewportSize;
|
|
image: string;
|
|
length: number;
|
|
index: number;
|
|
planeWidth: number;
|
|
planeHeight: number;
|
|
distortion: number;
|
|
|
|
program!: OGLProgram;
|
|
plane!: OGLMesh;
|
|
extra = 0;
|
|
padding = 0;
|
|
height = 0;
|
|
heightTotal = 0;
|
|
y = 0;
|
|
|
|
constructor({
|
|
gl,
|
|
geometry,
|
|
scene,
|
|
screen,
|
|
viewport,
|
|
image,
|
|
length,
|
|
index,
|
|
planeWidth,
|
|
planeHeight,
|
|
distortion
|
|
}: MediaParams) {
|
|
this.gl = gl;
|
|
this.geometry = geometry;
|
|
this.scene = scene;
|
|
this.screen = screen;
|
|
this.viewport = viewport;
|
|
this.image = image;
|
|
this.length = length;
|
|
this.index = index;
|
|
this.planeWidth = planeWidth;
|
|
this.planeHeight = planeHeight;
|
|
this.distortion = distortion;
|
|
|
|
this.createShader();
|
|
this.createMesh();
|
|
this.onResize();
|
|
}
|
|
|
|
createShader() {
|
|
const texture = new Texture(this.gl, { generateMipmaps: false });
|
|
this.program = new Program(this.gl, {
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
fragment: fragmentShader,
|
|
vertex: vertexShader,
|
|
uniforms: {
|
|
tMap: { value: texture },
|
|
uPosition: { value: 0 },
|
|
uPlaneSize: { value: [0, 0] },
|
|
uImageSize: { value: [0, 0] },
|
|
uSpeed: { value: 0 },
|
|
rotationAxis: { value: [0, 1, 0] },
|
|
distortionAxis: { value: [1, 1, 0] },
|
|
uDistortion: { value: this.distortion },
|
|
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
|
|
uTime: { value: 0 }
|
|
},
|
|
cullFace: false
|
|
});
|
|
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.src = this.image;
|
|
img.onload = () => {
|
|
texture.image = img;
|
|
this.program.uniforms.uImageSize.value = [img.naturalWidth, img.naturalHeight];
|
|
};
|
|
}
|
|
|
|
createMesh() {
|
|
this.plane = new Mesh(this.gl, {
|
|
geometry: this.geometry,
|
|
program: this.program
|
|
});
|
|
this.plane.setParent(this.scene);
|
|
}
|
|
|
|
setScale() {
|
|
this.plane.scale.x = (this.viewport.width * this.planeWidth) / this.screen.width;
|
|
this.plane.scale.y = (this.viewport.height * this.planeHeight) / this.screen.height;
|
|
this.plane.position.x = 0;
|
|
this.program.uniforms.uPlaneSize.value = [this.plane.scale.x, this.plane.scale.y];
|
|
}
|
|
|
|
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
|
|
if (screen) this.screen = screen;
|
|
if (viewport) {
|
|
this.viewport = viewport;
|
|
this.program.uniforms.uViewportSize.value = [viewport.width, viewport.height];
|
|
}
|
|
this.setScale();
|
|
|
|
this.padding = 5;
|
|
this.height = this.plane.scale.y + this.padding;
|
|
this.heightTotal = this.height * this.length;
|
|
this.y = -this.heightTotal / 2 + (this.index + 0.5) * this.height;
|
|
}
|
|
|
|
update(scroll: ScrollState) {
|
|
this.plane.position.y = this.y - scroll.current - this.extra;
|
|
const position = map(this.plane.position.y, -this.viewport.height, this.viewport.height, 5, 15);
|
|
|
|
this.program.uniforms.uPosition.value = position;
|
|
this.program.uniforms.uTime.value += 0.04;
|
|
this.program.uniforms.uSpeed.value = scroll.current;
|
|
|
|
const planeHeight = this.plane.scale.y;
|
|
const viewportHeight = this.viewport.height;
|
|
const topEdge = this.plane.position.y + planeHeight / 2;
|
|
const bottomEdge = this.plane.position.y - planeHeight / 2;
|
|
|
|
if (topEdge < -viewportHeight / 2) {
|
|
this.extra -= this.heightTotal;
|
|
} else if (bottomEdge > viewportHeight / 2) {
|
|
this.extra += this.heightTotal;
|
|
}
|
|
}
|
|
}
|
|
|
|
class Canvas {
|
|
container: HTMLElement;
|
|
canvas: HTMLCanvasElement;
|
|
items: string[];
|
|
planeWidth: number;
|
|
planeHeight: number;
|
|
distortion: number;
|
|
scroll: ScrollState;
|
|
cameraFov: number;
|
|
cameraZ: number;
|
|
|
|
renderer!: Renderer;
|
|
gl!: GL;
|
|
camera!: Camera;
|
|
scene!: OGLTransform;
|
|
planeGeometry!: OGLPlane;
|
|
medias!: Media[];
|
|
screen!: ScreenSize;
|
|
viewport!: ViewportSize;
|
|
isDown = false;
|
|
start = 0;
|
|
loaded = 0;
|
|
|
|
constructor({
|
|
container,
|
|
canvas,
|
|
items,
|
|
planeWidth,
|
|
planeHeight,
|
|
distortion,
|
|
scrollEase,
|
|
cameraFov,
|
|
cameraZ
|
|
}: CanvasParams) {
|
|
this.container = container;
|
|
this.canvas = canvas;
|
|
this.items = items;
|
|
this.planeWidth = planeWidth;
|
|
this.planeHeight = planeHeight;
|
|
this.distortion = distortion;
|
|
this.scroll = {
|
|
ease: scrollEase,
|
|
current: 0,
|
|
target: 0,
|
|
last: 0
|
|
};
|
|
this.cameraFov = cameraFov;
|
|
this.cameraZ = cameraZ;
|
|
|
|
AutoBind(this);
|
|
this.createRenderer();
|
|
this.createCamera();
|
|
this.createScene();
|
|
this.onResize();
|
|
this.createGeometry();
|
|
this.createMedias();
|
|
this.initializeScrollPosition();
|
|
this.update();
|
|
this.addEventListeners();
|
|
this.createPreloader();
|
|
}
|
|
|
|
createRenderer() {
|
|
this.renderer = new Renderer({
|
|
canvas: this.canvas,
|
|
alpha: true,
|
|
antialias: true,
|
|
dpr: Math.min(window.devicePixelRatio, 2)
|
|
});
|
|
this.gl = this.renderer.gl;
|
|
}
|
|
|
|
createCamera() {
|
|
this.camera = new Camera(this.gl);
|
|
this.camera.fov = this.cameraFov;
|
|
this.camera.position.z = this.cameraZ;
|
|
}
|
|
|
|
createScene() {
|
|
this.scene = new Transform();
|
|
}
|
|
|
|
createGeometry() {
|
|
this.planeGeometry = new Plane(this.gl, {
|
|
heightSegments: 1,
|
|
widthSegments: 100
|
|
});
|
|
}
|
|
|
|
createMedias() {
|
|
this.medias = this.items.map(
|
|
(image, index) =>
|
|
new Media({
|
|
gl: this.gl,
|
|
geometry: this.planeGeometry,
|
|
scene: this.scene,
|
|
screen: this.screen,
|
|
viewport: this.viewport,
|
|
image,
|
|
length: this.items.length,
|
|
index,
|
|
planeWidth: this.planeWidth,
|
|
planeHeight: this.planeHeight,
|
|
distortion: this.distortion
|
|
})
|
|
);
|
|
}
|
|
|
|
initializeScrollPosition() {
|
|
if (this.medias && this.medias.length > 0) {
|
|
const centerIndex = Math.floor(this.medias.length / 2);
|
|
const centerMedia = this.medias[centerIndex];
|
|
this.scroll.current = centerMedia.y;
|
|
this.scroll.target = centerMedia.y;
|
|
}
|
|
}
|
|
|
|
createPreloader() {
|
|
this.loaded = 0;
|
|
this.items.forEach(src => {
|
|
const image = new Image();
|
|
image.crossOrigin = 'anonymous';
|
|
image.src = src;
|
|
image.onload = () => {
|
|
if (++this.loaded === this.items.length) {
|
|
document.documentElement.classList.remove('loading');
|
|
document.documentElement.classList.add('loaded');
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
onResize() {
|
|
const rect = this.container.getBoundingClientRect();
|
|
this.screen = { width: rect.width, height: rect.height };
|
|
this.renderer.setSize(this.screen.width, this.screen.height);
|
|
|
|
this.camera.perspective({
|
|
aspect: this.gl.canvas.width / this.gl.canvas.height
|
|
});
|
|
|
|
const fov = (this.camera.fov * Math.PI) / 180;
|
|
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
|
|
const width = height * this.camera.aspect;
|
|
this.viewport = { width, height };
|
|
|
|
this.medias?.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));
|
|
}
|
|
|
|
onTouchDown(e: MouseEvent | TouchEvent) {
|
|
this.isDown = true;
|
|
this.scroll.position = this.scroll.current;
|
|
this.start = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
|
}
|
|
|
|
onTouchMove(e: MouseEvent | TouchEvent) {
|
|
if (!this.isDown || !this.scroll.position) return;
|
|
const y = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
|
const distance = (this.start - y) * 0.1;
|
|
this.scroll.target = this.scroll.position + distance;
|
|
}
|
|
|
|
onTouchUp() {
|
|
this.isDown = false;
|
|
}
|
|
|
|
onWheel(e: WheelEvent) {
|
|
this.scroll.target += e.deltaY * 0.005;
|
|
}
|
|
|
|
update() {
|
|
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
|
|
this.medias?.forEach(media => media.update(this.scroll));
|
|
this.renderer.render({ scene: this.scene, camera: this.camera });
|
|
this.scroll.last = this.scroll.current;
|
|
requestAnimationFrame(this.update);
|
|
}
|
|
|
|
addEventListeners() {
|
|
window.addEventListener('resize', this.onResize);
|
|
window.addEventListener('wheel', this.onWheel);
|
|
window.addEventListener('mousedown', this.onTouchDown);
|
|
window.addEventListener('mousemove', this.onTouchMove);
|
|
window.addEventListener('mouseup', this.onTouchUp);
|
|
window.addEventListener('touchstart', this.onTouchDown as EventListener);
|
|
window.addEventListener('touchmove', this.onTouchMove as EventListener);
|
|
window.addEventListener('touchend', this.onTouchUp as EventListener);
|
|
}
|
|
|
|
destroy() {
|
|
window.removeEventListener('resize', this.onResize);
|
|
window.removeEventListener('wheel', this.onWheel);
|
|
window.removeEventListener('mousedown', this.onTouchDown);
|
|
window.removeEventListener('mousemove', this.onTouchMove);
|
|
window.removeEventListener('mouseup', this.onTouchUp);
|
|
window.removeEventListener('touchstart', this.onTouchDown as EventListener);
|
|
window.removeEventListener('touchmove', this.onTouchMove as EventListener);
|
|
window.removeEventListener('touchend', this.onTouchUp as EventListener);
|
|
}
|
|
}
|
|
|
|
export interface FlyingPostersProps {
|
|
items?: string[];
|
|
planeWidth?: number;
|
|
planeHeight?: number;
|
|
distortion?: number;
|
|
scrollEase?: number;
|
|
cameraFov?: number;
|
|
cameraZ?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export { Canvas, Media };
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
|
|
|
|
const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
|
items: () => [],
|
|
planeWidth: 320,
|
|
planeHeight: 320,
|
|
distortion: 3,
|
|
scrollEase: 0.01,
|
|
cameraFov: 45,
|
|
cameraZ: 20,
|
|
className: ''
|
|
});
|
|
|
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
|
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef');
|
|
const instanceRef = ref<Canvas | null>(null);
|
|
|
|
const initCanvas = () => {
|
|
if (!containerRef.value || !canvasRef.value) return;
|
|
|
|
instanceRef.value = new Canvas({
|
|
container: containerRef.value,
|
|
canvas: canvasRef.value,
|
|
items: props.items,
|
|
planeWidth: props.planeWidth,
|
|
planeHeight: props.planeHeight,
|
|
distortion: props.distortion,
|
|
scrollEase: props.scrollEase,
|
|
cameraFov: props.cameraFov,
|
|
cameraZ: props.cameraZ
|
|
});
|
|
};
|
|
|
|
const destroyCanvas = () => {
|
|
if (instanceRef.value) {
|
|
instanceRef.value.destroy();
|
|
instanceRef.value = null;
|
|
}
|
|
};
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
if (instanceRef.value) {
|
|
instanceRef.value.onWheel(e);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
watch(
|
|
() => [
|
|
props.items,
|
|
props.planeWidth,
|
|
props.planeHeight,
|
|
props.distortion,
|
|
props.scrollEase,
|
|
props.cameraFov,
|
|
props.cameraZ
|
|
],
|
|
() => {
|
|
destroyCanvas();
|
|
initCanvas();
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
initCanvas();
|
|
|
|
if (canvasRef.value) {
|
|
const canvasEl = canvasRef.value;
|
|
canvasEl.addEventListener('wheel', handleWheel, { passive: false });
|
|
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
destroyCanvas();
|
|
|
|
if (canvasRef.value) {
|
|
const canvasEl = canvasRef.value;
|
|
canvasEl.removeEventListener('wheel', handleWheel);
|
|
canvasEl.removeEventListener('touchmove', handleTouchMove);
|
|
}
|
|
});
|
|
</script>
|