< Volver 

Dándole vida a un ViewportFrame

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 radianes
  • pitch: ángulo de elevación vertical, en radianes
  • distance: 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.