hydra-composition: великий вираз як рендер-граф
Hydra виглядає як ланцюг крапок: osc().rotate().color().out(). Поки скетч короткий, це працює. Але щойно він доростає до тридцяти викликів у один рядок, приходить стеля — таке вже не читається й не правиться. Змінити одну частину означає знайти її очима серед решти й не зачепити сусідів. Це не проблема довжини рядка. Це проблема того, що ланцюг сприймають як текст, а не як граф.
Великий Hydra-вираз — це орієнтований граф рендера. Вузли — текстури, ребра — читання одної текстури іншою, а чотири вихідні буфери o0..o3 — точки, куди граф стікається. Ланцюг крапок — лише один із способів записати ребро, і не найважливіший. Тільки-но видно граф за синтаксисом, «нечитабельний вираз на 30 викликів» розпадається на кілька коротких, кожен зі своїм ім'ям.
Ця стаття — про те, де граф різати на буфери, де модулювати, де давати зворотний зв'язок, і чим платиться за кожен рівень. Матеріал звірений з hydra-synth і обкатаний на власному ригу — плеєрі hydra-album і лайв-кодинг-редакторі mandflok, де Hydra живе в iframe із власним компоузером переходів.
Обчислювальна модель: буфери, джерела, кадр

За замовчуванням Hydra піднімає чотири вихідні буфери — o0, o1, o2, o3 — і чотири зовнішні джерела s0, s1, s2, s3. Буфер — це текстура на GPU розміром із канву. Джерело — вхід ззовні: камера, екран, відео, зображення, інша канва.
Ключова частина, яку легко проґавити: те, що пишеться в скетчі, виконується не щокадру. Вираз osc(...).out(o0) — це разова інструкція «збери ось такий ланцюг шейдерів і признач його буферу o0». Далі щокадру Hydra рендерить уже зібраний ланцюг у буфер, а render() виводить буфер на екран. .out() без аргумента — те саме, що .out(o0); render() без аргумента показує сітку 2×2 з усіх чотирьох буферів, render(o0) — лише o0 на весь екран.
osc(10, 0.1, 0.8)
.out(o0)
render(o0)
Наслідок прямий: JS-код у скетчі — це конструктор графа, а не тіло циклу. Він виконується один раз на кожен запуск. Усе, що має змінюватись щокадру, живе всередині графа — як функція-параметр (нижче про це окремо), а не як JS-логіка навколо. Цикл на for, який щось «анімує» перезбором виразу, — майже завжди помилка: він перебудовує граф замість того, щоб дати графу самому текти в часі.
Зовнішнє джерело спершу ініціалізують, потім читають як текстуру через src():
s0.initCam()
src(s0)
.scale(1.2)
.color(1, 0.7, 0.9)
.out(o0)
render(o0)
s0.initScreen() бере захоплення екрана, s0.init({ src: canvasElement }) — довільну канву (так mandflok підтягує вихід Strudel у Hydra). Ініціалізація асинхронна: перший кадр після initCam() ще порожній, поки браузер віддає дозвіл і потік.
Три способи з'єднати: ланцюг, композиція, модуляція

Новачок бачить в API один прийом — «чіпляти крапки» — і не помічає, що за ним ховаються три різні операції з різною семантикою. Плутанина між ними і є головним джерелом графів, які «щось роблять, але не те».
Послідовна трансформація (ланцюг). Кожна крапка бере текстуру зліва й повертає нову. Порядок значущий: rotate перед pixelate і після нього дають різне. Це чиста композиція функцій — один вхід, один вихід.
osc(20, 0.05, 1)
.kaleid(5)
.rotate(0, 0.1)
.color(1, 0, 0.1)
.out(o0)
render(o0)
Просторова композиція. Оператори blend, add, mult, diff, layer, mask беруть другу текстуру як аргумент і зводять дві картинки в одну. Тут з'являється друга гілка графа — і саме тут ланцюг перестає бути лінійним. layer кладе поверх з урахуванням альфи, mask вирізає за яскравістю другої текстури, diff дає різницю (добре для країв), add світлішає, mult темнішає.
shape(4, 0.3, 0.02)
.color(0.8, 0, 0.2)
.layer(
osc(30, 0.1, 1).mask(shape(64, 0.5, 0.1))
)
.out(o0)
render(o0)
Модуляція. Сімейство modulate* — інша річ узагалі: друга текстура не змішується з першою за кольором, а зсуває координати, за якими читається перша. modulate(tex, amount) рухає UV, modulateScale — масштаб, modulateRotate — кут, modulateHue — колірне зрушення, modulatePixelate — сітку. Це найпотужніший і найдорожчий інструмент: одна текстура керує геометрією іншої.
voronoi(5, 0.3, 0.1)
.color(0.9, 0.2, 0.5)
.modulateScale(noise(3, 0.2), 0.5)
.modulateHue(osc(3, 0.1, 1), 1)
.out(o0)
render(o0)
Правило вибору просте. Треба змінити вигляд пікселя — ланцюг. Треба звести дві картинки — композиція. Треба, щоб одна текстура керувала розташуванням іншої, — модуляція. Сплутати модуляцію з композицією — типова помилка: .add(noise()) підмішує шум за яскравістю, .modulate(noise()) жмакає простір шумом; виглядають зовсім по-різному.
Зворотний зв'язок: src(o0) як пам'ять

Граф без зворотного зв'язку не має пам'яті — кожен кадр рахується з нуля. Єдиний спосіб дати графу стан між кадрами — прочитати попередній вміст буфера через src() того самого буфера, у який граф пише:
src(o0)
.modulate(noise(4, 0.5), 0.05)
.layer(shape(4, 0.3, 0.1).color(1, 0, 0.3))
.out(o0)
render(o0)
Тут o0 читає сам себе, легко зсуває координати шумом і домальовує зверху нову фігуру. Кадр за кадром вона розмазується в слід — це і є feedback. Той самий src(o0).modulate(...) — найпоширеніша ідіома в живому кодингу, і mandflok-компоузер спеціально її береже: коли ввімкнено плавні переходи, він переписує o0 у свій пінг-понг-буфер так, щоб зворотний зв'язок читав власний бік кросфейду, а не чужий.
Проблема зворотного зв'язку — він схильний вибухати. Якщо кожен кадр додає яскравості або масштабу без утрат, граф за секунду залипає в білий шум і більше нічого не показує:
// АНТИПАТЕРН: без загасання — за мить суцільний шум
src(o0)
.add(shape(4, 0.4).color(1, 1, 1))
.modulate(noise(3), 0.03)
.out(o0)
render(o0)
Лікується загасанням — кожен прохід має трохи втрачати. Множник кольору трохи менший за одиницю, крихітний зсув масштабу всередину, зниження контрасту — будь-що, що тягне сигнал донизу, поки нова деталь тягне вгору. Стабільний feedback — це завжди баланс приросту й утрати:
src(o0)
.scale(0.99)
.color(0.97, 0.98, 0.99)
.modulate(noise(2.5, 0.1), 0.02)
.layer(
osc(6, 0.1, 1).thresh(0.7).color(1, 0.1, 0.2).mask(shape(3, 0.2))
)
.out(o0)
render(o0)
scale(0.99) щокадру стягує на 1% всередину, color(0.97, …) гасить яскравість — разом вони не дають слідові накопичитись до насичення. Прибрати обидві — і граф знову вибухне.
Параметр як функція: єдине місце, де живе час
Граф збирається один раз, тож щоб він рухався, аргумент має бути не числом, а функцією. Будь-який числовий параметр приймає () => …, і Hydra викликає її щокадру:
osc(10, 0.1, 0.8)
.rotate(() => time * 0.2)
.modulateScale(
noise(2), () => 0.3 + 0.2 * Math.sin(time)
)
.out(o0)
render(o0)
time — глобальний лічильник секунд від запуску; це і є основний годинник. Усе, що змінюється в часі без звуку й миші, спирається на time. Миша доступна як mouse.x / mouse.y.
Другий спосіб анімувати параметр — масив. Hydra проходить його значення в часі, а .fast(), .smooth(), .ease() керують тим, як саме:
osc(20, 0.1, 0)
.color([1, 0, 0.5].smooth(1), 0.2, [0.3, 0.9].fast(2))
.kaleid([3, 4, 5, 6].fast(0.5))
.rotate(0, 0.1)
.out(o0)
render(o0)
[3,4,5,6].fast(0.5) перемикає число сегментів удвічі повільніше за базовий темп, .smooth(1) інтерполює між кольорами замість різких стрибків, .ease('easeInOutCubic') дає нелінійний перехід. Масив — це секвенсер прямо в аргументі; ним зручно робити дискретні зміни (перемикання форми), а () => — плавні (обертання, дрейф).
Ці дві техніки закривають майже всю анімацію. Якщо тягне написати JS-цикл, що перезбирає вираз, — майже напевно те саме робиться параметром-функцією, дешевше й без перебудови графа.
Декомпозиція засобами JS: лік від «виразу на 30 викликів»
Ось де лікується головний біль. Ланцюг у Hydra — це звичайний JS-об'єкт. Його можна покласти в змінну, зібрати функцією-фабрикою, перевикористати в кількох місцях. .out() не обов'язковий у кінці кожного — без нього просто виходить текстура, яку можна віддати як аргумент у blend, mask, modulate.
Замість одного нечитабельного рядка граф збирається з іменованих шматків:
const grid = () => shape(4, 0.3, 0.02).repeat(4, 4)
const tint = tex => tex.color(0.8, 0, 0.2).saturate(1.4)
const wobble = tex => tex.modulate(noise(2, 0.3), 0.15)
tint(wobble(grid()))
.add(osc(20, 0.1, 1).luma(0.6), 0.4)
.out(o0)
render(o0)
Кожен шматок має ім'я, тестується окремо й читається за секунду. grid — фабрика (повертає нову текстуру щоразу, бо текстуру не можна безпечно ділити між двома різними ланцюгами як той самий об'єкт), tint і wobble — трансформери, що беруть текстуру й повертають змінену. Правити колір тепер означає йти в tint, а не шукати .color(...) серед тридцяти крапок.
Одне застереження: якщо той самий побудований ланцюг покласти в змінну й вставити у граф двічі, GPU порахує його двічі — спільної підвираз-оптимізації немає. Змінна економить читабельність, не обчислення. Коли той самий проміжок потрібен у кількох місцях графа по-справжньому один раз — його кладуть у окремий буфер (наступний розділ).
Бюджет: кожне читання текстури коштує
Абстракція безкоштовна на рівні JS, але не на рівні GPU. Ціна графа вимірюється в читаннях текстур за кадр, і вони діляться на два класи.
Ланцюг трансформацій коштує лінійно: десять крапок поспіль — приблизно десять кроків шейдера над одним потоком пікселів. Це дешево, довгі лінійні ланцюги — не проблема.
Модуляція й композиція коштують множно: кожен modulate*, blend, mask, src(oN) додає ще одне повне читання другої текстури на кожен піксель. Вклали modulate(noise(...).modulate(voronoi(...))) — і зовнішній прохід тягне за собою весь внутрішній підграф на кожному пікселі. Вкладена модуляція в модуляцію росте не додаванням, а добутком глибин.
Звідси головне правило вартості: дублювати важкий підграф дорожче, ніж порахувати його один раз у буфер і читати звідти. Якщо той самий шумовий шар модулює три різні гілки, не варто вписувати noise(...) тричі — його рахують у o1 раз, а гілки читають готове src(o1):
// шум рахується ОДИН раз у o1, три гілки читають готове
noise(3, 0.2).color(0.5, 0.6, 1).out(o1)
src(o0)
.modulate(src(o1), 0.2)
.add(osc(10).modulate(src(o1), 0.1))
.layer(shape(4).mask(src(o1)))
.out(o0)
render(o0)
src(o1) — це читання вже готового буфера, а не перерахунок шуму. Три читання одного буфера дешевші за три незалежні гілки noise(). На слабкому GPU (телефон) різниця між 60 і 20 fps часто саме тут.
Шини: o1/o2 як проміжні, o0 як мастер

Коли граф переростає одну гілку, буфери починають працювати як шини мікшера. Кожен oN — окрема текстура, яку можна порахувати незалежно, а тоді звести. Типовий поділ: o1 і o2 — проміжні шари (фон, передній план, шар модуляції), o0 — мастер, що їх зводить і йде на екран.
// o1 — фон, o2 — передній шар, o0 — мастер
osc(4, 0.1, 0.6).color(0.1, 0.1, 0.3).out(o1)
voronoi(8, 0.2)
.color(1, 0.2, 0.4)
.mask(shape(6, 0.4, 0.1))
.out(o2)
src(o1)
.add(src(o2))
.modulate(noise(2, 0.1), 0.03)
.out(o0)
render(o0)
Плюс поділу: кожну шину видно й правиться окремо, важкий шар рахується раз і перевикористовується. Мінус: буфери — обмежений ресурс (за замовчуванням чотири; mandflok свої три верхні o8/o9/o10 тримає під компоузер переходів, лишаючи o0..o3 скетчу), і кожна активна шина — це ще один повний рендер за кадр.
Тому ділити на буфери варто, коли шар справді перевикористовується або справді потребує окремого налагодження. Розкладати лінійний ланцюг по трьох буферах «щоб було чисто» — передчасна оптимізація: вона додає рендер-проходи, нічого не спрощуючи. Буфер виправданий там, де є розгалуження або повтор, а не там, де є просто довжина.
Аудіо-реактивність як окремий шар
Реакцію на звук чіпляють до параметрів через ту саму механіку () =>. Нативний спосіб Hydra — глобальний об'єкт a: a.fft[0], a.fft[1], … — амплітуди частотних смуг, a.setBins(n) задає кількість смуг, a.setSmooth(v) згладжує, a.show() малює діагностику. На ригу поверх цього є хелпер fft(index, buckets) (той самий у mandflok і hydra-album), а також fftLow/fftMid/fftHigh, beat і touch-канали tv('low'|'mid'|'high'|'beat').
Головне рішення — не куди чіпляти, а що саме. Звук дає два різні типи сигналу, і плутати їх не можна.
Амплітудний параметр — неперервна величина, що плавно росте й падає. Її чіпляють до неперервних параметрів: радіус, масштаб, яскравість, швидкість:
// амплітуда баса → радіус фігури, плавно
shape(4, () => 0.2 + fft(0) * 0.3, 0.05)
.color(1, 0.05, 0.15)
.modulateScale(noise(2), () => fft(2) * 0.4)
.out(o0)
render(o0)
Біт — дискретна подія, спалах у момент удару. Його логічно вішати на дискретні перемикання: зміна форми, інверсія, стрибок кута, спалах кольору. Прив'язати біт до плавного параметра — дістати смикання; прив'язати амплітуду до перемикання — дістати мерехтіння:
// біт → різке перемикання: інверсія + стрибок сегментів
osc(20, 0.05, 1)
.kaleid(() => beat > 0.5 ? 8 : 4)
.invert(() => beat > 0.5 ? 1 : 0)
.rotate(0, 0.1)
.out(o0)
render(o0)
Найчастіша каша — вішати все на один fftLow. Тоді весь кадр пульсує в унісон одним сигналом і читається як мигання лампочки. Багатший результат дає розвести смуги: бас — на великі повільні рухи (масштаб фону), середина — на середні (колір, модуляція), верх — на дрібні швидкі (зерно, дрож). Тоді картинка «чує» трек по частинах, а не як один рівень гучності.
Порядок налагодження великого графа

Коли граф на екрані «не те», вдивлятися у фінальний кадр марно — у ньому змішано всі гілки. Робочий прийом: рвати ланцюг і виводити проміжок у вільний буфер. Замість того, щоб гадати, що робить внутрішня модуляція, її виводять окремо й дивляться прямо на неї.
// що НАСПРАВДІ робить модулятор — дивимось на нього окремо
noise(4, 0.5).modulate(voronoi(3), 0.3).out(o1)
render(o1)
Побачивши модулятор окремо, повертають його назад у головний граф уже свідомо. Той самий прийом — з render() без аргумента: він показує сітку з усіх чотирьох буферів разом, тож видно кожну шину поряд. Ще один хід — тимчасово підмінити підозрілу гілку на solid(1,0,0) (суцільний колір): якщо картина ожила, проблема була в тій гілці.
Правило: діагностувати граф по частинах, не по фіналу. Розрив ланцюга у буфер коштує один зайвий рендер на час налагодження — і повертає видимість замість здогадів.
Таблиця рішень
| Що потрібно | Яка конструкція |
|---|---|
| Змінити вигляд пікселя (колір, форма, різкість) | ланцюг: .color, .kaleid, .pixelate, .thresh |
| Звести дві картинки в одну | композиція: blend / add / mult / diff / layer / mask |
| Щоб одна текстура керувала розташуванням іншої | модуляція: modulate / modulateScale / modulateRotate / modulateHue |
| Пам'ять між кадрами, слід, datamosh | зворотний зв'язок src(o0)…out(o0) із загасанням |
| Плавна зміна параметра в часі | функція-аргумент () => time * k або .smooth() на масиві |
| Дискретне перемикання (форма, число сегментів) | масив [a,b,c].fast(k) або тернар у () => |
| Той самий важкий шар у кількох гілках | порахувати в окремий буфер …out(o1), читати src(o1) |
| Розкласти вираз на 30 викликів | винести шматки в змінні/фабрики (const f = tex => …) |
| Реакція на гучність/бас (неперервна) | амплітуда fft(i) / fftLow на радіус, масштаб, яскравість |
| Реакція на удар (дискретна) | beat на інверсію, зміну форми, стрибок кута |
| Зрозуміти, що робить внутрішня гілка | вивести її окремо: …out(o1); render(o1) |
Антипатерни
- Сприймати вираз як рядок, а не граф. Тридцять крапок у ряд читаються лінійно й правляться наосліп; той самий граф з іменованих шматків правиться точково. Довжина — симптом, хвороба — відсутність декомпозиції.
- Перезбирати граф JS-циклом заради анімації. Скетч виконується раз; щоб щось рухалось, аргумент має бути
() =>або масивом, а не результатом циклу, що перебудовує вираз щокадру. - Зворотний зв'язок без загасання.
src(o0).add(...).out(o0)без множника-менше-одиниці вибухає в білий шум за секунду. Кожен прохід feedback мусить трохи втрачати. - Плутати модуляцію з композицією.
.add(noise())підмішує шум за яскравістю,.modulate(noise())жмакає простір. Це різні операції; вибирати за наміром, не навмання. - Дублювати важкий підграф замість буфера. Вписаний тричі
noise().modulate(...)рахується тричі; порахований раз уo1і прочитанийsrc(o1)— один раз. На телефоні це різниця між 60 і 20 fps. - Вішати все на один
fftLow. Весь кадр пульсує в унісон і читається як мигання лампочки. Розводити смуги: бас — великі рухи, верх — дрібні. - Ділити лінійний ланцюг на буфери «щоб чисто». Кожна шина — окремий рендер за кадр. Буфер виправданий розгалуженням чи повтором, не самою лише довжиною.
- Діагностувати по фіналу. У фінальному кадрі змішано всі гілки. Рвати ланцюг і виводити проміжок у вільний буфер, а не вдивлятись у результат.
Чекліст перед тим, як лишити великий скетч
- Вираз розкладено на іменовані шматки (змінні/фабрики), а не один рядок на 30 викликів
- Жодного JS-циклу, що перезбирає граф щокадру; анімація — через
() =>і масиви - Кожен зворотний зв'язок
src(oN)…out(oN)має загасання (множник < 1, зсув масштабу чи спад контрасту) - Важкий підграф, потрібний у кількох місцях, порахований у окремий буфер і читається через
src(oN) - Кількість активних буферів виправдана розгалуженням або повтором, а не косметикою
- Амплітудні сигнали (
fft,fftLow) чіпляються до неперервних параметрів,beat— до дискретних перемикань - Аудіо-реакція розведена по смугах, а не зав'язана на один
fftLow - Кожну неочевидну гілку перевірено окремим виводом у буфер (
…out(o1); render(o1))
ЗВОРОТНИЙ ЗВ'ЯЗОК БЕЗ ЗАГАСАННЯ
кадр 0 кадр 1 кадр 12
┌───────┐ ┌───────┐ ┌───────┐
│ ▓▒░ │ ──▶ │ ▓▓▒░ │ ──▶ │ █████ │
│ фігурка│ │ +ще яскр│ │ БІЛИЙ │
└───────┘ └───────┘ └───────┘
│ │ │
▼ ▼ ▼
src(o0).add(...).out(o0) // жодного *0.98
Граф щокадру дописує деталь і жодного разу нічого не віднімає. На дванадцятому кадрі він переконаний, що додав максимум деталі. Технічно — так. Видно лише білий прямокутник, який дуже старається.