├── .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 |
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 |
--------------------------------------------------------------------------------