Skip to content

L10 — Color, lighting & vertex-color blending

Goal

Learn a tiny lighting vocabulary (ambient, base color / albedo) and the classic N64 trick: multiply a texture (or base color) by vertex colors so big surfaces get cheap variation without huge textures.

Why this lesson exists

By Starshard Cove you’ll want:

  • Grass that isn’t one flat green stamp
  • Paths that look worn
  • Soft shadow-ish areas

You could paint giant unique textures. On N64, TMEM is only about 4 KiB (L05). So artists combine:

  1. A small tiling texture (or even a flat base color)
  2. Vertex colors painted on the mesh
  3. Hardware multiply (“modulate”)

L10 shows the idea with two triangles side by side — no Blender yet.

What you will see (ROM)

bash
source scripts/env.sh
make -C lessons/l10-color-light
SideStory
LeftOne green “material,” only overall brightness changes
RightSame green idea, but each corner has a tint — looks blotchy/painted on purpose
InputEffect
Stick YAmbient brightness (brighter / darker overall)

Push stick up/down: both sides get lighter/darker, but the right side keeps its spatial pattern. That’s “lighting × paint.”


Terms in plain English

TermPlain EnglishKitchen metaphor
Albedo / base colorThe surface’s own color if light is neutralThe color of the frosting
AmbientFill light so caves aren’t pure blackRoom light so you see something everywhere
Directional lightSun/moon from a direction; needs normalsA window casting light from one side
Vertex colorColor stored at corners, blended across facesFood coloring drops that blend into frosting
Modulate / multiplyresult = base * vertex * light…Tinting frosting by mixing (dark × color = darker color)

Multiply intuition

In 0–1 math, white vertex color (1,1,1) leaves the base unchanged. Gray (0.5,0.5,0.5) darkens. Black zeros it out. That’s why vertex paint is great for dirt and fake AO.


Left triangle vs right triangle

Left — “flat × ambient”

Every corner uses the same base green × ambient. Looks like a sticky note under room light. Fine for UI panels; boring for terrain.

Right — “base × vertex tint × ambient”

Corners use different tints (bright / muddy / medium). Same “grass” identity, spatial variation for free. This is the N64 terrain mindset.

We fake it by baking the multiply into the vertex RGBA we send to the RDP. In Tiny3D + Fast64, the color combiner does this for real with textures.


Lighting equation (friendly form)

You may see:

text
lit = ambient + light_color * max(0, dot(normal, light_dir))
out = albedo * lit * vertex_color
PieceNeed now?
ambientYes — stick Y in the ROM
albedoYes — our green base
vertex_colorYes — right triangle
directional + normalsModule 2

If dot(normal, light_dir) makes your eyes glaze over: it only means “how much does this face point toward the sun?” Faces toward the sun get brighter. You’ll see it when Tiny3D lights a spinning mesh.


Why N64 loves vertex color (again, with budget)

text
Small tiling grass texture
        ×
Vertex paint (path brown, shade blue, dry yellow)
        =
Big readable landscape without huge unique textures

Module 3: paint Col in Blender + Fast64.
Module 2: draw meshes where that data survives the pipeline.

Write this in your notes if nothing else:

Starshard Cove art rule of thumb

Terrain: tiling texture × vertex colors.
Starshards: bright / special material so pickups pop.
Player: readable colors from behind the camera.


What this ROM is not

  • Not real textured sampling yet
  • Not real directional lights / normals yet
  • Not the final combiner setup

It’s a concept sculpture so Module 2–3 don’t introduce five ideas at once.


Common noob confusions

FeelingReality
“Vertex color replaces textures”Often multiplies with them
“Ambient is a hack”Games still use ambient or GI approximations; ambient is the simple cousin
“Left and right should match”They match only if vertex tints are all 1
“I’ll paint this in Photoshop on a 1024 texture”You can on PC; on N64 you’ll hurt TMEM — prefer vertex paint for large-scale tints

Exercises

  1. Stick Y all the way both directions — describe ambient in your own words.
  2. In source, set all right-side tints to 1.0 — sides should look much more alike.
  3. Change base RGB to sand; adjust tints so a “path” still reads.
  4. Answer without notes: why not a unique 256×256 texture for every path?

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/l10-color-light/Makefile · lessons/l10-color-light/src/main.c

lessons/l10-color-light/Makefile
make
ROMNAME   := l10_color
ROM_TITLE := "L10 Color"
USE_NG_MATH := 1

include ../../common/lesson.mk
lessons/l10-color-light/src/main.c
c
/**
 * L10 — Color, lighting concepts, vertex-color blending
 * ============================================================================
 * Left triangle: flat base * ambient.
 * Right triangle: base * per-corner tints * ambient  ← N64 terrain idea.
 * Stick Y changes ambient. Same "material", spatial paint free of huge textures.
 * DOCS: docs/guide/m1/l10-color-light.md
 */


#include <libdragon.h>
#include <stdio.h>
#include "ng_math.h"

#define DEADZONE 8

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

int main(void)
{
    display_init(RESOLUTION_320x240, DEPTH_16_BPP, 2, GAMMA_NONE,
                 FILTERS_RESAMPLE);
    rdpq_init();
    rdpq_text_register_font(1, rdpq_font_load_builtin(FONT_BUILTIN_DEBUG_VAR));
    joypad_init();

    char line[72];

    while (1) {
        joypad_poll();
        joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
        /* Map stick Y to ambient 0.25..1.0 */
        float ambient = 0.65f + (float)dz(in.stick_y) / 160.f;
        if (ambient < 0.25f) {
            ambient = 0.25f;
        }
        if (ambient > 1.f) {
            ambient = 1.f;
        }

        /* Base "albedo" green (like a grass texture sample) */
        float br = 0.25f, bg = 0.75f, bb = 0.30f;

        /* Left: flat shade of albedo * ambient (no spatial vertex variation) */
        float L0[] = { 40, 70, br * ambient, bg * ambient, bb * ambient, 1 };
        float L1[] = { 140, 70, br * ambient, bg * ambient, bb * ambient, 1 };
        float L2[] = { 90, 170, br * ambient, bg * ambient, bb * ambient, 1 };

        /* Right: vertex colors multiply albedo (path / dirt / bright spots) */
        float tint0 = 1.0f, tint1 = 0.45f, tint2 = 0.75f; /* per-vertex grayscale tints */
        float R0[] = { 180, 70, br * ambient * tint0, bg * ambient * tint0, bb * ambient * tint0, 1 };
        float R1[] = { 280, 70, br * ambient * tint1, bg * ambient * tint1, bb * ambient * tint1, 1 };
        float R2[] = { 230, 170, br * ambient * tint2, bg * ambient * tint2, bb * ambient * tint2, 1 };

        surface_t *disp = display_get();
        rdpq_attach(disp, NULL);
        rdpq_clear((color_t){ .r = 16, .g = 16, .b = 22, .a = 255 });

        rdpq_set_mode_standard();
        rdpq_mode_combiner(RDPQ_COMBINER_SHADE);
        rdpq_triangle(&TRIFMT_SHADE, L0, L1, L2);
        rdpq_triangle(&TRIFMT_SHADE, R0, R1, R2);

        rdpq_text_print(NULL, 1, 12, 12, "L10 — Lighting & vertex color blend");
        rdpq_text_print(NULL, 1, 40, 50, "Flat * ambient");
        rdpq_text_print(NULL, 1, 175, 50, "Albedo * vertex * amb");
        snprintf(line, sizeof(line), "ambient=%.2f  (stick Y)", ambient);
        rdpq_text_print(NULL, 1, 12, 190, line);
        rdpq_text_print(NULL, 1, 12, 210, "Right: one material, color varies by vertex (N64 classic)");
        rdpq_text_print(NULL, 1, 12, 224, "Next: Tiny3D real meshes, lights, textures");

        rdpq_detach_show();
    }
}

What you learned

  • Ambient vs base color
  • Vertex color as spatial tint
  • Multiply/modulate as the N64 workhorse
  • Bridge to Tiny3D lights/textures and Blender paint

Module 1 checkpoint (self-test)

Try without scrolling up:

  1. Point vs vector — one sentence each.
  2. In our M = T × R × S, what happens to the point first, scale or translate?
  3. If the camera moves left, which way do world props appear to slide (and why)?
  4. Name three things a vertex might store.
  5. Why multiply textures (or albedo) by vertex color on N64?

Comfortable answers (peek after trying)

  1. Point = location; vector = displacement/direction+length.
  2. Scale first (right-to-left on the point).
  3. Appear to slide right — view uses world − camera (museum story).
  4. e.g. position, color, normal, UV.
  5. Cheap large-scale variation under tiny TMEM / texture budgets.

If you mostly got those, you’re ready for Module 2 — Tiny3D. If not, re-run the ROMs for the weak spot — thumbs beat rereading alone.

Next

Module 2 — Tiny3D first light: same vocabulary on the real 3D pipeline (viewport, depth, lights, loaded meshes, vertex-color terrain).

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