Drawing Three-Dimensional Surfaces in a Custom Rendering Engine

I am currently working on a custom three-dimensional rendering engine using Frames to represent lines in wireframe representations of objects and ImageLabels to represent triangles comprising the surfaces thereof (these GuiObjects are all descendants of a ScreenGui). Specifically, each triangle that composes the surfaces of objects rendered using this engine should, after its vertices are converted into screen coordinates, be (for lack of a better term) “split” into two right triangles based on the location of the altitude line drawn from its longest edge (henceforth referred to as the “base”) to the opposite vertex, as shown in the following example.

Triangle_Altitude_Demonstration

The triangles formed thereby will then be filled with ImageLabels containing an image of a right triangle to create the appearance of a surface of a three-dimensional object.

Currently, the ImageLabels are not given the correct Size or Rotation by the engine (Edit: by “engine,” I am referring to the custom rendering engine that I am attempting to create) when rendered and thus do not create the appearance of a surface of a three-dimensional object. The following is a video depicting their current, undesired behavior (note that the correctly rendered wireframe illustrates the intended surfaces of the part).


The relevant portions of the LocalScript responsible for rendering are as follows:

function getValue(list, criterion) --Given a table of numbers, returns the minimum, maximum, or "center" value therein, as well as the index or key at which it is located
	if criterion ~= "center" then
		local foundValue, foundKey = nil, nil
		for key, value in pairs(list) do
			if criterion == "max" then
				if (not foundValue) or value > foundValue then
					foundValue = value
					foundKey = key
				end
			elseif criterion == "min" then
				if (not foundValue) or value < foundValue then
					foundValue = value
					foundKey = key
				end
			end
		end
		return foundValue, foundKey
	else --The "center" value is the value nearest to the average of all numbers in the table
		local sum = 0
		local numValues = 0
		for key, value in pairs(list) do
			sum += value
			numValues += 1
		end
		local average = sum / numValues
		local closestValue, foundKey, actualValue = nil, nil, nil
		for key, value in pairs(list) do
			if (not closestValue) or math.abs(value - average) < closestValue then
				closestValue = math.abs(value - average)
				actualValue = value
				foundKey = key
			end
		end
		return actualValue, foundKey
	end
end
function getRotation(point1, point2) --Returns the rotation (in degrees) necessary for a GuiObject to form a line segment connecting the two given points
	return math.deg(math.atan((point2.Y - point1.Y) / (point2.X - point1.X)))
end

function drawTriangle(a: Vector3, b: Vector3, c: Vector3, outlineWidth: number) --This is called every frame
	outlineWidth = outlineWidth or 2
	local surfaceColor = Color3.new(1, 1, 1)
	--The following five lines are not relevant
	if outlineWidth > 0 then
		drawLine(a, b, outlineWidth)
		drawLine(a, c, outlineWidth)
		drawLine(b, c, outlineWidth)
	end
	--Convert triangle vertices to screen coordinates
	local a2d = camera:WorldToViewportPoint(a)
	local b2d = camera:WorldToViewportPoint(b)
	local c2d = camera:WorldToViewportPoint(c)
	local vertices = {
		["a"] = Vector2.new(a2d.X, a2d.Y),
		["b"] = Vector2.new(b2d.X, b2d.Y),
		["c"] = Vector2.new(c2d.X, c2d.Y)
	}
	--Determine side lengths of the triangle on-screen
	local sideLengths = {
		["ac"] = (a2d - c2d).Magnitude,
		["ab"] = (a2d - b2d).Magnitude,
		["bc"] = (b2d - c2d).Magnitude
	}
	--Identify the base of the altitude and the hypotenuses of the resulting right triangles
	local baseLength, baseKey = getValue(sideLengths, "max")
	--The terms "short" and "long" used in some variable names are used solely to identify the triangle of interest and otherwise hold no real significance
	local shortHypotenuseLength, shortHypotenuseKey = getValue(sideLengths, "min")
	local longHypotenuseLength, longHypotenuseKey = getValue(sideLengths, "center")
	
	local function getVertices(key) --Returns the vertices connected by the given line segment
		return vertices[string.sub(key, 1, 1)], vertices[string.sub(key, 2, 2)]
	end
	
	--Calculate altitude
	local semiperimiter = (sideLengths.ac + sideLengths.ab + sideLengths.bc) / 2
	local area = math.sqrt(semiperimiter * (semiperimiter - sideLengths.ab) * (semiperimiter - sideLengths.bc) * (semiperimiter - sideLengths.ac)) --Heron's formula
	local altitude = 2 * (area / baseLength)
	
	local function createSurfaceImage() --Create the triangle ImageLabel.
		local surfaceImage = Instance.new("ImageLabel")
		surfaceImage.Name = "SurfaceColor"
		surfaceImage.AnchorPoint = Vector2.new(0.5, 0.5)
		surfaceImage.BackgroundTransparency = 1
		surfaceImage.ImageColor3 = surfaceColor
		surfaceImage.Image = "rbxassetid://10555810706"
		return surfaceImage
	end
	--Identify the vertex opposite the base of the altitude
	local baseVertex1, baseVertex2 = getVertices(baseKey)
	local vertexOppositeBase = nil
	for key, value in pairs(vertices) do
		if value ~= baseVertex1 and value ~= baseVertex2 then
			vertexOppositeBase = value
			break
		end
	end
	
	--Henceforth, "surfaceImage" refers to an ImageLabel created by the "createSurfaceImage" function
	
	--Identify the vertices connected by the "short hypotenuse"
	local shortVertex1, shortVertex2 = getVertices(shortHypotenuseKey)
	local surfaceImage1 = createSurfaceImage()
	--Because the AnchorPoint of surfaceImage is (0.5, 0.5), its intended position is equal to the midpoint of the relevant hypotenuse.
	surfaceImage1.Position = UDim2.new(0, (shortVertex1.X + shortVertex2.X) / 2, 0, (shortVertex1.Y + shortVertex2.Y) / 2)
	--"SizeH" variables determine the length of the leg of the triangle represented by the surfaceImage that is perpendicular to the altitude line
	local shortSizeH = UDim.new(0, math.sqrt(shortHypotenuseLength^2 - altitude^2))
	--"SizeA" variables are always equal to the length of the altitude line
	local shortSizeA = UDim.new(0, altitude)
	--Determine the rotation of the base in degrees
	local parallelRotation = getRotation(baseVertex1, baseVertex2)
	--Determine whether the surfaceImage should be rotated by -90 degrees relative to the base before being rendered
	local isPerpendicular = nil
	if parallelRotation > 315 or parallelRotation <= 45 then
		isPerpendicular = surfaceImage1.Position.X.Offset < vertexOppositeBase.X
	elseif parallelRotation > 45 and parallelRotation <= 135 then
		isPerpendicular = surfaceImage1.Position.Y.Offset < vertexOppositeBase.Y
	elseif parallelRotation > 135 and parallelRotation <= 225 then
		isPerpendicular = surfaceImage1.Position.X.Offset > vertexOppositeBase.X
	else
		isPerpendicular = surfaceImage1.Position.Y.Offset > vertexOppositeBase.Y
	end
	--If the surfaceImage should be rotated by -90 degrees relative to the base, reverse its size on the X and Y axes
	surfaceImage1.Size = if isPerpendicular then UDim2.new(shortSizeA, shortSizeH) else UDim2.new(shortSizeH, shortSizeA)
	surfaceImage1.Rotation = if isPerpendicular then parallelRotation - 90 else parallelRotation
	surfaceImage1.Parent = mainGui.Background
	
	--The following five lines are identical to the corresponding lines for the first surfaceImage, except the following lines utilize variables associated with the "long hypotenuse" rather than the "short hypotenuse"
	local longVertex1, longVertex2 = getVertices(longHypotenuseKey)
	local surfaceImage2 = createSurfaceImage()
	surfaceImage2.Position = UDim2.new(0, (longVertex1.X + longVertex2.X) / 2, 0, (longVertex1.Y + longVertex2.Y) / 2)
	local longSizeH = UDim.new(0, math.sqrt(longHypotenuseLength^2 - altitude^2))
	local longSizeA = UDim2.new(0, altitude)
	--If the first surfaceImage was rotated by -90 degrees relative to the base, this surfaceImage should not be, and vice versa
	surfaceImage2.Size = if isPerpendicular then UDim2.new(longSizeH, longSizeA) else UDim2.new(longSizeA, longSizeH)
	surfaceImage2.Rotation = if isPerpendicular then parallelRotation else parallelRotation - 90
	surfaceImage2.Parent = mainGui.Background
end

I have not found other posts on the Developer Forum addressing this type of issue, and although other renderers do exist, they appear to function differently from the one I am attempting to create.

I do not currently understand what I am doing incorrectly in the above code, so if anyone is willing to offer assistance, that would be greatly appreciated.

1 Like

How certain are you that this is the problem? I haven’t seen any issues in the past with ImageLabels being assigned incorrectly.

I would instead guess there is a math error in your drawTriangle function. I would split up your function a little more (such as a separate function to draw the right triangles) and test each component of the drawTriangle function. You could also try adding visual dots to see what the function calculates.

It’s somewhat unlikely that Roblox is suddenly failing to assign properties or render ImageLabels in this way.

1 Like

I apologize for the confusion; I had meant that the custom renderer that I created was not assigning the correct Size or Rotation to the ImageLabels, not that the Roblox engine was faulty. I do appreciate your advice, and I will split the drawTriangle function into different components, as you suggested.

2 Likes

That makes sense :sweat_smile:

If you’re looking for a guidance or a premade solution, EgoMoose has a tutorial article about drawing 2D triangles along with some code here:

Might be something to check out. (EgoMoose’s github has some amazing resources.)

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.