Dart и WebGL

Создание головоломки на языке Dart при помощи WebGL

4 May 2014 г. 22:50:58

В данной статье будет показано как при помощи dart и webgl создать простую головомку puzzle.

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

Готовый пример можно посмотреть тут, а исходники доступны на гитхабе

Создание пазла

Создание формы деталей головоломки

Генерация деталей головоломки в Webgl

Использование текстур в шейдере

Выбор деталей

Перемещение и поворот деталей

Создание пазла

Основной структурой данных для хранения головоломки будет класс Puzzle, который содержит массив элементов. Каждый элемент это класс PyzzleElement который содержит координаты элемента, а так же информацию о каждой стороне, в виде числа:

  • 0: прямая поверхность

  • -1: вогнутая поверхность

  • 1: выпуклая поверхность

class PuzzleElement{ 
  int x, y; 
  int left, top, bottom, right; 
  PuzzleElement(this.x, this.y, this.left, this.top, this.right, this.bottom){} 
}

class Puzzle{ 
  int sizex, sizey; 
  List<list> elements; 
  TextureMask mask;

  int randEdge(){ 
    Math.Random random = new Math.Random(); 
    return random.nextBool()?1:-1; 
  } 
  int reverseEdge(edge){ 
    return edge <0?1:-1; 
  }

  Puzzle(this.sizex, this.sizey){ 
    elements = new List<list>(); 
    for (int y = 0; y < this.sizey; y++){ 
      elements.add(new List()); 
      for (int x = 0; x < this.sizex; x++){ 
        int left = x==0 ?0:reverseEdge(elements[y][x-1].right); 
        int top = y==0 ?0:reverseEdge(elements[y-1][x].bottom); 
        int right = x==this.sizex-1 ?0:randEdge(); 
        int bottom = y==this.sizey-1 ?0:randEdge(); 
        elements[y].add(new PuzzleElement(x, y, left,top,right, bottom)); 
      } 
    } 
    //Создадим текстуру для пазла 
    mask = new TextureMask(this); 
  } 
}

Создание формы деталей головоломки

Пазл состоит из элементов которые подходят друг к другу, у каждого пазла должна быть своя форма. Не обязательно, но желательно иметь возможность деформировать детали головоломки, таким образом чтобы одинаковых деталей не было. Однако каждый элемент состоит из квадрата и выпуклой или вогнутой части. Так как все трехмерные объекты состоят из треугольников, можно создать из треугольников объекты такой формы:

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

Таким образом мы можем использовать детали любой формы, и можем деформировать их как угодно. Но для создания текстуры надо будет работать с растровой графикой. К счастью в арсенале Dart есть, библиотека package:image/image.dart которая позволяет работать с изображениями различных форматов.

Создадим класс TextureMask который будет генерировать текстуры следующего вида:

Для создание формы деталей пазла используются следующие константы.

final int TEXTURE_MASK_SIZE = 2048;
final int MASK_SIZE = 99;
final int SQUARE_SIZE = 69;
final int CIRCLE_SIZE = 14;
final int MASK_OFFSET = 1;

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

  void fillRange(Img.Image image, int x, int y, int color){
//Рекурсивно вызываем функцию во всех направлениях
    image.setPixel(x, y, color);
    if (image.getPixel(x+1, y  ) != color) fillRange(image, x+1, y  ,color);
    if (image.getPixel(x-1, y  ) != color) fillRange(image, x-1, y  ,color);
    if (image.getPixel(x  , y+1) != color) fillRange(image, x  , y+1,color);
    if (image.getPixel(x  , y-1) != color) fillRange(image, x  , y-1,color);    
  }

//рисуем окружность и закрашиваем внутренность
  void drawFillCircle(x, y, color){
    Img.drawCircle(this.image, x, y, CIRCLE_SIZE, color);
    fillRange(this.image, x, y, color);
  }

В конструкторе создадим новое исображение текстуры размером TEXTURE_MASK_SIZE*TEXTURE_MASK_SIZE и создадим текстуру на основе массива элементов.

  void createEdge(int x, y, type){
    if (type == 0)
      return;

    drawFillCircle(x, y, TRANSPARENT_COLOR);
    if (type ==1)
      drawFillCircle(x, y, TEXCOLOR);
  }

  puzzleTex(int x, y, PuzzleElement e){
//Вычислим смещение по текстуре   
    int offset = (MASK_SIZE - SQUARE_SIZE) ~/ 2;
    int xOffset = x*MASK_SIZE+MASK_OFFSET*e.x;
    int yOffset = y*MASK_SIZE+MASK_OFFSET*e.y;
    Img.fillRect(this.image, 
        xOffset + offset, 
        yOffset + offset, 
        xOffset + MASK_SIZE-offset, 
        yOffset + MASK_SIZE-offset, 
        TEXCOLOR);
    //Найдем центр элемента пазлв
    int xCenter = xOffset + (MASK_SIZE ~/ 2);
    int yCenter = yOffset + (MASK_SIZE ~/ 2);
//создаем четыре стороны пазла
    createEdge(xOffset + CIRCLE_SIZE, yCenter, e.left);
    createEdge(xCenter              , yOffset+CIRCLE_SIZE,  e.top);
    createEdge(xOffset + MASK_SIZE - CIRCLE_SIZE-1, yCenter, e.right);
    createEdge(xCenter              , yOffset + MASK_SIZE - CIRCLE_SIZE-1, e.bottom);
  }

Сохранить изображение можно в любом формате, я выбрал PNG, т.к. он сохраняет изображения без потери качества индексируя цвета. И создадим в DOM элемент изображения, с только что получившейся текстурой.

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

Генерация деталей головоломки в Webgl

Пришло время заняться выводом деталей головоломки при помощи WebGL. Каждая деталь головоломки представляет собой прямоугольник, из руководства знакомство с WebGL, количество деталей рассчитывается, на основе константы PUZZLE_COUNT. Создадим класс WebGLPuzzleElement который наследуется от класса Quad. В качестве параметров WebGLPuzzleElement будет принимать, контекст, шейдер и элемент который надо отрисовывать.

class WebGLPuzzleElement extends Quad{
    PuzzleElement element;
    WebGLPuzzleElement._internal (gl, shader, x, y, w, h, angle, color, this.element, this.texture, this.textureMask): super(gl, shader, x, y, w, h, angle, color){}
    factory WebGLPuzzleElement (WebGL.RenderingContext gl, Shader shader, PuzzleElement element, texture, textureMask) {
        final e = new WebGLPuzzleElement._internal(gl, shader,
            PUZZLE_SIZE*element.x, 500-PUZZLE_SIZE*element.y,
            PUZZLE_SIZE, PUZZLE_SIZE, 
            0.0,
            new Vector4(element.x/PUZZLE_COUNT, element.y/PUZZLE_COUNT, 0.0,1.0));
        return e;
    }
}

В качестве конструктора используется фабрика, которая вычисляет координаты и размер прямоугольников на основе свойств элемента и создает экземпляр Quad.

Создаем массив элементов вызывая в конструкторе следующую функцию:

void createQuadArray(){ 
for(int y =0; y < puzzle.sizey; y++ )
for(int x =0; x < puzzle.sizex; x++ ){
WebGLPuzzleElement q = new WebGLPuzzleElement(gl, quadShader,
puzzle.elements[y][x],
texture, textureMask);
quads.add(q);
}
}

И отрисовываем

for(int i=0; i<quads.length; i++){ 
quads[i].render();
};

В результате должно появится следующее:

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

WebGL.Texture textureMask;

Для загрузки текстуры вызовем в конструкторе следующую функцию

void loadTextures(){
    //инициализируем переменную текстуры
    this.textureMask = gl.createTexture();
    //связываем буффер с текстурой
    //тем самым даем WebGL понять что все следующие команды относятся
    //к заданной текстуры
    gl.bindTexture(WebGL.TEXTURE_2D, this.textureMask);
    //следующеи 2 комманды указывают на параметры врапинга
    //как должна выглядеть текстура, если она выходит за область [0,1]
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_WRAP_S, WebGL.CLAMP_TO_EDGE);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_WRAP_T, WebGL.CLAMP_TO_EDGE);
    //параметры интерполяции текстуры
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MIN_FILTER, WebGL.LINEAR);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MAG_FILTER, WebGL.LINEAR);
    //связываение текстуры с конкреным изображением
    gl.texImage2D(WebGL.TEXTURE_2D, 0, WebGL.RGBA, WebGL.RGBA, WebGL.UNSIGNED_BYTE, puzzle.mask.imgElement);
    //передаем текстуру в виде униформа. 
    //последняий параметр это уровень.
    gl.uniform1i(gl.getUniformLocation(quadShader.program, 'uPuzzleMask'), 1);
}

И активизируем текстуру первого уровня добавив следующую функцию в конструктор WebGLPuzzleElement.

setTexture(){
    int aVertexTextureCoords = gl.getAttribLocation(shader.program, 'aVertexTextureCoords');
    gl.enableVertexAttribArray(aVertexTextureCoords);
    gl.vertexAttribPointer(aVertexTextureCoords, 3, WebGL.RenderingContext.FLOAT, false, 0, 0);

    gl.activeTexture(WebGL.TEXTURE1);
    gl.bindTexture(WebGL.TEXTURE_2D, this.textureMask);

}

Использование текстур в шейдере

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

На первый взгляд это кажется странным, однако этот факт является очень удобным, так как фактический размер текстуры (в пикселях) не имеет значения. И мы можем легко подменять текстуры, допустим для слабых компьютеров выводить текстуры 256x256, а для мощных компьютеров 1024x1024 или больше.

Процесс настройки параметров применения текстуры к поверхности называется враппингом. Враппинг вычисляется в шейдере вершин и получившиеся значения передаются во фрагментарный шейдер при помощи переменной типа varying.

Для начала посмотрим как текстура применяется к поверхности размером от 0 до 1. для этого в шейдере вершин добавим следующий код:

...
varying vec2 vTextureMaskCoord;
...
void main() 
{
...
    vTextureMaskCoord = aVertexTextureCoords;
...
}

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

...
varying vec2 vTextureMaskCoord;
uniform sampler2D uPuzzleMask;
...
void main() {
...
    gl_FragColor = texture2D(uPuzzleMask, vTextureMaskCoord);
...
}

После этого программа должна выдать следующее:

 

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

uniform mat4 uTexMaskMatrix; 
...
vTextureMaskCoord = (uTexMaskMatrix*vec4(aVertexTextureCoords, 1.0, 1.0)).xy;

Матричные преобразования для матрицы текстуры аналогичны матричным преобразованиям для матрицы объекта. Все что надо сделать это изменить масштаб таким образом, чтобы перейти от размерности [0,1] к [0, 2048]. Переместиться в нужное место и опять изменить масштаб, оставив в диапазоне [0, 1] только тот фрагмент, который необходимо вывести. Поэтому добавим в функцию setTexture следующее:

texMaskMatrix.setIdentity();
//изменяем масштаб чтобы перейти от размерности [0,1]
texMaskMatrix.scale(1.0/TEXTURE_MASK_SIZE, 1.0/TEXTURE_MASK_SIZE);
//перемещаемся в нужное место, которое зависит от положения элемента
texMaskMatrix.translate(1.0*(element.x * MASK_SIZE)+MASK_OFFSET*element.x, 
                            1.0* TEXTURE_MASK_SIZE-MASK_SIZE-MASK_OFFSET*element.y - (element.y * MASK_SIZE));
//масштабируем еще раз
texMaskMatrix.scale(1.0*MASK_SIZE, 1.0*MASK_SIZE);

после этого должно получиться следующее:

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

Чтобы смешать 2 текстуры возьмем, цвет из текстуры с картинкой, а прозрачность из текстуры с маской:

gl_FragColor = vec4(uColor.rgb, uColorMask.r );

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

gl.enable(WebGL.BLEND);
gl.blendFunc(WebGL.SRC_ALPHA, WebGL.ONE_MINUS_SRC_ALPHA);

Выбор деталей

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

К счастью есть более простые методы. Один из них это выбор элемента по его цвету. В WebGL есть функция readPixels которая считывает в массив цвета в заданных координатах холста. Таким если бы мы могли выводить каждый элемент уникального цвета, то считав цвет буфера мы можем понять какой элемент под курсором.

Но как быть с освещением, которое меняет цвет. Или с текстурами, как в нашем случае. К счастью эта проблема имеет достаточно простое решение. По умолчанию изображение выводится в render buffer, и отображается на экран, но мы можем создать не отображаемый frame buffer и выводить разноцветные элементы во фрейм буфер, а в render buffer выводить элементы с текстурами.

Основным достоинством в данном методе является его простота, однако у него много недостатков. И главный в том, что мы вынуждены дважды отрисовывть изображение (один раз на экран, второй раз во фрейм буфер). Мы должны заботиться о том, чтобы данные в этих двух буфферах были идентичны. К тому же эта логика должна быть не только в основной программе, но и в шейдере.

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

ColorMask  = texture2D(uPuzzleMask, vTextureMaskCoord);
if (isVisibleRender == 1.0){
    ColorTexture = texture2D(uSampler, vTextureCoord);
    if (uCurrent == 1.0)
        gl_FragColor = vec4(ColorTexture.rgb, ColorMask.r );
}
else
    gl_FragColor = vec4(v_uniqueColor.rgb, ColorMask.r);

Таким образом если (isVisibleRender == 1.0) все работает как и раньше, но когда выводятся изображения во фрейм буфер картина будет следующая:

Далее необходимо создать фрейм буфер.

void createFrameBuffer(){
    framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, framebuffer);    
    WebGL.Texture rttTexture = gl.createTexture();
    gl.bindTexture(WebGL.TEXTURE_2D, rttTexture);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MAG_FILTER, WebGL.NEAREST);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MIN_FILTER, WebGL.NEAREST);
    gl.texImage2D(WebGL.TEXTURE_2D, 0, WebGL.RGBA, canvas.width, canvas.height, 0, WebGL.RGBA, WebGL.UNSIGNED_BYTE, null);
    WebGL.Renderbuffer renderbuffer = gl.createRenderbuffer();
    gl.bindRenderbuffer(WebGL.RENDERBUFFER, renderbuffer);
    gl.renderbufferStorage(WebGL.RENDERBUFFER, WebGL.DEPTH_COMPONENT16, canvas.width, canvas.height);
    gl.framebufferTexture2D(WebGL.FRAMEBUFFER, WebGL.COLOR_ATTACHMENT0, WebGL.TEXTURE_2D, rttTexture, 0);
    gl.framebufferRenderbuffer(WebGL.FRAMEBUFFER, WebGL.DEPTH_ATTACHMENT, WebGL.RENDERBUFFER, renderbuffer);
    gl.bindTexture(WebGL.TEXTURE_2D, null);
    gl.bindRenderbuffer(WebGL.RENDERBUFFER, null);
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, null);        
}

Теперь в функции render, когда необходимо выводить во фрейм буфер. Надо просто связать его с переменной framebuffer, а когда он больше не нужен - освободить:

void createFrameBuffer(){
    framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, framebuffer);    
    WebGL.Texture rttTexture = gl.createTexture();
    gl.bindTexture(WebGL.TEXTURE_2D, rttTexture);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MAG_FILTER, WebGL.NEAREST);
    gl.texParameteri(WebGL.TEXTURE_2D, WebGL.TEXTURE_MIN_FILTER, WebGL.NEAREST);
    gl.texImage2D(WebGL.TEXTURE_2D, 0, WebGL.RGBA, canvas.width, canvas.height, 0, WebGL.RGBA, WebGL.UNSIGNED_BYTE, null);
    WebGL.Renderbuffer renderbuffer = gl.createRenderbuffer();
    gl.bindRenderbuffer(WebGL.RENDERBUFFER, renderbuffer);
    gl.renderbufferStorage(WebGL.RENDERBUFFER, WebGL.DEPTH_COMPONENT16, canvas.width, canvas.height);
    gl.framebufferTexture2D(WebGL.FRAMEBUFFER, WebGL.COLOR_ATTACHMENT0, WebGL.TEXTURE_2D, rttTexture, 0);
    gl.framebufferRenderbuffer(WebGL.FRAMEBUFFER, WebGL.DEPTH_ATTACHMENT, WebGL.RENDERBUFFER, renderbuffer);
    gl.bindTexture(WebGL.TEXTURE_2D, null);
    gl.bindRenderbuffer(WebGL.RENDERBUFFER, null);
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, null);        
}

И наконец создадим функцию считывающую данные из фрейм буфера.

int getCurrentElement(int x, y){
    Uint8List pixels = new Uint8List(4);
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, framebuffer);
    gl.readPixels(x, y, 1, 1, WebGL.RGBA, WebGL.UNSIGNED_BYTE, pixels);
    gl.bindFramebuffer(WebGL.FRAMEBUFFER, null);

    if (pixels[3] == 255)
        return (pixels[0]/(255/PUZZLE_COUNT)).round()+((pixels[1]/(255/PUZZLE_COUNT)).round()*PUZZLE_COUNT);
    else
        return -1;
}

Перемещение и поворот деталей

Для перемещения и поворота деталей воспользуемся стандартными событиями мыши в Dart:html:

this.canvas.onMouseDown.listen(onDown);
this.canvas.onMouseMove.listen(onMove);
this.canvas.onMouseUp.listen(onUp);

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

void onDown(e){
    moveSartX = e.client.x - canvas.offsetLeft;
    moveSartY = canvas.height - e.client.y + canvas.offsetTop;
    var index = getCurrentElement(moveSartX, moveSartY);
    if (index >= 0)
    {
        switch (e.button){
            case 0:
                isMove = true;
                this.move.clear();
                this.move.add(index);
                elemenMoveX = quads[index].x;
                elemenMoveY = quads[index].y;
                break;
            case 2:
                this.move.clear();
                this.move.add(index);
                elemenMoveX = quads[index].x;
                elemenMoveY = quads[index].y;
                elementMoveAngle = quads[index].angle;
                isRotate = true;
        }
    }
}

void onMove(e){
    int index = getCurrentElement(e.client.x - canvas.offsetLeft, canvas.height - e.client.y + canvas.offsetTop);
    querySelector("#header").innerHtml = "index: $index";
    if (index >=0)
        this.currentElement = index;
    else
        this.currentElement = -1;

    if (!isMove&&!isRotate)
        return;
    int x = this.moveSartX - e.client.x + canvas.offsetLeft;
    int y = this.moveSartY - (canvas.height - e.client.y + canvas.offsetTop);
    if (isMove){
        isRotate = false;
        this.move.forEach((i){
            this.quads[i].x = elemenMoveX - x;
            this.quads[i].y = elemenMoveY - y;
        });
    }
    if (isRotate){
        isMove = false;
        this.move.forEach((i){
            double angle = (elementMoveAngle - (y -x)/2) % 360;
            [0.0,90.0,180.0,270.0].forEach((a){
                if ((a - angle).abs() < 5.0) angle = a;
            });
            this.quads[i].angle = angle;
        });
    }
}

Оставьте свой комментарий

comments powered by Disqus
Меню

Cult of digits 2014 Яндекс.Метрика