L-LingRen / UnitySimpleCartoonLine

使用模型网格生成的卡通描边
131 stars 38 forks source link

关于如何绘制出完美宽度的线条 #2

Open JavinYang opened 3 years ago

JavinYang commented 3 years ago

当前的工程的线条会产生粗细变化原因是。 在这里深度使用的e0和e1的深度

    float3 v0 = float3(e0.xy + n1 / 2.0, e0.z);
    float3 v1 = float3(e0.xy - n1 / 2.0,  e0.z);
    float3 v2 = float3(e1.xy + n2 / 2.0, e1.z);
    float3 v3 = float3(e1.xy - n2 / 2.0, e1.z);

随着模型角度变化线条会插入到模型中,导致深度检测失败。就导致了线条的随着角度变化产生了粗细不均匀的变化,通过使用正方形模型把线条调粗这个现象会很明显。

我尝试解决这个问题,但目前还没想到完美的方案。目前想了两种方案。 第一种就是不使用深度检测直接剔除掉正面,但是这种情况下复杂模型不能使用比如甜甜圈结构有两层正面。这样前后两层正面的线条就会叠加到一起,只有简单的模型比如圆形,方形才可以用。(这个已经实现) 第二种方式就是考虑直接控制v0,v1,v2,v3的z深度值。做法是将triangle1_vertex3和triangle2_vertex3进行MVP计算。然后再计算出v0,v1,v2,v3修正的的深度值,但是这样也不够完美,因为vertex1和vertex2是线的两个端的点,每个点极有可能会有相邻的多个线而不是一条线,如果只有一条线我们只需要z深度修正到正确的位置就不会被这条线所覆盖,但是多条线就像波浪一样,会造成线条宽度的不完美。 我还在思考如果有什么想法方案还请鞭打我。

L-LingRen commented 3 years ago

我想到的是在世界空间计算线宽,然后绘制四边形。但世界空间绘制四边形并且不能穿模,我很久前尝试过了,比较复杂,最后我放弃了。

JavinYang commented 3 years ago

我想到的是在世界空间计算线宽,然后绘制四边形。但世界空间绘制四边形并且不能穿模,我很久前尝试过了,比较复杂,最后我放弃了。

在世界空间中绘制出的线条还要想在投影矩阵后看到的是一样粗的确实不好整。着先要知道他在投影矩阵之后的位置是哪里,然后再反推...计算量有点大,我想了个新办法应该可能大概行我先试试哈。

JavinYang commented 3 years ago

我想到的是在世界空间计算线宽,然后绘制四边形。但世界空间绘制四边形并且不能穿模,我很久前尝试过了,比较复杂,最后我放弃了。

我想了两种办法,第一种办法较为复杂我完成了一半,基本原理就是在view空间中计算出相邻线的交叉点,然后把内部的三角形的一个角直接移动到这个点(对三角面的位置进行修正),每个三角形保存了临近的退化四边形(每条边一个)我将这个东西叫做退化三角形(Degraded Triangles),首先计算出所有view空间中每个退化四边形线的位置,然后再绘制退化三角形,因为退化三角形里面存了退化四边形的id,所以可以获取到每条边之前计算出来的偏移位置,然后相邻的边直接求直线焦点(当然每个退化四边形会绘制出的线有4个顶点e0,e1,e2,e3我们要判断是哪两条线求交点我的做法是判断线是否是在退化三角形那一侧),并把点直接设置到这个交点位置(到这里已经完成了)。 以上大部分计算都是在计算着色器里完成的,最终把view空间中的点交给渲染管线去渲染。 但是实际上这样做是有显示bug的,如果只计算每个退化三角形中相邻退化四边形的交叉点,在正面看上去是没有问题的,但是在侧面就会产生两个问题,第一个问题是侧面旋转三角形会产生一条缝隙(这个很难想象出来要画图,或者我截图才能说明白github不能发图有点难受),第二个缺点是在某些角度依然会发生深度穿透(依然需要截图说明),举个例子如果我们绘制一个正方形面片,他是两个三角形组成的,那么会有边缘四条线被显示出来,中间一条线不显示,此时在正面看上去所有的退化三角面的顶点都被偏移到了正确位置,但是角度发生偏转以后,两个三角形中间那条线就会产生缝隙,和线条的深度错误。这个原因是中间的那条线(实际上是两条线)分别和他旁边的线求交点以后并不在同一个位置。 为了解决上面这个问题,我做了思考这个问题是可以解决的,就是每个退化四边形要保存他所包含的两个退化三边形,然后在退化三边形绘制的时候,先找一条边是不是被绘制出有宽度的,然后看第二条边有没有宽度,如果两个都有宽度那么就可以直接求交点,如果其中一条边没有宽度,而另一条边有,那么通过这条边的退化四边形拿到这条边另一侧的退化三角形,然后这个退化三角形的一条边的点一定和我们刚开始没有宽度的那条边相同(也就是说他们是共点的),如果这条边有宽度那么就用这条边,和我们刚开始找到的那条有宽度的边求交点,并把当前退化三角面的点移动到这个交点位置,如果这条边还没有宽度那么继续这个操作,直到围着点饶了一圈,又回到最开始没有宽度的那条边证明一圈当前这条边没有需要求交点的线了。那么我们就可以不移动当前退化三角面的点。通常来说如果模型是合理化布线在建模的时候都是四边面,那么三角化以后一个点最多会有8个相邻的边也就是说这个过程最多会循环7次。因为每一次只需要简单的判断有没有宽度所以这个过程是很快的不会太消耗性能。而且求出交点以后还有一个好处我们因为要求交点所以需要两根线完全彼此穿透所以要增加线的长度,求出交点以后我们可以将线的顶点一起移动到交点位置,这样两条线就可以完美拼接到一起不会有多出来的部分或者缺口。而且绕一圈没有检测到相连接的线我们可以让这条线的一头宽度设置接近为0来模拟收笔。 当然还有优化算法,比如每一个退化四边形,不去存两个退化三角形,而是直接存算好的相邻的那两条退化四边形,这样我们渲染退化三角形的时候只需要循环临近的边。 当然这里还有一些细节问题没有说,比如两个线平行的情况需要判断否则交点计算会有些问题。 昨天搞了一整天我只完成了一半,是否要继续完成我还在犹豫。 然后就是想出了第二种办法: 准备4个颜色缓冲区,开启ztest zwrite绘制三遍模型,每个三角形可能会影响到三个退化四边形绘制出来的线条深度,所以每一次绘制将影响到的退化四边形id存入颜色缓冲区(32位)这样颜色中存储的是最靠前的面而且是这个面能影响到哪条线的id ,然后我们同样开启ztest zwrite再绘制线条,此时我们将当前线条的退化四边形id当做颜色写入第四个颜色缓冲区,然后将四个缓冲区交给计算着色器对每一个颜色像素进行id比对。 准备一个新的int缓冲区5用于模板

// 违代码
if (buffer1 == buffer4 || buffer2 == buffer4 || buffer3 == buffer4) {
    buffer5 = 1
}

unity好像Command buffer好像不能单独设置模板缓冲区,CommandBuffer.SetRenderTarget里没有模板缓冲区也许模板缓冲区是和深度是同一个缓冲区,前24位是深度后8位是模板(猜测)。 然后我们绘制开启ztest zwrite这次渲染线条的颜色,并用之前的模板缓冲区通过值1的像素点。正确的线条就被绘制出来了,但是在着之前需要先绘制出模型颜色,我们将后绘制的线条直接覆盖上去。 第二种做法的缺点是更消耗显存,而且要重复绘制多次模型才行拿到三张id图,但是他要好做不少,这种做法会消除掉线条超出三角面区域的部分(线两头多出来的部分通常并不和两边的三角面重合)但是这样带来了一个好处,就是加长以后的线条不会显示出来,那么线条的拼接处就是无缝的。但是模型外轮廓外的线条的一半也会被剔除掉,所以针对外轮廓可以用back facing单独加一圈,也可以使用模板单独渲染一遍外轮廓。

我还想出了挺多在这个基础上线条的效果处理,不过要先完成这个统一粗细的线段才行。毕竟有一个不变的标准之后再变心里才舒服。 这样来看也许还是第一种方法效率更高一些,不过我真的点肝不动了。

JavinYang commented 3 years ago

经过我一下午的沉思总结方法一还是不行,因为他有两个缺陷。第一个缺陷是算内角交叉把面移动到交叉点没什么太大问题,但是移动后的点的深度要求当前这段线两点之间求一个百分比(不画图很难说明原因),这个问题还不是很大,最大的问题是外角,如果外角超过270度就会变得很长很尖这个尖角的位置随着外角增大会越来越远(毕竟线的宽度不发生变化),我想的是外角超过270度就不对三角面的点进行修正了而且线段也不会无限长的,但是着破坏了绘制完美粗细线条的原则。

L-LingRen commented 3 years ago

经过我一下午的沉思总结方法一还是不行,因为他有两个缺陷。第一个缺陷是算内角交叉把面移动到交叉点没什么太大问题,但是移动后的点的深度要求当前这段线两点之间求一个百分比(不画图很难说明原因),这个问题还不是很大,最大的问题是外角,如果外角超过270度就会变得很长很尖这个尖角的位置随着外角增大会越来越远(毕竟线的宽度不发生变化),我想的是外角超过270度就不对三角面的点进行修正了而且线段也不会无限长的,但是着破坏了绘制完美粗细线条的原则。

厉害,向你学习。

JavinYang commented 3 years ago

经过我一下午的沉思总结方法一还是不行,因为他有两个缺陷。第一个缺陷是算内角交叉把面移动到交叉点没什么太大问题,但是移动后的点的深度要求当前这段线两点之间求一个百分比(不画图很难说明原因),这个问题还不是很大,最大的问题是外角,如果外角超过270度就会变得很长很尖这个尖角的位置随着外角增大会越来越远(毕竟线的宽度不发生变化),我想的是外角超过270度就不对三角面的点进行修正了而且线段也不会无限长的,但是着破坏了绘制完美粗细线条的原则。

厉害,向你学习。

我搞定了,做法是直接在投影空间中直接计算出z的位置,原理是计算线四个角落在三角形平面的共面上的z深度,但是实际上线的宽度不能超过三角面的宽度,因为共面如果和摄像机水平的话我们可以想到这个共面会插到摄像机里面(因为这个面无限大),这样如果线的点落到了面上非常接近摄像机的位置z就会无限靠前,我的做法是最大宽度不能超过当前三角面在投影空间中的宽度。还有为了让线离开面一定距离你的demo代码中使用在法线方向偏移了一点,我改了一下会更好一些,就是在世界空间中让所有点靠近一点摄像机(向摄像机拉近0.01),这样的好处有三个,1.如果是布料比如只有单层,这样反面的线也能看到。2.是线和面看上去是完全重合的不会有视觉上的偏移,3.是用面法线偏移如果线条很粗的时候会造成线条彼此分离,如果不想分离要使用均匀化的点法线,但是这样依然不如所有点向摄像机偏移好。 因为我需要点数据控制线的粗细(做了一个线条粗细绘制工具),所以有些地方的线绘制的比较粗,如果直接延长两头线会导致看上去有个线的交叉比较难看,所以我在线两头绘制了半个六边形...这样衔接的地方看上去还不错。我想以后可以通过动态判断线条的粗细来决定这两头要绘制的多接近圆形。

下面是一些代码的展示,刚弄完代码很乱我也需要整理。 这些代码都是发生在计算着色器里所以不能使用unity URP shader中的函数。

    /* 向摄像机拉近 */
    /* 点在世界空间中向摄像机拉近的距离 */
    const float ToCameraDirectionOffset = 0.001;

    /* 将线两侧的三角点位置移动到世界空间 */
    triangle1_vertex3 = mul(Object2World, triangle1_vertex3);
    triangle2_vertex3 = mul(Object2World, triangle2_vertex3);
    /* 向摄像机拉近 */
    triangle1_vertex3 = float4(triangle1_vertex3.xyz + (normalize(WorldSpaceCameraPos - triangle1_vertex3.xyz) * ToCameraDirectionOffset), triangle1_vertex3.w);
    triangle2_vertex3 = float4(triangle2_vertex3.xyz + (normalize(WorldSpaceCameraPos - triangle2_vertex3.xyz) * ToCameraDirectionOffset), triangle2_vertex3.w);
    /* 转换为投影空间 */
    triangle1_vertex3 = mul(VP, triangle1_vertex3);
    triangle2_vertex3 = mul(VP, triangle2_vertex3);
    float3 t0 = triangle1_vertex3.xyz / triangle1_vertex3.w;
    float3 t1 = triangle2_vertex3.xyz / triangle2_vertex3.w;

    /* 将线两头端点位置移动到世界空间 */
    vertex1 = mul(Object2World, float4(vertex1.xyz, 1.0));
    vertex2 = mul(Object2World, float4(vertex2.xyz, 1.0));
    /* 向摄像机拉近 */
    vertex1 = float4(vertex1.xyz + (normalize(WorldSpaceCameraPos - vertex1.xyz) * ToCameraDirectionOffset), 1.0);
    vertex2 = float4(vertex2.xyz + (normalize(WorldSpaceCameraPos - vertex2.xyz) * ToCameraDirectionOffset), 1.0);
    /* 转换为投影空间 */
    vertex1 = mul(VP, vertex1);
    vertex2 = mul(VP, vertex2);
    float3 e0 = vertex1.xyz / vertex1.w;
    float3 e1 = vertex2.xyz / vertex2.w;
/* 获取线最大宽度
l1线的一个点
l2三角形的点
p 三角形和线的另一个点
原理是计算p点那个角的对边
*/
inline float GetLineMaxWidth(float2 l1, float2 l2, float2 p) {
    float l2_l1_y = l2.y - l1.y;
    float l2_l1_x = l2.x - l1.x;
       float atan1 = atan2(l2_l1_y, l2_l1_x);

    float p_l1_y = p.y - l1.y;
    float p_l1_x = p.x - l1.x;
    float atan_2 = atan2(p_l1_y, p_l1_x);

    float atan0 = atan1 - atan_2;
    float tan0 = tan(atan0);
    float2 p_to_l1 = p - l1;
    float l1_to_p_len = float2_norm(p_to_l1);
    return tan_to_sin(tan0) * l1_to_p_len;
}

/* 二维向量长度 */
inline float float2_norm(float2 v) {
    return sqrt(v.x*v.x + v.y*v.y);
}

/* 正切转正旋 */
inline float tan_to_sin(float tan) {
    if (tan < 0) {
        return -tan / sqrt(1+tan*tan);
    } else {
        return tan / sqrt(1+tan*tan);
    }
}
// 计算面和线的交点
// planeVector 平面的法线向量
// planePoint 面经过的一点坐标
// lineVector 直线的方向向量
// linePoint 直线经过的一点坐标
// 最终我们只需要返回值的 z 深度
inline float3 PlaneLineIntersectPoint(float3 planeVector, float3 planePoint, float3 lineVector, float3 linePoint)
{
    float vp1, vp2, vp3, n1, n2, n3, v1, v2, v3, m1, m2, m3, t, vpt;
    vp1 = planeVector.x;
    vp2 = planeVector.y;
    vp3 = planeVector.z;
    n1 = planePoint.x;
    n2 = planePoint.y;
    n3 = planePoint.z;
    v1 = lineVector.x;
    v2 = lineVector.y;
    v3 = lineVector.z;
    m1 = linePoint.x;
    m2 = linePoint.y;
    m3 = linePoint.z;
    vpt = v1 * vp1 + v2 * vp2 + v3 * vp3;
    t = ((n1 - m1) * vp1 + (n2 - m2) * vp2 + (n3 - m3) * vp3) / vpt;
    float3 rtn = float3(m1 + v1 * t, m2 + v2 * t, m3 + v3 * t);
    return rtn;
}
// 如果不需要超高精度其实不需要下面这两个函数
// 之所以需要这两个函数是因为 经过透视矩阵z其实是非线性的,而我们计算共面上的点时,因为面是平直的所以最后我们取到的z是线性的。
// 所以要使用下面这两个函数在PlaneLineIntersectPoint传入参数之前,这些参数点的z要经过Linear01Depth处理计算好,当PlaneLineIntersectPoint计划完成拿新到z再用ProjectDepth返回透视空间中的非线性值,但其实实测并不需要这么高的精度。

// 非线性深度转线性深度
float Linear01Depth(float depth) 
{
    float Near = 0.3; 
    float Far  = 100.0; 
    float zbuffer_x = 1.0-Far/Near;
    float zbuffer_y = Far/Near;
    //float zbuffer_w = zbuffer_y/far;
    return 1.0 / (zbuffer_x*depth + zbuffer_y);
}
// 线性深度转非线性深度
float ProjectDepth(float depth)
{
    float Near = 0.3; 
    float Far  = 100.0; 
    float zbuffer_x = 1.0-Far/Near;
    float zbuffer_y = Far/Near;
    //float zbuffer_w = zbuffer_y/far;
    return (1.0 / depth - zbuffer_y) / zbuffer_x;
}
L-LingRen commented 3 years ago

在投影空间往摄像机推出一点,是个好思路,当初写的时候没想到,用了顶点法线,顶点法线还需要用cpu计算,性能消耗有点大。当时为了性能我就全部使用最简单的计算方式了,所以线就只用了2个三角形来模拟,计算少运行效率高,有空我重新整合下╰( ̄▽ ̄)╮

JavinYang commented 3 years ago

(๑•̀ㅂ•́)و✧ ╰( ̄▽ ̄)╮

JavinYang commented 3 years ago

其实最好在世界空间中偏移,因为投影空间的z是非线性的,有可能偏移了0.01但是在近处偏移了很多。远处偏移又不够多。所以我的代码偏移发生在世界空间中。