browser DOM support
This commit is contained in:
parent
3859c43ce3
commit
5352ef69a2
37 changed files with 2637 additions and 59 deletions
|
|
@ -44,19 +44,28 @@ module;
|
|||
#include <windows.h>
|
||||
#include <cassert>
|
||||
#endif
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
#include "vulkan/vulkan.h"
|
||||
#endif
|
||||
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
||||
#include "vulkan/vulkan_wayland.h"
|
||||
#endif
|
||||
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
|
||||
#include "vulkan/vulkan_win32.h"
|
||||
#endif
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include "../lib/stb_image_write.h"
|
||||
#endif
|
||||
module Crafter.Graphics:Window_impl;
|
||||
import :Window;
|
||||
import :Device;
|
||||
import :Gamepad;
|
||||
// The Vulkan-typed partitions exist as empty stubs in DOM builds (the
|
||||
// build system scans `import :X` statements pre-preprocessor, so even
|
||||
// guarded imports must resolve to a real partition). Their bodies are
|
||||
// gated under !CRAFTER_GRAPHICS_WINDOW_DOM so DOM compiles see empty
|
||||
// modules. Cheap.
|
||||
import :VulkanTransition;
|
||||
import :DescriptorHeapVulkan;
|
||||
import :RenderPass;
|
||||
|
|
@ -64,7 +73,7 @@ import std;
|
|||
|
||||
using namespace Crafter;
|
||||
|
||||
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
||||
void randname(char *buf) {
|
||||
struct timespec ts;
|
||||
|
|
@ -1246,4 +1255,277 @@ void Window::SaveFrame(const std::filesystem::path& path) {
|
|||
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
|
||||
vkDestroyBuffer(Device::device, stagingBuf, nullptr);
|
||||
vkFreeMemory(Device::device, stagingMem, nullptr);
|
||||
}
|
||||
}
|
||||
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DOM backend
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// In DOM mode the "window" IS the browser page; there is no separate
|
||||
// surface to create, no swapchain to manage, no GPU pipeline to wait on.
|
||||
// All Window does here is:
|
||||
// - mirror requested title onto document.title
|
||||
// - register itself with the JS bridge so DOM-level events route into
|
||||
// its event objects
|
||||
// - hand the frame loop to requestAnimationFrame; `Update` runs on
|
||||
// each rAF tick when `updating` is true
|
||||
//
|
||||
// The C exports (__crafterDom_*) below are how the JS bridge reaches
|
||||
// back into the live Window instance. We keep a process-global pointer
|
||||
// for V1 — only one Window per page — and lookups are O(1).
|
||||
|
||||
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
|
||||
// The JS runtime initializes itself before main() runs, so there's
|
||||
// nothing to do here. Defined as a no-op so user code calling
|
||||
// `Device::Initialize()` links the same way it does on native.
|
||||
void Device::Initialize() {}
|
||||
|
||||
namespace {
|
||||
Window* g_domWindow = nullptr;
|
||||
}
|
||||
|
||||
namespace Crafter::DomEnv {
|
||||
__attribute__((import_module("env"), import_name("domAttachWindow")))
|
||||
void domAttachWindow(std::int32_t handle);
|
||||
__attribute__((import_module("env"), import_name("domSetTitle")))
|
||||
void domSetTitle(const char* title, std::int32_t titleLen);
|
||||
__attribute__((import_module("env"), import_name("domGetInnerWidth")))
|
||||
std::int32_t domGetInnerWidth();
|
||||
__attribute__((import_module("env"), import_name("domGetInnerHeight")))
|
||||
std::int32_t domGetInnerHeight();
|
||||
__attribute__((import_module("env"), import_name("domStartFrameLoop")))
|
||||
void domStartFrameLoop();
|
||||
__attribute__((import_module("env"), import_name("domStopFrameLoop")))
|
||||
void domStopFrameLoop();
|
||||
}
|
||||
|
||||
// Compile-time string hash matching what dom-env.js sends through. The
|
||||
// JS bridge marshals `KeyboardEvent.code` as a UTF-8 string; we hash it
|
||||
// to a 32-bit KeyCode here so the same value compares equal against the
|
||||
// table in :Keys (DOM branch). FNV-1a, deterministic, no allocation.
|
||||
namespace {
|
||||
constexpr KeyCode HashKeyCode(const char* p, std::size_t n) {
|
||||
std::uint32_t h = 2166136261u;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
h ^= static_cast<std::uint8_t>(p[i]);
|
||||
h *= 16777619u;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
}
|
||||
|
||||
Window::Window(std::uint32_t w, std::uint32_t h, const std::string_view title)
|
||||
: Window(w, h) {
|
||||
SetTitle(title);
|
||||
}
|
||||
|
||||
Window::Window(std::uint32_t w, std::uint32_t h) : width(w), height(h) {
|
||||
if (g_domWindow != nullptr) {
|
||||
// Only one Window per page in V1. Subsequent constructions are
|
||||
// a programming error — log loudly and clobber the previous
|
||||
// pointer so the new Window's events at least fire.
|
||||
// (stderr isn't reachable via `import std;` on wasi-sdk yet; just log
|
||||
// to cout. The browser console pipes both to the same place.)
|
||||
std::println("Crafter::Window: only one DOM Window per page; "
|
||||
"overwriting the previous instance.");
|
||||
}
|
||||
g_domWindow = this;
|
||||
|
||||
// Use the browser-reported viewport size as the initial dimensions
|
||||
// unless the caller asked for something specific. Browser owns the
|
||||
// real size; w/h passed in are advisory.
|
||||
if (w == 0 || h == 0) {
|
||||
width = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerWidth());
|
||||
height = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerHeight());
|
||||
}
|
||||
|
||||
// The handle passed to attach is just a non-zero token the JS side
|
||||
// includes back in every dispatcher call. We don't use it on the
|
||||
// C++ side (g_domWindow is the lookup) but it has to be non-zero so
|
||||
// the JS bridge treats the window as "attached".
|
||||
Crafter::DomEnv::domAttachWindow(1);
|
||||
|
||||
lastMousePos = {0, 0};
|
||||
currentMousePos = {0, 0};
|
||||
mouseDelta = {0, 0};
|
||||
}
|
||||
|
||||
Window::~Window() {
|
||||
// Clear the global pointer iff it still references us — defensive
|
||||
// against a stack-allocated Window in main() that goes out of scope
|
||||
// while rAF / DOM event callbacks are still queued. After this, the
|
||||
// JS-side dispatchers (__crafterDom_*) early-return harmlessly. A
|
||||
// shrill warning to the console flags the (almost certainly
|
||||
// unintended) lifetime mistake so the user notices before everything
|
||||
// mysteriously stops working.
|
||||
if (g_domWindow == this) {
|
||||
g_domWindow = nullptr;
|
||||
std::println("Crafter::Window: destroyed while DOM mode is active. "
|
||||
"Browser events will no-op until a new Window is constructed. "
|
||||
"Did you forget to put the Window in `static` / `new`d storage?");
|
||||
}
|
||||
}
|
||||
|
||||
void Window::SetTitle(const std::string_view title) {
|
||||
Crafter::DomEnv::domSetTitle(title.data(), static_cast<std::int32_t>(title.size()));
|
||||
}
|
||||
|
||||
void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
|
||||
if (newWidth == 0 || newHeight == 0) return;
|
||||
if (newWidth == width && newHeight == height) return;
|
||||
width = newWidth;
|
||||
height = newHeight;
|
||||
onResize.Invoke();
|
||||
}
|
||||
|
||||
void Window::SetCursorImage(std::uint16_t /*cw*/, std::uint16_t /*ch*/,
|
||||
std::uint16_t /*hx*/, std::uint16_t /*hy*/,
|
||||
const std::uint8_t* /*pixels*/) {
|
||||
// V1: not wired. The natural impl is to base64-encode an inline PNG
|
||||
// and assign it via document.body.style.cursor = `url(data:...) hx hy, auto`.
|
||||
// Left for a follow-up so the first DOM build can ship without an
|
||||
// inline PNG encoder.
|
||||
}
|
||||
|
||||
void Window::SetDefaultCursor() {
|
||||
// Mirror SetCursorImage stub. Future impl: clear body.style.cursor.
|
||||
}
|
||||
|
||||
void Window::StartSync() {
|
||||
// Hand the loop to rAF. Returns immediately; the wasm `_start`
|
||||
// (main) finishes, and the runtime keeps the module alive while
|
||||
// the JS-side rAF chain ticks `__crafterDom_frame`.
|
||||
Crafter::DomEnv::domStartFrameLoop();
|
||||
}
|
||||
|
||||
void Window::StartUpdate() {
|
||||
lastFrameBegin = std::chrono::high_resolution_clock::now();
|
||||
updating = true;
|
||||
}
|
||||
|
||||
void Window::StopUpdate() {
|
||||
updating = false;
|
||||
Crafter::DomEnv::domStopFrameLoop();
|
||||
}
|
||||
|
||||
void Window::Update() {
|
||||
auto now = std::chrono::high_resolution_clock::now();
|
||||
mouseDelta = {currentMousePos.x - lastMousePos.x,
|
||||
currentMousePos.y - lastMousePos.y};
|
||||
currentFrameTime = {now, now - lastFrameBegin};
|
||||
onUpdate.Invoke(currentFrameTime);
|
||||
lastMousePos = currentMousePos;
|
||||
lastFrameBegin = now;
|
||||
}
|
||||
|
||||
void Window::Render() {
|
||||
// V1: no rendering in DOM mode. Kept as a callable no-op so
|
||||
// existing cross-platform code paths (e.g. main loops calling
|
||||
// window.Render() before window.StartSync()) compile. V2 will
|
||||
// hang the WebGPU command-submit here.
|
||||
}
|
||||
|
||||
// ─── C exports the JS bridge calls back into ──────────────────────────
|
||||
|
||||
extern "C" {
|
||||
__attribute__((export_name("__crafterDom_frame")))
|
||||
void __crafterDom_frame(std::int32_t /*handle*/) {
|
||||
if (!g_domWindow) return;
|
||||
Gamepad::Tick();
|
||||
g_domWindow->onBeforeUpdate.Invoke();
|
||||
if (g_domWindow->updating) {
|
||||
g_domWindow->Update();
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_mouseMove")))
|
||||
void __crafterDom_mouseMove(std::int32_t /*handle*/, double x, double y) {
|
||||
if (!g_domWindow) return;
|
||||
g_domWindow->currentMousePos = {static_cast<float>(x), static_cast<float>(y)};
|
||||
g_domWindow->onMouseMove.Invoke();
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_mouseDown")))
|
||||
void __crafterDom_mouseDown(std::int32_t /*handle*/, std::int32_t button) {
|
||||
if (!g_domWindow) return;
|
||||
// MouseEvent.button: 0=left, 1=middle, 2=right
|
||||
if (button == 0) {
|
||||
g_domWindow->mouseLeftHeld = true;
|
||||
g_domWindow->onMouseLeftClick.Invoke();
|
||||
} else if (button == 2) {
|
||||
g_domWindow->mouseRightHeld = true;
|
||||
g_domWindow->onMouseRightClick.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_mouseUp")))
|
||||
void __crafterDom_mouseUp(std::int32_t /*handle*/, std::int32_t button) {
|
||||
if (!g_domWindow) return;
|
||||
if (button == 0) {
|
||||
g_domWindow->mouseLeftHeld = false;
|
||||
g_domWindow->onMouseLeftRelease.Invoke();
|
||||
} else if (button == 2) {
|
||||
g_domWindow->mouseRightHeld = false;
|
||||
g_domWindow->onMouseRightRelease.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_wheel")))
|
||||
void __crafterDom_wheel(std::int32_t /*handle*/, double deltaY) {
|
||||
if (!g_domWindow) return;
|
||||
// Window::onMouseScroll is uint32 — preserve sign via two's complement.
|
||||
g_domWindow->onMouseScroll.Invoke(static_cast<std::uint32_t>(static_cast<std::int32_t>(deltaY)));
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_keyDown")))
|
||||
void __crafterDom_keyDown(std::int32_t /*handle*/,
|
||||
const char* codePtr, std::int32_t codeLen,
|
||||
const char* keyPtr, std::int32_t keyLen,
|
||||
bool repeat) {
|
||||
if (!g_domWindow) return;
|
||||
KeyCode code = HashKeyCode(codePtr, static_cast<std::size_t>(codeLen));
|
||||
if (repeat) {
|
||||
g_domWindow->onRawKeyHold.Invoke(code);
|
||||
} else {
|
||||
g_domWindow->heldKeys.insert(code);
|
||||
g_domWindow->onRawKeyDown.Invoke(code);
|
||||
}
|
||||
// KeyboardEvent.key is the printable form. Forward as UTF-8
|
||||
// text input for non-control keys so onTextInput drives input
|
||||
// fields the same way it does on Win32 / Wayland.
|
||||
if (keyLen == 1 && static_cast<unsigned char>(keyPtr[0]) >= 0x20
|
||||
&& static_cast<unsigned char>(keyPtr[0]) != 0x7F) {
|
||||
g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast<std::size_t>(keyLen)));
|
||||
} else if (keyLen > 1) {
|
||||
// Multi-byte UTF-8 (non-ASCII printable). Forward as-is —
|
||||
// dom-env.js always sends valid UTF-8.
|
||||
g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast<std::size_t>(keyLen)));
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_keyUp")))
|
||||
void __crafterDom_keyUp(std::int32_t /*handle*/, const char* codePtr, std::int32_t codeLen) {
|
||||
if (!g_domWindow) return;
|
||||
KeyCode code = HashKeyCode(codePtr, static_cast<std::size_t>(codeLen));
|
||||
g_domWindow->heldKeys.erase(code);
|
||||
g_domWindow->onRawKeyUp.Invoke(code);
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_resize")))
|
||||
void __crafterDom_resize(std::int32_t /*handle*/, std::int32_t newW, std::int32_t newH) {
|
||||
if (!g_domWindow) return;
|
||||
g_domWindow->Resize(static_cast<std::uint32_t>(newW),
|
||||
static_cast<std::uint32_t>(newH));
|
||||
}
|
||||
|
||||
__attribute__((export_name("__crafterDom_close")))
|
||||
void __crafterDom_close(std::int32_t /*handle*/) {
|
||||
if (!g_domWindow) return;
|
||||
g_domWindow->open = false;
|
||||
g_domWindow->onClose.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
Loading…
Add table
Add a link
Reference in a new issue