L14 — Lighting in Tiny3D
Goal
Drive ambient and directional lights live. See why normals matter (faces point somewhere).
In plain English (L10 → hardware)
| L10 word | Tiny3D call |
|---|---|
| Ambient | t3d_light_set_ambient(rgba) |
| Directional “sun” | t3d_light_set_directional(index, color, dir) |
| How many extras | t3d_light_set_count(n) |
Directional light uses each vertex’s normal: faces toward the sun get brighter. That’s why we packed normals in L11–L13, not only colors.
What you will see
source scripts/env.sh
make -C lessons/l14-lightingTwo white faces (front + top) of a blocky shape, slowly spinning.
| Input | Effect |
|---|---|
| Stick Y | Ambient level |
| Stick X | Orbit the sun direction |
| A | Toggle directional light |
Try this
Turn directional OFF (A). Only ambient left — flat, no “shape from shading.”
Turn it ON and move Stick X — the bright side crawls around the model.
Normals for humans
A normal is an arrow sticking out of the surface: “which way is outward?”
- Floor: normal ≈ (0, 1, 0)
- Wall facing +Z: normal ≈ (0, 0, 1)
If normals are wrong, lighting looks inside-out or dead. Blender “recalculate normals” (Module 3) fixes many export issues.
Color multiply chain (again)
Roughly:
out ≈ vertex_color * (ambient + directional * n·L)White vertices show lighting clearly. Dark vertex paint (L16) multiplies on top — N64 terrain trick.
Common noob confusions
Draw pattern matches L12/L13: matrix_push → vert_load → matrix_pop → tris, and RDPQ_COMBINER_SHADE.
| Feeling | Reality |
|---|---|
| “Light position” | Directional lights have direction, not a point in space (point lights come later) |
| “Ambient 0 is realistic” | Often too harsh for N64 readability; keep a little fill |
| “Normals optional” | Without them, directional lighting can’t work |
| “Mesh is pure black” | Check RDPQ_COMBINER_SHADE and ambient; shade × light can crush color |
| “No mesh at all” | Check push/pop vs matrix_set (camera stack) |
Exercises
- Ambient 0 + dir ON — dramatic. Ambient high + dir OFF — chalky.
- Change
colorDirto pure blue — moonlit vibe. - Predict: top face vs front face, which is brighter for light from above?
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/l14-lighting/Makefile · lessons/l14-lighting/src/main.c
lessons/l14-lighting/Makefile
ROMNAME := l14_light
ROM_TITLE := "L14 Lighting"
USE_T3D := 1
include ../../common/lesson.mklessons/l14-lighting/src/main.c
/**
* L14 — Lighting in Tiny3D
* ============================================================================
* Ambient = fill light everywhere (always on).
* Directional = sun: direction + color; needs vertex normals.
* Stick Y = ambient level; Stick X = orbit sun; A = toggle directional.
* DOCS: docs/guide/m2/l14-lighting.md
*/
#include <libdragon.h>
#include <t3d/t3d.h>
#include <stdio.h>
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));
/* "Box" as 6 quads would be long — use one subdivided-ish fan cube via 8 corners.
* Simpler: spinning sphere-ish is hard without model. Use a cube of 12 tris.
* For teaching size, one double-sided lit quad + a second facing another way.
*/
T3DVertPacked *vFront = malloc_uncached(sizeof(T3DVertPacked) * 2);
T3DVertPacked *vTop = malloc_uncached(sizeof(T3DVertPacked) * 2);
uint16_t nFront = t3d_vert_pack_normal(&(fm_vec3_t){{ 0, 0, 1 }});
uint16_t nTop = t3d_vert_pack_normal(&(fm_vec3_t){{ 0, 1, 0 }});
uint32_t white = 0xE0E0E0FF;
vFront[0] = (T3DVertPacked){
.posA = { -14, -14, 14 }, .rgbaA = white, .normA = nFront,
.posB = { 14, -14, 14 }, .rgbaB = white, .normB = nFront,
};
vFront[1] = (T3DVertPacked){
.posA = { 14, 14, 14 }, .rgbaA = white, .normA = nFront,
.posB = { -14, 14, 14 }, .rgbaB = white, .normB = nFront,
};
vTop[0] = (T3DVertPacked){
.posA = { -14, 14, -14 }, .rgbaA = white, .normA = nTop,
.posB = { 14, 14, -14 }, .rgbaB = white, .normB = nTop,
};
vTop[1] = (T3DVertPacked){
.posA = { 14, 14, 14 }, .rgbaA = white, .normA = nTop,
.posB = { -14, 14, 14 }, .rgbaB = white, .normB = nTop,
};
float modelYaw = 0.f;
float lightYaw = 0.6f;
float ambientLevel = 0.25f;
bool dirOn = true;
char line[80];
T3DViewport viewport = t3d_viewport_create();
const fm_vec3_t camPos = {{ 28, 22, 36 }};
const fm_vec3_t camTarget = {{ 0, 0, 0 }};
for (;;) {
joypad_poll();
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);
modelYaw += 0.012f;
lightYaw += (float)in.stick_x * 0.0012f;
ambientLevel += (float)in.stick_y * 0.0008f;
if (ambientLevel < 0.0f) {
ambientLevel = 0.0f;
}
if (ambientLevel > 0.9f) {
ambientLevel = 0.9f;
}
if (pressed.a) {
dirOn = !dirOn;
}
uint8_t amb = (uint8_t)(ambientLevel * 255.f);
uint8_t colorAmbient[4] = { amb, amb, (uint8_t)(amb + 10 > 255 ? 255 : amb + 10), 0xFF };
uint8_t colorDir[4] = { 0xFF, 0xE8, 0xC0, 0xFF };
fm_vec3_t lightDir = {{
fm_sinf(lightYaw),
0.55f,
fm_cosf(lightYaw),
}};
fm_vec3_norm(&lightDir, &lightDir);
t3d_viewport_set_projection(&viewport, T3D_DEG_TO_RAD(60.0f), 5.0f, 120.0f);
t3d_viewport_look_at(&viewport, &camPos, &camTarget, &(fm_vec3_t){{ 0, 1, 0 }});
t3d_mat4fp_from_srt_euler(modelMatFP,
(float[3]){ 0.45f, 0.45f, 0.45f },
(float[3]){ 0.2f, modelYaw, 0.1f },
(float[3]){ 0, 0, 0 });
rdpq_attach(display_get(), display_get_zbuf());
t3d_frame_start();
t3d_viewport_attach(&viewport);
t3d_screen_clear_color(RGBA32(12, 14, 22, 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(colorAmbient);
if (dirOn) {
t3d_light_set_directional(0, colorDir, &lightDir);
t3d_light_set_count(1);
} else {
t3d_light_set_count(0);
}
t3d_state_set_drawflags(T3D_FLAG_SHADED | T3D_FLAG_DEPTH);
/* PUSH so the model composes with the camera matrix on the stack;
* POP once the verts are loaded (t3d_matrix_set here would overwrite
* the camera → blank mesh). */
t3d_matrix_push(modelMatFP);
t3d_vert_load(vFront, 0, 4);
t3d_matrix_pop(1);
t3d_tri_draw(0, 1, 2);
t3d_tri_draw(2, 3, 0);
t3d_tri_sync();
t3d_matrix_push(modelMatFP);
t3d_vert_load(vTop, 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, "L14 — Lighting");
snprintf(line, sizeof(line), "ambient=%.2f dir=%s (A toggles sun)",
ambientLevel, dirOn ? "ON" : "OFF");
rdpq_text_print(NULL, 1, 12, 28, line);
rdpq_text_print(NULL, 1, 12, 44, "Stick X: light orbit Stick Y: ambient");
rdpq_detach_show();
}
}What you learned
- Ambient vs directional in Tiny3D
- Normals feed directional lighting
- Interactive light debugging with the stick
Next
L15 — Load model — stop hand-authoring every vertex; load art from Blender/glTF.