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 radianspitch: vertical elevation angle, in radiansdistance: 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.