Инженерлік жазбаГүлді

Floral тақырыбын жасау: бір күлтеден бастап

Shaqyrtu авторынан

Floral тақырыбы қозғалуды шешкен фотосуретке ұқсайды. Күлтелер жұмсақ, суретшінің қолымен салынғандай аспаннан төмен қалықтайды; сіз скролл жасаған сайын олар жиналады — алдымен баяу айналатын тәжге, содан кейін жүрек пішініне, ал жауап беретін блокта — гүл шоғына. Мұның ешқайсысы видео да, GIF те емес. Мұнда циклге салынатын кадр де, жүктеп алатын клип те жоқ. Әр пиксель сіздің телефоныңыздың GPU-сында, математикадан, секундына алпыс рет салынады.

Мен оны шын мәнінде қалай жасалғанындай етіп құрғым келеді: бос canvas-тан бастап, рендерленетін ең кішкентай нәрсені қосамын, тек содан кейін ғана келесісін қосамын. Соңында сіз "гүлдер даласы" шын мәнінде бірнеше draw call және мұқият алдау екенін көресіз.

0-қадам: бір canvas және бір шешім

Барлығы қарапайым requestAnimationFrame циклімен рендерленетін бір three.js сценасында өмір сүреді. Камера — 50° көру өрісі бар PerspectiveCamera, және мен оны z = 0 жазықтығы viewport-ты толығымен толтыратын дәл сол қашықтыққа орналастырамын:

camera.position.z = height / 2 / Math.tan(fovRad / 2)

Осы бір жол көп ыңғайлылық сыйлайды: гүл жазықтығы world бірліктерінде дәл экран биіктігін қамтитындықтан, мен кейінірек әр позицияны қарапайым CSS пиксельдерінде жаза аламын және ол дәл мен күткен жерге түседі. Рендер циклі бір uTime-ды секундпен алға жылжытады және кадрлар арасындағы delta-ны 0.05-ке дейін шектейді, сондықтан сіз бетбелгіні ауыстырып, кері оралғанда ештеңе секіріп кетпейді:

const dt = Math.min((ts - tPrev) / 1000, 0.05)

Экранда әлі ештеңе жоқ. Оған аспан берейік.

1-қадам: аспан — бұл noise функциясы

Фон — сурет емес. Бұл жұмсақ, бұлтты жағынды — "жібекті" — салатын fragment shader-і бар бір толық экрандық үшбұрыш. Shader — фракталдық value noise (кішкентай fbm, үш октава), жолақтар түзу жүрудің орнына иілуі үшін домені бойынша бұрмаланған, содан кейін төрт палитра түсімен боялған:

// three octaves of value noise, warped by itself
float n = fbm(uv * 2.0 + warp);
vec3  sky = mix(uC1, uC2, smoothstep(0.2, 0.8, n));

Палитра Poppy Field деп аталады: көгілдір аспан, қызғылт гүлдер, алтын жүрек акценттері. Аспанды лай тігіссіз күлтелердің астына орналастыру бір айқын емес реттеу түзетуін талап етті — алдымен салқын фонды бірінші салу, содан кейін жоғарыдан жылырақ күлте жағындыларын композиттеу. Керісінше жасаңыз, жылы/салқын диагональ араласу шекарасы бойымен таласады. Салқыны бірінші, жылысы үстінде — тігіс жоғалады.

Бүкіл нәрсені бірнеше uniform басқарады — ажыратымдылық, уақыт, скролл және сіз есімдер, күнтізбе мен жауап бөлімдерінің арасында қозғалғанда аспанның жылырақ немесе салқынырақ болуына мүмкіндік беретін үш "көңіл-күй" салмағы (uM1, uM2, uM3). Аспан ешқашан статикалық емес, бірақ ол ешқашан назарды да талап етпейді. Оның жұмысы — осы.

2-қадам: күлте — бұл fragment shader айтқан өтірік

Бүкіл тақырып сүйенетін айла осында: гүл геометриясы деген жоқ. Күлте — жалпақ бірлік quad — төрт төбе — және оның пішіні толығымен fragment shader-де салынады. Quad-тың жергілікті координатасы p берілгенде, shader тамшы пішінін ойып шығарады:

float t = clamp(p.y * 0.5 + 0.5, 0.0, 1.0);
float w = 0.72 * sin(pow(t, 0.85) * 3.14159);        // teardrop width along the length
float f = pow(abs(p.x) / max(w, 1e-4), 2.0)
        + pow(abs(t - 0.52) / 0.54, 2.3);            // radial "inside/outside" field
float alpha = (1.0 - smoothstep(1.0 - vBlur, 1.0 + vBlur * 0.8, f)) * vAlpha;

f — күлте пішінінің ішінде кішкентай және сыртында 1.0-ден асып өсетін тегіс өріс. smoothstep сол шекараны жұмсақ, фокустан тыс жиекке айналдырады — vBlur мүшесі алыс күлтелердің қатты кесіліп алынған емес, bokeh ретінде оқылуын қамтамасыз етеді. Текстура жоқ, mesh жоқ; тамшы — бұл жай теңсіздік.

Жиек бойындағы жібек жылтырлық — rim мүшесі. Мен мұнда дәл болғым келеді, өйткені артық айту оңай: бұл dot(normal, viewDir) fresnel емес — күлтеде normal да жоқ, view вектор да жоқ. Бұл сол силуэт өрісі f-тен алынған 2D жарық, жиектер жылы дененің фонында салқын жарықты ұстауы үшін көгілдір реңкке боялған:

col += uRim * smoothstep(0.55, 1.0, f) * (0.22 + (1.0 - vBlur) * 0.42);

Жарықтандырудан арзан, әрі жалпақ, армандай күлте үшін ол жақсырақ та оқылады.

Low vBlur = crisp petal; high vBlur = soft bokeh. uRim adds the cyan edge light. No texture, no mesh — just the field f.

3-қадам: бір күлтеден елу үшке дейін, тегін

Бір quad — бір күлте. Аспанды толтыру үшін мен елу үш объект жасамаймын — бұл елу үш draw call және кекештенген телефон болар еді. Оның орнына күлтелер — InstancedBufferGeometry: бір quad, бір ғана шақыруда көп рет салынады, әр instance өзінің per-instance атрибуттарын оқиды.

Олар үш тереңдік жолағына бөлінген, сондықтан даланың алдыдан-артқа қарай нақты ауасы болады:

ЖолақСаныӨлшеміСезім
Алыс шаң26unit·0.020ұсақ, бұлыңғыр дақтар
Орта22unit·0.042айқын, "нағыз" күлтелер
Жақын bokeh5unit·0.115үлкен, жұмсақ, фокустан тыс

Елу үш instance, бір draw call. Әрқайсысы кішкентай атрибуттар бумасын алып жүреді — seed, құлау жылдамдығы, тербелу амплитудасы, айналу жылдамдығы, базалық тереңдік және (оған жетеміз) құрылымдарға арналған мақсатты позициялар. Жақын жолақ саны бойынша әдейі ұсақ, ал өлшемі бойынша үлкен: линзаға жақын қалықтайтын бірнеше жұмсақ дақ ешқандай нақты depth-of-field pass-сыз "өріс тереңдігін" сатады.

4-қадам: қозғалыс vertex shader-де өмір сүреді

Енді олар қозғалады — және барлық қозғалыс vertex shader-де, әр instance үшін, GPU-да жүреді. Еркін қалықтап жүргенде күлте:

  • fract(seed + uTime * fallSpeed) арқылы тігіссіз циклде құлайды, сондықтан ол көрінетін қайта орнатусыз төменнен жоғарыға оралады;
  • бір жаққа тербеледі және мен сіздің скролл жылдамдығыңыздан беретін uWind мәніне реакция жасайды, сондықтан бетті сермегенде дала қозғалады;
  • үш кішкентай айналу матрицасы rX · rY · rZ арқылы 3D-де аунайды, бұл жалпақ quad-тың ешқашан жалпақ болып көрінбеуін қамтамасыз етеді.

Әр күлтенің өз seed-і мен жылдамдықтары болғандықтан, елу үш quad ешқашан бір ырғақпен құламайды. Бұл бірнеше sin шақыруының бағасына органикалық болып көрінеді.

53 petals · 1 draw · 3 depth bands

5-қадам: күлтелерге баратын жер беру

Қалықтау әдемі, бірақ шақырудың эмоционалдық доғасына ниет керек. Сондықтан беттің әр бөлімінде presence bell — сол бөлім экранда ортаға келгенде шыңға жететін тегіс 0 → 1 → 0 салмағы бар. Мен оны скролл позициясынан есептеймін, әрі ешнәрсе секіріп кетпеуі үшін скроллдың өзін де тегістеймін:

uScroll += (rootTop - uScroll) * 0.06   // critically-damped follow

Бөлімнің bell-і көтерілгенде, оның күлтелері қалықтаудан шығып, мақсатты пішінге қарай морфтанады. Үш акт бар:

  1. Тәж. Күлтелер сақинаға орналасады — полярлық мақсаттар (angle, radius, hubY) — және shader бүкіл тәждің баяу айналуы үшін ақырын uTime · 0.20 қосады. Радиус мәтін қорабына сай өзін реттейді, сондықтан сөздер сақинаның ішінде отырады.
  2. Жүрек. Бұл — тақырыптағы шынайы параметрлік математиканың жалғыз бөлігі. Күлте мақсаттары классикалық жүрек қисығын сызады:
    hx = 16 * Math.pow(Math.sin(t), 3)
    hy = 13*Math.cos(t) - 5*Math.cos(2*t) - 2*Math.cos(3*t) - Math.cos(4*t)
    
    Гүлдер өздерін сол контурға орналастырады. Параметрлік — бұл layout, күлтелер емес.
  3. Гүл шоғы. Жауап блогында күлтелер hub-ы төменгі жиектің дәл астында отыратын айналатын дөңгелекке жиналады, сондықтан сіз RSVP-ды тәждеп тұрған гүл шоғының жоғарғы жағын көресіз. Көп қалықтайтын нәрселер бірге болады, дәл қонақ қосылуға шақырылатын жерде.

Морфтың өзі — "гүлдеу". Әр күлте өзінің қалықтау позициясынан мақсатына smoothstep-пен жеңілдейді, per-petal stagger-мен ығыстырылған, сондықтан пішін орнына секіріп қоюдың орнына бір гүлден кейін екіншісін құрайды:

float w  = clamp(bell - stagger, 0.0, 1.0);
float we = w * w * (3.0 - 2.0 * w);              // smoothstep — the "bloom" easing
center   = mix(driftPos, formPos, we)
         + perp * sin(we * 3.14159) * 90.0;      // arc out on the way in, so it flies rather than slides

Скроллды әрі жылжытыңыз да, bell түседі; сол жеңілдету кері жүреді және гүлдер қайтадан қалықтайтын күлтелерге еріп кетеді. Актілер арасында жұмсақ құйын бәрін тірі ұстайды, сондықтан дала ешқашан қатып қалмайды.

wreath

Drag scroll. Each act is a smoothstep bell; petals fly in on an arc, staggered so the shape assembles one flower after another. Scrub back and it dissolves.

6-қадам: фотосуретіңіздің алдында та, артында да күлтелер

Hero — сіздің фотосуретіңіз, нақты DOM элементі. Мен күлтелердің оны айналуын қаладым — біреулері артынан өтеді, біреулері алдынан кеседі — мұны бір ғана canvas жасай алмайды, өйткені бір canvas стек тәртібінде фотосуреттің не үстінде, не астында болады.

Сондықтан екі canvas бар. Негізгі canvas мазмұнның артында отырады; екінші, мөлдір overlay canvas оның үстінде отырады. Айналатын күлтелер (он бір instance-тен тұратын кішкентай сақина) екеуіне де салынады, uZSide uniform-ы артқы жарты үшін -1, алдыңғы жарты үшін +1 етіп ауыстырылады. Артқы жарты төменгі canvas-та, алдыңғы жарты жоғарғысында рендерленеді, ал сіздің фотосуретіңіз екеуінің арасына салынады. Күлтелер суретті айналып жүргендей көрінеді. Бұл — тақырыптың екінші WebGL контекстін жұмсайтын жалғыз орны, әрі сол бір эффект үшін оны істеуге тұрарлық.

back half behind · front half in front

Watch a petal at the top edge: it slides behind the photo, comes around the bottom, and crosses back in front. In the real theme those two halves live on two stacked canvases with the photo between them.

7-қадам: жанасу және мейірімді болып қалу

Аспанды түртіңіз де, жақын күлтелер шашырайды — жаңа гүл емес, толқын. Pointer handler сіз қайда және қашан түрткеніңізді жазады (uTouchPos, uTouchTime), ал vertex shader күлтелерді сол нүктеден итеріп, қашықтық пен жасқа қарай өшіреді:

float push = exp(-dist / 220.0) * exp(-age * 2.4) * uMotion;
center += normalize(d) * push * 46.0;

Тағы екі әдеп маңызды. Егер сізде reduce motion қосулы болса, бір ғана uMotion = 0 uniform-ы уақытпен басқарылатын әр мүшені қатырады — күлтелер мен аспан жай ұстап тұрады, арнайы жағдайлық код жоқ. Ал сіз құрылымның жанында скроллды тоқтатқаннан кейін, жұмсақ магниттік тартылыс соңғы бірнеше пиксельді итеріп жібереді, сондықтан тәж немесе жүрек жартылай құралудың орнына таза қонады.

Tap or click the petals — the ripple pushes them out and settles back.

8-қадам: тек шын өмірде ғана болған баг

Жоғарыдағының бәрі редакторда тамаша жұмыс істеді және бірінші нақты шақыруда бұзылды. Сервер жағында рендерленіп, содан кейін hydrate жасалатын жария /i/:slug бетінде canvas үнсіз ешқашан инициализацияланбайтын.

Себебі — SSR hydration-ның уақыт қиындығы: onMounted ішінде canvas-қа арналған template ref әлі null болатын. DOM бар еді, бірақ onMounted жанған сәтте Vue ref-терді байланыстырып бітірмеген еді. three.js-ке null canvas беру сізге ешнәрсе әпермейді, әрі ол тек hydration кезінде ғана қайталанатын — таза клиенттік mount оны ешқашан көрсетпеді.

Компонент — <ClientOnly>-ге оралған .client.vue, сондықтан WebGL сервер рендері кезінде ешқашан жұмыс істемейді, ал инициализация GPU-ға тиіспес бұрын ref-тердің шынымен байланысуын күтеді:

onMounted(async () => {
  // onMounted can fire a tick before template refs are bound under SSR hydration.
  for (let i = 0; i < 20 && (!canvasRef.value || !host.value); i++) {
    await nextTick()
  }
  if (!canvasRef.value || !host.value) return   // give up gracefully, never crash
  rootEl = host.value.closest('.floral-scroll')
  if (!rootEl) return
  renderer = new THREE.WebGLRenderer({
    canvas: canvasRef.value,
    antialias: true,
    alpha: true,
    powerPreference: 'low-power'
  })
  // …start the render loop
})

Бұл кішкентай цикл, бірақ ол нақты сабақты кодтайды: SSR + WebGL — бұл рендерлеу мәселесі емес, уақыт мәселесі. GPU коды бүкіл уақыт бойы жақсы болды; canvas жай әлі жоқ еді. Инициализацияны onMounted-қа сенудің орнына әлі байланыспаған ref-тен қорғау — бұл сіздің ноутбугіңізде жұмыс істейтін демо мен мыңдаған қонақ телефондарында ашатын тақырыптың арасындағы айырмашылық.

Неге әуре болу керек

Гүлдің фотосуреті — бұрын болып өткен нәрсенің суреті. Сіз скролл жасаған кезде өзін-өзі құрайтын гүл дәл қазір, сіздің құрылғыңызда болып жатыр, әрі ешқашан екі рет бірдей емес. Осы осы шақтық, қайталанбас сапа — Floral тақырыбының бүкіл ставкасы, әрі оның видеофайл емес, елу үш quad пен екі shader болуының себебі. Бос canvas-тан бастаңыз, бір уақытта бір шынайы алдауды қосыңыз, сонда сіз тірідей сезілетін нәрсеге ие боласыз.

Өз шақыруыңды жаса

Осы дизайнды таңдап, 5 минутта дайында.

Осы дизайнмен жасау