diff --git a/cli/lib/server.js b/cli/lib/server.js index 35751e94..e0cc7a92 100644 --- a/cli/lib/server.js +++ b/cli/lib/server.js @@ -41,7 +41,17 @@ async function start (cartFile, opts) { } // Serve the WASM-4 developer runtime. - app.use(express.static(path.resolve(__dirname, "../assets/runtime/developer-build"))); + app.use(express.static( + path.resolve(__dirname, "../assets/runtime/developer-build"), + { + setHeaders: function (res, path, stat) { + // These COOP and COEP headers allow us to get high-precision time. + // https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements + res.set("Cross-Origin-Opener-Policy", "same-origin"); + res.set("Cross-Origin-Embedder-Policy", "require-corp"); + } + } + )); const first_port = opts.port; diff --git a/devtools/web/package-lock.json b/devtools/web/package-lock.json index e46a7efb..2ef24f1d 100644 --- a/devtools/web/package-lock.json +++ b/devtools/web/package-lock.json @@ -14,7 +14,6 @@ "lodash-es": "^4.17.21" }, "devDependencies": { - "@types/node": "^20.11.16", "prettier": "3.2.4", "rimraf": "5.0.5", "rollup-plugin-postcss-lit": "^2.1.0", @@ -646,6 +645,8 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1670,7 +1671,9 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/vite": { "version": "5.0.12", diff --git a/devtools/web/package.json b/devtools/web/package.json index 734bee16..2ae4be90 100644 --- a/devtools/web/package.json +++ b/devtools/web/package.json @@ -34,7 +34,6 @@ "@types/lodash-es": "^4.17.12" }, "devDependencies": { - "@types/node": "^20.11.16", "prettier": "3.2.4", "rimraf": "5.0.5", "rollup-plugin-postcss-lit": "^2.1.0", diff --git a/devtools/web/tsconfig.json b/devtools/web/tsconfig.json index 1f2ca0c7..8ed8bac2 100644 --- a/devtools/web/tsconfig.json +++ b/devtools/web/tsconfig.json @@ -17,7 +17,8 @@ "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "useDefineForClassFields": false, - "isolatedModules": true + "isolatedModules": true, + "types": [] }, "include": ["src/**/*.ts"], "exclude": [] diff --git a/runtimes/web/index.html b/runtimes/web/index.html index ba662fab..2341f754 100644 --- a/runtimes/web/index.html +++ b/runtimes/web/index.html @@ -3,7 +3,7 @@ - + WASM-4 web runtime dev diff --git a/runtimes/web/package-lock.json b/runtimes/web/package-lock.json index b818720f..c05bf4e7 100644 --- a/runtimes/web/package-lock.json +++ b/runtimes/web/package-lock.json @@ -10,7 +10,6 @@ "lit": "^3.1.2" }, "devDependencies": { - "@types/node": "^20.11.16", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "concurrently": "8.2.2", @@ -31,7 +30,6 @@ "lodash-es": "^4.17.21" }, "devDependencies": { - "@types/node": "^20.11.16", "prettier": "3.2.4", "rimraf": "5.0.5", "rollup-plugin-postcss-lit": "^2.1.0", diff --git a/runtimes/web/package.json b/runtimes/web/package.json index fcc9e0cf..83ef1d97 100644 --- a/runtimes/web/package.json +++ b/runtimes/web/package.json @@ -20,7 +20,6 @@ "lit": "^3.1.2" }, "devDependencies": { - "@types/node": "^20.11.16", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "concurrently": "8.2.2", diff --git a/runtimes/web/public/favicon.ico b/runtimes/web/public/favicon.ico new file mode 120000 index 00000000..8fa2ea42 --- /dev/null +++ b/runtimes/web/public/favicon.ico @@ -0,0 +1 @@ +../../../site/static/img/favicon.ico \ No newline at end of file diff --git a/runtimes/web/public/index.html b/runtimes/web/public/index.html index 5b9c3bb8..1cdffaec 100644 --- a/runtimes/web/public/index.html +++ b/runtimes/web/public/index.html @@ -3,7 +3,7 @@ - + WASM-4 Cart diff --git a/runtimes/web/src/apu.ts b/runtimes/web/src/apu.ts index 820eed6f..5ca527af 100644 --- a/runtimes/web/src/apu.ts +++ b/runtimes/web/src/apu.ts @@ -12,6 +12,7 @@ export class APU { audioCtx: AudioContext; processor!: APUProcessor; processorPort!: MessagePort; + paused: boolean = false; constructor () { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({ @@ -73,7 +74,8 @@ export class APU { } } - unlockAudio () { + unpauseAudio () { + this.paused = false; const audioCtx = this.audioCtx; if (audioCtx.state == "suspended") { audioCtx.resume(); @@ -81,9 +83,20 @@ export class APU { } pauseAudio () { + this.paused = true; const audioCtx = this.audioCtx; if (audioCtx.state == "running") { audioCtx.suspend(); } } + + // Most web browsers won't play audio until the user has interacted with the web page. + // Even if there are already pending Promises from an AudioContext.resume() call when the + // page gets interaction, apparently the AudioContext still needs to be poked with a + // resume() call to start playing. + pokeAudio () { + if (!this.paused) { + this.audioCtx.resume(); + } + } } diff --git a/runtimes/web/src/runtime.ts b/runtimes/web/src/runtime.ts index 515cc151..d573c026 100644 --- a/runtimes/web/src/runtime.ts +++ b/runtimes/web/src/runtime.ts @@ -90,14 +90,18 @@ export class Runtime { return this.data.getUint8(constants.ADDR_SYSTEM_FLAGS) & mask; } - unlockAudio () { - this.apu.unlockAudio(); + unpauseAudio () { + this.apu.unpauseAudio(); } - pauseAudio() { + pauseAudio () { this.apu.pauseAudio(); } + pokeAudio () { + this.apu.pokeAudio(); + } + reset (zeroMemory?: boolean) { // Initialize default color table and palette const mem32 = new Uint32Array(this.memory.buffer); diff --git a/runtimes/web/src/ui/app.ts b/runtimes/web/src/ui/app.ts index 0b3d1274..25cbf9b7 100644 --- a/runtimes/web/src/ui/app.ts +++ b/runtimes/web/src/ui/app.ts @@ -8,6 +8,7 @@ import * as z85 from "../z85"; import { Netplay, DEV_NETPLAY } from "../netplay"; import { Runtime } from "../runtime"; import { State } from "../state"; +import { callAt60Hz } from "../update-timing"; import { MenuOverlay } from "./menu-overlay"; import { Notifications } from "./notifications"; @@ -83,7 +84,7 @@ export class App extends LitElement { } // Try to begin playing audio - this.runtime.unlockAudio(); + this.runtime.pokeAudio(); } constructor () { @@ -261,8 +262,8 @@ export class App extends LitElement { const down = (event.type == "keydown"); - // Poke WebAudio - runtime.unlockAudio(); + // Try to begin playing audio + runtime.pokeAudio(); // We're using the keyboard now, hide the mouse cursor for extra immersion document.body.style.cursor = "none"; @@ -427,13 +428,7 @@ export class App extends LitElement { } } - // When we should perform the next update - let timeNextUpdate = performance.now(); - // Track the timestamp of the last frame - let lastTimeFrameStart = timeNextUpdate; - - const onFrame = (timeFrameStart: number) => { - requestAnimationFrame(onFrame); + const doUpdate = (interFrameTime: number | null) => { pollPhysicalGamepads(); let input = this.inputState; @@ -450,45 +445,28 @@ export class App extends LitElement { } } - let calledUpdate = false; - - // Prevent timeFrameStart from getting too far ahead and death spiralling - if (timeFrameStart - timeNextUpdate >= 200) { - timeNextUpdate = timeFrameStart; - } - - while (timeFrameStart >= timeNextUpdate) { - timeNextUpdate += 1000/60; - - if (this.netplay) { - if (this.netplay.update(input.gamepad[0])) { - calledUpdate = true; - } - - } else { - // Pass inputs into runtime memory - for (let playerIdx = 0; playerIdx < 4; ++playerIdx) { - runtime.setGamepad(playerIdx, input.gamepad[playerIdx]); - } - runtime.setMouse(input.mouseX, input.mouseY, input.mouseButtons); - runtime.update(); - calledUpdate = true; + if (this.netplay) { + this.netplay.update(input.gamepad[0]); + } else { + // Pass inputs into runtime memory + for (let playerIdx = 0; playerIdx < 4; ++playerIdx) { + runtime.setGamepad(playerIdx, input.gamepad[playerIdx]); } + runtime.setMouse(input.mouseX, input.mouseY, input.mouseButtons); + runtime.update(); } - if (calledUpdate) { - this.hideGamepadOverlay = !!runtime.getSystemFlag(constants.SYSTEM_HIDE_GAMEPAD_OVERLAY); + this.hideGamepadOverlay = !!runtime.getSystemFlag(constants.SYSTEM_HIDE_GAMEPAD_OVERLAY); - runtime.composite(); + runtime.composite(); - if (constants.GAMEDEV_MODE) { - // FIXED(2023-12-13): Pass the correct FPS for display - devtoolsManager.updateCompleted(runtime, timeFrameStart - lastTimeFrameStart); - lastTimeFrameStart = timeFrameStart; + if (constants.GAMEDEV_MODE) { + if (interFrameTime !== null) { + devtoolsManager.updateCompleted(runtime, interFrameTime); } } } - requestAnimationFrame(onFrame); + callAt60Hz(doUpdate); } onMenuButtonPressed () { @@ -497,12 +475,19 @@ export class App extends LitElement { this.inputState.gamepad[0] |= constants.BUTTON_X; } else { this.showMenu = true; + // During netplay, opening the menu doesn't pause the game, so shouldn't + // pause the audio either. Also, when audio is paused, the browser is + // more likely to throttle our update rate when we're in the background. + if (!this.netplay) { + this.runtime.pauseAudio(); + } } } closeMenu () { if (this.showMenu) { this.showMenu = false; + this.runtime.unpauseAudio(); // Kind of a hack to prevent the button press to close the menu from being passed // through to the game diff --git a/runtimes/web/src/update-timing.ts b/runtimes/web/src/update-timing.ts new file mode 100644 index 00000000..a96b0212 --- /dev/null +++ b/runtimes/web/src/update-timing.ts @@ -0,0 +1,153 @@ +export function callAt60Hz(callback: (interFrameTime: number | null) => void) { + if (_callback) { + throw new Error("can only have one update function"); + } + _callback = callback; + requestAnimationFrame(onVsync); +} + +let _callback: ((interFrameTime: number | null) => void) | undefined; + +let previousFrameStartTime: number | null = null; +function doUpdate() { + let frameStartTime = performance.now(); + let interFrameTime = previousFrameStartTime === null ? null : frameStartTime - previousFrameStartTime; + _callback!(interFrameTime); + previousFrameStartTime = frameStartTime; +} + + +// We use a scheme to switch between a vsync-based and timer-based update timing, depending on +// the vsync rate. This keeps the updates smooth and regular on a 60 fps monitor (or multiple thereof), +// but still at the correct update rate for other framerates. + +const idealIntervalMs = 1000 / 60; + +type TimerMode = { + vsyncMode: false, + timerID: number, +} + +type VsyncMode = { + vsyncMode: true, + vsyncTimeoutID: number, +} + +type UpdateTimingMode = TimerMode | VsyncMode; + +let updateTimingMode: UpdateTimingMode = { + vsyncMode: true, + // It's safe to just set this to 0 because that's never a valid timer ID, and clearing + // a non-existant ID does nothing. + vsyncTimeoutID: 0, +} + +let previousVsyncTime: number | null = null; +let smoothedVsyncInterval = 60; +let vsyncDividerCounter = 0; +// A requestAnimationFrame callback generally happens once soon after vsync, and the time passed +// to it is essentially the vsync time. Roughly speaking, this is a vsync callback. +// Switching between timing modes is controlled from this function. +function onVsync(vsyncTime: number) { + requestAnimationFrame(onVsync); + + if (previousVsyncTime !== null) { + let vsyncInterval = (vsyncTime - previousVsyncTime); + const a = 0.3; + smoothedVsyncInterval = (1-a)*smoothedVsyncInterval + a*vsyncInterval; + } + previousVsyncTime = vsyncTime; + + + let framerateRatio = idealIntervalMs / smoothedVsyncInterval; + let roundedFramerateRatio = Math.round(framerateRatio); + let fractionalFramerateRatio = framerateRatio % 1; + if (roundedFramerateRatio >= 1 && (fractionalFramerateRatio < 0.01 || fractionalFramerateRatio > 0.99)) { + // The framerate is near to a multiple of 60, so we go to (or stay in) Vsync mode, and do an update. + + // In case requestAnimationFrame callbacks suddenly stop happening as often or stop altogether + // (e.g. when a desktop user puts the browser window in the background, moves the window to a monitor + // with a different framerate, etc.), we use a timeout that will rapidly switch to timer mode. + + if (updateTimingMode.vsyncMode) { + clearTimeout(updateTimingMode.vsyncTimeoutID); + } else { + clearTimeout(updateTimingMode.timerID); + } + + updateTimingMode = { + vsyncMode: true, + vsyncTimeoutID: setTimeout(onTimer, 1.2*idealIntervalMs), + } + + vsyncDividerCounter++; + if (vsyncDividerCounter >= roundedFramerateRatio) { + vsyncDividerCounter = 0; + doUpdate(); + } + } else { + // Switch to (or stay in) timer mode. + // We need to be able to handle going to either a lower vsync rate like 30 per second, + // or a higher one like 90 per second. + if (updateTimingMode.vsyncMode) { + clearTimeout(updateTimingMode.vsyncTimeoutID); + + let timeout; + let now = performance.now(); + if (previousFrameStartTime !== null) { + target = previousFrameStartTime + idealIntervalMs; + } else { + target = now; + } + timeout = target - now; + updateTimingMode = { + vsyncMode: false, + timerID: setTimeout(onTimer, timeout) + }; + } + } +} + +// For framerates that aren't a multiple of 60, a setTimeout() solution is used. +// This is especially necessary when requestAnimationFrame callbacks happen at less +// than 60 times a second, to ensure that audio is updated at a uniform interval of 60 times per second. +// This could happen e.g. when the device only has a 30 fps screen, or on a desktop when the browser +// window is put in the background. The runtime also falls into timer mode when update calls are taking +// too long. +// setTimeout() is used over setInterval() because setInterval rounds down 16.66ms to 16ms and some browsers +// run setInterval late whereas others try to keep it at the correct frequency on average. Overall, careful use +// of setTimeout() gives better control of timing. +let target = 0; +function onTimer() { + let now = performance.now(); + + if (updateTimingMode.vsyncMode) { + // The vsync timeout has triggered. + target = now; + } + + // If it's been too long since our target time, don't try to catch up on lost time and frames. + // Just accept that there was lag and continue at normal pace from now. + // For this reason, the value chosen for this should be only just large enough to absorb timer jitter. + // I've chosen a conservatively large value of 16.6 milliseconds. + if (now - target > idealIntervalMs) { + target = now + idealIntervalMs; + } else { + // By setting a target that increases at 60 fps and aiming next frame for it, various timer + // innaccuracies are corrected for and averaged out, including: the jitter added to performance.now() + // for security purposes, intrinsic lateness of setTimeout() callbacks, setTimeout() only taking + // an integer number of milliseconds and removing any fractional part (1000/60 = 16.666ms becomes 16ms + // on major browsers, which corresponds to 62.5 updates per second, a noticable speedup). + target += idealIntervalMs; + } + + updateTimingMode = { + vsyncMode: false, + // Calling setTimeout before doUpdate means that the browser clamping the timeout to a minimum of 4ms + // isn't a problem. If we called it after, we would get slowdown at high load, when update ends less + // than 4ms before the start of the next frame. + timerID: setTimeout(onTimer, target-now) + }; + + doUpdate(); +} \ No newline at end of file diff --git a/runtimes/web/tsconfig.json b/runtimes/web/tsconfig.json index 7c9ebc81..57d686f8 100644 --- a/runtimes/web/tsconfig.json +++ b/runtimes/web/tsconfig.json @@ -14,7 +14,8 @@ "forceConsistentCasingInFileNames": true, "alwaysStrict": true, "useDefineForClassFields": false, - "isolatedModules": true + "isolatedModules": true, + "types": [] }, "include": ["src/**/*.ts", "src/**/*.js"], "exclude": [] diff --git a/runtimes/web/vite.config.ts b/runtimes/web/vite.config.ts index d656c411..2d9e4548 100644 --- a/runtimes/web/vite.config.ts +++ b/runtimes/web/vite.config.ts @@ -10,6 +10,12 @@ export default defineConfig(({ mode }) => { server: { port: 3000, open: '/?url=cart.wasm', + headers: { + // These COOP and COEP headers allow us to get high-precision time. + // https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", // "credentialless" is also a possibility + }, }, build: { sourcemap: gamedev_build, diff --git a/site/src/components/NetplayCart.js b/site/src/components/NetplayCart.js index da067387..86fef4e5 100644 --- a/site/src/components/NetplayCart.js +++ b/site/src/components/NetplayCart.js @@ -39,9 +39,6 @@ export default function NetplayCart () {

FAQ

- The game seems to be paused or incredibly slow? -

Make sure you're not playing in a background tab! Most browsers heavily throttle pages in a background tab. To test netplay, open the game in two separate, visible windows.

- How do I check our connection quality?

You can see your ping to each of the other players on the pause menu by pressing Enter. For fast-paced action games, ping below 200 ms is recommended for best results.