Подсветка синтаксиса шейдеров. Связь между шейдерами и внешними структурами данных. Юнит-тесты для шейдеров, дебаг, рефакторинг, статический анализ кода, и вообще полная поддержка IDE. О том, как всё это получить, в чём подвох, и что прописать в мавене…

Создаем проект.

$ git clone https://github.com/kravchik/senjin

Копируем нативные библиотеки в корень проекта.

$ mvn nativedependencies:copy

Это позволит нам запускать файлы, в которых есть метод “main” по Ctrl+Shift+F10 (в IDEA) непосредственно из окна редактора, не беспокоясь о classpath.

Библиотека работает так: код шейдеров пишется на Groovy, затем транслируется в обычный glsl код. Шейдер в Groovy пишется как обычный код, который можно вызывать из Java. Шейдер использует те же поля и классы что и основная программа. Это позволяет IDE понимать как шейдер и остальной код связаны между собой, они для неё — обычные Groovy и Java классы. В результате имеем следующие удобства:

  • поддержка IDE (рефакторинги, подсветка)
  • статический анализ простых и не очень ошибок
  • отладка шейдера
  • юнит тесты для шейдеров
  • связь между структурами буферов и шейдеров

Но даже если вы используете другие языки — вы всё равно можете держать шейдера в Groovy и Java. Вы не получите связки с остальным проектом, но юнит-тесты, дебаг, поддержка IDE будут доступны. Тогда в основном проекте просто будут использоваться авто-генерируемые файлы с glsl кодом.

Конкретный пример



Покажу основные моменты на примере specular shader (рендеринг “пластмассового материала”) — он достаточно простой, но в нём используются varying, uniform, attributes, текстуры, есть математика, в общем можно пощупать технологию.

Пиксельный шейдер


Это обычный Groovy-класс с методом main. Стандартные opengl-функции шейдер получает в наследство. Uniform переменные объявляются как поля шейдера. Код шейдера находится в функции main, но её объявление отличается от glsl — в явном виде указывается что попадает на вход шейдера (SpecularFi), и куда надо писать результат (StandardFrame). Так же пришлось отказаться от имен вида vec3, vec4, поскольку Groovy не удалось подружить с именами классов начинающихся с маленькой буквы.

public class SpecularF extends FragmentShaderParent<SpecularFi> {
    public Sampler2D txt = new Sampler2D()
    public float shininess = 10;
    public Vec3f ambient = Vec3f(0.1, 0.1, 0.1);
    public Vec3f lightDir

    def void main(SpecularFi i, StandardFrame o) {
        Vec3f color = texture(txt, i.uv).xyz;
        Vec3f matSpec = Vec3f(0.6, 0.5, 0.3);
        Vec3f lightColor = Vec3f(1, 1, 1);

        Vec3f diffuse  = color * max(0.0, dot(i.normal, lightDir)) * lightColor;
        Vec3f r = normalize(reflect(normalize(i.csLightDir), normalize(i.csNormal)));
        Vec3f specular = lightColor * matSpec * pow(max(0.0, dot(r, normalize(i.csEyeDir))), shininess);
        o.gl_FragColor =  Vec4f(ambient + diffuse + specular, 1);
    }
}

Здесь уже можно увидеть достоинства подхода. Делаем небольшую ошибку в названии и IDE сразу сообщает об этом.



Смотрим что попадает на вход пиксельного шейдера (ctrl+space).



Запускаем юнит тест и смотрим в дебаге на вычисления.



Входные данные для пиксельного шейдера


SpecularFi (fragment input). Класс содержащий данные, являющиеся исходящими для вертексного шейдера и входящими для пиксельного.

public class SpecularFi extends BaseVSOutput {
   public Vec3f normal;
   public Vec3f csNormal;//cam space normal
   public Vec3f csEyeDir;
   public Vec2f uv;
   public Vec3f csLightDir;//cam space light dir
}

Вертексный шейдер


Так же как и пиксельный шейдер — это Groovy-класс, с uniform переменными в полях и методом main с явным указанием классов входящих и исходящих данных.

class SpecularV extends VertexShaderParent<SpecularVi, SpecularFi> {
   public Matrix3 normalMatrix;
   public Matrix4 modelViewProjectionMatrix;
   public Vec3f lightDir

   void main(SpecularVi i, SpecularFi o) {
       o.normal = i.normal
       o.csNormal = normalMatrix * i.normal
       o.gl_Position = modelViewProjectionMatrix * Vec4f(i.pos, 1)
       o.csEyeDir = o.gl_Position.xyz
       o.uv = i.uv
       o.csLightDir = normalMatrix * lightDir
   }
}

Входные данные для вертексного шейдера


SpecularVi (vertex input). Класс попадающий на вход вертексному шейдеру. Его же можно использовать для заполнения буфера данных, код которого без участия программиста договорится с кодом шейдера (прощайте glGetAttribLocation, glBindBuffer, glVertexAttribPointer и другие потроха).

public class SpecularVi {
    public Vec3f normal;
    public Vec3f pos;
    public Vec2f uv;
}

Создание вертексного и пиксельного шейдера и объединение их в программу:

SpecularF fragmentShader = new SpecularF();
SpecularV vertexShader = new SpecularV();
GShader shaderProgram = new GShader(vertexShader, fragmentShader);

Как видно, их создание — это обычное инстанциирование классов. Шейдера оставляем в переменных чтобы позднее передать в них данные (степень блеска, направление света, и др.).

Далее создаётся буфер с данными. Здесь используется тот же класс, что попадал на вход вертексному шейдеру.

ReflectionVBO vbo1 = new ReflectionVBO();
vbo1.bindToShader(shaderProgram);
vbo1.setData(al(
       new SpecularVi(v3(-5, -5, 0), v3(-1,-1, 1).normalized(), v2(0, 1)),
       new SpecularVi(v3( 5, -5, 0), v3( 1,-1, 1).normalized(), v2(1, 1)),
       new SpecularVi(v3( 5,  5, 0), v3( 1, 1, 1).normalized(), v2(1, 0)),
       new SpecularVi(v3(-5,  5, 0), v3(-1, 1, 1).normalized(), v2(0, 0))));
vbo1.upload();

Заполнение входных данных для шейдеров. Передача параметров — просто выставление значений полей в Groovy-объектах шейдеров (которые предусмотрительно остались доступны в виде переменных).

fragmentShader.shininess = 100;
vertexShader.lightDir = new Vec3f(1, 1, 1).normalized();
  //enable texture
texture.enable(0);
fragmentShader.txt.set(texture);
  //give data to shader
shaderProgram.currentVBO = vbo1;

И, собственно, подключение шейдера и отрисовка.

shaderProgram.enable();
indices.draw();

Юнит-тест шейдера.

f.main(vso, frame);
assertEquals(1, frame.gl_FragColor.w, 0.000001);
assertEquals(1 + 0.1 + 0.6, frame.gl_FragColor.x, 0.0001);
assertEquals(1 + 0.1 + 0.5, frame.gl_FragColor.y, 0.0001);
assertEquals(1 + 0.1 + 0.3, frame.gl_FragColor.z, 0.0001);

Весь код примера находится тут.

Test.java //простой юнит-тест шейдера
RawSpecular.java //простейший мейник создающий картинку для хабра
SpecularF.groovy //пиксельный шейдер
SpecularV.groovy //вертексный шейдер
SpecularVi.java //класс, описывающий вертекс (specular Vertex shader Input)
SpecularFi.java //класс, описывающий данные идущие из вертексного в пиксельный (specular Fragment shader Input) шейдер
WatchSpecular.java //более сложный мейник с кнопками, мышью, и прочим, усложняющим понимание и улучшающим экспириенс

Библиотеку легко подключить через Maven:

    <dependencies>
        <dependency>
            <groupId>yk</groupId>
            <artifactId>senjin</artifactId>
            <version>0.11</version>
        </dependency>
    </dependencies>

    <repositories>
        <repository>
            <id>yk.senjin</id>
            <url>https://github.com/kravchik/mvn-repo/raw/master</url>
        </repository>
    </repositories>

Ну и коротко о синтаксических отличиях:

  1. в теле шейдера используется Vec3f вместо vec3 (груви не удалось подружить с классом начинающимся с маленькой буквы)
  2. нет uniform — вместо них просто поля в шейдере
  3. нет varying, in, out — вместо них поля в классах, передаваемых в main

P.S. Проект я развиваю стихийно — то понадобится что-то, то просто интересно что-то сделать. Пока не успел сделать структуры и много чего другого. Если вам необходим какой-то функционал или направление развития (android? geometry shaders? kotlin?) — обращайтесь, обсудим!

Так же хочу выразить благодарность oshyshko и olexiy за помощь в написании статьи.

Комментарии (11)


  1. vektory79
    27.10.2015 11:22

    ВАУ! Вы прям мои мысли прочитали. Сам давно думаю над подобным, но всё никак не соберусь с духом. А тут уже такой задел хороший.

    Правда все мои эксперименты крутятся вокруг геометрических шейдеров. Так что их точно хотелось бы увидеть (или доделать :).

    По поводу Kotlin — тоже интересно. Нужно будет на досуге глянуть что да как у Вас сделано. Сейчас как раз развлекаюсь с переписыванием некоторых хитрых алгоритмов на котлин. Очень результат нравится.

    И ещё вопрос: а у вас шейдеры какой версии генерируются? Лично меня интересуют возможности 4.2 и выше. Но в тоже время я понимаю, что это только мой интерес. Большинство сейчас на Android затачивается и в результате ограничивает себя исключительно версиями около 3.3. Возможно имеет смысл предусмотреть какой-то способ указания версии шейдеров.

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

    P.S. Огромное спасибо за статью. Буду напосмотреть.


    1. jorgen
      27.10.2015 12:51

      Рад что не только мне это интересно!
      Сейчас версия шейдера — 130. Да, хочется поддерживать разные версии, и геометрический шейдер — хороший повод :)
      Я абсолютно не представляю что можно рассказать об истории разработки. Может перечислите что было бы интересно услышать лично вам?


      1. vektory79
        27.10.2015 14:30
        +1

        Ну, например, бегло пробежавшись по исходникам, даже не понял толком, как это работает. Хотя толком не вникал. Было бы интересно получить некоторую развёрнутую информация как реализован функционал. Какие и как использовались технологии. Чтобы другим было легче разобраться и, возможно, попробовать что-либо добавить.

        Ну как-то так. Извиняюсь, если слишком сумбурно. Просто тема очень взбудоражила, а работа тоже не ждёт…


        1. jorgen
          27.10.2015 15:16

          Люблю сумбурные ТЗ, они — декларативны :)


        1. jorgen
          27.10.2015 16:40

          Могу поотвечать на вопросы в чатике


          1. vektory79
            27.10.2015 16:46

            Большое спасибо. Как начну разбираться — обязательно обращусь. Спасибо!


  1. fogone
    27.10.2015 12:38

    Хороший концепт, я думал о подобной компиляции котлина в шейдер по аналогии с джаваскриптом, но конечно руки всё никак не доходят.


    1. jorgen
      27.10.2015 14:24

      Согласен, Kotlin был бы в трэнде :) Тоже смотрел на него, но не могу выделить время чтобы разобраться как из него AST выдрать. На stackoverflow не знают. Если умелец найдётся — соберём за пару дней.


      1. vektory79
        27.10.2015 14:36

        Да с AST там не сильно понятно. Создаётся впечатление, что это должно быть где-то на поверхности, но когда попытался разобраться, то быстро заблудился. Возможно опыт личных навыков в этой области не хватило. Так-то у них даже есть модуль под названием kotlin-compiler-embeddable. Но никакой информации по поводу него найти не удалось.
        Скорее всего где-то там AST искать надо. Если будет время вечером — попробую сделать ещё один заход в ту сторону. По крайней мере теперь есть конкретная мотивация.


        1. fogone
          28.10.2015 12:13

          Вот здесь видно как создается аст.

          KotlinCoreEnvironment environmentForJS = 
                KotlinCoreEnvironment.createForProduction(rootDisposable, configuration, EnvironmentConfigFiles.JS_CONFIG_FILES);
          


          Сам аст для всех файлов получается несколькими строками ниже.
          List<KtFile> sourcesFiles = environmentForJS.getSourceFiles();
          


          1. vektory79
            28.10.2015 14:24
            +1

            Да. Я вчера весь вечер разбирался что там да как. Больше всего потратил на то, чтобы понять как сформировать configuration правильно.

            Просто изначально не догадался начать с kotlin-maven-plugin. Который простой как палка.