Иногда отражение в зеркале более реально, чем сам объект…
— Льюис Кэрролл (Алиса в зазеркалье)

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

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

Приглашаю и Вас окунуться со мной в мир отражений.

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

Самым очевидным решением для меня стало использовать трассировку лучей (Ray tracing). Были созданы 3 зеркальные плоскости под углом 120 градусов друг к другу.



Размещая объекты за дальним краем зеркал и использовав множественное переотражение лучей (около 20 отражений) получаем вполне себе рабочий калейдоскоп.



Для создания рейтресинга используется вычислительный шейдер. Вывод изображения производится в текстуру, которая позже выводится на экран. В качестве объектов отрисовки используются сферы, как более простые фигуры. На моей видеокарте в режиме реалтайм рендеренга мне удалось добиться около 20-25 FPS, и это всего при трёх объектах и одном источнике света, что грустно. Хотелось хаотичного перемещения множества разнообразных фигур, как и источников освещения в реальном времени, но это привело бы к ещё большему замедлению.

После нескольких подходов к оптимизации я отложил эту модель как малоперспективную.

Код вычислительного шейдера GLSL
#version 430 core
layout( local_size_x = 32, local_size_y = 32 ) in;
layout(binding = 0, rgba8) uniform image2D IMG;
layout(binding = 1, std430) buffer InSphere {vec4 Shape_obj[];};
layout(binding = 2, std430) buffer InSphere_color {vec4 Sphere_color[];};

uniform vec2 u_InvScreenSize;
uniform float u_ScreenRatio;
uniform vec3 u_LightPosition;
uniform vec3 u_CameraPosition;

// задаём положение камеры четырьмя векторами
const vec3 ray00 = vec3(-1*u_ScreenRatio,-1, -1.2);
const vec3 ray01 = vec3(-1*u_ScreenRatio,+1, -1.2);
const vec3 ray10 = vec3(+1*u_ScreenRatio,-1, -1.2);
const vec3 ray11 = vec3(+1*u_ScreenRatio,+1, -1.2);
const ivec2 size = imageSize(IMG);

const mat3 mat_rotate = mat3(-0.5, -0.86602540378443864676372317075294, 0, 0.86602540378443864676372317075294, -0.5, 0, 0, 0, 1);
struct plane {
vec3 v_plane;
vec3 n_plane;
vec3 p_plane;
};

// объявляем три плоскости зеркала
plane m[3];
int last_plane;

//----------------------------------------------------------
float ray_intersect_sphere(vec3 orig, vec3 dir, vec4 Shape_obj) {
vec3 l = Shape_obj.xyz - orig;
float tca = dot(l,dir);
float d2 = dot(l,l) - tca * tca;
if (d2 > Shape_obj.w * Shape_obj.w) {return 0;}
float thc = sqrt(Shape_obj.w * Shape_obj.w - d2);
float t0 = tca - thc;
float t1 = tca + thc;
if (t0 < 0) {t0 = t1;}
if (t0 < 0) {return 0;}
return t0;
}
//---------------------------------------------------------
'float ray_intersect_plane(in vec3 orig, in vec3 dir, inout plane p) {
vec3 tested_direction = p.v_plane - orig;
float k = dot(tested_direction, p.v_plane) / dot(dir, p.v_plane);
if (k>=0) {
vec3 p0 = orig + dir * k;
// обрезаем зеркала в плоскости z
if ((p0.z>-80)&&(p0.z<3)) {
p.p_plane = p0;
return length(p0-orig);
}
}
return 1000000;
}'+
//---------------------------------------------------------
bool all_obj(inout vec3 loc_eye, inout vec3 dir, inout vec3 c) {
float min_len = 1000000;
uint near_id = 0;
float len;
float min_len2 = 1000000;
int near_id2 = -1;
for (int i=0; i<3; i++) {
if (i!=last_plane) {
len = ray_intersect_plane(loc_eye, dir, m[i]);
if (len<min_len2) {
min_len2 = len;
near_id2 = i;
}
}
}

// луч попал в одно из зеркал
if (near_id2>=0) {
loc_eye = m[near_id2].p_plane;
dir = reflect(dir, m[near_id2].n_plane);
last_plane =near_id2;
return true;
}

for (uint i=0; i<Shape_obj.length(); i++) {
len = ray_intersect_sphere(loc_eye, dir, Shape_obj[i]);
if ((len>0)&&(len<min_len)) {
min_len = len;
near_id = i;
}
}
// нет точки пересечения с объектами
if (min_len>=1000000) {return false;}

vec3 hit = loc_eye + dir * min_len;
vec3 Normal = normalize(hit - Shape_obj[near_id].xyz);
vec3 to_light = u_LightPosition - hit;
float to_light_len = length(to_light);
vec3 light_dir = normalize(to_light);
float diffuse_light = max(dot(light_dir, Normal), 0.0);
c = min(c + Sphere_color[near_id].xyz * (diffuse_light*0.8+0.2),1);
return false;
}
//---------------------------------------------------------
void main(void) {
if (gl_GlobalInvocationID.x >= size.x || gl_GlobalInvocationID.y >= size.y) return;
const vec2 pos = gl_GlobalInvocationID.xy * u_InvScreenSize.xy;
vec3 dir = normalize(mix(mix(ray00, ray01, pos.y), mix(ray10, ray11, pos.y), pos.x));
vec3 c = vec3(0, 0, 0);
// начальная позиция камеры
vec3 eye = vec3(u_CameraPosition);

// задаём положение зеркалам
m[0].v_plane = vec3(0,-5,0);
m[0].n_plane = vec3(0,1,0);
m[1].v_plane = mat_rotate * m[0].v_plane;
m[1].n_plane = mat_rotate * m[0].n_plane;
m[2].v_plane = mat_rotate * m[1].v_plane;
m[2].n_plane = mat_rotate * m[1].n_plane;

// максимальное число переотражений луча между зеркалами
for (int i=0; i<20; i++) {
if (!all_obj(eye, dir, c)) {break;}
}

// сохраняем текущий пиксель в текстуру
imageStore(IMG, ivec2(gl_GlobalInvocationID.xy), vec4(c,1));
}


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



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



Далее заменяем цвета на текстурные координаты из мини-текстуры — шаблона.



Пример заполнения текстуры прямоугольниками случайных цветов.

Для улучшения отображения, шестигранник увеличиваем до размера экрана, а так же добавляем осевое вращение.

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

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

В итоге получились довольно симпатичные изображения







Видео
(Не мастак ваять видео, извиняюсь за качество)










Код шейдерной программы невероятно прост.

Код шейдеров GLSL
//Вершинный шейдер
#version 330 core
layout (location = 0) in vec4 a_Position;
uniform mat4 u_MVP;
out vec4 v_Color;
out vec2 v_TexCoords;
void main() {
  v_TexCoords = a_Position.zw;
  gl_Position = u_MVP * vec4(a_Position.xy, 0, 1);
}

//Фрагментный шейдер
#version 330 core
precision mediump float;
varying vec2 v_TexCoords;
uniform sampler2D u_Texture;
void main(){
  gl_FragColor = texture(u_Texture, v_TexCoords);
}


Дети остались довольны, а я завис в медитации на несколько вечеров.

Демо (EXE для Windows)

Комментарии (15)


  1. YuryZakharov
    25.07.2019 20:34

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

    Дети не сами по себе модерновые, мы их такими делаем:
    Поэтому мне захотелось воссоздать цифровой прототип варианта калейдоскопа

    А ведь у калейдоскопа всё очарование именно в том, что он аналоговый…


    1. rebuilder Автор
      25.07.2019 21:24

      На счёт детей, возможно они в первую очередь перенимают ценности родителей.
      А по поводу аналоговости, не соглашусь, думаю тут очарование всё же в геометрии.


    1. teecat
      26.07.2019 12:34

      Недавно вытащил калейдоскоп, дал детям. Комментарий старшего — ничего же там цветопередача, как достигли?


  1. Bhudh
    26.07.2019 03:07

    Отдельное спасибо за то, что на XP программа тоже работает :)
    Немного непонятно, зачем при изменении размера окна картинка масштабируется.
    Мне кажется, было бы наоборот интереснее отсекать часть картинки и дать возможность оставить маленькое окошко в виде «глазка» в классический калейдоскоп.


    1. rebuilder Автор
      26.07.2019 08:54

      Не проверял, но возможно программа будет работать и на более старых версиях Виндовс, главное наличие видеокарты и драйверов.
      Собирал и под linux, но к сожалению не разобрался с настройками видеокарты, так что программа падала.
      Друзья предложили портировать проект на WebGl+JavaScript в окно браузера. Если общественность заинтересует, то это не так уж и сложно сделать.

      Что не так с масштабом, если я правильно понял нужна простая функция зуммирования картинки?


      1. Bhudh
        27.07.2019 09:33

        Зуммирование, наоборот, есть по умолчанию, масштаб меняется вместе с окном.
        А хотелось бы неизменения масштаба при изменении границ окна.


  1. Atminatana
    26.07.2019 12:38

    Рассматривался ли вариант использовать Ray tracing только для создания треугольного изображения (например для случайно расположенных полупрозрачных многогранниках) с последующим копированием? Для аутентичности можно было бы добавить затенение/размывание у крайних треугольников.


    1. rebuilder Автор
      26.07.2019 12:42

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


  1. Vitter
    26.07.2019 16:47

    конечно мутит — с такой скоростью калейдоскоп не вращают )))
    Если в общем — получилось красиво. Ещё бы помедленнее менять картинку и вращать — было бы значительно лучше!


    1. rebuilder Автор
      26.07.2019 16:57

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


  1. Evir
    26.07.2019 19:05

    С самого начала статьи вылезла мысль, что для калейдоскопа главное – всё же набор битых стекляшек и возможность их покрутить.
    Я думаю, что если развивать эту мысль – нужно сделать генерацию выпуклых полупрозрачных осколков и ёмкость, в которой они будут крутиться. И затем рендерить в текстуру, брать оттуда треугольник, а уже дальше шейдером выводить на экран то, что получилось. На том же Unity должно быть несложно реализовать, включая всю необходимую для калейдоскопа функциональность – вращение в обе стороны и несколько видов встряски. А дальше уже как фантазия пойдёт.
    Соотвественно, собрать можно будет для windows, linux, mac, ios, android – как минимум. На телефонах должно быть несложно привязаться к сенсорам, чтобы вращать ёмкость с виртуальным стеклом при вращении телефона. Ну и на webgl должно собраться, тогда и в браузере можно будет покрутить.


    1. rebuilder Автор
      26.07.2019 20:30

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


      1. Evir
        26.07.2019 22:25

        Зачем трассировать? Не обязательно. Конечно, трассировка может дать более реалистичное и красивое изображение, но в реальном времени тот же 1920?1080 с хотя бы 30 fps вряд ли даст. Тем более на мобильных устройствах. Достаточно просто использовать рендер Unity, прозрачные объекты он вроде сам рассортирует по расстоянию от камеры, так что «осколки» отрисуются в нужном порядке. Можно поставить две камеры – первая рисует в render texture осколки, вторая растягивает на весь экран простой прямоугольник, применяя аналог Вашего шейдера из статьи (только переписать с glsl на hlsl).
        Ну и прелесть использования Unity ещё и в том, что физикой движения «осколков» он будет заниматься сам. Не нужно привязывать какой-нибудь Bullet, и связывать физику с отрисовкой. Можно прямо в runtime генерировать Mesh, и его использовать и с MeshFilter+MeshRenderer для отрисовки, и с MeshCollider+Rigidbody для физики.


      1. Evir
        26.07.2019 22:35

        Кстати, если всё же тянет на realtime трассировку, то в свежих версиях Unity можно более-менее просто организовать многопоточную трассировку с помощью ECS. Сначала в буфер забивать треугольники, затем W?H (если подходить к задаче «в лоб») подзаданий трассировки, причём выполняться будут параллельно, если не ошибаюсь, в N-2 потока (где N – количество логических ядер на текущем устройстве). Ну и затем переносить из буфера результат в текстуру.


        1. rebuilder Автор
          26.07.2019 23:22

          Тут всё проще не нужно весь экран трассировать, достаточно небольшого изображения для окна в программе выше использовалось 64х64 пикселя, можно и меньше. Да и реальная физика осколков не нужна, достаточно правдоподобной эмуляции гравитации и вращения. Самое сложное на мой взгляд, создать сложные выпуклые многогранники не симметричной формы, так будет красивее.