├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── launch.json ├── README.md ├── astro.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon.svg ├── fonts │ └── Poppins-Regular.ttf └── images │ ├── 1.jpg │ ├── 10.jpg │ ├── 11.jpg │ ├── 12.jpg │ ├── 13.jpg │ ├── 14.jpg │ ├── 15.jpg │ ├── 16.jpg │ ├── 17.jpg │ ├── 18.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ └── 9.jpg ├── src ├── components │ ├── CanvasContainer.astro │ ├── Layout.astro │ ├── Link.astro │ ├── MouseStalker.astro │ └── MouseStalker.ts ├── env.d.ts ├── pages │ └── index.astro ├── scripts │ ├── entry.ts │ ├── utils.ts │ └── webgl │ │ ├── TCanvas.ts │ │ ├── core │ │ └── WebGL.ts │ │ ├── shader │ │ ├── fs.glsl │ │ └── vs.glsl │ │ └── utils │ │ ├── Mouse2D.ts │ │ ├── OrbitControls.ts │ │ ├── assetLoader.ts │ │ └── coveredTexture.ts ├── styles │ ├── global.scss │ ├── loadFonts.scss │ └── mixins │ │ ├── fonts.scss │ │ └── medias.scss └── types │ └── glsl.d.ts └── tsconfig.json /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | # Trigger the workflow every time you push to the `main` branch 5 | # Using a different branch name? Replace `main` with your branch’s name 6 | push: 7 | branches: [ main ] 8 | # Allows you to run this workflow manually from the Actions tab on GitHub. 9 | workflow_dispatch: 10 | 11 | # Allow this job to clone the repo and create a page deployment 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout your repository using git 22 | uses: actions/checkout@v3 23 | - name: Install, build, and upload your site 24 | uses: withastro/action@v0 25 | # with: 26 | # path: . # The root location of your Astro project inside the repository. (optional) 27 | # node-version: 16 # The specific version of Node that should be used to build your site. Defaults to 16. (optional) 28 | # package-manager: yarn # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional) 29 | 30 | deploy: 31 | needs: build 32 | runs-on: ubuntu-latest 33 | environment: 34 | name: github-pages 35 | url: ${{ steps.deployment.outputs.page_url }} 36 | steps: 37 | - name: Deploy to GitHub Pages 38 | id: deployment 39 | uses: actions/deploy-pages@v1 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false, 5 | "trailingComma": "all", 6 | "singleQuote": true, 7 | "printWidth": 160 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | inspired by [HISAMI KURITA Portforio](https://hsmkrt1996.com/archive/). 4 | 5 | https://nemutas.github.io/draggable/ 6 | 7 | 8 | 9 | # Memo 10 | Object(カード)がCameraの範囲内に収まっているかの判定は、以下を参考にしています。 11 | 12 | [check if object is still in view of the camera](https://stackoverflow.com/questions/29758233/three-js-check-if-object-is-still-in-view-of-the-camera) 13 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config' 2 | import glsl from 'vite-plugin-glsl' 3 | 4 | // https://astro.build/config 5 | export default defineConfig({ 6 | site: 'https://nemutas.github.io', 7 | base: '/draggable', 8 | server: { 9 | host: true, 10 | }, 11 | vite: { 12 | plugins: [glsl()], 13 | build: { 14 | assetsInlineLimit: 0, 15 | rollupOptions: { 16 | output: { 17 | assetFileNames: '[ext]/[name][extname]', 18 | entryFileNames: 'script/entry.js', 19 | }, 20 | }, 21 | cssCodeSplit: false, 22 | }, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draggable", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "astro": "^2.0.18", 14 | "three": "^0.150.1", 15 | "virtual-scroll": "^2.2.1" 16 | }, 17 | "devDependencies": { 18 | "@types/three": "^0.149.0", 19 | "@types/virtual-scroll": "^2.0.1", 20 | "autoprefixer": "^10.4.14", 21 | "prettier": "^2.8.4", 22 | "prettier-plugin-astro": "^0.8.0", 23 | "ress": "^5.0.2", 24 | "sass": "^1.58.3", 25 | "vite-plugin-glsl": "^1.1.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('autoprefixer')], 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /public/fonts/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/fonts/Poppins-Regular.ttf -------------------------------------------------------------------------------- /public/images/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/1.jpg -------------------------------------------------------------------------------- /public/images/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/10.jpg -------------------------------------------------------------------------------- /public/images/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/11.jpg -------------------------------------------------------------------------------- /public/images/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/12.jpg -------------------------------------------------------------------------------- /public/images/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/13.jpg -------------------------------------------------------------------------------- /public/images/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/14.jpg -------------------------------------------------------------------------------- /public/images/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/15.jpg -------------------------------------------------------------------------------- /public/images/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/16.jpg -------------------------------------------------------------------------------- /public/images/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/17.jpg -------------------------------------------------------------------------------- /public/images/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/18.jpg -------------------------------------------------------------------------------- /public/images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/2.jpg -------------------------------------------------------------------------------- /public/images/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/3.jpg -------------------------------------------------------------------------------- /public/images/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/4.jpg -------------------------------------------------------------------------------- /public/images/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/5.jpg -------------------------------------------------------------------------------- /public/images/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/6.jpg -------------------------------------------------------------------------------- /public/images/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/7.jpg -------------------------------------------------------------------------------- /public/images/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/8.jpg -------------------------------------------------------------------------------- /public/images/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemutas/draggable/2a3944cdc1e76d362f50b8dc55d1a8b02aad9d57/public/images/9.jpg -------------------------------------------------------------------------------- /src/components/CanvasContainer.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 | 10 | -------------------------------------------------------------------------------- /src/components/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.scss' 3 | 4 | export interface Props { 5 | title: string 6 | } 7 | 8 | const { title } = Astro.props 9 | --- 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {title} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/components/Link.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { HTMLAttributes } from 'astro/types' 3 | 4 | interface Props extends HTMLAttributes<'a'> { 5 | children: any 6 | color?: string 7 | } 8 | 9 | const { color = '#fff', ...attr } = Astro.props 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 50 | -------------------------------------------------------------------------------- /src/components/MouseStalker.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 | 13 | -------------------------------------------------------------------------------- /src/components/MouseStalker.ts: -------------------------------------------------------------------------------- 1 | import { lerp, qs } from '../scripts/utils' 2 | 3 | class MouseStalker { 4 | private mouseStalker = qs('.mouse-stalker') 5 | private mouseTarget = { x: 0, y: 0 } 6 | private mouseCurrent = { x: 0, y: 0 } 7 | private animeID?: number 8 | 9 | private lerpRatio = 0.3 10 | 11 | constructor() { 12 | this.addEvents() 13 | this.anime() 14 | } 15 | 16 | private addEvents() { 17 | window.addEventListener('mousemove', this.handleMouseMove) 18 | } 19 | 20 | private handleMouseMove = (e: MouseEvent) => { 21 | const rect = this.mouseStalker.getBoundingClientRect() 22 | this.mouseTarget.x = e.clientX - rect.width / 2 23 | this.mouseTarget.y = e.clientY - rect.height / 2 24 | } 25 | 26 | private anime = () => { 27 | this.mouseCurrent.x = lerp(this.mouseCurrent.x, this.mouseTarget.x, this.lerpRatio) 28 | this.mouseCurrent.y = lerp(this.mouseCurrent.y, this.mouseTarget.y, this.lerpRatio) 29 | this.mouseStalker.style.setProperty('translate', `${this.mouseCurrent.x}px ${this.mouseCurrent.y}px`) 30 | this.animeID = requestAnimationFrame(this.anime) 31 | } 32 | 33 | dispse() { 34 | window.removeEventListener('mousemove', this.handleMouseMove) 35 | this.animeID && cancelAnimationFrame(this.animeID) 36 | } 37 | } 38 | 39 | export const mouseStalker = new MouseStalker() 40 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import CanvasContainer from '../components/CanvasContainer.astro' 3 | import Layout from '../components/Layout.astro' 4 | import Link from '../components/Link.astro' 5 | import MouseStalker from '../components/MouseStalker.astro' 6 | --- 7 | 8 | 9 |
10 | 11 | Github 12 | 13 |
14 |
15 | 16 | 27 | -------------------------------------------------------------------------------- /src/scripts/entry.ts: -------------------------------------------------------------------------------- 1 | import { mouseStalker } from '../components/MouseStalker' 2 | import { qs } from './utils' 3 | import { TCanvas } from './webgl/TCanvas' 4 | 5 | const canvas = new TCanvas(qs('.canvas-container')) 6 | 7 | window.addEventListener('beforeunload', () => { 8 | canvas.dispose() 9 | mouseStalker.dispse() 10 | }) 11 | -------------------------------------------------------------------------------- /src/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | export function resolvePath(path: string) { 2 | const p = path.startsWith('/') ? path.substring(1) : path 3 | return import.meta.env.BASE_URL + p 4 | } 5 | 6 | export function qs(selectors: string) { 7 | return document.querySelector(selectors)! 8 | } 9 | 10 | export function qsAll(selectors: string) { 11 | return document.querySelectorAll(selectors) 12 | } 13 | 14 | export function lerp(x: number, y: number, t: number) { 15 | return x * (1 - t) + y * t 16 | } 17 | -------------------------------------------------------------------------------- /src/scripts/webgl/TCanvas.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { gl } from './core/WebGL' 3 | import VirtualScroll from 'virtual-scroll' 4 | import { Assets, loadAssets } from './utils/assetLoader' 5 | import vertexShader from './shader/vs.glsl' 6 | import fragmentShader from './shader/fs.glsl' 7 | import { calcCoveredTextureScale } from './utils/coveredTexture' 8 | 9 | export class TCanvas { 10 | private centerTarget = new THREE.Vector3() 11 | private edgeTarget = new THREE.Vector3() 12 | private frustum = new THREE.Frustum() 13 | 14 | private cards = new THREE.Group() 15 | private cardParams = { 16 | width: 1, 17 | height: 1.3, 18 | row: 3, 19 | col: 6, 20 | gap: 0.1, 21 | } 22 | 23 | private assets: Assets = { 24 | image1: { path: 'images/1.jpg' }, 25 | image2: { path: 'images/2.jpg' }, 26 | image3: { path: 'images/3.jpg' }, 27 | image4: { path: 'images/4.jpg' }, 28 | image5: { path: 'images/5.jpg' }, 29 | image6: { path: 'images/6.jpg' }, 30 | image7: { path: 'images/7.jpg' }, 31 | image8: { path: 'images/8.jpg' }, 32 | image9: { path: 'images/9.jpg' }, 33 | image10: { path: 'images/10.jpg' }, 34 | image11: { path: 'images/11.jpg' }, 35 | image12: { path: 'images/12.jpg' }, 36 | image13: { path: 'images/13.jpg' }, 37 | image14: { path: 'images/14.jpg' }, 38 | image15: { path: 'images/15.jpg' }, 39 | image16: { path: 'images/16.jpg' }, 40 | image17: { path: 'images/17.jpg' }, 41 | image18: { path: 'images/18.jpg' }, 42 | } 43 | 44 | constructor(private container: HTMLElement) { 45 | loadAssets(this.assets).then(() => { 46 | this.init() 47 | this.createObjects() 48 | this.addEvents() 49 | gl.requestAnimationFrame(this.anime) 50 | }) 51 | } 52 | 53 | private init() { 54 | gl.setup(this.container) 55 | gl.scene.background = new THREE.Color('#000') 56 | gl.camera.position.z = this.cardParams.height * 2 + this.cardParams.gap * (this.cardParams.row + 1) 57 | gl.setResizeCallback(this.resize) 58 | this.resize() 59 | 60 | // gl.scene.add(new THREE.AxesHelper()) 61 | } 62 | 63 | private isMouseDowon = false 64 | private prevMousePosition = { x: 0, y: 0 } 65 | 66 | private addEvents() { 67 | const scroller = new VirtualScroll() 68 | scroller.on((event) => { 69 | this.cards.userData.target.position.y -= event.deltaY * 0.003 70 | }) 71 | 72 | window.addEventListener('mousedown', (e) => { 73 | this.isMouseDowon = true 74 | this.prevMousePosition = { x: e.clientX, y: e.clientY } 75 | }) 76 | 77 | window.addEventListener('mousemove', (e) => { 78 | if (this.isMouseDowon) { 79 | this.cards.userData.target.position.x += (e.clientX - this.prevMousePosition.x) * 0.004 80 | this.cards.userData.target.position.y -= (e.clientY - this.prevMousePosition.y) * 0.004 81 | this.prevMousePosition = { x: e.clientX, y: e.clientY } 82 | } 83 | }) 84 | 85 | window.addEventListener('mouseup', () => { 86 | this.isMouseDowon = false 87 | }) 88 | 89 | window.addEventListener('mouseleave', () => { 90 | this.isMouseDowon = false 91 | }) 92 | } 93 | 94 | private createObjects() { 95 | const { width, height, row, col, gap } = this.cardParams 96 | 97 | const geometry = new THREE.PlaneGeometry(width, height, 50, 50) 98 | const material = new THREE.ShaderMaterial({ 99 | uniforms: { 100 | tImage: { value: null }, 101 | uUvScale: { value: new THREE.Vector2() }, 102 | uSpeed: { value: new THREE.Vector2() }, 103 | uAspect: { value: width / height }, 104 | }, 105 | vertexShader, 106 | fragmentShader, 107 | }) 108 | 109 | const textures = Object.values(this.assets).map((val) => { 110 | const texture = val.data as THREE.Texture 111 | texture.wrapS = THREE.MirroredRepeatWrapping 112 | texture.wrapT = THREE.MirroredRepeatWrapping 113 | return texture 114 | }) 115 | 116 | const centerX = ((width + gap) * (col - 1)) / 2 117 | const centerY = ((height + gap) * (row - 1)) / 2 118 | const halfY = (height + gap) / 2 119 | let i = 0 120 | 121 | for (let x = 0; x < col; x++) { 122 | for (let y = 0; y < row; y++) { 123 | const mat = material.clone() 124 | mat.uniforms.tImage.value = textures[i++] 125 | calcCoveredTextureScale(mat.uniforms.tImage.value, width / height, mat.uniforms.uUvScale.value) 126 | 127 | const mesh = new THREE.Mesh(geometry, mat) 128 | mesh.position.set(width * x + gap * x - centerX, height * y + gap * y - centerY, 0) 129 | 130 | if (x % 2 === 0) { 131 | mesh.position.y += halfY 132 | } 133 | this.cards.add(mesh) 134 | } 135 | } 136 | 137 | this.cards.userData.target = { 138 | position: { x: 0, y: 0, z: 0 }, 139 | } 140 | 141 | gl.scene.add(this.cards) 142 | } 143 | 144 | private resize = () => { 145 | let scale = THREE.MathUtils.smoothstep(gl.size.aspect, 1.969, 3) 146 | scale = scale * (1.5 - 1) + 1 147 | gl.scene.scale.set(scale, scale, scale) 148 | } 149 | 150 | private updateCardPosition() { 151 | gl.camera.updateMatrix() 152 | gl.camera.updateMatrixWorld() 153 | const matrix = new THREE.Matrix4().multiplyMatrices(gl.camera.projectionMatrix, gl.camera.matrixWorldInverse) 154 | this.frustum.setFromProjectionMatrix(matrix) 155 | 156 | const { width, height, row, col, gap } = this.cardParams 157 | const screenHeight = (height + gap) * (row - 1) 158 | const screenWidth = (width + gap) * (col - 1) 159 | 160 | for (let i = 0; i < this.cards.children.length; i++) { 161 | const card = this.cards.children[i] as THREE.Mesh 162 | card.getWorldPosition(this.centerTarget) 163 | 164 | if (this.centerTarget.y < 0) { 165 | this.edgeTarget.copy(this.centerTarget).y += height / 2 + gap 166 | this.edgeTarget.x = 0 167 | if (!this.frustum.containsPoint(this.edgeTarget)) { 168 | card.position.y += screenHeight + height + gap 169 | } 170 | } else { 171 | this.edgeTarget.copy(this.centerTarget).y -= height / 2 + gap 172 | this.edgeTarget.x = 0 173 | if (!this.frustum.containsPoint(this.edgeTarget)) { 174 | card.position.y -= screenHeight + height + gap 175 | } 176 | } 177 | 178 | if (this.centerTarget.x < 0) { 179 | this.edgeTarget.copy(this.centerTarget).x += width / 2 + gap 180 | this.edgeTarget.y = 0 181 | if (!this.frustum.containsPoint(this.edgeTarget)) { 182 | card.position.x += screenWidth + width + gap 183 | } 184 | } else { 185 | this.edgeTarget.copy(this.centerTarget).x -= width / 2 + gap 186 | this.edgeTarget.y = 0 187 | if (!this.frustum.containsPoint(this.edgeTarget)) { 188 | card.position.x -= screenWidth + width + gap 189 | } 190 | } 191 | } 192 | } 193 | 194 | // ---------------------------------- 195 | // animation 196 | private anime = () => { 197 | this.updateCardPosition() 198 | this.cards.position.x = THREE.MathUtils.lerp(this.cards.position.x, this.cards.userData.target.position.x, 0.1) 199 | this.cards.position.y = THREE.MathUtils.lerp(this.cards.position.y, this.cards.userData.target.position.y, 0.1) 200 | 201 | const speedX = this.cards.userData.target.position.x - this.cards.position.x 202 | const speedY = this.cards.userData.target.position.y - this.cards.position.y 203 | 204 | this.cards.children.forEach((child) => { 205 | const card = child as THREE.Mesh 206 | card.material.uniforms.uSpeed.value.x = THREE.MathUtils.lerp(card.material.uniforms.uSpeed.value.x, speedX, 0.1) 207 | card.material.uniforms.uSpeed.value.y = THREE.MathUtils.lerp(card.material.uniforms.uSpeed.value.y, speedY, 0.1) 208 | }) 209 | 210 | gl.render() 211 | } 212 | 213 | // ---------------------------------- 214 | // dispose 215 | dispose() { 216 | gl.dispose() 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/scripts/webgl/core/WebGL.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | class WebGL { 4 | public renderer: THREE.WebGLRenderer 5 | public scene: THREE.Scene 6 | public camera: THREE.PerspectiveCamera 7 | public time = { delta: 0, elapsed: 0 } 8 | 9 | private clock = new THREE.Clock() 10 | private resizeCallback?: () => void 11 | 12 | constructor() { 13 | const { width, height, aspect } = this.size 14 | 15 | this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) 16 | this.renderer.setPixelRatio(window.devicePixelRatio) 17 | this.renderer.setSize(width, height) 18 | this.renderer.shadowMap.enabled = true 19 | this.renderer.outputEncoding = THREE.sRGBEncoding 20 | 21 | this.scene = new THREE.Scene() 22 | this.camera = new THREE.PerspectiveCamera(50, aspect, 0.01, 100) 23 | 24 | window.addEventListener('resize', this.handleResize) 25 | } 26 | 27 | private handleResize = () => { 28 | this.resizeCallback && this.resizeCallback() 29 | 30 | const { width, height, aspect } = this.size 31 | this.camera.aspect = aspect 32 | this.camera.updateProjectionMatrix() 33 | this.renderer.setSize(width, height) 34 | } 35 | 36 | get size() { 37 | const { innerWidth: width, innerHeight: height } = window 38 | return { width, height, aspect: width / height } 39 | } 40 | 41 | setup(container: HTMLElement) { 42 | container.appendChild(this.renderer.domElement) 43 | } 44 | 45 | setResizeCallback(callback: () => void) { 46 | this.resizeCallback = callback 47 | } 48 | 49 | getMesh(name: string) { 50 | return this.scene.getObjectByName(name) as THREE.Mesh 51 | } 52 | 53 | render() { 54 | this.renderer.render(this.scene, this.camera) 55 | } 56 | 57 | requestAnimationFrame(callback: () => void) { 58 | gl.renderer.setAnimationLoop(() => { 59 | this.time.delta = this.clock.getDelta() 60 | this.time.elapsed = this.clock.getElapsedTime() 61 | callback() 62 | }) 63 | } 64 | 65 | cancelAnimationFrame() { 66 | gl.renderer.setAnimationLoop(null) 67 | } 68 | 69 | dispose() { 70 | this.cancelAnimationFrame() 71 | gl.scene?.clear() 72 | } 73 | } 74 | 75 | export const gl = new WebGL() 76 | -------------------------------------------------------------------------------- /src/scripts/webgl/shader/fs.glsl: -------------------------------------------------------------------------------- 1 | uniform sampler2D tImage; 2 | uniform vec2 uUvScale; 3 | uniform float uAspect; 4 | varying vec2 vUv; 5 | varying vec3 vWorldPos; 6 | 7 | float luma(vec3 color) { 8 | return dot(color, vec3(0.299, 0.587, 0.114)); 9 | } 10 | 11 | void main() { 12 | vec2 uv = (vUv - 0.5) * uUvScale * 0.8 + 0.5; 13 | vec4 tex = texture2D(tImage, uv + vWorldPos.xy * 0.05); 14 | float gray = luma(tex.rgb); 15 | 16 | float dist = length(vWorldPos.xy); 17 | float threshold = smoothstep(1.0, 2.5, dist); 18 | vec3 color = mix(tex.rgb, vec3(gray), threshold); 19 | 20 | gl_FragColor = vec4(color, 1.0); 21 | } -------------------------------------------------------------------------------- /src/scripts/webgl/shader/vs.glsl: -------------------------------------------------------------------------------- 1 | uniform vec2 uSpeed; 2 | varying vec2 vUv; 3 | varying vec3 vWorldPos; 4 | 5 | void main() { 6 | vUv = uv; 7 | 8 | vec4 worldPos = modelViewMatrix * vec4( position, 1.0 ); 9 | vWorldPos = worldPos.xyz; 10 | float dist = length(worldPos.xy); 11 | dist = pow(dist, 1.5); 12 | worldPos.xy -= dist * uSpeed.xy * 0.03; 13 | worldPos.z += dist * length(uSpeed) * 0.04; 14 | 15 | gl_Position = projectionMatrix * worldPos; 16 | } -------------------------------------------------------------------------------- /src/scripts/webgl/utils/Mouse2D.ts: -------------------------------------------------------------------------------- 1 | class Mouse2D { 2 | position: [number, number] = [0, 0] 3 | 4 | constructor() { 5 | window.addEventListener('mousemove', this.handleMouseMove) 6 | window.addEventListener('touchmove', this.handleTouchMove) 7 | } 8 | 9 | private handleMouseMove = (e: MouseEvent) => { 10 | const x = (e.clientX / window.innerWidth) * 2 - 1 11 | const y = -1 * ((e.clientY / window.innerHeight) * 2 - 1) 12 | this.position = [x, y] 13 | } 14 | 15 | private handleTouchMove = (e: TouchEvent) => { 16 | const { pageX, pageY } = e.touches[0] 17 | const x = (pageX / window.innerWidth) * 2 - 1 18 | const y = -1 * ((pageY / window.innerHeight) * 2 - 1) 19 | this.position = [x, y] 20 | } 21 | 22 | dispose() { 23 | window.removeEventListener('mousemove', this.handleMouseMove) 24 | window.removeEventListener('touchmove', this.handleTouchMove) 25 | } 26 | } 27 | 28 | export const mouse2d = new Mouse2D() 29 | -------------------------------------------------------------------------------- /src/scripts/webgl/utils/OrbitControls.ts: -------------------------------------------------------------------------------- 1 | import { OrbitControls as OC } from 'three/examples/jsm/controls/OrbitControls' 2 | import { gl } from '../core/WebGL' 3 | 4 | class OrbitControls { 5 | private orbitControls: OC 6 | 7 | constructor() { 8 | this.orbitControls = new OC(gl.camera, gl.renderer.domElement) 9 | this.orbitControls.enableDamping = true 10 | this.orbitControls.dampingFactor = 0.1 11 | } 12 | 13 | get primitive() { 14 | return this.orbitControls 15 | } 16 | 17 | disableDamping() { 18 | this.orbitControls.enableDamping = false 19 | } 20 | 21 | update() { 22 | this.orbitControls.update() 23 | } 24 | } 25 | 26 | export const controls = new OrbitControls() 27 | -------------------------------------------------------------------------------- /src/scripts/webgl/utils/assetLoader.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 3 | import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader' 4 | import { resolvePath } from '../../utils' 5 | 6 | export type Assets = { 7 | [key in string]: { 8 | data?: THREE.Texture | THREE.VideoTexture | GLTF 9 | path: string 10 | encoding?: boolean 11 | flipY?: boolean 12 | } 13 | } 14 | 15 | export async function loadAssets(assets: Assets) { 16 | const textureLoader = new THREE.TextureLoader() 17 | const gltfLoader = new GLTFLoader() 18 | const rgbeLoader = new RGBELoader() 19 | 20 | const getExtension = (path: string) => { 21 | const s = path.split('.') 22 | return s[s.length - 1] 23 | } 24 | 25 | await Promise.all( 26 | Object.values(assets).map(async (v) => { 27 | const path = resolvePath(v.path) 28 | const extension = getExtension(path) 29 | 30 | if (['jpg', 'png', 'webp'].includes(extension)) { 31 | const texture = await textureLoader.loadAsync(path) 32 | texture.userData.aspect = texture.image.width / texture.image.height 33 | v.encoding && (texture.encoding = THREE.sRGBEncoding) 34 | v.flipY !== undefined && (texture.flipY = v.flipY) 35 | v.data = texture 36 | } else if (['glb'].includes(extension)) { 37 | const gltf = await gltfLoader.loadAsync(path) 38 | v.data = gltf 39 | } else if (['webm', 'mp4'].includes(extension)) { 40 | const video = document.createElement('video') 41 | video.src = path 42 | video.muted = true 43 | video.loop = true 44 | video.autoplay = true 45 | video.preload = 'metadata' 46 | video.playsInline = true 47 | // await video.play() 48 | const texture = new THREE.VideoTexture(video) 49 | texture.userData.aspect = video.videoWidth / video.videoHeight 50 | v.encoding && (texture.encoding = THREE.sRGBEncoding) 51 | v.data = texture 52 | } else if (['hdr'].includes(extension)) { 53 | const texture = await rgbeLoader.loadAsync(path) 54 | texture.mapping = THREE.EquirectangularReflectionMapping 55 | v.data = texture 56 | } 57 | }), 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/scripts/webgl/utils/coveredTexture.ts: -------------------------------------------------------------------------------- 1 | export function calcCoveredTextureScale(texture: THREE.Texture, aspect: number, target?: THREE.Vector2) { 2 | const imageAspect = texture.image.width / texture.image.height 3 | 4 | let result: [number, number] = [1, 1] 5 | if (aspect < imageAspect) result = [aspect / imageAspect, 1] 6 | else result = [1, imageAspect / aspect] 7 | 8 | target?.set(result[0], result[1]) 9 | 10 | return result 11 | } 12 | 13 | export function coveredTexture(texture: THREE.Texture, screenAspect: number) { 14 | texture.matrixAutoUpdate = false 15 | const scale = calcCoveredTextureScale(texture, screenAspect) 16 | texture.matrix.setUvTransform(0, 0, scale[0], scale[1], 0, 0.5, 0.5) 17 | 18 | return texture 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | @use 'ress'; 2 | @use './loadFonts.scss'; 3 | @use './mixins/medias.scss' as *; 4 | @use './mixins/fonts.scss'; 5 | 6 | html { 7 | width: 100%; 8 | height: 100%; 9 | font-size: calc(1000vw / 750); 10 | @include pc { 11 | font-size: calc(1000vw / 1500); 12 | } 13 | @include fonts.poppins; 14 | overflow: hidden; 15 | } 16 | 17 | body { 18 | position: relative; 19 | width: 100%; 20 | height: 100%; 21 | } 22 | 23 | a { 24 | color: inherit; 25 | text-decoration: none; 26 | } 27 | 28 | ul { 29 | list-style: none; 30 | } 31 | 32 | h1, 33 | h2, 34 | h3, 35 | h4, 36 | h5, 37 | h6 { 38 | font-size: inherit; 39 | font-weight: inherit; 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/loadFonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'poppins'; 3 | src: url('/fonts/Poppins-Regular.ttf') format('truetype'); 4 | font-style: normal; 5 | font-display: swap; 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/mixins/fonts.scss: -------------------------------------------------------------------------------- 1 | @mixin poppins($weight: 400) { 2 | font-family: 'poppins'; 3 | font-weight: $weight; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/mixins/medias.scss: -------------------------------------------------------------------------------- 1 | @mixin pc { 2 | @media screen and (min-width: 751px) { 3 | @content; 4 | } 5 | } 6 | 7 | @mixin sp { 8 | @media screen and (max-width: 750px) { 9 | @content; 10 | } 11 | } 12 | 13 | @mixin hoverable { 14 | @media (hover: hover) and (pointer: fine) { 15 | @content; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types/glsl.d.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/UstymUkhman/vite-plugin-glsl/blob/main/test/shaders.d.ts 2 | 3 | declare module '*.vs' { 4 | const value: string 5 | export default value 6 | } 7 | 8 | declare module '*.fs' { 9 | const value: string 10 | export default value 11 | } 12 | 13 | declare module '*.vert' { 14 | const value: string 15 | export default value 16 | } 17 | 18 | declare module '*.frag' { 19 | const value: string 20 | export default value 21 | } 22 | 23 | declare module '*.glsl' { 24 | const value: string 25 | export default value 26 | } 27 | 28 | declare module '*.wgsl' { 29 | const value: string 30 | export default value 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "isolatedModules": false, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true 18 | }, 19 | "extends": "astro/tsconfigs/base" 20 | } 21 | --------------------------------------------------------------------------------