Advanced Three.js: materiais de shader e pós-processamento


Existem algumas introduções ao básico do trabalho com o Three.js. on-line, mas você pode notar uma escassez de materiais sobre tópicos mais avançados. E um desses tópicos é a combinação de shaders e cenas com modelos tridimensionais. Aos olhos de muitos desenvolvedores iniciantes, essas são coisas aparentemente incompatíveis de mundos diferentes. Hoje, usando um exemplo simples de uma “esfera de plasma”, veremos o que é ShaderMaterial e o que é consumido, qual é o efeito de efeito e a rapidez com que é possível fazer o pós-processamento para uma cena renderizada.


Supõe-se que o leitor esteja familiarizado com o básico do trabalho com o Three.js e entenda como os shaders funcionam. Se você não encontrou isso antes, recomendo a leitura primeiro:



Mas vamos começar ...


ShaderMaterial - o que é isso?


Já vimos como uma textura plana é usada e como é esticada sobre um objeto tridimensional. Como essa textura era uma imagem comum. Quando examinamos a escrita de shaders de fragmentos, tudo estava lá também. Então: se podemos gerar uma imagem plana usando um sombreador, por que não usá-la como textura?


É essa idéia que forma a base do material shader. Ao criar material para um objeto tridimensional, indicamos shaders em vez de uma textura para ele. Na sua forma básica, é algo parecido com isto:


const shaderMaterial = new THREE.ShaderMaterial({ uniforms: { // ... }, vertexShader: '...', fragmentShader: '...' }); 

O shader de fragmento será usado para criar a textura do material e, é claro, você pergunta: o que o shader de vértice fará? Ele fará novamente uma recontagem banal de coordenadas? Sim, começaremos com essa opção simples, mas também podemos definir um deslocamento ou executar outras manipulações para cada vértice de um objeto tridimensional - agora não há restrições no plano. Mas é melhor ver tudo isso com um exemplo. Em palavras, pouco é entendido. Crie uma cena e faça uma esfera no centro.



Como material para a esfera, usaremos ShaderMaterial:


 const geometry = new THREE.SphereBufferGeometry(30, 64, 64); const shaderMaterial = new THREE.ShaderMaterial({ uniforms: { // . . . }, vertexShader: document.getElementById('sphere-vertex-shader').textContent, fragmentShader: document.getElementById('sphere-fragment-shader').textContent }); const sphere = new THREE.Mesh(geometry, shaderMaterial); SCENE.add(sphere); 

O vertex shader será neutro:


 void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } 

Observe que o Three.js passa suas variáveis ​​uniformes. Não precisamos fazer nada, eles estão implícitos. Em si, eles contêm todos os tipos de matrizes, às quais já temos acesso a partir do JS, bem como a posição da câmera. Imagine que no início dos próprios shaders algo seja inserido:


 // = object.matrixWorld uniform mat4 modelMatrix; // = camera.matrixWorldInverse * object.matrixWorld uniform mat4 modelViewMatrix; // = camera.projectionMatrix uniform mat4 projectionMatrix; // = camera.matrixWorldInverse uniform mat4 viewMatrix; // = inverse transpose of modelViewMatrix uniform mat3 normalMatrix; // = camera position in world space uniform vec3 cameraPosition; 

Além disso, várias variáveis ​​de atributo são passadas para o sombreador de vértice:


 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; 

Pelos nomes, fica claro o que é: a posição do vértice atual, o normal para a superfície nesse ponto e as coordenadas na textura à qual o vértice corresponde.


Tradicionalmente, as coordenadas no espaço são designadas como (x, y, z) e as coordenadas no plano de textura como (u, v). Daí o nome da variável. Você frequentemente o encontrará em vários exemplos. Em teoria, precisamos transferir essas coordenadas para o shader de fragmentos para trabalhar com elas lá. Nós vamos fazer isso.


 varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } 

Para começar, o shader de fragmento deve ser algo como isto:


 #define EPSILON 0.02 varying vec2 vUv; void main() { if ((fract(vUv.x * 10.0) < EPSILON) || (fract(vUv.y * 10.0) < EPSILON)) { gl_FragColor = vec4(vec3(0.0), 1.0); } else { gl_FragColor = vec4(1.0); } } 

Basta criar uma malha. Se você pensar um pouco, então no plano será apenas uma grade de quadrados, mas como a sobrepomos a uma esfera, ela é distorcida, transformando-se em um globo. Há uma boa imagem na Wikipedia ilustrando o que está acontecendo:



Ou seja, no shader de fragmento, fazemos uma textura plana, como no centro desta ilustração, e o Three.js a puxa para a esfera. Muito confortável



Obviamente, para modelos mais complexos, a varredura será mais complicada. Mas, geralmente, ao criar vários sites de design, trabalhamos com formas geométricas simples e é fácil imaginar uma varredura em sua cabeça.


Ok, o que você pode fazer sobre isso?


A principal característica é que o material do shader pode mudar com o tempo. Não é uma coisa estática que desenhamos uma vez e esquecemos, podemos animá-la. Além disso, tanto na cor (no sombreador do fragmento) quanto na forma (no vértice). Esta é uma ferramenta muito poderosa.


Em nosso exemplo, faremos um fogo envolvendo uma esfera. Haverá duas esferas - uma comum (interna) e a segunda do material shader (externa, com um raio grande). Adicionar outra esfera não comentará.



Primeiro, adicione tempo como uma variável uniforme para os shaders em nosso material. Em nenhum lugar sem tempo. Já fizemos isso em JS puro, mas no Three.js é tão simples. Deixe o tempo nos shaders ser chamado uTime e ser armazenado na variável TIME:


 function updateUniforms() { SCENE.traverse((child) => { if (child instanceof THREE.Mesh && child.material.type === 'ShaderMaterial') { child.material.uniforms.uTime.value = TIME; child.material.needsUpdate = true; } }); } 

Atualizamos tudo a cada chamada para a função animar:


 function animate() { requestAnimationFrame(animate); TIME += 0.005; updateUniforms(); render(); } 

Fogo


Criar um incêndio é essencialmente muito semelhante a gerar uma paisagem, mas em vez de alturas, cores. Ou transparência, como no nosso caso.


Funções para aleatoriedade e ruído que já vimos, não as analisaremos em detalhes. Tudo o que precisamos fazer é emitir ruídos em frequências diferentes para adicionar variedade e fazer com que cada um desses ruídos se mova em velocidades diferentes. Você terá chamas, as grandes se movem lentamente, as pequenas se movem mais rápido:


 uniform float uTime; varying vec2 vUv; float rand(vec2); float noise(vec2); void main() { vec2 position1 = vec2(vUv.x * 4.0, vUv.y - uTime); vec2 position2 = vec2(vUv.x * 4.0, vUv.y - uTime * 2.0); vec2 position3 = vec2(vUv.x * 4.0, vUv.y - uTime * 3.0); float color = ( noise(position1 * 5.0) + noise(position2 * 10.0) + noise(position3 * 15.0)) / 3.0; gl_FragColor = vec4(0.0, 0.0, 0.0, color - smoothstep(0.1, 1.3, vUv.y)); } float rand(vec2 seed) { return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123); } float noise(vec2 position) { vec2 blockPosition = floor(position); float topLeftValue = rand(blockPosition); float topRightValue = rand(blockPosition + vec2(1.0, 0.0)); float bottomLeftValue = rand(blockPosition + vec2(0.0, 1.0)); float bottomRightValue = rand(blockPosition + vec2(1.0, 1.0)); vec2 computedValue = smoothstep(0.0, 1.0, fract(position)); return mix(topLeftValue, topRightValue, computedValue.x) + (bottomLeftValue - topLeftValue) * computedValue.y * (1.0 - computedValue.x) + (bottomRightValue - topRightValue) * computedValue.x * computedValue.y; } 

Para que a chama não cubra toda a esfera, brincamos com o quarto parâmetro de cor - transparência - e o vinculamos à coordenada y. No nosso caso, esta opção é muito conveniente. Em termos mais gerais, aplicamos um gradiente com transparência ao ruído.


Em momentos como este, é útil lembrar o passo suave

Em geral, essa abordagem para criar fogo usando shaders é um clássico. Você frequentemente o encontrará em vários lugares. Será útil jogar com números mágicos - eles são definidos aleatoriamente no exemplo e a aparência do plasma depende deles.


Para tornar o fogo mais interessante, vamos para o shader de vértice e um pouco de shaman ...


Como tornar a chama um pouco "derramada" no espaço? Para iniciantes, essa questão pode causar grandes dificuldades, apesar de sua simplicidade. Vi abordagens muito complexas para resolver esse problema, mas, em essência - precisamos mover suavemente os vértices da esfera ao longo das linhas "do centro". De um lado para o outro. O Three.js já nos passou a posição atual do vértice e do normal - nós os usaremos. Para “ir e vir”, alguma função limitada caberá, por exemplo, em um seno. É claro que você pode experimentar, mas o seno é a opção padrão.


Não sei o que levar - tome o seno. Melhor ainda, a soma dos senos com diferentes frequências.

Mudamos as coordenadas normais para o valor obtido e recalculamos de acordo com a fórmula conhecida anteriormente.


 uniform float uTime; varying vec2 vUv; void main() { vUv = uv; vec3 delta = normal * sin(position.x * position.y * uTime / 10.0); vec3 newPosition = position + delta; gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); } 

O que obtemos não é mais uma esfera. Isso ... eu nem sei se esse tem um nome. Mas, novamente, não se esqueça de brincar com as probabilidades - elas afetam muito. Ao criar esses efeitos, muitas vezes algo é selecionado por tentativa e erro e é muito útil desenvolver a “intuição matemática” em si mesmo - a capacidade de mais ou menos imaginar como uma função se comporta, como depende de quais variáveis.


Nesta fase, temos uma imagem interessante, mas um pouco desajeitada. Então, primeiro, vamos dar uma olhada no pós-processamento e depois passar para um exemplo vivo.


Pós-processamento


A capacidade de fazer algo com a imagem renderizada Three.js. é uma coisa muito útil, enquanto é esquecida imerecidamente em várias séries de lições. Tecnicamente, isso é implementado da seguinte forma: a imagem que o renderizador nos forneceu é enviada ao EffectComposer (contanto que seja uma caixa preta), que xamaniza algo em si e exibe a imagem final na tela. Ou seja, após o renderizador, mais um módulo é adicionado. Transferimos parâmetros para esse compositor - o que fazer com a imagem recebida. Um desses parâmetros é chamado de pass. De certa forma, o compositor funciona como um Gulp - não faz nada, fornecemos plugins que já fazem o trabalho. Talvez não seja inteiramente correto dizer isso, mas a ideia deve ser clara.


Tudo o que usaremos mais adiante não está incluído na estrutura básica do Three.js, portanto, conectamos algumas dependências e dependências das próprias dependências:


 <script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/EffectComposer.js'></script> <script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/RenderPass.js'></script> <script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/ShaderPass.js'></script> <script src='https://unpkg.com/three@0.99.0/examples/js/shaders/CopyShader.js'></script> <script src='https://unpkg.com/three@0.99.0/examples/js/shaders/LuminosityHighPassShader.js'></script> <script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/UnrealBloomPass.js'></script> 

Lembre-se de que esses scripts estão incluídos no pacote de três e você pode colocar tudo isso em um único pacote usando um webpack ou análogos.

Em sua forma básica, o compositor é criado assim:


 COMPOSER = new THREE.EffectComposer(RENDERER); COMPOSER.setSize(window.innerWidth, window.innerHeight); const renderPass = new THREE.RenderPass(SCENE, CAMERA); renderPass.renderToScreen = true; COMPOSER.addPass(renderPass); 

O RenderPass não faz nada de novo. Apenas renderiza o que costumávamos obter de um renderizador regular. De fato, se você olhar o código fonte do RenderPass, poderá encontrar o renderizador padrão lá. Como agora a renderização está acontecendo lá, precisamos substituir o renderizador pelo compositor em nosso script:


 function render() { // RENDERER.render(SCENE, CAMERA); COMPOSER.render(SCENE, CAMERA); } 

Essa abordagem usando o RenderPass como primeira passagem é uma prática padrão ao trabalhar com o EffectComposer. Normalmente, precisamos primeiro obter uma imagem renderizada da cena e depois fazer algo com ela.


Nos exemplos de Three.js, na seção de pós-processamento, você pode encontrar uma coisa chamada UnrealBloomPass. Este é um script portado do mecanismo Unreal. Ele adiciona um pouco de brilho que pode ser usado para criar uma iluminação mais bonita. Frequentemente, este será o primeiro passo para melhorar a imagem.


 const bloomPass = new THREE.UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 1, 0.1); bloomPass.renderToScreen = true; COMPOSER.addPass(bloomPass); 

Observe: a opção renderToScreen é definida apenas para o último passe que passamos para o compositor.


Mas já vamos ver que tipo de brilho esse bloomPass nos deu e como ele se encaixa na esfera:



Concordo, isso é muito mais interessante do que apenas uma esfera e uma fonte de luz comum, como geralmente são mostradas nas lições iniciais do Three.js.


Mas vamos ainda mais longe ...


Mais shaders para o deus shader!



É muito útil usar o console.log e observar a estrutura do compositor. Nele, você pode encontrar alguns elementos com os nomes renderTarget1, renderTarget2, etc., onde os números correspondem aos índices das passagens passadas. E então fica claro por que o EffectComposer é chamado. Ele trabalha com o princípio de filtros no SVG. Lembre-se, lá você pode usar o resultado da execução de alguns filtros em outros? Aqui a mesma coisa - você pode combinar efeitos.


Usar o console.log para entender a estrutura interna dos objetos Three.js. e muitas outras bibliotecas é muito útil. Use essa abordagem com mais frequência para entender melhor o que é o quê.

Adicione outro passe. Desta vez, será o ShaderPass.


 const shader = { uniforms: { uRender: { value: COMPOSER.renderTarget2 }, uTime: { value: TIME } }, vertexShader: document.getElementById('postprocessing-vertex-shader').textContent, fragmentShader: document.getElementById('postprocessing-fragment-shader').textContent }; const shaderPass = new THREE.ShaderPass(shader); shaderPass.renderToScreen = true; COMPOSER.addPass(shaderPass); 

RenderTarget2 contém o resultado do passe anterior - bloomPass (foi o segundo em uma linha), usamos como textura (essa é essencialmente uma imagem renderizada plana) e passamos como uma variável uniforme para o novo shader.


Provavelmente vale a pena frear e realizar toda a magia aqui ...


Em seguida, crie um shader de vértice simples. Na maioria dos casos, nesta fase, não precisamos fazer nada com os vértices, apenas passamos as coordenadas (u, v) para o shader de fragmento:


 varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } 

E no fragmentário podemos nos divertir com nosso gosto e cor. Por exemplo, podemos adicionar um efeito de falha de luz, deixar tudo em preto e branco e brincar com brilho / contraste:


 uniform sampler2D uRender; uniform float uTime; varying vec2 vUv; float rand(vec2); void main() { float randomValue = rand(vec2(floor(vUv.y * 7.0), uTime / 1.0)); vec4 color; if (randomValue < 0.02) { color = texture2D(uRender, vec2(vUv.x + randomValue - 0.01, vUv.y)); } else { color = texture2D(uRender, vUv); } float lightness = (color.r + color.g + color.b) / 3.0; color.rgb = vec3(smoothstep(0.02, 0.7, lightness)); gl_FragColor = color; } float rand(vec2 seed) { return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123); } 

Vejamos o resultado:



Como você pode ver, os filtros foram sobrepostos na esfera. Ainda é tridimensional, nada quebrou, mas na tela temos uma imagem processada.


Conclusão


Os materiais de shader e o pós-processamento no Three.js são duas ferramentas pequenas, mas muito poderosas, que definitivamente valem a pena ser usadas. Há muitas opções para seu uso - tudo é limitado pela sua imaginação. Mesmo as cenas mais simples com a ajuda deles podem ser alteradas além do reconhecimento.

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


All Articles