JS. Создаем и вращаем куб средствами Canvas

Доброго времени суток. Предлагаю посмотреть пример - как можно средствами JS и Canvas создать куб, вращать его и затемнять отдельные стороны куба относительно некого источника света. (метод Гуро)
(в итоге мы получим вот такой куб - куб-демо)

Подготовка
Для работы с canvas добавим в DOM элемент canvas ->
   <body >
   <canvas id="iCanvas" width="1000" height="700" >

   </canvas >

This code was highlighted with code.xnim.ru


Для работы с canvas будем использовать чистый JS.
Основную "работу" с cavas в данном примере сведем к:
1) Получить HTML-элемент canvas
2) Получить контекст (на данный момент поддерживается только 2d, поэтому необходимо будет вручную производить аффинные преобразования. В будущем может появиться поддержка 3d)
3) Получить "изображение", которое хранит canvas
4) Получить ссылку на массив пикселов данного изображения.

Все последующая работа с отображением данных будет сводиться к присвоению пикселам определенных значений (цвета\\прозрачности)

Далее я буду описывать пример написания такого куба, без применения классов\\оптимизации производительности. Возможные оптимизации я буду описывать курсивом

Глобальные переменные
Нам необходимы переменные, которые будут содержить:
1) Canvas
2) Содержимое Canvas
3) Изображение, которое содержит canvas
4) Массив пикселов
5) Константа, указывающая на:
5.1) Количество точек фигуры
5.2) Количество граней фигуры
5.3) Количество вершин фигуры
6) Матрица точек
7) Массив точек для трехмерного отображения фигуры
8) Массив проекций (для экранного отображения фигуры)
9) Координаты "центра" (содержат 2 точки, которые будут "мнимым центром")
10) Константы для поворота фигуры (аффинные преобразования)
11) Матрица, описывающая грани
12) Массив для описания освещенности ребер (вычисляется по алгоритму Гуро)
13) Координаты Canvas (левая\\правая\\ширина\\высота)

Пример описания переменных для куба:
  1. var canvas;
  2. var context;
  3. var pixelsData;
  4. var imageData;
  5. //Количества
  6. var pointCount = 8 ; //точки
  7. var faces = 6 ; //грани
  8. var fVertex = 4 ; //вершины
  9. //матрица точек
  10. var cubeTemp = [[5, 5 , 5 ], [24, 5 , 5 ], [24, 24 , 5 ], [5, 24 ,5],
  11.    [5, 5 , 24 ], [24, 5 , 24 ], [24, 24 , 24 ], [5, 24 , 24 ]];
  12. var cube3d = [];
  13. var cube2d = [];
  14. var center = [];
  15. var ctn = Math.cos(Math.atan(2 .0)) / 2 ;
  16. var stn = Math.sin(Math.atan(2 .0)) / 2 ;
  17. //описываем грани на основе точек куба
  18. var edges = [[0, 4 , 5 , 1 ], [0, 1 , 2 , 3 ], [0, 3 , 7 , 4 ], [5, 4 , 7 , 6 ], [1, 5 , 6 , 2 ], [2, 6 , 7 , 3 ]];
  19. //Цвета
  20. var colors = [[0,100,255],[100,255,0],[255,100,0],[0,255,255],[255,0,255],[255,255,0]];
  21. //Освещенность ребер
  22. var lightTemp = [];
  23. var count = 0 ;
  24. //координаты для закрашивания canvas после очередного кадра
  25. var left = 0 , up = 0 , bottom = 700 , right = 1000 ;

This code was highlighted with code.xnim.ru


Так как глобальные переменные зло, и, если допустить, что данный скрипт будет использоваться где-то еще, то могут быть "печальные" последствия использования одних и тех же переменных.
Поэтому, best practices, собрать эти переменные в классы:
1) фигура
2) поле


Инициализация работы:
После прогрузки DOM-структуры документа, необходимо получить массив, изменяя который мы будем формировать изображение куба. (в самом начале я описывал эти действия с Canvas):
  1. $(function () {
  2.    canvas = document.getElementById(''iCanvas'');
  3.    if (canvas && canvas.getContext) {
  4.      context = canvas.getContext(''2d'' );
  5.       imageData = context.getImageData(0 , 0 , canvas.width, canvas.height);
  6.       pixelsData = imageData.data;

This code was highlighted with code.xnim.ru


Следующим шагом, зададим координаты центра посередине canvas:
  1.   center[0] = 1000 /2 ;
  2.      center[1] = 700 / 2 ;

This code was highlighted with code.xnim.ru


Заполним матрицу cube3d (данная матрица должна содержать "объемные" значения точек и нормалей к ним. нормали изначально инициализируем 0):
  1. for( var i=0; i < pointCount; i++)
  2.      {
  3.         cube3d.push({
  4.            point: {
  5.              x: 0 , y: 0 , z: 0
  6.            },
  7.            normal: { x: 0 , y: 0 , z: 0 }
  8.         });
  9.         cube3d[i].point = {x:0,y:0,z:0};
  10.         cube3d[i].point.x = cubeTemp[i][0] * 6 ;
  11.         cube3d[i].point.y = cubeTemp[i][1] * 6 ;
  12.         cube3d[i].point.z = cubeTemp[i][2] * 6 ;
  13.         cube3d[i].point.x += center[0];
  14.         cube3d[i].point.y += center[1];
  15.         
  16.      }

This code was highlighted with code.xnim.ru


Затем необходимо вычислить нормали к вершинам. Для этого введем 2 функции:
1) Функция, которая вычисляет разницу между векторами:
  1. var decVect = function (t1, t2) {
  2.    var summ = {};
  3.    summ.x = t1.x - t2.x;
  4.    summ.y = t1.y - t2.y;
  5.    summ.z = t1.z - t2.z;
  6.    return summ;
  7. }

This code was highlighted with code.xnim.ru


2) Функция нормализации:
  1. //Нормализация
  2. var normalize = function( vector)
  3. {
  4.   
  5.    var vec_length = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
  6.    if( vec_length != 0 ) vector.x /= vec_length, vector.y /= vec_length, vector.z /= vec_length; else vector.x = 0 , vector.y = 0 , vector.z = 0 ;
  7. }

This code was highlighted with code.xnim.ru


В еще не дописанную функцию добавим код, который "доинициализирует" массив cube3d (а именно вычислит нормали):
  1. //вычислим нормали во всех вершинах
  2.      var v1,v2;
  3.      for( var j = 0 ; j < pointCount; j++ )
  4.      {
  5.         
  6.        for( var i=0; i < faces; i++ )
  7.         {
  8.            for( var k = 0 ; k < fVertex; k++ )
  9.            if( edges[i][k] == j )
  10.            {
  11.              v1 = decVect(cube3d[edges[i][0]].point,cube3d[edges[i][1]].point);
  12.              v2 = decVect(cube3d[edges[i][2]].point,cube3d[edges[i][1]].point);
  13.              cube3d[j].normal.x += v1.y * v2.z - v1.z * v2.y;
  14.              cube3d[j].normal.y += v1.z * v2.x - v1.x * v2.z;
  15.              cube3d[j].normal.z += v1.x * v2.y - v1.y * v2.x;
  16.            }
  17.         }
  18.         
  19.         normalize(cube3d[j].normal);
  20.           
  21.      }

This code was highlighted with code.xnim.ru


Затем нам остается только отрисовать данную фигуру и задать таймер отрисовки:
  1.       paint();
  2.      setInterval(move, 10 );

This code was highlighted with code.xnim.ru

Здесь move - функция движения (перемещения\\поворота фигуры)
paint - функция отрисовки

Поворот фигуры

Поворачивать фигуру в двухмерном пространстве возможно по оси x и y.
Обе функции практически идентичны друг другу. Разберем одну функцию, другую определим по аналогии.

Поворот по оси X:
На вход функции подается угол поворота alpha. Введем переменную x (новое положение точки) и переменные, хранящие в себе значения синуса и косинуса alpha:
  1. var rotateY = function( alpha)
  2. {
  3.    var x, alsin = Math.sin(alpha), alcos = Math.cos(alpha);

This code was highlighted with code.xnim.ru


Затем для каждой точки куба (cube3d) необходимо:
1) Отнять координаты центра (точки относительно которой будет поворот)
2) Вычислить новую координату X по формуле: X*cos(alpha) - Z*sin(alpha) (здесь и далее используются формулы аффинных преобразований)
3) Вычислить новую координату Z = X*sin(alpha) + Z*cos(alpha)
4) Вернуть координаты центра
5) Аналогично вычислить координаты нормалей

Получим цикл:
  1. for (var i = 0 ; i < pointCount; i++) {
  2.      //Снимаем центр экрана
  3.       cube3d[i].point.x -= center[0];
  4.      
  5.      //Вычисляем новое положение точки
  6.       x = cube3d[i].point.x * alcos - cube3d[i].point.z * alsin;
  7.      //Вычисляем глубину
  8.       cube3d[i].point.z = cube3d[i].point.x * alsin + cube3d[i].point.z * alcos;
  9.      //Возвращаем значения
  10.       cube3d[i].point.x = x;
  11.       cube3d[i].point.x += center[0];
  12.      
  13.      //Нормаль
  14.       x = cube3d[i].normal.x * alcos - cube3d[i].normal.z * alsin;
  15.       cube3d[i].normal.z = cube3d[i].normal.x * alsin + cube3d[i].normal.z * alcos;
  16.       cube3d[i].normal.x = x;
  17.    }

This code was highlighted with code.xnim.ru


Аналогичные операции и для поворота по Y:
  1. var rotateX = function( alpha)
  2. {
  3.    var y, alsin = Math.sin(alpha), alcos = Math.cos(alpha);
  4.    for( var i = 0 ; i < pointCount; i++ )
  5.    {
  6.     
  7.      cube3d[i].point.y -= center[1];
  8.      y = cube3d[i].point.y * alcos + cube3d[i].point.z * alsin;
  9.      cube3d[i].point.z = cube3d[i].point.z * alcos - cube3d[i].point.y * alsin;
  10.      cube3d[i].point.y = y;
  11.     
  12.      cube3d[i].point.y += center[1];
  13.      y = cube3d[i].normal.y * alcos + cube3d[i].normal.z * alsin;
  14.      cube3d[i].normal.z = cube3d[i].normal.z * alcos - cube3d[i].normal.y * alsin;
  15.      cube3d[i].normal.y = y;
  16.     
  17.    }
  18. }

This code was highlighted with code.xnim.ru


Теперь перейдем непосредственно к прорисовке фигуры.

Рисование


Разбор кода прорисовки фигуры будем делать с верха вниз - вначале более общие функции, затем детали

Paint
Код основной функции рисования. Очишает canvas, рисует фигуру, биндит изменения:
  1. var paint = function () {
  2.    fillrect(pixelsData, left, up, right, bottom, { r: 0 , g: 0 , b: 0 , a:0 });
  3.    up = 700 ; bottom = 0 ; left = 1000 ; right = 0 ;
  4.    project(); // проецирование
  5.    showCube(pixelsData); // отображение
  6.    context.putImageData(imageData, 0 , 0 );
  7. }

This code was highlighted with code.xnim.ru


fillrect
Данная функция очень проста - по координатам заполняем весь массив одним и тем же значением:
  1. var fillrect = function( pixels, x1,y1, x2, y2, color)
  2. {
  3.   
  4.    var minX = min( x1, x2);
  5.    var minY = min( y1, y2);
  6.    var dx = Math.abs(x2 - x1) + 1 , dy = Math.abs(y2 - y1) + 1 ;
  7.    for (var i = 0 ; i < 1000 ; i++) {
  8.      for (var j = 0 ; j < 700 ; j++) {
  9.         setPoint(pixels,i,j, color);
  10.      }
  11.    }
  12. }

This code was highlighted with code.xnim.ru


project
Функция преобразует координаты из трехмерных в двухмерные:
  1. var project = function( )
  2. {
  3.    for( var i = 0 ; i < pointCount; i++ )
  4.    {
  5.       cube2d[i] = { point: { x: 0 , y: 0 } };
  6.       cube2d[i].point.x = Math.floor(cube3d[i].point.x - cube3d[i].point.z * ctn);
  7.       cube2d[i].point.y = Math.floor(cube3d[i].point.y - cube3d[i].point.z * stn);
  8.      
  9.    }
  10. }

This code was highlighted with code.xnim.ru


showCube
Функция, которая занимается фактической отрисовкой куба
Данная функция изначально определяет интенсивность света в каждой точке, а затем если грань видима, то отображает ее:
  1. var showCube = function( pixels)
  2. {
  3.    //вычислим интенсивность во всех точках
  4.    for( var j = 0 ; j < pointCount; j++ )
  5.    {
  6.      cube2d[j].light = lightIntense(j);
  7.    }
  8.    // закрашиваем каждую грань
  9.    for( var i = 0 ; i < faces; i++)
  10.    {
  11.      if( visible(i))
  12.      {
  13.          guroFill(pixels,i,fVertex); // только для видимых граней      
  14.      }  
  15.    }  
  16. }

This code was highlighted with code.xnim.ru


lightIntense
Данная функция определяет интенсивность освещения в заданной вершине.
Изначально задаются настраиваемые параметры положения источника света, его яркость и интенсивность самого источника:
  1. var lightIntense = function (point) {
  2.    //Настраиваемые параметры:
  3.    var amp = 0 .9; // яркость источника (0 -1)
  4.    var ambient = 0 .8;//рассеянный свет (0 -1)
  5.    var K = 0 .1; // постоянная (изменение интенсивности с расстоянием от источника (0 -1))
  6.    var ks = 0 .5;
  7.   
  8.    var light = {}, s = {}, lightVect = {}, pointVect = {}, normal = {};
  9.    light.x = 0 , light.y = 0 , light.z = 1 ; // координаты источника света (x,y,z : 0 -1)
  10.    s.x = 0 , s.y = 0 , s.z = 0 .1; // точка наблюдения
  11.    var n = 3 ;

This code was highlighted with code.xnim.ru


Затем получаем координаты вершиныи в виде вектора, нормализуем полученный вектор и строим вектор от вершины до источника света.
  1.    // координаты вершины, для которой вычисляется освещенность
  2.    pointVect = { x: cube3d[point].point.x, y: cube3d[point].point.y, z: cube3d[point].point.z };
  3.    normalize(pointVect);
  4.    // вектор от вершины на источник света
  5.    lightVect.x = (light.x - pointVect.x);
  6.    lightVect.y = (light.y - pointVect.y);
  7.    lightVect.z = -(light.z - pointVect.z);
  8.    normalize(lightVect);
  9.    normal = cube3d[point].normal;

This code was highlighted with code.xnim.ru


Вычисляем углы наклона и интенсивность света в заданной вершине.
  1. //т.к. вектора нормализованы, то сумму делить не нужно
  2.    var cosFi = lightVect.x * normal.x + lightVect.y * normal.y + lightVect.z * normal.z;
  3.    // между отраженным лучом и вектором наблюдения
  4.    var cosAlpha = -lightVect.x * s.x - lightVect.y * s.y - -lightVect.z * s.z;
  5.    var d = vectorSize(lightVect);
  6.    var val = ambient + (amp * cosFi + ks * Math.pow(cosAlpha,n) ) / (d + K);
  7.    var res = val < 0 ? 0 : val > 1 ? 1 : val;
  8.   
  9.    return res;
  10. }

This code was highlighted with code.xnim.ru


Используемая функция vectorSize вычисляет модуль вектора (квадратный корень из суммы квадратов координат)

visible
Функция определяет видимость заданной грани. Используется алгоритм Робертса. (подробнее почитать о нем можно здесь - http://compgraph.ad.cctpu.edu.ru/roberts.htm)

  1. // отсечение невидимых граней
  2. var visible = function( num)
  3. {
  4.    var j;
  5.    var sum = 0 ;
  6.    for (var i = 0 ; i < fVertex; i++) {
  7.      j = (i == fVertex - 1 ) ? 0 : i + 1 ;
  8.      sum += (cube2d[edges[num][i]].point.x - cube2d[edges[num][j]].point.x) * (cube2d[edges[num][i]].point.y + cube2d[edges[num][j]].point.y);
  9.    }
  10.   
  11.    return (sum > 0 )? 1 :0;
  12. }

This code was highlighted with code.xnim.ru


guroFill
Данная функция и занимается работой с массивом изображения и отрисовывает полученную фигуру.
Изначально функция создает массив точек контура фигуры, которые затем будут соединены линиями попиксельно для отображения фигуры.
  1. //закрашивание полигона методом Гуро. Интенсивность считается только в вершинах, в остальных апроксимируется
  2. // points   - координаты вершин грани
  3. // light   - интенсивности отраженного света в вершинах
  4. // count   - количество точек
  5. // gr_num   - номер грани, для закрашивания цветом из массива цветов
  6. var guroFill=function(pixels, gr_num, pcount)
  7. {
  8.    // будем использовать массив пикселей для построения грани
  9.    var i, next, x, y, x1, x2;
  10.    var I, I2, incr;
  11.    count = 0 ;
  12.    lightTemp = [];
  13.    // создаем растровый массив точек контура с учетом освещенности
  14.    for (i = 0 ; i < pcount; i++) {
  15.      // добавление в массив точек очередного ребра полигона
  16.      next = (i != (pcount - 1 )) ? i + 1 : 0 ;
  17.      makeLine(cube2d[edges[gr_num][i]], cube2d[edges[gr_num][next]]);
  18.    }

This code was highlighted with code.xnim.ru


Отсортируем полученный массив (чтобы осталось провести линии по одной оси для завершения работы)
  1.    // сортируем точки по Y
  2.    lightTemp.sort(function (a, b) {
  3.      return (a.point.y > b.point.y ? 1 : a.point.y == b.point.y ? 0 : -1);
  4.    });

This code was highlighted with code.xnim.ru


И закрашиваем грань: (проходим по всем точкам, и если существует рядом (массив отсортирован) другая точка, различающаяся по оси Y не более чем на 1, то соединяем эти точки. Интенсивность света изменяется линейно при прорисовки от одной точки к другой)
  1. // закрашиваем грань
  2.    for (i = 0 ; i < count - 1 ; i++) {
  3.      y = lightTemp[i].point.y;
  4.      if (y > bottom) bottom = y;
  5.      if (y < up) up = y;
  6.     
  7.      if (y != lightTemp[i + 1 ].point.y) continue;
  8.      x1 = lightTemp[i].point.x, x2 = lightTemp[i + 1 ].point.x;
  9.      I = lightTemp[i].light, I2 = lightTemp[i + 1 ].light;
  10.      //рисуем горизонтальную линию
  11.      if (x1 > x2) {
  12.         var temp = x1;
  13.         x1 = x2;
  14.         x2 = temp;
  15.         temp = I;
  16.         I = I2;
  17.         I2 = temp;
  18.      }
  19.      incr = (I2 - I) / (x2 - x1);
  20.      if (x1 < left) left = x1;
  21.      if (x2 > right) right = x2;
  22.      for (x = x1; x <= x2; x++) {
  23.         //поставить точку
  24.         
  25.         setPoint(pixels, x, y,
  26.            {
  27.              "r" : colors[gr_num][0] * I,
  28.              "g" : colors[gr_num][1] * I,
  29.              "b" : colors[gr_num][2] * I,
  30.              "a" : 255
  31.            }
  32.           );
  33.         //         SetPoint(pixels,x,y,RGB(0*(I),255*(I),0*(I)));
  34.         I += incr; // интерполируем интенсивность
  35.      }
  36.    }

This code was highlighted with code.xnim.ru


Здесь функция setPoint - изменяет элемент массива изображения canvas на заданное значение, если нет выхода за границы canvas

makeLine
На вход функции подается две точки. Функция строит растровый массив точек, который описывает прямую линию от одной точки p1 до другой p2
  1. var makeLine = function( p1,p2)
  2. {
  3.    var dx = Math.abs(p2.point.x - p1.point.x), dy = Math.abs(p2.point.y - p1.point.y);
  4.    var sx = p1.point.x < p2.point.x ? 1 : -1, sy = p1.point.y < p2.point.y ? 1 : -1;
  5.    var t, len = Math.sqrt((dx*dx + dy*dy)), dxc, dyc;
  6.    var x = p1.point.x, y = p1.point.y, prev = -32767;
  7.   
  8.    var error = dx - dy, err;
  9.    for (;;) {
  10.      // Вычисляем интенсивность в точке линии
  11.      dxc = p1.point.x - x, dyc = p1.point.y - y;
  12.      t = Math.sqrt(dxc*dxc + dyc*dyc) / len;
  13.      err = error * 2 ;
  14.      if( y != prev )
  15.      {
  16.         lightTemp[count] = { point: { x: x, y: y }, light: 0 };
  17.         
  18.         lightTemp[count].light = (1 -t) * p1.light + t * p2.light;
  19.         count++;
  20.      }
  21.      prev = y;
  22.      if( x == p2.point.x && y == p2.point.y) break;
  23.       
  24.      if( err > -dy) error -= dy, x += sx;
  25.      if( err < dx)
  26.      {
  27.         lightTemp[count] = { point: { x: x, y: y }, light: 0 };
  28.         lightTemp[count].light = (1 -t) * p1.light + t * p2.light;
  29.         count++;
  30.         error += dx, y += sy;
  31.      }
  32.    }
  33. }

This code was highlighted with code.xnim.ru


Пример полученного куба можно посмотреть здесь: http://xnim.ru/demo/cube/

Данный пример работает весьма плавно и "красиво" в Chrome и Mozilla. Однако можно еще больше оптимизировать код. Для этого например, можно при каждой перерисовке изображения затирать не все изображение, а только ту часть, где находился куб. (необходимо определить крайнюю левую и нижнюю правую вершины. и полученный квадрат зарисовывать прозрачным цветом)
Также можно перевести код на ООП стиль.
2013-06-16