browser DOM support

This commit is contained in:
Jorijn van der Graaf 2026-05-18 02:07:48 +02:00
commit 5352ef69a2
37 changed files with 2637 additions and 59 deletions

View file

@ -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