如何使用 Oasis 快速实现描边效果

前言

描边可以分为外描边、内描边、后处理描边外描边只显示整个模型的外轮廓,模型的内部细节是无法描边的,比如眼睛,眉毛这些内部元素;内描边不仅可以显示外轮廓,还能够显示眼睛、眉毛、腰带这种内部轮廓;后处理描边其实是检测颜色的突变,无论模型是否有凹凸细节,只要有颜色发生变化,都会被当作轮廓绘制。

本文介绍两种描边方法:一种是先渲染想要描边的模型,然后沿法线撑大模型,将描边效果(如纯黑)作为该模型的材质再渲染一遍,其中可以利用模版测试和正面剔除来达到第二遍的pass只渲染描边;另一种是图像后处理,利用2D图形算法针对整幅画面进行边缘检测。在 Oasis 中,都有相应的组件能够方便快速地实现。

使用多渲染器实现内外描边

引擎截至v0.6还不支持 Shader 多 Pass,所以本文介绍通过多渲染器的方式来达到同样的目的

在 Oasis 中 3D 模型的渲染可以用渲染器组件来实现,渲染器组件由网格材质组成,网格负责存储顶点、法线等元数据,材质负责像素着色。我们一般不需要手动设置渲染器,通过加载 glTF 模型 会自带渲染器。当我们渲染描边的时候,因为多个渲染器组件可以共享同一个网格或者材质,所以我们只需要新增一个渲染器组件,网格从 glTF 里面获取,材质参考自定义材质教程创建一个纯色的描边材质就行。

新增渲染器的代码如下:

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);
});
复制代码

外描边

外描边可以参考模板测试教程来实现,根本原理就是通过比较模版缓冲区的值来决定是否渲染该片元。

1. 填入模板

在 Oasis 中操作渲染状态只需要对材质的 renderState 赋值即可。按照上面的思路,我们先开启原渲染器的模板状态,并强制写入 1,因为 canvas 的默认模版值为 0 ,所以第一次渲染,保证了渲染的像素位置模版值为1,其余的地方为0 :

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

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

如下图的黑色部分表示模版值为1,白色部分为默认0:
image.png

2. 撑大

接下来,我们需要将模型的所有顶点沿着法线方向撑大,可以参考自定义材质教程的写法,在顶点着色器中对 gl_Position 进行缩放:

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 查看。

  • 截至 Oasis 0.6,Oasis 还没有封装相应的全屏后处理组件和 shader 多 pass 能力,用户需要创建多个渲染器,或者创建离屏纹理、脚本、全屏Plane ,稍微有点不方便,我们在未来会封装相应的能力,使用户能够专注于算法实现,简化流程步骤。
  • 描边实现还有很多方法和细节,比如镜头空间的描边粗细,描边的锯齿问题,描边的破面问题等等,一般可以针对具体的应用场景选择适合的方法进行实现或者定制。

最后

为了给广大的 Oasis 引擎用户提供更高效、更便捷的工作流,编辑器对外开放是我们 Oasis 团队的核心重点之一。为了更好的服务大家,我们准备了一份关于 “互动图形引擎调研” 的问卷 (幸运用户将获得我们准备的精美奖品),期待您的反馈~
170837-256.png

猜你喜欢

转载自juejin.im/post/7084165043264159775