v0.7.0: SDL-free wasm worker backend (WebGPU + WebGL2) with on-demand assets

The engine now runs on a plain Web Worker (no SDL, no PROXY_TO_PTHREAD: in a pthread Emscripten proxies every filesystem syscall to the main browser thread, where synchronous XHR - and thus FS.createLazyFile - is forbidden). A page/worker pair transfers an OffscreenCanvas and forwards DOM input; assets load lazily from an embedded manifest, so entering a map fetches only that map and its tiles.

Presentation: ra3.webgpu (WebGPU via Emscripten's emdawnwebgpu port, WGSL shaders, the default on wasm) and ra3.wasmgl (WebGL2, GLSL ES). The legacy SDL ra3.webgl backend is removed.
This commit is contained in:
EnderTheCoder
2026-09-29 12:08:07 +08:00
parent 70d4beca4f
commit 889ce945f2
27 changed files with 2161 additions and 639 deletions
+21 -17
View File
@@ -9,18 +9,19 @@ import ra3.client;
import ra3.ui;
import ra3.vulkan;
import ra3.dx;
import ra3.webgl;
import ra3.wasmgl;
import ra3.webgpu;
/**
* Backend selection for the presentation layer.
*
* The app talks only to `ra3::client::display`; this module picks the concrete
* backend so no caller has to know which one is in use. The available backends
* are Vulkan, Direct3D 11 and Direct3D 12, WebGL (all GPU terrain, available
* per platform) and the SDL software blit fallback. The caller may name a
* preferred backend (the in-game menu exposes this); if it does not start, the
* others are tried in turn. The GPU terrain path reports failure so the caller
* can fall back to the software renderer.
* are Vulkan, Direct3D 11 and Direct3D 12 (desktop), the WebGPU and SDL-free
* WebGL2 wasm worker backends (wasm) and the SDL software blit fallback. The
* caller may name a preferred backend (the in-game menu exposes this); if it
* does not start, the others are tried in turn. The GPU terrain path reports
* failure so the caller can fall back to the software renderer.
*/
export namespace ra3::display {
using ra3::client::display_options;
@@ -33,12 +34,12 @@ export namespace ra3::display {
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
/** A concrete presentation backend, or `none`. */
enum class backend { none, vulkan, d3d11, d3d12, webgl, sdl };
enum class backend { none, vulkan, d3d11, d3d12, wasmgl, webgpu, sdl };
/** The backend preferred on this host: WebGL under Emscripten, else Vulkan. */
/** The backend preferred on this host: WebGPU under Emscripten (falling back to the WebGL worker), else Vulkan. */
[[nodiscard]] inline constexpr auto default_backend() -> backend {
#if defined(__EMSCRIPTEN__)
return backend::webgl;
return backend::webgpu;
#else
return backend::vulkan;
#endif
@@ -49,7 +50,8 @@ export namespace ra3::display {
case backend::vulkan: return "vulkan";
case backend::d3d11: return "d3d11";
case backend::d3d12: return "d3d12";
case backend::webgl: return "webgl";
case backend::wasmgl: return "wasmgl";
case backend::webgpu: return "webgpu";
case backend::sdl: return "sdl";
default: return "none";
}
@@ -60,21 +62,23 @@ export namespace ra3::display {
case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_display>();
case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
case backend::webgl: return std::make_unique<ra3::webgl::webgl_display>();
case backend::wasmgl: return std::make_unique<ra3::wasmgl::wasmgl_display>();
case backend::webgpu: return std::make_unique<ra3::webgpu::webgpu_display>();
case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
default: return nullptr;
}
}
/** The order in which backends are attempted for a preferred one. */
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 5> {
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 6> {
switch (preferred) {
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl, backend::sdl};
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgl, backend::sdl};
case backend::webgl: return {backend::webgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl};
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
case backend::wasmgl: return {backend::wasmgl, backend::webgpu, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
case backend::webgpu: return {backend::webgpu, backend::wasmgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl};
case backend::vulkan:
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgl, backend::sdl};
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgpu, backend::wasmgl, backend::sdl};
}
}
+2 -1
View File
@@ -19,5 +19,6 @@ export import ra3.terrain;
export import ra3.ui;
export import ra3.vulkan;
export import ra3.dx;
export import ra3.webgl;
export import ra3.wasmgl;
export import ra3.webgpu;
export import ra3.display;
+661
View File
@@ -0,0 +1,661 @@
module;
// Emscripten/GLES3 headers in the global module fragment (C headers). No SDL:
// this backend runs the engine on a Web Worker and renders into an
// OffscreenCanvas transferred from the page, so it must not touch the
// main-thread-only SDL Emscripten port.
#include <GLES3/gl3.h>
#include <emscripten/em_asm.h>
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
// Sized normalized texture formats used by the terrain textures; core in
// WebGL2/GLES3 but missing from some GLES3 headers.
#ifndef GL_R16
#define GL_R16 0x822A
#endif
#ifndef GL_RGBA16
#define GL_RGBA16 0x805B
#endif
#include "wasmgl_glsl_embedded.hpp"
export module ra3.wasmgl;
import std;
export import ra3.core;
export import ra3.render;
export import ra3.terrain;
export import ra3.client;
import ender.log;
/**
* An SDL-free WebGL 2 presentation backend for the wasm build.
*
* The whole runtime runs on a plain Web Worker (started by
* `apps/web/openra3.worker.js`), so `main()` lives there and its virtual
* filesystem is local to the worker: `FS.createLazyFile` (synchronous XHR) is
* legal and the browser fetches each asset only when the engine opens it.
* Note that Emscripten's `PROXY_TO_PTHREAD` cannot be used for this: in a
* pthread every filesystem syscall is proxied to the main browser thread, so
* the engine would read the main thread's FS, not the worker's lazy one.
*
* The page transfers its `#canvas` to the worker as an OffscreenCanvas; this
* module creates the WebGL2 context on it with `emscripten_webgl_create_context`
* and draws the same 2D menu / GPU terrain as the other backends. Input arrives
* over the worker message channel (the page owns the DOM listeners) into the
* `openra3_*` C functions below, which queue `ra3::render::ui_event`s.
*
* It reuses the same GL pipeline/texture code as the desktop GPU backends; only
* the windowing/context/event layer differs.
*/
export namespace ra3::wasmgl {
class wasmgl_display;
/**
* Receives the input events the worker forwards from the page. Each wasm
* backend (WebGL here, WebGPU in `ra3.webgpu`) implements it and registers
* itself on init, so the `openra3_*` C exports live in exactly one module.
*/
class event_sink {
public:
virtual ~event_sink() = default;
virtual void on_key(std::string_view code, bool down) = 0;
virtual void on_text(int codepoint) = 0;
virtual void on_mouse_button(double x, double y, bool left) = 0;
virtual void on_mouse_move(double x, double y, double dx, double dy, bool left) = 0;
virtual void on_wheel(double delta_y) = 0;
virtual void on_resize(int width, int height) = 0;
virtual void on_quit() = 0;
};
namespace detail {
/** The backend that currently receives worker input. */
[[nodiscard]] inline auto sink_slot() -> event_sink *& {
static event_sink *sink = nullptr;
return sink;
}
} // namespace detail
/** Register (or, with null, clear) the backend that receives input events. */
inline auto set_event_sink(event_sink *sink) -> void { detail::sink_slot() = sink; }
namespace detail {
/** Movement keys tracked as "held" for panning (physical codes). */
enum class move_key : std::uint8_t { w, s, a, d, arrow_up, arrow_down, arrow_left, arrow_right, count };
[[nodiscard]] inline auto move_key_index(std::string_view code) -> int {
if (code == "KeyW") return static_cast<int>(move_key::w);
if (code == "KeyS") return static_cast<int>(move_key::s);
if (code == "KeyA") return static_cast<int>(move_key::a);
if (code == "KeyD") return static_cast<int>(move_key::d);
if (code == "ArrowUp") return static_cast<int>(move_key::arrow_up);
if (code == "ArrowDown") return static_cast<int>(move_key::arrow_down);
if (code == "ArrowLeft") return static_cast<int>(move_key::arrow_left);
if (code == "ArrowRight") return static_cast<int>(move_key::arrow_right);
return -1;
}
/** The `ui_key` for an action key, or `none`. */
[[nodiscard]] inline auto key_from_code(std::string_view code) -> ra3::render::ui_key {
using ra3::render::ui_key;
if (code == "ArrowUp") return ui_key::up;
if (code == "ArrowDown") return ui_key::down;
if (code == "ArrowLeft") return ui_key::left;
if (code == "ArrowRight") return ui_key::right;
if (code == "PageUp") return ui_key::page_up;
if (code == "PageDown") return ui_key::page_down;
if (code == "Enter" || code == "NumpadEnter") return ui_key::confirm;
if (code == "Escape") return ui_key::cancel;
if (code == "Tab") return ui_key::tab;
if (code == "Backspace") return ui_key::backspace;
return ui_key::none;
}
/** The single live display (there is only ever one). */
[[nodiscard]] inline auto active_slot() -> wasmgl_display *& {
static wasmgl_display *instance = nullptr;
return instance;
}
/** Camera + terrain uniforms (5 x vec4), matching the other backends. */
[[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
float aspect) -> std::array<float, 20> {
return {camera.target_x,
camera.target_y,
camera.yaw,
camera.height,
camera.pitch,
camera.fov,
terrain.water_z,
terrain.has_water ? 1.0F : 0.0F,
0.45F,
0.35F,
0.82F,
0.38F,
static_cast<float>(terrain.width),
static_cast<float>(terrain.height),
0.0F,
terrain.z_scale,
time_s,
0.0F,
terrain.cell_span,
aspect};
}
/**
* Read `assets.manifest.json` and register every listed file as an
* Emscripten lazy file, so the browser fetches a file only when the
* engine first opens it. Runs on the worker, where synchronous XHR
* (used by `FS.createLazyFile`) is allowed. Returns the number of files
* registered, or -1 on failure. A missing manifest is not an error (the
* caller may have preloaded a full asset tree instead).
*/
[[nodiscard]] inline auto mount_lazy_assets(const std::string &manifest_path, const std::string &assets_root, const std::string &url_prefix)
-> int {
std::ifstream in(manifest_path, std::ios::binary);
if (!in) return 0;
const std::string json{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
if (json.empty()) return 0;
return EM_ASM_INT(
{
try {
var manifest = JSON.parse(UTF8ToString($0));
var root = UTF8ToString($1);
var prefix = UTF8ToString($2);
var files = manifest.files || [];
var n = 0;
for (var i = 0; i < files.length; ++i) {
var rel = files[i];
var full = root + '/' + rel;
var slash = full.lastIndexOf('/');
var dir = full.substring(0, slash);
var name = full.substring(slash + 1);
FS.mkdirTree(dir);
FS.createLazyFile(dir, name, prefix + rel, true, false);
++n;
}
console.log('ra3.wasmgl: mounted ' + n + ' lazy assets under ' + root);
return n;
} catch (e) {
console.error('ra3.wasmgl: lazy asset mount failed: ' + e);
return -1;
}
},
json.c_str(), assets_root.c_str(), url_prefix.c_str());
}
} // namespace detail
/**
* Mount the wasm asset tree lazily (call once, before any asset access).
* On non-wasm builds this does nothing.
*/
inline auto mount_assets(const std::string &manifest_path = "/assets.manifest.json", const std::string &assets_root = "/assets",
const std::string &url_prefix = "assets/") -> int {
#if defined(__EMSCRIPTEN__)
return detail::mount_lazy_assets(manifest_path, assets_root, url_prefix);
#else
(void) manifest_path;
(void) assets_root;
(void) url_prefix;
return 0;
#endif
}
class wasmgl_display final : public ra3::client::display, public event_sink {
public:
wasmgl_display() = default;
wasmgl_display(const wasmgl_display &) = delete;
auto operator=(const wasmgl_display &) -> wasmgl_display & = delete;
~wasmgl_display() override { this->shutdown(); }
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
this->fps_limit_ = options.fps_limit;
// There is no DOM in the worker, so Emscripten's canvas lookup
// (`findEventTarget`, a CSS selector) cannot find #canvas. Register
// the transferred OffscreenCanvas (Module.canvas) as a special target
// so `emscripten_webgl_create_context`/`..._element_size` resolve it.
EM_ASM({
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
specialHTMLTargets['#canvas'] = Module['canvas'];
}
});
EmscriptenWebGLContextAttributes attributes;
emscripten_webgl_init_context_attributes(&attributes);
attributes.majorVersion = 2;
attributes.minorVersion = 0;
attributes.alpha = false;
attributes.depth = true;
attributes.stencil = false;
attributes.antialias = false;
attributes.premultipliedAlpha = false;
attributes.preserveDrawingBuffer = false;
attributes.powerPreference = EM_WEBGL_POWER_PREFERENCE_HIGH_PERFORMANCE;
attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW;
context_ = emscripten_webgl_create_context("#canvas", &attributes);
if (context_ == 0) {
ender::log::error("ra3.wasmgl: emscripten_webgl_create_context failed (is #canvas an OffscreenCanvas in this worker?)");
return false;
}
if (emscripten_webgl_make_context_current(context_) != EMSCRIPTEN_RESULT_SUCCESS) {
ender::log::error("ra3.wasmgl: emscripten_webgl_make_context_current failed");
this->shutdown();
return false;
}
// The page sized the canvas before transfer; fall back to the
// requested size if it is missing.
const auto [width, height] = this->window_size();
if (width <= 0 || height <= 0) {
emscripten_set_canvas_element_size("#canvas", options.width, options.height);
}
if (!this->create_scene_pipeline()) {
this->shutdown();
return false;
}
glGenVertexArrays(1, &vao_);
glBindVertexArray(vao_);
detail::active_slot() = this;
detail::sink_slot() = this;
ender::log::info(std::format("ra3.wasmgl: WebGL worker backend initialized ({})",
reinterpret_cast<const char *>(glGetString(GL_VERSION))));
return true;
}
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
if (frame.empty()) return false;
if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) {
this->upload_scene(frame);
}
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
glViewport(0, 0, window_w, window_h);
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glClearColor(0.05F, 0.06F, 0.08F, 1.0F);
glClear(GL_COLOR_BUFFER_BIT);
this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h);
this->mark_frame();
return true;
}
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
if (events_.empty()) return false;
out = events_.front();
events_.pop_front();
return true;
}
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
int w = 0;
int h = 0;
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
return {w, h};
}
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
const auto held = [this](detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
switch (key) {
case ra3::render::ui_key::up: return held(detail::move_key::w) || held(detail::move_key::arrow_up);
case ra3::render::ui_key::down: return held(detail::move_key::s) || held(detail::move_key::arrow_down);
case ra3::render::ui_key::left: return held(detail::move_key::a) || held(detail::move_key::arrow_left);
case ra3::render::ui_key::right: return held(detail::move_key::d) || held(detail::move_key::arrow_right);
default: return false;
}
}
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
const ra3::client::terrain_overlay &overlay) -> bool override {
if (!terrain_ready_) {
if (!this->create_terrain_pipeline()) return false;
if (!this->create_terrain_textures(terrain)) return false;
terrain_ready_ = true;
ender::log::info(std::format("ra3.wasmgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
}
if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label);
if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap);
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
glViewport(0, 0, window_w, window_h);
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glClearColor(0.45F, 0.55F, 0.70F, 1.0F);
glClear(GL_COLOR_BUFFER_BIT);
glBindVertexArray(vao_);
glUseProgram(terrain_program_);
const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect);
glUniform4fv(terrain_cam_loc_, 5, uniforms.data());
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, height_tex_);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, cell_tex_);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
glUniform1i(terrain_height_loc_, 0);
glUniform1i(terrain_cell_loc_, 1);
glUniform1i(terrain_atlas_loc_, 2);
glDrawArrays(GL_TRIANGLES, 0, 3);
// Overlays (FPS label, corner minimap) use the image program.
const float margin = 12.0F;
this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h);
this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(),
static_cast<float>(window_w) - static_cast<float>(overlay.minimap.width()) - margin, margin, window_w, window_h);
this->mark_frame();
return true;
}
auto shutdown() -> void override {
if (detail::active_slot() == this) detail::active_slot() = nullptr;
if (detail::sink_slot() == this) detail::sink_slot() = nullptr;
if (context_ != 0) {
this->destroy_textures();
if (scene_program_ != 0U) glDeleteProgram(scene_program_);
if (terrain_program_ != 0U) glDeleteProgram(terrain_program_);
if (vao_ != 0U) glDeleteVertexArrays(1, &vao_);
emscripten_webgl_destroy_context(context_);
}
context_ = 0;
vao_ = 0U;
scene_program_ = terrain_program_ = 0U;
scene_rect_loc_ = terrain_cam_loc_ = terrain_height_loc_ = terrain_cell_loc_ = terrain_atlas_loc_ = -1;
terrain_ready_ = false;
scene_w_ = scene_h_ = 0U;
events_.clear();
held_.fill(false);
}
[[nodiscard]] auto name() const -> std::string_view override { return "wasmgl"; }
// ---- worker message entry points (see apps/web/openra3.worker.js) ----
/** A key down/up; `code` is a DOM `KeyboardEvent.code`. */
auto on_key(std::string_view code, bool down) -> void override {
if (const int index = detail::move_key_index(code); index >= 0) {
held_[static_cast<std::size_t>(index)] = down;
}
if (!down) return;
if (const auto key = detail::key_from_code(code); key != ra3::render::ui_key::none) {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::key;
out.key = key;
events_.push_back(out);
}
}
/** A printable character (DOM `KeyboardEvent.charCode`). */
auto on_text(int codepoint) -> void override {
if (codepoint <= 0 || codepoint > 0x7F) return;
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::text;
out.character = static_cast<char>(codepoint);
events_.push_back(out);
}
auto on_mouse_button(double x, double y, bool left) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::mouse_button;
out.x = static_cast<float>(x);
out.y = static_cast<float>(y);
out.left = left;
events_.push_back(out);
}
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::mouse_move;
out.x = static_cast<float>(x);
out.y = static_cast<float>(y);
out.dx = static_cast<float>(dx);
out.dy = static_cast<float>(dy);
out.left = left;
events_.push_back(out);
}
auto on_wheel(double delta_y) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::wheel;
out.wheel = static_cast<float>(-delta_y); // browser +Y is scroll-down; SDL +Y is scroll-up.
events_.push_back(out);
}
auto on_quit() -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::quit;
events_.push_back(out);
}
/** Resize the drawing buffer (backing pixels, already DPR-scaled). */
auto on_resize(int width, int height) -> void override {
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
}
private:
// ---- GL helpers ------------------------------------------------------
[[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint ok = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (ok == GL_FALSE) {
std::array<char, 1024> log{};
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
ender::log::error(std::format("ra3.wasmgl: {} shader compile failed: {}", what, log.data()));
glDeleteShader(shader);
return 0U;
}
return shader;
}
[[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint {
const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what);
const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what);
if (vs == 0U || fs == 0U) {
if (vs != 0U) glDeleteShader(vs);
if (fs != 0U) glDeleteShader(fs);
return 0U;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint ok = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &ok);
if (ok == GL_FALSE) {
std::array<char, 1024> log{};
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
ender::log::error(std::format("ra3.wasmgl: {} program link failed: {}", what, log.data()));
glDeleteProgram(program);
return 0U;
}
return program;
}
[[nodiscard]] auto create_scene_pipeline() -> bool {
scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene");
if (scene_program_ == 0U) return false;
scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect");
glUseProgram(scene_program_);
glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0);
return true;
}
[[nodiscard]] auto create_terrain_pipeline() -> bool {
terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain");
if (terrain_program_ == 0U) return false;
terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data");
terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap");
terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata");
terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas");
return true;
}
/** (Re)create the 2D texture used by the scene/overlay path. */
auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear,
bool wrap) -> void {
if (texture == 0U) glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
if (const GLenum error = glGetError(); error != GL_NO_ERROR) {
ender::log::error(std::format("ra3.wasmgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}",
static_cast<unsigned>(internal), static_cast<unsigned>(format), static_cast<unsigned>(type), w, h,
static_cast<unsigned>(error)));
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
}
auto upload_scene(const ra3::render::image &frame) -> void {
// The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let
// the shader swizzle `.bgra`.
this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(frame.width()),
static_cast<int>(frame.height()), frame.data(), true, false);
scene_w_ = frame.width();
scene_h_ = frame.height();
}
auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void {
if (source.empty()) return;
this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(source.width()),
static_cast<int>(source.height()), source.data(), true, false);
}
[[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool {
this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
static_cast<int>(terrain.height), terrain.heights.data(), false, false);
this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
static_cast<int>(terrain.height), terrain.cell_data.data(), false, false);
if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_);
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast<GLsizei>(terrain.layer_size), static_cast<GLsizei>(terrain.layer_size),
static_cast<GLsizei>(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data());
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT);
return true;
}
auto destroy_textures() -> void {
for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) {
if (*texture != 0U) glDeleteTextures(1, texture);
*texture = 0U;
}
}
/** Publish a coarse frame counter to the page for the fps probe. */
auto mark_frame() const -> void {
++frames_;
if (frames_ % 15U == 0U) {
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
}
}
auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h)
-> void {
glUseProgram(program);
glBindVertexArray(vao_);
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
glUniform4fv(rect_location, 1, rect);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void {
if (texture == 0U || w == 0U || h == 0U) return;
const ra3::render::view_rect rect{x, y, static_cast<float>(w), static_cast<float>(h)};
this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h);
}
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context_ = 0;
GLuint vao_ = 0U;
GLuint scene_program_ = 0U;
GLint scene_rect_loc_ = -1;
GLuint terrain_program_ = 0U;
GLint terrain_cam_loc_ = -1;
GLint terrain_height_loc_ = -1;
GLint terrain_cell_loc_ = -1;
GLint terrain_atlas_loc_ = -1;
GLuint scene_tex_ = 0U;
core::uint32 scene_w_ = 0U;
core::uint32 scene_h_ = 0U;
bool terrain_ready_ = false;
GLuint height_tex_ = 0U;
GLuint cell_tex_ = 0U;
GLuint atlas_tex_ = 0U;
GLuint label_tex_ = 0U;
GLuint minimap_tex_ = 0U;
std::deque<ra3::render::ui_event> events_;
std::array<bool, static_cast<std::size_t>(detail::move_key::count)> held_{};
mutable unsigned frames_ = 0U;
};
// ---- C API called by apps/web/openra3.worker.js --------------------------
/** Key down/up: `code` is a DOM KeyboardEvent.code. */
inline auto post_key(const char *code, int down) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_key(code != nullptr ? code : "", down != 0);
}
/** Printable character (DOM charCode). */
inline auto post_text(int codepoint) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_text(codepoint);
}
inline auto post_mouse_button(double x, double y, int left) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_button(x, y, left != 0);
}
inline auto post_mouse_move(double x, double y, double dx, double dy, int left) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_move(x, y, dx, dy, left != 0);
}
inline auto post_wheel(double delta_y) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_wheel(delta_y);
}
inline auto post_resize(int width, int height) -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_resize(width, height);
}
inline auto post_quit() -> void {
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_quit();
}
} // namespace ra3::wasmgl
extern "C" {
EMSCRIPTEN_KEEPALIVE inline auto openra3_key(const char *code, int down) -> void { ra3::wasmgl::post_key(code, down); }
EMSCRIPTEN_KEEPALIVE inline auto openra3_text(int codepoint) -> void { ra3::wasmgl::post_text(codepoint); }
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_button(double x, double y, int left) -> void { ra3::wasmgl::post_mouse_button(x, y, left); }
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_move(double x, double y, double dx, double dy, int left) -> void {
ra3::wasmgl::post_mouse_move(x, y, dx, dy, left);
}
EMSCRIPTEN_KEEPALIVE inline auto openra3_wheel(double delta_y) -> void { ra3::wasmgl::post_wheel(delta_y); }
EMSCRIPTEN_KEEPALIVE inline auto openra3_resize(int width, int height) -> void { ra3::wasmgl::post_resize(width, height); }
EMSCRIPTEN_KEEPALIVE inline auto openra3_quit() -> void { ra3::wasmgl::post_quit(); }
}
+32
View File
@@ -0,0 +1,32 @@
export module ra3.wasmgl;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* Fallback wasm-worker WebGL backend for builds without Emscripten. `init` fails
* so the caller can select another display; the class name matches the real
* `ra3.wasmgl` module so the backend factory in `ra3.display` compiles
* unchanged.
*/
export namespace ra3::wasmgl {
/** No-op off Emscripten (there is no Emscripten virtual filesystem). */
inline auto mount_assets(const std::string & = "/assets.manifest.json", const std::string & = "/assets",
const std::string & = "assets/") -> int {
return 0;
}
class wasmgl_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "wasmgl(null)"; }
};
}
-461
View File
@@ -1,461 +0,0 @@
module;
// Emscripten/GLES3 headers in the global module fragment (C headers).
#include <SDL3/SDL.h>
#include <GLES3/gl3.h>
// Sized normalized texture formats used by the terrain textures; core in
// WebGL2/GLES3 but missing from some GLES3 headers.
#ifndef GL_R16
#define GL_R16 0x822A
#endif
#ifndef GL_RGBA16
#define GL_RGBA16 0x805B
#endif
#include "glsl_embedded.hpp"
export module ra3.webgl;
import std;
export import ra3.core;
import ra3.render;
import ra3.terrain;
import ra3.client;
import ender.log;
/**
* A WebGL (GLES 3.0) presentation backend, a peer of the Vulkan and Direct3D
* backends. It owns a GL context on an SDL3 canvas and implements the same
* `ra3::client::display` primitives, including the GPU heightfield terrain, so
* the web build gets the same renderer as the desktop ones rather than the
* software SDL blit.
*
* The 2D path draws a fullscreen triangle sampling a software `ra3::render::image`;
* the terrain path ray-marches the heightfield in a fragment shader (the GLSL ES
* port of `terrain.frag`). Textures carry the engine's 0xAARRGGBB pixels, so the
* shaders swizzle `.bgra`.
*/
export namespace ra3::webgl {
namespace detail {
[[nodiscard]] inline auto key_from_sdl(SDL_Keycode key) -> ra3::render::ui_key {
using ra3::render::ui_key;
switch (key) {
case SDLK_UP: return ui_key::up;
case SDLK_DOWN: return ui_key::down;
case SDLK_LEFT: return ui_key::left;
case SDLK_RIGHT: return ui_key::right;
case SDLK_PAGEUP: return ui_key::page_up;
case SDLK_PAGEDOWN: return ui_key::page_down;
case SDLK_RETURN:
case SDLK_KP_ENTER: return ui_key::confirm;
case SDLK_ESCAPE: return ui_key::cancel;
case SDLK_TAB: return ui_key::tab;
case SDLK_BACKSPACE: return ui_key::backspace;
default: return ui_key::none;
}
}
[[nodiscard]] inline auto poll_event(SDL_Window *window, ra3::render::ui_event &out) -> bool {
(void) window;
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_EVENT_QUIT:
out = {};
out.type = ra3::render::ui_event_type::quit;
return true;
case SDL_EVENT_KEY_DOWN:
if (const auto key = key_from_sdl(event.key.key); key != ra3::render::ui_key::none) {
out = {};
out.type = ra3::render::ui_event_type::key;
out.key = key;
return true;
}
break;
case SDL_EVENT_TEXT_INPUT:
if (event.text.text[0] != '\0') {
out = {};
out.type = ra3::render::ui_event_type::text;
out.character = event.text.text[0];
return true;
}
break;
case SDL_EVENT_MOUSE_MOTION:
out = {};
out.type = ra3::render::ui_event_type::mouse_move;
out.x = event.motion.x;
out.y = event.motion.y;
out.dx = event.motion.xrel;
out.dy = event.motion.yrel;
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
return true;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
out = {};
out.type = ra3::render::ui_event_type::mouse_button;
out.x = event.button.x;
out.y = event.button.y;
out.left = event.button.button == SDL_BUTTON_LEFT;
return true;
case SDL_EVENT_MOUSE_WHEEL:
out = {};
out.type = ra3::render::ui_event_type::wheel;
out.wheel = event.wheel.y;
return true;
default:
break;
}
}
return false;
}
[[nodiscard]] inline auto key_down(ra3::render::ui_key key) -> bool {
const bool *keys = SDL_GetKeyboardState(nullptr);
if (keys == nullptr) return false;
switch (key) {
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP];
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN];
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT];
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT];
default: return false;
}
}
/** Camera + terrain uniforms (5 x vec4), matching the other backends. */
[[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
float aspect) -> std::array<float, 20> {
return {camera.target_x,
camera.target_y,
camera.yaw,
camera.height,
camera.pitch,
camera.fov,
terrain.water_z,
terrain.has_water ? 1.0F : 0.0F,
0.45F,
0.35F,
0.82F,
0.38F,
static_cast<float>(terrain.width),
static_cast<float>(terrain.height),
0.0F,
terrain.z_scale,
time_s,
0.0F,
terrain.cell_span,
aspect};
}
} // namespace detail
class webgl_display final : public ra3::client::display {
public:
webgl_display() = default;
webgl_display(const webgl_display &) = delete;
auto operator=(const webgl_display &) -> webgl_display & = delete;
~webgl_display() override { this->shutdown(); }
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
if (!SDL_Init(SDL_INIT_VIDEO)) return false;
this->fps_limit_ = options.fps_limit;
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height,
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
if (window_ == nullptr) {
SDL_Quit();
return false;
}
SDL_RaiseWindow(window_);
context_ = SDL_GL_CreateContext(window_);
if (context_ == nullptr) {
ender::log::error(std::format("ra3.webgl: SDL_GL_CreateContext failed: {}", SDL_GetError()));
this->shutdown();
return false;
}
SDL_GL_SetSwapInterval(options.fps_limit == 0 ? 1 : 0);
if (!this->create_scene_pipeline()) {
this->shutdown();
return false;
}
glGenVertexArrays(1, &vao_);
glBindVertexArray(vao_);
SDL_StartTextInput(window_);
ender::log::info(std::format("ra3.webgl: WebGL backend initialized ({})", reinterpret_cast<const char *>(glGetString(GL_VERSION))));
return true;
}
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
if (frame.empty()) return false;
if (!this->ensure_size()) return false;
if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) {
this->upload_scene(frame);
}
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
glViewport(0, 0, window_w, window_h);
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glClearColor(0.05F, 0.06F, 0.08F, 1.0F);
glClear(GL_COLOR_BUFFER_BIT);
this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h);
SDL_GL_SwapWindow(window_);
return true;
}
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { return detail::poll_event(window_, out); }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
int w = 0;
int h = 0;
SDL_GetWindowSizeInPixels(window_, &w, &h);
return {w, h};
}
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { return detail::key_down(key); }
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
const ra3::client::terrain_overlay &overlay) -> bool override {
if (!terrain_ready_) {
if (!this->create_terrain_pipeline()) return false;
if (!this->create_terrain_textures(terrain)) return false;
terrain_ready_ = true;
ender::log::info(std::format("ra3.webgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
}
if (!this->ensure_size()) return false;
if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label);
if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap);
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
glViewport(0, 0, window_w, window_h);
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glClearColor(0.45F, 0.55F, 0.70F, 1.0F);
glClear(GL_COLOR_BUFFER_BIT);
glBindVertexArray(vao_);
glUseProgram(terrain_program_);
const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect);
glUniform4fv(terrain_cam_loc_, 5, uniforms.data());
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, height_tex_);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, cell_tex_);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
glUniform1i(terrain_height_loc_, 0);
glUniform1i(terrain_cell_loc_, 1);
glUniform1i(terrain_atlas_loc_, 2);
glDrawArrays(GL_TRIANGLES, 0, 3);
// Overlays (FPS label, corner minimap) use the image program.
const float margin = 12.0F;
this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h);
this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(),
static_cast<float>(window_w) - static_cast<float>(overlay.minimap.width()) - margin, margin, window_w, window_h);
SDL_GL_SwapWindow(window_);
return true;
}
auto shutdown() -> void override {
if (context_ != nullptr) {
this->destroy_textures();
if (scene_program_ != 0U) glDeleteProgram(scene_program_);
if (terrain_program_ != 0U) glDeleteProgram(terrain_program_);
if (vao_ != 0U) glDeleteVertexArrays(1, &vao_);
SDL_GL_DestroyContext(context_);
}
if (window_ != nullptr) SDL_DestroyWindow(window_);
SDL_Quit();
window_ = nullptr;
context_ = nullptr;
vao_ = 0U;
scene_program_ = terrain_program_ = 0U;
terrain_ready_ = false;
scene_w_ = scene_h_ = 0U;
swapchain_w_ = swapchain_h_ = 0;
}
[[nodiscard]] auto name() const -> std::string_view override { return "webgl"; }
private:
[[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint ok = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (ok == GL_FALSE) {
std::array<char, 1024> log{};
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
ender::log::error(std::format("ra3.webgl: {} shader compile failed: {}", what, log.data()));
glDeleteShader(shader);
return 0U;
}
return shader;
}
[[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint {
const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what);
const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what);
if (vs == 0U || fs == 0U) {
if (vs != 0U) glDeleteShader(vs);
if (fs != 0U) glDeleteShader(fs);
return 0U;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint ok = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &ok);
if (ok == GL_FALSE) {
std::array<char, 1024> log{};
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
ender::log::error(std::format("ra3.webgl: {} program link failed: {}", what, log.data()));
glDeleteProgram(program);
return 0U;
}
return program;
}
[[nodiscard]] auto create_scene_pipeline() -> bool {
scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene");
if (scene_program_ == 0U) return false;
scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect");
glUseProgram(scene_program_);
glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0);
return true;
}
[[nodiscard]] auto create_terrain_pipeline() -> bool {
terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain");
if (terrain_program_ == 0U) return false;
terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data");
terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap");
terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata");
terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas");
return true;
}
/** (Re)create the 2D texture used by the scene/overlay path. */
auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear,
bool wrap) -> void {
if (texture == 0U) glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
if (const GLenum error = glGetError(); error != GL_NO_ERROR) {
ender::log::error(std::format("ra3.webgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}",
static_cast<unsigned>(internal), static_cast<unsigned>(format), static_cast<unsigned>(type), w, h,
static_cast<unsigned>(error)));
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
}
auto upload_scene(const ra3::render::image &frame) -> void {
// The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let
// the shader swizzle `.bgra`.
this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(frame.width()),
static_cast<int>(frame.height()), frame.data(), true, false);
scene_w_ = frame.width();
scene_h_ = frame.height();
}
auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void {
if (source.empty()) return;
this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(source.width()),
static_cast<int>(source.height()), source.data(), true, false);
}
[[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool {
this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
static_cast<int>(terrain.height), terrain.heights.data(), false, false);
this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
static_cast<int>(terrain.height), terrain.cell_data.data(), false, false);
if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_);
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast<GLsizei>(terrain.layer_size), static_cast<GLsizei>(terrain.layer_size),
static_cast<GLsizei>(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data());
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT);
return true;
}
auto destroy_textures() -> void {
for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) {
if (*texture != 0U) glDeleteTextures(1, texture);
*texture = 0U;
}
}
/** Resize the backing store when the canvas changed; false on failure. */
[[nodiscard]] auto ensure_size() -> bool {
const auto [width, height] = this->window_size();
if (width <= 0 || height <= 0) return true;
swapchain_w_ = width;
swapchain_h_ = height;
return true;
}
auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h)
-> void {
glUseProgram(program);
glBindVertexArray(vao_);
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
glUniform4fv(rect_location, 1, rect);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void {
if (texture == 0U || w == 0U || h == 0U) return;
const ra3::render::view_rect rect{x, y, static_cast<float>(w), static_cast<float>(h)};
this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h);
}
SDL_Window *window_ = nullptr;
SDL_GLContext context_ = nullptr;
GLuint vao_ = 0U;
GLuint scene_program_ = 0U;
GLint scene_rect_loc_ = -1;
GLuint terrain_program_ = 0U;
GLint terrain_cam_loc_ = -1;
GLint terrain_height_loc_ = -1;
GLint terrain_cell_loc_ = -1;
GLint terrain_atlas_loc_ = -1;
GLuint scene_tex_ = 0U;
core::uint32 scene_w_ = 0U;
core::uint32 scene_h_ = 0U;
bool terrain_ready_ = false;
GLuint height_tex_ = 0U;
GLuint cell_tex_ = 0U;
GLuint atlas_tex_ = 0U;
GLuint label_tex_ = 0U;
GLuint minimap_tex_ = 0U;
int swapchain_w_ = 0;
int swapchain_h_ = 0;
};
}
+610
View File
@@ -0,0 +1,610 @@
module;
// Dawn's header-only C++ wrapper for the WebGPU C API, provided by Emscripten's
// emdawnwebgpu port (`--use-port=emdawnwebgpu`). This backend is wasm-only: on
// the web WebGPU accepts WGSL only (SPIR-V is not available), so the shaders are
// WGSL ports of the Vulkan/WebGL ones rather than a reuse of the SPIR-V blobs.
#include <webgpu/webgpu_cpp.h>
#include <emscripten/em_asm.h>
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
#include "webgpu_wgsl_embedded.hpp"
export module ra3.webgpu;
import std;
export import ra3.core;
export import ra3.render;
export import ra3.terrain;
export import ra3.client;
import ra3.wasmgl;
import ender.log;
/**
* A WebGPU presentation backend for the wasm build, a peer of `ra3.wasmgl`.
*
* It runs in the same plain Web Worker and draws into the same transferred
* OffscreenCanvas, but through WebGPU: a surface is created on `#canvas`, a
* swapchain image is acquired each frame and presented. The uniform layout and
* the terrain data plumbing are shared with the Vulkan/WebGL backends
* (`terrain_uniforms`); only the API calls and the WGSL shaders differ.
*
* It reuses `ra3.wasmgl`'s `event_sink` so the page/worker input protocol is the
* same for both wasm backends.
*/
export namespace ra3::webgpu {
class webgpu_display final : public ra3::client::display, public ra3::wasmgl::event_sink {
public:
webgpu_display() = default;
webgpu_display(const webgpu_display &) = delete;
auto operator=(const webgpu_display &) -> webgpu_display & = delete;
~webgpu_display() override { this->shutdown(); }
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
this->fps_limit_ = options.fps_limit;
wgpu::Device device(emscripten_webgpu_get_device());
if (device == nullptr) {
ender::log::warn("ra3.webgpu: no preinitialized WebGPU device");
return false;
}
device_ = std::move(device);
queue_ = device_.GetQueue();
instance_ = wgpu::CreateInstance();
if (instance_ == nullptr) {
ender::log::warn("ra3.webgpu: wgpuCreateInstance failed");
return false;
}
wgpu::EmscriptenSurfaceSourceCanvasHTMLSelector selector;
selector.selector = "#canvas";
// There is no DOM in the worker, so Emscripten's canvas lookup cannot
// find #canvas by CSS selector; register the transferred OffscreenCanvas
// (Module.canvas) as a special target for `wgpuInstanceCreateSurface`.
EM_ASM({
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
specialHTMLTargets['#canvas'] = Module['canvas'];
}
});
wgpu::SurfaceDescriptor surface_descriptor;
surface_descriptor.nextInChain = &selector;
surface_ = instance_.CreateSurface(&surface_descriptor);
if (surface_ == nullptr) {
ender::log::warn("ra3.webgpu: CreateSurface failed (is #canvas an OffscreenCanvas?)");
return false;
}
if (!this->create_pipelines()) {
ender::log::warn("ra3.webgpu: pipeline creation failed");
this->shutdown();
return false;
}
const auto [width, height] = this->window_size();
this->configure(width > 0 ? width : options.width, height > 0 ? height : options.height);
ra3::wasmgl::set_event_sink(this);
ender::log::info("ra3.webgpu: WebGPU backend initialized");
return true;
}
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
if (frame.empty()) return false;
if (changed || !scene_.texture || static_cast<int>(frame.width()) != scene_.width || static_cast<int>(frame.height()) != scene_.height) {
this->upload_image(scene_, frame);
}
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
return this->draw({0.05F, 0.06F, 0.08F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
this->draw_image(pass, scene_, dest, w, h);
});
}
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
if (events_.empty()) return false;
out = events_.front();
events_.pop_front();
return true;
}
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
int w = 0;
int h = 0;
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
return {w, h};
}
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
const auto held = [this](ra3::wasmgl::detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
switch (key) {
case ra3::render::ui_key::up: return held(ra3::wasmgl::detail::move_key::w) || held(ra3::wasmgl::detail::move_key::arrow_up);
case ra3::render::ui_key::down: return held(ra3::wasmgl::detail::move_key::s) || held(ra3::wasmgl::detail::move_key::arrow_down);
case ra3::render::ui_key::left: return held(ra3::wasmgl::detail::move_key::a) || held(ra3::wasmgl::detail::move_key::arrow_left);
case ra3::render::ui_key::right: return held(ra3::wasmgl::detail::move_key::d) || held(ra3::wasmgl::detail::move_key::arrow_right);
default: return false;
}
}
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
const ra3::client::terrain_overlay &overlay) -> bool override {
if (!terrain_ready_) {
if (!this->create_terrain(terrain)) return false;
terrain_ready_ = true;
ender::log::info(std::format("ra3.webgpu: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
}
if (overlay.label_changed) this->upload_image(label_, overlay.label);
if (overlay.minimap_changed) this->upload_image(minimap_, overlay.minimap);
const auto [window_w, window_h] = this->window_size();
if (window_w <= 0 || window_h <= 0) return true;
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
const auto uniforms = detail_uniforms(terrain, camera, time_s, aspect);
queue_.WriteBuffer(terrain_.ubo, 0, uniforms.data(), uniforms.size() * sizeof(float));
const float margin = 12.0F;
return this->draw({0.45F, 0.55F, 0.70F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
pass.SetPipeline(terrain_.pipeline);
pass.SetBindGroup(0, terrain_.bind_group);
pass.Draw(3);
this->draw_image(pass, label_, {margin, margin, static_cast<float>(label_.width), static_cast<float>(label_.height)}, w, h);
this->draw_image(pass, minimap_, {static_cast<float>(w) - static_cast<float>(minimap_.width) - margin, margin,
static_cast<float>(minimap_.width), static_cast<float>(minimap_.height)},
w, h);
});
}
auto shutdown() -> void override {
if (ra3::wasmgl::detail::sink_slot() == this) ra3::wasmgl::set_event_sink(nullptr);
terrain_ready_ = false;
scene_ = {};
label_ = {};
minimap_ = {};
terrain_ = {};
surface_ = nullptr;
queue_ = nullptr;
device_ = nullptr;
instance_ = nullptr;
events_.clear();
held_.fill(false);
}
[[nodiscard]] auto name() const -> std::string_view override { return "webgpu"; }
// ---- worker input (ra3.wasmgl::event_sink) ---------------------------
auto on_key(std::string_view code, bool down) -> void override {
if (const int index = ra3::wasmgl::detail::move_key_index(code); index >= 0) {
held_[static_cast<std::size_t>(index)] = down;
}
if (!down) return;
if (const auto key = ra3::wasmgl::detail::key_from_code(code); key != ra3::render::ui_key::none) {
push_key(key);
}
}
auto on_text(int codepoint) -> void override {
if (codepoint <= 0 || codepoint > 0x7F) return;
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::text;
out.character = static_cast<char>(codepoint);
events_.push_back(out);
}
auto on_mouse_button(double x, double y, bool left) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::mouse_button;
out.x = static_cast<float>(x);
out.y = static_cast<float>(y);
out.left = left;
events_.push_back(out);
}
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::mouse_move;
out.x = static_cast<float>(x);
out.y = static_cast<float>(y);
out.dx = static_cast<float>(dx);
out.dy = static_cast<float>(dy);
out.left = left;
events_.push_back(out);
}
auto on_wheel(double delta_y) -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::wheel;
out.wheel = static_cast<float>(-delta_y);
events_.push_back(out);
}
auto on_quit() -> void override {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::quit;
events_.push_back(out);
}
auto on_resize(int width, int height) -> void override {
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
}
private:
/** A drawn image: its texture, uniform rect buffer and bind group. */
struct image_target {
wgpu::Texture texture;
wgpu::TextureView view;
wgpu::Buffer ubo;
wgpu::BindGroup bind_group;
int width = 0;
int height = 0;
int tex_width = 0;
int tex_height = 0;
};
struct terrain_target {
wgpu::RenderPipeline pipeline;
wgpu::Buffer ubo;
wgpu::Texture height;
wgpu::Texture cell;
wgpu::Texture atlas;
wgpu::TextureView height_view;
wgpu::TextureView cell_view;
wgpu::TextureView atlas_view;
wgpu::BindGroup bind_group;
};
static auto detail_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect)
-> std::array<float, 20> {
return {camera.target_x,
camera.target_y,
camera.yaw,
camera.height,
camera.pitch,
camera.fov,
terrain.water_z,
terrain.has_water ? 1.0F : 0.0F,
0.45F,
0.35F,
0.82F,
0.38F,
static_cast<float>(terrain.width),
static_cast<float>(terrain.height),
0.0F,
terrain.z_scale,
time_s,
0.0F,
terrain.cell_span,
aspect};
}
auto push_key(ra3::render::ui_key key) -> void {
ra3::render::ui_event out;
out.type = ra3::render::ui_event_type::key;
out.key = key;
events_.push_back(out);
}
[[nodiscard]] auto make_shader(const char *wgsl) -> wgpu::ShaderModule {
wgpu::ShaderSourceWGSL source;
source.code = wgsl;
wgpu::ShaderModuleDescriptor descriptor;
descriptor.nextInChain = &source;
return device_.CreateShaderModule(&descriptor);
}
[[nodiscard]] auto create_pipelines() -> bool {
scene_shader_ = this->make_shader(ra3_shaders::webgpu_scene_wgsl);
terrain_shader_ = this->make_shader(ra3_shaders::webgpu_terrain_wgsl);
if (scene_shader_ == nullptr || terrain_shader_ == nullptr) {
ender::log::warn(std::format("ra3.webgpu: shader module creation failed (scene={} terrain={})",
scene_shader_ != nullptr, terrain_shader_ != nullptr));
return false;
}
wgpu::SamplerDescriptor clamp_desc;
clamp_desc.addressModeU = wgpu::AddressMode::ClampToEdge;
clamp_desc.addressModeV = wgpu::AddressMode::ClampToEdge;
clamp_desc.magFilter = wgpu::FilterMode::Linear;
clamp_desc.minFilter = wgpu::FilterMode::Linear;
clamp_desc.lodMaxClamp = 32.0F;
clamp_sampler_ = device_.CreateSampler(&clamp_desc);
wgpu::SamplerDescriptor repeat_desc = clamp_desc;
repeat_desc.addressModeU = wgpu::AddressMode::Repeat;
repeat_desc.addressModeV = wgpu::AddressMode::Repeat;
repeat_sampler_ = device_.CreateSampler(&repeat_desc);
// Scene: uniform(rect) + texture + sampler.
wgpu::BindGroupLayoutEntry scene_entries[3] = {};
scene_entries[0].binding = 0;
scene_entries[0].visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment;
scene_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
scene_entries[0].buffer.minBindingSize = 16;
scene_entries[1].binding = 1;
scene_entries[1].visibility = wgpu::ShaderStage::Fragment;
scene_entries[1].texture.sampleType = wgpu::TextureSampleType::Float;
scene_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
scene_entries[2].binding = 2;
scene_entries[2].visibility = wgpu::ShaderStage::Fragment;
scene_entries[2].sampler.type = wgpu::SamplerBindingType::Filtering;
wgpu::BindGroupLayoutDescriptor scene_layout_desc;
scene_layout_desc.entryCount = 3;
scene_layout_desc.entries = scene_entries;
scene_bind_group_layout_ = device_.CreateBindGroupLayout(&scene_layout_desc);
wgpu::PipelineLayoutDescriptor scene_pipeline_layout_desc;
scene_pipeline_layout_desc.bindGroupLayoutCount = 1;
scene_pipeline_layout_desc.bindGroupLayouts = &scene_bind_group_layout_;
scene_pipeline_layout_ = device_.CreatePipelineLayout(&scene_pipeline_layout_desc);
scene_pipeline_ = this->create_pipeline(scene_pipeline_layout_, scene_shader_, ra3_shaders::webgpu_scene_wgsl);
if (scene_pipeline_ == nullptr) {
ender::log::warn("ra3.webgpu: scene render pipeline creation failed");
}
// Terrain: uniform(data[5]) + R16Uint + RGBA16Uint + RGBA8 array + sampler.
wgpu::BindGroupLayoutEntry terrain_entries[5] = {};
terrain_entries[0].binding = 0;
terrain_entries[0].visibility = wgpu::ShaderStage::Fragment;
terrain_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
terrain_entries[0].buffer.minBindingSize = 80;
terrain_entries[1].binding = 1;
terrain_entries[1].visibility = wgpu::ShaderStage::Fragment;
terrain_entries[1].texture.sampleType = wgpu::TextureSampleType::Uint;
terrain_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
terrain_entries[2].binding = 2;
terrain_entries[2].visibility = wgpu::ShaderStage::Fragment;
terrain_entries[2].texture.sampleType = wgpu::TextureSampleType::Uint;
terrain_entries[2].texture.viewDimension = wgpu::TextureViewDimension::e2D;
terrain_entries[3].binding = 3;
terrain_entries[3].visibility = wgpu::ShaderStage::Fragment;
terrain_entries[3].texture.sampleType = wgpu::TextureSampleType::Float;
terrain_entries[3].texture.viewDimension = wgpu::TextureViewDimension::e2DArray;
terrain_entries[4].binding = 4;
terrain_entries[4].visibility = wgpu::ShaderStage::Fragment;
terrain_entries[4].sampler.type = wgpu::SamplerBindingType::Filtering;
wgpu::BindGroupLayoutDescriptor terrain_layout_desc;
terrain_layout_desc.entryCount = 5;
terrain_layout_desc.entries = terrain_entries;
terrain_bind_group_layout_ = device_.CreateBindGroupLayout(&terrain_layout_desc);
return scene_pipeline_ != nullptr;
}
[[nodiscard]] auto create_pipeline(const wgpu::PipelineLayout &layout, const wgpu::ShaderModule &shader, const char *wgsl) -> wgpu::RenderPipeline {
(void) wgsl;
wgpu::ColorTargetState target;
target.format = surface_format_;
target.writeMask = wgpu::ColorWriteMask::All;
wgpu::FragmentState fragment;
fragment.module = shader;
fragment.entryPoint = "fs_main";
fragment.targetCount = 1;
fragment.targets = &target;
wgpu::RenderPipelineDescriptor descriptor;
descriptor.layout = layout;
descriptor.vertex.module = shader;
descriptor.vertex.entryPoint = "vs_main";
descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
descriptor.fragment = &fragment;
descriptor.multisample.count = 1;
return device_.CreateRenderPipeline(&descriptor);
}
auto configure(int width, int height) -> void {
if (surface_ == nullptr || width <= 0 || height <= 0) return;
if (width == surface_w_ && height == surface_h_) return;
wgpu::SurfaceConfiguration config;
config.device = device_;
config.format = surface_format_;
config.usage = wgpu::TextureUsage::RenderAttachment;
config.width = static_cast<uint32_t>(width);
config.height = static_cast<uint32_t>(height);
config.presentMode = wgpu::PresentMode::Fifo;
surface_.Configure(&config);
surface_w_ = width;
surface_h_ = height;
}
auto make_target(image_target &target) -> void {
wgpu::BufferDescriptor ubo_desc;
ubo_desc.size = 16;
ubo_desc.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
target.ubo = device_.CreateBuffer(&ubo_desc);
}
auto upload_image(image_target &target, const ra3::render::image &frame) -> void {
if (frame.empty()) return;
target.width = static_cast<int>(frame.width());
target.height = static_cast<int>(frame.height());
if (target.texture == nullptr || target.tex_width != target.width || target.tex_height != target.height) {
wgpu::TextureDescriptor descriptor;
descriptor.size = {frame.width(), frame.height(), 1};
descriptor.format = wgpu::TextureFormat::RGBA8Unorm;
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
descriptor.dimension = wgpu::TextureDimension::e2D;
descriptor.mipLevelCount = 1;
descriptor.sampleCount = 1;
target.texture = device_.CreateTexture(&descriptor);
target.view = target.texture.CreateView();
target.tex_width = target.width;
target.tex_height = target.height;
if (!target.ubo) this->make_target(target);
target.bind_group = this->make_image_bind_group(target);
}
wgpu::TexelCopyTextureInfo destination;
destination.texture = target.texture;
wgpu::TexelCopyBufferLayout layout;
layout.bytesPerRow = frame.width() * 4;
layout.rowsPerImage = frame.height();
wgpu::Extent3D size = {frame.width(), frame.height(), 1};
queue_.WriteTexture(&destination, frame.data(), static_cast<size_t>(frame.width()) * frame.height() * 4, &layout, &size);
}
[[nodiscard]] auto make_image_bind_group(image_target &target) -> wgpu::BindGroup {
wgpu::BindGroupEntry entries[3] = {};
entries[0].binding = 0;
entries[0].buffer = target.ubo;
entries[0].size = 16;
entries[1].binding = 1;
entries[1].textureView = target.view;
entries[2].binding = 2;
entries[2].sampler = clamp_sampler_;
wgpu::BindGroupDescriptor descriptor;
descriptor.layout = scene_bind_group_layout_;
descriptor.entryCount = 3;
descriptor.entries = entries;
return device_.CreateBindGroup(&descriptor);
}
auto draw_image(wgpu::RenderPassEncoder &pass, image_target &target, const ra3::render::view_rect &dest, int window_w, int window_h) -> void {
if (target.bind_group == nullptr || window_w <= 0 || window_h <= 0) return;
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
queue_.WriteBuffer(target.ubo, 0, rect, sizeof(rect));
pass.SetPipeline(scene_pipeline_);
pass.SetBindGroup(0, target.bind_group);
pass.Draw(3);
}
[[nodiscard]] auto create_terrain(const ra3::terrain::gpu_terrain &terrain) -> bool {
{
wgpu::BufferDescriptor descriptor;
descriptor.size = 80;
descriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
terrain_.ubo = device_.CreateBuffer(&descriptor);
}
terrain_.height = this->make_texture(wgpu::TextureFormat::R16Uint, terrain.width, terrain.height, 1);
terrain_.cell = this->make_texture(wgpu::TextureFormat::RGBA16Uint, terrain.width, terrain.height, 1);
terrain_.atlas = this->make_texture(wgpu::TextureFormat::RGBA8Unorm, terrain.layer_size, terrain.layer_size, terrain.layer_count);
terrain_.height_view = terrain_.height.CreateView();
terrain_.cell_view = terrain_.cell.CreateView();
terrain_.atlas_view = terrain_.atlas.CreateView();
this->write_texture(terrain_.height, terrain.heights.data(), terrain.width, terrain.height, 1, 2);
this->write_texture(terrain_.cell, terrain.cell_data.data(), terrain.width, terrain.height, 1, 8);
this->write_texture(terrain_.atlas, terrain.layers.data(), terrain.layer_size, terrain.layer_size, terrain.layer_count, 4);
wgpu::BindGroupEntry entries[5] = {};
entries[0].binding = 0;
entries[0].buffer = terrain_.ubo;
entries[0].size = 80;
entries[1].binding = 1;
entries[1].textureView = terrain_.height_view;
entries[2].binding = 2;
entries[2].textureView = terrain_.cell_view;
entries[3].binding = 3;
entries[3].textureView = terrain_.atlas_view;
entries[4].binding = 4;
entries[4].sampler = repeat_sampler_;
wgpu::BindGroupDescriptor bind_desc;
bind_desc.layout = terrain_bind_group_layout_;
bind_desc.entryCount = 5;
bind_desc.entries = entries;
terrain_.bind_group = device_.CreateBindGroup(&bind_desc);
terrain_.pipeline = this->create_pipeline(terrain_pipeline_layout(), terrain_shader_, ra3_shaders::webgpu_terrain_wgsl);
return terrain_.pipeline != nullptr && terrain_.bind_group != nullptr;
}
[[nodiscard]] auto terrain_pipeline_layout() -> wgpu::PipelineLayout {
if (terrain_pipeline_layout_ == nullptr) {
wgpu::PipelineLayoutDescriptor descriptor;
descriptor.bindGroupLayoutCount = 1;
descriptor.bindGroupLayouts = &terrain_bind_group_layout_;
terrain_pipeline_layout_ = device_.CreatePipelineLayout(&descriptor);
}
return terrain_pipeline_layout_;
}
[[nodiscard]] auto make_texture(wgpu::TextureFormat format, uint32_t width, uint32_t height, uint32_t layers) -> wgpu::Texture {
wgpu::TextureDescriptor descriptor;
descriptor.size = {width, height, layers};
descriptor.format = format;
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
descriptor.dimension = wgpu::TextureDimension::e2D;
descriptor.mipLevelCount = 1;
descriptor.sampleCount = 1;
return device_.CreateTexture(&descriptor);
}
auto write_texture(const wgpu::Texture &texture, const void *data, uint32_t width, uint32_t height, uint32_t layers, uint32_t bytes_per_pixel)
-> void {
wgpu::TexelCopyTextureInfo destination;
destination.texture = texture;
wgpu::TexelCopyBufferLayout layout;
layout.bytesPerRow = width * bytes_per_pixel;
layout.rowsPerImage = height;
wgpu::Extent3D size = {width, height, layers};
queue_.WriteTexture(&destination, data, static_cast<size_t>(width) * height * layers * bytes_per_pixel, &layout, &size);
}
/** Clear, run `record`, submit and present. */
template<typename Record>
[[nodiscard]] auto draw(std::array<float, 4> clear, Record &&record) -> bool {
const auto [window_w, window_h] = this->window_size();
this->configure(window_w, window_h);
wgpu::SurfaceTexture surface_texture;
surface_.GetCurrentTexture(&surface_texture);
if (surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal &&
surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) {
return false;
}
wgpu::TextureView view = surface_texture.texture.CreateView();
wgpu::RenderPassColorAttachment color;
color.view = view;
color.depthSlice = 0xFFFFFFFFu; // kWGPU_DEPTH_SLICE_UNDEFINED
color.loadOp = wgpu::LoadOp::Clear;
color.storeOp = wgpu::StoreOp::Store;
color.clearValue = {clear[0], clear[1], clear[2], clear[3]};
wgpu::RenderPassDescriptor pass_desc;
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
wgpu::CommandEncoder encoder = device_.CreateCommandEncoder();
wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&pass_desc);
record(pass, window_w, window_h);
pass.End();
wgpu::CommandBuffer commands = encoder.Finish();
queue_.Submit(1, &commands);
// WebGPU presents implicitly at the end of the frame; the port's
// wgpuSurfacePresent is intentionally unsupported.
this->mark_frame();
return true;
}
/** Publish a coarse frame counter to the page for the fps probe. */
auto mark_frame() const -> void {
++frames_;
if (frames_ % 15U == 0U) {
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
}
}
wgpu::Instance instance_;
wgpu::Device device_;
wgpu::Queue queue_;
wgpu::Surface surface_;
wgpu::TextureFormat surface_format_ = wgpu::TextureFormat::BGRA8Unorm;
int surface_w_ = 0;
int surface_h_ = 0;
wgpu::ShaderModule scene_shader_;
wgpu::ShaderModule terrain_shader_;
wgpu::BindGroupLayout scene_bind_group_layout_;
wgpu::BindGroupLayout terrain_bind_group_layout_;
wgpu::PipelineLayout scene_pipeline_layout_;
wgpu::PipelineLayout terrain_pipeline_layout_;
wgpu::RenderPipeline scene_pipeline_;
wgpu::Sampler clamp_sampler_;
wgpu::Sampler repeat_sampler_;
image_target scene_;
image_target label_;
image_target minimap_;
terrain_target terrain_;
bool terrain_ready_ = false;
std::deque<ra3::render::ui_event> events_;
std::array<bool, static_cast<std::size_t>(ra3::wasmgl::detail::move_key::count)> held_{};
mutable unsigned frames_ = 0U;
};
}
@@ -1,18 +1,19 @@
export module ra3.webgl;
export module ra3.webgpu;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
export import ra3.render;
export import ra3.client;
/**
* Fallback WebGL backend for builds without Emscripten. `init` fails so the
* Fallback WebGPU backend for builds without Emscripten. `init` fails so the
* caller can select another display; the class name matches the real
* `ra3.webgl` module so the backend factory in `ra3.display` compiles unchanged.
* `ra3.webgpu` module so the backend factory in `ra3.display` compiles
* unchanged.
*/
export namespace ra3::webgl {
class webgl_display final : public ra3::client::display {
export namespace ra3::webgpu {
class webgpu_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
@@ -20,6 +21,6 @@ export namespace ra3::webgl {
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "webgl(null)"; }
[[nodiscard]] auto name() const -> std::string_view override { return "webgpu(null)"; }
};
}