Source Code
src/pages/projects/orbit_camera.rs
use leptos::*;
use leptos_meta::{Meta, Title};
use super::data::ALL_PROJECTS;
#[component]
pub fn OrbitCamera() -> impl IntoView {
let project = ALL_PROJECTS
.iter()
.find(|p| p.slug == "orbit-camera")
.unwrap();
let skills_view = project
.skills
.iter()
.map(|&s| view! { <li>{s}</li> })
.collect::<Vec<_>>();
view! {
<Title text="Orbit Camera – Peter Pinto"/>
<Meta name="description" content="A reusable Bevy plugin for orbital camera control with trait-based input abstraction, quaternion rotation, and distance-aware speed falloff."/>
<div class="page">
<span class="eyebrow">"Projects"</span>
<h1>"Orbit " <em style="font-style:italic; color: var(--accent)">"Camera"</em></h1>
<p class="lead">
"A reusable Bevy plugin for orbital camera control. Input handling is
fully decoupled from camera logic via a trait, making it straightforward
to bind any combination of keyboard, mouse, gamepad, or custom input."
</p>
<ul class="skills-list" style="margin-top: 1.5rem;">
{skills_view}
</ul>
<hr class="divider"/>
// ── Input Abstraction ─────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Design"</span>
<h2>"Trait-Based Input"</h2>
<p>
"The plugin is generic over an "
<code class="inline-code">"OrbitCameraInput"</code>
" trait. Any type that implements this trait can drive the camera —
default mouse controls, a gamepad, keyboard arrows, or a custom input
reader that pulls from a network socket. The camera system itself
never touches "
<code class="inline-code">"Res<ButtonInput<KeyCode>>"</code>
" directly; it only sees normalised orbit/zoom/roll deltas."
</p>
<p style="margin-top: 1rem;">
"This pattern means the same "
<code class="inline-code">"OrbitCameraPlugin<I>"</code>
" can be dropped into any project and configured for the project's
input conventions without forking or patching the plugin."
</p>
</section>
<hr class="divider"/>
// ── Rotation ──────────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Mathematics"</span>
<h2>"Quaternion Rotation"</h2>
<p>
"All rotations use quaternions exclusively — no Euler angles. This
prevents gimbal lock regardless of orbit angle, which matters when
the camera is used to orbit a planet where the user can reach any
viewing angle including directly over the poles."
</p>
<p style="margin-top: 1rem;">
"Two rotation modes are supported: "
<code class="inline-code">"GlobalY"</code>
" (turntable — yaw around the world up axis, pitch around the camera's
local X) and "
<code class="inline-code">"CameraRelative"</code>
" (free-look — both axes relative to the camera). The former is natural
for viewing a model; the latter for flying around a scene."
</p>
</section>
<hr class="divider"/>
// ── Speed Falloff ─────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Feel"</span>
<h2>"Speed Falloff"</h2>
<p>
"Zoom speed decreases as the camera approaches its minimum radius,
providing a natural \"resistance\" feeling rather than abruptly hitting
a hard stop. Orbit speed scales proportionally with the current radius
so that a given mouse movement always sweeps the same arc length on
the focus sphere — the camera feels equally responsive when zoomed in
close or pulled back to a wide view."
</p>
</section>
<hr class="divider"/>
// ── Usage ─────────────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"API"</span>
<h2>"Usage"</h2>
<p>
"Adding the camera to a Bevy app is a few lines:"
</p>
<div class="code-block" style="margin-top: 1.25rem;">
<pre><code>"app.add_plugins(OrbitCameraPlugin::<MouseInput>::default());
commands.spawn((
Camera3dBundle::default(),
OrbitCamera {
focus: Vec3::ZERO,
radius: 10.0,
..default()
},
));"</code></pre>
</div>
<p style="margin-top: 1rem;">
"Swapping to a custom input scheme means changing the type parameter
and providing an implementation of "
<code class="inline-code">"OrbitCameraInput"</code>
" — the camera behaviour is unchanged."
</p>
</section>
</div>
}
}