[Adelta: Автоматическое дифференцирование для прерывистых программ — Часть 1: Основы математики]

[Adelta: Автоматическое дифференцирование прерывистых программ — Часть 2: Введение в DSL]

[Учебное пособие по Adelta — Часть 1: Отличие простой шейдерной программы]

[Учебное пособие по Адельте — Часть 2: Raymarching Primitive]

[Учебное пособие по Adelta — Часть 3: Анимация логотипа SIGGRAPH]

В последнем уроке мы используем логотип SIGGRAPH, чтобы продемонстрировать, как использовать инфраструктуру Aδ для создания программы шейдера, оптимизировать ее параметры для соответствия целевому изображению и анимировать шейдер в GLSL. Одно предостережение заключается в том, что, поскольку программа GLSL создается компилятором, она менее удобочитаема и может вызвать трудности при изменении программы. В прошлый раз мы представили несколько интерфейсов, предоставляемых компилятором, чтобы упростить модификацию кода. В этом руководстве мы используем другой пример шейдера, чтобы продемонстрировать больше интерфейсов компилятора, которые добавляют дополнительную свободу при внесении изменений в бэкэнд GLSL.

Целевое изображение и шейдерная программа

В этом примере мы напишем кольцевой шейдер для выражения значка кельтского узла
Александра Панасовского с сайта thenounproject.com.

Значок может быть выражен несколькими кольцами. Однако, если мы хотим вручную указать параметры, есть несколько проблем. Во-первых, хотя может быть не слишком сложно вручную определить положение x, y и радиус для каждого кольца, достижение идеальной параметризации с точки зрения пикселей может быть очень утомительным. Кроме того, кольца взаимосвязаны друг с другом, поэтому их Z-порядок не может быть тривиально выражен как некоторая постоянная величина. На самом деле, мы будем кодировать Z-порядок для каждого кольца как линейную функцию относительно координаты x относительно центра кольца. Однако может потребоваться несколько итераций проб и ошибок, чтобы вручную определить масштаб линейного моделирования. Поэтому мы собираемся написать шейдерную программу в нашем DSL и автоматически получить наилучшую параметризацию за счет оптимизации.

Мы будем моделировать каждое кольцо как отдельный примитив. Поскольку примитивы могут легко застрять на локальных минимумах во время оптимизации, мы будем отображать 10 колец для оптимизации вместо 6, которые показаны на целевом изображении. Лишние будут либо вытолкнуты за границы изображения, либо их радиус будет уменьшен до небольшого значения, чтобы позволить им спрятаться за другими видимыми примитивами.

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

rings = []
for i in range(nrings):
    ring_params = [X[i + k * nrings + 2] for k in range(4)]
    ring = Object('ring', 
                  pos = ring_params[:2],
                  radius = ring_params[2],
                  tilt = ring_params[3])
    rings.append(ring)

С помощью сгруппированных экземпляров Object мы могли бы легко определить функцию, которая рисует одно кольцо и вызывает его 10 раз для каждого кольца. Всю программу можно найти здесь. Примитивы, которые он использует, уже были представлены в предыдущих уроках.

Нахождение оптимальных параметров с помощью Aδ

Точно так же мы будем использовать мультимасштаб L2, чтобы найти оптимальные конфигурации параметров. Эта задача является более сложной, поскольку черно-белый рисунок не дает цветового оттенка. В результате оптимизации сходятся с меньшей скоростью. Если вам интересно, это также описано в нашей статье Раздел 8.2.2. Мы оптимизируем с 5 случайными инициализациями, используя следующую команду:

python approx_gradient.py --dir <path_to_store_result> --shader celtic_knot --backend hl --init_values_pool apps/example_init_values/test_finite_diffring_contour_init_values_pool.npy --modes optimization --metrics 5_scale_L2 --smoothing_sigmas 0.5,1,2,5 --learning_rate 0.01 --render_size 640,640 --gt_file celtic_knot.png --gt_transposed --multi_scale_optimization --alternating_times 5 --tunable_param_random_var --tunable_param_random_var_opt --tunable_param_random_var_seperate_opt --tunable_param_random_var_std 1 --no_reset_sigma --no_reset_opt --save_best_par --quiet

В качестве альтернативы можно также выполнить оптимизацию с помощью блокнота Jupyter здесь.

Мы показываем пару результатов инициализации и сходимости с наименьшей ошибкой оптимизации. Он прекрасно реконструирует исходное изображение с правильными взаимосвязанными шаблонами.

Сокращение избыточных вычислений

Подобно шейдеру SIGGRAPH, мы могли бы создавать интересные анимации, используя сгенерированный компилятором код GLSL, хранящийся в path_to_store_result/compiler_problem.frag. Пример программы можно найти здесь.

Одно предостережение: код GLSL включает параметры для всех 10 колец, но в финальном рендеринге видны только 6 из них. Было бы утомительно смотреть на значения параметров, чтобы выяснить, какие из них видны, а какие нет. Например, в начале программы GLSL компилятор определяет переменные для позиции x на всех 10 кольцах:

#define ring_0_pos_0_idx 2
float ring_0_pos_0 = X[ring_0_pos_0_idx];

#define ringoptional_updatepos_0_idx 3
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringoptional_updatepos_0_idx 4
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringupdate_rings()pos_0_idx 5
float ringupdate_rings()pos_0 = X[ringupdate_rings()pos_0_idx];

#define ringoptional_updatepos_0_idx 6
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringoptional_updatepos_0_idx 7
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringupdate_ringpos_0_idx 8
float ringupdate_ringpos_0 = X[ringupdate_ringpos_0_idx];

#define ring<path_to_store_result>pos_0_idx 9
float ring<path_to_store_result>pos_0 = X[ring<path_to_store_result>pos_0_idx];

#define ring<path2_to_store_result>pos_0_idx 10
float ring<path2_to_store_result>pos_0 = X[ring<path2_to_store_result>pos_0_idx];

#define ringAnimatepos_0_idx 11
float ringAnimatepos_0 = X[ringAnimatepos_0_idx];

Для решения этой проблемы наш фреймворк предоставляет интерфейс под названием optional_update. Цель интерфейса — идентифицировать вычисление, которое обновляет значения, и позволить компилятору определить, являются ли эти обновления избыточными и могут ли они быть безопасно удалены.

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

vals = [col, default_phase]
    
for i in range(nrings):
    vals = update_ring(vals, rings[i], i)
        
return vals[0]

Чтобы применить интерфейс optional_update, нам нужно обернуть вычисление обновления в вызов функции без побочных эффектов, первый входной аргумент которой включает значения для обновления и возвращает обновленные значения. В нашем примере с шейдером колец update_rings() оказывается одной из таких функций. Поэтому мы переписываем приведенный выше блок кода в следующий: входными аргументами интерфейса optional_update являются функция подпрограммы, значения, которые необходимо обновить, и дополнительные аргументы, передаваемые в функцию подпрограммы. Всю модифицированную программу можно найти здесь.

vals = [col, default_phase]
    
for i in range(nrings):
    optional_update(update_ring, vals, rings[i], i)
        
return vals[0]

Во время оптимизации использование optional_update или прямой вызов подпрограммы update_ring эквивалентны. Однако при генерации кода GLSL после сходимости компилятор пытается жадно пропустить вычисления, завернутые в каждую подпрограмму, и если пропуск вычислений не влияет на окончательный результат рендеринга, эти вычисления будут обрезаны.

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

python approx_gradient.py --dir <path2_to_store_result> --shader celtic_knot2 --backend hl --init_values_pool <path_to_store_result>/best_par.npy --modes render --render_size 640,640 --gt_file celtic_knot.png --gt_transposed

Обратите внимание, что в приведенной выше команде <path_to_store_result> должен быть путем, используемым для оптимизации, но <path2_to_store_result> должен быть другим путем, чтобы убедиться, что каждый шейдер скомпилирован в другой каталог. Можно также внести следующие изменения в ту же записную книжку Jupyter, которую мы использовали для оптимизации, и снова запустить ячейки. Блокнот следует перезапустить перед запуском другого шейдера, чтобы убедиться, что все модули pybind загружены правильно.

run_optimization = False
shader_suffix = '2'

Сгенерированный компилятором код GLSL хранится в пути_к_хранилищу_результата2/compiler_problem.frag. Пример программы можно найти здесь. Если мы снова посмотрим на параметры шейдера, они теперь содержат только те, которые соответствуют 6 видимым кольцам.

#define ringoptional_updatepos_0_idx 2
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringoptional_updatepos_0_idx 3
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringupdate_rings()pos_0_idx 4
float ringupdate_rings()pos_0 = X[ringupdate_rings()pos_0_idx];

#define ringoptional_updatepos_0_idx 5
float ringoptional_updatepos_0 = X[ringoptional_updatepos_0_idx];

#define ringupdate_ringpos_0_idx 6
float ringupdate_ringpos_0 = X[ringupdate_ringpos_0_idx];

#define ring<path2_to_store_result>pos_0_idx 7
float ring<path2_to_store_result>pos_0 = X[ring<path2_to_store_result>pos_0_idx];

Анимация, изменяющая промежуточные переменные

Как и в случае с SIGGRAPH, компилятор уже вставляет пустые функции в код GLSL, чтобы упростить изменение значений параметров шейдера. Но что, если нам нужно больше свободы в модификации кода? Например, мы можем захотеть раскрасить кольца какой-нибудь радужной палитрой. Но поскольку исходный шейдер просто отображает белый цвет внутри кольца, для достижения такого эффекта нельзя изменить ни один параметр. Мы должны заглянуть в сгенерированную компилятором основную функцию шейдера и найти места для изменения значений промежуточных переменных.

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

Например, цвет каждого кольца задается с помощью двух векторов постоянного цвета: черного edge_col и белого fill_col:

ring.fill_col = fill_col
col_current = Var('col_current_%s' % ring.name, select(cond1, edge_col, ring.fill_col))

Чтобы покрасить каждое кольцо, нам нужно изменить fill_col на другое значение для каждого кольца. Этого можно добиться, используя интерфейс Animate для вставки фиктивной функции в граф вычислений, где необходимо применить обновление. Интерфейс определяет имя и входную сигнатуру фиктивной функции. По аналогии с ключевыми словами in и inout в функции GLSL интерфейс также различает список переменных, которые должны быть неизменны, и список переменных, которые необходимо изменить, используя аргументы ключевых слов in_ls и inout_ls по отдельности. Приведенные выше две строки кода можно переписать следующим образом. Поскольку мы планируем создать радужную палитру вокруг кольца, мы также вводим относительное положение пикселя относительно центра кольца в качестве сигнатуры функции.

ring.fill_col, = Animate("animate_ring_col_%d" % idx, inout_ls=[fill_col], in_ls=[rel_pos]).update()
col_current = Var('col_current_%s' % ring.name, select(cond1, edge_col, ring.fill_col))

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

u, v = Animate("animate_uv", inout_ls=[Var("u", u), Var("v", v)]).update()

Опять же, мы могли бы сгенерировать программу GLSL для нового шейдера, используя ранее оптимизированные параметры:

python approx_gradient.py --dir <path3_to_store_result> --shader celtic_knot3 --backend hl --init_values_pool <path_to_store_result>/best_par.npy --modes render --render_size 640,640 --gt_file celtic_knot.png --gt_transposed

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

run_optimization = False
shader_suffix = '3'

Мы могли найти пустые функции, вставленные перед основной функцией. Например, мы могли бы раскрасить каждое кольцо в зависимости от его относительного углового положения по отношению к центру кольца. Функцию цветовой палитры можно скопировать из этого Шейдера палитр автора Shadertoy Иниго Килеза.

float PI = 3.141592653589793;
float get_t(float x, float y, float speed) {
    return atan(x, y) / 2. / PI + iTime * speed;
}
void animate_ring_col_8(
inout vec3  fill_col, 
in float  rel_pos<path2_to_store_result>x, 
in float  rel_pos<path2_to_store_result>y){
    float t = get_t(rel_pos<path2_to_store_result>x, rel_pos<path2_to_store_result>y, 0.5);
    fill_col = pal(t, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.0,0.33,0.67) );
}

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

void animate_ring_col_6(
inout vec3  fill_col, 
in float  rel_posupdate_ringx, 
in float  rel_posupdate_ringy){
    float t = get_t(rel_posupdate_ringx, rel_posupdate_ringy, 1.);
    fill_col = pal(t, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.0,0.10,0.20) );
}
void animate_ring_col_5(
inout vec3  fill_col, 
in float  rel_posoptional_updatex, 
in float  rel_posoptional_updatey){
    float t = get_t(rel_posoptional_updatex, rel_posoptional_updatey, 0.25);
    fill_col = pal(t, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.3,0.20,0.20) );
}
void animate_ring_col_3(
inout vec3  fill_col, 
in float  rel_posupdate_rings()x, 
in float  rel_posupdate_rings()y){
    
    float t = get_t(rel_posupdate_rings()x, rel_posupdate_rings()y, 2.);
    fill_col = pal(t, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,2.0),vec3(0.8,0.90,0.30) );
}
void animate_ring_col_2(
inout vec3  fill_col, 
in float  rel_posoptional_updatex, 
in float  rel_posoptional_updatey){
    float t = get_t(rel_posoptional_updatex, rel_posoptional_updatey, 4. / 3.);
    fill_col = pal(t, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(2.0,1.0,0.0),vec3(0.5,0.20,0.25) );
}
void animate_ring_col_1(
inout vec3  fill_col, 
in float  rel_posoptional_updatex, 
in float  rel_posoptional_updatey){
 
    float t = get_t(rel_posoptional_updatex, rel_posoptional_updatey, 2. / 3.);
    fill_col = pal(t, vec3(0.8,0.5,0.4),vec3(0.2,0.4,0.2),vec3(2.0,1.0,1.0),vec3(0.0,0.25,0.25) );
}

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

void animate_uv(
inout float  u,
inout float  v){
    vec2 rel_pos = vec2(u, v) - vec2(width, height) / 2.;
    float r = length(rel_pos);
    float theta = atan(rel_pos.x, rel_pos.y) + iGlobalTime * PI / 2.;
    u = r * sin(theta) + width / 2.;
    v = r * cos(theta) + height / 2.;
}

Краткое содержание

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

Ссылка

[1] Aδ: Autodiff для прерывистой программы — применяется к шейдерам: [страница проекта] [Github]

[2] Кельтская икона Александра Панасовского: [Существительное Проект]

[2] Палитры Иниго Килеза: [Shadertoy]