Тёмные подземелья, ночные уровни, фонарик в руке персонажа - освещение превращает плоскую 2D-сцену в атмосферную. И реализуется проще, чем кажется.
Освещение - это не только красиво. Это игровая механика, фича:
Т.е. свет и тьма влияют на геймплей, а не только на картинку.
Как сделать так, чтобы часть экрана была тёмной, а часть - освещённой?
Решение в лоб: нарисовать полупрозрачный чёрный прямоугольник поверх всего. Но тогда всё одинаково тёмное - нет смысла.
Ок. Подход лучше: карта освещения (light map). Это отдельная поверхность размером с экран. На ней мы рисуем, насколько освещён каждый участок. Потом накладываем её поверх сцены умножением (multiply blend). Грубо говоря:
# create light map (once)
light_map = pygame.Surface((SCREEN_W, SCREEN_H))
# each frame:
light_map.fill((20, 20, 30)) # ambient: almost dark, slight blue tint
# draw circle of light around player (screen coordinates!)
pygame.draw.circle(light_map, (255, 255, 200),
camera.apply(player.x, player.y), 150)
# draw scene as usual
screen.blit(background, (0, 0))
draw_entities(screen)
# apply light map on top (multiply blend)
screen.blit(light_map, (0, 0), special_flags=pygame.BLEND_RGB_MULT)
// create light map render target (once, in LoadContent)
var lightMap = new RenderTarget2D(GraphicsDevice, screenWidth, screenHeight);
// multiply blend state
var multiplyBlend = new BlendState
{
ColorBlendFunction = BlendFunction.Add,
ColorSourceBlend = Blend.DestinationColor,
ColorDestinationBlend = Blend.Zero,
};
// each frame: render lights to light map
GraphicsDevice.SetRenderTarget(lightMap);
GraphicsDevice.Clear(new Color(20, 20, 30)); // ambient darkness
spriteBatch.Begin();
// lightTexture: white circle r=150 (see "Soft light" section below)
var playerPos = new Vector2(player.X, player.Y); // convert to screen coords if using camera
spriteBatch.Draw(lightTexture, playerPos - new Vector2(150), Color.White);
spriteBatch.End();
// render scene to screen
GraphicsDevice.SetRenderTarget(null);
spriteBatch.Begin();
DrawScene(spriteBatch);
spriteBatch.End();
// apply light map with multiply
spriteBatch.Begin(SpriteSortMode.Deferred, multiplyBlend);
spriteBatch.Draw(lightMap, Vector2.Zero, Color.White);
spriteBatch.End();
ambient - это цвет "без света". (20, 20, 30) даёт почти чёрную ночь с лёгким синим оттенком. (100, 100, 110) - пасмурные сумерки. (255, 255, 255) - обычный день.
Порядок отрисовки: сначала сцена, потом light map поверх. Если рисовать наоборот - ничего не сработает. Multiply blend затемняет то, что уже на экране.
Позиции источников света - экранные координаты, не мировые. Если есть камера с прокруткой, учитывайте. Подробнее - Камера.

Резкий круг выглядит неестественно - свет обрывается неожиданно. Нам нужен радиальный градиент: яркий в центре, плавно затухающий к краям.
Принцип: для каждого пикселя текстуры считаем расстояние до центра, нормализуем в диапазон 0..1, и получаем интенсивность = 1 - dist. Возведение в степень управляет формой спада:
falloff = 1.5 - на середине радиуса (dist/radius = 0.5): 0.5^1.5 = 0.35. Свет быстро гаснет - компактное пятноfalloff = 0.8 - на середине радиуса: 0.5^0.8 = 0.57. Свет держится дольше - размытое заревоintensity = max(0, 1 - dist/radius)
intensity = intensity ^ falloff // falloff > 1 = sharper, < 1 = softer
pixel_color = light_color * intensity
В pygame это делается через Surface + set_at() попиксельно (медленно, но нужно один раз при загрузке). В MonoGame - либо Texture2D.SetData(), либо проще подготовить PNG в графическом редакторе: белый круг с плавным спадом на чёрном фоне, а цвет задать при отрисовке через Color.
И тут важно: на light map мы рисуем с аддитивным блендингом (BLEND_RGB_ADD / BlendState.Additive). Это значит, что свет от нескольких источников складывается - два факела рядом освещают ярче, чем один.
# additive: each blit adds brightness to the light map
light_map.blit(player_light, (px - radius, py - radius),
special_flags=pygame.BLEND_RGB_ADD)
light_map.blit(torch_light, (tx - r, ty - r),
special_flags=pygame.BLEND_RGB_ADD)
// additive blend: light sources stack
spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive);
spriteBatch.Draw(playerLight, playerScreenPos - origin, Color.White);
spriteBatch.Draw(torchLight, torchScreenPos - origin, torchColor);
spriteBatch.End();
Текстуру градиента создаём один раз при загрузке. Попиксельная генерация - операция не из быстрых, каждый кадр её вызывать не стоит.
Карта освещения хороша тем, что на неё можно рисовать сколько угодно источников. Факелы на стенах, костёр, светящиеся грибы - тот же код, другие параметры.
Тот же принцип, что и в системах частиц или ИИ врагов: одинаковый код, разные данные. Хотим мерцающий факел? Меняем радиус каждый кадр на случайную величину:
torch_radius = base_radius + random.randint(-5, 5)
var torchRadius = baseRadius + Random.Shared.Next(-5, 6);
Хотим не круговое мерцание? Добавляем анимацию нашей окружности.
Допустим, в игре есть цикл день/ночь. Днём всё видно, вечером темнеет, ночью - только искусственные источники света. Реализуется через ambient-цвет карты освещения.
Нужна линейная интерполяция (lerp) между опорными точками: день -> вечер -> ночь -> рассвет. Время суток - число от 0.0 до 1.0, опорные цвета - через какие ambient-значения проходит цикл:
# key points: (time_of_day, ambient_color)
ambient_keys = [
(0.0, (15, 15, 35)), # midnight
(0.25, (60, 40, 50)), # dawn
(0.4, (255, 255, 255)),# day
(0.75, (60, 40, 30)), # dusk
(1.0, (15, 15, 35)), # midnight again
]
# lerp between nearest two keys based on current time_of_day
# time_of_day += speed * dt; time_of_day %= 1.0
// same idea: lerp between keyframes
Color[] ambientKeys = { /* midnight, dawn, day, dusk, midnight */ };
float[] keyTimes = { 0f, 0.25f, 0.4f, 0.75f, 1f };
// find two nearest keys, Color.Lerp(a, b, t)
// timeOfDay += speed * deltaTime; timeOfDay %= 1f;
Фишка в том, что искусственные источники рисуются всегда. Днём ambient и так (255, 255, 255) - добавление света ничего не меняет. Но вечером ambient падает, и факелы, фонари, окна домов становятся видны. Ночью они - единственное, что освещает сцену.
Хотим тёплый вечер с фонарями? Ambient (60, 40, 30) + оранжевые источники. Холодная лунная ночь? Ambient (15, 15, 35) + один большой бледно-голубой "лунный свет" на всю сцену.
Хм. До сих пор наш свет проходил сквозь стены - факел в соседней комнате светит через стену. Чтобы это исправить, нужен рейкастинг: бросаем лучи от источника света во все стороны и проверяем, не упёрлись ли они в стену.
Идея та же, что и луч видимости у врагов - только вместо одного луча "глаза -> игрок" мы бросаем лучи веером из источника света.
для каждого угла от 0° до 360°:
бросаем луч из позиции источника
двигаемся по лучу шаг за шагом
если уперлись в стену -> остановились
все пиксели до стены - освещены
все пиксели за стеной - в тени
Результат рейкастинга - полигон видимости (visibility polygon): область, которую "видит" источник. Рисуем этот полигон на light map с градиентом - и свет больше не проходит сквозь стены.
Более интересные темы для исследования: visibility polygon, 2D shadow casting, shadow volumes. Для тайловых карт есть упрощённый вариант - пометить каждый тайл как "непрозрачный" и не рисовать свет за ним.
Всё, что описано выше, работает с цветом. Градиентная текстура хранит не просто яркость, а RGB. Факел - (255, 180, 60), магический кристалл - (100, 150, 255), ядовитое болото - (80, 255, 50). При аддитивном блендинге цвета смешиваются: оранжевый факел рядом с синим кристаллом даст белёсое пятно на стыке.
Цвет - мощный инструмент для подсказок игроку. Красный свет из-за двери? Опасность. Зелёный? Может, магазин. Тёплый жёлтый? Безопасная зона. Грубо говоря, свет общается с игроком без единого слова текста.
Вся система освещения - это одна дополнительная поверхность (light map) и пара строк блендинга. Порядок действий:
| Что | Где подробнее |
|---|---|
| deltaTime для анимаций и дня/ночи | Основной цикл |
| Лучи видимости, рейкастинг | ИИ врагов |
| Тайлы и структура уровня | Уровни и поиск путей |
| Объекты как данные (свет = позиция + радиус + цвет) | ECS, частицы |
| Генерация подземелий, где освещение критично | Процедурная генерация |
| Камера и экранные координаты | Камера |