Hace poco salí con este sistema algo bacano para Rod and Reef, el juego para el que al momento de escribir esto, estoy trabajando como scripter principal de frontend.
Implementación en el juego:
Empezando por el inicio, un ViewportFrame en Roblox es un elemento de UI que renderiza objetos 3D. Más o menos. No es una escena completa, no hay motor de iluminación, no hay simulación de física, no hay entorno. Es más cercano a una miniatura 3D: metes modelos, apuntas una cámara, y dibuja píxeles. Esa limitación es precisamente lo que lo hace interesante, porque todo lo que normalmente manejaría el engine (como órbita de cámara, zoom, paneo) lo tienes que implementar tú, desde cero, en matemática pura (esto me recuerda a programar OpenGL, una belleza).
Este post desglosa exactamente cómo lo hice: dos modos de viewport, uno simple y uno completamente interactivo, ambos impulsados por el mismo modelo de cámara.
En teoría
El modelo de cámara: coordenadas esféricas
Ambos modos comparten la misma idea fundamental. En lugar de guardar la posición de la cámara directamente como un triplete (x, y, z), la cámara se define por su relación con un punto objetivo (el centro del modelo) usando tres valores:
yaw: ángulo de rotación horizontal alrededor del objetivo, en radianespitch: ángulo de elevación vertical, en radianesdistance: qué tan lejos está la cámara del objetivo
Esto son coordenadas esféricas. Dados esos tres valores, la posición de la cámara en espacio mundo es:
camX = center.X + sin(yaw) * cos(pitch) * distance
camY = center.Y + sin(pitch) * distance
camZ = center.Z + cos(yaw) * cos(pitch) * distance
Luego simplemente se llama CFrame.lookAt(camPos, center) y la cámara siempre mira al objetivo, sin importar a dónde haya orbitado. La matemática es la misma en Roblox, Unity, Three.js, o si estás escribiendo un renderer en OpenGL puro; El sistema de coordenadas es universal.
Los viewports simples (slots) solo usan yaw y distance. El viewport interactivo agrega pitch y un vector adicional panOffset, que explico más adelante.
Slot viewports: giro y zoom
El caso simple: Cada slot viewport tiene un modelo que gira automáticamente y permite al usuario hacer scroll para hacer zoom. No hay rotación controlada por el usuario, el yaw se incrementa cada frame a velocidad fija:
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)
Algunos detalles que vale la pena notar:
El componente Y está hardcodeado. data.distance * 0.25 pone la cámara ligeramente por encima del centro del modelo en todo momento. Es una simplificación deliberada, los slots no necesitan control de pitch, y un ángulo suave hacia abajo se ve bien para la mayoría de los modelos sin ninguna lógica adicional.
La velocidad de giro es aleatoria por slot, entre SLOT_SPIN_MIN y SLOT_SPIN_MAX. Esto evita el efecto de valle inquietante de ver todos los modelos en una grilla girar en perfecta sincronía, como soldados en un desfile, pero de juguete, y raro. (Ahora que lo pienso esto sí da mal rollo).
Los ángulos iniciales están escalonados con index * 0.7 radianes. Sin esto, todos los slots arrancarían mirando en la misma dirección, lo que parece un bug aunque técnicamente no lo sea.
El zoom se maneja leyendo el input del mouse wheel directamente en el 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)
El delta del zoom es proporcional a la distancia actual (data.distance * ZOOM_PERCENT). Un delta fijo se sentiría lento cuando estás lejos y brutalmente rápido cuando estás cerca. Escalar por la distancia da una velocidad aparente consistente sin importar en qué punto del rango de zoom estés. El clamp evita que atravieses el modelo o te alejes al infinito, usando el tamaño del bounding box del modelo como unidad de referencia.
El info viewport: control interactivo completo
El viewport interactivo agrega tres cosas sobre el modelo de los slots: control de pitch (órbita vertical), paneo, y auto-giro que se detiene en el momento en que el usuario toca algo.
Estado
type InfoVP = {
cam : Camera,
model : Model,
center : Vector3,
yaw : number,
pitch : number,
distance : number,
panOffset : Vector3, -- desplazamiento acumulado por paneo
baseSize : number,
autoSpin : boolean,
}
local iv = {} :: InfoVP -- (agregar todos los componentes bruh)
panOffset es la adición clave. El paneo no mueve la cámara en sí si no que desplaza el punto objetivo que la cámara mira. Así es exactamente como funciona el paneo en Blender o cualquier editor 3D. La posición de la cámara se calcula relativa a center + panOffset en lugar de solo center.
Órbita (clic izquierdo + arrastre)
iv.yaw -= delta.X * ORBIT_SENS
iv.pitch = math.clamp(iv.pitch + delta.Y * ORBIT_SENS, MIN_PITCH, MAX_PITCH)
delta.X se niega para que arrastrar a la derecha rote el modelo hacia la derecha, lo que significa que la cámara gira a la izquierda alrededor de él. Sin la negación se siente invertido, como los controles de vuelo de un juego chichipato.
delta.Y se suma (no se resta) porque el Y de pantalla crece hacia abajo. Arrastrar el mouse hacia arriba da un delta.Y negativo, y restar un negativo bajaría el pitch en lugar de subirlo, lo opuesto de lo que esperarías. Sumarlo hace que “arrastrar hacia arriba - cámara sube” se sienta natural.
El pitch se limita a ±80°. Más allá de eso, la cámara pasaría por encima de los polos y la órbita se rompería por completo o la cámara empezaría a girar en el sentido equivocado y todo se volvería un desastre.
Paneo (clic derecho + arrastre)
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)
Esta es la parte que la mayoría de las implementaciones hacen mal. El paneo tiene que ocurrir en espacio cámara, no en espacio mundo. Si paneas a lo largo del eje X del mundo, la dirección del movimiento cambia a medida que la cámara orbita. A 90° de yaw se convierte en un movimiento en Z, lo que se siente completamente roto. Usando cam.CFrame.RightVector y cam.CFrame.UpVector, el paneo siempre mueve la escena en la dirección hacia la que la cámara realmente apunta.
La sensibilidad del paneo también escala con iv.distance, por la misma razón que el zoom: de cerca, movimientos pequeños deben sentirse precisos; de lejos, el mismo movimiento de píxeles debería cubrir más espacio mundo para no estar arrastrando centímetro a centímetro.
Ambos ejes se ajustan para que la escena siga el cursor (“arrastrar a la izquierda, escena va a la izquierda”). Sin los signos correctos la escena se alejaría del cursor en lugar de seguirlo, ese efecto tan odioso que hace dudar si el programa está al revés o uno está al revés.
Manejo del estado de arrastre
Hay un pequeñísimo detalle en cómo se maneja el drag que es fácil de meter mal: InputBegan está conectado al ViewportFrame, pero InputChanged e InputEnded están conectados a UserInputService globalmente.
Si conectas InputChanged al viewport, arrastrar lo suficientemente rápido como para sacar el cursor del frame silenciosamente descarta todos los eventos de movimiento siguientes, y la cámara se congela a mitad del arrastre hasta que vuelves a entrar. Conectar globalmente significa que el drag siempre rastrea el cursor correctamente, e InputEnded siempre dispara aunque sueltes el botón fuera del frame, así nunca se queda atrapado en un estado de arrastre eterno.
Setup: el bounding box como regla universal
Ambos tipos de viewport usan el bounding box del modelo para derivar sus parámetros iniciales de cámara:
local cf, size = clone:GetBoundingBox()
local baseSize = math.max(size.X, size.Y, size.Z)
baseSize es el eje más largo del modelo. Todo (distancia inicial, límites de zoom, sensibilidad de paneo) se expresa como un múltiplo de baseSize. Esto hace el sistema agnóstico al modelo: un anillo diminuto y una nave enorme arrancan correctamente encuadrados y con rangos de zoom proporcionales, sin ninguna configuración por modelo. Es como tener una regla que se estira sola dependiendo de qué estás midiendo, super cómodo.
Un loop, dos comportamientos
Ambos tipos de viewport se actualizan dentro de un único loop de RenderStepped. El loop itera sobre una tabla de slot viewports y maneja el info viewport como caso especial. Sin threads separados, sin conexiones por viewport. Un loop, un solo lugar donde depurar si le da un calambre en la porra al programa.
RunService.RenderStepped:Connect(function(dt: number)
for vp, data in SlotViewports do
-- auto-giro solamente
end
local iv = CurrentInfoVP
if iv then
-- auto-giro O interactivo, dependiendo de iv.autoSpin
end
end)
El flag autoSpin en InfoVP conecta los dos comportamientos: el viewport arranca girando como un slot, y la primera vez que el usuario hace clic o arrastra, autoSpin se pone en false y la cámara se congela en su lugar, esperando input.
En la práctica
Aquí esta la implementación como tal en el juego, conectando todos los conceptos y usando un tris de lógica no tan exótica (sencilla)
--> Copyright (c) 2026 Mastedore.
--> SPDX-License-Identifier: Unlicense
--[[
Este código lo he dedicado al dominio publico,
Usalo como se te pegue, si?
]]
-- Estado de un slot (solo spin + zoom)
type SlotVP = {
cam : Camera,
model : Model,
center : Vector3,
yaw : number,
distance : number,
baseSize : number,
spinSpeed : number,
}
-- Estado del InfoFrame (full interactivo)
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 -- fraccion de la distancia recorrida por cada paso de desplazamiento
local MIN_ZOOM_MUL = 0.8 -- distancia min = baseSize * this
local MAX_ZOOM_MUL = 6 -- distancia max = baseSize * this
-- orbita del InfoFrame (drag de clik izquierdo)
local ORBIT_SENS = 0.005 -- radianes por pixel
local MIN_PITCH = math.rad(-80)
local MAX_PITCH = math.rad(80)
-- pan 🥖🥐🍞🥯 del InfoFrame (drag pero con click derecho)
local PAN_SENS = 0.003 -- unidades por pixel, escalado por la distancia
-- 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 -
--------------------------------------------------------------------------------
-- prepara un viewportframe y le penetra un modelo (sin worldmodels).
-- retorna el modelo y la camara
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
-- giro + scroll pa zoom. cada slot tiene una velocidad random para darle un mejor look
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, -- lo q ya mencione
distance = startDist,
baseSize = baseSize,
spinSpeed = SLOT_SPIN_MIN + math.random() * (SLOT_SPIN_MAX - SLOT_SPIN_MIN),
}
SlotViewports[vp] = data
-- scroll zoom (y solo pasa cuando el cursor esta dentro del viewport!!11!1!!!1!!1!1111!!!!!!)
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
--setupiar el infoframe lol ya explique eso
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
-- scroll zoom
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))
-- drag
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))
-- drag delta
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))
-- liberar
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
La matemática es la misma en todas partes
Las llamadas de API específicas son de Roblox/Luau (CFrame.lookAt, RenderStepped, ViewportFrame) pero nada del sistema subyacente es exclusivo del engine. Coordenadas esféricas para la órbita, vectores en espacio cámara para el paneo, sensibilidad proporcional a la distancia para el zoom: estas son las mismas técnicas que usa OrbitControls de Three.js, el viewport de Blender, cada editor 3D que hayas usado. Si entiendes esta implementación, entiendes todas.
- -Marcos.
“Dándole vida a un ViewportFrame” por Mastedore está licenciado bajo CC BY 4.0 International.