轮廓线的绘制

1,335 阅读12分钟

轮廓线常用于场景中的物体拾取后高亮,在视觉呈现中起到了必不可少的作用。

首先探讨一下,什么是轮廓线。

第一种轮廓线就是描边,就是当前物体的边界。

第二种轮廓线就是能表现出一个物体的模糊形状,这种轮廓线包含了描边,且多出了内部的细节。

image.png

1 卡通轮廓线 背面绘制

不知道为什么前人称之为卡通轮廓线,或许是因为卡通材质十分的简单,这个轮廓线也十分的简单。

其基本原理就是,一个封闭的几何体,一条射线穿过的话,至少有两层。,相机看过去的时候,只能看到前面的一层, 如果没有前面这一层,就能看见它的背面。

正面会完美遮挡背面,所以如果我们让背面稍稍扩张一些,就像div的阴影延伸出来,结果就是一个轮廓线。

当然了,上面说的是原理,就算不是封闭的几何体,比如单平面,也是可以使用这种方案的。 因为,我们会额外绘制一个背面,原物体只需要负责正面就行。

所以这个方法就两步搞定。

1、克隆原本的物体并扩张,这里不能简单的缩放,我们需要按法线方向进行顶点外扩。

2、顶点外扩的物体仅仅绘制背面。

这样一个轮廓线就绘制出来了。 顶点外扩,这里也简单封装了一个材质, 后面几种方法都会用到顶点外扩。 这里顶点外扩的方式是直接按法线偏移的,会被相机的投影矩阵影响,所以轮廓的粗细会跟相机有关。

class ExpandMaterial extends RawShaderMaterial { 
    glslVersion =GLSL3;
    vertexShader = /*glsl*/`
    in vec3 position ;
    in vec3 normal ;
    uniform mat4 projectionMatrix;
    uniform mat4 modelViewMatrix;
    uniform mat3 normalMatrix;
    uniform float offset;
    void main() {
        vec4 worldPos = (modelViewMatrix * vec4(position,1.0));
        vec3 n= normalize(normalMatrix * normal) ;
        vec4 offsetPos =  vec4(offset*n * worldPos.w,0.0); // 只改三维坐标 齐次给他乘上
        gl_Position = projectionMatrix *(worldPos + offsetPos);
    }`;
    fragmentShader=  /*glsl*/`
    precision mediump float ;
    out vec4 fragColor;
    uniform vec4 color ;
    void main() {fragColor = vec4(color );}`;
    uniforms = { 
        color:{ 
            value: [0,0,0,1]
        },
        offset: {
            value:1.
        }
    }
    constructor(params ={} ) { 
        super(params) ;
        params.color&&( this.uniforms.color.value  = params.color) ;
        params.color&&( this.uniforms.offset.value  =params.offset );
    }
}


更新

后面发现,之前的偏移方式仍会受到模型矩阵缩放的影响,原来是法线没有归一化

现在有两种方式可以移除矩阵对偏移量的影响。以下是顶点着色器主要代码。

1 提取模型矩阵的缩放值,偏移量除以此值。这种方案仅仅移除了模型矩阵的影响。

    in vec3 position ;
    in vec3 normal ;
    uniform mat4 projectionMatrix;
    uniform mat4 modelViewMatrix;
    uniform float offset;
    float extractScaling(mat4 m) {
        // 提取前三列的向量长度
        vec3 sx = vec3(m[0][0], m[1][0], m[2][0]); // X轴缩放
        vec3 sy = vec3(m[0][1], m[1][1], m[2][1]); // Y轴缩放
        vec3 sz = vec3(m[0][2], m[1][2], m[2][2]); // Z轴缩放
        return sqrt(dot(sx, sx) + dot(sy, sy) + dot(sz, sz));
      }
    // 现在直接提取模型矩阵的缩放系数,除回去
    void main() {
        float scale = extractScaling(modelViewMatrix);
        gl_Position = projectionMatrix * modelViewMatrix *vec4 (position + normal * offset/scale, 1.0);
    }
    

2 参照three/examples/jsm/effect/outlineEffect的方法,计算在裁剪空间中偏移的方向,在最终的裁剪坐标(四维向量)的基础上再偏移。这种方法同时移除了模型矩阵和视图投影矩阵的影响。 这里和源代码唯一的区别就是norm方向的计算,及传参normal的方向。

    in vec3 position ;
    in vec3 normal ;
    uniform mat4 projectionMatrix;
    uniform mat4 modelViewMatrix;
    uniform float offset;
  vec4 calculateExpand(  vec3 normal, vec4 skinned ) {
        vec4 pos = projectionMatrix * modelViewMatrix * skinned;
	const float ratio = 0.1; // TODO: support outline thickness ratio for each vertex
        vec4 pos2 = projectionMatrix * modelViewMatrix * vec4( skinned.xyz + normal, 1.0 );
        vec4 norm = normalize( pos2 - pos );
        return pos + norm * offset * pos.w * ratio;
}
    void main() {
        gl_Position = calculateExpand(normal, vec4(position, 1.0));
    }
    


克隆原物体之后,替换为上述材质。

值得注意的地方,背面绘制的物体,其变换要和原物体一致。

export function getOutlineCartoonMesh(obj3d, color=[.5,.4,1,1], offset=.1 ) {
    
    // mask的值默认是0xff
    const outlineMat =  new ExpandMaterial({
        side:BackSide,
        colorWrite:true,
        stencilWrite:	false ,
        color , // 这里现在是直接能用的值没经过three转换
        offset
        
    })
    if(  typeof color[3] === 'number'){ 
        const alpha = color[3] ;
        if( alpha < 1){ 
            outlineMat.opacity = alpha ;
            outlineMat.transparent = true ;
            outlineMat.depthWrite = false ;
        }
     }
    const  outLineMesh  =obj3d.clone(true);
    outLineMesh.traverse((child)=> { 
        if(child.isMesh){
            child.material = outlineMat ;
       }
    })
    obj3d.add( outLineMesh);// 应该是可以直接添加到子节点
    outLineMesh.position.set(0,0,0);
    outLineMesh.scale.set(1,1,1);
    outLineMesh.quaternion.set(0,0,0,1);
    obj3d.userData.outLineMesh = outLineMesh ;// 用于移除
    return outLineMesh ;
    
}

具体实现见示例。

outlineEffect

后来我发现,three的outlineEffect,使用的就是这种方案,除了添加了很多常规特性支持外,和我这个简单的demo最大的区别就是,他不会去clone物体,而是在原本的场景绘制之后,再单独绘制一次轮廓线。

绘制轮廓线的时候,把全部的物体材质替换为类似上面的材质,这样就行了。

2 模板测试轮廓线

如果你已经理解模板测试是如何工作的,这里就非常简单了。

前面说了,卡通轮廓线的原理是原本的物体遮挡了非轮廓线的部分, 也就是依赖深度测试。

如果希望画出下面这种轮廓线,它是无能为力的。

因为,要让轮廓线不被遮挡,一直可见,就需要关掉深度测试,或者改变其深度值。但是,如此一来,呈现在我们面前不会是一个轮廓线,而是一个整个纯色物体。

image.png

模板测试轮廓线的绘制步骤如下。

1 第一次绘制原物体,模板测试设为一直通过,带上模板值1。

2 第二次绘制外扩后的物体,带上模板值0 ,设定只有模板值相等才通过测试。

结果就是第而次绘制的物体只有轮廓线部分会保留。

基本绘制到这里就结束了,但是,现在还要做一件事,让轮廓线永远可见,那就需要在进行上述步骤的时候关闭深度测试。 这里修改材质的配置即可。

主要代码如下,我们复用上面的外扩材质。


export function getOutlineMesh(obj3d, color = [.5, .4, 1], offset = .1) {

  // 如果这里colorwrite改成true  那应该用这个替换原先的mesh
  const stencilMat = new MeshBasicMaterial({
    side: FrontSide,
    colorWrite: false,
    stencilWrite: true,
    stencilRef: 1,
    stencilFunc: AlwaysStencilFunc,
    stencilFail: KeepStencilOp,
    stencilZFail: KeepStencilOp,
    stencilZPass: ReplaceStencilOp,
    depthTest:false

  })
  // mask的值默认是0xff
  const outlineMat = new ExpandMaterial({
    side: FrontSide,
    colorWrite: true,
    stencilWrite: true,
    stencilRef: 0,
    stencilFunc: EqualStencilFunc,
    stencilFail: KeepStencilOp,
    stencilZFail: KeepStencilOp,
    stencilZPass: ReplaceStencilOp,
    color, // 这里现在是直接能用的值没经过three转换
    offset
  });

  outlineMat.depthTest = false;

  obj3d.updateWorldMatrix(true, true);
  const stencilMesh = new Object3D();
  stencilMesh.onBeforeRender = clearStencil
  stencilMesh.onAfterRender = clearStencil
  const group = obj3d.clone(false);
  obj3d.matrixWorld.decompose(group.position, group.quaternion, group.scale);
  const stencilGroup = group.clone();
  if (obj3d instanceof Mesh) {
    (stencilGroup).material = stencilMat;
    (group).material = outlineMat;
  } else {
    obj3d.traverse((child) => {
      if (child.isMesh) {
        const m1 = child.clone();
        const m2 = m1.clone();
        m1.material = outlineMat;
        m2.material = stencilMat;
        group.add(m1)
        stencilGroup.add(m2)
      }
    })
  }
  stencilMesh.add(stencilGroup, group); //这样就会先渲染模板,
  return stencilMesh;

}

可以看到,即便有一个平面遮挡,也能看见小男孩的轮廓。

3 卷积轮廓线

这里就用到后处理了。顺便一提的是,人眼是如何区分轮廓的。 那就是两种不同颜色的边界。如果两个物体的颜色十分接近,你是区别不了的,变色龙就是利用了这一点隐形的。

卷积很简单,就是求平均值。

绘制黑白图

先说绘制方法。 我们配置两种颜色,就黑白吧,简单。

黑色作为背景,物体绘制为白色,最后我们就能得到下面这样的一张图。

image.png

找到黑白边界

然后,就用卷积来找到边缘,也就是黑白交界的地方。

我们定义一个九宫格,每次都取当前的格子及其周围的八个格子的颜色,加在一起。 因为我们用的是黑白两色,所以即便发生了补间,rgb三通道仍旧是相等的,我们就可以直接取某一个通道的值就行了。

如果求和的结果是9 ,说明全白,那就是在物体内的,结果是0 ,那就是全黑,是在物体外的。 我们甚至都不用求平均值,相当便捷。

float dx = dFdx( v_uv.x),dy = dFdy(v_uv.y) ;
      float  sum   ;
      int N = lineWidth;
      for(int i = -1; i < 2; i++) {
        for(int j = -1; j < 2; j++) {
          sum +=  texture(sampler2, v_uv + vec2(float( i* N)* dx , float(j*N)* dy)).r ;
        }
      }

中间的1~8灰色地带,就是我们要找的轮廓线。

image.png

krita_LpqXoDyET7.gif

基于上述原理,很明显我们需要用到后处理通道。绘制步骤如下。

1. 单独绘制物体得到黑白图,存在纹理sampler2中。

2. 将整个场景绘制为彩图,存在纹理sampler1中。

3. 读取纹理sampler2,用卷积找出边缘,和sample1进行混合。

这里我将其封装为了一个effect。 可以看到是需要调用三次render方法的,其实这里可以不将整个场景绘制为纹理,而是直接把轮廓线所在的平面蒙在上面的。

为什么我会有这个想法呢?因为,我发现three中,只要用了后处理通道,把场景绘制为纹理,再绘制到一个平面上,最后锯齿就会比之前严重的多,所以最后是避免走这一遭。

export class EdgeEfect  { 
    texture1 = new WebGLRenderTarget() ;
    texture2 = new WebGLRenderTarget() ;
    plainMat = new ExpandMaterial() ;
    edgeMat = new  RawShaderMaterial( { 
        glslVersion:GLSL3,
    vertexShader: /*glsl*/`   
    in vec3 position ;
    in vec2 uv ;
    out vec2  v_uv ;
        void main() {
            v_uv = uv ;
            gl_Position = vec4( position, 1.0 );}`,
    fragmentShader: /*glsl*/` 
    precision highp float;
    uniform vec3  color ;
    uniform sampler2D sampler1 ;
    uniform sampler2D sampler2 ;
    uniform int lineWidth ;
    in vec2 v_uv  ;
    out vec4 fragColor;
    void main() {
      vec4 outColor  = texture( sampler1, v_uv) ;
      float dx = dFdx( v_uv.x),dy = dFdy(v_uv.y) ;
    
      // 这里就卷九宫格吧,我的颜色已经确定只有黑白
      float  sum   ;
      int N = lineWidth;
      for(int i = -1; i < 2; i++) {
        for(int j = -1; j < 2; j++) {
          sum +=  texture(sampler2, v_uv + vec2(float( i* N)* dx , float(j*N)* dy)).r ;
        }
      }
      // 0-9  比如我希望 1-8 内画轮廓  算了 用if吧
      float factor  =  sum < 1. || sum >8. ? 0.: 1.  ;
      
  float  k = step( 5., sum) ;
   k *= smoothstep (9.,5.,sum  ) ;
   
      outColor = mix( outColor, vec4( color* (k+.3), 1.), step(0.1,k)) ;
      fragColor =  outColor ; // 这样一个简单的轮廓线就有了,  视线和法线垂直的地方才可见
    }` ,
    uniforms: { 
        sampler1:{},
        sampler2:{},
        color: { 
            value: [1,0,1]
        },
        lineWidth:{ 
            value :10
        }
    }
    })
    constructor(renderer = new WebGLRenderer(), object = new Object3D(), color=[1,0,1], offset=.1, lineWidth =10){ 
        this.renderer = renderer ;
        let width = renderer.domElement.width ;
        let height = renderer.domElement.height ;
        // 色彩空间的问题
        this.texture1.setSize( width, height);
        this.texture2.setSize( width, height);
        this.plainMat.uniforms.offset.value =offset ;
        this.edgeMat.uniforms.color.value = color ;
        this.edgeMat.uniforms.lineWidth.value = lineWidth ;
        if( object){ 
            this.setTarget(object);
        }
        this.quadMesh =  new Mesh( _geometry, this.edgeMat) ;
    }
    setTarget(object){ 
        object.updateWorldMatrix(true,true);
        this.edgeObject = object.clone(true) ;
        object.matrixWorld.decompose( this.edgeObject.position, this.edgeObject.quaternion, this.edgeObject.scale);

        this.edgeObject.traverse((child)=> {
            if(child.isMesh) {
                child.material = this.plainMat ;
            }
        });
        
    }

    render(scene , camera){
        
     if( !this.edgeObject){ return this.renderer.render( scene,camera)}   
     // 原始图可以先绘制也可以最后 
     this.renderer.autoClear = false ;
     this.renderer.setRenderTarget(this.texture1) ;
     this.renderer.clear() ;
     this.renderer.render(scene,camera) ;

     //  然后绘制纯色图
     this.renderer.setRenderTarget(this.texture2) ;
     this.renderer.setClearColor(0xffffff) ;//白底黑图
     this.renderer.clear(true,true,true) ; //  还是不能留, 除非吧深度测试的规则改成等于也可以进来
     this.renderer.render(this.edgeObject, camera) ;
     
    //  更新纹理
     this.edgeMat.uniforms.sampler1.value = this.texture1.texture
     this.edgeMat.uniforms.sampler2.value = this.texture2.texture
     // 最后卷积出轮廓 绘制在原图之上
     this.renderer.setRenderTarget( null) ;
     this.renderer.clear() ;
     this.renderer.render(this.quadMesh, camera) ;
    //  this.renderer.autoClear =  true;
    }
}

可以看到最后的结果不太好,我没加抗锯齿通道,加了也不会太好。仔细观察会发现轮廓线的颜色在变化,因为我把到边缘的距离和颜色关联起来了。

现在卷积的是九宫格,是可以设置八个颜色节点的。也可以使用16格,当然那样性能消耗会大一些。

4 概念版轮廓线 纯材质

可以说,这是我唯一一个自己想到的绘制方法。 但是还没完全实现,因此称之为概念版。

当时的想法是,所谓轮廓线,就是只能看到边缘,正对着我们视线的部分,反而什么都看不见。 所以如何判定边缘呢? 那自然是通过判断视线和物体表面法线的重合度,也就是求内积就可以了。

从光追的角度来说,这轮廓线就是反着来的,进入人眼的光线强度越小,轮廓线就越明显。 所以这个轮廓线强烈依赖法线。

想象很美好,结果做出的并不是常规的轮廓线。 只有用在类似球体这种几何体上的时候,它才能称之为轮廓线,否则就和下图一样。 float fractor = 1.- abs(dot(eyeLight, v_normal))

1723878199918.gif

核心着色器代码就是这些了,计算法线和视线和点积,判断点积小于某个值的时候不显示。

但是,效果比较诡异,于是就改成了,把计算结果和透明度关联起来。 下面的code示例就是这样的。

out  vec3 v_normal ;
out  vec3 v_worldPos ;
uniform float offset ;
void main() {    vec3 posOffset =offset * normal;  
  v_worldPos = (modelMatrix  * vec4(position +posOffset,1.)).xyz ; 
  v_normal = (modelMatrix * vec4(normal, 1.)).xyz;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position +posOffset, 1.0 );}
  
  in  vec3 v_normal ;
  in  vec3 v_worldPos ;
  uniform vec3  color ;
  void main() {
    vec3 eyeLight = cameraPosition - v_worldPos ;  // 指向相机的视线。
    float fractor = 1.-  abs(dot(eyeLight, v_normal)) ;// 绝对值还是要得
    if( fractor < .9) discard;
    gl_FragColor = vec4( color * fractor, 1.0 ); // 这样一个简单的轮廓线就有了,  视线和法线垂直的地方才可见}

总结

本文介绍了四种绘制轮廓线的简单方式,实际应用可能需要增加需要许多配置,或者结合几种。

  1. 卡通轮廓线很简单,不仅能描边,还能绘制出内部一部分细节,但是依赖深度测试,做不到一直显示在前面。

  2. 模板测试轮廓线,可以关掉深度测试,一直显示在前,但是它只能描边,相对的开了模板测试,性能会多消耗一些。

  3. 卷积轮廓线,用到了后处理,实际上,它的特点是可以做颜色渐变,也就是轮廓发光。three的outlinePass就是成熟的方案,但是后处理带来的走样问题,看能否接受了。这种方式使用了后处理,其性能消耗又多了一些呢。

  4. 纯材质轮廓线,这方案和其它方案最大的区别就是,它可以单独绘制,对原场景没有任何依赖和影响,就是效果还不太好。

three还有一种轮廓线,是纯几何的EdgesGeometry,需要基于原始模型的顶点算出新的几何体。如果是静态的场景,最好是预先把几何体算出来。

除了three的outlineEffect和outlinePass, 这也推荐一个比较完善的轮廓线实现(webgl-outline)[github.com/OmarShehata…