Three.js — grafika 3D w przeglądarce na WebGL i WebGPU
Three.js w rewizji r185: dwa renderery, TSL zamiast GLSL-a i realna cena w kilobajtach — zmierzona, nie przepisana z README. Pokazujemy pełne wdrożenie w Laravelu z Inertią i Reactem: obejście SSR-u, konfigurację Vite dla plików .glb, dekodery Draco i KTX2 po zmianie z r185 oraz zwalnianie kontekstu WebGL przy nawigacji w SPA.
Klient chce konfigurator produktu. Obracany model, wymienne kolory, zoom na detal, wszystko w przeglądarce, bez wtyczek i bez aplikacji do instalowania. Pytanie, które pada zaraz potem, brzmi zawsze tak samo: ile to waży i czy zadziała na telefonie.
Odpowiedź na pierwsze pytanie da się zmierzyć i zrobiliśmy to niżej — z liczbami z konkretnego builda, nie z README. Odpowiedź na drugie jest w 2026 roku wreszcie prosta: zadziała, tylko trzeba wiedzieć, co się zwalnia po wyjściu z podstrony i czego Vite domyślnie nie umie zapakować.
Three.js (github.com/mrdoob/three.js, licencja MIT) jest biblioteką, która stoi pod większością trójwymiarowych rzeczy w sieci. Repozytorium założono 23 marca 2010 roku, ma 115 210 gwiazdek i 36 532 forki, a paczka three notuje 15,19 mln pobrań tygodniowo (tydzień 23–29 sierpnia 2026). Aktualna rewizja to r185, wydana 1 lipca 2026; w npm jest jako 0.185.1.
Poniżej to, co realnie trzeba wiedzieć przed wdrożeniem: dwa renderery i różnica między nimi, nowy język shaderów, zmierzona cena w kilobajtach oraz pięć pułapek, które w projekcie na Laravelu z Inertią kosztują dzień pracy każda.
Czym three.js jest, a czym nie
Three.js jest warstwą pośrednią między API graficznym przeglądarki a pojęciami, którymi da się myśleć: scena, kamera, siatka, materiał, światło, animacja. Bez niej rysowanie obróconego sześcianu to kilkaset linii kodu obsługi buforów, macierzy i shaderów. Z nią — dwadzieścia linii.
Minimalna scena z oficjalnego README wygląda dokładnie tak:
import * as THREE from 'three';
const camera = new THREE.PerspectiveCamera( 70, width / height, 0.01, 10 );
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry( 0.2, 0.2, 0.2 );
const material = new THREE.MeshNormalMaterial();
scene.add( new THREE.Mesh( geometry, material ) );
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( width, height );
renderer.setAnimationLoop( animate );
document.body.appendChild( renderer.domElement );
function animate( time ) {
mesh.rotation.x = time / 2000;
renderer.render( scene, camera );
}Czym three.js nie jest: silnikiem gier. Nie ma edytora scen jako produktu, systemu komponentów w rozumieniu Unity, wbudowanej fizyki ani gotowego pipeline'u assetów. Fizykę dokłada się osobno (w katalogu dodatków są wiązania do Rapiera, Jolta i Ammo), a przygotowanie modeli robi się poza biblioteką.
Czym nie jest po drugiej stronie skali: obudową na jeden efekt. To biblioteka ogólnego przeznaczenia — 425 modułów w katalogu dodatków, w tym 56 loaderów formatów od glTF przez FBX i USDZ po dane medyczne i chmury punktów. Jeśli twój format ma jakąkolwiek popularność, loader już istnieje.
Stan projektu na wrzesień 2026
Przy bibliotece, na której ma stać produkt klienta, aktywność repozytorium jest ważniejsza niż liczba gwiazdek. Liczby prosto z API GitHuba, stan na 7 września 2026:
- 2028 commitów w ostatnich 52 tygodniach — średnio 39 tygodniowo. Ostatnie cztery tygodnie: 55, 52, 31 i 48 commitów,
- ostatni commit tego samego dnia, o 02:05 UTC — optymalizacja przebiegów w
UnrealBloomPass, - 254 otwarte zgłoszenia i 133 otwarte pull requesty przy skali projektu liczonej w setkach tysięcy linii,
- rewizje wychodzą co 4–10 tygodni: r180 (3 września 2025), r181 (19 listopada), r182 (10 grudnia), r183 (20 lutego 2026), r184 (16 kwietnia), r185 (1 lipca),
- 607 oficjalnych przykładów — i to jest liczba, która mówi najwięcej o kierunku: 230 z nich działa na rendererze WebGPU, a 220 na WebGL. Reszta to WebXR (27), fizyka (13), CSS3D, SVG i audio.
Przykładów WebGPU jest już więcej niż WebGL. To nie deklaracja z roadmapy, tylko rozkład pracy w repozytorium: nowe rzeczy powstają na nowym rendererze, stary dostaje poprawki.
Paczka npm nie ma ani jednej zależności produkcyjnej. Rozpakowana zajmuje 22 MB, ale to dlatego, że zawiera pełne źródła, dodatki i skompilowane dekodery WebAssembly — do przeglądarki nie leci nic z tego, czego nie zaimportujesz.
Jedna uwaga na start, jeśli ktoś chce klonować repozytorium: pełna historia od 2010 roku to około 2 GB. Do przeglądania przykładów lokalnie wystarczy git clone --depth=1.
Ile to waży — pomiary, nie deklaracje
„Three.js jest ciężki" to najczęstsze zdanie w dyskusjach o tej bibliotece i praktycznie nigdy nie towarzyszy mu liczba. Zmierzyliśmy więc sami: three@0.185.1, esbuild w trybie --bundle --minify, format ESM, kompresja gzip -9. Cztery scenariusze:
- wszystko (
import * as THREE from 'three'): 716 KB po minifikacji, 185 KB po gzipie, - minimalna scena (kamera, scena, siatka,
WebGLRenderer— sześć importów): 509 KB, 129 KB po gzipie, - realistyczna scena (materiał PBR, dwa światła, tekstura,
OrbitControls,GLTFLoaderz Draco): 620 KB, 158 KB po gzipie, - ścieżka WebGPU (minimalna scena z
three/webgpuplus shader w TSL zthree/tsl): 840 KB, 235 KB po gzipie.
Z tych czterech liczb wynikają trzy wnioski, których nie znajdziesz w dokumentacji.
Pierwszy: tree-shaking działa, ale słabiej, niż się zakłada. Zejście z pełnego importu do sześciu nazw zdejmuje 56 KB po gzipie — jakieś 30%. Nie 90%. Powód jest strukturalny: WebGLRenderer ciągnie za sobą program shaderowy, zarządzanie stanem, obsługę cieni, zarządzanie kolorem i cały ShaderLib. To jest podłoga i nie da się jej obniżyć wyborem importów.
Drugi: realistyczna scena kosztuje 158 KB po gzipie. Dla porównania — cały React z ReactDOM to około 45 KB. Trójwymiar to zatem trzy–cztery Reacty dodatkowego JavaScriptu i tę informację warto przekazać na etapie wyceny, nie po wdrożeniu.
Trzeci: WebGPU jest dziś o 80% droższy w bajtach. 235 KB kontra 129 KB po gzipie, bo three/webgpu zawiera oba backendy (WebGPU i awaryjny WebGL 2) plus cały system materiałów węzłowych. Sam plik build/three.webgpu.js ma 2,07 MB źródła i 413 KB po gzipie, wobec 635 KB i 126 KB dla build/three.module.js.
Jedna rzecz, którą warto wiedzieć przy audycie bundla: package.json biblioteki deklaruje "sideEffects": ["./src/nodes/**/*"]. Moduły węzłowe są oznaczone jako mające efekty uboczne, więc bundler nie usunie z nich niczego samodzielnie — przy ścieżce WebGPU liczy się to, co zaimportujesz z three/tsl, i nic poniżej.
Dwa renderery: WebGL i WebGPU
Od kilku rewizji three.js utrzymuje dwa niezależne renderery i wybór między nimi jest pierwszą decyzją architektoniczną w projekcie.
WebGLRenderer importuje się z three. Działa wszędzie, gdzie jest WebGL 2, czyli praktycznie w każdej przeglądarce od 2020 roku. Materiały ustawia się właściwościami, własne shadery pisze się w GLSL-u przez ShaderMaterial albo onBeforeCompile().
WebGPURenderer importuje się z three/webgpu. Używa WebGPU, a gdy przeglądarka go nie ma — sam przechodzi na backend WebGL 2 i wypisuje ostrzeżenie w konsoli. Materiały są węzłowe, shadery pisze się w TSL.
Co mówi oficjalny podręcznik
Tu trzeba oddzielić dokumentację od entuzjazmu blogów. Oficjalny podręcznik three.js nazywa WebGPURenderer nadal eksperymentalnym — z zastrzeżeniem, że poziom dojrzałości znacząco wzrósł w ostatnich latach i że można trafić na brakujące funkcje albo gorszą wydajność niż na WebGLRenderer. Nie ma podanej daty wyjścia ze statusu eksperymentalnego; jest zalecenie, żeby aktualizować rewizje, bo każda coś poprawia.
O starym rendererze podręcznik mówi równie wprost: WebGLRenderer jest utrzymywany i pozostaje zalecanym wyborem dla aplikacji czysto WebGL 2, ale nie są planowane żadne większe nowe funkcje. Rozwój przeniósł się na nowy renderer.
Praktyczne tłumaczenie tych dwóch akapitów: jeśli robisz wizualizację produktu, konfigurator, przeglądarkę modelu czy planszę z rzutem mieszkania — bierz WebGLRenderer. Jest nudny, przewidywalny, o 106 KB lżejszy po gzipie i nikt ci nie zepsuje shadera przy aktualizacji. Jeśli piszesz coś, co liczy na GPU (symulacje cząstek, obliczenia w compute shaderach, wielotysięczne instancje) albo świadomie inwestujesz w kod na następne pięć lat — WebGPU.
Wsparcie przeglądarek
WebGPU osiągnęło status Baseline w styczniu 2026. Szczegóły z wiki grupy roboczej GPU for the Web, bo diabeł siedzi w platformach:
- Chrome i Edge — stabilnie od wersji 113 na macOS, Windows (x86/x64) i ChromeOS. Android od 121 w zależności od producenta GPU (Imagination dopiero od 139). Linux wybiórczo: Intel Gen12+ od 144, NVIDIA od 147 przy nowych sterownikach,
- Safari — pełne wsparcie, domyślnie włączone, w wersji 26 na macOS, iOS, iPadOS i visionOS,
- Firefox — Windows od 141, macOS na Apple Silicon od 145. Intelowe Maki, Linux i Android nadal w toku; Mozilla celuje w 2026 rok,
- luki — Windows na ARM64 za flagą, Firefox na Linuksie i Androidzie bez wsparcia.
Dla projektu komercyjnego oznacza to jedno: nawet wybierając WebGPU, nie wybierasz go w pełni. Awaryjny backend WebGL 2 zostaje w bundlu i musi być przetestowany, bo część użytkowników trafi właśnie na niego. Testuje się to bez zmiany kodu, jednym parametrem:
const renderer = new WebGPURenderer( { forceWebGL: true } );Co trzeba przepisać przy przejściu
Migracja z WebGLRenderer na WebGPURenderer nie jest podmianą jednej klasy. Podręcznik wymienia twardą granicę: ShaderMaterial, RawShaderMaterial i onBeforeCompile() nie są obsługiwane. Każdy własny shader trzeba przenieść na materiały węzłowe i TSL.
Druga różnica to inicjalizacja. WebGPU startuje asynchronicznie. Jeśli używasz setAnimationLoop(), biblioteka ogarnia to sama. Jeśli kręcisz własną pętlą na requestAnimationFrame() albo dotykasz renderera w kodzie inicjalizującym, musisz poczekać:
const renderer = new THREE.WebGPURenderer( { antialias: true } );
await renderer.init();Warto to zapamiętać, bo od rewizji r181 asynchroniczne odpowiedniki metod (renderAsync(), computeAsync()) są przestarzałe — właściwym wzorcem jest jedno await renderer.init() i dalej wywołania synchroniczne.
TSL — shader pisany w JavaScripcie
Three.js Shading Language to najbardziej nietypowa część nowego renderera. Shader nie jest tu tekstem w osobnym języku, tylko drzewem obiektów budowanym w JavaScripcie. Bibliotekę importuje się z three/tsl — 638 eksportowanych funkcji i stałych, od vec3 i mix przez uv, positionGeometry i normalWorld po szumy MaterialX i pętle Loop.
import { MeshBasicNodeMaterial } from 'three/webgpu';
import { color, positionLocal, sin, time } from 'three/tsl';
const material = new MeshBasicNodeMaterial();
material.colorNode = color( 0xff3300 )
.mul( sin( time.add( positionLocal.y ) ).abs() );Ten kod nie jest GLSL-em ani WGSL-em. Jest opisem, który biblioteka transpiluje do WGSL-a albo GLSL-a w zależności od aktywnego backendu. Jeden shader, dwa API graficzne, zero utrzymywania dwóch wersji tego samego efektu.
Zysk poza przenośnością jest praktyczny i bardzo doceniany w zespole, który nie ma dedykowanego grafika: shader przestaje być stringiem. Autouzupełnianie działa, literówka w nazwie funkcji wychodzi w edytorze, a nie jako czarny materiał po odświeżeniu strony, i można wyciągać fragmenty do funkcji, składać je i testować jak zwykły JavaScript.
Cena jest też realna. Wiedza o GLSL-u przenosi się tylko częściowo — trzeba nauczyć się nowego zestawu nazw. Materiałów w sieci jest wielokrotnie mniej niż o GLSL-u, więc kopiowanie efektu ze Shadertoya przestaje być drogą na skróty. TSL jest młody i nazwy nadal się zmieniają: w r185 directionToColor() stało się packNormalToRGB(), colorToDirection() stało się unpackRGBToNormal(), a positionLocal w material.positionNode przestało aktualizować wewnętrzne transformacje wierzchołków — trzeba tam teraz użyć positionGeometry.
Post-processing na węzłach
Efekty pełnoekranowe dla nowego renderera mieszkają w three/addons/tsl/display/ i jest ich ponad czterdzieści: bloom, GTAO, SSR, SSGI, SSS, głębia ostrości, FXAA, SMAA, upscaling FSR1, promienie Boga, rozmycie ruchu, aberracja chromatyczna, LUT-y 3D, efekty CRT i sepii. Uwaga na dwie zmiany, jeśli wracasz do kodu z zeszłego roku: w r183 klasa PostProcessing została przemianowana na RenderPipeline, a w r185 AnamorphicNode usunięto na rzecz BloomNode.
Wdrożenie w Laravelu z Inertią i Reactem
Tu przechodzimy do części, której nie ma w dokumentacji three.js, bo dotyczy naszego stosu, a nie biblioteki. Poniższe pułapki wypisujemy w kolejności, w jakiej realnie zabierają czas.
Pułapka pierwsza: SSR wywala się na document
Aplikacja na Inertii z włączonym SSR-em renderuje komponenty w Node.js, z pliku bootstrap/ssr/app.js. Nie ma tam window ani document. Konstruktor WebGLRenderer sięga po document.createElement, więc import three.js na poziomie modułu w komponencie strony wywraca renderowanie serwerowe.
Rozwiązanie: komponent 3D ładowany dynamicznie, montowany dopiero po stronie klienta.
import { lazy, Suspense, useEffect, useState } from 'react';
const ProductViewer = lazy( () => import( '@/Components/ProductViewer' ) );
export default function ProductPage( { product } ) {
const [ mounted, setMounted ] = useState( false );
useEffect( () => setMounted( true ), [] );
return (
<div className="aspect-square">
{ mounted && (
<Suspense fallback={ <ViewerSkeleton /> } >
<ProductViewer src={ product.model_url } />
</Suspense>
) }
</div>
);
}Warunek mounted nie jest tu ozdobą. Bez niego React spróbuje dopasować drzewo z serwera do drzewa klienta i dostaniesz ostrzeżenie o niezgodności hydracji — nawet jeśli sam import jest leniwy.
Pułapka druga: 129 KB w głównym bundlu
Statyczny import three.js w dowolnym miejscu wspólnego drzewa komponentów wkłada całą bibliotekę do głównej paczki. Efekt: strona kontaktowa ładuje silnik 3D. Dynamiczny import z poprzedniego punktu rozwiązuje to sam — Vite wydzieli osobny kawałek — ale warto to sprawdzić w statystykach builda, a nie założyć. Przy jawnym rozdziale pomaga też ręczne wskazanie kawałka:
// vite.config.js
build: {
rollupOptions: {
output: {
manualChunks: {
three: [ 'three', 'three/addons/controls/OrbitControls.js' ],
},
},
},
},Osobny kawałek ma jeszcze jedną zaletę: rewizje three.js wychodzą co kilka tygodni, a resztę aplikacji zmieniamy codziennie. Trzymanie ich w jednym pliku unieważnia 129 KB cache'u przy każdej poprawce w formularzu.
Pułapka trzecia: Vite nie zna rozszerzenia .glb
To kosztuje najwięcej niepotrzebnego debugowania, bo komunikat błędu nie mówi, o co chodzi. Sprawdziliśmy listę KNOWN_ASSET_TYPES w Vite 8 zainstalowanym w tym projekcie: są tam obrazy, wideo, audio, fonty, pdf i txt. Nie ma glb, gltf, hdr, exr ani ktx2. Import modelu jako assetu trzeba więc albo oznaczyć jawnie:
import modelUrl from '@/models/chair.glb?url';albo dopisać rozszerzenia do konfiguracji:
// vite.config.js
assetsInclude: [ '**/*.glb', '**/*.gltf', '**/*.hdr', '**/*.exr', '**/*.ktx2' ],Drugie podejście jest wygodniejsze, gdy modele leżą w resources/ i mają jechać przez pipeline z hashem w nazwie. Pierwsze wystarczy przy kilku plikach. Sam serwer deweloperski Vite ma poprawne typy MIME w swojej bazie (glb to model/gltf-binary, gltf to model/gltf+json), więc problem dotyczy wyłącznie etapu budowania.
Pułapka czwarta: dekodery Draco i KTX2 — zmiana, która ułatwia życie
Historycznie użycie DRACOLoader wymagało skopiowania plików dekodera z node_modules/three/examples/jsm/libs/draco/ do public/ i wskazania ścieżki. Każdy, kto to robił, pamięta też, jak łatwo zapomnieć o tym kroku przy wdrożeniu — model po prostu się nie ładował, bez sensownego błędu.
W r185 to jest już domyślnie rozwiązane. Zajrzeliśmy w źródło loadera: ścieżki dekoderów są ustawiane przez new URL( '../libs/draco/gltf/draco_decoder.wasm', import.meta.url ). Bundler rozpoznaje ten wzorzec, kopiuje pliki do katalogu wyjściowego i przepisuje adresy. Tak samo KTX2Loader — przy nieustawionym transcoderPath sięga po transkoder przez import.meta.url. W buildzie na Vite nie musisz już wywoływać setDecoderPath() ani setTranscoderPath().
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const loader = new GLTFLoader();
loader.setDRACOLoader( new DRACOLoader() );
loader.setKTX2Loader( new KTX2Loader().detectSupport( renderer ) );
const gltf = await loader.loadAsync( modelUrl );
scene.add( gltf.scene );Trzy rzeczy, o których trzeba pamiętać przy tej wersji kodu.
Po pierwsze, detectSupport( renderer ) jest obowiązkowe dla KTX2. Transkoder musi wiedzieć, które formaty kompresji obsługuje karta graficzna użytkownika, żeby wybrać docelowy format — bez tego dostaniesz wyjątek.
Po drugie, polityka CSP. KTX2Loader składa kod workera w locie i uruchamia go z adresu blob:. Jeśli aplikacja ma restrykcyjny Content-Security-Policy, potrzebny jest worker-src blob:. Objaw bez tego jest mylący: modele bez tekstur KTX2 działają, a te z teksturami wiszą w nieskończoność.
Po trzecie, waga dekoderów. Nie liczą się do bundla JS, ale liczą się do transferu: dekoder Draco w wariancie glTF to 188 KB WebAssembly plus 57 KB wrappera, a transkoder Basis dla KTX2 — 515 KB WebAssembly plus 56 KB JavaScriptu. Ładują się leniwie, przy pierwszym modelu, ale przy jednym drobnym modelu na stronie warto się zastanowić, czy kompresja Draco w ogóle się opłaca.
Uwaga na rewizję: DRACOLoader.setDecoderConfig() jest w r185 przestarzałe, a komunikat w kodzie zapowiada usunięcie w r194. Wybór między dekoderem JS a WebAssembly robi się teraz przez obiekt z jawnymi adresami w setDecoderPath().
Pułapka piąta: kontekst WebGL zostaje po nawigacji
Ta jest specyficzna dla SPA i dlatego dotyka nas bezpośrednio. Inertia nie przeładowuje strony — wymienia komponent. Jeśli komponent 3D nie posprząta po sobie, kontekst WebGL zostaje. Przeglądarki utrzymują ograniczoną liczbę aktywnych kontekstów (w Chromie rzędu szesnastu) i po przekroczeniu limitu ubijają najstarszy. Objaw u klienta: „po kliknięciu w kilka produktów model przestaje się wyświetlać", a w konsoli sucha informacja o utracie kontekstu.
useEffect( () => {
const renderer = new WebGLRenderer( { antialias: true, canvas } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
// ...
return () => {
renderer.setAnimationLoop( null );
controls.dispose();
scene.traverse( ( object ) => {
object.geometry?.dispose();
const materials = [].concat( object.material ?? [] );
for ( const material of materials ) {
for ( const value of Object.values( material ) ) {
if ( value?.isTexture ) value.dispose();
}
material.dispose();
}
} );
renderer.dispose();
renderer.forceContextLoss();
};
}, [] );renderer.dispose() zwalnia zasoby renderera, ale nie zwalnia geometrii, materiałów ani tekstur — te trzymają pamięć GPU niezależnie i trzeba je przejść samodzielnie. forceContextLoss() na końcu jest jawnym oddaniem kontekstu, żeby nie czekać na odśmiecanie.
Warto wiedzieć, że nadchodząca rewizja r186 to upraszcza: Object3D dostaje własną metodę dispose(). Kto nadpisuje tę metodę w swoich klasach, będzie musiał wywołać w niej super.dispose() — to jest w przewodniku migracji jako zmiana łamiąca.
Serwowanie modeli z Laravela
Modele publiczne najprościej trzymać w public/models/ i podawać przez asset(). Modele zależne od uprawnień — na dysku prywatnym, z kontrolerem sprawdzającym politykę i zwracającym Storage::download() albo podpisany adres tymczasowy.
Dwie rzeczy po stronie serwera. Typ MIME dla .glb to model/gltf-binary; przy pliku serwowanym jako application/octet-stream loader zadziała, ale stracisz sensowne nagłówki i podpowiedzi dla pamięci podręcznej. I nie kompresuj gzipem plików, które są już skompresowane — .glb z geometrią Draco i teksturami KTX2 nie zmniejszy się, a procesor policzy to za każdym żądaniem. Za to .glb z surowymi tablicami zmiennoprzecinkowych schodzi na gzipie znacząco, więc reguła w nginksie zależy od tego, jak przygotowujesz assety.
Pipeline assetów: model wchodzi 40 MB, wychodzi 2 MB
Największy zysk wydajnościowy w projektach 3D nie leży w kodzie. Leży w plikach, które przychodzą od klienta — a przychodzą z programu CAD albo z Blendera, w rozmiarach liczonych w dziesiątkach megabajtów.
Kolejność operacji, która się nam sprawdza:
- Konwersja do glTF 2.0 (
.glb). Jeden plik binarny z geometrią, materiałami i teksturami. Wszystko inne — FBX, OBJ, USDZ, STEP — traktujemy jako format wejściowy, nie docelowy. - Redukcja siatki. Model z CAD-a ma zwykle rząd wielkości więcej trójkątów, niż potrzeba do wyświetlenia go na ekranie o wysokości 800 pikseli.
- Kompresja geometrii. Draco daje najmocniejszą kompresję, meshopt dekoduje szybciej i ma mniejszy dekoder. Przy jednym modelu na stronie meshopt zwykle wygrywa; przy scenie z kilkudziesięcioma — Draco.
- Kompresja tekstur do KTX2. To najczęściej pomijany krok i zwykle największy. Tekstura 2048×2048 w PNG to kilka megabajtów i po dekodowaniu zajmuje 16 MB pamięci GPU. Ta sama tekstura w KTX2 waży kilkaset kilobajtów i trafia na GPU bez dekompresji, w formacie skompresowanym sprzętowo.
- Weryfikacja. Po każdym z tych kroków model trzeba obejrzeć. Redukcja siatki potrafi zniszczyć detal, na którym klientowi zależy najbardziej.
Trzy i cztery robi się narzędziami zewnętrznymi — gltf-transform albo gltfpack — najlepiej w skrypcie w repozytorium, nie ręcznie. Model przechodzi tę drogę raz, a wchodzi w nią przy każdej korekcie od klienta.
Redukcja z 40 MB do 2 MB nie jest przesadą retoryczną — to typowy wynik połączenia konwersji, redukcji siatki i KTX2 na modelu z CAD-a. Żaden trik w kodzie renderera nie zbliży się do tego rzędu poprawy.
Wydajność: gdzie realnie leży problem
Po stronie kodu wąskie gardło jest prawie zawsze w jednym z czterech miejsc.
Liczba wywołań rysowania. Każda siatka z osobnym materiałem to osobne polecenie do GPU. Scena złożona ze pięciuset małych obiektów będzie wolniejsza od jednego obiektu z pięciuset tysiącami trójkątów. Lekarstwem jest InstancedMesh (jedna geometria, wiele pozycji) albo BatchedMesh (wiele różnych geometrii w jednym wywołaniu, z osobnym stanem widoczności dla każdej instancji).
Gęstość pikseli. Telefon z devicePixelRatio równym 3 renderuje dziewięciokrotnie więcej pikseli niż ekran nieretinowy tej samej wielkości. Ograniczenie do 2 przez renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) ) jest praktycznie niewidoczne dla oka i bywa różnicą między 30 a 60 klatkami.
Cienie. Mapy cieni renderują scenę po raz drugi, z perspektywy światła. Trzy światła rzucające cień to cztery przebiegi renderowania. W wizualizacji produktu zapieczone cienie w teksturze albo pojedyncze światło kierunkowe wystarczą.
Pętla animacji, która nie ma co robić. Statyczny model nie musi być rysowany sześćdziesiąt razy na sekundę. Renderowanie na żądanie — po zmianie kamery, po interakcji — zdejmuje obciążenie procesora do zera, gdy użytkownik czyta opis produktu. Dobrze łączy się to z IntersectionObserver: scena poza kadrem zatrzymuje pętlę.
I rzecz, która nie jest wydajnością, ale trafia do tej samej rubryki po odbiorze: prefers-reduced-motion. Automatycznie obracający się model jest dla części użytkowników fizycznie nieprzyjemny. Ustawienie systemowe trzeba respektować — animacja startuje po interakcji, nie sama.
react-three-fiber: kiedy warto
W projekcie na Reactcie naturalnym odruchem jest sięgnięcie po @react-three/fiber — renderer Reacta, który zamienia drzewo komponentów na scenę three.js. Stan ekosystemu na dziś: fiber 9.7.0 (5,13 mln pobrań tygodniowo) i biblioteka pomocników drei 10.7.8 (3,90 mln). Warto porównać to z 15,19 mln pobrań samego three — mniej niż połowa użyć three.js idzie przez Reacta.
Zależności rówieśnicze fibera są ciasne i trzeba je sprawdzić przed instalacją: react >=19 <19.3, react-dom tak samo, three >=0.156. Przy Reactcie 19.2 mieścimy się w tym okienku, ale górna granica jest domknięta — aktualizacja Reacta do 19.3 będzie wymagała czekania na wydanie fibera.
Kiedy fiber się opłaca: gdy scena zależy od stanu aplikacji. Lista produktów w Reactcie i obok scena, w której każdy produkt jest siatką reagującą na zaznaczenie, filtr i hover — deklaratywnie to jest kilkanaście linii, imperatywnie kilkaset, z ręczną synchronizacją i wyciekami przy każdym niedopilnowanym dispose().
Kiedy nie: gdy scena jest zamknięta i samodzielna. Konfigurator, w którym całe 3D jest jednym prostokątem na stronie, a komunikacja z Reactem sprowadza się do kilku właściwości, nie potrzebuje trzeciej biblioteki w łańcuchu aktualizacji. Zwykły useEffect z porządnym sprzątaniem, jak w przykładzie wyżej, jest wtedy prostszy w utrzymaniu — i nie wiąże terminu aktualizacji Reacta z cyklem wydawniczym kolejnej paczki.
Jeszcze jedna rzecz w projekcie na TypeScripcie: three.js nie dostarcza własnych typów. Trzeba doinstalować @types/three (obecnie 0.185.4) i pilnować, żeby numer środkowy zgadzał się z rewizją biblioteki. Rozjazd nie wywala builda — daje tylko błędy typów w miejscach, które akurat zmieniono w API.
Narzędzia wokół biblioteki
607 przykładów pod adresem threejs.org/examples jest w praktyce najlepszą dokumentacją, jaką ma ten projekt. Każdy ma podgląd i kompletne źródło, więc szukanie efektu zaczyna się tam, nie w opisie API.
Edytor (threejs.org/editor) to graficzna scena w przeglądarce z eksportem do formatu JSON biblioteki. Nie jest konkurencją dla Blendera, ale do ustawienia świateł i kamery, a potem przeniesienia liczb do kodu, sprawdza się dobrze.
Inspector to nowszy dodatek i mało o nim mowy poza repozytorium. Importuje się go z three/addons/inspector/Inspector.js i daje panel z zakładkami: wydajność, pamięć, konsola, parametry, przeglądarka zasobów, os czasu i ustawienia. Jedno zastrzeżenie z kodu źródłowego: importuje z three/webgpu, więc działa wyłącznie z WebGPURenderer. Na starym rendererze zostaje klasyczny licznik klatek ze stats.module.js.
Dokumentacja dla modeli językowych. Projekt publikuje threejs.org/llms.txt oraz pełną wersję threejs.org/docs/llms-full.txt (129 KB, około 13 tysięcy słów, z TSL-em włącznie). Jeśli używacie asystenta do kodowania, wskazanie mu tego pliku jest szybszą drogą do prawdziwych nazw właściwości niż liczenie na to, że model pamięta API sprzed czterdziestu rewizji. To akurat nie jest szczegół kosmetyczny: przy bibliotece, która przemianowuje funkcje między wydaniami, zmyślona nazwa metody jest najczęstszym błędem generowanego kodu.
Aktualizacje: co pęka między rewizjami
Three.js nie ma wersjonowania semantycznego. Numer to rewizja i każda może wprowadzić zmiany łamiące. Projekt prowadzi za to porządny przewodnik migracji na wiki, rewizja po rewizji — i to jest lektura obowiązkowa przed podniesieniem wersji, nie po.
Kilka przykładów z ostatnich sześciu wydań, żeby pokazać skalę i charakter tych zmian:
- r181 — zmieniono sposób liczenia odbić rozproszonych w materiałach PBR: materiały lepiej zachowują energię, więc szorstkie powierzchnie wyglądają jaśniej. To zmiana wyglądu bez zmiany kodu, czyli najgorszy rodzaj przy odbiorze przez klienta,
- r182 —
PCFSoftShadowMapprzestarzałe naWebGLRenderer, właściwośćcolorBufferTypeprzemianowana naoutputBufferType, - r183 —
PostProcessingtoRenderPipeline, klasaClockprzestarzała na rzeczTimer, poprawki cieni w WebGPU wymagają zmniejszenia albo usunięcia wartościbias, - r184 —
FBXLoadersam konwertuje modele z osią +Z w górę na +Y,FileLoader.load()nie zwraca już wartości (tylko wywołanie zwrotne), - r185 — usunięto
TiledLighting(zostajeClusteredLighting),SVGLoader.createShapes()przestarzałe, metodytranslate(),scale()irotate()wMatrix3przestarzałe,GTAONodeliczy okluzję bardziej fizycznie — trzeba zmniejszyćradiusiscale, - r186 (w gałęzi rozwojowej) —
Sourceprzemianowane naTextureSource,SimplifyModifierprzepisane na meshoptimizer i asynchroniczne,PCFSoftShadowMapusunięte z WebGPU.
Wniosek praktyczny dla projektu klienckiego: przypnij rewizję na sztywno w package.json, bez ^. Podnoszenie wersji planuj jako zadanie z czasem na obejrzenie scen, a nie jako element rutynowej aktualizacji zależności. Zmiany w cieniach, tonemappingu i modelu światła nie wywalają builda — po prostu scena zaczyna wyglądać inaczej i dowiadujesz się o tym od klienta.
Kiedy 3D w projekcie klienckim ma sens
Ma sens, gdy trójwymiar coś rozstrzyga. Konfigurator, w którym klient składa produkt z opcji i widzi wynik. Wizualizacja mieszkania z rzutem i przejściem. Prezentacja maszyny, której z jednego zdjęcia nie da się zrozumieć. Przymiarka mebla we wnętrzu. Wspólny mianownik: użytkownik dostaje odpowiedź, której zdjęcie nie daje, a decyzja zakupowa jest realna.
Nie ma sensu jako dekoracja nagłówka. Obracająca się bryła nad nagłówkiem strony to 158 KB JavaScriptu, kilka megabajtów assetów i pętla animacji zjadająca baterię, w zamian za efekt, który użytkownik widzi przez trzy sekundy i przewija. Ten sam efekt z pliku wideo albo animowanego SVG waży ułamek tego i nie ma ryzyka utraty kontekstu WebGL.
Osobna kategoria: mierzalne rzeczy dla wąskiej grupy. Podgląd modelu w panelu administracyjnym dla dziesięciu osób z firmowych komputerów ma zupełnie inny budżet niż widok na karcie produktu w sklepie z ruchem mobilnym. Warto rozdzielić te dwa przypadki w rozmowie o wymaganiach, bo w pierwszym można sięgnąć po WebGPU i pełne post-processingi, a w drugim liczy się każdy kilobajt.
Podsumowanie
Three.js jest dziś standardem, nie wyborem — po szesnastu latach i 185 rewizjach nie ma w przeglądarce alternatywy o porównywalnym zasięgu. Co z tego wynika przy planowaniu wdrożenia:
- Realistyczna scena to 158 KB po gzipie — zmierzone, nie oszacowane. Minimalna 129 KB, ścieżka WebGPU 235 KB. Tree-shaking zdejmuje około 30%, nie 90%,
- WebGLRenderer do produkcji, WebGPURenderer do inwestycji — oficjalny podręcznik nadal nazywa nowy renderer eksperymentalnym, a stary „zalecanym wyborem dla aplikacji czysto WebGL 2", jednocześnie zapowiadając brak nowych funkcji w starym,
- TSL zamiast GLSL-a przy WebGPU — jeden shader transpilowany do WGSL-a albo GLSL-a, 638 funkcji w
three/tsl, aleShaderMaterialionBeforeCompile()tam nie działają, - WebGPU jest Baseline od stycznia 2026, z realnymi lukami: Firefox na Linuksie i Androidzie, Windows na ARM64. Awaryjny backend WebGL 2 zostaje w bundlu i trzeba go przetestować przez
forceWebGL: true, - Pięć pułapek w Laravelu z Inertią — SSR w Node bez
document, biblioteka wpadająca do głównego bundla, brakglbwKNOWN_ASSET_TYPESVite,worker-src blob:dla KTX2 i kontekst WebGL, który zostaje po nawigacji, - Dekodery nie wymagają już kopiowania do
public/— w r185DRACOLoaderiKTX2Loaderrozwiązują adresy przezimport.meta.url, więc bundler robi to za ciebie, - Największy zysk jest w assetach, nie w kodzie — konwersja do
.glb, redukcja siatki, Draco albo meshopt, tekstury w KTX2. Z 40 MB do 2 MB to typowy, nie wyjątkowy wynik, - Rewizje łamią API i wygląd — przypnij wersję na sztywno i czytaj przewodnik migracji przed podniesieniem, nie po.
Licencja: three.js jest na MIT (Copyright © 2010–2026 three.js authors) — użycie komercyjne, modyfikacje i produkty zamknięte bez ograniczeń, przy zachowaniu treści licencji. Nie ma zależności produkcyjnych, więc drzewo licencyjne kończy się na tym jednym pliku. Dwa wyjątki do odnotowania w rejestrze zależności: dekoder Draco (Google) i transkoder Basis Universal (Binomial) dołączone w katalogu dodatków są na Apache License 2.0 — również przyjaznej komercyjnie, ale z własnymi wymogami co do informacji o zmianach. Osobna sprawa, o której łatwo zapomnieć przy demo: modele, tekstury i mapy HDRI z katalogu przykładów mają własne licencje i wymogi atrybucji, niezależne od MIT-a biblioteki. Asset skopiowany z przykładu do projektu klienta trzeba sprawdzić osobno albo zastąpić własnym.