habrahabr

3D на D

  • понедельник, 19 января 2015 г. в 02:10:57
http://habrahabr.ru/post/248119/

Доброго времени суток, хабр!

С языком D я познакомился на 3 курсе, но решил его использовать только через год, когда вышла книга Александреску. Сразу взялся писать лабораторные, курсовые. Основной проблемой являлось отсутствие нужных библиотек (графика, удобная математика) или неудобная их установка. Сейчас многое изменилось, пишутся библиотеки (gfm, dlib, dlangui и тд), появился dub. С одной из таких библиотек хочу познакомить в этом посте. Имя ей DES. Выросла она из институтских наработок, поэтому, возможно, кому-то она будет полезной в учёбе и/или станет катализатором изучения языка.Сразу следует оговорить: статья для новичков, всё предельно просто.

Напишем простое приложение, рисующее на экране некий абстрактный mesh.




Первое, что обычно делается, создаётся dub.json с необходимыми настройками.
Скрытый текст
{
    "name": "example",
    "targetPath": "bin",
    "targetType": "executable",
    "sourcePaths": [ "src" ],
    "dependencies": { "des": ">=1.3.0" }
}


Подробней про dub можно прочитать здесь.

Создадим пару папок:
  • src — здесь будут лежать исходники
  • data — здесь будет файл с шейдером

Займёмся исходниками.

Файл src/main.d
import des.app; // модуль предоставляет базовую работу с SDL: окна, события мыши, клавиатуры, джойстика, создание GL контекста и тд
import window; // будет разобран ниже
void main()
{
    auto app = new DesApp;
    // передаём делегат, создающий окно, спорное архитектурное решение, но оно имело обоснование
    app.addWindow({ return new MainWindow; });
    // основной цикл
    while( app.isRuning )
        app.step(); // обработка событий, отрисовка и всё остальное
}


Класс DesApp занимается инициализацией SDL, GL, перенаправлением событий SDL в окна, хранением как таковых окон.

Перейдём к более интересным вещам

Файл src/window.d
module window;
import des.app;
import draw; // класс Sphere
import camera; // класс MCamera 

// класс главного окна
class MainWindow : DesWindow
{
protected:
    MCamera cam; // позволит осматривать объект с помощью мыши
    Sphere obj; // объект, который будет рисоваться

    /+ вызывается после конструктора, когда DesApp позаботится о создании OpenGL контекста
        здесь нужно проводить всю подготовительную работу: создавать объекты, настраивать GL, соединять сигналы
     +/
    override void prepare()
    {
        cam = new MCamera;

        obj = newEMM!Sphere( 1, 12, 12 ); // эта странная конструкция будет объяснена позже

        // соединяем сигналы с делегатами и функциями, которые потом оборачиваются в слоты
        connect( draw, { obj.draw( cam ); } );
        connect( key, &keyControl );
        // возлагаем ответственность за обработку мыши непосредственно на камеру
        connect( mouse, &(cam.mouseReaction) );

        // изменение соотношения сторон должно перестраивать перспективную матрицу
        connect( event.resized, (ivec2 sz)
        { cam.ratio = cast(float)sz.w / sz.h; } );
    }

    // функция обработки нажатия клавиш
    void keyControl( in KeyboardEvent ke )
    {
        if( ke.scan == ke.Scan.ESCAPE ) app.quit(); // каждое окно имеет доступ к экземпляру класса DesApp, в котором содержится
    }

public:
    // передаётся title окна, размер, и открывать ли окно в полноэкранном режиме
    this() { super( "example", ivec2(800,600), false ); }
}


Начнём пояснение со странной конструкции
obj = newEMM!Sphere( 1, 12, 12 );

И начнём издалека. В языке D есть неприятная особенность, связанная со сборщиком мусора: нельзя управлять памятью в деструкторе. А иногда хочется. Да и сам деструктор вызывается не явно. EMM — сокращение от ExternalMemoryManager — управление внешней памятью. Это базовый интерфейс практически для всех классов в библиотеке. Под внешней памятью понимаются объекты, которые деинициализировать с помощью GC затруднительно. Например, это буферы OpenGL, указатели на SDL окна и тд. В ExternalMemoryManager могут храниться другие, дочерние объекты, реализующие этот интерфейс. Вся соль в методе destroy, который вызывает selfDestroy и destroy для всех дочерних объектов. Получается некая модель владения объектом: при деинициализации родителя деинициализируются все дочерние объекты. Для добавления новоиспечённого объекта bar в качестве дочернего в foo нужно написать такой код:
auto bar = new Bar;
foo.registerChildEMM( bar );

Такая ситуация возникает часто, и для сокращения было решено вынести это в метод интерфейса ExternalMemoryManager:
auto bar = foo.newEMM!Bar();

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

Перейдём к сигналам. DesWindow является наследником от DesObject, в котором и объявлен метод connect. Каждый DesObject является хранителем такой концепции как SlotHandler. При создании объекта Slot из делегата так же передаётся SlotHandler, без которого это невозможно. И уже объект Slot передаётся в сигнал, для регистрации. Может возникнуть вопрос «Почему просто не записывать делегаты в массив?». Такие сложности введены в архитектуру для последующего удобства. Когда мы просто записываем делегат в массив, то всё в порядке, ссылка на контекст хранится, GC объекты не трогает, но тут другая ситуация. Объект, реализующий ExternalMemoryManager может быть ещё и существует, но вот вызов любого из его методов перестаёт быть целесообразным после вызова destroy(). Например, мы можем высвободить OpenGL буфер к этому моменту, а делегат в сигнале будет записан. Не хорошо. SlotHandler как раз и есть олицетворение контекста через ExternalMemoryManager. Он является дочерним по отношению к какому-либо DesObject'у и при его удалении он сам разъединяет себя со всеми соединёнными сигналами, поэтому у нас нет нужды об этом беспокоиться. Есть и у сигнала метод connect и мы могли бы написать так
draw.connect( this.newSlot( { obj.draw( cam ); } );

В любом случае нам нужен тот, на кого можно возложить ответственность за валидность контекста делегата.

ivec2 это просто alias для Vector!(2,int,«x y|w h|u v»)…

Далее файл src/draw.d
Он уже чуть объёмней
module draw;

import std.math;

import des.math.linear; // вектора, матрицы
import des.util.stdext.algorithm; // amap это просто array( map ... )
import des.util.helpers; // appPath -- путь до исполняемого файла

import des.space; // камера, SpaceNode, построение перспективы

import des.gl; // работа с OpenGL

class Sphere : GLSimpleObject, SpaceNode
{
    /+ реализует интерфейс SpaceNode
        вставляет приватную матрицу трансформации self_mtr и реализует свойство matrix для доступа к ней
     +/
    mixin SpaceNodeHelper;

protected:

    // буферы с данными
    GLBuffer pos, ind;

    void prepareBuffers()
    {
        // получаем адрес атрибута в шейдере по имени
        auto loc = shader.getAttribLocations( "pos" );
        
        // методы createArrayBuffer и createIndexBuffer относятся только к GLSimpleObject, в GLObject их нет
        pos = createArrayBuffer; // здесь будут храниться точки
        ind = createIndexBuffer;  // а здесь порядок их отрисовки
        // выставляем связь между буфером и атрибутом в шейдере
        setAttribPointer( pos, loc[0], 3, GLType.FLOAT );

       // заполняем буфер точек

        vec3[] buf;

        vec3 sp( vec2 a, float R ) { return spheric(a) * R; }

        buf ~= amap!(a=>a+vec3(0,0,0))( amap!(a=>sp(a,R))( planeCoord( uivec2( resU, resV/2 ), vec2(0,PI*2), vec2(PI,PI/2) ) ) );
        buf ~= amap!(a=>a-vec3(0,0,0))( amap!(a=>sp(a,R*0.9))( planeCoord( uivec2( resU, resV/2 ), vec2(0,PI*2), vec2(PI/2,PI) ) ) );

        // передаём на карточку
        pos.setData( buf );
        
        // передаём на карточку
        ind.setData( triangleStripPlaneIndex( uivec2( resU+1, resV+2 ), uint.max ) );
    }

    // параметры полигональной сетки
    uint resU, resV;
    float R;

public:

    this( float r, uint u, uint v )
    {
        R = r;
        resU = u;
        resV = v;
        import std.file;
        super( newEMM!CommonShaderProgram( // создаём шейдер и добавляем его в список дочерних объектов
                parseShaderSource( // функция разбирает 1 файл на несколько шейдеров
                    readText( // стандартная функция
                        appPath( "..", "data", "object.glsl" ) // путь до шейдера относительно бинарного файла
                    ))));
        prepareBuffers();
    }

    void draw( Camera cam )
    {
        // выставляем цвет в шейдере
        shader.setUniform!col4( "col", col4(1,0,0,1) );

        glEnable( GL_PRIMITIVE_RESTART );
        glPrimitiveRestartIndex(uint.max);
        glPolygonMode( GL_FRONT_AND_BACK, GL_LINE );
        // вычисляем матрицу трансформации и передаём её в шейдер
        shader.setUniform!mat4( "prj", cam.projection.matrix * cam.resolve(this) );
        // собственно отрисовка
        drawElements( DrawMode.TRIANGLE_STRIP );
        glDisable( GL_PRIMITIVE_RESTART );
    }
}

// далее функции создания полигональной сетки

vec2[] planeCoord( uivec2 res, vec2 x_size=vec2(0,1), vec2 y_size=vec2(0,1) )
{
    vec2[] ret;

    float sx = (x_size[1] - x_size[0]) / res.u;
    float sy = (y_size[1] - y_size[0]) / res.v;

    foreach( y; 0 .. res.y+1 )
        foreach( x; 0 .. res.x+1 )
            ret ~= vec2( x_size[0] + sx * x, y_size[0] + sy * y );

    return ret;
}

vec3 spheric( in vec2 c ) pure
{ return vec3( cos(c.x) * sin(c.y), sin(c.x) * sin(c.y), cos(c.y) ); }

uint[] triangleStripPlaneIndex( uivec2 res, uint term )
{
    uint[] ret;
    foreach( y; 0 .. res.y-1 )
    {
        ret ~= [ y*res.x, (y+1)*res.x ];
        foreach( x; 1 .. res.x )
            ret ~= [ y*res.x+x, (y+1)*res.x+x ];
        ret ~= term;
    }
    return ret;
}


И файл data/object.glsl
без изысков
//### vert
#version 330
in vec4 pos;
uniform mat4 prj;
void main() { gl_Position = prj * pos; }
//### frag
#version 330
uniform vec4 col;
out vec4 color;
void main() { color = col; }


Немного о камере и SpaceNode. В OpenGL до 3ей версии была концепция стека матриц, отдельно видовые и матрицы модели. Здесь всё немного проще, хотя при желании можно сделать тот же стек. Каждый SpaceNode содержит указатель на родительский SpaceNode и матрицу трансформации из локальной системы координат в родительскую. Родительский SpaceNode может быть и null. Камера — это тоже SpaceNode, но с некоторыми дополнительными полями и методами. Главное отличие это наличие метода resolve, который принимает SpaceNode и возвращает матрицу трансформации от системы координат объекта к системе координат камеры. Для этого используется отдельный класс Resolver. По сути, с его помощью можно смотреть из любого объекта SpaceNode на любой другой.
И последний src/camera.d
module camera;
import std.math;
import des.space; 
import des.app;

class MCamera : SimpleCamera
{
protected:
    vec3 orb;
    vec2 rot;

    float rotate_coef = 80.0f;
    float offset_coef = 50.0f;
    float y_angle_limit = PI_2 - 0.01;

public:
    this()
    {
        super();
        orb = vec3( 5, 1, 3 );
        look_tr.target = vec3(0,0,0);
        look_tr.up = vec3(0,0,1);
        near = 0.001;
        updatePos();
    }

    // обрабатываем события мыши
    void mouseReaction( in MouseEvent ev )
    {
        // колёсиком приближаем
        if( ev.type == MouseEvent.Type.WHEEL )
            moveFront( -ev.whe.y * 0.1 );

        if( ev.type == ev.Type.MOTION )
        {
            // на левую крутим вокруг центра
            if( ev.isPressed( ev.Button.LEFT ) )
            {
                auto frel = vec2( ev.rel ) * vec2(-1,1);
                auto angle = frel / rotate_coef;
                addRotate( angle );
            }
            // на среднюю перемещаемся параллельно плоскости экрана
            if( ev.isPressed( ev.Button.MIDDLE ) )
            {
                auto frel = vec2( ev.rel ) * vec2(-1,1);
                auto offset = frel / offset_coef * sqrt( orb.len );
                moveCenter( offset );
            }
        }
    }

protected:
    void moveFront( float dist )
    {
        orb += orb * dist;
        if( orb.len2 < 1 ) orb = orb.e;
        updatePos();
    }

    void addRotate( in vec2 angle )
    {
        rot = normRotate( rot + angle );
        orb = vec3( cos(rot.x) * cos(rot.y),
                    sin(rot.x) * cos(rot.y),
                    sin(rot.y) ) * orb.len;
        updatePos();
    }

    void moveCenter( in vec2 offset )
    {
        auto lo = (look_tr.matrix * vec4(offset,0,0)).xyz;
        look_tr.target += lo;
        updatePos();
    }

    void updatePos() { pos = orb + look_tr.target; }

    vec2 normRotate( in vec2 r )
    {
        vec2 ret = r;
        if( ret.y > y_angle_limit ) ret.y = y_angle_limit;
        if( ret.y < -y_angle_limit ) ret.y = -y_angle_limit;
        return ret;
    }
}



Вот в общем и всё. Запускаем dub build и получаем бинарник в папке bin.

Понятно, что информации в статье мало, поэтому предлагаю заинтересованым собрать документацию.
Документацию мы ориентировали на проект harbored-mod, поэтому, если вы захотите сгенерировать доку с помощью встроенной в dmd функции, то результат Вас разочарует.
Для unix систем переходим в папку ~/.dub/packages/des-1.3.0 и запускаем path/to/harbored-mod/bin/hmod и открываем doc/index.html в любимом браузере. Так же не забываем про пакет descore, который должен лежать рядом с des, для него таким же образом собирается документация.
Там много интересного, начиная от линейной алгебры, заканчивая системами логирования и локализации.

Кстати, этот пример лежит в папке des-/example/object_draw.

Надеюсь, кому-то это пригодится, спасибо за внимание.