new input system

This commit is contained in:
Jorijn van der Graaf 2026-05-12 00:24:48 +02:00
commit ac2eb7fb0a
31 changed files with 3292 additions and 781 deletions

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,189 @@
// End-to-end demo of GPU asset decompression via VK_EXT_memory_decompression.
//
// Walks the full compressed-asset pipeline:
// 1. Build a procedural cube + checkerboard texture in memory.
// 2. SaveCompressed → on-disk .cmesh / .ctex (GDeflate streams).
// 3. LoadCompressed* → CompressedMeshAsset / CompressedTextureAsset.
// 4. CPU verification: blob → DecompressCPU → byte-equal to source.
// 5. GPU: Mesh::Build(compressedMesh, cmd) and ImageVulkan::Update(compressedTex, cmd, layout).
// Picks the GPU path on NVIDIA (extension supported) or the CPU fallback elsewhere.
//
// No rendering — the goal is to exercise the asset pipeline and prove the
// new APIs round-trip. Validation layers are enabled by Crafter.Graphics in
// debug builds, so any VUID violation surfaces during FinishInit().
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Asset;
import Crafter.Math;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
// Procedural unit cube centered at origin, scaled so it's visible if the
// example is ever wired into a renderer. 8 unique positions, 36 indices.
MeshAsset<void> MakeCubeMesh() {
MeshAsset<void> mesh;
mesh.vertexes = {
{-50.f, -50.f, -50.f}, { 50.f, -50.f, -50.f},
{ 50.f, 50.f, -50.f}, {-50.f, 50.f, -50.f},
{-50.f, -50.f, 50.f}, { 50.f, -50.f, 50.f},
{ 50.f, 50.f, 50.f}, {-50.f, 50.f, 50.f},
};
mesh.indexes = {
// -Z
0, 1, 2, 0, 2, 3,
// +Z
4, 6, 5, 4, 7, 6,
// -X
0, 3, 7, 0, 7, 4,
// +X
1, 5, 6, 1, 6, 2,
// -Y
0, 4, 5, 0, 5, 1,
// +Y
3, 2, 6, 3, 6, 7,
};
return mesh;
}
struct RGBA8 { std::uint8_t r, g, b, a; };
// 256×256 checkerboard with smooth radial fade — compressible enough to make
// the GDeflate ratio interesting, structured enough that the demo is
// meaningful.
TextureAsset<RGBA8> MakeCheckerboard() {
TextureAsset<RGBA8> tex;
tex.sizeX = 256;
tex.sizeY = 256;
tex.opaque = OpaqueType::FullyOpaque;
tex.pixels.resize(tex.sizeX * tex.sizeY);
for (std::uint32_t y = 0; y < tex.sizeY; ++y) {
for (std::uint32_t x = 0; x < tex.sizeX; ++x) {
bool checker = ((x / 32) ^ (y / 32)) & 1;
float dx = float(x) - 128.0f;
float dy = float(y) - 128.0f;
float d = std::sqrt(dx * dx + dy * dy) / 180.0f;
float fade = std::clamp(1.0f - d, 0.0f, 1.0f);
std::uint8_t base = checker ? 220 : 40;
std::uint8_t lit = static_cast<std::uint8_t>(base * fade + 16);
tex.pixels[y * tex.sizeX + x] = RGBA8{lit, lit, lit, 255};
}
}
return tex;
}
double Ratio(std::size_t a, std::size_t b) {
return b == 0 ? 0.0 : 100.0 * double(a) / double(b);
}
} // namespace
int main() {
const fs::path meshPath = "cube.cmesh";
const fs::path texPath = "checker.ctex";
// ── 1. Build procedural assets ─────────────────────────────────────
MeshAsset<void> srcMesh = MakeCubeMesh();
TextureAsset<RGBA8> srcTex = MakeCheckerboard();
const std::size_t srcMeshBytes =
srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0])
+ srcMesh.indexes.size() * sizeof(srcMesh.indexes[0]);
const std::size_t srcTexBytes =
srcTex.pixels.size() * sizeof(RGBA8);
std::println("Procedural cube: {} vertices, {} indices ({} bytes)",
srcMesh.vertexes.size(), srcMesh.indexes.size(), srcMeshBytes);
std::println("Procedural checker: {}x{} RGBA8 ({} bytes)",
srcTex.sizeX, srcTex.sizeY, srcTexBytes);
// ── 2. SaveCompressed ──────────────────────────────────────────────
srcMesh.SaveCompressed(meshPath);
srcTex.SaveCompressed(texPath);
const std::size_t meshFileSize = fs::file_size(meshPath);
const std::size_t texFileSize = fs::file_size(texPath);
std::println("Saved {}: {} bytes ({:.1f}% of raw)",
meshPath.string(), meshFileSize, Ratio(meshFileSize, srcMeshBytes));
std::println("Saved {}: {} bytes ({:.1f}% of raw)",
texPath.string(), texFileSize, Ratio(texFileSize, srcTexBytes));
// ── 3. LoadCompressed ──────────────────────────────────────────────
CompressedMeshAsset loadedMesh = LoadCompressedMesh(meshPath);
CompressedTextureAsset loadedTex = LoadCompressedTexture(texPath);
if (loadedMesh.vertexCount != srcMesh.vertexes.size()
|| loadedMesh.indexCount != srcMesh.indexes.size()) {
std::println(std::cerr,"[FAIL] mesh header mismatch after LoadCompressedMesh");
return 1;
}
if (loadedTex.sizeX != srcTex.sizeX || loadedTex.sizeY != srcTex.sizeY) {
std::println(std::cerr,"[FAIL] texture header mismatch after LoadCompressedTexture");
return 1;
}
std::println("Loaded headers OK.");
// ── 4. CPU roundtrip verification ──────────────────────────────────
{
std::vector<Vector<float, 3, 3>> v(loadedMesh.vertexCount);
std::vector<std::uint32_t> i(loadedMesh.indexCount);
std::array<std::span<std::byte>, 3> outputs = {
std::as_writable_bytes(std::span(v)),
std::as_writable_bytes(std::span(i)),
std::span<std::byte>{},
};
Compression::DecompressCPU(loadedMesh.blob,
std::span(outputs).first(loadedMesh.blob.regions.size()));
if (v != srcMesh.vertexes || i != srcMesh.indexes) {
std::println(std::cerr,"[FAIL] CPU mesh decompress != source");
return 1;
}
}
{
std::vector<RGBA8> p(loadedTex.sizeX * loadedTex.sizeY);
std::array<std::span<std::byte>, 1> outputs = {
std::as_writable_bytes(std::span(p)),
};
Compression::DecompressCPU(loadedTex.blob, outputs);
if (std::memcmp(p.data(), srcTex.pixels.data(), srcTexBytes) != 0) {
std::println(std::cerr,"[FAIL] CPU texture decompress != source");
return 1;
}
}
std::println("CPU roundtrip OK (mesh + texture decode byte-equal).");
// ── 5. GPU path via Mesh::Build / ImageVulkan::Update ──────────────
Device::Initialize();
Window window(800, 600, "Decompression");
std::println("VK_EXT_memory_decompression: {}",
Device::memoryDecompressionSupported ? "AVAILABLE → GPU path" : "absent → CPU fallback");
VkCommandBuffer cmd = window.StartInit();
DescriptorHeapVulkan heap;
heap.Initialize(/*images*/ 1, /*buffers*/ 1, /*samplers*/ 0);
window.descriptorHeap = &heap;
// Mesh cubeMesh;
// cubeMesh.Build(loadedMesh, cmd);
ImageVulkan<RGBA8> checkerImage;
checkerImage.Create(
loadedTex.sizeX, loadedTex.sizeY, /*mipLevels*/ 1, cmd,
VK_FORMAT_R8G8B8A8_UNORM,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
checkerImage.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
window.FinishInit();
std::println("GPU init submit + wait completed without validation errors.");
//std::println("BLAS device address: 0x{:x}", cubeMesh.blasAddr);
// Cleanup happens via Window/Device dtors when they go out of scope.
return 0;
}

View file

@ -11,15 +11,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
Configuration cfg; Configuration cfg;
cfg.path = "./"; cfg.path = "./";
cfg.name = "OptionsSpike"; cfg.name = "Decompression";
cfg.outputName = "OptionsSpike"; cfg.outputName = "Decompression";
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics }; cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {}; std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" }; std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("font.ttf");
return cfg; return cfg;
} }

View file

@ -0,0 +1,241 @@
// =====================================================================
// InputSystem — guided tour of Crafter::Input
// =====================================================================
//
// Run this with a window focused. The console narrates each event as
// it fires; the on-screen window only exists so the OS routes keyboard
// and mouse input to us. Plug a gamepad in (Xbox / DualShock /
// Switch Pro) and it will be picked up live.
//
// What this demo demonstrates, in order:
//
// 1. Three runtime layers stacked end-to-end:
// - Crafter::Key(CrafterKeys::X) compile-time abstract → raw
// platform KeyCode
// - Window::onRawKeyDown raw keyboard channel for UI /
// text input (NOT used for game
// input — actions handle that)
// - Crafter::Input::Map named actions → bindings →
// events
// - Crafter::Gamepad hot-plug, button + axis state,
// rumble
//
// 2. All four action types: Button, Axis1D, Vector2, with multiple
// bindings per action (any-of semantics — press Space OR press
// gamepad-A; both trigger Jump).
//
// 3. Composite bindings: WASDBind (4 keys → Vector2),
// GamepadStickBind (analog stick → Vector2), MouseDeltaBind
// (mouse movement → Vector2).
//
// 4. Runtime rebinding: press R to enter rebind mode; the next key /
// mouse button / gamepad button you press becomes the new Jump
// binding. The captured input does NOT fire Jump that frame.
//
// 5. Serialization: bindings are round-tripped to text strings at
// startup. Use these as the on-disk format for your settings file.
//
// 6. Hot-plug: connect / disconnect a gamepad while running. The
// Map's polling-based evaluation seamlessly switches over —
// bindings to a vanished gamepad become inert until reconnect.
//
// Controls:
// Space / Gamepad A → Jump
// WASD / Left stick → Move
// Mouse-Left / Right Trigger → Fire
// Mouse delta / Right stick → Look
// Mouse wheel → Zoom (Axis1D)
// R → Start rebinding "Jump" — press
// any key/button next to assign it
// Esc → Quit
//
// Output is intentionally chatty so you can SEE the system working.
// In a real game, replace the print statements with your gameplay
// code — that's it.
// =====================================================================
import Crafter.Graphics;
import Crafter.Event; // EventListener<>: Crafter.Graphics imports
import Crafter.Math; // Vector<float,2>: it transitively, but
// C++20 module-reachability rules require
// us to name them explicitly to instantiate
// the templates in this TU.
import std;
using namespace Crafter;
using namespace Crafter::Input;
int main() {
Device::Initialize();
Window window(640, 360, "Crafter Input — give the window focus");
// =================================================================
// Build the action map.
//
// AddAction returns a reference; we keep it so we can subscribe.
// The map owns the actions (vector<unique_ptr>); references stay
// valid for the map's lifetime.
// =================================================================
Map map;
Action& jump = map.AddAction("Jump", ActionType::Button);
Action& move = map.AddAction("Move", ActionType::Vector2);
Action& fire = map.AddAction("Fire", ActionType::Button);
Action& look = map.AddAction("Look", ActionType::Vector2);
Action& zoom = map.AddAction("Zoom", ActionType::Axis1D);
Action& reset = map.AddAction("Reset", ActionType::Button);
Action& quit = map.AddAction("Quit", ActionType::Button);
// =================================================================
// Default bindings.
//
// Key(CrafterKeys::X) is `consteval` — these literals fold to
// per-platform scancodes at compile time, no runtime translation.
// Multiple bindings per action are OR'd: pressing EITHER triggers
// the action.
//
// gamepadId == 0 is a sentinel here meaning "first connected
// gamepad". For multi-player you'd save the stable id from a
// specific Device and use that.
// =================================================================
jump.bindings = {
KeyBind{ Key(CrafterKeys::Space) },
GamepadButtonBind{ 0, Gamepad::Button::South },
};
move.bindings = {
WASDBind{
Key(CrafterKeys::W), Key(CrafterKeys::S),
Key(CrafterKeys::A), Key(CrafterKeys::D),
},
GamepadStickBind{ 0, Gamepad::Stick::Left },
};
fire.bindings = {
MouseButtonBind{ 0 }, // left click
GamepadAxisBind{ 0, Gamepad::Axis::RightTrigger, false }, // analog R2
};
look.bindings = {
MouseDeltaBind{ 0.01f }, // scale down
GamepadStickBind{ 0, Gamepad::Stick::Right },
};
zoom.bindings = {
MouseScrollBind{},
};
reset.bindings = { KeyBind{ Key(CrafterKeys::R) } };
quit.bindings = { KeyBind{ Key(CrafterKeys::Escape) } };
// =================================================================
// Serialization round-trip — call sites can save these strings to
// disk and load them back into Action::bindings later.
//
// IMPORTANT: KeyCode values are platform-specific (Win32 scancodes
// vs. Wayland kernel keycodes). A saved file from one platform
// does NOT load on the other. Store your config under platform-
// specific paths.
// =================================================================
std::println("─── Default bindings (serialized form) ───");
for (auto& a : map.actions) {
for (auto& b : a->bindings) {
std::string s = BindingToString(b);
// Parse it back to prove the format roundtrips.
auto parsed = BindingFromString(s);
std::println(" {:<6} {} (roundtrip OK: {})",
a->name, s, parsed.has_value());
}
}
std::println("");
// =================================================================
// Subscribe to action events.
//
// EventListeners use the same RAII pattern as the rest of the
// library — declare them in scope, they unsubscribe when they go
// out of scope. Capturing references to the Actions is safe
// because the Map (and therefore its actions) outlive these
// listeners (both are in main()'s scope).
// =================================================================
EventListener<void> jumpPerf(&jump.onPerformed,
[] { std::println("[Jump] performed"); });
EventListener<void> jumpCanc(&jump.onCanceled,
[] { std::println("[Jump] canceled"); });
EventListener<Vector<float,2>> moveChanged(&move.onVector2Changed,
[](Vector<float,2> v) {
std::println("[Move] ({:+.2f}, {:+.2f})", v.x, v.y);
});
EventListener<void> firePerf(&fire.onPerformed,
[] { std::println("[Fire] performed"); });
EventListener<void> fireCanc(&fire.onCanceled,
[] { std::println("[Fire] canceled"); });
EventListener<Vector<float,2>> lookChanged(&look.onVector2Changed,
[](Vector<float,2> v) {
// Mouse delta fires every frame the mouse moves; throttle
// by checking magnitude so the console doesn't drown.
if (std::abs(v.x) + std::abs(v.y) > 0.01f) {
std::println("[Look] ({:+.3f}, {:+.3f})", v.x, v.y);
}
});
EventListener<float> zoomChanged(&zoom.onValueChanged,
[](float v) { std::println("[Zoom] delta {:+.0f}", v); });
EventListener<void> quitPerf(&quit.onPerformed,
[&] {
std::println("[Quit] shutting down");
window.open = false;
});
// =================================================================
// Rebinding flow.
//
// Pressing R fires Reset → we call StartRebind for the Jump
// action. While rebinding, the next qualifying input does NOT
// fire Jump (or any other action that uses the same input). It
// routes to the onCaptured callback exactly once.
//
// CaptureMask::Any accepts keyboard | mouse | gamepad. Narrow it
// (Keyboard, Gamepad, etc.) to restrict what counts as a valid
// binding for a given action.
// =================================================================
EventListener<void> resetPerf(&reset.onPerformed, [&] {
std::println("[Rebind] Press any key / button to bind \"Jump\" "
"(Esc to cancel)…");
map.StartRebind(jump, CaptureMask::Any, [&](Binding captured) {
jump.bindings.clear();
jump.bindings.push_back(captured);
std::println("[Rebind] Jump → {}", BindingToString(captured));
});
});
// =================================================================
// Gamepad lifecycle observers.
//
// Gamepad::Tick() is called automatically inside Window::StartSync
// every frame, so these events fire on the main thread alongside
// every other event. Hot-plug a controller while running and you
// should see the connect line below.
// =================================================================
EventListener<Gamepad::Device*> gpAdded(&Gamepad::onConnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] connected: id={} name=\"{}\"",
d->id, d->name);
});
EventListener<Gamepad::Device*> gpRemoved(&Gamepad::onDisconnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] disconnected: id={}", d->id);
});
// =================================================================
// Connect the map to the window. Map polls window state + gamepad
// state every Tick; we trigger Tick from onBeforeUpdate so it
// runs after the OS event pump (and Gamepad::Tick) for that frame.
// =================================================================
map.Attach(window);
EventListener<void> tick(&window.onBeforeUpdate, [&] { map.Tick(); });
std::println("─── Ready. Focus the window and try the controls. ───");
window.Render();
window.StartSync();
std::println("Done.");
return 0;
}

View file

@ -0,0 +1,23 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = std::vector<std::string>(args.begin(), args.end()),
});
Configuration cfg;
cfg.path = "./";
cfg.name = "InputSystem";
cfg.outputName = "InputSystem";
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

Binary file not shown.

View file

@ -1,309 +0,0 @@
// Spike for the OptionsMenu port (3DForts → Crafter.Graphics imperative UI).
// Validates: Rect::SubRect carving at real-menu density, InputField shape and
// caret blink, click-routing-to-focused-field, tab switching with per-tab
// builders. Throwaway example, but kept around as a living reference.
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Event;
import std;
using namespace Crafter;
namespace {
enum class Tab : std::uint8_t { Graphics, Input, Audio };
constexpr std::array<std::string_view, 3> kTabLabels = {
"Graphics", "Input", "Audio"
};
struct Layout {
Rect canvas;
Rect titleBar;
Rect footer;
Rect main;
Rect tabStrip;
Rect content;
std::array<Rect, 3> tabRects;
Rect btnExit;
Rect btnSave;
};
Layout ComputeLayout(const Window& window) {
Layout L;
L.canvas = Rect::FromWindow(window);
L.titleBar = L.canvas.SubRect(60, Rect::Anchor::Top);
L.footer = L.canvas.SubRect(60, Rect::Anchor::Bottom);
L.main = L.canvas.Inset(60, 0, 60, 0);
L.tabStrip = L.main.SubRect(220, Rect::Anchor::Left).Inset(20);
L.content = L.main.Inset(0, 20, 0, 240);
Rect strip = L.tabStrip;
for (int i = 0; i < 3; ++i) {
L.tabRects[i] = strip.SubRect(48, Rect::Anchor::Top).Inset(0, 0, 8, 0);
strip = strip.Inset(56, 0, 0, 0);
}
Rect footerInset = L.footer.Inset(20, 60, 20, 60);
L.btnExit = footerInset.SubRect(160, Rect::Anchor::Left);
L.btnSave = footerInset.SubRect(160, Rect::Anchor::Right);
return L;
}
}
int main() {
Device::Initialize();
Window window(1280, 720, "OptionsSpike");
VkCommandBuffer init = window.StartInit();
DescriptorHeapVulkan heap;
heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4);
window.descriptorHeap = &heap;
Font font("font.ttf");
FontAtlas atlas;
atlas.Initialize(init);
UIRenderer ui;
ui.fontAtlas = &atlas;
ui.Initialize(window, heap, init);
window.passes.push_back(&ui);
VulkanBuffer<QuadItem, true> quadsBuf;
VulkanBuffer<GlyphItem, true> glyphsBuf;
quadsBuf.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256);
glyphsBuf.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096);
auto quadsSlot = ui.RegisterBuffer(quadsBuf);
auto glyphsSlot = ui.RegisterBuffer(glyphsBuf);
// Application state.
Tab activeTab = Tab::Graphics;
std::array<bool, 3> tabHover{};
bool exitHover = false, saveHover = false;
// Graphics-tab fields. In the real port these are rebuilt per-tab; here we
// just keep one set live and only show them on the Graphics tab to confirm
// the routing pattern. Width/height come from a fake "tempOptions" state.
InputField widthField { .value = "1920", .type = InputFieldType::UInt };
InputField heightField { .value = "1080", .type = InputFieldType::UInt };
std::array<InputField*, 2> fields{ &widthField, &heightField };
std::array<Rect, 2> fieldRects{};
std::array<bool, 2> fieldHover{};
std::ptrdiff_t focusedField = -1;
auto BlurAll = [&]() {
for (auto* f : fields) f->focused = false;
focusedField = -1;
};
auto SetFocus = [&](std::ptrdiff_t idx) {
BlurAll();
if (idx >= 0 && idx < std::ssize(fields)) {
fields[idx]->focused = true;
focusedField = idx;
}
};
auto LayoutGraphicsTabFields = [&](const Layout& L) {
Rect rows = L.content;
Rect row0 = rows.SubRect(48, Rect::Anchor::Top);
rows = rows.Inset(56, 0, 0, 0);
Rect row1 = rows.SubRect(48, Rect::Anchor::Top);
// Each row: label on left half, field on right half (right half inset).
fieldRects[0] = row0.SubRect(row0.w * 0.5f, Rect::Anchor::Right).Inset(4);
fieldRects[1] = row1.SubRect(row1.w * 0.5f, Rect::Anchor::Right).Inset(4);
};
Layout L = ComputeLayout(window);
LayoutGraphicsTabFields(L);
// ─── input listeners ───────────────────────────────────────────────
EventListener<void> moveSub(&window.onMouseMove, [&]() {
float mx = window.currentMousePos.x;
float my = window.currentMousePos.y;
for (int i = 0; i < 3; ++i) tabHover[i] = L.tabRects[i].Contains(mx, my);
exitHover = L.btnExit.Contains(mx, my);
saveHover = L.btnSave.Contains(mx, my);
if (activeTab == Tab::Graphics) {
for (int i = 0; i < (int)fields.size(); ++i)
fieldHover[i] = fieldRects[i].Contains(mx, my);
} else {
for (auto& h : fieldHover) h = false;
}
});
EventListener<void> clickSub(&window.onMouseLeftClick, [&]() {
for (int i = 0; i < 3; ++i) {
if (tabHover[i]) {
activeTab = static_cast<Tab>(i);
BlurAll();
return;
}
}
if (exitHover) { window.open = false; return; }
if (saveHover) {
// In the real port: OptionsIO::Save + Apply. Here we just print.
std::println("[spike] save: width={} height={}",
widthField.value, heightField.value);
return;
}
if (activeTab == Tab::Graphics) {
for (int i = 0; i < (int)fields.size(); ++i) {
if (fieldHover[i]) {
SetFocus(i);
fields[i]->cursorPos = InputField_HitTestCursor(
*fields[i], fieldRects[i],
window.currentMousePos.x,
font, 18.0f, InputFieldColors{
.bg = {0.18f, 0.18f, 0.22f, 1.0f},
.bgFocused = {0.22f, 0.22f, 0.30f, 1.0f},
.border = {0.10f, 0.10f, 0.14f, 1.0f},
.borderFocused = {0.40f, 0.65f, 1.00f, 1.0f},
.text = {1.00f, 1.00f, 1.00f, 1.0f},
.caret = {1.00f, 1.00f, 1.00f, 1.0f},
});
return;
}
}
}
BlurAll();
});
EventListener<const std::string_view> textSub(&window.onTextInput, [&](std::string_view t) {
if (focusedField >= 0) InputField_OnText(*fields[focusedField], t);
});
EventListener<CrafterKeys> keySub(&window.onAnyKeyDown, [&](CrafterKeys k) {
if (focusedField >= 0) InputField_OnKey(*fields[focusedField], k);
});
// ─── palettes ──────────────────────────────────────────────────────
ButtonColors tabPalette{
.bg = {0.20f, 0.22f, 0.28f, 1.0f},
.bgHover = {0.30f, 0.55f, 0.95f, 1.0f},
.bgPressed = {0.18f, 0.40f, 0.78f, 1.0f},
.text = {1, 1, 1, 1},
.cornerRadius = 6.0f,
};
ButtonColors tabPaletteSelected = tabPalette;
tabPaletteSelected.bg = {0.30f, 0.55f, 0.95f, 1.0f};
ButtonColors footerPalette{
.bg = {0.20f, 0.22f, 0.28f, 1.0f},
.bgHover = {0.30f, 0.55f, 0.95f, 1.0f},
.bgPressed = {0.18f, 0.40f, 0.78f, 1.0f},
.text = {1, 1, 1, 1},
.cornerRadius = 8.0f,
};
InputFieldColors fieldPalette{
.bg = {0.18f, 0.18f, 0.22f, 1.0f},
.bgFocused = {0.22f, 0.22f, 0.30f, 1.0f},
.border = {0.10f, 0.10f, 0.14f, 1.0f},
.borderFocused = {0.40f, 0.65f, 1.00f, 1.0f},
.text = {1, 1, 1, 1},
.caret = {1, 1, 1, 1},
};
auto startTime = std::chrono::steady_clock::now();
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
VkCommandBuffer cmd = a.cmd;
L = ComputeLayout(window);
LayoutGraphicsTabFields(L);
std::uint32_t qc = 0, gc = 0;
UIBuffer buf{
.quads = quadsBuf.value,
.quadCount = &qc,
.quadCap = 256,
.glyphs = glyphsBuf.value,
.glyphCount = &gc,
.glyphCap = 4096,
.atlas = &atlas,
.renderer = &ui,
};
// Background.
if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{
L.canvas.x, L.canvas.y, L.canvas.w, L.canvas.h,
0.10f, 0.10f, 0.13f, 1.0f,
0, 0, 0, 0, 0, 0, 0, 0,
};
// Title bar.
if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{
L.titleBar.x, L.titleBar.y, L.titleBar.w, L.titleBar.h,
0.14f, 0.14f, 0.18f, 1.0f,
0, 0, 0, 0, 0, 0, 0, 0,
};
DrawText(buf, "OPTIONS",
L.titleBar.x + L.titleBar.w * 0.5f,
L.titleBar.y + L.titleBar.h * 0.5f + 18.0f * 0.32f,
font, 24.0f, {1, 1, 1, 1}, TextAlign::Center);
// Tabs.
for (int i = 0; i < 3; ++i) {
const auto& palette = (activeTab == static_cast<Tab>(i))
? tabPaletteSelected : tabPalette;
DrawButton(buf, L.tabRects[i], kTabLabels[i],
tabHover[i], false, font, 18.0f, palette);
}
// Active tab content.
if (activeTab == Tab::Graphics) {
Rect rows = L.content;
Rect row0 = rows.SubRect(48, Rect::Anchor::Top);
rows = rows.Inset(56, 0, 0, 0);
Rect row1 = rows.SubRect(48, Rect::Anchor::Top);
DrawText(buf, "Resolution width",
row0.x + 8.0f,
row0.y + row0.h * 0.5f + 18.0f * 0.32f,
font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left);
DrawText(buf, "Resolution height",
row1.x + 8.0f,
row1.y + row1.h * 0.5f + 18.0f * 0.32f,
font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left);
auto now = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime).count();
bool caretVisible = ((ms / 500) & 1) == 0;
DrawInputField(buf, widthField, fieldRects[0], font, 18.0f, fieldPalette, caretVisible);
DrawInputField(buf, heightField, fieldRects[1], font, 18.0f, fieldPalette, caretVisible);
} else {
DrawText(buf, "(tab content not populated in spike)",
L.content.x + L.content.w * 0.5f,
L.content.y + L.content.h * 0.5f,
font, 18.0f, {0.6f, 0.6f, 0.6f, 1.0f}, TextAlign::Center);
}
// Footer.
DrawButton(buf, L.btnExit, "Exit", exitHover, false, font, 18.0f, footerPalette);
DrawButton(buf, L.btnSave, "Save changes", saveHover, false, font, 18.0f, footerPalette);
// Dispatch.
if (qc > 0) {
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
ui.DispatchQuads(cmd, quadsSlot, qc);
}
if (gc > 0) {
glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
ui.DispatchText(cmd, glyphsSlot, gc);
}
});
window.FinishInit();
window.Render();
window.StartUpdate();
window.StartSync();
}

View file

@ -43,6 +43,27 @@ library does not track widgets or focus.
Drop a TTF in this directory as `font.ttf` before running (the example Drop a TTF in this directory as `font.ttf` before running (the example
loads it via `Font("font.ttf")`). loads it via `Font("font.ttf")`).
### [InputSystem](InputSystem/)
Guided tour of `Crafter::Input`: name actions ("Jump", "Move", "Fire",
"Look", "Zoom"), bind them to keys / mouse / gamepad (with composite
bindings for WASD-as-Vector2 and analog sticks), and consume them as
events. Demonstrates:
- The compile-time `Key(CrafterKeys::Space)` helper that folds to a
per-platform raw scancode — bindings stay cross-platform-readable
in source while runtime data stores raw codes only.
- All four action types (Button, Axis1D, Vector2) with multiple
bindings per action (any-of semantics).
- `Map::StartRebind` — press R, then press any input to remap "Jump"
at runtime. Captured input is filtered out for that frame.
- `BindingToString` / `BindingFromString` round-trip — print the
default bindings as the on-disk format.
- Gamepad hot-plug events: plug a controller in mid-run and the
bindings start firing immediately.
Console-driven (no UI rendering needed); focus the window and watch
stdout.
### [CustomShader](CustomShader/) ### [CustomShader](CustomShader/)
Tier 1 demo: a user-authored compute shader (`inverse-circle.comp.glsl`) Tier 1 demo: a user-authored compute shader (`inverse-circle.comp.glsl`)
running alongside the shipped `drawQuads`. The custom shader inverts RGB running alongside the shipped `drawQuads`. The custom shader inverts RGB

View file

@ -0,0 +1,174 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include <wayland-client.h>
#include <wayland-client-protocol.h>
#include <unistd.h>
#include <errno.h>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#endif
module Crafter.Graphics;
import std;
using namespace Crafter;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
namespace {
// Heap-allocated state attached to each wl_data_source via its
// user_data slot. The text must outlive the source (the compositor
// can call `send` minutes after we set the selection, every time a
// remote app pastes), so we own it here and free it from
// `OnSourceCancelled` — which is the only callback the compositor
// guarantees will fire when the source is no longer needed (when
// the selection is replaced, or on app exit).
struct Held {
std::string text;
};
void OnSourceTarget(void*, wl_data_source*, const char*) {}
void OnSourceSend(void* data, wl_data_source*,
const char* /*mime_type*/, std::int32_t fd) {
// We only ever advertise text MIME types, so any negotiated
// type maps to the same UTF-8 buffer. `send` may fire multiple
// times across the lifetime of one selection (each paste is a
// fresh fd), so we must not consume `text` here.
Held* h = static_cast<Held*>(data);
const char* p = h->text.data();
std::size_t rem = h->text.size();
while (rem > 0) {
const ssize_t w = ::write(fd, p, rem);
if (w < 0) {
if (errno == EINTR) continue;
break; // pipe closed; remote gave up reading
}
if (w == 0) break;
p += w;
rem -= static_cast<std::size_t>(w);
}
::close(fd);
}
void OnSourceCancelled(void* data, wl_data_source* source) {
// Selection was replaced (someone else copied) or the app is
// exiting. Either way, this is our hook to release the held
// text and the source itself.
delete static_cast<Held*>(data);
wl_data_source_destroy(source);
}
// Drag-and-drop only callbacks. We never start a DnD action, so
// these can't fire — but the listener struct's size is fixed at
// the v3 shape, and wayland-client uses the struct's slots
// directly. Stubs are required.
void OnSourceDndDropPerformed(void*, wl_data_source*) {}
void OnSourceDndFinished(void*, wl_data_source*) {}
void OnSourceAction(void*, wl_data_source*, std::uint32_t) {}
constexpr wl_data_source_listener kSourceListener = {
.target = OnSourceTarget,
.send = OnSourceSend,
.cancelled = OnSourceCancelled,
.dnd_drop_performed = OnSourceDndDropPerformed,
.dnd_finished = OnSourceDndFinished,
.action = OnSourceAction,
};
}
#endif
bool Crafter::Clipboard::SetText(std::string_view text) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (Device::dataDeviceManager == nullptr || Device::dataDevice == nullptr) {
// Compositor doesn't expose wl_data_device_manager (rare; some
// headless / minimal compositors). Caller can fall back.
return false;
}
// wl_data_device.set_selection requires a serial from a recent
// input event. We track the most recent pointer-enter serial on
// each window — fine for "user clicked Copy" interactions, since
// the click itself produced the serial. Without one, the
// compositor would silently reject the request.
std::uint32_t serial = 0;
if (Device::focusedWindow != nullptr) {
serial = Device::focusedWindow->lastPointerSerial_;
}
if (serial == 0) return false;
auto* held = new Held{ std::string(text) };
wl_data_source* source =
wl_data_device_manager_create_data_source(Device::dataDeviceManager);
if (source == nullptr) {
delete held;
return false;
}
wl_data_source_add_listener(source, &kSourceListener, held);
// Advertise the four common text MIME types so legacy + modern
// pasters both find a match. Wayland clients prefer the first one
// they recognise, in order.
wl_data_source_offer(source, "text/plain;charset=utf-8");
wl_data_source_offer(source, "text/plain");
wl_data_source_offer(source, "UTF8_STRING");
wl_data_source_offer(source, "TEXT");
wl_data_device_set_selection(Device::dataDevice, source, serial);
// Push the request so the compositor sees it before the next event
// loop iteration — otherwise a quick read in another app might
// miss the selection update.
wl_display_flush(Device::display);
return true;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WIN32)
// CF_UNICODETEXT round-trip. Convert UTF-8 → UTF-16, allocate a
// moveable HGLOBAL, hand it off to the OS. The OS frees the global
// when the next clipboard owner replaces the data, so our caller
// doesn't need to keep `text` alive — same lifetime contract as
// the Wayland path.
if (!OpenClipboard(nullptr)) return false;
EmptyClipboard();
const int wlen = MultiByteToWideChar(CP_UTF8, 0,
text.data(), static_cast<int>(text.size()), nullptr, 0);
if (wlen < 0) { CloseClipboard(); return false; }
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE,
(static_cast<std::size_t>(wlen) + 1) * sizeof(wchar_t));
if (h == nullptr) { CloseClipboard(); return false; }
wchar_t* dst = static_cast<wchar_t*>(GlobalLock(h));
if (dst == nullptr) {
GlobalFree(h);
CloseClipboard();
return false;
}
MultiByteToWideChar(CP_UTF8, 0,
text.data(), static_cast<int>(text.size()), dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
const bool ok = SetClipboardData(CF_UNICODETEXT, h) != nullptr;
if (!ok) GlobalFree(h); // OS only takes ownership on success
CloseClipboard();
return ok;
#else
(void)text;
return false;
#endif
}

View file

@ -176,158 +176,6 @@ VkBool32 onError(VkDebugUtilsMessageSeverityFlagBitsEXT severity, VkDebugUtilsMe
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
constexpr CrafterKeys keysym_to_crafter_key(xkb_keysym_t sym)
{
switch (sym)
{
// Alphabet
case XKB_KEY_a: return CrafterKeys::A;
case XKB_KEY_b: return CrafterKeys::B;
case XKB_KEY_c: return CrafterKeys::C;
case XKB_KEY_d: return CrafterKeys::D;
case XKB_KEY_e: return CrafterKeys::E;
case XKB_KEY_f: return CrafterKeys::F;
case XKB_KEY_g: return CrafterKeys::G;
case XKB_KEY_h: return CrafterKeys::H;
case XKB_KEY_i: return CrafterKeys::I;
case XKB_KEY_j: return CrafterKeys::J;
case XKB_KEY_k: return CrafterKeys::K;
case XKB_KEY_l: return CrafterKeys::L;
case XKB_KEY_m: return CrafterKeys::M;
case XKB_KEY_n: return CrafterKeys::N;
case XKB_KEY_o: return CrafterKeys::O;
case XKB_KEY_p: return CrafterKeys::P;
case XKB_KEY_q: return CrafterKeys::Q;
case XKB_KEY_r: return CrafterKeys::R;
case XKB_KEY_s: return CrafterKeys::S;
case XKB_KEY_t: return CrafterKeys::T;
case XKB_KEY_u: return CrafterKeys::U;
case XKB_KEY_v: return CrafterKeys::V;
case XKB_KEY_w: return CrafterKeys::W;
case XKB_KEY_x: return CrafterKeys::X;
case XKB_KEY_y: return CrafterKeys::Y;
case XKB_KEY_z: return CrafterKeys::Z;
case XKB_KEY_A: return CrafterKeys::A;
case XKB_KEY_B: return CrafterKeys::B;
case XKB_KEY_C: return CrafterKeys::C;
case XKB_KEY_D: return CrafterKeys::D;
case XKB_KEY_E: return CrafterKeys::E;
case XKB_KEY_F: return CrafterKeys::F;
case XKB_KEY_G: return CrafterKeys::G;
case XKB_KEY_H: return CrafterKeys::H;
case XKB_KEY_I: return CrafterKeys::I;
case XKB_KEY_J: return CrafterKeys::J;
case XKB_KEY_K: return CrafterKeys::K;
case XKB_KEY_L: return CrafterKeys::L;
case XKB_KEY_M: return CrafterKeys::M;
case XKB_KEY_N: return CrafterKeys::N;
case XKB_KEY_O: return CrafterKeys::O;
case XKB_KEY_P: return CrafterKeys::P;
case XKB_KEY_Q: return CrafterKeys::Q;
case XKB_KEY_R: return CrafterKeys::R;
case XKB_KEY_S: return CrafterKeys::S;
case XKB_KEY_T: return CrafterKeys::T;
case XKB_KEY_U: return CrafterKeys::U;
case XKB_KEY_V: return CrafterKeys::V;
case XKB_KEY_W: return CrafterKeys::W;
case XKB_KEY_X: return CrafterKeys::X;
case XKB_KEY_Y: return CrafterKeys::Y;
case XKB_KEY_Z: return CrafterKeys::Z;
// Numbers
case XKB_KEY_0: return CrafterKeys::_0;
case XKB_KEY_1: return CrafterKeys::_1;
case XKB_KEY_2: return CrafterKeys::_2;
case XKB_KEY_3: return CrafterKeys::_3;
case XKB_KEY_4: return CrafterKeys::_4;
case XKB_KEY_5: return CrafterKeys::_5;
case XKB_KEY_6: return CrafterKeys::_6;
case XKB_KEY_7: return CrafterKeys::_7;
case XKB_KEY_8: return CrafterKeys::_8;
case XKB_KEY_9: return CrafterKeys::_9;
// Function keys
case XKB_KEY_F1: return CrafterKeys::F1;
case XKB_KEY_F2: return CrafterKeys::F2;
case XKB_KEY_F3: return CrafterKeys::F3;
case XKB_KEY_F4: return CrafterKeys::F4;
case XKB_KEY_F5: return CrafterKeys::F5;
case XKB_KEY_F6: return CrafterKeys::F6;
case XKB_KEY_F7: return CrafterKeys::F7;
case XKB_KEY_F8: return CrafterKeys::F8;
case XKB_KEY_F9: return CrafterKeys::F9;
case XKB_KEY_F10: return CrafterKeys::F10;
case XKB_KEY_F11: return CrafterKeys::F11;
case XKB_KEY_F12: return CrafterKeys::F12;
// Control keys
case XKB_KEY_Escape: return CrafterKeys::Escape;
case XKB_KEY_Tab: return CrafterKeys::Tab;
case XKB_KEY_Return: return CrafterKeys::Enter;
case XKB_KEY_space: return CrafterKeys::Space;
case XKB_KEY_BackSpace: return CrafterKeys::Backspace;
case XKB_KEY_Delete: return CrafterKeys::Delete;
case XKB_KEY_Insert: return CrafterKeys::Insert;
case XKB_KEY_Home: return CrafterKeys::Home;
case XKB_KEY_End: return CrafterKeys::End;
case XKB_KEY_Page_Up: return CrafterKeys::PageUp;
case XKB_KEY_Page_Down: return CrafterKeys::PageDown;
case XKB_KEY_Caps_Lock: return CrafterKeys::CapsLock;
case XKB_KEY_Num_Lock: return CrafterKeys::NumLock;
case XKB_KEY_Scroll_Lock:return CrafterKeys::ScrollLock;
// Modifiers
case XKB_KEY_Shift_L: return CrafterKeys::LeftShift;
case XKB_KEY_Shift_R: return CrafterKeys::RightShift;
case XKB_KEY_Control_L: return CrafterKeys::LeftCtrl;
case XKB_KEY_Control_R: return CrafterKeys::RightCtrl;
case XKB_KEY_Alt_L: return CrafterKeys::LeftAlt;
case XKB_KEY_Alt_R: return CrafterKeys::RightAlt;
case XKB_KEY_Super_L: return CrafterKeys::LeftSuper;
case XKB_KEY_Super_R: return CrafterKeys::RightSuper;
// Arrows
case XKB_KEY_Up: return CrafterKeys::Up;
case XKB_KEY_Down: return CrafterKeys::Down;
case XKB_KEY_Left: return CrafterKeys::Left;
case XKB_KEY_Right: return CrafterKeys::Right;
// Keypad
case XKB_KEY_KP_0: return CrafterKeys::keypad_0;
case XKB_KEY_KP_1: return CrafterKeys::keypad_1;
case XKB_KEY_KP_2: return CrafterKeys::keypad_2;
case XKB_KEY_KP_3: return CrafterKeys::keypad_3;
case XKB_KEY_KP_4: return CrafterKeys::keypad_4;
case XKB_KEY_KP_5: return CrafterKeys::keypad_5;
case XKB_KEY_KP_6: return CrafterKeys::keypad_6;
case XKB_KEY_KP_7: return CrafterKeys::keypad_7;
case XKB_KEY_KP_8: return CrafterKeys::keypad_8;
case XKB_KEY_KP_9: return CrafterKeys::keypad_9;
case XKB_KEY_KP_Enter: return CrafterKeys::keypad_enter;
case XKB_KEY_KP_Add: return CrafterKeys::keypad_plus;
case XKB_KEY_KP_Subtract: return CrafterKeys::keypad_minus;
case XKB_KEY_KP_Multiply: return CrafterKeys::keypad_multiply;
case XKB_KEY_KP_Divide: return CrafterKeys::keypad_divide;
case XKB_KEY_KP_Decimal: return CrafterKeys::keypad_decimal;
// Punctuation
case XKB_KEY_grave: return CrafterKeys::grave;
case XKB_KEY_minus: return CrafterKeys::minus;
case XKB_KEY_equal: return CrafterKeys::equal;
case XKB_KEY_bracketleft: return CrafterKeys::bracket_left;
case XKB_KEY_bracketright:return CrafterKeys::bracket_right;
case XKB_KEY_backslash: return CrafterKeys::backslash;
case XKB_KEY_semicolon: return CrafterKeys::semicolon;
case XKB_KEY_apostrophe: return CrafterKeys::quote;
case XKB_KEY_comma: return CrafterKeys::comma;
case XKB_KEY_period: return CrafterKeys::period;
case XKB_KEY_slash: return CrafterKeys::slash;
default: return CrafterKeys::CrafterKeysMax;
}
}
void Device::xdg_wm_base_handle_ping(void* data, xdg_wm_base* xdg_wm_base, std::uint32_t serial) { void Device::xdg_wm_base_handle_ping(void* data, xdg_wm_base* xdg_wm_base, std::uint32_t serial) {
xdg_wm_base_pong(xdg_wm_base, serial); xdg_wm_base_pong(xdg_wm_base, serial);
} }
@ -336,8 +184,15 @@ void Device::handle_global(void *data, wl_registry *registry, std::uint32_t name
if (strcmp(interface, wl_shm_interface.name) == 0) { if (strcmp(interface, wl_shm_interface.name) == 0) {
shm = reinterpret_cast<wl_shm*>(wl_registry_bind(registry, name, &wl_shm_interface, 1)); shm = reinterpret_cast<wl_shm*>(wl_registry_bind(registry, name, &wl_shm_interface, 1));
} else if (strcmp(interface, wl_seat_interface.name) == 0) { } else if (strcmp(interface, wl_seat_interface.name) == 0) {
wl_seat* seat = reinterpret_cast<wl_seat*>(wl_registry_bind(registry, name, &wl_seat_interface, 1)); // Assign to Device::seat (not a fresh local) so SetClipboardText
// and any other code that needs the seat post-init can find it.
seat = reinterpret_cast<wl_seat*>(wl_registry_bind(registry, name, &wl_seat_interface, 1));
wl_seat_add_listener(seat, &seat_listener, nullptr); wl_seat_add_listener(seat, &seat_listener, nullptr);
// If the manager came in first, the data device couldn't be
// created yet — do it now that we have the seat.
if (dataDeviceManager != nullptr && dataDevice == nullptr) {
dataDevice = wl_data_device_manager_get_data_device(dataDeviceManager, seat);
}
} else if (compositor == nullptr && strcmp(interface, wl_compositor_interface.name) == 0) { } else if (compositor == nullptr && strcmp(interface, wl_compositor_interface.name) == 0) {
compositor = reinterpret_cast<wl_compositor*>(wl_registry_bind(registry, name, &wl_compositor_interface, 3)); compositor = reinterpret_cast<wl_compositor*>(wl_registry_bind(registry, name, &wl_compositor_interface, 3));
} else if (strcmp(interface, xdg_wm_base_interface.name) == 0) { } else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
@ -349,6 +204,16 @@ void Device::handle_global(void *data, wl_registry *registry, std::uint32_t name
wpViewporter = reinterpret_cast<wp_viewporter*>(wl_registry_bind(registry, name, &wp_viewporter_interface, 1)); wpViewporter = reinterpret_cast<wp_viewporter*>(wl_registry_bind(registry, name, &wp_viewporter_interface, 1));
} else if (strcmp(interface, wp_fractional_scale_manager_v1_interface.name) == 0) { } else if (strcmp(interface, wp_fractional_scale_manager_v1_interface.name) == 0) {
fractionalScaleManager = reinterpret_cast<wp_fractional_scale_manager_v1*>(wl_registry_bind(registry, name, &wp_fractional_scale_manager_v1_interface, 1)); fractionalScaleManager = reinterpret_cast<wp_fractional_scale_manager_v1*>(wl_registry_bind(registry, name, &wp_fractional_scale_manager_v1_interface, 1));
} else if (strcmp(interface, wl_data_device_manager_interface.name) == 0) {
// v3 gives us the full set of data_source events (target / send /
// cancelled / dnd_*). Universally supported by the compositors
// we target — fall back path is the per-source listener simply
// not getting the v3-only callbacks.
dataDeviceManager = reinterpret_cast<wl_data_device_manager*>(
wl_registry_bind(registry, name, &wl_data_device_manager_interface, 3));
if (seat != nullptr && dataDevice == nullptr) {
dataDevice = wl_data_device_manager_get_data_device(dataDeviceManager, seat);
}
} }
} }
@ -441,23 +306,24 @@ void Device::keyboard_leave(void *data, wl_keyboard *keyboard, uint32_t serial,
} }
void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, uint32_t time, uint32_t key, uint32_t state) { void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, uint32_t time, uint32_t key, uint32_t state) {
xkb_keycode_t keycode = key + 8; // `key` is the kernel input-event-code (KEY_*). That is exactly what
xkb_keysym_t keysym = xkb_state_key_get_one_sym(xkb_state, keycode); // :Keys returns for Wayland builds, so we store it verbatim with no
CrafterKeys crafterKey = keysym_to_crafter_key(keysym); // translation. The +8 X11 offset is only needed for the XKB layer,
// which we still consult to produce UTF-8 text.
KeyCode code = key;
xkb_keycode_t xkbKeycode = key + 8;
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (focusedWindow->heldkeys[(std::uint8_t)crafterKey]) { if (focusedWindow->heldKeys.contains(code)) {
focusedWindow->onKeyHold[(std::uint8_t)crafterKey].Invoke(); focusedWindow->onRawKeyHold.Invoke(code);
focusedWindow->onAnyKeyHold.Invoke(crafterKey);
} else { } else {
focusedWindow->heldkeys[(std::uint8_t)crafterKey] = true; focusedWindow->heldKeys.insert(code);
focusedWindow->onKeyDown[(std::uint8_t)crafterKey].Invoke(); focusedWindow->onRawKeyDown.Invoke(code);
focusedWindow->onAnyKeyDown.Invoke(crafterKey);
} }
std::string buf; std::string buf;
buf.resize(16); buf.resize(16);
int n = xkb_state_key_get_utf8(xkb_state, keycode, buf.data(), 16); int n = xkb_state_key_get_utf8(xkb_state, xkbKeycode, buf.data(), 16);
std::string utf8; std::string utf8;
if (n > 0 && (unsigned char)buf[0] >= 0x20 && buf[0] != 0x7f) { if (n > 0 && (unsigned char)buf[0] >= 0x20 && buf[0] != 0x7f) {
buf.resize(n); buf.resize(n);
@ -468,19 +334,18 @@ void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, ui
// Replace the active repeat with this key — most recent press wins, // Replace the active repeat with this key — most recent press wins,
// matching xkbcommon's typical behaviour and most desktop apps. // matching xkbcommon's typical behaviour and most desktop apps.
keyRepeat.active = (keyRepeat.rate > 0); keyRepeat.active = (keyRepeat.rate > 0);
keyRepeat.key = crafterKey; keyRepeat.key = code;
keyRepeat.utf8 = std::move(utf8); keyRepeat.utf8 = std::move(utf8);
keyRepeat.pressTime = std::chrono::steady_clock::now(); keyRepeat.pressTime = std::chrono::steady_clock::now();
keyRepeat.lastFireTime = keyRepeat.pressTime; keyRepeat.lastFireTime = keyRepeat.pressTime;
} else { } else {
focusedWindow->heldkeys[(std::uint8_t)crafterKey] = false; focusedWindow->heldKeys.erase(code);
focusedWindow->onKeyUp[(std::uint8_t)crafterKey].Invoke(); focusedWindow->onRawKeyUp.Invoke(code);
focusedWindow->onAnyKeyUp.Invoke(crafterKey);
// If the released key was the one repeating, stop. Otherwise leave // If the released key was the one repeating, stop. Otherwise leave
// the existing repeat alone (user pressed/released a modifier // the existing repeat alone (user pressed/released a modifier
// mid-repeat etc.). // mid-repeat etc.).
if (keyRepeat.active && keyRepeat.key == crafterKey) { if (keyRepeat.active && keyRepeat.key == code) {
keyRepeat.active = false; keyRepeat.active = false;
keyRepeat.utf8.clear(); keyRepeat.utf8.clear();
} }
@ -513,10 +378,8 @@ void Device::TickKeyRepeats() {
// Catch up — emit one event per missed period so a paused frame doesn't // Catch up — emit one event per missed period so a paused frame doesn't
// make the repeat permanently lag behind. // make the repeat permanently lag behind.
while (now - keyRepeat.lastFireTime >= period) { while (now - keyRepeat.lastFireTime >= period) {
focusedWindow->onKeyDown[(std::uint8_t)keyRepeat.key].Invoke(); focusedWindow->onRawKeyDown.Invoke(keyRepeat.key);
focusedWindow->onAnyKeyDown.Invoke(keyRepeat.key); focusedWindow->onRawKeyHold.Invoke(keyRepeat.key);
focusedWindow->onKeyHold[(std::uint8_t)keyRepeat.key].Invoke();
focusedWindow->onAnyKeyHold.Invoke(keyRepeat.key);
if (!keyRepeat.utf8.empty()) { if (!keyRepeat.utf8.empty()) {
focusedWindow->onTextInput.Invoke(keyRepeat.utf8); focusedWindow->onTextInput.Invoke(keyRepeat.utf8);
} }
@ -673,11 +536,40 @@ void Device::Initialize() {
} }
} }
// Enumerate available device extensions so we can opt into
// VK_EXT_memory_decompression when the driver advertises it. Drivers
// without it (AMD, Intel as of early 2026) get the CPU-decode fallback.
{
std::uint32_t extCount = 0;
vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, nullptr);
std::vector<VkExtensionProperties> exts(extCount);
vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, exts.data());
for (const VkExtensionProperties& e : exts) {
if (std::strcmp(e.extensionName, VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME) == 0) {
memoryDecompressionSupported = true;
break;
}
}
}
// Properties query: chain memory-decompression props only when supported,
// otherwise sType validation flags it as an unrecognized struct on
// drivers that don't expose the extension.
if (memoryDecompressionSupported) {
memoryDecompressionProperties.pNext = const_cast<void*>(rayTracingProperties.pNext);
rayTracingProperties.pNext = &memoryDecompressionProperties;
}
VkPhysicalDeviceProperties2 properties2 { VkPhysicalDeviceProperties2 properties2 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
.pNext = &rayTracingProperties .pNext = &rayTracingProperties
}; };
vkGetPhysicalDeviceProperties2(physDevice, &properties2); vkGetPhysicalDeviceProperties2(physDevice, &properties2);
// Sanity-gate: GDeflate 1.0 must actually be in the supported method set.
if (memoryDecompressionSupported &&
(memoryDecompressionProperties.decompressionMethods & VK_MEMORY_DECOMPRESSION_METHOD_GDEFLATE_1_0_BIT_EXT) == 0) {
memoryDecompressionSupported = false;
}
uint32_t queueFamilyCount; uint32_t queueFamilyCount;
vkGetPhysicalDeviceQueueFamilyProperties(physDevice, &queueFamilyCount, NULL); vkGetPhysicalDeviceQueueFamilyProperties(physDevice, &queueFamilyCount, NULL);
@ -724,9 +616,26 @@ void Device::Initialize() {
.shaderUntypedPointers = VK_TRUE, .shaderUntypedPointers = VK_TRUE,
}; };
// Enables synchronization2 sentinels (VkMemoryBarrier2, VK_PIPELINE_STAGE_2_*,
// VK_ACCESS_2_*) — required for VK_EXT_memory_decompression's sync tokens.
VkPhysicalDeviceVulkan13Features features13 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES,
.pNext = &untypedPointersFeatures,
.synchronization2 = VK_TRUE,
};
VkPhysicalDeviceMemoryDecompressionFeaturesEXT memoryDecompressionFeatures {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_FEATURES_EXT,
.pNext = &features13,
.memoryDecompression = VK_TRUE,
};
void* postDecompressChain = memoryDecompressionSupported
? static_cast<void*>(&memoryDecompressionFeatures)
: static_cast<void*>(&features13);
VkPhysicalDeviceDescriptorHeapFeaturesEXT desciptorHeapFeatures { VkPhysicalDeviceDescriptorHeapFeaturesEXT desciptorHeapFeatures {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_FEATURES_EXT, .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_FEATURES_EXT,
.pNext = &untypedPointersFeatures, .pNext = postDecompressChain,
.descriptorHeap = VK_TRUE, .descriptorHeap = VK_TRUE,
}; };
@ -787,12 +696,22 @@ void Device::Initialize() {
} }
}; };
// Build the enabled-extension list dynamically so we can append the
// optional VK_EXT_memory_decompression entry only when the driver
// advertises it.
std::vector<const char*> enabledDeviceExtensions(
std::begin(deviceExtensionNames),
std::end(deviceExtensionNames));
if (memoryDecompressionSupported) {
enabledDeviceExtensions.push_back(VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME);
}
VkDeviceCreateInfo deviceCreateInfo = {}; VkDeviceCreateInfo deviceCreateInfo = {};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceCreateInfo.queueCreateInfoCount = 1; deviceCreateInfo.queueCreateInfoCount = 1;
deviceCreateInfo.pQueueCreateInfos = &queueCreateInfo; deviceCreateInfo.pQueueCreateInfos = &queueCreateInfo;
deviceCreateInfo.enabledExtensionCount = sizeof(deviceExtensionNames) / sizeof(const char*); deviceCreateInfo.enabledExtensionCount = static_cast<std::uint32_t>(enabledDeviceExtensions.size());
deviceCreateInfo.ppEnabledExtensionNames = deviceExtensionNames; deviceCreateInfo.ppEnabledExtensionNames = enabledDeviceExtensions.data();
deviceCreateInfo.pNext = &physical_features2; deviceCreateInfo.pNext = &physical_features2;
uint32_t deviceLayerCount; uint32_t deviceLayerCount;
@ -846,6 +765,19 @@ void Device::Initialize() {
vkCmdPushDataEXT = reinterpret_cast<PFN_vkCmdPushDataEXT>(vkGetInstanceProcAddr(instance, "vkCmdPushDataEXT")); vkCmdPushDataEXT = reinterpret_cast<PFN_vkCmdPushDataEXT>(vkGetInstanceProcAddr(instance, "vkCmdPushDataEXT"));
vkGetPhysicalDeviceDescriptorSizeEXT = reinterpret_cast<PFN_vkGetPhysicalDeviceDescriptorSizeEXT>(vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceDescriptorSizeEXT")); vkGetPhysicalDeviceDescriptorSizeEXT = reinterpret_cast<PFN_vkGetPhysicalDeviceDescriptorSizeEXT>(vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceDescriptorSizeEXT"));
vkGetDeviceFaultInfoEXT = reinterpret_cast<PFN_vkGetDeviceFaultInfoEXT>(vkGetInstanceProcAddr(instance, "vkGetDeviceFaultInfoEXT")); vkGetDeviceFaultInfoEXT = reinterpret_cast<PFN_vkGetDeviceFaultInfoEXT>(vkGetInstanceProcAddr(instance, "vkGetDeviceFaultInfoEXT"));
if (memoryDecompressionSupported) {
// vkGetDeviceProcAddr skips the loader trampoline that vkGetInstanceProcAddr
// requires for device-level functions. The other PFNs above predate this
// realization; opportunistic adoption for new entry points only.
vkCmdDecompressMemoryEXT = reinterpret_cast<PFN_vkCmdDecompressMemoryEXT>(
vkGetDeviceProcAddr(device, "vkCmdDecompressMemoryEXT"));
if (vkCmdDecompressMemoryEXT == nullptr) {
// Driver advertised the extension but didn't expose the entry
// point — defensively fall back to CPU decode.
memoryDecompressionSupported = false;
}
}
} }
std::uint32_t Device::GetMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties) { std::uint32_t Device::GetMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties) {

View file

@ -0,0 +1,683 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include <libudev.h>
#include <libevdev/libevdev.h>
#include <linux/input.h>
#include <linux/input-event-codes.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
#include <sys/ioctl.h>
#include <cstring>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#define COBJMACROS
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <windows.gaming.input.h>
#include <roapi.h>
#include <initguid.h>
#endif
module Crafter.Graphics;
import std;
using namespace Crafter;
// ────────────────────────────────────────────────────────────────────
// Linux backend (libudev + libevdev)
// ────────────────────────────────────────────────────────────────────
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
namespace {
// Per-device runtime state. The public `Gamepad::Device` carries the
// identity + polled state + events; this struct holds the libevdev /
// fd / mapping tables we need to drain events from.
struct LinuxBackend {
libevdev* evdev = nullptr;
int fd = -1;
// Per-axis calibration (libevdev's min/max so we can normalize).
// Indexed by Gamepad::Axis. -1 in `code` means "this axis isn't
// exposed by this device".
struct AxisInfo {
int code = -1; // kernel ABS_* code, or -1
int min = 0;
int max = 0;
float flat = 0.0f; // deadzone hint (raw units)
bool invertY = false; // negate output (stick Y by convention)
};
AxisInfo axisInfo[(std::size_t)Crafter::Gamepad::Axis::Max];
// FF effect id from EVIOCSFF, -1 if no rumble support / not active.
int ffEffectId = -1;
std::chrono::steady_clock::time_point rumbleExpiry{};
};
std::unordered_map<Crafter::Gamepad::Device*, std::unique_ptr<LinuxBackend>> g_backends;
udev* g_udev = nullptr;
udev_monitor* g_monitor = nullptr;
bool g_initialized = false;
std::uint32_t SyspathToId(const char* syspath) {
// FNV-1a 32-bit, stable across runs for the same physical device.
std::uint32_t h = 0x811C9DC5u;
for (const char* p = syspath; *p; ++p) {
h ^= static_cast<std::uint8_t>(*p);
h *= 0x01000193u;
}
// Reserve 0 as "unmapped".
return h == 0 ? 1 : h;
}
// BTN_* → Gamepad::Button. -1 = not a button we care about.
int ButtonFromKernel(int code) {
using B = Crafter::Gamepad::Button;
switch (code) {
case BTN_SOUTH: return (int)B::South;
case BTN_EAST: return (int)B::East;
case BTN_NORTH: return (int)B::North;
case BTN_WEST: return (int)B::West;
case BTN_TL: return (int)B::LeftBumper;
case BTN_TR: return (int)B::RightBumper;
case BTN_TL2: return (int)B::LeftTrigger;
case BTN_TR2: return (int)B::RightTrigger;
case BTN_SELECT: return (int)B::Select;
case BTN_START: return (int)B::Start;
case BTN_MODE: return (int)B::Home;
case BTN_THUMBL: return (int)B::LeftStickClick;
case BTN_THUMBR: return (int)B::RightStickClick;
case BTN_DPAD_UP: return (int)B::DPadUp;
case BTN_DPAD_DOWN: return (int)B::DPadDown;
case BTN_DPAD_LEFT: return (int)B::DPadLeft;
case BTN_DPAD_RIGHT: return (int)B::DPadRight;
default: return -1;
}
}
// Populate `bp->axisInfo` from libevdev. Sticks use ABS_X/Y/RX/RY;
// triggers prefer ABS_Z/RZ but fall back to ABS_BRAKE/GAS.
void DiscoverAxes(LinuxBackend* bp) {
using A = Crafter::Gamepad::Axis;
auto setup = [&](A which, int code, bool invertY = false) {
if (!libevdev_has_event_code(bp->evdev, EV_ABS, code)) return;
auto* info = libevdev_get_abs_info(bp->evdev, code);
if (!info) return;
auto& slot = bp->axisInfo[(std::size_t)which];
slot.code = code;
slot.min = info->minimum;
slot.max = info->maximum;
slot.flat = (float)info->flat;
slot.invertY = invertY;
};
setup(A::LeftStickX, ABS_X);
setup(A::LeftStickY, ABS_Y, /*invertY=*/true);
setup(A::RightStickX, ABS_RX);
setup(A::RightStickY, ABS_RY, /*invertY=*/true);
// Trigger axis: Z/RZ on Xbox, BRAKE/GAS on some others.
if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_Z)) {
setup(A::LeftTrigger, ABS_Z);
} else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_BRAKE)) {
setup(A::LeftTrigger, ABS_BRAKE);
}
if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_RZ)) {
setup(A::RightTrigger, ABS_RZ);
} else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_GAS)) {
setup(A::RightTrigger, ABS_GAS);
}
}
// Normalize a raw axis value to -1..1 (sticks) or 0..1 (triggers).
float NormalizeAxis(const LinuxBackend::AxisInfo& info, int raw, bool isTrigger) {
if (info.code < 0 || info.max == info.min) return 0.0f;
float v;
if (isTrigger) {
v = float(raw - info.min) / float(info.max - info.min);
if (v < 0.0f) v = 0.0f;
if (v > 1.0f) v = 1.0f;
} else {
float mid = 0.5f * (info.max + info.min);
float halfR = 0.5f * (info.max - info.min);
v = (raw - mid) / halfR;
if (v < -1.0f) v = -1.0f;
if (v > 1.0f) v = 1.0f;
if (info.invertY) v = -v;
}
return v;
}
bool IsGamepadDevice(libevdev* evdev) {
// A real gamepad reports BTN_GAMEPAD (a kernel alias for BTN_SOUTH
// when the device is classified as a gamepad). This filters out
// keyboards/mice/touchpads/tablets that share the evdev interface.
return libevdev_has_event_type(evdev, EV_KEY)
&& libevdev_has_event_code(evdev, EV_KEY, BTN_GAMEPAD);
}
Crafter::Gamepad::Device* OpenDevice(const char* devnode, const char* syspath, const char* name) {
int fd = ::open(devnode, O_RDWR | O_NONBLOCK);
if (fd < 0) {
// Read-only is fine if we just can't rumble.
fd = ::open(devnode, O_RDONLY | O_NONBLOCK);
if (fd < 0) return nullptr;
}
libevdev* evdev = nullptr;
if (libevdev_new_from_fd(fd, &evdev) < 0) {
::close(fd);
return nullptr;
}
if (!IsGamepadDevice(evdev)) {
libevdev_free(evdev);
::close(fd);
return nullptr;
}
auto dev = std::make_unique<Crafter::Gamepad::Device>();
dev->id = SyspathToId(syspath);
dev->name = name ? name : libevdev_get_name(evdev);
auto bp = std::make_unique<LinuxBackend>();
bp->evdev = evdev;
bp->fd = fd;
DiscoverAxes(bp.get());
auto* raw = dev.get();
g_backends[raw] = std::move(bp);
Crafter::Gamepad::connected.push_back(std::move(dev));
return raw;
}
void CloseDevice(Crafter::Gamepad::Device* dev) {
auto it = g_backends.find(dev);
if (it != g_backends.end()) {
if (it->second->evdev) libevdev_free(it->second->evdev);
if (it->second->fd >= 0) ::close(it->second->fd);
g_backends.erase(it);
}
auto& v = Crafter::Gamepad::connected;
for (auto vi = v.begin(); vi != v.end(); ++vi) {
if (vi->get() == dev) { v.erase(vi); break; }
}
}
void EnumerateExisting() {
udev_enumerate* en = udev_enumerate_new(g_udev);
if (!en) return;
udev_enumerate_add_match_subsystem(en, "input");
udev_enumerate_scan_devices(en);
udev_list_entry* list = udev_enumerate_get_list_entry(en);
udev_list_entry* entry;
udev_list_entry_foreach(entry, list) {
const char* syspath = udev_list_entry_get_name(entry);
udev_device* d = udev_device_new_from_syspath(g_udev, syspath);
if (!d) continue;
const char* devnode = udev_device_get_devnode(d);
const char* sysname = udev_device_get_sysname(d);
if (devnode && sysname && std::strncmp(sysname, "event", 5) == 0) {
const char* name = udev_device_get_sysattr_value(
udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr),
"name");
auto* dev = OpenDevice(devnode, syspath, name);
if (dev) Crafter::Gamepad::onConnected.Invoke(dev);
}
udev_device_unref(d);
}
udev_enumerate_unref(en);
}
void HandleHotplugEvent(udev_device* d) {
const char* action = udev_device_get_action(d);
const char* devnode = udev_device_get_devnode(d);
const char* sysname = udev_device_get_sysname(d);
const char* syspath = udev_device_get_syspath(d);
if (!action || !devnode || !sysname || !syspath) return;
if (std::strncmp(sysname, "event", 5) != 0) return;
if (std::strcmp(action, "add") == 0) {
const char* name = udev_device_get_sysattr_value(
udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr),
"name");
auto* dev = OpenDevice(devnode, syspath, name);
if (dev) Crafter::Gamepad::onConnected.Invoke(dev);
} else if (std::strcmp(action, "remove") == 0) {
std::uint32_t id = SyspathToId(syspath);
for (auto& up : Crafter::Gamepad::connected) {
if (up->id == id) {
Crafter::Gamepad::Device* dev = up.get();
Crafter::Gamepad::onDisconnected.Invoke(dev);
CloseDevice(dev);
return;
}
}
}
}
void DrainHotplug() {
if (!g_monitor) return;
for (;;) {
udev_device* d = udev_monitor_receive_device(g_monitor);
if (!d) break;
HandleHotplugEvent(d);
udev_device_unref(d);
}
}
void DrainDeviceEvents(Crafter::Gamepad::Device* dev, LinuxBackend* bp) {
using A = Crafter::Gamepad::Axis;
using B = Crafter::Gamepad::Button;
// Walk libevdev's internal queue; SYNC means we missed events and
// need to resync the state. We treat the synced events the same
// way as normal events — net effect is the polled state ends up
// correct and the event stream reports the deltas.
input_event ev;
int rc;
while ((rc = libevdev_next_event(bp->evdev,
LIBEVDEV_READ_FLAG_NORMAL | LIBEVDEV_READ_FLAG_SYNC,
&ev)) >= 0)
{
if (rc == LIBEVDEV_READ_STATUS_SYNC) continue; // handled in next iter
if (ev.type == EV_KEY) {
int b = ButtonFromKernel(ev.code);
if (b < 0) continue;
bool pressed = (ev.value != 0);
if (dev->buttons[b] == pressed) continue;
dev->buttons[b] = pressed;
if (pressed) dev->onButtonDown.Invoke((B)b);
else dev->onButtonUp .Invoke((B)b);
} else if (ev.type == EV_ABS) {
// ABS_HAT0X / ABS_HAT0Y emulate the D-pad on some
// controllers. Translate to the four DPad buttons so
// bindings can be uniform.
if (ev.code == ABS_HAT0X) {
bool left = ev.value < 0;
bool right = ev.value > 0;
auto edge = [&](int idx, bool now) {
if (dev->buttons[idx] == now) return;
dev->buttons[idx] = now;
if (now) dev->onButtonDown.Invoke((B)idx);
else dev->onButtonUp .Invoke((B)idx);
};
edge((int)B::DPadLeft, left);
edge((int)B::DPadRight, right);
continue;
}
if (ev.code == ABS_HAT0Y) {
bool up = ev.value < 0;
bool down = ev.value > 0;
auto edge = [&](int idx, bool now) {
if (dev->buttons[idx] == now) return;
dev->buttons[idx] = now;
if (now) dev->onButtonDown.Invoke((B)idx);
else dev->onButtonUp .Invoke((B)idx);
};
edge((int)B::DPadUp, up);
edge((int)B::DPadDown, down);
continue;
}
// Look up which Gamepad::Axis this kernel code maps to
// (by scanning the calibration table — sticks first,
// triggers second).
for (std::size_t i = 0; i < (std::size_t)A::Max; ++i) {
if (bp->axisInfo[i].code != ev.code) continue;
bool isTrigger = (i == (std::size_t)A::LeftTrigger
|| i == (std::size_t)A::RightTrigger);
float v = NormalizeAxis(bp->axisInfo[i], ev.value, isTrigger);
if (dev->axes[i] == v) break;
dev->axes[i] = v;
dev->onAxisChanged.Invoke((A)i);
break;
}
}
}
}
void ExpireRumbles() {
auto now = std::chrono::steady_clock::now();
for (auto& [dev, bp] : g_backends) {
if (bp->ffEffectId >= 0 && now >= bp->rumbleExpiry) {
input_event stop{};
stop.type = EV_FF;
stop.code = bp->ffEffectId;
stop.value = 0;
if (bp->fd >= 0) ::write(bp->fd, &stop, sizeof(stop));
ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId);
bp->ffEffectId = -1;
}
}
}
void EnsureInit() {
if (g_initialized) return;
g_initialized = true;
g_udev = udev_new();
if (!g_udev) return;
g_monitor = udev_monitor_new_from_netlink(g_udev, "udev");
if (g_monitor) {
udev_monitor_filter_add_match_subsystem_devtype(g_monitor, "input", nullptr);
udev_monitor_enable_receiving(g_monitor);
}
EnumerateExisting();
}
}
void Crafter::Gamepad::Tick() {
EnsureInit();
DrainHotplug();
for (auto& up : Gamepad::connected) {
Device* dev = up.get();
auto it = g_backends.find(dev);
if (it == g_backends.end()) continue;
DrainDeviceEvents(dev, it->second.get());
}
ExpireRumbles();
}
void Crafter::Gamepad::Rumble(Device& dev, float low, float high,
std::chrono::milliseconds duration)
{
auto it = g_backends.find(&dev);
if (it == g_backends.end()) return;
LinuxBackend* bp = it->second.get();
if (bp->fd < 0) return;
// Cancel any active effect first.
if (bp->ffEffectId >= 0) {
input_event stop{};
stop.type = EV_FF; stop.code = bp->ffEffectId; stop.value = 0;
::write(bp->fd, &stop, sizeof(stop));
ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId);
bp->ffEffectId = -1;
}
if (duration.count() <= 0 || (low <= 0.0f && high <= 0.0f)) return;
auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; };
ff_effect effect{};
effect.type = FF_RUMBLE;
effect.id = -1;
effect.u.rumble.strong_magnitude = (std::uint16_t)(clamp01(low) * 0xFFFFu);
effect.u.rumble.weak_magnitude = (std::uint16_t)(clamp01(high) * 0xFFFFu);
effect.replay.length = (std::uint16_t)std::min<std::int64_t>(duration.count(), 0xFFFF);
if (ioctl(bp->fd, EVIOCSFF, &effect) < 0) return;
bp->ffEffectId = effect.id;
bp->rumbleExpiry = std::chrono::steady_clock::now() + duration;
input_event play{};
play.type = EV_FF;
play.code = effect.id;
play.value = 1;
::write(bp->fd, &play, sizeof(play));
}
#endif
// ────────────────────────────────────────────────────────────────────
// Win32 backend (Windows.Gaming.Input via C ABI)
// ────────────────────────────────────────────────────────────────────
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
namespace {
using IGamepad = __x_ABI_CWindows_CGaming_CInput_CIGamepad;
using IGamepadStatics = __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics;
using GamepadAddedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad;
using GamepadRemovedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad;
using GamepadReading = __x_ABI_CWindows_CGaming_CInput_CGamepadReading;
using GamepadVibration = __x_ABI_CWindows_CGaming_CInput_CGamepadVibration;
using GamepadButtons = __x_ABI_CWindows_CGaming_CInput_CGamepadButtons;
IGamepadStatics* g_statics = nullptr;
EventRegistrationToken g_addedToken {};
EventRegistrationToken g_removedToken {};
bool g_initialized = false;
// Cross-thread queue. WGI fires Added/Removed on a thread pool; the
// event loop drains this on the main thread inside Tick().
struct QueueEntry {
bool added;
IGamepad* gamepad; // AddRef'd
};
std::mutex g_queueMutex;
std::vector<QueueEntry> g_queue;
// Maps a live IGamepad* to its Crafter::Gamepad::Device. Pointer is
// stable from WGI's perspective for the lifetime of the connection.
std::unordered_map<IGamepad*, Crafter::Gamepad::Device*> g_byGamepad;
std::uint32_t PointerToId(IGamepad* g) {
// Hash the pointer for a stable-while-connected id. (NonRoamableId
// would be more durable across reconnects but adds another COM
// round-trip; pointer hash is fine for v1.)
std::uintptr_t v = reinterpret_cast<std::uintptr_t>(g);
v = (v ^ (v >> 16)) * 0x85EBCA6Bu;
v = (v ^ (v >> 13)) * 0xC2B2AE35u;
v = v ^ (v >> 16);
std::uint32_t out = (std::uint32_t)v;
return out == 0 ? 1 : out;
}
// ── Delegate vtable for GamepadAdded / GamepadRemoved ───────────
struct AddedDelegate {
// The first member matches the C ABI vtable layout. Cast yields
// a valid GamepadAddedHandler*.
void* vtbl;
LONG refCount;
bool isAdded;
};
extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl;
extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl;
HRESULT STDMETHODCALLTYPE DelegateQI(GamepadAddedHandler* This, REFIID riid, void** out) {
if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject) ||
IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad))
{
*out = This;
This->lpVtbl->AddRef(This);
return S_OK;
}
*out = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE DelegateAddRef(GamepadAddedHandler* This) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
return (ULONG)InterlockedIncrement(&d->refCount);
}
ULONG STDMETHODCALLTYPE DelegateRelease(GamepadAddedHandler* This) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
LONG r = InterlockedDecrement(&d->refCount);
return (ULONG)r;
}
HRESULT STDMETHODCALLTYPE DelegateInvoke(GamepadAddedHandler* This, IInspectable* sender, IGamepad* gamepad) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
if (gamepad) IGamepad_AddRef(gamepad);
{
std::lock_guard<std::mutex> lock(g_queueMutex);
g_queue.push_back({ d->isAdded, gamepad });
}
return S_OK;
}
GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl = {
DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke
};
GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl = {
DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke
};
AddedDelegate g_addedDelegate { &g_addedVtbl, 1, true };
AddedDelegate g_removedDelegate { &g_removedVtbl, 1, false };
// Translate GamepadReading.Buttons bitfield to our Button enum.
bool ReadingHas(const GamepadReading& r, GamepadButtons b) {
return (r.Buttons & b) != 0;
}
void ApplyReading(Crafter::Gamepad::Device* dev, const GamepadReading& r) {
using B = Crafter::Gamepad::Button;
using A = Crafter::Gamepad::Axis;
struct Map { B which; GamepadButtons mask; };
static const Map buttonMap[] = {
{ B::DPadUp, GamepadButtons_DPadUp },
{ B::DPadDown, GamepadButtons_DPadDown },
{ B::DPadLeft, GamepadButtons_DPadLeft },
{ B::DPadRight, GamepadButtons_DPadRight },
{ B::Start, GamepadButtons_Menu },
{ B::Select, GamepadButtons_View },
{ B::LeftStickClick, GamepadButtons_LeftThumbstick },
{ B::RightStickClick,GamepadButtons_RightThumbstick },
{ B::LeftBumper, GamepadButtons_LeftShoulder },
{ B::RightBumper, GamepadButtons_RightShoulder },
{ B::South, GamepadButtons_A },
{ B::East, GamepadButtons_B },
{ B::West, GamepadButtons_X },
{ B::North, GamepadButtons_Y },
};
for (const Map& m : buttonMap) {
bool now = ReadingHas(r, m.mask);
bool& cur = dev->buttons[(std::size_t)m.which];
if (cur == now) continue;
cur = now;
if (now) dev->onButtonDown.Invoke(m.which);
else dev->onButtonUp .Invoke(m.which);
}
// WGI doesn't have a Home/Guide button in the standard Gamepad
// interface (it's reserved for the OS). Leave dev->buttons[Home]
// at false.
auto axis = [&](A which, float v) {
float& cur = dev->axes[(std::size_t)which];
if (cur == v) return;
cur = v;
dev->onAxisChanged.Invoke(which);
};
axis(A::LeftStickX, (float)r.LeftThumbstickX);
axis(A::LeftStickY, (float)r.LeftThumbstickY);
axis(A::RightStickX, (float)r.RightThumbstickX);
axis(A::RightStickY, (float)r.RightThumbstickY);
axis(A::LeftTrigger, (float)r.LeftTrigger);
axis(A::RightTrigger, (float)r.RightTrigger);
// Triggers also expose digital "pressed" buttons at a threshold.
constexpr float kTriggerThreshold = 0.1f;
auto trigBtn = [&](B which, double value) {
bool now = value >= kTriggerThreshold;
bool& cur = dev->buttons[(std::size_t)which];
if (cur == now) return;
cur = now;
if (now) dev->onButtonDown.Invoke(which);
else dev->onButtonUp .Invoke(which);
};
trigBtn(B::LeftTrigger, r.LeftTrigger);
trigBtn(B::RightTrigger, r.RightTrigger);
}
void EnsureInit() {
if (g_initialized) return;
g_initialized = true;
// RoInitialize is required once per thread; ignore RPC_E_CHANGED_MODE
// (some hosts initialize as STA, that's fine for WGI).
RoInitialize(RO_INIT_MULTITHREADED);
HSTRING_HEADER header;
HSTRING className;
const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad;
if (FAILED(WindowsCreateStringReference(name,
(UINT32)wcslen(name), &header, &className))) return;
if (FAILED(RoGetActivationFactory(className,
&IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics,
(void**)&g_statics))) return;
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded(
g_statics,
reinterpret_cast<GamepadAddedHandler*>(&g_addedDelegate),
&g_addedToken);
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved(
g_statics,
reinterpret_cast<GamepadAddedHandler*>(&g_removedDelegate),
&g_removedToken);
}
}
void Crafter::Gamepad::Tick() {
EnsureInit();
if (!g_statics) return;
// Drain the cross-thread connect/disconnect queue.
std::vector<QueueEntry> pending;
{
std::lock_guard<std::mutex> lock(g_queueMutex);
pending.swap(g_queue);
}
for (QueueEntry& e : pending) {
if (!e.gamepad) continue;
if (e.added) {
if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) {
IGamepad_Release(e.gamepad);
continue;
}
auto dev = std::make_unique<Crafter::Gamepad::Device>();
dev->id = PointerToId(e.gamepad);
dev->name = "Gamepad";
auto* raw = dev.get();
g_byGamepad[e.gamepad] = raw;
connected.push_back(std::move(dev));
onConnected.Invoke(raw);
// keep AddRef'd; released on disconnect
} else {
auto it = g_byGamepad.find(e.gamepad);
if (it != g_byGamepad.end()) {
Crafter::Gamepad::Device* raw = it->second;
onDisconnected.Invoke(raw);
for (auto vi = connected.begin(); vi != connected.end(); ++vi) {
if (vi->get() == raw) { connected.erase(vi); break; }
}
g_byGamepad.erase(it);
}
IGamepad_Release(e.gamepad);
}
}
// Poll each connected gamepad.
for (auto& [gp, dev] : g_byGamepad) {
GamepadReading reading{};
if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading(gp, &reading))) continue;
ApplyReading(dev, reading);
}
}
void Crafter::Gamepad::Rumble(Device& dev, float low, float high,
std::chrono::milliseconds /*duration*/)
{
// WGI vibration is "set and forget" — no duration. Caller can stop
// by re-calling with zero magnitudes. We honor 0 duration as stop.
IGamepad* gp = nullptr;
for (auto& [g, d] : g_byGamepad) {
if (d == &dev) { gp = g; break; }
}
if (!gp) return;
auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; };
GamepadVibration vib{};
vib.LeftMotor = clamp01(low);
vib.RightMotor = clamp01(high);
__x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration(gp, vib);
}
#endif
Crafter::Gamepad::Device* Crafter::Gamepad::FindById(std::uint32_t id) {
for (auto& up : connected) {
if (up->id == id) return up.get();
}
return nullptr;
}

View file

@ -0,0 +1,454 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
module Crafter.Graphics;
import std;
import Crafter.Math;
import Crafter.Event;
using namespace Crafter;
using namespace Crafter::Input;
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
namespace {
// Look up a connected gamepad by stable id. Returns nullptr if not
// currently connected — the binding becomes inert until reconnect.
Gamepad::Device* FindGamepad(std::uint32_t id) {
return Gamepad::FindById(id);
}
float ApplyDeadzone(float v, float dz) {
float a = std::abs(v);
if (a <= dz) return 0.0f;
float sign = v < 0 ? -1.0f : 1.0f;
return sign * (a - dz) / (1.0f - dz);
}
Vector<float, 2> ApplyRadialDeadzone(Vector<float, 2> v, float dz) {
float mag = std::sqrt(v.x * v.x + v.y * v.y);
if (mag <= dz) return Vector<float, 2>{0.0f, 0.0f};
float scaled = (mag - dz) / (1.0f - dz);
float k = scaled / mag;
return Vector<float, 2>{ v.x * k, v.y * k };
}
// ─── Evaluate one binding to its raw contribution. ───────────────
// Buttons: bool. Axis: float. Vector2: pair.
struct Eval {
bool button = false;
float value = 0.0f;
Vector<float, 2> vec { 0.0f, 0.0f };
};
Eval EvalBinding(const Binding& b, Window* w, std::int32_t scrollAccum,
Vector<float, 2> mouseDelta, float deadzone)
{
Eval r;
std::visit([&](auto const& bb) {
using T = std::decay_t<decltype(bb)>;
if constexpr (std::is_same_v<T, KeyBind>) {
if (w && w->heldKeys.contains(bb.code)) r.button = true;
r.value = r.button ? 1.0f : 0.0f;
} else if constexpr (std::is_same_v<T, MouseButtonBind>) {
if (w) {
if (bb.button == 0 && w->mouseLeftHeld) r.button = true;
if (bb.button == 1 && w->mouseRightHeld) r.button = true;
}
r.value = r.button ? 1.0f : 0.0f;
} else if constexpr (std::is_same_v<T, MouseScrollBind>) {
r.value = (float)scrollAccum;
r.button = scrollAccum != 0;
} else if constexpr (std::is_same_v<T, MouseDeltaBind>) {
r.vec = Vector<float, 2>{
mouseDelta.x * bb.scale,
mouseDelta.y * bb.scale
};
} else if constexpr (std::is_same_v<T, GamepadButtonBind>) {
auto* gp = FindGamepad(bb.gamepadId);
if (gp) r.button = gp->buttons[(std::size_t)bb.button];
r.value = r.button ? 1.0f : 0.0f;
} else if constexpr (std::is_same_v<T, GamepadAxisBind>) {
auto* gp = FindGamepad(bb.gamepadId);
if (gp) {
float v = gp->axes[(std::size_t)bb.axis];
if (bb.invert) v = -v;
// Triggers (0..1) and sticks (-1..1) share this path;
// deadzone applies symmetrically.
r.value = ApplyDeadzone(v, deadzone);
r.button = std::abs(r.value) > 0.0f;
}
} else if constexpr (std::is_same_v<T, GamepadStickBind>) {
auto* gp = FindGamepad(bb.gamepadId);
if (gp) {
using A = Gamepad::Axis;
A xAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickX : A::RightStickX;
A yAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickY : A::RightStickY;
Vector<float, 2> raw{
gp->axes[(std::size_t)xAxis],
gp->axes[(std::size_t)yAxis]
};
r.vec = ApplyRadialDeadzone(raw, deadzone);
}
} else if constexpr (std::is_same_v<T, WASDBind>) {
Vector<float, 2> v{ 0.0f, 0.0f };
if (w) {
if (w->heldKeys.contains(bb.left)) v.x -= 1.0f;
if (w->heldKeys.contains(bb.right)) v.x += 1.0f;
if (w->heldKeys.contains(bb.up)) v.y += 1.0f;
if (w->heldKeys.contains(bb.down)) v.y -= 1.0f;
}
r.vec = v;
}
}, b);
return r;
}
// Combine bindings for the action's type. For Button: OR. For Axis1D:
// sum-then-clamp (so two analog inputs add but cap at ±1; two
// digital inputs cap at 1 too). For Vector2: per-axis sum-and-clamp.
void EvaluateAction(Action& a, Window* w, std::int32_t scrollAccum,
Vector<float, 2> mouseDelta)
{
bool nextPressed = false;
float nextValue = 0.0f;
Vector<float, 2> nextVec { 0.0f, 0.0f };
for (const Binding& b : a.bindings) {
Eval e = EvalBinding(b, w, scrollAccum, mouseDelta, a.deadzone);
if (e.button) nextPressed = true;
nextValue += e.value;
nextVec.x += e.vec.x;
nextVec.y += e.vec.y;
}
// Clamp scalars/vectors to ±1; sum semantics for combining.
auto clamp = [](float v) { return v < -1.0f ? -1.0f : v > 1.0f ? 1.0f : v; };
nextValue = clamp(nextValue);
nextVec.x = clamp(nextVec.x);
nextVec.y = clamp(nextVec.y);
// Dispatch edges + value changes.
if (a.type == ActionType::Button) {
if (nextPressed && !a.pressed) {
a.pressed = true;
a.onPerformed.Invoke();
} else if (!nextPressed && a.pressed) {
a.pressed = false;
a.onCanceled.Invoke();
}
} else if (a.type == ActionType::Axis1D) {
bool wasNonZero = a.value != 0.0f;
bool isNonZero = nextValue != 0.0f;
if (nextValue != a.value) {
a.value = nextValue;
a.onValueChanged.Invoke(nextValue);
}
if (isNonZero && !wasNonZero) a.onPerformed.Invoke();
else if (!isNonZero && wasNonZero) a.onCanceled.Invoke();
} else { // Vector2
bool wasNonZero = (a.vector2.x != 0.0f || a.vector2.y != 0.0f);
bool isNonZero = (nextVec.x != 0.0f || nextVec.y != 0.0f);
if (nextVec.x != a.vector2.x || nextVec.y != a.vector2.y) {
a.vector2 = nextVec;
a.onVector2Changed.Invoke(nextVec);
}
if (isNonZero && !wasNonZero) a.onPerformed.Invoke();
else if (!isNonZero && wasNonZero) a.onCanceled.Invoke();
}
}
// ─── Rebind detection ───────────────────────────────────────────
// Scan for a fresh down-edge against the snapshot taken at
// StartRebind. Returns the captured binding if found, nullopt if
// nothing fresh is held. The first match wins; preference goes
// keyboard > mouse > gamepad to keep the behavior predictable.
std::optional<Binding> DetectRebindEdge(Map::RebindState& s, Window* w) {
// Keyboard
if (w && HasFlag(s.mask, CaptureMask::Keyboard)) {
for (KeyCode c : w->heldKeys) {
if (!s.keysHeldAtStart.contains(c)) {
return Binding{ KeyBind{c} };
}
}
}
// Mouse buttons
if (w && HasFlag(s.mask, CaptureMask::Mouse)) {
if (w->mouseLeftHeld && !s.mouseLeftHeldAtStart) {
return Binding{ MouseButtonBind{0} };
}
if (w->mouseRightHeld && !s.mouseRightHeldAtStart) {
return Binding{ MouseButtonBind{1} };
}
}
// Gamepad buttons + axes
if (HasFlag(s.mask, CaptureMask::Gamepad)) {
for (auto& up : Gamepad::connected) {
Gamepad::Device* gp = up.get();
auto it = s.gamepadButtonsAtStart.find(gp->id);
bool hadSnapshot = it != s.gamepadButtonsAtStart.end();
for (std::size_t i = 0; i < (std::size_t)Gamepad::Button::Max; ++i) {
bool now = gp->buttons[i];
bool before = hadSnapshot ? it->second[i] : false;
if (now && !before) {
return Binding{ GamepadButtonBind{
gp->id, (Gamepad::Button)i
} };
}
}
// Axes: any value past 0.5 captures as a 1-D axis bind.
constexpr float kRebindAxisThreshold = 0.5f;
for (std::size_t i = 0; i < (std::size_t)Gamepad::Axis::Max; ++i) {
float v = gp->axes[i];
if (std::abs(v) >= kRebindAxisThreshold) {
return Binding{ GamepadAxisBind{
gp->id, (Gamepad::Axis)i, v < 0.0f
} };
}
}
}
}
return std::nullopt;
}
}
// ────────────────────────────────────────────────────────────────────
// Map
// ────────────────────────────────────────────────────────────────────
Action& Map::AddAction(std::string name, ActionType type) {
auto up = std::make_unique<Action>();
up->name = std::move(name);
up->type = type;
Action& ref = *up;
actions.push_back(std::move(up));
return ref;
}
Action* Map::Find(std::string_view name) {
for (auto& up : actions) {
if (up->name == name) return up.get();
}
return nullptr;
}
void Map::Attach(Window& w) {
Detach();
window = &w;
// Only event-driven source we need is mouse scroll — everything
// else (held keys, mouse buttons, mouse position, gamepad state) is
// polled directly. Accumulate scroll between ticks so a flick that
// fires multiple wheel events in one frame all count.
scrollListener = std::make_unique<EventListener<std::uint32_t>>(
&w.onMouseScroll,
[this](std::uint32_t delta) {
// Wayland and Win32 both deliver scroll as a signed delta
// packed into a uint32; we reinterpret to preserve sign.
scrollAccumulator += (std::int32_t)delta;
});
lastMousePos.reset();
scrollAccumulator = 0;
}
void Map::Detach() {
if (scrollListener) {
scrollListener->Clear();
scrollListener.reset();
}
window = nullptr;
scrollAccumulator = 0;
lastMousePos.reset();
rebind.reset();
}
void Map::Tick() {
// Mouse delta: derived from window position vs. last frame.
Vector<float, 2> mouseDelta{ 0.0f, 0.0f };
if (window) {
if (lastMousePos.has_value()) {
mouseDelta.x = window->currentMousePos.x - lastMousePos->x;
mouseDelta.y = window->currentMousePos.y - lastMousePos->y;
}
lastMousePos = window->currentMousePos;
}
// Rebind takes priority. If a fresh edge is detected, fire the
// callback and exit rebind mode. Actions don't evaluate this tick
// for the captured input — the user usually wants the bind to take
// effect on the NEXT press of that input.
if (rebind.has_value()) {
if (auto captured = DetectRebindEdge(*rebind, window)) {
auto cb = std::move(rebind->onCaptured);
rebind.reset();
// Drain scroll so the next Tick doesn't double-fire on the
// same wheel input that may have triggered the rebind.
scrollAccumulator = 0;
cb(*captured);
return;
}
}
std::int32_t scrollThisTick = scrollAccumulator;
scrollAccumulator = 0;
for (auto& up : actions) {
EvaluateAction(*up, window, scrollThisTick, mouseDelta);
}
}
void Map::StartRebind(Action& action, CaptureMask mask,
std::function<void(Binding)> onCaptured)
{
RebindState s;
s.action = &action;
s.mask = mask;
s.onCaptured = std::move(onCaptured);
if (window) {
s.keysHeldAtStart = window->heldKeys;
s.mouseLeftHeldAtStart = window->mouseLeftHeld;
s.mouseRightHeldAtStart = window->mouseRightHeld;
}
for (auto& up : Gamepad::connected) {
std::array<bool, (std::size_t)Gamepad::Button::Max> snap{};
for (std::size_t i = 0; i < snap.size(); ++i) {
snap[i] = up->buttons[i];
}
s.gamepadButtonsAtStart[up->id] = snap;
}
rebind.emplace(std::move(s));
}
void Map::StopRebind() {
rebind.reset();
}
bool Map::IsRebinding() const {
return rebind.has_value();
}
// ────────────────────────────────────────────────────────────────────
// Serialization
// ────────────────────────────────────────────────────────────────────
std::string Crafter::Input::BindingToString(const Binding& b) {
std::string out;
std::visit([&](auto const& bb) {
using T = std::decay_t<decltype(bb)>;
if constexpr (std::is_same_v<T, KeyBind>) {
out = std::format("key:{:x}", bb.code);
} else if constexpr (std::is_same_v<T, MouseButtonBind>) {
out = std::format("mb:{}", (int)bb.button);
} else if constexpr (std::is_same_v<T, MouseScrollBind>) {
out = "mscroll";
} else if constexpr (std::is_same_v<T, MouseDeltaBind>) {
out = std::format("mdelta:{}", bb.scale);
} else if constexpr (std::is_same_v<T, GamepadButtonBind>) {
out = std::format("gpb:{}:{}", bb.gamepadId, (int)bb.button);
} else if constexpr (std::is_same_v<T, GamepadAxisBind>) {
out = std::format("gpa:{}:{}:{}", bb.gamepadId, (int)bb.axis, bb.invert ? 1 : 0);
} else if constexpr (std::is_same_v<T, GamepadStickBind>) {
out = std::format("gps:{}:{}", bb.gamepadId, (int)bb.stick);
} else if constexpr (std::is_same_v<T, WASDBind>) {
out = std::format("wasd:{:x}:{:x}:{:x}:{:x}",
bb.up, bb.down, bb.left, bb.right);
}
}, b);
return out;
}
namespace {
// Split on ':' and parse. Tolerates leading whitespace; rejects
// malformed input by returning nullopt.
std::vector<std::string_view> Split(std::string_view s) {
std::vector<std::string_view> parts;
std::size_t start = 0;
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == ':') {
parts.emplace_back(s.substr(start, i - start));
start = i + 1;
}
}
parts.emplace_back(s.substr(start));
return parts;
}
bool ParseHex(std::string_view s, std::uint32_t& out) {
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16);
return ec == std::errc{} && p == s.data() + s.size();
}
bool ParseDec(std::string_view s, std::uint32_t& out) {
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 10);
return ec == std::errc{} && p == s.data() + s.size();
}
bool ParseFloat(std::string_view s, float& out) {
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out);
return ec == std::errc{} && p == s.data() + s.size();
}
}
std::optional<Binding> Crafter::Input::BindingFromString(std::string_view s) {
auto parts = Split(s);
if (parts.empty()) return std::nullopt;
const std::string_view tag = parts[0];
if (tag == "key" && parts.size() == 2) {
std::uint32_t v;
if (!ParseHex(parts[1], v)) return std::nullopt;
return Binding{ KeyBind{ v } };
}
if (tag == "mb" && parts.size() == 2) {
std::uint32_t v;
if (!ParseDec(parts[1], v) || v > 255) return std::nullopt;
return Binding{ MouseButtonBind{ (std::uint8_t)v } };
}
if (tag == "mscroll") return Binding{ MouseScrollBind{} };
if (tag == "mdelta" && parts.size() == 2) {
float scale;
if (!ParseFloat(parts[1], scale)) return std::nullopt;
return Binding{ MouseDeltaBind{ scale } };
}
if (tag == "gpb" && parts.size() == 3) {
std::uint32_t id, btn;
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], btn)) return std::nullopt;
if (btn >= (std::uint32_t)Gamepad::Button::Max) return std::nullopt;
return Binding{ GamepadButtonBind{ id, (Gamepad::Button)btn } };
}
if (tag == "gpa" && parts.size() == 4) {
std::uint32_t id, ax, inv;
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], ax) || !ParseDec(parts[3], inv)) {
return std::nullopt;
}
if (ax >= (std::uint32_t)Gamepad::Axis::Max) return std::nullopt;
return Binding{ GamepadAxisBind{ id, (Gamepad::Axis)ax, inv != 0 } };
}
if (tag == "gps" && parts.size() == 3) {
std::uint32_t id, st;
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], st)) return std::nullopt;
if (st > 1) return std::nullopt;
return Binding{ GamepadStickBind{ id, (Gamepad::Stick)st } };
}
if (tag == "wasd" && parts.size() == 5) {
std::uint32_t u, d, l, r;
if (!ParseHex(parts[1], u) || !ParseHex(parts[2], d) ||
!ParseHex(parts[3], l) || !ParseHex(parts[4], r)) {
return std::nullopt;
}
return Binding{ WASDBind{ u, d, l, r } };
}
return std::nullopt;
}

View file

@ -23,6 +23,7 @@ import :UI;
import :UIComponents; import :UIComponents;
import :Font; import :Font;
import :Types; import :Types;
import :Keys;
import std; import std;
using namespace Crafter; using namespace Crafter;
@ -87,32 +88,26 @@ void Crafter::InputField_OnText(InputField& f, std::string_view utf8) {
f.cursorPos += utf8.size(); f.cursorPos += utf8.size();
} }
void Crafter::InputField_OnKey(InputField& f, CrafterKeys key) { void Crafter::InputField_OnKey(InputField& f, KeyCode code) {
switch (key) { // `Key(...)` is consteval — every comparison below folds to an immediate
case CrafterKeys::Backspace: // integer at compile time, so this is just a sequence of int-eq tests.
if (f.cursorPos > 0 && !f.value.empty()) { if (code == Key(CrafterKeys::Backspace)) {
f.value.erase(f.cursorPos - 1, 1); if (f.cursorPos > 0 && !f.value.empty()) {
--f.cursorPos; f.value.erase(f.cursorPos - 1, 1);
} --f.cursorPos;
break; }
case CrafterKeys::Delete: } else if (code == Key(CrafterKeys::Delete)) {
if (f.cursorPos < f.value.size()) { if (f.cursorPos < f.value.size()) {
f.value.erase(f.cursorPos, 1); f.value.erase(f.cursorPos, 1);
} }
break; } else if (code == Key(CrafterKeys::Left)) {
case CrafterKeys::Left: if (f.cursorPos > 0) --f.cursorPos;
if (f.cursorPos > 0) --f.cursorPos; } else if (code == Key(CrafterKeys::Right)) {
break; if (f.cursorPos < f.value.size()) ++f.cursorPos;
case CrafterKeys::Right: } else if (code == Key(CrafterKeys::Home)) {
if (f.cursorPos < f.value.size()) ++f.cursorPos; f.cursorPos = 0;
break; } else if (code == Key(CrafterKeys::End)) {
case CrafterKeys::Home: f.cursorPos = f.value.size();
f.cursorPos = 0;
break;
case CrafterKeys::End:
f.cursorPos = f.value.size();
break;
default: break;
} }
} }

View file

@ -21,16 +21,112 @@ module;
#include "vulkan/vulkan.h" #include "vulkan/vulkan.h"
module Crafter.Graphics:Mesh_impl; module Crafter.Graphics:Mesh_impl;
import Crafter.Math; import Crafter.Math;
import Crafter.Asset;
import :Mesh; import :Mesh;
import :Device; import :Device;
import :Decompress;
import :Types; import :Types;
import std; import std;
using namespace Crafter; using namespace Crafter;
namespace {
// Buffer-usage flag set shared by both Build paths. The compressed path
// appends VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT.
constexpr VkBufferUsageFlags2 kVertexUsageBase =
VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
| VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR;
constexpr VkBufferUsageFlags2 kIndexUsageBase =
kVertexUsageBase | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
void RecordBLASBuild(Mesh& self, std::uint32_t vertexCount, std::uint32_t indexCount, VkCommandBuffer cmd) {
VkDeviceOrHostAddressConstKHR vertexAddr;
vertexAddr.deviceAddress = self.vertexBuffer.address;
VkDeviceOrHostAddressConstKHR indexAddr;
indexAddr.deviceAddress = self.indexBuffer.address;
auto trianglesData = VkAccelerationStructureGeometryTrianglesDataKHR {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR,
.vertexFormat = VK_FORMAT_R32G32B32_SFLOAT,
.vertexData = vertexAddr,
.vertexStride = sizeof(Vector<float, 3, 3>),
.maxVertex = vertexCount - 1,
.indexType = VK_INDEX_TYPE_UINT32,
.indexData = indexAddr,
.transformData = {.deviceAddress = 0}
};
VkAccelerationStructureGeometryDataKHR geometryData(trianglesData);
VkAccelerationStructureGeometryKHR blasGeometry {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR,
.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR,
.geometry = geometryData,
.flags = VK_GEOMETRY_OPAQUE_BIT_KHR
};
VkAccelerationStructureBuildGeometryInfoKHR blasBuildGeometryInfo{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR,
.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR,
.mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR,
.geometryCount = 1,
.pGeometries = &blasGeometry,
};
auto primitiveCount = indexCount / 3;
VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR
};
Device::vkGetAccelerationStructureBuildSizesKHR(
Device::device,
VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR,
&blasBuildGeometryInfo,
&primitiveCount,
&blasBuildSizes
);
self.scratchBuffer.Resize(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
blasBuildSizes.buildScratchSize);
blasBuildGeometryInfo.scratchData.deviceAddress = self.scratchBuffer.address;
self.blasBuffer.Resize(
VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR
| VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
| VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
blasBuildSizes.accelerationStructureSize);
VkAccelerationStructureCreateInfoKHR blasCreateInfo{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR,
.buffer = self.blasBuffer.buffer,
.offset = 0,
.size = blasBuildSizes.accelerationStructureSize,
.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR,
};
Device::CheckVkResult(Device::vkCreateAccelerationStructureKHR(Device::device, &blasCreateInfo, nullptr, &self.accelerationStructure));
blasBuildGeometryInfo.dstAccelerationStructure = self.accelerationStructure;
VkAccelerationStructureBuildRangeInfoKHR blasRangeInfo {
.primitiveCount = primitiveCount,
.primitiveOffset = 0,
.firstVertex = 0,
.transformOffset = 0
};
VkAccelerationStructureBuildRangeInfoKHR* blasRangeInfoPP = &blasRangeInfo;
Device::vkCmdBuildAccelerationStructuresKHR(cmd, 1, &blasBuildGeometryInfo, &blasRangeInfoPP);
VkAccelerationStructureDeviceAddressInfoKHR addrInfo {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR,
.accelerationStructure = self.accelerationStructure
};
self.blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo);
}
}
void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd) { void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd) {
vertexBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, verticies.size()); vertexBuffer.Resize(kVertexUsageBase, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, verticies.size());
indexBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, indicies.size()); indexBuffer.Resize(kIndexUsageBase, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, indicies.size());
std::memcpy(vertexBuffer.value, verticies.data(), verticies.size() * sizeof(Vector<float, 3, 3>)); std::memcpy(vertexBuffer.value, verticies.data(), verticies.size() * sizeof(Vector<float, 3, 3>));
std::memcpy(indexBuffer.value, indicies.data(), indicies.size() * sizeof(std::uint32_t)); std::memcpy(indexBuffer.value, indicies.data(), indicies.size() * sizeof(std::uint32_t));
@ -38,91 +134,72 @@ void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
vertexBuffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); vertexBuffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
indexBuffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); indexBuffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
VkDeviceOrHostAddressConstKHR vertexAddr; RecordBLASBuild(*this, static_cast<std::uint32_t>(verticies.size()), static_cast<std::uint32_t>(indicies.size()), cmd);
vertexAddr.deviceAddress = vertexBuffer.address; }
VkDeviceOrHostAddressConstKHR indexAddr; void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd) {
indexAddr.deviceAddress = indexBuffer.address; if (!Device::memoryDecompressionSupported) {
// CPU fallback: decompress into temporary host vectors, then take
// the existing uncompressed path. The data region is decompressed
// into a discard buffer (consumer is expected to handle data-stream
// decoding via Compression::DecompressCPU on its own buffer).
std::vector<Vector<float, 3, 3>> vertices(asset.vertexCount);
std::vector<std::uint32_t> indices(asset.indexCount);
std::vector<std::byte> dataDiscard(
asset.blob.regions.size() >= 3 ? asset.blob.regions[2].decompressedSize : 0);
std::array<std::span<std::byte>, 3> outputs = {
std::as_writable_bytes(std::span(vertices)),
std::as_writable_bytes(std::span(indices)),
std::span<std::byte>(dataDiscard),
};
Compression::DecompressCPU(asset.blob, std::span(outputs).first(asset.blob.regions.size()));
Build(vertices, indices, cmd);
return;
}
auto trianglesData = VkAccelerationStructureGeometryTrianglesDataKHR { vertexBuffer.Resize(
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR, kVertexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
.vertexFormat = VK_FORMAT_R32G32B32_SFLOAT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
.vertexData = vertexAddr, asset.vertexCount);
.vertexStride = sizeof(Vector<float, 3, 3>), indexBuffer.Resize(
.maxVertex = static_cast<std::uint32_t>(verticies.size())-1, kIndexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
.indexType = VK_INDEX_TYPE_UINT32, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
.indexData = indexAddr, asset.indexCount);
.transformData = {.deviceAddress = 0}
}; compressedStaging.Resize(
VkAccelerationStructureGeometryDataKHR geometryData(trianglesData); VK_BUFFER_USAGE_TRANSFER_SRC_BIT
VkAccelerationStructureGeometryKHR blasGeometry { | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
.geometry = geometryData, static_cast<std::uint32_t>(asset.blob.bytes.size()));
.flags = VK_GEOMETRY_OPAQUE_BIT_KHR std::memcpy(compressedStaging.value, asset.blob.bytes.data(), asset.blob.bytes.size());
compressedStaging.FlushDevice();
std::array<VkDeviceAddress, 3> dstAddresses = {
vertexBuffer.address,
indexBuffer.address,
0, // data region is not consumed by Mesh; caller handles it separately.
}; };
std::vector<VkDecompressMemoryRegionEXT> regions;
for (std::size_t i = 0; i < asset.blob.regions.size() && i < 2; ++i) {
const Compression::RegionMeta& r = asset.blob.regions[i];
if (r.decompressedSize == 0) continue;
std::span<const std::byte> streamBytes(
asset.blob.bytes.data() + r.srcOffset,
static_cast<std::size_t>(r.compressedSize));
Decompress::ExpandStreamToTileRegions(
streamBytes,
compressedStaging.address + r.srcOffset,
dstAddresses[i],
regions);
}
VkAccelerationStructureBuildGeometryInfoKHR blasBuildGeometryInfo{ Decompress::DecompressOnGPU(
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR, cmd,
.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR, regions,
.mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR, VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
.geometryCount = 1, VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR);
.pGeometries = &blasGeometry,
};
// Query the memory sizes that will be needed for this BLAS RecordBLASBuild(*this, asset.vertexCount, asset.indexCount, cmd);
auto primitiveCount = static_cast<uint32_t>(indicies.size() / 3); }
VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR
};
Device::vkGetAccelerationStructureBuildSizesKHR(
Device::device,
VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR,
&blasBuildGeometryInfo,
&primitiveCount,
&blasBuildSizes
);
scratchBuffer.Resize(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, blasBuildSizes.buildScratchSize);
VkPhysicalDeviceAccelerationStructurePropertiesKHR asProps{
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_PROPERTIES_KHR
};
VkDeviceAddress scratchAddr = scratchBuffer.address;
blasBuildGeometryInfo.scratchData.deviceAddress = scratchAddr;
blasBuffer.Resize(VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, blasBuildSizes.accelerationStructureSize);
// Create and store the BLAS handle
VkAccelerationStructureCreateInfoKHR blasCreateInfo{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR,
.buffer = blasBuffer.buffer,
.offset = 0,
.size = blasBuildSizes.accelerationStructureSize,
.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR,
};
Device::CheckVkResult(Device::vkCreateAccelerationStructureKHR(Device::device, &blasCreateInfo, nullptr, &accelerationStructure));
blasBuildGeometryInfo.dstAccelerationStructure = accelerationStructure;
// Prepare the build range for the BLAS
VkAccelerationStructureBuildRangeInfoKHR blasRangeInfo {
.primitiveCount = primitiveCount,
.primitiveOffset = 0,
.firstVertex = 0,
.transformOffset = 0
};
VkAccelerationStructureBuildRangeInfoKHR* blasRangeInfoPP = &blasRangeInfo;
Device::vkCmdBuildAccelerationStructuresKHR(cmd, 1, &blasBuildGeometryInfo, &blasRangeInfoPP);
VkAccelerationStructureDeviceAddressInfoKHR addrInfo {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR,
.accelerationStructure = accelerationStructure
};
blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo);
}

View file

@ -68,6 +68,14 @@ void UIRenderer::Initialize(Window& window, DescriptorHeapVulkan& heap, VkComman
for (auto& h : heap_->resourceHeap) h.FlushDevice(); for (auto& h : heap_->resourceHeap) h.FlushDevice();
for (auto& h : heap_->samplerHeap) h.FlushDevice(); for (auto& h : heap_->samplerHeap) h.FlushDevice();
// Re-bind the swapchain image descriptors after every resize. Window
// already drained the queue and rebuilt the imageViews[] before
// firing the event, so vkWriteResourceDescriptorsEXT is safe here.
resizeSub_.SetEvent(&window.onResize, [this]() {
WriteSwapchainDescriptors();
for (auto& h : heap_->resourceHeap) h.FlushDevice();
});
(void)initCmd; // reserved for future image-layout tweaks (void)initCmd; // reserved for future image-layout tweaks
} }

View file

@ -56,6 +56,7 @@ module;
module Crafter.Graphics:Window_impl; module Crafter.Graphics:Window_impl;
import :Window; import :Window;
import :Device; import :Device;
import :Gamepad;
import :VulkanTransition; import :VulkanTransition;
import :DescriptorHeapVulkan; import :DescriptorHeapVulkan;
import :RenderPass; import :RenderPass;
@ -110,131 +111,15 @@ int create_shm_file(off_t size) {
#endif #endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
CrafterKeys vk_to_crafter_key(WPARAM vk) // Extract the layout-independent raw key code from a WM_KEY* lParam. Bits
{ // 16-23 hold the PS/2 set-1 scancode byte; bit 24 is the extended-key flag
switch (vk) // (the 0xE0-prefixed variants — RightCtrl, RightAlt, the cursor cluster,
{ // keypad Enter/Slash, the Windows keys). We pack the extended flag into bit
// Alphabet // 8 of the returned KeyCode so it round-trips with the compile-time
case 'A': return CrafterKeys::A; // `Key(CrafterKeys::...)` table in :Keys.
case 'B': return CrafterKeys::B; static inline KeyCode KeyCodeFromLParam(LPARAM lParam) {
case 'C': return CrafterKeys::C; return ((KeyCode)((lParam >> 16) & 0xFF))
case 'D': return CrafterKeys::D; | (((lParam >> 24) & 1u) << 8);
case 'E': return CrafterKeys::E;
case 'F': return CrafterKeys::F;
case 'G': return CrafterKeys::G;
case 'H': return CrafterKeys::H;
case 'I': return CrafterKeys::I;
case 'J': return CrafterKeys::J;
case 'K': return CrafterKeys::K;
case 'L': return CrafterKeys::L;
case 'M': return CrafterKeys::M;
case 'N': return CrafterKeys::N;
case 'O': return CrafterKeys::O;
case 'P': return CrafterKeys::P;
case 'Q': return CrafterKeys::Q;
case 'R': return CrafterKeys::R;
case 'S': return CrafterKeys::S;
case 'T': return CrafterKeys::T;
case 'U': return CrafterKeys::U;
case 'V': return CrafterKeys::V;
case 'W': return CrafterKeys::W;
case 'X': return CrafterKeys::X;
case 'Y': return CrafterKeys::Y;
case 'Z': return CrafterKeys::Z;
// Numbers
case '0': return CrafterKeys::_0;
case '1': return CrafterKeys::_1;
case '2': return CrafterKeys::_2;
case '3': return CrafterKeys::_3;
case '4': return CrafterKeys::_4;
case '5': return CrafterKeys::_5;
case '6': return CrafterKeys::_6;
case '7': return CrafterKeys::_7;
case '8': return CrafterKeys::_8;
case '9': return CrafterKeys::_9;
// Function keys
case VK_F1: return CrafterKeys::F1;
case VK_F2: return CrafterKeys::F2;
case VK_F3: return CrafterKeys::F3;
case VK_F4: return CrafterKeys::F4;
case VK_F5: return CrafterKeys::F5;
case VK_F6: return CrafterKeys::F6;
case VK_F7: return CrafterKeys::F7;
case VK_F8: return CrafterKeys::F8;
case VK_F9: return CrafterKeys::F9;
case VK_F10: return CrafterKeys::F10;
case VK_F11: return CrafterKeys::F11;
case VK_F12: return CrafterKeys::F12;
// Control keys
case VK_ESCAPE: return CrafterKeys::Escape;
case VK_TAB: return CrafterKeys::Tab;
case VK_RETURN: return CrafterKeys::Enter;
case VK_SPACE: return CrafterKeys::Space;
case VK_BACK: return CrafterKeys::Backspace;
case VK_DELETE: return CrafterKeys::Delete;
case VK_INSERT: return CrafterKeys::Insert;
case VK_HOME: return CrafterKeys::Home;
case VK_END: return CrafterKeys::End;
case VK_PRIOR: return CrafterKeys::PageUp;
case VK_NEXT: return CrafterKeys::PageDown;
case VK_CAPITAL: return CrafterKeys::CapsLock;
case VK_NUMLOCK: return CrafterKeys::NumLock;
case VK_SCROLL: return CrafterKeys::ScrollLock;
// Modifiers
case VK_SHIFT: return CrafterKeys::LeftShift;
case VK_LSHIFT: return CrafterKeys::LeftShift;
case VK_RSHIFT: return CrafterKeys::RightShift;
case VK_CONTROL: return CrafterKeys::LeftCtrl;
case VK_LCONTROL: return CrafterKeys::LeftCtrl;
case VK_RCONTROL: return CrafterKeys::RightCtrl;
case VK_LMENU: return CrafterKeys::LeftAlt;
case VK_RMENU: return CrafterKeys::RightAlt;
case VK_LWIN: return CrafterKeys::LeftSuper;
case VK_RWIN: return CrafterKeys::RightSuper;
// Arrows
case VK_UP: return CrafterKeys::Up;
case VK_DOWN: return CrafterKeys::Down;
case VK_LEFT: return CrafterKeys::Left;
case VK_RIGHT: return CrafterKeys::Right;
// Keypad
case VK_NUMPAD0: return CrafterKeys::keypad_0;
case VK_NUMPAD1: return CrafterKeys::keypad_1;
case VK_NUMPAD2: return CrafterKeys::keypad_2;
case VK_NUMPAD3: return CrafterKeys::keypad_3;
case VK_NUMPAD4: return CrafterKeys::keypad_4;
case VK_NUMPAD5: return CrafterKeys::keypad_5;
case VK_NUMPAD6: return CrafterKeys::keypad_6;
case VK_NUMPAD7: return CrafterKeys::keypad_7;
case VK_NUMPAD8: return CrafterKeys::keypad_8;
case VK_NUMPAD9: return CrafterKeys::keypad_9;
case VK_SEPARATOR: return CrafterKeys::keypad_enter;
case VK_ADD: return CrafterKeys::keypad_plus;
case VK_SUBTRACT: return CrafterKeys::keypad_minus;
case VK_MULTIPLY: return CrafterKeys::keypad_multiply;
case VK_DIVIDE: return CrafterKeys::keypad_divide;
case VK_DECIMAL: return CrafterKeys::keypad_decimal;
// Punctuation
case VK_OEM_3: return CrafterKeys::grave; // `
case VK_OEM_MINUS: return CrafterKeys::minus; // -
case VK_OEM_PLUS: return CrafterKeys::equal; // =
case VK_OEM_4: return CrafterKeys::bracket_left; // [
case VK_OEM_6: return CrafterKeys::bracket_right; // ]
case VK_OEM_5: return CrafterKeys::backslash; //
case VK_OEM_1: return CrafterKeys::semicolon; // ;
case VK_OEM_7: return CrafterKeys::quote; // '
case VK_OEM_COMMA:return CrafterKeys::comma; // ,
case VK_OEM_PERIOD:return CrafterKeys::period; // .
case VK_OEM_2: return CrafterKeys::slash; // /
default: throw std::runtime_error(std::format("Unkown VK {}", vk));
}
} }
// Define a window class name // Define a window class name
@ -266,28 +151,36 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
PostQuitMessage(0); PostQuitMessage(0);
break; break;
} }
case WM_SIZE: {
// SIZE_MINIMIZED reports (0, 0) — Resize() short-circuits, so
// we just propagate the values directly. WM_SIZE fires
// synchronously during a drag-resize loop; the StartSync
// pump runs WndProc between frames, so the swapchain is
// never touched mid-Render.
if (window) {
window->Resize(LOWORD(lParam), HIWORD(lParam));
}
break;
}
case WM_KEYDOWN: case WM_KEYDOWN:
case WM_SYSKEYDOWN: { // SYSKEYDOWN catches Alt combos, F10, etc. case WM_SYSKEYDOWN: { // SYSKEYDOWN catches Alt combos, F10, etc.
CrafterKeys crafterKey = vk_to_crafter_key(wParam); KeyCode code = KeyCodeFromLParam(lParam);
bool isRepeat = (lParam & (1 << 30)) != 0; bool isRepeat = (lParam & (1 << 30)) != 0;
if (isRepeat) { if (isRepeat) {
window->onKeyHold[(uint8_t)crafterKey].Invoke(); window->onRawKeyHold.Invoke(code);
window->onAnyKeyHold.Invoke(crafterKey);
} else { } else {
window->heldkeys[(uint8_t)crafterKey] = true; window->heldKeys.insert(code);
window->onKeyDown[(uint8_t)crafterKey].Invoke(); window->onRawKeyDown.Invoke(code);
window->onAnyKeyDown.Invoke(crafterKey);
} }
break; break;
} }
case WM_KEYUP: case WM_KEYUP:
case WM_SYSKEYUP: { case WM_SYSKEYUP: {
CrafterKeys crafterKey = vk_to_crafter_key(wParam); KeyCode code = KeyCodeFromLParam(lParam);
window->heldkeys[(uint8_t)crafterKey] = false; window->heldKeys.erase(code);
window->onKeyUp[(uint8_t)crafterKey].Invoke(); window->onRawKeyUp.Invoke(code);
window->onAnyKeyUp.Invoke(crafterKey);
break; break;
} }
@ -510,6 +403,93 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
currentMousePos = {0,0}; currentMousePos = {0,0};
} }
void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
// Skip degenerate resizes. Win32 minimised windows give (0, 0); Wayland
// sometimes echoes the current size in a configure.
if (newWidth == 0 || newHeight == 0) return;
if (newWidth == width && newHeight == height) return;
// Win32 fires WM_SIZE synchronously inside CreateWindowEx (before the
// constructor's CreateSwapchain). Defer the first resize to that
// CreateSwapchain call instead of trying to recreate a non-existent
// swapchain.
if (swapChain == VK_NULL_HANDLE) {
width = newWidth;
height = newHeight;
return;
}
width = newWidth;
height = newHeight;
// Caller (configure handler / WM_SIZE) runs between frames, but be
// defensive: ensure no in-flight commands reference the old swapchain.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (wpViewport) {
wp_viewport_set_destination(wpViewport,
static_cast<int>(std::ceil(width / scale)),
static_cast<int>(std::ceil(height / scale)));
}
#endif
RecreateSwapchainAndImages();
onResize.Invoke();
}
void Window::RecreateSwapchainAndImages() {
CreateSwapchain();
// CreateSwapchain leaves new swapchain images in VK_IMAGE_LAYOUT_UNDEFINED.
// Render() barriers from PRESENT_SRC_KHR, so transition them now to
// match. Mirrors the StartInit logic, on a one-shot command buffer.
{
VkCommandBufferAllocateInfo cba{
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
.commandPool = Device::commandPool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
.commandBufferCount = 1,
};
VkCommandBuffer cmd = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cba, &cmd));
VkCommandBufferBeginInfo cbi{
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
};
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &cbi));
VkImageSubresourceRange range{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = VK_REMAINING_MIP_LEVELS,
.baseArrayLayer = 0,
.layerCount = VK_REMAINING_ARRAY_LAYERS,
};
for (std::uint32_t i = 0; i < numFrames; i++) {
image_layout_transition(cmd,
images[i],
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
range);
}
Device::CheckVkResult(vkEndCommandBuffer(cmd));
VkSubmitInfo si{
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd,
};
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &si, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
}
}
void Window::SetTitle(const std::string_view title) { void Window::SetTitle(const std::string_view title) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
xdg_toplevel_set_title(xdgToplevel, title.data()); xdg_toplevel_set_title(xdgToplevel, title.data());
@ -630,6 +610,7 @@ void Window::SetDefaultCursor() {
void Window::StartSync() { void Window::StartSync() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
while (open && wl_display_dispatch(Device::display) != -1) { while (open && wl_display_dispatch(Device::display) != -1) {
Gamepad::Tick();
onBeforeUpdate.Invoke(); onBeforeUpdate.Invoke();
} }
#endif #endif
@ -640,6 +621,7 @@ void Window::StartSync() {
TranslateMessage(&msg); TranslateMessage(&msg);
DispatchMessage(&msg); DispatchMessage(&msg);
} }
Gamepad::Tick();
onBeforeUpdate.Invoke(); onBeforeUpdate.Invoke();
if(updating) { if(updating) {
Update(); Update();
@ -697,8 +679,23 @@ void Window::Update() {
} }
void Window::Render() { void Window::Render() {
// Acquire the next image from the swap chain // Acquire the next image from the swap chain. If the surface has
Device::CheckVkResult(vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, semaphores.presentComplete, (VkFence)nullptr, &currentBuffer)); // changed size out from under us (compositor/Win32 resize delivered
// between Render calls), recreate and retry once.
{
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
semaphores.presentComplete, (VkFence)nullptr, &currentBuffer);
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
RecreateSwapchainAndImages();
onResize.Invoke();
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
semaphores.presentComplete, (VkFence)nullptr, &currentBuffer);
}
if (acquire != VK_SUBOPTIMAL_KHR) {
Device::CheckVkResult(acquire);
}
}
submitInfo.commandBufferCount = 1; submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer]; submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
@ -822,8 +819,13 @@ void Window::Render() {
} }
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo); VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
if(result == VK_SUBOPTIMAL_KHR) { if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
CreateSwapchain(); // Surface size changed mid-present. Drain the queue, rebuild the
// swapchain, and let dependents (descriptors holding old image
// handles) re-bind via onResize before the next frame.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
RecreateSwapchainAndImages();
onResize.Invoke();
} else { } else {
Device::CheckVkResult(result); Device::CheckVkResult(result);
} }
@ -1067,8 +1069,13 @@ void Window::wl_surface_frame_done(void* data, struct wl_callback *cb, uint32_t
} }
void Window::xdg_toplevel_configure(void*, xdg_toplevel*, std::int32_t, std::int32_t, wl_array*){ void Window::xdg_toplevel_configure(void* data, xdg_toplevel*, std::int32_t width, std::int32_t height, wl_array*){
// xdg-shell batches state — width/height are pending until the matching
// xdg_surface.configure arrives. Width/height are in surface-local
// (logical DP) units; (0, 0) means "compositor has no preference".
Window* window = reinterpret_cast<Window*>(data);
window->pendingLogicalWidth = width;
window->pendingLogicalHeight = height;
} }
void Window::xdg_toplevel_handle_close(void* data, xdg_toplevel*) { void Window::xdg_toplevel_handle_close(void* data, xdg_toplevel*) {
@ -1083,9 +1090,20 @@ void Window::xdg_surface_handle_configure(void* data, xdg_surface* xdg_surface,
xdg_surface_ack_configure(xdg_surface, serial); xdg_surface_ack_configure(xdg_surface, serial);
if (window->configured) { if (window->configured) {
// If this isn't the first configure event we've received, we already // Subsequent configure: if the toplevel asked for a new size
// have a buffer attached, so no need to do anything. Commit the // (non-zero, different from current), drive the resize end-to-end.
// surface to apply the configure acknowledgement. // (0, 0) means "compositor has no preference, keep current size".
// The swapchain may not exist yet on the very first frame between
// the constructor's wait loop and CreateSwapchain — the Resize
// guard against equal sizes already covers that path.
if (window->pendingLogicalWidth > 0 && window->pendingLogicalHeight > 0 &&
window->swapChain != VK_NULL_HANDLE) {
std::uint32_t newWidth = static_cast<std::uint32_t>(
std::ceil(window->pendingLogicalWidth * window->scale));
std::uint32_t newHeight = static_cast<std::uint32_t>(
std::ceil(window->pendingLogicalHeight * window->scale));
window->Resize(newWidth, newHeight);
}
wl_surface_commit(window->surface); wl_surface_commit(window->surface);
} }

View file

@ -0,0 +1,42 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
export module Crafter.Graphics:Clipboard;
import std;
// Native system-clipboard writes. No popen, no helper binaries — just
// the platform's own clipboard API. Implementation lives next to the
// other window-backend code (Wayland data_device on Linux, Win32 in
// the Windows build); callers don't pick a backend.
//
// `Get` is intentionally not exposed yet: paste-from-clipboard isn't
// a feature the game's UI wants right now, and the read path needs
// more lifecycle plumbing (mime negotiation, fd reads on the Wayland
// event loop) than the simple write path. Easy to add later.
export namespace Crafter::Clipboard {
// Place `text` on the system clipboard as UTF-8 plain text. Returns
// true if the platform accepted the request — false means the
// backend isn't initialised, no input event has been seen yet
// (Wayland needs a recent serial), or the OS API failed. On
// success the ownership of the clipboard contents is held until
// either another app replaces the selection or the application
// exits; the caller doesn't need to keep `text` alive.
bool SetText(std::string_view text);
}

View file

@ -0,0 +1,133 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
#include "vulkan/vulkan.h"
export module Crafter.Graphics:Decompress;
import :Device;
import std;
export namespace Crafter::Decompress {
// GDeflate 1.0 requires each VkDecompressMemoryRegionEXT to cover one
// 64 KiB tile. CompressedBlob stores one stream per RegionMeta — this
// helper walks the stream's tile-stream header and emits per-tile
// regions ready for vkCmdDecompressMemoryEXT.
//
// streamBytes is the raw stream bytes (CompressedBlob::bytes.subspan(
// r.srcOffset, r.compressedSize)). srcBase is its GPU device address.
// dstBase is the GPU device address of the first decompressed byte.
inline void ExpandStreamToTileRegions(
std::span<const std::byte> streamBytes,
VkDeviceAddress srcBase,
VkDeviceAddress dstBase,
std::vector<VkDecompressMemoryRegionEXT>& out)
{
if (streamBytes.empty()) return;
constexpr std::size_t kTileSize = 64 * 1024;
constexpr std::size_t kHeaderSize = 8;
// TileStream wire layout (see vendored TileStream.h):
// u8 id; u8 magic; u16 numTiles;
// { u2 tileSizeIdx; u18 lastTileSize; u12 reserved; } packed into u32
const std::uint8_t* p = reinterpret_cast<const std::uint8_t*>(streamBytes.data());
std::uint8_t id = p[0];
std::uint8_t magic = p[1];
if (id != (magic ^ 0xff)) {
throw std::runtime_error("GDeflate tile-stream: bad id/magic");
}
std::uint16_t numTiles =
static_cast<std::uint16_t>(p[2])
| static_cast<std::uint16_t>(p[3]) << 8;
std::uint32_t packed =
static_cast<std::uint32_t>(p[4])
| static_cast<std::uint32_t>(p[5]) << 8
| static_cast<std::uint32_t>(p[6]) << 16
| static_cast<std::uint32_t>(p[7]) << 24;
std::uint32_t lastTileSize = (packed >> 2) & 0x3FFFFu;
const std::uint32_t* tileOffsets = reinterpret_cast<const std::uint32_t*>(
streamBytes.data() + kHeaderSize);
const std::size_t dataStart = kHeaderSize + std::size_t(numTiles) * sizeof(std::uint32_t);
out.reserve(out.size() + numTiles);
for (std::uint32_t k = 0; k < numTiles; ++k) {
std::size_t tileOff = (k > 0) ? tileOffsets[k] : 0;
std::size_t compressedSize = (k + 1 < numTiles)
? std::size_t(tileOffsets[k + 1]) - tileOff
: std::size_t(tileOffsets[0]);
std::size_t decompressedSize = (k + 1 < numTiles)
? kTileSize
: (lastTileSize != 0 ? std::size_t(lastTileSize) : kTileSize);
out.push_back(VkDecompressMemoryRegionEXT {
.srcAddress = srcBase + dataStart + tileOff,
.dstAddress = dstBase + std::size_t(k) * kTileSize,
.compressedSize = compressedSize,
.decompressedSize = decompressedSize,
});
}
}
// Records vkCmdDecompressMemoryEXT into `cmd` for the supplied regions
// (caller pre-resolves srcAddress/dstAddress) and follows it with a
// synchronization2 memory barrier so the consumer at (dstStage, dstAccess)
// observes the decompressed bytes.
//
// Method is fixed at GDeflate 1.0 — Crafter.Asset's compressed file format
// emits GDeflate streams. Caller must guarantee Device::memoryDecompressionSupported.
inline void DecompressOnGPU(
VkCommandBuffer cmd,
std::span<const VkDecompressMemoryRegionEXT> regions,
VkPipelineStageFlags2 dstStage,
VkAccessFlags2 dstAccess)
{
if (regions.empty()) return;
VkDecompressMemoryInfoEXT info {
.sType = VK_STRUCTURE_TYPE_DECOMPRESS_MEMORY_INFO_EXT,
.pNext = nullptr,
.decompressionMethod = VK_MEMORY_DECOMPRESSION_METHOD_GDEFLATE_1_0_BIT_EXT,
.regionCount = static_cast<std::uint32_t>(regions.size()),
.pRegions = regions.data(),
};
Device::vkCmdDecompressMemoryEXT(cmd, &info);
VkMemoryBarrier2 barrier {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2,
.pNext = nullptr,
.srcStageMask = VK_PIPELINE_STAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
.srcAccessMask = VK_ACCESS_2_MEMORY_DECOMPRESSION_WRITE_BIT_EXT,
.dstStageMask = dstStage,
.dstAccessMask = dstAccess,
};
VkDependencyInfo dep {
.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
.pNext = nullptr,
.dependencyFlags = 0,
.memoryBarrierCount = 1,
.pMemoryBarriers = &barrier,
.bufferMemoryBarrierCount = 0,
.pBufferMemoryBarriers = nullptr,
.imageMemoryBarrierCount = 0,
.pImageMemoryBarriers = nullptr,
};
vkCmdPipelineBarrier2(cmd, &dep);
}
}

View file

@ -30,7 +30,7 @@ module;
#endif #endif
export module Crafter.Graphics:Device; export module Crafter.Graphics:Device;
import std; import std;
import :Types; // CrafterKeys for keyboard repeat state import :Keys; // KeyCode for keyboard repeat state
export namespace Crafter { export namespace Crafter {
struct Window; struct Window;
@ -43,7 +43,7 @@ export namespace Crafter {
int rate = 25; // chars/sec int rate = 25; // chars/sec
int delay = 500; // ms before first repeat int delay = 500; // ms before first repeat
bool active = false; bool active = false;
CrafterKeys key{}; KeyCode key = 0;
std::string utf8; // UTF-8 to re-emit as onTextInput, if any std::string utf8; // UTF-8 to re-emit as onTextInput, if any
std::chrono::time_point<std::chrono::steady_clock> pressTime; std::chrono::time_point<std::chrono::steady_clock> pressTime;
std::chrono::time_point<std::chrono::steady_clock> lastFireTime; std::chrono::time_point<std::chrono::steady_clock> lastFireTime;
@ -68,6 +68,13 @@ export namespace Crafter {
inline static xkb_state* xkb_state; inline static xkb_state* xkb_state;
inline static std::vector<Window*> windows; inline static std::vector<Window*> windows;
inline static wl_pointer* wlPointer; inline static wl_pointer* wlPointer;
// wl_data_device_manager + wl_data_device drive copy/paste. Bound
// lazily in handle_global; the data device is created once both
// the manager and the seat are present (registry binding order
// isn't guaranteed). nullptr on compositors that don't expose
// the manager — Clipboard::SetText silently no-ops there.
inline static wl_data_device_manager* dataDeviceManager = nullptr;
inline static wl_data_device* dataDevice = nullptr;
static void seat_handle_capabilities(void* data, wl_seat* seat, uint32_t capabilities); static void seat_handle_capabilities(void* data, wl_seat* seat, uint32_t capabilities);
static void xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale); static void xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale);
@ -137,6 +144,12 @@ export namespace Crafter {
inline static PFN_vkGetPhysicalDeviceDescriptorSizeEXT vkGetPhysicalDeviceDescriptorSizeEXT; inline static PFN_vkGetPhysicalDeviceDescriptorSizeEXT vkGetPhysicalDeviceDescriptorSizeEXT;
inline static PFN_vkGetDeviceFaultInfoEXT vkGetDeviceFaultInfoEXT; inline static PFN_vkGetDeviceFaultInfoEXT vkGetDeviceFaultInfoEXT;
// VK_EXT_memory_decompression — opt-in. When the driver advertises it
// and exposes the GDeflate 1.0 method, GPU asset decompression is
// available; otherwise consumers fall back to CPU decode.
inline static bool memoryDecompressionSupported = false;
inline static PFN_vkCmdDecompressMemoryEXT vkCmdDecompressMemoryEXT = nullptr;
inline static VkPhysicalDeviceMemoryProperties memoryProperties; inline static VkPhysicalDeviceMemoryProperties memoryProperties;
inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = { inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = {
@ -146,16 +159,19 @@ export namespace Crafter {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR, .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR,
.pNext = &descriptorHeapProperties .pNext = &descriptorHeapProperties
}; };
inline static VkPhysicalDeviceMemoryDecompressionPropertiesEXT memoryDecompressionProperties = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_PROPERTIES_EXT
};
static void CheckVkResult(VkResult result); static void CheckVkResult(VkResult result);
static std::uint32_t GetMemoryType(std::uint32_t typeBits, VkMemoryPropertyFlags properties); static std::uint32_t GetMemoryType(std::uint32_t typeBits, VkMemoryPropertyFlags properties);
// ─── Wayland key repeat ──────────────────────────────────────── // ─── Wayland key repeat ────────────────────────────────────────
// TickKeyRepeats walks the held-key state and fires onKeyDown / // TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on
// onTextInput accordingly. Called once per frame from // the focused window for whichever key is currently repeating.
// Window::Render. KeyRepeatState lives at namespace scope so its // Called once per frame from Window::Render. KeyRepeatState lives
// member initializers don't trip C++'s "complete-type-needed" // at namespace scope so its member initializers don't trip C++'s
// rule for the inline static below. // "complete-type-needed" rule for the inline static below.
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
inline static KeyRepeatState keyRepeat; inline static KeyRepeatState keyRepeat;
static void TickKeyRepeats(); static void TickKeyRepeats();

View file

@ -0,0 +1,99 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
export module Crafter.Graphics:Gamepad;
import std;
import Crafter.Event;
// Raw gamepad device API. Platform-split implementation:
// - Linux: libudev (enumerate + hot-plug) + libevdev (read events,
// calibrate axes). Open every /dev/input/event* node the kernel
// classifies as a gamepad; ignore the rest.
// - Windows: Windows.Gaming.Input via the C ABI (COBJMACROS pattern,
// same as SDL). Added/Removed events fire on a thread pool and are
// marshaled into a queue drained from Tick() on the main thread.
//
// Button names follow the standardized kernel BTN_SOUTH / BTN_EAST /
// BTN_WEST / BTN_NORTH convention — physical position, not label. An
// Xbox "A" and a PlayStation "Cross" both arrive as `South`.
//
// Stick axes are -1..1 (post-deadzone calibration, raw float). Trigger
// axes are 0..1. The `LeftTrigger` / `RightTrigger` entries in `Button`
// and `Axis` are both populated — pick whichever fits your use case
// (digital threshold vs. analog reading).
export namespace Crafter::Gamepad {
enum class Button : std::uint8_t {
South, East, West, North, // BTN_SOUTH/EAST/WEST/NORTH
Select, Start, Home,
LeftStickClick, RightStickClick,
LeftBumper, RightBumper,
DPadUp, DPadDown, DPadLeft, DPadRight,
LeftTrigger, RightTrigger, // digital threshold; analog also on Axis
Max
};
enum class Axis : std::uint8_t {
LeftStickX, LeftStickY,
RightStickX, RightStickY,
LeftTrigger, RightTrigger,
Max
};
enum class Stick : std::uint8_t { Left, Right };
struct Device {
std::uint32_t id; // stable across the device's lifetime
std::string name;
bool buttons[(std::size_t)Button::Max] = {};
float axes[(std::size_t)Axis::Max] = {}; // sticks: -1..1, triggers: 0..1
Event<Button> onButtonDown;
Event<Button> onButtonUp;
Event<Axis> onAxisChanged;
};
// Process-level state — one input subsystem per app. Indices into
// `connected` are NOT stable across hot-plug; use `Device::id` when
// serializing bindings. The list is unsorted; iterate to find a
// specific id.
inline std::vector<std::unique_ptr<Device>> connected;
inline Event<Device*> onConnected;
inline Event<Device*> onDisconnected;
// Drains pending events from the OS, fires the relevant onButton* /
// onAxisChanged / onConnected / onDisconnected events synchronously,
// and updates the polled `buttons[]` / `axes[]` state on each Device.
// Must be called from the main thread once per frame — Window's
// event loop does this automatically. Safe to call when no gamepads
// are connected (early-out path).
void Tick();
// Optional rumble. `low` drives the heavy/low-frequency motor,
// `high` drives the light/high-frequency motor (Xbox naming). Both
// are clamped to 0..1. `duration` clamps to the backend's max; pass
// 0ms to stop any active rumble. No-op if the device doesn't
// advertise force-feedback.
void Rumble(Device& dev, float low, float high,
std::chrono::milliseconds duration);
// Find a connected device by its stable id. Returns nullptr if the
// device isn't currently connected — typical use after deserializing
// a saved binding.
Device* FindById(std::uint32_t id);
}

View file

@ -23,6 +23,8 @@ module;
export module Crafter.Graphics:ImageVulkan; export module Crafter.Graphics:ImageVulkan;
import std; import std;
import Crafter.Asset;
import :Decompress;
import :VulkanBuffer; import :VulkanBuffer;
export namespace Crafter { export namespace Crafter {
@ -35,6 +37,10 @@ export namespace Crafter {
VkImage image; VkImage image;
VkDeviceMemory imageMemory; VkDeviceMemory imageMemory;
VulkanBuffer<PixelType, true> buffer; VulkanBuffer<PixelType, true> buffer;
// Lives until the compressed Update path's cmd buffer completes.
// Same lifetime contract as Mesh::compressedStaging — caller must
// not destroy / re-Update before the submit fence is signaled.
VulkanBuffer<std::byte, true> compressedStaging;
VkImageView imageView; VkImageView imageView;
VkDescriptorImageInfo descriptor; VkDescriptorImageInfo descriptor;
@ -153,6 +159,114 @@ export namespace Crafter {
} }
} }
// GPU compressed-asset Update: stage compressed bytes, decompress
// into `buffer` via VK_EXT_memory_decompression, then copy buffer→image
// and transition to `layout`. Falls back to CPU decode + the existing
// Update path when Device::memoryDecompressionSupported is false.
// Caller is responsible for the dimensions matching: asset.sizeX/sizeY
// must equal this->width/height (set by Create), and asset.pixelStride
// must equal sizeof(PixelType).
void Update(const CompressedTextureAsset& asset, VkCommandBuffer cmd, VkImageLayout layout) {
if (asset.pixelStride != sizeof(PixelType)) {
throw std::runtime_error("ImageVulkan::Update(compressed): pixel stride mismatch");
}
if (!Device::memoryDecompressionSupported) {
std::span<PixelType> dst{ buffer.value, static_cast<std::size_t>(width) * height };
std::array<std::span<std::byte>, 1> outputs = {
std::as_writable_bytes(dst),
};
Compression::DecompressCPU(asset.blob, outputs);
Update(cmd, layout);
return;
}
// Re-create the staging-into-image buffer with MEMORY_DECOMPRESSION
// permission so the GPU codec can write into it. Keeps it
// HOST_VISIBLE (matches the existing path) — on UMA / ReBAR that's
// a fast path, on older systems the decompress writes traverse
// PCIe but correctness is unchanged.
buffer.Resize(
VK_BUFFER_USAGE_TRANSFER_SRC_BIT
| VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
| VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
static_cast<std::uint32_t>(width) * height);
compressedStaging.Resize(
VK_BUFFER_USAGE_TRANSFER_SRC_BIT
| VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
| VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
static_cast<std::uint32_t>(asset.blob.bytes.size()));
std::memcpy(compressedStaging.value, asset.blob.bytes.data(), asset.blob.bytes.size());
compressedStaging.FlushDevice();
std::vector<VkDecompressMemoryRegionEXT> regions;
for (const Compression::RegionMeta& r : asset.blob.regions) {
if (r.decompressedSize == 0) continue;
std::span<const std::byte> streamBytes(
asset.blob.bytes.data() + r.srcOffset,
static_cast<std::size_t>(r.compressedSize));
Decompress::ExpandStreamToTileRegions(
streamBytes,
compressedStaging.address + r.srcOffset,
buffer.address,
regions);
}
Decompress::DecompressOnGPU(
cmd,
regions,
VK_PIPELINE_STAGE_2_COPY_BIT,
VK_ACCESS_2_TRANSFER_READ_BIT);
// Continue with the existing buffer→image upload + layout transitions.
// We've already inserted the decompress→transfer-read barrier,
// so we skip the FlushDevice host-write barrier the regular Update
// would emit (no host write happened).
TransitionImageLayout(cmd, image, layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, 0, mipLevels);
VkBufferImageCopy region{};
region.bufferOffset = 0;
region.bufferRowLength = 0;
region.bufferImageHeight = 0;
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.mipLevel = 0;
region.imageSubresource.baseArrayLayer = 0;
region.imageSubresource.layerCount = 1;
region.imageOffset = {0, 0, 0};
region.imageExtent = { width, height, 1 };
vkCmdCopyBufferToImage(cmd, buffer.buffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
if (mipLevels > 1) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1);
for (std::uint16_t i = 1; i < mipLevels; ++i) {
std::uint16_t mipWidth = width >> i;
std::uint16_t mipHeight = height >> i;
std::uint16_t previousMipWidth = width >> (i - std::uint16_t(1));
std::uint16_t previousMipHeight = height >> (i - std::uint16_t(1));
VkImageBlit blit = {};
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { (int32_t)previousMipWidth, (int32_t)previousMipHeight, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { (int32_t)mipWidth, (int32_t)mipHeight, 1 };
vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1);
}
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
} else {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
}
}
void Destroy() { void Destroy() {
vkDestroyImageView(Device::device, imageView, nullptr); vkDestroyImageView(Device::device, imageView, nullptr);
vkDestroyImage(Device::device, image, nullptr); vkDestroyImage(Device::device, image, nullptr);

View file

@ -0,0 +1,180 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
export module Crafter.Graphics:Input;
import std;
import Crafter.Math;
import Crafter.Event;
import :Keys;
import :Gamepad;
import :Window;
// Named-action input mapping with rebindable bindings. Game code declares
// actions ("Jump", "Move", "Fire"), attaches binding sources (keys, mouse
// buttons, gamepad sticks, WASD-as-Vector2, etc.), and subscribes to
// `onPerformed` / `onCanceled` / `onValueChanged` events. Bindings can be
// rebound at runtime via `StartRebind` — the next qualifying input
// becomes the new binding.
//
// Polling-based evaluation: `Map::Tick()` (called once per frame from
// game code, AFTER `Gamepad::Tick()`) reads the polled state of the
// attached window + connected gamepads and diffs against the action's
// previous state. No per-device event listeners — works regardless of
// gamepad connect/disconnect timing.
//
// Bindings store raw platform key codes (the same `KeyCode` domain as
// `Window::onRawKeyDown`). Source code uses `Key(CrafterKeys::Space)`
// from :Keys to obtain cross-platform default codes at compile time.
// Binding files serialized with `BindingToString` are platform-specific
// (a Win32 save won't load on Wayland) — document this for callers.
export namespace Crafter::Input {
enum class ActionType : std::uint8_t {
Button, // digital — pressed/released
Axis1D, // -1..1 (or 0..1 for triggers / scroll)
Vector2, // 2D — sticks, WASD, mouse delta
};
// ─── Binding alternatives ───────────────────────────────────────
struct KeyBind { KeyCode code; };
struct MouseButtonBind { std::uint8_t button; }; // 0 = left, 1 = right
struct MouseScrollBind { }; // Axis1D, accumulator drained per tick
struct MouseDeltaBind { float scale = 1.0f; }; // Vector2
struct GamepadButtonBind { std::uint32_t gamepadId; Gamepad::Button button; };
struct GamepadAxisBind { std::uint32_t gamepadId; Gamepad::Axis axis; bool invert = false; };
struct GamepadStickBind { std::uint32_t gamepadId; Gamepad::Stick stick; }; // Vector2
struct WASDBind {
KeyCode up;
KeyCode down;
KeyCode left;
KeyCode right;
};
using Binding = std::variant<
KeyBind, MouseButtonBind, MouseScrollBind, MouseDeltaBind,
GamepadButtonBind, GamepadAxisBind, GamepadStickBind, WASDBind
>;
struct Action {
std::string name;
ActionType type;
std::vector<Binding> bindings;
// Analog deadzone — bindings below this magnitude read as zero.
// Applied per-axis to GamepadAxisBind, radially to
// GamepadStickBind. Doesn't apply to Button (digital).
float deadzone = 0.15f;
// Polled state. Read freely from the listener callback or
// anywhere else; updated by `Map::Tick()`.
bool pressed = false;
float value = 0.0f;
Vector<float, 2> vector2{};
// Events fired during `Map::Tick()` when the polled state crosses
// an edge or changes value.
Event<void> onPerformed; // pressed (Button) / non-zero edge (axis)
Event<void> onCanceled; // released (Button) / back to zero
Event<float> onValueChanged; // Axis1D — value
Event<Vector<float,2>> onVector2Changed; // Vector2 — vector2
};
enum class CaptureMask : std::uint8_t {
Keyboard = 1,
Mouse = 2,
Gamepad = 4,
Any = 0xFF,
};
constexpr CaptureMask operator|(CaptureMask a, CaptureMask b) {
return (CaptureMask)((std::uint8_t)a | (std::uint8_t)b);
}
constexpr bool HasFlag(CaptureMask m, CaptureMask f) {
return ((std::uint8_t)m & (std::uint8_t)f) != 0;
}
struct Map {
std::vector<std::unique_ptr<Action>> actions;
Action& AddAction(std::string name, ActionType type);
Action* Find(std::string_view name);
// Subscribe internally to the window's mouse-scroll event so a
// single scroll between ticks isn't lost. All other state is
// polled from `Window` / `Gamepad`. Detaching releases that one
// listener; the polled-state reads automatically stop.
void Attach(Window& window);
void Detach();
// Apply current input state to all actions. Fire edge / value
// events. Call once per frame AFTER `Gamepad::Tick()`. Safe to
// call when not attached (no-op).
void Tick();
// Capture the next qualifying input as a binding. While capture
// is active, that input does NOT dispatch to actions — it only
// routes to `onCaptured`. Capturing replaces nothing in the
// action's bindings; the callback decides whether to assign,
// append, or discard. Cancel via `StopRebind`.
void StartRebind(Action& action, CaptureMask mask,
std::function<void(Binding)> onCaptured);
void StopRebind();
bool IsRebinding() const;
// ─── Internals (exposed because we're a POD-ish struct) ──────
Window* window = nullptr;
// Mouse scroll is the one event-driven source; everything else
// is polled. The listener feeds into `scrollAccumulator`,
// drained on the next Tick.
std::unique_ptr<EventListener<std::uint32_t>> scrollListener;
std::int32_t scrollAccumulator = 0;
// Mouse delta is computed from window.currentMousePos vs the
// position observed last tick. nullopt before first tick.
std::optional<Vector<float, 2>> lastMousePos;
// Rebind state.
struct RebindState {
Action* action;
CaptureMask mask;
std::function<void(Binding)> onCaptured;
// Snapshot of all input at rebind-start, so we only fire on
// a fresh down-edge (not on a button held since before).
std::unordered_set<KeyCode> keysHeldAtStart;
bool mouseLeftHeldAtStart = false;
bool mouseRightHeldAtStart = false;
std::unordered_map<std::uint32_t, std::array<bool, (std::size_t)Gamepad::Button::Max>> gamepadButtonsAtStart;
};
std::optional<RebindState> rebind;
};
// ─── Serialization ──────────────────────────────────────────────
// Single-binding text round-trip. Caller composes binding files
// however they like (JSON / INI / line-per-binding). Formats:
// "key:<hex>" — KeyBind
// "mb:<0|1>" — MouseButtonBind
// "mscroll" — MouseScrollBind
// "mdelta:<scale>" — MouseDeltaBind
// "gpb:<id>:<button>" — GamepadButtonBind
// "gpa:<id>:<axis>:<inv>" — GamepadAxisBind (inv = 0/1)
// "gps:<id>:<stick>" — GamepadStickBind
// "wasd:<u>:<d>:<l>:<r>" — WASDBind, four hex KeyCodes
// KeyCode hex values are platform-specific; see :Keys.
std::string BindingToString(const Binding&);
std::optional<Binding> BindingFromString(std::string_view);
}

View file

@ -20,6 +20,7 @@ module;
export module Crafter.Graphics:InputField; export module Crafter.Graphics:InputField;
import std; import std;
import :Types; import :Types;
import :Keys;
import :Font; import :Font;
import :UI; import :UI;
import :UIComponents; import :UIComponents;
@ -81,8 +82,12 @@ export namespace Crafter {
void InputField_OnText(InputField&, std::string_view utf8); void InputField_OnText(InputField&, std::string_view utf8);
// Edit-control keys: Backspace, Delete, Left, Right, Home, End. Anything // Edit-control keys: Backspace, Delete, Left, Right, Home, End. Anything
// else is ignored. Safe to feed every key the host receives. // else is ignored. Safe to feed every key the host receives. The KeyCode
void InputField_OnKey(InputField&, CrafterKeys); // is the raw platform key (Win32 PS/2 scancode + extended bit; Wayland
// kernel keycode) — typically the value delivered by
// `Window::onRawKeyDown`. Internal comparisons use `Key(CrafterKeys::X)`
// from :Keys so the code stays cross-platform.
void InputField_OnKey(InputField&, KeyCode);
// Map a click x-coord (in window pixels) to a cursor position. `rect` is // Map a click x-coord (in window pixels) to a cursor position. `rect` is
// the field's current draw rect; `colors.paddingX` is consulted for the // the field's current draw rect; `colors.paddingX` is consulted for the

View file

@ -0,0 +1,331 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include <linux/input-event-codes.h>
#endif
export module Crafter.Graphics:Keys;
import std;
import :Types;
// Compile-time translation from the abstract `CrafterKeys` enum to the raw
// platform key code that the runtime actually stores and compares. The whole
// point: bindings, events, and InputField never carry the abstract enum at
// runtime — they carry the raw integer the platform delivers. `Key(...)` is
// `consteval` so every call from source folds to an immediate operand; user
// code stays cross-platform (`Key(CrafterKeys::Space)`) without any `#ifdef`
// at the call site.
//
// Domain of the returned KeyCode:
// Win32: bits 0..7 = PS/2 set-1 scancode byte. Bit 8 = extended-key flag
// (the 0xE0-prefixed variants — RightCtrl, RightAlt, the keypad's
// Enter and /, the cursor cluster's arrows/Home/End/etc., the
// Windows keys). At runtime, WndProc OR-s in 0x100 when lParam
// bit 24 is set, so a saved binding for "RightCtrl" (0x11D) survives
// round-trip with the actual hardware event.
// Wayland/Linux: kernel input-event-codes (KEY_*) directly. The +8 X11
// offset is stripped at the wl_keyboard.key boundary so the
// runtime values match the table here.
//
// Keys with no clean scancode encoding (Pause, some keys without standard
// physical position) return 0. 0 is documented as "unmapped"; binding to it
// will never match a real keypress.
export namespace Crafter {
using KeyCode = std::uint32_t;
consteval KeyCode Key(CrafterKeys k) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
// PS/2 set-1 scancodes. Extended keys carry 0x100 so the runtime
// path can disambiguate by OR-ing in 0x100 when lParam bit 24 is set.
switch (k) {
// Alphabetic
case CrafterKeys::A: return 0x1E;
case CrafterKeys::B: return 0x30;
case CrafterKeys::C: return 0x2E;
case CrafterKeys::D: return 0x20;
case CrafterKeys::E: return 0x12;
case CrafterKeys::F: return 0x21;
case CrafterKeys::G: return 0x22;
case CrafterKeys::H: return 0x23;
case CrafterKeys::I: return 0x17;
case CrafterKeys::J: return 0x24;
case CrafterKeys::K: return 0x25;
case CrafterKeys::L: return 0x26;
case CrafterKeys::M: return 0x32;
case CrafterKeys::N: return 0x31;
case CrafterKeys::O: return 0x18;
case CrafterKeys::P: return 0x19;
case CrafterKeys::Q: return 0x10;
case CrafterKeys::R: return 0x13;
case CrafterKeys::S: return 0x1F;
case CrafterKeys::T: return 0x14;
case CrafterKeys::U: return 0x16;
case CrafterKeys::V: return 0x2F;
case CrafterKeys::W: return 0x11;
case CrafterKeys::X: return 0x2D;
case CrafterKeys::Y: return 0x15;
case CrafterKeys::Z: return 0x2C;
// Numeric (top row)
case CrafterKeys::_1: return 0x02;
case CrafterKeys::_2: return 0x03;
case CrafterKeys::_3: return 0x04;
case CrafterKeys::_4: return 0x05;
case CrafterKeys::_5: return 0x06;
case CrafterKeys::_6: return 0x07;
case CrafterKeys::_7: return 0x08;
case CrafterKeys::_8: return 0x09;
case CrafterKeys::_9: return 0x0A;
case CrafterKeys::_0: return 0x0B;
// Function keys
case CrafterKeys::F1: return 0x3B;
case CrafterKeys::F2: return 0x3C;
case CrafterKeys::F3: return 0x3D;
case CrafterKeys::F4: return 0x3E;
case CrafterKeys::F5: return 0x3F;
case CrafterKeys::F6: return 0x40;
case CrafterKeys::F7: return 0x41;
case CrafterKeys::F8: return 0x42;
case CrafterKeys::F9: return 0x43;
case CrafterKeys::F10: return 0x44;
case CrafterKeys::F11: return 0x57;
case CrafterKeys::F12: return 0x58;
// Control keys
case CrafterKeys::Escape: return 0x01;
case CrafterKeys::Tab: return 0x0F;
case CrafterKeys::Enter: return 0x1C;
case CrafterKeys::Space: return 0x39;
case CrafterKeys::Backspace: return 0x0E;
case CrafterKeys::Delete: return 0x153; // extended
case CrafterKeys::Insert: return 0x152; // extended
case CrafterKeys::Home: return 0x147; // extended
case CrafterKeys::End: return 0x14F; // extended
case CrafterKeys::PageUp: return 0x149; // extended
case CrafterKeys::PageDown: return 0x151; // extended
case CrafterKeys::CapsLock: return 0x3A;
case CrafterKeys::NumLock: return 0x45;
case CrafterKeys::ScrollLock: return 0x46;
// Modifiers
case CrafterKeys::LeftShift: return 0x2A;
case CrafterKeys::RightShift: return 0x36;
case CrafterKeys::LeftCtrl: return 0x1D;
case CrafterKeys::RightCtrl: return 0x11D; // extended
case CrafterKeys::LeftAlt: return 0x38;
case CrafterKeys::RightAlt: return 0x138; // extended
case CrafterKeys::LeftSuper: return 0x15B; // extended
case CrafterKeys::RightSuper: return 0x15C; // extended
// Arrows (all extended on Win32 — the cursor cluster, not the keypad)
case CrafterKeys::Up: return 0x148;
case CrafterKeys::Down: return 0x150;
case CrafterKeys::Left: return 0x14B;
case CrafterKeys::Right: return 0x14D;
// Keypad
case CrafterKeys::keypad_0: return 0x52;
case CrafterKeys::keypad_1: return 0x4F;
case CrafterKeys::keypad_2: return 0x50;
case CrafterKeys::keypad_3: return 0x51;
case CrafterKeys::keypad_4: return 0x4B;
case CrafterKeys::keypad_5: return 0x4C;
case CrafterKeys::keypad_6: return 0x4D;
case CrafterKeys::keypad_7: return 0x47;
case CrafterKeys::keypad_8: return 0x48;
case CrafterKeys::keypad_9: return 0x49;
case CrafterKeys::keypad_enter: return 0x11C; // extended
case CrafterKeys::keypad_plus: return 0x4E;
case CrafterKeys::keypad_minus: return 0x4A;
case CrafterKeys::keypad_multiply: return 0x37;
case CrafterKeys::keypad_divide: return 0x135; // extended
case CrafterKeys::keypad_decimal: return 0x53;
// Punctuation
case CrafterKeys::grave: return 0x29;
case CrafterKeys::minus: return 0x0C;
case CrafterKeys::equal: return 0x0D;
case CrafterKeys::bracket_left: return 0x1A;
case CrafterKeys::bracket_right: return 0x1B;
case CrafterKeys::backslash: return 0x2B;
case CrafterKeys::semicolon: return 0x27;
case CrafterKeys::quote: return 0x28;
case CrafterKeys::comma: return 0x33;
case CrafterKeys::period: return 0x34;
case CrafterKeys::slash: return 0x35;
case CrafterKeys::print_screen: return 0x137; // extended
case CrafterKeys::pause: return 0; // unmapped — multi-byte sequence
case CrafterKeys::menu: return 0x15D; // extended (App key)
// Multimedia / browser / launch keys (all extended on Win32)
case CrafterKeys::volume_up: return 0x130;
case CrafterKeys::volume_down: return 0x12E;
case CrafterKeys::volume_mute: return 0x120;
case CrafterKeys::media_play: return 0x122;
case CrafterKeys::media_stop: return 0x124;
case CrafterKeys::media_prev: return 0x110;
case CrafterKeys::media_next: return 0x119;
case CrafterKeys::browser_back: return 0x16A;
case CrafterKeys::browser_forward: return 0x169;
case CrafterKeys::browser_refresh: return 0x167;
case CrafterKeys::browser_stop: return 0x168;
case CrafterKeys::browser_search: return 0x165;
case CrafterKeys::browser_home: return 0x132;
case CrafterKeys::launch_mail: return 0x16C;
case CrafterKeys::launch_calculator: return 0x121;
case CrafterKeys::launch_media_player: return 0x16D;
case CrafterKeys::CrafterKeysMax: return 0;
}
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
// Linux kernel input-event-codes. The Wayland keyboard handler strips
// the +8 X11 offset before delivery so these match wl_keyboard.key
// values directly.
switch (k) {
// Alphabetic
case CrafterKeys::A: return KEY_A;
case CrafterKeys::B: return KEY_B;
case CrafterKeys::C: return KEY_C;
case CrafterKeys::D: return KEY_D;
case CrafterKeys::E: return KEY_E;
case CrafterKeys::F: return KEY_F;
case CrafterKeys::G: return KEY_G;
case CrafterKeys::H: return KEY_H;
case CrafterKeys::I: return KEY_I;
case CrafterKeys::J: return KEY_J;
case CrafterKeys::K: return KEY_K;
case CrafterKeys::L: return KEY_L;
case CrafterKeys::M: return KEY_M;
case CrafterKeys::N: return KEY_N;
case CrafterKeys::O: return KEY_O;
case CrafterKeys::P: return KEY_P;
case CrafterKeys::Q: return KEY_Q;
case CrafterKeys::R: return KEY_R;
case CrafterKeys::S: return KEY_S;
case CrafterKeys::T: return KEY_T;
case CrafterKeys::U: return KEY_U;
case CrafterKeys::V: return KEY_V;
case CrafterKeys::W: return KEY_W;
case CrafterKeys::X: return KEY_X;
case CrafterKeys::Y: return KEY_Y;
case CrafterKeys::Z: return KEY_Z;
// Numeric
case CrafterKeys::_0: return KEY_0;
case CrafterKeys::_1: return KEY_1;
case CrafterKeys::_2: return KEY_2;
case CrafterKeys::_3: return KEY_3;
case CrafterKeys::_4: return KEY_4;
case CrafterKeys::_5: return KEY_5;
case CrafterKeys::_6: return KEY_6;
case CrafterKeys::_7: return KEY_7;
case CrafterKeys::_8: return KEY_8;
case CrafterKeys::_9: return KEY_9;
// Function keys
case CrafterKeys::F1: return KEY_F1;
case CrafterKeys::F2: return KEY_F2;
case CrafterKeys::F3: return KEY_F3;
case CrafterKeys::F4: return KEY_F4;
case CrafterKeys::F5: return KEY_F5;
case CrafterKeys::F6: return KEY_F6;
case CrafterKeys::F7: return KEY_F7;
case CrafterKeys::F8: return KEY_F8;
case CrafterKeys::F9: return KEY_F9;
case CrafterKeys::F10: return KEY_F10;
case CrafterKeys::F11: return KEY_F11;
case CrafterKeys::F12: return KEY_F12;
// Control keys
case CrafterKeys::Escape: return KEY_ESC;
case CrafterKeys::Tab: return KEY_TAB;
case CrafterKeys::Enter: return KEY_ENTER;
case CrafterKeys::Space: return KEY_SPACE;
case CrafterKeys::Backspace: return KEY_BACKSPACE;
case CrafterKeys::Delete: return KEY_DELETE;
case CrafterKeys::Insert: return KEY_INSERT;
case CrafterKeys::Home: return KEY_HOME;
case CrafterKeys::End: return KEY_END;
case CrafterKeys::PageUp: return KEY_PAGEUP;
case CrafterKeys::PageDown: return KEY_PAGEDOWN;
case CrafterKeys::CapsLock: return KEY_CAPSLOCK;
case CrafterKeys::NumLock: return KEY_NUMLOCK;
case CrafterKeys::ScrollLock: return KEY_SCROLLLOCK;
// Modifiers
case CrafterKeys::LeftShift: return KEY_LEFTSHIFT;
case CrafterKeys::RightShift: return KEY_RIGHTSHIFT;
case CrafterKeys::LeftCtrl: return KEY_LEFTCTRL;
case CrafterKeys::RightCtrl: return KEY_RIGHTCTRL;
case CrafterKeys::LeftAlt: return KEY_LEFTALT;
case CrafterKeys::RightAlt: return KEY_RIGHTALT;
case CrafterKeys::LeftSuper: return KEY_LEFTMETA;
case CrafterKeys::RightSuper: return KEY_RIGHTMETA;
// Arrows
case CrafterKeys::Up: return KEY_UP;
case CrafterKeys::Down: return KEY_DOWN;
case CrafterKeys::Left: return KEY_LEFT;
case CrafterKeys::Right: return KEY_RIGHT;
// Keypad
case CrafterKeys::keypad_0: return KEY_KP0;
case CrafterKeys::keypad_1: return KEY_KP1;
case CrafterKeys::keypad_2: return KEY_KP2;
case CrafterKeys::keypad_3: return KEY_KP3;
case CrafterKeys::keypad_4: return KEY_KP4;
case CrafterKeys::keypad_5: return KEY_KP5;
case CrafterKeys::keypad_6: return KEY_KP6;
case CrafterKeys::keypad_7: return KEY_KP7;
case CrafterKeys::keypad_8: return KEY_KP8;
case CrafterKeys::keypad_9: return KEY_KP9;
case CrafterKeys::keypad_enter: return KEY_KPENTER;
case CrafterKeys::keypad_plus: return KEY_KPPLUS;
case CrafterKeys::keypad_minus: return KEY_KPMINUS;
case CrafterKeys::keypad_multiply: return KEY_KPASTERISK;
case CrafterKeys::keypad_divide: return KEY_KPSLASH;
case CrafterKeys::keypad_decimal: return KEY_KPDOT;
// Punctuation
case CrafterKeys::grave: return KEY_GRAVE;
case CrafterKeys::minus: return KEY_MINUS;
case CrafterKeys::equal: return KEY_EQUAL;
case CrafterKeys::bracket_left: return KEY_LEFTBRACE;
case CrafterKeys::bracket_right: return KEY_RIGHTBRACE;
case CrafterKeys::backslash: return KEY_BACKSLASH;
case CrafterKeys::semicolon: return KEY_SEMICOLON;
case CrafterKeys::quote: return KEY_APOSTROPHE;
case CrafterKeys::comma: return KEY_COMMA;
case CrafterKeys::period: return KEY_DOT;
case CrafterKeys::slash: return KEY_SLASH;
case CrafterKeys::print_screen: return KEY_SYSRQ;
case CrafterKeys::pause: return KEY_PAUSE;
case CrafterKeys::menu: return KEY_COMPOSE;
// Multimedia / browser / launch keys
case CrafterKeys::volume_up: return KEY_VOLUMEUP;
case CrafterKeys::volume_down: return KEY_VOLUMEDOWN;
case CrafterKeys::volume_mute: return KEY_MUTE;
case CrafterKeys::media_play: return KEY_PLAYPAUSE;
case CrafterKeys::media_stop: return KEY_STOPCD;
case CrafterKeys::media_prev: return KEY_PREVIOUSSONG;
case CrafterKeys::media_next: return KEY_NEXTSONG;
case CrafterKeys::browser_back: return KEY_BACK;
case CrafterKeys::browser_forward: return KEY_FORWARD;
case CrafterKeys::browser_refresh: return KEY_REFRESH;
case CrafterKeys::browser_stop: return KEY_STOP;
case CrafterKeys::browser_search: return KEY_SEARCH;
case CrafterKeys::browser_home: return KEY_HOMEPAGE;
case CrafterKeys::launch_mail: return KEY_MAIL;
case CrafterKeys::launch_calculator: return KEY_CALC;
case CrafterKeys::launch_media_player: return KEY_MEDIA;
case CrafterKeys::CrafterKeysMax: return 0;
}
#endif
return 0;
}
}

View file

@ -24,6 +24,7 @@ module;
export module Crafter.Graphics:Mesh; export module Crafter.Graphics:Mesh;
import std; import std;
import Crafter.Math; import Crafter.Math;
import Crafter.Asset;
import :VulkanBuffer; import :VulkanBuffer;
export namespace Crafter { export namespace Crafter {
@ -33,11 +34,25 @@ export namespace Crafter {
VulkanBuffer<char, false> blasBuffer; VulkanBuffer<char, false> blasBuffer;
VulkanBuffer<Vector<float, 3, 3>, true> vertexBuffer; VulkanBuffer<Vector<float, 3, 3>, true> vertexBuffer;
VulkanBuffer<std::uint32_t, true> indexBuffer; VulkanBuffer<std::uint32_t, true> indexBuffer;
// Lives until the cmd buffer issued by the compressed Build path
// completes execution. Kept as a member so the recorded
// vkCmdDecompressMemoryEXT references valid memory until the queue
// submit signals — caller must not re-Build or destroy the Mesh
// before that submit's fence is signaled (same contract as the
// existing uncompressed path).
VulkanBuffer<std::byte, true> compressedStaging;
VkAccelerationStructureGeometryTrianglesDataKHR blasData; VkAccelerationStructureGeometryTrianglesDataKHR blasData;
VkAccelerationStructureGeometryKHR blas; VkAccelerationStructureGeometryKHR blas;
VkAccelerationStructureKHR accelerationStructure; VkAccelerationStructureKHR accelerationStructure;
VkDeviceAddress blasAddr; VkDeviceAddress blasAddr;
bool opaque; bool opaque;
void Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd); void Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd);
// GPU path: decompresses vertex (region 0) and index (region 1) streams
// from asset.blob into vertexBuffer / indexBuffer using
// VK_EXT_memory_decompression. Falls back to CPU decode + the
// uncompressed Build if Device::memoryDecompressionSupported is false.
// Region 2 (data) is not consumed here — the caller decompresses it
// into their own buffer if needed (or uses Compression::DecompressCPU).
void Build(const ::Crafter::CompressedMeshAsset& asset, VkCommandBuffer cmd);
}; };
} }

View file

@ -274,6 +274,12 @@ export namespace Crafter {
bool firstDispatchThisFrame_ = true; bool firstDispatchThisFrame_ = true;
// Subscription to window.onResize. Each resize destroys the old
// swapchain images, so the per-frame heap entries we wrote at
// outImageSlot_ now reference dangling VkImage handles. The
// listener re-writes them and flushes the descriptor heaps.
Crafter::EventListener<void> resizeSub_;
void WriteSwapchainDescriptors(); void WriteSwapchainDescriptors();
void WriteFontAtlasDescriptor(); void WriteFontAtlasDescriptor();

View file

@ -52,12 +52,20 @@ namespace Crafter {
class VulkanBuffer : public VulkanBufferBase, public VulkanBufferMappedConditional<T, Mapped> { class VulkanBuffer : public VulkanBufferBase, public VulkanBufferMappedConditional<T, Mapped> {
public: public:
VulkanBuffer() = default; VulkanBuffer() = default;
void Create(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count) { void Create(VkBufferUsageFlags2 usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count) {
size = count * sizeof(T); size = count * sizeof(T);
// Carry usage in the maintenance5 flags2 chain so 64-bit bits
// (e.g. VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, bit 35)
// are not truncated.
VkBufferUsageFlags2CreateInfo usageFlags2 {
.sType = VK_STRUCTURE_TYPE_BUFFER_USAGE_FLAGS_2_CREATE_INFO,
.usage = usageFlags,
};
VkBufferCreateInfo bufferCreateInfo {}; VkBufferCreateInfo bufferCreateInfo {};
bufferCreateInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; bufferCreateInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferCreateInfo.usage = usageFlags; bufferCreateInfo.pNext = &usageFlags2;
bufferCreateInfo.usage = 0;
bufferCreateInfo.size = sizeof(T)*count; bufferCreateInfo.size = sizeof(T)*count;
Device::CheckVkResult(vkCreateBuffer(Device::device, &bufferCreateInfo, nullptr, &buffer)); Device::CheckVkResult(vkCreateBuffer(Device::device, &bufferCreateInfo, nullptr, &buffer));
@ -98,7 +106,7 @@ namespace Crafter {
buffer = VK_NULL_HANDLE; buffer = VK_NULL_HANDLE;
} }
void Resize(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count) { void Resize(VkBufferUsageFlags2 usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count) {
if(buffer != VK_NULL_HANDLE) { if(buffer != VK_NULL_HANDLE) {
Clear(); Clear();
} }

View file

@ -46,6 +46,7 @@ module;
export module Crafter.Graphics:Window; export module Crafter.Graphics:Window;
import std; import std;
import :Types; import :Types;
import :Keys;
import Crafter.Event; import Crafter.Event;
export namespace Crafter { export namespace Crafter {
@ -66,15 +67,21 @@ export namespace Crafter {
Event<void> onClose; Event<void> onClose;
Event<void> onBeforeUpdate; Event<void> onBeforeUpdate;
Event<FrameTime> onUpdate; Event<FrameTime> onUpdate;
// Fires when the swapchain has been recreated for a new size.
// width/height already reflect the new size. Passes that hold
// descriptors referring to window.imageViews[] must re-write them
// here (the old VkImage handles have been destroyed).
Event<void> onResize;
bool open = true; bool open = true;
bool updating = false; bool updating = false;
bool heldkeys[static_cast<std::uint32_t>(CrafterKeys::CrafterKeysMax)] = {}; // Currently-pressed raw key codes. The runtime stores raw platform
Event<void> onKeyDown[static_cast<std::uint32_t>(CrafterKeys::CrafterKeysMax)]; // codes only (Win32 PS/2 scancode + extended bit; Wayland kernel
Event<void> onKeyHold[static_cast<std::uint32_t>(CrafterKeys::CrafterKeysMax)]; // keycode). Cross-platform default bindings use `Key(CrafterKeys::X)`
Event<void> onKeyUp[static_cast<std::uint32_t>(CrafterKeys::CrafterKeysMax)]; // from :Keys to obtain the right code at compile time.
Event<CrafterKeys> onAnyKeyDown; std::unordered_set<KeyCode> heldKeys;
Event<CrafterKeys> onAnyKeyHold; Event<KeyCode> onRawKeyDown;
Event<CrafterKeys> onAnyKeyUp; Event<KeyCode> onRawKeyHold;
Event<KeyCode> onRawKeyUp;
Event<const std::string_view> onTextInput; Event<const std::string_view> onTextInput;
Event<void> onMouseRightClick; Event<void> onMouseRightClick;
Event<void> onMouseLeftClick; Event<void> onMouseLeftClick;
@ -141,6 +148,11 @@ export namespace Crafter {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
float scale = 1.0f; float scale = 1.0f;
bool configured = false; bool configured = false;
// Pending size from the most recent xdg_toplevel.configure, in
// surface-local (logical DP) units. Applied on xdg_surface.configure
// via Resize(); 0 means "compositor has no preference, keep current".
std::int32_t pendingLogicalWidth = 0;
std::int32_t pendingLogicalHeight = 0;
xdg_toplevel* xdgToplevel = nullptr; xdg_toplevel* xdgToplevel = nullptr;
wp_viewport* wpViewport = nullptr; wp_viewport* wpViewport = nullptr;
wl_surface* surface = nullptr; wl_surface* surface = nullptr;
@ -186,6 +198,12 @@ export namespace Crafter {
VkCommandBuffer GetCmd(); VkCommandBuffer GetCmd();
void EndCmd(VkCommandBuffer cmd); void EndCmd(VkCommandBuffer cmd);
void CreateSwapchain(); void CreateSwapchain();
// Tear-and-rebuild helper used by Resize() and the OUT_OF_DATE
// recovery in Render(). Calls CreateSwapchain() and re-issues the
// initial PRESENT_SRC_KHR layout transition for the new images so
// Render()'s barriers (which assume oldLayout = PRESENT_SRC_KHR)
// stay valid. Does NOT fire onResize — callers do that.
void RecreateSwapchainAndImages();
// Save the current swapchain image (state after Render() returns) to // Save the current swapchain image (state after Render() returns) to
// a PNG file. Allocates a one-shot staging buffer + command buffer, // a PNG file. Allocates a one-shot staging buffer + command buffer,

View file

@ -20,8 +20,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
export module Crafter.Graphics; export module Crafter.Graphics;
export import :Window;
export import :Types; export import :Types;
export import :Keys;
export import :Gamepad;
export import :Window;
export import :Input;
export import :Device; export import :Device;
export import :Font; export import :Font;
export import :Animation; export import :Animation;
@ -42,4 +45,6 @@ export import :FontAtlas;
export import :ComputeShader; export import :ComputeShader;
export import :UI; export import :UI;
export import :UIComponents; export import :UIComponents;
export import :InputField; export import :InputField;
export import :Clipboard;
export import :Decompress;

View file

@ -5,18 +5,35 @@ using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::vector<std::string> depArgs(args.begin(), args.end()); std::vector<std::string> depArgs(args.begin(), args.end());
Configuration* event = GitProject({
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Event.git" }, // --local resolves Crafter.* deps from sibling working trees instead of
.args = depArgs, // fetching them from forgejo. Use during cross-repo development so edits
}); // in ../Crafter.Asset are picked up without commit-and-pull. Add to
Configuration* math = GitProject({ // depArgs too so transitive deps inherit the same mode.
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, bool useLocal = false;
.args = depArgs, for (std::string_view a : args) {
}); if (a == "--local") { useLocal = true; break; }
Configuration* asset = GitProject({ }
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" }, if (useLocal && std::find(depArgs.begin(), depArgs.end(), std::string("--local")) == depArgs.end()) {
.args = depArgs, depArgs.push_back("--local");
}); }
auto resolveDep = [&](std::string_view name, std::string_view gitUrl) -> Configuration* {
if (useLocal) {
return LocalProject({
.projectFile = fs::path("../") / name / "project.cpp",
.args = depArgs,
});
}
return GitProject({
.source = { .url = std::string(gitUrl) },
.args = depArgs,
});
};
Configuration* event = resolveDep("Crafter.Event", "https://forgejo.catcrafts.net/Catcrafts/Crafter.Event.git");
Configuration* math = resolveDep("Crafter.Math", "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git");
Configuration* asset = resolveDep("Crafter.Asset", "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git");
Configuration cfg; Configuration cfg;
cfg.path = "./"; cfg.path = "./";
@ -37,10 +54,20 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.linkFlags.push_back("-lkernel32"); cfg.linkFlags.push_back("-lkernel32");
cfg.linkFlags.push_back("-luser32"); cfg.linkFlags.push_back("-luser32");
cfg.linkFlags.push_back("-lgdi32"); cfg.linkFlags.push_back("-lgdi32");
// Windows.Gaming.Input (WGI) needs the WinRT activation runtime
// and combase for HSTRING / RoGetActivationFactory.
cfg.linkFlags.push_back("-lruntimeobject");
cfg.linkFlags.push_back("-lcombase");
} else { } else {
cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_WAYLAND", ""}); cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_WAYLAND", ""});
cfg.linkFlags.push_back("-lwayland-client"); cfg.linkFlags.push_back("-lwayland-client");
cfg.linkFlags.push_back("-lxkbcommon"); cfg.linkFlags.push_back("-lxkbcommon");
// Gamepad: libudev for hot-plug + device enumeration; libevdev
// for event parsing + axis calibration. libevdev ships its headers
// under a versioned dir (libevdev-1.0/) so the -I is mandatory.
cfg.linkFlags.push_back("-ludev");
cfg.linkFlags.push_back("-levdev");
cfg.compileFlags.push_back("-I/usr/include/libevdev-1.0");
cfg.cFiles.push_back("lib/xdg-shell-protocol"); cfg.cFiles.push_back("lib/xdg-shell-protocol");
cfg.cFiles.push_back("lib/wayland-xdg-decoration-unstable-v1-client-protocol"); cfg.cFiles.push_back("lib/wayland-xdg-decoration-unstable-v1-client-protocol");
cfg.cFiles.push_back("lib/fractional-scale-v1"); cfg.cFiles.push_back("lib/fractional-scale-v1");
@ -63,17 +90,22 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
if (opts.Has("--timing")) cfg.defines.push_back({"CRAFTER_TIMING", ""}); if (opts.Has("--timing")) cfg.defines.push_back({"CRAFTER_TIMING", ""});
std::array<fs::path, 24> ifaces = { std::array<fs::path, 29> ifaces = {
"interfaces/Crafter.Graphics", "interfaces/Crafter.Graphics",
"interfaces/Crafter.Graphics-Animation", "interfaces/Crafter.Graphics-Animation",
"interfaces/Crafter.Graphics-Clipboard",
"interfaces/Crafter.Graphics-ComputeShader", "interfaces/Crafter.Graphics-ComputeShader",
"interfaces/Crafter.Graphics-Decompress",
"interfaces/Crafter.Graphics-DescriptorHeapVulkan", "interfaces/Crafter.Graphics-DescriptorHeapVulkan",
"interfaces/Crafter.Graphics-Device", "interfaces/Crafter.Graphics-Device",
"interfaces/Crafter.Graphics-Font", "interfaces/Crafter.Graphics-Font",
"interfaces/Crafter.Graphics-FontAtlas", "interfaces/Crafter.Graphics-FontAtlas",
"interfaces/Crafter.Graphics-ForwardDeclarations", "interfaces/Crafter.Graphics-ForwardDeclarations",
"interfaces/Crafter.Graphics-Gamepad",
"interfaces/Crafter.Graphics-ImageVulkan", "interfaces/Crafter.Graphics-ImageVulkan",
"interfaces/Crafter.Graphics-Input",
"interfaces/Crafter.Graphics-InputField", "interfaces/Crafter.Graphics-InputField",
"interfaces/Crafter.Graphics-Keys",
"interfaces/Crafter.Graphics-Mesh", "interfaces/Crafter.Graphics-Mesh",
"interfaces/Crafter.Graphics-PipelineRTVulkan", "interfaces/Crafter.Graphics-PipelineRTVulkan",
"interfaces/Crafter.Graphics-RenderingElement3D", "interfaces/Crafter.Graphics-RenderingElement3D",
@ -89,11 +121,14 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Graphics-VulkanTransition", "interfaces/Crafter.Graphics-VulkanTransition",
"interfaces/Crafter.Graphics-Window", "interfaces/Crafter.Graphics-Window",
}; };
std::array<fs::path, 10> impls = { std::array<fs::path, 13> impls = {
"implementations/Crafter.Graphics-Clipboard",
"implementations/Crafter.Graphics-ComputeShader", "implementations/Crafter.Graphics-ComputeShader",
"implementations/Crafter.Graphics-Device", "implementations/Crafter.Graphics-Device",
"implementations/Crafter.Graphics-Font", "implementations/Crafter.Graphics-Font",
"implementations/Crafter.Graphics-FontAtlas", "implementations/Crafter.Graphics-FontAtlas",
"implementations/Crafter.Graphics-Gamepad",
"implementations/Crafter.Graphics-Input",
"implementations/Crafter.Graphics-InputField", "implementations/Crafter.Graphics-InputField",
"implementations/Crafter.Graphics-Mesh", "implementations/Crafter.Graphics-Mesh",
"implementations/Crafter.Graphics-RenderingElement3D", "implementations/Crafter.Graphics-RenderingElement3D",