F̲o̲r̲m̲a̲t̲t̲e̲r̲ (*Unicode* **Markdown** __Formatter__)
Stax, 75 73 74 70 bytes

Ç╜ÿPÜV►♀b7╛i┴?τ\⌂:Té√╖■♠(µ`ómƒÜKx▬∙═τ½εxÅr∩!E#î⌡╕B┴zäÅéë┘S²↔óh₧≡6ÿ╖iô├


Run and debug it online

The basic approach is store the 4 types of formatting in a bit field, and then get the correct character offset from a lookup using the integer value.  Only 8 values are in the lookup.  The high bit denoting underline is handled separately.

Here's a commented ungolfed version.

m                       Map mode: for each line of input, 
                            run the rest of the program, then output
"\*\*|__|."|F           Find all regex matches using pattern. This splits the string into 
                            individual characters except for ** and __, which stay together.
                            These will be called "atoms" of the input.
{                       begin a block
  VAVa+                 Construct "ABC..XYZabc..xyz"
  "_*``_****__"_I       Get the index of the current atom in "_*`_****__"
  0|MY                  Take the maximum with zero, then store in register y.
                            This gives {*: 1, `: 2, **: 4, __: 8} and 0 for all else. 
  x|^X                  Xor with the x register and write back. It stores bitflags for 4 modes.
  "&amp;6mU*9*9 0E*9 !"!@   Index into codepoint lookup table using x.
  c52+|r                Build range of 52 consecutive values starting at codepoint.
  \$                    Build translation string by combination with the alphabetic string.
  |t                    Perform translation on the current atom.
  818+                  Add codepoint 818 to the current atom.
  x8&lt;T                  Remove it again if x&lt;8.  This is iff underline mode is off.
  y!*                   Multiply by the logical not of y.  If the atom is for formatting, 
                        this eliminates output for this atom
m                       map over atoms using the enclosed block


Run this one