Как оживить картинку в браузере. Многопроходный рендеринг в WebGL

    Каждый, кто сталкивался с трехмерной графикой, рано или поздно открывал документацию на методы отрисовки, которые предполагают несколько проходов рендерера. Такие методы позволяют дополнить картинку красивыми эффектами, вроде свечения ярких пятен (Glow), Ambient occlusion, эффекта глубины резкости.


    И «взрослый» OpenGL, и мой любимый WebGL предлагают богатую функциональность для отрисовки результатов в промежуточные текстуры. Однако управление этой функциональностью — довольно сложный процесс, в котором очень легко получить ошибку на любом из этапов, начиная от создания текстур нужного разрешения до именования юниформ и передачи их в соответствующий шейдер.


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



    Чтобы вы понимали всю серьезность сиутации, коротко расскажу о компании. В Align выпускают продукт, который позволяет людям исправлять улыбки без традиционных брекетов. То есть их непосредственные потребители — это доктора. Это довольно ограниченная аудитория со специфическими запросами, накладывающими фантастические требования на надежность, производительность и качество пользовательского интерфейса. В свое время основным инструментом был выбран С++, но у него было серьезное ограничение: только десктопное приложение, только для Windows. Примерно два года назад начался переход на веб-версию. Возможности современных браузеров и стека технологий позволили быстро и удобно пересоздавать пользовательский интерфейс и адаптировать кодовую базу, которая до этого писалась почти 15 лет. Конечно, это привело к необходимости решать кучу задач на фронте и бэкенде, в том числе — к необходимости оптимизировать объемы данных и скорости загрузки. Этим задачам будут посвящены эта и следующие статьи.


    И дабы дважды не вставать, я постараюсь не загромождать пост исходниками. То есть все, что входит в детали реализации и навевает тоску читателям кода, будет по возможности поскипано или сокращено до чистой, незамутненной идеи. Повествование будет вестись от первого лица, как это рассказывал Василий Ставенко — один из специалистов Align Technology, который согласился приоткрыть нам завесу тайны внутренней кухни WebGL-фронта.


    Описание проблемы


    Для начала стоило бы рассказать, что, собственно, мы хотели реализовать и что для этого требовалось. Наша специфика не подразумевает большое количество визуальных эффектов. Мы решили реализовать Screen Space Ambient Occlusion (или SSAO) и простенькую тень.


    SSAO — это, грубо говоря, подсчет суммарного затенения в точке, окруженной другими точками. Вот суть этой идеи:


    float light = 0;
    float deltaLight;
    for(int astep =0; astep < ANGULAR_STEPS; ++astep){
        vec2 offset = getOffset(astep, ANGULAR_STEPS);
        for (int rstep = 0; rstep < RADIAL_STEPS; ++rstep ){
          float radius = getRadius(rstep, RADIAL_STEPS);
          vec4 otherPointPosition = textureLookup(offset, radius);
          float screenSpaceDistance = length(point.xy - otherPointPosition.xy);
          screenSpaceDistance = max(screenSpaceDistance, 0.00001);
          float deltaHeight = otherPointPosition.z - point.z;
          float lightness = (deltaHeight / screenSpaceDistance); // Суть!
          deltaLight = companyRelatedMagic(lightness);
        }
        light += companyRelatedMagic2(deltaLight);
    }

    Функция textureLookup выбирает из подключенной текстуры пиксель, представляющий собой не цвет, а позицию точки. Далее вычисляем его освещенность как отношение его глубины к удалению от текущего, рисуемого фрагмента в координатах gl_FragCoords. Потом мы делаем особу магию с волшебными числами, чтобы получить значение в нужном диапазоне.


    Получившаяся текстура будет иметь примерно вот такой вид:



    Вот так выглядит окончательный результат:



    Заметно, что SSAO-текстура имеет меньшее разрешение, чем полное изображение. Это сделано нарочно. Сразу после отрисовки позиций в фрагменты мы ужимаем текстуру, и только после этого вычисляем SSAO. Меньшее разрешение означает более быструю отрисовку и процессинг. А значит, что перед тем, как мы будет компоновать конечное изображение, нам надо увеличить разрешение промежуточного изображения.


    Резюмируя, нам необходимо отрисовывать следующие текстуры:


    1. Текстура позиций оригинального разрешения в формате GL_FLOAT
    2. Текстура позиций малого разрешения.
    3. Текстура SSAO малого разрешения.
    4. Размытая текстура SSAO малого разрешения.
    5. Размытая текстура SSAO высокого разрешения.
    6. Текстура маски тени.
    7. Изображение сцены, отрисованное с правильными материалами.

    Зависимость и переиспользование


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


    Отладка


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


    Управление текстурами и фреймбуферами


    Поскольку для нашей работы мы уже используем фреймворк THREE.js, следующие требования уже вытекают из взаимодействия с ним. Мы решили не скатываться в чистый WebGL и задействовали THREE.WebGLRenderTarget, который, к сожалению, дает оверхед фреймбуферов, связывая воедино текстуру и созданный объект фреймбуфера, не позволяя использовать имеющиеся буфера для других текстур. Но даже с этим оверхедом наш рендеринг работает на приемлемых скоростях, а управление таким объектом намного легче, чем управление двумя связанными, но при этом независимыми объектами.


    Управление разрешением текстур


    Нам бы очень хотелось иметь возможность «играться» с параметрами даунсэмплинга, с магией чисел и пределов освещенности и не заморачиваться тем, что надо полностью менять код вывода изображения — менять его разрешения, матрицы и прочие вещи. Поэтому было решено «зашить» механизм сэмплинга в наш менеджер.


    Замена материала перед рендерингом сцены


    Материал всех объектов в THREE.Scene должен быть заменен для отрисовки позиций, с учетом видимости объектов, а потом восстановлен без потерь. Тут следует отметить, что можно было бы воспользоваться параметром Scene.overrideMaterial. Но в нашем случае логика оказалась несколько более сложной.


    Реализация — основная идея


    Что же мы сделали в итоге?
    Во-первых, сделали менеджер, описание которого вы найдете ниже. И написали такие классы, которые автоматически читают шейдеры и смотрят, какие текстуры им нужны для отрисовки самих себя. Менеджер должен уметь понять, что есть зависимости для отрисовки текстуры, и должен отрисовать необходимые зависимости.


    Этот менеджер должен был, по задумке, быть проинициализирован экземплярами класса Pass. То есть понадобится еще один объект, который добавит в него проходы и будет уже application-specific. Из-за того, что в современных WebGL-шейдерах мы не можем задать имя исходящей текстуры, нам пришлось сделать ScreenSpacePass безымянным и давать ему имя при добавлении. А могли бы вычитывать его из текста шейдера.


    Вот такой вот метод:


    addPass(name, pass){
        if(!pass instanceof Pass) throw new Error("Adding wrong class to renderer passes");
        pass.setSceneAndCamera(this.screenSpaceScene, this.camera);
        this.passes.set(name, pass);
    }

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


    Вот такой вот метод нам понадобился для отрисовки конкретного прохода на экран:


    if(!this.passes.has(name)) throw new Error(`Multipass manager has no rendertarget named ${name}`)
    const target = this.passes.get(name);
    if(target.dependencies) {
        this._prepareDependencies(target.dependencies);  // <--- Ради чего все делалось
        target.installDependencies(this.passes);
    }
    
    if(this.prerenderCallbacks[name]) // это позволяет делать функциональные юниформы.
        this.prerenderCallbacks[name].forEach(fn=>fn(this));
    
    let clear = options.clear || {color:true, depth:true, stencil:true};
    clear = {...clear, ...target.clearOptions}
    target.setResolutionWithoutScaling(this.width, this.height); // Куча настроек - скукотища
    target.prerender();
    this.setupClearing(clear);
    this.renderer.render(target.getScene(), target.getCamera());
    this.restoreClearing();
    target.postrender();

    Немного комментариев:


    1. Каждая цель — это наш Pass для отрисовки.
    2. this.passes — это экземпляр javascript Map() (Тип: Map<String, Pass>).
    3. target.dependencies — это список текстурных юниформ в шейдере. Их мы считываем из исходника шейдера с помощью регулярных выражений.
    4. installDependencies — это ничто иное, как установка юниформ.
    5. prepareDependencies для каждой зависимости запускает функцию this.prerender, которая является младшей сестрой указанной функции. Разница методов небольшая, например, отрисовка идет в фреймбуфер цели:

    this.renderer.render(target.getScene(), target.getCamera(), target.framebuffer);

    Таким образом, у нас нарисовался общий класс для наших проходов с вот таким вот интерфейсом:


    class Pass{
    // Публичное:
      constructor(framebufferOptions = {}) {}
    // Детали реализации
      get clearOptions()
      get framebuffer()
      resize(w, h) // Это изменяет фреймбуфер
      setResolution(width, height) // это задает разрешение рисуемой текстуры.
      setResolutionWithoutScaling(width, height) // это изменяет разрешение текстуры в особых случаях.
      touchUniformFunctions()
      prerender()
      postrender()
      installDependencies(dependenciesMap)
      getScene()
      getCamera()
    }

    Как это должно работать


    Для начала надо настроить наш менеджер. Для этого мы его инстанциируем и добавляем в него некоторое количество Pass-ов. Затем, когда нам надо нарисовать какой-то Pass на наш контекст, мы просто вызываем


    manager.renderOnCanvas("passName");

    Этот Pass должен отрисоваться на экране, а менеджер должен подготовить перед этим все зависимости. Поскольку мы хотим переиспользовать текстуры, наш менеджер будет проверять наличие уже отрисованных текстур, и чтобы он не решил, что текстуры с прошлого кадра — это те текстуры, которые можно не рисовать, мы перед началом отрисовки сбрасываем старые текстуры. Для этого у менеджера есть функция с соответствующим названием start.


    function render(){
      manager.start();
      manager.renderOnCanvas('mainPass');
    }

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


    1. Стираем фон с помощью gl.Clear — three.js делает это автоматически, если ему не сказать, что стирать не надо.
    2. Накладываем тень с помощью блендинга.
    3. Накладываем изображение нашей челюсти, используя прозрачность.
    4. Накладываем SSAO.

    Вот так:


    function render(){
      this.passManager.start();
      if(showShadow)
          this.passManager.renderOnCanvas('displayShadow');
      this.passManager.renderOnCanvas('main', {
        clear:{color:false, stencil:true, depth:true}
      });
      if(showSSAO)
          this.passManager.renderOnCanvas('displaySSAO',{
            clear:{color:false, stencil:true, depth:true}
          });
    }
    

    Видно, что небольшое отличие состоит в том, что буфер цвета не стирается, а все остальные буферы очищаются.


    Если нам захочется вывести на экран какую-то промежуточную текстуру (например, в целях отладки), мы можем лишь слегка модифицировать render. Например, текстура с SSAO, которуя я привел выше, была отрисована вот таким кодом:


    function render(){
      this.passManager.start();
      this.passManager.renderOnCanvas('ssao');
    }
    

    Реализация ScenePass


    Теперь подробней остановимся на том, как именно рисовать наши проходы сцен в текстуры. Очевидно, что нам потребуется что-то, что умеет отрисовывать сцену, заменяя материал, и что-то, что будет отрисовывать все в экранных координатах.


    export class ScenePass extends Pass{
        constructor(scene, camera, options={}){
            let prerender=options.prerender ||null,
                postrender=options.postrender || null;
            super(options.framebufferOptions);
            this.scene = scene;
            this.camera = camera;
            this._prerender = prerender;
            this._postrender = postrender;
            this._clearOptions = options.clearOptions;
            this.overrideMaterial = options.overrideMaterial || null;
        }
        setSceneAndCamera(){ // Do not rewrite our scene
        }
    }
    

    Это весь класс. Получился довольно простым, поскольку почти всю функциональность удалось оставить в родителе. Как видите, я решил оставить overrideMaterial на тот возможный случай, когда мы сможем заменить материал на всей сцене разом в одну операцию присваивания, а не во время последовательной замены материала на всех подходящих объектах. Собственно, _prerender и _postrender — это и есть довольно умные заменители материала для каждого отдельного меша. Вот как они выглядят в нашем случае:


    class Pass{
      /// Skip-skip
      prerender(){
        if(this.overrideMaterial)
          this.scene.overrideMaterial = this.overrideMaterial;
        if(this._prerender)
          this.scene.traverse(this._prerender);
      }
      postrender(){
        if(this.scene.overrideMaterial)
          this.scene.overrideMaterial = null;
        if(this._postrender)
          this.scene.traverse(this._postrender);
      }
      /// Skip-skip
    }

    Scene.traverse — это метод THREE.js, который рекурсивно проходит по всей сцене.


    Реализация ScreenSpacePass


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


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


    export class ScreenSpacePass extends Pass {
        constructor(fragmentShader, options = {}){ // scaleFactor = 1.0, uniforms={}){
            let scaleFactor = options.scaleFactor || 1.0;
            let uniforms = options.uniforms || {};
            let blendingOptions = options.blendingOptions || {};
            super(options.framebufferOptions);
            /// Skip
        }
    
        resize(w, h){
            const scaler = getScaler(this.scaleFactor, w, h);
            let v = new Vector2(w,h).multiply(scaler);
            this.framebuffer.setSize(Math.round(v.x), Math.round(v.y));
        }
    
        setResolution(width, height){
            const scaling = getScaler(this.scaleFactor, width, height);
            let v = new Vector2(width, height).multiply(scaling);
            this.uniforms.resolution.value = v;
        }
    
        setResolutionWithoutScaling(width, height){
            this.uniforms.resolution.value = new Vector2(width, height);
        }
    
        isSampler(uname){
            return this.samplerUniforms.indexOf(uname) != -1;
        }
    
        tryFindDefaultValueForUniformType(utype){
            switch(utype){
                case 'vec2': return new Vector2(0., 0.);
                default: return null;
            }
        }
    
        getValueForUniform(uniformDescr){
            if(!this.uniformData.hasOwnProperty(uniformDescr.name )){
                if(uniformDescr.name != 'resolution' && !this.isSampler(uniformDescr.name))
                    console.warn(`Value for uniform '${uniformDescr.name}' is not found.`);
                return this.tryFindDefaultValueForUniformType(uniformDescr.type);
            }
            if(typeof(this.uniformData[uniformDescr.name]) == 'function'){
                this.uniformData[uniformDescr.name] = this.uniformData[uniformDescr.name].bind(this);
                return this.uniformData[uniformDescr.name]();
            }
            else  return this.uniformData[uniformDescr.name];
        }
    
        touchUniformFunctions(){
            for(const k in this.uniformData){
                if(typeof this.uniformData[k] !== 'function') continue;
                this.uniforms[k].value = this.uniformData[k]();
            }
        }
    
        prerender(){
            this.scene.overrideMaterial = this.shader;
            this.touchUniformFunctions();
        }
    
        parseUniforms(glslShader){
            let shaderLines = glslShader.split('\n');
            const uniformRe = /uniform ([\w\d]+) ([\w\d]+);/;
            let foundUniforms = shaderLines.map(line=>line.match(uniformRe))
                .filter(x=>x)
                .map(x=>{return {type:x[1],name:x[2]}});
            const umap = this.mapping;
            this.dependencies = foundUniforms.filter(x=>x.type == 'sampler2D').map(x=>umap[x.name]?umap[x.name]:x.name);
            this.samplerUniforms = foundUniforms.filter(x=>x.type == 'sampler2D').map(x=>x.name);
            this.uniforms = {};
            foundUniforms.forEach(u=>{
                this.uniforms[u.name] = {value:this.getValueForUniform(u)};
            });
            if(!this.uniforms.hasOwnProperty('resolution'))
                throw new Error('ScreenSpace shader in WebGL must have resolution uniform');
        }
    
        installDependencies(dependenciesMap){
            this.samplerUniforms.forEach(uname=>{
                this.uniforms[uname].value =   dependenciesMap.get(uname).framebuffer.texture;
            })
        }
    
        parseShader(fragmentShader){
            let glslShader = parseIncludes(fragmentShader);
            this.parseUniforms(glslShader);
            return new RawShaderMaterial({
                vertexShader: ssVertex,
                fragmentShader:glslShader,
                uniforms: this.uniforms,
                transparent: this.blendingOptions.transparent || false
            });
        }
    }
    
    function parseIncludes( string ) {
        // Stolen from three.js
        var pattern = /#include +<([\w\d.]+)>/g;
        function replace( match, include ) {
            var replace = ShaderChunk[ include ];
            if ( replace === undefined ) {
                throw new Error( 'Can not resolve #include <' + include + '>' );
            }
            return parseIncludes( replace );
        }
        return string.replace( pattern, replace );
    }

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


    Ну и в самом конце, я покажу, как мы этим пользовались. Application specific-сущность мы назвали EffectComposer. В его конструкторе создаем описанный менеджер и создаем ему пассы:


    this.passManager = new PassManager(threeRenderer);
    this.passManager.addPass('downscalePositionSSAO',
        new ScreenSpacePass(require('./shaders/passingFragmentShader.glsl'),
            {scaleFactor})
    );
    this.passManager.addPass('downscalePositionShadow',
        new ScreenSpacePass(require('./shaders/positionDownSampler.glsl'),
            {scaleFactor})
    );
    this.passManager.addPass('ssao',
        new ScreenSpacePass(require('./shaders/SSAO.glsl'), {scaleFactor})
    );
    /// Skip-skip-skip

    В качестве примера — содержимое файла passingFragmentShader.glsl:


    precision highp float;
    uniform sampler2D positions; // это зависимость - нужно сначала отрисовать текстуру positions
    uniform vec2 resolution;
    
    void main(){
      vec2 vUv = gl_FragCoord.xy / resolution;
      gl_FragColor = texture2D(positions, vUv);
    }

    Шейдер очень короткий — достать пиксель, который проинтерполируется, и тут же отдать его. Всю работу сделает линейная интерполяция в настройках текстуры (GL_LINEAR).


    Теперь посмотрим, как будет отрисована positions.


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


    function updateScenes(scenes, camera){
      this.passManager.addPass('main', new ScenePass(scene, camera));
      this.passManager.addPass('positions', new ScenePass(scene, camera, {
        prerender: materialReplacer,
        postrender:materialRestore,
        framebufferOptions
      }))
    }

    Как видно, если кто-то сообщил нам об изменении сцены, EffectComposer создаст два Pass-a: один с настройками по умолчанию, а другой — с хитрой заменой материалов. Проходы сцены у нас не содержат каких-то хитрых зависимостей, они, как правило, рисуются сами по себе, однако описываемый подход позволяет это делать, если мы добавим в ScenePass несколько методов для того, чтобы добавлять зависимости. Потому что это неочевидно, какой именно материал из сцены захочет иметь отрисованную зависимость.


    Заключение


    Несмотря на простоту использования в нашем случае, нам не удалось добиться полностью автоматической генерации пассов, основанных на шейдерах. Мне не хотелось добавлять в шейдеры маркеров, которые бы дополняли проходы прорисовки сцены дополнительными параметрами, такими как параметры вывода текстуры — GL_RGB, GL_RGBA, GL_FLOAT, GL_UNSIGNED_BYTE. Это бы, с одной стороны, упростило код, но дало бы меньше свободы переиспользования шейдеров. То есть эту настройку все равно пришлось описывать.


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


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

    • +33
    • 7,2k
    • 6
    JUG.ru Group 658,73
    Конференции для взрослых. Java, .NET, JS и др. 18+
    Поделиться публикацией
    Комментарии 6
    • –2
      Господи, какой ужасный перевод. Чёрт с ней, с кровью и глаз, но смысл теряется полностью — читаешь и местами вообще не можешь понять о чём речь!
      Чувак, запомни, вот это "… и при этом у вас нет идей, почему" переводится не как «нет идей почему», а «не представляете почему»! Даже гуглотранслейт это знает!
      • +2
        Только вот это не перевод. Это живой человек так говорит. :)
      • +2
        В JS коде довольно популярная ошибка:

        if(!pass instanceof Pass) throw new Error("Adding wrong class to renderer passes");
        


        нужно:

        if(!(pass instanceof Pass)) throw new Error("Adding wrong class to renderer passes");
        

        • +1

          Спасибо автору за стать, но… смешно читать, что с++ имеет ограничение на Window, исходники Doom3 можно глянуть

          • 0
            А что вы скажете на утверждение, что кроссплатформенный железный код на С++ на порядок сложней писать, чем обычный JS со стандартизированным API?
            • 0
              Сложнее, никто не спорит, но лучше все таки перефразируйте
              основным инструментом был выбран С++, но у него было серьезное ограничение: только десктопное приложение, только для Windows
              Это не правда

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

          Самое читаемое