Refactor simulation timing and frame processing logic for improved accuracy and performance

This commit is contained in:
Kirill
2026-10-08 00:15:23 +03:00
parent 3fcc7211ce
commit 413f2be904
4 changed files with 29 additions and 22 deletions
+3 -3
View File
@@ -46,9 +46,9 @@ float. Весь порядок E27 сохраняет целые единицы.
### Общие окна рождения и эволюции
На всех скоростях полный цикл симуляции обрабатывает 0,2 реальной секунды: пять циклов на секунду запрошенного времени. До накопления полного интервала расчёт не запускается. Игровая длительность цикла равна `0.2 × 10^(speed - 1)` лет. При отставании размер шага не увеличивается, долг сохраняется. Незавершённая транзакция из сохранения возобновляется с прежней границей. Кооперативный бюджет кадра — 40 мс; частота отрисовки не определяет частоту полных циклов.
На всех скоростях целевая частота запуска — пять полных циклов в секунду. После накопления не менее 0,2 реальной секунды запускается расчёт за весь ожидающий игровой интервал. Время, поступающее во время расчёта, остаётся для следующего цикла. При задержках шаг увеличивается; время не отбрасывается. Смена скорости не сокращает реальный интервал между запусками. Незавершённая транзакция из сохранения возобновляется с прежней границей. Кооперативный бюджет кадра — 40 мс; фактическая частота завершения зависит от стоимости расчёта.
На скоростях 4–6 укрупнение звёздных партий зависит от размера цикла и прежнего окна рождения. Размер цикла — `0.2 × 10^(speed - 1)` игровых лет: 200, 2000 и 20000 лет. Если прежнее окно `max(lifespan / 40, временное разрешение)` шире цикла, оно сохраняется: долгоживущие звёзды не дробятся на более мелкую сетку. Если оно не шире цикла, новые звёзды группы `Star` (включая красные гиганты) объединяются по общей границе завершения жизни и переходят целиком.
На скоростях 4–6 укрупнение звёздных партий зависит от размера цикла и прежнего окна рождения. Номинальная ширина сетки партий — `0.2 × 10^(speed - 1)` игровых лет: 200, 2000 и 20000 лет. Если прежнее окно `max(lifespan / 40, временное разрешение)` шире цикла, оно сохраняется: долгоживущие звёзды не дробятся на более мелкую сетку. Если оно не шире цикла, новые звёзды группы `Star` (включая красные гиганты) объединяются по общей границе завершения жизни и переходят целиком.
При обработке существующих партий объединяются только две или более партии одного типа, весь оставшийся интервал превращения которых попадает в один и тот же цикл. Широкие партии и партии, пересекающие границу циклов, остаются без изменений. Количества и накопленные остатки складываются точно; объединённая партия переходит целиком на границе цикла. Повторное укрупнение не меняет результат.
@@ -117,7 +117,7 @@ GI не хранит накопленные счётчики первичной
Прогресс и деградация округляются вниз с сохранением остатков. Если оба накопленных исхода конкурируют за последнюю планету, распределяются только существующие объекты, а неиспользованный остаток переносится на следующую попытку. Деградация распределяется по весам `fall`; знаковые ошибки распределения также сохраняются.
Для всех шести стадий базовые доли за попытку — `progress=0.00025`, `degradation=0.0025`. Интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 10 млн, 10 млн, 1 млн, 1 млн, 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются. Агрессия, порядок и просвещение изменяют вероятности по формулам из раздела индикаторов ниже.
Базовые доли за попытку: для HP `progress=0.00025`, для EON1–EON5 `progress=0.025`; у всех `degradation=0.0025`. Текущие интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 1 млн, 1 млн, 100 тыс., 100 тыс., 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются. Агрессия, порядок и просвещение изменяют вероятности по формулам из раздела индикаторов ниже.
HP деградирует в RP. У эонов возможен откат к предыдущим стадиям по таблице. RP не развивается сам. C0.0 и C0.1 также развиваются: период 2000 лет, прогресс 0.0175, деградация 0.025. Переходы C0.0 → C0.1 → C0.2, откат C0.0 → EON5 и C0.1 → C0.0. Развитие C0.2 и выше подключено через этические правила. Конфликт civ_bonus устранён повторным импортом: бонусы структур совпадают с Unity. Для существующих этических цивилизаций пользователь выбрал равномерное распределение между шестью этиками с детерминированным целым остатком.
+2 -9
View File
@@ -243,9 +243,7 @@ godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui
Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает
следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся
на главном потоке, состояние публикуется только после полного завершения.
Одна транзакция обрабатывает 0,2 реальной секунды на любой скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может
отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий.
Расчёт запускается после накопления 0,2 реальной секунды и обрабатывает весь ожидающий интервал. Время, поступившее во время расчёта, обрабатывается следующим циклом. При перегрузке шаг растёт; пять завершений в секунду остаются целевой частотой, а не гарантией.
Производство во время транзакции выполняется после её завершения. Запрошенное
время, активный интервал, скорость и очередь производства входят в сохранение;
@@ -292,12 +290,7 @@ Stage вызывает `evolute_frame`: транзакция получает б
1.057. Замер включает сокращение накопленного отставания сохранения;
это результат конкретного сценария на Mac, а не гарантия для Android.
Полный шаг фиксирован: 0,2 реальной секунды на всех скоростях, пять циклов
на секунду запрошенного времени. Неполный интервал накапливается. При отставании
шаг сохраняется, долг не отбрасывается. Возобновляемая транзакция сохраняет
границу из checkpoint. Бюджет кадра остаётся 40 мс. `test_frame_evolution`
проверяет расписание всех шести скоростей, сохранение долга и пять циклов
на одну секунду.
Целевая частота запуска — пять циклов в секунду. Размер шага равен всему накопленному интервалу. Checkpoint сохраняет границу активного расчёта и ожидающее реальное время. Тест проверяет все шесть скоростей, смену скорости, задержку запуска и накопление времени во время расчёта.
Дипломатический drift использует шесть взвешенных вкладов вместо матрицы
пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад.
+12 -4
View File
@@ -50,7 +50,9 @@ func run():
direct.speed = 3
check(E.eq(direct.state, sliced.state), "Queued action and speed change survive publication")
sliced.evolute_frame(0.001, self)
var second_clock: Dictionary = Clock.advance(requested, 0.001, 3)
check(not sliced.frame_busy, "Speed change does not bypass real-time cadence")
sliced.evolute_frame(0.189, self)
var second_clock: Dictionary = Clock.advance(Clock.advance(requested, 0.001, 3), 0.189, 3)
var active: Dictionary = sliced.frame_active_clock.duplicate()
while sliced.frame_busy: await process_frame
direct.evolution.evolute_to_datetime(active.datetime, active)
@@ -111,11 +113,17 @@ func run():
var behind = fixture()
behind.evolute_frame(1.0, self)
while behind.frame_busy: await process_frame
check(E.eq(behind.state.time.datetime, 20000) and E.eq(behind.frame_clock.datetime, 100000), "Debt keeps the fixed step and preserves all requested time")
for cycle in 4:
check(E.eq(behind.state.time.datetime, 100000) and E.eq(behind.state.time, behind.frame_clock), "Delayed start consumes all elapsed time in one cycle")
behind.evolute_frame(0.0, self)
check(behind.frame_commits == 1 and not behind.frame_busy, "Consumed interval cannot be processed twice")
behind.evolute_frame(0.23, self)
behind.evolute_frame(0.53, self)
var pending_clock: Dictionary = behind.frame_clock.duplicate()
while behind.frame_busy: await process_frame
check(E.eq(behind.state.time.datetime, 123000), "Busy transaction keeps its original target")
behind.evolute_frame(0.0, self)
while behind.frame_busy: await process_frame
check(behind.frame_commits == 5 and E.eq(behind.state.time, behind.frame_clock), "One second is processed in exactly five full cycles")
check(E.eq(behind.state.time, pending_clock), "Next cycle consumes all time accumulated during computation")
dispose(behind)
print("Frame evolution: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
+10 -4
View File
@@ -66,6 +66,7 @@ var _requested_speed: int = 0
var _queued_productions: Array = []
var frame_active_clock: Dictionary = {}
var _resume_clock: Dictionary = {}
var frame_elapsed_seconds: float = 0.0
const frame_step_seconds: float = 0.2
## Keep the requested clock advancing while a transaction spans render frames.
@@ -73,12 +74,14 @@ func evolute_frame(delta: float, tree: SceneTree) -> void:
if not is_finite(delta) or delta < 0.0 or not is_finite(delta * pow(10.0, speed - 1)): return
if frame_clock.is_empty(): frame_clock = state.time.duplicate()
frame_clock = SimulationTime.advance(frame_clock, delta, speed)
frame_elapsed_seconds += delta
if frame_busy: return
var next_clock: Dictionary = _resume_clock
if next_clock.is_empty():
next_clock = SimulationTime.advance(state.time, frame_step_seconds, speed)
# Start only after a full 0.2 seconds of simulation time is requested.
if E.gt(next_clock.datetime, frame_clock.datetime): return
# Cadence limits starts; each transaction consumes the whole pending interval.
if frame_elapsed_seconds + 1e-12 < frame_step_seconds: return
next_clock = frame_clock.duplicate()
frame_elapsed_seconds = 0.0
frame_busy = true
frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree)
frame_active_clock = next_clock
@@ -95,11 +98,14 @@ func request_production(id: String, amount: Variant, resource: bool = false) ->
## Persist work requests, never the partially calculated transaction.
func frame_checkpoint() -> Dictionary:
return {"requested_clock": frame_clock, "active_clock": frame_active_clock if frame_busy else {}, "speed": speed, "productions": _queued_productions}
return {"requested_clock": frame_clock, "active_clock": frame_active_clock if frame_busy else {}, "speed": speed, "productions": _queued_productions, "elapsed_seconds": frame_elapsed_seconds}
func restore_frame_checkpoint(checkpoint: Dictionary) -> void:
frame_clock = checkpoint.get("requested_clock", {})
_resume_clock = checkpoint.get("active_clock", {})
var reserved_clock: Dictionary = _resume_clock if not _resume_clock.is_empty() else state.time
var pending_years: Variant = E.maximum(0, E.subtract(frame_clock.get("datetime", reserved_clock.datetime), reserved_clock.datetime))
frame_elapsed_seconds = float(checkpoint.get("elapsed_seconds", E.approx(pending_years) / pow(10.0, int(checkpoint.get("speed", state.speed)) - 1)))
_queued_productions = checkpoint.get("productions", [])
if _resume_clock.is_empty():
speed = checkpoint.get("speed", state.speed)