Предисловие

Данная статья предназначена для тех, кто уже имеет некоторое интуитивное представление о VEX, для тех, кто уже видел Wrangle в сетапах или повторял туторы.

Цель статьи - формализовать интуитивное представление, объяснить базовые концепции VEX, преобладающие в контексте геометрических и вольюмных вранглов, и собственно базовые концепции самих вранглов. В статье иногда приводятся примеры в виде hpaste-ссылок, типа ofelarenor@HPaste эти ссылки содержат куски нодового графа, которые можно одним нажатием хоткея вставить в свою Houdini сцену.

Подробнее читайте в репозитории плагина.


VEX

VEX - это простой по дизайну и очень высокопроизводительный язык, используемый в Houdini для обработки геометрии и шейдинга. VEX - язык спроектированный для выполнения одного и того же кода на большом массиве входных данных. Этими данными могут быть пиксели картинки, шейдинг семплы, воксели вольюма, точки/вертексы/примитивы/и т.п. геометрии. VEX - язык процедурной парадигмы с небольшим влиянием некоторых базовых концепций ООП, синтаксисом похож на упрощённый Javascript, или очень упрощённый C. Сразу замечу, что учить C ради изучения VEX - пустая трата времени, так как эти языки очень разные, и VEX - значительно проще и более ограниченный чем С.

Язык VEX компилируемый, это значит, что перед выполнением VEX программы её проанализирует некоторая другая программа, называемая компилятором, которая на выходе выдаст преобразованную VEX программу в некоторое эффективное для выполнения представление. Это представление может и не являться машинным кодом, но в данной статье это не важно.

Базовые концепции

Инструкции

Код программы состоит из инструкций (statement), которые выполняются последовательно. Некоторые инструкции влияют на дальнейший порядок выполнения:

x = 1;
y = 2 + 3 * 4; // простые инструкции
while(1) {}
if(x == 1) y = 2; // составные инструкции
{x = 1; y = 2;} // блок инструкция

Выражения

Выражения (expression) - это составные части инструкций, имеющие некоторый результат:

2 + 3;
2 >= 5; // это выражение
// в VEX это тоже является выражением, его результат - 7,
// а побочный эффект - присвоения переменной х этого же значения 7
x = 7;
if(x = 5) {}

Эту путающую особенность VEX взял скорее всего из C, зачем - непонятно, во многих современных языках, даже в том же Python, оператор присваивания не является частью выражения, что позволяет избежать глупых ошибок типа скорее всего автор этой строки хотел проверить, равен ли х пяти и сделать что-то в этом случае, но оператор проверки равенства в VEX - двойное равно ==. Так что строка выше на самом деле присваивает переменной x значение 5 и вернет 5, от чего условие if выполнится. Ясное дело, что это мелкое недоразумение полностью ломает программу.

Переменные

Переменные (variables) в VEX - это области памяти, где хранится некоторое значение, некоторого типа. Переменные надо объявлять заранее с заданным типом, который не может быть изменён по ходу выполнения программы.

Например,

int x;
x = 42;
float f = 3.14156;
int y = 42;
string y = "hello, VEX!"; // Ошибка компиляции
// Недопустимо присваивать значения типа string - типу int,
// a так же создавать множественные объявления переменной: y

Литералы

Литералы - это константные значения, являющиеся собственно частью кода программы. 1, 3, 1942, 2.3445, 1e-4, "qwerty", {1.2,4,-1} - это всё литералы. Литералы участвуют в выражениях и инструкциях, и в целом везде. Вы можете думать о них как о значениях, создаваемых компилятором VEX в момент компиляции кода, а не на момент его выполнения, а поэтому никаких переменных и прочих выражений в литералах не может быть.

Так, что записи вида:

float a = 1;
vector v = {a, 2, 3}; // являются ошибкой, тогда как
vector v = {42, 2, 3}; // корректная инструкция

Логический тип

Логического типа в VEX нет, так что все логические выражения, типа 1 > 2, a == b, !c возвращают значение типа int, 1 - в случае истины (true),0 - в случае лжи (false). В целом же любое ненулевое значение int считается истиной (true)

Ленивая логика

В VEX, как и в очень многих языках, не только си-подобных, существует концепция ленивых логических вычислений. Суть концепции в том, что если результат логического оператора понятен после вычисления только первого из операндов - то второй операнд вычисляться и не будет. В VEX два бинарных логических оператора: && (И) и || (ИЛИ) суть их проста:

Отсюда видно, что, в случае:

На примере выражений без вызова функций не очень понятно, как важна эта концепция, в случае наличия вызовов функций в невычисляемом выражении b, естественно эти функции не будут вызваны:

int print_and_ret(int val){
  printf("val = %d\n", val);
  return val;
}
// значением y будет 1, а в консоль выведется только val = 10,
// функция print_and_ret(0) вызвана не будет
int y = print_and_ret(10) || print_and_ret(0);

Замечание: привыкшим к ленивой логике в Python - в VEX “питоновских” трюков не выйдет, логические операторы всегда возвращают int, и именно 1 в случае истины, и 0 в случае лжи.

Инструкции управление потоком выполнения

В VEX существует более-менее ожидаемый набор для управления тем, какие инструкции будут выполняться дальше. В дальнейшем описании <expression> будет означать некоторое выражение, <statement> - некоторую инструкцию.

if else

if(expression) <statement>;

В случае результата <expression> не равного нулю будет выполнена инструкция <statement> так как в VEX присутствует блок-инструкция {}, вместо одной инструкции мы можем выполнить много,

if(<expression>){<statement1>; <statement2>; .......; <statementN>}

В VEX, в отличии от Python, переносы строки, пробелы и форматирование в целом не являются частью синтаксиса, поэтому можно хоть всю программу записать в одну единую длинную строку. Но удобная и более читаемая форма всё таки использовать переносы строк и отступы для блоков, хоть это и не обязательно, например

if(<expression>){
    <statement1>;
    <statement2>;
    .......
    <statementN>
}

Расширенная версия этой инструкции:

if(<expression>) <statement1>; else <statement2>;

Если результат <expression> не равен нулю - будет выполнена инструкция <statement1>, иначе же - <statement2>

int a = 2;
if(a > 1) a++; else a *= 10;
// a инициализируется значением 2, затем вычисляется выражение a > 1, что есть истина,
// а значит результатом будет 1, что не равно нулю, а значит выполнится а++
printf("a = %d\n", a);

while

while(<expression>) <statement>;

До тех пор, пока выражение <expression> не равно нулю, будет выполнена инструкция <statement>.

Например,

while(x < 10) ++x;

Заметьте, <expression> вычисляется и проверяется каждый раз перед решением, выполнять ли <statement> или нет.

Другая форма цикла while,

do <statement>; while(<expression>)

Логика тут ровно такая же, как у обычного while, за исключением того, что <statement> выполнится один дополнительный раз в самом начале, до проверки условия <expression>.

for

for(<expr_init>; <expr_cond>; <expr_inc>) <statement>;

Логика этой инструкции следующая:

  1. выполнить выражение <expr_init> (результат не важен). В данном случае вместо обычного выражение может быть использовано объявление переменной. В таком случае область видимости объявленных переменных ограничено областью видимости <statement>
  2. вычислить <expr_cond>, если оно неравно нулю, то выполнить <statement>, иначе - закончить вычисление данной инструкции, перейти к следующей
  3. вычислить выражение <expr_inc>, результат отбросить, и вернуться к пункту 2.

Классический пример цикла for:

for(int i = 0; i < 10; i++) printf("iter %d\n", i);

Шаги выполнения следующие:

  1. объявить переменную i равную нулю
  2. проверить, i < 10, если нет - всё, цикл окончен, если да - то выполняем <statement> printf("iter %d\n", i};
  3. выполняем i++, возвращаемся к 2 пустое выражение/инструкция - тоже вполне допустимы
int i = 0;
for(; i < 10;) printf("iter %d\n",i); i++;

и даже

for(;;) printf("run forever\n");

Всё это вполне легальные инструкции.

foreach

Цикл foreach имеет несколько форм в VEX:

foreach(<variable>; <array_expression>) <statement>;

и

foreach(<int_variable>; <variable>; <array_expression>) <statement>;

В этих циклах вычисляется выражение <array_expression>, результатом которого должен быть массив некоторого типа, являющегося типом переменной <variable> далее переменной <variable> будет присваиваться каждый элемент полученного массива по очереди и исполняться <statement> в случае версии foreach с <int_variable> - так же переменной <int_variable> будет присвоен номер текущего элемента массива.

То есть следующие конструкции делают одно и то же:

int numbers[] = {2, 9, 3, -6, 9, 1, 0, 3, 0, -5};

foreach(int i; int n; numbers){
  printf("iter %d, number %d\n", i, n);
}

for(int i = 0; i < len(numbers); i++){
  int n = numbers[i];
  printf("iter %d, number %d\n", i, n);
}

break/continue

Ключевые слова break и continue могут использоваться внутри любого из описанных выше циклов: while, do..while, for, foreach.

Инструкция break внутри блок-инструкции цикла - немедленно прекращает выполнение цикла и переходит к следующей инструкции после цикла инструкция continue внутри блок-инструкции цикла немедленно заканчивает выполнение текущей итерации цикла и переходит к следующей, соблюдая все правила цикла, в котором она находится.

Например,

for(int i = 0; i < 100; ++i){
  printf("iteration %d\n);
  if(i == 25) break;
  printf("going\n");
}
printf("for finished");

Совершит 25 итераций, выдаст по 2 строки на каждую итерацию, затем на 26-ой итерации выдаст iteration 25 и выйдет из цикла к следующей инструкции printf, и выдаст в консоль: for finished.

Другой пример,

for(int i = 99; i >= 0; i--){
  if(i % 2 != 0) continue;
  printf("%d is even\n", i);
}

В этом примере i % 2 возвращает остаток от целочисленного деления i на2, который мы проверяем на неравенство нулю. Если остаток от деления на 2 не равен нулю - инструкция continue выполняется и блок-инструкция цикла немедленно заканчивается, и цикл переходит к следующей итерации.

В выводе вы увидите 98 is even 96 is even 94 is even .... и прочие чётные числа до нуля включительно вне циклов использовать инструкции break и continue - ошибка.

return

Инструкция return <expression> используется внутри тела определяемой функции, чтобы немедленно закончить выполнение функции и вернуть результат вычисления выражения <expression> как результат. Так же в теле вранглов инструкция return без выражения используется для немедленного прекращения вычисления врангла для текущего элемента.

Операторы , ;

Литерал точка с запятой ; используется в VEX для разделения инструкций.

a = b * 2 + 12; c = 1 + 2; float d = foo(a);

Как мы уже знаем, пробелы, табы и переносы строк не являются разделителями инструкций, так что это:

a = b * 2;

так же верно, как и это:

a
=
b
*
2
;

Исключением тут являются блок-стейтменты, границы которого заранее чётко определены, так что точка с запятой после него не требуется.

Литерал запятая , - это оператор, такой же как + - * и т.д. <expression1>, <expression2> этот оператор вычисляет выражение <expression1>, затем вычисляет выражение <expression2> результат вычисления <expression2> становится результатом оператора. Например,

a = (4 + 9, 2); // значением в a будет 2

Одна из классических ошибок, особенно для тех, кто привык к инициализации vector из OpenCL, это писать следующее:

vector vec = (vector)(1, 2, 3);  // неожиданный результат

или

vector vec = (1, 2, 3);  // неожиданный результат

В обоих случаях компилятор не выдаст даже предупреждения, но что на самом деле произойдет:

Приоритет операторов

Важно помнить, что операторы в VEX, как и в большинстве других языков, имеют строго определённый приоритет. Подробную таблицу приоритета смотрите в документации.

Парсинг любого выражения происходит с учётом этих приоритетов.

Например,

a = 1 + 2 + 3;

Три оператора: два + и =, смотрим по таблице: у оператора + приоритет 15, у = - 2, смотрим порядок - LtR (left to right), слева на право, значит первым исполнится 1 + 2, затем к результату прибавится 3, затем отработает оператор =, в итоге:

1 + 2 -> 3
3 + 3 -> 6
a = 6
Тернарный оператор условия

Оператор <expr1> ? <expr2> : <expr3> несколько особый в плане приоритета. Напомню, что действует он так:

  1. вычисляется выражение <expr1>
  2. если результат неравен нулю, то результатом оператора является результат вычисления <expr2>
  3. иначе, результатом оператора является результат вычисления <expr3>

Т.е. из <expr2> и <expr3> будет вычислено только одно из выражений в связи со структурой оператора, приоритет операторов между ? и : не важен, всё это будет как бы сгруппировано в скобки. В целом проще думать о тернарном операторе, расставив скобки следующим образом:

Например,

x = 1 + 0 ? 2 * 8 : 8 - 18;

слева только у оператора = приоритет меньше

x = (1 + 0) ? 2 * 8 : 8 - 18;

справа всё попадает в скобки

x = (1 + 0) ? 2 * 8 : (8 - 18);

ну и всё между ? и :

x = (1 + 0) ? (2 * 8) : (8 - 18);

Итого: 1 + 0 не равен нулю, так что будет вычислено 2 * 8, результат 16 будет записан в x для вложенных тернарных операторов логика та же, начиная с самого внешнего оператора. Искать соответствие между ? и : можно по той же логике, что и между открывающей и закрывающей скобкой,

x = 1 + 0 ? 2 - 2 ? 3 * 9 : 2 * 8 : 2 + 2 ? 19 + 2 : 8 - 18;
x = (1 + 0) ? (2 - 2 ? 3 * 9 : 2 * 8) : (2 + 2 ? 19 + 2 : 8 - 18);
x = (1 + 0) ? ((2 - 2) ? (3 * 9) : (2 * 8)) : ((2 + 2) ? (19 + 2) : (8 - 18));

В итоге посчитается 1 + 0, не равен нулю, значит будет вычислено ((2 - 2) ? (3 * 9) : (2 * 8)) 2 - 2 равно нулю, значит результатом будет 2 * 8, и опять 16 будет записано в x

Теперь более сложный пример, с учётом приоритетов прочих операторов:

int a = 1, b = 2, c = 3;
a += b += c += 2 * 4 ? a * 2 < b ? b + 3 :(3 & b * c) : a;
printf("a = %d, b = %d, c = %d\n", a, b, c);

сначала разберемся с тернарными условными операторами

a += b += c += (2 * 4) ? (a * 2 < b ? b + 3 :(3 & b * c)) : (a);
a += b += c += (2 * 4) ? ((a * 2 < b) ? (b + 3) : (3 & b * c)) : (a);

итак, сначала вычисляется условие условного оператора

a += b += c += (2 * 4) ? ((a * 2 < b) ? (b + 3) : (3 & b * c)) : (a);
a += b += c += 8 ? ((a * 2 < b) ? (b + 3) : (3 & b * c)) : (a);

8 не равно нулю, так что результатом самого внешнего тернарного оператора будет результат вычисления первого из его выражения

a += b += c += (a * 2 < b) ? (b + 3) : (3 & b * с);

это опять тернарный оператор, вычисляем его условие

a += b += c+= (a * 2 < b) ? (b + 3) : (3 & b * с);

по таблице приоритетов сначала выполняется умножение

a += b += c += (a * 2 < b) ? (b + 3) : (3 & b * c);  // a = 1, b = 2, c = 3
a += b += c += (2 < b) ? (b + 3) : (3 & b * c);

далее <

a + =b += c += (2 < b) ? (b + 3) : (3 & b * c);  // a = 1, b = 2, c = 3
a += b += c += (0) ? (b + 3) : (3 & b * c);

ноль равен нулю, так что у тернарного условного оператора вычисляем и возвращаем результат последнего выражения

a += b += c += 3 && b * с;

приоритет умножения выше, так что

a += b += c += 3 & b * c;  // a = 1, b = 2, c = 3
a += b += c += 3 & 6;

теперь побитовое И

a += b += c += 3 & 6; // 3 & 6 -> 11 & 110 = 10 = 2
a += b += c += 2;

теперь операторы += выполняются RtL - справа налево

a += b += c += 2;  // a = 1, b = 2, c = 3
a += b += 5;       // a = 1, b = 2, c = 5
a += b += 5;       // a = 1, b = 2, c = 5
a += 7;            // a = 1, b = 7, c = 5
a += 7;            // a = 1, b = 7, c = 5
8;                 // a = 8, b = 7, c = 5

В итоге a = 8, b = 7, c = 5.

Типизация

В VEX существует набор встроенных типов данных: string, int, float, vector2, vector3, vector4, matrix2, matrix3, matrix и массивы из них и прочих типов, (плюс набор типов данных для шейдинга, который мы пока пропустим). Набор типов данных может быть расширен как с помощью HDK, так и определением новых структур в VEX коде.

Преобразование типов

Как уже говорилось, VEX - язык явной слабой статической типизации:

В теории языков программирования типизацию характеризуют как слабую/сильную, в зависимости от того, насколько компилятор фривольно может неявно преобразовывать типы в случае необходимости. Преобразование типа переменной на английском называется cast, в таком виде оно и переехало в русский жаргон - каст. Так вот VEX умеет неявно преобразовывать между некоторыми из своих стандартных типов, что часто может вызывать недоразумения у неопытных программистов. Однако компилятор VEX таки всегда выдаёт предупреждение при неявном преобразовании, что облегчает поиск проблем. Например, float a = {2, 3, 4}; - это корректное выражение. vector {2, 3, 4} будет неявно преобразован во float, и результат присвоен в переменную a. (“каст” vector во float в VEX - это просто взятие первой компоненты vector, так что значение в a будет 2)

Все становится немного сложнее, когда выражения разных типов встречаются в арифметическом операторе. Например,

vector v3;
vector4 v4;
float a = v3 + v4;

Что произойдет тут?

Компилятор VEX выберет и нужные преобразования и добавит их в итоговую программу, это и называется неявным преобразованием, но что преобразуется во что?

В данном случае v3 будет преобразована к типу vector4, затем произойдет сложение двух vector4, затем результат будет преобразован во float и присвоен в a почему именно v3 преобразуется к типу vector4, а не v4 преобразуется к типу vector?

Это зашито в компилятор. При выборе преобразований VEX-компилятор, в отличии от C и подобных языков, учитывает и операнды, и ожидаемый тип результата (так что об арифметических операторах можно думать как о вызовах функции типа type1 add(type2 a; type3 b), и смотреть далее про перегрузку функций.) это делает муторным, да и нужным ли вообще, делать описание всех преобразований между всеми стандартными типами, вместо этого проще опираться на эмпирическое правило, что типы преобразуются с наименьшей потерей. Преобразовать vector в vector4 - никаких данных не потеряется, а преобразовать vector4 в vector - для этого придется отбросить одну значимую компоненту.

Строки (string) и массивы никак неявно и явно не преобразуются, для работы с ними существуют отдельные наборы функций.

Если компилятор решает добавить операцию преобразования типа, он выдаст предупреждение, отображаемое на ноде. Чтобы избавиться от предупреждения компилятора, и для бОльшей наглядности кода - лучше переписать выражение float a = {2, 3, 4}; выше как float a = (float){2, 3, 4}; или float a = float({2, 3, 4}); (это выражение приведено исключительно для наглядного примера, глубокого смысла оно не несёт)

Выражение типа (type)expression - это классический каст выражения в тип в C-подобных языках. Такое преобразование подразумевает некоторые реальные действия в ходе выполнения программы, т.е. такое преобразование - это операция. Выражение типа type(expression) - это выражение, синтаксически похожее на классический конструктор каст для типа, но на самом деле в VEX это немножко другое, к этому мы вернемся чуть позже. Если не хотите читать про перегрузку функций и т.д просто всегда используйте этот тип преобразования и забудьте про первый.

Перегрузка функций

Перед этим поговорим о перегрузке функций. В VEX существует концепция перегрузки функций, как и во многих процедурных языках. Это позволяет нам иметь несколько функций с одинаковым именем, но разными сигнатурами (сигнатурой функции называется её имя, набор типов её аргументов и тип результата).

Например, функция rand(...) - это на самом деле набор функций с одинаковым именем и разными типами входных (и выходных) аргументов). Ради субъективного удобства мы отдаём компилятору задачу разобраться, какую именно функцию мы хотим вызвать. Таким образом мы можем писать rand(42), rand(2.34), rand({1, 2, 3}), тогда как без концепции перегрузки нам бы пришлось иметь разные имена функций для разных типов аргументов, например, rand_i(42), rand_f(2.34), rand_v({1, 2, 3}).

Кроме того, в отличии от C/C++, VEX так же умеет перегружать функции по типу возвращаемого значения. Тот же rand(2.34) может вернуть как значение типа float, так и типа vector, в зависимости от того, где в коде оно находится, и что решит компилятор.

Учитывая, что компилятор умеет преобразовывать типы аргументов, и еще и подбирать одну из перегруженных функций, подходящую по типу как аргументов, так и возвращаемого значения - можно представить, какая это замороченная задача с неоднозначным решением…

И в общем случае так и есть - представьте простейший гипотетический пример,

float foo(float a; vector b) {...}
float foo(vector a; float b) {...}
float a = foo(1.1, 2.2);

Компилятор будет думать, то ли вызвать первую функцию, и “кастовать” 2.2 в vector, то ли вызвать вторую функцию и “кастовать” 1.1 в vector… и выдаст ошибку, говорящую Ambiguous call.

В отличии от C/C++, в которых перегрузки по возвращаемому значению нет, VEX компилятору приходится анализировать цепочки функций в выражении целиком, выбирая подходящие комбинации. На самом деле комбинация из перегрузки функций по возвращаемому значению и нечётко описанного неявного преобразования типов создают довольно сильно запутанные ситуации в случаях чуть более сложных, чем тривиальные.

Так что я считаю бессмысленно описывать тут чёткую логику, так как она выходит длинной, запутанной, и даже не до конца продуманной, и почти не требуемой на практике.

Важно помнить ряд свойств:

Вернёмся к операторам преобразования типа.

Как уже было сказано, оператор (type)x - это по сути вызов функции type convert_to_type(<any> x), то есть (type)x превращается в реальные инструкции, выполняющиеся во время выполнения программы.

Конструкция type(x) в свою очередь была введена для подсказки компилятору, какую из перегруженных по возвращаемому значению функций выбрать в момент компиляции.

int x = int(rand(1.23)) - здесь int(...) подсказывает компилятору на момент компиляции, какую функцию из набора перегруженных rand функций выбрать, конкретно выбрать те, которые возвращают int, среди них уже разбираться, что выбрать согласно входным аргументам, который тут 1.23 - float.

Еще раз, конструкция int(some_function(...)) выше учитывается только на момент компиляции, и не создаёт никаких дополнительных инструкций для выполнения в самой программе. (int) - это по сути вызов функции конвертации, и оно как раз создаёт дополнительные инструкции для выполнения в самой программе.

Однако заметьте, что эти два преобразования не взаимозаменяемы, вполне легально записать float(rand(1.23)) и совсем не легально записать (float)rand(1.23).

Почему?

Как вы уже поняли из описания выше, о второй записи можно думать как о вызове функции, типа convert_to_float(rand(0.23)) , и эта функция convert_to_float перегружена по входному аргументу, она умеет конвертить во float и float и int, и vector, и т.д., так что компилятор натыкается на проблему множественных точных соответствий типов выбираемых функций, и выдаёт ошибку. Кроме того, чтобы избавить пользователя от мыслей о разных типах каста, VEX будет интерпретировать запись вида type(x) как (type)x, если x - какое-то выражение и не вызов функции.

Таким образом вы можете смело использовать только форму type(...), и оставлять компилятору раздумья о том, надо ли выполнять явное преобразование, или просто выбрать подходящую функцию.


Массивы

Массивы в VEX - это структуры, позволяющие хранить множество значений одного заранее определённого типа данных.

float x[]; // объявляет переменную х, являющуюся массивом для типа данных float.

Как уже говорилось, все элементы массива имеют один и тот же заранее определённый тип, который в VEX может быть любым из имеющихся типов данных, в том числе и структур, кроме другого массива.

Размером/длиной массива называется число элементов в нём. Длину массива можно изменять набором функций, типа resize, insert, append, remove, push, pop, и т.д, для получения длины массива есть функция len.

Доступ к элементам массива возможен с помощью индексирования:

// индексы начинаются с нуля, а не с единицы
x[1] = 2;
x[2] = x[1] + x[0];

Инициализировать массив можно с помощью литералов в фигурных скобках, так же как и вектор, только элементами будут литералы типа данных массива.

float x[] = {1, 2, 3, 4, 5};
vector y[] = {\{1, 2, 3}, {2, 3.5, 4}};
matrix2 z[] = {\{1, 2, 3, 4}, {2, 3, 4, 5}};

или с помощью функции array

float a = 3, b = 4;
float x[] = array(a, b, 2);

Заметьте, что несмотря на такое объявление, для преобразования типов и задания массивов в объявлениях функций используется запись <type>[]. Например,

int[](nearpoints(...));
string[] get_some_strings(string a, float[] foos);
// функции, возвращающие массив без указания ключевого слова function перед именем функции,
// по каким-то причинам внутри текста врангла определять нельзя, только во внешнем инклюд файле

Массивы в VEX поддерживают “слайсинг”, очень похожий на “питоновский”,

int x[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
printf("%f\n", x[5:7]);   // напечатает {6, 7}
printf("%f\n", x[3::2]);  // напечатает {4, 6, 8, 10}

т.е. <array_var>[start:end:step] (не забывайте, что элемент с индексом end не включается) и да, printf отлично печатает массивы (и вектора)

Для удобства можно использовать отрицательный индекс, чтобы индексировать массив с конца. Запись arr[-3] эквивалентна записи arr[len(arr) - 3].

int arr[] = {1, 2, 3, 4, 5};
printf("%f == %f\n", arr[-2], arr[len(arr) - 2]); // выведет: 4 == 4

Однако заметьте, что “враппинга” индекса не происходит, а значит индексирование отрицательным значением, меньшим чем минус длина массива, будет индексированием вне границ массива. Чтение оттуда вернёт значение по умолчанию типа данных массива, запись туда не приведёт ни к чему (в отличии от записи вне границ массива с положительным индексом) Отрицательный индекс также можно использовать и в “слайсинге”.

Индексирование массива - очень быстрая операция, можно не стесняясь ей пользоваться. Изменение размера массива - медленная операция, старайтесь оптимизировать свой код, чтобы как можно реже изменять размер массива.

VEX прощает ошибки в работе с массивами, что может привести к серьезным потерям производительности. Например, индексацию за пределами массива,

vector arr[] = {\{1, 2, 3}, {2, 3, 4}, {3, 4, 5}};
printf("%f\n", arr[24]);  // выдаст {0, 0, 0}, хотя элемента с индексом 24 в массиве нет
arr[5] = {-1, -3, -5};  // в этом месте VEX увеличит массив до размера в 6 элементов
printf(%f\n, arr);  // выдаст {\{1, 2, 3}, {2, 3, 4}, {3, 4, 5}, {0, 0, 0}, {0, 0, 0}, {-1, -3, -5}}

т.е при индексации на чтении за пределами массива будет возвращён дефолтный/нулевой элемент для типа данных массива при индексации на запись за пределами массива - массив будет расширен, чтобы включать требуемый индекс учитывая всё вышесказанное, следующий код будет крайне неоптимален,

int arr[];
for(int i = 0; i < 1000; ++i){
  arr[i] = i * 2;  // массив будет расширен неявно для каждого нового значения i
}

при этом исправить его очень просто, добавив единовременный “ресайз” массива до нужной длины:

int arr[];
resize(arr, 1000);
for(int i = 0; i < 1000; ++i){
  arr[i] = i * 2;
}

Многомерных массивов в VEX не существуют.


Словарь

Совсем недавно (относительно момента написания статьи), с версии Houdini 18.5, в VEX так же появился тип словарь (dict), схожий по логике с словарём из Python. Для незнакомых с концепцией словарей, можете думать о нём в VEX как о массиве, у которого индексы не целые числа, а строки, а элементы могут быть перемешанных типов. Индекс в таком представлении называется ключом. т.е. словарь - это соответствие ключа некоторому значению.

Знакомым с типом dict в питоне поспешу огорчить:

В документации есть отдельная статья, посвященная словарю.

Словари - не исключение для статической типизации - типа значения словаря для каждого используемого ключа в VEX должен быть понятен компилятору на момент компиляции. Логика точно такая же, как и перегруженных по возвращаемому значению функций чтения геометрии (point, prim, primuv и т.д). Так что можно думать о словаре, как о функции, возвращающей значение некоторого типа по переданному ей строковому аргументу, так же как функция point возвращает значение некоторого фиксированного, но неизвестного компилятору типа по имени атрибута.

dict d = {};  // литерал {} означает пустой словарь
d["one"] = 1;
d["dog"] = "bark";
d["cat"] = {"m", "e", "o", "w"};
d["key0"] = d["one"];  // ошибка, компилятор не может однозначно определить тип d["key0"] и d["one"]
d["key0"] = int(d["one"]);
// как и в случае с функциями, конструкция type(...) подсказывает компилятору,
// какую из перегруженных функций чтения словаря использовать

Функции

Существуют 2 типа функций в VEX:

Функции, определенные в VEX, на самом деле не являются функциями в классическом понимании. Знакомым с си будет понятнее думать о функциях в VEX как о макроподстановках, со всеми вытекающими последствиями, т.е. функции в VEX на самом деле не будут вызываться в ходе выполнения кода, вместо этого компилятор подставит тело функции прямо в код программы, конечно правильным образом переименовывая переменные.

Из этого следует следующее:

Несмотря на это, определенные в VEX функции являются отличным вспомогательным инструментом, и не стоит ими пренебрегать для структуризации своего кода.

Houdini поддерживает два синтаксиса для определения функций:

C-подобный,

return_type function_name(arg0_type arg0; arg1_type arg1; ...; argN_type argN) {<body>}

Например,

int surprise(float x; vector v){
  return floor(x + v.y);
}

или Javascript-подобный,

function return_type function_name(arg0_type arg0; arg1_type arg1; ...; argN_type argN) {<body>}

Например,

function int surprise(float x; vector v){
  return floor(x + v.y);
}

Да, вся разница только в слове function в начале определения, заметьте, что аргументы разных типов разделяются точкой с запятой, вместо простой запятой, а элементы одного типа можно группировать через запятую, например:

float sub(float x, y){
  return x - y;
}

и еще заметьте, что перегрузка встроенных и прочих загружаемых функций VEX таким образом возможна, например, можно определить функцию

float xyzdist(float a; string s){
  return a - len(s);
}

и теперь компилятор будет учитывать этот вариант функции при встрече функции xyzdist в коде и решении о выборе одной из перегруженных функций. И не важно, что делает она что-то странное.

Определить же функцию с уже существующей сигнатурой - не удастся.

float xyzdist(int x; vector a) {} // выдаст ошибку компиляции

Возврат массива из врангла возможен только при явном указании ключевого слова function перед именем функции:

int[] foo() { return {}; }          // Ошибка компиляции
function int[] foo() { return {}; } // OK

Область видимости

Переменные, определенные в VEX коде, имеют строго определенную область видимости. Вне этой области обратиться к этой переменной невозможно. В случае вранглов, все переменные, определенные вне каких-либо блок-инструкций имеют областью видимости весь “врангл” переменные, определенные внутри какой-либо блок-инструкции имеют областью видимости только этот (и, естественно, вложенные) блок т.е.,

{
  int a = 1;
}
printf("%f\n", a);

будет ошибкой компиляции, так как вне блока переменная a не существует

Перекрытие областей видимости

Если во вложенном блоке объявлена переменная с именем, совпадающим с именем переменной, уже присутствующей в текущей области видимости, то новое объявление временно перекроет (shadowing) видимость старой переменной. Обратиться к старой переменной во вложенном блоке станет невозможно, VEX компилятор выдаст предупреждение. Однако после окончания области видимости новой переменной, старая снова станет видна. Например,

int x = 1;
printf("a %f\n", x);
{
  vector x = {2, 3, 4};  // переменная х во внешнем блоке перекрывается этой
  printf("b %f\n", x);
  if(1){
    float x = 4.2;  // перекрывает вектор х из внешнего блока
    printf("c %f\n", x);
  }
  printf("d %f\n", x);  // вектор х снова виден
}
printf("e %f\n", x);  // и снова виден х

выведет в консоль следующее:

a 1
b {2.000000, 3.000000, 4.000000}
c 4.200000
d {2.000000, 3.000000, 4.000000}
e 1

Кратко о внешних файлах

Часто используемые функции и структур можно хранить в отдельных файлах, по традиции из языка C, этим файлам обычно дают расширение .h , сокращённо от header, но это в принципе не обязательно.

Эти файлы Houdini будет искать по путям в cписке HOUDINI_VEX_PATH в подпапке include. Использовать эти файлы в своём врангле можно с помощью директивы компилятору #include "...", например, если я создал файл useful_shit.h в папке $HIP/vex/include, то в сцене во врангле, в самом его начале, я могу написать #include "useful_shit.h", и использовать функции и структуры из него.

Директивы компилятору, типа #include - это указания компилятору на момент компиляции, это не инструкции программы. Данная директива просто говорит компилятору - “используй определения функций и структур из этого файла” несмотря на свою похожесть на директиву #include из C, директива в VEX работает не совсем так, поэтому не стоит читать документацию по C, по этой директиве. В VEX в .h файле не может быть ничего, кроме определения функций и структур, и мы не можем просто вставить содержание include файла во врангл.

Начиная с Houdini 20.5 стало возможным объявлять структуры в теле “врангла” используя блок из парных директив outer и endouter. Смотри пример в разделе структуры.


Структуры

Аналогично языку C, в VEX можно группировать несколько переменных в один контейнер, новый тип данных, называемый структурой.

Как было отмечено выше, начиная с Houdini 20.5 стало возможным определять структуры не только во внешнем файле .h с помощью директивы #include, но и так же “на месте” в теле врангла. Для этого достаточно заключить саму структуры в блок директив.

Например,

#outer // {
       // Этот код будет размещен за пределами сгенерированной функции
struct edge{
  int pt0, pt1;
  vector direction;
}
#endouter // }
          // Этот код будет находиться в функции.

тогда в коде врангла мы можем объявить переменную нового типа edge

edge x;

и теперь мы можем обращаться к переменным внутри x через точку, например,

x.pt0 = 123;
x.direction = {1, 2, 3};

переменные внутри структуры называются элементами или полями (members) этой структуры.

В VEX элементы структуры могут иметь дефолтные значения, заданные в определении структуры

struct edge{
  int pt0 = 123, pt1 = -1;
  vector direction = {2, 1, 3};
  string foo;
}

тогда

edge x;
printf("%d\n", x.pt0);

в консоль выведет результат: 123

Все элементы переменной x будут инициализированы согласно определенным у них дефолтным значениям. Элементы без явно указанных дефолтных значения будут инициализированы глобальным дефолтным значением для данного типа данных. У всех скалярных, векторных, матричных типов это значения из всех нулей, для строк - пустая строка.

Мы можем также задавать элементы x вручную.

Например,

edge x = edge(1, 2, {3, 4, 5}, "qqq");

или, что для литералов равнозначно

edge x = {1, 2, {3, 4, 5}, "qqq"};

в случае такой инициализации мы обязаны указать инициализационные значения для всех элементов структуры, в том же порядке, в котором элементы определены в определении, так что,

printf("%f %f %f %s\n", x.pt0, x.pt1, x.direction, x.foo);

в консоль выведет результат: 1 2 {3.000000,4.000000,5.000000} qqq

Я упомянул, что для инициализации структуры таким образом

edge x = {1, 2, {3, 4, 5}, "qqq"};

можно использовать литералы, так и есть, точно так же, как в литералах массивов или векторов, здесь в фигурных скобках могут быть только другие литералы.

Так что подобное не выйдет

int a = 1;
edge x = {a, 2, {3, 4, 5}, "qqq"};

зато выйдет через вызов конструктора

int a = 1;
edge x = edge(a, 2, {3, 4, 5}, "qqq");

функции, привязанные к структурам

VEX даёт некоторые ограниченные возможности определения функционала структуры в самой структуре.

Например для структуры,

struct complex{
  float r, i;
}

можно определить внешнюю функцию

complex conjugated(complex c) { return complex(c.r, -c.i); }

соответственно вызывали бы мы её так

complex c = {10, 2};
c = conjugated(с);

но если функция тесно связана со структурой - мы можем определить её внутри самой структуры

struct complex{
  float r, i;
  complex conjugated(){
    complex ret;
    ret.r = r;
    ret.i = -this.i;
    return ret;
  }
}

функция conjugated определена внутри структуры complex, и теперь её можно вызывать вот так

complex c = {10, 2};
c = c->conjugated();

т.е. мы обращаемся к функции, определенной внутри структуры, как к элементу структуры, только с символами -> вместо символа точка, (замечу, что хоть это и напоминает синтаксис обращения к элементу с разыменование в C, в VEX же всё ещё никаких указателей нет, это просто такой синтаксис) заметьте, что теперь мы не подаем никаких аргументов функции, вместо этого функция выполняется на переменной c, элементом типа которого является эта функция.

Теперь обратите внимание на определение функции conjugated внутри структуры. Функция не принимает явно аргумент типа complex, но неявным образом он передаётся в функцию через переменную this т.е определение выше эквивалентно определению функции не в структуре с сигнатурой complex conjugated(complex this).

Таким образом мы можем обращаться к элементам переменной типа complex, для который выполняется функция conjugated, через this.i и this.r. Более того, для удобства и чистоты кода можно упустить this и просто обращаться к переменным r и i в примере выше мы обращаемся к r без this, и обращаемся к i как к this.i.

К сожалению, внутри такой функции, определенной в структуре, мы не можем обращаться к конструктору структуры, так как формально структура еще не определена в глазах компилятора на момент анализа функции. Это больше баг, чем фича, но уж как есть.

Т.е. сделать так - это ошибка,

struct complex{
  float r, i;
  complex conjugated(){
    return complex(r, -i);
  }
}

хоть это и глупо, но да, VEX считает структуру complex неопределенной на момент вызова complex(r, -i) чтобы обойти это ограничение приходится выкручиваться, как в начальном примере,

complex conjugated(){
  complex ret;
  ret.r = r;
  ret.i = -this.i;
  return ret;
}

Важно помнить, что хоть всё это и удобно - в VEX нету полноценной инкапсуляции с изоляцией имён и функция conjugated будет доступна и для обычного вызова, с первым аргументом - переменной типа complex т.е

c = c->conjugated();

эквивалентно

c = conjugated(c);

точно так же и функцию, определенную вне структуры

complex conjugated(complex c){ return complex(c.r, -c.i); }

можно вызвать как классически

c = conjugated(c);

так и

c = c->conjugated();

т.е. все эти функции внутри структур - это ни что более как так называемый синтаксический сахар, т.е более красивый способ записи того же самого.


Геометрические вранглы

Поговорим об особенностях VEX в контексте геометрических вранглов в SOP и DOP контекстах: attribute wrangle, point/primitive/vertex wrangle, geometry wrangle, pop wrangle и т.д.

Код врангла выполняется для КАЖДОГО элемента из выбранной группы входной геометрии. Для SOP вранглов входная геометрия, очевидно - геометрия, приходящая в первый вход ноды врангла. Для DOP вранглов входная геометрия - геометрические или вольюм данные доп объекта, задающиеся по имени в параметре data binding.

Начнем с геометрических вранглов attribute/point/primitive/etc wrangle:

Замечу, рёбер (edge) в этом списке нет, потому что рёбер как носителей атрибутов в Houdini не существует. (Однако врангл можно запустить в режиме numbers по общему числу халфэджей, и рассматривать @elemnum как номер хэджа. Но никаких биндингов атрибутов в этом случае доступно не будет, пример: fitaponivi@HPaste.

На всякий случай поясню: в Houdini геометрия строится из 3-х типов элементов: точки (points), вертексы (vertices), примитивы (primitives) (это не только полигоны, но и что угодно, крепящееся к точкам) подробнее в документации. Все эти типы элементов, плюс вся геометрия в целом, могут иметь разнообразные атрибуты. Если атрибут существует на определенном типе элементов - все элементы этого типа имеют какое-то значение этого атрибута, даже если по задумке только несколько элементов должны иметь какие-то полезные значения. Не может быть так, например, что часть точек имеют атрибут цвета (Cd), а часть нет.

Итак, VEX-код вызывается для всех элементов, например для всех точек, как теперь получать и задавать значения атрибутов каждой обрабатываемой точки?

Houdini умеет привязывать VEX-код к атрибутам каждого обрабатываемого элемента (к атрибутам точек, примитивов, вертексов, или всей геометрии). Привязка обрабатываемых элементов к VEX-коду называется биндингом (binding) например переменные в VEX-коде, имеющие префикс с символом @ - обращаются к биндингу с тем же именем, что и переменная. По умолчанию вранглы “биндят” все атрибуты к их же именам, так что можно говорить о переменных с префиксом @ как просто об атрибутах, для простоты.

То есть эти переменные будут автоматически объявлены и выставлены на значение атрибута с именем данной переменной текущего обрабатываемого элемента ПЕРЕД выполнением VEX-кода. Переменная @attrib будет выставлена в значение атрибута attrib для каждой из точек, перед выполнением VEX-кода. А ПОСЛЕ выполнения VEX-кода для данного элемента значение переменной с @ будет записано обратно в атрибут выходной геометрии.

Например, “поточечный” врангл @attrib += 1 будет выполнен для каждой точки. Перед выполнением этого врангла, например, для точки 0 float переменная attrib будет выставлена в значение атрибута attrib точки 0, пусть, например, это будет 3.2. После отработки врангла в переменной @attrib останется значение 4.2, которое и будет задано как новое значение атрибута attrib на ВЫХОДНОЙ геометрии. То же самое для точки 1, 2, 3 и т.д.

Важно различать ВХОДНУЮ и ВЫХОДНУЮ геометрию. Всё, что мы в VEX читаем - мы читаем с ВХОДНОЙ геометрии. Всё что изменяем, создаём, удаляем - происходит с ВЫХОДНОЙ геометрией, т.е. изменения сделанные при выполнении врангла для одной точки мы никак не сможем получить в обработке следующей точки. Это особенность дизайна языка: код выполняется параллельно и многопоточно на массивном наборе данных, но эти выполнения никак друг с другом не могут взаимодействовать. С одной стороны это ограничивает пользователя, отнимает целую группу конструкций синхронизации. С другой - значительно понижает порог вхождения в VEX, исключает целый ряд ошибок, которые постоянно будут допускать средние ТД/артисты, не знакомые с подобной парадигмой и в целом ускоряет выполнение кода.

Важно понимать, что VEX - язык с явной статической типизацией, а значит компилятору нужно явно указывать все типы переменных. Например, int a;. То же самое касается и “биндящихся” переменных (атрибутов), но если они определены ДО нашего кода - как же мы их типизируем? Тип атрибутов задаётся одним из фиксированного набора префиксов перед символом @.

Если префикса нет - тип подразумевается float. Если вы будете использовать разные префиксы для одного и того же атрибута - VEX компилятор прочтёт первый префикс по тексту кода (в if он, в цикле и т.д., не важно), и проигнорирует все далее встречающиеся префиксы этого же атрибута. Исключением тут является набор стандартных (скорее - часто используемых в одной и той же роли) имён переменных, хранящийся в недрах Houdini. Например переменным @P, @ptnum ... @elemnum, @Cd и некоторым другим префикс типа указывать необязательно. Префиксы могут быть f = float, i = int, v = vector, p = vector4.

Полную таблицу смотрите в документации (в этой же главе документации перечислены атрибуты, тип которых можно не указывать). Например, f@Alpha, v@v, 4@transform, p@orient и т.д.

Атрибуты типа массив обозначаются дополнительными квадратными скобками в префиксе, например, s[]@nameparts, v[]@prevposarr, 4[]@transforms.

Альтернативно, тип атрибута можно определить конструкцией:

type @attr;

или

type @attr = const_value;

например,

vector @dir;

или

vector @dir = {0, 1, 0};

В таком случае компилятор поймет, что тип атрибута dir - vector, а во второй конструкции - если атрибут dir не существует на геометрии - он будет создан перед запуском VEX, с дефолтным значением равным {0, 1, 0}. Дефолтное значение для всей геометрии, не конкретное значение для элемента! Этим значением будет инициализироваться значение атрибута на новых точках. Если атрибут dir уже существует на геометрии, то дефолтное значение игнорируется, и обе записи становятся эквивалентными.

При типизации атрибута таким образом дальнейшее использование какого-либо префикса будет игнорироваться. Заметьте, что на данный момент строковые атрибуты геометрии не могут иметь дефолтных значений, отличных от пустой строки. Если тип атрибута в VEX не совпадает с типом атрибута на геометрии - врангл не будет работать как ожидается. В случае некоторых несоответствий, как, например, атрибут типизирован как float в коде, но на входной геометрии является вектором, в переменную будет биндится первая компонента вектора. Но общее правило - это что неправильно типизированный атрибут в коде не будет “забинжен” на атрибут геометрии.

Компиляция VEX происходит независимо от входящей геометрии, так что нет, компилятор не будет догадываться о типах атрибутов по входной геометрии, он о ней ничего не знает и знать не хочет. И вообще кто знает - сейчас там есть какой-нибудь атрибут, на следующем кадре не будет, а на следующем - еще и другим типом может стать.

Важно заметить - компиляция VEX быстрая, но все же не моментальная, и если вы составляете свой VEX-код с помощью Python Expression, или HScript вставок - сам параметр врангл ноды, содержащий VEX-код, может стать зависимым от времени, что приведет к постоянной перекомпиляции VEX-кода каждый кадр, что может добавить ощутимый “оверхед” к вранглам, которые иначе должны быть очень быстрыми.

Примеры ОШИБОК:

Вместо этого используйте, соответственно:

Существуют также особенные биндинги, не связанные с атрибутами. Они разные для разных типов вранглов, для геометрических это, например, @elemnum, @ptnum, @primnum, @vtxnum, @numelem, @numpt, @numprim, @numvtx.

Все они имеют предопределённый тип int @elemnum - биндится на номер текущего элемента. Для врангла по точкам - это номер текущей точки, по примитивам - номер примитива, по вертексам - линейный номер вертекса. Соответственно @numelem - это общее число обрабатываемых элементов (число точек, примитивов, вертексов для вранглом точечных, примитивных и вертексных соответственно).

  per point per vertex per primitive detail number
@elemnum № текущей точки № текущего вертекса № текущего примитива 0 текущий №
@ptnum № текущей точки № точки, к которой принадлежит текущий вертекс № первой точки, принадлежащей текущему примитиву -1 0
@vtxnum № первого вертекса, принадлежащего текущей точке № текущего вертекса № первого вертекса, принадлежащего текущему примитиву -1 0
@primnum № первого попавшегося примитива, к которому принадлежит текущая точка № примитива, вертексом которого является текущий вертекс № текущего примитива -1 0
@numelem общее число точек общее число вертексов общее число примитивов 1 общее число чисел
@numpt общее число точек общее число точек общее число точек общее число точек общее число точек
@numvtx число вертексов в примитиве, к которому принадлежит вертекс @vtxnum число вертексов в примитиве, к которому принадлежит текущий вертекс число вертексов в примитиве, к которому принадлежит вертекс @vtxnum общее число вертексов общее число вертексов
@numprim общее число примитивов общее число примитивов общее число примитивов общее число примитивов общее число примитивов

В таблице выше приведены значения всех стандартных биндингов, связанных с номером элемента, “текущий” элемент в этом описании подразумевает, что врангл выполняется для всех элементов этого типа, и если мы возьмем одно любое из этих “выполнений” - “текущий” элемент будет тем элементом, для которого происходит это выполнение. Или, другими словами, если представить себе, что ваш код врангла выполняется в цикле по выбранным элементам, “текущий” элемент - это номер итерации цикла.


Вольюм вранглы

Вольюм вранглы исполняют VEX-код для каждого вокселя, некоторого вольюм примитива (определение вольюм примитивов - вне рамок данной статьи, смотрите документацию).

В отличии от геометрических вранглов, к переменным, начинающимся с @ биндятся не атрибуты элементов, а вольюм примитивы по атрибуту name. Т.е. к переменной @density забиндится вольюм примитив, у которого примитивный строковый атрибут name имеет значение density.

По умолчанию Houdini биндит к имени переменной имя соответствующего вольюм примитива, но, как и в случае с геометрическими вранглами, эти биндинги могут быть изменены как угодно на вкладке bindings врангл ноды.

Кроме биндингов вольюмов присутствуют стандартные биндинги, такие как:

Точный список стандартных биндингов смотрите в документации.

Случай одного изменяемого вольюма

Рассмотрим пока случай одного обычного скалярного вольюм примитива, такого как, например, density.

VEX-код будет выполняться для каждого вокселя указанного примитива, например density, при каждом выполнении значением переменной @density будет значение очередного вокселя из вольюм примитива.

Но если мы используем несколько разных вольюмов в одном врангле - для вокселей какого из них выполняется VEX-код?

Представим, что у нас есть 2 вольюм примитива: density и mask, и что мы хотим сделать в коде - это подрезать “денсити” по маске, т.е.,

@density *= @mask;

или, что эквивалентно, но более наглядно

@density = @density * @mask;

Houdini понимает, какие вольюмы изменяются вранглом, смотря маску в параметре врангл ноды volumes to write to, по умолчанию там * - значит оба вольюма density и mask будут рассмотрены как изменяемые. Для данного случая мы запишем туда только density, вместо звёздочки.

Тогда:

Быстро напомню, что такое семплинг вольюма: “сэмплить” что-то, обычно функцию, банально означает взять значение функции в некоторой точки. Так же и с вольюмами: вольюм - это трёхмерная функция, т.е. она преобразует 3 входных параметра координат позиции в значение своего вокселя в данной позиции. Если мы семплим вольюм не точно в позиции центра вокселя, а где-то между ними - мы получим интерполированное значение, между восемью ближайшими к заданной позиции вокселей.

Кстати, именно это в VEX делает функция,

volumesample(input, volume_name, position)

т.е. можно думать, что перед выполнением VEX-кода выполняется следующая строчка

@mask = volumesample(0, "mask", @P);

Случай двух и более изменяемых вольюмов

Но что если мы в одном врангле пишем в сразу несколько вольюм примитивов? Для вокселей какого из них будет запущен код?

Оставим в параметре volumes to write to на врангл ноде дефолтную звездочку * и для наглядности расширим наш пример:

@density *= @mask;
@mask += @density;

хоть и смысла в этой операции над @mask и немного.

Случай полностью выравненных вольюмов

Если все из записываемых вольюмов, в данном случае density и mask, имеют одинаковые размер и одинаковое положение в пространстве - число, позиции, индексы и всё прочее их вокселей идеально совпадает. В таком случае не важно, для вокселей которого из них будет выполняться VEX-код.

Всё происходит как и в случае одного изменяемого врангла, но вместо семплинга и в переменную @mask записывается значение очередного вокселя т.е. повторим, будем считать, что VEX выполняется для вольюма density, но это совершенно не важно, в дальнейшем описании можно безболезненно поменять местами слова density и mask:

  1. перед выполнением кода, в переменную @density будет записано значение текущего вокселя вольюма density
  2. в значение переменной @mask будет записано значение вокселя с тем же индексом вольюма mask
  3. будет выполнен векс код
  4. финальное значение переменной @density будет записано в значение текущего вокселя вольюма density на выходе из ноды.
  5. финальное значение переменной @mask будет записано в значение вокселя с тем же индексом, что и текущий, вольюма mask на выходе из ноды.

Случай не выравненных вольюмов

Если один из записываемых вольюмов относительно другого имеет другое количество вокселей или же он хоть на “миллиюнит” сдвинут, или на “миллиградус” повернут в пространстве, то вольюмы не выравнены. Тогда для каждого из записываемых вольюмов, mask и density в этом случае, данный код будет воспринят будто он выполняется исключительно для записи только этого одного вольюма, остальные переменные с @ будут рассматриваться как обычные переменные, и никуда не записываться после выполнения кода.

Т.е. код выше на самом деле превратится, упрощённо говоря, в два отдельных кода:

float mask = volumesample(0, "mask", @P);
@density *= mask;
mask += @density;

и соответственно,

float density = volumesample(0, "density", @P);
density *= @mask;
@mask += density;

и каждый из этих кодов будет скомпилирован отдельно и запущен по всем правилам, описанным выше для случая одного изменяемого вольюма.

Из описанного следует, что даже в случае врангла с кодом

density *= @mask;

при маске * в параметре volumes to write to врангл будет выполняться 2 раза, один раз для вольюма density, второй раз для вольюма mask, не смотря на то, что никаких изменений над ним произведено не будет.

Это может стать источником значительного замедления и использования лишней памяти на очень детальных “невыравненных” друг относительно друга вольюмах, так что при оптимизации работы для тяжелых сцен записывайте в маску volumes to write to только те имена вольюм примитивов в которые врангл собственно пишет.

Пример, удостовериться в этом можно на простейшем примере: uxegobibap@HPaste, вольюмы foo и bar состоят из одного единственного вокселя каждый, для простоты. Вольюм bar сдвинут относительно foo на 0.001, посмотрите в консоль и вы увидите, что printf отработал 2 независимых раза, хотя вокселей в каждом вольюме по одному. Так же заметьте, что два printf выдали разные значения переменной @bar как вы могли догадаться, это как раз потому, что один раз VEX исполнился для вокселя примитива bar, в этом случае значение @bar было равно значению вокселя, т.е 1, а второй раз код исполнялся для примитива foo, сдвинутого от bar, в этом случае в переменную @bar попадает результат семплинга вольюма bar в позиции сдвинутой на 0.001 от точного центра вокселя.

Поэтому результат семплинга интерполируется между значением вокселя и бордер валью для bar, которое по дефолту равно нулю. Углубляясь чуть более, можно сказать, что Houdini при таком семплинге вольюмов производит трилинейную интерполяцию, в данном случае сдвиг только по одной оси, а значит она сведётся к линейной, и значение @bar будет равно 0 * 0.001 + 1 * (1 - 0.001) = 0.999 и именно это значение мы и видим в консоли выводимое print.

Однако выставите center в ноде bar на 0, и printf в вольюм врангле отработает всего один раз, потому что теперь вольюмы foo и bar полностью выравнены друг относительно друга.

Случай смешанных выравненных и не выравненных вольюмов

Данный случай просто сводится к предыдущим. Все выравненные относительно друг друга вольюмы будут сгруппированы. Теперь к не выравненным относительно друг друга группам будет применена логика из предыдущего случая, с не выравненными вольюмами, а внутри каждой группы будет работать логика первого, выравненного, случая.

Особенности работы с VDB

Все сказанное выше справедливо и для стандартных Houdini Volumes, и для VDB примитивов. Однако следует держать в голове ряд особенностей работы VDB примитивами.

Стандартный Houdini Volumes - это некоторый прямоугольный параллелепипед, разрезанный плоскостями, параллельными его сторонам на множество одинаковых маленьких параллелепипедов. Чаще всего на практике эти мелкие параллелепипеды являются равносторонними, т.е. кубиками.

Таким образом можно определить некоторый угол исходного параллелепипеда, обозвать его левым ближним нижним углом, и дать вокселю в этом углу нулевые индексы по всем осям. Начиная с этого вокселя можно проиндексировать все остальные воксели подряд по каждой оси. Таким образом эти индексы вокселей (которые как раз и биндятся в переменные i@ix, i@iy, i@iz) будут начинаться с нуля и без пропусков иметь все значения до некоторого числа, являющегося числом вокселей по этой оси (которые как раз биндятся в переменные i@resx, i@resy, i@resz)

Однако VDB примитивы - другие. Подробное объяснение, что такое VDB - вне рамок данной статьи, об этом можно прочитать в исходной публикации. Статья сложная, но на странице 4 есть информативная картинка.

Общий смысл, что VDB - это иерархическая структура, дерево, с фиксированной глубиной. Листья этого дерева являются маленькими структурами, схожими с обычными вольюмами, с фиксированным набором собственно вокселей и маской их активности.

Главная суть такого представления - это то, что у VDB нет прямоугольных границ, как у обычного вольюма. Для работы со вранглами можно думать о VDB как о решетке, как и у обычного вольюма, но только бесконечной и лишь в нужных узлах этой решетки существуют собственно воксели с информацией. Врангл исполняется для всех активных вокселей VDB, все остальные уровни иерархии VDB в исполнении врангла не участвуют.

Сразу встаёт вопрос, а как воксели такой решетки индексировать, ведь левого ближнего нижнего угла у неё не существует? Сначала очень кратко заметим, как VDB представляется в Houdini. Сетка VDB примитива всегда имеет одинаковый шаг по всем осям, равный строго 1.

Однако эта сетка трансформируется матрицей 4 x 4, хранящейся в “интринсике” transform на VDB примитиве. Именно масштаб, присутствующий в этой матрице задаёт собственно размер вокселя, что мы видим во вьюпорте и который может быть как uniform так и ununiform, делая воксели параллелепипедами.

В объяснении мы будем игнорировать эту матрицу трансформа, так как принципиальной разницы она не создаёт. Узлом бесконечной сетки с нулевым индексом выбирается узел в нуле координат, по каждой оси узлы индексируются подряд, в одну сторону в положительном направлении, в другую сторону - в отрицательном. Если воксель существует в некотором узле, то он имеет индексы этого узла.

Таким образом получается, что индексы вокселей в VDB примитиве могут быть положительными и отрицательными, и совершенно не обязательно будут идти подряд При этом в переменной i@resx, i@resy, i@resz будет не число вокселей по каждой из осей, а номер максимального индекса по данной оси минус номер минимального индекса по данной оси + 1. @resx = ixmax - ixmin + 1 т.е. если на оси х всего 2 вокселя, у одного индекс по x равен 10, у второго -4, @resx вернёт 10 - -4 + 1 = 15.

Создание геометрии в вольюм врангле

В вольюм врангле, как и в геометрическом, можно создавать новую геометрию используя весь набор VEX функций.

Но, учитывая особенности работы с вольюм вранглами, конкретно то, что один врангл может быть скомпилирован и запущен несколько раз для нескольких групп вольюмов, очень легко случайно создать двойную, тройную и более геометрию, и совершенно этого не заметить. Причем в одном кадре вольюмы будут выравнены, и создание геометрии отработает один раз, а в другом кадре Houdini посчитает, возможно даже из-за ошибки округления в операциях над “трансформами” VDB, что вольюм примитивы не выравнены, и вы получите вдруг двойную геометрию на выходе из врангла.

Поэтому очень важно в вольюм вранглах, создающих геометрию, явно прописывать один единственный вольюм в параметр volumes to write to (даже если вы этот вольюм собственно не меняете), если в коде врангла вы используете более одного биндинга вольюм примитива, разумеется.

Например,

if(@density > @threshold) addpoint(geoself(), @P);

здесь мы используем 2 биндинга, 2 вольюма - density и threshold, следуя логике, описанной выше, если density и threshold вольюмы выравнены, то создастся, например, 1000 точек если же density и threshold не выравнены друг относительно друга - мы получим 2000 точек, 2 набора по 1000 точек, причем чуть-чуть сдвинутых друг относительно друга, потому что при отрабатывании для density в @P будут координаты позиции вокселей density, а при отрабатывании для threshold в @P, соответственно, будут координаты позиций вокселей вольюма threshold.

На самом деле точек будет не 2000, а сколько-то между 1000 и 1000 + общее число вокселей в threshold, ведь наше условие if(@density > @threshold) может сработать по-разному при обходе вокселей вольюма density и при обходе вокселей threshold, если эти вольюмы не выравнены. Пример тут: olagidavaj@HPaste.

© 2024 Illithid Collective   •  Theme forked from  Moonwalk