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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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)"; }
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 = ⌖
|
||||
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)"; }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user