--- title: hydra-composition desc: Як тримати довгий Hydra-вираз керованим: чотири буфери й джерела, три різні способи з'єднання, зворотний зв'язок із загасанням, декомпозиція в JS і бюджет читань текстур. locked: false order: 1 section: av featured: false --- # hydra-composition: великий вираз як рендер-граф Hydra виглядає як ланцюг крапок: `osc().rotate().color().out()`. Поки скетч короткий, це працює. Але щойно він доростає до тридцяти викликів у один рядок, приходить стеля — таке вже не читається й не правиться. Змінити одну частину означає знайти її очима серед решти й не зачепити сусідів. Це не проблема довжини рядка. Це проблема того, що ланцюг сприймають як текст, а не як **граф**. Великий Hydra-вираз — це орієнтований граф рендера. Вузли — текстури, ребра — читання одної текстури іншою, а чотири вихідні буфери `o0..o3` — точки, куди граф стікається. Ланцюг крапок — лише один із способів записати ребро, і не найважливіший. Тільки-но видно граф за синтаксисом, «нечитабельний вираз на 30 викликів» розпадається на кілька коротких, кожен зі своїм ім'ям. Ця стаття — про те, де граф різати на буфери, де модулювати, де давати зворотний зв'язок, і чим платиться за кожен рівень. Матеріал звірений з `hydra-synth` і обкатаний на власному ригу — плеєрі `hydra-album` і лайв-кодинг-редакторі mandflok, де Hydra живе в iframe із власним компоузером переходів. --- ## Обчислювальна модель: буфери, джерела, кадр ![обчислювальна модель: один кадр — один прохід GPU](https://i.ibb.co/whrLfdFk/pipeline.png) За замовчуванням 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` на весь екран. ```js osc(10, 0.1, 0.8) .out(o0) render(o0) ``` Наслідок прямий: JS-код у скетчі — це **конструктор графа**, а не тіло циклу. Він виконується один раз на кожен запуск. Усе, що має змінюватись щокадру, живе всередині графа — як функція-параметр (нижче про це окремо), а не як JS-логіка навколо. Цикл на `for`, який щось «анімує» перезбором виразу, — майже завжди помилка: він перебудовує граф замість того, щоб дати графу самому текти в часі. Зовнішнє джерело спершу ініціалізують, потім читають як текстуру через `src()`: ```js 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()` ще порожній, поки браузер віддає дозвіл і потік. --- ## Три способи з'єднати: ланцюг, композиція, модуляція ![три способи з'єднати ноди: ланцюг, композиція, модуляція](https://i.ibb.co/n8D1FDx5/topology.png) Новачок бачить в API один прийом — «чіпляти крапки» — і не помічає, що за ним ховаються три різні операції з різною семантикою. Плутанина між ними і є головним джерелом графів, які «щось роблять, але не те». **Послідовна трансформація (ланцюг).** Кожна крапка бере текстуру зліва й повертає нову. Порядок значущий: `rotate` перед `pixelate` і після нього дають різне. Це чиста композиція функцій — один вхід, один вихід. ```js 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` темнішає. ```js 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` — сітку. Це найпотужніший і найдорожчий інструмент: одна текстура керує геометрією іншої. ```js 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(o0) як пам'ять кадру](https://i.ibb.co/Z6T1ytNL/feedback.png) Граф без зворотного зв'язку не має пам'яті — кожен кадр рахується з нуля. Єдиний спосіб дати графу стан між кадрами — прочитати попередній вміст буфера через `src()` того самого буфера, у який граф пише: ```js 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` у свій пінг-понг-буфер так, щоб зворотний зв'язок читав власний бік кросфейду, а не чужий. Проблема зворотного зв'язку — він схильний **вибухати**. Якщо кожен кадр додає яскравості або масштабу без утрат, граф за секунду залипає в білий шум і більше нічого не показує: ```js // АНТИПАТЕРН: без загасання — за мить суцільний шум src(o0) .add(shape(4, 0.4).color(1, 1, 1)) .modulate(noise(3), 0.03) .out(o0) render(o0) ``` Лікується загасанням — кожен прохід має трохи **втрачати**. Множник кольору трохи менший за одиницю, крихітний зсув масштабу всередину, зниження контрасту — будь-що, що тягне сигнал донизу, поки нова деталь тягне вгору. Стабільний feedback — це завжди баланс приросту й утрати: ```js 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 викликає її щокадру: ```js 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()` керують тим, як саме: ```js 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`. Замість одного нечитабельного рядка граф збирається з іменованих шматків: ```js 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)`: ```js // шум рахується ОДИН раз у 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` як мастер ![шини: o1/o2 як проміжні, o0 як майстер-вихід](https://i.ibb.co/BFWzrGr/buffers.png) Коли граф переростає одну гілку, буфери починають працювати як шини мікшера. Кожен `oN` — окрема текстура, яку можна порахувати незалежно, а тоді звести. Типовий поділ: `o1` і `o2` — проміжні шари (фон, передній план, шар модуляції), `o0` — мастер, що їх зводить і йде на екран. ```js // 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')`. Головне рішення — не *куди* чіпляти, а *що* саме. Звук дає два різні типи сигналу, і плутати їх не можна. **Амплітудний параметр** — неперервна величина, що плавно росте й падає. Її чіпляють до неперервних параметрів: радіус, масштаб, яскравість, швидкість: ```js // амплітуда баса → радіус фігури, плавно 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) ``` **Біт** — дискретна подія, спалах у момент удару. Його логічно вішати на дискретні перемикання: зміна форми, інверсія, стрибок кута, спалах кольору. Прив'язати біт до плавного параметра — дістати смикання; прив'язати амплітуду до перемикання — дістати мерехтіння: ```js // біт → різке перемикання: інверсія + стрибок сегментів 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`. Тоді весь кадр пульсує в унісон одним сигналом і читається як мигання лампочки. Багатший результат дає розвести смуги: бас — на великі повільні рухи (масштаб фону), середина — на середні (колір, модуляція), верх — на дрібні швидкі (зерно, дрож). Тоді картинка «чує» трек по частинах, а не як один рівень гучності. --- ## Порядок налагодження великого графа ![порядок налагодження великого графа](https://i.ibb.co/gFPbgBkZ/debug-flow.png) Коли граф на екрані «не те», вдивлятися у фінальний кадр марно — у ньому змішано всі гілки. Робочий прийом: **рвати ланцюг і виводити проміжок у вільний буфер.** Замість того, щоб гадати, що робить внутрішня модуляція, її виводять окремо й дивляться прямо на неї. ```js // що НАСПРАВДІ робить модулятор — дивимось на нього окремо 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)`)
P.S. — граф, який слухає лише себе
```jd ЗВОРОТНИЙ ЗВ'ЯЗОК БЕЗ ЗАГАСАННЯ кадр 0 кадр 1 кадр 12 ┌───────┐ ┌───────┐ ┌───────┐ │ ▓▒░ │ ──▶ │ ▓▓▒░ │ ──▶ │ █████ │ │ фігурка│ │ +ще яскр│ │ БІЛИЙ │ └───────┘ └───────┘ └───────┘ │ │ │ ▼ ▼ ▼ src(o0).add(...).out(o0) // жодного *0.98 ```
Граф щокадру дописує деталь і жодного разу нічого не віднімає. На дванадцятому кадрі він переконаний, що додав максимум деталі. Технічно — так. Видно лише білий прямокутник, який дуже старається.