轮廓线常用于场景中的物体拾取后高亮,在视觉呈现中起到了必不可少的作用。
首先探讨一下,什么是轮廓线。
第一种轮廓线就是描边,就是当前物体的边界。
第二种轮廓线就是能表现出一个物体的模糊形状,这种轮廓线包含了描边,且多出了内部的细节。
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 模板测试轮廓线
如果你已经理解模板测试是如何工作的,这里就非常简单了。
前面说了,卡通轮廓线的原理是原本的物体遮挡了非轮廓线的部分, 也就是依赖深度测试。
如果希望画出下面这种轮廓线,它是无能为力的。
因为,要让轮廓线不被遮挡,一直可见,就需要关掉深度测试,或者改变其深度值。但是,如此一来,呈现在我们面前不会是一个轮廓线,而是一个整个纯色物体。
模板测试轮廓线的绘制步骤如下。
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 卷积轮廓线
这里就用到后处理了。顺便一提的是,人眼是如何区分轮廓的。 那就是两种不同颜色的边界。如果两个物体的颜色十分接近,你是区别不了的,变色龙就是利用了这一点隐形的。
卷积很简单,就是求平均值。
绘制黑白图
先说绘制方法。 我们配置两种颜色,就黑白吧,简单。
黑色作为背景,物体绘制为白色,最后我们就能得到下面这样的一张图。
找到黑白边界
然后,就用卷积来找到边缘,也就是黑白交界的地方。
我们定义一个九宫格,每次都取当前的格子及其周围的八个格子的颜色,加在一起。 因为我们用的是黑白两色,所以即便发生了补间,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灰色地带,就是我们要找的轮廓线。
基于上述原理,很明显我们需要用到后处理通道。绘制步骤如下。
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))
核心着色器代码就是这些了,计算法线和视线和点积,判断点积小于某个值的时候不显示。
但是,效果比较诡异,于是就改成了,把计算结果和透明度关联起来。 下面的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 ); // 这样一个简单的轮廓线就有了, 视线和法线垂直的地方才可见}
总结
本文介绍了四种绘制轮廓线的简单方式,实际应用可能需要增加需要许多配置,或者结合几种。
-
卡通轮廓线很简单,不仅能描边,还能绘制出内部一部分细节,但是依赖深度测试,做不到一直显示在前面。
-
模板测试轮廓线,可以关掉深度测试,一直显示在前,但是它只能描边,相对的开了模板测试,性能会多消耗一些。
-
卷积轮廓线,用到了后处理,实际上,它的特点是可以做颜色渐变,也就是轮廓发光。three的outlinePass就是成熟的方案,但是后处理带来的走样问题,看能否接受了。这种方式使用了后处理,其性能消耗又多了一些呢。
-
纯材质轮廓线,这方案和其它方案最大的区别就是,它可以单独绘制,对原场景没有任何依赖和影响,就是效果还不太好。
three还有一种轮廓线,是纯几何的EdgesGeometry,需要基于原始模型的顶点算出新的几何体。如果是静态的场景,最好是预先把几何体算出来。
除了three的outlineEffect和outlinePass, 这也推荐一个比较完善的轮廓线实现(webgl-outline)[github.com/OmarShehata…