├── .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 |
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 |
82 | fixed with padding
83 | fixed without padding
84 | no fix
85 |
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 |
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 |
--------------------------------------------------------------------------------