Camera framing problem in R3F (CameraControls + different object aspect ratios on screens with different sizes)

Hey folks :waving_hand:
I’m building a 3D website in React Three Fiber, Three.js, and @react-three/drei.
The scene is a 3d circular monument with 4 wall-mounted objects spread evenly around the monument: Mirror, ATM, Statue, and Orb. Each has a fixed aspect ratio (e.g., ATM = 2:1, Mirror = 1:1.5).

When a user taps one of them (say, the ATM), the camera zooms in using CameraControls.setLookAt(). The object then displays an HTML iframe (for ATM say 2:1 is aspect ratio of the html) on a mesh as overlay, and this should fill the screen as much as possible without cropping, whether on a wide desktop screen or a tall phone screen.

Right now, when zoomed in:
On some screens the iframe is cropped (portrait or 1:1 screens). On others, there’s too much padding.

I need the camera to dynamically position so that each object’s HTML plane fits the viewport perfectly, respecting its aspect ratio:

Landscape → match height

Portrait → match width

In between → match dynamically

What I’ve tried

  1. Manual fitting:
    Measured the mesh’s screen-space size, compared with window size, and adjusted camera distance.
    Result: oscillations and inconsistent fits across devices.

  2. Bounds from drei:
    Wrapped the mesh in .
    Crashed (Cannot read properties of undefined (reading ‘copy’)) because the mesh mounts a few frames late.

  3. Hybrid:
    Mounted only after mesh ready → fewer crashes but inconsistent results.

Goal:
Find a reliable way to auto-frame each object (with unique aspect ratios) so its HTML content always fills the viewport without cropping, across all screen sizes and orientations, even if the mesh appears slightly delayed.

Context:

Controls: CameraControls (not OrbitControls)

Object example: ATM html mesh (mesh appears after shader load)

Tried: manual math, useFrame, Bounds, conditional mounting

Thank you in advance!

Each of your listed approchaes sound feasible, maybe the logic in your code is not correct, can you provide a minimal live reproduction or a sample of the code your using to analyse?

Thank you for the reply Lawrence. Here is the code, a bit long…

// Castle.jsx (condensed for review)
import { CameraControls, OrbitControls /, Bounds/ } from “@react-three/drei”
import AtmIframe from “./AtmIframe”
// Few more import statements

// -----------------------------
// Navigation helpers (kept minimal)
// -----------------------------
const NavigationSystem = {
positions: {},
navigationSources: {},
init() {
window.navigationSystem = {
storePosition: (id, pos, tgt) => {
NavigationSystem.positions[id] = { position: pos, target: tgt }
window.audioManager?.play(“transition”)
},
setNavigationSource: (id, src) => (NavigationSystem.navigationSources[id] = src),
getNavigationSource: (id) => NavigationSystem.navigationSources[id] || “direct”,
getPosition: (id) => NavigationSystem.positions[id],
clearPositions: () => { NavigationSystem.positions = {}; NavigationSystem.navigationSources = {} },
returnToPosition: (id, fallback) => {
const stored = NavigationSystem.positions[id]
const src = NavigationSystem.navigationSources[id] || “direct”
if (src === “pole” && window.globalNavigation?.navigateTo) {
window.globalNavigation.navigateTo(“nav”); return true
}
if (stored && src === “direct” && window.controls?.current) {
const cc = window.controls.current
const { position, target } = stored
cc.setLookAt(…position, …target, true).catch(()=>{})
return true
}
fallback(); return false
},
}
},
createElementHandlers(id, navigateTo, setActive, isActive) {
const handleClick = (e) => {
/* top-most / facing / occlusion checks omitted /
if (isActive) return
if (window.controls?.current) {
const cc = window.controls.current
const p = cc.getPosition(new THREE.Vector3())
const t = cc.getTarget(new THREE.Vector3())
window.navigationSystem.storePosition(id, [p.x,p.y,p.z], [t.x,t.y,t.z])
window.navigationSystem.setNavigationSource(id, “direct”)
}
navigateTo()
}
return { handleClick, pointerHandlers: { /
hover handlers omitted */ } }
},
}
NavigationSystem.init()

// -----------------------------
// Camera presets (focus: “token”/ATM target)
// -----------------------------
const cameraConfig = {
default: [ -0.61918, 0.95, 100.2743, -0.21831, 1.04208, 0.86046 ],
sections: {
nav: [ -0.14842, 0.95658, 7.2, -0.21831, 1.04208, -0.1 ],
token: [ 1.76200, 1.19830, 0.89747, -0.12, 0.75, -0.04174 ], // ATM
/* about, aidatingcoach, roadmap… omitted */
},
}

// Only “nav” camX is aspect-adjusted; others are used as-is
const getCameraPosition = (section) => {
if (section === “default”) return […cameraConfig.default]
const base = […cameraConfig.sections[section]]
if (section !== “nav”) return base
const aspect = window.innerWidth / window.innerHeight
const MID = -0.48, MIN = -0.8, MAX = 0, RANGE = 2
const lerp = (a,b,t)=>a+(b-a)*t, clamp01=(x)=>Math.max(0,Math.min(1,x))
let camX = MID
if (aspect >= 1) camX = lerp(MID, MAX, clamp01((aspect-1)/(RANGE-1)))
else camX = lerp(MID, MIN, clamp01(((1/aspect)-1)/(RANGE-1)))
base[0] = camX
return base
}

// -----------------------------
// ATM sizing-related helpers (where the issue lives)
// -----------------------------
function smoothCameraReturn(position, target) {
if (!window.controls?.current) return
const cc = window.controls.current
cc.enabled = true
setTimeout(() => cc.setLookAt(…position, …target, true).catch(()=>{}), 50)
}

// -----------------------------
// CastleModel: meshes + ATM iframe mount
// -----------------------------
const CastleModel = ({
onCastleClick,
atmIframeActive,
mirrorIframeActive,
scrollIframeActive,
setAtmiframeActive,
setMirrorIframeActive,
setScrollIframeActive,
activeSection,
}) => {
const { /* nodes, scene, animations… / } = {} / loaded via useAsset in full file */
const atmHandlers = NavigationSystem.createElementHandlers(
“atm”,
() => { if (!atmIframeActive) onCastleClick(“token”) },
setAtmiframeActive,
atmIframeActive
)

/* most meshes/materials omitted */
return (

{/* …monument meshes omitted… /}
<mesh /
the ATM 3D frame / onClick={atmHandlers.handleClick} {…atmHandlers.pointerHandlers} />
<AtmIframe
position={[1.675, 1.185, 0.86]}
rotation={[1.47, 0.194, -1.088]}
isActive={atmIframeActive}
onResolutionMeasured={({ width, height }) => {
// ATM plane’s current screen-pixel size, compared to window size
window.atmResolution = { width, height }
console.log([Castle] ATMIFrame resolution: ${width}×${height})
}}
onReturnToMain={(source) => {
setAtmiframeActive(false)
window.audioManager?.unduckAmbient?.(1000)
setTimeout(() => {
window.audioManager?.play(“transition”)
if (source === “pole”) {
onCastleClick(“nav”)
} else {
const stored = window.navigationSystem.getPosition(“atm”)
stored ? smoothCameraReturn(stored.position, stored.target) : onCastleClick(“nav”)
}
}, 100)
}}
/>
{/
MirrorIframe / ScrollIframe blocks omitted */}

)
}

// -----------------------------
// Castle: controls, transitions, and the zoom → ATM
// -----------------------------
const INITIAL_SMOOTH = 1.0
const INTERACTION_SMOOTH = 0.6

const Castle = ({ activeSection, isStarted=false }) => {
const controls = useRef()
const [atmIframeActive, setAtmiframeActive] = useState(false)
const [mirrorIframeActive, setMirrorIframeActive] = useState(false)
const [scrollIframeActive, setScrollIframeActive] = useState(false)

// expose controls globally (used by NavigationSystem & smoothCameraReturn)
useEffect(() => { window.controls = controls; return () => { delete window.controls } }, )

// Core transition: navigate to section, animate camera, then activate iframe
const playTransition = (sectionName, opts = {}) => {
if (!controls.current) return
const cc = controls.current
cc.smoothTime = opts.initial ? INITIAL_SMOOTH : INTERACTION_SMOOTH

setAtmiframeActive(false); setMirrorIframeActive(false); setScrollIframeActive(false)
if (["about","download","token","roadmap","aidatingcoach"].includes(sectionName)) {
  window.audioManager?.duckAmbient?.(0.10, 1000)
}

const target = getCameraPosition(sectionName)
if (!target) return

cc.enabled = true
cc.setLookAt(...target, true)
  .then(() => {
    // after zoom: turn on the relevant iframe
    switch (sectionName) {
      case "aidatingcoach": setMirrorIframeActive(true); break
      case "token":         setAtmiframeActive(true);    break // ATM
      case "roadmap":       setScrollIframeActive(true); break
      default: break
    }

    // (Where I tried ATM fitting: compute window.atmResolution vs window size
    //  and adjust distance / or wrap in <Bounds>. Both approaches shown issues.)
  })
  .catch(()=>{})
  .finally(() => {
    cc.smoothTime = INTERACTION_SMOOTH
    cc.enabled = sectionName === "nav"
  })

}

// initial intro zoom to “nav”
useEffect(() => {
if (!controls.current || !isStarted) return
setTimeout(() => playTransition(“nav”, { initial: true }), 100)
}, [isStarted])

return (

)
}

export default React.memo(Castle)



The pieces:

CameraControls (drei) — main camera driver. Calls setLookAt(pos, target, smooth=true) to animate.
cameraConfig — preset poses for each section (nav, token/ATM, roadmap, etc.). Each is [camX, camY, camZ, targetX, targetY, targetZ].
getCameraPosition(section) — returns the pose for a section. It only tweaks camX for nav based on aspect ratio; every other section uses the preset unchanged.
NavigationSystem — remembers the user’s current camera position/target before jumping to an object so I can return later.

Startup / initial move:

  1. On first start, Castle waits a tick, then calls
  2. playTransition sets a slower smoothTime for the intro (1.0), gets the nav pose via getCameraPosition(“nav”), and calls
  3. After the move completes, it restores normal smoothTime (0.6) and disables controls unless I’m in nav

What happens when the user taps an object (e.g., ATM):

  1. A click handler from NavigationSystem.createElementHandlers(“atm”, …):
    Stores the current camera position/target (so I can return later).
    Marks the navigation source (“direct” vs “pole”).
    Calls onCastleClick(“token”) → that’s playTransition(“token”).
  2. playTransition(“token”):
    Ducks ambient audio briefly.
    Enables controls and calls:
    When the animation resolves, it activates the ATM iframe:
    Finally, it disables controls again (since I’m not in nav).
  3. While the ATM iframe is active, AtmIframe measures its screen-pixel size and reports it up

Returning from an object:
When the user closes the iframe, I call onReturnToMain:
If they came from the Pole UI, I navigate to “nav”.
If they clicked the object directly, I fetch the stored pose from NavigationSystem and call:
smoothCameraReturn re-enables controls just to animate back, then setLookAt(…, true) returns me to the saved viewpoint.

Other behaviors:
Aspect-aware only for nav: getCameraPosition modifies camX for nav using the window aspect ratio; other sections (ATM, Mirror, Scroll) use fixed presets.
Control limits on mount: I set polar angle, distance, Y limits, and an initial “default” lookAt. These keep the world framed on first load.
Audio hooks: simple duck/unduck around transitions and iframe open/close.