Skip to content

Commit 8173a03

Browse files
committed
reduce gamepad poll cost
1 parent c80132c commit 8173a03

6 files changed

Lines changed: 145 additions & 48 deletions

File tree

src/demo/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const v = new V.Void({
1414
description,
1515
loader: new Loader()
1616
})
17-
v.setPoller(((V.debug?.seconds ? 1 : 60) * 1000) as V.Millis, () =>
17+
v.setInterval(((V.debug?.seconds ? 1 : 60) * 1000) as V.Millis, () =>
1818
V.millisUntilNext(new Date(), V.debug?.seconds ? 'Sec' : 'Min')
1919
)
2020
await v.register('add')

src/demo/level/loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class Loader implements V.Loader {
5959
looper: v.looper,
6060
pageBlocks: 10
6161
})
62-
v.setPoller(((V.debug?.seconds ? 1 : 60) * 1000) as V.Millis, () =>
62+
v.setInterval(((V.debug?.seconds ? 1 : 60) * 1000) as V.Millis, () =>
6363
V.millisUntilNext(new Date(), V.debug?.seconds ? 'Sec' : 'Min')
6464
)
6565

src/input/gamepad.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,26 @@ export class Gamepad {
3232
}
3333

3434
update(): void {
35-
this.bits = 0
36-
if (!isSecureContext) return
35+
this.bits = this.#read()
36+
}
37+
38+
#read(): number {
39+
if (!isSecureContext) return 0
40+
let bits = 0
3741
for (const pad of navigator.getGamepads()) {
3842
if (!pad) continue
39-
for (const [index, axis] of pad.axes.entries() ?? []) {
40-
const lessMore = this.bitByAxis[index]
43+
for (const [i, axis] of pad.axes.entries()) {
44+
const lessMore = this.bitByAxis[i]
4145
if (!lessMore) continue
4246
const bit = axis < 0 ? lessMore[0] : axis === 0 ? 0 : lessMore[1]
43-
this.bits |= Math.abs(axis) >= 0.5 ? bit : 0
47+
bits |= Math.abs(axis) >= 0.5 ? bit : 0
4448
}
45-
for (const [index, btn] of pad.buttons.entries() ?? []) {
46-
const bit = this.bitByButton[index]
49+
for (const [i, btn] of pad.buttons.entries()) {
50+
const bit = this.bitByButton[i]
4751
if (bit == null) continue
48-
this.bits |= btn.pressed ? bit : 0
52+
bits |= btn.pressed ? bit : 0
4953
}
5054
}
55+
return bits
5156
}
5257
}

src/looper.test.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {afterEach, beforeEach, test} from 'node:test'
2-
import {Looper} from './looper.ts'
2+
import {Looper, type LoopReason} from './looper.ts'
33
import {assert} from './test/assert.ts'
44
import {TestEvent} from './test/test-event.ts'
55
import type {Millis} from './types/time.ts'
@@ -27,7 +27,9 @@ afterEach(() => {
2727
test('Looper', async ctx => {
2828
using framer = new Looper()
2929
let frame = 0
30-
framer.onFrame = () => ++frame
30+
framer.onFrame = (_millis, _reason) => {
31+
++frame
32+
}
3133

3234
await ctx.test('init', () => assert(frame, 0))
3335

@@ -39,27 +41,27 @@ test('Looper', async ctx => {
3941

4042
await ctx.test('onFrame()', () => {
4143
performance.now = () => 8 as Millis
42-
framer.requestFrame()
44+
framer.requestFrame('Render')
4345
performance.now = () => 24 as Millis // millis = 24 - 8 = 16
4446
onFrame!()
4547
assert(frame, 1)
4648
assert(framer.age, 16 as Millis)
4749
performance.now = () => 40 as Millis
48-
framer.requestFrame()
50+
framer.requestFrame('Render')
4951
performance.now = () => 56 as Millis // millis = 56 - 40 = 16
5052
onFrame!()
5153
assert(frame, 2)
5254
assert(framer.age, 32 as Millis)
5355
performance.now = () => 72 as Millis
54-
framer.requestFrame()
56+
framer.requestFrame('Render')
5557
performance.now = () => 88 as Millis // millis = 88 - 72 = 16
5658
onFrame!()
5759
assert(frame, 3)
5860
assert(framer.age, 48 as Millis)
5961
})
6062

6163
await ctx.test('hidden', () => {
62-
framer.requestFrame()
64+
framer.requestFrame('Render')
6365
doc.hidden = true
6466
doc.dispatchEvent(TestEvent('visibilitychange'))
6567
assert(onFrame, undefined)
@@ -75,3 +77,82 @@ test('Looper', async ctx => {
7577
assert(framer.age, 64 as Millis) // 48 + 16
7678
})
7779
})
80+
81+
test('poll reason', async ctx => {
82+
using framer = new Looper()
83+
let frame = 0
84+
let lastReason: LoopReason | undefined
85+
let skip: 'Skip' | undefined
86+
framer.onFrame = (_millis, reason) => {
87+
++frame
88+
lastReason = reason
89+
return skip
90+
}
91+
framer.register('add')
92+
93+
await ctx.test('requestFrame(Render) passes Render reason', () => {
94+
performance.now = () => 0 as Millis
95+
framer.requestFrame('Render')
96+
performance.now = () => 16 as Millis
97+
onFrame!()
98+
assert(frame, 1)
99+
assert(lastReason, 'Render')
100+
})
101+
102+
await ctx.test('requestFrame(Poll) passes Poll reason', () => {
103+
performance.now = () => 16 as Millis
104+
framer.requestFrame('Poll')
105+
performance.now = () => 32 as Millis
106+
onFrame!()
107+
assert(frame, 2)
108+
assert(lastReason, 'Poll')
109+
})
110+
111+
await ctx.test('upgrade poll to render', () => {
112+
performance.now = () => 32 as Millis
113+
framer.requestFrame('Poll')
114+
// non-poll call upgrades pending poll.
115+
framer.requestFrame('Render')
116+
performance.now = () => 48 as Millis
117+
onFrame!()
118+
assert(frame, 3)
119+
assert(lastReason, 'Render')
120+
})
121+
122+
await ctx.test('poll does not upgrade to poll', () => {
123+
performance.now = () => 48 as Millis
124+
framer.requestFrame('Poll')
125+
// another poll call doesn't schedule a second rAF.
126+
framer.requestFrame('Poll')
127+
performance.now = () => 64 as Millis
128+
onFrame!()
129+
assert(frame, 4)
130+
assert(lastReason, 'Poll')
131+
})
132+
133+
await ctx.test('cancel clears reason', () => {
134+
performance.now = () => 64 as Millis
135+
framer.requestFrame('Poll')
136+
framer.register('remove')
137+
assert(onFrame, undefined)
138+
// re-register and request normal frame.
139+
framer.register('add')
140+
performance.now = () => 80 as Millis
141+
framer.requestFrame('Render')
142+
performance.now = () => 96 as Millis
143+
onFrame!()
144+
assert(frame, 5)
145+
assert(lastReason, 'Render')
146+
})
147+
148+
await ctx.test('skip does not accumulate age', () => {
149+
performance.now = () => 96 as Millis
150+
framer.requestFrame('Poll')
151+
const ageBefore = framer.age
152+
skip = 'Skip'
153+
performance.now = () => 112 as Millis
154+
onFrame!()
155+
skip = undefined
156+
assert(framer.age, ageBefore)
157+
})
158+
})

src/looper.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type {Millis} from './types/time.ts'
22
import {debug} from './utils/debug.ts'
33

4-
/**
5-
* frame manager. passes frame requests when not hidden. callers should request
6-
* frames when an update occurs or a check for an update occurs. eg, gamepads
7-
* must be polled.
8-
*
9-
* when registered, restoring visibility triggers an automatic request.
10-
*/
4+
export type LoopReason = 'Poll' | 'Render'
5+
116
export class Looper {
127
/** duration of frames observed. */
138
age: Millis = 0
14-
onFrame: ((millis: Millis) => void) | undefined
9+
onFrame:
10+
| ((millis: Millis, reason: LoopReason) => 'Skip' | undefined)
11+
| undefined
12+
#reason: LoopReason = 'Render'
1513
#req: number = 0
1614
#registered: boolean = false
1715
#start: Millis = 0
@@ -25,7 +23,8 @@ export class Looper {
2523
return this
2624
}
2725

28-
requestFrame(): void {
26+
requestFrame(reason: LoopReason): void {
27+
this.#reason = this.#reason === 'Render' ? this.#reason : reason
2928
if (this.#req || document.hidden || !this.#registered) return
3029
this.#start = performance.now()
3130
this.#req = requestAnimationFrame(this.#onFrame)
@@ -41,10 +40,14 @@ export class Looper {
4140
}
4241

4342
#onFrame = (): void => {
44-
const millis = (performance.now() - this.#start) as Millis
45-
this.age = (this.age + millis) as Millis
43+
const now = performance.now()
44+
const millis = (now - this.#start) as Millis
4645
this.#req = 0
47-
this.onFrame?.(millis)
46+
this.age += millis
47+
const reason = this.#reason
48+
this.#reason = 'Poll'
49+
if (this.onFrame && this.onFrame(millis, reason) === 'Skip')
50+
this.age -= millis
4851
}
4952

5053
#onVisibility = (ev: Event): void => {
@@ -53,7 +56,7 @@ export class Looper {
5356
this.#cancel()
5457
if (debug?.looper) console.debug('[looper] paused')
5558
} else {
56-
this.requestFrame()
59+
this.requestFrame('Poll')
5760
if (debug?.looper) console.debug('[looper] resumed')
5861
}
5962
}

src/void.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {CamConfig} from './level/level.ts'
1111
import {type ComponentHook, parseLevel} from './level/level-parser.ts'
1212
import type {LevelSchema} from './level/level-schema.ts'
1313
import type {Loader} from './level/loader.ts'
14-
import {Looper} from './looper.ts'
14+
import {Looper, type LoopReason} from './looper.ts'
1515
import type {PoolOpts} from './mem/pool.ts'
1616
import type {PoolMap} from './mem/pool-map.ts'
1717
import {SpritePool} from './mem/sprite-pool.ts'
@@ -55,7 +55,7 @@ export class Void {
5555
#backgroundRGBA: number
5656
#invalid: boolean = false
5757
readonly #pixelRatioObserver: PixelRatioObserver = new PixelRatioObserver()
58-
#poller: DelayInterval | undefined
58+
#interval: DelayInterval | undefined
5959
#registered: boolean = false
6060
/** may trigger an initial force update. */
6161
readonly #resizeObserver = new ResizeObserver(() => this.onResize())
@@ -103,7 +103,7 @@ export class Void {
103103
})
104104
}
105105

106-
this.looper.onFrame = millis => this.onFrame(millis)
106+
this.looper.onFrame = (millis, reason) => this.onFrame(millis, reason)
107107

108108
this.loader = opts.loader
109109

@@ -124,9 +124,9 @@ export class Void {
124124
this.#backgroundRGBA = rgba
125125
}
126126

127-
clearPoller(): void {
128-
this.#poller?.register('remove')
129-
this.#poller = undefined
127+
clearInterval(): void {
128+
this.#interval?.register('remove')
129+
this.#interval = undefined
130130
}
131131

132132
configCam(config: Readonly<CamConfig>): void {
@@ -172,21 +172,24 @@ export class Void {
172172
}
173173

174174
/** update input, update canvas, update cam, update world, then render. */
175-
onFrame(millis: Millis): void {
175+
onFrame(millis: Millis, reason: LoopReason): 'Skip' | undefined {
176176
this.tick.ms = millis
177177
this.tick.s = (millis / 1000) as Secs
178178
if (document.hidden) return
179179
this.input.update(millis)
180180

181-
this.requestFrame() // request frame before in case update cancels.
181+
// request frame before in case update cancels. next reason is 'Render' when
182+
// input.
183+
const nextReason = this.requestFrame()
184+
if (reason === 'Poll' && nextReason !== 'Render') return 'Skip'
182185

183186
this.#invalid = false
184187
this.loader.update(this)
185188

186189
this.cam.postupdate()
187190
}
188191

189-
onPoll(): void {
192+
onInterval(): void {
190193
this.requestFrame('Force')
191194
}
192195

@@ -202,8 +205,8 @@ export class Void {
202205
else this.#resizeObserver.unobserve(this.canvas.parentElement!)
203206
this.#pixelRatioObserver.register(op)
204207

205-
if (op === 'add') this.looper.requestFrame()
206-
this.#poller?.register(op)
208+
if (op === 'add') this.requestFrame('Force')
209+
this.#interval?.register(op)
207210

208211
await loadImage(this.#atlasImage)
209212
this.renderer.loadAtlas(this.#atlasImage)
@@ -214,17 +217,22 @@ export class Void {
214217
this.#registered = op === 'add'
215218
}
216219

217-
requestFrame(force?: 'Force'): void {
218-
if (force || this.renderer.always || this.input.anyOn || this.input.gamepad)
219-
this.looper.requestFrame()
220+
requestFrame(force?: 'Force'): LoopReason | undefined {
221+
let reason: LoopReason | undefined
222+
if (force || this.input.invalid || this.input.anyOn || this.renderer.always)
223+
reason = 'Render'
224+
else if (this.input.gamepad) reason = 'Poll'
225+
226+
if (reason) this.looper.requestFrame(reason)
227+
return reason
220228
}
221229

222-
setPoller(period: Millis, delay?: () => Millis): void {
223-
this.#poller?.register('remove')
224-
this.#poller = new DelayInterval(delay ?? (() => 0), period, () =>
225-
this.onPoll()
230+
setInterval(period: Millis, delay?: () => Millis): void {
231+
this.#interval?.register('remove')
232+
this.#interval = new DelayInterval(delay ?? (() => 0), period, () =>
233+
this.onInterval()
226234
)
227-
if (this.#registered) this.#poller.register('add')
235+
if (this.#registered) this.#interval.register('add')
228236
}
229237

230238
async [Symbol.asyncDispose](): Promise<void> {

0 commit comments

Comments
 (0)