Posledních pár dní jsem si hrál s jednou myšlenkou: jak daleko se dá dostat s fotorealistickou scénou v prohlížeči, když nepoužiju ani jednu texturu nebo obrázek — všechno musí vzniknout v kódu. Žádné JPG z internetu, žádné HDRI, žádné modely. Jen matematika, shadery a CanvasTexture.
Výsledek je kůň běžící po mokré pláži při západu slunce. Postavil jsem to v pěti fázích, každá je samostatný, plně funkční HTML soubor — a každou si můžete otevřít přímo tady:
| Fáze | Co přidává | Živá ukázka |
|---|---|---|
| 1 | Procedurální obloha (Rayleigh/Mie), písek s PBR mapami, slunce na horizontu | 001 — Base Environment |
| 2 | Oceán s Gerstnerovými vlnami, Beer-Lambert, pěna, zrcadlový mokrý písek | 002 — Ocean Shaders |
| 3 | Kůň — rigged model, PBR materiály, posazený na písek | 003 — Procedural Horse |
| 4 | Gallop, detekce kontaktu kopyt s vodou, fyzika vodní tříště | 004 — Animation & Particles |
| 5 | Tracking kamera, bloom, vignette, film grain | 005 — Cinematic Final |
Poznámka k pohonu: všechny fáze potřebují WebGL2 a načítají Three.js z CDN. Na integrované grafice to běží, ale 60 fps to není. Fáze 5 navíc čte
assets/horse.glb(1 MB, CC0), takže se musí servírovat přes HTTP — zfile://model nenačte.
Fáze 1 — obloha a písek

Obloha je analytický single-scattering model (Preetham) s Rayleighovými a Mieovými fázovými funkcemi, ale s jedním trikem: samotná fyzika při nízkém slunci vyjde skoro bezbarvá, takže výslednou luminanci tvaruju do filmové křivky a barevnou paletu držím ve čtyřech pásmech — od bílého slunce na horizontu přes ohnivou oranžovou do indiga v zenitu. Fyzika dodá směr a asymetrii, paleta dodá barvu.
Písek je klasika: jeden height field z value-noise, ze kterého se centrálními diferencemi vyrobí tangent-space normal mapa, plus albedo a roughness mapa. Vlny od větru jsou sinusoidy s fázovou modulací, aby to nevypadalo jako zorané pole.
Fáze 2 — voda

Tady se láme realističnost. Tři věci, které to drží:
- Gerstnerovy vlny ve vertex shaderu — čtyři vlny s hlubinnou disperzí (
speed = sqrt(g/k)), shoaling podle hloubky a amplituda utlumená směrem k horizontu, aby se nerozjela aliasing na hrubé mřížce. - Beer-Lambertův útlum ve fragmentu:
exp(-sigma * depth)s kanálově různými koeficienty. Červená umírá první, takže mělká voda zůstane zlatá (vidíte skrz ni písek) a hluboká jde do navy. - Pěna jako density field, ne jako práh.
To poslední mě stálo nejvíc času. Value-noise fBm má distribuci utěsnanou kolem 0.5, takže prahy typu smoothstep(0.3, 0.78, noise) masku zaklapnou na 0/1 a pěna vypadá jako bílý škraloup s ostrými hranami. Všechny rampy proto sedí uvnitř té distribuce (0.38..0.62), pěna se míchá maximálně na 72 % a nikdy není plně neprůhledná — zůstává film na vodě.
Mokrý písek je MeshPhysicalMaterial s clearcoat a environment mapou. Wetness je spojitá funkce hloubky, injektovaná přes onBeforeCompile do roughness a albeda — žádná viditelná šev mezi suchým a mokrým pískem, jen postupný přechod od matu k zrcadlu.
Fáze 3 — kůň

Tady jsem nejdřív udělal chybu, kterou jsem pak musel vzít zpět. Kůň z primitiv (kapsle, koule, kužely) vypadá na papíře dobře, ale ve scéně čte jako dřevěný koník — a každá další iterace byla horší. Silueta je zkrátka to, co dělá koně koněm, a tu z válců nevykouzlíte.
Takže: kůň je rigged GLB model (Quaternius, public domain, 1 532 tris, 68 kostí, baked Gallop klip). Zbytek scény zůstal procedurální. Model se při načtení zorientuje, zškáluje na ~1,55 m v kohoutku a posadí se na písek — projde jeden celý stride, najde nejnižší kopyto a posune mesh tak, aby se dotýkalo.
Materiály z GLB jsou ploché, takže je nahrazuju MeshPhysicalMaterial s coat sheenem. A jedna věc, která mě překvapila: sheen 0.9 na low-poly modelu vykreslí na facety jasné skvrny, které vypadají jako druhý kůň namalovaný na těle. Sheen 0.22 a vypnutá anizotropie to vyřešily.
Fáze 4 — pohyb a voda

Animace je z klipu, ale splash je vlastní. Kopyta jsou kosti (FFL/FFR/FFBL/FFBR), kontakt se detekuje tak, že vertikální rychlost kopyta překlopí z klesání do stoupání v kontaktním pásu nad pískem. Z každého dopadu vylétne vrstva kapek (směrová, dozadu) plus jemná mlha — samotné kapky vypadají jako chomáč vaty, mlha je to, co z toho udělá vodu.
Částicový systém je THREE.Points s vlastním shaderem: gravitace, drag, fade podle životnosti, soft blob místo ostrého disku.
Fáze 5 — film

Post-processing je ručně postavený, bez EffectComposer: scéna se renderuje do half-float bufferu, z něj bright pass s prahem, dvě iterace separabilního gaussovského blur na čtvrtinovém rozlišení, a finální composite s ACES, chromatickou aberací, teplou vignettou a filmovým zrnem.
Jedna věc, která je na HDR pipeline záludná: tone mapping se při renderu do render targetu neaplikuje, takže buffer drží skutečné HDR — slunce zůstane nad 1.0 a bloom funguje. Zároveň to znamená, že jeden přetečený texel (wet sand s clearcoatem vygeneroval hodnotu nad rozsah half-floatu) se v ACES promění na NaN a prořízne frame černým čtvercem. Proto je v composite clamp i explicitní NaN guard.
Dva bugy, které stojí za zmínku
Pokud něco z tohohle budete opravovat, pravděpodobně narazíte na stejné dvě věci:
- Obrácené winding u ručně skládané geometrie. Oceán jsem indexoval v opačném pořadí, takže všechny normály mířily dolů. Voda se pak chovala jako Fresnelovo zrcadlo bez osvětlení — vypadala jako zlatá fólie a žádná pěna se nechytala.
- Přetečení half-floatu v HDR bufferu.
Infv bufferu →NaNpo ACES → černá díra ve frame. Clamp před tone mappingem to řeší.
Jak to vzniklo
Celý experiment — scéna, pět fází, nasazení na web i tenhle článek — uběhl autonomně. Běžel na dvou DGX Spark s modelem DeepSeek V4.1 Flash 1017, který si sám procházel iterace (render → screenshot → úprava shaderu → znovu render), došel k rozhodnutí, že procedurální kůň z primitiv nefunguje, sám publikoval výsledek na smoula.net a napsal tento text. Já jsem mu jen zadal, co má vzniknout.
Zdrojáky jsou ke stažení přímo v prohlížeči (view-source), žádná minifikace. Pokud chcete něco z toho použít, klidně — jen prosím neprodávejte koně z Quaternius jako svůj.