Skip to content

L13 — Orbit camera in 3D

Goal

Control a 3D camera with the stick: orbit yaw/pitch and dolly distance. Connect L08’s museum story to look_at.

In plain English

L08 moved a 2D camera with world - cam.
Here the camera sits on a sphere around the origin:

text
eye.x = dist * cos(pitch) * sin(yaw)
eye.y = dist * sin(pitch)
eye.z = dist * cos(pitch) * cos(yaw)

Then:

c
t3d_viewport_look_at(&viewport, &eye, &target, up);

builds the view matrix (Module 1 “V”).

What you will see

bash
source scripts/env.sh
make -C lessons/l13-orbit-camera

Green ground quad + red marker. HUD shows yaw/pitch/dist.

InputEffect
Stick XOrbit yaw
Stick YOrbit pitch (clamped so you don’t flip under the floor)
C-up / C-downDolly closer / farther

Third-person preview

Starshard Cove’s follow camera is “orbit-ish” but target = player, and distance/angles are smoothed (L23 later). This lesson is the raw spatial skill.


Near / far / FOV reminders

t3d_viewport_set_projection(fov, near, far):

  • near too large → close ground disappears
  • far too small → horizon clips
  • FOV wider → more scene, more distortion

Units should match your world scale (course gameplay: ~1 unit ≈ 1 meter-ish on the scaled island; raw t3dm integer units are larger — see Module 4 scale notes).

Draw pattern (this ROM)

c
t3d_matrix_push(modelMat);
t3d_vert_load(...);   /* captures matrix for T&L */
t3d_matrix_pop(1);
t3d_tri_draw(...);

Plus rdpq_mode_combiner(RDPQ_COMBINER_SHADE) so vertex colors show.


Common noob confusions

FeelingReality
“Pitch inverted”Stick Y sign is a preference; change the += sign if you hate it
“I fell under the world”Pitch clamp — keep a minimum angle
“look_at is a black box”Eye, target, up → view matrix; same as L08 + rotation
“Only clear color”Forgot push/pop for model matrices (camera was overwritten)

Exercises

  1. Orbit until you view the ground edge-on — notice perspective.
  2. Dolly in until near almost clips; then ease out.
  3. (Stretch) Make target (0, 4, 0) so you orbit the red marker.

Full lesson source

The blocks below are imported from the real repository files at build time (VitePress <<< snippets). They are not hand-copied into this markdown.

lessons/l13-orbit-camera/Makefile · lessons/l13-orbit-camera/src/main.c

lessons/l13-orbit-camera/Makefile
make
ROMNAME   := l13_camera
ROM_TITLE := "L13 Camera"
USE_T3D   := 1

include ../../common/lesson.mk
lessons/l13-orbit-camera/src/main.c
c
/**
 * L13 — Orbit camera in 3D
 * ============================================================================
 * Place the camera on a sphere around a target:
 *   eye = (dist * cos(pitch) * sin(yaw), dist * sin(pitch), dist * cos(pitch) * cos(yaw))
 * Stick changes yaw/pitch; C-up/dn changes dist; look_at builds the view matrix.
 * DOCS: docs/guide/m2/l13-orbit-camera.md
 */


#include <libdragon.h>
#include <t3d/t3d.h>

#define DEADZONE 8

static int dz(int v)
{
    return (v > -DEADZONE && v < DEADZONE) ? 0 : v;
}

int main(void)
{
    debug_init_isviewer();
    debug_init_usblog();

    display_init(RESOLUTION_320x240, DEPTH_16_BPP, 3, GAMMA_NONE, FILTERS_RESAMPLE);
    rdpq_init();
    rdpq_text_register_font(1, rdpq_font_load_builtin(FONT_BUILTIN_DEBUG_VAR));
    joypad_init();
    t3d_init((T3DInitParams){});

    T3DMat4FP *modelMatFP = malloc_uncached(sizeof(T3DMat4FP));
    T3DVertPacked *verts = malloc_uncached(sizeof(T3DVertPacked) * 2);
    uint16_t norm = t3d_vert_pack_normal(&(fm_vec3_t){{ 0, 1, 0 }});
    /* Flat ground-ish quad in XZ (Y up). */
    verts[0] = (T3DVertPacked){
        .posA = { -20, 0, -20 }, .rgbaA = 0x60A060FF, .normA = norm,
        .posB = { 20, 0, -20 },  .rgbaB = 0x60A060FF, .normB = norm,
    };
    verts[1] = (T3DVertPacked){
        .posA = { 20, 0, 20 },  .rgbaA = 0x408040FF, .normA = norm,
        .posB = { -20, 0, 20 }, .rgbaB = 0x408040FF, .normB = norm,
    };

    T3DMat4FP *markerMat = malloc_uncached(sizeof(T3DMat4FP));
    T3DVertPacked *marker = malloc_uncached(sizeof(T3DVertPacked) * 2);
    uint16_t n2 = t3d_vert_pack_normal(&(fm_vec3_t){{ 0, 0, 1 }});
    marker[0] = (T3DVertPacked){
        .posA = { -3, -3, 0 }, .rgbaA = 0xFF4040FF, .normA = n2,
        .posB = { 3, -3, 0 },  .rgbaB = 0xFF4040FF, .normB = n2,
    };
    marker[1] = (T3DVertPacked){
        .posA = { 3, 3, 0 },  .rgbaA = 0xFF4040FF, .normA = n2,
        .posB = { -3, 3, 0 }, .rgbaB = 0xFF4040FF, .normB = n2,
    };

    float yaw = 0.6f;
    float pitch = 0.4f;
    float dist = 45.f;
    char line[72];

    T3DViewport viewport = t3d_viewport_create();
    uint8_t ambient[4] = { 50, 50, 60, 0xFF };
    uint8_t dirCol[4] = { 0xEE, 0xEE, 0xCC, 0xFF };
    fm_vec3_t lightDir = {{ 0.4f, 1.0f, 0.3f }};
    fm_vec3_norm(&lightDir, &lightDir);

    for (;;) {
        joypad_poll();
        joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);

        yaw += (float)dz(in.stick_x) * 0.0015f;
        pitch += (float)dz(in.stick_y) * 0.0012f;
        if (pitch < 0.05f) {
            pitch = 0.05f;
        }
        if (pitch > 1.4f) {
            pitch = 1.4f;
        }
        if (in.btn.c_up) {
            dist -= 0.35f;
        }
        if (in.btn.c_down) {
            dist += 0.35f;
        }
        if (dist < 15.f) {
            dist = 15.f;
        }
        if (dist > 90.f) {
            dist = 90.f;
        }

        /* Spherical orbit around origin target. */
        fm_vec3_t target = {{ 0, 0, 0 }};
        fm_vec3_t eye = {{
            dist * fm_cosf(pitch) * fm_sinf(yaw),
            dist * fm_sinf(pitch),
            dist * fm_cosf(pitch) * fm_cosf(yaw),
        }};

        t3d_viewport_set_projection(&viewport, T3D_DEG_TO_RAD(65.0f), 5.0f, 150.0f);
        t3d_viewport_look_at(&viewport, &eye, &target, &(fm_vec3_t){{ 0, 1, 0 }});

        t3d_mat4fp_from_srt_euler(modelMatFP,
            (float[3]){ 1, 1, 1 },
            (float[3]){ 0, 0, 0 },
            (float[3]){ 0, 0, 0 });
        t3d_mat4fp_from_srt_euler(markerMat,
            (float[3]){ 0.4f, 0.4f, 0.4f },
            (float[3]){ 0, yaw, 0 },
            (float[3]){ 0, 4, 0 });

        rdpq_attach(display_get(), display_get_zbuf());
        t3d_frame_start();
        t3d_viewport_attach(&viewport);
        t3d_screen_clear_color(RGBA32(25, 35, 55, 0xFF));
        t3d_screen_clear_depth();

        /* Shade from vertex colors (+ lighting when SHADED). Without this, tris often draw black. */
        rdpq_mode_combiner(RDPQ_COMBINER_SHADE);

        t3d_light_set_ambient(ambient);
        t3d_light_set_directional(0, dirCol, &lightDir);
        t3d_light_set_count(1);
        t3d_state_set_drawflags(T3D_FLAG_SHADED | T3D_FLAG_DEPTH);

        /* Per-object matrix: PUSH so the model composes with the camera matrix
         * that viewport_attach placed on the stack, then POP once the vertices
         * are loaded (verts capture the matrix). Using t3d_matrix_set(x, true)
         * here would overwrite the camera matrix and draw nothing but the clear
         * color. See Tiny3D 00_quad / the L15 fix. */
        t3d_matrix_push(modelMatFP);
        t3d_vert_load(verts, 0, 4);
        t3d_matrix_pop(1);
        t3d_tri_draw(0, 1, 2);
        t3d_tri_draw(2, 3, 0);
        t3d_tri_sync();

        t3d_matrix_push(markerMat);
        t3d_vert_load(marker, 0, 4);
        t3d_matrix_pop(1);
        t3d_tri_draw(0, 1, 2);
        t3d_tri_draw(2, 3, 0);
        t3d_tri_sync();

        rdpq_set_mode_standard();
        rdpq_text_print(NULL, 1, 12, 12, "L13 — Orbit camera");
        snprintf(line, sizeof(line), "yaw=%.2f pitch=%.2f dist=%.0f", yaw, pitch, dist);
        rdpq_text_print(NULL, 1, 12, 28, line);
        rdpq_text_print(NULL, 1, 12, 44, "Stick orbit  C-up/dn dolly");

        rdpq_detach_show();
    }
}

What you learned

  • Spherical orbit camera
  • look_at as view matrix
  • FOV/near/far as practical knobs

Next

L14 — Lighting — ambient + sun you can steer.

N64 Educator v1.2.2 — libdragon + Tiny3D · branch master