├── .editorconfig ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── fonts │ └── Boldonse-Regular.woff2 ├── images │ ├── 0.webp │ ├── 1.webp │ ├── 2.webp │ ├── 3.webp │ ├── 4.webp │ ├── 5.webp │ ├── 6.webp │ ├── 7.webp │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── cover.png │ ├── favicon-16x16.png │ └── favicon-32x32.png └── site.webmanifest ├── src ├── css │ ├── reset.css │ └── style.css ├── index.html ├── js │ └── main.js └── shaders │ ├── img.frag │ └── img.vert └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{html,js,ts,json,less,sass,scss,css}] 10 | indent_style = tab 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | /dist-ssr/ 4 | 5 | # environment 6 | /.env* 7 | .env.production 8 | 9 | # generated types 10 | .astro/ 11 | 12 | # dependencies 13 | node_modules/ 14 | 15 | # lock files 16 | package-lock.json 17 | yarn.lock 18 | pnpm-lock.yaml 19 | 20 | # logs 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | 26 | # editor settings 27 | /.vscode/ 28 | /.jshint* 29 | 30 | # Logs 31 | tmp 32 | logs 33 | *.log* 34 | *.local 35 | .DS_Store 36 | 37 | .code-workspace -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | corepack=false 2 | package-manager=false 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "jsxBracketSameLine": false, 6 | "printWidth": 999, 7 | "quoteProps": "consistent", 8 | "semi": true, 9 | "singleQuote": true, 10 | "tabWidth": 4, 11 | "trailingComma": "all", 12 | "useTabs": true 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lusion Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # “Solution” to Connect WebGL Visuals to Multiple DOM Elements with One Canvas - Without Scroll-Jacking 2 | 3 | **TL;DR:** An imperfect solution. One that creates another issue, but it's a problem we can mitigate. 4 | 5 | 👉 [Live Demo](https://webgl-scroll-sync.lusion.co/) 6 | 7 | --- 8 | 9 | In the world of web development, it's basically gospel that scroll-jacking is bad. If you're a developer who hasn't worked much with WebGL or animation-heavy interactions, you might wonder why so many award-winning websites (like our own at [Lusion](https://lusion.co)) seem to always be scroll-jacked. 10 | 11 | The truth is, aside from the aesthetic of smooth scrolling, scroll-jacking actually solves a key synchronization issue - especially on mobile. The problem? Native scrolling doesn’t run on the same thread as `requestAnimationFrame (rAF)`. Since WebGL rendering relies on `rAF` to keep visuals smooth and efficient, this desync causes issues. 12 | 13 | --- 14 | 15 | ## The Problem 16 | 17 | Let’s say you want to render a 3D box inside a DOM element - maybe a `
`. The obvious approach: create a WebGL canvas and place it inside that div. Done, right? 18 | 19 | Not quite. 20 | 21 | The problem is: 22 | 23 | - You can’t have infinite WebGL contexts on a single page. 24 | - Resources can’t be shared across different contexts. 25 | (👀 Looking at you, WebGPU…) 26 | - You can't apply shader effect outside that DOM element area. 27 | 28 | A better approach is to create a single fullscreen WebGL canvas, fixed to the viewport, and render everything there. Then, during each `rAF`, you get the bounding box of your DOM elements and scroll position (`window.scrollY`) to position your 3D objects accordingly. 29 | 30 | But here comes the kicker: 31 | If the scroll happens between two `rAF` calls, the canvas doesn’t get the updated scroll info in time - so your visuals start drifting, lagging behind where they’re meant to be. 32 | 33 | This is why scroll-jacking became the go-to workaround. It gives you full control over the scroll timing, which helps ensure the WebGL visuals stay in sync with your DOM elements. 34 | 35 | --- 36 | 37 | ## A “New” (but kinda overlooked) Approach 38 | 39 | Here’s an idea that I haven’t seen many used - and it's super simple: 40 | 41 | **What if the WebGL canvas isn't fixed to the viewport?** 42 | 43 | Wait, what? 44 | 45 | Yeah - instead of setting the canvas `position: fixed` and pinning it behind everything, let it scroll with the page. Set it to `position: absolute`, and during each `rAF`, offset the canvas to match the current scroll position. 46 | 47 | At first glance, it sounds like the same thing. But here’s the difference: 48 | If the scroll happens between two `rAF`s, the canvas will physically scroll _with_ the page, keeping your 3D visuals attached to the DOM elements they’re linked to. No drift. 49 | 50 | Pretty cool, right? 51 | 52 | --- 53 | 54 | ## The Tradeoff (and Fix) 55 | 56 | There is a catch: 57 | Since the canvas now scrolls, it may get clipped when parts of the viewport move outside the canvas bounds. You’ll notice some visual issues during fast scrolls. 58 | 59 | But this can be mitigated: 60 | 61 | - Simply add **vertical padding** to your canvas - render extra pixels offscreen. In our demo, we added 25% top and bottom padding to the canvas. 62 | - Or, for better performance, render to a **fullscreen framebuffer** and apply **edge blending or fading** to mask the overflow areas. 63 | 64 | Of course, this isn't free. You'll be rendering more pixels, which can be a concern depending on performance requirements. But in many use cases, it’s a totally acceptable tradeoff. The bottm-line to us is that drifting is visually distributing but clipping isn't, so you can based on your needed to apply different fixes to this solution. 65 | 66 | --- 67 | 68 | ## In Summary 69 | 70 | This approach won’t magically fix the `rAF` desync problem - but it offers a neat workaround for keeping WebGL visuals visually in sync with DOM elements, _without_ scroll-jacking. 71 | 72 | It's not perfect, but sometimes, "good enough" is what gets the job done. 73 | 74 | **Hope you find this useful!** 75 | 76 | --- 77 | 78 | ## How to Use This Demo 79 | 80 | Clone the repo and run it locally: 81 | 82 | ```bash 83 | # Clone the repo 84 | git clone https://github.com/lusionltd/WebGL-Scroll-Sync 85 | cd WebGL-Scroll-Sync 86 | 87 | # Install dependencies 88 | npm install 89 | 90 | # Run the development server 91 | npm run dev 92 | 93 | # Build for production 94 | npm run build 95 | ``` 96 | 97 | Explore the source code in the `/src` folder to see how the WebGL canvas is managed and synced with DOM elements. 98 | 99 | --- 100 | 101 | ## Contribution 102 | 103 | We don't expect any contribution to this repo as it is just a demo. 104 | 105 | --- 106 | 107 | ## License 108 | 109 | MIT License. 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-threejs-setup", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "vite": "^5.1.4" 13 | }, 14 | "dependencies": { 15 | "three": "^0.161.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/Boldonse-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/fonts/Boldonse-Regular.woff2 -------------------------------------------------------------------------------- /public/images/0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/0.webp -------------------------------------------------------------------------------- /public/images/1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/1.webp -------------------------------------------------------------------------------- /public/images/2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/2.webp -------------------------------------------------------------------------------- /public/images/3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/3.webp -------------------------------------------------------------------------------- /public/images/4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/4.webp -------------------------------------------------------------------------------- /public/images/5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/5.webp -------------------------------------------------------------------------------- /public/images/6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/6.webp -------------------------------------------------------------------------------- /public/images/7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/7.webp -------------------------------------------------------------------------------- /public/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/cover.png -------------------------------------------------------------------------------- /public/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusionltd/WebGL-Scroll-Sync/d2f2c844b449878c760e3f435ab01c85ed5ee072/public/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"Lusion - WebGL Scroll Sync","short_name":"WebGL Scroll Sync","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#490700","background_color":"#490700","display":"standalone"} -------------------------------------------------------------------------------- /src/css/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | https://www.joshwcomeau.com/css/custom-css-reset/ 3 | */ 4 | 5 | /* 1. Use a more-intuitive box-sizing model */ 6 | *, 7 | *::before, 8 | *::after { 9 | box-sizing: border-box; 10 | } 11 | 12 | /* 2. Remove default margin */ 13 | * { 14 | margin: 0; 15 | } 16 | 17 | /* 3. Enable keyword animations */ 18 | @media (prefers-reduced-motion: no-preference) { 19 | html { 20 | interpolate-size: allow-keywords; 21 | } 22 | } 23 | 24 | body { 25 | /* 4. Add accessible line-height */ 26 | line-height: 1.5; 27 | /* 5. Improve text rendering */ 28 | -webkit-font-smoothing: antialiased; 29 | } 30 | 31 | /* 6. Improve media defaults */ 32 | img, 33 | picture, 34 | video, 35 | canvas, 36 | svg { 37 | display: block; 38 | max-width: 100%; 39 | } 40 | 41 | /* 7. Inherit fonts for form controls */ 42 | input, 43 | button, 44 | textarea, 45 | select { 46 | font: inherit; 47 | } 48 | 49 | /* 8. Avoid text overflows */ 50 | p, 51 | h1, 52 | h2, 53 | h3, 54 | h4, 55 | h5, 56 | h6 { 57 | overflow-wrap: break-word; 58 | } 59 | 60 | /* 9. Improve line wrapping */ 61 | p { 62 | text-wrap: pretty; 63 | } 64 | h1, 65 | h2, 66 | h3, 67 | h4, 68 | h5, 69 | h6 { 70 | text-wrap: balance; 71 | } 72 | 73 | /* 74 | 10. Create a root stacking context 75 | */ 76 | #root, 77 | #__next { 78 | isolation: isolate; 79 | } 80 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Boldonse'; 3 | src: url('/fonts/Boldonse-Regular.woff2') format('woff2'); 4 | font-weight: normal; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | :root { 10 | /* Typography */ 11 | --font-family: 'Boldonse', sans-serif; 12 | --font-size-heading: 140px; 13 | --font-size-h1: 370px; 14 | --font-size-h2: 170px; 15 | --font-size-h3: 60px; 16 | --font-size-body1: 16px; 17 | --font-size-body2: 18px; 18 | --font-size-body3: 20px; 19 | --font-size-body4: 14px; 20 | --font-size-body5: 18px; 21 | --font-size-body6: 16px; 22 | --line-height-heading: 1.3; 23 | 24 | /* Colors */ 25 | --color-text: #e32604; 26 | --color-background: #490700; 27 | --color-border: #82e7c2; 28 | --color-white: #ffffff; 29 | 30 | /* Layout */ 31 | --margin-top-section: 250px; 32 | --breakpoint-mobile: 1024px; 33 | --border-radius: 20px; 34 | --border-width: 3px; 35 | --border-width-image: 8px; 36 | 37 | /* Animations */ 38 | --slow-start-easing: cubic-bezier(0.73, 0, 0, 1); 39 | --fast-start-easing: cubic-bezier(0.17, 0.67, 0.1, 0.99); 40 | --faster-start-easing: cubic-bezier(0.19, 1, 0.22, 1); 41 | 42 | @media (max-width: 1900px) { 43 | --font-size-h1: 200px; 44 | } 45 | 46 | @media (max-width: 1024px) { 47 | --font-size-h1: 10vw; 48 | --font-size-h2: 50px; 49 | --font-size-h3: 24px; 50 | --font-size-body1: 16px; 51 | --font-size-body2: 14px; 52 | --font-size-body3: 14px; 53 | --font-size-body4: 12px; 54 | --font-size-body5: 12px; 55 | --font-size-body6: 14px; 56 | --margin-top-section: 150px; 57 | --border-width-image: 6px; 58 | } 59 | } 60 | 61 | body { 62 | color: var(--color-text); 63 | background-color: var(--color-background); 64 | overflow-x: hidden; 65 | font-family: var(--font-family); 66 | 67 | &::selection { 68 | background-color: var(--color-text); 69 | color: var(--color-background); 70 | } 71 | 72 | &::-moz-selection { 73 | background-color: var(--color-text); 74 | color: var(--color-background); 75 | } 76 | } 77 | 78 | * { 79 | box-sizing: border-box; 80 | -webkit-tap-highlight-color: transparent; 81 | } 82 | 83 | a { 84 | display: inline-block; 85 | color: inherit; 86 | outline: none; 87 | position: relative; 88 | text-decoration: none; 89 | 90 | @media (hover: hover) { 91 | &:hover::after { 92 | transition: transform 1s var(--fast-start-easing); 93 | transform: scaleX(0); 94 | } 95 | 96 | &:hover::before { 97 | transform: scaleX(1); 98 | transition: transform 1s 0.075s var(--fast-start-easing); 99 | } 100 | } 101 | 102 | &::before, 103 | &::after { 104 | content: ''; 105 | display: block; 106 | width: 100%; 107 | height: 3px; 108 | position: absolute; 109 | left: 0; 110 | right: 0; 111 | bottom: 0; 112 | background-color: var(--color-text); 113 | position: absolute; 114 | } 115 | 116 | &::before { 117 | transform: scaleX(0); 118 | transform-origin: left; 119 | transition: transform 1s 0s var(--fast-start-easing); 120 | } 121 | 122 | &::after { 123 | transform-origin: right; 124 | transition: transform 1s 0.075s var(--fast-start-easing); 125 | } 126 | } 127 | 128 | header { 129 | position: fixed; 130 | top: 0; 131 | left: 0; 132 | width: 100%; 133 | } 134 | 135 | #header { 136 | margin: 40px 30px 120px; 137 | 138 | @media (max-width: 1024px) { 139 | margin-bottom: 40px; 140 | margin-top: 40px; 141 | } 142 | } 143 | 144 | #header__made-by, 145 | #overlay-toggle { 146 | font-size: var(--font-size-body3); 147 | } 148 | 149 | #header__made-by { 150 | position: absolute; 151 | left: 30px; 152 | top: 30px; 153 | 154 | @media (max-width: 1024px) { 155 | display: none; 156 | } 157 | } 158 | 159 | #settings { 160 | position: fixed; 161 | left: 50%; 162 | width: 100%; 163 | text-transform: uppercase; 164 | font-size: var(--font-size-body6); 165 | display: flex; 166 | justify-content: center; 167 | flex-direction: column; 168 | align-items: center; 169 | z-index: 100; 170 | 171 | @media (min-width: 1024px) { 172 | transform: translateX(-50%); 173 | bottom: 30px; 174 | } 175 | 176 | @media (max-width: 1024px) { 177 | bottom: 0; 178 | transform: translate(-50%, calc(100% - 48px - var(--font-size-body6))); 179 | transition: transform 1s var(--faster-start-easing); 180 | 181 | @media (min-width: 1024px) { 182 | bottom: 30px; 183 | } 184 | 185 | &.is-active { 186 | transform: translate(-50%, 10px); 187 | } 188 | } 189 | 190 | &.is-active { 191 | #settings__button span:last-child::before { 192 | transform: translateY(-50%) rotate(180deg); 193 | } 194 | 195 | #settings__button span:last-child::after { 196 | transform: translateX(-50%) rotate(90deg); 197 | } 198 | 199 | #settings__items { 200 | transform: translateY(-10px); 201 | transition: transform 1s 0.2s var(--faster-start-easing); 202 | } 203 | } 204 | } 205 | 206 | #settings__button { 207 | text-align: center; 208 | background: var(--color-text); 209 | color: var(--color-background); 210 | padding: 25px 40px calc(23px + 10px); 211 | line-height: 1; 212 | border-top-left-radius: var(--border-radius); 213 | border-top-right-radius: var(--border-radius); 214 | border: var(--border-width) solid var(--color-background); 215 | border-bottom: none; 216 | display: flex; 217 | gap: 10px; 218 | align-items: center; 219 | 220 | span:last-child { 221 | position: relative; 222 | width: 10px; 223 | height: 10px; 224 | top: -2px; 225 | 226 | &::before { 227 | position: absolute; 228 | content: ''; 229 | display: block; 230 | width: 100%; 231 | top: 50%; 232 | transform: translateY(-50%); 233 | transform-origin: center; 234 | height: 3px; 235 | background: var(--color-background); 236 | transition: transform 1s var(--fast-start-easing); 237 | } 238 | 239 | &::after { 240 | position: absolute; 241 | content: ''; 242 | display: block; 243 | width: 3px; 244 | left: 50%; 245 | transform: translateX(-50%); 246 | transform-origin: center; 247 | top: 0; 248 | height: 100%; 249 | background: var(--color-background); 250 | transition: transform 1s var(--fast-start-easing); 251 | } 252 | } 253 | 254 | @media (min-width: 1024px) { 255 | display: none; 256 | } 257 | } 258 | 259 | #settings__items { 260 | list-style: none; 261 | padding: 0; 262 | background: var(--color-text); 263 | color: var(--color-background); 264 | border: var(--border-width) solid var(--color-background); 265 | border-top-left-radius: var(--border-radius); 266 | border-top-right-radius: var(--border-radius); 267 | display: flex; 268 | flex-direction: column; 269 | 270 | @media (max-width: 1024px) { 271 | border-bottom: none; 272 | width: calc(100vw - 40px + 2px); 273 | transition: transform 0s 1s var(--faster-start-easing); 274 | } 275 | 276 | @media (min-width: 1024px) { 277 | flex-direction: row; 278 | border-bottom-left-radius: var(--border-radius); 279 | border-bottom-right-radius: var(--border-radius); 280 | } 281 | } 282 | 283 | .settings__item { 284 | --size: 20px; 285 | 286 | line-height: 1; 287 | position: relative; 288 | cursor: pointer; 289 | 290 | @media (max-width: 1024px) { 291 | padding: 25px 30px 20px 50px; 292 | 293 | &:not(:last-child) { 294 | border-bottom: var(--border-width) solid var(--color-background); 295 | } 296 | } 297 | 298 | @media (min-width: 1024px) { 299 | padding: 30px 40px 30px 60px; 300 | 301 | &:first-child { 302 | padding-left: 90px; 303 | 304 | &::before { 305 | left: 50px; 306 | } 307 | 308 | &::after { 309 | left: 50px; 310 | } 311 | } 312 | 313 | &:nth-child(3) { 314 | padding-right: 70px; 315 | } 316 | } 317 | 318 | &::before { 319 | content: ''; 320 | display: block; 321 | width: var(--size); 322 | height: var(--size); 323 | border: 2px solid var(--color-background); 324 | position: absolute; 325 | border-radius: 50%; 326 | left: 20px; 327 | top: 50%; 328 | transform: translateY(calc(-50%)); 329 | } 330 | 331 | &::after { 332 | content: ''; 333 | display: block; 334 | width: var(--size); 335 | height: var(--size); 336 | position: absolute; 337 | border-radius: 50%; 338 | background: var(--color-background); 339 | left: 20px; 340 | top: 50%; 341 | transform: translateY(calc(-50%)) scale(0.1); 342 | opacity: 0; 343 | transition: transform 0.5s var(--fast-start-easing), opacity 0.5s var(--fast-start-easing); 344 | } 345 | 346 | &.is-active { 347 | &::after { 348 | transform: translateY(calc(-50%)) scale(0.6); 349 | opacity: 1; 350 | } 351 | } 352 | } 353 | 354 | #overlay-open, 355 | #overlay-close { 356 | z-index: 100; 357 | right: 30px; 358 | top: 30px; 359 | cursor: pointer; 360 | user-select: none; 361 | 362 | @media (max-width: 1024px) { 363 | right: 50%; 364 | transform: translateX(50%); 365 | } 366 | } 367 | 368 | #overlay-open { 369 | position: fixed; 370 | } 371 | 372 | #overlay-close { 373 | position: absolute; 374 | color: var(--color-background); 375 | } 376 | 377 | #header__title01, 378 | #header__title02 { 379 | line-height: var(--line-height-heading); 380 | font-size: var(--font-size-h2); 381 | text-align: center; 382 | text-transform: uppercase; 383 | } 384 | 385 | #header__title01 { 386 | @media (max-width: 1024px) { 387 | margin-top: 80px; 388 | } 389 | } 390 | 391 | #header__description { 392 | font-size: var(--font-size-body3); 393 | max-width: 25ch; 394 | margin-left: auto; 395 | margin-right: auto; 396 | text-align: center; 397 | margin-bottom: 80px; 398 | margin-top: 80px; 399 | line-height: 1.6; 400 | 401 | @media (max-width: 1024px) { 402 | margin-bottom: 40px; 403 | margin-top: 40px; 404 | } 405 | } 406 | 407 | #overlay { 408 | position: fixed; 409 | inset: 0; 410 | z-index: 9998; 411 | color: var(--color-background); 412 | pointer-events: none; 413 | transition: opacity 0.3s ease-in-out; 414 | 415 | &::selection { 416 | background: var(--color-background); 417 | color: var(--color-text); 418 | } 419 | } 420 | 421 | html.is-overlay-active #overlay { 422 | pointer-events: auto; 423 | } 424 | 425 | #overlay__content { 426 | position: absolute; 427 | inset: 0; 428 | transform: translateY(-100%); 429 | overflow: hidden; 430 | transition: transform 1s var(--fast-start-easing); 431 | background: var(--color-text); 432 | } 433 | 434 | .is-overlay-active #overlay__content { 435 | transform: translateY(0); 436 | } 437 | 438 | #overlay__content-inner { 439 | position: absolute; 440 | inset: 0; 441 | transform: translateY(100%); 442 | transition: transform 1s var(--fast-start-easing); 443 | } 444 | 445 | .is-overlay-active #overlay__content-inner { 446 | transform: translateY(0); 447 | } 448 | 449 | #overlay__content-inner-inner { 450 | position: absolute; 451 | display: flex; 452 | flex-direction: column; 453 | gap: 50px; 454 | inset: 30px; 455 | 456 | @media (max-width: 1024px) { 457 | gap: 30px; 458 | } 459 | } 460 | 461 | #overlay__content-title { 462 | font-size: var(--font-size-h3); 463 | max-width: 20ch; 464 | 465 | @media (max-width: 1024px) { 466 | margin-top: 40px; 467 | } 468 | } 469 | 470 | #overlay__content-list { 471 | --padding: 15px; 472 | 473 | font-size: var(--font-size-body2); 474 | list-style: none; 475 | padding: 0; 476 | text-transform: uppercase; 477 | display: flex; 478 | flex-direction: column; 479 | gap: var(--padding); 480 | font-size: var(--font-size-body4); 481 | border-top: var(--border-width) solid var(--color-background); 482 | padding-top: var(--padding); 483 | 484 | @media (max-width: 1024px) { 485 | --padding: 10px; 486 | } 487 | 488 | li { 489 | padding-bottom: var(--padding); 490 | border-bottom: var(--border-width) solid var(--color-background); 491 | 492 | &::before { 493 | content: attr(data-index); 494 | margin-right: 50px; 495 | text-align: center; 496 | line-height: 10px; 497 | display: inline-block; 498 | width: 2ex; 499 | 500 | @media (max-width: 1024px) { 501 | margin-right: 3ex; 502 | } 503 | } 504 | } 505 | } 506 | 507 | #overlay__content-description { 508 | font-size: var(--font-size-body5); 509 | max-width: 25ch; 510 | margin-top: auto; 511 | } 512 | 513 | #overlay a { 514 | transition: color 0.1s; 515 | } 516 | 517 | #overlay a:hover { 518 | color: var(--color-white); 519 | } 520 | 521 | #wrapper { 522 | position: relative; 523 | overflow: hidden; 524 | padding-left: 20px; 525 | padding-right: 20px; 526 | } 527 | 528 | #canvas { 529 | pointer-events: none; 530 | position: absolute; 531 | left: 0; 532 | z-index: -1; 533 | } 534 | 535 | html.no-fix #canvas { 536 | position: fixed; 537 | top: 0; 538 | } 539 | 540 | #section02, 541 | #section03, 542 | #section04 { 543 | margin-top: var(--margin-top-section); 544 | } 545 | 546 | #section01, 547 | #section03 { 548 | width: 30vw; 549 | margin-left: auto; 550 | margin-right: auto; 551 | 552 | @media (max-width: 1024px) { 553 | width: 100%; 554 | } 555 | } 556 | 557 | #section02, 558 | #section04 { 559 | width: 60vw; 560 | margin-left: auto; 561 | margin-right: auto; 562 | display: flex; 563 | flex-direction: column; 564 | gap: 20px; 565 | 566 | @media (max-width: 1024px) { 567 | width: 100%; 568 | } 569 | } 570 | 571 | #section04__bottom-content { 572 | @media (max-width: 1024px) { 573 | margin-top: calc(var(--margin-top-section) / 1.5); 574 | } 575 | } 576 | 577 | #section02__top-content, 578 | #section02__bottom-content, 579 | #section04__top-content { 580 | @media (max-width: 1024px) { 581 | display: none !important; 582 | } 583 | } 584 | 585 | #section02__top-content, 586 | #section02__bottom-content, 587 | #section04__top-content, 588 | #section04__bottom-content { 589 | display: flex; 590 | justify-content: space-between; 591 | font-size: var(--font-size-body2); 592 | text-transform: uppercase; 593 | } 594 | 595 | #section02__images-wrapper, 596 | #section04__images-wrapper { 597 | display: flex; 598 | gap: 20px; 599 | 600 | @media (max-width: 1024px) { 601 | flex-direction: column; 602 | gap: var(--margin-top-section); 603 | } 604 | } 605 | 606 | #section02__img01, 607 | #section02__img02, 608 | #section02__img03, 609 | #section04__img01, 610 | #section04__img02, 611 | #section04__img03 { 612 | flex: 1; 613 | } 614 | 615 | #section02__img01, 616 | #section02__img03, 617 | #section04__img01, 618 | #section04__img03 { 619 | width: 70%; 620 | margin-left: auto; 621 | margin-right: auto; 622 | } 623 | 624 | #section01__img01, 625 | #section02__img02, 626 | #section03__img01, 627 | #section04__img02 { 628 | @media (max-width: 1024px) { 629 | &::before { 630 | content: 'webgl scroll test'; 631 | position: absolute; 632 | font-size: var(--font-size-body2); 633 | text-transform: uppercase; 634 | z-index: 1; 635 | bottom: 0; 636 | transform: translate(calc(-6px), calc(100% + 6px + 10px)); 637 | left: 0; 638 | } 639 | 640 | &::after { 641 | content: '01'; 642 | position: absolute; 643 | font-size: var(--font-size-body2); 644 | text-transform: uppercase; 645 | z-index: 1; 646 | bottom: 0; 647 | transform: translate(calc(6px), calc(100% + 6px + 10px)); 648 | right: 0; 649 | } 650 | } 651 | } 652 | 653 | #section01__img01 div, 654 | #section02__img02 div, 655 | #section03__img01 div, 656 | #section04__img02 div { 657 | @media (max-width: 1024px) { 658 | &::before { 659 | content: 'webgl scroll test'; 660 | position: absolute; 661 | font-size: var(--font-size-body2); 662 | text-transform: uppercase; 663 | z-index: 1; 664 | top: 0; 665 | transform: translate(calc(-6px), calc(-100% - 6px - 10px)); 666 | left: 0; 667 | } 668 | 669 | &::after { 670 | content: '01'; 671 | position: absolute; 672 | font-size: var(--font-size-body2); 673 | text-transform: uppercase; 674 | z-index: 1; 675 | top: 0; 676 | transform: translate(calc(6px), calc(-100% - 6px - 10px)); 677 | right: 0; 678 | } 679 | } 680 | } 681 | 682 | .image { 683 | border: var(--border-width-image) solid var(--color-border); 684 | position: relative; 685 | aspect-ratio: 1024 / 1536; 686 | } 687 | 688 | .image img { 689 | position: absolute; 690 | top: 0; 691 | left: 0; 692 | width: 100%; 693 | height: 100%; 694 | object-fit: cover; 695 | } 696 | 697 | footer { 698 | position: relative; 699 | font-size: var(--font-size-h1); 700 | text-transform: uppercase; 701 | line-height: 1.25; 702 | margin-top: var(--margin-top-section); 703 | margin-left: 30px; 704 | margin-right: 30px; 705 | display: flex; 706 | flex-direction: column; 707 | justify-content: flex-end; 708 | 709 | @media (max-width: 1024px) { 710 | min-height: auto; 711 | margin-left: 0; 712 | margin-right: 0; 713 | } 714 | } 715 | 716 | #footer-top { 717 | display: flex; 718 | justify-content: space-between; 719 | } 720 | 721 | #footer-bottom { 722 | margin-top: 30px; 723 | margin-bottom: 30px; 724 | display: block; 725 | 726 | @media (max-width: 1024px) { 727 | margin-top: 10px; 728 | } 729 | } 730 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lusion Demo -WebGL Scroll Sync 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | about 45 | 46 | 47 | 48 |
49 | made by Lusion 50 |
51 | 52 | 53 |
54 |
55 |
56 | 57 | close 58 | 59 | 60 |
61 |

This is a demo we made to show how to synchronize the WebGL visuals with the DOM elements.

62 | 68 |

Lusion is an award-winning creative studio that specializes in creating unique and engaging web experiences.

69 |
70 |
71 |
72 |
73 | 74 | 75 |
76 |
77 | Filter 78 | 79 |
80 | 81 | 86 |
87 | 88 | 89 |
90 | 91 | 92 | 93 | 98 | 99 | 100 |
101 |
102 | 103 |
104 |
105 | 106 | 107 |
108 |
109 | WebGL Scroll Sync 110 | 01 111 |
112 | 113 |
114 |
115 | 116 |
117 | 118 |
119 |
120 | 121 |
122 | 123 |
124 | 125 |
126 |
127 | 128 |
129 | WebGL Scroll Sync 130 | 01 131 |
132 |
133 | 134 | 135 |
136 |
137 |
138 | 139 |
140 |
141 | 142 | 143 |
144 |
145 | WebGL Scroll Sync 146 | 01 147 |
148 | 149 |
150 |
151 | 152 |
153 | 154 |
155 |
156 | 157 |
158 | 159 |
160 | 161 |
162 |
163 | 164 |
165 | WebGL Scroll Sync 166 | 01 167 |
168 |
169 | 170 | 171 |
172 | 176 | 177 | 180 |
181 |
182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import vertexShader from '../shaders/img.vert?raw'; 3 | import fragmentShader from '../shaders/img.frag?raw'; 4 | 5 | // DOM elements 6 | const domWrapper = document.getElementById('wrapper'); 7 | let canvas; 8 | 9 | // Three.js core objects 10 | let scene; 11 | let camera; 12 | let renderer; 13 | let geometry; 14 | 15 | // State variables 16 | let time = 0; 17 | let padding = 0; 18 | let isNoFix = false; 19 | let isFixedWithPadding = false; 20 | let viewportWidth; 21 | let viewportHeight; 22 | let prevScrollY = 0; 23 | let strength = 0; 24 | 25 | // Environment variables 26 | const dpr = window.devicePixelRatio; 27 | let colorBackground; 28 | 29 | // Uniform variables for shaders 30 | const resolution = new THREE.Vector2(1, 1); 31 | const scrollOffset = new THREE.Vector2(0, 0); 32 | const sharedUniforms = { 33 | u_resolution: { value: resolution }, 34 | u_scrollOffset: { value: scrollOffset }, 35 | u_time: { value: 0 }, 36 | u_strength: { value: 0 }, 37 | }; 38 | 39 | // Items tracking 40 | const itemList = []; 41 | 42 | /** 43 | * Initialize the application 44 | */ 45 | function init() { 46 | colorBackground = getComputedStyle(document.documentElement).getPropertyValue('--color-background'); 47 | 48 | // Set up Three.js scene 49 | setupThreeJS(); 50 | 51 | // Create meshes for all images 52 | createImageMeshes(); 53 | 54 | // Set up event listeners 55 | setupEventListeners(); 56 | 57 | // Initialize state 58 | time = performance.now() / 1000; 59 | prevScrollY = window.scrollY; 60 | 61 | // Start animation loop 62 | animate(); 63 | 64 | console.log( 65 | // credit 66 | '%c Created by Lusion: https://lusion.co/', 67 | 'border:2px solid gray; padding:5px; font-family:monospace; font-size:11px;', 68 | ); 69 | } 70 | 71 | /** 72 | * Set up Three.js scene, camera and renderer 73 | */ 74 | function setupThreeJS() { 75 | canvas = document.querySelector('#canvas'); 76 | scene = new THREE.Scene(); 77 | camera = new THREE.Camera(); 78 | renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); 79 | renderer.setSize(window.innerWidth, window.innerHeight); 80 | renderer.setClearColor(colorBackground); 81 | geometry = new THREE.PlaneGeometry(1, 1, 1, 1); 82 | } 83 | 84 | /** 85 | * Create meshes for all image containers 86 | */ 87 | function createImageMeshes() { 88 | const domImageContainerList = document.querySelectorAll('.image'); 89 | const textureLoader = new THREE.TextureLoader(); 90 | 91 | for (let i = 0; i < domImageContainerList.length; i++) { 92 | const domContainer = domImageContainerList[i]; 93 | const mesh = new THREE.Mesh( 94 | geometry, 95 | new THREE.ShaderMaterial({ 96 | uniforms: { 97 | u_texture: { value: textureLoader.load(`/images/${i}.webp`) }, 98 | u_domXY: { value: new THREE.Vector2(0, 0) }, 99 | u_domWH: { value: new THREE.Vector2(1, 1) }, 100 | u_resolution: sharedUniforms.u_resolution, 101 | u_scrollOffset: sharedUniforms.u_scrollOffset, 102 | u_time: sharedUniforms.u_time, 103 | u_strength: sharedUniforms.u_strength, 104 | u_rands: { value: new THREE.Vector4(0, 0, 0, 0) }, 105 | u_id: { value: i }, 106 | }, 107 | vertexShader, 108 | fragmentShader, 109 | side: THREE.DoubleSide, 110 | }), 111 | ); 112 | 113 | itemList.push({ 114 | domContainer, 115 | mesh, 116 | width: 1, 117 | height: 1, 118 | x: 0, 119 | top: 0, 120 | }); 121 | 122 | scene.add(mesh); 123 | mesh.frustumCulled = false; 124 | } 125 | } 126 | 127 | /** 128 | * Set up all event listeners 129 | */ 130 | function setupEventListeners() { 131 | // Resize events 132 | window.addEventListener('resize', onResize); 133 | if (window.ResizeObserver) { 134 | new ResizeObserver(onResize).observe(domWrapper); 135 | } 136 | 137 | // Overlay toggle 138 | setupOverlayEvents(); 139 | 140 | // Settings panel 141 | setupSettingsEvents(); 142 | } 143 | 144 | /** 145 | * Set up overlay toggle events 146 | */ 147 | function setupOverlayEvents() { 148 | let aboutToggle = false; 149 | 150 | document.getElementById('overlay-open').addEventListener('click', () => { 151 | aboutToggle = !aboutToggle; 152 | document.documentElement.classList.toggle('is-overlay-active', aboutToggle); 153 | }); 154 | 155 | document.getElementById('overlay-close').addEventListener('click', () => { 156 | aboutToggle = false; 157 | document.documentElement.classList.toggle('is-overlay-active', aboutToggle); 158 | }); 159 | } 160 | 161 | /** 162 | * Set up settings panel events 163 | */ 164 | function setupSettingsEvents() { 165 | document.querySelector('#settings__button').addEventListener('click', () => { 166 | document.querySelector('#settings').classList.toggle('is-active'); 167 | }); 168 | 169 | document.querySelectorAll('#settings__items li').forEach((li) => { 170 | li.addEventListener('click', () => { 171 | document.querySelectorAll('#settings__items li').forEach((item) => { 172 | item.classList.remove('is-active'); 173 | }); 174 | 175 | li.classList.add('is-active'); 176 | 177 | isNoFix = li.dataset.noFix === '1'; 178 | isFixedWithPadding = li.dataset.fixedWithPadding === '1'; 179 | document.documentElement.classList.toggle('no-fix', isNoFix); 180 | onResize(); 181 | 182 | document.querySelector('#settings').classList.toggle('is-active', false); 183 | }); 184 | }); 185 | document.querySelectorAll('#settings__items li')[0].click(); 186 | } 187 | 188 | /** 189 | * Handle resize events 190 | */ 191 | function onResize() { 192 | padding = isFixedWithPadding && !isNoFix ? 0.25 : 0; 193 | 194 | viewportWidth = domWrapper.clientWidth; 195 | viewportHeight = window.innerHeight; 196 | 197 | const canvasHeight = viewportHeight * (1 + padding * 2); 198 | 199 | resolution.set(viewportWidth, canvasHeight); 200 | 201 | renderer.setSize(viewportWidth * dpr, canvasHeight * dpr); 202 | canvas.style.width = `${viewportWidth}px`; 203 | canvas.style.height = `${canvasHeight}px`; 204 | 205 | scrollOffset.set(window.scrollX, window.scrollY); 206 | 207 | updateItemPositions(); 208 | } 209 | 210 | /** 211 | * Update item positions and dimensions 212 | */ 213 | function updateItemPositions() { 214 | for (let i = 0; i < itemList.length; i++) { 215 | const item = itemList[i]; 216 | const rect = item.domContainer.getBoundingClientRect(); 217 | 218 | item.width = rect.width; 219 | item.height = rect.height; 220 | item.x = rect.left + scrollOffset.x; 221 | item.y = rect.top + scrollOffset.y; 222 | 223 | item.mesh.material.uniforms.u_domWH.value.set(item.width, item.height); 224 | } 225 | } 226 | 227 | /** 228 | * Animation loop 229 | */ 230 | function animate() { 231 | requestAnimationFrame(animate); 232 | 233 | const scrollY = window.scrollY; 234 | const scrollDelta = scrollY - prevScrollY; 235 | 236 | // Calculate time delta 237 | const newTime = performance.now() / 1000; 238 | const dt = newTime - time; 239 | time = newTime; 240 | 241 | // Update animation strength based on scroll speed 242 | updateStrength(scrollDelta, dt); 243 | 244 | // Update uniform values 245 | updateUniforms(dt, scrollY); 246 | 247 | // Position canvas based on scroll 248 | updateCanvasPosition(); 249 | 250 | // Update and optimize mesh visibility 251 | updateMeshes(dt); 252 | 253 | // Render the scene 254 | renderer.render(scene, camera); 255 | 256 | prevScrollY = scrollY; 257 | } 258 | 259 | /** 260 | * Update animation strength based on scroll speed 261 | */ 262 | function updateStrength(scrollDelta, dt) { 263 | const targetStrength = (Math.abs(scrollDelta) * 10) / viewportHeight; 264 | 265 | strength *= Math.exp(-dt * 10); 266 | strength += Math.min(targetStrength, 5); 267 | } 268 | 269 | /** 270 | * Update uniform values for shaders 271 | */ 272 | function updateUniforms(dt, scrollY) { 273 | sharedUniforms.u_time.value += dt; 274 | sharedUniforms.u_strength.value = Math.min(1, strength); 275 | scrollOffset.set(window.scrollX, scrollY - viewportHeight * padding); 276 | } 277 | 278 | /** 279 | * Update canvas position based on settings 280 | */ 281 | function updateCanvasPosition() { 282 | if (!isNoFix) { 283 | canvas.style.transform = `translate(${scrollOffset.x}px, ${scrollOffset.y}px)`; 284 | } else { 285 | canvas.style.transform = `translateZ(0)`; 286 | } 287 | } 288 | 289 | /** 290 | * Update meshes and optimize visibility 291 | */ 292 | function updateMeshes(dt) { 293 | const canvasTop = scrollOffset.y; 294 | const canvasBottom = canvasTop + resolution.y; 295 | 296 | for (let i = 0; i < itemList.length; i++) { 297 | const item = itemList[i]; 298 | 299 | // Update position 300 | item.mesh.material.uniforms.u_domXY.value.set(item.x, item.y); 301 | 302 | // Randomly update random values 303 | if (Math.random() > Math.exp(-dt * 25 * (1 + strength))) { 304 | item.mesh.material.uniforms.u_rands.value = new THREE.Vector4(Math.random(), Math.random(), Math.random(), Math.random()); 305 | } 306 | 307 | // Optimize by hiding items that are not visible 308 | item.mesh.visible = item.y < canvasBottom && item.y + item.height > canvasTop; 309 | } 310 | } 311 | 312 | // wait one frame before initializing to ensure the css properties are set 313 | requestAnimationFrame(init); 314 | -------------------------------------------------------------------------------- /src/shaders/img.frag: -------------------------------------------------------------------------------- 1 | uniform sampler2D u_texture; 2 | uniform vec4 u_rands; 3 | uniform float u_strength; 4 | uniform float u_time; 5 | uniform float u_id; 6 | 7 | varying vec2 v_uv; 8 | 9 | #define NUM_SAMPLES 5 10 | 11 | // hash function by Dave_Hoskins from https://www.shadertoy.com/view/4djSRW 12 | vec4 hash43(vec3 p) { 13 | vec4 p4 = fract(vec4(p.xyzx) * vec4(.1031, .1030, .0973, .1099)); 14 | p4 += dot(p4, p4.wzxy+33.33); 15 | return fract((p4.xxyz+p4.yzzw)*p4.zywx); 16 | } 17 | 18 | void main() { 19 | 20 | // get some white noise 21 | vec4 noises = hash43(vec3(gl_FragCoord.xy, u_id)); 22 | 23 | // get lazy random glitchy uv offset 24 | vec4 rands = hash43(vec3(floor(sin(v_uv.x * 2. + u_rands.x * 6.283) * mix(3., 40., u_rands.y)) * 30., u_id, u_rands.z)); 25 | vec2 uvOffset = vec2(0., (rands.x - .5) * 0.5 * (rands.y > .7 ? 1. : 0.)) / float(NUM_SAMPLES) * (0.05 + u_strength * 0.3); 26 | 27 | vec2 uv = v_uv + noises.xy * uvOffset; 28 | vec3 color = vec3(0.); 29 | 30 | // accumulate samples 31 | for (int i = 0; i < NUM_SAMPLES; i++) { 32 | color += texture2D(u_texture, uv).rgb; 33 | uv += uvOffset; 34 | } 35 | 36 | // normalize and apply strength 37 | color /= float(NUM_SAMPLES); 38 | gl_FragColor = vec4(color * (1. + u_strength * 2.), 1.); 39 | } 40 | -------------------------------------------------------------------------------- /src/shaders/img.vert: -------------------------------------------------------------------------------- 1 | uniform vec2 u_resolution; 2 | uniform vec2 u_scrollOffset; 3 | uniform vec2 u_domXY; 4 | uniform vec2 u_domWH; 5 | varying vec2 v_uv; 6 | 7 | void main() { 8 | vec2 pixelXY = u_domXY - u_scrollOffset + u_domWH * 0.5; 9 | pixelXY.y = u_resolution.y - pixelXY.y; 10 | pixelXY += position.xy * u_domWH; 11 | vec2 xy = pixelXY / u_resolution * 2. - 1.; 12 | v_uv = uv; 13 | gl_Position = vec4(xy, 0., 1.0); 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | root: 'src', 7 | build: { 8 | outDir: '../build', 9 | emptyOutDir: true, 10 | rollupOptions: { 11 | input: { 12 | main: path.resolve(__dirname, 'src/index.html'), 13 | }, 14 | }, 15 | }, 16 | publicDir: path.resolve(__dirname, 'public'), 17 | server: { 18 | port: 3000, 19 | host: true, 20 | }, 21 | assetsInclude: ['**/*.vert', '**/*.frag'], 22 | plugins: [ 23 | { 24 | name: 'raw-loader', 25 | transform(code, id) { 26 | if (id.endsWith('.vert') || id.endsWith('.frag')) { 27 | return { 28 | code: `export default ${JSON.stringify(code)}`, 29 | map: null, 30 | }; 31 | } 32 | }, 33 | }, 34 | ], 35 | }); 36 | --------------------------------------------------------------------------------