CASE STUDIESProjekt własny
Portret z cząsteczek: scena 3D, która nie blokuje oferty
Hero tej strony zamienia zdjęcie w pole cząsteczek liczone na GPU i reaguje na kursor, żyroskop i dotyk. Warunek: nagłówek i wezwanie do działania mają być w pierwszym bajcie HTML, a scena ma mieścić się w budżecie telefonu.
- Kiedy
- 2025–2026
- Rola
- Autor: projekt, shadery, animacje, wydajność
- Stack
- Next.jsReactTypeScriptThree.jsGLSLGSAPTailwindCSS
- Linki
- repozytoriumdemo

8 000
cząsteczek tekstu bio po krzywych Béziera
4
własne shadery GLSL
1
zegar dla scrolla i animacji
Problem
Strona ofertowa jednoosobowego software house’u ma sprzedawać usługi, więc nagłówek i przyciski muszą być w HTML z serwera, zanim załaduje się jakikolwiek JavaScript. Jednocześnie chciałem, żeby hero pokazywało, co umiem w 3D: portret rozsypany na cząsteczki, tekst bio płynący w stronę twarzy, reakcję na kursor i tapnięcia. Te dwa cele łatwo się gryzą: ciężki canvas opóźnia treść, a pole cząsteczek liczone na CPU tnie klatki na telefonach.
Drugi problem wyszedł po dodaniu płynnego przewijania: Lenis i ScrollTrigger miały osobne zegary, więc animacje sterowane scrollem lądowały klatkę za pozycją strony. Trzeci: po zmianie trasy ScrollTrigger przywracał pozycję przewijania poprzedniej strony, a kurtyna przejścia odsłaniała nową stronę w połowie.
Podejście
Treść i scena są rozdzielone na poziomie Reacta: hero renderuje nagłówek, pitch i CTA po stronie serwera, a scena to chunk ładowany dynamicznie bez SSR, który „nigdy nie może blokować oferty”. Jeśli WebGL zawiedzie, ErrorBoundary pokazuje tekst zamiast pustki.
Portret jest zamieniany na cząsteczki w przeglądarce: obraz trafia na offscreen canvas zeskalowany do 220 pikseli szerokości, a pikselem-cząsteczką zostaje tylko ten, który ma alfa powyżej progu i luminancję poniżej progu. Wynik to InstancedBufferGeometry z jednym quadem i atrybutami instancji, rysowana w trybie multiply nad zwykłą płaszczyzną z teksturą. Tekst bio to 8 000 cząsteczek, z których każda ma punkt startu, dwa punkty kontrolne i koniec; krzywą i obrót liczy vertex shader, a CPU aktualizuje tylko czas.
Rozwiązanie
- Shockwave po tapnięciu: pointerdown na canvasie dodaje puls (współrzędne NDC plus czas) do pierścienia o pojemności dwóch, uruchamia navigator.vibrate(12) i ripple w CSS. Obwiednia pulsu (attack smoothstep do 0,08, release od 0,5 do 1) istnieje w jednej wersji w JS i w GLSL, więc kamera, portret i strumień oddychają w tym samym rytmie.
- Pozycja tapnięcia jest zapisywana w przestrzeni lokalnej obiektu, nie w NDC, bo przy dryfie kamery fala przesuwała się razem z nią. Sentinel poza ekranem zapobiega „fantomowemu” odpychaniu przed pierwszym tapnięciem.
- Kamera: na desktopie tłumiony ruch za kursorem (maath damp3), na telefonie wejście z żyroskopu z neutralnym pochyleniem 45° i zakresem 45°, z prośbą o uprawnienie na iOS przy pierwszym dotknięciu. Odmowa zostawia kamerę w centrum.
- Budżet renderu: dpr obcięty do 1, bez antialiasingu, frustum culling wyłączony na obu systemach cząsteczek, a pętla renderowania zatrzymywana przez IntersectionObserver, gdy hero wyjdzie z ekranu.
- Jeden zegar: Lenis tickowany z gsap.ticker i raportujący scroll do ScrollTrigger, z lagSmoothing(0). Scrubowane tweeny lądują w tej samej klatce co pozycja przewijania.
- Kurtyna przejść: po zmianie trasy czyszczenie pamięci scrolla ScrollTriggera, refresh i natychmiastowe scrollTo, a scroll blokowany przez wheel/touchmove zamiast overflow:hidden, bo znikający pasek przesuwał stronę w bok. Awaryjne odsłonięcie po 10 sekundach.
- prefers-reduced-motion wyłącza Lenis, prędkość scrolla, kurtynę i haptykę, a tweeny ScrollTriggera przyspiesza pięciokrotnie.
Efekt
Oferta jest w pierwszym bajcie HTML, scena dojeżdża po niej i wygasza się, gdy nie jest widoczna. Cząsteczki portretu i tekstu liczy GPU, a wszystkie efekty (fala, kamera, scroll) chodzą na jednym zegarze. Strona jest poligonem: każdy nowy efekt motion najpierw trafia tutaj.
Czego się nauczyłem
- Falę uderzeniową trzeba kotwiczyć w świecie, nie w ekranie. Każdy efekt zależny od kamery, który nie ma własnego układu odniesienia, będzie pływał.
- Dwa źródła czasu to zawsze klatka opóźnienia gdzieś w systemie. Lenis, GSAP i ScrollTrigger muszą tykać z jednego miejsca.