Cómo usar Oasis para lograr rápidamente efectos de trazo

prefacio

Los trazos se pueden dividir en trazos externos, trazos internos y trazos de posprocesamiento . El trazo exterior solo muestra el contorno exterior de todo el modelo, y los detalles internos del modelo no se pueden acariciar, como elementos internos como ojos y cejas; el trazo interior no solo puede mostrar el contorno exterior, sino también el contorno interior de los ojos, cejas y cinturones. ; El trazo de posprocesamiento es en realidad para detectar la mutación del color. Independientemente de si el modelo tiene detalles de protuberancia, siempre que el color cambie, se dibujará como un contorno.

Este artículo presenta dos métodos de trazo: uno es renderizar el modelo que desea trazar primero, luego expandir el modelo a lo largo de la línea normal y renderizar el efecto de trazo (como el negro puro) como el material del modelo, en el que puede usar la plantilla El segundo paso de prueba y selección frontal solo genera trazos; el otro es el posprocesamiento de imágenes, que utiliza algoritmos de gráficos 2D para realizar la detección de bordes en toda la pantalla. En Oasis, hay componentes correspondientes que se pueden implementar fácil y rápidamente.

Uso de renderizadores múltiples para lograr trazos internos y externos

A partir de la v0.6, el motor no es compatible con Shader multi-pass, por lo que este artículo presenta el método de renderizado múltiple para lograr el mismo propósito.

La representación de modelos 3D en Oasis se puede implementar con componentes de representación. Los componentes de representación están compuestos de mallas y materiales . Las mallas son responsables de almacenar metadatos como vértices y normales, y los materiales son responsables de la coloración de píxeles. Por lo general, no necesitamos configurar manualmente el renderizador, al cargar el modelo glTF , el renderizador vendrá con él. Cuando renderizamos trazos, debido a que varios componentes del renderizador pueden compartir la misma malla o material, solo necesitamos agregar un componente del renderizador, la malla se obtiene de glTF y el material se crea consultando el tutorial de material personalizado para crear un color sólido. El material del trazo servirá.

El código para el nuevo renderizador es el siguiente:

const renderers: MeshRenderer[] = [];
// 从 glTF 中获取所有渲染器组件
rootEntity.getComponentsIncludeChildren(MeshRenderer, renderers);

// 新增描边渲染器组件
renderers.forEach((renderer) => {
   const entity = renderer.entity;
   // 新增渲染器组件
   const borderRenderer = entity.addComponent(MeshRenderer);
   // 网格 - 共享网格数据
   borderRenderer.mesh = renderer.mesh;
   // 设置描边材质,具体的会在下文中介绍
   borderRenderer.setMaterial(borderMaterial);
});
复制代码

trazo exterior

El trazo externo se puede implementar consultando el tutorial de prueba de la plantilla El principio básico es decidir si renderizar el fragmento comparando el valor del búfer de la plantilla .

1. Rellena la plantilla

Manipular el estado de renderizado en Oasis solo requiere asignar un valor al renderState del material . De acuerdo con la idea anterior, primero abrimos el estado de la plantilla del renderizador original y forzamos a escribir 1, porque el valor predeterminado de la plantilla del lienzo es 0, por lo que la primera representación garantiza que el valor de la plantilla de posición de píxel de la representación sea 1, y el resto del lugar es 0:

const material = renderer.getBorderMaterial();
const stencilState = material.renderState.stencilState;

// 开启模版测试
stencilState.enabled = true;
// 设置参考值为 1
stencilState.referenceValue = 1;
// 通过时将“参考值”写入模版缓冲。因为默认“总是通过”,所以强制写入1
stencilState.passOperationFront = StencilOperation.Replace;
复制代码

La parte negra de la siguiente figura indica que el valor de la plantilla es 1 y la parte blanca es el valor predeterminado 0:
imagen.png

2. Estirar

A continuación, necesitamos expandir todos los vértices del modelo a lo largo de la dirección normal. Puede consultar el tutorial de material personalizado y escalar gl_Position en el sombreador de vértices :

const vertex = `
  attribute vec3 POSITION;
  attribute vec3 NORMAL;

  uniform float u_width;
  uniform mat4 u_MVPMat;
  uniform mat4 u_modelMat;
  uniform mat4 u_viewMat;
  uniform mat4 u_projMat;
  uniform mat4 u_normalMat;

  void main() {
     vec4 mPosition = u_modelMat * vec4(POSITION, 1.0);
     // 得到世界坐标系下的归一化法线矢量
     vec3 mNormal = normalize( mat3(u_normalMat) * NORMAL );
     // 将模型位置沿着法线方向缩放 u_width
     mPosition.xyz += mNormal * u_width;
     gl_Position = u_projMat * u_viewMat * mPosition;
  }
`;
复制代码

描边材质只需要在片元着色器中设置纯色即可:

const fragment = `
uniform vec3 u_color;

void main(){
  gl_FragColor = vec4(u_color, 1);
}
`;
复制代码

创建完描边材质后,我们将它绑定到新增的渲染器组件上,我们会发现缩放的渲染器组件生效了,但是会挡在原来的渲染器前面,如下图:
border-1.gif
这是因为我们还没有对描边材质配置模版状态,这时候生效的是深度测试,因为撑大后的模型离屏幕更近,所以覆盖了原来的渲染器。

3. 模板测试

第一个渲染器已经将 1 填入了模板缓冲,我们可以在渲染撑大的模型的时候,只渲染那些不等于1的像素,即将模板测试的比较函数设置为不相等

如下图所示,利用模板测试只渲染了撑大的部分,即描边部分:
image.png
描边材质的模板测试代码如下:

// 保证最后再渲染描边材质
material.renderQueueType = RenderQueueType.Transparent + 1;

// 双面渲染防止一些破面
material.renderState.rasterState.cullMode = CullMode.Off;
const stencilState = material.renderState.stencilState;
// 开启模版测试
stencilState.enabled = true;

// 设置比较值
stencilState.referenceValue = 1;

// 通过条件:referenceValue != 模板缓冲值
stencilState.compareFunctionFront = CompareFunction.NotEqual;
stencilState.compareFunctionBack = CompareFunction.NotEqual;
复制代码

最终达到的外描边效果:
image.pngimage.png

内描边

因为模板测试只能渲染撑大的那一部分,所以无法显示内轮廓,比如腰带等等,我们一般使用第二遍 shader pass 采用正面剔除的方法来实现内描边。

1. 渲染正面

正常渲染模型,不需要设置任何模版状态或者深度状态。

2. 撑大

撑大部分的代码还是和前面一样,沿着法线缩放,参考前面即可。

3. 渲染背面
// 保证最后渲染描边材质
material.renderQueueType = RenderQueueType.Transparent + 1;
// 剔除正面,只渲染背面
material.renderState.rasterState.cullMode = CullMode.Front;
复制代码

渲染撑大后的背面,能够保证撑大的模型不会因为深度测试挡住本来的模型,也不会因为模板测试只渲染模型的外轮廓,内描边效果如下图,可以看到,模型的内部轮廓也显示了:
image.pngimage.png

使用后处理实现描边

在前言里面提到过,除了使用多渲染器方案实现内外描边效果外,还可以使用后处理方案对图像进行边缘检测。

后处理的优点:

  • 在某些场景下(如模型面数非常多)能够提高性能。
  • 能够整体处理描边效果,解决多模型描边重合的问题。
  • 能够解决模型撑大后破面的问题(如正方体撑大后因为法线拐角超过90度,会导致破面)。

1. 渲染离屏纹理

Oasis 使用 RenderTarget 来渲染离屏纹理,即不渲染到屏幕,而是渲染到一张纹理上,然后利用这张离屏纹理来实现各种后处理特效,比如描边。

const { width, height } = engine.canvas;
const renderColorTexture = new RenderColorTexture(engine, width, height);
const renderTarget = new RenderTarget(engine, width, height, renderColorTexture);
复制代码

2.添加脚本

RenderTarget 是过程式,我们需要利用脚本的生命周期将整个渲染管线串起来。

我们可以给相机创建一个脚本,在渲染场景前设置 renderTarget ,代表我们想将场景渲染到纹理上;在渲染后,我们将 renderTarget 清空,并设置了和全屏平面一样的 Layer1,代表我们想将离屏纹理绘制到屏幕上,并进行描边后处理:

 class PostScript extends Script {
  renderTarget: RenderTarget;

  onBeginRender(camera: Camera): void {
    // 绘制场景到纹理上
    camera.renderTarget = this.renderTarget;
    camera.cullingMask = Layer.Layer0;
  }

  onEndRender(camera: Camera): void {
    camera.renderTarget = null;
    // 绘制到屏幕上,并在screenMaterial里面进行描边后处理
    camera.cullingMask = Layer.Layer1;
    // 再绘制一遍,只绘制全屏纹理
    camera.render();
  }
}


// 创建全屏 Plane, 用来实现全屏后处理特效。
const screen = rootEntity.createChild("screen"));
const screenRenderer = screen.addComponent(MeshRenderer);
screen.layer = Layer.Layer1;
screenRenderer.mesh = PrimitiveMesh.createPlane(engine, 2, 2);
// 自定义材质,shader 里面运行边缘检测算法。 
const material = this.getScreenMaterial(engine);
screenRenderer.setMaterial(material);

const renderColorTexture = new RenderColorTexture(engine, width, height);
const renderTarget = new RenderTarget(engine, width, height, renderColorTexture);
// 传入默认管线得到的离屏纹理。
material.shaderData.setTexture("u_texture", renderColorTexture);

// 添加脚本
const screenRenderer = screen.addComponent(MeshRenderer);
screenRenderer.renderTarget = renderTarget;

复制代码

可以看到,我们创建了一个掩码为 Layer1 的平面, 只用来渲染一个全屏平面,如果直接渲染离屏纹理,看到的效果就是场景直接渲染在屏幕上,但是我们想要描边效果,就需要对这张纹理做一点算法处理。

3. sobel 边缘检测

边缘检测的方法很多,这里用 sobel 卷积因子来举例。我们把后处理中的每一个像素亮度分别进行纵向、横向的卷积运算,即每个像素的周边9个像素亮度分别乘以下图这些权重:
image.png
G_x

image.png
G_y

黄色和红色为分别对应的权重,可以看到,如果一个像素的周边信号(如亮度)变化越陡峭,则卷积结果越大,我们可以利用这个特性来检测边缘,因为边缘那几个像素的亮度变化都是比较陡峭的。

4. shader 代码

基于 sobel 的边缘检测算法,我们可以通过离屏纹理,绘制描边效果,shader 代码如下:

// 描边颜色
uniform vec3 u_color;
// 离屏纹理
uniform sampler2D u_texture;
// 纹素大小
uniform vec2 u_texSize;

varying vec2 v_uv;

// 对应像素的亮度
float luminance(vec4 color) {
    return  0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b; 
}

// 前面提到的 sobel 卷积运算,得到陡峭程度。
float sobel() {
  // sobel 卷积因子
  float Gx[9] = float[](
              -1.0,  0.0,  1.0,
              -2.0,  0.0,  2.0,
              -1.0,  0.0,  1.0);
  float Gy[9] = float[](
              -1.0, -2.0, -1.0,
              0.0,  0.0,  0.0,
              1.0,  2.0,  1.0);		
      
  float texColor;
  float edgeX = 0.0;
  float edgeY = 0.0;
  vec2 uv[9];

  // 周边的9个像素的纹理坐标。
  uv[0] = v_uv + u_texSize.xy * vec2(-1, -1);
  uv[1] = v_uv + u_texSize.xy * vec2(0, -1);
  uv[2] = v_uv + u_texSize.xy * vec2(1, -1);
  uv[3] = v_uv + u_texSize.xy * vec2(-1, 0);
  uv[4] = v_uv + u_texSize.xy * vec2(0, 0);
  uv[5] = v_uv + u_texSize.xy * vec2(1, 0);
  uv[6] = v_uv + u_texSize.xy * vec2(-1, 1);
  uv[7] = v_uv + u_texSize.xy * vec2(0, 1);
  uv[8] = v_uv + u_texSize.xy * vec2(1, 1);

  for (int i = 0; i < 9; i++) {
    // 计算下对应纹素的亮度
    texColor = luminance(texture2D(u_texture, uv[i]));
    // 横向卷积
    edgeX += texColor * Gx[i];
    // 纵向卷积
    edgeY += texColor * Gy[i];
  }
                  
    return abs(edgeX) + abs(edgeY);
}

void main(){
  float sobelFactor = sobel();
  // sobel 越大,说明陡峭程度越大,越接近描边颜色。
  gl_FragColor = mix( texture2D(u_texture, v_uv), vec4(u_color,1.0), sobelFactor);  
}
复制代码

得到渲染结果如下:
image.png

结语

本篇所有相关代码和效果可以通过 Playground 查看。

  • A partir de Oasis 0.6 , Oasis no ha encapsulado los componentes de posprocesamiento de pantalla completa correspondientes y las capacidades de paso múltiple de sombreado. pequeño inconveniente. Vamos a resumir lo correspondiente en el futuro. La capacidad de permitir a los usuarios centrarse en la implementación de algoritmos, agilizando los pasos del proceso.
  • Hay muchos métodos y detalles para la implementación del trazo, como el grosor del trazo en el espacio de la lente, la irregularidad del trazo, la cara rota del trazo, etc. En general, puede elegir el método apropiado para escenarios de aplicación para implementar o personalizar.

Al final

Con el fin de proporcionar un flujo de trabajo más eficiente y conveniente para la mayoría de los usuarios del motor Oasis, abrir el editor al mundo exterior es uno de los enfoques principales de nuestro equipo de Oasis. Con el fin de brindarle un mejor servicio, hemos preparado un cuestionario sobre " Investigación de motores de gráficos interactivos " ( los usuarios afortunados recibirán premios exquisitos preparados por nosotros ), esperamos sus comentarios ~
170837-256.png

Supongo que te gusta

Origin juejin.im/post/7084165043264159775
Recomendado
Clasificación