Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow running web runtime in background windows #793

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion cli/lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions devtools/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion devtools/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion devtools/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": false,
"isolatedModules": true
"isolatedModules": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": []
Expand Down
2 changes: 1 addition & 1 deletion runtimes/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
<link rel="shortcut icon" href="https://wasm4.org/img/favicon.ico">
<link rel="shortcut icon" href="favicon.ico">
<title>WASM-4 web runtime dev</title>
</head>
<body>
Expand Down
2 changes: 0 additions & 2 deletions runtimes/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion runtimes/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions runtimes/web/public/favicon.ico
2 changes: 1 addition & 1 deletion runtimes/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
<link rel="shortcut icon" href="https://wasm4.org/img/favicon.ico">
<link rel="shortcut icon" href="favicon.ico">
<title>WASM-4 Cart</title>
<link rel="stylesheet" href="wasm4.css">
</head>
Expand Down
54 changes: 16 additions & 38 deletions runtimes/web/src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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 () {
Expand Down
153 changes: 153 additions & 0 deletions runtimes/web/src/update-timing.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 2 additions & 1 deletion runtimes/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"forceConsistentCasingInFileNames": true,
"alwaysStrict": true,
"useDefineForClassFields": false,
"isolatedModules": true
"isolatedModules": true,
"types": []
},
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": []
Expand Down
6 changes: 6 additions & 0 deletions runtimes/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading