Предыстория
Всем привет! Совсем недавно нам удалось всего с восьмой попытки завести на современном смартфоне старинное приложение камеры Xiaomi Mi A1, а точнее, конкретный фильтр с непримечательным названием «Ломо». Ознакомиться с этим приключением можно неподалеку.
Предлагаю немного освежить в памяти этот фильтр –
![Слева ДО, справа ПОСЛЕ Слева ДО, справа ПОСЛЕ](https://habrastorage.org/getpro/habr/upload_files/d24/c1e/913/d24c1e913ecd290db945aabaa7ea1a2d.jpg)
Для некоторых фотографий (особенно учитывая нефлагманские возможности камеры Xiaomi Mi A1) этот фильтр очень даже подходит. В свое время я его использовал в каждой 3-4 фотографии, т.к. иначе фотографии на Mi A1 выходили очень не очень).
Ну а в этой статье мы попытаемся заглянуть во внутренности устройства этого фильтра. Вытрем пыль с исходного кода приложения камеры Xiaomi, раскрывая секреты которым уже целых 8 лет!
Подготовка
Для погружения нам понадобятся некоторые инструменты.
Camera – apk файл исследуемой камеры Xiaomi Mi A1
ApkDecompiler – онлайн декомпилятор apk приложений
SHADERed – минималистичное приложение для редактирования шейдеров (да, вы правильно поняли что нас ждет))
Любимый «блокнот», я в таких случаях использую VSCode
Ну и парочка фотографий для тестирования эффекта
В любом случае, соответствующие исходные файлы я буду прикреплять по ходу продвижения нашего приключения.
В поисках фильтра
Итак, приступим! Файл apk имеется, приступаем к его декомпиляции с помощью онлайн ресурса. После декомпиляции открываем результат в VSCode и ожидаемо видим папку ресурсов и папку исходного кода.
Для начала было-бы неплохо понять куда копать, чтобы найти что-то связанное с нужным нам фильтром. Взглянем на экран приложения в поисках зацепок –
![Приложение камеры на Mi A1 Приложение камеры на Mi A1](https://habrastorage.org/getpro/habr/upload_files/ef1/a49/154/ef1a49154e7a93e376c792233f82122d.png)
Хм, предлагаю как и в прошлой статье пойти в лоб – с поиска строки «Ломо» в сорцах приложения.
Начало положено, по пути resources\res\values-ru
был найден ресурс pref_camera_coloreffect_entry_instagram_rise
-
...
<string name="pref_camera_coloreffect_entry_blackwhite">Ч/Б</string>
<string name="pref_camera_coloreffect_entry_instagram_rise">Ломо</string>
<string name="pref_camera_coloreffect_entry_instagram_hudson">Лед</string>
...
Вероятно, этот ресурс будет в коде рядом с местом, логически связанном с эффектами изображений. Проводим поиск этого ресурса по всей папке исходного кода -
![Ищем ресурс в VSCode Ищем ресурс в VSCode](https://habrastorage.org/getpro/habr/upload_files/027/685/6b0/0276856b08fe1f9510e0aa6c74e8489f.png)
Видим его объявление в уже знакомом нам файле strings.xml
, в котором мы его встретили изначально, а также файле R.java
, который по сути есть отражение strings.xml
. А что за файл EffectController.java
? Кажется, это уже можно считать зацепкой покрупнее.
Общее исследование
Открываем файл EffectController.java
и видим, что наш искомый ресурс pref_camera_coloreffect_entry_instagram_rise
можно встретить на 370 строке (в вставке ниже на 30 строке) метода с говорящим названием getEffectGroup
. Спасибо Xiaomi что не было обфускации.
Код метода
public RenderGroup getEffectGroup(GLCanvas canvas, RenderGroup renderGroup, boolean wholeRender, boolean isSnapShotRender, int index) {
boolean matchPartRender0;
boolean matchPartRender1;
Render gradienterEffectRender;
if (!Device.isSupportedShaderEffect()) {
return null;
}
boolean addEntry = canvas == null;
boolean initOne = false;
if (canvas == null) {
this.mEffectEntries = new ArrayList<>();
this.mEffectEntryValues = new ArrayList<>();
this.mEffectImageIds = new ArrayList<>();
this.mEffectKeys = new ArrayList<>();
this.mNeedRectSet = new ArrayList<>();
this.mNeedScaleDownSet = new ArrayList<>();
addEntry = true;
} else if (renderGroup == null) {
renderGroup = new RenderGroup(canvas, this.mEffectGroupSize);
if (!wholeRender && index < 0) {
return renderGroup;
}
} else if (!renderGroup.isNeedInit(index)) {
return renderGroup;
}
int id = 0;
...
id = 0 + 1;
if (addEntry) {
addEntryItem(R.string.pref_camera_coloreffect_entry_instagram_rise, id);
this.mEffectImageIds.add(Integer.valueOf(R.drawable.camera_effect_image_instagram_rise));
this.mEffectKeys.add("effect_instagram_rise_picture_taken_key");
} else if (renderGroup.getRender(id) == null && (V6ModulePicker.isCameraModule() || isSnapShotRender)) {
if (!wholeRender && index != id) {
if (index < 0) {
if (0 != 0) {
}
}
}
renderGroup.setRender(InstagramRiseEffectRender.create(canvas, id), id);
initOne = true;
}
...
}
После небольшой преамбулы в начале метода, ниже нас встречает куча блоков вида -
int id = ...;
if (addEntry) {
...
} else if (...) {
...
}
Один из которых интересующий нас InstagramRise -
id = 0 + 1;
if (addEntry) {
addEntryItem(R.string.pref_camera_coloreffect_entry_instagram_rise, id);
this.mEffectImageIds.add(Integer.valueOf(R.drawable.camera_effect_image_instagram_rise));
this.mEffectKeys.add("effect_instagram_rise_picture_taken_key");
} else if (renderGroup.getRender(id) == null && (V6ModulePicker.isCameraModule() || isSnapShotRender)) {
if (!wholeRender && index != id) {
if (index < 0) {
if (0 != 0) {
}
}
}
renderGroup.setRender(InstagramRiseEffectRender.create(canvas, id), id);
initOne = true;
}
В условии на 6 строке мы видим, что идет проверка существует ли эффект с заданным id в неком renderGroup, и если его нет, он добавляется на 13 строке. Вероятно этот RenderGroup являет собой некую коллекцию доступных эффектов, которую данный метод «getEffectGroup» призван пополнить.
Более того, ниже в аналогичных блоках мы также видим похожие фрагменты вида –
renderGroup.setRender(InstagramRiseEffectRender.create(canvas, id), id);
renderGroup.setRender(InstagramClarendonEffectRender.create(canvas, id2), id2);
renderGroup.setRender(InstagramCremaEffectRender.create(canvas, id3), id3);
...
И так далее. При этом эти id отличаются друг от друга на единицу, их присваивание мы наблюдаем между блоками. Все это только больше наводит на гипотезу о том, что renderGroup это коллекция фильтров.
Давайте подробнее изучим метод create у класса «InstagramRiseEffectRender» -
public static Render create(GLCanvas canvas, int id) {
CurveEffectRender firstPassRender = new CurveEffectRender(canvas, id);
firstPassRender.setRGBTransLutBuffer(getCurveRGBLutBuffer());
InstagramRiseEffectRender secondPassRender = new InstagramRiseEffectRender(canvas, id);
secondPassRender.setRGBTransLutBuffer(getSelfRGBLutBuffer());
return new PipeRenderPair(canvas, firstPassRender, secondPassRender, false);
}
В данном методе происходит объявление неких эффектов CurveEffectRender и текущего класса InstagramRiseEffectRender, присваивание им каких-то LUT-буферов, и соединение их через PipeRenderPair. При этом оба эффекта наследуют RGBTransEffectRender, который в свою очередь наследует PixelEffectRender который в свою очередь… Так! Мы получили слишком много неизвестных сущностей на единицу времени) Давайте попробуем это хоть как-то структурировать.
В схему я буду выносить поля, которые как по мне будут основными по семантике, начал я с сущности InstagramRiseEffectRender и продолжил всеми его «родителями». На доскональный анализ этих сущностей уйдет много времени, поэтому будем пытаться определить смысл и роль каждого опираясь на отрывистые куски методов, названия методов, и пару ссылок на эти методы. Это позволит нам сильно не погружаться в связи этих сущностей с остальными сущностями в приложении, т.к. такое погружение приведет в конечном итоге к полному анализу всего приложения, что очень долго.
Пройдя по наследуемым классам мы получаем такую вот картину -
![Схема зависимостей Схема зависимостей](https://habrastorage.org/getpro/habr/upload_files/6a4/218/f07/6a4218f07a8f37c8bc6e99729008270b.png)
На случай если хочется изучить их подробнее, прикладываю эти зависимости сюда.
Давайте попробуем выделить основные моменты в этой куче абстракций, на основе представленной схемы и метода create описанного выше.
Эффект InstagramRiseEffectRender является составным фильтром (логика его создания описана в методе create). Он состоит из фильтра CurveEffectRender и себя (навеяло бородатый анекдот про рекурсию: состав салата – помидор, огурец, салат).
Эффекты InstagramRiseEffectRender и CurveEffectRender являются шейдерами, т.к. они реализуют метод
String getFragShaderString()
, возвращающий код шейдера.Оба фильтра прикрепляют к шейдеру произвольный массив методом setRGBTransLutBuffer (который объявлен в классе RGBTransEffectRender).
Вроде стало чуть понятнее. По крайней мере мы теперь знаем что в конечном итоге все эти фильтры это просто шейдеры (а точнее, цепочка двух шейдеров).
Давайте вновь взглянем на метод create, но теперь с нашими предполагаемыми комментариями -
public static Render create(GLCanvas canvas, int id) {
// Создание первого эффекта - шейдера CurveEffectRender
CurveEffectRender firstPassRender = new CurveEffectRender(canvas, id);
// Прикрепление к первому эффекту массива getCurveRGBLutBuffer()
firstPassRender.setRGBTransLutBuffer(getCurveRGBLutBuffer());
// Создание второго эффекта - шейдера InstagramRiseEffectRender
InstagramRiseEffectRender secondPassRender = new InstagramRiseEffectRender(canvas, id);
// Прикрепление к второму эффекту массива getSelfRGBLutBuffer()
secondPassRender.setRGBTransLutBuffer(getSelfRGBLutBuffer());
// Последовательное наслаивание первого и второго эффектов
return new PipeRenderPair(canvas, firstPassRender, secondPassRender, false);
}
Хорошо, в общем случае понятно, но по конкретике возникает сразу несколько вопросов –
Так что это за шейдеры в первом и втором эффектах?
Что за LUT буфер, который мы присваиваем обеим эффектам?
Предлагаю начать с последнего.
Разбор шейдеров
Что такое шейдеры?
Сначала вспомним, что простыми словами фрагментный шейдер это функция от параметров X, Y и еще произвольного множества параметров заданного вами (например различные массивы), которая на выходе дает конкретный цвет пикселя на плоскости (будем рассматривать именно этот случай) в позиции X, Y.
Иными словами его можно представить как-то так -
Color F(int x, int y) {
// Ваш шейдер
}
void applyShader() {
Canvas canva = new Canvas(1080, 1920);
for (int y = 0; y < canva.height; y++) {
for (int x = 0; x < canva.width; x++) {
canva.setPixel(x, y, F(x, y));
}
}
}
Только работать это будет в сотни раз быстрее, чем код выше, т.к. процессинг будет происходить силами GPU, который будет вычислять значения F параллельно.
При этом фрагментные шейдеры пишут на C подобном языке HLSL, вот пример небольшой программы -
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// Нормализованные (0..1) входные координаты X, Y
vec2 uv = fragCoord/iResolution.xy;
// Функция от времени и координат
float col = 0.5 + 0.5*cos(iTime + uv.x * uv.y);
// Результат - цвет пикселя в позиции X, Y
fragColor = vec4(0.0, 0.0, col ,1.0);
}
В итоге мы увидим такую картину -
![Запуск шейдера выше Запуск шейдера выше](https://habrastorage.org/getpro/habr/upload_files/18c/a3c/eec/18ca3ceec662b7ff41405a57634427c2.gif)
Думаю для нашей темы этих знаний будет достаточно, но если хочется большего, на Хабре есть множество занимательных статей на тему шейдеров, вот к примеру та, которую в свое время читал я.
Также запускать простые шейдеры можно прямо в браузере, для тренировки подойдет знаменитый ресурс ShaderToy.
Наши эффекты InstagramRiseEffectRender и CurveEffectRender содержат код шейдера в методе getFragShaderString, который его возвращает строкой.
Разбор параметров шейдера
Вернемся к загадочным установкам массивов обеим фильтрам. Вспомним эти строки -
firstPassRender.setRGBTransLutBuffer(getCurveRGBLutBuffer());
secondPassRender.setRGBTransLutBuffer(getSelfRGBLutBuffer());
Взглянем к примеру на функцию getCurveRGBLutBuffer, которая и предоставляет массив для первого эффекта -
private static IntBuffer getCurveRGBLutBuffer() {
if (sCurveRGBLutBuffer == null) {
int[] rgbLut = new int[sCurveRLut.length];
for (int i = 0; i < rgbLut.length; i++) {
rgbLut[i] = (sCurveBLut[i] << 16) | -16777216 | (sCurveGLut[i] << 8) | sCurveRLut[i];
}
sCurveRGBLutBuffer = IntBuffer.wrap(rgbLut);
}
sCurveRGBLutBuffer.rewind();
return sCurveRGBLutBuffer;
}
Подождите! Ведь фактически все что она делает это оборачивает некие массивы sCurveBLut, sCurveGLut, sCurveRLut в выходной массив rgbLut.
Давайте посмотрим на эти массивы -
private static final int[] sCurveBLut = {34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 40, 40, 41, 41, 42, 42, 43, 44, 44, 45, 45, 46, 47, 47, 48, 48, 49, 50, 50, 51, 52, 53, 53, 54, 55, 55, 56, 57, 58, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 71, 72, 72, 73, 74, 75, 76, 77, 78, 79, 79, 80, 81, 82, 83, 84, 85, 86, 86, 87, 88, 89, 90, 91, 92, 93, 93, 94, 95, 96, 97, 98, 99, 100, 100, 101, 102, 103, 104, 105, 106, 107, 107, 108, 109, 110, 111, 112, 113, 113, 114, 115, 116, 117, 118, 119, 119, 120, 121, 122, 123, 124, 124, 125, 126, 127, 128, 129, 129, 130, 131, 132, 133, 134, 134, 135, 136, 137, 138, 138, 139, 140, 141, 142, 142, 143, 144, 145, 146, 146, 147, 148, 149, 150, 150, 151, 152, 153, 154, 154, 155, 156, 157, 158, 159, 159, 160, 161, 162, 163, 163, 164, 165, 166, 167, 168, 169, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 179, 180, 181, 182, 183, 184, 185, 186, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 200, 201, 202, 203, 204, 205, 207, 208, 209, 210, 211, 212, 213, 215, 216, 217, 218, 219, 220, 221, 222, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255};
private static final int[] sCurveGLut = {23, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 226, 227, 228, 229, 230, 231, 232, 233, 233, 234, 235, 236, 237, 237, 238, 239, 239, 240, 241, 241, 242, 243, 243, 244, 244, 245, 245, 246, 247, 247, 248, 248, 249, 249, 250, 250, 251, 251, 252, 252, 253, 253, 254, 254, 255};
private static final int[] sCurveRLut = {35, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 93, 94, 95, 96, 97, 98, 99, 100, 101, 101, 102, 103, 104, 105, 106, 107, 108, 108, 109, 110, 111, 112, 113, 114, 115, 115, 116, 117, 118, 119, 120, 121, 121, 122, 123, 124, 125, 126, 126, 127, 128, 129, 130, 131, 131, 132, 133, 134, 135, 136, 136, 137, 138, 139, 140, 141, 141, 142, 143, 144, 145, 146, 147, 148, 148, 149, 150, 151, 152, 153, 154, 155, 156, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 238, 239, 240, 241, 242, 242, 243, 244, 244, 245, 245, 246, 246, 247, 247, 248, 248, 249, 249, 249, 250, 250, 251, 251, 251, 252, 252, 252, 252, 253, 253, 253, 253, 254, 254, 254, 255};
Тут мы можем вспомнить, что в памяти компьютера цвет хранится в виде int, младшие 8 бит которого отвечают за красный цвет, следующие 8 за зеленый, следующие 8 за синий, и следующие 8 за прозрачность, иными словами -
![](https://habrastorage.org/getpro/habr/upload_files/48b/302/d64/48b302d6470936f8e1709afb8ee8b186.png)
Таким образом массивы sCurveBLut, sCurveGLut, sCurveRLut содержат цвета трех компонент (красной, зеленой и синей), и функция getCurveRGBLutBuffer объединяет их в единый цвет с помощью битовых сдвигов.
Результат (rgbLut) | sCurveBLut | sCurveGLut | sCurveRLut |
rgb(35, 23, 34) | 34 | 23 | 35 |
rgb(35, 23, 34) | 34 | 23 | 35 |
rgb(36, 24, 35) | 35 | 24 | 36 |
rgb(37, 25, 35) | 35 | 25 | 37 |
rgb(38, 26, 36) | 36 | 26 | 38 |
Хорошо, с первым разобрались, а что насчет второго буфера getSelfRGBLutBuffer? Тут все еще проще, он формирует результат устанавливая одно значение для всех трех компонент (т.е. X = sSelfRGBLut[i]; result = rgb(X, X, X)
) -
private static IntBuffer getSelfRGBLutBuffer() {
if (sSelfRGBLutBuffer == null) {
for (int i = 0; i < sSelfRGBLut.length; i++) {
sSelfRGBLut[i] = (sSelfRGBLut[i] << 16) | -16777216 | (sSelfRGBLut[i] << 8) | sSelfRGBLut[i];
}
sSelfRGBLutBuffer = IntBuffer.wrap(sSelfRGBLut);
}
sSelfRGBLutBuffer.rewind();
return sSelfRGBLutBuffer;
}
По итогу мы получаем такой массив. Далее он передается в шейдер функцией setRGBTransLutBuffer -
public void setRGBTransLutBuffer(IntBuffer lut) {
GLId.glGenTextures(1, sTextureIds, 0);
this.mRGBLutId = sTextureIds[0];
GLES20.glBindTexture(3553, this.mRGBLutId);
GLES20.glTexParameteri(3553, 10242, 33071);
GLES20.glTexParameteri(3553, 10243, 33071);
GLES20.glTexParameterf(3553, 10241, 9728.0f);
GLES20.glTexParameterf(3553, 10240, 9728.0f);
GLES20.glTexImage2D(3553, 0, 6408, lut.capacity(), 1, 0, 6408, 5121, lut);
this.mRGBLutLoaded = true;
}
Так, а что за куча магических чисел? Вероятно после декомпиляции мы потеряли некоторые константы, давайте их восстановим из официальной документации.
public void setRGBTransLutBuffer(IntBuffer lut) {
GLId.glGenTextures(1, sTextureIds, 0);
this.mRGBLutId = sTextureIds[0];
GLES20.glBindTexture(3553, this.mRGBLutId);
GLES20.glTexParameteri(3553, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(3553, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
GLES20.glTexParameterf(3553, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
GLES20.glTexParameterf(3553, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
GLES20.glTexImage2D(3553, 0, GL_RGBA, lut.capacity(), 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, lut);
this.mRGBLutLoaded = true;
}
Другое дело! Метод создает текстуру из буфера, иными словами, это был массив вида A[i]
, а теперь это картинка, в которой элементы массива можно получить так - texture(sRGBLut, vec2(i, 0.0)
).
Также это приводит к тому, что индексы нашего массива теперь нормированы в рамках 0..1. Разберем пример: если был массив с значениями [5, 10, 15], то элемент по индексу 1 (равный значению 10), в текстуре будет по координатам (0.5, 0.0). Т.е. X = 0.5 т.к. он по центру массива, а Y всегда равен 0.0.
Теперь мы знаем, что 3553 это id буфера, а также –
GL_TEXTURE_WRAP_S
иGL_TEXTURE_WRAP_T
определяет что делать, если происходит обращение за границы буфера (в данном случаеGL_CLAMP_TO_EDGE
выставляет значение ближайшей границы).Т.к. теперь индексы нормированы от 0..1,
GL_TEXTURE_MIN_FILTER
иGL_TEXTURE_MAG_FILTER
определяет что делать если заданный индекс между двумя элементами.GL_NEAREST
говорит выбирать в таком случае ближайший.
Так, допустим массив передали в шейдер, а что такое LUT?
Фильтр LUT
LUT – образовано от LookUp Table, т.е. просто таблица поиска. Такой инструмент в графике используется для цветокоррекции.
Зачастую он заменяет одно значение цвета на другое, и содержит в себе все возможные соответствия для каждого входного цвета. С помощью такого инструмента например можно сделать фото насыщеннее, ярче, и еще очень многое.
Схематически такой фильтр можно представить так -
Было | Стало |
rgb(255, 0, 0) | rgb(230, 0, 0) |
rgb(254, 0, 0) | rgb(228, 0, 0) |
rgb(253, 0, 0) | rgb(227, 0, 0) |
... | |
rgb(130, 45, 11) | rgb(120, 40, 5) |
Он содержит в себе соответствия для любого цвета (т.е. 256 * 256 * 256 = 16.7 млн. соответствий).
Сейчас мы рассмотрели 3D LUT, (3D т.к. три пространства – красный, зеленый, синий), в котором 16.7 млн. соответствий, но ведь у нас такого словаря нет? По крайней мере тот массив, который мы передавали в шейдер, он был размером 256.
Так вот, оказывается LUT бывает не только трехмерным, но и покомпонентным. Давайте распилим его на три 1D LUT для каждой компоненты цвета? Т.е. свой LUT для красного, свой для зеленого, и свой для синего.
Таким образом наша таблица будет выглядеть уже немного иначе, например она может быть такой -
Было R | Стало R | Было G | Стало G | Было B | Стало B | ||
230 | 225 | 130 | 125 | 30 | 25 | ||
229 | 223 | 129 | 123 | 29 | 23 | ||
228 | 221 | 128 | 121 | 28 | 21 |
Тут важно понимать, что это три независимые таблицы, для каждой компоненты цвета. И если нам на вход, допустим, придет цвет rgb(229, 128, 30), то на выходе мы получим rgb(223, 121, 25), проводя замену независимо для каждой компоненты.
Подождите-ка! Ведь в таком случае длина каждой такой таблицы будет 256, и их можно троих спрятать горизонтально в массив int, как и было сделано в методе setRGBTransLutBuffer! Выходит, те массивы, что мы собирали, это как раз LUT (покомпонентные) массивы замены цвета.
Шаг 1. LUT
Итак, мы разобрались в общем с параметрами шейдеров, предлагаю начать погружение в сами шейдеры. Начнем с первого – CurveEffectRender, достанем его шейдер из метода getFragShaderString -
precision mediump int;
precision mediump float;
// Входное изображение
uniform sampler2D sTexture;
// Переданный массив LUT
uniform sampler2D sRGBLut;
// Текущие координаты пикселя
varying vec2 vTexCoord;
// Множитель яркости, будем считать что равен 1.0
uniform float uAlpha;
void main() {
vec3 color = texture2D(sTexture, vTexCoord).rgb;
vec3 index;
// Получаем индексы для LUT таблицы (фактически текущий цвет и есть индекс)
index.r = float(int(color.r * 255.0)) / 256.0;
index.g = float(int(color.g * 255.0)) / 256.0;
index.b = float(int(color.b * 255.0)) / 256.0;
color.r = texture2D(sRGBLut, vec2(index.r, 0.0)).r;
color.g = texture2D(sRGBLut, vec2(index.g, 0.0)).g;
color.b = texture2D(sRGBLut, vec2(index.b, 0.0)).b;
// Возвращаемый цвет
gl_FragColor = vec4(color, 1.0) * uAlpha;
}
Я сразу добавил несколько комментариев для ясности.
Попробуем разобрать шейдер по порядку:
Сначала происходит считывание соответствующего пикселя с изображения.
Заводим переменную индекса (вероятно для LUT).
Присваиваем индексы в зависимости от цвета (т.е. цвет каждой из компонент и есть индекс).
Считываем новое значение для каждой из компоненты из переданного массива LUT.
В нашем случае этот шейдер и является эффектом LUT, где ключи это индексы (они и входной цвет), а значение в соответствующем индексе и есть новое значение для компоненты -
Было R (индекс) | Стало R | Было G (индекс) | Стало G | Было B (индекс) | Стало B | ||
230 | 225 | 130 | 125 | 30 | 25 | ||
229 | 223 | 129 | 123 | 29 | 23 | ||
228 | 221 | 128 | 121 | 28 | 21 |
Выходит этот шейдер имеет свои соответствия для каждого значения у каждой из компонент R, G, B. Хорошо, а какие это соответствия? Чтобы это узнать мы можем построить график используя массивы sCurveBLut, sCurveGLut, sCurveRLut. Мы теперь знаем, что в этих массивах в позиции [i] содержится новое значение для значения i в входном изображении (в соответствующей компоненте). Таким образом мы можем построить график, который покажет как будет изменена каждая компонента (где например красный станет ярче, а зеленый затемнён) -
![](https://habrastorage.org/getpro/habr/upload_files/902/2b7/9a1/9022b79a197f529419d069e542be6f9f.png)
Ось X – входное значение (значение яркости компоненты), ось Y – выходное (значение яркости компоненты). Черная линия тут – значения без изменений (т.е. соответствие вида Y = X). А цветные линии – компоненты красного, зеленого и синего цвета.
Что мы видим? Получается что этот фильтр делает красную и зеленую компоненты всегда светлее (сравниваем с черной линией, которая показывает значение до обработки), и только под самый конец они плавно выравниваются. При этом отметим что больше всего у нас отклоняется именно красный цвет. И только синий цвет у нас в точке ~180 совсем ненадолго становится тусклее чем на входном фото, и с ~200 почти не изменяется. Более того, все три компоненты «начинаются» теперь не с 0, а с ~35, т.е. после этого фильтра невозможно получить истинно «черный» цвет.
Пропустим произвольное фото через этот шейдер чтобы увидеть эффект -
![](https://habrastorage.org/getpro/habr/upload_files/7da/707/d08/7da707d082478b0cb18baa3867836b00.jpg)
Изображение действительно стало больше похоже на фильтр Rise, но это мы разобрали только первый шейдер! Остался еще один.
Шаг 2.1. Еще один LUT
Теперь разберем второй шейдер, рассмотрев значение метода getFragShaderString класса InstagramRiseEffectRender -
Весь код шейдера
precision mediump int;
precision mediump float;
// Входное изображение
uniform sampler2D sTexture;
// Массив LUT
uniform sampler2D sRGBLut;
// Координаты текущей точки
varying vec2 vTexCoord;
// Входные параметры (заданы в InstagramRiseEffectRender)
uniform float uAlpha; // 1.0
uniform float uCon; // -7.0
uniform vec2 uCenPos; // vec2(0.57, 0.5814)
uniform vec2 uGCenPos; // vec2(0.5, 0.64)
uniform float uRadius; // 0.491
uniform float uStd; // 0.236
uniform vec2 uWH; // например vec2(1080, 1920)
vec3 CProcess(vec3 color) {
vec3 dstcolor;
float cValue = uCon/100.0 + 1.0;
dstcolor = clamp((color - 0.5) *cValue + 0.5,0.0,1.0);
return dstcolor;
}
float WJianbianProcess() {
float disx,disy,dis,f1,f2,f,pf = uWH.x / uWH.y,x,y;
f1 = max(uCenPos.x,1.0 - uCenPos.x);f2 = max(uCenPos.y,1.0 - uCenPos.y);
if (pf < 1.0) {
disx = (vTexCoord.x - uCenPos.x) * (vTexCoord.x - uCenPos.x);
if (vTexCoord.y/pf < uCenPos.y) {
y = vTexCoord.y;
} else if ((1.0 - vTexCoord.y)/pf < (1.0 - uCenPos.y)) {
y = pf - (1.0 - vTexCoord.y);
} else {
y = uCenPos.y * pf;
}
disy = (y/pf - uCenPos.y) * (y/pf - uCenPos.y);
} else {
disy = (vTexCoord.y - uCenPos.y) * (vTexCoord.y - uCenPos.y);
if (vTexCoord.x * pf < uCenPos.x) {
x = vTexCoord.x;
} else if ((1.0 - vTexCoord.x)*pf < (1.0 - uCenPos.x)) {
x = 1.0/pf - (1.0 - vTexCoord.x);
} else {
x = uCenPos.x / pf;
}
disx = (x * pf - uCenPos.x) * (x * pf - uCenPos.x);
}
dis = disx + disy;
f1 = sqrt(dis)/(sqrt(f1 * f1 + f2 * f2) * uRadius);
if (f1 > 1.0) {
f = 0.4;
} else {
f2 = 0.9908 * pow(f1,3.0) -1.4934 * pow(f1,2.0) -0.4974 * f1 + 1.0;
f = 0.6 * f2 + 0.4;
}
return f;
}
float WEraserProcess() {
float disx,disy,dis,f1,f2,f,pf = uWH.x / uWH.y,x,y,std1;
f1 = max(uGCenPos.x,1.0 - uGCenPos.x);f2 = max(uGCenPos.y,1.0 - uGCenPos.y);
std1 = 2.0 * uStd * uStd * (f1 * f1 + f2 * f2);
if (pf < 1.0) {
disx = (vTexCoord.x - uGCenPos.x) * (vTexCoord.x - uGCenPos.x);
if (vTexCoord.y /pf < uCenPos.y) {
y = vTexCoord.y;
} else if ((1.0 - vTexCoord.y)/pf < (1.0 - uCenPos.y)) {
y = pf - (1.0 - vTexCoord.y);
} else {
y = uCenPos.y * pf;
}
disy = (y/pf - uGCenPos.y) * (y/pf - uGCenPos.y);
} else {
disy = (vTexCoord.y - uCenPos.y) * (vTexCoord.y - uCenPos.y);
if (vTexCoord.x * pf < uCenPos.x) {
x = vTexCoord.x;
} else if ((1.0 - vTexCoord.x)*pf < (1.0 - uCenPos.x)) {
x = 1.0/pf - (1.0 - vTexCoord.x);
} else {
x = uCenPos.x / pf;
}
disx = (x * pf - uCenPos.x) * (x * pf - uCenPos.x);
}
dis = disx + disy;
f = exp(-1.0 * (disx + disy)/std1);
return f;
}
float BlendOverlayF(float base, float blend) {
return (base < 0.5 ? (2.0 * base * blend) : (1.0 - 2.0 * (1.0 - base) * (1.0 - blend)));
}
vec3 BlendOverlay(vec3 base, vec3 blend) {
vec3 destColor;
destColor.r = BlendOverlayF(base.r, blend.r);
destColor.g = BlendOverlayF(base.g, blend.g);
destColor.b = BlendOverlayF(base.b, blend.b);
return destColor;
}
// Точка входа
void main() {
// Еще один LUT, но перефразированный)
vec3 color = texture2D(sTexture, vTexCoord).rgb;
int index = int(color.r * 255.0);
float index1 = float(index) / 256.0;
color.r = texture2D(sRGBLut,vec2(index1,0.0)).r;
index = int(color.g * 255.0);
index1 = float(index) / 256.0;
color.g = texture2D(sRGBLut,vec2(index1,0.0)).r;
index = int(color.b * 255.0);
index1 = float(index) / 256.0;
color.b = texture2D(sRGBLut,vec2(index1,0.0)).r;
vec3 oricolor = vec3(color.r,color.g,color.b);
// Бельмо
oricolor = CProcess(oricolor);
// Затенение
vec3 dstcolor = BlendOverlay(oricolor,vec3(0.0,0.0,0.0));
// Овалы фокуса
float f1 = WJianbianProcess();
float f2 = WEraserProcess();
float f = (1.0 - f2) * f1 + f2;
f = (1.0 - f2) * f + f2;
f = 1.0 - f;
// Соединение "Бельмо" и "Затенение" в зависимости от овалов фокуса
dstcolor = dstcolor * f + oricolor * (1.0 - f);
gl_FragColor = vec4(dstcolor.rgb,1.0) * uAlpha;
}
Не забываем выставить изначальные значения параметров шейдера (они находятся в классе InstagramRiseEffectRender, такие как uAlpha, uCon и т.д.). Для удобства я их выставил сразу.
Ух, тут явно все сложнее. Ладно, давайте начнем с точки входа main. Подождите-ка! Узнаете? Начало очень похоже на предыдущий фильтр, да это он и есть, только строки немного перемешаны.
Давайте разобьем этот фильтр на несколько частей -
Шаг | Описание | Фрагмент |
1 | Еще один LUT | строки 107 - 117 |
2 | Бельмо | строка 119 |
3 | Затенение | строка 121 |
4 | Овалы | строки 123 - 127 |
5 | Соединение | строки 129 - 130 |
Для удобства я вынес вызов BlendOverlay повыше и сразу добавил примерные комментарии к каждому шагу.
И встречает нас очередной LUT! Помните как мы его создавали? Делали это функцией getSelfRGBLutBuffer (которая использовала одно значение для всех компонент). Так вот, тут принцип тот же. Опять сначала считываем цвет с соответствующего пикселя, и находим его соответствие через индекс нужной замене в буфере LUT.
Только вот на этот раз мы используем один LUT для всех трех компонент (а не отдельный LUT для каждой, как в предыдущем LUT). Давайте рассмотрим график для этого LUT? Строим его по значениям sSelfRGBLut.
![](https://habrastorage.org/getpro/habr/upload_files/b14/b66/256/b14b662563c8a2535b046e501f5fbd00.png)
Как и ранее – черная линия тут для сравнения, а золотистая это изменение для всех трех компонент (можно представить что это сразу и красный, и зеленый, и синий).
Хм, интересная картина. До ~54 мы делаем цвета тусклее, затем до ~209 ярче, и потом после вновь тусклее. Лично я не знаю с чем такое может быть связано, может это корректирует особенности восприятия света камерой (тем самым компенсируя ее ошибки) или это просто выглядит прикольно)).
В любом случае предлагаю посмотреть фото до и после (с применением двух рассмотренных LUT) -
![](https://habrastorage.org/getpro/habr/upload_files/fe7/19e/816/fe719e8167c4c461210937daafce550a.jpg)
Фото стало посветлее, и еще больше похоже на фильтр Rise.
Шаг 2.2. Бельмо
Рассмотрим следующий блок -
oricolor = CProcess(oricolor);
Взглянем на функцию Cprocess -
vec3 CProcess(vec3 color) {
vec3 dstcolor;
float cValue = uCon/100.0 + 1.0;
dstcolor = clamp((color - 0.5) *cValue + 0.5,0.0,1.0);
return dstcolor;
}
Напомню, что uCon всегда равен -7, по итогу cValue всегда равен 0.93.
Из кода видим, что мы смещаем на -0.5 каждую компоненту, затем умножаем это на 0.93 и смещаем обратно на +0.5. Пока не совсем понятно как это сказывается на конечном изображении, давайте построим график для всех возможных значений компоненты -
![](https://habrastorage.org/getpro/habr/upload_files/364/aaf/7bc/364aaf7bc44327afb45e45434b2967a5.png)
По традиции – черная линия это эталон Y = X. Выходит, что этот фильтр лишь перенормировал пространство с 0..255 до 8..247. Т.е. с 1 – uCon до 255 + uCon. Больше всего изменение затрагивает начало и конец.
Посмотрим что в итоге -
![](https://habrastorage.org/getpro/habr/upload_files/4c1/2fd/741/4c12fd741e08067668d97d280cd918a4.jpg)
Изменение ну очень мелкое, я даже не могу заметить его на глаз, но раз оно есть, пусть будет)
Шаг 2.3. Затенение
Хух, что-то этих шагов оказалось немало для старичка Xiaomi. И следующий на очереди -
vec3 dstcolor = BlendOverlay(oricolor,vec3(0.0,0.0,0.0));
Заглянем в функцию и увидим -
float BlendOverlayF(float base, float blend) {
return (base < 0.5 ? (2.0 * base * blend) : (1.0 - 2.0 * (1.0 - base) * (1.0 - blend)));
}
vec3 BlendOverlay(vec3 base, vec3 blend) {
vec3 destColor;
destColor.r = BlendOverlayF(base.r, blend.r);
destColor.g = BlendOverlayF(base.g, blend.g);
destColor.b = BlendOverlayF(base.b, blend.b);
return destColor;
}
Нас встречает классическое наложение умножением (прям как в фотошопе).
Не будем забывать что blend у нас всегда 0, поэтому можем упростить функцию BlendOverlayF -
float BlendOverlayF(float base) {
return (base < 0.5 ? 0 : (2.0 * base - 1.0));
}
Построим график и для этого -
![](https://habrastorage.org/getpro/habr/upload_files/515/45f/abc/51545fabc84faee963adc3eba2857c30.png)
Необычный график. Выходит так, что все ниже 128 будет равно 0, а то что больше будет с двойной скоростью набирать силу с начала, и в точке 255 догонит изначальное значение.
Давайте посмотрим на такую обработку -
![](https://habrastorage.org/getpro/habr/upload_files/ba5/e1a/36e/ba5e1a36edb2fbf52f37baf6bf63e6da.jpg)
Фото как и ожидалось стало сильно темнее. При этом заметим, что этот эффект помещается в память (переменная dstcolor) для дальнейшего смешивания, а не сразу заменяет собой текущий цвет, как делали эффекты ранее.
Шаг 2.4. Овалы фокуса
Наш следующий шаг -
float f1 = WJianbianProcess();
float f2 = WEraserProcess();
float f = (1.0 - f2) * f1 + f2;
f = (1.0 - f2) * f + f2;
f = 1.0 - f;
Ой, что-то тут не особо понятно что происходит. Я даже не смог выяснить что такое Jianbian, гугл только говорит что это город в Китае)
Хорошо, давайте разбирать функция за функцией. Начнем с WjianbianProcess -
float WJianbianProcess() {
/*
pf - соотношение ширины к высоте экрана
disx - расстояние до центра овала по X
disy - расстояние до центра овала по Y
dis - евклидово расстояние от текущей точки до центра овала
f1 - нормированный dis (от 0..1)
*/
float disx,disy,dis,f1,f2,f,pf = uWH.x / uWH.y,x,y;
f1 = max(uCenPos.x,1.0 - uCenPos.x);f2 = max(uCenPos.y,1.0 - uCenPos.y);
if (pf < 1.0) {
disx = (vTexCoord.x - uCenPos.x) * (vTexCoord.x - uCenPos.x);
if (vTexCoord.y/pf < uCenPos.y) {
y = vTexCoord.y;
} else if ((1.0 - vTexCoord.y)/pf < (1.0 - uCenPos.y)) {
y = pf - (1.0 - vTexCoord.y);
} else {
y = uCenPos.y * pf;
}
disy = (y/pf - uCenPos.y) * (y/pf - uCenPos.y);
} else {
disy = (vTexCoord.y - uCenPos.y) * (vTexCoord.y - uCenPos.y);
if (vTexCoord.x * pf < uCenPos.x) {
x = vTexCoord.x;
} else if ((1.0 - vTexCoord.x)*pf < (1.0 - uCenPos.x)) {
x = 1.0/pf - (1.0 - vTexCoord.x);
} else {
x = uCenPos.x / pf;
}
disx = (x * pf - uCenPos.x) * (x * pf - uCenPos.x);
}
dis = disx + disy;
f1 = sqrt(dis)/(sqrt(f1 * f1 + f2 * f2) * uRadius);
if (f1 > 1.0) {
f = 0.4;
} else {
f2 = 0.9908 * pow(f1,3.0) -1.4934 * pow(f1,2.0) -0.4974 * f1 + 1.0;
f = 0.6 * f2 + 0.4;
}
return f;
}
Я попытался прокомментировать некоторые строки. Несмотря на страшную арифметику, все что делает этот метод – рисует закругленный овал, с плавным переходом от 0.4 до 1.0. А ветки if и else делают одну работу, просто в одном случае когда ширина экрана больше высоты, а другая в противоположном.
Этот овал можно увидеть если явно рисовать значение f1 -
float f1 = WJianbianProcess();
float f2 = WEraserProcess();
float f = (1.0 - f2) * f1 + f2;
gl_FragColor = vec4(0, 0, 1 ,1.0) * uAlpha;
return;
Готовы его увидеть? Вот он -
![](https://habrastorage.org/getpro/habr/upload_files/d25/0eb/d6a/d250ebd6aca4d9d3992f1871ed899add.png)
Чем более синий пиксель, тем больше значение функции Jianbian в этой точке. Заметим что позиция и радиус этого овала заданы в параметрах шейдера uCenPos и uRadius.
Хорошо, а что тогда делает функция ниже «WEraserProcess»? Давайте выясним -
float WEraserProcess() {
float disx,disy,dis,f1,f2,f,pf = uWH.x / uWH.y,x,y,std1;
f1 = max(uGCenPos.x,1.0 - uGCenPos.x);f2 = max(uGCenPos.y,1.0 - uGCenPos.y);
std1 = 2.0 * uStd * uStd * (f1 * f1 + f2 * f2);
if (pf < 1.0) {
disx = (vTexCoord.x - uGCenPos.x) * (vTexCoord.x - uGCenPos.x);
if (vTexCoord.y /pf < uCenPos.y) {
y = vTexCoord.y;
} else if ((1.0 - vTexCoord.y)/pf < (1.0 - uCenPos.y)) {
y = pf - (1.0 - vTexCoord.y);
} else {
y = uCenPos.y * pf;
}
disy = (y/pf - uGCenPos.y) * (y/pf - uGCenPos.y);
} else {
disy = (vTexCoord.y - uCenPos.y) * (vTexCoord.y - uCenPos.y);
if (vTexCoord.x * pf < uCenPos.x) {
x = vTexCoord.x;
} else if ((1.0 - vTexCoord.x)*pf < (1.0 - uCenPos.x)) {
x = 1.0/pf - (1.0 - vTexCoord.x);
} else {
x = uCenPos.x / pf;
}
disx = (x * pf - uCenPos.x) * (x * pf - uCenPos.x);
}
dis = disx + disy;
f = exp(-1.0 * (disx + disy)/std1);
return f;
}
«Так это почти тоже самое!» воскликните вы, и будете абсолютно правы. Это все тот же овал, только в конце используется другая функция от расстояния текущего пикселя до овала.
Забавно, вероятно разработчик так спешил копируя эту функцию с предыдущей, что даже забыл использовать переменную dis после ее последнего присваивания)
Вот как выглядит второй овал -
![](https://habrastorage.org/getpro/habr/upload_files/b13/89c/f5d/b1389cf5def51ef0287c8381e4ca7007.png)
Отличия есть, но они не особо большие.
А что за три строчки комбинации этих двух значений f1 и f2 с дальнейшим их превращением в переменную f? Это попытка бесшовного наложения этих двух овалов, чтобы между ними не было резкого перехода. Эта формула float f = (1.0 - f2) * f1 + f2;
есть линейная интерполяция с коэффициентом f1, причем строчка ниже ее повторяет второй раз, вероятно чтобы придать немного резкости. Ну а строка 3 инвертирует получившийся овал. В итоге картинка выглядит так -
![](https://habrastorage.org/getpro/habr/upload_files/f24/aa2/782/f24aa27821064a9f519f9ab66ce8c624.png)
Отметим, что данный овал не симметричен по X, слева он более плавный. Это вызвано тем, что позиция и диаметр овала f2 (uGCenPos и uStd) отличны от f1. Но зачем нам эти овалы?
Шаг 2.5. Соединение
У нас остался всего один шаг!
dstcolor = dstcolor * f + oricolor * (1.0 - f);
gl_FragColor = vec4(dstcolor.rgb,1.0) * uAlpha;
Помните мы запоминали затененное изображение в переменную dstcolor? Оно нам наконец пригодилось. Данный код смешивает фото с шага 2.2. и с шага 2.3. используя f как коэффициент (т.е. если f = 0.3, это значит что в результате будет 0.3 от затененного пикселя, и 0.7 от пикселя после эффекта «бельмо»). При этом f зависит только от позиции (это тот самый овал), и чтобы понять это более наглядно, можно взглянуть на последнюю иллюстрацию с овалами – чем пиксель темнее (как например в центре) тем больше тут вклад светлого пикселя, а чем пиксель синее (как по краям), тем больше вклад затененного пикселя. В итоге это создает эффект некого темного туннеля по краям.
Давайте взглянем на результат -
![](https://habrastorage.org/getpro/habr/upload_files/8c5/3ea/55f/8c53ea55f76b510e3505dfd14d26be7c.jpg)
Ого! Очень даже похоже на исследуемый фильтр, более того, один в один)
Все вместе
Давайте теперь для закрепления опишем весь процесс –
Исходное изображение
LUT
Второй LUT
Бельмо
Затенение
Овалы фокуса
Соединение
Схематично это выглядит так -
![](https://habrastorage.org/getpro/habr/upload_files/cc2/a60/9bd/cc2a609bde4418bb043ae84924208e41.jpg)
Ну и как анимация это выглядит так -
![](https://habrastorage.org/getpro/habr/upload_files/958/863/644/9588636446ad52a7e0454fb29685f5c0.gif)
Пожалуй так это и работает, тайна фильтра Rise раскрыта!
Пишем обертку
Если вы желаете самостоятельно поэкспериментировать с данными шейдерами, я объединил два шейдера этого эффекта в один и выгрузил его в архив, который можно скачать и поиграть с ним.
Открывать следует файл «riseEffect.sprj» минималистичным редактором шейдеров c открытым исходным кодом – SHAREDed. Он как раз подходит для небольших исследований -
![Интерфейс SHAREDed Интерфейс SHAREDed](https://habrastorage.org/getpro/habr/upload_files/82e/7c4/936/82e7c4936662999655fa11dc3ae55a34.png)
Также я решил сделать небольшую программу на C#, которая с помощью FFMPEG способна применять данный шейдер эффекта покадрово к mp4 видео. Рассмотреть ее можно на моем Гитхабе – тут. При желании код шейдера можно поменять на какой либо другой, кроме эффекта Rise.
Результат
Ну вот и все! Начиная с декомпиляции, и заканчивая применением шейдера к видео, мы смогли подробно разложить эффект на его составляющие и раскрыть все его тайны 8 летней давности.
Спасибо за то что следили за этим путешествием и до новых встреч!