← Back 

Bringing a ViewportFrame to life

I recently scripted this pretty cool system for Rod and Reef, the game I’m currently working on as the lead frontend scripter.

In-game implementation:

First things first, a ViewportFrame in Roblox is a GUI element that renders 3D objects. Sorta. It’s not a full scene, there’s no lighting engine, no physics simulation, no environment. It’s closer to a 3D thumbnail: you drop models into it, point a camera at them, and it draws pixels. That limitation is actually what makes it interesting to work with, because everything that would normally be handled by the engine (camera orbit, zoom, pan) you have to implement yourself, from scratch, in math (like programming OpenGL, i love it).

This post breaks down exactly how I did that: two viewport modes, one simple and one fully interactive, both driven by the same underlying camera model.


In theory

The camera model: spherical coordinates

Both viewport modes share the same fundamental idea. Instead of storing the camera’s position directly as a (x, y, z) triplet, the camera is defined by its relationship to a target point (the center of the model) using three values:

  • yaw: horizontal rotation angle around the target, in radians
  • pitch: vertical elevation angle, in radians
  • distance: how far the camera is from the target

This is spherical coordinates. Given those three values, the camera position in world space is:

camX = center.X + sin(yaw)  * cos(pitch) * distance
camY = center.Y + sin(pitch) * distance
camZ = center.Z + cos(yaw)  * cos(pitch) * distance

Then you just call CFrame.lookAt(camPos, center) and the camera always faces the target, regardless of where it orbits to. The math is the same whether you’re in Roblox, Unity, Three.js, or writing a raw OpenGL renderer, the coordinate system is universal.

The simple (slot) viewports only use yaw and distance. The interactive (info) viewport adds pitch and an additional panOffset vector, covered below.


Slot viewports: spin and zoom

The simple case. Each slot viewport holds a model that auto-spins and lets the user scroll to zoom. There’s no user-driven rotation: the yaw increments every frame at a fixed speed:

data.yaw += dt * data.spinSpeed
local camPos = data.center + Vector3.new(
    math.sin(data.yaw) * data.distance,
    data.distance * 0.25,
    math.cos(data.yaw) * data.distance
)
data.cam.CFrame = CFrame.lookAt(camPos, data.center)

A few things worth noting here:

The Y component is hardcoded. data.distance * 0.25 places the camera slightly above the model’s center at all times. This is a deliberate simplification: slots don’t need pitch control, and a fixed gentle downward angle looks good for most models without any additional logic.

Spin speed is randomized per slot between SLOT_SPIN_MIN and SLOT_SPIN_MAX. This avoids the uncanny valley effect of every model in a grid spinning in perfect lockstep.

Starting angles are staggered by index * 0.7 radians. Without this, every slot would face the same direction on load, which looks like a bug even if it technically isn’t.

Zoom is handled by reading the mouse wheel input directly on the ViewportFrame:

vp.InputChanged:Connect(function(input: InputObject)
    if input.UserInputType ~= Enum.UserInputType.MouseWheel then return end
    local delta = -input.Position.Z * (data.distance * ZOOM_PERCENT)
    data.distance = math.clamp(
        data.distance + delta,
        baseSize * MIN_ZOOM_MUL,
        baseSize * MAX_ZOOM_MUL
    )
end)

The zoom delta is proportional to the current distance (data.distance * ZOOM_PERCENT). A fixed delta would feel sluggish when zoomed out and violently fast when zoomed in. Scaling by distance gives you consistent apparent speed regardless of where you are in the zoom range. The clamp prevents zooming through the model or infinitely away from it, using the model’s bounding box size as a reference unit.


The info viewport: full interactive control

The interactive viewport adds three things on top of the slot model: pitch control (vertical orbit), pan, and auto-spin that stops the moment the user touches it.

State

type InfoVP = {
    cam       : Camera,
    model     : Model,
    center    : Vector3,
    yaw       : number,
    pitch     : number,
    distance  : number,
    panOffset : Vector3,  -- accumulated pan displacement
    baseSize  : number,
    autoSpin  : boolean,
}

local iv = {} :: InfoVP -- add components lol

panOffset is the key addition. Pan doesn’t move the camera itself, it shifts the target point that the camera looks at. This is exactly how pan works in Blender or any 3D editor. The camera position is computed relative to center + panOffset instead of just center.

Orbit (left-drag)

iv.yaw   -= delta.X * ORBIT_SENS
iv.pitch  = math.clamp(iv.pitch + delta.Y * ORBIT_SENS, MIN_PITCH, MAX_PITCH)

delta.X is negated so that dragging right rotates the model rightward, which means the camera swings left around it. Without the negation it feels inverted.

delta.Y is added (not subtracted) because screen Y increases downward. Dragging the mouse up gives a negative delta.Y, and subtracting a negative would push pitch down instead of up, the opposite of what you’d expect. Adding it makes “drag up - camera goes up” feel natural.

Pitch is clamped to ±80°. Beyond that the camera would flip over the poles, which breaks the orbit metaphor completely.

Pan (right-drag)

local right = cam.CFrame.RightVector
local up    = cam.CFrame.UpVector
iv.panOffset += right * (-delta.X * PAN_SENS * iv.distance)
iv.panOffset += up    * ( delta.Y * PAN_SENS * iv.distance)

This is the part most implementations get wrong. Pan has to happen in camera space, not world space. If you pan along the world X axis, the direction of movement changes as the camera orbits. At 90° yaw it becomes a Z-axis movement, which feels completely broken. By using cam.CFrame.RightVector and cam.CFrame.UpVector, pan always moves the scene in the direction the camera is actually facing.

Pan sensitivity is also scaled by iv.distance for the same reason zoom is: at close range, small movements should feel precise; at long range, the same pixel movement should cover more world space so you’re not inching around.

Both axes are negated on X and kept positive on Y so the scene follows the cursor (“drag left, scene goes left”). Without the negation the scene would feel like it’s being pushed away from the cursor instead.

Drag state management

There’s one subtlety in how drag is handled that’s easy to get wrong: InputBegan is connected to the ViewportFrame, but InputChanged and InputEnded are connected to UserInputService globally.

If you connect InputChanged to the viewport, dragging fast enough to move the cursor outside the frame silently drops all further move events, and the camera freezes mid-drag until you move back inside. Connecting globally means drag always tracks the cursor correctly, and InputEnded always fires even if the button is released outside the frame, so you never get stuck in a perpetual-drag state.


Setup: bounding box as the universal ruler

Both viewport types use the model’s bounding box to derive their initial camera parameters:

local cf, size = clone:GetBoundingBox()
local baseSize = math.max(size.X, size.Y, size.Z)

baseSize is the longest axis of the model. Everything (initial distance, zoom limits, pan sensitivity) is expressed as a multiple of baseSize. This makes the system model-agnostic: a tiny ring and a massive ship both start framed correctly and have proportionally sane zoom ranges without any per-model configuration.


One render loop, two behaviors

Both viewport types update inside a single RenderStepped loop. The loop iterates over a table of slot viewports and handles the info viewport as a special case. No separate threads, no per-viewport connections. one loop, one place to debug if the program crashes out of nowhere.

RunService.RenderStepped:Connect(function(dt: number)
    for vp, data in SlotViewports do
        -- auto-spin only
    end

    local iv = CurrentInfoVP
    if iv then
        -- auto-spin OR interactive, depending on iv.autoSpin
    end
end)

The autoSpin flag on InfoVP bridges the two behaviors: the viewport starts spinning like a slot, and the first time the user clicks or drags, autoSpin is set to false and the camera freezes in place, waiting for input.


In practice

Here’s the full implementation that is on the game, connecting all the concepts and a bit of simple logic.

--> Copyright (c) 2026 Mastedore.
--> SPDX-License-Identifier: Unlicense
--[[
    I released this code into the public domain,
    do what the fuck u want with it.
]]

-- Single slot state
type SlotVP = {
	cam       : Camera,
	model     : Model,
	center    : Vector3,
	yaw       : number,
	distance  : number,
	baseSize  : number,
	spinSpeed : number,
}

-- InfoFrame state
type InfoVP = {
	cam       : Camera,
	model     : Model,
	center    : Vector3,
	yaw       : number,
	pitch     : number,
	distance  : number,
	panOffset : Vector3,
	baseSize  : number,
	autoSpin  : boolean,
}

-- Zoom
local ZOOM_PERCENT  = 0.15    -- fraction of current distance moved per scroll tick
local MIN_ZOOM_MUL  = 0.8     -- minimum distance = baseSize * this
local MAX_ZOOM_MUL  = 6       -- maximum distance = baseSize * this

-- orbit InfoFrame (right click drag)
local ORBIT_SENS    = 0.005   -- radians/pixel
local MIN_PITCH     = math.rad(-80)
local MAX_PITCH     = math.rad(80)

-- pan 🍳🍳🍳🥘
local PAN_SENS      = 0.003   -- units per pixel, scaled by distance

-- radianes por segundo
local SLOT_SPIN_MIN = 0.35
local SLOT_SPIN_MAX = 0.65
local INFO_SPIN_SPD = 0.50


local function StartLoop()
	if LoopConn then return end
	LoopConn = RunService.RenderStepped:Connect(function(dt: number)

		-- viewports de slots
		for vp, data in SlotViewports do
			if not vp.Parent then
				SlotViewports[vp] = nil
				continue
			end
			data.yaw += dt * data.spinSpeed
			local camPos = data.center + Vector3.new(
				math.sin(data.yaw) * data.distance,
				data.distance * 0.25,
				math.cos(data.yaw) * data.distance
			)
			data.cam.CFrame = CFrame.lookAt(camPos, data.center)
		end

		-- viewporet del infoframe
		local iv = CurrentInfoVP
		if iv and InfoViewport and InfoViewport.Parent then
			if iv.autoSpin then
				iv.yaw += dt * INFO_SPIN_SPD
			end
			local camPos = iv.center + iv.panOffset + Vector3.new(
				math.sin(iv.yaw)  * math.cos(iv.pitch) * iv.distance,
				math.sin(iv.pitch) * iv.distance,
				math.cos(iv.yaw)  * math.cos(iv.pitch) * iv.distance
			)
			iv.cam.CFrame = CFrame.lookAt(camPos, iv.center + iv.panOffset)
		end
	end)
end


--------------------------------------------------------------------------------
-- VIEWPORT HELPERS                                                            -
--------------------------------------------------------------------------------


local function CloneIntoVP(vp: viewportframe, model: Model): (Model, Camera)
	for _, child in vp:GetChildren() do
		if child:IsA("UIStroke") or child:IsA("UIGradient") then continue end
		child:Destroy()
	end

	local clone = model:Clone()
	for _, desc in clone:GetDescendants() do
		-- Beams/Trails need Attachments that don't render properly in ViewportFrames
		if desc:IsA("Beam") or desc:IsA("Trail") then desc:Destroy() end
	end
	clone.Parent = vp

	local cam = Instance.new("Camera")
	cam.FieldOfView = 40
	vp.CurrentCamera = cam
	cam.Parent = vp

	return clone, cam
end


local function SetupSlotViewport(
	vp      : ViewportFrame,
	model   : Model,
	index   : number,
	janitor : typeof(Janitor.new())
)
	local clone, cam = CloneIntoVP(vp, model)
	local cf, size   = clone:GetBoundingBox()
	local baseSize   = math.max(size.X, size.Y, size.Z)
	local startDist  = baseSize * 2.2

	local data: SlotVP = {
		cam       = cam,
		model     = clone,
		center    = cf.Position,
		yaw       = index * 0.7,   -- what i mentioned
		distance  = startDist,
		baseSize  = baseSize,
		spinSpeed = SLOT_SPIN_MIN + math.random() * (SLOT_SPIN_MAX - SLOT_SPIN_MIN),
	}
	SlotViewports[vp] = data


	janitor:Add(vp.InputChanged:Connect(function(input: InputObject)
		if input.UserInputType ~= Enum.UserInputType.MouseWheel then return end
		local delta = -input.Position.Z * (data.distance * ZOOM_PERCENT)
		data.distance = math.clamp(
			data.distance + delta,
			baseSize * MIN_ZOOM_MUL,
			baseSize * MAX_ZOOM_MUL
		)
	end))
end


local function SetupInfoViewport(model: Model)
	if not InfoViewport then return end
	InfoInputJanitor:Cleanup()

	SlotViewports[InfoViewport] = nil

	local clone, cam = CloneIntoVP(InfoViewport, model)
	local cf, size   = clone:GetBoundingBox()
	local baseSize   = math.max(size.X, size.Y, size.Z)

	local iv: InfoVP = {
		cam       = cam,
		model     = clone,
		center    = cf.Position,
		yaw       = 0,
		pitch     = math.rad(15),   -- para darle una pequeña sacudida la primera vez que renderiza 
		distance  = baseSize * 2.5,
		panOffset = Vector3.zero,
		baseSize  = baseSize,
		autoSpin  = true,
	}
	CurrentInfoVP = iv

	local orbitActive  = false
	local panActive    = false
	local lastMousePos = Vector2.zero

	InfoInputJanitor:Add(InfoViewport.InputChanged:Connect(function(input: InputObject)
		if input.UserInputType ~= Enum.UserInputType.MouseWheel then return end
		local delta = -input.Position.Z * (iv.distance * ZOOM_PERCENT)
		iv.distance = math.clamp(
			iv.distance + delta,
			baseSize * MIN_ZOOM_MUL,
			baseSize * MAX_ZOOM_MUL
		)
	end))

	InfoInputJanitor:Add(InfoViewport.InputBegan:Connect(function(input: InputObject)
		local t = input.UserInputType
		if t == Enum.UserInputType.MouseButton1 then
			orbitActive   = true
			panActive     = false
			lastMousePos  = Vector2.new(input.Position.X, input.Position.Y)
			iv.autoSpin   = false
		elseif t == Enum.UserInputType.MouseButton2 then
			panActive     = true
			orbitActive   = false
			lastMousePos  = Vector2.new(input.Position.X, input.Position.Y)
			iv.autoSpin   = false
		end
	end))

	InfoInputJanitor:Add(UserInputService.InputChanged:Connect(function(input: InputObject)
		if input.UserInputType ~= Enum.UserInputType.MouseMovement then return end
		if not orbitActive and not panActive then return end

		local curPos = Vector2.new(input.Position.X, input.Position.Y)
		local delta  = curPos - lastMousePos
		lastMousePos = curPos

		if orbitActive then
			iv.yaw   -= delta.X * ORBIT_SENS
			iv.pitch  = math.clamp(iv.pitch + delta.Y * ORBIT_SENS, MIN_PITCH, MAX_PITCH)

		elseif panActive then
			local right = cam.CFrame.RightVector
			local up    = cam.CFrame.UpVector
			iv.panOffset += right * (-delta.X * PAN_SENS * iv.distance)
			iv.panOffset += up    * ( delta.Y * PAN_SENS * iv.distance)
		end
	end))

	InfoInputJanitor:Add(UserInputService.InputEnded:Connect(function(input: InputObject)
		local t = input.UserInputType
		if t == Enum.UserInputType.MouseButton1 then
			orbitActive = false
		elseif t == Enum.UserInputType.MouseButton2 then
			panActive = false
		end
	end))
end

The math is the same everywhere

The specific API calls are Roblox/Luau (CFrame.lookAt, RenderStepped, ViewportFrame) but nothing about the underlying system is engine-specific. Spherical coordinates for orbit, camera-space vectors for pan, distance-proportional sensitivity for zoom: these are the same techniques used in three.js’s OrbitControls, in Blender’s viewport, in every 3D editor you’ve used. If you understand this implementation, you understand all of them.

  • -Marcos.

“Bringing a ViewportFrame to life” by Mastedore is licensed under CC BY 4.0 International.