Skip to content

Monolito

Última actualización: 03-02-2026

Documento técnico del visor tal como está montado hoy (escena + scripts + conexiones). Sin relleno.

Índice modular (lo que vamos a tocar por partes)

Section titled “Índice modular (lo que vamos a tocar por partes)”
  • M1 — Mapa de conexiones (RED + Input + UI)
  • M2 — Escena y jerarquía (GameObjects)
  • M3 — RED aplicado (Rt–Ev–Dt) + DI
  • M4 — Input System: ViewerControls (Action Map Viewer)
  • M5 — Desktop Bridge: ViewerInputAdapter (InputActions → Dt/Ev)
  • M6 — Touch Bridge: ViewerTouchGestureAdapter (Touch → Dt)
  • M7 — Orquestación: ViewerController (Dt/Ev → Cámara)
  • M8 — Cámara: OrbitCameraController (Pivot + Camera)
  • M9 — Contenido: ModelRoot (bounds para Frame)
  • M10 — UI: Debug Menu (UI Toolkit) — tecla M
  • M11 — WebGL / Hosting (Itch + iframe) — foco, RMB, wheel

M1 — Mapa de conexiones (RED + Input + UI)

Section titled “M1 — Mapa de conexiones (RED + Input + UI)”

Viewer (Desktop):

  • ViewerControls (Input Actions, Action Map Viewer) → ViewerInputAdapter
  • ViewerInputAdapter escribe Dt (DataSO) y dispara Ev (EventsSO)
  • ViewerController consume Dt/Ev y ejecuta la cámara vía OrbitCameraController

Touch (Mobile):

  • Enhanced TouchViewerTouchGestureAdapterDt (DataSO) → ViewerControllerOrbitCameraController

UI Debug Menu (UI Toolkit):

  • UiToolkitDebugMenuController controla el panel y la tecla M.
  • Por ahora, este módulo NO pasa por ViewerControls ni por RED (solo UI).

Jerarquía actual (tal como está en tu escena)

Section titled “Jerarquía actual (tal como está en tu escena)”
  • Viewer_Root

    • Viewer_Pivot

      • Viewer_Camera (MainCamera + AspectRatioEnforcer)
      • BackgroundCamera (relleno negro)
    • Viewer_Input (input del viewer)

      • PlayerInput (Actions = ViewerControls, Default Map = Viewer)
      • ViewerInputAdapter
      • ViewerTouchGestureAdapter
    • ViewerController (solo el script ViewerController)

    • ModelRoot

      • Main Light
      • Mesh 3D (contenido)

Qué es cada nodo (para que no vuelva a confundirse)

Section titled “Qué es cada nodo (para que no vuelva a confundirse)”
  • Viewer_Root

    • Contiene RtLifetimeScope (VContainer).
    • Es el único lugar con referencias serializadas a Dt/Ev: DataSO, EventsSO.
    • También serializa ModelRoot como scene ref para inyectarlo (se usa para bounds/Frame).
  • Viewer_Pivot

    • Transform “pivot” del visor.

    • Tiene OrbitCameraController y referencia:

      • Pivot = Viewer_Pivot (Transform)
      • Camera = Viewer_Camera (Camera)
  • Viewer_Camera

    • Cámara que renderiza el contenido.
    • Tiene AspectRatioEnforcer (en tu caso 1:1) que ajusta camera.rect para forzar el aspect.
  • BackgroundCamera

    • Cámara de fondo para que las barras (letterbox/pillarbox) queden negras.

    • Config típica (como la tienes):

      • Solid Color negro
      • Culling Mask = Nothing
      • Priority menor que Viewer_Camera
  • Viewer_Input

    • Agrupa todo lo de entrada del viewer:

      • PlayerInput (Input Actions)
      • ViewerInputAdapter (desktop → Dt/Ev)
      • ViewerTouchGestureAdapter (touch → Dt)
  • ViewerController

    • Orquestador.
    • Consume DataSO, escucha EventsSO y llama a OrbitCameraController.
  • ModelRoot

    • Punto único donde vive el contenido.

    • ViewerController lo usa para:

      • centrado inicial (startup): calcula Bounds cuando existan renderers y fija el centro con SetLastFrameCenter(bounds.center, alsoMoveTargetPivot: true) + ResetView() (arranca centrado sin efecto “como si hiciera F”).
      • Frame() (F): calcular bounds de lo que haya debajo y ejecutar camera.Frame(bounds) cuando el usuario lo pide (centra y ajusta distancia).
    • Nota: una Light no aporta bounds (no es renderer). Si quieres orden, opcional mover Main Light fuera de ModelRoot, pero no es obligatorio.

  • ModelRoot nunca debe ser hijo de Viewer_Pivot.
  • Viewer_Pivot contiene cámaras (y el script de cámara), no contenido.
  • Viewer_Input agrupa input; ViewerController agrupa lógica.

Este módulo define cómo se cablea todo: quién conoce a quién, por qué, y dónde está prohibido resolver dependencias.

  • Rt (Root): único lugar donde se construye el grafo (VContainer) y se serializan refs de escena.
  • Dt (DataSO): estado runtime en memoria (buffer entre input y gameplay).
  • Ev (EventsSO): señales sin payload (acciones discretas).

Resultado: Input no toca cámara, cámara no lee dispositivos, y el acoplamiento queda concentrado en Rt.


M3.2 Rt — RtLifetimeScope (cómo construye el grafo)

Section titled “M3.2 Rt — RtLifetimeScope (cómo construye el grafo)”
  • DataSO _data
  • EventsSO _events
  • Transform _modelRoot
  • Awake(): si existe _data, llama ResetRuntimeState() antes de construir el contenedor.

    • Motivo: DataSO es ScriptableObject; su estado runtime puede “quedarse pegado” entre runs si hay Domain Reload deshabilitado o si el asset conserva valores. Se fuerza un estado limpio al arrancar.
  • RegisterInstance(_data) → todos reciben el mismo DataSO.

  • RegisterInstance(_events) → todos reciben el mismo EventsSO.

  • RegisterInstance(_modelRoot) → permite inyectar Transform del contenido (bounds/Frame).

  • RegisterComponentInHierarchy<...>() → inyecta componentes existentes en escena:

    • ViewerInputAdapter
    • ViewerTouchGestureAdapter
    • ViewerController
    • OrbitCameraController
  • DataSO/EventsSO/ModelRoot viven en Rt porque son dependencias globales del viewer (y deben ser visibles/serializables en un solo lugar).
  • RegisterComponentInHierarchy evita “auto-resolve” por fuera y mantiene los scripts como componentes de escena normales.

M3.3 Grafo de inyección (quién recibe qué y para qué)

Section titled “M3.3 Grafo de inyección (quién recibe qué y para qué)”
ReceptorInyectaPor qué lo necesitaQué NO debe hacer
ViewerInputAdapterDataSO, EventsSOTraducir Input Actions a Dt/EvNo llamar a cámara ni calcular bounds
ViewerTouchGestureAdapterDataSOTraducir touch gestures a DtNo disparar eventos discretos (por ahora)
ViewerControllerDataSO, EventsSO, OrbitCameraController, Transform modelRootConsumir Dt, escuchar Ev y ejecutar cámara + FrameNo leer InputActions ni teclado
OrbitCameraController(ninguna por DI)Control de cámara/pivot con parámetros de InspectorNo leer dispositivos ni DataSO

Nota: OrbitCameraController no usa DI para sus refs internas (_pivot, _camera) porque son wiring local del módulo cámara (ref directa de escena, estable y visible en Inspector).


DataSO guarda únicamente estado runtime del viewer (no persistencia).

  • _orbitHeld, _panHeld, _dollyHeld
  • Setters: ViewerSetOrbitHeld(bool), ViewerSetPanHeld(bool), ViewerSetDollyHeld(bool)
  • Getters: ViewerOrbitHeld(), ViewerPanHeld(), ViewerDollyHeld()

Por qué existe: el input (desktop/touch) define “qué modo está activo” y el orquestador decide qué operación aplicar.

  • _pointerDelta (Vector2)
  • _zoomScrollY (float)

API:

  • ViewerAccumulatePointerDelta(Vector2) + ViewerConsumePointerDelta()
  • ViewerAccumulateZoomScrollY(float) + ViewerConsumeZoomScrollY()

Regla: los adapters acumulan, el ViewerController consume (lee y limpia).

  • MaxPointerDelta y MaxScroll para clamping.

Por qué: evita spikes raros (p.ej. delta enorme por pérdida de foco o eventos acumulados) que rompan la cámara.


Eventos disponibles:

  • FrameRequested
  • ResetRequested

API:

  • RaiseFrameRequested()
  • RaiseResetRequested()

Conexión:

  • ViewerInputAdapter dispara Raise*() cuando el input discreto ocurre.
  • ViewerController se suscribe en OnEnable() y se desuscribe en OnDisable().

Por qué sin payload: Frame y Reset no requieren estado extra. El consumidor (ViewerController) decide cómo actuar (ej. bounds de ModelRoot).


M3.6 Qué NO está conectado (a propósito)

Section titled “M3.6 Qué NO está conectado (a propósito)”
  • UI Toolkit Debug Menu (tecla M) no pasa por RED todavía: vive como módulo UI independiente.

    • Motivo: no afecta gameplay del viewer aún; evita meter dependencias prematuras.
    • Si más adelante debe bloquear cámara o disparar acciones, se conectará vía Dt/Ev.
  • DataSO no persiste en disco. Persistencia sería otro sistema.



M4 — Input System: ViewerControls (Action Map Viewer)

Section titled “M4 — Input System: ViewerControls (Action Map Viewer)”

Este módulo define el asset de Input Actions y su contrato. No describe gameplay.

  • Qué hace M4: define qué acciones existen y qué valores producen.
  • Dónde se conectan: ver M5 (Desktop → Dt/Ev) y M6 (Touch → Dt).
  • Dónde se ejecutan: ver M7 (consume Dt/Ev) y M8 (cámara).

  • Map activo: Viewer
AcciónAction TypeControl TypeBinding actualQué produceQuién la consume
OrbitButtonButtonLeft Button [Mouse]Held (performed/canceled)M5 → DataSO.ViewerSetOrbitHeld()
PanButtonButtonMiddle Button [Mouse]Held (performed/canceled)M5 → DataSO.ViewerSetPanHeld()
DollyButtonButtonRight Button [Mouse]Held (performed/canceled)M5 → DataSO.ViewerSetDollyHeld()
PointerDeltaValueVector2Delta [Pointer] (<Pointer>/delta)Vector2 delta (px por evento)M5/M6 → DataSO.ViewerAccumulatePointerDelta()
ZoomPassThroughAxis (float)Scroll/Y [Mouse]float scrollY (delta por rueda)M5/M6 → DataSO.ViewerAccumulateZoomScrollY()
FrameButtonButtonF [Keyboard]discreto (performed)M5 → EventsSO.RaiseFrameRequested()
ResetButtonButtonR [Keyboard]discreto (performed)M5 → EventsSO.RaiseResetRequested()

<Pointer>/delta es el movimiento del puntero en píxeles desde el último evento (mouse o pointer compatible). En este proyecto se usa como “delta único” para Orbit/Pan/Dolly.

Por qué está separado de Orbit/Pan/Dolly:

  • Orbit/Pan/Dolly solo dicen qué modo está activo (held).
  • PointerDelta es el dato (delta).
  • El orquestador (M7) decide con prioridad Dolly > Pan > Orbit qué operación aplicar usando el mismo delta.

En Input System, PassThrough es apropiado para señales tipo delta (rueda/pinch):

  • No quieres “estado sostenido”; quieres impulsos repetidos.
  • Evita que el sistema intente hacer resolución de conflictos como si fuera un valor “dominante”.

En este proyecto, además, el adapter (M5) lee Zoom en ctx.performed y acumula en Dt. Con rueda, performed ocurre por cada delta.

Conclusión: Zoom = PassThrough + Axis es correcto para el wheel.


ViewerInputAdapter compara por ctx.action.name usando strings constantes.

  • Si renombrar acciones en el asset (ViewerControls) → rompes el puente.
  • Mantener nombres: PointerDelta, Orbit, Pan, Dolly, Zoom, Frame, Reset.

TeclaAcciónDónde está definidaNota
FFrameViewerControls → M5 → EventsSOEl “cuánto encuadra” se ajusta en M8 (_framePadding). Frame centra en bounds.center y ajusta distancia.
RResetViewerControls → M5 → EventsSOResetea a defaults de M8 (_defaultAngles/_defaultDistance/_cameraLocalOffsetXY). Además, si existe un centro válido (lastFrameCenter), centra el pivot ahí (esto hace que el Reset sea consistente tras Frame y también en el arranque). No “restaura” una posición fija inicial del pivot.
MDebug MenuUI Toolkit (M10)No está en ViewerControls por ahora.

M5 — Desktop Bridge: ViewerInputAdapter (InputActions → Dt/Ev)

Section titled “M5 — Desktop Bridge: ViewerInputAdapter (InputActions → Dt/Ev)”

Traducir Input Actions a:

  • Escrituras en DataSO (held + acumulados)
  • Señales en EventsSO (Frame/Reset)
  • PlayerInput (mismo GO)
  • ViewerControls (Action Map Viewer)
  • PointerDelta → DataSO.ViewerAccumulatePointerDelta(Vector2)
  • Zoom → DataSO.ViewerAccumulateZoomScrollY(float)
  • Orbit/Pan/Dolly → DataSO.ViewerSet*Held(bool) por performed/canceled
  • Frame/Reset → EventsSO.Raise*Requested()
  • No llama a cámara.
  • No hace gameplay.
  • En WebGL: RMB puede abrir contextmenu si el host/template no lo bloquea.
  • Teclado depende del foco del canvas.

M6 — Touch Bridge: ViewerTouchGestureAdapter (Touch → Dt)

Section titled “M6 — Touch Bridge: ViewerTouchGestureAdapter (Touch → Dt)”

Soportar gestos compuestos en Mobile WebGL sin interferir con Desktop.

  • EnhancedTouchSupport
  • Touch.activeTouches

Escribe en el mismo contrato DataSO:

  • 1 dedo: Orbit + PointerDelta
  • 2 dedos: Pan (centroide) + Zoom (pinch → scrollY)
  • No usa Input Actions.
  • No emite eventos Ev (por ahora).

Si Touchscreen.current == null → no hace nada. Esto evita apagar el mouse en PC.

  • _orbitMultiplier
  • _panMultiplier
  • _pinchToScroll
  • _pinchThresholdPixels

M7 — Orquestación: ViewerController (Dt/Ev → Cámara)

Section titled “M7 — Orquestación: ViewerController (Dt/Ev → Cámara)”

Este script existe para una sola cosa: ser el “cerebro” del visor.

  • Input (M5/M6) solo produce intención: escribe DataSO y dispara EventsSO.
  • Cámara (M8) solo sabe moverse: orbit/pan/dolly/zoom/frame/reset.
  • ViewerController es el puente de ejecución: interpreta Dt/Ev y llama a la cámara.

Por qué existe (y por qué no lo hace la cámara)

Section titled “Por qué existe (y por qué no lo hace la cámara)”
  1. Mantiene la cámara agnóstica de input (no lee mouse/teclado/touch).
  2. Centraliza la regla: “los adapters acumulan, el controller consume”.
  3. Decide prioridad de navegación (Dolly > Pan > Orbit) usando un solo PointerDelta.
  4. Es el único lugar que conoce ModelRoot para calcular bounds y ejecutar Frame().
  • DataSO → lee y consume acumulados por frame.
  • EventsSO → se suscribe a señales discretas.
  • OrbitCameraController → ejecuta operaciones de cámara.
  • Transform modelRoot → origen para bounds del contenido.
  • delta = DataSO.ViewerConsumePointerDelta()
  • scrollY = DataSO.ViewerConsumeZoomScrollY()
  • Si hay scroll → camera.Zoom(scrollY)
  • Si DollyHeldcamera.Dolly(delta) si no, si PanHeldcamera.Pan(delta) si no, si OrbitHeldcamera.Orbit(delta)
  • FrameRequested → calcula bounds desde modelRoot y llama camera.Frame(bounds)
  • ResetRequestedcamera.ResetView()

Arranque: iniciar SIEMPRE centrado y en Reset (fix Editor + WebGL)

Section titled “Arranque: iniciar SIEMPRE centrado y en Reset (fix Editor + WebGL)”

Problema real encontrado: al iniciar (sobre todo en WebGL) los renderers/bounds pueden no estar listos inmediatamente. Si se intenta hacer Frame() demasiado pronto, se siente como que el visor “hizo F” al arrancar (porque Frame() ajusta distancia).

Solución final aplicada (determinística y estable):

  1. En Start() se marca un flag de inicialización (_pendingInitCenter = true).

  2. En Update(), mientras ese flag esté activo, se intenta calcular Bounds desde ModelRoot hasta que existan renderers.

  3. Cuando hay bounds válidos:

    • Se fija el centro sin ejecutar Frame:

      • camera.SetLastFrameCenter(bounds.center, alsoMoveTargetPivot: true)
    • Se fuerza la vista inicial:

      • camera.ResetView()
    • Se apaga _pendingInitCenter.

Resultado: el visor arranca centrado al modelo y en Reset, sin depender de “esperar X frames”, y sin que parezca un Frame() involuntario.


M8 — Cámara: OrbitCameraController (Pivot + Camera)

Section titled “M8 — Cámara: OrbitCameraController (Pivot + Camera)”

Este módulo es el corazón del “feeling” del visor. Aquí vive:

  • cómo orbit/pan/dolly/zoom afectan al pivot + cámara hija
  • cómo funciona Frame (F)
  • cómo funciona Reset (R) (y la vista inicial)
  • El pivot rota (yaw/pitch) y se traslada (pan, reset centrado, y frame).

  • La cámara hija se fuerza a:

    • localRotation = identity
    • localPosition = (offsetX, offsetY, -distance)

Esto significa que el “ángulo” real del visor se controla desde el pivot (no desde una rotación manual de la cámara hija).

  • Orbit(delta): yaw += dxsens ; pitch -= dysens (clamp)
  • Pan(delta): mueve el pivot usando right/up de la cámara, escalado por distancia
  • Zoom(scrollY): distancia lineal (rueda / pinch)
  • Dolly(deltaY): distancia exponencial por arrastre (RMB drag)
  • Frame(bounds): centra en bounds.center y calcula distancia por FOV/aspect + _framePadding
  • ResetView(): vuelve a defaults (y si existe último centro, centra ahí)
  • SetLastFrameCenter(center): define un centro válido para Reset sin necesidad de ejecutar Frame

Cambios recientes implementados (lo de este chat)

Section titled “Cambios recientes implementados (lo de este chat)”

Captura robusta de Reset (fix de “pitch raro / invertido”)

Section titled “Captura robusta de Reset (fix de “pitch raro / invertido”)”

Se corrigió la captura de reset “auto” para que no dependa de eulers frágiles ni de LookRotation contra el centro.

  • Se guarda la pose inicial real de la cámara en Awake() (posición y rotación) antes de que el rig fuerce la cámara.
  • La captura de defaults se calcula a partir del forward real de esa rotación y una distancia consistente (dot hacia el centro).

Nota práctica: es normal ver yaw como 330° en lugar de -30°. Es el mismo ángulo, solo normalizado a [0..360).

  • Frame() fija objetivos (_targetPivotPos y _targetDistance).
  • El movimiento ocurre interpolado en LateUpdate() con _smooth.

Calibración principal:

  • _framePadding → más alto = encuadra más lejos (menos zoom).

Defensa de arranque si algo llama Frame demasiado pronto

Section titled “Defensa de arranque si algo llama Frame demasiado pronto”

Se añadió una defensa interna para que, si Frame(bounds) ocurre durante inicialización, no modifique la distancia (evita el “parece que hizo F al inicio”), pero sí actualice el centro y mantenga clip planes defensivos. Tras el primer LateUpdate, Frame() vuelve a operar normal.

En la práctica, el arranque recomendado se resuelve desde M7 con SetLastFrameCenter(...) + ResetView().

ResetView() se usa también como vista inicial (el visor inicia como si presionaras R).

Hay dos modos, controlados por _resetMode:

Reset usa valores del Inspector:

  • _defaultAngles (Yaw/Pitch)
  • _defaultDistance
  • _cameraLocalOffsetXY

En Play Mode, si editas defaults en Inspector, el script los aplica de inmediato (por OnValidate) cuando estás en modo Manual.

2) Auto (desde pose inicial real de la cámara)

Section titled “2) Auto (desde pose inicial real de la cámara)”
  • Captura pose inicial real de cámara en Awake().
  • Calcula _defaultAngles y _defaultDistance desde esa pose.
  • Cuando se llama Frame(bounds) con un bounds.center real, puede recalcular defaults con ese centro (solo en modo Auto).
  • Orbit: _orbitSensitivity, _minPitch, _maxPitch
  • Pan: _panSensitivity
  • Zoom: _zoomSensitivity, _minDistance, _maxDistance
  • Dolly: _dollySensitivity
  • Suavizado: _smooth
  • Snap opcional: _snapToTargetEpsilon (0 = off)
  • Frame: _framePadding
  • Reset: _resetMode, _defaultAngles, _defaultDistance, _cameraLocalOffsetXY
  • Ajustar _defaultAngles/_defaultDistance/_cameraLocalOffsetXY hasta que el “reset por defecto” sea el deseado.

  • Ajustar _framePadding para que el encuadre de F sea el correcto.

  • Verificar consistencia de R tras:

    • usar Frame() varias veces
    • panear
    • orbitar
  • Validar en WebGL (focus + wheel + RMB) que el comportamiento percibido sea el mismo.


M9 — Contenido: ModelRoot (bounds para Frame)

Section titled “M9 — Contenido: ModelRoot (bounds para Frame)”

ModelRoot es un Transform de escena que actúa como “contenedor del contenido”. Su única función técnica en el viewer es permitir que el sistema calcule Bounds de forma determinista.

  • ViewerController recorre renderers bajo ModelRoot.
  • Con esos renderers calcula un Bounds (AABB).
  • Ese mismo Bounds.center se usa también en el arranque para centrar el Reset inicial (ver M7).
  • Ese Bounds se pasa a OrbitCameraController.Frame(bounds) cuando el usuario pide Frame (F).
  • Todo lo que deba entrar en el encuadre debe ser renderer y vivir bajo ModelRoot.
  • Evitar meter helpers con renderers que no quieras encuadrar (gizmos/planes ocultos/etc.).
  • Luces no aportan bounds; no rompen el encuadre, pero si quieres orden puedes sacarlas.

Dónde se calibra “qué tanto se acerca”

Section titled “Dónde se calibra “qué tanto se acerca””

No es aquí: se calibra en M8 con _framePadding.


M10 — UI: Debug Menu (UI Toolkit) — tecla M

Section titled “M10 — UI: Debug Menu (UI Toolkit) — tecla M”

Panel de depuración (no bloqueante) hecho con UI Toolkit.

  • DebugMenu.uxml + DebugMenu.uss
  • UiToolkitDebugMenuController (RequireComponent UIDocument)
  • Tecla M leída con Input System directo: Keyboard.current[toggleKey].wasPressedThisFrame
  • No está en ViewerControls por decisión práctica actual.
  • Abre/cierra con clase USS open sobre menuRoot.
  • scrim cierra al click.
  • closeBtn cierra.
  • Intenta asegurar foco en WebGL: root.focusable = true y root.Focus() al primer click.
  • Switches actuales son placeholders (solo togglean clase on y log).
  • No escribe en DataSO.
  • No emite en EventsSO.
  • No bloquea la cámara explícitamente (si en el futuro molesta, se añade gating).

Evitar que el navegador/iframe rompa input.

  • Focus: el teclado no entra hasta click inicial.
  • RMB: aparece menú del navegador (contextmenu).
  • Wheel: scrollea la página en lugar del visor.

Estas correcciones viven en template/host HTML/JS, no en gameplay.


Contexto (qué es el producto) y Backlog (qué falta) viven en documentos separados. Este documento es solo el cómo está conectado.