WebAssembly (Emscripten) 移植

EmbeddedGUI 支持通过 Emscripten 编译为 WebAssembly,在浏览器中运行 GUI 演示。这对于在线展示、文档演示和跨平台测试非常有用。

构建环境

前提条件

  • Emscripten SDK (emsdk) 已安装并激活

  • Python 3.x

  • Make 工具

环境变量

# 激活 emsdk
source /path/to/emsdk/emsdk_env.sh

# 或设置 EMSDK_PATH
export EMSDK_PATH=/path/to/emsdk

Emscripten 编译配置

构建命令

# 构建单个示例
make all APP=HelloSimple PORT=emscripten

# 构建后在 output/ 目录生成:
# - HelloSimple.html  (入口页面)
# - HelloSimple.js    (JS 胶水代码)
# - HelloSimple.wasm  (WebAssembly 二进制)
# - HelloSimple.data  (预加载资源,如有)

build.mk 配置

porting/emscripten/build.mk 定义了 Emscripten 特有的编译选项:

# 使用 Emscripten 的 SDL2 端口
COMMON_FLAGS += -s USE_SDL=2
LFLAGS += -s USE_SDL=2

# 内存配置
LFLAGS += -s ALLOW_MEMORY_GROWTH=1
LFLAGS += -s INITIAL_MEMORY=33554432    # 32MB 初始内存
LFLAGS += -s STACK_SIZE=5242880         # 5MB 栈

# 导出函数
LFLAGS += -s EXPORTED_FUNCTIONS='["_main"]'
LFLAGS += -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'

# 自定义 HTML 模板
LFLAGS += --shell-file $(EGUI_PORT_PATH)/shell.html

# 预加载资源文件到虚拟文件系统
LFLAGS += --preload-file $(OUTPUT_PATH)/app_egui_resource_merge.bin@app_egui_resource_merge.bin

Makefile.emscripten

porting/emscripten/Makefile.emscripten 是 Emscripten 专用的构建规则文件,替代 PC 平台的 Makefile.base。主要区别:

  • 编译器使用 emcc 而非 gcc

  • 输出目标为 .html 而非可执行文件

  • 排除 PC 平台的 main.cegui_port_pc.c,使用 Emscripten 专用版本

  • 使用 per-app OBJDIR 避免重复编译共享核心库

浏览器事件映射

主循环适配

浏览器环境不允许阻塞式主循环。Emscripten 使用 emscripten_set_main_loop 替代 while(1)

static void main_loop_iteration(void)
{
    egui_polling_work(&core);
    VT_sdl_refresh_task();

    if (VT_is_request_quit())
    {
        emscripten_cancel_main_loop();
    }
}

int main(int argc, const char *argv[])
{
    printf("Hello, egui! (WebAssembly)\n");

    strcpy(input_file_path, "app_egui_resource_merge.bin");
    VT_init();
    egui_init(&core, egui_pfb);
    egui_port_register_core(&core);
    egui_port_init();
#if EGUI_CONFIG_FUNCTION_SUPPORT_TOUCH
    egui_port_register_touch_driver(&core);
#endif
    egui_platform_register(&core, egui_port_get_platform());
    egui_display_driver_register(&core, egui_port_get_display_driver());
    uicode_disp0_init(&core);
    egui_screen_on(&core);

    // 0 = requestAnimationFrame (~60fps), 1 = simulate infinite loop
    emscripten_set_main_loop(main_loop_iteration, 0, 1);

    VT_deinit();
    return 0;
}

当前 emscripten 入口仍采用单屏低层初始化路径:egui_init() 后手动注册 platform / display / touch。新的多屏推荐流程见 多屏方案,其核心入口是 egui_setup_display()

与 PC 版本的关键区别:

  • 单线程运行(浏览器主线程)

  • 使用 requestAnimationFrame 驱动帧循环

  • delay 函数为空操作(不能阻塞浏览器)

时间戳

使用 emscripten_get_now() 获取高精度时间戳:

static uint32_t em_get_tick_ms(void)
{
    return (uint32_t)emscripten_get_now();
}

断言处理

浏览器环境下不能使用 while(1) 死循环,改为取消主循环并退出:

static void em_assert_handler(const char *file, int line)
{
    printf("Assert@ file = %s, line = %d\n", file, line);
    emscripten_cancel_main_loop();
    emscripten_force_exit(1);
}

触摸/鼠标事件

Emscripten 的 SDL2 端口自动将浏览器鼠标和触摸事件映射为 SDL 事件,与 PC 模拟器共享同一套 SDL 事件处理代码。

资源加载

外部资源通过 Emscripten 的 --preload-file 预加载到虚拟文件系统,代码中使用标准 fopen/fread 访问:

strcpy(input_file_path, "app_egui_resource_merge.bin");
// 后续通过 fopen(input_file_path, "rb") 访问

HTML 模板

porting/emscripten/shell.html 提供了简洁的 HTML 模板:

  • 全屏黑色背景,居中显示 Canvas

  • 加载状态指示器(spinner + 进度)

  • WebGL 上下文丢失自动恢复

  • 响应式布局,适配不同屏幕

模板中的 {{{ SCRIPT }}} 占位符会被 Emscripten 替换为生成的 JS 代码。

wasm_build_demos.py 批量构建

scripts/web/wasm_build_demos.py 用于批量构建所有示例的 WASM 版本。

用法

# 构建所有示例
python scripts/web/wasm_build_demos.py

# 指定 emsdk 路径
python scripts/web/wasm_build_demos.py --emsdk-path /path/to/emsdk

# 指定输出目录
python scripts/web/wasm_build_demos.py --output-dir web/demos

# 只构建指定示例
python scripts/web/wasm_build_demos.py --app HelloSimple

`HelloCustomWidgets`  WASM 构建与发布已迁移到独立仓库 `EmbeddedGUI_Widgets`

工作流程

  1. 扫描 example/ 目录获取示例列表;默认整站构建会跳过 HelloUnitTest

  2. 对每个示例:

    • 生成资源文件(make resource

    • 使用 Emscripten 编译(make all PORT=emscripten

    • 复制输出文件到部署目录

  3. 使用 per-app OBJDIR 优化构建产物复用

  4. HelloBasicHelloVirtual 这类多子应用示例会按家族顺序构建,共享中间产物或避免共享输出互相覆盖

  5. 在输出目录生成 demos.json,供 web/index.htmlbasic.htmlexamples.html 读取

并行构建

脚本使用 ProcessPoolExecutor 并行构建独立示例;多子应用家族则保持顺序构建,以兼顾速度和输出隔离。

GitHub Pages 部署

目录结构

批量构建后的部署目录结构:

web/
├── index.html          # 首页
├── basic.html          # HelloBasic 聚合页
├── examples.html       # 独立示例聚合页
├── widgets repo        # HelloCustomWidgets 已迁移到独立仓库 `EmbeddedGUI_Widgets`
├── doc-render.js       # README 渲染
├── i18n.js             # 多语言切换
├── style.css           # 页面样式
├── lib/
└── demos/
    ├── demos.json
    ├── HelloSimple/
    │   ├── HelloSimple.html
    │   ├── HelloSimple.js
    │   ├── HelloSimple.wasm
    │   └── HelloSimple.data
    ├── HelloActivity/
    │   └── ...
    └── HelloBasic_button/
        └── ...

CI 配置

在 GitHub Actions 中自动构建和部署:

- name: Setup Emscripten
  uses: mymindstorm/setup-emsdk@v14

- name: Build WASM demos
  run: python scripts/web/wasm_build_demos.py

- name: Upload Pages artifact
  uses: actions/upload-pages-artifact@v3
  with:
    path: web

- name: Deploy to GitHub Pages
  uses: actions/deploy-pages@v4

本地预览

# 构建单个示例后,预览 output/ 下的 HTML
cd output
python3 -m http.server 8000

# 浏览器打开
# http://localhost:8000/HelloSimple.html

# 构建整站后,直接启动 web/ 下的本地服务器
python web/start_server.py

Windows 本地 emsdk 工作流

推荐先在仓库根目录准备本地 emsdk:

python scripts\setup_env.py --python-mode none --install-emsdk

Windows 下 porting/emscripten/build.mk 会通过 python scripts/web/emcc_wrapper.py 优先使用仓库内的 tools\emsdk。因此直接执行以下命令即可,不要求先手动激活当前 shell:

make all APP=HelloSimple PORT=emscripten

scripts/web/wasm_build_demos.py 也会优先复用本地 emsdk。只有在当前终端手动执行 emcc -vem++ -v 等命令时,才需要额外运行:

call tools\emsdk\emsdk_env.bat

如需跳过 Emscripten 检查,可在环境脚本中使用:

python scripts\setup_env.py --python-mode none --skip-emsdk

SDL2 端口缓存排障

当前 WASM 端口使用 -s USE_SDL=2。首次构建时,Emscripten 会在 tools/emsdk/upstream/emscripten/cache/ports/ 下载 SDL2 端口,因此第一次构建可能明显更慢。

如果下载过程中网络中断,或者历史代理残留导致 SDL2 端口缓存变成半成品,常见现象是构建长时间停在 SDL2 port 阶段。可删除以下缓存后重试:

tools/emsdk/upstream/emscripten/cache/ports/sdl2/
tools/emsdk/upstream/emscripten/cache/ports/sdl2.zip

清理后重新执行:

make all APP=HelloSimple PORT=emscripten

注意事项

  • Emscripten 构建禁用了录制测试(EGUI_CONFIG_FUNCTION_RECORDING_TEST=0

  • 浏览器中 delay 为空操作,不会阻塞

  • 初始内存设为 32MB,启用了 ALLOW_MEMORY_GROWTH 允许动态增长

  • 栈大小设为 5MB,足够大多数 GUI 应用使用

  • WASM 文件通常比原生二进制大,但经过 gzip 压缩后传输量可接受