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 очевиден.