mandrock-files / av

hydra-composition

Як тримати довгий Hydra-вираз керованим: чотири буфери й джерела, три різні способи з'єднання, зворотний зв'язок із загасанням, декомпозиція в JS і бюджет читань текстур.

hydra-composition: великий вираз як рендер-граф

Hydra виглядає як ланцюг крапок: osc().rotate().color().out(). Поки скетч короткий, це працює. Але щойно він доростає до тридцяти викликів у один рядок, приходить стеля — таке вже не читається й не правиться. Змінити одну частину означає знайти її очима серед решти й не зачепити сусідів. Це не проблема довжини рядка. Це проблема того, що ланцюг сприймають як текст, а не як граф.

Великий Hydra-вираз — це орієнтований граф рендера. Вузли — текстури, ребра — читання одної текстури іншою, а чотири вихідні буфери o0..o3 — точки, куди граф стікається. Ланцюг крапок — лише один із способів записати ребро, і не найважливіший. Тільки-но видно граф за синтаксисом, «нечитабельний вираз на 30 викликів» розпадається на кілька коротких, кожен зі своїм ім'ям.

Ця стаття — про те, де граф різати на буфери, де модулювати, де давати зворотний зв'язок, і чим платиться за кожен рівень. Матеріал звірений з hydra-synth і обкатаний на власному ригу — плеєрі hydra-album і лайв-кодинг-редакторі mandflok, де Hydra живе в iframe із власним компоузером переходів.


Обчислювальна модель: буфери, джерела, кадр

обчислювальна модель: один кадр — один прохід GPU

За замовчуванням 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(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 як мастер

шини: 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)

Антипатерни


Чекліст перед тим, як лишити великий скетч

P.S. — граф, який слухає лише себе
       ЗВОРОТНИЙ ЗВ'ЯЗОК БЕЗ ЗАГАСАННЯ

   кадр 0        кадр 1        кадр 12
  ┌───────┐     ┌───────┐     ┌───────┐
  │ ▓▒░   │ ──▶ │ ▓▓▒░  │ ──▶ │ █████ │
  │  фігурка│    │ +ще яскр│   │ БІЛИЙ │
  └───────┘     └───────┘     └───────┘
       │             │             │
       ▼             ▼             ▼
  src(o0).add(...).out(o0)   // жодного *0.98

Граф щокадру дописує деталь і жодного разу нічого не віднімає. На дванадцятому кадрі він переконаний, що додав максимум деталі. Технічно — так. Видно лише білий прямокутник, який дуже старається.