#include #include #include // ----------------------------------------------------------------------------- // Time. static simloop_time_t time_delta_from_sec(double seconds) { static constexpr double NANOS_PER_SEC = 1e9; return (simloop_time_t)(seconds * NANOS_PER_SEC); } // ----------------------------------------------------------------------------- // Randomness. typedef struct { uint64_t a; } XorShift64State; static uint64_t xorshift64(XorShift64State* state) { uint64_t x = state->a; x ^= x << 7; x ^= x >> 9; return state->a = x; } // ----------------------------------------------------------------------------- // Tests. /// At time/frame 0, no update is triggered (not enough time passed). TEST_CASE(simloop_initial_render) { Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10}); SimloopOut simout; simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); TEST_EQUAL(simout.frame, 0); } /// The simulation is not updated if time does not advance. /// This applies generally to any time > 0. TEST_CASE(simloop_render_not_retriggered) { Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10}); SimloopOut simout; // Advance time by some amount to get past t=0. simloop_update(&simloop, 1, &simout); // Now "advance" by 0. const uint64_t frame_before = simout.frame; simloop_update(&simloop, 0, &simout); const uint64_t frame_after = simout.frame; TEST_TRUE(!simout.should_update); TEST_EQUAL(frame_before, frame_after); } /// A simulation loop with no render frame cap: /// 1. Updates based on the desired update frame rate. /// 2. Does not throttle rendering. TEST_CASE(simloop_no_render_frame_cap) { constexpr int UPDATE_FPS = 10; // 100ms delta const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); const simloop_time_t STEP = time_delta_from_sec(0.05); // 50ms const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); // We need simulation time to be an exact multiple of the desired deltas for // the modulo comparison below. TEST_TRUE((UPDATE_DDT % STEP) == 0); Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. TEST_EQUAL(simout.throttle, 0); // No throttling with no render frame cap. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); const bool expect_update = (t % UPDATE_DDT) == 0; TEST_EQUAL(simout.should_update, expect_update); TEST_EQUAL(simout.throttle, 0); } } /// A simulation loop with a render frame cap: /// 1. Updates based on the desired update frame rate. /// 2. Throttles rendering based on the desired render frame rate. TEST_CASE(simloop_with_render_frame_cap) { constexpr int UPDATE_FPS = 10; // 100ms delta constexpr int RENDER_FPS = 5; // 200ms delta const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); const simloop_time_t RENDER_DDT = time_delta_from_sec(1.0 / (double)RENDER_FPS); const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); // We need simulation time to be an exact multiple of the desired deltas for // the modulo comparisons below. TEST_TRUE((UPDATE_DDT % STEP) == 0); Simloop simloop = simloop_make( &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS}); SimloopOut simout; simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. TEST_EQUAL(simout.throttle, 0); // No throttle since time has not advanced. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); TEST_EQUAL(simout.should_update, (t % UPDATE_DDT) == 0); TEST_NOTEQUAL(simout.throttle, 0); } } /// If the update falls behind the clock, then percent_frame can fall out of /// range (>1) if we are not careful. This tests for this condition. TEST_CASE(simloop_percent_frame_01_large_jump) { constexpr int UPDATE_FPS = 10; // 100ms delta const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); const simloop_time_t STEP = time_delta_from_sec(1); const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); // We need simulation time to be an exact multiple of the desired deltas for // the modulo comparison below. TEST_TRUE((STEP % UPDATE_DDT) == 0); Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); TEST_TRUE(simout.should_update); // Tries to catch up to clock. TEST_TRUE(0. <= simout.percent_frame); TEST_TRUE(simout.percent_frame <= 1.); } } /// One benefit of fixed over variable time deltas is determinism. Test for /// this by getting to t=10 by different clock time increments. /// /// Note that the time increments must be able to keep up with the desired frame /// delta, otherwise determinism is not maintained. We can guarantee determinism /// at the expense of re-introducing divergence. /// TODO: Perhaps the API should return an update count instead of a boolean, /// advance simulation time per the number of updates, then leave it up to /// the client to decide whether to update just once or as many times as /// requested, depending on whether they want determinism or convergence. TEST_CASE(simloop_determinism) { constexpr int UPDATE_FPS = 100; // 10ms delta const simloop_time_t RANDOM_STEPS[] = { time_delta_from_sec(0.007), // 7ms time_delta_from_sec(0.005), // 5ms time_delta_from_sec(0.003), // 3ms }; constexpr uint64_t NUM_RANDOM_STEPS = sizeof(RANDOM_STEPS) / sizeof(RANDOM_STEPS[0]); const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(10); constexpr float ADD = 0.123f; typedef struct Simulation { int iter_count; float sum; } Simulation; #define UPDATE_SIMULATION(SIM) \ { \ SIM.sum += ADD; \ SIM.iter_count++; \ } Simulation sim[2] = {0}; XorShift64State xss = (XorShift64State){12069019817132197873ULL}; // Perform two simulations with random clock-time steps. for (int s = 0; s < 2; ++s) { simloop_time_t dt = 0; Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; for (simloop_time_t t = 0; t <= SIM_DURATION_SEC;) { simloop_update(&simloop, dt, &simout); if (simout.should_update) { UPDATE_SIMULATION(sim[s]); } // Advance time with a random step. const simloop_time_t step = RANDOM_STEPS[xorshift64(&xss) % NUM_RANDOM_STEPS]; t += step; dt = step; } } // Make sure the simulations have advanced by the same number of updates so // that we can compare them. They may not have had the same update count // depending on the clock-time steps. while (sim[0].iter_count < sim[1].iter_count) { UPDATE_SIMULATION(sim[0]); } while (sim[1].iter_count < sim[0].iter_count) { UPDATE_SIMULATION(sim[1]); } TEST_EQUAL(sim[0].iter_count, sim[1].iter_count); // The sums should be exactly equal if determinism holds. // Check also that they are non-zero to make sure the simulation actually // advanced. TEST_TRUE(sim[0].sum > 0.f); TEST_EQUAL(sim[0].sum, sim[1].sum); } /// The simulation loop attempts to catch up with the clock in the event of a /// time spike. /// /// Catch-up is possible only if the simulation loops with a frequency higher /// than the requested update frequency given by the update delta time. /// /// Catch-up is performed only for sufficiently small time spikes. For large /// time spikes, the simulation clock is warped. This test is for the small /// time spike case. static void simloop_catch_up( struct test_case_metadata* metadata, int update_ddt_ms, int loop_step_ms, bool expect_catchup) { const int UPDATE_FPS = 1000 / update_ddt_ms; const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); const simloop_time_t STEP = time_delta_from_sec((double)loop_step_ms / 1000.0); const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); const int EXPECTED_TOTAL_FRAMES_WITH_CATCHUP = (int)(SIM_DURATION_SEC / UPDATE_DDT); Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; int frames = 0; // Simulate a time spike. // Advance time to t=1s. That is a lag of 1,000ms / 100ms = 10 frames. // 10 frames is the maximum allowed catch-up. // The simulation now has 29s to catch up. simloop_time_t dt = time_delta_from_sec(1); for (simloop_time_t t = dt; t <= SIM_DURATION_SEC;) { simloop_update(&simloop, dt, &simout); if (simout.should_update) { frames++; } // New delta is as usual. dt = STEP; t += dt; } if (expect_catchup) { TEST_EQUAL(frames, EXPECTED_TOTAL_FRAMES_WITH_CATCHUP); } else { TEST_TRUE(frames < EXPECTED_TOTAL_FRAMES_WITH_CATCHUP); } } /// (Loop frequency > update frequency) => successful catch-up. TEST_CASE(simloop_catch_up_success) { constexpr int UPDATE_DDT_MS = 100; constexpr int LOOP_DDT_MS = 10; simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, true); } /// (Loop frequency < update frequency) => failed catch-up. TEST_CASE(simloop_catch_up_failure) { constexpr int UPDATE_DDT_MS = 10; constexpr int LOOP_DDT_MS = 100; simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, false); } /// This tests the large time spike case, where the simulation clock is warped /// to the wall clock. TEST_CASE(simloop_warp) { const int UPDATE_FPS = 50; const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; // The maximum allowed catch-up is 10 frames. Simulate a time spike larger // than that. const simloop_time_t TIME_SPIKE = UPDATE_DDT * 20; simloop_update(&simloop, TIME_SPIKE, &simout); TEST_TRUE(simout.should_update); // Warp should still request update. // Now "advance" by 0. simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // No more updates after warp. } int main() { return 0; }