Як я малював картинки за допомогою JavaScript заради лайку від дівчини
Привіт, мене звати Юра. Я давно займаюсь фронтендом і люблю математику. В якийсь момент вирішив спробувати робити те, чого боюсь, і став малювати картинки за допомогою JavaScript і Canvas. А потім і робити всілякі анімації.
Швидко втягнувся і почав робити їх кожен день. Тоді це було для того, щоб привернути увагу дівчини. Тож, самі розумієте, мені дуже критично було кожен день постити нову анімацію. Я шукав собі різні референси, і намагався їх відтворити в своєму стилі. В один з днів я побачив класичну гіпнотичну гіфку:
Автор гіфки, до речі, легендарна людина Dave Whyte. Якщо вам подобається такий арт, рекомендую підписатися. Він навіть робив титри для серіалу Queen’s Gambit.
Але повернемось до мене. Гіфка виглядала простою — щось збільшується, в кінці вона починається з початку, участь в процесі беруть лише чорно-білі квадрати. Що могло піти не так, та і яка тут може бути математика, окрім збільшення квадратів?
Та що тут робити
Спочатку я подумав: тут все двомірне, в 3D немає сенсу, просто зроблю на Canvas2D. Теоретично можна було і на CSS, але з нього складніше зробити якісне відео. А тут ще і явно був якийсь motion blur.
Якщо вам цікаво, код що намалює шахову дошку, виглядає десь так:
grid.forEach(cell=>{ // масив з шаховою сіткою context.save(); context.translate(x,y); context.fillStyle = (cell.odd>0.5)?'#fff':'#000' context.fillRect(0,0,size,size); context.restore() })
Я звернув увагу, що кожен квадратик перетворюється на дев’ять маленьких. Тобто мені треба було два таких гріди, один з яких в три рази менший за інший.
Потім я просто збільшував розміри обох грідів у три рази та проявляв другий шар з маленьким грідом пізніше. Звів анімацію до зміни розміру гріда, і її динамічна частина виглядала так:
let CellSize = lerp(size,3*size,progress) // lerp(a,b,t) = a*(1-t) +b*t - лінійна інтерполяція двох чисел
До речі, progress тут і далі — це зміна часу від 0 до 1, фактично, таймлайн анімації.
Радісний я записав відео з Canvas. І...
На перший погляд, я розв’язав свою задачу. Воно анімувалось, було зациклено. Звичайно вона трохи відрізнялась, якісь числа я не вгадав. Але.
Можливо, хтось би і забив, але ж я бачив стрибочок при кожному програванні, а оригінал був плавний. І на кону була увага дівчини, яка, як я думав, могла через це не лайкнути гіфку. Це, звісно, лише жарт (ха-ха, ні 🥲).
Тож я не зупинився. Подумав, це сталось через 2D-простір, і «несправжній» зум вирішив переробити в 3D-просторі. Щоб я фізично «летів» назустріч кубикам, а кубики не збільшувалися, а просто наближалися. Мабуть, це з 2D-зумом не працює так, як в реальному світі. А от 3D мене виручить — в цьому просторі я живу, його я точно знаю!
3D
Я використав three.js, бо це стандарт всього 3D на фронтенді зараз. Створив сцену, де камера летіла назустріч кубикам, та в процесі проявляв втричі менший грід. Той самий сетап, але тепер змінювалась лише камера. А кубики залишались на місці і проявлялись.
Звісно, і код був інший. Тепер кожен кубик був Mesh, і на three.js малювання виглядало так:
grid.forEach(cell=>{ let cube = new Mesh(new BoxGeometry(1),new MeshBasicMaterial({color: g.odd?'white':'black')) cube.position.set(x,y,0) // кубики в площині xy, а камера рухається по осі z scene.add(cube) })
Безпосередньо анімація наближення — один рядок з рухом камери. Бо, щоб щось стало в три рази більшим, треба лише скоротити відстань до об’єкта в три рази.
Тож це був мій код:
// progress змінюється від 0 до 1, відстань до камери -- від 1 до 1/3 camera.position.z = lerp(1,1/3, progress);
І я отримав результат 🥳
Той самий клятий результат!
Але тепер я вже розумів, що річ у тому, як рухається камера, бо лише це збільшувало сцену. Я згадав, що її рух — це насправді математична функція:
f(x) = lerp(1,1/3, x) = 1 - x*2/3
Графік виглядав так:
Вона лінійна, і все, що важливе в ній — значення у точці 0 та 1, це відповідно 1 та ⅓.
І тут я подумав як «математик», що, мабуть, лінійний рух камери не описує плавність, яка мені треба. А також потрібні функції вищого ступеня, другого чи третього. І настала фаза відчаю, де я просто вгадував всі функціі, що задовольняли:
f(0) =1 f(1) = ⅓
Відчай
Я подумав про сімейство функцій f = ax^2+bx+c і їх виявилось нескінченно багато:
Безліч a та b, що підійдуть. На графіку це виглядає так:
Тож я спробував декілька варіантів. Зараз дивлюсь в старий код, там навіть був варіант
f(x) = (32/45)*x^2 — (8/5)*x + 1; який взагалі не задовольняє рівняння 😅 Ну як так, Юра з минулого?!
З деякими функціями анімація стала іншою, теж цікавою. Фактично це був easing.
f(x) = ⅔ *x^2 -4/3 *x+1
Для мене було інсайтом, які різні емоції можуть викликати рухи камери різними параболами. А одна з функцій просто раптово була ну дуже близька:
f(x) = ⅓ x^2-x+1
Це виглядає як майже ідеальний луп. І навіть зараз мені важко побачити різницю. Але уявіть, наскільки я був прискіпливим, що побачив, що вона не ідеальна — зум нерівномірний. Плюс я подумав, що ця функція спрацювала для збільшення 1 до 3, але тепер для збільшення 1 до 4 не підійде. Знов треба рівняння і вгадування? Тобто це якесь «несправжнє рішення» задачі.
Прийняття математика в собі
В якийсь момент мене все ж осяяла математична муза, і я зрозумів, що крім точок-значень в 0 та 1, я хочу також однакову швидкість наближення моєї камери до об’єкта на початку і наприкінці. І це викликало моментальний ланцюжок думок.
Бо швидкість це буквально похідна від функції руху. Функції руху, що я вгадую!
Тобто мені треба дописати ще одне рівняння:
f(0) = 1 f(1) = ⅓ f’(0) =3*f’(1) - найважливіше!
Чому саме 3 — тому, що в кінці мого циклу, в мене фактично відбувалася заміна об’єктів, що збільшилися в три рази, на їх копії з початку анімації. Тобто швидкість мала впасти в три рази, щоб залишитися для глядача сталою!
Бо. наприклад, похідна від початкової функції завжди стала:
f’(x) = (1-⅔ x)’ = -⅔
Саме це ми й бачили — об’єкти підмінялись і швидкість для глядача суб’єктивно стрибала в три рази. Цікаво: якщо підставити сюди наші квадратні функції, отримаємо саме той варіант, що я вгадав!
Але я вже знав, що мені її мало, тож відбувся другий візит математичної музи. Згадав, що всі ці збільшення в рази мають щось спільне з природою експоненти, і розглянув сімейство функцій:
F(x) = a*e^(bx)
Бо в нього ще й цікаві властивості з похідними.
Тож я кинувся розв’язувати ці рівняння для нової функції.
Розвʼязок швидко знайшовся, і ця функція ідеально лягала в наші обмеження!
f(x) = e^(ln(1/3)*x)
Тут одразу легко побачити, як його масштабувати для будь-якого збільшення в N разів! Просто замінити 3 на N.
Додайте сюди властивість з мемів:
Вона або він не змінюється при диференціації, тобто швидкість рівномірна. І це і є та природа, що забезпечує безперервний плавний рух збільшення! Те, що я так довго шукав.
Просто уявіть собі той момент, коли після годин страждань, відчаю і диференційних рівнянь, я, затамувавши подих, підставив функцію у свою анімацію, і вона стала ідеально плавною!
Я тут ще намалював вам квадратичну, експоненційну і лінійну функцію, що я використав. Просто зацініть, наскільки квадратична була близька до реального розв’язку!
Може здатися, що ця функція тільки для цього і підійшла б. Але ці анімації побудовані на ідентичній функції:
Висновок
Я не думаю, що саме таке рішення врятує вам життя, адже це досить специфічна ситуація. Але цей майндсет, коли ти настільки хочеш розв’язати задачу, що навіть згадуєш універ і матан, виручав мене вже не раз. Як бонус: саме це рішення простого диференційного рівняння допомогло мені зробити так багато гарних анімацій. Тому я вирішив поділитися цією і математичною і візуальною красою експонент з вами. Якщо теж бачите в цих логарифмах і експонентах красу, пишіть в коментарях, надсилаю вам математичні обійми :)
P.S. Не пам’ятаю, чи вона лайкнула цю гіфку, але я пригадую, що лайкнула інші. В ретроспективі я щасливий, що йшов в цих своїх ідеях, нехай і відчайдушно наївних, до кінця.
37 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів