Flat Characters on 3d surface fix

1. What do you want to achieve? I’m trying to get my mirror system (which uses viewport frame and a custom projection matrix) to work on horizontal surfaces like floors, just like it does for vertical walls.

2. What is the issue? The wall mirrors work seemingly fine, but when I try applying the current math to a floor part (top or bottom face), the projection just glitches or turn blank. Since looking down makes the camera’s LookVector run parallel to the floor’s normal, the calculations and cross-products seem to collapse.

3. What solutions have you tried so far? I’ve tried swapping out the look vector for camera-to-center vectors and tweaking the skew/scale variables, but every adjustment either warps the aspect ratio completely or inverts the viewport camera. (I’ve also found other posts trying to replicate this effect but none of them came to a solid solution.)

Here is the current script:

`Lua
local RunService = game:GetService(“RunService”)
local Players = game:GetService(“Players”)
local camera = workspace.CurrentCamera

local localPlayer = Players.LocalPlayer
local playerGui = localPlayer:WaitForChild(“PlayerGui”)

local UNIT_Y = Vector3.new(0, 1, 0)
local UNIT_NZ = Vector3.new(0, 0, -1)
local XZ = Vector3.new(1, 0, 1)
local YZ = Vector3.new(0, 1, 1)
local YSPIN = CFrame.fromEulerAnglesXYZ(0, math.pi, 0)
local PI2 = math.pi / 2

local portals = {
{part = workspace:WaitForChild(“PortalPart”), gui = playerGui:WaitForChild(“SurfaceGui”)},
{part = workspace:WaitForChild(“PP2”), gui = playerGui:WaitForChild(“SurfaceGui2”)},
{part = workspace:WaitForChild(“PP3”), gui = playerGui:WaitForChild(“SurfaceGui3”)},
{part = workspace:WaitForChild(“PP4”), gui = playerGui:WaitForChild(“SurfaceGui4”)},
}

local activeCharParts = {}
local activeCloneMap = {}

local function getSurfaceInfo(part, face)
local partCF, partSize = part.CFrame, part.Size
local back = -Vector3.FromNormalId(face)
local axis = (math.abs(back.Y) == 1) and Vector3.new(back.Y, 0, 0) or UNIT_Y
local right = CFrame.fromAxisAngle(axis, PI2) * back
local top = back:Cross(right).Unit
local cf = partCF * CFrame.fromMatrix(-back * partSize / 2, right, top, back)
local size = Vector3.new((partSize * right).Magnitude, (partSize * top).Magnitude, (partSize * back).Magnitude)
return cf, size
end

local function renderFrame(vpCamera, camCF, surfaceCF, surfaceSize)
local tc = surfaceCF * Vector3.new(0, surfaceSize.Y / 2, 0)
local bc = surfaceCF * Vector3.new(0, -surfaceSize.Y / 2, 0)

local cross = camCF.LookVector:Cross(surfaceCF.UpVector)
local right = cross:Dot(cross) > 0 and cross.Unit or camCF.RightVector
local levelCamCF = CFrame.fromMatrix(camCF.p, right, surfaceCF.UpVector, right:Cross(surfaceCF.UpVector))
local levelCamCFInv = levelCamCF:Inverse()

local csbc = levelCamCFInv * bc
local cstc = levelCamCFInv * tc
local v1 = (csbc * YZ).Unit
local v2 = (cstc * YZ).Unit

local alpha = math.sign(v1.Y) * math.acos(math.clamp(v1:Dot(UNIT_NZ), -1, 1))
local beta = math.sign(v2.Y) * math.acos(math.clamp(v2:Dot(UNIT_NZ), -1, 1))

local fh = 2 * math.tan(math.rad(camera.FieldOfView) / 2)
local hPrime = math.tan(beta) - math.tan(alpha)
local refHeight = hPrime / fh

local c2p = surfaceCF:VectorToObjectSpace(surfaceCF.p - camCF.p)
local c2pXZ = c2p * XZ
local c2pYZ = c2p * YZ

local dpX = c2pXZ.Unit:Dot(UNIT_NZ)
if math.abs(dpX) < 0.0001 then dpX = math.sign(dpX) * 0.0001 end
if dpX == 0 then dpX = 0.0001 end

local scale = (surfaceCF:VectorToObjectSpace(camCF.LookVector) * XZ).Unit:Dot(c2pXZ.Unit) / UNIT_NZ:Dot(c2pXZ.Unit)
local tanArcCos = math.sqrt(math.max(0, 1 - dpX * dpX)) / dpX

local w, h = 1, (surfaceSize.X / surfaceSize.Y)
local dx = math.sign(c2p.X * c2p.Z) * tanArcCos
local dy = c2pYZ.Y / c2pYZ.Z * h
local d = math.abs(scale * refHeight * h)

local ncf = (surfaceCF - surfaceCF.p) * YSPIN * CFrame.new(0, 0, 0, w, 0, 0, 0, h, 0, dx, dy, d)

local c = {ncf:GetComponents()}
local maxVal = 0
for i = 4, 12 do
	maxVal = math.max(maxVal, math.abs(c[i]))
end

if maxVal > 1 then
	for i = 4, 12 do
		c[i] = c[i] / maxVal
	end
	ncf = CFrame.new(c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11], c[12])
end

vpCamera.FieldOfView = camera.FieldOfView
vpCamera.CFrame = ncf + camCF.p

end

local function getRelativePath(descendant, ancestor)
local path = {}
local current = descendant
while current and current ~= ancestor do
table.insert(path, 1, current.Name)
current = current.Parent
end
return path
end

local function setupCharacter(char)
table.clear(activeCharParts)
table.clear(activeCloneMap)

for _, part in ipairs(char:GetDescendants()) do
	if part:IsA("BasePart") then
		table.insert(activeCharParts, part)
	end
end

for _, portal in ipairs(portals) do
	portal.gui.CanvasSize = Vector2.new(1000, 1000)
	portal.gui.ClipsDescendants = true

	local viewportFrame = portal.gui:WaitForChild("ViewportFrame")
	viewportFrame.Size = UDim2.new(1, 0, 1, 0)
	viewportFrame.ClipsDescendants = true

	local worldModel = viewportFrame:WaitForChild("WorldModel")
	worldModel:ClearAllChildren()

	local vpCamera = viewportFrame:FindFirstChildOfClass("Camera") or Instance.new("Camera")
	vpCamera.Parent = viewportFrame
	viewportFrame.CurrentCamera = vpCamera

	char.Archivable = true
	local charClone = char:Clone()
	charClone.Parent = worldModel

	for _, obj in ipairs(charClone:GetDescendants()) do
		if obj:IsA("JointInstance") or obj:IsA("WeldConstraint") then
			obj:Destroy()
		end
	end

	activeCloneMap[portal] = {}

	for _, clonePart in ipairs(charClone:GetDescendants()) do
		if clonePart:IsA("BasePart") then
			clonePart.Anchored = true

			local path = getRelativePath(clonePart, charClone)
			local realPart = char
			for _, name in ipairs(path) do
				if realPart then
					realPart = realPart:FindFirstChild(name)
				end
			end

			if realPart and realPart:IsA("BasePart") then
				activeCloneMap[portal][realPart] = clonePart
			end
		end
	end

	portal.vpCamera = vpCamera
end

end

local function onPlayerAdded(targetPlayer)
if targetPlayer == localPlayer then
targetPlayer.CharacterAdded:Connect(function(newChar)
newChar:WaitForChild(“HumanoidRootPart”)
setupCharacter(newChar)
end)

	if targetPlayer.Character and targetPlayer.Character:FindFirstChild("HumanoidRootPart") then
		setupCharacter(targetPlayer.Character)
	end
end

end

Players.PlayerAdded:Connect(onPlayerAdded)
for _, p in ipairs(Players:GetPlayers()) do
onPlayerAdded(p)
end

RunService.RenderStepped:Connect(function()
if not localPlayer.Character or not localPlayer.Character:FindFirstChild(“HumanoidRootPart”) then return end
local camCF = camera.CFrame

for _, portal in ipairs(portals) do
	if portal.vpCamera then
		portal.surfaceCF, portal.surfaceSize = getSurfaceInfo(portal.part, Enum.NormalId.Front)
		renderFrame(portal.vpCamera, camCF, portal.surfaceCF, portal.surfaceSize)

		local cloneParts = activeCloneMap[portal]
		if cloneParts then
			for _, realPart in ipairs(activeCharParts) do
				local clonePart = cloneParts[realPart]
				if clonePart then
					clonePart.CFrame = realPart.CFrame
				end
			end
		end
	end
end

for _, part in ipairs(activeCharParts) do
	part.LocalTransparencyModifier = 1
end

end)
`

Any ideas on how to tweak this math to properly support floor/horizontal surfaces without breaking the walls? ANY HELP WILL BE GREATLY APPRECIATED

Link to sample video: https://www.youtube.com/watch?v=dr8E9QRpMmQ
Link to sample game: Flat Characters | Play on Roblox

1 Like

If you’d like a link to the other topic regarding this then I’ll be happy to send. (Though they are quite Inefficacious)

You’re hitting a classic degenerate basis problem: for a floor, the surface normal is ±Y, and when the camera looks mostly straight down/up, LookVector is nearly parallel to that normal — so every Cross you use to build the mirror camera’s right/up axes collapses toward zero and the projection flips or blanks.

Fix the basis, don’t fight LookVector

Build the mirror camera from the surface orthonormal frame, and only use the camera for the reflection math:

local function orthonormalOnSurface(normal: Vector3, hint: Vector3): (Vector3, Vector3, Vector3)
	local n = normal.Unit
	local h = hint - n * hint:Dot(n)
	if h.Magnitude < 1e-4 then
		-- LookVector parallel to normal — pick a stable world hint
		h = (math.abs(n.Y) > 0.9) and Vector3.zAxis or Vector3.yAxis
		h = h - n * h:Dot(n)
	end
	local xAxis = h.Unit
	local yAxis = n:Cross(xAxis).Unit
	return xAxis, yAxis, n
end

For a floor (Enum.NormalId.Top):

  • normal = part.CFrame.UpVector (or -UpVector for bottom)
  • hint = camera.CFrame.LookVector or (portalCenter - camera.CFrame.Position) — the second is usually stabler for mirrors

Then reflect the camera position across the plane and rebuild CFrame.fromMatrix with that triad (apply your existing Y-spin / face flip after the basis is valid).

Extra checks that kill floor portals

  1. Aspect / size: SurfaceGui on Top/Bottom often needs swapped width/height vs Front.
  2. Near plane: floor cams sit close to the plane — bump ViewportFrame.CurrentCamera.NearPlaneZ / offset the reflected cam a few studs along normal so it doesn’t clip.
  3. Don’t use UNIT_Y as both world-up and surface-up in the same cross chain when normal ≈ Y.
  4. Debug: print xAxis:Dot(n), yAxis:Dot(n) each frame — if either isn’t ~0, the basis is broken that frame.

Walls work because LookVector almost never lines up with ±X/±Z in normal play; floors do it constantly when you glance down. Stabilize the hint vector and the blanking goes away.

1 Like