|
1 | 1 | ## FixedTimeStep |
2 | 2 |
|
3 | | -`FixedTimeStep` decouples the game's **logical update rate** from |
4 | | -the display's frame rate. Instead of updating once per rendered |
5 | | -frame (which varies with hardware and browser load), it |
6 | | -accumulates elapsed time and runs a fixed number of updates at a |
7 | | -constant interval. This ensures deterministic, reproducible |
8 | | -physics and gameplay regardless of rendering performance. |
| 3 | +`FixedTimeStep` decouples the game's **logical update rate** (e.g. physics, simulation) from the display's frame rate. Instead of updating once per rendered frame (which varies with hardware and browser load), it accumulates elapsed time and runs a fixed number of updates at a constant interval. This ensures deterministic, reproducible physics and gameplay regardless of rendering performance. |
9 | 4 |
|
10 | | -The implementation guards against the |
11 | | -["doom spiral"](http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx) |
12 | | -— a situation where each tick takes longer than the previous one — |
13 | | -by capping accumulated time to at most 5 update intervals. |
14 | | - |
15 | | -### How it works |
16 | | - |
17 | | -``` |
18 | | -requestAnimationFrame |
19 | | - │ |
20 | | - ├─ clock.update() ← advance the timer |
21 | | - │ |
22 | | - ├─ accumulatedTime += dt ← add frame delta |
23 | | - │ |
24 | | - └─ while accumulatedTime >= updateInterval |
25 | | - ├─ callback(deltaTime) ← fixed-step game update |
26 | | - └─ accumulatedTime -= updateInterval |
27 | | -``` |
28 | | - |
29 | | -On every browser frame the caller feeds the accumulated time into |
30 | | -`tick()`. The method consumes as many fixed-size intervals as |
31 | | -possible, calls the update callback for each one, and returns how |
32 | | -much time is left over for the next frame. |
| 5 | +The implementation guards against the ["doom spiral"](http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx) — a situation where each tick takes longer than the previous one — by capping accumulated time to at most 5 update intervals. |
33 | 6 |
|
34 | 7 | ### Usage |
35 | 8 |
|
36 | 9 | ```ts |
37 | 10 | import { FixedTimeStep } from "@jolly-pixel/engine"; |
38 | 11 |
|
39 | 12 | const fixedTimeStep = new FixedTimeStep(); |
40 | | -fixedTimeStep.setFps(60); |
| 13 | +fixedTimeStep.setFps(60); // (render FPS, fixed update FPS) |
41 | 14 |
|
42 | | -let accumulatedTime = 0; |
| 15 | +// Start the timer before your main loop |
| 16 | +fixedTimeStep.start(); |
43 | 17 |
|
44 | 18 | function loop() { |
45 | | - const { updates, timeLeft } = fixedTimeStep.tick( |
46 | | - accumulatedTime, |
47 | | - (deltaTime) => { |
48 | | - // Run one fixed-step update |
49 | | - const exited = game.update(deltaTime); |
50 | | - |
51 | | - // Returning true stops processing remaining steps |
52 | | - return exited; |
| 19 | + fixedTimeStep.tick({ |
| 20 | + fixedUpdate: (fixedDelta) => { |
| 21 | + // Run physics or deterministic logic here |
| 22 | + game.fixedUpdate(fixedDelta); |
| 23 | + }, |
| 24 | + update: (interpolation, delta) => { |
| 25 | + // Use interpolation for smooth rendering |
| 26 | + game.render(interpolation, delta); |
53 | 27 | } |
54 | | - ); |
| 28 | + }); |
55 | 29 |
|
56 | | - accumulatedTime = timeLeft; |
57 | | - game.render(); |
58 | 30 | requestAnimationFrame(loop); |
59 | 31 | } |
60 | 32 |
|
61 | 33 | requestAnimationFrame(loop); |
62 | 34 | ``` |
63 | 35 |
|
64 | | -### TimerAdapter |
65 | | - |
66 | | -`FixedTimeStep` relies on a `TimerAdapter` for time measurement. |
67 | | -By default it uses Three.js's `THREE.Timer`, but any object that |
68 | | -satisfies the interface can be injected (useful for testing with |
69 | 36 | deterministic time): |
70 | 37 |
|
| 38 | +### Callbacks |
| 39 | + |
| 40 | +`FixedTimeStep` uses a callback object: |
| 41 | + |
71 | 42 | ```ts |
72 | | -interface TimerAdapter { |
73 | | - /** Advance the internal clock. Called once per tick. */ |
74 | | - update: () => void; |
75 | | - /** Return the elapsed time since the last update (in ms). */ |
76 | | - getDelta: () => number; |
| 43 | +interface FixedTimeStepCallbacks { |
| 44 | + fixedUpdate: (fixedDelta: number) => void; |
| 45 | + update?: (interpolation: number, delta: number) => void; |
77 | 46 | } |
78 | 47 | ``` |
79 | 48 |
|
80 | | -```ts |
81 | | -// Custom clock for tests |
82 | | -const mockClock: TimerAdapter = { |
83 | | - update: () => {}, |
84 | | - getDelta: () => 16.67 |
85 | | -}; |
86 | | - |
87 | | -const fixedTimeStep = new FixedTimeStep(mockClock); |
88 | | -``` |
| 49 | +- `fixedUpdate` is called one or more times per frame, using a fixed timestep (in ms). Use this for physics and deterministic logic. |
| 50 | +- `update` is called once per frame |
89 | 51 |
|
90 | | -### Configuring the frame rate |
| 52 | +#### Interpolation |
91 | 53 |
|
92 | | -The target frame rate defaults to **60 FPS** and can be changed |
93 | | -with `setFps`. Values are clamped between `1` and |
94 | | -`FixedTimeStep.MaxFramesPerSecond` (60): |
| 54 | +The `interpolation` value passed to `update` allows you to render objects smoothly between physics steps. For example, if your physics runs at 60Hz but your display is 144Hz, you can interpolate between the previous and current physics states: |
95 | 55 |
|
96 | 56 | ```ts |
97 | | -fixedTimeStep.setFps(30); |
| 57 | +const renderedPosition = (1 - interpolation) * previousPosition + interpolation * currentPosition; |
98 | 58 | ``` |
99 | 59 |
|
100 | | -The update interval is derived as `1000 / framesPerSecond` |
101 | | -(in milliseconds). |
| 60 | +This makes movement appear smooth, even if the physics steps are less frequent than the rendering frames. |
102 | 61 |
|
103 | | -### Tick result |
| 62 | +### Configuring the frame rate |
104 | 63 |
|
105 | | -`tick()` returns an object describing what happened during the |
106 | | -call: |
| 64 | +The target frame rate defaults to **60 FPS** for both rendering and fixed updates. You can change them with `setFps(renderFps, fixedFps)`. Values are clamped between `1` and `FixedTimeStep.MaxFramesPerSecond` (60): |
107 | 65 |
|
108 | 66 | ```ts |
109 | | -interface TickResult { |
110 | | - /** Number of fixed updates that were executed. */ |
111 | | - updates: number; |
112 | | - /** Remaining accumulated time to carry over to the next frame. */ |
113 | | - timeLeft: number; |
114 | | -} |
| 67 | +fixedTimeStep.setFps(30, 60); // 30 FPS render, 60 FPS fixed update |
115 | 68 | ``` |
116 | 69 |
|
117 | | -- **`updates === 0`** — the frame was too short for a full step; |
118 | | - the leftover time is carried over. |
119 | | -- **`updates > 0`** — the callback ran that many times, each with |
120 | | - a consistent `deltaTime`. |
121 | | -- **Early exit** — if the callback returns `true`, processing |
122 | | - stops immediately and the remaining time is returned. |
| 70 | +The fixed update interval is derived as `1000 / fixedFramesPerSecond` (in milliseconds). |
123 | 71 |
|
124 | 72 | ### Doom spiral protection |
125 | 73 |
|
126 | | -If the browser stalls (e.g. tab switch, debugger breakpoint), the |
127 | | -accumulated time can grow very large. Without a cap, the engine |
128 | | -would try to catch up with dozens of updates in a single frame, |
129 | | -making the problem worse. `FixedTimeStep` limits catch-up to at |
130 | | -most **5 intervals**, discarding excess time. |
| 74 | +If the browser stalls (e.g. tab switch, debugger breakpoint), the accumulated time can grow very large. Without a cap, the engine would try to catch up with dozens of updates in a single frame, making the problem worse. |
| 75 | + |
| 76 | +`FixedTimeStep` limits catch-up to at most **5 intervals**, discarding excess time. |
0 commit comments