v0.6.0: WebAssembly build with a WebGL backend (playable in the browser)

- ra3.webgl: a Web peer of Vulkan/Direct3D (SDL3 canvas + GLES3/WebGL2), with
  2D blit and the GPU terrain raymarch; GLSL ES shaders; null fallback off
  Emscripten. backend::webgl + default_backend() (webgl under Emscripten).
- Emscripten toolchain + Dockerfile.wasm + scripts/build-wasm.sh; import std,
  global -fexceptions, and Asyncify so the blocking frame loop yields to the
  browser (display::sleep_frame calls emscripten_sleep).
- apps/web/shell.html + apps/web/serve.py (Range-capable dev server).
- Assets: browsers forbid synchronous on-demand reads on the main thread (and
  FS.createLazyFile / the WasmFS fetch backend are worker-only; SDL3's Emscripten
  backend is main-thread DOM only, so the engine cannot run in a worker). The
  wasm build therefore preloads a compact per-map set via OPENRA3_WEB_ASSETS.
- New openra3 textures --map ID lists the loose terrain TGAs a map resolves to
  (terrain::resolve_texture_files, shared with load_textures_from_dir).
- Fixes: webgl heightmap used GL_R16 = 0x8229 (that is R8) -> upload rejected,
  terrain flattened to water; now 0x822A. Logger uses a stdout console sink on
  the web (stderr maps to console.error); a GL error check logs bad uploads.
- CI: wasm image + build jobs.
This commit is contained in:
EnderTheCoder
2026-09-28 22:57:19 +08:00
parent d0ae4c76ba
commit 70d4beca4f
22 changed files with 1341 additions and 72 deletions
+51 -29
View File
@@ -394,12 +394,13 @@ export namespace ra3::terrain {
uint32 supersample = 2U; ///< Render at Nx and box-downsample (antialiasing).
};
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set {
std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) {
namespace detail {
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
-> std::unordered_map<std::string, std::filesystem::path> {
std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec;
if (!std::filesystem::is_directory(dir, ec)) return files;
// Recursive: our own extract writes flat `terrain/*.tga`, ra3tools
// writes nested `.../art/terrain/*.tga`. Only index `terrain` dirs
// so a full asset dump does not pull in every unrelated TGA.
@@ -409,38 +410,59 @@ export namespace ra3::terrain {
auto parent = path.parent_path().filename().string();
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (parent != "terrain") continue;
auto stem = detail::tga_stem(path.filename().string());
auto stem = tga_stem(path.filename().string());
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, path);
}
return files;
}
/** Match one map texture name against the index (see `load_textures_from_dir`). */
[[nodiscard]] inline auto match_terrain_file(const std::unordered_map<std::string, std::filesystem::path> &files, std::string name)
-> std::filesystem::path {
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
if (const auto it = files.find(candidate); it != files.end()) return it->second;
}
for (const auto &[stem, path]: files) {
if (stem.ends_with(name)) return path;
}
return {};
}
} // namespace detail
/**
* The loose `*.tga` files `map` resolves to under `dir`, de-duplicated.
*
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
*/
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
const auto files = detail::terrain_file_index(dir);
std::vector<std::filesystem::path> resolved;
for (const auto &texture: map.textures) {
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
}
std::sort(resolved.begin(), resolved.end());
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
return resolved;
}
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set {
const auto files = detail::terrain_file_index(dir);
texture_set set;
set.images.resize(map.textures.size());
for (usize i = 0; i < map.textures.size(); ++i) {
auto name = map.textures[i].name;
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
std::filesystem::path found;
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
if (const auto it = files.find(candidate); it != files.end()) {
found = it->second;
break;
const auto found = detail::match_terrain_file(files, map.textures[i].name);
if (!found.empty()) {
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
}
if (found.empty()) {
for (const auto &[stem, path]: files) {
if (stem.ends_with(name)) {
found = path;
break;
}
}
}
if (found.empty()) continue;
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
}
return set;