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.

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: 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
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.lightis 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 andTexturedadd a little ambient light, coloring by0.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.

