WebGLFundamentals.org

WebGL - Антипаттерны

Ниже приведён список антипаттернов в WebGL. Антипаттерны - это вещи, которых стоит избегать.

  1. Добавление свойств viewportWidth и viewportHeight объекту WebGLRenderingContext

    Порой можно встретить, что значения ширины и высоты области просмотра записываются в свойства объекта WebGLRenderingContext, например так:

    gl = canvas.getContext("webgl");
    gl.viewportWidth = canvas.width;    // ПЛОХО!!!
    gl.viewportHeight = canvas.height;  // ПЛОХО!!!
    

    Затем свойства могут использоваться следующим образом:

    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    

    Почему это плохо:

    Это объективно плохо, потому что теперь у вас появляется 2 свойства, которые нужно не забывать обновлять каждый раз при изменении размера canvas. Например, при изменении размера окна пользователем свойства gl.viewportWidth и gl.viewportHeight будут некорректными, их нужно обновить согласно новым размерам canvas.

    Это субъективно плохо, потому что новички в WebGL посмотрят на ваш код и подумают, что gl.viewportWidth и gl.viewportHeight - часть спецификации WebGL, что введёт их в заблуждение, возможно, на месяцы.

    Что делать вместо этого:

    Зачем создавать себе больше работы? Контекст WebGL уже содержит ширину и высоту. Просто используйте их.

    // если вам необходимо, чтобы область просмотра соответствовала размеру
    // буфера отрисовки canvas'а, это всегда будет работать
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    

    Такой подход даже лучше справится со сложными случаями, где использование gl.canvas.width и gl.canvas.height не поможет. Объяснение смотри ниже.

  2. Использование canvas.width и canvas.height для соотношения сторон экрана

    Часто можно увидеть код, использующий canvas.width и canvas.height для соотношения сторон экрана, например:

    var aspect = canvas.width / canvas.height;
    perspective(fieldOfView, aspect, zNear, zFar);
    

    Почему это плохо:

    Ширина и высота canvas никак не связана с тем размером, как canvas отображается на странице. За размер canvas на странице отвечает CSS.

    Что делать вместо этого:

    Используйте canvas.clientWidth и canvas.clientHeight. Эти размеры вернут реальный размер отображаемого на экране canvas. Используя эти свойства, у вас всегда будет правильное соотношение сторон экрана, независимо от CSS.

    var aspect = canvas.clientWidth / canvas.clientHeight;
    perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
    

    Ниже идут несколько примеров canvas с одинаковым размером (width="400" height="300"), но через CSS мы задаём разный размер отображения в браузере. Заметьте, что примеры сохраняют корректное соотношение сторон экрана.

    А если мы используем canvas.width и canvas.height, изображение искажается.

  3. Использование для вычислений window.innerWidth и window.innerHeight

    Многие программы WebGL используют window.innerWidth и window.innerHeight во многих местах. Например:

    canvas.width = window.innerWidth;                    // ПЛОХО!!
    canvas.height = window.hinnerHeight;                 // ПЛОХО!!
    

    Почему это плохо:

    Это не переносится. Да, это будет работать на HTML-страницах, где вы хотите растянуть canvas на весь экран. Проблема появится, когда полноэкранный режим станет не нужен. Возможно, вы решите сделать статью подобную моей, где canvas - лишь небольшая часть большой страницы. А может сбоку вам нужен редактор свойств или статистика по игре. Конечно, вы сможете обойти эти ситуации, но почему бы сразу не написать код так, чтобы он работал везде? Тогда вам не придётся менять его при создании нового проекта или использование старого проекта по-новому.

    Что делать вместо этого:

    Вместо того, чтобы бороться с веб-страницей, сотрудничайте с ней! Используйте CSS вместе с clientWidth и clientHeight.

    var width = gl.canvas.clientWidth;
    var height = gl.canvas.clientHeight;
    
    gl.canvas.width = width;
    gl.canvas.height = height;
    

    Ниже приведены 9 примеров. Все они используют один и тот же код. Ни один из них не использует ни window.innerWidth, ни window.innerHeight.

    Страница с единственным элементом canvas, растянутым на весь экран через CSS

    Страница с canvas размером 70% ширины страницы, чтобы осталось место для контрольной панели

    Страница с canvas внутри параграфа

    Страница с canvas внутри параграфа, использует box-sizing: border-box;

    При использовании box-sizing: border-box; границы и внутренние отступы забирают пространство самого элемента, а не области вокруг него. Другими словами, в обычном режиме box-sizing элемент размером 400x300 пикселей с границей толщиной 15 пикселей будет содержать 400x300 пикселей контента и будет окружён границей толщиной 15 пикселей, то есть итоговый размер составит 430x330 пикселей. В режиме box-sizing: border-box граница уйдёт внутрь, поэтому суммарный размер элемента останется 400x300 пикселей, а контент займёт 370x270 пикселей. Это ещё одна причина, по которой вам нужно использовать clientWidth и clientHeight. При установки ширины границы, скажем, 1em вы не будете знать, какой размер у неё будет, так как он будет разным в зависимости от шрифта, компьютера, браузера.

    Страница, содержащая только контейнер, которому через CSS задан полноэкранный размер, и в который через код вставлен canvas

    Страница с контейнером шириной 70%, чтобы оставить место для панели управления, в который через код вставлен canvas

    Страница с контейнером, вставленным в параграф, в который через код вставлен canvas

    Страница с контейнером, вставленным в параграф, используется box-sizing: border-box;, в контейнер через код вставлен canvas

    Страница без элементов, настроенная через CSS для полноэкранного режима, в которую через код вставлен canvas

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

  4. Использование события 'resize' для изменения размера canavs

    Некоторые приложения отслеживают событие 'resize', чтобы подстроить размер canavs.

    window.addEventListener('resize', resizeTheCanvas);
    

    или так

    window.onresize = resizeTheCanvas;
    

    Почему это плохо:

    Само по себе это не плохо. Просто для большинства программ на WebGL такое решение не охватывает некоторых ситуаций. Событие 'resize' срабатывает, только когда размер окна меняется. Оно не сработает, если по каким-то причинам изменился размер canvas. Например, вы делаете 3D-редактор. Ваш canvas находится слева, настройки расположены справа. И у вас есть возможность расширять или сужать панель настроек, потянув за границу между настройками и canvas. В этом случае не будет никакого события 'resize'. Аналогично, при добавлении или удалении контента на страницу размер canvas может меняться, и вы тоже не получите события 'resize'.

    Что делать вместо этого:

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

    function resize() {
      var width = gl.canvas.clientWidth;
      var height = gl.canvas.clientHeight;
      if (gl.canvas.width != width ||
          gl.canvas.height != height) {
         gl.canvas.width = width;
         gl.canvas.height = height;
      }
    }
    
    function render() {
       resize();
       drawStuff();
       requestAnimationFrame(render);
    }
    render();
    

    Теперь во всех описанных выше ситуациях canvas будет всегда иметь правильный размер. Нет необходимости менять код для отдельных случаев. Например, используя код из примера #3, сделаем редактор с возможностью менять размер панели настроек.

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

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

    function resize() {
      var width = gl.canvas.clientWidth;
      var height = gl.canvas.clientHeight;
      if (gl.canvas.width != width ||
          gl.canvas.height != height) {
         gl.canvas.width = width;
         gl.canvas.height = height;
         return true;
      }
      return false;
    }
    
    var needToRender = true;  // необходимо отрисовать хотя бы раз
    function checkRender() {
       if (resize() || needToRender) {
         needToRender = false;
         drawStuff();
       }
       requestAnimationFrame(checkRender);
    }
    checkRender();
    

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

  5. Добавление свойств к объектам WebGLObject

    Объекты WebGLObject - это различные виды ресурсов в WebGL, например, WebGLBuffer или WebGLTexture. Некоторые приложения добавляют свои свойства к этим объектам. Например, код может выглядеть так:

    var buffer = gl.createBuffer();
    buffer.itemSize = 3;        // ПЛОХО!!
    buffer.numComponents = 75;  // ПЛОХО!!
    
    var program = gl.createProgram();
    ...
    program.u_matrixLoc = gl.getUniformLocation(program, "u_matrix");  // ПЛОХО!!
    

    Почему это плохо:

    Причина заключается в том, что WebGL может "потерять контекст"". Это может случиться по многим причинам, но чаще всего возникает ситуация, когда браузер решает, что используется слишком много ресурсов видеокарты, и вытесняет контекст некоторых WebGLRenderingContext, чтобы освободить память. Программам WebGL, работающим без остановки, необходимо отлавливать эту ситуацию. Например, Google Maps обрабатывает такую ситуацию.

    Проблема упомянутого кода в том, что при потере контекста теряются и функции создания объектов, например gl.createBuffer() вернёт null. Поэтому следующий код

    var buffer = null;
    buffer.itemSize = 3;        // ОШИБКА!
    buffer.numComponents = 75;  // ОШИБКА!
    

    скорей всего прекратит выполнение вашего приложение с ошибкой

    TypeError: Cannot set property 'itemSize' of null
    

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

    Что делать вместо этого:

    Если вам нужно сохранить WebGLObjects и другую информации в одном месте, одним из способов будет использование объектов JavaScript. Например:

    var bufferInfo = {
      id: gl.createBuffer(),
      itemSize: 3,
      numComponents: 75,
    };
    
    var programInfo = {
      id: program,
      u_matrixLoc: gl.getUniformLocation(program, "u_matrix"),
    };
    

    Лично я бы предложил использовать несколько простых помощников, которые облегчат написание кода WebGL.

Описанные выше ситуации, которые я отношу к антипаттернам WebGL, я встретил на просторах интернета. Надеюсь, я показал, почему их нужно избегать, а приведённые решение будут вам полезны.

Что такое drawingBufferWidth и drawingBufferHeight?

В видеокарте есть ограничение на размер прямоугольника пикселей (текстуры, буфера отрисовки). Обычно этот размер является степенью двойки и при этом превышает разрешение монитора, которое распространено на момент выпуска видеокарты. Например, если видеокарта выпущена для мониторов 1280x1024, её ограничением скорей всего будет значение 2048. В случае с мониторами разрешением 2560x1600 ограничение видеокарты составит 4096.

Это кажется разумным, но что случится, если у вас несколько мониторов? Скажем, у меня видеокарта с ограничением 2048, но у меня два монитора 1920x1080. Пользователь откроет окно браузера со страницей WebGL и растянет окно на два монитора. Ваш код попытается установить canvas.width в значение canvas.clientWidth , которое в данный момент равно 3840. Что произойдёт?

У меня сходу есть 3 варианта

  1. Исключение

    Плохой сценарий. Большинство приложений не проверят это исключение и прервут выполнение. Если в приложении были данные пользователя, они потеряются.

  2. Ограничить размер canvas до ограничения видеокарты

    Это решение тоже, вероятно, приведёт к прерыванию работы программы или другим проблемам на странице, так как код ожидает, что размер canvas будет другим, и что другие части интерфейса будут на своих местах страницы.

  3. Размер canvas может быть любым, но буфер отрисовки будет ограничен

    Это решение, которое использует WebGL. Если ваш код написан верно, пользователь сможет заметить лишь то, что изображение на canvas немного растянулось. В других случаях всё будет в порядке. В худшем случае, когда ваш код написан не совсем корректно, пользователь увидит смещение картинки, но при уменьшении размера окна картинка будет отображаться нормально.

У большинства пользователей один монитор, поэтому проблема возникает редко. Или, по крайней мере, возникала редко. Chrome и Safari, во всяком случае на январь 2015 года, имели встроенное ограничение canvas 4096. У Apple iMac 5k ограничение было выше. Из-за этого многие приложения WebGL имели странности с отображением. Аналогично многие люди начали использовать WebGL с двумя мониторами для работы с инсталляциями и тоже достигли ограничения.

Поэтому если вы хотите обработать эти случаи, используйте gl.drawingBufferWidth и gl.drawingBufferHeight, как показано в примере #1. Для большинства приложений при следовании советам, описанных выше, всё будет работать без проблем. Однако, если вы выполняете вычисления, для которых нужен реальный размер буфера отрисовки, вам нужно принять во внимание описанное выше. Например, выбор объектов сцены, где нужно преобразовать координаты мыши в пиксели canvas. Другой пример - пост-эффекты, для которых нужно знать реальный размер буфера отрисовки.


Вопросы? Спросите на stackoverflow.
Нашли ошибку? Создайте задачу на github.
comments powered by Disqus