Advanced Three.js: materiales de sombreado y postprocesamiento


Hay bastantes introducciones a los conceptos básicos de trabajar con Three.js en la web, pero puede notar una escasez de materiales sobre temas más avanzados. Y uno de estos temas es la combinación de sombreadores y escenas con modelos tridimensionales. A los ojos de muchos desarrolladores novatos, estas son cosas aparentemente incompatibles de mundos diferentes. Hoy, usando un ejemplo simple de una "esfera de plasma", veremos qué es ShaderMaterial y con qué se come, qué efecto de efecto es y qué tan rápido es posible realizar un procesamiento posterior para una escena renderizada.


Se supone que el lector está familiarizado con los conceptos básicos de trabajar con Three.js y comprende cómo funcionan los sombreadores. Si no ha encontrado esto antes, le recomiendo leer esto primero:



Pero empecemos ...


ShaderMaterial: ¿qué es eso?


Ya hemos visto cómo se usa una textura plana y cómo se estira sobre un objeto tridimensional. Como esta textura era una imagen ordinaria. Cuando examinamos la escritura de sombreadores de fragmentos, allí también todo era plano. Entonces: si podemos usar un sombreador para generar una imagen plana, ¿por qué no usarlo como textura?


Es esta idea la que forma la base del material de sombreado. Al crear material para un objeto tridimensional, indicamos sombreadores en lugar de una textura para él. En su forma básica, se ve más o menos así:


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

El sombreador de fragmentos se usará para crear la textura del material, y usted, por supuesto, pregunta, ¿qué hará el sombreador de vértices? ¿Volverá a hacer un recuento banal de coordenadas? Sí, comenzaremos con esta opción simple, pero también podemos establecer un desplazamiento o realizar otras manipulaciones para cada vértice de un objeto tridimensional; ahora no hay restricciones en el plano. Pero es mejor ver todo esto con un ejemplo. En palabras, poco se entiende. Crea una escena y haz una esfera en el centro.



Como material para la esfera, utilizaremos 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); 

El sombreador de vértices será neutral:


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

Tenga en cuenta que Three.js pasa sus variables uniformes. No tenemos que hacer nada, están implícitos. En sí mismos, contienen todo tipo de matrices, a las que ya tenemos acceso desde JS, así como la posición de la cámara. Imagine que al comienzo de los sombreadores se inserta algo:


 // = 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; 

Además, se pasan varias variables de atributo al sombreador de vértices:


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

Por los nombres queda claro de qué se trata: la posición del vértice actual, la normal a la superficie en este punto y las coordenadas de la textura a la que corresponde el vértice.


Tradicionalmente, las coordenadas en el espacio se designan como (x, y, z), y las coordenadas en el plano de textura como (u, v). De ahí el nombre de la variable. A menudo lo encontrarás en varios ejemplos. En teoría, necesitamos transferir estas coordenadas al sombreador de fragmentos para poder trabajar con ellas allí. Lo haremos


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

Para empezar, el fragment shader debería ser algo como esto:


 #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); } } 

Solo crea una malla. Si piensas un poco, en el plano será solo una cuadrícula de cuadrados, pero como lo superponemos en una esfera, se distorsiona y se convierte en un globo. Hay una buena imagen en Wikipedia que ilustra lo que está sucediendo:



Es decir, en el sombreador de fragmentos hacemos una textura plana, como en el centro de esta ilustración, y Three.js luego la arrastra hacia la esfera. Muy comodo



Por supuesto, para modelos más complejos, el barrido será más complicado. Pero generalmente al crear varios sitios de diseño trabajamos con formas geométricas simples y es fácil imaginar un barrido en su cabeza.


Ok, ¿qué puedes hacer al respecto?


La característica principal es que el material del sombreador puede cambiar con el tiempo. Esto no es algo estático que dibujamos una vez y olvidamos, podemos animarlo. Además, tanto en color (en el sombreador de fragmentos) como en forma (en el vértice). Esta es una herramienta muy poderosa.


En nuestro ejemplo, haremos un fuego que envuelva una esfera. Habrá dos esferas: una ordinaria (adentro) y la segunda del material del sombreador (afuera, con un radio grande). Agregar otra esfera no hará comentarios.



Primero, agregue el tiempo como una variable uniforme para los sombreadores de nuestro material. En ninguna parte sin tiempo. Ya hicimos esto en JS puro, pero en Three.js es igual de simple. Deje que el tiempo en los sombreadores se llame uTime y se almacene en la variable 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; } }); } 

Actualizamos todo con cada llamada a la función animada:


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

Fuego


Crear un fuego es esencialmente muy similar a generar un paisaje, pero en lugar de alturas, color. O transparencia, como en nuestro caso.


Funciones de aleatoriedad y ruido que ya hemos visto, no las analizaremos en detalle. Todo lo que tenemos que hacer es hacer ruido a diferentes frecuencias para agregar variedad, y hacer que cada uno de estos ruidos se mueva a diferentes velocidades. Obtendrá algo así como llamas, las grandes se mueven lentamente, las pequeñas se mueven más 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 la llama no cubra toda la esfera, jugamos con el cuarto parámetro de color, la transparencia, y lo unimos a la coordenada y. En nuestro caso, esta opción es muy conveniente. En términos más generales, aplicamos un gradiente con transparencia al ruido.


En momentos como estos, es útil recordar la función smoothstep.

En general, este enfoque para crear fuego usando sombreadores es un clásico. A menudo lo encontrarás en varios lugares. Será útil jugar con números mágicos: se establecen aleatoriamente en el ejemplo, y cómo se verá el plasma depende de ellos.


Para hacer que el fuego sea más interesante, pasemos al sombreador de vértices y a un pequeño chamán ...


¿Cómo hacer que la llama se vierta un poco en el espacio? Para los principiantes, esta pregunta puede causar grandes dificultades, a pesar de su simplicidad. Vi enfoques muy complejos para resolver este problema, pero en esencia, necesitamos mover suavemente los vértices de la esfera a lo largo de las líneas "desde su centro". De aquí para allá, de aquí para allá. Three.js ya nos ha pasado la posición actual del vértice y la normal: los usaremos. Para "hacia adelante y hacia atrás" se ajustará alguna función limitada, por ejemplo, un seno. Por supuesto, puede experimentar, pero el seno es la opción predeterminada.


No sé qué llevar, tome el seno. Mejor aún, la suma de los senos con diferentes frecuencias.

Cambiamos las coordenadas normales al valor obtenido y recalculamos de acuerdo con la fórmula previamente conocida.


 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); } 

Lo que obtenemos ya no es una esfera. Esto ... ni siquiera sé si este tiene un nombre. Pero, de nuevo, no te olvides de jugar con las probabilidades: afectan mucho. Al crear tales efectos, a menudo se selecciona algo por prueba y error y es muy útil desarrollar una "intuición matemática" en uno mismo: la capacidad de imaginar más o menos cómo se comporta una función, cómo depende de qué variables.


En esta etapa, tenemos una imagen interesante, pero un poco torpe. Primero, echemos un vistazo al postprocesamiento y luego pasemos a un ejemplo vivo.


Post procesamiento


La capacidad de hacer algo con la imagen renderizada de Three.js es algo muy útil, aunque se olvida inmerecidamente en numerosas series de lecciones. Técnicamente, esto se implementa de la siguiente manera: la imagen que nos dio el renderizador se envía a EffectComposer (siempre que sea un cuadro negro), que chamaniza algo en sí mismo y muestra la imagen final en el lienzo. Es decir, después del renderizador, se agrega un módulo más. Transferimos parámetros a este compositor: qué hacer con la imagen recibida. Uno de esos parámetros se llama pasar. En cierto sentido, el compositor funciona como un Gulp: no hace nada, le damos complementos que ya hacen el trabajo. Quizás no sea del todo correcto decirlo, pero la idea debería ser clara.


Todo lo que usaremos más adelante no está incluido en la estructura básica de Three.js, por lo que conectamos algunas dependencias y dependencias de las dependencias mismas:


 <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> 

Recuerde que estos scripts están incluidos en el paquete de tres y puede poner todo esto en un solo paquete usando un paquete web o análogos.

En su forma básica, el compositor se crea así:


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

RenderPass en realidad no hace nada nuevo. Simplemente representa lo que solíamos obtener de un renderizador regular. De hecho, si observa el código fuente de RenderPass, puede encontrar el renderizador estándar allí. Como ahora el renderizado está sucediendo allí, debemos reemplazar el renderizador con el compositor en nuestro script:


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

Este enfoque que utiliza RenderPass como primer paso es una práctica estándar cuando se trabaja con EffectComposer. Por lo general, primero necesitamos obtener una imagen renderizada de la escena, luego hacer algo con ella.


En los ejemplos de Three.js, en la sección de postprocesamiento, puede encontrar una cosa llamada UnrealBloomPass. Este es un script portado del motor Unreal. Agrega un poco de brillo que se puede usar para crear una iluminación más bella. A menudo, este será el primer paso para mejorar la imagen.


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

Tenga en cuenta: la opción renderToScreen está configurada solo para el último Pase que pasamos al compositor.


Pero ya veamos qué tipo de brillo nos dio este bloomPass y cómo encaja con la esfera:



De acuerdo, esto es mucho más interesante que solo una esfera y una fuente de luz ordinaria, ya que generalmente se muestran en las lecciones iniciales en Three.js.


Pero iremos aún más lejos ...


¡Más sombreadores para el dios sombreador!



Es muy útil usar console.log y observar la estructura del compositor. En él, puede encontrar algunos elementos con los nombres renderTarget1, renderTarget2, etc., donde los números corresponden a los índices de los pases pasados. Y luego queda claro por qué se llama a EffectComposer. Funciona según el principio de filtros en SVG. ¿Recuerdas que puedes usar el resultado de realizar algunos filtros en otros? Aquí lo mismo: puedes combinar efectos.


Usar console.log para comprender la estructura interna de los objetos Three.js y muchas otras bibliotecas es muy útil. Use este enfoque con más frecuencia para comprender mejor qué es qué.

Agrega otro pase. Esta vez será 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 contiene el resultado del pase anterior: bloomPass (fue el segundo en una fila), lo usamos como una textura (esto es esencialmente una imagen plana) y lo pasamos como una variable uniforme al nuevo sombreador.


Probablemente valga la pena frenar y darse cuenta de toda la magia aquí ...


A continuación, cree un sombreador de vértices simple. En la mayoría de los casos, en esta etapa, no necesitamos hacer nada con los vértices, solo pasamos las coordenadas (u, v) al sombreador de fragmentos:


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

Y en fragmentaria podemos divertirnos a nuestro gusto y color. Por ejemplo, podemos agregar un efecto de falla de luz, hacer que todo sea en blanco y negro y jugar con brillo / 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); } 

Veamos el resultado:



Como puede ver, los filtros se superponen a la esfera. Todavía es tridimensional, nada se ha roto, pero en el lienzo tenemos una imagen procesada.


Conclusión


Los materiales de sombreado y el procesamiento posterior en Three.js son dos herramientas pequeñas pero muy poderosas que definitivamente vale la pena usar. Hay muchas opciones para su uso: todo está limitado por su imaginación. Incluso las escenas más simples con su ayuda pueden cambiarse más allá del reconocimiento.

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


All Articles