PcoWSkbVqDnWTu_dm2ix
We use cookies on this site to enhance your user experience

Jul 03 2018, 10:14 AM PST 10 min

Introduction

Every so often you may find yourself wanting to draw triangles in GUI. They have many uses in game development, but probably the most common and arguably the most useful is their ability to build up any polygon. In this article we will go over the math and code behind drawing triangles in Roblox.

Prerequisites

This article will get a little mathy and as a result, in order to fully understand the concepts behind this you are going to need to know the following:

  • Articles/Calculating Dot Products
  • Articles/Cross product
    • Specifically the right hand rule!
  • Articles/2D Collision Detection
  • Trigonometric functions
    • How to use them to solve right triangles
  • Vector math (covered in the dot product article)
    • Addition
    • Subtraction
    • Magnitude
    • Multiplication by scalars
    • Unit vectors

The basis of the algorithm

In Roblox we are limited to using semi-static shapes (I say semi-static because they can be stretched and squished). Triangles however tend to be a dynamic shape. Luckily for us we can take any triangle and split it into a maximum of two right angle triangles which we can draw via image labels.

2dtriangle_1.png

What’s important to note from this is that the base of the two right triangles is always the longest edge. In a similar fashion the two right triangles are split where the two shorter edges meet. Believe it or not this general concept is enough to get us into the math.

The math behind triangles

To start off we’re going to get as much information about the triangle as we can without worrying about the GUI placement. As was discussed above our first goal is to get the longest edge. We’ll treat the edge as not only the bottom (base) of our two right triangles, but also as the base of the whole triangle (this will become more important once we get to rotation). We don’t want to lose any information either though so what we will do is get the longest edge in vector form relative to one of the vertices it’s connected to. We’ll also get the other edge connected to that vertex in vector form as well.

2dtriangle_2.png

In code form:

function drawTriangle(a, b, c, parent)
	local edges = {
		{longest = (c - b), other = (a - b), position = b}
		{longest = (a - c), other = (b - c), position = c}
		{longest = (b - a), other = (c - a), position = a}
	}

	table.sort(edges, function(a, b) return a.longest.magnitude > b.longest.magnitude end)

	local edge = edges[1]
end

From here our next step is going to be to find the height of the triangle and where along the longest edge the two right angle triangles are split. We can do this very easily by getting the angle between the two vectors using the dot product and then using trigonometric functions to solve.

2dtriangle_3.png

Once again in code form:

function dotv2(a, b)
	return a.x * b.x + a.y * b.y
end

function drawTriangle(a, b, c, parent)
	-- ... stuff from before

	-- unit vectors have a magnitude of 1 so 1 * 1 = 1 and division by 1 is redundant
	edge.angle = math.acos(dotv2(edge.longest.unit, edge.other.unit))
	edge.x = edge.other.magnitude * math.cos(edge.angle)
	edge.y = edge.other.magnitude * math.sin(edge.angle)
end

The last thing we are going to solve before moving onto finding values specifically regarding the placement of user interface is the rotation of the triangle as a whole. Now remember we’re viewing the longest side of the triangle as the base which is what we’re measuring for rotation. In other words, if I drew the triangle with its base parallel to the x-axis how much would I have to rotate it to get it tilted so it lines up with the points I have.

The key to doing this right is to remember that the rotation of the triangle isn’t just dependant on the longest edge, it is also very much dependant on the vertex that is not connected to it. That means we need to add that “other” vector to our calculation, use arc tan to get the new answer and then subtract 90 degrees to get the properly adjusted rotation. This part is a little difficult to explain over text because it’s mainly just trig so you kind of have to just work it out on a piece of paper. If you’re too lazy to do that you can always accept the results as proof.

In code form:

function drawTriangle(a, b, c, parent)
	-- ... stuff from before

	local r = edge.longest.unit * edge.x - edge.other
	local rotation = math.atan2(r.y, r.x) - math.pi/2
end

Drawing the UI

Alright, we have avoided it for as long as we can, but now it’s time to deal with our user interface. From this point on we’ll be using these two right-triangle images to do our dirty work:

Now because those are white images, they’re not exactly easy to see. As a result here’s a masterful artist’s rendition:

2dtriangle_4.png

The important thing you take away from this is that r1’s hypotenuse is sloping up and r2’s hypotenuse is sloping down.

If we arbitrarily position either triangle with the values we calculate it might not look right. Visually we as humans know how to stretch or squish those triangles and then place them like jigsaw, but the computer doesn’t. As a result we have to mathematically figure out which triangle goes on what side. To do this we’re going to be smart and instead of using a trig equation straight out of a Lovecraftian horror we’ll use what we know about the right hand rule and the cross product.

2dtriangle_5.png

function drawTriangle(a, b, c, parent)
	-- ... stuff from before

	local tp = -edge.other
	local tx = (edge.longest.unit * edge.x) - edge.other
	local nz = tp.x * tx.y - tp.y * tx.x -- all we care about is the depth
end

If we cross the negative vector of the “other” edge (t) with the vector we get from subtracting the point along the longest edge where the two right angle splits and the “other” vector (s) (meaning t × s) we will get either a positive or negative value as our z-value. According to the right hand rule, if that value is positive we know the triangle attached to our “edge.position” point is upward sloping, otherwise its downward sloping.

2dtriangle_6.png

We can now solve for each triangle’s top-left corner which is needed since image labels are positioned from their top left corners. We know the downward sloping triangle is always going to have its top left corner in the position of the vertex that’s not related to the longest edge. We can find the other corner with our knowledge about the right hand rule and some simple vector addition and subtraction. Once we have found those values we can also solve for the size of our two triangles too!

function drawTriangle(a, b, c, parent)
	-- ... stuff from before

	-- top left corner 1 & top left corner 2
	local tlc1 = edge.position + edge.other
	local tlc2 = nz > 0 and edge.position + edge.longest - tx or edge.position - tx

	local tasize = Vector2.new((tlc1 - tlc2).magnitude, edge.y)
	local tbsize = Vector2.new(edge.longest.magnitude - tasize.x, edge.y)
end

Finally, the last thing we need is to adjust the corner positions we just calculated. We have the real position, but Roblox (regardless of rotation) will always place GUI elements as if they aren’t rotated. Since this is the case we have to essentially “de-rotate” our corners and use those. To do that we need to follow a process that is pretty mechanical. First get the corners relative to their shapes center since that’s what they rotate around, “de-rotate” them, finally add the converted vector back to the shape’s center to get the world position again:

function rotateV2(vec, angle)
	-- 2D rotation matrix
	local x = vec.x * math.cos(angle) + vec.y * math.sin(angle)
	local y = -vec.x * math.sin(angle) + vec.y * math.cos(angle)
	return Vector2.new(x, y)
end

function drawTriangle(a, b, c, parent)
	-- ... stuff from before

	local center1 = nz <= 0 and edge.position + ((edge.longest + edge.other)/2) or (edge.position + edge.other/2)
	local center2 = nz > 0 and edge.position + ((edge.longest + edge.other)/2) or (edge.position + edge.other/2)

	tlc1 = center1 + rotateV2(tlc1 - center1, math.rad(ta.Rotation))
	tlc2 = center2 + rotateV2(tlc2 - center2, math.rad(tb.Rotation))
end

Wow! Congrats you’ve done all the math, now it’s just a matter of plugging the values into the two GUI elements with their respective right triangle images and you’re done!

Here’s the final product:

local extra = 2

local img = Instance.new("ImageLabel")
img.BackgroundTransparency = 1
img.BorderSizePixel = 0

function dotv2(a, b)
	return a.x * b.x + a.y * b.y
end

function rotateV2(vec, angle)
	local x = vec.x * math.cos(angle) + vec.y * math.sin(angle)
	local y = -vec.x * math.sin(angle) + vec.y * math.cos(angle)
	return Vector2.new(x, y)
end

function drawTriangle(a, b, c, parent)
	local edges = {
		{longest = (c - b), other = (a - b), position = b}
		{longest = (a - c), other = (b - c), position = c}
		{longest = (b - a), other = (c - a), position = a}
	}

	table.sort(edges, function(a, b) return a.longest.magnitude > b.longest.magnitude end)

	local edge = edges[1]
	edge.angle = math.acos(dotv2(edge.longest.unit, edge.other.unit))
	edge.x = edge.other.magnitude * math.cos(edge.angle)
	edge.y = edge.other.magnitude * math.sin(edge.angle)

	local r = edge.longest.unit * edge.x - edge.other
	local rotation = math.atan2(r.y, r.x) - math.pi/2

	local tp = -edge.other
	local tx = (edge.longest.unit * edge.x) - edge.other
	local nz = tp.x * tx.y - tp.y * tx.x

	local tlc1 = edge.position + edge.other
	local tlc2 = nz > 0 and edge.position + edge.longest - tx or edge.position - tx

	local tasize = Vector2.new((tlc1 - tlc2).magnitude, edge.y)
	local tbsize = Vector2.new(edge.longest.magnitude - tasize.x, edge.y)

	local center1 = nz <= 0 and edge.position + ((edge.longest + edge.other)/2) or (edge.position + edge.other/2)
	local center2 = nz > 0 and edge.position + ((edge.longest + edge.other)/2) or (edge.position + edge.other/2)

	tlc1 = center1 + rotateV2(tlc1 - center1, rotation)
	tlc2 = center2 + rotateV2(tlc2 - center2, rotation)

	local ta = img:Clone()
	local tb = img:Clone()
	ta.Image = "rbxassetid://319692171"
	tb.Image = "rbxassetid://319692151"
	ta.Position = UDim2.new(0, tlc1.x, 0, tlc1.y)
	tb.Position = UDim2.new(0, tlc2.x, 0, tlc2.y)
	ta.Size = UDim2.new(0, tbsize.x + extra, 0, tbsize.y + extra)
	tb.Size = UDim2.new(0, tasize.x + extra, 0, tasize.y + extra)
	ta.Rotation = math.deg(rotation)
	tb.Rotation = ta.Rotation
	ta.Parent = parent
	tb.Parent = parent
end

Note: I added an “extra” variable to add a few extra pixels. All it does is make the triangles a little bit smoother.

Conclusion

Hope you enjoyed this explanation and learned something new from it. Now go do something awesome the triangles you now know how to draw!

2dtriangle_7.gif

Tags:
  • math
  • coding
  • design