Основы 3D-графики

Автор статьи: www.hitmaster.ru ©
Сайт Автора: www.hitmaster.ru
E-mail Автора: нет
Дата публикации: 11.03.2005

1. Задание объектов и сцен
Покажем здесь достаточно распространенную схему задания 3D объектов и сцен. Подобная схема, кстати, используется, в 3D Studio.

Каждая сцена представляет собой следующее:
* набор объектов
* набор источников света
* набор текстур
* набор камер (хотя обычно используется одна)
Каждый объект задается следующим:
* набор вершин
* вершина определяется своими 3D координатами и соответствующими ей координатами в текстуре
* набор граней
* грань определяется тремя вершинами и текстурой (вообще говоря, не текстурой, а материалом: кроме текстуры могут быть заданы, например, коэффициенты рассеивания и отражения света)
* поведение объекта
* то есть, расположение (то есть смещение, ось поворота, угол поворота, коэффициент масштабирования, и т.д.) в зависимости от номера кадра; обычно задается в нескольких ключевых точках и интерполируется между ними с помощью сплайнов

Каждый источник света задается следующим:
*положение
*ориентация (точка, в которую направлен этот источник, target)
*тип (фоновый/направленный/ненаправленный)
*цвет (обычно RGB) Каждая текстура представляет собой прямоугольную 2D картинку, часто бывает фиксированных размеров (например, 64x64, 128x128, 256x256).

Каждая камера задается следующим:
* положение (location)
* направление (точнее, точкой, в которую направлена эта камера; target)
* угол зрения (FOV)
* угол поворота относительно своей оси (roll)

2. Проецирование
Мы будем использовать только обычное перспективное проецирование на плоскость зрения "стандартной" камеры, определенной в пункте 1.1 (там же написаны и формулы проецирования). Случай произвольной камеры будет приводиться к случаю стандартной камеры.

3. Матричные преобразования

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

Введем несколько терминов. n-мерный вектор, он же вектор размерности n, он же вектор размера n: упорядоченный набор n действительных чисел. Вообще говоря, практически то же самое, что и обычный 1D-массив. Матрица размера m на n (будет обозначаться как m*n, mxn): таблица размера m на n, в каждой клетке которой — действительное число. Это уже 2D-массив. Всего лишь. Вот пример матрицы 3x3:

[ 15 y*z 0.6 ]

[ 7 -3 91 ]

[ sin(x) 0.123 exp(t) ]

Займемся определением операций над векторами и матрицами. Вектор будем записывать в столбик и рассматривать его как матрицу размера n*1.

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

[ 1 ] [ 4 ]

[ 2 ] * [ 5 ] = 1*4 + 2*5 + 3*6 = 32

[ 3 ] [ 6 ]

Операция векторного произведения: определена для (n-1) вектора одинакового размера n. Результат — вектор, причем, что интересно, перпендикулярный всем множителям. Результат меняется от перестановки мест множителей!!! Формально определяется как определитель матрицы, первая строка которой есть все базисные вектора, а все последующие — соответствующие координаты всех множителей. Поскольку нам она будет требоваться только для 3D пространства, мы определим векторное произведение двух 3D векторов явно:

[ Ax ] [ Bx ] | i j k | [ Ay*Bz-Az*By ]

AxB = [ Ay ] x [ By ] = | Ax Ay Az | = [ Az*Bx-Ax*Bz ]

[ Az ] [ Bz ] | Bx By Bz | [ Ax*By-Ay*Bx ]

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

[ 1 x 500 ] [ 8 a 3 ] [ 9 a+x 503 ]

[ 2 y 600 ] + [ 9 b 2 ] = [ 11 b+y 602 ]

[ 3 z 700 ] [ 10 c 1 ] [ 13 c+z 701 ]

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

Операция умножения двух матриц: определена для двух матриц таких размеров a*b и c*d, что b = c. Например, если b = c, но a != d, то при перестановке множителей операция будет вообще не определена. Результатом умножения матрицы A размером a*b на матрицу B размером b*d будет матрица C размером a*d, в которой элемент, стоящий в строке i и столбце j, равен произведению строки i матрицы A на столбец j матрицы B. Произведение строки на столбец определяется как сумма произведений соответствующих элементов строки и столбца. Чтобы было хоть чуть-чуть понятно, пример умножения строки на столбец (они должны быть равной длины, кстати; поэтому и такие ограничения на размеры матриц):

[ 4 ]

[ 1 2 3 ] * [ 5 ] = 1*4 + 2*5 + 3*6 = 32

[ 6 ]

А чтобы перемножить две матрицы, надо эту операцию проделать для каждого элемента. Вот пример:

[ 1 2 3 ] [ 0 3 ] [ 1*0+2*1+3*2 1*3+2*4+3*5 ]

[ 4 5 6 ] * [ 1 4 ] = [ 4*0+5*1+6*2 4*3+5*4+6*5 ] = ...

[ 7 8 9 ] [ 2 5 ] [ 7*0+8*1+9*2 7*3+8*4+9*5 ]

Умножение и сложение матриц обладают почти тем же набором свойств, что и обычные числа, хотя некоторые привычные свойства не выполняются (например, A*B != B*A); нам на самом деле понадобится знать, что произведение вида A*B*C*D*... не зависит от того, как расставить скобки. Или, если угодно, что

A*(B*C) = (A*B)*C.

Теперь забудем об этом на некоторое время и перейдем к преобразованиям. Любое движение (то есть преобразование пространства, сохраняющее расстояние между точками) в трехмерном пространстве, согласно теореме Шаля, может быть представлено в виде суперпозиции поворота и параллельного переноса, то есть последовательного выполнения поворота и параллельного переноса. Именно поэтому основная часть информация о поведении объекта — это его смещение, ось поворота и угол поворота. И именно поэтому нам достаточно знать, как сделать два преобразования — перенос и поворот.

Перенос точки (кстати, точки будут также рассматриваться как вектора с началом в начале координат и концом в собственно точке) с координатами (x,y,z) на вектор (dx,dy,dz) делается простым сложением всех координат. То есть результат — это (x+dx,y+dy,z+dz). Как бы сложили вектор-точку и вектор-перенос.

Поворот — занятие уже более интересное. Но тоже простое. Рассмотрим для примера поворот точки (x,y,z) относительно оси z. В этом случае z не меняется вообще, а (x,y) меняются так же, как и при 2D повороте относительно начала координат.

Посмотрим, какие будут координаты у точки A' — результата поворота A(x,y) на угол alpha.

Пусть r = sqrt(x*x+y*y). Пусть угол AOx равен phi, тогда из рисунка видно, что cos(phi) = x/r, sin(phi) = y/r. Угол A'OA равен по условию alpha. Отсюда

x' = r*cos(alpha+phi) = r*(cos(alpha)*cos(phi)-sin(alpha)*sin(phi)) =

= (r*cos(phi))*cos(alpha)-(r*sin(phi))*sin(alpha) =

= x*cos(alpha)-y*sin(alpha)

y' = r*sin(alpha+phi) = r*(cos(alpha)*sin(phi)+sin(alpha)*cos(phi)) =

= (r*cos(phi))*sin(alpha)+(r*sin(phi))*cos(alpha) =

= x*sin(alpha)+y*cos(alpha)

Для трехмерного случая, таким образом

x' = x*cos(alpha)-y*sin(alpha)

y' = x*sin(alpha)+y*cos(alpha)

z' = z

Аналогичные формулы получатся и для других осей поворота (то есть Ox, Oy). Поворот относительно произвольной оси, проходящей через начало координат, можно сделать с помощью этих поворотов — сделать поворот относительно Ox так, чтобы ось поворота стала перпендикулярна Oy, затем поворот относительно Oy так, чтобы ось поворота совпала с Oz, сделать собственно поворот, а затем обратные повороты относительно Oy и Ox. Можно даже вывести формулы для такого поворота и убедиться в том, что они очень громоздкие.

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

[ x' ] = [ cos(alpha) -sin(alpha) 0 ] [ x ]

[ y' ] = [ sin(alpha) cos(alpha) 0 ] [ y ]

[ z' ] = [ 0 0 1 ] [ z ]

То есть поворот на угол alpha задается одной и той же матрицей, и с помощью этой матрицы (умножая ее на вектор-точку) можно получить координаты повернутой точки. Пока никакого выигрыша не видно — здесь умножение матрицы на вектор требует больше операций, чем расчет x' и y' по формулам.

Удобство матриц для нас заключается как раз в свойстве A*(B*C) = (A*B)*C. Пусть мы делаем несколько поворотов подряд, например, пять (как раз столько, сколько надо для поворота относительно произвольной оси), и пусть они задаюся матрицами A, B, C, D, E (A — матрица самого первого поворота, E — последнего). Тогда для вектора p мы получаем

p' = E*(D*(C*(B*(A*p)))) = E*D*C*B*A*p = (E*D*C*B*A)*p = (E*(D*(C*(B*A))))*p = T*p,

где

T = (E*(D*(C*(B*A))))

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

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

На самом деле, эти преобразования тоже легко записываются в виде матриц. Только вместо матриц 3x3 и 3-мерных векторов используются так называемые однородные 4-мерные координаты и матрицы 4x4. При этом вместо векторов вида

[ x ]

[ y ]

[ z ]

используются вектора вида

[ x ]

[ y ]

[ z ]

[ 1 ]

а вместо произвольных матриц 3x3 используются матрицы 4x4 такого вида:

[ a b c d ]

[ e f g h ]

[ i j k l ]

[ 0 0 0 1 ]

Видно, что если d = h = l = 0, то в результате применения всех операций получается то же самое, что и для матриц 3x3.

Матрица параллельного переноса теперь определяется как

[ 1 0 0 dx ]

[ 0 1 0 dy ]

[ 0 0 1 dz ]

[ 0 0 0 1 ]

Матрицу масштабирования можно определить и для матриц 3x3, и для матриц 4x4:

[ kx 0 0 ] [ kx 0 0 0 ]

[ 0 ky 0 ] или [ 0 ky 0 0 ]

[ 0 0 kz ] [ 0 0 kz 0 ]

[ 0 0 0 1 ]

где kx, ky, kz — коэффициенты масштабирования по соответствующим осям.

Таким образом, получаем следующее. Любое нужное нам преобразование пространства можно задать матрицей 4x4 определенной структуры, разной для разных преобразований. Результат последовательного выполнений нескольких преобразований совпадает с результатом одного преобразования T, которое также задается матрицей 4x4, вычисляемой как произведение матриц всех этих преобразований. Важен порядок умножения, так как A*B != B*A. Результат применения преобразования T к вектору [ x y z ] считается как результат

умножения матрицы T на вектор [ x y z 1 ]. Вот и все.

Осталось только на примере показать, почему A*B != B*A. Пусть A — матрица переноса, B — поворота. Если мы сначала перенесем объект, а потом повернем относительно центра координат (это будет B*A), получим далеко не то, что будет, если сначала объект повернуть, а потом перенести (это уже A*B).

4. Рисование одноцветного треугольника

Без понимания того, как рисовать залитый одним цветом треугольник, дальше лезть в 3D графику явно не стоит. Поэтому вот объяснение.

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

Отсортируем вершины так, чтобы вершина A была верхней, C — нижней, тогда у нас min_y = A.y, max_y = C.y, и нам надо пройтись по всем линиям от min_y до max_y. Рассмотрим какую-то линию sy, A.y <= sy <= C.y. Если sy < B.y, то она пересекает стороны AB и AC; если sy >= B.y — то стороны BC и AC. Мы знаем координаты всех вершин, поэтому мы можем написать уравнения сторон и найти пересечение нужной стороны с прямой y = sy. Получим два конца отрезка. Так как мы не знаем, какой из них левый, а какой правый, сравним их координаты по x и обменяем значения, если надо. Рисуем этот отрезок, повторяем процедуру для каждой строки — и вуаля, трегуольник нарисован.

Остановимся более подробно на нахождении пересечения прямой y = sy (текущей строки) и стороны треугольника, например AB. Напишем уравнение прямой AB в форме x = k*y+b:

x = A.x+(y-A.y)*(B.x-A.x)/(B.y-A.y)

Подставляем сюда известное для текущей прямой значение y = sy:

x = A.x+(sy-A.y)*(B.x-A.x)/(B.y-A.y)

Вот, в общем-то, и все. Для других сторон пересечение ищется совершенно точно

так же. А вот и пример кода.

// ...

// здесь сортируем вершины (A,B,C)

// ...

for (sy = A.y; sy <= C.y; sy++) {

x1 = A.x + (sy — A.y) * (C.x — A.x) / (C.y — A.y);

if (sy < B.y)

x2 = A.x + (sy — A.y) * (B.x — A.x) / (B.y — A.y);

else

x2 = B.x + (sy — B.y) * (C.x — B.x) / (C.y — B.y);

if (x1 > x2) { tmp = x1; x1 = x2; x2 = tmp; }

drawHorizontalLine(sy, x1, x2);

}

// ...

Надо, правда, защититься от случая, когда B.y = C.y — в этом (и только этом, потому как если C.y = A.y, то треугольник пустой и рисовать его не стоит, или можно рисовать горизонтальную линию; а если B.y = A.y, то sy >= A.y и до деления на B.y — A.y не дойдет) случае произойдет попытка деления на ноль.

Код изменится совсем чуть-чуть:

// ...

// здесь сортируем вершины (A,B,C)

// ...

for (sy = A.y; sy <= C.y; sy++) {

x1 = A.x + (sy — A.y) * (C.x — A.x) / (C.y — A.y);

if (sy < B.y)

x2 = A.x + (sy — A.y) * (B.x — A.x) / (B.y — A.y);

else {

if (C.y == B.y)

x2 = B.x;

else

x2 = B.x + (sy — B.y) * (C.x — B.x) / (C.y — B.y);

}

if (x1 > x2) { tmp = x1; x1 = x2; x2 = tmp; }

drawHorizontalLine(sy, x1, x2);

}

// ...

Вот и все. Ну, горизонтальную линию, надеюсь, нарисовать сумеют все желающие.

5. Работа с произвольной камерой

Рассмотрим любую камеру как точку — центр проецирования и экран — плоский прямоугольник в 3D пространстве, на плоскость которого идет проецирование. Наша стандартная камера, например, задается точкой (0,0,-dist) и экраном с вершинами (-xSize/2,ySize/2), ..., (xSize/2,-ySize/2). Можно задать эту систему тремя векторами, задающими с точки зрения камеры направления вперед, вправо и вверх; вектор "вперед" соединяет центр проецирования и центр экрана, вектор "вправо" соединяет центр экрана и правую его границу, вектор "вверх", соответственно, центр экрана и верхнюю его границу. Обозначим эти вектора как p, q и r соответственно, а центр проецирования за s. Вот пример для

стандартной камеры.

Здесь (для стандартной камеры; обозначим ее вектора как Sp, Sq, Sr, Ss)

Sp = p = (0,0,dist)

Sq = q = (xSize/2,0,0)

Sr = r = (0,ySize/2,0)

Ss = s = (0,0,-dist)

Любые три взаимно перпендикулярных вектора и точка — центр координат задают в 3D пространстве систему координат. Так что объект мы можем рассматривать в системе обычных координат (x,y,z), в системе координат стандартной камеры (Sp,Sq,Sr) или в системе (p,q,r), соответствующей какой-то произвольной камере. В любом случае, если (a,b,c) — координаты точки в системе координат камеры (точнее, в системе координат с центром в точке s и базисом (p,q,r)), то координаты проекции точки на экране равны

screenX = xSize/2 + xSize/2 * a/c

screenY = ySize/2 — ySize/2 * b/c

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

a = x / (xSize/2)

b = y / (ySize/2)

c = (z + dist) / dist

Подставив это в формулы для screenX, screenY, получим как раз те самые формулы

для проекции на стандартную камеру.

Поскольку со стандартной камерой нам достаточно удобно и понятно работать, для произвольной камеры мы должны сделаеть такое преобразование пространства, что как бы совместит произвольную камеру и стандартную камеру. То есть, такое преобразование, что вектора p, q, r перейдут в Sp, Sq, Sr, а точка s в точку Ss.

Посчитаем матрицу для *обратного* преобразования; оно должно переводить Sp,Sq, Sr, Ss в p, q, r, s. Преобразование, переводящее Ss в s (и наоборот) — это обычный паралелльный перенос; остается написать преобразование перевода Sp, Sq, Sr в p, q, r. Пусть у нас есть координаты p, q, r в системе координат (x,y,z):

p = (px,py,pz)

q = (qx,qy,qz)

r = (rx,ry,rz)

Для Sp, Sq, Sr координаты (в этой же системе) известны и равны следующему:

Sp = (0,0,dist)

Sq = (xSize/2,0,0)

Sr = (0,ySize/2,0)

Пусть T — искомая матрица перевода,

[ a b c ]

T = [ d e f ], a..i — какие-то неизвестные.

[ g h i ]

Поскольку T переводит Sp, Sq, Sr в p, q, r; то есть

p = T*Sp

q = T*Sq

r = T*Sr

то, подставляя, например, p и Sp, получаем:

[ px ] [ a b c ] [ 0 ] [ c*dist ]

[ py ] = [ d e f ] [ 0 ] = [ f*dist ], откуда

[ pz ] [ g h i ] [ dist ] [ i*dist ]

c = px/dist

f = py/dist

i = pz/dist.

Аналогично находим все остальные элементы матрицы T:

[ qx*2/xSize rx*2/ySize px/dist ]

T = [ qy*2/xSize ry*2/ySize py/dist ]

[ qz*2/xSize rz*2/ySize pz/dist ]

Но нас интересует обратное к этому преобразование. Оно задается обратной матрицей к T, то есть такой матрицей T1, что

[ 1 0 0 ]

T * T1 = T1 * T = [ 0 1 0 ]

[ 0 0 1 ]

Обратная матрица, вообще говоря, существует далеко не всегда, да и вычисление ее в общем случае — достаточно неприятная задача. Однако в данном случае из-за специального вида матрицы T (конкретнее, из-за того, что T — ортогональная матрица) она не только всегда существует, но и считается очень просто:

[ qx*2/xSize rx*2/ySize px/dist ] [ qx1 rx1 px1 ]

T = [ qy*2/xSize ry*2/ySize py/dist ] = [ qy1 ry1 py1 ]

[ qz*2/xSize rz*2/ySize pz/dist ] [ qz1 rz1 pz1 ]

[ qx1/lq qy1/lq qz1/lq ]

T1 = [ rx1/lr ry1/lr rz1/lr ]

[ px1/lp py1/lp pz1/lp ]

где

lp = px1*px1 + py1*py1 + pz1*pz1

lq = qx1*qx1 + qy1*qy1 + qz1*qz1

lr = rx1*rx1 + ry1*ry1 + rz1*rz1

Сделав сначала параллельный перенос, совмещающий s и Ss, а потом полученное преобразование, как раз и получим преобразование, переводящее произвольную камеру в стандартную.

Теперь надо выяснить, как, собственно посчитать координаты p, q, r через имеющиеся у нас характеристики: положение, направление, угол зрения и угол поворота. 3D Studio (и мы вслед за ней) рассчитывает эти вектора по такому алгоритму:

1. Считаем p = target — location

2. Если p.x == 0 и p.z == 0, то q = (0, 0, 1); иначе q = (p.z, 0, -p.x)

3. Считаем r = crossProduct(p, q) — векторное произведение p на q

4. Считаем lp = length(p) — длина p

5. Приводим r и q к длине 2*lp*tan(FOV/2)

Здесь мы не учитываем поворот камеры вокруг своей оси, его удобнее сделать после перехода к стандартной камере — в этом случае получаем обычный поворот относительно оси z на угол roll.

Таким образом, окончательная матрица перевода должна представлять собой произведение матрицы параллельного переноса, матрицы T1 и матрицы поворота вокруг оси z на угол roll:

FinalCameraMatrix = RollMatrix * T1 * MoveMatrix

Расчет матриц RollMatrix и MoveMatrix очевиден.