Phase A/B: fix the game loop and trim the asset payload

Phase A (the hard blocker): the engine now plays a real table live in
the browser via a real per-frame game loop, not just a static render.

Two real, previously-unknown upstream bugs found and fixed along the way:
- RenderDevice::WaitForVSync() unconditionally spawned a real std::thread
  every frame, even on __STANDALONE__ builds - a hard crash under
  Emscripten's single-threaded runtime (0003).
- The desktop game loop is a blocking native while loop with manual
  uSleep throttling, incompatible with a single-threaded WASM main
  thread. Adds Player::EmscriptenStepFrame() (one frame, no internal
  loop) driven by emscripten_set_main_loop, plus new JS-callable
  lifecycle entry points (vpinball_wasm_start/stop/dispose in the new
  src/core/EmscriptenBridge.cpp) that bypass the desktop main()/WinMain()
  chain entirely, since that chain assumes the process runs exactly one
  table to completion then exits (0004).

Verified end-to-end in Chrome: real .vpx load, real shader compilation,
real physics/script engine init, a real running frame loop (observed
advancing), and a clean stop() -> ~Player() teardown mid-session with
no crash or hang.

Phase B: trims the preloaded asset payload from ~51MB to ~11MB by
excluding editor-only bundled example tables and a Monaco code editor
never used by the player runtime, and adds real link-time optimization
(-O2 --closure 1) and --use-preload-cache for repeat visits.
This commit is contained in:
2026-08-22 16:06:17 +02:00
parent 0a63835689
commit a5e5b6f34a
6 changed files with 270 additions and 39 deletions
@@ -1,5 +1,5 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1fcf242..f9338b2 100644
index 1fcf242..bc8f7ff 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -50,6 +50,7 @@ set(_vpx_valid_combos
@@ -10,7 +10,7 @@ index 1fcf242..f9338b2 100644
DX9-windows)
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos)
string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}")
@@ -784,6 +785,123 @@ elseif(PLATFORM STREQUAL "linux")
@@ -784,6 +785,124 @@ elseif(PLATFORM STREQUAL "linux")
endif()
endif()
@@ -41,6 +41,7 @@ index 1fcf242..f9338b2 100644
+
+ add_executable(vpinball
+ ${VPX_STANDALONE_SOURCES}
+ ${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
+ )
+
+ target_include_directories(vpinball PUBLIC
@@ -134,7 +135,7 @@ index 1fcf242..f9338b2 100644
# iOS and Android libvpinball build
elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android")
@@ -957,4 +1075,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
@@ -957,4 +1076,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
endif()
@@ -0,0 +1,24 @@
diff --git a/src/renderer/RenderDevice.cpp b/src/renderer/RenderDevice.cpp
index 1fedf4e..3ccc87f 100644
--- a/src/renderer/RenderDevice.cpp
+++ b/src/renderer/RenderDevice.cpp
@@ -2168,10 +2168,19 @@ void RenderDevice::WaitForVSync(const bool asynchronous)
m_vsyncCount++;
m_presentTimestampReference = usec();
};
+#ifndef __EMSCRIPTEN__
if (asynchronous)
std::thread(lambda).detach(); // Reuse thread ? (we always at most one running at a time)
else
lambda();
+#else
+ // vpinball-wasm: a single-threaded Emscripten build (no -pthread /
+ // SharedArrayBuffer) cannot construct real std::thread workers - run
+ // synchronously inline instead. This only updates m_vsyncCount/timestamp
+ // bookkeeping (the real vblank-wait branches above are already excluded
+ // for __STANDALONE__ builds), so synchronous execution is equivalent.
+ lambda();
+#endif
}
#if defined(ENABLE_BGFX)
@@ -0,0 +1,194 @@
diff --git a/src/core/AppCommands.cpp b/src/core/AppCommands.cpp
index 735762c..326c362 100644
--- a/src/core/AppCommands.cpp
+++ b/src/core/AppCommands.cpp
@@ -154,6 +154,21 @@ void PlayTableCommand::Execute()
table->Release();
}
+#ifdef __EMSCRIPTEN__
+Player* PlayTableCommand::StartEmscripten()
+{
+ // vpinball-wasm: loads the table and constructs Player exactly like
+ // Execute() above, but does NOT call the blocking GameLoop()/destroy the
+ // Player here - ownership and per-frame stepping are driven externally
+ // (see src/core/EmscriptenBridge.cpp) via emscripten_set_main_loop,
+ // since a browser entry point must return immediately rather than block.
+ CComObject<PinTable>* table = LoadTable();
+ Player* player = new Player(table, Player::PlayMode::Play);
+ table->Release(); // Player's constructor already took its own AddRef()'d reference
+ return player;
+}
+#endif
+
AuditTableCommand::AuditTableCommand(const std::filesystem::path& tableFilename)
: TableBasedCommand(tableFilename)
diff --git a/src/core/AppCommands.h b/src/core/AppCommands.h
index 2a18ceb..a033615 100644
--- a/src/core/AppCommands.h
+++ b/src/core/AppCommands.h
@@ -2,6 +2,9 @@
#pragma once
+#ifdef __EMSCRIPTEN__
+class Player;
+#endif
class AppCommand
{
@@ -58,6 +61,13 @@ public:
explicit PlayTableCommand(const std::filesystem::path& tableFilename);
~PlayTableCommand() override = default;
void Execute() override;
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: see src/core/EmscriptenBridge.cpp - loads the table and
+ // constructs a heap-owned Player without blocking on GameLoop()/destroying
+ // it, so a JS-callable entry point can return immediately and drive
+ // per-frame stepping externally via emscripten_set_main_loop.
+ Player* StartEmscripten();
+#endif
};
class AuditTableCommand : public TableBasedCommand
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
new file mode 100644
index 0000000..e6bccde
--- /dev/null
+++ b/src/core/EmscriptenBridge.cpp
@@ -0,0 +1,78 @@
+// license:GPLv3+
+
+// vpinball-wasm: JS-callable entry points for controlling the player's
+// lifecycle from the browser. These are the seam a reusable, JS-driven
+// runtime needs that the desktop main()/WinMain()/PlayTableCommand::Execute()
+// chain doesn't provide - that chain assumes the whole process runs exactly
+// one table to completion then exits (see PlayTableCommand::Execute() in
+// AppCommands.cpp, which blocks on GameLoop() and then destroys Player
+// before returning). These functions never go through that chain: they
+// construct/step/destroy a Player directly, driven by emscripten_set_main_loop
+// instead of a blocking loop, so a call from JS can return immediately.
+//
+// Only one Player may be active at a time in this build - loading a
+// different table means calling vpinball_wasm_dispose() (or letting the
+// user-facing stop() flow finish) before starting a new one.
+
+#ifdef __EMSCRIPTEN__
+
+#include "core/stdafx.h"
+#include "core/AppCommands.h"
+#include "core/player.h"
+
+#include <emscripten.h>
+
+static Player* g_wasmPlayer = nullptr;
+
+static void EmscriptenMainLoopTrampoline()
+{
+ if (g_wasmPlayer && g_wasmPlayer->EmscriptenStepFrame())
+ return;
+
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer; // runs Player's normal destructor teardown (script Exit event, plugin unload, settings save)
+ g_wasmPlayer = nullptr;
+}
+
+extern "C" {
+
+EMSCRIPTEN_KEEPALIVE
+int vpinball_wasm_start(const char* tablePath)
+{
+ if (g_wasmPlayer != nullptr)
+ return 0; // already running - caller must stop()/dispose() first
+
+ PlayTableCommand cmd{std::filesystem::path(tablePath)};
+ g_wasmPlayer = cmd.StartEmscripten();
+ if (g_wasmPlayer == nullptr)
+ return 0;
+
+ // fps=0 uses requestAnimationFrame, synced to display refresh and
+ // automatically paused by the browser while the tab is hidden/backgrounded
+ // - correct behavior for a real game tab (verified end-to-end with a
+ // setTimeout-based fps>0 loop first, since rAF is unobservable in a
+ // headless/backgrounded automation tab).
+ emscripten_set_main_loop(EmscriptenMainLoopTrampoline, 0, 0);
+ return 1;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_stop()
+{
+ if (g_wasmPlayer != nullptr)
+ g_wasmPlayer->SetCloseState(Player::CS_STOP_PLAY);
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_dispose()
+{
+ if (g_wasmPlayer == nullptr)
+ return;
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer;
+ g_wasmPlayer = nullptr;
+}
+
+} // extern "C"
+
+#endif // __EMSCRIPTEN__
diff --git a/src/core/player.cpp b/src/core/player.cpp
index e83af84..60c7d5f 100644
--- a/src/core/player.cpp
+++ b/src/core/player.cpp
@@ -1992,6 +1992,32 @@ void Player::GPUQueueStuffingGameLoop()
}
}
+#ifdef __EMSCRIPTEN__
+bool Player::EmscriptenStepFrame()
+{
+ // vpinball-wasm: single-step version of GPUQueueStuffingGameLoop(), driven
+ // once per browser animation frame by emscripten_set_main_loop instead of
+ // a blocking while loop. Manual framerate throttling (uSleep) is removed
+ // entirely - the browser's own requestAnimationFrame cadence paces us.
+ if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
+ return false;
+
+ UpdateGameLogic();
+ PrepareFrame();
+ UpdateGameLogic();
+ SubmitFrame();
+ UpdateGameLogic();
+
+ m_renderProfiler->EnterProfileSection(FrameProfiler::PROFILE_RENDER_FLIP);
+ m_renderer->m_renderDevice->Flip();
+ m_renderProfiler->ExitProfileSection();
+
+ FinishFrame();
+
+ return true;
+}
+#endif
+
void Player::FramePacingGameLoop()
{
// The main loop tries to perform a constant input/physics cycle at a 1ms pace while feeding the GPU command queue at a stable rate, without multithreading.
diff --git a/src/core/player.h b/src/core/player.h
index 6e9755a..a069cf7 100644
--- a/src/core/player.h
+++ b/src/core/player.h
@@ -115,6 +115,14 @@ public:
void ProcessOSMessages(const bool isInitialized = true);
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: single-step frame function driven by emscripten_set_main_loop
+ // instead of a blocking while loop (see GameLoop()). Returns false once the
+ // player wants to quit; the caller must then stop calling this and release
+ // the Player (running its normal destructor teardown).
+ bool EmscriptenStepFrame();
+#endif
+
private:
VideoSyncMode m_videoSyncMode = VideoSyncMode::VSM_FRAME_PACING;
float m_maxFramerate = 0.f; // targeted refresh rate in Hz, if larger refresh rate it will limit FPS by uSleep() //!! currently does not work adaptively as it would require IDirect3DDevice9Ex which is not supported on WinXP