Breve revisão
Um buffer de profundidade hierárquico é um buffer de profundidade de vários níveis (Z-buffer) usado como uma estrutura de aceleração para consultas de profundidade. Como no caso de cadeias mip de textura, os tamanhos de cada nível geralmente são o resultado da divisão pelo grau de dois dos tamanhos do buffer de resolução total. Neste artigo, falarei sobre duas maneiras de gerar um buffer de profundidade hierárquico a partir de um buffer de resolução completa.Primeiro, mostrarei como gerar uma cadeia mip completa para um buffer de profundidade, que preserva a precisão das consultas de profundidade no espaço de coordenadas da textura (ou NDC), mesmo para tamanhos de buffer de profundidade diferentes de potências de dois. (Na Internet, deparei-me com exemplos de código que não garantem essa precisão, o que complica a execução de consultas precisas em altos níveis de mip.)Então, nos casos em que apenas um nível de downsampling é necessário, demonstrarei como gerar esse nível com uma única chamada para um shader de computação que usa operações atômicas na memória compartilhada do grupo de trabalho. Para minha aplicação, em que apenas é necessária a resolução 1/16 x 1/16 (nível mip 4), o método com um shader computacional é 2-3 vezes mais rápido do que a abordagem usual com a redução da amostragem da cadeia mip em várias passagens.Introdução
Profundidades hierárquicas (também chamadas de Hi-Z) é uma técnica frequentemente usada em gráficos 3D. É usado para acelerar o corte da geometria invisível (seleção de oclusão) (na CPU , bem como na GPU ), o cálculo de reflexões no espaço da tela , neblina volumétrica e muito mais.Além disso, as GPUs Hi-Z são frequentemente implementadas como parte de um pipeline de rasterização. As operações de pesquisa rápida Hi-Z nos caches do chip permitem que você pule completamente os blocos de fragmentos se eles estiverem completamente cobertos por primitivas renderizadas anteriormente.A idéia básica do Hi-Z é acelerar as operações de consulta em profundidade lendo os buffers de baixa resolução. Isso é mais rápido do que ler profundidades de resolução total do buffer, por dois motivos:- Um texel (ou apenas alguns texels) de um buffer de resolução mais baixa pode ser usado como um valor aproximado de uma pluralidade de texels de um buffer de alta resolução.
- Um buffer de resolução mais baixa pode ser pequeno o suficiente e armazenado em cache, o que acelera bastante a execução de operações de pesquisa (especialmente com acesso aleatório).
O conteúdo dos níveis de buffer Hi-Z com amostragem reduzida depende de como eles são usados (se o buffer de profundidade será "invertido" , que tipos de solicitações devem ser utilizados). Em geral, um texel no nível do buffer Hi-Z armazena min
ou max
todos os texels correspondentes a ele no nível anterior. Às vezes, os valores de min
e são armazenados ao mesmo tempo max
. Valores médios simples (geralmente usados nos níveis mip de texturas regulares) são usados com pouca frequência porque raramente são úteis para esses tipos de consultas.Os buffers Hi-Z são frequentemente solicitados quase imediatamente na saída para evitar processamento adicional e operações de pesquisa mais precisas no buffer de resolução completa. Por exemplo, se armazenarmos valoresmax
para um buffer de profundidade não invertido (no qual quanto maior o valor de profundidade, maior o objeto), podemos determinar rapidamente exatamente se uma posição específica no espaço da tela é coberta por um buffer de profundidade (se sua coordenada Z> for o valor (máximo) armazenado em alguma nível mais alto (ou seja, resolução mais baixa) do buffer Hi-Z).Observe que eu usei a frase “exatamente”: se a coordenada Z <= o valor recebido (máx), não se sabe se o buffer se sobrepõe. Em algumas aplicações, em casos de incerteza, pode ser necessário procurar no buffer a profundidade da resolução total; em outros casos, isso não é necessário (por exemplo, se apenas cálculos desnecessários estiverem em jogo e não a renderização correta).Minha aplicação: renderizando partículas em um shader computacional
Fui confrontado com a necessidade de usar o Hi-Z ao implementar a renderização de partículas em um shader computacional no mecanismo do meu aplicativo PARTICULATE VR . Como essa técnica de renderização não usa rasterização com funções fixas, ela precisa usar sua própria verificação de profundidade para cada partícula com o tamanho de um pixel. E como as partículas não são classificadas de maneira alguma, o acesso ao buffer de profundidade é (no pior dos casos) quase aleatório.As operações de pesquisa em uma textura de acesso aleatório em tela cheia são o caminho para um desempenho ruim. Para reduzir a carga, primeiro procuro profundidades no buffer de profundidade reduzida com uma resolução de 1/16 x 1/16 do original. Este buffer contém valores de profundidade.min
, que permite que o shader de renderização computacional para a grande maioria das partículas visíveis pule o teste de profundidade de resolução total. (Se a profundidade da partícula é <a profundidade mínima armazenada no buffer de resolução mais baixa, sabemos que é absolutamente visível. Se> = min, precisamos verificar o buffer de profundidade da resolução total.)Graças a isso, o teste de profundidade para partículas visíveis no caso geral torna-se uma operação de baixo custo. (Para partículas sobrepostas pela geometria, é mais caro, mas nos convém porque não causa custos de renderização, portanto as partículas ainda exigem pouco cálculo.)Devido ao fato de a pesquisa ser realizada pela primeira vez no buffer de profundidades de menor resolução (como mencionado acima) , o tempo de renderização das partículas é reduzido em 35% no máximocomparado ao caso em que a pesquisa é realizada apenas no buffer de resolução completa. Portanto, para o meu aplicativo, o Hi-Z é muito benéfico.Agora, examinaremos duas técnicas para gerar um buffer de profundidade hierárquico.Técnica 1: gerando uma cadeia Mip completa
Em muitas aplicações Hi-Z, é necessária a criação de uma cadeia mip de buffer de profundidade completa. Por exemplo, ao executar o descarte de oclusão usando o Hi-Z, o volume delimitador é projetado no espaço da tela e o tamanho projetado é usado para selecionar o nível mip apropriado (para que um número fixo de texels esteja envolvido em cada teste de sobreposição).Gerar uma cadeia mip a partir do buffer de profundidade de resolução total é geralmente uma tarefa simples - para cada texel no nível N, pegamos max
(ou min
ou ambos) os 4 texels correspondentes no nível N-1 gerado anteriormente. Executamos passes sequenciais (cada vez que reduzimos o tamanho pela metade) até obtermos o último nível mip 1x1 de tamanho.No entanto, no caso de buffers de profundidade, cujos tamanhos não correspondem às potências de dois, tudo é mais complicado. Como o Hi-Z para buffers de profundidade geralmente é criado a partir de resoluções de tela padrão (que raramente são potências de dois), precisamos encontrar uma solução confiável para esse problema.Vamos primeiro decidir o que significa o valor de cada buffer de profundidade no nível mip texel. Neste artigo, assumiremos que a cadeia mip armazena valores min
. As operações de pesquisa de profundidade devem usar a filtragem dos vizinhos mais próximos, porque a interpolação de valores é min
inútil para nós e prejudicará a natureza hierárquica da cadeia de profundidades criada.Então, o que exatamente o valor do texel individual no nível mip N obtido por nós significa? Esse deve ser o valor mínimo (min
) de todos os texels do buffer de profundidade de tela inteira que ocupa o mesmo espaço no espaço de coordenadas da textura (normalizada).Em outras palavras, se uma coordenada separada da textura (no intervalo) é mapeado (filtrando os vizinhos mais próximos) para um texel individual do buffer de resolução completa, esse texel de resolução total deve ser considerado um candidato para o valor min
calculado para o texel em cada nível mip mais alto subsequente, com o qual a mesma coordenada de textura é mapeada.Se essa correspondência for garantida, teremos certeza de que a operação de busca em altos níveis de mip nunca retornará o valor de profundidade> valor texel na mesma coordenada de textura correspondente ao buffer de resolução total (nível 0). No caso de um N separado, essa garantia é mantida para todos os níveis abaixo dele (<N).Para dimensões pares (e no caso de buffers de resolução total, que são potências de dois, dimensões pares em cada nível até o último, onde as dimensões se tornam iguais a 1), será fácil. No caso unidimensional, para texel com um índice no nível N, precisamos pegar texels no nível N-1 com índices e e encontre seu significado min
. I.e. Podemos comparar diretamente os texels na proporção “2 para 1” (e, portanto, o tamanho das coordenadas da textura), porque o tamanho de cada nível é exatamente duas vezes menor que o anterior.Um exemplo de tamanhos de níveis pares: 6 texels nesse nível são reduzidos para 3 em um nível superior. Os tamanhos das coordenadas de textura de cada um dos três texels de alto nível são sobrepostos com precisão em cada dois texels de nível inferior. (Pontos são os centros de texels e quadrados são as dimensões da coordenada da textura ao filtrar os vizinhos mais próximos.)No caso de tamanhos de níveis ímpares (e buffers de resolução total que não são uma potência de dois terão pelo menos um nível com um tamanho ímpar) tudo ficando mais difícil. Para o nível N-1 de tamanho ímpar o tamanho do próximo nível (N) será igual , isto é .Isso significa que agora não temos um mapeamento 2 a 1 claro de texels do nível N-1 para texels do nível N. Agora, o tamanho da coordenada de textura de cada texel no nível N é sobreposto ao tamanho de 3 texels no nível N-1.Um exemplo de tamanho de nível ímpar: 7 texels desse nível são reduzidos para 3 texels no próximo nível. As dimensões das coordenadas de textura dos três texels de alto nível são sobrepostas nos tamanhos dos três texels do nível inferior.Conseqüentemente. Isso significa que um texel no nível de N-1 às vezes afeta o valor min
calculado para 2 texels no nível de N. Isso é necessário para manter a comparação descrita acima.A descrição acima foi apresentada em apenas uma dimensão por simplicidade. Em duas dimensões, se as duas dimensões do nível N-1 são pares, a região texel 2x2 no nível N-1 é mapeada para um texel no nível N. Se uma das dimensões for ímpar, a região 2x3 ou 3x2 no nível N-1 é mapeada para uma texel no nível N. Se ambas as dimensões são ímpares , o texel “angular” também deve ser levado em consideração, ou seja, a região 3x3 no nível N-1 é comparada com uma texel no nível N.Exemplo de código
O código shader GLSL mostrado abaixo implementa o algoritmo que descrevemos. Ele deve ser executado para cada mip subsequente, começando no nível 1 (o nível 0 é o nível de resolução total).uniform sampler2D u_depthBuffer;
uniform int u_previousLevel;
uniform ivec2 u_previousLevelDimensions;
void main() {
ivec2 thisLevelTexelCoord = ivec2(gl_FragCoord);
ivec2 previousLevelBaseTexelCoord = 2 * thisLevelTexelCoord;
vec4 depthTexelValues;
depthTexelValues.x = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord,
u_previousLevel).r;
depthTexelValues.y = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(1, 0),
u_previousLevel).r;
depthTexelValues.z = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(1, 1),
u_previousLevel).r;
depthTexelValues.w = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(0, 1),
u_previousLevel).r;
float minDepth = min(min(depthTexelValues.x, depthTexelValues.y),
min(depthTexelValues.z, depthTexelValues.w));
bool shouldIncludeExtraColumnFromPreviousLevel = ((u_previousLevelDimensions.x & 1) != 0);
bool shouldIncludeExtraRowFromPreviousLevel = ((u_previousLevelDimensions.y & 1) != 0);
if (shouldIncludeExtraColumnFromPreviousLevel) {
vec2 extraColumnTexelValues;
extraColumnTexelValues.x = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(2, 0),
u_previousLevel).r;
extraColumnTexelValues.y = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(2, 1),
u_previousLevel).r;
if (shouldIncludeExtraRowFromPreviousLevel) {
float cornerTexelValue = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(2, 2),
u_previousLevel).r;
minDepth = min(minDepth, cornerTexelValue);
}
minDepth = min(minDepth, min(extraColumnTexelValues.x, extraColumnTexelValues.y));
}
if (shouldIncludeExtraRowFromPreviousLevel) {
vec2 extraRowTexelValues;
extraRowTexelValues.x = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(0, 2),
u_previousLevel).r;
extraRowTexelValues.y = texelFetch(u_depthBuffer,
previousLevelBaseTexelCoord + ivec2(1, 2),
u_previousLevel).r;
minDepth = min(minDepth, min(extraRowTexelValues.x, extraRowTexelValues.y));
}
gl_FragDepth = minDepth;
}
Falhas neste código
Primeiro, no caso de buffers de profundidade de resolução total para os quais uma dimensão é mais do que duas vezes o tamanho de outra dimensão, os índices de chamada texelFetch
podem ir além u_depthBuffer
. (Nesses casos, a dimensão menor se transforma em 1 antes da outra.) Eu queria usar este exemplo texelFetch
(usando coordenadas inteiras) para que o que estivesse acontecendo fosse o mais claro possível e não encontrasse pessoalmente esses buffers particularmente largos / de profundidade alta. Se você encontrar esses problemas, poderá limitar ( clamp
) as texelFetch
coordenadas transmitidas ou usar as texture
coordenadas normalizadas da textura (no amostrador, defina um limite na borda). Ao calcular min
ou max
sempre deve considerar um texel várias vezes para a presença de casos limítrofes.Em segundo lugar, apesar do fato de as quatro primeiras chamadas texelFetch
poderem ser substituídas por uma textureGather
, isso complica as coisas (já que o textureGather
nível mip não pode ser indicado); Além disso, não notei um aumento na velocidade ao usar textureGather
.atuação
Usei o shader de fragmento acima para gerar duas cadeias mip completas para dois buffers de profundidade (um para cada olho) no meu mecanismo de VR. No teste, a resolução para cada olho era 1648x1776, o que levou à criação de 10 níveis adicionais de mip reduzidos (o que significa 10 passes). Foram necessários 0,25 ms no NVIDIA GTX 980 e 0,30 ms no AMD R9 290 para gerar uma cadeia completa para os dois olhos.Mip- 4, 5 6, , . ( , , , .) Mip- 4 — , (103x111) 2.mip-
A tarefa do algoritmo descrito acima é manter a precisão das consultas de profundidade no espaço de coordenadas da textura (ou NDC). Para garantir a integridade (e porque recusei essa garantia na técnica abaixo 2), gostaria de demonstrar mais um método que me deparei (por exemplo, neste artigo ).Observe que, como o anterior, esse método alternativo foi projetado para funcionar com buffers de resolução total cujos tamanhos não são potências de dois (mas, é claro, eles funcionam com tamanhos iguais à potência de dois).Nesse método alternativo, ao reduzir a amostragem de um nível com largura (ou altura) ímpar, em vez de adicionar para cada texel de saída uma coluna (ou linha) adicional de texels do nível anterior (inferior), realizamos esta operação apenas para texels de saída com índices máximos (texels "extremos" ) A única coisa que muda no shader de fragmento apresentado acima é definir valores shouldIncludeExtraColumnFromPreviousLevel
e shouldIncludeExtraRowFromPreviousLevel
:
bool shouldIncludeExtraColumnFromPreviousLevel =
(previousMipLevelBaseTexelCoords.x == u_previousLevelDimensions.x - 3);
bool shouldIncludeExtraRowFromPreviousLevel =
(previousMipLevelBaseTexelCoords.y == u_previousLevelDimensions.y - 3);
Por esse motivo, os texels extremos com o índice mais alto tornam-se muito "espessos", pois cada divisão por 2 dimensões ímpares leva ao fato de ocuparem um intervalo proporcionalmente maior do espaço de coordenadas da textura normalizada.A desvantagem dessa abordagem é que fica mais difícil executar consultas de profundidade de altos níveis de mip. Em vez de usar apenas as coordenadas de textura normalizadas, primeiro precisamos determinar o texel de resolução total correspondente a essas coordenadas e depois transferir as coordenadas desse texel para as coordenadas do nível mip correspondente, cuja solicitação está sendo executada.O trecho de código abaixo está migrando do espaço NDCpara coordenadas texel no nível mip higherMipLevel
:vec2 windowCoords = (0.5 * ndc.xy + vec2(0.5)) * textureSize(u_depthBuffer, 0);
ivec2 texelCoords = ivec2(round(windowCoords.xy - vec2(0.5)));
ivec2 higherMipLevelTexelCoords =
min(texelCoords / (1 << higherMipLevel),
textureSize(u_depthBuffer, higherMipLevel).xy - ivec2(1));
Técnica 2: Gerar um único nível Hi-Z usando um sombreador de computação
A geração de uma cadeia mip completa é bastante rápida, mas me incomodou um pouco que meu aplicativo gere todos esses níveis e use apenas um deles (nível 4). Além de eliminar essa leve ineficiência, também queria ver quanto tudo poderia ser acelerado se eu usasse apenas uma chamada de sombreador de computação para gerar o nível necessário. (Vale a pena notar que meu aplicativo pode parar no nível 4 ao usar uma solução com um shader de fragmentos de várias passagens; portanto, no final desta seção, eu o usei como base para comparar tempos de execução.)Na maioria dos aplicativos Hi-Z, é necessário apenas um nível de profundidade; portanto, acho essa situação comum. Escrevi um sombreador computacional para meus próprios requisitos específicos (geração de nível 4, que tem uma resolução de 1/16 x 1/16 do original). Código semelhante pode ser usado para gerar níveis diferentes.O sombreador computacional é adequado para esta tarefa, porque pode usar a memória compartilhada do grupo de trabalho para trocar dados entre threads. Cada grupo de trabalho é responsável por um texel de saída (reduzido por downsampling do buffer), e os threads do grupo de trabalho compartilham o trabalho de calcular os min
texels correspondentes de resolução total, compartilhando os resultados através da memória compartilhada.Tentei duas soluções principais baseadas em shaders computacionais. No primeiro, cada thread solicitava atomicMin
uma variável de memória compartilhada.Observe que, como os programadores não podem (sem extensões para o hardware de um fabricante específico) executar operações atômicas em valores não inteiros (e minhas profundidades são armazenadas como float
), é necessário algum truque aqui. Como os valores de ponto flutuante não negativos do padrão IEEE 754 mantêm sua ordem quando seus bits são processados como valores inteiros não assinados, podemos usar floatBitsToUint
para trazer (usando reinterpretar a conversão) os valores de profundidade float
para uint
e, em seguida, chamar atomicMin
(para executar uintBitsToFloat
o valor mínimo final uint
) .A solução mais óbvia atomicMin
seria criar grupos de threads 16x16 nos quais cada thread recebe um texel e, em seguida, executa-o atomicMin
com um valor na memória compartilhada. Comparei essa abordagem usando blocos de fluxo menores (8x8, 4x8, 4x4, 2x4, 2x2), nos quais cada fluxo recebe uma região texel e calcula seu próprio mínimo local e depois chama atomicMin
.A mais rápida de todas essas soluções testadas comatomicMin
A NVIDIA e a AMD acabaram tendo uma solução com blocos de fluxo 4x4 (nos quais cada fluxo recebe uma área texel 4x4). Não entendo muito bem por que essa opção acabou sendo a mais rápida, mas talvez ela reflita um compromisso entre a competição de operações atômicas e os cálculos em fluxos independentes. Também é importante notar que o tamanho do grupo de trabalho 4x4 usa apenas 16 threads por warp / wave (e é possível usar também 32 ou 64), o que é interessante. O exemplo abaixo implementa essa abordagem.Como alternativa ao uso, atomicMin
tentei realizar uma redução paralela usando as técnicas usadas nesta apresentação da NVIDIA ativamente citada.. (A idéia básica é usar uma matriz de memória compartilhada do mesmo tamanho que o número de threads no grupo de trabalho, bem comopassa para o cálculo conjunto seqüencial dos min
mínimos de cada fluxo até que o mínimo final de todo o grupo de trabalho seja obtido.)Tentei esta solução com todos os mesmos tamanhos de grupos de trabalho da solução c atomicMin
. Mesmo com todas as otimizações descritas na apresentação da NVIDIA, a solução de redução paralela é um pouco mais lenta (dentro de dez GPUs em ambas as GPUs) do que a solução atomicMin
que encontrei. Além disso, a solução com é atomicMin
muito mais simples em termos de código.Exemplo de código
Com esse método, a maneira mais fácil é não tentar manter a correspondência no espaço normalizado das coordenadas de textura entre texels de buffers reduzidos e resolução total. Você pode simplesmente realizar conversões de coordenadas texel de resolução total em coordenadas texel de resolução reduzida:ivec2 reducedResTexelCoords = texelCoords / ivec2(downscalingFactor);
No meu caso (gerar o equivalente ao nível mip 4) downscalingFactor
é 16.Como mencionado acima, esse shader computacional GLSL implementa uma solução com atomicMin
tamanhos de grupo de trabalho 4x4, em que cada thread recebe uma área texel 4x4 do buffer de resolução total. O buffer de profundidade de valor reduzido resultante min
é 1/16 x 1/16 do tamanho do buffer de resolução total (arredondado para cima quando os tamanhos de resolução total não são divisíveis por 16 completamente).uniform sampler2D u_inputDepthBuffer;
uniform restrict writeonly image2DArray u_outputDownsampledMinDepthBufferImage;
uniform vec2 u_texelDimensions;
layout(local_size_x = 16/4, local_size_y = 16/4, local_size_z = 1) in;
shared uint s_workgroupMinDepthEncodedAsUint;
void main() {
if (gl_LocalInvocationIndex == 0) {
s_workgroupMinDepthEncodedAsUint = floatBitsToUint(1.0);
}
memoryBarrierShared();
barrier();
uvec2 baseTexelCoords = 4 * gl_GlobalInvocationID.xy;
vec2 gatherCoords1 = (baseTexelCoords + uvec2(1, 1)) * u_texelDimensions;
vec2 gatherCoords2 = (baseTexelCoords + uvec2(3, 1)) * u_texelDimensions;
vec2 gatherCoords3 = (baseTexelCoords + uvec2(1, 3)) * u_texelDimensions;
vec2 gatherCoords4 = (baseTexelCoords + uvec2(3, 3)) * u_texelDimensions;
vec4 gatheredTexelValues1 = textureGather(u_inputDepthBuffer, gatherCoords1);
vec4 gatheredTexelValues2 = textureGather(u_inputDepthBuffer, gatherCoords2);
vec4 gatheredTexelValues3 = textureGather(u_inputDepthBuffer, gatherCoords3);
vec4 gatheredTexelValues4 = textureGather(u_inputDepthBuffer, gatherCoords4);
vec4 gatheredTexelMins = min(min(gatheredTexelValues1, gatheredTexelValues2),
min(gatheredTexelValues3, gatheredTexelValues4));
float finalMin = min(min(gatheredTexelMins.x, gatheredTexelMins.y),
min(gatheredTexelMins.z, gatheredTexelMins.w));
atomicMin(s_workgroupMinDepthEncodedAsUint, floatBitsToUint(finalMin));
memoryBarrierShared();
barrier();
if (gl_LocalInvocationIndex == 0) {
float workgroupMinDepth = uintBitsToFloat(s_workgroupMinDepthEncodedAsUint);
imageStore(u_outputDownsampledMinDepthBufferImage,
ivec2(gl_WorkGroupID.xy),
vec4(workgroupMinDepth));
}
}
atuação
Usei o shader computacional acima para processar o buffer de profundidade de resolução total com as mesmas dimensões que foram usadas para gerar a cadeia mip completa (buffers de 1648x1776 para cada olho). Ele roda em 0,12 ms no NVIDIA GTX 980 e 0,08 ms no AMD R9 290. Se compararmos com o tempo de geração de apenas 1 a 4 níveis de mip (0,22 ms no NVIDIA, 0,25 ms AMD), A solução com um shader computacional acabou sendo 87% mais rápida com as GPUs NVIDIA e 197% mais rápida que as GPUs AMD .Em termos absolutos, a aceleração não é tão grande, mas a cada 0,1 ms é importante, especialmente em VR :)