0%

Outline Effect In Unity

Outline effect is widely used in real-time rendering, and there have been a number of methods advanced. In the book “Real Time Rendering”, the author classifies them into five main categories. This article is based on one of them. The core idea is to use two passes to render. In the first pass, only render the back face and expend the vertices in their normal directions. And in the second pass, cull the back face. Further, this article presents a method to render a pixel-perfect outline.

  • The core idea
    1
    Cull Front
    In the first pass, we only need to render the back face. It’s convenient to disable front face rendering in Unity. The theory is that the vertices of primitives are defined in a fixed winding order, clockwise or counter-clockwise. But in the view direction, the back face vertices’ order will be reversed. For example, if we defined vertices in the counter-clockwise order, the triangle in the back face will be rendered in clockwise order.
    1
    2
    3
    4
    float4 clipPosition = UnityObjectToClipPos(v.vertex);
    float3 clipNormal = mul((float3x3)UNITY_MATRIX_VP, mul((float3x3)UNITY_MATRIX_M, v.normal));

    clipPosition += normalize(clipNormal) * _OutlineWidth;
    Then, we extend each vertex’s position in their normal direction with a short distance. But, there exists a problem. If we extend vertex’s position in both x, y, and z directions. The vertex in the back face may run to the front, especially in the part of the recessed inward model, like the corners of the mouth.
    1
    clipPosition.xy += normalize(clipNormal).xy * _OutlineWidth;
    Yes, now we get an outline. But it’s not perfect. The outline of close objects and far objects are not in the same width. That’s because in the “perspective division” step, the x and y components of the vertex are divided by the w component. The far the object, the larger the w component.
    1
    clipPosition.xy += normalize(clipNormal).xy * _OutlineWidth * clipPosition.w;
    Now, the outline width is uniform for both close and far objects. But the meaning of the parameter “_OutlineWidth” is a bit counterintuitive, where 1 means 50% of the screen. For example, if vertex’s position in clip space is \((0, 0, z, w)\), and it’s normal direction is \((1, 0, 0)\), the “_OutlineWidth” is 1. After changing, it will become \((w, 0, z, w)\). After perspective division, it’s mapped to \((1, 0)\). What’s more, the screen with and height are always different. In order to make “_outlineWidth” into the pixels, we need to divide half screen width and height.
    1
    clipPosition.xy += normalize(clipNormal).xy / _ScreenParams.xy * 2 * _OutlineWidth * clipPosition.w;