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 space has one light, a direction pointing toward it. For each face, it compares two arrows: the face's normal and the direction to the light.

  • 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.

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 where the light is, and the shape hides them while the light is behind. Save it as light.py:

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
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.space3d import Shaded, Space3D, Sphere, Torus, rotation_about
from terminaltexteffects.utils import argutils
from terminaltexteffects.utils.geometry3d import Vec3


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.space = Space3D(self.terminal)
        shape = Sphere(radius=16, segments=32, rings=16) if self.config.shape == "sphere" else Torus(18, 7)
        self.body = self.space.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 mounted on a body of their own that turns with the
        # light, so the space places them in 3D and the shape hides them while the light is behind.
        self.marker = self.space.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")))
            self.marker.add_char(character, self.marker.anchor(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. Space3D.light is the direction toward the light, so it must be a unit vector.
            angle = 2 * math.pi * self.frame_number / self.frames
            self.space.light = Vec3(math.sin(angle), 0.5, math.cos(angle)).normalized()
            # Turning the marker about y by the same angle carries it along the light's circle.
            self.marker.set_rotation(rotation_about("y", math.degrees(angle)))
            self.body.rotate("y", 0.5)
            self.space.step()
            self.frame_number += 1
        elif not self.text_shown:
            self.body.erase()
            self.marker.release_all()
            for character in self.markers:
                self.terminal.set_character_visibility(character, is_visible=False)
            self.space.step()  # Hides the surface characters once nothing is drawn.
            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

  • Space3D(terminal, light="upper left") sets the light at creation: "upper left", "upper right", "front", "above", "left", "right", or any (x, y, z) direction. The default, upper left, gives most shapes a lit side, a shadowed side and a top.
  • space.light is a unit vector you can change each frame, as the example does.
  • 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.

Mounted characters can follow the light too. Each mount reports light, the Lambert value for its point, 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.

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.space.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 light straight from the front ("front") makes every face that faces you bright and flattens the shape. A light from one side shows form best.