mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
686 lines
16 KiB
Vue
686 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 } from 'vue'
|
|
|
|
const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
|
items: () => [],
|
|
planeWidth: 320,
|
|
planeHeight: 320,
|
|
distortion: 3,
|
|
scrollEase: 0.01,
|
|
cameraFov: 45,
|
|
cameraZ: 20,
|
|
className: '',
|
|
})
|
|
|
|
const containerRef = ref<HTMLDivElement>()
|
|
const canvasRef = ref<HTMLCanvasElement>()
|
|
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> |