Renderização volumétrica no WebGL


Figura 1. Um exemplo de renderizações volumétricas executadas pelo renderizador WebGL descrito na publicação. Esquerda: simulação da distribuição espacial de probabilidade de elétrons em uma molécula de proteína de alto potencial. Direita: tomografia de um bonsai. Ambos os conjuntos de dados são obtidos no repositório Open SciVis Datasets .

Na visualização científica, a renderização volumétrica é amplamente usada para visualizar campos escalares tridimensionais. Esses campos escalares geralmente são grades homogêneas de valores que representam, por exemplo, a densidade de carga ao redor da molécula, uma ressonância magnética ou tomografia computadorizada, uma corrente de ar envolvendo a aeronave etc. A renderização volumétrica é um método conceitualmente simples de converter esses dados em imagens: amostrando os dados ao longo dos raios do olho e atribuindo cor e transparência a cada amostra, podemos criar imagens úteis e bonitas de tais campos escalares (veja a Figura 1). No renderizador da GPU, esses campos escalares tridimensionais são armazenados como texturas 3D; no entanto, o WebGL1 não suporta texturas 3D, portanto, hacks adicionais são necessários para emulá-los na renderização de volume. O WebGL2 adicionou recentemente suporte para texturas 3D, permitindo que o navegador implemente uma renderização de volume elegante e rápida. Neste post, discutiremos os fundamentos matemáticos da renderização volumétrica e falaremos sobre como implementá-la no WebGL2 para criar um renderizador volumétrico interativo que funcione completamente no navegador! Antes de começar, você pode testar o renderizador volumétrico on-line descrito nesta postagem.

1. Introdução



Figura 2: renderização volumétrica física, levando em consideração a absorção e emissão de luz por volume, bem como os efeitos de espalhamento.

Para criar uma imagem fisicamente realista a partir de dados volumétricos, precisamos simular como os raios de luz são absorvidos, emitidos e dispersos pelo meio (Figura 2). Embora modelar a propagação da luz através de um meio nesse nível crie resultados bonitos e fisicamente corretos, é muito caro para a renderização interativa, que é o objetivo do software de visualização. Nas visualizações científicas, o objetivo final é permitir que os cientistas pesquisem interativamente seus dados, além de fazer perguntas sobre suas tarefas de pesquisa e respondê-las. Como um modelo de dispersão totalmente físico seria muito caro para renderização interativa, os aplicativos de visualização usam um modelo simplificado de absorção de emissões, ignorando os efeitos de dispersão dispendiosos ou aproximando-os de alguma forma. Neste artigo, consideramos apenas o modelo de absorção de emissões.

No modelo de absorção de emissões, calculamos os efeitos de iluminação que aparecem na Figura 2 apenas ao longo do raio preto e ignoramos os que surgem dos raios cinza pontilhados. Os raios que passam pelo volume e atingem os olhos acumulam a cor emitida pelo volume e desaparecem gradualmente até serem completamente absorvidos pelo volume. Se rastrearmos os raios do olho através do volume, podemos calcular a luz que entra no olho integrando o feixe sobre o volume, a fim de acumular emissão e absorção ao longo do feixe. Faça um raio caindo no volume em um ponto s=0e sem volume em um ponto s=L. Podemos calcular a luz que entra no olho usando a seguinte integral:

C(r)= int0LC(s) mu(s)e int0s mu(t)dtds


À medida que o feixe passa pelo volume, integramos a cor emitida C(s)e absorção  mu(s)em todo ponto sao longo da viga. A luz emitida em cada ponto diminui e retorna ao olho a absorção de volume até esse ponto, calculada pelo termo e int0s mu(t)dt.

No caso geral, essa integral não pode ser calculada analiticamente; portanto, uma aproximação numérica deve ser usada. Realizamos a aproximação da integral colhendo muitas amostras Nao longo do raio no intervalo s=[0,L]cada um dos quais está localizado à distância  Deltasseparados um do outro (Figura 3) e resumindo todas essas amostras. O termo de amortecimento em cada ponto de amostragem se torna o produto da série que acumula absorção em amostras anteriores.

C(r)= sumi=0NC(i Deltas) mu(i Deltas) Deltas prodj=0i1e mu(j Deltas) Deltas


Para simplificar ainda mais essa soma, aproximamos o termo de amortecimento ( e mu(j Deltas) Deltas) perto de Taylor. Também por conveniência, apresentamos alfa  alpha(i Deltas)= mu(i Deltas) Deltas. Isso nos dá a equação da composição alfa realizada da frente para trás:

C(r)= sumi=0NC(i Deltas) alpha(i Deltas) prodj=0i1(1 alpha(j Deltas))



Figura 3: Cálculo da integral da renderização da absorção de emissões em volume.

A equação acima se reduz a um loop for, no qual percorremos o feixe passo a passo pelo volume e acumulamos iterativamente cor e opacidade. Esse ciclo continua até que o feixe deixe o volume ou a cor acumulada se torne opaca (  alpha=1) O cálculo iterativo da soma acima é realizado usando as familiares equações de composição de frente para trás:

 hatCi= hatCi1+(1 alphai1) hatC(i Deltas)


 alphai= alphai1+(1 alphai1) alpha(i Deltas)


Essas equações finais contêm a opacidade anteriormente multiplicada para uma mistura adequada,  hatC(i Deltas)=C(i Deltas) alpha(i Deltas).

Para renderizar uma imagem de volume, basta traçar o raio do olho para cada pixel e, em seguida, execute a iteração mostrada acima para cada raio que atravessa o volume. Cada raio (ou pixel) processado é independente; portanto, se queremos renderizar a imagem rapidamente, precisamos de uma maneira de processar um grande número de pixels em paralelo. É aqui que a GPU é útil. Ao implementar o processo de raymarching no fragment shader, podemos usar o poder da computação paralela de GPU para implementar um renderizador de volume muito rápido!


Figura 4: Raymarching sobre uma grade de volume.

2. Implementação de GPU no WebGL2


Para que o raymarching seja executado no shader de fragmento, é necessário forçar a GPU a executar o shader de fragmento nos pixels ao longo dos quais queremos rastrear o raio. No entanto, o pipeline OpenGL trabalha com primitivas geométricas (Figura 5) e não possui maneiras diretas de executar um sombreador de fragmento em uma área específica da tela. Para contornar esse problema, podemos renderizar algum tipo de geometria intermediária para executar o sombreador de fragmento nos pixels que precisamos renderizar. Nossa abordagem ao volume de renderização será semelhante à do Shader Toy e dos renderizadores de cenas de demonstração , que renderizam dois triângulos em tela cheia para executar um shader de fragmento e, em seguida, realizam o trabalho real de renderização.


Figura 5: O pipeline OpenGL no WebGL consiste em dois estágios de sombreadores programáveis: um sombreador de vértice, responsável por converter os vértices de entrada em um espaço de clipe, e um sombreador de fragmento, responsável por sombrear os pixels cobertos pelo triângulo.

Embora a renderização de dois triângulos de tela inteira da maneira do ShaderToy funcione, ele executará o processamento desnecessário de fragmentos quando o volume não cobrir a tela inteira. Este caso é bastante comum: os usuários afastam a câmera do volume para examinar muitos dados em geral ou estudar grandes partes características. Para limitar o processamento de fragmentos apenas aos pixels afetados pelo volume, podemos rasterizar o paralelogramo delimitador da grade do volume e executar a etapa de marcação com raios no sombreador do fragmento. Além disso, não precisamos renderizar as faces frontal e traseira do paralelogramo, porque com uma certa ordem de renderização de triângulos, o shader de fragmento nesse caso pode ser executado duas vezes. Além disso, se renderizarmos apenas rostos faciais, podemos encontrar problemas quando o usuário ampliar, porque os rostos dianteiros serão projetados atrás da câmera, o que significa que serão cortados, ou seja, esses pixels não serão renderizados. Para permitir que os usuários aproximem completamente a câmera do volume, renderizaremos apenas as faces inversas do paralelogramo. O pipeline de renderização resultante é mostrado na Figura 6.


Figura 6: Pipeline WebGL para volume de raymarching. Rasterizaremos as faces inversas do paralelogramo de volume delimitador, para que o sombreador de fragmento seja executado para pixels tocando nesse volume. Dentro do shader de fragmento, renderizamos os raios através do volume passo a passo para renderização.

Nesse pipeline, a maior parte da renderização real é feita no shader de fragmento; no entanto, ainda podemos usar o sombreador e o equipamento de vértice para interpolação fixa de funções para realizar cálculos úteis. O sombreador de vértice converterá o volume com base na posição da câmera do usuário, calculará a direção do feixe e a posição dos olhos no espaço de volume e depois os transferirá para o sombreador de fragmento. A direção do feixe calculada em cada vértice é interpolada em triângulo pelo equipamento de interpolação de função fixa na GPU, permitindo calcular as direções dos raios para cada fragmento um pouco menos dispendioso; no entanto, quando transferidas para o shader de fragmento, essas direções podem não ser normalizadas, portanto ainda tem que normalizá-los.

Renderizaremos o paralelogramo delimitador como um único cubo [0, 1] e o dimensionaremos pelos valores dos eixos de volume para fornecer suporte para volumes de volume desigual. A posição do olho é convertida em um único cubo e, neste espaço, a direção do feixe é calculada. O Raymarching em um único espaço de cubo nos permitirá simplificar as operações de amostragem de textura durante o raymarching em um shader de fragmento. porque eles já estarão no espaço das coordenadas de textura [0, 1] do volume tridimensional.

O sombreador de vértice usado por nós é mostrado acima, as faces traseiras rasterizadas pintadas na direção do feixe de visibilidade são mostradas na Figura 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; }; 


Figura 7: Faces inversas do paralelogramo delimitador de volume pintado na direção do feixe.

Agora que o sombreador de fragmento processa os pixels para os quais precisamos renderizar o volume, podemos marcar o volume com um raio e calcular a cor de cada pixel. Além da direção do feixe e da posição do olho, calculada no shader de vértice, para renderizar o volume, precisamos transferir outros dados de entrada para o shader de fragmento. Obviamente, para iniciantes, precisamos de um amostrador de textura 3D para amostrar o volume. No entanto, o volume é apenas um bloco de valores escalares e, se os usarmos diretamente como valores de cores ( C(s)) e opacidade (  alpha(s)), uma imagem renderizada em escala de cinza não seria muito útil para o usuário. Por exemplo, seria impossível destacar áreas interessantes com cores diferentes, adicionar ruído e tornar transparentes as áreas de segundo plano para ocultá-las.

Para dar ao usuário controle sobre a cor e a opacidade atribuídas a cada valor de amostra, um mapa de cores adicional chamado função de transferência é usado nos renderizadores de visualizações científicas. A função de transferência define a cor e a opacidade a serem atribuídas a um valor específico amostrado no volume. Embora existam funções de transferência mais complexas, geralmente tabelas simples de pesquisa de cores são usadas como tais funções, que podem ser representadas como uma textura unidimensional de cor e opacidade (no formato RGBA). Para aplicar a função de transferência ao executar a marcação de raio de volume, podemos provar a textura da função de transferência com base em um valor escalar amostrado da textura de volume. Os valores de cor e opacidade de retorno são então usados ​​como C(s)e  alpha(s)amostra.

Os últimos dados de entrada para o shader de fragmento são as dimensões de volume que usamos para calcular o tamanho da etapa da viga (  Deltas) para provar cada voxel ao longo do feixe pelo menos uma vez. Como a equação tradicional do feixe tem a forma r(t)= veco+t vecd, por conformidade, alteraremos a terminologia no código e denotaremos  Deltascomo  textttdt. Da mesma forma, o intervalo s=[0,L]ao longo da viga, coberto por volume, denotamos como [ texttttmin, texttttmax].

Para realizar a marcação de raios de volume em um shader de fragmento, faremos o seguinte:

  1. Normalizamos a direção do feixe de visibilidade recebido como entrada do shader de vértice;
  2. Cruze a linha de visão com os limites do volume para determinar o intervalo [ texttttmin, texttttmax]realizar raymarching com o objetivo de renderizar volume;
  3. Nós calculamos esse comprimento de passo  textttdtpara que cada voxel seja amostrado pelo menos uma vez;
  4. Começando no ponto de entrada para r( texttttmin), vamos atravessar o feixe através do volume até chegarmos ao ponto final em r( texttttmax)
    1. Em cada ponto, amostramos o volume e usamos a função de transferência para atribuir cores e opacidade;
    2. Acumularemos cor e opacidade ao longo do feixe usando a equação de composição da frente para trás.

Como uma otimização adicional, você pode adicionar a condição para sair prematuramente do ciclo de marcação com raios para completá-lo quando a cor acumulada se tornar quase opaca. Quando a cor se torna quase opaca, quaisquer amostras posteriores terão quase nenhum efeito no pixel, porque sua cor será completamente absorvida pelo ambiente e não alcançará os olhos.

O sombreador de fragmento completo do nosso renderizador de volume é mostrado abaixo. Foram adicionados comentários, marcando cada etapa do processo.


Figura 8: Resultado da visualização de bonsai renderizada pronta do mesmo ponto de vista da Figura 7.

Isso é tudo!

O renderizador descrito neste artigo poderá criar imagens semelhantes às mostradas na Figura 8 e na Figura 1. Você também pode testá-lo online . Por uma questão de brevidade, omiti o código Javascript necessário para preparar o contexto WebGL, carregando texturas de volume e funções de transferência, configurando shaders e renderizando um cubo para renderizar volume; O código completo do renderizador está disponível para referência no Github .

Source: https://habr.com/ru/post/pt437526/


All Articles