28.10.2026 18 мин чтения Просмотров:0

Time slicing в Unity: тяжёлая работа по кадрам без фризов

Как разрезать тяжёлые вычисления на куски по несколько миллисекунд за кадр: бюджет времени через Stopwatch, общий планировщик задач, вариант на Awaitable и распределение агентов ИИ по кадрам.

Time slicing в Unity: тяжёлая работа по кадрам без фризов

Генерация чанка, пересчёт карты проходимости, перестройка сетки поиска пути, обновление сотни агентов ИИ — всё это легко укладывается в 40–80 мс, и если сделать это в одном кадре, игрок увидит фриз. В этой статье разберём time slicing: как разрезать тяжёлую работу на маленькие куски и выполнять их по несколько миллисекунд за кадр. Получите готовый планировщик с бюджетом времени, вариант на корутинах и на Awaitable, схему распределения агентов по кадрам и список ловушек, на которых обычно спотыкаются при первой реализации.

Почему средний FPS врёт

При 60 FPS у кадра есть около 16,6 мс, при 30 FPS — 33,3 мс. В эти миллисекунды должно уложиться всё: скрипты, физика, анимация, рендер. Если игра в среднем держит 60 кадров, но раз в секунду один кадр длится 70 мс, счётчик FPS покажет что-то вроде 57, а игрок почувствует отчётливый рывок. Глаз реагирует не на среднее, а на выбросы.

Типичные источники таких выбросов в игровом коде:

  • пересчёт чего-то большого «по событию»: игрок поставил стену — перестраиваем всю сетку навигации;
  • генерация контента: новый чанк мира, процедурный меш, раскладка декора;
  • массовые запросы: сотня агентов одновременно ищет путь или цель, потому что их таймеры стартовали в одном кадре;
  • загрузка и разбор данных: большой JSON, сохранение, таблица баланса;
  • массовый Instantiate при входе в новую зону.

Есть три способа бороться с этим: сделать работу дешевле (оптимизация алгоритма), увести её с главного потока (Job System, фоновые потоки) или растянуть по времени. Time slicing — третий вариант. Он не уменьшает суммарную стоимость работы, зато превращает один кадр на 80 мс в двадцать кадров с добавкой по 4 мс, которых никто не заметит.

Главная идея: бюджет в миллисекундах, а не в итерациях

Самая частая первая попытка выглядит так: «буду обрабатывать по 100 элементов за кадр». Это работает, пока стоимость одного элемента стабильна. На практике она скачет: один тайл карты пересчитывается за микросекунду, другой тянет за собой обход соседей и стоит в сто раз дороже. На слабом телефоне те же 100 элементов займут в четыре раза больше времени, чем на рабочем ПК разработчика.

Надёжнее задавать бюджет временем: «работаю, пока не потрачено 2 мс, потом отдаю управление до следующего кадра». Для замера нужен точный и дешёвый таймер. Time.realtimeSinceStartup для этого подходит хуже: это float, и через несколько часов работы его точности перестаёт хватать на доли миллисекунды. Лучше брать System.Diagnostics.Stopwatch.GetTimestamp() — он возвращает тики высокоточного таймера в виде long и не создаёт объектов.

using System.Diagnostics;

public readonly struct FrameBudget
{
    private readonly long _deadline;

    public FrameBudget(double milliseconds)
    {
        long ticks = (long)(milliseconds * Stopwatch.Frequency / 1000.0);
        _deadline = Stopwatch.GetTimestamp() + ticks;
    }

    public bool IsExceeded => Stopwatch.GetTimestamp() >= _deadline;
}

Структура запоминает момент, после которого время кадра считается исчерпанным. Создали в начале среза, проверяем после каждой единицы работы — вот и вся механика. Обратите внимание: бюджет проверяется после единицы работы, поэтому реальное время среза всегда немного превышает лимит на стоимость последнего элемента. Это нормально, просто учитывайте это, когда выбираете размер единицы работы.

Вариант 1: корутина с бюджетом

Самый быстрый способ получить результат — корутина, которая делает yield return null, когда бюджет кончился. Пусть нужно пересчитать поле стоимостей для большой сетки:

using System.Collections;
using UnityEngine;

public class CostFieldBuilder : MonoBehaviour
{
    [SerializeField] private int _width = 512;
    [SerializeField] private int _height = 512;
    [SerializeField] private float _budgetMs = 2f;

    private float[] _costs;
    private bool _isBuilding;

    public void Rebuild()
    {
        if (_isBuilding) return;
        StartCoroutine(RebuildRoutine());
    }

    private IEnumerator RebuildRoutine()
    {
        _isBuilding = true;
        var result = new float[_width * _height];
        var budget = new FrameBudget(_budgetMs);

        for (int y = 0; y < _height; y++)
        {
            for (int x = 0; x < _width; x++)
            {
                result[y * _width + x] = ComputeCost(x, y);
            }

            if (budget.IsExceeded)
            {
                yield return null;
                budget = new FrameBudget(_budgetMs);
            }
        }

        _costs = result;
        _isBuilding = false;
    }

    private float ComputeCost(int x, int y)
    {
        // Здесь дорогая логика: проверка препятствий, соседей и т. д.
        return Mathf.PerlinNoise(x * 0.05f, y * 0.05f);
    }
}

Несколько деталей, которые здесь важны.

Бюджет проверяется раз в строку, а не на каждой клетке. Вызов Stopwatch.GetTimestamp() дешёвый, но не бесплатный — десятки наносекунд. Если единица работы сама по себе стоит столько же, проверка заметно удорожит цикл. Группируйте: строка сетки, десяток объектов, один чанк.

Результат пишется в новый массив и подменяется одним присваиванием. Пока идёт пересчёт, остальной код продолжает читать старое, целое и согласованное поле. Если писать прямо в _costs, агенты в соседних кадрах увидят наполовину старые, наполовину новые данные. Об этом подробнее ниже.

Повторный запуск блокируется флагом. Без него два быстрых события подряд запустят две параллельные корутины, которые будут драться за процессорное время и за поле _costs.

У этого варианта есть очевидный минус: каждая корутина сама решает, сколько ей можно. Если одновременно идут пять таких задач, каждая с бюджетом 2 мс, кадр получит 10 мс сверху. Бюджет должен быть общим.

Вариант 2: общий планировщик задач

Сделаем один компонент, который владеет бюджетом кадра и раздаёт его задачам по очереди. Задача — это обычный IEnumerator: каждый MoveNext() выполняет одну единицу работы и возвращает true, пока работа не закончена. Так задачи пишутся в привычном стиле корутин, но не управляют временем сами.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public sealed class TimeSlicer : MonoBehaviour
{
    [SerializeField] private float _budgetMs = 3f;

    private readonly List<SlicedTask> _tasks = new();
    private int _cursor;

    public SlicedTask Enqueue(IEnumerator work, int priority = 0)
    {
        var task = new SlicedTask(work, priority);
        _tasks.Add(task);
        _tasks.Sort((a, b) => b.Priority.CompareTo(a.Priority));
        return task;
    }

    private void Update()
    {
        if (_tasks.Count == 0) return;

        var budget = new FrameBudget(_budgetMs);
        while (!budget.IsExceeded && _tasks.Count > 0)
        {
            if (_cursor >= _tasks.Count) _cursor = 0;
            var task = _tasks[_cursor];

            if (task.IsCancelled)
            {
                _tasks.RemoveAt(_cursor);
                continue;
            }

            bool hasMore;
            try
            {
                hasMore = task.Work.MoveNext();
            }
            catch (Exception e)
            {
                Debug.LogException(e);
                hasMore = false;
            }

            if (!hasMore)
            {
                task.Complete();
                _tasks.RemoveAt(_cursor);
                continue;
            }

            _cursor++;
        }
    }
}

public sealed class SlicedTask
{
    public readonly IEnumerator Work;
    public readonly int Priority;
    public bool IsCancelled { get; private set; }
    public bool IsDone { get; private set; }
    public event Action Completed;

    public SlicedTask(IEnumerator work, int priority)
    {
        Work = work;
        Priority = priority;
    }

    public void Cancel() => IsCancelled = true;

    internal void Complete()
    {
        IsDone = true;
        Completed?.Invoke();
    }
}

Планировщик крутит задачи по кругу: одна единица работы первой задачи, одна — второй и так далее, пока не кончится бюджет. Благодаря этому длинная генерация мира не блокирует короткую задачу пересчёта видимости, которая пришла позже. Сортировка по приоритету ставит важные задачи в начало списка, и при маленьком бюджете они получают ход раньше.

Пример задачи для планировщика — раскладка декора по чанку:

private IEnumerator ScatterDecor(Chunk chunk, List<Vector3> output)
{
    var points = chunk.SamplePoints();
    for (int i = 0; i < points.Count; i++)
    {
        if (IsValidSpot(points[i]))
            output.Add(points[i]);

        if (i % 32 == 31)
            yield return null;
    }
}

// Запуск:
var task = _slicer.Enqueue(ScatterDecor(chunk, decorPoints), priority: 1);
task.Completed += () => SpawnDecor(decorPoints);

Здесь yield return null означает не «жди следующего кадра», а «я сделал единицу работы, решай сам». Планировщик может вызвать MoveNext() ещё сотню раз в этом же кадре, если бюджет позволяет. Значение, которое возвращает yield return, планировщик игнорирует, поэтому WaitForSeconds и прочие инструкции Unity здесь не работают — это обычный итератор C#, а не корутина движка.

Почему не просто вызвать MoveNext в корутине

Можно было бы обойтись без отдельного компонента и запускать каждую задачу через StartCoroutine, но тогда пропадает главное: общий бюджет и честное разделение времени. Ещё один плюс своего планировщика — его легко профилировать. Оберните тело Update в ProfilerMarker, и в профайлере будет видна отдельная строка «сколько time slicing стоит этот кадр».

Вариант 3: async-метод на Awaitable

В Unity 6 есть класс Awaitable со статическим методом Awaitable.NextFrameAsync(), который возобновляет выполнение метода в следующем кадре. С ним time slicing пишется в линейном стиле, а результат удобно вернуть из метода:

using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class PathRequestBatch : MonoBehaviour
{
    [SerializeField] private float _budgetMs = 1.5f;

    public async Awaitable<List<Path>> FindPathsAsync(
        IReadOnlyList<PathRequest> requests,
        CancellationToken token)
    {
        var results = new List<Path>(requests.Count);
        var budget = new FrameBudget(_budgetMs);

        for (int i = 0; i < requests.Count; i++)
        {
            results.Add(Pathfinder.Find(requests[i]));

            if (budget.IsExceeded)
            {
                await Awaitable.NextFrameAsync(token);
                budget = new FrameBudget(_budgetMs);
            }
        }

        return results;
    }

    private async void Start()
    {
        try
        {
            var paths = await FindPathsAsync(CollectRequests(), destroyCancellationToken);
            ApplyPaths(paths);
        }
        catch (System.OperationCanceledException)
        {
            // Объект уничтожен до окончания расчёта — это не ошибка.
        }
    }
}

Что здесь важно:

  • Токен отмены обязателен. Корутина умирает вместе с объектом, а async-метод — нет. Если объект уничтожен посреди расчёта, без токена метод продолжит работу в следующем кадре и обратится к уничтоженным компонентам. destroyCancellationToken у MonoBehaviour отменяется при уничтожении объекта, а NextFrameAsync в ответ бросает OperationCanceledException.
  • Awaitable нельзя ожидать дважды. В отличие от Task, после завершения Unity возвращает объект во внутренний пул. Сохранять его в поле и делать await из двух мест нельзя.
  • Бюджет здесь локальный. Как и у корутины из первого варианта, каждый async-метод тратит свой лимит. Если таких методов много, нужно либо общее хранилище бюджета на кадр, либо планировщик из второго варианта.

Общий бюджет для async-кода можно сделать статическим счётчиком, который сбрасывается раз в кадр. Метод сначала спрашивает «осталось ли время в этом кадре», и если нет — ждёт следующий:

public static class SharedFrameBudget
{
    private static int _frame = -1;
    private static long _deadline;

    public static float BudgetMs = 3f;

    public static bool HasTime
    {
        get
        {
            if (_frame != Time.frameCount)
            {
                _frame = Time.frameCount;
                _deadline = Stopwatch.GetTimestamp()
                    + (long)(BudgetMs * Stopwatch.Frequency / 1000.0);
            }
            return Stopwatch.GetTimestamp() < _deadline;
        }
    }
}

// Внутри async-метода:
while (!SharedFrameBudget.HasTime)
    await Awaitable.NextFrameAsync(token);

У такого подхода есть тонкость: отсчёт бюджета начинается в момент первого обращения в кадре, а не в начале кадра. Для большинства задач этого достаточно. Если нужна точность, сбрасывайте дедлайн из раннего этапа PlayerLoop или из скрипта с минимальным [DefaultExecutionOrder].

Распределение регулярной работы: round-robin и stagger

Не вся тяжёлая работа разовая. Восприятие ИИ, поиск ближайшей цели, проверка видимости, обновление LOD-логики — всё это нужно делать постоянно, но не обязательно каждый кадр. Если 300 агентов раз в 0,2 секунды проверяют окружение, а их таймеры запустились одновременно при загрузке сцены, нагрузка приходит пиками: четыре кадра пусто, пятый — 300 проверок.

Round-robin: N объектов за кадр

Простейшее решение — один менеджер, который в каждом кадре обновляет следующую порцию из списка:

public sealed class PerceptionScheduler : MonoBehaviour
{
    [SerializeField] private int _perFrame = 40;

    private readonly List<IPerceiver> _agents = new();
    private int _next;

    public void Register(IPerceiver agent) => _agents.Add(agent);

    public void Unregister(IPerceiver agent)
    {
        int index = _agents.IndexOf(agent);
        if (index < 0) return;

        int last = _agents.Count - 1;
        _agents[index] = _agents[last];
        _agents.RemoveAt(last);

        if (_next > last) _next = 0;
    }

    private void Update()
    {
        int count = Mathf.Min(_perFrame, _agents.Count);
        float now = Time.time;

        for (int i = 0; i < count; i++)
        {
            if (_next >= _agents.Count) _next = 0;
            _agents[_next].Sense(now);
            _next++;
        }
    }
}

При 300 агентах и 40 за кадр каждый агент обновляется примерно раз в 8 кадров, а нагрузка в каждом кадре одинаковая. Удаление сделано через перестановку с последним элементом, чтобы не сдвигать массив. Побочный эффект: порядок обхода меняется, и один агент может пропустить ход или получить лишний. Для восприятия ИИ это неважно, но если порядок важен, используйте пометку «удалён» и чистку списка вне цикла.

Агент при этом не должен считать, что между вызовами прошёл один кадр. Передавайте ему время и пусть он сам вычисляет, сколько прошло с прошлой проверки:

public void Sense(float now)
{
    float dt = now - _lastSenseTime;
    _lastSenseTime = now;

    // Память о цели угасает с реальной скоростью,
    // независимо от того, как часто нас обновляют.
    _targetMemory = Mathf.Max(0f, _targetMemory - dt);
    ScanForTargets();
}

Stagger: размазать таймеры при старте

Если у каждого агента свой таймер и переделывать архитектуру на центральный менеджер не хочется, достаточно разнести моменты срабатывания. Начальная фаза таймера — случайная в пределах интервала:

private float _interval = 0.2f;
private float _timer;

private void OnEnable()
{
    _timer = Random.Range(0f, _interval);
}

private void Update()
{
    _timer -= Time.deltaTime;
    if (_timer > 0f) return;

    _timer += _interval;
    Sense(Time.time);
}

Одна строчка в OnEnable превращает пик из 300 проверок в равномерный поток примерно по 25 проверок за кадр при 60 FPS. Обратите внимание на _timer += _interval вместо присваивания: так таймер не накапливает ошибку и не дрейфует. Вместо случайной фазы можно использовать детерминированную, например (GetInstanceID() & 0xFF) / 256f * _interval, если важна воспроизводимость.

Адаптивная частота по важности

Следующий шаг — обновлять чаще то, что важнее. Враг в трёх метрах от игрока проверяет окружение каждый кадр, враг за сотню метров — раз в секунду. В round-robin это реализуется несколькими списками с разной квотой на кадр или интервалом, который агент пересчитывает сам в зависимости от дистанции до камеры. Суть та же: работа распределяется по кадрам, а её объём определяется тем, насколько результат заметен игроку.

Согласованность данных: главная ловушка

Растягивая работу на 20 кадров, вы создаёте окно, в котором мир живёт с недоделанными данными. Ошибки здесь неприятные: они плавающие и редко воспроизводятся.

Двойная буферизация

Правило: потребители читают только готовый результат. Расчёт пишет в отдельный буфер, а по завершении буферы меняются местами. Мы уже делали так в первом примере с result и _costs. Чтобы не выделять новый массив на каждый пересчёт, держите два и меняйте ссылки:

private float[] _front;   // читают все
private float[] _back;    // пишет пересчёт

private void Swap()
{
    (_front, _back) = (_back, _front);
}

Изменения во время расчёта

Пока поле стоимостей пересчитывается по строкам, игрок может поставить стену в строке, которая уже обработана. Новый результат выйдет устаревшим ещё до публикации. Варианты решения:

  • Номер версии. Каждое изменение мира увеличивает счётчик. Расчёт запоминает версию на старте и после завершения сравнивает: если она изменилась, результат публикуется (он всё равно свежее старого), но сразу ставится в очередь новый пересчёт.
  • Отмена и перезапуск. Для дешёвых расчётов проще отменить текущий и начать заново. Опасно, если изменения идут чаще, чем успевает завершиться расчёт: результат не появится никогда. Ограничивайте перезапуски или публикуйте промежуточный результат.
  • Грязные регионы. Вместо пересчёта всего поля помечайте изменённые чанки и пересчитывайте только их. Это одновременно и оптимизация, и защита: кусок работы становится маленьким, а окно рассогласования коротким.

Ссылки на объекты, которые успели исчезнуть

Задача, которая запущена с ссылкой на Transform врага, через десять кадров может обнаружить, что враг уничтожен. В Unity уничтоженный объект не становится null по ссылке C#, но сравнение == null вернёт true благодаря перегруженному оператору. Проверяйте объекты через == (а не is null и не ?.) на каждом шаге, где к ним обращаетесь, либо собирайте нужные данные (позиции, ID) в начале задачи и работайте с копией.

Подбор размера единицы работы и бюджета

Две настройки определяют, насколько гладко работает time slicing.

Единица работы должна быть заметно меньше бюджета. Если одна итерация стоит 5 мс, а бюджет 2 мс, срез всегда будет 5 мс: проверка сработает только после итерации. Ориентир — единица работы в 10–50 раз дешевле бюджета. Если алгоритм не дробится (например, рекурсивный обход), перепишите его на явный стек: итеративная версия с собственной структурой Stack<T> легко прерывается на любом шаге, рекурсивная — нет.

Бюджет зависит от платформы и от того, сколько запаса остаётся в кадре. Хорошая практика — не фиксировать его, а считать от целевой частоты кадров и текущей нагрузки:

public float CalculateBudgetMs()
{
    int targetFps = Application.targetFrameRate > 0 ? Application.targetFrameRate : 60;
    float frameMs = 1000f / targetFps;
    float lastFrameMs = Time.unscaledDeltaTime * 1000f;

    float spare = frameMs - lastFrameMs;
    return Mathf.Clamp(spare * 0.5f, 0.5f, 4f);
}

Логика простая: если прошлый кадр уложился с запасом, отдаём половину запаса фоновой работе; если кадр уже тяжёлый, оставляем минимальные 0,5 мс, чтобы задачи всё же двигались. Верхний предел не даёт планировщику съесть кадр целиком на мощной машине, где запас огромный. Не забудьте, что при включённом vSync deltaTime выравнивается по частоте обновления экрана, и «запас» по нему не виден. Для точной картины используйте FrameTimingManager или собственные замеры CPU-времени кадра.

Где time slicing не нужен или вреден

  • Работа, которая хорошо параллелится. Если задача — чистые вычисления над массивами без Unity API, Job System с Burst выполнит её на рабочих потоках, и главный поток не потратит на неё вообще ничего. Time slicing и джобы хорошо сочетаются: планировщик раз в кадр запускает джоб на следующий кусок данных и забирает результат предыдущего.
  • Операции, которые нельзя разрезать. Один вызов Instantiate тяжёлого префаба, загрузка сцены, компиляция шейдера — их не разделить на итерации. Здесь помогают пулы объектов, асинхронная загрузка через SceneManager.LoadSceneAsync и Addressables, прогрев шейдеров заранее. Единственное, что может сделать планировщик, — не запускать несколько таких операций в одном кадре.
  • Результат нужен немедленно. Если от расчёта зависит ответ на ввод игрока в этом же кадре, растягивание даст задержку управления. Лучше заранее подготовить данные или упростить расчёт.
  • Слишком мелкая работа. Если весь пересчёт занимает 0,3 мс, обвязка планировщика, итераторы и аллокации стоят больше пользы. Измеряйте до того, как резать.

Аллокации и стоимость самой обвязки

Каждый вызов метода-итератора создаёт объект в куче: компилятор превращает метод с yield в класс. Для задачи, которая запускается раз в несколько секунд, это не важно. Для механизма, который создаёт сотни задач в секунду, это постоянный источник мусора для GC. В таком случае замените итераторы на структуры или классы с явным состоянием и методом bool Step() и переиспользуйте их через пул:

public interface ISlicedWork
{
    // Возвращает true, пока работа не закончена.
    bool Step();
}

public sealed class RebuildRegionWork : ISlicedWork
{
    private NavGrid _grid;
    private RectInt _region;
    private int _y;

    public void Setup(NavGrid grid, RectInt region)
    {
        _grid = grid;
        _region = region;
        _y = region.yMin;
    }

    public bool Step()
    {
        for (int x = _region.xMin; x < _region.xMax; x++)
            _grid.RecalculateCell(x, _y);

        _y++;
        return _y < _region.yMax;
    }
}

Планировщик из второго варианта переделывается под ISlicedWork заменой MoveNext() на Step(). Состояние работы явное, его видно в отладчике, его можно сериализовать и, например, продолжить пересчёт после загрузки сохранения.

Аналогично с async: Awaitable в Unity пулится и почти не создаёт мусора на сам await, но захват локальных переменных в состояние async-метода по-прежнему требует аллокации при первом приостановлении. Для редких задач это неважно, для горячего пути — повод выбрать явную структуру состояния.

Как проверить, что стало лучше

Смотреть нужно не на средний FPS, а на худшие кадры. Удобный способ — оборачивать срез в ProfilerMarker и одновременно собирать максимум времени кадра за последние несколько секунд:

using Unity.Profiling;

private static readonly ProfilerMarker SliceMarker = new("TimeSlicer.Update");

private void Update()
{
    using (SliceMarker.Auto())
    {
        RunSlice();
    }
}

В окне Profiler в режиме Hierarchy строка TimeSlicer.Update покажет, сколько в каждом кадре съедает фоновая работа. На графике CPU после внедрения time slicing должны исчезнуть одиночные пики, а вместо них появиться ровное плато чуть выше прежней базовой линии. Если пики остались, ищите в их стеке вызовов то, что не попало в планировщик, или единицу работы, которая оказалась крупнее бюджета.

Проверяйте на целевом железе. Срез, который на ПК занимает 1 мс, на бюджетном Android легко превращается в 5 мс. Если бюджет задан временем, это не сломает частоту кадров, но работа будет завершаться в несколько раз дольше. Иногда это приемлемо, иногда нужно уменьшить объём (пересчитывать только видимую область, снизить разрешение сетки).

Чек-лист

  1. Найдите в профайлере кадры-выбросы и определите, какая работа их вызывает.
  2. Решите, можно ли её удешевить или вынести в Job System. Time slicing — для остального.
  3. Разбейте работу на единицы в 10–50 раз дешевле бюджета; рекурсию перепишите на явный стек.
  4. Задавайте бюджет в миллисекундах через Stopwatch.GetTimestamp(), а не количеством итераций.
  5. Используйте один общий бюджет на кадр для всех задач, а не по бюджету на каждую.
  6. Пишите результат в отдельный буфер и публикуйте целиком; отслеживайте версию данных.
  7. Для async-вариантов всегда передавайте токен отмены, например destroyCancellationToken.
  8. Регулярную работу (ИИ, восприятие) распределяйте через round-robin или случайную фазу таймеров.
  9. Проверяйте худшие кадры и на целевом железе, а не средний FPS на рабочем ПК.

Time slicing — один из самых дешёвых способов убрать фризы: он не требует переписывать алгоритм, менять архитектуру или переносить код в потоки. Планировщик на сотню строк, бюджет в пару миллисекунд и дисциплина с буферами — и тяжёлая логика перестаёт быть заметна игроку.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также