Pull to refresh

Объёмный рендеринг в WebGL

Reading time8 min
Views4.9K
Original author: Will Usher

Рисунок 1. Пример объёмных рендеров, выполненных описанным в посте рендерером WebGL. Слева: симуляция пространственного распределения вероятностей электронов в высокопотенциальной молекуле белка. Справа: томограмма дерева бонсай. Оба набора данных взяты из репозитория Open SciVis Datasets.

В научной визуализации объёмный рендеринг широко используется для визуализации трёхмерных скалярных полей. Эти скалярные поля часто являются однородными сетками значений, представляющими, например, плотность заряда вокруг молекулы, скан МРИ или КТ, поток огибающего самолёт воздуха, и т.д. Объёмный рендеринг — это концептуально простой метод превращения таких данных в изображения: сэмплируя данные вдоль пущенных из глаза лучей, и назначив каждому сэмплу цвет и прозрачность, мы можем создавать полезные и красивые изображения таких скалярных полей (см. Рисунок 1). В GPU-рендерере такие трёхмерные скалярные поля хранятся как 3D-текстуры; однако в WebGL1 3D-текстуры не поддерживаются, поэтому для их эмуляции в объёмном рендеринге требуются дополнительные хаки. Недавно в WebGL2 появилась поддержка 3D-текстур, позволяющая реализовать браузере элегантный и быстрый объёмный рендерер. В этом посте мы обсудим математические основы объёмного рендеринга и расскажем о том, как реализовать его на WebGL2, чтобы создать интерактивный объёмный рендерер, полностью работающий в браузере! Прежде чем начать, вы можете протестировать описанный в этом посте объёмный рендерер онлайн.

1. Введение



Рисунок 2: физический объёмный рендеринг, учитывающий поглощение и испускание света объёмом, а также эффекты рассеяния.

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

В модели испускания-поглощения мы вычисляем эффекты освещения, возникающие на Рисунке 2 только вдоль чёрного луча, и проигнорируем те, которые возникают от пунктирных серых лучей. Проходящие сквозь объём и достигающие глаза лучи накапливают цвет, испущенный объёмом и постепенно затухают, пока полностью не будут поглощены объёмом. Если мы отследим лучи из глаза через объём, то сможем вычислить попадающий в глаз свет, проинтегрировав луч по объёму, чтобы накопить испускание и поглощение вдоль луча. Возьмём луч, попадающий в объём в точке $s = 0$ и выходящий из объёма в точке $s = L$. Мы можем вычислить попадающий в глаз свет с помощью следующего интеграла:

$C(r) = \int_0^L C(s) \mu(s) e^{-\int_0^s \mu(t) dt} ds$


В процессе прохождения луча через объём мы интегрируем испущенный цвет $C(s)$ и поглощение $\mu(s)$ в каждой точке $s$ вдоль луча. Испущенный свет в каждой точке затухает и возвращает в глаз поглощение объёмом до этой точки, которое вычисляется членом $e^{-\int_0^s \mu(t) dt}$.

В общем случае этот интеграл невозможно вычислить аналитически, поэтому нужно использовать численную аппроксимацию. Мы выполняем аппроксимацию интеграла, взяв множество сэмплов $N$ вдоль луча на интервале $s = [0, L]$, каждый из которых расположен на расстоянии $\Delta s$ друг от друга (Рисунок 3), и просуммировав все эти сэмплы. Член затухания в каждой точке сэмплирования становится произведением рядов, накапливающим поглощение в предыдущих сэмплах.

$C(r) = \sum_{i=0}^N C(i \Delta s) \mu (i \Delta s) \Delta s \prod_{j=0}^{i-1} e^{-\mu(j \Delta s) \Delta s}$


Чтобы ещё больше упростить эту сумму, мы аппроксимируем член затухания ($e^{-\mu(j \Delta s) \Delta s}$) его рядом Тейлора. Также для удобства мы вводим альфу $\alpha(i \Delta s) = \mu(i \Delta s) \Delta s$. Это даёт нам уравнение альфа-композитинга, выполняемого спереди назад:

$C(r) = \sum_{i=0}^N C(i \Delta s) \alpha (i \Delta s) \prod_{j=0}^{i-1} (1 - \alpha(j \Delta s))$



Рисунок 3: Вычисление интеграла рендеринга испускания-поглощения в объёме.

Показанное выше уравнение сводится к циклу for, в котором мы пошагово проходим по лучу через объём и итеративно накапливаем цвет и непрозрачность. Этот цикл продолжается до тех пор, пока или луч не покинет объём, или накопленный цвет не станет непрозрачным ($\alpha = 1$). Итеративное вычисление представленной выше суммы выполняется с помощью знакомых уравнений композитинга спереди назад:

$\hat{C}_i = \hat{C}_{i-1} + (1 - \alpha_{i-1}) \hat{C}(i \Delta s)$


$\alpha_i = \alpha_{i - 1} + (1 - \alpha_{i-1}) \alpha(i \Delta s)$


В этих окончательных уравнениях содержится предварительно умноженная для правильного смешения непрозрачность, $\hat{C}(i\Delta s) = C(i\Delta s) \alpha(i \Delta s)$.

Для рендеринга изображения объёма достаточно просто трассировать луч из глаза в каждый пиксель, а затем выполнить показанную выше итерацию для каждого луча, пересекающего объём. Каждый обрабатываемый луч (или пиксель) назависим, поэтому если мы хотим рендерить изображение быстро, нам нужен способ параллельной обработки большого количества пикселей. Здесь нам пригодится GPU. Реализовав во фрагментном шейдере процесс raymarching, мы сможем использовать мощь параллельных вычислений GPU, чтобы реализовать очень быстрый объёмный рендерер!


Рисунок 4: Raymarching по сетке объёма.

2. GPU-реализация на WebGL2


Чтобы raymarching выполнялся во фрагментном шейдере, необходимо заставить GPU выполнять фрагментный шейдер по тем пикселям, по которым мы хотим трассировать луч. Однако конвейер OpenGL работает с геометрическими примитивами (Рисунок 5), и не имеет прямых способов выполнения фрагментного шейдера в определённой области экрана. Чтобы обойти эту проблему, мы можем отрендерить какую-то промежуточную геометрию, чтобы выполнить фрагментный шейдер на пикселях, которые нам нужно отрендерить. Наш подход к рендеринуг объёма будет схож с подходом Shader Toy и рендереров демосцены, которые рендерят два полноэкранных треугольника для выполнения фрагментного шейдера, а он затем выполняет настоящую работу по рендерингу.


Рисунок 5: Конвейер OpenGL в WebGL состоит из двух этапов программируемых шейдеров: вершинного шейдера, отвечающего за преобразование входных вершин в пространство усечённых координат (clip space), и фрагментного шейдера, отвечающего за затенение покрытых треугольником пикселей.

Хотя рендеринг двух полноэкранных треугольников на манер ShaderToy вполне сработает, он будет выполнять ненужную фрагментную обработку в случае, когда объём не покрывает весь экран. Этот случай встречается довольно часто: пользователи отодвигают камеру от объёма, чтобы посмотреть на множество данных в целом или изучить крупные характерные части. Чтобы ограничить фрагментную обработку только пикселями, затронутыми объёмом, мы можем растеризировать ограничивающий параллелограмм сетки объёма, а затем выполнить во фрагментном шейдере этап raymarching. Кроме того, нам не нужно рендерить лицевую и обратную грани параллелограмма, потому что при при определённом порядке рендеринга треугольников фрагментный шейдер в таком случае может выполняться дважды. Более того, если мы рендерим только лицевые грани, то можем столкнуться с проблемами, когда пользователь приближает объём, потому что передние грани будут проецироваться за камерой, а значит отсекутся, то есть эти пиксели не отрендерятся. Чтобы позволить пользователям полностью приближать камеру к объёму, мы будем рендерить только обратные грани параллелограмма. Получившийся конвейер рендеринга показан на Рисунке 6.


Рисунок 6: Конвейер WebGL для raymarching-а объёма. Мы растеризируем обратные грани ограничивающего параллелограмма объёма, чтобы фрагментный шейдер выполнялся для пикселей, касающихся этого объёма. Внутри фрагментного шейдера мы для рендеринга пошагово пропускаем лучи через объём.

В этом конвейере основная часть реального рендеринга выполняется во фрагментном шейдере; однако мы по-прежнему можем использовать вершинный шейдер и оборудование для фиксированного интерполирования функций, чтобы выполнять полезные вычисления. Вершинный шейдер будет преобразовывать объём на основании позиции камеры пользователя, вычислять направление луча и положение глаза в пространстве объёма, а затем передавать их во фрагментный шейдер. Направление луча, вычисленное в каждой вершине, затем интерполируется по треугольнику оборудованием фиксированного интерполирования функций в GPU, позволяя нам чуть менее затратно вычислять направления лучей для каждого фрагмента.Тем не менее, при передаче во фрагментный шейдер эти направления могут быть не нормализованными, так что нам всё равно придётся их нормализовать.

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

Используемый нами вершинный шейдер показан выше, растеризованные обратные грани, раскрашенные по направлению луча видимости, показаны на Рисунке 7.

#version 300 es
layout(location=0) in vec3 pos;
uniform mat4 proj_view;
uniform vec3 eye_pos;
uniform vec3 volume_scale;

out vec3 vray_dir;
flat out vec3 transformed_eye;

void main(void) {
	// Translate the cube to center it at the origin.
	vec3 volume_translation = vec3(0.5) - volume_scale * 0.5;
	gl_Position = proj_view * vec4(pos * volume_scale + volume_translation, 1);

	// Compute eye position and ray directions in the unit cube space
	transformed_eye = (eye_pos - volume_translation) / volume_scale;
	vray_dir = pos - transformed_eye;
};


Рисунок 7: Обратные грани ограничивающего параллелограмма объёма, раскрашенные по направлению луча.

Теперь, когда фрагментный шейдер обрабатывает пиксели, для которых нам нужно отрендерить объём, мы можем выполнить raymarching объёма и вычислить цвет для каждого пикселя. В дополнение к направлению луча и позиции глаза, вычисленным в вершинном шейдере, для рендеринга объёма нам нужно передать во фрагментный шейдер и другие входные данные. Разумеется, для начала нам необходим сэмплер 3D-текстуры для сэмплирования объёма. Однако объём — это просто блок скалярных значений, и если бы мы использовали их непосредственно в качестве значений цвета ($C(s)$) и непрозрачности ($\alpha(s)$), то отрендеренное изображение в градациях серого было бы не очень полезно для пользователя. Например, было бы невозможно выделять интересные области разными цветами, добавлять шум и делать фоновые области прозрачными, чтобы скрыть их.

Чтобы предоставить пользователю контроль над цветом и непрозрачностью, назначаемыми каждому значению сэмпла, в рендерерах научных визуализаций используется дополнительная карта цветов, называемая функцией переноса. Функция переноса задаёт цвет и непрозрачность, которые необходимо назначить определённому значению, сэмплированному из объёма. Хотя существуют и более сложные функции переноса, обычно в качестве таких функций используются простые таблицы поиска цветов, которые можно представить как одномерную текстуру цвета и непрозрачности (в формате RGBA). Для применения функции переноса при выполнении raymarching объёма, мы можем сэмплировать текстуру функции переноса на основании скалярного значения, сэмплированного из текстуры объёма. Возвращаемые значения цвета и непрозрачности затем используются в качестве $C(s)$ и $\alpha(s)$ сэмпла.

Последними входящими данными для фрагментного шейдера являются размеры объёма, которые мы используем для вычисления величины шага луча ($\Delta s$), чтобы как минимум один раз сэмплировать каждый воксель вдоль луча. Так как традиционное уравнение луча имеет вид $r(t) = \vec{o} + t \vec{d}$, для соответствия мы изменим терминологию в коде, и обозначим $\Delta s$ как $\texttt{dt}$. Аналогично, интервал $s = [0, L]$ вдоль луча, перекрытый объёмом, мы обозначим как $[\texttt{tmin}, \texttt{tmax}]$.

Чтобы выполнить raymarching объёма во фрагментном шейдере, мы сделаем следующее:

  1. Нормализуем направление луча видимости, полученного как входные данные от вершинного шейдера;
  2. Пересечём луч видимости границами объёма, чтобы определить интервал $[\texttt{tmin}, \texttt{tmax}]$ для выполнения raymarching с целью рендеринга объёма;
  3. Вычислим такую длину шага $\texttt{dt}$, чтобы каждый воксель был сэмплирован хотя бы один раз;
  4. Начиная с точки входа в $r(\texttt{tmin})$, пройдём по лучу через объём, пока не достигнем конечной точки в $r(\texttt{tmax})$
    1. В каждой точке сэмплируем объём и используем функцию переноса для назначения цвета и непрозрачности;
    2. Будем накапливать цвет и непрозрачность вдоль луча с помощью уравнения композитинга спереди назад.

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

Полный фрагментный шейдер для нашего объёмного рендерера показан ниже. В него добавлены комментарии, помечающие каждый этап процесса.


Рисунок 8: Готовый отрендеренный результат визуализации бонсай с той же точки обзора, что и на Рисунке 7.

Вот и всё!

Описанный в данной статье рендерер будет способен создавать изображения, подобные показанному на Рисунке 8 и Рисунке 1. Также вы можете протестировать его онлайн. Ради краткости я опустил код на Javascript, необходимый для подготовки контекста WebGL, загрузку текстур объёма и функции переноса, настройки шейдеров и рендеринг куба для рендеринга объёма; полный код рендерера выложен для справки на Github.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+17
Comments0

Articles

Change theme settings