Как стать автором
Обновить

Комментарии 116

Очень крутой стиль изложения!
Жаль, что когда начинал заниматься программированием графики не попадались такие ёмкие статьи.

PS Простите, не удержался: www.everfall.com/paste/id.php?t5ht4mw6kvls
Как дешевле всего под линуксом запустить .bat?
Никогда не проверял под линуксом, возможно, что-то пойдёт не так как задумывалось.
Я бы попробовал виртуальную машину с XP или попросил кого-нибудь из знакомых расшарить виндовый рабочий стол.
Можете просто выложить скриншот(ы)? :)
как-то так:

О, спасибо большое! На это я могу только ответить прекрасным рейтрейсером, сделанным на постскрипте (должен выполняться непосредственно на принтере!)

Класс!
Божественно. Я, как человек далёкий от компьютерной графики, благодаря вашим статьям, очень увлекся темой. Уже хочется вникать в следующую часть статьи, аналогично тому, как хочется начать читать следующую главу захватывающей книги )
Заголовок спойлера
Хотелось бы немного узнать о вас в качестве вступления к следующей статье :)
Присоединяюсь! Расскажите о себе, очень интересно. Статья очень интересная, изложение прекрасное!
Когда я читал своим студентам аналогичный курс, мы совместно пришли к выводу, (навозившись со всеми этими условиями для перестановки порядка вершин и так далее), что наиболее дубовый вариант рисования треугольника — проход по прямоугольнику, который его содержит и проверка того факта, что точка лежит по одну сторону от всех трех его сторон — является самым надежным с точки зрения кодирования и формального анализа. Там можно даже оценку погрешности определения принадлежности точки треугольнику дать. Да. он медленнее, но нам было не до скорости, мы за истину боролись.

Кроме того, он замечательно разводится по параллельным процессорам.
Гхм. А я что написал в самом-самом начале статьи? Cразу после строчки В самом начале давайте рассмотрим вот такой псевдокод:.
Я об этом варианте и говорил. Жалко, что вы не показали, что внутри inside() происходит.
Да что там может происходить, считаем барицентрические координаты точки относительно треугольника, и дело с концом. Всё, что с негативными координатами, отбрасывается.

Заодно заменяет билинейную интерполяцию, которая нам нужна будет для текстурирования и для z-буфера.
Можно чуть быстрее, для каждой из трех сторон определить знак выражения D = (х — х1) * (у2 — у1) — (у — у1) * (х2 — х1). Это числитель в формуле для барицентрических координат.
Если вершины перечислены в правильном порядке (либо по часовой, либо — против часовой), то все знаки знаменателей в формулах для барицентрических координат будут совпадать.

А так как для нашей модели это справедливо, можно и в ускоряшки поиграть.
Еще быстрее — это посчитать линейные функции, которые задают грани полигона. Затем достаточно посчитать значения этих функций в самом первом пикселе в bounding box. Далее значения этих функций в соседних пикселях вычисляется за счет одного сложения, например f(x +1, y) = f(x, y) + a, где a — соответствующий коэф. в функции грани ax + by + c
Да, но этот метод не позволяет интерполировать. Скорость мне абсолютно побоку, мне компромисс между самым коротким и самым читаемым кодом. Мой код абсолютно нечитаем, это плохо.

Вот так у меня выглядит отрисовка треугольника с z-буфером. Вы можете написать код лучше моего? С удовольствием его возьму :)

Вычисление барицентрических координат мне импонирует тем, что получается элегантнаая интерполяция, особенно когда мы добавим текстурные координаты и/или векторы нормали.
Это не связано с интерполяцией, а просто оптимизация. Таким образом это реализовано в железе в различных GPU. Барицентрические координаты считаются уже после того, как выясняется, что точка внутри треугольника и используются для интерполяции. Нет смысла считать их до того момента, как это выяснится. Это особенно критично при использовании bounding-box алгоритма, поскольку кол-во проверяемых пикселей, не попадающих в полигон, может быть велико. Я не говорю, что у вас так должно быть реализовано, разумеется, просто рассказал про оптимизацию
Ага, спасибо, может кому пригодится. К сожалению, это увеличит размер кода, а это для меня неприемлимо. А вот непосредственный просчёт координат для каждой точки прямоугольника может быть кратким и существенно более читаемым, нежели мой код растеризации.
Если использовать структуры данных не в стиле «хватай мешки, вокзал отходит», можно получить чуть более лаконичный и красивый код.

Например, если совместить буфер кадра и z-буфер (RGBA — 4 байта, Z — 4 байта), можно вычислять итератор на редактируемый пиксель один раз, перед сравнением. Тогда запись в буфер будет происходить за одно присваивание, с использованием того же итератора:
auto p=frame.locate(x,y); //предположим, что frame - ссылка на класс, порожденный от array (или от vector), в котором определен метод auto locate(size_t x, size_t y);
if( (*p).z<Z)
{
     (*p)={R,G,B,A,Z};
}


Однако тут (со всеми этими абстракциями, использованием C++14 и так далее) можно такого хлеба напечь, что мало не покажется.
Не дело с концом. Чтобы избежать графических артефактов, как например дыры между полигонами (упомянуто у вас) или перезаписывание пикселей на смежных гранях, нужно добавлять код для реализации filling convention, например top-left rule. Плюс, для проверки принадлежности пикселя к треугольнику достаточно вычислить значения линейных функций, которые заданными гранями треугольника, и необязательно до этого считать барицентрические координаты. Их можно посчитать потом, как только установлено, что пиксель внутри
Обычная растеризация прекрасно параллелится. Собственно, каждую линию можно считать в отдельном процессе — никаких зависимостей за пределами линии нет.
Браво, очень полезная статья! Глядя на код сразу не разобрался, как рисуете треугольник, но набросал на бумаге и все сразу стало понятно (может быть и Вам стоит схематичный рисуночек добавить в статью, который наверняка есть в лекциях?).

Кстати по доступности напомнило курсы КГ Ravi Ramamoorthi. Очень помогло разобраться в основах, рекомендую!
Мне казалось, что схематичных рисунков по отрисовке треугольника там как минимум три-четыре…
Я имею ввиду рисунок в произвольный момент движения y от t0.y к t1.y, с показом точек A и B, а так же вектора t1-t0, было бы полезно, мне кажется)
Хехе, я с самого начала сказал про 10-20 часов программирования для рендера правой головы.
И я ни разу не соврал, могу выдать мою голову, она честно отрендерится так, как нарисовано :)
А чем её оцифровывали? Кинектом?
Нет, восстановление 3д модели по трём обычным фотоснимкам.
Поклонники ждут от вас название учебного заведения во Франции, чтоб подать заявку на поступление в этом году, вне зависимости от возраста и основной специализации, а также тотального незнания французского, хехе.
Реквестирую статью и по этой теме…
Ох. Добавляю в todo, но у меня минимум на десяток статей уже расписан график.
Ничего, подожду. Всё равно времени немного, на всё не хватает. :(
Лучше сделайте это, потому что иначе вовсе антиклимакс какой-то :) Просто дайте ссылку на файлик с обьектом головы, и домашку его отрендерить.
Окей, модель доступна в репозитории. Раз уж домашка, то мой текущий код на ней сломается. Почините и выкладывайте скриншоты!
НЛО прилетело и опубликовало эту надпись здесь
Хорошая статья, но было бы не плохо если Вы добавляли ссылку на предыдущую статью из вашей серии.
В моём профиле есть список всех публикаций.
Все-таки оглавление в начале каждой статьи удобнее — но это опять же только для удобства читателей. В конечном итоге вам решать, делать или не делать. Эти плюшки ценность статьи как таковой не увеличат, просто сделают удобнее прочтение, особенно для тех, кто нагуглит их через 2-3-6 месяцев.
И что? А если вы потом напишете еще 200 постов, а кто-то попадет на эту статью из Гугла или по ссылке с любого другого ресурса? Как ему в вашем профиле искать другие посты?
В каждом из постов серии должно быть содержание со ссылками на все опубликованные в серии посты. Как здесь, например.
А теперь посмотрите на свой первый пост: там нет ссылки на вторую часть. Это совершенно неправильно.
В какой момент я вам задолжал, не подскажете? Аккуратнее со словами, пожалуйста.
Здесь подразумевается правило хорошего тона ведения статьи. Если она имеет несколько частей, то разумеется логично предположить, что где-то должно быть написано краткое оглавление, дабы иметь быстрый доступ к статьям.
Тем самым вы повышаете удобство для пользователей, вы же с расчетом на них пишете статьи? Вроде и мелочь, но было бы приятно увидеть ссылки на другие статьи этой серии.
Такой язык я прекрасно понимаю, спасибо.
Может быть в репозитории рендерера стоит проставить таги для каждого урока?
Мм. Я не очень понял, у меня несколько коммитов на одну статью. Вы что предлагаете в тагах ставить?
Финальный рендерер для главы1, главы2 и т.д. (таги chapter01, chapter02...). Если я сейчас зайду в репо — сходу открыть рендерер для первой главы не получится — и это всего 2 главы пока )

Ну это так, для удобства читающих…
Хотите права администратора на репозиторий?
Да я как-то не напрашивался ) Давайте я вам вечером напишу, после работы.
Для удобства читающих, добавлены теги на коммиты, которые соответствуют финальным версиям исходников для первой и второй главы.
Супер, спасибо!
в архиве для первой главы в файле main.cpp — самая первая версия функции line вместо финальной, реализующей алгоритм Брезенхэма.
А почему segment_height вы считаете внутри цикла, а не перед ним?
a) да просто потому, что мне всё равно, мне скорость абсолютно не важна здесь
б) потому, что в финальной версии отрисовки треугольников эта переменная зависит от индекса цикла
Спасибо за статью, легкий стиль изложения, безусловно, импонирует. Однако, показалось, что математических обоснований маловато. Барицентрические координаты затронуты только в обсуждении выше, проекция 3D-модели на 2D есть, но про матрицу проекции упоминаний нет. Предполагается, что у студентов уже есть необходимый бэкграунд?
Вся геометрия будет вынесена в отдельную статью.
Майкл Абраш писал такие книжки с оптимизированным кодом еще 2 десятилетия назад. Из наших могу посоветовать Борескова и Ко. Купить сейчас в книжном их, скорее всего, не получится, но интернет всё помнит :)
Отлично, спасибо! К выходным выложу код z-буфера.
Попробуйте отрендерить мою голову (осторожно, требует доработки напильником, ~10-20 строк кода).
Вот такая вполне неплохая штука выходит, если прикрутить тупую (sorted Python'а) сортировку по возрастанию минимальной z-координаты (минимальной из трех вершин полигона)
Работает медленно, но работает
Штука


Вот голова, насчет «размеров» изображения уже поколдовал, когда искал посторонние изображения, сделал ручной ввод, а вот двойные слеши в описаниях полигонов переварились не сразу
Рисовалась почти минуту
Голова


А вот с отсортированными полигонами, 7 секунд в плюс сортировки
Полость носа наружу не торчит


Вот кот с «z-буфером»
Кот

Сортировка — с целью корректировки порядка прорисовки
искал посторонние модели*
Супер, отличные рендеры.
Сортировка по минимальной вершине — это практически «алгоритм художника». У меня в репозитории код з-буфера уже есть, статья будет через несколько дней.
Глянул в репозиторий, уловил примерную идею z-буфера
Для того, чтобы вместо такого
Бррррр


Получалось такое


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

Но все равно заметны погрехи в освещении
Стоп. Освещение и отсечение «задних» поверхностей — это разные вещи, они совпадают только если свет позади камеры.

Если свет сбоку, то освещённость нужно считать скалярным произведением между нормалью и светом, а отсекать рисование треугольников скалярным произведением между направлением вектора взгляда и нормалью.
Хм, спасибо, куда-то меня слегка не туда понесло
Попробую наковырять поворот модели, может что и получится
Супер, добавьте небольшую константу к освещённости всех треугольников (около 40 из 255 возможных). Это будет так называемый ambient lighting (свет, отражённый от стен, от пола и прочего). Скалярное произведение с нормалями даёт диффузную компоненту освещённости — это свет непосредственно от источника, падающий на матовую поверхность.

Получилось что-то такое
image


Еще очень мало нравилась монотонная неосвещенная поверхность, поэтому прикрутил зависимость яркости от расстояния от «стен» (от центра, на самом деле)

Как-то так
Код ОЧЕНЬ плохой, подозреваю

Но перепады все-таки слишком резкие, хех
Отличные картинки. Перепады можно сгладить, взяв нормальный вектор не к треугольнику, а к каждой его вершине. Массив хранится в строках vn x y z, в строках f x/x/vn1 x/x/vn2 x/x/vn3 число vni — это индекс из массива нормалей.
Таким образом, мы получаем три разные интенсивности в вершинах треугольника. Теперь треугольник нужно заливать не одним цветом, а плавным градиентом между тремя. Всё это называется тонировкой Гуро.

Должно получиться нечто вроде вот этого
Хм, либо где-то у меня косяк (хотя некоторые грани стыкуются хорошо), либо в файле что-то слегка не то
Например



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

Точно. Телепат из вас тоже неплохой, как и лектор :)
проверьте вот этот кусок кода, в нём нужно ещё присваивание цвета сделать

if y <= vert[1].y:
if vert[1].y == vert[0].y:
x1 = vert[0].x
x2 = vert[1].x
z1 = vert[0].z
z2 = vert[1].z
error = True
Это я еще не закоммитил тонирование, проблема была действительно в том, что вершины я сортировал, а к нетронутым яркостям обращался по индексу
Вроде готовое тонирование
image

Эта картинка отрисовывалась почти целых 15 секунд, ох
Много вычислений и медленный язык
Супер. Теперь осталась последняя неиспользованная вещь из .obj файла — это текстурные координаты. Принцип тот же, вы читаете vec2 из массива vt ui vi, среднее число между слешами в f x/x/x x/x/x x/x/x — это текстурные координаты данной вершины в данном треугольнике. Интерполируете их внутри треугольника, умножаете на ширину-высоту текстурного файла и получаете пиксель из файла текстуры. Для цвета, про карты нормалей потом.
Вот диффузная текстура
Готово.
Сначала что-то получилось не то с координатами пикселей, поэтому получилось
ОНО
image

Но оказалось достаточным просто перевернуть координаты
Вот так

А!!! Большое спасибо!

Теперь просто забудьте про векторы нормали, которые вы проинтерполировали, они не будут использоваться в финальном рендере. С uv-координатам, что вы использовали для диффузной текстуры, прочитайте вот эту текстуру.

Там каждый rgb-цвет соответствует xyz-нормальному вектору для данного пикселя.
То есть, внутри отрисовки треугольника вы получаете цвет из диффузной текстуры, а нормаль (и, как следствие, интенсивность) из нормальмапной текстуры. (не забудьте пронормализовать все векторы)

После этого вам останется ещё пара текстур (глянцевость и «мраморность») и рендер закончен.

да, разумеется, [0,255] из картинки нужно трансформировать в [-1,1] координат нормали
Да, забыл. Это сработает только для объекта, не подвергшегося никаким вращениям. То есть, для вида, как у меня на рендерах. Иначе нужно преобразовывать нормальные вектора в зависимости от положения камеры. Если у вас просто вращения, то достаточно просто провращать нормальные вектора. Иначе ждите моей статьи про геометрию :)
Вроде так
Рендер


И я уже начинаю путаться в своей лапше
Очень, очень хорошо.

Выглядит неплохо, правда, я привык к шрамам на левой щеке :)


Свет не обязан бить в лицо, просто камеру вращать надо вместе с картой нормалей.
Если не вдаваться совсем в дебри, осталось сделать настоящие тени, использовать глянцевую карту.
Вот тут про подсчёт.

Ну и подповерхностное рассеяние (это я не кодировал и сам, но это несложно).

Сколько у вас это в итоге времени заняло?
Времени заняло много, с утра сижу :)
Затянуло

В координатах и их порядке я и сам путаюсь, немного чищу вот сейчас
Про настоящие тени ссылка. Легко видеть, что у меня на этом рендере у носа тени нет, например.

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


Теперь я понимаю, как много и как сложно считает видеокарта, хех
Прекрасно! Только шрам на левую щёку переместите :)
Например


(как раз вектор освещенности был обращен по Х)
сел писать продолжение :)
Удачи
Я попробую с бликами разобраться
Вот такие вот красивые блики получились

5+
А с какой целью в вашем коде вы трижды производите манипуляции с z-буфером?
Не понял вопроса, в каком именно месте кода?
И да, у меня возникла проблема, на которой я застрял прочно: как достать из вектора освещения угол, на который надо повернуть модель для того, чтобы можно было использовать z-буфер и для тени?
Ох, вы определённо не можете подождать до шестой статьи :)

Вам нужно разобраться, как работает gluLookAt.
В инете должно быть полно примеров. Но там реально нужна матричная алгебра, иначе всё будет очень некрасиво.

Открою секрет, у меня код уже написан.
Репозиторий infographie содержит ужасный (но рабочий) код, который я сейчас переписываю. Есть большие шансы, что geometry.cpp переедет как есть, без изменений. Посмотрите заодно в папку скриншоты, последние две картинки.
Посмотрите заодно в папку скриншоты, последние две картинки.

Я понял, что надо проходиться depth-тестом по видимым пикселям и с точки зрения источника света, и невидимые с точки зрения источника света пиксели будут находиться в тени

Проблема возникла как раз в том, чтобы «научиться смотреть с точки зрения источника света»

Спасибо, попробую поковырять
Да, рендер делается в два прохода: первый раз только z-buffer, цветная картинка ни к чему с камерой, помещённой в источник света, а второй раз с нормального места камеры.

Но. Вам недостаточно просто иметь два з-буфера, вам необходимо знать отображение одного в другой (какой пиксель одного какому пикселю другого соответствует). А это 4x4 матрица.
Хм, теперь я совсем запутался, планировал делать слегка по-другому (и теперь понял, что не очень-то и верно)

Попытаюсь все-таки дотерпеть до продолжения
Есть задача куда проще, попробуйте сделать подповерхностное рассеяние (subsurface scattering). Соотв. текстура лежит здесь
Суть я уловил, но в оригинале всего один цикл. В чем будет конечное отличие в рендеринге?
В оригинале все это объединено
А у меня всего лишь индийский код
Понял вас :). Ну, необъединенные циклы зато легче воспринимаются при чтении такой статьи: compgraph.tpu.ru/zbuffer.htm
«line sweeping» я бы перевел как «проход отрезком». Тут sweeping это в главном своем значении «wide in range or effect». Вообще я бы не стал ставить какое-то русское слово в перевод sweep/sweeping, так как языки не совпадают вовсе. Спасибо за статьи! Очень интересно подалать все как в молодости :)
Увлекательно! С удовольствием читаю все части. С нетерпением жду часть про 1) сглаживание (относительно) низкополигональных сеток 2) вертексные нормали вместо плоскостных.
Реализация на JS (первая часть):
1. jsfiddle.net/2wvyga24/8/ — простейшая заливка по z-координате.
2. jsfiddle.net/2wvyga24/15/ — свет. Очень долго не понимал, в чём проблема при рисовании, а, оказывается, при вычислении нормали использовал экранные координаты вместо мировых :)
jsfiddle.net/2wvyga24/18/ интересный эффект. Полигональная сетка с учётом света.
На Scala: github.com/klpx/tinyrenderer/tree/step-2
Из интересного — тесты на отрисовку треугольников, а также сам алгоритм отрисовки: я нахожу фундамент (ребро, проекция которого соответствует проекции всего треугольника) и крышу треугольника, а затем рисую линии, ограниченные функциями фундамента и крыши. Выглядит примерно так:
  def separateXBaseAndAngle(p1: Point, p2: Point, p3: Point): (Line, (Line, Line)) = {
    val px = Array(p1, p2, p3)
    val baseP1 = px.minBy(_.x)
    val notP1Points = px.filter(_ ne baseP1)
    val baseP2 = notP1Points.maxBy(_.x)
    val angleP = notP1Points.filter(_ ne baseP2)(0)
    
    (Line(baseP1, baseP2), (Line(baseP1, angleP), Line(angleP, baseP2)))
  }
  
  def drawTriangleNormal(p1: Point, p2: Point, p3: Point) {
    val (base, (roof1, roof2)) = separateXBaseAndAngle(p1, p2, p3)
    for (x <- base.p1.x to base.p2.x) {
      val baseY = base getYByX x
      val roof = (if (x <= roof1.p2.x) roof1 else roof2)
      if (roof.p1.x == roof.p2.x) {
        g.drawLine(x, roof.p1.y, x, roof.p2.y)
      } else {
        val roofY = roof getYByX x
        g.drawLine(x, baseY, x, roofY)
      }
    }
  }

картинка


Спасибо за ваши статьи еще раз, haqreu!

Я сейчас как раз на этапе з-буффера, и у меня возникли проблемы:

В алгоритме рисования линии можно было обойтись вычислением y = y0 * (1. - t) + y1 * t, но вы этого не стали делать, так как «неэффективно». Тем не менее, в алгоритме рисования треугольников вы вовсю пользуетесь этой формулой, в итоге у вас несколько умножений и делений на каждый y в треугольнике, и даже не дали ни одного комментария, почему внезапно мы стали использовать неэффективный код, хотя раньше у нас был эффективный (видимо, чтобы было проще для понимания, но тогда, наверно, можно было и алгоритм брезенхама не давать?)

В общем, я решил, что мне нужна производительность, и написал код, который, как я надеялся, должен был быть быстрее — https://goo.gl/lzftxO, что, конечно, возможно, и не так. А в третьей статье оказалось, что вы используете подход из предыдущей статьи, чтобы вычислить координату z у точек, и, похоже, мой выстраданный код мало применим для этого :)

Не совсем, вот цитата из предыдущей статьи:



    for (int x=x0; x<=x1; x++) {
        float t = (x-x0)/(float)(x1-x0);
        int y = y0*(1.-t) + y1*t;

[...]
Этот код работает прекрасно. Именно подобной сложности код я хочу видеть в финальной версии нашего рендера.
Разумеется, он неэффективен (множественные деления и тому подобное), но он короткий и читаемый.
Ага, я это помню. Если бы только где-нибудь возле строчки
Итак, предыдущий код прекрасно работает, но он может быть оптимизирован.
была бы приписка, что оптимизированный код, который мы сейчас получим, не будет использоваться в финальной версии рендера, а предыдущая версия будет, у меня бы наверное, и вопросов не возникло.

Но если вы считаете, что все и так хорошо, то все и так хорошо. Спасибо еще раз!
НЛО прилетело и опубликовало эту надпись здесь
Спасибо — вдохновили написать на флеше image

снова залез в свой косой код на С под ДОС
когда рисовал закрашенные треугольники, не делал проверку обращения (y1-y0) в 0 и получал артефакты в виде горизонтальных штрихов влево, до начала Х
разобрался с этим (накодив это в питоне и получив ошибку деления на 0), в Си почему-то не вылетало, возможно какие-то косяки с типами данных и значениями близкими, но не равными 0.
голова из цветных лоскутов рисуется и крутится вокруг вертикальной оси с досбокс с ~3..4 FPS

теперь хочу убрать невидимые грани, те что смотрят от наблюдателя. почитал как перемножаются вектора. (у вас там какой-то класс похоже работает, с которыми я не уметь, да и вряд ли он есть в BorlandC 3.1) дюже громоздко и медленно.

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации