Draw line on mesh texture

Hi! I’m working on a small painting app that draws lines on mesh textures.
The user presses on the mesh in frame N-1 we do a raycast and store local position, world position, and uv. The same for the current frame N. When we have two raycasts, we’re interpolating between raycast[N-1].uv and raycast[N].uv and draw a few brush samples to draw a line.
In some cases, it works, but there are cases when it gives unexpected results:

Frame N-1

Mesh with texture / UV, user drawn a dot:

Frame N

Mesh with texture / UV, user drawn the second dot, interpolating them:

Expected result:

Mesh with texture / UV, this is what I expected:

Is there any way to get the expected result with good performance? I think that instead of interpolating between N-1.uv and N.uv we can raycast from N-1 to N and draw brush samples, but I assume that performance will be low.

(u, v) + (n, m) all point all to the same texel when the address mode is set to wrap, where n and m are positive or negative integers.

I think, it is safe to assume that you always want to draw the shortest possible line, so I would just calculate the length of all the possible lines in u/v space and draw the shortest one.

Pseudo code:

float minSquaredLength = FLT_MAX;
float2 start = float2(u1, v1);
float2 end;

for (int du2 = -1; du2 <= 1; ++du2)
{
    for (int dv2 = -1; dv2 <= 1; ++dv2)
    {
        float2 tmpEnd = float2(u2 + du2, v2 + dv2);
        float2 line = tmpEnd - start;
        float squaredLength = dot(line, line);
        if (squaredLength < minSquaredLength)
        {
            minSquaredLength = squaredLength;
            end = tmpEnd;
        }
    }
}

// Draw line from start to end but be aware that every texel coordinate must be mapped to 0-1 range

But no guarantee that this actually works…

Thank you! I think about this way in the case when we painting on the plane mesh that contains 4 vertices and in should render the line like in Spoiler: Frame N variant, but it will do it as in Spoiler: Expected result, so corner cases still remaining :frowning:

Is it possible to draw such a long line within a single frame? I kind of assumed that the user is holding down the mouse button to paint from frame N to N + x (with x being framerate times seconds the button was held down) and you are just drawing the small line segments between each frame. If somebody wanted to draw a long line like in Spoiler: Frame N, it would consist of many single line segments since it is impossible to move the mouse all the way around the cylinder (or all the way from one corner to the other of a plane) within a single frame. Is this not how it works?

Yes, if you move the mouse or touchscreen pointer really fast, you can get a long single line between two frames. My mouse positions list can contains maximum two items - one for the previous frame and one for the current frame.

I’m having a hard time believing that this is an actual issue since you’d have to move the mouse really, really fast or the frame rate would have to be really low.

But if you really want to cover this case, you could interpolate between the two mouse positions on the screen, send a ray for each interpolated position, intersect them with the geometry to get a list of u/v coordinates and then use the algorithm above to render a bunch of line segments each frame.

The line will be curved in UV space because the surface of the mesh isn’t flat. However, the surface of the mesh isn’t truly smooth because it’s constructed with a bunch of flat triangles. If you project the line onto just a single triangle, the line in UV space will be straight. So, all you need to do is get the intersections of the line with the triangle edges in screen space and get the UV coordinates of those intersections. Those UV coordinates will form a curved line in UV space.

Good point, haven’t thought of that.

If the look of a hand-drawn line is fine, the easiest solution is probably “splatoon painting”.

Otherwise, you could also go the inverse direction:

  • Render a quad onto the texture to run a fragment shader for each texel.
  • Calculate the screen-space coordinate of the texel
  • Calculate the distance to the screen-space line
  • If the distance is close enough, color it

It works! But I faced the issue: when I draw the line in the screen-space coords, it renders on the backside vertices too:
Model 9267270--1297179--model1.png
Any way to fix that?

Hm, I see. I guess, you have to check the depth map too (_CameraDepthTexture) and clip the texel if necessary. You already have the screen coordinate for sampling the texture. Additionally, you also need the NDC space depth for the given texel to compare it with the camera depth. You should have that if you followed the Splatoon approach.

Bit hard to explain. I’ll write some pseudo code if I have some time.

Thank you!

I tried to compare the world positions, but I missing something: if I render i.worldPos in fragment and compare it with depth worldPos, the result are different:

Positions 9267747--1297269--pos.png

Shader:

//vertex
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
o.viewPos = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)));
float4 screenPos = o.viewPos * 0.5f;
screenPos.xy = float2(screenPos.x, screenPos.y * _ProjectionParams.x) + screenPos.w;
screenPos.zw = o.viewPos.zw;
o.screenPos = screenPos;
//fragment
float2 screenUV = i.screenPos / i.screenPos.w;
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV);

#if UNITY_REVERSED_Z
    if (depth < 0.0001)
        return 0;
#else
    if (depth > 0.9999)
        return 0;
#endif

float4 positionCS = float4(screenUV.xy * 2.0 - 1.0, depth, 1.0);

#if UNITY_UV_STARTS_AT_TOP
    positionCS.y = -positionCS.y;
#endif

float4 hpositionWS = mul(_Matrix_IVP, positionCS);
float3 worldPos = hpositionWS.xyz / hpositionWS.w;

if (distance(worldPos.xyz, i.worldPos.xyz) < 0.03f)
{
    //Draw Line
}

Tried to

Found the issue: my custom _ProjectionParams.x had incorrect value: changed it to 1 from -1 and it works. Also I commented the block with UNITY_UV_STARTS_AT_TOP.
Now it works by comparing the position in world space. I wondering, if there’s more performant way to do that?

Your solution is not quite correct. You have to compare the depth from the depth map with the depth of the currently rendered polygon.

Shader:

Shader "Unlit/Splatoon"
{
    Properties
    {
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Cull Off
            ZTest Always
            ZWrite Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            //#pragma enable_d3d11_debug_symbols

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD1; // using the lightmap uv's since those are non-overlapping
            };

            struct v2f
            {
                float4 viewPos : POSITION1;
                float4 clipPos : POSITION2;
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            float4x4 _MV;
            float4x4 _MVP;
            texture2D _CameraDepthTexture;
            float2 _LineStart;
            float2 _LineEnd;
            float _LineThickness;

            // Finds the t on the segment [a, b] which is closest to an arbitrary point c.
            float GetClosestPointOnSegment(float2 a, float2 b, float2 c)
            {
                // Explanation: Realtime Collision Detection chapter 5.1.2 page 127
                float2 ab = b - a;
                float2 ac = c - a;

                // t = ac.dot(ab) / ab.dot(ab)
                // If c projects outside the [a, b] interval on the a side; clamp to a
                float numer = dot(ac, ab);
                if (numer <= 0)
                    return 0.0;

                // If c projects outside the [a, b] interval on the b side; clamp to b
                float denom = dot(ab, ab);    // always nonnegative
                if (numer >= denom)
                    return 1.0;

                // c projects inside the [a, b] interval
                return numer / denom;
            }

            float GetDistanceToSegment(float2 a, float2 b, float2 c)
            {
                float t = GetClosestPointOnSegment(a, b, c);
                float2 p = lerp(a, b, t);

                return distance(p, c);
            }

            v2f vert (appdata v)
            {
                v2f o;
                o.viewPos = mul(_MV, v.vertex);
                o.clipPos = mul(_MVP, v.vertex);
                o.uv = v.uv;
                float4 uv = float4(0, 0, 0, 1);
                uv.xy = (v.uv.xy * 2 - 1) * float2(1, _ProjectionParams.x);
                o.vertex = uv;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                // Check if current texel maps to a pixel on the screen that's behind the camera
                float viewDepth = -i.viewPos.z; // Camera looks down the negative z axis
                float nearPlaneDistance = _ProjectionParams.y;
                if (viewDepth < nearPlaneDistance)
                    return float4(1, 1, 1, 1);

                // Check if current texel maps to a pixel on the screen that failed the depth test
                float2 ndcPos = i.clipPos.xy / i.clipPos.w;
                float2 screenPos = (ndcPos + 1.0) * 0.5 * _ScreenParams.xy;  // upside down
                float depth = LinearEyeDepth(_CameraDepthTexture[int2(screenPos + 0.5f)].x);
                if (abs(depth - viewDepth) > 0.1)
                    return float4(1, 1, 1, 1);

                // Calculate distance to the line
                // Note: Origin of screen coordinates is at the bottom left
                screenPos = (ndcPos + 1.0) * 0.5 * _ScreenParams.xy;
                float distance = GetDistanceToSegment(_LineStart, _LineEnd, screenPos);

                // Convert distance to line color
                float t = abs(distance) / _LineThickness;
                t = smoothstep(0, 1, t);
                t = smoothstep(0, 1, t);

                return float4(t, t, t, 1);
            }
            ENDCG
        }
    }
}

Script:

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UIElements;

public class RenderScript : MonoBehaviour
{
    public RenderTexture RenderTexture;
    public float LineThickness = 10.0f;

    private Material _material;
    private Camera _camera;
    private CommandBuffer _buffer;
    private Renderer _renderer;
    private Vector2 _lineStart;
    private Vector2 _lineEnd;

    private static int _mvpId = Shader.PropertyToID("_MVP");
    private static int _mvId = Shader.PropertyToID("_MV");
    private static int _lineStartId = Shader.PropertyToID("_LineStart");
    private static int _lineEndId = Shader.PropertyToID("_LineEnd");
    private static int _lineThicknessId = Shader.PropertyToID("_LineThickness");

    public void Awake()
    {
        Shader shader = Shader.Find("Unlit/Splatoon");

        if (shader == null)
        {
            enabled = false;
            return;
        }

        _buffer = new CommandBuffer();
        _buffer.name = "Splatoon";

        _material = new Material(shader);
    }

    public void Start()
    {
        _renderer = GetComponent<Renderer>();
        _camera = Camera.main;

        if (_renderer == null || _camera == null || RenderTexture == null)
        {
            enabled = false;
            return;
        }

        _buffer.SetRenderTarget(RenderTexture);
        _buffer.ClearRenderTarget(false, true, Color.white);
        _buffer.DrawRenderer(_renderer, _material);

        _camera.AddCommandBuffer(CameraEvent.AfterDepthTexture, _buffer);

        _lineStart = new Vector2(0.0f, 0.0f);
        _lineEnd = new Vector2(Screen.width, Screen.height);
    }

    public void OnDestroy()
    {
        if (_camera != null)
            _camera.RemoveCommandBuffer(CameraEvent.AfterDepthTexture, _buffer);

        if (_buffer != null)
            _buffer.Release();

        if (_material != null)
            Destroy(_material);
    }

    void Update()
    {
        // origin is at the bottom left!
        if (Input.GetMouseButtonDown(0))
        {
            _lineStart = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
        }
        if (Input.GetMouseButtonDown(1))
        {
            _lineEnd = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
        }

        Matrix4x4 modelViewMatrix = _camera.worldToCameraMatrix * transform.localToWorldMatrix;
        Matrix4x4 modelViewProjectionMatrix = GL.GetGPUProjectionMatrix(_camera.projectionMatrix, false) * modelViewMatrix;
        _material.SetMatrix(_mvpId, modelViewProjectionMatrix);
        _material.SetMatrix(_mvId, modelViewMatrix);
        _material.SetVector(_lineStartId, _lineStart);
        _material.SetVector(_lineEndId, _lineEnd);
        _material.SetFloat(_lineThicknessId, LineThickness);
    }
}

Game view and scene view:

UV-Space

I’ve attached the project.

9269880–1297824–Splatoon.zip (123 KB)

Wow! Thank you a lot, I deeply appreciate your help!

Actually, your approach should work too. Sorry, misread your code at first.
Well now you’ve got two solutions :wink: