Skip to content

Light and Shading

Shading is what makes a filled shape look solid. Without it, a cube is a flat blob of one symbol. With it, each face gets a symbol and color from how directly it faces the light, and the shape's form reads at once.

The idea

The world has one directional light: a single direction pointing toward it, the same everywhere in the scene, like sunlight. It has no position, so nothing is nearer to it or farther from it. For each face, the world compares two arrows: the face's normal and that direction.

  • Facing the light squarely, the face is fully lit.
  • Turned edge-on to the light, it's unlit.
  • In between, brightness falls off smoothly with the angle. The measure is called Lambert shading: the cosine of the angle between the two arrows, from 1 (facing the light) to 0 (edge-on or facing away).

That number picks a symbol from a ramp and a color from a gradient, both ordered from unlit to fully lit. Unlit faces still get the first symbol and color, so the shape's dark side stays visible.

Because only the angle counts, there's no falloff with distance and no cast shadows: a face turned toward the light is lit even if another shape stands between them. Each face is shaded on its own.

A face turns under a fixed light; as the angle between its normal and the light grows, the brightness bar falls and a dimmer symbol is chosen

In TTE

The Light example moves the light in a circle around a slowly turning shape. A yellow ☼ and a dotted ray mark the light's direction, and the shape hides them while the light is behind. The light is just a direction, changed each frame:

angle = 2 * math.pi * self.frame_number / self.frames
self.world.set_light((math.sin(angle), 0.5, math.cos(angle)))
light.py

Save it in your community effects directory as light.py.

"""Light: the light circles a slowly turning shape.

An Effect Guide example for the Light and Shading chapter.
"""

from __future__ import annotations

import math
from dataclasses import dataclass

from terminaltexteffects import Color, ColorPair, Shaded, Vec3, rotation_about
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.shapes import Sphere, Torus
from terminaltexteffects.utils import argutils


def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
    """Return the command name, effect class, and configuration class TTE registers."""
    return "light", Light, LightConfig


@dataclass
class LightConfig(BaseConfig):
    """Configuration for the Light effect."""

    parser_spec = argutils.ParserSpec(
        name="light",
        help="The light circles a slowly turning shape.",
        description="light | The light circles a slowly turning shape.",
        epilog='Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c light --symbols " .:-=+*#%@"',
    )
    shape: str = argutils.ArgSpec(
        name="--shape",
        default="sphere",
        choices=["sphere", "torus"],
        help="The shape to light.",
    )  # pyright: ignore[reportAssignmentType]
    "str : The shape to light."

    symbols: str = argutils.ArgSpec(
        name="--symbols",
        default=".,-~:;=!*#$@",
        help="Shading symbols from unlit to fully lit.",
    )  # pyright: ignore[reportAssignmentType]
    "str : Shading symbols from unlit to fully lit."


class LightIterator(BaseEffectIterator[LightConfig]):
    """Produce the frames of the Light effect."""

    def __init__(self, effect: Light) -> None:
        """Draw the shape with the chosen symbols."""
        super().__init__(effect)
        self.world = self.terminal.world
        shape = Sphere(radius=16, segments=32, rings=16) if self.config.shape == "sphere" else Torus(18, 7)
        self.body = self.world.body(shape).draw(Shaded(symbols=self.config.symbols))
        self.body.rotate("x", 30)
        # A sun and a dotted ray mark where the light is. They are attached to a body of their own that turns with
        # the light, so the world places them in 3D and the shape hides them while the light is behind.
        self.marker = self.world.body()
        start = Vec3(0, 0.5, 1).normalized()  # The light's direction at the start of its circle.
        self.markers = []
        for distance, symbol in ((32, "☼"), (29, "•"), (26, "•"), (23, "•"), (20, "•")):
            character = self.terminal.add_character(symbol, self.terminal.canvas.center)
            character.animation.set_appearance(symbol, ColorPair(fg=Color("#ffd75f")))
            character.attach(self.marker, at=(0, start.y * distance, start.z * distance))
            self.terminal.set_character_visibility(character, is_visible=True)
            self.active_characters.add(character)
            self.markers.append(character)
        self.frames = 360
        self.frame_number = 0
        self.text_shown = False

    def __next__(self) -> str:
        """Move the light one step around the shape, then swap the shape for the text."""
        if self.frame_number < self.frames:
            # The light travels a full circle around the y axis, a little above the shape and in front of it at
            # the start. set_light takes the direction toward the light.
            angle = 2 * math.pi * self.frame_number / self.frames
            self.world.set_light((math.sin(angle), 0.5, math.cos(angle)))
            # Turning the marker about y by the same angle carries it along the light's circle.
            self.marker.rotation = rotation_about("y", math.degrees(angle))
            self.body.rotate("y", 0.5)
            self.frame_number += 1
        elif not self.text_shown:
            self.body.erase()  # The world hides its cells when it next paints.
            for character in self.markers:
                self.terminal.set_character_visibility(character, is_visible=False)
            for character in self.terminal.get_characters():
                self.terminal.set_character_visibility(character, is_visible=True)
            self.text_shown = True
        else:
            raise StopIteration
        self.update()
        return self.frame


class Light(BaseEffect[LightConfig]):
    """The light circles a slowly turning shape."""

    @property
    def _config_cls(self) -> type[LightConfig]:
        return LightConfig

    @property
    def _iterator_cls(self) -> type[LightIterator]:
        return LightIterator
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c light

A shaded sphere whose bright side follows a sun and its dotted ray circling it

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c light --shape torus --symbols "░▒▓█"

A torus shaded with block symbols, its bright side following the sun and its ray

  • world.set_light(direction) points the light, now: "upper left", "upper right", "front", "above", "left", "right", or any (x, y, z) direction toward the light. The default, upper left, gives most shapes a lit side, a shadowed side and a top.
  • set_light can be called on any frame, as the example does. world.light reads the direction back, as a unit vector. The example's sun is only a marker placed along that direction so you can see it. Moving the marker wouldn't move the light, and the light doesn't shine from the marker's position.
  • Shaded(gradient, symbols) fills faces from the ramp. The default symbols .,-~:;=!*#$@ get denser as they get brighter. Block symbols such as ░▒▓█ read as solid and suit large shapes.
  • Solid(symbol, gradient) uses one symbol and lets color alone show the light. It and Textured add a little ambient light, coloring by 0.15 + 0.85 x lambert, so their unlit faces aren't the darkest color.
  • Gradients default to the TTE brand colors, purple to cyan to white.
  • Solid(background=True) paints the lit color as each cell's background instead of drawing a symbol, and Textured(background=...) lights a fill behind its text. See Text over a solid fill.

Characters can follow the light too. character.view.light is the Lambert value for the surface under the character, and a scene created with sync=Scene.SyncMetric.LIGHT picks its frame from it. The logo recipe uses that to brighten text as it turns toward the light. Only a character on a surface has a normal to compare (see What a character sees): one on a pivot, on the screen or free in the world always reports light=1.

World light or camera light?

The light direction can live in either of two coordinate spaces:

Call Meaning of "front" When the camera turns
set_light("front"), the default space="world" Fixed world +z The same world faces remain lit
set_light("front", space="camera") Camera-local +z, back toward the eye The light rotates with the view, like a headlight

The camera circles a fixed cube; a world light arrow stays fixed, while the camera-relative arrow turns with the eye

These recordings use the same stationary cube and camera orbit. Only the light's space changes. The light remains directional in both: camera travel does not cause falloff or turn it into a point light at the eye.

self.world.set_light("front")
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 32 --anchor-text c cameralab --lesson world-light

As the camera orbits, the cube's world-front face stays bright and its world-back face stays dark

self.world.set_light("front", space="camera")
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 32 --anchor-text c cameralab --lesson camera-light

As the camera orbits, the headlight keeps the currently viewed faces lit

world.light stores the direction in the selected space, and world.light_space names it. Surface shading and character.view.light use the same mode. Body normals are in world space after the body's pose, so a manual Lambert calculation needs the light in world space too: world.world_light gives it in either mode.

Under the hood

geometry3d.lambert(normal, light) returns the Lambert value. With a face's normal from the depth buffer, choosing a symbol is one line:

from terminaltexteffects.utils import geometry3d as g3

shade = g3.lambert(fragment.normal, self.world.world_light)
symbol = ramp[min(len(ramp) - 1, int(shade * len(ramp)))]
color = gradient.get_color_at_fraction(shade)

Each face is flat, so all of its cells share one normal and one symbol. That's why a sphere shows bands rather than a smooth fade, and why more segments make the shading smoother.

At cell resolution

  • Pick ramps where each symbol is clearly denser than the last. Ten or twelve steps is plenty, and fewer can look bolder.
  • Color carries most of the shading in a dark terminal. A gradient from a dark color to a bright one works with almost any symbols.
  • A camera-relative "front" light points back along the current view axis, so visible faces come out bright and the shape can look flat. In an orthographic view, facing that light and facing the viewer agree. In perspective, off-axis viewing rays differ a little. A light from one side often shows form better. In world mode, "front" means fixed world +z and is viewer-facing only while the camera has its default orientation.